diff --git a/CHANGELOG.md b/CHANGELOG.md index 9da067ed27..6138def96b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ Only write entries that are worth mentioning to users. ## Unreleased +- Core: Fix parallel foreground subagent approval requests hanging the session — in interactive shell mode, `_set_active_approval_sink` no longer flushes pending approval requests to the live view sink (which cannot render approval modals); requests stay in the pending queue for the prompt modal path; also adds a 300-second timeout to `wait_for_response` so that any unresolved approval request eventually raises `ApprovalCancelledError` instead of hanging forever - CLI: Add `--session`/`--resume` (`-S`/`-r`) flag to resume sessions — without an argument opens an interactive session picker (shell UI only); with a session ID resumes that specific session; replaces the reverted `--pick-session`/`--list-sessions` design with a unified optional-value flag - CLI: Add CJK-safe `shorten()` utility — replaces all `textwrap.shorten` calls so that CJK text without spaces is truncated gracefully instead of collapsing to just the placeholder - Core: Fix skills in brand directories (e.g. `~/.kimi/skills/`) silently disappearing when a generic directory (`~/.config/agents/skills/`) exists but is empty — skill directory discovery now searches brand and generic directory groups independently and merges both results, instead of stopping at the first existing directory across all candidates diff --git a/docs/en/release-notes/changelog.md b/docs/en/release-notes/changelog.md index 64a90a1a04..1c041ded56 100644 --- a/docs/en/release-notes/changelog.md +++ b/docs/en/release-notes/changelog.md @@ -4,6 +4,7 @@ This page documents the changes in each Kimi Code CLI release. ## Unreleased +- Core: Fix parallel foreground subagent approval requests hanging the session — in interactive shell mode, `_set_active_approval_sink` no longer flushes pending approval requests to the live view sink (which cannot render approval modals); requests stay in the pending queue for the prompt modal path; also adds a 300-second timeout to `wait_for_response` so that any unresolved approval request eventually raises `ApprovalCancelledError` instead of hanging forever - CLI: Add `--session`/`--resume` (`-S`/`-r`) flag to resume sessions — without an argument opens an interactive session picker (shell UI only); with a session ID resumes that specific session; replaces the reverted `--pick-session`/`--list-sessions` design with a unified optional-value flag - CLI: Add CJK-safe `shorten()` utility — replaces all `textwrap.shorten` calls so that CJK text without spaces is truncated gracefully instead of collapsing to just the placeholder - Core: Fix skills in brand directories (e.g. `~/.kimi/skills/`) silently disappearing when a generic directory (`~/.config/agents/skills/`) exists but is empty — skill directory discovery now searches brand and generic directory groups independently and merges both results, instead of stopping at the first existing directory across all candidates diff --git a/docs/zh/release-notes/changelog.md b/docs/zh/release-notes/changelog.md index 00e5b97e8e..d1359c7094 100644 --- a/docs/zh/release-notes/changelog.md +++ b/docs/zh/release-notes/changelog.md @@ -4,6 +4,7 @@ ## 未发布 +- Core:修复并行 foreground 子 Agent 审批请求导致会话挂死的问题——在交互式 Shell 模式下,`_set_active_approval_sink` 不再将待处理的审批请求 flush 到 live view sink(该 sink 无法渲染审批弹窗);请求保留在 pending 队列中由 prompt modal 路径处理;同时为 `wait_for_response` 增加 300 秒超时,确保未被 resolve 的审批请求最终抛出 `ApprovalCancelledError` 而非永久挂起 - CLI:新增 `--session`/`--resume`(`-S`/`-r`)参数用于恢复会话——不带参数时打开交互式会话选择器(仅 Shell UI);带会话 ID 时恢复指定会话;以统一的可选值参数设计替代了被回退的 `--pick-session`/`--list-sessions` - CLI:新增 CJK 安全的 `shorten()` 工具函数——替换所有 `textwrap.shorten` 调用,使不含空格的中日韩文本能优雅截断,而非被折叠成仅剩省略号 - Core:修复当通用目录(如 `~/.config/agents/skills/`)存在但为空时,品牌目录(如 `~/.kimi/skills/`)中的 Skills 静默消失的问题——Skill 目录发现现在独立搜索品牌组和通用组目录并合并结果,而非在所有候选目录中找到第一个就停止 diff --git a/src/kimi_cli/approval_runtime/runtime.py b/src/kimi_cli/approval_runtime/runtime.py index b310e0fc96..3938001ed8 100644 --- a/src/kimi_cli/approval_runtime/runtime.py +++ b/src/kimi_cli/approval_runtime/runtime.py @@ -82,7 +82,9 @@ def create_request( self._publish_wire_request(request) return request - async def wait_for_response(self, request_id: str) -> tuple[ApprovalResponseKind, str]: + async def wait_for_response( + self, request_id: str, timeout: float = 300.0 + ) -> tuple[ApprovalResponseKind, str]: waiter = self._waiters.get(request_id) request = self._requests.get(request_id) if request is None: @@ -95,7 +97,20 @@ async def wait_for_response(self, request_id: str) -> tuple[ApprovalResponseKind return request.response, request.feedback waiter = asyncio.get_running_loop().create_future() self._waiters[request_id] = waiter - return await waiter + try: + return await asyncio.wait_for(asyncio.shield(waiter), timeout=timeout) + except TimeoutError: + logger.warning( + "Approval request {id} timed out after {t}s", + id=request_id, + t=timeout, + ) + # Pop the waiter before cancelling so _cancel_request won't + # set_exception on a future that nobody is awaiting (which would + # trigger an "exception was never retrieved" warning from asyncio). + self._waiters.pop(request_id, None) + self._cancel_request(request_id, feedback="approval timed out") + raise ApprovalCancelledError(request_id) from None def resolve(self, request_id: str, response: ApprovalResponseKind, feedback: str = "") -> bool: request = self._requests.get(request_id) @@ -114,6 +129,23 @@ def resolve(self, request_id: str, response: ApprovalResponseKind, feedback: str self._publish_wire_response(request_id, response, feedback) return True + def _cancel_request(self, request_id: str, feedback: str = "") -> None: + """Cancel a single pending request by ID.""" + import time + + request = self._requests.get(request_id) + if request is None or request.status != "pending": + return + request.status = "cancelled" + request.response = "reject" + request.feedback = feedback + request.resolved_at = time.time() + waiter = self._waiters.pop(request_id, None) + if waiter is not None and not waiter.done(): + waiter.set_exception(ApprovalCancelledError(request_id)) + self._publish_event(ApprovalRuntimeEvent(kind="request_resolved", request=request)) + self._publish_wire_response(request_id, "reject", feedback) + def cancel_by_source(self, source_kind: ApprovalSourceKind, source_id: str) -> int: cancelled = 0 import time diff --git a/src/kimi_cli/ui/shell/__init__.py b/src/kimi_cli/ui/shell/__init__.py index 4628423d21..41eb772fe9 100644 --- a/src/kimi_cli/ui/shell/__init__.py +++ b/src/kimi_cli/ui/shell/__init__.py @@ -738,6 +738,10 @@ def _enrich_approval_request_for_ui(self, request: ApprovalRequest) -> ApprovalR def _set_active_approval_sink(self, sink: Any) -> None: self._active_approval_sink = sink + # In interactive mode, approvals are handled by the prompt modal, + # not by the live view sink. Don't flush to avoid losing requests. + if self._prompt_session is not None: + return # Flush pending approvals to the newly active sink while self._pending_approval_requests: request = self._pending_approval_requests.popleft() diff --git a/tests/core/test_approval_runtime.py b/tests/core/test_approval_runtime.py index b67273e65a..7944bd64f2 100644 --- a/tests/core/test_approval_runtime.py +++ b/tests/core/test_approval_runtime.py @@ -202,3 +202,32 @@ async def fake_ensure_fresh(_runtime): reset_current_approval_source(token) assert seen_sources == [source] + + +@pytest.mark.asyncio +async def test_approval_runtime_wait_for_response_times_out() -> None: + """wait_for_response should raise ApprovalCancelledError after timeout + instead of hanging forever when no resolve happens. + + Regression test for: subagent approval requests that are never resolved + cause the entire session to hang permanently. + """ + runtime = ApprovalRuntime() + request = runtime.create_request( + request_id="req-timeout", + tool_call_id="call-timeout", + sender="WriteFile", + action="edit file", + description="Write file /tmp/test.txt", + display=[], + source=ApprovalSource(kind="foreground_turn", id="turn-timeout"), + ) + + # Use a very short timeout to avoid slow tests + with pytest.raises(ApprovalCancelledError): + await runtime.wait_for_response(request.id, timeout=0.05) + + # After timeout, the request should be cancelled and cleaned up + record = runtime.get_request(request.id) + assert record is not None + assert record.status == "cancelled" diff --git a/tests/ui_and_conv/test_shell_task_slash.py b/tests/ui_and_conv/test_shell_task_slash.py index dc93b502bf..2dd14e2180 100644 --- a/tests/ui_and_conv/test_shell_task_slash.py +++ b/tests/ui_and_conv/test_shell_task_slash.py @@ -879,3 +879,69 @@ def enqueue_external_message(self, msg) -> None: assert record.status == "resolved" assert record.response == "reject" assert record.feedback == "use rm -i instead" + + +@pytest.mark.asyncio +async def test_set_active_approval_sink_does_not_flush_in_interactive_mode( + runtime: Runtime, + tmp_path: Path, +) -> None: + """In interactive mode (_prompt_session is set), pending approval requests + should NOT be flushed to the live view sink. They must stay in the pending + queue so the prompt modal can present them to the user. + + Regression test for: subagent WriteFile approval requests silently lost + when _set_active_approval_sink flushes to a _PromptLiveView that cannot + render approval modals. + """ + 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")) + shell = Shell(soul) + + # Simulate interactive mode by setting _prompt_session + shell._prompt_session = Mock() # type: ignore[attr-defined] + + # Create a pending approval request in the runtime + runtime.approval_runtime.create_request( + request_id="req-interactive-flush", + tool_call_id="call-interactive-flush", + sender="WriteFile", + action="edit file", + description="Write file /tmp/test.txt", + display=[], + source=ApprovalSource(kind="foreground_turn", id="turn-interactive"), + ) + + # Queue an approval request (simulating what _handle_root_hub_message does) + request = ApprovalRequest( + id="req-interactive-flush", + tool_call_id="call-interactive-flush", + sender="WriteFile", + action="edit file", + description="Write file /tmp/test.txt", + source_kind="foreground_turn", + source_id="turn-interactive", + ) + shell._queue_approval_request(request) # type: ignore[attr-defined] + assert len(shell._pending_approval_requests) == 1 # type: ignore[attr-defined] + + # Now set a sink — in interactive mode, this should NOT flush pending requests + class _Sink: + def __init__(self) -> None: + self.requests: list[ApprovalRequest] = [] + + def enqueue_external_message(self, req: ApprovalRequest) -> None: + self.requests.append(req) + + sink = _Sink() + shell._set_active_approval_sink(sink) # type: ignore[attr-defined] + + # Requests must remain in pending queue for the prompt modal + assert len(shell._pending_approval_requests) == 1 # type: ignore[attr-defined] + # Sink should NOT have received any requests + assert sink.requests == []