ae0f9376c9
- directThreadChat 把运行态事件与历史切片归并成同一份聊天条目,活动回合只由 turn.started/turn.completed 判定 - item.delta 载荷补 kind,reducer 不再靠 itemId 猜条目类型 - bootstrap 原子替换运行态并保留历史窗口,历史与运行态按 callId ?? itemId 去重合并 - 新增 5 条 reducer 单测
171 lines
5.1 KiB
TypeScript
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');
|
|
});
|
|
});
|