From 1ff1a9965b128f839810bb2fc2ed337c200a8734 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Thu, 24 Sep 2026 18:20:24 +0800 Subject: [PATCH] =?UTF-8?q?=E5=89=8D=E7=AB=AF=EF=BC=9A=E5=88=A0=E6=8E=89?= =?UTF-8?q?=20Direct=20=E7=9A=84=E6=9C=AC=E5=9C=B0=E4=B9=90=E8=A7=82?= =?UTF-8?q?=E7=94=A8=E6=88=B7=E6=B0=94=E6=B3=A1=EF=BC=8C=E7=94=A8=E6=88=B7?= =?UTF-8?q?=E6=B0=94=E6=B3=A1=E5=8F=AA=E6=9D=A5=E8=87=AA=E5=AE=BF=E4=B8=BB?= =?UTF-8?q?=E6=9D=A1=E7=9B=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - controller 删 `pendingUserItemId` / `beginTurnCommand` / `endTurnCommand`:忙态改由 `beginTurnBusy` / `endTurnBusy` 持有,宿主认领判据 = `turnRunning` 或收口计数变过(一轮在同一次 consume 里开始并结束) - controller 删 `startTurn` 的乐观追加与 `messageAppended` 重跑参数、`DirectProjectTurnInput.messageText` 与首轮的 `directInitialTurnText`;controller 不再需要 `assets` - 投影删 `awaiting-start` 展示态(只剩 `running` / `finished`)、`localSentTimes` / `sameIdentitySentAt`、本地用户气泡与它开回合的路径;带身份的拒单提示在会话末尾自成一组,不挂进上一轮,也不开运行态标记 - 时间口径:起点只认 `turn.started.at`、终点只认 `turn.completed.at`,用户气泡时钟取宿主落盘 / 观测时间;两边都空的回合整条「本轮结束于 … 」隐藏,不再出现 0.0 秒 - 用例:改造 `directTurnPresentation`(本地用户消息不进回合、拒单提示自成一组、两态判据、失败说明按身份归位)、`directProjectTurn` / `directProjectTurnStatus` / appSurface 窗口期用例,`directProjectTurn` 补 `afterEach(cleanup)` - 注释与文档:ADR「命令接单化」后续更新、实施计划新增「删掉本地乐观用户气泡」、decision-log 与 pitfalls 同日条目、Codex 原始历史方案的口径句、`codex_app_server` 用户条目时间注释 --- .../src/agent/codex_app_server/mod.rs | 3 +- .../chat/DirectProjectChatView.tsx | 18 +- .../DirectProjectTurn.tsx | 20 +- .../toolCallGroupPresentation.ts | 4 +- .../useDirectProjectChatController.ts | 162 +++++------- .../controller/useDirectProjectTurnStatus.ts | 8 +- .../conversation/directCodexConversation.ts | 11 +- .../chat/conversation/directThreadChat.ts | 12 +- .../conversation/directTurnPresentation.ts | 240 +++++------------- .../tests/appSurface/chat-composer.suite.ts | 24 +- .../tests/chatComposerAttachmentCap.test.tsx | 1 - .../tests/directProjectTurn.test.tsx | 20 +- .../tests/directProjectTurnStatus.test.ts | 11 +- .../tests/directTurnPresentation.test.ts | 142 +++++------ ...�ADR】DirectProject命令接单化-2026-09-23.md | 11 + .../shared-memory/decision-log.md | 10 + docs/project-memory/shared-memory/pitfalls.md | 7 + ...–½计划】DirectProject命令接单化-2026-09-23.md | 23 ++ ...ectProject Codex原始历史与异常恢复-2026-09-04.md | 2 +- 19 files changed, 301 insertions(+), 428 deletions(-) diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/mod.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/mod.rs index ef2064864..f2058531c 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/mod.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/mod.rs @@ -846,7 +846,8 @@ pub(crate) fn emit_direct_thread_user_item( item: &serde_json::Value, ) -> Option { let entry_item = direct_thread_event_item(root, item)?; - // 条目时间是落盘 / 观测时间;前端按同一个身份保留更早的真实发送时间,不用此时间覆盖它。 + // 条目时间是落盘 / 观测时间。前端乐观用户气泡已删(ADR「DirectProject命令接单化」后续更新), + // 所以这就是界面显示这条用户消息的唯一时间口径:它晚于用户按下发送,但不再有第二份更早的时间。 let at = entry_item.at(); append_direct_thread_event( &direct_thread_id_for_project(root), diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/DirectProjectChatView.tsx b/apps/ai-game-creator-shell/src/view/project-development/chat/DirectProjectChatView.tsx index 2787dab0c..f2c2a1aa9 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/chat/DirectProjectChatView.tsx +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/DirectProjectChatView.tsx @@ -92,15 +92,6 @@ export type DirectProjectChatViewProps = { ref?: Ref; }; -/** 首轮那条本地用户消息展示入口原话:附件与引用仍按 canonical content 进入回合。 */ -function directInitialTurnText(content: readonly DirectCodexUserContentPart[]) { - return content - .filter((part) => part.type === 'input_text') - .map((part) => part.text) - .join('') - .trim(); -} - export function DirectProjectChatView({ onRequestGamePublish, onConfirmConfirmation, @@ -122,7 +113,6 @@ export function DirectProjectChatView({ const [approvalMode, setApprovalMode] = useState('strict'); const [approvalNotice, setApprovalNotice] = useState(''); const chat = useDirectProjectChatController({ - assets, enabled: Boolean(projectPath), ensureConversationReadAllowed, ensureConversationWriteAllowed, @@ -143,7 +133,6 @@ export function DirectProjectChatView({ historyHasMore, loadEarlierHistory, localMessages, - pendingUserItemId, queuedTurns, startInitialTurn, statusNotice, @@ -161,11 +150,10 @@ export function DirectProjectChatView({ entries: directEntries, localMessages, turnRunning: directTurnRunning, - pendingUserItemId, }), - [directEntries, localMessages, directTurnRunning, pendingUserItemId], + [directEntries, localMessages, directTurnRunning], ); - // 「这一轮在跑吗」只从这一个派生入口读:原生真相 / 本地命令在飞 / 最新一轮三态。 + // 「这一轮在跑吗」只从这一个派生入口读:原生真相 / 本地命令在飞 / 最新一轮两态。 const turnStatus = useDirectProjectTurnStatus({ turnRunning: directTurnRunning, turnBusy, @@ -198,8 +186,6 @@ export function DirectProjectChatView({ ); shouldFollowLatestRef.current = true; startInitialTurn({ - // 首轮那条用户消息按入口原话展示,引用/附件仍按 canonical content 发给运行时。 - messageText: directInitialTurnText(initialTurn.content), clientTurnId, ...(initialTurn.creationType ? { creationType: initialTurn.creationType } diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectConversation/DirectProjectTurn.tsx b/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectConversation/DirectProjectTurn.tsx index b3d0df6c6..8ac6cb5f9 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectConversation/DirectProjectTurn.tsx +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectConversation/DirectProjectTurn.tsx @@ -20,15 +20,15 @@ import { /** * 一个完整回合的分区表现:用户发言、执行过程(工具/思考)与最终答复。 * - * 未结束的回合(`running` / `awaiting-start`)把执行过程平铺出来并隐藏终态文案, + * 未结束的回合(`running`)把执行过程平铺出来并隐藏终态文案, * `finished` 才折叠进「执行过程」;这一层只做投影到表现的渲染,不拥有任何回合状态。 * - * 三态的判据分两类,不要对调(三态定义与真值表见 + * 两态的判据分两类,不要对调(两态定义与真值表见 * `../../conversation/directTurnPresentation.ts` 的 `DirectChatTurnState`): * - **否定式**(不要说它结束、不要折叠、不要显示终态文案)读 `state !== 'finished'`: - * `awaiting-start` 时轮次确实还没结束,只是宿主还没确认。 + * `running` 时轮次确实还没结束。 * - **肯定式**(哪一段正文在流式、"正在处理"这类断言)读 `state === 'running'`: - * `awaiting-start` 只说明本地已发出,不能据此断言宿主已经在跑。 + * 只有宿主开始事件到了才能这么说。 */ export function DirectProjectTurn({ turn }: { turn: DirectChatTurn }) { const streamingKey = @@ -66,13 +66,11 @@ function renderTurnProcess(turn: DirectChatTurn, streamingKey: string | null) { } function DirectProjectTurnUsage({ turn }: { turn: DirectChatTurn }) { - // 否定式判据:未结束的回合不显示终态文案。`awaiting-start` 走这一条,所以"本地已发出、 - // 原生还没认领"的窗口里不会再出现「本轮结束于 … 耗时 0.0秒」。 - // 仍未修的另一半(A):`finished` 但没有可证明终态时间的回合,会被下面的 `Math.max` 兜底 - // 量化成 0.0 秒,共两类——① 重进项目后读回来的历史回合(`turnEndedAt` 只活在本次会话里, - // 不会随 `project.jsonl` 持久化);② 本地已发出却一个原生事件都没产生的回合(发送失败)。 - // 修法是只在 `turn.endedAt > 0` 时渲染终态文案、耗时改由 `turnTotalDurationMs()` 出(边界缺失 - // 就隐藏),属于产品口径变化(宁可隐藏也不编),确认后单独改;改完把这半段注释删掉。 + // 否定式判据:未结束的回合不显示终态文案(`running` 走这一条)。 + // `finished` 但没有可证明起点 / 终点的回合整条隐藏:重进项目后读回来的历史回合就是这样 + // (`turnStartedAt` / `turnEndedAt` 只活在本次会话里,不随 `project.jsonl` 持久化)。 + // `Math.max` 只剩兜底时钟回拨的作用——本地乐观气泡已删,不再有"本地已发出却一个事件都没有" + // 的回合(发送失败的用户消息也不会进聊天区)。 if (turn.state !== 'finished' || !turn.startedAt) return null; const endedAt = Math.max(turn.endedAt, turn.startedAt); return ( diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/components/ToolCallGroup/toolCallGroupPresentation.ts b/apps/ai-game-creator-shell/src/view/project-development/chat/components/ToolCallGroup/toolCallGroupPresentation.ts index 7c4788351..ab3baa8c8 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/chat/components/ToolCallGroup/toolCallGroupPresentation.ts +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/components/ToolCallGroup/toolCallGroupPresentation.ts @@ -214,7 +214,7 @@ export function formatClockTime( /** * 整轮耗时(毫秒)= 本轮起点 → 本轮终态。 * - * 起点是该轮实际用户消息的发送时间,缺失时用原生 `turn.started.at`;终点是明确的 + * 起点是原生 `turn.started.at`(本轮唯一可证明的起点);终点是明确的 * `turn.completed.at`,运行中则是当前时刻(回合还在跑就持续增长,即使组内工具都结束了)。 * 两端任一缺失、非有限或倒序都返回 `null`:不伪造 `0.0秒`。 */ @@ -224,7 +224,7 @@ export function turnTotalDurationMs({ running = false, now = 0, }: { - /** 本轮起点:用户实际发送时间优先,缺失时原生 `turn.started.at`;0 = 未知。 */ + /** 本轮起点:原生 `turn.started.at`;0 = 未知(历史回合就是这一类,调用方须整条隐藏)。 */ startedAt: number | null | undefined; /** 本轮明确终态时间(`turn.completed.at`);运行中忽略。 */ endedAt?: number | null; 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 5feb91e9f..839a9f50d 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 @@ -13,10 +13,8 @@ import type { import { projectRuntimeVisibleError } from '../../../../features/agent-runtime'; import { uploadLocalFilesAsAttachments } from '../../../../features/app-shell/useHomeProjectCreation'; import { - directCodexContentToPromptText, directCodexUserItemFromContent, hasMeaningfulDirectCodexContent, - resourceLabelResolver, } from '../../../../features/project-workspace/resourceReferences'; import { beginDirectRunAnalytics } from '../../../../services/clientAnalytics'; import { captureAgentRuntimeError } from '../../../../services/errorReporting'; @@ -56,9 +54,6 @@ import { directHistoryAnchorGateToWaitFor } from '../history/directHistoryAnchor import { readDirectHistoryPages } from '../history/directHistoryPaging'; import { useDirectThreadChatSubscription } from './useDirectThreadChatSubscription'; -type AssetManifestEntry = - import('../../../../../../../packages/shared/src/contracts/gameCreationApp').GameCreationAppAssetManifestEntry; - export const MAX_CHAT_COMPOSER_ATTACHMENTS = 8; export const DIRECT_HISTORY_PAGE_SIZE = CONVERSATION_VISIBLE_STEP; @@ -81,8 +76,6 @@ export type DirectProjectConversationWriteGate = (input: { }) => Promise; export type DirectProjectChatControllerProps = { - /** 当前项目 manifest 的素材:`@` 引用显示名与 canonical 文案都按它展开。 */ - assets: AssetManifestEntry[]; enabled: boolean; ensureConversationReadAllowed: DirectProjectConversationReadGate; ensureConversationWriteAllowed: DirectProjectConversationWriteGate; @@ -110,12 +103,12 @@ export type DirectProjectChatControllerProps = { * 传输层(三条通道,前端各拉各的) * A 运行态:notify → invoke consume_direct_project_thread → events[](实时) * B 历史: invoke read_direct_project_history_slice → items[](分页,文件尾反向扫描) - * C 本地: 前端自己造(乐观用户气泡、忙态、终止说明) + * C 本地: 前端自己造(忙态、拒单提示、终止说明)——**不造用户消息** * ▼ * 前端 * useDirectThreadChatSubscription reducer:A + B 进同一份 state(turnRunning / history / live) * ▼ - * useDirectProjectChatController 本地状态:localMessages / turnBusy / pendingUserItemId / 队列 + * useDirectProjectChatController 本地状态:localMessages / turnBusy / 队列 * ▼ * DirectProjectChatView turns = buildDirectChatTurns(...);status = useDirectProjectTurnStatus(...) * ▼ @@ -130,33 +123,34 @@ export type DirectProjectChatControllerProps = { * `lifecycle_anchor` 保住最新一条生命周期事件。 * 失败也走这条流:`turn.completed.failure` 自己带脱敏后的原因,reducer 把它落成本轮说明条目; * 命令返回那条通道只提供横幅与诊断,不再写聊天文案。 - * - 本地发送:只存在于本次会话,`projectPath` 变化即清空;乐观气泡与原生条目同身份 - * (`direct-codex:{clientTurnId}:user`),所以两边按**身份**合并,不按时间戳猜。 + * - 本地说明:只存在于本次会话,`projectPath` 变化即清空;只有壳层 `announce` 与拒单提示两种。 + * 用户消息一律来自宿主条目——本地乐观气泡已删(见 ADR「DirectProject命令接单化」的后续更新), + * 所以"用户那句话说没说出去"只有宿主条目一个来源。 * - * 一次发送的时序(第 2 → 3 步之间就是「本地已发出、宿主还没确认」的空窗): - * 1. 按下发送:`localMessages += 乐观气泡`、`turnBusy=true`、`pendingUserItemId=本轮身份`(同帧)。 + * 一次发送的时序(第 2 → 3 步之间就是「本地已发出、宿主还没确认」的空窗:聊天区里没有这一轮的 + * 任何条目,只有 composer 忙态与状态行): + * 1. 按下发送:`turnBusy=true`(同帧)。聊天区不动——这一轮在宿主认领之前不存在。 * 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`;本地气泡此时被去重。 + * 4. `item.completed`(本轮用户条目下发):同身份条目已在历史里就合并进去,否则进 `live`。这是这一轮 + * 的用户气泡**第一次**出现在聊天区(发点在接单之后、起 codex 之前)。 * 5. `item.delta` / `item.started` / `item.completed`:正文追加、工具卡片 upsert(先到定形、后到只补空)。 * 6. `turn.completed`:`live` 并入 `history` 后清空,`turnEndedAt` 冻结,边界按身份盖到本轮开口条目上, * 收口计数 +1;带 `failure` 载荷时,说明条目已经在上一步由 reducer 落进 `live`,随本轮一起并入历史。 * 7. **回合终态**(第 6 步的收口计数变化):结算本轮埋点 → 放行发送队列,顺序固定在这一处。 - * 8. 命令收尾(`finally`):刷新清单;只在**没接单**时放掉忙态与在途身份并出队(权限被拒那种 + * 8. 命令收尾(`finally`):刷新清单;只在**没接单**时放掉忙态并出队(权限被拒那种 * 「本轮从未发出但要继续出队」的路径也在这里收口),接单成立的那一轮交给第 3 / 7 步。 * * 状态变量归属:reducer 的回合字段、收口计数与 `history` / `live` 只由 `directThreadChat.ts` 写; - * 本文件的 `turnBusy` / `pendingUserItemId`(同生共死,唯一入口 `beginTurnCommand` / - * `endTurnCommand`)、`localMessages`、发送队列、埋点句柄与分页 ref 只服务发送与展示;界面上的 - * 「这一轮在跑吗」只有一个派生入口 `useDirectProjectTurnStatus()`,三态判据与真值表在 + * 本文件的 `turnBusy`(唯一入口 `beginTurnBusy` / `endTurnBusy`)、`localMessages`、发送队列、 + * 埋点句柄与分页 ref 只服务发送与展示;界面上的 + * 「这一轮在跑吗」只有一个派生入口 `useDirectProjectTurnStatus()`,两态判据与真值表在 * `../conversation/directTurnPresentation.ts` 的 `DirectChatTurnState`,渲染时否定式读 * `state !== 'finished'`、肯定式读 `state === 'running'`(见 `DirectProjectTurn.tsx`)。 */ export function useDirectProjectChatController({ - assets, enabled, ensureConversationReadAllowed, ensureConversationWriteAllowed, @@ -184,14 +178,6 @@ export function useDirectProjectChatController({ const [statusNotice, setStatusNotice] = useState(''); const [turnCancelling, setTurnCancelling] = useState(false); const [turnBusy, setTurnBusy] = useState(false); - /** - * 本地已发出、宿主还没认领的那一轮用户条目身份:只服务投影的 `awaiting-start` 展示态。 - * - * 生命周期与 `turnBusy` 一致(同生共死),但**不是**"命令在飞":接单化之后命令只等到接单, - * 所以它从按下发送一直活到宿主那一轮的开始事件被 reducer 认领(身份命中)。这一段必须仍在 - * "在途",否则命令返回与开始事件到达之间会出现一个可发送的空窗。 - */ - const [pendingUserItemId, setPendingUserItemId] = useState(''); const [localMessages, setLocalMessages] = useState([]); // 订阅(subscribe/consume/notify)与聊天 reducer 状态在自己的 hook 里: // controller 只读投影后的条目与回合忙态,不再直接持有线程状态。 @@ -202,7 +188,6 @@ 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); @@ -215,11 +200,15 @@ export function useDirectProjectChatController({ turnBusyRef.current = turnBusy; /** 已经处理过的收口回合数:与 reducer 的计数比较,识别"又有回合结束了"。 */ const handledCompletedTurnCountRef = useRef(0); - /** 起这一轮时的收口计数:宿主没给身份时只能靠"计数变过"认领这一轮(见下面的 effect)。 */ - const pendingTurnBaselineRef = useRef(0); + /** + * 起这一轮时的收口计数:宿主没给开始事件时只能靠"计数变过"认领这一轮(见下面的 effect)—— + * 一轮可能在同一次 consume 里开始并结束,那时 `turnRunning` 的上升沿永远不会被观察到。 + */ + const busyBaselineTurnCountRef = useRef(0); + /** 最近一次渲染时的收口计数:权限确认后的重跑是异步续跑,闭包里的值可能过期。 */ const completedTurnCountRef = useRef(completedTurnCount); completedTurnCountRef.current = completedTurnCount; - /** 有回合结束了、但还不能出队(本地在途标签还没退场)时挂起,等忙态放掉再出队。 */ + /** 有回合结束了、但还不能出队(本地忙态还没放掉)时挂起,等忙态放掉再出队。 */ const completionPendingRef = useRef(false); /** * 本轮的埋点句柄。它在命令返回之后仍然要活着:成绩是**回合末**才在宿主侧入账的, @@ -237,7 +226,7 @@ export function useDirectProjectChatController({ setQueuedTurns([]); queuedTurnsRef.current = []; setLocalMessages([]); - setPendingUserItemId(''); + busyBaselineTurnCountRef.current = 0; setHistoryHasMore(false); historyOldestItemIdRef.current = null; handledCompletedTurnCountRef.current = 0; @@ -255,6 +244,26 @@ export function useDirectProjectChatController({ return () => window.clearInterval(timer); }, [enabled, turnBusy, currentTurnRunning]); + /** + * 宿主认领了这一轮:本地忙态交给原生真相。 + * + * 判据是**事件流里的回合边界**而不是命令的返回值:命令先返回、开始事件后到,中间那一段必须仍算 + * "命令在飞"——提前放掉,composer 会在两个回合之间开出一个能并发发送的空窗。 + * + * 两条判据任一条成立都算认领(后一条是兜底):开始事件已经落进 reducer(`turnRunning`), + * 或收口计数变过(这一轮在同一次 consume 里开始又结束,`turnRunning` 的上升沿观察不到)。 + */ + useEffect(() => { + if (!turnBusyRef.current) return; + if ( + currentTurnRunning || + completedTurnCount > busyBaselineTurnCountRef.current + ) { + endTurnBusy(); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [turnBusy, currentTurnRunning, completedTurnCount]); + /** * 回合终态是队列放行与埋点结算的唯一出口(接单被拒走命令那条路,见 `startTurn` 的收尾)。 * @@ -283,38 +292,10 @@ export function useDirectProjectChatController({ completionPendingRef.current = false; dispatchNextQueuedTurn(); } - // 出队只由"回合收口次数 + 本地在途状态"驱动,队列本身不是依赖。 + // 出队只由"回合收口次数 + 本地忙态"驱动,队列本身不是依赖。 // eslint-disable-next-line react-hooks/exhaustive-deps }, [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; let disposed = false; @@ -336,22 +317,21 @@ export function useDirectProjectChatController({ }, [enabled, projectPath]); /** - * 「本地这一轮在途」的唯一起止点:按下发送时带上本轮用户条目身份,宿主认领这一轮(开始 - * 事件的身份命中)或这一轮明确没成立时一起清掉。 + * 「本地命令在飞」的唯一起止点:按下发送时置上,宿主认领这一轮(`turn.started` 落进 reducer) + * 或这一轮明确没成立时放掉。 * - * 忙态与待认领身份必须同生共死,否则投影会拿一个过期的身份去判 `awaiting-start`。 + * 它与原生忙态是两件事,所以**不在这里**按回合身份收口:接单化之后命令只等到接单就返回, + * 「宿主认领了吗」由 reducer 的 `turnRunning` 回答(`displayBusy` 是两者的并集)。 */ - function beginTurnCommand(userItemId: string) { + function beginTurnBusy() { turnBusyRef.current = true; setTurnBusy(true); - setPendingUserItemId(userItemId); - pendingTurnBaselineRef.current = completedTurnCountRef.current; + busyBaselineTurnCountRef.current = completedTurnCountRef.current; } - function endTurnCommand() { + function endTurnBusy() { turnBusyRef.current = false; setTurnBusy(false); - setPendingUserItemId(''); } /** @@ -533,20 +513,19 @@ export function useDirectProjectChatController({ } /** - * 发起一轮 DirectProject 回合:写权限门 + invoke + 本地消息与错误收尾。 + * 发起一轮 DirectProject 回合:写权限门 + invoke + 本地忙态与错误收尾。 * - * 权限确认后重跑的是同一份输入,所以重跑只跳过权限检查,不重复乐观消息。 + * 这里**不往聊天里写用户消息**:这一轮的用户气泡只来自宿主条目,所以"接单窗口期聊天区没有这一 + * 轮"是正常现象(忙态与状态行负责告知)。权限确认后重跑的是同一份输入,只跳过权限检查。 */ - function startTurn( - input: DirectProjectTurnInput, - options: { messageAppended?: boolean } = {}, - ) { + function startTurn(input: DirectProjectTurnInput) { const nextProjectPath = projectPath; if (!nextProjectPath || !projectId) { const message = resolveTauriInvoke() ? '当前项目尚未准备好,无法启动智能创作。' : '需要在 Tauri App 内运行,无法启动智能创作。'; onRuntimeError(message); + // 这一轮从没发出去,聊天区里也不会有它的用户条目:说明只能自己开一组(带身份的本地说明)。 appendLocalMessage({ role: 'assistant', text: message, @@ -559,28 +538,12 @@ export function useDirectProjectChatController({ }); return; } - if (!options.messageAppended) { - appendLocalMessage({ - role: 'user', - text: - input.messageText ?? - directCodexContentToPromptText( - input.userItem.content, - resourceLabelResolver(assets), - ), - runtimeOwned: true, - messageId: directCodexConversationMessageId(input.clientTurnId, 'user'), - updatedAt: Date.now(), - }); - } - beginTurnCommand( - directCodexConversationMessageId(input.clientTurnId, 'user'), - ); + beginTurnBusy(); void (async () => { let invoked = false; // 命令是否接了单。只有它为真时,这一轮的收尾才交给宿主的事件。 let turnAccepted = false; - // 权限被拒也要继续出队(见下),但出队必须发生在 finally 的 endTurnCommand() 之后: + // 权限被拒也要继续出队(见下),但出队必须发生在 finally 的 endTurnBusy() 之后: // 在这里出队的话,下一轮刚设上的忙态会被紧接着的 finally 清掉。 let queueAdvance = false; try { @@ -589,9 +552,7 @@ export function useDirectProjectChatController({ const allowed = await ensureConversationWriteAllowed({ projectPath: nextProjectPath, onConfirmed: () => { - startTurn(directCodexPolicyRetryInput(input), { - messageAppended: true, - }); + startTurn(directCodexPolicyRetryInput(input)); }, }); if (!allowed) { @@ -624,8 +585,8 @@ export function useDirectProjectChatController({ // 时出队与结算,见上面两个 effect);没成立的那些路径没有任何事件会来,只能在这里收口。 if (!turnAccepted) { // 忙态必须早于出队放掉:出队会同步开始下一轮并设上它自己的忙态,清在它后面就等于 - // 把下一轮的忙态抹掉(composer 会以为可以并发发送,下一轮的三态也会掉回 finished)。 - endTurnCommand(); + // 把下一轮的忙态抹掉(composer 会以为可以并发发送)。 + endTurnBusy(); // 出队条件:权限被拒的一轮从未发出,不受项目切换影响,照旧出队;命令真发出过又返回 // 拒单时要求项目没被换掉;没有 invoke(非 Tauri 环境)时不出队,避免空转。 if ( @@ -766,7 +727,7 @@ export function useDirectProjectChatController({ if (result?.outcome === 'released') { // 本地只放掉"命令在飞"这一层;这一轮的**回合边界**不在这里收口——宿主的兜底终止 // 已经把终态写进事件流,界面等那条 `turn.completed` 自己落到 reducer 上。 - endTurnCommand(); + endTurnBusy(); onRuntimeError(''); setComposerNotice(message ?? '已结束这一轮占用,可以直接重新发送消息'); } else if (message) { @@ -896,7 +857,6 @@ export function useDirectProjectChatController({ historyHasMore, loadEarlierHistory, localMessages, - pendingUserItemId, queuedTurns, startInitialTurn: startTurn, statusNotice, 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 a57a19a89..0fcc749c8 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,15 +14,15 @@ import type { * * - `nativeRunning`:**原生真相**。只由订阅 reducer 的 `turnRunning` 给出(`turn.started` * 已到、`turn.completed` 未到)。它决定"陶泥儿正在处理"这类原生过程提示。 - * - `commandInFlight`:**本地真相**。本次会话是否有一条本地在途回合(写权限门 → invoke → + * - `commandInFlight`:**本地真相**。本次会话是否有一条本地在飞的回合(写权限门 → invoke → * 宿主认领这一轮);它从按下发送那一刻就为真,与原生是否已经开始无关。接单化之后命令只 - * 等到接单就返回,所以它不等于"命令还没返回"。 + * 等到接单就返回,所以它不等于"命令还没返回"——它活到宿主那一轮的开始事件被观察到为止。 * - `displayBusy`:header / composer 该读的忙态,就是两者的并集:只要有一条成立就不能再 * 接受新的发送。 - * - `latestTurnState`:最新一轮在界面上的三态(投影结果);没有回合时为 null。 + * - `latestTurnState`:最新一轮在界面上的两态(投影结果);没有回合时为 null。 * * 约定:新增"忙/在跑"类判据一律先落进这里,不要在组件里再拼布尔。 - * `latestTurnState` 三态各自的含义、判据输入与真值表写在 + * `latestTurnState` 两态各自的含义、判据输入与真值表写在 * `../conversation/directTurnPresentation.ts` 的 `DirectChatTurnState`。 * 数据流、变量归属与一次发送的时序见 `useDirectProjectChatController.ts` 的模块注释。 */ diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/conversation/directCodexConversation.ts b/apps/ai-game-creator-shell/src/view/project-development/chat/conversation/directCodexConversation.ts index 10c347789..5547facbf 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/chat/conversation/directCodexConversation.ts +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/conversation/directCodexConversation.ts @@ -21,8 +21,6 @@ export type DirectProjectTurnInput = { * content 里内联,附件不再作为并排字段单独传递。 */ userItem: DirectCodexUserItem; - /** 界面展示的这段话:默认按 canonical content 展开,首页首轮需求按用户原话展示。 */ - messageText?: string; /** 本轮已经通过项目写权限检查:确认后重跑时不再二次确认。 */ directPolicyChecked?: boolean; }; @@ -124,11 +122,12 @@ export function directTurnRejectionNoticeMessageId(userItemId: string) { } /** - * **认不出的**拒单(宿主 / 环境事实)在聊天里与用户消息同级的提示文案。 + * **认不出的**拒单(宿主 / 环境事实)在聊天区末尾自成一组提示的文案。 * - * 这两类拒单不产生 `turn.completed`(拒单没有接单),所以聊天里那条乐观用户气泡后面不会再有任何 - * 事件来解释它——说明只能在这里补一条,否则用户只看得到一条会消失的横幅。上报与横幅照旧保留: - * 两件事不是同一份(一个是给用户看的话,一个是把现场送进上报池与 `.agent/runtime/errors`)。 + * 这两类拒单不产生 `turn.completed`(拒单没有接单),而本地也不再造用户气泡,所以这一轮在聊天区里 + * 本来什么都不剩——说明只能由命令边界补一条,否则用户只看得到一条会消失的横幅。它带自己的身份 + * (`…:rejected`),投影据此自成一组,不挂进上一轮。上报与横幅照旧保留:两件事不是同一份 + * (一个是给用户看的话,一个是把现场送进上报池与 `.agent/runtime/errors`)。 * * 文案不能原样用宿主给的 `message`:这类拒单的 `message` 是宿主的收口文案(带 `stage=` / `code=` * 这类机器字段),先过与失败说明同一份可见文案映射再进聊天;映射认不出形状时给一句通用兜底, 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 e7061824e..199a335bb 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 @@ -69,12 +69,12 @@ export type DirectThreadChatState = { * 最新**原生**回合是否还在跑;只由生命周期事件(`turn.started` / `turn.completed`) * 的先后决定。 * - * 它不等于界面上的「这一轮在跑吗」:本地已发出、宿主还没回 `turn.started` 的那一段 - * 窗口里它为假,但那一轮在界面上是"待认领"而不是"已结束"。界面侧的三态与判据见 - * `directTurnPresentation.ts` 的 `DirectChatTurnState`。 + * 它不等于界面上的「这一轮在跑吗」:本地已发出、宿主还没回 `turn.started` 的那一段窗口里它为假, + * 那时聊天区里没有这一轮的任何条目(本地不再造乐观气泡),忙态由 controller 的 `turnBusy` 出。 + * 界面侧的两态与判据见 `directTurnPresentation.ts` 的 `DirectChatTurnState`。 */ turnRunning: boolean; - /** 原生 `turn.started.at`:本轮用户实际发送时间缺失时的起点兜底;0 = 缺失。 */ + /** 原生 `turn.started.at`:本轮的起点(运行中读它、收口后由 `turnEndedAt` 一起盖到条目上);0 = 缺失。 */ turnStartedAt: number; /** 本轮明确终态时间;只写一次,0 = 还没有可证明的终态时间。 */ turnEndedAt: number; @@ -441,8 +441,8 @@ export function reduceDirectThreadEvent( * `endedAt` 只接受明确的终态时间:`turn.completed.at`(正常与失败同源),或宿主终止收口时 * 观测到的时刻。 * 缺失就是缺失,宁可不显示总耗时,也不用最后一条工具 / 正文的时间顶替。 - * 已经冻结的终态时间不会被后来的调用抬高;开始时间只记原生值,用户实际发送时间的优先级 - * 由投影层决定(条目上的 `at` 才是气泡时间)。 + * 已经冻结的终态时间不会被后来的调用抬高;开始时间只记原生值——本地不再有"用户实际发送时间" + * 这一份(乐观气泡已删),投影层的起点就是这里的 `turnStartedAt`。 */ export function finishDirectThreadTurn( state: DirectThreadChatState, 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 924a96c4e..1ab3d433e 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 @@ -2,7 +2,11 @@ * DirectProject 聊天呈现:把聊天条目切成"用户气泡 / 过程 / 最终回复"三段。 * * 输入是唯一一份聊天条目(历史切片 + 运行态事件归并的结果),顺序就是条目顺序; - * 这里只做分区与合并(连续工具合成一块),不认回合身份,也不再从文本长度 / 标点猜切点。 + * 这里只做分组与合并(连续工具合成一块),不再从文本长度 / 标点猜切点。 + * + * **回合归属只认身份**:开口用户条目的 canonical `itemId`(`direct-codex:{clientTurnId}:user`) + * 就是这一轮的回合身份,本轮用户条目与失败说明按它归进同一轮。本地只保留"说明"类消息 + * (拒单提示、壳层 `announce`),它们不是回合条目,也不参与回合身份。 */ import type { ChatMessage } from '../../../../app/types'; @@ -37,72 +41,61 @@ export type DirectChatBlock = | { kind: 'tools'; key: string; calls: DirectChatToolCard[] }; /** - * 界面上一轮的三态。它是**展示态**,不是第二套回合生命周期。 + * 界面上一轮的两态。它是**展示态**,不是第二套回合生命周期。 * - * 三态各自能断言什么(渲染时按这个分两类,不要对调): + * 两态各自能断言什么(渲染时按这个分两类,不要对调): * - `running`:**宿主已确认这一轮开始了**(订阅流里出现过 `turn.started`、还没出现 * `turn.completed`)。它是唯一能做肯定式断言的态。 - * - `awaiting-start`:**本地已把这轮交出去、宿主还没确认**(乐观气泡已出现,`turn.started` - * 未到)。只支持否定式断言:"它还没结束",不能说"它正在跑"。 - * - `finished`:其余全部 —— 拿到终态的、身份不匹配的、不是最新一轮的,以及**拿不到边界的 - * 历史回合**(这类最容易被误判成"还在跑",必须落在这一态)。 + * - `finished`:其余全部 —— 拿到终态的,以及**拿不到边界的历史回合**(这类最容易被误判成 + * "还在跑",必须落在这一态)。 * - * 判据用四个输入(下方 `buildDirectChatTurns` 里那几句 if 就是全部实现): + * 判据只有一个输入(下方 `buildDirectChatTurns` 里那几句 if 就是全部实现): * - `turn.nativeRunning` ← 入参 `turnRunning` ← reducer 的 `state.turnRunning` * (只由 `turn.started` / `turn.completed` 决定;`if (current)` 只赋给最后一条回合, * 所以「非最新一轮 + nativeRunning」不可达)。 - * - `pendingUserItemId` ← controller 在 `beginTurnCommand` / `endTurnCommand` 之间维护, - * 生命周期与 `turnBusy` 一致:从按下发送到宿主那一轮的开始事件被认领(身份命中)为止, - * 空串 = 没有在途的本地回合。它**不是**"命令在飞":接单化之后命令只等到接单就返回了。 - * - `turn.key` ← 开这一轮的条目身份:原生用户条目用 `entry.itemId`,本地乐观气泡用 - * `message.messageId` —— 两者是**同一个** `direct-codex:{clientTurnId}:user`。 - * - `stampedEnd` ← 本轮条目上盖的终态时间,只有 `turn.completed` / 终止收口才写。 * * 真值表: * - * | nativeRunning | 最新一轮 && pendingUserItemId 身份命中 | stampedEnd > 0 | → state | - * | true | — | — | running | - * | false | false | 任意 | finished | - * | false | true | true | finished | - * | false | true | false | awaiting-start | + * | nativeRunning | → state | + * | true | running | + * | false | finished | * - * 判据一律用**身份与显式事件**,不用时间戳大小:原生阶段时间是秒级精度、同一秒里可能连开 - * 两轮,回显条目的 `at` 还是宿主 ack 的观测时间(晚于用户真实发送)。这也是为什么 - * `awaiting-start` 在"原生条目已回显、`turn.started` 未到"的次窗口里同样成立。 + * 判据一律用**显式事件**,不用时间戳大小:原生阶段时间是秒级精度、同一秒里可能连开两轮, + * 回显条目的 `at` 还是宿主落盘 / 观测时间(晚于用户真实发送)。 + * + * 这里曾经有过第三个态 `awaiting-start`(本地已发出、宿主还没认领):它只服务本地乐观用户气泡。 + * 气泡已按"回合只认宿主条目"删除,所以"接单窗口"不再是一种展示态——窗口期聊天区里没有这一轮的 + * 任何条目,只有 composer 的忙态与状态行(见 ADR「DirectProject命令接单化」的后续更新)。 * * 两个容易读错的地方: - * - `pendingUserItemId` 有值 **≠** `awaiting-start`:`turn.started` 之后它可能还在(同一批事件 - * 到达、或有别的回合收口在它前面),但那时 `nativeRunning` 已经把它接成 `running`。 * - 命令返回 **≠** 这一轮结束了:`invoke` 只等到接单,宿主确认这一轮靠的是开始事件; - * 所以"命令还没回来"不再是任何展示态依据,只有身份与显式事件是。 + * 所以"命令还没回来"不是任何展示态依据,只有显式事件是。 * - `endedAt === 0` **≠** 还在跑:历史回合没有边界元数据(`turnEndedAt` 只是会话内展示 * 缓存),它们必须落 `finished`。 * - * 三态在渲染上的映射(否定式 / 肯定式)见 `DirectProjectTurn.tsx` 顶部注释;数据流、变量归属与 + * 两态在渲染上的映射(否定式 / 肯定式)见 `DirectProjectTurn.tsx` 顶部注释;数据流、变量归属与 * 一次发送的时序见 `../controller/useDirectProjectChatController.ts` 的模块注释。 * * 已知边界(改 `finished` 判据时要连着一起看):`finished` 只断言"不再有理由认为它在跑", - * **不断言"拿得到终态时间"**。有两类回合没有可证明的边界时间,只被 - * `Math.max(turn.endedAt, turn.startedAt)` 兜底量化成 0.0 秒——① 重进项目后读回来的历史回合 - * (`turnEndedAt` 只是会话内展示缓存,不随 `project.jsonl` 持久化);② 本地已发出却一个原生事件 - * 都没产生的回合(发送失败、`turn.started` 没来)。要不要把这两类的终态文案藏掉是产品口径问题 - * (宁可隐藏也不编),需要单独确认后单独改,不要顺手塞进三态判据。 + * **不断言"拿得到终态时间"**。重进项目后读回来的历史回合没有边界元数据(`turnEndedAt` 只是会话内 + * 展示缓存,不随 `project.jsonl` 持久化),`startedAt` / `endedAt` 都是 0,本轮终态文案整条隐藏 + * (判据见 `DirectProjectTurn.tsx` 的 `turn.startedAt` 那一句)。 */ -export type DirectChatTurnState = 'running' | 'awaiting-start' | 'finished'; +export type DirectChatTurnState = 'running' | 'finished'; export type DirectChatTurn = { key: string; - /** 用户气泡:顺序即发出顺序。 */ + /** 用户气泡:顺序即条目顺序;本地不再造气泡,它只来自宿主条目。 */ users: DirectChatBlock[]; /** 过程块:工具与中间正文按条目顺序,连续工具合成一块。 */ process: DirectChatBlock[]; - /** 最终回复,以及失败 / 终止这类只存在于运行期的说明。 */ + /** 最终回复,以及失败 / 终止这类说明(本地说明只在没有回合可挂时自成一组)。 */ finals: DirectChatBlock[]; - /** 这一轮在界面上的状态(三态,取代原来的 `active` 布尔)。 */ + /** 这一轮在界面上的状态(两态,取代原来的 `active` 布尔)。 */ state: DirectChatTurnState; /** - * 本轮起点:该轮**实际用户消息的发送时间**优先(与气泡显示的时间同源), - * 缺失时用原生 `turn.started.at`,都拿不到是 0(此时隐藏不能证明的总耗时)。 + * 本轮起点:宿主 `turn.started.at`——运行中读实时值,收口后读 reducer 盖在条目上的值。 + * 它是当前唯一可证明的起点:拿不到(重进项目读回来的历史回合)就是 0,此时整条终态文案隐藏。 */ startedAt: number; /** 本轮明确终态时间(`turn.completed.at`);运行中或旧历史没有边界时是 0。 */ @@ -112,8 +105,7 @@ export type DirectChatTurn = { type DirectChatTurnEntries = { key: string; entries: DirectChatEntry[]; - /** 本地乐观用户气泡:还没有任何落盘条目时的用户消息。 */ - localUsers: DirectChatBlock[]; + /** 本地说明(拒单提示、壳层 `announce`):不是回合条目,按挂点归组。 */ notices: ChatMessage[]; /** 原生回合是否在跑;只有一个来源——reducer 的 `turnRunning`。 */ nativeRunning: boolean; @@ -122,7 +114,6 @@ type DirectChatTurnEntries = { function blockFromEntry( entry: DirectChatEntry, key: string, - localSentAt: ReadonlyMap, ): DirectChatBlock | null { if (entry.kind === 'tool') { return entry.toolCall @@ -135,16 +126,7 @@ function blockFromEntry( return { kind: 'reasoning', key, text }; } return entry.role === 'user' - ? { - kind: 'user', - key, - text, - at: sameIdentitySentAt( - entry.itemId, - normalizeDirectTimestamp(entry.at), - localSentAt, - ), - } + ? { kind: 'user', key, text, at: normalizeDirectTimestamp(entry.at) } : { kind: 'assistant', key, @@ -153,41 +135,6 @@ function blockFromEntry( }; } -/** - * 同身份(同一个 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[] = []; @@ -210,18 +157,10 @@ function blockFromLocalMessage( ): 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), - }; - } + // 本地消息只剩"说明"一种(拒单提示、壳层 `announce`):用户消息一律来自宿主条目。 return { kind: 'assistant', - key, + key: message.messageId ?? `local:${index}`, text: message.text, at: normalizeDirectTimestamp(message.updatedAt), notice: true, @@ -232,7 +171,6 @@ function newTurn(key: string): DirectChatTurnEntries { return { key, entries: [], - localUsers: [], notices: [], nativeRunning: false, }; @@ -253,13 +191,16 @@ function directEntryTurnKey(entry: DirectChatEntry): string { * 条目 + 运行期本地消息 → 回合列表。 * * **回合身份是开口用户条目的 canonical `itemId`**(`direct-codex:{clientTurnId}:user`),不是 - * 数组位置:用户条目、本地乐观气泡、以及这一轮的失败说明都按同一个身份归进同一轮。三种来源谁先到 - * 都行——回合在宿主下发用户条目之前就失败时,只有失败说明会到,那时也必须靠身份归位,否则说明会 - * 按位置落进上一轮(界面表现:错误显示在用户消息上面、上一轮顶替本轮显示耗时),用户气泡再自成 - * 一轮(多出一个 0.0 秒的假回合)。 + * 数组位置:这一轮的用户条目与失败说明都按同一个身份归进同一轮。两种条目谁先到都行——回合在宿主 + * 下发用户条目之前就失败时,只有失败说明会到,那时也必须靠身份归位,否则说明会按位置落进上一轮 + * (界面表现:错误显示在用户消息上面、上一轮顶替本轮显示耗时)。 * - * 其余条目(工具、思考、正文)不带身份,跟着当前回合走;本地 assistant 消息(终止说明、壳层 - * `announce`)挂到当前回合末尾。同身份的本地消息不重复渲染:条目赢。 + * 其余条目(工具、思考、正文)不带身份,跟着当前回合走。 + * + * 本地消息只剩说明一种,用户消息一律来自宿主条目(本地乐观气泡已删,见 ADR「DirectProject命令 + * 接单化」的后续更新):带身份的说明(拒单提示,id 形如 `…:user:rejected`)说的是"这一轮从没 + * 成立过",不属于任何回合,自己在会话末尾开一组;不带头身份的是壳层 `announce`,挂到当前回合末 + * 尾。同身份的本地消息不重复渲染:条目赢。 * * 失败说明不在这条本地通道里:它是宿主 `turn.completed.failure` 载荷落成的普通条目,来源与顺序 * 都归 reducer(身份字段 `turnUserItemId` 也由 reducer 写)。 @@ -269,29 +210,16 @@ export function buildDirectChatTurns({ localMessages = [], turnRunning = false, turnStartedAt = 0, - pendingUserItemId = '', }: { entries: readonly DirectChatEntry[]; localMessages?: readonly ChatMessage[]; turnRunning?: boolean; - /** - * 当前回合的原生起点(`turn.started.at`):只在该轮用户发送时间缺失时兜底, - * 不会覆盖用户实际发送时间,也不参与已完成回合。 - */ + /** 当前回合的原生起点(`turn.started.at`):只在该轮条目上还没盖边界时兜底。 */ turnStartedAt?: number; - /** - * 本地已发出、原生还没认领的那一轮用户条目身份(`direct-codex:{clientTurnId}:user`)。 - * - * 只服务 `awaiting-start` 这一个展示态:身份命中、且本轮还没有明确终态时,最新一轮按 - * 「待认领」而不是「已结束」呈现。原生 `turn.started` 一到,`turnRunning` 就把这一轮接 - * 过去,这个入参不再参与判定;空串 = 没有在途的本地回合。 - */ - pendingUserItemId?: string; }): DirectChatTurn[] { const turns: DirectChatTurnEntries[] = []; const turnsByIdentity = new Map(); let current: DirectChatTurnEntries | null = null; - const localSentAt = localSentTimes(localMessages); // 分页切片的开头可能落在半截回合里(那一条用户条目还在更早的一屏):这些前导条目先攒着, // 交给后面第一个用户条目开的回合,避免渲染出一个没有用户气泡的孤儿回合。 const leadingEntries: DirectChatEntry[] = []; @@ -333,36 +261,30 @@ export function buildDirectChatTurns({ turns.push(current); } + // 本地说明可能在最后一个回合之后另开一组「不属于任何回合」的提示:运行态标记只给条目流的那一组, + // 否则真正在跑的那一轮会被读成已结束(过程被折叠、耗时也不显示)。 + const lastEntryTurn = current; const entryIds = new Set(entries.map((entry) => entry.itemId)); - localMessages.forEach((message, index) => { + localMessages.forEach((message) => { + // 本地不再造用户消息(乐观气泡已删):万一还来了一条,既不进聊天区、也不开回合。 + if (message.role === 'user') return; if (message.messageId && entryIds.has(message.messageId)) return; - if (message.role === 'user') { - const block = blockFromLocalMessage(message, index); - // 身份已经开过回合(本轮的开口用户条目还没到,但它的失败说明到了):挂回自己那一轮。 - // 另开一轮就会多出一个"用户气泡 + 0.0 秒终态"的假回合,而这一轮的说明还留在上面那一段。 - const claimed = message.messageId - ? turnsByIdentity.get(message.messageId) - : undefined; - if (claimed) { - if (block && claimed.localUsers.length === 0) { - claimed.localUsers.push(block); - } - return; - } - current = openTurn(message.messageId ?? `local:${index}`); - if (block) current.localUsers.push(block); - return; - } - if (!current) { + // 带身份的本地说明(拒单提示)说的是"这一轮从没成立过":它不属于任何回合,也不能按位置挂进 + // 上一轮(那会被读成上一轮的问题)。它自己在会话末尾开一组提示:没有用户条目的回合不会凭空 + // 多出一条耗时文案(`startedAt` 拿不到时终态文案整条隐藏)。 + if (message.messageId) { + current = openTurn(message.messageId); + } else if (!current) { + // 不带头身份的是壳层 `announce`:跟着当前回合照旧,没有回合时才兜一个容器。 current = newTurn(`history:${turns.length}`); turns.push(current); } current.notices.push(message); }); - if (current) current.nativeRunning = turnRunning; + const runningTurn = lastEntryTurn ?? current; + if (runningTurn) runningTurn.nativeRunning = turnRunning; - const newestTurnIndex = turns.length - 1; - return turns.map((turn, turnIndex) => { + return turns.map((turn) => { const lastAssistant = turn.nativeRunning ? -1 : turn.entries.reduce( @@ -376,11 +298,7 @@ export function buildDirectChatTurns({ const process: DirectChatBlock[] = []; const finals: DirectChatBlock[] = []; turn.entries.forEach((entry, index) => { - const block = blockFromEntry( - entry, - `${turn.key}:${entry.itemId}`, - localSentAt, - ); + const block = blockFromEntry(entry, `${turn.key}:${entry.itemId}`); if (!block) return; if (block.kind === 'user') { users.push(block); @@ -392,20 +310,12 @@ export function buildDirectChatTurns({ } 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; - } - } + // 回合边界只认宿主事件:起点是 `turn.started.at`,终点是 `turn.completed.at`。本地不再有 + // 用户发送时刻可以当起点,也不拿条目自己的 `at` 猜(它是落盘 / 观测时间,晚于真实发送)。 const stampedStart = turn.entries.reduce( (found, entry) => found || normalizeDirectTimestamp(entry.turnStartedAt), 0, @@ -415,31 +325,19 @@ export function buildDirectChatTurns({ 0, ); // 起点优先级(逐级覆盖,不嵌套三元):条目上盖的起点 → 运行中改读原生 - // `turn.started.at`(拿不到就是 0,不退回去用条目兜底)→ 该轮用户气泡自己的发送 - // 时间最高优先。 + // `turn.started.at`(拿不到就是 0,不退回去用条目兜底)。 let startedAt = stampedStart; if (turn.nativeRunning) { startedAt = normalizeDirectTimestamp(turnStartedAt); } - if (userSentAt > 0) { - startedAt = userSentAt; - } // 终态只读**本轮条目**上盖的边界:跨轮 fallback 会把最新回合的终点填进所有 // 拿不到时间的旧历史回合,等于给未知耗时编一个值。 const endedAt = turn.nativeRunning ? 0 : stampedEnd; - // 三态只在这里产生:原生在跑 = running;最新一轮是本地在途身份且没有终态 = - // awaiting-start;其余都是 finished。判据是身份(`itemId`)而不是时间戳大小。 - let state: DirectChatTurnState = 'finished'; - if (turn.nativeRunning) { - state = 'running'; - } else if ( - turnIndex === newestTurnIndex && - pendingUserItemId !== '' && - turn.key === pendingUserItemId && - stampedEnd <= 0 - ) { - state = 'awaiting-start'; - } + // 两态只在这里产生:宿主开始事件到、终态还没到 = running;其余都是 finished。 + // 判据是显式事件,不是时间戳大小,也不是"最新一轮"这种位置判据。 + const state: DirectChatTurnState = turn.nativeRunning + ? 'running' + : 'finished'; return { key: turn.key, users, 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 ae7447270..b436d1e67 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 @@ -441,8 +441,8 @@ export function registerChatComposerControlTests() { }); it('writes a same-level chat notice when the host rejects a turn without a turn event', async () => { - // 宿主 / 环境事实的拒单没有接单、也就不产生 `turn.completed`:聊天里那条乐观用户气泡后面不会 - // 再有任何事件来解释它,说明必须由命令边界补一条;上报与横幅照旧保留。 + // 宿主 / 环境事实的拒单没有接单、也就不产生 `turn.completed`:这一轮在聊天区里根本不存在 + // (本地不再造用户气泡),说明必须由命令边界补一条;上报与横幅照旧保留。 const { surface } = await openDirectCodexSurface({ chat_with_game_creator_direct_codex: () => { throw { @@ -558,7 +558,7 @@ export function registerChatComposerControlTests() { }); }); - it('does not report a finished turn while the host has not acknowledged the send yet', async () => { + it('keeps the accept window silent in the chat and busy in the composer', async () => { const pending: Array<{ resolve: (value: string) => void }> = []; const { invoke, surface } = await openDirectCodexSurface({ chat_with_game_creator_direct_codex: () => @@ -578,14 +578,18 @@ export function registerChatComposerControlTests() { ); }); - // 本地乐观气泡立刻可见;此刻原生既没回 turn.started,也没回显用户条目, - // 这一轮属于「本地已发出、宿主未确认」,不得渲染成已结束。 - await waitFor(() => { - expect(within(surface).getByText('窗口期的消息')).not.toBeNull(); - }); - expect(within(surface).queryByText(/本轮结束于/)).toBeNull(); - expect(within(surface).queryByTestId('turn-usage')).toBeNull(); + // 本地不再造乐观气泡:宿主既没回 turn.started、也没下发用户条目,聊天区里就没有这一轮, + // 也就不会有"已结束"的终态文案;窗口期的反馈只有 composer 的忙态。 + const conversation = within(surface).getByLabelText('陶泥儿消息'); + expect(within(conversation).queryByText('窗口期的消息')).toBeNull(); + expect(within(conversation).queryByText(/本轮结束于/)).toBeNull(); + expect(within(conversation).queryByTestId('turn-usage')).toBeNull(); + expect(within(surface).queryByRole('button', { name: '发送' })).toBeNull(); + expect( + within(surface).getByRole('button', { name: '终止' }), + ).not.toBeNull(); + // 宿主认领这一轮:用户条目下发,气泡这时才出现。 await act(async () => { pending[0]?.resolve('回复'); }); diff --git a/apps/ai-game-creator-shell/tests/chatComposerAttachmentCap.test.tsx b/apps/ai-game-creator-shell/tests/chatComposerAttachmentCap.test.tsx index 9698af429..48d990264 100644 --- a/apps/ai-game-creator-shell/tests/chatComposerAttachmentCap.test.tsx +++ b/apps/ai-game-creator-shell/tests/chatComposerAttachmentCap.test.tsx @@ -42,7 +42,6 @@ describe('聊天输入盒的附件上限', () => { const { result } = renderHook(() => useDirectProjectChatController({ - assets: [], enabled: false, ensureConversationReadAllowed: async () => true, ensureConversationWriteAllowed: async () => true, diff --git a/apps/ai-game-creator-shell/tests/directProjectTurn.test.tsx b/apps/ai-game-creator-shell/tests/directProjectTurn.test.tsx index 1561a54e7..666455af6 100644 --- a/apps/ai-game-creator-shell/tests/directProjectTurn.test.tsx +++ b/apps/ai-game-creator-shell/tests/directProjectTurn.test.tsx @@ -1,7 +1,7 @@ /** @vitest-environment jsdom */ -import { render } from '@testing-library/react'; +import { cleanup, render } from '@testing-library/react'; import React from 'react'; -import { expect, it } from 'vitest'; +import { afterEach, expect, it } from 'vitest'; import { DirectProjectTurn } from '../src/view/project-development/chat/components/DirectProjectConversation/DirectProjectTurn'; import type { @@ -9,6 +9,8 @@ import type { DirectChatTurnState, } from '../src/view/project-development/chat/conversation/directTurnPresentation'; +afterEach(() => cleanup()); + const SENT_AT = 1_800_000_000_000; const ENDED_AT = SENT_AT + 12_400; @@ -29,11 +31,9 @@ const turn = ( ...overrides, }); -it('awaiting-start:本地已发出、原生还没认领时不显示终态文案,也不折叠过程', () => { +it('running:宿主已认领、回合还没结束时不显示终态文案,也不折叠过程', () => { const view = render( - React.createElement(DirectProjectTurn, { - turn: turn('awaiting-start'), - }), + React.createElement(DirectProjectTurn, { turn: turn('running') }), ); expect(view.queryByTestId('turn-usage')).toBeNull(); expect(view.queryByText(/本轮结束于/)).toBeNull(); @@ -42,12 +42,14 @@ it('awaiting-start:本地已发出、原生还没认领时不显示终态文 expect(view.getByLabelText('思考过程')).not.toBeNull(); }); -it('running:原生回合在跑时同样不显示终态文案', () => { +it('finished 但没有回合边界(重进项目读回来的历史回合):整条终态文案隐藏', () => { const view = render( - React.createElement(DirectProjectTurn, { turn: turn('running') }), + React.createElement(DirectProjectTurn, { + turn: turn('finished', { startedAt: 0, endedAt: 0 }), + }), ); expect(view.queryByTestId('turn-usage')).toBeNull(); - expect(view.queryByTestId('turn-process')).toBeNull(); + expect(view.queryByText(/本轮结束于/)).toBeNull(); }); it('finished 且有明确终态:显示结束时间与耗时,过程折叠', () => { diff --git a/apps/ai-game-creator-shell/tests/directProjectTurnStatus.test.ts b/apps/ai-game-creator-shell/tests/directProjectTurnStatus.test.ts index 7cab1828d..9c8e210b1 100644 --- a/apps/ai-game-creator-shell/tests/directProjectTurnStatus.test.ts +++ b/apps/ai-game-creator-shell/tests/directProjectTurnStatus.test.ts @@ -48,14 +48,14 @@ describe('DirectProject 回合状态派生', () => { expect(status.commandInFlight).toBe(true); }); - it('latestTurnState 取最新一轮的三态;没有回合时为 null', () => { + it('latestTurnState 取最新一轮的两态;没有回合时为 null', () => { expect( deriveDirectProjectTurnStatus({ turnRunning: false, turnBusy: false, - turns: [turn('u1', 'finished'), turn('u2', 'awaiting-start')], + turns: [turn('u1', 'finished'), turn('u2', 'running')], }).latestTurnState, - ).toBe('awaiting-start'); + ).toBe('running'); expect( deriveDirectProjectTurnStatus({ turnRunning: false, @@ -69,9 +69,10 @@ describe('DirectProject 回合状态派生', () => { const status = deriveDirectProjectTurnStatus({ turnRunning: false, turnBusy: true, - turns: [turn('u1', 'awaiting-start')], + turns: [turn('u1', 'finished')], }); expect(status.nativeRunning).toBe(false); - expect(status.latestTurnState).toBe('awaiting-start'); + expect(status.commandInFlight).toBe(true); + expect(status.latestTurnState).toBe('finished'); }); }); diff --git a/apps/ai-game-creator-shell/tests/directTurnPresentation.test.ts b/apps/ai-game-creator-shell/tests/directTurnPresentation.test.ts index 13bab533e..f3ae0325d 100644 --- a/apps/ai-game-creator-shell/tests/directTurnPresentation.test.ts +++ b/apps/ai-game-creator-shell/tests/directTurnPresentation.test.ts @@ -92,13 +92,6 @@ const reasoningEntry = ( at, }); -const localUser = (text: string, messageId?: string): ChatMessage => ({ - role: 'user', - text, - ...(messageId ? { messageId } : {}), - updatedAt: 1_800_000_002_000, -}); - const localNotice = (text: string, messageId?: string): ChatMessage => ({ role: 'assistant', text, @@ -128,7 +121,8 @@ describe('DirectProject 聊天分区', () => { expect(turns[0]?.users[0]).toMatchObject({ text: '问题 u1' }); expect(turns[0]?.finals[0]).toMatchObject({ text: '第一轮答复' }); expect(turns[1]?.finals[0]).toMatchObject({ text: '第二轮答复' }); - expect(turns[0]?.startedAt).toBe(1_800_000_000_000); + // 历史条目没有回合边界:起点也是 0,整条终态文案因此隐藏(不按用户条目的落盘时间编一个)。 + expect(turns[0]?.startedAt).toBe(0); // 终点只认明确终态:旧历史条目里没有 `turnEndedAt` 就隐藏,不拿最后一条正文的时间顶替。 expect(turns[0]?.endedAt).toBe(0); }); @@ -216,12 +210,12 @@ describe('DirectProject 聊天分区', () => { expect(turns.map((turn) => turn.endedAt)).toEqual([ 0, 0, 1_800_000_022_500, ]); - // 旧历史回合并不会因为"拿不到时间"被填上最新回合的终点。 - expect(turns[0]?.startedAt).toBe(1_800_000_000_000); + // 旧历史回合并不会因为"拿不到时间"被填上最新回合的终点 / 起点。 + expect(turns[0]?.startedAt).toBe(0); expect(turns[2]?.startedAt).toBe(1_800_000_020_000); }); - it('运行中的整轮起点:优先用户实际发送时间,缺失才用原生 turn.started.at', () => { + it('运行中的整轮起点只认原生 turn.started.at,条目自己的 at 不当起点', () => { const liveTurn = buildDirectChatTurns({ entries: [ { ...userEntry('u1', 0), at: 0 }, @@ -241,32 +235,28 @@ describe('DirectProject 聊天分区', () => { turnRunning: true, turnStartedAt: 1_800_000_000_100, }); - // 用户发送时间更早且是真实发送:以它为准,不取所有条目的最小时间。 - expect(withUserTime[0]?.startedAt).toBe(1_800_000_000_050); + // 起点只认宿主的回合边界:条目自己的 `at` 是落盘 / 观测时间,不当起点用。 + expect(withUserTime[0]?.startedAt).toBe(1_800_000_000_100); }); - it('正式条目的晚 ack 时间不顶掉本地真实发送时间', () => { + it('本地用户消息不再进回合:气泡只来自宿主条目', () => { const sentAt = 1_800_000_000_000; - // 原生落盘 / 观测到的 ack 时间晚于用户真正按下发送的时刻。 - const ackAt = sentAt + 1_200; const turns = buildDirectChatTurns({ - entries: [ - userEntry('direct-codex:turn-1:user', ackAt), - liveToolEntry('t1', sentAt + 400, sentAt + 900), - ], - // 同一条消息的本地乐观气泡(messageId 就是原生条目身份,时间是本地发送时刻)。 + entries: [userEntry('direct-codex:turn-1:user', sentAt + 1_200)], + // 本地只保留说明:任何用户消息(哪怕是同一条消息的副本)都不造气泡、也不开回合。 localMessages: [ { role: 'user' as const, text: '问题 direct-codex:turn-1:user', - messageId: 'direct-codex:turn-1:user', + messageId: 'direct-codex:turn-2:user', updatedAt: sentAt, }, ], }); - // 同身份合并保留真实发送时间:既不是正式条目的 ack 时间,也不按所有条目取最小值。 - expect(turns[0]?.startedAt).toBe(sentAt); - expect(turns[0]?.users[0]).toMatchObject({ at: sentAt }); + expect(turns.map((turn) => turn.key)).toEqual(['direct-codex:turn-1:user']); + // 显示时间就是宿主的落盘 / 观测时间(本地不再有第二份更早的发送时刻)。 + expect(turns[0]?.users).toHaveLength(1); + expect(turns[0]?.users[0]).toMatchObject({ at: sentAt + 1_200 }); }); it('运行期失败说明挂到当前回合末尾,不当成最终回复', () => { @@ -285,12 +275,12 @@ describe('DirectProject 聊天分区', () => { }); }); - it('本轮开口条目没到时,失败说明按身份挂回自己那一轮,气泡不再自成假回合', () => { - // 现场:回合在宿主下发开口用户条目之前就失败,于是界面上既没有正式用户条目、也没有历史 - // 切片,只有这一轮的失败说明和本地乐观气泡(这条消息的开口条目还没到)。 + it('本轮开口条目没到时,失败说明按身份挂回自己那一轮', () => { + // 现场:回合在宿主下发开口用户条目之前就失败,于是界面上只有这一轮的失败说明——开口条目 + // 要等历史切片(或迟到的 item.completed)才到。靠位置分组会把说明留给上一轮,界面表现就是 + // "错误显示在用户消息上面",上一轮还会顶替本轮显示耗时。 const turns = buildDirectChatTurns({ entries: [ - userEntry('direct-codex:turn-1:user'), { ...assistantEntry( 'direct-codex:turn-1:user:failure', @@ -310,12 +300,10 @@ describe('DirectProject 聊天分区', () => { turnEndedAt: 1_800_000_035_600, }, ], - localMessages: [localUser('hello', 'direct-codex:turn-2:user')], turnRunning: false, }); - // 两个回合:第二个回合的用户气泡与它自己的失败说明同段 —— 说明不再落进上一轮(否则界面 - // 表现就是"错误显示在用户消息上面"),气泡也不再自成"0.0 秒"的假回合。 + // 两个回合:说明各自挂在自己的身份上(第二个回合的开口条目还没到,这一组里没有用户气泡)。 expect(turns.map((turn) => turn.key)).toEqual([ 'direct-codex:turn-1:user', 'direct-codex:turn-2:user', @@ -323,80 +311,66 @@ describe('DirectProject 聊天分区', () => { expect(turns[0]?.finals.map((block) => block.key)).toEqual([ 'direct-codex:turn-1:user:direct-codex:turn-1:user:failure', ]); - expect(turns[1]?.users.map((block) => block.text)).toEqual(['hello']); + expect(turns[0]?.users).toEqual([]); expect(turns[1]?.finals.map((block) => block.key)).toEqual([ 'direct-codex:turn-2:user:direct-codex:turn-2:user:failure', ]); - // 耗时按用户气泡自己的发送时间起算,不再把上一轮的终点借过来。 + // 边界只看各自条目上盖的回合时间,不借上一轮的终点。 + expect(turns[0]?.startedAt).toBe(1_800_000_010_000); expect(turns[0]?.endedAt).toBe(1_800_000_010_400); - expect(turns[1]?.startedAt).toBe(1_800_000_002_000); + expect(turns[1]?.startedAt).toBe(1_800_000_020_000); expect(turns[1]?.endedAt).toBe(1_800_000_035_600); }); - it('乐观用户气泡自成回合,已落盘的同一身份不重复渲染', () => { - const turns = buildDirectChatTurns({ - entries: [userEntry('u1'), assistantEntry('a1', '答复')], - localMessages: [localUser('问题 u1', 'u1'), localUser('第二条')], - turnRunning: true, - }); - expect(turns.map((turn) => turn.key)).toEqual(['u1', 'local:1']); - expect(turns[1]?.users).toHaveLength(1); - expect(turns[1]?.state).toBe('running'); - }); + it('两态:宿主开始事件是唯一的 running 判据,其余都是 finished', () => { + // 接单窗口(本地已发出、宿主还没认领)不再是展示态:这一轮在聊天区里根本不存在, + // 所以投影拿不到任何条目可渲染,也不会造一个"待认领"的回合出来。 + const windowTurns = buildDirectChatTurns({ entries: [] }); + expect(windowTurns).toEqual([]); - it('三态:本地已发出、原生还没认领的那一轮是 awaiting-start,不是已结束', () => { - const turns = buildDirectChatTurns({ - entries: [], - localMessages: [localUser('本轮提问', 'direct-codex:turn-1:user')], - pendingUserItemId: 'direct-codex:turn-1:user', - }); - expect(turns.map((turn) => turn.state)).toEqual(['awaiting-start']); - expect(turns[0]?.endedAt).toBe(0); - expect(turns[0]?.startedAt).toBe(1_800_000_002_000); - }); - - it('三态:原生用户条目先到、turn.started 还没到时仍是 awaiting-start', () => { - const turns = buildDirectChatTurns({ - // 同身份的原生条目已经到了(本地气泡被去重),但原生回合还没开始。 + const running = buildDirectChatTurns({ entries: [userEntry('direct-codex:turn-1:user')], - localMessages: [localUser('本轮提问', 'direct-codex:turn-1:user')], - pendingUserItemId: 'direct-codex:turn-1:user', + turnRunning: true, + turnStartedAt: 1_800_000_003_000, }); - expect(turns.map((turn) => turn.state)).toEqual(['awaiting-start']); - }); + expect(running.map((turn) => turn.state)).toEqual(['running']); + expect(running[0]?.startedAt).toBe(1_800_000_003_000); + expect(running[0]?.endedAt).toBe(0); - it('三态:拿到明确终态后,在途身份不再把这一轮判成待认领', () => { - const turns = buildDirectChatTurns({ + // 拿到终态、或压根没有开始事件的历史回合,都只能是 finished。 + const finished = buildDirectChatTurns({ entries: [ { ...userEntry('direct-codex:turn-1:user'), turnEndedAt: 1_800_000_010_000, }, ], - pendingUserItemId: 'direct-codex:turn-1:user', + turnRunning: false, }); - expect(turns[0]?.state).toBe('finished'); - expect(turns[0]?.endedAt).toBe(1_800_000_010_000); + expect(finished.map((turn) => turn.state)).toEqual(['finished']); + expect(finished[0]?.endedAt).toBe(1_800_000_010_000); }); - it('三态:只有最新一轮能是 awaiting-start,身份不匹配也不影响判定', () => { - const notNewest = buildDirectChatTurns({ - entries: [userEntry('direct-codex:turn-1:user')], - localMessages: [localUser('第二条', 'direct-codex:turn-2:user')], - pendingUserItemId: 'direct-codex:turn-1:user', + it('带身份的本地说明自成一组:不挂进上一轮,也不造出耗时文案', () => { + const turns = buildDirectChatTurns({ + entries: [userEntry('u1'), assistantEntry('a1', '上一轮答复')], + localMessages: [ + localNotice( + '同一轮消息仍在处理中', + 'direct-codex:turn-9:user:rejected', + ), + ], }); - expect(notNewest.map((turn) => turn.state)).toEqual([ - 'finished', - 'finished', + expect(turns.map((turn) => turn.key)).toEqual([ + 'u1', + 'direct-codex:turn-9:user:rejected', ]); - const mismatch = buildDirectChatTurns({ - entries: [userEntry('u1')], - localMessages: [localUser('第二条', 'direct-codex:turn-2:user')], - pendingUserItemId: 'direct-codex:turn-9:user', - }); - expect(mismatch.map((turn) => turn.state)).toEqual([ - 'finished', - 'finished', + // 这一组没有用户条目,也就没有起点:`DirectProjectTurn` 的 `turn.startedAt` 判据会把整条 + // 「本轮结束于 … 」隐藏(不再出现 0.0 秒)。 + expect(turns[1]?.users).toEqual([]); + expect(turns[1]?.startedAt).toBe(0); + expect(turns[1]?.finals.map((block) => block.text)).toEqual([ + '同一轮消息仍在处理中', ]); }); diff --git a/docs/adr/【ADR】DirectProject命令接单化-2026-09-23.md b/docs/adr/【ADR】DirectProject命令接单化-2026-09-23.md index 13d217ed8..c18428879 100644 --- a/docs/adr/【ADR】DirectProject命令接单化-2026-09-23.md +++ b/docs/adr/【ADR】DirectProject命令接单化-2026-09-23.md @@ -171,3 +171,14 @@ 本轮的用户气泡再自成一个 0.0 秒的假回合;下一条消息同样看不到自己的失败说明。§7 的界面口径据此补上 "回合归属只认身份":失败说明条目带 `turnUserItemId`,投影层按开口条目身份分组,本地乐观气泡按身份挂回 自己的回合;reducer 的收口早退也不再吞掉"订阅重建只回放生命周期锚点"时那条还没写进界面的失败说明。 + +后续更新(2026-09-24,删掉本地乐观用户气泡):§7 的"同级提示"再收一层——**本地不再造用户消息**。 +前端把乐观气泡、`awaiting-start` 展示态、`pendingUserItemId` / `messageAppended` / `messageText` +这一整套一起删掉,用户气泡**只**来自宿主条目(发点=接单成立、落盘成功、起 codex 之前)。三条口径 +随之固定:① 接单窗口(按下发送到 `turn.started` 落进 reducer)与订阅重建窗口里聊天区没有这一轮的 +任何条目,反馈只有 composer 忙态与状态行;② 回合起点只认 `turn.started.at`、终点只认 +`turn.completed.at`,用户气泡显示的时钟是宿主落盘 / 观测时间(不再有更早的本地发送时间),两边都 +拿不到(重进项目读回来的历史回合)时整条「本轮结束于 … 」隐藏,不再兜出 0.0 秒;③ 拒单提示带自己 +的身份(`…:rejected`),投影据此在会话末尾自成一组,不挂进上一轮。§7 里"排在用户消息后面"在没有 +用户消息的回合里指"这一组提示自己"。代价(已知并接受):条目下发之前用户看不到自己那句话, +`project.jsonl` 里的用户条目也依旧只在首屏 / 翻页时读进前端。 diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 03adcb0bd..ddb1a8dfc 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -9480,3 +9480,13 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在 - 影响面:`apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/mod.rs`、`.../agent/direct_runtime/user_input.rs`、`.../chat/conversation/{directThreadChat.ts,directTurnPresentation.ts}`。 - 边界:`project.jsonl` 里的用户条目依旧只在首屏 / 翻页时读进前端,本轮不改读取时机——开口条目的运行态下发 + 身份归位已经让"说明挂错回合"不成立。 - 验证:宿主 `cargo test --bins "agent::"`、`the_opening_user_item_is_emitted_before_anything_that_can_fail_in_the_turn`、`direct_project_turn_does_not_forward_codex_user_echo_as_chat_items`(补上同一发点);前端 `directTurnPresentation.test.ts` 的"本轮用户条目没到时,失败说明按身份挂回自己那一轮,本地气泡不再自成假回合"、`directThreadChat.test.ts` 的两条(身份字段、收口早退不吞说明)。 + +## 2026-09-24 DirectProject 删掉本地乐观用户气泡:用户气泡只来自宿主条目 + +- 背景:接单化之后,"接单窗口期"只服务本地乐观气泡(`awaiting-start` 展示态 + `pendingUserItemId` 身份)。上一轮把开口用户条目的发点提前到接单之后,"说明挂错回合"已不再需要气泡兜底;用户确认按"这条消息就像从来没存在过"处理,直接删干净。 +- 决策(不造用户消息):删 `pendingUserItemId` / `beginTurnCommand` / `endTurnCommand`(忙态改由 `beginTurnBusy` / `endTurnBusy` 持有,宿主认领判据 = `turnRunning` 或收口计数变过)、权限确认重跑的 `messageAppended` 参数、`DirectProjectTurnInput.messageText`(含首轮 `directInitialTurnText`)、投影里的 `awaiting-start` 与本地用户气泡路径(展示态只剩 `running` / `finished`);controller 不再需要 `assets`。 +- 决策(时间口径):回合起点只认 `turn.started.at`、终点只认 `turn.completed.at`;用户气泡的时钟是宿主落盘 / 观测时间,不再有"本地更早的真实发送时刻"(`sameIdentitySentAt` 删除)。两边都拿不到(重进项目读回来的历史回合)时整条「本轮结束于 … 」隐藏,不再兜出 0.0 秒。 +- 决策(本地说明):拒单提示带自己的身份(`…:rejected`),投影据此在会话末尾自成一组,不挂进上一轮;壳层 `announce`(无身份)照旧挂当前回合末尾。带身份的本地说明不开运行态标记,避免把真正在跑的那一轮读成已结束。 +- 代价(已知并接受):接单窗口与订阅重建窗口里聊天区没有这一轮的显示,反馈只有 composer 忙态与状态行;`project.jsonl` 用户条目依旧只在首屏 / 翻页读进前端。 +- 影响面:`apps/ai-game-creator-shell/src/view/project-development/chat/conversation/{directTurnPresentation.ts,directCodexConversation.ts}`、`.../chat/controller/{useDirectProjectChatController.ts,useDirectProjectTurnStatus.ts}`、`.../chat/DirectProjectChatView.tsx`、`.../chat/components/DirectProjectConversation/DirectProjectTurn.tsx`、`src-tauri/src/agent/codex_app_server/mod.rs`(用户条目时间的注释口径)、对应 ADR 与实施计划。 +- 验证:`npx vitest run tests/directTurnPresentation.test.ts tests/directProjectTurn.test.tsx tests/directProjectTurnStatus.test.ts tests/directThreadChat.test.ts tests/chatComposerAttachmentCap.test.tsx`、`tests/appSurface.test.ts`(213 passed / 9 skipped)、`npx tsc -p tsconfig.json --noEmit`、`eslint`、`prettier --check`、`npm run check:encoding`、`git diff --check` 全绿。真实客户端观感未复核。 diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index 8356c6331..3acb7d597 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -5969,3 +5969,10 @@ Cocos Creator 根目录由 `package.json.creator.version` 与普通 `assets/` - **排查提示**:先分清两层 —— 逻辑回合的 `turn.started` / `turn.completed`(Thread Manager,一定有、成对)vs app-server 协议的 `turn/start` 请求(连接拿到之后才发)。"失败说明挂错回合"永远先看这条顺序,不要先怀疑事件丢了。 - **验证**:宿主 `the_opening_user_item_is_emitted_before_anything_that_can_fail_in_the_turn`、前端 `本轮用户条目没到时,失败说明按身份挂回自己那一轮,本地气泡不再自成假回合` 与 `收口早退不吞掉还没写进界面的失败说明(订阅重建只回放生命周期锚点)`。 - **关联**:`apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/mod.rs`、`.../agent/direct_runtime/user_input.rs`、`.../chat/conversation/{directThreadChat.ts,directTurnPresentation.ts}`、`docs/adr/【ADR】DirectProject命令接单化-2026-09-23.md`。 + +## 2026-09-24 DirectProject「接单窗口里看不到自己刚发的话」是设计,不是丢消息 + +- **现象**:按下发送后聊天区里不会立刻出现自己那句话;宿主还在接单 / 落盘的那段时间只能看到 composer 忙态与状态行,滚动也停在原地。订阅重建的窗口同理。容易被读成"消息丢了 / 没发出去"。 +- **原因**:本地乐观用户气泡已删(ADR「DirectProject命令接单化」后续更新 2026-09-24)。用户气泡的唯一来源是宿主下发的开口条目(发点=接单成立 + 用户条目落盘成功 + 起 codex 之前)。删它的收益是"回合归属只认身份"不再需要给本地消息一份同名身份,投影也少一个展示态(`awaiting-start`)。 +- **排查提示**:窗口期不要拿"有没有本地气泡"当发送成功的证据;证据是 `invoke` 返回 `Ok`(接单成立)与随后到达的 `turn.started` / 开口条目。显示时间与耗时也全以宿主事件为准:起点 `turn.started.at`、终点 `turn.completed.at`;重进项目读回来的历史回合两边都空,整条「本轮结束于 … 」直接隐藏(不再出现 0.0 秒)。 +- **关联**:`apps/ai-game-creator-shell/src/view/project-development/chat/conversation/directTurnPresentation.ts`、`.../chat/controller/useDirectProjectChatController.ts`、`.../chat/components/DirectProjectConversation/DirectProjectTurn.tsx`、`docs/adr/【ADR】DirectProject命令接单化-2026-09-23.md`。 diff --git a/docs/technical/【实施计划】DirectProject命令接单化-2026-09-23.md b/docs/technical/【实施计划】DirectProject命令接单化-2026-09-23.md index 74d2d38f9..64a442342 100644 --- a/docs/technical/【实施计划】DirectProject命令接单化-2026-09-23.md +++ b/docs/technical/【实施计划】DirectProject命令接单化-2026-09-23.md @@ -120,3 +120,26 @@ `收口早退不吞掉还没写进界面的失败说明(订阅重建只回放生命周期锚点)`。 - 已知边界:`project.jsonl` 里的用户条目依旧只在首屏 / 翻页时读进前端,本次不改这条读取时机—— 开口条目的运行态下发与身份归位已经让"说明挂错回合"不再成立。 + +## 删掉本地乐观用户气泡(2026-09-24) + +上一节的"按身份归位"落地后,本地乐观气泡只剩一个作用:把"接单窗口期"变成一种展示态 +(`awaiting-start`),并给投影多带一份与宿主条目同身份的本地用户消息。用户确认按"这条消息就像从来 +没存在过"处理,于是整套删掉。 + +- controller:删 `pendingUserItemId`、`beginTurnCommand` / `endTurnCommand`;忙态保留(改叫 + `beginTurnBusy` / `endTurnBusy`),宿主认领判据 = `turnRunning` 或收口计数变过(一轮在同一次 + consume 里开始并结束)。同时删掉 `startTurn` 的乐观追加、权限确认重跑的 `messageAppended` 参数、 + `DirectProjectTurnInput.messageText` 与首轮的 `directInitialTurnText`;controller 不再需要 `assets`。 +- 投影 / 渲染:`DirectChatTurnState` 只剩 `running` / `finished`;删 `localSentTimes` / + `sameIdentitySentAt`、本地用户气泡与它开回合的那条路径。本地说明保留:带身份的拒单提示在会话末尾 + 自成一组(不挂上一轮,也不造耗时文案),不带头身份的壳层 `announce` 照旧挂当前回合末尾。 +- 时间口径:起点只认 `turn.started.at`(运行中读实时值、收口后读盖在条目上的值),终点只认 + `turn.completed.at`;用户气泡的时钟就是宿主落盘 / 观测时间。历史回合两边都是 0 → 整条 + 「本轮结束于 … 」隐藏,不再出现 0.0 秒。 +- 回归用例:`directTurnPresentation.test.ts`(本地用户消息不进回合、带身份的本地说明自成一组、 + 两态判据、失败说明按身份归位)、`directProjectTurn.test.tsx`(`running` 不显示终态文案;无边界的 + 历史回合整条隐藏)、`directProjectTurnStatus.test.ts`、`appSurface` 的 + `keeps the accept window silent in the chat and busy in the composer`。 +- 已知边界:条目下发之前(接单窗口、订阅重建窗口)聊天区里没有这一轮的任何显示;`project.jsonl` + 里的用户条目依旧只在首屏 / 翻页时读进前端。 diff --git a/docs/technical/【技术方案】DirectProject Codex原始历史与异常恢复-2026-09-04.md b/docs/technical/【技术方案】DirectProject Codex原始历史与异常恢复-2026-09-04.md index 7b0250464..d635dcd7d 100644 --- a/docs/technical/【技术方案】DirectProject Codex原始历史与异常恢复-2026-09-04.md +++ b/docs/technical/【技术方案】DirectProject Codex原始历史与异常恢复-2026-09-04.md @@ -61,7 +61,7 @@ Codex 启动时注入的 `host_skills.instructions`、`permissions.instructions` 聊天界面只从 message item 提取 user/assistant 内容;工具 item 不再拼成 `tool: ...` 假文本。 -DirectProject 的浏览器层只负责显示和乐观状态,不再调用通用对话写入器。历史读写与回合累计分别位于 `agent/direct_project_history.rs` 和 `agent/direct_project_turn_history.rs`。 +DirectProject 的浏览器层只负责显示与本地忙态,不再调用通用对话写入器,也不再造用户消息(本地乐观气泡已删,见 [`【ADR】DirectProject命令接单化-2026-09-23`](../adr/【ADR】DirectProject命令接单化-2026-09-23.md) 的后续更新)。历史读写与回合累计分别位于 `agent/direct_project_history.rs` 和 `agent/direct_project_turn_history.rs`。 `project.jsonl` 的 DirectProject 现行合同只允许 `response_item` envelope。其它模式产生的旧 conversation 行不属于本合同,不得注入 DirectProject。