Skip to content

fix(kosong/kimi): omit content field on empty assistant tool-call messages#2035

Merged
wbxl2000 merged 3 commits intomainfrom
fix/kimi-empty-tool-call-content
Apr 23, 2026
Merged

fix(kosong/kimi): omit content field on empty assistant tool-call messages#2035
wbxl2000 merged 3 commits intomainfrom
fix/kimi-empty-tool-call-content

Conversation

@wbxl2000
Copy link
Copy Markdown
Collaborator

@wbxl2000 wbxl2000 commented Apr 23, 2026

Related Issue

No pre-existing issue filed (reported directly by a user running into it).
Related: #1663 — same 400 "text content is empty" error, different trigger
path (MCP tool returning image data). Not fixed by this PR.

Description

The bug

Kimi-for-Coding (api.kimi.com/coding/v1) rejects chat.completions requests
whose assistant message has content of shape
[{"type": "text", "text": ""}] with 400 "text content is empty".

This shape arises naturally in normal tool-call flow:

  1. Turn 1: the model emits reasoning tokens + a tool call, no user-visible text.
  2. kimi-cli stores the reply as Message(content=[TextPart(text="")], tool_calls=[...]).
  3. Turn 2: _convert_message serializes this for replay. Before this fix, the
    wire body contained an empty text part, so the compat layer returned 400 and
    the entire conversation became unrecoverable.

The fix

In _convert_message, when the assistant message has tool_calls AND the visible
content is effectively empty (all TextParts are whitespace-only), drop the
content field from the outbound body. OpenAI-compatible APIs explicitly allow
omitting content on assistant tool-call messages, so this is strictly safer.

Unaffected paths (verified via snapshot tests):

  • assistant messages with real text content
  • assistant messages containing non-text parts (images, audio, video)
  • user / tool / system messages

Verification

  • Snapshot test added to packages/kosong/tests/api_snapshot_tests/test_kimi.py
    asserting the wire body omits content in this case.
  • E2E test added at tests/e2e/test_kimi_empty_tool_call_content_e2e.py using a
    strict mock compat server that returns 400 for effectively-empty content.
  • Verified end-to-end against the real api.kimi.com/coding/v1:
    • after fix: 200 (request accepted)
    • injecting content: [{"type":"text","text":""}] back into the same body:
      400 "text content is empty" — reproduces the original error.

