reducer 用例:失败终态落说明条目、文案与横幅同源、重放不重复

- 新增「失败终态(turn.completed 带 failure 载荷)」六条用例:失败照样收口并冻结终点、说明条目按本轮开口身份派生、本轮开口条目拿到边界
- 可见文案与运行错误横幅共用同一份映射(点名 codex-app-server-error:context-window-exceeded)
- 重复 / 迟到的失败终态不追加第二条说明、不抬高冻结终点、不复活运行态
- 身份不匹配的失败终态不动正在跑的这一轮;空原因不落说明条目但终态照样收口
- 没有身份时用事件时间派生说明身份,两轮失败不会合并成一条
This commit is contained in:
2026-09-22 17:18:17 +08:00
parent 258645f182
commit b432556ee3
@@ -284,6 +284,165 @@ describe('DirectProject 聊天 reducer', () => {
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('没有身份时用事件时间派生说明身份,两轮失败不会合并成一条', () => {
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 当开始或完成', () => {