前端:发送队列放行与埋点结算改听回合终态

- reducer 新增 `completedTurnCount`(单调计数):一轮可能在同一次 consume 里开始并结束,
  下降沿不可靠,收口是**状态**不是转移
- 队列放行只在"回合终态或拒绝接单"发生;命令返回不再驱动出队
  (接单被拒仍当场出队,权限被拒等从未发出的路径保持原样)
- 埋点结算挂到回合终态:接单返回时成绩还没入账,句柄因此活过命令返回;
  拒单只丢句柄、不发一次注定被丢弃的结算
- 本地在途标签活到宿主认领这一轮(身份命中 / 出现开始事件 / 收口计数变化),
  "命令返回"不再等于"这一轮结束",命令与开始事件之间不再有可发送的空窗
- 删掉 `markTurnStopped()`:终止成功的回合边界由宿主写的兜底终态收口
- 删掉 `turn.started` 的"重复起点保留第一次"兼容分支(接单只发一次开始事件)
- 同步注释:发送时序、待认领窗口、`commandInFlight` 的真实含义
- 测试:队列用例改用终态事件驱动,认证失败用例断言拒单不结算,reducer 补收口计数用例
This commit is contained in:
2026-09-23 21:54:53 +08:00
parent af5fdf8a0e
commit 93202f2f91
7 changed files with 215 additions and 85 deletions
@@ -120,8 +120,9 @@ export type DirectProjectChatControllerProps = {
* 三份原始输入各自是什么、带什么、活多久:
* - 项目对话历史(`.agent/conversations/project.jsonl`):持久,只有条目、**没有回合边界**,
* 经历史切片读取(首屏按 `lastCompletedItemId` 锚定)。
* - 运行态事件(subscribe / consume / notify):进程内;`turn.started` / `turn.completed` 是原生回合
* 活跃与否的**唯一**判据;可回收事件被回收后靠 `lifecycle_anchor` 保住最新一条生命周期事件。
* - 运行态事件(subscribe / consume / notify):进程内;`turn.started` / `turn.completed` 是**逻辑回合**
* 活跃与否的**唯一**判据(接单时成对发出,不再镜像 Codex 原生回合);可回收事件被回收后靠
* `lifecycle_anchor` 保住最新一条生命周期事件。
* 失败也走这条流:`turn.completed.failure` 自己带脱敏后的原因,reducer 把它落成本轮说明条目;
* 命令返回那条通道只提供横幅与诊断,不再写聊天文案。
* - 本地发送:只存在于本次会话,`projectPath` 变化即清空;乐观气泡与原生条目同身份
@@ -129,20 +130,22 @@ export type DirectProjectChatControllerProps = {
*
* 一次发送的时序(第 2 → 3 步之间就是「本地已发出、宿主还没确认」的空窗):
* 1. 按下发送:`localMessages += 乐观气泡`、`turnBusy=true`、`pendingUserItemId=本轮身份`(同帧)。
* 2. `invoke('chat_with_game_creator_direct_codex')`Rust 先落盘用户条目,再发 `turn/start`
* **应答返回后**才 append `turn.started` 并 notify。
* 2. `invoke('chat_with_game_creator_direct_codex')`Rust 走完接单前的检查 → 接单(登记占用 +
* append `turn.started`)→ 落盘用户条目 → spawn 整轮 → **立刻返回**。命令返回只说明接单成立,
* 整轮的结果不再从这条通道回来;拒单则返回结构化的 typed 错误。
* 3. notify → consume → `turn.started`reducer 的 `turnRunning=true`、`turnStartedAt`、`turnUserItemId`。
* 身份命中本轮时本地在途标签退场(第 2 步到这一步之间界面仍算"在途",见 `pendingUserItemId`)。
* 4. `item.completed`(本轮用户条目回显):同身份条目已在历史里就合并进去,否则进 `live`;本地气泡此时被去重。
* 5. `item.delta` / `item.started` / `item.completed`:正文追加、工具卡片 upsert(先到定形、后到只补空)。
* 6. `turn.completed``live` 并入 `history` 后清空,`turnEndedAt` 冻结,边界按身份盖到本轮开口条目上
* 带 `failure` 载荷时,说明条目已经在上一步由 reducer 落进 `live`,随本轮一起并入历史。
* 7. 命令收尾(`finally`):刷新清单 → `endTurnCommand()` 清掉忙态与在途身份 → 出队下一轮
* **顺序是契约**:出队会同步开始下一轮并设上它自己的忙态,所以清忙态必须早于出队;
* 权限被拒那种「本轮从未发出但要继续出队」的情况,也只标记 `queueAdvance`、由这里统一收口
* 6. `turn.completed``live` 并入 `history` 后清空,`turnEndedAt` 冻结,边界按身份盖到本轮开口条目上
* 收口计数 +1带 `failure` 载荷时,说明条目已经在上一步由 reducer 落进 `live`,随本轮一起并入历史。
* 7. **回合终态**(第 6 步的收口计数变化):结算本轮埋点 → 放行发送队列,顺序固定在这一处
* 8. 命令收尾(`finally`):刷新清单;只在**没接单**时放掉忙态与在途身份并出队(权限被拒那种
* 「本轮从未发出但要继续出队」的路径也在这里收口),接单成立的那一轮交给第 3 / 7 步
*
* 状态变量归属:reducer 的三个回合字段与 `history` / `live` 只由 `directThreadChat.ts` 写;
* 状态变量归属:reducer 的回合字段、收口计数与 `history` / `live` 只由 `directThreadChat.ts` 写;
* 本文件的 `turnBusy` / `pendingUserItemId`(同生共死,唯一入口 `beginTurnCommand` /
* `endTurnCommand`)、`localMessages`、发送队列与分页 ref 只服务发送与展示;界面上的
* `endTurnCommand`)、`localMessages`、发送队列、埋点句柄与分页 ref 只服务发送与展示;界面上的
* 「这一轮在跑吗」只有一个派生入口 `useDirectProjectTurnStatus()`,三态判据与真值表在
* `../conversation/directTurnPresentation.ts` 的 `DirectChatTurnState`,渲染时否定式读
* `state !== 'finished'`、肯定式读 `state === 'running'`(见 `DirectProjectTurn.tsx`)。
@@ -176,8 +179,13 @@ export function useDirectProjectChatController({
const [statusNotice, setStatusNotice] = useState('');
const [turnCancelling, setTurnCancelling] = useState(false);
const [turnBusy, setTurnBusy] = useState(false);
// 本地已发出、原生还没认领的那一轮用户条目身份:只服务投影的 `awaiting-start` 展示态,
// 生命周期与 `turnBusy` 完全一致(命令在飞期间有值,收尾即清)
/**
* 本地已发出、宿主还没认领的那一轮用户条目身份:只服务投影的 `awaiting-start` 展示态
*
* 生命周期与 `turnBusy` 一致(同生共死),但**不是**"命令在飞":接单化之后命令只等到接单,
* 所以它从按下发送一直活到宿主那一轮的开始事件被 reducer 认领(身份命中)。这一段必须仍在
* "在途",否则命令返回与开始事件到达之间会出现一个可发送的空窗。
*/
const [pendingUserItemId, setPendingUserItemId] = useState<string>('');
const [localMessages, setLocalMessages] = useState<ChatMessage[]>([]);
// 订阅(subscribe/consume/notify)与聊天 reducer 状态在自己的 hook 里:
@@ -188,6 +196,8 @@ export function useDirectProjectChatController({
});
const directEntries = directThread.entries;
const currentTurnRunning = directThread.turnRunning;
const completedTurnCount = directThread.completedTurnCount;
const turnUserItemId = directThread.state.turnUserItemId;
const [historyHasMore, setHistoryHasMore] = useState(false);
const historyOldestItemIdRef = useRef<string | null>(null);
const historyLoadingRef = useRef(false);
@@ -198,8 +208,22 @@ export function useDirectProjectChatController({
directTurnRunningRef.current = currentTurnRunning;
const turnBusyRef = useRef(turnBusy);
turnBusyRef.current = turnBusy;
/** 已经处理过的收口回合数:与 reducer 的计数比较,识别"又有回合结束了"。 */
const handledCompletedTurnCountRef = useRef(0);
/** 起这一轮时的收口计数:宿主没给身份时只能靠"计数变过"认领这一轮(见下面的 effect)。 */
const pendingTurnBaselineRef = useRef(0);
const completedTurnCountRef = useRef(completedTurnCount);
completedTurnCountRef.current = completedTurnCount;
/** 有回合结束了、但还不能出队(本地在途标签还没退场)时挂起,等忙态放掉再出队。 */
const completionPendingRef = useRef(false);
const previousTurnRunningRef = useRef(currentTurnRunning);
/**
* 本轮的埋点句柄。它在命令返回之后仍然要活着:成绩是**回合末**才在宿主侧入账的,
* 接单返回时结算只会静默丢掉这一次埋点(见 `settlePendingRunAnalytics`)。
*/
const pendingRunAnalyticsRef = useRef<{
clientTurnId: string;
runAnalytics: ReturnType<typeof beginDirectRunAnalytics>;
} | null>(null);
useEffect(() => {
setAttachmentNotice('');
@@ -211,6 +235,9 @@ export function useDirectProjectChatController({
setPendingUserItemId('');
setHistoryHasMore(false);
historyOldestItemIdRef.current = null;
handledCompletedTurnCountRef.current = 0;
completionPendingRef.current = false;
pendingRunAnalyticsRef.current = null;
}, [projectPath]);
useEffect(() => {
@@ -223,22 +250,65 @@ export function useDirectProjectChatController({
return () => window.clearInterval(timer);
}, [enabled, turnBusy, currentTurnRunning]);
/**
* 回合终态是队列放行与埋点结算的唯一出口(接单被拒走命令那条路,见 `startTurn` 的收尾)。
*
* 判据用 reducer 的**单调计数**而不是 `turnRunning` 的下降沿:一轮可能在同一次 consume 里
* 开始并结束,那时下降沿永远不会出现,队列就永久卡住了。
*
* TODO(发送队列):这条队列整体挪到 Rust 端,放行点就是 Thread Manager 的接单动作。
*/
useEffect(() => {
if (!enabled) {
handledCompletedTurnCountRef.current = completedTurnCount;
completionPendingRef.current = false;
previousTurnRunningRef.current = currentTurnRunning;
return;
}
const wasRunning = previousTurnRunningRef.current;
previousTurnRunningRef.current = currentTurnRunning;
if (wasRunning && !currentTurnRunning) completionPendingRef.current = true;
if (completionPendingRef.current && !turnBusy && !currentTurnRunning) {
if (completedTurnCount !== handledCompletedTurnCountRef.current) {
handledCompletedTurnCountRef.current = completedTurnCount;
// 先结算埋点:宿主此刻已经把这一轮的成绩写进候选,再晚也还是同一轮。
settlePendingRunAnalytics();
completionPendingRef.current = true;
}
if (
completionPendingRef.current &&
!turnBusyRef.current &&
!directTurnRunningRef.current
) {
completionPendingRef.current = false;
dispatchNextQueuedTurn();
}
// 队列出队只由忙碌态和线程完成态驱动
// 出队只由"回合收口次数 + 本地在途状态"驱动,队列本身不是依赖
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [turnBusy, currentTurnRunning, enabled]);
}, [enabled, completedTurnCount, turnBusy, currentTurnRunning]);
/**
* 宿主认领了这一轮:本地在途标签退场,忙态交给原生真相。
*
* 判据是**事件流里的回合边界**而不是命令的返回值:接单化之后命令先返回、开始事件后到,
* 中间那一段必须仍算"在途"(投影显示 `awaiting-start`)。
*
* 三条判据任何一条成立都算认领(前一条是正常路径,后两条是"订阅流没带回合身份"时的兜底,
* 没有它们一个不带 `userItemId` 的边界事件就能把 composer 永久锁成忙碌):
* - 身份命中:`turnUserItemId` 就是本轮的开口用户条目身份(ADR §5:宿主按 `clientTurnId` 现算);
* - 原生已经在跑:这条流里出现了开始事件;
* - 收口计数变过:这一轮在同一次 consume 里开始又结束。
*/
useEffect(() => {
if (!pendingUserItemId) return;
const claimedByHost =
turnUserItemId === pendingUserItemId ||
currentTurnRunning ||
completedTurnCount > pendingTurnBaselineRef.current;
if (!claimedByHost) return;
endTurnCommand();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [
pendingUserItemId,
turnUserItemId,
currentTurnRunning,
completedTurnCount,
]);
useEffect(() => {
if (!enabled || !projectPath) return;
@@ -261,7 +331,8 @@ export function useDirectProjectChatController({
}, [enabled, projectPath]);
/**
* 「本地这一轮的命令在飞」的唯一起止点:按下发送时带上本轮用户条目身份,收尾时一起清掉。
* 「本地这一轮在途」的唯一起止点:按下发送时带上本轮用户条目身份,宿主认领这一轮(开始
* 事件的身份命中)或这一轮明确没成立时一起清掉。
*
* 忙态与待认领身份必须同生共死,否则投影会拿一个过期的身份去判 `awaiting-start`。
*/
@@ -269,6 +340,7 @@ export function useDirectProjectChatController({
turnBusyRef.current = true;
setTurnBusy(true);
setPendingUserItemId(userItemId);
pendingTurnBaselineRef.current = completedTurnCountRef.current;
}
function endTurnCommand() {
@@ -277,6 +349,16 @@ export function useDirectProjectChatController({
setPendingUserItemId('');
}
/**
* 结算本轮埋点。只在**回合终态**调用:成绩是回合末才在宿主侧入账的,接单返回时就结算
* 会变成一次空操作(宿主找不到候选,静默丢弃)。拒单那一轮没有候选,句柄由 `runTurn` 自己清掉。
*/
function settlePendingRunAnalytics() {
const pending = pendingRunAnalyticsRef.current;
pendingRunAnalyticsRef.current = null;
pending?.runAnalytics.settle();
}
function appendLocalMessage(message: ChatMessage) {
setLocalMessages((current) => [...current, message]);
}
@@ -500,6 +582,8 @@ export function useDirectProjectChatController({
);
void (async () => {
let invoked = false;
// 命令是否接了单。只有它为真时,这一轮的收尾才交给宿主的事件。
let turnAccepted = false;
// 权限被拒也要继续出队(见下),但出队必须发生在 finally 的 endTurnCommand() 之后:
// 在这里出队的话,下一轮刚设上的忙态会被紧接着的 finally 清掉。
let queueAdvance = false;
@@ -525,7 +609,7 @@ export function useDirectProjectChatController({
const invoke = resolveTauriInvoke();
if (!invoke) return;
invoked = true;
await runTurn(invoke, nextProjectPath, input);
turnAccepted = await runTurn(invoke, nextProjectPath, input);
} catch (error) {
// 写权限门等前置步骤抛出时不能只留一个未处理的 rejection:回合会静默失败,
// 已经乐观追加的用户消息也没有任何解释。
@@ -540,16 +624,20 @@ export function useDirectProjectChatController({
if (invoked) {
await refreshDirectManifest(nextProjectPath);
}
// 忙态与在途身份每轮只在这里放一次,且必须早于出队:出队会同步开始下一轮并设上
// 它自己的忙态,清在它后面就等于把下一轮的忙态抹掉(composer 会以为可以并发发送,
// 下一轮的三态也会因为身份被清空而掉回 finished)。
endTurnCommand();
// 出队条件保持原样:真发出过的一轮要求项目没被换掉;权限被拒的一轮从未发出,
// 不受项目切换影响,照旧出队。
const invokedInSameProject =
invoked && projectPathRef.current === nextProjectPath;
if (queueAdvance || invokedInSameProject) {
dispatchNextQueuedTurn();
// 收尾分两种:接单成立的整轮交给宿主的事件(`turn.started` 时退场、`turn.completed`
// 时出队与结算,见上面两个 effect);没成立的那些路径没有任何事件会来,只能在这里收口。
if (!turnAccepted) {
// 忙态必须早于出队放掉:出队会同步开始下一轮并设上它自己的忙态,清在它后面就等于
// 把下一轮的忙态抹掉(composer 会以为可以并发发送,下一轮的三态也会掉回 finished)。
endTurnCommand();
// 出队条件:权限被拒的一轮从未发出,不受项目切换影响,照旧出队;命令真发出过又返回
// 拒单时要求项目没被换掉;没有 invoke(非 Tauri 环境)时不出队,避免空转。
if (
queueAdvance ||
(invoked && projectPathRef.current === nextProjectPath)
) {
dispatchNextQueuedTurn();
}
}
}
})();
@@ -559,25 +647,34 @@ export function useDirectProjectChatController({
invoke: NonNullable<ReturnType<typeof resolveTauriInvoke>>,
nextProjectPath: string,
input: DirectProjectTurnInput,
) {
): Promise<boolean> {
// 命令返回 `Ok` 只说明**接单成立**:整轮怎么收场只由 `turn.completed` 事件回答。
// 所以这里的返回值只服务队列放行——"这一轮有没有真的开始"。
let turnAccepted = false;
try {
const runAnalytics = beginDirectRunAnalytics(
invoke,
currentPlatformSessionGeneration,
);
try {
await invoke<string>('chat_with_game_creator_direct_codex', {
projectPath: nextProjectPath,
clientTurnId: input.clientTurnId,
userItem: input.userItem,
analyticsAttemptId: runAnalytics.nextAttempt(),
...(input.creationType ? { creationType: input.creationType } : {}),
});
} finally {
runAnalytics.settle();
}
// 埋点句柄必须活过命令返回:成绩是回合末才入账的,settle 只能在回合终态发生。
pendingRunAnalyticsRef.current = {
clientTurnId: input.clientTurnId,
runAnalytics,
};
await invoke<string>('chat_with_game_creator_direct_codex', {
projectPath: nextProjectPath,
clientTurnId: input.clientTurnId,
userItem: input.userItem,
analyticsAttemptId: runAnalytics.nextAttempt(),
...(input.creationType ? { creationType: input.creationType } : {}),
});
turnAccepted = true;
// 清单刷新统一交给 startTurn 的 finally:成功与报错路径都覆盖,且只读一次。
} catch (error) {
// 拒单那一轮没有接单,也就不会有埋点候选:句柄只清不发(发出去也只是空操作)。
if (pendingRunAnalyticsRef.current?.clientTurnId === input.clientTurnId) {
pendingRunAnalyticsRef.current = null;
}
// 命令的拒单是**结构化的**:命令返回 `Ok` 只说明接单成立,所以这条 catch 从接单化之后
// 只剩"拒单"一种输入(整轮结果由 `turn.completed` 事件回答,不再回到这里)。
const rejection = readDirectTurnRejection(error);
@@ -598,10 +695,10 @@ export function useDirectProjectChatController({
updatedAt: Date.now(),
});
}
return;
return false;
}
}
if (projectPathRef.current !== nextProjectPath) return;
if (projectPathRef.current !== nextProjectPath) return false;
// 认不出的拒单(宿主 / 环境事实)与其它非结构化错误走同一条通道:上报 + 横幅。
void captureAgentRuntimeError(error, DIRECT_CODEX_AGENT_ID);
const message = rejection
@@ -616,11 +713,12 @@ export function useDirectProjectChatController({
'陶泥儿智能创作',
true,
);
if (projectPathRef.current !== nextProjectPath) return;
if (projectPathRef.current !== nextProjectPath) return false;
// 聊天里的失败说明不由这里写:宿主已经把它放进了 `turn.completed.failure`reducer 会把它
// 落成本轮最后一条条目(唯一来源)。这里只保留运行错误横幅与诊断留痕。
onRuntimeError(visibleMessage);
}
return turnAccepted;
}
async function refreshDirectManifest(nextProjectPath: string) {
@@ -653,7 +751,8 @@ export function useDirectProjectChatController({
);
const message = result?.message?.trim();
if (result?.outcome === 'released') {
directThread.markTurnStopped();
// 本地只放掉"命令在飞"这一层;这一轮的**回合边界**不在这里收口——宿主的兜底终止
// 已经把终态写进事件流,界面等那条 `turn.completed` 自己落到 reducer 上。
endTurnCommand();
onRuntimeError('');
setComposerNotice(message ?? '已结束这一轮占用,可以直接重新发送消息');
@@ -14,8 +14,9 @@ import type {
*
* - `nativeRunning`**原生真相**。只由订阅 reducer 的 `turnRunning` 给出(`turn.started`
* 已到、`turn.completed` 未到)。它决定"陶泥儿正在处理"这类原生过程提示。
* - `commandInFlight`**本地真相**。本次会话的发送命令是否在飞(写权限门 → invoke →
* 收尾);它从按下发送那一刻就为真,与原生是否已经开始无关。
* - `commandInFlight`**本地真相**。本次会话是否有一条本地在途回合(写权限门 → invoke →
* 宿主认领这一轮);它从按下发送那一刻就为真,与原生是否已经开始无关。接单化之后命令只
* 等到接单就返回,所以它不等于"命令还没返回"。
* - `displayBusy`header / composer 该读的忙态,就是两者的并集:只要有一条成立就不能再
* 接受新的发送。
* - `latestTurnState`:最新一轮在界面上的三态(投影结果);没有回合时为 null。
@@ -34,12 +34,12 @@ export type DirectThreadChatSubscription = {
/** 聊天投影结果:历史顺序 + 运行态覆盖。 */
entries: DirectChatEntry[];
turnRunning: boolean;
/** 已收口回合数的单调计数:上层拿它当"回合完成"这个事实(见 reducer 的同名字段)。 */
completedTurnCount: number;
/** 订阅回执锚点闸门:首屏历史读取靠它拿到 `lastCompletedItemId`。 */
anchorGateRef: MutableRefObject<DirectHistoryAnchorGate | null>;
/** 历史切片并入同一个 reducer:条目只有这一份事实源。 */
mergeHistoryItems: (items: readonly DirectThreadItem[]) => void;
/** 终止成功(`released`)时手动放掉回合占用:订阅可能要等下一个事件才知道。 */
markTurnStopped: () => void;
};
/**
@@ -178,21 +178,14 @@ export function useDirectThreadChatSubscription({
[],
);
const markTurnStopped = useMemo(
() => () => {
setState((current) => ({ ...current, turnRunning: false }));
},
[],
);
const entries = useMemo(() => selectDirectChatEntries(state), [state]);
return {
state,
entries,
turnRunning: state.turnRunning,
completedTurnCount: state.completedTurnCount,
anchorGateRef,
mergeHistoryItems,
markTurnStopped,
};
}
@@ -73,6 +73,15 @@ export type DirectThreadChatState = {
* 也不用时间戳近似。空串 = 原生没给身份(旧事件),此时不猜历史归属。
*/
turnUserItemId: string;
/**
* 已经收口的回合数(单调递增,项目切换时随整份状态重置)。
*
* 它是"回合完成"这个事实**唯一的计数**,给上层放行发送队列与结算埋点用。为什么要计数
* 而不是看 `turnRunning` 的下降沿:一轮可能在**同一次 consume** 里开始并结束(接单后
* 立刻失败),那时 `turnRunning` 从头到尾没有被观察到真,下降沿永远不会来。计数是状态,
* 批量到达也一样看得见。
*/
completedTurnCount: number;
/** 历史切片条目,保持文件顺序。 */
history: DirectChatEntry[];
/** 当前回合的运行态条目,保持到达顺序;回合结束即并入历史并清空。 */
@@ -85,6 +94,7 @@ export function emptyDirectThreadChatState(): DirectThreadChatState {
turnStartedAt: 0,
turnEndedAt: 0,
turnUserItemId: '',
completedTurnCount: 0,
history: [],
live: [],
};
@@ -301,12 +311,9 @@ export function reduceDirectThreadEvent(
const eventAt = readDirectThreadEventAt(event);
// 回合身份只认**事件流顺序**,不拿时间戳大小当身份:原生回合时间是秒级精度、
// 宿主收口时间可能带毫秒,"上一轮结束之后又来一条 turn.started"就是新回合,
// 哪怕它落在同一秒。同一轮内部的重复开始事件(真正重放)在流里表现为
// "还在跑时又收到 turn.started",那种情况保留第一次的起点
const turnStartedAt =
state.turnRunning && state.turnStartedAt > 0
? state.turnStartedAt
: eventAt;
// 哪怕它落在同一秒。开始事件由 Thread Manager 在接单时发一次,线上不再有同一轮的
// 重复起点,所以这里不做"保留第一次"的兼容
const turnStartedAt = eventAt;
// 本轮的 canonical user identity 跟着事件走:新回合就换成新的;旧原生不带身份时
// 清空而不是继承上一轮,避免上一轮迟到的终态按身份匹配到这一轮。
const turnUserItemId = readDirectThreadEventUserItemId(event);
@@ -420,6 +427,7 @@ export function finishDirectThreadTurn(
turnRunning: false,
turnStartedAt,
turnEndedAt,
completedTurnCount: state.completedTurnCount + 1,
history: mergeHistoryEntries(history, stamped),
live: [],
};
@@ -52,7 +52,8 @@ export type DirectChatBlock =
* (只由 `turn.started` / `turn.completed` 决定;`if (current)` 只赋给最后一条回合,
* 所以「非最新一轮 + nativeRunning」不可达)。
* - `pendingUserItemId` ← controller 在 `beginTurnCommand` / `endTurnCommand` 之间维护,
* 生命周期与 `turnBusy` 一致;空串 = 没有在途的本地回合。
* 生命周期与 `turnBusy` 一致:从按下发送到宿主那一轮的开始事件被认领(身份命中)为止,
* 空串 = 没有在途的本地回合。它**不是**"命令在飞":接单化之后命令只等到接单就返回了。
* - `turn.key` ← 开这一轮的条目身份:原生用户条目用 `entry.itemId`,本地乐观气泡用
* `message.messageId` —— 两者是**同一个** `direct-codex:{clientTurnId}:user`。
* - `stampedEnd` ← 本轮条目上盖的终态时间,只有 `turn.completed` / 终止收口才写。
@@ -70,8 +71,10 @@ export type DirectChatBlock =
* `awaiting-start` 在"原生条目已回显、`turn.started` 未到"的次窗口里同样成立。
*
* 两个容易读错的地方:
* - `pendingUserItemId` 有值 **≠** `awaiting-start``invoke` 直到整轮结束才返回,所以
* `turn.started` 之后它仍在,但那时 `nativeRunning` 已经把它接成 `running`。
* - `pendingUserItemId` 有值 **≠** `awaiting-start``turn.started` 之后它可能还在(同一批事件
* 到达、或有别的回合收口在它前面),但那时 `nativeRunning` 已经把它接成 `running`。
* - 命令返回 **≠** 这一轮结束了:`invoke` 只等到接单,宿主确认这一轮靠的是开始事件;
* 所以"命令还没回来"不再是任何展示态依据,只有身份与显式事件是。
* - `endedAt === 0` **≠** 还在跑:历史回合没有边界元数据(`turnEndedAt` 只是会话内展示
* 缓存),它们必须落 `finished`。
*
@@ -411,7 +411,7 @@ export function registerChatComposerControlTests() {
await waitFor(() => {
expect(
invoke.mock.calls.filter(
([command]) => command === 'settle_direct_run_analytics',
([command]) => command === 'chat_with_game_creator_direct_codex',
),
).toHaveLength(1);
});
@@ -420,6 +420,12 @@ export function registerChatComposerControlTests() {
);
expect(attempts).toHaveLength(1);
expect(refresh).not.toHaveBeenCalled();
// 拒单没有接单、也就没有埋点候选:句柄直接丢掉,不产生一次注定被丢弃的结算。
expect(
invoke.mock.calls.filter(
([command]) => command === 'settle_direct_run_analytics',
),
).toHaveLength(0);
// 认不出的拒单 / 非结构化错误仍走既有捕获链路:横幅给用户一句可读的话。
await waitFor(() => {
expect(
@@ -432,15 +438,12 @@ export function registerChatComposerControlTests() {
});
it('queues messages sent while a turn runs, cancels one chip, and sends the rest in order', async () => {
const pending: Array<{
resolve: (value: string) => void;
reject: (error: Error) => void;
}> = [];
const { invoke, surface } = await openDirectCodexSurface({
chat_with_game_creator_direct_codex: () =>
new Promise<string>((resolve, reject) => {
pending.push({ resolve, reject });
}),
const { invoke, surface, harness } = await openDirectCodexSurface({
// 命令只接单(接单时 Thread Manager 已经发过开始事件),随后立刻返回。
chat_with_game_creator_direct_codex: () => {
harness.emitDirectThreadEvents({ type: 'turn.started' });
return Promise.resolve(null);
},
});
const composer = within(surface).getByLabelText('陶泥儿对话内容');
await submitDirectTurn(surface, composer, '第一条消息');
@@ -484,8 +487,12 @@ export function registerChatComposerControlTests() {
expect(within(queue).getAllByRole('listitem')).toHaveLength(1);
});
// 队列放行听的是**回合终态**,不是命令返回:接单之后命令早就回来了。
act(() => {
pending[0]?.resolve('第一条回复');
harness.emitDirectThreadEvents({
type: 'turn.completed',
status: 'completed',
});
});
await waitFor(() => {
expect(invoke).toHaveBeenCalledWith(
@@ -500,7 +507,10 @@ export function registerChatComposerControlTests() {
expect(sentTexts).toEqual(['第一条消息', '第三条消息']);
act(() => {
pending[1]?.resolve('第三条回复');
harness.emitDirectThreadEvents({
type: 'turn.completed',
status: 'completed',
});
});
await waitFor(() => {
expect(within(surface).queryByLabelText('待发送消息队列')).toBeNull();
@@ -557,14 +557,30 @@ describe('DirectProject 聊天 reducer', () => {
expect(next.turnEndedAt).toBe(0);
});
it('同一轮内的重复开始事件保留第一次的起点', () => {
it('不再为同一轮内的重复开始事件做兼容:起点就是最后一条开始事件', () => {
// 宿主在接单时**只发一次** `turn.started`Thread Manager 的接单动作),线上不存在"同一轮
// 里又来一条开始事件"。所以这里不再保留第一次的起点:真出现重复那是事件源的问题,reducer
// 按事件顺序照实收下,不替它编一个更早的起点。
const started = reduceDirectThreadEvents(emptyDirectThreadChatState(), [
event({ type: 'turn.started', at: 1_000_000 }),
event({ type: 'turn.started', at: 1_000_000 }),
event({ type: 'turn.started', at: 1_000_500 }),
]);
expect(started.turnRunning).toBe(true);
expect(started.turnStartedAt).toBe(1_000_000);
expect(started.turnStartedAt).toBe(1_000_500);
});
it('收口计数是状态:同一批里开始又结束也数得到,重复终态不重复计数', () => {
// 队列放行与埋点结算读这个计数,而不是 `turnRunning` 的下降沿——一轮可能在同一次
// consume 里开始并结束(接单后立刻失败),那时下降沿永远不会出现。
const batched = reduceDirectThreadEvents(emptyDirectThreadChatState(), [
event({ type: 'turn.started', at: 1_000_000 }),
event({ type: 'turn.completed', status: 'failed', at: 1_000_400 }),
]);
expect(batched.completedTurnCount).toBe(1);
const replayed = reduceDirectThreadEvents(batched, [
event({ type: 'turn.completed', status: 'failed', at: 1_000_400 }),
]);
expect(replayed.completedTurnCount).toBe(1);
});
it('终态之后同身份的迟到条目补进历史,不挂到下一轮运行态', () => {