DirectProject 空窗期不再谎报本轮结束:三态接入渲染

- 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 追加本条决策、保守卡片口径的理由、未修边界与变异验证结论
This commit is contained in:
2026-09-22 14:07:07 +08:00
parent 860dcb1bf5
commit cfcd3cd604
4 changed files with 112 additions and 8 deletions
@@ -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 (
<details className="message-turn-process" data-testid="turn-process">
<summary>执行过程</summary>
@@ -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 (
<p
@@ -469,6 +469,30 @@ export function registerChatComposerControlTests() {
});
});
it('does not report a finished turn while the host has not acknowledged the send yet', async () => {
const pending: Array<{ resolve: (value: string) => void }> = [];
const { surface } = await openDirectCodexSurface({
chat_with_game_creator_direct_codex: () =>
new Promise<string>((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(
@@ -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> = {},
): 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();
});