d3202bbc8d
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).
71 lines
2.2 KiB
Python
71 lines
2.2 KiB
Python
"""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
|