Skip to content
Merged
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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions docs/en/release-notes/changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions docs/zh/release-notes/changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 目录发现现在独立搜索品牌组和通用组目录并合并结果,而非在所有候选目录中找到第一个就停止
Expand Down
36 changes: 34 additions & 2 deletions src/kimi_cli/approval_runtime/runtime.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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
Comment on lines +100 to +113

Copilot AI Apr 2, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In the timeout handler, popping the waiter before calling _cancel_request() means the shared Future is never completed (no set_exception/set_result). If another task is also awaiting the same waiter (e.g. concurrent callers of wait_for_response for the same request), it won’t be released when the request is cancelled and will block until its own timeout. Consider cancelling via _cancel_request() without removing the waiter first, and suppressing the “exception was never retrieved” warning by explicitly retrieving the exception (or adding a done-callback that calls future.exception()).

Copilot uses AI. Check for mistakes.
Comment on lines +100 to +113

Copilot AI Apr 2, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

asyncio.wait_for() can raise a timeout right as the approval is being resolved; in that case request.status may already be resolved / waiter.done() may be true, but this handler will still mark the request cancelled and raise ApprovalCancelledError. To avoid incorrectly rejecting an approval at the timeout boundary, consider re-checking waiter.done() (or request.status) inside the except block and returning the resolved result when available before cancelling.

Copilot uses AI. Check for mistakes.

def resolve(self, request_id: str, response: ApprovalResponseKind, feedback: str = "") -> bool:
request = self._requests.get(request_id)
Expand All @@ -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
Expand Down
4 changes: 4 additions & 0 deletions src/kimi_cli/ui/shell/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
29 changes: 29 additions & 0 deletions tests/core/test_approval_runtime.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
66 changes: 66 additions & 0 deletions tests/ui_and_conv/test_shell_task_slash.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 == []
Loading