From a0fdafe1e06b31d47d7c16d22612173e0b3d9cc2 Mon Sep 17 00:00:00 2001 From: rootkiller6788 Date: Sat, 22 Aug 2026 01:07:44 +0800 Subject: [PATCH 1/2] 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([ From 8ac7305525da3c5ecd45025fc46cf7c06cfce07d Mon Sep 17 00:00:00 2001 From: rootkiller6788 Date: Thu, 3 Sep 2026 13:48:33 +0800 Subject: [PATCH 2/2] test(kosong): drive the missing-text regression test through clientFactory Follow-up to review: the new test poked the provider's private _client to inject the mock stream, so any change to how the client is stored would break the test even when streaming stays correct. Rework it to reuse collectAnthropicStreamParts, which stubs the SDK through the public clientFactory boundary, like the neighbouring empty-thinking tests. --- packages/kosong/test/anthropic.test.ts | 18 ++---------------- 1 file changed, 2 insertions(+), 16 deletions(-) diff --git a/packages/kosong/test/anthropic.test.ts b/packages/kosong/test/anthropic.test.ts index fffcde7d312..72c79c152bd 100644 --- a/packages/kosong/test/anthropic.test.ts +++ b/packages/kosong/test/anthropic.test.ts @@ -2817,14 +2817,10 @@ describe('AnthropicChatProvider', () => { }); it('coerces a missing text field to an empty string for relays that omit it', async () => { - const provider = createStreamProvider(); - const stream = mockStream([ + const parts = await collectAnthropicStreamParts([ { type: 'message_start', - message: { - id: 'msg_stream_002', - usage: { input_tokens: 10 }, - }, + 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' } }, @@ -2833,16 +2829,6 @@ describe('AnthropicChatProvider', () => { { 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: '' },