diff --git a/packages/coding-agent/docs/json.md b/packages/coding-agent/docs/json.md index ddb9cb305..2d538802e 100644 --- a/packages/coding-agent/docs/json.md +++ b/packages/coding-agent/docs/json.md @@ -15,12 +15,16 @@ except that streaming message updates omit cumulative snapshots: ```typescript type WithoutPartial = T extends { partial: unknown } ? Omit : T; +type JsonAssistantMessageEvent = T extends { type: "toolcall_start"; partial: unknown } + ? WithoutPartial & { id: string; toolName: string } + : WithoutPartial; + type JsonAgentSessionEvent = | Exclude | { type: "message_update"; usage: Usage; - assistantMessageEvent: WithoutPartial; + assistantMessageEvent: JsonAssistantMessageEvent; }; ``` @@ -84,7 +88,8 @@ Followed by events as they occur: `assistantMessageEvent.partial` to keep stream size linear. The top-level `usage` field contains the latest cumulative provider-reported usage and may remain zero when a provider only reports usage at completion. Use `contentIndex` and `delta` to assemble live text, thinking, or tool-call -arguments if needed. `message_end` contains the final authoritative message. +arguments if needed. A `toolcall_start` event also includes the constant-sized `id` and `toolName` +fields. `message_end` contains the final authoritative message. ## Example diff --git a/packages/coding-agent/docs/rpc.md b/packages/coding-agent/docs/rpc.md index fc4c69867..f92c12e8e 100644 --- a/packages/coding-agent/docs/rpc.md +++ b/packages/coding-agent/docs/rpc.md @@ -945,7 +945,7 @@ The `assistantMessageEvent` field contains one of these delta types: | `thinking_start` | Thinking block started | | `thinking_delta` | Thinking content chunk | | `thinking_end` | Thinking block ended | -| `toolcall_start` | Tool call started | +| `toolcall_start` | Tool call started (includes `id` and `toolName`) | | `toolcall_delta` | Tool call arguments chunk | | `toolcall_end` | Tool call ended (includes full `toolCall` object) | @@ -960,11 +960,17 @@ Example streaming a text response: The top-level `usage` field contains the latest cumulative provider-reported usage. It may remain zero until completion when a provider does not report usage during streaming. +Example starting a tool call: +```json +{"type":"message_update","usage":{...},"assistantMessageEvent":{"type":"toolcall_start","contentIndex":1,"id":"call_abc123","toolName":"write"}} +``` + `message_update` intentionally omits the former cumulative `message` field and `assistantMessageEvent.partial`. Clients that need a live partial message must assemble it from `message_start` and subsequent events using `contentIndex`. Treat `message_end.message` -as authoritative. For tool calls, buffer `toolcall_delta.delta`; `toolcall_end.toolCall` -contains the completed call. +as authoritative. For tool calls, `toolcall_start` provides the call `id` and `toolName`; +buffer `toolcall_delta.delta` for arguments. `toolcall_end.toolCall` contains the completed +call. ### bash_execution_update diff --git a/packages/coding-agent/src/modes/json-event.ts b/packages/coding-agent/src/modes/json-event.ts index f300b17a9..c0c04fde0 100644 --- a/packages/coding-agent/src/modes/json-event.ts +++ b/packages/coding-agent/src/modes/json-event.ts @@ -3,28 +3,45 @@ import type { AgentSessionEvent } from "../core/agent-session.ts"; type WithoutPartial = T extends { partial: unknown } ? Omit : T; -type ToJsonEvent = T extends { - type: "message_update"; - assistantMessageEvent: infer TAssistantMessageEvent; -} - ? { - type: "message_update"; - usage: Usage; - assistantMessageEvent: WithoutPartial; - } - : T; - -/** Session event shape emitted by the JSON and RPC stdout protocols. */ -export type JsonAgentSessionEvent = ToJsonEvent; +type ToJsonAssistantMessageEvent = T extends { type: "toolcall_start"; partial: unknown } + ? WithoutPartial & { id: string; toolName: string } + : WithoutPartial; type MessageUpdateEvent = Extract; -type JsonMessageUpdateEvent = Extract; +type JsonMessageUpdateEvent = { + type: "message_update"; + usage: Usage; + assistantMessageEvent: ToJsonAssistantMessageEvent; +}; + +/** Session event shape emitted by the JSON and RPC stdout protocols. */ +export type JsonAgentSessionEvent = Exclude | JsonMessageUpdateEvent; + +function toJsonAssistantMessageEvent( + event: MessageUpdateEvent["assistantMessageEvent"], +): JsonMessageUpdateEvent["assistantMessageEvent"] { + if (event.type === "toolcall_start") { + const toolCall = event.partial.content[event.contentIndex]; + if (toolCall?.type !== "toolCall") { + throw new Error(`toolcall_start content at index ${event.contentIndex} is not a tool call`); + } + const { partial: _partial, ...deltaEvent } = event; + return { ...deltaEvent, id: toolCall.id, toolName: toolCall.name }; + } + + if (!("partial" in event)) { + return event; + } + + const { partial: _partial, ...deltaEvent } = event; + return deltaEvent; +} /** * Remove cumulative assistant snapshots from streaming wire events. * `message_start` provides the initial message, deltas build it, and - * `message_end` provides the final authoritative message. Cumulative usage - * remains available because its size is constant. + * `message_end` provides the final authoritative message. Cumulative usage, + * tool-call ids, and tool names remain available because their size is constant. */ export function toJsonEvent(event: MessageUpdateEvent): JsonMessageUpdateEvent; export function toJsonEvent(event: AgentSessionEvent): JsonAgentSessionEvent; @@ -36,11 +53,9 @@ export function toJsonEvent(event: AgentSessionEvent): JsonAgentSessionEvent { throw new Error("message_update message is not an assistant message"); } - const assistantMessageEvent = event.assistantMessageEvent; - if (!("partial" in assistantMessageEvent)) { - return { type: "message_update", usage: event.message.usage, assistantMessageEvent }; - } - - const { partial: _partial, ...deltaEvent } = assistantMessageEvent; - return { type: "message_update", usage: event.message.usage, assistantMessageEvent: deltaEvent }; + return { + type: "message_update", + usage: event.message.usage, + assistantMessageEvent: toJsonAssistantMessageEvent(event.assistantMessageEvent), + }; } diff --git a/packages/coding-agent/test/suite/regressions/7925-toolcall-start-metadata.test.ts b/packages/coding-agent/test/suite/regressions/7925-toolcall-start-metadata.test.ts new file mode 100644 index 000000000..e58de9e97 --- /dev/null +++ b/packages/coding-agent/test/suite/regressions/7925-toolcall-start-metadata.test.ts @@ -0,0 +1,43 @@ +import { fauxAssistantMessage, fauxToolCall } from "@earendil-works/pi-ai"; +import { afterEach, describe, expect, it } from "vitest"; +import { toJsonEvent } from "../../../src/modes/json-event.ts"; +import { createHarness, type Harness } from "../harness.ts"; + +describe("regression #7925: tool-call metadata is available when streaming starts", () => { + let harness: Harness | undefined; + + afterEach(() => { + harness?.cleanup(); + }); + + it("includes the tool call id and name without cumulative snapshots", async () => { + harness = await createHarness(); + harness.setResponses([ + fauxAssistantMessage( + fauxToolCall("write", { path: "output.txt", content: "x".repeat(100) }, { id: "call_7925" }), + { stopReason: "toolUse" }, + ), + fauxAssistantMessage("done"), + ]); + + await harness.session.prompt("write a file"); + + const update = harness + .eventsOfType("message_update") + .find((event) => event.assistantMessageEvent.type === "toolcall_start"); + if (!update || update.message.role !== "assistant") { + throw new Error("Expected toolcall_start assistant update"); + } + + expect(toJsonEvent(update)).toEqual({ + type: "message_update", + usage: update.message.usage, + assistantMessageEvent: { + type: "toolcall_start", + contentIndex: 0, + id: "call_7925", + toolName: "write", + }, + }); + }); +});