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) => (
- -
- {index + 1}
-
- {queuedChatTurnLabel(turn, assets)}
-
-
-
- ))}
+ {turns.map((turn, index) => {
+ const label = pendingTurnChipLabel(turn, assets);
+ return (
+ -
+
+ {index + 1}
+
+ {label}
+
+
+ );
+ })}
);
}
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 与夹具退役