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
- 解决 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 触发沙箱预检失败,与本合并无关)
701 lines
26 KiB
TypeScript
701 lines
26 KiB
TypeScript
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,
|
||
});
|
||
});
|
||
}
|