feat(tui_gateway): fire agent_loop_stopped on session.interrupt too
Widens the new hook to the sibling interrupt surface: the TUI/desktop session.interrupt path stops a live turn exactly like the gateway's /stop, so plugins holding per-turn external resources get the same signal there (platform='tui'). Gated on a genuinely running turn; dispatch failures are swallowed so a plugin can never break the interrupt. Docs updated to describe both surfaces. Inspired by ChatGPT Work / Codex CLI 0.150.0 'Interrupt' hooks (hooks that run when an active top-level turn is interrupted).
This commit is contained in:
70
tests/tui_gateway/test_interrupt_agent_loop_stopped_hook.py
Normal file
70
tests/tui_gateway/test_interrupt_agent_loop_stopped_hook.py
Normal file
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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:**
|
||||
|
||||
|
||||
Reference in New Issue
Block a user