diff --git a/docs/src/content/docs/agents/built-in/grounded-agent.mdx b/docs/src/content/docs/agents/built-in/grounded-agent.mdx index 065e7d48..4bf1a244 100644 --- a/docs/src/content/docs/agents/built-in/grounded-agent.mdx +++ b/docs/src/content/docs/agents/built-in/grounded-agent.mdx @@ -485,7 +485,7 @@ When the turn's primary tool returns a `UIPayload`, the `GroundedAgent` forwards -In TypeScript, consume the widget by iterating the **agent's** stream directly (as above). Routing a `GroundedAgent` through `AgentSquad.routeRequest` accumulates text for the reply, and its accumulator forwards only text chunks — the `{ ui }` object chunk is dropped there. To render widgets, drive the agent directly. (Python's orchestrator forwards the widget-bearing chunk unchanged, so either path works there.) +In TypeScript, the widget arrives as a `{ ui }` object chunk interleaved with the text-string chunks — whether you iterate the **agent's** stream directly or route through `AgentSquad.routeRequest` (the orchestrator forwards the widget chunk and keeps it out of the saved text answer). Check each chunk's type as you consume the stream. (Python delivers it the same way, as `AgentStreamResponse.ui`.) An `.app`-only tool (one the widget calls back into, but the model must never see) is kept out of what the model is offered by filtering the provider's tool list to model-visible tools — the same way the widget's Refresh button re-invokes a tool without the LLM's involvement. diff --git a/docs/src/content/docs/agents/mcp-tool-provider.mdx b/docs/src/content/docs/agents/mcp-tool-provider.mdx index c7685481..adba9820 100644 --- a/docs/src/content/docs/agents/mcp-tool-provider.mdx +++ b/docs/src/content/docs/agents/mcp-tool-provider.mdx @@ -249,7 +249,7 @@ If an MCP server advertises a UI widget on a tool — via `_meta.ui.resourceUri` Pair the provider with a [`GroundedAgent`](/agent-squad/agents/built-in/grounded-agent/) and the widget is forwarded to the caller on the streaming response, exactly like a native tool that returns a `UIPayload` — so the same MCP server that backs a ChatGPT App renders its widgets here. For a server that advertises no `_meta.ui`, the model sees the same text as before. -Available in the Python and TypeScript `MCPToolProvider`. In TypeScript, consume the widget by driving the agent directly (the orchestrator's streaming accumulator forwards text only) — see the [GroundedAgent tool-UI notes](/agent-squad/agents/built-in/grounded-agent/#tool-ui-widgets). +Available in the Python and TypeScript `MCPToolProvider`. See the [GroundedAgent tool-UI notes](/agent-squad/agents/built-in/grounded-agent/#tool-ui-widgets) for how the widget is delivered on the stream in each language. ## Configuration reference diff --git a/typescript/src/utils/helpers.ts b/typescript/src/utils/helpers.ts index f1e42a8d..e6d954ad 100644 --- a/typescript/src/utils/helpers.ts +++ b/typescript/src/utils/helpers.ts @@ -13,6 +13,13 @@ export class AccumulatorTransform extends Transform { } _transform(chunk: any, encoding: string, callback: TransformCallback): void { + // A widget chunk is forwarded to the consumer but never folded into the accumulated text + // answer (which is what gets saved to storage). + if (chunk && typeof chunk === 'object' && chunk.ui) { + this.push(chunk); + callback(); + return; + } const text = this.extractFromChunk(chunk); if (text) { this.accumulator += text; diff --git a/typescript/tests/utils/helpers.test.ts b/typescript/tests/utils/helpers.test.ts new file mode 100644 index 00000000..c12c888b --- /dev/null +++ b/typescript/tests/utils/helpers.test.ts @@ -0,0 +1,54 @@ +import { AccumulatorTransform } from "../../src/utils/helpers"; + +describe("AccumulatorTransform", () => { + it("forwards a { ui } widget chunk without folding it into the accumulated text", async () => { + const transform = new AccumulatorTransform(); + const out: any[] = []; + transform.on("data", (c) => out.push(c)); + const done = new Promise((resolve) => transform.on("end", () => resolve())); + + transform.write("Hello "); + transform.write({ ui: { resourceUri: "ui://x", mimeType: "text/html;profile=mcp-app" } }); + transform.write("world"); + transform.end(); + await done; + + // The saved text answer excludes the widget object. + expect(transform.getAccumulatedData()).toBe("Hello world"); + // The widget object is forwarded to the stream consumer... + const widget = out.find((c) => c && typeof c === "object" && c.ui); + expect(widget.ui.resourceUri).toBe("ui://x"); + // ...alongside the text chunks. + expect(out.filter((c) => typeof c === "string").join("")).toBe("Hello world"); + }); + + it("accumulates and forwards plain text chunks unchanged", async () => { + const transform = new AccumulatorTransform(); + const out: string[] = []; + transform.on("data", (c) => out.push(c)); + const done = new Promise((resolve) => transform.on("end", () => resolve())); + + transform.write("a"); + transform.write("b"); + transform.end(); + await done; + + expect(transform.getAccumulatedData()).toBe("ab"); + expect(out.join("")).toBe("ab"); + }); + + it("does not treat a chunk with a falsy .ui as a widget", async () => { + const transform = new AccumulatorTransform(); + const out: any[] = []; + transform.on("data", (c) => out.push(c)); + const done = new Promise((resolve) => transform.on("end", () => resolve())); + + transform.write({ ui: undefined }); // falsy ui → text path → dropped like any unknown chunk + transform.write("text"); + transform.end(); + await done; + + expect(transform.getAccumulatedData()).toBe("text"); + expect(out.some((c) => c && typeof c === "object")).toBe(false); // no object forwarded + }); +});