From c522b9432b81b581b226869049814faca3a235e2 Mon Sep 17 00:00:00 2001 From: FreshlyBrewedCode Date: Fri, 2 Oct 2026 19:59:32 +0000 Subject: [PATCH 1/3] feat(events): record context and cost on AgentStepFinished MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AgentStepFinished gains optional `context: {used, size}` and `cost: {amount, currency}` (ADR 0013 §5), taken from the step's `usage` signals: the last one's context, and the latest cost any of them carried, since the agents report the session's cost so far and Claude sends it only at the end of a turn. Cancelled and failed steps keep what was reported. The `usage` doc comment now says what it means per agent. The ACP adapter adds an `acp.tool-call` CUSTOM chunk after each tool call update that carries a title: translateAcpStream keeps only the first, generic title (`Edit`, `Read File`), and drops the descriptive one (`Edit math.ts`) and the real input that arrive in later updates. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/events.test.ts | 53 +++++++++++++++++ src/events.ts | 62 +++++++++++++++----- src/runtime/acp-adapter.test.ts | 21 +++++++ src/runtime/acp-adapter.ts | 56 +++++++++++++++++- src/runtime/agent-adapter.ts | 4 +- src/runtime/agent-step.ts | 22 ++++++- src/runtime/agent-step.usage.test.ts | 55 ++++++++++++++++-- src/runtime/run.test.ts | 87 ++++++++++++++++++++++++++++ src/runtime/run.ts | 16 ++++- test/fixtures/fake-acp-agent.ts | 35 +++++++++++ 10 files changed, 384 insertions(+), 27 deletions(-) diff --git a/src/events.test.ts b/src/events.test.ts index 8a35b7a..425590c 100644 --- a/src/events.test.ts +++ b/src/events.test.ts @@ -419,3 +419,56 @@ describe("AgentStepStarted.agent (ADR 0013 §2)", () => { expect(event.payload._tag === "AgentStepStarted" && "agent" in event.payload).toBe(false); }); }); + +describe("AgentStepFinished.context / .cost (ADR 0013 §5)", () => { + const finished = (extra: Record) => ({ + runId: "r", + seq: 1, + ts: 0, + payload: { + _tag: "AgentStepFinished", + stepId: "step-0", + name: "implement", + outcome: "completed", + chunkCount: 3, + durationMs: 10, + finalText: "done", + ...extra, + }, + }); + const encode = SchemaParser.encodeUnknownSync(RunEvent); + + test("round-trips context and cost beside usage", () => { + const raw = finished({ + usage: { inputTokens: 1, outputTokens: 2, cachedInputTokens: 3, reasoningTokens: 0 }, + context: { used: 15_305, size: 200_000 }, + cost: { amount: 0.0391796, currency: "USD" }, + }); + const event = decodeRunEvent(raw); + expect(event.payload).toMatchObject({ + context: { used: 15_305, size: 200_000 }, + cost: { amount: 0.0391796, currency: "USD" }, + }); + expect(encode(event) as unknown).toEqual(raw); + }); + + test("a log written before the fields existed has neither, and still decodes", () => { + const event = decodeRunEvent( + finished({ + usage: { + inputTokens: 260, + outputTokens: 288, + cachedInputTokens: 14208, + reasoningTokens: 0, + }, + }), + ); + expect("context" in event.payload).toBe(false); + expect("cost" in event.payload).toBe(false); + }); + + test("rejects a context that is not a token count", () => { + expect(() => decodeRunEvent(finished({ context: { used: 1.5, size: 10 } }))).toThrow(); + expect(() => decodeRunEvent(finished({ cost: { amount: 0.1 } }))).toThrow(); + }); +}); diff --git a/src/events.ts b/src/events.ts index a79bf62..1835343 100644 --- a/src/events.ts +++ b/src/events.ts @@ -213,27 +213,27 @@ export const RunEventPayload = Schema.TaggedUnion({ /** From the `opencode.session-id` CUSTOM chunk. Fresh per step (D10). */ sessionId: Schema.optional(Schema.String), /** - * Token counts from the step's `RUN_FINISHED.usage` chunk. + * Token counts from the step's `RUN_FINISHED.usage` chunk, **as the agent + * reports them** (ADR 0013 §5). What they cover depends on the step's + * `agent` (`AgentStepStarted.agent`): + * + * - **opencode**: the *last* assistant message only. One step runs + * opencode's whole agent loop, often dozens of model calls, and only the + * final one is reported. Logs from before ACP (no `agent`) mean the same. + * - **claude**: the *whole turn*, summed over its model calls, so the + * cached-read figure can be a multiple of the context size. + * + * Do not bill off these; `cost` exists for that. For the context in use, + * read `context`; `agentStepContextTokens` derives it from these + * components only for logs written before `context` existed. * * Absent when the step produced no `RUN_FINISHED` (cancelled mid-stream, or * a failure that surfaced as `RUN_ERROR` — the adapter emits one or the * other, never both). * - * ONLY THE FINAL ASSISTANT MESSAGE IS REPRESENTED HERE. One `ctx.agent()` - * step is one opencode `session.prompt()`, which internally runs opencode's - * whole agent loop — commonly 50-150 assistant messages. Each has its own - * token counts, but `@tanstack/ai-opencode` builds `RUN_FINISHED.usage` - * from the terminal `done` message alone and drops the rest (its translator - * ignores `message.updated`, which is where the per-message counts arrive). - * So these are the *last turn's* numbers, not the step's cumulative spend — - * do not bill off them. Recovering the true cumulative figure needs - * opencode's own message store, keyed by `sessionId`. - * * Stored as the four raw components, deliberately without a total: the - * adapter's own `usage.totalTokens` is `input + output` and silently omits - * cache reads, which for a coding session are ~99% of the context. Consumers - * derive their own sum (see `agentStepContextTokens`); the components are - * individually correct, so that derivation survives an upstream fix. + * adapters' own `usage.totalTokens` is `input + output` and silently omits + * cache reads, which for a coding session are ~99% of the context. */ usage: Schema.optional( Schema.Struct({ @@ -244,6 +244,24 @@ export const RunEventPayload = Schema.TaggedUnion({ reasoningTokens: Integer, }), ), + /** + * The context in use and the window size, as the agent measured them + * (ADR 0013 §5), from the step's last `usage` signal (ACP + * `usage_update`). Present for cancelled and failed steps too, as far as + * the agent got. Absent in logs written before it existed and for steps + * whose agent never reported usage; there, `agentStepContextTokens(usage)` + * is the fallback. + */ + context: Schema.optional(Schema.Struct({ used: Integer, size: Integer })), + /** + * What the agent says the step cost (ADR 0013 §5): the latest cost its + * `usage` signals carried. A step is one ACP session, and the agents + * report the session's cost so far, so the latest figure is the step's. + * Recorded as reported: opencode says `0 USD` for a free model. Claude + * reports cost only at the end of a turn, so a step cancelled mid-turn + * usually has none. + */ + cost: Schema.optional(Schema.Struct({ amount: Schema.Finite, currency: Schema.String })), /** From a `RUN_ERROR` chunk, or the abort reason. */ error: Schema.optional(Schema.String), }, @@ -366,6 +384,16 @@ export type AgentStepUsage = NonNullable< Extract["usage"] >; +/** `AgentStepFinished.context`: tokens in context and the window size. */ +export type AgentStepContext = NonNullable< + Extract["context"] +>; + +/** `AgentStepFinished.cost`: an amount in a currency, as the agent reported it. */ +export type AgentStepCost = NonNullable< + Extract["cost"] +>; + /** * Size of the context the step's final assistant turn ran against: prompt plus * completion plus the cached prefix. This is the number opencode itself @@ -376,6 +404,10 @@ export type AgentStepUsage = NonNullable< * Cache reads are added rather than assumed subsumed because opencode reports * `input` exclusive of them (the Anthropic convention). It is emphatically not * the step's cumulative token spend — see `AgentStepFinished.usage`. + * + * Superseded by `AgentStepFinished.context.used` where present (ADR 0013 §5); + * kept as the fallback for logs written before that field, which all ran on + * opencode, where this derivation is exact. */ export function agentStepContextTokens(usage: AgentStepUsage): number { return usage.inputTokens + usage.outputTokens + usage.cachedInputTokens; diff --git a/src/runtime/acp-adapter.test.ts b/src/runtime/acp-adapter.test.ts index e24a983..4573200 100644 --- a/src/runtime/acp-adapter.test.ts +++ b/src/runtime/acp-adapter.test.ts @@ -169,6 +169,27 @@ describe("acpAdapter", () => { }); }); + test("carries each tool call title the translator drops as an acp.tool-call chunk", async () => { + const c = await collect("tools"); + const chunks = c.yields.map((y) => y.chunk as { type: string; name?: string; value?: unknown }); + const tools = chunks.filter((ch) => ch.type === "CUSTOM" && ch.name === "acp.tool-call"); + expect(tools.map((ch) => ch.value)).toEqual([ + { toolCallId: "t1", title: "Edit" }, + { toolCallId: "t1", title: "Edit math.ts", input: { file_path: "math.ts" } }, + ]); + // The translator keeps the first, generic title only. + const end = chunks.find((ch) => ch.type === "TOOL_CALL_END") as { input?: unknown }; + expect(end.input).toEqual({ title: "Edit" }); + // Each info chunk follows the call's TOOL_CALL_START, and carries no signal. + expect(chunks.indexOf(tools[0]!)).toBeGreaterThan( + chunks.findIndex((ch) => ch.type === "TOOL_CALL_START"), + ); + expect(c.yields.filter((y) => tools.includes(y.chunk as never)).map((y) => y.signal)).toEqual([ + undefined, + undefined, + ]); + }); + test("parses structured output from the last message and yields it before RUN_FINISHED", async () => { const c = await collect("json", { outputSchema: { type: "object" } }); expect(signals(c, "structuredOutput")).toEqual([ diff --git a/src/runtime/acp-adapter.ts b/src/runtime/acp-adapter.ts index cea628e..a961fa4 100644 --- a/src/runtime/acp-adapter.ts +++ b/src/runtime/acp-adapter.ts @@ -21,6 +21,12 @@ * `allow_once` — #24's policy, without writing a file into the tree. * - **Usage** (§5): each ACP `usage_update` becomes a `CUSTOM` `acp.usage` * chunk carrying a `usage` signal, in stream order. + * - **Tool titles**: each ACP `tool_call` / `tool_call_update` that carries a + * `title` is followed by a `CUSTOM` `acp.tool-call` chunk (`ToolCallInfo`), + * because `translateAcpStream` keeps only the first title, and that one is + * generic (`Edit`, `Read File` on Claude; the tool name on opencode). The + * descriptive one (`Edit math.ts`, `git status`) and the real input come in + * later updates the translator drops. No signal: it is for the transcript. * - **Cancel** (§6): ACP `session/cancel`, then a kill after a grace period; * always a kill in `finally`. * - **Exit mid-turn**: the step fails with the agent's exit code and the tail @@ -62,6 +68,7 @@ export const ACP_CHUNK = { content: "acp.message-content", plan: "acp.plan", usage: "acp.usage", + toolCall: "acp.tool-call", structuredOutput: "structured-output.complete", } as const; @@ -168,8 +175,38 @@ export function usageOf(update: { }; } -/** What the adapter's own queue carries: ACP events, plus usage, which AG-UI has no event for. */ -type Incoming = AcpStreamEvent | { readonly kind: "usage"; readonly usage: AgentUsage }; +/** + * The value of an `acp.tool-call` chunk: the latest title an ACP tool call + * update carried, and its input when the update had a non-empty one. + */ +export interface ToolCallInfo { + readonly toolCallId: string; + readonly title: string; + readonly input?: unknown; +} + +/** The `ToolCallInfo` a `tool_call` / `tool_call_update` carries, if it has a title. */ +export function toolCallInfoOf(update: SessionUpdate): ToolCallInfo | undefined { + if (update.sessionUpdate !== "tool_call" && update.sessionUpdate !== "tool_call_update") { + return undefined; + } + if (typeof update.title !== "string" || update.title === "") return undefined; + const input = update.rawInput; + const hasInput = + input !== undefined && + input !== null && + !(typeof input === "object" && Object.keys(input).length === 0); + return { toolCallId: update.toolCallId, title: update.title, ...(hasInput && { input }) }; +} + +/** + * What the adapter's own queue carries: ACP events, plus usage and tool + * titles, which AG-UI has no event for. + */ +type Incoming = + | AcpStreamEvent + | { readonly kind: "usage"; readonly usage: AgentUsage } + | { readonly kind: "tool"; readonly tool: ToolCallInfo }; /** * Drive `translateAcpStream` one input event at a time, so the adapter can @@ -297,6 +334,9 @@ export function acpAdapter( return; } incoming.push({ kind: "update", update: update as AcpSessionUpdate }); + // After the update, so the call's TOOL_CALL_START comes first. + const tool = toolCallInfoOf(update); + if (tool) incoming.push({ kind: "tool", tool }); const plan = todoPlan(update); if (plan) incoming.push({ kind: "update", update: plan as AcpSessionUpdate }); }, @@ -484,6 +524,18 @@ export function acpAdapter( }; continue; } + if (event.kind === "tool") { + yield { + chunk: { + type: "CUSTOM", + name: ACP_CHUNK.toolCall, + value: event.tool, + model: options.model, + timestamp: Date.now(), + }, + }; + continue; + } const step = await translator.feed(event); yield* handle(step.chunks); if (step.error !== undefined) throw step.error; diff --git a/src/runtime/agent-adapter.ts b/src/runtime/agent-adapter.ts index af934ff..1f12950 100644 --- a/src/runtime/agent-adapter.ts +++ b/src/runtime/agent-adapter.ts @@ -37,8 +37,8 @@ export interface AgentAdapterOptions { * - `sessionId` → `AgentStepFinished.sessionId` * - `structuredOutput` → `AgentStepFinished.output` (via `resolveOutput`) * - `runError` → `AgentStepFinished.error` - * - `usage` → `AgentStepFinished.context` / `.cost` (ADR 0013 §5; recorded - * from #65 on — until then the runtime reads past it) + * - `usage` → `AgentStepFinished.context` / `.cost` (ADR 0013 §5): the + * last one's context, and the latest cost any of them carried */ export type AgentSignal = | { readonly _tag: "sessionId"; readonly value: string } diff --git a/src/runtime/agent-step.ts b/src/runtime/agent-step.ts index 5eb1f7b..e49bc20 100644 --- a/src/runtime/agent-step.ts +++ b/src/runtime/agent-step.ts @@ -37,7 +37,7 @@ */ import { Effect, Schema, Stream } from "effect"; -import type { AgentStepUsage } from "../events"; +import type { AgentStepContext, AgentStepCost, AgentStepUsage } from "../events"; import type { AcpAgentKind } from "./acp-agents"; import type { AgentAdapterYield } from "./agent-adapter"; import { AgentRuntime } from "./agent-runtime"; @@ -93,6 +93,8 @@ export interface AgentStepOutcome { readonly structuredOutput: unknown; readonly sessionId: string | undefined; readonly usage: AgentStepUsage | undefined; + readonly context: AgentStepContext | undefined; + readonly cost: AgentStepCost | undefined; readonly runError: string | undefined; readonly durationMs: number; } @@ -107,6 +109,10 @@ export interface AgentStepPartial { finalText: string; sessionId: string | undefined; usage: AgentStepUsage | undefined; + /** From the last `usage` signal (ADR 0013 §5), so a cancelled step keeps it. */ + context: AgentStepContext | undefined; + /** The latest cost a `usage` signal carried; see `AgentStepFinished.cost`. */ + cost: AgentStepCost | undefined; } export interface AgentStepHandle { @@ -220,6 +226,8 @@ export function buildAgentStepEffect(options: AgentStepEffectOptions): AgentStep finalText: "", sessionId: undefined, usage: undefined, + context: undefined, + cost: undefined, }; let currentMessageBuffer: string | undefined; let structuredOutput: unknown; @@ -245,9 +253,15 @@ export function buildAgentStepEffect(options: AgentStepEffectOptions): AgentStep case "runError": runError = yieldItem.signal.value; break; - case "usage": - // No field yet: `AgentStepFinished.context` / `.cost` arrive with #65. + case "usage": { + // Last one wins. Cost is cumulative per session (one per step), + // and Claude sends it only at a turn's end, so an update without + // one keeps the last figure rather than erasing it. + const { context, cost } = yieldItem.signal.value; + partial.context = { used: context.used, size: context.size }; + if (cost !== undefined) partial.cost = { amount: cost.amount, currency: cost.currency }; break; + } } } @@ -295,6 +309,8 @@ export function buildAgentStepEffect(options: AgentStepEffectOptions): AgentStep structuredOutput, sessionId: partial.sessionId, usage: partial.usage, + context: partial.context, + cost: partial.cost, runError, durationMs: Date.now() - startedAt, })); diff --git a/src/runtime/agent-step.usage.test.ts b/src/runtime/agent-step.usage.test.ts index ec2be4b..9ee719d 100644 --- a/src/runtime/agent-step.usage.test.ts +++ b/src/runtime/agent-step.usage.test.ts @@ -15,7 +15,7 @@ import { describe, expect, test } from "bun:test"; import { Effect } from "effect"; import { agentStepContextTokens } from "../events"; -import type { AgentAdapter, AgentAdapterYield } from "./agent-adapter"; +import type { AgentAdapter, AgentAdapterYield, AgentUsage } from "./agent-adapter"; import { AgentRuntimeLayer } from "./agent-runtime"; import { fakeAgents, loadCorpusBlocks } from "../replay/adapter"; import { buildAgentStepEffect } from "./agent-step"; @@ -23,12 +23,14 @@ import { buildAgentStepEffect } from "./agent-step"; const CORPUS = "test/corpus/run-1789308170212.ndjson"; async function runBlock(chunks: ReadonlyArray) { + return await runYields(chunks.map((chunk) => ({ chunk }))); +} + +async function runYields(yields: ReadonlyArray) { const adapter: AgentAdapter = { async prepareWorkspace(_dir: string): Promise {}, async *stream(): AsyncGenerator { - for (const chunk of chunks) { - yield { chunk }; - } + yield* yields; }, }; const handle = buildAgentStepEffect({ @@ -109,3 +111,48 @@ describe("agent step usage", () => { expect(outcome.usage).toBeUndefined(); }); }); + +const usageYield = (value: AgentUsage): AgentAdapterYield => ({ + chunk: { type: "CUSTOM", name: "acp.usage", value }, + signal: { _tag: "usage", value }, +}); + +describe("agent step context and cost (ADR 0013 §5)", () => { + test("the last usage signal's context wins", async () => { + const outcome = await runYields([ + { chunk: { type: "RUN_STARTED" } }, + usageYield({ context: { used: 14_874, size: 1_000_000 } }), + usageYield({ context: { used: 15_305, size: 200_000 } }), + { chunk: { type: "RUN_FINISHED" } }, + ]); + expect(outcome.context).toEqual({ used: 15_305, size: 200_000 }); + expect(outcome.cost).toBeUndefined(); + }); + + test("cost is the latest one reported; an update without one keeps it", async () => { + const outcome = await runYields([ + usageYield({ context: { used: 100, size: 1000 }, cost: { amount: 0.01, currency: "USD" } }), + usageYield({ context: { used: 200, size: 1000 }, cost: { amount: 0.04, currency: "USD" } }), + usageYield({ context: { used: 300, size: 1000 } }), + ]); + expect(outcome.context).toEqual({ used: 300, size: 1000 }); + expect(outcome.cost).toEqual({ amount: 0.04, currency: "USD" }); + }); + + test("a free model's 0 USD is recorded as reported", async () => { + const outcome = await runYields([ + usageYield({ + context: { used: 14_010, size: 200_000 }, + cost: { amount: 0, currency: "USD" }, + }), + ]); + expect(outcome.cost).toEqual({ amount: 0, currency: "USD" }); + }); + + test("a step whose agent never reports usage has neither", async () => { + const blocks = loadCorpusBlocks(CORPUS); + const outcome = await runBlock(blocks.find((b) => b.step === "fix")!.chunks); + expect(outcome.context).toBeUndefined(); + expect(outcome.cost).toBeUndefined(); + }); +}); diff --git a/src/runtime/run.test.ts b/src/runtime/run.test.ts index 1c2ffa5..7b68bff 100644 --- a/src/runtime/run.test.ts +++ b/src/runtime/run.test.ts @@ -70,6 +70,93 @@ describe("startRun cancellation", () => { expect(runFailed).toBeUndefined(); }); + test("a cancelled step keeps the context and cost its agent reported (ADR 0013 §5)", async () => { + const events: Array = []; + const usage = { + context: { used: 15_305, size: 200_000 }, + cost: { amount: 0.039, currency: "USD" }, + }; + const workflow = defineWorkflow("cancel-usage", { + input: Schema.Struct({}), + run: async (ctx) => { + await ctx.agent("slow-step", "irrelevant"); + return {}; + }, + }); + const chunks = [ + { type: "CUSTOM", name: "acp.usage", value: usage }, + ...SLOW_CHUNKS, + ...SLOW_CHUNKS, + ...SLOW_CHUNKS, + ]; + const handle = startRun( + workflow, + makeAgentRuntime( + fakeAgents( + createSlowFakeAdapter(chunks, 15, [ + { index: 0, signal: { _tag: "usage", value: usage } }, + ]), + ), + ), + { runId: "run-cancel-usage", dir: "/tmp", input: {}, onEvent: (e) => events.push(e) }, + ); + + await new Promise((resolve) => setTimeout(resolve, 60)); + await handle.cancel(); + expect((await handle.result).outcome).toBe("cancelled"); + + const finished = events.find((e) => e.payload._tag === "AgentStepFinished"); + expect(finished?.payload).toMatchObject({ + outcome: "cancelled", + context: { used: 15_305, size: 200_000 }, + cost: { amount: 0.039, currency: "USD" }, + }); + }); + + test("a completed step records context and cost, and omits them when none came", async () => { + const events: Array = []; + const usage = { + context: { used: 1200, size: 200_000 }, + cost: { amount: 0.25, currency: "USD" }, + }; + const workflow = defineWorkflow("usage-fields", { + input: Schema.Struct({}), + run: async (ctx) => { + await ctx.agent("measured", "irrelevant"); + await ctx.agent("unmeasured", "irrelevant"); + return {}; + }, + }); + let call = 0; + const adapter: AgentAdapter = { + async prepareWorkspace() {}, + async *stream() { + call += 1; + if (call === 1) { + yield { + chunk: { type: "CUSTOM", name: "acp.usage", value: usage }, + signal: { _tag: "usage", value: usage }, + }; + } + yield { chunk: { type: "RUN_FINISHED" } }; + }, + }; + const handle = startRun(workflow, makeAgentRuntime(fakeAgents(adapter)), { + runId: "run-usage-fields", + dir: "/tmp", + input: {}, + onEvent: (e) => events.push(e), + }); + expect((await handle.result).outcome).toBe("completed"); + + const finished = events.flatMap((e) => + e.payload._tag === "AgentStepFinished" ? [e.payload] : [], + ); + expect(finished[0]).toMatchObject({ context: usage.context, cost: usage.cost }); + expect("context" in finished[1]!).toBe(false); + expect("cost" in finished[1]!).toBe(false); + }); + test("an uninterrupted run completes normally through the same adapter", async () => { const workflow = defineWorkflow("no-cancel-test", { input: Schema.Struct({}), diff --git a/src/runtime/run.ts b/src/runtime/run.ts index 58cc236..4f2db19 100644 --- a/src/runtime/run.ts +++ b/src/runtime/run.ts @@ -14,7 +14,7 @@ */ import { Cause, Effect, Exit, Fiber, Schema, SchemaParser, type ManagedRuntime } from "effect"; -import type { RunEvent, RunEventPayload } from "../events"; +import type { AgentStepContext, AgentStepCost, RunEvent, RunEventPayload } from "../events"; import { hostExec, type ExecResult } from "../lib/exec"; import { writeBack as writeBackLib, type WriteBackResult } from "../lib/writeback"; import type { @@ -141,6 +141,17 @@ function boundedTeardown(teardown: Promise): Promise { }); } +/** `AgentStepFinished.context` / `.cost` (ADR 0013 §5), each only when the agent reported it. */ +function measured(step: { + readonly context: AgentStepContext | undefined; + readonly cost: AgentStepCost | undefined; +}): { context?: AgentStepContext; cost?: AgentStepCost } { + return { + ...(step.context !== undefined ? { context: step.context } : {}), + ...(step.cost !== undefined ? { cost: step.cost } : {}), + }; +} + function makeIdCounter(prefix: string): () => string { let n = 0; return () => `${prefix}-${n++}`; @@ -315,6 +326,7 @@ export function startRun( ? { sessionId: handle.partial.sessionId } : {}), ...(handle.partial.usage !== undefined ? { usage: handle.partial.usage } : {}), + ...measured(handle.partial), }); throw RunCancelledSignal.of(); } @@ -330,6 +342,7 @@ export function startRun( finalText: handle.partial.finalText, ...(handle.partial.sessionId !== undefined ? { sessionId: handle.partial.sessionId } : {}), ...(handle.partial.usage !== undefined ? { usage: handle.partial.usage } : {}), + ...measured(handle.partial), error: message, }); throw new Error(`agent step "${name}" failed: ${message}`); @@ -350,6 +363,7 @@ export function startRun( ...(output !== undefined ? { output: output as never } : {}), ...(result.sessionId !== undefined ? { sessionId: result.sessionId } : {}), ...(result.usage !== undefined ? { usage: result.usage } : {}), + ...measured(result), ...(result.runError !== undefined ? { error: result.runError } : {}), }); diff --git a/test/fixtures/fake-acp-agent.ts b/test/fixtures/fake-acp-agent.ts index 9c60dcb..2ec3412 100644 --- a/test/fixtures/fake-acp-agent.ts +++ b/test/fixtures/fake-acp-agent.ts @@ -5,6 +5,8 @@ * * - `hello`: usage, a text reply naming the session's model, usage with cost. * - `permission`: asks to edit a file and replies with the option it got. + * - `tools`: a tool call the way Claude sends one: a generic title and no + * input first, then the descriptive title and the input, then the result. * - `json`: replies with a JSON object (structured output). * - `env`: replies with the names of its `CLAUDE*` environment variables. * - `hang`: works until cancelled, then ends the turn as `cancelled`. @@ -132,6 +134,39 @@ const connection = new AgentSideConnection( await say(sessionId, `permission: ${chosen}`); return { stopReason: "end_turn" }; } + case "tools": { + await conn.sessionUpdate({ + sessionId, + update: { + sessionUpdate: "tool_call", + toolCallId: "t1", + title: "Edit", + kind: "edit", + status: "pending", + rawInput: {}, + }, + }); + await conn.sessionUpdate({ + sessionId, + update: { + sessionUpdate: "tool_call_update", + toolCallId: "t1", + title: "Edit math.ts", + rawInput: { file_path: "math.ts" }, + }, + }); + await conn.sessionUpdate({ + sessionId, + update: { + sessionUpdate: "tool_call_update", + toolCallId: "t1", + status: "completed", + rawOutput: "edited", + }, + }); + await say(sessionId, "tools done"); + return { stopReason: "end_turn" }; + } case "json": await say(sessionId, '{"summary":"done","count":2}'); return { stopReason: "end_turn" }; From 7ccdc8dff108330428918fe30872c847ff6aae9d Mon Sep 17 00:00:00 2001 From: FreshlyBrewedCode Date: Fri, 2 Oct 2026 19:59:32 +0000 Subject: [PATCH 2/3] feat(web): context, cost and tool titles in run detail - Steps show context against the window and cost; the run's cost is summed per currency. Old logs fall back to agentStepContextTokens. - A running step's context and cost follow its `acp.usage` chunks. - Transcript tool calls show the agent's title (`Edit math.ts`) and input from `acp.tool-call` chunks, else the title in the arguments. Co-Authored-By: Claude Opus 5.5 (1M context) --- e2e/acp.e2e.ts | 49 +++++++++ e2e/server.ts | 100 ++++++++++++++++++ src/web/lib/format.test.ts | 26 +++++ src/web/lib/format.ts | 25 +++++ src/web/lib/run-events.test.ts | 185 ++++++++++++++++++++++++++++++++- src/web/lib/run-events.ts | 108 ++++++++++++++++++- src/web/lib/transcript.test.ts | 116 ++++++++++++++++++++- src/web/lib/transcript.ts | 70 ++++++++++++- src/web/pages/run-detail.tsx | 120 ++++++++++++++++----- 9 files changed, 764 insertions(+), 35 deletions(-) create mode 100644 e2e/acp.e2e.ts diff --git a/e2e/acp.e2e.ts b/e2e/acp.e2e.ts new file mode 100644 index 0000000..5f06505 --- /dev/null +++ b/e2e/acp.e2e.ts @@ -0,0 +1,49 @@ +import { expect, test } from "@playwright/test"; + +/** + * Issue #65 (ADR 0013 §5): run detail shows what an ACP agent reported — + * context against the window and cost per step, the run's summed cost, and + * the descriptive tool title in the transcript. Seeded as `run-static-acp` + * by `e2e/server.ts`; the live context fold is covered by `run-events.test.ts`. + */ +test("run detail shows context, cost and tool titles for an ACP run", async ({ page }) => { + await page.goto("/runs/run-static-acp"); + + // Run cost: both steps, the cancelled one included ($0.039 + $0.028). + await expect(page.getByTestId("run-cost")).toHaveText("$0.067"); + + const implement = page + .locator('[data-testid="step-row"][data-kind="agent"]') + .filter({ hasText: "implement" }); + await expect(implement.getByTestId("step-meta")).toHaveText(/15\.3k\/200k ctx · \$0\.039/); + const review = page + .locator('[data-testid="step-row"][data-kind="agent"]') + .filter({ hasText: "review" }); + await expect(review).toHaveAttribute("data-status", "cancelled"); + await expect(review.getByTestId("step-meta")).toHaveText(/9\.1k\/200k ctx · \$0\.028/); + + await implement.click(); + await expect(page.getByTestId("step-context")).toContainText("15.3k / 200k · 8%"); + await expect(page.getByTestId("step-context-meter")).toHaveAttribute("data-share", "0.077"); + await expect(page.getByTestId("step-cost")).toHaveText("$0.039"); + + await page.getByTestId("open-transcript").click(); + const tool = page.getByTestId("transcript-tool"); + await expect(tool).toHaveCount(1); + await expect(tool).toContainText("Edit math.ts"); + await expect(tool).toContainText("edit · complete"); +}); + +test("an old opencode log falls back to the derived context, without cost", async ({ page }) => { + await page.goto("/runs/run-static-corpus"); + await expect(page.getByTestId("run-meta")).toContainText("cost"); + await expect(page.getByTestId("run-cost")).toHaveCount(0); + const implement = page + .locator('[data-testid="step-row"][data-kind="agent"]') + .filter({ hasText: "implement" }) + .first(); + await expect(implement.getByTestId("step-meta")).toHaveText(/\d+(\.\d)?k ctx$/); + await implement.click(); + await expect(page.getByTestId("step-context")).toContainText("(final turn)"); + await expect(page.getByTestId("step-context-meter")).toHaveCount(0); +}); diff --git a/e2e/server.ts b/e2e/server.ts index 31d4e2c..4b8940d 100644 --- a/e2e/server.ts +++ b/e2e/server.ts @@ -245,9 +245,109 @@ async function seedCorpusRuns(root: string, db: ReturnType): P payload: { _tag: "RunFinished", durationMs: 5 }, }); + seedAcpRun(db, workDir, ts); + return remoteDir; } +/** + * Issue #65: an agent step as the ACP adapter records it, shaped after the + * recorded finding-13 Claude run — `acp.usage` chunks while it runs, a tool + * call whose descriptive title arrives in an `acp.tool-call` chunk, and + * `AgentStepFinished.context` / `.cost`. A second step is cancelled + * mid-turn and keeps what it reported. + */ +function seedAcpRun(db: ReturnType, dir: string, ts: number): void { + const runId = "run-static-acp"; + let seq = 0; + const append = (payload: Parameters[1]["payload"]) => + appendEvent(db, { runId, seq: seq++, ts: ts + seq, payload }); + const chunk = (stepId: string, value: Record) => + append({ _tag: "AgentChunk", stepId, chunkType: String(value.type), chunk: value as never }); + const usage = (stepId: string, used: number, cost?: number) => + chunk(stepId, { + type: "CUSTOM", + name: "acp.usage", + value: { + context: { used, size: 200_000 }, + ...(cost !== undefined && { cost: { amount: cost, currency: "USD" } }), + }, + }); + + append({ _tag: "RunStarted", workflowId: "acp-hello", dir, input: {} }); + append({ + _tag: "AgentStepStarted", + stepId: "step-0", + name: "implement", + agent: "claude", + model: "haiku", + prompt: "add multiply to math.ts", + structured: false, + }); + chunk("step-0", { type: "RUN_STARTED", runId: "r0", threadId: "t" }); + usage("step-0", 14_874); + chunk("step-0", { type: "TOOL_CALL_START", toolCallId: "toolu_1", toolCallName: "edit" }); + chunk("step-0", { + type: "TOOL_CALL_ARGS", + toolCallId: "toolu_1", + delta: '{"title":"Edit"}', + args: '{"title":"Edit"}', + }); + chunk("step-0", { + type: "TOOL_CALL_END", + toolCallId: "toolu_1", + toolCallName: "edit", + input: { title: "Edit" }, + }); + chunk("step-0", { + type: "CUSTOM", + name: "acp.tool-call", + value: { toolCallId: "toolu_1", title: "Edit math.ts", input: { file_path: "math.ts" } }, + }); + chunk("step-0", { + type: "TOOL_CALL_RESULT", + toolCallId: "toolu_1", + messageId: "m1", + content: "The file math.ts has been updated.", + }); + usage("step-0", 15_305, 0.0391796); + chunk("step-0", { type: "RUN_FINISHED", runId: "r0", threadId: "t", finishReason: "stop" }); + append({ + _tag: "AgentStepFinished", + stepId: "step-0", + name: "implement", + outcome: "completed", + chunkCount: 8, + durationMs: 7_800, + finalText: "Added multiply.", + usage: { inputTokens: 3, outputTokens: 120, cachedInputTokens: 36_386, reasoningTokens: 0 }, + context: { used: 15_305, size: 200_000 }, + cost: { amount: 0.0391796, currency: "USD" }, + }); + append({ + _tag: "AgentStepStarted", + stepId: "step-1", + name: "review", + agent: "claude", + model: "haiku", + prompt: "review the change", + structured: false, + }); + usage("step-1", 9_120); + append({ + _tag: "AgentStepFinished", + stepId: "step-1", + name: "review", + outcome: "cancelled", + chunkCount: 1, + durationMs: 1_200, + finalText: "", + context: { used: 9_120, size: 200_000 }, + cost: { amount: 0.0275702, currency: "USD" }, + }); + append({ _tag: "RunCancelled", durationMs: 9_100 }); +} + async function main(): Promise { const port = Number(process.env.FACTORY_E2E_PORT ?? "5199"); const dbPath = process.env.FACTORY_E2E_DB ?? join(import.meta.dir, "../.factory/e2e/factory.db"); diff --git a/src/web/lib/format.test.ts b/src/web/lib/format.test.ts index 2424f05..f9b2fca 100644 --- a/src/web/lib/format.test.ts +++ b/src/web/lib/format.test.ts @@ -5,6 +5,8 @@ import { formatClock, formatDuration, formatInZone, + formatContext, + formatCost, formatTokenCount, shortRunId, } from "./format"; @@ -89,3 +91,27 @@ describe("formatAhead (issue #17)", () => { expect(formatAhead(now + 27 * 3_600_000, now)).toBe("in 1d 3h"); }); }); + +describe("formatCost", () => { + test("dollars with digits enough for the amount", () => { + expect(formatCost({ amount: 0.0391796, currency: "USD" })).toBe("$0.039"); + expect(formatCost({ amount: 1.2449, currency: "USD" })).toBe("$1.24"); + expect(formatCost({ amount: 0.00041, currency: "USD" })).toBe("$0.0004"); + }); + + test("a free model's zero, and other currencies by code", () => { + expect(formatCost({ amount: 0, currency: "USD" })).toBe("$0"); + expect(formatCost({ amount: 0.25, currency: "EUR" })).toBe("0.250 EUR"); + }); +}); + +describe("formatContext", () => { + test("used against the window, with the share", () => { + expect(formatContext({ used: 15_305, size: 200_000 })).toBe("15.3k / 200k · 8%"); + expect(formatContext({ used: 14_874, size: 1_000_000 })).toBe("14.9k / 1m · 1%"); + }); + + test("without a window, the figure alone", () => { + expect(formatContext({ used: 14_756, size: undefined })).toBe("14.8k"); + }); +}); diff --git a/src/web/lib/format.ts b/src/web/lib/format.ts index ee4aa83..98ad647 100644 --- a/src/web/lib/format.ts +++ b/src/web/lib/format.ts @@ -76,3 +76,28 @@ export function formatAhead(ts: number, now: number = Date.now()): string { const days = Math.floor(hours / 24); return `in ${days}d ${hours % 24}h`; } + +/** + * An agent-reported cost: `$0.039`, `$1.24`, `0.25 EUR`. Small amounts keep + * enough digits to tell steps apart (`$0.0004`); zero, which opencode reports + * for a free model, reads `$0`. + */ +export function formatCost(cost: { readonly amount: number; readonly currency: string }): string { + const { amount, currency } = cost; + const digits = amount === 0 ? 0 : amount >= 1 ? 2 : amount >= 0.01 ? 3 : 4; + const figure = amount.toFixed(digits); + return currency === "USD" ? `$${figure}` : `${figure} ${currency}`; +} + +/** + * Context in use against the window: `15.3k / 200k · 8%`. Without a window + * (a log from before ACP) just the figure. + */ +export function formatContext(context: { + readonly used: number; + readonly size: number | undefined; +}): string { + if (context.size === undefined || context.size <= 0) return formatTokenCount(context.used); + const percent = Math.round((context.used / context.size) * 100); + return `${formatTokenCount(context.used)} / ${formatTokenCount(context.size)} · ${percent}%`; +} diff --git a/src/web/lib/run-events.test.ts b/src/web/lib/run-events.test.ts index 9475540..6e12560 100644 --- a/src/web/lib/run-events.test.ts +++ b/src/web/lib/run-events.test.ts @@ -1,6 +1,14 @@ import { describe, expect, test } from "bun:test"; import { agentStepContextTokens, type RunEvent, type RunEventPayload } from "../../events"; -import { deriveRunMeta, deriveSteps, summarizeEvent } from "./run-events"; +import { ACP_CHUNK } from "../../runtime/acp-adapter"; +import { + ACP_USAGE_CHUNK, + deriveRunMeta, + deriveSteps, + runCost, + summarizeEvent, + type AgentStepView, +} from "./run-events"; function event(seq: number, payload: RunEventPayload): RunEvent { return { runId: "run-test", seq, ts: 1_000 + seq, payload }; @@ -475,3 +483,178 @@ describe("dedupe keys (issue #15)", () => { ).toContain("issue:41"); }); }); + +describe("context and cost per agent step (ADR 0013 §5)", () => { + const started = (seq: number, stepId: string): RunEvent => + event(seq, { + _tag: "AgentStepStarted", + stepId, + name: stepId, + agent: "claude", + model: "sonnet", + prompt: "p", + structured: false, + }); + const usage = ( + seq: number, + stepId: string, + used: number, + size: number, + cost?: { amount: number; currency: string }, + ): RunEvent => + event(seq, { + _tag: "AgentChunk", + stepId, + chunkType: "CUSTOM", + chunk: { + type: "CUSTOM", + name: "acp.usage", + value: { context: { used, size }, ...(cost !== undefined && { cost }) }, + }, + }); + const finished = (seq: number, stepId: string, extra: Record = {}): RunEvent => + event(seq, { + _tag: "AgentStepFinished", + stepId, + name: stepId, + outcome: "completed", + chunkCount: 3, + durationMs: 10, + finalText: "", + ...extra, + } as RunEventPayload); + const agent = (steps: ReturnType, i = 0) => steps[i] as AgentStepView; + + test("the chunk name is the one the adapter emits", () => { + expect(ACP_USAGE_CHUNK).toBe(ACP_CHUNK.usage); + }); + + test("a running step's context follows its latest acp.usage chunk", () => { + const first = [STARTED, started(1, "s"), usage(2, "s", 14_874, 200_000)]; + expect(agent(deriveSteps(first)).context).toEqual({ + used: 14_874, + size: 200_000, + source: "reported", + }); + const later = [...first, usage(3, "s", 15_305, 200_000)]; + const step = agent(deriveSteps(later)); + expect(step.status).toBe("running"); + expect(step.context?.used).toBe(15_305); + expect(step.cost).toBeUndefined(); + expect(step.chunkCount).toBe(2); + }); + + test("a usage chunk without cost keeps the cost an earlier one reported", () => { + const steps = deriveSteps([ + STARTED, + started(1, "s"), + usage(2, "s", 100, 1000, { amount: 0.02, currency: "USD" }), + usage(3, "s", 200, 1000), + ]); + expect(agent(steps).cost).toEqual({ amount: 0.02, currency: "USD" }); + }); + + test("the recorded fields win once the step finishes", () => { + const steps = deriveSteps([ + STARTED, + started(1, "s"), + usage(2, "s", 100, 1000), + finished(3, "s", { + context: { used: 15_305, size: 200_000 }, + cost: { amount: 0.0391796, currency: "USD" }, + usage: { inputTokens: 3, outputTokens: 2, cachedInputTokens: 36_386, reasoningTokens: 0 }, + }), + ]); + expect(agent(steps).context).toEqual({ used: 15_305, size: 200_000, source: "reported" }); + expect(agent(steps).cost).toEqual({ amount: 0.0391796, currency: "USD" }); + }); + + test("a step finished without the fields keeps what its chunks reported", () => { + // A log written by the ACP adapter before AgentStepFinished had the fields. + const steps = deriveSteps([ + STARTED, + started(1, "s"), + usage(2, "s", 15_270, 1_000_000, { amount: 0.0275702, currency: "USD" }), + finished(3, "s", { + usage: { inputTokens: 3, outputTokens: 2, cachedInputTokens: 36_386, reasoningTokens: 0 }, + }), + ]); + expect(agent(steps).context).toEqual({ used: 15_270, size: 1_000_000, source: "reported" }); + expect(agent(steps).cost).toEqual({ amount: 0.0275702, currency: "USD" }); + }); + + test("an old log falls back to agentStepContextTokens, with no window and no cost", () => { + const usageFields = { + inputTokens: 260, + outputTokens: 288, + cachedInputTokens: 14_208, + reasoningTokens: 927, + }; + const steps = deriveSteps([STARTED, started(1, "s"), finished(2, "s", { usage: usageFields })]); + expect(agent(steps).context).toEqual({ + used: agentStepContextTokens(usageFields), + size: undefined, + source: "derived", + }); + expect(agent(steps).cost).toBeUndefined(); + }); + + test("a cancelled step keeps its context and cost", () => { + const steps = deriveSteps([ + STARTED, + started(1, "s"), + finished(2, "s", { + outcome: "cancelled", + context: { used: 9000, size: 200_000 }, + cost: { amount: 0.01, currency: "USD" }, + }), + ]); + expect(agent(steps).status).toBe("cancelled"); + expect(agent(steps).context?.used).toBe(9000); + expect(agent(steps).cost?.amount).toBe(0.01); + }); + + test("usage chunks for one step never touch another", () => { + const steps = deriveSteps([ + STARTED, + started(1, "a"), + started(2, "b"), + usage(3, "b", 500, 1000), + ]); + expect(agent(steps, 0).context).toBeUndefined(); + expect(agent(steps, 1).context?.used).toBe(500); + }); + + test("runCost sums step costs per currency, running steps included", () => { + const steps = deriveSteps([ + STARTED, + started(1, "a"), + finished(2, "a", { cost: { amount: 0.039, currency: "USD" } }), + started(3, "b"), + finished(4, "b", { cost: { amount: 0, currency: "USD" } }), + started(5, "c"), + usage(6, "c", 10, 100, { amount: 0.028, currency: "USD" }), + started(7, "d"), + finished(8, "d"), + ]); + const [total, ...rest] = runCost(steps); + expect(rest).toEqual([]); + expect(total?.currency).toBe("USD"); + expect(total?.amount).toBeCloseTo(0.067, 10); + }); + + test("runCost is empty when no step reported a cost, and keeps currencies apart", () => { + expect(runCost(deriveSteps([STARTED, started(1, "a"), finished(2, "a")]))).toEqual([]); + const mixed = deriveSteps([ + STARTED, + started(1, "a"), + finished(2, "a", { cost: { amount: 1, currency: "USD" } }), + started(3, "b"), + finished(4, "b", { cost: { amount: 2, currency: "EUR" } }), + ]); + expect(runCost(mixed)).toEqual([ + { amount: 1, currency: "USD" }, + { amount: 2, currency: "EUR" }, + ]); + }); +}); diff --git a/src/web/lib/run-events.ts b/src/web/lib/run-events.ts index 36a5b62..176f16c 100644 --- a/src/web/lib/run-events.ts +++ b/src/web/lib/run-events.ts @@ -1,4 +1,9 @@ -import type { AgentStepUsage, RunEvent } from "../../events"; +import { + agentStepContextTokens, + type AgentStepCost, + type AgentStepUsage, + type RunEvent, +} from "../../events"; /** * S3's read-only projection of the typed event spine (`src/events.ts`) for the @@ -11,7 +16,33 @@ import type { AgentStepUsage, RunEvent } from "../../events"; * peek into `RUN_FINISHED`'s raw JSON is gone: that route could only see the * adapter's `usage.totalTokens`, which omits the cached prefix and under-reports * a step's context by ~100x. + * + * One exception to "never decoded" (ADR 0013 §5): the ACP adapter's + * `acp.usage` CUSTOM chunks are read for a running step's context and cost, + * so the steps list shows the context filling up before `AgentStepFinished` + * records the last figure. + */ + +/** + * The CUSTOM chunk name the ACP adapter gives each `usage_update` + * (`ACP_CHUNK.usage` in `src/runtime/acp-adapter.ts`; a test pins the two + * together). Spelled out here so the SPA bundle does not import the runtime. */ +export const ACP_USAGE_CHUNK = "acp.usage"; + +/** + * A step's context: tokens in use, and the window size when the agent said. + * + * - `reported`: the agent measured it (`AgentStepFinished.context`, or the + * step's latest `acp.usage` chunk while it runs); + * - `derived`: a log from before ACP, where `agentStepContextTokens(usage)` is + * the figure and the window is unknown. + */ +export interface StepContext { + readonly used: number; + readonly size: number | undefined; + readonly source: "reported" | "derived"; +} export type StepStatus = | "running" @@ -44,8 +75,16 @@ export interface AgentStepView extends StepBase { readonly prompt: string; readonly chunkCount: number; readonly sessionId: string | undefined; - /** Final-turn token counts; absent for steps that never reached `RUN_FINISHED`. */ + /** + * Token counts as the agent reports them (`AgentStepFinished.usage`): the + * last message on opencode, the whole turn on Claude. Absent for steps that + * never reached `RUN_FINISHED`. + */ readonly usage: AgentStepUsage | undefined; + /** Context in use; live from `acp.usage` chunks while the step runs. */ + readonly context: StepContext | undefined; + /** What the agent says the step cost; live from `acp.usage` chunks too. */ + readonly cost: AgentStepCost | undefined; readonly finalText: string | undefined; readonly output: unknown; readonly error: string | undefined; @@ -150,6 +189,8 @@ export function deriveSteps( chunkCount: 0, sessionId: undefined, usage: undefined, + context: undefined, + cost: undefined, finalText: undefined, output: undefined, error: undefined, @@ -162,7 +203,15 @@ export function deriveSteps( const index = agentIndex.get(payload.stepId); const current = index === undefined ? undefined : steps[index]; if (index !== undefined && current?.kind === "agent") { - steps[index] = { ...current, chunkCount: current.chunkCount + 1 }; + const usage = usageChunk(payload.chunk); + steps[index] = { + ...current, + chunkCount: current.chunkCount + 1, + ...(usage !== undefined && { + context: { ...usage.context, source: "reported" as const }, + cost: usage.cost ?? current.cost, + }), + }; } break; } @@ -191,6 +240,20 @@ export function deriveSteps( chunkCount: payload.chunkCount, sessionId: payload.sessionId, usage: payload.usage, + // Recorded fields first; then what the chunks said (logs from before + // the fields, written by an ACP adapter); then the derivation. + context: + payload.context !== undefined + ? { ...payload.context, source: "reported" } + : (base?.context ?? + (payload.usage !== undefined + ? { + used: agentStepContextTokens(payload.usage), + size: undefined, + source: "derived", + } + : undefined)), + cost: payload.cost ?? base?.cost, finalText: payload.finalText, output: payload.output, error: payload.error, @@ -321,6 +384,45 @@ export function deriveSteps( return steps; } +/** The `{context, cost}` of an `acp.usage` chunk, or undefined for any other chunk. */ +function usageChunk( + chunk: unknown, +): { context: { used: number; size: number }; cost: AgentStepCost | undefined } | undefined { + if (typeof chunk !== "object" || chunk === null) return undefined; + const record = chunk as { type?: unknown; name?: unknown; value?: unknown }; + if (record.type !== "CUSTOM" || record.name !== ACP_USAGE_CHUNK) return undefined; + const value = record.value as + | { + context?: { used?: unknown; size?: unknown }; + cost?: { amount?: unknown; currency?: unknown }; + } + | undefined; + const used = value?.context?.used; + const size = value?.context?.size; + if (typeof used !== "number" || typeof size !== "number") return undefined; + const amount = value?.cost?.amount; + const currency = value?.cost?.currency; + return { + context: { used, size }, + cost: + typeof amount === "number" && typeof currency === "string" ? { amount, currency } : undefined, + }; +} + +/** + * The run's cost: every agent step's `cost`, summed per currency, in the + * order the currencies first appear. Running steps count with what they + * reported so far. Empty when no step reported a cost. + */ +export function runCost(steps: ReadonlyArray): ReadonlyArray { + const totals = new Map(); + for (const step of steps) { + if (step.kind !== "agent" || step.cost === undefined) continue; + totals.set(step.cost.currency, (totals.get(step.cost.currency) ?? 0) + step.cost.amount); + } + return [...totals].map(([currency, amount]) => ({ amount, currency })); +} + export interface RunSession { readonly name: string; readonly sessionId: string; diff --git a/src/web/lib/transcript.test.ts b/src/web/lib/transcript.test.ts index 341e769..253cd87 100644 --- a/src/web/lib/transcript.test.ts +++ b/src/web/lib/transcript.test.ts @@ -2,7 +2,13 @@ import { describe, expect, test } from "bun:test"; import { readFileSync } from "node:fs"; import { join } from "node:path"; import type { RunEvent, RunEventPayload } from "../../events"; -import { deriveTranscript, toTranscriptRows, type TranscriptRow } from "./transcript"; +import { ACP_CHUNK } from "../../runtime/acp-adapter"; +import { + ACP_TOOL_CALL_CHUNK, + deriveTranscript, + toTranscriptRows, + type TranscriptRow, +} from "./transcript"; const CORPUS_DIR = join(import.meta.dir, "..", "..", "..", "test", "corpus"); const ROUND_TRIP = "run-1789308170212.ndjson"; @@ -297,6 +303,7 @@ describe("deriveTranscript without a corpus", () => { kind: "tool-call", toolCallId: "c1", name: "read", + title: undefined, args: '{"path":"a.ts"}', state: "complete", result: "file body", @@ -309,3 +316,110 @@ describe("deriveTranscript without a corpus", () => { expect(toTranscriptRows([])).toEqual([]); }); }); + +/** + * Tool calls as the ACP adapter stores them, shaped after the recorded + * finding-13 runs: `translateAcpStream` keeps the first title only, inside + * the arguments, and the adapter adds `acp.tool-call` chunks with the + * descriptive title and the real input. + */ +describe("ACP tool call titles", () => { + const chunk = (seq: number, value: Record): RunEvent => + event(seq, { + _tag: "AgentChunk", + stepId: "s", + chunkType: String(value.type), + chunk: value as never, + }); + const open = (seq: number, id: string, kind: string, title: string): RunEvent[] => [ + chunk(seq, { type: "TOOL_CALL_START", toolCallId: id, toolCallName: kind, toolName: kind }), + chunk(seq + 1, { + type: "TOOL_CALL_ARGS", + toolCallId: id, + delta: JSON.stringify({ title }), + args: JSON.stringify({ title }), + }), + chunk(seq + 2, { + type: "TOOL_CALL_END", + toolCallId: id, + toolCallName: kind, + toolName: kind, + input: { title }, + }), + ]; + const info = (seq: number, value: Record) => + chunk(seq, { type: "CUSTOM", name: ACP_TOOL_CALL_CHUNK, value }); + const result = (seq: number, id: string, content: string) => + chunk(seq, { type: "TOOL_CALL_RESULT", toolCallId: id, messageId: `m${seq}`, content }); + + test("the chunk name is the one the adapter emits", () => { + expect(ACP_TOOL_CALL_CHUNK).toBe(ACP_CHUNK.toolCall); + }); + + test("shows the latest acp.tool-call title, and its input as the arguments", () => { + const transcript = deriveTranscript( + [ + chunk(0, { type: "RUN_STARTED" }), + ...open(1, "toolu_1", "edit", "Edit"), + info(4, { toolCallId: "toolu_1", title: "Edit" }), + info(5, { toolCallId: "toolu_1", title: "Edit math.ts", input: { file_path: "math.ts" } }), + result(6, "toolu_1", "The file math.ts has been updated."), + ], + "s", + ); + const rows = toTranscriptRows(transcript.messages, transcript.tools); + expect(rows).toEqual([ + { + kind: "tool-call", + toolCallId: "toolu_1", + name: "edit", + title: "Edit math.ts", + args: '{"file_path":"math.ts"}', + state: "complete", + result: "The file math.ts has been updated.", + isError: false, + }, + ]); + }); + + test("a later title without input keeps the input it had (opencode's completed update)", () => { + const transcript = deriveTranscript( + [ + ...open(0, "call_1", "execute", "bash"), + info(3, { toolCallId: "call_1", title: "git status", input: { command: "git status" } }), + info(4, { toolCallId: "call_1", title: "Shows working tree status" }), + result(5, "call_1", "clean"), + ], + "s", + ); + const [row] = toTranscriptRows(transcript.messages, transcript.tools); + expect(row).toMatchObject({ + title: "Shows working tree status", + args: '{"command":"git status"}', + }); + }); + + test("without acp.tool-call chunks, the title in the arguments is used", () => { + // Logs written by the ACP adapter before it added the chunk. + const transcript = deriveTranscript([...open(0, "toolu_1", "read", "Read File")], "s"); + const [row] = toTranscriptRows(transcript.messages, transcript.tools); + expect(row).toMatchObject({ name: "read", title: "Read File", args: '{"title":"Read File"}' }); + }); + + test("the acp.tool-call chunks are not transcript rows of their own", () => { + const transcript = deriveTranscript( + [...open(0, "t", "read", "Read"), info(3, { toolCallId: "t", title: "Read a.ts" })], + "s", + ); + expect(toTranscriptRows(transcript.messages, transcript.tools)).toHaveLength(1); + }); + + test("an old opencode log has no title: the tool name stands", () => { + const transcript = deriveTranscript(corpusEvents(ROUND_TRIP, "implement"), STEP_ID); + const tools = toTranscriptRows(transcript.messages, transcript.tools).filter( + (row) => row.kind === "tool-call", + ); + expect(tools.length).toBeGreaterThan(0); + for (const row of tools) expect(row.title).toBeUndefined(); + }); +}); diff --git a/src/web/lib/transcript.ts b/src/web/lib/transcript.ts index b8f27fa..820f33b 100644 --- a/src/web/lib/transcript.ts +++ b/src/web/lib/transcript.ts @@ -25,12 +25,27 @@ import type { RunEvent } from "../../events"; * (it ships no renderer); no `@tanstack/ai-react*` surface fits a replayed log. */ +/** + * The CUSTOM chunk the ACP adapter adds after each tool call update that + * carries a title (`ACP_CHUNK.toolCall` in `src/runtime/acp-adapter.ts`; a + * test pins the two together). Its value is `{ toolCallId, title, input? }`. + */ +export const ACP_TOOL_CALL_CHUNK = "acp.tool-call"; + +/** What the agent said about one tool call beyond the AG-UI chunks: its latest title and input. */ +export interface ToolCallInfo { + readonly title: string; + readonly input: unknown; +} + export interface Transcript { readonly stepId: string; /** `AgentStepStarted.prompt`, the header block. Empty if the event is absent. */ readonly prompt: string; /** The step's assistant messages, with the leading prompt echo removed. */ readonly messages: ReadonlyArray; + /** Per `toolCallId`, the latest `acp.tool-call` chunk's title and input. */ + readonly tools: ReadonlyMap; } /** @@ -43,20 +58,39 @@ export interface Transcript { export function deriveTranscript(events: ReadonlyArray, stepId: string): Transcript { let prompt = ""; const chunks: Array = []; + const tools = new Map(); for (const event of events) { const payload = event.payload; if (payload._tag === "AgentStepStarted" && payload.stepId === stepId) { prompt = payload.prompt; } else if (payload._tag === "AgentChunk" && payload.stepId === stepId) { - chunks.push(payload.chunk as unknown as StreamChunk); + const tool = toolCallChunk(payload.chunk); + if (tool !== undefined) { + const previous = tools.get(tool.toolCallId); + tools.set(tool.toolCallId, { + title: tool.title, + input: tool.input !== undefined ? tool.input : previous?.input, + }); + } else chunks.push(payload.chunk as unknown as StreamChunk); } } const processor = new StreamProcessor({}); for (const chunk of chunks) processor.processChunk(chunk); - return { stepId, prompt, messages: stripEchoedPrompt(processor.getMessages(), prompt) }; + return { stepId, prompt, messages: stripEchoedPrompt(processor.getMessages(), prompt), tools }; +} + +function toolCallChunk( + chunk: unknown, +): { toolCallId: string; title: string; input: unknown } | undefined { + if (typeof chunk !== "object" || chunk === null) return undefined; + const record = chunk as { type?: unknown; name?: unknown; value?: unknown }; + if (record.type !== "CUSTOM" || record.name !== ACP_TOOL_CALL_CHUNK) return undefined; + const value = record.value as { toolCallId?: unknown; title?: unknown; input?: unknown }; + if (typeof value?.toolCallId !== "string" || typeof value.title !== "string") return undefined; + return { toolCallId: value.toolCallId, title: value.title, input: value.input }; } /** @@ -88,7 +122,14 @@ export type TranscriptRow = | { readonly kind: "tool-call"; readonly toolCallId: string; + /** The tool's name; on ACP, its kind (`read`, `edit`, `execute`). */ readonly name: string; + /** + * What the agent calls this call (`Edit math.ts`, `git status`): the + * latest `acp.tool-call` title, else a `title` in the arguments (what + * `translateAcpStream` puts there). Absent for pre-ACP logs. + */ + readonly title: string | undefined; readonly args: string; readonly state: string; /** The matching `tool-result` content, joined by `toolCallId`, if it arrived. */ @@ -108,7 +149,10 @@ export type TranscriptRow = * id, never by nesting) and lay every part out as one ordered row list. The * result is emitted with its call, never as its own row. */ -export function toTranscriptRows(messages: ReadonlyArray): ReadonlyArray { +export function toTranscriptRows( + messages: ReadonlyArray, + tools: ReadonlyMap = new Map(), +): ReadonlyArray { const results = new Map(); for (const message of messages) { for (const part of message.parts) { @@ -119,7 +163,7 @@ export function toTranscriptRows(messages: ReadonlyArray): ReadonlyAr const rows: Array = []; for (const message of messages) { for (const part of message.parts) { - const row = rowFor(part, results); + const row = rowFor(part, results, tools); if (row !== undefined) rows.push(row); } } @@ -129,6 +173,7 @@ export function toTranscriptRows(messages: ReadonlyArray): ReadonlyAr function rowFor( part: MessagePart, results: ReadonlyMap, + tools: ReadonlyMap, ): TranscriptRow | undefined { switch (part.type) { case "text": @@ -137,11 +182,15 @@ function rowFor( return { kind: "thinking", content: part.content }; case "tool-call": { const result = results.get(part.id); + const tool = tools.get(part.id); return { kind: "tool-call", toolCallId: part.id, name: part.name, - args: part.arguments, + title: tool?.title ?? titleInArguments(part.arguments), + // The agent's own input beats the processor's string, which for a + // re-sent ACP input is two JSON objects end to end. + args: tool?.input !== undefined ? JSON.stringify(tool.input) : part.arguments, state: part.state, result: result === undefined ? undefined : contentToText(result.content), isError: part.state === "error" || result?.state === "error", @@ -155,6 +204,17 @@ function rowFor( } } +/** A string `title` in a tool call's JSON arguments, as `translateAcpStream` writes them. */ +function titleInArguments(args: string): string | undefined { + try { + const parsed: unknown = JSON.parse(args); + const title = (parsed as { title?: unknown } | null)?.title; + return typeof title === "string" && title !== "" ? title : undefined; + } catch { + return undefined; + } +} + /** `tool-result.content` is a string or multimodal parts; the transcript wants text. */ function contentToText(content: string | ReadonlyArray): string { if (typeof content === "string") return content; diff --git a/src/web/pages/run-detail.tsx b/src/web/pages/run-detail.tsx index a77f3f3..8318cc0 100644 --- a/src/web/pages/run-detail.tsx +++ b/src/web/pages/run-detail.tsx @@ -18,13 +18,22 @@ import { ResizablePanelGroup, } from "@/web/components/ui/resizable"; import { useRun, useRunEvents, useTickingNow } from "@/web/hooks"; -import { agentStepContextTokens, type AgentStepUsage } from "@/events"; -import { formatAgo, formatClock, formatDuration, formatTokenCount } from "@/web/lib/format"; +import type { AgentStepCost, AgentStepUsage } from "@/events"; +import { + formatAgo, + formatClock, + formatContext, + formatCost, + formatDuration, + formatTokenCount, +} from "@/web/lib/format"; import { deriveRunMeta, deriveSteps, + runCost, summarizeEvent, type AgentStepView, + type StepContext, type StepView, } from "@/web/lib/run-events"; import { deriveTranscript, toTranscriptRows, type TranscriptRow } from "@/web/lib/transcript"; @@ -99,6 +108,7 @@ function MetaTable({ dispatched, dedupeKey, collisions, + cost, }: { readonly runId: string; readonly workflowId: string | undefined; @@ -122,6 +132,8 @@ function MetaTable({ readonly holderRunId: string; readonly childWorkflowId: string; }>; + /** Agent-reported cost summed per currency (`runCost`); empty when none was reported. */ + readonly cost: ReadonlyArray; }) { return ( @@ -139,6 +151,15 @@ function MetaTable({ )} {finishedAt !== undefined ? metaRow("finished", formatClock(finishedAt), true) : null} {metaRow("duration", duration, true)} + {metaRow( + "cost", + cost.length > 0 ? ( + {cost.map(formatCost).join(" + ")} + ) : ( + — + ), + true, + )} {/* * Parent ↔ child navigation (issue #14): "origin" links child → parent, * "children" links parent → each dispatched child, straight to run @@ -228,15 +249,19 @@ function stepKindLabel(step: StepView): string { } /** - * "ctx", not "tok": this is the size of the context the step's final turn ran - * against, not the tokens the step burned — `@tanstack/ai-opencode` reports - * only the last of the step's assistant messages. See `AgentStepFinished.usage`. + * "ctx", not "tok": the context in use (`AgentStepView.context`), not the + * tokens the step burned. Live while the step runs, from the agent's own + * usage updates. Cost follows when the agent reported one. */ function stepMeta(step: StepView): string { - if (step.kind === "agent" && step.usage !== undefined) { - return `${step.descriptor} · ${formatTokenCount(agentStepContextTokens(step.usage))} ctx`; + if (step.kind !== "agent") return step.descriptor; + const parts = [step.descriptor]; + if (step.context !== undefined) { + const size = step.context.size !== undefined ? `/${formatTokenCount(step.context.size)}` : ""; + parts.push(`${formatTokenCount(step.context.used)}${size} ctx`); } - return step.descriptor; + if (step.cost !== undefined) parts.push(formatCost(step.cost)); + return parts.join(" · "); } /* @@ -339,6 +364,7 @@ function StepRow({ + {row.args !== "" ?
{row.args}
: null} {row.result !== undefined ? (
; readonly onBack: () => void; }) { - const { prompt, messages } = useMemo( + const { prompt, messages, tools } = useMemo( () => deriveTranscript(events, step.stepId), [events, step.stepId], ); - const rows = useMemo(() => toTranscriptRows(messages), [messages]); + const rows = useMemo(() => toTranscriptRows(messages, tools), [messages, tools]); const live = step.status === "running"; return ( @@ -602,13 +636,44 @@ function TranscriptPanel({ } /** - * The final assistant turn's context size, with its components spelled out so - * the cached prefix — usually the overwhelming majority — is visible rather - * than folded into one opaque figure. - * - * Labelled "context", not "tokens": this is the last turn of the step, not the - * step's cumulative spend, which `@tanstack/ai-opencode` does not report. See - * `AgentStepFinished.usage`. + * The context in use against the window, with a meter when the window is + * known. A `derived` figure (a log from before ACP) is the final turn's + * prompt + completion + cached prefix, and says so. + */ +function ContextField({ context }: { readonly context: StepContext | undefined }) { + if (context === undefined) return "—"; + const share = + context.size !== undefined && context.size > 0 + ? Math.min(1, context.used / context.size) + : undefined; + return ( + + {formatContext(context)} + {share !== undefined ? ( + // Decorative: the figure beside it is the accessible reading. + + + + ) : null} + {context.source === "derived" ? ( + (final turn) + ) : null} + + ); +} + +/** + * The four token components as the agent reports them: the last message on + * opencode, the whole turn on Claude (`AgentStepFinished.usage`), so the + * cached figure can exceed the context. Not a bill; see `cost`. */ function UsageField({ usage }: { readonly usage: AgentStepUsage | undefined }) { if (usage === undefined) return "—"; @@ -618,12 +683,7 @@ function UsageField({ usage }: { readonly usage: AgentStepUsage | undefined }) { `cached ${formatTokenCount(usage.cachedInputTokens)}`, ...(usage.reasoningTokens > 0 ? [`reasoning ${formatTokenCount(usage.reasoningTokens)}`] : []), ]; - return ( - - {formatTokenCount(agentStepContextTokens(usage))} - ({parts.join(" · ")}) - - ); + return {parts.join(" · ")}; } function fieldRow(label: string, value: React.ReactNode) { @@ -654,7 +714,16 @@ function StepDetails({ fieldRow("outcome", ), fieldRow("chunks", String(step.chunkCount)), fieldRow("duration", formatDuration(step.durationMs)), - fieldRow("context", ), + fieldRow("context", ), + fieldRow( + "cost", + step.cost !== undefined ? ( + {formatCost(step.cost)} + ) : ( + "—" + ), + ), + fieldRow("tokens", ), fieldRow("sessionId", step.sessionId ?? "—"), ); break; @@ -1011,6 +1080,7 @@ function RunDetailView({ runId }: { readonly runId: string }) { dispatched={meta.dispatched} dedupeKey={meta.dedupeKey} collisions={meta.collisions} + cost={runCost(steps)} /> From 06db0ad5ec3ee40d83394cc016702fd8a7a5b7f6 Mon Sep 17 00:00:00 2001 From: FreshlyBrewedCode Date: Fri, 2 Oct 2026 20:12:45 +0000 Subject: [PATCH 3/3] fix(web): room for context and cost in the step row Co-Authored-By: Claude Opus 5.5 (1M context) --- src/web/pages/run-detail.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/web/pages/run-detail.tsx b/src/web/pages/run-detail.tsx index 8318cc0..45e7bdc 100644 --- a/src/web/pages/run-detail.tsx +++ b/src/web/pages/run-detail.tsx @@ -366,7 +366,7 @@ function StepRow({ title={stepMeta(step)} data-testid="step-meta" className={cn( - "max-w-[32ch] min-w-0 overflow-hidden text-right font-mono text-[11px] whitespace-nowrap text-ellipsis text-muted-foreground", + "max-w-[44ch] min-w-0 overflow-hidden text-right font-mono text-[11px] whitespace-nowrap text-ellipsis text-muted-foreground", STEP_AREA_META, )} > @@ -417,7 +417,7 @@ function TerminalRow({