Files
Genarrative/apps/ai-game-creator-shell/tests/directThreadChat.test.ts
T
k88936 656c89e4b2 把 DirectProject 聊天条目的投影从前端收口,Rust 只搬运脱敏原始条目
新增 agent/direct_thread_raw_item.rs:把 Codex 原始 response item 与 app-server item 收敛成同一形状的脱敏原始条目,只做挑字段、脱敏、截断,不再生成卡片的 kind / 标题 / 折叠摘要
删除 agent/direct_chat_entry.rs:工具卡片形状不再由 Rust 生产
历史切片改为返回脱敏原始条目列表,不再在 Rust 侧合并 function_call 与 function_call_output
新增前端 directThreadItemProjection.ts:工具卡片 kind / 标题 / 摘要 / 状态与可见性判定全部在前端完成
directThreadChat reducer 改用同一投影函数处理运行态事件,新增 mergeDirectHistoryItems 走同一份投影与合并规则
DirectThreadHistorySlice 去掉 entries 与 itemTimestamps,改为 items + firstItemId
2026-09-16 18:45:13 +08:00

240 lines
7.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { describe, expect, it } from 'vitest';
import {
directChatEntryIdentity,
emptyDirectThreadChatState,
mergeDirectHistoryItems,
reduceDirectThreadEvents,
resolveDirectThreadBootstrap,
selectDirectChatEntries,
} from '../src/features/project-workspace/directThreadChat';
import type { DirectThreadRawEvent } from '../src/features/project-workspace/directThreadEvents';
import type { DirectThreadRawItem } from '../src/features/project-workspace/directThreadItemProjection';
function event(
partial: Partial<DirectThreadRawEvent> &
Pick<DirectThreadRawEvent, 'type' | 'turnId'>,
): DirectThreadRawEvent {
return { seq: 1, payload: {}, ...partial };
}
/** app-server `item/started`:工具真正开始执行,itemId 就是 call_id。 */
function rawToolStarted(
overrides: Partial<DirectThreadRawItem> = {},
): DirectThreadRawItem {
return {
itemId: 'call_00_Gpd0s0Ytm9YgIbwbEXva1473',
turnId: 'turn-1',
itemType: 'function_call',
name: 'exec_command',
arguments: '{"cmd": "ls"}',
completed: false,
at: 1000,
...overrides,
};
}
/** 原始 response item 的 `function_call_output`itemId 是另一个 id 空间,靠 callId 对齐。 */
function rawToolOutput(
overrides: Partial<DirectThreadRawItem> = {},
): DirectThreadRawItem {
return {
itemId: 'fco_01a06fa5-d636-7452-b337-a641c2e6bc76',
callId: 'call_00_Gpd0s0Ytm9YgIbwbEXva1473',
turnId: 'turn-1',
itemType: 'function_call_output',
output: 'assets\ngame',
completed: true,
at: 2000,
...overrides,
};
}
describe('DirectProject 聊天 reducer', () => {
it('只有 turn.started 且未完成的回合才是活动回合', () => {
const started = reduceDirectThreadEvents(emptyDirectThreadChatState(), [
event({ type: 'turn.started', turnId: 'turn-1' }),
]);
expect(started.activeTurnId).toBe('turn-1');
const completed = reduceDirectThreadEvents(started, [
event({ type: 'turn.completed', turnId: 'turn-1' }),
]);
expect(completed.activeTurnId).toBeNull();
});
it('bootstrap 原子替换运行态,没有生命周期事件就没有活动回合', () => {
const stale = reduceDirectThreadEvents(emptyDirectThreadChatState(), [
event({ type: 'turn.started', turnId: 'turn-old' }),
]);
const bootstrapped = resolveDirectThreadBootstrap(stale, {
subscriptionId: 'sub-1',
lastCompletedItemId: 'msg-9',
events: [],
});
expect(bootstrapped.activeTurnId).toBeNull();
expect(bootstrapped.subscriptionId).toBe('sub-1');
expect(bootstrapped.lastCompletedItemId).toBe('msg-9');
});
it('增量正文按条目累计,完成快照覆盖同一段', () => {
const streamed = reduceDirectThreadEvents(emptyDirectThreadChatState(), [
event({
type: 'item.delta',
turnId: 'turn-1',
itemId: 'msg-1',
payload: { delta: '你', kind: 'message' },
}),
event({
type: 'item.delta',
turnId: 'turn-1',
itemId: 'msg-1',
payload: { delta: '好', kind: 'message' },
}),
]);
expect(selectDirectChatEntries(streamed)).toHaveLength(1);
expect(selectDirectChatEntries(streamed)[0]?.text).toBe('你好');
const done = reduceDirectThreadEvents(streamed, [
event({
type: 'item.completed',
turnId: 'turn-1',
itemId: 'msg-1',
payload: {
itemId: 'msg-1',
turnId: 'turn-1',
itemType: 'message',
role: 'assistant',
text: '你好,我是陶泥儿。',
completed: true,
at: 3000,
} satisfies DirectThreadRawItem,
}),
]);
const entries = selectDirectChatEntries(done);
expect(entries).toHaveLength(1);
expect(entries[0]?.text).toBe('你好,我是陶泥儿。');
});
it('思考增量按 reasoning 条目累计,不混进助手文本', () => {
const state = reduceDirectThreadEvents(emptyDirectThreadChatState(), [
event({
type: 'item.delta',
turnId: 'turn-1',
itemId: 'reason-1',
payload: { delta: '先看目录', kind: 'reasoning' },
}),
]);
const entries = selectDirectChatEntries(state);
expect(entries).toHaveLength(1);
expect(entries[0]?.kind).toBe('reasoning');
expect(entries[0]?.role).toBeNull();
expect(entries[0]?.text).toBe('先看目录');
});
it('工具 started / output 跨 id 空间按 callId 归并成一张卡片', () => {
const state = reduceDirectThreadEvents(emptyDirectThreadChatState(), [
event({
type: 'item.started',
turnId: 'turn-1',
itemId: 'call_00_Gpd0s0Ytm9YgIbwbEXva1473',
payload: rawToolStarted(),
}),
event({
type: 'item.completed',
turnId: 'turn-1',
itemId: 'fco_01a06fa5-d636-7452-b337-a641c2e6bc76',
payload: rawToolOutput(),
}),
]);
const entries = selectDirectChatEntries(state);
expect(entries).toHaveLength(1);
expect(directChatEntryIdentity(entries[0]!)).toBe(
'call_00_Gpd0s0Ytm9YgIbwbEXva1473',
);
expect(entries[0]?.toolCall?.kind).toBe('command');
expect(entries[0]?.toolCall?.title).toBe('执行命令');
expect(entries[0]?.toolCall?.detail.command).toBe('{"cmd": "ls"}');
expect(entries[0]?.toolCall?.detail.output).toBe('assets\ngame');
expect(entries[0]?.toolCall?.status).toBe('completed');
});
it('历史切片搬运层不合并,合并发生在前端投影', () => {
const state = mergeDirectHistoryItems(emptyDirectThreadChatState(), [
rawToolStarted(),
rawToolOutput(),
{
itemId: 'msg-user',
itemType: 'message',
role: 'user',
text: '做一个拼图游戏',
completed: true,
at: 0,
},
]);
const entries = selectDirectChatEntries(state);
expect(entries).toHaveLength(2);
expect(entries[0]?.toolCall?.detail.output).toBe('assets\ngame');
expect(entries[1]?.role).toBe('user');
});
it('系统条目与未识别的 item 类型不进聊天视图', () => {
const state = reduceDirectThreadEvents(emptyDirectThreadChatState(), [
event({
type: 'item.completed',
turnId: 'turn-1',
itemId: 'sys-1',
payload: {
itemId: 'sys-1',
itemType: 'message',
role: 'system',
text: '内部指令',
completed: true,
at: 0,
} satisfies DirectThreadRawItem,
}),
event({
type: 'item.completed',
turnId: 'turn-1',
itemId: 'plan-1',
payload: {
itemId: 'plan-1',
itemType: 'plan',
completed: true,
at: 0,
} satisfies DirectThreadRawItem,
}),
]);
expect(selectDirectChatEntries(state)).toHaveLength(0);
});
it('历史条目与运行态按身份合并,重复条目只出现一次', () => {
const historical = mergeDirectHistoryItems(emptyDirectThreadChatState(), [
rawToolStarted(),
{
itemId: 'msg-user',
itemType: 'message',
role: 'user',
text: '做一个拼图游戏',
completed: true,
at: 0,
},
]);
const live = reduceDirectThreadEvents(historical, [
event({
type: 'item.completed',
turnId: 'turn-1',
itemId: 'fco_01a06fa5-d636-7452-b337-a641c2e6bc76',
payload: rawToolOutput(),
}),
]);
const entries = selectDirectChatEntries(live);
expect(entries).toHaveLength(2);
expect(entries[0]?.toolCall?.status).toBe('completed');
expect(entries[0]?.toolCall?.detail.command).toBe('{"cmd": "ls"}');
expect(directChatEntryIdentity(entries[0]!)).toBe(
'call_00_Gpd0s0Ytm9YgIbwbEXva1473',
);
});
});