From beea927b9192fde95ab219d60d30b05f80ec0b70 Mon Sep 17 00:00:00 2001 From: "logic.wu0" <605524858@qq.com> Date: Mon, 15 Jun 2026 21:32:16 +0800 Subject: [PATCH] fix(hooks): pass prompt text to UserPromptSubmit from structured input The UserPromptSubmit hook derived its prompt text with `user_input if isinstance(user_input, str) else ""`. Input submitted from the interactive shell UI is structured content (list[ContentPart]), not a plain str, so it fell into the else branch: hooks received an empty `prompt` and `matcher_value`, and regex-based prompt hooks never matched. Add `user_input_to_text()` (extracts text from str or structured content) and use it when building the hook payload. Fixes #2303 --- src/kimi_cli/soul/kimisoul.py | 6 +++++- src/kimi_cli/utils/message.py | 15 ++++++++++++++- tests/utils/test_message_utils.py | 25 ++++++++++++++++++++++++- 3 files changed, 43 insertions(+), 3 deletions(-) diff --git a/src/kimi_cli/soul/kimisoul.py b/src/kimi_cli/soul/kimisoul.py index 6f27b4233..0d11a84a7 100644 --- a/src/kimi_cli/soul/kimisoul.py +++ b/src/kimi_cli/soul/kimisoul.py @@ -66,6 +66,7 @@ from kimi_cli.tools.dmail import NAME as SendDMail_NAME from kimi_cli.tools.utils import ToolRejectedError from kimi_cli.utils.logging import logger +from kimi_cli.utils.message import user_input_to_text from kimi_cli.utils.slashcmd import SlashCommand, parse_slash_command_call from kimi_cli.wire.file import WireFile from kimi_cli.wire.types import ( @@ -605,7 +606,10 @@ async def run( # 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_input may be structured content (e.g. from the shell UI), + # not a plain str; extract its text so prompt-matching hooks see + # the actual prompt instead of an empty string. + text_input_for_hook = user_input_to_text(user_input) hook_results = await self._hook_engine.trigger( "UserPromptSubmit", diff --git a/src/kimi_cli/utils/message.py b/src/kimi_cli/utils/message.py index 6ca2c4568..b4221828a 100644 --- a/src/kimi_cli/utils/message.py +++ b/src/kimi_cli/utils/message.py @@ -1,10 +1,23 @@ from __future__ import annotations -from kosong.message import Message +from kosong.message import ContentPart, Message from kimi_cli.wire.types import AudioURLPart, ImageURLPart, TextPart, VideoURLPart +def user_input_to_text(user_input: str | list[ContentPart]) -> str: + """Extract the plain text from user input. + + ``user_input`` may be a plain string or structured content (e.g. the parts + produced by the interactive shell UI). Return the concatenated text in both + cases so text-based consumers (such as prompt-matching hooks) work + regardless of the input shape. + """ + if isinstance(user_input, str): + return user_input + return Message(role="user", content=user_input).extract_text(" ") + + def message_stringify(message: Message) -> str: """Get a string representation of a message.""" # TODO: this should be merged into `kosong.message.Message.extract_text` diff --git a/tests/utils/test_message_utils.py b/tests/utils/test_message_utils.py index f19c7e643..836e11f74 100644 --- a/tests/utils/test_message_utils.py +++ b/tests/utils/test_message_utils.py @@ -4,10 +4,33 @@ from kosong.message import Message -from kimi_cli.utils.message import message_stringify +from kimi_cli.utils.message import message_stringify, user_input_to_text from kimi_cli.wire.types import ImageURLPart, TextPart +def test_user_input_to_text_from_string(): + """A plain string is returned unchanged.""" + assert user_input_to_text("hello world") == "hello world" + + +def test_user_input_to_text_from_content_parts(): + """Structured content (e.g. from the shell UI) yields its text, not ''.""" + user_input = [TextPart(text="hello"), TextPart(text="world")] + assert user_input_to_text(user_input) == "hello world" + + +def test_user_input_to_text_ignores_non_text_parts(): + """Non-text parts are skipped when extracting the text.""" + image_part = ImageURLPart(image_url=ImageURLPart.ImageURL(url="https://example.com/image.jpg")) + user_input = [TextPart(text="describe"), image_part, TextPart(text="this")] + assert user_input_to_text(user_input) == "describe this" + + +def test_user_input_to_text_from_empty_parts(): + """No text parts yields an empty string.""" + assert user_input_to_text([]) == "" + + def test_extract_text_from_string_content(): """Test extracting text from message with string content.""" message = Message(role="user", content="Simple text")