前端收口:待发消息队列改由事件投影驱动,退役本地队列机器

- 新增 conversation/directPendingTurns.ts:入队/移除两个纯函数,不判上限、不排期、不排序
- reducer 新增 queue.enqueued / queue.removed 分支,折成 pendingTurns 投影
- chip 文案派生搬到 pendingTurnChipLabel.ts,删除 chatComposerQueue.ts 整套本地队列实现
- controller 退役 queuedTurns / queueSequenceRef / completionPendingRef / busyBaselineTurnCountRef
  与看门 counts effect;取消 chip 改调 remove_direct_project_pending_turn 按 typed 结果说话
- 埋点句柄按 clientTurnId 存表,回合终态按本轮开口条目身份结算
- turnBusy 改名 commandInFlight 并收窄成 IPC 在飞;忙态 = 原生在跑 ∨ IPC 在飞 ∨ 待发消息非空
- 提交改成等命令的入队结果:用户自己能改的入队失败保留草稿,其余按已交出处理
- 测试:队列投影单测、入队只发一次 IPC、IPC 在飞挡住第二次提交、满队保留草稿、取消已放行条目
- 文档:实施计划标注第 0–3 步落地并补第 3 步细则,ADR 的埋点与退役面口径改成落地后的写法
This commit is contained in:
2026-09-30 10:20:37 +08:00
parent 132f96581a
commit 0534e9c678
14 changed files with 887 additions and 610 deletions
@@ -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={() => {
// 素材标签、改名、删除这些写入发生在资源画布,聊天侧收不到失效事件:
@@ -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 (
<ol className="project-chat-composer-queue" aria-label="待发送消息队列">
{turns.map((turn, index) => (
<li key={turn.id} data-queue-index={index}>
<span className="project-chat-composer-queue-order">{index + 1}</span>
<span className="project-chat-composer-queue-text">
{queuedChatTurnLabel(turn, assets)}
</span>
<button
type="button"
aria-label={`取消排队消息 ${queuedChatTurnLabel(turn, assets)}`}
title="取消这条排队消息"
onClick={() => onCancel(turn.id)}
>
<X size={12} aria-hidden="true" />
</button>
</li>
))}
{turns.map((turn, index) => {
const label = pendingTurnChipLabel(turn, assets);
return (
<li key={turn.clientTurnId} data-queue-index={index}>
<span className="project-chat-composer-queue-order">
{index + 1}
</span>
<span className="project-chat-composer-queue-text">{label}</span>
<button
type="button"
aria-label={`取消待发消息 ${label}`}
title="取消这条待发消息"
onClick={() => onCancel(turn.clientTurnId)}
>
<X size={12} aria-hidden="true" />
</button>
</li>
);
})}
</ol>
);
}
@@ -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<boolean>;
/**
* 导入文件:`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({
}}
>
<ComposerTurnQueue
turns={queuedTurns}
turns={pendingTurns}
assets={assets}
onCancel={onCancelQueuedTurn}
onCancel={onCancelPendingTurn}
/>
<ResourceReferenceInput
ref={composerRef}
@@ -1,99 +0,0 @@
/**
* 输入盒本地消息队列(纯前端状态,不改后端协议)。
*
* 回合运行中用户再次发送时,消息进入 FIFO 队列而不是被丢弃;当前回合结束后按入队顺序
* 依次发出。队列项能在输入盒上方单独取消。这里只放与 React 无关的纯逻辑,便于单测。
*/
import type { GameCreationAppAssetManifestEntry } from '../../../../../../../../packages/shared/src/contracts/gameCreationApp';
import {
directCodexContentToPromptText,
resourceLabelResolver,
} from '../../../../../features/project-workspace/resourceReferences';
import type { DirectCodexUserItem } from '../../generated/DirectCodexUserItem';
/** 队列上限:满了以后拒绝入队并给出可读提示,而不是静默丢消息。 */
export const MAX_QUEUED_CHAT_TURNS = 5;
export type QueuedChatTurn = {
id: string;
clientTurnId: string;
userItem: DirectCodexUserItem;
createdAt: number;
};
export function createQueuedChatTurn(input: {
id: string;
clientTurnId: string;
userItem: DirectCodexUserItem;
createdAt: number;
}): QueuedChatTurn {
return {
id: input.id,
clientTurnId: input.clientTurnId,
userItem: {
...input.userItem,
content: [...input.userItem.content],
},
createdAt: input.createdAt,
};
}
/** 队尾追加。同一 id 已在队列里时原样返回,避免重复入队把同一条消息发两遍。 */
export function enqueueChatTurn(
queue: readonly QueuedChatTurn[],
turn: QueuedChatTurn,
): QueuedChatTurn[] {
if (queue.some((item) => 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;
}
@@ -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;
}
@@ -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],
);
}
@@ -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;
}
@@ -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;
File diff suppressed because it is too large Load Diff
@@ -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);
@@ -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<DirectThreadEvent>);
}
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(), [
@@ -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` 判定仍是渲染侧职责(结算时必须由渲染侧给出)。
## 明确不做
@@ -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<boolean>`,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 与夹具退役