前端收口:待发消息队列改由事件投影驱动,退役本地队列机器
- 新增 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:
+10
-7
@@ -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={() => {
|
||||
// 素材标签、改名、删除这些写入发生在资源画布,聊天侧收不到失效事件:
|
||||
|
||||
+30
-22
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
+20
-12
@@ -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}
|
||||
|
||||
-99
@@ -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;
|
||||
}
|
||||
+33
@@ -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;
|
||||
}
|
||||
+186
-256
File diff suppressed because it is too large
Load Diff
+32
-16
@@ -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],
|
||||
);
|
||||
}
|
||||
|
||||
+49
@@ -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;
|
||||
}
|
||||
+49
-9
@@ -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 与夹具退役
|
||||
|
||||
|
||||
Reference in New Issue
Block a user