2257a78d6b
思考展开时隐藏重复预览并使用灯泡图标,首个响应前显示临时思考提示。 统一分钟内小数和分钟以上整数秒,保持内外过程的紧凑间距与正文层级。 失败工具与分组显示错误色,非零退出码不再误判为成功。 补齐状态、格式、样式和交互回归,本批其余需求留待后续PR。
744 lines
28 KiB
TypeScript
744 lines
28 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/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;
|
|
}
|
|
|
|
/** 生命周期事件上的 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('事件级计时边界', () => {
|
|
/** 工具的开始 / 完成只读事件级 `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('同一轮内的重复开始事件保留第一次的起点', () => {
|
|
const started = reduceDirectThreadEvents(emptyDirectThreadChatState(), [
|
|
event({ type: 'turn.started', at: 1_000_000 }),
|
|
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_000);
|
|
});
|
|
|
|
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);
|
|
});
|
|
});
|
|
});
|