1b40f030e7
- DirectThreadChatState 去掉 subscriptionId 与 lastCompletedItemId 两个只写不读的字段 - resolveDirectThreadBootstrap 只把 bootstrap 事件 reduce 进状态,不再顺带写入这两个字段 - directThreadChat 单测改成断言聊天状态里不再出现这两个字段(bootstrap 载荷本身不变)
215 lines
7.3 KiB
TypeScript
215 lines
7.3 KiB
TypeScript
import { describe, expect, it } from 'vitest';
|
|
|
|
import {
|
|
emptyDirectThreadChatState,
|
|
mergeDirectHistoryItems,
|
|
reduceDirectThreadEvents,
|
|
resolveDirectThreadBootstrap,
|
|
selectDirectChatEntries,
|
|
} from '../src/features/project-workspace/directThreadChat';
|
|
import type { DirectThreadEvent } from '../src/features/project-workspace/directThreadEvents';
|
|
import type { DirectThreadItem } from '../src/features/project-workspace/directThreadItemProjection';
|
|
|
|
function event(
|
|
partial: Pick<DirectThreadEvent, 'type'> & Partial<DirectThreadEvent>,
|
|
): DirectThreadEvent {
|
|
return partial as DirectThreadEvent;
|
|
}
|
|
|
|
/** app-server `item/started`:工具真正开始执行,itemId 就是归一后的唯一身份。 */
|
|
function toolStarted(
|
|
overrides: Partial<
|
|
Extract<DirectThreadItem, { itemType: 'function_call' }>
|
|
> = {},
|
|
): DirectThreadItem {
|
|
return {
|
|
itemType: 'function_call',
|
|
itemId: 'call_00_Gpd0s0Ytm9YgIbwbEXva1473',
|
|
name: 'exec_command',
|
|
arguments: '{"cmd": "ls"}',
|
|
at: 1000,
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
/** 原始 response item 的输出条目:Rust 已经把它归一成同一个 itemId。 */
|
|
function toolOutput(): DirectThreadItem {
|
|
return {
|
|
itemType: 'function_call_output',
|
|
itemId: 'call_00_Gpd0s0Ytm9YgIbwbEXva1473',
|
|
output: 'assets\ngame',
|
|
at: 2000,
|
|
};
|
|
}
|
|
|
|
function messageItem(
|
|
overrides: Partial<Extract<DirectThreadItem, { itemType: 'message' }>> = {},
|
|
): DirectThreadItem {
|
|
return {
|
|
itemType: 'message',
|
|
itemId: 'msg-1',
|
|
role: 'assistant',
|
|
text: '你好',
|
|
at: 3000,
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
describe('DirectProject 聊天 reducer', () => {
|
|
it('生命周期事件只切换"是否还在跑",不带回合身份', () => {
|
|
const started = reduceDirectThreadEvents(emptyDirectThreadChatState(), [
|
|
event({ type: 'turn.started' }),
|
|
]);
|
|
expect(started.turnRunning).toBe(true);
|
|
|
|
const completed = reduceDirectThreadEvents(started, [
|
|
event({ type: 'turn.completed', status: 'completed' }),
|
|
]);
|
|
expect(completed.turnRunning).toBe(false);
|
|
});
|
|
|
|
it('bootstrap 事件就是要处理的事件:未完成条目直接进运行态', () => {
|
|
const bootstrapped = resolveDirectThreadBootstrap(
|
|
emptyDirectThreadChatState(),
|
|
{
|
|
subscriptionId: 'sub-1',
|
|
lastCompletedItemId: 'msg-9',
|
|
events: [
|
|
event({ type: 'turn.started' }),
|
|
event({ type: 'item.started', item: toolStarted() }),
|
|
],
|
|
},
|
|
);
|
|
// 订阅身份与首屏锚点由订阅循环自己持有,不写进聊天 reducer 状态。
|
|
expect(bootstrapped).not.toHaveProperty('subscriptionId');
|
|
expect(bootstrapped).not.toHaveProperty('lastCompletedItemId');
|
|
expect(bootstrapped.turnRunning).toBe(true);
|
|
expect(selectDirectChatEntries(bootstrapped)).toHaveLength(1);
|
|
});
|
|
|
|
it('增量正文按条目累计,完成快照更长时覆盖同一段', () => {
|
|
const streamed = reduceDirectThreadEvents(emptyDirectThreadChatState(), [
|
|
event({
|
|
type: 'item.delta',
|
|
itemId: 'msg-1',
|
|
kind: 'message',
|
|
delta: '你',
|
|
}),
|
|
event({
|
|
type: 'item.delta',
|
|
itemId: 'msg-1',
|
|
kind: 'message',
|
|
delta: '好',
|
|
}),
|
|
]);
|
|
expect(selectDirectChatEntries(streamed)).toHaveLength(1);
|
|
expect(selectDirectChatEntries(streamed)[0]?.text).toBe('你好');
|
|
|
|
const done = reduceDirectThreadEvents(streamed, [
|
|
event({
|
|
type: 'item.completed',
|
|
item: messageItem({ text: '你好,我是陶泥儿。' }),
|
|
}),
|
|
]);
|
|
const entries = selectDirectChatEntries(done);
|
|
expect(entries).toHaveLength(1);
|
|
expect(entries[0]?.text).toBe('你好,我是陶泥儿。');
|
|
});
|
|
|
|
it('思考增量按 reasoning 条目累计,不混进助手文本', () => {
|
|
const state = reduceDirectThreadEvents(emptyDirectThreadChatState(), [
|
|
event({
|
|
type: 'item.delta',
|
|
itemId: 'reason-1',
|
|
kind: 'reasoning',
|
|
delta: '先看目录',
|
|
}),
|
|
]);
|
|
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('工具调用与输出共用唯一 itemId,归并成一张卡片', () => {
|
|
const state = reduceDirectThreadEvents(emptyDirectThreadChatState(), [
|
|
event({ type: 'item.started', item: toolStarted() }),
|
|
event({ type: 'item.completed', item: toolOutput() }),
|
|
]);
|
|
const entries = selectDirectChatEntries(state);
|
|
expect(entries).toHaveLength(1);
|
|
expect(entries[0]?.itemId).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 running = reduceDirectThreadEvents(emptyDirectThreadChatState(), [
|
|
event({ type: 'turn.started' }),
|
|
event({ type: 'item.completed', item: messageItem() }),
|
|
event({ type: 'item.started', item: toolStarted() }),
|
|
]);
|
|
expect(running.live).toHaveLength(2);
|
|
|
|
const done = reduceDirectThreadEvents(running, [
|
|
event({ type: 'turn.completed', status: 'completed' }),
|
|
]);
|
|
expect(done.live).toHaveLength(0);
|
|
expect(done.history).toHaveLength(2);
|
|
expect(selectDirectChatEntries(done)).toHaveLength(2);
|
|
});
|
|
|
|
it('历史切片搬运层不合并,合并发生在前端投影', () => {
|
|
const state = mergeDirectHistoryItems(emptyDirectThreadChatState(), [
|
|
toolStarted(),
|
|
toolOutput(),
|
|
messageItem({ itemId: 'msg-user', role: 'user', text: '做一个拼图游戏' }),
|
|
]);
|
|
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',
|
|
item: messageItem({
|
|
itemId: 'sys-1',
|
|
role: 'system',
|
|
text: '内部指令',
|
|
}),
|
|
}),
|
|
event({
|
|
type: 'item.completed',
|
|
item: {
|
|
itemType: 'other',
|
|
itemId: 'plan-1',
|
|
rawType: 'plan',
|
|
at: 0,
|
|
} satisfies DirectThreadItem,
|
|
}),
|
|
]);
|
|
expect(selectDirectChatEntries(state)).toHaveLength(0);
|
|
});
|
|
|
|
it('历史条目与运行态按唯一 id 合并,重复条目只出现一次', () => {
|
|
const historical = mergeDirectHistoryItems(emptyDirectThreadChatState(), [
|
|
toolStarted(),
|
|
messageItem({ itemId: 'msg-user', role: 'user', text: '做一个拼图游戏' }),
|
|
]);
|
|
const live = reduceDirectThreadEvents(historical, [
|
|
event({ type: 'item.completed', item: toolOutput() }),
|
|
]);
|
|
const entries = selectDirectChatEntries(live);
|
|
expect(entries).toHaveLength(2);
|
|
expect(entries[0]?.toolCall?.status).toBe('completed');
|
|
expect(entries[0]?.toolCall?.detail.command).toBe('{"cmd": "ls"}');
|
|
});
|
|
});
|