656c89e4b2
新增 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
240 lines
7.6 KiB
TypeScript
240 lines
7.6 KiB
TypeScript
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',
|
||
);
|
||
});
|
||
});
|