工具调用改成「一回合一个折叠块」:结构与锚点
- 新增 ToolCallGroup.tsx:块头一行汇总(按 kind 固定顺序计数拼接),展开态每行一条工具,行可二级展开看命令 / 文件变更 / 输出 - 新增 toolCallGroupPresentation.ts:汇总与行文案纯函数(组件文件不导出非组件值) - 删除 ToolCallCard.tsx:一回合多卡片的旧形态不再使用 - ProjectSupervisorView:工具调用块插到该回合 assistant 消息之前;实时回合还没有 assistant 消息时先落在消息流末尾 - 汇总 / 行文案按契约固定:已执行 N 个命令、… / 已运行、已编辑、已调用、已搜索、已整理上下文、已执行 - appSurface 用例改为断言折叠块:默认折叠、行数 = 工具数、回读历史插在 assistant 消息之前 - 新增 tool-call-group.suite.ts:汇总文案、行文案、默认折叠、行二级展开、空集合不渲染 - 技术方案文档同步第 4 节:一回合一个折叠块的 DOM 与文案契约
This commit is contained in:
+27
-27
@@ -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:<turnId>:<role>`。 */
|
||||
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:<turnId>:assistant`,不回落到别的回合。
|
||||
const liveAssistantMessageId = [...visibleMessages]
|
||||
.reverse()
|
||||
.find((message) => message.role === 'assistant')?.messageId;
|
||||
// 工具调用折叠块按回合分组,插在**同一回合 assistant 消息之前**(Codex 是「工具在上、答复在下」)。
|
||||
// 历史回合锚到自己那条 `direct-codex:<turnId>:assistant`,不回落到别的回合;
|
||||
// 正在跑的回合还没有 assistant 消息落盘,先落在消息流末尾,等那条消息落盘后回到它之前。
|
||||
const streamTurnId = activeTurnId?.trim() ?? '';
|
||||
const toolCallsByAnchor = new Map<string, GameCreatorDirectToolCall[]>();
|
||||
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 (
|
||||
<Fragment key={message.messageId ?? `${message.role}-${index}`}>
|
||||
{anchoredToolCalls.length > 0 ? (
|
||||
<ToolCallGroup
|
||||
calls={anchoredToolCalls}
|
||||
className="message-tool-call"
|
||||
/>
|
||||
) : null}
|
||||
<div className={`message message--${message.role}`}>
|
||||
<ChatMarkdownMessage
|
||||
role={message.role}
|
||||
text={projectSupervisorChatMessageText(message)}
|
||||
/>
|
||||
</div>
|
||||
{anchoredToolCalls.map((call) => (
|
||||
<ToolCallCard
|
||||
key={call.id}
|
||||
call={call}
|
||||
className="message-tool-call"
|
||||
/>
|
||||
))}
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
{liveToolCalls.length > 0 ? (
|
||||
<ToolCallGroup
|
||||
calls={liveToolCalls}
|
||||
className="message-tool-call"
|
||||
/>
|
||||
) : null}
|
||||
{designReasoning ? (
|
||||
<details className="design-agent-reasoning">
|
||||
<summary>显示思考过程</summary>
|
||||
|
||||
@@ -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`。
|
||||
*
|
||||
* 无障碍:折叠/展开是一只 `<button aria-expanded>` + `hidden` 控制正文,
|
||||
* 所以 Tab 可达、Enter/Space 可切换、读屏能读到展开状态与 `aria-label`。
|
||||
*/
|
||||
export function ToolCallCard({
|
||||
call,
|
||||
className,
|
||||
}: {
|
||||
call: GameCreatorDirectToolCall;
|
||||
className?: string;
|
||||
}) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const bodyId = useId();
|
||||
const statusLabel =
|
||||
call.status === 'running'
|
||||
? '执行中'
|
||||
: call.status === 'failed'
|
||||
? '失败'
|
||||
: '已完成';
|
||||
const summary = call.summary.trim();
|
||||
const bodyLabel = `${call.title}:${summary || call.id}`;
|
||||
const changes = call.detail.changes ?? [];
|
||||
return (
|
||||
<section
|
||||
className={className ? `agent-tool-call ${className}` : 'agent-tool-call'}
|
||||
data-kind={call.kind}
|
||||
data-status={call.status}
|
||||
data-testid="agent-tool-call-card"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="agent-tool-call-head"
|
||||
aria-expanded={expanded}
|
||||
aria-controls={bodyId}
|
||||
aria-label={bodyLabel}
|
||||
title={bodyLabel}
|
||||
onClick={() => setExpanded((current) => !current)}
|
||||
>
|
||||
<span className="agent-tool-call-icon" aria-hidden="true">
|
||||
<ToolCallIcon kind={call.kind} />
|
||||
</span>
|
||||
<span className="agent-tool-call-title">{call.title}</span>
|
||||
{summary ? (
|
||||
<span className="agent-tool-call-summary">{summary}</span>
|
||||
) : null}
|
||||
{call.status === 'running' ? (
|
||||
<span className="agent-tool-call-running-dot" aria-hidden="true" />
|
||||
) : null}
|
||||
<span className="agent-tool-call-status">{statusLabel}</span>
|
||||
<ChevronDown
|
||||
className="agent-tool-call-chevron"
|
||||
size={14}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</button>
|
||||
<div className="agent-tool-call-body" id={bodyId} hidden={!expanded}>
|
||||
{call.detail.command ? (
|
||||
<pre className="agent-tool-call-command">{call.detail.command}</pre>
|
||||
) : null}
|
||||
{changes.length > 0 ? (
|
||||
<ul className="agent-tool-call-changes">
|
||||
{changes.map((change, index) => (
|
||||
<li key={`${change.path}-${index}`}>
|
||||
{change.path}
|
||||
<small>{toolCallChangeKindLabel(change.kind)}</small>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
{call.detail.output ? (
|
||||
<pre className="agent-tool-call-output">{call.detail.output}</pre>
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function toolCallChangeKindLabel(kind: string) {
|
||||
if (kind === 'add') {
|
||||
return '新增';
|
||||
}
|
||||
if (kind === 'delete') {
|
||||
return '删除';
|
||||
}
|
||||
return '修改';
|
||||
}
|
||||
|
||||
function ToolCallIcon({ kind }: { kind: string }) {
|
||||
switch (kind) {
|
||||
case 'command':
|
||||
return <Terminal size={14} />;
|
||||
case 'file_change':
|
||||
return <FileText size={14} />;
|
||||
case 'web_search':
|
||||
return <Globe size={14} />;
|
||||
case 'context_compaction':
|
||||
return <Minimize2 size={14} />;
|
||||
default:
|
||||
return <Wrench size={14} />;
|
||||
}
|
||||
}
|
||||
@@ -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`。
|
||||
*
|
||||
* 无障碍:块头与每一行都是 `<button aria-expanded>` + `hidden` 控制正文,
|
||||
* 所以 Tab 可达、Enter/Space 可切换、读屏能读到展开状态与 `aria-label`。
|
||||
* 文案规则(纯函数)在 `toolCallGroupPresentation.ts`。
|
||||
*/
|
||||
|
||||
/** 一回合的工具调用折叠块;空集合不渲染。 */
|
||||
export function ToolCallGroup({
|
||||
calls,
|
||||
className,
|
||||
}: {
|
||||
calls: GameCreatorDirectToolCall[];
|
||||
className?: string;
|
||||
}) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const bodyId = useId();
|
||||
if (calls.length === 0) {
|
||||
return null;
|
||||
}
|
||||
const orderedCalls = [...calls].sort(
|
||||
(left, right) => left.startedAt - right.startedAt,
|
||||
);
|
||||
const summary = toolCallGroupSummary(orderedCalls);
|
||||
const status = orderedCalls.some((call) => call.status === 'running')
|
||||
? 'running'
|
||||
: orderedCalls.some((call) => call.status === 'failed')
|
||||
? 'failed'
|
||||
: 'completed';
|
||||
return (
|
||||
<section
|
||||
className={
|
||||
className
|
||||
? `agent-tool-call-group ${className}`
|
||||
: 'agent-tool-call-group'
|
||||
}
|
||||
data-testid="agent-tool-call-group"
|
||||
data-status={status}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="agent-tool-call-group-head"
|
||||
data-testid="agent-tool-call-group-head"
|
||||
aria-expanded={expanded}
|
||||
aria-controls={bodyId}
|
||||
aria-label={summary}
|
||||
title={summary}
|
||||
onClick={() => setExpanded((current) => !current)}
|
||||
>
|
||||
<span className="agent-tool-call-group-icon" aria-hidden="true">
|
||||
<ToolCallKindIcon kind={orderedCalls[0]?.kind ?? 'other'} />
|
||||
</span>
|
||||
<span className="agent-tool-call-group-summary">{summary}</span>
|
||||
<ChevronDown
|
||||
className="agent-tool-call-group-chevron"
|
||||
size={14}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</button>
|
||||
<div
|
||||
className="agent-tool-call-group-body"
|
||||
id={bodyId}
|
||||
hidden={!expanded}
|
||||
>
|
||||
<ul className="agent-tool-call-group-rows">
|
||||
{orderedCalls.map((call) => (
|
||||
<ToolCallRow key={call.id} call={call} />
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
/** 展开态的一行工具;行可二级展开看命令 / 文件明细 / 输出。 */
|
||||
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 (
|
||||
<li
|
||||
className="agent-tool-call-group-row"
|
||||
data-testid="agent-tool-call-row"
|
||||
data-kind={call.kind}
|
||||
data-status={call.status}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="agent-tool-call-row-head"
|
||||
aria-expanded={expanded}
|
||||
aria-controls={detailId}
|
||||
aria-label={rowLabel}
|
||||
title={rowLabel}
|
||||
onClick={() => setExpanded((current) => !current)}
|
||||
>
|
||||
<span className="agent-tool-call-row-icon" aria-hidden="true">
|
||||
<ToolCallKindIcon kind={call.kind} />
|
||||
</span>
|
||||
<span className="agent-tool-call-row-text">{text}</span>
|
||||
{statusText ? (
|
||||
<span className="agent-tool-call-row-status">{statusText}</span>
|
||||
) : null}
|
||||
{hasDetail ? (
|
||||
<ChevronDown
|
||||
className="agent-tool-call-row-chevron"
|
||||
size={12}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
) : null}
|
||||
</button>
|
||||
<div
|
||||
className="agent-tool-call-row-detail"
|
||||
id={detailId}
|
||||
hidden={!expanded}
|
||||
>
|
||||
{detailCommand ? (
|
||||
<pre className="agent-tool-call-row-command">{detailCommand}</pre>
|
||||
) : null}
|
||||
{changes.length > 0 ? (
|
||||
<ul className="agent-tool-call-row-changes">
|
||||
{changes.map((change, index) => (
|
||||
<li key={`${change.path}-${index}`}>
|
||||
<span>{change.path}</span>
|
||||
<small>{toolCallChangeKindLabel(change.kind)}</small>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
{detailOutput ? (
|
||||
<pre className="agent-tool-call-row-output">{detailOutput}</pre>
|
||||
) : null}
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
function toolCallChangeKindLabel(kind: string) {
|
||||
if (kind === 'add') {
|
||||
return '新增';
|
||||
}
|
||||
if (kind === 'delete') {
|
||||
return '删除';
|
||||
}
|
||||
return '修改';
|
||||
}
|
||||
|
||||
function ToolCallKindIcon({ kind }: { kind: string }) {
|
||||
switch (kind) {
|
||||
case 'command':
|
||||
return <Terminal size={14} />;
|
||||
case 'file_change':
|
||||
return <FileText size={14} />;
|
||||
case 'web_search':
|
||||
return <Globe size={14} />;
|
||||
case 'context_compaction':
|
||||
return <Minimize2 size={14} />;
|
||||
default:
|
||||
return <Wrench size={14} />;
|
||||
}
|
||||
}
|
||||
+94
@@ -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<GameCreatorDirectToolCallKind, string> = {
|
||||
command: '命令',
|
||||
file_change: '文件变更',
|
||||
mcp_tool: '工具调用',
|
||||
web_search: '联网搜索',
|
||||
context_compaction: '上下文整理',
|
||||
other: '其他操作',
|
||||
};
|
||||
|
||||
/** 行文案动词:`已运行 {summary}` / `已编辑 {summary}` / …,`context_compaction` 不带 summary。 */
|
||||
const TOOL_CALL_ROW_VERBS: Partial<
|
||||
Record<GameCreatorDirectToolCallKind, string>
|
||||
> = {
|
||||
command: '已运行',
|
||||
file_change: '已编辑',
|
||||
mcp_tool: '已调用',
|
||||
web_search: '已搜索',
|
||||
other: '已执行',
|
||||
};
|
||||
|
||||
/** 汇总文案:按 kind 计数、固定顺序拼成 `已执行 5 个命令、2 个文件变更`;空集合返回空串。 */
|
||||
export function toolCallGroupSummary(calls: GameCreatorDirectToolCall[]) {
|
||||
const counts = new Map<string, number>();
|
||||
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();
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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<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('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();
|
||||
});
|
||||
}
|
||||
@@ -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
|
||||
<section class="agent-tool-call" data-kind="command" data-status="running">
|
||||
<button type="button" class="agent-tool-call-head" aria-expanded="false">
|
||||
<span class="agent-tool-call-icon" aria-hidden="true"></span>
|
||||
<span class="agent-tool-call-title">执行命令</span>
|
||||
<span class="agent-tool-call-summary">npm run build</span>
|
||||
<span class="agent-tool-call-status">执行中</span>
|
||||
<svg class="agent-tool-call-chevron" aria-hidden="true"></svg>
|
||||
<section class="agent-tool-call-group" data-testid="agent-tool-call-group" data-status="completed">
|
||||
<button type="button" class="agent-tool-call-group-head" aria-expanded="false" aria-controls="…"
|
||||
aria-label="已执行 2 个命令、1 个文件变更,用时 42秒">
|
||||
<span class="agent-tool-call-group-icon" aria-hidden="true"></span>
|
||||
<span class="agent-tool-call-group-summary">已执行 2 个命令、1 个文件变更</span>
|
||||
<span class="agent-tool-call-group-time">14:20 → 14:21</span>
|
||||
<span class="agent-tool-call-group-duration">用时 42秒</span>
|
||||
<svg class="agent-tool-call-group-chevron" aria-hidden="true"></svg>
|
||||
</button>
|
||||
<div class="agent-tool-call-body" hidden>
|
||||
<pre class="agent-tool-call-command">...</pre>
|
||||
<ul class="agent-tool-call-changes"><li>game/src/x.ts <small>新增</small></li></ul>
|
||||
<pre class="agent-tool-call-output">...</pre>
|
||||
<div class="agent-tool-call-group-body" hidden>
|
||||
<ul class="agent-tool-call-group-rows">
|
||||
<li class="agent-tool-call-group-row" data-testid="agent-tool-call-row" data-kind="command" data-duration-ms="12300">
|
||||
<button type="button" class="agent-tool-call-row-head" aria-expanded="false" aria-controls="…"
|
||||
aria-label="已运行 npm run build,耗时 12.3s">
|
||||
<span class="agent-tool-call-row-icon" aria-hidden="true"></span>
|
||||
<span class="agent-tool-call-row-text">已运行 npm run build</span>
|
||||
<span class="agent-tool-call-row-duration">12.3s</span>
|
||||
<svg class="agent-tool-call-row-chevron" aria-hidden="true"></svg>
|
||||
</button>
|
||||
<div class="agent-tool-call-row-detail" hidden>
|
||||
<pre class="agent-tool-call-row-command">…</pre>
|
||||
<ul class="agent-tool-call-row-changes"><li><span>game/src/x.ts</span><small>新增</small></li></ul>
|
||||
<pre class="agent-tool-call-row-output">…</pre>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
```
|
||||
|
||||
- 必须用 `<button aria-expanded>` + `hidden` 控制展开(键盘可达、可读屏),`aria-label` 说明「执行命令:npm run build」。
|
||||
- 默认折叠;`running` 时标题右侧显示进行中状态点;`failed` 时标题与状态文案用错误色(用现有 `--platform-*` 变量,不新增色值)。
|
||||
- 输入框、消息气泡、消息列表滚动模型**不变**;卡片只是消息流里的一个块。
|
||||
- 文案规则(按 kind,不允许自由发挥):
|
||||
- 块头汇总按 kind 计数、顺序固定 `command → file_change → mcp_tool → web_search → context_compaction → other`,标签 `命令`/`文件变更`/`工具调用`/`联网搜索`/`上下文整理`/`其他操作`,形如 `已执行 5 个命令、2 个文件变更`;空集合不渲染块。
|
||||
- 行文案:`command` → `已运行 {summary}`、`file_change` → `已编辑 {summary}`、`mcp_tool` → `已调用 {summary}`、`web_search` → `已搜索 {summary}`、`context_compaction` → `已整理上下文`、`other` → `已执行 {summary}`;`failed` 行加 `失败` 并用现有 `--platform-*` 错误色。
|
||||
- 耗时:单条工具 `startedAt` → `updatedAt`,块头 = 该回合所有工具的 `min(startedAt)` → `max(updatedAt)`。`startedAt` 为 0 或 `updatedAt < startedAt` 时不显示耗时(不显示 `0s` / 负数)。
|
||||
- 必须用 `<button aria-expanded>` + `hidden` 控制展开(键盘可达、可读屏),块头与行都是按钮:`aria-label` = 汇总 / 行文案 + 耗时;默认折叠。
|
||||
- 输入框、消息气泡、消息列表滚动模型**不变**;块只是消息流里的一个块。
|
||||
|
||||
## 验收判据(每条都要有可复现证据)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user