DirectProject 聊天框改用线程订阅作为唯一运行态来源

- App.tsx 删除 Direct 回合事件订阅、turn-stream/tool-calls 读取、活动回合快照接管与瞬时应答文本
- 新增 game-creator-direct-thread-notify 唤醒的 subscribe/consume 单飞循环,过期时重订一次
- 通知先于订阅回执到达时记一笔欠账,回执到达后补一次 consume,避免回合尾部事件卡在队列里
- 历史切片并入同一个 reducer,聊天条目只由 selectDirectChatEntries 投影
- ProjectSupervisorView 改为渲染用户气泡 / 执行过程折叠区 / 最终回复,运行态只保留一个 turnRunning
- directTurnPresentation 重写为条目分区:连续工具合块、本地运行期说明只作最终提示
- 测试改按订阅事件与历史条目断言,补回执竞态、持久化工具卡片、空对话首轮三条用例
This commit is contained in:
2026-09-16 22:16:15 +08:00
parent bf8f96c115
commit 33ddb5de27
11 changed files with 1194 additions and 2368 deletions
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -5,9 +5,6 @@
* 入口转发:前端不再自己抄一份形状,字段增删必须改 Rust。
*/
import type { LocalConversationMessageRecord } from '../../app/types';
import type { DirectThreadItem } from './generated';
export type {
DirectThreadConsumeResult,
DirectThreadDeltaKind,
@@ -18,41 +15,3 @@ export type {
DirectThreadRequestKind,
DirectThreadSubscriptionBootstrap,
} from './generated';
/**
* 历史条目转聊天消息。
*
* 过渡函数:`App.tsx` 仍按 `LocalConversationMessageRecord` 渲染,切换成 reducer 之后删除。
* 只保留 `role ∈ {user, assistant}` 且有正文的条目;工具卡片与交替顺序由 reducer 投影。
*/
export function directThreadHistoryItemsToMessages(
items: readonly DirectThreadItem[],
): LocalConversationMessageRecord[] {
return items.flatMap((item) => {
if (item.itemType !== 'message') return [];
if (item.role !== 'user' && item.role !== 'assistant') return [];
if (!item.text) return [];
return [
{
schemaVersion: 'agc-direct-project-context.v1',
role: item.role,
content: item.text,
agentId: null,
messageId: item.itemId,
updatedAt: item.at ?? 0,
},
];
});
}
/** 只有这些状态表示仍持有活动回合;Provider 回放的终态不是活动快照。 */
export function isDirectTurnInProgress(
status: string | null | undefined,
): status is 'accepted' | 'running' | 'streaming' | 'finalizing' {
return (
status === 'accepted' ||
status === 'running' ||
status === 'streaming' ||
status === 'finalizing'
);
}
@@ -217,7 +217,8 @@ export function projectDirectThreadItem(
item: DirectThreadItem | null | undefined,
): DirectChatEntry | null {
if (!item) return null;
const itemId = item.itemId.trim();
// 身份是条目唯一的主键:拿不到身份的载荷既不能渲染也不能合并,只丢弃这一条。
const itemId = typeof item.itemId === 'string' ? item.itemId.trim() : '';
if (!itemId) return null;
switch (item.itemType) {
File diff suppressed because it is too large Load Diff
@@ -110,7 +110,12 @@ async function openDirectCodexSurface(overrides: InvokeOverrides = {}) {
renderLauncherProjectsAt('/?launcher');
pickProjectFromLauncher(DYNAMIC_GAME_PROJECT_PATH);
const surface = await screen.findByLabelText('陶泥儿项目对话');
return { invoke, path: DYNAMIC_GAME_PROJECT_PATH, surface };
return {
invoke,
path: DYNAMIC_GAME_PROJECT_PATH,
surface,
harness: supervisorHarness,
};
}
async function submitDirectTurn(
@@ -479,9 +484,11 @@ export function registerChatComposerControlTests() {
resolve: (value: string) => void;
reject: (error: Error) => void;
}> = [];
const { invoke, path, surface } = await openDirectCodexSurface({
const { invoke, path, surface, harness } = await openDirectCodexSurface({
chat_with_game_creator_direct_codex: () =>
new Promise<string>((resolve, reject) => {
// 回合真正开跑:生命周期事件由订阅下发,界面据此进入"可终止"。
harness.emitDirectThreadEvents({ type: 'turn.started' });
pending.push({ resolve, reject });
}),
cancel_direct_codex_turn: async () => undefined,
@@ -502,7 +509,6 @@ export function registerChatComposerControlTests() {
await waitFor(() => {
expect(invoke).toHaveBeenCalledWith('cancel_direct_codex_turn', {
projectPath: path,
clientTurnId: expect.any(String),
});
});
@@ -744,6 +744,16 @@ function createProjectSupervisorRuntimeHarness({
let designAgentUpdateHandler:
| ((event: { payload: Record<string, unknown> }) => void)
| null = null;
let directThreadNotifyHandler:
| ((event: { payload: { subscriptionId: string } }) => void)
| null = null;
let directThreadSubscriptionId: string | null = null;
let directThreadSubscriptionSequence = 0;
// 未消费的运行态事件队列:`subscribe` 的 bootstrap 与 `consume` 都从这里取,
// 与 Rust 侧"游标在队尾、事件按序下发"的语义一致。
let pendingDirectThreadEvents: Array<Record<string, unknown>> = [];
let directThreadHistoryItems: Array<Record<string, unknown>> = [];
let directThreadLastCompletedItemId: string | null = null;
const conversationRecord = (
role: 'user' | 'assistant',
@@ -938,6 +948,32 @@ function createProjectSupervisorRuntimeHarness({
const states = runtimeMapLoader ? await runtimeMapLoader() : [];
return states.map((state) => runtimeResult(state));
}
if (command === 'subscribe_direct_project_thread') {
directThreadSubscriptionSequence += 1;
directThreadSubscriptionId = `direct-thread-${directThreadSubscriptionSequence}`;
const events = pendingDirectThreadEvents;
pendingDirectThreadEvents = [];
return {
subscriptionId: directThreadSubscriptionId,
lastCompletedItemId: directThreadLastCompletedItemId,
events,
};
}
if (command === 'consume_direct_project_thread') {
if (String(args?.subscriptionId ?? '') !== directThreadSubscriptionId) {
throw new Error('SUBSCRIPTION_EXPIRED');
}
const events = pendingDirectThreadEvents;
pendingDirectThreadEvents = [];
return { events };
}
if (command === 'read_direct_project_history_slice') {
return {
items: [...directThreadHistoryItems],
hasMore: false,
firstItemId: null,
};
}
if (command === 'start_game_creator_supervisor_runtime_task') {
if (args?.runProfile !== expectedRunProfile) {
throw new Error('unexpected Project Supervisor run profile');
@@ -1120,6 +1156,10 @@ function createProjectSupervisorRuntimeHarness({
designAgentUpdateHandler =
handler as unknown as typeof designAgentUpdateHandler;
}
if (eventName === 'game-creator-direct-thread-notify') {
directThreadNotifyHandler =
handler as unknown as typeof directThreadNotifyHandler;
}
return () => {
if (runtimeUpdateHandler === handler) {
runtimeUpdateHandler = null;
@@ -1133,10 +1173,25 @@ function createProjectSupervisorRuntimeHarness({
if (designAgentUpdateHandler === handler) {
designAgentUpdateHandler = null;
}
if (directThreadNotifyHandler === handler) {
directThreadNotifyHandler = null;
}
};
},
);
/** 追加运行态事件并唤醒订阅者:bootstrap / consume 共用同一份队列。 */
const emitDirectThreadEvents = (
...events: Array<Record<string, unknown>>
) => {
pendingDirectThreadEvents.push(...events);
if (directThreadSubscriptionId) {
directThreadNotifyHandler?.({
payload: { subscriptionId: directThreadSubscriptionId },
});
}
};
return {
invoke,
listen,
@@ -1229,6 +1284,55 @@ function createProjectSupervisorRuntimeHarness({
},
});
},
emitDirectThreadEvents,
/**
* 一轮 Direct 回合的标准事件序列:生命周期 → 落盘用户条目 → 助手正文 → 终态。
*
* 与 Rust 侧一致:消息身份是 `direct-codex:{turnId}:{role}`,工具条目另配 itemId。
*/
completeDirectThreadTurn({
turnId,
reply,
prompt = '',
at = 9_000,
status = 'completed',
}: {
turnId: string;
reply: string;
prompt?: string;
at?: number;
status?: string;
}) {
const events: Array<Record<string, unknown>> = [{ type: 'turn.started' }];
if (prompt.trim()) {
events.push({
type: 'item.completed',
item: {
itemType: 'message',
itemId: `direct-codex:${turnId}:user`,
role: 'user',
text: prompt,
at,
},
});
}
events.push({
type: 'item.completed',
item: {
itemType: 'message',
itemId: `direct-codex:${turnId}:assistant`,
role: 'assistant',
text: reply,
at: at + 1,
},
});
directThreadLastCompletedItemId = `direct-codex:${turnId}:assistant`;
events.push({ type: 'turn.completed', status });
emitDirectThreadEvents(...events);
},
setDirectThreadHistory(items: Array<Record<string, unknown>>) {
directThreadHistoryItems = [...items];
},
};
}
@@ -1483,6 +1483,11 @@ export function registerHomeProjectCreationTests() {
};
}
if (command === 'chat_with_game_creator_direct_codex') {
supervisorHarness.completeDirectThreadTurn({
turnId: String(args?.clientTurnId ?? ''),
prompt: '你好,今天多少号',
reply: '别这么骂自己,具体发生什么了?',
});
return '别这么骂自己,具体发生什么了?';
}
return supervisorHarness.invoke(command, args);
@@ -1620,6 +1625,11 @@ export function registerHomeProjectCreationTests() {
return '角色参考游戏';
}
if (command === 'chat_with_game_creator_direct_codex') {
supervisorHarness.completeDirectThreadTurn({
turnId: String(args?.clientTurnId ?? ''),
prompt: '按这个角色做游戏',
reply: '附件已经进入当前项目。',
});
return '附件已经进入当前项目。';
}
return supervisorHarness.invoke(command, args);
@@ -2451,7 +2461,11 @@ export function registerHomeProjectCreationTests() {
}
if (command === 'read_direct_project_history_slice') {
expect(args).toEqual({ projectPath, limit: 20 });
return { items: [...persistedMessages], hasMore: false };
return {
items: [...persistedMessages],
hasMore: false,
firstItemId: null,
};
}
if (command === 'append_local_conversation_message') {
throw new Error(
@@ -2460,14 +2474,22 @@ export function registerHomeProjectCreationTests() {
}
if (command === 'chat_with_game_creator_direct_codex') {
const clientTurnId = String(args?.clientTurnId ?? '');
persistedMessages.push(args?.userItem as Record<string, unknown>, {
role: 'assistant',
type: 'message',
content: [
{ type: 'output_text', text: 'DIRECT_EXISTING_PROJECT_OK' },
],
id: `direct-codex:${clientTurnId}:assistant`,
});
persistedMessages.push(
{
itemType: 'message',
itemId: `direct-codex:${clientTurnId}:user`,
role: 'user',
text: '继续修改已有项目',
at: 9_000,
},
{
itemType: 'message',
itemId: `direct-codex:${clientTurnId}:assistant`,
role: 'assistant',
text: 'DIRECT_EXISTING_PROJECT_OK',
at: 9_001,
},
);
return 'DIRECT_EXISTING_PROJECT_OK';
}
throw new Error(`unexpected invoke ${command}`);
@@ -2537,7 +2559,11 @@ export function registerHomeProjectCreationTests() {
}
if (command === 'read_direct_project_history_slice') {
expect(args).toEqual({ projectPath, limit: 20 });
return { items: [...persistedMessages], hasMore: false };
return {
items: [...persistedMessages],
hasMore: false,
firstItemId: null,
};
}
if (command === 'append_local_permission_log') {
return {};
@@ -2561,17 +2587,23 @@ export function registerHomeProjectCreationTests() {
content: [{ type: 'input_text', text: '生成一个游戏' }],
},
});
persistedMessages.push(args?.userItem as Record<string, unknown>, {
id: `direct-codex:${clientTurnId}:assistant`,
type: 'message',
role: 'assistant',
content: [
{
type: 'output_text',
text: '陶泥儿智能创作 鉴权失败,请重新登录后重试',
},
],
});
// Rust 落盘的原始条目在回读时已经是投影后的形状:身份只有一个 itemId。
persistedMessages.push(
{
itemType: 'message',
itemId: `direct-codex:${clientTurnId}:user`,
role: 'user',
text: '生成一个游戏',
at: 9_000,
},
{
itemType: 'message',
itemId: `direct-codex:${clientTurnId}:assistant`,
role: 'assistant',
text: '陶泥儿智能创作 鉴权失败,请重新登录后重试',
at: 9_001,
},
);
throw new Error('codex-app-server-error:unauthorized');
}
throw new Error(`unexpected invoke ${command}`);
@@ -2596,21 +2628,18 @@ export function registerHomeProjectCreationTests() {
});
expect(persistedMessages).toEqual([
{
id: expect.stringMatching(/^direct-codex:[A-Za-z0-9-]+:user$/),
type: 'message',
itemType: 'message',
itemId: expect.stringMatching(/^direct-codex:[A-Za-z0-9-]+:user$/),
role: 'user',
content: [{ type: 'input_text', text: '生成一个游戏' }],
text: '生成一个游戏',
at: 9_000,
},
{
id: expect.stringMatching(/^direct-codex:[A-Za-z0-9-]+:assistant$/),
type: 'message',
itemType: 'message',
itemId: expect.stringMatching(/^direct-codex:[A-Za-z0-9-]+:assistant$/),
role: 'assistant',
content: [
{
type: 'output_text',
text: '陶泥儿智能创作 鉴权失败,请重新登录后重试',
},
],
text: '陶泥儿智能创作 鉴权失败,请重新登录后重试',
at: 9_001,
},
]);
expect(JSON.stringify(persistedMessages)).not.toContain(
File diff suppressed because it is too large Load Diff
@@ -1,48 +0,0 @@
import { describe, expect, it } from 'vitest';
import {
directThreadHistoryItemsToMessages,
isDirectTurnInProgress,
} from '../src/features/project-workspace/directThreadEvents';
import type { DirectThreadItem } from '../src/features/project-workspace/directThreadItemProjection';
describe('Direct 回合状态与历史时间', () => {
it('终态和空状态不恢复为活动回合', () => {
for (const status of [
'completed',
'failed',
'interrupted',
null,
undefined,
]) {
expect(isDirectTurnInProgress(status)).toBe(false);
}
for (const status of ['accepted', 'running', 'streaming', 'finalizing']) {
expect(isDirectTurnInProgress(status)).toBe(true);
}
});
it('条目时间来自搬运后的 at,前端不自己补造时间', () => {
const items = [
{
itemId: 'direct-codex:turn:user',
itemType: 'message',
role: 'user',
text: '帮我修改游戏',
at: 1_800_000_000_001,
},
{
itemId: 'direct-codex:turn:assistant',
itemType: 'message',
role: 'assistant',
text: '好的',
at: 0,
},
] satisfies DirectThreadItem[];
expect(directThreadHistoryItemsToMessages(items)[0]?.updatedAt).toBe(
1_800_000_000_001,
);
// Rust 拿不到条目时间时给 0,前端不得用"当前时间"补造。
expect(directThreadHistoryItemsToMessages(items)[1]?.updatedAt).toBe(0);
expect(items[0]).not.toHaveProperty('recordedAt');
});
});
@@ -1,236 +1,220 @@
import { describe, expect, it } from 'vitest';
import type {
ChatMessage,
GameCreatorDirectToolCall,
TurnStreamItem,
} from '../src/app/types';
import type { ChatMessage } from '../src/app/types';
import type { DirectChatEntry } from '../src/features/project-workspace/directThreadChat';
import {
buildDirectTurnPresentations,
buildDirectChatTurns,
directChatEntriesToMessages,
directMessageTimestamp,
mergeDirectChatMessages,
normalizeDirectTimestamp,
splitDirectTurnContent,
} from '../src/features/project-workspace/directTurnPresentation';
const user = (turn: string): ChatMessage => ({
const userEntry = (
itemId: string,
at = 1_800_000_000_000,
): DirectChatEntry => ({
itemId,
kind: 'message',
role: 'user',
text: '只读检查',
messageId: `direct-codex:${turn}:user`,
text: `问题 ${itemId}`,
at,
});
const assistant = (id: string, text = '完整回复'): ChatMessage => ({
const assistantEntry = (
itemId: string,
text: string,
at = 1_800_000_001_000,
): DirectChatEntry => ({
itemId,
kind: 'message',
role: 'assistant',
text,
messageId: id,
at,
});
const text = (turnId: string, id: string, seq = 1): TurnStreamItem => ({
schemaVersion: 'agc-turn-stream.v1',
kind: 'text',
turnId,
id: `text:${turnId}:${id}`,
text: '前缀',
seq,
at: 1_800_000_000_000,
updatedAt: 1_800_000_000_001,
});
const tool = (turnId: string): GameCreatorDirectToolCall => ({
schemaVersion: 'agc-tool-call.v1',
id: 'call',
turnId,
kind: 'mcp_tool',
title: '调用工具',
summary: '读取',
status: 'completed',
detail: { command: '{"path":"file"}', output: '内容', changes: [] },
startedAt: 1_800_000_000_000,
updatedAt: 1_800_000_000_001,
});
const build = (
messages: ChatMessage[],
items: TurnStreamItem[],
options: Partial<Parameters<typeof buildDirectTurnPresentations>[0]> = {},
) =>
buildDirectTurnPresentations({
messages,
visibleMessages: messages,
items,
calls: [],
transientReply: '',
...options,
});
describe('DirectProject 回合唯一呈现', () => {
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,
},
});
const reasoningEntry = (
itemId: string,
text = '先看目录',
at = 1_800_000_000_200,
): DirectChatEntry => ({
itemId,
kind: 'reasoning',
role: null,
text,
at,
});
const localUser = (text: string, messageId?: string): ChatMessage => ({
role: 'user',
text,
...(messageId ? { messageId } : {}),
updatedAt: 1_800_000_002_000,
});
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(1_800_000_000)).toBe(1_800_000_000_000);
});
it('历史仍有未加载切片时,不把那些回合的工具流追加到当前页末尾', () => {
const rows = build(
[user('one')],
[text('old', 'raw-old'), text('one', 'raw')],
{
hasUnloadedHistory: true,
},
);
expect(rows.map((row) => row.turnId)).toEqual(['one']);
const active = build([], [text('live', 'raw')], {
hasUnloadedHistory: true,
activeTurnId: 'live',
});
expect(active.map((row) => row.turnId)).toEqual(['live']);
});
it('尚未落盘用户的实时回合也只产生一个 owner,不另建 live 与 unmapped 出口', () => {
const rows = build([], [text('one', 'raw')], {
activeTurnId: 'one',
transientReply: '同一份累计回复',
calls: [tool('one')],
});
expect(rows).toHaveLength(1);
expect(rows[0].source).toBe('stream');
expect(rows[0].transientReply).toBe('');
expect(rows[0].calls).toHaveLength(1);
});
it('同一身份用户重复快照不增加回合,不丢用户正文', () => {
const rows = build([user('one'), user('one')], [text('one', 'raw')]);
expect(rows).toHaveLength(1);
expect(rows[0].messages).toHaveLength(1);
expect(rows[0].messages[0].role).toBe('user');
});
it('用原始 item 身份补齐旧前缀,最终合成消息不另占正文出口', () => {
const rows = build(
[user('one'), assistant('raw'), assistant('direct-codex:one:assistant')],
[text('one', 'raw')],
);
expect(rows).toHaveLength(1);
expect(rows[0].source).toBe('stream');
expect(rows[0].items).toHaveLength(1);
expect(rows[0].items[0].text).toBe('完整回复');
});
it('先关联完整历史再分页,首条可见 assistant 不会失去用户归属', () => {
const messages = [
user('old'),
assistant('old-raw'),
user('one'),
assistant('raw'),
];
const rows = build(messages, [text('old', 'old-raw'), text('one', 'raw')], {
visibleMessages: messages.slice(3),
});
expect(rows.map((row) => row.turnId)).toEqual(['one']);
expect(rows[0].messages[0].messageId).toBe('direct-codex:one:user');
});
it('纯文本旧回合不挤占之后有工具的回合身份', () => {
const rows = build(
[user('old'), assistant('plain'), user('one'), assistant('raw')],
[text('one', 'raw')],
{ calls: [tool('one')] },
);
expect(rows.map((row) => row.turnId)).toEqual(['old', 'one']);
expect(rows[0].source).toBe('messages');
expect(rows[0].calls).toEqual([]);
expect(rows[1].source).toBe('stream');
});
it('没有流的原始 assistant 与工具仍归属一个回合,输入输出保留', () => {
const rows = build([user('one'), assistant('raw')], [], {
calls: [tool('one')],
});
expect(rows).toHaveLength(1);
expect(rows[0].source).toBe('messages');
expect(rows[0].calls[0].detail.output).toBe('内容');
});
it('无法证明流覆盖历史时整轮回退,不能混画部分流与全文', () => {
const rows = build(
[user('one'), assistant('raw-a'), assistant('raw-b')],
[text('one', 'raw-b')],
);
expect(rows[0].source).toBe('messages');
expect(
rows[0].messages.filter((message) => message.role === 'assistant'),
).toHaveLength(2);
});
it('不同回合的相同文字不是重复消息,不做文本去重', () => {
const rows = build(
[user('one'), assistant('raw-one'), user('two'), assistant('raw-two')],
[text('one', 'raw-one'), text('two', 'raw-two')],
);
expect(rows).toHaveLength(2);
expect(rows.every((row) => row.source === 'stream')).toBe(true);
});
it('乱序重复快照按 seq 排列且不增加 item', () => {
const first = text('one', 'a');
const last = text('one', 'b', 3);
const marker: TurnStreamItem = {
...text('one', 'unused', 2),
kind: 'tool',
id: 'tool:one:call',
callId: 'call',
};
const rows = build([user('one')], [last, marker, first, first]);
expect(rows[0].items.map((item) => item.seq)).toEqual([1, 2, 3]);
});
it('完成后中间文本和工具进入过程,最终回复独立且分区不重复', () => {
const marker: TurnStreamItem = {
...text('one', 'unused', 2),
kind: 'tool',
id: 'tool:one:call',
callId: 'call',
};
const row = build(
[user('one')],
[text('one', 'start'), marker, text('one', 'final', 3)],
)[0];
const parts = splitDirectTurnContent(row);
expect(parts.processItems.map((item) => item.id)).toEqual([
'text:one:start',
'tool:one:call',
]);
expect(parts.finalItems.map((item) => item.id)).toEqual(['text:one:final']);
const active = splitDirectTurnContent({ ...row, active: true });
expect(active.processItems).toHaveLength(3);
expect(active.finalItems).toEqual([]);
});
it('失败回合保留失败提示,不把末尾过程文本提升为最终回复', () => {
const row = build(
[user('one'), assistant('direct-codex:one:failure', '失败')],
[text('one', 'progress'), { ...text('one', 'failure', 2), text: '失败' }],
)[0];
const parts = splitDirectTurnContent(row);
expect(parts.processItems.map((item) => item.id)).toEqual([
'text:one:progress',
]);
expect(parts.finalItems.map((item) => item.id)).toEqual([
'text:one:failure',
]);
});
it('无流历史只保留最后一条回复,发送时间不从工具推断', () => {
const row = build(
[user('one'), assistant('progress'), assistant('final')],
[],
)[0];
const parts = splitDirectTurnContent(row);
expect(parts.processMessages.map((message) => message.messageId)).toEqual([
'progress',
]);
expect(parts.finalMessages.map((message) => message.messageId)).toEqual([
'final',
]);
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('无流活动回合的累计文本只属于该回合,持久 assistant 到达即接管', () => {
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: '第二轮答复' });
expect(turns[0]?.startedAt).toBe(1_800_000_000_000);
expect(turns[0]?.endedAt).toBe(1_800_000_001_000);
});
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]?.active).toBe(true);
expect(turns[0]?.finals).toEqual([]);
expect(turns[0]?.process.map((block) => block.kind)).toEqual([
'assistant',
'tools',
]);
});
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('乐观用户气泡自成回合,已落盘的同一身份不重复渲染', () => {
const turns = buildDirectChatTurns({
entries: [userEntry('u1'), assistantEntry('a1', '答复')],
localMessages: [localUser('问题 u1', 'u1'), localUser('第二条')],
turnRunning: true,
});
expect(turns.map((turn) => turn.key)).toEqual(['u1', 'local:1']);
expect(turns[1]?.users).toHaveLength(1);
expect(turns[1]?.active).toBe(true);
});
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 messages = directChatEntriesToMessages([
userEntry('u1'),
reasoningEntry('r1'),
toolEntry('t1'),
assistantEntry('a1', '答复'),
]);
expect(messages.map((message) => message.messageId)).toEqual(['u1', 'a1']);
expect(
build([user('one')], [], {
activeTurnId: 'one',
transientReply: '回复',
})[0].transientReply,
).toBe('回复');
expect(
build([user('one'), assistant('direct-codex:one:assistant')], [], {
activeTurnId: 'one',
transientReply: '回复',
})[0].transientReply,
).toBe('');
mergeDirectChatMessages(messages, [
{ role: 'assistant', text: '答复', messageId: 'a1' },
localNotice('只在运行期的失败说明', 'local-1'),
]).map((message) => message.messageId),
).toEqual(['u1', 'a1', 'local-1']);
});
});