From b913de53268b62a8ca8193a162f455f6146f7ea2 Mon Sep 17 00:00:00 2001 From: ilterkavlak Date: Thu, 9 Apr 2026 11:48:18 +0300 Subject: [PATCH 1/3] CLOUD-4325 work-on-box-issue-122 --- .../sdk/src/__tests__/box-agent-run.test.ts | 51 ++++++++++++++++++- packages/sdk/src/client.ts | 11 ++++ packages/sdk/src/types.ts | 25 ++++++++- 3 files changed, 84 insertions(+), 3 deletions(-) diff --git a/packages/sdk/src/__tests__/box-agent-run.test.ts b/packages/sdk/src/__tests__/box-agent-run.test.ts index e1b01e5..30c1788 100644 --- a/packages/sdk/src/__tests__/box-agent-run.test.ts +++ b/packages/sdk/src/__tests__/box-agent-run.test.ts @@ -442,7 +442,7 @@ describe("box.agent.stream", () => { fetchMock.mockResolvedValueOnce( mockSSEResponse([ { event: "run_start", data: { run_id: "r1" } }, - { event: "tool", data: { name: "Write", input: { path: "/x" } } }, + { event: "tool", data: { id: "toolu_1", name: "Write", input: { path: "/x" } } }, { event: "text", data: { text: "done" } }, { event: "done", data: {} }, ]), @@ -459,14 +459,61 @@ describe("box.agent.stream", () => { expect(tools).toHaveLength(1); expect(tools[0]!.name).toBe("Write"); - const toolChunks = chunks.filter((c) => c.type === "tool-call"); + const toolChunks = chunks.filter( + (c): c is Extract => c.type === "tool-call", + ); expect(toolChunks).toHaveLength(1); + expect(toolChunks[0]!.toolCallId).toBe("toolu_1"); + expect(toolChunks[0]!.toolName).toBe("Write"); const textChunks = chunks.filter( (c): c is Extract => c.type === "text-delta", ); expect(textChunks.map((c) => c.text)).toEqual(["done"]); }); + it("matches parallel tool-call and tool-result chunks by id", async () => { + const { box, fetchMock } = await createTestBox(); + + fetchMock.mockResolvedValueOnce( + mockSSEResponse([ + { event: "run_start", data: { run_id: "r1" } }, + { event: "tool", data: { id: "toolu_a", name: "Read", input: { path: "/a" } } }, + { event: "tool", data: { id: "toolu_b", name: "Read", input: { path: "/b" } } }, + // Results arrive out of order — must still match by id. + { + event: "tool_result", + data: { tool_use_id: "toolu_b", output: "B contents", is_error: false }, + }, + { + event: "tool_result", + data: { tool_use_id: "toolu_a", output: "A contents", is_error: false }, + }, + { event: "done", data: {} }, + ]), + ); + + const run = await box.agent.stream({ prompt: "read both" }); + const chunks: Chunk[] = []; + for await (const chunk of run) { + chunks.push(chunk); + } + + const calls = chunks.filter( + (c): c is Extract => c.type === "tool-call", + ); + const results = chunks.filter( + (c): c is Extract => c.type === "tool-result", + ); + + expect(calls.map((c) => c.toolCallId)).toEqual(["toolu_a", "toolu_b"]); + // Out-of-order results must still be matchable by id. + expect(results.map((r) => r.toolCallId)).toEqual(["toolu_b", "toolu_a"]); + + const resultsById = new Map(results.map((r) => [r.toolCallId, r.output])); + expect(resultsById.get("toolu_a")).toBe("A contents"); + expect(resultsById.get("toolu_b")).toBe("B contents"); + }); + it("yields all chunk types in order", async () => { const { box, fetchMock } = await createTestBox(); diff --git a/packages/sdk/src/client.ts b/packages/sdk/src/client.ts index 845e660..b155e92 100644 --- a/packages/sdk/src/client.ts +++ b/packages/sdk/src/client.ts @@ -980,12 +980,23 @@ export class Box { case "tool": { const chunk: Chunk = { type: "tool-call", + toolCallId: parsed.id ?? "", toolName: parsed.name ?? "", input: parsed.input ?? {}, }; options.onToolUse?.({ name: parsed.name ?? "", input: parsed.input ?? {} }); return chunk; } + case "tool_result": { + const chunk: Chunk = { + type: "tool-result", + toolCallId: parsed.tool_use_id ?? parsed.id ?? "", + toolName: parsed.name, + output: parsed.output ?? parsed.content, + isError: parsed.is_error, + }; + return chunk; + } case "done": { Run._update(run, { inputTokens: parsed.input_tokens ?? 0, diff --git a/packages/sdk/src/types.ts b/packages/sdk/src/types.ts index 820d664..07b1765 100644 --- a/packages/sdk/src/types.ts +++ b/packages/sdk/src/types.ts @@ -385,7 +385,30 @@ export type Chunk = | { type: "start"; runId: string } | { type: "text-delta"; text: string } | { type: "reasoning"; text: string } - | { type: "tool-call"; toolName: string; input: Record } + | { + type: "tool-call"; + /** + * Stable identifier for this tool invocation. Use this to match a + * `tool-result` chunk back to its originating call when multiple tool + * calls are in flight in the same turn. + * + * May be an empty string for older agents that don't surface an ID. + */ + toolCallId: string; + toolName: string; + input: Record; + } + | { + type: "tool-result"; + /** Identifier of the `tool-call` chunk this result corresponds to. */ + toolCallId: string; + /** Name of the tool that produced this result, when known. */ + toolName?: string; + /** Tool output payload. Shape is tool-specific. */ + output: unknown; + /** True when the tool reported an error. */ + isError?: boolean; + } | { type: "finish"; output: string; From c462d866b0a6f20e114963d2d3439c3346e18053 Mon Sep 17 00:00:00 2001 From: ilterkavlak Date: Thu, 9 Apr 2026 12:04:27 +0300 Subject: [PATCH 2/3] CLOUD-4325 work-on-box-issue-122 --- .changeset/tool-call-ids-and-results.md | 5 +++ .../sdk/src/__tests__/box-agent-run.test.ts | 31 +++++++++++++++++++ packages/sdk/src/client.ts | 4 +-- packages/sdk/src/types.ts | 11 +++++-- 4 files changed, 46 insertions(+), 5 deletions(-) create mode 100644 .changeset/tool-call-ids-and-results.md diff --git a/.changeset/tool-call-ids-and-results.md b/.changeset/tool-call-ids-and-results.md new file mode 100644 index 0000000..5e581f0 --- /dev/null +++ b/.changeset/tool-call-ids-and-results.md @@ -0,0 +1,5 @@ +--- +"@upstash/box": minor +--- + +Add `toolCallId` to `tool-call` chunks and add a new `tool-result` chunk variant to the streaming `Chunk` union. This lets consumers reliably match results back to their originating call when an agent runs multiple tools in parallel, and removes the need to intercept `tool_result` from the `unknown` event variant. diff --git a/packages/sdk/src/__tests__/box-agent-run.test.ts b/packages/sdk/src/__tests__/box-agent-run.test.ts index 30c1788..4a0972b 100644 --- a/packages/sdk/src/__tests__/box-agent-run.test.ts +++ b/packages/sdk/src/__tests__/box-agent-run.test.ts @@ -514,6 +514,37 @@ describe("box.agent.stream", () => { expect(resultsById.get("toolu_b")).toBe("B contents"); }); + it("parses tool-result with fallback fields (id instead of tool_use_id, content instead of output)", async () => { + const { box, fetchMock } = await createTestBox(); + + fetchMock.mockResolvedValueOnce( + mockSSEResponse([ + { event: "run_start", data: { run_id: "r1" } }, + { event: "tool", data: { id: "t1", name: "Bash", input: { command: "ls" } } }, + // Backend uses `id` instead of `tool_use_id`, and `content` instead of `output` + { + event: "tool_result", + data: { id: "t1", content: "file.txt", is_error: true }, + }, + { event: "done", data: {} }, + ]), + ); + + const run = await box.agent.stream({ prompt: "test" }); + const chunks: Chunk[] = []; + for await (const chunk of run) { + chunks.push(chunk); + } + + const results = chunks.filter( + (c): c is Extract => c.type === "tool-result", + ); + expect(results).toHaveLength(1); + expect(results[0]!.toolCallId).toBe("t1"); + expect(results[0]!.output).toBe("file.txt"); + expect(results[0]!.isError).toBe(true); + }); + it("yields all chunk types in order", async () => { const { box, fetchMock } = await createTestBox(); diff --git a/packages/sdk/src/client.ts b/packages/sdk/src/client.ts index b155e92..d937fb8 100644 --- a/packages/sdk/src/client.ts +++ b/packages/sdk/src/client.ts @@ -826,7 +826,7 @@ export class Box { break; } case "tool": { - options.onToolUse?.({ name: parsed.name, input: parsed.input }); + options.onToolUse?.({ name: parsed.name, input: parsed.input, toolCallId: parsed.id ?? "" }); break; } case "done": { @@ -984,7 +984,7 @@ export class Box { toolName: parsed.name ?? "", input: parsed.input ?? {}, }; - options.onToolUse?.({ name: parsed.name ?? "", input: parsed.input ?? {} }); + options.onToolUse?.({ name: parsed.name ?? "", input: parsed.input ?? {}, toolCallId: parsed.id ?? "" }); return chunk; } case "tool_result": { diff --git a/packages/sdk/src/types.ts b/packages/sdk/src/types.ts index 07b1765..4991f39 100644 --- a/packages/sdk/src/types.ts +++ b/packages/sdk/src/types.ts @@ -400,7 +400,12 @@ export type Chunk = } | { type: "tool-result"; - /** Identifier of the `tool-call` chunk this result corresponds to. */ + /** + * Identifier of the `tool-call` chunk this result corresponds to. + * + * May be an empty string when the backend does not provide an ID + * (e.g. older agents or the OpenCode provider). + */ toolCallId: string; /** Name of the tool that produced this result, when known. */ toolName?: string; @@ -454,7 +459,7 @@ export interface StreamOptions { /** Timeout in milliseconds — aborts if exceeded */ timeout?: number; /** Tool use callback — called when the agent invokes a tool (Read, Write, Bash, etc.) */ - onToolUse?: (tool: { name: string; input: Record }) => void; + onToolUse?: (tool: { name: string; input: Record; toolCallId?: string }) => void; } /** @@ -474,7 +479,7 @@ export interface RunOptions { /** Retries with exponential backoff on transient failures */ maxRetries?: number; /** Tool use callback — called when the agent invokes a tool (Read, Write, Bash, etc.) */ - onToolUse?: (tool: { name: string; input: Record }) => void; + onToolUse?: (tool: { name: string; input: Record; toolCallId?: string }) => void; /** Webhook — fire-and-forget, POST to URL on completion */ webhook?: WebhookConfig; } From 370bb73d666e2b28ec241e5c17c73960531c87f3 Mon Sep 17 00:00:00 2001 From: ilterkavlak Date: Thu, 9 Apr 2026 12:07:40 +0300 Subject: [PATCH 3/3] CLOUD-4325 work-on-box-issue-122 --- packages/sdk/src/client.ts | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/packages/sdk/src/client.ts b/packages/sdk/src/client.ts index d937fb8..58fbcef 100644 --- a/packages/sdk/src/client.ts +++ b/packages/sdk/src/client.ts @@ -826,7 +826,11 @@ export class Box { break; } case "tool": { - options.onToolUse?.({ name: parsed.name, input: parsed.input, toolCallId: parsed.id ?? "" }); + options.onToolUse?.({ + name: parsed.name, + input: parsed.input, + toolCallId: parsed.id ?? "", + }); break; } case "done": { @@ -984,7 +988,11 @@ export class Box { toolName: parsed.name ?? "", input: parsed.input ?? {}, }; - options.onToolUse?.({ name: parsed.name ?? "", input: parsed.input ?? {}, toolCallId: parsed.id ?? "" }); + options.onToolUse?.({ + name: parsed.name ?? "", + input: parsed.input ?? {}, + toolCallId: parsed.id ?? "", + }); return chunk; } case "tool_result": {