2081 lines
83 KiB
Python
2081 lines
83 KiB
Python
"""Tests for the native Antigravity (agy) connect-RPC client.
|
|
|
|
No live agy is launched here. The OS seams (``lsof`` / ``pgrep``) are
|
|
monkeypatched and the HTTP layer is driven through ``httpx.MockTransport`` so
|
|
each RPC's URL / headers / body shape is asserted without a real socket.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import struct
|
|
import subprocess
|
|
from collections.abc import AsyncIterator
|
|
from pathlib import Path
|
|
|
|
import httpx
|
|
import pytest
|
|
|
|
import omnigent.antigravity_native_rpc as rpc
|
|
|
|
# Realistic two-port agy ``lsof`` output: the LOWER (52548) is the TLS
|
|
# connect-RPC port; the higher (52549) is plain HTTP. Mirrors the verified
|
|
# spike output (``docs/claude/antigravity-sidecar-spike.md``).
|
|
_LSOF_TWO_PORTS = (
|
|
"COMMAND PID USER FD TYPE DEVICE SIZE/OFF NODE NAME\n"
|
|
"agy 72753 bryanli 8u IPv4 0x7a69608b086a898d 0t0 TCP 127.0.0.1:52549 (LISTEN)\n"
|
|
"agy 72753 bryanli 9u IPv4 0x9fe8e4e3d01a00bd 0t0 TCP 127.0.0.1:52548 (LISTEN)\n"
|
|
)
|
|
|
|
_CONVERSATION_ID = "90468e33-38c3-4e48-ae9f-03c843196227"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# _parse_loopback_listen_ports
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_parse_loopback_listen_ports_sorted_unique() -> None:
|
|
"""
|
|
Loopback LISTEN ports parse ascending and de-duplicated.
|
|
|
|
The discovery probes lowest-first to find the TLS connect-RPC port, so the
|
|
parser must return the lower port first regardless of ``lsof`` row order.
|
|
"""
|
|
assert rpc._parse_loopback_listen_ports(_LSOF_TWO_PORTS) == [52548, 52549]
|
|
|
|
|
|
def test_parse_loopback_listen_ports_ignores_non_loopback() -> None:
|
|
"""
|
|
Non-127.0.0.1 listeners (``*:`` / IPv6 / other IPs) are excluded.
|
|
|
|
agy binds its control ports on ``127.0.0.1``; a wildcard or LAN listener
|
|
from another process must never be mistaken for agy's port.
|
|
"""
|
|
mixed = (
|
|
"node 1 u IPv6 0x0 0t0 TCP *:5001 (LISTEN)\n"
|
|
"x 2 u IPv4 0x0 0t0 TCP 192.168.1.5:8080 (LISTEN)\n"
|
|
"agy 3 u IPv4 0x0 0t0 TCP 127.0.0.1:61000 (LISTEN)\n"
|
|
)
|
|
assert rpc._parse_loopback_listen_ports(mixed) == [61000]
|
|
|
|
|
|
def test_parse_loopback_listen_ports_empty() -> None:
|
|
"""
|
|
Empty ``lsof`` output yields no ports.
|
|
|
|
A dead/exited agy produces no listeners; the parser must return ``[]`` so
|
|
discovery reports "no port" rather than crashing.
|
|
"""
|
|
assert rpc._parse_loopback_listen_ports("") == []
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# discover_language_server_port
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_discover_language_server_port_returns_validated_lower(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""
|
|
Discovery returns the lower port that answers ``Heartbeat`` with 200.
|
|
|
|
Of agy's two ports only the lower (TLS connect-RPC) answers Heartbeat; the
|
|
discovery must probe lowest-first and return it, not the plain-HTTP port.
|
|
"""
|
|
monkeypatch.setattr(rpc, "_run_lsof_listen_ports", lambda pid: _LSOF_TWO_PORTS)
|
|
probed: list[int] = []
|
|
|
|
def _fake_heartbeat(port: int) -> bool:
|
|
probed.append(port)
|
|
return port == 52548
|
|
|
|
monkeypatch.setattr(rpc, "_heartbeat_ok", _fake_heartbeat)
|
|
assert rpc.discover_language_server_port(72753) == 52548
|
|
# Lowest probed first; 52548 validated so the higher port is never probed.
|
|
assert probed == [52548]
|
|
|
|
|
|
def test_discover_language_server_port_skips_failing_lower(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""
|
|
A lower port that fails ``Heartbeat`` is skipped for the next candidate.
|
|
|
|
The two ports are not guaranteed exactly adjacent, and the lower could be an
|
|
unrelated listener; discovery must keep probing in ascending order rather
|
|
than give up after the first failure.
|
|
"""
|
|
monkeypatch.setattr(rpc, "_run_lsof_listen_ports", lambda pid: _LSOF_TWO_PORTS)
|
|
monkeypatch.setattr(rpc, "_heartbeat_ok", lambda port: port == 52549)
|
|
assert rpc.discover_language_server_port(72753) == 52549
|
|
|
|
|
|
def test_discover_language_server_port_none_when_no_ports(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""
|
|
No loopback listeners → ``None``.
|
|
|
|
When agy has exited or not yet bound, ``lsof`` returns nothing and discovery
|
|
must yield ``None`` so the executor surfaces a clear error.
|
|
"""
|
|
monkeypatch.setattr(rpc, "_run_lsof_listen_ports", lambda pid: "")
|
|
monkeypatch.setattr(rpc, "_heartbeat_ok", lambda port: True)
|
|
assert rpc.discover_language_server_port(72753) is None
|
|
|
|
|
|
def test_discover_language_server_port_none_when_no_heartbeat(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""
|
|
Ports exist but none answer ``Heartbeat`` → ``None``.
|
|
|
|
Guards against returning a non-connect-RPC port (e.g. only the plain-HTTP
|
|
port is up): without a 200 Heartbeat the port is not the control surface.
|
|
"""
|
|
monkeypatch.setattr(rpc, "_run_lsof_listen_ports", lambda pid: _LSOF_TWO_PORTS)
|
|
monkeypatch.setattr(rpc, "_heartbeat_ok", lambda port: False)
|
|
assert rpc.discover_language_server_port(72753) is None
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# _heartbeat_ok / _conversation_matches (HTTP via MockTransport)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_heartbeat_ok_true_on_200(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
"""
|
|
``_heartbeat_ok`` POSTs ``Heartbeat`` and returns True on HTTP 200.
|
|
|
|
Asserts the exact connect-RPC URL + JSON content-type the spike verified, so
|
|
a refactor cannot silently change the probe wire shape.
|
|
"""
|
|
seen: dict[str, object] = {}
|
|
|
|
def _handler(request: httpx.Request) -> httpx.Response:
|
|
seen["url"] = str(request.url)
|
|
seen["content_type"] = request.headers.get("content-type")
|
|
seen["body"] = request.content
|
|
return httpx.Response(200, json={"lastExtensionHeartbeat": "2026-06-15T00:00:00Z"})
|
|
|
|
monkeypatch.setattr(rpc, "_HTTP_TRANSPORT", httpx.MockTransport(_handler))
|
|
assert rpc._heartbeat_ok(52548) is True
|
|
assert seen["url"] == (
|
|
"https://127.0.0.1:52548/exa.language_server_pb.LanguageServerService/Heartbeat"
|
|
)
|
|
assert seen["content_type"] == "application/json"
|
|
assert seen["body"] == b"{}"
|
|
|
|
|
|
def test_heartbeat_ok_false_on_404(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
"""
|
|
A non-200 Heartbeat (the plain-HTTP port 404s) returns False.
|
|
|
|
This is how discovery distinguishes the higher plain-HTTP port from the TLS
|
|
connect-RPC port.
|
|
"""
|
|
monkeypatch.setattr(
|
|
rpc,
|
|
"_HTTP_TRANSPORT",
|
|
httpx.MockTransport(lambda request: httpx.Response(404)),
|
|
)
|
|
assert rpc._heartbeat_ok(52549) is False
|
|
|
|
|
|
def test_heartbeat_ok_false_on_transport_error(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
"""
|
|
A transport/TLS error during Heartbeat returns False, not an exception.
|
|
|
|
A port that resets the connection (e.g. wrong protocol) must be treated as
|
|
"not the connect-RPC port" without crashing discovery.
|
|
"""
|
|
|
|
def _boom(request: httpx.Request) -> httpx.Response:
|
|
raise httpx.ConnectError("connection reset", request=request)
|
|
|
|
monkeypatch.setattr(rpc, "_HTTP_TRANSPORT", httpx.MockTransport(_boom))
|
|
assert rpc._heartbeat_ok(52548) is False
|
|
|
|
|
|
def test_conversation_matches_true(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
"""
|
|
``_conversation_matches`` is True when metadata echoes the requested id.
|
|
|
|
Asserts it sends ``{"conversationId": ...}`` to GetConversationMetadata and
|
|
accepts a 200 whose ``metadata.rootConversationId`` echoes the id (the real
|
|
agy response shape) — the disambiguation used when several agy run.
|
|
"""
|
|
seen: dict[str, object] = {}
|
|
|
|
def _handler(request: httpx.Request) -> httpx.Response:
|
|
seen["url"] = str(request.url)
|
|
seen["body"] = json.loads(request.content)
|
|
return httpx.Response(
|
|
200, json={"metadata": {"rootConversationId": _CONVERSATION_ID, "projectId": "p1"}}
|
|
)
|
|
|
|
monkeypatch.setattr(rpc, "_HTTP_TRANSPORT", httpx.MockTransport(_handler))
|
|
assert rpc._conversation_matches(52548, _CONVERSATION_ID) is True
|
|
assert seen["url"] == (
|
|
"https://127.0.0.1:52548/exa.language_server_pb.LanguageServerService/"
|
|
"GetConversationMetadata"
|
|
)
|
|
assert seen["body"] == {"conversationId": _CONVERSATION_ID}
|
|
|
|
|
|
def test_conversation_matches_false_on_id_mismatch(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
"""
|
|
A 200 whose metadata echoes a DIFFERENT root id returns False.
|
|
|
|
Injection-safety gate: with the /proc/net/tcp fallback the candidate ports
|
|
span every loopback listener, so a port that answers Heartbeat 200 and even
|
|
returns a metadata object — but for another conversation — must be rejected
|
|
before any SendAgentMessage. Only an exact id echo confirms ownership.
|
|
"""
|
|
monkeypatch.setattr(
|
|
rpc,
|
|
"_HTTP_TRANSPORT",
|
|
httpx.MockTransport(
|
|
lambda request: httpx.Response(
|
|
200,
|
|
json={"metadata": {"rootConversationId": "00000000-0000-4000-8000-000000000000"}},
|
|
)
|
|
),
|
|
)
|
|
assert rpc._conversation_matches(52548, _CONVERSATION_ID) is False
|
|
|
|
|
|
def test_conversation_matches_false_on_error_status(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
"""
|
|
A non-200 (unknown conversation) returns False.
|
|
|
|
An agy process that does not host this conversation answers HTTP 500
|
|
(``"trajectory not found"``, verified live), so it must not be selected.
|
|
"""
|
|
monkeypatch.setattr(
|
|
rpc,
|
|
"_HTTP_TRANSPORT",
|
|
httpx.MockTransport(lambda request: httpx.Response(500, json={"code": "unknown"})),
|
|
)
|
|
assert rpc._conversation_matches(52548, _CONVERSATION_ID) is False
|
|
|
|
|
|
def test_conversation_matches_false_without_metadata(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
"""
|
|
A 200 lacking a ``metadata`` object (or its id echo) returns False.
|
|
|
|
Guards against treating an empty/odd 200 body — e.g. a non-agy loopback
|
|
service — as a match.
|
|
"""
|
|
monkeypatch.setattr(
|
|
rpc,
|
|
"_HTTP_TRANSPORT",
|
|
httpx.MockTransport(lambda request: httpx.Response(200, json={})),
|
|
)
|
|
assert rpc._conversation_matches(52548, _CONVERSATION_ID) is False
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# resolve_language_server_port
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_resolve_finds_conversation_owner(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
"""
|
|
The resolver finds the agy that owns the conversation by validated discovery.
|
|
|
|
Among several candidate connect-RPC ports, only the one whose server reports
|
|
this conversation id is selected. Discovery is port-first — every candidate
|
|
is conversation-validated, which is what makes a recycled or foreign port
|
|
safe.
|
|
"""
|
|
monkeypatch.setattr(rpc, "_candidate_agy_rpc_ports", lambda: [52548, 60000])
|
|
# Only the agy on port 60000 hosts the target conversation.
|
|
monkeypatch.setattr(rpc, "_conversation_matches", lambda port, cid: port == 60000)
|
|
assert rpc.resolve_language_server_port(_CONVERSATION_ID) == 60000
|
|
|
|
|
|
def test_resolve_validates_every_candidate(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
"""
|
|
Every resolved port is conversation-checked before it is returned.
|
|
|
|
Guards the cross-inject fix: a port that answers Heartbeat but hosts a
|
|
*different* conversation must be rejected, never returned positionally.
|
|
"""
|
|
monkeypatch.setattr(rpc, "_candidate_agy_rpc_ports", lambda: [40000, 52548])
|
|
probed_for_match: list[int] = []
|
|
|
|
def _matches(port: int, cid: str) -> bool:
|
|
probed_for_match.append(port)
|
|
return port == 52548 # only this port hosts the conversation
|
|
|
|
monkeypatch.setattr(rpc, "_conversation_matches", _matches)
|
|
assert rpc.resolve_language_server_port(_CONVERSATION_ID) == 52548
|
|
# Both candidate ports were conversation-checked, in enumeration order.
|
|
assert probed_for_match == [40000, 52548]
|
|
|
|
|
|
def test_resolve_none_when_nothing_matches(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
"""
|
|
No agy hosting the conversation → ``None``.
|
|
|
|
The executor turns this into a clear ExecutorError instead of hanging.
|
|
"""
|
|
monkeypatch.setattr(rpc, "_candidate_agy_rpc_ports", lambda: [52548, 40000])
|
|
monkeypatch.setattr(rpc, "_conversation_matches", lambda port, cid: False)
|
|
assert rpc.resolve_language_server_port(_CONVERSATION_ID) is None
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# conversation_id_owned_by_pid (Finding 2: deterministic by-pid binding)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
_CID_A = "11111111-1111-4111-8111-111111111111"
|
|
_CID_B = "22222222-2222-4222-8222-222222222222"
|
|
|
|
|
|
def test_conversation_id_owned_by_pid_returns_the_owned_candidate(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""
|
|
A specific pid's port confirms exactly one candidate — that id is returned.
|
|
|
|
This is the inverse of ``resolve_language_server_port``: given a known agy
|
|
pid (the one under this session's tmux pane) and several candidate brain-dir
|
|
ids, the candidate THIS pid's connect-RPC server hosts is selected. Two
|
|
concurrent agys have different pids → different ports, so each resolves only
|
|
its own conversation — eliminating the newest-dir guess and the livelock.
|
|
"""
|
|
monkeypatch.setattr(rpc, "discover_language_server_port", lambda pid: 52548)
|
|
# This pid's port hosts only _CID_B (a concurrent agy would host _CID_A).
|
|
monkeypatch.setattr(rpc, "_conversation_matches", lambda port, cid: cid == _CID_B)
|
|
assert rpc.conversation_id_owned_by_pid(72753, [_CID_A, _CID_B]) == _CID_B
|
|
|
|
|
|
def test_conversation_id_owned_by_pid_none_when_port_unresolved(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""
|
|
No resolvable connect-RPC port for the pid → ``None`` (keep polling).
|
|
|
|
A still-cold-starting agy has not bound its port yet; the resolver must not
|
|
bind anything and the caller keeps polling rather than guessing. Both port
|
|
sources are stubbed empty: the pid-scoped ``discover_language_server_port``
|
|
AND the ``_candidate_agy_rpc_ports`` fallback (the resolver falls back to
|
|
every live agy port when lsof can't attribute the socket — see the source).
|
|
Stubbing only the former leaves the fallback scanning REAL agy processes,
|
|
so this test would flake on any host/CI runner with a concurrent agy.
|
|
"""
|
|
monkeypatch.setattr(rpc, "discover_language_server_port", lambda pid: None)
|
|
monkeypatch.setattr(rpc, "_candidate_agy_rpc_ports", list)
|
|
calls: list[tuple[int, str]] = []
|
|
|
|
def _matches(port: int, cid: str) -> bool:
|
|
calls.append((port, cid))
|
|
return True
|
|
|
|
monkeypatch.setattr(rpc, "_conversation_matches", _matches)
|
|
assert rpc.conversation_id_owned_by_pid(72753, [_CID_A, _CID_B]) is None
|
|
# No port from either source → nothing to confirm against.
|
|
assert calls == []
|
|
|
|
|
|
def test_conversation_id_owned_by_pid_none_when_no_candidate_matches(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""
|
|
The port resolves but owns none of the candidates → ``None``.
|
|
|
|
Defensive: if the in-window candidate set somehow excludes this pid's real
|
|
conversation (e.g. a timing skew), bind nothing rather than mis-bind.
|
|
"""
|
|
monkeypatch.setattr(rpc, "discover_language_server_port", lambda pid: 52548)
|
|
monkeypatch.setattr(rpc, "_conversation_matches", lambda port, cid: False)
|
|
assert rpc.conversation_id_owned_by_pid(72753, [_CID_A, _CID_B]) is None
|
|
|
|
|
|
def test_conversation_id_owned_by_pid_refuses_when_multiple_match(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""
|
|
More than one candidate confirmed → ``None`` (refuse, do not guess).
|
|
|
|
A pid's connect-RPC server can confirm more than one candidate brain-dir id,
|
|
and first-match would then depend on ``candidate_ids`` order and could bind
|
|
the wrong transcript / external_session_id. So the resolver refuses an
|
|
ambiguous match and returns ``None`` (the caller defers to the next poll)
|
|
rather than returning either id — mirroring the forwarder's
|
|
correctness-over-liveness ambiguity refusal.
|
|
"""
|
|
monkeypatch.setattr(rpc, "discover_language_server_port", lambda pid: 52548)
|
|
monkeypatch.setattr(rpc, "_conversation_matches", lambda port, cid: True)
|
|
resolved = rpc.conversation_id_owned_by_pid(72753, [_CID_A, _CID_B])
|
|
assert resolved is None
|
|
# Explicitly: neither candidate is bound on an ambiguous confirmation.
|
|
assert resolved != _CID_A
|
|
assert resolved != _CID_B
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# _assert_loopback_url (loopback guard for verify=False clients)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"url",
|
|
[
|
|
"https://127.0.0.1:52548/svc/Method",
|
|
"https://localhost:52548/svc/Method",
|
|
"https://[::1]:52548/svc/Method",
|
|
"https://127.5.6.7:9000/svc/Method", # entire 127/8 block is loopback
|
|
],
|
|
)
|
|
def test_assert_loopback_url_allows_loopback(url: str) -> None:
|
|
"""
|
|
Loopback hosts (127/8, ``localhost``, ``::1``) pass the guard silently.
|
|
|
|
The connect-RPC clients disable TLS verification, which is only safe on
|
|
loopback; the allow path must cover the hostnames and the whole 127/8 block
|
|
that agy can bind so legitimate requests are never refused.
|
|
"""
|
|
rpc._assert_loopback_url(url)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"url",
|
|
[
|
|
"https://10.0.0.1:1234/svc/Method",
|
|
"https://192.168.1.5:52548/svc/Method",
|
|
"https://example.com:52548/svc/Method",
|
|
"https://8.8.8.8:443/svc/Method",
|
|
],
|
|
)
|
|
def test_assert_loopback_url_rejects_non_loopback(url: str) -> None:
|
|
"""
|
|
Any non-loopback host is refused with ``ValueError``.
|
|
|
|
With ``verify=False`` a non-loopback URL would silently trust any cert, so
|
|
the guard fails loudly rather than letting a MITM succeed.
|
|
"""
|
|
with pytest.raises(ValueError, match="non-loopback connect-RPC URL"):
|
|
rpc._assert_loopback_url(url)
|
|
|
|
|
|
def test_heartbeat_ok_rejects_non_loopback_url(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
"""
|
|
``_heartbeat_ok`` raises ``ValueError`` if the built URL is non-loopback.
|
|
|
|
The sync probe shares the loopback guard, so a discovered port that ever
|
|
resolved off-host is refused before the ``verify=False`` client is used.
|
|
"""
|
|
monkeypatch.setattr(rpc, "_rpc_url", lambda port, method: "https://10.0.0.1:1234/svc/Method")
|
|
|
|
def _unexpected(request: httpx.Request) -> httpx.Response: # pragma: no cover
|
|
raise AssertionError("request must not be sent for a non-loopback URL")
|
|
|
|
monkeypatch.setattr(rpc, "_HTTP_TRANSPORT", httpx.MockTransport(_unexpected))
|
|
with pytest.raises(ValueError, match="non-loopback connect-RPC URL"):
|
|
rpc._heartbeat_ok(52548)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# interrupt_turn (best-effort, wired OFF — phase 4 task 4)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
async def test_interrupt_turn_is_off_and_issues_no_rpc(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
"""
|
|
``interrupt_turn`` returns False and sends NO RPC.
|
|
|
|
The agy cancel-RPC request contract is unverified (and the forwarder lacks
|
|
agy's internal cascade id), so the interrupt is wired OFF: it must never
|
|
issue a network call. Any attempt to send a request fails the test.
|
|
"""
|
|
|
|
def _unexpected(request: httpx.Request) -> httpx.Response: # pragma: no cover
|
|
raise AssertionError("interrupt_turn must not send an RPC while wired off")
|
|
|
|
monkeypatch.setattr(rpc, "_ASYNC_HTTP_TRANSPORT", httpx.MockTransport(_unexpected))
|
|
result = await rpc.interrupt_turn(52548, _CONVERSATION_ID)
|
|
assert result is False
|
|
|
|
|
|
async def test_interrupt_turn_never_raises() -> None:
|
|
"""``interrupt_turn`` is fail-open: it returns False rather than raising."""
|
|
assert await rpc.interrupt_turn(0, _CONVERSATION_ID) is False
|
|
|
|
|
|
# --- agy pid enumeration: pgrep primary, /proc fallback -------------------
|
|
|
|
|
|
def _completed(stdout: str) -> subprocess.CompletedProcess[str]:
|
|
"""A ``CompletedProcess`` stand-in for a stubbed ``pgrep`` run."""
|
|
return subprocess.CompletedProcess(
|
|
args=["pgrep", "-f", "bin/agy"], returncode=0, stdout=stdout
|
|
)
|
|
|
|
|
|
def test_list_agy_pids_parses_pgrep_output(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
"""``_list_agy_pids`` returns the pids ``pgrep`` prints, ignoring noise."""
|
|
|
|
def _run(*_args: object, **_kwargs: object) -> subprocess.CompletedProcess[str]:
|
|
return _completed("1234\n5678\nnot-a-pid\n")
|
|
|
|
monkeypatch.setattr(subprocess, "run", _run)
|
|
assert rpc._list_agy_pids() == [1234, 5678]
|
|
|
|
|
|
def test_list_agy_pids_falls_back_to_proc_when_pgrep_missing(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""
|
|
A missing ``pgrep`` (no procps) routes to the /proc scan, not ``[]``.
|
|
|
|
Guards the homelab regression: the host image shipped without ``procps``,
|
|
so ``pgrep`` raised ``FileNotFoundError`` and discovery silently found no
|
|
agy, surfacing as "is the agy terminal still open?". The fallback must run.
|
|
"""
|
|
|
|
def _no_pgrep(*_args: object, **_kwargs: object) -> subprocess.CompletedProcess[str]:
|
|
raise FileNotFoundError(2, "No such file or directory", "pgrep")
|
|
|
|
monkeypatch.setattr(subprocess, "run", _no_pgrep)
|
|
monkeypatch.setattr(rpc, "_list_agy_pids_from_proc", lambda: [4242])
|
|
assert rpc._list_agy_pids() == [4242]
|
|
|
|
|
|
def test_list_agy_pids_falls_back_to_proc_on_pgrep_timeout(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""
|
|
A ``pgrep`` *timeout* (present but hung) also routes to the /proc scan.
|
|
|
|
Pins the contract that the except clause stays broad (``OSError`` AND
|
|
``SubprocessError``): narrowing it to only the missing-binary case would
|
|
silently re-introduce the empty-list regression whenever pgrep hangs.
|
|
"""
|
|
|
|
def _timeout(*_args: object, **_kwargs: object) -> subprocess.CompletedProcess[str]:
|
|
raise subprocess.TimeoutExpired(cmd=["pgrep"], timeout=5.0)
|
|
|
|
monkeypatch.setattr(subprocess, "run", _timeout)
|
|
monkeypatch.setattr(rpc, "_list_agy_pids_from_proc", lambda: [9999])
|
|
assert rpc._list_agy_pids() == [9999]
|
|
|
|
|
|
def test_list_agy_pids_from_proc_matches_bin_agy(
|
|
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
|
) -> None:
|
|
"""The /proc scan matches ``bin/agy`` cmdlines and skips everything else."""
|
|
|
|
def _write(pid: str, argv: list[str]) -> None:
|
|
proc_dir = tmp_path / pid
|
|
proc_dir.mkdir()
|
|
(proc_dir / "cmdline").write_bytes(b"\0".join(a.encode() for a in argv) + b"\0")
|
|
|
|
_write("111", ["/usr/local/bin/agy", "--dangerously-skip-permissions"])
|
|
_write("222", ["node", "/opt/other/server.js"]) # unrelated → skipped
|
|
_write("333", ["/data/.local/bin/agy"]) # installer-default path → matches
|
|
(tmp_path / "not-a-pid").mkdir() # non-numeric entry → skipped
|
|
|
|
monkeypatch.setattr(rpc, "_PROC_FS", str(tmp_path))
|
|
assert sorted(rpc._list_agy_pids_from_proc()) == [111, 333]
|
|
|
|
|
|
def test_list_agy_pids_from_proc_skips_empty_cmdline(
|
|
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
|
) -> None:
|
|
"""A pid with an empty cmdline (kernel thread / zombie) is not matched."""
|
|
(tmp_path / "444").mkdir()
|
|
(tmp_path / "444" / "cmdline").write_bytes(b"")
|
|
monkeypatch.setattr(rpc, "_PROC_FS", str(tmp_path))
|
|
assert rpc._list_agy_pids_from_proc() == []
|
|
|
|
|
|
def test_list_agy_pids_from_proc_empty_when_no_procfs(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""A missing ``/proc`` (non-Linux) yields ``[]`` rather than raising."""
|
|
monkeypatch.setattr(rpc, "_PROC_FS", "/no/such/proc/root")
|
|
assert rpc._list_agy_pids_from_proc() == []
|
|
|
|
|
|
# --- /proc/net/tcp loopback port discovery (lsof-attribution fallback) ----
|
|
|
|
|
|
def test_is_loopback_hex_addr() -> None:
|
|
"""Only the IPv4 127.0.0.1 hex encoding is loopback (the RPC client is IPv4)."""
|
|
assert rpc._is_loopback_hex_addr("0100007F") is True # 127.0.0.1 (LE)
|
|
assert rpc._is_loopback_hex_addr("0100007f") is True # case-insensitive
|
|
assert rpc._is_loopback_hex_addr("00000000000000000000000001000000") is False # ::1 (IPv6)
|
|
assert rpc._is_loopback_hex_addr("0100A8C0") is False # 192.168.0.1
|
|
assert rpc._is_loopback_hex_addr("00000000") is False # 0.0.0.0 (wildcard)
|
|
assert rpc._is_loopback_hex_addr("") is False
|
|
|
|
|
|
def _write_proc_net(tmp_path: Path, tcp: str, tcp6: str | None = None) -> None:
|
|
net = tmp_path / "net"
|
|
net.mkdir()
|
|
(net / "tcp").write_text(tcp)
|
|
if tcp6 is not None:
|
|
(net / "tcp6").write_text(tcp6)
|
|
|
|
|
|
def test_list_loopback_listen_ports_parses_ipv4_only(
|
|
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
|
) -> None:
|
|
"""IPv4 127.0.0.1 LISTEN ports parse; non-loopback, non-LISTEN, and IPv6 are excluded."""
|
|
# tcp rows: 127.0.0.1:6763 LISTEN (keep); 192.168.0.1:8080 LISTEN (not
|
|
# loopback, skip); 127.0.0.1:80 ESTABLISHED (state 01, not LISTEN, skip).
|
|
tcp = (
|
|
" sl local_address rem_address st ...\n"
|
|
" 0: 0100007F:1A6B 00000000:0000 0A 0 0 0\n"
|
|
" 1: 0100A8C0:1F90 00000000:0000 0A 0 0 0\n"
|
|
" 2: 0100007F:0050 0100007F:CAFE 01 0 0 0\n"
|
|
)
|
|
# An ::1 LISTEN row in tcp6 must be IGNORED — agy and the RPC client are IPv4.
|
|
v6_lo = "00000000000000000000000001000000"
|
|
v6_any = "00000000000000000000000000000000"
|
|
tcp6 = f" sl local_address ...\n 0: {v6_lo}:30D4 {v6_any}:0000 0A 0\n"
|
|
_write_proc_net(tmp_path, tcp, tcp6)
|
|
monkeypatch.setattr(rpc, "_PROC_FS", str(tmp_path))
|
|
assert rpc._list_loopback_listen_ports() == [6763]
|
|
|
|
|
|
def test_list_loopback_listen_ports_missing_proc(
|
|
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
|
) -> None:
|
|
"""A present /proc/net/tcp parses; a missing /proc (non-Linux) yields []."""
|
|
_write_proc_net(tmp_path, "header\n 0: 0100007F:2710 00000000:0000 0A 0\n")
|
|
monkeypatch.setattr(rpc, "_PROC_FS", str(tmp_path))
|
|
assert rpc._list_loopback_listen_ports() == [10000]
|
|
monkeypatch.setattr(rpc, "_PROC_FS", "/no/such/proc")
|
|
assert rpc._list_loopback_listen_ports() == []
|
|
|
|
|
|
def test_candidate_agy_rpc_ports_uses_lsof_when_attributed(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""When lsof attributes ports to a pid, /proc/net/tcp is NOT consulted."""
|
|
monkeypatch.setattr(rpc, "_list_agy_pids", lambda: [72753])
|
|
monkeypatch.setattr(rpc, "_run_lsof_listen_ports", lambda pid: _LSOF_TWO_PORTS)
|
|
monkeypatch.setattr(rpc, "_heartbeat_ok", lambda port: port == 52548) # lower = connect-RPC
|
|
|
|
def _must_not_call() -> list[int]:
|
|
raise AssertionError("/proc/net/tcp fallback must not run when lsof attributed ports")
|
|
|
|
monkeypatch.setattr(rpc, "_list_loopback_listen_ports", _must_not_call)
|
|
assert rpc._candidate_agy_rpc_ports() == [52548]
|
|
|
|
|
|
def test_candidate_agy_rpc_ports_falls_back_to_proc_net_tcp(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""
|
|
When lsof attributes nothing (restricted /proc), enumerate /proc/net/tcp.
|
|
|
|
Pins the homelab uid-1000 fix: agy's listening socket is not in its pid's fd
|
|
table, so lsof -p <pid> is empty; discovery must still find the port via the
|
|
network-namespace socket table, Heartbeat-filtered.
|
|
"""
|
|
monkeypatch.setattr(rpc, "_list_agy_pids", lambda: [72753])
|
|
monkeypatch.setattr(rpc, "_run_lsof_listen_ports", lambda pid: "") # lsof attributes nothing
|
|
monkeypatch.setattr(rpc, "_list_loopback_listen_ports", lambda: [6767, 44955, 37479])
|
|
# Only agy's TLS connect-RPC port answers Heartbeat (6767=omnigent, 37479=agy plain-HTTP).
|
|
monkeypatch.setattr(rpc, "_heartbeat_ok", lambda port: port == 44955)
|
|
assert rpc._candidate_agy_rpc_ports() == [44955]
|
|
|
|
|
|
def test_candidate_agy_rpc_ports_no_fallback_when_no_agy(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""
|
|
No agy process → no /proc/net/tcp scan.
|
|
|
|
A turn-injection attempt against a dead session must not heartbeat every
|
|
unrelated loopback service: the fallback is gated on agy actually running.
|
|
"""
|
|
monkeypatch.setattr(rpc, "_list_agy_pids", list)
|
|
monkeypatch.setattr(rpc, "_run_lsof_listen_ports", lambda pid: "")
|
|
|
|
def _must_not_call() -> list[int]:
|
|
raise AssertionError("fallback must not run when no agy is present")
|
|
|
|
monkeypatch.setattr(rpc, "_list_loopback_listen_ports", _must_not_call)
|
|
monkeypatch.setattr(rpc, "_heartbeat_ok", lambda port: True)
|
|
assert rpc._candidate_agy_rpc_ports() == []
|
|
|
|
|
|
def test_conversation_id_owned_by_pid_falls_back_when_lsof_blind(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""
|
|
The forwarder's by-pid binding survives a /proc where lsof can't see the port.
|
|
|
|
discover_language_server_port returns None (lsof blind); the candidate is
|
|
still confirmed against every live agy connect-RPC port — correct because the
|
|
conversation id is globally unique.
|
|
"""
|
|
monkeypatch.setattr(rpc, "discover_language_server_port", lambda pid: None)
|
|
monkeypatch.setattr(rpc, "_candidate_agy_rpc_ports", lambda: [44955])
|
|
monkeypatch.setattr(
|
|
rpc, "_conversation_matches", lambda port, cid: cid == _CID_A and port == 44955
|
|
)
|
|
assert rpc.conversation_id_owned_by_pid(72753, [_CID_A, _CID_B]) == _CID_A
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# get_trajectory_steps / cancel_cascade_steps (unary RPC, Task 2)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_get_trajectory_steps_posts_cascade_id_and_returns_steps(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""
|
|
``get_trajectory_steps`` POSTs ``{"cascadeId": ...}`` to
|
|
``GetCascadeTrajectorySteps`` and returns the ``steps`` list from the
|
|
response body.
|
|
|
|
Asserts the exact URL, JSON body, and parsed list shape so a refactor
|
|
cannot silently change the wire contract consumed by the read driver.
|
|
"""
|
|
seen: dict[str, object] = {}
|
|
|
|
def handler(req: httpx.Request) -> httpx.Response:
|
|
seen["url"] = str(req.url)
|
|
seen["body"] = req.content
|
|
return httpx.Response(
|
|
200, json={"steps": [{"stepIndex": 0, "status": "CORTEX_STEP_STATUS_DONE"}]}
|
|
)
|
|
|
|
monkeypatch.setattr(rpc, "_HTTP_TRANSPORT", httpx.MockTransport(handler))
|
|
steps = rpc.get_trajectory_steps(52548, "conv-uuid")
|
|
assert seen["url"] == (
|
|
"https://127.0.0.1:52548/exa.language_server_pb.LanguageServerService/"
|
|
"GetCascadeTrajectorySteps"
|
|
)
|
|
body = seen["body"]
|
|
assert isinstance(body, (bytes, bytearray))
|
|
assert json.loads(body) == {"cascadeId": "conv-uuid"}
|
|
assert steps[0]["stepIndex"] == 0
|
|
|
|
|
|
def test_get_trajectory_steps_raises_on_500(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
"""
|
|
A non-2xx response from ``GetCascadeTrajectorySteps`` raises
|
|
``httpx.HTTPStatusError``.
|
|
|
|
Pins the "non-2xx raises, not fail-open" contract: the Task 6 read driver
|
|
is responsible for retry/backoff, and a 500 must propagate rather than
|
|
silently returning an empty list.
|
|
"""
|
|
monkeypatch.setattr(
|
|
rpc,
|
|
"_HTTP_TRANSPORT",
|
|
httpx.MockTransport(lambda r: httpx.Response(500, json={"code": "unknown"})),
|
|
)
|
|
with pytest.raises(httpx.HTTPStatusError):
|
|
rpc.get_trajectory_steps(52548, "conv-uuid")
|
|
|
|
|
|
def test_cancel_cascade_steps_true_on_200(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
"""
|
|
``cancel_cascade_steps`` POSTs ``{"cascadeId": ...}`` to
|
|
``CancelCascadeSteps`` and returns ``True`` when the server responds 200.
|
|
"""
|
|
monkeypatch.setattr(
|
|
rpc,
|
|
"_HTTP_TRANSPORT",
|
|
httpx.MockTransport(lambda r: httpx.Response(200, json={})),
|
|
)
|
|
assert rpc.cancel_cascade_steps(52548, "conv-uuid") is True
|
|
|
|
|
|
def test_cancel_cascade_steps_false_on_transport_error(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""
|
|
A transport error during ``cancel_cascade_steps`` returns ``False``.
|
|
|
|
Cancel is best-effort: a connection reset (e.g. agy already exited) must
|
|
not propagate an exception to the executor, which treats ``False`` as a
|
|
soft no-op.
|
|
"""
|
|
|
|
def raise_transport(req: httpx.Request) -> httpx.Response:
|
|
raise httpx.ConnectError("refused")
|
|
|
|
monkeypatch.setattr(rpc, "_HTTP_TRANSPORT", httpx.MockTransport(raise_transport))
|
|
assert rpc.cancel_cascade_steps(52548, "conv-uuid") is False
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# handle_user_interaction (unary RPC, Task 3)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_handle_user_interaction_nests_traj_and_step_inside_interaction(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""
|
|
``handle_user_interaction`` POSTs a body where ``trajectoryId`` and
|
|
``stepIndex`` are nested inside ``interaction``, not at the top level.
|
|
|
|
The proto-JSON encoding drops top-level extras, so the ``trajectoryId`` and
|
|
``stepIndex`` MUST be inside the ``interaction`` key, alongside the payload
|
|
variant dict. Asserts the exact wire body so a refactor cannot silently
|
|
break the contract consumed by the interaction bridge.
|
|
"""
|
|
seen: dict[str, bytes] = {}
|
|
|
|
def handler(req: httpx.Request) -> httpx.Response:
|
|
seen["body"] = req.content
|
|
return httpx.Response(200, json={})
|
|
|
|
monkeypatch.setattr(rpc, "_HTTP_TRANSPORT", httpx.MockTransport(handler))
|
|
rpc.handle_user_interaction(
|
|
52548,
|
|
"c",
|
|
trajectory_id="t",
|
|
step_index=14,
|
|
payload={"permission": {"allow": True}},
|
|
)
|
|
assert json.loads(seen["body"]) == {
|
|
"cascadeId": "c",
|
|
"interaction": {"trajectoryId": "t", "stepIndex": 14, "permission": {"allow": True}},
|
|
}
|
|
|
|
|
|
def test_handle_user_interaction_raises_on_500(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
"""
|
|
A non-2xx response from ``HandleCascadeUserInteraction`` raises
|
|
``AntigravityRpcError`` carrying the raw response body text.
|
|
|
|
The body text is load-bearing: the Task 8 interaction bridge detects the
|
|
overloaded ``"input not registered for step N"`` string to handle the
|
|
race-condition case where agy has not yet registered the interaction. The
|
|
exception must carry the body, not httpx's generic message.
|
|
"""
|
|
monkeypatch.setattr(
|
|
rpc,
|
|
"_HTTP_TRANSPORT",
|
|
httpx.MockTransport(
|
|
lambda r: httpx.Response(500, json={"message": "input not registered for step 14"})
|
|
),
|
|
)
|
|
with pytest.raises(rpc.AntigravityRpcError) as exc_info:
|
|
rpc.handle_user_interaction(
|
|
52548,
|
|
"c",
|
|
trajectory_id="t",
|
|
step_index=14,
|
|
payload={"permission": {"allow": True}},
|
|
)
|
|
assert "input not registered" in str(exc_info.value)
|
|
|
|
|
|
def test_handle_user_interaction_raises_rpc_error_on_transport_error(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""
|
|
A transport failure from ``HandleCascadeUserInteraction`` raises
|
|
``AntigravityRpcError``, NOT a raw ``httpx.HTTPError``.
|
|
|
|
The Task 8 bridge catches ``AntigravityRpcError`` as the single failure
|
|
surface for all delivery problems; a raw transport error bypassing that
|
|
type would crash the bridge. Asserts the unified failure surface contract.
|
|
"""
|
|
|
|
def raise_transport(req: httpx.Request) -> httpx.Response:
|
|
raise httpx.ConnectError("refused")
|
|
|
|
monkeypatch.setattr(rpc, "_HTTP_TRANSPORT", httpx.MockTransport(raise_transport))
|
|
with pytest.raises(rpc.AntigravityRpcError, match="transport error"):
|
|
rpc.handle_user_interaction(
|
|
52548,
|
|
"c",
|
|
trajectory_id="t",
|
|
step_index=0,
|
|
payload={"permission": {"allow": True}},
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# send_user_cascade_message (unary RPC, Task T-A)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_send_user_cascade_message_posts_exact_nested_body(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""
|
|
``send_user_cascade_message`` POSTs a body with ``items[].text`` and
|
|
``cascadeConfig.plannerConfig.planModel`` to ``SendUserCascadeMessage``.
|
|
|
|
The design specifies EXACT shapes: text MUST be in ``items[0].text`` (a list
|
|
of objects, NOT a flat "message"), and the model MUST be at
|
|
``cascadeConfig.plannerConfig.planModel`` (omitting it causes agy to error
|
|
"neither PlanModel nor RequestedModel specified"). Asserts the full wire body
|
|
so a refactor cannot silently break either constraint.
|
|
"""
|
|
seen: dict[str, object] = {}
|
|
|
|
def handler(req: httpx.Request) -> httpx.Response:
|
|
seen["url"] = str(req.url)
|
|
seen["body"] = json.loads(req.content)
|
|
seen["content_type"] = req.headers.get("content-type")
|
|
return httpx.Response(200, json={})
|
|
|
|
monkeypatch.setattr(rpc, "_HTTP_TRANSPORT", httpx.MockTransport(handler))
|
|
rpc.send_user_cascade_message(
|
|
52548,
|
|
"conv-uuid",
|
|
"hello from omnigent",
|
|
plan_model="gemini-2.5-pro",
|
|
)
|
|
assert seen["url"] == (
|
|
"https://127.0.0.1:52548/exa.language_server_pb.LanguageServerService/"
|
|
"SendUserCascadeMessage"
|
|
)
|
|
assert seen["content_type"] == "application/json"
|
|
assert seen["body"] == {
|
|
"cascadeId": "conv-uuid",
|
|
"items": [{"text": "hello from omnigent"}],
|
|
"cascadeConfig": {"plannerConfig": {"planModel": "gemini-2.5-pro"}},
|
|
}
|
|
|
|
|
|
def test_send_user_cascade_message_returns_none_on_200(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""
|
|
A 200 response causes ``send_user_cascade_message`` to return without raising.
|
|
|
|
The turn-send is fire-and-forget from the caller's perspective; the only
|
|
signal needed is "agy accepted" (200) vs "error" (raise). No exception on
|
|
200 mirrors ``handle_user_interaction``'s contract.
|
|
"""
|
|
monkeypatch.setattr(
|
|
rpc,
|
|
"_HTTP_TRANSPORT",
|
|
httpx.MockTransport(lambda r: httpx.Response(200, json={})),
|
|
)
|
|
rpc.send_user_cascade_message(52548, "c", "hi", plan_model="m") # must not raise
|
|
|
|
|
|
def test_send_user_cascade_message_raises_rpc_error_on_500(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""
|
|
A non-2xx response raises ``AntigravityRpcError`` carrying the raw body text.
|
|
|
|
Mirrors ``handle_user_interaction``: the body is load-bearing so the executor
|
|
can surface model/validation error messages (e.g. "neither PlanModel nor
|
|
RequestedModel specified") without wrapping them in a generic string.
|
|
"""
|
|
monkeypatch.setattr(
|
|
rpc,
|
|
"_HTTP_TRANSPORT",
|
|
httpx.MockTransport(
|
|
lambda r: httpx.Response(
|
|
500, json={"message": "neither PlanModel nor RequestedModel specified"}
|
|
)
|
|
),
|
|
)
|
|
with pytest.raises(rpc.AntigravityRpcError) as exc_info:
|
|
rpc.send_user_cascade_message(52548, "c", "hi", plan_model="")
|
|
assert "PlanModel" in str(exc_info.value)
|
|
|
|
|
|
def test_send_user_cascade_message_raises_rpc_error_on_transport_error(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""
|
|
A transport failure raises ``AntigravityRpcError``, NOT raw ``httpx.HTTPError``.
|
|
|
|
The executor catches ``AntigravityRpcError`` as the single failure surface;
|
|
a raw transport error bypassing that type would crash the executor. Mirrors
|
|
``handle_user_interaction``'s transport-error contract.
|
|
"""
|
|
|
|
def raise_transport(req: httpx.Request) -> httpx.Response:
|
|
raise httpx.ConnectError("refused")
|
|
|
|
monkeypatch.setattr(rpc, "_HTTP_TRANSPORT", httpx.MockTransport(raise_transport))
|
|
with pytest.raises(rpc.AntigravityRpcError, match="transport error"):
|
|
rpc.send_user_cascade_message(52548, "c", "hi", plan_model="m")
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# start_cascade (unary RPC, Task 11a cold-start bootstrap)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_start_cascade_posts_cascade_id_and_source(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""
|
|
``start_cascade`` POSTs ``{"cascadeId": ..., "source": ...}`` to ``StartCascade``.
|
|
|
|
The runner cold-starts the conversation so its turn-1 has a real cascade id
|
|
instead of waiting for the TUI to lazily mint one. ``source`` is the only
|
|
required field; the runner PROVIDES the cascadeId so it owns the id. Asserts
|
|
the full wire body so a refactor cannot drop either field or change the
|
|
default source enum.
|
|
"""
|
|
seen: dict[str, object] = {}
|
|
|
|
def handler(req: httpx.Request) -> httpx.Response:
|
|
seen["url"] = str(req.url)
|
|
seen["body"] = json.loads(req.content)
|
|
seen["content_type"] = req.headers.get("content-type")
|
|
return httpx.Response(200, json={"cascadeId": "runner-uuid"})
|
|
|
|
monkeypatch.setattr(rpc, "_HTTP_TRANSPORT", httpx.MockTransport(handler))
|
|
rpc.start_cascade(52548, "runner-uuid")
|
|
assert seen["url"] == (
|
|
"https://127.0.0.1:52548/exa.language_server_pb.LanguageServerService/StartCascade"
|
|
)
|
|
assert seen["content_type"] == "application/json"
|
|
assert seen["body"] == {
|
|
"cascadeId": "runner-uuid",
|
|
"source": "CORTEX_TRAJECTORY_SOURCE_CLI",
|
|
}
|
|
|
|
|
|
def test_start_cascade_returns_none_on_200(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""
|
|
A 200 response causes ``start_cascade`` to return without raising.
|
|
|
|
The bootstrap only needs "agy created the cascade" (200) vs "error" (raise);
|
|
no return value is consumed (the runner already holds the id it sent). Mirrors
|
|
``send_user_cascade_message``'s 200 contract.
|
|
"""
|
|
monkeypatch.setattr(
|
|
rpc,
|
|
"_HTTP_TRANSPORT",
|
|
httpx.MockTransport(lambda r: httpx.Response(200, json={"cascadeId": "c"})),
|
|
)
|
|
rpc.start_cascade(52548, "c") # must not raise
|
|
|
|
|
|
def test_start_cascade_raises_rpc_error_on_500(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""
|
|
A non-2xx response raises ``AntigravityRpcError`` carrying the raw body text.
|
|
|
|
Mirrors ``send_user_cascade_message``: the body is the message (NOT
|
|
``raise_for_status()``) so the runner can surface agy's error verbatim rather
|
|
than a generic status string.
|
|
"""
|
|
monkeypatch.setattr(
|
|
rpc,
|
|
"_HTTP_TRANSPORT",
|
|
httpx.MockTransport(lambda r: httpx.Response(500, json={"message": "cascade boom"})),
|
|
)
|
|
with pytest.raises(rpc.AntigravityRpcError) as exc_info:
|
|
rpc.start_cascade(52548, "c")
|
|
assert "cascade boom" in str(exc_info.value)
|
|
|
|
|
|
def test_start_cascade_raises_rpc_error_on_transport_error(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""
|
|
A transport failure raises ``AntigravityRpcError``, NOT a raw ``httpx.HTTPError``.
|
|
|
|
The runner bootstrap catches ``AntigravityRpcError`` as the single failure
|
|
surface; a raw transport error bypassing that type would crash the launch.
|
|
Mirrors ``send_user_cascade_message``'s transport-error contract.
|
|
"""
|
|
|
|
def raise_transport(req: httpx.Request) -> httpx.Response:
|
|
raise httpx.ConnectError("refused")
|
|
|
|
monkeypatch.setattr(rpc, "_HTTP_TRANSPORT", httpx.MockTransport(raise_transport))
|
|
with pytest.raises(rpc.AntigravityRpcError, match="transport error"):
|
|
rpc.start_cascade(52548, "c")
|
|
|
|
|
|
def test_start_cascade_custom_source(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""
|
|
A caller-provided ``source`` overrides the default enum in the POST body.
|
|
|
|
Keeps the source a parameter (not a hardcoded constant) so a future agy enum
|
|
rename is a one-line caller change, not an edit to the RPC helper.
|
|
"""
|
|
seen: dict[str, object] = {}
|
|
|
|
def handler(req: httpx.Request) -> httpx.Response:
|
|
seen["body"] = json.loads(req.content)
|
|
return httpx.Response(200, json={})
|
|
|
|
monkeypatch.setattr(rpc, "_HTTP_TRANSPORT", httpx.MockTransport(handler))
|
|
rpc.start_cascade(52548, "c", source="CORTEX_TRAJECTORY_SOURCE_OTHER")
|
|
assert seen["body"] == {"cascadeId": "c", "source": "CORTEX_TRAJECTORY_SOURCE_OTHER"}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# get_available_models (unary RPC, Task T-A)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_get_available_models_posts_empty_body_and_returns_catalog(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""
|
|
``get_available_models`` POSTs ``{}`` to ``GetAvailableModels`` and returns
|
|
the catalog unwrapped from the live ``{"response": {...}}`` envelope.
|
|
|
|
The live 200 body wraps the catalog under ``"response"``; the function unwraps
|
|
it so callers see ``{"models": {<key>: {"model", "displayName", ...}}}`` at the
|
|
top level. Asserts the URL and wire body so a refactor cannot silently change
|
|
the method or add unexpected request fields.
|
|
"""
|
|
seen: dict[str, object] = {}
|
|
catalog = {
|
|
"models": {
|
|
"gemini-2.5-pro": {
|
|
"model": "gemini-2.5-pro",
|
|
"displayName": "Gemini 2.5 Pro",
|
|
"recommended": True,
|
|
"supportsThinking": True,
|
|
"thinkingBudget": 8192,
|
|
}
|
|
},
|
|
"defaultAgentModelId": "gemini-2.5-pro",
|
|
}
|
|
|
|
def handler(req: httpx.Request) -> httpx.Response:
|
|
seen["url"] = str(req.url)
|
|
seen["body"] = json.loads(req.content)
|
|
# Live agy wraps the catalog under a top-level "response" key.
|
|
return httpx.Response(200, json={"response": catalog})
|
|
|
|
monkeypatch.setattr(rpc, "_HTTP_TRANSPORT", httpx.MockTransport(handler))
|
|
result = rpc.get_available_models(52548)
|
|
assert seen["url"] == (
|
|
"https://127.0.0.1:52548/exa.language_server_pb.LanguageServerService/GetAvailableModels"
|
|
)
|
|
assert seen["body"] == {}
|
|
# The "response" envelope is unwrapped: callers read "models" at the top level.
|
|
assert result == catalog
|
|
|
|
|
|
def test_get_available_models_raises_on_500(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
"""
|
|
A non-2xx response from ``GetAvailableModels`` raises ``httpx.HTTPStatusError``.
|
|
|
|
Mirrors ``get_trajectory_steps``' error contract: a hard read failure raises
|
|
rather than silently returning ``{}``, so the executor gets a clear signal
|
|
that model resolution failed.
|
|
"""
|
|
monkeypatch.setattr(
|
|
rpc,
|
|
"_HTTP_TRANSPORT",
|
|
httpx.MockTransport(lambda r: httpx.Response(500, json={"code": "unknown"})),
|
|
)
|
|
with pytest.raises(httpx.HTTPStatusError):
|
|
rpc.get_available_models(52548)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# get_all_cascade_trajectories (unary RPC, Task T-G /clear-rotation signal)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_get_all_cascade_trajectories_posts_empty_body_and_returns_summaries(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""
|
|
``get_all_cascade_trajectories`` POSTs ``{}`` to ``GetAllCascadeTrajectories``
|
|
and returns the parsed body (the ``trajectorySummaries`` map).
|
|
|
|
Asserts the exact URL and wire body so a refactor cannot silently change the
|
|
method or add unexpected request fields, and that the returned body is the
|
|
raw response (the reader's detection helper does the summary selection).
|
|
"""
|
|
seen: dict[str, object] = {}
|
|
body = {
|
|
"trajectorySummaries": {
|
|
"0715c922-02fc-4278-bab8-3a6ea565bbbf": {
|
|
"trajectoryId": "0b3d852b-b4b4-4aa4-b51c-ca80d3b2df94",
|
|
"status": "CASCADE_RUN_STATUS_IDLE",
|
|
"lastUserInputTime": "2026-06-23T17:50:29.232919Z",
|
|
"trajectoryType": "CORTEX_TRAJECTORY_TYPE_CASCADE",
|
|
}
|
|
}
|
|
}
|
|
|
|
def handler(req: httpx.Request) -> httpx.Response:
|
|
seen["url"] = str(req.url)
|
|
seen["body"] = json.loads(req.content)
|
|
return httpx.Response(200, json=body)
|
|
|
|
monkeypatch.setattr(rpc, "_HTTP_TRANSPORT", httpx.MockTransport(handler))
|
|
result = rpc.get_all_cascade_trajectories(52548)
|
|
assert seen["url"] == (
|
|
"https://127.0.0.1:52548/exa.language_server_pb.LanguageServerService/"
|
|
"GetAllCascadeTrajectories"
|
|
)
|
|
assert seen["body"] == {}
|
|
assert result == body
|
|
|
|
|
|
def test_get_all_cascade_trajectories_non_dict_body_returns_empty(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""
|
|
A 200 whose body is not a JSON object yields ``{}`` (defensive).
|
|
|
|
Guards against a future agy returning a non-object 200 — the reader then sees
|
|
no summaries and simply does not rotate, rather than crashing.
|
|
"""
|
|
monkeypatch.setattr(
|
|
rpc,
|
|
"_HTTP_TRANSPORT",
|
|
httpx.MockTransport(lambda r: httpx.Response(200, json=[1, 2, 3])),
|
|
)
|
|
assert rpc.get_all_cascade_trajectories(52548) == {}
|
|
|
|
|
|
def test_get_all_cascade_trajectories_raises_on_500(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
"""
|
|
A non-2xx response raises ``httpx.HTTPStatusError`` (NOT fail-open).
|
|
|
|
Mirrors ``get_trajectory_steps`` / ``get_available_models``: a hard read
|
|
failure raises rather than silently returning ``{}`` (which would be
|
|
indistinguishable from "no rotation"); the reader owns retry/backoff.
|
|
"""
|
|
monkeypatch.setattr(
|
|
rpc,
|
|
"_HTTP_TRANSPORT",
|
|
httpx.MockTransport(lambda r: httpx.Response(500, json={"code": "unknown"})),
|
|
)
|
|
with pytest.raises(httpx.HTTPStatusError):
|
|
rpc.get_all_cascade_trajectories(52548)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# stream_agent_state_updates (connect server-stream, Task T-C)
|
|
# ---------------------------------------------------------------------------
|
|
#
|
|
# These tests drive the connect-protocol server-stream client over a STREAMING
|
|
# ``httpx.MockTransport`` response (a custom ``httpx.AsyncByteStream`` whose
|
|
# chunks are hand-crafted framed bytes). The framing is load-bearing: a bug in
|
|
# reassembly corrupts or drops live token deltas, so the tests pin the request
|
|
# envelope and exercise the three reassembly hazards — multiple frames in one
|
|
# chunk, one frame split across chunks, and the trailer terminating the stream.
|
|
|
|
|
|
def _frame(flag: int, payload: bytes) -> bytes:
|
|
"""
|
|
Build one connect-protocol frame: ``[flag: 1B][length: 4B BE][payload]``.
|
|
|
|
Mirrors the wire format the client must parse. ``flag == 0x00`` is a data
|
|
message (payload is the JSON ``update`` object); ``flag & 0x02`` marks the
|
|
end-of-stream trailer.
|
|
|
|
:param flag: The 1-byte frame flag (e.g. ``0x00`` data, ``0x02`` trailer).
|
|
:param payload: The frame payload bytes (JSON for a data frame).
|
|
:returns: The fully framed bytes ready to concatenate into a stream.
|
|
"""
|
|
return bytes([flag]) + struct.pack(">I", len(payload)) + payload
|
|
|
|
|
|
def _data_frame(obj: dict[str, object]) -> bytes:
|
|
"""Build a ``flag==0x00`` data frame for the logical update ``obj``.
|
|
|
|
The live wire wraps every DATA frame's update in a connect envelope
|
|
``{"update": {...}}`` (agy 1.0.10), and the generator unwraps it before
|
|
yielding. This helper wraps ``obj`` in that envelope so the test exercises the
|
|
unwrap, while ``obj`` itself is what the generator is expected to yield back.
|
|
"""
|
|
return _frame(0x00, json.dumps({"update": obj}).encode("utf-8"))
|
|
|
|
|
|
class _ByteChunks(httpx.AsyncByteStream):
|
|
"""
|
|
An ``httpx.AsyncByteStream`` that yields a fixed list of byte chunks.
|
|
|
|
Used to drive ``stream_agent_state_updates`` over ``MockTransport`` with
|
|
EXACT control of chunk boundaries (``aiter_bytes`` preserves them), so the
|
|
split-frame reassembly path can be exercised deterministically.
|
|
"""
|
|
|
|
def __init__(self, chunks: list[bytes]) -> None:
|
|
self._chunks = chunks
|
|
|
|
async def __aiter__(self) -> AsyncIterator[bytes]:
|
|
for chunk in self._chunks:
|
|
yield chunk
|
|
|
|
async def aclose(self) -> None:
|
|
return None
|
|
|
|
|
|
async def _collect(port: int, conversation_id: str) -> list[dict[str, object]]:
|
|
"""Drive the async generator to completion, collecting its yielded dicts."""
|
|
return [update async for update in rpc.stream_agent_state_updates(port, conversation_id)]
|
|
|
|
|
|
def _stream_transport(chunks: list[bytes], seen: dict[str, object]) -> httpx.MockTransport:
|
|
"""
|
|
Build a ``MockTransport`` that records the request and replies with a stream.
|
|
|
|
Captures the request URL / content-type / body bytes into ``seen`` (so the
|
|
connect envelope can be asserted) and returns a 200 streaming response whose
|
|
body is ``chunks`` (hand-crafted frames).
|
|
"""
|
|
|
|
def handler(request: httpx.Request) -> httpx.Response:
|
|
seen["url"] = str(request.url)
|
|
seen["content_type"] = request.headers.get("content-type")
|
|
seen["body"] = request.content
|
|
return httpx.Response(200, stream=_ByteChunks(chunks))
|
|
|
|
return httpx.MockTransport(handler)
|
|
|
|
|
|
async def test_stream_agent_state_updates_request_envelope(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""
|
|
The POST body is a single connect-enveloped frame and the content-type is
|
|
``application/connect+json``.
|
|
|
|
The request body MUST be ``[0x00][BE-uint32 len][{"conversationId": ...}]``
|
|
(a connect-enveloped message), not a bare JSON object — agy's connect-stream
|
|
endpoint rejects an unframed body. Asserts the exact URL, header, and the
|
|
byte-level envelope so a refactor cannot silently drop the framing.
|
|
"""
|
|
seen: dict[str, object] = {}
|
|
trailer = _frame(0x02, b"")
|
|
monkeypatch.setattr(rpc, "_ASYNC_HTTP_TRANSPORT", _stream_transport([trailer], seen))
|
|
await _collect(52548, _CONVERSATION_ID)
|
|
|
|
assert seen["url"] == (
|
|
"https://127.0.0.1:52548/exa.language_server_pb.LanguageServerService/"
|
|
"StreamAgentStateUpdates"
|
|
)
|
|
assert seen["content_type"] == "application/connect+json"
|
|
body = seen["body"]
|
|
assert isinstance(body, (bytes, bytearray))
|
|
# Envelope: flag byte 0x00, then a 4-byte big-endian length, then the payload.
|
|
assert body[0] == 0x00
|
|
payload_len = struct.unpack(">I", body[1:5])[0]
|
|
payload = body[5:]
|
|
assert payload_len == len(payload)
|
|
assert json.loads(payload) == {"conversationId": _CONVERSATION_ID}
|
|
|
|
|
|
async def test_stream_agent_state_updates_yields_data_frames_in_order(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""
|
|
A stream of several data frames + a trailer yields the unwrapped updates in order.
|
|
|
|
The happy path: each ``flag==0x00`` frame's JSON payload is the connect
|
|
envelope ``{"update": {...}}`` (see :func:`_data_frame`); the generator
|
|
unwraps it and yields the inner ``update`` object, in arrival order, and the
|
|
trailer ends iteration cleanly.
|
|
"""
|
|
updates: list[dict[str, object]] = [
|
|
{"mainTrajectoryUpdate": {"stepsUpdate": {"steps": [{"stepIndex": 0}]}}},
|
|
{"mainTrajectoryUpdate": {"stepsUpdate": {"steps": [{"stepIndex": 1}]}}},
|
|
{"conversationId": _CONVERSATION_ID},
|
|
]
|
|
chunks = [_data_frame(u) for u in updates] + [_frame(0x02, b"")]
|
|
monkeypatch.setattr(rpc, "_ASYNC_HTTP_TRANSPORT", _stream_transport(chunks, {}))
|
|
assert await _collect(52548, _CONVERSATION_ID) == updates
|
|
|
|
|
|
async def test_stream_agent_state_updates_yields_frame_without_envelope_verbatim(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""
|
|
A DATA frame lacking the ``{"update": ...}`` envelope is yielded verbatim.
|
|
|
|
Defensive fallback: live agy wraps every update under ``"update"``, but if a
|
|
future agy ever drops the envelope, the generator must yield the parsed dict
|
|
as-is rather than ``None`` — so the reader keeps receiving frames. Built with
|
|
raw ``_frame`` (NOT ``_data_frame``, which would re-wrap).
|
|
"""
|
|
no_envelope = {"mainTrajectoryUpdate": {"stepsUpdate": {"steps": [{"stepIndex": 0}]}}}
|
|
chunks = [
|
|
_frame(0x00, json.dumps(no_envelope).encode("utf-8")),
|
|
_frame(0x02, b""),
|
|
]
|
|
monkeypatch.setattr(rpc, "_ASYNC_HTTP_TRANSPORT", _stream_transport(chunks, {}))
|
|
assert await _collect(52548, _CONVERSATION_ID) == [no_envelope]
|
|
|
|
|
|
async def test_stream_agent_state_updates_reassembles_split_and_packed_frames(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""
|
|
Frames split across chunks AND multiple frames in one chunk reassemble.
|
|
|
|
The critical reassembly case. The transport yields chunk boundaries that do
|
|
NOT line up with frame boundaries: frame A is split across two chunks, while
|
|
frames B and C arrive packed together in a single chunk. The client must
|
|
maintain a byte buffer and emit exactly A, B, C — never assuming one chunk
|
|
equals one frame.
|
|
"""
|
|
frame_a = _data_frame({"n": "a", "pad": "x" * 50})
|
|
frame_b = _data_frame({"n": "b"})
|
|
frame_c = _data_frame({"n": "c"})
|
|
trailer = _frame(0x02, b"")
|
|
|
|
split = len(frame_a) // 2
|
|
chunks = [
|
|
frame_a[:split], # first half of A (header may itself be split here)
|
|
frame_a[split:] + frame_b, # rest of A, then B fully — boundary mid-chunk
|
|
frame_c + trailer, # C and the trailer packed together
|
|
]
|
|
monkeypatch.setattr(rpc, "_ASYNC_HTTP_TRANSPORT", _stream_transport(chunks, {}))
|
|
assert await _collect(52548, _CONVERSATION_ID) == [
|
|
{"n": "a", "pad": "x" * 50},
|
|
{"n": "b"},
|
|
{"n": "c"},
|
|
]
|
|
|
|
|
|
async def test_stream_agent_state_updates_header_split_across_chunks(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""
|
|
A 5-byte frame header itself split across chunks reassembles correctly.
|
|
|
|
The buffer must not attempt to read the header until it holds the full 5
|
|
bytes (1 flag + 4 length). Here the first chunk carries only 2 bytes of the
|
|
header, so the loop must await more before parsing.
|
|
"""
|
|
frame = _data_frame({"hello": "world"})
|
|
chunks = [frame[:2], frame[2:], _frame(0x02, b"")]
|
|
monkeypatch.setattr(rpc, "_ASYNC_HTTP_TRANSPORT", _stream_transport(chunks, {}))
|
|
assert await _collect(52548, _CONVERSATION_ID) == [{"hello": "world"}]
|
|
|
|
|
|
async def test_stream_agent_state_updates_trailer_ends_stream(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""
|
|
A trailer frame (``flag & 0x02``) stops iteration even if more bytes follow.
|
|
|
|
The end-of-stream trailer is authoritative: any data frame after it must NOT
|
|
be yielded. Guards against over-reading a stream that agy has signalled
|
|
complete.
|
|
"""
|
|
chunks = [
|
|
_data_frame({"n": "first"}),
|
|
_frame(0x02, b'{"someTrailer": true}'), # clean trailer (no error → stop)
|
|
_data_frame({"n": "after-trailer-must-be-ignored"}),
|
|
]
|
|
monkeypatch.setattr(rpc, "_ASYNC_HTTP_TRANSPORT", _stream_transport(chunks, {}))
|
|
assert await _collect(52548, _CONVERSATION_ID) == [{"n": "first"}]
|
|
|
|
|
|
async def test_stream_agent_state_updates_raises_on_trailer_error(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""
|
|
A trailer carrying a connect ``error`` object raises ``AntigravityRpcError``.
|
|
|
|
In connect streaming a mid-stream server failure is reported in the TRAILER
|
|
PAYLOAD as ``{"error": {...}}`` — NOT via HTTP status (200 + headers were
|
|
already flushed). Treating that trailer as a clean stop would silently
|
|
truncate the turn for the reader, so the framing layer must surface it: the
|
|
generator yields the data frames that DID arrive, then raises with the error
|
|
message in the exception text.
|
|
"""
|
|
chunks = [
|
|
_data_frame({"n": "first"}),
|
|
_data_frame({"n": "second"}),
|
|
_frame(0x02, b'{"error": {"code": "internal", "message": "cascade exploded"}}'),
|
|
]
|
|
monkeypatch.setattr(rpc, "_ASYNC_HTTP_TRANSPORT", _stream_transport(chunks, {}))
|
|
collected: list[dict[str, object]] = []
|
|
with pytest.raises(rpc.AntigravityRpcError, match="cascade exploded"):
|
|
async for update in rpc.stream_agent_state_updates(52548, _CONVERSATION_ID):
|
|
collected.append(update)
|
|
# The data frames before the error trailer were still delivered in order.
|
|
assert collected == [{"n": "first"}, {"n": "second"}]
|
|
|
|
|
|
async def test_stream_agent_state_updates_empty_trailer_is_clean_stop(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""
|
|
An empty trailer payload (``b""``) — and a ``{}`` trailer — end the stream
|
|
cleanly.
|
|
|
|
Only a non-empty ``error`` object is an error; the common case is a trailer
|
|
with no payload (or an empty object), which is a normal end-of-stream and
|
|
must NOT raise.
|
|
"""
|
|
chunks = [_data_frame({"n": "only"}), _frame(0x02, b"")]
|
|
monkeypatch.setattr(rpc, "_ASYNC_HTTP_TRANSPORT", _stream_transport(chunks, {}))
|
|
assert await _collect(52548, _CONVERSATION_ID) == [{"n": "only"}]
|
|
|
|
chunks_empty_obj = [_data_frame({"n": "only"}), _frame(0x02, b"{}")]
|
|
monkeypatch.setattr(rpc, "_ASYNC_HTTP_TRANSPORT", _stream_transport(chunks_empty_obj, {}))
|
|
assert await _collect(52548, _CONVERSATION_ID) == [{"n": "only"}]
|
|
|
|
|
|
async def test_stream_agent_state_updates_raises_on_compressed_frame(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""
|
|
A compressed frame (``flag & 0x01``) raises rather than mis-decoding.
|
|
|
|
agy sends uncompressed frames; a compressed flag would mean the JSON decode
|
|
is wrong. The client must raise a clear error instead of feeding compressed
|
|
bytes to ``json.loads`` (which would surface as a confusing decode error).
|
|
"""
|
|
chunks = [_frame(0x01, b"\x1f\x8b compressed junk")]
|
|
monkeypatch.setattr(rpc, "_ASYNC_HTTP_TRANSPORT", _stream_transport(chunks, {}))
|
|
with pytest.raises(rpc.AntigravityRpcError, match="compress"):
|
|
await _collect(52548, _CONVERSATION_ID)
|
|
|
|
|
|
async def test_stream_agent_state_updates_rejects_non_loopback_url(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""
|
|
A non-loopback stream URL is refused before the ``verify=False`` client runs.
|
|
|
|
Shares the loopback guard with the unary RPCs: a discovered port that ever
|
|
resolved off-host must be refused, since ``verify=False`` would otherwise
|
|
silently trust any cert.
|
|
"""
|
|
monkeypatch.setattr(rpc, "_rpc_url", lambda port, method: "https://10.0.0.1:1234/svc/Method")
|
|
|
|
def _unexpected(request: httpx.Request) -> httpx.Response: # pragma: no cover
|
|
raise AssertionError("request must not be sent for a non-loopback URL")
|
|
|
|
monkeypatch.setattr(rpc, "_ASYNC_HTTP_TRANSPORT", httpx.MockTransport(_unexpected))
|
|
with pytest.raises(ValueError, match="non-loopback connect-RPC URL"):
|
|
await _collect(52548, _CONVERSATION_ID)
|
|
|
|
|
|
async def test_stream_agent_state_updates_raises_on_malformed_json_frame(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""A DATA frame whose payload is not valid JSON raises ``AntigravityRpcError``.
|
|
|
|
Regression for Fix B: ``json.loads`` on the frame payload was unguarded, so a
|
|
malformed DATA frame raised a bare ``json.JSONDecodeError`` that the reader's
|
|
supervisor does not catch — the reader task died silently with no
|
|
poll-fallback. The parse must surface as ``AntigravityRpcError`` (which the
|
|
supervisor catches) instead. The data frame BEFORE the bad one is still
|
|
yielded in order.
|
|
"""
|
|
chunks = [
|
|
_data_frame({"n": "good"}),
|
|
# A data frame whose payload is not valid JSON.
|
|
_frame(0x00, b"{not valid json"),
|
|
]
|
|
monkeypatch.setattr(rpc, "_ASYNC_HTTP_TRANSPORT", _stream_transport(chunks, {}))
|
|
collected: list[dict[str, object]] = []
|
|
with pytest.raises(rpc.AntigravityRpcError, match="malformed JSON"):
|
|
async for update in rpc.stream_agent_state_updates(52548, _CONVERSATION_ID):
|
|
collected.append(update)
|
|
# The valid frame before the malformed one was delivered first.
|
|
assert collected == [{"n": "good"}]
|
|
|
|
|
|
async def test_stream_agent_state_updates_raises_on_non_2xx_status(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""A non-2xx stream response raises rather than yielding nothing forever.
|
|
|
|
Regression for Fix D: httpx ``client.stream()`` does not raise on a non-2xx
|
|
status, and a 4xx/5xx body is not connect-framed — so the frame loop yielded
|
|
nothing and the generator returned cleanly, which the reader treated as a
|
|
normal end-of-stream and reconnected every backoff forever. The status must
|
|
surface as ``AntigravityRpcError`` so the reader takes ONE poll-fallback
|
|
transition instead of busy-reconnecting.
|
|
"""
|
|
|
|
def handler(request: httpx.Request) -> httpx.Response:
|
|
return httpx.Response(503, json={"message": "service unavailable"})
|
|
|
|
monkeypatch.setattr(rpc, "_ASYNC_HTTP_TRANSPORT", httpx.MockTransport(handler))
|
|
with pytest.raises(rpc.AntigravityRpcError, match="HTTP 503"):
|
|
await _collect(52548, _CONVERSATION_ID)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Functional-RPC timeout: the functional calls use _RPC_CALL_TIMEOUT_S, the
|
|
# discovery probes keep the tight _PROBE_TIMEOUT_S
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _capture_sync_client_timeouts(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> list[float]:
|
|
"""Spy on ``_sync_client`` to record every per-request timeout it is built with.
|
|
|
|
Returns the list the spy appends to; the real client is still constructed so
|
|
the RPC's MockTransport request/response path runs unchanged.
|
|
"""
|
|
timeouts: list[float] = []
|
|
real_sync_client = rpc._sync_client
|
|
|
|
def _spy(timeout: float) -> httpx.Client:
|
|
timeouts.append(timeout)
|
|
return real_sync_client(timeout)
|
|
|
|
monkeypatch.setattr(rpc, "_sync_client", _spy)
|
|
return timeouts
|
|
|
|
|
|
def test_functional_rpcs_use_rpc_call_timeout(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
"""The functional RPCs build their client with ``_RPC_CALL_TIMEOUT_S``.
|
|
|
|
A tight discovery-probe timeout (2s) misused here would raise a transport
|
|
``TimeoutException`` against a momentarily busy agy; the caller does not retry
|
|
— the interaction bridge only retries on the "input not registered" substring
|
|
(a timed-out human-answer delivery would be permanently abandoned) and the
|
|
cold-start does NOT retry ``StartCascade`` (a slow ack would deadlock the
|
|
placeholder conversation id). ``get_available_models`` resolves the per-turn
|
|
model enum with no retry (a 2s abort against a momentarily-busy agy surfaces a
|
|
spurious "no model" error instead of completing the turn) and
|
|
``get_all_cascade_trajectories`` is the /clear-rotation functional poll
|
|
(morally a step-read), so both also belong on the generous
|
|
``_RPC_CALL_TIMEOUT_S`` headroom rather than the probe budget.
|
|
"""
|
|
monkeypatch.setattr(
|
|
rpc,
|
|
"_HTTP_TRANSPORT",
|
|
httpx.MockTransport(lambda r: httpx.Response(200, json={"steps": []})),
|
|
)
|
|
|
|
timeouts = _capture_sync_client_timeouts(monkeypatch)
|
|
rpc.get_trajectory_steps(52548, "c")
|
|
rpc.cancel_cascade_steps(52548, "c")
|
|
rpc.handle_user_interaction(
|
|
52548, "c", trajectory_id="t", step_index=0, payload={"permission": {"allow": True}}
|
|
)
|
|
rpc.send_user_cascade_message(52548, "c", "hi", plan_model="MODEL_X")
|
|
rpc.start_cascade(52548, "c")
|
|
rpc.get_available_models(52548)
|
|
rpc.get_all_cascade_trajectories(52548)
|
|
|
|
assert timeouts == [rpc._RPC_CALL_TIMEOUT_S] * 7
|
|
|
|
|
|
def test_discovery_probes_keep_probe_timeout(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
"""The discovery/probe RPCs keep the tight ``_PROBE_TIMEOUT_S``.
|
|
|
|
Scanning several candidate ports must stay fast, so the port-discovery probes
|
|
``Heartbeat`` and ``GetConversationMetadata`` keep the short probe budget —
|
|
they were NOT widened to the functional timeout. (The functional ``GetAvailable
|
|
Models`` / ``GetAllCascadeTrajectories`` calls, by contrast, use
|
|
``_RPC_CALL_TIMEOUT_S`` — see ``test_functional_rpcs_use_rpc_call_timeout``.)
|
|
"""
|
|
monkeypatch.setattr(
|
|
rpc,
|
|
"_HTTP_TRANSPORT",
|
|
httpx.MockTransport(
|
|
lambda r: httpx.Response(200, json={"metadata": {"rootConversationId": "c"}})
|
|
),
|
|
)
|
|
|
|
timeouts = _capture_sync_client_timeouts(monkeypatch)
|
|
rpc._heartbeat_ok(52548)
|
|
rpc._conversation_matches(52548, "c")
|
|
|
|
assert timeouts == [rpc._PROBE_TIMEOUT_S] * 2
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# _pane_pid
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _fake_tmux_pane_pid(stdout: str, *, returncode: int = 0) -> subprocess.CompletedProcess[str]:
|
|
"""Build a ``tmux display-message`` CompletedProcess for stubbing."""
|
|
return subprocess.CompletedProcess(
|
|
args=["tmux"], returncode=returncode, stdout=stdout, stderr=""
|
|
)
|
|
|
|
|
|
def test_pane_pid_parses_display_message(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
"""The pane pid is read from ``tmux display-message -p #{pane_pid}``."""
|
|
captured: list[list[str]] = []
|
|
|
|
def _fake_run(argv: list[str], **_kwargs: object) -> subprocess.CompletedProcess[str]:
|
|
captured.append(argv)
|
|
return _fake_tmux_pane_pid("72753\n")
|
|
|
|
monkeypatch.setattr(rpc.subprocess, "run", _fake_run)
|
|
assert rpc._pane_pid(Path("/tmp/agy/tmux.sock"), "main") == 72753
|
|
# The exact tmux invocation: socket-scoped, the pane target, ``#{pane_pid}``.
|
|
assert captured == [
|
|
[
|
|
"tmux",
|
|
"-S",
|
|
"/tmp/agy/tmux.sock",
|
|
"display-message",
|
|
"-p",
|
|
"-t",
|
|
"main",
|
|
"#{pane_pid}",
|
|
]
|
|
]
|
|
|
|
|
|
def test_pane_pid_none_on_nonzero_exit(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
"""A dead pane (non-zero tmux exit) yields ``None`` so the caller falls back."""
|
|
monkeypatch.setattr(
|
|
rpc.subprocess,
|
|
"run",
|
|
lambda *_a, **_k: _fake_tmux_pane_pid("", returncode=1),
|
|
)
|
|
assert rpc._pane_pid(Path("/tmp/agy/tmux.sock"), "main") is None
|
|
|
|
|
|
def test_pane_pid_none_on_nonnumeric_output(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
"""Non-numeric tmux output (e.g. an error string) yields ``None``."""
|
|
monkeypatch.setattr(
|
|
rpc.subprocess,
|
|
"run",
|
|
lambda *_a, **_k: _fake_tmux_pane_pid("can't find pane\n"),
|
|
)
|
|
assert rpc._pane_pid(Path("/tmp/agy/tmux.sock"), "main") is None
|
|
|
|
|
|
def test_pane_pid_none_when_tmux_missing(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
"""A missing ``tmux`` binary (OSError) yields ``None``, never raising."""
|
|
|
|
def _boom(*_a: object, **_k: object) -> subprocess.CompletedProcess[str]:
|
|
raise FileNotFoundError("tmux")
|
|
|
|
monkeypatch.setattr(rpc.subprocess, "run", _boom)
|
|
assert rpc._pane_pid(Path("/tmp/agy/tmux.sock"), "main") is None
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# _agy_pid_in_pane_subtree
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_agy_pid_in_pane_subtree_uses_pane_pid_when_it_is_agy(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""When the pane process IS agy (the ``exec agy`` case), the pane pid is used.
|
|
|
|
The CLI / non-sandbox launch ``exec``s agy in the pane, so the pane pid is
|
|
agy's own pid — no descendant walk needed.
|
|
"""
|
|
monkeypatch.setattr(rpc, "_list_agy_pids", lambda: [72753, 99999])
|
|
# _child_pids must NOT be consulted when the pane pid itself is agy.
|
|
monkeypatch.setattr(
|
|
rpc,
|
|
"_child_pids",
|
|
lambda _pid: (_ for _ in ()).throw(AssertionError("should not walk children")),
|
|
)
|
|
assert rpc._agy_pid_in_pane_subtree(72753) == 72753
|
|
|
|
|
|
def test_agy_pid_in_pane_subtree_finds_agy_descendant(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""When the pane runs a wrapper (sandbox launcher), agy is a descendant.
|
|
|
|
The sandbox launch makes the pane process the launcher → bwrap → ... → agy,
|
|
so the agy pid is found by walking the pane pid's subtree and intersecting
|
|
with the live agy pids.
|
|
"""
|
|
# Tree: pane 100 -> 200 (bwrap) -> 300 (agy). agy pids = [300].
|
|
children = {100: [200], 200: [300], 300: []}
|
|
monkeypatch.setattr(rpc, "_list_agy_pids", lambda: [300])
|
|
monkeypatch.setattr(rpc, "_child_pids", lambda pid: children.get(pid, []))
|
|
assert rpc._agy_pid_in_pane_subtree(100) == 300
|
|
|
|
|
|
def test_agy_pid_in_pane_subtree_none_when_no_agy_in_tree(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""No agy anywhere in the pane subtree yields ``None`` (caller falls back)."""
|
|
children = {100: [200], 200: []}
|
|
monkeypatch.setattr(rpc, "_list_agy_pids", lambda: [999]) # agy elsewhere, not in tree
|
|
monkeypatch.setattr(rpc, "_child_pids", lambda pid: children.get(pid, []))
|
|
assert rpc._agy_pid_in_pane_subtree(100) is None
|
|
|
|
|
|
def test_agy_pid_in_pane_subtree_tolerates_cycle(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""A pathological parent cycle in the process table terminates (no hang)."""
|
|
# 100 -> 200 -> 100 (cycle); agy is not in the tree.
|
|
children = {100: [200], 200: [100]}
|
|
monkeypatch.setattr(rpc, "_list_agy_pids", lambda: [300])
|
|
monkeypatch.setattr(rpc, "_child_pids", lambda pid: children.get(pid, []))
|
|
assert rpc._agy_pid_in_pane_subtree(100) is None
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# _child_pids
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_child_pids_parses_pgrep_output(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
"""Direct children are parsed from ``pgrep -P`` output."""
|
|
captured: list[list[str]] = []
|
|
|
|
def _fake_run(argv: list[str], **_kwargs: object) -> subprocess.CompletedProcess[str]:
|
|
captured.append(argv)
|
|
return subprocess.CompletedProcess(args=argv, returncode=0, stdout="200\n201\n", stderr="")
|
|
|
|
monkeypatch.setattr(rpc.subprocess, "run", _fake_run)
|
|
assert rpc._child_pids(100) == [200, 201]
|
|
assert captured == [["pgrep", "-P", "100"]]
|
|
|
|
|
|
def test_child_pids_falls_back_to_proc(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
|
|
"""When ``pgrep`` is missing, children come from ``/proc/<child>/stat`` PPid.
|
|
|
|
Exercises the comm-with-spaces-and-parens edge: the PPid is read relative to
|
|
the LAST ``)`` so a process named ``(a) b)`` does not corrupt field offsets.
|
|
"""
|
|
|
|
def _boom(*_a: object, **_k: object) -> subprocess.CompletedProcess[str]:
|
|
raise FileNotFoundError("pgrep")
|
|
|
|
monkeypatch.setattr(rpc.subprocess, "run", _boom)
|
|
|
|
# Fake /proc: child 200 has PPid 100; 201 has a paren-laden comm but PPid 100;
|
|
# 999 has PPid 1 (not a child); "self"/non-numeric entries are ignored.
|
|
proc = tmp_path / "proc"
|
|
proc.mkdir()
|
|
(proc / "200").mkdir()
|
|
(proc / "200" / "stat").write_text("200 (agy) S 100 200 200 0 -1 0\n", encoding="ascii")
|
|
(proc / "201").mkdir()
|
|
(proc / "201" / "stat").write_text("201 (weird ) name) S 100 201 0\n", encoding="ascii")
|
|
(proc / "999").mkdir()
|
|
(proc / "999" / "stat").write_text("999 (init) S 1 999 0\n", encoding="ascii")
|
|
(proc / "self").mkdir() # non-numeric entry must be skipped
|
|
monkeypatch.setattr(rpc, "_PROC_FS", str(proc))
|
|
|
|
assert sorted(rpc._child_pids(100)) == [200, 201]
|
|
|
|
|
|
def test_child_pids_proc_empty_when_no_procfs(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
"""A missing ``/proc`` (non-Linux) yields ``[]`` from the fallback."""
|
|
|
|
def _boom(*_a: object, **_k: object) -> subprocess.CompletedProcess[str]:
|
|
raise FileNotFoundError("pgrep")
|
|
|
|
monkeypatch.setattr(rpc.subprocess, "run", _boom)
|
|
monkeypatch.setattr(rpc, "_PROC_FS", "/nonexistent-proc-xyz")
|
|
assert rpc._child_pids(100) == []
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# resolve_pane_agy_rpc_port
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_resolve_pane_agy_rpc_port_scopes_to_pane_agy(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""The pane's own agy pid drives ``discover_language_server_port``.
|
|
|
|
This is the cross-bind fix: the port comes from THIS session's pane agy, not
|
|
from the lowest of all agy ports on the host.
|
|
"""
|
|
monkeypatch.setattr(rpc, "_pane_pid", lambda socket_path, target: 100)
|
|
monkeypatch.setattr(rpc, "_agy_pid_in_pane_subtree", lambda pid: 300)
|
|
discovered_for: list[int] = []
|
|
|
|
def _fake_discover(pid: int) -> int:
|
|
discovered_for.append(pid)
|
|
return 61000
|
|
|
|
monkeypatch.setattr(rpc, "discover_language_server_port", _fake_discover)
|
|
assert rpc.resolve_pane_agy_rpc_port(Path("/tmp/agy/tmux.sock"), "main") == 61000
|
|
assert discovered_for == [300] # scoped to the pane's agy, not a host-wide scan
|
|
|
|
|
|
def test_resolve_pane_agy_rpc_port_none_when_no_pane_pid(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""No resolvable pane pid → ``None`` (the caller uses the candidates fallback)."""
|
|
monkeypatch.setattr(rpc, "_pane_pid", lambda socket_path, target: None)
|
|
monkeypatch.setattr(
|
|
rpc,
|
|
"_agy_pid_in_pane_subtree",
|
|
lambda _pid: (_ for _ in ()).throw(AssertionError("unreachable")),
|
|
)
|
|
assert rpc.resolve_pane_agy_rpc_port(Path("/tmp/agy/tmux.sock"), "main") is None
|
|
|
|
|
|
def test_resolve_pane_agy_rpc_port_none_when_no_agy_in_subtree(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""A live pane with no agy in its subtree → ``None`` (fallback)."""
|
|
monkeypatch.setattr(rpc, "_pane_pid", lambda socket_path, target: 100)
|
|
monkeypatch.setattr(rpc, "_agy_pid_in_pane_subtree", lambda _pid: None)
|
|
monkeypatch.setattr(
|
|
rpc,
|
|
"discover_language_server_port",
|
|
lambda _pid: (_ for _ in ()).throw(AssertionError("unreachable")),
|
|
)
|
|
assert rpc.resolve_pane_agy_rpc_port(Path("/tmp/agy/tmux.sock"), "main") is None
|
|
|
|
|
|
def test_resolve_pane_agy_rpc_port_none_when_port_not_bound(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""agy resolved but not yet bound (discover returns None) → ``None``."""
|
|
monkeypatch.setattr(rpc, "_pane_pid", lambda socket_path, target: 100)
|
|
monkeypatch.setattr(rpc, "_agy_pid_in_pane_subtree", lambda _pid: 300)
|
|
monkeypatch.setattr(rpc, "discover_language_server_port", lambda _pid: None)
|
|
assert rpc.resolve_pane_agy_rpc_port(Path("/tmp/agy/tmux.sock"), "main") is None
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# resolve_pane_agy_rpc_port_state (3-state)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_resolve_pane_state_found_with_port(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
"""State 1: our agy is in the pane subtree and its port resolves."""
|
|
monkeypatch.setattr(rpc, "_pane_pid", lambda socket_path, target: 100)
|
|
monkeypatch.setattr(rpc, "_agy_pid_in_pane_subtree", lambda _pid: 300)
|
|
monkeypatch.setattr(rpc, "discover_language_server_port", lambda _pid: 61000)
|
|
state = rpc.resolve_pane_agy_rpc_port_state(Path("/tmp/agy/tmux.sock"), "main")
|
|
assert state == rpc.PaneAgyResolution(agy_found=True, port=61000)
|
|
|
|
|
|
def test_resolve_pane_state_found_without_port(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
"""State 2: our agy IS in the subtree but its port is not lsof-attributable.
|
|
|
|
``agy_found`` MUST be ``True`` so the cold-start treats the candidate fallback
|
|
as safe (agy is up in this pane; restricted-/proc one-agy-pod).
|
|
"""
|
|
monkeypatch.setattr(rpc, "_pane_pid", lambda socket_path, target: 100)
|
|
monkeypatch.setattr(rpc, "_agy_pid_in_pane_subtree", lambda _pid: 300)
|
|
monkeypatch.setattr(rpc, "discover_language_server_port", lambda _pid: None)
|
|
state = rpc.resolve_pane_agy_rpc_port_state(Path("/tmp/agy/tmux.sock"), "main")
|
|
assert state == rpc.PaneAgyResolution(agy_found=True, port=None)
|
|
|
|
|
|
def test_resolve_pane_state_no_agy_when_no_pane_pid(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
"""State 3 (no pane pid): ``agy_found`` is ``False`` so the caller keeps polling."""
|
|
monkeypatch.setattr(rpc, "_pane_pid", lambda socket_path, target: None)
|
|
monkeypatch.setattr(
|
|
rpc,
|
|
"_agy_pid_in_pane_subtree",
|
|
lambda _pid: (_ for _ in ()).throw(AssertionError("unreachable")),
|
|
)
|
|
state = rpc.resolve_pane_agy_rpc_port_state(Path("/tmp/agy/tmux.sock"), "main")
|
|
assert state == rpc.PaneAgyResolution(agy_found=False, port=None)
|
|
|
|
|
|
def test_resolve_pane_state_no_agy_when_not_in_subtree(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
"""State 3 (agy not exec'd yet): ``agy_found`` is ``False`` (CLI early-poll)."""
|
|
monkeypatch.setattr(rpc, "_pane_pid", lambda socket_path, target: 100)
|
|
monkeypatch.setattr(rpc, "_agy_pid_in_pane_subtree", lambda _pid: None)
|
|
monkeypatch.setattr(
|
|
rpc,
|
|
"discover_language_server_port",
|
|
lambda _pid: (_ for _ in ()).throw(AssertionError("unreachable")),
|
|
)
|
|
state = rpc.resolve_pane_agy_rpc_port_state(Path("/tmp/agy/tmux.sock"), "main")
|
|
assert state == rpc.PaneAgyResolution(agy_found=False, port=None)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# resolve_cold_start_agy_rpc_port (3-state dispatch)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_cold_start_port_uses_scoped_when_resolved(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
"""State 1: a resolved pane-scoped port wins over a lower foreign candidate."""
|
|
monkeypatch.setattr(
|
|
rpc,
|
|
"resolve_pane_agy_rpc_port_state",
|
|
lambda _sock, _tgt: rpc.PaneAgyResolution(agy_found=True, port=61000),
|
|
)
|
|
monkeypatch.setattr(
|
|
rpc,
|
|
"_candidate_agy_rpc_ports",
|
|
lambda: (_ for _ in ()).throw(AssertionError("scoped port resolved; no candidate scan")),
|
|
)
|
|
assert rpc.resolve_cold_start_agy_rpc_port(Path("/tmp/agy/tmux.sock"), "main") == 61000
|
|
|
|
|
|
def test_cold_start_port_keeps_polling_when_no_agy_in_pane(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""State 3 (THE FIX): pane present, NO agy yet, FOREIGN candidate up → None.
|
|
|
|
The CLI ``tmux_start_on_attach`` early-poll window: our agy is not ``exec``-ed
|
|
yet. A foreign agy is the only candidate (52548). The resolver MUST return
|
|
``None`` (keep polling) and NOT bind the foreign candidate — the durable
|
|
cross-bind this whole change prevents.
|
|
"""
|
|
monkeypatch.setattr(
|
|
rpc,
|
|
"resolve_pane_agy_rpc_port_state",
|
|
lambda _sock, _tgt: rpc.PaneAgyResolution(agy_found=False, port=None),
|
|
)
|
|
monkeypatch.setattr(
|
|
rpc,
|
|
"_candidate_agy_rpc_ports",
|
|
lambda: (_ for _ in ()).throw(
|
|
AssertionError("must NOT consult candidates when our agy is not up yet")
|
|
),
|
|
)
|
|
assert rpc.resolve_cold_start_agy_rpc_port(Path("/tmp/agy/tmux.sock"), "main") is None
|
|
|
|
|
|
def test_cold_start_port_falls_back_when_agy_found_but_no_port(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""State 2: our agy IS up in the pane but its port is not lsof-attributable.
|
|
|
|
Restricted-/proc one-agy-per-pod: agy exists here so the lone candidate is
|
|
ours — the candidate fallback is safe and necessary.
|
|
"""
|
|
monkeypatch.setattr(
|
|
rpc,
|
|
"resolve_pane_agy_rpc_port_state",
|
|
lambda _sock, _tgt: rpc.PaneAgyResolution(agy_found=True, port=None),
|
|
)
|
|
monkeypatch.setattr(rpc, "_candidate_agy_rpc_ports", lambda: [52548])
|
|
assert rpc.resolve_cold_start_agy_rpc_port(Path("/tmp/agy/tmux.sock"), "main") == 52548
|
|
|
|
|
|
def test_cold_start_port_no_pane_uses_candidate(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
"""No pane supplied (remote runner) → lowest candidate; pane resolver untouched."""
|
|
monkeypatch.setattr(
|
|
rpc,
|
|
"resolve_pane_agy_rpc_port_state",
|
|
lambda _sock, _tgt: (_ for _ in ()).throw(
|
|
AssertionError("no pane → pane resolver must not be consulted")
|
|
),
|
|
)
|
|
monkeypatch.setattr(rpc, "_candidate_agy_rpc_ports", lambda: [52548, 61000])
|
|
assert rpc.resolve_cold_start_agy_rpc_port(None, None) == 52548
|
|
|
|
|
|
def test_cold_start_port_no_pane_none_when_no_candidates(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""No pane and no candidates yet → ``None`` (keep polling)."""
|
|
monkeypatch.setattr(rpc, "_candidate_agy_rpc_ports", list)
|
|
assert rpc.resolve_cold_start_agy_rpc_port(None, None) is None
|