Checklist

  • I have read the CONTRIBUTING document.
  • I have linked the related issue (LLM provider error 400 "text content is empty" when processing image input from MCP tools #1663 as context; no pre-existing issue for this exact trigger).
  • I have added tests that prove my fix is effective.
  • I have run make gen-changelog to update the changelog.
  • I have run make gen-docs to update the user documentation. N/A — this fix has no user-visible behavior change: no new/removed flag, no configuration change, no CLI output change. It only prevents a specific wire shape from being rejected by the provider.

Open in Devin Review

…sages

The Kimi-for-Coding compat layer at api.kimi.com/coding/v1 rejects
assistant messages whose content list contains an empty text part with
400 "text content is empty". This shape arises when replaying an
earlier assistant message that emitted only reasoning tokens + a tool
call.

Drop `content` entirely when an assistant message has a tool call and
the visible content is effectively empty. OpenAI-compatible APIs allow
omitting `content` in this case, so the change is safe and narrow.

Verified end-to-end against the real api.kimi.com/coding/v1.

Related: #1663 (same 400 error from a different trigger).
Copilot AI review requested due to automatic review settings April 23, 2026 12:08
- annotate the mock handler with `web.StreamResponse` so that both
  `web.Response` (`json_response`) and `web.StreamResponse` (streaming
  branch) are valid returns
- assert `process.returncode is not None` after `communicate()` so the
  `tuple[int, str, str]` return type holds
Copy link
Copy Markdown
Contributor

@devin-ai-integration devin-ai-integration Bot left a comment

Choose a reason for hiding this comment

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

✅ Devin Review: No Issues Found

Devin Review analyzed this PR and found no potential bugs to report.

View in Devin Review to see 3 additional findings.

Open in Devin Review

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Fixes a Kimi-for-Coding compatibility issue where assistant tool-call messages with effectively-empty visible text were being serialized with an empty content, triggering 400 "text content is empty" and breaking conversation replay.

Changes:

  • Update Kimi message serialization to omit content for assistant messages that include tool_calls when visible content is effectively empty.
  • Add snapshot coverage for assistant tool-call messages without user-visible text.
  • Add an end-to-end regression test using a strict mock server that rejects effectively-empty assistant content.

Reviewed changes

Copilot reviewed 7 out of 7 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
packages/kosong/src/kosong/chat_provider/kimi.py Drops content for assistant tool-call messages when content parts are effectively empty, avoiding Kimi gateway 400s.
packages/kosong/tests/api_snapshot_tests/test_kimi.py Adds snapshot cases to assert content omission for assistant tool-call messages without visible text.
tests/e2e/test_kimi_empty_tool_call_content_e2e.py New E2E regression test with a mock compat server that fails on empty assistant content.
packages/kosong/CHANGELOG.md Documents the provider-side fix in the Kosong package changelog.
docs/en/release-notes/changelog.md Adds user-facing release note for the fix (EN).
docs/zh/release-notes/changelog.md Adds user-facing release note for the fix (ZH).
CHANGELOG.md Adds top-level release note entry for the fix.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +233 to +254
process = await asyncio.create_subprocess_exec(
sys.executable,
"-m",
"kimi_cli.cli",
"--print",
"--output-format",
"stream-json",
"--final-message-only",
"--prompt",
prompt,
"--config-file",
str(config_path),
"--work-dir",
str(work_dir),
cwd=str(_repo_root()),
env=env,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)

stdout_bytes, stderr_bytes = await process.communicate()
assert process.returncode is not None
Copy link

Copilot AI Apr 23, 2026

Choose a reason for hiding this comment

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

The subprocess-based CLI invocation has no timeout, so a hang (e.g., deadlock in CLI/tool execution or server not responding) can stall the entire test run/CI indefinitely. Please wrap process.communicate() in asyncio.wait_for(..., timeout=...) and, on timeout, terminate/kill the process and include stdout/stderr in the failure to aid debugging. Other E2E tests in this repo use explicit timeouts (e.g. tests/e2e/test_cli_error_output.py:25-32).

Copilot uses AI. Check for mistakes.
Comment on lines +39 to +68
"assistant_tool_call_without_text": {
"history": [
Message(role="user", content="Call the add tool"),
Message(
role="assistant",
content=[],
tool_calls=[
ToolCall(
id="call_abc123",
function=ToolCall.FunctionBody(name="add", arguments='{"a": 2, "b": 3}'),
)
],
),
Message(role="tool", content="5", tool_call_id="call_abc123"),
],
},
"assistant_tool_call_with_reasoning_only": {
"history": [
Message(role="user", content="Think and call the add tool"),
Message(
role="assistant",
content=[ThinkPart(think="I should call the tool.")],
tool_calls=[
ToolCall(
id="call_abc123",
function=ToolCall.FunctionBody(name="add", arguments='{"a": 2, "b": 3}'),
)
],
),
Message(role="tool", content="5", tool_call_id="call_abc123"),
Copy link

Copilot AI Apr 23, 2026

Choose a reason for hiding this comment

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

The snapshot coverage added here exercises content=[] and content=[ThinkPart(...)], but the reported failure mode is an assistant tool-call message stored as a text part with empty/whitespace text (e.g. content=[TextPart(text="")], which serializes to content: "" or [{"type":"text","text":""}]). Consider adding an explicit snapshot case for empty/whitespace-only TextPart alongside tool_calls to ensure _is_effectively_empty_content_parts is actually covered for the original trigger.

Copilot uses AI. Check for mistakes.
@wbxl2000
Copy link
Copy Markdown
Collaborator Author

@codex

@chatgpt-codex-connector
Copy link
Copy Markdown

Codex Review: Didn't find any major issues. 👍

ℹ️ 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".

…all-content

# Conflicts:
#	packages/kosong/CHANGELOG.md
@wbxl2000 wbxl2000 merged commit 2ad7e64 into main Apr 23, 2026
18 checks passed
@wbxl2000 wbxl2000 deleted the fix/kimi-empty-tool-call-content branch April 23, 2026 13:31
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.

2 participants