Skip to content

Commit af53beb

Browse files
Pr 137 sdk (#144)
* add toolCallId and firstclass toolresult event #122 * BOX-137: fixes * BOX-137: fixes * BOX-137: fixes * BOX-137: fixes --------- Co-authored-by: Ibrahim <ibrahimeahmed25@gmail.com>
1 parent f4a507a commit af53beb

4 files changed

Lines changed: 165 additions & 10 deletions

File tree

.changeset/loose-adults-glow.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@upstash/box": minor
3+
---
4+
5+
add toolCallId and first class tool result event to streaming chunks #122

packages/sdk/src/__tests__/box-agent-run.test.ts

Lines changed: 110 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -29,12 +29,12 @@ describe("box.agent.run", () => {
2929

3030
it("calls onToolUse callback", async () => {
3131
const { box, fetchMock } = await createTestBox();
32-
const tools: Array<{ name: string; input: Record<string, unknown> }> = [];
32+
const tools: Array<{ toolCallId?: string; name: string; input: Record<string, unknown> }> = [];
3333

3434
fetchMock.mockResolvedValueOnce(
3535
mockSSEResponse([
3636
{ event: "run_start", data: { run_id: "r1" } },
37-
{ event: "tool", data: { name: "Read", input: { path: "/test" } } },
37+
{ event: "tool", data: { id: "tool-1", name: "Read", input: { path: "/test" } } },
3838
{ event: "done", data: {} },
3939
]),
4040
);
@@ -45,9 +45,30 @@ describe("box.agent.run", () => {
4545
});
4646

4747
expect(tools).toHaveLength(1);
48+
expect(tools[0]!.toolCallId).toBe("tool-1");
4849
expect(tools[0]!.name).toBe("Read");
4950
});
5051

52+
it("calls onToolResult callback", async () => {
53+
const { box, fetchMock } = await createTestBox();
54+
const results: Array<{ toolCallId?: string; output: unknown }> = [];
55+
56+
fetchMock.mockResolvedValueOnce(
57+
mockSSEResponse([
58+
{ event: "run_start", data: { run_id: "r1" } },
59+
{ event: "tool_result", data: { toolCallId: "tool-1", output: { ok: true } } },
60+
{ event: "done", data: {} },
61+
]),
62+
);
63+
64+
await box.agent.run({
65+
prompt: "test",
66+
onToolResult: (result) => results.push(result),
67+
});
68+
69+
expect(results).toEqual([{ toolCallId: "tool-1", output: { ok: true } }]);
70+
});
71+
5172
it("parses structured output with responseSchema", async () => {
5273
const { box, fetchMock } = await createTestBox();
5374

@@ -437,12 +458,12 @@ describe("box.agent.stream", () => {
437458

438459
it("yields tool-call chunks and calls onToolUse", async () => {
439460
const { box, fetchMock } = await createTestBox();
440-
const tools: Array<{ name: string; input: Record<string, unknown> }> = [];
461+
const tools: Array<{ toolCallId?: string; name: string; input: Record<string, unknown> }> = [];
441462

442463
fetchMock.mockResolvedValueOnce(
443464
mockSSEResponse([
444465
{ event: "run_start", data: { run_id: "r1" } },
445-
{ event: "tool", data: { name: "Write", input: { path: "/x" } } },
466+
{ event: "tool", data: { id: "tool-2", name: "Write", input: { path: "/x" } } },
446467
{ event: "text", data: { text: "done" } },
447468
{ event: "done", data: {} },
448469
]),
@@ -458,15 +479,97 @@ describe("box.agent.stream", () => {
458479
}
459480

460481
expect(tools).toHaveLength(1);
482+
expect(tools[0]!.toolCallId).toBe("tool-2");
461483
expect(tools[0]!.name).toBe("Write");
462484
const toolChunks = chunks.filter((c) => c.type === "tool-call");
463485
expect(toolChunks).toHaveLength(1);
486+
expect(toolChunks[0]).toEqual({
487+
type: "tool-call",
488+
toolCallId: "tool-2",
489+
toolName: "Write",
490+
input: { path: "/x" },
491+
});
464492
const textChunks = chunks.filter(
465493
(c): c is Extract<Chunk, { type: "text-delta" }> => c.type === "text-delta",
466494
);
467495
expect(textChunks.map((c) => c.text)).toEqual(["done"]);
468496
});
469497

498+
it("yields tool-result chunks and calls onToolResult", async () => {
499+
const { box, fetchMock } = await createTestBox();
500+
const results: Array<{ toolCallId?: string; output: unknown }> = [];
501+
502+
fetchMock.mockResolvedValueOnce(
503+
mockSSEResponse([
504+
{ event: "run_start", data: { run_id: "r1" } },
505+
{ event: "tool_result", data: { tool_use_id: "tool-3", output: "ok" } },
506+
{ event: "done", data: {} },
507+
]),
508+
);
509+
510+
const run = await box.agent.stream({
511+
prompt: "test",
512+
onToolResult: (result) => results.push(result),
513+
});
514+
const chunks: Chunk[] = [];
515+
for await (const chunk of run) {
516+
chunks.push(chunk);
517+
}
518+
519+
expect(results).toEqual([{ toolCallId: "tool-3", output: "ok" }]);
520+
expect(chunks).toContainEqual({
521+
type: "tool-result",
522+
toolCallId: "tool-3",
523+
output: "ok",
524+
});
525+
});
526+
527+
it("prefers explicit tool call identifiers over generic ids", async () => {
528+
const { box, fetchMock } = await createTestBox();
529+
530+
fetchMock.mockResolvedValueOnce(
531+
mockSSEResponse([
532+
{ event: "run_start", data: { run_id: "r1" } },
533+
{
534+
event: "tool",
535+
data: {
536+
id: "event-id",
537+
tool_use_id: "tool-use-id",
538+
name: "Read",
539+
input: { path: "/x" },
540+
},
541+
},
542+
{
543+
event: "tool_result",
544+
data: {
545+
id: "result-event-id",
546+
toolCallId: "tool-call-id",
547+
output: "ok",
548+
},
549+
},
550+
{ event: "done", data: {} },
551+
]),
552+
);
553+
554+
const run = await box.agent.stream({ prompt: "test" });
555+
const chunks: Chunk[] = [];
556+
for await (const chunk of run) {
557+
chunks.push(chunk);
558+
}
559+
560+
expect(chunks).toContainEqual({
561+
type: "tool-call",
562+
toolCallId: "tool-use-id",
563+
toolName: "Read",
564+
input: { path: "/x" },
565+
});
566+
expect(chunks).toContainEqual({
567+
type: "tool-result",
568+
toolCallId: "tool-call-id",
569+
output: "ok",
570+
});
571+
});
572+
470573
it("yields all chunk types in order", async () => {
471574
const { box, fetchMock } = await createTestBox();
472575

@@ -475,7 +578,8 @@ describe("box.agent.stream", () => {
475578
{ event: "run_start", data: { run_id: "r1" } },
476579
{ event: "text", data: { text: "Hello " } },
477580
{ event: "thinking", data: { text: "trace" } },
478-
{ event: "tool", data: { name: "Write", input: { path: "/x" } } },
581+
{ event: "tool", data: { toolCallId: "tool-4", name: "Write", input: { path: "/x" } } },
582+
{ event: "tool_result", data: { tool_use_id: "tool-4", output: "done" } },
479583
{
480584
event: "done",
481585
data: { output: "Hello world", input_tokens: 7, output_tokens: 9, session_id: "s1" },
@@ -495,6 +599,7 @@ describe("box.agent.stream", () => {
495599
"text-delta",
496600
"reasoning",
497601
"tool-call",
602+
"tool-result",
498603
"finish",
499604
"stats",
500605
]);

packages/sdk/src/client.ts

Lines changed: 42 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,14 @@ function toBackendAgentOptions(
8787
return mapped;
8888
}
8989

90+
function resolveToolCallId(parsed: Record<string, unknown>): string | undefined {
91+
if (typeof parsed.tool_call_id === "string") return parsed.tool_call_id;
92+
if (typeof parsed.tool_use_id === "string") return parsed.tool_use_id;
93+
if (typeof parsed.toolCallId === "string") return parsed.toolCallId;
94+
if (typeof parsed.id === "string") return parsed.id;
95+
return undefined;
96+
}
97+
9098
/**
9199
* Error thrown by the Box SDK
92100
*/
@@ -943,7 +951,20 @@ export class Box<TProvider = unknown> {
943951
break;
944952
}
945953
case "tool": {
946-
options.onToolUse?.({ name: parsed.name, input: parsed.input });
954+
const toolCallId = resolveToolCallId(parsed);
955+
options.onToolUse?.({
956+
toolCallId,
957+
name: parsed.name ?? "",
958+
input: parsed.input ?? {},
959+
});
960+
break;
961+
}
962+
case "tool_result": {
963+
const toolCallId = resolveToolCallId(parsed);
964+
options.onToolResult?.({
965+
toolCallId,
966+
output: parsed.output,
967+
});
947968
break;
948969
}
949970
case "done": {
@@ -1095,12 +1116,31 @@ export class Box<TProvider = unknown> {
10951116
return null;
10961117
}
10971118
case "tool": {
1119+
const toolCallId = resolveToolCallId(parsed);
10981120
const chunk: Chunk = {
10991121
type: "tool-call",
1122+
toolCallId,
11001123
toolName: parsed.name ?? "",
11011124
input: parsed.input ?? {},
11021125
};
1103-
options.onToolUse?.({ name: parsed.name ?? "", input: parsed.input ?? {} });
1126+
options.onToolUse?.({
1127+
toolCallId,
1128+
name: parsed.name ?? "",
1129+
input: parsed.input ?? {},
1130+
});
1131+
return chunk;
1132+
}
1133+
case "tool_result": {
1134+
const toolCallId = resolveToolCallId(parsed);
1135+
const chunk: Chunk = {
1136+
type: "tool-result",
1137+
toolCallId,
1138+
output: parsed.output,
1139+
};
1140+
options.onToolResult?.({
1141+
toolCallId,
1142+
output: parsed.output,
1143+
});
11041144
return chunk;
11051145
}
11061146
case "done": {

packages/sdk/src/types.ts

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -434,7 +434,8 @@ export type Chunk =
434434
| { type: "start"; runId: string }
435435
| { type: "text-delta"; text: string }
436436
| { type: "reasoning"; text: string }
437-
| { type: "tool-call"; toolName: string; input: Record<string, unknown> }
437+
| { type: "tool-call"; toolCallId?: string; toolName: string; input: Record<string, unknown> }
438+
| { type: "tool-result"; toolCallId?: string; output: unknown }
438439
| {
439440
type: "finish";
440441
output: string;
@@ -480,7 +481,9 @@ export interface StreamOptions<TProvider = unknown> {
480481
/** Timeout in milliseconds — aborts if exceeded */
481482
timeout?: number;
482483
/** Tool use callback — called when the agent invokes a tool (Read, Write, Bash, etc.) */
483-
onToolUse?: (tool: { name: string; input: Record<string, unknown> }) => void;
484+
onToolUse?: (tool: { toolCallId?: string; name: string; input: Record<string, unknown> }) => void;
485+
/** Tool result callback — called when a tool invocation completes */
486+
onToolResult?: (result: { toolCallId?: string; output: unknown }) => void;
484487
}
485488

486489
/**
@@ -500,7 +503,9 @@ export interface RunOptions<T = undefined, TProvider = unknown> {
500503
/** Retries with exponential backoff on transient failures */
501504
maxRetries?: number;
502505
/** Tool use callback — called when the agent invokes a tool (Read, Write, Bash, etc.) */
503-
onToolUse?: (tool: { name: string; input: Record<string, unknown> }) => void;
506+
onToolUse?: (tool: { toolCallId?: string; name: string; input: Record<string, unknown> }) => void;
507+
/** Tool result callback — called when a tool invocation completes */
508+
onToolResult?: (result: { toolCallId?: string; output: unknown }) => void;
504509
/** Webhook — fire-and-forget, POST to URL on completion */
505510
webhook?: WebhookConfig;
506511
}

0 commit comments

Comments
 (0)