diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/GddApprovalCard.tsx b/apps/ai-game-creator-shell/src/features/project-workspace/GddApprovalCard.tsx
index 3018200fb..1273bb017 100644
--- a/apps/ai-game-creator-shell/src/features/project-workspace/GddApprovalCard.tsx
+++ b/apps/ai-game-creator-shell/src/features/project-workspace/GddApprovalCard.tsx
@@ -1,5 +1,6 @@
import { useState } from 'react';
+import { formatElapsedDuration } from '../../../../../packages/shared/src/lib/formatElapsedDuration';
import { resolveTauriInvoke } from '../../app/tauri';
import type {
PlanGddDecisionAction,
@@ -171,7 +172,7 @@ export function PlanGddStageProgress({
: `当前版本:v${latestVersion}`}
{processingSeconds > 0 ? (
- {`处理耗时:${processingSeconds.toFixed(1)} 秒`}
+ {`处理耗时:${formatElapsedDuration(processingSeconds * 1000) ?? '—'}`}
) : null}
{deliveredGdd ? (
diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/ProjectSupervisorView.tsx b/apps/ai-game-creator-shell/src/features/project-workspace/ProjectSupervisorView.tsx
index 8e10c1b89..8c6312681 100644
--- a/apps/ai-game-creator-shell/src/features/project-workspace/ProjectSupervisorView.tsx
+++ b/apps/ai-game-creator-shell/src/features/project-workspace/ProjectSupervisorView.tsx
@@ -1,4 +1,12 @@
-import { ArrowUp, AtSign, Loader2, Settings } from 'lucide-react';
+import {
+ ArrowUp,
+ AtSign,
+ Brain,
+ ChevronDown,
+ Loader2,
+ Settings,
+ Wrench,
+} from 'lucide-react';
import type {
ComponentProps,
FormEventHandler,
@@ -12,6 +20,7 @@ import {
AgentMessageContent,
type AgentMessageTone,
} from '../../../../../packages/shared/src/components/AgentMessageContent';
+import { AgentProcessSummary } from '../../../../../packages/shared/src/components/AgentProcessSummary';
import type {
AgentStatusCard,
ChatMessage,
@@ -41,6 +50,7 @@ import {
import type { DirectCodexTurnAttachment } from '../app-shell/directCodexTurnAttachments';
import { formatAgentCardRuntimeStatus } from '../project-summary/agentPresentation';
import { taskStatusLabels } from '../project-summary/projectSummary';
+import { agentProcessPreview } from './agentProcessPreview';
import type { QueuedChatTurn } from './chatComposerQueue';
import {
ComposerPendingAttachments,
@@ -81,6 +91,7 @@ import type { ChatComposerDraft, ChatReference } from './resourceReferences';
import { ToolCallGroup } from './ToolCallGroup';
import {
formatClockTime,
+ resolveToolGroupTiming,
resolveTurnTiming,
} from './toolCallGroupPresentation';
import { useLiveNow } from './useLiveNow';
@@ -114,6 +125,8 @@ function AgentReasoning({
label?: string;
testId?: string;
}) {
+ // 折叠态:单行纯文本预览(走 Markdown AST 取文字,链接只留字面文字、不含目标)。
+ const preview = agentProcessPreview(text);
return (
{text}
+
')).toBe('');
+ expect(
+ agentProcessPreview('先检查\n\n\n\n再继续'),
+ ).toBe('先检查 再继续');
+ // 行内 HTML 与其文字内容都不泄漏。
+ expect(agentProcessPreview('结果 加粗 完成')).toBe('结果 加粗 完成');
+ });
+
+ it('GFM 删除线按同源插件解析,波浪号不进预览', () => {
+ // 与正文同源:删除线保留其文字(正文里也是这段字),但 `~~` 控制符不进预览。
+ expect(agentProcessPreview('~~删除这段~~ 保留这段')).toBe(
+ '删除这段 保留这段',
+ );
+ expect(agentProcessPreview('~~删除~~')).toBe('删除');
+ });
+
+ it('压成单行并按上限省略', () => {
+ expect(agentProcessPreview('第一行\n\n第二行')).toBe('第一行 第二行');
+ expect(agentProcessPreview('- 第一项\n- 第二项')).toBe('第一项 第二项');
+ const long = 'x'.repeat(AGENT_PROCESS_PREVIEW_MAX_CHARS + 20);
+ const preview = agentProcessPreview(long);
+ expect(preview.endsWith('…')).toBe(true);
+ expect(preview.length).toBeLessThanOrEqual(
+ AGENT_PROCESS_PREVIEW_MAX_CHARS + 1,
+ );
+ expect(agentProcessPreview('')).toBe('');
+ expect(agentProcessPreview(' ')).toBe('');
+ });
+});
diff --git a/apps/ai-game-creator-shell/tests/appSurface/design-agent.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/design-agent.suite.ts
index 54d9722fa..56f6737d2 100644
--- a/apps/ai-game-creator-shell/tests/appSurface/design-agent.suite.ts
+++ b/apps/ai-game-creator-shell/tests/appSurface/design-agent.suite.ts
@@ -222,16 +222,29 @@ export function registerDesignAgentSurfaceTests() {
projectPath: harness.projectPath,
clientTurnId,
kind: 'reasoning',
- reasoningText: '先分析需求,再组织方案。',
+ reasoningText:
+ '## 结论\n\n- 先分析需求\n- 再组织方案\n\n用 `npm run build` 验证',
});
- const summary = await screen.findByText('思考过程');
- const details = summary.closest('details') as HTMLDetailsElement;
+ // 折叠入口按 aria-label 定位(标题文案不再写死,折叠态显示的是单行预览)。
+ const details = (await screen.findByLabelText(
+ /思考过程/,
+ )) as HTMLDetailsElement;
+ const summary = details.querySelector('summary') as HTMLElement;
expect(details.getAttribute('data-agent-content')).toBe('process');
expect(details.open).toBe(false);
+ // 折叠态是纯文本单行预览:Markdown 符号不出现在入口文字里。
+ expect(summary.textContent).toContain('结论');
+ expect(summary.textContent).not.toContain('##');
+ expect(summary.textContent).not.toContain('`');
fireEvent.click(summary);
expect(details.open).toBe(true);
- expect(screen.getByText('先分析需求,再组织方案。')).not.toBeNull();
+ // 展开态复用助手正文的 Markdown 安全链路:标题 / 列表 / 行内代码都成为真实语义元素。
+ await waitFor(() => {
+ expect(details.querySelector('h2')?.textContent).toBe('结论');
+ });
+ expect(details.querySelectorAll('li')).toHaveLength(2);
+ expect(details.querySelector('code')?.textContent).toBe('npm run build');
});
it('renders historical reasoning as independent collapsed sections', async () => {
@@ -252,10 +265,12 @@ export function registerDesignAgentSurfaceTests() {
}),
);
- const summaries = await screen.findAllByText('思考过程');
- expect(summaries).toHaveLength(2);
- const details = summaries.map(
- (summary) => summary.closest('details') as HTMLDetailsElement,
+ const details = (await screen.findAllByLabelText(
+ /思考过程/,
+ )) as HTMLDetailsElement[];
+ expect(details).toHaveLength(2);
+ const summaries = details.map(
+ (element) => element.querySelector('summary') as HTMLElement,
);
expect(details.every((element) => !element.open)).toBe(true);
expect(
diff --git a/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts
index 7177656dc..8cef811fb 100644
--- a/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts
+++ b/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts
@@ -8111,7 +8111,7 @@ export function registerProjectSupervisorSurfaceTests() {
);
expect(runningBody).not.toBeNull();
expect(runningBody?.hasAttribute('hidden')).toBe(true);
- expect(runningHead.textContent).toContain('1 个命令');
+ expect(runningHead.textContent).toContain('执行了 1 个操作');
// 命令完成 + 文件变更:同一回合两块合成一个块,顺序按 startedAt。
await act(async () => {
@@ -8166,12 +8166,12 @@ export function registerProjectSupervisorSurfaceTests() {
'agent-tool-call-group',
)[0] as HTMLElement;
const groupHead = within(group).getByTestId('agent-tool-call-group-head');
- expect(groupHead.textContent).toContain('已执行 1 个命令、1 个文件变更');
+ expect(groupHead.textContent).toContain('执行了 2 个操作');
// 组头右侧是**本组**用时(本组工具 min(开始) → max(完成) = 100ms),与整轮总耗时无关:
// 组内工具都结束了,即使回合还在跑,也不标"进行中"、也不再滚动。
expect(group.getAttribute('data-status')).toBe('completed');
expect(group.getAttribute('data-duration-ms')).toBe('100');
- expect(groupHead.textContent).toContain('本组用时 0.1秒');
+ expect(groupHead.textContent).toContain('耗时 0.1秒');
expect(groupHead.textContent).not.toContain('进行中');
expect(groupHead.textContent).not.toContain('总耗时');
// 实时回合的块落在消息流末尾:该回合还没有正文,不依赖任何锚点消息。
@@ -8261,6 +8261,13 @@ export function registerProjectSupervisorSurfaceTests() {
const processSection =
within(supervisorSurface).getByTestId('turn-process');
expect(processSection.contains(settledGroups[0] ?? null)).toBe(true);
+ // 外层"执行过程"汇总这一轮**全部工具调用**(1 组 2 条 = 2 个操作),
+ // 耗时是首工具 → 末工具的执行跨度(不是整轮总耗时)。
+ const turnProcessSummary = within(processSection).getByTestId(
+ 'agent-tool-call-group-head',
+ );
+ expect(processSection.textContent).toContain('执行了 2 个操作');
+ expect(turnProcessSummary.textContent).toContain('耗时 0.1秒');
expect(processSection.textContent).not.toContain(
'DIRECT_REPLY:做一个跑酷游戏',
);
@@ -8275,7 +8282,7 @@ export function registerProjectSupervisorSurfaceTests() {
// 回合收口不改变组头:组头始终是**本组**用时,不随回合变长。
expect(settledGroup.getAttribute('data-status')).toBe('completed');
expect(settledGroup.getAttribute('data-duration-ms')).toBe('100');
- expect(settledHead.textContent).toContain('本组用时 0.1秒');
+ expect(settledHead.textContent).toContain('耗时 0.1秒');
expect(settledHead.textContent).not.toContain('进行中');
expect(settledHead.textContent).not.toContain('总耗时');
// 整轮总耗时在界面上只有一处:回合小结;任何工具组都不再显示它。
@@ -8406,9 +8413,7 @@ export function registerProjectSupervisorSurfaceTests() {
'agent-tool-call-group-head',
);
expect(persistedHead.getAttribute('aria-expanded')).toBe('false');
- expect(persistedHead.textContent).toContain(
- '已执行 1 个命令、1 个文件变更',
- );
+ expect(persistedHead.textContent).toContain('执行了 2 个操作');
const processSection =
within(supervisorSurface).getByTestId('turn-process');
expect(processSection.contains(persistedGroup)).toBe(true);
@@ -8932,11 +8937,11 @@ export function registerProjectSupervisorSurfaceTests() {
'agent-tool-call-group-head',
);
expect(runningHead.getAttribute('aria-expanded')).toBe('false');
- expect(runningHead.textContent).toContain('1 个命令');
+ expect(runningHead.textContent).toContain('执行了 1 个操作');
// 组内还有工具在跑:组头按"本组起点 → 现在"增长,一位小数、显示进行中。
// 整轮总耗时不在组头(它只在回合小结 / 底部耗时行出现)。
expect(runningHead.textContent).toContain('进行中');
- expect(runningHead.textContent).toMatch(/本组用时 \d+\.\d+秒/);
+ expect(runningHead.textContent).toMatch(/耗时 \d+\.\d+秒/);
expect(runningHead.textContent).not.toContain('总耗时');
const runningChildren = Array.from((messageList as HTMLElement).children);
expect(
@@ -8980,7 +8985,7 @@ export function registerProjectSupervisorSurfaceTests() {
const settledRunningHead = within(settledRunningGroup).getByTestId(
'agent-tool-call-group-head',
);
- expect(settledRunningHead.textContent).toContain('本组用时 0.4秒');
+ expect(settledRunningHead.textContent).toContain('耗时 0.4秒');
expect(settledRunningHead.textContent).not.toContain('进行中');
expect(settledRunningHead.textContent).not.toContain('总耗时');
fireEvent.click(settledRunningHead);
@@ -8990,7 +8995,7 @@ export function registerProjectSupervisorSurfaceTests() {
expect(settledRunningRows).toHaveLength(1);
expect(settledRunningRows[0]?.getAttribute('data-duration-ms')).toBe('400');
expect(
- within(settledRunningRows[0] as HTMLElement).getByText('0.4s'),
+ within(settledRunningRows[0] as HTMLElement).getByText('0.4秒'),
).not.toBeNull();
// 回合结束:assistant 正文进历史,工具块收进「执行过程」折叠区,且**只有一份**。
@@ -9022,7 +9027,7 @@ export function registerProjectSupervisorSurfaceTests() {
expect(
within(settledGroup).getByTestId('agent-tool-call-group-head')
.textContent,
- ).toContain('本组用时 0.4秒');
+ ).toContain('耗时 0.4秒');
// 整轮总耗时冻结在用户发送 → turn.completed.at,且只在回合小结里出现一处。
expect(
within(supervisorSurface).getByTestId('turn-usage').textContent,
diff --git a/apps/ai-game-creator-shell/tests/appSurface/tool-call-group.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/tool-call-group.suite.ts
index 8ea104509..7f0ce0570 100644
--- a/apps/ai-game-creator-shell/tests/appSurface/tool-call-group.suite.ts
+++ b/apps/ai-game-creator-shell/tests/appSurface/tool-call-group.suite.ts
@@ -34,13 +34,11 @@ function toolCall(
}
export function registerToolCallGroupTests() {
- it('summarizes tool calls by kind in a fixed order', () => {
- // 单 kind。
+ it('summarizes the group as its own operation count', () => {
+ // 用户口径:`执行了 X 个操作`,X = 这一组自己的调用数(不拆 kind、不看段落数)。
expect(toolCallGroupSummary([toolCall({ id: 'a', kind: 'command' })])).toBe(
- '已执行 1 个命令',
+ '执行了 1 个操作',
);
- // 混合:顺序固定 command → file_change → mcp_tool → web_search →
- // context_compaction → other,与传入顺序无关。
expect(
toolCallGroupSummary([
toolCall({ id: 'a', kind: 'other' }),
@@ -51,10 +49,8 @@ export function registerToolCallGroupTests() {
toolCall({ id: 'f', kind: 'context_compaction' }),
toolCall({ id: 'g', kind: 'mcp_tool' }),
]),
- ).toBe(
- '已执行 2 个命令、1 个文件变更、1 个工具调用、1 个联网搜索、1 个上下文整理、1 个其他操作',
- );
- // 空集合。
+ ).toBe('执行了 7 个操作');
+ // 空集合不渲染汇总。
expect(toolCallGroupSummary([])).toBe('');
});
@@ -122,12 +118,10 @@ export function registerToolCallGroupTests() {
// 块头是一行按钮:图标 + 汇总 + 展开箭头,默认折叠,正文由 `hidden` 收起。
expect(head.tagName).toBe('BUTTON');
expect(head.getAttribute('aria-expanded')).toBe('false');
- expect(head.getAttribute('aria-label')).toBe(
- '已执行 1 个命令、1 个文件变更',
- );
+ expect(head.getAttribute('aria-label')).toBe('执行了 2 个操作');
expect(
- head.querySelector('.agent-tool-call-group-summary')?.textContent,
- ).toBe('已执行 1 个命令、1 个文件变更');
+ head.querySelector('.agent-process-summary-preview')?.textContent,
+ ).toBe('执行了 2 个操作');
const body = container.querySelector(
`#${head.getAttribute('aria-controls')}`,
);
@@ -240,14 +234,14 @@ export function registerToolCallGroupTests() {
),
).toBeNull();
// 合法 0 显示 `0.0s`;两种耗时都始终一位小数。
- expect(formatToolCallDuration(0)).toBe('0.0s');
- expect(formatToolCallDuration(400)).toBe('0.4s');
- expect(formatToolCallDuration(950)).toBe('1.0s');
- expect(formatToolCallDuration(12300)).toBe('12.3s');
- expect(formatToolCallDuration(12000)).toBe('12.0s');
- expect(formatToolCallDuration(59900)).toBe('59.9s');
- expect(formatToolCallDuration(60000)).toBe('1m 0.0s');
- expect(formatToolCallDuration(125000)).toBe('2m 5.0s');
+ 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.0秒');
+ expect(formatToolCallDuration(125000)).toBe('2分05.0秒');
// 整轮总耗时 = 本轮起点 → 本轮终态(不是块内工具的时间跨度)。
expect(turnTotalDurationMs({ startedAt: 1000, endedAt: 9000 })).toBe(8000);
@@ -265,9 +259,9 @@ export function registerToolCallGroupTests() {
expect(formatTurnDuration(8000)).toBe('8.0秒');
expect(formatTurnDuration(42000)).toBe('42.0秒');
expect(formatTurnDuration(59900)).toBe('59.9秒');
- expect(formatTurnDuration(60000)).toBe('1分钟 0.0秒');
- expect(formatTurnDuration(240000)).toBe('4分钟 0.0秒');
- expect(formatTurnDuration(345000)).toBe('5分钟 45.0秒');
+ expect(formatTurnDuration(60000)).toBe('1分00.0秒');
+ expect(formatTurnDuration(240000)).toBe('4分00.0秒');
+ expect(formatTurnDuration(345000)).toBe('5分45.0秒');
expect(formatTurnDuration(null)).toBeNull();
expect(formatTurnDuration(undefined)).toBeNull();
@@ -324,27 +318,27 @@ export function registerToolCallGroupTests() {
const group = container.querySelector(
'[data-testid="agent-tool-call-group"]',
) as HTMLElement;
- // 本组用时读**这一组**的边界:1000 → 17900,块头显示「本组用时 16.9秒」,
+ // 本组耗时读**这一组**的边界: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).toContain('耗时 16.9秒');
// 整轮总耗时只在对话底部渲染一处,组头不再出现"总耗时",也不再显示时间范围。
expect(head.textContent).not.toContain('总耗时');
expect(head.querySelector('.agent-tool-call-group-time')).toBeNull();
expect(head.getAttribute('aria-label')).toMatch(
- /^已执行 1 个命令、1 个文件变更、1 个联网搜索,本组用时 16\.9秒$/,
+ /^执行了 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.4s')).not.toBeNull();
+ 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.5s')).not.toBeNull();
+ 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.0s')).not.toBeNull();
+ expect(within(rows[2] as HTMLElement).getByText('0.0秒')).not.toBeNull();
// 时间只显示在块头,展开后不重复追加块尾时间。
expect(
within(group).queryByTestId('agent-tool-call-group-end-time'),
@@ -365,13 +359,13 @@ export function registerToolCallGroupTests() {
expect(
within(missingGroup).getByTestId('agent-tool-call-group-head')
.textContent,
- ).toBe('已执行 1 个命令');
+ ).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('0s')).toBeNull();
+ expect(within(missingRow).queryByText('0秒')).toBeNull();
expect(
missingGroup.querySelector('.agent-tool-call-group-end-time'),
).toBeNull();
@@ -412,9 +406,9 @@ export function registerToolCallGroupTests() {
'[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(head.textContent).toContain('耗时 0.4秒');
expect(group.getAttribute('data-duration-ms')).toBe('400');
// 没有新事件,时间推进组用时也增长;5000ms 后是 `5.4秒`。
@@ -422,12 +416,12 @@ export function registerToolCallGroupTests() {
vi.advanceTimersByTime(5000);
});
expect(group.getAttribute('data-duration-ms')).toBe('5400');
- expect(head.textContent).toContain('本组用时 5.4秒');
- // 分钟进位:65400ms → `1分钟 5.4秒`。
+ expect(head.textContent).toContain('耗时 5.4秒');
+ // 分钟进位:65400ms → `1分05.4秒`。
act(() => {
vi.advanceTimersByTime(60000);
});
- expect(head.textContent).toContain('本组用时 1分钟 5.4秒');
+ expect(head.textContent).toContain('耗时 1分05.4秒');
// 组内工具全部拿到终态:组用时冻结在**工具终点**上(不是当前时钟),
// 继续推进时钟不再变化,也不等回合收口。
@@ -450,7 +444,7 @@ export function registerToolCallGroupTests() {
vi.advanceTimersByTime(30000);
});
expect(group.getAttribute('data-duration-ms')).toBe('65000');
- expect(head.textContent).toContain('本组用时 1分钟 5.0秒');
+ expect(head.textContent).toContain('耗时 1分05.0秒');
expect(head.textContent).not.toContain('进行中');
view.unmount();
vi.useRealTimers();
@@ -502,14 +496,14 @@ export function registerToolCallGroupTests() {
'agent-tool-call-group-head',
);
expect(first.getAttribute('data-duration-ms')).toBe('2000');
- expect(finishedHead.textContent).toContain('本组用时 2.0秒');
+ 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('耗时 5.0秒');
expect(runningHead.textContent).toContain('进行中');
// 行级:已完成的工具冻结在 2.0 秒,运行中的工具按当前时钟增长。
fireEvent.click(finishedHead);
diff --git a/apps/ai-game-creator-shell/tests/elapsedDuration.test.ts b/apps/ai-game-creator-shell/tests/elapsedDuration.test.ts
new file mode 100644
index 000000000..b0c6945b2
--- /dev/null
+++ b/apps/ai-game-creator-shell/tests/elapsedDuration.test.ts
@@ -0,0 +1,33 @@
+import { describe, expect, it } from 'vitest';
+
+import { formatElapsedDuration } from '../../../packages/shared/src/lib/formatElapsedDuration';
+import {
+ formatToolCallDuration,
+ formatTurnDuration,
+} from '../src/features/project-workspace/toolCallGroupPresentation';
+import { resourceCanvasAssetGenerationElapsedLabel } from '../src/features/resource-canvas/resourceCanvasAssetGenerationTaskModel';
+
+describe('统一中文耗时', () => {
+ it.each([
+ [0, '0.0秒'],
+ [5200, '5.2秒'],
+ [59_949, '59.9秒'],
+ [59_950, '1分00.0秒'],
+ [125_200, '2分05.2秒'],
+ [3_599_950, '1时00分00.0秒'],
+ [3_725_200, '1时02分05.2秒'],
+ [90_061_200, '25时01分01.2秒'],
+ ])('%s ms → %s,所有入口一致', (ms, expected) => {
+ expect(formatElapsedDuration(ms)).toBe(expected);
+ expect(formatToolCallDuration(ms)).toBe(expected);
+ expect(formatTurnDuration(ms)).toBe(expected);
+ expect(resourceCanvasAssetGenerationElapsedLabel(ms)).toBe(expected);
+ });
+
+ it.each([null, undefined, NaN, Infinity, -1])(
+ '未知时间不伪造零:%s',
+ (ms) => {
+ expect(formatElapsedDuration(ms)).toBeNull();
+ },
+ );
+});
diff --git a/apps/ai-game-creator-shell/tests/resourceCanvasAssetGenerationQueue.test.ts b/apps/ai-game-creator-shell/tests/resourceCanvasAssetGenerationQueue.test.ts
index 422fe0b86..9a75ead7b 100644
--- a/apps/ai-game-creator-shell/tests/resourceCanvasAssetGenerationQueue.test.ts
+++ b/apps/ai-game-creator-shell/tests/resourceCanvasAssetGenerationQueue.test.ts
@@ -208,11 +208,9 @@ describe('生成任务模型', () => {
});
test('已耗时文案按分秒呈现', () => {
- expect(resourceCanvasAssetGenerationElapsedLabel(12_000)).toBe('12 秒');
- expect(resourceCanvasAssetGenerationElapsedLabel(72_000)).toBe(
- '1 分 12 秒',
- );
- expect(resourceCanvasAssetGenerationElapsedLabel(-5)).toBe('0 秒');
+ expect(resourceCanvasAssetGenerationElapsedLabel(12_000)).toBe('12.0秒');
+ expect(resourceCanvasAssetGenerationElapsedLabel(72_000)).toBe('1分12.0秒');
+ expect(resourceCanvasAssetGenerationElapsedLabel(-5)).toBe('—');
});
});
diff --git a/apps/ai-game-creator-shell/tests/resourceCanvasAssetGenerationTasksPanel.test.tsx b/apps/ai-game-creator-shell/tests/resourceCanvasAssetGenerationTasksPanel.test.tsx
index 41b20e135..2b3a663fa 100644
--- a/apps/ai-game-creator-shell/tests/resourceCanvasAssetGenerationTasksPanel.test.tsx
+++ b/apps/ai-game-creator-shell/tests/resourceCanvasAssetGenerationTasksPanel.test.tsx
@@ -124,7 +124,7 @@ describe('「生成任务」侧栏', () => {
expect(within(doneSection).getByText('生成已完成。')).not.toBeNull();
expect(within(doneSection).getByRole('alert').textContent).toBe('远端拒绝');
expect(
- screen.getAllByText(/^已耗时 \d+ (秒|分 \d+ 秒)$/).length,
+ screen.getAllByText(/^已耗时 (?:\d+时)?(?:\d+分)?\d+\.\d秒$/).length,
).toBeGreaterThan(0);
});
diff --git a/docs/project-memory/shared-memory/team-conventions.md b/docs/project-memory/shared-memory/team-conventions.md
index 19fbb19b6..00252bcf1 100644
--- a/docs/project-memory/shared-memory/team-conventions.md
+++ b/docs/project-memory/shared-memory/team-conventions.md
@@ -16,6 +16,8 @@
## 开发中
+- AGC 思考与执行入口共用共享单行摘要骨架;Markdown 只在展开正文走既有安全渲染,折叠预览只取纯文本,不在 summary 嵌套链接或按钮。耗时统一复用中文时分秒格式(秒一位小数),格式化与各层计时边界分离,不因统一文案改变状态来源。
+
- Direct 对话计时区分条目展示时间与生命周期事件时间:整轮用用户发送到明确终态的跨度,工具用各自开始/完成边界;运行时用 100ms 叶子时钟刷新一位小数,终态冻结,旧历史缺边界不推测。不得用整秒时间的大小比较取代 Thread Manager 的事件顺序判定新回合。
- AGC 批量追加素材标签由原生在一次项目写锁与 revision CAS 下合并各项原标签,先校验全批再写 manifest;前端不能循环单素材分类命令,不回传展示层推导的分类或旧标签全集,以免部分写入或覆盖未编辑字段。
diff --git a/docs/technical/【技术方案】GameAgent对话工具调用卡片-2026-09-14.md b/docs/technical/【技术方案】GameAgent对话工具调用卡片-2026-09-14.md
index 36658c09a..e34d0161f 100644
--- a/docs/technical/【技术方案】GameAgent对话工具调用卡片-2026-09-14.md
+++ b/docs/technical/【技术方案】GameAgent对话工具调用卡片-2026-09-14.md
@@ -15,11 +15,20 @@
## 总耗时与动态工具计时
+### 紧凑过程入口与 Markdown
+
+- 思考过程、执行过程和工具组统一为无大块底色的单行折叠入口:左侧小图标/简短预览,最右侧展开箭头;长文本省略,不把箭头挤出窄聊天列。沿用共享过程色和 12px 层级,键盘可展开、有可见焦点。
+- 思考折叠态直接显示浅色的内容预览,而不是只有“思考过程”标题;预览不显示 Markdown 控制符、原始 HTML 或链接地址。展开后复用现有安全 Markdown 渲染链路,支持段落、强调、列表、链接、代码块等,不开启原始 HTML 执行。实时与历史、当前 Agent 与策划 Agent 使用同一呈现。
+- 工具组摘要只统计本组工具条目总数,显示“执行了 N 个操作,耗时 XXX”;运行中另有状态提示,不把操作数称作成功数,失败仍保留明确状态。回合外层执行过程统计所有工具块的操作数,不把思考段落或文本消息当工具操作;缺失耗时不伪造。
+- 所有 AGC 耗时统一用中文时分秒:`5.2秒`、`2分05.2秒`、`1时02分05.2秒`。省略前导零单位,带小时则保留两位分钟,带分钟则秒补齐两位整数,秒始终一位小数;先整体舍入再拆分单位,避免出现 60 秒/60 分。工具行、组、整轮、生成任务及策划耗时共用一个纯格式化函数,不改变各自计时来源。
+- 独立计时保持不变,组状态/用时在单行中作为次要信息,空间不足可移至展开内容,不能占第二行破坏紧凑入口;整轮总耗时仍只显示一次。工具输入/输出、失败信息与操作明细不因样式变更丢失。
+- 只改展示与对应测试,不改变消息身份、分组顺序、生成或原生执行语义。验证覆盖 Markdown 安全与语义、预览省略、准确计数、展开/键盘操作、窄屏布局及原有计时冻结。
+
- “总耗时”表示同一轮用户请求从发送到回合终态的墙钟跨度,包含 LLM 推理、工具调用和等待;只在本轮运行状态或完成小结显示,不重复放进各工具组。
-- 工具组顶部显示“本组用时”,范围是本组首个工具开始到最后一个工具完成,包含组内等待但不是工具耗时之和。不同工具组独立计时;本组全部终态后立即固定,即使本轮或后面的工具组仍运行,也不能显示“进行中”或继续增加。组头不再展示容易与整轮混淆的发送/结束时刻,仅展示本组用时;不能沿用用户发送时间计算本组。
+- 工具组顶部“执行了 N 个操作,耗时 XXX”中的耗时范围是本组首个工具开始到最后一个工具完成,包含组内等待但不是工具耗时之和。不同工具组独立计时;本组全部终态后立即固定,即使本轮或后面的工具组仍运行,也不能显示“进行中”或继续增加。组头不展示发送/结束时刻,不沿用用户发送时间计算本组。
- 回合仍运行但全部工具暂时结束时,只增长本轮总耗时。成功、失败或终止到达后整轮按实际终态时间固定;随后打开折叠块、翻页或其它回合的新事件不得改变已完成的组或回合用时。
- 单条工具从其实际开始到完成计时。运行中的工具按当前时间持续增长,不能把最近一次快照更新时间当作当前时间;已经结束的工具必须立即固定,即使同组其它工具或 LLM 仍在运行。
-- 两种耗时均每 100 毫秒刷新,始终保留一位小数(例如 `0.0秒`、`5.1秒`、`1分钟 2.3秒`,单条可沿用 `5.1s` / `1m 2.3s` 的紧凑格式)。以时间戳计算而不是按 tick 累加,避免后台节流后的累计漂移;缺失或倒序边界不伪造 `0.0`。
+- 对话的组、工具及整轮耗时运行中均每 100 毫秒刷新,统一使用上述中文时分秒格式。以时间戳计算而不是按 tick 累加,避免后台节流后的累计漂移;缺失或倒序边界不伪造 `0.0`。非对话场景只统一文本格式,不改变原有刷新或后端累计时间语义。
- 各层时间范围与对应耗时使用相同的起止边界。运行标记也按本层状态判定;完成后如果展示起止时间,其精度不得造成范围差与用时矛盾。组内存在缺失或倒序的工具边界时,不能用其他组或整轮的时间补造本组耗时。
- 原生事件保留各阶段的时间语义:开始与完成不能都优先折叠成开始时间。优先采用上游明确提供的阶段时间或实际调用时长,缺失时使用宿主观察该阶段的时间;重放沿用原事件时间,不能在前端收到或重放时重新取当前时间。
- 工具开始/完成的权威边界是事件级 `at`,不是条目展示字段 `item.at`。整轮起点优先采用该轮实际用户消息的发送时间(与气泡一致,不取所有条目的最小时间),缺失时采用原生 `turn.started.at`;终点只采用 `turn.completed.at` 或明确的终止/失败收口事件。首次补到更早的真实发送时间可以校正起点,但旧历史或重复事件不能覆盖已经固定的终点。
@@ -92,20 +101,20 @@ DirectRuntime 写 `