From 6d6c0a483e78a014ff5826eae1bee77ccd5c5997 Mon Sep 17 00:00:00 2001 From: isletspace Date: Mon, 27 Jul 2026 12:34:21 +0800 Subject: [PATCH 1/3] fix(kosong): skip empty reasoning fields in OpenAI-compatible streams Some OpenAI-compatible gateways pad every content-phase stream chunk with an empty reasoning field (e.g. "reasoning_content":""). The provider yielded an empty think part for each of those chunks, interleaving them between text deltas; sequential part merging then could not merge the text, so the assembled message ended up with one text part per token and the TUI rendered a line break per token. Skip empty reasoning strings at both yield sites (stream and non-stream) so text deltas stay adjacent and merge into a single text part. --- .changeset/tidy-rivers-merge.md | 5 ++ .../kosong/src/providers/openai-legacy.ts | 10 +++- packages/kosong/test/openai-legacy.test.ts | 50 +++++++++++++++++-- 3 files changed, 59 insertions(+), 6 deletions(-) create mode 100644 .changeset/tidy-rivers-merge.md diff --git a/.changeset/tidy-rivers-merge.md b/.changeset/tidy-rivers-merge.md new file mode 100644 index 0000000000..ea80a31d6b --- /dev/null +++ b/.changeset/tidy-rivers-merge.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Fix streamed assistant replies rendering one token per line with OpenAI-compatible providers that pad empty reasoning fields into every chunk. diff --git a/packages/kosong/src/providers/openai-legacy.ts b/packages/kosong/src/providers/openai-legacy.ts index f40e59df7d..3cad67e517 100644 --- a/packages/kosong/src/providers/openai-legacy.ts +++ b/packages/kosong/src/providers/openai-legacy.ts @@ -386,8 +386,11 @@ export class OpenAILegacyStreamedMessage implements StreamedMessage { // Reasoning content: honor the explicit key when set, otherwise scan the // de facto field set and remember the dialect for outbound echo. + // Skip empty strings: some gateways pad every content-phase chunk with an + // empty reasoning field, which would otherwise interleave empty think + // parts between text deltas and defeat sequential merging downstream. const reasoning = reasoningKeyDialect.observe(message); - if (reasoning !== undefined) { + if (reasoning !== undefined && reasoning.length > 0) { yield { type: 'think', think: reasoning } satisfies StreamedMessagePart; } @@ -441,8 +444,11 @@ export class OpenAILegacyStreamedMessage implements StreamedMessage { // Reasoning content: honor the explicit key when set, otherwise scan // the de facto field set and remember the dialect for outbound echo. + // Skip empty strings: some gateways pad every content-phase chunk with + // an empty reasoning field, which would otherwise interleave empty + // think parts between text deltas and defeat sequential merging. const reasoning = reasoningKeyDialect.observe(delta); - if (reasoning !== undefined) { + if (reasoning !== undefined && reasoning.length > 0) { yield { type: 'think', think: reasoning } satisfies StreamedMessagePart; } diff --git a/packages/kosong/test/openai-legacy.test.ts b/packages/kosong/test/openai-legacy.test.ts index 3f5d8b5bf8..748fc1fd3b 100644 --- a/packages/kosong/test/openai-legacy.test.ts +++ b/packages/kosong/test/openai-legacy.test.ts @@ -1469,7 +1469,11 @@ describe('OpenAILegacyChatProvider', () => { ]); }); - it('yields an empty ThinkPart from an explicitly empty streaming reasoning field', async () => { + it('skips empty streaming reasoning fields instead of yielding empty ThinkParts', async () => { + // Some OpenAI-compatible gateways pad every content-phase chunk with an + // empty reasoning field. Yielding empty ThinkParts would interleave them + // between text deltas and defeat sequential merging (symptom downstream: + // one text part per token). const provider = new OpenAILegacyChatProvider({ model: 'deepseek-reasoner', apiKey: 'test-key', @@ -1488,7 +1492,45 @@ describe('OpenAILegacyChatProvider', () => { const parts: StreamedMessagePart[] = []; for await (const part of stream) parts.push(part); - expect(parts).toEqual([{ type: 'think', think: '' }]); + expect(parts).toEqual([]); + }); + + it('does not interleave empty ThinkParts between text deltas when chunks pad empty reasoning', async () => { + const provider = new OpenAILegacyChatProvider({ + model: 'some-reasoning-model', + apiKey: 'test-key', + stream: true, + }); + + async function* mockedStream(): AsyncIterable> { + yield { + id: 'c1', + choices: [{ index: 0, delta: { content: '', reasoning_content: 'think 1' } }], + }; + yield { + id: 'c1', + choices: [{ index: 0, delta: { content: 'Hello', reasoning_content: '' } }], + }; + yield { + id: 'c1', + choices: [{ index: 0, delta: { content: ' world', reasoning_content: '' } }], + }; + yield { id: 'c1', choices: [{ index: 0, delta: {}, finish_reason: 'stop' }] }; + } + + (provider as any)._client.chat.completions.create = vi + .fn() + .mockResolvedValue(mockedStream()); + + const stream = await provider.generate('', [], []); + const parts: StreamedMessagePart[] = []; + for await (const part of stream) parts.push(part); + + expect(parts).toEqual([ + { type: 'think', think: 'think 1' }, + { type: 'text', text: 'Hello' }, + { type: 'text', text: ' world' }, + ]); }); it('treats blank reasoning_key as unset so defaults still apply', async () => { @@ -1838,7 +1880,7 @@ describe('OpenAILegacyChatProvider — non-stream response parsing', () => { ]); }); - it('yields an empty ThinkPart when the non-stream reasoning field is explicitly empty', async () => { + it('skips an explicitly empty non-stream reasoning field', async () => { const provider = new OpenAILegacyChatProvider({ model: 'deepseek-reasoner', apiKey: 'test-key', @@ -1855,7 +1897,7 @@ describe('OpenAILegacyChatProvider — non-stream response parsing', () => { }), ); - expect(parts).toEqual([{ type: 'think', think: '' }]); + expect(parts).toEqual([]); }); it('non-stream response yields ToolCall parts when tool_calls present', async () => { From ad702bed83fa0ad13ff811d1d815ba2d78f3d309 Mon Sep 17 00:00:00 2001 From: isletspace Date: Mon, 27 Jul 2026 13:08:27 +0800 Subject: [PATCH 2/3] fix(kosong): preserve empty reasoning marker on tool-call responses Addresses review feedback: the empty-reasoning skip now applies only to content-only messages/chunks. Tool-call responses keep the empty think marker so the reasoning field is replayed on continuation, matching the existing serialization contract for reasoning endpoints. --- .../kosong/src/providers/openai-legacy.ts | 29 ++++++--- packages/kosong/test/openai-legacy.test.ts | 65 +++++++++++++++++++ 2 files changed, 86 insertions(+), 8 deletions(-) diff --git a/packages/kosong/src/providers/openai-legacy.ts b/packages/kosong/src/providers/openai-legacy.ts index 3cad67e517..e3f3de595a 100644 --- a/packages/kosong/src/providers/openai-legacy.ts +++ b/packages/kosong/src/providers/openai-legacy.ts @@ -386,11 +386,17 @@ export class OpenAILegacyStreamedMessage implements StreamedMessage { // Reasoning content: honor the explicit key when set, otherwise scan the // de facto field set and remember the dialect for outbound echo. - // Skip empty strings: some gateways pad every content-phase chunk with an - // empty reasoning field, which would otherwise interleave empty think - // parts between text deltas and defeat sequential merging downstream. + // Skip empty strings on pure content messages: some gateways pad every + // content-phase chunk with an empty reasoning field, which would otherwise + // interleave empty think parts between text deltas and defeat sequential + // merging downstream. Tool-call messages keep the empty marker so the + // reasoning field is replayed on continuation, as some reasoning + // endpoints require. const reasoning = reasoningKeyDialect.observe(message); - if (reasoning !== undefined && reasoning.length > 0) { + if ( + reasoning !== undefined && + (reasoning.length > 0 || (message.tool_calls?.length ?? 0) > 0) + ) { yield { type: 'think', think: reasoning } satisfies StreamedMessagePart; } @@ -444,11 +450,18 @@ export class OpenAILegacyStreamedMessage implements StreamedMessage { // Reasoning content: honor the explicit key when set, otherwise scan // the de facto field set and remember the dialect for outbound echo. - // Skip empty strings: some gateways pad every content-phase chunk with - // an empty reasoning field, which would otherwise interleave empty - // think parts between text deltas and defeat sequential merging. + // Skip empty strings on content-only chunks: some gateways pad every + // content-phase chunk with an empty reasoning field, which would + // otherwise interleave empty think parts between text deltas and + // defeat sequential merging. Tool-call chunks keep the empty marker + // so the reasoning field is replayed on continuation, as some + // reasoning endpoints require; adjacent empty think parts merge into + // one, so at most a single marker survives. const reasoning = reasoningKeyDialect.observe(delta); - if (reasoning !== undefined && reasoning.length > 0) { + if ( + reasoning !== undefined && + (reasoning.length > 0 || (delta.tool_calls?.length ?? 0) > 0) + ) { yield { type: 'think', think: reasoning } satisfies StreamedMessagePart; } diff --git a/packages/kosong/test/openai-legacy.test.ts b/packages/kosong/test/openai-legacy.test.ts index 748fc1fd3b..97a431f125 100644 --- a/packages/kosong/test/openai-legacy.test.ts +++ b/packages/kosong/test/openai-legacy.test.ts @@ -1533,6 +1533,45 @@ describe('OpenAILegacyChatProvider', () => { ]); }); + it('preserves the empty reasoning marker on streaming tool-call chunks', async () => { + // Reasoning endpoints may require the reasoning field to be replayed on + // tool-call continuation; the empty marker must survive so the outbound + // message keeps carrying the reasoning field. + const provider = new OpenAILegacyChatProvider({ + model: 'some-reasoning-model', + apiKey: 'test-key', + stream: true, + }); + + async function* mockedStream(): AsyncIterable> { + yield { + id: 'c1', + choices: [ + { + index: 0, + delta: { + reasoning_content: '', + tool_calls: [ + { id: 'call_1', index: 0, function: { name: 'foo', arguments: '{}' } }, + ], + }, + }, + ], + }; + yield { id: 'c1', choices: [{ index: 0, delta: {}, finish_reason: 'tool_calls' }] }; + } + + (provider as any)._client.chat.completions.create = vi + .fn() + .mockResolvedValue(mockedStream()); + + const stream = await provider.generate('', [], []); + const parts: StreamedMessagePart[] = []; + for await (const part of stream) parts.push(part); + + expect(parts.filter((p) => p.type === 'think')).toEqual([{ type: 'think', think: '' }]); + }); + it('treats blank reasoning_key as unset so defaults still apply', async () => { // ModelAliasSchema accepts `reasoning_key = ""` (z.string().optional()). // A blank value must not route reads/writes through an empty property @@ -1900,6 +1939,32 @@ describe('OpenAILegacyChatProvider — non-stream response parsing', () => { expect(parts).toEqual([]); }); + it('preserves the empty reasoning marker on a non-stream tool-call response', async () => { + // Reasoning endpoints may require the reasoning field to be replayed on + // tool-call continuation; the empty marker must survive so the outbound + // message keeps carrying the reasoning field. + const provider = new OpenAILegacyChatProvider({ + model: 'deepseek-reasoner', + apiKey: 'test-key', + stream: false, + reasoningKey: 'reasoning_content', + }); + + const parts = await collectFromMockedResponse( + provider, + makeNonStreamResponse({ + role: 'assistant', + content: null, + reasoning_content: '', + tool_calls: [ + { id: 'call_1', type: 'function', function: { name: 'foo', arguments: '{}' } }, + ], + }), + ); + + expect(parts.filter((p) => p.type === 'think')).toEqual([{ type: 'think', think: '' }]); + }); + it('non-stream response yields ToolCall parts when tool_calls present', async () => { const provider = new OpenAILegacyChatProvider({ model: 'gpt-4.1', From af5fa73703fc44b23b11d847b2cd8a7ce6c68b6b Mon Sep 17 00:00:00 2001 From: isletspace Date: Mon, 27 Jul 2026 13:36:42 +0800 Subject: [PATCH 3/3] test(kosong): assert single merged text part through generate assembly End-to-end regression coverage for the per-token-line-breaks symptom: after draining and merging a stream whose content chunks pad empty reasoning fields, the assembled message must contain one text part. --- packages/kosong/test/openai-legacy.test.ts | 42 ++++++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/packages/kosong/test/openai-legacy.test.ts b/packages/kosong/test/openai-legacy.test.ts index 97a431f125..7718352b9f 100644 --- a/packages/kosong/test/openai-legacy.test.ts +++ b/packages/kosong/test/openai-legacy.test.ts @@ -1533,6 +1533,48 @@ describe('OpenAILegacyChatProvider', () => { ]); }); + it('assembles one merged text part through generate() when chunks pad empty reasoning', async () => { + // End-to-end invariant behind the per-token-line-breaks bug: after the + // stream is drained and merged, the reply must be a single text part. + const provider = new OpenAILegacyChatProvider({ + model: 'some-reasoning-model', + apiKey: 'test-key', + stream: true, + }); + + async function* mockedStream(): AsyncIterable> { + yield { + id: 'c1', + choices: [{ index: 0, delta: { content: '', reasoning_content: 'think 1' } }], + }; + yield { + id: 'c1', + choices: [{ index: 0, delta: { content: 'Hello', reasoning_content: '' } }], + }; + yield { + id: 'c1', + choices: [{ index: 0, delta: { content: ' world', reasoning_content: '' } }], + }; + yield { id: 'c1', choices: [{ index: 0, delta: {}, finish_reason: 'stop' }] }; + } + + (provider as any)._client.chat.completions.create = vi + .fn() + .mockResolvedValue(mockedStream()); + + const result = await generate( + provider, + '', + [], + [{ role: 'user', content: [{ type: 'text', text: 'hi' }], toolCalls: [] }], + ); + + expect(result.message.content).toEqual([ + { type: 'think', think: 'think 1' }, + { type: 'text', text: 'Hello world' }, + ]); + }); + it('preserves the empty reasoning marker on streaming tool-call chunks', async () => { // Reasoning endpoints may require the reasoning field to be replayed on // tool-call continuation; the empty marker must survive so the outbound