From 33ddb5de2788a89733b53a52b6a7d80550554395 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Wed, 16 Sep 2026 22:16:15 +0800 Subject: [PATCH] =?UTF-8?q?DirectProject=20=E8=81=8A=E5=A4=A9=E6=A1=86?= =?UTF-8?q?=E6=94=B9=E7=94=A8=E7=BA=BF=E7=A8=8B=E8=AE=A2=E9=98=85=E4=BD=9C?= =?UTF-8?q?=E4=B8=BA=E5=94=AF=E4=B8=80=E8=BF=90=E8=A1=8C=E6=80=81=E6=9D=A5?= =?UTF-8?q?=E6=BA=90=20-=20App.tsx=20=E5=88=A0=E9=99=A4=20Direct=20?= =?UTF-8?q?=E5=9B=9E=E5=90=88=E4=BA=8B=E4=BB=B6=E8=AE=A2=E9=98=85=E3=80=81?= =?UTF-8?q?turn-stream/tool-calls=20=E8=AF=BB=E5=8F=96=E3=80=81=E6=B4=BB?= =?UTF-8?q?=E5=8A=A8=E5=9B=9E=E5=90=88=E5=BF=AB=E7=85=A7=E6=8E=A5=E7=AE=A1?= =?UTF-8?q?=E4=B8=8E=E7=9E=AC=E6=97=B6=E5=BA=94=E7=AD=94=E6=96=87=E6=9C=AC?= =?UTF-8?q?=20-=20=E6=96=B0=E5=A2=9E=20game-creator-direct-thread-notify?= =?UTF-8?q?=20=E5=94=A4=E9=86=92=E7=9A=84=20subscribe/consume=20=E5=8D=95?= =?UTF-8?q?=E9=A3=9E=E5=BE=AA=E7=8E=AF=EF=BC=8C=E8=BF=87=E6=9C=9F=E6=97=B6?= =?UTF-8?q?=E9=87=8D=E8=AE=A2=E4=B8=80=E6=AC=A1=20-=20=E9=80=9A=E7=9F=A5?= =?UTF-8?q?=E5=85=88=E4=BA=8E=E8=AE=A2=E9=98=85=E5=9B=9E=E6=89=A7=E5=88=B0?= =?UTF-8?q?=E8=BE=BE=E6=97=B6=E8=AE=B0=E4=B8=80=E7=AC=94=E6=AC=A0=E8=B4=A6?= =?UTF-8?q?=EF=BC=8C=E5=9B=9E=E6=89=A7=E5=88=B0=E8=BE=BE=E5=90=8E=E8=A1=A5?= =?UTF-8?q?=E4=B8=80=E6=AC=A1=20consume=EF=BC=8C=E9=81=BF=E5=85=8D?= =?UTF-8?q?=E5=9B=9E=E5=90=88=E5=B0=BE=E9=83=A8=E4=BA=8B=E4=BB=B6=E5=8D=A1?= =?UTF-8?q?=E5=9C=A8=E9=98=9F=E5=88=97=E9=87=8C=20-=20=E5=8E=86=E5=8F=B2?= =?UTF-8?q?=E5=88=87=E7=89=87=E5=B9=B6=E5=85=A5=E5=90=8C=E4=B8=80=E4=B8=AA?= =?UTF-8?q?=20reducer=EF=BC=8C=E8=81=8A=E5=A4=A9=E6=9D=A1=E7=9B=AE?= =?UTF-8?q?=E5=8F=AA=E7=94=B1=20selectDirectChatEntries=20=E6=8A=95?= =?UTF-8?q?=E5=BD=B1=20-=20ProjectSupervisorView=20=E6=94=B9=E4=B8=BA?= =?UTF-8?q?=E6=B8=B2=E6=9F=93=E7=94=A8=E6=88=B7=E6=B0=94=E6=B3=A1=20/=20?= =?UTF-8?q?=E6=89=A7=E8=A1=8C=E8=BF=87=E7=A8=8B=E6=8A=98=E5=8F=A0=E5=8C=BA?= =?UTF-8?q?=20/=20=E6=9C=80=E7=BB=88=E5=9B=9E=E5=A4=8D=EF=BC=8C=E8=BF=90?= =?UTF-8?q?=E8=A1=8C=E6=80=81=E5=8F=AA=E4=BF=9D=E7=95=99=E4=B8=80=E4=B8=AA?= =?UTF-8?q?=20turnRunning=20-=20directTurnPresentation=20=E9=87=8D?= =?UTF-8?q?=E5=86=99=E4=B8=BA=E6=9D=A1=E7=9B=AE=E5=88=86=E5=8C=BA=EF=BC=9A?= =?UTF-8?q?=E8=BF=9E=E7=BB=AD=E5=B7=A5=E5=85=B7=E5=90=88=E5=9D=97=E3=80=81?= =?UTF-8?q?=E6=9C=AC=E5=9C=B0=E8=BF=90=E8=A1=8C=E6=9C=9F=E8=AF=B4=E6=98=8E?= =?UTF-8?q?=E5=8F=AA=E4=BD=9C=E6=9C=80=E7=BB=88=E6=8F=90=E7=A4=BA=20-=20?= =?UTF-8?q?=E6=B5=8B=E8=AF=95=E6=94=B9=E6=8C=89=E8=AE=A2=E9=98=85=E4=BA=8B?= =?UTF-8?q?=E4=BB=B6=E4=B8=8E=E5=8E=86=E5=8F=B2=E6=9D=A1=E7=9B=AE=E6=96=AD?= =?UTF-8?q?=E8=A8=80=EF=BC=8C=E8=A1=A5=E5=9B=9E=E6=89=A7=E7=AB=9E=E6=80=81?= =?UTF-8?q?=E3=80=81=E6=8C=81=E4=B9=85=E5=8C=96=E5=B7=A5=E5=85=B7=E5=8D=A1?= =?UTF-8?q?=E7=89=87=E3=80=81=E7=A9=BA=E5=AF=B9=E8=AF=9D=E9=A6=96=E8=BD=AE?= =?UTF-8?q?=E4=B8=89=E6=9D=A1=E7=94=A8=E4=BE=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/ai-game-creator-shell/src/App.tsx | 888 ++------------ .../ProjectSupervisorView.tsx | 439 ++----- .../project-workspace/directThreadEvents.ts | 41 - .../directThreadItemProjection.ts | 3 +- .../directTurnPresentation.ts | 499 ++++---- .../tests/appSurface/chat-composer.suite.ts | 12 +- .../tests/appSurface/harness.ts | 104 ++ .../tests/appSurface/home.suite.ts | 93 +- .../appSurface/project-development.suite.ts | 1025 ++++++----------- .../tests/directThreadEvents.test.ts | 48 - .../tests/directTurnPresentation.test.ts | 410 ++++--- 11 files changed, 1194 insertions(+), 2368 deletions(-) delete mode 100644 apps/ai-game-creator-shell/tests/directThreadEvents.test.ts diff --git a/apps/ai-game-creator-shell/src/App.tsx b/apps/ai-game-creator-shell/src/App.tsx index 5fc8d07e0..7cdff8576 100644 --- a/apps/ai-game-creator-shell/src/App.tsx +++ b/apps/ai-game-creator-shell/src/App.tsx @@ -58,9 +58,6 @@ import type { DirectTurnCancelView, GameCreatorAgentRuntimeUpdateEvent, GameCreatorChatAgentReply, - GameCreatorDirectActiveTurn, - GameCreatorDirectToolCall, - GameCreatorDirectTurnUpdateEvent, GameCreatorLlmConfigStatus, GameCreatorManifestInvalidatedEvent, GameCreatorRoleAgentChatStreamEvent, @@ -99,7 +96,6 @@ import type { ProjectPermissionPolicyView, SyncCanvasProjectAssetsResult, TauriInvoke, - TurnStreamItem, UploadLocalAssetResult, } from './app/types'; import { useWindowChrome } from './components/windowChromeContext'; @@ -136,7 +132,6 @@ import { submitProjectSupervisorRuntimeTask, taskRowsFromManifest, } from './features/agent-runtime'; -import { DIRECT_ACTIVE_TURNS_POLL_INTERVAL_MS } from './features/agent-runtime/directActiveTurns'; import { type DirectCodexTurnAttachment, toDirectCodexTurnAttachments, @@ -237,14 +232,19 @@ import { } from './features/project-workspace/chatComposerQueue'; import { DeveloperProjectPanels } from './features/project-workspace/DeveloperProjectPanels'; import { DeveloperRuntimePanels } from './features/project-workspace/DeveloperRuntimePanels'; +import { + applyDirectThreadConsumeResult, + type DirectThreadChatState, + emptyDirectThreadChatState, + mergeDirectThreadHistorySlice, + resolveDirectThreadBootstrap, + selectDirectChatEntries, +} from './features/project-workspace/directThreadChat'; import { type DirectThreadConsumeResult, - directThreadHistoryItemsToMessages, type DirectThreadHistorySlice, type DirectThreadSubscriptionBootstrap, - isDirectTurnInProgress, } from './features/project-workspace/directThreadEvents'; -import { normalizeDirectTimestamp } from './features/project-workspace/directTurnPresentation'; import type { DirectCodexUserContentPart } from './features/project-workspace/generated'; import { appendMemoryContent, @@ -314,13 +314,6 @@ const DIRECT_CODEX_TURN_ALREADY_RUNNING_ERROR_PREFIX = /** 与 Rust 侧 `DirectTaonierActiveInvocationGuard::enter` 的 else 分支文案保持一致。 */ const DIRECT_CODEX_ANOTHER_TURN_RUNNING_ERROR_MARKER = '当前项目已有另一条 Direct 客户端回合正在运行'; -/** - * 恢复出来的回合多久没有任何事件就算"没响应"。Rust 守卫是进程内的:重进会话时它还在, - * 但 app-server 侧可能早就没了。这时界面必须给出明确动作,而不是让用户一直等。 - */ -const DIRECT_CODEX_RECOVERED_TURN_STALLED_MS = 15_000; -const DIRECT_CODEX_RECOVERED_TURN_STALLED_NOTICE = - '该回合已无响应,可在输入盒点「终止」结束它以继续'; // Platform access tokens are short lived. DirectProject can spend several // minutes in image generation, build and browser validation, so keep the // client-owned native session current while a turn is running. The singleflight @@ -356,136 +349,6 @@ async function withDirectCodexSessionRefresh(operation: () => Promise) { } } -const DIRECT_CODEX_TURN_UPDATE_STATUSES = new Set([ - 'accepted', - 'running', - 'streaming', - 'finalizing', - 'completed', - 'failed', -]); - -function ensureDirectProcessPrefix(text: string) { - const trimmed = text.trim(); - if (!trimmed) { - return ''; - } - if (trimmed.startsWith('正在')) { - return trimmed; - } - if (/^(?:执行|调用|读取|写入|验证|搜索|整理|修改|生成)/u.test(trimmed)) { - return `正在${trimmed}`; - } - return `正在处理:${trimmed}`; -} - -function directCodexActivityDetail( - activity: string | null | undefined, - status: string | null | undefined, -) { - switch (activity) { - case 'request-accepted': - return '正在等待陶泥儿开始'; - case 'preparing': - return '正在思考中'; - case 'file-read': - return '正在读取文件'; - case 'file-write': - return status === 'finalizing' ? '正在同步项目文件' : '正在写入文件'; - case 'game-verify': - return '正在验证游戏'; - case 'command-exec': - return '正在执行命令'; - case 'controlled-tool': - return '正在调用工具'; - case 'web-search': - return '正在搜索资料'; - case 'context-compaction': - return '正在整理上下文'; - case 'response-finalization': - return '正在整理回复'; - case 'none': - default: - switch (status) { - case 'accepted': - return '正在等待陶泥儿开始'; - case 'finalizing': - return '正在整理结果'; - case 'completed': - case 'failed': - return ''; - default: - return '正在处理任务'; - } - } -} - -const DIRECT_CODEX_SPECIFIC_WORK_DETAIL_PREFIXES = [ - '正在写入文件:', - '正在浏览项目文件', - '正在读取素材库', - '正在读取账户素材', - '正在导入素材', - '正在生成图片', - '正在编辑图片', - '正在准备美术素材', - '正在创建素材资源', - '正在去除图片背景', - '正在试玩游戏', - '正在搜索资料:', - '正在执行命令:', - '正在验证游戏:', -] as const; - -function directCodexProcessDetail({ - accumulatedText, - activity, - status, -}: { - accumulatedText?: string | null; - activity?: string | null; - status: string; -}) { - if (status === 'completed' || status === 'failed') { - return ''; - } - if (status === 'streaming') { - return '正在生成回复'; - } - if (status === 'finalizing') { - return directCodexActivityDetail(activity, status); - } - const text = accumulatedText?.trim(); - if (text) { - return ensureDirectProcessPrefix(text); - } - return directCodexActivityDetail(activity, status); -} - -function isDirectCodexSpecificWorkDetail(text: string) { - return DIRECT_CODEX_SPECIFIC_WORK_DETAIL_PREFIXES.some((prefix) => - text.startsWith(prefix), - ); -} - -function directCodexTransientReplyText({ - accumulatedText, - status, -}: { - accumulatedText?: string | null; - status: string; -}) { - if ( - status !== 'streaming' && - status !== 'finalizing' && - status !== 'completed' - ) { - return null; - } - const text = accumulatedText?.trim(); - return text || null; -} - function directCodexConversationMessageId( turnId: string, role: ChatMessage['role'], @@ -495,70 +358,6 @@ function directCodexConversationMessageId( export const MAX_CHAT_COMPOSER_ATTACHMENTS = 8; -/** - * 回合流排序:`seq`(条目首次出现时钉死)优先,其次 `at`,最后按 id 兜底。 - * 与 Rust 侧同一口径——前端不自己发明顺序。 - */ -function sortTurnStreamItems(items: readonly TurnStreamItem[]) { - return [...items].sort( - (left, right) => - left.seq - right.seq || - left.at - right.at || - left.id.localeCompare(right.id), - ); -} - -/** - * 归并一批回合流条目:同 id 幂等覆盖(`updatedAt` 单调,同刻取更长文本),新 id 追加。 - * 实时增量与回读历史共用这一处,所以界面上的顺序只有一份来源。 - */ -function mergeTurnStreamItems( - existing: readonly TurnStreamItem[], - incoming: readonly TurnStreamItem[], -): TurnStreamItem[] { - if (incoming.length === 0) { - return [...existing]; - } - const byId = new Map(); - for (const item of existing) { - const id = item.id?.trim(); - if (id) { - byId.set(id, item); - } - } - for (const item of incoming) { - const id = item.id?.trim(); - if (!id) { - continue; - } - const previous = byId.get(id); - const normalized: TurnStreamItem = { ...item, id }; - if (!previous) { - byId.set(id, normalized); - continue; - } - // 内容只在更新(或同刻更长)的快照上替换;`seq` 取最早,位置不许回退。 - const textLength = (value: TurnStreamItem) => - value.kind === 'text' ? (value.text?.length ?? 0) : 0; - // writer 更新时间单调;完成快照允许纠正正文,迟到旧快照不能覆盖。 - const takeIncoming = - normalized.updatedAt > previous.updatedAt || - (normalized.updatedAt === previous.updatedAt && - textLength(normalized) > textLength(previous)); - byId.set(id, { - ...(takeIncoming ? normalized : previous), - id, - updatedAt: Math.max(previous.updatedAt, normalized.updatedAt), - seq: Math.min(previous.seq, normalized.seq), - at: - previous.at > 0 && normalized.at > 0 - ? Math.min(previous.at, normalized.at) - : Math.max(previous.at, normalized.at), - } as TurnStreamItem); - } - return sortTurnStreamItems([...byId.values()]); -} - export function isDirectCodexTurnAlreadyRunningError(error: unknown) { const message = error instanceof Error ? error.message : String(error); return message @@ -796,7 +595,6 @@ export function App({ useEffect(() => { if (supervisorChatOnly) return; const nextProjectPath = localProject?.projectPath ?? null; - ensureDirectTimelineProject(nextProjectPath); const previousProjectPath = localProjectPathRef.current; localProjectPathRef.current = nextProjectPath; // 未绑定项目时无需触发插件宿主;这也避免启动空首页时产生无意义的 Tauri 调用。 @@ -931,125 +729,20 @@ export function App({ chatTurnQueueRef.current = []; }, [localProject?.projectPath]); const [chatAgentBusy, setChatAgentBusy] = useState(false); - const [directCodexProgress, setDirectCodexProgress] = useState(''); - const [directCodexStatus, setDirectCodexStatus] = useState< - GameCreatorDirectTurnUpdateEvent['status'] | null - >(null); - const [directCodexProcessKey, setDirectCodexProcessKey] = useState(''); - const [directCodexProgressUpdatedAt, setDirectCodexProgressUpdatedAt] = - useState(null); - const [directCodexTransientReply, setDirectCodexTransientReply] = - useState(''); - const directCodexTransientReplyRef = useRef(''); - // 直连回合的思考过程(流式):整段替换;回合结束/开始新回合/清空对话时一并清掉。 - const [directCodexTransientReasoning, setDirectCodexTransientReasoning] = - useState(''); - /** 实时回合里"某个工具首次出现时,已生成正文的长度"——用它把正文与工具交替排列。 */ - const [ - directCodexTransientReplyUpdatedAt, - setDirectCodexTransientReplyUpdatedAt, - ] = useState(null); - const activeDirectCodexTurnRef = useRef<{ - projectPath: string; - turnId: string; - lastSequence: number; - receivedDirectUpdate: boolean; - restored?: boolean; - } | null>(null); - const directActiveSnapshotVersionRef = useRef(0); - const directTurnLifecycleRef = useRef<{ - reset: () => void; - loadHistory: (projectPath: string) => Promise; - restore: (projectPath: string) => Promise; - } | null>(null); - const lastDirectCodexActivityRef = useRef(null); - /** - * 重进会话后从 Rust 恢复出来的回合:只有在恢复后的第一个窗口内一直收不到事件, - * 才判定"这一轮其实已经没响应",给出终止出口。收到任何一条本回合事件就撤掉。 - */ - const recoveredDirectCodexTurnRef = useRef<{ - projectPath: string; - turnId: string; - } | null>(null); - const recoveredDirectCodexTurnTimerRef = useRef(null); const directCodexConversationTurnSequenceRef = useRef(0); - // 工具调用卡片:按 **id** 归并(实时增量 + 回读历史共用一份),同一 id 只渲染一次。 - // 用 ref 做写入基准,避免同一批事件里多条增量互相覆盖。 - const [directToolCalls, setDirectToolCalls] = useState< - GameCreatorDirectToolCall[] - >([]); - const directToolCallsRef = useRef([]); - const directTimelineProjectPathRef = useRef(null); - - function ensureDirectTimelineProject(project: string | null) { - if (directTimelineProjectPathRef.current === project) return; - directTimelineProjectPathRef.current = project; - directToolCallsRef.current = []; - directTurnStreamRef.current = []; - setDirectToolCalls([]); - setDirectTurnStream([]); - } /** - * 归并一批工具调用:同 id 覆盖已有条目(`completed` 覆盖 `running`), - * 新 id 追加(保持首次出现顺序)。实时增量与回读历史都走这里,所以同一 id 不会重复渲染。 + * DirectProject 聊天真相源:历史切片与运行态事件归并成同一份条目。 + * + * 运行态只来自 Thread Manager(`subscribe` 的 bootstrap + `notify` 唤醒的 `consume`); + * 前端不再订阅 DirectRuntime 的回合进度事件,也不再各存一份回合流 / 工具卡片。 */ - 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 && existing.turnId === call.turnId, - ); - const normalized: GameCreatorDirectToolCall = { - ...call, - id, - startedAt: normalizeDirectTimestamp(call.startedAt), - updatedAt: normalizeDirectTimestamp(call.updatedAt), - detail: call.detail ?? { changes: [] }, - }; - // 起点时间取更早的那个:`completed` 事件不一定带 startedAt。 - const existing = existingIndex >= 0 ? merged[existingIndex] : undefined; - if (existing) { - if ( - existing.updatedAt > normalized.updatedAt || - (existing.status !== 'running' && normalized.status === 'running') - ) - continue; - normalized.detail = { - ...normalized.detail, - command: normalized.detail.command ?? existing.detail.command, - output: normalized.detail.output ?? existing.detail.output, - }; - } - 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 [directThreadChat, setDirectThreadChat] = + useState(() => emptyDirectThreadChatState()); + /** 最新回合是否在跑:历史里留下的半截回合一律按已结束渲染。 */ + const directTurnRunning = directThreadChat.turnRunning; + /** 界面忙碌判定:本地正在跑这次 invoke,或订阅告诉还有一条回合没结束。 */ + const supervisorChatBusy = + chatAgentBusy || (directCodexProductRuntime && directTurnRunning); const [projectSupervisorSessionId, setProjectSupervisorSessionId] = useState< string | null >(null); @@ -1069,193 +762,11 @@ export function App({ return `${Date.now().toString(36)}-${directCodexConversationTurnSequenceRef.current.toString(36)}`; } - function resetDirectCodexTurn() { - directActiveSnapshotVersionRef.current += 1; - clearRecoveredDirectCodexTurnWatch(); - activeDirectCodexTurnRef.current = null; - lastDirectCodexActivityRef.current = null; - setDirectCodexProgress(''); - setDirectCodexStatus(null); - setDirectCodexProcessKey(''); - setDirectCodexProgressUpdatedAt(null); - setDirectCodexTransientReply(''); - setDirectCodexTransientReasoning(''); - directCodexTransientReplyRef.current = ''; - setDirectCodexTransientReplyUpdatedAt(null); + /** 换项目 / 清空对话:聊天条目回到初始态(运行态与已加载历史一起清掉)。 */ + function resetDirectThreadChat() { + setDirectThreadChat(emptyDirectThreadChatState()); } - /** 撤掉"恢复出来的回合没响应"的看门狗;回合正常结束、被终止、或收到事件时都要撤。 */ - function clearRecoveredDirectCodexTurnWatch() { - if (recoveredDirectCodexTurnTimerRef.current !== null) { - window.clearTimeout(recoveredDirectCodexTurnTimerRef.current); - recoveredDirectCodexTurnTimerRef.current = null; - } - recoveredDirectCodexTurnRef.current = null; - } - - /** - * 给恢复出来的回合挂一个看门狗:一个窗口内没有任何本回合事件,就说明 app-server 侧 - * 其实已经没了、Rust 守卫是残留。这时把可读动作放到过程卡与输入盒提示上,用户点 - * 「终止」会走 `cancel_direct_codex_turn` 的兜底释放(见 handleCancelDirectCodexTurn)。 - * 收到任何一条本回合事件就由调用方撤掉它,绝不会覆盖真实的进度文案。 - */ - function watchRecoveredDirectCodexTurn(projectPath: string, turnId: string) { - clearRecoveredDirectCodexTurnWatch(); - recoveredDirectCodexTurnRef.current = { projectPath, turnId }; - recoveredDirectCodexTurnTimerRef.current = window.setTimeout(() => { - recoveredDirectCodexTurnTimerRef.current = null; - const watch = recoveredDirectCodexTurnRef.current; - const activeTurn = activeDirectCodexTurnRef.current; - if ( - !watch || - watch.projectPath !== projectPath || - watch.turnId !== turnId || - activeTurn?.projectPath !== projectPath || - activeTurn.turnId !== turnId || - activeTurn.receivedDirectUpdate - ) { - return; - } - setDirectCodexStatus('running'); - setDirectCodexProgress(DIRECT_CODEX_RECOVERED_TURN_STALLED_NOTICE); - setDirectCodexProgressUpdatedAt(Date.now()); - setChatComposerNotice(DIRECT_CODEX_RECOVERED_TURN_STALLED_NOTICE); - }, DIRECT_CODEX_RECOVERED_TURN_STALLED_MS); - } - - /** - * 重进会话时接管仍在运行的 Direct 回合。 - * - * 背景:活跃回合的守卫(`DirectTaonierActiveInvocationGuard`)是 Rust 进程内的,重开 - * 项目时前端 `activeDirectCodexTurnRef` 是空的——界面既不订阅这一轮的事件,也不显示 - * 过程卡,用户再发消息只会被守卫拒绝。这里把后端登记的回合读回来重新接管。 - * - * 只读探测,不改后端回合本身;探测失败保留当前已知状态,不视为没有活动回合。 - */ - async function restoreRunningDirectCodexTurn( - projectPath: string, - reconcile = false, - ) { - if (!directCodexProductRuntime || !projectPath) { - return; - } - const invoke = resolveTauriInvoke(); - if (!invoke) { - return; - } - const owner = activeDirectCodexTurnRef.current; - const sequence = owner?.lastSequence; - const scopeVersion = projectScopeVersionRef.current; - if (!reconcile && owner?.projectPath === projectPath) { - return; - } - const readVersion = ++directActiveSnapshotVersionRef.current; - let turns: GameCreatorDirectActiveTurn[]; - try { - turns = await invoke( - 'list_game_creator_direct_active_turns', - ); - } catch { - // 读取失败不等于没有活动回合。 - return; - } - if (!Array.isArray(turns)) return; - if ( - localProjectPathRef.current !== projectPath || - planningV2ActiveRef.current || - designAgentLaneRef.current || - projectScopeVersionRef.current !== scopeVersion || - directActiveSnapshotVersionRef.current !== readVersion || - activeDirectCodexTurnRef.current !== owner || - owner?.lastSequence !== sequence - ) { - return; - } - const activeView = turns.find( - (turn) => - projectPathsMatchForInvalidation(turn.projectPath, projectPath) && - isDirectTurnInProgress(turn.status), - ); - if (!activeView || !isDirectTurnInProgress(activeView.status)) { - // 本地刚发送但尚未进入 Rust 的请求不能被空快照取消。 - if (owner && !owner.restored && owner.lastSequence < 0) return; - resetDirectCodexTurn(); - setChatAgentBusy(false); - if (owner && reconcile) { - void loadProjectConversation(projectPath, false, 'replace'); - } - return; - } - const matchingOwner = owner?.turnId === activeView.turnId ? owner : null; - if (owner && !matchingOwner) { - if (!owner.restored && owner.lastSequence < 0) return; - resetDirectCodexTurn(); - } - if (!matchingOwner) { - activeDirectCodexTurnRef.current = { - projectPath, - turnId: activeView.turnId, - lastSequence: -1, - receivedDirectUpdate: false, - restored: true, - }; - setDirectCodexProcessKey(`${projectPath}\u0000${activeView.turnId}`); - setProjectSupervisorRuntimeError(''); - watchRecoveredDirectCodexTurn(projectPath, activeView.turnId); - } - setChatAgentBusy(true); - // 活动快照不携带正文;已有实时进度不能被同序号的通用描述覆盖。 - if ( - !matchingOwner?.receivedDirectUpdate || - activeView.sequence > matchingOwner.lastSequence - ) { - setDirectCodexStatus(activeView.status); - setDirectCodexProgress( - directCodexActivityDetail(activeView.activity, activeView.status), - ); - setDirectCodexProgressUpdatedAt(activeView.updatedAt); - } - } - - directTurnLifecycleRef.current = { - reset: resetDirectCodexTurn, - loadHistory: (projectPath) => - loadProjectConversation(projectPath, false, 'replace'), - restore: (projectPath) => restoreRunningDirectCodexTurn(projectPath, true), - }; - - // 回合流(文本段 + 工具按**出现顺序**交替):实时增量与回读历史共用一份状态, - // 渲染顺序只由条目的 `seq` 决定,界面不再按文本长度 / 标点 / 时间窗猜切点。 - const [directTurnStream, setDirectTurnStream] = useState( - [], - ); - const directTurnStreamRef = useRef([]); - - /** 归并一批回合流条目(实时事件里的 `streamItems`)。 */ - function applyTurnStreamItems(incoming: readonly TurnStreamItem[]) { - if (incoming.length === 0) { - return; - } - const merged = mergeTurnStreamItems(directTurnStreamRef.current, incoming); - directTurnStreamRef.current = merged; - setDirectTurnStream(merged); - } - - function clearDirectCodexTransientReply(projectPath: string, turnId: string) { - const activeTurn = activeDirectCodexTurnRef.current; - if ( - activeTurn?.projectPath !== projectPath || - activeTurn.turnId !== turnId - ) { - return false; - } - activeDirectCodexTurnRef.current = null; - setDirectCodexTransientReply(''); - setDirectCodexTransientReasoning(''); - directCodexTransientReplyRef.current = ''; - setDirectCodexTransientReplyUpdatedAt(null); - return true; - } const [projectSupervisorRuntime, setProjectSupervisorRuntime] = useState(null); const [projectSupervisorResponseStream, setProjectSupervisorResponseStream] = @@ -2285,117 +1796,7 @@ export function App({ }, [initialProjectPath, projectSupervisorOnly]); useEffect(() => { - if (!directCodexProductRuntime) { - return; - } - if (!canSubscribeTauriEvents()) { - return; - } - let cleanup: (() => void) | null = null; - let disposed = false; - void subscribeTauriEvent( - 'game-creator-direct-turn-update', - (event) => { - const payload = event.payload; - const activeTurn = activeDirectCodexTurnRef.current; - if ( - !activeTurn || - payload.projectPath !== localProjectPathRef.current || - payload.projectPath !== activeTurn.projectPath || - payload.turnId !== activeTurn.turnId || - !Number.isSafeInteger(payload.sequence) || - payload.sequence < 0 || - !DIRECT_CODEX_TURN_UPDATE_STATUSES.has(payload.status) || - payload.sequence <= activeTurn.lastSequence - ) { - return; - } - activeTurn.lastSequence = payload.sequence; - activeTurn.receivedDirectUpdate = true; - // 恢复出来的回合只要回来一条真实事件,就不再是"没响应",撤掉看门狗与那句提示。 - if ( - recoveredDirectCodexTurnRef.current?.projectPath === - payload.projectPath && - recoveredDirectCodexTurnRef.current.turnId === payload.turnId - ) { - clearRecoveredDirectCodexTurnWatch(); - setChatComposerNotice((current) => - current === DIRECT_CODEX_RECOVERED_TURN_STALLED_NOTICE - ? '' - : current, - ); - } - ensureDirectTimelineProject(payload.projectPath); - // 工具调用增量:字段可选,老事件(undefined)走原路径,行为不变。 - if (payload.toolCalls?.length) { - applyDirectToolCalls( - payload.toolCalls.map((call) => ({ - ...call, - turnId: payload.turnId, - })), - ); - } - if (typeof payload.reasoningText === 'string') { - setDirectCodexTransientReasoning(payload.reasoningText); - } - // 回合流的顺序真相:字段可选,老事件(undefined)走原路径。 - if (payload.streamItems?.length) { - applyTurnStreamItems(payload.streamItems); - } - const updatedAt = - Number.isFinite(payload.updatedAt) && payload.updatedAt > 0 - ? payload.updatedAt - : Date.now(); - const processDetail = directCodexProcessDetail(payload); - if (payload.status === 'failed' || payload.status === 'completed') { - directTurnLifecycleRef.current?.reset(); - setChatAgentBusy(false); - void directTurnLifecycleRef.current?.loadHistory(payload.projectPath); - return; - } - setDirectCodexStatus(payload.status); - const genericActivity = payload.activity ?? null; - const previousActivity = lastDirectCodexActivityRef.current; - setDirectCodexProgress((current) => { - const heartbeatWouldDowngrade = - genericActivity !== null && - !payload.accumulatedText?.trim() && - payload.status === 'running' && - previousActivity === genericActivity && - current !== processDetail && - isDirectCodexSpecificWorkDetail(current); - return heartbeatWouldDowngrade ? current : processDetail; - }); - if (genericActivity !== null) { - lastDirectCodexActivityRef.current = genericActivity; - } - setDirectCodexProgressUpdatedAt(updatedAt); - const transientReply = directCodexTransientReplyText(payload); - if (transientReply !== null) { - setDirectCodexTransientReply(transientReply); - directCodexTransientReplyRef.current = transientReply; - setDirectCodexTransientReplyUpdatedAt(updatedAt); - } - }, - ) - .then((unlisten) => { - if (disposed) { - unlisten(); - return; - } - cleanup = unlisten; - }) - .catch(() => { - // The existing safe progress event remains the activity fallback. - }); - return () => { - disposed = true; - cleanup?.(); - }; - }, [directCodexProductRuntime]); - - useEffect(() => { - if (!directCodexProductRuntime || !chatAgentBusy) { + if (!directCodexProductRuntime || !supervisorChatBusy) { return; } const timer = window.setInterval(() => { @@ -2405,22 +1806,33 @@ export function App({ }); }, DIRECT_CODEX_SESSION_KEEPALIVE_MS); return () => window.clearInterval(timer); - }, [chatAgentBusy, directCodexProductRuntime]); + }, [supervisorChatBusy, directCodexProductRuntime]); + /** + * DirectProject 聊天真相源。 + * + * `subscribe` 的 bootstrap 就是此刻要处理的事件(游标已在队尾),此后只由 + * `game-creator-direct-thread-notify` 唤醒 `consume`;前端不再订阅 DirectRuntime 的回合 + * 进度事件,也不轮询"有没有回合在跑"。 + */ useEffect(() => { + if (!directCodexProductRuntime) { + return; + } const projectPath = localProject?.projectPath ?? null; const directInvoke = resolveTauriInvoke(); - if (!directCodexProductRuntime || !projectPath || !directInvoke) return; + if (!projectPath || !directInvoke || !canSubscribeTauriEvents()) { + return; + } let disposed = false; let cleanup: (() => void) | null = null; let subscriptionId: string | null = null; let consuming = false; let consumeAgain = false; + // 通知先于 subscribe 回执到达时,前端还不知道自己的 subscriptionId,没法立刻 consume。 + // 记一笔欠账,拿到回执后立刻补一次,事件就不会卡在队伍里等下一次通知。 + let notifyBeforeSubscription = false; - // Provider 原始事件只用于通知;运行状态始终取 client 回合快照和 Direct 事件。 - const refreshActive = () => { - if (!disposed) void directTurnLifecycleRef.current?.restore(projectPath); - }; const bootstrap = async () => { const result = await directInvoke( 'subscribe_direct_project_thread', @@ -2428,7 +1840,13 @@ export function App({ ); if (disposed) return; subscriptionId = result.subscriptionId; - refreshActive(); + setDirectThreadChat((state) => + resolveDirectThreadBootstrap(state, result), + ); + if (notifyBeforeSubscription) { + notifyBeforeSubscription = false; + void consume(); + } }; const consume = async () => { if (!subscriptionId || disposed) return; @@ -2445,30 +1863,18 @@ export function App({ { subscriptionId }, ); if (disposed) return; - if ( - result.events.some( - (event) => - event.type === 'turn.started' || - event.type === 'turn.completed', - ) - ) { - refreshActive(); - } - if ( - result.events.some((event) => event.type === 'turn.completed') && - !activeDirectCodexTurnRef.current?.receivedDirectUpdate - ) { - // 重进时若未接到 Direct 结束事件,原始 item 的落盘通知仍可补齐最终回复。 - void directTurnLifecycleRef.current?.loadHistory(projectPath); - } + setDirectThreadChat((state) => + applyDirectThreadConsumeResult(state, result), + ); } while (consumeAgain && !disposed); } catch (error) { + // 订阅被别人顶掉时重订一次;通知允许丢,下一次通知会再唤醒。 if (!disposed && String(error).includes('SUBSCRIPTION_EXPIRED')) { subscriptionId = null; try { await bootstrap(); } catch { - /* 活动快照轮询仍然有效。 */ + /* 让下一次通知再试。 */ } } } finally { @@ -2480,7 +1886,11 @@ export function App({ const unlisten = await subscribeTauriEvent<{ subscriptionId: string }>( 'game-creator-direct-thread-notify', (event) => { - if (event.payload.subscriptionId === subscriptionId) void consume(); + if (event.payload.subscriptionId === subscriptionId) { + void consume(); + return; + } + if (!subscriptionId) notifyBeforeSubscription = true; }, ); if (disposed) { @@ -2489,21 +1899,16 @@ export function App({ } cleanup = unlisten; await bootstrap(); - await consume(); } catch { // 历史仍可使用;订阅失败不伪造忙碌态。 } }; - refreshActive(); - const timer = window.setInterval( - refreshActive, - DIRECT_ACTIVE_TURNS_POLL_INTERVAL_MS, - ); + // 换项目就是换一份聊天:先回到初始态,再订阅新线程。 + setDirectThreadChat(emptyDirectThreadChatState()); void setup(); return () => { disposed = true; cleanup?.(); - window.clearInterval(timer); }; }, [directCodexProductRuntime, localProject?.projectPath]); @@ -2523,20 +1928,7 @@ export function App({ return; } if (directCodexProductRuntime) { - const activeTurn = activeDirectCodexTurnRef.current; - if ( - !activeTurn || - activeTurn.projectPath !== event.payload.projectPath || - activeTurn.receivedDirectUpdate - ) { - return; - } - const progressDetail = ensureDirectProcessPrefix( - event.payload.message, - ); - setDirectCodexStatus('running'); - setDirectCodexProgress(progressDetail); - setDirectCodexProgressUpdatedAt(Date.now()); + // DirectProject 的进度已经从线程事件流进聊天条目,这条通用进度事件不再参与。 return; } setMessages((current) => [ @@ -3076,10 +2468,7 @@ export function App({ projectSupervisorResponseStream?.sequence, projectSupervisorRuntime?.updatedAt, projectSupervisorRuntimeError, - directCodexProgress, - directCodexProgressUpdatedAt, - directCodexTransientReply, - directCodexTransientReplyUpdatedAt, + directThreadChat, directCodexProductRuntime, planningV2Active, projectSupervisorOnly, @@ -4113,10 +3502,14 @@ export function App({ ).then((slice) => { loadedDirectHistoryHasMore = slice.hasMore; loadedDirectHistoryFirstItemId = slice.firstItemId; + // 首屏历史进聊天 reducer;`messages` 在 DirectProject 下只剩运行期本地消息。 + setDirectThreadChat((state) => + mergeDirectThreadHistorySlice(state, slice), + ); return { path: nextProjectPath, agentId: null, - messages: directThreadHistoryItemsToMessages(slice.items), + messages: [], } satisfies LocalConversationResult; }); })() @@ -4125,35 +3518,7 @@ export function App({ agentId: null, }); const resolvedProjectConversation = await projectConversation; - // 工具调用卡片走独立历史文件(`tool-calls.jsonl`)。必须在读完项目对话之后、 - // 任何提前 return 之前回读:direct-codex 下后面那条 design-agent 分支会直接返回, - // 放在它后面等于永远不执行。文件缺失 / 读取失败都只是没有卡片,不能因此把整个 - // 项目打开流程判失败。卡片按项目维度整表替换,同一 id 只渲染一次。 - if (directCodexProductRuntime) { - const persistedToolCalls = await invoke( - 'read_direct_tool_calls', - { projectPath: nextProjectPath }, - ).catch(() => []); - // 回合流(顺序真相)走独立历史文件(`turn-stream.jsonl`)。与工具调用同一处:必须在 - // 读完项目对话之后、任何提前 return 之前回读。缺命令(老客户端)/ 缺文件 / 读取失败 - // 都只是"这个回合没有流",界面回退到原来的渲染,不能因此把整个打开流程判失败。 - const persistedTurnStream = await invoke( - 'read_direct_turn_stream', - { projectPath: nextProjectPath }, - ).catch(() => []); - if ( - projectSupervisorHistoryLoadVersionRef.current !== loadVersion || - localProjectPathRef.current !== nextProjectPath - ) - return; - ensureDirectTimelineProject(nextProjectPath); - // 回读可能与实时事件交错:按同一身份合并,不能用旧磁盘快照覆盖实时状态。 - applyDirectToolCalls(persistedToolCalls); - applyTurnStreamItems(persistedTurnStream); - // 重进会话时 Rust 侧可能仍登记着上一条 Direct 回合。不接管的话界面既不显示 - // 过程卡也不给终止入口,用户再发消息只会被守卫拒绝("已有另一条回合正在运行")。 - await restoreRunningDirectCodexTurn(nextProjectPath); - } + // 首屏历史直接进聊天 reducer;`messages` 在 DirectProject 下只剩运行期本地消息。 let supervisorConversation: LocalConversationResult | null = null; let runtime: AgentRuntimeState | null = null; let runtimeResponseStream: AgentRuntimeResponseStream | null = null; @@ -4309,7 +3674,7 @@ export function App({ const projectScopeVersion = projectScopeVersionRef.current + 1; projectScopeVersionRef.current = projectScopeVersion; resetProjectSupervisorState(); - resetDirectCodexTurn(); + resetDirectThreadChat(); updateClientPreview(null); setPreviewStatus('未启动'); setWorkspaceStatus('正在打开'); @@ -7153,21 +6518,7 @@ export function App({ index === existingIndex ? nextMessage : message, ); }; - activeDirectCodexTurnRef.current = { - projectPath: directProjectPath, - turnId: clientTurnId, - lastSequence: -1, - receivedDirectUpdate: false, - }; setChatAgentBusy(true); - setDirectCodexStatus('accepted'); - setDirectCodexProcessKey(`${directProjectPath}\u0000${clientTurnId}`); - setDirectCodexProgress('正在等待陶泥儿开始'); - setDirectCodexTransientReply(''); - setDirectCodexTransientReasoning(''); - setDirectCodexProgressUpdatedAt(Date.now()); - directCodexTransientReplyRef.current = ''; - setDirectCodexTransientReplyUpdatedAt(null); setProjectSupervisorRuntimeError(''); try { const directTurnInput: { @@ -7191,14 +6542,6 @@ export function App({ } directTurnInput.userItem = effectiveUserItem; const reply = await withDirectCodexSessionRefresh(() => { - // 每次调用都会新建 Rust 事件流;续期重试需重新接收同一回合的进度。 - activeDirectCodexTurnRef.current = { - projectPath: directProjectPath, - turnId: clientTurnId, - lastSequence: -1, - receivedDirectUpdate: false, - }; - setDirectCodexStatus('accepted'); return directInvoke( 'chat_with_game_creator_direct_codex', directTurnInput, @@ -7210,10 +6553,8 @@ export function App({ projectSupervisorHistoryLoadVersionRef.current += 1; } if (localProjectPathRef.current === directProjectPath) { - clearDirectCodexTransientReply(directProjectPath, clientTurnId); - setMessages((current) => - appendDirectAssistantMessage(current, reply), - ); + // 回复正文按条目身份从线程事件 / 历史切片进聊天,本地不再补一条。 + void reply; await refreshDirectProjectManifest(directProjectPath); } } catch (error) { @@ -7222,16 +6563,9 @@ export function App({ isDirectCodexAnotherTurnRunningError(error) ) { if (localProjectPathRef.current === directProjectPath) { - clearDirectCodexTransientReply(directProjectPath, clientTurnId); setProjectSupervisorRuntimeError( '陶泥儿仍在处理上一条消息,可在输入盒点「终止」结束它,或等它结束后再发送。', ); - // 兜底:出现这条拒绝说明本项目确实有回合在跑,而本组件此前没接管它 - // (重进会话的漏网情况)。放到当前任务之后再接管,避开本回合 finally - // 里 setChatAgentBusy(false) 的复位竞态。 - window.setTimeout(() => { - void restoreRunningDirectCodexTurn(directProjectPath); - }, 0); } return; } @@ -7240,9 +6574,6 @@ export function App({ } if (isDirectCodexTurnInterruptedError(error)) { // 用户主动终止:不是失败,不写运行错误与诊断,只把回合标记成已终止。 - clearDirectCodexTransientReply(directProjectPath, clientTurnId); - setDirectCodexStatus('failed'); - setDirectCodexProgress(''); setProjectSupervisorRuntimeError(''); setChatComposerNotice('已终止本次回合'); setMessages((current) => @@ -7277,9 +6608,6 @@ export function App({ ); projectSupervisorHistoryLoadVersionRef.current += 1; if (localProjectPathRef.current === directProjectPath) { - clearDirectCodexTransientReply(directProjectPath, clientTurnId); - setDirectCodexStatus('failed'); - setDirectCodexProgress('正在记录失败原因'); setProjectSupervisorRuntimeError(visibleMessage); setMessages((current) => appendDirectAssistantMessage(current, visibleMessage, true), @@ -7291,16 +6619,9 @@ export function App({ await refreshManifest(directProjectPath); } } finally { - const activeTurn = activeDirectCodexTurnRef.current; - if ( - localProjectPathRef.current === directProjectPath && - (!activeTurn || - (activeTurn.projectPath === directProjectPath && - activeTurn.turnId === clientTurnId)) - ) { + if (localProjectPathRef.current === directProjectPath) { setChatAgentBusy(false); setDirectCodexTurnCancelling(false); - resetDirectCodexTurn(); // 只有当前回合的收尾才能释放发送队列,不能覆盖后来启动的回合。 dispatchNextQueuedChatTurn(); } @@ -12490,20 +11811,10 @@ export function App({ if (localProjectPathRef.current !== projectPath) { return; } - const older = directThreadHistoryItemsToMessages(slice.items).map( - (message) => ({ - role: - message.role === 'user' - ? ('user' as const) - : ('assistant' as const), - text: message.content, - runtimeOwned: true, - messageId: message.messageId, - updatedAt: message.updatedAt, - }), + // 更早的一屏历史进聊天 reducer:与运行态同形、同身份,重复读取只补不重。 + setDirectThreadChat((state) => + mergeDirectThreadHistorySlice(state, slice), ); - setMessages((current) => [...older, ...current]); - setConversationVisibleCount((current) => current + older.length); setDirectHistoryHasMore(slice.hasMore); directHistoryOldestItemIdRef.current = slice.firstItemId ?? directHistoryOldestItemIdRef.current; @@ -12732,10 +12043,8 @@ export function App({ return; } const invoke = resolveTauriInvoke(); - const activeTurn = activeDirectCodexTurnRef.current; - const directProjectPath = - activeTurn?.projectPath ?? resolveChatProjectPath(localProject); - if (!invoke || !directProjectPath || !activeTurn) { + const directProjectPath = resolveChatProjectPath(localProject); + if (!invoke || !directProjectPath || !directTurnRunning) { setProjectSupervisorRuntimeError('当前没有正在运行的回合,无法终止。'); return; } @@ -12744,17 +12053,13 @@ export function App({ try { const result = await invoke( 'cancel_direct_codex_turn', - { - projectPath: directProjectPath, - clientTurnId: activeTurn.turnId, - }, + { projectPath: directProjectPath }, ); const message = result?.message?.trim(); if (result?.outcome === 'released') { - // 这一轮已经没有人替它收尾(执行进程已退出 / 从没进执行器),Rust 侧已强制释放 - // 守卫。没有会 return 的回合 promise 来复位界面,这里必须自己复位,否则过程卡 - // 与"任务执行中"会一直挂着,用户仍然发不出消息。 - resetDirectCodexTurn(); + // 这一轮已经没有人替它收尾(执行进程已退出 / 从没进执行器),Rust 侧强制释放了 + // 守卫并补了终态事件;这里同步把界面复位,不等 IPC 通知。 + setDirectThreadChat((state) => ({ ...state, turnRunning: false })); setChatAgentBusy(false); setProjectSupervisorRuntimeError(''); setChatComposerNotice( @@ -12762,7 +12067,6 @@ export function App({ ); return; } - setDirectCodexProgress('正在终止当前回合'); if (message) { setChatComposerNotice(message); } @@ -12932,9 +12236,7 @@ export function App({ transientReply={ planningV2Active ? planningV2TransientReply - : directCodexProductRuntime - ? directCodexTransientReply - : projectSupervisorTransientReply + : projectSupervisorTransientReply } hasConversationControls={projectSupervisorHasConversationControls} hiddenConversationCount={hiddenConversationCount} @@ -12950,7 +12252,6 @@ export function App({ if (projectSupervisorOnly) { return ( (); - for (const call of toolCalls) { - const id = call.id?.trim(); - if (id && !callsById.has(id)) { - callsById.set(id, call); - } - } - return ( - <> - {runs.map((run, index) => - run.kind === 'text' ? ( -
- - - -
- ) : ( - callsById.get(callId)) - .filter((call): call is GameCreatorDirectToolCall => - Boolean(call), - )} - userSentAt={userSentAt} - active={active} - className="message-tool-call" - /> - ), - )} - - ); -} - /** 当前 Agent 和策划 Agent 的实时/历史思考使用同一个折叠入口。 */ function AgentReasoning({ text, @@ -217,25 +110,6 @@ function AgentReasoning({ type RuntimePanelProps = ComponentProps; -function directStatusTitle(status: string | null | undefined) { - switch (status) { - case 'accepted': - return '需求已接收'; - case 'running': - return '任务执行中'; - case 'streaming': - return '回复生成中'; - case 'finalizing': - return '结果整理中'; - case 'completed': - return '回复已生成'; - case 'failed': - return '处理失败'; - default: - return '任务执行中'; - } -} - type ProjectSupervisorViewProps = RuntimePanelProps & { activeVersionId?: string | null; /** 输入盒待发送附件:随下次提交进入回合(direct-codex 才渲染)。 */ @@ -247,9 +121,10 @@ type ProjectSupervisorViewProps = RuntimePanelProps & { chatProjectAssets: import('../../../../../packages/shared/src/contracts/gameCreationApp').GameCreationAppAssetManifestEntry[]; composerRef?: RefObject; directCodex?: boolean; - directStatus?: GameCreatorDirectTurnUpdateStatus | null; - directProcessDetail?: string; - directProcessKey?: string; + /** 聊天条目:历史切片与运行态事件归并后的唯一顺序(工具卡片也在里面)。 */ + directEntries?: DirectChatEntry[]; + /** 最新回合是否还在跑;只由生命周期事件决定。 */ + directTurnRunning?: boolean; hiddenConversationCount: number; hasEarlierConversationMessages?: boolean; messagesRef: RefObject; @@ -276,28 +151,14 @@ type ProjectSupervisorViewProps = RuntimePanelProps & { pendingConfirmation: PendingUiConfirmation | null; pendingCommand: PendingCommand | null; projectPath: string; - /** 本回合(含历史回读)的工具调用卡片,按 `startedAt` 升序,同一 id 只会出现一次。 */ - toolCalls?: GameCreatorDirectToolCall[]; - /** - * 「文本段 + 工具」的顺序真相(`turn-stream.jsonl` / 回合事件里的 `streamItems`)。 - * - * 有值的回合按它渲染(文本段 → 工具块 → 文本段 → …),没有值的回合回退到 - * 「工具块 + 整轮消息 + 整轮用量」的老渲染——老项目、缺文件、读取失败都不能白屏。 - */ - turnStreamItems?: TurnStreamItem[]; - /** 当前正在跑的回合 id;卡片在 assistant 消息落盘前锚到它。 */ - activeTurnId?: string | null; initialSupervisorMessage?: string; showProfessionalCollaboration?: boolean; transientReply: string; - /** 流式思考过程(direct-codex):拿不到就为空,空则不渲染。 */ - transientReasoning?: string; showDesignReasoning?: boolean; designReasoning?: string; designReasoningEntries?: DesignReasoningEntry[]; visibleMessages: ChatMessage[]; conversationMessages?: ChatMessage[]; - hasUnloadedHistory?: boolean; visibleProfessionalAgentCards: AgentStatusCard[]; walletEntry?: ReactNode; workspaceStatus: string; @@ -332,9 +193,8 @@ export function ProjectSupervisorView({ chatProjectAssets, composerRef, directCodex = false, - directStatus = null, - directProcessDetail = '', - directProcessKey = '', + directEntries = [], + directTurnRunning = false, hiddenConversationCount, hasEarlierConversationMessages = false, messagesRef, @@ -357,19 +217,14 @@ export function ProjectSupervisorView({ pendingConfirmation, pendingCommand, projectPath, - toolCalls = [], - turnStreamItems = [], - activeTurnId = null, initialSupervisorMessage = '', showProfessionalCollaboration = true, transientReply, - transientReasoning = '', showDesignReasoning = false, designReasoning = '', designReasoningEntries = [], visibleMessages, conversationMessages = visibleMessages, - hasUnloadedHistory = false, visibleProfessionalAgentCards, walletEntry, workspaceStatus, @@ -390,26 +245,18 @@ export function ProjectSupervisorView({ }: ProjectSupervisorViewProps) { const planningSurfaceActive = planningLane || isPlanningLaneRuntime(runtimePanelProps.runtime); - const [expandedProcessKey, setExpandedProcessKey] = useState( - null, - ); + const [settingsOpen, setSettingsOpen] = useState(false); + // 整轮会话的耗时在回合进行中要每秒刷新:用 tick 驱动的 `now` 计算"现在 - 开始"。 + const [turnUsageNow, setTurnUsageNow] = useState(() => Date.now()); + // 语音输入的降级/失败提示:不支持时按钮本身就带提示,这里只承载启动失败与权限类错误。 + const [voiceNotice, setVoiceNotice] = useState(''); + const [approvalOpen, setApprovalOpen] = useState(false); + const [approvalMode, setApprovalMode] = useState('strict'); + const [approvalNotice, setApprovalNotice] = useState(''); useEffect(() => { - if (!activeTurnId) { - return; - } - setTurnUsageNow(Date.now()); - const timer = setInterval(() => setTurnUsageNow(Date.now()), 1000); - return () => clearInterval(timer); - }, [activeTurnId]); - - useEffect(() => { - setExpandedProcessKey(null); - }, [directProcessKey]); - useEffect(() => { - setActiveTurnStartedAt(activeTurnId ? Date.now() : 0); - }, [activeTurnId]); - const processDetailExpanded = - Boolean(directProcessKey) && expandedProcessKey === directProcessKey; + setApprovalOpen(false); + setApprovalNotice(''); + }, [settingsOpen]); const submitLabel = needsUserInput ? '等待回答' : runtimePanelProps.controlBusy @@ -420,34 +267,24 @@ export function ProjectSupervisorView({ const [modelValidating, setModelValidating] = useState(false); const modelSelectRef = useRef(null); const modelValidateInFlightRef = useRef(false); - // 设置浮层:Codex 顶栏只剩状态与齿轮,运行配置 / 审批模式 / 钱包都收进这里。 - const [settingsOpen, setSettingsOpen] = useState(false); - // 整轮会话的耗时在回合进行中要每秒刷新:用 tick 驱动的 `now` 计算"现在 - 开始"。 - const [turnUsageNow, setTurnUsageNow] = useState(() => Date.now()); - const [activeTurnStartedAt, setActiveTurnStartedAt] = useState(0); - // 语音输入的降级/失败提示:不支持时按钮本身就带提示,这里只承载启动失败与权限类错误。 - const [voiceNotice, setVoiceNotice] = useState(''); - const [approvalOpen, setApprovalOpen] = useState(false); - const [approvalMode, setApprovalMode] = useState('strict'); - const [approvalNotice, setApprovalNotice] = useState(''); useEffect(() => { - setApprovalOpen(false); - setApprovalNotice(''); - }, [settingsOpen]); + if (!directTurnRunning) { + return; + } + setTurnUsageNow(Date.now()); + const timer = setInterval(() => setTurnUsageNow(Date.now()), 1000); + return () => clearInterval(timer); + }, [directTurnRunning]); const runBusy = runtimePanelProps.controlBusy || submitting; const directTurns = directCodex - ? buildDirectTurnPresentations({ - messages: conversationMessages, - visibleMessages, - items: turnStreamItems, - calls: toolCalls, - activeTurnId, - transientReply, - hasUnloadedHistory, + ? buildDirectChatTurns({ + entries: directEntries, + localMessages: conversationMessages, + turnRunning: directTurnRunning, }) : []; - const turnStartedAtFor = (turnId: string) => - directTurns.find((turn) => turn.turnId === turnId)?.startedAt ?? 0; + const activeTurnStartedAt = + directTurns.find((turn) => turn.active)?.startedAt ?? 0; const clockTimeWithSeconds = (timestamp: number) => { const date = new Date(timestamp); @@ -455,14 +292,13 @@ export function ProjectSupervisorView({ return `${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`; }; - const renderTurnUsage = (turn: DirectTurnPresentation) => { - if (!turn.turnId || turn.active || !turn.startedAt) return null; + const renderTurnUsage = (turn: DirectChatTurn) => { + if (turn.active || !turn.startedAt) return null; const endedAt = Math.max(turn.endedAt, turn.startedAt); return (

{`本轮结束于 ${clockTimeWithSeconds(endedAt)} · 耗时 ${formatTurnDuration(endedAt - turn.startedAt) ?? '0秒'}`} @@ -568,7 +404,7 @@ export function ProjectSupervisorView({ aria-hidden="true" /> {runBusy - ? directStatusTitle(directStatus) + ? '陶泥儿正在处理' : projectWorkspaceStatusForDisplay(workspaceStatus)} - ) : null} - - ) : null} ) : null}

{ - if (item.itemType !== 'message') return []; - if (item.role !== 'user' && item.role !== 'assistant') return []; - if (!item.text) return []; - return [ - { - schemaVersion: 'agc-direct-project-context.v1', - role: item.role, - content: item.text, - agentId: null, - messageId: item.itemId, - updatedAt: item.at ?? 0, - }, - ]; - }); -} - -/** 只有这些状态表示仍持有活动回合;Provider 回放的终态不是活动快照。 */ -export function isDirectTurnInProgress( - status: string | null | undefined, -): status is 'accepted' | 'running' | 'streaming' | 'finalizing' { - return ( - status === 'accepted' || - status === 'running' || - status === 'streaming' || - status === 'finalizing' - ); -} diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/directThreadItemProjection.ts b/apps/ai-game-creator-shell/src/features/project-workspace/directThreadItemProjection.ts index f1982c80f..1f44ddda0 100644 --- a/apps/ai-game-creator-shell/src/features/project-workspace/directThreadItemProjection.ts +++ b/apps/ai-game-creator-shell/src/features/project-workspace/directThreadItemProjection.ts @@ -217,7 +217,8 @@ export function projectDirectThreadItem( item: DirectThreadItem | null | undefined, ): DirectChatEntry | null { if (!item) return null; - const itemId = item.itemId.trim(); + // 身份是条目唯一的主键:拿不到身份的载荷既不能渲染也不能合并,只丢弃这一条。 + const itemId = typeof item.itemId === 'string' ? item.itemId.trim() : ''; if (!itemId) return null; switch (item.itemType) { diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/directTurnPresentation.ts b/apps/ai-game-creator-shell/src/features/project-workspace/directTurnPresentation.ts index cd5327ef5..20e17ca0e 100644 --- a/apps/ai-game-creator-shell/src/features/project-workspace/directTurnPresentation.ts +++ b/apps/ai-game-creator-shell/src/features/project-workspace/directTurnPresentation.ts @@ -1,30 +1,12 @@ -import type { - ChatMessage, - GameCreatorDirectToolCall, - TurnStreamItem, -} from '../../app/types'; -import { projectSupervisorChatMessageText } from '../agent-runtime'; +/** + * DirectProject 聊天呈现:把聊天条目切成"用户气泡 / 过程 / 最终回复"三段。 + * + * 输入是唯一一份聊天条目(历史切片 + 运行态事件归并的结果),顺序就是条目顺序; + * 这里只做分区与合并(连续工具合成一块),不认回合身份,也不再从文本长度 / 标点猜切点。 + */ -export function directConversationTurnId(messageId: string | null | undefined) { - const match = /^direct-codex:(.+):(user|assistant|failure)$/.exec( - messageId ?? '', - ); - return match?.[1] ?? null; -} - -export type DirectTurnPresentation = { - key: string; - turnId: string | null; - messages: ChatMessage[]; - notices: ChatMessage[]; - items: TurnStreamItem[]; - calls: GameCreatorDirectToolCall[]; - source: 'stream' | 'messages'; - active: boolean; - transientReply: string; - startedAt: number; - endedAt: number; -}; +import type { ChatMessage } from '../../app/types'; +import type { DirectChatEntry, DirectChatToolCard } from './directThreadChat'; /** 统一 Direct 时间戳为 Unix 毫秒;历史/异常 payload 可能传 Unix 秒。 */ export function normalizeDirectTimestamp(value: number | undefined) { @@ -42,236 +24,251 @@ export function normalizeDirectTimestamp(value: number | undefined) { export const directMessageTimestamp = normalizeDirectTimestamp; -/** 从唯一呈现来源分区,完成后只把最终回复及失败提示留在过程折叠区之外。 */ -export function splitDirectTurnContent(turn: DirectTurnPresentation) { - const assistants = turn.messages.filter( - (message) => message.role === 'assistant', - ); - const isFailureMessage = (message: ChatMessage) => - Boolean( - message.messageId?.endsWith(':failure') && - directConversationTurnId(message.messageId), - ); - const isFailureItem = (item: TurnStreamItem) => - item.id === `text:${turn.turnId}:failure`; - const failed = - assistants.some(isFailureMessage) || turn.items.some(isFailureItem); - const lastText = - !turn.active && !failed - ? turn.items - .filter((item) => item.kind === 'text' && item.text.trim()) - .at(-1) - : undefined; - const lastMessage = !turn.active && !failed ? assistants.at(-1) : undefined; - return { - processItems: turn.items.filter( - (item) => item !== lastText && !isFailureItem(item), - ), - finalItems: turn.items.filter( - (item) => item === lastText || isFailureItem(item), - ), - processMessages: assistants.filter( - (message) => message !== lastMessage && !isFailureMessage(message), - ), - finalMessages: assistants.filter( - (message) => message === lastMessage || isFailureMessage(message), - ), - }; +export type DirectChatBlock = + | { kind: 'user'; key: string; text: string; at: number } + | { + kind: 'assistant'; + key: string; + text: string; + at: number; + notice?: boolean; + } + | { kind: 'reasoning'; key: string; text: string } + | { kind: 'tools'; key: string; calls: DirectChatToolCard[] }; + +export type DirectChatTurn = { + key: string; + /** 用户气泡:顺序即发出顺序。 */ + users: DirectChatBlock[]; + /** 过程块:工具与中间正文按条目顺序,连续工具合成一块。 */ + process: DirectChatBlock[]; + /** 最终回复,以及失败 / 终止这类只存在于运行期的说明。 */ + finals: DirectChatBlock[]; + active: boolean; + startedAt: number; + endedAt: number; +}; + +type DirectChatTurnEntries = { + key: string; + entries: DirectChatEntry[]; + /** 本地乐观用户气泡:还没有任何落盘条目时的用户消息。 */ + localUsers: DirectChatBlock[]; + notices: ChatMessage[]; + active: boolean; +}; + +function blockFromEntry( + entry: DirectChatEntry, + key: string, +): DirectChatBlock | null { + if (entry.kind === 'tool') { + return entry.toolCall + ? { kind: 'tools', key, calls: [entry.toolCall] } + : null; + } + const text = entry.text ?? ''; + if (!text.trim()) return null; + if (entry.kind === 'reasoning') { + return { kind: 'reasoning', key, text }; + } + return entry.role === 'user' + ? { kind: 'user', key, text, at: normalizeDirectTimestamp(entry.at) } + : { + kind: 'assistant', + key, + text, + at: normalizeDirectTimestamp(entry.at), + }; } -/** 完整历史先按身份归属,再决定可见回合;渲染层只消费这一个列表。 */ -export function buildDirectTurnPresentations({ - messages, - visibleMessages, - items, - calls, - activeTurnId, - transientReply, - hasUnloadedHistory = false, -}: { - messages: readonly ChatMessage[]; - visibleMessages: readonly ChatMessage[]; - items: readonly TurnStreamItem[]; - calls: readonly GameCreatorDirectToolCall[]; - activeTurnId?: string | null; - transientReply: string; - hasUnloadedHistory?: boolean; -}): DirectTurnPresentation[] { - const rows = new Map(); - const itemTurnIds = new Map(); - for (const item of items) { - const prefix = `text:${item.turnId}:`; - if (item.kind === 'text' && item.id.startsWith(prefix)) { - itemTurnIds.set(item.id.slice(prefix.length), item.turnId); +/** 连续的工具条目合成一块;中间夹了正文就分块。 */ +function mergeToolBlocks(blocks: DirectChatBlock[]): DirectChatBlock[] { + const merged: DirectChatBlock[] = []; + for (const block of blocks) { + const last = merged[merged.length - 1]; + if (block.kind === 'tools' && last?.kind === 'tools') { + last.calls.push(...block.calls); + continue; } + merged.push( + block.kind === 'tools' ? { ...block, calls: [...block.calls] } : block, + ); } - const ensure = (turnId: string | null, key: string) => { - const existing = rows.get(key); - if (existing) return existing; - const row: DirectTurnPresentation = { + return merged; +} + +function blockFromLocalMessage( + message: ChatMessage, + index: number, +): DirectChatBlock | null { + const text = message.text.trim(); + if (!text) return null; + const key = message.messageId ?? `local:${index}`; + if (message.role === 'user') { + return { + kind: 'user', key, - turnId, - messages: [], - notices: [], - items: [], - calls: [], - source: 'messages', - active: Boolean(turnId && turnId === activeTurnId), - transientReply: '', - startedAt: 0, - endedAt: 0, + text: message.text, + at: normalizeDirectTimestamp(message.updatedAt), }; - rows.set(key, row); - return row; + } + return { + kind: 'assistant', + key, + text: message.text, + at: normalizeDirectTimestamp(message.updatedAt), + notice: true, }; - - // 原始 assistant id 可通过流 item 精确反查。旧用户记录无 turn id 时, - // 只用同一用户记录区间内的这种精确证据关联,不与第 N 个工具回合配对。 - const boundaryTurnIds = new Map(); - let boundary = -1; - messages.forEach((message, index) => { - if (message.role === 'user') boundary = index; - const id = - directConversationTurnId(message.messageId) ?? - itemTurnIds.get(message.messageId ?? ''); - if (id && boundary >= 0 && !boundaryTurnIds.has(boundary)) { - boundaryTurnIds.set(boundary, id); - } - }); - const visible = new Set(visibleMessages); - const visibleKeys = new Set(); - const seenMessages = new Set(); - boundary = -1; - messages.forEach((message, index) => { - if (message.role === 'user') boundary = index; - const turnId = - directConversationTurnId(message.messageId) ?? - itemTurnIds.get(message.messageId ?? '') ?? - boundaryTurnIds.get(boundary) ?? - null; - const key = turnId - ? `turn:${turnId}` - : `history:${boundary < 0 ? index : boundary}`; - const row = ensure(turnId, key); - if (visible.has(message)) visibleKeys.add(key); - if (message.messageId && seenMessages.has(message.messageId)) return; - if (message.messageId) seenMessages.add(message.messageId); - row.messages.push(message); - }); - - const itemsByKey = new Map(); - for (const item of items) { - const key = `${item.turnId}\0${item.id}`; - const previous = itemsByKey.get(key); - if (!previous || item.updatedAt >= previous.updatedAt) - itemsByKey.set(key, item); - } - for (const item of itemsByKey.values()) { - ensure(item.turnId, `turn:${item.turnId}`).items.push(item); - } - const callsByKey = new Map(); - for (const call of calls) { - const key = `${call.turnId}\0${call.id}`; - const previous = callsByKey.get(key); - if (!previous || call.updatedAt >= previous.updatedAt) - callsByKey.set(key, call); - } - for (const call of callsByKey.values()) { - ensure(call.turnId, `turn:${call.turnId}`).calls.push(call); - } - if (activeTurnId) ensure(activeTurnId, `turn:${activeTurnId}`); - - for (const row of rows.values()) { - row.items.sort((a, b) => a.seq - b.seq || a.id.localeCompare(b.id)); - row.calls.sort( - (a, b) => a.startedAt - b.startedAt || a.id.localeCompare(b.id), - ); - const assistants = row.messages.filter( - (message) => message.role === 'assistant', - ); - const rawAssistants = assistants.filter( - (message) => - message.messageId && !directConversationTurnId(message.messageId), - ); - const textIds = new Set( - row.items.filter((item) => item.kind === 'text').map((item) => item.id), - ); - // 精确 item id 才能补齐旧快照正文;无法证明流覆盖完整历史时,整轮使用历史, - // 不同时渲染“部分流 + 全文”,也不按长度比例猜测是否重复。 - const historyCovered = assistants.every( - (message) => - Boolean(directConversationTurnId(message.messageId)) || - Boolean( - message.messageId && - textIds.has(`text:${row.turnId}:${message.messageId}`), - ), - ); - row.source = - row.items.length > 0 && - historyCovered && - (textIds.size > 0 || assistants.length === 0) - ? 'stream' - : 'messages'; - if (row.source === 'stream') { - row.notices = assistants.filter( - (message) => - message.messageId === `direct-codex:${row.turnId}:failure` && - !textIds.has(`text:${row.turnId}:failure`), - ); - const historyById = new Map( - rawAssistants.map((message) => [ - `text:${row.turnId}:${message.messageId}`, - message, - ]), - ); - row.items = row.items.map((item) => { - const history = historyById.get(item.id); - return item.kind === 'text' && history - ? { ...item, text: projectSupervisorChatMessageText(history) } - : item; - }); - } - if (row.source === 'messages' && rawAssistants.length > 0) { - const lastText = row.items.filter((item) => item.kind === 'text').at(-1); - const lastRaw = rawAssistants.at(-1); - // 最后一个原始 item 与流尾身份一致时,GUI 整轮回执不是另一条回复。 - if ( - lastText && - lastRaw && - lastText.id === `text:${row.turnId}:${lastRaw.messageId}` - ) { - row.messages = row.messages.filter( - (message) => - message.messageId !== `direct-codex:${row.turnId}:assistant`, - ); - } - } - if (row.active && row.source === 'messages' && assistants.length === 0) { - row.transientReply = transientReply; - } - const starts = [ - ...row.messages - .filter((message) => message.role === 'user') - .map((message) => directMessageTimestamp(message.updatedAt)), - ...row.items.map((item) => directMessageTimestamp(item.at)), - ...row.calls.map((call) => directMessageTimestamp(call.startedAt)), - ].filter((at) => at > 0); - row.startedAt = starts.length ? Math.min(...starts) : 0; - row.endedAt = Math.max( - 0, - ...row.messages.map((message) => - directMessageTimestamp(message.updatedAt), - ), - ...row.items.map((item) => directMessageTimestamp(item.updatedAt)), - ...row.calls.map((call) => directMessageTimestamp(call.updatedAt)), - ); - } - return [...rows.values()].filter( - (row) => - row.active || - visibleKeys.has(row.key) || - (!hasUnloadedHistory && - row.messages.length === 0 && - (row.items.length > 0 || row.calls.length > 0)), - ); +} + +function newTurn(key: string): DirectChatTurnEntries { + return { + key, + entries: [], + localUsers: [], + notices: [], + active: false, + }; +} + +/** + * 条目 + 运行期本地消息 → 回合列表。 + * + * 每个用户条目开一个新回合;本地用户气泡(乐观发送)也算开新回合;本地 assistant 消息 + * (失败 / 终止说明)挂到当前回合末尾。同身份的本地消息不重复渲染:条目赢。 + */ +export function buildDirectChatTurns({ + entries, + localMessages = [], + turnRunning = false, +}: { + entries: readonly DirectChatEntry[]; + localMessages?: readonly ChatMessage[]; + turnRunning?: boolean; +}): DirectChatTurn[] { + const turns: DirectChatTurnEntries[] = []; + let current: DirectChatTurnEntries | null = null; + for (const entry of entries) { + if (entry.kind === 'message' && entry.role === 'user') { + current = newTurn(entry.itemId); + turns.push(current); + } + if (!current) { + current = newTurn(`history:${turns.length}`); + turns.push(current); + } + current.entries.push(entry); + } + + const entryIds = new Set(entries.map((entry) => entry.itemId)); + localMessages.forEach((message, index) => { + if (message.messageId && entryIds.has(message.messageId)) return; + if (message.role === 'user') { + const block = blockFromLocalMessage(message, index); + current = newTurn(message.messageId ?? `local:${index}`); + turns.push(current); + if (block) current.localUsers.push(block); + return; + } + if (!current) { + current = newTurn(`history:${turns.length}`); + turns.push(current); + } + current.notices.push(message); + }); + if (current) current.active = turnRunning; + + return turns.map((turn) => { + const lastAssistant = turn.active + ? -1 + : turn.entries.reduce( + (found, entry, index) => + entry.kind === 'message' && entry.role === 'assistant' + ? index + : found, + -1, + ); + const users: DirectChatBlock[] = []; + const process: DirectChatBlock[] = []; + const finals: DirectChatBlock[] = []; + turn.entries.forEach((entry, index) => { + const block = blockFromEntry(entry, `${turn.key}:${entry.itemId}`); + if (!block) return; + if (block.kind === 'user') { + users.push(block); + return; + } + if (index === lastAssistant) { + finals.push(block); + return; + } + process.push(block); + }); + users.push(...turn.localUsers); + turn.notices.forEach((message, index) => { + const block = blockFromLocalMessage(message, index); + if (block) finals.push(block); + }); + const times = [ + ...turn.entries.map((entry) => normalizeDirectTimestamp(entry.at)), + ...turn.notices.map((message) => + normalizeDirectTimestamp(message.updatedAt), + ), + ...users.map((block) => (block.kind === 'user' ? block.at : 0)), + ].filter((at) => at > 0); + const startedAt = times.length ? Math.min(...times) : 0; + const endedAt = [ + ...turn.entries.map((entry) => normalizeDirectTimestamp(entry.at)), + ...turn.notices.map((message) => + normalizeDirectTimestamp(message.updatedAt), + ), + ].reduce((latest, at) => Math.max(latest, at), 0); + return { + key: turn.key, + users, + process: mergeToolBlocks(process), + finals, + active: turn.active, + startedAt, + endedAt, + } satisfies DirectChatTurn; + }); +} + +/** 条目里的用户 / 助手消息转聊天消息(独立窗口只渲染气泡,不渲染工具卡片)。 */ +export function directChatEntriesToMessages( + entries: readonly DirectChatEntry[], +): ChatMessage[] { + return entries.flatMap((entry): ChatMessage[] => { + if (entry.kind !== 'message') return []; + const text = entry.text ?? ''; + if (!text.trim()) return []; + return [ + { + role: entry.role === 'user' ? 'user' : 'assistant', + text, + runtimeOwned: true, + messageId: entry.itemId, + updatedAt: normalizeDirectTimestamp(entry.at), + }, + ]; + }); +} + +/** 同一 messageId 只留一条:条目版本在前,本地运行期消息只补空位。 */ +export function mergeDirectChatMessages( + leading: readonly ChatMessage[], + trailing: readonly ChatMessage[], +): ChatMessage[] { + const seen = new Set(); + const merged: ChatMessage[] = []; + for (const message of [...leading, ...trailing]) { + if (message.messageId) { + if (seen.has(message.messageId)) continue; + seen.add(message.messageId); + } + merged.push(message); + } + return merged; } diff --git a/apps/ai-game-creator-shell/tests/appSurface/chat-composer.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/chat-composer.suite.ts index 45f59dcd4..57bb32690 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/chat-composer.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/chat-composer.suite.ts @@ -110,7 +110,12 @@ async function openDirectCodexSurface(overrides: InvokeOverrides = {}) { renderLauncherProjectsAt('/?launcher'); pickProjectFromLauncher(DYNAMIC_GAME_PROJECT_PATH); const surface = await screen.findByLabelText('陶泥儿项目对话'); - return { invoke, path: DYNAMIC_GAME_PROJECT_PATH, surface }; + return { + invoke, + path: DYNAMIC_GAME_PROJECT_PATH, + surface, + harness: supervisorHarness, + }; } async function submitDirectTurn( @@ -479,9 +484,11 @@ export function registerChatComposerControlTests() { resolve: (value: string) => void; reject: (error: Error) => void; }> = []; - const { invoke, path, surface } = await openDirectCodexSurface({ + const { invoke, path, surface, harness } = await openDirectCodexSurface({ chat_with_game_creator_direct_codex: () => new Promise((resolve, reject) => { + // 回合真正开跑:生命周期事件由订阅下发,界面据此进入"可终止"。 + harness.emitDirectThreadEvents({ type: 'turn.started' }); pending.push({ resolve, reject }); }), cancel_direct_codex_turn: async () => undefined, @@ -502,7 +509,6 @@ export function registerChatComposerControlTests() { await waitFor(() => { expect(invoke).toHaveBeenCalledWith('cancel_direct_codex_turn', { projectPath: path, - clientTurnId: expect.any(String), }); }); diff --git a/apps/ai-game-creator-shell/tests/appSurface/harness.ts b/apps/ai-game-creator-shell/tests/appSurface/harness.ts index 5dbedafbf..d0e923feb 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/harness.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/harness.ts @@ -744,6 +744,16 @@ function createProjectSupervisorRuntimeHarness({ let designAgentUpdateHandler: | ((event: { payload: Record }) => void) | null = null; + let directThreadNotifyHandler: + | ((event: { payload: { subscriptionId: string } }) => void) + | null = null; + let directThreadSubscriptionId: string | null = null; + let directThreadSubscriptionSequence = 0; + // 未消费的运行态事件队列:`subscribe` 的 bootstrap 与 `consume` 都从这里取, + // 与 Rust 侧"游标在队尾、事件按序下发"的语义一致。 + let pendingDirectThreadEvents: Array> = []; + let directThreadHistoryItems: Array> = []; + let directThreadLastCompletedItemId: string | null = null; const conversationRecord = ( role: 'user' | 'assistant', @@ -938,6 +948,32 @@ function createProjectSupervisorRuntimeHarness({ const states = runtimeMapLoader ? await runtimeMapLoader() : []; return states.map((state) => runtimeResult(state)); } + if (command === 'subscribe_direct_project_thread') { + directThreadSubscriptionSequence += 1; + directThreadSubscriptionId = `direct-thread-${directThreadSubscriptionSequence}`; + const events = pendingDirectThreadEvents; + pendingDirectThreadEvents = []; + return { + subscriptionId: directThreadSubscriptionId, + lastCompletedItemId: directThreadLastCompletedItemId, + events, + }; + } + if (command === 'consume_direct_project_thread') { + if (String(args?.subscriptionId ?? '') !== directThreadSubscriptionId) { + throw new Error('SUBSCRIPTION_EXPIRED'); + } + const events = pendingDirectThreadEvents; + pendingDirectThreadEvents = []; + return { events }; + } + if (command === 'read_direct_project_history_slice') { + return { + items: [...directThreadHistoryItems], + hasMore: false, + firstItemId: null, + }; + } if (command === 'start_game_creator_supervisor_runtime_task') { if (args?.runProfile !== expectedRunProfile) { throw new Error('unexpected Project Supervisor run profile'); @@ -1120,6 +1156,10 @@ function createProjectSupervisorRuntimeHarness({ designAgentUpdateHandler = handler as unknown as typeof designAgentUpdateHandler; } + if (eventName === 'game-creator-direct-thread-notify') { + directThreadNotifyHandler = + handler as unknown as typeof directThreadNotifyHandler; + } return () => { if (runtimeUpdateHandler === handler) { runtimeUpdateHandler = null; @@ -1133,10 +1173,25 @@ function createProjectSupervisorRuntimeHarness({ if (designAgentUpdateHandler === handler) { designAgentUpdateHandler = null; } + if (directThreadNotifyHandler === handler) { + directThreadNotifyHandler = null; + } }; }, ); + /** 追加运行态事件并唤醒订阅者:bootstrap / consume 共用同一份队列。 */ + const emitDirectThreadEvents = ( + ...events: Array> + ) => { + pendingDirectThreadEvents.push(...events); + if (directThreadSubscriptionId) { + directThreadNotifyHandler?.({ + payload: { subscriptionId: directThreadSubscriptionId }, + }); + } + }; + return { invoke, listen, @@ -1229,6 +1284,55 @@ function createProjectSupervisorRuntimeHarness({ }, }); }, + emitDirectThreadEvents, + /** + * 一轮 Direct 回合的标准事件序列:生命周期 → 落盘用户条目 → 助手正文 → 终态。 + * + * 与 Rust 侧一致:消息身份是 `direct-codex:{turnId}:{role}`,工具条目另配 itemId。 + */ + completeDirectThreadTurn({ + turnId, + reply, + prompt = '', + at = 9_000, + status = 'completed', + }: { + turnId: string; + reply: string; + prompt?: string; + at?: number; + status?: string; + }) { + const events: Array> = [{ type: 'turn.started' }]; + if (prompt.trim()) { + events.push({ + type: 'item.completed', + item: { + itemType: 'message', + itemId: `direct-codex:${turnId}:user`, + role: 'user', + text: prompt, + at, + }, + }); + } + events.push({ + type: 'item.completed', + item: { + itemType: 'message', + itemId: `direct-codex:${turnId}:assistant`, + role: 'assistant', + text: reply, + at: at + 1, + }, + }); + directThreadLastCompletedItemId = `direct-codex:${turnId}:assistant`; + events.push({ type: 'turn.completed', status }); + emitDirectThreadEvents(...events); + }, + setDirectThreadHistory(items: Array>) { + directThreadHistoryItems = [...items]; + }, }; } diff --git a/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts index 01e5f217d..98706a90e 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts @@ -1483,6 +1483,11 @@ export function registerHomeProjectCreationTests() { }; } if (command === 'chat_with_game_creator_direct_codex') { + supervisorHarness.completeDirectThreadTurn({ + turnId: String(args?.clientTurnId ?? ''), + prompt: '你好,今天多少号', + reply: '别这么骂自己,具体发生什么了?', + }); return '别这么骂自己,具体发生什么了?'; } return supervisorHarness.invoke(command, args); @@ -1620,6 +1625,11 @@ export function registerHomeProjectCreationTests() { return '角色参考游戏'; } if (command === 'chat_with_game_creator_direct_codex') { + supervisorHarness.completeDirectThreadTurn({ + turnId: String(args?.clientTurnId ?? ''), + prompt: '按这个角色做游戏', + reply: '附件已经进入当前项目。', + }); return '附件已经进入当前项目。'; } return supervisorHarness.invoke(command, args); @@ -2451,7 +2461,11 @@ export function registerHomeProjectCreationTests() { } if (command === 'read_direct_project_history_slice') { expect(args).toEqual({ projectPath, limit: 20 }); - return { items: [...persistedMessages], hasMore: false }; + return { + items: [...persistedMessages], + hasMore: false, + firstItemId: null, + }; } if (command === 'append_local_conversation_message') { throw new Error( @@ -2460,14 +2474,22 @@ export function registerHomeProjectCreationTests() { } if (command === 'chat_with_game_creator_direct_codex') { const clientTurnId = String(args?.clientTurnId ?? ''); - persistedMessages.push(args?.userItem as Record, { - role: 'assistant', - type: 'message', - content: [ - { type: 'output_text', text: 'DIRECT_EXISTING_PROJECT_OK' }, - ], - id: `direct-codex:${clientTurnId}:assistant`, - }); + persistedMessages.push( + { + itemType: 'message', + itemId: `direct-codex:${clientTurnId}:user`, + role: 'user', + text: '继续修改已有项目', + at: 9_000, + }, + { + itemType: 'message', + itemId: `direct-codex:${clientTurnId}:assistant`, + role: 'assistant', + text: 'DIRECT_EXISTING_PROJECT_OK', + at: 9_001, + }, + ); return 'DIRECT_EXISTING_PROJECT_OK'; } throw new Error(`unexpected invoke ${command}`); @@ -2537,7 +2559,11 @@ export function registerHomeProjectCreationTests() { } if (command === 'read_direct_project_history_slice') { expect(args).toEqual({ projectPath, limit: 20 }); - return { items: [...persistedMessages], hasMore: false }; + return { + items: [...persistedMessages], + hasMore: false, + firstItemId: null, + }; } if (command === 'append_local_permission_log') { return {}; @@ -2561,17 +2587,23 @@ export function registerHomeProjectCreationTests() { content: [{ type: 'input_text', text: '生成一个游戏' }], }, }); - persistedMessages.push(args?.userItem as Record, { - id: `direct-codex:${clientTurnId}:assistant`, - type: 'message', - role: 'assistant', - content: [ - { - type: 'output_text', - text: '陶泥儿智能创作 鉴权失败,请重新登录后重试', - }, - ], - }); + // Rust 落盘的原始条目在回读时已经是投影后的形状:身份只有一个 itemId。 + persistedMessages.push( + { + itemType: 'message', + itemId: `direct-codex:${clientTurnId}:user`, + role: 'user', + text: '生成一个游戏', + at: 9_000, + }, + { + itemType: 'message', + itemId: `direct-codex:${clientTurnId}:assistant`, + role: 'assistant', + text: '陶泥儿智能创作 鉴权失败,请重新登录后重试', + at: 9_001, + }, + ); throw new Error('codex-app-server-error:unauthorized'); } throw new Error(`unexpected invoke ${command}`); @@ -2596,21 +2628,18 @@ export function registerHomeProjectCreationTests() { }); expect(persistedMessages).toEqual([ { - id: expect.stringMatching(/^direct-codex:[A-Za-z0-9-]+:user$/), - type: 'message', + itemType: 'message', + itemId: expect.stringMatching(/^direct-codex:[A-Za-z0-9-]+:user$/), role: 'user', - content: [{ type: 'input_text', text: '生成一个游戏' }], + text: '生成一个游戏', + at: 9_000, }, { - id: expect.stringMatching(/^direct-codex:[A-Za-z0-9-]+:assistant$/), - type: 'message', + itemType: 'message', + itemId: expect.stringMatching(/^direct-codex:[A-Za-z0-9-]+:assistant$/), role: 'assistant', - content: [ - { - type: 'output_text', - text: '陶泥儿智能创作 鉴权失败,请重新登录后重试', - }, - ], + text: '陶泥儿智能创作 鉴权失败,请重新登录后重试', + at: 9_001, }, ]); expect(JSON.stringify(persistedMessages)).not.toContain( diff --git a/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts index 2438d7657..70c2d799c 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts @@ -7295,6 +7295,11 @@ export function registerProjectSupervisorSurfaceTests() { return manifest; } if (command === 'chat_with_game_creator_direct_codex') { + supervisorHarness.completeDirectThreadTurn({ + turnId: String(args?.clientTurnId ?? ''), + prompt: '从旧任务状态继续生成,但使用 direct Codex', + reply: 'DIRECT_AFTER_RECONCILIATION_OK', + }); return 'DIRECT_AFTER_RECONCILIATION_OK'; } if (command === 'get_local_game_preview_status') { @@ -7689,7 +7694,7 @@ export function registerProjectSupervisorSurfaceTests() { }); }); - it.skip('opens an existing project without hydrating legacy Supervisor history, then sends consecutive direct Codex turns', async () => { + it('opens an existing project without hydrating legacy Supervisor history, then sends consecutive direct Codex turns', async () => { const projectPath = '/tmp/launcher-supervisor-game'; const manifest = createGameCreationAppManifest( 'local-project-draft', @@ -7698,28 +7703,9 @@ export function registerProjectSupervisorSurfaceTests() { const supervisorHarness = createProjectSupervisorRuntimeHarness({ projectPath, }); - let directTurnUpdateHandler: - | ((event: { payload: Record }) => void) - | null = null; - const listen = vi.fn( - async ( - eventName: string, - handler: (event: { payload: Record }) => void, - ) => { - if (eventName === 'game-creator-direct-turn-update') { - directTurnUpdateHandler = handler; - return () => { - if (directTurnUpdateHandler === handler) { - directTurnUpdateHandler = null; - } - }; - } - return supervisorHarness.listen( - eventName, - handler as Parameters[1], - ); - }, - ); + const firstDirectReply = createDeferred(); + const secondDirectReply = createDeferred(); + let directReplyCount = 0; const invoke = vi.fn( async (command: string, args?: Record) => { if (command === 'get_design_agent_runtime_mode') return null; @@ -7738,20 +7724,28 @@ export function registerProjectSupervisorSurfaceTests() { return manifest; } if (command === 'chat_with_game_creator_direct_codex') { - return `DIRECT_REPLY:${String(args?.prompt ?? '')}`; + directReplyCount += 1; + return directReplyCount === 1 + ? firstDirectReply.promise + : secondDirectReply.promise; + } + if (command === 'get_local_game_preview_status') { + // 进入项目时按内存 registry 核对一次预览活体(有活体才作为会话预览)。 + return { status: 'stopped', url: null, port: null, root: null }; } return supervisorHarness.invoke(command, args); }, ); window.__TAURI__ = { core: { invoke }, - event: { listen }, + event: { listen: supervisorHarness.listen }, }; renderLauncherProjectsAt('/?launcher'); pickProjectFromLauncher(projectPath); const supervisorSurface = await screen.findByLabelText('陶泥儿项目对话'); + // DirectProject 拥有自己的会话身份:打开项目不得触碰老 Supervisor 会话与运行态。 expect( within(supervisorSurface).queryByText('已恢复的项目总控历史'), ).toBeNull(); @@ -7768,10 +7762,8 @@ export function registerProjectSupervisorSurfaceTests() { ), ).toBe(false); expect(screen.queryByLabelText('专业 Agent 协作状态')).toBeNull(); - expect(screen.queryByRole('status', { name: '自动执行' })).toBeNull(); expect(screen.queryByLabelText('子 Agent 状态栏')).toBeNull(); expect(screen.queryByText('严格审批')).toBeNull(); - expect(screen.queryByRole('button', { name: /审批配置/ })).toBeNull(); expect(screen.queryByLabelText('选择 Agent')).toBeNull(); expect(screen.queryByRole('dialog', { name: 'Agent 对话' })).toBeNull(); expect(supervisorHarness.listen).not.toHaveBeenCalledWith( @@ -7782,53 +7774,31 @@ export function registerProjectSupervisorSurfaceTests() { ([command]) => command === 'read_project_permission_policy', ).length; - const firstDirectReply = createDeferred(); - const secondDirectReply = createDeferred(); - let directReplyCount = 0; - invoke.mockImplementation( - async (command: string, args?: Record) => { - if (command === 'chat_with_game_creator_direct_codex') { - directReplyCount += 1; - return directReplyCount === 1 - ? firstDirectReply.promise - : secondDirectReply.promise; - } - return supervisorHarness.invoke(command, args); - }, - ); - await setComposerText( screen.getByLabelText('陶泥儿对话内容'), '先完成正式客户端玩法拆解', ); - fireEvent.click( - within(supervisorSurface).getByRole('button', { name: '发送' }), + fireEvent.submit( + screen + .getByLabelText('陶泥儿对话内容') + .closest('form') as HTMLFormElement, ); - await waitFor(() => - expect( - within( - within(supervisorSurface).getByTestId('agent-tool-call-group'), - ).getByText('需求已接收'), - ).not.toBeNull(), - ); - const directMessageList = - within(supervisorSurface).getByLabelText('陶泥儿消息'); - Object.defineProperties(directMessageList, { - clientHeight: { configurable: true, value: 180 }, - scrollHeight: { configurable: true, value: 640 }, - scrollTop: { configurable: true, value: 0, writable: true }, + await waitFor(() => { + expect(invoke).toHaveBeenCalledWith( + 'chat_with_game_creator_direct_codex', + { + projectPath, + prompt: '先完成正式客户端玩法拆解', + clientTurnId: expect.any(String), + userItem: { + id: expect.stringMatching(/^direct-codex:[A-Za-z0-9-]+:user$/), + type: 'message', + role: 'user', + content: [{ type: 'input_text', text: '先完成正式客户端玩法拆解' }], + }, + }, + ); }); - const waitingProcessCard = within(directMessageList).getByTestId( - 'agent-tool-call-group', - ); - expect(waitingProcessCard.parentElement).toBe(directMessageList); - expect( - within(waitingProcessCard).getByText('正在等待陶泥儿开始'), - ).not.toBeNull(); - expect( - within(waitingProcessCard).getByLabelText('陶泥儿正在执行的内容') - .textContent, - ).toBe('正在等待陶泥儿开始'); const firstDirectCall = invoke.mock.calls.find( ([command]) => command === 'chat_with_game_creator_direct_codex', ); @@ -7837,223 +7807,16 @@ export function registerProjectSupervisorSurfaceTests() { ?.clientTurnId ?? '', ); expect(firstTurnId).not.toBe(''); - expect(firstDirectCall?.[1]).toEqual({ - projectPath, - prompt: '先完成正式客户端玩法拆解', - clientTurnId: firstTurnId, - userItem: { - id: `direct-codex:${firstTurnId}:user`, - type: 'message', - role: 'user', - content: [{ type: 'input_text', text: '先完成正式客户端玩法拆解' }], - }, - }); - await act(async () => { - supervisorHarness.emitProgress('direct', '正在准备安全阶段'); - directTurnUpdateHandler?.({ - payload: { - projectPath, - turnId: firstTurnId, - sequence: 0, - status: 'accepted', - activity: 'request-accepted', - updatedAt: 1000, - }, - }); - directTurnUpdateHandler?.({ - payload: { - projectPath, - turnId: firstTurnId, - sequence: 1, - status: 'running', - activity: 'preparing', - updatedAt: 1200, - }, - }); - }); - const thinkingProcessCard = within(directMessageList).getByTestId( - 'agent-tool-call-group', - ); - expect(within(thinkingProcessCard).getByText('任务执行中')).not.toBeNull(); - expect( - within(thinkingProcessCard).getByLabelText('陶泥儿正在执行的内容') - .textContent, - ).toBe('正在思考中'); - await act(async () => { - directTurnUpdateHandler?.({ - payload: { - projectPath, - turnId: firstTurnId, - sequence: 2, - status: 'running', - activity: 'file-read', - accumulatedText: `正在读取 game/index.html,${'这是一段非常长的执行内容。'.repeat(12)}用于验证执行详情展开状态不会因为后续事件更新而被重置。`, - updatedAt: 1500, - }, - }); - }); - const runningProcessCard = within(directMessageList).getByTestId( - 'agent-tool-call-group', - ); - expect(within(runningProcessCard).getByText('任务执行中')).not.toBeNull(); - expect( - within(runningProcessCard).getByRole('button', { name: '展开' }), - ).not.toBeNull(); - fireEvent.click( - within(runningProcessCard).getByRole('button', { name: '展开' }), - ); - await act(async () => { - directTurnUpdateHandler?.({ - payload: { - projectPath, - turnId: firstTurnId, - sequence: 3, - status: 'streaming', - activity: 'controlled-tool', - accumulatedText: 'DIRECT_STREAM:先完成正式客户端玩法拆解', - updatedAt: 2000, - }, - }); - directTurnUpdateHandler?.({ - payload: { - projectPath, - turnId: firstTurnId, - sequence: 1, - status: 'streaming', - activity: 'file-write', - accumulatedText: '乱序事件不能回退正文', - updatedAt: 1500, - }, - }); - directTurnUpdateHandler?.({ - payload: { - projectPath, - turnId: 'old-direct-turn', - sequence: 99, - status: 'streaming', - activity: 'validation', - accumulatedText: '旧回合不能覆盖正文', - updatedAt: 3000, - }, - }); - directTurnUpdateHandler?.({ - payload: { - projectPath: '/tmp/wrong-direct-project', - turnId: firstTurnId, - sequence: 100, - status: 'streaming', - activity: 'response-finalization', - accumulatedText: '错误项目不能覆盖正文', - updatedAt: 4000, - }, - }); - supervisorHarness.emitProgress('direct', '旧 fallback 不能覆盖精确事件'); - }); - const streamingProcessCard = within(directMessageList).getByTestId( - 'agent-tool-call-group', - ); - expect(within(streamingProcessCard).getByText('回复生成中')).not.toBeNull(); - expect( - within(streamingProcessCard).getByLabelText('陶泥儿正在执行的内容') - .textContent, - ).toBe('正在生成回复'); - expect( - within(supervisorSurface).getByLabelText('陶泥儿实时回复').textContent, - ).toBe('DIRECT_STREAM:先完成正式客户端玩法拆解'); - expect(directMessageList.scrollTop).toBe(640); - expect(within(supervisorSurface).queryByText(/不能覆盖正文/u)).toBeNull(); - expect( - within(supervisorSurface).queryByText('旧 fallback 不能覆盖精确事件'), - ).toBeNull(); - directMessageList.scrollTop = 100; - fireEvent.scroll(directMessageList); - await act(async () => { - directTurnUpdateHandler?.({ - payload: { - projectPath, - turnId: firstTurnId, - sequence: 4, - status: 'running', - accumulatedText: `正在执行 npm test,${'工具输出很长时需要保持展开状态。'.repeat(10)}`, - updatedAt: 4500, - }, - }); - }); - expect(directMessageList.scrollTop).toBe(100); - expect(within(streamingProcessCard).getByText('任务执行中')).not.toBeNull(); - expect( - within(streamingProcessCard).getByText(/正在执行 npm test/u), - ).not.toBeNull(); - await act(async () => { - directTurnUpdateHandler?.({ - payload: { - projectPath, - turnId: firstTurnId, - sequence: 5, - status: 'running', - activity: 'command-exec', - accumulatedText: `正在执行命令:npm run smoke,${'heartbeat 不得覆盖具体命令。'.repeat(12)}`, - updatedAt: 5000, - }, - }); - directTurnUpdateHandler?.({ - payload: { - projectPath, - turnId: firstTurnId, - sequence: 6, - status: 'running', - activity: 'command-exec', - updatedAt: 5100, - }, - }); - }); - expect( - within(streamingProcessCard).getByText(/heartbeat 不得覆盖具体命令/u), - ).not.toBeNull(); - expect(within(streamingProcessCard).queryByText('正在执行命令')).toBeNull(); - await act(async () => { - directTurnUpdateHandler?.({ - payload: { - projectPath, - turnId: firstTurnId, - sequence: 7, - status: 'running', - activity: 'controlled-tool', - accumulatedText: `正在写入文件:game/index.html,${'写入详情需要保持展开状态。'.repeat(12)}`, - updatedAt: 5200, - }, - }); - directTurnUpdateHandler?.({ - payload: { - projectPath, - turnId: firstTurnId, - sequence: 8, - status: 'running', - activity: 'controlled-tool', - updatedAt: 5300, - }, - }); - }); - expect( - within(streamingProcessCard).getByText( - /正在写入文件:game\/index\.html/u, - ), - ).not.toBeNull(); - expect(within(streamingProcessCard).queryByText('正在调用工具')).toBeNull(); - expect( - within(supervisorSurface).getByLabelText('陶泥儿实时回复').textContent, - ).toBe('DIRECT_STREAM:先完成正式客户端玩法拆解'); - expect( - ( - within(streamingProcessCard).getByRole('button', { - name: '收起', - }) as HTMLButtonElement - ).getAttribute('aria-expanded'), - ).toBe('true'); await act(async () => { firstDirectReply.resolve('DIRECT_REPLY:先完成正式客户端玩法拆解'); + supervisorHarness.completeDirectThreadTurn({ + turnId: firstTurnId, + prompt: '先完成正式客户端玩法拆解', + reply: 'DIRECT_REPLY:先完成正式客户端玩法拆解', + }); }); + // 用户条目由落盘事件回填:乐观气泡与事件条目同身份,只渲染一次。 await waitFor(() => { expect( within(supervisorSurface).getAllByText( @@ -8061,16 +7824,8 @@ export function registerProjectSupervisorSurfaceTests() { ), ).toHaveLength(1); expect( - within(supervisorSurface).queryByLabelText('陶泥儿正在执行的内容'), - ).toBeNull(); - expect( - within(supervisorSurface).queryByText( - 'DIRECT_STREAM:先完成正式客户端玩法拆解', - ), - ).toBeNull(); - expect( - within(directMessageList).queryByTestId('agent-tool-call-group'), - ).toBeNull(); + within(supervisorSurface).getAllByText('先完成正式客户端玩法拆解'), + ).toHaveLength(1); }); await waitFor(() => { expect( @@ -8086,8 +7841,10 @@ export function registerProjectSupervisorSurfaceTests() { screen.getByLabelText('陶泥儿对话内容'), '补充:优先复用现有素材', ); - fireEvent.click( - within(supervisorSurface).getByRole('button', { name: '发送' }), + fireEvent.submit( + screen + .getByLabelText('陶泥儿对话内容') + .closest('form') as HTMLFormElement, ); await waitFor(() => { expect(invoke).toHaveBeenCalledWith( @@ -8113,68 +7870,12 @@ export function registerProjectSupervisorSurfaceTests() { ?.clientTurnId ?? '', ); expect(secondTurnId).not.toBe(firstTurnId); - await act(async () => { - directTurnUpdateHandler?.({ - payload: { - projectPath, - turnId: secondTurnId, - sequence: 0, - status: 'streaming', - activity: 'not-a-public-activity', - accumulatedText: '即将失败的临时正文', - updatedAt: 5000, - }, - }); - }); - const failedTurnProcessCard = within(directMessageList).getByTestId( - 'agent-tool-call-group', - ); - expect( - within(failedTurnProcessCard).getByText('回复生成中'), - ).not.toBeNull(); - expect( - within(failedTurnProcessCard).getByLabelText('陶泥儿正在执行的内容') - .textContent, - ).toBe('正在生成回复'); - expect( - within(supervisorSurface).getByLabelText('陶泥儿实时回复').textContent, - ).toBe('即将失败的临时正文'); - await act(async () => { - directTurnUpdateHandler?.({ - payload: { - projectPath, - turnId: secondTurnId, - sequence: 1, - status: 'failed', - activity: 'none', - accumulatedText: '失败事件不得保留这段正文', - updatedAt: 5100, - }, - }); - }); - expect( - within(supervisorSurface).queryByLabelText('陶泥儿实时回复'), - ).toBeNull(); - const failedStatusProcessCard = within(directMessageList).getByTestId( - 'agent-tool-call-group', - ); - expect( - within(failedStatusProcessCard).getByText('处理失败'), - ).not.toBeNull(); - expect( - within(failedStatusProcessCard).getByLabelText('陶泥儿正在执行的内容') - .textContent, - ).toBe('正在记录失败原因'); + + // 失败回合:运行期说明只留在界面,不进历史;输入盒必须回到可用态。 await act(async () => { secondDirectReply.reject(new Error('模拟 direct 失败')); }); await waitFor(() => { - expect( - within(supervisorSurface).queryByLabelText('陶泥儿实时回复'), - ).toBeNull(); - expect( - within(directMessageList).queryByTestId('agent-tool-call-group'), - ).toBeNull(); expect( ( within(supervisorSurface).getByRole('button', { @@ -8182,6 +7883,9 @@ export function registerProjectSupervisorSurfaceTests() { }) as HTMLButtonElement ).disabled, ).toBe(false); + expect( + within(supervisorSurface).queryByRole('button', { name: '终止' }), + ).toBeNull(); }); expect( invoke.mock.calls.filter( @@ -8198,8 +7902,7 @@ export function registerProjectSupervisorSurfaceTests() { ([command]) => command === 'chat_with_game_creator_direct_codex', ), ).toHaveLength(2); - // 进入项目时按内存 registry 核对一次预览活体(有活体才作为会话预览)。 - // 这里只核验、不启动:开始预览仍然只能由用户动作或 Agent 触发。 + // 首次进入项目时核验一次预览活体;开始预览仍然只能由用户动作或 Agent 触发。 expect( invoke.mock.calls.filter( ([command]) => command === 'get_local_game_preview_status', @@ -8217,7 +7920,7 @@ export function registerProjectSupervisorSurfaceTests() { ).toHaveLength(policyReadCountBeforeChat + 1); }); - it.skip('renders one collapsed tool-call group per turn from the direct turn event and keeps it after the turn completes', async () => { + it('renders one collapsed tool-call group per turn from thread events and keeps it after the turn completes', async () => { const projectPath = '/tmp/launcher-tool-call-card-game'; const manifest = createGameCreationAppManifest( 'local-project-draft', @@ -8226,28 +7929,23 @@ export function registerProjectSupervisorSurfaceTests() { const supervisorHarness = createProjectSupervisorRuntimeHarness({ projectPath, }); - let directTurnUpdateHandler: - | ((event: { payload: Record }) => void) - | null = null; - const listen = vi.fn( - async ( - eventName: string, - handler: (event: { payload: Record }) => void, - ) => { - if (eventName === 'game-creator-direct-turn-update') { - directTurnUpdateHandler = handler; - return () => { - if (directTurnUpdateHandler === handler) { - directTurnUpdateHandler = null; - } - }; - } - return supervisorHarness.listen( - eventName, - handler as Parameters[1], - ); + // 打开项目时已有上一轮对话:这一轮的块必须落在消息流末尾,而不是被锚到别人头上。 + supervisorHarness.setDirectThreadHistory([ + { + itemType: 'message', + itemId: 'direct-codex:turn-existing:user', + role: 'user', + text: '先做一个主菜单', + at: 900, }, - ); + { + itemType: 'message', + itemId: 'direct-codex:turn-existing:assistant', + role: 'assistant', + text: '上一轮已完成', + at: 1000, + }, + ]); const directReply = createDeferred(); const invoke = vi.fn( async (command: string, args?: Record) => { @@ -8269,35 +7967,6 @@ export function registerProjectSupervisorSurfaceTests() { 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 }; } @@ -8306,7 +7975,7 @@ export function registerProjectSupervisorSurfaceTests() { ); window.__TAURI__ = { core: { invoke }, - event: { listen }, + event: { listen: supervisorHarness.listen }, }; renderLauncherProjectsAt('/?launcher'); @@ -8342,31 +8011,33 @@ export function registerProjectSupervisorSurfaceTests() { ); expect(clientTurnId).not.toBe(''); - // 实时增量:同一条 `item-command` 从 running 走到 completed,`item-file` 由下一条事件带出。 + // 运行中:命令开始执行。同一 itemId 的后续事件就地更新,不新起一张卡。 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, - }, - ], + supervisorHarness.emitDirectThreadEvents( + { type: 'turn.started' }, + { + type: 'item.completed', + item: { + itemType: 'message', + itemId: `direct-codex:${clientTurnId}:user`, + role: 'user', + text: '做一个跑酷游戏', + at: 995, + }, }, - }); + { + type: 'item.started', + item: { + itemType: 'commandExecution', + itemId: 'item-command', + command: 'npm run build', + output: null, + status: 'inProgress', + exitCode: null, + at: 1000, + }, + }, + ); }); // 一回合一个折叠块(默认折叠):块头是按钮,正文用 `hidden` 收起。 const runningGroups = within(supervisorSurface).getAllByTestId( @@ -8385,52 +8056,49 @@ export function registerProjectSupervisorSurfaceTests() { ); expect(runningBody).not.toBeNull(); expect(runningBody?.hasAttribute('hidden')).toBe(true); - // 此刻只应看到本回合的那一条命令:不在对话里、也不属于当前回合的 turnId - // 会在 App 侧被过滤掉,不会漂在消息流里。 expect(runningHead.textContent).toContain('1 个命令'); + // 命令完成 + 文件变更:同一回合两块合成一个块,顺序按 startedAt。 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, - }, - ], + supervisorHarness.emitDirectThreadEvents( + { + type: 'item.completed', + item: { + itemType: 'commandExecution', + itemId: 'item-command', + command: 'npm run build', + output: 'build ok', + status: 'completed', + exitCode: 0, + at: 1100, + }, }, - }); + { + type: 'item.started', + item: { + itemType: 'fileChange', + itemId: 'item-file', + changes: [ + { path: 'game/src/hero.ts', kind: 'update' }, + { path: 'game/src/hero.ts', kind: 'delete' }, + ], + at: 1050, + }, + }, + { + type: 'item.completed', + item: { + itemType: 'fileChange', + itemId: 'item-file', + changes: [ + { path: 'game/src/hero.ts', kind: 'update' }, + { path: 'game/src/hero.ts', kind: 'delete' }, + ], + at: 1100, + }, + }, + ); }); - // 同一回合的两条工具只产生一个块;同回合内按 startedAt 升序。 await waitFor(() => { expect( within(supervisorSurface).getAllByTestId('agent-tool-call-group'), @@ -8439,18 +8107,11 @@ export function registerProjectSupervisorSurfaceTests() { 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 个文件变更'); // 块头右侧是总用时(min(startedAt) → max(updatedAt)),并在 data-* 上暴露原始毫秒。 expect(group.getAttribute('data-duration-ms')).toBe('100'); - expect(groupHead.textContent).toContain('用时 1秒'); - // 同一回合的用户消息时间(App 提交时写入)→ 显示「发送 → 结束」。 - expect( - groupHead.querySelector('.agent-tool-call-group-time')?.textContent, - ).toMatch(/^\d{2}:\d{2}:\d{2} → \d{2}:\d{2}:\d{2}$/); - // 实时回合的块落在消息流末尾:该回合 assistant 消息还没落盘, - // 所以它在上一条 assistant 消息之后,而不是被锚到别人头上。 + // 实时回合的块落在消息流末尾:该回合还没有正文,不依赖任何锚点消息。 const liveChildren = Array.from((messageList as HTMLElement).children); expect( liveChildren.findIndex((node) => node === group), @@ -8461,29 +8122,23 @@ export function registerProjectSupervisorSurfaceTests() { 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', ]); - // 同一 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(); + expect( + within(commandRow).getAllByText('npm run build').length, + ).toBeGreaterThan(0); + expect( + within(fileRow).getAllByText('game/src/hero.ts').length, + ).toBeGreaterThan(0); // 每行右侧显示该工具自己的耗时(`startedAt` → `updatedAt`)。 expect(commandRow.getAttribute('data-duration-ms')).toBe('100'); - expect(within(commandRow).getByText('0.1s')).not.toBeNull(); expect(fileRow.getAttribute('data-duration-ms')).toBe('50'); - expect(within(fileRow).getByText('0.1s')).not.toBeNull(); // 行可二级展开:默认折叠,展开后看到命令 / 路径 + 变更类型 / 输出。 const commandRowHead = within(commandRow).getByRole('button'); @@ -8517,10 +8172,14 @@ export function registerProjectSupervisorSurfaceTests() { 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:做一个跑酷游戏'); + supervisorHarness.completeDirectThreadTurn({ + turnId: clientTurnId, + reply: 'DIRECT_REPLY:做一个跑酷游戏', + at: 1500, + }); }); await waitFor(() => { expect( @@ -8536,24 +8195,12 @@ export function registerProjectSupervisorSurfaceTests() { const settledGroups = within(supervisorSurface).getAllByTestId( 'agent-tool-call-group', ); - const children = Array.from((messageList as HTMLElement).children); - const assistantIndex = children.findIndex( - (node) => - node.classList.contains('message--assistant') && - node.textContent?.includes('DIRECT_REPLY:做一个跑酷游戏'), + const processSection = + within(supervisorSurface).getByTestId('turn-process'); + expect(processSection.contains(settledGroups[0] ?? null)).toBe(true); + expect(processSection.textContent).not.toContain( + 'DIRECT_REPLY:做一个跑酷游戏', ); - expect(assistantIndex).toBeGreaterThanOrEqual(0); - const settledGroupIndex = children.findIndex( - (node) => node === settledGroups[0], - ); - expect(settledGroupIndex).toBeGreaterThanOrEqual(0); - // 块在该回合的 user 消息与 assistant 消息之间:紧邻 assistant 消息之前。 - expect(settledGroupIndex).toBeLessThan(assistantIndex); - expect(settledGroupIndex).toBe(assistantIndex - 1); - expect( - children[settledGroupIndex - 1]?.classList.contains('message--user'), - ).toBe(true); - // 重新挂载后的块回到默认折叠;键盘可达:块头就是按钮(Tab 可达), // Enter/Space 触发的 click 同步 aria-expanded 与 hidden。 const settledGroup = settledGroups[0] as HTMLElement; @@ -8582,8 +8229,7 @@ export function registerProjectSupervisorSurfaceTests() { expect(settledBody?.hasAttribute('hidden')).toBe(true); }); }); - - it.skip('reads the persisted tool-call history whenever a project is opened', async () => { + it('renders persisted tool cards from the history slice whenever a project is opened', async () => { const projectPath = '/tmp/launcher-tool-call-persisted-game'; const manifest = createGameCreationAppManifest( 'local-project-draft', @@ -8592,7 +8238,42 @@ export function registerProjectSupervisorSurfaceTests() { const supervisorHarness = createProjectSupervisorRuntimeHarness({ projectPath, }); - let readToolCallCount = 0; + // 历史切片的条目已经是投影形状:工具的调用与输出共用同一个 itemId。 + supervisorHarness.setDirectThreadHistory([ + { + itemType: 'message', + itemId: 'direct-codex:turn-persisted:user', + role: 'user', + text: '做一个小球弹跳游戏', + at: 1000, + }, + { + itemType: 'function_call', + itemId: 'persisted-command', + name: 'exec_command', + arguments: 'npm run build', + at: 1000, + }, + { + itemType: 'function_call_output', + itemId: 'persisted-command', + output: 'built in 500ms', + at: 1500, + }, + { + itemType: 'fileChange', + itemId: 'persisted-file', + changes: [{ path: 'game/src/hero.ts', kind: 'add' }], + at: 1500, + }, + { + itemType: 'message', + itemId: 'direct-codex:turn-persisted:assistant', + role: 'assistant', + text: '上一轮的答复', + at: 2000, + }, + ]); const invoke = vi.fn( async (command: string, args?: Record) => { if (command === 'get_design_agent_runtime_mode') return null; @@ -8610,62 +8291,6 @@ export function registerProjectSupervisorSurfaceTests() { if (command === 'get_local_game_manifest') { return manifest; } - if (command === 'read_direct_project_conversation') { - 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-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 [ - { - 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 }; } @@ -8681,15 +8306,15 @@ export function registerProjectSupervisorSurfaceTests() { pickProjectFromLauncher(projectPath); const supervisorSurface = await screen.findByLabelText('陶泥儿项目对话'); - // 打开项目时必须回读一次工具调用历史:这是「刷新后卡片仍在」的数据来源。 - await waitFor(() => { - expect(readToolCallCount).toBeGreaterThanOrEqual(1); - }); - expect(invoke).toHaveBeenCalledWith('read_direct_tool_calls', { + // 打开项目时读一屏历史:这是「刷新后卡片仍在」的数据来源,工具卡片由前端投影。 + expect(invoke).toHaveBeenCalledWith('read_direct_project_history_slice', { projectPath, + limit: 20, }); - // 回读到的工具调用挂在自己回合的 assistant 消息**之前**(工具在上、答复在下), - // 默认折叠、展开后行数 = 回读到的工具数。 + expect( + await within(supervisorSurface).findByText('做一个小球弹跳游戏'), + ).not.toBeNull(); + // 工具在「执行过程」折叠区里,最终回复在折叠区外:默认折叠、展开后行数 = 工具数。 const persistedGroups = await within(supervisorSurface).findAllByTestId( 'agent-tool-call-group', ); @@ -8702,38 +8327,115 @@ export function registerProjectSupervisorSurfaceTests() { expect(persistedHead.textContent).toContain( '已执行 1 个命令、1 个文件变更', ); - // 回读回来的时间戳一样能算总用时与「发送 → 结束」。 - expect(persistedGroup.getAttribute('data-duration-ms')).toBe('1000'); - expect(persistedHead.textContent).toContain('用时 1秒'); - expect( - persistedHead.querySelector('.agent-tool-call-group-time')?.textContent, - ).toMatch(/^\d{2}:\d{2} → \d{2}:\d{2}$/); - 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); + const processSection = + within(supervisorSurface).getByTestId('turn-process'); + expect(processSection.contains(persistedGroup)).toBe(true); + expect(processSection.textContent).not.toContain('上一轮的答复'); fireEvent.click(persistedHead); const persistedRows = within(persistedGroup).getAllByTestId( 'agent-tool-call-row', ); expect(persistedRows).toHaveLength(2); - // 每行右侧是该工具自己的耗时(0.5s / 0.5s)。 - expect(persistedRows[0]?.getAttribute('data-duration-ms')).toBe('500'); + // 行标题与展开后的命令详情都写着这条命令,这里只要求它确实出现在这一行里。 expect( - within(persistedRows[0] as HTMLElement).getByText('0.5s'), - ).not.toBeNull(); + within(persistedRows[0] as HTMLElement).getAllByText('npm run build'), + ).toHaveLength(2); }); - it('renders the running turn tool-call group in a fresh chat that has no anchored assistant message', async () => { + it('drains a notify that lands before the subscribe receipt so the running turn is not stranded', async () => { + // 真实竞态:`subscribe` 在 Rust 侧已经注册好 subscriber 并开始通知,但前端还没拿到 + // subscriptionId。此时的通知不能白丢——拿到回执后必须补一次 consume,否则整个回合会卡在队列里。 + const projectPath = '/tmp/launcher-notify-before-subscribe-game'; + const manifest = createGameCreationAppManifest( + 'local-project-draft', + 'launcher-notify-before-subscribe-game', + ); + const supervisorHarness = createProjectSupervisorRuntimeHarness({ + projectPath, + }); + // 订阅回执被挂住:Rust 侧已经建好订阅(因此事件会带通知),前端还在等回执。 + const subscribeReceipt = createDeferred(); + const invoke = vi.fn( + async (command: string, args?: Record) => { + 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-notify-before-subscribe-game', + recentRunStatus: null, + recentRunStopReason: null, + }; + } + if (command === 'get_local_game_manifest') { + return manifest; + } + if (command === 'get_local_game_preview_status') { + return { status: 'stopped', url: null, port: null, root: null }; + } + if (command === 'subscribe_direct_project_thread') { + const result = await supervisorHarness.invoke(command, args); + await subscribeReceipt.promise; + return result; + } + 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(invoke).toHaveBeenCalledWith('subscribe_direct_project_thread', { + projectPath, + }); + }); + + // 回执还没回到前端,整轮事件已经到了:通知此刻拿不到 subscriptionId,只能记账。 + await act(async () => { + supervisorHarness.emitDirectThreadEvents( + { type: 'turn.started' }, + { + type: 'item.completed', + item: { + itemType: 'message', + itemId: 'direct-codex:notify-window:assistant', + role: 'assistant', + text: '回执前就到达的回复', + at: 900, + }, + }, + { type: 'turn.completed', status: 'completed' }, + ); + }); + expect( + within(supervisorSurface).queryByText('回执前就到达的回复'), + ).toBeNull(); + + // 回执到达:前端补一次 consume,把队列里的事件取回来。 + await act(async () => { + subscribeReceipt.resolve(); + }); + expect( + await within(supervisorSurface).findByText('回执前就到达的回复'), + ).not.toBeNull(); + // 补取之后不再有残留的忙碌态。 + expect( + within(supervisorSurface).queryByRole('button', { name: '终止' }), + ).toBeNull(); + }); + + it('renders the running turn tool-call group in a fresh chat from the thread subscription', async () => { // 空对话首轮:历史为空,消息列表里只有 App 落的默认问候(`描述你的想法,或 @ 引用素材`,没有 messageId)。 - // 这种回合没有任何「可锚」的 assistant 消息,块必须兜底落在消息列表末尾, - // 而不是等到回合结束、assistant 消息带上 messageId 之后才出现。 + // 运行中的回合没有任何落盘正文,工具块必须靠订阅事件直接出现在消息列表末尾, + // 而不是等到回合结束、历史切片回来之后才出现。 const projectPath = '/tmp/launcher-tool-call-fresh-turn-game'; const manifest = createGameCreationAppManifest( 'local-project-draft', @@ -8742,25 +8444,6 @@ export function registerProjectSupervisorSurfaceTests() { const supervisorHarness = createProjectSupervisorRuntimeHarness({ projectPath, }); - let directTurnUpdateHandler: - | ((event: { payload: Record }) => void) - | null = null; - const listen = vi.fn( - async ( - eventName: string, - handler: (event: { payload: Record }) => void, - ) => { - if (eventName === 'game-creator-direct-turn-update') { - directTurnUpdateHandler = handler; - return () => { - if (directTurnUpdateHandler === handler) { - directTurnUpdateHandler = null; - } - }; - } - return supervisorHarness.listen(eventName, handler); - }, - ); const directReply = createDeferred(); const invoke = vi.fn( async (command: string, args?: Record) => { @@ -8782,18 +8465,6 @@ export function registerProjectSupervisorSurfaceTests() { if (command === 'chat_with_game_creator_direct_codex') { return directReply.promise; } - if (command === 'read_direct_tool_calls') { - return []; - } - if (command === 'read_direct_project_conversation') { - // 真正的空对话:没有任何历史消息,App 会落一条没有 messageId 的默认问候。 - return { - path: `${projectPath}/.agent/conversations/project.jsonl`, - agentId: null, - sessionId: null, - messages: [], - }; - } if (command === 'get_local_game_preview_status') { return { status: 'stopped', url: null, port: null, root: null }; } @@ -8802,7 +8473,7 @@ export function registerProjectSupervisorSurfaceTests() { ); window.__TAURI__ = { core: { invoke }, - event: { listen }, + event: { listen: supervisorHarness.listen }, }; renderLauncherProjectsAt('/?launcher'); @@ -8842,29 +8513,32 @@ export function registerProjectSupervisorSurfaceTests() { expect(clientTurnId).not.toBe(''); await act(async () => { - directTurnUpdateHandler?.({ - payload: { - projectPath, - turnId: clientTurnId, - sequence: 1, - status: 'running', - activity: 'command-exec', - updatedAt: 1400, - toolCalls: [ - { - schemaVersion: 'agc-tool-call.v1', - id: 'fresh-turn-command', - kind: 'command', - title: '执行命令', - summary: 'npm run build', - status: 'running', - detail: { command: 'npm run build' }, - startedAt: 1000, - updatedAt: 1400, - }, - ], + // 订阅事件就是运行态:生命周期 + 用户条目 + 命令开始执行。 + supervisorHarness.emitDirectThreadEvents( + { type: 'turn.started' }, + { + type: 'item.completed', + item: { + itemType: 'message', + itemId: `direct-codex:${clientTurnId}:user`, + role: 'user', + text: '做一个跑酷游戏', + at: 900, + }, }, - }); + { + type: 'item.started', + item: { + itemType: 'commandExecution', + itemId: 'fresh-turn-command', + command: 'npm run build', + output: null, + status: 'inProgress', + exitCode: null, + at: 1000, + }, + }, + ); }); // 回合进行中:块已经可见,且落在消息列表末尾(默认问候之后),不依赖任何带 messageId 的 assistant 消息。 @@ -8874,7 +8548,6 @@ export function registerProjectSupervisorSurfaceTests() { expect(runningGroups).toHaveLength(1); const runningGroup = runningGroups[0] as HTMLElement; expect(runningGroup.getAttribute('data-status')).toBe('running'); - expect(runningGroup.getAttribute('data-duration-ms')).toBe('400'); const runningHead = within(runningGroup).getByTestId( 'agent-tool-call-group-head', ); @@ -8890,14 +8563,51 @@ export function registerProjectSupervisorSurfaceTests() { ); expect(runningRows).toHaveLength(1); expect(runningRows[0]?.getAttribute('data-kind')).toBe('command'); - expect(runningRows[0]?.getAttribute('data-duration-ms')).toBe('400'); + + // 同一条命令完成:同一个 itemId 就地更新,不新起一张卡,耗时按 startedAt → updatedAt 计算。 + await act(async () => { + supervisorHarness.emitDirectThreadEvents({ + type: 'item.completed', + item: { + itemType: 'commandExecution', + itemId: 'fresh-turn-command', + command: 'npm run build', + output: 'built in 400ms', + status: 'completed', + exitCode: 0, + at: 1400, + }, + }); + }); + await waitFor(() => { + expect( + within(supervisorSurface).getAllByTestId('agent-tool-call-group'), + ).toHaveLength(1); + }); + const settledRunningGroup = within(supervisorSurface).getAllByTestId( + 'agent-tool-call-group', + )[0] as HTMLElement; + expect(settledRunningGroup.getAttribute('data-duration-ms')).toBe('400'); + fireEvent.click( + within(settledRunningGroup).getByTestId('agent-tool-call-group-head'), + ); + const settledRunningRows = within(settledRunningGroup).getAllByTestId( + 'agent-tool-call-row', + ); + expect(settledRunningRows).toHaveLength(1); + expect(settledRunningRows[0]?.getAttribute('data-duration-ms')).toBe('400'); expect( - within(runningRows[0] as HTMLElement).getByText('0.4s'), + within(settledRunningRows[0] as HTMLElement).getByText('0.4s'), ).not.toBeNull(); - // 回合结束:assistant 消息落盘后块回到它之前,且**只有一份**(末尾兜底不留下重复块)。 + // 回合结束:assistant 正文进历史,工具块收进「执行过程」折叠区,且**只有一份**。 await act(async () => { directReply.resolve('DIRECT_REPLY:空对话首轮'); + supervisorHarness.completeDirectThreadTurn({ + turnId: clientTurnId, + reply: 'DIRECT_REPLY:空对话首轮', + at: 1500, + }); }); await waitFor(() => { expect( @@ -8928,9 +8638,8 @@ export function registerProjectSupervisorSurfaceTests() { chatProjectAssets: [], composerRef: createRef(), directCodex: true, - directStatus: null, - directProcessDetail: '', - directProcessKey: '', + directEntries: [], + directTurnRunning: false, hiddenConversationCount: 0, messagesRef: createRef(), needsUserInput: false, diff --git a/apps/ai-game-creator-shell/tests/directThreadEvents.test.ts b/apps/ai-game-creator-shell/tests/directThreadEvents.test.ts deleted file mode 100644 index 6229b26b5..000000000 --- a/apps/ai-game-creator-shell/tests/directThreadEvents.test.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { describe, expect, it } from 'vitest'; - -import { - directThreadHistoryItemsToMessages, - isDirectTurnInProgress, -} from '../src/features/project-workspace/directThreadEvents'; -import type { DirectThreadItem } from '../src/features/project-workspace/directThreadItemProjection'; - -describe('Direct 回合状态与历史时间', () => { - it('终态和空状态不恢复为活动回合', () => { - for (const status of [ - 'completed', - 'failed', - 'interrupted', - null, - undefined, - ]) { - expect(isDirectTurnInProgress(status)).toBe(false); - } - for (const status of ['accepted', 'running', 'streaming', 'finalizing']) { - expect(isDirectTurnInProgress(status)).toBe(true); - } - }); - it('条目时间来自搬运后的 at,前端不自己补造时间', () => { - const items = [ - { - itemId: 'direct-codex:turn:user', - itemType: 'message', - role: 'user', - text: '帮我修改游戏', - at: 1_800_000_000_001, - }, - { - itemId: 'direct-codex:turn:assistant', - itemType: 'message', - role: 'assistant', - text: '好的', - at: 0, - }, - ] satisfies DirectThreadItem[]; - expect(directThreadHistoryItemsToMessages(items)[0]?.updatedAt).toBe( - 1_800_000_000_001, - ); - // Rust 拿不到条目时间时给 0,前端不得用"当前时间"补造。 - expect(directThreadHistoryItemsToMessages(items)[1]?.updatedAt).toBe(0); - expect(items[0]).not.toHaveProperty('recordedAt'); - }); -}); diff --git a/apps/ai-game-creator-shell/tests/directTurnPresentation.test.ts b/apps/ai-game-creator-shell/tests/directTurnPresentation.test.ts index f47f263a2..a887f02c2 100644 --- a/apps/ai-game-creator-shell/tests/directTurnPresentation.test.ts +++ b/apps/ai-game-creator-shell/tests/directTurnPresentation.test.ts @@ -1,236 +1,220 @@ import { describe, expect, it } from 'vitest'; -import type { - ChatMessage, - GameCreatorDirectToolCall, - TurnStreamItem, -} from '../src/app/types'; +import type { ChatMessage } from '../src/app/types'; +import type { DirectChatEntry } from '../src/features/project-workspace/directThreadChat'; import { - buildDirectTurnPresentations, + buildDirectChatTurns, + directChatEntriesToMessages, directMessageTimestamp, + mergeDirectChatMessages, normalizeDirectTimestamp, - splitDirectTurnContent, } from '../src/features/project-workspace/directTurnPresentation'; -const user = (turn: string): ChatMessage => ({ +const userEntry = ( + itemId: string, + at = 1_800_000_000_000, +): DirectChatEntry => ({ + itemId, + kind: 'message', role: 'user', - text: '只读检查', - messageId: `direct-codex:${turn}:user`, + text: `问题 ${itemId}`, + at, }); -const assistant = (id: string, text = '完整回复'): ChatMessage => ({ + +const assistantEntry = ( + itemId: string, + text: string, + at = 1_800_000_001_000, +): DirectChatEntry => ({ + itemId, + kind: 'message', role: 'assistant', text, - messageId: id, + at, }); -const text = (turnId: string, id: string, seq = 1): TurnStreamItem => ({ - schemaVersion: 'agc-turn-stream.v1', - kind: 'text', - turnId, - id: `text:${turnId}:${id}`, - text: '前缀', - seq, - at: 1_800_000_000_000, - updatedAt: 1_800_000_000_001, -}); -const tool = (turnId: string): GameCreatorDirectToolCall => ({ - schemaVersion: 'agc-tool-call.v1', - id: 'call', - turnId, - kind: 'mcp_tool', - title: '调用工具', - summary: '读取', - status: 'completed', - detail: { command: '{"path":"file"}', output: '内容', changes: [] }, - startedAt: 1_800_000_000_000, - updatedAt: 1_800_000_000_001, -}); -const build = ( - messages: ChatMessage[], - items: TurnStreamItem[], - options: Partial[0]> = {}, -) => - buildDirectTurnPresentations({ - messages, - visibleMessages: messages, - items, - calls: [], - transientReply: '', - ...options, - }); -describe('DirectProject 回合唯一呈现', () => { +const toolEntry = ( + itemId: string, + at = 1_800_000_000_500, +): DirectChatEntry => ({ + itemId, + kind: 'tool', + role: null, + text: null, + at, + toolCall: { + schemaVersion: 'agc-tool-call.v1', + id: itemId, + kind: 'command', + title: '执行命令', + summary: 'npm run build', + status: 'completed', + detail: { command: 'npm run build' }, + startedAt: at, + updatedAt: at, + }, +}); + +const reasoningEntry = ( + itemId: string, + text = '先看目录', + at = 1_800_000_000_200, +): DirectChatEntry => ({ + itemId, + kind: 'reasoning', + role: null, + text, + at, +}); + +const localUser = (text: string, messageId?: string): ChatMessage => ({ + role: 'user', + text, + ...(messageId ? { messageId } : {}), + updatedAt: 1_800_000_002_000, +}); + +const localNotice = (text: string, messageId?: string): ChatMessage => ({ + role: 'assistant', + text, + ...(messageId ? { messageId } : {}), + updatedAt: 1_800_000_002_500, +}); + +describe('DirectProject 聊天分区', () => { it('把 Unix 秒时间戳归一化为毫秒,已有毫秒值保持不变', () => { expect(normalizeDirectTimestamp(1_800_000_000)).toBe(1_800_000_000_000); expect(normalizeDirectTimestamp(1_800_000_000_000)).toBe(1_800_000_000_000); - expect(directMessageTimestamp(1_800_000_000)).toBe(1_800_000_000_000); - }); - - it('历史仍有未加载切片时,不把那些回合的工具流追加到当前页末尾', () => { - const rows = build( - [user('one')], - [text('old', 'raw-old'), text('one', 'raw')], - { - hasUnloadedHistory: true, - }, - ); - expect(rows.map((row) => row.turnId)).toEqual(['one']); - const active = build([], [text('live', 'raw')], { - hasUnloadedHistory: true, - activeTurnId: 'live', - }); - expect(active.map((row) => row.turnId)).toEqual(['live']); - }); - it('尚未落盘用户的实时回合也只产生一个 owner,不另建 live 与 unmapped 出口', () => { - const rows = build([], [text('one', 'raw')], { - activeTurnId: 'one', - transientReply: '同一份累计回复', - calls: [tool('one')], - }); - expect(rows).toHaveLength(1); - expect(rows[0].source).toBe('stream'); - expect(rows[0].transientReply).toBe(''); - expect(rows[0].calls).toHaveLength(1); - }); - it('同一身份用户重复快照不增加回合,不丢用户正文', () => { - const rows = build([user('one'), user('one')], [text('one', 'raw')]); - expect(rows).toHaveLength(1); - expect(rows[0].messages).toHaveLength(1); - expect(rows[0].messages[0].role).toBe('user'); - }); - it('用原始 item 身份补齐旧前缀,最终合成消息不另占正文出口', () => { - const rows = build( - [user('one'), assistant('raw'), assistant('direct-codex:one:assistant')], - [text('one', 'raw')], - ); - expect(rows).toHaveLength(1); - expect(rows[0].source).toBe('stream'); - expect(rows[0].items).toHaveLength(1); - expect(rows[0].items[0].text).toBe('完整回复'); - }); - it('先关联完整历史再分页,首条可见 assistant 不会失去用户归属', () => { - const messages = [ - user('old'), - assistant('old-raw'), - user('one'), - assistant('raw'), - ]; - const rows = build(messages, [text('old', 'old-raw'), text('one', 'raw')], { - visibleMessages: messages.slice(3), - }); - expect(rows.map((row) => row.turnId)).toEqual(['one']); - expect(rows[0].messages[0].messageId).toBe('direct-codex:one:user'); - }); - it('纯文本旧回合不挤占之后有工具的回合身份', () => { - const rows = build( - [user('old'), assistant('plain'), user('one'), assistant('raw')], - [text('one', 'raw')], - { calls: [tool('one')] }, - ); - expect(rows.map((row) => row.turnId)).toEqual(['old', 'one']); - expect(rows[0].source).toBe('messages'); - expect(rows[0].calls).toEqual([]); - expect(rows[1].source).toBe('stream'); - }); - it('没有流的原始 assistant 与工具仍归属一个回合,输入输出保留', () => { - const rows = build([user('one'), assistant('raw')], [], { - calls: [tool('one')], - }); - expect(rows).toHaveLength(1); - expect(rows[0].source).toBe('messages'); - expect(rows[0].calls[0].detail.output).toBe('内容'); - }); - it('无法证明流覆盖历史时整轮回退,不能混画部分流与全文', () => { - const rows = build( - [user('one'), assistant('raw-a'), assistant('raw-b')], - [text('one', 'raw-b')], - ); - expect(rows[0].source).toBe('messages'); - expect( - rows[0].messages.filter((message) => message.role === 'assistant'), - ).toHaveLength(2); - }); - it('不同回合的相同文字不是重复消息,不做文本去重', () => { - const rows = build( - [user('one'), assistant('raw-one'), user('two'), assistant('raw-two')], - [text('one', 'raw-one'), text('two', 'raw-two')], - ); - expect(rows).toHaveLength(2); - expect(rows.every((row) => row.source === 'stream')).toBe(true); - }); - it('乱序重复快照按 seq 排列且不增加 item', () => { - const first = text('one', 'a'); - const last = text('one', 'b', 3); - const marker: TurnStreamItem = { - ...text('one', 'unused', 2), - kind: 'tool', - id: 'tool:one:call', - callId: 'call', - }; - const rows = build([user('one')], [last, marker, first, first]); - expect(rows[0].items.map((item) => item.seq)).toEqual([1, 2, 3]); - }); - it('完成后中间文本和工具进入过程,最终回复独立且分区不重复', () => { - const marker: TurnStreamItem = { - ...text('one', 'unused', 2), - kind: 'tool', - id: 'tool:one:call', - callId: 'call', - }; - const row = build( - [user('one')], - [text('one', 'start'), marker, text('one', 'final', 3)], - )[0]; - const parts = splitDirectTurnContent(row); - expect(parts.processItems.map((item) => item.id)).toEqual([ - 'text:one:start', - 'tool:one:call', - ]); - expect(parts.finalItems.map((item) => item.id)).toEqual(['text:one:final']); - const active = splitDirectTurnContent({ ...row, active: true }); - expect(active.processItems).toHaveLength(3); - expect(active.finalItems).toEqual([]); - }); - it('失败回合保留失败提示,不把末尾过程文本提升为最终回复', () => { - const row = build( - [user('one'), assistant('direct-codex:one:failure', '失败')], - [text('one', 'progress'), { ...text('one', 'failure', 2), text: '失败' }], - )[0]; - const parts = splitDirectTurnContent(row); - expect(parts.processItems.map((item) => item.id)).toEqual([ - 'text:one:progress', - ]); - expect(parts.finalItems.map((item) => item.id)).toEqual([ - 'text:one:failure', - ]); - }); - it('无流历史只保留最后一条回复,发送时间不从工具推断', () => { - const row = build( - [user('one'), assistant('progress'), assistant('final')], - [], - )[0]; - const parts = splitDirectTurnContent(row); - expect(parts.processMessages.map((message) => message.messageId)).toEqual([ - 'progress', - ]); - expect(parts.finalMessages.map((message) => message.messageId)).toEqual([ - 'final', - ]); expect(directMessageTimestamp(undefined)).toBe(0); expect(directMessageTimestamp(Number.MAX_VALUE)).toBe(0); expect(directMessageTimestamp(1_800_000_000_001)).toBe(1_800_000_000_001); }); - it('无流活动回合的累计文本只属于该回合,持久 assistant 到达即接管', () => { + + it('每个用户条目开一个新回合,顺序就是条目顺序', () => { + const turns = buildDirectChatTurns({ + entries: [ + userEntry('u1'), + assistantEntry('a1', '第一轮答复'), + userEntry('u2', 1_800_000_010_000), + assistantEntry('a2', '第二轮答复', 1_800_000_011_000), + ], + }); + expect(turns.map((turn) => turn.key)).toEqual(['u1', 'u2']); + expect(turns[0]?.users[0]).toMatchObject({ text: '问题 u1' }); + expect(turns[0]?.finals[0]).toMatchObject({ text: '第一轮答复' }); + expect(turns[1]?.finals[0]).toMatchObject({ text: '第二轮答复' }); + expect(turns[0]?.startedAt).toBe(1_800_000_000_000); + expect(turns[0]?.endedAt).toBe(1_800_000_001_000); + }); + + it('已结束的回合只把最后一条助手正文当最终回复,中间正文与工具进过程', () => { + const turns = buildDirectChatTurns({ + entries: [ + userEntry('u1'), + reasoningEntry('r1'), + assistantEntry('a-mid', '我先看一下项目'), + toolEntry('t1'), + assistantEntry('a-final', '改好了', 1_800_000_002_000), + ], + }); + expect(turns[0]?.process.map((block) => block.key)).toEqual([ + 'u1:r1', + 'u1:a-mid', + 'u1:t1', + ]); + expect(turns[0]?.finals.map((block) => block.key)).toEqual(['u1:a-final']); + }); + + it('连续工具合成一块,夹了正文就另起一块', () => { + const turns = buildDirectChatTurns({ + entries: [ + userEntry('u1'), + toolEntry('t1'), + toolEntry('t2', 1_800_000_000_600), + assistantEntry('a-mid', '继续', 1_800_000_000_700), + toolEntry('t3', 1_800_000_000_800), + ], + turnRunning: true, + }); + const process = turns[0]?.process ?? []; + expect(process.map((block) => block.kind)).toEqual([ + 'tools', + 'assistant', + 'tools', + ]); + expect(process[0]).toMatchObject({ + kind: 'tools', + calls: [{ id: 't1' }, { id: 't2' }], + }); + expect(process[2]).toMatchObject({ kind: 'tools', calls: [{ id: 't3' }] }); + }); + + it('运行中的回合:过程不折叠,最后的助手正文仍在过程里流式显示', () => { + const turns = buildDirectChatTurns({ + entries: [ + userEntry('u1'), + assistantEntry('a-live', '正在写'), + toolEntry('t1', 1_800_000_001_100), + ], + turnRunning: true, + }); + expect(turns[0]?.active).toBe(true); + expect(turns[0]?.finals).toEqual([]); + expect(turns[0]?.process.map((block) => block.kind)).toEqual([ + 'assistant', + 'tools', + ]); + }); + + it('运行期失败说明挂到当前回合末尾,不当成最终回复', () => { + const turns = buildDirectChatTurns({ + entries: [userEntry('u1'), assistantEntry('a1', '正文')], + localMessages: [localNotice('后台任务失败:端口被占用')], + }); + expect(turns).toHaveLength(1); + expect(turns[0]?.finals.map((block) => block.kind)).toEqual([ + 'assistant', + 'assistant', + ]); + expect(turns[0]?.finals[1]).toMatchObject({ + notice: true, + text: '后台任务失败:端口被占用', + }); + }); + + it('乐观用户气泡自成回合,已落盘的同一身份不重复渲染', () => { + const turns = buildDirectChatTurns({ + entries: [userEntry('u1'), assistantEntry('a1', '答复')], + localMessages: [localUser('问题 u1', 'u1'), localUser('第二条')], + turnRunning: true, + }); + expect(turns.map((turn) => turn.key)).toEqual(['u1', 'local:1']); + expect(turns[1]?.users).toHaveLength(1); + expect(turns[1]?.active).toBe(true); + }); + + it('历史无用户条目时也保留一个回合承载正文', () => { + const turns = buildDirectChatTurns({ + entries: [assistantEntry('a1', '只有回复')], + }); + expect(turns).toHaveLength(1); + expect(turns[0]?.users).toEqual([]); + expect(turns[0]?.finals[0]).toMatchObject({ text: '只有回复' }); + }); + + it('条目转消息只保留用户 / 助手正文,并按身份去重', () => { + const messages = directChatEntriesToMessages([ + userEntry('u1'), + reasoningEntry('r1'), + toolEntry('t1'), + assistantEntry('a1', '答复'), + ]); + expect(messages.map((message) => message.messageId)).toEqual(['u1', 'a1']); expect( - build([user('one')], [], { - activeTurnId: 'one', - transientReply: '回复', - })[0].transientReply, - ).toBe('回复'); - expect( - build([user('one'), assistant('direct-codex:one:assistant')], [], { - activeTurnId: 'one', - transientReply: '回复', - })[0].transientReply, - ).toBe(''); + mergeDirectChatMessages(messages, [ + { role: 'assistant', text: '答复', messageId: 'a1' }, + localNotice('只在运行期的失败说明', 'local-1'), + ]).map((message) => message.messageId), + ).toEqual(['u1', 'a1', 'local-1']); }); });