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