Skip to content

fix(kosong): skip empty reasoning fields in OpenAI-compatible streams - #2227

Open
Liewzheng wants to merge 3 commits into
MoonshotAI:mainfrom
Liewzheng:fix/openai-compatible-empty-reasoning
Open

fix(kosong): skip empty reasoning fields in OpenAI-compatible streams#2227
Liewzheng wants to merge 3 commits into
MoonshotAI:mainfrom
Liewzheng:fix/openai-compatible-empty-reasoning

Conversation

@Liewzheng

@Liewzheng Liewzheng commented Jul 27, 2026

Copy link
Copy Markdown

Related Issue

Resolve #2226

Problem

See linked issue. In short: some OpenAI-compatible gateways pad every content-phase stream chunk with an empty reasoning field ("reasoning_content":""). The provider yielded an empty think part per chunk, which interleaved between text deltas and defeated sequential part merging — so streamed replies rendered one token per line in the TUI.

What changed

Fix — In the OpenAI chat-completions provider (openai-legacy), skip empty reasoning strings at both yield sites (stream and non-stream), except when the message/chunk carries tool_calls. Rationale for the exception: #1676 established that reasoning backends can reject continuations when the reasoning field is omitted from assistant tool-call messages, so the empty marker on tool-call turns is load-bearing and is preserved exactly. Adjacent empty think parts merge into one, so at most a single marker survives per turn. Content-only turns — the case that produced the per-token rendering bug — no longer emit empty think parts at all.

Why at the provider layer — dropping the padding at the yield site protects every downstream consumer (message assembly, persisted history, transcript projection, TUI). Fixing it in mergeInPlace instead would still persist semantically empty think parts into session history.

Scope audit — the other kosong adapters were checked for the same hazard: anthropic and openai-responses empty think parts carry protocol significance (thinking-block signatures, reasoning-item boundaries) and are not produced by per-chunk padding; kimi's first-party API does not pad empty reasoning fields; google-genai yields think parts only from real thought text. agent-core-v2 now builds its model wire layer on kosong, so this single fix covers both engines.

Tests — updated the two tests that asserted the old empty-ThinkPart behavior, and added regression coverage for: skipping empty streaming reasoning fields, no interleaving between text deltas (GLM-style chunks), preserving the empty marker on streaming tool-call chunks, preserving it on non-stream tool-call responses, and an end-to-end generate() assertion that the assembled message contains exactly one merged text part.

Changeset@moonshot-ai/kimi-code, patch.

Checklist

  • I have read the CONTRIBUTING document.
  • I have linked a related issue, or explained the problem above.
  • I have added tests that prove my feature works.
  • Ran gen-changesets skill, or this PR needs no changeset.
  • Ran gen-docs skill, or this PR needs no doc update.

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-bot

changeset-bot Bot commented Jul 27, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: af5fa73

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
@moonshot-ai/kimi-code Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 6d6c0a483e

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

// parts between text deltas and defeat sequential merging downstream.
const reasoning = reasoningKeyDialect.observe(message);
if (reasoning !== undefined) {
if (reasoning !== undefined && reasoning.length > 0) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve empty reasoning on tool-call responses

When a non-stream response contains tool_calls together with reasoning_content: "", this condition now discards the empty ThinkPart even though the existing serialization contract explicitly preserves that marker for assistant tool-call messages (openai-legacy.test.ts's serializes an explicitly empty ThinkPart to reasoning_content). As a result, the assembled assistant history has no ThinkPart, so convertMessage omits reasoning_content from the subsequent tool-result request; OpenAI-compatible reasoning endpoints that require the reasoning field to be replayed can reject or mishandle the continuation. The padding workaround should be limited to content-phase stream chunks rather than dropping an empty reasoning field from tool-call responses.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch — addressed in ad702be. The empty-reasoning skip now applies only to content-only messages/chunks; tool-call responses (stream and non-stream) keep the empty think marker so the reasoning field is still replayed on continuation. Adjacent empty think parts merge into one, so at most a single marker survives. Added regression tests for both paths.

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.
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.
@Liewzheng

Copy link
Copy Markdown
Author

Hardening pass after the Codex review:

  • af5fa73 adds an end-to-end generate() regression test: after the stream drains and merges, a padded-reasoning reply assembles into exactly one text part (the user-visible invariant behind the per-token-line-breaks symptom).
  • Audited the other adapters for the same hazard: anthropic / openai-responses empty think parts carry protocol significance (signatures, reasoning-item boundaries), kimi's first-party API does not pad empty reasoning, and agent-core-v2 now builds on kosong, so no other adapter needs the same guard. Details in the updated PR description.
  • The tool-call exception added in ad702be keeps fix: preserve empty reasoning across providers #1676's contract intact: the empty reasoning marker is preserved exactly on assistant tool-call turns, which is the case reasoning backends validate on continuation.

All 1338 kosong tests pass; tsc --noEmit clean.

@Liewzheng

Copy link
Copy Markdown
Author

Hi @RealKai42 — gentle ping for a review when you have a moment. This touches the empty-reasoning preservation contract from your #1676: the fix skips empty reasoning strings only on content-only messages/chunks, while tool-call turns (the case #1676 protects) keep the marker, with regression tests on both paths. Happy to adjust the approach if you'd rather handle the padding at a different layer.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Streamed replies render one token per line with OpenAI-compatible providers that pad empty reasoning fields

1 participant