109 lines
3.8 KiB
Python
109 lines
3.8 KiB
Python
"""Focused tests for GooseExecutor.interrupt_session (#1748).
|
|
|
|
Verifies that the web Stop button (interrupt_session) correctly:
|
|
1. Returns False when there is no live process.
|
|
2. Falls back to SIGTERM when no ACP session has been established yet.
|
|
3. Sends ACP session/cancel when a session_id is known.
|
|
4. Falls back to SIGTERM when session/cancel errors out.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from unittest.mock import AsyncMock, MagicMock, patch
|
|
|
|
from omnigent.inner.goose_executor import GooseExecutor
|
|
|
|
|
|
def _make_executor() -> GooseExecutor:
|
|
"""Return a GooseExecutor with no real subprocess wired."""
|
|
return GooseExecutor(cwd="/tmp", goose_path="/usr/bin/goose")
|
|
|
|
|
|
def _live_proc(returncode: int | None = None) -> MagicMock:
|
|
"""Return a mock subprocess with the given returncode."""
|
|
proc = MagicMock()
|
|
proc.returncode = returncode
|
|
proc.terminate = MagicMock()
|
|
return proc
|
|
|
|
|
|
async def test_interrupt_no_process_returns_false() -> None:
|
|
"""interrupt_session returns False when no subprocess is running."""
|
|
ex = _make_executor()
|
|
assert ex._proc is None
|
|
result = await ex.interrupt_session("s1")
|
|
assert result is False
|
|
|
|
|
|
async def test_interrupt_dead_process_returns_false() -> None:
|
|
"""interrupt_session returns False when the process has already exited."""
|
|
ex = _make_executor()
|
|
ex._proc = _live_proc(returncode=0)
|
|
result = await ex.interrupt_session("s1")
|
|
assert result is False
|
|
|
|
|
|
async def test_interrupt_no_session_id_sends_sigterm() -> None:
|
|
"""Without a session_id, interrupt falls back to SIGTERM immediately."""
|
|
ex = _make_executor()
|
|
ex._proc = _live_proc() # returncode=None → live
|
|
ex._session_id = None # no ACP session established yet
|
|
|
|
result = await ex.interrupt_session("s1")
|
|
|
|
assert result is True
|
|
ex._proc.terminate.assert_called_once()
|
|
|
|
|
|
async def test_interrupt_with_session_sends_cancel_notification() -> None:
|
|
"""With a session_id, interrupt sends the session/cancel notification.
|
|
|
|
``session/cancel`` is an ACP notification: it goes out via ``_send`` with no
|
|
``id`` and no reply is expected (Goose ends the in-flight prompt with a
|
|
cancelled stop reason). Asserting on ``_send`` exercises that real contract.
|
|
"""
|
|
ex = _make_executor()
|
|
ex._proc = _live_proc()
|
|
ex._session_id = "goose_session_42"
|
|
|
|
with patch.object(ex, "_send", new_callable=AsyncMock) as mock_send:
|
|
result = await ex.interrupt_session("s1")
|
|
|
|
assert result is True
|
|
mock_send.assert_awaited_once()
|
|
sent = mock_send.call_args.args[0]
|
|
assert sent["method"] == "session/cancel"
|
|
assert sent["params"]["sessionId"] == "goose_session_42"
|
|
# A notification carries no id — the agent never responds to it.
|
|
assert "id" not in sent
|
|
# SIGTERM should NOT have been sent — the clean cancel was enough.
|
|
ex._proc.terminate.assert_not_called()
|
|
|
|
|
|
async def test_interrupt_cancel_send_error_falls_back_to_sigterm() -> None:
|
|
"""When the session/cancel send errors, interrupt falls back to SIGTERM."""
|
|
ex = _make_executor()
|
|
ex._proc = _live_proc()
|
|
ex._session_id = "goose_session_99"
|
|
|
|
with patch.object(
|
|
ex, "_send", new_callable=AsyncMock, side_effect=RuntimeError("broken pipe")
|
|
):
|
|
result = await ex.interrupt_session("s1")
|
|
|
|
assert result is True
|
|
ex._proc.terminate.assert_called_once()
|
|
|
|
|
|
async def test_interrupt_process_lookup_error_returns_false() -> None:
|
|
"""ProcessLookupError on terminate → process vanished → returns False."""
|
|
ex = _make_executor()
|
|
proc = _live_proc()
|
|
proc.terminate.side_effect = ProcessLookupError
|
|
ex._proc = proc
|
|
ex._session_id = None
|
|
|
|
result = await ex.interrupt_session("s1")
|
|
# suppress(ProcessLookupError) means we fall through to `return False`
|
|
assert result is False
|