From e8806bd787981f8a84b4074b565e635add0fc33c Mon Sep 17 00:00:00 2001 From: Clemens Helm Date: Wed, 22 Jul 2026 08:17:52 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20forward=20tool=20results=20to=20consume?= =?UTF-8?q?rs=20(phase:"result")=20=E2=80=94=20v0.14.0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The streaming chat() generator only recognized the older tool-result event shape (stream:"tool", phase:"end", data.output), which the current OpenClaw Gateway does not send — it emits phase:"result" with the tool in `name` and the payload in `result` (a toTranscriptToolResult object). The result: no tool_result chunk was ever produced, and all tool output was invisible to consumers. Handle both shapes, read the tool name from `name` (fallback `tool`), and set the chunk text to the tool's actual text output (content[].text joined) rather than a JSON-escaped envelope — so a structured marker a plugin writes into its output survives with literal quotes. Adds an exported toolResultText() helper. Unblocks Pinchy's agent→user file delivery (heypinchy/pinchy#703), which needs to observe a delivery marker in a plugin's tool output. --- CHANGELOG.md | 6 +++ package.json | 2 +- src/__tests__/chat.test.ts | 65 ++++++++++++++++++++++++++ src/__tests__/tool-result-text.test.ts | 42 +++++++++++++++++ src/client.ts | 46 ++++++++++++++++-- 5 files changed, 156 insertions(+), 5 deletions(-) create mode 100644 src/__tests__/tool-result-text.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index d696c45..4bf65cd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/package.json b/package.json index ba5af37..f4d3584 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/__tests__/chat.test.ts b/src/__tests__/chat.test.ts index 71f952c..418b750 100644 --- a/src/__tests__/chat.test.ts +++ b/src/__tests__/chat.test.ts @@ -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 ', + }, + ], + }, + }, + }, + }); + + 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(' { const ws = getMockWs(); const sentBefore = ws.sent.length; diff --git a/src/__tests__/tool-result-text.test.ts b/src/__tests__/tool-result-text.test.ts new file mode 100644 index 0000000..620e7ed --- /dev/null +++ b/src/__tests__/tool-result-text.test.ts @@ -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 ', + }, + ], + }; + expect(toolResultText(payload)).toBe( + 'saved ', + ); + }); + + 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('""'); + }); +}); diff --git a/src/client.ts b/src/client.ts index 24c09d7..b63fb99 100644 --- a/src/client.ts +++ b/src/client.ts @@ -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"]; @@ -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