From 2031d2ccc81d609bce2f8d33cf0f1e4d95d794bb Mon Sep 17 00:00:00 2001 From: qer Date: Thu, 23 Apr 2026 20:08:10 +0800 Subject: [PATCH 1/2] fix(kosong/kimi): omit content field on empty assistant tool-call messages 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). --- CHANGELOG.md | 1 + docs/en/release-notes/changelog.md | 1 + docs/zh/release-notes/changelog.md | 1 + packages/kosong/CHANGELOG.md | 1 + .../kosong/src/kosong/chat_provider/kimi.py | 21 ++ .../tests/api_snapshot_tests/test_kimi.py | 69 ++++- .../test_kimi_empty_tool_call_content_e2e.py | 292 ++++++++++++++++++ 7 files changed, 385 insertions(+), 1 deletion(-) create mode 100644 tests/e2e/test_kimi_empty_tool_call_content_e2e.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 9ba4ad17e..c5fb138ce 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ Only write entries that are worth mentioning to users. - Core: Add `skip_yolo_prompt_injection` config option to suppress the system reminder normally injected when yolo mode is active — useful when building custom applications on top of `KimiSoul` that do not need the non-interactive mode hint - Kimi: Add `KIMI_MODEL_THINKING_KEEP` environment variable that forwards its value verbatim to the Moonshot API as `thinking.keep`, enabling Preserved Thinking (e.g. `export KIMI_MODEL_THINKING_KEEP=all` to retain historical `reasoning_content` across turns); effective only for Moonshot models supporting Preserved Thinking (e.g. `kimi-k2.6` / `kimi-k2-thinking`), unset or empty string preserves the previous behavior and omits the field, and the override only applies when the current model is actually in thinking mode so the API never receives a `thinking.keep` without the companion `thinking.type`. Note that `keep=all` increases input tokens and API cost because history reasoning is resent - Kosong: Fix `Kimi.with_extra_body` silently dropping previously set `thinking.type` when a later call added another `thinking.*` field — the `thinking` sub-dict is now merged field-by-field instead of shallow-replaced, so composing `with_thinking(...)` with `with_extra_body({"thinking": {...}})` preserves both contributions +- Kosong: Fix Kimi provider sending empty `content` alongside `tool_calls`, which caused 400 "text content is empty" errors from the Moonshot API. When an assistant message has tool calls and its visible content is effectively empty (no text or only whitespace/think parts), the `content` field is now omitted entirely - Shell: Fix approval request feedback text cursor rendering — the block cursor now correctly renders at the actual cursor position instead of always being pinned to the end of the line; when the cursor is in the middle of the text, the character under the cursor is drawn with reverse video (mimicking a terminal's native block cursor) ## 1.38.0 (2026-04-22) diff --git a/docs/en/release-notes/changelog.md b/docs/en/release-notes/changelog.md index 57411bd01..b916144d7 100644 --- a/docs/en/release-notes/changelog.md +++ b/docs/en/release-notes/changelog.md @@ -7,6 +7,7 @@ This page documents the changes in each Kimi Code CLI release. - Core: Add `skip_yolo_prompt_injection` config option to suppress the system reminder normally injected when yolo mode is active — useful when building custom applications on top of `KimiSoul` that do not need the non-interactive mode hint - Kimi: Add `KIMI_MODEL_THINKING_KEEP` environment variable that forwards its value verbatim to the Moonshot API as `thinking.keep`, enabling Preserved Thinking (e.g. `export KIMI_MODEL_THINKING_KEEP=all` to retain historical `reasoning_content` across turns); effective only for Moonshot models supporting Preserved Thinking (e.g. `kimi-k2.6` / `kimi-k2-thinking`), unset or empty string preserves the previous behavior and omits the field, and the override only applies when the current model is actually in thinking mode so the API never receives a `thinking.keep` without the companion `thinking.type`. Note that `keep=all` increases input tokens and API cost because history reasoning is resent - Kosong: Fix `Kimi.with_extra_body` silently dropping previously set `thinking.type` when a later call added another `thinking.*` field — the `thinking` sub-dict is now merged field-by-field instead of shallow-replaced, so composing `with_thinking(...)` with `with_extra_body({"thinking": {...}})` preserves both contributions +- Kosong: Fix Kimi provider sending empty `content` alongside `tool_calls`, which caused 400 "text content is empty" errors from the Moonshot API. When an assistant message has tool calls and its visible content is effectively empty (no text or only whitespace/think parts), the `content` field is now omitted entirely - Shell: Fix approval request feedback text cursor rendering — the block cursor now correctly renders at the actual cursor position instead of always being pinned to the end of the line; when the cursor is in the middle of the text, the character under the cursor is drawn with reverse video (mimicking a terminal's native block cursor) ## 1.38.0 (2026-04-22) diff --git a/docs/zh/release-notes/changelog.md b/docs/zh/release-notes/changelog.md index 5e6b49f29..35e062293 100644 --- a/docs/zh/release-notes/changelog.md +++ b/docs/zh/release-notes/changelog.md @@ -7,6 +7,7 @@ - Core:新增 `skip_yolo_prompt_injection` 配置项,用于抑制 yolo 模式下注入的系统提示词——基于 `KimiSoul` 构建自定义应用且不需要该提示时很有用 - Kimi:新增环境变量 `KIMI_MODEL_THINKING_KEEP`,将其值原样作为 `thinking.keep` 字段发送给 Moonshot API,用于启用 Preserved Thinking(例如 `export KIMI_MODEL_THINKING_KEEP=all` 可让模型在多轮之间保留历史 `reasoning_content`);仅对支持 Preserved Thinking 的 Moonshot 模型(如 `kimi-k2.6` / `kimi-k2-thinking`)生效,未设置或空字符串时请求体不携带该字段、等同当前默认行为,且仅在当前模型真正处于 Thinking 模式时才注入,以避免 API 收到只有 `thinking.keep` 而缺少 `thinking.type` 的无效请求体。注意 `keep=all` 会因为重新发送历史推理内容而显著增加输入 token 与 API 费用 - Kosong:修复 `Kimi.with_extra_body` 在后续调用新增其它 `thinking.*` 字段时静默丢掉已有 `thinking.type` 的问题——`thinking` 子对象现在按字段合并,而不是被整体浅覆盖,使得 `with_thinking(...)` 与 `with_extra_body({"thinking": {...}})` 组合使用时两次设置的字段都能保留 +- Kosong:修复 Kimi provider 在 `tool_calls` 旁发送空 `content` 导致 Moonshot API 返回 400 "text content is empty" 错误的问题。当 Assistant 消息带有工具调用且可见内容实际为空(无文本或仅包含空白 / think 部分)时,现在会完全省略 `content` 字段 - Shell:修复审批请求反馈文本输入的光标渲染问题——光标块现在根据实际光标位置正确渲染,不再始终固定在行尾;当光标位于文本中间时,光标所在字符会以反色显示(模拟终端原生块光标效果) ## 1.38.0 (2026-04-22) diff --git a/packages/kosong/CHANGELOG.md b/packages/kosong/CHANGELOG.md index bbadf4898..8f54fc155 100644 --- a/packages/kosong/CHANGELOG.md +++ b/packages/kosong/CHANGELOG.md @@ -4,6 +4,7 @@ - Kimi: Add `keep` to `ThinkingConfig` (Moonshot `thinking.keep` passthrough for Preserved Thinking); value is typed as `Any` and forwarded unchanged, with case-preservation and no validation — callers choose a value the server accepts (e.g. `"all"`) - Kimi: Fix `with_extra_body` silently dropping earlier `thinking.*` fields on subsequent calls — the `thinking` sub-dict is now merged field-by-field so composing `with_thinking(...)` with `with_extra_body({"thinking": {...}})` preserves both contributions; other top-level keys retain last-writer-wins semantics +- Kimi: Fix sending empty `content` alongside `tool_calls`, which caused 400 "text content is empty" errors from the Moonshot API. When an assistant message has tool calls and its visible content is effectively empty (no text or only whitespace/think parts), the `content` field is now omitted entirely ## 0.51.0 (2026-04-22) diff --git a/packages/kosong/src/kosong/chat_provider/kimi.py b/packages/kosong/src/kosong/chat_provider/kimi.py index efc7bdad5..ee41d583a 100644 --- a/packages/kosong/src/kosong/chat_provider/kimi.py +++ b/packages/kosong/src/kosong/chat_provider/kimi.py @@ -310,11 +310,32 @@ def _convert_message(message: Message) -> ChatCompletionMessageParam: content.append(part) message.content = content dumped_message = message.model_dump(exclude_none=True) + if ( + message.role == "assistant" + and message.tool_calls + and _is_effectively_empty_content_parts(content) + ): + # OpenAI-compatible APIs allow assistant tool-call messages to omit + # `content`, but the Kimi-for-Coding compat layer rejects a content + # list that contains an empty text part (observed: `content: + # [{"type": "text", "text": ""}]` -> 400 "text content is empty"). + # Dropping `content` entirely is always accepted, so do that whenever + # the visible content is effectively empty alongside a tool call. + dumped_message.pop("content", None) if reasoning_content: dumped_message["reasoning_content"] = reasoning_content return cast(ChatCompletionMessageParam, dumped_message) +def _is_effectively_empty_content_parts(content: Sequence[ContentPart]) -> bool: + for part in content: + if not isinstance(part, TextPart): + return False + if part.text.strip(): + return False + return True + + def _convert_tool(tool: Tool) -> ChatCompletionToolParam: if tool.name.startswith("$"): # Kimi builtin functions start with `$` diff --git a/packages/kosong/tests/api_snapshot_tests/test_kimi.py b/packages/kosong/tests/api_snapshot_tests/test_kimi.py index d355d25f0..6358f985d 100644 --- a/packages/kosong/tests/api_snapshot_tests/test_kimi.py +++ b/packages/kosong/tests/api_snapshot_tests/test_kimi.py @@ -8,7 +8,7 @@ from inline_snapshot import snapshot from kosong.chat_provider.kimi import Kimi -from kosong.message import Message, TextPart, ThinkPart +from kosong.message import Message, TextPart, ThinkPart, ToolCall from kosong.tooling import Tool BUILTIN_TOOL = Tool( @@ -36,6 +36,38 @@ Message(role="user", content="Thanks!"), ], }, + "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"), + ], + }, } @@ -285,6 +317,41 @@ async def test_kimi_message_conversion(): ], "tools": [], }, + "assistant_tool_call_without_text": { + "messages": [ + {"role": "user", "content": "Call the add tool"}, + { + "role": "assistant", + "tool_calls": [ + { + "type": "function", + "id": "call_abc123", + "function": {"name": "add", "arguments": '{"a": 2, "b": 3}'}, + } + ], + }, + {"role": "tool", "content": "5", "tool_call_id": "call_abc123"}, + ], + "tools": [], + }, + "assistant_tool_call_with_reasoning_only": { + "messages": [ + {"role": "user", "content": "Think and call the add tool"}, + { + "role": "assistant", + "reasoning_content": "I should call the tool.", + "tool_calls": [ + { + "type": "function", + "id": "call_abc123", + "function": {"name": "add", "arguments": '{"a": 2, "b": 3}'}, + } + ], + }, + {"role": "tool", "content": "5", "tool_call_id": "call_abc123"}, + ], + "tools": [], + }, } ) diff --git a/tests/e2e/test_kimi_empty_tool_call_content_e2e.py b/tests/e2e/test_kimi_empty_tool_call_content_e2e.py new file mode 100644 index 000000000..01f3b297c --- /dev/null +++ b/tests/e2e/test_kimi_empty_tool_call_content_e2e.py @@ -0,0 +1,292 @@ +"""E2E regression test for Kimi-compatible endpoints that reject empty assistant text. + +This reproduces the real compatibility issue against a local mock server: + +1. First response returns an assistant tool call with no visible text. +2. The CLI executes the tool and sends the next request with conversation history. +3. Some Kimi-compatible gateways reject the second request if the prior assistant + tool-call message contains `content: []` / `content: ""`, returning + `400 {"error": {"message": "text content is empty"}}`. + +The provider should omit `content` for assistant tool-call messages when the +content is effectively empty. +""" + +from __future__ import annotations + +import asyncio +import json +import os +import sys +from collections.abc import AsyncIterator +from pathlib import Path +from typing import Any, Protocol, cast + +import pytest_asyncio +from aiohttp import web + + +def _repo_root() -> Path: + return Path(__file__).resolve().parents[2] + + +class MockKimiCompatServer(Protocol): + base_url: str + requests: list[dict[str, Any]] + + +class _MockKimiCompatServer: + def __init__(self, base_url: str) -> None: + self.base_url = base_url + self.requests: list[dict[str, Any]] = [] + + +async def _write_sse_event(response: web.StreamResponse, payload: dict[str, Any]) -> None: + await response.write(f"data: {json.dumps(payload)}\n\n".encode()) + + +async def _write_sse_done(response: web.StreamResponse) -> None: + await response.write(b"data: [DONE]\n\n") + await response.write_eof() + + +def _tool_call_stream_chunk() -> dict[str, Any]: + return { + "id": "chatcmpl-tool-call", + "object": "chat.completion.chunk", + "created": 1, + "model": "kimi-for-coding", + "choices": [ + { + "index": 0, + "delta": { + "role": "assistant", + "tool_calls": [ + { + "index": 0, + "id": "call_read", + "type": "function", + "function": { + "name": "ReadFile", + "arguments": '{"path":"sample.txt"}', + }, + } + ], + }, + "finish_reason": "tool_calls", + } + ], + } + + +def _final_stream_chunk() -> dict[str, Any]: + return { + "id": "chatcmpl-final", + "object": "chat.completion.chunk", + "created": 2, + "model": "kimi-for-coding", + "choices": [ + { + "index": 0, + "delta": { + "role": "assistant", + "content": "Read finished.", + }, + "finish_reason": "stop", + } + ], + } + + +def _error_response() -> dict[str, Any]: + return { + "error": { + "message": "text content is empty", + "type": "invalid_request_error", + } + } + + +def _find_assistant_tool_call_message(body: dict[str, Any]) -> dict[str, Any] | None: + raw_messages = body.get("messages") + if not isinstance(raw_messages, list): + return None + for raw_message in raw_messages: + if not isinstance(raw_message, dict): + continue + message = cast(dict[str, Any], raw_message) + if message.get("role") == "assistant" and message.get("tool_calls"): + return message + return None + + +def _content_is_effectively_empty(content: Any) -> bool: + if content is None: + return True + if isinstance(content, str): + return not content.strip() + if not isinstance(content, list): + return False + for item in content: + if not isinstance(item, dict): + return False + if item.get("type") != "text": + return False + text = item.get("text") + if not isinstance(text, str) or text.strip(): + return False + return True + + +@pytest_asyncio.fixture +async def mock_kimi_compat_server() -> AsyncIterator[MockKimiCompatServer]: + server_holder: _MockKimiCompatServer | None = None + + async def handler(request: web.Request) -> web.Response: + assert server_holder is not None + body = cast(dict[str, Any], await request.json()) + server_holder.requests.append(body) + + if len(server_holder.requests) == 1: + response = web.StreamResponse( + status=200, + headers={"Content-Type": "text/event-stream"}, + ) + await response.prepare(request) + await _write_sse_event(response, _tool_call_stream_chunk()) + await _write_sse_done(response) + return response + + assistant_message = _find_assistant_tool_call_message(body) + if assistant_message is None: + raise AssertionError(f"Missing assistant tool-call message in body: {body}") + if "content" in assistant_message and _content_is_effectively_empty( + assistant_message["content"] + ): + return web.json_response(_error_response(), status=400) + response = web.StreamResponse( + status=200, + headers={"Content-Type": "text/event-stream"}, + ) + await response.prepare(request) + await _write_sse_event(response, _final_stream_chunk()) + await _write_sse_done(response) + return response + + app = web.Application() + app.router.add_post("/v1/chat/completions", handler) + + runner = web.AppRunner(app) + await runner.setup() + site = web.TCPSite(runner, host="127.0.0.1", port=0) + await site.start() + + sockets = site._server.sockets # type: ignore[attr-defined] + assert sockets, "Server failed to bind to a port." + port = sockets[0].getsockname()[1] + server_holder = _MockKimiCompatServer(f"http://127.0.0.1:{port}") + + try: + yield server_holder + finally: + await runner.cleanup() + + +def _write_kimi_config(config_path: Path, *, base_url: str) -> None: + config_path.write_text( + json.dumps( + { + "default_model": "mock-kimi", + "models": { + "mock-kimi": { + "provider": "mock-kimi-provider", + "model": "kimi-for-coding", + "max_context_size": 100000, + } + }, + "providers": { + "mock-kimi-provider": { + "type": "kimi", + "base_url": base_url, + "api_key": "test-api-key", + } + }, + } + ), + encoding="utf-8", + ) + + +async def _run_kimi_print_json( + *, + config_path: Path, + share_dir: Path, + work_dir: Path, + prompt: str, +) -> tuple[int, str, str]: + env = os.environ.copy() + env["KIMI_SHARE_DIR"] = str(share_dir) + env["KIMI_DISABLE_TELEMETRY"] = "1" + env["COLUMNS"] = "120" + env["LINES"] = "40" + + 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() + return process.returncode, stdout_bytes.decode(), stderr_bytes.decode() + + +def _extract_final_message(stdout: str) -> dict[str, Any]: + lines = [line for line in stdout.splitlines() if line.strip()] + assert lines, "Expected at least one JSON line in stdout." + return cast(dict[str, Any], json.loads(lines[-1])) + + +async def test_kimi_compat_endpoint_accepts_tool_call_history_without_empty_content( + tmp_path: Path, mock_kimi_compat_server: MockKimiCompatServer +) -> None: + work_dir = tmp_path / "work" + work_dir.mkdir() + (work_dir / "sample.txt").write_text("hello from sample\n", encoding="utf-8") + + share_dir = tmp_path / "share" + share_dir.mkdir() + + config_path = tmp_path / "config.json" + _write_kimi_config(config_path, base_url=f"{mock_kimi_compat_server.base_url}/v1") + + return_code, stdout, stderr = await _run_kimi_print_json( + config_path=config_path, + share_dir=share_dir, + work_dir=work_dir, + prompt="Read sample.txt with ReadFile and then confirm success.", + ) + + assert return_code == 0, f"stdout:\n{stdout}\nstderr:\n{stderr}" + assert _extract_final_message(stdout) == { + "role": "assistant", + "content": "Read finished.", + } + + assert len(mock_kimi_compat_server.requests) == 2 + assistant_message = _find_assistant_tool_call_message(mock_kimi_compat_server.requests[1]) + assert assistant_message is not None + assert "content" not in assistant_message From 083c93b17a3a27a303563a6d3f79ed6a0796de9a Mon Sep 17 00:00:00 2001 From: qer Date: Thu, 23 Apr 2026 20:11:29 +0800 Subject: [PATCH 2/2] fix(test): satisfy pyright on kimi-empty-content e2e test - 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 --- tests/e2e/test_kimi_empty_tool_call_content_e2e.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/e2e/test_kimi_empty_tool_call_content_e2e.py b/tests/e2e/test_kimi_empty_tool_call_content_e2e.py index 01f3b297c..0a40d0167 100644 --- a/tests/e2e/test_kimi_empty_tool_call_content_e2e.py +++ b/tests/e2e/test_kimi_empty_tool_call_content_e2e.py @@ -142,7 +142,7 @@ def _content_is_effectively_empty(content: Any) -> bool: async def mock_kimi_compat_server() -> AsyncIterator[MockKimiCompatServer]: server_holder: _MockKimiCompatServer | None = None - async def handler(request: web.Request) -> web.Response: + async def handler(request: web.Request) -> web.StreamResponse: assert server_holder is not None body = cast(dict[str, Any], await request.json()) server_holder.requests.append(body) @@ -251,6 +251,7 @@ async def _run_kimi_print_json( ) stdout_bytes, stderr_bytes = await process.communicate() + assert process.returncode is not None return process.returncode, stdout_bytes.decode(), stderr_bytes.decode()