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:
File diff suppressed because it is too large
Load Diff
+119
-320
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'
|
||||
);
|
||||
}
|
||||
|
||||
+2
-1
@@ -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) {
|
||||
|
||||
+248
-251
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']);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user