From a624643d900273445f058d9c7bded8e7b01af553 Mon Sep 17 00:00:00 2001 From: Patrick Wozniak Date: Tue, 25 Aug 2026 16:58:29 +0200 Subject: [PATCH] fix(stream): forward incremental tool-call arguments --- CHANGELOG.md | 1 + src/core.ts | 82 +++++++++++++++++++++++++++++++----- src/types.ts | 6 +++ tests/test-stream.ts | 98 ++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 176 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5730962..66b71dd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ## Unreleased +- Stream incremental tool-call arguments from the `/alpha/generate` transport instead of waiting for the final complete tool-call event. - Add a daily GitHub Actions synchronization job that opens or updates a pull request for CLI version, image capability, and reasoning-effort changes in the latest published Command Code catalog. - Refresh static model capabilities from `command-code@1.32.2`, including new image and reasoning metadata. - Add `/commandcode-quota` with live credits, plan, usage totals, and rolling-limit diagnostics from Command Code's alpha usage endpoints. diff --git a/src/core.ts b/src/core.ts index 57ed455..a988a5d 100644 --- a/src/core.ts +++ b/src/core.ts @@ -286,6 +286,10 @@ export function createStreamCommandCode(deps: CoreDependencies) { let textBlock: TextContent | undefined let currentTextIdx = -1 let thinkingIdx = -1 + const streamingToolCalls = new Map< + string, + { contentIndex: number; toolCall: ToolCallContent; partialArgs: string } + >() let finished = false const abortUpstream = () => { @@ -398,25 +402,81 @@ export function createStreamCommandCode(deps: CoreDependencies) { break } + case "tool-input-start": { + endTextBlock() + endThinking() + const id = stringValue(event.id) + if (!id || streamingToolCalls.has(id)) break + + const toolCall: ToolCallContent = { + type: "toolCall", + id, + name: stringValue(event.toolName) ?? "", + arguments: {}, + } + output.content.push(toolCall) + const contentIndex = output.content.length - 1 + streamingToolCalls.set(id, { contentIndex, toolCall, partialArgs: "" }) + stream.push({ + type: "toolcall_start", + contentIndex, + partial: output, + }) + break + } + + case "tool-input-delta": { + const id = stringValue(event.id) + const delta = stringValue(event.delta) + if (!id || delta === undefined) break + const active = streamingToolCalls.get(id) + if (!active) break + + active.partialArgs += delta + active.toolCall.arguments = recordOrEmpty(active.partialArgs) + stream.push({ + type: "toolcall_delta", + contentIndex: active.contentIndex, + delta, + partial: output, + }) + break + } + + case "tool-input-end": { + break + } + case "tool-call": { endTextBlock() endThinking() - const toolCall: ToolCallContent = { + const id = stringValue(event.toolCallId) ?? "" + const active = streamingToolCalls.get(id) + const toolCall: ToolCallContent = active?.toolCall ?? { type: "toolCall", - id: stringValue(event.toolCallId) ?? "", + id, name: stringValue(event.toolName) ?? "", - arguments: recordOrEmpty(event.input ?? event.args ?? event.arguments), + arguments: {}, + } + toolCall.name = stringValue(event.toolName) ?? toolCall.name + toolCall.arguments = recordOrEmpty(event.input ?? event.args ?? event.arguments) + + let contentIndex: number + if (active) { + contentIndex = active.contentIndex + streamingToolCalls.delete(id) + } else { + output.content.push(toolCall) + contentIndex = output.content.length - 1 + stream.push({ + type: "toolcall_start", + contentIndex, + partial: output, + }) } - output.content.push(toolCall) - const idx = output.content.length - 1 - stream.push({ - type: "toolcall_start", - contentIndex: idx, - partial: output, - }) stream.push({ type: "toolcall_end", - contentIndex: idx, + contentIndex, toolCall, partial: output, }) diff --git a/src/types.ts b/src/types.ts index dc680ea..2688976 100644 --- a/src/types.ts +++ b/src/types.ts @@ -172,6 +172,12 @@ export type AssistantMessageEvent = contentIndex: number partial: AssistantMessageLike } + | { + type: "toolcall_delta" + contentIndex: number + delta: string + partial: AssistantMessageLike + } | { type: "toolcall_end" contentIndex: number diff --git a/tests/test-stream.ts b/tests/test-stream.ts index a015e75..a54bd85 100644 --- a/tests/test-stream.ts +++ b/tests/test-stream.ts @@ -408,6 +408,104 @@ describe("streamCommandCode — successful streams", () => { assert.equal(toolCall?.type === "toolCall" ? toolCall.name : "", "read_file") }) + it("streams incremental tool-call arguments from generate events", async () => { + server.mockResponse({ + type: "success", + events: [ + JSON.stringify({ + type: "tool-input-start", + id: "call_1", + toolName: "read_file", + }), + JSON.stringify({ type: "tool-input-delta", id: "call_1", delta: '{"path":"' }), + JSON.stringify({ type: "tool-input-delta", id: "call_1", delta: '/tmp/x"}' }), + JSON.stringify({ type: "tool-input-end", id: "call_1" }), + JSON.stringify({ + type: "tool-call", + toolCallId: "call_1", + toolName: "read_file", + input: { path: "/tmp/x" }, + }), + JSON.stringify({ type: "finish", finishReason: "tool-calls" }), + ], + }) + const { streamCommandCode } = createTestDeps({ apiBase: server.baseUrl() }) + + const events = await collectEvents( + streamCommandCode(makeModel(), makeContext(), { apiKey: "mock-key" }), + ) + + assert.deepEqual(eventTypes(events), [ + "start", + "toolcall_start", + "toolcall_delta", + "toolcall_delta", + "toolcall_end", + "done", + ]) + const deltas = events.flatMap((event) => (event.type === "toolcall_delta" ? [event.delta] : [])) + assert.deepEqual(deltas, ['{"path":"', '/tmp/x"}']) + + const done = events.at(-1) + if (done?.type !== "done") throw new Error("expected done") + assert.equal(done.reason, "toolUse") + const toolCall = done.message.content[0] + assert.equal(toolCall?.type, "toolCall") + if (toolCall?.type !== "toolCall") throw new Error("expected tool call") + assert.equal(toolCall.id, "call_1") + assert.equal(toolCall.name, "read_file") + assert.deepEqual(toolCall.arguments, { path: "/tmp/x" }) + }) + + it("keeps concurrent incremental tool calls separate", async () => { + server.mockResponse({ + type: "success", + events: [ + JSON.stringify({ type: "tool-input-start", id: "call_1", toolName: "read_file" }), + JSON.stringify({ type: "tool-input-start", id: "call_2", toolName: "read_file" }), + JSON.stringify({ type: "tool-input-delta", id: "call_1", delta: '{"path":"/a"}' }), + JSON.stringify({ type: "tool-input-delta", id: "call_2", delta: '{"path":"/b"}' }), + JSON.stringify({ + type: "tool-call", + toolCallId: "call_2", + toolName: "read_file", + input: { path: "/b" }, + }), + JSON.stringify({ + type: "tool-call", + toolCallId: "call_1", + toolName: "read_file", + input: { path: "/a" }, + }), + JSON.stringify({ type: "finish", finishReason: "tool-calls" }), + ], + }) + const { streamCommandCode } = createTestDeps({ apiBase: server.baseUrl() }) + + const events = await collectEvents( + streamCommandCode(makeModel(), makeContext(), { apiKey: "mock-key" }), + ) + + const starts = events.flatMap((event) => + event.type === "toolcall_start" ? [event.contentIndex] : [], + ) + const deltas = events.flatMap((event) => + event.type === "toolcall_delta" ? [[event.contentIndex, event.delta] as const] : [], + ) + const ends = events.flatMap((event) => + event.type === "toolcall_end" ? [[event.contentIndex, event.toolCall.id] as const] : [], + ) + assert.deepEqual(starts, [0, 1]) + assert.deepEqual(deltas, [ + [0, '{"path":"/a"}'], + [1, '{"path":"/b"}'], + ]) + assert.deepEqual(ends, [ + [1, "call_2"], + [0, "call_1"], + ]) + }) + it("flushes reasoning if finish arrives without reasoning-end", async () => { server.mockResponse({ type: "success",