Skip to content
This repository was archived by the owner on Sep 23, 2026. It is now read-only.
Open
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ Only write entries that are worth mentioning to users.

## Unreleased

- Core: Fix `UserPromptSubmit` hook receiving an empty string when user input is a list of `ContentPart` objects — the hook now correctly extracts concatenated text content instead of treating non-string input as empty

## 1.50.0 (2026-09-02)

- Kosong: Stop sending an empty `anthropic-beta` header when no beta features are declared — adaptive thinking removes the interleaved-thinking beta, which previously left an empty header value that some backends reject
Expand Down
1 change: 1 addition & 0 deletions docs/en/release-notes/changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ This page documents the changes in each Kimi Code CLI release.

## Unreleased

- Core: Fix `UserPromptSubmit` hook receiving an empty string when user input is a list of `ContentPart` objects — the hook now correctly extracts concatenated text content instead of treating non-string input as empty
- Kosong: Stop sending an empty `anthropic-beta` header when no beta features are declared — adaptive thinking removes the interleaved-thinking beta, which previously left an empty header value that some backends reject

## 1.49.0 (2026-07-16)
Expand Down
1 change: 1 addition & 0 deletions docs/zh/release-notes/changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

## 未发布

- Core:修复 `UserPromptSubmit` hook 在用户输入为 `ContentPart` 列表时收到空字符串的问题——hook 现在会正确提取并拼接文本内容,而非将非字符串输入视为空值
- Kosong:当没有声明任何 beta 特性时不再发送值为空的 `anthropic-beta` 请求头——adaptive thinking 会移除 interleaved-thinking beta,此前会留下一个空值请求头,部分后端会拒绝该请求

## 1.49.0 (2026-07-16)
Expand Down
11 changes: 5 additions & 6 deletions src/kimi_cli/soul/kimisoul.py
Original file line number Diff line number Diff line change
Expand Up @@ -689,16 +689,17 @@ async def run(
# the wait ceiling is hit) must bypass ``UserPromptSubmit``:
# they are not user input, and a user-configured prompt-blocking
# hook would drop the notification and hang the wait loop.
if not skip_user_prompt_hook:
text_input_for_hook = user_input if isinstance(user_input, str) else ""
user_message = Message(role="user", content=user_input)
text_input = user_message.extract_text(" ").strip()

if not skip_user_prompt_hook:
hook_results = await self._hook_engine.trigger(
"UserPromptSubmit",
matcher_value=text_input_for_hook,
matcher_value=text_input,
input_data=events.user_prompt_submit(
session_id=self._runtime.session.id,
cwd=str(Path.cwd()),
prompt=text_input_for_hook,
prompt=text_input,
),
)
for result in hook_results:
Expand All @@ -719,8 +720,6 @@ async def run(
mode="plan" if self._plan_mode else "agent",
**_provider_telemetry_kwargs(self._runtime.llm),
)
user_message = Message(role="user", content=user_input)
text_input = user_message.extract_text(" ").strip()

if command_call := parse_slash_command_call(text_input):
command = self._find_slash_command(command_call.name)
Expand Down
80 changes: 80 additions & 0 deletions tests/core/test_user_prompt_submit_hook.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
"""Tests for UserPromptSubmit hook text extraction."""

from __future__ import annotations

from typing import cast
from unittest.mock import AsyncMock, MagicMock, patch

import pytest
from kosong.message import ContentPart, TextPart

from kimi_cli.hooks.engine import HookEngine
from kimi_cli.soul.kimisoul import KimiSoul


def _make_runnable_soul() -> KimiSoul:
"""Minimal KimiSoul bypassing __init__, just enough for run()."""
soul = object.__new__(KimiSoul)

runtime = MagicMock()
runtime.session.id = "test-session"
runtime.approval_runtime = None
runtime.oauth.ensure_fresh = AsyncMock()
soul._runtime = runtime

ctx = MagicMock()
ctx.history = []
ctx.append_message = AsyncMock()
soul._context = ctx

soul._hook_engine = MagicMock(spec=HookEngine)
soul._hook_engine.trigger = AsyncMock(return_value=[])

soul._loop_control = MagicMock()
soul._loop_control.max_ralph_iterations = 0

soul._plan_mode = False

soul._agent = MagicMock()
soul._agent.system_prompt = "sys"

soul._turn = AsyncMock(return_value=MagicMock())
soul._slash_commands = []
soul._steer_queue = MagicMock()
soul._steer_queue.empty.return_value = True

soul._stop_hook_active = False
soul._injection_providers = []
soul._compaction = MagicMock()
soul._checkpoint = AsyncMock()

return soul


@pytest.mark.asyncio
async def test_user_prompt_submit_hook_receives_text_from_string() -> None:
"""When user_input is a plain string, the hook receives it as prompt."""
soul = _make_runnable_soul()

with patch("kimi_cli.soul.kimisoul.wire_send"):
await soul.run("hello world")

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Initialize _plan_mode before calling KimiSoul.run

The new tests build KimiSoul via object.__new__ but never initialize _plan_mode, and run() unconditionally reads that field when emitting telemetry ("plan" if self._plan_mode else "agent"). As written, this call path raises AttributeError before the hook assertions execute, so both tests fail when run. Set _plan_mode (and any other run()-required fields) in _make_runnable_soul to keep the tests runnable.

Useful? React with 👍 / 👎.


call_args = cast(AsyncMock, soul._hook_engine.trigger).call_args_list[0]
assert call_args[0][0] == "UserPromptSubmit"
assert call_args[1]["matcher_value"] == "hello world"
assert call_args[1]["input_data"]["prompt"] == "hello world"


@pytest.mark.asyncio
async def test_user_prompt_submit_hook_receives_text_from_content_parts() -> None:
"""When user_input is a list of ContentPart, the hook receives extracted text."""
soul = _make_runnable_soul()
parts: list[ContentPart] = [TextPart(text="hello"), TextPart(text="world")]

with patch("kimi_cli.soul.kimisoul.wire_send"):
await soul.run(parts)

call_args = cast(AsyncMock, soul._hook_engine.trigger).call_args_list[0]
assert call_args[0][0] == "UserPromptSubmit"
assert call_args[1]["matcher_value"] == "hello world"
assert call_args[1]["input_data"]["prompt"] == "hello world"