Files
omnigent-ai--omnigent/tests/test_goose_executor_interrupt.py
2026-07-13 13:12:00 +08:00

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