fdc48fe725
- directThreadItemProjection.ts 直接消费生成的 DirectThreadItem,工具卡片 kind / 标题 / 摘要 / 状态与可见性判定都在这里 - directThreadChat.ts 聊天条目只留一个 itemId:去掉 callId 概念,去掉 deltaText 缓冲,增量直接追加到运行态条目正文 - 合并规则收成先到者赢、后到只补空:正文只增不减、工具状态允许从 running 升级到终态 - 回合结束把运行态条目并入历史并清空 live,条目既不消失也不重复 - directThreadEvents.ts 改为转发 ts-rs 绑定,只保留历史条目转消息的过渡函数 - ToolCallGroup 与 toolCallGroupPresentation 改用去掉回合身份的 DirectChatToolCard - generated/index.ts 补上 DirectThread* 绑定导出 - 回退本仓库其它模块仍在改的 DirectCodexUser* 绑定重新生成(避免误改他人进行中的契约) - 两个 vitest 用例文件按新 API 重写,11 条用例通过
214 lines
7.2 KiB
TypeScript
214 lines
7.2 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() }),
|
|
],
|
|
},
|
|
);
|
|
expect(bootstrapped.subscriptionId).toBe('sub-1');
|
|
expect(bootstrapped.lastCompletedItemId).toBe('msg-9');
|
|
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"}');
|
|
});
|
|
});
|