feat(web-shell): per-task token & time detail on completed todos (#5118)

* feat(web-shell): per-task token & time detail on completed todos

Expanding a completed task in the todo list now reveals when it ran (start /
end / duration) and what it spent: input / output / cached tokens, API time,
and tool time.

The agent stamps a cumulative-usage snapshot onto each todo (plan) update via
`_meta.stats`; the SDK normalizer carries it into the TodoWrite tool call's
rawOutput, and the web-shell diffs consecutive snapshots for tokens and API
time while summing transcript tool durations for tool time.

Works live (no polling race) and on /resume: tokens and tool time are
reconstructed from persisted usage metadata, while API time is live-only since
per-turn durations are not replayed. Sessions whose agent never stamped a
snapshot degrade gracefully to start/end + tool time.

* refactor(web-shell): show task duration inline on the end-time row

Trail the elapsed duration after the end time as a dimmed parenthetical
("12:34:15 (4m 14s)") instead of a separate row, since it's derived from the
start/end pair.

* fix(web-shell): correct per-task detail for reused todo ids; review follow-ups

- computeTodoDetails: when a completed id+content key restarts as in_progress (positional plan-N ids repeat across plans), reset the window so the new task diffs its own start instead of the prior task's far-earlier boundary — which rendered a cross-plan window with wildly inflated token/time numbers. Correct the todoStateKey JSDoc accordingly.
- Tool time: sort spans once and binary-search the task window instead of an O(todos x spans) scan per completed task.
- Tests: add the reuse-reset case, a windowed tool-time case, an SDK-normalizer -> extractTodoStats contract test (locks the stats passthrough so a field rename fails loudly), and a stopPropagation test (expander click must not bubble to the tool-row header).

* fix(web-shell): reset todo detail window on reopen via pending, not just direct

Track keys that have ever reached 'completed' instead of checking prev === 'completed': a reopened task can pass through 'pending' (completed → pending → in_progress), where prev at the re-activation is 'pending' and the direct check missed it, leaving a stale baseline that diffs across both runs. A pause/resume that never completed (in_progress → pending → in_progress) still keeps its first baseline, so its diff captures the whole task.

* fix(web-shell,cli): harden todo stats against NaN poisoning and partial snapshots

- MessageEmitter: only fold finite usage/duration values into the cumulative accumulator. A NaN/Infinity (incl. a NaN that survives `?? 0`) would poison the running total forever, making every later snapshot fail extractTodoStats and silently show 'not captured' for the rest of the session.
- extractTodoStats: require the token fields but default the live-only apiTimeMs to 0 when absent/non-finite, so a snapshot that omits it keeps its valid token counts instead of being dropped whole.
- computeTodoDetails: gate the start baseline on the stored value, not Map.has — a stats-less start (e.g. a plain plan message) recorded undefined, which Map.has treated as already-set, blocking a later stats-bearing snapshot from upgrading the baseline.
- Document the MessageEmitter-before-PlanEmitter ordering invariant in both emitters.
This commit is contained in:
Shaojin Wen 2026-06-15 15:40:06 +08:00 committed by GitHub
parent a969c84620
commit 6677ca1dd3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
19 changed files with 1711 additions and 41 deletions

View file

@ -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 {

View file

@ -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

View file

@ -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
});
});
});
});

View file

@ -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 }

View file

@ -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' },

View file

@ -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 } } } : {}),
});
}

View file

