Skip to content
This repository was archived by the owner on Sep 23, 2026. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from 4 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: Approval requests no longer auto-timeout after 5 minutes, which previously surfaced as `Rejected by user`; active foreground and subagent approvals now wait indefinitely for user response
- Core: Fix yolo mode reminder being lost after context compaction — the non-interactive-mode guidance ("don't call AskUserQuestion, plan-mode toggles are auto-approved") is now re-injected on the first LLM step after each compaction while yolo remains active, instead of being silently dropped when the original reminder is folded into the compaction summary
- Shell: Fix `/usage` remaining quota rendering — the progress bar, warning colors, and `% left` label now all use the remaining quota ratio consistently, so high remaining quota shows as green/full and near-exhausted quota shows as yellow or red
- Shell: Show active background agent task count in the prompt status bar — the existing `⚙ bash: N` badge only counted background Shell tasks and filtered out background Agent subagents, so when many subagents were running the prompt looked idle and users could not tell work was in progress; the toolbar now renders `⚙ bash: N` and `⚙ agent: N` as two independent badges (each hidden when its count is 0) and drops the agent badge first when the terminal is too narrow to fit both
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: Approval requests no longer auto-timeout after 5 minutes, which previously surfaced as `Rejected by user`; active foreground and subagent approvals now wait indefinitely for user response
- Core: Fix yolo mode reminder being lost after context compaction — the non-interactive-mode guidance ("don't call AskUserQuestion, plan-mode toggles are auto-approved") is now re-injected on the first LLM step after each compaction while yolo remains active, instead of being silently dropped when the original reminder is folded into the compaction summary
- Shell: Fix `/usage` remaining quota rendering — the progress bar, warning colors, and `% left` label now all use the remaining quota ratio consistently, so high remaining quota shows as green/full and near-exhausted quota shows as yellow or red
- Shell: Show active background agent task count in the prompt status bar — the existing `⚙ bash: N` badge only counted background Shell tasks and filtered out background Agent subagents, so when many subagents were running the prompt looked idle and users could not tell work was in progress; the toolbar now renders `⚙ bash: N` and `⚙ agent: N` as two independent badges (each hidden when its count is 0) and drops the agent badge first when the terminal is too narrow to fit both
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:修复审批请求 5 分钟自动超时并被误报为 `Rejected by user` 的问题;现在活跃的前台和子 Agent 审批请求都会无限等待用户响应
- Core:修复上下文压缩后 yolo 模式提示词丢失的问题——当 yolo 模式处于激活状态时,非交互模式下的指导提示(不要调用 AskUserQuestion、计划模式切换自动批准等)现在会在每次上下文压缩后的第一个 LLM 步骤重新注入,而不是在原始提示被折叠进压缩摘要后静默消失
- Shell:修复 `/usage` 剩余额度渲染错误——进度条、告警颜色和 `% left` 文案现在都统一基于剩余额度比例计算,剩余额度充足时显示为绿色满格,接近耗尽时显示为黄色或红色
- Shell:在提示框状态栏显示当前正在运行的后台 Agent 任务数——原有的 `⚙ bash: N` 徽章只统计后台 Shell 任务,把后台 Agent 子代理过滤掉了,所以多个子代理同时在跑时提示框看起来像空闲,用户无法判断工作是否还在进行;现在状态栏会渲染 `⚙ bash: N` 与 `⚙ agent: N` 两个相互独立的徽章(任一计数为 0 时自动隐藏),终端太窄无法同时容纳两者时优先丢弃 agent 徽章
Expand Down
30 changes: 27 additions & 3 deletions src/kimi_cli/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@
from kimi_cli.llm import augment_provider_with_env_vars, create_llm, model_display_name
from kimi_cli.session import Session
from kimi_cli.share import get_share_dir
from kimi_cli.soul import run_soul
from kimi_cli.soul import RunCancelled, run_soul
from kimi_cli.soul.agent import Runtime, load_agent
from kimi_cli.soul.context import Context
from kimi_cli.soul.kimisoul import KimiSoul
Expand Down Expand Up @@ -618,28 +618,52 @@ async def _ui_loop_fn(wire: Wire) -> None:
assert self._runtime.root_wire_hub is not None
self._runtime.root_wire_hub.unsubscribe(root_hub_queue)

