Files
Genarrative/apps/ai-game-creator-shell/tests/directThreadChat.test.ts
T
k88936 ae0f9376c9 新增 DirectProject 聊天 reducer 深模块
- directThreadChat 把运行态事件与历史切片归并成同一份聊天条目,活动回合只由 turn.started/turn.completed 判定
- item.delta 载荷补 kind,reducer 不再靠 itemId 猜条目类型
- bootstrap 原子替换运行态并保留历史窗口,历史与运行态按 callId ?? itemId 去重合并
- 新增 5 条 reducer 单测
2026-09-16 18:05:10 +08:00

171 lines
5.1 KiB
TypeScript

import { describe, expect, it } from 'vitest';
import {
type DirectChatEntry,
directChatEntryIdentity,
emptyDirectThreadChatState,
mergeDirectHistoryEntries,
reduceDirectThreadEvents,
resolveDirectThreadBootstrap,
selectDirectChatEntries,
} from '../src/features/project-workspace/directThreadChat';
import type { DirectThreadRawEvent } from '../src/features/project-workspace/directThreadEvents';
function event(
partial: Partial<DirectThreadRawEvent> &
Pick<DirectThreadRawEvent, 'type' | 'turnId'>,
): DirectThreadRawEvent {
return { seq: 1, payload: {}, ...partial };
}
function toolEntry(
overrides: Partial<DirectChatEntry & { status?: string }> = {},
): DirectChatEntry {
return {
itemId: 'call-1',
callId: 'call-1',
turnId: 'turn-1',
kind: 'tool',
toolCall: {
schemaVersion: 'agc-tool-call.v1',
id: 'call-1',
turnId: 'turn-1',
kind: 'command',
title: '执行命令',
summary: 'ls',
status: 'running',
detail: { command: 'ls', output: undefined, changes: [] },
startedAt: 1000,
updatedAt: 1000,
},
...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',
kind: 'message',
role: 'assistant',
text: '你好,我是陶泥儿。',
},
}),
]);
const entries = selectDirectChatEntries(done);
expect(entries).toHaveLength(1);
expect(entries[0]?.text).toBe('你好,我是陶泥儿。');
});
it('工具条目 started/completed 归并成一张卡片', () => {
const state = reduceDirectThreadEvents(emptyDirectThreadChatState(), [
event({
type: 'item.started',
turnId: 'turn-1',
itemId: 'call-1',
payload: toolEntry(),
}),
event({
type: 'item.completed',
turnId: 'turn-1',
itemId: 'call-1',
payload: {
...toolEntry(),
toolCall: {
...toolEntry().toolCall!,
status: 'completed',
detail: { command: 'ls', output: 'assets\ngame', changes: [] },
updatedAt: 2000,
},
},
}),
]);
const entries = selectDirectChatEntries(state);
expect(entries).toHaveLength(1);
expect(entries[0]?.toolCall?.status).toBe('completed');
expect(entries[0]?.toolCall?.detail.output).toBe('assets\ngame');
expect(entries[0]?.toolCall?.detail.command).toBe('ls');
});
it('历史条目与运行态按 callId 合并,重复条目只出现一次', () => {
const historical = mergeDirectHistoryEntries(emptyDirectThreadChatState(), [
toolEntry(),
{
itemId: 'msg-user',
kind: 'message',
role: 'user',
text: '做一个拼图游戏',
},
]);
const live = reduceDirectThreadEvents(historical, [
event({
type: 'item.completed',
turnId: 'turn-1',
itemId: 'call-1',
payload: {
...toolEntry(),
itemId: 'call-1',
toolCall: {
...toolEntry().toolCall!,
status: 'completed',
detail: { command: 'ls', output: 'assets', changes: [] },
},
},
}),
]);
const entries = selectDirectChatEntries(live);
expect(entries).toHaveLength(2);
expect(entries[0]?.toolCall?.status).toBe('completed');
expect(directChatEntryIdentity(entries[0]!)).toBe('call-1');
});
});