qwen-code/packages/web-shell/client/components/MessageList.test.ts
ermin.zem 5c82857fea
Some checks are pending
E2E Tests / E2E Test (Linux) - sandbox:docker (push) Waiting to run
E2E Tests / E2E Test (Linux) - sandbox:none (push) Waiting to run
E2E Tests / E2E Test - macOS (push) Waiting to run
E2E Tests / web-shell Browser Regression (push) Waiting to run
Add harness infrastructure for web-shell package (#6517)
* test(web-shell): add browser and lint harness

* test(web-shell): harden browser smoke harness

* fix(web-shell): guard mock daemon model state

* test(web-shell): remove unused scenario harness

* fix(web-shell): remove stale lint disables

* test(web-shell): make matchMedia stub writable

* fix(web-shell): exclude tests from package typecheck

* test(web-shell): tighten mock daemon route contract

* Update packages/web-shell/client/e2e/utils/mockDaemon.ts

Co-authored-by: qwen-code-ci-bot <qwen-code-ci@service.alibaba.com>

* test(web-shell): clear stale SSE connections

* ci(web-shell): gate smoke on full CI profile

---------

Co-authored-by: ermin.zem <ermin.zem@alibaba-inc.com>
Co-authored-by: qwen-code-ci-bot <qwen-code-ci@service.alibaba.com>
Co-authored-by: Shaojin Wen <shaojin.wensj@alibaba-inc.com>
Co-authored-by: 易良 <1204183885@qq.com>
2026-07-09 08:11:58 +00:00

1767 lines
51 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { describe, expect, it, vi } from 'vitest';
import type { Message, TurnCollapseHead } from '../adapters/types';
import {
applyTurnCollapse,
findDisplayItemIndex,
findTurnIdForIndex,
getSessionTimelineEntries,
getSessionTimelineRangeForIndexes,
getSessionTimelineSignature,
getTurnTimelineNode,
getDisplayItemVirtualKey,
getTurnIdByDisplayIndex,
groupParallelAgents,
shouldUseVirtualScroll,
VIRTUAL_SCROLL_THRESHOLD,
type DisplayItem,
} from './MessageList';
function messageRow(
item: DisplayItem,
): Extract<DisplayItem, { type: 'message' }> {
if (item.type !== 'message') {
throw new Error(`expected a message row, got ${item.type}`);
}
return item;
}
function collapseOf(
items: DisplayItem[],
idxOrTurnId: number | string,
): TurnCollapseHead | undefined {
const idx =
typeof idxOrTurnId === 'number'
? idxOrTurnId
: items.findIndex(
(item) =>
item.type === 'message' &&
(item.message.role === 'user' ||
item.message.role === 'user_shell') &&
item.message.id === idxOrTurnId,
);
if (idx < 0) return undefined;
const next = items[idx + 1];
if (next && next.type === 'turn_collapse') return next.turnCollapse;
return undefined;
}
function messageById(
items: DisplayItem[],
id: string,
): Extract<DisplayItem, { type: 'message' }> {
const item = items.find(
(item) => item.type === 'message' && item.message.id === id,
);
if (!item) throw new Error(`expected message row ${id}`);
return messageRow(item);
}
function makeThinkingMessage(id: string, content = 'pondering'): Message {
return {
id,
role: 'thinking',
content,
};
}
function makeSystemMessage(id: string): Message {
return { id, role: 'system', content: 'heads up', variant: 'error' };
}
function makePlanMessage(id: string): Message {
return { id, role: 'plan', todos: [] };
}
function makeAgentToolGroup(
id: string,
toolName = 'Agent',
timestamp?: number,
): Extract<Message, { role: 'tool_group' }> {
return {
id,
role: 'tool_group',
tools: [
{
callId: `call-${id}`,
toolName,
status: 'completed',
args: { description: `task ${id}` },
},
],
...(timestamp !== undefined ? { timestamp } : {}),
};
}
function makeBackgroundAgentToolGroup(id: string): Message {
return {
id,
role: 'tool_group',
tools: [
{
callId: `call-${id}`,
toolName: 'Agent',
status: 'pending',
args: {
description: `task ${id}`,
run_in_background: true,
},
rawOutput: {
type: 'task_execution',
taskDescription: `task ${id}`,
status: 'background',
},
},
],
};
}
function makeMultiToolGroup(
id: string,
): Extract<Message, { role: 'tool_group' }> {
return {
id,
role: 'tool_group',
tools: [
{ callId: `call-${id}-a`, toolName: 'Read', status: 'completed' },
{ callId: `call-${id}-b`, toolName: 'Write', status: 'completed' },
],
};
}
function makeUserMessage(id: string): Extract<Message, { role: 'user' }> {
return { id, role: 'user', content: 'hello' };
}
function makeUserShellMessage(
id: string,
): Extract<Message, { role: 'user_shell' }> {
return { id, role: 'user_shell', command: 'npm test', output: '' };
}
function makeAssistantMessage(
id: string,
): Extract<Message, { role: 'assistant' }> {
return { id, role: 'assistant', content: 'response' };
}
function makeThoughtMessage(id: string): Message {
return {
id,
role: 'thinking',
content: 'launching another agent',
};
}
describe('groupParallelAgents', () => {
it('returns empty array for empty input', () => {
expect(groupParallelAgents([])).toEqual([]);
});
it('does not group a single agent tool_group', () => {
const msgs = [makeAgentToolGroup('1')];
const items = groupParallelAgents(msgs);
expect(items).toHaveLength(1);
expect(items[0].type).toBe('message');
});
it('groups 2+ consecutive agent-only tool_groups', () => {
const msgs = [
makeAgentToolGroup('1'),
makeAgentToolGroup('2'),
makeAgentToolGroup('3'),
];
const items = groupParallelAgents(msgs);
expect(items).toHaveLength(1);
expect(items[0].type).toBe('parallel_agents');
if (items[0].type === 'parallel_agents') {
expect(items[0].agents).toHaveLength(3);
expect(items[0].agents[0].callId).toBe('call-1');
expect(items[0].agents[2].callId).toBe('call-3');
}
});
it('carries the first launch time onto the grouped parallel-agents row', () => {
const msgs = [
makeAgentToolGroup('1', 'Agent', 1000),
makeAgentToolGroup('2', 'Agent', 2000),
];
const items = groupParallelAgents(msgs);
expect(items[0].type).toBe('parallel_agents');
if (items[0].type === 'parallel_agents') {
expect(items[0].timestamp).toBe(1000);
}
});
it('non-agent message breaks the group', () => {
const msgs = [
makeAgentToolGroup('1'),
makeAgentToolGroup('2'),
makeAssistantMessage('3'),
makeAgentToolGroup('4'),
makeAgentToolGroup('5'),
];
const items = groupParallelAgents(msgs);
expect(items).toHaveLength(3);
expect(items[0].type).toBe('parallel_agents');
expect(items[1].type).toBe('message');
expect(items[2].type).toBe('parallel_agents');
});
it('multi-tool tool_group is not grouped as agent', () => {
const msgs = [
makeAgentToolGroup('1'),
makeMultiToolGroup('2'),
makeAgentToolGroup('3'),
];
const items = groupParallelAgents(msgs);
expect(items).toHaveLength(3);
expect(items.every((i) => i.type === 'message')).toBe(true);
});
it('non-agent tool names are not grouped', () => {
const msgs: Message[] = [
{
id: '1',
role: 'tool_group',
tools: [{ callId: 'c1', toolName: 'Read', status: 'completed' }],
},
{
id: '2',
role: 'tool_group',
tools: [{ callId: 'c2', toolName: 'Write', status: 'completed' }],
},
];
const items = groupParallelAgents(msgs);
expect(items).toHaveLength(2);
expect(items.every((i) => i.type === 'message')).toBe(true);
});
it('preserves non-tool_group messages as-is', () => {
const msgs = [
makeUserMessage('1'),
makeAssistantMessage('2'),
makeUserMessage('3'),
];
const items = groupParallelAgents(msgs);
expect(items).toHaveLength(3);
expect(items.every((i) => i.type === 'message')).toBe(true);
});
it('groups Task tool calls as sub-agents', () => {
const msgs: Message[] = [
{
id: '1',
role: 'tool_group',
tools: [{ callId: 'c1', toolName: 'Task', status: 'in_progress' }],
},
{
id: '2',
role: 'tool_group',
tools: [{ callId: 'c2', toolName: 'Task', status: 'completed' }],
},
];
const items = groupParallelAgents(msgs);
expect(items).toHaveLength(1);
expect(items[0].type).toBe('parallel_agents');
});
it('mixed agent and user messages produce correct order', () => {
const msgs = [
makeUserMessage('u1'),
makeAgentToolGroup('a1'),
makeAgentToolGroup('a2'),
makeAssistantMessage('r1'),
makeAgentToolGroup('a3'),
];
const items = groupParallelAgents(msgs);
expect(items).toHaveLength(4);
expect(items[0].type).toBe('message');
expect(items[1].type).toBe('parallel_agents');
expect(items[2].type).toBe('message');
expect(items[3].type).toBe('message');
});
it('groups background agents separated by thought-only launch narration', () => {
const msgs = [
makeBackgroundAgentToolGroup('a1'),
makeThoughtMessage('t1'),
makeBackgroundAgentToolGroup('a2'),
];
const items = groupParallelAgents(msgs);
expect(items).toHaveLength(1);
expect(items[0].type).toBe('parallel_agents');
if (items[0].type === 'parallel_agents') {
expect(items[0].agents.map((a) => a.callId)).toEqual([
'call-a1',
'call-a2',
]);
}
});
it('preserves background thought narration when it is not between launches', () => {
const msgs = [
makeBackgroundAgentToolGroup('a1'),
makeThoughtMessage('t1'),
makeBackgroundAgentToolGroup('a2'),
makeThoughtMessage('t2'),
];
const items = groupParallelAgents(msgs);
expect(items).toHaveLength(2);
expect(items[0].type).toBe('parallel_agents');
expect(items[1].type).toBe('message');
if (items[1].type === 'message') {
expect(items[1].message.id).toBe('t2');
}
});
});
describe('getTurnTimelineNode', () => {
const item = (
message: Message,
): Extract<DisplayItem, { type: 'message' }> => ({
type: 'message',
key: message.id,
message,
});
it('classifies thinking blocks as thought nodes', () => {
expect(
getTurnTimelineNode(item(makeThinkingMessage('think'))),
).toMatchObject({
kind: 'thought',
});
});
it('classifies mid-turn assistant text as commentary nodes', () => {
expect(
getTurnTimelineNode(item(makeAssistantMessage('assistant'))),
).toMatchObject({
kind: 'commentary',
});
});
it('does not add a node to the final assistant answer', () => {
expect(
getTurnTimelineNode({
...item(makeAssistantMessage('assistant')),
turnCollapse: {
turnId: 'user',
collapsed: false,
hiddenCount: 1,
},
}),
).toMatchObject({
kind: 'none',
});
});
it('classifies tool groups and plans', () => {
expect(
getTurnTimelineNode(item(makeMultiToolGroup('tools'))),
).toMatchObject({
kind: 'tool',
label: '2 tool calls',
});
expect(getTurnTimelineNode(item(makePlanMessage('plan')))).toMatchObject({
kind: 'plan',
});
});
it('classifies grouped parallel agents', () => {
const [agents] = groupParallelAgents([
makeAgentToolGroup('a1', 'Agent', 1000),
makeAgentToolGroup('a2', 'Agent', 2000),
]);
expect(getTurnTimelineNode(agents)).toMatchObject({
kind: 'agents',
timestamp: 1000,
});
});
it('classifies mid-turn status and ignores plain user rows', () => {
expect(
getTurnTimelineNode(
item({
id: 'status',
role: 'system',
content: 'inserted',
variant: 'info',
source: 'mid_turn_message_injected',
}),
),
).toMatchObject({ kind: 'status' });
expect(getTurnTimelineNode(item(makeUserMessage('user')))).toMatchObject({
kind: 'none',
});
});
});
describe('getSessionTimelineSignature', () => {
it('keeps streaming text updates from invalidating the timeline cache', () => {
const before = getSessionTimelineSignature([
makeUserMessage('u1'),
{
id: 'a1',
role: 'assistant',
content: 'partial',
isStreaming: true,
},
]);
const during = getSessionTimelineSignature([
makeUserMessage('u1'),
{
id: 'a1',
role: 'assistant',
content: 'partial with more tokens',
isStreaming: true,
},
]);
const complete = getSessionTimelineSignature([
makeUserMessage('u1'),
{
id: 'a1',
role: 'assistant',
content: 'partial with more tokens',
isStreaming: false,
},
]);
expect(during).toBe(before);
expect(complete).not.toBe(before);
});
});
describe('getSessionTimelineEntries', () => {
it('returns no entries for an empty transcript', () => {
expect(getSessionTimelineEntries([])).toEqual([]);
});
it('builds one entry per user turn', () => {
expect(
getSessionTimelineEntries([
makeUserMessage('u1'),
makeThinkingMessage('think'),
makeMultiToolGroup('tools'),
makePlanMessage('plan'),
makeAssistantMessage('a1'),
makeUserMessage('u2'),
makeAssistantMessage('a2'),
]),
).toEqual([
{
id: 'u1',
label: 'hello',
detail: 'response',
timestamp: undefined,
nodeKinds: ['thought', 'tool', 'plan'],
},
{
id: 'u2',
label: 'hello',
detail: 'response',
timestamp: undefined,
nodeKinds: [],
},
]);
});
it('prefers the visible final answer over hidden turn steps', () => {
expect(
getSessionTimelineEntries([
makeUserMessage('u1'),
makeAssistantMessage('mid'),
makeThinkingMessage('think'),
makeAssistantMessage('final'),
]),
).toEqual([
{
id: 'u1',
label: 'hello',
detail: 'response',
timestamp: undefined,
nodeKinds: ['commentary', 'thought'],
},
]);
});
it('does not prefer assistant text before later tool work as the final detail', () => {
const [entry] = getSessionTimelineEntries([
makeUserMessage('u1'),
{ ...makeAssistantMessage('mid'), content: "I'll check" },
makeAgentToolGroup('tool', 'Read'),
]);
expect(entry?.detail).toBe("I'll check · 1 tool call");
});
it('uses the final answer detail without exposing thinking content', () => {
const [entry] = getSessionTimelineEntries([
makeUserMessage('u1'),
makeThinkingMessage('think', 'private reasoning details'),
makeAssistantMessage('final'),
]);
expect(entry?.detail).toBe('response');
expect(entry?.detail).not.toContain('private');
});
it('falls back to a thinking summary when there is no final answer', () => {
const [entry] = getSessionTimelineEntries([
makeUserMessage('u1'),
makeThinkingMessage('think', 'private reasoning details'),
]);
expect(entry?.detail).toBe('thinking');
expect(entry?.detail).not.toContain('private');
});
it('keeps streaming assistant content in the active turn detail', () => {
expect(
getSessionTimelineEntries([
makeUserMessage('u1'),
{
...makeAssistantMessage('stream'),
content: 'draft',
isStreaming: true,
},
]),
).toEqual([
{
id: 'u1',
label: 'hello',
detail: 'draft',
timestamp: undefined,
nodeKinds: ['commentary'],
},
]);
});
it('summarizes grouped parallel agents in the turn detail', () => {
expect(
getSessionTimelineEntries([
makeUserMessage('u1'),
makeAgentToolGroup('agent-1'),
makeAgentToolGroup('agent-2'),
makeAssistantMessage('final'),
]),
).toEqual([
{
id: 'u1',
label: 'hello',
detail: 'response',
timestamp: undefined,
nodeKinds: ['agents'],
},
]);
});
it('handles empty assistant content and user shell turns', () => {
const entries = getSessionTimelineEntries([
makeUserShellMessage('shell'),
{ id: 'empty', role: 'assistant' } as Message,
makeMultiToolGroup('tools'),
]);
expect(entries).toEqual([
{
id: 'shell',
label: 'npm test',
detail: '2 tool calls',
timestamp: undefined,
nodeKinds: ['tool'],
},
]);
});
it('preserves shell glob syntax in timeline labels', () => {
const [entry] = getSessionTimelineEntries([
{
...makeUserShellMessage('shell'),
command: 'find packages/*/src/*.ts',
},
]);
expect(entry?.label).toBe('find packages/*/src/*.ts');
});
it('keeps a single prompt turn as no activity', () => {
expect(getSessionTimelineEntries([makeUserMessage('u1')])).toEqual([
{
id: 'u1',
label: 'hello',
detail: 'No activity',
timestamp: undefined,
nodeKinds: [],
},
]);
});
it('truncates timeline text without splitting emoji', () => {
const [entry] = getSessionTimelineEntries([
{ ...makeUserMessage('u1'), content: '😀'.repeat(40) },
]);
expect(entry?.label.endsWith('…')).toBe(true);
expect(/[\uD800-\uDFFF]/u.test(entry?.label ?? '')).toBe(false);
});
it('cleans markdown markers from timeline details', () => {
const [entry] = getSessionTimelineEntries([
{
...makeUserMessage('u1'),
content: '介绍下 `agent-reproduce-align`',
},
{
...makeAssistantMessage('a1'),
content:
'**agent-reproduce-align** 对齐测试技能\n\n**用途:** 在 [Qwen Code](https://example.com) 中运行参考代码,中文*强调*·*范围*—*引用*「_下划线_保留 snake_case。',
},
]);
expect(entry?.label).toBe('介绍下 `agent-reproduce-align`');
expect(entry?.detail).toBe(
'agent-reproduce-align 对齐测试技能 用途: 在 Qwen Code 中运行参考代码,中文强调·范围—引用「下划线,保留 snake_case。',
);
});
it('cleans preview markdown without rewriting code text', () => {
const [entry] = getSessionTimelineEntries([
makeUserMessage('u1'),
{
...makeAssistantMessage('a1'),
content:
'# Title\n> quoted\n- item\n~~gone~~\n![alt text](url)\n`*literal*`\n```ts\n**code**\n```',
},
]);
expect(entry?.detail).toBe(
'Title quoted item gone alt text *literal* **code**',
);
});
it('falls back when the final answer cleans to an empty detail', () => {
const [entry] = getSessionTimelineEntries([
makeUserMessage('u1'),
{ ...makeAssistantMessage('a1'), content: '![](url)' },
]);
expect(entry?.detail).toBe('assistant update');
expect(entry?.nodeKinds).toEqual(['commentary']);
});
});
describe('getSessionTimelineRangeForIndexes', () => {
it('maps visible rows to a timeline range and current turn', () => {
const messages = [
makeUserMessage('u1'),
makeMultiToolGroup('tools'),
makeAssistantMessage('a1'),
makeUserMessage('u2'),
makeThinkingMessage('think'),
makeAssistantMessage('a2'),
makeUserMessage('u3'),
makeAssistantMessage('a3'),
];
const entries = getSessionTimelineEntries(messages);
const entryIndexById = new Map(
entries.map((entry, index) => [entry.id, index]),
);
const visibleItems = groupParallelAgents(messages);
const turnIdByDisplayIndex = getTurnIdByDisplayIndex(visibleItems);
expect(
getSessionTimelineRangeForIndexes(
visibleItems,
[1, 2, 3, 4],
entryIndexById,
3,
turnIdByDisplayIndex,
),
).toEqual({
startIndex: 0,
endIndex: 1,
currentIndex: 1,
});
});
it('returns null when visible rows do not belong to a turn', () => {
const entryIndexById = new Map<string, number>([['u1', 0]]);
expect(
getSessionTimelineRangeForIndexes([], [0], entryIndexById, 0),
).toBeNull();
});
it('ignores out-of-bounds rows when mapping the visible range', () => {
const messages = [makeUserMessage('u1'), makeAssistantMessage('a1')];
const entries = getSessionTimelineEntries(messages);
const entryIndexById = new Map(
entries.map((entry, index) => [entry.id, index]),
);
const visibleItems = groupParallelAgents(messages);
expect(
getSessionTimelineRangeForIndexes(
visibleItems,
[-1, 0, 99],
entryIndexById,
99,
),
).toEqual({
startIndex: 0,
endIndex: 0,
currentIndex: 0,
});
});
});
describe('getDisplayItemVirtualKey', () => {
it('keeps message and grouped rows in separate key namespaces', () => {
expect(
getDisplayItemVirtualKey({
type: 'message',
key: 'header',
message: makeUserMessage('header'),
}),
).toBe('msg:header');
expect(
getDisplayItemVirtualKey({
type: 'parallel_agents',
key: 'header',
agents: [makeAgentToolGroup('a').tools[0]],
}),
).toBe('group:header');
});
it('keys live turn rows by their start time', () => {
expect(
getDisplayItemVirtualKey({
type: 'turn_collapse',
key: 'u1',
turnCollapse: {
turnId: 'u1',
collapsed: false,
hiddenCount: 0,
liveStartedAt: 1_000,
},
}),
).toBe('tc:u1:1000');
expect(
getDisplayItemVirtualKey({
type: 'turn_collapse',
key: 'u1',
turnCollapse: {
turnId: 'u1',
collapsed: true,
hiddenCount: 1,
},
}),
).toBe('tc:u1');
});
});
describe('shouldUseVirtualScroll', () => {
it('enables virtual scrolling only above the default threshold', () => {
expect(shouldUseVirtualScroll(VIRTUAL_SCROLL_THRESHOLD - 1)).toBe(false);
expect(shouldUseVirtualScroll(VIRTUAL_SCROLL_THRESHOLD)).toBe(false);
expect(shouldUseVirtualScroll(VIRTUAL_SCROLL_THRESHOLD + 1)).toBe(true);
});
it('accepts a custom threshold', () => {
expect(shouldUseVirtualScroll(50, 50)).toBe(false);
expect(shouldUseVirtualScroll(51, 50)).toBe(true);
});
});
describe('findDisplayItemIndex', () => {
it('finds a row by message id', () => {
const items = groupParallelAgents([
makeUserMessage('u1'),
makeMultiToolGroup('g1'),
makeUserMessage('u2'),
]);
expect(findDisplayItemIndex(items, 'g1')).toBe(1);
expect(findDisplayItemIndex(items, 'missing')).toBe(-1);
});
it('falls back to the call id when the message id was merged away', () => {
// Simulates compact mode, where consecutive tool groups collapse into
// the first group's message id.
const merged: Message = {
id: 'g1',
role: 'tool_group',
tools: [
{ callId: 'call-a', toolName: 'Read', status: 'completed' },
{ callId: 'call-b', toolName: 'TodoWrite', status: 'completed' },
],
};
const items = groupParallelAgents([makeUserMessage('u1'), merged]);
expect(findDisplayItemIndex(items, 'g2', 'call-b')).toBe(1);
expect(findDisplayItemIndex(items, 'g2', 'call-x')).toBe(-1);
});
it('finds tool calls grouped into a parallel agents row', () => {
const items = groupParallelAgents([
makeAgentToolGroup('a1'),
makeAgentToolGroup('a2'),
]);
expect(items).toHaveLength(1);
expect(items[0].type).toBe('parallel_agents');
expect(findDisplayItemIndex(items, 'a2', 'call-a2')).toBe(0);
});
it('skips turn_collapse rows when searching by message id', () => {
const items: DisplayItem[] = [
{ type: 'message', key: 'msg-u1', message: makeUserMessage('u1') },
{
type: 'turn_collapse',
key: 'tc-u1',
turnCollapse: {
turnId: 'u1',
collapsed: true,
hiddenCount: 1,
},
},
{ type: 'message', key: 'msg-a1', message: makeAssistantMessage('a1') },
];
expect(findDisplayItemIndex(items, 'u1')).toBe(0);
expect(findDisplayItemIndex(items, 'a1')).toBe(2);
expect(findDisplayItemIndex(items, 'missing')).toBe(-1);
});
});
function collapseItems(
items: DisplayItem[],
opts: Partial<{
overrides: Map<string, boolean>;
isResponding: boolean;
pendingApprovalCallId: string | null;
enabled: boolean;
}> = {},
): DisplayItem[] {
return applyTurnCollapse(items, {
overrides: opts.overrides ?? new Map(),
isResponding: opts.isResponding ?? false,
pendingApprovalCallId: opts.pendingApprovalCallId ?? null,
enabled: opts.enabled ?? true,
});
}
function rowIds(items: DisplayItem[]): string[] {
return items.flatMap((item) => {
if (item.type === 'turn_content' && item.collapsed) return [];
return item.type === 'message' ? item.message.id : item.key;
});
}
function flattenedRowIds(items: DisplayItem[]): string[] {
return items.flatMap((item) => {
if (item.type === 'turn_content') return flattenedRowIds(item.items);
return item.type === 'message' ? item.message.id : item.key;
});
}
describe('applyTurnCollapse', () => {
it('returns the same array reference when disabled', () => {
const items = groupParallelAgents([
makeUserMessage('u1'),
makeMultiToolGroup('g1'),
makeAssistantMessage('a1'),
]);
expect(collapseItems(items, { enabled: false })).toBe(items);
});
it('returns the same array reference when there are no turns', () => {
const items = groupParallelAgents([
makeAssistantMessage('a1'),
makeMultiToolGroup('g1'),
]);
expect(collapseItems(items)).toBe(items);
});
it('collapses a completed turn to prompt + final answer and tags the head', () => {
const items = groupParallelAgents([
makeUserMessage('u1'),
makeMultiToolGroup('g1'),
makeAssistantMessage('a1'),
]);
const out = collapseItems(items);
expect(rowIds(out)).toEqual(['u1', 'tc-u1', 'a1']);
expect(collapseOf(out, 0)).toEqual({
turnId: 'u1',
collapsed: true,
hiddenCount: 1,
toolCallCount: 2,
});
expect(collapseOf(out, 1)).toBeUndefined();
});
it('keeps every row but still tags the head when the turn is expanded', () => {
const items = groupParallelAgents([
makeUserMessage('u1'),
makeMultiToolGroup('g1'),
makeAssistantMessage('a1'),
]);
const out = collapseItems(items, {
overrides: new Map([['u1', true]]),
});
expect(rowIds(out)).toEqual(['u1', 'tc-u1', 'u1-content-0', 'a1']);
expect(flattenedRowIds(out)).toEqual(['u1', 'tc-u1', 'g1', 'a1']);
expect(collapseOf(out, 0)).toEqual({
turnId: 'u1',
collapsed: false,
hiddenCount: 1,
toolCallCount: 2,
});
});
it('keeps narration followed by a tool visible when expanded', () => {
const items = groupParallelAgents([
makeUserMessage('u1'),
{
id: 'a0',
role: 'assistant',
content: 'I will inspect the project.',
},
makeMultiToolGroup('g1'),
]);
const out = collapseItems(items, {
isResponding: true,
overrides: new Map([['u1', true]]),
});
expect(rowIds(out)).toEqual(['u1', 'tc-u1', 'u1-content-0']);
expect(flattenedRowIds(out)).toEqual(['u1', 'tc-u1', 'a0', 'g1']);
});
it('tags but keeps the active turn expanded while responding', () => {
const items = groupParallelAgents([
makeUserMessage('u1'),
makeMultiToolGroup('g1'),
makeAssistantMessage('a1'),
]);
const out = collapseItems(items, { isResponding: true });
// Every row stays visible; the head carries the seam but is not collapsed.
// The streamed answer is provisional (not a step), so only the tool group
// counts — a step-less reply stays step-less rather than flashing "1 step".
expect(rowIds(out)).toEqual(['u1', 'tc-u1', 'u1-content-0', 'a1']);
expect(flattenedRowIds(out)).toEqual(['u1', 'tc-u1', 'g1', 'a1']);
expect(collapseOf(out, 0)?.collapsed).toBe(false);
expect(collapseOf(out, 0)?.hiddenCount).toBe(1);
});
it('collapses a completed user shell turn', () => {
const items = groupParallelAgents([
makeUserShellMessage('shell'),
makeMultiToolGroup('g1'),
makeAssistantMessage('a1'),
]);
const out = collapseItems(items);
expect(rowIds(out)).toEqual(['shell', 'tc-shell', 'a1']);
expect(collapseOf(out, 'shell')).toEqual({
turnId: 'shell',
collapsed: true,
hiddenCount: 1,
toolCallCount: 2,
});
});
it('collapsing the active turn folds to prompt + seam (no stranded line)', () => {
const items = groupParallelAgents([
makeUserMessage('u1'),
makeMultiToolGroup('g1'),
// An intermediate status line, not a final answer — the turn is still live.
{ id: 'a1', role: 'assistant', content: 'Deterministic analysis clean…' },
]);
const out = collapseItems(items, {
isResponding: true,
overrides: new Map([['u1', false]]),
});
// No final answer yet, so the fold drops the intermediate text too — only
// the prompt row plus its standalone seam survive.
expect(rowIds(out)).toEqual(['u1', 'tc-u1']);
expect(collapseOf(out, 0)?.collapsed).toBe(true);
});
it('keeps collapsed content mounted but hidden', () => {
const items = groupParallelAgents([
makeUserMessage('u1'),
makeMultiToolGroup('g1'),
makeAssistantMessage('a1'),
]);
const out = collapseItems(items, {
overrides: new Map([['u1', false]]),
});
expect(collapseOf(out, 0)?.collapsed).toBe(true);
expect(rowIds(out)).toEqual(['u1', 'tc-u1', 'a1']);
expect(flattenedRowIds(out)).toEqual(['u1', 'tc-u1', 'g1', 'a1']);
const hidden = out[2];
expect(hidden?.type).toBe('turn_content');
if (hidden?.type === 'turn_content') {
expect(hidden.collapsed).toBe(true);
}
});
it('keeps a step-less reply step-less while it streams', () => {
const items = groupParallelAgents([
{ id: 'u1', role: 'user', content: '你好', timestamp: 1_000 },
{
id: 'a1',
role: 'assistant',
content: '你好!',
timestamp: 1_500,
usage: { inputTokens: 100, outputTokens: 20 },
},
]);
const head = collapseOf(collapseItems(items, { isResponding: true }), 0);
// The streamed answer is provisional, not a step → nothing to fold, so no
// chevron flashes in then out when the turn completes.
expect(head?.hiddenCount).toBe(0);
});
it('marks the active turn live with its prompt timestamp', () => {
const items = groupParallelAgents([
{ id: 'u1', role: 'user', content: 'hi', timestamp: 1_000 },
{
id: 'g1',
role: 'tool_group',
tools: [{ callId: 'c1', toolName: 'Read', status: 'in_progress' }],
timestamp: 2_000,
},
]);
const head = collapseOf(collapseItems(items, { isResponding: true }), 0);
expect(head?.liveStartedAt).toBe(1_000);
});
it('marks a prompt-only active turn live with its prompt timestamp', () => {
const items = groupParallelAgents([
{ id: 'u1', role: 'user', content: 'hi', timestamp: 1_000 },
]);
const head = collapseOf(collapseItems(items, { isResponding: true }), 0);
expect(head).toMatchObject({
collapsed: false,
hiddenCount: 0,
liveStartedAt: 1_000,
});
});
it('marks a prompt-only active turn live without a prompt timestamp', () => {
vi.useFakeTimers();
vi.setSystemTime(10_000);
try {
const items = groupParallelAgents([
{ id: 'u1', role: 'user', content: 'hi' },
]);
const head = collapseOf(collapseItems(items, { isResponding: true }), 0);
expect(head).toMatchObject({
collapsed: false,
hiddenCount: 0,
liveStartedAt: 10_000,
});
} finally {
vi.useRealTimers();
}
});
it('does not mark a completed turn live', () => {
const items = groupParallelAgents([
{ id: 'u1', role: 'user', content: 'hi', timestamp: 1_000 },
{
id: 'g1',
role: 'tool_group',
tools: [{ callId: 'c1', toolName: 'Read', status: 'completed' }],
timestamp: 2_000,
},
{ id: 'a1', role: 'assistant', content: 'done', timestamp: 3_000 },
]);
expect(collapseOf(collapseItems(items), 0)?.liveStartedAt).toBeUndefined();
});
it('collapses earlier turns but leaves the active last turn expanded', () => {
const items = groupParallelAgents([
makeUserMessage('u1'),
makeMultiToolGroup('g1'),
makeAssistantMessage('a1'),
makeUserMessage('u2'),
makeMultiToolGroup('g2'),
]);
const out = collapseItems(items, { isResponding: true });
expect(rowIds(out)).toEqual([
'u1',
'tc-u1',
'a1',
'u2',
'tc-u2',
'u2-content-0',
]);
expect(flattenedRowIds(out)).toEqual([
'u1',
'tc-u1',
'g1',
'a1',
'u2',
'tc-u2',
'g2',
]);
expect(collapseOf(out, 0)?.collapsed).toBe(true);
expect(collapseOf(out, 'u2')?.collapsed).toBe(false);
});
it('shows live metrics on the active turn without collapsing it', () => {
const items = groupParallelAgents([
{ id: 'u1', role: 'user', content: 'hi', timestamp: 1_000 },
{
id: 'g1',
role: 'tool_group',
tools: [{ callId: 'c1', toolName: 'Read', status: 'in_progress' }],
timestamp: 3_000,
},
{
id: 'a1',
role: 'assistant',
content: 'working',
timestamp: 3_500,
usage: { inputTokens: 120, outputTokens: 30 },
},
]);
const out = collapseItems(items, { isResponding: true });
// Active turn stays fully expanded, yet the seam carries live metrics.
expect(rowIds(out)).toEqual(['u1', 'tc-u1', 'u1-content-0', 'a1']);
expect(flattenedRowIds(out)).toEqual(['u1', 'tc-u1', 'g1', 'a1']);
const head = collapseOf(out, 0);
expect(head?.collapsed).toBe(false);
expect(head?.elapsedMs).toBe(2_500);
expect(head?.inputTokens).toBe(120);
expect(head?.outputTokens).toBe(30);
});
it('does not tag a turn with no intermediate steps', () => {
const items = groupParallelAgents([
makeUserMessage('u1'),
makeAssistantMessage('a1'),
]);
const out = collapseItems(items);
expect(rowIds(out)).toEqual(['u1', 'a1']);
expect(collapseOf(out, 0)).toBeUndefined();
});
it('keeps a turn with no final answer expanded by default', () => {
const items = groupParallelAgents([
makeUserMessage('u1'),
makeMultiToolGroup('g1'),
makeMultiToolGroup('g2'),
]);
const out = collapseItems(items);
expect(rowIds(out)).toEqual(['u1', 'tc-u1', 'u1-content-0']);
expect(flattenedRowIds(out)).toEqual(['u1', 'tc-u1', 'g1', 'g2']);
expect(collapseOf(out, 0)).toEqual({
turnId: 'u1',
collapsed: false,
hiddenCount: 2,
toolCallCount: 4,
});
});
it('still allows manually collapsing a turn with no final answer', () => {
const items = groupParallelAgents([
makeUserMessage('u1'),
makeMultiToolGroup('g1'),
makeMultiToolGroup('g2'),
]);
const out = collapseItems(items, {
overrides: new Map([['u1', false]]),
});
expect(rowIds(out)).toEqual(['u1', 'tc-u1']);
expect(collapseOf(out, 0)?.collapsed).toBe(true);
});
it('keeps a turn with a turn error expanded by default', () => {
const items = groupParallelAgents([
makeUserMessage('u1'),
makeMultiToolGroup('g1'),
{
id: 's1',
role: 'system',
content: 'The turn failed.',
variant: 'error',
source: 'turn_error',
},
]);
const out = collapseItems(items);
expect(rowIds(out)).toEqual(['u1', 'tc-u1', 'u1-content-0']);
expect(flattenedRowIds(out)).toEqual(['u1', 'tc-u1', 'g1', 's1']);
expect(collapseOf(out, 0)).toEqual({
turnId: 'u1',
collapsed: false,
hiddenCount: 1,
toolCallCount: 2,
});
});
it('keeps a turn error expanded even when a final answer is present', () => {
const items = groupParallelAgents([
makeUserMessage('u1'),
makeMultiToolGroup('g1'),
makeAssistantMessage('a1'),
{
id: 's1',
role: 'system',
content: 'The turn failed.',
variant: 'error',
source: 'turn_error',
},
]);
const out = collapseItems(items);
expect(rowIds(out)).toEqual([
'u1',
'tc-u1',
'u1-content-0',
'a1',
'u1-content-1',
]);
expect(flattenedRowIds(out)).toEqual(['u1', 'tc-u1', 'g1', 'a1', 's1']);
expect(collapseOf(out, 0)).toEqual({
turnId: 'u1',
collapsed: false,
hiddenCount: 1,
toolCallCount: 2,
});
});
it('folds thinking separately from the final answer', () => {
const items = groupParallelAgents([
makeUserMessage('u1'),
makeMultiToolGroup('g1'),
makeThinkingMessage('t1'),
{ id: 'a1', role: 'assistant', content: 'final answer' },
]);
const collapsed = collapseItems(items);
expect(rowIds(collapsed)).toEqual(['u1', 'tc-u1', 'a1']);
expect(collapseOf(collapsed, 0)?.thinkingCount).toBe(1);
const collapsedAnswer = messageById(collapsed, 'a1').message;
expect(collapsedAnswer.role).toBe('assistant');
if (collapsedAnswer.role === 'assistant') {
expect(collapsedAnswer.content).toBe('final answer');
}
const expanded = collapseItems(items, {
overrides: new Map([['u1', true]]),
});
expect(rowIds(expanded)).toEqual(['u1', 'tc-u1', 'u1-content-0', 'a1']);
});
it('passes through rows that precede the first turn', () => {
const items = groupParallelAgents([
makeAssistantMessage('pre'),
makeUserMessage('u1'),
makeMultiToolGroup('g1'),
makeAssistantMessage('a1'),
]);
const out = collapseItems(items);
expect(rowIds(out)).toEqual(['pre', 'u1', 'tc-u1', 'a1']);
expect(collapseOf(out, 0)).toBeUndefined();
expect(collapseOf(out, 1)?.collapsed).toBe(true);
});
it('keeps system rows (errors/output) visible while hiding tool steps', () => {
const items = groupParallelAgents([
makeUserMessage('u1'),
makeMultiToolGroup('g1'),
makeSystemMessage('s1'),
makeAssistantMessage('a1'),
]);
const out = collapseItems(items);
expect(rowIds(out)).toEqual(['u1', 'tc-u1', 's1', 'a1']);
expect(collapseOf(out, 0)?.hiddenCount).toBe(1);
});
it('hides mid-turn injected debug rows with collapsed tool steps', () => {
const items = groupParallelAgents([
makeUserMessage('u1'),
makeMultiToolGroup('g1'),
{
id: 's1',
role: 'system',
content: '已插入消息hi',
variant: 'info',
source: 'mid_turn_message_injected',
},
makeAssistantMessage('a1'),
]);
const out = collapseItems(items);
expect(rowIds(out)).toEqual(['u1', 'tc-u1', 'a1']);
expect(collapseOf(out, 0)?.hiddenCount).toBe(2);
});
it('does not collapse a turn whose only response is a system row', () => {
const items = groupParallelAgents([
makeUserMessage('u1'),
makeSystemMessage('s1'),
]);
const out = collapseItems(items);
expect(rowIds(out)).toEqual(['u1', 's1']);
expect(collapseOf(out, 0)).toBeUndefined();
});
it('hides mid-turn assistant narration but keeps the final answer', () => {
const items = groupParallelAgents([
makeUserMessage('u1'),
makeAssistantMessage('mid'),
makeMultiToolGroup('g1'),
makeAssistantMessage('a1'),
]);
const out = collapseItems(items);
expect(rowIds(out)).toEqual(['u1', 'tc-u1', 'a1']);
expect(collapseOf(out, 0)?.hiddenCount).toBe(2);
});
it('hides plan rows', () => {
const items = groupParallelAgents([
makeUserMessage('u1'),
makePlanMessage('p1'),
makeAssistantMessage('a1'),
]);
const out = collapseItems(items);
expect(rowIds(out)).toEqual(['u1', 'tc-u1', 'a1']);
expect(collapseOf(out, 0)?.hiddenCount).toBe(1);
});
it('counts a grouped parallel-agents row as one hidden step', () => {
const items = groupParallelAgents([
makeUserMessage('u1'),
makeAgentToolGroup('x1'),
makeAgentToolGroup('x2'),
makeAssistantMessage('a1'),
]);
// x1/x2 collapse into a single parallel_agents row upstream.
const out = collapseItems(items);
expect(rowIds(out)).toEqual(['u1', 'tc-u1', 'a1']);
expect(collapseOf(out, 0)?.hiddenCount).toBe(1);
});
it('treats an assistant row with undefined content as a non-answer without crashing', () => {
const items = groupParallelAgents([
makeUserMessage('u1'),
makeMultiToolGroup('g1'),
// Daemon SSE can leave content undefined despite the `string` type.
{ id: 'x', role: 'assistant', content: undefined as unknown as string },
]);
const out = collapseItems(items);
// No assistant-with-content → no final answer → stays expanded.
expect(rowIds(out)).toEqual(['u1', 'tc-u1', 'u1-content-0']);
expect(flattenedRowIds(out)).toEqual(['u1', 'tc-u1', 'g1', 'x']);
expect(collapseOf(out, 0)?.hiddenCount).toBe(2);
});
it('force-expands a completed turn that holds a pending approval', () => {
const items = groupParallelAgents([
makeUserMessage('u1'),
makeMultiToolGroup('g1'),
makeAssistantMessage('a1'),
]);
// call-g1-a belongs to g1's tool group → the turn must stay expanded so
// its inline approve/reject UI is reachable.
const out = collapseItems(items, { pendingApprovalCallId: 'call-g1-a' });
expect(rowIds(out)).toEqual(['u1', 'g1', 'a1']);
expect(collapseOf(out, 0)).toBeUndefined();
});
it('still collapses when the pending approval is in a different turn', () => {
const items = groupParallelAgents([
makeUserMessage('u1'),
makeMultiToolGroup('g1'),
makeAssistantMessage('a1'),
]);
const out = collapseItems(items, { pendingApprovalCallId: 'call-other' });
expect(rowIds(out)).toEqual(['u1', 'tc-u1', 'a1']);
expect(collapseOf(out, 0)?.collapsed).toBe(true);
});
it('records elapsed (prompt → last step) and token usage on the head', () => {
const items = groupParallelAgents([
{ id: 'u1', role: 'user', content: 'hi', timestamp: 1_000 },
{
id: 'g1',
role: 'tool_group',
tools: [{ callId: 'c1', toolName: 'Read', status: 'completed' }],
timestamp: 2_000,
},
{
id: 'a1',
role: 'assistant',
content: 'final',
timestamp: 5_000,
usage: { inputTokens: 3100, outputTokens: 5100 },
},
]);
const out = collapseItems(items);
expect(collapseOf(out, 0)).toEqual({
turnId: 'u1',
collapsed: true,
hiddenCount: 1,
elapsedMs: 4_000,
inputTokens: 3100,
outputTokens: 5100,
toolCallCount: 1,
});
});
it('ignores non-step system timestamps when recording elapsed', () => {
const items = groupParallelAgents([
{ id: 'u1', role: 'user', content: 'hi', timestamp: 1_000 },
{
id: 'g1',
role: 'tool_group',
tools: [{ callId: 'c1', toolName: 'Read', status: 'completed' }],
timestamp: 2_000,
},
{
id: 'a1',
role: 'assistant',
content: 'final',
timestamp: 5_000,
},
{
id: 's1',
role: 'system',
content: 'late title refresh',
variant: 'info',
timestamp: 100_000,
},
]);
const head = collapseOf(collapseItems(items), 0);
expect(head?.elapsedMs).toBe(4_000);
});
it('ignores replay-stamped step timestamps after the final answer', () => {
const items = groupParallelAgents([
{ id: 'u1', role: 'user', content: 'hi', timestamp: 1_000 },
{
id: 'g1',
role: 'tool_group',
tools: [{ callId: 'c1', toolName: 'Read', status: 'completed' }],
timestamp: 2_000,
},
{
id: 'a1',
role: 'assistant',
content: 'final',
timestamp: 5_000,
},
{
id: 'p1',
role: 'plan',
todos: [],
timestamp: 100_000,
},
{ id: 'u2', role: 'user', content: 'next', timestamp: 6_000 },
]);
const head = collapseOf(collapseItems(items), 'u1');
expect(head?.elapsedMs).toBe(4_000);
});
it('ignores empty assistant usage rows when recording elapsed', () => {
const items = groupParallelAgents([
{ id: 'u1', role: 'user', content: 'hi', timestamp: 1_000 },
{
id: 'a0',
role: 'assistant',
content: '',
timestamp: 100_000,
usage: { inputTokens: 100, outputTokens: 10 },
},
{
id: 'a1',
role: 'assistant',
content: 'final',
timestamp: 3_000,
},
{ id: 'u2', role: 'user', content: 'next', timestamp: 4_000 },
]);
const head = collapseOf(collapseItems(items), 'u1');
expect(head?.elapsedMs).toBe(2_000);
expect(head?.inputTokens).toBe(100);
expect(head?.outputTokens).toBe(10);
});
it('omits elapsed when there is no assistant content timestamp', () => {
const items = groupParallelAgents([
{ id: 'u1', role: 'user', content: 'hi', timestamp: 1_000 },
{
id: 'g1',
role: 'tool_group',
tools: [{ callId: 'c1', toolName: 'Read', status: 'completed' }],
timestamp: 100_000,
},
{
id: 'p1',
role: 'plan',
todos: [],
timestamp: 100_000,
},
]);
const head = collapseOf(collapseItems(items), 'u1');
expect(head?.elapsedMs).toBeUndefined();
});
it('uses turn error time when the turn fails', () => {
const items = groupParallelAgents([
{ id: 'u1', role: 'user', content: 'hi', timestamp: 1_000 },
{
id: 'a1',
role: 'assistant',
content: 'partial',
timestamp: 3_000,
},
{
id: 'e1',
role: 'system',
content: 'failed',
variant: 'error',
source: 'turn_error',
timestamp: 5_000,
},
]);
const head = collapseOf(collapseItems(items), 'u1');
expect(head?.elapsedMs).toBe(4_000);
});
it('uses prompt cancelled time when the turn is cancelled', () => {
const items = groupParallelAgents([
{ id: 'u1', role: 'user', content: 'hi', timestamp: 1_000 },
{
id: 'a1',
role: 'assistant',
content: 'partial',
timestamp: 3_000,
},
{
id: 'c1',
role: 'system',
content: 'cancelled',
variant: 'info',
source: 'prompt_cancelled',
timestamp: 6_000,
},
]);
const head = collapseOf(collapseItems(items), 'u1');
expect(head?.elapsedMs).toBe(5_000);
});
it('sums token usage across a turn (hidden mid-turn text + final answer)', () => {
const items = groupParallelAgents([
{ id: 'u1', role: 'user', content: 'hi', timestamp: 1_000 },
{
id: 'a0',
role: 'assistant',
content: 'mid-turn note',
timestamp: 2_000,
usage: { inputTokens: 100, outputTokens: 50 },
},
{
id: 'g1',
role: 'tool_group',
tools: [{ callId: 'c1', toolName: 'Read', status: 'completed' }],
timestamp: 3_000,
},
{
id: 'a1',
role: 'assistant',
content: 'final',
timestamp: 4_000,
usage: { inputTokens: 200, outputTokens: 80 },
},
]);
const head = collapseOf(collapseItems(items), 0);
expect(head?.inputTokens).toBe(300);
expect(head?.outputTokens).toBe(130);
expect(head?.elapsedMs).toBe(3_000);
expect(head?.toolCallCount).toBe(1);
});
it('counts visible tool calls across regular and grouped agent rows', () => {
const items = groupParallelAgents([
{ id: 'u1', role: 'user', content: 'hi', timestamp: 1_000 },
{
id: 'g1',
role: 'tool_group',
tools: [
{ callId: 'c1', toolName: 'Read', status: 'completed' },
{ callId: 'c2', toolName: 'Write', status: 'completed' },
],
timestamp: 2_000,
},
{
id: 'agent-1',
role: 'tool_group',
tools: [
{
callId: 'a1',
toolName: 'agent',
status: 'completed',
subTools: [
{ callId: 'a1-read', toolName: 'Read', status: 'completed' },
{
callId: 'a1-shell',
toolName: 'Shell',
status: 'completed',
subTools: [
{
callId: 'a1-shell-child',
toolName: 'Parse',
status: 'completed',
},
],
},
],
},
],
timestamp: 3_000,
},
{
id: 'agent-2',
role: 'tool_group',
tools: [
{
callId: 'a2',
toolName: 'agent',
status: 'completed',
},
],
timestamp: 4_000,
},
{
id: 'a1',
role: 'assistant',
content: 'final',
timestamp: 5_000,
},
]);
const head = collapseOf(collapseItems(items), 0);
expect(head?.toolCallCount).toBe(4);
});
it('omits elapsed/usage when the turn carries no timestamps or usage', () => {
const items = groupParallelAgents([
makeUserMessage('u1'),
makeMultiToolGroup('g1'),
makeAssistantMessage('a1'),
]);
const head = collapseOf(collapseItems(items), 0);
expect(head).toEqual({
turnId: 'u1',
collapsed: true,
hiddenCount: 1,
toolCallCount: 2,
});
});
it('shows a chevron-less metrics seam on a step-less turn', () => {
const items = groupParallelAgents([
{ id: 'u1', role: 'user', content: '你好', timestamp: 1_000 },
{
id: 'a1',
role: 'assistant',
content: '你好!有什么可以帮你的吗?',
timestamp: 1_900,
usage: { inputTokens: 1200, outputTokens: 45 },
},
]);
const out = collapseItems(items);
// Nothing foldable, but the metrics still surface and all rows stay visible.
expect(rowIds(out)).toEqual(['u1', 'tc-u1', 'a1']);
const head = collapseOf(out, 0);
expect(head?.hiddenCount).toBe(0);
expect(head?.collapsed).toBe(false);
expect(head?.elapsedMs).toBe(900);
expect(head?.inputTokens).toBe(1200);
expect(head?.outputTokens).toBe(45);
});
it("folds the final answer's thinking even without tool steps", () => {
const items = groupParallelAgents([
{ id: 'u1', role: 'user', content: '你好', timestamp: 1_000 },
{
id: 't1',
role: 'thinking',
content: 'The user sent a simple greeting.',
timestamp: 1_900,
},
{
id: 'a1',
role: 'assistant',
content: '你好!有什么可以帮你的?',
timestamp: 1_900,
usage: { inputTokens: 1200, outputTokens: 45 },
},
]);
const out = collapseItems(items);
expect(rowIds(out)).toEqual(['u1', 'tc-u1', 'a1']);
const head = collapseOf(out, 0);
expect(head?.hiddenCount).toBe(1);
expect(head?.collapsed).toBe(true);
expect(head?.thinkingCount).toBe(1);
const collapsedAnswer = messageById(out, 'a1').message;
expect(collapsedAnswer.role).toBe('assistant');
if (collapsedAnswer.role === 'assistant') {
expect(collapsedAnswer.content).toBe('你好!有什么可以帮你的?');
}
});
it('sums cached-read tokens across the turn', () => {
const items = groupParallelAgents([
{ id: 'u1', role: 'user', content: 'hi', timestamp: 1_000 },
{
id: 'g1',
role: 'tool_group',
tools: [{ callId: 'c1', toolName: 'Read', status: 'completed' }],
timestamp: 2_000,
},
{
id: 'a1',
role: 'assistant',
content: 'done',
timestamp: 3_000,
usage: { inputTokens: 2000, outputTokens: 100, cachedTokens: 1800 },
},
]);
expect(collapseOf(collapseItems(items), 0)?.cachedTokens).toBe(1800);
});
});
describe('findTurnIdForIndex', () => {
it('maps each row to the prompt that heads its turn', () => {
const items = groupParallelAgents([
makeUserMessage('u1'),
makeMultiToolGroup('g1'),
makeUserMessage('u2'),
makeMultiToolGroup('g2'),
]);
expect(findTurnIdForIndex(items, 0)).toBe('u1');
expect(findTurnIdForIndex(items, 1)).toBe('u1');
expect(findTurnIdForIndex(items, 2)).toBe('u2');
expect(findTurnIdForIndex(items, 3)).toBe('u2');
});
it('returns null for rows before the first turn', () => {
const items = groupParallelAgents([
makeAssistantMessage('pre'),
makeUserMessage('u1'),
]);
expect(findTurnIdForIndex(items, 0)).toBeNull();
});
it('precomputes turn ids for display rows', () => {
const items = groupParallelAgents([
makeAssistantMessage('pre'),
makeUserMessage('u1'),
makeMultiToolGroup('g1'),
makeUserShellMessage('shell'),
makeMultiToolGroup('g2'),
]);
expect(getTurnIdByDisplayIndex(items)).toEqual([
null,
'u1',
'u1',
'shell',
'shell',
]);
});
});