diff --git a/tests/tui_gateway/test_interrupt_agent_loop_stopped_hook.py b/tests/tui_gateway/test_interrupt_agent_loop_stopped_hook.py new file mode 100644 index 0000000000..d4a3e9f57a --- /dev/null +++ b/tests/tui_gateway/test_interrupt_agent_loop_stopped_hook.py @@ -0,0 +1,70 @@ +"""The TUI/desktop ``session.interrupt`` path is a sibling of the gateway's +``_interrupt_and_clear_session``: when a live turn is stopped, plugins holding +per-turn external resources need the same ``agent_loop_stopped`` signal. +""" + +import threading +from unittest.mock import MagicMock, patch + + +def _make_session(running: bool) -> dict: + return { + "history_lock": threading.Lock(), + "running": running, + "queued_prompt": None, + "session_key": "agent:main:tui:dm:s1", + "agent": MagicMock(), + "_run_thread": None, + } + + +def _hook_calls(mock_invoke_hook): + return [ + call + for call in mock_invoke_hook.call_args_list + if call.args and call.args[0] == "agent_loop_stopped" + ] + + +@patch("hermes_cli.plugins.invoke_hook") +def test_interrupt_running_turn_fires_agent_loop_stopped(mock_invoke_hook): + from tui_gateway import server + + session = _make_session(running=True) + with patch.object(server, "_clear_pending"): + server._interrupt_session_turn("s1", session) + + calls = _hook_calls(mock_invoke_hook) + assert len(calls) == 1 + assert calls[0].kwargs == { + "session_key": "agent:main:tui:dm:s1", + "platform": "tui", + "reason": "user_stop", + "invalidation_reason": "session_interrupt", + } + + +@patch("hermes_cli.plugins.invoke_hook") +def test_interrupt_idle_session_does_not_fire_hook(mock_invoke_hook): + """No live turn -> nothing for a plugin to cancel -> no hook noise.""" + from tui_gateway import server + + session = _make_session(running=False) + with patch.object(server, "_clear_pending"): + server._interrupt_session_turn("s1", session) + + assert _hook_calls(mock_invoke_hook) == [] + + +@patch("hermes_cli.plugins.invoke_hook") +def test_hook_failure_does_not_break_interrupt(mock_invoke_hook): + """A misbehaving plugin must never prevent the interrupt itself.""" + mock_invoke_hook.side_effect = RuntimeError("plugin exploded") + from tui_gateway import server + + session = _make_session(running=True) + with patch.object(server, "_clear_pending"): + server._interrupt_session_turn("s1", session) + + # The cancel flag was still set despite the hook blowing up. + assert session["_turn_cancel_requested"] is True diff --git a/tui_gateway/session_lifecycle.py b/tui_gateway/session_lifecycle.py index 210ef5dc66..5c385df9e3 100644 --- a/tui_gateway/session_lifecycle.py +++ b/tui_gateway/session_lifecycle.py @@ -405,6 +405,18 @@ def _interrupt_session_turn(sid: str, session: dict, *, request_id: str | None = session["queued_prompt"] = None session.pop("queued_prompts", None) session["_queued_prompt_generation"] = int(session.get("_queued_prompt_generation", 0)) + 1 + if should_interrupt: + # Sibling of gateway/run_agent_cache.py::_interrupt_and_clear_session: a user-initiated stop of a + # live TUI/desktop turn is the same "loop is gone" event for plugins holding per-turn external + # resources. Observer-only; dispatch failures never break the interrupt. + try: + from hermes_cli.plugins import invoke_hook as _invoke_hook + _invoke_hook( + "agent_loop_stopped", session_key=session.get("session_key", ""), platform="tui", + reason="user_stop", invalidation_reason="session_interrupt", + ) + except Exception: + logger.debug("agent_loop_stopped hook dispatch failed", exc_info=True) if not use_compute_host: if should_interrupt: from agent.interrupt_compat import request_hard_interrupt diff --git a/website/docs/user-guide/features/hooks.md b/website/docs/user-guide/features/hooks.md index 054a3bb75e..67add32eae 100644 --- a/website/docs/user-guide/features/hooks.md +++ b/website/docs/user-guide/features/hooks.md @@ -459,7 +459,7 @@ Payload fields below are the exact event-specific fields supplied by each call s | `on_session_end` | Observer | Canonically at each turn finalization; CLI/TUI exits have additional reduced legacy shapes. Return ignored. | Canonical: `session_id`, `task_id`, `turn_id`, `completed`, `failed`, `interrupted`, `turn_exit_reason`, `model`, `platform`; exit paths may add `reason`/`api_request_id` and omit fields. | IDs, model/platform, and outcome; canonical payload has no message body. | | `on_session_finalize` | Observer | CLI/TUI/gateway teardown through `finalize_session`; gateway shutdown may finalize without a reset. Return ignored. | Surface-dependent `session_id`, `platform`, optionally `reason`, `old_session_id`, `new_session_id` | Session and routing identifiers. | | `on_session_reset` | Observer | CLI/TUI session boundary and gateway after the replacement session exists; return ignored. | CLI: `session_id`, `platform`, `reason`; TUI: `session_id`, `platform`; gateway: those plus `reason`, `old_session_id`, `new_session_id` | Session and routing identifiers. | -| `agent_loop_stopped` | Observer | Immediately after a real gateway agent is interrupted in `_interrupt_and_clear_session`; return ignored. | `session_key`, `platform`, `reason`, `invalidation_reason` | Session/routing identifiers and interruption reasons; no message body. | +| `agent_loop_stopped` | Observer | Immediately after a real running agent is interrupted — gateway `_interrupt_and_clear_session` or TUI/desktop `session.interrupt`; return ignored. | `session_key`, `platform`, `reason`, `invalidation_reason` | Session/routing identifiers and interruption reasons; no message body. | | `on_skill_lifecycle` | Observer | After an authoritative skill-usage state change; return ignored. | `action`, `skill_name`, `provenance`, `task_id`, `session_id`, `use_count`, `reused`, `reuse_after_patch` | Exposes the local skill name and provenance. | | `subagent_start` | Observer | Child constructed and about to run; return ignored. | `parent_session_id`, `parent_turn_id`, `parent_subagent_id`, `child_session_id`, `child_subagent_id`, `child_role`, `child_goal` | Child goal may contain user/project content. | | `subagent_stop` | Observer | Child exit; return ignored. | `parent_session_id`, `parent_turn_id`, `child_session_id`, `child_role`, `child_summary`, `child_status`, `tool_call_history`, `duration_ms` | Summary and redacted tool-history metadata may reveal project structure. | @@ -1051,7 +1051,7 @@ See the **[Build a Plugin guide](/developer-guide/plugins)** for the full walkth Fires when the gateway **interrupts a running agent turn** — the user ran `/stop` while the loop was working, or the running-agent fast-path inside `/new` cleared the in-flight run before swapping the session. Unlike `on_session_finalize`, this fires earlier, while a turn is mid-flight, so plugins can drop per-turn external resources the agent loop will never consume (e.g. an outbound RPC that was waiting for a tool result). -**Gateway only.** Does not fire in the CLI; there is no equivalent interruption surface there. +Fires on both interruption surfaces: the messaging **gateway** (`/stop`, `/new` fast-path) and the **TUI/desktop** `session.interrupt` path (platform is reported as `"tui"`). Does not fire in the plain CLI; there is no equivalent interruption surface there. **Callback signature:**