GameAgent 工具调用卡片:回读合并、折叠卡片与无障碍

- app/types.ts:新增 GameCreatorDirectToolCall / Detail / Change 类型,并给
  GameCreatorDirectTurnUpdateEvent 增加可选 toolCalls 字段(老事件 undefined 走原路径)
- App.tsx:订阅 game-creator-direct-turn-update 时按 id 归并工具调用增量(completed 覆盖
  running、startedAt 取最早),打开项目时整表替换为 read_direct_tool_calls 的回读结果
- App.tsx:只把「对话里已有该回合」或「当前正在跑的回合」的工具调用交给面板,避免旧卡片漂尾
- ProjectSupervisorView.tsx:新增 toolCalls / activeTurnId 属性,按回合把卡片插在
  **同一回合最后一条 assistant 消息之后**,同回合内按 startedAt 升序;当前回合在 assistant
  消息落盘前锚到最后一条 assistant 消息
- ToolCallCard.tsx(新增):Codex 风格可折叠卡片,`<button aria-expanded aria-controls>` +
  `hidden` 控制展开,键盘可达,aria-label 为「执行命令:npm run build」
- ToolCallCard.tsx:折叠态显示图标/标题/摘要/进行中状态点/状态文案;展开态显示命令、
  文件路径 + 变更类型、输出
- styles.css:新增工具调用卡片样式区块,running 状态点与 failed 配色都用现有 --platform-* 变量
- tests/appSurface/project-development.suite.ts:新增两条用例 —— 实时回合卡片从 running 走到
  completed 并在回合结束后仍挂在同一回合消息之后;打开项目时调用 read_direct_tool_calls
