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:
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
+67
-12
@@ -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} />;
|
||||
}
|
||||
}
|
||||
@@ -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`),所以这里直接挂面板本体,
|
||||
|
||||
Reference in New Issue
Block a user