diff --git a/packages/cli/src/acp-integration/acpAgent.ts b/packages/cli/src/acp-integration/acpAgent.ts index d725845194..1d917181b7 100644 --- a/packages/cli/src/acp-integration/acpAgent.ts +++ b/packages/cli/src/acp-integration/acpAgent.ts @@ -6153,6 +6153,16 @@ class QwenAgent implements Agent { sendUpdate: async (update) => { updates.push(update); }, + // Fresh accumulator for this replay: MessageEmitter advances it from + // replayed usage metadata (tokens only — no per-turn durations) and + // PlanEmitter snapshots it onto each todo update, so resumed sessions + // recover per-task token spend (API time stays live-only). + cumulativeUsage: { + promptTokens: 0, + cachedTokens: 0, + candidateTokens: 0, + apiTimeMs: 0, + }, }; let replayError: string | undefined; try { diff --git a/packages/cli/src/acp-integration/session/Session.ts b/packages/cli/src/acp-integration/session/Session.ts index 06c074ee49..33b90b12bd 100644 --- a/packages/cli/src/acp-integration/session/Session.ts +++ b/packages/cli/src/acp-integration/session/Session.ts @@ -135,6 +135,7 @@ import { getPersistScopeForModelSelection } from '../../config/modelProvidersSco // Import modular session components import type { ApprovalModeValue, + CumulativeUsage, SessionContext, ToolCallStartParams, } from './types.js'; @@ -417,6 +418,16 @@ export class Session implements SessionContext { private followupAbort: AbortController | null = null; private turn: number = 0; private readonly createdAt: number = Date.now(); + /** + * Running cumulative usage for this session, snapshotted onto each todo/plan + * update by PlanEmitter so the web-shell can show per-task token/API spend. + */ + readonly cumulativeUsage: CumulativeUsage = { + promptTokens: 0, + cachedTokens: 0, + candidateTokens: 0, + apiTimeMs: 0, + }; private readonly runtimeBaseDir: string; // Cron scheduling state diff --git a/packages/cli/src/acp-integration/session/emitters/MessageEmitter.test.ts b/packages/cli/src/acp-integration/session/emitters/MessageEmitter.test.ts index ea19d6d837..941c131efa 100644 --- a/packages/cli/src/acp-integration/session/emitters/MessageEmitter.test.ts +++ b/packages/cli/src/acp-integration/session/emitters/MessageEmitter.test.ts @@ -278,5 +278,106 @@ describe('MessageEmitter', () => { }, }); }); + + it('accumulates token counts and API time into the context cumulative usage', async () => { + const cumulativeUsage = { + promptTokens: 0, + cachedTokens: 0, + candidateTokens: 0, + apiTimeMs: 0, + }; + const ctx: SessionContext = { + sessionId: 'test-session-id', + config: {} as Config, + sendUpdate: sendUpdateSpy, + cumulativeUsage, + }; + const e = new MessageEmitter(ctx); + await e.emitUsageMetadata( + { + promptTokenCount: 100, + candidatesTokenCount: 50, + cachedContentTokenCount: 10, + }, + '', + 800, + ); + await e.emitUsageMetadata( + { + promptTokenCount: 30, + candidatesTokenCount: 20, + cachedContentTokenCount: 5, + }, + '', + 200, + ); + + expect(cumulativeUsage).toEqual({ + promptTokens: 130, + cachedTokens: 15, + candidateTokens: 70, + apiTimeMs: 1000, + }); + }); + + it('accumulates tokens but not API time when no duration is provided (replay)', async () => { + const cumulativeUsage = { + promptTokens: 0, + cachedTokens: 0, + candidateTokens: 0, + apiTimeMs: 0, + }; + const ctx: SessionContext = { + sessionId: 'test-session-id', + config: {} as Config, + sendUpdate: sendUpdateSpy, + cumulativeUsage, + }; + await new MessageEmitter(ctx).emitUsageMetadata({ + promptTokenCount: 100, + candidatesTokenCount: 50, + cachedContentTokenCount: 10, + }); + + expect(cumulativeUsage).toEqual({ + promptTokens: 100, + cachedTokens: 10, + candidateTokens: 50, + apiTimeMs: 0, + }); + }); + + it('skips non-finite usage and durations so they do not poison the accumulator', async () => { + const cumulativeUsage = { + promptTokens: 5, + cachedTokens: 1, + candidateTokens: 2, + apiTimeMs: 100, + }; + const ctx: SessionContext = { + sessionId: 'test-session-id', + config: {} as Config, + sendUpdate: sendUpdateSpy, + cumulativeUsage, + }; + // NaN survives `?? 0` (NaN ?? 0 === NaN); a non-finite duration or token + // would otherwise make every later snapshot NaN forever. + await new MessageEmitter(ctx).emitUsageMetadata( + { + promptTokenCount: Number.NaN, + candidatesTokenCount: 10, + cachedContentTokenCount: Number.POSITIVE_INFINITY, + }, + '', + Number.NaN, + ); + + expect(cumulativeUsage).toEqual({ + promptTokens: 5, // NaN skipped + cachedTokens: 1, // Infinity skipped + candidateTokens: 12, // 2 + 10 + apiTimeMs: 100, // NaN duration skipped + }); + }); }); }); diff --git a/packages/cli/src/acp-integration/session/emitters/MessageEmitter.ts b/packages/cli/src/acp-integration/session/emitters/MessageEmitter.ts index 2e822b9648..0b6149f571 100644 --- a/packages/cli/src/acp-integration/session/emitters/MessageEmitter.ts +++ b/packages/cli/src/acp-integration/session/emitters/MessageEmitter.ts @@ -160,6 +160,41 @@ export class MessageEmitter extends BaseEmitter { cachedReadTokens: usageMetadata.cachedContentTokenCount, }; + // ORDERING INVARIANT: this runs before PlanEmitter.emitPlan within a turn — + // usage advances the cumulative accumulator, then the plan update snapshots + // it. Reordering or batching emissions so a plan is sent before its turn's + // usage would zero out that task's per-task stats. + // + // Only fold in finite values: a NaN/Infinity from a provider (or a NaN that + // slips through `?? 0`, since `NaN ?? 0 === NaN`) would poison the running + // total forever (`NaN + x === NaN`), so every later snapshot would fail + // extractTodoStats's Number.isFinite check and silently show "not captured" + // for the rest of the session. apiTimeMs only advances on the live path + // (a per-turn duration is present), keeping API time live-only on replay. + const cumulative = this.ctx.cumulativeUsage; + if (cumulative) { + const addFinite = ( + total: number, + value: number | null | undefined, + ): number => + typeof value === 'number' && Number.isFinite(value) + ? total + value + : total; + cumulative.promptTokens = addFinite( + cumulative.promptTokens, + usage.inputTokens, + ); + cumulative.candidateTokens = addFinite( + cumulative.candidateTokens, + usage.outputTokens, + ); + cumulative.cachedTokens = addFinite( + cumulative.cachedTokens, + usage.cachedReadTokens, + ); + cumulative.apiTimeMs = addFinite(cumulative.apiTimeMs, durationMs); + } + const meta = typeof durationMs === 'number' ? { usage, durationMs, ...subagentMeta } diff --git a/packages/cli/src/acp-integration/session/emitters/PlanEmitter.test.ts b/packages/cli/src/acp-integration/session/emitters/PlanEmitter.test.ts index 4140fb33a5..91c4e171b0 100644 --- a/packages/cli/src/acp-integration/session/emitters/PlanEmitter.test.ts +++ b/packages/cli/src/acp-integration/session/emitters/PlanEmitter.test.ts @@ -54,6 +54,37 @@ describe('PlanEmitter', () => { }); }); + it('omits _meta.stats when the context has no cumulative usage', async () => { + await emitter.emitPlan([{ id: '1', content: 'Task', status: 'pending' }]); + + const update = sendUpdateSpy.mock.calls[0][0]; + expect(update['_meta']).toBeUndefined(); + }); + + it('stamps a copy of the cumulative usage on _meta.stats when present', async () => { + const cumulativeUsage = { + promptTokens: 100, + cachedTokens: 10, + candidateTokens: 20, + apiTimeMs: 500, + }; + const ctx: SessionContext = { + sessionId: 'test-session-id', + config: {} as Config, + sendUpdate: sendUpdateSpy, + cumulativeUsage, + }; + await new PlanEmitter(ctx).emitPlan([ + { id: '1', content: 'Task', status: 'completed' }, + ]); + + const update = sendUpdateSpy.mock.calls[0][0]; + expect(update['_meta']).toEqual({ stats: { ...cumulativeUsage } }); + // Snapshot is a copy: later accumulation must not mutate what was sent. + cumulativeUsage.promptTokens = 999; + expect(update['_meta'].stats.promptTokens).toBe(100); + }); + it('should set default priority to medium for all entries', async () => { const todos: TodoItem[] = [ { id: '1', content: 'Task', status: 'pending' }, diff --git a/packages/cli/src/acp-integration/session/emitters/PlanEmitter.ts b/packages/cli/src/acp-integration/session/emitters/PlanEmitter.ts index 3556e03024..540203f736 100644 --- a/packages/cli/src/acp-integration/session/emitters/PlanEmitter.ts +++ b/packages/cli/src/acp-integration/session/emitters/PlanEmitter.ts @@ -28,9 +28,20 @@ export class PlanEmitter extends BaseEmitter { status: todo.status, })); + // Snapshot the running cumulative usage as a per-snapshot baseline. The + // web-shell diffs consecutive snapshots to attribute tokens/API time to the + // task that ran between two todo updates. Copied so later accumulation + // doesn't mutate this snapshot. + // + // ORDERING INVARIANT: the turn's usage must have been folded into + // cumulativeUsage (MessageEmitter.emitUsageMetadata) before this snapshot — + // emitting a plan ahead of its turn's usage would record a stale baseline + // and zero out that task's stats. + const cumulative = this.ctx.cumulativeUsage; await this.sendUpdate({ sessionUpdate: 'plan', entries, + ...(cumulative ? { _meta: { stats: { ...cumulative } } } : {}), }); } diff --git a/packages/cli/src/acp-integration/session/types.ts b/packages/cli/src/acp-integration/session/types.ts index f7259ca86e..f79c41ae76 100644 --- a/packages/cli/src/acp-integration/session/types.ts +++ b/packages/cli/src/acp-integration/session/types.ts @@ -28,6 +28,24 @@ export interface SessionUpdateSender { sendUpdate(update: SessionUpdate): Promise; } +/** + * Running cumulative usage for the conversation, mutated in place as usage + * metadata is emitted (MessageEmitter) and snapshotted onto each plan/todo + * update (PlanEmitter). The web-shell diffs consecutive snapshots to show a + * finished task's token/time spend. + * + * `apiTimeMs` only advances on the live path: history replay re-emits usage + * metadata without per-turn durations, so on `/resume` it stays 0 — the + * intended "API time is live-only" behaviour. Tokens accumulate on both paths + * because replayed usage metadata carries the counts. + */ +export interface CumulativeUsage { + promptTokens: number; + cachedTokens: number; + candidateTokens: number; + apiTimeMs: number; +} + /** * Session context shared across all emitters. * Provides access to session state and configuration. @@ -38,6 +56,13 @@ export interface SessionContext extends SessionUpdateSender { /** Optional message rewrite middleware for ACP message transformation. * Installed after history replay to avoid rewriting historical messages. */ messageRewriter?: MessageRewriteMiddleware; + /** + * Running cumulative usage, when the context wants per-todo resource detail. + * Mutated by MessageEmitter as usage is emitted and read by PlanEmitter to + * stamp plan updates. Optional so contexts that don't need it (export, etc.) + * can omit it. + */ + readonly cumulativeUsage?: CumulativeUsage; } /** diff --git a/packages/sdk-typescript/src/daemon/ui/normalizer.ts b/packages/sdk-typescript/src/daemon/ui/normalizer.ts index 9a58330268..3c231bb5c8 100644 --- a/packages/sdk-typescript/src/daemon/ui/normalizer.ts +++ b/packages/sdk-typescript/src/daemon/ui/normalizer.ts @@ -654,6 +654,11 @@ function normalizePlanUpdate( base.eventId !== undefined ? `${DAEMON_PLAN_TOOL_CALL_ID}-${base.eventId}` : DAEMON_PLAN_TOOL_CALL_ID; + // Carry the cumulative-usage snapshot the agent stamps on each plan update + // (PlanEmitter) through to rawOutput, so the web-shell can diff consecutive + // todo snapshots into per-task token/time detail. + const meta = isRecord(update['_meta']) ? update['_meta'] : undefined; + const stats = meta && isRecord(meta['stats']) ? meta['stats'] : undefined; return { ...base, type: 'tool.update', @@ -668,7 +673,7 @@ function normalizePlanUpdate( content: { type: 'text', text: contentText }, }, ], - rawOutput: { entries }, + rawOutput: stats ? { entries, stats } : { entries }, }; } diff --git a/packages/sdk-typescript/test/unit/daemonUi.test.ts b/packages/sdk-typescript/test/unit/daemonUi.test.ts index 0946935d01..f9b658fc2b 100644 --- a/packages/sdk-typescript/test/unit/daemonUi.test.ts +++ b/packages/sdk-typescript/test/unit/daemonUi.test.ts @@ -64,6 +64,58 @@ describe('daemon UI normalizer and transcript reducer', () => { ]); }); + it('passes the agent-stamped plan stats snapshot through to rawOutput', () => { + const events = normalizeDaemonEvent({ + id: 5, + v: 1, + type: 'session_update', + data: { + update: { + sessionUpdate: 'plan', + entries: [ + { content: 'Task', status: 'completed', priority: 'medium' }, + ], + _meta: { + stats: { + promptTokens: 100, + cachedTokens: 10, + candidateTokens: 20, + apiTimeMs: 500, + }, + }, + }, + }, + }); + + expect(events).toHaveLength(1); + expect(events[0]).toMatchObject({ + type: 'tool.update', + toolName: 'TodoWrite', + rawOutput: { + entries: [{ content: 'Task', status: 'completed', priority: 'medium' }], + stats: { + promptTokens: 100, + cachedTokens: 10, + candidateTokens: 20, + apiTimeMs: 500, + }, + }, + }); + }); + + it('omits stats from a plan rawOutput when the update carries none', () => { + const events = normalizeDaemonEvent({ + id: 6, + v: 1, + type: 'session_update', + data: { update: { sessionUpdate: 'plan', entries: [] } }, + }); + + const rawOutput = (events[0] as { rawOutput: Record }) + .rawOutput; + expect(rawOutput).toEqual({ entries: [] }); + }); + it('keeps optimistic local user blocks before daemon replies when sorting', () => { let state = createDaemonTranscriptState({ now: 1 }); state = appendLocalUserTranscriptMessage(state, 'hello', { now: 10 }); diff --git a/packages/web-shell/client/App.tsx b/packages/web-shell/client/App.tsx index 968329496e..33f618416b 100644 --- a/packages/web-shell/client/App.tsx +++ b/packages/web-shell/client/App.tsx @@ -5,6 +5,7 @@ import { useMemo, useRef, useState, + type ReactNode, } from 'react'; import { useActions, @@ -121,9 +122,12 @@ import { TASKS_STATUS_ACTIVE_EVENT } from './components/messages/TasksStatusMess import { BtwMessage } from './components/messages/BtwMessage'; import type { ACPToolCall, Message, PermissionRequest } from './adapters/types'; import { + computeTodoDetails, computeTodoTimeline, getFloatingTodos, + todoDetailSignature, todoTimelineSignature, + type TodoDetail, type TodoSnapshotDiff, } from './utils/todos'; import { ThemeProvider } from './themeContext'; @@ -155,6 +159,38 @@ export const TodoTimelineContext = createContext>( new Map(), ); +/** + * Per-todo timing and resource detail keyed by todoStateKey, consumed by the + * expanded todo list so a finished task can reveal when it ran and what it + * spent. Empty by default so a row rendered outside the provider (or in tests) + * simply shows no expander. + */ +export const TodoDetailContext = createContext>( + new Map(), +); + +/** + * Provides both todo contexts in one wrapper so the message list stays at a + * single nesting level (one provider in the tree, not two). + */ +function TodoContextsProvider({ + timeline, + details, + children, +}: { + timeline: Map; + details: Map; + children: ReactNode; +}) { + return ( + + + {children} + + + ); +} + const MODES_CYCLE = DAEMON_APPROVAL_MODES; const MAX_DISPLAYED_QUEUED_PROMPTS = 3; const MAX_QUEUED_PROMPT_PREVIEW_CHARS = 240; @@ -703,6 +739,25 @@ export function App({ todoTimelineRef.current = { signature, timeline }; return timeline; }, [messages]); + // Per-todo detail (start/end + token/API/tool spend) is derived entirely from + // the transcript: the agent stamps a cumulative-usage snapshot on each todo + // update and the web-shell diffs consecutive snapshots, so this works live and + // on resume with no polling. Kept referentially stable like the timeline + // above (rebuilt only when a relevant snapshot, timestamp, stat, or tool span + // changes) so an unrelated streaming tick doesn't re-render every expanded + // todo row that consumes TodoDetailContext. + const todoDetailRef = useRef<{ + signature: string; + details: Map; + } | null>(null); + const todoDetails = useMemo(() => { + const signature = todoDetailSignature(messages); + const cached = todoDetailRef.current; + if (cached && cached.signature === signature) return cached.details; + const details = computeTodoDetails(messages); + todoDetailRef.current = { signature, details }; + return details; + }, [messages]); const floatingTodos = useStableArray( floatingTodosState.todos, (t) => `${t.id}:${t.status}:${t.content}`, @@ -2583,7 +2638,10 @@ export function App({ - +
- + diff --git a/packages/web-shell/client/components/messages/PlanMessage.test.tsx b/packages/web-shell/client/components/messages/PlanMessage.test.tsx index 4b2df45aad..11fcbf14d9 100644 --- a/packages/web-shell/client/components/messages/PlanMessage.test.tsx +++ b/packages/web-shell/client/components/messages/PlanMessage.test.tsx @@ -5,11 +5,15 @@ import { createRoot, type Root } from 'react-dom/client'; import { I18nProvider } from '../../i18n'; import type { TodoItem } from '../../adapters/types'; -// PlanMessage imports App only for TodoTimelineContext; mock it so the unit -// test doesn't pull the whole application graph. +// PlanMessage's expanded list reads TodoTimelineContext and (via TodoFullList) +// TodoDetailContext from App; mock both so the unit test doesn't pull the whole +// application graph. vi.mock('../../App', async () => { const { createContext } = await import('react'); - return { TodoTimelineContext: createContext(new Map()) }; + return { + TodoTimelineContext: createContext(new Map()), + TodoDetailContext: createContext(new Map()), + }; }); const { PlanMessage } = await import('./PlanMessage'); diff --git a/packages/web-shell/client/components/messages/StatsMessage.tsx b/packages/web-shell/client/components/messages/StatsMessage.tsx index c288408d35..4639d537bb 100644 --- a/packages/web-shell/client/components/messages/StatsMessage.tsx +++ b/packages/web-shell/client/components/messages/StatsMessage.tsx @@ -35,7 +35,7 @@ export function parseStatsMessage(content: string): ParsedStats | null { } } -function formatDuration(ms: number): string { +export function formatDuration(ms: number): string { if (ms <= 0) return '0s'; if (ms < 1000) return `${Math.round(ms)}ms`; const totalSeconds = ms / 1000; diff --git a/packages/web-shell/client/components/messages/TodoView.module.css b/packages/web-shell/client/components/messages/TodoView.module.css index aefd7678af..16a4f6189b 100644 --- a/packages/web-shell/client/components/messages/TodoView.module.css +++ b/packages/web-shell/client/components/messages/TodoView.module.css @@ -57,3 +57,82 @@ text-decoration: line-through; text-decoration-color: var(--text-dimmed); } + +/* Each expanded-list entry stacks its row above an optional detail panel. */ +.item { + display: flex; + flex-direction: column; +} + +/* A row that reveals task detail on click — reset the button chrome so it + reads exactly like the static rows around it. */ +.rowButton { + width: 100%; + margin: 0; + padding: 0; + border: none; + background: none; + font: inherit; + color: inherit; + text-align: left; + cursor: pointer; +} + +.detailChevron { + flex-shrink: 0; + margin-left: auto; + padding-left: 8px; + color: var(--text-dimmed); +} + +/* Indented under its row and aligned past the status icon. */ +.detail { + display: flex; + flex-direction: column; + gap: 6px; + margin: 3px 0 6px 20px; + padding-left: 8px; + border-left: 1px solid var(--border-color); + font-size: 12px; +} + +.detailSection { + display: flex; + flex-direction: column; + gap: 2px; +} + +.detailSectionTitle { + color: var(--text-secondary); + font-size: 11px; +} + +/* Label and value share two grid columns so every row in the section aligns: + labels in a tight first column, values left-aligned right after them rather + than stretched to the far edge. */ +.detailRows { + display: grid; + grid-template-columns: max-content auto; + column-gap: 12px; + row-gap: 1px; + padding-left: 10px; +} + +.detailLabel { + color: var(--text-dimmed); +} + +.detailValue { + color: var(--text-secondary); + font-variant-numeric: tabular-nums; +} + +/* The elapsed duration trails the end time as a dimmed parenthetical. */ +.detailDuration { + color: var(--text-dimmed); +} + +.detailHint { + color: var(--text-dimmed); + font-style: italic; +} diff --git a/packages/web-shell/client/components/messages/TodoView.test.tsx b/packages/web-shell/client/components/messages/TodoView.test.tsx new file mode 100644 index 0000000000..e40be1146e --- /dev/null +++ b/packages/web-shell/client/components/messages/TodoView.test.tsx @@ -0,0 +1,156 @@ +// @vitest-environment jsdom +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { I18nProvider } from '../../i18n'; +import type { TodoItem } from '../../adapters/types'; +import { todoStateKey, type TodoDetail } from '../../utils/todos'; + +// TodoFullList reads TodoDetailContext from App; mock it so the unit test +// doesn't pull the whole application graph and can inject its own detail map. +vi.mock('../../App', async () => { + const { createContext } = await import('react'); + return { TodoDetailContext: createContext(new Map()) }; +}); + +const { TodoFullList } = await import('./TodoView'); +const { TodoDetailContext } = await import('../../App'); + +( + globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean } +).IS_REACT_ACT_ENVIRONMENT = true; + +const mounted: Array<{ root: Root; container: HTMLElement }> = []; + +afterEach(() => { + for (const { root, container } of mounted.splice(0)) { + act(() => root.unmount()); + container.remove(); + } +}); + +function todo( + id: string, + content: string, + status: TodoItem['status'], +): TodoItem { + return { id, content, status }; +} + +function keyOf(id: string, content: string): string { + return todoStateKey(todo(id, content, 'completed')); +} + +function render( + todos: TodoItem[], + details: Map, + onParentClick?: () => void, +): HTMLElement { + const container = document.createElement('div'); + document.body.appendChild(container); + const root = createRoot(container); + act(() => { + root.render( + + + {/* Parent click handler stands in for the surrounding todo_write + tool-row header, to assert the expander click doesn't bubble. */} +
+ +
+
+
, + ); + }); + mounted.push({ root, container }); + return container; +} + +function click(el: Element): void { + act(() => { + el.dispatchEvent(new MouseEvent('click', { bubbles: true })); + }); +} + +const TODOS = [ + todo('1', 'Done with stats', 'completed'), + todo('2', 'Done without stats', 'completed'), + todo('3', 'Running', 'in_progress'), + todo('4', 'Later', 'pending'), +]; + +describe('TodoFullList detail', () => { + it('makes only completed tasks with detail expandable', () => { + const details = new Map([ + [keyOf('1', 'Done with stats'), { startTs: 1000, endTs: 4000 }], + [keyOf('2', 'Done without stats'), { endTs: 4000 }], + ]); + const container = render(TODOS, details); + // Items 1 and 2 are completed with detail; 3 (in_progress) and 4 (pending) + // carry no detail entry and stay plain rows. + expect(container.querySelectorAll('button')).toHaveLength(2); + }); + + it('reveals the token and time breakdown on click', () => { + const details = new Map([ + [ + keyOf('1', 'Done with stats'), + { + startTs: 1000, + endTs: 4000, + resources: { + inputTokens: 1234, + cachedTokens: 200, + outputTokens: 567, + apiTimeMs: 2500, + toolTimeMs: 800, + }, + }, + ], + ]); + const container = render([TODOS[0]], details); + const button = container.querySelector('button'); + expect(button).not.toBeNull(); + // Collapsed: no metric rows yet. + expect(container.textContent).not.toContain('1,234'); + + click(button!); + const text = container.textContent ?? ''; + // Section headers group the metrics. + expect(text).toContain('Tokens'); + expect(text).toContain('Time spent'); + // Token values. + expect(text).toContain('1,234'); + expect(text).toContain('567'); + // Time-spent values. + expect(text).toContain('2.5s'); + expect(text).toContain('800ms'); + // 4000 - 1000 ms window. + expect(text).toContain('3.0s'); + }); + + it('shows the not-captured hint for a completed task without resources', () => { + const details = new Map([ + [keyOf('2', 'Done without stats'), { startTs: 1000, endTs: 4000 }], + ]); + const container = render([TODOS[1]], details); + click(container.querySelector('button')!); + const text = container.textContent ?? ''; + expect(text).toContain("wasn't captured"); + expect(text).toContain('Time'); // the Time section still renders + expect(text).not.toContain('Time spent'); // but not the token/spent groups + }); + + it('does not bubble the expander click to a surrounding click handler', () => { + // The detail expander lives inside the todo_write tool row, whose header + // toggles the whole list on click. The button must stopPropagation so + // expanding a task never collapses its list. + const details = new Map([ + [keyOf('1', 'Done with stats'), { startTs: 1000, endTs: 4000 }], + ]); + const parentClick = vi.fn(); + const container = render([TODOS[0]], details, parentClick); + click(container.querySelector('button')!); + expect(parentClick).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/web-shell/client/components/messages/TodoView.tsx b/packages/web-shell/client/components/messages/TodoView.tsx index 00a071587b..25d25da915 100644 --- a/packages/web-shell/client/components/messages/TodoView.tsx +++ b/packages/web-shell/client/components/messages/TodoView.tsx @@ -1,5 +1,14 @@ +import { useContext, useState, type ReactNode } from 'react'; import type { TodoItem } from '../../adapters/types'; -import { getTodoStatusIcon, type TodoEvent } from '../../utils/todos'; +import { + getTodoStatusIcon, + todoStateKey, + type TodoDetail, + type TodoEvent, +} from '../../utils/todos'; +import { TodoDetailContext } from '../../App'; +import { formatTimestamp } from '../MessageTimestamp'; +import { formatDuration } from './StatsMessage'; import { useI18n } from '../../i18n'; import styles from './TodoView.module.css'; @@ -81,6 +90,135 @@ export function TodoEventSummary({ ); } +function DetailRow({ + label, + value, + suffix, +}: { + label: string; + value: string; + suffix?: ReactNode; +}) { + // Two bare grid cells so every row's labels and values align in shared + // columns within a section (see .detailRows). + return ( + <> + {label} + + {value} + {suffix} + + + ); +} + +function DetailSection({ + title, + children, +}: { + title: string; + children: ReactNode; +}) { + return ( +
+
{title}
+
{children}
+
+ ); +} + +/** + * Timing and resource breakdown for one finished task, grouped into Time / + * Tokens / Time-spent sections. Start/end come from the transcript so they show + * even on a restored session. Token and time-spent rows render only for the + * fields that were measured (tokens absent without stamped snapshots, API time + * absent on resume, tool time absent when no tools ran); when nothing was + * measured a short hint explains the absence. + */ +function TodoDetailBlock({ detail }: { detail: TodoDetail }) { + const { t } = useI18n(); + const { startTs, endTs, resources } = detail; + const hasTime = startTs !== undefined || endTs !== undefined; + const hasTokens = resources?.inputTokens !== undefined; + const hasSpent = + resources?.apiTimeMs !== undefined || resources?.toolTimeMs !== undefined; + return ( +
+ {hasTime && ( + + {startTs !== undefined && ( + + )} + {endTs !== undefined && ( + + {' '} + ({formatDuration(endTs - startTs)}) + + ) : undefined + } + /> + )} + + )} + {hasTokens && ( + + + + + + )} + {hasSpent && ( + + {resources?.apiTimeMs !== undefined && ( + + )} + {resources?.toolTimeMs !== undefined && ( + + )} + + )} + {!resources && ( +
{t('todo.detail.noResources')}
+ )} +
+ ); +} + +/** + * Only finished tasks are expandable — `endTs` and `resources` are both set on + * the completed transition, so either marks completion. An in_progress item + * (which carries just `startTs`) stays a plain row, matching the feature's + * focus on completed tasks and avoiding a half-empty detail panel mid-run. + */ +function hasTodoDetail(detail: TodoDetail | undefined): detail is TodoDetail { + return ( + !!detail && (detail.endTs !== undefined || detail.resources !== undefined) + ); +} + /** Expanded view: the full list. `numbered` adds the 1. 2. 3. index column. */ export function TodoFullList({ todos, @@ -89,27 +227,73 @@ export function TodoFullList({ todos: TodoItem[]; numbered?: boolean; }) { + const { t } = useI18n(); + const details = useContext(TodoDetailContext); + const [expanded, setExpanded] = useState>( + () => new Set(), + ); // Size the number column to the widest index so the markers stay aligned once // the list grows past 9 items. const numColumnWidth = `${String(todos.length).length + 1}ch`; + const toggle = (rowKey: string) => + setExpanded((prev) => { + const next = new Set(prev); + if (next.has(rowKey)) next.delete(rowKey); + else next.add(rowKey); + return next; + }); return (
- {todos.map((todo, index) => ( -
- {numbered && ( - - {index + 1}. + {todos.map((todo, index) => { + const rowKey = todo.id || String(index); + const detail = details.get(todoStateKey(todo)); + const expandable = hasTodoDetail(detail); + const isOpen = expandable && expanded.has(rowKey); + const rowInner = ( + <> + {numbered && ( + + {index + 1}. + + )} + - )} - - {todo.content} -
- ))} + {todo.content} + {expandable && ( + + )} + + ); + return ( +
+ {expandable ? ( + + ) : ( +
+ {rowInner} +
+ )} + {isOpen && detail && } +
+ ); + })}
); } diff --git a/packages/web-shell/client/components/messages/ToolGroup.test.tsx b/packages/web-shell/client/components/messages/ToolGroup.test.tsx index c89f87150a..3bdcbba398 100644 --- a/packages/web-shell/client/components/messages/ToolGroup.test.tsx +++ b/packages/web-shell/client/components/messages/ToolGroup.test.tsx @@ -5,14 +5,15 @@ import { createRoot, type Root } from 'react-dom/client'; import { I18nProvider } from '../../i18n'; import type { ACPToolCall } from '../../adapters/types'; -// ToolGroup imports App only for CompactModeContext and TodoTimelineContext; -// loading the real App module would pull the whole application graph into this -// unit test. +// ToolGroup imports App for CompactModeContext and TodoTimelineContext, and its +// expanded todo list (via TodoFullList) reads TodoDetailContext; loading the +// real App module would pull the whole application graph into this unit test. vi.mock('../../App', async () => { const { createContext } = await import('react'); return { CompactModeContext: createContext(false), TodoTimelineContext: createContext(new Map()), + TodoDetailContext: createContext(new Map()), }; }); diff --git a/packages/web-shell/client/i18n.tsx b/packages/web-shell/client/i18n.tsx index c0da6e8326..3b64ddea4a 100644 --- a/packages/web-shell/client/i18n.tsx +++ b/packages/web-shell/client/i18n.tsx @@ -751,6 +751,20 @@ const EN: Messages = { 'todo.allDone': 'All tasks completed', 'todo.collapse': 'Collapse task list', 'todo.completedAbove': (v) => `✓ ${v?.count ?? 0} completed`, + 'todo.detail.api': 'API', + 'todo.detail.cached': 'Cached', + 'todo.detail.end': 'End', + 'todo.detail.hide': 'Hide task detail', + 'todo.detail.input': 'Input', + 'todo.detail.noResources': + "Token & time usage wasn't captured for this task.", + 'todo.detail.output': 'Output', + 'todo.detail.sectionSpent': 'Time spent', + 'todo.detail.sectionTime': 'Time', + 'todo.detail.sectionTokens': 'Tokens', + 'todo.detail.show': 'Show task detail', + 'todo.detail.start': 'Start', + 'todo.detail.tool': 'Tool', 'todo.expand': 'Expand task list', 'todo.locate': 'Show in transcript', 'todo.more': (v) => `... ${v?.count ?? 0} more`, @@ -1501,22 +1515,22 @@ const ZH: Messages = { 'stats.avgDuration': '平均耗时', 'stats.avgLatency': '平均延迟', 'stats.cached': '缓存', - 'stats.cacheDesc': '的输入令牌来自缓存,降低了成本。', + 'stats.cacheDesc': '的输入 Token 来自缓存,降低了成本。', 'stats.calls': '调用次数', 'stats.codeChanges': '代码变更', 'stats.decisionSummary': '用户决策摘要', 'stats.duration': '时长', 'stats.errors': '错误数', - 'stats.inputTokens': '输入令牌', + 'stats.inputTokens': '输入 Token', 'stats.metric': '指标', 'stats.modelStats': '模型统计(技术细节)', - 'stats.modelTip': '提示:运行 /stats model 查看完整令牌明细。', + 'stats.modelTip': '提示:运行 /stats model 查看完整 Token 明细。', 'stats.modelUsage': '模型使用', 'stats.modified': '已修改:', 'stats.noApiCalls': '本次会话暂无 API 调用。', 'stats.noToolCalls': '本次会话暂无工具调用。', 'stats.output': '输出', - 'stats.outputTokens': '输出令牌', + 'stats.outputTokens': '输出 Token', 'stats.overview': '会话概览', 'stats.performance': '性能', 'stats.prompt': '提示', @@ -1528,7 +1542,7 @@ const ZH: Messages = { 'stats.successRate': '成功率', 'stats.thoughts': '思考', 'stats.title': '会话统计', - 'stats.tokens': '令牌', + 'stats.tokens': 'Token', 'stats.toolCalls': '工具调用', 'stats.toolName': '工具名称', 'stats.toolStats': '工具统计(技术细节)', @@ -1551,6 +1565,19 @@ const ZH: Messages = { 'todo.allDone': '任务已全部完成', 'todo.collapse': '折叠任务列表', 'todo.completedAbove': (v) => `✓ 已完成 ${v?.count ?? 0} 项`, + 'todo.detail.api': 'API', + 'todo.detail.cached': '缓存', + 'todo.detail.end': '结束', + 'todo.detail.hide': '收起任务明细', + 'todo.detail.input': '输入', + 'todo.detail.noResources': '未采集到该任务的 Token 与耗时明细。', + 'todo.detail.output': '输出', + 'todo.detail.sectionSpent': '耗时', + 'todo.detail.sectionTime': '时间', + 'todo.detail.sectionTokens': 'Token', + 'todo.detail.show': '查看任务明细', + 'todo.detail.start': '开始', + 'todo.detail.tool': '工具', 'todo.expand': '展开任务列表', 'todo.locate': '在会话中定位', 'todo.more': (v) => `... 还有 ${v?.count ?? 0} 项`, diff --git a/packages/web-shell/client/utils/todos.test.ts b/packages/web-shell/client/utils/todos.test.ts index 32d8a781ae..b1b07be573 100644 --- a/packages/web-shell/client/utils/todos.test.ts +++ b/packages/web-shell/client/utils/todos.test.ts @@ -1,13 +1,19 @@ import { describe, expect, it } from 'vitest'; +import { normalizeDaemonEvent } from '@qwen-code/sdk/daemon'; import type { ACPToolCall, Message, TodoItem } from '../adapters/types'; import { + computeTodoDetails, computeTodoTimeline, + extractTodoStats, extractTodosFromToolCall, getFloatingTodos, getTodoStatusIcon, getTodoWindow, isTodoWriteToolName, + todoDetailSignature, + todoStateKey, todoTimelineSignature, + type TodoStatsSnapshot, } from './todos'; function todo(id: string, status: TodoItem['status']): TodoItem { @@ -26,17 +32,40 @@ function planMessage(id: string, todos: TodoItem[]): Message { return { id, role: 'plan', todos }; } -function todoWriteMessage(id: string, todos: TodoItem[]): Message { +function todoWriteMessage( + id: string, + todos: TodoItem[], + stats?: TodoStatsSnapshot, +): Message { + const tool: ACPToolCall = { + callId: `call-${id}`, + toolName: 'todo_write', + status: 'completed', + kind: 'think', + args: { todos }, + ...(stats ? { rawOutput: { stats } } : {}), + }; + return { id, role: 'tool_group', tools: [tool] }; +} + +/** A non-todo tool call carrying a wall-clock span, used for tool-time tests. */ +function toolMessage( + id: string, + startTime: number, + endTime: number, + toolName = 'read', +): Message { return { id, role: 'tool_group', tools: [ { - callId: `call-${id}`, - toolName: 'todo_write', + callId: `tc-${id}`, + toolName, status: 'completed', - kind: 'think', - args: { todos }, + kind: 'read', + startTime, + endTime, }, ], }; @@ -471,3 +500,524 @@ describe('getTodoWindow', () => { expect(getTodoWindow(todos, 5)).toEqual({ start: 0, end: 5 }); }); }); + +function stats( + promptTokens: number, + cachedTokens: number, + candidateTokens: number, + apiTimeMs: number, +): TodoStatsSnapshot { + return { promptTokens, cachedTokens, candidateTokens, apiTimeMs }; +} + +const at = (message: Message, timestamp: number): Message => ({ + ...message, + timestamp, +}); + +describe('extractTodoStats', () => { + function toolCall(rawOutput: unknown): ACPToolCall { + return { + callId: 'c1', + toolName: 'todo_write', + status: 'completed', + kind: 'think', + rawOutput, + }; + } + + it('reads a complete stats snapshot from rawOutput', () => { + expect( + extractTodoStats( + toolCall({ + stats: { + promptTokens: 1, + cachedTokens: 2, + candidateTokens: 3, + apiTimeMs: 4, + }, + }), + ), + ).toEqual({ + promptTokens: 1, + cachedTokens: 2, + candidateTokens: 3, + apiTimeMs: 4, + }); + }); + + it('returns undefined when stats is absent', () => { + expect(extractTodoStats(toolCall({ entries: [] }))).toBeUndefined(); + }); + + it('returns undefined when a token field is missing or non-numeric', () => { + expect( + extractTodoStats( + toolCall({ + // candidateTokens missing + stats: { promptTokens: 1, cachedTokens: 2, apiTimeMs: 4 }, + }), + ), + ).toBeUndefined(); + expect( + extractTodoStats( + toolCall({ + stats: { + promptTokens: '1', // non-numeric + cachedTokens: 2, + candidateTokens: 3, + apiTimeMs: 4, + }, + }), + ), + ).toBeUndefined(); + }); + + it('defaults the live-only apiTimeMs to 0 when omitted, keeping the tokens', () => { + expect( + extractTodoStats( + toolCall({ + stats: { promptTokens: 1, cachedTokens: 2, candidateTokens: 3 }, + }), + ), + ).toEqual({ + promptTokens: 1, + cachedTokens: 2, + candidateTokens: 3, + apiTimeMs: 0, + }); + }); + + it('treats a non-finite apiTimeMs as 0 rather than dropping the snapshot', () => { + expect( + extractTodoStats( + toolCall({ + stats: { + promptTokens: 1, + cachedTokens: 2, + candidateTokens: 3, + apiTimeMs: Number.NaN, + }, + }), + ), + ).toEqual({ + promptTokens: 1, + cachedTokens: 2, + candidateTokens: 3, + apiTimeMs: 0, + }); + }); +}); + +describe('computeTodoDetails', () => { + it('records start and end timestamps from the transcript with no stats', () => { + const details = computeTodoDetails([ + at(planMessage('p1', [todo('1', 'in_progress')]), 1000), + at(planMessage('p2', [todo('1', 'completed')]), 5000), + ]); + expect(details.get(todoStateKey(todo('1', 'pending')))).toEqual({ + startTs: 1000, + endTs: 5000, + }); + }); + + it('derives token and API resources from the diff of the boundary snapshots', () => { + const details = computeTodoDetails([ + at( + todoWriteMessage( + 'm1', + [todo('1', 'in_progress')], + stats(100, 10, 20, 500), + ), + 1000, + ), + at( + todoWriteMessage( + 'm2', + [todo('1', 'completed')], + stats(300, 40, 80, 1500), + ), + 5000, + ), + ]); + expect(details.get(todoStateKey(todo('1', 'pending')))).toEqual({ + startTs: 1000, + endTs: 5000, + resources: { + inputTokens: 200, + cachedTokens: 30, + outputTokens: 60, + apiTimeMs: 1000, + }, + }); + }); + + it('omits token resources when the start boundary has no stamped snapshot', () => { + const details = computeTodoDetails([ + at(todoWriteMessage('m1', [todo('1', 'in_progress')]), 1000), + at( + todoWriteMessage( + 'm2', + [todo('1', 'completed')], + stats(300, 40, 80, 1500), + ), + 5000, + ), + ]); + expect(details.get(todoStateKey(todo('1', 'pending')))).toEqual({ + startTs: 1000, + endTs: 5000, + }); + }); + + it('clamps an individually shrinking token field to zero', () => { + // Tokens shrink (clamp to 0) while API time grows, so the diff is not + // all-zero and stays surfaced — exercising the per-field clamp. + const details = computeTodoDetails([ + at( + todoWriteMessage( + 'm1', + [todo('1', 'in_progress')], + stats(300, 40, 80, 1500), + ), + 1000, + ), + at( + todoWriteMessage( + 'm2', + [todo('1', 'completed')], + stats(100, 10, 20, 1600), + ), + 5000, + ), + ]); + expect(details.get(todoStateKey(todo('1', 'pending')))?.resources).toEqual({ + inputTokens: 0, + cachedTokens: 0, + outputTokens: 0, + apiTimeMs: 100, + }); + }); + + it('treats an all-zero token diff as not captured, keeping the timestamps', () => { + // Equal (or fully shrinking) snapshots measure nothing; the task still + // shows start/end but no misleading row of zeros. + const details = computeTodoDetails([ + at( + todoWriteMessage( + 'm1', + [todo('1', 'in_progress')], + stats(300, 40, 80, 1500), + ), + 1000, + ), + at( + todoWriteMessage( + 'm2', + [todo('1', 'completed')], + stats(100, 10, 20, 500), + ), + 5000, + ), + ]); + const detail = details.get(todoStateKey(todo('1', 'pending'))); + expect(detail?.startTs).toBe(1000); + expect(detail?.endTs).toBe(5000); + expect(detail?.resources).toBeUndefined(); + }); + + it('omits API time when the diff did not advance it (resume path)', () => { + // Replayed sessions carry token counts but not per-turn durations, so the + // API time component stays flat and is dropped while tokens still show. + const details = computeTodoDetails([ + at( + todoWriteMessage( + 'm1', + [todo('1', 'in_progress')], + stats(100, 0, 0, 500), + ), + 1000, + ), + at( + todoWriteMessage('m2', [todo('1', 'completed')], stats(300, 0, 0, 500)), + 5000, + ), + ]); + const resources = details.get( + todoStateKey(todo('1', 'pending')), + )?.resources; + expect(resources?.inputTokens).toBe(200); + expect(resources?.apiTimeMs).toBeUndefined(); + }); + + it('spans intermediate snapshots while a task stays in progress', () => { + // First start (m1) and completion (m3) bound the window; the middle + // boundary (m2) does not affect the cumulative diff. + const details = computeTodoDetails([ + at( + todoWriteMessage( + 'm1', + [todo('1', 'in_progress')], + stats(100, 0, 0, 100), + ), + 1000, + ), + at( + todoWriteMessage( + 'm2', + [todo('1', 'in_progress')], + stats(300, 0, 0, 500), + ), + 3000, + ), + at( + todoWriteMessage('m3', [todo('1', 'completed')], stats(500, 0, 0, 900)), + 5000, + ), + ]); + const detail = details.get(todoStateKey(todo('1', 'pending'))); + expect(detail?.startTs).toBe(1000); + expect(detail?.endTs).toBe(5000); + expect(detail?.resources?.inputTokens).toBe(400); + expect(detail?.resources?.apiTimeMs).toBe(800); + }); + + it('sums tool time from transcript tool spans started within the window', () => { + const details = computeTodoDetails([ + toolMessage('t0', 100, 500), // before the window + at(todoWriteMessage('m1', [todo('1', 'in_progress')]), 1000), + toolMessage('t1', 2000, 2500), // within: 500ms + at(todoWriteMessage('m2', [todo('1', 'completed')]), 5000), + toolMessage('t2', 6000, 9000), // after the window + ]); + expect(details.get(todoStateKey(todo('1', 'pending')))?.resources).toEqual({ + toolTimeMs: 500, + }); + }); + + it('yields no detail for an item first seen already completed', () => { + const details = computeTodoDetails([ + at(planMessage('p1', [todo('1', 'completed')]), 1000), + ]); + expect(details.has(todoStateKey(todo('1', 'pending')))).toBe(false); + }); + + it('records an end with no start when an item skips in_progress', () => { + const details = computeTodoDetails([ + at(planMessage('p1', [todo('1', 'pending')]), 1000), + at(planMessage('p2', [todo('1', 'completed')]), 5000), + ]); + expect(details.get(todoStateKey(todo('1', 'pending')))).toEqual({ + endTs: 5000, + }); + }); + + it('starts a fresh window when a completed id+content is reused by a new task', () => { + // The ACP bridge reuses positional ids across plans, so two unrelated tasks + // can share id+content. The second occurrence must diff against its own + // start (plan B: 900→1000, 5000→5200) — not plan A's far-earlier boundary, + // which would render a cross-plan window with inflated numbers. + const details = computeTodoDetails([ + at( + todoWriteMessage( + 'a1', + [item('1', 'Run tests', 'in_progress')], + stats(100, 0, 0, 100), + ), + 1000, + ), + at( + todoWriteMessage( + 'a2', + [item('1', 'Run tests', 'completed')], + stats(200, 0, 0, 300), + ), + 2000, + ), + userMessage('u1'), + at( + todoWriteMessage( + 'b1', + [item('1', 'Run tests', 'in_progress')], + stats(900, 0, 0, 5000), + ), + 8000, + ), + at( + todoWriteMessage( + 'b2', + [item('1', 'Run tests', 'completed')], + stats(1000, 0, 0, 5200), + ), + 9000, + ), + ]); + const detail = details.get( + todoStateKey(item('1', 'Run tests', 'completed')), + ); + expect(detail?.startTs).toBe(8000); + expect(detail?.endTs).toBe(9000); + expect(detail?.resources?.inputTokens).toBe(100); + expect(detail?.resources?.apiTimeMs).toBe(200); + }); + + it('resets the window when a task reopens via pending (completed → pending → in_progress)', () => { + // The intervening `pending` makes prev `pending` (not `completed`) at the + // re-activation, so this relies on the "ever completed" tracking; the + // reopened run must diff its own window (900→1000, 5000→5200), not span + // back to the original start. + const details = computeTodoDetails([ + at( + todoWriteMessage( + 'a1', + [item('1', 'Build', 'in_progress')], + stats(100, 0, 0, 100), + ), + 1000, + ), + at( + todoWriteMessage( + 'a2', + [item('1', 'Build', 'completed')], + stats(200, 0, 0, 300), + ), + 2000, + ), + at(todoWriteMessage('a3', [item('1', 'Build', 'pending')]), 3000), + at( + todoWriteMessage( + 'a4', + [item('1', 'Build', 'in_progress')], + stats(900, 0, 0, 5000), + ), + 8000, + ), + at( + todoWriteMessage( + 'a5', + [item('1', 'Build', 'completed')], + stats(1000, 0, 0, 5200), + ), + 9000, + ), + ]); + const detail = details.get(todoStateKey(item('1', 'Build', 'completed'))); + expect(detail?.startTs).toBe(8000); + expect(detail?.endTs).toBe(9000); + expect(detail?.resources?.inputTokens).toBe(100); + expect(detail?.resources?.apiTimeMs).toBe(200); + }); + + it('upgrades a stats-less start baseline to a later snapshot that has stats', () => { + // A `plan`-message start carries no stats (the baseline is stored as + // undefined); a later in_progress snapshot with real stats must become the + // baseline, which a plain Map.has guard would block. + const details = computeTodoDetails([ + at(planMessage('p1', [item('1', 'X', 'in_progress')]), 1000), + at(planMessage('p2', [item('1', 'X', 'pending')]), 2000), + at( + todoWriteMessage( + 'm1', + [item('1', 'X', 'in_progress')], + stats(100, 0, 0, 500), + ), + 3000, + ), + at( + todoWriteMessage( + 'm2', + [item('1', 'X', 'completed')], + stats(300, 0, 0, 900), + ), + 5000, + ), + ]); + const detail = details.get(todoStateKey(item('1', 'X', 'completed'))); + expect(detail?.resources?.inputTokens).toBe(200); // 300 - 100 + expect(detail?.resources?.apiTimeMs).toBe(400); // 900 - 500 + }); + + it('sums only tool spans whose start falls within the task window', () => { + const details = computeTodoDetails([ + toolMessage('t-before', 100, 500), + at(todoWriteMessage('m1', [todo('1', 'in_progress')]), 1000), + toolMessage('t-a', 1500, 2000), // 500ms, in window + toolMessage('t-b', 3000, 3700), // 700ms, in window + at(todoWriteMessage('m2', [todo('1', 'completed')]), 5000), + toolMessage('t-after', 6000, 9000), + ]); + expect(details.get(todoStateKey(todo('1', 'pending')))?.resources).toEqual({ + toolTimeMs: 1200, + }); + }); +}); + +describe('plan stats contract (SDK normalizer → extractTodoStats)', () => { + it('round-trips an agent-stamped _meta.stats into a defined snapshot', () => { + // Field names mirror what the cli PlanEmitter stamps. Exercising the real + // SDK normalizer locks the sdk → web-shell hop: if normalizePlanUpdate stops + // forwarding stats, or extractTodoStats's field names drift from the + // forwarded shape, this returns undefined and fails here rather than + // silently rendering "not captured". (The cli field names are pinned + // separately by PlanEmitter.test.) + const events = normalizeDaemonEvent({ + id: 1, + v: 1, + type: 'session_update', + data: { + update: { + sessionUpdate: 'plan', + entries: [ + { content: 'Task', status: 'completed', priority: 'medium' }, + ], + _meta: { + stats: { + promptTokens: 100, + cachedTokens: 10, + candidateTokens: 20, + apiTimeMs: 500, + }, + }, + }, + }, + }); + const rawOutput = (events[0] as { rawOutput?: unknown }).rawOutput; + const tool: ACPToolCall = { + callId: 'c', + toolName: 'TodoWrite', + status: 'completed', + kind: 'think', + rawOutput, + }; + expect(extractTodoStats(tool)).toEqual({ + promptTokens: 100, + cachedTokens: 10, + candidateTokens: 20, + apiTimeMs: 500, + }); + }); +}); + +describe('todoDetailSignature', () => { + it('changes when a snapshot timestamp changes', () => { + const a = todoDetailSignature([ + at(planMessage('p1', [todo('1', 'in_progress')]), 1000), + ]); + const b = todoDetailSignature([ + at(planMessage('p1', [todo('1', 'in_progress')]), 2000), + ]); + expect(b).not.toBe(a); + }); + + it('is unchanged by edits to non-todo messages', () => { + const a = todoDetailSignature([ + at(planMessage('p1', [todo('1', 'in_progress')]), 1000), + assistantMessage('a1'), + ]); + const b = todoDetailSignature([ + at(planMessage('p1', [todo('1', 'in_progress')]), 1000), + { id: 'a1', role: 'assistant', content: 'different text' }, + ]); + expect(b).toBe(a); + }); +}); diff --git a/packages/web-shell/client/utils/todos.ts b/packages/web-shell/client/utils/todos.ts index fecff350a5..bbd37e5f2b 100644 --- a/packages/web-shell/client/utils/todos.ts +++ b/packages/web-shell/client/utils/todos.ts @@ -144,6 +144,8 @@ interface TodoSnapshot { /** Key the diff is stored under: tool callId, or plan message id. */ key: string; todos: TodoItem[]; + /** Cumulative-usage baseline the agent stamped on this snapshot, if any. */ + stats?: TodoStatsSnapshot; } /** @@ -156,16 +158,20 @@ interface TodoSnapshot { * unlike a user-turn reset — it still tracks a list correctly when it spans * turns (a "continue" turn that completes an item carried over from before). * - * Two rare cases this trades for, both only affecting the collapsed diff while - * the expanded list stays correct: + * Two rare cases this trades for, affecting the collapsed diff and the per-task + * detail ({@link computeTodoDetails}) but not the expanded list itself: * - A todo reworded on a stable id reads as a new task. Reworded while still * `in_progress` it emits a spurious `started`; reworded straight to * `completed` (`1 "Write report"` → `1 "Write the final report" completed`) * the completion is treated as first-seen and dropped. * - Two unrelated plans that reuse both the id AND the exact content (a generic - * recurring todo like `"Run tests"`) still collide. + * recurring todo like `"Run tests"`) still collide. computeTodoDetails resets + * a task's window when a completed key restarts as `in_progress`, so the + * common reuse keeps correct numbers; a reused id+content that goes *straight* + * to `completed` (never observed `in_progress`) still shares the earlier + * task's detail slot. */ -function todoStateKey(todo: TodoItem): string { +export function todoStateKey(todo: TodoItem): string { return JSON.stringify([todo.id, todo.content]); } @@ -182,7 +188,13 @@ function todoSnapshotsOf(message: Message): TodoSnapshot[] { const snapshots: TodoSnapshot[] = []; for (const tool of message.tools) { const todos = extractTodosFromToolCall(tool); - if (todos) snapshots.push({ key: tool.callId, todos }); + if (todos) { + snapshots.push({ + key: tool.callId, + todos, + stats: extractTodoStats(tool), + }); + } } return snapshots; } @@ -255,6 +267,42 @@ export function todoTimelineSignature(messages: readonly Message[]): string { return parts.join('\n'); } +/** + * Like {@link todoTimelineSignature} but folds in everything + * {@link computeTodoDetails} reads beyond item status: each snapshot's message + * timestamp and stamped stats, plus every non-todo tool span (whose durations + * feed tool time). App memoizes the detail map on this so the TodoDetailContext + * value stays referentially stable across streaming ticks that touch none of it. + */ +export function todoDetailSignature(messages: readonly Message[]): string { + const parts: string[] = []; + for (const message of messages) { + if (message.role === 'tool_group') { + for (const tool of message.tools) { + if ( + !isTodoWriteToolName(tool.toolName) && + (tool.startTime !== undefined || tool.endTime !== undefined) + ) { + parts.push( + JSON.stringify(['span', tool.callId, tool.startTime, tool.endTime]), + ); + } + } + } + for (const { key, todos, stats } of todoSnapshotsOf(message)) { + parts.push( + JSON.stringify([ + key, + message.timestamp, + todos.map((t) => [t.id, t.status, t.content]), + stats, + ]), + ); + } + } + return parts.join('\n'); +} + export interface TodoWindow { start: number; end: number; @@ -281,6 +329,288 @@ export function getTodoWindow( return { start, end }; } +/** + * Cumulative-usage baseline the agent stamps onto each todo update + * (`_meta.stats`, surfaced via the tool call's rawOutput). The web-shell diffs + * consecutive snapshots to attribute a task's spend. `apiTimeMs` only advances + * live — replayed sessions carry tokens but not per-turn durations. + */ +export interface TodoStatsSnapshot { + promptTokens: number; + cachedTokens: number; + candidateTokens: number; + apiTimeMs: number; +} + +/** + * Read the cumulative-usage snapshot the agent stamped onto a todo_write tool + * call's rawOutput. Absent for snapshots emitted by an agent that predates the + * stamping, or non-tool todo sources (plain plan messages). + */ +export function extractTodoStats( + tool: ACPToolCall, +): TodoStatsSnapshot | undefined { + const stats = getRecord(getRecord(tool.rawOutput)?.['stats']); + if (!stats) return undefined; + const num = (key: string): number | undefined => { + const value = stats[key]; + return typeof value === 'number' && Number.isFinite(value) + ? value + : undefined; + }; + const promptTokens = num('promptTokens'); + const cachedTokens = num('cachedTokens'); + const candidateTokens = num('candidateTokens'); + if ( + promptTokens === undefined || + cachedTokens === undefined || + candidateTokens === undefined + ) { + return undefined; + } + // apiTimeMs is the "live-only" field — a snapshot may legitimately omit it + // (e.g. a future agent on the replay path). Default it to 0 rather than + // dropping the whole snapshot and losing the valid token counts. + return { + promptTokens, + cachedTokens, + candidateTokens, + apiTimeMs: num('apiTimeMs') ?? 0, + }; +} + +/** + * Resource usage consumed during a single todo's [start, end] window. Every + * field is optional: tokens/API time come from the snapshot diff (absent on + * sessions whose agent didn't stamp snapshots; API time is also absent on + * replay), while tool time comes from transcript tool durations and is shown + * whenever any tool ran in the window. + */ +export interface TodoResources { + inputTokens?: number; + cachedTokens?: number; + outputTokens?: number; + apiTimeMs?: number; + toolTimeMs?: number; +} + +/** Per-todo timing and resource breakdown. */ +export interface TodoDetail { + /** Wall-clock ms when the item first became in_progress. */ + startTs?: number; + /** Wall-clock ms when the item became completed. */ + endTs?: number; + /** + * Tokens and time spent while this item was the active task. Tokens and API + * time come from diffing the cumulative-usage snapshots stamped on its start + * and end todo boundaries; tool time is summed from the transcript's tool + * durations in the window. Undefined when nothing could be measured. + */ + resources?: TodoResources; +} + +interface ToolSpan { + start: number; + end: number; +} + +/** + * Wall-clock spans of every non-todo tool call that has both a start and end + * time, sorted by start. Used to attribute tool time to the task window a tool + * ran in; sorting once lets {@link sumToolTimeInWindow} binary-search each + * window rather than scan every span per task. + */ +function collectToolSpans(messages: readonly Message[]): ToolSpan[] { + const spans: ToolSpan[] = []; + for (const message of messages) { + if (message.role !== 'tool_group') continue; + for (const tool of message.tools) { + if (isTodoWriteToolName(tool.toolName)) continue; + const { startTime, endTime } = tool; + if ( + typeof startTime === 'number' && + typeof endTime === 'number' && + endTime >= startTime + ) { + spans.push({ start: startTime, end: endTime }); + } + } + } + spans.sort((a, b) => a.start - b.start); + return spans; +} + +/** + * Total duration of tool spans whose start falls within [startTs, endTs]. + * `sortedSpans` must be sorted by start (see {@link collectToolSpans}); binary + * search finds the window's first span, then we walk until past its end — + * O(log S + matched) instead of a full scan per task. + */ +function sumToolTimeInWindow( + sortedSpans: readonly ToolSpan[], + startTs: number, + endTs: number, +): number { + let lo = 0; + let hi = sortedSpans.length; + while (lo < hi) { + const mid = (lo + hi) >>> 1; + if (sortedSpans[mid].start < startTs) lo = mid + 1; + else hi = mid; + } + let total = 0; + for ( + let i = lo; + i < sortedSpans.length && sortedSpans[i].start <= endTs; + i++ + ) { + total += sortedSpans[i].end - sortedSpans[i].start; + } + return total; +} + +interface TokenDiff { + inputTokens: number; + cachedTokens: number; + outputTokens: number; + apiTimeMs: number; +} + +function diffStats( + start: TodoStatsSnapshot, + end: TodoStatsSnapshot, +): TokenDiff { + // Clamp to zero: snapshots are cumulative, so a smaller end (a reset, or two + // snapshots that coincided) must never surface a negative count. + const nonNeg = (a: number, b: number) => Math.max(0, b - a); + return { + inputTokens: nonNeg(start.promptTokens, end.promptTokens), + cachedTokens: nonNeg(start.cachedTokens, end.cachedTokens), + outputTokens: nonNeg(start.candidateTokens, end.candidateTokens), + apiTimeMs: nonNeg(start.apiTimeMs, end.apiTimeMs), + }; +} + +/** + * Combine the token snapshot diff and the windowed tool time into the resource + * fields to show. Token fields are included only when the diff measured + * something (a coincident diff of all zeros reads as not-captured, not a row of + * zeros); API time only when it advanced (live); tool time only when nonzero. + * Returns undefined when nothing was measured. + */ +function buildResources( + tokenDiff: TokenDiff | undefined, + toolTimeMs: number | undefined, +): TodoResources | undefined { + const resources: TodoResources = {}; + if ( + tokenDiff && + (tokenDiff.inputTokens > 0 || + tokenDiff.outputTokens > 0 || + tokenDiff.cachedTokens > 0 || + tokenDiff.apiTimeMs > 0) + ) { + resources.inputTokens = tokenDiff.inputTokens; + resources.cachedTokens = tokenDiff.cachedTokens; + resources.outputTokens = tokenDiff.outputTokens; + if (tokenDiff.apiTimeMs > 0) resources.apiTimeMs = tokenDiff.apiTimeMs; + } + if (toolTimeMs !== undefined && toolTimeMs > 0) { + resources.toolTimeMs = toolTimeMs; + } + return Object.keys(resources).length > 0 ? resources : undefined; +} + +/** + * Per-todo detail keyed by {@link todoStateKey}: when each item started and + * completed, plus the resources spent in that window. + * + * Mirrors {@link computeTodoTimeline}'s state machine — a todo's start is its + * first in_progress transition and its end the completed transition — but + * records timestamps, the snapshot diff (tokens + API time) between the start + * and end boundaries, and the transcript tool time in the window. An item first + * seen already completed (a restored opening snapshot) yields no detail, exactly + * as it produces no timeline event. + */ +export function computeTodoDetails( + messages: readonly Message[], +): Map { + const result = new Map(); + const lastStatus = new Map(); + const startStatsByKey = new Map(); + // Keys that have reached `completed`. Going active again afterwards (directly, + // or via an intervening `pending`) is a re-activation that must start a fresh + // window rather than diff against the prior completion's baseline. + const completedKeys = new Set(); + const toolSpans = collectToolSpans(messages); + + const ensure = (stateKey: string): TodoDetail => { + let detail = result.get(stateKey); + if (!detail) { + detail = {}; + result.set(stateKey, detail); + } + return detail; + }; + + for (const message of messages) { + const ts = message.timestamp; + for (const { todos, stats } of todoSnapshotsOf(message)) { + for (const todo of todos) { + const stateKey = todoStateKey(todo); + const prev = lastStatus.get(stateKey); + if (todo.status === 'in_progress' && prev !== 'in_progress') { + // Re-activated after a completion (a reopened task, or a new task + // reusing a positional `plan-N` id) — reset so its window diffs + // against its own start, not the prior completion's far-earlier + // boundary, which would render a window spanning both with wildly + // inflated token/time numbers. Keyed on "ever completed" so an + // intervening `pending` (completed → pending → in_progress) still + // resets, while a pause/resume that never completed + // (in_progress → pending → in_progress) keeps its first baseline. + if (completedKeys.has(stateKey)) { + completedKeys.delete(stateKey); + startStatsByKey.delete(stateKey); + result.delete(stateKey); + } + // First start *with stats* wins: record the baseline for the resource + // diff even when this message has no timestamp. Checking the stored + // value (not `Map.has`) lets a real snapshot upgrade a baseline that a + // stats-less start (e.g. a plain `plan` message) recorded as + // `undefined`, which `has` would otherwise treat as already set. + if (startStatsByKey.get(stateKey) === undefined) { + startStatsByKey.set(stateKey, stats); + if (ts !== undefined) ensure(stateKey).startTs = ts; + } + } else if ( + todo.status === 'completed' && + prev !== 'completed' && + prev !== undefined + ) { + const startStats = startStatsByKey.get(stateKey); + const tokenDiff = + startStats && stats ? diffStats(startStats, stats) : undefined; + const startTs = result.get(stateKey)?.startTs; + const toolTimeMs = + startTs !== undefined && ts !== undefined + ? sumToolTimeInWindow(toolSpans, startTs, ts) + : undefined; + const resources = buildResources(tokenDiff, toolTimeMs); + if (ts !== undefined || resources) { + const detail = ensure(stateKey); + if (ts !== undefined) detail.endTs = ts; + if (resources) detail.resources = resources; + } + } + if (todo.status === 'completed') completedKeys.add(stateKey); + lastStatus.set(stateKey, todo.status); + } + } + } + + return result; +} + function getTodoArray( record: Record | undefined, ): readonly unknown[] | undefined {