From 8909b18a1550b81ab1cf156c5de367c1285a55b4 Mon Sep 17 00:00:00 2001 From: Linghong Date: Fri, 18 Sep 2026 11:30:56 +0000 Subject: [PATCH] =?UTF-8?q?=E6=81=A2=E5=A4=8D=E5=90=88=E5=B9=B6=E4=B8=AD?= =?UTF-8?q?=E8=A2=AB=E5=9B=9E=E9=80=80=E7=9A=84=20Direct=20=E5=89=8D?= =?UTF-8?q?=E7=AB=AF=E6=9E=B6=E6=9E=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 恢复主分支的线程事件消费、聊天投影、历史锚点与分页模块。 恢复过程折叠、工具卡片计时以及对应测试和测试桩。 保留策划 V1/V2 退役清理及 Design Agent 总控面板修复。 修正 Direct 验证文档中的测试入口。 验证通过:60 个 Direct 单元测试、69 个相关界面测试、4 个 Design Agent 测试及静态检查。 完整界面测试仍有 8 个资源 UI 测试失败,涉及旧卡片断言和提示词输入方式,本次未修改。 --- apps/ai-game-creator-shell/src/App.tsx | 1112 +++-------- .../ProjectSupervisorView.tsx | 567 +++--- .../project-workspace/ToolCallGroup.tsx | 146 +- .../directHistoryAnchorGate.ts | 59 + .../project-workspace/directHistoryPaging.ts | 84 + .../project-workspace/directThreadChat.ts | 516 +++++ .../project-workspace/directThreadEvents.ts | 138 +- .../directThreadItemProjection.ts | 349 ++++ .../directTurnPresentation.ts | 577 +++--- .../toolCallGroupPresentation.ts | 356 ++-- .../tests/appSurface/harness.ts | 235 ++- .../appSurface/project-development.suite.ts | 1758 ++++++++++------- .../tests/directHistoryAnchorGate.test.ts | 108 + .../tests/directHistoryPaging.test.ts | 253 +++ .../tests/directThreadChat.test.ts | 743 +++++++ ...划】DirectProject聊天真相源收敛-2026-09-16.md | 4 +- 16 files changed, 4547 insertions(+), 2458 deletions(-) create mode 100644 apps/ai-game-creator-shell/src/features/project-workspace/directHistoryAnchorGate.ts create mode 100644 apps/ai-game-creator-shell/src/features/project-workspace/directHistoryPaging.ts create mode 100644 apps/ai-game-creator-shell/src/features/project-workspace/directThreadChat.ts create mode 100644 apps/ai-game-creator-shell/src/features/project-workspace/directThreadItemProjection.ts create mode 100644 apps/ai-game-creator-shell/tests/directHistoryAnchorGate.test.ts create mode 100644 apps/ai-game-creator-shell/tests/directHistoryPaging.test.ts create mode 100644 apps/ai-game-creator-shell/tests/directThreadChat.test.ts diff --git a/apps/ai-game-creator-shell/src/App.tsx b/apps/ai-game-creator-shell/src/App.tsx index a5ecfa89c..6ea787b23 100644 --- a/apps/ai-game-creator-shell/src/App.tsx +++ b/apps/ai-game-creator-shell/src/App.tsx @@ -57,9 +57,6 @@ import type { DirectTurnCancelView, GameCreatorAgentRuntimeUpdateEvent, GameCreatorChatAgentReply, - GameCreatorDirectActiveTurn, - GameCreatorDirectToolCall, - GameCreatorDirectTurnUpdateEvent, GameCreatorLlmConfigStatus, GameCreatorManifestInvalidatedEvent, GameCreatorRoleAgentChatStreamEvent, @@ -96,7 +93,6 @@ import type { ProjectPermissionPolicyView, SyncCanvasProjectAssetsResult, TauriInvoke, - TurnStreamItem, UploadLocalAssetResult, } from './app/types'; import { useWindowChrome } from './components/windowChromeContext'; @@ -133,7 +129,6 @@ import { submitProjectSupervisorRuntimeTask, taskRowsFromManifest, } from './features/agent-runtime'; -import { DIRECT_ACTIVE_TURNS_POLL_INTERVAL_MS } from './features/agent-runtime/directActiveTurns'; import { type DirectCodexTurnAttachment, toDirectCodexTurnAttachments, @@ -234,15 +229,28 @@ import { } from './features/project-workspace/chatComposerQueue'; import { DeveloperProjectPanels } from './features/project-workspace/DeveloperProjectPanels'; import { DeveloperRuntimePanels } from './features/project-workspace/DeveloperRuntimePanels'; +import { + type DirectHistoryAnchorGate, + directHistoryAnchorGateToWaitFor, + reuseOrOpenDirectHistoryAnchorGate, +} from './features/project-workspace/directHistoryAnchorGate'; +import { readDirectHistoryPages } from './features/project-workspace/directHistoryPaging'; +import { + applyDirectThreadConsumeResult, + type DirectThreadChatState, + directThreadTurnMatchesUser, + emptyDirectThreadChatState, + finishDirectThreadTurn, + mergeDirectHistoryItems, + resolveDirectThreadBootstrap, + selectDirectChatEntries, +} from './features/project-workspace/directThreadChat'; import { type DirectThreadConsumeResult, - directThreadHistoryPage, type DirectThreadHistorySlice, + type DirectThreadItem, type DirectThreadSubscriptionBootstrap, - isDirectTurnInProgress, - prependDirectHistoryMessages, } from './features/project-workspace/directThreadEvents'; -import { normalizeDirectTimestamp } from './features/project-workspace/directTurnPresentation'; import type { DirectCodexUserContentPart } from './features/project-workspace/generated'; import { appendMemoryContent, @@ -302,13 +310,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 @@ -344,136 +345,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'], @@ -483,70 +354,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 @@ -613,7 +420,7 @@ function claimInitialSupervisorMessageForPage(projectPath: string, scope = '') { * 初始需求是**乐观插入**到 messages 的(latch 命中后先插一条 user 消息,再发起回合), * 而历史回读在 replace 分支里是无条件整体替换 —— 只要回读晚于乐观插入,那条用户消息 * 就会被冲掉(界面上看不到初始需求,但回合其实已经跑起来了)。 - * 这里只把当前 messages 里"非历史来源、运行时拥有、且回读结果里没有"的消息保留在末尾。 + * 这里把当前 messages 里"运行时拥有、且回读结果里没有"的消息保留在末尾(它们是最新的)。 */ function mergeLoadedConversationWithPendingRuntimeMessages( loaded: ChatMessage[], @@ -631,7 +438,7 @@ function mergeLoadedConversationWithPendingRuntimeMessages( loaded.map((message) => `${message.role}\u0000${message.text}`), ); const pending = current.filter((message) => { - if (!message.runtimeOwned || message.fromHistory) { + if (!message.runtimeOwned) { return false; } if (message.messageId) { @@ -782,7 +589,6 @@ export function App({ useEffect(() => { if (supervisorChatOnly) return; const nextProjectPath = localProject?.projectPath ?? null; - ensureDirectTimelineProject(nextProjectPath); const previousProjectPath = localProjectPathRef.current; localProjectPathRef.current = nextProjectPath; // 未绑定项目时无需触发插件宿主;这也避免启动空首页时产生无意义的 Tauri 调用。 @@ -918,125 +724,26 @@ 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 chatAgentBusyRef = useRef(chatAgentBusy); + const directTurnRunningRef = useRef(directTurnRunning); + chatAgentBusyRef.current = chatAgentBusy; + directTurnRunningRef.current = directTurnRunning; + const previousDirectTurnRunningRef = useRef(directTurnRunning); + const directTurnCompletionPendingRef = useRef(false); const [projectSupervisorSessionId, setProjectSupervisorSessionId] = useState< string | null >(null); @@ -1056,192 +763,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 || - designAgentActiveRef.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] = @@ -1601,7 +1127,10 @@ export function App({ ); const [directHistoryHasMore, setDirectHistoryHasMore] = useState(false); const directHistoryOldestItemIdRef = useRef(null); - const directHistoryLoadingRef = useRef(null); + const directHistoryLoadingRef = useRef(false); + const directHistoryAnchorGateRef = useRef( + null, + ); const [pendingCommand, setPendingCommand] = useState( null, ); @@ -1794,7 +1323,6 @@ export function App({ function resetProjectSupervisorState() { projectSupervisorHistoryLoadVersionRef.current += 1; - directHistoryLoadingRef.current = null; projectSupervisorRuntimeResumeProjectPathRef.current = null; projectSupervisorSessionIdRef.current = null; projectSupervisorRuntimeRef.current = null; @@ -1928,117 +1456,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(() => { @@ -2048,30 +1466,60 @@ 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; + // 闸门先建(或复用首屏读取已经开好的那道):同一轮渲染里首屏会等订阅回执里的锚点。 + const anchorGate = reuseOrOpenDirectHistoryAnchorGate( + directHistoryAnchorGateRef.current, + projectPath ?? '', + ); + directHistoryAnchorGateRef.current = anchorGate; + if (!projectPath || !directInvoke || !canSubscribeTauriEvents()) { + // 订阅不可用:首屏退化成"取文件尾",不阻塞加载。 + anchorGate.settle(null); + 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', { projectPath }, ); - if (disposed) return; + if (disposed) { + anchorGate.settle(null); + return; + } subscriptionId = result.subscriptionId; - refreshActive(); + // 首屏边界:回执里这一刻的最后一条已完成条目(含该条)。 + anchorGate.settle(result.lastCompletedItemId ?? null); + setDirectThreadChat((state) => + resolveDirectThreadBootstrap(state, result), + ); + if (notifyBeforeSubscription) { + notifyBeforeSubscription = false; + void consume(); + } }; const consume = async () => { if (!subscriptionId || disposed) return; @@ -2088,30 +1536,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 { @@ -2123,7 +1559,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) { @@ -2132,21 +1572,19 @@ export function App({ } cleanup = unlisten; await bootstrap(); - await consume(); } catch { - // 历史仍可使用;订阅失败不伪造忙碌态。 + // 历史仍可使用;订阅失败不伪造忙碌态,锚点缺失时首屏按文件尾取尾屏。 + anchorGate.settle(null); } }; - refreshActive(); - const timer = window.setInterval( - refreshActive, - DIRECT_ACTIVE_TURNS_POLL_INTERVAL_MS, - ); + // 换项目就是换一份聊天:先回到初始态,再订阅新线程。 + setDirectThreadChat(emptyDirectThreadChatState()); void setup(); return () => { disposed = true; cleanup?.(); - window.clearInterval(timer); + // 首屏可能还在等这道闸门(例如切项目打断了订阅),不能让它永远等下去。 + anchorGate.settle(null); }; }, [directCodexProductRuntime, localProject?.projectPath]); @@ -2166,20 +1604,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) => [ @@ -2663,10 +2088,7 @@ export function App({ projectSupervisorResponseStream?.sequence, projectSupervisorRuntime?.updatedAt, projectSupervisorRuntimeError, - directCodexProgress, - directCodexProgressUpdatedAt, - directCodexTransientReply, - directCodexTransientReplyUpdatedAt, + directThreadChat, directCodexProductRuntime, planningLaneActive, projectSupervisorOnly, @@ -3592,8 +3014,6 @@ export function App({ } const loadVersion = projectSupervisorHistoryLoadVersionRef.current + 1; projectSupervisorHistoryLoadVersionRef.current = loadVersion; - if (directCodexProductRuntime) - directHistoryLoadingRef.current = loadVersion; try { if (planningStartMode) { try { @@ -3653,24 +3073,62 @@ export function App({ : await readProjectSupervisorActiveSession(invoke, nextProjectPath); let runtimeError = ''; let loadedDirectHistoryHasMore = false; - let loadedDirectHistoryCursor: string | null = null; + let loadedDirectHistoryFirstItemId: string | null = null; + // 首屏连拉到的条目先暂存,待下方 staleness 守卫通过后再并入聊天 reducer: + // 迟到的切片属于已经切走的项目,不能在守卫之前就写进全局聊天状态。 + let loadedDirectHistoryItems: DirectThreadItem[] = []; + // 首屏切片的新端边界只认订阅回执里的 `lastCompletedItemId`(含该条):比它更新的条目 + // 只能来自运行态事件。打开项目时订阅 effect 还没跑,这里就先把闸门开好,订阅侧会复用 + // 同一道闸门并 settle 它。同一道闸门只锚定一次:同一订阅下再读一次(例如重开同一个项目) + // 没有新回执可等,退回"按当前文件尾取尾屏",与 `/history` 手动重读一致。 + let directHistoryThroughItemId: string | null = null; + if (directCodexProductRuntime && mode !== 'replace') { + const anchorGate = directHistoryAnchorGateToWaitFor( + directHistoryAnchorGateRef.current, + nextProjectPath, + ); + if (anchorGate) { + directHistoryAnchorGateRef.current = anchorGate; + directHistoryThroughItemId = await anchorGate.anchor; + anchorGate.consumed = true; + } + } const projectConversation = directCodexProductRuntime ? (() => { - return invoke( - 'read_direct_project_history_slice', - { - projectPath: nextProjectPath, - limit: CONVERSATION_INITIAL_VISIBLE_COUNT, - messagesOnly: true, - }, - ).then((slice) => { - const page = directThreadHistoryPage(slice); - loadedDirectHistoryHasMore = page.hasMore; - loadedDirectHistoryCursor = page.cursor; + // 首屏铺的就是这份视图的基线,"新回合"比较没有意义:判据退化为"这一页得有能渲染 + // 的条目",所以基线取空,免得拿上一份对话的残留状态去比。首屏失败仍按原有语义 + // 抛出,交给调用方的 catch 处理。 + return readDirectHistoryPages({ + existingEntries: [], + beforeItemId: null, + readSlice: (beforeItemId) => + invoke( + 'read_direct_project_history_slice', + beforeItemId + ? { + projectPath: nextProjectPath, + beforeItemId, + limit: CONVERSATION_INITIAL_VISIBLE_COUNT, + } + : { + projectPath: nextProjectPath, + ...(directHistoryThroughItemId + ? { throughItemId: directHistoryThroughItemId } + : {}), + limit: CONVERSATION_INITIAL_VISIBLE_COUNT, + }, + ), + }).then((pages) => { + if (pages.error) { + throw pages.error; + } + loadedDirectHistoryHasMore = pages.hasMore; + loadedDirectHistoryFirstItemId = pages.firstItemId; + loadedDirectHistoryItems = pages.items; return { path: nextProjectPath, agentId: null, - messages: page.messages, + messages: [], } satisfies LocalConversationResult; }); })() @@ -3679,17 +3137,7 @@ export function App({ agentId: null, }); const resolvedProjectConversation = await projectConversation; - if (directCodexProductRuntime) { - if ( - projectSupervisorHistoryLoadVersionRef.current !== loadVersion || - localProjectPathRef.current !== nextProjectPath - ) - return; - ensureDirectTimelineProject(nextProjectPath); - // 重进会话时 Rust 侧可能仍登记着上一条 Direct 回合。不接管的话界面既不显示 - // 过程卡也不给终止入口,用户再发消息只会被守卫拒绝("已有另一条回合正在运行")。 - await restoreRunningDirectCodexTurn(nextProjectPath); - } + // 首屏历史直接进聊天 reducer;`messages` 在 DirectProject 下只剩运行期本地消息。 let supervisorConversation: LocalConversationResult | null = null; let runtime: AgentRuntimeState | null = null; let runtimeResponseStream: AgentRuntimeResponseStream | null = null; @@ -3728,7 +3176,7 @@ export function App({ const conversationMessages = mergeProjectSupervisorConversation( resolvedProjectConversation.messages, supervisorConversation?.messages ?? [], - ).map((message) => ({ ...message, fromHistory: true })); + ); if ( conversationContainsProjectSupervisorResponseStream( supervisorConversation?.messages ?? [], @@ -3749,7 +3197,12 @@ export function App({ setProjectSupervisorRuntimeError(runtimeError || resumeError); if (directCodexProductRuntime) { setDirectHistoryHasMore(loadedDirectHistoryHasMore); - directHistoryOldestItemIdRef.current = loadedDirectHistoryCursor; + directHistoryOldestItemIdRef.current = loadedDirectHistoryFirstItemId; + if (loadedDirectHistoryItems.length > 0) { + const items = loadedDirectHistoryItems; + // 首屏历史进聊天 reducer;`messages` 在 DirectProject 下只剩运行期本地消息。 + setDirectThreadChat((state) => mergeDirectHistoryItems(state, items)); + } } setMessages((current) => { // replace 分支同样不能丢掉尚未落盘的运行时消息(初始需求)。 @@ -3810,10 +3263,6 @@ export function App({ : workspaceStatus, ); // Keep the default greeting when history is missing or blocked. - } finally { - if (directHistoryLoadingRef.current === loadVersion) { - directHistoryLoadingRef.current = null; - } } } @@ -3849,7 +3298,7 @@ export function App({ const projectScopeVersion = projectScopeVersionRef.current + 1; projectScopeVersionRef.current = projectScopeVersion; resetProjectSupervisorState(); - resetDirectCodexTurn(); + resetDirectThreadChat(); updateClientPreview(null); setPreviewStatus('未启动'); setWorkspaceStatus('正在打开'); @@ -3929,9 +3378,6 @@ export function App({ setAgentRunHistoryFiles([]); setAgentRuntimeById({}); setMessages(conversationMessages); - // 只有新项目确实打开后才丢弃旧分页位置;打开失败时旧会话仍可继续翻页。 - directHistoryOldestItemIdRef.current = null; - setDirectHistoryHasMore(false); setConversationVisibleCount(CONVERSATION_INITIAL_VISIBLE_COUNT); savedConversationProjectPathRef.current = openedProject.projectPath; savedConversationCountRef.current = conversationMessages.length; @@ -6610,21 +6056,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: { @@ -6647,15 +6079,9 @@ export function App({ directTurnInput.attachments = attachments; } directTurnInput.userItem = effectiveUserItem; - const reply = await withDirectCodexSessionRefresh(() => { - // 每次调用都会新建 Rust 事件流;续期重试需重新接收同一回合的进度。 - activeDirectCodexTurnRef.current = { - projectPath: directProjectPath, - turnId: clientTurnId, - lastSequence: -1, - receivedDirectUpdate: false, - }; - setDirectCodexStatus('accepted'); + // 回复正文按条目身份从线程事件 / 历史切片进聊天,本地不再补一条, + // 因此这里只等回合跑完,不接返回值。 + await withDirectCodexSessionRefresh(() => { return directInvoke( 'chat_with_game_creator_direct_codex', directTurnInput, @@ -6667,10 +6093,6 @@ export function App({ projectSupervisorHistoryLoadVersionRef.current += 1; } if (localProjectPathRef.current === directProjectPath) { - clearDirectCodexTransientReply(directProjectPath, clientTurnId); - setMessages((current) => - appendDirectAssistantMessage(current, reply), - ); await refreshDirectProjectManifest(directProjectPath); } } catch (error) { @@ -6679,16 +6101,9 @@ export function App({ isDirectCodexAnotherTurnRunningError(error) ) { if (localProjectPathRef.current === directProjectPath) { - clearDirectCodexTransientReply(directProjectPath, clientTurnId); setProjectSupervisorRuntimeError( '陶泥儿仍在处理上一条消息,可在输入盒点「终止」结束它,或等它结束后再发送。', ); - // 兜底:出现这条拒绝说明本项目确实有回合在跑,而本组件此前没接管它 - // (重进会话的漏网情况)。放到当前任务之后再接管,避开本回合 finally - // 里 setChatAgentBusy(false) 的复位竞态。 - window.setTimeout(() => { - void restoreRunningDirectCodexTurn(directProjectPath); - }, 0); } return; } @@ -6697,9 +6112,6 @@ export function App({ } if (isDirectCodexTurnInterruptedError(error)) { // 用户主动终止:不是失败,不写运行错误与诊断,只把回合标记成已终止。 - clearDirectCodexTransientReply(directProjectPath, clientTurnId); - setDirectCodexStatus('failed'); - setDirectCodexProgress(''); setProjectSupervisorRuntimeError(''); setChatComposerNotice('已终止本次回合'); setMessages((current) => @@ -6734,9 +6146,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), @@ -6748,17 +6157,11 @@ 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(); - // 只有当前回合的收尾才能释放发送队列,不能覆盖后来启动的回合。 + chatAgentBusyRef.current = false; + // 本地 invoke 正常收尾仍可直接推进队列;恢复场景则由 turn.completed effect 推进。 dispatchNextQueuedChatTurn(); } } @@ -11877,63 +11280,53 @@ export function App({ : null; async function showEarlierConversationMessages() { - if (hiddenConversationCount > 0) { - setConversationVisibleCount((current) => - Math.min(messages.length, current + CONVERSATION_VISIBLE_STEP), - ); - return; - } if (directCodexProductRuntime && directHistoryHasMore) { const invoke = resolveTauriInvoke(); const projectPath = localProject?.projectPath; - if (invoke && projectPath && directHistoryLoadingRef.current === null) { - const loadVersion = projectSupervisorHistoryLoadVersionRef.current; - const beforeItemId = directHistoryOldestItemIdRef.current; - directHistoryLoadingRef.current = loadVersion; - const isCurrentLoad = () => - manifestRefreshMountedRef.current && - localProjectPathRef.current === projectPath && - projectSupervisorHistoryLoadVersionRef.current === loadVersion; + if (invoke && projectPath && !directHistoryLoadingRef.current) { + directHistoryLoadingRef.current = true; try { - const slice = await invoke( - 'read_direct_project_history_slice', - { - projectPath, - beforeItemId, - limit: CONVERSATION_VISIBLE_STEP, - messagesOnly: true, - }, - ); - if (!isCurrentLoad()) { + // 一次点击连拉,直到出现新回合或取不动为止:一屏全是工具卡片 / 思考文本时, + // 单发一页会让用户点了"显示更早"却看不到任何变化。 + const pages = await readDirectHistoryPages({ + existingEntries: selectDirectChatEntries(directThreadChat), + beforeItemId: directHistoryOldestItemIdRef.current, + readSlice: (beforeItemId) => + invoke( + 'read_direct_project_history_slice', + beforeItemId + ? { + projectPath, + beforeItemId, + limit: CONVERSATION_VISIBLE_STEP, + } + : { projectPath, limit: CONVERSATION_VISIBLE_STEP }, + ), + }); + if (localProjectPathRef.current !== projectPath) { return; } - const page = directThreadHistoryPage(slice, beforeItemId); - const older = page.messages.map((message) => ({ - role: - message.role === 'user' - ? ('user' as const) - : ('assistant' as const), - text: message.content, - runtimeOwned: true, - fromHistory: true, - messageId: message.messageId, - updatedAt: message.updatedAt, - })); - setMessages((current) => - prependDirectHistoryMessages(current, older), + // 连拉到的页一次性进聊天 reducer:与运行态同形、同身份,重复读取只补不重。 + setDirectThreadChat((state) => + mergeDirectHistoryItems(state, pages.items), ); - setConversationVisibleCount((current) => current + older.length); - setDirectHistoryHasMore(page.hasMore); - directHistoryOldestItemIdRef.current = page.cursor; + // 首页或中途读取失败时保留旧值:失败页没有可靠的 hasMore,不能因为 + // 一次瞬时 IO 抖动把「显示更早」按钮永久收掉。 + if (!pages.error) { + setDirectHistoryHasMore(pages.hasMore); + } + directHistoryOldestItemIdRef.current = + pages.firstItemId ?? directHistoryOldestItemIdRef.current; + if (pages.error) { + // 已经取到的页照常进视图,失败只走下面同一个报错出口。 + throw pages.error; + } } catch (error) { - if (!isCurrentLoad()) return; setWorkspaceStatus( `读取更早的对话历史失败:${error instanceof Error ? error.message : String(error)}`, ); } finally { - if (directHistoryLoadingRef.current === loadVersion) { - directHistoryLoadingRef.current = null; - } + directHistoryLoadingRef.current = false; } } return; @@ -12128,8 +11521,12 @@ export function App({ } } - /** 队首出队并立即发出:只在当前回合确实结束(`finally`)后调用。 */ + /** 队首出队并立即发出:只在 DirectProject 收到 `turn.completed` 后调用。 */ function dispatchNextQueuedChatTurn() { + if (chatAgentBusyRef.current || directTurnRunningRef.current) { + return; + } + directTurnCompletionPendingRef.current = false; const { next, rest } = dequeueChatTurn(chatTurnQueueRef.current); if (!next) { return; @@ -12147,16 +11544,42 @@ export function App({ }); } + /** + * 回合事件可能先于本地 invoke 的 finally 到达,因此先记 pending,等本地忙碌态复位后再出队。 + */ + useEffect(() => { + if (!directCodexProductRuntime) { + directTurnCompletionPendingRef.current = false; + previousDirectTurnRunningRef.current = directTurnRunning; + return; + } + const wasRunning = previousDirectTurnRunningRef.current; + previousDirectTurnRunningRef.current = directTurnRunning; + if (wasRunning && !directTurnRunning) { + directTurnCompletionPendingRef.current = true; + } + if ( + directTurnCompletionPendingRef.current && + !chatAgentBusy && + !directTurnRunning + ) { + directTurnCompletionPendingRef.current = false; + dispatchNextQueuedChatTurn(); + } + // 队列出队函数读取本轮 render 的项目输入;这里只由三个生命周期信号触发。 + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [chatAgentBusy, directCodexProductRuntime, directTurnRunning]); + /** 终止当前 direct-codex 回合:只取消这一轮,UI 由回合的 finally 复位。 */ async function handleCancelDirectCodexTurn() { if (directCodexTurnCancelling) { return; } const invoke = resolveTauriInvoke(); - const activeTurn = activeDirectCodexTurnRef.current; - const directProjectPath = - activeTurn?.projectPath ?? resolveChatProjectPath(localProject); - if (!invoke || !directProjectPath || !activeTurn) { + const directProjectPath = resolveChatProjectPath(localProject); + // 繁忙判据与「终止」按钮的可见条件一致:本地 invoke 正在跑,或订阅说还有一条回合没结束。 + // 只认 `turn.started` 推导出来的 `directTurnRunning` 会漏掉"刚提交、事件还没到"的窗口。 + if (!invoke || !directProjectPath || !supervisorChatBusy) { setProjectSupervisorRuntimeError('当前没有正在运行的回合,无法终止。'); return; } @@ -12165,17 +11588,24 @@ 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 通知。 + // 时刻取宿主观测到的这一刻:终止返回就是这一轮的终态,原生随后补的事件若先到, + // 收口已经是冻结值,不会被抬高,也不会复活成"永远运行中"。 + // 但取消回包可能晚于新回合的开始:先核对身份(clientTurnId 对应的本轮用户条目), + // 只收口确实是这一轮的那一次,避免在新回合的回调里把旧轮的时间盖上来。 + const cancelledUserItemId = result.clientTurnId + ? directCodexConversationMessageId(result.clientTurnId, 'user') + : ''; + setDirectThreadChat((state) => + directThreadTurnMatchesUser(state, cancelledUserItemId) + ? finishDirectThreadTurn(state, Date.now()) + : state, + ); setChatAgentBusy(false); setProjectSupervisorRuntimeError(''); setChatComposerNotice( @@ -12183,7 +11613,6 @@ export function App({ ); return; } - setDirectCodexProgress('正在终止当前回合'); if (message) { setChatComposerNotice(message); } @@ -12226,7 +11655,7 @@ export function App({ ) { return; } - if (chatAgentBusy) { + if (supervisorChatBusy) { // 回合运行中再次发送:direct-codex 面板把消息放进本地 FIFO 队列,当前回合结束后 // 依次发出;其它面板保持原有"运行中不接受新输入"的行为。 if (directCodexProductRuntime) { @@ -12332,9 +11761,7 @@ export function App({ transientReply={ designAgentActive ? designAgentTransientReply - : directCodexProductRuntime - ? directCodexTransientReply - : projectSupervisorTransientReply + : projectSupervisorTransientReply } hasConversationControls={projectSupervisorHasConversationControls} hiddenConversationCount={hiddenConversationCount} @@ -12350,7 +11777,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); - } +function TurnElapsedTotal({ startedAt }: { startedAt: number }) { + const now = useLiveNow(startedAt > 0); + const timing = resolveTurnTiming({ startedAt, running: true, now }); + if (!timing.durationText) { + return null; } 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" - /> - ), - )} - + + {`总耗时 ${timing.durationText}`} + ); } @@ -195,6 +120,9 @@ function AgentReasoning({ label?: string; testId?: string; }) { + const [expanded, setExpanded] = useState(false); + // 折叠态:单行纯文本预览(走 Markdown AST 取文字,链接只留字面文字、不含目标)。 + const preview = agentProcessPreview(text); return ( + setExpanded((event.currentTarget as HTMLDetailsElement).open) + } > - 思考过程 -
{text}
+ + + {/* 展开态复用助手正文的安全 Markdown 链路(内部 skipHtml,不用 innerHTML)。 */} +
); } 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 才渲染)。 */ @@ -241,9 +160,12 @@ 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; + /** 最新回合的原生起点(`turn.started.at`):用户发送时间缺失时兜底,0 = 缺失。 */ + directTurnStartedAt?: number; hiddenConversationCount: number; hasEarlierConversationMessages?: boolean; messagesRef: RefObject; @@ -270,28 +192,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; @@ -315,9 +223,9 @@ export function ProjectSupervisorView({ chatProjectAssets, composerRef, directCodex = false, - directStatus = null, - directProcessDetail = '', - directProcessKey = '', + directEntries = [], + directTurnRunning = false, + directTurnStartedAt = 0, hiddenConversationCount, hasEarlierConversationMessages = false, messagesRef, @@ -340,19 +248,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, @@ -364,26 +267,16 @@ export function ProjectSupervisorView({ ...runtimePanelProps }: ProjectSupervisorViewProps) { const designAgentActive = Boolean(designView || onDesignApprove); - const [expandedProcessKey, setExpandedProcessKey] = useState( - null, - ); + const [settingsOpen, setSettingsOpen] = useState(false); + // 语音输入的降级/失败提示:不支持时按钮本身就带提示,这里只承载启动失败与权限类错误。 + 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 @@ -394,52 +287,65 @@ 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]); 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, + turnStartedAt: directTurnStartedAt, }) : []; - const turnStartedAtFor = (turnId: string) => - directTurns.find((turn) => turn.turnId === turnId)?.startedAt ?? 0; + const activeTurnStartedAt = + directTurns.find((turn) => turn.active)?.startedAt ?? 0; + const latestDirectTurn = directTurns.at(-1); + // 仅是首个响应到达前的临时提示,不创建聊天条目或第二套回合状态。 + const awaitingFirstResponse = + directCodex && + (runBusy || directTurnRunning) && + !turnCancelling && + !runtimePanelProps.error && + Boolean(latestDirectTurn?.users.length) && + latestDirectTurn?.process.length === 0 && + latestDirectTurn?.finals.length === 0; + // 初始占位气泡只是"最初那条消息还没有正式条目"时的顶位,两种情况下不再渲染: + // - 已经翻出更早的历史(`hasEarlierConversationMessages`):这里不是对话开头,不补占位; + // - Direct 模式已经有了正式用户条目:正式气泡自己会显示,占位再渲染就是同一条消息出现两次。 + // 这里按**结构**判断(存在正式用户条目),不按文本去重真实消息,也不影响合法的连续重复发送。 + const initialSupervisorText = initialSupervisorMessage.trim(); + const initialSupervisorPlaceholderSuperseded = + hasEarlierConversationMessages || + (directCodex + ? directEntries.some( + (entry) => entry.kind === 'message' && entry.role === 'user', + ) || conversationMessages.some((message) => message.role === 'user') + : conversationMessages.some( + (message) => + message.role === 'user' && + message.text.trim() === initialSupervisorText, + )); - const clockTimeWithSeconds = (timestamp: number) => { - const date = new Date(timestamp); - const pad = (value: number) => String(value).padStart(2, '0'); - return `${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`; - }; - - const renderTurnUsage = (turn: DirectTurnPresentation) => { - if (!turn.turnId || turn.active || !turn.startedAt) return null; - const endedAt = Math.max(turn.endedAt, turn.startedAt); + /** + * 回合结束后的一行小结:时间范围与总耗时读同一组边界,两者都取不到就不渲染—— + * 旧历史没有完整边界时不猜"这一轮跑了多久"。终态不会再变,这里不用时钟。 + */ + const renderTurnUsage = (turn: DirectChatTurn) => { + if (turn.active) return null; + const timing = resolveTurnTiming({ + startedAt: turn.startedAt, + endedAt: turn.endedAt, + }); + if (!timing.durationText) return null; + const endedLabel = formatClockTime(turn.endedAt, { tenths: true }); return (

- {`本轮结束于 ${clockTimeWithSeconds(endedAt)} · 耗时 ${formatTurnDuration(endedAt - turn.startedAt) ?? '0秒'}`} + {endedLabel + ? `本轮结束于 ${endedLabel} · 总耗时 ${timing.durationText}` + : `总耗时 ${timing.durationText}`}

); }; @@ -530,7 +436,7 @@ export function ProjectSupervisorView({ aria-hidden="true" /> {runBusy - ? directStatusTitle(directStatus) + ? '陶泥儿正在处理' : projectWorkspaceStatusForDisplay(workspaceStatus)} - ) : null} - - ) : null} ) : null}
` + `hidden` 控制正文, * 所以 Tab 可达、Enter/Space 可切换、读屏能读到展开状态与 `aria-label`。 * 文案与耗时规则(纯函数)在 `toolCallGroupPresentation.ts`。 @@ -34,75 +40,53 @@ import { /** 一回合的工具调用折叠块;空集合不渲染。 */ export function ToolCallGroup({ calls, - userSentAt = 0, active = false, className, }: { - calls: GameCreatorDirectToolCall[]; - /** 同一回合用户消息的 `updatedAt`;拿不到就传 0,只显示结束时间。 */ - userSentAt?: number | null; + calls: DirectChatToolCard[]; /** - * 这个块是否属于**正在跑的回合**。只有正在跑的回合才允许出现"执行中 / 进行中": - * 已结束的回合里若还留着 `running` 快照(进程被杀、completed 事件没到), - * 那是数据残留,不该让用户一直看到"执行中"。 + * 这个块是否属于**正在跑的回合**:决定本组是否还能用当前时钟增长,以及残留的 + * `running` 快照要不要显示"执行中 / 进行中"。已结束的回合里残留的 `running` 快照 + * (进程被杀、completed 事件没到)是数据残留,不该让用户一直看到"执行中"。 */ active?: boolean; className?: string; }) { const [expanded, setExpanded] = useState(false); - const [tick, setTick] = useState(0); const bodyId = useId(); - const hasRunningSnapshot = calls.some((call) => call.status === 'running'); - const running = active && hasRunningSnapshot; - // 正在跑时每秒重算一次"已用时",否则这个数字只在收到新事件时才变。 - useEffect(() => { - if (!running) { - return undefined; - } - const timer = setInterval(() => setTick((current) => current + 1), 1000); - return () => clearInterval(timer); - }, [running]); - if (calls.length === 0) { - return null; - } - void tick; const orderedCalls = [...calls].sort( (left, right) => left.startedAt - right.startedAt, ); - const summary = toolCallGroupSummary(orderedCalls, running); - const startedAt = - orderedCalls.find((call) => call.startedAt > 0)?.startedAt ?? 0; - // 进行中:用"现在 - 最早开始时间"(会随 tick 每秒变);已结束:用快照里的 - // min(startedAt) → max(updatedAt),不掺入本地时钟。 - const totalDurationMs = running - ? startedAt > 0 - ? (() => { - const latestUpdatedAt = Math.max( - ...orderedCalls.map((call) => call.updatedAt || 0), - ); - // A running snapshot can still carry a monotonic update time. Use - // that delta when available; fall back to wall-clock elapsed time - // for live events that have no update timestamp yet. - return latestUpdatedAt > startedAt - ? latestUpdatedAt - startedAt - : Math.max(0, Date.now() - startedAt); - })() - : null - : turnToolCallDurationMs(orderedCalls); - const durationText = formatTurnDuration(totalDurationMs); - const timeLabel = turnToolCallTimeLabel(orderedCalls, userSentAt); - const status = running - ? 'running' - : orderedCalls.some((call) => call.status === 'failed') - ? 'failed' - : 'completed'; - const headLabel = [ - summary, - running ? '进行中' : '', - durationText ? `用时 ${durationText}` : '', + const hasRunningSnapshot = calls.some((call) => call.status === 'running'); + // 行级"执行中"与 `data-status` 看**这一组**是否还有工具在跑:同轮另一组在跑、或回合还在 + // 推理,都不会让已经跑完的组继续标"进行中"。 + const toolRunning = active && hasRunningSnapshot; + // 是否需要跟着墙上时钟走:只取决于本组里"还有工具在跑(且回合还在跑)"。 + const ticking = + active && + orderedCalls.some( + (call) => call.status === 'running' && call.startedAt > 0, + ); + const now = useLiveNow(ticking); + if (calls.length === 0) { + return null; + } + // 本组用时:只看这一组自己的工具边界;本组全完成即冻结。 + const timing = resolveToolGroupTiming(orderedCalls, { running: active, now }); + const summary = toolCallGroupSummary(orderedCalls); + const hasFailure = orderedCalls.some((call) => call.status === 'failed'); + const status = toolRunning ? 'running' : hasFailure ? 'failed' : 'completed'; + // 用户口径:`执行了 X 个操作,耗时 XXX`。耗时属于**这一组**(不是整轮),可见文本里用 + // 中文逗号连起来,所以分隔符跟着 meta 一起进 DOM。 + // 顺序按用户口径:`执行了 X 个操作,耗时 XXX`,运行状态挂到最后,不插在中间。 + const headMeta = [ + timing.durationText ? `耗时 ${timing.durationText}` : '', + hasFailure ? '有操作失败' : '', + toolRunning ? '进行中' : '', ] .filter(Boolean) .join(','); + const headLabel = [summary, headMeta].filter(Boolean).join(','); return (
    {orderedCalls.map((call) => ( - + ))}
@@ -168,14 +144,18 @@ export function ToolCallGroup({ function ToolCallRow({ call, active, + now, }: { - call: GameCreatorDirectToolCall; + call: DirectChatToolCard; active: boolean; + /** 所属回合的运行时钟;只在"该工具仍在跑"时用得上。 */ + now: number; }) { const [expanded, setExpanded] = useState(false); const detailId = useId(); const text = toolCallRowText(call); - const durationMs = toolCallDurationMs(call); + const liveRunning = active && call.status === 'running'; + const durationMs = toolCallDurationMs(call, { now, running: liveRunning }); const durationText = formatToolCallDuration(durationMs); // "执行中"只在**正在跑的回合**里显示;已结束的回合里残留的 running 快照按已完成处理, // 否则用户会看到一条永远停在"执行中"的记录。 diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/directHistoryAnchorGate.ts b/apps/ai-game-creator-shell/src/features/project-workspace/directHistoryAnchorGate.ts new file mode 100644 index 000000000..ca67e196e --- /dev/null +++ b/apps/ai-game-creator-shell/src/features/project-workspace/directHistoryAnchorGate.ts @@ -0,0 +1,59 @@ +/** + * 首屏历史锚点闸门:把「订阅回执里的 `lastCompletedItemId`」交给「首屏切片读取」。 + * + * 这个 id 是订阅那一刻最后一条已完成条目,也是历史切片与运行态事件的唯一分界:首屏切片只 + * 允许取到它为止(含该条),比它更新的条目只能来自运行态事件。回执到达之前首屏必须等它, + * 不能退化成"取文件尾"——那会把回执之后才完成的条目也拉进历史,与运行态事件重叠。 + * + * 生命周期:打开项目时首屏读取先开一道闸门(订阅 effect 还没跑),订阅侧复用同一道闸门并在 + * 回执到达后 `settle` 它;订阅不可用 / 失败 / 切项目时 settle 成 `null`,首屏退化成取文件尾。 + * 一道闸门只服务这次订阅的第一次首屏读取,用过之后置 `consumed`。 + */ + +export type DirectHistoryAnchorGate = { + projectPath: string; + anchor: Promise; + settle: (anchor: string | null) => void; + /** 是否已经被一次首屏读取用掉:同一道闸门只锚定"这次订阅的第一次首屏"。 */ + consumed: boolean; +}; + +/** 为某个项目开一道闸门:`settle` 由订阅侧调用,`anchor` 由首屏读取 `await`。 */ +export function openDirectHistoryAnchorGate( + projectPath: string, +): DirectHistoryAnchorGate { + let settle: (anchor: string | null) => void = () => {}; + const anchor = new Promise((resolve) => { + settle = resolve; + }); + return { projectPath, anchor, settle, consumed: false }; +} + +/** 订阅侧用:已有同项目闸门就复用(首屏可能已经开好),换项目才新开一道。 */ +export function reuseOrOpenDirectHistoryAnchorGate( + current: DirectHistoryAnchorGate | null, + projectPath: string, +): DirectHistoryAnchorGate { + if (current && current.projectPath === projectPath) { + return current; + } + return openDirectHistoryAnchorGate(projectPath); +} + +/** + * 首屏读取侧用:取这次要等的闸门,`null` 表示这次首屏不该等锚点。 + * + * 没有这个项目的闸门(打开项目时订阅 effect 还没跑)就新开一道;同一个订阅下已经用过 + * (重开同一个项目)没有新回执可等,返回 `null` 让调用方按当前文件尾取尾屏。 + */ +export function directHistoryAnchorGateToWaitFor( + current: DirectHistoryAnchorGate | null, + projectPath: string, +): DirectHistoryAnchorGate | null { + const sameProjectGate = + current && current.projectPath === projectPath ? current : null; + if (!sameProjectGate) { + return openDirectHistoryAnchorGate(projectPath); + } + return sameProjectGate.consumed ? null : sameProjectGate; +} diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/directHistoryPaging.ts b/apps/ai-game-creator-shell/src/features/project-workspace/directHistoryPaging.ts new file mode 100644 index 000000000..3065848b0 --- /dev/null +++ b/apps/ai-game-creator-shell/src/features/project-workspace/directHistoryPaging.ts @@ -0,0 +1,84 @@ +/** + * DirectProject 历史分页连拉:一次翻页操作连续取页,直到出现用户看得见的变化。 + * + * 可见性判断留在前端聊天投影(后端切片只按原始条目切片):工具卡片与思考文本同样占满一屏, + * 所以一整页完全可能都落进已经渲染的回合里,对用户就是"点了没变化"。判据因此取**合并后 + * 聊天投影的回合数增加**(出现新的用户气泡),而不是"这一页里有没有能渲染的条目"。 + * + * 这里是该判据的唯一出处:首屏与「显示更早」共用同一条循环,别在调用点各写一份。 + */ + +import { DIRECT_HISTORY_MAX_PAGES_PER_ACTION } from '../../app/constants'; +import { + type DirectChatEntry, + mergeHistoryEntries, + projectDirectHistoryItems, +} from './directThreadChat'; +import type { + DirectThreadHistorySlice, + DirectThreadItem, +} from './directThreadEvents'; +import { buildDirectChatTurns } from './directTurnPresentation'; + +export type DirectHistoryPages = { + /** 累积到的条目,保持文件顺序(旧 → 新)。 */ + items: DirectThreadItem[]; + /** 后端声明的"还有更早的历史";取不动时收口为 false。 */ + hasMore: boolean; + /** 下一屏的锚点;取不动或失败时停在最后一个可用的锚点上。 */ + firstItemId: string | null; + /** 中断原因;`null` 表示正常停止。 */ + error: unknown; +}; + +function visibleTurnCount(entries: readonly DirectChatEntry[]): number { + return buildDirectChatTurns({ entries }).length; +} + +/** 取页是"从新往旧"的,拼回文件顺序要整个翻过来。 */ +function inFileOrder(pages: readonly (readonly DirectThreadItem[])[]) { + return [...pages].reverse().flat(); +} + +export async function readDirectHistoryPages({ + existingEntries, + beforeItemId, + readSlice, +}: { + /** 当前聊天视图里的条目:判据必须和用户看到的是同一份。 */ + existingEntries: readonly DirectChatEntry[]; + beforeItemId: string | null; + readSlice: (beforeItemId: string | null) => Promise; +}): Promise { + const baseTurnCount = visibleTurnCount(existingEntries); + const pages: DirectThreadItem[][] = []; + let anchor = beforeItemId; + let hasMore = false; + let error: unknown = null; + for (let page = 0; page < DIRECT_HISTORY_MAX_PAGES_PER_ACTION; page += 1) { + let slice: DirectThreadHistorySlice; + try { + slice = await readSlice(anchor); + } catch (thrown) { + // 已经取到的页照常交给调用方:第 N 页失败不该丢掉前 N-1 页。 + error = thrown; + break; + } + pages.push([...slice.items]); + hasMore = slice.hasMore; + const nextAnchor = slice.firstItemId; + if (slice.items.length === 0 || !nextAnchor || nextAnchor === anchor) { + // 锚点不前进就再也取不到更早的页:继续只会重复拿回同一个窗口。按"取不动了"收口。 + hasMore = false; + break; + } + anchor = nextAnchor; + if (!hasMore) break; + const merged = mergeHistoryEntries( + projectDirectHistoryItems(inFileOrder(pages)), + existingEntries, + ); + if (visibleTurnCount(merged) > baseTurnCount) break; + } + return { items: inFileOrder(pages), hasMore, firstItemId: anchor, error }; +} diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/directThreadChat.ts b/apps/ai-game-creator-shell/src/features/project-workspace/directThreadChat.ts new file mode 100644 index 000000000..5838bfb9c --- /dev/null +++ b/apps/ai-game-creator-shell/src/features/project-workspace/directThreadChat.ts @@ -0,0 +1,516 @@ +/** + * DirectProject 聊天 reducer:把运行态事件与历史切片归并成同一份聊天条目。 + * + * 事实源只有一个——项目对话历史;运行态事件只负责"当前回合"。顺序 = 历史文件顺序 + + * 运行态独有条目。这里不做可见性判断(那是投影的事):DirectProject 同一时刻只有一个回合在跑, + * `turn.started` / `turn.completed` 只切换"是否还在跑"这一个布尔;回合身份只用原生生命周期 + * 事件自带的 canonical user identity(`userItemId`)做展示边界关联,不新建回合注册表。 + */ + +import type { GameCreatorDirectToolCall } from '../../app/types'; +import type { + DirectThreadConsumeResult, + DirectThreadEvent, + DirectThreadHistorySlice, + DirectThreadItem, + DirectThreadSubscriptionBootstrap, +} from './directThreadEvents'; +import { projectDirectThreadItem } from './directThreadItemProjection'; + +export type DirectChatEntryKind = 'message' | 'reasoning' | 'tool'; + +/** 聊天卡片里的工具形状:持久化卡片去掉回合身份(Rust 侧已经不下发 turn id)。 */ +export type DirectChatToolCard = Omit; + +/** 聊天视图里的一条条目;运行态事件与历史切片共用的唯一形状。 */ +export type DirectChatEntry = { + itemId: string; + kind: DirectChatEntryKind; + role?: 'user' | 'assistant' | null; + text?: string | null; + toolCall?: DirectChatToolCard | null; + /** 条目展示时间(`item.at`);只用于气泡 / 条目的时间显示,不作任何计时的起止边界。 */ + at?: number; + /** + * 本轮起点的展示元数据:原生 `turn.started.at`。 + * + * 只在该轮结束时盖到本轮条目上:回合收口后运行态清空,条目上的这组边界是本次会话里 + * 唯一还记得终态的载体。它是**本次会话的展示缓存**(不落盘、不进历史文件), + * 页面刷新后旧历史仍然没有边界、也不会因此显示出总耗时。不是第二套生命周期。 + */ + turnStartedAt?: number; + /** + * 本轮明确终态时间的展示元数据:`turn.completed.at`(或宿主终止收口的观测时间)。 + * 缺失时不写,不能拿最后一条工具 / 正文的时间顶替。 + */ + turnEndedAt?: number; +}; + +export type DirectThreadChatState = { + /** 最新回合是否还在跑;只由生命周期事件的先后决定。 */ + turnRunning: boolean; + /** 原生 `turn.started.at`:本轮用户实际发送时间缺失时的起点兜底;0 = 缺失。 */ + turnStartedAt: number; + /** 本轮明确终态时间;只写一次,0 = 还没有可证明的终态时间。 */ + turnEndedAt: number; + /** + * 本轮 canonical user identity(原生生命周期事件带的 `userItemId`,即本轮用户条目身份)。 + * + * 收口时按它精确回填本轮开口条目的展示边界:身份来自原生事件,不是第二套回合状态源, + * 也不用时间戳近似。空串 = 原生没给身份(旧事件),此时不猜历史归属。 + */ + turnUserItemId: string; + /** 历史切片条目,保持文件顺序。 */ + history: DirectChatEntry[]; + /** 当前回合的运行态条目,保持到达顺序;回合结束即并入历史并清空。 */ + live: DirectChatEntry[]; +}; + +export function emptyDirectThreadChatState(): DirectThreadChatState { + return { + turnRunning: false, + turnStartedAt: 0, + turnEndedAt: 0, + turnUserItemId: '', + history: [], + live: [], + }; +} + +/** 时间戳合法性:缺失 / 0 / 非有限都算没有这个边界,不用它计任何耗时。 */ +function validBoundaryAt(value: number | null | undefined): number { + return typeof value === 'number' && Number.isFinite(value) && value > 0 + ? value + : 0; +} + +/** + * 事件级阶段时间(毫秒):原生在 `turn.started` / `turn.completed` / `item.started` / + * `item.completed` 上给出的 `at`。 + * + * 它与条目里的 `item.at` 是两件事:后者是条目展示时间,不作工具计时的起止边界。 + * 字段由生成绑定声明(ts-rs),重放沿用原值,因此这里只读不取当前时间。 + */ +export function readDirectThreadEventAt(event: DirectThreadEvent): number { + return validBoundaryAt('at' in event ? event.at : 0); +} + +/** + * 生命周期事件自带的 canonical user identity(`userItemId`),即本轮用户条目的稳定 itemId。 + * + * 字段可选:缺失表示身份不可证明(旧事件保持原有顺序语义),此时不猜历史归属,也不拿 + * 时间戳近似。 + */ +export function readDirectThreadEventUserItemId( + event: DirectThreadEvent, +): string { + const value = 'userItemId' in event ? event.userItemId : ''; + return typeof value === 'string' ? value.trim() : ''; +} + +function longerText( + left: string | null | undefined, + right: string | null | undefined, +): string | null { + const a = typeof left === 'string' ? left : ''; + const b = typeof right === 'string' ? right : ''; + // 正文只增不减:增量往同一段落追加,完成快照可能比累计更长(漏过几条 delta)。 + return b.length > a.length ? b : a; +} + +function mergeToolStatus( + left: DirectChatToolCard['status'] | null | undefined, + right: DirectChatToolCard['status'] | null | undefined, +): DirectChatToolCard['status'] { + // 只有终态才算数:先到的 `running` 允许被后到的完成 / 失败覆盖,反过来不行。 + if (left === 'running' || !left) return right ?? left ?? 'running'; + return left; +} + +/** 工具的终态:完成 / 失败一旦抵达就不再被后续快照改写。 */ +function isTerminalToolStatus( + status: DirectChatToolCard['status'] | null | undefined, +) { + return status === 'completed' || status === 'failed'; +} + +/** + * 工具终点只写一次:卡片已经拿到终态和终点时间之后,迟到 / 重放的事件不得把它抬高, + * 也不得把它抹掉;只有"还没冻结"时才允许用后到的、更晚的时间补上终点。 + */ +function mergeToolCardEndAt( + left: DirectChatToolCard, + right: DirectChatToolCard, +): number { + const leftUpdatedAt = Number.isFinite(left.updatedAt) ? left.updatedAt : 0; + if (isTerminalToolStatus(left.status) && leftUpdatedAt > 0) { + return leftUpdatedAt; + } + const rightUpdatedAt = Number.isFinite(right.updatedAt) ? right.updatedAt : 0; + return Math.max(leftUpdatedAt, rightUpdatedAt); +} + +function mergeToolCard( + left: DirectChatToolCard | null, + right: DirectChatToolCard | null, +): DirectChatToolCard | null { + if (!left) return right; + if (!right) return left; + return { + ...left, + kind: left.kind && left.kind !== 'other' ? left.kind : right.kind, + title: left.title?.trim() ? left.title : right.title, + summary: left.summary?.trim() ? left.summary : right.summary, + status: mergeToolStatus(left.status, right.status), + detail: { + command: left.detail.command ?? right.detail.command, + output: left.detail.output ?? right.detail.output, + changes: left.detail.changes?.length + ? left.detail.changes + : right.detail.changes, + }, + startedAt: left.startedAt > 0 ? left.startedAt : right.startedAt, + updatedAt: mergeToolCardEndAt(left, right), + }; +} + +/** + * 先到的快照赢,后到的只补空字段。 + * + * 三个例外只有"后到信息一定更全"时才成立:正文取更长的一份、工具状态允许从 `running` + * 升级到终态、`updatedAt` 取较新的时间。其余字段一律先到先用,后到的空值不得抹掉它。 + */ +export function mergeDirectChatEntry( + existing: DirectChatEntry, + incoming: DirectChatEntry, +): DirectChatEntry { + return { + itemId: existing.itemId || incoming.itemId, + kind: + existing.kind === 'tool' || incoming.kind === 'tool' + ? 'tool' + : existing.kind, + role: existing.role ?? incoming.role ?? null, + text: longerText(existing.text, incoming.text), + toolCall: mergeToolCard( + existing.toolCall ?? null, + incoming.toolCall ?? null, + ), + at: existing.at || incoming.at, + // 展示元数据先到先用:后到的重放 / 历史切片不得覆盖已经确定的边界。 + turnStartedAt: existing.turnStartedAt || incoming.turnStartedAt, + turnEndedAt: existing.turnEndedAt || incoming.turnEndedAt, + }; +} + +function upsertLiveEntry( + state: DirectThreadChatState, + entry: DirectChatEntry, +): DirectThreadChatState { + const index = state.live.findIndex( + (existing) => existing.itemId === entry.itemId, + ); + if (index < 0) { + return { ...state, live: [...state.live, entry] }; + } + const existing = state.live[index]; + if (!existing) { + return { ...state, live: [...state.live, entry] }; + } + const live = [...state.live]; + live[index] = mergeDirectChatEntry(existing, entry); + return { ...state, live }; +} + +/** + * 只补条目上缺失的展示边界:已经写上的起点 / 终点不会被后来的收口改写(冻结先到值)。 + * + * 收口会被调用多次(重复的终态事件、宿主终止收口 + 原生补发的终态),因此这里必须是 + * 单向填空而不是 `{...entry, ...boundary}` 覆盖——否则迟到 / 重放的那一次会把已经冻结的 + * 终点抬高,界面上的总耗时在收口之后又变一次。 + */ +function withTurnBoundary( + entry: DirectChatEntry, + boundary: Pick, +): DirectChatEntry { + const turnStartedAt = entry.turnStartedAt || boundary.turnStartedAt; + const turnEndedAt = entry.turnEndedAt || boundary.turnEndedAt; + return { + ...entry, + ...(turnStartedAt ? { turnStartedAt } : {}), + ...(turnEndedAt ? { turnEndedAt } : {}), + }; +} + +/** + * 按 canonical user identity 给本轮开口条目补展示边界:只有 `itemId` 正好是本轮 + * `userItemId` 的那一条(可能已经在历史里,也可能收口之后才回读到)才拿边界。 + * + * 没有身份(旧事件)时原样返回:不猜历史归属,旧历史继续隐藏未知用时。 + */ +function stampTurnUserEntry( + entries: DirectChatEntry[], + userItemId: string, + boundary: Pick, +): DirectChatEntry[] { + if (!userItemId || Object.keys(boundary).length === 0) return entries; + let matched = false; + const next = entries.map((entry) => { + if (entry.itemId !== userItemId) return entry; + matched = true; + return withTurnBoundary(entry, boundary); + }); + return matched ? next : entries; +} + +function appendLiveText( + state: DirectThreadChatState, + event: Extract, +): DirectThreadChatState { + const itemId = event.itemId.trim(); + if (!itemId || !event.delta) return state; + const reasoning = event.kind === 'reasoning'; + const existing = state.live.find((entry) => entry.itemId === itemId); + return upsertLiveEntry(state, { + itemId, + kind: reasoning ? 'reasoning' : 'message', + role: reasoning ? null : 'assistant', + text: `${existing?.text ?? ''}${event.delta}`, + }); +} + +export function reduceDirectThreadEvent( + state: DirectThreadChatState, + event: DirectThreadEvent, +): DirectThreadChatState { + switch (event.type) { + case 'turn.started': { + const eventAt = readDirectThreadEventAt(event); + // 回合身份只认**事件流顺序**,不拿时间戳大小当身份:原生回合时间是秒级精度、 + // 宿主收口时间可能带毫秒,"上一轮结束之后又来一条 turn.started"就是新回合, + // 哪怕它落在同一秒。同一轮内部的重复开始事件(真正重放)在流里表现为 + // "还在跑时又收到 turn.started",那种情况保留第一次的起点。 + const turnStartedAt = + state.turnRunning && state.turnStartedAt > 0 + ? state.turnStartedAt + : eventAt; + // 本轮的 canonical user identity 跟着事件走:新回合就换成新的;旧原生不带身份时 + // 清空而不是继承上一轮,避免上一轮迟到的终态按身份匹配到这一轮。 + const turnUserItemId = readDirectThreadEventUserItemId(event); + return { + ...state, + turnRunning: true, + turnStartedAt, + turnEndedAt: 0, + turnUserItemId, + }; + } + case 'turn.completed': { + const eventAt = readDirectThreadEventAt(event); + const eventUserItemId = readDirectThreadEventUserItemId(event); + // 带身份的终态只收本轮的:上一轮迟到 / 重放的终态按 id 拒绝,绝不关掉正在跑的这一轮。 + // 判据是身份,不是时间戳大小(同一秒内的新回合也必须能正常收口)。 + if ( + eventUserItemId && + state.turnUserItemId && + eventUserItemId !== state.turnUserItemId + ) { + return state; + } + // 已经收口、而且没有新的运行态条目:重复 / 迟到的终态事件不改动时间,也不复活运行态。 + if (!state.turnRunning && state.live.length === 0) { + return state; + } + return finishDirectThreadTurn(state, eventAt); + } + case 'item.delta': + return appendLiveText(state, event); + case 'item.started': + case 'item.completed': { + const eventAt = readDirectThreadEventAt(event); + const entry = projectDirectThreadItem( + event.item, + event.type === 'item.started' + ? { startedAt: eventAt } + : { completedAt: eventAt }, + ); + if (!entry) { + return state; + } + // 身份已经落盘过(同一个 itemId 只属于一个回合):这是迟到 / 重放的条目, + // 直接补进历史里同一条目(只补空字段、不改冻结的终点),不塞进运行态—— + // 否则它会挂到已经收口的那一轮之后的运行态里,被算进还没开始的新回合。 + const historyIndex = state.history.findIndex( + (existing) => existing.itemId === entry.itemId, + ); + if (historyIndex >= 0) { + const history = [...state.history]; + const existing = history[historyIndex]; + if (existing) { + history[historyIndex] = mergeDirectChatEntry(existing, entry); + return { ...state, history }; + } + } + return upsertLiveEntry(state, entry); + } + case 'request': + // 审批 / 提问只影响面板交互,不并入聊天条目。 + return state; + default: + return state; + } +} + +/** + * 回合收口:把运行态条目并入历史、清空运行态,并固定本轮终态时间。 + * + * `endedAt` 只接受明确的终态时间(`turn.completed.at`,或宿主终止收口时观测到的时刻): + * 缺失就是缺失,宁可不显示总耗时,也不用最后一条工具 / 正文的时间顶替。 + * 已经冻结的终态时间不会被后来的调用抬高;开始时间只记原生值,用户实际发送时间的优先级 + * 由投影层决定(条目上的 `at` 才是气泡时间)。 + */ +export function finishDirectThreadTurn( + state: DirectThreadChatState, + endedAt: number | null | undefined, +): DirectThreadChatState { + const turnEndedAt = + state.turnEndedAt > 0 ? state.turnEndedAt : validBoundaryAt(endedAt); + const turnStartedAt = state.turnStartedAt; + const boundary = { + ...(turnStartedAt > 0 ? { turnStartedAt } : {}), + ...(turnEndedAt > 0 ? { turnEndedAt } : {}), + }; + const stamped = state.live.map((entry) => withTurnBoundary(entry, boundary)); + // 本轮开口条目按 canonical user identity 精确回填:它可能已经在历史里(历史切片先到), + // 也可能这一轮压根没有运行态条目。命中不了就不盖——不按历史尾或用时间戳猜归属。 + const history = stampTurnUserEntry( + state.history, + state.turnUserItemId, + boundary, + ); + return { + ...state, + turnRunning: false, + turnStartedAt, + turnEndedAt, + history: mergeHistoryEntries(history, stamped), + live: [], + }; +} + +/** + * 取消 / 收口回包是否还对应界面上的这一轮。 + * + * 按 canonical user identity 判(不是时间戳):调用方先核对回包里的 clientTurnId 对应的 + * 用户条目就是当前这一轮,再本地收口;否则(新回合已经开始)不要在新回合的回调里收口旧轮。 + * 任一侧没有身份(旧原生)时返回真,保持原有顺序语义。 + */ +export function directThreadTurnMatchesUser( + state: DirectThreadChatState, + userItemId: string, +): boolean { + const expected = userItemId.trim(); + if (!expected || !state.turnUserItemId) return true; + return state.turnUserItemId === expected; +} + +export function reduceDirectThreadEvents( + state: DirectThreadChatState, + events: readonly DirectThreadEvent[], +): DirectThreadChatState { + return events.reduce(reduceDirectThreadEvent, state); +} + +/** + * bootstrap 是运行态的唯一权威:游标已在队尾,返回的事件就是此刻要处理的事件。 + * + * 订阅身份与首屏历史锚点(`subscriptionId` / `lastCompletedItemId`)是订阅循环自己的局部 + * 事实,不进聊天状态:这里只把 bootstrap 事件 reduce 进现有状态。 + */ +export function resolveDirectThreadBootstrap( + state: DirectThreadChatState, + bootstrap: DirectThreadSubscriptionBootstrap, +): DirectThreadChatState { + return reduceDirectThreadEvents(state, bootstrap.events); +} + +/** 事件顺序 = 游标顺序;调用方只需要把 `consume` 的结果喂进来。 */ +export function applyDirectThreadConsumeResult( + state: DirectThreadChatState, + result: DirectThreadConsumeResult, +): DirectThreadChatState { + return reduceDirectThreadEvents(state, result.events); +} + +/** 同一身份的条目合并,先到者在前:历史在前、运行态在后,运行态只补空。 */ +export function mergeHistoryEntries( + leading: readonly DirectChatEntry[], + trailing: readonly DirectChatEntry[], +): DirectChatEntry[] { + const byId = new Map(); + const entries: DirectChatEntry[] = []; + for (const entry of [...leading, ...trailing]) { + const index = byId.get(entry.itemId); + if (index === undefined) { + byId.set(entry.itemId, entries.length); + entries.push(entry); + continue; + } + const existing = entries[index]; + if (existing) entries[index] = mergeDirectChatEntry(existing, entry); + } + return entries; +} + +/** 历史切片条目 → 聊天条目:可见性判定的唯一入口,分页判据也读这一份。 */ +export function projectDirectHistoryItems( + items: readonly DirectThreadItem[], +): DirectChatEntry[] { + return items + .map((item) => projectDirectThreadItem(item)) + .filter((entry): entry is DirectChatEntry => Boolean(entry)); +} + +/** + * 历史切片并入:切片是脱敏原始条目,投影规则与运行态完全同一份。 + * + * 同一调用的调用与输出在这里按身份合并成一张卡片,而不是在 Rust 侧合并。 + */ +export function mergeDirectHistoryItems( + state: DirectThreadChatState, + items: readonly DirectThreadItem[], +): DirectThreadChatState { + const history = mergeHistoryEntries( + projectDirectHistoryItems(items), + state.history, + ); + // 终态可能先于历史切片到达:收口之后才回读到本轮开口条目时,按同一个 canonical + // user identity 把已经冻结的边界补上(只填空;旧历史没有身份或身份不匹配就不盖)。 + const stamped = + state.turnEndedAt > 0 + ? stampTurnUserEntry(history, state.turnUserItemId, { + ...(state.turnStartedAt > 0 + ? { turnStartedAt: state.turnStartedAt } + : {}), + turnEndedAt: state.turnEndedAt, + }) + : history; + return { + ...state, + history: stamped, + }; +} + +export function mergeDirectThreadHistorySlice( + state: DirectThreadChatState, + slice: DirectThreadHistorySlice, +): DirectThreadChatState { + return mergeDirectHistoryItems(state, slice.items); +} + +/** 聊天投影输入:历史顺序 + 运行态覆盖;运行态独有条目排在最后。 */ +export function selectDirectChatEntries( + state: DirectThreadChatState, +): DirectChatEntry[] { + return mergeHistoryEntries(state.history, state.live); +} diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/directThreadEvents.ts b/apps/ai-game-creator-shell/src/features/project-workspace/directThreadEvents.ts index 6cd3bf15d..741e694f3 100644 --- a/apps/ai-game-creator-shell/src/features/project-workspace/directThreadEvents.ts +++ b/apps/ai-game-creator-shell/src/features/project-workspace/directThreadEvents.ts @@ -1,123 +1,17 @@ -import type { - ChatMessage, - LocalConversationMessageRecord, -} from '../../app/types'; +/** + * DirectProject 运行态事件的线上类型。 + * + * 类型由 Rust 侧 ts-rs 导出(改完 Rust 模型后跑 `cargo test export_bindings`),这里只做 + * 入口转发:前端不再自己抄一份形状,字段增删必须改 Rust。 + */ -export type DirectThreadRawEvent = { - seq: number; - type: string; - turnId: string; - itemId?: string; - payload: Record; -}; - -export type DirectThreadSubscriptionBootstrap = { - subscriptionId: string; - lastCompletedItemId: string | null; - events: DirectThreadRawEvent[]; -}; - -export type DirectThreadConsumeResult = { - events: DirectThreadRawEvent[]; -}; - -export type DirectThreadHistorySlice = { - items: unknown[]; - hasMore: boolean; - itemTimestamps?: Record; - oldestItemId?: string | null; -}; - -/** 游标取原始响应,而非过滤后的聊天消息;拒绝不能前进的页,避免静默反复回读。 */ -export function directThreadHistoryPage( - slice: DirectThreadHistorySlice, - previousCursor: string | null = null, -) { - const first = slice.items[0]; - const firstId = - first && typeof first === 'object' && 'id' in first - ? (first as { id?: unknown }).id - : null; - const cursor = - slice.oldestItemId ?? - (typeof firstId === 'string' && firstId ? firstId : null); - if (slice.hasMore && (!cursor || cursor === previousCursor)) { - throw new Error('对话历史分页游标未前进,请重新读取项目历史'); - } - return { - messages: directThreadHistoryItemsToMessages( - slice.items, - slice.itemTimestamps, - ), - hasMore: slice.hasMore, - cursor, - }; -} - -/** 保留当前实时/已显示版本;原始身份相同的回读消息不能插入第二次。 */ -export function prependDirectHistoryMessages( - current: readonly ChatMessage[], - older: readonly ChatMessage[], -): ChatMessage[] { - const ids = new Set( - current.flatMap((message) => - message.messageId ? [message.messageId] : [], - ), - ); - const additions = older.filter((message) => { - if (!message.messageId) return true; - if (ids.has(message.messageId)) return false; - ids.add(message.messageId); - return true; - }); - return [...additions, ...current]; -} - -export function directThreadHistoryItemsToMessages( - items: unknown[], - itemTimestamps: Readonly> = {}, -): LocalConversationMessageRecord[] { - return items.flatMap((raw) => { - if (!raw || typeof raw !== 'object') return []; - const item = raw as Record; - const role = item.role; - if (role !== 'user' && role !== 'assistant') return []; - const messageRole = role as 'user' | 'assistant'; - const content = Array.isArray(item.content) - ? item.content - .map((part) => - part && typeof part === 'object' && 'text' in part - ? (part as { text?: unknown }).text - : null, - ) - .filter((text): text is string => typeof text === 'string') - .join('') - : ''; - if (!content) return []; - const messageId = typeof item.id === 'string' ? item.id : undefined; - return [ - { - schemaVersion: 'agc-direct-project-context.v1', - role: messageRole, - content, - agentId: null, - messageId, - updatedAt: messageId ? (itemTimestamps[messageId] ?? 0) : 0, - }, - ]; - }); -} - -/** 只有这些状态表示仍持有活动回合;Provider 回放的终态不是活动快照。 */ -export function isDirectTurnInProgress( - status: string | null | undefined, -): status is 'accepted' | 'running' | 'streaming' | 'finalizing' { - return ( - status === 'accepted' || - status === 'running' || - status === 'streaming' || - status === 'finalizing' - ); -} - -export type { DirectThreadEvent, DirectThreadItem } from './generated'; +export type { + DirectThreadConsumeResult, + DirectThreadDeltaKind, + DirectThreadEvent, + DirectThreadFileChange, + DirectThreadHistorySlice, + DirectThreadItem, + DirectThreadRequestKind, + DirectThreadSubscriptionBootstrap, +} from './generated'; 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 new file mode 100644 index 000000000..c24e9643d --- /dev/null +++ b/apps/ai-game-creator-shell/src/features/project-workspace/directThreadItemProjection.ts @@ -0,0 +1,349 @@ +/** + * DirectProject「原始条目 → 聊天条目」投影。 + * + * 输入是 Rust 侧 ts-rs 导出的 `DirectThreadItem`(脱敏后的 Codex 原始条目),工具卡片的 + * `kind`、标题、折叠摘要、状态判定和可见性全部在这里完成。运行态事件与历史切片走同一个 + * 函数,因此实时与回读不可能出现两套口径。 + */ + +import type { + GameCreatorDirectToolCallChange, + GameCreatorDirectToolCallDetail, + GameCreatorDirectToolCallKind, + GameCreatorDirectToolCallStatus, +} from '../../app/types'; +import type { DirectChatEntry, DirectChatToolCard } from './directThreadChat'; +import type { DirectThreadItem } from './directThreadEvents'; + +/** 折叠态摘要上限,与卡片契约一致。 */ +const TOOL_SUMMARY_MAX_CHARS = 120; + +const FAILED_ITEM_STATUS = new Set([ + 'failed', + 'declined', + 'cancelled', + 'canceled', + 'aborted', +]); + +function firstLine(value: string): string { + const [line = ''] = value.split('\n'); + const trimmed = line.trim(); + return trimmed.length > TOOL_SUMMARY_MAX_CHARS + ? `${trimmed.slice(0, TOOL_SUMMARY_MAX_CHARS)}…` + : trimmed; +} + +function toolKindFromFunctionName(name: string): GameCreatorDirectToolCallKind { + switch (name) { + case 'exec_command': + case 'shell': + case 'exec': + return 'command'; + case 'apply_patch': + case 'write_file': + case 'edit_file': + case 'create_file': + return 'file_change'; + case 'web_search': + case 'web_search_preview': + return 'web_search'; + default: + return 'mcp_tool'; + } +} + +function toolStatus( + status: string | null, + exitCode: number | null, +): GameCreatorDirectToolCallStatus { + // 显式失败 / 取消终态最优先:上游已经说这次调用失败,就不能再被别的字段洗成成功。 + if (status && FAILED_ITEM_STATUS.has(status)) return 'failed'; + // 非零退出码优先于 `completed`:命令跑完了不等于成功(退出码 1 是失败,只认退出码, + // 不从 output 的自然语言里推断)。 + if (typeof exitCode === 'number' && exitCode !== 0) return 'failed'; + if (status === 'completed') return 'completed'; + // 退出码 0:正常完成。 + if (typeof exitCode === 'number') return 'completed'; + // 还有状态但没有终态信息:仍在跑;完全没有状态字段的调用按「已完成」处理。 + return status ? 'running' : 'completed'; +} + +function toolTitle( + kind: GameCreatorDirectToolCallKind, + changes: readonly GameCreatorDirectToolCallChange[], +): string { + switch (kind) { + case 'command': + return '执行命令'; + case 'file_change': { + const paths = new Set(changes.map((change) => change.path)); + return paths.size > 0 ? `编辑 ${paths.size} 个文件` : '编辑文件'; + } + case 'web_search': + return '联网检索'; + case 'context_compaction': + return '整理上下文'; + default: + return '调用工具'; + } +} + +function fileChanges( + item: Extract, +): GameCreatorDirectToolCallChange[] { + return item.changes + .filter((change) => change.path.trim().length > 0) + .map((change) => ({ + path: change.path, + kind: change.kind || 'update', + })); +} + +type ToolCardInput = { + itemId: string; + kind: GameCreatorDirectToolCallKind; + /** 输出条目只带输出:标题与摘要留空,交给先到的调用快照。 */ + outputOnly?: boolean; + tool?: string; + command?: string; + output?: string; + status: GameCreatorDirectToolCallStatus; + changes?: GameCreatorDirectToolCallChange[]; +}; + +/** + * 条目的事件级阶段时间:`item.started` / `item.completed` 事件上的 `at`。 + * + * 工具卡片的时间边界**只认这里**:条目自身的 `item.at` 是展示时间,不能当起止。 + * 缺失(0)就留空:这个边界不知道,由展示层隐藏不能证明的耗时,不猜时间。 + */ +export type DirectItemPhaseTime = { + /** 收到 `item.started` 时该事件的时间;其它事件与历史切片不传。 */ + startedAt?: number; + /** 收到 `item.completed` 时该事件的时间;其它事件与历史切片不传。 */ + completedAt?: number; +}; + +/** 阶段时间合法性:缺失 / 0 / 非有限一律算"没有这个边界"。 */ +function phaseTime(value: number | undefined) { + return typeof value === 'number' && Number.isFinite(value) && value > 0 + ? value + : 0; +} + +/** + * 阶段优先的状态:只有 `item.started` 阶段、而且条目本身没有终态信息时,工具就是"运行中"。 + * + * 有些条目(文件变更 / 联网检索 / 上下文整理 / 没有状态字段的 MCP 调用)type 上不带状态, + * 收到开始事件时不能一律当"已完成",否则它们在块里永远不会动态增长;真正结束由同身份的 + * `item.completed` 事件给出。条目自带的失败 / 完成状态优先,不被阶段改写。 + */ +function toolStatusForPhase( + status: GameCreatorDirectToolCallStatus, + phase: DirectItemPhaseTime, +): GameCreatorDirectToolCallStatus { + if ( + phaseTime(phase.startedAt) > 0 && + phaseTime(phase.completedAt) === 0 && + status === 'completed' + ) { + return 'running'; + } + return status; +} + +/** + * `function_call` 的时间只表示"这次调用什么时候发起":即使它是随 `item.completed` + * 到达的(调用条目本身完成了),那也不是工具结束——结束由 `function_call_output` 给出。 + * 因此这里只认起点,终点留空,不会伪造一个 0 秒的完成。 + */ +function callStartPhase(phase: DirectItemPhaseTime): DirectItemPhaseTime { + return { + startedAt: phaseTime(phase.startedAt) || phaseTime(phase.completedAt), + }; +} + +function buildToolCard( + input: ToolCardInput, + phase: DirectItemPhaseTime, +): DirectChatToolCard | null { + const changes = input.changes ?? []; + const detail: GameCreatorDirectToolCallDetail = {}; + if (input.command && !input.outputOnly) detail.command = input.command; + if (input.output) detail.output = input.output; + if (changes.length > 0) detail.changes = changes; + // 没有命令 / 输出 / 文件明细的条目不渲染成卡片:一张空卡片对用户没有信息量。 + if (!detail.command && !detail.output && !detail.changes?.length) return null; + + const summarySource = + (input.kind === 'mcp_tool' ? (input.tool ?? '') : '') || + detail.command || + changes[0]?.path || + input.tool || + ''; + return { + schemaVersion: 'agc-tool-call.v1', + id: input.itemId, + kind: input.kind, + title: input.outputOnly ? '' : toolTitle(input.kind, changes), + summary: input.outputOnly ? '' : firstLine(summarySource), + // 输出条目本身就是"结果快照",不因阶段被改成运行中。 + status: input.outputOnly + ? input.status + : toolStatusForPhase(input.status, phase), + detail, + startedAt: phaseTime(phase.startedAt), + // 终点只有一种来源:明确的完成事件时间。运行中的卡片拿不到终点(0), + // 由展示层用宿主当前时钟算增长,绝不用快照更新时间冒充"现在"。 + updatedAt: phaseTime(phase.completedAt), + }; +} + +function toolCardFromItem( + item: DirectThreadItem, + phase: DirectItemPhaseTime, +): DirectChatToolCard | null { + switch (item.itemType) { + case 'function_call': + return buildToolCard( + { + itemId: item.itemId, + kind: toolKindFromFunctionName(item.name), + tool: item.name, + command: item.arguments, + status: 'running', + }, + callStartPhase(phase), + ); + case 'function_call_output': + return buildToolCard( + { + itemId: item.itemId, + kind: 'other', + outputOnly: true, + output: item.output, + status: 'completed', + }, + phase, + ); + case 'commandExecution': + return buildToolCard( + { + itemId: item.itemId, + kind: 'command', + command: item.command, + output: item.output ?? '', + status: toolStatus(item.status, item.exitCode), + }, + phase, + ); + case 'fileChange': + return buildToolCard( + { + itemId: item.itemId, + kind: 'file_change', + changes: fileChanges(item), + status: 'completed', + }, + phase, + ); + case 'mcpToolCall': + return buildToolCard( + { + itemId: item.itemId, + kind: 'mcp_tool', + tool: item.tool, + command: item.arguments, + output: item.output ?? '', + status: toolStatus(item.status, null), + }, + phase, + ); + case 'webSearch': + return buildToolCard( + { + itemId: item.itemId, + kind: 'web_search', + command: item.query ?? '', + output: item.output ?? '', + status: 'completed', + }, + phase, + ); + case 'contextCompaction': + return buildToolCard( + { + itemId: item.itemId, + kind: 'context_compaction', + command: '整理上下文', + status: 'completed', + }, + phase, + ); + default: + return null; + } +} + +/** + * 原始条目投影成聊天条目;不属于聊天内容的条目返回 `null`。 + * + * 可见性判定只在这里:系统 / 开发者 message、无正文的空条目、未识别的 item 类型都不进 + * 聊天视图。`other` 是 Rust 原样透传的未知类型,要不要显示属于前端可见性决策,当前不显示。 + */ +export function projectDirectThreadItem( + item: DirectThreadItem | null | undefined, + phase: DirectItemPhaseTime = {}, +): DirectChatEntry | null { + if (!item) return null; + // 身份是条目唯一的主键:拿不到身份的载荷既不能渲染也不能合并,只丢弃这一条。 + const itemId = typeof item.itemId === 'string' ? item.itemId.trim() : ''; + if (!itemId) return null; + + switch (item.itemType) { + case 'message': { + const role = + item.role === 'user' + ? 'user' + : item.role === 'assistant' + ? 'assistant' + : null; + if (!role || !item.text.trim()) return null; + return { + itemId, + kind: 'message', + role, + text: item.text, + toolCall: null, + at: item.at, + }; + } + case 'reasoning': { + if (!item.text.trim()) return null; + return { + itemId, + kind: 'reasoning', + role: null, + text: item.text, + toolCall: null, + at: item.at, + }; + } + case 'other': + // TODO(direct-thread): 未识别类型目前不显示;要让它们出现只改这里,别回 Rust 加白名单。 + return null; + default: { + const toolCall = toolCardFromItem(item, phase); + if (!toolCall) return null; + return { + itemId, + kind: 'tool', + role: null, + text: null, + toolCall, + at: item.at, + }; + } + } +} 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 269b67679..4034ad94b 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,337 @@ 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), +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; + /** + * 本轮起点:该轮**实际用户消息的发送时间**优先(与气泡显示的时间同源), + * 缺失时用原生 `turn.started.at`,都拿不到是 0(此时隐藏不能证明的总耗时)。 + */ + startedAt: number; + /** 本轮明确终态时间(`turn.completed.at`);运行中或旧历史没有边界时是 0。 */ + endedAt: number; +}; + +type DirectChatTurnEntries = { + key: string; + entries: DirectChatEntry[]; + /** 本地乐观用户气泡:还没有任何落盘条目时的用户消息。 */ + localUsers: DirectChatBlock[]; + notices: ChatMessage[]; + active: boolean; +}; + +function blockFromEntry( + entry: DirectChatEntry, + key: string, + localSentAt: ReadonlyMap, +): 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: sameIdentitySentAt( + entry.itemId, + normalizeDirectTimestamp(entry.at), + localSentAt, + ), + } + : { + kind: 'assistant', + key, + text, + at: normalizeDirectTimestamp(entry.at), + }; +} + +/** + * 同身份(同一个 itemId)的本地乐观发送时间,按 messageId 建立索引。 + * + * DirectProject 运行期只在 `messages` 里保留本地乐观消息,正式条目走 `directEntries`: + * 两者身份相同(`direct-codex:{turnId}:user`),所以这里能按身份把用户真正按下发送的时刻 + * 找回来,不需要、也不允许按整轮所有条目取最小值猜起点。 + */ +function localSentTimes(messages: readonly ChatMessage[]): Map { + const sentAt = new Map(); + for (const message of messages) { + if (message.role !== 'user' || !message.messageId) continue; + const at = normalizeDirectTimestamp(message.updatedAt); + if (at <= 0) continue; + const known = sentAt.get(message.messageId); + if (known === undefined || at < known) sentAt.set(message.messageId, at); + } + return sentAt; +} + +/** + * 同身份合并后的用户发送时间:正式条目的 `at` 是宿主观测到的 ack 时间,晚于真实发送时刻。 + * 因此同一 itemId 上取两个时刻里更早的那个,迟到的 ack 顶不掉真实发送时间; + * 找不到同身份本地消息时保持条目自身的 `at`(旧历史不编造发送时间)。 + */ +function sameIdentitySentAt( + itemId: string, + entryAt: number, + localSentAt: ReadonlyMap, +): number { + const local = localSentAt.get(itemId) ?? 0; + if (local <= 0) return entryAt; + if (entryAt <= 0) return local; + return Math.min(entryAt, local); +} + +/** 连续的工具条目合成一块;中间夹了正文就分块。 */ +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 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 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, + text: message.text, + at: normalizeDirectTimestamp(message.updatedAt), + }; + } 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), - ), + kind: 'assistant', + key, + text: message.text, + at: normalizeDirectTimestamp(message.updatedAt), + notice: true, }; } -/** 完整历史先按身份归属,再决定可见回合;渲染层只消费这一个列表。 */ -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); - } - } - const ensure = (turnId: string | null, key: string) => { - const existing = rows.get(key); - if (existing) return existing; - const row: DirectTurnPresentation = { - key, - turnId, - messages: [], - notices: [], - items: [], - calls: [], - source: 'messages', - active: Boolean(turnId && turnId === activeTurnId), - transientReply: '', - startedAt: 0, - endedAt: 0, - }; - rows.set(key, row); - return row; +function newTurn(key: string): DirectChatTurnEntries { + return { + key, + entries: [], + localUsers: [], + notices: [], + active: false, }; +} - // 原始 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`, - ); +/** + * 条目 + 运行期本地消息 → 回合列表。 + * + * 每个用户条目开一个新回合;本地用户气泡(乐观发送)也算开新回合;本地 assistant 消息 + * (失败 / 终止说明)挂到当前回合末尾。同身份的本地消息不重复渲染:条目赢。 + */ +export function buildDirectChatTurns({ + entries, + localMessages = [], + turnRunning = false, + turnStartedAt = 0, +}: { + entries: readonly DirectChatEntry[]; + localMessages?: readonly ChatMessage[]; + turnRunning?: boolean; + /** + * 当前回合的原生起点(`turn.started.at`):只在该轮用户发送时间缺失时兜底, + * 不会覆盖用户实际发送时间,也不参与已完成回合。 + */ + turnStartedAt?: number; +}): DirectChatTurn[] { + const turns: DirectChatTurnEntries[] = []; + let current: DirectChatTurnEntries | null = null; + const localSentAt = localSentTimes(localMessages); + // 分页切片的开头可能落在半截回合里(那一条用户条目还在更早的一屏):这些前导条目先攒着, + // 交给后面第一个用户条目开的回合,避免渲染出一个没有用户气泡的孤儿回合。 + const leadingEntries: DirectChatEntry[] = []; + for (const entry of entries) { + if (entry.kind === 'message' && entry.role === 'user') { + current = newTurn(entry.itemId); + turns.push(current); + if (leadingEntries.length > 0) { + current.entries.push(...leadingEntries); + leadingEntries.length = 0; } } - if (row.active && row.source === 'messages' && assistants.length === 0) { - row.transientReply = transientReply; + if (!current) { + leadingEntries.push(entry); + continue; } - 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)), - ); + current.entries.push(entry); } - return [...rows.values()].filter( - (row) => - row.active || - visibleKeys.has(row.key) || - (!hasUnloadedHistory && - row.messages.length === 0 && - (row.items.length > 0 || row.calls.length > 0)), - ); + if (!current && leadingEntries.length > 0) { + // 整份历史都没有用户条目:仍然保留一个回合承载正文。 + current = newTurn(`history:${turns.length}`); + current.entries.push(...leadingEntries); + turns.push(current); + } + + 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}`, + localSentAt, + ); + 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); + }); + // 回合边界只认两件事:该轮用户气泡自己的发送时间(不是所有条目的最小值), + // 以及明确的终态事件时间。回合进行中先给"进行中"的滚动总耗时,结束后冻结。 + let userSentAt = 0; + for (const block of users) { + if (block.kind === 'user' && block.at > 0) { + userSentAt = block.at; + break; + } + } + const stampedStart = turn.entries.reduce( + (found, entry) => found || normalizeDirectTimestamp(entry.turnStartedAt), + 0, + ); + const stampedEnd = turn.entries.reduce( + (found, entry) => found || normalizeDirectTimestamp(entry.turnEndedAt), + 0, + ); + const startedAt = + userSentAt > 0 + ? userSentAt + : turn.active + ? normalizeDirectTimestamp(turnStartedAt) + : stampedStart; + // 终态只读**本轮条目**上盖的边界:跨轮 fallback 会把最新回合的终点填进所有 + // 拿不到时间的旧历史回合,等于给未知耗时编一个值。 + const endedAt = turn.active ? 0 : stampedEnd; + 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/src/features/project-workspace/toolCallGroupPresentation.ts b/apps/ai-game-creator-shell/src/features/project-workspace/toolCallGroupPresentation.ts index a48a125bd..9c953ac03 100644 --- a/apps/ai-game-creator-shell/src/features/project-workspace/toolCallGroupPresentation.ts +++ b/apps/ai-game-creator-shell/src/features/project-workspace/toolCallGroupPresentation.ts @@ -1,7 +1,6 @@ -import type { - GameCreatorDirectToolCall, - GameCreatorDirectToolCallKind, -} from '../../app/types'; +import { formatElapsedDuration } from '../../../../../packages/shared/src/lib/formatElapsedDuration'; +import type { GameCreatorDirectToolCallKind } from '../../app/types'; +import type { DirectChatToolCard } from './directThreadChat'; /** * 工具调用折叠块的纯文案计算:汇总 / 行文案。 @@ -11,25 +10,6 @@ import type { * 组件文件不导出非组件值,也便于单测直接断言文案规则)。 */ -/** 汇总文案里 kind 的固定顺序:command → file_change → mcp_tool → web_search → context_compaction → other。 */ -const TOOL_CALL_KIND_ORDER: GameCreatorDirectToolCallKind[] = [ - 'command', - 'file_change', - 'mcp_tool', - 'web_search', - 'context_compaction', - 'other', -]; - -const TOOL_CALL_KIND_LABELS: Record = { - command: '命令', - file_change: '文件变更', - mcp_tool: '工具调用', - web_search: '联网搜索', - context_compaction: '上下文整理', - other: '其他操作', -}; - /** 行文案动词:`已运行 {summary}` / `已编辑 {summary}` / …,`context_compaction` 不带 summary。 */ export const TOOL_CALL_ROW_VERBS: Partial< Record @@ -41,43 +21,21 @@ export const TOOL_CALL_ROW_VERBS: Partial< other: '已执行', }; -/** 汇总文案:按 kind 计数、固定顺序拼成 `已执行 5 个命令、2 个文件变更`;空集合返回空串。 */ +/** + * 汇总文案:`执行了 N 个操作`(N = 这一组自己的调用数,不按段落数、不拆 kind)。 + * + * 与耗时一起构成用户口径的 `执行了 X 个操作,耗时 XXX`:耗时由调用方作为 meta 追加, + * 这里只出前半句。 + */ export function toolCallGroupSummary( - calls: GameCreatorDirectToolCall[], - running = false, -) { - const counts = new Map(); - for (const call of calls) { - counts.set(call.kind, (counts.get(call.kind) ?? 0) + 1); - } - const parts: string[] = []; - const append = (kind: string) => { - const count = counts.get(kind) ?? 0; - if (count <= 0) { - return; - } - counts.delete(kind); - const label = - TOOL_CALL_KIND_LABELS[kind as GameCreatorDirectToolCallKind] ?? - '其他操作'; - parts.push(`${count} 个${label}`); - }; - for (const kind of TOOL_CALL_KIND_ORDER) { - append(kind); - } - // 契约外的 kind:不丢计数,落到末尾的「其他操作」。 - for (const kind of [...counts.keys()]) { - append(kind); - } - return parts.length > 0 - ? running - ? `已执行 ${parts.join('、')}进行中` - : `已执行 ${parts.join('、')}` - : ''; + calls: readonly DirectChatToolCard[], +): string { + if (calls.length === 0) return ''; + return `执行了 ${calls.length} 个操作`; } /** 一行工具的文案:只用工具本身的摘要(不带"已运行"这类动词前缀),状态由行尾状态列表达。 */ -export function toolCallRowText(call: GameCreatorDirectToolCall) { +export function toolCallRowText(call: DirectChatToolCard) { // 行首不再写"已运行/已编辑"这类动词前缀:命令状态由行尾的状态列表达 // (执行中 / 已执行 / 失败),前缀会和它重复。 if (call.kind === 'context_compaction') { @@ -86,7 +44,7 @@ export function toolCallRowText(call: GameCreatorDirectToolCall) { return toolCallRowSummary(call); } -function toolCallRowSummary(call: GameCreatorDirectToolCall) { +function toolCallRowSummary(call: DirectChatToolCard) { if (call.kind === 'command') { // 历史摘要可能已被可执行文件路径占满;优先从完整、已脱敏的输入提取正文。 const script = windowsPowerShellCommandBody( @@ -114,7 +72,7 @@ function toolCallRowSummary(call: GameCreatorDirectToolCall) { } /** 仅格式化卡片输入,不修改执行参数、历史记录或工具输出。 */ -export function toolCallInputText(call: GameCreatorDirectToolCall) { +export function toolCallInputText(call: DirectChatToolCard) { const input = call.detail.command?.trim() ?? ''; return call.kind === 'command' ? (windowsPowerShellCommandBody(input) ?? input) @@ -175,97 +133,65 @@ function unwrapDisplayArgument(argument: string) { /** * 单条工具的耗时(毫秒)。 - * `startedAt` 为 0(缺失)或 `updatedAt < startedAt`(时间倒序)时返回 `null`: - * 这两种情况不显示耗时,不显示 `0s` / 负数。 + * + * 边界只来自事件级时间:`startedAt` 是 `item.started` 事件的时间,完成态用 + * `item.completed` 事件的时间。两者任一缺失(0 / 非有限)或时间倒序时返回 `null`: + * 没有开始事件的完成快照不猜开始时间,缺失与倒序都不显示耗时。 + * + * `running` 为真(该工具仍在跑、且所属回合仍在跑)时用 `now` 当终点:以时间戳差值 + * 计算,不按 tick 累加,后台节流后回到前台不会累计漂移。合法 0 是有效耗时,照常返回 0。 */ export function toolCallDurationMs( - call: Pick, + call: Pick, + options?: { now?: number; running?: boolean }, ): number | null { const startedAt = Number.isFinite(call.startedAt) ? call.startedAt : 0; - const updatedAt = Number.isFinite(call.updatedAt) ? call.updatedAt : 0; - if (startedAt <= 0 || updatedAt < startedAt) { + if (startedAt <= 0) { return null; } - return updatedAt - startedAt; + const endAt = options?.running + ? Number(options.now) + : Number.isFinite(call.updatedAt) + ? call.updatedAt + : 0; + if (!Number.isFinite(endAt) || endAt <= 0 || endAt < startedAt) { + return null; + } + return endAt - startedAt; } /** - * 单条工具的耗时文案:`0.4s`(<1s)/ `12.3s`(<60s,整秒省略小数)/ `2m 5s`(≥60s)。 - * 无法计算的耗时(`null` / 0 / 负数)返回 `null`。 + * 单条工具与整轮、生成任务共用中文时分秒格式;未知耗时隐藏,合法 0 显示 0.0秒。 */ export function formatToolCallDuration(ms: number | null | undefined) { - if (ms === null || ms === undefined || !Number.isFinite(ms) || ms <= 0) { - return null; - } - if (ms < 60000) { - const tenths = Math.max(1, Math.round(ms / 100)); - if (tenths < 600) { - const value = tenths / 10; - return Number.isInteger(value) ? `${value}s` : `${value.toFixed(1)}s`; - } - } - const totalSeconds = Math.max(60, Math.round(ms / 1000)); - const minutes = Math.floor(totalSeconds / 60); - const restSeconds = totalSeconds % 60; - return restSeconds === 0 ? `${minutes}m` : `${minutes}m ${restSeconds}s`; + return formatElapsedDuration(ms); } -/** 一回合总用时:该回合所有工具的 `min(startedAt)` → `max(updatedAt)`;取不到返回 `null`。 */ -export function turnToolCallDurationMs( - calls: Array>, -): number | null { - let minStartedAt = Number.POSITIVE_INFINITY; - let maxUpdatedAt = Number.NEGATIVE_INFINITY; - for (const call of calls) { - const startedAt = Number.isFinite(call.startedAt) ? call.startedAt : 0; - const updatedAt = Number.isFinite(call.updatedAt) ? call.updatedAt : 0; - if (startedAt > 0) { - minStartedAt = Math.min(minStartedAt, startedAt); - } - if (updatedAt > 0) { - maxUpdatedAt = Math.max(maxUpdatedAt, updatedAt); - } - } - if (!Number.isFinite(minStartedAt) || !Number.isFinite(maxUpdatedAt)) { - return null; - } - if (maxUpdatedAt < minStartedAt) { - return null; - } - return maxUpdatedAt - minStartedAt; -} - -/** 块头总用时文案:`42秒` / `4分钟` / `5分钟 45秒`;无法计算的耗时返回 `null`。 */ +/** + * 整轮总耗时文案:0.0秒 / 1分02.3秒 / 1时02分05.2秒。 + * 无法计算的耗时(`null` / `undefined` / 非有限 / 负数)返回 `null`;合法 0 显示 `0.0秒`。 + */ export function formatTurnDuration(ms: number | null | undefined) { - if (ms === null || ms === undefined || !Number.isFinite(ms) || ms <= 0) { - return null; - } - const seconds = Math.max(1, Math.round(ms / 1000)); - if (seconds < 60) { - return `${seconds}秒`; - } - const minutes = Math.floor(seconds / 60); - const restSeconds = seconds % 60; - return restSeconds === 0 - ? `${minutes}分钟` - : `${minutes}分钟 ${restSeconds}秒`; + return formatElapsedDuration(ms); } -/** 该回合的结束时间:`max(updatedAt)`;取不到返回 0。 */ -export function turnToolCallEndedAt( - calls: Array>, +/** + * 时间戳取整到展示用的 100ms 网格(非法 / 缺失一律回 0)。 + * + * 时间范围与总耗时必须来自同一组取整边界:分别对原始毫秒做四舍五入会让 + * `14:20:05.9 → 14:20:06.1` 与 `0.1秒` 这类组合互相矛盾。 + */ +export function quantizeDisplayMs(value: number | null | undefined) { + return typeof value === 'number' && Number.isFinite(value) && value > 0 + ? Math.round(value / 100) * 100 + : 0; +} + +/** 本地 `HH:mm:ss`;`tenths` 为真时补一位小数(`HH:mm:ss.S`)。缺失(0 / 非法)返回 `null`。 */ +export function formatClockTime( + timestamp: number | null | undefined, + options?: { tenths?: boolean }, ) { - let maxUpdatedAt = 0; - for (const call of calls) { - if (Number.isFinite(call.updatedAt) && call.updatedAt > maxUpdatedAt) { - maxUpdatedAt = call.updatedAt; - } - } - return maxUpdatedAt; -} - -/** 本地 `HH:mm:ss`;时间戳缺失(0 / 非法)返回 `null`,不编造时间。 */ -export function formatClockTime(timestamp: number | null | undefined) { if ( timestamp === null || timestamp === undefined || @@ -279,21 +205,165 @@ export function formatClockTime(timestamp: number | null | undefined) { const hours = String(date.getHours()).padStart(2, '0'); const minutes = String(date.getMinutes()).padStart(2, '0'); const seconds = String(date.getSeconds()).padStart(2, '0'); - return `${hours}:${minutes}:${seconds}`; + if (!options?.tenths) { + return `${hours}:${minutes}:${seconds}`; + } + return `${hours}:${minutes}:${seconds}.${Math.floor(date.getMilliseconds() / 100)}`; } /** - * 块头时间文案:该回合结束时间(`max(updatedAt)` 的本地 `HH:mm:ss`); - * 能拿到同回合用户消息时间(`updatedAt > 0`)时显示「发送 → 结束」,取不到就只显示结束时间。 + * 整轮耗时(毫秒)= 本轮起点 → 本轮终态。 + * + * 起点是该轮实际用户消息的发送时间,缺失时用原生 `turn.started.at`;终点是明确的 + * `turn.completed.at`,运行中则是当前时刻(回合还在跑就持续增长,即使组内工具都结束了)。 + * 两端任一缺失、非有限或倒序都返回 `null`:不伪造 `0.0秒`。 */ -export function turnToolCallTimeLabel( - calls: Array>, - userSentAt: number | null | undefined, -) { - const endLabel = formatClockTime(turnToolCallEndedAt(calls)); - if (!endLabel) { +export function turnTotalDurationMs({ + startedAt, + endedAt, + running = false, + now = 0, +}: { + /** 本轮起点:用户实际发送时间优先,缺失时原生 `turn.started.at`;0 = 未知。 */ + startedAt: number | null | undefined; + /** 本轮明确终态时间(`turn.completed.at`);运行中忽略。 */ + endedAt?: number | null; + /** 本轮是否仍在跑:为真时用 `now` 当终点。 */ + running?: boolean; + now?: number; +}): number | null { + const rawEnd = running ? now : endedAt; + if ( + typeof startedAt !== 'number' || + !Number.isFinite(startedAt) || + startedAt <= 0 || + typeof rawEnd !== 'number' || + !Number.isFinite(rawEnd) || + rawEnd <= 0 || + rawEnd < startedAt + ) { return null; } - const sentLabel = formatClockTime(userSentAt ?? 0); - return sentLabel ? `${sentLabel} → ${endLabel}` : endLabel; + const start = quantizeDisplayMs(startedAt); + const end = quantizeDisplayMs(rawEnd); + if (start <= 0 || end <= 0 || end < start) { + return null; + } + return end - start; +} + +export type DirectTurnTiming = { + /** 展示用总耗时(毫秒,已取整到 100ms 网格);null = 边界不完整,隐藏。 */ + durationMs: number | null; + /** `总耗时` 后面的文案;null = 隐藏。 */ + durationText: string | null; + /** `HH:mm:ss.S → HH:mm:ss.S`;起点未知时只给终点,两端都未知为 null。 */ + timeLabel: string | null; +}; + +export type DirectToolGroupTiming = { + /** 本组用时(毫秒,已取整到 100ms 网格);null = 边界不完整,这一组的用时隐藏。 */ + durationMs: number | null; + /** `本组用时` 后面的文案;null = 隐藏。 */ + durationText: string | null; + /** 本组此刻是否仍在跑(组内有运行中的工具且回合还在跑):只有它为真才跟时钟增长。 */ + running: boolean; +}; + +/** + * 本组用时:只读**这一组自己**的工具边界 —— `min(工具开始)` → `max(工具完成)`。 + * + * 它与整轮总耗时是两件事:整轮是用户发送 → 回合终态(含无工具的 LLM 等待),只在对话里 + * 渲染一处;组头只回答"这一组工具跑了多久"。因此这个函数**不接收任何回合边界**,也不会 + * 因为同轮另一组还在跑而跟着增长(那是上一版把两组显示成同一用时的原因)。 + * + * - 缺任何开始时间(`startedAt <= 0`)、或某个**已结束**的工具缺完成时间(`updatedAt <= 0`) + * → 整组用时未知:隐藏,不拿别组或整轮边界补(已知的单行耗时照常显示)。 + * - 组内还有运行中的工具且回合在跑时,终点取调用方给的宿主时钟 `now`,组头每 100ms 增长; + * 组内工具全部拿到终态那一刻立即冻结,不等回合收口。 + * - 每条工具**自己的**原始边界先成立(终点 >= 起点,运行中的工具用 `now` 当终点), + * 再聚合成 `min(开始)` → `max(完成)`:任何一条倒序 / 非有限都让整组隐藏,不靠别的正常 + * 工具体把坏边界掩盖成合法区间;亚 100ms 的倒序也不会被展示量化抹成 `0.0秒`。 + * - 合法的同一时刻显示 `0.0秒`。 + */ +export function resolveToolGroupTiming( + calls: readonly Pick< + DirectChatToolCard, + 'status' | 'startedAt' | 'updatedAt' + >[], + options?: { running?: boolean; now?: number }, +): DirectToolGroupTiming { + const hidden: DirectToolGroupTiming = { + durationMs: null, + durationText: null, + running: false, + }; + if (calls.length === 0) return hidden; + const turnRunning = Boolean(options?.running); + const now = Number(options?.now); + let startedAt = 0; + let endedAt = 0; + let running = false; + for (const call of calls) { + const start = Number.isFinite(call.startedAt) ? call.startedAt : 0; + if (start <= 0) return hidden; + let end = 0; + if (call.status === 'running') { + // 运行中的工具没有终态时间:只有"组内还在跑且回合在跑"时才用当前时钟当终点。 + if (!turnRunning || !Number.isFinite(now) || now <= 0) return hidden; + running = true; + end = now; + } else { + end = Number.isFinite(call.updatedAt) ? call.updatedAt : 0; + if (end <= 0) return hidden; + } + // 单条边界先判:倒序(含量化后会被抹成 0.0 秒的亚 100ms 倒序)整组隐藏, + // 不允许被同组另一条正常工具的区间掩盖。 + if (end < start) return hidden; + startedAt = startedAt === 0 ? start : Math.min(startedAt, start); + endedAt = Math.max(endedAt, end); + } + const start = quantizeDisplayMs(startedAt); + const end = quantizeDisplayMs(endedAt); + // 聚合后量化:每条都已成立,聚合区间不会倒序;量化取整按单调函数做,也不会互相矛盾。 + if (start <= 0 || end <= 0 || end < start) return hidden; + const durationMs = end - start; + return { + durationMs, + durationText: formatTurnDuration(durationMs), + running, + }; +} + +/** + * 整轮计时的唯一口径:总耗时与时间范围共用同一组边界。 + * + * 只认明确边界:缺字段、非有限、时间戳为 0、结束早于开始一律隐藏耗时(也不会退化成 + * `0.0秒`);合法的同一时刻(耗时 0)正常显示 `0.0秒`。 + */ +export function resolveTurnTiming({ + startedAt, + endedAt, + running = false, + now = 0, +}: { + startedAt: number | null | undefined; + endedAt?: number | null; + running?: boolean; + now?: number; +}): DirectTurnTiming { + const start = quantizeDisplayMs(startedAt); + const end = quantizeDisplayMs(running ? now : endedAt); + const durationMs = turnTotalDurationMs({ startedAt, endedAt, running, now }); + const startLabel = + start > 0 ? formatClockTime(start, { tenths: true }) : null; + const endLabel = end > 0 ? formatClockTime(end, { tenths: true }) : null; + return { + durationMs, + durationText: formatTurnDuration(durationMs), + timeLabel: + startLabel && endLabel + ? `${startLabel} → ${endLabel}` + : (endLabel ?? null), + }; } diff --git a/apps/ai-game-creator-shell/tests/appSurface/harness.ts b/apps/ai-game-creator-shell/tests/appSurface/harness.ts index e42c88ea8..3b38bd2fe 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/harness.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/harness.ts @@ -482,6 +482,23 @@ function agentRuntimeUserInputRequest({ }; } +/** + * 运行态事件里的条目身份:只有 `item.started` / `item.completed` 带条目。 + * + * `item.delta` 只带 itemId 不带条目,属于瞬时事件,不参与 bootstrap 回放。 + */ +function directThreadEventItemId( + event: Record, +): string | null { + if (event.type !== 'item.started' && event.type !== 'item.completed') { + return null; + } + const item = event.item; + if (!item || typeof item !== 'object') return null; + const itemId = (item as Record).itemId; + return typeof itemId === 'string' && itemId ? itemId : null; +} + function createProjectSupervisorRuntimeHarness({ projectPath = '/tmp/authorized-game', sessionId = 'supervisor-session-active', @@ -588,8 +605,31 @@ function createProjectSupervisorRuntimeHarness({ }) => void) | null = null; let designAgentUpdateHandler: - | ((event: { payload: Record }) => void) - | null = null; + ((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; + // 运行态事件里只有一个"最新回合是否在跑"的布尔:与 Thread Manager 只保留一条生命周期 + // 锚点一致,重复 `turn.started` 在生产里不会出现。 + let directThreadTurnRunning = false; + // bootstrap 的回放规则与 Rust 侧 `is_bootstrap_event` 对齐:只补"这一刻还没结束的条目" + // 与最新一条生命周期锚点;已完成条目、增量正文这类瞬时事件都不回放。 + const directThreadActiveItemIds = new Set(); + const directThreadPendingEventSeqs = new Map< + Record, + number + >(); + let directThreadEventSequence = 0; + let directThreadLifecycleAnchor: { + seq: number; + event: Record; + } | null = null; const conversationRecord = ( role: 'user' | 'assistant', @@ -784,6 +824,93 @@ 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 pending = pendingDirectThreadEvents; + pendingDirectThreadEvents = []; + // 游标落在队尾:只有"还没结束的条目"与最新生命周期锚点作为 bootstrap 回放, + // 已完成条目与增量正文不再补发(Rust 侧 `is_bootstrap_event` 的同一口径)。 + const replay = pending + .filter((event) => { + const itemId = directThreadEventItemId(event); + return Boolean(itemId && directThreadActiveItemIds.has(itemId)); + }) + .map((event) => ({ + seq: directThreadPendingEventSeqs.get(event) ?? 0, + event, + })); + for (const event of pending) { + directThreadPendingEventSeqs.delete(event); + } + if ( + directThreadLifecycleAnchor && + !replay.some( + (entry) => entry.event === directThreadLifecycleAnchor?.event, + ) + ) { + replay.push(directThreadLifecycleAnchor); + } + replay.sort((left, right) => left.seq - right.seq); + return { + subscriptionId: directThreadSubscriptionId, + lastCompletedItemId: directThreadLastCompletedItemId, + events: replay.map((entry) => entry.event), + }; + } + 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') { + // 生产口径:从文件尾反向取一屏可显示条目——`throughItemId` 是窗口新端边界(含该条, + // 首屏用),`beforeItemId` 是旧端边界(不含该条,翻页用);收满一屏之后再见一条才算 + // `hasMore`,`firstItemId` 是本屏最老一条的 itemId。 + const requestedLimit = Number(args?.limit ?? 20); + const limit = Math.min( + Math.max(Number.isFinite(requestedLimit) ? requestedLimit : 20, 1), + 200, + ); + const throughItemId = + typeof args?.throughItemId === 'string' && args.throughItemId + ? args.throughItemId + : null; + const beforeItemId = + typeof args?.beforeItemId === 'string' && args.beforeItemId + ? args.beforeItemId + : null; + let end = directThreadHistoryItems.length; + if (throughItemId) { + const anchorIndex = directThreadHistoryItems.findIndex( + (item) => String(item.itemId ?? '') === throughItemId, + ); + if (anchorIndex < 0) { + throw new Error( + `DirectProject 历史中不存在 item:${throughItemId}`, + ); + } + end = anchorIndex + 1; + } else if (beforeItemId) { + const anchorIndex = directThreadHistoryItems.findIndex( + (item) => String(item.itemId ?? '') === beforeItemId, + ); + end = + anchorIndex >= 0 ? anchorIndex : directThreadHistoryItems.length; + } + const start = Math.max(0, end - limit); + const window = directThreadHistoryItems.slice(start, end); + const oldest = window[0]; + return { + items: [...window], + hasMore: start > 0, + firstItemId: + oldest && typeof oldest.itemId === 'string' ? oldest.itemId : null, + }; + } if (command === 'start_game_creator_supervisor_runtime_task') { if (args?.runProfile !== expectedRunProfile) { throw new Error('unexpected Project Supervisor run profile'); @@ -937,6 +1064,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; @@ -950,10 +1081,45 @@ function createProjectSupervisorRuntimeHarness({ if (designAgentUpdateHandler === handler) { designAgentUpdateHandler = null; } + if (directThreadNotifyHandler === handler) { + directThreadNotifyHandler = null; + } }; }, ); + /** 追加运行态事件并唤醒订阅者:bootstrap / consume 共用同一份队列。 */ + const emitDirectThreadEvents = ( + ...events: Array> + ) => { + for (const event of events) { + directThreadEventSequence += 1; + directThreadPendingEventSeqs.set(event, directThreadEventSequence); + if (event.type === 'turn.started') directThreadTurnRunning = true; + if (event.type === 'turn.completed') directThreadTurnRunning = false; + if (event.type === 'turn.started' || event.type === 'turn.completed') { + // 生命周期锚点只留最新一条,与 Thread Manager 的 `lifecycle_anchor` 一致。 + directThreadLifecycleAnchor = { + seq: directThreadEventSequence, + event, + }; + } + const itemId = directThreadEventItemId(event); + if (itemId && event.type === 'item.started') { + directThreadActiveItemIds.add(itemId); + } + if (itemId && event.type === 'item.completed') { + directThreadActiveItemIds.delete(itemId); + } + } + pendingDirectThreadEvents.push(...events); + if (directThreadSubscriptionId) { + directThreadNotifyHandler?.({ + payload: { subscriptionId: directThreadSubscriptionId }, + }); + } + }; + return { invoke, listen, @@ -1036,6 +1202,71 @@ function createProjectSupervisorRuntimeHarness({ }, }); }, + emitDirectThreadEvents, + /** + * 一轮 Direct 回合的标准事件序列:生命周期 → 落盘用户条目 → 助手正文 → 终态。 + * + * 与 Rust 侧一致:消息身份是 `direct-codex:{turnId}:{role}`,工具条目另配 itemId。 + * 事件级 `at` 与条目 `item.at` 同源:原生在两个阶段事件上都给时间,计时只读事件级那一份。 + */ + completeDirectThreadTurn({ + turnId, + reply, + prompt = '', + at = 9_000, + status = 'completed', + }: { + turnId: string; + reply: string; + prompt?: string; + at?: number; + status?: string; + }) { + // 生命周期锚点只有一份:回合已经在跑时不再补 `turn.started`。 + const events: Array> = directThreadTurnRunning + ? [] + : [{ type: 'turn.started', at }]; + if (prompt.trim()) { + events.push({ + type: 'item.completed', + at, + item: { + itemType: 'message', + itemId: `direct-codex:${turnId}:user`, + role: 'user', + text: prompt, + at, + }, + }); + } + events.push({ + type: 'item.completed', + at: at + 1, + 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, at }); + emitDirectThreadEvents(...events); + }, + setDirectThreadHistory(items: Array>) { + directThreadHistoryItems = [...items]; + // 生产口径:`subscribe` 回执里的 `lastCompletedItemId` 是订阅那一刻文件里最后一条可显示 + // 条目(Rust 侧由 `read_direct_project_last_item_id_at` 从磁盘回填)。 + const newest = directThreadHistoryItems.at(-1); + directThreadLastCompletedItemId = + typeof newest?.itemId === 'string' ? newest.itemId : null; + }, + /** 模拟"订阅回执给的就是这一刻的最后一条已完成条目":之后落盘的条目只应从运行态事件来。 */ + setDirectThreadLastCompletedItemId(itemId: string | null) { + directThreadLastCompletedItemId = itemId; + }, }; } 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 96197e563..0b229f8d9 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 @@ -7322,6 +7322,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') { @@ -7716,7 +7721,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', @@ -7725,27 +7730,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; @@ -7764,20 +7751,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(); @@ -7794,10 +7789,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( @@ -7808,53 +7801,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', ); @@ -7863,223 +7834,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( @@ -8087,16 +7851,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( @@ -8112,8 +7868,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( @@ -8139,68 +7897,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', { @@ -8208,6 +7910,9 @@ export function registerProjectSupervisorSurfaceTests() { }) as HTMLButtonElement ).disabled, ).toBe(false); + expect( + within(supervisorSurface).queryByRole('button', { name: '终止' }), + ).toBeNull(); }); expect( invoke.mock.calls.filter( @@ -8224,8 +7929,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', @@ -8243,7 +7947,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', @@ -8252,27 +7956,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) => { @@ -8294,35 +7994,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 }; } @@ -8331,7 +8002,7 @@ export function registerProjectSupervisorSurfaceTests() { ); window.__TAURI__ = { core: { invoke }, - event: { listen }, + event: { listen: supervisorHarness.listen }, }; renderLauncherProjectsAt('/?launcher'); @@ -8367,31 +8038,38 @@ export function registerProjectSupervisorSurfaceTests() { ); expect(clientTurnId).not.toBe(''); - // 实时增量:同一条 `item-command` 从 running 走到 completed,`item-file` 由下一条事件带出。 + // 运行中:命令开始执行。同一 itemId 的后续事件就地更新,不新起一张卡。 + // 事件级 `at` 是计时的唯一边界(原生在 item.started / item.completed 上给出), + // 条目里的 `item.at` 只是展示时间。用一个贴近宿主时钟的基准,运行中的总耗时才是真数值。 + const turnSentAt = Date.now(); 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', at: turnSentAt }, + { + type: 'item.completed', + at: turnSentAt, + item: { + itemType: 'message', + itemId: `direct-codex:${clientTurnId}:user`, + role: 'user', + text: '做一个跑酷游戏', + at: turnSentAt, + }, }, - }); + { + type: 'item.started', + at: turnSentAt, + item: { + itemType: 'commandExecution', + itemId: 'item-command', + command: 'npm run build', + output: null, + status: 'inProgress', + exitCode: null, + at: 1000, + }, + }, + ); }); // 一回合一个折叠块(默认折叠):块头是按钮,正文用 `hidden` 收起。 const runningGroups = within(supervisorSurface).getAllByTestId( @@ -8410,52 +8088,52 @@ export function registerProjectSupervisorSurfaceTests() { ); expect(runningBody).not.toBeNull(); expect(runningBody?.hasAttribute('hidden')).toBe(true); - // 此刻只应看到本回合的那一条命令:不在对话里、也不属于当前回合的 turnId - // 会在 App 侧被过滤掉,不会漂在消息流里。 - expect(runningHead.textContent).toContain('1 个命令'); + 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', + at: turnSentAt + 100, + item: { + itemType: 'commandExecution', + itemId: 'item-command', + command: 'npm run build', + output: 'build ok', + status: 'completed', + exitCode: 0, + at: turnSentAt + 100, + }, }, - }); + { + type: 'item.started', + at: turnSentAt + 50, + item: { + itemType: 'fileChange', + itemId: 'item-file', + changes: [ + { path: 'game/src/hero.ts', kind: 'update' }, + { path: 'game/src/hero.ts', kind: 'delete' }, + ], + at: turnSentAt + 50, + }, + }, + { + type: 'item.completed', + at: turnSentAt + 100, + item: { + itemType: 'fileChange', + itemId: 'item-file', + changes: [ + { path: 'game/src/hero.ts', kind: 'update' }, + { path: 'game/src/hero.ts', kind: 'delete' }, + ], + at: turnSentAt + 100, + }, + }, + ); }); - // 同一回合的两条工具只产生一个块;同回合内按 startedAt 升序。 await waitFor(() => { expect( within(supervisorSurface).getAllByTestId('agent-tool-call-group'), @@ -8464,18 +8142,16 @@ 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(groupHead.textContent).toContain('执行了 2 个操作'); + // 组头右侧是**本组**用时(本组工具 min(开始) → max(完成) = 100ms),与整轮总耗时无关: + // 组内工具都结束了,即使回合还在跑,也不标"进行中"、也不再滚动。 + expect(group.getAttribute('data-status')).toBe('completed'); 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 消息之后,而不是被锚到别人头上。 + expect(groupHead.textContent).toContain('耗时 0.1秒'); + expect(groupHead.textContent).not.toContain('进行中'); + expect(groupHead.textContent).not.toContain('总耗时'); + // 实时回合的块落在消息流末尾:该回合还没有正文,不依赖任何锚点消息。 const liveChildren = Array.from((messageList as HTMLElement).children); expect( liveChildren.findIndex((node) => node === group), @@ -8486,29 +8162,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'); @@ -8542,10 +8212,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: turnSentAt + 500, + }); }); await waitFor(() => { expect( @@ -8561,24 +8235,19 @@ 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); + // 外层"执行过程"汇总这一轮**全部工具调用**(1 组 2 条 = 2 个操作), + // 耗时是首工具 → 末工具的执行跨度(不是整轮总耗时)。 + const turnProcessSummary = within(processSection).getByTestId( + 'agent-tool-call-group-head', ); - expect(assistantIndex).toBeGreaterThanOrEqual(0); - const settledGroupIndex = children.findIndex( - (node) => node === settledGroups[0], + expect(processSection.textContent).toContain('执行了 2 个操作'); + expect(turnProcessSummary.textContent).toContain('耗时 0.1秒'); + expect(processSection.textContent).not.toContain( + 'DIRECT_REPLY:做一个跑酷游戏', ); - 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; @@ -8587,6 +8256,23 @@ export function registerProjectSupervisorSurfaceTests() { ); expect(settledHead.tagName).toBe('BUTTON'); expect(settledHead.getAttribute('aria-expanded')).toBe('false'); + // 回合收口不改变组头:组头始终是**本组**用时,不随回合变长。 + expect(settledGroup.getAttribute('data-status')).toBe('completed'); + expect(settledGroup.getAttribute('data-duration-ms')).toBe('100'); + expect(settledHead.textContent).toContain('耗时 0.1秒'); + expect(settledHead.textContent).not.toContain('进行中'); + expect(settledHead.textContent).not.toContain('总耗时'); + // 整轮总耗时在界面上只有一处:回合小结;任何工具组都不再显示它。 + expect( + Array.from( + supervisorSurface.querySelectorAll('[data-testid="turn-usage"]'), + ).filter((node) => node.textContent?.includes('总耗时')), + ).toHaveLength(1); + expect( + Array.from( + supervisorSurface.querySelectorAll('.agent-tool-call-group'), + ).filter((node) => node.textContent?.includes('总耗时')), + ).toHaveLength(0); const settledBody = settledGroup.querySelector( `#${settledHead.getAttribute('aria-controls')}`, ); @@ -8607,8 +8293,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', @@ -8617,7 +8302,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; @@ -8635,61 +8355,96 @@ export function registerProjectSupervisorSurfaceTests() { if (command === 'get_local_game_manifest') { return manifest; } - if (command === 'read_direct_project_conversation') { + if (command === 'get_local_game_preview_status') { + return { status: 'stopped', url: null, port: null, root: null }; + } + return supervisorHarness.invoke(command, args); + }, + ); + window.__TAURI__ = { + core: { invoke }, + event: { listen: supervisorHarness.listen }, + }; + renderLauncherProjectsAt('/?launcher'); + + pickProjectFromLauncher(projectPath); + + const supervisorSurface = await screen.findByLabelText('陶泥儿项目对话'); + // 打开项目时读一屏历史:这是「刷新后卡片仍在」的数据来源,工具卡片由前端投影。 + expect(invoke).toHaveBeenCalledWith('read_direct_project_history_slice', { + projectPath, + // 首屏的新端边界是订阅回执里的 `lastCompletedItemId`(含该条)。 + throughItemId: 'direct-codex:turn-persisted:assistant', + limit: 20, + }); + expect( + await within(supervisorSurface).findByText('做一个小球弹跳游戏'), + ).not.toBeNull(); + // 工具在「执行过程」折叠区里,最终回复在折叠区外:默认折叠、展开后行数 = 工具数。 + const persistedGroups = await within(supervisorSurface).findAllByTestId( + 'agent-tool-call-group', + ); + expect(persistedGroups).toHaveLength(1); + const persistedGroup = persistedGroups[0] as HTMLElement; + const persistedHead = within(persistedGroup).getByTestId( + 'agent-tool-call-group-head', + ); + expect(persistedHead.getAttribute('aria-expanded')).toBe('false'); + expect(persistedHead.textContent).toContain('执行了 2 个操作'); + 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); + // 行标题与展开后的命令详情都写着这条命令,这里只要求它确实出现在这一行里。 + expect( + within(persistedRows[0] as HTMLElement).getAllByText('npm run build'), + ).toHaveLength(2); + }); + + it('loads the earlier direct history page from the first-item anchor', async () => { + // 分页锚点走的是首屏切片给出的 `firstItemId`:切片本身从队尾取,老的一屏靠锚点继续向前。 + const projectPath = '/tmp/launcher-direct-history-pagination-game'; + const manifest = createGameCreationAppManifest( + 'local-project-draft', + 'launcher-direct-history-pagination-game', + ); + const supervisorHarness = createProjectSupervisorRuntimeHarness({ + projectPath, + }); + // 26 条 > 首屏 20 条:首屏只显示最后 20 条,前面 6 条要靠"显示更早"翻页取回。 + supervisorHarness.setDirectThreadHistory( + Array.from({ length: 26 }, (_, index) => { + const label = `历史对话 ${String(index + 1).padStart(2, '0')}`; + return { + itemType: 'message', + itemId: `direct-codex:turn-${label}:user`, + role: 'user', + text: label, + at: 1000 + index, + }; + }), + ); + 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 { - 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, - }, - ], + projectPath, + exists: true, + isDirectory: true, + isGameCreatorProject: true, + projectName: 'launcher-direct-history-pagination-game', + recentRunStatus: null, + recentRunStopReason: null, }; } - 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_manifest') { + return manifest; } if (command === 'get_local_game_preview_status') { return { status: 'stopped', url: null, port: null, root: null }; @@ -8706,59 +8461,333 @@ export function registerProjectSupervisorSurfaceTests() { pickProjectFromLauncher(projectPath); const supervisorSurface = await screen.findByLabelText('陶泥儿项目对话'); - // 打开项目时必须回读一次工具调用历史:这是「刷新后卡片仍在」的数据来源。 - await waitFor(() => { - expect(readToolCallCount).toBeGreaterThanOrEqual(1); - }); - expect(invoke).toHaveBeenCalledWith('read_direct_tool_calls', { - projectPath, - }); - // 回读到的工具调用挂在自己回合的 assistant 消息**之前**(工具在上、答复在下), - // 默认折叠、展开后行数 = 回读到的工具数。 - const persistedGroups = await within(supervisorSurface).findAllByTestId( - 'agent-tool-call-group', - ); - expect(persistedGroups).toHaveLength(1); - const persistedGroup = persistedGroups[0] as HTMLElement; - const persistedHead = within(persistedGroup).getByTestId( - 'agent-tool-call-group-head', - ); - expect(persistedHead.getAttribute('aria-expanded')).toBe('false'); - expect(persistedHead.textContent).toContain( - '已执行 1 个命令、1 个文件变更', - ); - // 回读回来的时间戳一样能算总用时与「发送 → 结束」。 - 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); - 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'), + await within(supervisorSurface).findByText('历史对话 26'), ).not.toBeNull(); + // 首屏是尾部的 20 条:第 7 条起可见,更早的 6 条还没读进来。 + expect(within(supervisorSurface).getByText('历史对话 07')).not.toBeNull(); + expect(within(supervisorSurface).queryByText('历史对话 06')).toBeNull(); + expect(invoke).toHaveBeenCalledWith('read_direct_project_history_slice', { + projectPath, + throughItemId: 'direct-codex:turn-历史对话 26:user', + limit: 20, + }); + + fireEvent.click(screen.getByRole('button', { name: '显示更早的对话' })); + + // 翻页按首屏最老一条的锚点向前取;取到文件头后按钮消失。 + expect( + await within(supervisorSurface).findByText('历史对话 01'), + ).not.toBeNull(); + expect(within(supervisorSurface).getByText('历史对话 06')).not.toBeNull(); + expect(screen.queryByRole('button', { name: /显示更早/ })).toBeNull(); + expect(invoke).toHaveBeenCalledWith('read_direct_project_history_slice', { + projectPath, + beforeItemId: 'direct-codex:turn-历史对话 07:user', + limit: 20, + }); }); - it('renders the running turn tool-call group in a fresh chat that has no anchored assistant message', async () => { + it('anchors the first history page at the subscribe receipt instead of the file tail', async () => { + // 首屏切片的新端边界只认 `subscribe` 回执里的 `lastCompletedItemId`(含该条):回执之后才 + // 完成的条目只能从运行态事件来,不能再被"取文件尾"带进历史。 + const projectPath = '/tmp/launcher-direct-history-anchor-game'; + const manifest = createGameCreationAppManifest( + 'local-project-draft', + 'launcher-direct-history-anchor-game', + ); + const supervisorHarness = createProjectSupervisorRuntimeHarness({ + projectPath, + }); + supervisorHarness.setDirectThreadHistory([ + { + itemType: 'message', + itemId: 'turn-1-user', + role: 'user', + text: '历史对话 01', + at: 1000, + }, + { + itemType: 'message', + itemId: 'turn-2-user', + role: 'user', + text: '历史对话 02', + at: 1100, + }, + // 订阅回执之后才落盘的两条:只应从运行态事件来。 + { + itemType: 'function_call', + itemId: 'process-1', + name: 'exec_command', + arguments: 'npm run build', + at: 1200, + }, + { + itemType: 'message', + itemId: 'turn-3-user', + role: 'user', + text: '历史对话 03', + at: 1300, + }, + ]); + supervisorHarness.setDirectThreadLastCompletedItemId('turn-2-user'); + 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-direct-history-anchor-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 }; + } + 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('read_direct_project_history_slice', { + projectPath, + throughItemId: 'turn-2-user', + limit: 20, + }), + ); + // 首屏 = 锚点那一刻的尾部一屏:锚点之后的条目不在这一屏里。 + expect( + await within(supervisorSurface).findByText('历史对话 02'), + ).not.toBeNull(); + expect(within(supervisorSurface).queryByText('历史对话 03')).toBeNull(); + // 锚点只能来自订阅回执:回执必须先于首屏读取发生。 + const commands = invoke.mock.calls.map(([command]) => command); + expect( + commands.indexOf('subscribe_direct_project_thread'), + ).toBeGreaterThanOrEqual(0); + expect(commands.indexOf('subscribe_direct_project_thread')).toBeLessThan( + commands.indexOf('read_direct_project_history_slice'), + ); + }); + + it('keeps pulling earlier pages while the page only deepens the rendered turn', async () => { + // 用户报的现象:一屏 20 条全是同一个回合的工具卡片,落在折叠的「执行过程」里, + // 点一次「显示更早」看不到任何变化。连拉必须越过这一屏,直到出现新的用户气泡。 + const projectPath = '/tmp/launcher-direct-history-cross-page-game'; + const manifest = createGameCreationAppManifest( + 'local-project-draft', + 'launcher-direct-history-cross-page-game', + ); + const supervisorHarness = createProjectSupervisorRuntimeHarness({ + projectPath, + }); + // 51 条:用户气泡 + 50 条工具条目。首屏取尾 20 条(process-31..process-50), + // 第一次翻页再取 20 条(process-11..process-30),都还在同一个回合里。 + supervisorHarness.setDirectThreadHistory([ + { + itemType: 'message', + itemId: 'direct-codex:turn-更早:user', + role: 'user', + text: '更早的用户提问', + at: 1000, + }, + ...Array.from({ length: 50 }, (_, index) => ({ + itemType: 'function_call', + itemId: `process-${index + 1}`, + name: 'exec_command', + arguments: `echo ${index + 1}`, + at: 1100 + index, + })), + ]); + 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-direct-history-cross-page-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 }; + } + 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('read_direct_project_history_slice', { + projectPath, + throughItemId: 'process-50', + limit: 20, + }), + ); + // 首屏只有这个回合的执行过程:用户气泡还在更早的页里。 + expect(within(supervisorSurface).queryByText('更早的用户提问')).toBeNull(); + + // 视口停在列表中部(既不在顶部触发自动翻页,也不在底部跟随最新)。 + const messageList = screen.getByLabelText('陶泥儿消息') as HTMLDivElement; + let messageScrollHeight = 600; + Object.defineProperty(messageList, 'scrollHeight', { + configurable: true, + get: () => messageScrollHeight, + }); + Object.defineProperty(messageList, 'clientHeight', { + configurable: true, + get: () => 100, + }); + messageList.scrollTop = 100; + fireEvent.scroll(messageList); + // 插入后列表会变长:跟随最新的话视口会被拉到这个高度。 + messageScrollHeight = 2000; + + fireEvent.click(screen.getByRole('button', { name: '显示更早的对话' })); + + // 一次点击连拉两页:第一页仍然只有执行过程,第二页才带回用户气泡。 + expect( + await within(supervisorSurface).findByText('更早的用户提问'), + ).not.toBeNull(); + expect(invoke).toHaveBeenCalledWith('read_direct_project_history_slice', { + projectPath, + beforeItemId: 'process-31', + limit: 20, + }); + expect(invoke).toHaveBeenCalledWith('read_direct_project_history_slice', { + projectPath, + beforeItemId: 'process-11', + limit: 20, + }); + // 取到文件头后按钮收起。 + expect(screen.queryByRole('button', { name: /显示更早/ })).toBeNull(); + // 连拉插入的旧内容不得把视口弹到列表底部。 + expect(messageList.scrollTop).toBe(100); + }); + + 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', @@ -8767,24 +8796,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) => { @@ -8806,18 +8817,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 }; } @@ -8826,7 +8825,7 @@ export function registerProjectSupervisorSurfaceTests() { ); window.__TAURI__ = { core: { invoke }, - event: { listen }, + event: { listen: supervisorHarness.listen }, }; renderLauncherProjectsAt('/?launcher'); @@ -8865,30 +8864,43 @@ export function registerProjectSupervisorSurfaceTests() { ); expect(clientTurnId).not.toBe(''); + // 正式条目沿用同身份乐观消息的真实发送时间。不能在等待 IPC 调用后再取 Date.now() + // 冒充发送时刻:全量运行的调度间隔会被正确计入总耗时,使硬编码的 1.0 秒期望漂移。 + const sentTimeElement = supervisorSurface.querySelector( + '.message--user time.message-sent-at', + ); + expect(sentTimeElement).not.toBeNull(); + const freshTurnSentAt = Date.parse(sentTimeElement!.dateTime); + expect(Number.isFinite(freshTurnSentAt)).toBe(true); 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', at: freshTurnSentAt }, + { + type: 'item.completed', + at: freshTurnSentAt, + item: { + itemType: 'message', + itemId: `direct-codex:${clientTurnId}:user`, + role: 'user', + text: '做一个跑酷游戏', + at: freshTurnSentAt, + }, }, - }); + { + type: 'item.started', + at: freshTurnSentAt, + item: { + itemType: 'commandExecution', + itemId: 'fresh-turn-command', + command: 'npm run build', + output: null, + status: 'inProgress', + exitCode: null, + at: freshTurnSentAt, + }, + }, + ); }); // 回合进行中:块已经可见,且落在消息列表末尾(默认问候之后),不依赖任何带 messageId 的 assistant 消息。 @@ -8898,12 +8910,16 @@ 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', ); expect(runningHead.getAttribute('aria-expanded')).toBe('false'); - expect(runningHead.textContent).toContain('1 个命令'); + expect(runningHead.textContent).toContain('执行了 1 个操作'); + // 组内还有工具在跑:组头按"本组起点 → 现在"增长,一位小数、显示进行中。 + // 整轮总耗时不在组头(它只在回合小结 / 底部耗时行出现)。 + expect(runningHead.textContent).toContain('进行中'); + expect(runningHead.textContent).toMatch(/耗时 \d+\.\d+秒/); + expect(runningHead.textContent).not.toContain('总耗时'); const runningChildren = Array.from((messageList as HTMLElement).children); expect( runningChildren.findIndex((node) => node === runningGroup), @@ -8914,14 +8930,59 @@ 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', + at: freshTurnSentAt + 400, + item: { + itemType: 'commandExecution', + itemId: 'fresh-turn-command', + command: 'npm run build', + output: 'built in 400ms', + status: 'completed', + exitCode: 0, + at: freshTurnSentAt + 400, + }, + }); + }); + await waitFor(() => { + expect( + within(supervisorSurface).getAllByTestId('agent-tool-call-group'), + ).toHaveLength(1); + }); + const settledRunningGroup = within(supervisorSurface).getAllByTestId( + 'agent-tool-call-group', + )[0] as HTMLElement; + // 工具已经结束(块内没有 running 快照):本组用时立刻冻结,也不再标"进行中", + // 即使回合还在跑(这正是之前把整轮总耗时借给每组造成的误报)。 + expect(settledRunningGroup.getAttribute('data-status')).toBe('completed'); + expect(settledRunningGroup.getAttribute('data-duration-ms')).toBe('400'); + const settledRunningHead = within(settledRunningGroup).getByTestId( + 'agent-tool-call-group-head', + ); + expect(settledRunningHead.textContent).toContain('耗时 0.4秒'); + expect(settledRunningHead.textContent).not.toContain('进行中'); + expect(settledRunningHead.textContent).not.toContain('总耗时'); + fireEvent.click(settledRunningHead); + 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.4秒'), ).not.toBeNull(); - // 回合结束:assistant 消息落盘后块回到它之前,且**只有一份**(末尾兜底不留下重复块)。 + // 回合结束:assistant 正文进历史,工具块收进「执行过程」折叠区,且**只有一份**。 await act(async () => { directReply.resolve('DIRECT_REPLY:空对话首轮'); + supervisorHarness.completeDirectThreadTurn({ + turnId: clientTurnId, + reply: 'DIRECT_REPLY:空对话首轮', + at: freshTurnSentAt + 1000, + }); }); await waitFor(() => { expect( @@ -8937,6 +8998,359 @@ export function registerProjectSupervisorSurfaceTests() { 'agent-tool-call-group', )[0] as HTMLElement; expect(settledGroup).toBeTruthy(); + // 回合收口只决定底部小结:组头仍然是本组自己的用时。 + expect(settledGroup.getAttribute('data-status')).toBe('completed'); + expect(settledGroup.getAttribute('data-duration-ms')).toBe('400'); + expect( + within(settledGroup).getByTestId('agent-tool-call-group-head') + .textContent, + ).toContain('耗时 0.4秒'); + // 整轮总耗时冻结在用户发送 → turn.completed.at,且只在回合小结里出现一处。 + expect( + within(supervisorSurface).getByTestId('turn-usage').textContent, + ).toContain('总耗时 1.0秒'); + }); + + /** 直接渲染 ProjectSupervisorView 的最小 props(与下面 initial 占位用例同源)。 */ + function supervisorSurfaceProps( + overrides: Record = {}, + ): Record { + return { + activeVersionId: null, + chatInput: '', + chatReferences: [], + chatProjectAssets: [], + composerRef: createRef(), + directCodex: true, + directTurnRunning: false, + hiddenConversationCount: 0, + messagesRef: createRef(), + needsUserInput: false, + onCancelConfirmation: vi.fn(), + onCancelPendingCommand: vi.fn(), + onChatInputChange: vi.fn(), + onConfirmConfirmation: vi.fn(), + onConfirmPendingCommand: vi.fn(), + onScroll: vi.fn(), + onShowEarlierMessages: vi.fn(), + onSubmit: vi.fn(), + pendingConfirmation: null, + pendingCommand: null, + projectPath: '/tmp/launcher-codex-placeholder-game', + transientReply: '', + visibleMessages: [], + visibleProfessionalAgentCards: [], + workspaceStatus: '等待指令', + runtime: null, + error: '', + runtimeByAgentId: {}, + controlBusy: false, + professionalResultsByAgentId: {}, + onToolAction: vi.fn(), + onSupervisorRetry: vi.fn(), + onProfessionalToolAction: vi.fn(), + onProfessionalRetry: vi.fn(), + onUserInput: vi.fn(), + initialSupervisorMessage: '', + ...overrides, + }; + } + + /** + * 等待首个响应时的临时"思考中…":只在 Direct 忙碌、本轮已有用户消息但**还没有任何过程 / + * 正文**时,在工具组之外顶一条;本轮任何 reasoning / assistant / tool 到达即消失。 + */ + it('shows a temporary thinking row until the first reasoning, text or tool arrives', () => { + const historyUser = { + itemId: 'direct-codex:turn-1:user', + kind: 'message', + role: 'user', + text: '上一轮的问题', + at: 1_000_000, + }; + const historyFinal = { + itemId: 'direct-codex:turn-1:assistant', + kind: 'message', + role: 'assistant', + text: '上一轮的答复', + at: 1_001_000, + }; + // 本地乐观新用户消息:还没有任何正式条目,所以本轮 process / finals 都是空的。 + const optimisticUser = { + role: 'user' as const, + text: '这一轮的新问题', + messageId: 'direct-codex:turn-2:user', + updatedAt: 2_000_000, + }; + const busyProps = supervisorSurfaceProps({ + directTurnRunning: true, + directEntries: [historyUser, historyFinal], + conversationMessages: [optimisticUser], + }); + const thinkingRows = (surface: HTMLElement) => + Array.from(surface.querySelectorAll('*')).filter( + (node) => + node.children.length === 0 && + /思考中/.test(node.textContent ?? '') && + !node.closest('[data-testid="agent-tool-call-group"]'), + ); + + // 旧历史 final + 本地新用户 + 忙碌:思考中顶位,且不在工具组里。 + const view = render( + React.createElement(ProjectSupervisorView, busyProps as never), + ); + const surface = screen.getByLabelText('陶泥儿项目对话'); + expect(thinkingRows(surface)).toHaveLength(1); + + // 本轮开口条目落盘后 reasoning 到达 → 消失。 + const turn2User = { + itemId: 'direct-codex:turn-2:user', + kind: 'message', + role: 'user', + text: '这一轮的新问题', + at: 2_000_050, + }; + view.rerender( + React.createElement(ProjectSupervisorView, { + ...busyProps, + directEntries: [ + historyUser, + historyFinal, + turn2User, + { + itemId: 'direct-codex:turn-2:reasoning', + kind: 'reasoning', + role: null, + text: '先看目录', + at: 2_000_100, + }, + ], + } as never), + ); + expect(thinkingRows(surface)).toHaveLength(0); + + // 本轮 assistant 正文到达 → 消失。 + view.rerender( + React.createElement(ProjectSupervisorView, { + ...busyProps, + directEntries: [ + historyUser, + historyFinal, + turn2User, + { + itemId: 'direct-codex:turn-2:assistant', + kind: 'message', + role: 'assistant', + text: '我先看一下项目', + at: 2_000_200, + }, + ], + } as never), + ); + expect(thinkingRows(surface)).toHaveLength(0); + + // 本轮工具到达(工具组出现)→ 消失,且不是被工具组内文字顶掉的。 + view.rerender( + React.createElement(ProjectSupervisorView, { + ...busyProps, + directEntries: [ + historyUser, + historyFinal, + turn2User, + { + itemId: 'call-2', + kind: 'tool', + role: null, + text: null, + at: 0, + toolCall: { + schemaVersion: 'agc-tool-call.v1', + id: 'call-2', + kind: 'command', + title: '执行命令', + summary: 'npm run build', + status: 'running', + detail: { command: 'npm run build' }, + startedAt: 2_000_300, + updatedAt: 0, + }, + }, + ], + } as never), + ); + expect( + within(surface).getAllByTestId('agent-tool-call-group'), + ).toHaveLength(1); + expect(thinkingRows(surface)).toHaveLength(0); + + // 空闲(不忙)→ 不显示;终止中 → 不显示。 + view.rerender( + React.createElement(ProjectSupervisorView, { + ...busyProps, + directTurnRunning: false, + directEntries: [historyUser, historyFinal], + directTurnStartedAt: 0, + } as never), + ); + expect(thinkingRows(surface)).toHaveLength(0); + view.rerender( + React.createElement(ProjectSupervisorView, { + ...busyProps, + turnCancelling: true, + } as never), + ); + expect(thinkingRows(surface)).toHaveLength(0); + view.unmount(); + + // 只有旧历史、没有任何本轮用户消息:即使忙碌也不显示。 + const historyOnly = render( + React.createElement(ProjectSupervisorView, { + ...busyProps, + directEntries: [historyUser, historyFinal], + conversationMessages: [], + } as never), + ); + expect(thinkingRows(screen.getByLabelText('陶泥儿项目对话'))).toHaveLength( + 0, + ); + historyOnly.unmount(); + }); + + it('keeps the initial supervisor placeholder from duplicating the landed direct user entry', () => { + // 初始占位气泡只在"最初那条消息还没有正式条目"时顶位: + // Direct 模式的正式条目走 `directEntries`、不进 `conversationMessages`, + // 正式条目一到就必须撤掉占位,否则同一条消息会上下各出现一次(正式气泡 + 无时间占位)。 + const initialText = '做一个跑酷游戏'; + const baseProps = { + activeVersionId: null, + chatInput: '', + chatReferences: [], + chatProjectAssets: [], + composerRef: createRef(), + directCodex: true, + directTurnRunning: false, + hiddenConversationCount: 0, + messagesRef: createRef(), + needsUserInput: false, + onCancelConfirmation: vi.fn(), + onCancelPendingCommand: vi.fn(), + onChatInputChange: vi.fn(), + onConfirmConfirmation: vi.fn(), + onConfirmPendingCommand: vi.fn(), + onScroll: vi.fn(), + onShowEarlierMessages: vi.fn(), + onSubmit: vi.fn(), + pendingConfirmation: null, + pendingCommand: null, + projectPath: '/tmp/launcher-codex-placeholder-game', + transientReply: '', + visibleMessages: [], + visibleProfessionalAgentCards: [], + workspaceStatus: '等待指令', + runtime: null, + error: '', + runtimeByAgentId: {}, + controlBusy: false, + professionalResultsByAgentId: {}, + onToolAction: vi.fn(), + onSupervisorRetry: vi.fn(), + onProfessionalToolAction: vi.fn(), + onProfessionalRetry: vi.fn(), + onUserInput: vi.fn(), + initialSupervisorMessage: initialText, + }; + const initialUserBubbles = (surface: HTMLElement) => + Array.from(surface.querySelectorAll('.message--user')).filter((node) => + node.textContent?.includes(initialText), + ); + + // 正式条目还没到:占位顶上,只有一条。 + const pending = render( + React.createElement(ProjectSupervisorView, { + ...baseProps, + directEntries: [], + }), + ); + expect( + initialUserBubbles(screen.getByLabelText('陶泥儿项目对话')), + ).toHaveLength(1); + pending.unmount(); + + // 本地乐观气泡先于原生条目到达,也已接管初始占位。 + const optimistic = render( + React.createElement(ProjectSupervisorView, { + ...baseProps, + directEntries: [], + conversationMessages: [ + { + messageId: 'direct-codex:turn-1:user', + role: 'user' as const, + text: initialText, + updatedAt: 1_789_700_960_000, + }, + ], + }), + ); + expect( + initialUserBubbles(screen.getByLabelText('陶泥儿项目对话')), + ).toHaveLength(1); + // 用户真实重复发送同文时仍保留两条,只撤下额外占位。 + optimistic.rerender( + React.createElement(ProjectSupervisorView, { + ...baseProps, + directEntries: [], + conversationMessages: [ + { + messageId: 'direct-codex:turn-1:user', + role: 'user' as const, + text: initialText, + updatedAt: 1_789_700_960_000, + }, + { + messageId: 'direct-codex:turn-2:user', + role: 'user' as const, + text: initialText, + updatedAt: 1_789_700_970_000, + }, + ], + }), + ); + expect( + initialUserBubbles(screen.getByLabelText('陶泥儿项目对话')), + ).toHaveLength(2); + optimistic.unmount(); + + // 正式 Direct 条目到了:只剩正式气泡那一条,占位撤掉。 + const landed = render( + React.createElement(ProjectSupervisorView, { + ...baseProps, + directEntries: [ + { + itemId: 'direct-codex:turn-1:user', + kind: 'message' as const, + role: 'user' as const, + text: initialText, + at: 1_789_700_960_000, + }, + ], + }), + ); + expect( + initialUserBubbles(screen.getByLabelText('陶泥儿项目对话')), + ).toHaveLength(1); + landed.unmount(); + + // 已经翻出更早的历史:这里不是对话开头,不补最初那条消息。 + render( + React.createElement(ProjectSupervisorView, { + ...baseProps, + hasEarlierConversationMessages: true, + directEntries: [], + }), + ); + expect( + initialUserBubbles(screen.getByLabelText('陶泥儿项目对话')), + ).toHaveLength(0); }); it.skip('renders the Codex empty state and opens the panel settings overlay in a fresh direct chat', async () => { @@ -8952,9 +9366,8 @@ export function registerProjectSupervisorSurfaceTests() { chatProjectAssets: [], composerRef: createRef(), directCodex: true, - directStatus: null, - directProcessDetail: '', - directProcessKey: '', + directEntries: [], + directTurnRunning: false, hiddenConversationCount: 0, messagesRef: createRef(), needsUserInput: false, @@ -9142,7 +9555,6 @@ export function registerProjectSupervisorSurfaceTests() { expect(invoke).toHaveBeenCalledWith('read_direct_project_history_slice', { projectPath, limit: 20, - messagesOnly: true, }), ); // 默认任务占位行也不能触发专业 Agent 历史的批量读取。 diff --git a/apps/ai-game-creator-shell/tests/directHistoryAnchorGate.test.ts b/apps/ai-game-creator-shell/tests/directHistoryAnchorGate.test.ts new file mode 100644 index 000000000..524af7152 --- /dev/null +++ b/apps/ai-game-creator-shell/tests/directHistoryAnchorGate.test.ts @@ -0,0 +1,108 @@ +import { describe, expect, it } from 'vitest'; + +import { + directHistoryAnchorGateToWaitFor, + openDirectHistoryAnchorGate, + reuseOrOpenDirectHistoryAnchorGate, +} from '../src/features/project-workspace/directHistoryAnchorGate'; + +const projectA = '/tmp/项目A'; +const projectB = '/tmp/项目B'; + +/** 订阅侧的回执:一次订阅只 settle 一次。 */ +function subscribeReceipt(gate: { settle: (anchor: string | null) => void }) { + gate.settle('turn-2-user'); +} + +describe('openDirectHistoryAnchorGate', () => { + it('默认不解析:回执到达前首屏必须等它', async () => { + const gate = openDirectHistoryAnchorGate(projectA); + const settled = { value: 'pending' as string | null | 'pending' }; + void gate.anchor.then((anchor) => { + settled.value = anchor; + }); + + // 让微任务跑一轮:没有回执就不该有结果。 + await Promise.resolve(); + await Promise.resolve(); + expect(settled.value).toBe('pending'); + expect(gate.projectPath).toBe(projectA); + expect(gate.consumed).toBe(false); + }); + + it('settle 之后 anchor 解析成回执里的 lastCompletedItemId', async () => { + const gate = openDirectHistoryAnchorGate(projectA); + subscribeReceipt(gate); + + await expect(gate.anchor).resolves.toBe('turn-2-user'); + }); + + it('订阅不可用时 settle(null):首屏退化成取文件尾', async () => { + const gate = openDirectHistoryAnchorGate(projectA); + gate.settle(null); + + await expect(gate.anchor).resolves.toBeNull(); + }); +}); + +describe('reuseOrOpenDirectHistoryAnchorGate', () => { + it('同一个项目复用同一道闸门:订阅侧要 settle 首屏已经开好的那道', () => { + const opened = openDirectHistoryAnchorGate(projectA); + + expect(reuseOrOpenDirectHistoryAnchorGate(opened, projectA)).toBe(opened); + }); + + it('换项目开一道新闸门,不复用旧项目的回执', () => { + const opened = openDirectHistoryAnchorGate(projectA); + const next = reuseOrOpenDirectHistoryAnchorGate(opened, projectB); + + expect(next).not.toBe(opened); + expect(next.projectPath).toBe(projectB); + expect(next.consumed).toBe(false); + }); + + it('还没有闸门时新开一道', () => { + expect(reuseOrOpenDirectHistoryAnchorGate(null, projectA).projectPath).toBe( + projectA, + ); + }); +}); + +describe('directHistoryAnchorGateToWaitFor', () => { + it('没有闸门(首屏比订阅 effect 先跑)时开一道给调用方登记', async () => { + const gate = directHistoryAnchorGateToWaitFor(null, projectA); + + expect(gate).not.toBeNull(); + expect(gate?.projectPath).toBe(projectA); + subscribeReceipt(gate!); + await expect(gate!.anchor).resolves.toBe('turn-2-user'); + }); + + it('同项目未消费的闸门直接复用:首屏等到订阅回执', async () => { + const opened = openDirectHistoryAnchorGate(projectA); + const toWaitFor = directHistoryAnchorGateToWaitFor(opened, projectA); + + expect(toWaitFor).toBe(opened); + subscribeReceipt(opened); + await expect(toWaitFor!.anchor).resolves.toBe('turn-2-user'); + }); + + it('同项目已消费返回 null:重开项目没有新回执可等,退回取文件尾', () => { + const opened = openDirectHistoryAnchorGate(projectA); + opened.consumed = true; + + expect(directHistoryAnchorGateToWaitFor(opened, projectA)).toBeNull(); + }); + + it('换项目不复用旧闸门:新项目的首屏要等新订阅的回执', () => { + const opened = openDirectHistoryAnchorGate(projectA); + subscribeReceipt(opened); + opened.consumed = true; + + const toWaitFor = directHistoryAnchorGateToWaitFor(opened, projectB); + + expect(toWaitFor).not.toBeNull(); + expect(toWaitFor).not.toBe(opened); + expect(toWaitFor?.projectPath).toBe(projectB); + }); +}); diff --git a/apps/ai-game-creator-shell/tests/directHistoryPaging.test.ts b/apps/ai-game-creator-shell/tests/directHistoryPaging.test.ts new file mode 100644 index 000000000..6d127375d --- /dev/null +++ b/apps/ai-game-creator-shell/tests/directHistoryPaging.test.ts @@ -0,0 +1,253 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { DIRECT_HISTORY_MAX_PAGES_PER_ACTION } from '../src/app/constants'; +import { readDirectHistoryPages } from '../src/features/project-workspace/directHistoryPaging'; +import type { DirectChatEntry } from '../src/features/project-workspace/directThreadChat'; +import { + emptyDirectThreadChatState, + mergeDirectHistoryItems, + selectDirectChatEntries, +} from '../src/features/project-workspace/directThreadChat'; +import type { + DirectThreadHistorySlice, + DirectThreadItem, +} from '../src/features/project-workspace/directThreadEvents'; + +function userItem(itemId: string): DirectThreadItem { + return { itemType: 'message', itemId, role: 'user', text: itemId, at: 1000 }; +} + +function assistantItem(itemId: string): DirectThreadItem { + return { + itemType: 'message', + itemId, + role: 'assistant', + text: `答复 ${itemId}`, + at: 2000, + }; +} + +function toolItem(itemId: string): DirectThreadItem { + return { + itemType: 'function_call', + itemId, + name: 'exec_command', + arguments: '{"cmd": "ls"}', + at: 1500, + }; +} + +function reasoningItem(itemId: string): DirectThreadItem { + return { itemType: 'reasoning', itemId, text: '想想', at: 1400 }; +} + +function slice( + items: DirectThreadItem[], + hasMore: boolean, + firstItemId: string | null, +): DirectThreadHistorySlice { + return { items, hasMore, firstItemId }; +} + +/** 用生产投影把条目拉成聊天条目:判据必须和视图看到的是同一份。 */ +function entriesOf(items: DirectThreadItem[]): DirectChatEntry[] { + return selectDirectChatEntries( + mergeDirectHistoryItems(emptyDirectThreadChatState(), items), + ); +} + +function readerOf(pages: DirectThreadHistorySlice[]) { + const remaining = [...pages]; + return vi.fn(async (_beforeItemId: string | null) => { + const next = remaining.shift(); + if (!next) throw new Error('测试桩:不该再取页'); + return next; + }); +} + +describe('DirectProject 历史分页连拉', () => { + it('整页工具与思考条目落进已渲染回合时继续取页,直到出现新的用户气泡', async () => { + // 已渲染的回合是「turn-2-user」,后面跟着它的折叠执行过程。 + const existing = entriesOf([userItem('turn-2-user'), toolItem('call-2')]); + const readSlice = readerOf([ + slice([toolItem('call-3'), reasoningItem('reason-3')], true, 'reason-3'), + slice( + [userItem('turn-1-user'), assistantItem('turn-1-assistant')], + false, + 'turn-1-user', + ), + ]); + + const result = await readDirectHistoryPages({ + existingEntries: existing, + beforeItemId: 'turn-2-user', + readSlice, + }); + + // 第二页才带来新回合:锚点按上一页最老一条推进,条目按文件顺序拼回去。 + expect(readSlice.mock.calls).toEqual([['turn-2-user'], ['reason-3']]); + expect(result.items.map((item) => item.itemId)).toEqual([ + 'turn-1-user', + 'turn-1-assistant', + 'call-3', + 'reason-3', + ]); + expect(result.hasMore).toBe(false); + expect(result.firstItemId).toBe('turn-1-user'); + expect(result.error).toBeNull(); + const merged = selectDirectChatEntries( + mergeDirectHistoryItems( + { turnRunning: false, history: existing, live: [] }, + result.items, + ), + ); + expect(merged.some((entry) => entry.itemId === 'turn-1-user')).toBe(true); + }); + + it('第一页就出现新的用户气泡时只取一页', async () => { + const existing = entriesOf([userItem('turn-2-user')]); + const readSlice = readerOf([ + slice([userItem('turn-1-user')], true, 'turn-1-user'), + ]); + + const result = await readDirectHistoryPages({ + existingEntries: existing, + beforeItemId: 'turn-2-user', + readSlice, + }); + + expect(readSlice).toHaveBeenCalledTimes(1); + // 后端说还有更早的:按钮继续留在界面上,用户可以接着往前翻。 + expect(result.hasMore).toBe(true); + expect(result.firstItemId).toBe('turn-1-user'); + }); + + it('首屏没有已渲染回合时,第一页本身就构成可见反馈', async () => { + const readSlice = readerOf([slice([toolItem('call-1')], true, 'call-1')]); + + const result = await readDirectHistoryPages({ + existingEntries: [], + beforeItemId: null, + readSlice, + }); + + expect(readSlice.mock.calls).toEqual([[null]]); + expect(result.items.map((item) => item.itemId)).toEqual(['call-1']); + }); + + it('hasMore=false 时即使没有新回合也立即停止', async () => { + const existing = entriesOf([userItem('turn-2-user')]); + const readSlice = readerOf([slice([toolItem('call-3')], false, 'call-3')]); + + const result = await readDirectHistoryPages({ + existingEntries: existing, + beforeItemId: 'turn-2-user', + readSlice, + }); + + expect(readSlice).toHaveBeenCalledTimes(1); + expect(result.hasMore).toBe(false); + }); + + it('一直没有新回合时最多连拉上限页数就停手', async () => { + const existing = entriesOf([userItem('turn-2-user')]); + const readSlice = readerOf( + Array.from( + { length: DIRECT_HISTORY_MAX_PAGES_PER_ACTION + 2 }, + (_, index) => + slice([toolItem(`call-${index + 3}`)], true, `call-${index + 3}`), + ), + ); + + const result = await readDirectHistoryPages({ + existingEntries: existing, + beforeItemId: 'turn-2-user', + readSlice, + }); + + expect(readSlice).toHaveBeenCalledTimes( + DIRECT_HISTORY_MAX_PAGES_PER_ACTION, + ); + // 用满上限但文件里还有更早的历史:按钮留在界面上,用户可再点一次。 + expect(result.hasMore).toBe(true); + expect(result.items).toHaveLength(DIRECT_HISTORY_MAX_PAGES_PER_ACTION); + expect(result.firstItemId).toBe( + `call-${DIRECT_HISTORY_MAX_PAGES_PER_ACTION + 2}`, + ); + }); + + it('锚点不前进时立即停止,不空转也不留按钮', async () => { + const existing = entriesOf([userItem('turn-2-user')]); + const readSlice = readerOf([slice([], true, null)]); + + const result = await readDirectHistoryPages({ + existingEntries: existing, + beforeItemId: 'turn-2-user', + readSlice, + }); + + expect(readSlice).toHaveBeenCalledTimes(1); + expect(result.items).toEqual([]); + expect(result.hasMore).toBe(false); + expect(result.firstItemId).toBe('turn-2-user'); + }); + + it('锚点原地打转时立即停止,不重复取同一页', async () => { + const existing = entriesOf([userItem('turn-2-user')]); + const readSlice = readerOf([ + slice([toolItem('call-3')], true, 'turn-2-user'), + ]); + + const result = await readDirectHistoryPages({ + existingEntries: existing, + beforeItemId: 'turn-2-user', + readSlice, + }); + + expect(readSlice).toHaveBeenCalledTimes(1); + expect(result.hasMore).toBe(false); + }); + + it('某一页读取失败时保留已经取到的页', async () => { + const existing = entriesOf([userItem('turn-2-user')]); + const failure = new Error('读取失败'); + const readSlice = vi.fn(async (beforeItemId: string | null) => { + if (beforeItemId === 'turn-2-user') { + return slice([toolItem('call-3')], true, 'call-3'); + } + throw failure; + }); + + const result = await readDirectHistoryPages({ + existingEntries: existing, + beforeItemId: 'turn-2-user', + readSlice, + }); + + expect(readSlice).toHaveBeenCalledTimes(2); + expect(result.items.map((item) => item.itemId)).toEqual(['call-3']); + // 失败的页没有推进锚点:下一次点击会重取这一页。 + expect(result.firstItemId).toBe('call-3'); + expect(result.hasMore).toBe(true); + expect(result.error).toBe(failure); + }); + + it('首页读取失败时保留首屏锚点并返回错误', async () => { + const failure = new Error('首页读取失败'); + const readSlice = vi.fn(async (_beforeItemId: string | null) => { + throw failure; + }); + + const result = await readDirectHistoryPages({ + existingEntries: [], + beforeItemId: null, + readSlice, + }); + + expect(readSlice).toHaveBeenCalledTimes(1); + expect(result.items).toEqual([]); + expect(result.firstItemId).toBeNull(); + expect(result.hasMore).toBe(false); + expect(result.error).toBe(failure); + }); +}); diff --git a/apps/ai-game-creator-shell/tests/directThreadChat.test.ts b/apps/ai-game-creator-shell/tests/directThreadChat.test.ts new file mode 100644 index 000000000..b8ea1f7d8 --- /dev/null +++ b/apps/ai-game-creator-shell/tests/directThreadChat.test.ts @@ -0,0 +1,743 @@ +import { describe, expect, it } from 'vitest'; + +import type { + DirectChatEntry, + DirectThreadChatState, +} from '../src/features/project-workspace/directThreadChat'; +import { + emptyDirectThreadChatState, + finishDirectThreadTurn, + mergeDirectHistoryItems, + reduceDirectThreadEvents, + resolveDirectThreadBootstrap, + selectDirectChatEntries, +} from '../src/features/project-workspace/directThreadChat'; +import type { DirectThreadEvent } from '../src/features/project-workspace/directThreadEvents'; +import type { DirectThreadItem } from '../src/features/project-workspace/directThreadItemProjection'; + +function event( + partial: Pick & Partial, +): DirectThreadEvent { + return partial as DirectThreadEvent; +} + +/** 生命周期事件上的 canonical user identity(原生 `userItemId`)。 */ +function withUserItemId( + lifecycle: DirectThreadEvent, + userItemId: string, +): DirectThreadEvent { + return { ...lifecycle, userItemId }; +} + +/** app-server `item/started`:工具真正开始执行,itemId 就是归一后的唯一身份。 */ +function toolStarted( + overrides: Partial< + Extract + > = {}, +): DirectThreadItem { + return { + itemType: 'function_call', + itemId: 'call_00_Gpd0s0Ytm9YgIbwbEXva1473', + name: 'exec_command', + arguments: '{"cmd": "ls"}', + at: 1000, + ...overrides, + }; +} + +/** 原始 response item 的输出条目:Rust 已经把它归一成同一个 itemId。 */ +function toolOutput(): DirectThreadItem { + return { + itemType: 'function_call_output', + itemId: 'call_00_Gpd0s0Ytm9YgIbwbEXva1473', + output: 'assets\ngame', + at: 2000, + }; +} + +function messageItem( + overrides: Partial> = {}, +): DirectThreadItem { + return { + itemType: 'message', + itemId: 'msg-1', + role: 'assistant', + text: '你好', + at: 3000, + ...overrides, + }; +} + +describe('DirectProject 聊天 reducer', () => { + it('生命周期事件只切换"是否还在跑",不带回合身份', () => { + const started = reduceDirectThreadEvents(emptyDirectThreadChatState(), [ + event({ type: 'turn.started' }), + ]); + expect(started.turnRunning).toBe(true); + + const completed = reduceDirectThreadEvents(started, [ + event({ type: 'turn.completed', status: 'completed' }), + ]); + expect(completed.turnRunning).toBe(false); + }); + + it('bootstrap 事件就是要处理的事件:未完成条目直接进运行态', () => { + const bootstrapped = resolveDirectThreadBootstrap( + emptyDirectThreadChatState(), + { + subscriptionId: 'sub-1', + lastCompletedItemId: 'msg-9', + events: [ + event({ type: 'turn.started' }), + event({ type: 'item.started', item: toolStarted() }), + ], + }, + ); + // 订阅身份与首屏锚点由订阅循环自己持有,不写进聊天 reducer 状态。 + expect(bootstrapped).not.toHaveProperty('subscriptionId'); + expect(bootstrapped).not.toHaveProperty('lastCompletedItemId'); + expect(bootstrapped.turnRunning).toBe(true); + expect(selectDirectChatEntries(bootstrapped)).toHaveLength(1); + }); + + it('增量正文按条目累计,完成快照更长时覆盖同一段', () => { + const streamed = reduceDirectThreadEvents(emptyDirectThreadChatState(), [ + event({ + type: 'item.delta', + itemId: 'msg-1', + kind: 'message', + delta: '你', + }), + event({ + type: 'item.delta', + itemId: 'msg-1', + kind: 'message', + delta: '好', + }), + ]); + expect(selectDirectChatEntries(streamed)).toHaveLength(1); + expect(selectDirectChatEntries(streamed)[0]?.text).toBe('你好'); + + const done = reduceDirectThreadEvents(streamed, [ + event({ + type: 'item.completed', + item: messageItem({ text: '你好,我是陶泥儿。' }), + }), + ]); + const entries = selectDirectChatEntries(done); + expect(entries).toHaveLength(1); + expect(entries[0]?.text).toBe('你好,我是陶泥儿。'); + }); + + it('思考增量按 reasoning 条目累计,不混进助手文本', () => { + const state = reduceDirectThreadEvents(emptyDirectThreadChatState(), [ + event({ + type: 'item.delta', + itemId: 'reason-1', + kind: 'reasoning', + delta: '先看目录', + }), + ]); + const entries = selectDirectChatEntries(state); + expect(entries).toHaveLength(1); + expect(entries[0]?.kind).toBe('reasoning'); + expect(entries[0]?.role).toBeNull(); + expect(entries[0]?.text).toBe('先看目录'); + }); + + it('工具调用与输出共用唯一 itemId,归并成一张卡片', () => { + const state = reduceDirectThreadEvents(emptyDirectThreadChatState(), [ + event({ type: 'item.started', item: toolStarted() }), + event({ type: 'item.completed', item: toolOutput() }), + ]); + const entries = selectDirectChatEntries(state); + expect(entries).toHaveLength(1); + expect(entries[0]?.itemId).toBe('call_00_Gpd0s0Ytm9YgIbwbEXva1473'); + expect(entries[0]?.toolCall?.kind).toBe('command'); + expect(entries[0]?.toolCall?.title).toBe('执行命令'); + expect(entries[0]?.toolCall?.detail.command).toBe('{"cmd": "ls"}'); + expect(entries[0]?.toolCall?.detail.output).toBe('assets\ngame'); + expect(entries[0]?.toolCall?.status).toBe('completed'); + }); + + it('回合结束把运行态并入历史并清空,条目不会消失也不会重复', () => { + const running = reduceDirectThreadEvents(emptyDirectThreadChatState(), [ + event({ type: 'turn.started' }), + event({ type: 'item.completed', item: messageItem() }), + event({ type: 'item.started', item: toolStarted() }), + ]); + expect(running.live).toHaveLength(2); + + const done = reduceDirectThreadEvents(running, [ + event({ type: 'turn.completed', status: 'completed' }), + ]); + expect(done.live).toHaveLength(0); + expect(done.history).toHaveLength(2); + expect(selectDirectChatEntries(done)).toHaveLength(2); + }); + + it('历史切片搬运层不合并,合并发生在前端投影', () => { + const state = mergeDirectHistoryItems(emptyDirectThreadChatState(), [ + toolStarted(), + toolOutput(), + messageItem({ itemId: 'msg-user', role: 'user', text: '做一个拼图游戏' }), + ]); + const entries = selectDirectChatEntries(state); + expect(entries).toHaveLength(2); + expect(entries[0]?.toolCall?.detail.output).toBe('assets\ngame'); + expect(entries[1]?.role).toBe('user'); + }); + + it('系统条目与未识别的 item 类型不进聊天视图', () => { + const state = reduceDirectThreadEvents(emptyDirectThreadChatState(), [ + event({ + type: 'item.completed', + item: messageItem({ + itemId: 'sys-1', + role: 'system', + text: '内部指令', + }), + }), + event({ + type: 'item.completed', + item: { + itemType: 'other', + itemId: 'plan-1', + rawType: 'plan', + at: 0, + } satisfies DirectThreadItem, + }), + ]); + expect(selectDirectChatEntries(state)).toHaveLength(0); + }); + + it('退出码非零优先于 completed:命令跑完但失败不标成功', () => { + const command = ( + status: string | null, + exitCode: number | null, + itemId: string, + ) => ({ + itemType: 'commandExecution' as const, + itemId, + command: 'npm test', + output: 'boom', + status, + exitCode, + at: 2_000, + }); + // status=completed + exitCode=1:失败(不能因为"跑完了"就标成功)。 + const failed = reduceDirectThreadEvents(emptyDirectThreadChatState(), [ + event({ + type: 'item.completed', + at: 2_000, + item: command('completed', 1, 'call-fail'), + }), + ]); + expect(selectDirectChatEntries(failed)[0]?.toolCall?.status).toBe('failed'); + + // status=completed + exitCode=0:成功。 + const ok = reduceDirectThreadEvents(emptyDirectThreadChatState(), [ + event({ + type: 'item.completed', + at: 2_000, + item: command('completed', 0, 'call-ok'), + }), + ]); + expect(selectDirectChatEntries(ok)[0]?.toolCall?.status).toBe('completed'); + + // status=inProgress 且没有退出码:仍在跑。 + const running = reduceDirectThreadEvents(emptyDirectThreadChatState(), [ + event({ + type: 'item.started', + at: 2_000, + item: command('inProgress', null, 'call-running'), + }), + ]); + expect(selectDirectChatEntries(running)[0]?.toolCall?.status).toBe( + 'running', + ); + + // 上游显式失败终态优先于 completed:即使 exitCode=0 也算失败。 + const declined = reduceDirectThreadEvents(emptyDirectThreadChatState(), [ + event({ + type: 'item.completed', + at: 2_000, + item: command('declined', 0, 'call-declined'), + }), + ]); + expect(selectDirectChatEntries(declined)[0]?.toolCall?.status).toBe( + 'failed', + ); + }); + + it('历史条目与运行态按唯一 id 合并,重复条目只出现一次', () => { + const historical = mergeDirectHistoryItems(emptyDirectThreadChatState(), [ + toolStarted(), + messageItem({ itemId: 'msg-user', role: 'user', text: '做一个拼图游戏' }), + ]); + const live = reduceDirectThreadEvents(historical, [ + event({ type: 'item.completed', item: toolOutput() }), + ]); + const entries = selectDirectChatEntries(live); + expect(entries).toHaveLength(2); + expect(entries[0]?.toolCall?.status).toBe('completed'); + expect(entries[0]?.toolCall?.detail.command).toBe('{"cmd": "ls"}'); + }); + + describe('事件级计时边界', () => { + /** 工具的开始 / 完成只读事件级 `at`,条目 `item.at` 不作起止。 */ + it('工具耗时只读事件级 at,不把 item.at 当开始或完成', () => { + const state = reduceDirectThreadEvents(emptyDirectThreadChatState(), [ + event({ type: 'turn.started', at: 1_000_000 }), + event({ + type: 'item.started', + at: 1_000_100, + item: toolStarted({ at: 999_999 }), + }), + event({ + type: 'item.completed', + at: 1_000_700, + item: toolOutput({ at: 1_200_000 }), + }), + ]); + const call = selectDirectChatEntries(state)[0]?.toolCall; + expect(call?.startedAt).toBe(1_000_100); + expect(call?.updatedAt).toBe(1_000_700); + }); + + it('只有完成事件、没有开始事件的工具不猜开始时间', () => { + const state = reduceDirectThreadEvents(emptyDirectThreadChatState(), [ + event({ type: 'item.completed', at: 1_000_700, item: toolOutput() }), + ]); + const call = selectDirectChatEntries(state)[0]?.toolCall; + expect(call?.startedAt).toBe(0); + expect(call?.updatedAt).toBe(1_000_700); + }); + + it('内部工具的阶段状态:开始事件就是运行中,正式完成事件才结束', () => { + const fileChange = { + itemType: 'fileChange', + itemId: 'file-1', + changes: [{ path: 'game/src/hero.ts', kind: 'update' }], + at: 0, + } as const; + const started = reduceDirectThreadEvents(emptyDirectThreadChatState(), [ + event({ type: 'item.started', at: 1_000_100, item: fileChange }), + ]); + const startedCall = selectDirectChatEntries(started)[0]?.toolCall; + expect(startedCall?.status).toBe('running'); + expect(startedCall?.startedAt).toBe(1_000_100); + expect(startedCall?.updatedAt).toBe(0); + + const finished = reduceDirectThreadEvents(started, [ + event({ type: 'item.completed', at: 1_000_900, item: fileChange }), + ]); + const finishedCall = selectDirectChatEntries(finished)[0]?.toolCall; + expect(finishedCall?.status).toBe('completed'); + expect(finishedCall?.updatedAt).toBe(1_000_900); + }); + + it('function_call 完成快照不等于工具结束:一直运行到 output 到达', () => { + const state = reduceDirectThreadEvents(emptyDirectThreadChatState(), [ + event({ type: 'item.started', at: 1_000_100, item: toolStarted() }), + // 同一个 function_call 又以完成快照重放一次:不是工具结束。 + event({ type: 'item.completed', at: 1_000_200, item: toolStarted() }), + ]); + const running = selectDirectChatEntries(state)[0]?.toolCall; + expect(running?.status).toBe('running'); + // 完成快照不改开始边界,也不产生终点。 + expect(running?.startedAt).toBe(1_000_100); + expect(running?.updatedAt).toBe(0); + + const done = reduceDirectThreadEvents(state, [ + event({ type: 'item.completed', at: 1_000_900, item: toolOutput() }), + ]); + const finished = selectDirectChatEntries(done)[0]?.toolCall; + expect(finished?.status).toBe('completed'); + expect(finished?.updatedAt).toBe(1_000_900); + }); + + it('收口后重复 / 迟到的终态事件不抬高冻结终点、不复活运行态', () => { + const done = reduceDirectThreadEvents(emptyDirectThreadChatState(), [ + event({ type: 'turn.started', at: 1_000_000 }), + event({ type: 'item.started', at: 1_000_100, item: toolStarted() }), + event({ + type: 'item.completed', + at: 1_000_700, + item: toolOutput(), + }), + event({ type: 'turn.completed', status: 'completed', at: 1_000_900 }), + ]); + expect(done.turnRunning).toBe(false); + expect(done.turnEndedAt).toBe(1_000_900); + expect(done.history[0]?.turnEndedAt).toBe(1_000_900); + expect(done.history[0]?.turnStartedAt).toBe(1_000_000); + + const replayed = reduceDirectThreadEvents(done, [ + // 重复的完成事件 + 迟到的事件不得抬高已经冻结的终点。 + event({ type: 'turn.completed', status: 'completed', at: 1_009_900 }), + event({ type: 'item.completed', at: 1_009_900, item: toolOutput() }), + ]); + expect(replayed.turnRunning).toBe(false); + expect(replayed.turnEndedAt).toBe(1_000_900); + expect(replayed.history[0]?.toolCall?.updatedAt).toBe(1_000_700); + }); + + it('回合身份只认事件顺序:同一秒内开始的下一轮也照常接上', () => { + const done = reduceDirectThreadEvents(emptyDirectThreadChatState(), [ + event({ type: 'turn.started', at: 1_000_000 }), + // 宿主收口的毫秒时间:原生回合时间是秒级精度,"上一轮结束之后又来一条开始事件" + // 就是新回合,不能因为时间不比终点晚就把它当重放吞掉。 + event({ type: 'turn.completed', status: 'cancelled', at: 1_000_900 }), + ]); + const next = reduceDirectThreadEvents(done, [ + event({ type: 'turn.started', at: 1_000_000 }), + ]); + expect(next.turnRunning).toBe(true); + expect(next.turnStartedAt).toBe(1_000_000); + expect(next.turnEndedAt).toBe(0); + }); + + it('同一轮内的重复开始事件保留第一次的起点', () => { + const started = reduceDirectThreadEvents(emptyDirectThreadChatState(), [ + event({ type: 'turn.started', at: 1_000_000 }), + event({ type: 'turn.started', at: 1_000_000 }), + event({ type: 'turn.started', at: 1_000_500 }), + ]); + expect(started.turnRunning).toBe(true); + expect(started.turnStartedAt).toBe(1_000_000); + }); + + it('终态之后同身份的迟到条目补进历史,不挂到下一轮运行态', () => { + const done = reduceDirectThreadEvents(emptyDirectThreadChatState(), [ + event({ type: 'turn.started', at: 1_000_000 }), + event({ type: 'item.started', at: 1_000_100, item: toolStarted() }), + event({ type: 'turn.completed', status: 'completed', at: 1_000_900 }), + ]); + // 迟到的事件:同身份的完成快照在回合收口之后才到。 + const late = reduceDirectThreadEvents(done, [ + event({ type: 'item.completed', at: 1_000_700, item: toolOutput() }), + ]); + expect(late.live).toHaveLength(0); + expect(late.history).toHaveLength(1); + expect(late.history[0]?.toolCall?.status).toBe('completed'); + expect(late.history[0]?.toolCall?.detail.output).toBe('assets\ngame'); + // 冻结的终点不被抬高。 + expect(late.history[0]?.toolCall?.updatedAt).toBe(1_000_700); + + // 新回合开始后,迟到条目仍然回历史,不会挤进这一轮。 + const next = reduceDirectThreadEvents(late, [ + event({ type: 'turn.started', at: 1_001_000 }), + event({ type: 'item.completed', at: 1_001_700, item: toolOutput() }), + ]); + expect(next.live).toHaveLength(0); + expect(next.history).toHaveLength(1); + }); + + it('bootstrap 已把用户消息当历史锚点给出时,边界仍落在本轮开口条目上', () => { + const bootstrapped = resolveDirectThreadBootstrap( + mergeDirectHistoryItems(emptyDirectThreadChatState(), [ + messageItem({ itemId: 'msg-user', role: 'user', text: '做一个拼图' }), + ]), + { + subscriptionId: 'sub-1', + lastCompletedItemId: 'msg-user', + events: [ + // 原生在生命周期锚点上带出本轮 canonical user identity,开口条目已经在历史里。 + withUserItemId( + event({ type: 'turn.started', at: 1_000_000 }), + 'msg-user', + ), + event({ + type: 'item.completed', + at: 1_000_700, + item: toolOutput(), + }), + event({ + type: 'turn.completed', + status: 'completed', + at: 1_000_900, + }), + ], + }, + ); + expect(selectDirectChatEntries(bootstrapped)[0]?.turnEndedAt).toBe( + 1_000_900, + ); + expect(selectDirectChatEntries(bootstrapped)[0]?.turnStartedAt).toBe( + 1_000_000, + ); + }); + + it('没有 userItemId 的生命周期事件保持顺序语义,但不猜历史归属', () => { + const bootstrapped = resolveDirectThreadBootstrap( + mergeDirectHistoryItems(emptyDirectThreadChatState(), [ + messageItem({ itemId: 'msg-user', role: 'user', text: '做一个拼图' }), + ]), + { + subscriptionId: 'sub-1', + lastCompletedItemId: 'msg-user', + events: [ + // 旧原生事件不带身份:运行态条目照常收口,历史里那条不按尾巴猜。 + event({ + type: 'item.completed', + at: 1_000_700, + item: toolOutput(), + }), + event({ + type: 'turn.completed', + status: 'completed', + at: 1_000_900, + }), + ], + }, + ); + const entries = selectDirectChatEntries(bootstrapped); + expect(entries).toHaveLength(2); + expect(entries[0]?.itemId).toBe('msg-user'); + expect(entries[0]?.turnEndedAt).toBeUndefined(); + // 运行态那条照常拿到边界(顺序语义不变)。 + expect(entries[1]?.turnEndedAt).toBe(1_000_900); + }); + + it('本轮开口条目已在历史、运行态为空时,收口仍按身份把边界盖在它身上', () => { + const userItem = messageItem({ + itemId: 'direct-codex:turn-1:user', + role: 'user', + text: '做一个拼图游戏', + }); + const running = reduceDirectThreadEvents( + mergeDirectHistoryItems(emptyDirectThreadChatState(), [userItem]), + [ + // 原生在生命周期事件上带出本轮 canonical user identity;这一轮没有任何运行态 + // 条目,开口条目只存在于历史里。 + withUserItemId( + event({ type: 'turn.started', at: 1_000_000 }), + 'direct-codex:turn-1:user', + ), + ], + ); + expect(running.live).toHaveLength(0); + + const done = reduceDirectThreadEvents(running, [ + withUserItemId( + event({ type: 'turn.completed', status: 'aborted', at: 1_000_900 }), + 'direct-codex:turn-1:user', + ), + ]); + // 边界落在本轮这条用户条目上:既不丢终态时间,也没有第二套回合身份。 + expect(done.history[0]?.turnStartedAt).toBe(1_000_000); + expect(done.history[0]?.turnEndedAt).toBe(1_000_900); + }); + + it('终态先于历史切片到达时,回读到开口条目仍补上已冻结的边界', () => { + const started = resolveDirectThreadBootstrap( + emptyDirectThreadChatState(), + { + subscriptionId: 'sub-1', + lastCompletedItemId: null, + events: [ + withUserItemId( + event({ type: 'turn.started', at: 1_000_000 }), + 'direct-codex:turn-1:user', + ), + ], + }, + ); + // 终态先到:这一刻历史里还没有那条用户条目。 + const finished = reduceDirectThreadEvents(started, [ + withUserItemId( + event({ type: 'turn.completed', status: 'completed', at: 1_000_900 }), + 'direct-codex:turn-1:user', + ), + ]); + expect(finished.turnEndedAt).toBe(1_000_900); + expect(finished.history).toHaveLength(0); + + const hydrated = mergeDirectHistoryItems(finished, [ + messageItem({ + itemId: 'direct-codex:turn-1:user', + role: 'user', + text: '做一个拼图游戏', + at: 999_000, + }), + ]); + expect(hydrated.history[0]?.turnStartedAt).toBe(1_000_000); + expect(hydrated.history[0]?.turnEndedAt).toBe(1_000_900); + }); + + it('上一轮迟到的终态按身份被拒,不关掉正在跑的这一轮', () => { + const running = reduceDirectThreadEvents(emptyDirectThreadChatState(), [ + withUserItemId( + event({ type: 'turn.started', at: 2_000_000 }), + 'direct-codex:turn-1:user', + ), + event({ type: 'item.started', at: 2_000_100, item: toolStarted() }), + ]); + + const late = reduceDirectThreadEvents(running, [ + // 上一轮的终态(身份不同):哪怕时间戳更早,也不允许收口正在跑的这一轮。 + withUserItemId( + event({ type: 'turn.completed', status: 'aborted', at: 1_000_900 }), + 'direct-codex:turn-0:user', + ), + ]); + expect(late.turnRunning).toBe(true); + expect(late.turnEndedAt).toBe(0); + expect(late.live).toHaveLength(1); + }); + + it('同一轮的重复终态不抬高已冻结的终点', () => { + const done = reduceDirectThreadEvents(emptyDirectThreadChatState(), [ + withUserItemId( + event({ type: 'turn.started', at: 1_000_000 }), + 'direct-codex:turn-1:user', + ), + withUserItemId( + event({ type: 'turn.completed', status: 'completed', at: 1_000_900 }), + 'direct-codex:turn-1:user', + ), + ]); + const replayed = reduceDirectThreadEvents(done, [ + withUserItemId( + event({ type: 'turn.completed', status: 'completed', at: 9_900_000 }), + 'direct-codex:turn-1:user', + ), + ]); + expect(replayed.turnEndedAt).toBe(1_000_900); + expect(replayed.live).toHaveLength(0); + }); + + it('本轮自己没有任何条目时,不把边界盖到上一轮已经收口的开口条目上', () => { + const previousOpener: DirectChatEntry = { + itemId: 'direct-codex:turn-0:user', + kind: 'message', + role: 'user', + text: '上一轮的问题', + at: 900_000, + turnStartedAt: 910_000, + turnEndedAt: 950_000, + }; + const previous: DirectThreadChatState = { + ...emptyDirectThreadChatState(), + history: [previousOpener], + }; + const done = reduceDirectThreadEvents(previous, [ + withUserItemId( + event({ type: 'turn.started', at: 2_000_000 }), + 'direct-codex:turn-2:user', + ), + withUserItemId( + event({ type: 'turn.completed', status: 'aborted', at: 2_000_400 }), + 'direct-codex:turn-2:user', + ), + ]); + expect(done.history[0]?.turnStartedAt).toBe(910_000); + expect(done.history[0]?.turnEndedAt).toBe(950_000); + }); + + /** + * 本轮开口条目只由 history slice 回读、运行态全程为空:身份只能来自原生生命周期事件上 + * 带的 canonical user identity,不能按历史尾或时间戳猜。 + */ + it('只有生命周期锚点 + 历史回读的开口条目时,按原生身份精准恢复用时', () => { + const running = resolveDirectThreadBootstrap( + emptyDirectThreadChatState(), + { + subscriptionId: 'sub-1', + lastCompletedItemId: null, + events: [ + withUserItemId( + event({ type: 'turn.started', at: 1_000_000 }), + 'direct-codex:turn-1:user', + ), + ], + }, + ); + const withHistory = mergeDirectHistoryItems(running, [ + messageItem({ + itemId: 'direct-codex:turn-1:user', + role: 'user', + text: '做一个拼图游戏', + at: 999_000, + }), + ]); + expect(withHistory.live).toHaveLength(0); + + const done = reduceDirectThreadEvents(withHistory, [ + withUserItemId( + event({ type: 'turn.completed', status: 'aborted', at: 1_000_900 }), + 'direct-codex:turn-1:user', + ), + ]); + expect(done.turnRunning).toBe(false); + expect(done.turnEndedAt).toBe(1_000_900); + // 身份来自原生事件,历史里那条开口条目因此拿到同一组边界。 + expect(done.history[0]?.turnStartedAt).toBe(1_000_000); + expect(done.history[0]?.turnEndedAt).toBe(1_000_900); + }); + + it('收口只填空,不抬高条目上已经冻结的终点', () => { + // 规则本身:已经写上的边界先到先用,重复 / 迟到的收口不得抬高它。 + const frozenOpener: DirectChatEntry = { + itemId: 'direct-codex:turn-1:user', + kind: 'message', + role: 'user', + text: '做一个拼图游戏', + at: 999_000, + turnStartedAt: 1_000_000, + turnEndedAt: 1_000_900, + }; + const state: DirectThreadChatState = { + ...emptyDirectThreadChatState(), + history: [frozenOpener], + turnUserItemId: 'direct-codex:turn-1:user', + live: [ + { + itemId: 'call-late', + kind: 'tool', + role: null, + text: null, + at: 0, + toolCall: { + schemaVersion: 'agc-tool-call.v1', + id: 'call-late', + kind: 'command', + title: '执行命令', + summary: 'npm run build', + status: 'completed', + detail: { command: 'npm run build' }, + startedAt: 1_000_100, + updatedAt: 1_000_700, + }, + }, + ], + turnStartedAt: 1_000_000, + }; + const done = finishDirectThreadTurn(state, 9_900_000); + expect(done.history[0]?.turnEndedAt).toBe(1_000_900); + expect(done.history[0]?.turnStartedAt).toBe(1_000_000); + }); + + it('宿主终止收口:没有权威终态时间就不写,之后的原生终态事件也不会被抬高', () => { + const running = reduceDirectThreadEvents(emptyDirectThreadChatState(), [ + event({ type: 'turn.started', at: 1_000_000 }), + event({ type: 'item.completed', at: 1_000_700, item: toolOutput() }), + ]); + const released = finishDirectThreadTurn(running, Date.now()); + expect(released.turnRunning).toBe(false); + expect(released.live).toHaveLength(0); + expect(released.history[0]?.turnEndedAt).toBe(released.turnEndedAt); + + const unknown = finishDirectThreadTurn(running, 0); + expect(unknown.turnEndedAt).toBe(0); + expect(unknown.history[0]?.turnEndedAt).toBeUndefined(); + // 已经收口:后续事件不再改动终态时间,也不复活运行态。 + const replayed = reduceDirectThreadEvents(unknown, [ + event({ type: 'turn.completed', status: 'completed', at: 1_009_900 }), + ]); + expect(replayed.turnRunning).toBe(false); + expect(replayed.turnEndedAt).toBe(0); + }); + }); +}); diff --git a/docs/project-memory/plans/【实施计划】DirectProject聊天真相源收敛-2026-09-16.md b/docs/project-memory/plans/【实施计划】DirectProject聊天真相源收敛-2026-09-16.md index 933fca8a6..5c8e78681 100644 --- a/docs/project-memory/plans/【实施计划】DirectProject聊天真相源收敛-2026-09-16.md +++ b/docs/project-memory/plans/【实施计划】DirectProject聊天真相源收敛-2026-09-16.md @@ -28,8 +28,8 @@ 1. `cargo test --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml direct_thread -- --nocapture` 2. `cargo test --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml export_bindings`(生成 `src/features/project-workspace/generated/`),随后用 `prettier --write` 格式化生成目录,避免未格式化的 ts-rs 输出混进提交 -3. `npx vitest run apps/ai-game-creator-shell/tests/directThreadChat.test.ts apps/ai-game-creator-shell/tests/directTurnPresentation.test.ts apps/ai-game-creator-shell/tests/directHistoryPaging.test.ts` -4. `npx vitest run apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts` +3. `npm run test -- apps/ai-game-creator-shell/tests/directThreadChat.test.ts apps/ai-game-creator-shell/tests/directTurnPresentation.test.ts apps/ai-game-creator-shell/tests/directHistoryPaging.test.ts apps/ai-game-creator-shell/tests/directHistoryAnchorGate.test.ts` +4. `npm run test -- apps/ai-game-creator-shell/tests/appSurface.test.ts`(`project-development.suite.ts` 由该入口注册,不能作为独立测试入口;同时验证现役 Design Agent 的会话恢复与审批界面。) 5. TypeScript 类型检查与 ESLint(范围同前次 DirectProject 迁移)。 6. `npm run check:encoding`、`npm run check:doc-index`、`git diff --check`