From b0e870e7e28c5b06259671f4a3872828208e4859 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Thu, 24 Sep 2026 13:03:53 +0800 Subject: [PATCH] =?UTF-8?q?=E5=89=8D=E7=AB=AF=EF=BC=9A=E5=A4=B1=E8=B4=A5?= =?UTF-8?q?=E7=BB=88=E6=80=81=E8=BD=BD=E8=8D=B7=E7=9A=84=20message=20?= =?UTF-8?q?=E7=BC=BA=E5=AD=97=E6=AE=B5=E4=B8=8D=E5=86=8D=E6=89=93=E6=96=AD?= =?UTF-8?q?=20reducer?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit directThreadChat.ts:`turn.completed.failure.message` 在生成类型里是必填 string,但跨 IPC 的载荷没有运行时校验,缺字段 / null 时 `.trim()` 会在 reducer 里抛错,把这条订阅之后的所有事件一起打断;改成与兄弟函数 directTurnFailureNoticeText 一致的 typeof 判据,取不到非空字符串就按"没有原因"收口。 directThreadChat.test.ts:补一条回归用例(message 为 undefined / null 时不抛错、不补空气泡、终态照样收口);变异验证:撤掉 typeof 判据后该用例变红。 --- .../chat/conversation/directThreadChat.ts | 26 ++++++++++++------- .../tests/directThreadChat.test.ts | 23 ++++++++++++++++ 2 files changed, 39 insertions(+), 10 deletions(-) diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/conversation/directThreadChat.ts b/apps/ai-game-creator-shell/src/view/project-development/chat/conversation/directThreadChat.ts index 4b8250e28..aee4c8a08 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/chat/conversation/directThreadChat.ts +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/conversation/directThreadChat.ts @@ -344,16 +344,22 @@ export function reduceDirectThreadEvent( // 失败终态带 `failure` 载荷:先把它落成本轮最后一条说明条目,再和正常终态走同一个收口 // 函数。载荷在、原因非空才算一条说明;空原因不补一条空气泡(终态照样收口)。 const failure = event.failure; - const withNotice = - failure && failure.message.trim() - ? upsertLiveEntry(state, { - itemId: directTurnFailureItemId(eventUserItemId, eventAt), - kind: 'message', - role: 'assistant', - text: directTurnFailureNoticeText(failure.message), - at: eventAt, - }) - : state; + // `message` 在生成类型里是必填 string,但跨 IPC 的载荷没有运行时校验:缺字段 / `null` + // 时直接 `.trim()` 会在 reducer 里抛错,把这一条订阅之后的全部事件一起打断。判据与兄弟 + // 函数 `directTurnFailureNoticeText` 保持一致,都是"不是非空字符串就当没有原因"。 + const failureText = + failure && typeof failure.message === 'string' + ? failure.message.trim() + : ''; + const withNotice = failureText + ? upsertLiveEntry(state, { + itemId: directTurnFailureItemId(eventUserItemId, eventAt), + kind: 'message', + role: 'assistant', + text: directTurnFailureNoticeText(failureText), + at: eventAt, + }) + : state; return finishDirectThreadTurn(withNotice, eventAt); } case 'item.delta': diff --git a/apps/ai-game-creator-shell/tests/directThreadChat.test.ts b/apps/ai-game-creator-shell/tests/directThreadChat.test.ts index f26a33c6d..6dd1ef409 100644 --- a/apps/ai-game-creator-shell/tests/directThreadChat.test.ts +++ b/apps/ai-game-creator-shell/tests/directThreadChat.test.ts @@ -417,6 +417,29 @@ describe('DirectProject 聊天 reducer', () => { 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 first = reduceDirectThreadEvents(emptyDirectThreadChatState(), [ event({ type: 'turn.started', at: 1_000 }),