fix(stream): forward incremental tool-call arguments

This commit is contained in:
Patrick Wozniak
2026-08-25 16:58:29 +02:00
parent e26e06c582
commit a624643d90
4 changed files with 176 additions and 11 deletions
+1
View File
@@ -2,6 +2,7 @@
## Unreleased ## 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. - 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. - 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. - Add `/commandcode-quota` with live credits, plan, usage totals, and rolling-limit diagnostics from Command Code's alpha usage endpoints.
+66 -6
View File
@@ -286,6 +286,10 @@ export function createStreamCommandCode(deps: CoreDependencies) {
let textBlock: TextContent | undefined let textBlock: TextContent | undefined
let currentTextIdx = -1 let currentTextIdx = -1
let thinkingIdx = -1 let thinkingIdx = -1
const streamingToolCalls = new Map<
string,
{ contentIndex: number; toolCall: ToolCallContent; partialArgs: string }
>()
let finished = false let finished = false
const abortUpstream = () => { const abortUpstream = () => {
@@ -398,25 +402,81 @@ export function createStreamCommandCode(deps: CoreDependencies) {
break 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": { case "tool-call": {
endTextBlock() endTextBlock()
endThinking() endThinking()
const toolCall: ToolCallContent = { const id = stringValue(event.toolCallId) ?? ""
const active = streamingToolCalls.get(id)
const toolCall: ToolCallContent = active?.toolCall ?? {
type: "toolCall", type: "toolCall",
id: stringValue(event.toolCallId) ?? "", id,
name: stringValue(event.toolName) ?? "", 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) output.content.push(toolCall)
const idx = output.content.length - 1 contentIndex = output.content.length - 1
stream.push({ stream.push({
type: "toolcall_start", type: "toolcall_start",
contentIndex: idx, contentIndex,
partial: output, partial: output,
}) })
}
stream.push({ stream.push({
type: "toolcall_end", type: "toolcall_end",
contentIndex: idx, contentIndex,
toolCall, toolCall,
partial: output, partial: output,
}) })
+6
View File
@@ -172,6 +172,12 @@ export type AssistantMessageEvent =
contentIndex: number contentIndex: number
partial: AssistantMessageLike partial: AssistantMessageLike
} }
| {
type: "toolcall_delta"
contentIndex: number
delta: string
partial: AssistantMessageLike
}
| { | {
type: "toolcall_end" type: "toolcall_end"
contentIndex: number contentIndex: number
+98
View File
@@ -408,6 +408,104 @@ describe("streamCommandCode — successful streams", () => {
assert.equal(toolCall?.type === "toolCall" ? toolCall.name : "", "read_file") 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 () => { it("flushes reasoning if finish arrives without reasoning-end", async () => {
server.mockResponse({ server.mockResponse({
type: "success", type: "success",