This commit is contained in:
2026-09-14 15:00:46 +08:00
parent 6ba86f5061
commit abbf1465e2
6 changed files with 891 additions and 12 deletions
+124
View File
@@ -57,6 +57,7 @@ import type {
DesignView,
GameCreatorAgentRuntimeUpdateEvent,
GameCreatorChatAgentReply,
GameCreatorDirectToolCall,
GameCreatorDirectTurnUpdateEvent,
GameCreatorLlmConfigStatus,
GameCreatorManifestInvalidatedEvent,
@@ -456,6 +457,21 @@ function directCodexConversationMessageId(
return `${DIRECT_CODEX_CONVERSATION_MESSAGE_ID_PREFIX}${turnId}:${role}`;
}
/** 从 `direct-codex:<turnId>:assistant` 反解回合 id;不是这个形状就返回 `null`。 */
function directCodexTurnIdFromAssistantMessageId(messageId: string) {
const suffix = ':assistant';
if (
!messageId.startsWith(DIRECT_CODEX_CONVERSATION_MESSAGE_ID_PREFIX) ||
!messageId.endsWith(suffix)
) {
return null;
}
return messageId.slice(
DIRECT_CODEX_CONVERSATION_MESSAGE_ID_PREFIX.length,
-suffix.length,
);
}
export function isDirectCodexTurnAlreadyRunningError(error: unknown) {
const message = error instanceof Error ? error.message : String(error);
return message
@@ -755,6 +771,57 @@ export function App({
} | null>(null);
const lastDirectCodexActivityRef = useRef<string | null>(null);
const directCodexConversationTurnSequenceRef = useRef(0);
// 工具调用卡片:按 **id** 归并(实时增量 + 回读历史共用一份),同一 id 只渲染一次。
// 用 ref 做写入基准,避免同一批事件里多条增量互相覆盖。
const [directToolCalls, setDirectToolCalls] = useState<
GameCreatorDirectToolCall[]
>([]);
const directToolCallsRef = useRef<GameCreatorDirectToolCall[]>([]);
/**
* id `completed` `running`
* id id
*/
function applyDirectToolCalls(
incoming: readonly GameCreatorDirectToolCall[],
) {
if (incoming.length === 0) {
return;
}
const merged = [...directToolCallsRef.current];
for (const call of incoming) {
const id = call.id?.trim();
if (!id) {
continue;
}
const existingIndex = merged.findIndex((existing) => existing.id === id);
const normalized: GameCreatorDirectToolCall = {
...call,
id,
detail: call.detail ?? { changes: [] },
};
// 起点时间取更早的那个:`completed` 事件不一定带 startedAt。
const existing = existingIndex >= 0 ? merged[existingIndex] : undefined;
if (
existing &&
existing.startedAt > 0 &&
(normalized.startedAt === 0 ||
existing.startedAt < normalized.startedAt)
) {
normalized.startedAt = existing.startedAt;
}
if (existingIndex >= 0) {
merged[existingIndex] = normalized;
} else {
merged.push(normalized);
}
}
merged.sort(
(left, right) =>
left.startedAt - right.startedAt || left.id.localeCompare(right.id),
);
directToolCallsRef.current = merged;
setDirectToolCalls(merged);
}
const [projectSupervisorSessionId, setProjectSupervisorSessionId] = useState<
string | null
>(null);
@@ -786,6 +853,23 @@ export function App({
setDirectCodexTransientReplyUpdatedAt(null);
}
/** 工具调用卡片按项目维度作废:换项目 / 重开历史时整体替换,避免串项目。 */
function replaceDirectToolCalls(next: readonly GameCreatorDirectToolCall[]) {
const normalized = next
.filter((call) => Boolean(call.id?.trim()))
.map((call) => ({
...call,
id: call.id.trim(),
detail: call.detail ?? { changes: [] },
}))
.sort(
(left, right) =>
left.startedAt - right.startedAt || left.id.localeCompare(right.id),
);
directToolCallsRef.current = normalized;
setDirectToolCalls(normalized);
}
function clearDirectCodexTransientReply(projectPath: string, turnId: string) {
const activeTurn = activeDirectCodexTurnRef.current;
if (
@@ -1702,6 +1786,15 @@ export function App({
}
activeTurn.lastSequence = payload.sequence;
activeTurn.receivedDirectUpdate = true;
// 工具调用增量:字段可选,老事件(undefined)走原路径,行为不变。
if (payload.toolCalls?.length) {
applyDirectToolCalls(
payload.toolCalls.map((call) => ({
...call,
turnId: payload.turnId,
})),
);
}
const updatedAt =
Number.isFinite(payload.updatedAt) && payload.updatedAt > 0
? payload.updatedAt
@@ -3321,6 +3414,17 @@ export function App({
? { projectPath: nextProjectPath }
: { projectPath: nextProjectPath, agentId: null },
);
// 工具调用卡片走独立历史文件(`tool-calls.jsonl`)。必须在读完项目对话之后、
// 任何提前 return 之前回读:direct-codex 下后面那条 design-agent 分支会直接返回,
// 放在它后面等于永远不执行。文件缺失 / 读取失败都只是没有卡片,不能因此把整个
// 项目打开流程判失败。卡片按项目维度整表替换,同一 id 只渲染一次。
if (directCodexProductRuntime) {
const persistedToolCalls = await invoke<GameCreatorDirectToolCall[]>(
'read_direct_tool_calls',
{ projectPath: nextProjectPath },
).catch(() => []);
replaceDirectToolCalls(persistedToolCalls);
}
let supervisorConversation: LocalConversationResult | null = null;
let runtime: AgentRuntimeState | null = null;
let runtimeResponseStream: AgentRuntimeResponseStream | null = null;
@@ -11520,6 +11624,20 @@ export function App({
0,
messages.length - visibleMessages.length,
);
// 工具调用卡片只保留「当前消息列表里确实有这个回合」的那些:实时回合一进来就能挂上,
// 历史回合只有对应的 assistant 消息还在列表里才渲染,避免旧卡片漂在列表尾部。
const visibleTurnIds = new Set(
messages
.map((message) => message.messageId)
.filter((messageId): messageId is string => Boolean(messageId))
.map(directCodexTurnIdFromAssistantMessageId)
.filter((turnId): turnId is string => Boolean(turnId)),
);
const visibleToolCalls = directToolCalls.filter(
(call) =>
visibleTurnIds.has(call.turnId) ||
call.turnId === activeDirectCodexTurnRef.current?.turnId,
);
const projectSupervisorTransientReply =
projectSupervisorResponseStream?.accumulatedText.trim() ?? '';
const projectSupervisorNeedsUserInput = agentRuntimeNeedsUserInput(
@@ -11789,6 +11907,12 @@ export function App({
}
pendingCommand={directCodexProductRuntime ? pendingCommand : null}
projectPath={localProject?.projectPath ?? projectPath}
toolCalls={visibleToolCalls}
activeTurnId={
directCodexProductRuntime
? (activeDirectCodexTurnRef.current?.turnId ?? null)
: null
}
transientReply={
planningV2Active
? planningV2TransientReply
@@ -1087,6 +1087,57 @@ export type GameCreatorDirectTurnActivity =
| 'response-finalization'
| 'none';
export type GameCreatorDirectToolCallKind =
| 'command'
| 'file_change'
| 'mcp_tool'
| 'web_search'
| 'context_compaction'
| 'other';
export type GameCreatorDirectToolCallStatus =
| 'running'
| 'completed'
| 'failed';
export interface GameCreatorDirectToolCallChange {
path: string;
kind: 'add' | 'update' | 'delete' | string;
}
export interface GameCreatorDirectToolCallDetail {
command?: string;
output?: string;
changes?: GameCreatorDirectToolCallChange[];
}
/**
* 一条工具调用(Codex item 的结构化投影)。
*
* 契约见 `docs/technical/【技术方案】GameAgent对话工具调用卡片-2026-09-14.md`
* 字段形状与 Rust 侧 `DirectToolCall`、独立历史文件
* `.agent/conversations/tool-calls.jsonl` 的 payload 一致(这里少 `turnId` 的变体用于
* 事件增量,见下面 `GameCreatorDirectTurnToolCall`)。
*/
export interface GameCreatorDirectToolCall {
schemaVersion: string;
id: string;
turnId: string;
kind: GameCreatorDirectToolCallKind;
title: string;
summary: string;
status: GameCreatorDirectToolCallStatus;
detail: GameCreatorDirectToolCallDetail;
startedAt: number;
updatedAt: number;
}
/** 事件里下发的增量条目:与持久化同形,去掉 `turnId`(回合 id 在事件顶层)。 */
export type GameCreatorDirectTurnToolCall = Omit<
GameCreatorDirectToolCall,
'turnId'
>;
export interface GameCreatorDirectTurnUpdateEvent {
projectPath: string;
turnId: string;
@@ -1094,6 +1145,11 @@ export interface GameCreatorDirectTurnUpdateEvent {
status: GameCreatorDirectTurnUpdateStatus;
activity?: GameCreatorDirectTurnActivity | null;
accumulatedText?: string | null;
/**
* 本回合内**发生变化**的结构化工具调用(只有变化时才带,不是每个 heartbeat 都带全量)。
* 可选:老版本事件没有这个字段,前端拿到 `undefined` 时必须与改造前行为一致。
*/
toolCalls?: GameCreatorDirectTurnToolCall[] | null;
updatedAt: number;
}
@@ -13,11 +13,12 @@ import type {
RefObject,
UIEventHandler,
} from 'react';
import { useEffect, useRef, useState } from 'react';
import { Fragment, useEffect, useRef, useState } from 'react';
import type {
AgentStatusCard,
ChatMessage,
GameCreatorDirectToolCall,
GameCreatorDirectTurnUpdateStatus,
PendingCommand,
PendingUiConfirmation,
@@ -62,6 +63,12 @@ import {
type ResourceReferenceInputHandle,
} from './ResourceReferenceInput';
import type { ChatComposerDraft, ChatReference } from './resourceReferences';
import { ToolCallCard } from './ToolCallCard';
/** 与 `App.tsx` 的回合消息 id 同构:`direct-codex:<turnId>:<role>`。 */
function directCodexTurnMessageId(turnId: string, role: 'user' | 'assistant') {
return `direct-codex:${turnId}:${role}`;
}
type RuntimePanelProps = ComponentProps<typeof ProjectSupervisorRuntimePanel>;
@@ -108,6 +115,10 @@ type ProjectSupervisorViewProps = RuntimePanelProps & {
pendingConfirmation: PendingUiConfirmation | null;
pendingCommand: PendingCommand | null;
projectPath: string;
/** 本回合(含历史回读)的工具调用卡片,按 `startedAt` 升序,同一 id 只会出现一次。 */
toolCalls?: GameCreatorDirectToolCall[];
/** 当前正在跑的回合 id;卡片在 assistant 消息落盘前锚到它。 */
activeTurnId?: string | null;
showProfessionalCollaboration?: boolean;
transientReply: string;
designReasoning?: string;
@@ -161,6 +172,8 @@ export function ProjectSupervisorView({
pendingConfirmation,
pendingCommand,
projectPath,
toolCalls = [],
activeTurnId = null,
showProfessionalCollaboration = true,
transientReply,
designReasoning = '',
@@ -214,6 +227,37 @@ 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;
const toolCallsByAnchor = new Map<string, 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) {
continue;
}
const bucket = toolCallsByAnchor.get(anchor);
if (bucket) {
bucket.push(call);
} else {
toolCallsByAnchor.set(anchor, [call]);
}
}
}
const emptyState =
directCodex &&
visibleMessages.length === 0 &&
@@ -320,17 +364,28 @@ export function ProjectSupervisorView({
{`显示更早 · 还有 ${hiddenConversationCount} 条对话`}
</button>
) : null}
{visibleMessages.map((message, index) => (
<div
key={message.messageId ?? `${message.role}-${index}`}
className={`message message--${message.role}`}
>
<ChatMarkdownMessage
role={message.role}
text={projectSupervisorChatMessageText(message)}
/>
</div>
))}
{visibleMessages.map((message, index) => {
const anchoredToolCalls = message.messageId
? (toolCallsByAnchor.get(message.messageId) ?? [])
: [];
return (
<Fragment key={message.messageId ?? `${message.role}-${index}`}>
<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>
);
})}
{designReasoning ? (
<details className="design-agent-reasoning">
<summary></summary>
@@ -0,0 +1,117 @@
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} />;
}
}
+161
View File
@@ -11239,3 +11239,164 @@ button.design-workspace-tree__entry:hover,
min-height: 0;
flex: 1 1 auto;
}
/* ============================================================
工具调用卡片(2026-09):Codex 风格可折叠卡片
============================================================
契约见 `docs/technical/【技术方案】GameAgent对话工具调用卡片-2026-09-14.md`
折叠态一行摘要(图标 / 标题 / 摘要 / 状态点 / 状态 / 折角),展开态看命令、文件明细与输出。
折叠由 `<button aria-expanded>` + `hidden` 控制(不改消息气泡与列表滚动模型),
配色只用现有 `--platform-*` 变量。 */
/* 卡片不进气泡:它是消息流里跟在 assistant 消息之后的一个块,左右与消息对齐。 */
.game-workbench-chat
.project-supervisor-surface.is-direct-codex
.project-supervisor-message-list
> .agent-tool-call {
display: grid;
width: 100%;
min-width: 0;
margin-top: -4px;
}
.agent-tool-call {
display: grid;
width: 100%;
min-width: 0;
margin-top: 10px;
overflow: hidden;
border: 1px solid var(--platform-surface-border);
border-radius: 12px;
background: var(--platform-button-secondary-fill);
color: var(--platform-text-base);
}
.agent-tool-call-head {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
min-width: 0;
padding: 8px 10px;
border: 0;
background: transparent;
color: inherit;
font: inherit;
text-align: left;
cursor: pointer;
}
.agent-tool-call-head:hover,
.agent-tool-call-head:focus-visible {
background: var(--platform-button-ghost-fill);
color: var(--platform-text-strong);
outline: none;
}
.agent-tool-call-icon {
display: inline-flex;
align-items: center;
justify-content: center;
flex: 0 0 auto;
color: var(--platform-text-soft);
}
.agent-tool-call-title {
flex: 0 0 auto;
color: var(--platform-text-strong);
font-size: 12px;
font-weight: 600;
}
/* 摘要压在标题后面:窄面板下靠 ellipsis 收窄,不换行、不把状态挤出可视区。 */
.agent-tool-call-summary {
flex: 1 1 auto;
min-width: 0;
overflow: hidden;
color: var(--platform-text-soft);
font-size: 12px;
text-overflow: ellipsis;
white-space: nowrap;
}
.agent-tool-call-status {
flex: 0 0 auto;
color: var(--platform-text-soft);
font-size: 11px;
}
.agent-tool-call-running-dot {
width: 6px;
height: 6px;
flex: 0 0 6px;
border-radius: 50%;
background: var(--platform-accent);
}
.agent-tool-call-chevron {
flex: 0 0 auto;
color: var(--platform-text-soft);
transition: transform 120ms ease;
}
.agent-tool-call-head[aria-expanded='true'] .agent-tool-call-chevron {
transform: rotate(180deg);
}
/* `failed` 用错误色语义变量:标题、状态文案与图标一起变,不新增色值。 */
.agent-tool-call[data-status='failed'] .agent-tool-call-title,
.agent-tool-call[data-status='failed'] .agent-tool-call-status,
.agent-tool-call[data-status='failed'] .agent-tool-call-icon {
color: var(--platform-button-danger-text);
}
.agent-tool-call[data-status='failed'] {
border-color: var(--platform-button-danger-border);
background: var(--platform-button-danger-fill);
}
.agent-tool-call-body {
display: grid;
gap: 8px;
padding: 0 10px 10px;
}
.agent-tool-call-command,
.agent-tool-call-output {
margin: 0;
max-height: 220px;
padding: 8px 9px;
overflow: auto;
border: 1px solid var(--platform-line-soft);
border-radius: 8px;
background: var(--platform-neutral-bg);
color: var(--platform-text-base);
font-size: 11px;
line-height: 1.6;
white-space: pre-wrap;
overflow-wrap: anywhere;
}
.agent-tool-call-changes {
display: grid;
gap: 4px;
margin: 0;
padding: 0;
list-style: none;
}
.agent-tool-call-changes li {
display: flex;
align-items: baseline;
gap: 6px;
min-width: 0;
color: var(--platform-text-base);
font-size: 12px;
overflow-wrap: anywhere;
}
.agent-tool-call-changes li small {
flex: 0 0 auto;
color: var(--platform-text-soft);
font-size: 10px;
}
@@ -8031,6 +8031,372 @@ export function registerProjectSupervisorSurfaceTests() {
).toHaveLength(policyReadCountBeforeChat + 1);
});
it('renders tool-call cards from the direct turn event and keeps them after the turn completes', async () => {
const projectPath = '/tmp/launcher-tool-call-card-game';
const manifest = createGameCreationAppManifest(
'local-project-draft',
'launcher-tool-call-card-game',
);
const supervisorHarness = createProjectSupervisorRuntimeHarness({
projectPath,
});
let directTurnUpdateHandler:
| ((event: { payload: Record<string, unknown> }) => void)
| null = null;
const listen = vi.fn(
async (
eventName: string,
handler: (event: { payload: Record<string, unknown> }) => void,
) => {
if (eventName === 'game-creator-direct-turn-update') {
directTurnUpdateHandler = handler;
return () => {
if (directTurnUpdateHandler === handler) {
directTurnUpdateHandler = null;
}
};
}
return supervisorHarness.listen(
eventName,
handler as Parameters<typeof supervisorHarness.listen>[1],
);
},
);
const directReply = createDeferred<string>();
const invoke = vi.fn(
async (command: string, args?: Record<string, unknown>) => {
if (command === 'get_design_agent_runtime_mode') return null;
if (command === 'inspect_local_project_directory') {
return {
projectPath,
exists: true,
isDirectory: true,
isGameCreatorProject: true,
projectName: 'launcher-tool-call-card-game',
recentRunStatus: null,
recentRunStopReason: null,
};
}
if (command === 'get_local_game_manifest') {
return manifest;
}
if (command === 'chat_with_game_creator_direct_codex') {
return directReply.promise;
}
if (command === 'read_direct_tool_calls') {
return [];
}
if (command === 'read_direct_project_conversation') {
// 打开项目时有一轮已落盘的对话:工具调用卡要挂在它的 assistant 消息之后。
return {
path: `${projectPath}/.agent/conversations/project.jsonl`,
agentId: null,
sessionId: null,
messages: [
{
schemaVersion: 'agc-direct-project-context.v1',
role: 'user',
content: '做一个跑酷游戏',
agentId: null,
messageId: 'direct-codex:turn-existing:user',
updatedAt: 900,
},
{
schemaVersion: 'agc-direct-project-context.v1',
role: 'assistant',
content: '上一轮已完成',
agentId: null,
messageId: 'direct-codex:turn-existing:assistant',
updatedAt: 1000,
},
],
};
}
if (command === 'get_local_game_preview_status') {
return { status: 'stopped', url: null, port: null, root: null };
}
return supervisorHarness.invoke(command, args);
},
);
window.__TAURI__ = {
core: { invoke },
event: { listen },
};
renderLauncherProjectsAt('/?launcher');
pickProjectFromLauncher(projectPath);
const supervisorSurface = await screen.findByLabelText('陶泥儿项目对话');
const messageList = supervisorSurface.querySelector(
'.project-supervisor-message-list',
);
expect(messageList).not.toBeNull();
await setComposerText(
screen.getByLabelText('陶泥儿对话内容'),
'做一个跑酷游戏',
);
fireEvent.submit(
screen
.getByLabelText('陶泥儿对话内容')
.closest('form') as HTMLFormElement,
);
await waitFor(() => {
expect(invoke).toHaveBeenCalledWith(
'chat_with_game_creator_direct_codex',
expect.objectContaining({ projectPath }),
);
});
const clientTurnId = String(
(
invoke.mock.calls.find(
([command]) => command === 'chat_with_game_creator_direct_codex',
)?.[1] as Record<string, unknown> | undefined
)?.clientTurnId ?? '',
);
expect(clientTurnId).not.toBe('');
// 实时增量:同一条 `item-command` 从 running 走到 completed`item-file` 由下一条事件带出。
await act(async () => {
directTurnUpdateHandler?.({
payload: {
projectPath,
turnId: clientTurnId,
sequence: 1,
status: 'running',
activity: 'command-exec',
updatedAt: 1000,
toolCalls: [
{
schemaVersion: 'agc-tool-call.v1',
id: 'item-command',
kind: 'command',
title: '执行命令',
summary: 'npm run build',
status: 'running',
detail: { command: 'npm run build' },
startedAt: 1000,
updatedAt: 1000,
},
],
},
});
});
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
// 会在 App 侧被过滤掉,不会漂在消息流里。
const visibleKinds = within(supervisorSurface)
.getAllByTestId('agent-tool-call-card')
.map((card) => card.getAttribute('data-kind'));
expect(visibleKinds).toEqual(['command']);
await act(async () => {
directTurnUpdateHandler?.({
payload: {
projectPath,
turnId: clientTurnId,
sequence: 2,
status: 'running',
activity: 'file-write',
updatedAt: 1100,
toolCalls: [
{
schemaVersion: 'agc-tool-call.v1',
id: 'item-command',
kind: 'command',
title: '执行命令',
summary: 'npm run build',
status: 'completed',
detail: { command: 'npm run build', output: 'build ok' },
startedAt: 1000,
updatedAt: 1100,
},
{
schemaVersion: 'agc-tool-call.v1',
id: 'item-file',
kind: 'file_change',
title: '编辑 1 个文件',
summary: 'game/src/hero.ts',
status: 'failed',
detail: {
changes: [
{ path: 'game/src/hero.ts', kind: 'update' },
{ path: 'game/src/hero.ts', kind: 'delete' },
],
},
startedAt: 1050,
updatedAt: 1100,
},
],
},
});
});
await waitFor(() => {
expect(
within(supervisorSurface).getAllByTestId('agent-tool-call-card'),
).toHaveLength(2);
});
const cards = within(supervisorSurface).getAllByTestId(
'agent-tool-call-card',
);
// 同一 id 只渲染一次,状态由 completed 覆盖;同回合内按 startedAt 升序。
expect(cards.map((card) => card.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);
// 回合结束:assistant 消息落盘后卡片仍在该回合消息之后,不重复、不消失。
await act(async () => {
directReply.resolve('DIRECT_REPLY:做一个跑酷游戏');
});
await waitFor(() => {
expect(
within(supervisorSurface).getByText('DIRECT_REPLY:做一个跑酷游戏'),
).not.toBeNull();
});
await waitFor(() => {
expect(
within(supervisorSurface).getAllByTestId('agent-tool-call-card'),
).toHaveLength(2);
});
// 重新取一次:回合结束会重渲染,之前抓到的引用已经不是当前 DOM 节点。
const settledCards = within(supervisorSurface).getAllByTestId(
'agent-tool-call-card',
);
const children = Array.from((messageList as HTMLElement).children);
const assistantIndex = children.findIndex(
(node) =>
node.classList.contains('message--assistant') &&
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')}`,
);
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(
within(secondBody as HTMLElement).getAllByText('game/src/hero.ts'),
).toHaveLength(2);
expect(within(secondBody as HTMLElement).getByText('修改')).not.toBeNull();
expect(within(secondBody as HTMLElement).getByText('删除')).not.toBeNull();
});
it('reads the persisted tool-call history whenever a project is opened', async () => {
const projectPath = '/tmp/launcher-tool-call-persisted-game';
const manifest = createGameCreationAppManifest(
'local-project-draft',
'launcher-tool-call-persisted-game',
);
const supervisorHarness = createProjectSupervisorRuntimeHarness({
projectPath,
});
let readToolCallCount = 0;
const invoke = vi.fn(
async (command: string, args?: Record<string, unknown>) => {
if (command === 'get_design_agent_runtime_mode') return null;
if (command === 'inspect_local_project_directory') {
return {
projectPath,
exists: true,
isDirectory: true,
isGameCreatorProject: true,
projectName: 'launcher-tool-call-persisted-game',
recentRunStatus: null,
recentRunStopReason: null,
};
}
if (command === 'get_local_game_manifest') {
return manifest;
}
if (command === 'read_direct_project_conversation') {
return {
path: projectPath,
agentId: null,
sessionId: null,
messages: [],
};
}
if (command === 'read_direct_tool_calls') {
readToolCallCount += 1;
return [];
}
if (command === 'get_local_game_preview_status') {
return { status: 'stopped', url: null, port: null, root: null };
}
return supervisorHarness.invoke(command, args);
},
);
window.__TAURI__ = {
core: { invoke },
event: { listen: supervisorHarness.listen },
};
renderLauncherProjectsAt('/?launcher');
pickProjectFromLauncher(projectPath);
const supervisorSurface = await screen.findByLabelText('陶泥儿项目对话');
// 打开项目时必须回读一次工具调用历史:这是「刷新后卡片仍在」的数据来源。
await waitFor(() => {
expect(readToolCallCount).toBeGreaterThanOrEqual(1);
});
expect(invoke).toHaveBeenCalledWith('read_direct_tool_calls', {
projectPath,
});
// 历史为空时与改造前一致:没有卡片,也不报错。
expect(
within(supervisorSurface).queryAllByTestId('agent-tool-call-card'),
).toHaveLength(0);
});
it('renders the Codex empty state and opens the panel settings overlay in a fresh direct chat', async () => {
// 空态只在 direct-codex 面板、且没有任何消息/流式回复/待确认时出现。真实项目打开时
// App 总会先放一条默认问候(`createDefaultChatMessages`),所以这里直接挂面板本体,