From a0fdafe1e06b31d47d7c16d22612173e0b3d9cc2 Mon Sep 17 00:00:00 2001 From: rootkiller6788 Date: Sat, 22 Aug 2026 01:07:44 +0800 Subject: [PATCH] fix: tolerate anthropic relays that omit text deltas Some Anthropic-compatible relays stream text blocks and text_delta events without a text field. The stream converter then yields a text part with undefined text, which the v2 loop publishes as an assistant.delta event without a delta field, crashing the TUI renderer with a trim() on undefined. Coerce missing text to an empty string in both the v2 and kosong Anthropic stream converters (matching the existing thinking ?? '' pattern), and defensively coerce a non-string delta in the TUI session event handlers. --- .../anthropic-relay-missing-text-crash.md | 5 +++ .../tui/controllers/session-event-handler.ts | 13 ++++--- .../provider/bases/anthropic/anthropic.ts | 4 +-- packages/kosong/src/providers/anthropic.ts | 4 +-- packages/kosong/test/anthropic.test.ts | 34 +++++++++++++++++++ 5 files changed, 52 insertions(+), 8 deletions(-) create mode 100644 .changeset/anthropic-relay-missing-text-crash.md diff --git a/.changeset/anthropic-relay-missing-text-crash.md b/.changeset/anthropic-relay-missing-text-crash.md new file mode 100644 index 00000000000..e5e606814bd --- /dev/null +++ b/.changeset/anthropic-relay-missing-text-crash.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Fix a crash when an Anthropic-compatible relay streams a text block without a text field. diff --git a/apps/kimi-code/src/tui/controllers/session-event-handler.ts b/apps/kimi-code/src/tui/controllers/session-event-handler.ts index 9cc95eba33b..8846a12de35 100644 --- a/apps/kimi-code/src/tui/controllers/session-event-handler.ts +++ b/apps/kimi-code/src/tui/controllers/session-event-handler.ts @@ -551,8 +551,9 @@ export class SessionEventHandler { // moon spinner while no ThinkingComponent is ever created (it needs visible // text), leaving a blank, spinner-less gap until the first real text/tool // token arrives. Keep the moon up until actual thinking text shows up. - if (event.delta.trim().length === 0 && !streamingUI.hasThinkingDraft()) return; - streamingUI.appendThinkingDelta(event.delta); + const delta = typeof event.delta === 'string' ? event.delta : ''; + if (delta.trim().length === 0 && !streamingUI.hasThinkingDraft()) return; + streamingUI.appendThinkingDelta(delta); this.host.patchLivePane({ mode: 'idle' }); if (state.appState.streamingPhase !== 'thinking') { this.host.setAppState({ streamingPhase: 'thinking', streamingStartTime: Date.now() }); @@ -562,15 +563,19 @@ export class SessionEventHandler { private handleAssistantDelta(event: AssistantDeltaEvent): void { const { state, streamingUI } = this.host; + // A compatible relay may stream a text block whose `text` field is absent; + // the wire event then carries no `delta` at all. Coerce it to an empty + // string so a malformed record can never crash the renderer. + const delta = typeof event.delta === 'string' ? event.delta : ''; if (streamingUI.hasThinkingDraft()) { streamingUI.flushThinkingToTranscript('idle'); } - if (event.delta.trim().length > 0) { + if (delta.trim().length > 0) { this.currentTurnHasAssistantText = true; this.pendingModelBlockedFallback = undefined; } - streamingUI.appendAssistantDelta(event.delta); + streamingUI.appendAssistantDelta(delta); this.host.patchLivePane({ mode: 'idle', diff --git a/packages/agent-core-v2/src/kosong/provider/bases/anthropic/anthropic.ts b/packages/agent-core-v2/src/kosong/provider/bases/anthropic/anthropic.ts index 77576d00fcc..03f4cd3bc53 100644 --- a/packages/agent-core-v2/src/kosong/provider/bases/anthropic/anthropic.ts +++ b/packages/agent-core-v2/src/kosong/provider/bases/anthropic/anthropic.ts @@ -691,7 +691,7 @@ class AnthropicStreamedMessage implements StreamedMessage { const blockIndex = blockEvt.index; switch (block.type) { case 'text': - yield { type: 'text', text: block.text }; + yield { type: 'text', text: block.text ?? '' }; break; case 'thinking': yield { type: 'think', think: block.thinking ?? '' }; @@ -720,7 +720,7 @@ class AnthropicStreamedMessage implements StreamedMessage { const blockIndex = deltaEvt.index; switch (delta.type) { case 'text_delta': - yield { type: 'text', text: delta.text }; + yield { type: 'text', text: delta.text ?? '' }; break; case 'thinking_delta': yield { type: 'think', think: delta.thinking ?? '' }; diff --git a/packages/kosong/src/providers/anthropic.ts b/packages/kosong/src/providers/anthropic.ts index 5af85106e2a..8fa70574292 100644 --- a/packages/kosong/src/providers/anthropic.ts +++ b/packages/kosong/src/providers/anthropic.ts @@ -809,7 +809,7 @@ class AnthropicStreamedMessage implements StreamedMessage { // eslint-disable-next-line typescript-eslint/switch-exhaustiveness-check switch (block.type) { case 'text': - yield { type: 'text', text: block.text }; + yield { type: 'text', text: block.text ?? '' }; break; case 'thinking': yield { type: 'think', think: block.thinking ?? '' }; @@ -842,7 +842,7 @@ class AnthropicStreamedMessage implements StreamedMessage { // eslint-disable-next-line typescript-eslint/switch-exhaustiveness-check switch (delta.type) { case 'text_delta': - yield { type: 'text', text: delta.text }; + yield { type: 'text', text: delta.text ?? '' }; break; case 'thinking_delta': yield { type: 'think', think: delta.thinking ?? '' }; diff --git a/packages/kosong/test/anthropic.test.ts b/packages/kosong/test/anthropic.test.ts index 6b079a75a62..fffcde7d312 100644 --- a/packages/kosong/test/anthropic.test.ts +++ b/packages/kosong/test/anthropic.test.ts @@ -2816,6 +2816,40 @@ describe('AnthropicChatProvider', () => { }); }); + it('coerces a missing text field to an empty string for relays that omit it', async () => { + const provider = createStreamProvider(); + const stream = mockStream([ + { + type: 'message_start', + message: { + id: 'msg_stream_002', + usage: { input_tokens: 10 }, + }, + }, + { type: 'content_block_start', index: 0, content_block: { type: 'text' } }, + { type: 'content_block_delta', index: 0, delta: { type: 'text_delta' } }, + { type: 'content_block_delta', index: 0, delta: { type: 'text_delta', text: 'ok' } }, + { type: 'message_delta', delta: {}, usage: { output_tokens: 5 } }, + { type: 'message_stop' }, + ]); + + (provider as any)._client.messages.create = vi.fn().mockResolvedValue(stream) as never; + + const result = await provider.generate( + '', + [], + [{ role: 'user', content: [{ type: 'text', text: 'Hi' }], toolCalls: [] }], + ); + + const parts = await collectParts(result); + + expect(parts).toEqual([ + { type: 'text', text: '' }, + { type: 'text', text: '' }, + { type: 'text', text: 'ok' }, + ]); + }); + it('yields thinking delta and signature from stream events', async () => { const provider = createStreamProvider(); const stream = mockStream([