把 DirectProject 聊天状态与数据流的说明写进代码注释
- useDirectProjectChatController 模块注释补三层地图、三份原始输入、一次发送的时序(含空窗步骤)与状态变量归属 - DirectChatTurnState 注明三态各自能断言什么、判据的四个输入及来路、state 真值表、两个易读错的点与「finished 拿不到终态时间」的已知边界 - DirectThreadChatState.turnRunning 注明它只是原生真相,不等于界面上的「这一轮在跑吗」 - DirectProjectTurnUsage 注释写清两类「finished 却拿不到终态时间」的回合、覆盖范围与修法口径,说明不再甩给任何文档 - decision-log 的未修边界条目改为指向上述代码位置,避免同一套说明在代码与文档里各留一份
This commit is contained in:
+5
-3
@@ -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 (
|
||||
|
||||
+48
@@ -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 reducer:A + B 进同一份 state(turnRunning / 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,
|
||||
|
||||
+3
@@ -21,6 +21,9 @@ import type {
|
||||
* - `latestTurnState`:最新一轮在界面上的三态(投影结果);没有回合时为 null。
|
||||
*
|
||||
* 约定:新增"忙/在跑"类判据一律先落进这里,不要在组件里再拼布尔。
|
||||
* `latestTurnState` 三态各自的含义、判据输入与真值表写在
|
||||
* `../conversation/directTurnPresentation.ts` 的 `DirectChatTurnState`。
|
||||
* 数据流、变量归属与一次发送的时序见 `useDirectProjectChatController.ts` 的模块注释。
|
||||
*/
|
||||
export type DirectProjectTurnStatus = {
|
||||
nativeRunning: boolean;
|
||||
|
||||
+8
-1
@@ -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;
|
||||
|
||||
+45
-8
@@ -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` 通过。真实客户端观感未复核。
|
||||
|
||||
Reference in New Issue
Block a user