qwen-code/packages/web-shell/client/components/messages/PlanMessage.test.tsx
Shaojin Wen 6677ca1dd3
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.
2026-06-15 15:40:06 +08:00

138 lines
4.3 KiB
TypeScript

// @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';
// 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()),
TodoDetailContext: createContext(new Map()),
};
});
const { PlanMessage } = await import('./PlanMessage');
const { TodoTimelineContext } = 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 renderPlan(
id: string,
todos: TodoItem[],
timeline?: Map<string, unknown>,
): HTMLElement {
const container = document.createElement('div');
document.body.appendChild(container);
const root = createRoot(container);
act(() => {
root.render(
<I18nProvider language="en">
<TodoTimelineContext.Provider value={timeline ?? new Map()}>
<PlanMessage id={id} todos={todos} />
</TodoTimelineContext.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', 'First task', 'completed'),
todo('2', 'Second task', 'in_progress'),
todo('3', 'Third task', 'pending'),
];
describe('PlanMessage', () => {
it('collapses to the current step with a progress count', () => {
const container = renderPlan('p1', TODOS);
expect(container.textContent).toContain('1/3');
expect(container.textContent).toContain('Second task');
expect(container.textContent).not.toContain('Third task');
expect(container.textContent).toContain('▸');
});
it('expands to the full list on click', () => {
const container = renderPlan('p1', TODOS);
const chevron = [...container.querySelectorAll('span')].find(
(s) => s.textContent === '▸',
);
click(chevron!.parentElement!);
expect(container.textContent).toContain('First task');
expect(container.textContent).toContain('Second task');
expect(container.textContent).toContain('Third task');
expect(container.textContent).toContain('▾');
});
it('shows the plan-keyed diff when a timeline is present', () => {
const timeline = new Map<string, unknown>([
[
'p1',
{
events: [
{ kind: 'completed', id: '1', content: 'First task' },
{ kind: 'started', id: '2', content: 'Second task' },
],
},
],
]);
const container = renderPlan('p1', TODOS, timeline);
expect(container.textContent).toContain('First task');
expect(container.textContent).toContain('Second task');
expect(container.textContent).not.toContain('Third task');
});
it('shows an all-done summary when every item is completed', () => {
const container = renderPlan('p1', [
todo('1', 'First task', 'completed'),
todo('2', 'Second task', 'completed'),
]);
expect(container.textContent).toContain('2/2');
expect(container.textContent).toContain('All tasks completed');
});
it('falls back to the first pending item when nothing is in progress', () => {
const container = renderPlan('p1', [
todo('1', 'First task', 'pending'),
todo('2', 'Second task', 'pending'),
]);
expect(container.textContent).toContain('0/2');
expect(container.textContent).toContain('First task');
expect(container.textContent).not.toContain('Second task');
});
it('renders nothing for an empty plan', () => {
const container = renderPlan('p1', []);
expect(container.textContent).toBe('');
});
});