Files
Genarrative/apps/ai-game-creator-shell/tests/directThreadChat.test.ts
T
k88936 99be1d76fe 抽离 DirectProject 独立聊天容器并删除工作台壳 Direct 状态
- 新增 view/project-development/chat 模块:DirectProjectChatView 自己持有项目清单订阅、首屏锚点、历史分页、发送队列、附件、终止、草稿与本地消息
- DirectProjectChatView 入参收窄为项目路径、入口首轮需求(canonical content[])和两条权限门,Direct 状态不再穿过 App.tsx
- 线程订阅与聊天 reducer 提成 useDirectThreadChatSubscription:subscribe/consume/notify 单飞循环、订阅欠账、SUBSCRIPTION_EXPIRED 重订与 DirectThreadChatState
- chat/components 按「一个组件一个目录、逻辑与表现同目录」重组(ChatHeader、Composer、Conversation、SettingsDialog、ToolCallGroup)
- DirectProjectChatView 拆成聊天头、会话区、回合、输入盒四个组件
- App.tsx 删除 Direct 专属 state/ref/effect/handler 与 directCodex 分支,首轮需求按 canonical content 交给聊天
- 工作台级动作(运行本地预览等)经 DirectProjectChatHandle.announce 交给聊天自己的本地消息流
- Direct 首轮认领记录提成 app/initialSupervisorMessageClaims,供各入口共用
- Rust 侧 Direct 契约 ts-rs 导出目录改到 chat/generated
- 补齐 direct_codex_user_item/wire.rs 既有的 rustfmt 空格漂移
- 同步 ADR、实施计划、decision-log、pitfalls 与 Direct 技术文档
2026-09-19 15:08:31 +08:00

215 lines
7.3 KiB
TypeScript

import { describe, expect, it } from 'vitest';
import {
emptyDirectThreadChatState,
mergeDirectHistoryItems,
reduceDirectThreadEvents,
resolveDirectThreadBootstrap,
selectDirectChatEntries,
} from '../src/view/project-development/chat/conversation/directThreadChat';
import type { DirectThreadItem } from '../src/view/project-development/chat/conversation/directThreadItemProjection';
import type { DirectThreadEvent } from '../src/view/project-development/chat/generated/DirectThreadEvent';
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"}');
});
});