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 d285bcb7e..0d7c44c42 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 @@ -63,7 +63,7 @@ import { type ResourceReferenceInputHandle, } from './ResourceReferenceInput'; import type { ChatComposerDraft, ChatReference } from './resourceReferences'; -import { ToolCallCard } from './ToolCallCard'; +import { ToolCallGroup } from './ToolCallGroup'; /** 与 `App.tsx` 的回合消息 id 同构:`direct-codex::`。 */ function directCodexTurnMessageId(turnId: string, role: 'user' | 'assistant') { @@ -227,34 +227,29 @@ export function ProjectSupervisorView({ }, [settingsOpen]); const runBusy = runtimePanelProps.controlBusy || Boolean(directProcessDetail) || submitting; - // 工具调用卡片按回合分组:契约要求插在**同一回合最后一条 assistant 消息之后**。 - // 当前正在跑的回合还没有 assistant 消息落盘,锚到窗口里最后一条 assistant 消息; - // 历史回合一律锚到自己那条 `direct-codex::assistant`,不回落到别的回合。 - const liveAssistantMessageId = [...visibleMessages] - .reverse() - .find((message) => message.role === 'assistant')?.messageId; + // 工具调用折叠块按回合分组,插在**同一回合 assistant 消息之前**(Codex 是「工具在上、答复在下」)。 + // 历史回合锚到自己那条 `direct-codex::assistant`,不回落到别的回合; + // 正在跑的回合还没有 assistant 消息落盘,先落在消息流末尾,等那条消息落盘后回到它之前。 + const streamTurnId = activeTurnId?.trim() ?? ''; const toolCallsByAnchor = new Map(); + const liveToolCalls: GameCreatorDirectToolCall[] = []; if (directCodex) { - const streamTurnId = activeTurnId?.trim() ?? ''; for (const call of toolCalls) { const expected = directCodexTurnMessageId(call.turnId, 'assistant'); const hasPersistedAssistant = visibleMessages.some( (message) => message.messageId === expected, ); - const anchor = - call.turnId === streamTurnId - ? (liveAssistantMessageId ?? null) - : hasPersistedAssistant - ? expected - : null; - if (!anchor) { + if (hasPersistedAssistant) { + const bucket = toolCallsByAnchor.get(expected); + if (bucket) { + bucket.push(call); + } else { + toolCallsByAnchor.set(expected, [call]); + } continue; } - const bucket = toolCallsByAnchor.get(anchor); - if (bucket) { - bucket.push(call); - } else { - toolCallsByAnchor.set(anchor, [call]); + if (streamTurnId && call.turnId === streamTurnId) { + liveToolCalls.push(call); } } } @@ -370,22 +365,27 @@ export function ProjectSupervisorView({ : []; return ( + {anchoredToolCalls.length > 0 ? ( + + ) : null}
- {anchoredToolCalls.map((call) => ( - - ))}
); })} + {liveToolCalls.length > 0 ? ( + + ) : null} {designReasoning ? (
显示思考过程 diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/ToolCallCard.tsx b/apps/ai-game-creator-shell/src/features/project-workspace/ToolCallCard.tsx deleted file mode 100644 index 7548fd090..000000000 --- a/apps/ai-game-creator-shell/src/features/project-workspace/ToolCallCard.tsx +++ /dev/null @@ -1,117 +0,0 @@ -import { - ChevronDown, - FileText, - Globe, - Minimize2, - Terminal, - Wrench, -} from 'lucide-react'; -import { useId, useState } from 'react'; - -import type { GameCreatorDirectToolCall } from '../../app/types'; - -/** - * 工具调用卡片(Codex 风格): - * 折叠态一行摘要,展开态看命令与文件明细。契约见 - * `docs/technical/【技术方案】GameAgent对话工具调用卡片-2026-09-14.md`。 - * - * 无障碍:折叠/展开是一只 ` - - - ); -} - -function toolCallChangeKindLabel(kind: string) { - if (kind === 'add') { - return '新增'; - } - if (kind === 'delete') { - return '删除'; - } - return '修改'; -} - -function ToolCallIcon({ kind }: { kind: string }) { - switch (kind) { - case 'command': - return ; - case 'file_change': - return ; - case 'web_search': - return ; - case 'context_compaction': - return ; - default: - return ; - } -} diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/ToolCallGroup.tsx b/apps/ai-game-creator-shell/src/features/project-workspace/ToolCallGroup.tsx new file mode 100644 index 000000000..781c12f94 --- /dev/null +++ b/apps/ai-game-creator-shell/src/features/project-workspace/ToolCallGroup.tsx @@ -0,0 +1,191 @@ +import { + ChevronDown, + FileText, + Globe, + Minimize2, + Terminal, + Wrench, +} from 'lucide-react'; +import { useId, useState } from 'react'; + +import type { GameCreatorDirectToolCall } from '../../app/types'; +import { + toolCallGroupSummary, + toolCallRowText, +} from './toolCallGroupPresentation'; + +/** + * 一回合的工具调用折叠块(Codex 风格): + * 块头一行汇总,展开态每行一条工具(行可二级展开看命令 / 文件明细 / 输出)。 + * 契约见 `docs/technical/【技术方案】GameAgent对话工具调用卡片-2026-09-14.md`。 + * + * 无障碍:块头与每一行都是 ` + + + ); +} + +/** 展开态的一行工具;行可二级展开看命令 / 文件明细 / 输出。 */ +function ToolCallRow({ call }: { call: GameCreatorDirectToolCall }) { + const [expanded, setExpanded] = useState(false); + const detailId = useId(); + const text = toolCallRowText(call); + const statusText = + call.status === 'running' + ? '执行中' + : call.status === 'failed' + ? '失败' + : ''; + const rowLabel = [text, statusText].filter(Boolean).join(','); + const changes = call.detail.changes ?? []; + const detailCommand = call.detail.command?.trim() ?? ''; + const detailOutput = call.detail.output?.trim() ?? ''; + const hasDetail = + Boolean(detailCommand) || Boolean(detailOutput) || changes.length > 0; + return ( +
  • + + +
  • + ); +} + +function toolCallChangeKindLabel(kind: string) { + if (kind === 'add') { + return '新增'; + } + if (kind === 'delete') { + return '删除'; + } + return '修改'; +} + +function ToolCallKindIcon({ kind }: { kind: string }) { + switch (kind) { + case 'command': + return ; + case 'file_change': + return ; + case 'web_search': + return ; + case 'context_compaction': + return ; + default: + return ; + } +} diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/toolCallGroupPresentation.ts b/apps/ai-game-creator-shell/src/features/project-workspace/toolCallGroupPresentation.ts new file mode 100644 index 000000000..71ef915e7 --- /dev/null +++ b/apps/ai-game-creator-shell/src/features/project-workspace/toolCallGroupPresentation.ts @@ -0,0 +1,94 @@ +import type { + GameCreatorDirectToolCall, + GameCreatorDirectToolCallKind, +} from '../../app/types'; + +/** + * 工具调用折叠块的纯文案计算:汇总 / 行文案。 + * 契约见 `docs/technical/【技术方案】GameAgent对话工具调用卡片-2026-09-14.md`。 + * + * 与组件分文件:`ToolCallGroup.tsx` 只导出组件,纯函数放这里(react-refresh 要求 + * 组件文件不导出非组件值,也便于单测直接断言文案规则)。 + */ + +/** 汇总文案里 kind 的固定顺序:command → file_change → mcp_tool → web_search → context_compaction → other。 */ +const TOOL_CALL_KIND_ORDER: GameCreatorDirectToolCallKind[] = [ + 'command', + 'file_change', + 'mcp_tool', + 'web_search', + 'context_compaction', + 'other', +]; + +const TOOL_CALL_KIND_LABELS: Record = { + command: '命令', + file_change: '文件变更', + mcp_tool: '工具调用', + web_search: '联网搜索', + context_compaction: '上下文整理', + other: '其他操作', +}; + +/** 行文案动词:`已运行 {summary}` / `已编辑 {summary}` / …,`context_compaction` 不带 summary。 */ +const TOOL_CALL_ROW_VERBS: Partial< + Record +> = { + command: '已运行', + file_change: '已编辑', + mcp_tool: '已调用', + web_search: '已搜索', + other: '已执行', +}; + +/** 汇总文案:按 kind 计数、固定顺序拼成 `已执行 5 个命令、2 个文件变更`;空集合返回空串。 */ +export function toolCallGroupSummary(calls: GameCreatorDirectToolCall[]) { + const counts = new Map(); + for (const call of calls) { + counts.set(call.kind, (counts.get(call.kind) ?? 0) + 1); + } + const parts: string[] = []; + const append = (kind: string) => { + const count = counts.get(kind) ?? 0; + if (count <= 0) { + return; + } + counts.delete(kind); + const label = + TOOL_CALL_KIND_LABELS[kind as GameCreatorDirectToolCallKind] ?? + '其他操作'; + parts.push(`${count} 个${label}`); + }; + for (const kind of TOOL_CALL_KIND_ORDER) { + append(kind); + } + // 契约外的 kind:不丢计数,落到末尾的「其他操作」。 + for (const kind of [...counts.keys()]) { + append(kind); + } + return parts.length > 0 ? `已执行 ${parts.join('、')}` : ''; +} + +/** 一行工具的文案:`已运行 npm run build` / `已整理上下文`。 */ +export function toolCallRowText(call: GameCreatorDirectToolCall) { + if (call.kind === 'context_compaction') { + return '已整理上下文'; + } + const summary = toolCallRowSummary(call); + const verb = TOOL_CALL_ROW_VERBS[call.kind] ?? '已执行'; + return summary ? `${verb} ${summary}` : verb; +} + +function toolCallRowSummary(call: GameCreatorDirectToolCall) { + const summary = call.summary.trim(); + if (summary) { + return summary; + } + if (call.kind === 'file_change') { + const firstPath = call.detail.changes?.[0]?.path?.trim(); + if (firstPath) { + return firstPath; + } + } + return call.title.trim(); +} diff --git a/apps/ai-game-creator-shell/tests/appSurface.test.ts b/apps/ai-game-creator-shell/tests/appSurface.test.ts index 51f42dffe..1edaebefe 100644 --- a/apps/ai-game-creator-shell/tests/appSurface.test.ts +++ b/apps/ai-game-creator-shell/tests/appSurface.test.ts @@ -35,6 +35,7 @@ import { registerRuntimeSettingsTests, } from './appSurface/runtime-settings.suite'; import { registerSupervisorRuntimeTests } from './appSurface/supervisor-runtime.suite'; +import { registerToolCallGroupTests } from './appSurface/tool-call-group.suite'; /** * 原生文件对话框是宿主能力,不能在 jsdom 里真开窗。 @@ -74,4 +75,5 @@ describe('AI 游戏创作 App 界面边界', () => { registerCanvasAssetTests(); registerPlanGddApprovalTests(); registerDesignAgentSurfaceTests(); + registerToolCallGroupTests(); }); 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 5f9601540..da2b77396 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 @@ -8035,7 +8035,7 @@ export function registerProjectSupervisorSurfaceTests() { ).toHaveLength(policyReadCountBeforeChat + 1); }); - it('renders tool-call cards from the direct turn event and keeps them after the turn completes', async () => { + it('renders one collapsed tool-call group per turn from the direct turn event and keeps it after the turn completes', async () => { const projectPath = '/tmp/launcher-tool-call-card-game'; const manifest = createGameCreationAppManifest( 'local-project-draft', @@ -8186,18 +8186,26 @@ export function registerProjectSupervisorSurfaceTests() { }, }); }); - const runningCards = within(supervisorSurface) - .getAllByTestId('agent-tool-call-card') - .filter((card) => card.getAttribute('data-status') === 'running'); - expect(runningCards).toHaveLength(1); - const runningCard = runningCards[0] as HTMLElement; - expect(within(runningCard).getByText('执行中')).not.toBeNull(); - // 此刻只应看到本回合的「执行命令」:不在对话里、也不属于当前回合的 turnId + // 一回合一个折叠块(默认折叠):块头是按钮,正文用 `hidden` 收起。 + const runningGroups = within(supervisorSurface).getAllByTestId( + 'agent-tool-call-group', + ); + expect(runningGroups).toHaveLength(1); + const runningGroup = runningGroups[0] as HTMLElement; + expect(runningGroup.getAttribute('data-status')).toBe('running'); + const runningHead = within(runningGroup).getByTestId( + 'agent-tool-call-group-head', + ); + expect(runningHead.tagName).toBe('BUTTON'); + expect(runningHead.getAttribute('aria-expanded')).toBe('false'); + const runningBody = runningGroup.querySelector( + `#${runningHead.getAttribute('aria-controls')}`, + ); + expect(runningBody).not.toBeNull(); + expect(runningBody?.hasAttribute('hidden')).toBe(true); + // 此刻只应看到本回合的那一条命令:不在对话里、也不属于当前回合的 turnId // 会在 App 侧被过滤掉,不会漂在消息流里。 - const visibleKinds = within(supervisorSurface) - .getAllByTestId('agent-tool-call-card') - .map((card) => card.getAttribute('data-kind')); - expect(visibleKinds).toEqual(['command']); + expect(runningHead.textContent).toContain('已执行 1 个命令'); await act(async () => { directTurnUpdateHandler?.({ @@ -8240,25 +8248,89 @@ export function registerProjectSupervisorSurfaceTests() { }, }); }); + // 同一回合的两条工具只产生一个块;同回合内按 startedAt 升序。 await waitFor(() => { expect( - within(supervisorSurface).getAllByTestId('agent-tool-call-card'), - ).toHaveLength(2); + within(supervisorSurface).getAllByTestId('agent-tool-call-group'), + ).toHaveLength(1); }); - const cards = within(supervisorSurface).getAllByTestId( - 'agent-tool-call-card', + const group = within(supervisorSurface).getAllByTestId( + 'agent-tool-call-group', + )[0] as HTMLElement; + expect(group.getAttribute('data-status')).toBe('failed'); + const groupHead = within(group).getByTestId('agent-tool-call-group-head'); + expect(groupHead.textContent).toContain('已执行 1 个命令、1 个文件变更'); + // 实时回合的块落在消息流末尾:该回合 assistant 消息还没落盘, + // 所以它在上一条 assistant 消息之后,而不是被锚到别人头上。 + const liveChildren = Array.from((messageList as HTMLElement).children); + const previousAssistantIndex = liveChildren.findIndex( + (node) => + node.classList.contains('message--assistant') && + node.textContent?.includes('上一轮已完成'), ); - // 同一 id 只渲染一次,状态由 completed 覆盖;同回合内按 startedAt 升序。 - expect(cards.map((card) => card.getAttribute('data-kind'))).toEqual([ + expect(previousAssistantIndex).toBeGreaterThanOrEqual(0); + expect(liveChildren.findIndex((node) => node === group)).toBeGreaterThan( + previousAssistantIndex, + ); + + // 展开块:行数 = 本回合工具数,每行一条,按 startedAt 升序。 + fireEvent.click(groupHead); + await waitFor(() => { + expect(groupHead.getAttribute('aria-expanded')).toBe('true'); + }); + const groupBody = group.querySelector( + `#${groupHead.getAttribute('aria-controls')}`, + ); + expect(groupBody?.hasAttribute('hidden')).toBe(false); + const rows = within(group).getAllByTestId('agent-tool-call-row'); + expect(rows).toHaveLength(2); + expect(rows.map((row) => row.getAttribute('data-kind'))).toEqual([ 'command', 'file_change', ]); - expect(cards[0]?.getAttribute('data-status')).toBe('completed'); - expect(cards[1]?.getAttribute('data-status')).toBe('failed'); - // 这两张卡都锚在「正在跑的回合」上;回合结束后下面对 DOM 顺序重新取一次。 - expect(cards).toHaveLength(2); + // 同一 id 只渲染一次,状态由 completed 覆盖;failed 行标「失败」。 + expect(rows[0]?.getAttribute('data-status')).toBe('completed'); + expect(rows[1]?.getAttribute('data-status')).toBe('failed'); + const commandRow = rows[0] as HTMLElement; + const fileRow = rows[1] as HTMLElement; + expect(within(commandRow).getByText('已运行 npm run build')).not.toBeNull(); + expect(within(fileRow).getByText('已编辑 game/src/hero.ts')).not.toBeNull(); + expect(within(fileRow).getByText('失败')).not.toBeNull(); - // 回合结束:assistant 消息落盘后卡片仍在该回合消息之后,不重复、不消失。 + // 行可二级展开:默认折叠,展开后看到命令 / 路径 + 变更类型 / 输出。 + const commandRowHead = within(commandRow).getByRole('button'); + expect(commandRowHead.getAttribute('aria-expanded')).toBe('false'); + expect( + commandRow + .querySelector(`#${commandRowHead.getAttribute('aria-controls')}`) + ?.hasAttribute('hidden'), + ).toBe(true); + fireEvent.click(commandRowHead); + const commandDetail = commandRow.querySelector( + `#${commandRowHead.getAttribute('aria-controls')}`, + ); + expect(commandDetail?.hasAttribute('hidden')).toBe(false); + expect( + within(commandDetail as HTMLElement).getByText('npm run build'), + ).not.toBeNull(); + expect( + within(commandDetail as HTMLElement).getByText('build ok'), + ).not.toBeNull(); + // 文件变更行:路径与变更类型都读得到。 + const fileRowHead = within(fileRow).getByRole('button'); + fireEvent.click(fileRowHead); + const fileDetail = fileRow.querySelector( + `#${fileRowHead.getAttribute('aria-controls')}`, + ); + expect(fileDetail?.hasAttribute('hidden')).toBe(false); + expect( + within(fileDetail as HTMLElement).getAllByText('game/src/hero.ts'), + ).toHaveLength(2); + expect(within(fileDetail as HTMLElement).getByText('修改')).not.toBeNull(); + expect(within(fileDetail as HTMLElement).getByText('删除')).not.toBeNull(); + + // 回合结束:assistant 消息落盘后,块移到该回合 assistant 消息**之前**(工具在上、答复在下), + // 不重复、不消失。 await act(async () => { directReply.resolve('DIRECT_REPLY:做一个跑酷游戏'); }); @@ -8269,12 +8341,12 @@ export function registerProjectSupervisorSurfaceTests() { }); await waitFor(() => { expect( - within(supervisorSurface).getAllByTestId('agent-tool-call-card'), - ).toHaveLength(2); + within(supervisorSurface).getAllByTestId('agent-tool-call-group'), + ).toHaveLength(1); }); // 重新取一次:回合结束会重渲染,之前抓到的引用已经不是当前 DOM 节点。 - const settledCards = within(supervisorSurface).getAllByTestId( - 'agent-tool-call-card', + const settledGroups = within(supervisorSurface).getAllByTestId( + 'agent-tool-call-group', ); const children = Array.from((messageList as HTMLElement).children); const assistantIndex = children.findIndex( @@ -8283,55 +8355,44 @@ export function registerProjectSupervisorSurfaceTests() { node.textContent?.includes('DIRECT_REPLY:做一个跑酷游戏'), ); expect(assistantIndex).toBeGreaterThanOrEqual(0); - expect( - children.findIndex((node) => node === settledCards[0]), - ).toBeGreaterThan(assistantIndex); - - // 折叠态:标题 + 摘要 + 状态;正文用 `hidden` 收起。 - const firstCard = settledCards[0] as HTMLElement; - const firstHead = within(firstCard).getByRole('button', { - name: '执行命令:npm run build', - }); - expect(firstHead.getAttribute('aria-expanded')).toBe('false'); - const firstBody = (messageList as HTMLElement).querySelector( - `#${firstHead.getAttribute('aria-controls')}`, + const settledGroupIndex = children.findIndex( + (node) => node === settledGroups[0], ); - expect(firstBody?.hasAttribute('hidden')).toBe(true); - expect(within(firstHead).getByText('执行命令')).not.toBeNull(); - expect(within(firstHead).getByText('npm run build')).not.toBeNull(); - expect(within(firstCard).getByText('已完成')).not.toBeNull(); - - // 键盘可达:头部就是按钮(Tab 可达),Enter/Space 触发的 click 同步 aria-expanded 与 hidden。 - expect(firstHead.tagName).toBe('BUTTON'); - (firstHead as HTMLButtonElement).focus(); - expect(document.activeElement).toBe(firstHead); - fireEvent.click(firstHead); - await waitFor(() => { - expect(firstHead.getAttribute('aria-expanded')).toBe('true'); - expect(firstBody?.hasAttribute('hidden')).toBe(false); - }); - expect(within(firstCard).getByText('build ok')).not.toBeNull(); - fireEvent.click(firstHead); - await waitFor(() => { - expect(firstHead.getAttribute('aria-expanded')).toBe('false'); - expect(firstBody?.hasAttribute('hidden')).toBe(true); - }); - - // 展开文件变更卡:路径与变更类型都读得到。 - const secondCard = settledCards[1] as HTMLElement; - const secondHead = within(secondCard).getByRole('button', { - name: '编辑 1 个文件:game/src/hero.ts', - }); - fireEvent.click(secondHead); - const secondBody = (messageList as HTMLElement).querySelector( - `#${secondHead.getAttribute('aria-controls')}`, - ); - expect(secondBody?.hasAttribute('hidden')).toBe(false); + expect(settledGroupIndex).toBeGreaterThanOrEqual(0); + // 块在该回合的 user 消息与 assistant 消息之间:紧邻 assistant 消息之前。 + expect(settledGroupIndex).toBeLessThan(assistantIndex); + expect(settledGroupIndex).toBe(assistantIndex - 1); expect( - within(secondBody as HTMLElement).getAllByText('game/src/hero.ts'), + children[settledGroupIndex - 1]?.classList.contains('message--user'), + ).toBe(true); + + // 重新挂载后的块回到默认折叠;键盘可达:块头就是按钮(Tab 可达), + // Enter/Space 触发的 click 同步 aria-expanded 与 hidden。 + const settledGroup = settledGroups[0] as HTMLElement; + const settledHead = within(settledGroup).getByTestId( + 'agent-tool-call-group-head', + ); + expect(settledHead.tagName).toBe('BUTTON'); + expect(settledHead.getAttribute('aria-expanded')).toBe('false'); + const settledBody = settledGroup.querySelector( + `#${settledHead.getAttribute('aria-controls')}`, + ); + expect(settledBody?.hasAttribute('hidden')).toBe(true); + (settledHead as HTMLButtonElement).focus(); + expect(document.activeElement).toBe(settledHead); + fireEvent.click(settledHead); + await waitFor(() => { + expect(settledHead.getAttribute('aria-expanded')).toBe('true'); + expect(settledBody?.hasAttribute('hidden')).toBe(false); + }); + expect( + within(settledGroup).getAllByTestId('agent-tool-call-row'), ).toHaveLength(2); - expect(within(secondBody as HTMLElement).getByText('修改')).not.toBeNull(); - expect(within(secondBody as HTMLElement).getByText('删除')).not.toBeNull(); + fireEvent.click(settledHead); + await waitFor(() => { + expect(settledHead.getAttribute('aria-expanded')).toBe('false'); + expect(settledBody?.hasAttribute('hidden')).toBe(true); + }); }); it('reads the persisted tool-call history whenever a project is opened', async () => { @@ -8363,15 +8424,59 @@ export function registerProjectSupervisorSurfaceTests() { } if (command === 'read_direct_project_conversation') { return { - path: projectPath, + path: `${projectPath}/.agent/conversations/project.jsonl`, agentId: null, sessionId: null, - messages: [], + messages: [ + { + schemaVersion: 'agc-direct-project-context.v1', + role: 'user', + content: '做一个小球弹跳游戏', + agentId: null, + messageId: 'direct-codex:turn-persisted:user', + updatedAt: 1000, + }, + { + schemaVersion: 'agc-direct-project-context.v1', + role: 'assistant', + content: '上一轮的答复', + agentId: null, + messageId: 'direct-codex:turn-persisted:assistant', + updatedAt: 2000, + }, + ], }; } if (command === 'read_direct_tool_calls') { readToolCallCount += 1; - return []; + return [ + { + schemaVersion: 'agc-tool-call.v1', + id: 'persisted-command', + turnId: 'turn-persisted', + kind: 'command', + title: '执行命令', + summary: 'npm run build', + status: 'completed', + detail: { command: 'npm run build' }, + startedAt: 1000, + updatedAt: 1500, + }, + { + schemaVersion: 'agc-tool-call.v1', + id: 'persisted-file', + turnId: 'turn-persisted', + kind: 'file_change', + title: '编辑 1 个文件', + summary: 'game/src/hero.ts', + status: 'completed', + detail: { + changes: [{ path: 'game/src/hero.ts', kind: 'add' }], + }, + startedAt: 1500, + updatedAt: 2000, + }, + ]; } if (command === 'get_local_game_preview_status') { return { status: 'stopped', url: null, port: null, root: null }; @@ -8395,10 +8500,34 @@ export function registerProjectSupervisorSurfaceTests() { expect(invoke).toHaveBeenCalledWith('read_direct_tool_calls', { projectPath, }); - // 历史为空时与改造前一致:没有卡片,也不报错。 + // 回读到的工具调用挂在自己回合的 assistant 消息**之前**(工具在上、答复在下), + // 默认折叠、展开后行数 = 回读到的工具数。 + const persistedGroups = await within(supervisorSurface).findAllByTestId( + 'agent-tool-call-group', + ); + expect(persistedGroups).toHaveLength(1); + const persistedGroup = persistedGroups[0] as HTMLElement; + const persistedHead = within(persistedGroup).getByTestId( + 'agent-tool-call-group-head', + ); + expect(persistedHead.getAttribute('aria-expanded')).toBe('false'); + expect(persistedHead.textContent).toContain( + '已执行 1 个命令、1 个文件变更', + ); + const messageList = supervisorSurface.querySelector( + '.project-supervisor-message-list', + ) as HTMLElement; + const children = Array.from(messageList.children); + const assistantIndex = children.findIndex((node) => + node.classList.contains('message--assistant'), + ); + const groupIndex = children.findIndex((node) => node === persistedGroup); + expect(assistantIndex).toBeGreaterThanOrEqual(0); + expect(groupIndex).toBe(assistantIndex - 1); + fireEvent.click(persistedHead); expect( - within(supervisorSurface).queryAllByTestId('agent-tool-call-card'), - ).toHaveLength(0); + within(persistedGroup).getAllByTestId('agent-tool-call-row'), + ).toHaveLength(2); }); it('renders the Codex empty state and opens the panel settings overlay in a fresh direct chat', async () => { 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 new file mode 100644 index 000000000..152d5d8da --- /dev/null +++ b/apps/ai-game-creator-shell/tests/appSurface/tool-call-group.suite.ts @@ -0,0 +1,208 @@ +import type { GameCreatorDirectToolCall } from '../../src/app/types'; +import { ToolCallGroup } from '../../src/features/project-workspace/ToolCallGroup'; +import { + toolCallGroupSummary, + toolCallRowText, +} from '../../src/features/project-workspace/toolCallGroupPresentation'; +import { expect, fireEvent, it, React, render, within } from './harness'; + +function toolCall( + overrides: Partial & + Pick, +): 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('summarizes tool calls by kind in a fixed order', () => { + // 单 kind。 + expect(toolCallGroupSummary([toolCall({ id: 'a', kind: 'command' })])).toBe( + '已执行 1 个命令', + ); + // 混合:顺序固定 command → file_change → mcp_tool → web_search → + // context_compaction → other,与传入顺序无关。 + 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( + '已执行 2 个命令、1 个文件变更、1 个工具调用、1 个联网搜索、1 个上下文整理、1 个其他操作', + ); + // 空集合。 + 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(); + 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( + '已执行 1 个命令、1 个文件变更', + ); + expect( + head.querySelector('.agent-tool-call-group-summary')?.textContent, + ).toBe('已执行 1 个命令、1 个文件变更'); + 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).getByText('已编辑 game/src/hero.ts'), + ).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: '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( + 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).getByText('game/src/hero.ts'), + ).not.toBeNull(); + expect(within(fileDetail as HTMLElement).getByText('新增')).not.toBeNull(); + + // 空集合不渲染块。 + const empty = render(React.createElement(ToolCallGroup, { calls: [] })); + expect(empty.container.firstChild).toBeNull(); + }); +} diff --git a/docs/technical/【技术方案】GameAgent对话工具调用卡片-2026-09-14.md b/docs/technical/【技术方案】GameAgent对话工具调用卡片-2026-09-14.md index 7cf7c832e..10da3aea8 100644 --- a/docs/technical/【技术方案】GameAgent对话工具调用卡片-2026-09-14.md +++ b/docs/technical/【技术方案】GameAgent对话工具调用卡片-2026-09-14.md @@ -48,32 +48,49 @@ toolCalls?: DirectTurnToolCall[] | null; - 历史文件缺失 → 返回空数组,不报错。 - 单行损坏 → 跳过该行继续,不整体失败(与 Codex item 流一样是"尽力而为"的展示数据,不是业务真相)。 -### 4. 前端合并与渲染 +### 4. 前端合并与渲染(2026-09 修订:一回合一个折叠块) -- 加载对话时把回读结果按 `turnId` 归并进消息流:工具调用卡插在**同一回合最后一条 assistant 消息之后**,同一回合内按 `startedAt` 升序。 -- 实时回合(`directCodexProductRuntime` 且 `activeDirectCodexTurnRef` 命中)时,卡片跟着事件增量更新;回合结束后由持久化数据接管(不出现重复卡片,同一 `id` 只渲染一次)。 -- 卡片 DOM 与交互(对齐 Codex): +- 加载对话时把回读结果按 `turnId` 归并进消息流:**同一回合的工具调用收成一个折叠块**,块插在该回合 **assistant 消息之前**(Codex 是「工具在上、答复在下」),同一回合内按 `startedAt` 升序。 +- 实时回合(`directCodexProductRuntime` 且 `activeDirectCodexTurnRef` 命中)时,块跟着事件增量更新;回合的 assistant 消息还没落盘时,块落在消息流末尾(下一条 assistant 消息一落盘,块就回到它之前),回合结束后由持久化数据接管(不出现重复块,同一 `id` 每条工具只渲染一行)。 +- 块 DOM 与交互(对齐 Codex): ```html -
    - -
    ``` -- 必须用 `