Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,12 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## 0.14.0 — 2026-07-22

### Added

- **Tool results now reach consumers.** The streaming `chat()` generator now emits a `tool_result` chunk for the current Gateway's tool-result event shape (`stream:"tool", phase:"result"`, with the tool in `name` and the payload in `result`). Previously the tool handler only recognized the older `phase:"end"`/`output` shape — which the current Gateway does not send — so `tool_result` chunks were never produced and all tool output was invisible to consumers. Both shapes are now supported, and the tool name is read from `name` (falling back to the legacy `tool` field). The chunk text is the tool's actual text output (the `content[].text` items of `toTranscriptToolResult` joined with newlines), preserving literal quotes so structured markers a plugin writes into its output survive to the consumer. Exposes a `toolResultText(payload)` helper for the same normalization. (heypinchy/pinchy#703)

## 0.13.1 — 2026-07-02

### Fixed
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "openclaw-node",
"version": "0.13.1",
"version": "0.14.0",
"description": "Node.js client for the OpenClaw Gateway WebSocket protocol",
"main": "dist/index.js",
"module": "dist/index.mjs",
Expand Down
65 changes: 65 additions & 0 deletions src/__tests__/chat.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,71 @@ describe("Chat streaming", () => {
expect(chunks).toEqual(["Hello", " world", "!"]);
});

it("emits a tool_result chunk from a gateway phase:'result' tool event", async () => {
// The Gateway emits tool results as `stream:"tool", phase:"result"` with a
// `name` field and a `result` object (`toTranscriptToolResult`, whose
// `content[].text` carries the tool's text output). Older releases used
// `phase:"end"`/`output`; we must handle BOTH so a consumer (e.g. Pinchy's
// agent→user file delivery) actually sees tool output — otherwise the tool
// result never reaches the web layer at all.
const ws = getMockWs();
const sentBefore = ws.sent.length;

const chunks: ChatChunk[] = [];
const gen = client.chat("download the invoice");
const consumePromise = (async () => {
for await (const chunk of gen) chunks.push(chunk);
})();

await new Promise((r) => setTimeout(r, 0));
const sentMsg = JSON.parse(ws.sent[sentBefore]);
const requestId = sentMsg.id;

ws.simulateMessage({
type: "res",
id: requestId,
ok: true,
payload: { runId: requestId, status: "accepted" },
});

ws.simulateMessage({
type: "event",
event: "agent",
payload: {
runId: requestId,
stream: "tool",
data: {
phase: "result",
name: "email_get_attachment",
result: {
content: [
{
type: "text",
text: 'saved <pinchy:file name="invoice.pdf" mime="application/pdf" zone="uploads" />',
},
],
},
},
},
});

ws.simulateMessage({
type: "res",
id: requestId,
ok: true,
payload: { runId: requestId, status: "ok", result: { payloads: [] } },
});

await consumePromise;

const toolResult = chunks.find((c) => c.type === "tool_result");
expect(toolResult).toBeDefined();
// Carries the tool name and the full serialized result, so a marker in the
// tool's text output survives to the consumer.
expect(toolResult!.text).toContain("email_get_attachment");
expect(toolResult!.text).toContain('<pinchy:file name="invoice.pdf"');
});

it("does not terminate on accepted response, only on ok response", async () => {
const ws = getMockWs();
const sentBefore = ws.sent.length;
Expand Down
42 changes: 42 additions & 0 deletions src/__tests__/tool-result-text.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { describe, it, expect } from "vitest";
import { toolResultText } from "../client";

describe("toolResultText", () => {
it("returns a string payload as-is", () => {
expect(toolResultText("plain output")).toBe("plain output");
});

it("joins the text items of a toTranscriptToolResult content array (literal quotes preserved)", () => {
const payload = {
content: [
{
type: "text",
text: 'saved <pinchy:file name="x.pdf" mime="application/pdf" zone="uploads" />',
},
],
};
expect(toolResultText(payload)).toBe(
'saved <pinchy:file name="x.pdf" mime="application/pdf" zone="uploads" />',
);
});

it("joins multiple text items with newlines and ignores non-text items", () => {
const payload = {
content: [
{ type: "text", text: "line 1" },
{ type: "image", url: "http://example/img.png" },
{ type: "text", text: "line 2" },
],
};
expect(toolResultText(payload)).toBe("line 1\nline 2");
});

it("falls back to JSON for an object without a usable content array", () => {
expect(toolResultText({ foo: "bar" })).toBe('{"foo":"bar"}');
});

it("stringifies null/undefined to an empty JSON string", () => {
expect(toolResultText(undefined)).toBe('""');
expect(toolResultText(null)).toBe('""');
});
});
46 changes: 42 additions & 4 deletions src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,36 @@ export type ChatChunk =
runId: string;
};

/**
* Serialize a tool-result payload into the text carried by a `tool_result`
* chunk. The Gateway's `phase:"result"` payload is a `toTranscriptToolResult`
* object whose `content[]` holds the tool's output items; we join the text
* items so the consumer sees the tool's ACTUAL text output (with literal
* quotes), not a JSON-escaped envelope. Falls back to the raw string, or a
* JSON dump for shapes we don't recognize.
*/
export function toolResultText(payload: unknown): string {
if (typeof payload === "string") return payload;
if (
payload &&
typeof payload === "object" &&
Array.isArray((payload as { content?: unknown }).content)
) {
const items = (payload as { content: unknown[] }).content;
const texts = items
.filter(
(c): c is { type: "text"; text: string } =>
!!c &&
typeof c === "object" &&
(c as { type?: unknown }).type === "text" &&
typeof (c as { text?: unknown }).text === "string",
)
.map((c) => c.text);
if (texts.length > 0) return texts.join("\n");
}
return JSON.stringify(payload ?? "");
}

const PROTOCOL_VERSION = 4;
const DEFAULT_SCOPES = ["operator.read", "operator.write"];

Expand Down Expand Up @@ -515,14 +545,22 @@ export class OpenClawClient extends EventEmitter {
}
} else if (stream === "tool") {
const phase = data?.phase as string | undefined;
const toolName = (data?.tool as string) ?? "unknown";
// The Gateway names the tool in `name`; older releases used `tool`.
const toolName = (data?.tool as string) ?? (data?.name as string) ?? "unknown";

if (phase === "start") {
chunks.push({ type: "tool_use", text: toolName, runId });
resolveChunk?.();
} else if (phase === "end") {
const output = data?.output;
const outputText = typeof output === "string" ? output : JSON.stringify(output ?? "");
} else if (phase === "end" || phase === "result") {
// A tool result. The current Gateway emits `phase:"result"` with a
// `result` object (`toTranscriptToolResult`, whose `content[].text`
// holds the tool's text output); older releases emitted
// `phase:"end"` with `output`. Support BOTH — without the `result`
// case the tool result never reaches consumers at all (the
// `end`/`output` shape is not what the current Gateway sends), so
// e.g. a marker a plugin writes into its output would be lost.
const payload = phase === "result" ? data?.result : data?.output;
const outputText = toolResultText(payload);
chunks.push({ type: "tool_result", text: `${toolName}: ${outputText}`, runId });
// A completed tool call ends the current assistant turn. Only
// emit the turn-boundary marker if we've actually seen
Expand Down