把 DirectProject 聊天状态与数据流的说明写进代码注释

- useDirectProjectChatController 模块注释补三层地图、三份原始输入、一次发送的时序(含空窗步骤)与状态变量归属
- DirectChatTurnState 注明三态各自能断言什么、判据的四个输入及来路、state 真值表、两个易读错的点与「finished 拿不到终态时间」的已知边界
- DirectThreadChatState.turnRunning 注明它只是原生真相,不等于界面上的「这一轮在跑吗」
- DirectProjectTurnUsage 注释写清两类「finished 却拿不到终态时间」的回合、覆盖范围与修法口径,说明不再甩给任何文档
- decision-log 的未修边界条目改为指向上述代码位置,避免同一套说明在代码与文档里各留一份
This commit is contained in:
2026-09-22 14:07:35 +08:00
parent cfcd3cd604
commit 417137baf8
6 changed files with 110 additions and 13 deletions
@@ -68,9 +68,11 @@ function renderTurnProcess(turn: DirectChatTurn, streamingKey: string | null) {
function DirectProjectTurnUsage({ turn }: { turn: DirectChatTurn }) {
// 否定式判据:未结束的回合不显示终态文案。`awaiting-start` 走这一条,所以"本地已发出、
// 原生还没认领"的窗口里不会再出现「本轮结束于 … 耗时 0.0秒」。
// 仍未修的另一半:`finished` 但没有可证明终态时间的回合(例如重进项目后读回来的历史回合,
// 以及发送后没有产生任何原生事件的本地回合)仍会被下面的 `Math.max` 兜底量化成 0.0 秒;
// 覆盖范围和产品口径见 decision-log 的 2026-09-22 条目
// 仍未修的另一半A`finished` 但没有可证明终态时间的回合,会被下面的 `Math.max` 兜底
// 量化成 0.0 秒,共两类——① 重进项目后读回来的历史回合(`turnEndedAt` 只活在本次会话里,
// 不会随 `project.jsonl` 持久化);② 本地已发出却一个原生事件都没产生的回合(发送失败)
// 修法是只在 `turn.endedAt > 0` 时渲染终态文案、耗时改由 `turnTotalDurationMs()` 出(边界缺失
// 就隐藏),属于产品口径变化(宁可隐藏也不编),确认后单独改;改完把这半段注释删掉。
if (turn.state !== 'finished' || !turn.startedAt) return null;
const endedAt = Math.max(turn.endedAt, turn.startedAt);
return (
@@ -91,6 +91,54 @@ export type DirectProjectChatControllerProps = {
*
* 订阅、首屏锚点与历史分页、发送与 FIFO 队列、附件、终止、草稿和本地消息都归这里;
* 工作台壳只注入项目上下文与两条权限门,不再持有 Direct 专属 state/ref/effect。
*
* ## 数据流(改判据前先读这一段)
*
* ```
* Rust 宿主(事实的产生地)
* ├─ project.jsonl 持久化原始条目(AGC 写;app-server 回显的用户消息被过滤)
* └─ Thread Manager 事件队列(内存) per-thread 事件序列 + 每 subscriber 游标 + lifecycle_anchor
* │ append_direct_thread_event() → notify(只带 subscriptionId,纯唤醒)
* ▼
* 传输层(三条通道,前端各拉各的)
* A 运行态:notify → invoke consume_direct_project_thread → events[](实时)
* B 历史: invoke read_direct_project_history_slice → items[](分页,文件尾反向扫描)
* C 本地: 前端自己造(乐观用户气泡、忙态、失败 / 终止说明)
* ▼
* 前端
* useDirectThreadChatSubscription reducerA + B 进同一份 stateturnRunning / history / live
* ▼
* useDirectProjectChatController 本地状态:localMessages / turnBusy / pendingUserItemId / 队列
* ▼
* DirectProjectChatView turns = buildDirectChatTurns(...)status = useDirectProjectTurnStatus(...)
* ▼
* DirectProjectTurn 用户气泡 / 过程块 / 最终回复 / 本轮耗时
* ```
*
* 三份原始输入各自是什么、带什么、活多久:
* - 项目对话历史(`.agent/conversations/project.jsonl`):持久,只有条目、**没有回合边界**,
* 经历史切片读取(首屏按 `lastCompletedItemId` 锚定)。
* - 运行态事件(subscribe / consume / notify):进程内;`turn.started` / `turn.completed` 是原生回合
* 活跃与否的**唯一**判据;可回收事件被回收后靠 `lifecycle_anchor` 保住最新一条生命周期事件。
* - 本地发送:只存在于本次会话,`projectPath` 变化即清空;乐观气泡与原生条目同身份
* `direct-codex:{clientTurnId}:user`),所以两边按**身份**合并,不按时间戳猜。
*
* 一次发送的时序(第 2 → 3 步之间就是「本地已发出、宿主还没确认」的空窗):
* 1. 按下发送:`localMessages += 乐观气泡`、`turnBusy=true`、`pendingUserItemId=本轮身份`(同帧)。
* 2. `invoke('chat_with_game_creator_direct_codex')`Rust 先落盘用户条目,再发 `turn/start`
* **应答返回后**才 append `turn.started` 并 notify。
* 3. notify → consume → `turn.started`reducer 的 `turnRunning=true`、`turnStartedAt`、`turnUserItemId`。
* 4. `item.completed`(本轮用户条目回显):同身份条目已在历史里就合并进去,否则进 `live`;本地气泡此时被去重。
* 5. `item.delta` / `item.started` / `item.completed`:正文追加、工具卡片 upsert(先到定形、后到只补空)。
* 6. `turn.completed``live` 并入 `history` 后清空,`turnEndedAt` 冻结,边界按身份盖到本轮开口条目上。
* 7. 命令收尾(`finally`):`endTurnCommand()` 清掉忙态与在途身份,刷新清单,按边沿推进队列出队。
*
* 状态变量归属:reducer 的三个回合字段与 `history` / `live` 只由 `directThreadChat.ts` 写;
* 本文件的 `turnBusy` / `pendingUserItemId`(同生共死,唯一入口 `beginTurnCommand` /
* `endTurnCommand`)、`localMessages`、发送队列与分页 ref 只服务发送与展示;界面上的
* 「这一轮在跑吗」只有一个派生入口 `useDirectProjectTurnStatus()`,三态判据与真值表在
* `../conversation/directTurnPresentation.ts` 的 `DirectChatTurnState`,渲染时否定式读
* `state !== 'finished'`、肯定式读 `state === 'running'`(见 `DirectProjectTurn.tsx`)。
*/
export function useDirectProjectChatController({
assets,
@@ -21,6 +21,9 @@ import type {
* - `latestTurnState`:最新一轮在界面上的三态(投影结果);没有回合时为 null。
*
* 约定:新增"忙/在跑"类判据一律先落进这里,不要在组件里再拼布尔。
* `latestTurnState` 三态各自的含义、判据输入与真值表写在
* `../conversation/directTurnPresentation.ts` 的 `DirectChatTurnState`。
* 数据流、变量归属与一次发送的时序见 `useDirectProjectChatController.ts` 的模块注释。
*/
export type DirectProjectTurnStatus = {
nativeRunning: boolean;
@@ -45,7 +45,14 @@ export type DirectChatEntry = {
};
export type DirectThreadChatState = {
/** 最新回合是否还在跑;只由生命周期事件的先后决定。 */
/**
* 最新**原生**回合是否还在跑;只由生命周期事件(`turn.started` / `turn.completed`
* 的先后决定。
*
* 它不等于界面上的「这一轮在跑吗」:本地已发出、宿主还没回 `turn.started` 的那一段
* 窗口里它为假,但那一轮在界面上是"待认领"而不是"已结束"。界面侧的三态与判据见
* `directTurnPresentation.ts` 的 `DirectChatTurnState`。
*/
turnRunning: boolean;
/** 原生 `turn.started.at`:本轮用户实际发送时间缺失时的起点兜底;0 = 缺失。 */
turnStartedAt: number;
@@ -37,16 +37,53 @@ export type DirectChatBlock =
| { kind: 'tools'; key: string; calls: DirectChatToolCard[] };
/**
* 界面上一轮的三态。
* 界面上一轮的三态。它是**展示态**,不是第二套回合生命周期。
*
* - `running`:原生回合在跑(订阅流里出现过 `turn.started`、还没出现 `turn.completed`)。
* - `awaiting-start`:本地已发出、原生还没认领(乐观用户气泡已出现,`turn.started` 未到)。
* - `finished`:拿到明确终态,或不再有理由认为它在跑
* 三态各自能断言什么(渲染时按这个分两类,不要对调):
* - `running`:**宿主已确认这一轮开始了**(订阅流里出现过 `turn.started`、还没出现
* `turn.completed`)。它是唯一能做肯定式断言的态
* - `awaiting-start`**本地已把这轮交出去、宿主还没确认**(乐观气泡已出现,`turn.started`
* 未到)。只支持否定式断言:"它还没结束",不能说"它正在跑"。
* - `finished`:其余全部 —— 拿到终态的、身份不匹配的、不是最新一轮的,以及**拿不到边界的
* 历史回合**(这类最容易被误判成"还在跑",必须落在这一态)。
*
* 它是**展示态**,不是第二套回合生命周期:`running` 只由 reducer 的 `turnRunning` 决定,
* `awaiting-start` 只由「这条用户条目身份就是本地这次发送的身份、且本轮还没有终态」决定。
* 数据流与变量归属见 `../controller/useDirectProjectChatController.ts` 的模块注释;
* 三态在渲染上的映射见 `DirectProjectTurn.tsx`
* 判据用四个输入(下方 `buildDirectChatTurns` 里那几句 if 就是全部实现):
* - `turn.nativeRunning` ← 入参 `turnRunning` ← reducer 的 `state.turnRunning`
* (只由 `turn.started` / `turn.completed` 决定;`if (current)` 只赋给最后一条回合,
* 所以「非最新一轮 + nativeRunning」不可达)
* - `pendingUserItemId` ← controller 在 `beginTurnCommand` / `endTurnCommand` 之间维护,
* 生命周期与 `turnBusy` 一致;空串 = 没有在途的本地回合。
* - `turn.key` ← 开这一轮的条目身份:原生用户条目用 `entry.itemId`,本地乐观气泡用
* `message.messageId` —— 两者是**同一个** `direct-codex:{clientTurnId}:user`。
* - `stampedEnd` ← 本轮条目上盖的终态时间,只有 `turn.completed` / 终止收口才写。
*
* 真值表:
*
* | nativeRunning | 最新一轮 && pendingUserItemId 身份命中 | stampedEnd > 0 | → state |
* | true | — | — | running |
* | false | false | 任意 | finished |
* | false | true | true | finished |
* | false | true | false | awaiting-start |
*
* 判据一律用**身份与显式事件**,不用时间戳大小:原生阶段时间是秒级精度、同一秒里可能连开
* 两轮,回显条目的 `at` 还是宿主 ack 的观测时间(晚于用户真实发送)。这也是为什么
* `awaiting-start` 在"原生条目已回显、`turn.started` 未到"的次窗口里同样成立。
*
* 两个容易读错的地方:
* - `pendingUserItemId` 有值 **≠** `awaiting-start``invoke` 直到整轮结束才返回,所以
* `turn.started` 之后它仍在,但那时 `nativeRunning` 已经把它接成 `running`。
* - `endedAt === 0` **≠** 还在跑:历史回合没有边界元数据(`turnEndedAt` 只是会话内展示
* 缓存),它们必须落 `finished`。
*
* 三态在渲染上的映射(否定式 / 肯定式)见 `DirectProjectTurn.tsx` 顶部注释;数据流、变量归属与
* 一次发送的时序见 `../controller/useDirectProjectChatController.ts` 的模块注释。
*
* 已知边界(改 `finished` 判据时要连着一起看):`finished` 只断言"不再有理由认为它在跑",
* **不断言"拿得到终态时间"**。有两类回合没有可证明的边界时间,只被
* `Math.max(turn.endedAt, turn.startedAt)` 兜底量化成 0.0 秒——① 重进项目后读回来的历史回合
* `turnEndedAt` 只是会话内展示缓存,不随 `project.jsonl` 持久化);② 本地已发出却一个原生事件
* 都没产生的回合(发送失败、`turn.started` 没来)。要不要把这两类的终态文案藏掉是产品口径问题
* (宁可隐藏也不编),需要单独确认后单独改,不要顺手塞进三态判据。
*/
export type DirectChatTurnState = 'running' | 'awaiting-start' | 'finished';
@@ -9328,5 +9328,5 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在
- 背景:上一条只把回合三态显式化,渲染层仍按 `state === 'running'` 判断,于是「本地已发出、宿主还没回 `turn.started`」的窗口里 `awaiting-start` 被当成 `finished` 渲染,显示「本轮结束于 <用户发送时间> · 耗时 0.0秒」(用户现场反馈的现象)。
- 决策(判据分两类,不许对调):**否定式**判断(不要说它结束、不要折叠过程、不要显示终态文案)读 `state !== 'finished'`;**肯定式**判断(哪段正文在流式、「陶泥儿正在处理」卡片与滚动已耗时)读 `state === 'running'`。理由是 `awaiting-start` 能支持"还没结束",但不能支持"宿主已经在跑"——后者只有 `turn.started` 能证明。
- 决策(卡片口径取保守):`DirectProjectConversation` 的「正在处理」卡片与 `activeTurnStartedAt` 仍只认 `turnStatus.nativeRunning`,窗口期不出现这张卡片。文案是「陶泥儿正在处理」,在 `turn.started` 之前无法断言宿主已经开始,这与空窗缺陷是同一个病根(把"本地已发出"当成"宿主已在跑");窗口期用户看到的是"消息已发出 + 输入框忙",语义诚实。若将来改成窗口期也显示卡片,`running` 在渲染层就没有消费者了,那时应把投影压成 `unfinished: boolean`,不要留一个没人读的状态成员。
- 边界(A 仍未修):`DirectProjectTurnUsage``Math.max(turn.endedAt, turn.startedAt)` 兜底没动,所以两类 `finished` 回合仍显示「耗时 0.0秒」——① 页面重进后读回来的历史回合(`turnEndedAt` 只是会话内展示缓存);② 发送后没有产生任何原生事件 / 发送失败的本地回合。修法是只在 `endedAt > 0` 时渲染终态文案并把耗时交给 `turnTotalDurationMs()`,属于产品口径变化(宁可隐藏也不编),需要单独确认。修法落地后要同步删除 `DirectProjectTurn.tsx` 里那段说明未修范围的注释。
- 边界(A 仍未修):`DirectProjectTurnUsage``Math.max(turn.endedAt, turn.startedAt)` 兜底没动,所以两类 `finished` 回合仍显示「耗时 0.0秒」——① 页面重进后读回来的历史回合(`turnEndedAt` 只是会话内展示缓存);② 发送后没有产生任何原生事件 / 发送失败的本地回合。为什么会有这两类、修法与要产品确认的口径都写在代码里(`DirectProjectTurn.tsx``DirectProjectTurnUsage` 注释与 `directTurnPresentation.ts``DirectChatTurnState` 注释),改完删掉那段注释。
- 验证:`tests/directProjectTurn.test.tsx`(新增 3 条渲染契约:`awaiting-start``running` 不显示终态文案且不折叠、`finished` 有终态时显示结束时间与耗时);`tests/appSurface/chat-composer.suite.ts` 新增 `does not report a finished turn while the host has not acknowledged the send yet`(invoke 挂起、无任何原生事件时断言不出现「本轮结束于」);变异验证:把 `state !== 'finished'` 退回 `state === 'running'` 后渲染契约用例变红,恢复即绿。定向 vitest、`appSurface.test.ts`203 passed / 13 skipped)、`tsc`、ESLint、Prettier、`check:encoding``check:doc-index``git diff --check` 通过。真实客户端观感未复核。