diff --git a/CHANGELOG.md b/CHANGELOG.md index b74b42ce9..d29f7104d 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 - Core: Improve session startup resilience — `--continue`/`--resume` now tolerate malformed `context.jsonl` records and corrupted subagent, background-task, or notification artifacts; the CLI skips invalid persisted state where possible instead of failing to restore the session - Grep: Add `include_ignored` parameter to search files excluded by `.gitignore` — when set to `true`, ripgrep's `--no-ignore` flag is enabled, allowing searches in gitignored artifacts such as build outputs or `node_modules`; sensitive files (like `.env`) remain filtered by the sensitive-file protection layer; defaults to `false` to preserve existing behavior - Core: Add sensitive file protection to Grep and Read tools — `.env`, SSH private keys (`id_rsa`, `id_ed25519`, `id_ecdsa`), and cloud credentials (`.aws/credentials`, `.gcp/credentials`) are now detected and blocked; Grep filters them from results with a warning, Read rejects them outright; `.env.example`/`.env.sample`/`.env.template` are exempted diff --git a/docs/en/release-notes/changelog.md b/docs/en/release-notes/changelog.md index 6620afca8..dd6fff162 100644 --- a/docs/en/release-notes/changelog.md +++ b/docs/en/release-notes/changelog.md @@ -4,6 +4,9 @@ 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 +- Grep: Add `include_ignored` parameter to search files excluded by `.gitignore` — when set to `true`, ripgrep's `--no-ignore` flag is enabled, allowing searches in gitignored artifacts such as build outputs or `node_modules`; sensitive files (like `.env`) remain filtered by the sensitive-file protection layer; defaults to `false` to preserve existing behavior - Core: Improve session startup resilience — `--continue`/`--resume` now tolerate malformed `context.jsonl` records and corrupted subagent, background-task, or notification artifacts; the CLI skips invalid persisted state where possible instead of failing to restore the session - Grep: Add `include_ignored` parameter to search files excluded by `.gitignore` — when set to `true`, ripgrep's `--no-ignore` flag is enabled, allowing searches in gitignored artifacts such as build outputs or `node_modules`; sensitive files (like `.env`) remain filtered by the sensitive-file protection layer; defaults to `false` to preserve existing behavior - CLI: Improve `kimi export` session export UX — `kimi export` now previews the previous session for the current working directory and asks for confirmation, showing the session ID, title, and last user-message time; adds `--yes` to skip confirmation; also fixes explicit session-ID invocations where `--output` after the argument was incorrectly parsed as a subcommand diff --git a/docs/zh/release-notes/changelog.md b/docs/zh/release-notes/changelog.md index 8ea4f61db..a3ec39b7f 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` 越来越脏 - Core:提升会话启动恢复的鲁棒性——`--continue`/`--resume` 现在可容忍损坏的 `context.jsonl` 记录,以及损坏的子 Agent、后台任务或通知持久化工件;CLI 会尽可能跳过无效状态并继续恢复会话,而不是直接启动失败 - CLI:改进 `kimi export` 会话导出体验——`kimi export` 现在默认预览并确认当前工作目录的上一个会话,显示会话 ID、标题和最后一条用户消息时间;新增 `--yes` 跳过确认;同时修复显式会话 ID 时 `--output` 放在参数后面会被错误解析为子命令的问题 - Grep:新增 `include_ignored` 参数,支持搜索被 `.gitignore` 排除的文件——设为 `true` 时启用 ripgrep 的 `--no-ignore` 标志,可搜索构建产物或 `node_modules` 等通常被忽略的文件;敏感文件(如 `.env`)仍由敏感文件保护层过滤;默认 `false`,不影响现有行为 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 7c78f0e80..6c72a907b 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,11 +74,22 @@ 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: + 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 @@ -97,11 +111,16 @@ 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). + # 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: - return None + if self._can_auto_trigger_pending(): + return None idle_task = asyncio.create_task(idle_events.get()) if not self.enabled: @@ -128,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: @@ -137,6 +158,16 @@ 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: ... + + def recent_input_activity_remaining(self, *, within_s: float) -> float: ... + + async def wait_for_input_activity(self) -> None: ... + + class Shell: def __init__( self, @@ -388,19 +419,44 @@ 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 + 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, + timeout_s=self._background_auto_trigger_timeout_s(prompt_session), + ) 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( @@ -426,6 +482,9 @@ async def _invalidate_after_mcp_loading() -> None: event = result + if event.kind == "input_activity": + continue + if event.kind == "bg_noop": continue @@ -445,6 +504,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() @@ -470,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() @@ -481,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() @@ -675,6 +737,59 @@ 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 + ) + + @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( + [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, timeout_task): + if task is None: + continue + 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 a6fbcae31..0b7870ccc 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() @@ -1891,6 +1895,24 @@ 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 + + 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() + 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..08f7ff428 100644 --- a/tests/ui_and_conv/test_background_completion_watcher.py +++ b/tests/ui_and_conv/test_background_completion_watcher.py @@ -3,22 +3,27 @@ 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( *, 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 @@ -28,19 +33,25 @@ 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).""" - watcher = _make_watcher(has_pending=True) +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, can_auto_trigger_pending=False) 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 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) @@ -52,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) @@ -61,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 # ------------------------------------------------------------------- @@ -68,7 +89,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() @@ -85,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.""" @@ -137,3 +178,90 @@ 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, + remaining: float = 0.0, + ) -> None: + self._pending = pending + self._recent = recent + self._remaining = remaining + 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 + + 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() + + +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 + + +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) + 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 + + +@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 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: