diff --git a/CHANGELOG.md b/CHANGELOG.md index 5af02c766..bb1a38698 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) - Kosong: Fix Moonshot 400 `At path 'properties.X': type is not defined` when an MCP server exposes tools whose parameter schemas have enum-only or otherwise type-less properties (seen with the JetBrains Rider MCP's `truncateMode`) — the Kimi provider now patches each tool's schema in-flight to fill in a JSON Schema `type` (inferred from `enum`/`const` values when possible, else defaulted to `"string"`), so the whole session no longer fails every request with a schema validation error; OpenAI and Anthropic paths are unaffected diff --git a/docs/en/release-notes/changelog.md b/docs/en/release-notes/changelog.md index e916442bc..d23d86d2a 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) - Kosong: Fix Moonshot 400 `At path 'properties.X': type is not defined` when an MCP server exposes tools whose parameter schemas have enum-only or otherwise type-less properties (seen with the JetBrains Rider MCP's `truncateMode`) — the Kimi provider now patches each tool's schema in-flight to fill in a JSON Schema `type` (inferred from `enum`/`const` values when possible, else defaulted to `"string"`), so the whole session no longer fails every request with a schema validation error; OpenAI and Anthropic paths are unaffected diff --git a/docs/zh/release-notes/changelog.md b/docs/zh/release-notes/changelog.md index a5b2a5f57..52d45f761 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:修复审批请求反馈文本输入的光标渲染问题——光标块现在根据实际光标位置正确渲染,不再始终固定在行尾;当光标位于文本中间时,光标所在字符会以反色显示(模拟终端原生块光标效果) - Kosong:修复接入某些 MCP 服务端(如 JetBrains Rider MCP 的 `truncateMode`)时,Moonshot API 以 `400 At path 'properties.X': type is not defined` 拒绝每次请求导致会话完全无法使用的问题——这些 MCP 工具的参数 schema 里有仅声明 `enum`/`const` 或根本没有类型提示的属性,符合 JSON Schema 规范但过不了 Moonshot 的严格校验;现在 Kimi 供应商会在发送前为每个工具 schema 补齐 JSON Schema `type`(尽量从 `enum`/`const` 值推断,否则默认 `"string"`),OpenAI 和 Anthropic 路径不受影响 diff --git a/packages/kosong/CHANGELOG.md b/packages/kosong/CHANGELOG.md index 06fd6f949..c315ba9d6 100644 --- a/packages/kosong/CHANGELOG.md +++ b/packages/kosong/CHANGELOG.md @@ -5,6 +5,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: Normalize MCP tool parameter schemas before sending to Moonshot — properties that declare `enum`/`const` but no `type` (or have no type hint at all) get an inferred `type` filled in, so MCP servers whose schemas are valid JSON Schema but not strict enough for Moonshot's validator (notably the JetBrains Rider MCP, whose `truncateMode` property is enum-only) no longer make every request fail with `400 At path 'properties.X': type is not defined`; the normalization is Kimi-specific and leaves OpenAI/Anthropic conversions untouched +- 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 0d982a074..9c1686dd3 100644 --- a/packages/kosong/src/kosong/chat_provider/kimi.py +++ b/packages/kosong/src/kosong/chat_provider/kimi.py @@ -311,11 +311,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..0a40d0167 --- /dev/null +++ b/tests/e2e/test_kimi_empty_tool_call_content_e2e.py @@ -0,0 +1,293 @@ +"""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.StreamResponse: + 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() + assert process.returncode is not None + 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