From a6f66ce03dcff228513dff0c41f50323e26c0e0c Mon Sep 17 00:00:00 2001 From: AkaCoder404 Date: Fri, 15 May 2026 18:47:47 +0800 Subject: [PATCH 1/4] feat(hook): include response and stop_reason in Stop hook payload --- src/kimi_cli/hooks/events.py | 4 ++ src/kimi_cli/soul/kimisoul.py | 12 +++++- tests/core/test_kimisoul_hooks.py | 61 +++++++++++++++++++++++++++++++ 3 files changed, 76 insertions(+), 1 deletion(-) create mode 100644 tests/core/test_kimisoul_hooks.py diff --git a/src/kimi_cli/hooks/events.py b/src/kimi_cli/hooks/events.py index 66e447a41..6f08e79cd 100644 --- a/src/kimi_cli/hooks/events.py +++ b/src/kimi_cli/hooks/events.py @@ -75,10 +75,14 @@ def stop( session_id: str, cwd: str, stop_hook_active: bool = False, + response: str = "", + stop_reason: str = "", ) -> dict[str, Any]: return { **_base("Stop", session_id, cwd), "stop_hook_active": stop_hook_active, + "response": response, + "stop_reason": stop_reason, } diff --git a/src/kimi_cli/soul/kimisoul.py b/src/kimi_cli/soul/kimisoul.py index 6f27b4233..f15da4c5a 100644 --- a/src/kimi_cli/soul/kimisoul.py +++ b/src/kimi_cli/soul/kimisoul.py @@ -632,6 +632,7 @@ async def run( _track_telemetry("turn_started", mode="plan" if self._plan_mode else "agent") user_message = Message(role="user", content=user_input) text_input = user_message.extract_text(" ").strip() + turn_outcome: TurnOutcome | None = None if command_call := parse_slash_command_call(text_input): command = self._find_slash_command(command_call.name) @@ -649,16 +650,25 @@ async def run( ) await runner.run(self, "") else: - await self._turn(user_message) + turn_outcome = await self._turn(user_message) # --- Stop hook (max 1 re-trigger to prevent infinite loop) --- if not self._stop_hook_active: + stop_response = "" + stop_reason = "" + if turn_outcome is not None: + stop_reason = turn_outcome.stop_reason + if turn_outcome.final_message is not None: + stop_response = turn_outcome.final_message.extract_text(" ").strip() + stop_results = await self._hook_engine.trigger( "Stop", input_data=events.stop( session_id=self._runtime.session.id, cwd=str(Path.cwd()), stop_hook_active=False, + response=stop_response, + stop_reason=stop_reason, ), ) for result in stop_results: diff --git a/tests/core/test_kimisoul_hooks.py b/tests/core/test_kimisoul_hooks.py new file mode 100644 index 000000000..302c23848 --- /dev/null +++ b/tests/core/test_kimisoul_hooks.py @@ -0,0 +1,61 @@ +"""Tests for KimiSoul lifecycle hook integration.""" + +from __future__ import annotations + +from pathlib import Path +from unittest.mock import AsyncMock + +import pytest +from kosong.message import Message +from kosong.tooling.empty import EmptyToolset + +import kimi_cli.soul.kimisoul as kimisoul_module +from kimi_cli.hooks.config import HookDef +from kimi_cli.hooks.engine import HookEngine +from kimi_cli.soul.agent import Agent, Runtime +from kimi_cli.soul.context import Context +from kimi_cli.soul.kimisoul import KimiSoul, TurnOutcome +from kimi_cli.wire.types import TextPart + + +@pytest.mark.asyncio +async def test_stop_hook_includes_response_and_stop_reason( + runtime: Runtime, tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """Stop hook receives response text and stop_reason from TurnOutcome.""" + captured_payloads: list[dict] = [] + + async def _capture_trigger( + event: str, *, matcher_value: str = "", input_data: dict | None = None + ) -> list: + captured_payloads.append(input_data or {}) + return [] + + agent = Agent( + name="Test Agent", + system_prompt="Test system prompt.", + toolset=EmptyToolset(), + runtime=runtime, + ) + soul = KimiSoul(agent, context=Context(file_backend=tmp_path / "history.jsonl")) + soul._turn = AsyncMock( # type: ignore[method-assign] + return_value=TurnOutcome( + stop_reason="no_tool_calls", + final_message=Message( + role="assistant", content=[TextPart(text="The fix is complete.")] + ), + step_count=1, + ) + ) + monkeypatch.setattr(kimisoul_module, "wire_send", lambda _msg: None) + + hook_engine = HookEngine([HookDef(event="Stop", command="echo ok", timeout=5)]) + monkeypatch.setattr(hook_engine, "trigger", _capture_trigger) + soul.set_hook_engine(hook_engine) + + await soul.run([TextPart(text="fix the bug")]) + + stop_payloads = [p for p in captured_payloads if p.get("hook_event_name") == "Stop"] + assert len(stop_payloads) == 1 + assert stop_payloads[0].get("response") == "The fix is complete." + assert stop_payloads[0].get("stop_reason") == "no_tool_calls" From c1a10da9aa00722d387571fdd052d4dbd32d870b Mon Sep 17 00:00:00 2001 From: AkaCoder404 Date: Fri, 15 May 2026 18:54:43 +0800 Subject: [PATCH 2/4] docs(hooks): document response and stop_reason fields in Stop hook --- docs/en/customization/hooks.md | 2 +- docs/zh/customization/hooks.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/en/customization/hooks.md b/docs/en/customization/hooks.md index 548120550..d7beda72f 100644 --- a/docs/en/customization/hooks.md +++ b/docs/en/customization/hooks.md @@ -28,7 +28,7 @@ Kimi Code CLI supports 13 lifecycle events: | `PostToolUse` | After successful tool execution | Tool name | `tool_name`, `tool_input`, `tool_output` | | `PostToolUseFailure` | After tool execution fails | Tool name | `tool_name`, `tool_input`, `error` | | `UserPromptSubmit` | Before user input is processed | None | `prompt` | -| `Stop` | When Agent turn ends | None | `stop_hook_active` | +| `Stop` | When Agent turn ends | None | `stop_hook_active`, `response`, `stop_reason` | | `StopFailure` | When turn ends due to error | Error type | `error_type`, `error_message` | | `SessionStart` | When session is created/resumed | Source (`startup`/`resume`) | `source` | | `SessionEnd` | When session closes | Reason | `reason` | diff --git a/docs/zh/customization/hooks.md b/docs/zh/customization/hooks.md index e191614d0..94863bc2a 100644 --- a/docs/zh/customization/hooks.md +++ b/docs/zh/customization/hooks.md @@ -28,7 +28,7 @@ Kimi Code CLI 支持 13 种生命周期事件: | `PostToolUse` | 工具成功执行后 | 工具名称 | `tool_name`, `tool_input`, `tool_output` | | `PostToolUseFailure` | 工具执行失败后 | 工具名称 | `tool_name`, `tool_input`, `error` | | `UserPromptSubmit` | 用户提交输入前 | 无 | `prompt` | -| `Stop` | Agent 轮次结束时 | 无 | `stop_hook_active` | +| `Stop` | Agent 轮次结束时 | 无 | `stop_hook_active`, `response`, `stop_reason` | | `StopFailure` | 轮次因错误结束时 | 错误类型 | `error_type`, `error_message` | | `SessionStart` | 会话创建/恢复时 | 来源 (`startup`/`resume`) | `source` | | `SessionEnd` | 会话关闭时 | 原因 | `reason` | From 3a4e6c65c554ec30c2464e6f7fb4c04ea4cef3ff Mon Sep 17 00:00:00 2001 From: AkaCoder404 Date: Fri, 15 May 2026 19:18:51 +0800 Subject: [PATCH 3/4] docs(changelog): add Stop hook response and stop_reason entries --- CHANGELOG.md | 2 ++ docs/en/release-notes/changelog.md | 2 ++ docs/zh/release-notes/changelog.md | 2 ++ 3 files changed, 6 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 881d674dd..fbc793f14 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,8 @@ Only write entries that are worth mentioning to users. ## Unreleased +- Core: Include the assistant's final response text and stop reason in the Stop hook payload — hook scripts can now inspect turn outcomes + ## 1.44.0 (2026-05-13) - Shell: Add slash command alias resolution — aliases such as `/h`, `?`, and `status` now resolve to their canonical commands (`/help`, `/usage`); the completer and help output display alias matches as `/name (alias)` for clarity diff --git a/docs/en/release-notes/changelog.md b/docs/en/release-notes/changelog.md index b1a0e670a..4a2b398e2 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: Include the assistant's final response text and stop reason in the Stop hook payload — hook scripts can now inspect turn outcomes + ## 1.44.0 (2026-05-13) - Shell: Add slash command alias resolution — aliases such as `/h`, `?`, and `status` now resolve to their canonical commands (`/help`, `/usage`); the completer and help output display alias matches as `/name (alias)` for clarity diff --git a/docs/zh/release-notes/changelog.md b/docs/zh/release-notes/changelog.md index 731435225..12d2884cc 100644 --- a/docs/zh/release-notes/changelog.md +++ b/docs/zh/release-notes/changelog.md @@ -4,6 +4,8 @@ ## 未发布 +- Core:Stop 钩子 payload 现在包含 Assistant 的最终回复文本和停止原因——钩子脚本可以据此检查轮次结果 + ## 1.44.0 (2026-05-13) - Shell:新增斜杠命令别名解析——别名(如 `/h`、`?`、`status`)现在能正确解析到对应的正式命令(`/help`、`/usage`);补全器和帮助输出会将别名匹配项显示为 `/name (alias)`,方便识别 From 18b3e0eb1ad1c23c57e97df3023e5fff326b2221 Mon Sep 17 00:00:00 2001 From: AkaCoder404 Date: Fri, 15 May 2026 19:34:13 +0800 Subject: [PATCH 4/4] fix(hook): truncate Stop hook response to 500 chars for parity with SubagentStop --- src/kimi_cli/soul/kimisoul.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/kimi_cli/soul/kimisoul.py b/src/kimi_cli/soul/kimisoul.py index f15da4c5a..77396d670 100644 --- a/src/kimi_cli/soul/kimisoul.py +++ b/src/kimi_cli/soul/kimisoul.py @@ -659,7 +659,7 @@ async def run( if turn_outcome is not None: stop_reason = turn_outcome.stop_reason if turn_outcome.final_message is not None: - stop_response = turn_outcome.final_message.extract_text(" ").strip() + stop_response = turn_outcome.final_message.extract_text(" ").strip()[:500] stop_results = await self._hook_engine.trigger( "Stop",