@ -28,6 +28,24 @@ export interface SessionUpdateSender {
sendUpdate(update: SessionUpdate): Promise<void>;
}
/**
* 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;
}
/**

View file

@ -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 },
};
}

View file

@ -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<string, unknown> })
.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 });

View file

@ -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<Map<string, TodoSnapshotDiff>>(
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<Map<string, TodoDetail>>(
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<string, TodoSnapshotDiff>;
details: Map<string, TodoDetail>;
children: ReactNode;
}) {
return (
<TodoTimelineContext.Provider value={timeline}>
<TodoDetailContext.Provider value={details}>
{children}
</TodoDetailContext.Provider>
</TodoTimelineContext.Provider>
);
}
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<string, TodoDetail>;
} | 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({
<WebShellCustomizationProvider value={customization}>
<CompactModeContext.Provider value={compactMode}>
<TodoTimelineContext.Provider value={todoTimeline}>
<TodoContextsProvider
timeline={todoTimeline}
details={todoDetails}
>
<div
className={
showFloatingTodos
@ -2716,7 +2774,7 @@ export function App({
<StreamingStatus />
</div>
<div ref={setMemoryPortalHost} data-web-shell-overlay-root />
</TodoTimelineContext.Provider>
</TodoContextsProvider>
</CompactModeContext.Provider>
</WebShellCustomizationProvider>

View file

@ -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');

View file

@ -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;

View file

@ -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;
}

View file

@ -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<string, TodoDetail>,
onParentClick?: () => void,
): HTMLElement {
const container = document.createElement('div');
document.body.appendChild(container);
const root = createRoot(container);
act(() => {
root.render(
<I18nProvider language="en">
<TodoDetailContext.Provider value={details}>
{/* Parent click handler stands in for the surrounding todo_write
tool-row header, to assert the expander click doesn't bubble. */}
<div onClick={onParentClick}>
<TodoFullList todos={todos} />
</div>
</TodoDetailContext.Provider>
</I18nProvider>,
);
});
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<string, TodoDetail>([
[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<string, TodoDetail>([
[
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<string, TodoDetail>([
[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<string, TodoDetail>([
[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();
});
});

View file

@ -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 (
<>
<span className={styles.detailLabel}>{label}</span>
<span className={styles.detailValue}>
{value}
{suffix}
</span>
</>
);
}
function DetailSection({
title,
children,
}: {
title: string;
children: ReactNode;
}) {
return (
<div className={styles.detailSection}>
<div className={styles.detailSectionTitle}>{title}</div>
<div className={styles.detailRows}>{children}</div>
</div>
);
}
/**
* 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 (
<div className={styles.detail}>
{hasTime && (
<DetailSection title={t('todo.detail.sectionTime')}>
{startTs !== undefined && (
<DetailRow
label={t('todo.detail.start')}
value={formatTimestamp(startTs)}
/>
)}
{endTs !== undefined && (
<DetailRow
label={t('todo.detail.end')}
value={formatTimestamp(endTs)}
suffix={
startTs !== undefined ? (
<span className={styles.detailDuration}>
{' '}
({formatDuration(endTs - startTs)})
</span>
) : undefined
}
/>
)}
</DetailSection>
)}
{hasTokens && (
<DetailSection title={t('todo.detail.sectionTokens')}>
<DetailRow
label={t('todo.detail.input')}
value={(resources?.inputTokens ?? 0).toLocaleString()}
/>
<DetailRow
label={t('todo.detail.output')}
value={(resources?.outputTokens ?? 0).toLocaleString()}
/>
<DetailRow
label={t('todo.detail.cached')}
value={(resources?.cachedTokens ?? 0).toLocaleString()}
/>
</DetailSection>
)}
{hasSpent && (
<DetailSection title={t('todo.detail.sectionSpent')}>
{resources?.apiTimeMs !== undefined && (
<DetailRow
label={t('todo.detail.api')}
value={formatDuration(resources.apiTimeMs)}
/>
)}
{resources?.toolTimeMs !== undefined && (
<DetailRow
label={t('todo.detail.tool')}
value={formatDuration(resources.toolTimeMs)}
/>
)}
</DetailSection>
)}
{!resources && (
<div className={styles.detailHint}>{t('todo.detail.noResources')}</div>
)}
</div>
);
}
/**
* 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<ReadonlySet<string>>(
() => 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 (
<div className={styles.list}>
{todos.map((todo, index) => (
<div
key={todo.id || index}
className={`${styles.row} ${statusClass(todo.status)}`}
>
{numbered && (
<span className={styles.num} style={{ minWidth: numColumnWidth }}>
{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 && (
<span className={styles.num} style={{ minWidth: numColumnWidth }}>
{index + 1}.
</span>
)}
<span className={styles.icon} aria-hidden="true">
{getTodoStatusIcon(todo.status)}
</span>
)}
<span className={styles.icon} aria-hidden="true">
{getTodoStatusIcon(todo.status)}
</span>
<span className={styles.text}>{todo.content}</span>
</div>
))}
<span className={styles.text}>{todo.content}</span>
{expandable && (
<span className={styles.detailChevron} aria-hidden="true">
{isOpen ? '▾' : '▸'}
</span>
)}
</>
);
return (
<div key={rowKey} className={styles.item}>
{expandable ? (
<button
type="button"
className={`${styles.row} ${styles.rowButton} ${statusClass(todo.status)}`}
onClick={(e) => {
// This row toggles its own detail only — never bubble to a
// surrounding expandable container (e.g. the todo_write tool
// row, whose header would otherwise collapse the whole list).
e.stopPropagation();
toggle(rowKey);
}}
aria-expanded={isOpen}
title={isOpen ? t('todo.detail.hide') : t('todo.detail.show')}
>
{rowInner}
</button>
) : (
<div className={`${styles.row} ${statusClass(todo.status)}`}>
{rowInner}
</div>
)}
{isOpen && detail && <TodoDetailBlock detail={detail} />}
</div>
);
})}
</div>
);
}

View file

@ -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()),
};
});

View file

@ -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}`,

View file

@ -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);
});
});

View file

@ -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<string, TodoDetail> {
const result = new Map<string, TodoDetail>();
const lastStatus = new Map<string, TodoItem['status']>();
const startStatsByKey = new Map<string, TodoStatsSnapshot | undefined>();
// 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<string>();
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<string, unknown> | undefined,
): readonly unknown[] | undefined {