4a75de1c31
- 回合归属改成按身份(开口用户条目的 canonical itemId):失败说明条目带 turnUserItemId(reducer 写,缺身份时保持原顺序语义),buildDirectChatTurns 按身份分组,同一身份的条目永远同一轮 - 本轮开口条目还没到(回合在宿主下发条目之前就失败、或历史切片还没读回)时,本地乐观气泡按身份挂回自己那一轮,不再另开一轮:界面不再出现「错误显示在用户消息上面」+「气泡底下 0.0 秒」+「上一轮借走本轮终点(15.6 秒)」这一组现象 - 收口早退不再吞掉还没写进界面的失败说明(订阅重建后的 bootstrap 只回放生命周期锚点):只补说明、终点时间与「回合完成」计数,不重开回合、不动本轮起点 / 终点 / 身份 - 用例:directTurnPresentation 复现现场(两个回合、说明与气泡同段、耗时不再借上一轮的终点);directThreadChat 补身份字段与早退不吞说明两条
1028 lines
39 KiB
TypeScript
1028 lines
39 KiB
TypeScript
import { describe, expect, it } from 'vitest';
|
||
|
||
import type {
|
||
DirectChatEntry,
|
||
DirectThreadChatState,
|
||
} from '../src/features/project-workspace/directThreadChat';
|
||
import {
|
||
emptyDirectThreadChatState,
|
||
finishDirectThreadTurn,
|
||
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;
|
||
}
|
||
|
||
/** 生命周期事件上的 canonical user identity(原生 `userItemId`)。 */
|
||
function withUserItemId(
|
||
lifecycle: DirectThreadEvent,
|
||
userItemId: string,
|
||
): DirectThreadEvent {
|
||
return { ...lifecycle, userItemId };
|
||
}
|
||
|
||
/** 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('退出码非零优先于 completed:命令跑完但失败不标成功', () => {
|
||
const command = (
|
||
status: string | null,
|
||
exitCode: number | null,
|
||
itemId: string,
|
||
) => ({
|
||
itemType: 'commandExecution' as const,
|
||
itemId,
|
||
command: 'npm test',
|
||
output: 'boom',
|
||
status,
|
||
exitCode,
|
||
at: 2_000,
|
||
});
|
||
// status=completed + exitCode=1:失败(不能因为"跑完了"就标成功)。
|
||
const failed = reduceDirectThreadEvents(emptyDirectThreadChatState(), [
|
||
event({
|
||
type: 'item.completed',
|
||
at: 2_000,
|
||
item: command('completed', 1, 'call-fail'),
|
||
}),
|
||
]);
|
||
expect(selectDirectChatEntries(failed)[0]?.toolCall?.status).toBe('failed');
|
||
|
||
// status=completed + exitCode=0:成功。
|
||
const ok = reduceDirectThreadEvents(emptyDirectThreadChatState(), [
|
||
event({
|
||
type: 'item.completed',
|
||
at: 2_000,
|
||
item: command('completed', 0, 'call-ok'),
|
||
}),
|
||
]);
|
||
expect(selectDirectChatEntries(ok)[0]?.toolCall?.status).toBe('completed');
|
||
|
||
// status=inProgress 且没有退出码:仍在跑。
|
||
const running = reduceDirectThreadEvents(emptyDirectThreadChatState(), [
|
||
event({
|
||
type: 'item.started',
|
||
at: 2_000,
|
||
item: command('inProgress', null, 'call-running'),
|
||
}),
|
||
]);
|
||
expect(selectDirectChatEntries(running)[0]?.toolCall?.status).toBe(
|
||
'running',
|
||
);
|
||
|
||
// 上游显式失败终态优先于 completed:即使 exitCode=0 也算失败。
|
||
const declined = reduceDirectThreadEvents(emptyDirectThreadChatState(), [
|
||
event({
|
||
type: 'item.completed',
|
||
at: 2_000,
|
||
item: command('declined', 0, 'call-declined'),
|
||
}),
|
||
]);
|
||
expect(selectDirectChatEntries(declined)[0]?.toolCall?.status).toBe(
|
||
'failed',
|
||
);
|
||
});
|
||
|
||
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"}');
|
||
});
|
||
|
||
describe('失败终态(turn.completed 带 failure 载荷)', () => {
|
||
it('失败也是终态:收口本轮、冻结终点,并把原因落成本轮最后一条说明', () => {
|
||
const failed = reduceDirectThreadEvents(emptyDirectThreadChatState(), [
|
||
withUserItemId(
|
||
event({ type: 'turn.started', at: 1_000_000 }),
|
||
'direct-codex:turn-1:user',
|
||
),
|
||
event({ type: 'item.started', at: 1_000_100, item: toolStarted() }),
|
||
withUserItemId(
|
||
event({
|
||
type: 'turn.completed',
|
||
status: 'failed',
|
||
failure: {
|
||
kind: 'transport-failed',
|
||
message: 'DirectProject 收尾历史失败:未确认历史完整落盘',
|
||
},
|
||
at: 1_000_900,
|
||
}),
|
||
'direct-codex:turn-1:user',
|
||
),
|
||
]);
|
||
|
||
expect(failed.turnRunning).toBe(false);
|
||
expect(failed.turnEndedAt).toBe(1_000_900);
|
||
expect(failed.live).toHaveLength(0);
|
||
const notice = failed.history.at(-1);
|
||
expect(notice?.itemId).toBe('direct-codex:turn-1:user:failure');
|
||
expect(notice?.role).toBe('assistant');
|
||
expect(notice?.text).toBe(
|
||
'陶泥儿智能创作 保存运行记录失败,请检查项目目录后重试',
|
||
);
|
||
// 本轮开口条目照样按身份拿到边界(失败与正常终态同源)。
|
||
expect(selectDirectChatEntries(failed)[0]?.turnEndedAt).toBe(1_000_900);
|
||
expect(selectDirectChatEntries(failed)[0]?.turnStartedAt).toBe(1_000_000);
|
||
});
|
||
|
||
it('失败说明的可见文案与运行错误横幅共用同一份映射', () => {
|
||
const failed = reduceDirectThreadEvents(emptyDirectThreadChatState(), [
|
||
withUserItemId(
|
||
event({ type: 'turn.started', at: 1_000 }),
|
||
'direct-codex:turn-1:user',
|
||
),
|
||
event({
|
||
type: 'turn.completed',
|
||
status: 'failed',
|
||
failure: {
|
||
kind: 'request-rejected',
|
||
message: 'codex-app-server-error:context-window-exceeded',
|
||
},
|
||
at: 2_000,
|
||
}),
|
||
]);
|
||
expect(failed.history.at(-1)?.text).toBe(
|
||
'陶泥儿智能创作 模型上下文已超限,请缩小任务范围后重试',
|
||
);
|
||
});
|
||
|
||
it('重复 / 迟到的失败终态不追加第二条说明,也不抬高冻结终点或复活运行态', () => {
|
||
const failed = reduceDirectThreadEvents(emptyDirectThreadChatState(), [
|
||
withUserItemId(
|
||
event({ type: 'turn.started', at: 1_000_000 }),
|
||
'direct-codex:turn-1:user',
|
||
),
|
||
withUserItemId(
|
||
event({
|
||
type: 'turn.completed',
|
||
status: 'failed',
|
||
failure: { kind: 'model-failed', message: '模型服务暂不可用' },
|
||
at: 1_000_900,
|
||
}),
|
||
'direct-codex:turn-1:user',
|
||
),
|
||
]);
|
||
|
||
const replayed = reduceDirectThreadEvents(failed, [
|
||
withUserItemId(
|
||
event({
|
||
type: 'turn.completed',
|
||
status: 'failed',
|
||
failure: { kind: 'model-failed', message: '模型服务暂不可用' },
|
||
at: 9_900_000,
|
||
}),
|
||
'direct-codex:turn-1:user',
|
||
),
|
||
]);
|
||
expect(replayed.turnRunning).toBe(false);
|
||
expect(replayed.turnEndedAt).toBe(1_000_900);
|
||
expect(
|
||
replayed.history.filter((entry) => entry.itemId.endsWith(':failure')),
|
||
).toHaveLength(1);
|
||
});
|
||
|
||
it('身份不匹配的失败终态不动正在跑的这一轮', () => {
|
||
const running = reduceDirectThreadEvents(emptyDirectThreadChatState(), [
|
||
withUserItemId(
|
||
event({ type: 'turn.started', at: 2_000_000 }),
|
||
'direct-codex:turn-2:user',
|
||
),
|
||
]);
|
||
const untouched = reduceDirectThreadEvents(running, [
|
||
event({
|
||
type: 'turn.completed',
|
||
status: 'failed',
|
||
failure: { kind: 'model-failed', message: '上一轮的失败' },
|
||
at: 2_000_900,
|
||
userItemId: 'direct-codex:turn-1:user',
|
||
}),
|
||
]);
|
||
expect(untouched.turnRunning).toBe(true);
|
||
expect(untouched.turnEndedAt).toBe(0);
|
||
expect(untouched.history).toHaveLength(0);
|
||
expect(untouched.live).toHaveLength(0);
|
||
});
|
||
|
||
it('空原因不落说明条目,但终态照样收口', () => {
|
||
const failed = reduceDirectThreadEvents(emptyDirectThreadChatState(), [
|
||
withUserItemId(
|
||
event({ type: 'turn.started', at: 1_000 }),
|
||
'direct-codex:turn-1:user',
|
||
),
|
||
withUserItemId(
|
||
event({
|
||
type: 'turn.completed',
|
||
status: 'failed',
|
||
failure: { kind: 'host-dropped', message: ' ' },
|
||
at: 2_000,
|
||
}),
|
||
'direct-codex:turn-1:user',
|
||
),
|
||
]);
|
||
expect(failed.turnRunning).toBe(false);
|
||
expect(failed.turnEndedAt).toBe(2_000);
|
||
expect(failed.history).toHaveLength(0);
|
||
});
|
||
|
||
it('载荷的 message 缺失或为 null 时不抛错,按"没有原因"收口', () => {
|
||
// 跨 IPC 的载荷没有运行时校验:字段缺失 / `null` 都到得了 reducer。这里只要求
|
||
// "不抛错 + 不补空气泡",终态照样收口——抛错会连带打断这条订阅之后的所有事件。
|
||
for (const message of [undefined, null]) {
|
||
const malformed = {
|
||
type: 'turn.completed',
|
||
status: 'failed',
|
||
at: 2_000,
|
||
failure: { kind: 'host-dropped', message },
|
||
} as unknown as DirectThreadEvent;
|
||
const failed = reduceDirectThreadEvents(emptyDirectThreadChatState(), [
|
||
withUserItemId(
|
||
event({ type: 'turn.started', at: 1_000 }),
|
||
'direct-codex:turn-1:user',
|
||
),
|
||
malformed,
|
||
]);
|
||
expect(failed.turnRunning).toBe(false);
|
||
expect(failed.turnEndedAt).toBe(2_000);
|
||
expect(failed.history).toHaveLength(0);
|
||
}
|
||
});
|
||
|
||
it('失败说明带上它所属回合的身份,开口用户条目没到时投影层也能归位', () => {
|
||
const failed = reduceDirectThreadEvents(emptyDirectThreadChatState(), [
|
||
withUserItemId(
|
||
event({ type: 'turn.started', at: 1_000 }),
|
||
'direct-codex:turn-1:user',
|
||
),
|
||
withUserItemId(
|
||
event({
|
||
type: 'turn.completed',
|
||
status: 'failed',
|
||
failure: { kind: 'transport-failed', message: '连接失败' },
|
||
at: 2_000,
|
||
}),
|
||
'direct-codex:turn-1:user',
|
||
),
|
||
]);
|
||
// 身份是投影层"这条说明属于哪一轮"的唯一判据:本轮的开口用户条目可能还没到过界面
|
||
// (回合在宿主下发用户条目之前就失败、或历史切片还没读回),那时只有它能把说明挂回自己的回合。
|
||
expect(failed.history.at(-1)?.itemId).toBe(
|
||
'direct-codex:turn-1:user:failure',
|
||
);
|
||
expect(failed.history.at(-1)?.turnUserItemId).toBe(
|
||
'direct-codex:turn-1:user',
|
||
);
|
||
// 没有身份(旧事件)时不写这个字段,保持原有的顺序语义。
|
||
const anonymous = reduceDirectThreadEvents(emptyDirectThreadChatState(), [
|
||
event({ type: 'turn.started', at: 1_000 }),
|
||
event({
|
||
type: 'turn.completed',
|
||
status: 'failed',
|
||
failure: { kind: 'model-failed', message: '第一轮失败' },
|
||
at: 2_000,
|
||
}),
|
||
]);
|
||
expect(anonymous.history.at(-1)?.turnUserItemId).toBeUndefined();
|
||
});
|
||
|
||
it('收口早退不吞掉还没写进界面的失败说明(订阅重建只回放生命周期锚点)', () => {
|
||
const finished = reduceDirectThreadEvents(emptyDirectThreadChatState(), [
|
||
withUserItemId(
|
||
event({ type: 'turn.started', at: 1_000 }),
|
||
'direct-codex:turn-1:user',
|
||
),
|
||
withUserItemId(
|
||
event({ type: 'turn.completed', status: 'completed', at: 2_000 }),
|
||
'direct-codex:turn-1:user',
|
||
),
|
||
]);
|
||
expect(finished.history).toHaveLength(0);
|
||
|
||
// 订阅重建后的 bootstrap 只回放最新一条生命周期事件:它就是某个已收口回合的失败,界面上
|
||
// 没有任何东西能解释这一轮,必须补上这条说明(而不是按"重复终态"早退)。
|
||
const replayed = reduceDirectThreadEvents(finished, [
|
||
withUserItemId(
|
||
event({
|
||
type: 'turn.completed',
|
||
status: 'failed',
|
||
failure: { kind: 'transport-failed', message: '连接失败' },
|
||
at: 3_000,
|
||
}),
|
||
'direct-codex:turn-1:user',
|
||
),
|
||
]);
|
||
expect(replayed.history.map((entry) => entry.itemId)).toEqual([
|
||
'direct-codex:turn-1:user:failure',
|
||
]);
|
||
expect(replayed.history[0]?.turnEndedAt).toBe(3_000);
|
||
expect(replayed.completedTurnCount).toBe(finished.completedTurnCount + 1);
|
||
// 重复回放同一条锚点:说明已经写进去了,计数不再涨,也不追加第二条。
|
||
const replayAgain = reduceDirectThreadEvents(replayed, [
|
||
withUserItemId(
|
||
event({
|
||
type: 'turn.completed',
|
||
status: 'failed',
|
||
failure: { kind: 'transport-failed', message: '连接失败' },
|
||
at: 3_000,
|
||
}),
|
||
'direct-codex:turn-1:user',
|
||
),
|
||
]);
|
||
expect(replayAgain.history).toHaveLength(1);
|
||
expect(replayAgain.completedTurnCount).toBe(replayed.completedTurnCount);
|
||
});
|
||
|
||
it('没有身份时用事件时间派生说明身份,两轮失败不会合并成一条', () => {
|
||
const first = reduceDirectThreadEvents(emptyDirectThreadChatState(), [
|
||
event({ type: 'turn.started', at: 1_000 }),
|
||
event({
|
||
type: 'turn.completed',
|
||
status: 'failed',
|
||
failure: { kind: 'model-failed', message: '第一轮失败' },
|
||
at: 2_000,
|
||
}),
|
||
]);
|
||
const second = reduceDirectThreadEvents(first, [
|
||
event({ type: 'turn.started', at: 3_000 }),
|
||
event({
|
||
type: 'turn.completed',
|
||
status: 'failed',
|
||
failure: { kind: 'model-failed', message: '第二轮失败' },
|
||
at: 4_000,
|
||
}),
|
||
]);
|
||
expect(second.history.map((entry) => entry.itemId)).toEqual([
|
||
'direct-thread-turn-failure:2000',
|
||
'direct-thread-turn-failure:4000',
|
||
]);
|
||
});
|
||
});
|
||
|
||
describe('事件级计时边界', () => {
|
||
/** 工具的开始 / 完成只读事件级 `at`,条目 `item.at` 不作起止。 */
|
||
it('工具耗时只读事件级 at,不把 item.at 当开始或完成', () => {
|
||
const state = reduceDirectThreadEvents(emptyDirectThreadChatState(), [
|
||
event({ type: 'turn.started', at: 1_000_000 }),
|
||
event({
|
||
type: 'item.started',
|
||
at: 1_000_100,
|
||
item: toolStarted({ at: 999_999 }),
|
||
}),
|
||
event({
|
||
type: 'item.completed',
|
||
at: 1_000_700,
|
||
item: toolOutput({ at: 1_200_000 }),
|
||
}),
|
||
]);
|
||
const call = selectDirectChatEntries(state)[0]?.toolCall;
|
||
expect(call?.startedAt).toBe(1_000_100);
|
||
expect(call?.updatedAt).toBe(1_000_700);
|
||
});
|
||
|
||
it('只有完成事件、没有开始事件的工具不猜开始时间', () => {
|
||
const state = reduceDirectThreadEvents(emptyDirectThreadChatState(), [
|
||
event({ type: 'item.completed', at: 1_000_700, item: toolOutput() }),
|
||
]);
|
||
const call = selectDirectChatEntries(state)[0]?.toolCall;
|
||
expect(call?.startedAt).toBe(0);
|
||
expect(call?.updatedAt).toBe(1_000_700);
|
||
});
|
||
|
||
it('内部工具的阶段状态:开始事件就是运行中,正式完成事件才结束', () => {
|
||
const fileChange = {
|
||
itemType: 'fileChange',
|
||
itemId: 'file-1',
|
||
changes: [{ path: 'game/src/hero.ts', kind: 'update' }],
|
||
at: 0,
|
||
} as const;
|
||
const started = reduceDirectThreadEvents(emptyDirectThreadChatState(), [
|
||
event({ type: 'item.started', at: 1_000_100, item: fileChange }),
|
||
]);
|
||
const startedCall = selectDirectChatEntries(started)[0]?.toolCall;
|
||
expect(startedCall?.status).toBe('running');
|
||
expect(startedCall?.startedAt).toBe(1_000_100);
|
||
expect(startedCall?.updatedAt).toBe(0);
|
||
|
||
const finished = reduceDirectThreadEvents(started, [
|
||
event({ type: 'item.completed', at: 1_000_900, item: fileChange }),
|
||
]);
|
||
const finishedCall = selectDirectChatEntries(finished)[0]?.toolCall;
|
||
expect(finishedCall?.status).toBe('completed');
|
||
expect(finishedCall?.updatedAt).toBe(1_000_900);
|
||
});
|
||
|
||
it('function_call 完成快照不等于工具结束:一直运行到 output 到达', () => {
|
||
const state = reduceDirectThreadEvents(emptyDirectThreadChatState(), [
|
||
event({ type: 'item.started', at: 1_000_100, item: toolStarted() }),
|
||
// 同一个 function_call 又以完成快照重放一次:不是工具结束。
|
||
event({ type: 'item.completed', at: 1_000_200, item: toolStarted() }),
|
||
]);
|
||
const running = selectDirectChatEntries(state)[0]?.toolCall;
|
||
expect(running?.status).toBe('running');
|
||
// 完成快照不改开始边界,也不产生终点。
|
||
expect(running?.startedAt).toBe(1_000_100);
|
||
expect(running?.updatedAt).toBe(0);
|
||
|
||
const done = reduceDirectThreadEvents(state, [
|
||
event({ type: 'item.completed', at: 1_000_900, item: toolOutput() }),
|
||
]);
|
||
const finished = selectDirectChatEntries(done)[0]?.toolCall;
|
||
expect(finished?.status).toBe('completed');
|
||
expect(finished?.updatedAt).toBe(1_000_900);
|
||
});
|
||
|
||
it('收口后重复 / 迟到的终态事件不抬高冻结终点、不复活运行态', () => {
|
||
const done = reduceDirectThreadEvents(emptyDirectThreadChatState(), [
|
||
event({ type: 'turn.started', at: 1_000_000 }),
|
||
event({ type: 'item.started', at: 1_000_100, item: toolStarted() }),
|
||
event({
|
||
type: 'item.completed',
|
||
at: 1_000_700,
|
||
item: toolOutput(),
|
||
}),
|
||
event({ type: 'turn.completed', status: 'completed', at: 1_000_900 }),
|
||
]);
|
||
expect(done.turnRunning).toBe(false);
|
||
expect(done.turnEndedAt).toBe(1_000_900);
|
||
expect(done.history[0]?.turnEndedAt).toBe(1_000_900);
|
||
expect(done.history[0]?.turnStartedAt).toBe(1_000_000);
|
||
|
||
const replayed = reduceDirectThreadEvents(done, [
|
||
// 重复的完成事件 + 迟到的事件不得抬高已经冻结的终点。
|
||
event({ type: 'turn.completed', status: 'completed', at: 1_009_900 }),
|
||
event({ type: 'item.completed', at: 1_009_900, item: toolOutput() }),
|
||
]);
|
||
expect(replayed.turnRunning).toBe(false);
|
||
expect(replayed.turnEndedAt).toBe(1_000_900);
|
||
expect(replayed.history[0]?.toolCall?.updatedAt).toBe(1_000_700);
|
||
});
|
||
|
||
it('回合身份只认事件顺序:同一秒内开始的下一轮也照常接上', () => {
|
||
const done = reduceDirectThreadEvents(emptyDirectThreadChatState(), [
|
||
event({ type: 'turn.started', at: 1_000_000 }),
|
||
// 宿主收口的毫秒时间:原生回合时间是秒级精度,"上一轮结束之后又来一条开始事件"
|
||
// 就是新回合,不能因为时间不比终点晚就把它当重放吞掉。
|
||
event({ type: 'turn.completed', status: 'cancelled', at: 1_000_900 }),
|
||
]);
|
||
const next = reduceDirectThreadEvents(done, [
|
||
event({ type: 'turn.started', at: 1_000_000 }),
|
||
]);
|
||
expect(next.turnRunning).toBe(true);
|
||
expect(next.turnStartedAt).toBe(1_000_000);
|
||
expect(next.turnEndedAt).toBe(0);
|
||
});
|
||
|
||
it('不再为同一轮内的重复开始事件做兼容:起点就是最后一条开始事件', () => {
|
||
// 宿主在接单时**只发一次** `turn.started`(Thread Manager 的接单动作),线上不存在"同一轮
|
||
// 里又来一条开始事件"。所以这里不再保留第一次的起点:真出现重复那是事件源的问题,reducer
|
||
// 按事件顺序照实收下,不替它编一个更早的起点。
|
||
const started = reduceDirectThreadEvents(emptyDirectThreadChatState(), [
|
||
event({ type: 'turn.started', at: 1_000_000 }),
|
||
event({ type: 'turn.started', at: 1_000_500 }),
|
||
]);
|
||
expect(started.turnRunning).toBe(true);
|
||
expect(started.turnStartedAt).toBe(1_000_500);
|
||
});
|
||
|
||
it('收口计数是状态:同一批里开始又结束也数得到,重复终态不重复计数', () => {
|
||
// 队列放行与埋点结算读这个计数,而不是 `turnRunning` 的下降沿——一轮可能在同一次
|
||
// consume 里开始并结束(接单后立刻失败),那时下降沿永远不会出现。
|
||
const batched = reduceDirectThreadEvents(emptyDirectThreadChatState(), [
|
||
event({ type: 'turn.started', at: 1_000_000 }),
|
||
event({ type: 'turn.completed', status: 'failed', at: 1_000_400 }),
|
||
]);
|
||
expect(batched.completedTurnCount).toBe(1);
|
||
const replayed = reduceDirectThreadEvents(batched, [
|
||
event({ type: 'turn.completed', status: 'failed', at: 1_000_400 }),
|
||
]);
|
||
expect(replayed.completedTurnCount).toBe(1);
|
||
});
|
||
|
||
it('终态之后同身份的迟到条目补进历史,不挂到下一轮运行态', () => {
|
||
const done = reduceDirectThreadEvents(emptyDirectThreadChatState(), [
|
||
event({ type: 'turn.started', at: 1_000_000 }),
|
||
event({ type: 'item.started', at: 1_000_100, item: toolStarted() }),
|
||
event({ type: 'turn.completed', status: 'completed', at: 1_000_900 }),
|
||
]);
|
||
// 迟到的事件:同身份的完成快照在回合收口之后才到。
|
||
const late = reduceDirectThreadEvents(done, [
|
||
event({ type: 'item.completed', at: 1_000_700, item: toolOutput() }),
|
||
]);
|
||
expect(late.live).toHaveLength(0);
|
||
expect(late.history).toHaveLength(1);
|
||
expect(late.history[0]?.toolCall?.status).toBe('completed');
|
||
expect(late.history[0]?.toolCall?.detail.output).toBe('assets\ngame');
|
||
// 冻结的终点不被抬高。
|
||
expect(late.history[0]?.toolCall?.updatedAt).toBe(1_000_700);
|
||
|
||
// 新回合开始后,迟到条目仍然回历史,不会挤进这一轮。
|
||
const next = reduceDirectThreadEvents(late, [
|
||
event({ type: 'turn.started', at: 1_001_000 }),
|
||
event({ type: 'item.completed', at: 1_001_700, item: toolOutput() }),
|
||
]);
|
||
expect(next.live).toHaveLength(0);
|
||
expect(next.history).toHaveLength(1);
|
||
});
|
||
|
||
it('bootstrap 已把用户消息当历史锚点给出时,边界仍落在本轮开口条目上', () => {
|
||
const bootstrapped = resolveDirectThreadBootstrap(
|
||
mergeDirectHistoryItems(emptyDirectThreadChatState(), [
|
||
messageItem({ itemId: 'msg-user', role: 'user', text: '做一个拼图' }),
|
||
]),
|
||
{
|
||
subscriptionId: 'sub-1',
|
||
lastCompletedItemId: 'msg-user',
|
||
events: [
|
||
// 原生在生命周期锚点上带出本轮 canonical user identity,开口条目已经在历史里。
|
||
withUserItemId(
|
||
event({ type: 'turn.started', at: 1_000_000 }),
|
||
'msg-user',
|
||
),
|
||
event({
|
||
type: 'item.completed',
|
||
at: 1_000_700,
|
||
item: toolOutput(),
|
||
}),
|
||
event({
|
||
type: 'turn.completed',
|
||
status: 'completed',
|
||
at: 1_000_900,
|
||
}),
|
||
],
|
||
},
|
||
);
|
||
expect(selectDirectChatEntries(bootstrapped)[0]?.turnEndedAt).toBe(
|
||
1_000_900,
|
||
);
|
||
expect(selectDirectChatEntries(bootstrapped)[0]?.turnStartedAt).toBe(
|
||
1_000_000,
|
||
);
|
||
});
|
||
|
||
it('没有 userItemId 的生命周期事件保持顺序语义,但不猜历史归属', () => {
|
||
const bootstrapped = resolveDirectThreadBootstrap(
|
||
mergeDirectHistoryItems(emptyDirectThreadChatState(), [
|
||
messageItem({ itemId: 'msg-user', role: 'user', text: '做一个拼图' }),
|
||
]),
|
||
{
|
||
subscriptionId: 'sub-1',
|
||
lastCompletedItemId: 'msg-user',
|
||
events: [
|
||
// 旧原生事件不带身份:运行态条目照常收口,历史里那条不按尾巴猜。
|
||
event({
|
||
type: 'item.completed',
|
||
at: 1_000_700,
|
||
item: toolOutput(),
|
||
}),
|
||
event({
|
||
type: 'turn.completed',
|
||
status: 'completed',
|
||
at: 1_000_900,
|
||
}),
|
||
],
|
||
},
|
||
);
|
||
const entries = selectDirectChatEntries(bootstrapped);
|
||
expect(entries).toHaveLength(2);
|
||
expect(entries[0]?.itemId).toBe('msg-user');
|
||
expect(entries[0]?.turnEndedAt).toBeUndefined();
|
||
// 运行态那条照常拿到边界(顺序语义不变)。
|
||
expect(entries[1]?.turnEndedAt).toBe(1_000_900);
|
||
});
|
||
|
||
it('本轮开口条目已在历史、运行态为空时,收口仍按身份把边界盖在它身上', () => {
|
||
const userItem = messageItem({
|
||
itemId: 'direct-codex:turn-1:user',
|
||
role: 'user',
|
||
text: '做一个拼图游戏',
|
||
});
|
||
const running = reduceDirectThreadEvents(
|
||
mergeDirectHistoryItems(emptyDirectThreadChatState(), [userItem]),
|
||
[
|
||
// 原生在生命周期事件上带出本轮 canonical user identity;这一轮没有任何运行态
|
||
// 条目,开口条目只存在于历史里。
|
||
withUserItemId(
|
||
event({ type: 'turn.started', at: 1_000_000 }),
|
||
'direct-codex:turn-1:user',
|
||
),
|
||
],
|
||
);
|
||
expect(running.live).toHaveLength(0);
|
||
|
||
const done = reduceDirectThreadEvents(running, [
|
||
withUserItemId(
|
||
event({ type: 'turn.completed', status: 'aborted', at: 1_000_900 }),
|
||
'direct-codex:turn-1:user',
|
||
),
|
||
]);
|
||
// 边界落在本轮这条用户条目上:既不丢终态时间,也没有第二套回合身份。
|
||
expect(done.history[0]?.turnStartedAt).toBe(1_000_000);
|
||
expect(done.history[0]?.turnEndedAt).toBe(1_000_900);
|
||
});
|
||
|
||
it('终态先于历史切片到达时,回读到开口条目仍补上已冻结的边界', () => {
|
||
const started = resolveDirectThreadBootstrap(
|
||
emptyDirectThreadChatState(),
|
||
{
|
||
subscriptionId: 'sub-1',
|
||
lastCompletedItemId: null,
|
||
events: [
|
||
withUserItemId(
|
||
event({ type: 'turn.started', at: 1_000_000 }),
|
||
'direct-codex:turn-1:user',
|
||
),
|
||
],
|
||
},
|
||
);
|
||
// 终态先到:这一刻历史里还没有那条用户条目。
|
||
const finished = reduceDirectThreadEvents(started, [
|
||
withUserItemId(
|
||
event({ type: 'turn.completed', status: 'completed', at: 1_000_900 }),
|
||
'direct-codex:turn-1:user',
|
||
),
|
||
]);
|
||
expect(finished.turnEndedAt).toBe(1_000_900);
|
||
expect(finished.history).toHaveLength(0);
|
||
|
||
const hydrated = mergeDirectHistoryItems(finished, [
|
||
messageItem({
|
||
itemId: 'direct-codex:turn-1:user',
|
||
role: 'user',
|
||
text: '做一个拼图游戏',
|
||
at: 999_000,
|
||
}),
|
||
]);
|
||
expect(hydrated.history[0]?.turnStartedAt).toBe(1_000_000);
|
||
expect(hydrated.history[0]?.turnEndedAt).toBe(1_000_900);
|
||
});
|
||
|
||
it('上一轮迟到的终态按身份被拒,不关掉正在跑的这一轮', () => {
|
||
const running = reduceDirectThreadEvents(emptyDirectThreadChatState(), [
|
||
withUserItemId(
|
||
event({ type: 'turn.started', at: 2_000_000 }),
|
||
'direct-codex:turn-1:user',
|
||
),
|
||
event({ type: 'item.started', at: 2_000_100, item: toolStarted() }),
|
||
]);
|
||
|
||
const late = reduceDirectThreadEvents(running, [
|
||
// 上一轮的终态(身份不同):哪怕时间戳更早,也不允许收口正在跑的这一轮。
|
||
withUserItemId(
|
||
event({ type: 'turn.completed', status: 'aborted', at: 1_000_900 }),
|
||
'direct-codex:turn-0:user',
|
||
),
|
||
]);
|
||
expect(late.turnRunning).toBe(true);
|
||
expect(late.turnEndedAt).toBe(0);
|
||
expect(late.live).toHaveLength(1);
|
||
});
|
||
|
||
it('同一轮的重复终态不抬高已冻结的终点', () => {
|
||
const done = reduceDirectThreadEvents(emptyDirectThreadChatState(), [
|
||
withUserItemId(
|
||
event({ type: 'turn.started', at: 1_000_000 }),
|
||
'direct-codex:turn-1:user',
|
||
),
|
||
withUserItemId(
|
||
event({ type: 'turn.completed', status: 'completed', at: 1_000_900 }),
|
||
'direct-codex:turn-1:user',
|
||
),
|
||
]);
|
||
const replayed = reduceDirectThreadEvents(done, [
|
||
withUserItemId(
|
||
event({ type: 'turn.completed', status: 'completed', at: 9_900_000 }),
|
||
'direct-codex:turn-1:user',
|
||
),
|
||
]);
|
||
expect(replayed.turnEndedAt).toBe(1_000_900);
|
||
expect(replayed.live).toHaveLength(0);
|
||
});
|
||
|
||
it('本轮自己没有任何条目时,不把边界盖到上一轮已经收口的开口条目上', () => {
|
||
const previousOpener: DirectChatEntry = {
|
||
itemId: 'direct-codex:turn-0:user',
|
||
kind: 'message',
|
||
role: 'user',
|
||
text: '上一轮的问题',
|
||
at: 900_000,
|
||
turnStartedAt: 910_000,
|
||
turnEndedAt: 950_000,
|
||
};
|
||
const previous: DirectThreadChatState = {
|
||
...emptyDirectThreadChatState(),
|
||
history: [previousOpener],
|
||
};
|
||
const done = reduceDirectThreadEvents(previous, [
|
||
withUserItemId(
|
||
event({ type: 'turn.started', at: 2_000_000 }),
|
||
'direct-codex:turn-2:user',
|
||
),
|
||
withUserItemId(
|
||
event({ type: 'turn.completed', status: 'aborted', at: 2_000_400 }),
|
||
'direct-codex:turn-2:user',
|
||
),
|
||
]);
|
||
expect(done.history[0]?.turnStartedAt).toBe(910_000);
|
||
expect(done.history[0]?.turnEndedAt).toBe(950_000);
|
||
});
|
||
|
||
/**
|
||
* 本轮开口条目只由 history slice 回读、运行态全程为空:身份只能来自原生生命周期事件上
|
||
* 带的 canonical user identity,不能按历史尾或时间戳猜。
|
||
*/
|
||
it('只有生命周期锚点 + 历史回读的开口条目时,按原生身份精准恢复用时', () => {
|
||
const running = resolveDirectThreadBootstrap(
|
||
emptyDirectThreadChatState(),
|
||
{
|
||
subscriptionId: 'sub-1',
|
||
lastCompletedItemId: null,
|
||
events: [
|
||
withUserItemId(
|
||
event({ type: 'turn.started', at: 1_000_000 }),
|
||
'direct-codex:turn-1:user',
|
||
),
|
||
],
|
||
},
|
||
);
|
||
const withHistory = mergeDirectHistoryItems(running, [
|
||
messageItem({
|
||
itemId: 'direct-codex:turn-1:user',
|
||
role: 'user',
|
||
text: '做一个拼图游戏',
|
||
at: 999_000,
|
||
}),
|
||
]);
|
||
expect(withHistory.live).toHaveLength(0);
|
||
|
||
const done = reduceDirectThreadEvents(withHistory, [
|
||
withUserItemId(
|
||
event({ type: 'turn.completed', status: 'aborted', at: 1_000_900 }),
|
||
'direct-codex:turn-1:user',
|
||
),
|
||
]);
|
||
expect(done.turnRunning).toBe(false);
|
||
expect(done.turnEndedAt).toBe(1_000_900);
|
||
// 身份来自原生事件,历史里那条开口条目因此拿到同一组边界。
|
||
expect(done.history[0]?.turnStartedAt).toBe(1_000_000);
|
||
expect(done.history[0]?.turnEndedAt).toBe(1_000_900);
|
||
});
|
||
|
||
it('收口只填空,不抬高条目上已经冻结的终点', () => {
|
||
// 规则本身:已经写上的边界先到先用,重复 / 迟到的收口不得抬高它。
|
||
const frozenOpener: DirectChatEntry = {
|
||
itemId: 'direct-codex:turn-1:user',
|
||
kind: 'message',
|
||
role: 'user',
|
||
text: '做一个拼图游戏',
|
||
at: 999_000,
|
||
turnStartedAt: 1_000_000,
|
||
turnEndedAt: 1_000_900,
|
||
};
|
||
const state: DirectThreadChatState = {
|
||
...emptyDirectThreadChatState(),
|
||
history: [frozenOpener],
|
||
turnUserItemId: 'direct-codex:turn-1:user',
|
||
live: [
|
||
{
|
||
itemId: 'call-late',
|
||
kind: 'tool',
|
||
role: null,
|
||
text: null,
|
||
at: 0,
|
||
toolCall: {
|
||
schemaVersion: 'agc-tool-call.v1',
|
||
id: 'call-late',
|
||
kind: 'command',
|
||
title: '执行命令',
|
||
summary: 'npm run build',
|
||
status: 'completed',
|
||
detail: { command: 'npm run build' },
|
||
startedAt: 1_000_100,
|
||
updatedAt: 1_000_700,
|
||
},
|
||
},
|
||
],
|
||
turnStartedAt: 1_000_000,
|
||
};
|
||
const done = finishDirectThreadTurn(state, 9_900_000);
|
||
expect(done.history[0]?.turnEndedAt).toBe(1_000_900);
|
||
expect(done.history[0]?.turnStartedAt).toBe(1_000_000);
|
||
});
|
||
|
||
it('宿主终止收口:没有权威终态时间就不写,之后的原生终态事件也不会被抬高', () => {
|
||
const running = reduceDirectThreadEvents(emptyDirectThreadChatState(), [
|
||
event({ type: 'turn.started', at: 1_000_000 }),
|
||
event({ type: 'item.completed', at: 1_000_700, item: toolOutput() }),
|
||
]);
|
||
const released = finishDirectThreadTurn(running, Date.now());
|
||
expect(released.turnRunning).toBe(false);
|
||
expect(released.live).toHaveLength(0);
|
||
expect(released.history[0]?.turnEndedAt).toBe(released.turnEndedAt);
|
||
|
||
const unknown = finishDirectThreadTurn(running, 0);
|
||
expect(unknown.turnEndedAt).toBe(0);
|
||
expect(unknown.history[0]?.turnEndedAt).toBeUndefined();
|
||
// 已经收口:后续事件不再改动终态时间,也不复活运行态。
|
||
const replayed = reduceDirectThreadEvents(unknown, [
|
||
event({ type: 'turn.completed', status: 'completed', at: 1_009_900 }),
|
||
]);
|
||
expect(replayed.turnRunning).toBe(false);
|
||
expect(replayed.turnEndedAt).toBe(0);
|
||
});
|
||
});
|
||
});
|