diff --git a/CHANGELOG.md b/CHANGELOG.md index 07425327e0..3458bfaedc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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.49.0 (2026-07-16) **Highlights**: The completion-token budget for Kimi providers now adapts to the model's remaining context window, reducing context-length overflow errors on long turns diff --git a/docs/en/release-notes/changelog.md b/docs/en/release-notes/changelog.md index 1393930f73..6bd022e326 100644 --- a/docs/en/release-notes/changelog.md +++ b/docs/en/release-notes/changelog.md @@ -4,6 +4,8 @@ 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 + ## 1.49.0 (2026-07-16) **Highlights**: The completion-token budget for Kimi providers now adapts to the model's remaining context window, reducing context-length overflow errors on long turns diff --git a/docs/zh/release-notes/changelog.md b/docs/zh/release-notes/changelog.md index 690d3f5288..62b748b0f5 100644 --- a/docs/zh/release-notes/changelog.md +++ b/docs/zh/release-notes/changelog.md @@ -4,6 +4,8 @@ ## 未发布 +- Core:修复 `UserPromptSubmit` hook 在用户输入为 `ContentPart` 列表时收到空字符串的问题——hook 现在会正确提取并拼接文本内容,而非将非字符串输入视为空值 + ## 1.49.0 (2026-07-16) **亮点**:Kimi 供应商的补全 token 预算现在会根据模型剩余上下文窗口动态调整,减少长轮次中的上下文超限错误 diff --git a/src/kimi_cli/soul/kimisoul.py b/src/kimi_cli/soul/kimisoul.py index 3f14c2a2f7..3f3a8e044b 100644 --- a/src/kimi_cli/soul/kimisoul.py +++ b/src/kimi_cli/soul/kimisoul.py @@ -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: @@ -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) diff --git a/tests/core/test_user_prompt_submit_hook.py b/tests/core/test_user_prompt_submit_hook.py new file mode 100644 index 0000000000..419aa1825c --- /dev/null +++ b/tests/core/test_user_prompt_submit_hook.py @@ -0,0 +1,79 @@ +"""Tests for UserPromptSubmit hook text extraction.""" + +from __future__ import annotations + +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") + + call_args = 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 = 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"