Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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.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
Expand Down
2 changes: 2 additions & 0 deletions docs/en/release-notes/changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions docs/zh/release-notes/changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@

## 未发布

- Core:修复 `UserPromptSubmit` hook 在用户输入为 `ContentPart` 列表时收到空字符串的问题——hook 现在会正确提取并拼接文本内容,而非将非字符串输入视为空值

## 1.49.0 (2026-07-16)

**亮点**:Kimi 供应商的补全 token 预算现在会根据模型剩余上下文窗口动态调整,减少长轮次中的上下文超限错误
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
79 changes: 79 additions & 0 deletions tests/core/test_user_prompt_submit_hook.py
Original file line number Diff line number Diff line change
@@ -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")

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 = 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"