run_cancel_event = asyncio.Event()

async def _mirror_external_cancel() -> None:
await cancel_event.wait()
run_cancel_event.set()

external_cancel_task = asyncio.create_task(
_mirror_external_cancel(),
name="cancel-event-mirror",
)
soul_task = asyncio.create_task(
run_soul(
self.soul,
user_input,
_ui_loop_fn,
cancel_event,
run_cancel_event,
runtime=self._runtime,
)
)

wire_shut_down = False
try:
wire_ui = await wire_future
while True:
msg = await wire_ui.receive()
yield msg
except QueueShutDown:
wire_shut_down = True
pass
finally:
# stop consuming Wire messages
stop_ui_loop.set()
cleanup_cancelled_run = False
if not wire_shut_down and not soul_task.done() and not cancel_event.is_set():
cleanup_cancelled_run = True
run_cancel_event.set()
# wait for the soul task to finish, or raise
await soul_task
try:
await soul_task
except RunCancelled:
if not cleanup_cancelled_run:
raise
finally:
external_cancel_task.cancel()
with contextlib.suppress(asyncio.CancelledError):
await external_cancel_task

async def run_shell(
self, command: str | None = None, *, prefill_text: str | None = None
Expand Down
8 changes: 7 additions & 1 deletion src/kimi_cli/approval_runtime/runtime.py
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,7 @@ def create_request(
return request

async def wait_for_response(
self, request_id: str, timeout: float = 300.0
self, request_id: str, timeout: float | None = None
) -> tuple[ApprovalResponseKind, str]:
waiter = self._waiters.get(request_id)
request = self._requests.get(request_id)
Expand All @@ -98,7 +98,13 @@ async def wait_for_response(
waiter = asyncio.get_running_loop().create_future()
self._waiters[request_id] = waiter
try:
if timeout is None:
return await asyncio.shield(waiter)
return await asyncio.wait_for(asyncio.shield(waiter), timeout=timeout)
except asyncio.CancelledError:
if self._waiters.get(request_id) is waiter:
self._waiters.pop(request_id, None)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Keep shared waiter registered when one awaiter is cancelled

ApprovalRuntime.wait_for_response reuses a single future per request ID, but this new CancelledError handler removes that shared future from _waiters whenever any waiting task is cancelled. If two coroutines are waiting on the same request and one is cancelled (for example, one caller aborts while another still awaits), resolve()/cancel_by_source() can no longer find the waiter to complete, leaving the remaining waiter hung indefinitely despite the request being resolved.

Useful? React with 👍 / 👎.

raise
except TimeoutError:
logger.warning(
"Approval request {id} timed out after {t}s",
Expand Down
11 changes: 8 additions & 3 deletions src/kimi_cli/soul/kimisoul.py
Original file line number Diff line number Diff line change
Expand Up @@ -530,12 +530,12 @@ async def run(
skip_user_prompt_hook: bool = False,
):
approval_source_token = None
created_approval_source: ApprovalSource | None = None
turn_started = False
turn_finished = False
if get_current_approval_source_or_none() is None:
approval_source_token = set_current_approval_source(
ApprovalSource(kind="foreground_turn", id=uuid.uuid4().hex)
)
created_approval_source = ApprovalSource(kind="foreground_turn", id=uuid.uuid4().hex)
approval_source_token = set_current_approval_source(created_approval_source)
try:
# Refresh OAuth tokens on each turn to avoid idle-time expirations.
await self._runtime.oauth.ensure_fresh(self._runtime)
Expand Down Expand Up @@ -645,6 +645,11 @@ async def run(
finally:
if turn_started and not turn_finished:
wire_send(TurnEnd())
if created_approval_source is not None and self._runtime.approval_runtime is not None:
self._runtime.approval_runtime.cancel_by_source(
created_approval_source.kind,
created_approval_source.id,
)
if approval_source_token is not None:
reset_current_approval_source(approval_source_token)

Expand Down
98 changes: 97 additions & 1 deletion tests/acp/test_session_notifications.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,20 @@
from __future__ import annotations

import asyncio
from pathlib import Path

import acp
import pytest
from kosong.tooling.empty import EmptyToolset

from kimi_cli.acp.session import ACPSession
from kimi_cli.wire.types import Notification, TextPart, TurnBegin, TurnEnd
from kimi_cli.app import KimiCLI
from kimi_cli.approval_runtime import get_current_approval_source_or_none
from kimi_cli.soul import wire_send
from kimi_cli.soul.agent import Agent, Runtime
from kimi_cli.soul.context import Context
from kimi_cli.soul.kimisoul import KimiSoul
from kimi_cli.wire.types import Notification, TextPart, ToolCall, TurnBegin, TurnEnd


class _FakeConn:
Expand Down Expand Up @@ -52,3 +62,89 @@ async def test_acp_session_surfaces_notification_as_message_chunk() -> None:
)
assert "Task ID: b1234567" in notification_update.content.text
assert text_update.content.text == "done"


class _BlockingApprovalConn(_FakeConn):
def __init__(self) -> None:
super().__init__()
self.permission_requested = asyncio.Event()

async def request_permission(
self,
options: list[acp.schema.PermissionOption],
session_id: str,
tool_call: acp.schema.ToolCallUpdate,
**kwargs,
) -> acp.schema.RequestPermissionResponse:
self.permission_requested.set()
pending: asyncio.Future[acp.schema.RequestPermissionResponse] = asyncio.Future()
return await pending


@pytest.mark.asyncio
async def test_acp_prompt_cancel_closes_abandoned_approval_stream(
runtime: Runtime,
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
assert runtime.approval_runtime is not None

async def fake_turn(self, _user_message):
source = get_current_approval_source_or_none()
assert source is not None
tool_call_id = "call-acp-abandoned-approval"
wire_send(
ToolCall(
id=tool_call_id,
function=ToolCall.FunctionBody(name="WriteFile", arguments="{}"),
)
)
request = runtime.approval_runtime.create_request(
request_id="req-acp-abandoned-approval",
tool_call_id=tool_call_id,
sender="WriteFile",
action="edit file",
description="write file",
display=[],
source=source,
)
await runtime.approval_runtime.wait_for_response(request.id)

async def fake_ensure_fresh(_runtime):
return None

monkeypatch.setattr(KimiSoul, "_turn", fake_turn)
monkeypatch.setattr(runtime.oauth, "ensure_fresh", fake_ensure_fresh)

soul = KimiSoul(
Agent(
name="ACP Approval Agent",
system_prompt="System prompt.",
toolset=EmptyToolset(),
runtime=runtime,
),
context=Context(file_backend=tmp_path / "history.jsonl"),
)
conn = _BlockingApprovalConn()
session = ACPSession("session-1", KimiCLI(soul, runtime, {}), conn) # type: ignore[arg-type]
prompt_task = asyncio.create_task(session.prompt([acp.text_block("hello")]))

await asyncio.wait_for(conn.permission_requested.wait(), timeout=1.0)
prompt_task.cancel()
with pytest.raises(asyncio.CancelledError):
await asyncio.wait_for(prompt_task, timeout=1.0)

async def wait_for_cancelled_approval() -> None:
for _ in range(20):
record = runtime.approval_runtime.get_request("req-acp-abandoned-approval")
if record is not None and record.status == "cancelled":
return
await asyncio.sleep(0.01)
pytest.fail("approval request was not cancelled")

await wait_for_cancelled_approval()
record = runtime.approval_runtime.get_request("req-acp-abandoned-approval")
assert record is not None
assert record.status == "cancelled"
assert record.response == "reject"
assert runtime.approval_runtime.list_pending() == []
Loading
Loading