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:
Teknium
2026-08-31 19:19:30 -07:00
parent f361971eed
commit d3202bbc8d
3 changed files with 84 additions and 2 deletions

View 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

View File

@@ -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

View File

@@ -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:**