mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-07-10 01:29:17 +00:00
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:
parent
a969c84620
commit
6677ca1dd3
19 changed files with 1711 additions and 41 deletions
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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' },
|
||||
|
|
|
|||
|
|
@ -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 } } } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
156
packages/web-shell/client/components/messages/TodoView.test.tsx
Normal file
156
packages/web-shell/client/components/messages/TodoView.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()),
|
||||
};
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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} 项`,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue