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 d8e02903e..f1c00df6d 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 @@ -125,8 +125,9 @@ export function DirectProjectChatView({ appendLocalMessage, attachmentNotice, attachmentsImporting, - cancelQueuedTurn, + cancelPendingTurn, cancelTurn, + commandInFlight, composerNotice, directEntries, directTurnRunning, @@ -134,11 +135,10 @@ export function DirectProjectChatView({ historyHasMore, loadEarlierHistory, localMessages, - queuedTurns, + pendingTurns, startInitialTurn, statusNotice, submit, - turnBusy, turnCancelling, uploadFiles, } = chat; @@ -156,10 +156,11 @@ export function DirectProjectChatView({ }), [directEntries, localMessages, directTurnRunning, directTurnStartedAt], ); - // 「这一轮在跑吗」只从这一个派生入口读:原生真相 / 本地命令在飞 / 最新一轮两态。 + // 「这一轮在跑吗」只从这一个派生入口读:原生真相 / IPC 在飞 / 待发消息条数 / 最新一轮两态。 const turnStatus = useDirectProjectTurnStatus({ turnRunning: directTurnRunning, - turnBusy, + commandInFlight, + pendingTurns, turns: directTurns, }); // 状态条的起点只认**未结束**的最新一轮:只有它才拿得到本轮的 `turn.started.at`(运行中读实时值)。 @@ -268,11 +269,13 @@ export function DirectProjectChatView({ projectPath={projectPath} attachmentNotice={attachmentNotice} attachmentsImporting={attachmentsImporting} - queuedTurns={queuedTurns} + pendingTurns={pendingTurns} composerNotice={composerNotice} busy={turnStatus.displayBusy} turnCancelling={turnCancelling} - onCancelQueuedTurn={cancelQueuedTurn} + onCancelPendingTurn={(clientTurnId) => + void cancelPendingTurn(clientTurnId) + } onCancelTurn={() => void cancelTurn()} onReferencePickerOpen={() => { // 素材标签、改名、删除这些写入发生在资源画布,聊天侧收不到失效事件: diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectComposer/ComposerControls.tsx b/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectComposer/ComposerControls.tsx index dc4904ed0..3db8b8f3b 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectComposer/ComposerControls.tsx +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectComposer/ComposerControls.tsx @@ -3,7 +3,7 @@ * 模型 + 麦克风 + 发送/终止,以及输入盒上方的待发附件与消息队列 chip。 * * 这些组件只承载表现与交互;回合附件由宿主导入后插成正文芯片(正文是唯一事实源), - * 队列由 `chatComposerQueue.ts` 的纯函数维护。 + * 待发消息队列由宿主持有(这里只是一份事件投影,文案派生见 `pendingTurnChipLabel.ts`)。 */ import { Check, @@ -30,8 +30,7 @@ import { DEFAULT_COMPOSER_REASONING_EFFORT, normalizeComposerReasoningEffort, } from '../../../../../features/project-workspace/composerReasoningEffort'; -import type { QueuedChatTurn } from './chatComposerQueue'; -import { queuedChatTurnLabel } from './chatComposerQueue'; +import type { DirectPendingTurn } from '../../conversation/directPendingTurns'; import { resolveSpeechRecognitionCtor, speechEventTranscript, @@ -41,6 +40,7 @@ import { type SpeechRecognitionLike, VOICE_INPUT_UNSUPPORTED_MESSAGE, } from './chatComposerVoice'; +import { pendingTurnChipLabel } from './pendingTurnChipLabel'; type ComposerAttachmentMenuProps = { disabled: boolean; @@ -151,38 +151,46 @@ export function ComposerAttachmentMenu({ ); } -/** 队列 chip:回合运行中入队的消息,按 FIFO 顺序展示,可单条取消。 */ +/** + * 待发消息 chip:还没放行的消息,按 FIFO 顺序展示,可单条取消。 + * + * 列表就是宿主事件流折出来的那一份(`DirectPendingTurn`),顺序与宿主认领队首的顺序一致; + * 这里不排序、不重排,也不自己决定放行。 + */ export function ComposerTurnQueue({ turns, assets, onCancel, }: { - turns: readonly QueuedChatTurn[]; + turns: readonly DirectPendingTurn[]; /** 与聊天消息渲染同源的素材清单:chip 文案里的 `@` 引用按它展开成显示名。 */ assets: readonly GameCreationAppAssetManifestEntry[]; - onCancel: (id: string) => void; + onCancel: (clientTurnId: string) => void; }) { if (turns.length === 0) { return null; } return (
    - {turns.map((turn, index) => ( -
  1. - {index + 1} - - {queuedChatTurnLabel(turn, assets)} - - -
  2. - ))} + {turns.map((turn, index) => { + const label = pendingTurnChipLabel(turn, assets); + return ( +
  3. + + {index + 1} + + {label} + +
  4. + ); + })}
); } diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectComposer/DirectProjectComposer.tsx b/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectComposer/DirectProjectComposer.tsx index c92cd74d1..97e3ae34d 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectComposer/DirectProjectComposer.tsx +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectComposer/DirectProjectComposer.tsx @@ -23,8 +23,8 @@ import { } from '../../../../../features/project-workspace/ResourceReferencePicker'; import { assetsSignature } from '../../../../../features/project-workspace/resourceReferences'; import type { DirectCodexTurnAttachment } from '../../conversation/directCodexTurnAttachments'; +import type { DirectPendingTurn } from '../../conversation/directPendingTurns'; import type { DirectCodexUserContentPart } from '../../generated/DirectCodexUserContentPart'; -import type { QueuedChatTurn } from './chatComposerQueue'; import { ComposerAttachmentMenu, ComposerReasoningEffortSelect, @@ -43,10 +43,10 @@ function draftAttachmentCount(draft: { } /** - * DirectProject 输入区:队列、附件、`@` 引用输入框、模型/推理选择和发送/终止。 + * DirectProject 输入区:待发消息 chip、附件、`@` 引用输入框、模型/推理选择和发送/终止。 * - * 模型可用性校验、语音提示这类输入区自己的瞬时状态留在这里;回合、队列和附件的事实源 - * 仍然在聊天容器里,这里只负责把用户动作交回去。 + * 模型可用性校验、语音提示这类输入区自己的瞬时状态留在这里;回合、待发消息队列和附件的事实源 + * 仍然在聊天容器里(队列更上游是宿主),这里只负责把用户动作交回去。 */ export function DirectProjectComposer({ assets, @@ -54,11 +54,11 @@ export function DirectProjectComposer({ projectPath, attachmentNotice, attachmentsImporting, - queuedTurns, + pendingTurns, composerNotice, busy, turnCancelling, - onCancelQueuedTurn, + onCancelPendingTurn, onCancelTurn, onReferencePickerOpen, onSubmit, @@ -70,11 +70,11 @@ export function DirectProjectComposer({ attachmentNotice: string; /** 附件导入进行中:导入结果还没落成芯片,这一轮先不许发出去。 */ attachmentsImporting: boolean; - queuedTurns: QueuedChatTurn[]; + pendingTurns: readonly DirectPendingTurn[]; composerNotice: string; busy: boolean; turnCancelling: boolean; - onCancelQueuedTurn: (id: string) => void; + onCancelPendingTurn: (clientTurnId: string) => void; onCancelTurn: () => void; /** * 打开 `@` 选择器前刷新一次项目清单。 @@ -84,7 +84,14 @@ export function DirectProjectComposer({ * 用户真正要看这份清单的时刻是打开选择器的这一刻,所以在这里补一次重读。 */ onReferencePickerOpen?: () => void; - onSubmit: (content: DirectCodexUserContentPart[]) => boolean; + /** + * 提交草稿:返回**是否把这条消息交出去了**。 + * + * `true` 只代表"草稿可以清"——入队成立、或已经交给权限确认流程(确认后会重跑同一份内容); + * 入队失败里用户自己能改的那些返回 `false`,草稿原样留在输入盒里,不让一条被拒的消息从 + * 界面上凭空消失。 + */ + onSubmit: (content: DirectCodexUserContentPart[]) => Promise; /** * 导入文件:`draftAttachmentCount` 是草稿里已有的附件芯片数(上限按它算)。 * 返回导入成功的附件——由这里插成正文芯片;失败的一条都不插入。 @@ -180,7 +187,8 @@ export function DirectProjectComposer({ : modelReady; if (ready) { const content = composerRef.current?.getDraft().content ?? []; - if (onSubmit(content)) { + // 草稿清不清由命令的入队结果回答(可能是一次 IPC 往返):被拒的消息必须留下草稿。 + if (await onSubmit(content)) { composerRef.current?.clear(); } } @@ -192,9 +200,9 @@ export function DirectProjectComposer({ }} > item.id === turn.id)) { - return [...queue]; - } - return [...queue, turn]; -} - -/** 取队首(FIFO)。队列为空时 `next` 为 `null`,`rest` 保持空数组。 */ -export function dequeueChatTurn(queue: readonly QueuedChatTurn[]): { - next: QueuedChatTurn | null; - rest: QueuedChatTurn[]; -} { - if (queue.length === 0) { - return { next: null, rest: [] }; - } - const [next, ...rest] = queue; - return { next: next ?? null, rest }; -} - -/** 单条取消:按 id 移除,其余项保持原有顺序。 */ -export function removeQueuedChatTurn( - queue: readonly QueuedChatTurn[], - id: string, -): QueuedChatTurn[] { - return queue.filter((item) => item.id !== id); -} - -export function isChatTurnQueueFull(queue: readonly QueuedChatTurn[]): boolean { - return queue.length >= MAX_QUEUED_CHAT_TURNS; -} - -export function chatQueueFullNotice(): string { - return `队列已满(最多 ${MAX_QUEUED_CHAT_TURNS} 条),请等当前回合结束后再发送`; -} - -/** - * 队列 chip 上显示的文字:与真实消息同一个派生(`directCodexContentToPromptText`), - * 再压成单行并限长。 - * - * `assets` 与聊天消息渲染同源(`manifest.assets`)且必填:`@` 引用按显示名展开,chip - * 与消息正文逐字一致,不会露出 `@asset:…` 这种内部 id。 - */ -export function queuedChatTurnLabel( - turn: QueuedChatTurn, - assets: readonly GameCreationAppAssetManifestEntry[], -): string { - const text = directCodexContentToPromptText( - turn.userItem.content, - resourceLabelResolver(assets), - ) - .trim() - .replace(/\s+/gu, ' '); - if (!text) return '未命名消息'; - return text.length > 24 ? `${text.slice(0, 24)}…` : text; -} diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectComposer/pendingTurnChipLabel.ts b/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectComposer/pendingTurnChipLabel.ts new file mode 100644 index 000000000..1a3e58a9d --- /dev/null +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectComposer/pendingTurnChipLabel.ts @@ -0,0 +1,33 @@ +/** + * 待发消息 chip 上显示的那一行字。 + * + * 文案与真实消息用**同一个派生**(`directCodexContentToPromptText`),再压成单行并限长:chip 与 + * 用户气泡逐字一致,不会一处展开 `@` 引用、另一处露出 `@asset:…` 这种内部 id。 + * + * `assets` 与聊天消息渲染同源(`manifest.assets`)且必填:`@` 引用按显示名展开。 + */ +import type { GameCreationAppAssetManifestEntry } from '../../../../../../../../packages/shared/src/contracts/gameCreationApp'; +import { + directCodexContentToPromptText, + resourceLabelResolver, +} from '../../../../../features/project-workspace/resourceReferences'; +import type { DirectPendingTurn } from '../../conversation/directPendingTurns'; + +/** 单行 + 24 字上限:chip 是输入盒上的一条窄带,不展开整段需求。 */ +const PENDING_TURN_CHIP_MAX_CHARS = 24; + +export function pendingTurnChipLabel( + turn: DirectPendingTurn, + assets: readonly GameCreationAppAssetManifestEntry[], +): string { + const text = directCodexContentToPromptText( + turn.userItem.content, + resourceLabelResolver(assets), + ) + .trim() + .replace(/\s+/gu, ' '); + if (!text) return '未命名消息'; + return text.length > PENDING_TURN_CHIP_MAX_CHARS + ? `${text.slice(0, PENDING_TURN_CHIP_MAX_CHARS)}…` + : text; +} 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 232197e32..429c71343 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 @@ -22,15 +22,6 @@ import { currentPlatformSessionGeneration, requestPlatformSessionRefresh, } from '../../../../services/platformSession'; -import { - chatQueueFullNotice, - createQueuedChatTurn, - dequeueChatTurn, - enqueueChatTurn, - isChatTurnQueueFull, - type QueuedChatTurn, - removeQueuedChatTurn, -} from '../components/DirectProjectComposer/chatComposerQueue'; import { createDirectProjectTurnId, DIRECT_CODEX_AGENT_ID, @@ -48,6 +39,7 @@ import { toDirectCodexTurnAttachments, } from '../conversation/directCodexTurnAttachments'; import type { DirectCodexUserContentPart } from '../generated/DirectCodexUserContentPart'; +import type { DirectQueueRemovalOutcome } from '../generated/DirectQueueRemovalOutcome'; import type { DirectThreadHistorySlice } from '../generated/DirectThreadHistorySlice'; import type { DirectThreadItem } from '../generated/DirectThreadItem'; import { directHistoryAnchorGateToWaitFor } from '../history/directHistoryAnchorGate'; @@ -106,9 +98,9 @@ export type DirectProjectChatControllerProps = { * C 本地: 前端自己造(忙态、入队失败提示、终止说明)——**不造用户消息** * ▼ * 前端 - * useDirectThreadChatSubscription reducer:A + B 进同一份 state(turnRunning / history / live) + * useDirectThreadChatSubscription reducer:A + B 进同一份 state(turnRunning / history / live / pendingTurns) * ▼ - * useDirectProjectChatController 本地状态:localMessages / turnBusy / 队列 + * useDirectProjectChatController 本地状态:localMessages / IPC 在飞 / 附件与分页 * ▼ * DirectProjectChatView turns = buildDirectChatTurns(...);status = useDirectProjectTurnStatus(...) * ▼ @@ -119,7 +111,8 @@ export type DirectProjectChatControllerProps = { * - 项目对话历史(`.agent/conversations/project.jsonl`):持久,只有条目、**没有回合边界**, * 经历史切片读取(首屏按 `lastCompletedItemId` 锚定)。 * - 运行态事件(subscribe / consume / notify):进程内;`turn.started` / `turn.completed` 是**逻辑回合** - * 活跃与否的**唯一**判据(放行时成对发出,不再镜像 Codex 原生回合);可回收事件被回收后靠 + * 活跃与否的**唯一**判据(放行时成对发出,不再镜像 Codex 原生回合);`queue.enqueued` / + * `queue.removed` 是**待发消息队列**的唯一判据(同一个 reducer 折成 chip);可回收事件被回收后靠 * `lifecycle_anchor` 保住最新一条生命周期事件。 * 失败也走这条流:`turn.completed.failure` 自己带脱敏后的原因,reducer 把它落成本轮说明条目; * 命令返回那条通道只提供横幅与诊断,不再写聊天文案。 @@ -127,26 +120,29 @@ export type DirectProjectChatControllerProps = { * 用户消息一律来自宿主条目——本地乐观气泡已删(见 ADR「DirectProject命令入队化」), * 所以"用户那句话说没说出去"只有宿主条目一个来源。 * - * 一次发送的时序(第 2 → 3 步之间就是「本地已发出、宿主还没确认」的空窗:聊天区里没有这一轮的 + * 一次发送的时序(第 2 → 3 步之间就是「已交给宿主、宿主还没认领」的空窗:聊天区里没有这一轮的 * 任何条目,只有 composer 忙态与状态行): - * 1. 按下发送:`turnBusy=true`(同帧)。聊天区不动——这一轮在宿主放行之前不存在。 - * 2. `invoke('enqueue_direct_codex_turn')`:Rust 走完入队检查 → **入队**(排进待发消息队列)→ - * **立刻返回**。命令返回只说明入队成立;放行(登记占用 + `turn.started` + 离开队列)由宿主 + * 1. 按下发送:`commandInFlight=true`(同帧)。聊天区不动——这一轮在宿主放行之前不存在;草稿这时 + * 也先留着,清不清由命令的入队结果回答。 + * 2. `invoke('enqueue_direct_codex_turn')`:宿主走完入队检查 → **入队**(排进待发消息队列)→ + * **立刻返回**。命令返回只说明入队成立;放行(认领队首 + `turn.started` + `queue.removed`)由宿主 * 在队首就绪时自己完成,整轮的结果不再从这条通道回来,入队失败则返回结构化的 typed 错误。 - * 3. notify → consume → `turn.started`:reducer 的 `turnRunning=true`、`turnStartedAt`、`turnUserItemId`。 - * 4. `item.completed`(本轮用户条目下发):同身份条目已在历史里就合并进去,否则进 `live`。这是这一轮 + * 入队成立 → 清草稿;入队失败里"用户自己就能改"的那些 → **草稿原样留着**,另在聊天里补一条同级提示。 + * 3. `queue.enqueued`:chip 出现(队列在宿主,界面只是投影)。这一刻线程若正空闲,宿主已经在同一次 + * 认领里放行,`queue.removed{dispatched}` 与 `turn.started` 会在同一批 consume 里到达。 + * 4. `turn.started`:reducer 的 `turnRunning=true`、`turnStartedAt`、`turnUserItemId`。 + * 5. `item.completed`(本轮用户条目下发):同身份条目已在历史里就合并进去,否则进 `live`。这是这一轮 * 的用户气泡**第一次**出现在聊天区(发点在放行之后、起 codex 之前)。 - * 5. `item.delta` / `item.started` / `item.completed`:正文追加、工具卡片 upsert(先到定形、后到只补空)。 - * 6. `turn.completed`:`live` 并入 `history` 后清空,`turnEndedAt` 冻结,边界按身份盖到本轮开口条目上, + * 6. `item.delta` / `item.started` / `item.completed`:正文追加、工具卡片 upsert(先到定形、后到只补空)。 + * 7. `turn.completed`:`live` 并入 `history` 后清空,`turnEndedAt` 冻结,边界按身份盖到本轮开口条目上, * 收口计数 +1;带 `failure` 载荷时,说明条目已经在上一步由 reducer 落进 `live`,随本轮一起并入历史。 - * 7. **回合终态**(第 6 步的收口计数变化):结算本轮埋点 → 放行发送队列,顺序固定在这一处。 - * 8. 命令收尾(`finally`):刷新清单;只在**入队失败**时放掉忙态并出队(权限被拒那种 - * 「本轮从未发出但要继续出队」的路径也在这里收口),入队成立的那一轮交给第 3 / 7 步。 + * 8. **回合终态**(第 7 步的收口计数变化):按本轮身份结算埋点。**队列不在这里推进**——下一条什么时候 + * 走由宿主在占用释放时自己踢。 * - * 状态变量归属:reducer 的回合字段、收口计数与 `history` / `live` 只由 `directThreadChat.ts` 写; - * 本文件的 `turnBusy`(唯一入口 `beginTurnBusy` / `endTurnBusy`)、`localMessages`、发送队列、 - * 埋点句柄与分页 ref 只服务发送与展示;界面上的 - * 「这一轮在跑吗」只有一个派生入口 `useDirectProjectTurnStatus()`,两态判据与真值表在 + * 状态变量归属:reducer 的回合字段、待发消息投影、收口计数与 `history` / `live` 只由 + * `directThreadChat.ts` 写;本文件的 `commandInFlight`(唯一入口 `beginCommandInFlight` / + * `endCommandInFlight`,只覆盖 IPC 在飞)、`localMessages`、埋点句柄与分页 ref 只服务发送与展示; + * 界面上的「这一轮在跑吗」只有一个派生入口 `useDirectProjectTurnStatus()`,两态判据与真值表在 * `../conversation/directTurnPresentation.ts` 的 `DirectChatTurnState`,渲染时否定式读 * `state !== 'finished'`、肯定式读 `state === 'running'`(见 `DirectProjectTurn.tsx`)。 */ @@ -171,13 +167,10 @@ export function useDirectProjectChatController({ * 否则两次并发导入会拿着同一份草稿附件数各自放行,插进正文的芯片就超过上限。 */ const attachmentsPendingCountRef = useRef(0); - const [queuedTurns, setQueuedTurns] = useState([]); - const queuedTurnsRef = useRef([]); - queuedTurnsRef.current = queuedTurns; const [composerNotice, setComposerNotice] = useState(''); const [statusNotice, setStatusNotice] = useState(''); const [turnCancelling, setTurnCancelling] = useState(false); - const [turnBusy, setTurnBusy] = useState(false); + const [commandInFlight, setCommandInFlight] = useState(false); const [localMessages, setLocalMessages] = useState([]); // 订阅(subscribe/consume/notify)与聊天 reducer 状态在自己的 hook 里: // controller 只读投影后的条目与回合忙态,不再直接持有线程状态。 @@ -189,113 +182,76 @@ export function useDirectProjectChatController({ const currentTurnRunning = directThread.turnRunning; const currentTurnStartedAt = directThread.turnStartedAt; const completedTurnCount = directThread.completedTurnCount; + /** + * 本轮开口条目的 canonical 身份:收口之后仍留着,用来把"刚结束的是哪一轮"讲清楚(结算埋点读它)。 + */ + const currentTurnUserItemId = directThread.state.turnUserItemId; + /** + * 待发消息:**投影自事件流**,界面不持有第二份队列。入队、取消、放行都只让这条列表跟着变。 + */ + const pendingTurns = directThread.state.pendingTurns; const [historyHasMore, setHistoryHasMore] = useState(false); const historyOldestItemIdRef = useRef(null); const historyLoadingRef = useRef(false); - const queueSequenceRef = useRef(0); const projectPathRef = useRef(projectPath); projectPathRef.current = projectPath; const directTurnRunningRef = useRef(currentTurnRunning); directTurnRunningRef.current = currentTurnRunning; - const turnBusyRef = useRef(turnBusy); - turnBusyRef.current = turnBusy; + const commandInFlightRef = useRef(commandInFlight); + commandInFlightRef.current = commandInFlight; /** 已经处理过的收口回合数:与 reducer 的计数比较,识别"又有回合结束了"。 */ const handledCompletedTurnCountRef = useRef(0); /** - * 起这一轮时的收口计数:宿主没给开始事件时只能靠"计数变过"认领这一轮(见下面的 effect)—— - * 一轮可能在同一次 consume 里开始并结束,那时 `turnRunning` 的上升沿永远不会被观察到。 + * 每个已经交给宿主的回合的埋点句柄,按 `clientTurnId` 存。 + * + * 必须按身份存:队列在宿主侧,同一时刻可能有好几条待发消息(各自带着自己的 attempt id), + * 而成绩是**各自那一轮**收尾时在宿主侧入账的——单个槽位只会结算到最后一个,前面的全部静默丢掉。 + * 句柄活到本轮终态(见下面结算的那个 effect)。 */ - const busyBaselineTurnCountRef = useRef(0); - /** 最近一次渲染时的收口计数:权限确认后的重跑是异步续跑,闭包里的值可能过期。 */ - const completedTurnCountRef = useRef(completedTurnCount); - completedTurnCountRef.current = completedTurnCount; - /** 有回合结束了、但还不能出队(本地忙态还没放掉)时挂起,等忙态放掉再出队。 */ - const completionPendingRef = useRef(false); - /** - * 本轮的埋点句柄。它在命令返回之后仍然要活着:成绩是**回合末**才在宿主侧入账的, - * 入队返回时结算只会静默丢掉这一次埋点(见 `settlePendingRunAnalytics`)。 - */ - const pendingRunAnalyticsRef = useRef<{ - clientTurnId: string; - runAnalytics: ReturnType; - } | null>(null); + const pendingRunAnalyticsRef = useRef( + new Map>(), + ); useEffect(() => { setAttachmentNotice(''); setComposerNotice(''); setStatusNotice(''); - setQueuedTurns([]); - queuedTurnsRef.current = []; setLocalMessages([]); - busyBaselineTurnCountRef.current = 0; setHistoryHasMore(false); historyOldestItemIdRef.current = null; handledCompletedTurnCountRef.current = 0; - completionPendingRef.current = false; - pendingRunAnalyticsRef.current = null; + pendingRunAnalyticsRef.current.clear(); }, [projectPath]); useEffect(() => { - if (!enabled || (!turnBusy && !currentTurnRunning)) return; + if (!enabled || (!commandInFlight && !currentTurnRunning)) return; const timer = window.setInterval(() => { void requestPlatformSessionRefresh().catch(() => { // 回合本身会用原始鉴权错误收尾;保活不能把它替换成无关的后台错误。 }); }, DIRECT_CODEX_SESSION_KEEPALIVE_MS); return () => window.clearInterval(timer); - }, [enabled, turnBusy, currentTurnRunning]); + }, [enabled, commandInFlight, 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` 的收尾)。 + * 回合终态是**埋点结算**的唯一出口(入队失败走命令那条路,见 `runTurn` 的清理)。 * * 判据用 reducer 的**单调计数**而不是 `turnRunning` 的下降沿:一轮可能在同一次 consume 里 - * 开始并结束,那时下降沿永远不会出现,队列就永久卡住了。 + * 开始并结束,那时下降沿永远不会出现,这一轮的埋点就永远没人结算。 * - * TODO(发送队列):这条队列整体挪到宿主的待发消息队列(放行由 Thread Manager 自己驱动)。 + * 队列**不在这里推进**:待发消息归宿主,下一条什么时候走由 Thread Manager 在占用释放时自己踢。 */ useEffect(() => { if (!enabled) { handledCompletedTurnCountRef.current = completedTurnCount; - completionPendingRef.current = false; return; } - if (completedTurnCount !== handledCompletedTurnCountRef.current) { - handledCompletedTurnCountRef.current = completedTurnCount; - // 先结算埋点:宿主此刻已经把这一轮的成绩写进候选,再晚也还是同一轮。 - settlePendingRunAnalytics(); - completionPendingRef.current = true; - } - if ( - completionPendingRef.current && - !turnBusyRef.current && - !directTurnRunningRef.current - ) { - completionPendingRef.current = false; - dispatchNextQueuedTurn(); - } - // 出队只由"回合收口次数 + 本地忙态"驱动,队列本身不是依赖。 + if (completedTurnCount === handledCompletedTurnCountRef.current) return; + handledCompletedTurnCountRef.current = completedTurnCount; + // 先结算埋点:宿主此刻已经把这一轮的成绩写进候选,再晚也还是同一轮。 + settlePendingRunAnalytics(currentTurnUserItemId); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [enabled, completedTurnCount, turnBusy, currentTurnRunning]); + }, [enabled, completedTurnCount, currentTurnUserItemId]); useEffect(() => { if (!enabled || !projectPath) return; @@ -318,78 +274,83 @@ export function useDirectProjectChatController({ }, [enabled, projectPath]); /** - * 「本地命令在飞」的唯一起止点:按下发送时置上,宿主放行这一轮(`turn.started` 落进 reducer) - * 或这一轮明确没成立时放掉。 + * 「IPC 在飞」的唯一起止点:按下发送时置上,这一次 `invoke` 落地(成功或失败)时放掉。 * - * 它与原生忙态是两件事,所以**不在这里**按回合身份收口:入队化之后命令只等到入队就返回, - * 「宿主认领了吗」由 reducer 的 `turnRunning` 回答(`displayBusy` 是两者的并集)。 + * 它只盖命令这一段:宿主认领这一轮之后忙态由原生真相与待发消息条数回答(`displayBusy` 是三者 + * 的并集,见 `useDirectProjectTurnStatus`)。 */ - function beginTurnBusy() { - turnBusyRef.current = true; - setTurnBusy(true); - busyBaselineTurnCountRef.current = completedTurnCountRef.current; + function beginCommandInFlight() { + commandInFlightRef.current = true; + setCommandInFlight(true); } - function endTurnBusy() { - turnBusyRef.current = false; - setTurnBusy(false); + function endCommandInFlight() { + commandInFlightRef.current = false; + setCommandInFlight(false); } /** * 结算本轮埋点。只在**回合终态**调用:成绩是回合末才在宿主侧入账的,入队返回时就结算 * 会变成一次空操作(宿主找不到候选,静默丢弃)。入队失败那一轮没有候选,句柄由 `runTurn` 自己清掉。 + * + * 认领靠身份:`userItemId` 是本轮开口条目的 canonical id,与 `clientTurnId` 一一对应 + * (`directCodexConversationMessageId`)。身份缺失(旧事件 / 没有开口条目)时退一步结算**最早**的 + * 那一条——放行严格按队首顺序,收口顺序就是入队顺序,这一层没有歧义。 */ - function settlePendingRunAnalytics() { + function settlePendingRunAnalytics(turnUserItemId: string) { const pending = pendingRunAnalyticsRef.current; - pendingRunAnalyticsRef.current = null; - pending?.runAnalytics.settle(); + if (pending.size === 0) return; + const matched = [...pending.keys()].find( + (clientTurnId) => + directCodexConversationMessageId(clientTurnId, 'user') === + turnUserItemId, + ); + const key = matched ?? pending.keys().next().value; + if (key === undefined) return; + const handle = pending.get(key); + pending.delete(key); + handle?.settle(); } function appendLocalMessage(message: ChatMessage) { setLocalMessages((current) => [...current, message]); } - function dispatchNextQueuedTurn() { - if (turnBusyRef.current || directTurnRunningRef.current) return; - const { next, rest } = dequeueChatTurn(queuedTurnsRef.current); - if (!next) return; - completionPendingRef.current = false; - queuedTurnsRef.current = rest; - setQueuedTurns(rest); - if (rest.length === 0) setComposerNotice(''); - startTurn({ - clientTurnId: next.clientTurnId, - userItem: next.userItem, - }); - } - - function enqueueTurn(input: { - clientTurnId: string; - userItem: DirectProjectTurnInput['userItem']; - }) { - if (isChatTurnQueueFull(queuedTurnsRef.current)) { - setComposerNotice(chatQueueFullNotice()); - return false; + /** + * 取消一条**还没放行**的待发消息:由宿主在临界区里判断"这条还在不在队里",界面只把结果说给用户。 + * + * chip 的消失**不靠这里**:宿主真的移除了就会发 `queue.removed{cancelled}`,界面按事件投影撤掉它。 + * 所以这里不改本地任何队列状态——它不是事实源,改了就多出第二份。 + */ + async function cancelPendingTurn(clientTurnId: string) { + const nextProjectPath = projectPath; + const invoke = resolveTauriInvoke(); + if (!invoke || !nextProjectPath) { + setComposerNotice('需要在 Tauri App 内运行,才能取消待发消息'); + return; + } + try { + const outcome = await invoke( + 'remove_direct_project_pending_turn', + { projectPath: nextProjectPath, clientTurnId }, + ); + if (projectPathRef.current !== nextProjectPath) return; + setComposerNotice( + outcome === 'notFound' + ? '这条待发消息已经不在队列里' + : // 已放行的那一条不能按待发消息取消:它就是正在跑的这一轮,说明清楚该用什么入口。 + outcome === 'alreadyDispatched' + ? '这条消息已经发出,不能按待发消息取消;要停下正在跑的这一轮请点「终止」' + : '', + ); + } catch (error) { + if (projectPathRef.current !== nextProjectPath) return; + setComposerNotice( + `取消待发消息失败:${ + error instanceof Error ? error.message : String(error) + }`, + ); } - queueSequenceRef.current += 1; - const turn = createQueuedChatTurn({ - id: `queued-chat-turn-${Date.now()}-${queueSequenceRef.current}`, - clientTurnId: input.clientTurnId, - userItem: input.userItem, - createdAt: Date.now(), - }); - const next = enqueueChatTurn(queuedTurnsRef.current, turn); - queuedTurnsRef.current = next; - setQueuedTurns(next); - setComposerNotice('已加入发送队列,当前回合结束后自动发送'); - return true; - } - - function cancelQueuedTurn(id: string) { - const next = removeQueuedChatTurn(queuedTurnsRef.current, id); - queuedTurnsRef.current = next; - setQueuedTurns(next); - if (next.length === 0) setComposerNotice(''); } /** @@ -480,37 +441,23 @@ export function useDirectProjectChatController({ setComposerNotice(''); } - function submit(content: DirectCodexUserContentPart[]): boolean { + /** + * 提交草稿:**只有一条路**——交给宿主入队。 + * + * 本文件不再有"回合运行中就先排队"的分支:队列在宿主侧,忙不忙都由宿主自己判(队满、重复入队 + * 也由它回答)。返回值就是"草稿能不能清":入队失败里用户自己能改的那些返回 `false`,草稿留下。 + */ + async function submit( + content: DirectCodexUserContentPart[], + ): Promise { if (!hasMeaningfulDirectCodexContent(content)) return false; - if (turnBusyRef.current || currentTurnRunning) { - const clientTurnId = createDirectProjectTurnId(); - if ( - enqueueTurn({ - clientTurnId, - userItem: directCodexUserItemFromContent( - content, - directCodexConversationMessageId(clientTurnId, 'user'), - ), - }) - ) { - // 只清草稿:enqueueTurn 刚写入的「已加入发送队列」提示必须留给用户看到。 - clearDraftInput(); - return true; - } - return false; - } const clientTurnId = createDirectProjectTurnId(); const userItem = directCodexUserItemFromContent( content, directCodexConversationMessageId(clientTurnId, 'user'), ); - const turn: DirectProjectTurnInput = { - clientTurnId, - userItem, - }; clearPendingInput(); - startTurn(turn); - return true; + return startTurn({ clientTurnId, userItem }); } /** @@ -519,7 +466,7 @@ export function useDirectProjectChatController({ * 这里**不往聊天里写用户消息**:这一轮的用户气泡只来自宿主条目,所以"入队窗口期聊天区没有这一 * 轮"是正常现象(忙态与状态行负责告知)。权限确认后重跑的是同一份输入,只跳过权限检查。 */ - function startTurn(input: DirectProjectTurnInput) { + async function startTurn(input: DirectProjectTurnInput): Promise { const nextProjectPath = projectPath; if (!nextProjectPath || !projectId) { const message = resolveTauriInvoke() @@ -537,68 +484,52 @@ export function useDirectProjectChatController({ ), updatedAt: Date.now(), }); - return; + return true; } - beginTurnBusy(); - void (async () => { - let invoked = false; - // 命令是否接了单。只有它为真时,这一轮的收尾才交给宿主的事件。 - let turnAccepted = false; - // 权限被拒也要继续出队(见下),但出队必须发生在 finally 的 endTurnBusy() 之后: - // 在这里出队的话,下一轮刚设上的忙态会被紧接着的 finally 清掉。 - let queueAdvance = false; - try { - onRuntimeError(''); - if (!input.directPolicyChecked) { - const allowed = await ensureConversationWriteAllowed({ - projectPath: nextProjectPath, - onConfirmed: () => { - startTurn(directCodexPolicyRetryInput(input)); - }, - }); - if (!allowed) { - // 写权限门返回 false 且没有调用 onConfirmed(被策略拒绝、或读策略失败), - // 说明这一轮不会重跑;它已经被出队,必须继续出队,否则后面的排队消息会 - // 永久卡住。放忙态与出队都交给 finally 收口,这里只做标记。 - queueAdvance = true; - return; - } - } - const invoke = resolveTauriInvoke(); - if (!invoke) return; - invoked = true; - turnAccepted = await runTurn(invoke, nextProjectPath, input); - } catch (error) { - // 写权限门等前置步骤抛出时不能只留一个未处理的 rejection:回合会静默失败, - // 已经乐观追加的用户消息也没有任何解释。 - if (projectPathRef.current === nextProjectPath) { - onRuntimeError( - error instanceof Error ? error.message : String(error), - ); - } - } finally { - setTurnCancelling(false); - // 权限门没放行时本轮从未发出:不刷新清单,也不推进队列,等确认后重跑。 - if (invoked) { - await refreshDirectManifest(nextProjectPath); - } - // 收尾分两种:入队成立的整轮交给宿主的事件(`turn.started` 时退场、`turn.completed` - // 时出队与结算,见上面两个 effect);入队失败的那些路径没有任何事件会来,只能在这里收口。 - if (!turnAccepted) { - // 忙态必须早于出队放掉:出队会同步开始下一轮并设上它自己的忙态,清在它后面就等于 - // 把下一轮的忙态抹掉(composer 会以为可以并发发送)。 - endTurnBusy(); - // 出队条件:权限被拒的一轮从未发出,不受项目切换影响,照旧出队;命令真发出过又返回 - // 入队失败时要求项目没被换掉;没有 invoke(非 Tauri 环境)时不出队,避免空转。 - if ( - queueAdvance || - (invoked && projectPathRef.current === nextProjectPath) - ) { - dispatchNextQueuedTurn(); - } + beginCommandInFlight(); + /** + * 草稿能不能清:入队成立、交给权限确认流程、认不出的宿主 / 环境失败都算已交出; + * 只有"用户自己就能改"的入队失败(宿主已经给了同级提示)才留着草稿,别让人重打一遍。 + */ + let draftSafe = false; + let invoked = false; + try { + onRuntimeError(''); + if (!input.directPolicyChecked) { + const allowed = await ensureConversationWriteAllowed({ + projectPath: nextProjectPath, + onConfirmed: () => { + void startTurn(directCodexPolicyRetryInput(input)); + }, + }); + if (!allowed) { + // 写权限门返回 false 有两种:策略拒绝(这一轮不会重跑)与交给确认流程(确认后重跑同一份 + // 内容)。两种都不该再让用户按一次发送——内容已经在重跑的入参里(或者用户已经被明确拒绝), + // 所以草稿照清,与入队化之前一致。 + return true; } } - })(); + const invoke = resolveTauriInvoke(); + if (!invoke) return true; + invoked = true; + draftSafe = await runTurn(invoke, nextProjectPath, input); + } catch (error) { + // 写权限门等前置步骤抛出时不能只留一个未处理的 rejection:回合会静默失败, + // 这一轮的任何说明都没有出口。 + if (projectPathRef.current === nextProjectPath) { + onRuntimeError(error instanceof Error ? error.message : String(error)); + } + draftSafe = true; + } finally { + setTurnCancelling(false); + // 命令已经落地,IPC 在飞这一段结束;这一轮还在不在跑由事件流回答。 + endCommandInFlight(); + // 权限门没放行时本轮从未发出:不刷新清单,等确认后重跑。 + if (invoked) { + await refreshDirectManifest(nextProjectPath); + } + } + return draftSafe; } async function runTurn( @@ -607,18 +538,16 @@ export function useDirectProjectChatController({ input: DirectProjectTurnInput, ): Promise { // 命令返回 `Ok` 只说明**入队成立**:整轮怎么收场只由 `turn.completed` 事件回答。 - // 所以这里的返回值只服务队列放行——"这一轮有没有真的开始"。 - let turnAccepted = false; + // 这里的返回值只回答"草稿能不能清"——入队成立能清,用户自己能改的入队失败不能清。 + let draftSafe = false; try { const runAnalytics = beginDirectRunAnalytics( invoke, currentPlatformSessionGeneration, ); // 埋点句柄必须活过命令返回:成绩是回合末才入账的,settle 只能在回合终态发生。 - pendingRunAnalyticsRef.current = { - clientTurnId: input.clientTurnId, - runAnalytics, - }; + // 按 `clientTurnId` 存:队列在宿主侧,同一时刻可能有好几条待发消息各自带着自己的身份。 + pendingRunAnalyticsRef.current.set(input.clientTurnId, runAnalytics); await invoke('enqueue_direct_codex_turn', { projectPath: nextProjectPath, clientTurnId: input.clientTurnId, @@ -626,7 +555,7 @@ export function useDirectProjectChatController({ analyticsAttemptId: runAnalytics.nextAttempt(), ...(input.creationType ? { creationType: input.creationType } : {}), }); - turnAccepted = true; + draftSafe = true; // 清单刷新统一交给 startTurn 的 finally:成功与报错路径都覆盖,且只读一次。 } catch (error) { // 命令的入队失败是**结构化的**:命令返回 `Ok` 只说明入队成立,所以这条 catch 从入队化之后 @@ -635,17 +564,15 @@ export function useDirectProjectChatController({ // 只有结构化入队失败能证明"这一轮没成立"(入队失败不产生回合事件、也就不会有埋点候选), // 句柄才只清不发;非结构化错误(IPC 失败、命令 panic)可能发生在放行之后,那时必须留着句柄等 // `turn.completed` 来结算——提前清掉会让宿主侧这一轮的候选永远没有人结算。 - if ( - rejection && - pendingRunAnalyticsRef.current?.clientTurnId === input.clientTurnId - ) { - pendingRunAnalyticsRef.current = null; + if (rejection) { + pendingRunAnalyticsRef.current.delete(input.clientTurnId); } if (rejection) { const notice = directTurnEnqueueFailureNotice(rejection); if (notice) { // 认得的前置条件 / 参数 / 队列已满类入队失败:写成与用户消息同级的提示,不占状态行、不写运行错误、 - // 也不上报(用户自己就能改,上报只会变成噪声)。 + // 也不上报(用户自己就能改,上报只会变成噪声)。**草稿留着**——宿主拒绝了这一条,界面上 + // 那条同级提示说的是"为什么",用户改一改就能直接再按发送。 if (projectPathRef.current === nextProjectPath) { onRuntimeError(''); appendLocalMessage({ @@ -661,7 +588,7 @@ export function useDirectProjectChatController({ return false; } } - if (projectPathRef.current !== nextProjectPath) return false; + if (projectPathRef.current !== nextProjectPath) return true; // 认不出的入队失败(宿主 / 环境事实)与其它非结构化错误走同一条通道:上报 + 横幅。 void captureAgentRuntimeError(error, DIRECT_CODEX_AGENT_ID); // 入队失败文案优先:它是宿主生成的唯一一份(`Display` 或脱敏收口文案),比 `Error` 的形状更可信; @@ -673,7 +600,10 @@ export function useDirectProjectChatController({ '陶泥儿智能创作', true, ); - if (projectPathRef.current !== nextProjectPath) return false; + if (projectPathRef.current !== nextProjectPath) return true; + // 认不出的失败按"已交出"处理:它说不出用户改哪一处能变好(宿主 / 环境事实),留着草稿只会让 + // 用户以为没发出去、再按一次。 + draftSafe = true; // 回合失败的说明不由这里写:宿主已经把它放进了 `turn.completed.failure`,reducer 会把它落成 // 本轮最后一条条目(唯一来源)。**入队失败没有这条出口**——入队失败不产生回合事件,聊天里 // 那条乐观用户气泡后面永远不会再有任何说明,所以这里必须补一条同级提示;上报与横幅照旧保留。 @@ -693,7 +623,7 @@ export function useDirectProjectChatController({ // `.agent/runtime/errors`、应用日志与错误上报池里,界面只显示这一句话。 onRuntimeError(visibleMessage); } - return turnAccepted; + return draftSafe; } async function refreshDirectManifest(nextProjectPath: string) { @@ -710,7 +640,7 @@ export function useDirectProjectChatController({ if ( !invoke || !projectPath || - (!turnBusyRef.current && !currentTurnRunning) + (!commandInFlightRef.current && !currentTurnRunning) ) { onRuntimeError('当前没有正在运行的回合,无法终止。'); return; @@ -728,7 +658,7 @@ export function useDirectProjectChatController({ if (result?.outcome === 'released') { // 本地只放掉"命令在飞"这一层;这一轮的**回合边界**不在这里收口——宿主的兜底终止 // 已经把终态写进事件流,界面等那条 `turn.completed` 自己落到 reducer 上。 - endTurnBusy(); + endCommandInFlight(); onRuntimeError(''); setComposerNotice(message ?? '已结束这一轮占用,可以直接重新发送消息'); } else if (message) { @@ -850,7 +780,7 @@ export function useDirectProjectChatController({ appendLocalMessage, attachmentNotice, attachmentsImporting, - cancelQueuedTurn, + cancelPendingTurn, cancelTurn, composerNotice, directEntries, @@ -858,12 +788,12 @@ export function useDirectProjectChatController({ directTurnStartedAt: currentTurnStartedAt, historyHasMore, loadEarlierHistory, + commandInFlight, localMessages, - queuedTurns, + pendingTurns, startInitialTurn: startTurn, statusNotice, submit, - turnBusy, turnCancelling, uploadFiles, }; 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 b884211e9..86632de3e 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 @@ -1,5 +1,6 @@ import { useMemo } from 'react'; +import type { DirectPendingTurn } from '../conversation/directPendingTurns'; import type { DirectChatTurn, DirectChatTurnState, @@ -8,19 +9,21 @@ import type { /** * DirectProject「这一轮在跑吗」的唯一派生入口。 * - * 同一件事此前在四层里各叫一个名字(reducer 的 `turnRunning`、controller 的 `turnBusy`、 + * 同一件事此前在四层里各叫一个名字(reducer 的 `turnRunning`、controller 的 `turnBusy`(今 `commandInFlight`)、 * 视图里手拼的 `busy`、投影里的 `active`),读代码时无法判断谁该信谁。这里把它们的语义 * 一次讲清楚,组件只读这一个对象: * * - `nativeRunning`:**原生真相**。只由订阅 reducer 的 `turnRunning` 给出(`turn.started` * 已到、`turn.completed` 未到)。 - * - `commandInFlight`:**本地真相**。本次会话是否有一条本地在飞的回合(写权限门 → invoke → - * 宿主放行这一轮);它从按下发送那一刻就为真,与原生是否已经开始无关。入队化之后命令只 - * 等到入队就返回,所以它不等于"命令还没返回"——它活到宿主那一轮的开始事件被观察到为止。 - * - `displayBusy`:header / composer / 「陶泥儿正在处理」卡片该读的忙态,就是两者的并集: + * - `commandInFlight`:**本地真相**,而且只覆盖 **IPC 在飞**这一段:从按下发送到入队命令返回。 + * 入队化之后命令只等到入队就返回(放行由宿主自己完成),所以它不再是"这一轮还没被认领", + * 只是"这一次调用还没落地"。 + * - `pendingCount`:**队列真相**。宿主事件流折出来的待发消息条数(`queue.enqueued` / + * `queue.removed` 两条事件的投影);界面不持有第二份队列。 + * - `displayBusy`:header / composer / 「陶泥儿正在处理」卡片该读的忙态,是三者的并集: * 只要有一条成立就不能再接受新的发送。卡片读它而不是 `nativeRunning`:`turn.started` - * 要等宿主应答返回才发出,只认原生真相会让模型首 token 之前那十来秒没有任何「正在处理」 - * 的交代(2026-09-24 口令)。 + * 要等宿主应答返回才发出,只认原生真相会让模型首 token 之前那十来秒没有任何「正在处理」的 + * 交代(2026-09-24 口令)。 * - `latestTurnState`:最新一轮在界面上的两态(投影结果);没有回合时为 null。 * * 约定:新增"忙/在跑"类判据一律先落进这里,不要在组件里再拼布尔。 @@ -31,41 +34,54 @@ import type { export type DirectProjectTurnStatus = { nativeRunning: boolean; commandInFlight: boolean; + pendingCount: number; displayBusy: boolean; latestTurnState: DirectChatTurnState | null; }; export function deriveDirectProjectTurnStatus({ turnRunning, - turnBusy, + commandInFlight, + pendingTurns, turns, }: { turnRunning: boolean; - turnBusy: boolean; + commandInFlight: boolean; + pendingTurns: readonly DirectPendingTurn[]; turns: readonly DirectChatTurn[]; }): DirectProjectTurnStatus { const nativeRunning = Boolean(turnRunning); - const commandInFlight = Boolean(turnBusy); + const inFlight = Boolean(commandInFlight); + const pendingCount = pendingTurns.length; const latest = turns.length > 0 ? turns[turns.length - 1] : null; return { nativeRunning, - commandInFlight, - displayBusy: nativeRunning || commandInFlight, + commandInFlight: inFlight, + pendingCount, + displayBusy: nativeRunning || inFlight || pendingCount > 0, latestTurnState: latest ? latest.state : null, }; } export function useDirectProjectTurnStatus({ turnRunning, - turnBusy, + commandInFlight, + pendingTurns, turns, }: { turnRunning: boolean; - turnBusy: boolean; + commandInFlight: boolean; + pendingTurns: readonly DirectPendingTurn[]; turns: readonly DirectChatTurn[]; }): DirectProjectTurnStatus { return useMemo( - () => deriveDirectProjectTurnStatus({ turnRunning, turnBusy, turns }), - [turnRunning, turnBusy, turns], + () => + deriveDirectProjectTurnStatus({ + turnRunning, + commandInFlight, + pendingTurns, + turns, + }), + [turnRunning, commandInFlight, pendingTurns, turns], ); } diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/conversation/directPendingTurns.ts b/apps/ai-game-creator-shell/src/view/project-development/chat/conversation/directPendingTurns.ts new file mode 100644 index 000000000..72fa4dfad --- /dev/null +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/conversation/directPendingTurns.ts @@ -0,0 +1,49 @@ +/** + * 待发消息队列的**投影**。 + * + * 队列的事实源只有一个:运行态事件流(`queue.enqueued` / `queue.removed`),和回合边界一样。 + * 这里只有"把两条事件折成一份有序列表"的纯逻辑,不持有任何自己的状态、不做上限判断、不排期—— + * 队列归宿主(`docs/adr/【ADR】DirectProject命令入队化与待发消息队列归宿主-2026-09-24.md`), + * 界面只把宿主给的形状折成 chip。 + * + * 列表顺序就是事件的到达顺序(FIFO,宿主侧的认领也认队首),所以这里没有排序、没有身份表。 + */ +import type { DirectCodexUserItem } from '../generated/DirectCodexUserItem'; + +/** 一条还没放行的待发消息。 */ +export type DirectPendingTurn = { + /** 这条待发消息的回合身份;放行之后同一轮的 `turn.started` / `turn.completed` 用它。 */ + clientTurnId: string; + /** canonical 用户条目:chip 文案由它派生(与聊天正文同一个派生)。 */ + userItem: DirectCodexUserItem; + /** 入队那一刻的宿主毫秒钟。 */ + at: number; +}; + +/** + * 队尾追加。同一个 `clientTurnId` 已经在队里时**原样返回入参**(引用相同),让调用方能把 + * "没有变化"直接折回原状态,不制造一次无意义的重渲染。 + */ +export function enqueuePendingTurn( + pending: readonly DirectPendingTurn[], + turn: DirectPendingTurn, +): readonly DirectPendingTurn[] { + if (pending.some((item) => item.clientTurnId === turn.clientTurnId)) { + return pending; + } + return [...pending, turn]; +} + +/** + * 按身份移除,取消与放行共用(原因只决定界面话术,不改这里的形状)。 + * + * 身份不在队里时同样原样返回:放行事件与 `turn.started` 同批到达,重复处理不能把后来的 + * 条目一起带走。 + */ +export function removePendingTurn( + pending: readonly DirectPendingTurn[], + clientTurnId: string, +): readonly DirectPendingTurn[] { + const next = pending.filter((item) => item.clientTurnId !== clientTurnId); + return next.length === pending.length ? pending : next; +} 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 85d58327e..12a1d728b 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 @@ -1,10 +1,11 @@ /** * DirectProject 聊天 reducer:把运行态事件与历史切片归并成同一份聊天条目。 * - * 事实源只有一个——项目对话历史;运行态事件只负责"当前回合"。顺序 = 历史文件顺序 + - * 运行态独有条目。这里不做可见性判断(那是投影的事):DirectProject 同一时刻只有一个回合在跑, - * `turn.started` / `turn.completed` 只切换"是否还在跑"这一个布尔;回合身份只用原生生命周期 - * 事件自带的 canonical user identity(`userItemId`)做展示边界关联,不新建回合注册表。 + * 事实源只有一个——项目对话历史;运行态事件只负责"当前回合"与"还没放行的待发消息"。顺序 = 历史 + * 文件顺序 + 运行态独有条目。这里不做可见性判断(那是投影的事):DirectProject 同一时刻只有一个 + * 回合在跑,`turn.started` / `turn.completed` 只切换"是否还在跑"这一个布尔;回合身份只用原生生命 + * 周期事件自带的 canonical user identity(`userItemId`)做展示边界关联,不新建回合注册表。 + * 待发消息同样是事件折出来的列表(`queue.enqueued` / `queue.removed`),没有第二份队列状态。 * * 失败(`turn.completed.status === "failed"`)不是第二套生命周期:终态还是同一个事件,只是带了 * `failure` 载荷。这里把载荷落成本轮最后一条说明条目再走同一个收口函数——失败文案的唯一来源 @@ -17,6 +18,11 @@ import type { DirectThreadEvent } from '../generated/DirectThreadEvent'; import type { DirectThreadHistorySlice } from '../generated/DirectThreadHistorySlice'; import type { DirectThreadItem } from '../generated/DirectThreadItem'; import type { DirectThreadSubscriptionBootstrap } from '../generated/DirectThreadSubscriptionBootstrap'; +import { + type DirectPendingTurn, + enqueuePendingTurn, + removePendingTurn, +} from './directPendingTurns'; import { projectDirectThreadItem } from './directThreadItemProjection'; import { directTurnFailureItemId, @@ -70,7 +76,7 @@ export type DirectThreadChatState = { * 的先后决定。 * * 它不等于界面上的「这一轮在跑吗」:本地已发出、宿主还没回 `turn.started` 的那一段窗口里它为假, - * 那时聊天区里没有这一轮的任何条目(本地不再造乐观气泡),忙态由 controller 的 `turnBusy` 出。 + * 那时聊天区里没有这一轮的任何条目(本地不再造乐观气泡),忙态由 `useDirectProjectTurnStatus` 的 `displayBusy` 出。 * 界面侧的两态与判据见 `directTurnPresentation.ts` 的 `DirectChatTurnState`。 */ turnRunning: boolean; @@ -88,12 +94,22 @@ export type DirectThreadChatState = { /** * 已经收口的回合数(单调递增,项目切换时随整份状态重置)。 * - * 它是"回合完成"这个事实**唯一的计数**,给上层放行发送队列与结算埋点用。为什么要计数 - * 而不是看 `turnRunning` 的下降沿:一轮可能在**同一次 consume** 里开始并结束(放行后 - * 立刻失败),那时 `turnRunning` 从头到尾没有被观察到真,下降沿永远不会来。计数是状态, - * 批量到达也一样看得见。 + * 它是"回合完成"这个事实**唯一的计数**,只给上层结算埋点用(放行不归它管——队列在宿主侧, + * 由 Thread Manager 自己认领队首)。为什么要计数而不是看 `turnRunning` 的下降沿:一轮可能在 + * **同一次 consume** 里开始并结束(放行后立刻失败),那时 `turnRunning` 从头到尾没有被观察到 + * 真,下降沿永远不会来。计数是状态,批量到达也一样看得见。 */ completedTurnCount: number; + /** + * 还没放行的待发消息,保持入队顺序(FIFO)。 + * + * 它**不是**第二份队列状态:入队与移除都由 `queue.enqueued` / `queue.removed` 两条运行态事件 + * 折出来,与回合边界同一个事实源。因此新订阅者拿到 bootstrap 的 live-set 就等于拿到了此刻的 + * 队列(在队条目不可回收),另一个窗口、离开工作台再回来看到的都是同一份。 + * + * 上限、幂等、认领顺序全部在宿主侧,这里不判、不排期、不自己放行。 + */ + pendingTurns: readonly DirectPendingTurn[]; /** 历史切片条目,保持文件顺序。 */ history: DirectChatEntry[]; /** 当前回合的运行态条目,保持到达顺序;回合结束即并入历史并清空。 */ @@ -107,6 +123,7 @@ export function emptyDirectThreadChatState(): DirectThreadChatState { turnEndedAt: 0, turnUserItemId: '', completedTurnCount: 0, + pendingTurns: [], history: [], live: [], }; @@ -427,6 +444,29 @@ export function reduceDirectThreadEvent( } return upsertLiveEntry(state, entry); } + case 'queue.enqueued': { + // 在队条目的身份就是 `clientTurnId`:宿主对同一条消息的重复入队返回同一次成功,这里也按同一 + // 身份折回原状态,不让同一句话在 chip 里出现两次。 + const pendingTurns = enqueuePendingTurn(state.pendingTurns, { + clientTurnId: event.clientTurnId, + userItem: event.userItem, + at: event.at, + }); + return pendingTurns === state.pendingTurns + ? state + : { ...state, pendingTurns }; + } + case 'queue.removed': { + // 取消与放行都只是"这条不再是待发消息":放行那一批里同时有 `turn.started`,用户气泡由宿主 + // 条目给出,chip 消失与气泡出现在同一批 consume 里,中间没有空窗。 + const pendingTurns = removePendingTurn( + state.pendingTurns, + event.clientTurnId, + ); + return pendingTurns === state.pendingTurns + ? state + : { ...state, pendingTurns }; + } case 'request': // 审批 / 提问只影响面板交互,不并入聊天条目。 return state; 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 51c3db79e..132940ace 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 @@ -1,14 +1,5 @@ import { directCodexUserItemFromContent } from '../../src/features/project-workspace/resourceReferences'; import * as platformSession from '../../src/services/platformSession'; -import { - chatQueueFullNotice, - createQueuedChatTurn, - dequeueChatTurn, - enqueueChatTurn, - isChatTurnQueueFull, - queuedChatTurnLabel, - removeQueuedChatTurn, -} from '../../src/view/project-development/chat/components/DirectProjectComposer/chatComposerQueue.ts'; import { appendDictationText, resolveSpeechRecognitionCtor, @@ -18,11 +9,16 @@ import { VOICE_INPUT_UNSUPPORTED_MESSAGE, } from '../../src/view/project-development/chat/components/DirectProjectComposer/chatComposerVoice.ts'; import { ComposerVoiceButton } from '../../src/view/project-development/chat/components/DirectProjectComposer/ComposerControls.tsx'; +import { pendingTurnChipLabel } from '../../src/view/project-development/chat/components/DirectProjectComposer/pendingTurnChipLabel.ts'; +import { + type DirectPendingTurn, + enqueuePendingTurn, + removePendingTurn, +} from '../../src/view/project-development/chat/conversation/directPendingTurns.ts'; import { act, createGameCreationAppManifest, createProjectChatRuntimeHarness, - emptyProjectPolicy, expect, fireEvent, it, @@ -214,38 +210,111 @@ function installFakeSpeechRecognition(): { }; } -/** 排队回合只持 canonical user item;展示文案由它派生。 */ -function queuedTurn( - id: string, +/** + * 一条待发消息的投影形状:事件带 canonical user item,chip 文案由它派生。 + * + * 身份换算与生产同源(`direct-codex:{clientTurnId}:{role}`),不另造一套。 + */ +function pendingTurn( clientTurnId: string, text: string, - createdAt: number, -) { - return createQueuedChatTurn({ - id, + at = 1, +): DirectPendingTurn { + return { clientTurnId, userItem: directCodexUserItemFromContent( [{ type: 'input_text', text }], - `${clientTurnId}:user`, + `direct-codex:${clientTurnId}:user`, ), - createdAt, - }); + at, + }; +} + +/** + * 宿主的队列动作在测试里的替身:入队、取消、回合收尾后放行队首。 + * + * 队列归宿主之后界面看到的每一步都来自事件——入队发 `queue.enqueued`,线程空闲时在同一次认领里 + * 追加 `queue.removed{dispatched}` 与 `turn.started`,取消发 `queue.removed{cancelled}`。 + * 这里只做 Thread Manager 会做的那几件事,顺序就是入队顺序(认领永远认队首)。 + */ +function hostQueue( + harness: ReturnType, + /** 打开面板时线程上是否已经有一条没收口的回合(恢复出来的那一轮)。 */ + { running: alreadyRunning = false }: { running?: boolean } = {}, +) { + const queued: Array<{ clientTurnId: string; userItem: unknown }> = []; + let running = alreadyRunning; + const dispatchHeadIfIdle = () => { + if (running) return; + const head = queued.shift(); + if (!head) return; + running = true; + harness.emitDirectThreadEvents( + { + type: 'queue.removed', + clientTurnId: head.clientTurnId, + reason: 'dispatched', + at: 2, + }, + { + type: 'turn.started', + at: 2, + userItemId: `direct-codex:${head.clientTurnId}:user`, + }, + ); + }; + return { + /** 入队命令的宿主实现:入队 → 线程空闲就顺手放行。 */ + enqueue(args?: Record) { + const clientTurnId = String(args?.clientTurnId ?? ''); + queued.push({ clientTurnId, userItem: args?.userItem }); + harness.emitDirectThreadEvents({ + type: 'queue.enqueued', + clientTurnId, + userItem: args?.userItem, + at: 1, + }); + dispatchHeadIfIdle(); + return null; + }, + /** 取消命令的宿主实现:真的在队里才移除,并广播原因。 */ + cancel(args?: Record) { + const clientTurnId = String(args?.clientTurnId ?? ''); + const index = queued.findIndex( + (item) => item.clientTurnId === clientTurnId, + ); + if (index < 0) return running ? 'alreadyDispatched' : 'notFound'; + queued.splice(index, 1); + harness.emitDirectThreadEvents({ + type: 'queue.removed', + clientTurnId, + reason: 'cancelled', + at: 3, + }); + return 'removed'; + }, + /** 回合收尾:宿主在占用释放那一刻踢一脚,队首接上。 */ + complete(status = 'completed') { + running = false; + harness.emitDirectThreadEvents({ type: 'turn.completed', status, at: 4 }); + dispatchHeadIfIdle(); + }, + }; } export function registerChatComposerControlTests() { - it('队列 chip 的 @ 引用按 manifest 显示名展开,不露出内部 resourceId', () => { - const turn = createQueuedChatTurn({ - id: 'turn-ref', + it('待发消息 chip 的 @ 引用按 manifest 显示名展开,不露出内部 resourceId', () => { + const turn: DirectPendingTurn = { clientTurnId: 'client-ref', userItem: directCodexUserItemFromContent( [ { type: 'input_text', text: '用这张图改一下' }, { type: 'agc_resource_reference', resourceId: 'asset:hero' }, ], - 'client-ref:user', + 'direct-codex:client-ref:user', ), - createdAt: 1, - }); + at: 1, + }; const manifestAssets = [ { id: 'asset:hero', @@ -258,57 +327,77 @@ export function registerChatComposerControlTests() { // chip 文案与聊天输入区同口径:用户看到的是 `@显示名`,不是 `@内部 id`; // 引用 token 前后各留一个空白(出站文本与反解析口径自洽)。 - expect(queuedChatTurnLabel(turn, manifestAssets)).toBe( + expect(pendingTurnChipLabel(turn, manifestAssets)).toBe( '用这张图改一下 @hero', ); + // 空内容也给一行可显示的字,不让 chip 变成一条空白带。 + expect(pendingTurnChipLabel(pendingTurn('client-empty', ''), [])).toBe( + '未命名消息', + ); }); - it('keeps queued chat turns in FIFO order and drops only the cancelled one', () => { - const first = queuedTurn('turn-1', 'client-1', '第一条', 1); - const second = queuedTurn('turn-2', 'client-2', '第二条', 2); - const third = queuedTurn('turn-3', 'client-3', '第三条', 3); + it('folds the pending queue from events: FIFO append, identity removal, idempotent enqueue', () => { + let pending: readonly DirectPendingTurn[] = []; + pending = enqueuePendingTurn(pending, pendingTurn('client-1', '第一条')); + pending = enqueuePendingTurn(pending, pendingTurn('client-2', '第二条')); + pending = enqueuePendingTurn(pending, pendingTurn('client-3', '第三条')); - let queue = enqueueChatTurn([], first); - queue = enqueueChatTurn(queue, second); - queue = enqueueChatTurn(queue, third); - // 同一条消息重复入队不得变成两次发送。 - expect(enqueueChatTurn(queue, third)).toHaveLength(3); - - // FIFO:先入先出,不丢、不乱序。 - const firstOut = dequeueChatTurn(queue); - expect(firstOut.next?.clientTurnId).toBe('client-1'); - expect(firstOut.next && queuedChatTurnLabel(firstOut.next, [])).toBe( + // 顺序就是事件顺序(宿主认领队首,所以界面不许自己排)。 + expect(pending.map((turn) => pendingTurnChipLabel(turn, []))).toEqual([ '第一条', - ); - expect(firstOut.rest.map((turn) => queuedChatTurnLabel(turn, []))).toEqual([ '第二条', '第三条', ]); - // 单条取消只移除那一条,顺序不变。 - expect( - removeQueuedChatTurn(firstOut.rest, 'turn-2').map((turn) => - queuedChatTurnLabel(turn, []), - ), - ).toEqual(['第三条']); - expect(removeQueuedChatTurn(firstOut.rest, 'turn-missing')).toHaveLength(2); + // 同一条消息重复入队不产生第二个 chip:宿主的入队是幂等的,投影这一层也按身份折回原状态 + // (引用不变,调用方能直接跳过重渲染)。 + expect(enqueuePendingTurn(pending, pendingTurn('client-3', '第三条'))).toBe( + pending, + ); - // 空队列出队不报错、也不产生"幽灵消息"。 - expect(dequeueChatTurn([]).next).toBeNull(); + // 取消与放行共用同一条移除:按身份删,其余顺序不变。 + expect( + removePendingTurn(pending, 'client-2').map((turn) => + pendingTurnChipLabel(turn, []), + ), + ).toEqual(['第一条', '第三条']); + // 身份不在队里时原样返回(放行事件与 `turn.started` 同批到达、或事件被重放)。 + expect(removePendingTurn(pending, 'client-missing')).toBe(pending); }); - it('reports a readable reason instead of silently dropping a full queue', () => { - let queue: ReturnType[] = []; - for (let index = 0; index < 5; index += 1) { - queue = enqueueChatTurn( - queue, - queuedTurn(`turn-${index}`, `client-${index}`, `第 ${index} 条`, index), - ); - } - expect(isChatTurnQueueFull(queue)).toBe(true); - expect(chatQueueFullNotice()).toContain('队列已满'); - expect(chatQueueFullNotice()).toContain('5'); - expect(removeQueuedChatTurn(queue, 'turn-0')).toHaveLength(4); + it('keeps the draft and explains why when the host rejects the enqueue as full', async () => { + // 队列上限只在宿主一处(`MAX_PENDING_DIRECT_TURNS`):界面不再自己数一份、不再自己造一句 + // 提示,只把 typed 入队失败显示出来——**草稿留着**,用户等前面几条发完就能直接再按发送。 + const { invoke, surface } = await openDirectCodexSurface({ + enqueue_direct_codex_turn: () => { + throw { + error: { type: 'queueFull', limit: 5 }, + message: '待发消息已达上限(最多 5 条),请等前面几条发完再发送', + }; + }, + }); + const composer = within(surface).getByLabelText('陶泥儿对话内容'); + await submitDirectTurn(surface, composer, '第六条消息'); + + const conversation = await within(surface).findByLabelText('陶泥儿消息'); + await waitFor(() => { + expect( + within(conversation).getByText( + '待发消息已达上限(最多 5 条),请等前面几条发完再发送', + ), + ).not.toBeNull(); + }); + // 草稿没丢:这条消息还在输入盒里,用户改一改或等一会儿就能重发。 + expect(composer.textContent ?? '').toContain('第六条消息'); + expect( + within(surface).getByRole('button', { name: '发送' }), + ).not.toBeNull(); + // 入队没成立就是没成立:只发了一次命令,也不会有回合事件来结算埋点。 + expect( + invoke.mock.calls.filter( + ([command]) => command === 'enqueue_direct_codex_turn', + ), + ).toHaveLength(1); }); it('degrades the voice input button with a readable hint when speech recognition is missing', async () => { @@ -478,14 +567,14 @@ export function registerChatComposerControlTests() { }); }); - it('queues messages sent while a turn runs, cancels one chip, and sends the rest in order', async () => { + it('enqueues messages sent while a turn runs, cancels one chip, and never sends the same message twice', async () => { + let host: ReturnType | null = null; const { invoke, surface, harness } = await openDirectCodexSurface({ - // 命令只入队(放行由 Thread Manager 自己完成),随后立刻返回。 - enqueue_direct_codex_turn: () => { - harness.emitDirectThreadEvents({ type: 'turn.started' }); - return Promise.resolve(null); - }, + // 宿主替身:入队就发 `queue.enqueued`,线程空闲时同一次认领里放行队首。 + enqueue_direct_codex_turn: (args) => host?.enqueue(args), + remove_direct_project_pending_turn: (args) => host?.cancel(args), }); + host = hostQueue(harness); const composer = within(surface).getByLabelText('陶泥儿对话内容'); await submitDirectTurn(surface, composer, '第一条消息'); await waitFor(() => { @@ -494,16 +583,14 @@ export function registerChatComposerControlTests() { expectDirectTurnWithText('第一条消息'), ); }); - // 回合运行中:发送钮位置变成终止钮。 + // 第一条被立刻放行:回合运行中,发送钮位置变成终止钮,队列里没有它(它不是待发消息)。 expect( await within(surface).findByRole('button', { name: '终止' }), ).not.toBeNull(); + expect(within(surface).queryByLabelText('待发送消息队列')).toBeNull(); await setComposerText(composer, '第二条消息'); submitComposerForm(composer); - await waitFor(() => { - expect(within(surface).getByText('第二条消息')).not.toBeNull(); - }); await setComposerText(composer, '第三条消息'); submitComposerForm(composer); @@ -520,42 +607,73 @@ export function registerChatComposerControlTests() { expect.stringContaining('第三条消息'), ]); - // 单条取消:只移除第二条,第三条保留。 + // 单条取消:命令交给宿主判,界面按 `queue.removed{cancelled}` 撤掉那一条,顺序不变。 fireEvent.click( - within(queue).getByRole('button', { name: '取消排队消息 第二条消息' }), + within(queue).getByRole('button', { name: '取消待发消息 第二条消息' }), ); + await waitFor(() => { + expect( + invoke.mock.calls.filter( + ([command]) => command === 'remove_direct_project_pending_turn', + ), + ).toHaveLength(1); + }); await waitFor(() => { expect(within(queue).getAllByRole('listitem')).toHaveLength(1); }); + expect(queue.textContent ?? '').toContain('第三条消息'); - // 队列放行听的是**回合终态**,不是命令返回:入队之后命令早就回来了。 + // 回合收尾 → 宿主放行剩下的那条:chip 消失与它自己的回合开始在同一批事件里。 act(() => { - harness.emitDirectThreadEvents({ - type: 'turn.completed', - status: 'completed', - }); - }); - await waitFor(() => { - expect(invoke).toHaveBeenCalledWith( - 'enqueue_direct_codex_turn', - expectDirectTurnWithText('第三条消息'), - ); - }); - // 发出去的就是「第一条 + 第三条」:被取消的第二条不在其中,队列顺序也不乱。 - const sentTexts = invoke.mock.calls - .filter(([command]) => command === 'enqueue_direct_codex_turn') - .map(([, args]) => directTurnInputText(args)); - expect(sentTexts).toEqual(['第一条消息', '第三条消息']); - - act(() => { - harness.emitDirectThreadEvents({ - type: 'turn.completed', - status: 'completed', - }); + host?.complete(); }); await waitFor(() => { expect(within(surface).queryByLabelText('待发送消息队列')).toBeNull(); }); + + // 关键不变式:排队的那条**没有**再走一次发送命令,入队就是一次性动作。 + expect( + invoke.mock.calls + .filter(([command]) => command === 'enqueue_direct_codex_turn') + .map(([, args]) => directTurnInputText(args)), + ).toEqual(['第一条消息', '第二条消息', '第三条消息']); + }); + + it('tells the user when the chip they tried to cancel has already been dispatched', async () => { + // 已放行的那一条不能按待发消息取消:宿主的 typed 结果把这件事讲清楚,界面照说,不自作主张。 + const { invoke, surface, harness } = await openDirectCodexSurface({ + // 入队成立但并不放行(线程上已经有别的回合在跑):chip 留着,取消才有东西可取消。 + enqueue_direct_codex_turn: (args) => { + harness.emitDirectThreadEvents({ + type: 'queue.enqueued', + clientTurnId: String(args?.clientTurnId ?? ''), + userItem: args?.userItem, + at: 1, + }); + return null; + }, + remove_direct_project_pending_turn: () => 'alreadyDispatched', + }); + const composer = within(surface).getByLabelText('陶泥儿对话内容'); + await submitDirectTurn(surface, composer, '还没跑的这条'); + const queue = await within(surface).findByLabelText('待发送消息队列'); + expect(within(queue).getByText('还没跑的这条')).not.toBeNull(); + + fireEvent.click( + within(queue).getByRole('button', { name: '取消待发消息 还没跑的这条' }), + ); + await waitFor(() => { + expect( + within(surface).getByText( + '这条消息已经发出,不能按待发消息取消;要停下正在跑的这一轮请点「终止」', + ), + ).not.toBeNull(); + }); + expect( + invoke.mock.calls.filter( + ([command]) => command === 'remove_direct_project_pending_turn', + ), + ).toHaveLength(1); }); it('keeps the accept window silent in the chat and busy in the composer', async () => { @@ -748,76 +866,55 @@ export function registerChatComposerControlTests() { }); }); - it('keeps the next queued turn busy when the write gate refuses the running one', async () => { - const pending: Array<{ resolve: (value: string) => void }> = []; - const deferredPolicies: Array<(value: unknown) => void> = []; - let policyAllowsWrite = false; + it('does not fire a second enqueue while the first one is still in flight', async () => { + // 草稿要等命令的入队结果才知道能不能清,所以这一段提交是"在飞"的。这期间第二次提交必须被挡住: + // 同一份草稿并发出两条就是两条消息(宿主按 `clientTurnId` 幂等也救不了——第二次是新身份)。 + const pending: Array<{ resolve: (value: null) => void }> = []; const { invoke, surface } = await openDirectCodexSurface({ - read_project_permission_policy: () => { - if (policyAllowsWrite) return Promise.resolve(emptyProjectPolicy()); - return new Promise((resolve) => { - deferredPolicies.push(resolve); - }); - }, enqueue_direct_codex_turn: () => - new Promise((resolve) => { + new Promise((resolve) => { pending.push({ resolve }); }), }); const composer = within(surface).getByLabelText('陶泥儿对话内容'); - await submitDirectTurn(surface, composer, '被拒的那条'); - - // 权限门还没回,先把第二条排进队列:后面那条要等被拒的这一轮出队才会发出去。 - await setComposerText(composer, '后面那条'); - submitComposerForm(composer); - const queue = await within(surface).findByLabelText('待发送消息队列'); - expect(within(queue).getByText('后面那条')).not.toBeNull(); - - // 写权限门拒绝这一轮(策略要求确认,且此刻没有 onConfirmed):这一轮不会重跑, - // 队列必须继续走,而它出队后那一轮仍要算「命令在飞」。 - policyAllowsWrite = true; - await act(async () => { - for (const resolve of deferredPolicies.splice(0)) { - resolve({ - path: '.agent/policy.json', - policy: { - deniedCommands: [], - confirmCommands: ['conversation.write'], - }, - }); - } - }); - + await submitDirectTurn(surface, composer, '在飞的那条'); await waitFor(() => { - expect(invoke).toHaveBeenCalledWith( - 'enqueue_direct_codex_turn', - expectDirectTurnWithText('后面那条'), - ); + expect( + invoke.mock.calls.filter( + ([command]) => command === 'enqueue_direct_codex_turn', + ), + ).toHaveLength(1); }); - // 被出队的那一轮还在飞:上一轮的收尾不得把它刚设上的忙态清掉。 - expect(within(surface).queryByRole('button', { name: '发送' })).toBeNull(); - expect( - within(surface).getByRole('button', { name: '终止' }), - ).not.toBeNull(); + submitComposerForm(composer); + expect( + invoke.mock.calls.filter( + ([command]) => command === 'enqueue_direct_codex_turn', + ), + ).toHaveLength(1); + + // 命令落地(入队成立):草稿清掉、IPC 在飞这一段结束,用户能接着发下一条。 await act(async () => { - pending[0]?.resolve('回复'); + pending[0]?.resolve(null); + }); + await waitFor(() => { + expect( + within(surface).getByRole('button', { name: '发送' }), + ).not.toBeNull(); }); }); - it('restores a running DirectProject turn, queues the next message, and dispatches it on turn.completed', async () => { - const pending: Array<{ resolve: (value: string) => void }> = []; + it('queues the next message on the host while a turn is already running', async () => { + // 回合已经在跑(比如刚切回工作台):这时发送只做一件事——交给宿主入队,chip 由事件投影出来。 + let host: ReturnType | null = null; const { invoke, surface, harness } = await openDirectCodexSurface( - { - enqueue_direct_codex_turn: () => - new Promise((resolve) => { - pending.push({ resolve }); - }), - }, + { enqueue_direct_codex_turn: (args) => host?.enqueue(args) }, (directHarness) => { directHarness.emitDirectThreadEvents({ type: 'turn.started' }); }, ); + // 线程上已经有回合在跑:入队成立但不放行,chip 就留着。 + host = hostQueue(harness, { running: true }); const composer = within(surface).getByLabelText('陶泥儿对话内容'); expect( @@ -825,37 +922,35 @@ export function registerChatComposerControlTests() { ).not.toBeNull(); expect(within(surface).queryByRole('button', { name: '发送' })).toBeNull(); - await setComposerText(composer, '恢复后排队的消息'); + // 回合运行中发送钮位置是终止钮,所以这里走表单提交(Enter 也是这条路)。 + await setComposerText(composer, '排在后面的消息'); submitComposerForm(composer); const queue = await within(surface).findByLabelText('待发送消息队列'); - expect(within(queue).getByText('恢复后排队的消息')).not.toBeNull(); - expect( - invoke.mock.calls.filter( - ([command]) => command === 'enqueue_direct_codex_turn', - ), - ).toHaveLength(0); - - act(() => { - harness.emitDirectThreadEvents({ - type: 'turn.completed', - status: 'completed', - }); - }); await waitFor(() => { - expect(invoke).toHaveBeenCalledWith( - 'enqueue_direct_codex_turn', - expectDirectTurnWithText('恢复后排队的消息'), - ); + expect(within(queue).getByText('排在后面的消息')).not.toBeNull(); }); + // 入队就是一次性动作:这条消息只有这一次命令,没有"先存本地、之后由这个窗口再发一次"。 expect( invoke.mock.calls.filter( ([command]) => command === 'enqueue_direct_codex_turn', ), ).toHaveLength(1); - await act(async () => { - pending[0]?.resolve('排队回合回复'); + // 宿主在回合收尾后自己放行队首:chip 消失,那一轮的回合开始事件同批到达。 + act(() => { + host?.complete(); }); + await waitFor(() => { + expect(within(surface).queryByLabelText('待发送消息队列')).toBeNull(); + }); + expect( + invoke.mock.calls.filter( + ([command]) => command === 'enqueue_direct_codex_turn', + ), + ).toHaveLength(1); + expect( + await within(surface).findByRole('button', { name: '终止' }), + ).not.toBeNull(); }); it('terminates the running turn and returns the composer to the idle state', async () => { diff --git a/apps/ai-game-creator-shell/tests/directProjectTurnStatus.test.ts b/apps/ai-game-creator-shell/tests/directProjectTurnStatus.test.ts index 9c8e210b1..779b70314 100644 --- a/apps/ai-game-creator-shell/tests/directProjectTurnStatus.test.ts +++ b/apps/ai-game-creator-shell/tests/directProjectTurnStatus.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest'; import { deriveDirectProjectTurnStatus } from '../src/view/project-development/chat/controller/useDirectProjectTurnStatus'; +import type { DirectPendingTurn } from '../src/view/project-development/chat/conversation/directPendingTurns'; import type { DirectChatTurn } from '../src/view/project-development/chat/conversation/directTurnPresentation'; const turn = (key: string, state: DirectChatTurn['state']): DirectChatTurn => ({ @@ -13,62 +14,91 @@ const turn = (key: string, state: DirectChatTurn['state']): DirectChatTurn => ({ endedAt: 0, }); +/** 待发消息:这里只关心条数,内容与 chip 文案无关。 */ +const pending = (clientTurnId: string): DirectPendingTurn => ({ + clientTurnId, + userItem: { + type: 'message', + role: 'user', + id: `direct-codex:${clientTurnId}:user`, + content: [{ type: 'input_text', text: clientTurnId }], + }, + at: 1, +}); + describe('DirectProject 回合状态派生', () => { - it('displayBusy 是原生真相与本地命令在飞的并集', () => { + it('displayBusy 是原生真相、IPC 在飞与待发消息条数的并集', () => { expect( deriveDirectProjectTurnStatus({ turnRunning: true, - turnBusy: false, + commandInFlight: false, + pendingTurns: [], turns: [], }).displayBusy, ).toBe(true); expect( deriveDirectProjectTurnStatus({ turnRunning: false, - turnBusy: true, + commandInFlight: true, + pendingTurns: [], + turns: [], + }).displayBusy, + ).toBe(true); + // 队列非空也是忙:还有一条待发消息在宿主手里,这时不能假装空闲。 + expect( + deriveDirectProjectTurnStatus({ + turnRunning: false, + commandInFlight: false, + pendingTurns: [pending('client-1')], turns: [], }).displayBusy, ).toBe(true); expect( deriveDirectProjectTurnStatus({ turnRunning: false, - turnBusy: false, + commandInFlight: false, + pendingTurns: [], turns: [], }).displayBusy, ).toBe(false); }); - it('两个来源各自独立暴露,不被并集吃掉', () => { + it('三个来源各自独立暴露,不被并集吃掉', () => { const status = deriveDirectProjectTurnStatus({ turnRunning: true, - turnBusy: true, + commandInFlight: true, + pendingTurns: [pending('client-1'), pending('client-2')], turns: [], }); expect(status.nativeRunning).toBe(true); expect(status.commandInFlight).toBe(true); + expect(status.pendingCount).toBe(2); }); it('latestTurnState 取最新一轮的两态;没有回合时为 null', () => { expect( deriveDirectProjectTurnStatus({ turnRunning: false, - turnBusy: false, + commandInFlight: false, + pendingTurns: [], turns: [turn('u1', 'finished'), turn('u2', 'running')], }).latestTurnState, ).toBe('running'); expect( deriveDirectProjectTurnStatus({ turnRunning: false, - turnBusy: false, + commandInFlight: false, + pendingTurns: [], turns: [], }).latestTurnState, ).toBeNull(); }); - it('本地命令在飞不等于原生在跑', () => { + it('IPC 在飞不等于原生在跑', () => { const status = deriveDirectProjectTurnStatus({ turnRunning: false, - turnBusy: true, + commandInFlight: true, + pendingTurns: [], turns: [turn('u1', 'finished')], }); expect(status.nativeRunning).toBe(false); diff --git a/apps/ai-game-creator-shell/tests/directThreadChat.test.ts b/apps/ai-game-creator-shell/tests/directThreadChat.test.ts index 4b385780e..ceb68ede4 100644 --- a/apps/ai-game-creator-shell/tests/directThreadChat.test.ts +++ b/apps/ai-game-creator-shell/tests/directThreadChat.test.ts @@ -284,6 +284,134 @@ describe('DirectProject 聊天 reducer', () => { expect(entries[0]?.toolCall?.detail.command).toBe('{"cmd": "ls"}'); }); + describe('待发消息队列投影(queue.enqueued / queue.removed)', () => { + /** 一条入队事件:canonical 用户条目由宿主给,界面只读它派生 chip。 */ + function enqueued(clientTurnId: string, text: string, at = 1_000) { + return event({ + type: 'queue.enqueued', + clientTurnId, + at, + userItem: { + type: 'message', + role: 'user', + id: `direct-codex:${clientTurnId}:user`, + content: [{ type: 'input_text', text }], + }, + } as Partial); + } + + it('按事件顺序折成队列,取消与放行都只按身份移除', () => { + const queued = reduceDirectThreadEvents(emptyDirectThreadChatState(), [ + enqueued('client-1', '第一条'), + enqueued('client-2', '第二条'), + enqueued('client-3', '第三条'), + ]); + expect(queued.pendingTurns.map((turn) => turn.clientTurnId)).toEqual([ + 'client-1', + 'client-2', + 'client-3', + ]); + + // 取消中间那条:其余顺序不变。 + const cancelled = reduceDirectThreadEvents(queued, [ + event({ + type: 'queue.removed', + clientTurnId: 'client-2', + reason: 'cancelled', + }), + ]); + expect(cancelled.pendingTurns.map((turn) => turn.clientTurnId)).toEqual([ + 'client-1', + 'client-3', + ]); + + // 放行走的是同一条移除:chip 消失,回合边界由同批的 `turn.started` 给出。 + const dispatched = reduceDirectThreadEvents(cancelled, [ + event({ + type: 'queue.removed', + clientTurnId: 'client-1', + reason: 'dispatched', + }), + withUserItemId( + event({ type: 'turn.started', at: 2_000 }), + 'direct-codex:client-1:user', + ), + ]); + expect(dispatched.pendingTurns.map((turn) => turn.clientTurnId)).toEqual([ + 'client-3', + ]); + expect(dispatched.turnRunning).toBe(true); + }); + + it('重复入队与重复移除都不改动状态引用(入队幂等、事件可能被重放)', () => { + const once = reduceDirectThreadEvents(emptyDirectThreadChatState(), [ + enqueued('client-1', '第一条'), + ]); + const twice = reduceDirectThreadEvents(once, [ + enqueued('client-1', '第一条'), + ]); + expect(twice).toBe(once); + + const removed = reduceDirectThreadEvents(twice, [ + event({ + type: 'queue.removed', + clientTurnId: 'client-1', + reason: 'cancelled', + }), + ]); + expect(removed.pendingTurns).toHaveLength(0); + const removedAgain = reduceDirectThreadEvents(removed, [ + event({ + type: 'queue.removed', + clientTurnId: 'client-1', + reason: 'cancelled', + }), + ]); + expect(removedAgain).toBe(removed); + }); + + it('队列不随回合收口消失:在队的那几条仍然是待发消息', () => { + // 一轮可以在同一次 consume 里开始又结束(放行后立刻失败),队列的推进归宿主: + // reducer 只按 `queue.removed` 撤 chip,绝不因为回合收口就自己清空。 + const state = reduceDirectThreadEvents(emptyDirectThreadChatState(), [ + enqueued('client-1', '第一条'), + enqueued('client-2', '第二条'), + withUserItemId( + event({ type: 'turn.started', at: 2_000 }), + 'direct-codex:client-1:user', + ), + event({ type: 'turn.completed', status: 'failed', at: 2_100 }), + ]); + expect(state.turnRunning).toBe(false); + expect(state.pendingTurns.map((turn) => turn.clientTurnId)).toEqual([ + 'client-1', + 'client-2', + ]); + }); + + it('bootstrap 把 bootstrap 那一刻的在队条目交给新订阅者', () => { + // 宿主在队条目不可回收,所以 live-set bootstrap 天然带着当前队列:另一个窗口、离开工作台 + // 再回来,看到的都是同一份(这里用 bootstrap 路径而不是实时 consume 钉住)。 + const bootstrap = resolveDirectThreadBootstrap( + emptyDirectThreadChatState(), + { + subscriptionId: 'sub-1', + lastCompletedItemId: null, + events: [ + enqueued('client-1', '第一条'), + enqueued('client-2', '第二条'), + ], + } as never, + ); + expect( + bootstrap.pendingTurns.map((turn) => turn.userItem.content), + ).toEqual([ + [{ type: 'input_text', text: '第一条' }], + [{ type: 'input_text', text: '第二条' }], + ]); + }); + }); + describe('失败终态(turn.completed 带 failure 载荷)', () => { it('失败也是终态:收口本轮、冻结终点,并把原因落成本轮最后一条说明', () => { const failed = reduceDirectThreadEvents(emptyDirectThreadChatState(), [ diff --git a/docs/adr/【ADR】DirectProject命令入队化与待发消息队列归宿主-2026-09-24.md b/docs/adr/【ADR】DirectProject命令入队化与待发消息队列归宿主-2026-09-24.md index d4e741723..67afbd699 100644 --- a/docs/adr/【ADR】DirectProject命令入队化与待发消息队列归宿主-2026-09-24.md +++ b/docs/adr/【ADR】DirectProject命令入队化与待发消息队列归宿主-2026-09-24.md @@ -106,16 +106,20 @@ Thread Manager 在一个回合收口**之后**原子地做:取队首 → 登 - **检查是时间点事实**:放行不重跑,按入队那一刻的结论放行;manifest、权限、目录在入队之后变化也照旧放行,偏差落到回合失败。 - **队列随进程消失**:待发消息只在内存与事件流里,`kill -9` / 退出后重进看不到(与 09-16 ADR 的 `kill -9` 口径一致)。 - **退役面**:前端 `completionPendingRef`、`handledCompletedTurnCountRef`、`busyBaselineTurnCountRef`、`dispatchNextQueuedTurn`、 - `turnBusyRef`/`beginTurnBusy`/`endTurnBusy`、`queueSequenceRef`、`queuedTurns*`、排队条目那部分 `pendingRunAnalyticsRef` - 与 `chatComposerQueue.ts` 的队列逻辑整体退役;`directProjectTurnStatus` 的"命令在飞"分支收成 IPC 在飞; - 埋点句柄改由宿主在放行时开。 + `queueSequenceRef`、`queuedTurns*` 与 `chatComposerQueue.ts` 整体退役(含它的上限常量与"队列已满"文案—— + 上限只留宿主一处);`turnBusy`/`beginTurnBusy`/`endTurnBusy` 改名 `commandInFlight` 并收窄成 IPC 在飞; + `directProjectTurnStatus` 的忙态改成「原生在跑 ∨ IPC 在飞 ∨ 待发消息非空」。 + 队列投影落在 `chat/conversation/directPendingTurns.ts`,chip 文案落在 + `chat/components/DirectProjectComposer/pendingTurnChipLabel.ts`。 - **词表切换是一次性跨文档动作**:仓库里「接单/拒单」共 384 处,并非全属同一个域 (`features/agent-runtime` 的"拒单文案"属另一个域,改成"请求被拒";`单测` 这类是假阳性)。 两份已接受的 ADR 保留正文与文件名,顶部加词表注记。 - **失去手工验证手段**:CLI 退役同时带走"真实二进制驱动执行层生产验证"这条手工路径(夹具脚本一并退役)。 它不在 CI,损失的是排障时的一次性手段,不是门禁。 -- **埋点**:attempt id 只用于宿主内存里的候选配对与前端结算(同 `session_id` 的 `Settle` 才提交), - 前端不再需要为排队条目提前持有它;平台会话代际翻转的 `discard` 判定仍是渲染侧职责,落地时逐条核对。 +- **埋点**:attempt id 仍由前端在**入队那一刻**生成并随命令交给宿主(放行不重算,与其它入队检查产物一样 + 存进队列条目),但前端不再为排队条目**单独持有**一个句柄槽——句柄按 `clientTurnId` 存成一张表, + 回合终态按本轮开口条目的 canonical 身份认领结算,入队失败的那一条直接删掉不结算。 + 平台会话代际翻转的 `discard` 判定仍是渲染侧职责(结算时必须由渲染侧给出)。 ## 明确不做 diff --git a/docs/technical/【实施计划】DirectProject命令入队化与待发消息队列归宿主-2026-09-24.md b/docs/technical/【实施计划】DirectProject命令入队化与待发消息队列归宿主-2026-09-24.md index 150ef125a..2416050e7 100644 --- a/docs/technical/【实施计划】DirectProject命令入队化与待发消息队列归宿主-2026-09-24.md +++ b/docs/technical/【实施计划】DirectProject命令入队化与待发消息队列归宿主-2026-09-24.md @@ -2,11 +2,22 @@ 更新时间:`2026-09-24` -状态:**待实施**(设计已定稿,尚未开工) +状态:**实施中**(第 0–3 步已落地;第 4、5 步待做) 设计口径见 [`【ADR】DirectProject命令入队化与待发消息队列归宿主-2026-09-24`](../adr/【ADR】DirectProject命令入队化与待发消息队列归宿主-2026-09-24.md)。 本文件只排实施顺序、不变式与验收,不重复设计理由。 +## 落地进度 + +| 步骤 | 状态 | 落地说明 | +| --- | --- | --- | +| 第 0 步 词表切换 | 已落地 | `rg "接单\|拒单"` 只剩 `direct_runtime/mod.rs` 对旧 ADR 文件名的引用(链接完整性,故意保留)与 `codex_app_server/mod.rs` 的一处假阳性 | +| 第 1 步 命令 = 入队 | 已落地 | `enqueue_direct_codex_turn`(`+_typed`);队列条目落在 `agent/direct_thread_queue.rs`,带上入队时产出的 `prompt` / `creation_type` / `analytics_attempt_id` | +| 第 2 步 队列归 Thread Manager | 已落地 | `StoredEvent.pending` 产物字段 + `enqueue_pending_turn` / `remove_pending_turn` / `claim_pending_turn`;放行在 `agent/direct_turn_dispatch.rs`(`kick_direct_queue_dispatch` + `DirectTurnReservation`) | +| 第 3 步 前端收口 | 已落地 | 见下面「第 3 步的落地细则」 | +| 第 4 步 CLI 与夹具退役 | 待做 | | +| 第 5 步 守卫清理 | 待做 | | + ## 第 0 步:词表切换(与代码同批,不单独提交) 「接单 / 拒单」退役,改成「入队 / 入队失败 / 放行」。逐处按角色改,**不做字面替换**: @@ -88,6 +99,26 @@ kick 幂等(并发两次只认领一次);队首在放行后被移除、rem ## 第 3 步:前端收口 +### 第 3 步的落地细则 + +- 队列投影是新文件 `chat/conversation/directPendingTurns.ts`:只做 `enqueuePendingTurn` / + `removePendingTurn` 两个纯函数,**不判上限、不排期、不排序**;上限与认领顺序只在宿主。 + reducer 的 `queue.enqueued` / `queue.removed` 两个分支是它唯一的调用方。 +- chip 文案派生搬到 `chat/components/DirectProjectComposer/pendingTurnChipLabel.ts` + (`pendingTurnChipLabel`),与 `chatComposerQueue.ts` 一起把"队列在本地"的最后一份实现删掉。 +- **草稿清不清由命令的入队结果回答**:`onSubmit` 改成返回 `Promise`,composer 只在 + `true` 时清草稿。返回 `false` 的口子是"用户自己就能改的入队失败"(队列已满、参数无效这类)—— + 宿主已经给了同级提示,草稿再没了就等于让用户重打一遍。写权限门让路给确认流程时返回 `true` + (内容已经在重跑的入参里),与入队化之前一致。 +- **埋点句柄按 `clientTurnId` 存成一张表**(`pendingRunAnalyticsRef`),回合终态按本轮开口条目的 + canonical 身份(`turn.completed.userItemId` ↔ `directCodexConversationMessageId(clientTurnId,'user')`) + 认领结算;身份缺失时退回结算最早的那一条(放行严格按队首顺序,收口顺序就是入队顺序)。 + 入队失败的那一轮直接把句柄删掉,不结算。 +- `displayBusy` = 原生在跑 ∨ IPC 在飞 ∨ 待发消息非空;`commandInFlight` 收窄成"IPC 在飞"。 +- 取消 chip 调 `remove_direct_project_pending_turn`,按 typed 结果 `removed / alreadyDispatched / + notFound` 说清楚;chip 的撤除仍然只认 `queue.removed` 事件(界面不改本地队列)。 + + 退役: - `chatComposerQueue.ts` 的 `enqueueChatTurn` / `dequeueChatTurn` / `removeQueuedChatTurn` / `isChatTurnQueueFull` / @@ -104,8 +135,9 @@ kick 幂等(并发两次只认领一次);队首在放行后被移除、rem - 忙态 = 事件投影 + 「队列非空」指示;`directProjectTurnStatus` 的"命令在飞"分支收成 IPC 在飞。 - 写权限门 `ensureConversationWriteAllowed` 留在入队之前(确认框必须在用户在场时弹)。 -验收:`tests/directThreadChat.test.ts` 补队列事件投影用例;`tests/appSurface/chat-composer.suite.ts` 的排队 / 取消 / 满队 / -放行三组用例改成新语义;`npm --workspace apps/ai-game-creator-shell run typecheck` 通过。 +验收:`tests/directThreadChat.test.ts` 补队列事件投影用例(顺序、幂等、按身份移除、bootstrap 带出在队条目、 +回合收口不清队列);`tests/appSurface/chat-composer.suite.ts` 的排队 / 取消 / 满队 / 放行四组用例改成新语义, +并新增「入队只发一次 IPC」「IPC 在飞时挡住第二次提交」两条;`npm --workspace apps/ai-game-creator-shell run typecheck` 通过。 ## 第 4 步:CLI 与夹具退役