前端:失败终态载荷的 message 缺字段不再打断 reducer

directThreadChat.ts:`turn.completed.failure.message` 在生成类型里是必填 string,但跨 IPC 的载荷没有运行时校验,缺字段 / null 时 `.trim()` 会在 reducer 里抛错,把这条订阅之后的所有事件一起打断;改成与兄弟函数 directTurnFailureNoticeText 一致的 typeof 判据,取不到非空字符串就按"没有原因"收口。
directThreadChat.test.ts:补一条回归用例(message 为 undefined / null 时不抛错、不补空气泡、终态照样收口);变异验证:撤掉 typeof 判据后该用例变红。
This commit is contained in:
2026-09-24 13:03:53 +08:00
parent 286d919129
commit b0e870e7e2
2 changed files with 39 additions and 10 deletions
@@ -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':
@@ -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 }),