mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-08-17 04:34:46 +00:00
* 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.
138 lines
4.3 KiB
TypeScript
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('');
|
|
});
|
|
});
|