Files
2026-07-13 13:12:00 +08:00

1783 lines
72 KiB
Python

"""
Tests for :class:`omnigent.runtime.harnesses._scaffold.HarnessApp`.
End-to-end through real subprocesses spawned via the same
:class:`HarnessProcessManager` used in production. Each test
selects one of the fixture subclasses in
``_test_scaffold_harnesses.py`` by setting the
``HARNESS_TEST_FIXTURE`` env var before the subprocess starts;
this lets the registry stay a single string while parametrizing
behavior per test.
Tests intentionally use a short tmp parent dir under ``/tmp/``
because macOS's ``AF_UNIX`` socket path limit is ~104 chars and
pytest's default ``tmp_path`` exceeds that.
Tests that need a side-channel request (``tool_result``,
``interrupt``, or ``approval`` event) concurrent with an open
streaming response use a SEPARATE httpx client built via
:func:`_make_side_client`. Reusing the manager's stream client for
the side request blocks behind the keepalive of the held
streaming connection and surfaces as a ``httpx.ReadError``
mid-stream.
"""
from __future__ import annotations
import asyncio
import contextlib
import json
import os
import shutil
import uuid
from collections.abc import AsyncIterator, Iterator
from dataclasses import dataclass
from pathlib import Path
from typing import Any
import httpx
import pytest
from omnigent.errors import ErrorCode
from omnigent.runtime.harnesses import _HARNESS_MODULES
from omnigent.runtime.harnesses._scaffold import HarnessApp, TurnContext
from omnigent.runtime.harnesses.process_manager import HarnessProcessManager
from omnigent.runtime.tool_output import MAX_TOOL_OUTPUT_BYTES
_TEST_HARNESS_NAME = "scaffold_fixture"
_TEST_HARNESS_MODULE = "tests.runtime.harnesses._test_scaffold_harnesses"
# Unit tests for cap_tool_output itself live in tests/runtime/test_tool_output.py
# (mirroring its source module). The integration test below proves the cap is
# wired into the scaffold's dispatch_tool emit path.
_TRUNCATION_MARKER = "[output truncated by omnigent:"
@dataclass
class _ParsedSSEEvent:
"""
Single parsed SSE event captured from a streaming response.
:param event: The SSE event name from the ``event:`` line,
e.g. ``"response.output_text.delta"``.
:param data: The JSON-decoded payload from the ``data:`` line.
"""
event: str
data: dict[str, Any]
async def _stream_iter(
response: httpx.Response,
) -> AsyncIterator[_ParsedSSEEvent]:
"""
Yield parsed SSE events from an open streaming response.
Splits on the blank-line terminator and re-buffers any
trailing partial frame across chunks. Useful for tests that
react to events as they arrive (e.g. PATCH after seeing
action_required, then continue consuming).
:param response: An open streaming response from
``client.stream("POST", ...)``.
:yields: Parsed events one by one.
"""
buffer = ""
async for chunk in response.aiter_text():
buffer += chunk
while "\n\n" in buffer:
frame, _, buffer = buffer.partition("\n\n")
event_line = next(
(line for line in frame.splitlines() if line.startswith("event:")),
None,
)
data_line = next(
(line for line in frame.splitlines() if line.startswith("data:")),
None,
)
if event_line is None or data_line is None:
continue
event_name = event_line[len("event:") :].strip()
data_payload = json.loads(data_line[len("data:") :].strip())
yield _ParsedSSEEvent(event=event_name, data=data_payload)
def _make_side_client(socket_path: str) -> httpx.AsyncClient:
"""
Build a SEPARATE httpx client bound to the same Unix socket.
Tests that need to issue a ``tool_result`` / ``interrupt`` /
``approval`` event concurrently with an open streaming
response use this so the second request gets its own TCP
connection — sharing one client across both ends of the
simulated round-trip can block the second request behind the
first's keepalive connection (the symptom is a httpx.ReadError
on the streaming side mid-test).
:param socket_path: Absolute Unix socket path the harness
bound, fetched via :meth:`HarnessProcessManager.socket_path`.
:returns: An :class:`httpx.AsyncClient` ready to issue
side-channel requests.
"""
return httpx.AsyncClient(
transport=httpx.AsyncHTTPTransport(uds=socket_path),
base_url="http://harness.local",
)
@pytest.mark.asyncio
async def test_build_terminal_event_waits_for_run_task_failure() -> None:
"""
Terminal synthesis must wait for the run task to fully settle.
The stream loop reaches ``_build_terminal_event`` after reading the
sentinel queued from ``run_turn``'s ``finally`` block. At that point the
task can still be in the last scheduling tick before its exception is
visible. Treating that as success emits the wrong terminal event; letting
the task exception escape makes the server surface a generic final-response
failure.
"""
async def _late_failure() -> None:
await asyncio.sleep(0)
raise RuntimeError("terminal boom")
ctx = TurnContext("resp_late_failure", asyncio.Queue(), asyncio.Event())
task = asyncio.create_task(_late_failure())
terminal = await HarnessApp()._build_terminal_event(
ctx,
model="test-agent",
run_task=task,
sequence=7,
)
assert terminal.type == "response.failed"
assert terminal.sequence_number == 7
assert terminal.response.status == "failed"
assert terminal.response.error is not None
assert terminal.response.error.message == "terminal boom"
@pytest.mark.asyncio
async def test_build_terminal_event_handles_pending_task_cancellation() -> None:
"""
A cancellation racing terminal synthesis should become response.cancelled.
This guards the cancel/terminal path: the builder should classify the
cancellation and return a terminal event instead of raising while reading
task state.
"""
task_started = asyncio.Event()
async def _wait_forever() -> None:
task_started.set()
await asyncio.sleep(60)
ctx = TurnContext("resp_cancel_race", asyncio.Queue(), asyncio.Event())
task = asyncio.create_task(_wait_forever())
await task_started.wait()
ctx.cancelled.set()
task.cancel()
terminal = await HarnessApp()._build_terminal_event(
ctx,
model="test-agent",
run_task=task,
sequence=3,
)
assert terminal.type == "response.cancelled"
assert terminal.sequence_number == 3
assert terminal.response.status == "cancelled"
@pytest.mark.asyncio
async def test_build_terminal_event_preserves_stream_task_cancellation() -> None:
"""
Cancelling terminal synthesis itself must not become response.completed.
``asyncio.shield(run_task)`` keeps the harness run task alive when the
stream task is cancelled, but the outer cancellation still needs to
propagate so teardown can cancel the run task through the normal path.
"""
task_started = asyncio.Event()
async def _wait_forever() -> None:
task_started.set()
await asyncio.sleep(60)
ctx = TurnContext("resp_stream_cancel", asyncio.Queue(), asyncio.Event())
run_task = asyncio.create_task(_wait_forever())
await task_started.wait()
terminal_task = asyncio.create_task(
HarnessApp()._build_terminal_event(
ctx,
model="test-agent",
run_task=run_task,
sequence=5,
)
)
try:
await asyncio.sleep(0)
terminal_task.cancel()
with pytest.raises(asyncio.CancelledError):
await terminal_task
assert not run_task.done()
finally:
run_task.cancel()
with contextlib.suppress(asyncio.CancelledError):
await run_task
@pytest.fixture
def register_fixture_harness() -> Iterator[None]:
"""Register the scaffold fixture harness module for the test."""
_HARNESS_MODULES[_TEST_HARNESS_NAME] = _TEST_HARNESS_MODULE
try:
yield
finally:
_HARNESS_MODULES.pop(_TEST_HARNESS_NAME, None)
@pytest.fixture
def short_tmp_parent() -> Iterator[Path]:
"""Per-test parent directory under /tmp with a short path."""
parent = Path("/tmp") / f"omni-sc-{uuid.uuid4().hex[:8]}"
parent.mkdir(mode=0o700)
try:
yield parent
finally:
shutil.rmtree(parent, ignore_errors=True)
@pytest.fixture
async def manager(
short_tmp_parent: Path,
register_fixture_harness: None,
) -> AsyncIterator[HarnessProcessManager]:
"""A started manager rooted in a short tmp dir."""
mgr = HarnessProcessManager(
idle_timeout_s=60.0,
reaper_interval_s=60.0,
tmp_parent=short_tmp_parent,
)
await mgr.start()
try:
yield mgr
finally:
await mgr.shutdown()
# ── Per-fixture-harness selectors ──────────────────────────────
#
# Each fixture below sets the ``HARNESS_TEST_FIXTURE`` env var
# the runner subprocess reads in
# ``tests/runtime/harnesses/_test_scaffold_harnesses.py:create_app``
# to pick which HarnessApp subclass to instantiate. Tests opt in
# by depending on the fixture they need (e.g.
# ``async def test_x(use_echo, manager): ...``) — declarative,
# self-documenting in the signature, impossible to forget.
@pytest.fixture
def use_echo(monkeypatch: pytest.MonkeyPatch) -> None:
"""Spawn the echo fixture harness for this test."""
monkeypatch.setenv("HARNESS_TEST_FIXTURE", "echo")
@pytest.fixture
def use_tool_dispatch(monkeypatch: pytest.MonkeyPatch) -> None:
"""Spawn the tool-dispatch fixture harness for this test."""
monkeypatch.setenv("HARNESS_TEST_FIXTURE", "tool_dispatch")
@pytest.fixture
def use_usage(monkeypatch: pytest.MonkeyPatch) -> None:
"""Spawn the usage fixture harness for this test."""
monkeypatch.setenv("HARNESS_TEST_FIXTURE", "usage")
@pytest.fixture
def use_elicitation(monkeypatch: pytest.MonkeyPatch) -> None:
"""Spawn the elicitation fixture harness for this test."""
monkeypatch.setenv("HARNESS_TEST_FIXTURE", "elicitation")
@pytest.fixture
def use_cancellable(monkeypatch: pytest.MonkeyPatch) -> None:
"""Spawn the cancellable fixture harness for this test."""
monkeypatch.setenv("HARNESS_TEST_FIXTURE", "cancellable")
@pytest.fixture
def use_injection(monkeypatch: pytest.MonkeyPatch) -> None:
"""Spawn the injection fixture harness for this test."""
monkeypatch.setenv("HARNESS_TEST_FIXTURE", "injection")
@pytest.fixture
def use_native_tool(monkeypatch: pytest.MonkeyPatch) -> None:
"""Spawn the native-tool fixture harness for this test."""
monkeypatch.setenv("HARNESS_TEST_FIXTURE", "native_tool")
@pytest.fixture
def use_fast_heartbeat(monkeypatch: pytest.MonkeyPatch) -> None:
"""Spawn the fast-heartbeat fixture harness for this test."""
monkeypatch.setenv("HARNESS_TEST_FIXTURE", "fast_heartbeat")
@pytest.fixture
def use_unclassified_exception(monkeypatch: pytest.MonkeyPatch) -> None:
"""Spawn the unclassified-exception fixture harness for this test."""
monkeypatch.setenv("HARNESS_TEST_FIXTURE", "unclassified_exception")
@pytest.fixture
def use_shutdown_tracking(monkeypatch: pytest.MonkeyPatch) -> None:
"""Spawn the shutdown-tracking fixture harness for this test."""
monkeypatch.setenv("HARNESS_TEST_FIXTURE", "shutdown_tracking")
@pytest.fixture
def use_wedged(monkeypatch: pytest.MonkeyPatch) -> None:
"""Spawn the wedged harness; 2s watchdog (scaffold reads the env at import)."""
monkeypatch.setenv("HARNESS_TEST_FIXTURE", "wedged")
monkeypatch.setenv("HARNESS_TURN_TIMEOUT_S", "2")
@pytest.fixture
def use_busy_progress(monkeypatch: pytest.MonkeyPatch) -> None:
"""Spawn the busy-progress harness; 2s idle watchdog (read at import)."""
monkeypatch.setenv("HARNESS_TEST_FIXTURE", "busy_progress")
monkeypatch.setenv("HARNESS_TURN_TIMEOUT_S", "2")
@pytest.fixture
def use_wedged_fast_heartbeat(monkeypatch: pytest.MonkeyPatch) -> None:
"""Spawn the wedged-with-fast-heartbeats harness; 2s idle watchdog."""
monkeypatch.setenv("HARNESS_TEST_FIXTURE", "wedged_fast_heartbeat")
monkeypatch.setenv("HARNESS_TURN_TIMEOUT_S", "2")
@pytest.fixture
def use_busy_absolute_cap(monkeypatch: pytest.MonkeyPatch) -> None:
"""Busy harness, 2s absolute ceiling below its ~3s runtime; idle high so only the cap fires."""
monkeypatch.setenv("HARNESS_TEST_FIXTURE", "busy_progress")
# Idle high + reset on every emit → never trips; only the absolute cap can fire.
monkeypatch.setenv("HARNESS_TURN_TIMEOUT_S", "10")
monkeypatch.setenv("HARNESS_TURN_ABSOLUTE_TIMEOUT_S", "2")
# ── Native function_call emission ──────────────────────────────
async def test_subclass_can_emit_paired_function_call_items(
use_native_tool: None,
manager: HarnessProcessManager,
) -> None:
"""A subclass can emit function_call + function_call_output directly.
Verifies the §Sub-agent representation pattern: harness-native
sub-agents (Claude Code Task, OpenAI Agents handoff) surface
as a paired function_call (status: completed) +
function_call_output emitted directly via ctx.emit, NOT going
through ctx.dispatch_tool (which is for server-dispatched
tools that need to park on a PATCH).
"""
conv_id = "conv_native"
client = await manager.get_client(conv_id, _TEST_HARNESS_NAME)
body = {"type": "message", "role": "user", "model": "test-agent", "content": []}
events: list[_ParsedSSEEvent] = []
async with client.stream("POST", f"/v1/sessions/{conv_id}/events", json=body) as response:
async for event in _stream_iter(response):
events.append(event)
output_items = [e for e in events if e.event == "response.output_item.done"]
# Exactly two output_item.done events: the function_call
# (already-completed, not action_required) and the paired
# function_call_output. AP-side resolver treats these as
# "observed" and forwards client-bound; doesn't try to
# dispatch.
assert len(output_items) == 2
fc_item = output_items[0].data["item"]
fco_item = output_items[1].data["item"]
assert fc_item["type"] == "function_call"
assert fc_item["status"] == "completed"
assert fc_item["name"] == "Task"
assert fco_item["type"] == "function_call_output"
# The pair correlates via call_id — if these don't match,
# consumers can't render them as a single sub-agent
# invocation.
assert fc_item["call_id"] == fco_item["call_id"]
assert fco_item["output"] == "subagent done"
# ── Heartbeat metadata ────────────────────────────────────────
async def test_heartbeat_carries_server_time_and_last_event_seq(
use_fast_heartbeat: None,
manager: HarnessProcessManager,
) -> None:
"""
The streaming wrapper stamps ``server_time`` and
``last_event_seq`` on every emitted ``response.heartbeat``
event per contract §Heartbeats.
Uses the ``fast_heartbeat`` fixture (overrides
``_heartbeat_loop`` to fire every 0.2s, sleeps 0.6s in
``run_turn``) so at least one heartbeat is captured during a
sub-second turn — production cadence is 15s which is too long
for a unit test.
What breaks if this fails: consumers can't detect clock drift
(no server_time) or dropped events (no last_event_seq),
regressing the contract's dead-detection promise. Step 5i's
"AP raises retryable ExecutorError after ~50s of silence"
behavior would still work without these fields, but richer
diagnostics depend on them.
"""
conv_id = "conv_heartbeat"
client = await manager.get_client(conv_id, _TEST_HARNESS_NAME)
body = {"type": "message", "role": "user", "model": "test-agent", "content": []}
events: list[_ParsedSSEEvent] = []
async with client.stream("POST", f"/v1/sessions/{conv_id}/events", json=body) as response:
async for event in _stream_iter(response):
events.append(event)
heartbeats = [e for e in events if e.event == "response.heartbeat"]
# At least one heartbeat must fire during the 0.6s turn at
# 0.2s cadence. Zero would mean either the heartbeat task
# wasn't started or the turn returned before the first
# interval — either way, regression in scaffold lifecycle.
assert len(heartbeats) >= 1, (
f"expected ≥1 heartbeat in a 0.6s turn at 0.2s cadence, "
f"got {len(heartbeats)}. All event types received: "
f"{[e.event for e in events]}"
)
# Find the first heartbeat that follows a non-heartbeat event;
# the wrapper's ``last_event_seq`` should equal that prior
# event's sequence number. The fast_heartbeat fixture emits
# ``warmup`` text first so this is guaranteed to exist.
warmup_index = next(i for i, e in enumerate(events) if e.event == "response.output_text.delta")
warmup_seq = events[warmup_index].data["sequence_number"]
later_heartbeat = next(
e for i, e in enumerate(events) if i > warmup_index and e.event == "response.heartbeat"
)
# server_time: ISO 8601 UTC string with trailing 'Z'.
# Wrapper-stamped at yield time, not heartbeat-construction
# time, so format must match _utc_now_iso's output.
server_time = later_heartbeat.data.get("server_time")
assert server_time is not None, (
"heartbeat after warmup is missing server_time — wrapper "
"didn't stamp it (regression in _stream_response_body)"
)
assert server_time.endswith("Z"), f"server_time {server_time!r} not ISO-UTC"
# Parsing back proves it's a real timestamp, not a placeholder
# string. A future change that hardcodes "now" or returns a
# dataclass repr would fail this.
import datetime
parsed = datetime.datetime.strptime(server_time, "%Y-%m-%dT%H:%M:%SZ")
assert parsed.year >= 2026
# last_event_seq: the sequence_number of the warmup event
# (the most recent non-heartbeat at the moment the heartbeat
# was yielded). If this is None, the wrapper didn't track
# the prior event; if it's wrong, the wrapper used the
# heartbeat's own seq (off-by-one bug).
last_event_seq = later_heartbeat.data.get("last_event_seq")
assert last_event_seq == warmup_seq, (
f"heartbeat last_event_seq={last_event_seq}, expected "
f"warmup's seq={warmup_seq}. If equal to the heartbeat's "
f"own sequence_number, the wrapper stamped after "
f"incrementing instead of before."
)
# ── Unclassified-exception robustness ────────────────────────
async def test_run_turn_raise_synthesizes_response_failed_event(
use_unclassified_exception: None,
manager: HarnessProcessManager,
) -> None:
"""
When ``run_turn`` raises an unclassified exception, the SSE
stream MUST end with a synthesized ``response.failed`` event,
not a bare connection close.
Direct repro of the 2026-04-29 user-reported
``[llm] ReadError`` symptom. Pre-fix, an exception in
``run_turn`` (or any other point in the streaming loop) caused
the response stream to close mid-flight without emitting the
terminal event. AP-side ``the harness HTTP client.run_turn``
saw ``httpx.ReadError`` reading a closed stream → workflow
catch-all rendered the unhelpful ``[llm] ReadError`` to the
REPL.
Post-fix, the scaffold's last-line-of-defense except clause
in ``_stream_turn`` synthesizes a ``response.failed`` carrying
``code="RuntimeError"`` (from the exception class name) and
yields it before the generator exits. AP-side translates the
failed event to a normal ``ExecutorError`` and the REPL
renders ``[llm] RuntimeError: simulated mid-turn failure``.
Failure mode this catches: any future regression that drops
the synthesized failure path (e.g., a refactor that removes
the except clause, or an exception in
``_synthesize_failed_event`` itself that propagates).
"""
conv_id = "conv_raise"
client = await manager.get_client(conv_id, _TEST_HARNESS_NAME)
body = {"type": "message", "role": "user", "model": "test-agent", "content": []}
events: list[_ParsedSSEEvent] = []
async with client.stream("POST", f"/v1/sessions/{conv_id}/events", json=body) as response:
async for event in _stream_iter(response):
events.append(event)
event_types = [e.event for e in events]
assert "response.failed" in event_types, (
f"Expected a terminal response.failed event after run_turn "
f"raised mid-flight; got {event_types!r}. If the list ends "
f"with anything other than response.failed (or "
f"response.completed when the harness recovered), the "
f"scaffold's last-line-of-defense terminal-event guarantee "
f"regressed — AP-side will see httpx.ReadError instead of "
f"a meaningful semantic code."
)
# The terminal event must be the LAST event on the stream;
# any frames after it would be dead frames the consumer
# would either ignore or get confused by.
assert event_types[-1] == "response.failed", (
f"response.failed must be the final event; got "
f"{event_types!r}. Trailing events after the terminal "
f"break the contract."
)
failed_data = events[-1].data
assert failed_data["response"]["status"] == "failed", (
f"Synthesized terminal must carry status='failed', got "
f"{failed_data['response']['status']!r}."
)
error = failed_data["response"]["error"]
assert error is not None, (
f"Synthesized response.failed must populate error; got "
f"{failed_data!r}. Without the error envelope, AP's "
f"retry-classification has nothing to read and falls back "
f"to permanent failure."
)
# Default base-class error_detail uses ``type(exc).__name__``
# as the code. The fixture raises ``RuntimeError`` so we
# expect that exact string.
assert error["code"] == "RuntimeError", (
f"Expected error.code='RuntimeError' (from "
f"type(exception).__name__); got {error['code']!r}. If "
f"the code is something else, the synthesizer is using a "
f"different classifier than _build_error_detail."
)
assert "simulated mid-turn failure" in error["message"], (
f"Error message should propagate str(exception); got "
f"{error['message']!r}. Without the original message in "
f"the envelope, operators have to dig through harness "
f"logs to identify the failure cause."
)
# The warmup delta the fixture emitted before raising MUST
# have made it through too. Proves the synthesized failure
# path doesn't suppress prior in-flight events; it just adds
# the missing terminal.
assert "response.output_text.delta" in event_types, (
f"Expected the warmup delta to land before the synthesized "
f"failure; got {event_types!r}. If the delta is missing, "
f"the except clause is somehow dropping prior queue events "
f"on the way out."
)
async def test_per_turn_watchdog_fails_wedged_turn(
use_wedged: None,
manager: HarnessProcessManager,
) -> None:
"""
A wedged ``run_turn`` must terminate with ``response.failed`` once
the watchdog fires, not stream heartbeats forever.
The test completing at all is half the assertion: a regressed
watchdog would hang the stream drain to the suite pytest-timeout.
"""
conv_id = "conv_wedged"
client = await manager.get_client(conv_id, _TEST_HARNESS_NAME)
body = {"type": "message", "role": "user", "model": "test-agent", "content": []}
events: list[_ParsedSSEEvent] = []
# Above the 2s watchdog, below the suite timeout: a regression fails here.
async with asyncio.timeout(30):
async with client.stream("POST", f"/v1/sessions/{conv_id}/events", json=body) as response:
async for event in _stream_iter(response):
events.append(event)
event_types = [e.event for e in events]
assert event_types[-1] == "response.failed", (
f"Wedged turn must terminate with response.failed once the "
f"watchdog fires; got {event_types!r}."
)
error = events[-1].data["response"]["error"]
assert error is not None and "watchdog" in error["message"], (
f"Watchdog failure must carry an explanatory error message; got {error!r}."
)
async def test_per_turn_watchdog_allows_active_turn_past_window(
use_busy_progress: None,
manager: HarnessProcessManager,
) -> None:
"""
A turn that keeps emitting progress must complete even when its
total duration exceeds the watchdog window.
The watchdog is an *idle* timeout: each non-heartbeat event resets
the deadline, so an orchestrator turn that legitimately runs for
minutes (tests + build + many tool calls) isn't killed mid-turn. The
``busy_progress`` harness emits a delta every 0.1s for ~3s against a
2s watchdog. This test FAILS on the old fixed-cumulative watchdog
(which fired at 2s → ``response.failed``), which is exactly the
nessie bug being fixed.
"""
conv_id = "conv_busy_progress"
client = await manager.get_client(conv_id, _TEST_HARNESS_NAME)
body = {"type": "message", "role": "user", "model": "test-agent", "content": []}
events: list[_ParsedSSEEvent] = []
# The ~3s turn must outlast the 2s watchdog; 20s caps a regression
# (cumulative watchdog still fails fast at 2s, so this is only a
# guard against an unrelated hang).
async with asyncio.timeout(20):
async with client.stream("POST", f"/v1/sessions/{conv_id}/events", json=body) as response:
async for event in _stream_iter(response):
events.append(event)
event_types = [e.event for e in events]
# response.completed proves the idle watchdog let the active turn run
# past its 2s window. response.failed here means the cumulative
# watchdog fired mid-stream (the regression / unfixed behavior).
assert event_types[-1] == "response.completed", (
f"An actively-streaming turn must complete, not be killed by the "
f"watchdog; got terminal {event_types[-1]!r} (full: {event_types!r})."
)
# The turn ran its full cadence: ~30 deltas. A much smaller count
# would mean the stream was cut short before the turn finished.
delta_count = sum(1 for t in event_types if t == "response.output_text.delta")
assert delta_count >= 25, (
f"Expected ~30 progress deltas from the full cadence; got "
f"{delta_count}. A low count means the turn was truncated."
)
async def test_per_turn_watchdog_ignores_heartbeats(
use_wedged_fast_heartbeat: None,
manager: HarnessProcessManager,
) -> None:
"""
Heartbeats must NOT reset the idle watchdog.
``response.heartbeat`` is keep-alive, not progress. If it reset the
deadline, a wedged turn emitting heartbeats every 15s would never
fail. The ``wedged_fast_heartbeat`` harness hangs forever while
firing a 0.2s heartbeat against a 2s watchdog (~10 heartbeats inside
the window); the watchdog must still fire and terminate the turn.
"""
conv_id = "conv_wedged_fast_heartbeat"
client = await manager.get_client(conv_id, _TEST_HARNESS_NAME)
body = {"type": "message", "role": "user", "model": "test-agent", "content": []}
events: list[_ParsedSSEEvent] = []
# Above the 2s watchdog, below the suite timeout. If heartbeats wrongly
# reset the deadline the stream never terminates and this timeout trips
# — a clean failure rather than a silent pass.
async with asyncio.timeout(20):
async with client.stream("POST", f"/v1/sessions/{conv_id}/events", json=body) as response:
async for event in _stream_iter(response):
events.append(event)
event_types = [e.event for e in events]
# Heartbeats fired throughout, but the wedged turn made no real
# progress — the idle watchdog must ignore the heartbeats and fail.
assert event_types[-1] == "response.failed", (
f"Wedged turn must fail despite ongoing heartbeats; got "
f"{event_types[-1]!r}. If response.completed/no-terminal, "
f"heartbeats wrongly reset the idle watchdog."
)
# At least one heartbeat must have actually fired inside the window —
# otherwise this test would pass even if heartbeat-reset were broken.
assert "response.heartbeat" in event_types, (
f"Expected heartbeats during the wedged window; got {event_types!r}. "
f"Without them this test wouldn't exercise heartbeat-vs-watchdog."
)
error = events[-1].data["response"]["error"]
assert error is not None and "watchdog" in error["message"], (
f"Watchdog failure must carry an explanatory error message; got {error!r}."
)
async def test_per_turn_absolute_watchdog_caps_runaway_active_turn(
use_busy_absolute_cap: None,
manager: HarnessProcessManager,
) -> None:
"""
The absolute ceiling fails a turn that keeps emitting but never ends.
The idle watchdog never trips an actively-streaming turn (each emit
resets it), so a runaway-but-active loop needs the absolute backstop.
The ``busy_progress`` harness emits every 0.1s for ~3s; with idle=10s
(reset on every emit → never trips) and absolute=2s, the absolute cap
must fire ~1s before the harness's natural completion.
"""
conv_id = "conv_busy_absolute_cap"
client = await manager.get_client(conv_id, _TEST_HARNESS_NAME)
body = {"type": "message", "role": "user", "model": "test-agent", "content": []}
events: list[_ParsedSSEEvent] = []
# Above the 2s cap, below the ~3s natural completion's headroom and the
# suite timeout; 20s only guards an unrelated hang.
async with asyncio.timeout(20):
async with client.stream("POST", f"/v1/sessions/{conv_id}/events", json=body) as response:
async for event in _stream_iter(response):
events.append(event)
event_types = [e.event for e in events]
# response.failed (not completed) proves the absolute ceiling fired
# before the harness's ~3s natural completion. response.completed would
# mean the cap didn't engage.
assert event_types[-1] == "response.failed", (
f"Absolute ceiling must fail a runaway-but-active turn; got "
f"{event_types[-1]!r} (full: {event_types!r})."
)
# The error must name the ABSOLUTE watchdog, not the idle one — proves
# the right ceiling tripped (idle was set high and reset by every emit).
error = events[-1].data["response"]["error"]
assert error is not None and "absolute" in error["message"], (
f"Failure must come from the absolute ceiling; got {error!r}. An "
f"'idle'-only message would mean the idle watchdog fired instead."
)
# Deltas streamed before the cap fired (it was actively emitting, not
# wedged) — distinguishes this from the idle/wedged path.
assert "response.output_text.delta" in event_types, (
f"Expected progress deltas before the absolute cap; got {event_types!r}."
)
# ── Health probe ──────────────────────────────────────────────
async def test_health_endpoint_returns_status_ok(
use_echo: None,
manager: HarnessProcessManager,
) -> None:
"""GET /health returns {"status": "ok"} at root, NOT /v1.
Per the design's API table: the health probe is the one
endpoint mounted at root rather than under /v1, matching AP's
own /health route.
"""
client = await manager.get_client("conv_health", _TEST_HARNESS_NAME)
resp = await client.get("/health")
assert resp.status_code == 200
assert resp.json() == {"status": "ok"}
# /v1/health should NOT exist — that would be a misconfiguration.
bad_resp = await client.get("/v1/health")
assert bad_resp.status_code == 404
async def test_scaffold_response_completed_preserves_context_tokens(
use_usage: None,
manager: HarnessProcessManager,
) -> None:
"""Terminal usage preserves ``context_tokens`` from the inner executor.
Billing totals (``total_tokens`` summed across sub-calls) are
split from context-fill totals (``context_tokens`` = last
sub-call total for multi-call executors like openai-agents).
The harness scaffold converts the inner executor's usage dict
into the wire ``Usage`` model before emitting
``response.completed``. If that conversion drops
``context_tokens``, the REPL/web context ring falls back to the
inflated billing sum and tool-call turns look like they compacted.
"""
conv_id = "conv_usage"
client = await manager.get_client(conv_id, _TEST_HARNESS_NAME)
body = {
"type": "message",
"role": "user",
"model": "test-agent",
"content": [{"type": "input_text", "text": "hi"}],
}
events: list[_ParsedSSEEvent] = []
async with client.stream("POST", f"/v1/sessions/{conv_id}/events", json=body) as response:
async for event in _stream_iter(response):
events.append(event)
completed = next(e for e in events if e.event == "response.completed")
usage = completed.data["response"]["usage"]
assert usage["total_tokens"] == 10_800
assert usage["context_tokens"] == 5_700, (
"context_tokens must survive scaffold serialization so clients can "
"render context fill from the last sub-call instead of the billing sum."
)
async def test_scaffold_response_completed_preserves_cache_tokens(
use_usage: None,
manager: HarnessProcessManager,
) -> None:
"""Terminal usage preserves the cache-read / cache-creation counts.
The harness scaffold converts the inner executor's usage dict
into the wire :class:`Usage` model before emitting
``response.completed``. Anthropic-style executors (e.g.
claude-sdk) report ``input_tokens`` as the *non-cached* portion
plus separate ``cache_read_input_tokens`` /
``cache_creation_input_tokens`` counts. If that conversion drops
the cache counts (the bug this guards), the server-side cost path
never sees them and ``total_cost_usd`` silently reverts to the
cache-blind ``input*price + output*price`` formula — a large,
systematic undercount in an agent loop where the system prompt
and history are cached almost every turn.
"""
conv_id = "conv_usage_cache"
client = await manager.get_client(conv_id, _TEST_HARNESS_NAME)
body = {
"type": "message",
"role": "user",
"model": "test-agent",
"content": [{"type": "input_text", "text": "hi"}],
}
events: list[_ParsedSSEEvent] = []
async with client.stream("POST", f"/v1/sessions/{conv_id}/events", json=body) as response:
async for event in _stream_iter(response):
events.append(event)
completed = next(e for e in events if e.event == "response.completed")
usage = completed.data["response"]["usage"]
# The fixture's provider_usage carries 8_000 cache-read + 2_000
# cache-creation tokens. A missing key (KeyError) or a 0 would mean
# the scaffold's Usage construction dropped the cache breakdown,
# which is exactly what made the cache-aware cost computation inert.
assert usage["cache_read_input_tokens"] == 8_000, (
"cache_read_input_tokens must survive scaffold serialization so "
"the server cost path can price cache hits at the reduced rate."
)
assert usage["cache_creation_input_tokens"] == 2_000, (
"cache_creation_input_tokens must survive scaffold serialization so "
"the server cost path can price cache writes at the premium rate."
)
# ── Session-keyed surface (POST /events) ──────────────────────
#
# Exercises the discriminated
# /v1/sessions/{conversation_id}/events endpoint — the harness
# scaffold's only downward-event surface after the legacy
# /v1/responses routes were dropped. Each test verifies that a
# downward event variant (message / interrupt / tool_result /
# approval) dispatches into the same in-flight machinery
# (TurnContext / Future registries). Endpoint shape matches
# ``designs/session_rearchitecture.md`` §3 "Endpoints" /
# "Event types and direction".
async def test_session_message_event_streams_initial_then_text_then_completes(
use_echo: None,
manager: HarnessProcessManager,
) -> None:
"""A ``message`` event starts a turn and streams the standard envelope.
Verifies the session-keyed equivalent of
``test_echo_streams_initial_then_text_then_completes``: the
initial envelope, the subclass's text emit, and the terminal
``response.completed`` all flow through identically. If the
discriminator dispatch regressed (e.g. silently no-op'd on
``message``), the streaming shape would diverge.
"""
conv_id = "conv_session_echo"
client = await manager.get_client(conv_id, _TEST_HARNESS_NAME)
body = {
"type": "message",
"role": "user",
"model": "test-agent",
"content": [{"type": "input_text", "text": "hi"}],
}
events: list[_ParsedSSEEvent] = []
async with client.stream("POST", f"/v1/sessions/{conv_id}/events", json=body) as response:
async for event in _stream_iter(response):
events.append(event)
assert [e.event for e in events] == [
"response.created",
"response.in_progress",
"response.output_text.delta",
"response.completed",
]
# The same allocation rule applies — turn id is the harness's
# response_id from the underlying _post_responses.
turn_id = events[0].data["response"]["id"]
assert turn_id.startswith("resp_")
assert events[-1].data["response"]["status"] == "completed"
# Echo payload survives — proves the body actually reached
# ``run_turn`` through the new route, not just an empty
# delegation that lost the request.
assert "hi" in events[2].data["delta"]
async def test_session_events_404s_on_conversation_id_mismatch(
use_echo: None,
manager: HarnessProcessManager,
) -> None:
"""``POST /v1/sessions/<wrong>/events`` returns 404, not 200.
The harness scaffold is per-conversation. A request addressed
to the wrong conversation indicates the caller routed to the
wrong subprocess — fail loud rather than silently start a
turn under a mismatched id (which would produce a turn that
Omnigent could never correlate).
"""
conv_id = "conv_session_mismatch"
client = await manager.get_client(conv_id, _TEST_HARNESS_NAME)
resp = await client.post(
"/v1/sessions/conv_does_not_match/events",
json={
"type": "message",
"role": "user",
"model": "test-agent",
"content": [],
},
)
assert resp.status_code == 404
body = resp.json()
# Error envelope shape matches OmnigentError's serialization
# (see _handle_omnigent_error). Without this, AP-side error
# handling would have to special-case session 404s.
assert "error" in body
assert body["error"]["code"] == ErrorCode.NOT_FOUND
async def test_session_tool_result_event_resolves_parked_dispatch(
use_tool_dispatch: None,
manager: HarnessProcessManager,
) -> None:
"""A ``tool_result`` event resolves the in-flight turn's parked Future.
Starts a turn via a ``message`` event, observes
``action_required``, then POSTs a ``tool_result`` event to
the same ``/events`` endpoint with the matching ``call_id``.
Verifies the parked Future resolves and the subclass receives
the output — proves the discriminated surface dispatches
into the right entry of the in-flight registry.
"""
conv_id = "conv_session_tool"
stream_client = await manager.get_client(conv_id, _TEST_HARNESS_NAME)
side_client = _make_side_client(str(manager.socket_path(conv_id)))
events: list[_ParsedSSEEvent] = []
start_body = {
"type": "message",
"role": "user",
"model": "test-agent",
"content": [],
}
try:
async with stream_client.stream(
"POST",
f"/v1/sessions/{conv_id}/events",
json=start_body,
) as response:
patched = False
async for event in _stream_iter(response):
events.append(event)
if (
not patched
and event.event == "response.output_item.done"
and event.data["item"].get("status") == "action_required"
):
patch_resp = await side_client.post(
f"/v1/sessions/{conv_id}/events",
json={
"type": "tool_result",
"call_id": "call_test_1",
"output": "ok",
},
)
assert patch_resp.status_code == 204
patched = True
# Subclass received the event-delivered output through the
# session-keyed surface — proves shared in-flight state.
deltas = [e.data["delta"] for e in events if e.event == "response.output_text.delta"]
assert any("got:ok" in d for d in deltas), (
f"expected the tool_result-delivered output to reach run_turn; got deltas={deltas!r}"
)
# A small output flows through the cap untouched — guards against an
# over-eager cap that truncates everything, which the large-input cap
# test alone would not catch.
streamed = [
e.data["item"]["output"]
for e in events
if e.event == "response.output_item.done"
and e.data["item"].get("type") == "function_call_output"
]
assert streamed == ["ok"]
assert _TRUNCATION_MARKER not in streamed[0]
assert events[-1].event == "response.completed"
finally:
await side_client.aclose()
async def test_session_tool_result_over_cap_streams_truncated_but_returns_full(
use_tool_dispatch: None,
manager: HarnessProcessManager,
) -> None:
"""A multi-MB tool_result is streamed truncated, but run_turn gets it full.
Proves the output cap end-to-end: the function_call_output the scaffold
streams + persists is bounded (so the client never receives one giant SSE
frame), while ``ctx.dispatch_tool`` still returns the FULL result to the
harness's ``run_turn`` — the live model's view is unaffected.
``_ToolDispatchHarness`` echoes the returned value as a ``got:<result>``
text delta, so the delta length proves the return was not truncated.
"""
conv_id = "conv_session_tool_cap"
stream_client = await manager.get_client(conv_id, _TEST_HARNESS_NAME)
side_client = _make_side_client(str(manager.socket_path(conv_id)))
# 2 MiB — comfortably over the 1 MiB cap so truncation is unambiguous.
big_output = "x" * (2 * 1024 * 1024)
start_body = {"type": "message", "role": "user", "model": "test-agent", "content": []}
events: list[_ParsedSSEEvent] = []
try:
async with stream_client.stream(
"POST",
f"/v1/sessions/{conv_id}/events",
json=start_body,
) as response:
patched = False
async for event in _stream_iter(response):
events.append(event)
if (
not patched
and event.event == "response.output_item.done"
and event.data["item"].get("status") == "action_required"
):
patch_resp = await side_client.post(
f"/v1/sessions/{conv_id}/events",
json={
"type": "tool_result",
"call_id": "call_test_1",
"output": big_output,
},
)
assert patch_resp.status_code == 204
patched = True
assert events[-1].event == "response.completed"
# The streamed function_call_output is capped: it carries the truncation
# notice and is far smaller than the 2 MiB input. Without the cap wiring
# this would be the full 2 MiB payload — one giant client frame.
streamed_outputs = [
e.data["item"]["output"]
for e in events
if e.event == "response.output_item.done"
and e.data["item"].get("type") == "function_call_output"
]
assert len(streamed_outputs) == 1, (
f"expected exactly one function_call_output; got {len(streamed_outputs)}"
)
streamed = streamed_outputs[0]
assert _TRUNCATION_MARKER in streamed, "streamed output was not truncated (cap not wired)"
# Capped size = kept bytes + the short notice (< ~100 bytes), never the
# multi-MB original. If this fails, truncation produced the wrong size.
assert len(streamed.encode("utf-8")) <= MAX_TOOL_OUTPUT_BYTES + 200
assert len(streamed.encode("utf-8")) < len(big_output)
# ...but run_turn received the FULL result via dispatch_tool's return:
# the echoed got:<result> delta is the whole 2 MiB. This is the load-
# bearing guarantee — capping the mirror must not shrink the model's view.
got_deltas = [
e.data["delta"]
for e in events
if e.event == "response.output_text.delta" and e.data["delta"].startswith("got:")
]
assert len(got_deltas) == 1, f"expected one got: delta; got {len(got_deltas)}"
assert got_deltas[0] == f"got:{big_output}", "dispatch_tool return was truncated"
finally:
await side_client.aclose()
async def test_session_tool_result_event_404s_on_conversation_id_mismatch(
use_tool_dispatch: None,
manager: HarnessProcessManager,
) -> None:
"""A ``tool_result`` posted to a wrong conversation_id 404s.
Verifies the conversation_id check fires before any
tool-result resolution: a stray event addressed to the wrong
conversation must NOT silently succeed. Stale ``call_id``
entries within the correct conversation are allowed to
no-op (loose-by-default), but a mismatched conversation_id
must fail loud.
"""
conv_id = "conv_session_patch_mismatch"
stream_client = await manager.get_client(conv_id, _TEST_HARNESS_NAME)
side_client = _make_side_client(str(manager.socket_path(conv_id)))
start_body = {
"type": "message",
"role": "user",
"model": "test-agent",
"content": [],
}
try:
async with stream_client.stream(
"POST",
f"/v1/sessions/{conv_id}/events",
json=start_body,
) as response:
handled = False
async for event in _stream_iter(response):
if (
not handled
and event.event == "response.output_item.done"
and event.data["item"].get("status") == "action_required"
):
bad = await side_client.post(
"/v1/sessions/conv_wrong/events",
json={
"type": "tool_result",
"call_id": "call_test_1",
"output": "ok",
},
)
assert bad.status_code == 404
# Resolve via the correctly-addressed event so
# the stream finalizes cleanly.
good = await side_client.post(
f"/v1/sessions/{conv_id}/events",
json={
"type": "tool_result",
"call_id": "call_test_1",
"output": "ok",
},
)
assert good.status_code == 204
handled = True
assert handled, "action_required never arrived"
finally:
await side_client.aclose()
async def test_session_interrupt_event_cancels_in_flight_turn(
use_cancellable: None,
manager: HarnessProcessManager,
) -> None:
"""An ``interrupt`` event cancels the in-flight turn.
Verifies that an interrupt sets ``ctx.cancelled``. The
fixture subclass observes it and emits ``"cancelled"`` before
returning — terminal event is ``response.cancelled``.
"""
conv_id = "conv_session_cancel"
stream_client = await manager.get_client(conv_id, _TEST_HARNESS_NAME)
side_client = _make_side_client(str(manager.socket_path(conv_id)))
events: list[_ParsedSSEEvent] = []
start_body = {
"type": "message",
"role": "user",
"model": "test-agent",
"content": [],
}
try:
async with stream_client.stream(
"POST",
f"/v1/sessions/{conv_id}/events",
json=start_body,
) as response:
cancelled = False
async for event in _stream_iter(response):
events.append(event)
if not cancelled and event.event == "response.in_progress":
cancel_resp = await side_client.post(
f"/v1/sessions/{conv_id}/events",
json={"type": "interrupt"},
)
assert cancel_resp.status_code == 204
cancelled = True
text_deltas = [e for e in events if e.event == "response.output_text.delta"]
assert text_deltas, "expected a delta from the fixture before terminate"
assert text_deltas[0].data["delta"] == "cancelled"
assert events[-1].event == "response.cancelled"
finally:
await side_client.aclose()
async def test_session_interrupt_event_404s_on_conversation_id_mismatch(
use_echo: None,
manager: HarnessProcessManager,
) -> None:
"""An ``interrupt`` event with a wrong conversation_id 404s.
The interrupt path is one of the most likely places for a
stale routing decision to surface (cancel can arrive after
a turn ended); the conversation_id check must fire even when
no turn is in flight.
"""
conv_id = "conv_session_cancel_mismatch"
client = await manager.get_client(conv_id, _TEST_HARNESS_NAME)
resp = await client.post(
"/v1/sessions/conv_other/events",
json={"type": "interrupt"},
)
assert resp.status_code == 404
assert resp.json()["error"]["code"] == ErrorCode.NOT_FOUND
async def test_session_interrupt_event_404s_when_no_turn_in_flight(
use_echo: None,
manager: HarnessProcessManager,
) -> None:
"""Correct conversation_id + no in-flight turn returns 404.
The harness has no concept of an idle interrupt — if no turn
is in flight, there is nothing to cancel. Fail loud rather
than silently no-op so a stray interrupt from Omnigent after a turn
already ended surfaces as an obvious operator error.
"""
conv_id = "conv_session_interrupt_idle"
client = await manager.get_client(conv_id, _TEST_HARNESS_NAME)
resp = await client.post(
f"/v1/sessions/{conv_id}/events",
json={"type": "interrupt"},
)
assert resp.status_code == 404
async def test_session_approval_event_resolves_elicitation(
use_elicitation: None,
manager: HarnessProcessManager,
) -> None:
"""An ``approval`` event resolves the parked elicitation Future.
Verifies the request/reply correlation: the elicitation
reply lands on the matching in-flight context's parked
Future via ``POST /v1/sessions/{id}/events`` with
``type=approval``.
"""
conv_id = "conv_session_elicit"
stream_client = await manager.get_client(conv_id, _TEST_HARNESS_NAME)
side_client = _make_side_client(str(manager.socket_path(conv_id)))
events: list[_ParsedSSEEvent] = []
start_body = {
"type": "message",
"role": "user",
"model": "test-agent",
"content": [],
}
try:
async with stream_client.stream(
"POST",
f"/v1/sessions/{conv_id}/events",
json=start_body,
) as response:
replied = False
async for event in _stream_iter(response):
events.append(event)
if not replied and event.event == "response.elicitation_request":
reply = await side_client.post(
f"/v1/sessions/{conv_id}/events",
json={
"type": "approval",
"elicitation_id": "elicit_test_1",
"action": "accept",
},
)
assert reply.status_code == 204
replied = True
text_deltas = [e for e in events if e.event == "response.output_text.delta"]
assert len(text_deltas) == 1
assert text_deltas[0].data["delta"] == "action:accept"
finally:
await side_client.aclose()
async def test_session_approval_event_404s_on_conversation_id_mismatch(
use_elicitation: None,
manager: HarnessProcessManager,
) -> None:
"""An ``approval`` event with a wrong conversation_id 404s.
The conversation_id check must fire even when the
elicitation_id is real (i.e. would resolve the Future on the
correct surface). Otherwise a misrouted reply could silently
resolve another conversation's elicitation in a multi-tenant
misconfiguration.
"""
conv_id = "conv_session_elicit_mismatch"
stream_client = await manager.get_client(conv_id, _TEST_HARNESS_NAME)
side_client = _make_side_client(str(manager.socket_path(conv_id)))
start_body = {
"type": "message",
"role": "user",
"model": "test-agent",
"content": [],
}
try:
async with stream_client.stream(
"POST",
f"/v1/sessions/{conv_id}/events",
json=start_body,
) as response:
handled = False
async for event in _stream_iter(response):
if not handled and event.event == "response.elicitation_request":
bad = await side_client.post(
"/v1/sessions/conv_other/events",
json={
"type": "approval",
"elicitation_id": "elicit_test_1",
"action": "decline",
},
)
assert bad.status_code == 404
# Unblock the parked Future so the stream
# completes cleanly.
good = await side_client.post(
f"/v1/sessions/{conv_id}/events",
json={
"type": "approval",
"elicitation_id": "elicit_test_1",
"action": "decline",
},
)
assert good.status_code == 204
handled = True
assert handled, "elicitation never arrived"
finally:
await side_client.aclose()
async def test_session_message_event_without_previous_response_id_injects_active_turn(
use_injection: None,
manager: HarnessProcessManager,
) -> None:
"""A second sessions-native message injects into the active turn.
Verifies the sessions-native steering path where the caller
does not know or provide ``previous_response_id``. While a turn
is actively streaming, a second ``message`` event for the same
conversation should be treated as steering and return 204, not
start a competing streaming response. If this regresses, REPL
mid-turn steering opens a second turn that races the original
turn instead of reaching ``ctx.next_injection``.
"""
conv_id = "conv_session_native_inject"
stream_client = await manager.get_client(conv_id, _TEST_HARNESS_NAME)
side_client = _make_side_client(str(manager.socket_path(conv_id)))
events: list[_ParsedSSEEvent] = []
start_body = {
"type": "message",
"role": "user",
"model": "test-agent",
"content": [],
}
try:
async with stream_client.stream(
"POST",
f"/v1/sessions/{conv_id}/events",
json=start_body,
) as response:
injected = False
async for event in _stream_iter(response):
events.append(event)
if (
not injected
and event.event == "response.output_text.delta"
and event.data.get("delta") == "ready:"
):
inject_resp = await side_client.post(
f"/v1/sessions/{conv_id}/events",
json={
"type": "message",
"role": "user",
"model": "test-agent",
"content": [
{"type": "input_text", "text": "x"},
{"type": "input_text", "text": "y"},
],
},
)
assert inject_resp.status_code == 204
injected = True
text_deltas = [e.data["delta"] for e in events if e.event == "response.output_text.delta"]
assert text_deltas == ["ready:", "got_2"]
finally:
await side_client.aclose()
async def test_interrupt_then_message_without_prev_id_starts_fresh_turn(
use_cancellable: None,
manager: HarnessProcessManager,
) -> None:
"""After an interrupt, a follow-up (no previous_response_id) starts a FRESH turn.
Production bug: the follow-up was injected into the just-cancelled turn —
whose session is mid-teardown — resuming the abandoned generation and
leaving the agent one message behind. ``_handle_interrupt_event`` now clears
``_active_turn_ctx``, so a follow-up with no ``previous_response_id`` must
start a new turn (HTTP 200 stream with its own ``response.created``), NOT a
204 in-band injection into the dying turn.
"""
conv_id = "conv_interrupt_fresh"
stream_client = await manager.get_client(conv_id, _TEST_HARNESS_NAME)
side_client = _make_side_client(str(manager.socket_path(conv_id)))
followup_client = _make_side_client(str(manager.socket_path(conv_id)))
start_body = {"type": "message", "role": "user", "model": "test-agent", "content": []}
followup_body = {
"type": "message",
"role": "user",
"model": "test-agent",
"content": [{"type": "input_text", "text": "new question"}],
}
followup_status: int | None = None
followup_events: list[_ParsedSSEEvent] = []
try:
async with stream_client.stream(
"POST", f"/v1/sessions/{conv_id}/events", json=start_body
) as response:
interrupted = False
async for event in _stream_iter(response):
if not interrupted and event.event == "response.in_progress":
cancel_resp = await side_client.post(
f"/v1/sessions/{conv_id}/events", json={"type": "interrupt"}
)
assert cancel_resp.status_code == 204
interrupted = True
# Send the follow-up while the cancelled turn is still
# tearing down — it must start a fresh turn, not inject.
async with followup_client.stream(
"POST", f"/v1/sessions/{conv_id}/events", json=followup_body
) as r2:
followup_status = r2.status_code
if r2.status_code == 200:
async for e2 in _stream_iter(r2):
followup_events.append(e2)
if e2.event == "response.created":
break
break
# 200 (fresh streaming turn), NOT 204 (in-band injection into the
# cancelled turn — the bug). If 204, the follow-up resumed the dying
# session and the agent would answer one message behind.
assert followup_status == 200, (
f"follow-up after interrupt must start a fresh turn (200), got {followup_status}"
)
assert any(e.event == "response.created" for e in followup_events), (
"the fresh turn must emit its own response.created"
)
finally:
await side_client.aclose()
await followup_client.aclose()
async def test_interrupt_then_message_with_prev_id_starts_fresh_turn(
use_cancellable: None,
manager: HarnessProcessManager,
) -> None:
"""A follow-up that steers via the interrupted turn's id also starts fresh.
A client may follow up using the in-progress response_id. After Stop, that
id points at a cancelled turn whose session is mid-teardown — injecting into
it (then dropping the injection) loses the message. The explicit
previous_response_id branch must also skip a cancelled turn and start a fresh
turn (HTTP 200 with a NEW response_id), not return 204.
"""
conv_id = "conv_interrupt_fresh_previd"
stream_client = await manager.get_client(conv_id, _TEST_HARNESS_NAME)
side_client = _make_side_client(str(manager.socket_path(conv_id)))
followup_client = _make_side_client(str(manager.socket_path(conv_id)))
start_body = {"type": "message", "role": "user", "model": "test-agent", "content": []}
followup_status: int | None = None
followup_events: list[_ParsedSSEEvent] = []
turn_id: str | None = None
try:
async with stream_client.stream(
"POST", f"/v1/sessions/{conv_id}/events", json=start_body
) as response:
interrupted = False
async for event in _stream_iter(response):
if event.event == "response.created":
turn_id = event.data["response"]["id"]
if not interrupted and event.event == "response.in_progress":
cancel_resp = await side_client.post(
f"/v1/sessions/{conv_id}/events", json={"type": "interrupt"}
)
assert cancel_resp.status_code == 204
interrupted = True
assert turn_id is not None
followup_body = {
"type": "message",
"role": "user",
"model": "test-agent",
"previous_response_id": turn_id,
"content": [{"type": "input_text", "text": "new question"}],
}
async with followup_client.stream(
"POST", f"/v1/sessions/{conv_id}/events", json=followup_body
) as r2:
followup_status = r2.status_code
if r2.status_code == 200:
async for e2 in _stream_iter(r2):
followup_events.append(e2)
if e2.event == "response.created":
break
break
# 200 fresh turn, NOT 204 in-band injection into the cancelled turn.
assert followup_status == 200, (
f"follow-up steering the interrupted turn must start a fresh turn (200), "
f"got {followup_status}"
)
fresh_ids = [
e.data["response"]["id"] for e in followup_events if e.event == "response.created"
]
assert fresh_ids and fresh_ids[0] != turn_id, "the fresh turn must have a new response id"
finally:
await side_client.aclose()
await followup_client.aclose()
async def test_session_message_event_in_band_injection(
use_injection: None,
manager: HarnessProcessManager,
) -> None:
"""A ``message`` with ``previous_response_id`` injects in-band.
Verifies the in-band POST routing works through the new
surface — the second message event with
``previous_response_id == in-flight turn_id`` returns 204
(not a streaming 200) and the subclass sees the injection.
Without this, the new surface would silently start a second
concurrent turn.
"""
conv_id = "conv_session_inject"
stream_client = await manager.get_client(conv_id, _TEST_HARNESS_NAME)
side_client = _make_side_client(str(manager.socket_path(conv_id)))
events: list[_ParsedSSEEvent] = []
start_body = {
"type": "message",
"role": "user",
"model": "test-agent",
"content": [],
}
try:
async with stream_client.stream(
"POST",
f"/v1/sessions/{conv_id}/events",
json=start_body,
) as response:
injected = False
async for event in _stream_iter(response):
events.append(event)
if (
not injected
and event.event == "response.output_text.delta"
and event.data.get("delta") == "ready:"
):
turn_id = next(
e.data["response"]["id"] for e in events if e.event == "response.created"
)
inject_resp = await side_client.post(
f"/v1/sessions/{conv_id}/events",
json={
"type": "message",
"role": "user",
"model": "test-agent",
"previous_response_id": turn_id,
"content": [
{"type": "input_text", "text": "x"},
{"type": "input_text", "text": "y"},
],
},
)
assert inject_resp.status_code == 204
injected = True
text_deltas = [e.data["delta"] for e in events if e.event == "response.output_text.delta"]
assert text_deltas == ["ready:", "got_2"]
finally:
await side_client.aclose()
async def test_session_events_unknown_type_returns_422(
use_echo: None,
manager: HarnessProcessManager,
) -> None:
"""An unknown ``type`` field on the events body returns 422.
Verifies the discriminated-union dispatcher rejects unknown
variants at request-validation time (Pydantic). Without this,
a typo in the runner's outgoing event body would silently
no-op rather than failing loud per
``designs/DESIGN_PRINCIPLES.md``.
"""
conv_id = "conv_session_422"
client = await manager.get_client(conv_id, _TEST_HARNESS_NAME)
resp = await client.post(
f"/v1/sessions/{conv_id}/events",
json={"type": "frobnicate", "frob": "nicate"},
)
assert resp.status_code == 422
# ── Lifespan shutdown hook ─────────────────────────
async def test_on_shutdown_hook_called_during_lifespan_teardown(
use_shutdown_tracking: None,
short_tmp_parent: Path,
register_fixture_harness: None,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""The scaffold's ``on_shutdown`` hook fires during lifespan
teardown, even when uvicorn overwrites the signal handlers.
Spawns a ``_ShutdownTrackingHarness`` subprocess that writes a
sentinel file in ``on_shutdown``. After a normal streaming turn,
the manager releases the subprocess (SIGTERM → graceful shutdown
→ lifespan finally → ``on_shutdown``). The test checks the
sentinel file exists.
Before the fix (Fix A + E): uvicorn's signal
handler overwrote the scaffold's, so ``_on_shutdown_signal`` was
never called, ``on_shutdown`` was never invoked, and inner
executor resources (e.g. child ``claude`` processes) leaked.
:meta: Exercises Fix A (lifespan finally calls
``_on_shutdown_signal``) and Fix E (``on_shutdown`` hook).
"""
import tempfile
marker_fd, marker_path = tempfile.mkstemp(prefix="shutdown-marker-", dir=str(short_tmp_parent))
os.close(marker_fd)
os.unlink(marker_path)
monkeypatch.setenv("HARNESS_SHUTDOWN_MARKER", marker_path)
mgr = HarnessProcessManager(
idle_timeout_s=60.0,
reaper_interval_s=60.0,
tmp_parent=short_tmp_parent,
)
await mgr.start()
try:
conv_id = "conv_shutdown_hook"
client = await mgr.get_client(conv_id, _TEST_HARNESS_NAME)
body = {
"type": "message",
"role": "user",
"model": "test-agent",
"content": "test",
}
events: list[_ParsedSSEEvent] = []
async with client.stream("POST", f"/v1/sessions/{conv_id}/events", json=body) as response:
async for event in _stream_iter(response):
events.append(event)
# Turn completed — the scaffold should have emitted
# response.completed.
terminal_events = [e for e in events if e.event == "response.completed"]
assert len(terminal_events) == 1, (
"Expected exactly one response.completed event; "
"without it, the turn didn't finish cleanly."
)
# Release triggers SIGTERM → graceful shutdown → lifespan
# finally → on_shutdown → sentinel write.
await mgr.release(conv_id)
import asyncio
# Give the subprocess a moment to finish writing the
# sentinel and exit.
for _ in range(30):
if Path(marker_path).exists():
break
await asyncio.sleep(0.1)
# The sentinel file should exist, proving on_shutdown was
# called. If missing, the lifespan's finally block didn't
# invoke the hook — the bug this guards against.
assert Path(marker_path).exists(), (
f"Sentinel file {marker_path} was not written. "
f"on_shutdown() was not called during lifespan teardown. "
f"This is the root cause of child-process leaks."
)
content = Path(marker_path).read_text(encoding="utf-8")
assert content == "shutdown_called"
finally:
await mgr.shutdown()
# ── Idle-watchdog surfaces forwarder connectivity cause ──
#
# The pure-module unit test for ``_native_forwarder_health`` (record / recency
# / clear) lives in ``tests/test_native_forwarder_health.py``. This file keeps
# only the watchdog-integration test, which needs the harness scaffold.
async def test_idle_watchdog_attaches_recent_forwarder_post_failure(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Idle-watchdog failure names a recent forwarder connectivity error.
Regression for issue #1119: when a native forwarder can't reach the server
its event POSTs starve the turn of progress, so the idle watchdog — not the
connectivity error — fails the turn. The watchdog must attach that recorded
POST failure to the reason instead of only the generic "wedged LLM" text.
Fails on the unfixed watchdog (reason omits the forwarder cause); passes
once the watchdog reads ``_native_forwarder_health``.
"""
from omnigent import _native_forwarder_health as health
from omnigent.runtime.harnesses import _scaffold
class _WedgedApp(HarnessApp):
async def run_turn(self, request: Any, ctx: TurnContext) -> None:
"""Never emit progress, so the idle watchdog must fire."""
del request, ctx
await asyncio.Event().wait()
# Short idle window so the watchdog trips quickly; absolute kept high so the
# idle ceiling (not the absolute one) is what fails the turn. The reader's
# recency window is 2x the idle timeout, comfortably covering the record
# made just before run_turn started plus scheduling overhead.
monkeypatch.setattr(_scaffold, "_TURN_IDLE_TIMEOUT_S", 0.2)
monkeypatch.setattr(_scaffold, "_TURN_ABSOLUTE_TIMEOUT_S", 3600.0)
health.clear()
health.record_post_failure("external_session_status", httpx.ConnectError("No route to host"))
app = _WedgedApp()
ctx = TurnContext(
response_id="resp_watchdog_1119",
event_queue=asyncio.Queue(),
cancelled=asyncio.Event(),
)
try:
with pytest.raises(RuntimeError) as excinfo:
await app._guarded_run_turn(None, ctx) # type: ignore[arg-type]
finally:
health.clear()
message = str(excinfo.value)
# Names the idle ceiling (not the absolute one) so we know which watchdog
# fired, and carries the full recorded forwarder detail — both the event
# type and the connectivity error, not just a coincidental substring.
assert "idle watchdog" in message, message
assert "external_session_status" in message, message
assert "No route to host" in message, message