Files
k88936 6981648796
Project CI / AI game creator shell Rust crates (pull_request) Successful in 2m56s
Project CI / Backend tests (pull_request) Failing after 12s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 3m48s
Project CI / Native shell tests (pull_request) Failing after 45s
Project CI / Repository checks (pull_request) Failing after 13s
Project CI / Frontend tests (pull_request) Failing after 1m52s
Project CI / AI game creator shell web tests (pull_request) Failing after 1m42s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Failing after 6m57s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Failing after 8m1s
合并 origin/master:Supervisor 永久退役,项目对话收敛为 DirectProject 与 Design Agent
- 解决 refactor/split-direct-project 与 origin/master 在 App.tsx、立项策划聊天视图、Direct composer/引用输入区、styles.css、Rust direct user item 与 appSurface 用例上的冲突,按「Supervisor 永久退役」口径保留 DirectProject 独立聊天容器与 Design Agent 两条产品路径
- 采纳 master 的策划 Agent V1/V2 退役:删除 GDD 审批卡、策划输入卡、planningLane、planningSessionV2、planningSessionContract、规划展示适配与 Rust planning_*_v2 命令、模块、契约及对应用例,不保留兼容别名或双跑路径
- 把 master「折叠思考显示单行预览」的目的落到当前结构:新增共享表现 chat/components/AgentReasoning/AgentReasoning.tsx(折叠态单行纯文本预览 + 箭头、展开态安全 Markdown),DirectProject 回合与策划回合共用,删掉两处写死的 pre 折叠实现
- 把 master「策划入口可选模型 / 推理档」的目的接到当前策划输入盒:复用 ConversationModelSelect 与 ComposerReasoningEffortSelect,配置写回仍走客户端配置通道
- App.tsx 删除只服务退役 Supervisor / 策划 V2 的 state、ref、effect、回调与死参数,并删除两条读路径都退役后的 workspaceProjectKind;openWorkspace 的工程类型入参保留为未使用契约
- Rust 侧保留本分支 canonical→wire 投影、无审计 Direct 回合与 direct user item 严格校验,并入 master 的 prepare_new_web_project_at 前置复核
- 更新 ADR 与 shared-memory 决策记录:策划当前只有 Design Agent、两条路径的共享表现清单,以及本次合并的口径、代价与验证证据
- 验证:AGC 与仓库 typecheck、check:encoding、check:doc-index、git diff --check、改动文件 eslint 0 error;AGC vitest 168 个文件中除 5 个 jsdom localStorage 环境失败文件与本分支既有 resourceTagStatsRefresh 失败外全绿,appSurface 198 passed / 13 skipped;Rust 定向用例 direct_codex_user_item、skill_pack、sessions 全过(整套分片在本容器受 /sbin -> usr/bin 触发沙箱预检失败,与本合并无关)
2026-09-21 21:09:34 +08:00

