fix(kosong/kimi): omit content field on empty assistant tool-call messages#2035
fix(kosong/kimi): omit content field on empty assistant tool-call messages#2035
Conversation
…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).
- 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
There was a problem hiding this comment.
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
contentfor assistant messages that includetool_callswhen 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.
| 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 |
There was a problem hiding this comment.
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).
| "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"), |
There was a problem hiding this comment.
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.
|
Codex Review: Didn't find any major issues. 👍 ℹ️ About Codex in GitHubCodex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
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
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 triggerpath (MCP tool returning image data). Not fixed by this PR.
Description
The bug
Kimi-for-Coding (
api.kimi.com/coding/v1) rejectschat.completionsrequestswhose assistant message has
contentof shape[{"type": "text", "text": ""}]with400 "text content is empty".This shape arises naturally in normal tool-call flow:
Message(content=[TextPart(text="")], tool_calls=[...])._convert_messageserializes this for replay. Before this fix, thewire 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 hastool_callsAND the visiblecontent is effectively empty (all
TextParts are whitespace-only), drop thecontentfield from the outbound body. OpenAI-compatible APIs explicitly allowomitting
contenton assistant tool-call messages, so this is strictly safer.Unaffected paths (verified via snapshot tests):
user/tool/systemmessagesVerification
packages/kosong/tests/api_snapshot_tests/test_kimi.pyasserting the wire body omits
contentin this case.tests/e2e/test_kimi_empty_tool_call_content_e2e.pyusing astrict mock compat server that returns 400 for effectively-empty content.
api.kimi.com/coding/v1:content: [{"type":"text","text":""}]back into the same body:400 "text content is empty"— reproduces the original error.Checklist
make gen-changelogto update the changelog.make gen-docsto 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.