diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/controller/useDirectProjectChatController.ts b/apps/ai-game-creator-shell/src/view/project-development/chat/controller/useDirectProjectChatController.ts index 851515762..555405dcb 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/chat/controller/useDirectProjectChatController.ts +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/controller/useDirectProjectChatController.ts @@ -120,8 +120,9 @@ export type DirectProjectChatControllerProps = { * 三份原始输入各自是什么、带什么、活多久: * - 项目对话历史(`.agent/conversations/project.jsonl`):持久,只有条目、**没有回合边界**, * 经历史切片读取(首屏按 `lastCompletedItemId` 锚定)。 - * - 运行态事件(subscribe / consume / notify):进程内;`turn.started` / `turn.completed` 是原生回合 - * 活跃与否的**唯一**判据;可回收事件被回收后靠 `lifecycle_anchor` 保住最新一条生命周期事件。 + * - 运行态事件(subscribe / consume / notify):进程内;`turn.started` / `turn.completed` 是**逻辑回合** + * 活跃与否的**唯一**判据(接单时成对发出,不再镜像 Codex 原生回合);可回收事件被回收后靠 + * `lifecycle_anchor` 保住最新一条生命周期事件。 * 失败也走这条流:`turn.completed.failure` 自己带脱敏后的原因,reducer 把它落成本轮说明条目; * 命令返回那条通道只提供横幅与诊断,不再写聊天文案。 * - 本地发送:只存在于本次会话,`projectPath` 变化即清空;乐观气泡与原生条目同身份 @@ -129,20 +130,22 @@ export type DirectProjectChatControllerProps = { * * 一次发送的时序(第 2 → 3 步之间就是「本地已发出、宿主还没确认」的空窗): * 1. 按下发送:`localMessages += 乐观气泡`、`turnBusy=true`、`pendingUserItemId=本轮身份`(同帧)。 - * 2. `invoke('chat_with_game_creator_direct_codex')`:Rust 先落盘用户条目,再发 `turn/start`, - * **应答返回后**才 append `turn.started` 并 notify。 + * 2. `invoke('chat_with_game_creator_direct_codex')`:Rust 走完接单前的检查 → 接单(登记占用 + + * append `turn.started`)→ 落盘用户条目 → spawn 整轮 → **立刻返回**。命令返回只说明接单成立, + * 整轮的结果不再从这条通道回来;拒单则返回结构化的 typed 错误。 * 3. notify → consume → `turn.started`:reducer 的 `turnRunning=true`、`turnStartedAt`、`turnUserItemId`。 + * 身份命中本轮时本地在途标签退场(第 2 步到这一步之间界面仍算"在途",见 `pendingUserItemId`)。 * 4. `item.completed`(本轮用户条目回显):同身份条目已在历史里就合并进去,否则进 `live`;本地气泡此时被去重。 * 5. `item.delta` / `item.started` / `item.completed`:正文追加、工具卡片 upsert(先到定形、后到只补空)。 - * 6. `turn.completed`:`live` 并入 `history` 后清空,`turnEndedAt` 冻结,边界按身份盖到本轮开口条目上; - * 带 `failure` 载荷时,说明条目已经在上一步由 reducer 落进 `live`,随本轮一起并入历史。 - * 7. 命令收尾(`finally`):刷新清单 → `endTurnCommand()` 清掉忙态与在途身份 → 出队下一轮。 - * **顺序是契约**:出队会同步开始下一轮并设上它自己的忙态,所以清忙态必须早于出队; - * 权限被拒那种「本轮从未发出但要继续出队」的情况,也只标记 `queueAdvance`、由这里统一收口。 + * 6. `turn.completed`:`live` 并入 `history` 后清空,`turnEndedAt` 冻结,边界按身份盖到本轮开口条目上, + * 收口计数 +1;带 `failure` 载荷时,说明条目已经在上一步由 reducer 落进 `live`,随本轮一起并入历史。 + * 7. **回合终态**(第 6 步的收口计数变化):结算本轮埋点 → 放行发送队列,顺序固定在这一处。 + * 8. 命令收尾(`finally`):刷新清单;只在**没接单**时放掉忙态与在途身份并出队(权限被拒那种 + * 「本轮从未发出但要继续出队」的路径也在这里收口),接单成立的那一轮交给第 3 / 7 步。 * - * 状态变量归属:reducer 的三个回合字段与 `history` / `live` 只由 `directThreadChat.ts` 写; + * 状态变量归属:reducer 的回合字段、收口计数与 `history` / `live` 只由 `directThreadChat.ts` 写; * 本文件的 `turnBusy` / `pendingUserItemId`(同生共死,唯一入口 `beginTurnCommand` / - * `endTurnCommand`)、`localMessages`、发送队列与分页 ref 只服务发送与展示;界面上的 + * `endTurnCommand`)、`localMessages`、发送队列、埋点句柄与分页 ref 只服务发送与展示;界面上的 * 「这一轮在跑吗」只有一个派生入口 `useDirectProjectTurnStatus()`,三态判据与真值表在 * `../conversation/directTurnPresentation.ts` 的 `DirectChatTurnState`,渲染时否定式读 * `state !== 'finished'`、肯定式读 `state === 'running'`(见 `DirectProjectTurn.tsx`)。 @@ -176,8 +179,13 @@ export function useDirectProjectChatController({ const [statusNotice, setStatusNotice] = useState(''); const [turnCancelling, setTurnCancelling] = useState(false); const [turnBusy, setTurnBusy] = useState(false); - // 本地已发出、原生还没认领的那一轮用户条目身份:只服务投影的 `awaiting-start` 展示态, - // 生命周期与 `turnBusy` 完全一致(命令在飞期间有值,收尾即清)。 + /** + * 本地已发出、宿主还没认领的那一轮用户条目身份:只服务投影的 `awaiting-start` 展示态。 + * + * 生命周期与 `turnBusy` 一致(同生共死),但**不是**"命令在飞":接单化之后命令只等到接单, + * 所以它从按下发送一直活到宿主那一轮的开始事件被 reducer 认领(身份命中)。这一段必须仍在 + * "在途",否则命令返回与开始事件到达之间会出现一个可发送的空窗。 + */ const [pendingUserItemId, setPendingUserItemId] = useState(''); const [localMessages, setLocalMessages] = useState([]); // 订阅(subscribe/consume/notify)与聊天 reducer 状态在自己的 hook 里: @@ -188,6 +196,8 @@ export function useDirectProjectChatController({ }); const directEntries = directThread.entries; const currentTurnRunning = directThread.turnRunning; + const completedTurnCount = directThread.completedTurnCount; + const turnUserItemId = directThread.state.turnUserItemId; const [historyHasMore, setHistoryHasMore] = useState(false); const historyOldestItemIdRef = useRef(null); const historyLoadingRef = useRef(false); @@ -198,8 +208,22 @@ export function useDirectProjectChatController({ directTurnRunningRef.current = currentTurnRunning; const turnBusyRef = useRef(turnBusy); turnBusyRef.current = turnBusy; + /** 已经处理过的收口回合数:与 reducer 的计数比较,识别"又有回合结束了"。 */ + const handledCompletedTurnCountRef = useRef(0); + /** 起这一轮时的收口计数:宿主没给身份时只能靠"计数变过"认领这一轮(见下面的 effect)。 */ + const pendingTurnBaselineRef = useRef(0); + const completedTurnCountRef = useRef(completedTurnCount); + completedTurnCountRef.current = completedTurnCount; + /** 有回合结束了、但还不能出队(本地在途标签还没退场)时挂起,等忙态放掉再出队。 */ const completionPendingRef = useRef(false); - const previousTurnRunningRef = useRef(currentTurnRunning); + /** + * 本轮的埋点句柄。它在命令返回之后仍然要活着:成绩是**回合末**才在宿主侧入账的, + * 接单返回时结算只会静默丢掉这一次埋点(见 `settlePendingRunAnalytics`)。 + */ + const pendingRunAnalyticsRef = useRef<{ + clientTurnId: string; + runAnalytics: ReturnType; + } | null>(null); useEffect(() => { setAttachmentNotice(''); @@ -211,6 +235,9 @@ export function useDirectProjectChatController({ setPendingUserItemId(''); setHistoryHasMore(false); historyOldestItemIdRef.current = null; + handledCompletedTurnCountRef.current = 0; + completionPendingRef.current = false; + pendingRunAnalyticsRef.current = null; }, [projectPath]); useEffect(() => { @@ -223,22 +250,65 @@ export function useDirectProjectChatController({ return () => window.clearInterval(timer); }, [enabled, turnBusy, currentTurnRunning]); + /** + * 回合终态是队列放行与埋点结算的唯一出口(接单被拒走命令那条路,见 `startTurn` 的收尾)。 + * + * 判据用 reducer 的**单调计数**而不是 `turnRunning` 的下降沿:一轮可能在同一次 consume 里 + * 开始并结束,那时下降沿永远不会出现,队列就永久卡住了。 + * + * TODO(发送队列):这条队列整体挪到 Rust 端,放行点就是 Thread Manager 的接单动作。 + */ useEffect(() => { if (!enabled) { + handledCompletedTurnCountRef.current = completedTurnCount; completionPendingRef.current = false; - previousTurnRunningRef.current = currentTurnRunning; return; } - const wasRunning = previousTurnRunningRef.current; - previousTurnRunningRef.current = currentTurnRunning; - if (wasRunning && !currentTurnRunning) completionPendingRef.current = true; - if (completionPendingRef.current && !turnBusy && !currentTurnRunning) { + if (completedTurnCount !== handledCompletedTurnCountRef.current) { + handledCompletedTurnCountRef.current = completedTurnCount; + // 先结算埋点:宿主此刻已经把这一轮的成绩写进候选,再晚也还是同一轮。 + settlePendingRunAnalytics(); + completionPendingRef.current = true; + } + if ( + completionPendingRef.current && + !turnBusyRef.current && + !directTurnRunningRef.current + ) { completionPendingRef.current = false; dispatchNextQueuedTurn(); } - // 队列出队只由忙碌态和线程完成态驱动。 + // 出队只由"回合收口次数 + 本地在途状态"驱动,队列本身不是依赖。 // eslint-disable-next-line react-hooks/exhaustive-deps - }, [turnBusy, currentTurnRunning, enabled]); + }, [enabled, completedTurnCount, turnBusy, currentTurnRunning]); + + /** + * 宿主认领了这一轮:本地在途标签退场,忙态交给原生真相。 + * + * 判据是**事件流里的回合边界**而不是命令的返回值:接单化之后命令先返回、开始事件后到, + * 中间那一段必须仍算"在途"(投影显示 `awaiting-start`)。 + * + * 三条判据任何一条成立都算认领(前一条是正常路径,后两条是"订阅流没带回合身份"时的兜底, + * 没有它们一个不带 `userItemId` 的边界事件就能把 composer 永久锁成忙碌): + * - 身份命中:`turnUserItemId` 就是本轮的开口用户条目身份(ADR §5:宿主按 `clientTurnId` 现算); + * - 原生已经在跑:这条流里出现了开始事件; + * - 收口计数变过:这一轮在同一次 consume 里开始又结束。 + */ + useEffect(() => { + if (!pendingUserItemId) return; + const claimedByHost = + turnUserItemId === pendingUserItemId || + currentTurnRunning || + completedTurnCount > pendingTurnBaselineRef.current; + if (!claimedByHost) return; + endTurnCommand(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [ + pendingUserItemId, + turnUserItemId, + currentTurnRunning, + completedTurnCount, + ]); useEffect(() => { if (!enabled || !projectPath) return; @@ -261,7 +331,8 @@ export function useDirectProjectChatController({ }, [enabled, projectPath]); /** - * 「本地这一轮的命令在飞」的唯一起止点:按下发送时带上本轮用户条目身份,收尾时一起清掉。 + * 「本地这一轮在途」的唯一起止点:按下发送时带上本轮用户条目身份,宿主认领这一轮(开始 + * 事件的身份命中)或这一轮明确没成立时一起清掉。 * * 忙态与待认领身份必须同生共死,否则投影会拿一个过期的身份去判 `awaiting-start`。 */ @@ -269,6 +340,7 @@ export function useDirectProjectChatController({ turnBusyRef.current = true; setTurnBusy(true); setPendingUserItemId(userItemId); + pendingTurnBaselineRef.current = completedTurnCountRef.current; } function endTurnCommand() { @@ -277,6 +349,16 @@ export function useDirectProjectChatController({ setPendingUserItemId(''); } + /** + * 结算本轮埋点。只在**回合终态**调用:成绩是回合末才在宿主侧入账的,接单返回时就结算 + * 会变成一次空操作(宿主找不到候选,静默丢弃)。拒单那一轮没有候选,句柄由 `runTurn` 自己清掉。 + */ + function settlePendingRunAnalytics() { + const pending = pendingRunAnalyticsRef.current; + pendingRunAnalyticsRef.current = null; + pending?.runAnalytics.settle(); + } + function appendLocalMessage(message: ChatMessage) { setLocalMessages((current) => [...current, message]); } @@ -500,6 +582,8 @@ export function useDirectProjectChatController({ ); void (async () => { let invoked = false; + // 命令是否接了单。只有它为真时,这一轮的收尾才交给宿主的事件。 + let turnAccepted = false; // 权限被拒也要继续出队(见下),但出队必须发生在 finally 的 endTurnCommand() 之后: // 在这里出队的话,下一轮刚设上的忙态会被紧接着的 finally 清掉。 let queueAdvance = false; @@ -525,7 +609,7 @@ export function useDirectProjectChatController({ const invoke = resolveTauriInvoke(); if (!invoke) return; invoked = true; - await runTurn(invoke, nextProjectPath, input); + turnAccepted = await runTurn(invoke, nextProjectPath, input); } catch (error) { // 写权限门等前置步骤抛出时不能只留一个未处理的 rejection:回合会静默失败, // 已经乐观追加的用户消息也没有任何解释。 @@ -540,16 +624,20 @@ export function useDirectProjectChatController({ if (invoked) { await refreshDirectManifest(nextProjectPath); } - // 忙态与在途身份每轮只在这里放一次,且必须早于出队:出队会同步开始下一轮并设上 - // 它自己的忙态,清在它后面就等于把下一轮的忙态抹掉(composer 会以为可以并发发送, - // 下一轮的三态也会因为身份被清空而掉回 finished)。 - endTurnCommand(); - // 出队条件保持原样:真发出过的一轮要求项目没被换掉;权限被拒的一轮从未发出, - // 不受项目切换影响,照旧出队。 - const invokedInSameProject = - invoked && projectPathRef.current === nextProjectPath; - if (queueAdvance || invokedInSameProject) { - dispatchNextQueuedTurn(); + // 收尾分两种:接单成立的整轮交给宿主的事件(`turn.started` 时退场、`turn.completed` + // 时出队与结算,见上面两个 effect);没成立的那些路径没有任何事件会来,只能在这里收口。 + if (!turnAccepted) { + // 忙态必须早于出队放掉:出队会同步开始下一轮并设上它自己的忙态,清在它后面就等于 + // 把下一轮的忙态抹掉(composer 会以为可以并发发送,下一轮的三态也会掉回 finished)。 + endTurnCommand(); + // 出队条件:权限被拒的一轮从未发出,不受项目切换影响,照旧出队;命令真发出过又返回 + // 拒单时要求项目没被换掉;没有 invoke(非 Tauri 环境)时不出队,避免空转。 + if ( + queueAdvance || + (invoked && projectPathRef.current === nextProjectPath) + ) { + dispatchNextQueuedTurn(); + } } } })(); @@ -559,25 +647,34 @@ export function useDirectProjectChatController({ invoke: NonNullable>, nextProjectPath: string, input: DirectProjectTurnInput, - ) { + ): Promise { + // 命令返回 `Ok` 只说明**接单成立**:整轮怎么收场只由 `turn.completed` 事件回答。 + // 所以这里的返回值只服务队列放行——"这一轮有没有真的开始"。 + let turnAccepted = false; try { const runAnalytics = beginDirectRunAnalytics( invoke, currentPlatformSessionGeneration, ); - try { - await invoke('chat_with_game_creator_direct_codex', { - projectPath: nextProjectPath, - clientTurnId: input.clientTurnId, - userItem: input.userItem, - analyticsAttemptId: runAnalytics.nextAttempt(), - ...(input.creationType ? { creationType: input.creationType } : {}), - }); - } finally { - runAnalytics.settle(); - } + // 埋点句柄必须活过命令返回:成绩是回合末才入账的,settle 只能在回合终态发生。 + pendingRunAnalyticsRef.current = { + clientTurnId: input.clientTurnId, + runAnalytics, + }; + await invoke('chat_with_game_creator_direct_codex', { + projectPath: nextProjectPath, + clientTurnId: input.clientTurnId, + userItem: input.userItem, + analyticsAttemptId: runAnalytics.nextAttempt(), + ...(input.creationType ? { creationType: input.creationType } : {}), + }); + turnAccepted = true; // 清单刷新统一交给 startTurn 的 finally:成功与报错路径都覆盖,且只读一次。 } catch (error) { + // 拒单那一轮没有接单,也就不会有埋点候选:句柄只清不发(发出去也只是空操作)。 + if (pendingRunAnalyticsRef.current?.clientTurnId === input.clientTurnId) { + pendingRunAnalyticsRef.current = null; + } // 命令的拒单是**结构化的**:命令返回 `Ok` 只说明接单成立,所以这条 catch 从接单化之后 // 只剩"拒单"一种输入(整轮结果由 `turn.completed` 事件回答,不再回到这里)。 const rejection = readDirectTurnRejection(error); @@ -598,10 +695,10 @@ export function useDirectProjectChatController({ updatedAt: Date.now(), }); } - return; + return false; } } - if (projectPathRef.current !== nextProjectPath) return; + if (projectPathRef.current !== nextProjectPath) return false; // 认不出的拒单(宿主 / 环境事实)与其它非结构化错误走同一条通道:上报 + 横幅。 void captureAgentRuntimeError(error, DIRECT_CODEX_AGENT_ID); const message = rejection @@ -616,11 +713,12 @@ export function useDirectProjectChatController({ '陶泥儿智能创作', true, ); - if (projectPathRef.current !== nextProjectPath) return; + if (projectPathRef.current !== nextProjectPath) return false; // 聊天里的失败说明不由这里写:宿主已经把它放进了 `turn.completed.failure`,reducer 会把它 // 落成本轮最后一条条目(唯一来源)。这里只保留运行错误横幅与诊断留痕。 onRuntimeError(visibleMessage); } + return turnAccepted; } async function refreshDirectManifest(nextProjectPath: string) { @@ -653,7 +751,8 @@ export function useDirectProjectChatController({ ); const message = result?.message?.trim(); if (result?.outcome === 'released') { - directThread.markTurnStopped(); + // 本地只放掉"命令在飞"这一层;这一轮的**回合边界**不在这里收口——宿主的兜底终止 + // 已经把终态写进事件流,界面等那条 `turn.completed` 自己落到 reducer 上。 endTurnCommand(); onRuntimeError(''); setComposerNotice(message ?? '已结束这一轮占用,可以直接重新发送消息'); diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/controller/useDirectProjectTurnStatus.ts b/apps/ai-game-creator-shell/src/view/project-development/chat/controller/useDirectProjectTurnStatus.ts index 05c9f456e..a57a19a89 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/chat/controller/useDirectProjectTurnStatus.ts +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/controller/useDirectProjectTurnStatus.ts @@ -14,8 +14,9 @@ import type { * * - `nativeRunning`:**原生真相**。只由订阅 reducer 的 `turnRunning` 给出(`turn.started` * 已到、`turn.completed` 未到)。它决定"陶泥儿正在处理"这类原生过程提示。 - * - `commandInFlight`:**本地真相**。本次会话的发送命令是否在飞(写权限门 → invoke → - * 收尾);它从按下发送那一刻就为真,与原生是否已经开始无关。 + * - `commandInFlight`:**本地真相**。本次会话是否有一条本地在途回合(写权限门 → invoke → + * 宿主认领这一轮);它从按下发送那一刻就为真,与原生是否已经开始无关。接单化之后命令只 + * 等到接单就返回,所以它不等于"命令还没返回"。 * - `displayBusy`:header / composer 该读的忙态,就是两者的并集:只要有一条成立就不能再 * 接受新的发送。 * - `latestTurnState`:最新一轮在界面上的三态(投影结果);没有回合时为 null。 diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/controller/useDirectThreadChatSubscription.ts b/apps/ai-game-creator-shell/src/view/project-development/chat/controller/useDirectThreadChatSubscription.ts index 90757bdce..bd0af57f6 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/chat/controller/useDirectThreadChatSubscription.ts +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/controller/useDirectThreadChatSubscription.ts @@ -34,12 +34,12 @@ export type DirectThreadChatSubscription = { /** 聊天投影结果:历史顺序 + 运行态覆盖。 */ entries: DirectChatEntry[]; turnRunning: boolean; + /** 已收口回合数的单调计数:上层拿它当"回合完成"这个事实(见 reducer 的同名字段)。 */ + completedTurnCount: number; /** 订阅回执锚点闸门:首屏历史读取靠它拿到 `lastCompletedItemId`。 */ anchorGateRef: MutableRefObject; /** 历史切片并入同一个 reducer:条目只有这一份事实源。 */ mergeHistoryItems: (items: readonly DirectThreadItem[]) => void; - /** 终止成功(`released`)时手动放掉回合占用:订阅可能要等下一个事件才知道。 */ - markTurnStopped: () => void; }; /** @@ -178,21 +178,14 @@ export function useDirectThreadChatSubscription({ [], ); - const markTurnStopped = useMemo( - () => () => { - setState((current) => ({ ...current, turnRunning: false })); - }, - [], - ); - const entries = useMemo(() => selectDirectChatEntries(state), [state]); return { state, entries, turnRunning: state.turnRunning, + completedTurnCount: state.completedTurnCount, anchorGateRef, mergeHistoryItems, - markTurnStopped, }; } diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/conversation/directThreadChat.ts b/apps/ai-game-creator-shell/src/view/project-development/chat/conversation/directThreadChat.ts index 8ff0a7424..4b8250e28 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/chat/conversation/directThreadChat.ts +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/conversation/directThreadChat.ts @@ -73,6 +73,15 @@ export type DirectThreadChatState = { * 也不用时间戳近似。空串 = 原生没给身份(旧事件),此时不猜历史归属。 */ turnUserItemId: string; + /** + * 已经收口的回合数(单调递增,项目切换时随整份状态重置)。 + * + * 它是"回合完成"这个事实**唯一的计数**,给上层放行发送队列与结算埋点用。为什么要计数 + * 而不是看 `turnRunning` 的下降沿:一轮可能在**同一次 consume** 里开始并结束(接单后 + * 立刻失败),那时 `turnRunning` 从头到尾没有被观察到真,下降沿永远不会来。计数是状态, + * 批量到达也一样看得见。 + */ + completedTurnCount: number; /** 历史切片条目,保持文件顺序。 */ history: DirectChatEntry[]; /** 当前回合的运行态条目,保持到达顺序;回合结束即并入历史并清空。 */ @@ -85,6 +94,7 @@ export function emptyDirectThreadChatState(): DirectThreadChatState { turnStartedAt: 0, turnEndedAt: 0, turnUserItemId: '', + completedTurnCount: 0, history: [], live: [], }; @@ -301,12 +311,9 @@ export function reduceDirectThreadEvent( const eventAt = readDirectThreadEventAt(event); // 回合身份只认**事件流顺序**,不拿时间戳大小当身份:原生回合时间是秒级精度、 // 宿主收口时间可能带毫秒,"上一轮结束之后又来一条 turn.started"就是新回合, - // 哪怕它落在同一秒。同一轮内部的重复开始事件(真正重放)在流里表现为 - // "还在跑时又收到 turn.started",那种情况保留第一次的起点。 - const turnStartedAt = - state.turnRunning && state.turnStartedAt > 0 - ? state.turnStartedAt - : eventAt; + // 哪怕它落在同一秒。开始事件由 Thread Manager 在接单时发一次,线上不再有同一轮的 + // 重复起点,所以这里不做"保留第一次"的兼容。 + const turnStartedAt = eventAt; // 本轮的 canonical user identity 跟着事件走:新回合就换成新的;旧原生不带身份时 // 清空而不是继承上一轮,避免上一轮迟到的终态按身份匹配到这一轮。 const turnUserItemId = readDirectThreadEventUserItemId(event); @@ -420,6 +427,7 @@ export function finishDirectThreadTurn( turnRunning: false, turnStartedAt, turnEndedAt, + completedTurnCount: state.completedTurnCount + 1, history: mergeHistoryEntries(history, stamped), live: [], }; diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/conversation/directTurnPresentation.ts b/apps/ai-game-creator-shell/src/view/project-development/chat/conversation/directTurnPresentation.ts index bd898c5b6..433f9bd3e 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/chat/conversation/directTurnPresentation.ts +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/conversation/directTurnPresentation.ts @@ -52,7 +52,8 @@ export type DirectChatBlock = * (只由 `turn.started` / `turn.completed` 决定;`if (current)` 只赋给最后一条回合, * 所以「非最新一轮 + nativeRunning」不可达)。 * - `pendingUserItemId` ← controller 在 `beginTurnCommand` / `endTurnCommand` 之间维护, - * 生命周期与 `turnBusy` 一致;空串 = 没有在途的本地回合。 + * 生命周期与 `turnBusy` 一致:从按下发送到宿主那一轮的开始事件被认领(身份命中)为止, + * 空串 = 没有在途的本地回合。它**不是**"命令在飞":接单化之后命令只等到接单就返回了。 * - `turn.key` ← 开这一轮的条目身份:原生用户条目用 `entry.itemId`,本地乐观气泡用 * `message.messageId` —— 两者是**同一个** `direct-codex:{clientTurnId}:user`。 * - `stampedEnd` ← 本轮条目上盖的终态时间,只有 `turn.completed` / 终止收口才写。 @@ -70,8 +71,10 @@ export type DirectChatBlock = * `awaiting-start` 在"原生条目已回显、`turn.started` 未到"的次窗口里同样成立。 * * 两个容易读错的地方: - * - `pendingUserItemId` 有值 **≠** `awaiting-start`:`invoke` 直到整轮结束才返回,所以 - * `turn.started` 之后它仍在,但那时 `nativeRunning` 已经把它接成 `running`。 + * - `pendingUserItemId` 有值 **≠** `awaiting-start`:`turn.started` 之后它可能还在(同一批事件 + * 到达、或有别的回合收口在它前面),但那时 `nativeRunning` 已经把它接成 `running`。 + * - 命令返回 **≠** 这一轮结束了:`invoke` 只等到接单,宿主确认这一轮靠的是开始事件; + * 所以"命令还没回来"不再是任何展示态依据,只有身份与显式事件是。 * - `endedAt === 0` **≠** 还在跑:历史回合没有边界元数据(`turnEndedAt` 只是会话内展示 * 缓存),它们必须落 `finished`。 * diff --git a/apps/ai-game-creator-shell/tests/appSurface/chat-composer.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/chat-composer.suite.ts index de50995bb..e2283ab9e 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/chat-composer.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/chat-composer.suite.ts @@ -411,7 +411,7 @@ export function registerChatComposerControlTests() { await waitFor(() => { expect( invoke.mock.calls.filter( - ([command]) => command === 'settle_direct_run_analytics', + ([command]) => command === 'chat_with_game_creator_direct_codex', ), ).toHaveLength(1); }); @@ -420,6 +420,12 @@ export function registerChatComposerControlTests() { ); expect(attempts).toHaveLength(1); expect(refresh).not.toHaveBeenCalled(); + // 拒单没有接单、也就没有埋点候选:句柄直接丢掉,不产生一次注定被丢弃的结算。 + expect( + invoke.mock.calls.filter( + ([command]) => command === 'settle_direct_run_analytics', + ), + ).toHaveLength(0); // 认不出的拒单 / 非结构化错误仍走既有捕获链路:横幅给用户一句可读的话。 await waitFor(() => { expect( @@ -432,15 +438,12 @@ export function registerChatComposerControlTests() { }); it('queues messages sent while a turn runs, cancels one chip, and sends the rest in order', async () => { - const pending: Array<{ - resolve: (value: string) => void; - reject: (error: Error) => void; - }> = []; - const { invoke, surface } = await openDirectCodexSurface({ - chat_with_game_creator_direct_codex: () => - new Promise((resolve, reject) => { - pending.push({ resolve, reject }); - }), + const { invoke, surface, harness } = await openDirectCodexSurface({ + // 命令只接单(接单时 Thread Manager 已经发过开始事件),随后立刻返回。 + chat_with_game_creator_direct_codex: () => { + harness.emitDirectThreadEvents({ type: 'turn.started' }); + return Promise.resolve(null); + }, }); const composer = within(surface).getByLabelText('陶泥儿对话内容'); await submitDirectTurn(surface, composer, '第一条消息'); @@ -484,8 +487,12 @@ export function registerChatComposerControlTests() { expect(within(queue).getAllByRole('listitem')).toHaveLength(1); }); + // 队列放行听的是**回合终态**,不是命令返回:接单之后命令早就回来了。 act(() => { - pending[0]?.resolve('第一条回复'); + harness.emitDirectThreadEvents({ + type: 'turn.completed', + status: 'completed', + }); }); await waitFor(() => { expect(invoke).toHaveBeenCalledWith( @@ -500,7 +507,10 @@ export function registerChatComposerControlTests() { expect(sentTexts).toEqual(['第一条消息', '第三条消息']); act(() => { - pending[1]?.resolve('第三条回复'); + harness.emitDirectThreadEvents({ + type: 'turn.completed', + status: 'completed', + }); }); await waitFor(() => { expect(within(surface).queryByLabelText('待发送消息队列')).toBeNull(); diff --git a/apps/ai-game-creator-shell/tests/directThreadChat.test.ts b/apps/ai-game-creator-shell/tests/directThreadChat.test.ts index 54066f748..f26a33c6d 100644 --- a/apps/ai-game-creator-shell/tests/directThreadChat.test.ts +++ b/apps/ai-game-creator-shell/tests/directThreadChat.test.ts @@ -557,14 +557,30 @@ describe('DirectProject 聊天 reducer', () => { expect(next.turnEndedAt).toBe(0); }); - it('同一轮内的重复开始事件保留第一次的起点', () => { + it('不再为同一轮内的重复开始事件做兼容:起点就是最后一条开始事件', () => { + // 宿主在接单时**只发一次** `turn.started`(Thread Manager 的接单动作),线上不存在"同一轮 + // 里又来一条开始事件"。所以这里不再保留第一次的起点:真出现重复那是事件源的问题,reducer + // 按事件顺序照实收下,不替它编一个更早的起点。 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); + expect(started.turnStartedAt).toBe(1_000_500); + }); + + it('收口计数是状态:同一批里开始又结束也数得到,重复终态不重复计数', () => { + // 队列放行与埋点结算读这个计数,而不是 `turnRunning` 的下降沿——一轮可能在同一次 + // consume 里开始并结束(接单后立刻失败),那时下降沿永远不会出现。 + const batched = reduceDirectThreadEvents(emptyDirectThreadChatState(), [ + event({ type: 'turn.started', at: 1_000_000 }), + event({ type: 'turn.completed', status: 'failed', at: 1_000_400 }), + ]); + expect(batched.completedTurnCount).toBe(1); + const replayed = reduceDirectThreadEvents(batched, [ + event({ type: 'turn.completed', status: 'failed', at: 1_000_400 }), + ]); + expect(replayed.completedTurnCount).toBe(1); }); it('终态之后同身份的迟到条目补进历史,不挂到下一轮运行态', () => {