701 lines
26 KiB
TypeScript
Raw Permalink 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 { act } from '@testing-library/react';
import { vi } from 'vitest';
import type { GameCreatorDirectToolCall } from '../../src/app/types';
import { ToolCallGroup } from '../../src/view/project-development/chat/components/ToolCallGroup/ToolCallGroup.tsx';
import {
formatClockTime,
formatToolCallDuration,
formatTurnDuration,
resolveToolGroupTiming,
resolveTurnTiming,
toolCallDurationMs,
toolCallGroupSummary,
toolCallRowText,
turnTotalDurationMs,
} from '../../src/view/project-development/chat/components/ToolCallGroup/toolCallGroupPresentation';
import { expect, fireEvent, it, React, render, within } from './harness';
function toolCall(
overrides: Partial<GameCreatorDirectToolCall> &
Pick<GameCreatorDirectToolCall, 'id' | 'kind'>,
): GameCreatorDirectToolCall {
return {
schemaVersion: 'agc-tool-call.v1',
turnId: 'turn-1',
title: '执行命令',
summary: 'npm run build',
status: 'completed',
detail: {},
startedAt: 0,
updatedAt: 0,
...overrides,
};
}
export function registerToolCallGroupTests() {
it('keeps failed operations visible when another tool in the group is running', () => {
const view = render(
React.createElement(ToolCallGroup, {
active: true,
calls: [
toolCall({ id: 'failed', kind: 'command', status: 'failed' }),
toolCall({ id: 'live', kind: 'command', status: 'running' }),
toolCall({ id: 'done', kind: 'command', status: 'completed' }),
],
}),
);
const group = within(view.container).getByTestId('agent-tool-call-group');
const head = within(group).getByTestId('agent-tool-call-group-head');
expect(group.getAttribute('data-status')).toBe('running');
expect(group.getAttribute('data-has-failure')).toBe('true');
expect(head.textContent).toContain('有操作失败');
expect(head.textContent).toContain('进行中');
fireEvent.click(head);
const rows = within(group).getAllByTestId('agent-tool-call-row');
expect(rows.map((row) => row.getAttribute('data-status'))).toEqual([
'failed',
'running',
'completed',
]);
expect(within(rows[0]).getByText('失败')).not.toBeNull();
expect(within(rows[2]).queryByText('失败')).toBeNull();
});
it('summarizes the group as its own operation count', () => {
// 用户口径:`执行了 X 个操作`,X = 这一组自己的调用数(不拆 kind、不看段落数)。
expect(toolCallGroupSummary([toolCall({ id: 'a', kind: 'command' })])).toBe(
'执行了 1 个操作',
);
expect(
toolCallGroupSummary([
toolCall({ id: 'a', kind: 'other' }),
toolCall({ id: 'b', kind: 'web_search' }),
toolCall({ id: 'c', kind: 'command' }),
toolCall({ id: 'd', kind: 'command' }),
toolCall({ id: 'e', kind: 'file_change' }),
toolCall({ id: 'f', kind: 'context_compaction' }),
toolCall({ id: 'g', kind: 'mcp_tool' }),
]),
).toBe('执行了 7 个操作');
// 空集合不渲染汇总。
expect(toolCallGroupSummary([])).toBe('');
});
it('builds the row text from the tool kind', () => {
expect(
toolCallRowText(
toolCall({ id: 'a', kind: 'command', summary: 'npm run build' }),
),
).toBe('npm run build');
expect(
toolCallRowText(
toolCall({
id: 'b',
kind: 'file_change',
summary: 'game/src/hero.ts',
}),
),
).toBe('game/src/hero.ts');
expect(
toolCallRowText(
toolCall({ id: 'c', kind: 'mcp_tool', summary: 'canvas.sync' }),
),
).toBe('canvas.sync');
expect(
toolCallRowText(
toolCall({ id: 'd', kind: 'web_search', summary: '弹幕游戏玩法' }),
),
).toBe('弹幕游戏玩法');
// 上下文整理不带 summary。
expect(
toolCallRowText(
toolCall({
id: 'e',
kind: 'context_compaction',
summary: 'should be ignored',
}),
),
).toBe('整理上下文');
expect(
toolCallRowText(
toolCall({ id: 'f', kind: 'other', summary: '未知动作' }),
),
).toBe('未知动作');
});
it('renders one collapsed block per turn and lists every tool on expand', async () => {
const calls = [
toolCall({ id: 'a', kind: 'command', summary: 'npm run build' }),
toolCall({
id: 'b',
kind: 'file_change',
title: '编辑 1 个文件',
summary: 'game/src/hero.ts',
status: 'running',
detail: { changes: [{ path: 'game/src/hero.ts', kind: 'update' }] },
}),
];
const { container } = render(React.createElement(ToolCallGroup, { calls }));
const group = container.querySelector(
'[data-testid="agent-tool-call-group"]',
) as HTMLElement;
expect(group).not.toBeNull();
expect(group.getAttribute('data-agent-content')).toBe('process');
const head = within(group).getByTestId('agent-tool-call-group-head');
// 块头是一行按钮:图标 + 汇总 + 展开箭头,默认折叠,正文由 `hidden` 收起。
expect(head.tagName).toBe('BUTTON');
expect(head.getAttribute('aria-expanded')).toBe('false');
expect(head.getAttribute('aria-label')).toBe('执行了 2 个操作');
expect(
head.querySelector('.agent-process-summary-preview')?.textContent,
).toBe('执行了 2 个操作');
const body = container.querySelector(
`#${head.getAttribute('aria-controls')}`,
);
expect(body?.hasAttribute('hidden')).toBe(true);
expect(within(group).queryAllByTestId('agent-tool-call-row')).toHaveLength(
2,
);
// 展开:行数 = 工具数,每行一条,按 startedAt 升序。
fireEvent.click(head);
expect(head.getAttribute('aria-expanded')).toBe('true');
expect(body?.hasAttribute('hidden')).toBe(false);
const rows = within(group).queryAllByTestId('agent-tool-call-row');
expect(rows).toHaveLength(2);
expect(rows.map((row) => row.getAttribute('data-kind'))).toEqual([
'command',
'file_change',
]);
expect(
within(rows[0] as HTMLElement).getByText('npm run build'),
).not.toBeNull();
expect(
within(rows[1] as HTMLElement).getAllByText('game/src/hero.ts')[0],
).not.toBeNull();
// 已结束回合里的 running 残留快照不能继续显示「执行中」。
expect(within(rows[1] as HTMLElement).getByText('已执行')).not.toBeNull();
});
it('expands a row to its own detail and renders nothing for an empty turn', () => {
const { container } = render(
React.createElement(ToolCallGroup, {
calls: [
toolCall({
id: 'a',
kind: 'command',
summary: 'npm run build',
detail: {
command: "pwsh -Command 'npm run build'",
output: 'build ok',
},
}),
toolCall({
id: 'b',
kind: 'file_change',
title: '编辑 1 个文件',
summary: 'game/src/hero.ts',
status: 'failed',
detail: { changes: [{ path: 'game/src/hero.ts', kind: 'add' }] },
}),
],
}),
);
const head = container.querySelector(
'[data-testid="agent-tool-call-group-head"]',
) as HTMLElement;
fireEvent.click(head);
const rows = container.querySelectorAll(
'[data-testid="agent-tool-call-row"]',
);
// 行也是按钮:`aria-expanded` + `aria-controls` 指向自己的明细,默认折叠。
const commandHead = within(rows[0] as HTMLElement).getByRole('button');
expect(commandHead.getAttribute('aria-expanded')).toBe('false');
expect(commandHead.getAttribute('aria-label')).toBe(
'npm run build,已执行',
);
const commandDetail = container.querySelector(
`#${commandHead.getAttribute('aria-controls')}`,
);
expect(commandDetail?.hasAttribute('hidden')).toBe(true);
fireEvent.click(commandHead);
expect(commandHead.getAttribute('aria-expanded')).toBe('true');
expect(commandDetail?.hasAttribute('hidden')).toBe(false);
expect(
commandDetail?.querySelector('.agent-tool-call-row-command')?.textContent,
).toBe('npm run build');
expect(
within(commandDetail as HTMLElement).getByText('build ok'),
).not.toBeNull();
// 文件变更明细:路径 + 变更类型。
const fileHead = within(rows[1] as HTMLElement).getByRole('button');
expect(fileHead.getAttribute('aria-label')).toBe('game/src/hero.ts,失败');
fireEvent.click(fileHead);
const fileDetail = container.querySelector(
`#${fileHead.getAttribute('aria-controls')}`,
);
expect(
within(fileDetail as HTMLElement).getAllByText('game/src/hero.ts')[0],
).not.toBeNull();
expect(within(fileDetail as HTMLElement).getByText('新增')).not.toBeNull();
// 空集合不渲染块。
const empty = render(React.createElement(ToolCallGroup, { calls: [] }));
expect(empty.container.firstChild).toBeNull();
});
it('formats durations and turn totals across the documented boundaries', () => {
// 单条工具耗时:开始边界缺失(0)/ 终点缺失 / 时间倒序 → 不显示耗时。
expect(
toolCallDurationMs(toolCall({ id: 'a', kind: 'command' })),
).toBeNull();
expect(formatToolCallDuration(null)).toBeNull();
expect(
toolCallDurationMs(
toolCall({
id: 'b',
kind: 'command',
startedAt: 2000,
updatedAt: 1000,
}),
),
).toBeNull();
// 合法 0 显示 0.0秒;分钟以内保留小数,达到分钟后显示整数秒。
expect(formatToolCallDuration(0)).toBe('0.0秒');
expect(formatToolCallDuration(400)).toBe('0.4秒');
expect(formatToolCallDuration(950)).toBe('1.0秒');
expect(formatToolCallDuration(12300)).toBe('12.3秒');
expect(formatToolCallDuration(12000)).toBe('12.0秒');
expect(formatToolCallDuration(59900)).toBe('59.9秒');
expect(formatToolCallDuration(60000)).toBe('1分00秒');
expect(formatToolCallDuration(125000)).toBe('2分05秒');
// 整轮总耗时 = 本轮起点 → 本轮终态(不是块内工具的时间跨度)。
expect(turnTotalDurationMs({ startedAt: 1000, endedAt: 9000 })).toBe(8000);
// 运行中用调用方给的宿主时钟当终点;结束早于开始 / 缺任一端都算不出来。
expect(
turnTotalDurationMs({ startedAt: 1000, running: true, now: 4200 }),
).toBe(3200);
expect(turnTotalDurationMs({ startedAt: 1000, endedAt: 900 })).toBeNull();
// 倒序不能被 100ms 展示舍入掩盖成合法 0.0 秒。
expect(turnTotalDurationMs({ startedAt: 1049, endedAt: 1001 })).toBeNull();
expect(turnTotalDurationMs({ startedAt: 0, endedAt: 9000 })).toBeNull();
expect(turnTotalDurationMs({ startedAt: 1000, endedAt: 0 })).toBeNull();
// 总耗时复用同一格式,达到分钟后不再显示小数。
expect(formatTurnDuration(0)).toBe('0.0秒');
expect(formatTurnDuration(8000)).toBe('8.0秒');
expect(formatTurnDuration(42000)).toBe('42.0秒');
expect(formatTurnDuration(59900)).toBe('59.9秒');
expect(formatTurnDuration(60000)).toBe('1分00秒');
expect(formatTurnDuration(240000)).toBe('4分00秒');
expect(formatTurnDuration(345000)).toBe('5分45秒');
expect(formatTurnDuration(null)).toBeNull();
expect(formatTurnDuration(undefined)).toBeNull();
// 时间范围与总耗时同一组边界:起点 / 终点都取整到 100ms 网格,精度不会互相矛盾。
const timing = resolveTurnTiming({ startedAt: 1000, endedAt: 42000 });
expect(timing.durationMs).toBe(41000);
expect(timing.durationText).toBe('41.0秒');
expect(timing.timeLabel).toMatch(/^\d{2}:\d{2}:01\.0 → \d{2}:\d{2}:42\.0$/);
// 起点未知:只显示终点时间,耗时隐藏(不编造)。
const noStart = resolveTurnTiming({ startedAt: 0, endedAt: 42000 });
expect(noStart.durationMs).toBeNull();
expect(noStart.durationText).toBeNull();
expect(noStart.timeLabel).toMatch(/^\d{2}:\d{2}:42\.0$/);
// 两端都没有:整块不显示时间和耗时。
expect(
resolveTurnTiming({ startedAt: 0, endedAt: 0 }).timeLabel,
).toBeNull();
expect(formatClockTime(0)).toBeNull();
});
/**
* 组头显示的是**本组**用时(`min(开始)` → `max(完成)`),与整轮总耗时无关:
* 组头不再接收任何回合边界,也不会重复渲染整轮时间范围。
*/
it('renders per-row durations plus this group total, and nothing when timestamps are missing', () => {
const { container } = render(
React.createElement(ToolCallGroup, {
calls: [
toolCall({
id: 'a',
kind: 'command',
summary: 'npm run build',
startedAt: 1000,
updatedAt: 1400,
}),
toolCall({
id: 'b',
kind: 'web_search',
summary: '玩法调研',
startedAt: 1400,
updatedAt: 17900,
}),
toolCall({
id: 'c',
kind: 'file_change',
title: '编辑 1 个文件',
summary: 'game/src/hero.ts',
startedAt: 17900,
updatedAt: 17900,
}),
],
}),
);
const group = container.querySelector(
'[data-testid="agent-tool-call-group"]',
) as HTMLElement;
// 本组耗时读**这一组**的边界:1000 → 17900,块头显示「耗时 16.9秒」,
// `data-duration-ms` 暴露取整到 100ms 网格后的展示耗时。
expect(group.getAttribute('data-duration-ms')).toBe('16900');
const head = within(group).getByTestId('agent-tool-call-group-head');
expect(head.textContent).toContain('耗时 16.9秒');
// 整轮总耗时只在对话底部渲染一处,组头不再出现"总耗时",也不再显示时间范围。
expect(head.textContent).not.toContain('总耗时');
expect(head.querySelector('.agent-tool-call-group-time')).toBeNull();
expect(head.getAttribute('aria-label')).toMatch(
/^执行了 3 个操作,耗时 16\.9秒$/,
);
fireEvent.click(head);
const rows = within(group).queryAllByTestId('agent-tool-call-row');
expect(rows[0]?.getAttribute('data-duration-ms')).toBe('400');
expect(within(rows[0] as HTMLElement).getByText('0.4秒')).not.toBeNull();
expect(rows[1]?.getAttribute('data-duration-ms')).toBe('16500');
expect(within(rows[1] as HTMLElement).getByText('16.5秒')).not.toBeNull();
// startedAt === updatedAt:合法 0,按契约显示 `0.0s`。
expect(rows[2]?.getAttribute('data-duration-ms')).toBe('0');
expect(within(rows[2] as HTMLElement).getByText('0.0秒')).not.toBeNull();
// 时间只显示在块头,展开后不重复追加块尾时间。
expect(
within(group).queryByTestId('agent-tool-call-group-end-time'),
).toBeNull();
// 时间戳缺失(startedAt 为 0):块头与行都不显示耗时。
const missing = render(
React.createElement(ToolCallGroup, {
calls: [
toolCall({ id: 'z', kind: 'command', summary: 'npm run build' }),
],
}),
);
const missingGroup = missing.container.querySelector(
'[data-testid="agent-tool-call-group"]',
) as HTMLElement;
expect(missingGroup.getAttribute('data-duration-ms')).toBe('');
expect(
within(missingGroup).getByTestId('agent-tool-call-group-head')
.textContent,
).toBe('执行了 1 个操作');
fireEvent.click(
within(missingGroup).getByTestId('agent-tool-call-group-head'),
);
const missingRow = within(missingGroup).getByTestId('agent-tool-call-row');
expect(missingRow.getAttribute('data-duration-ms')).toBe('');
expect(within(missingRow).queryByText('0秒')).toBeNull();
expect(
missingGroup.querySelector('.agent-tool-call-group-end-time'),
).toBeNull();
});
/**
* 动态计时:每 100ms 更新,分钟以内显示小数,达到分钟后显示整数秒。
*
* 假时钟同时接管 `Date.now()`:`useLiveNow` 读时间戳算差值,不按 tick 累加。
*/
it('grows this group total every 100ms while one of its tools runs and freezes when the group finishes', () => {
vi.useFakeTimers();
const base = Date.now();
const calls = [
toolCall({
id: 'live-a',
kind: 'command',
summary: 'npm run build',
status: 'running',
// 本组最早的工具起点:400ms 前开始。
startedAt: base - 400,
}),
toolCall({
id: 'live-b',
kind: 'web_search',
summary: '玩法调研',
status: 'running',
startedAt: base - 100,
}),
];
const view = render(
React.createElement(ToolCallGroup, {
calls,
active: true,
}),
);
const group = view.container.querySelector(
'[data-testid="agent-tool-call-group"]',
) as HTMLElement;
const head = within(group).getByTestId('agent-tool-call-group-head');
// 只有本组还有工具在跑时才标"进行中",并且组头显示的是本组耗时。
expect(head.textContent).toContain('进行中');
expect(head.textContent).toContain('耗时 0.4秒');
expect(group.getAttribute('data-duration-ms')).toBe('400');
// 没有新事件,时间推进组用时也增长;5000ms 后是 `5.4秒`。
act(() => {
vi.advanceTimersByTime(5000);
});
expect(group.getAttribute('data-duration-ms')).toBe('5400');
expect(head.textContent).toContain('耗时 5.4秒');
// 分钟进位后隐藏小数:65400ms → 1分05秒。
act(() => {
vi.advanceTimersByTime(60000);
});
expect(head.textContent).toContain('耗时 1分05秒');
// 组内工具全部拿到终态:组用时冻结在**工具终点**上(不是当前时钟),
// 继续推进时钟不再变化,也不等回合收口。
// 本组最后一条工具终点:base + 64600 → 与最早起点(base - 400)相差 65000。
const lastEnd = base + 64600;
view.rerender(
React.createElement(ToolCallGroup, {
calls: [
{ ...calls[0], status: 'completed' as const, updatedAt: lastEnd },
{
...calls[1],
status: 'completed' as const,
updatedAt: lastEnd - 400,
},
],
active: true,
}),
);
act(() => {
vi.advanceTimersByTime(30000);
});
expect(group.getAttribute('data-duration-ms')).toBe('65000');
expect(head.textContent).toContain('耗时 1分05秒');
expect(head.textContent).not.toContain('进行中');
view.unmount();
vi.useRealTimers();
});
/**
* 两组各自读自己的边界:起点不同、前组跑完立刻冻结,后组继续增长(这是本轮修掉的 bug ——
* 之前两组共享同一个整轮边界,前组明明跑完却仍显示"进行中 / 同一个总耗时")。
*/
it('reads each group own boundary: a finished group stays fixed while a later group keeps running', () => {
vi.useFakeTimers();
const base = Date.now();
const finishedCall = toolCall({
id: 'finished-call',
kind: 'command',
summary: 'npm run build',
// 先跑的那一组:130 秒前开始,2 秒后结束。
startedAt: base - 130000,
updatedAt: base - 128000,
});
const laterRunningCall = toolCall({
id: 'later-running-call',
kind: 'file_change',
summary: 'game/src/hero.ts',
status: 'running',
// 另一组起点完全不同(5 秒前):两组用时必须各算各的。
startedAt: base - 5000,
});
const firstGroup = render(
React.createElement(ToolCallGroup, {
calls: [finishedCall],
active: true,
}),
);
const secondGroup = render(
React.createElement(ToolCallGroup, {
calls: [laterRunningCall],
active: true,
}),
);
const first = firstGroup.container.querySelector(
'[data-testid="agent-tool-call-group"]',
) as HTMLElement;
const second = secondGroup.container.querySelector(
'[data-testid="agent-tool-call-group"]',
) as HTMLElement;
// 已跑完的那一组:用时固定 2.0 秒,且不再标"进行中"。
const finishedHead = within(first).getByTestId(
'agent-tool-call-group-head',
);
expect(first.getAttribute('data-duration-ms')).toBe('2000');
expect(finishedHead.textContent).toContain('耗时 2.0秒');
expect(finishedHead.textContent).not.toContain('进行中');
// 后开始的那一组:只算自己的 5 秒起点 → 当前时钟,仍标"进行中"。
const runningHead = within(second).getByTestId(
'agent-tool-call-group-head',
);
expect(second.getAttribute('data-duration-ms')).toBe('5000');
expect(runningHead.textContent).toContain('耗时 5.0秒');
expect(runningHead.textContent).toContain('进行中');
// 行级:已完成的工具冻结在 2.0 秒,运行中的工具按当前时钟增长。
fireEvent.click(finishedHead);
const finishedRow = within(first).getByTestId('agent-tool-call-row');
expect(finishedRow.getAttribute('data-duration-ms')).toBe('2000');
fireEvent.click(runningHead);
const runningRow = within(second).getByTestId('agent-tool-call-row');
expect(runningRow.getAttribute('data-duration-ms')).toBe('5000');
// 前一组跑完的组不会因为后一组还在跑而继续增长。
act(() => {
vi.advanceTimersByTime(125000);
});
expect(first.getAttribute('data-duration-ms')).toBe('2000');
expect(second.getAttribute('data-duration-ms')).toBe('130000');
expect(runningRow.getAttribute('data-duration-ms')).toBe('130000');
firstGroup.unmount();
secondGroup.unmount();
vi.useRealTimers();
});
/**
* 组用时纯函数:只读本组边界、缺边界隐藏、倒序隐藏、一位小数。
*/
it('computes the group total from this group only, hiding unknown or reversed boundaries', () => {
// 本组自己 min(开始) → max(完成)。
expect(
resolveToolGroupTiming([
toolCall({
id: 'a',
kind: 'command',
startedAt: 10_000,
updatedAt: 12_000,
}),
toolCall({
id: 'b',
kind: 'web_search',
startedAt: 11_000,
updatedAt: 42_000,
}),
]),
).toEqual({ durationMs: 32_000, durationText: '32.0秒', running: false });
// 缺任何开始边界就整组隐藏(已知的单行耗时不受影响)。
expect(
resolveToolGroupTiming([
toolCall({
id: 'a',
kind: 'command',
startedAt: 10_000,
updatedAt: 12_000,
}),
toolCall({ id: 'b', kind: 'web_search', updatedAt: 42_000 }),
]),
).toEqual({ durationMs: null, durationText: null, running: false });
// 已结束却没有终态时间 → 隐藏(不用别组 / 整轮边界补)。
expect(
resolveToolGroupTiming([
toolCall({
id: 'a',
kind: 'command',
startedAt: 10_000,
updatedAt: 12_000,
}),
toolCall({ id: 'b', kind: 'web_search', startedAt: 11_000 }),
]),
).toEqual({ durationMs: null, durationText: null, running: false });
// 倒序 / 非有限值 → 隐藏,不伪造 0.0 秒。
expect(
resolveToolGroupTiming([
toolCall({
id: 'a',
kind: 'command',
startedAt: 12_000,
updatedAt: 10_000,
}),
]).durationText,
).toBeNull();
// 单条边界先判:一条正常的工具不能把同组另一条倒序的工具掩盖成合法区间。
expect(
resolveToolGroupTiming([
toolCall({
id: 'ok',
kind: 'command',
startedAt: 10_000,
updatedAt: 12_000,
}),
toolCall({
id: 'reversed',
kind: 'web_search',
startedAt: 11_000,
updatedAt: 10_500,
}),
]),
).toEqual({ durationMs: null, durationText: null, running: false });
// 亚 100ms 倒序(1049 → 1001)不能被展示量化抹成 `0.0秒`。
expect(
resolveToolGroupTiming([
toolCall({
id: 'sub-tick-reversed',
kind: 'command',
startedAt: 1049,
updatedAt: 1001,
}),
]).durationText,
).toBeNull();
// 运行中的工具:`now` 早于它自己的起点时整组隐藏,哪怕同组另一条正常工具区间合法。
expect(
resolveToolGroupTiming(
[
toolCall({
id: 'ok',
kind: 'command',
startedAt: 9_000,
updatedAt: 20_000,
}),
toolCall({
id: 'running-behind-now',
kind: 'web_search',
status: 'running',
startedAt: 10_000,
}),
],
{ running: true, now: 9_500 },
),
).toEqual({ durationMs: null, durationText: null, running: false });
expect(
resolveToolGroupTiming([
toolCall({
id: 'a',
kind: 'command',
startedAt: Number.NaN,
updatedAt: 10_000,
}),
]).durationText,
).toBeNull();
// 运行中的工具:回合在跑时用当前时钟当终点;回合已结束时留着 running 快照也不编造。
const running = toolCall({
id: 'a',
kind: 'command',
status: 'running',
startedAt: 10_000,
});
expect(
resolveToolGroupTiming([running], { running: true, now: 13_400 }),
).toEqual({ durationMs: 3400, durationText: '3.4秒', running: true });
expect(
resolveToolGroupTiming([running], { running: false, now: 13_400 }),
).toEqual({ durationMs: null, durationText: null, running: false });
// 合法的同一时刻显示 0.0 秒。
expect(
resolveToolGroupTiming([
toolCall({
id: 'a',
kind: 'command',
startedAt: 10_000,
updatedAt: 10_000,
}),
]).durationText,
).toBe('0.0秒');
expect(resolveToolGroupTiming([])).toEqual({
durationMs: null,
durationText: null,
running: false,
});
});
}