Files
Genarrative/apps/ai-game-creator-shell/tests/directTurnPresentation.test.ts
k88936 1ff1a9965b 前端:删掉 Direct 的本地乐观用户气泡,用户气泡只来自宿主条目
- controller 删 `pendingUserItemId` / `beginTurnCommand` / `endTurnCommand`:忙态改由 `beginTurnBusy` / `endTurnBusy` 持有,宿主认领判据 = `turnRunning` 或收口计数变过(一轮在同一次 consume 里开始并结束)
- controller 删 `startTurn` 的乐观追加与 `messageAppended` 重跑参数、`DirectProjectTurnInput.messageText` 与首轮的 `directInitialTurnText`;controller 不再需要 `assets`
- 投影删 `awaiting-start` 展示态(只剩 `running` / `finished`)、`localSentTimes` / `sameIdentitySentAt`、本地用户气泡与它开回合的路径;带身份的拒单提示在会话末尾自成一组,不挂进上一轮,也不开运行态标记
- 时间口径:起点只认 `turn.started.at`、终点只认 `turn.completed.at`,用户气泡时钟取宿主落盘 / 观测时间;两边都空的回合整条「本轮结束于 … 」隐藏,不再出现 0.0 秒
- 用例:改造 `directTurnPresentation`(本地用户消息不进回合、拒单提示自成一组、两态判据、失败说明按身份归位)、`directProjectTurn` / `directProjectTurnStatus` / appSurface 窗口期用例,`directProjectTurn` 补 `afterEach(cleanup)`
- 注释与文档:ADR「命令接单化」后续更新、实施计划新增「删掉本地乐观用户气泡」、decision-log 与 pitfalls 同日条目、Codex 原始历史方案的口径句、`codex_app_server` 用户条目时间注释
2026-09-24 18:20:24 +08:00

423 lines
15 KiB
TypeScript

