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 }),