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

3783 lines
142 KiB
Python

"""Tests for the RPC read driver (:mod:`omnigent.antigravity_native_reader`).
The reader replaces the transcript-tail forwarder's read loop: it polls agy's
connect-RPC for trajectory steps, maps each new step to Omnigent conversation
items (via the pure Task 4 mapper), POSTs them, emits session-status edges on
transition, and hands WAITING steps to the Task 8 interaction bridge through an
``on_pending_interaction`` callback.
These tests drive the loop with NO real agy and NO real sockets:
* ``get_trajectory_steps`` is monkeypatched to return a scripted sequence of
step-list snapshots (one per poll).
* port discovery (``_candidate_agy_rpc_ports`` / ``_conversation_matches``) and
the cascade-id resolution (``read_bridge_state``) are monkeypatched so the
reader resolves immediately without OS/network access.
* posts are captured by replacing ``post_session_event_with_retry`` with a fake
sink that records every ``(event_type, data)`` it is asked to deliver.
The loop is made finite by an injectable ``stop`` predicate (checked once per
poll) so a test drives a bounded number of iterations rather than looping
forever.
Key assertions (the plan's Step 1 + status + error):
* Each new step posts exactly once; re-reads of the same steps post nothing.
* A USER_INPUT step posts nothing (already persisted by the direct POST).
* A WAITING step invokes ``on_pending_interaction`` exactly once (not on re-read).
* RUNNING/IDLE ``external_session_status`` edges are emitted on transition only.
* A ``get_trajectory_steps`` raising ``httpx.HTTPError`` does not crash the loop.
Task T-D adds STREAM mode (live ``output_text_delta`` typing). The reader now
prefers :func:`stream_agent_state_updates` (a scripted async generator of
cumulative frames in the tests) and only falls back to the poll loop on a stream
error. The stream-mode assertions:
* Growing ``plannerResponse.modifiedResponse`` while a step is GENERATING emits
incremental ``external_output_text_delta`` events whose ``delta`` suffixes
concatenate to the full text, share a stable per-step ``message_id``, and never
overlap/duplicate.
* The DONE frame emits exactly ONE committed ``message`` (via the mapper), AFTER
the deltas; a re-sent DONE (on-connect snapshot replay) does NOT re-post it.
* A stream raising ``httpx.HTTPError`` / ``AntigravityRpcError`` falls back to the
poll loop (committed-only) without crashing the reader.
* A WAITING frame hands its interaction to the bridge exactly once.
"""
from __future__ import annotations
import asyncio
import contextlib
import copy
import json
import logging
from collections.abc import AsyncIterator
from pathlib import Path
from typing import Any, cast
import httpx
import pytest
from omnigent import antigravity_native_reader as reader
from omnigent.antigravity_native_bridge import read_bridge_state
from omnigent.antigravity_native_rpc import AntigravityRpcError
from omnigent.antigravity_native_steps import PendingInteraction
# ---------------------------------------------------------------------------
# Fixtures + scaffolding
# ---------------------------------------------------------------------------
_FIXTURES = Path(__file__).parent / "fixtures" / "antigravity" / "steps"
_CASCADE_ID = "efb134b2-d69f-43de-bb54-c9ece346d8a3"
_SESSION_ID = "conv_reader_test"
_PORT = 52548
def _load(name: str) -> dict[str, Any]:
"""Load one recorded step fixture by filename (without extension)."""
path = _FIXTURES / f"{name}.json"
return cast(dict[str, Any], json.loads(path.read_text()))
class _PostSink:
"""Capture every event the reader asks to POST (no HTTP)."""
def __init__(self) -> None:
self.posts: list[tuple[str, dict[str, object]]] = []
async def __call__(
self,
*,
client: object,
url: str,
payload: dict[str, object],
event_type: str,
max_attempts: int,
retry_status_codes: object,
sleep: object,
retry_delay: object,
logger_name: str,
) -> httpx.Response:
data = payload.get("data")
self.posts.append((event_type, cast(dict[str, object], data)))
return httpx.Response(200, json={"ok": True})
def item_types(self) -> list[str]:
"""Return the ``item_type`` of every conversation-item post, in order."""
out: list[str] = []
for event_type, data in self.posts:
if event_type == "external_conversation_item":
item_type = data.get("item_type")
out.append(item_type if isinstance(item_type, str) else "<none>")
return out
def statuses(self) -> list[str]:
"""Return the ``status`` of every session-status edge, in order."""
out: list[str] = []
for event_type, data in self.posts:
if event_type == "external_session_status":
status = data.get("status")
out.append(status if isinstance(status, str) else "<none>")
return out
def message_roles(self) -> list[str]:
"""Return the ``role`` of every committed ``message`` item, in order."""
out: list[str] = []
for event_type, data in self.posts:
if event_type == "external_conversation_item" and data.get("item_type") == "message":
item_data = data.get("item_data")
role = item_data.get("role") if isinstance(item_data, dict) else None
out.append(role if isinstance(role, str) else "<none>")
return out
def deltas(self) -> list[dict[str, object]]:
"""Return the ``data`` payload of every ``external_output_text_delta``."""
return [
data for event_type, data in self.posts if event_type == "external_output_text_delta"
]
def reasonings(self) -> list[dict[str, object]]:
"""Return the ``data`` payload of every ``external_output_reasoning_delta``."""
return [
data
for event_type, data in self.posts
if event_type == "external_output_reasoning_delta"
]
def event_types(self) -> list[str]:
"""Return the ``type`` of every posted event, in order."""
return [event_type for event_type, _data in self.posts]
class _StepScript:
"""A scripted ``get_trajectory_steps`` returning one snapshot per call.
The final snapshot repeats once exhausted so re-reads (a steady-state poll
that returns the same finished list) can be asserted to post nothing.
"""
def __init__(self, snapshots: list[list[dict[str, Any]]]) -> None:
self._snapshots = snapshots
self.calls = 0
def __call__(self, port: int, cascade_id: str) -> list[dict[str, object]]:
self.calls += 1
idx = min(self.calls - 1, len(self._snapshots) - 1)
# Return a deep-ish copy so the reader cannot mutate the script.
return [dict(step) for step in self._snapshots[idx]]
class _RaisingThenOk:
"""``get_trajectory_steps`` that raises on the first call, then succeeds."""
def __init__(self, exc: Exception, snapshot: list[dict[str, Any]]) -> None:
self._exc = exc
self._snapshot = snapshot
self.calls = 0
def __call__(self, port: int, cascade_id: str) -> list[dict[str, object]]:
self.calls += 1
if self.calls == 1:
raise self._exc
return [dict(step) for step in self._snapshot]
# ---------------------------------------------------------------------------
# Stream-mode scaffolding (Task T-D)
# ---------------------------------------------------------------------------
def _frame(steps: list[dict[str, Any]]) -> dict[str, Any]:
"""Wrap a step list in a ``StreamAgentStateUpdates`` update frame.
Mirrors the live shape ``update.mainTrajectoryUpdate.stepsUpdate.steps[]``
(design §10.2) that :func:`stream_agent_state_updates` yields per frame.
"""
return {"mainTrajectoryUpdate": {"stepsUpdate": {"steps": copy.deepcopy(steps)}}}
def _generating_planner(text: str, *, step_index: int = 2) -> dict[str, Any]:
"""A PLANNER_RESPONSE step mid-generation (status GENERATING).
Built from the committed ``planner_response_text`` fixture but with the
partial-text contract verified live (design §10.2): ``modifiedResponse``
holds the growing partial, ``response`` is ABSENT during generation, and
``status == CORTEX_STEP_STATUS_GENERATING``.
"""
step = copy.deepcopy(_load("planner_response_text"))
step["status"] = "CORTEX_STEP_STATUS_GENERATING"
planner = cast(dict[str, Any], step["plannerResponse"])
planner.pop("response", None)
planner["modifiedResponse"] = text
cast(dict[str, Any], step["metadata"])["sourceTrajectoryStepInfo"]["stepIndex"] = step_index
return step
def _generating_planner_with_thinking(
*, thinking: str, text: str = "", step_index: int = 2
) -> dict[str, Any]:
"""A GENERATING PLANNER_RESPONSE carrying a growing ``thinking`` block.
Gemini Thinking-model variants stream chain-of-thought at
``plannerResponse.thinking`` (design §10.2) alongside the growing
``modifiedResponse``. Built from :func:`_generating_planner` so the partial
text contract (``response`` absent, status GENERATING) is preserved; the
``thinking`` field is added to model the reasoning stream.
"""
step = _generating_planner(text, step_index=step_index)
cast(dict[str, Any], step["plannerResponse"])["thinking"] = thinking
return step
def _done_planner(text: str, *, step_index: int = 2) -> dict[str, Any]:
"""A DONE PLANNER_RESPONSE step whose committed text is ``text``.
On DONE both ``response`` and ``modifiedResponse`` are present and equal
(design §10.2); the mapper emits one committed ``message`` from it.
"""
step = copy.deepcopy(_load("planner_response_text"))
step["status"] = "CORTEX_STEP_STATUS_DONE"
planner = cast(dict[str, Any], step["plannerResponse"])
planner["response"] = text
planner["modifiedResponse"] = text
cast(dict[str, Any], step["metadata"])["sourceTrajectoryStepInfo"]["stepIndex"] = step_index
return step
def _running_run_command() -> dict[str, Any]:
"""A RUN_COMMAND step still executing (status RUNNING; no output yet).
Built from the DONE fixture but rolled back to RUNNING with its output
stripped — the pre-DONE shape the stream surfaces before the command
completes. The mapper emits nothing for it (output only at DONE).
"""
step = copy.deepcopy(_load("run_command_done"))
step["status"] = "CORTEX_STEP_STATUS_RUNNING"
run_command = cast(dict[str, Any], step["runCommand"])
run_command.pop("combinedOutput", None)
return step
class _FrameScript:
"""A scripted ``stream_agent_state_updates`` async generator.
Yields one pre-built update frame per scripted entry, then ends cleanly (a
real stream long-polls; the test ends the turn by exhausting the script).
Records ``calls`` so a test can assert the stream was (re)entered.
"""
def __init__(self, frames: list[dict[str, Any]]) -> None:
self._frames = frames
self.calls = 0
def __call__(self, port: int, conversation_id: str) -> AsyncIterator[dict[str, object]]:
self.calls += 1
async def _gen() -> AsyncIterator[dict[str, object]]:
for frame in self._frames:
yield copy.deepcopy(frame)
return _gen()
class _RaisingStream:
"""A ``stream_agent_state_updates`` that raises before yielding any frame."""
def __init__(self, exc: Exception) -> None:
self._exc = exc
self.calls = 0
def __call__(self, port: int, conversation_id: str) -> AsyncIterator[dict[str, object]]:
self.calls += 1
async def _gen() -> AsyncIterator[dict[str, object]]:
raise self._exc
yield {} # pragma: no cover (unreachable; marks this an async gen)
return _gen()
async def _run_stream(
*,
bridge_dir: Path,
sink: _PostSink,
stream: object,
poll_steps: object,
monkeypatch: pytest.MonkeyPatch,
iterations: int,
on_pending: object | None = None,
) -> None:
"""Drive ``supervise_reader`` in STREAM mode for a bounded run.
Injects both the scripted ``stream_agent_state_updates`` (primary) and a
``get_trajectory_steps`` (poll fallback). ``stop`` bounds the poll loop so a
fallback path still terminates; the stream script ends on its own.
"""
monkeypatch.setattr(reader, "stream_agent_state_updates", stream)
monkeypatch.setattr(reader, "get_trajectory_steps", poll_steps)
monkeypatch.setattr(reader, "post_session_event_with_retry", sink)
async def _noop_sleep(_seconds: float) -> None:
# Yield to the event loop (no real delay) so the off-loop interaction
# bridge tasks the reader spawns get a turn to run in the bounded loop.
await asyncio.sleep(0)
monkeypatch.setattr(reader, "_sleep", _noop_sleep)
async def _default_pending(_cascade_id: str, _port: int, _pending: PendingInteraction) -> None:
return None
callback = on_pending if on_pending is not None else _default_pending
await reader.supervise_reader(
bridge_dir,
_SESSION_ID,
client=cast(httpx.AsyncClient, object()),
on_pending_interaction=cast(Any, callback),
poll_interval_s=0.0,
stop=_stop_after(iterations),
)
def _stop_after(n: int) -> _StopAfter:
"""Build a stop predicate that returns True once it has been polled ``n`` times."""
return _StopAfter(n)
class _StopAfter:
"""Stop the reader loop after a bounded number of poll iterations."""
def __init__(self, n: int) -> None:
self._remaining = n
def __call__(self) -> bool:
if self._remaining <= 0:
return True
self._remaining -= 1
return False
@pytest.fixture
def patched_discovery(monkeypatch: pytest.MonkeyPatch) -> None:
"""Make port + cascade-id discovery resolve immediately (no OS/network)."""
monkeypatch.setattr(reader, "_candidate_agy_rpc_ports", lambda: [_PORT])
monkeypatch.setattr(reader, "_conversation_matches", lambda port, cid: port == _PORT)
@pytest.fixture(autouse=True)
def no_rotation(monkeypatch: pytest.MonkeyPatch) -> None:
"""Default the Task T-G rotation detector to "no rotation" for every test.
``supervise_reader`` always spawns the ``GetAllCascadeTrajectories`` rotation
detector; by default it must hit no real socket and report no rotation, so the
existing reader tests exercise only the stream/poll body. A rotation-specific
test overrides ``reader.get_all_cascade_trajectories`` itself.
"""
monkeypatch.setattr(reader, "get_all_cascade_trajectories", lambda port: {})
def _bridge_dir(tmp_path: Path) -> Path:
"""A bridge dir whose state.json names the real (non-placeholder) cascade id."""
bridge_dir = tmp_path / "bridge"
bridge_dir.mkdir()
(bridge_dir / "state.json").write_text(
json.dumps({"session_id": _SESSION_ID, "conversation_id": _CASCADE_ID}),
encoding="utf-8",
)
return bridge_dir
async def _run(
*,
bridge_dir: Path,
sink: _PostSink,
steps: object,
monkeypatch: pytest.MonkeyPatch,
iterations: int,
on_pending: object | None = None,
) -> None:
"""Drive ``supervise_reader`` for a bounded number of poll iterations.
The reader is stream-primary (Task T-D), so to exercise the POLL path these
tests inject a ``stream_agent_state_updates`` that fails immediately —
forcing the documented graceful fallback to the (committed-only) poll loop.
"""
monkeypatch.setattr(
reader,
"stream_agent_state_updates",
_RaisingStream(httpx.ConnectError("stream disabled for poll test")),
)
monkeypatch.setattr(reader, "get_trajectory_steps", steps)
monkeypatch.setattr(reader, "post_session_event_with_retry", sink)
async def _noop_sleep(_seconds: float) -> None:
# Yield to the event loop (no real delay) so the off-loop interaction
# bridge tasks the reader spawns get a turn to run in the bounded loop.
await asyncio.sleep(0)
monkeypatch.setattr(reader, "_sleep", _noop_sleep)
async def _default_pending(_cascade_id: str, _port: int, _pending: PendingInteraction) -> None:
return None
callback = on_pending if on_pending is not None else _default_pending
await reader.supervise_reader(
bridge_dir,
_SESSION_ID,
client=cast(httpx.AsyncClient, object()),
on_pending_interaction=cast(Any, callback),
poll_interval_s=0.0,
stop=_stop_after(iterations),
)
# ---------------------------------------------------------------------------
# Dedup: each new step posts exactly once; re-reads post nothing
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_each_new_step_posts_once_and_rereads_dedup(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
patched_discovery: None,
) -> None:
"""A planner text step posts one assistant message; re-reads post nothing."""
planner = _load("planner_response_text")
# Three polls all return the SAME one-step snapshot (a steady finished list).
script = _StepScript([[planner], [planner], [planner]])
sink = _PostSink()
await _run(
bridge_dir=_bridge_dir(tmp_path),
sink=sink,
steps=script,
monkeypatch=monkeypatch,
iterations=3,
)
# Exactly one assistant message, despite three reads of the same step.
assert sink.item_types() == ["message"]
@pytest.mark.asyncio
async def test_incremental_steps_each_post_once(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
patched_discovery: None,
) -> None:
"""Steps appearing across polls each post exactly once (no re-post)."""
text = _load("planner_response_text")
tool_call = _load("planner_response_tool_call_run_command")
result = _load("run_command_done")
# Snapshot grows by one step each poll, then holds steady.
script = _StepScript(
[
[text],
[text, tool_call],
[text, tool_call, result],
[text, tool_call, result],
]
)
sink = _PostSink()
await _run(
bridge_dir=_bridge_dir(tmp_path),
sink=sink,
steps=script,
monkeypatch=monkeypatch,
iterations=4,
)
# message (text) + function_call (tool call) + function_call_output (result),
# each exactly once across the growing snapshots.
assert sink.item_types() == ["message", "function_call", "function_call_output"]
@pytest.mark.asyncio
async def test_poll_planner_generating_then_done_posts_one_final_message(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
patched_discovery: None,
) -> None:
"""POLL path: a planner caught GENERATING then DONE posts ONE final message.
Regression for the double-render the rework prevents. The poll loop does NOT
intercept GENERATING (only the stream path emits deltas), and the mapper now
gates the committed planner message on DONE. So a poll that sees the planner
GENERATING ("Hi") then DONE ("Hi there") must post exactly one ``message``
whose text is the FINAL "Hi there" — not "Hi", and not two messages.
"""
script = _StepScript(
[
[_generating_planner("Hi")],
[_done_planner("Hi there")],
[_done_planner("Hi there")],
]
)
sink = _PostSink()
await _run(
bridge_dir=_bridge_dir(tmp_path),
sink=sink,
steps=script,
monkeypatch=monkeypatch,
iterations=3,
)
# Exactly one committed message (no GENERATING message, no double-post).
assert sink.item_types() == ["message"]
# And it carries the FINAL text.
messages = [
data for event_type, data in sink.posts if event_type == "external_conversation_item"
]
item_data = cast(dict[str, Any], messages[0]["item_data"])
content = cast(list[dict[str, Any]], item_data["content"])
assert content[0]["text"] == "Hi there"
# The poll path emits no deltas.
assert sink.deltas() == []
# ---------------------------------------------------------------------------
# USER_INPUT commits the user message exactly once (#1155)
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_user_input_commits_user_message_once(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
patched_discovery: None,
) -> None:
"""A USER_INPUT step posts one user ``message`` item, deduped across reads.
Regression guard for #1155: the user turn must be committed (so the web UI
reconciles its optimistic bubble) and committed only ONCE — the reader
dedups the step by its per-turn ``executionId`` across repeated polls.
"""
user = _load("user_input")
script = _StepScript([[user], [user]])
sink = _PostSink()
await _run(
bridge_dir=_bridge_dir(tmp_path),
sink=sink,
steps=script,
monkeypatch=monkeypatch,
iterations=2,
)
assert sink.item_types() == ["message"]
# ---------------------------------------------------------------------------
# WAITING step → on_pending_interaction invoked exactly once
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_waiting_step_invokes_callback_once(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
patched_discovery: None,
) -> None:
"""A WAITING step hands its pending interaction to the callback exactly once.
The callback also receives the SAME cascade id + port the reader discovered,
so the interaction bridge it drives targets agy's live conversation without
re-discovering (and risking a recycled/foreign port).
"""
waiting = _load("ask_question_waiting")
script = _StepScript([[waiting], [waiting], [waiting]])
sink = _PostSink()
captured: list[tuple[str, int, PendingInteraction]] = []
async def _on_pending(cascade_id: str, port: int, pending: PendingInteraction) -> None:
captured.append((cascade_id, port, pending))
await _run(
bridge_dir=_bridge_dir(tmp_path),
sink=sink,
steps=script,
monkeypatch=monkeypatch,
iterations=3,
on_pending=_on_pending,
)
# Despite three reads of the same WAITING step, the bridge is called once.
assert len(captured) == 1
cascade_id, port, pending = captured[0]
# The callback is handed the SAME cascade id + port the reader bound to.
assert cascade_id == _CASCADE_ID
assert port == _PORT
assert pending["kind"] == "ask_question"
assert pending["trajectory_id"] == _CASCADE_ID
# ---------------------------------------------------------------------------
# Interaction bridge runs OFF the reader loop (no streaming starvation)
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_streaming_continues_while_interaction_pending(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
patched_discovery: None,
) -> None:
"""A pending (blocking) interaction must NOT freeze mirroring of later steps.
Regression for the inline-await starvation (gemini repro): with the bridge run
off the reader loop, a DONE planner that follows a WAITING step in the same
snapshot is still mirrored while the human is still answering.
"""
waiting = _load("ask_question_waiting")
planner = _done_planner("Answer arrives later", step_index=99)
script = _StepScript([[waiting, planner], [waiting, planner]])
sink = _PostSink()
async def _block(_cascade_id: str, _port: int, _pending: PendingInteraction) -> None:
await asyncio.Event().wait() # never completes — simulates a human holding
await asyncio.wait_for(
_run(
bridge_dir=_bridge_dir(tmp_path),
sink=sink,
steps=script,
monkeypatch=monkeypatch,
iterations=2,
on_pending=_block,
),
timeout=5.0,
)
# The planner message was mirrored despite the interaction still pending.
assert "message" in sink.item_types()
@pytest.mark.asyncio
async def test_single_in_flight_guard_skips_second_interaction() -> None:
"""While one interaction is handled off-loop, a second (e.g. agy's higher-index
WAITING retry) is NOT fired again — the in-flight bridge owns the retries."""
state = reader._ReaderState(
allocator=reader._ToolCallIdAllocator(conversation_id=_CASCADE_ID),
seen=set(),
interacted=set(),
port=_PORT,
)
waiting = _load("ask_question_waiting")
fired: list[int] = []
async def _block(_cascade_id: str, _port: int, pending: PendingInteraction) -> None:
fired.append(pending["step_index"])
await asyncio.Event().wait()
reader._maybe_handle_interaction(
waiting,
key=("traj", 0),
cascade_id=_CASCADE_ID,
state=state,
on_pending_interaction=cast(Any, _block),
)
reader._maybe_handle_interaction(
waiting,
key=("traj", 1),
cascade_id=_CASCADE_ID,
state=state,
on_pending_interaction=cast(Any, _block),
)
await asyncio.sleep(0) # let the spawned task start
assert len(fired) == 1 # the guard suppressed the second despite a distinct key
task = state.interaction_task
assert task is not None
task.cancel()
with contextlib.suppress(asyncio.CancelledError):
await task
@pytest.mark.asyncio
async def test_interaction_done_callback_clears_slot(monkeypatch: pytest.MonkeyPatch) -> None:
"""When an interaction task completes, the slot clears so a later distinct
interaction can fire."""
# The clear now ALSO re-scans the freshest steps (#1472); keep that a no-op here
# so this test stays focused on slot-clearing + the manual re-fire.
monkeypatch.setattr(reader, "get_trajectory_steps", lambda _port, _cascade_id: [])
state = reader._ReaderState(
allocator=reader._ToolCallIdAllocator(conversation_id=_CASCADE_ID),
seen=set(),
interacted=set(),
port=_PORT,
)
waiting = _load("ask_question_waiting")
fired: list[int] = []
async def _quick(_cascade_id: str, _port: int, pending: PendingInteraction) -> None:
fired.append(pending["step_index"])
reader._maybe_handle_interaction(
waiting,
key=("traj", 0),
cascade_id=_CASCADE_ID,
state=state,
on_pending_interaction=cast(Any, _quick),
)
first = state.interaction_task
assert first is not None
await first
await asyncio.sleep(0) # let the done-callback run
assert state.interaction_task is None # slot cleared
reader._maybe_handle_interaction(
waiting,
key=("traj", 1),
cascade_id=_CASCADE_ID,
state=state,
on_pending_interaction=cast(Any, _quick),
)
second = state.interaction_task
assert second is not None
await second
assert len(fired) == 2 # the slot cleared, so the second interaction fired
await _drain_interactions(state) # settle the no-op re-scans both clears scheduled
async def _drain_interactions(state: reader._ReaderState) -> None:
"""Await the interaction bridge + any chained re-scan tasks to quiescence.
A cleared bridge schedules a re-scan (#1472) that may spawn the next bridge,
whose clear re-scans again; this awaits each link (letting done-callbacks run
between rounds) so a test ends with no pending interaction tasks. Bounded so a
bug that keeps respawning surfaces as a test hang in CI rather than an infinite
loop here.
"""
for _ in range(10):
inflight = [
task
for task in (state.interaction_task, *state.interaction_rescans)
if task is not None and not task.done()
]
if not inflight:
return
await asyncio.gather(*inflight, return_exceptions=True)
await asyncio.sleep(0) # let each done-callback schedule the next link
@pytest.mark.asyncio
async def test_clear_slot_resurfaces_deferred_gate(monkeypatch: pytest.MonkeyPatch) -> None:
"""A bridge clearing re-scans and surfaces a gate the guard DEFERRED (#1472).
Models a chained ``a && b`` command where each segment is permission-gated: the
first gate surfaces and is answered; the second arrives while the first bridge
is still finishing and is skipped by the single-in-flight guard. On the stream
path no further frame carries it, so the clear's re-scan is what surfaces it.
"""
state = reader._ReaderState(
allocator=reader._ToolCallIdAllocator(conversation_id=_CASCADE_ID),
seen=set(),
interacted=set(),
port=_PORT,
)
gate1 = _load("run_command_waiting") # stepIndex 6 (the `pwd` segment)
gate2 = copy.deepcopy(gate1) # a distinct later gate (the `ls` segment)
gate2["metadata"]["sourceTrajectoryStepInfo"]["stepIndex"] = 8
gate2["runCommand"]["commandLine"] = "ls"
# The freshest snapshot the re-scan re-reads. gate1 still reads WAITING here —
# the dedup-race case: its verdict landed but the DONE transition has not yet
# propagated to the snapshot, so ``state.interacted`` (not status) is what stops
# it re-firing. gate2 is the newly-arrived, deferred gate.
monkeypatch.setattr(reader, "get_trajectory_steps", lambda _port, _cascade_id: [gate1, gate2])
fired: list[int] = []
async def _quick(_cascade_id: str, _port: int, pending: PendingInteraction) -> None:
fired.append(pending["step_index"])
reader._maybe_handle_interaction(
gate1,
key=reader._step_key(gate1),
cascade_id=_CASCADE_ID,
state=state,
on_pending_interaction=cast(Any, _quick),
)
first = state.interaction_task
assert first is not None
await first
await asyncio.sleep(0) # let _clear_slot run → schedule the re-scan
await _drain_interactions(state) # re-scan surfaces gate2 → its bridge fires
assert fired == [6, 8] # gate1 directly; gate2 via the clear's re-scan
assert reader._step_key(gate1) in state.interacted # gate1 was not re-surfaced
assert reader._step_key(gate2) in state.interacted
@pytest.mark.asyncio
async def test_clear_slot_rescan_empty_then_stream_frame_surfaces(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Case B (#1472 review): the clear's re-scan finds NOTHING because gate-2 is
not WAITING yet (agy must run segment 1 first). The re-scan must then leave the
slot OPEN so gate-2's later live stream frame surfaces it directly — proving the
single-shot re-scan does not strand a gate that arrives after it runs.
"""
state = reader._ReaderState(
allocator=reader._ToolCallIdAllocator(conversation_id=_CASCADE_ID),
seen=set(),
interacted=set(),
port=_PORT,
)
gate1 = _load("run_command_waiting") # stepIndex 6
gate1_done = copy.deepcopy(gate1) # answered → DONE, no longer WAITING
gate1_done["status"] = "CORTEX_STEP_STATUS_DONE"
gate1_done.pop("requestedInteraction", None)
gate2 = copy.deepcopy(gate1) # the next segment — arrives only LATER
gate2["metadata"]["sourceTrajectoryStepInfo"]["stepIndex"] = 8
gate2["runCommand"]["commandLine"] = "ls"
# Re-scan snapshot: gate-1 resolved (DONE), gate-2 not present yet → the re-scan
# surfaces nothing and must leave the slot open.
monkeypatch.setattr(reader, "get_trajectory_steps", lambda _port, _cascade_id: [gate1_done])
fired: list[int] = []
async def _quick(_cascade_id: str, _port: int, pending: PendingInteraction) -> None:
fired.append(pending["step_index"])
reader._maybe_handle_interaction(
gate1,
key=reader._step_key(gate1),
cascade_id=_CASCADE_ID,
state=state,
on_pending_interaction=cast(Any, _quick),
)
first = state.interaction_task
assert first is not None
await first
await _drain_interactions(state) # re-scan runs, finds no pending gate
assert fired == [6] # only gate-1 so far
assert state.interaction_task is None # slot OPEN — re-scan neither hung nor held it
# gate-2 now arrives as a live stream frame: with the slot open the guard passes
# and it surfaces directly (the backstop that makes the single-shot re-scan safe).
reader._maybe_handle_interaction(
gate2,
key=reader._step_key(gate2),
cascade_id=_CASCADE_ID,
state=state,
on_pending_interaction=cast(Any, _quick),
)
second = state.interaction_task
assert second is not None
await second
await _drain_interactions(state)
assert fired == [6, 8] # gate-2 surfaced via the live frame, not lost
@pytest.mark.asyncio
async def test_rescan_skips_auto_allowed_step_and_surfaces_next_gate(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""An ALREADY-ALLOWED segment in a chain (e.g. ``rm a && echo hi && rm b`` with
``echo`` auto-allowed) runs WITHOUT a gate — a DONE step with no
``requestedInteraction`` — so it must be transparent to the re-scan: skipped (not
surfaced), and the NEXT real gate after it still surfaces. Guards the edge case
where not every chained command is permission-gated.
"""
state = reader._ReaderState(
allocator=reader._ToolCallIdAllocator(conversation_id=_CASCADE_ID),
seen=set(),
interacted=set(),
port=_PORT,
)
gate1 = _load("run_command_waiting") # stepIndex 6 — the `rm a` gate
gate1_done = copy.deepcopy(gate1)
gate1_done["status"] = "CORTEX_STEP_STATUS_DONE"
gate1_done.pop("requestedInteraction", None)
allowed = copy.deepcopy(gate1) # the auto-allowed `echo hi` — ran, NEVER gated
allowed["metadata"]["sourceTrajectoryStepInfo"]["stepIndex"] = 7
allowed["runCommand"]["commandLine"] = "echo hi"
allowed["status"] = "CORTEX_STEP_STATUS_DONE"
allowed.pop("requestedInteraction", None) # no interaction → never a delivery target
gate2 = copy.deepcopy(gate1) # the next real gate `rm b`
gate2["metadata"]["sourceTrajectoryStepInfo"]["stepIndex"] = 8
gate2["runCommand"]["commandLine"] = "rm b"
# Re-scan snapshot: gate-1 resolved, the allowed command DONE (no gate), gate-2 WAITING.
monkeypatch.setattr(
reader, "get_trajectory_steps", lambda _port, _cascade_id: [gate1_done, allowed, gate2]
)
fired: list[int] = []
async def _quick(_cascade_id: str, _port: int, pending: PendingInteraction) -> None:
fired.append(pending["step_index"])
reader._maybe_handle_interaction(
gate1,
key=reader._step_key(gate1),
cascade_id=_CASCADE_ID,
state=state,
on_pending_interaction=cast(Any, _quick),
)
first = state.interaction_task
assert first is not None
await first
await _drain_interactions(state)
assert fired == [6, 8] # gate-1, then gate-2 — the allowed step (7) was NEVER surfaced
assert reader._step_key(allowed) not in state.interacted # not an interaction at all
@pytest.mark.asyncio
async def test_resurface_pending_interaction_swallows_poll_error(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""A re-scan whose steps re-read fails on EVERY bounded attempt is logged and
swallowed, not raised — and the read is retried, not given up after one shot
(#1472 review: the re-scan is the sole backstop on the healthy-stream path)."""
calls = 0
def _boom(_port: int, _cascade_id: str) -> list[dict[str, Any]]:
nonlocal calls
calls += 1
raise httpx.ConnectError("refused")
async def _noop_sleep(_seconds: float) -> None:
return None
monkeypatch.setattr(reader, "get_trajectory_steps", _boom)
monkeypatch.setattr(reader, "_sleep", _noop_sleep) # no real backoff in the test
state = reader._ReaderState(
allocator=reader._ToolCallIdAllocator(conversation_id=_CASCADE_ID),
seen=set(),
interacted=set(),
port=_PORT,
)
fired: list[int] = []
async def _quick(_cascade_id: str, _port: int, pending: PendingInteraction) -> None:
fired.append(pending["step_index"])
# Must not raise despite the read failing on every attempt.
await reader._resurface_pending_interaction(
cascade_id=_CASCADE_ID,
state=state,
on_pending_interaction=cast(Any, _quick),
)
assert calls == reader._INTERACTION_RESCAN_POLL_ATTEMPTS # retried, not one-shot
assert fired == [] # no gate surfaced
assert state.interaction_task is None # no bridge spawned
@pytest.mark.asyncio
async def test_resurface_pending_interaction_retries_transient_poll_error(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""A TRANSIENT re-scan poll error is retried and the deferred gate STILL surfaces
on a later attempt (#1472 review): the re-scan is the only backstop on the
healthy-stream path, so it must not abandon the gate after one failed read."""
gate = _load("run_command_waiting") # stepIndex 6 — the deferred gate
failed_once = False
def _flaky(_port: int, _cascade_id: str) -> list[dict[str, Any]]:
nonlocal failed_once
if not failed_once:
failed_once = True
raise httpx.ConnectError("refused") # transient blip on the first read
return [gate]
async def _noop_sleep(_seconds: float) -> None:
return None
monkeypatch.setattr(reader, "get_trajectory_steps", _flaky)
monkeypatch.setattr(reader, "_sleep", _noop_sleep) # no real backoff in the test
state = reader._ReaderState(
allocator=reader._ToolCallIdAllocator(conversation_id=_CASCADE_ID),
seen=set(),
interacted=set(),
port=_PORT,
)
fired: list[int] = []
async def _quick(_cascade_id: str, _port: int, pending: PendingInteraction) -> None:
fired.append(pending["step_index"])
await reader._resurface_pending_interaction(
cascade_id=_CASCADE_ID,
state=state,
on_pending_interaction=cast(Any, _quick),
)
await _drain_interactions(state)
assert failed_once # the first read raised and was retried, not abandoned
assert fired == [6] # the gate surfaced despite the transient blip
@pytest.mark.asyncio
async def test_reader_teardown_cancels_in_flight_interaction(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
patched_discovery: None,
) -> None:
"""A stopped reader cancels an interaction bridge still awaiting a verdict."""
waiting = _load("ask_question_waiting")
script = _StepScript([[waiting], [waiting]])
sink = _PostSink()
tasks: list[asyncio.Task[object]] = []
async def _block(_cascade_id: str, _port: int, _pending: PendingInteraction) -> None:
current = asyncio.current_task()
assert current is not None
tasks.append(current)
await asyncio.Event().wait()
await asyncio.wait_for(
_run(
bridge_dir=_bridge_dir(tmp_path),
sink=sink,
steps=script,
monkeypatch=monkeypatch,
iterations=2,
on_pending=_block,
),
timeout=5.0,
)
assert len(tasks) == 1 # the bridge started
assert tasks[0].cancelled() # reader teardown cancelled it
# ---------------------------------------------------------------------------
# #1200 direction 2: withdraw a surfaced elicitation resolved out-of-band
# ---------------------------------------------------------------------------
def _capturing_client() -> tuple[httpx.AsyncClient, list[dict[str, Any]]]:
"""Build a real AsyncClient over a MockTransport that records every POST body.
``_post_external_elicitation_resolved`` calls ``client.post`` directly (not
the post-retry sink), so a withdraw test needs a usable client. The transport
captures the JSON body of each POST and answers 200 so the reader proceeds.
"""
captured: list[dict[str, Any]] = []
def _handler(request: httpx.Request) -> httpx.Response:
if request.content:
captured.append(json.loads(request.content))
return httpx.Response(200, json={"ok": True})
client = httpx.AsyncClient(base_url="http://test", transport=httpx.MockTransport(_handler))
return client, captured
def _permission_waiting() -> dict[str, Any]:
"""A WAITING command-permission step (the #1200 fixture)."""
return _load("run_command_waiting")
def _permission_done() -> dict[str, Any]:
"""The same permission step advanced to DONE (answered/timed out → no WAITING).
Built from the WAITING fixture by flipping the status and dropping the
``requestedInteraction`` block, so ``pending_interaction`` returns ``None`` —
exactly what the reader sees once the step leaves WAITING.
"""
step = copy.deepcopy(_load("run_command_waiting"))
step["status"] = "CORTEX_STEP_STATUS_DONE"
step.pop("requestedInteraction", None)
return step
@pytest.mark.asyncio
async def test_step_leaving_waiting_withdraws_surfaced_elicitation(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
patched_discovery: None,
) -> None:
"""A surfaced WAITING step that later is NOT WAITING → withdraw the web card.
Models the terminal-answered / timed-out case: the reader surfaces the
permission elicitation, then on a later poll the step is DONE (no
``requestedInteraction``). The reader must POST exactly one
``external_elicitation_resolved`` for that step's deterministic elicitation id
so the lingering web card clears (#1200, direction 2).
"""
from omnigent.antigravity_native_interactions import agy_elicitation_id
waiting = _permission_waiting()
done = _permission_done()
# WAITING twice (surface + dedup), then DONE (withdraw), then steady DONE.
script = _StepScript([[waiting], [waiting], [done], [done]])
sink = _PostSink()
client, captured = _capturing_client()
monkeypatch.setattr(
reader,
"stream_agent_state_updates",
_RaisingStream(httpx.ConnectError("stream disabled for poll test")),
)
monkeypatch.setattr(reader, "get_trajectory_steps", script)
monkeypatch.setattr(reader, "post_session_event_with_retry", sink)
async def _noop_sleep(_seconds: float) -> None:
await asyncio.sleep(0)
monkeypatch.setattr(reader, "_sleep", _noop_sleep)
async def _on_pending(_cascade_id: str, _port: int, _pending: PendingInteraction) -> None:
# Simulate a long-poll await that the terminal answer / timeout will
# short-circuit — it never returns a verdict here.
await asyncio.Event().wait()
async with client:
await asyncio.wait_for(
reader.supervise_reader(
_bridge_dir(tmp_path),
_SESSION_ID,
client=client,
on_pending_interaction=cast(Any, _on_pending),
poll_interval_s=0.0,
stop=_stop_after(4),
),
timeout=5.0,
)
# Exactly one withdraw was posted, for THIS step's deterministic id.
expected_traj = waiting["metadata"]["sourceTrajectoryStepInfo"]["trajectoryId"]
expected_idx = waiting["metadata"]["sourceTrajectoryStepInfo"]["stepIndex"]
expected_id = agy_elicitation_id(_CASCADE_ID, expected_traj, expected_idx)
withdraws = [body for body in captured if body.get("type") == "external_elicitation_resolved"]
assert len(withdraws) == 1
assert withdraws[0]["data"]["elicitation_id"] == expected_id
@pytest.mark.asyncio
async def test_still_waiting_does_not_withdraw(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
patched_discovery: None,
) -> None:
"""A still-WAITING step is NOT withdrawn (the interaction is still live)."""
waiting = _permission_waiting()
script = _StepScript([[waiting], [waiting], [waiting]])
sink = _PostSink()
client, captured = _capturing_client()
monkeypatch.setattr(
reader,
"stream_agent_state_updates",
_RaisingStream(httpx.ConnectError("stream disabled for poll test")),
)
monkeypatch.setattr(reader, "get_trajectory_steps", script)
monkeypatch.setattr(reader, "post_session_event_with_retry", sink)
async def _noop_sleep(_seconds: float) -> None:
await asyncio.sleep(0)
monkeypatch.setattr(reader, "_sleep", _noop_sleep)
async def _on_pending(_cascade_id: str, _port: int, _pending: PendingInteraction) -> None:
await asyncio.Event().wait()
async with client:
await asyncio.wait_for(
reader.supervise_reader(
_bridge_dir(tmp_path),
_SESSION_ID,
client=client,
on_pending_interaction=cast(Any, _on_pending),
poll_interval_s=0.0,
stop=_stop_after(3),
),
timeout=5.0,
)
withdraws = [body for body in captured if body.get("type") == "external_elicitation_resolved"]
assert withdraws == []
@pytest.mark.asyncio
async def test_withdraw_posts_at_most_once(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
patched_discovery: None,
) -> None:
"""Even across many DONE re-reads, the withdraw is posted exactly once.
The dedup lives in ``surfaced_elicitations`` (popped on first withdraw), so a
steady-state poll that keeps returning the DONE step must not re-post.
"""
waiting = _permission_waiting()
done = _permission_done()
script = _StepScript([[waiting], [done], [done], [done], [done]])
sink = _PostSink()
client, captured = _capturing_client()
monkeypatch.setattr(
reader,
"stream_agent_state_updates",
_RaisingStream(httpx.ConnectError("stream disabled for poll test")),
)
monkeypatch.setattr(reader, "get_trajectory_steps", script)
monkeypatch.setattr(reader, "post_session_event_with_retry", sink)
async def _noop_sleep(_seconds: float) -> None:
await asyncio.sleep(0)
monkeypatch.setattr(reader, "_sleep", _noop_sleep)
async def _on_pending(_cascade_id: str, _port: int, _pending: PendingInteraction) -> None:
await asyncio.Event().wait()
async with client:
await asyncio.wait_for(
reader.supervise_reader(
_bridge_dir(tmp_path),
_SESSION_ID,
client=client,
on_pending_interaction=cast(Any, _on_pending),
poll_interval_s=0.0,
stop=_stop_after(5),
),
timeout=5.0,
)
withdraws = [body for body in captured if body.get("type") == "external_elicitation_resolved"]
assert len(withdraws) == 1
@pytest.mark.asyncio
async def test_withdraw_helper_pops_and_posts_once_directly() -> None:
"""Unit-level: ``_maybe_withdraw_interaction`` posts once then no-ops.
Drives the helper directly with a surfaced id so the pop/no-double-post
contract is asserted without the full supervise loop.
"""
from omnigent.antigravity_native_interactions import agy_elicitation_id
waiting = _permission_waiting()
done = _permission_done()
key = reader._step_key(waiting)
eid = agy_elicitation_id(
_CASCADE_ID,
waiting["metadata"]["sourceTrajectoryStepInfo"]["trajectoryId"],
waiting["metadata"]["sourceTrajectoryStepInfo"]["stepIndex"],
)
state = reader._ReaderState(
allocator=reader._ToolCallIdAllocator(conversation_id=_CASCADE_ID),
seen=set(),
interacted=set(),
port=_PORT,
)
state.surfaced_elicitations[key] = eid
client, captured = _capturing_client()
async with client:
# First call on a DONE step → posts the withdraw and pops the entry.
await reader._maybe_withdraw_interaction(
done, key=key, client=client, session_id=_SESSION_ID, state=state
)
# Second call → entry gone, no further post.
await reader._maybe_withdraw_interaction(
done, key=key, client=client, session_id=_SESSION_ID, state=state
)
assert key not in state.surfaced_elicitations
withdraws = [b for b in captured if b.get("type") == "external_elicitation_resolved"]
assert len(withdraws) == 1
assert withdraws[0]["data"]["elicitation_id"] == eid
@pytest.mark.asyncio
async def test_withdraw_helper_noop_while_still_waiting() -> None:
"""``_maybe_withdraw_interaction`` does nothing while the step is still WAITING."""
from omnigent.antigravity_native_interactions import agy_elicitation_id
waiting = _permission_waiting()
key = reader._step_key(waiting)
eid = agy_elicitation_id(
_CASCADE_ID,
waiting["metadata"]["sourceTrajectoryStepInfo"]["trajectoryId"],
waiting["metadata"]["sourceTrajectoryStepInfo"]["stepIndex"],
)
state = reader._ReaderState(
allocator=reader._ToolCallIdAllocator(conversation_id=_CASCADE_ID),
seen=set(),
interacted=set(),
port=_PORT,
)
state.surfaced_elicitations[key] = eid
client, captured = _capturing_client()
async with client:
await reader._maybe_withdraw_interaction(
waiting, key=key, client=client, session_id=_SESSION_ID, state=state
)
# Still WAITING → entry retained, nothing posted.
assert state.surfaced_elicitations.get(key) == eid
assert captured == []
# ---------------------------------------------------------------------------
# Status edges: RUNNING on user turn, IDLE on assistant-text close
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_status_running_then_idle_on_transition(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
patched_discovery: None,
) -> None:
"""USER_INPUT emits RUNNING; a closing assistant-text step emits IDLE; once each."""
user = _load("user_input")
text = _load("planner_response_text")
script = _StepScript(
[
[user],
[user, text],
[user, text],
]
)
sink = _PostSink()
await _run(
bridge_dir=_bridge_dir(tmp_path),
sink=sink,
steps=script,
monkeypatch=monkeypatch,
iterations=3,
)
# RUNNING (user turn) then IDLE (assistant answered, no tool calls), deduped.
assert sink.statuses() == ["running", "idle"]
@pytest.mark.asyncio
async def test_status_not_idle_while_tools_running(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
patched_discovery: None,
) -> None:
"""A planner step that only invokes a tool does not close the turn (no IDLE)."""
user = _load("user_input")
tool_call = _load("planner_response_tool_call_run_command")
script = _StepScript([[user], [user, tool_call], [user, tool_call]])
sink = _PostSink()
await _run(
bridge_dir=_bridge_dir(tmp_path),
sink=sink,
steps=script,
monkeypatch=monkeypatch,
iterations=3,
)
# Turn opened (RUNNING) but never closed: the planner step has tool calls.
assert sink.statuses() == ["running"]
@pytest.mark.asyncio
async def test_status_failed_on_error_planner_close(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
patched_discovery: None,
) -> None:
"""A turn that ends in a terminal-ERROR planner closes as FAILED (not idle).
A model/turn ERROR must surface as a failed turn — not a clean idle that
looks identical to a normal empty reply (#6). The turn still closes (the
spinner clears; ``turn_active`` resets so the next turn can re-open RUNNING),
but on the ``failed`` status edge, and the mapper commits a visible error
message item.
"""
user = _load("user_input")
error_planner = _load("planner_response_text")
error_planner["status"] = "CORTEX_STEP_STATUS_ERROR"
# No closing text — prove the close is from the ERROR-terminal rule.
error_planner["plannerResponse"] = {}
script = _StepScript([[user], [user, error_planner], [user, error_planner]])
sink = _PostSink()
await _run(
bridge_dir=_bridge_dir(tmp_path),
sink=sink,
steps=script,
monkeypatch=monkeypatch,
iterations=3,
)
# Closes as FAILED, not idle.
assert sink.statuses() == ["running", "failed"]
# The user message + a visible error item are committed (no silent empty reply).
assert sink.message_roles() == ["user", "assistant"]
# ---------------------------------------------------------------------------
# Error handling: a transient RPC failure does not crash the loop
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_http_error_does_not_crash_loop(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
patched_discovery: None,
) -> None:
"""An ``httpx.HTTPError`` on one poll is swallowed; the next poll recovers.
The reader is stream-primary, so ``_run`` injects a failing stream first
(consuming one ``stop`` tick on entry); the poll loop then needs two of its
own iterations to exercise the raise-then-recover ``get_trajectory_steps``,
hence ``iterations=3``.
"""
text = _load("planner_response_text")
steps = _RaisingThenOk(httpx.ConnectError("boom"), [text])
sink = _PostSink()
await _run(
bridge_dir=_bridge_dir(tmp_path),
sink=sink,
steps=steps,
monkeypatch=monkeypatch,
iterations=3,
)
# First poll raised; second poll delivered the message — loop survived.
assert steps.calls == 2
assert sink.item_types() == ["message"]
@pytest.mark.asyncio
async def test_value_error_does_not_crash_loop(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
patched_discovery: None,
) -> None:
"""A non-JSON 200 (``ValueError``) is swallowed too; the loop keeps polling.
``iterations=3`` for the same reason as the HTTP-error case: one tick is
spent on the stream attempt before the poll loop runs its two iterations.
"""
text = _load("planner_response_text")
steps = _RaisingThenOk(ValueError("not json"), [text])
sink = _PostSink()
await _run(
bridge_dir=_bridge_dir(tmp_path),
sink=sink,
steps=steps,
monkeypatch=monkeypatch,
iterations=3,
)
assert steps.calls == 2
assert sink.item_types() == ["message"]
# ---------------------------------------------------------------------------
# Discovery: a placeholder cascade id is treated as "not ready"
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_placeholder_conversation_id_waits_for_real_id(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
patched_discovery: None,
) -> None:
"""The reader polls past an ``agy_conv_*`` placeholder until the real id appears."""
bridge_dir = tmp_path / "bridge"
bridge_dir.mkdir()
state_path = bridge_dir / "state.json"
state_path.write_text(
json.dumps({"session_id": _SESSION_ID, "conversation_id": "agy_conv_placeholder"}),
encoding="utf-8",
)
text = _load("planner_response_text")
script = _StepScript([[text], [text]])
sink = _PostSink()
# Stream-primary reader: force the (committed-only) poll fallback so this test
# exercises poll-path discovery rather than a real stream connection.
monkeypatch.setattr(
reader,
"stream_agent_state_updates",
_RaisingStream(httpx.ConnectError("stream disabled for poll test")),
)
monkeypatch.setattr(reader, "get_trajectory_steps", script)
monkeypatch.setattr(reader, "post_session_event_with_retry", sink)
flip_calls = {"n": 0}
def _read_then_flip(bd: Path) -> object:
# Promote the placeholder to the real id after the first resolution poll
# so the reader is forced to wait for a real id before discovering.
flip_calls["n"] += 1
if flip_calls["n"] >= 2:
state_path.write_text(
json.dumps({"session_id": _SESSION_ID, "conversation_id": _CASCADE_ID}),
encoding="utf-8",
)
return read_bridge_state(bd)
monkeypatch.setattr(reader, "read_bridge_state", _read_then_flip)
async def _noop_sleep(_seconds: float) -> None:
# Yield to the event loop (no real delay) so the off-loop interaction
# bridge tasks the reader spawns get a turn to run in the bounded loop.
await asyncio.sleep(0)
monkeypatch.setattr(reader, "_sleep", _noop_sleep)
async def _on_pending(_cascade_id: str, _port: int, _pending: PendingInteraction) -> None:
return None
await reader.supervise_reader(
bridge_dir,
_SESSION_ID,
client=cast(httpx.AsyncClient, object()),
on_pending_interaction=cast(Any, _on_pending),
poll_interval_s=0.0,
# Budget covers: one discovery retry past the placeholder, the stream
# attempt (which fails), then the poll-fallback iteration that mirrors.
stop=_stop_after(4),
)
# The placeholder forced at least two cascade-id resolution passes, then the
# reader bound the real id and mirrored the step.
assert flip_calls["n"] >= 2
assert sink.item_types() == ["message"]
# ---------------------------------------------------------------------------
# Stream mode: incremental deltas during GENERATING, one committed message DONE
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_stream_generating_emits_incremental_deltas(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
patched_discovery: None,
) -> None:
"""Growing ``modifiedResponse`` while GENERATING emits non-overlapping deltas.
The deltas concatenate to the full partial text and share one stable
``message_id`` for the step (so the SPA coalesces them into one live block).
"""
full = "Hello! I am Antigravity, your AI coding assistant, ready to help."
cut1, cut2 = 6, 30 # "Hello!" then "Hello! I am Antigravity, your "
frames = [
_frame([_generating_planner(full[:cut1])]),
_frame([_generating_planner(full[:cut2])]),
_frame([_generating_planner(full)]),
]
sink = _PostSink()
await _run_stream(
bridge_dir=_bridge_dir(tmp_path),
sink=sink,
stream=_FrameScript(frames),
poll_steps=_StepScript([[]]),
monkeypatch=monkeypatch,
iterations=1,
)
deltas = sink.deltas()
# Three growing frames → three non-empty deltas.
assert [d["delta"] for d in deltas] == [full[:cut1], full[cut1:cut2], full[cut2:]]
# Suffixes concatenate exactly to the full text (no overlap, no gap).
assert "".join(cast(str, d["delta"]) for d in deltas) == full
# One stable per-step message_id; deltas are not final (committed item follows).
message_ids = {d["message_id"] for d in deltas}
assert message_ids == {f"antigravity:{_CASCADE_ID}:2:planner"}
assert all(d["final"] is False for d in deltas)
# No committed message yet — the step never reached DONE in this script.
assert sink.item_types() == []
@pytest.mark.asyncio
async def test_stream_done_emits_one_committed_message_after_deltas(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
patched_discovery: None,
) -> None:
"""GENERATING deltas precede exactly ONE committed message on DONE."""
full = "Hello there, friend."
frames = [
_frame([_generating_planner("Hello")]),
_frame([_generating_planner(full)]),
_frame([_done_planner(full)]),
]
sink = _PostSink()
await _run_stream(
bridge_dir=_bridge_dir(tmp_path),
sink=sink,
stream=_FrameScript(frames),
poll_steps=_StepScript([[]]),
monkeypatch=monkeypatch,
iterations=1,
)
# Exactly one committed assistant message.
assert sink.item_types() == ["message"]
# Delta-first ordering: every delta is posted BEFORE the committed item.
types = sink.event_types()
committed_idx = types.index("external_conversation_item")
delta_idxs = [i for i, t in enumerate(types) if t == "external_output_text_delta"]
assert delta_idxs, "expected at least one delta before the committed message"
assert max(delta_idxs) < committed_idx
# Deltas concatenate to the full committed text.
assert "".join(cast(str, d["delta"]) for d in sink.deltas()) == full
# The committed message carries the FINAL text (from the DONE step).
messages = [
data for event_type, data in sink.posts if event_type == "external_conversation_item"
]
item_data = cast(dict[str, Any], messages[0]["item_data"])
content = cast(list[dict[str, Any]], item_data["content"])
assert content[0]["text"] == full
@pytest.mark.asyncio
async def test_stream_generating_emits_incremental_reasoning_deltas(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
patched_discovery: None,
) -> None:
"""Growing ``thinking`` while GENERATING emits non-overlapping reasoning deltas.
Mirrors the text-delta contract for ``plannerResponse.thinking`` (design
§10.2): suffixes concatenate to the full reasoning, only the FIRST delta
carries ``started=True`` (so the server emits one ``response.reasoning.started``
before the block), and the rest carry ``started=False``.
"""
full = "Let me consider the request, then outline a plan before answering."
cut1, cut2 = 12, 38
frames = [
_frame([_generating_planner_with_thinking(thinking=full[:cut1])]),
_frame([_generating_planner_with_thinking(thinking=full[:cut2])]),
_frame([_generating_planner_with_thinking(thinking=full)]),
]
sink = _PostSink()
await _run_stream(
bridge_dir=_bridge_dir(tmp_path),
sink=sink,
stream=_FrameScript(frames),
poll_steps=_StepScript([[]]),
monkeypatch=monkeypatch,
iterations=1,
)
reasonings = sink.reasonings()
# Three growing frames → three non-empty reasoning deltas.
assert [r["delta"] for r in reasonings] == [full[:cut1], full[cut1:cut2], full[cut2:]]
# Suffixes concatenate exactly to the full reasoning (no overlap, no gap).
assert "".join(cast(str, r["delta"]) for r in reasonings) == full
# Only the first delta starts a new reasoning block.
assert [r["started"] for r in reasonings] == [True, False, False]
# Reasoning has no committed conversation item (the step never reached DONE).
assert sink.item_types() == []
@pytest.mark.asyncio
async def test_stream_reasoning_precedes_text_and_has_no_committed_item(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
patched_discovery: None,
) -> None:
"""Reasoning deltas precede text deltas (§10.2); DONE commits ONE message only.
Asserts the §10.2 ordering (thinking before response) within the frame and
that reasoning, unlike text, never produces a committed conversation item —
only the assistant ``message`` is committed on DONE.
"""
thinking = "First, parse intent. Then answer."
text = "Sure — here is the answer."
frames = [
_frame([_generating_planner_with_thinking(thinking="First, parse intent.", text="Sure")]),
_frame([_generating_planner_with_thinking(thinking=thinking, text=text)]),
_frame([_done_planner(text)]),
]
sink = _PostSink()
await _run_stream(
bridge_dir=_bridge_dir(tmp_path),
sink=sink,
stream=_FrameScript(frames),
poll_steps=_StepScript([[]]),
monkeypatch=monkeypatch,
iterations=1,
)
types = sink.event_types()
# Within each GENERATING frame the reasoning delta is posted before the text
# delta: the first reasoning post precedes the first text post.
first_reasoning = types.index("external_output_reasoning_delta")
first_text = types.index("external_output_text_delta")
assert first_reasoning < first_text
# Reasoning deltas concatenate to the full thinking text.
assert "".join(cast(str, r["delta"]) for r in sink.reasonings()) == thinking
# Exactly ONE committed item — the assistant message; NO committed reasoning.
assert sink.item_types() == ["message"]
# Every reasoning post is a delta (transient); none is a conversation item.
assert all(
event_type != "external_conversation_item"
or cast(dict[str, Any], data).get("item_type") != "reasoning"
for event_type, data in sink.posts
)
@pytest.mark.asyncio
async def test_stream_planner_without_thinking_emits_no_reasoning(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
patched_discovery: None,
) -> None:
"""A planner with no ``thinking`` field emits NO reasoning events (no regression).
The non-thinking model path must be untouched: text deltas still stream and
commit exactly as before, with zero reasoning deltas.
"""
full = "Plain answer, no chain-of-thought."
frames = [
_frame([_generating_planner(full[:10])]),
_frame([_generating_planner(full)]),
_frame([_done_planner(full)]),
]
sink = _PostSink()
await _run_stream(
bridge_dir=_bridge_dir(tmp_path),
sink=sink,
stream=_FrameScript(frames),
poll_steps=_StepScript([[]]),
monkeypatch=monkeypatch,
iterations=1,
)
# No reasoning events at all.
assert sink.reasonings() == []
# Text streaming + commit unchanged: deltas concatenate to the committed text.
assert "".join(cast(str, d["delta"]) for d in sink.deltas()) == full
assert sink.item_types() == ["message"]
@pytest.mark.asyncio
async def test_stream_reasoning_no_growth_frame_emits_nothing(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
patched_discovery: None,
) -> None:
"""A re-sent ``thinking`` snapshot (no growth) emits no duplicate reasoning delta.
Frames are cumulative; a frame that repeats the prior ``thinking`` must not
re-emit it. Only genuine growth produces a delta, and ``started`` fires once.
"""
thinking = "Reasoning that stops growing."
frames = [
_frame([_generating_planner_with_thinking(thinking=thinking)]),
# Same thinking again (no growth) → no second reasoning delta.
_frame([_generating_planner_with_thinking(thinking=thinking)]),
]
sink = _PostSink()
await _run_stream(
bridge_dir=_bridge_dir(tmp_path),
sink=sink,
stream=_FrameScript(frames),
poll_steps=_StepScript([[]]),
monkeypatch=monkeypatch,
iterations=1,
)
reasonings = sink.reasonings()
# Exactly one reasoning delta (the no-growth re-send adds nothing).
assert [r["delta"] for r in reasonings] == [thinking]
assert [r["started"] for r in reasonings] == [True]
@pytest.mark.asyncio
async def test_stream_reasoning_reanchors_after_non_monotonic_rewrite(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
patched_discovery: None,
) -> None:
"""A non-monotonic ``thinking`` rewrite re-anchors the tracker so growth resumes.
Regression for Fix A: the reasoning tracker must re-anchor UNCONDITIONALLY
(like the text path), not only when a delta is emitted. A frame streams
``"abcdef"``; the next frame is a non-monotonic rewrite ``"XYZ"`` (does not
extend the forwarded prefix → no delta); the third frame ``"XYZmore"`` DOES
extend the rewrite. Before the fix the tracker stayed pinned at ``"abcdef"``,
so ``"XYZmore"`` (which does not start with ``"abcdef"``) emitted nothing and
the step's reasoning froze forever; after the fix the rewrite re-anchors the
tracker to ``"XYZ"`` and the final ``"more"`` suffix is emitted.
"""
frames = [
_frame([_generating_planner_with_thinking(thinking="abcdef")]),
# Non-monotonic rewrite: does not extend "abcdef" → no delta, but must
# re-anchor the tracker to "XYZ".
_frame([_generating_planner_with_thinking(thinking="XYZ")]),
# Extends the re-anchored "XYZ" → the "more" suffix must be emitted.
_frame([_generating_planner_with_thinking(thinking="XYZmore")]),
]
sink = _PostSink()
await _run_stream(
bridge_dir=_bridge_dir(tmp_path),
sink=sink,
stream=_FrameScript(frames),
poll_steps=_StepScript([[]]),
monkeypatch=monkeypatch,
iterations=1,
)
deltas = [r["delta"] for r in sink.reasonings()]
# The first frame emits "abcdef"; the rewrite emits nothing; the recovery
# frame emits "more" only because the tracker re-anchored to "XYZ".
assert deltas == ["abcdef", "more"]
@pytest.mark.asyncio
async def test_stream_resent_done_snapshot_does_not_repost(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
patched_discovery: None,
) -> None:
"""A re-sent DONE step (on-connect snapshot replay) is deduped, not re-posted."""
full = "Done and done."
frames = [
_frame([_generating_planner(full)]),
_frame([_done_planner(full)]),
# Snapshot replay: the same DONE step arrives again in a later frame.
_frame([_done_planner(full)]),
_frame([_done_planner(full)]),
]
sink = _PostSink()
await _run_stream(
bridge_dir=_bridge_dir(tmp_path),
sink=sink,
stream=_FrameScript(frames),
poll_steps=_StepScript([[]]),
monkeypatch=monkeypatch,
iterations=1,
)
# Despite the DONE step repeating across three frames, one committed message.
assert sink.item_types() == ["message"]
@pytest.mark.asyncio
async def test_stream_on_connect_prior_done_snapshot_deduped(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
patched_discovery: None,
) -> None:
"""A prior-turn DONE step replayed on connect posts once, then never again."""
prior = _done_planner("Prior turn answer.", step_index=2)
# First frame is the on-connect snapshot of a prior (already-DONE) step; it
# repeats in the next frame (cumulative snapshot).
frames = [
_frame([prior]),
_frame([prior]),
]
sink = _PostSink()
await _run_stream(
bridge_dir=_bridge_dir(tmp_path),
sink=sink,
stream=_FrameScript(frames),
poll_steps=_StepScript([[]]),
monkeypatch=monkeypatch,
iterations=1,
)
# The committed prior step posts exactly once across the two snapshot frames.
assert sink.item_types() == ["message"]
@pytest.mark.asyncio
async def test_stream_tool_result_running_then_done_emits_output(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
patched_discovery: None,
) -> None:
"""A tool step seen RUNNING then DONE still emits its output (no early dedup).
Regression guard: the stream observes every intermediate status, so a
RUN_COMMAND surfaces RUNNING (mapper → ``[]``) before DONE. Recording its
identity as ``seen`` on the RUNNING sighting would dedup the DONE frame and
DROP the ``function_call_output``; the settled-only de-dup prevents that.
"""
tool_call = _load("planner_response_tool_call_run_command")
running = _running_run_command()
done = _load("run_command_done")
frames = [
_frame([tool_call, running]),
_frame([tool_call, running]), # still running — re-sent snapshot
_frame([tool_call, done]), # now complete
_frame([tool_call, done]), # snapshot replay of the DONE step
]
sink = _PostSink()
await _run_stream(
bridge_dir=_bridge_dir(tmp_path),
sink=sink,
stream=_FrameScript(frames),
poll_steps=_StepScript([[]]),
monkeypatch=monkeypatch,
iterations=1,
)
# The invocation commits once and the output commits once (despite the step
# being seen RUNNING twice before DONE, and DONE being replayed once).
assert sink.item_types() == ["function_call", "function_call_output"]
# ---------------------------------------------------------------------------
# Stream mode: WAITING frame → bridge callback once
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_stream_waiting_frame_invokes_callback_once(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
patched_discovery: None,
) -> None:
"""A WAITING step delivered over the stream hands its interaction once.
The stream path threads the SAME cascade id + port to the callback as the
poll path does, so the bridge targets agy's live conversation regardless of
which read path surfaced the interaction.
"""
waiting = _load("ask_question_waiting")
frames = [_frame([waiting]), _frame([waiting]), _frame([waiting])]
sink = _PostSink()
captured: list[tuple[str, int, PendingInteraction]] = []
async def _on_pending(cascade_id: str, port: int, pending: PendingInteraction) -> None:
captured.append((cascade_id, port, pending))
await _run_stream(
bridge_dir=_bridge_dir(tmp_path),
sink=sink,
stream=_FrameScript(frames),
poll_steps=_StepScript([[]]),
monkeypatch=monkeypatch,
iterations=2,
on_pending=_on_pending,
)
assert len(captured) == 1
cascade_id, port, pending = captured[0]
assert cascade_id == _CASCADE_ID
assert port == _PORT
assert pending["kind"] == "ask_question"
assert pending["trajectory_id"] == _CASCADE_ID
@pytest.mark.asyncio
async def test_stream_waiting_then_non_waiting_withdraws_elicitation(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
patched_discovery: None,
) -> None:
"""Over the STREAM path too, a WAITING step that goes DONE withdraws the card.
Confirms #1200 direction 2 is covered on the stream-primary path, not just the
poll fallback (a permission answered in the TUI / timed out surfaces as a
DONE frame after the WAITING frame).
"""
from omnigent.antigravity_native_interactions import agy_elicitation_id
waiting = _permission_waiting()
done = _permission_done()
frames = [_frame([waiting]), _frame([done]), _frame([done])]
sink = _PostSink()
client, captured = _capturing_client()
monkeypatch.setattr(reader, "stream_agent_state_updates", _FrameScript(frames))
monkeypatch.setattr(reader, "get_trajectory_steps", _StepScript([[]]))
monkeypatch.setattr(reader, "post_session_event_with_retry", sink)
async def _noop_sleep(_seconds: float) -> None:
await asyncio.sleep(0)
monkeypatch.setattr(reader, "_sleep", _noop_sleep)
async def _on_pending(_cascade_id: str, _port: int, _pending: PendingInteraction) -> None:
await asyncio.Event().wait()
async with client:
await asyncio.wait_for(
reader.supervise_reader(
_bridge_dir(tmp_path),
_SESSION_ID,
client=client,
on_pending_interaction=cast(Any, _on_pending),
poll_interval_s=0.0,
stop=_stop_after(1),
),
timeout=5.0,
)
expected_id = agy_elicitation_id(
_CASCADE_ID,
waiting["metadata"]["sourceTrajectoryStepInfo"]["trajectoryId"],
waiting["metadata"]["sourceTrajectoryStepInfo"]["stepIndex"],
)
withdraws = [b for b in captured if b.get("type") == "external_elicitation_resolved"]
assert len(withdraws) == 1
assert withdraws[0]["data"]["elicitation_id"] == expected_id
# ---------------------------------------------------------------------------
# Stream mode: status edges (USER_INPUT → RUNNING, assistant-text DONE → IDLE)
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_stream_status_running_then_idle(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
patched_discovery: None,
) -> None:
"""A user turn then a closing assistant-text step emit RUNNING then IDLE."""
user = _load("user_input")
full = "All set."
frames = [
_frame([user]),
_frame([user, _generating_planner(full)]),
_frame([user, _done_planner(full)]),
]
sink = _PostSink()
await _run_stream(
bridge_dir=_bridge_dir(tmp_path),
sink=sink,
stream=_FrameScript(frames),
poll_steps=_StepScript([[]]),
monkeypatch=monkeypatch,
iterations=1,
)
assert sink.statuses() == ["running", "idle"]
# USER_INPUT commits the user message first, then the assistant message —
# so the web UI renders the user turn ABOVE the reply (#1155).
assert sink.item_types() == ["message", "message"]
assert sink.message_roles() == ["user", "assistant"]
# ---------------------------------------------------------------------------
# Stream mode: /clear-rotation guard (design §10.5)
# ---------------------------------------------------------------------------
# ---------------------------------------------------------------------------
# Stream mode: a stream error falls back to the poll loop (no crash)
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_stream_http_error_falls_back_to_poll(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
patched_discovery: None,
) -> None:
"""A stream ``httpx.HTTPError`` falls back to the committed-only poll loop."""
text = _load("planner_response_text")
stream = _RaisingStream(httpx.ConnectError("stream boom"))
poll = _StepScript([[text], [text]])
sink = _PostSink()
await _run_stream(
bridge_dir=_bridge_dir(tmp_path),
sink=sink,
stream=stream,
poll_steps=poll,
monkeypatch=monkeypatch,
iterations=2,
)
# The stream was attempted, then the poll loop delivered the committed item.
assert stream.calls >= 1
assert poll.calls >= 1
# Poll path is committed-only (no deltas), so exactly one message, no deltas.
assert sink.item_types() == ["message"]
assert sink.deltas() == []
@pytest.mark.asyncio
async def test_stream_trailer_error_falls_back_to_poll(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
patched_discovery: None,
) -> None:
"""An ``AntigravityRpcError`` (connect trailer error) also falls back to poll."""
text = _load("planner_response_text")
stream = _RaisingStream(AntigravityRpcError("agy connect-stream error: boom"))
poll = _StepScript([[text], [text]])
sink = _PostSink()
await _run_stream(
bridge_dir=_bridge_dir(tmp_path),
sink=sink,
stream=stream,
poll_steps=poll,
monkeypatch=monkeypatch,
iterations=2,
)
assert stream.calls >= 1
assert sink.item_types() == ["message"]
assert sink.deltas() == []
@pytest.mark.asyncio
async def test_stream_error_midway_falls_back_without_losing_prior_deltas(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
patched_discovery: None,
) -> None:
"""A stream that yields a delta then errors falls back without crashing.
Verifies the reader survives a mid-stream failure (deltas already forwarded
stay forwarded) and the poll loop then delivers the committed item.
"""
full = "Half a message"
done_full = "Half a message, now complete."
class _DeltaThenRaise:
def __init__(self) -> None:
self.calls = 0
def __call__(self, port: int, conversation_id: str) -> AsyncIterator[dict[str, object]]:
self.calls += 1
async def _gen() -> AsyncIterator[dict[str, object]]:
yield _frame([_generating_planner(full)])
raise httpx.ReadError("mid-stream drop")
return _gen()
stream = _DeltaThenRaise()
poll = _StepScript([[_done_planner(done_full)], [_done_planner(done_full)]])
sink = _PostSink()
await _run_stream(
bridge_dir=_bridge_dir(tmp_path),
sink=sink,
stream=stream,
poll_steps=poll,
monkeypatch=monkeypatch,
iterations=2,
)
# The pre-error delta was forwarded.
assert [d["delta"] for d in sink.deltas()] == [full]
# The poll fallback delivered the committed message.
assert sink.item_types() == ["message"]
# ---------------------------------------------------------------------------
# Telemetry: external_session_usage (Task T-EF)
# ---------------------------------------------------------------------------
# Fake model catalog returned by the injected ``get_available_models`` stub.
_FAKE_CATALOG: dict[str, object] = {
"models": {
"m20": {
"model": "MODEL_PLACEHOLDER_M20",
"displayName": "Gemini 2.5 Flash",
"recommended": True,
"supportsThinking": True,
"thinkingBudget": 8192,
},
"m132": {
"model": "MODEL_PLACEHOLDER_M132",
"displayName": "Gemini 2.5 Pro",
"recommended": False,
"supportsThinking": True,
"thinkingBudget": 16384,
},
}
}
def _planner_with_model_usage(
*,
step_index: int = 2,
input_tokens: str = "1000",
output_tokens: str = "100",
thinking_tokens: str = "40",
response_tokens: str = "60",
cache_read_tokens: str = "200",
model_enum: str = "MODEL_PLACEHOLDER_M20",
with_requested_model: bool = True,
) -> dict[str, Any]:
"""A DONE PLANNER_RESPONSE step with modelUsage and requestedModel populated.
Built from the real fixture so metadata shape is authentic; the usage
fields are overridden so tests can control exact values.
"""
step = copy.deepcopy(_load("planner_response_text"))
step["status"] = "CORTEX_STEP_STATUS_DONE"
metadata = cast(dict[str, Any], step["metadata"])
metadata["modelUsage"] = {
"model": model_enum,
"inputTokens": input_tokens,
"outputTokens": output_tokens,
"thinkingOutputTokens": thinking_tokens,
"responseOutputTokens": response_tokens,
"cacheReadTokens": cache_read_tokens,
}
metadata["sourceTrajectoryStepInfo"]["stepIndex"] = step_index
if with_requested_model:
metadata.setdefault("requestedModel", {})["model"] = model_enum
return step
def _user_input_with_model(
model_enum: str = "MODEL_PLACEHOLDER_M20",
*,
step_index: int | None = None,
) -> dict[str, Any]:
"""A USER_INPUT step with a specific ``planModel`` enum (live wire shape).
:param model_enum: agy model enum string, written as the live
``plannerConfig.planModel`` string.
:param step_index: Optional step index override; when provided it is written
into ``metadata.sourceTrajectoryStepInfo.stepIndex`` so two consecutive
turns can be assigned distinct dedup keys (the base fixture has no
``stepIndex``, so they would otherwise share the same ``(trajectory_id,
None)`` key and the second turn would be silently de-duped).
"""
step = copy.deepcopy(_load("user_input"))
user_input = cast(dict[str, Any], step["userInput"])
planner_cfg = cast(dict[str, Any], user_input["userConfig"]["plannerConfig"])
planner_cfg["planModel"] = model_enum
if step_index is not None:
traj_info = cast(dict[str, Any], step["metadata"])["sourceTrajectoryStepInfo"]
traj_info["stepIndex"] = step_index
return step
def _user_input_real_wire(
*,
execution_id: str,
model_enum: str = "MODEL_PLACEHOLDER_M20",
) -> dict[str, Any]:
"""A USER_INPUT step shaped like the REAL agy wire: per-turn id, NO stepIndex.
Unlike :func:`_user_input_with_model` (which can inject a synthetic
``stepIndex`` to hand two turns distinct dedup keys), this models the live
shape exactly: USER_INPUT carries ``metadata.executionId`` (a per-turn uuid)
but NO ``sourceTrajectoryStepInfo.stepIndex``, so the only thing that
distinguishes two turns' USER_INPUT steps is the discriminator. The static
fixture's fixed ``executionId`` is overridden per turn so the two turns do
not themselves collide.
"""
step = copy.deepcopy(_load("user_input"))
metadata = cast(dict[str, Any], step["metadata"])
metadata["executionId"] = execution_id
# Make absolutely sure no stepIndex sneaks in (the base fixture has none).
metadata["sourceTrajectoryStepInfo"].pop("stepIndex", None)
user_input = cast(dict[str, Any], step["userInput"])
planner_cfg = cast(dict[str, Any], user_input["userConfig"]["plannerConfig"])
planner_cfg["planModel"] = model_enum
return step
async def _run_with_telemetry(
*,
bridge_dir: Path,
sink: _PostSink,
stream: object,
poll_steps: object,
monkeypatch: pytest.MonkeyPatch,
iterations: int,
catalog: dict[str, object] | None = None,
) -> None:
"""Drive ``supervise_reader`` in STREAM mode with a fake model catalog."""
fake_catalog = catalog if catalog is not None else _FAKE_CATALOG
monkeypatch.setattr(reader, "stream_agent_state_updates", stream)
monkeypatch.setattr(reader, "get_trajectory_steps", poll_steps)
monkeypatch.setattr(reader, "post_session_event_with_retry", sink)
monkeypatch.setattr(
reader,
"get_available_models",
lambda port: fake_catalog,
)
async def _noop_sleep(_seconds: float) -> None:
# Yield to the event loop (no real delay) so the off-loop interaction
# bridge tasks the reader spawns get a turn to run in the bounded loop.
await asyncio.sleep(0)
monkeypatch.setattr(reader, "_sleep", _noop_sleep)
async def _on_pending(_cascade_id: str, _port: int, _pending: PendingInteraction) -> None:
return None
await reader.supervise_reader(
bridge_dir,
_SESSION_ID,
client=cast(httpx.AsyncClient, object()),
on_pending_interaction=cast(Any, _on_pending),
poll_interval_s=0.0,
stop=_stop_after(iterations),
)
@pytest.mark.asyncio
async def test_planner_done_emits_session_usage(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
patched_discovery: None,
) -> None:
"""A PLANNER_RESPONSE DONE with modelUsage emits exactly one external_session_usage.
The event data must map agy's string-int fields onto the Omnigent shape:
- cumulative_input_tokens = inputTokens (int)
- cumulative_output_tokens = outputTokens (int)
- cumulative_cache_read_input_tokens = cacheReadTokens (int)
- model = the displayName from the catalog (not the raw enum)
"""
planner = _planner_with_model_usage(
input_tokens="1000",
output_tokens="100",
cache_read_tokens="200",
model_enum="MODEL_PLACEHOLDER_M20",
)
frames = [_frame([planner])]
sink = _PostSink()
await _run_with_telemetry(
bridge_dir=_bridge_dir(tmp_path),
sink=sink,
stream=_FrameScript(frames),
poll_steps=_StepScript([[]]),
monkeypatch=monkeypatch,
iterations=1,
)
usage_events = [(et, d) for et, d in sink.posts if et == "external_session_usage"]
assert len(usage_events) == 1, f"expected 1 usage event, got {len(usage_events)}"
_, usage_data = usage_events[0]
assert usage_data["cumulative_input_tokens"] == 1000
assert usage_data["cumulative_output_tokens"] == 100
assert usage_data["cumulative_cache_read_input_tokens"] == 200
assert usage_data["model"] == "Gemini 2.5 Flash"
@pytest.mark.asyncio
async def test_usage_replay_does_not_re_emit(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
patched_discovery: None,
) -> None:
"""A DONE planner step replayed on the same stream does NOT re-emit usage.
The step's ``(trajectory_id, step_index)`` identity is already in
``state.seen`` after the first DONE frame, so subsequent re-sends of the
same step post nothing (usage included).
"""
planner = _planner_with_model_usage()
# Same DONE step repeated across three frames (snapshot replay pattern).
frames = [_frame([planner]), _frame([planner]), _frame([planner])]
sink = _PostSink()
await _run_with_telemetry(
bridge_dir=_bridge_dir(tmp_path),
sink=sink,
stream=_FrameScript(frames),
poll_steps=_StepScript([[]]),
monkeypatch=monkeypatch,
iterations=1,
)
usage_events = [et for et, _ in sink.posts if et == "external_session_usage"]
assert usage_events == ["external_session_usage"]
@pytest.mark.asyncio
async def test_usage_missing_fields_skipped_gracefully(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
patched_discovery: None,
) -> None:
"""A planner DONE step without modelUsage emits no usage event (no crash)."""
planner = copy.deepcopy(_load("planner_response_text"))
planner["status"] = "CORTEX_STEP_STATUS_DONE"
metadata = cast(dict[str, Any], planner["metadata"])
metadata.pop("modelUsage", None)
frames = [_frame([planner])]
sink = _PostSink()
await _run_with_telemetry(
bridge_dir=_bridge_dir(tmp_path),
sink=sink,
stream=_FrameScript(frames),
poll_steps=_StepScript([[]]),
monkeypatch=monkeypatch,
iterations=1,
)
usage_events = [et for et, _ in sink.posts if et == "external_session_usage"]
assert usage_events == []
# ---------------------------------------------------------------------------
# Telemetry: external_model_change (Task T-EF)
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_first_turn_emits_model_change(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
patched_discovery: None,
) -> None:
"""The first USER_INPUT step with a new model enum emits external_model_change.
The event data must carry the resolved displayName, NOT the raw enum.
"""
user = _user_input_with_model("MODEL_PLACEHOLDER_M20")
frames = [_frame([user])]
sink = _PostSink()
await _run_with_telemetry(
bridge_dir=_bridge_dir(tmp_path),
sink=sink,
stream=_FrameScript(frames),
poll_steps=_StepScript([[]]),
monkeypatch=monkeypatch,
iterations=1,
)
model_change_events = [(et, d) for et, d in sink.posts if et == "external_model_change"]
assert len(model_change_events) == 1, (
f"expected 1 model_change event, got {len(model_change_events)}"
)
_, mc_data = model_change_events[0]
assert mc_data["model"] == "Gemini 2.5 Flash"
@pytest.mark.asyncio
async def test_same_model_second_turn_no_new_model_change(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
patched_discovery: None,
) -> None:
"""A second turn with the SAME model enum emits no additional model_change event."""
user1 = _user_input_with_model("MODEL_PLACEHOLDER_M20", step_index=0)
user2 = _user_input_with_model("MODEL_PLACEHOLDER_M20", step_index=4)
# Two separate turns each start with a USER_INPUT, same model.
frames = [_frame([user1]), _frame([user1, user2])]
sink = _PostSink()
await _run_with_telemetry(
bridge_dir=_bridge_dir(tmp_path),
sink=sink,
stream=_FrameScript(frames),
poll_steps=_StepScript([[]]),
monkeypatch=monkeypatch,
iterations=1,
)
model_change_events = [et for et, _ in sink.posts if et == "external_model_change"]
assert len(model_change_events) == 1, (
f"expected exactly 1 model_change, got {len(model_change_events)}"
)
@pytest.mark.asyncio
async def test_model_switch_mid_session_emits_new_model_change(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
patched_discovery: None,
) -> None:
"""A turn with a DIFFERENT model enum triggers a new external_model_change."""
user_m20 = _user_input_with_model("MODEL_PLACEHOLDER_M20", step_index=0)
user_m132 = _user_input_with_model("MODEL_PLACEHOLDER_M132", step_index=4)
# Turn 1 with M20, then turn 2 with M132.
frames = [_frame([user_m20]), _frame([user_m20, user_m132])]
sink = _PostSink()
await _run_with_telemetry(
bridge_dir=_bridge_dir(tmp_path),
sink=sink,
stream=_FrameScript(frames),
poll_steps=_StepScript([[]]),
monkeypatch=monkeypatch,
iterations=1,
)
model_change_events = [(et, d) for et, d in sink.posts if et == "external_model_change"]
assert len(model_change_events) == 2, (
f"expected 2 model_change events (one per distinct model), got {len(model_change_events)}"
)
assert model_change_events[0][1]["model"] == "Gemini 2.5 Flash"
assert model_change_events[1][1]["model"] == "Gemini 2.5 Pro"
@pytest.mark.asyncio
async def test_model_change_replay_no_re_emit(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
patched_discovery: None,
) -> None:
"""A USER_INPUT step replayed across frames emits model_change only once."""
user = _user_input_with_model("MODEL_PLACEHOLDER_M20")
frames = [_frame([user]), _frame([user]), _frame([user])]
sink = _PostSink()
await _run_with_telemetry(
bridge_dir=_bridge_dir(tmp_path),
sink=sink,
stream=_FrameScript(frames),
poll_steps=_StepScript([[]]),
monkeypatch=monkeypatch,
iterations=1,
)
model_change_events = [et for et, _ in sink.posts if et == "external_model_change"]
assert len(model_change_events) == 1
@pytest.mark.asyncio
async def test_unknown_model_enum_posts_raw_enum(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
patched_discovery: None,
) -> None:
"""An unresolvable model enum falls back to the raw enum string as the model name."""
unknown_enum = "MODEL_PLACEHOLDER_M999"
user = _user_input_with_model(unknown_enum)
frames = [_frame([user])]
sink = _PostSink()
await _run_with_telemetry(
bridge_dir=_bridge_dir(tmp_path),
sink=sink,
stream=_FrameScript(frames),
poll_steps=_StepScript([[]]),
monkeypatch=monkeypatch,
iterations=1,
)
model_change_events = [(et, d) for et, d in sink.posts if et == "external_model_change"]
assert len(model_change_events) == 1
# Falls back to the raw enum when the catalog does not contain it.
assert model_change_events[0][1]["model"] == unknown_enum
def test_requested_model_enum_from_step_falls_back_to_requested_model() -> None:
"""A legacy ``requestedModel.model`` (dict) shape is read when ``planModel`` is absent.
The live wire carries ``plannerConfig.planModel`` (a string), but a TUI-origin
step may still use the older ``requestedModel.model`` dict shape. The reader
must read that fallback so a model switch from such a step is not silently
dropped.
"""
legacy_step: dict[str, Any] = {
"type": reader._TYPE_USER_INPUT,
"userInput": {
"userConfig": {"plannerConfig": {"requestedModel": {"model": "MODEL_PLACEHOLDER_M20"}}}
},
}
assert reader._requested_model_enum_from_step(legacy_step) == "MODEL_PLACEHOLDER_M20"
@pytest.mark.asyncio
async def test_two_turn_usage_is_cumulative(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
patched_discovery: None,
) -> None:
"""Two turns each with 1000 input tokens → turn 1 posts 1000, turn 2 posts 2000.
Regression guard for the SET-semantics bug: if the reader emitted per-call
values, turn 2 would also post 1000, causing the server to compute a zero
delta for that turn and the cost badge to freeze after turn 1.
"""
planner_turn1 = _planner_with_model_usage(
step_index=2,
input_tokens="1000",
output_tokens="50",
cache_read_tokens="100",
)
planner_turn2 = _planner_with_model_usage(
step_index=6,
input_tokens="1000",
output_tokens="50",
cache_read_tokens="100",
)
# Two separate DONE planner steps (different step indices = different turns).
frames = [
_frame([planner_turn1]),
_frame([planner_turn1, planner_turn2]),
]
sink = _PostSink()
await _run_with_telemetry(
bridge_dir=_bridge_dir(tmp_path),
sink=sink,
stream=_FrameScript(frames),
poll_steps=_StepScript([[]]),
monkeypatch=monkeypatch,
iterations=1,
)
usage_events = [(et, d) for et, d in sink.posts if et == "external_session_usage"]
assert len(usage_events) == 2, (
f"expected 2 usage events (one per turn), got {len(usage_events)}"
)
# Turn 1: per-call values (first turn, cumulative == per-call).
assert usage_events[0][1]["cumulative_input_tokens"] == 1000
assert usage_events[0][1]["cumulative_output_tokens"] == 50
assert usage_events[0][1]["cumulative_cache_read_input_tokens"] == 100
# Turn 2: RUNNING total (2000 input, not 1000 again).
assert usage_events[1][1]["cumulative_input_tokens"] == 2000
assert usage_events[1][1]["cumulative_output_tokens"] == 100
assert usage_events[1][1]["cumulative_cache_read_input_tokens"] == 200
# ---------------------------------------------------------------------------
# USER_INPUT dedup key: real-wire turns (no stepIndex) must not collide
# ---------------------------------------------------------------------------
def test_step_key_distinct_for_user_input_turns_without_step_index() -> None:
"""Two USER_INPUT steps with distinct executionId key distinctly.
Regression for the dedup-key collision (Fix I-1): on the real wire a
USER_INPUT step has a per-conversation-stable ``trajectory_id`` and NO
``stepIndex``, so a ``(trajectory_id, None)`` key collides across every turn.
Folding the per-turn ``executionId`` into the key keeps each turn distinct.
Without the fix both keys are ``(trajectory_id, None)`` and compare equal.
"""
turn1 = _user_input_real_wire(execution_id="exec-turn-1")
turn2 = _user_input_real_wire(execution_id="exec-turn-2")
key1 = reader._step_key(turn1)
key2 = reader._step_key(turn2)
assert key1 != key2, f"USER_INPUT keys collided across turns: {key1} == {key2}"
# The discriminator (3rd element) is what separates them — the first two
# elements are identical (same trajectory_id, both step_index None).
assert key1[:2] == key2[:2]
assert key1[2] == "exec-turn-1"
assert key2[2] == "exec-turn-2"
@pytest.mark.asyncio
async def test_two_real_wire_turns_each_emit_running_then_idle(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
patched_discovery: None,
) -> None:
"""Two turns whose USER_INPUT carries NO stepIndex each fire RUNNING + IDLE.
End-to-end regression for Fix I-1. Each turn is a USER_INPUT (opens the
turn → RUNNING) followed by a DONE assistant-text planner (closes the turn →
IDLE). The USER_INPUT steps are built with the REAL wire shape (per-turn
``executionId``, no ``stepIndex``) — NOT the synthetic-stepIndex helper that
masks this bug. Before the fix, turn 2's USER_INPUT collides with turn 1's on
``(trajectory_id, None)``, is treated as already-seen, and its emit block is
skipped → no second RUNNING edge (the web spinner never restarts), so the
sequence would be ``["running", "idle", "idle"]`` instead of the expected
per-turn ``["running", "idle", "running", "idle"]``.
"""
user1 = _user_input_real_wire(execution_id="exec-turn-1")
user2 = _user_input_real_wire(execution_id="exec-turn-2")
done1 = _done_planner("First answer.", step_index=2)
done2 = _done_planner("Second answer.", step_index=6)
# The stream delivers cumulative snapshots: turn 2's frames include turn 1's
# already-committed steps.
frames = [
_frame([user1]),
_frame([user1, done1]),
_frame([user1, done1, user2]),
_frame([user1, done1, user2, done2]),
]
sink = _PostSink()
await _run_stream(
bridge_dir=_bridge_dir(tmp_path),
sink=sink,
stream=_FrameScript(frames),
poll_steps=_StepScript([[]]),
monkeypatch=monkeypatch,
iterations=1,
)
assert sink.statuses() == ["running", "idle", "running", "idle"]
# Each turn commits a user message (from USER_INPUT) then an assistant
# message — user-before-assistant ordering, per turn (#1155).
assert sink.item_types() == ["message", "message", "message", "message"]
assert sink.message_roles() == ["user", "assistant", "user", "assistant"]
# ---------------------------------------------------------------------------
# _is_assistant_text_close_step: the turn-close edge fires only on DONE
# ---------------------------------------------------------------------------
def test_close_step_false_for_generating_planner_with_text() -> None:
"""A GENERATING planner with text but no tool calls does NOT close the turn.
Regression: the IDLE status edge must fire only on the DONE closing step.
A GENERATING frame already carries growing ``modifiedResponse`` text with no
``toolCalls`` yet, so without the DONE gate the reader would close the turn
(the spinner) mid-response on the stream path.
"""
generating = {
"type": "CORTEX_STEP_TYPE_PLANNER_RESPONSE",
"status": "CORTEX_STEP_STATUS_GENERATING",
"plannerResponse": {"modifiedResponse": "Partial answer so far"},
}
assert reader._is_assistant_text_close_step(generating) is False
def test_close_step_true_for_done_planner_with_text() -> None:
"""The SAME step at status DONE (text, no tool calls) DOES close the turn."""
done = {
"type": "CORTEX_STEP_TYPE_PLANNER_RESPONSE",
"status": "CORTEX_STEP_STATUS_DONE",
"plannerResponse": {
"modifiedResponse": "Partial answer so far",
"response": "Partial answer so far",
},
}
assert reader._is_assistant_text_close_step(done) is True
def test_close_step_false_for_done_planner_with_tool_calls() -> None:
"""A DONE planner that still issues a tool call does NOT close the turn.
Confirms the DONE gate did not regress the tool-call carve-out: a planner
step that invokes a tool is followed by the tool result (and possibly more
planner steps), so it must not be treated as the closing edge.
"""
done_with_tool = {
"type": "CORTEX_STEP_TYPE_PLANNER_RESPONSE",
"status": "CORTEX_STEP_STATUS_DONE",
"plannerResponse": {
"response": "Running a command",
"toolCalls": [{"id": "call_1"}],
},
}
assert reader._is_assistant_text_close_step(done_with_tool) is False
# ---------------------------------------------------------------------------
# _is_turn_close_step: also closes on a terminal-ERROR or degenerate-DONE
# planner so the turn never sticks open (the stuck-spinner regression)
# ---------------------------------------------------------------------------
def test_turn_close_true_for_error_planner() -> None:
"""A terminal-ERROR planner closes the turn even with no closing text.
Regression: without this the IDLE edge never fires when a turn ends in an
ERROR planner — ``turn_active`` sticks True, the spinner never clears, and
the next USER_INPUT can't re-open RUNNING (it is gated on ``not
turn_active``). The text-close predicate (which requires DONE + text) does
NOT catch this case.
"""
error_planner = {
"type": "CORTEX_STEP_TYPE_PLANNER_RESPONSE",
"status": "CORTEX_STEP_STATUS_ERROR",
"plannerResponse": {},
}
assert reader._is_assistant_text_close_step(error_planner) is False
assert reader._is_turn_close_step(error_planner) is True
def test_turn_close_true_for_done_planner_no_text_no_tools() -> None:
"""A DONE planner with neither text nor tool calls is a degenerate close."""
empty_done = {
"type": "CORTEX_STEP_TYPE_PLANNER_RESPONSE",
"status": "CORTEX_STEP_STATUS_DONE",
"plannerResponse": {},
}
assert reader._is_turn_close_step(empty_done) is True
def test_turn_close_false_for_done_planner_with_tool_calls() -> None:
"""A DONE planner that dispatches a tool call is a continuation, not a close."""
done_with_tool = {
"type": "CORTEX_STEP_TYPE_PLANNER_RESPONSE",
"status": "CORTEX_STEP_STATUS_DONE",
"plannerResponse": {"toolCalls": [{"id": "call_1"}]},
}
assert reader._is_turn_close_step(done_with_tool) is False
def test_turn_close_false_for_generating_planner() -> None:
"""A GENERATING planner never closes the turn (mid-stream)."""
generating = {
"type": "CORTEX_STEP_TYPE_PLANNER_RESPONSE",
"status": "CORTEX_STEP_STATUS_GENERATING",
"plannerResponse": {"modifiedResponse": "partial"},
}
assert reader._is_turn_close_step(generating) is False
def test_turn_close_false_for_tool_result_step() -> None:
"""A tool-result step never closes the turn (a recovery planner follows)."""
tool_result = {
"type": "CORTEX_STEP_TYPE_RUN_COMMAND",
"status": "CORTEX_STEP_STATUS_DONE",
"metadata": {"toolCall": {"id": "cbawg2v8"}},
}
assert reader._is_turn_close_step(tool_result) is False
# ---------------------------------------------------------------------------
# Stream re-entry backoff: an immediate clean trailer must not busy-spin
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_stream_reentry_backoff_between_clean_immediate_returns(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
patched_discovery: None,
) -> None:
"""A stream that returns immediately with no frames backs off between re-entries.
Regression for the busy-spin: if agy returns an immediate clean trailer (no
frames) repeatedly, ``_stream_loop`` must NOT re-POST the stream at zero
delay — it must await ``_STREAM_REENTRY_BACKOFF_S`` between re-entries.
The ``stop`` predicate here fires once the stream has been entered
``target_entries`` times, so the assertion is tied to stream re-entries (not
to how many times ``stop`` is consulted per loop turn). Each backoff is gated
on ``not stop()`` AFTER the stream returns, so the run records exactly one
backoff per re-entry that is followed by another entry.
The reader body runs as a cancellable task alongside the rotation detector
(both share the ``_sleep`` seam), so the detector's own coarse interval sleep
can also be recorded before it is cancelled. That sleep is unrelated to the
busy-spin this test guards, so the recorded sleeps are filtered to the stream
re-entry backoff: the invariant is "no zero-delay re-POST", i.e. every re-entry
that precedes another entry paid exactly one backoff.
"""
empty_stream = _FrameScript([]) # each entry yields no frames, returns at once
backoff_sleeps: list[float] = []
async def _record_sleep(seconds: float) -> None:
backoff_sleeps.append(seconds)
target_entries = 3
def _stop_after_entries() -> bool:
# Stop once the stream has been (re-)entered the target number of times.
return empty_stream.calls >= target_entries
monkeypatch.setattr(reader, "stream_agent_state_updates", empty_stream)
monkeypatch.setattr(reader, "get_trajectory_steps", _StepScript([[]]))
monkeypatch.setattr(reader, "post_session_event_with_retry", _PostSink())
monkeypatch.setattr(reader, "_sleep", _record_sleep)
async def _noop_pending(_cid: str, _port: int, _pending: PendingInteraction) -> None:
return None
await reader.supervise_reader(
_bridge_dir(tmp_path),
_SESSION_ID,
client=cast(httpx.AsyncClient, object()),
on_pending_interaction=cast(Any, _noop_pending),
poll_interval_s=0.0,
stop=_stop_after_entries,
)
# The stream was re-entered exactly target_entries times (no crash, no
# fallback to the poll loop). Filtering out the rotation detector's coarse
# interval sleep (it shares the ``_sleep`` seam), the re-entry backoffs are
# exactly the documented value — proving the loop did NOT busy-spin re-POSTing
# at zero delay, and that no poll-interval sleep ran. There is one backoff per
# re-entry that precedes another entry: target_entries - 1.
assert empty_stream.calls == target_entries
reentry_backoffs = [s for s in backoff_sleeps if s == reader._STREAM_REENTRY_BACKOFF_S]
assert reentry_backoffs == [reader._STREAM_REENTRY_BACKOFF_S] * (target_entries - 1)
# No zero-delay re-POST and no unexpected poll-interval sleep crept in: every
# non-backoff sleep recorded is the rotation detector's coarse interval.
assert all(
s in (reader._STREAM_REENTRY_BACKOFF_S, reader._DEFAULT_ROTATION_INTERVAL_S)
for s in backoff_sleeps
)
# ---------------------------------------------------------------------------
# T-G: _detect_rotated_cascade (pure /clear-rotation detection)
# ---------------------------------------------------------------------------
#
# Fixtures mirror the real ``GetAllCascadeTrajectories`` capture shape: each
# summary is keyed by its root conversation id and carries a ``trajectoryType``
# plus ISO-8601 ``lastUserInputTime`` / ``lastModifiedTime`` (a ``Z`` UTC
# suffix), with the freshly-minted-but-unused cascade omitting both.
_BOUND_CASCADE = "0715c922-02fc-4278-bab8-3a6ea565bbbf"
_OTHER_CASCADE = "ef42f24d-7dfd-4810-a5f0-9e069c88709a"
def _summary(
*,
last_user_input_time: str | None = None,
last_modified_time: str | None = None,
trajectory_type: str = "CORTEX_TRAJECTORY_TYPE_CASCADE",
) -> dict[str, Any]:
"""Build one ``trajectorySummaries`` entry (real-capture shape).
Omitting a timestamp (``None``) models a never-set field, exactly as the live
capture omits ``lastUserInputTime`` / ``lastModifiedTime`` for a freshly
``/clear``-minted, never-used cascade.
"""
entry: dict[str, Any] = {
"trajectoryId": "tid-" + trajectory_type[-4:],
"status": "CASCADE_RUN_STATUS_IDLE",
"trajectoryType": trajectory_type,
}
if last_user_input_time is not None:
entry["lastUserInputTime"] = last_user_input_time
if last_modified_time is not None:
entry["lastModifiedTime"] = last_modified_time
return entry
def test_detect_rotation_newer_active_sibling_returns_its_id() -> None:
"""A sibling cascade with strictly newer activity is the rotation target."""
summaries = {
_BOUND_CASCADE: _summary(last_user_input_time="2026-06-23T17:34:54.152668Z"),
_OTHER_CASCADE: _summary(last_user_input_time="2026-06-23T17:50:29.232919Z"),
}
assert reader._detect_rotated_cascade(summaries, _BOUND_CASCADE) == _OTHER_CASCADE
def test_detect_rotation_minted_but_unused_sibling_returns_none() -> None:
"""A freshly /clear-minted sibling (no activity timestamps) is NOT a rotation.
The bare mint reports no ``lastUserInputTime`` / ``lastModifiedTime`` until its
first turn runs, so it must not trigger rotation — only a USED new conversation
does.
"""
summaries = {
_BOUND_CASCADE: _summary(last_user_input_time="2026-06-23T17:34:54.152668Z"),
_OTHER_CASCADE: _summary(), # minted, never used
}
assert reader._detect_rotated_cascade(summaries, _BOUND_CASCADE) is None
def test_detect_rotation_only_bound_present_returns_none() -> None:
"""With only the bound cascade present there is nothing to rotate to."""
summaries = {
_BOUND_CASCADE: _summary(last_user_input_time="2026-06-23T17:34:54.152668Z"),
}
assert reader._detect_rotated_cascade(summaries, _BOUND_CASCADE) is None
def test_detect_rotation_older_sibling_returns_none() -> None:
"""A sibling that is OLDER than the bound cascade is not a rotation."""
summaries = {
_BOUND_CASCADE: _summary(last_user_input_time="2026-06-23T17:50:29.232919Z"),
_OTHER_CASCADE: _summary(last_user_input_time="2026-06-23T17:34:54.152668Z"),
}
assert reader._detect_rotated_cascade(summaries, _BOUND_CASCADE) is None
def test_detect_rotation_non_cascade_sibling_returns_none() -> None:
"""A newer NON-cascade (subagent) sibling must not be a rotation target.
Rotating to a child/subagent trajectory would mirror a sub-conversation, not
the user's top-level conversation — so a newer subagent is ignored.
"""
summaries = {
_BOUND_CASCADE: _summary(last_user_input_time="2026-06-23T17:34:54.152668Z"),
_OTHER_CASCADE: _summary(
last_user_input_time="2026-06-23T17:50:29.232919Z",
trajectory_type="CORTEX_TRAJECTORY_TYPE_SUBAGENT",
),
}
assert reader._detect_rotated_cascade(summaries, _BOUND_CASCADE) is None
def test_detect_rotation_bound_absent_returns_none() -> None:
"""When the bound cascade is missing from the summaries, do not rotate blindly.
We cannot prove the bound conversation is staler than a sibling, so the reader
must stay on its current binding rather than chase a sibling on incomplete
information.
"""
summaries = {
_OTHER_CASCADE: _summary(last_user_input_time="2026-06-23T17:50:29.232919Z"),
}
assert reader._detect_rotated_cascade(summaries, _BOUND_CASCADE) is None
def test_detect_rotation_falls_back_to_last_modified_time() -> None:
"""``lastModifiedTime`` is used when ``lastUserInputTime`` is absent.
A cascade whose only activity signal is ``lastModifiedTime`` is still a valid
comparison/candidate (the helper prefers ``lastUserInputTime`` but falls back).
"""
summaries = {
_BOUND_CASCADE: _summary(last_modified_time="2026-06-23T17:34:54.000000Z"),
_OTHER_CASCADE: _summary(last_modified_time="2026-06-23T17:50:32.565300Z"),
}
assert reader._detect_rotated_cascade(summaries, _BOUND_CASCADE) == _OTHER_CASCADE
def test_detect_rotation_equal_activity_is_not_a_rotation() -> None:
"""A sibling whose activity merely EQUALS the bound cascade is not a rotation.
Rotation requires STRICTLY newer activity, so a steady state (equal stamps)
never flaps.
"""
stamp = "2026-06-23T17:50:29.232919Z"
summaries = {
_BOUND_CASCADE: _summary(last_user_input_time=stamp),
_OTHER_CASCADE: _summary(last_user_input_time=stamp),
}
assert reader._detect_rotated_cascade(summaries, _BOUND_CASCADE) is None
def test_detect_rotation_malformed_timestamp_is_not_candidate() -> None:
"""A sibling with a malformed activity timestamp is treated as no-activity.
A parse failure must never spuriously rotate: the malformed sibling reports no
activity and is skipped.
"""
summaries = {
_BOUND_CASCADE: _summary(last_user_input_time="2026-06-23T17:34:54.152668Z"),
_OTHER_CASCADE: _summary(last_user_input_time="not-a-timestamp"),
}
assert reader._detect_rotated_cascade(summaries, _BOUND_CASCADE) is None
def test_detect_rotation_real_capture_shape() -> None:
"""The exact two-entry capture: the used sibling rotates away from the idle one.
Mirrors ``wire_ref/all_cascade_trajectories.json`` — the bound cascade has no
activity timestamps (never used) while the sibling carries a
``lastUserInputTime`` — so the active sibling is the rotation target.
"""
summaries = {
# The capture's first entry: created, never used (no activity stamps).
_BOUND_CASCADE: _summary(),
# The capture's second entry: used (has lastUserInputTime/lastModifiedTime).
_OTHER_CASCADE: _summary(
last_user_input_time="2026-06-23T17:50:29.232919Z",
last_modified_time="2026-06-23T17:50:32.565300Z",
),
}
assert reader._detect_rotated_cascade(summaries, _BOUND_CASCADE) == _OTHER_CASCADE
# ---------------------------------------------------------------------------
# T-G: supervise_reader signals rotation; _rotate_session_for_cascade API;
# run_reader_with_bridge rebind loop
# ---------------------------------------------------------------------------
def _rotation_body(new_cascade_id: str) -> dict[str, Any]:
"""A ``GetAllCascadeTrajectories`` body where ``new_cascade_id`` is newer-active.
The bound cascade (``_CASCADE_ID``, the discovery fixture's id) is present but
never-used; the new cascade carries a ``lastUserInputTime`` so the detector
treats it as the current conversation.
"""
return {
"trajectorySummaries": {
_CASCADE_ID: _summary(),
new_cascade_id: _summary(last_user_input_time="2026-06-23T18:00:00.000000Z"),
}
}
@pytest.mark.asyncio
async def test_supervise_reader_returns_new_cascade_on_rotation(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
patched_discovery: None,
) -> None:
"""A detected /clear rotation makes ``supervise_reader`` stop and return the id.
With the rotation detector reporting a newer-active sibling, the stream body
must stop mirroring the (now-dead) bound conversation and ``supervise_reader``
must return the NEW cascade id so the caller can rotate + rebind.
"""
new_cascade = "11111111-2222-3333-4444-555555555555"
monkeypatch.setattr(
reader, "get_all_cascade_trajectories", lambda port: _rotation_body(new_cascade)
)
monkeypatch.setattr(reader, "stream_agent_state_updates", _FrameScript([]))
monkeypatch.setattr(reader, "get_trajectory_steps", _StepScript([[]]))
monkeypatch.setattr(reader, "post_session_event_with_retry", _PostSink())
async def _noop_sleep(_seconds: float) -> None:
await asyncio.sleep(0)
monkeypatch.setattr(reader, "_sleep", _noop_sleep)
async def _noop_pending(_cid: str, _port: int, _pending: PendingInteraction) -> None:
return None
# No bounded ``stop``: the run ends ONLY because the detector fires (proving
# rotation, not the test harness, terminated the body).
result = await reader.supervise_reader(
_bridge_dir(tmp_path),
_SESSION_ID,
client=cast(httpx.AsyncClient, object()),
on_pending_interaction=cast(Any, _noop_pending),
poll_interval_s=0.0,
detect_rotation_interval_s=0.0,
)
assert result == new_cascade
@pytest.mark.asyncio
async def test_supervise_reader_skips_failed_rotation_cascade(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
patched_discovery: None,
) -> None:
"""A cascade in ``skip_cascade_ids`` does NOT trigger a rotation signal.
After a failed rotation attempt the caller re-enters with the failed cascade in
``skip_cascade_ids``; the detector must ignore it, so the bounded body runs to
its ``stop`` and ``supervise_reader`` returns ``None`` (no rotation), letting
the reader keep serving the old binding instead of hot-looping.
"""
skipped = "deadbeef-0000-0000-0000-000000000000"
monkeypatch.setattr(
reader, "get_all_cascade_trajectories", lambda port: _rotation_body(skipped)
)
monkeypatch.setattr(reader, "stream_agent_state_updates", _FrameScript([]))
monkeypatch.setattr(reader, "get_trajectory_steps", _StepScript([[]]))
monkeypatch.setattr(reader, "post_session_event_with_retry", _PostSink())
async def _noop_sleep(_seconds: float) -> None:
await asyncio.sleep(0)
monkeypatch.setattr(reader, "_sleep", _noop_sleep)
async def _noop_pending(_cid: str, _port: int, _pending: PendingInteraction) -> None:
return None
result = await reader.supervise_reader(
_bridge_dir(tmp_path),
_SESSION_ID,
client=cast(httpx.AsyncClient, object()),
on_pending_interaction=cast(Any, _noop_pending),
poll_interval_s=0.0,
detect_rotation_interval_s=0.0,
stop=_stop_after(2),
skip_cascade_ids=frozenset({skipped}),
)
assert result is None
class _RotationFetchScript:
"""``get_all_cascade_trajectories`` that raises a scripted exception sequence.
Each call raises the next exception in ``exceptions``; once exhausted the
final exception repeats. A test ends the otherwise-infinite
:func:`_watch_for_rotation` loop by scripting a terminal
:class:`asyncio.CancelledError` — a ``BaseException`` (not ``Exception``), so
the detector's ``httpx``/``ValueError`` arms do not catch it and it
propagates out of the worker thread to stop the loop.
"""
def __init__(self, exceptions: list[BaseException]) -> None:
self._exceptions = exceptions
self.calls = 0
def __call__(self, port: int) -> dict[str, object]:
idx = min(self.calls, len(self._exceptions) - 1)
self.calls += 1
raise self._exceptions[idx]
def _fail_rotation(_cid: str) -> None:
"""``on_rotation`` that must never fire when every fetch fails."""
raise AssertionError("on_rotation must not fire when the fetch keeps failing")
async def _watch_until_cancelled(
monkeypatch: pytest.MonkeyPatch,
caplog: pytest.LogCaptureFixture,
first_error: BaseException,
) -> _RotationFetchScript:
"""Run :func:`_watch_for_rotation` for one ``first_error`` tick, then cancel.
Scripts the fetch to raise ``first_error`` then a terminal ``CancelledError``,
no-ops the interval sleep, and drives the loop under DEBUG capture until the
cancellation propagates out. Returns the script so the caller can assert it
reached its second call (i.e. the loop retried past ``first_error``); the
per-test log-level assertions read ``caplog.records`` directly.
"""
script = _RotationFetchScript([first_error, asyncio.CancelledError()])
monkeypatch.setattr(reader, "get_all_cascade_trajectories", script)
async def _noop_sleep(_seconds: float) -> None:
await asyncio.sleep(0)
monkeypatch.setattr(reader, "_sleep", _noop_sleep)
with caplog.at_level(logging.DEBUG, logger=reader.__name__):
with pytest.raises(asyncio.CancelledError):
await reader._watch_for_rotation(
port=_PORT,
bound_cascade_id=_BOUND_CASCADE,
interval_s=0.0,
skip_cascade_ids=frozenset(),
on_rotation=_fail_rotation,
)
return script
@pytest.mark.asyncio
async def test_watch_for_rotation_connect_error_logs_debug_and_continues(
monkeypatch: pytest.MonkeyPatch,
caplog: pytest.LogCaptureFixture,
) -> None:
"""A benign ``ConnectError`` is logged at DEBUG (not WARNING) and the loop retries.
When the agy port is force-killed during teardown/rotation/shutdown before this
detector is cancelled, every poll tick raises ``httpx.ConnectError`` (connection
refused). That is benign (no spawn/leak; the supervisor cancels us), so it must
log at DEBUG to avoid per-tick WARNING spam — and the loop must still advance to
the next tick (proven here by the script reaching its second call).
"""
script = await _watch_until_cancelled(
monkeypatch, caplog, httpx.ConnectError("connection refused")
)
# The loop continued past the ConnectError tick to the terminal stop tick.
assert script.calls == 2
connect_records = [r for r in caplog.records if "connect refused" in r.getMessage()]
assert len(connect_records) == 1
assert connect_records[0].levelno == logging.DEBUG
# The benign connect failure produced no WARNING (the whole point of the fix).
assert not [r for r in caplog.records if r.levelno >= logging.WARNING]
@pytest.mark.asyncio
async def test_watch_for_rotation_other_http_error_logs_warning(
monkeypatch: pytest.MonkeyPatch,
caplog: pytest.LogCaptureFixture,
) -> None:
"""A non-connect ``httpx.HTTPError`` (e.g. ``ReadTimeout``) still logs WARNING.
A hung-but-listening port that raises ``ReadTimeout`` is a real fault, not the
benign connection-refused case, so it must keep WARNING (the broad arm is
unchanged) while the loop still retries on the next tick.
"""
script = await _watch_until_cancelled(monkeypatch, caplog, httpx.ReadTimeout("read timed out"))
assert script.calls == 2
warnings = [r for r in caplog.records if r.levelno == logging.WARNING]
assert len(warnings) == 1
assert "GetAllCascadeTrajectories failed" in warnings[0].getMessage()
def _rotation_client(
monkeypatch: pytest.MonkeyPatch,
*,
calls: list[tuple[str, str, dict[str, object]]],
snapshot: dict[str, object],
new_session_id: str = "conv_new",
) -> httpx.AsyncClient:
"""Build an httpx.AsyncClient over a MockTransport that records the rotation calls.
Records every ``(method, path, json_body)`` so a test can assert the exact
session-rotation API sequence the codex forwarder mirrors. The session-create
POST returns ``{"id": new_session_id}``; the GET returns ``snapshot``; PATCHes
and the terminal transfer return 200.
"""
def handler(request: httpx.Request) -> httpx.Response:
body: dict[str, object] = {}
if request.content:
with contextlib.suppress(ValueError):
parsed = json.loads(request.content)
if isinstance(parsed, dict):
body = parsed
calls.append((request.method, request.url.path, body))
if request.method == "GET":
return httpx.Response(200, json=snapshot)
if request.method == "POST" and request.url.path == "/v1/sessions":
return httpx.Response(200, json={"id": new_session_id})
return httpx.Response(200, json={"id": "terminal_antigravity_main"})
return httpx.AsyncClient(base_url="http://test", transport=httpx.MockTransport(handler))
@pytest.mark.asyncio
async def test_rotate_session_for_cascade_mirrors_claude_sequence(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""``_rotate_session_for_cascade`` runs claude's exact session-rotation sequence.
Asserts: GET old snapshot → POST /v1/sessions (agent_id + inherited labels) →
PATCH runner_id → POST terminal transfer → PATCH old runner_id="" — and that
bridge state is rewritten with the new session id + new cascade id. Crucially,
NO ``external_session_id`` PATCH is made: agy is one long-lived process hosting
many cascades, so the new cascade is already live (reached via the rewritten
bridge state, not a later ``--resume``), exactly as claude's
``_create_clear_replacement_session`` makes no such PATCH. The old code PATCHed
it, which 400'd on the auto-cold-started session and looped the rotation.
"""
from omnigent.antigravity_native_bridge import (
ANTIGRAVITY_NATIVE_BRIDGE_ID_LABEL_KEY,
read_bridge_state,
)
bridge_dir = _bridge_dir(tmp_path)
new_cascade = "99999999-8888-7777-6666-555555555555"
snapshot: dict[str, object] = {
"agent_id": "agent_xyz",
"runner_id": "runner_abc",
"labels": {ANTIGRAVITY_NATIVE_BRIDGE_ID_LABEL_KEY: "bridge-123"},
}
calls: list[tuple[str, str, dict[str, object]]] = []
async with _rotation_client(monkeypatch, calls=calls, snapshot=snapshot) as client:
new_session_id = await reader._rotate_session_for_cascade(
client=client,
old_session_id=_SESSION_ID,
new_cascade_id=new_cascade,
bridge_dir=bridge_dir,
)
assert new_session_id == "conv_new"
# The exact ordered API sequence (method, path) mirroring claude rotation —
# ONE PATCH on the new session (runner_id bind), then transfer, then release.
methods_paths = [(m, p) for (m, p, _b) in calls]
assert methods_paths == [
("GET", f"/v1/sessions/{_SESSION_ID}"),
("POST", "/v1/sessions"),
("PATCH", "/v1/sessions/conv_new"), # runner_id bind
(
"POST",
f"/v1/sessions/{_SESSION_ID}/resources/terminals/terminal_antigravity_main/transfer",
),
("PATCH", f"/v1/sessions/{_SESSION_ID}"), # release old runner
]
# No external_session_id PATCH is made anywhere (the loop-bug source): every
# PATCH body is a runner_id bind/release, never an external_session_id write.
assert all("external_session_id" not in body for (_m, _p, body) in calls), (
f"rotation must not PATCH external_session_id (claude parity); calls={calls!r}"
)
# The create POST inherited the old agent_id + bridge-id label (so the new
# session resolves to the same bridge_dir).
create_body = calls[1][2]
assert create_body["agent_id"] == "agent_xyz"
assert isinstance(create_body["labels"], dict)
assert create_body["labels"][ANTIGRAVITY_NATIVE_BRIDGE_ID_LABEL_KEY] == "bridge-123"
# The runner_id bind carried the old runner; the new session is owned by it.
assert calls[2][2] == {"runner_id": "runner_abc"}
# The terminal transfer targeted the new session (the SAME agy moves over).
assert calls[3][2] == {"target_session_id": "conv_new"}
# Old runner released.
assert calls[4][2] == {"runner_id": ""}
# Bridge state was rewritten to the new session + new cascade (the reader
# rebinds to the new cascade on the SAME agy via this shared bridge_dir).
state = read_bridge_state(bridge_dir)
assert state is not None
assert state.session_id == "conv_new"
assert state.conversation_id == new_cascade
assert state.active_turn_id is None
@pytest.mark.asyncio
async def test_rotate_session_for_cascade_returns_none_on_create_failure(
tmp_path: Path,
) -> None:
"""A failed session-create yields ``None`` and does NOT rewrite bridge state.
The replacement could not be created, so the reader must stay on the old
binding: ``_rotate_session_for_cascade`` returns ``None`` and bridge state still
names the OLD cascade (no half-rotation).
"""
from omnigent.antigravity_native_bridge import read_bridge_state
bridge_dir = _bridge_dir(tmp_path)
snapshot: dict[str, object] = {"agent_id": "agent_xyz", "runner_id": "runner_abc"}
def handler(request: httpx.Request) -> httpx.Response:
if request.method == "GET":
return httpx.Response(200, json=snapshot)
# The create POST fails (500) — rotation must abort cleanly.
return httpx.Response(500, json={"error": {"message": "boom"}})
async with httpx.AsyncClient(
base_url="http://test", transport=httpx.MockTransport(handler)
) as client:
result = await reader._rotate_session_for_cascade(
client=client,
old_session_id=_SESSION_ID,
new_cascade_id="new-cascade-xyz",
bridge_dir=bridge_dir,
)
assert result is None
# Bridge state untouched: still the original cascade id.
state = read_bridge_state(bridge_dir)
assert state is not None
assert state.conversation_id == _CASCADE_ID
@pytest.mark.asyncio
async def test_run_reader_with_bridge_rebinds_after_rotation(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""The rebind loop: supervise_reader signals rotation → rotate → re-bind once.
Mocks ``supervise_reader`` to return a new cascade id on the FIRST call and
``None`` on the second (the rebound run ending), and ``_rotate_session_for_cascade``
to create a replacement session. Asserts: the replacement session was created
for the detected cascade, and the SECOND supervise_reader run was driven with
the NEW (rotated) session id — proving the loop rebinds and advances ownership.
"""
new_cascade = "abcdef00-1111-2222-3333-444444444444"
new_session = "conv_rotated"
supervise_session_ids: list[str] = []
rotate_calls: list[tuple[str, str]] = []
async def _fake_supervise(
bridge_dir: Path,
session_id: str,
*,
client: object,
on_pending_interaction: object,
skip_cascade_ids: frozenset[str] = frozenset(),
committed_steps_out: list[int] | None = None,
**_kwargs: object,
) -> str | None:
supervise_session_ids.append(session_id)
# Model a GENUINE /clear: the bound cascade HAD committed turns, so the
# loop FORKS a replacement session (not the first-cascade adopt-in-place
# path, which fires only when the bound cascade committed zero turns).
if committed_steps_out is not None:
committed_steps_out.append(3)
# First run detects a rotation; the rebound second run ends normally.
return new_cascade if len(supervise_session_ids) == 1 else None
async def _fake_rotate(
*,
client: object,
old_session_id: str,
new_cascade_id: str,
bridge_dir: Path,
) -> str | None:
rotate_calls.append((old_session_id, new_cascade_id))
return new_session
monkeypatch.setattr(reader, "supervise_reader", _fake_supervise)
monkeypatch.setattr(reader, "_rotate_session_for_cascade", _fake_rotate)
# Avoid importing the heavy interaction-bridge module in this unit test.
monkeypatch.setattr(
"omnigent.antigravity_native_interactions.bridge_interaction",
lambda *a, **k: None,
)
await reader.run_reader_with_bridge(
base_url="http://test",
headers={},
auth=None,
session_id=_SESSION_ID,
bridge_dir=_bridge_dir(tmp_path),
)
# Exactly one rotation, for the detected cascade, off the original session.
assert rotate_calls == [(_SESSION_ID, new_cascade)]
# supervise_reader ran twice: first on the original session, then rebound on
# the new (rotated) session id.
assert supervise_session_ids == [_SESSION_ID, new_session]
@pytest.mark.asyncio
async def test_run_reader_with_bridge_adopts_first_cascade_in_place(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""First-cascade adoption: a rotation off a ZERO-turn bound cascade rebinds in place.
The cold-start ``StartCascade`` cascade is a headless placeholder the agy TUI
never shows; the TUI mints its OWN cascade on the first typed turn. That first
transition is the conversation STARTING, not a ``/clear`` — so the loop must
adopt the new cascade in the SAME Omnigent session (rewrite bridge state, NO
fork) so the user's current session starts mirroring (#1156/#1158). Modeled by
a supervise_reader that reports ZERO committed turns on the rotation run.
"""
new_cascade = "11111111-2222-3333-4444-555555555555"
supervise_session_ids: list[str] = []
rotate_calls: list[str] = []
async def _fake_supervise(
bridge_dir: Path,
session_id: str,
*,
client: object,
on_pending_interaction: object,
skip_cascade_ids: frozenset[str] = frozenset(),
committed_steps_out: list[int] | None = None,
**_kwargs: object,
) -> str | None:
supervise_session_ids.append(session_id)
# The bound cascade committed ZERO turns → first-cascade adoption.
if committed_steps_out is not None:
committed_steps_out.append(0)
return new_cascade if len(supervise_session_ids) == 1 else None
async def _fake_rotate(**_kwargs: object) -> str | None:
rotate_calls.append("forked") # must NOT happen on adopt-in-place
return "conv_should_not_be_used"
recorded_external: list[tuple[str, str]] = []
async def _fake_record_external(client: object, session_id: str, cascade_id: str) -> None:
recorded_external.append((session_id, cascade_id))
monkeypatch.setattr(reader, "supervise_reader", _fake_supervise)
monkeypatch.setattr(reader, "_rotate_session_for_cascade", _fake_rotate)
monkeypatch.setattr(reader, "_record_external_session_id", _fake_record_external)
monkeypatch.setattr(
"omnigent.antigravity_native_interactions.bridge_interaction",
lambda *a, **k: None,
)
bridge_dir = _bridge_dir(tmp_path)
await reader.run_reader_with_bridge(
base_url="http://test",
headers={},
auth=None,
session_id=_SESSION_ID,
bridge_dir=bridge_dir,
)
# NO fork: _rotate_session_for_cascade must never be called.
assert rotate_calls == []
# Both supervise runs stay on the SAME (original) session id; the second
# rebinds to the adopted cascade via the rewritten bridge state.
assert supervise_session_ids == [_SESSION_ID, _SESSION_ID]
# Bridge state now names the adopted cascade under the SAME session.
state = reader.read_bridge_state(bridge_dir)
assert state is not None
assert state.session_id == _SESSION_ID
assert state.conversation_id == new_cascade
# The adopted cascade is recorded as external_session_id so a later --resume /
# server restart loads THIS conversation, not the headless cold-start phantom
# (#2 data-loss). It is recorded against the SAME session.
assert recorded_external == [(_SESSION_ID, new_cascade)]
@pytest.mark.asyncio
async def test_run_reader_with_bridge_keeps_old_binding_when_rotation_fails(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""A failed rotation keeps the old binding and skips the failed cascade.
When ``_rotate_session_for_cascade`` returns ``None``, the loop must re-enter
supervise_reader on the SAME (old) session id with the failed cascade in
``skip_cascade_ids`` — so it keeps serving rather than losing the reader, and
never hot-loops on the un-rotatable cascade.
"""
failed_cascade = "00000000-9999-8888-7777-666666666666"
supervise_calls: list[tuple[str, frozenset[str]]] = []
async def _fake_supervise(
bridge_dir: Path,
session_id: str,
*,
client: object,
on_pending_interaction: object,
skip_cascade_ids: frozenset[str] = frozenset(),
committed_steps_out: list[int] | None = None,
**_kwargs: object,
) -> str | None:
supervise_calls.append((session_id, skip_cascade_ids))
# Genuine /clear (bound cascade had committed turns) → the loop attempts a
# FORK (which fails here), not the zero-turn adopt-in-place path.
if committed_steps_out is not None:
committed_steps_out.append(2)
# First run detects the rotation; the second (post-failure) run ends.
return failed_cascade if len(supervise_calls) == 1 else None
async def _fake_rotate_fail(
*,
client: object,
old_session_id: str,
new_cascade_id: str,
bridge_dir: Path,
) -> str | None:
return None # rotation fails
monkeypatch.setattr(reader, "supervise_reader", _fake_supervise)
monkeypatch.setattr(reader, "_rotate_session_for_cascade", _fake_rotate_fail)
monkeypatch.setattr(
"omnigent.antigravity_native_interactions.bridge_interaction",
lambda *a, **k: None,
)
await reader.run_reader_with_bridge(
base_url="http://test",
headers={},
auth=None,
session_id=_SESSION_ID,
bridge_dir=_bridge_dir(tmp_path),
)
# Two runs, both on the ORIGINAL session id (rotation failed, no advance); the
# second carries the failed cascade in skip_cascade_ids.
assert len(supervise_calls) == 2
assert supervise_calls[0] == (_SESSION_ID, frozenset())
assert supervise_calls[1] == (_SESSION_ID, frozenset({failed_cascade}))
class _BlockingStream:
"""A ``stream_agent_state_updates`` that blocks forever after one frame.
Models the live deadlock shape: the connect stream long-polls inside a
deadline-less ``aiter_bytes`` read with no further frame and no trailer (after
a ``/clear`` the bound cascade goes idle). The generator awaits an
``asyncio.Event`` that never fires, so the cooperative ``stop`` re-check the
body would do between stream re-entries / poll iterations is NEVER reached —
only cancellation can unwedge it. ``await``-ing the event (rather than a true
busy-hang) keeps the generator cancellable so the test can't actually hang.
"""
def __init__(self) -> None:
self.calls = 0
self.cancelled = False
def __call__(self, port: int, conversation_id: str) -> AsyncIterator[dict[str, object]]:
self.calls += 1
async def _gen() -> AsyncIterator[dict[str, object]]:
never = asyncio.Event()
try:
await never.wait() # blocks until cancelled — never set
except asyncio.CancelledError:
self.cancelled = True
raise
yield {} # pragma: no cover (unreachable; marks this an async gen)
return _gen()
@pytest.mark.asyncio
async def test_supervise_reader_actuates_rotation_when_stream_is_wedged(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
patched_discovery: None,
) -> None:
"""A rotation detected while the stream is BLOCKED still returns the new id.
Regression test for the T-G actuation deadlock: the stream blocks forever on a
deadline-less idle read (the live ``/clear``-then-idle shape), so the body's
cooperative ``_body_should_stop`` checkpoint is never reached. The rotation
detector must therefore CANCEL the wedged body task — not merely flip ``stop``
— so ``supervise_reader`` returns the new cascade id instead of hanging.
Before the fix this would hang (the body ``await``-ed the wedged stream
directly); the tight :func:`asyncio.wait_for` budget makes a regression fail
loudly as a timeout rather than wedging the suite. The poll fallback is never
reached (the stream never raises), so its step source is asserted untouched.
"""
new_cascade = "deadlock-1111-2222-3333-444444444444"
blocking = _BlockingStream()
poll = _StepScript([[]])
monkeypatch.setattr(
reader, "get_all_cascade_trajectories", lambda port: _rotation_body(new_cascade)
)
monkeypatch.setattr(reader, "stream_agent_state_updates", blocking)
monkeypatch.setattr(reader, "get_trajectory_steps", poll)
monkeypatch.setattr(reader, "post_session_event_with_retry", _PostSink())
async def _noop_sleep(_seconds: float) -> None:
await asyncio.sleep(0)
monkeypatch.setattr(reader, "_sleep", _noop_sleep)
async def _noop_pending(_cid: str, _port: int, _pending: PendingInteraction) -> None:
return None
# No bounded ``stop``: the ONLY thing that can end this run is the rotation
# cancelling the wedged body. A tight timeout turns a regression (hang) into a
# loud failure instead of a stuck suite.
result = await asyncio.wait_for(
reader.supervise_reader(
_bridge_dir(tmp_path),
_SESSION_ID,
client=cast(httpx.AsyncClient, object()),
on_pending_interaction=cast(Any, _noop_pending),
poll_interval_s=0.0,
detect_rotation_interval_s=0.0,
),
timeout=5,
)
assert result == new_cascade
# The wedged stream was interrupted by cancellation (the actuation path), and
# the poll fallback was never reached (the stream never errored).
assert blocking.cancelled is True
assert poll.calls == 0
@pytest.mark.asyncio
async def test_supervise_reader_external_cancel_propagates_not_rotation(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
patched_discovery: None,
) -> None:
"""An external cancel of a wedged reader propagates — never a phantom rotation.
With NO rotation pending, cancelling the ``supervise_reader`` task (a shutdown)
must raise :class:`asyncio.CancelledError` out of it rather than being mistaken
for a rotation and returning a (non-existent) new cascade id. The autouse
``no_rotation`` fixture keeps the detector quiet, so the only way the run ends
is the external cancel.
"""
blocking = _BlockingStream()
monkeypatch.setattr(reader, "stream_agent_state_updates", blocking)
monkeypatch.setattr(reader, "get_trajectory_steps", _StepScript([[]]))
monkeypatch.setattr(reader, "post_session_event_with_retry", _PostSink())
async def _noop_sleep(_seconds: float) -> None:
await asyncio.sleep(0)
monkeypatch.setattr(reader, "_sleep", _noop_sleep)
async def _noop_pending(_cid: str, _port: int, _pending: PendingInteraction) -> None:
return None
task = asyncio.create_task(
reader.supervise_reader(
_bridge_dir(tmp_path),
_SESSION_ID,
client=cast(httpx.AsyncClient, object()),
on_pending_interaction=cast(Any, _noop_pending),
poll_interval_s=0.0,
detect_rotation_interval_s=0.0,
)
)
# Let the reader discover + start the body and wedge on the blocking stream.
while blocking.calls == 0:
await asyncio.sleep(0)
task.cancel()
with pytest.raises(asyncio.CancelledError):
await asyncio.wait_for(task, timeout=5)