diff --git a/packages/coding-agent/docs/json.md b/packages/coding-agent/docs/json.md index f497f3406..ddb9cb305 100644 --- a/packages/coding-agent/docs/json.md +++ b/packages/coding-agent/docs/json.md @@ -19,6 +19,7 @@ type JsonAgentSessionEvent = | Exclude | { type: "message_update"; + usage: Usage; assistantMessageEvent: WithoutPartial; }; ``` @@ -73,16 +74,17 @@ Followed by events as they occur: {"type":"agent_start"} {"type":"turn_start"} {"type":"message_start","message":{"role":"assistant","content":[],...}} -{"type":"message_update","assistantMessageEvent":{"type":"text_delta","contentIndex":0,"delta":"Hello"}} +{"type":"message_update","usage":{...},"assistantMessageEvent":{"type":"text_delta","contentIndex":0,"delta":"Hello"}} {"type":"message_end","message":{...}} {"type":"turn_end","message":{...},"toolResults":[]} {"type":"agent_end","messages":[...]} ``` `message_update` records are delta-only. They omit both the cumulative `message` field and -`assistantMessageEvent.partial` to keep stream size linear. Use `contentIndex` and `delta` -to assemble live text, thinking, or tool-call arguments if needed. `message_end` contains -the final authoritative message. +`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. ## Example diff --git a/packages/coding-agent/docs/rpc.md b/packages/coding-agent/docs/rpc.md index 499ca673b..fc4c69867 100644 --- a/packages/coding-agent/docs/rpc.md +++ b/packages/coding-agent/docs/rpc.md @@ -919,6 +919,14 @@ Emitted during streaming of assistant messages. Contains a delta event without a ```json { "type": "message_update", + "usage": { + "input": 100, + "output": 1, + "cacheRead": 0, + "cacheWrite": 0, + "totalTokens": 101, + "cost": {"input": 0, "output": 0, "cacheRead": 0, "cacheWrite": 0, "total": 0} + }, "assistantMessageEvent": { "type": "text_delta", "contentIndex": 0, @@ -943,12 +951,15 @@ The `assistantMessageEvent` field contains one of these delta types: Example streaming a text response: ```json -{"type":"message_update","assistantMessageEvent":{"type":"text_start","contentIndex":0}} -{"type":"message_update","assistantMessageEvent":{"type":"text_delta","contentIndex":0,"delta":"Hello"}} -{"type":"message_update","assistantMessageEvent":{"type":"text_delta","contentIndex":0,"delta":" world"}} -{"type":"message_update","assistantMessageEvent":{"type":"text_end","contentIndex":0,"content":"Hello world"}} +{"type":"message_update","usage":{...},"assistantMessageEvent":{"type":"text_start","contentIndex":0}} +{"type":"message_update","usage":{...},"assistantMessageEvent":{"type":"text_delta","contentIndex":0,"delta":"Hello"}} +{"type":"message_update","usage":{...},"assistantMessageEvent":{"type":"text_delta","contentIndex":0,"delta":" world"}} +{"type":"message_update","usage":{...},"assistantMessageEvent":{"type":"text_end","contentIndex":0,"content":"Hello world"}} ``` +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. + `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` diff --git a/packages/coding-agent/src/modes/json-event.ts b/packages/coding-agent/src/modes/json-event.ts index 6cff8ebf2..f300b17a9 100644 --- a/packages/coding-agent/src/modes/json-event.ts +++ b/packages/coding-agent/src/modes/json-event.ts @@ -1,3 +1,4 @@ +import type { Usage } from "@earendil-works/pi-ai"; import type { AgentSessionEvent } from "../core/agent-session.ts"; type WithoutPartial = T extends { partial: unknown } ? Omit : T; @@ -8,6 +9,7 @@ type ToJsonEvent = T extends { } ? { type: "message_update"; + usage: Usage; assistantMessageEvent: WithoutPartial; } : T; @@ -21,7 +23,8 @@ type JsonMessageUpdateEvent = Extract { + let harness: Harness | undefined; + + afterEach(() => { + harness?.cleanup(); + }); + + it("includes cumulative usage without cumulative message snapshots", async () => { + harness = await createHarness(); + harness.setResponses([fauxAssistantMessage("hello")]); + + await harness.session.prompt("respond"); + + // #7290's delta-only wire projection dropped this fixed-size metadata with the snapshots. + const update = harness + .eventsOfType("message_update") + .find((event) => event.message.role === "assistant" && event.message.usage.totalTokens > 0); + if (!update || update.message.role !== "assistant") { + throw new Error("Expected an assistant update with populated usage"); + } + + const wireUpdate = toJsonEvent(update); + expect(wireUpdate.usage).toEqual(update.message.usage); + expect(wireUpdate).not.toHaveProperty("message"); + expect(wireUpdate.assistantMessageEvent).not.toHaveProperty("partial"); + }); +});