import { describe, expect, it } from 'vitest';
import type { ChatMessage } from '../src/app/types';
import type { DirectChatEntry } from '../src/view/project-development/chat/conversation/directThreadChat';
import {
buildDirectChatTurns,
directChatEntriesToMessages,
directMessageTimestamp,
mergeDirectChatMessages,
normalizeDirectTimestamp,
} from '../src/view/project-development/chat/conversation/directTurnPresentation';
const userEntry = (
itemId: string,
at = 1_800_000_000_000,
): DirectChatEntry => ({
itemId,
kind: 'message',
role: 'user',
text: `问题 ${itemId}`,
at,
});
const assistantEntry = (
itemId: string,
text: string,
at = 1_800_000_001_000,
): DirectChatEntry => ({
itemId,
kind: 'message',
role: 'assistant',
text,
at,
});
const toolEntry = (
itemId: string,
at = 1_800_000_000_500,
): DirectChatEntry => ({
itemId,
kind: 'tool',
role: null,
text: null,
at,
toolCall: {
schemaVersion: 'agc-tool-call.v1',
id: itemId,
kind: 'command',
title: '执行命令',
summary: 'npm run build',
status: 'completed',
detail: { command: 'npm run build' },
startedAt: at,
updatedAt: at,
},
});
/** 工具条目:边界只来自事件级时间,历史切片拿不到就留 0。 */
const liveToolEntry = (
itemId: string,
startedAt: number,
updatedAt: number,
status: 'running' | 'completed' = 'completed',
): DirectChatEntry => ({
itemId,
kind: 'tool',
role: null,
text: null,
at: 0,
toolCall: {
schemaVersion: 'agc-tool-call.v1',
id: itemId,
kind: 'command',
title: '执行命令',
summary: 'npm run build',
status,
detail: { command: 'npm run build' },
startedAt,
updatedAt,
},
});
const reasoningEntry = (
itemId: string,
text = '先看目录',
at = 1_800_000_000_200,
): DirectChatEntry => ({
itemId,
kind: 'reasoning',
role: null,
text,
at,
});
const localNotice = (text: string, messageId?: string): ChatMessage => ({
role: 'assistant',
text,
...(messageId ? { messageId } : {}),
updatedAt: 1_800_000_002_500,
});
describe('DirectProject 聊天分区', () => {
it('把 Unix 秒时间戳归一化为毫秒,已有毫秒值保持不变', () => {
expect(normalizeDirectTimestamp(1_800_000_000)).toBe(1_800_000_000_000);
expect(normalizeDirectTimestamp(1_800_000_000_000)).toBe(1_800_000_000_000);
expect(directMessageTimestamp(undefined)).toBe(0);
expect(directMessageTimestamp(Number.MAX_VALUE)).toBe(0);
expect(directMessageTimestamp(1_800_000_000_001)).toBe(1_800_000_000_001);
});
it('每个用户条目开一个新回合,顺序就是条目顺序', () => {
const turns = buildDirectChatTurns({
entries: [
userEntry('u1'),
assistantEntry('a1', '第一轮答复'),
userEntry('u2', 1_800_000_010_000),
assistantEntry('a2', '第二轮答复', 1_800_000_011_000),
],
});
expect(turns.map((turn) => turn.key)).toEqual(['u1', 'u2']);
expect(turns[0]?.users[0]).toMatchObject({ text: '问题 u1' });
expect(turns[0]?.finals[0]).toMatchObject({ text: '第一轮答复' });
expect(turns[1]?.finals[0]).toMatchObject({ text: '第二轮答复' });
// 历史条目没有回合边界:起点也是 0,整条终态文案因此隐藏(不按用户条目的落盘时间编一个)。
expect(turns[0]?.startedAt).toBe(0);
// 终点只认明确终态:旧历史条目里没有 `turnEndedAt` 就隐藏,不拿最后一条正文的时间顶替。
expect(turns[0]?.endedAt).toBe(0);
});
it('已结束的回合只把最后一条助手正文当最终回复,中间正文与工具进过程', () => {
const turns = buildDirectChatTurns({
entries: [
userEntry('u1'),
reasoningEntry('r1'),
assistantEntry('a-mid', '我先看一下项目'),
toolEntry('t1'),
assistantEntry('a-final', '改好了', 1_800_000_002_000),
],
});
expect(turns[0]?.process.map((block) => block.key)).toEqual([
'u1:r1',
'u1:a-mid',
'u1:t1',
]);
expect(turns[0]?.finals.map((block) => block.key)).toEqual(['u1:a-final']);
});
it('连续工具合成一块,夹了正文就另起一块', () => {
const turns = buildDirectChatTurns({
entries: [
userEntry('u1'),
toolEntry('t1'),
toolEntry('t2', 1_800_000_000_600),
assistantEntry('a-mid', '继续', 1_800_000_000_700),
toolEntry('t3', 1_800_000_000_800),
],
turnRunning: true,
});
const process = turns[0]?.process ?? [];
expect(process.map((block) => block.kind)).toEqual([
'tools',
'assistant',
'tools',
]);
expect(process[0]).toMatchObject({
kind: 'tools',
calls: [{ id: 't1' }, { id: 't2' }],
});
expect(process[2]).toMatchObject({ kind: 'tools', calls: [{ id: 't3' }] });
});
it('运行中的回合:过程不折叠,最后的助手正文仍在过程里流式显示', () => {
const turns = buildDirectChatTurns({
entries: [
userEntry('u1'),
assistantEntry('a-live', '正在写'),
toolEntry('t1', 1_800_000_001_100),
],
turnRunning: true,
});
expect(turns[0]?.state).toBe('running');
expect(turns[0]?.finals).toEqual([]);
expect(turns[0]?.process.map((block) => block.kind)).toEqual([
'assistant',
'tools',
]);
});
it('整轮边界只落在本轮:旧历史回合没有终态就继续隐藏,不吃最新回合的终点', () => {
const turns = buildDirectChatTurns({
entries: [
// 两个旧回合:历史切片里没有事件级边界。
userEntry('u1', 1_800_000_000_000),
assistantEntry('a1', '第一轮答复', 1_800_000_001_000),
userEntry('u2', 1_800_000_010_000),
assistantEntry('a2', '第二轮答复', 1_800_000_011_000),
// 当前回合:用户发送时间 + 明确终态(收口时盖在本轮条目上)。
{
...userEntry('u3', 1_800_000_020_000),
turnEndedAt: 1_800_000_022_500,
},
liveToolEntry('t3', 1_800_000_021_000, 1_800_000_022_000),
{
...assistantEntry('a3', '第三轮答复', 1_800_000_022_400),
turnStartedAt: 1_800_000_020_000,
turnEndedAt: 1_800_000_022_500,
},
],
});
expect(turns.map((turn) => turn.endedAt)).toEqual([
0, 0, 1_800_000_022_500,
]);
// 旧历史回合并不会因为"拿不到时间"被填上最新回合的终点 / 起点。
expect(turns[0]?.startedAt).toBe(0);
expect(turns[2]?.startedAt).toBe(1_800_000_020_000);
});
it('运行中的整轮起点只认原生 turn.started.at,条目自己的 at 不当起点', () => {
const liveTurn = buildDirectChatTurns({
entries: [
{ ...userEntry('u1', 0), at: 0 },
liveToolEntry('t1', 1_800_000_000_500, 0, 'running'),
],
turnRunning: true,
turnStartedAt: 1_800_000_000_100,
});
// 用户条目没有发送时间:用原生 turn.started.at 兜底。
expect(liveTurn[0]?.startedAt).toBe(1_800_000_000_100);
const withUserTime = buildDirectChatTurns({
entries: [
userEntry('u1', 1_800_000_000_050),
liveToolEntry('t1', 1_800_000_000_500, 0, 'running'),
],
turnRunning: true,
turnStartedAt: 1_800_000_000_100,
});
// 起点只认宿主的回合边界:条目自己的 `at` 是落盘 / 观测时间,不当起点用。
expect(withUserTime[0]?.startedAt).toBe(1_800_000_000_100);
});
it('本地用户消息不再进回合:气泡只来自宿主条目', () => {
const sentAt = 1_800_000_000_000;
const turns = buildDirectChatTurns({
entries: [userEntry('direct-codex:turn-1:user', sentAt + 1_200)],
// 本地只保留说明:任何用户消息(哪怕是同一条消息的副本)都不造气泡、也不开回合。
localMessages: [
{
role: 'user' as const,
text: '问题 direct-codex:turn-1:user',
messageId: 'direct-codex:turn-2:user',
updatedAt: sentAt,
},
],
});
expect(turns.map((turn) => turn.key)).toEqual(['direct-codex:turn-1:user']);
// 显示时间就是宿主的落盘 / 观测时间(本地不再有第二份更早的发送时刻)。
expect(turns[0]?.users).toHaveLength(1);
expect(turns[0]?.users[0]).toMatchObject({ at: sentAt + 1_200 });
});
it('运行期失败说明挂到当前回合末尾,不当成最终回复', () => {
const turns = buildDirectChatTurns({
entries: [userEntry('u1'), assistantEntry('a1', '正文')],
localMessages: [localNotice('后台任务失败:端口被占用')],
});
expect(turns).toHaveLength(1);
expect(turns[0]?.finals.map((block) => block.kind)).toEqual([
'assistant',
'assistant',
]);
expect(turns[0]?.finals[1]).toMatchObject({
notice: true,
text: '后台任务失败:端口被占用',
});
});
it('本轮开口条目没到时,失败说明按身份挂回自己那一轮', () => {
// 现场:回合在宿主下发开口用户条目之前就失败,于是界面上只有这一轮的失败说明——开口条目
// 要等历史切片(或迟到的 item.completed)才到。靠位置分组会把说明留给上一轮,界面表现就是
// "错误显示在用户消息上面",上一轮还会顶替本轮显示耗时。
const turns = buildDirectChatTurns({
entries: [
{
...assistantEntry(
'direct-codex:turn-1:user:failure',
'陶泥儿智能创作 连接失败,请重试',
),
turnUserItemId: 'direct-codex:turn-1:user',
turnStartedAt: 1_800_000_010_000,
turnEndedAt: 1_800_000_010_400,
},
{
...assistantEntry(
'direct-codex:turn-2:user:failure',
'陶泥儿智能创作 连接失败,请重试',
),
turnUserItemId: 'direct-codex:turn-2:user',
turnStartedAt: 1_800_000_020_000,
turnEndedAt: 1_800_000_035_600,
},
],
turnRunning: false,
});
// 两个回合:说明各自挂在自己的身份上(第二个回合的开口条目还没到,这一组里没有用户气泡)。
expect(turns.map((turn) => turn.key)).toEqual([
'direct-codex:turn-1:user',
'direct-codex:turn-2:user',
]);
expect(turns[0]?.finals.map((block) => block.key)).toEqual([
'direct-codex:turn-1:user:direct-codex:turn-1:user:failure',
]);
expect(turns[0]?.users).toEqual([]);
expect(turns[1]?.finals.map((block) => block.key)).toEqual([
'direct-codex:turn-2:user:direct-codex:turn-2:user:failure',
]);
// 边界只看各自条目上盖的回合时间,不借上一轮的终点。
expect(turns[0]?.startedAt).toBe(1_800_000_010_000);
expect(turns[0]?.endedAt).toBe(1_800_000_010_400);
expect(turns[1]?.startedAt).toBe(1_800_000_020_000);
expect(turns[1]?.endedAt).toBe(1_800_000_035_600);
});
it('两态:宿主开始事件是唯一的 running 判据,其余都是 finished', () => {
// 接单窗口(本地已发出、宿主还没认领)不再是展示态:这一轮在聊天区里根本不存在,
// 所以投影拿不到任何条目可渲染,也不会造一个"待认领"的回合出来。
const windowTurns = buildDirectChatTurns({ entries: [] });
expect(windowTurns).toEqual([]);
const running = buildDirectChatTurns({
entries: [userEntry('direct-codex:turn-1:user')],
turnRunning: true,
turnStartedAt: 1_800_000_003_000,
});
expect(running.map((turn) => turn.state)).toEqual(['running']);
expect(running[0]?.startedAt).toBe(1_800_000_003_000);
expect(running[0]?.endedAt).toBe(0);
// 拿到终态、或压根没有开始事件的历史回合,都只能是 finished。
const finished = buildDirectChatTurns({
entries: [
{
...userEntry('direct-codex:turn-1:user'),
turnEndedAt: 1_800_000_010_000,
},
],
turnRunning: false,
});
expect(finished.map((turn) => turn.state)).toEqual(['finished']);
expect(finished[0]?.endedAt).toBe(1_800_000_010_000);
});
it('带身份的本地说明自成一组:不挂进上一轮,也不造出耗时文案', () => {
const turns = buildDirectChatTurns({
entries: [userEntry('u1'), assistantEntry('a1', '上一轮答复')],
localMessages: [
localNotice(
'同一轮消息仍在处理中',
'direct-codex:turn-9:user:rejected',
),
],
});
expect(turns.map((turn) => turn.key)).toEqual([
'u1',
'direct-codex:turn-9:user:rejected',
]);
// 这一组没有用户条目,也就没有起点:`DirectProjectTurn` 的 `turn.startedAt` 判据会把整条
// 「本轮结束于 … 」隐藏(不再出现 0.0 秒)。
expect(turns[1]?.users).toEqual([]);
expect(turns[1]?.startedAt).toBe(0);
expect(turns[1]?.finals.map((block) => block.text)).toEqual([
'同一轮消息仍在处理中',
]);
});
it('历史无用户条目时也保留一个回合承载正文', () => {
const turns = buildDirectChatTurns({
entries: [assistantEntry('a1', '只有回复')],
});
expect(turns).toHaveLength(1);
expect(turns[0]?.users).toEqual([]);
expect(turns[0]?.finals[0]).toMatchObject({ text: '只有回复' });
});
it('分页切片开头落在半截回合里时并进后面那个回合,不生成没有用户气泡的孤儿回合', () => {
// 更早的一屏把上一条用户条目切在外面:这三条前导条目属于上一轮,但不能自成回合。
const turns = buildDirectChatTurns({
entries: [
reasoningEntry('r-prev'),
toolEntry('t-prev'),
assistantEntry('a-prev', '上一轮的答复', 1_800_000_000_900),
userEntry('u2', 1_800_000_010_000),
assistantEntry('a2', '这一轮的答复', 1_800_000_011_000),
],
});
expect(turns.map((turn) => turn.key)).toEqual(['u2']);
expect(turns[0]?.users.map((block) => block.key)).toEqual(['u2:u2']);
expect(turns[0]?.process.map((block) => block.key)).toEqual([
'u2:r-prev',
'u2:t-prev',
'u2:a-prev',
]);
expect(turns[0]?.finals.map((block) => block.key)).toEqual(['u2:a2']);
});
it('条目转消息只保留用户 / 助手正文,并按身份去重', () => {
const messages = directChatEntriesToMessages([
userEntry('u1'),
reasoningEntry('r1'),
toolEntry('t1'),
assistantEntry('a1', '答复'),
]);
expect(messages.map((message) => message.messageId)).toEqual(['u1', 'a1']);
expect(
mergeDirectChatMessages(messages, [
{ role: 'assistant', text: '答复', messageId: 'a1' },
localNotice('只在运行期的失败说明', 'local-1'),
]).map((message) => message.messageId),
).toEqual(['u1', 'a1', 'local-1']);
});
});