From b432556ee3748e9eb52f498b1848d94f4ad4e80f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Tue, 22 Sep 2026 17:18:17 +0800 Subject: [PATCH] =?UTF-8?q?reducer=20=E7=94=A8=E4=BE=8B=EF=BC=9A=E5=A4=B1?= =?UTF-8?q?=E8=B4=A5=E7=BB=88=E6=80=81=E8=90=BD=E8=AF=B4=E6=98=8E=E6=9D=A1?= =?UTF-8?q?=E7=9B=AE=E3=80=81=E6=96=87=E6=A1=88=E4=B8=8E=E6=A8=AA=E5=B9=85?= =?UTF-8?q?=E5=90=8C=E6=BA=90=E3=80=81=E9=87=8D=E6=94=BE=E4=B8=8D=E9=87=8D?= =?UTF-8?q?=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增「失败终态(turn.completed 带 failure 载荷)」六条用例:失败照样收口并冻结终点、说明条目按本轮开口身份派生、本轮开口条目拿到边界 - 可见文案与运行错误横幅共用同一份映射(点名 codex-app-server-error:context-window-exceeded) - 重复 / 迟到的失败终态不追加第二条说明、不抬高冻结终点、不复活运行态 - 身份不匹配的失败终态不动正在跑的这一轮;空原因不落说明条目但终态照样收口 - 没有身份时用事件时间派生说明身份,两轮失败不会合并成一条 --- .../tests/directThreadChat.test.ts | 159 ++++++++++++++++++ 1 file changed, 159 insertions(+) diff --git a/apps/ai-game-creator-shell/tests/directThreadChat.test.ts b/apps/ai-game-creator-shell/tests/directThreadChat.test.ts index 55a1b05d6..54066f748 100644 --- a/apps/ai-game-creator-shell/tests/directThreadChat.test.ts +++ b/apps/ai-game-creator-shell/tests/directThreadChat.test.ts @@ -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 当开始或完成', () => {