From cfcd3cd604782307993cc6d1dd7c115a082c0d52 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Tue, 22 Sep 2026 14:07:07 +0800 Subject: [PATCH] =?UTF-8?q?DirectProject=20=E7=A9=BA=E7=AA=97=E6=9C=9F?= =?UTF-8?q?=E4=B8=8D=E5=86=8D=E8=B0=8E=E6=8A=A5=E6=9C=AC=E8=BD=AE=E7=BB=93?= =?UTF-8?q?=E6=9D=9F=EF=BC=9A=E4=B8=89=E6=80=81=E6=8E=A5=E5=85=A5=E6=B8=B2?= =?UTF-8?q?=E6=9F=93?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - DirectProjectTurnUsage 渲染判据改为 state !== 'finished',awaiting-start 不再显示「本轮结束于 … 耗时 0.0秒」 - DirectProjectTurn 的过程折叠判据同样改为 state !== 'finished';流式正文与工具卡片仍按 state === 'running' - 组件注释写明「否定式读 !== 'finished'、肯定式读 === 'running'」这条分工,并标出仍由 A 负责的另一半兜底与覆盖范围 - 新增 tests/directProjectTurn.test.tsx:awaiting-start / running 不显示终态文案且不折叠、finished 有终态时显示结束时间与耗时 - appSurface chat-composer 新增窗口期用例:invoke 挂起且无任何原生事件时不出现「本轮结束于」 - decision-log 追加本条决策、保守卡片口径的理由、未修边界与变异验证结论 --- .../DirectProjectTurn.tsx | 22 +++++-- .../tests/appSurface/chat-composer.suite.ts | 24 ++++++++ .../tests/directProjectTurn.test.tsx | 61 +++++++++++++++++++ .../shared-memory/decision-log.md | 13 +++- 4 files changed, 112 insertions(+), 8 deletions(-) create mode 100644 apps/ai-game-creator-shell/tests/directProjectTurn.test.tsx diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectConversation/DirectProjectTurn.tsx b/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectConversation/DirectProjectTurn.tsx index 1345343b0..362ecb5b4 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectConversation/DirectProjectTurn.tsx +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectConversation/DirectProjectTurn.tsx @@ -20,8 +20,15 @@ import { /** * 一个完整回合的分区表现:用户发言、执行过程(工具/思考)与最终答复。 * - * 运行中的回合把执行过程平铺出来,已结束的回合折叠进「执行过程」;这一层只做投影到 - * 表现的渲染,不拥有任何回合状态。 + * 未结束的回合(`running` / `awaiting-start`)把执行过程平铺出来并隐藏终态文案, + * `finished` 才折叠进「执行过程」;这一层只做投影到表现的渲染,不拥有任何回合状态。 + * + * 三态的判据分两类,不要对调(三态定义与真值表见 + * `../../conversation/directTurnPresentation.ts` 的 `DirectChatTurnState`): + * - **否定式**(不要说它结束、不要折叠、不要显示终态文案)读 `state !== 'finished'`: + * `awaiting-start` 时轮次确实还没结束,只是宿主还没确认。 + * - **肯定式**(哪一段正文在流式、"正在处理"这类断言)读 `state === 'running'`: + * `awaiting-start` 只说明本地已发出,不能据此断言宿主已经在跑。 */ export function DirectProjectTurn({ turn }: { turn: DirectChatTurn }) { const streamingKey = @@ -49,7 +56,7 @@ function renderTurnProcess(turn: DirectChatTurn, streamingKey: string | null) { const blocks = turn.process.map((block) => renderBlock(turn, block, 'process', streamingKey), ); - if (turn.state === 'running') return blocks; + if (turn.state !== 'finished') return blocks; return (
执行过程 @@ -59,9 +66,12 @@ function renderTurnProcess(turn: DirectChatTurn, streamingKey: string | null) { } function DirectProjectTurnUsage({ turn }: { turn: DirectChatTurn }) { - // 已知缺陷(另开改动):`awaiting-start` 目前与 `finished` 同渲染,所以本地已发出、 - // 原生还没认领的那个窗口里会显示「本轮结束于 … · 耗时 0.0秒」。 - if (turn.state === 'running' || !turn.startedAt) return null; + // 否定式判据:未结束的回合不显示终态文案。`awaiting-start` 走这一条,所以"本地已发出、 + // 原生还没认领"的窗口里不会再出现「本轮结束于 … 耗时 0.0秒」。 + // 仍未修的另一半:`finished` 但没有可证明终态时间的回合(例如重进项目后读回来的历史回合, + // 以及发送后没有产生任何原生事件的本地回合)仍会被下面的 `Math.max` 兜底量化成 0.0 秒; + // 覆盖范围和产品口径见 decision-log 的 2026-09-22 条目。 + if (turn.state !== 'finished' || !turn.startedAt) return null; const endedAt = Math.max(turn.endedAt, turn.startedAt); return (

{ + const pending: Array<{ resolve: (value: string) => void }> = []; + const { surface } = await openDirectCodexSurface({ + chat_with_game_creator_direct_codex: () => + new Promise((resolve) => { + pending.push({ resolve }); + }), + }); + const composer = within(surface).getByLabelText('陶泥儿对话内容'); + await submitDirectTurn(surface, composer, '窗口期的消息'); + + // 本地乐观气泡立刻可见;此刻原生既没回 turn.started,也没回显用户条目, + // 这一轮属于「本地已发出、宿主未确认」,不得渲染成已结束。 + await waitFor(() => { + expect(within(surface).getByText('窗口期的消息')).not.toBeNull(); + }); + expect(within(surface).queryByText(/本轮结束于/)).toBeNull(); + expect(within(surface).queryByTestId('turn-usage')).toBeNull(); + + await act(async () => { + pending[0]?.resolve('回复'); + }); + }); + it('restores a running DirectProject turn, queues the next message, and dispatches it on turn.completed', async () => { const pending: Array<{ resolve: (value: string) => void }> = []; const { invoke, surface, harness } = await openDirectCodexSurface( diff --git a/apps/ai-game-creator-shell/tests/directProjectTurn.test.tsx b/apps/ai-game-creator-shell/tests/directProjectTurn.test.tsx new file mode 100644 index 000000000..1561a54e7 --- /dev/null +++ b/apps/ai-game-creator-shell/tests/directProjectTurn.test.tsx @@ -0,0 +1,61 @@ +/** @vitest-environment jsdom */ +import { render } from '@testing-library/react'; +import React from 'react'; +import { expect, it } from 'vitest'; + +import { DirectProjectTurn } from '../src/view/project-development/chat/components/DirectProjectConversation/DirectProjectTurn'; +import type { + DirectChatTurn, + DirectChatTurnState, +} from '../src/view/project-development/chat/conversation/directTurnPresentation'; + +const SENT_AT = 1_800_000_000_000; +const ENDED_AT = SENT_AT + 12_400; + +const turn = ( + state: DirectChatTurnState, + overrides: Partial = {}, +): DirectChatTurn => ({ + key: 'direct-codex:turn-1:user', + users: [{ kind: 'user', key: 'u', text: '帮我做一个跳跃动作', at: SENT_AT }], + process: [ + { kind: 'reasoning', key: 'r', text: '先看目录' }, + { kind: 'assistant', key: 'a', text: '正在写' }, + ], + finals: [{ kind: 'assistant', key: 'a-final', text: '改好了', at: ENDED_AT }], + state, + startedAt: SENT_AT, + endedAt: state === 'finished' ? ENDED_AT : 0, + ...overrides, +}); + +it('awaiting-start:本地已发出、原生还没认领时不显示终态文案,也不折叠过程', () => { + const view = render( + React.createElement(DirectProjectTurn, { + turn: turn('awaiting-start'), + }), + ); + expect(view.queryByTestId('turn-usage')).toBeNull(); + expect(view.queryByText(/本轮结束于/)).toBeNull(); + // 否定式判据:未结束的回合把过程平铺出来,不折进「执行过程」。 + expect(view.queryByTestId('turn-process')).toBeNull(); + expect(view.getByLabelText('思考过程')).not.toBeNull(); +}); + +it('running:原生回合在跑时同样不显示终态文案', () => { + const view = render( + React.createElement(DirectProjectTurn, { turn: turn('running') }), + ); + expect(view.queryByTestId('turn-usage')).toBeNull(); + expect(view.queryByTestId('turn-process')).toBeNull(); +}); + +it('finished 且有明确终态:显示结束时间与耗时,过程折叠', () => { + const view = render( + React.createElement(DirectProjectTurn, { turn: turn('finished') }), + ); + const usage = view.getByTestId('turn-usage'); + expect(usage.textContent).toContain('本轮结束于'); + expect(usage.textContent).toContain('耗时 12.4秒'); + expect(view.queryByTestId('turn-process')).not.toBeNull(); +}); diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 8f330e4e5..fad14aa78 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -9318,6 +9318,15 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在 - 背景:DirectProject 聊天框只有三份真相源(`project.jsonl` 历史切片、Thread Manager 运行态事件、本地乐观消息),但「这一轮在跑吗」在四层里各叫一个名字——reducer 的 `turnRunning`、controller 的 `turnBusy`、视图里手拼的 `busy`、投影里的 `active`。定位发送后空窗缺陷时,读代码无法判断某个窗口期的界面表现是否有依据,也说不清谁该信谁。 - 决策(三态取代布尔):`DirectChatTurn.active` 改为 `DirectChatTurn.state: 'running' | 'awaiting-start' | 'finished'`。`running` 只由 reducer 的 `turnRunning` 决定;`awaiting-start` 由「最新一轮的用户条目身份 = 本地在途的 `pendingUserItemId`(`direct-codex:{clientTurnId}:user`)、且本轮还没有明确终态」决定;其余是 `finished`。判据是身份不是时间戳,`pendingUserItemId` 由 controller 在 `beginTurnCommand()` / `endTurnCommand()` 里与 `turnBusy` 同生共死。 - 决策(单一派生入口):新增 `useDirectProjectTurnStatus()`,返回 `{ nativeRunning, commandInFlight, displayBusy, latestTurnState }`。header / composer 只读 `displayBusy`(两者并集,语义与原来的 `turnBusy || directTurnRunning` 完全一致),「陶泥儿正在处理」卡片只读 `nativeRunning`。约定:新增「忙 / 在跑」类判据先落进这里,不在组件里另拼布尔。 -- 决策(地图落代码):三层数据流、三份原始输入、一次发送的时序(含空窗步骤)与状态变量归属写进 `apps/ai-game-creator-shell/src/view/project-development/chat/controller/useDirectProjectChatController.ts` 的模块注释;回合三态的定义与判据真值表写进 `.../conversation/directTurnPresentation.ts` 的 `DirectChatTurnState`。这套说明是给改这块代码的人看的,放代码里才不会与实现脱节;ADR 补一条「活动回合唯一判据约束的是**原生回合**」的澄清与代码指针。 -- 边界(本次不修):`awaiting-start` 暂时与 `finished` 同渲染,所以空窗期内仍会显示「本轮结束于 <用户发送时间> · 耗时 0.0秒」;`Math.max(turn.endedAt, turn.startedAt)` 的兜底与 `DirectProjectTurnUsage` 的渲染条件都没动。同源的第二条缺陷也记录在案:`turnEndedAt` 只是会话内展示缓存,页面重进后所有已结束回合都会走同一条兜底显示 0.0 秒(临时渲染用例实测确认,用例未入库)。修法与证据要求见后续那次三态接入渲染的改动,以及 `DirectProjectTurn.tsx` 里标注未修范围的注释。 +- 决策(地图落代码):三层数据流、三份原始输入、一次发送的时序(含空窗步骤)与状态变量归属写进 `apps/ai-game-creator-shell/src/view/project-development/chat/controller/useDirectProjectChatController.ts` 的模块注释;回合三态的定义与判据真值表写进 `.../conversation/directTurnPresentation.ts` 的 `DirectChatTurnState`。不另开技术方案文档——这套说明是给改这块代码的人看的,放代码里才不会与实现脱节。ADR 补一条「活动回合唯一判据约束的是**原生回合**」的澄清与代码指针。 +- 口径更正(2026-09-22,同日第二条):本条记录的「`awaiting-start` 暂时与 `finished` 同渲染」已由随后的三态接入渲染改动修掉,见下面那条。 +- 边界(本次不修):`awaiting-start` 暂时与 `finished` 同渲染,所以空窗期内仍会显示「本轮结束于 <用户发送时间> · 耗时 0.0秒」;`Math.max(turn.endedAt, turn.startedAt)` 的兜底与 `DirectProjectTurnUsage` 的渲染条件都没动。同源的第二条缺陷也记录在案:`turnEndedAt` 只是会话内展示缓存,页面重进后所有已结束回合都会走同一条兜底显示 0.0 秒(临时渲染用例实测确认,用例未入库)。修法与证据要求见下面那条(三态接入渲染)以及 `DirectProjectTurn.tsx` 里标注未修范围的注释。 - 验证:`npx vitest run` 定向 `directTurnPresentation`(17 条,新增 4 条三态用例)、`directProjectTurnStatus`(4 条)、`directHistoryPaging`(9 条)全绿;`appSurface.test.ts` 202 passed / 13 skipped;`tsc -p apps/ai-game-creator-shell/tsconfig.json --noEmit`、`npm run check:encoding`、`npm run check:doc-index`、`git diff --check` 通过。真实客户端观感与窗口期表现未在客户端复核。 + +## 2026-09-22 空窗期不再谎报「本轮结束」:DirectProject 三态接入渲染 + +- 背景:上一条只把回合三态显式化,渲染层仍按 `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` 里那段说明未修范围的注释。 +- 验证:`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` 通过。真实客户端观感未复核。