Files
Genarrative/apps/ai-game-creator-shell/tests/directThreadChat.test.ts
T
k88936 1b40f030e7 删掉聊天 reducer 里没人读的订阅身份与首屏锚点
- DirectThreadChatState 去掉 subscriptionId 与 lastCompletedItemId 两个只写不读的字段
- resolveDirectThreadBootstrap 只把 bootstrap 事件 reduce 进状态,不再顺带写入这两个字段
- directThreadChat 单测改成断言聊天状态里不再出现这两个字段(bootstrap 载荷本身不变)
2026-09-17 00:05:57 +08:00

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"}');
});
});