From ef2fae65aaae36a3637c7970f7e2e9305e09fded Mon Sep 17 00:00:00 2001 From: Kaiyi Date: Thu, 2 Apr 2026 19:48:46 +0800 Subject: [PATCH 1/4] fix(shell): defer background auto-trigger while typing --- CHANGELOG.md | 2 + docs/en/release-notes/changelog.md | 2 + docs/zh/release-notes/changelog.md | 2 + src/kimi_cli/soul/kimisoul.py | 8 ++ src/kimi_cli/ui/shell/__init__.py | 85 +++++++++++-- src/kimi_cli/ui/shell/prompt.py | 16 +++ tests/core/test_kimisoul_turn_balance.py | 115 ++++++++++++++++++ .../test_background_completion_watcher.py | 76 +++++++++++- 8 files changed, 293 insertions(+), 13 deletions(-) create mode 100644 tests/core/test_kimisoul_turn_balance.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 9da067ed2..ea14fa55a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,8 @@ Only write entries that are worth mentioning to users. ## Unreleased +- Shell: Refine idle background completion auto-trigger — resumed shell sessions no longer auto-start a foreground turn from stale pending background notifications before the user sends a message, and fresh background completions now wait briefly while the user is actively typing to avoid stealing the prompt or breaking CJK IME composition +- Core: Fix interrupted foreground turns leaving unbalanced wire events — `TurnEnd` is now emitted even when a turn exits via cancellation or step interruption, preventing dirty session wire logs from accumulating across resume cycles - 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 64a90a1a0..73976bc23 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 +- Shell: Refine idle background completion auto-trigger — resumed shell sessions no longer auto-start a foreground turn from stale pending background notifications before the user sends a message, and fresh background completions now wait briefly while the user is actively typing to avoid stealing the prompt or breaking CJK IME composition +- Core: Fix interrupted foreground turns leaving unbalanced wire events — `TurnEnd` is now emitted even when a turn exits via cancellation or step interruption, preventing dirty session wire logs from accumulating across resume cycles - 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 00e5b97e8..2818ac4ee 100644 --- a/docs/zh/release-notes/changelog.md +++ b/docs/zh/release-notes/changelog.md @@ -4,6 +4,8 @@ ## 未发布 +- Shell:细化空闲时后台完成的自动触发行为——恢复的 Shell 会话在用户发送消息前,不会因为历史遗留的后台通知而自动启动新的前景轮次;当用户正在输入时,新的后台完成事件也会短暂延后触发,避免抢占提示符或打断 CJK 输入法组合态 +- Core:修复前景轮次在中断后残留不平衡 Wire 事件的问题——轮次因取消或步骤中断退出时,现在也会补发 `TurnEnd`,避免恢复多次后会话 `wire.jsonl` 越来越脏 - 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/soul/kimisoul.py b/src/kimi_cli/soul/kimisoul.py index 697c1c9cd..dfe99eac4 100644 --- a/src/kimi_cli/soul/kimisoul.py +++ b/src/kimi_cli/soul/kimisoul.py @@ -460,6 +460,8 @@ def available_slash_commands(self) -> list[SlashCommand[Any]]: async def run(self, user_input: str | list[ContentPart]): approval_source_token = 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) @@ -489,11 +491,14 @@ async def run(self, user_input: str | list[ContentPart]): for result in hook_results: if result.action == "block": wire_send(TurnBegin(user_input=user_input)) + turn_started = True wire_send(TextPart(text=result.reason or "Prompt blocked by hook.")) wire_send(TurnEnd()) + turn_finished = True return wire_send(TurnBegin(user_input=user_input)) + turn_started = True user_message = Message(role="user", content=user_input) text_input = user_message.extract_text(" ").strip() @@ -535,6 +540,7 @@ async def run(self, user_input: str | list[ContentPart]): break wire_send(TurnEnd()) + turn_finished = True # Auto-set title after first real turn (skip slash commands) if not command_call: @@ -560,6 +566,8 @@ async def run(self, user_input: str | list[ContentPart]): save_session_state(fresh, session.dir) session.state.custom_title = fresh.custom_title finally: + if turn_started and not turn_finished: + wire_send(TurnEnd()) if approval_source_token is not None: reset_current_approval_source(approval_source_token) diff --git a/src/kimi_cli/ui/shell/__init__.py b/src/kimi_cli/ui/shell/__init__.py index 4628423d2..db4b65f70 100644 --- a/src/kimi_cli/ui/shell/__init__.py +++ b/src/kimi_cli/ui/shell/__init__.py @@ -8,7 +8,7 @@ from collections.abc import Awaitable, Callable, Coroutine from dataclasses import dataclass from enum import Enum -from typing import Any +from typing import Any, Protocol from kosong.chat_provider import APIStatusError, ChatProviderError from rich.console import Group, RenderableType @@ -64,6 +64,9 @@ class _PromptEvent: _MAX_BG_AUTO_TRIGGER_FAILURES = 3 """Stop auto-triggering after this many consecutive failures.""" +_BG_AUTO_TRIGGER_INPUT_GRACE_S = 0.75 +"""Delay background auto-trigger briefly after local prompt activity.""" + class _BackgroundCompletionWatcher: """Watches for background task completions and auto-triggers the agent. @@ -71,6 +74,11 @@ class _BackgroundCompletionWatcher: Sits between the idle event loop and the soul: when a background task finishes while the agent is idle *and* the LLM hasn't consumed the notification yet, it triggers a soul run. + + Important: pre-existing pending notifications alone should not trigger a + foreground run immediately on session resume. They are consumed either by + the next actual background completion signal or by the next user-triggered + turn. """ def __init__(self, soul: Soul) -> None: @@ -97,11 +105,14 @@ async def wait_for_next(self, idle_events: asyncio.Queue[_PromptEvent]) -> _Prom User input always takes priority over background completions. """ if self.enabled and self._has_pending_llm_notifications(): - # Pending notifications exist, but user input still wins. + # Pending notifications already exist (for example after resume). + # Do not auto-trigger immediately; only let a queued user action + # win eagerly. Otherwise wait for a fresh background completion + # signal before starting a foreground run. try: return idle_events.get_nowait() except asyncio.QueueEmpty: - return None + pass idle_task = asyncio.create_task(idle_events.get()) if not self.enabled: @@ -137,6 +148,14 @@ def _has_pending_llm_notifications(self) -> bool: return self._notifications.has_pending_for_sink("llm") +class _BackgroundAutoTriggerPromptState(Protocol): + def has_pending_input(self) -> bool: ... + + def had_recent_input_activity(self, *, within_s: float) -> bool: ... + + async def wait_for_input_activity(self) -> None: ... + + class Shell: def __init__(self, soul: Soul, welcome_info: list[WelcomeInfoItem] | None = None): self.soul = soul @@ -383,15 +402,28 @@ async def _invalidate_after_mcp_loading() -> None: shell_ok = True bg_auto_failures = 0 + deferred_bg_trigger = False try: while True: - bg_watcher.clear() - if bg_auto_failures >= _MAX_BG_AUTO_TRIGGER_FAILURES: - result = await idle_events.get() + if deferred_bg_trigger and not self._should_defer_background_auto_trigger( + prompt_session + ): + result = None + elif deferred_bg_trigger: + result = await self._wait_for_input_or_activity(prompt_session, idle_events) else: - result = await bg_watcher.wait_for_next(idle_events) + bg_watcher.clear() + if bg_auto_failures >= _MAX_BG_AUTO_TRIGGER_FAILURES: + result = await idle_events.get() + else: + result = await bg_watcher.wait_for_next(idle_events) if result is None: + if self._should_defer_background_auto_trigger(prompt_session): + deferred_bg_trigger = True + resume_prompt.set() + continue + deferred_bg_trigger = False logger.info("Background task completed while idle, triggering agent") resume_prompt.set() ok = await self.run_soul_command( @@ -417,6 +449,9 @@ async def _invalidate_after_mcp_loading() -> None: event = result + if event.kind == "input_activity": + continue + if event.kind == "bg_noop": continue @@ -436,6 +471,7 @@ async def _invalidate_after_mcp_loading() -> None: user_input = event.user_input assert user_input is not None bg_auto_failures = 0 + deferred_bg_trigger = False if not user_input: logger.debug("Got empty input, skipping") resume_prompt.set() @@ -666,6 +702,41 @@ def _handler(): remove_sigint() return False + @staticmethod + def _should_defer_background_auto_trigger( + prompt_session: _BackgroundAutoTriggerPromptState | None, + ) -> bool: + if prompt_session is None: + return False + return prompt_session.has_pending_input() or prompt_session.had_recent_input_activity( + within_s=_BG_AUTO_TRIGGER_INPUT_GRACE_S + ) + + async def _wait_for_input_or_activity( + self, + prompt_session: _BackgroundAutoTriggerPromptState, + idle_events: asyncio.Queue[_PromptEvent], + ) -> _PromptEvent: + idle_task = asyncio.create_task(idle_events.get()) + activity_task = asyncio.create_task(prompt_session.wait_for_input_activity()) + done: set[asyncio.Task[Any]] = set() + try: + done, _ = await asyncio.wait( + [idle_task, activity_task], + return_when=asyncio.FIRST_COMPLETED, + ) + finally: + for task in (idle_task, activity_task): + if task.done(): + continue + task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await task + + if idle_task in done: + return idle_task.result() + return _PromptEvent(kind="input_activity") + async def _watch_root_wire_hub(self) -> None: if not isinstance(self.soul, KimiSoul): return diff --git a/src/kimi_cli/ui/shell/prompt.py b/src/kimi_cli/ui/shell/prompt.py index eea559e21..d398091cc 100644 --- a/src/kimi_cli/ui/shell/prompt.py +++ b/src/kimi_cli/ui/shell/prompt.py @@ -1250,6 +1250,8 @@ def __init__( self._attachment_cache = self._placeholder_manager.attachment_cache self._last_tip_rotate_time: float = time.monotonic() self._last_submission_was_running = False + self._last_input_activity_time: float = 0.0 + self._input_activity_event: asyncio.Event = asyncio.Event() self._running_prompt_previous_mode: PromptMode | None = None self._running_prompt_delegate: RunningPromptDelegate | None = None self._modal_delegates: list[RunningPromptDelegate] = [] @@ -1516,6 +1518,8 @@ def _(event: KeyPressEvent) -> None: # such as when backspace is used to delete text. @self._session.default_buffer.on_text_changed.add_handler def _(buffer: Buffer) -> None: + self._last_input_activity_time = time.monotonic() + self._input_activity_event.set() if buffer.complete_while_typing(): buffer.start_completion() @@ -1882,6 +1886,18 @@ async def prompt_next(self) -> UserInput: def last_submission_was_running(self) -> bool: return getattr(self, "_last_submission_was_running", False) + def has_pending_input(self) -> bool: + return bool(self._session.default_buffer.text) + + def had_recent_input_activity(self, *, within_s: float) -> bool: + if self._last_input_activity_time <= 0: + return False + return (time.monotonic() - self._last_input_activity_time) <= within_s + + async def wait_for_input_activity(self) -> None: + await self._input_activity_event.wait() + self._input_activity_event.clear() + def attach_running_prompt(self, delegate: RunningPromptDelegate) -> None: current = getattr(self, "_running_prompt_delegate", None) if current is delegate: diff --git a/tests/core/test_kimisoul_turn_balance.py b/tests/core/test_kimisoul_turn_balance.py new file mode 100644 index 000000000..c321cbcae --- /dev/null +++ b/tests/core/test_kimisoul_turn_balance.py @@ -0,0 +1,115 @@ +from __future__ import annotations + +import asyncio +from pathlib import Path +from types import SimpleNamespace + +import pytest +from kosong.tooling.empty import EmptyToolset + +import kimi_cli.soul.kimisoul as kimisoul_module +from kimi_cli.soul.agent import Agent, Runtime +from kimi_cli.soul.approval import Approval +from kimi_cli.soul.context import Context +from kimi_cli.soul.kimisoul import KimiSoul +from kimi_cli.wire.types import StepBegin, StepInterrupted, TextPart, TurnBegin, TurnEnd + + +@pytest.fixture +def approval() -> Approval: + """Override global yolo=True fixture; these tests only need wire semantics.""" + return Approval(yolo=False) + + +def _make_soul(runtime: Runtime, tmp_path: Path) -> KimiSoul: + agent = Agent( + name="Turn Balance Agent", + system_prompt="Test prompt.", + toolset=EmptyToolset(), + runtime=runtime, + ) + return KimiSoul(agent, context=Context(file_backend=tmp_path / "history.jsonl")) + + +@pytest.mark.asyncio +async def test_run_emits_turn_end_when_step_interrupts( + runtime: Runtime, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + soul = _make_soul(runtime, tmp_path) + sent: list[object] = [] + + async def fake_checkpoint() -> None: + return None + + async def fake_step(): + raise RuntimeError("boom") + + monkeypatch.setattr(soul, "_checkpoint", fake_checkpoint) + monkeypatch.setattr(soul._denwa_renji, "set_n_checkpoints", lambda _n: None) + monkeypatch.setattr(soul, "_step", fake_step) + monkeypatch.setattr(kimisoul_module, "wire_send", lambda msg: sent.append(msg)) + + with pytest.raises(RuntimeError, match="boom"): + await soul.run("hello") + + assert [msg for msg in sent if isinstance(msg, TurnBegin)] == [TurnBegin(user_input="hello")] + assert [msg for msg in sent if isinstance(msg, StepBegin)] == [StepBegin(n=1)] + assert [msg for msg in sent if isinstance(msg, StepInterrupted)] == [StepInterrupted()] + assert [msg for msg in sent if isinstance(msg, TurnEnd)] == [TurnEnd()] + assert isinstance(sent[-1], TurnEnd) + + +@pytest.mark.asyncio +async def test_run_emits_turn_end_on_cancelled_error( + runtime: Runtime, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + soul = _make_soul(runtime, tmp_path) + sent: list[object] = [] + + async def fake_checkpoint() -> None: + return None + + async def fake_step(): + raise asyncio.CancelledError() + + monkeypatch.setattr(soul, "_checkpoint", fake_checkpoint) + monkeypatch.setattr(soul._denwa_renji, "set_n_checkpoints", lambda _n: None) + monkeypatch.setattr(soul, "_step", fake_step) + monkeypatch.setattr(kimisoul_module, "wire_send", lambda msg: sent.append(msg)) + + with pytest.raises(asyncio.CancelledError): + await soul.run("hello") + + assert [msg for msg in sent if isinstance(msg, TurnBegin)] == [TurnBegin(user_input="hello")] + assert [msg for msg in sent if isinstance(msg, StepBegin)] == [StepBegin(n=1)] + assert [msg for msg in sent if isinstance(msg, StepInterrupted)] == [] + assert [msg for msg in sent if isinstance(msg, TurnEnd)] == [TurnEnd()] + assert isinstance(sent[-1], TurnEnd) + + +@pytest.mark.asyncio +async def test_run_does_not_duplicate_turn_end_for_blocked_prompt( + runtime: Runtime, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + soul = _make_soul(runtime, tmp_path) + sent: list[object] = [] + + async def fake_trigger(*args, **kwargs): + return [SimpleNamespace(action="block", reason="blocked by hook")] + + monkeypatch.setattr(soul._hook_engine, "trigger", fake_trigger) + monkeypatch.setattr(kimisoul_module, "wire_send", lambda msg: sent.append(msg)) + + await soul.run("hello") + + assert sent == [ + TurnBegin(user_input="hello"), + TextPart(text="blocked by hook"), + TurnEnd(), + ] diff --git a/tests/ui_and_conv/test_background_completion_watcher.py b/tests/ui_and_conv/test_background_completion_watcher.py index eecf6f81c..c6b123dbd 100644 --- a/tests/ui_and_conv/test_background_completion_watcher.py +++ b/tests/ui_and_conv/test_background_completion_watcher.py @@ -3,11 +3,14 @@ from __future__ import annotations import asyncio +from types import SimpleNamespace +from typing import cast from unittest.mock import MagicMock import pytest -from kimi_cli.ui.shell import _BackgroundCompletionWatcher, _PromptEvent +from kimi_cli.soul import Soul +from kimi_cli.ui.shell import Shell, _BackgroundCompletionWatcher, _PromptEvent def _make_watcher( @@ -28,13 +31,19 @@ def _make_watcher( @pytest.mark.asyncio -async def test_pending_notification_and_empty_queue_returns_none(): - """Pending LLM notification + empty queue → return None (trigger agent).""" +async def test_pending_notification_and_empty_queue_waits_for_user_input(): + """Pending LLM notification alone should not auto-trigger the agent.""" watcher = _make_watcher(has_pending=True) queue: asyncio.Queue[_PromptEvent] = asyncio.Queue() - result = await watcher.wait_for_next(queue) - assert result is None + task = asyncio.create_task(watcher.wait_for_next(queue)) + await asyncio.sleep(0) + assert task.done() is False + + event = _PromptEvent(kind="input") + await queue.put(event) + result = await task + assert result is event @pytest.mark.asyncio @@ -68,7 +77,7 @@ async def test_pending_notification_but_eof_queued_returns_eof(): @pytest.mark.asyncio async def test_bg_event_fires_with_pending_returns_none(): - """Background event fires + pending notification → return None.""" + """A fresh background completion with pending LLM notification should auto-trigger.""" watcher = _make_watcher() queue: asyncio.Queue[_PromptEvent] = asyncio.Queue() @@ -137,3 +146,58 @@ async def test_disabled_watcher_just_awaits_idle(): result = await watcher.wait_for_next(queue) assert result is event + + +class _FakePromptActivity: + def __init__(self, *, pending: bool = False, recent: bool = False) -> None: + self._pending = pending + self._recent = recent + self._event = asyncio.Event() + + def has_pending_input(self) -> bool: + return self._pending + + def had_recent_input_activity(self, *, within_s: float) -> bool: + return self._recent + + async def wait_for_input_activity(self) -> None: + await self._event.wait() + self._event.clear() + + +def test_shell_defers_background_auto_trigger_when_buffer_non_empty() -> None: + prompt = _FakePromptActivity(pending=True, recent=False) + assert Shell._should_defer_background_auto_trigger(prompt) is True + + +def test_shell_defers_background_auto_trigger_when_recent_input_activity() -> None: + prompt = _FakePromptActivity(pending=False, recent=True) + assert Shell._should_defer_background_auto_trigger(prompt) is True + + +@pytest.mark.asyncio +async def test_shell_wait_for_input_or_activity_returns_activity_event() -> None: + shell = Shell(cast(Soul, SimpleNamespace(available_slash_commands=[], name="x")), None) + prompt = _FakePromptActivity() + queue: asyncio.Queue[_PromptEvent] = asyncio.Queue() + + task = asyncio.create_task(shell._wait_for_input_or_activity(prompt, queue)) + await asyncio.sleep(0) + prompt._event.set() + + result = await task + assert result.kind == "input_activity" + + +@pytest.mark.asyncio +async def test_shell_wait_for_input_or_activity_returns_idle_event() -> None: + shell = Shell(cast(Soul, SimpleNamespace(available_slash_commands=[], name="x")), None) + prompt = _FakePromptActivity() + queue: asyncio.Queue[_PromptEvent] = asyncio.Queue() + expected = _PromptEvent(kind="input") + + task = asyncio.create_task(shell._wait_for_input_or_activity(prompt, queue)) + await queue.put(expected) + + result = await task + assert result is expected From e785d863f2f26dab6a3c3aaf4aba1e3f2863bd17 Mon Sep 17 00:00:00 2001 From: Kaiyi Date: Thu, 2 Apr 2026 20:07:25 +0800 Subject: [PATCH 2/4] test(wire): accept TurnEnd on max-steps exit --- tests_e2e/test_wire_prompt.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/tests_e2e/test_wire_prompt.py b/tests_e2e/test_wire_prompt.py index 2e5f0b257..047139856 100644 --- a/tests_e2e/test_wire_prompt.py +++ b/tests_e2e/test_wire_prompt.py @@ -316,6 +316,11 @@ def test_max_steps_reached(tmp_path) -> None: }, }, }, + { + "method": "event", + "type": "TurnEnd", + "payload": {}, + }, ] ) finally: From 50ed33d49d1e0bc352e01289086a9d2ebe60f190 Mon Sep 17 00:00:00 2001 From: Kaiyi Date: Thu, 2 Apr 2026 20:32:59 +0800 Subject: [PATCH 3/4] fix(shell): wake deferred background trigger after typing grace --- src/kimi_cli/ui/shell/__init__.py | 30 ++++++++++++++-- src/kimi_cli/ui/shell/prompt.py | 6 ++++ .../test_background_completion_watcher.py | 34 ++++++++++++++++++- 3 files changed, 66 insertions(+), 4 deletions(-) diff --git a/src/kimi_cli/ui/shell/__init__.py b/src/kimi_cli/ui/shell/__init__.py index 1468bf829..591ba019a 100644 --- a/src/kimi_cli/ui/shell/__init__.py +++ b/src/kimi_cli/ui/shell/__init__.py @@ -153,6 +153,8 @@ def has_pending_input(self) -> bool: ... def had_recent_input_activity(self, *, within_s: float) -> bool: ... + def recent_input_activity_remaining(self, *, within_s: float) -> float: ... + async def wait_for_input_activity(self) -> None: ... @@ -419,7 +421,11 @@ async def _invalidate_after_mcp_loading() -> None: ): result = None elif deferred_bg_trigger: - result = await self._wait_for_input_or_activity(prompt_session, idle_events) + result = await self._wait_for_input_or_activity( + prompt_session, + idle_events, + timeout_s=self._background_auto_trigger_timeout_s(prompt_session), + ) else: bg_watcher.clear() if bg_auto_failures >= _MAX_BG_AUTO_TRIGGER_FAILURES: @@ -721,21 +727,39 @@ def _should_defer_background_auto_trigger( within_s=_BG_AUTO_TRIGGER_INPUT_GRACE_S ) + @staticmethod + def _background_auto_trigger_timeout_s( + prompt_session: _BackgroundAutoTriggerPromptState | None, + ) -> float | None: + if prompt_session is None or prompt_session.has_pending_input(): + return None + remaining = prompt_session.recent_input_activity_remaining( + within_s=_BG_AUTO_TRIGGER_INPUT_GRACE_S + ) + return remaining if remaining > 0 else None + async def _wait_for_input_or_activity( self, prompt_session: _BackgroundAutoTriggerPromptState, idle_events: asyncio.Queue[_PromptEvent], + *, + timeout_s: float | None = None, ) -> _PromptEvent: idle_task = asyncio.create_task(idle_events.get()) activity_task = asyncio.create_task(prompt_session.wait_for_input_activity()) + timeout_task = ( + asyncio.create_task(asyncio.sleep(timeout_s)) if timeout_s is not None else None + ) done: set[asyncio.Task[Any]] = set() try: done, _ = await asyncio.wait( - [idle_task, activity_task], + [task for task in (idle_task, activity_task, timeout_task) if task is not None], return_when=asyncio.FIRST_COMPLETED, ) finally: - for task in (idle_task, activity_task): + for task in (idle_task, activity_task, timeout_task): + if task is None: + continue if task.done(): continue task.cancel() diff --git a/src/kimi_cli/ui/shell/prompt.py b/src/kimi_cli/ui/shell/prompt.py index 9ac673fb6..0b7870ccc 100644 --- a/src/kimi_cli/ui/shell/prompt.py +++ b/src/kimi_cli/ui/shell/prompt.py @@ -1903,6 +1903,12 @@ def had_recent_input_activity(self, *, within_s: float) -> bool: return False return (time.monotonic() - self._last_input_activity_time) <= within_s + def recent_input_activity_remaining(self, *, within_s: float) -> float: + if self._last_input_activity_time <= 0: + return 0.0 + elapsed = time.monotonic() - self._last_input_activity_time + return max(0.0, within_s - elapsed) + async def wait_for_input_activity(self) -> None: await self._input_activity_event.wait() self._input_activity_event.clear() diff --git a/tests/ui_and_conv/test_background_completion_watcher.py b/tests/ui_and_conv/test_background_completion_watcher.py index c6b123dbd..0e9351fcb 100644 --- a/tests/ui_and_conv/test_background_completion_watcher.py +++ b/tests/ui_and_conv/test_background_completion_watcher.py @@ -149,9 +149,16 @@ async def test_disabled_watcher_just_awaits_idle(): class _FakePromptActivity: - def __init__(self, *, pending: bool = False, recent: bool = False) -> None: + def __init__( + self, + *, + pending: bool = False, + recent: bool = False, + remaining: float = 0.0, + ) -> None: self._pending = pending self._recent = recent + self._remaining = remaining self._event = asyncio.Event() def has_pending_input(self) -> bool: @@ -160,6 +167,9 @@ def has_pending_input(self) -> bool: def had_recent_input_activity(self, *, within_s: float) -> bool: return self._recent + def recent_input_activity_remaining(self, *, within_s: float) -> float: + return self._remaining + async def wait_for_input_activity(self) -> None: await self._event.wait() self._event.clear() @@ -175,6 +185,14 @@ def test_shell_defers_background_auto_trigger_when_recent_input_activity() -> No assert Shell._should_defer_background_auto_trigger(prompt) is True +def test_shell_uses_grace_timeout_only_for_recent_activity_without_pending_input() -> None: + prompt = _FakePromptActivity(pending=False, recent=True, remaining=0.25) + assert Shell._background_auto_trigger_timeout_s(prompt) == pytest.approx(0.25) + + with_pending = _FakePromptActivity(pending=True, recent=True, remaining=0.25) + assert Shell._background_auto_trigger_timeout_s(with_pending) is None + + @pytest.mark.asyncio async def test_shell_wait_for_input_or_activity_returns_activity_event() -> None: shell = Shell(cast(Soul, SimpleNamespace(available_slash_commands=[], name="x")), None) @@ -201,3 +219,17 @@ async def test_shell_wait_for_input_or_activity_returns_idle_event() -> None: result = await task assert result is expected + + +@pytest.mark.asyncio +async def test_shell_wait_for_input_or_activity_times_out_for_recent_activity_only() -> None: + shell = Shell(cast(Soul, SimpleNamespace(available_slash_commands=[], name="x")), None) + prompt = _FakePromptActivity() + queue: asyncio.Queue[_PromptEvent] = asyncio.Queue() + + started = asyncio.get_running_loop().time() + result = await shell._wait_for_input_or_activity(prompt, queue, timeout_s=0.05) + elapsed = asyncio.get_running_loop().time() - started + + assert result.kind == "input_activity" + assert elapsed >= 0.04 From e53964747e7f1ed6d5f7e62dfb2d336e15584d2c Mon Sep 17 00:00:00 2001 From: Kaiyi Date: Thu, 2 Apr 2026 21:39:56 +0800 Subject: [PATCH 4/4] fix(shell): re-arm background auto-trigger after first turn --- src/kimi_cli/ui/shell/__init__.py | 34 +++++++++++++---- .../test_background_completion_watcher.py | 38 +++++++++++++++++-- 2 files changed, 62 insertions(+), 10 deletions(-) diff --git a/src/kimi_cli/ui/shell/__init__.py b/src/kimi_cli/ui/shell/__init__.py index 591ba019a..6c72a907b 100644 --- a/src/kimi_cli/ui/shell/__init__.py +++ b/src/kimi_cli/ui/shell/__init__.py @@ -81,9 +81,15 @@ class _BackgroundCompletionWatcher: turn. """ - def __init__(self, soul: Soul) -> None: + def __init__( + self, + soul: Soul, + *, + can_auto_trigger_pending: Callable[[], bool] | None = None, + ) -> None: self._event: asyncio.Event | None = None self._notifications: NotificationManager | None = None + self._can_auto_trigger_pending = can_auto_trigger_pending or (lambda: True) if isinstance(soul, KimiSoul): self._event = soul.runtime.background_tasks.completion_event self._notifications = soul.runtime.notifications @@ -106,13 +112,15 @@ async def wait_for_next(self, idle_events: asyncio.Queue[_PromptEvent]) -> _Prom """ if self.enabled and self._has_pending_llm_notifications(): # Pending notifications already exist (for example after resume). - # Do not auto-trigger immediately; only let a queued user action - # win eagerly. Otherwise wait for a fresh background completion - # signal before starting a foreground run. + # Before the user sends the first foreground turn after resume, + # pending background notifications should not auto-trigger a run. + # Once the shell is armed by a user-triggered turn, pending + # notifications can resume the normal auto-follow-up behavior. try: return idle_events.get_nowait() except asyncio.QueueEmpty: - pass + if self._can_auto_trigger_pending(): + return None idle_task = asyncio.create_task(idle_events.get()) if not self.enabled: @@ -139,7 +147,9 @@ async def wait_for_next(self, idle_events: asyncio.Queue[_PromptEvent]) -> _Prom # Only bg fired self._event.clear() if self._has_pending_llm_notifications(): - return None + if self._can_auto_trigger_pending(): + return None + return _PromptEvent(kind="bg_noop") return _PromptEvent(kind="bg_noop") def _has_pending_llm_notifications(self) -> bool: @@ -409,7 +419,15 @@ async def _invalidate_after_mcp_loading() -> None: prompt_task = asyncio.create_task( self._route_prompt_events(prompt_session, idle_events, resume_prompt) ) - bg_watcher = _BackgroundCompletionWatcher(self.soul) + background_autotrigger_armed = False + + def _can_auto_trigger_pending() -> bool: + return background_autotrigger_armed + + bg_watcher = _BackgroundCompletionWatcher( + self.soul, + can_auto_trigger_pending=_can_auto_trigger_pending, + ) shell_ok = True bg_auto_failures = 0 @@ -512,6 +530,7 @@ async def _invalidate_after_mcp_loading() -> None: and shell_slash_registry.find_command(slash_cmd_call.name) is None ) if is_soul_slash: + background_autotrigger_armed = True resume_prompt.set() await self.run_soul_command(slash_cmd_call.raw_input) console.print() @@ -523,6 +542,7 @@ async def _invalidate_after_mcp_loading() -> None: resume_prompt.set() continue + background_autotrigger_armed = True resume_prompt.set() await self.run_soul_command(user_input.content) console.print() diff --git a/tests/ui_and_conv/test_background_completion_watcher.py b/tests/ui_and_conv/test_background_completion_watcher.py index 0e9351fcb..08f7ff428 100644 --- a/tests/ui_and_conv/test_background_completion_watcher.py +++ b/tests/ui_and_conv/test_background_completion_watcher.py @@ -16,12 +16,14 @@ def _make_watcher( *, has_pending: bool = False, + can_auto_trigger_pending: bool = True, ) -> _BackgroundCompletionWatcher: """Build a watcher with mocked internals (no real Soul needed).""" watcher = _BackgroundCompletionWatcher.__new__(_BackgroundCompletionWatcher) watcher._event = asyncio.Event() watcher._notifications = MagicMock() watcher._notifications.has_pending_for_sink.return_value = has_pending + watcher._can_auto_trigger_pending = lambda: can_auto_trigger_pending return watcher @@ -33,7 +35,7 @@ def _make_watcher( @pytest.mark.asyncio async def test_pending_notification_and_empty_queue_waits_for_user_input(): """Pending LLM notification alone should not auto-trigger the agent.""" - watcher = _make_watcher(has_pending=True) + watcher = _make_watcher(has_pending=True, can_auto_trigger_pending=False) queue: asyncio.Queue[_PromptEvent] = asyncio.Queue() task = asyncio.create_task(watcher.wait_for_next(queue)) @@ -49,7 +51,7 @@ async def test_pending_notification_and_empty_queue_waits_for_user_input(): @pytest.mark.asyncio async def test_pending_notification_but_user_input_queued_returns_event(): """Pending LLM notification + queued user input → user input wins.""" - watcher = _make_watcher(has_pending=True) + watcher = _make_watcher(has_pending=True, can_auto_trigger_pending=False) queue: asyncio.Queue[_PromptEvent] = asyncio.Queue() event = _PromptEvent(kind="input") await queue.put(event) @@ -61,7 +63,7 @@ async def test_pending_notification_but_user_input_queued_returns_event(): @pytest.mark.asyncio async def test_pending_notification_but_eof_queued_returns_eof(): """Pending notification + queued EOF → user can still exit.""" - watcher = _make_watcher(has_pending=True) + watcher = _make_watcher(has_pending=True, can_auto_trigger_pending=False) queue: asyncio.Queue[_PromptEvent] = asyncio.Queue() eof = _PromptEvent(kind="eof") await queue.put(eof) @@ -70,6 +72,16 @@ async def test_pending_notification_but_eof_queued_returns_eof(): assert result is eof +@pytest.mark.asyncio +async def test_pending_notification_auto_triggers_once_shell_is_armed(): + """After the first user-triggered turn, pending LLM notifications can auto-trigger.""" + watcher = _make_watcher(has_pending=True, can_auto_trigger_pending=True) + queue: asyncio.Queue[_PromptEvent] = asyncio.Queue() + + result = await watcher.wait_for_next(queue) + assert result is None + + # ------------------------------------------------------------------- # Event-based path: background event fires while waiting # ------------------------------------------------------------------- @@ -94,6 +106,26 @@ async def _set_event(): assert result is None +@pytest.mark.asyncio +async def test_bg_event_with_pending_returns_noop_before_shell_is_armed(): + """Before the first user turn after resume, fresh completions should not auto-trigger.""" + watcher = _make_watcher(has_pending=False, can_auto_trigger_pending=False) + queue: asyncio.Queue[_PromptEvent] = asyncio.Queue() + + async def _set_event(): + await asyncio.sleep(0) + mock = watcher._notifications + assert isinstance(mock, MagicMock) + mock.has_pending_for_sink.return_value = True + assert watcher._event is not None + watcher._event.set() + + asyncio.create_task(_set_event()) + result = await watcher.wait_for_next(queue) + assert result is not None + assert result.kind == "bg_noop" + + @pytest.mark.asyncio async def test_bg_event_fires_no_pending_returns_noop(): """Background event fires but no pending notification → bg_noop."""