da05b924f3
* build(antigravity): add google-antigravity SDK dep + host image (agy CLI, lsof, procps) The antigravity SDK harness needs the google-antigravity package; the managed host image needs the agy CLI on PATH plus lsof/procps for the executor's process discovery. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(antigravity): onboarding — agy auth, harness install/readiness, Gemini provider config Detects/installs the agy CLI, recognizes the Gemini provider family + GEMINI_API_KEY, and wires antigravity into the model catalog, override resolution, and effort levels. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(antigravity-native): native agy harness — registration, bridge state, launch + TUI delivery Registers the antigravity-native harness (aliases, wrapper labels, resume dispatch), the launch config, and the per-conversation bridge state. The bridge also carries the tmux send-keys delivery (inject_user_message_via_tui) used to type web turns into the agy TUI. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(antigravity-native): transcript forwarder (read path) + connect-RPC discovery Mirrors agy's JSONL transcript into the Omnigent session (with post-hoc policy audit), and discovers agy's connect-RPC port by conversation-ownership probe so the forwarder can bind the right brain dir. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(antigravity-native): TUI web-turn executor + runner/runtime/server wiring The executor types every web turn into the agy TUI (a connect-RPC SendAgentMessage is logged as a SYSTEM_MESSAGE the forwarder would not mirror), and the runner auto-creates the agy terminal + forwarder, advertising its tmux pane. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(antigravity): ap-web — agent card, new-chat flow, native-agent wiring Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test(antigravity): e2e-ui new-chat picker shows Antigravity + terminal labels Adds the tests/e2e_ui gate test for the ap-web changes: stubs /v1/agents with the native Antigravity agent, opens the new-chat composer, asserts the agent chip renders the harness-derived label 'Antigravity' (not the raw 'antigravity-native-ui'), and that send POSTs the terminal-first wrapper labels (omnigent.ui=terminal, omnigent.wrapper=antigravity-native-ui). Mirrors the pi-native picker test; runs against a no-agent server (agent-independent UI behavior). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(antigravity-native): use os.environ.copy() to clear exfil scanner The Security Scan's exfil-scan.py flags `dict(os.environ)` in added lines as a wholesale-environ-dump shape (regex `(json.dumps|dict|str|repr)\(\s* os.environ`). The direct-tmux-attach helper only copies the environment to drop TMUX before exec'ing `tmux attach` -- a legitimate subprocess-env build, byte-identical to the sibling claude/pi native harnesses, not an exfil. Switch to the idiomatic `os.environ.copy()` (already used in omnigent/onboarding/sandboxes/bootstrap.py), which returns the same dict[str, str] snapshot and is not matched by the heuristic. No behavior change; unblocks Security Scan and the 7 cascading Security Gate checks. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(antigravity-native): make launch tests hermetic (stub agy binary) The four `test_launch_and_record_*` tests drove `_launch_and_record` → `build_agy_launch`, which uses `agy_binary_path()` as argv[0] unconditionally and raises `RuntimeError` when agy is absent from PATH — true in CI. They only passed locally because agy happens to be installed. One test tried to patch `_mod.agy_binary_path`, but `build_agy_launch` resolves the name in its OWN module (`antigravity_native_launch`), so that patch was ineffective. Add an autouse fixture that stubs `agy_binary_path` at both lookup sites (launch module + the antigravity_native re-export), and drop the ineffective per-test patch. Proven via a no-agy reproduction: the real resolver raises, the tests fail without the fixture and pass with it. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(onboarding): keep gemini out of the openai-family "Other provider" picker Adding the `gemini` catalog provider (for the antigravity SDK flavor) put it in `key_providers()` but not in `_PRESET_KEY_PROVIDERS`, so `other_key_providers()` no longer excluded it. Gemini then leaked into the openai-family "Other provider" catch-all — whose tail is documented as "all openai-family" — and, sorting before `xai`, became picker entry #1. Selecting "Other → #1" stored the entry under the `gemini` family (KeyError: 'openai' in the add-other test). Gemini already has its own "Gemini — API key" top-level entry (gemini-family scoped), so it belongs in `_PRESET_KEY_PROVIDERS` like openai/anthropic/ openrouter. Add it there; update test_add_menu_options_ordering for the new first-party Gemini key entry and assert the gemini-family scoped subset. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(ap-web): stub AntigravityIcon in test-setup so suites load under vitest `SubagentsPanel.tsx` now imports `AntigravityIcon` (@lobehub/icons/es/ Antigravity), whose glyph drags in @lobehub/fluent-emoji → @emoji-mart/data. Those JSON modules need an import attribute that Node refuses under vitest, so every suite reaching SubagentsPanel (AddAgentDialog, AppShell.subagent-nav, SubagentsPanel) failed to LOAD — "needs an import attribute of type json". The sibling @lobehub icons (Claude/Codex/Cursor) are already stubbed here for the same broken-nested-resolution reason; AntigravityIcon was simply missing. Add the matching stub. Verified: with it the 3 suites load (negative control: without it SubagentsPanel.test.tsx fails to load on the fluent-emoji chain). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test(antigravity-native): de-flake restart-cursor forwarder test `test_restart_with_persisted_cursor_emits_only_new_steps` waited for the emitted item event, then cancelled the forwarder and asserted the persisted cursor was 4. But the forwarder posts the item THEN advances the cursor, so the immediate cancel could interrupt before the cursor write landed — a CI-load race that failed as `assert 2 == 4`. Wait for the cursor itself (strictly stronger: it implies the item was already mirrored), mirroring the first-run loop. Stable across 20 local repeats; full forwarder file green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * refactor(onboarding): family-filter the "Other provider" tail at the chokepoint Adversarial review (codex) flagged that keeping gemini out of the openai-family "Other provider" picker via _PRESET_KEY_PROVIDERS alone is exclusion-list based: a future non-openai catalog family omitted from that tuple would leak into the openai-only catch-all again (the gemini bug, reincarnated). The "Other provider" option is openai-family scoped (_add_option_families), so converge the fix at the chokepoint — other_key_providers() now filters to OPENAI_FAMILY, not just the preset list. Zero behavior change today (the whole current tail is openai-family); it hardens the class of bug. Also note in the agy-stub fixture that the real missing-binary path is covered in test_antigravity_native_launch.py. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(antigravity-native): address #892 review — durable SET resume cursor + tests Responds to PattaraS's 5 findings on PR #892: 1. Forwarder no longer drops a not-yet-written out-of-order step across a restart. The durable resume cursor is now the EXACT SET of acked step indices (forwarded_steps), suppressed by MEMBERSHIP, not a single <= high-water: agy writes step_index both non-contiguously AND out of order, so a <= floor advanced past a {12,14} batch silently dropped a later 13. The set is carried across same-conversation resume rewrites (_launch_and_record + runner auto-create) and materializes a legacy <=-floor into the set on upgrade. (bridge + forwarder + runner) 2. Pin the agy install: the bootstrapper has no version flag (always fetches latest from its auto-updater manifest), so the Dockerfile now fails the build when the installed agy != AGY_EXPECTED_VERSION (1.0.10) — a silent harness break becomes a conscious, visible bump. 3. Test the eager terminal-close finally seam (reattached / DETACHED). 4. Test the suppress-by-id branch (_dispatched_call_ids) directly — both arms. 5. Fix stale docstring: web turns inject via tmux send-keys, not connect-RPC SendAgentMessage (which agy logs as a SYSTEM_MESSAGE). Verified: 201 affected tests pass; ruff + format clean; a live omnigent end-to-end run confirms the out-of-order step survives a forwarder restart and renders in the web UI. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(antigravity-native): CLI reattaches to runner-owned terminal (no double-launch) A fresh/cold-resume `omnigent antigravity` launch bound the runner and then ALSO ran `_launch_and_record`, double-launching the agy terminal: binding the runner triggers the runner's idempotent auto-create of `antigravity:main` (runner/app.py `_auto_create_antigravity_terminal`, which owns the terminal for every antigravity-native session), so the CLI's redundant terminal POST 500'd ("already observed as required") AND its `clear_bridge_state` wiped the bridge state the runner wrote — leaving the session `failed` and every web turn erroring with "Antigravity native bridge state is missing". Fix: after binding the runner, reattach to the runner-owned terminal (`_await_runner_antigravity_terminal` polls for it post-bind, mirroring the existing pre-bind resume reattach which can't catch the post-bind auto-create). A CLI-side launch stays only as a defensive fallback, so the change can only help or be neutral. Also corrects the now-stale "the runner has no agy auto-create branch" docstrings (the branch was added in 3666dbb0). Restores claude/codex parity for fresh CLI launches. Adds a regression test (fresh launch reattaches, never calls `_launch_and_record`) and keeps the cold-resume fallback test fast via a shortened wait. Verified: 168 affected tests pass; ruff + format + mypy clean. Live confirmation of a working send still pending. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(antigravity-native): CLI defers forwarding to the runner on reattach Coupled follow-on to the double-launch fix, found in live testing: when the CLI reattaches to a runner-owned terminal it was STILL starting its own `supervise_forwarder` in `_attach_terminal`, while the runner already runs one (it auto-creates "terminal + forwarder" together). Two tailers POSTing the same agy transcript double-mirrored every step — verified live as duplicated chat messages and a duplicate one-time degrade notice. Fix: only start the CLI-side forwarder when NOT `prepared.reattached` (the fallback where the CLI launched its own terminal and is the sole mirror source); otherwise defer to the runner's forwarder. Same "runner owns the antigravity session" cleanup as the launch fix. Adds regression tests (reattached → no CLI forwarder; not-reattached → CLI forwards), counting the call deterministically rather than the cancellable task body. Verified live: with this + the launch fix, a fresh `omnigent antigravity` session sends from the web chat with no "bridge state missing", agy responds, and the reply mirrors back exactly once. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(antigravity-native): reattach on the local-server launch path (no double-launch/forward) The double-launch/double-forward fixes (7df3ba4d, f4ce3ce8) only patched the daemon prepare path (_prepare_antigravity_terminal_via_daemon). The default `omnigent antigravity` (local server) goes through _prepare_antigravity_terminal, which bound the runner then unconditionally called _launch_and_record with NO post-bind reattach -- racing the runner's _auto_create_antigravity_terminal exactly as the daemon path did. The local CLI usually wins (so it mostly worked), but when the runner wins, _launch_and_record's clear_bridge_state wipes the runner's bridge state (web turns fail "Antigravity native bridge state is missing"), its redundant terminal POST 500s, and reattached=False starts a second supervise_forwarder -> double-mirror. Mirror the daemon fix: after _bind_session_runner, poll for the runner-owned terminal (_await_runner_antigravity_terminal) and reattach (reattached=True) instead of launching; the CLI launch stays a defensive fallback. When no runner is bound (pure-local CLI), the path is unchanged (the CLI is the sole owner). Adds a regression test for the local path (fresh launch reattaches, never calls _launch_and_record). Found by adversarial review (gemini). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test(antigravity-native): make the port-unresolved RPC test hermetic test_conversation_id_owned_by_pid_none_when_port_unresolved stubbed discover_language_server_port -> None but not _candidate_agy_rpc_ports, so when the pid-scoped port is unresolved the production fallback scanned EVERY live agy connect-RPC port. On any host/CI runner with a concurrent agy that fallback found real ports and ran _conversation_matches -> calls != [] -> the test failed (reproduced live by two reviewers). Stub _candidate_agy_rpc_ports -> [] too so the test exercises the genuine "no port from either source" branch hermetically. Source is unchanged (it correctly returns None either way). Found by review (gemini + opus). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs(antigravity-native): correct RPC probe request/response shape; note sub-step at-least-once - antigravity_native_rpc.py module header described the GetConversationMetadata probe REQUEST as {"metadata": {"rootConversationId": ...}}, but the code sends {"conversationId": ...} and metadata.rootConversationId is the RESPONSE echo. Correct the header (request flat, response nested). - _post_events: note the at-least-once duplicate is also sub-step -- a step bundles a message + N function_calls, so one item's failed POST re-posts the whole step (re-emitting already-committed siblings) on restart. Found by review (gemini + opus). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs(antigravity-native): RPC core rework design spec Design for reworking the antigravity-native harness runtime onto agy's connect-RPC surface (live-verified): structured trajectory-step reads (GetCascadeTrajectorySteps / StreamAgentStateUpdates) replacing JSONL transcript-tailing, interaction bridging (ask_question + run_command permission via HandleCascadeUserInteraction → omnigent elicitations), and a real interrupt (CancelCascadeSteps). Eliminates the transcript-mirror fragility class (out-of-order cursor, live double-render, user-message duplication) and closes the interactive-prompt gap. Periphery from #892 (onboarding/auth, registration, terminal infra, Docker pin, ap-web picker) is reused; turn-send stays on tmux send-keys pending a user-turn RPC. Wire shapes captured in memory agy-rpc-interaction-bridge.md. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs(antigravity-native): RPC core rework implementation plan 13-task TDD plan for the RPC core rework (per the design spec): a discovery spike (turn-send + read-mode + step-type fixtures), the RPC client (trajectory steps / handle_user_interaction / cancel), a pure step→item mapper (no delta, skips USER_INPUT), the read driver, the interaction bridge with the timeout re-read loop, the server elicitation adapter + hook, real interrupt via CancelCascadeSteps, runner wiring, forwarder cutover, and live parity verification. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * spike(antigravity-native): record RPC step fixtures + turn-send/read-mode decisions Capture live agy 1.0.10 GetCascadeTrajectorySteps fixtures (11 live, 1 synthesized) covering every step type Tasks 4/5 map: USER_INPUT, PLANNER_RESPONSE (text + tool_call ask_question/run_command), RUN_COMMAND WAITING/DONE, ASK_QUESTION WAITING/DONE, plus CONVERSATION_HISTORY/CHECKPOINT/LIST_DIRECTORY; ERROR synthesized from the live WAITING shape (labelled, with _fixtureProvenance). Record decisions with evidence in docs/claude/antigravity-rpc-spike-notes.md: - turn-send: KEEP tmux send-keys (send-keys turn records as USER_INPUT with source USER_EXPLICIT; no user-turn RPC exists; SendAgentMessage mis-records as SYSTEM_MESSAGE). - read-mode: default StreamAgentStateUpdates (first steps frame ~130ms after a turn) with GetCascadeTrajectorySteps poll fallback; request MUST be connect-enveloped (bare JSON => protocol error). Poll-first is an acceptable de-scope. Also live-confirmed: permission + askQuestion answer round-trips (HandleCascadeUserInteraction => 200, step flips DONE); CancelCascadeSteps {cascadeId} => 200 but no-op on a WAITING-for-interaction step (Task 10 must validate cancel against RUNNING steps). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(antigravity-native): RPC client — trajectory steps + cancel Add two unary connect-RPC methods mirroring _conversation_matches: - get_trajectory_steps(port, cascade_id) -> list[dict]: POSTs {"cascadeId": ...} to GetCascadeTrajectorySteps, returns resp["steps"]. - cancel_cascade_steps(port, cascade_id) -> bool: POSTs {"cascadeId": ...} to CancelCascadeSteps, returns True on HTTP < 400, False on error. Both respect _assert_loopback_url + _sync_client(_HTTP_TRANSPORT) so the MockTransport seam covers them in tests. Also adds the two method name constants alongside the existing _METHOD_FORCE_STOP_CASCADE_TREE. TDD: 2 new tests written first (RED: AttributeError), then impl (GREEN). Full file: 47/47 passing, ruff+mypy --strict clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(antigravity-native): address Task 2 review — drop type:ignore, raise_for_status, fail-open test - Remove # type: ignore[arg-type] from test_get_trajectory_steps: narrow seen["body"] with isinstance(body, (bytes, bytearray)) before json.loads, so mypy accepts it without any suppression. - Add response.raise_for_status() in get_trajectory_steps before .json(): non-2xx responses (e.g. HTTP 500 "trajectory not found") may not be JSON, so decoding them would raise JSONDecodeError (undocumented). raise_for_status raises httpx.HTTPStatusError (subclass of httpx.HTTPError) on non-2xx, matching the documented :raises: and catchable at one site by Task 6. Updated docstring to explain the intentional raise (not fail-open) contract. - Add test_cancel_cascade_steps_false_on_transport_error: asserts the primary safety contract (ConnectError → False) that was previously untested. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(antigravity-native): RPC client — handle_user_interaction Add AntigravityRpcError exception class and handle_user_interaction() unary connect-RPC method to the existing antigravity_native_rpc module. Delivers interaction answers (question responses / approvals) to agy by POSTing to HandleCascadeUserInteraction with trajectoryId+stepIndex nested inside interaction (required by proto-JSON encoding). Raises AntigravityRpcError carrying the raw response body on non-2xx so Task 8 can detect the overloaded "input not registered for step N" race string. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(antigravity-native): pure step→item mapper (no delta, skip USER_INPUT) Create omnigent/antigravity_native_steps.py with map_step_to_events() for the RPC-based read path. Fixes two live bugs: drops output_text_delta so the web UI no longer double-renders assistant text, and skips USER_INPUT steps so the user message is not duplicated (already persisted by direct POST /events). Handles CORTEX_STEP_TYPE_* format (camelCase fields, argumentsJson strings) rather than the transcript format. WAITING tool steps emit no output event; DONE steps emit function_call_output keyed via the FIFO allocator. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(antigravity-native): WAITING-interaction extractor Add PendingInteraction TypedDict and pending_interaction() to antigravity_native_steps. Returns None for DONE steps even when requestedInteraction is present (status-keyed, not field-keyed). Extracts trajectory_id via a new _trajectory_id() helper that mirrors _step_index(). 19 new fixture-driven tests; 55 total green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(antigravity-native): surface is_multi_select in pending_interaction spec Add _merge_is_multi_select() helper that reads is_multi_select from metadata.toolCall.argumentsJson and injects it into a fresh copy of the requestedInteraction.askQuestion spec dict per question index. Defaults to False when argumentsJson is absent or malformed; never mutates the input step. 5 new tests (fixture False, synthetic True, absent json, malformed json, no-mutation); 60 total green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(antigravity-native): address Codex review of RPC client — wrap transport errors, guard steps body, add tests CDX-IMP2: Wrap handle_user_interaction's client.post in try/except httpx.HTTPError; re-raise as AntigravityRpcError("transport error contacting agy: {e}") so the Task 8 bridge has one exception type for all delivery failures (transport and non-2xx alike). Non-2xx still raises AntigravityRpcError(response.text) to preserve the body for "input not registered" detection. Add test_handle_user_interaction_raises_rpc_error_on_transport_error. CDX-MIN4: Guard get_trajectory_steps response body against {"steps": null} or non-dict body: use isinstance checks before list() so a malformed 2xx can't raise TypeError. Document that non-JSON 200 raises ValueError (Task 6 driver catches broadly). CDX-MIN5: Add test_get_trajectory_steps_raises_on_500 — pins the non-2xx raises contract (not fail-open, unlike cancel). CDX-MIN6: Broaden cancel_cascade_steps except from httpx.HTTPError to Exception with comment explaining deliberate fail-open intent; covers ssl.SSLError and other errors outside the httpx hierarchy. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(antigravity-native): address Opus/Codex review of step mapper — real tool-call ids, slot-0 index, robustness OPUS-IMP1: use agy's real tool-call ids for function_call/output pairing. plannerResponse.toolCalls[].id on invocation and metadata.toolCall.id on result steps are used directly; _ToolCallIdAllocator is fallback-only when the id field is absent (resume-mid-turn). Out-of-order multi-result regression test verifies FIFO would mis-pair but real-id pairing is correct. CDX-IMP1 + OPUS-MIN1: _step_index accepts string-encoded ints (agy sends some numerics as strings) and treats a missing stepIndex as 0 (proto omits zero-valued scalars) rather than silently dropping the step. OPUS-MIN2 / Task4-M1: modifiedResponse precedence over response is now tested with a synthetic step where the two fields differ; the choice is documented (post-moderation text, present and equal to response in live fixtures). OPUS-MIN3 / Task4-M2: collapse dead double USER_INPUT guard into a single `if step_type == _TYPE_USER_INPUT: return []`. Task4-M3: remove unused _TYPE_CHECKPOINT / _TYPE_CONVERSATION_HISTORY constants (catch-all return [] handles them; keeping them added noise). CDX-MIN3: fix _SOURCE_USER comment ("model-generated" → "user-submitted input"). T5FIX-MIN: collapse redundant `except (json.JSONDecodeError, Exception)` in _merge_is_multi_select to `except Exception`. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(antigravity-native): drop test type:ignore, remove orphaned constant (review follow-up) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * refactor(antigravity-native): simplify RPC client + step mapper (code-simplifier pass) Move _METHOD_HANDLE_CASCADE_USER_INTERACTION to the top-level _METHOD_* constant block where all sibling method constants live, removing the out-of-place inline definition between AntigravityRpcError and handle_user_interaction. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(antigravity-native): RPC read driver Add omnigent/antigravity_native_reader.py: the read-path driver that replaces the transcript-tail forwarder's read loop. It discovers agy's cascade id (from bridge state, past the agy_conv_* placeholder) and connect-RPC port (port-first, conversation-ownership confirmed), then polls GetCascadeTrajectorySteps, maps each new step to Omnigent conversation items (Task 4 mapper), posts them, emits RUNNING/IDLE external_session_status edges on turn transitions (replicating TranscriptParser's stateful heuristic), and hands WAITING steps to the Task 8 interaction bridge via an on_pending_interaction callback. - Dedup by (trajectory_id, step_index) identity in an in-memory seen-set (no durable cursor — retired in Task 12); re-reads post nothing. - One _ToolCallIdAllocator per run; real agy ids keep pairing order-independent. - httpx.HTTPError (transport + non-2xx) and ValueError (non-JSON 200) on a poll are logged and swallowed; the loop never dies on a transient. - Injectable stop predicate bounds the loop under test. TDD: 9 tests (dedup, USER_INPUT-skip, WAITING-once, status transitions, error recovery, placeholder-wait). ruff + mypy --strict clean; no type:ignore / noqa. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(server): antigravity elicitation adapter Add pure shape-mapping adapter that converts a PendingInteraction dict (ask_question or permission) into ElicitationRequestParams for the web UI, and converts the ElicitationResult back into the HandleCascadeUserInteraction payload. Mirrors _codex_elicitation.py's ask_question/permission patterns. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(antigravity-native): interaction bridge with timeout re-read Add omnigent/antigravity_native_interactions.py: the detect→elicit→deliver bridge for the agy RPC harness. It surfaces a WAITING interaction as an Omnigent elicitation, awaits the verdict, and delivers it via HandleCascadeUserInteraction — handling agy's WAITING-interaction timeout gotcha (design §2.1): - re-reads the freshest WAITING step at delivery time (never the captured detection-time ids — agy may have timed the step out and retried at a higher stepIndex while the human deliberated); - on the overloaded HTTP 500 "input not registered for step N", re-reads for a NEW higher-index WAITING step and re-surfaces a fresh elicitation against it (new deterministic id per step_index); - bounds the loop with max_retries so a timeout-retry storm terminates; - returns (no delivery) on a None verdict (human timeout/cancel) and on any non-"input not registered" RPC error. Three async seams (get_steps / request_elicitation / deliver) keep the timeout logic unit-testable without a live agy. deliver defaults to a _deliver_via_rpc wrapper that offloads the sync handle_user_interaction to a worker thread (mirrors the Task 6 read driver), since the bridge is async. TDD: 9 unit tests (happy path, input-not-registered re-read, permission accept, staleness-before-first-delivery, None verdict, no-WAITING-step, non-retryable error, bounded retry storm, deterministic id). ruff + mypy --strict clean; no type: ignore / noqa. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(server): antigravity elicitation hook endpoint Add POST /v1/sessions/{session_id}/hooks/antigravity-elicitation-request — the runner→server bridge for the agy native interaction bridge (Task 8). The bridge POSTs {elicitation_id, params} here; the endpoint parks on the shared harness elicitation registry, emits response.elicitation_request for the web UI, awaits the approval verdict, then returns the raw ElicitationResult JSON (simpler than the codex hook: no JSON-RPC envelope to build — the bridge does that via to_interaction_payload). Timeout returns empty 200 so the bridge reads None and leaves the agy WAITING step to expire on its own. Mirrors the codex-elicitation-request path exactly. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs(antigravity-native): Phase 2 full-RPC-parity spec (turn-send, streaming, usage, model, rotation) All shapes live-verified against agy 1.0.10. Resolves the §7 turn-send open question (SendUserCascadeMessage) and adds streaming-delta / token-usage / model-change / new-conversation-rotation parity with the codex+claude harnesses. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(antigravity-native): RPC client — send_user_cascade_message + model catalog Adds two typed connect-RPC wrappers to antigravity_native_rpc.py (Task T-A): - send_user_cascade_message(port, cascade_id, text, *, plan_model) POSTs the exact verified body shape {cascadeId, items:[{text}], cascadeConfig:{plannerConfig:{planModel}}} to SendUserCascadeMessage, recording USER_INPUT (not SYSTEM_MESSAGE). Raises AntigravityRpcError on transport errors or HTTP >= 400, carrying the raw body so the executor can surface model/validation errors (e.g. "neither PlanModel nor RequestedModel specified"). Mirrors handle_user_interaction. - get_available_models(port) POSTs {} to GetAvailableModels and returns the parsed catalog {models:{<key>:{model, displayName, recommended, ...}}} for runtime model enum resolution. raise_for_status() on non-2xx; returns {} on a non-dict 200 body. Mirrors get_trajectory_steps error contract. TDD: 6 new tests (MockTransport, no live agy); all 58 tests pass. Ruff/mypy --strict clean; no # type: ignore or # noqa anywhere. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(antigravity-native): RPC client — stream_agent_state_updates (connect server-stream) Add the connect-protocol server-stream client for agy's StreamAgentStateUpdates, the live-delta source the T-D streaming reader will consume. Opens a persistent streaming POST, reassembles connect frames from the raw byte stream, and yields each DATA frame's parsed JSON update dict in arrival order, stopping on the end-of-stream trailer. Framing (live-verified, agy 1.0.10; design §10.2): - Request: one connect-enveloped message [0x00][BE-len][{"conversationId"}], Content-Type application/connect+json (via new _encode_connect_envelope). - Response frames [flag][BE-len][payload]: flag 0x00 = data (yielded), flag & 0x02 = trailer (stop), flag & 0x01 = compressed (raise — agy sends uncompressed, so a set bit is a decode mismatch). - Buffer-based reassembly: one chunk is never assumed to be one frame — several frames may pack into a chunk and a frame (incl. its 5-byte header) may straddle chunks; a bytearray holds bytes until a full frame is present. Uses a dedicated _STREAM_TIMEOUT (read=None) so the long-poll is not aborted mid-turn; reuses _assert_loopback_url and the _async_client seam (signature widened to httpx.Timeout | float; docstring refreshed — it now has a live caller). TDD: 7 tests via httpx.MockTransport streaming responses (custom AsyncByteStream with controlled chunk boundaries) cover the request envelope, in-order multi-frame yields, split+packed frame reassembly, header-split reassembly, trailer termination, the compressed-frame raise, and the non-loopback URL refusal. mypy --strict clean; no type/lint suppressions. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(antigravity-native): raise on connect trailer error in stream_agent_state_updates In connect server-streaming a mid-stream server failure is reported in the end-of-stream TRAILER PAYLOAD as {"error": {...}} — NOT via HTTP status, because the 200 + headers were already flushed before the failure. The previous code treated any flag & 0x02 trailer as a clean stop, making an errored stream indistinguishable from clean completion and silently truncating the turn for the T-D streaming consumer. stream_agent_state_updates now parses the trailer payload (new _connect_trailer_error helper, which fails safe toward a clean stop on an empty / non-JSON / non-object / no-error payload) and raises AntigravityRpcError carrying the stringified error when the trailer holds a non-empty error object. Clean trailers (empty payload, {}, or any payload without a truthy error) still return normally — behavior is otherwise identical. The framing layer is the right place for this so T-D gets one failure surface and does not have to inspect trailers itself. Tests (same MockTransport streaming style): an error trailer after data frames yields those frames then raises (asserting the data was delivered in order before the raise); empty-payload and {} trailers are clean stops. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(antigravity-native): reader streaming mode (output_text_delta + poll fallback) Stream-primary read driver: consume StreamAgentStateUpdates for live output_text_delta typing parity, falling back to the committed-only poll loop on any stream error (httpx.HTTPError / AntigravityRpcError trailer). - Per GENERATING PLANNER_RESPONSE frame, prefix-diff plannerResponse.modifiedResponse and emit the new suffix as one external_output_text_delta (stable per-step message_id antigravity:<conv>:<step>:planner, final=False); commit the DONE message via the mapper afterward. Delta-first ordering + stable id satisfies the SPA single-render reconciliation contract. - Dedup committed items by (trajectory_id, step_index), recorded only once a step is SETTLED (DONE/ERROR/USER_INPUT) so a tool-result seen RUNNING before DONE is not deduped early and its output dropped (stream observes every status frame). - Relocate the delta builder out of the soon-retired forwarder into the mapper module as output_text_delta_event + planner_message_id (suffix + configurable final); the reader depends on the mapper, not the forwarder. - Reasoning-stream skipped: no external reasoning-delta POST contract exists; folding thinking into output_text_delta would corrupt the message (see report). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(antigravity-native): gate committed planner message on DONE (no poll-path double-render) The mapper emitted a planner `message` at ANY status (only tool-results were DONE-gated). The poll fallback does not intercept GENERATING (only the stream path does), so a poll catching a planner GENERATING then DONE posted TWO messages for one step — the exact double-render the RPC rework removes, on the fallback path. Gate the PLANNER_RESPONSE committed items (message + function_calls) on status == DONE, symmetric with the existing tool-result gate. A non-DONE (GENERATING) planner now maps to [] — its partial text is conveyed only via the streaming reader's output_text_delta events. Effect: exactly one committed message with the FINAL text on BOTH the stream and poll paths; the stream still emits live deltas, the poll stays committed-only. The _is_settled tool-result dedup fix from the prior commit is retained and now consistent: a planner records `seen` only at DONE (when it produces committed items). All planner fixtures are DONE, so no Task-4 mapper test needed updating. Tests: poll-path regression (generating→done → one message, final text, no deltas); stream-path analog strengthened to assert final committed text. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(antigravity-native): reader telemetry — session usage + model change Implements design §10.3 (external_session_usage) and §10.4 (external_model_change) in the RPC read driver. - _model_usage_from_step: extracts agy string-int modelUsage fields (inputTokens/outputTokens/cacheReadTokens) from PLANNER_RESPONSE DONE steps; maps to cumulative_input_tokens/cumulative_output_tokens/ cumulative_cache_read_input_tokens + model (displayName). - _requested_model_enum_from_step: reads userInput.userConfig.plannerConfig.requestedModel.model from USER_INPUT. - _resolve_display_name: resolves enum→displayName via GetAvailableModels catalog; falls back to raw enum when unknown. - _ensure_catalog: fetches and caches the model catalog once per reader run (asyncio.to_thread); logs + returns {} on failure (best-effort). - _maybe_emit_session_usage / _maybe_emit_model_change: fired inside the key-not-in-seen branch of _process_committed_step so replay of already-seen steps never re-emits. Model-change deduped by state.posted_model_enum (raw enum, not displayName). - _ReaderState extended with posted_model_enum, model_catalog, port. - 7 new tests cover: usage emission + field mapping, usage replay dedup, missing-usage graceful skip, first-turn model-change, same-model no-re-emit, model switch mid-session, model replay dedup, unknown enum fallback. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(antigravity-native): emit running cumulative session usage (SET-semantics) The server prices per-turn cost as delta = (new cumulative) - (old cumulative). Emitting agy's per-model-call inputTokens/outputTokens directly caused the server to compute a zero delta on turn 2+ (since each turn's per-call value was the same), freezing the cost badge after turn 1. Fix: accumulate per-call modelUsage values in _ReaderState and emit the running totals, matching codex's tokenUsage.total (cumulative, SET semantics). Also: - Thread the real step_index through to OutboundEvent for both usage and model-change events (was hardcoded to 0). - Add _ReaderState.cumulative_* reset comment for T-G /clear rotation. - Add test_two_turn_usage_is_cumulative regression guard: two turns of 1000 input tokens → turn 1 posts 1000, turn 2 posts 2000 (not 1000 again). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(antigravity-native): RPC-driven executor — real interrupt + RPC turn-send Make AntigravityNativeExecutor fully RPC-driven, retiring the tmux send-keys write path (Task 10 + Task T-B): - interrupt_session: resolve cascade id (= conversation id) from bridge state, discover the connect-RPC port, and call CancelCascadeSteps. Documents the live-verified limitation (C3): cancel stops a RUNNING cascade and is a NO-OP on a WAITING-for-interaction step (a DENY via the interaction bridge unblocks that). Returns False on placeholder / no port / cancel failure. - run_turn + _deliver: deliver turns via SendUserCascadeMessage instead of send-keys. Per-turn planModel is resolved at runtime (two-tier, design §10.4): echo the latest USER_INPUT step's requestedModel.model, else fall back to the recommended GetAvailableModels entry. ExecutorConfig.model/effort stay informational (agy owns model selection on this write path). - First turn (Option A, pure RPC): on the agy_conv_* placeholder, wait for the runner to mint the real id (Task 11), then send; surface a clear "not ready" ExecutorError if it never lands rather than typing into the TUI to mint it. - AntigravityRpcError from the turn-send is surfaced (carrying agy's message), not swallowed. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(antigravity-native): RPC conversation cold-start bootstrap (StartCascade) The runner now mints the agy conversation over connect-RPC on a fresh host-spawned launch (StartCascade) instead of seeding only an agy_conv_* placeholder, so the executor's turn-1 has a real cascade_id. The existing supervise_forwarder spawn is kept (Task 11b swaps it for the reader) and now binds the cold-started conversation directly. - antigravity_native_rpc.start_cascade(port, cascade_id, *, source): POSTs {cascadeId, source} to StartCascade; 200 -> None, non-2xx/transport -> AntigravityRpcError (mirrors send_user_cascade_message). - runner.app._cold_start_agy_conversation: polls the Heartbeat-OK connect-RPC port (bounded), StartCascades a runner-minted uuid4, and overwrites bridge state's conversation_id with the real id via update_conversation_id. Best-effort/non-raising so a failure leaves the placeholder for the forwarder and never aborts the launch. Wired into _auto_create_antigravity_terminal on fresh (not resume) launches, after the terminal starts and before the forwarder. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(antigravity-native): runner wires RPC streaming reader + interaction bridge Swap the antigravity auto-create's transcript-forwarder spawn for the RPC streaming reader (supervise_reader, T-D) and wire its on_pending_interaction to the Task 8 interaction bridge via the Task 9 elicitation hook, making the full RPC chain live (cold-start 11a -> reader T-D -> bridge Task 8 -> hook Task 9 -> executor Task 10/T-B). 11a's cold-start is untouched; the reader replaces the forwarder only and reuses the same single-instance per-session task registry. - Widen OnPendingInteraction to (cascade_id, port, pending) so the bridge gets the SAME ids the reader discovered (no re-discovery race); thread them through the single delivery point in _process_committed_step. - Add production elicitation glue in app.py (_post_agy_elicitation_request, _request_agy_elicitation) mirroring codex's long-poll re-POST + body handling, and _run_antigravity_reader which owns the client and runs supervise_reader with the bridge-wired callback. - Tests: reader callbacks updated to the new contract (poll + stream paths assert cascade_id/port threading); auto-create harness stubs the reader; new end-to-end wiring test (pending -> hook POST {elicitation_id, params} -> handle_user_interaction delivery; task named antigravity-reader-{session_id}). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * refactor(antigravity-native): retire transcript forwarder + durable cursor (RPC reader supersedes) The RPC streaming reader (Task 11) replaced the transcript-tail forwarder on the runner path; this completes the full cutover (Option A) by migrating the last forwarder consumer — the CLI ``omnigent antigravity`` attach fallback — to the reader + interaction bridge, then deleting the forwarder and its now-dead durable read cursor. - Extract a shared ``run_reader_with_bridge`` helper into ``antigravity_native_reader`` (Omnigent client + elicitation POST/retry + ``on_pending``→``bridge_interaction`` + ``supervise_reader`` spawn). The runner's ``_run_antigravity_reader`` and the CLI ``_attach_terminal`` both call it; the elicitation machinery moves out of ``runner/app.py``. - CLI ``_attach_terminal`` (non-reattached fallback only) now spawns the reader + a one-shot cold-start as background tasks at attach-start (cancelled in ``finally``), mirroring the runner. agy is started on attach (``tmux_start_on_attach=True``), so cold-start + reader run concurrently with the attach and poll agy in; the post-hoc ``audit_policies`` path is dropped in favor of real-time elicitation. The fallback TUI shows the empty ``>`` banner because the cold-started RPC conversation is headless (documented). - Both cold-starts (CLI + runner) now PATCH the cold-started cascade id onto the session as ``external_session_id`` (best-effort, mirroring codex/pi) so a later ``--resume`` continues agy's actual conversation — the read-path replacement for the forwarder's ``_patch_external_session_id``. The CLI cold-start is guarded to run only on a placeholder id (skipped on resume), so ``--resume`` is not clobbered by a fresh ``StartCascade``. - Drop the durable read cursor (``forwarded_steps`` / ``forwarded_step_index`` / ``update_forwarded_*``) from bridge state and both launch paths; the reader uses an in-memory seen-set. Legacy on-disk cursor keys are tolerated and ignored. - Delete ``antigravity_native_forwarder`` + its test; sweep forwarder-era docstrings across the rpc/launch/reader/runner/CLI/audit/post-delivery modules. Behavior-preserving for the surviving paths (runner reader + CLI reattach); the existing suites passing is the proof. The relocated shared types (``OutboundEvent`` / ``_ToolCallIdAllocator`` / ``_AGENT_NAME`` / ``_TOOL_ARG_DISPLAY_KEYS``, now canonical in ``antigravity_native_steps``) are included here. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(antigravity-native): harden external_session_id cold-start PATCH against silent rejection (CLI+runner) Follow-up to the decision-2=(b) external_session_id PATCH (landed in the preceding commit): the best-effort PATCH only caught a transport ``httpx.HTTPError`` and ignored 4xx/5xx *responses* (httpx does not raise on those), so a server-side rejection — and the lost ``--resume`` continuity it implies — was silently swallowed on BOTH the CLI fallback and runner paths. - Inspect ``status_code`` after the PATCH and log a warning on ``>= 400`` on both ``_cold_start_agy_conversation`` (CLI) and ``_patch_agy_external_session_id`` (runner), mirroring the codex recorder PATCH. Still strictly best-effort: a rejection (or transport error) never raises, and the cascade id is already in bridge state so the chat mirror is unaffected; only resume fidelity degrades. - Add focused coverage for the runner best-effort helper (None-client no-op, transport-error swallow, 4xx-rejection warning) and a CLI 4xx-rejection test. - Fix a stale "resets the resume cursor" comment on the runner cold-start (the durable cursor was removed in the cutover) and remove a pre-existing ``type: ignore[arg-type]`` in the CLI test's ``_mock_client`` by typing the handler as ``Callable[[httpx.Request], httpx.Response]``. The placeholder/resume guard that makes ``--resume`` continue agy's prior conversation (skip cold-start + PATCH on a non-placeholder id) is intact on both paths and covered by tests. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test(antigravity-native): cover legacy durable-cursor key tolerance on bridge read Addresses the Task 12 review's minor finding: the cutover removed the forwarded_step_index / forwarded_steps durable-cursor fields, and read_bridge_state must tolerate (ignore) them in a forwarder-era state.json. Extends the legacy-fields test to carry both cursor keys and asserts they are absent from the parsed dataclass. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * refactor(antigravity-native): code-simplifier pass (readability, behavior-preserving) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(antigravity-native): address 3-way review — functional-RPC timeout, IDLE-on-DONE gate, stream re-entry backoff, runner cold-start guard Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(antigravity-native): run interaction bridge off the reader loop with single-in-flight guard 3-way review (codex+gemini, with a repro) found the reader loop blocked for the full duration of a human interaction: _maybe_handle_interaction awaited the elicitation long-poll (up to ~24h) inline, freezing streaming/tool-output/status and risking stream severance. The naive create_task fix the reviewers proposed would double-fire on agy's WAITING-timeout retry steps (it re-issues at a higher step_index), so this adds a single-in-flight guard: the bridge runs off-loop as a tracked _ReaderState.interaction_task; while one is active the loop skips spawning another (the in-flight bridge owns the retries via its own freshest-WAITING re-read); a done-callback clears the slot; supervise_reader cancels it on teardown. Tests: streaming continues while an interaction is pending (gemini's repro), single-in-flight guard suppresses a retry-step double-fire, done-callback clears the slot for a later interaction, and reader teardown cancels the in-flight task. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(antigravity-native): scope cold-start to the session's agy pid (avoid wrong-agy cross-bind) The cold-start picked candidates[0] (the lowest Heartbeat-answering agy connect-RPC port). On a host running several agy instances under one runner (sub-agent fan-out, shared runner, `omnigent run --server` multi-session) this could StartCascade onto a FOREIGN agy and permanently bind the session to the wrong conversation, since no conversation exists yet to disambiguate. Scope the cold-start port to THIS session's own agy via its tmux pane: pane -> pane pid -> agy pid in the pane's process subtree -> that pid's connect-RPC port. agy is the pane process on the simple `exec agy` launch and a descendant (sandbox launcher -> bwrap -> agy) on a sandboxed launch, so the resolver checks the pane pid itself then walks descendants intersected with the live agy pids. Falls back to the existing candidate scan when no local pane is reachable (remote runner) or the pane cannot be resolved, so single-agy hosts and remote runners are unaffected; the fallback is logged. Both cold-starts (runner + CLI) are threaded the pane and share the new resolve_cold_start_agy_rpc_port helper. Placeholder/resume guards, the port-bind timeout/poll loop, and the external_session_id PATCH are preserved. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(antigravity-native): surface agy reasoning/thinking stream (parity) Gemini Thinking-model variants stream chain-of-thought at plannerResponse.thinking (design 10.2), which the RPC reader and step mapper never read — so reasoning was dropped, a parity gap vs the in-process antigravity executor (which emits the same reasoning SSE pair). Reader: mirror the modifiedResponse text-delta path for thinking — a new per-step reasoning prefix tracker on _ReaderState, _partial_planner_thinking extractor, and _emit_partial_reasoning_delta (prefix-diff suffix per GENERATING frame, started=True only on a step's first delta). Reasoning is emitted BEFORE the response delta (10.2 ordering) and the tracker is cleared on commit alongside the text tracker. A planner with no thinking emits nothing (no regression to text streaming). Steps mapper: output_reasoning_delta_event builder for the transient external_output_reasoning_delta event. Reasoning is delta-only — the mapper commits NO reasoning item (matching codex/claude/the in-process executor, none of which commit reasoning content); the SPA finalizes the reasoning block when the assistant message arrives. Server: external_output_reasoning_delta external event type publishes response.reasoning.started (once, when data.started) + response.reasoning_text.delta SSE — the events the SPA already maps (sse.ts) and renders (blockStream.ts). The reasoning-content wire bridge did not exist for native harnesses; only text (external_output_text_delta) and effort (external_reasoning_effort_change) did. Nothing is persisted. Tests: reader streaming (incremental reasoning deltas with started-once, reasoning-before-text ordering, no-thinking no-regression, no-growth dedup); mapper builder shape + no committed reasoning item on DONE-with-thinking; server route (started publishes both SSE, continuation publishes delta only, malformed delta rejected). No suppressions. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(antigravity-native): cold-start keeps polling when the session's agy isn't up yet (no foreign-agy fallback) R2 review found a residual cross-bind on the CLI path. CLI terminals use `tmux_start_on_attach=True`, so the pane runs `tmux wait-for; exec agy` and agy is only exec'd when the human attaches — but the cold-start polls CONCURRENTLY with the attach. During that early-poll window the pane is just the shell, so the pane resolver found no agy and returned None, and `resolve_cold_start_agy_rpc_port` fell through to `_candidate_agy_rpc_ports()[0]`. If a foreign agy was the only candidate, StartCascade bound this session into the FOREIGN agy — the exact durable cross-bind the scoping targets. Fix: distinguish THREE pane states via a new `PaneAgyResolution` (`resolve_pane_agy_rpc_port_state`): 1. agy found + port resolved -> scoped port. 2. agy found + port unattributable -> candidate fallback (restricted /proc; one-agy-per-pod, so the lone candidate is ours — preserves k8s behavior). 3. NO agy found yet -> return None, keep polling (do NOT touch candidates — a foreign agy could be the only one). No pane supplied (remote runner) still falls back to candidates. Also: only thread the pane into the CLI cold-start when the tmux socket exists LOCALLY (mirror `_can_attach_direct_tmux`), so a remote runner's server-side socket path doesn't trigger ~80 doomed `tmux display-message` spawns per poll and correctly routes to the no-pane -> candidate path. `resolve_pane_agy_rpc_port` is retained as a thin port-only wrapper. Bounded deadline/poll loop, placeholder/resume guard, and external_session_id PATCH unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(antigravity-native): guard multi-question askQuestion + detect stale /clear-rotated conversation Three R4 edge-guard fixes from the 3-way review. Fix A — multi-question askQuestion no longer broadcasts one answer to all. agy's askQuestion can carry several questions[i] (each with its own option ids + is_multi_select), and the agy wire wants one response entry PER question. But ElicitationResult.content is flat (one selectedOptionIds / writeInResponse, no per-question key), so the SPA can only collect a single answer end-to-end. The prior code broadcast that single answer to EVERY question — semantically wrong. Now we answer ONLY the first question and leave the rest to agy, logging the limitation. Single-question (the dominant, working case) is unchanged. Full per-question support needs a schema + SPA-form change and is flagged as a follow-up. Fix B — detect a TUI /clear that rotates the bound conversation. On the CLI-fallback path, a human running /clear in the agy TUI mints a NEW cascade id; the reader bound the old one at discovery and would keep mirroring the now-dead conversation silently. Each stream frame names the active conversation (update.conversationId, design §10.5); the reader now compares it to the bound cascade id and, on a mismatch, logs a clear warning and stops mirroring rather than failing silently. Absent/empty/ matching conversationId is not a rotation (false-positive-free on the normal path). Full automatic re-bind + Omnigent session rotation (T-G) is flagged as a follow-up; for the headless runner path it is obviated by the 1:1 design. Fix C — docstring nit (doc-only). output_reasoning_delta_event no longer claims it "matches the in-process executor (same SSE pair)"; the in-process antigravity executor emits only reasoning_text deltas and relies on an IMPLICIT reasoning-start, whereas this path emits an EXPLICIT response.reasoning.started. Both end with no committed reasoning item. Tests: multi-question answers only the first + does not broadcast + logs (single-question stays silent); a rotated conversationId stops+warns and does not mirror the dead step, while matching/absent ids do not false-fire. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs(antigravity-native): hedge /clear-rotation guard field path as unverified (R4 review) R4 review found Fix B's premise — that StreamAgentStateUpdates frames carry ``conversationId`` at the frame top level (design §10.5) — is UNVERIFIED and contradicted by the evidence: real stream captures show steps frames only as ``update.mainTrajectoryUpdate.stepsUpdate.steps[]``, and the only live-verified conversation-id echo is NESTED (``metadata.rootConversationId`` from GetConversationMetadata). §10.5 is planning intent (rotation tagged unimplemented follow-up T-G), and the reader test is self-referential (hand-sets the field). The control flow is correct (the early ``return`` is terminal — it does NOT fall through to the guard-less poll loop), and the field-path FIX needs a live capture that can only be taken during Task 13 (live-e2e). So this commit makes the code honest rather than guessing: docstrings/comments now flag the top-level field path as a design ASSUMPTION pending a Task 13 live ``/clear`` capture (dump the raw post-rotation frame; if the id is nested, fix ``_frame_conversation_id`` and swap the hand-built helper for a captured fixture). Also notes the two-axis uncertainty (field location + whether a foreign frame ever reaches this stream — §10.5 names GetAllCascadeTrajectories as the PRIMARY signal; this per-frame check is only the secondary one). Doc/comment-only; no behavior change. Fix A (multi-question guard) and Fix C (reasoning docstring) reviewed correct and unchanged. 43 reader tests pass. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * refactor(antigravity-native): code-simplifier pass (readability, behavior-preserving) Behavior-preserving readability cleanup over the antigravity-native RPC rework. No logic, signature, or control-flow changes; all gates green (ruff/mypy/pytest). - antigravity_native.py: R5 docstring consolidation. Folded the scattered historical references to retired mechanisms (transcript-tail forwarder, durable resume cursor, tmux send-keys) into one concise, accurate preamble at the top of the module docstring. Trimmed the now-redundant repetitions in the read/write bullet, the _launch_and_record docstring + inline comment, and the _attach_terminal note, while keeping the locally load-bearing facts (the dropped pre-tool audit / no refresh-capable reader auth, and the _patch_external_session_id "replacement for the retired forwarder's id capture" notes). - antigravity_native_rpc.py: extracted the byte-identical POST+raise tail shared by handle_user_interaction, send_user_cascade_message, and start_cascade into a private _post_rpc_raising(port, method, body) helper. Removes ~33 lines of duplication; each caller now just builds its body and delegates. Identical wire behavior (URL, headers, JSON body, transport-error wrapping, raw-body raise on >=400). - antigravity_native_steps.py: extracted the repeated metadata.sourceTrajectoryStepInfo navigation shared by _step_index and _trajectory_id into a private _source_traj_info(step) accessor. - antigravity_native_reader.py, antigravity_native_interactions.py, inner/antigravity_native_executor.py, server/routes/_antigravity_elicitation.py: unchanged — reviewed, no redundancy worth removing without behavior/clarity risk (and the reader's /clear-rotation honesty hedges are deliberately preserved). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(antigravity-native): 3-way re-review fixes — USER_INPUT dedup, reasoning re-anchor, stream guards, observability I-1 (ship-blocker): antigravity_native_steps.py + antigravity_native_reader.py — USER_INPUT dedup-key collision. USER_INPUT steps have a per-conversation-stable trajectory_id and no stepIndex, so every turn's USER_INPUT collided on (trajectory_id, None) and was silently de-duped after turn 1 (no per-turn RUNNING/IDLE status edge, no model-change). Added _execution_discriminator (executionId/createdAt) and widened _StepKey to a 3-tuple, folding the discriminator in only for steps that lack a stepIndex. Steps WITH a stepIndex key as (traj, idx, None) — unchanged dedup for seen/interacted (interaction and content steps always carry a stepIndex). Test now uses real per-turn executionId (no synthetic stepIndex): test_two_real_wire_turns_each_emit_running_then_idle + test_step_key_distinct_for_user_input_turns_without_step_index + TestExecutionDiscriminator. A (important): antigravity_native_reader.py — _emit_partial_reasoning_delta re-anchored reasoning_prefixes[idx] only inside the growth branch, so a non-monotonic thinking rewrite froze reasoning deltas permanently. Moved the re-anchor out of the if (mirrors the text path). Test: test_stream_reasoning_reanchors_after_non_monotonic_rewrite. B (important): antigravity_native_rpc.py — stream_agent_state_updates wrapped the DATA-frame json.loads; a malformed frame raised a bare JSONDecodeError that the supervisor does not catch (reader died silently, no poll-fallback). Now raises AntigravityRpcError. Test: test_stream_agent_state_updates_raises_on_malformed_json_frame. C (important): antigravity_native_bridge.py — update_conversation_id now returns bool and logs a WARNING (naming the dropped id) on a None state read instead of silently dropping the real cascade id. Both cold-start callers (antigravity_native.py, runner/app.py) check the result and warn on False. Test: test_update_conversation_id_returns_false_and_warns_when_no_state. D (minor): antigravity_native_rpc.py — stream_agent_state_updates now checks response.status_code >= 400 right after the stream opens (httpx stream() does not raise on non-2xx; an unframed error body looked like a clean empty stream and reconnected forever). Used the explicit status_code form to avoid httpx streaming-body read issues. Routes into the reader's poll-fallback. Test: test_stream_agent_state_updates_raises_on_non_2xx_status. E (minor): antigravity_native_interactions.py — _freshest_waiting dropped the cross-kind any_kind fallback; it now returns strictly same-kind (or None), since agy keys delivery on trajectoryId+stepIndex with no kind check. Tests: test_freshest_waiting_returns_none_for_only_different_kind + test_freshest_waiting_returns_highest_same_kind. F (minor): antigravity_native_interactions.py + antigravity_native_reader.py — reworded the bridge's no-verdict log so it no longer claims timeout/cancel exclusively (hook rejection also yields None); enriched the reader's elicitation 4xx WARNING to flag a likely misconfigured hook. Log wording only. G (minor): antigravity_native_interactions.py — the "input not registered" race discriminator is now matched case-insensitively (str(exc).lower()), so a capitalization change in agy's 500 body cannot reclassify the retryable race as fatal and drop the human's verdict. Test: test_input_not_registered_match_is_case_insensitive. Gates: ruff clean; mypy unchanged at 29 pre-existing baseline errors (0 new); 587 tests pass across the antigravity-native suite. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(antigravity-native): correct GetAvailableModels/USER_INPUT-model/stream-frame wire envelopes (live e2e) + real-wire fixtures A live e2e against agy 1.0.10 proved the branch's three RPC wire envelopes were wrong; the prior synthetic fixtures encoded the wrong shapes, so the tests passed while the real wire failed every turn. Captured the real wire and corrected both the code and the fixtures. BUG 1 (FATAL — model resolution failed every turn): GetAvailableModels returns {"response": {"models": ...}}, not {"models": ...} at the top level. get_available_models now unwraps body["response"] (falling back to the body itself defensively, {} for a non-dict), so both consumers (_recommended_model, _resolve_display_name) read catalog["models"] again. The get_available_models test now mocks {"response": {...}} and asserts the unwrapped catalog; consumer tests already used the post-unwrap shape. BUG 2 (FATAL — tier-1 model echo always None): the live USER_INPUT step carries plannerConfig.planModel as a STRING (the same field send_user_cascade_message sends), not requestedModel.model (a dict). Executor _latest_requested_model and reader _requested_model_enum_from_step now read planModel first and fall back to requestedModel.model for any TUI-origin step using the old shape. Fixtures relocated requestedModel -> planModel (steps/user_input.json; reader helpers _user_input_with_model / _user_input_real_wire; executor helper _steps_with_model); model-change and echo tests keep the same expected enums. Added one focused fallback test on each side (reader + executor) to keep the requestedModel.model path covered. BUG 3 (CRITICAL — stream mirrored nothing): each StreamAgentStateUpdates DATA frame is a connect envelope {"update": {...}}; the reader read mainTrajectoryUpdate/conversationId at the top level, so every frame yielded 0 steps and the stream-primary reader mirrored nothing (a 0-step frame does not raise, so poll-fallback never fired). The generator now unwraps parsed["update"] (falling back to the parsed dict defensively) before yielding, so the reader's _frame_steps/_frame_conversation_id work unchanged. The rpc-stream tests now build {"update": {...}} frames (via _data_frame) and assert the generator yields the unwrapped payload; a new test covers the no-envelope defensive fallback. Reader tests feed logical (post-unwrap) frames and are unchanged. All three fixes verified against the captured agy 1.0.10 wire. The Fix B /clear rotation guard is intentionally untouched (a separate follow-up replaces it). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(antigravity-native): real /clear rotation via GetAllCascadeTrajectories (T-G), replacing the dead per-frame guard The R4 per-frame /clear guard was a proven no-op: a StreamAgentStateUpdates stream is bound to ONE cascade and only ever reports THAT cascade's id, so a per-frame "did the conversation change?" check can never observe a sibling conversation. This replaces it with real, out-of-band rotation detection + automatic Omnigent session rotation, mirroring the codex forwarder. STEP 1 (RPC primitive). antigravity_native_rpc.get_all_cascade_trajectories: POSTs {} to GetAllCascadeTrajectories, raise_for_status (NOT fail-open, like get_trajectory_steps/get_available_models), returns the parsed body (the trajectorySummaries map). Documented with the live-verified shape. STEP 2 (pure detection). antigravity_native_reader._detect_rotated_cascade: selects the newest-active ROOT cascade (trajectoryType CORTEX_TRAJECTORY_TYPE_- CASCADE) by lastUserInputTime (falling back to lastModifiedTime), parsing ISO- 8601 robustly (trailing Z -> UTC). Rotates only when the current cascade differs from the bound one AND is strictly newer than the bound entry's own activity; returns None when the bound entry is absent (never rotate blindly), when the newer entry is a bare /clear mint (no activity timestamps yet), or for a non-CASCADE (subagent) sibling. STEP 3 (session rotation). _rotate_session_for_cascade mirrors codex's _create_thread_replacement_session API sequence: GET old snapshot -> POST /v1/sessions (old agent_id + INHERITED labels, so the new session resolves to the SAME bridge_dir; agy's bridge_dir is keyed off the launcher bridge-id, not the session id) -> PATCH runner_id -> PATCH external_session_id=new cascade -> POST terminal /transfer -> write_bridge_state(new session+cascade) -> PATCH old runner_id="". Best-effort: any failure logs a WARNING and returns None (the reader keeps the old binding). Bridge state is rewritten only after the new session is created+bound, so a mid-sequence failure never points it at a half-created session. STEP 4 (wire-up). supervise_reader spawns a _watch_for_rotation background task that polls GetAllCascadeTrajectories every few seconds (the stream cannot see a sibling); on detection it flips the body's stop and supervise_reader returns the new cascade id. run_reader_with_bridge now LOOPS: bind -> supervise -> on a returned cascade id, _rotate_session_for_cascade -> rebind (re-enter supervise, which rediscovers from the rewritten bridge state with a fresh _ReaderState). A failed rotation keeps the old binding and adds the cascade to skip_cascade_ids so it never hot-loops detect->fail->detect. The elicitation hook reads the current session id through a holder so a post-rotation interaction targets the new session. Existing teardown (interaction-task cancel in finally) is preserved and now also cancels the rotation detector. STEP 5 (cleanup). Removed the dead per-frame guard (_frame_names_other_- conversation, _frame_conversation_id, the rotation check + R4 honesty-hedge comments in _stream_loop) and the reader test helper _frame_with_conversation + the two /clear-rotation reader tests it backed. Updated stale comments/docstrings that referenced the dead guard or the unverified top-level conversationId field path (superseded by T-G). Tests: get_all_cascade_trajectories (returns/non-dict/500); _detect_rotated_- cascade (newer sibling, minted-unused, only-bound, older, non-cascade, bound- absent, lastModifiedTime fallback, equal-activity, malformed ts, real capture); supervise_reader returns the new cascade on rotation + honours skip_cascade_ids; _rotate_session_for_cascade exact codex API sequence + bridge-state write + None on create failure; run_reader_with_bridge rebind loop (advances session id) + keeps-old-binding-on-failure. mypy: 29 pre-existing, 0 new. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(antigravity-native): actuate /clear rotation by cancelling the wedged stream (T-G deadlock) The Task T-G /clear-rotation reader DETECTED a rotation but never ACTUATED it. `supervise_reader` ran the rotation detector concurrently with the reader body, but `await`ed the body DIRECTLY (`_stream_loop`, falling back to `_poll_loop`). When the detector fired it set `rotation_holder` and flipped `_body_should_stop()` to True — but that stop is only re-checked at `_stream_loop`'s outer `while` and after its inner `async for`. After a TUI /clear the bound cascade goes IDLE and the connect stream blocks forever inside `aiter_bytes()` (the idle long-poll uses a deliberately deadline-less read), so neither checkpoint is reached: `_stream_loop` never returns, the `finally` never runs, `supervise_reader` never returns, and `run_reader_with_bridge` never calls `_rotate_session_for_cascade`. No replacement session, no terminal transfer, no rebind — web turns kept targeting the dead conversation. Found by a live e2e. Fix: run the reader body as a cancellable task (`antigravity-reader-body`) and have the rotation callback cancel it in addition to recording the new cascade id. Cancellation raises CancelledError inside `aiter_bytes()`, which unwinds `stream_agent_state_updates`' `async with` cleanly (httpx supports cancellation) where a cooperative stop re-check cannot run. The body task is created BEFORE the detector starts (referenced via a holder) so the callback can never fire before the task exists. `await body_task` distinguishes a ROTATION cancel (rotation_holder set → fall through and return the new id) from an EXTERNAL shutdown cancel (rotation_holder empty → re-raise so it propagates, never a phantom rotation). The existing finally still cancels the rotation + interaction tasks in the documented order, and now also finalizes the body task on every exit path so nothing leaks. Neither `_stream_loop` nor the generator catches CancelledError (their excepts cover only httpx.HTTPError / AntigravityRpcError), so the cancel is not swallowed. Adds a regression test that wedges the stream on a never-firing event (the live /clear-then-idle shape) with the detector reporting a rotation, and asserts `supervise_reader` RETURNS the new cascade id under a tight `wait_for` budget (a regression times out loudly instead of hanging the suite); plus a test that an external cancel of a wedged reader propagates CancelledError rather than being mistaken for a rotation. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(antigravity-native): suppress runner turn-lifecycle idle (live-e2e double-idle) Live e2e found every web turn emitted a premature response.completed (0 items) + session.status idle at ~0.3s, THEN the real reasoning/text/usage ~1.8s later against the already-completed response (spinner stops, then text appears). Root cause: the runner's `_publish_turn_status` (runner/app.py) suppresses the turn-lifecycle session.status edge for terminal-backed harnesses whose status is owned by a native observer — claude/pi/cursor-native suppress BOTH running+idle, codex-native suppresses idle (its injection task returns before the model turn). antigravity-native was in NEITHER set, so its turn-lifecycle running+idle leaked alongside the RPC reader's own edges. The executor's SendUserCascadeMessage returns the instant agy accepts the turn, so the runner's idle fires ~2s before agy streams output; the server derives response.completed from that idle, hence the empty premature completion. Fix: antigravity-native shares codex's shape — add it to the codex-native idle suppression (publish `running` for immediate accept feedback; the RPC read driver owns the accurate `idle` once agy's output completes). The server then keeps the response in_progress until the reader's real idle, so output streams into the live response instead of after a phantom completion. Tests: parametrized test_message_turn_lifecycle_status_suppressed_for_terminal_backed_harnesses now covers antigravity-native (expected ["running"], no idle). 610 antigravity-surface tests pass; mypy unchanged at the 29-error pre-existing baseline. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(antigravity-native): /clear rotation at claude parity (transfer existing agy, no external_session_id, no auto-cold-start loop) A live e2e proved the prior T-G /clear rotation infinite-loops, spawning ~1 orphan agy + session every 3-5s. Root cause: the rotation POSTed a new session AND PATCHed its external_session_id=new_cascade. But POST /v1/sessions for an antigravity-native session makes the runner auto-cold-start a brand-new agy (_auto_create_antigravity_terminal fired for EVERY such session), which minted its OWN cascade AND set the new session's external_session_id. The rotation's external_session_id PATCH then hit that already-set, set-once-immutable field -> 400 -> rotation aborted; but the cold-start had already rebound the reader to its fresh cascade -> the detector re-fired -> infinite session-spawn loop. This mirrors claude's _create_clear_replacement_session, which already does /clear rotation correctly. agy, like claude, is ONE long-lived process hosting many cascades; a /clear mints a new cascade on the SAME process, so the replacement TRANSFERS the existing terminal (it does NOT re-spawn) and rewrites bridge state so the reader rebinds to the new cascade on the same process. Two changes, both copied from claude: 1. _rotate_session_for_cascade (antigravity_native_reader.py): drop the external_session_id PATCH entirely (claude never does it — the new cascade is already live on the existing agy, reached via the rewritten bridge state, not via a later --resume). New sequence: GET old snapshot -> POST /v1/sessions (agent_id + inherited bridge-id label) -> PATCH runner_id -> terminal /transfer old->new -> write_bridge_state(session_id=new, conversation_id=Y) -> clear old runner_id. The bridge-state write lands AFTER the transfer, so the runner's auto-create guard (below) still sees the OLD session owning the terminal while the new session binds. 2. The auto-cold-start-avoidance mechanism, replicated exactly from claude: claude gates _auto_create_claude_terminal on _terminal_inbound, computed by _claude_native_terminal_arrives_via_transfer — it reads the shared bridge's active session and returns True when a DIFFERENT session on the same bridge owns a live terminal (the one about to transfer in), so auto-create skips. It's race-free because the rotation writes the new active-session marker only AFTER the transfer, so at bind time the bridge still names the old terminal-owning session. Added the antigravity mirror _antigravity_native_terminal_arrives_via_transfer (reads read_bridge_state().session_id against the antigravity:main terminal) and wired the antigravity branch with the same _antigravity_inbound gate + "rotation target" skip log. After a successful rotation the reader is bound to Y; GetAllCascadeTrajectories shows Y as the most-recently-active root cascade == bound, so _detect_rotated_cascade returns None and the detector does not re-fire. Tests: rewrote the rotation sequence test to assert the claude sequence and that NO external_session_id PATCH is made; added a parametrized runner guard test (mirroring the claude one) proving an antigravity rotation-target session does NOT trigger _auto_create_antigravity_terminal while fresh/dead-terminal sessions still do. Verified the guard is load-bearing (neutering it reds the rotation-target case). Found by live e2e. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs(antigravity-native): record T-D poll-path double-render follow-up (2960b9b2) in SDD report Accurate SDD report update documenting the earlier poll-path double-render fix (commit 2960b9b2): map_step_to_events now DONE-gates PLANNER_RESPONSE committed items symmetrically with the tool-result gate, so both stream and poll paths post exactly one final message. Left unstaged across the session; committed now to finish with a clean working tree. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs(antigravity-native): document /clear-before-first-turn rationale in _detect_rotated_cascade Behavior-identical comment clarification. The bound_activity-is-None branch (rotate to any active sibling) is INTENTIONAL: it handles the /clear-before-first-turn case (a freshly-bound cascade that never took a turn, then a sibling the user actually used) — staying bound there would strand the reader on the dead pre-/clear cascade. A final-review pass proposed "hardening" this to stay-bound; that would regress this reachable case, so the comment now records why the branch exists. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(antigravity-native): close every tool call in the step mapper (P0 #2) The RPC step mapper emitted a `function_call` for every entry in `plannerResponse.toolCalls` unconditionally, but only emitted a paired `function_call_output` for three result types (RUN_COMMAND / LIST_DIRECTORY / ASK_QUESTION) at DONE with non-empty text. Three common paths therefore left a permanently-dangling `function_call` (the reader is the sole completion signal and the server pairs strictly by call_id, so an unpaired call renders a perpetual in-progress tool card): (a) result types with no extractor (VIEW_FILE / CODE_ACTION, live on agy 1.0.10) fell through to `return []`; (b) terminal-ERROR tool steps (e.g. an ignored/timed-out interactive prompt that flips WAITING->ERROR) returned []; (c) a successful RUN_COMMAND whose `combinedOutput.full` is proto3- omitted (cd / mkdir / redirects) returned []. Fix: treat a step as a tool result when it is a known type OR carries a `metadata.toolCall.id`, and on a terminal status (DONE/ERROR) always emit exactly one `function_call_output` keyed on that id — type-specific text when available, an error marker on ERROR, else an empty string. WAITING / RUNNING / PENDING still emit nothing (no result yet). System steps with no toolCall.id (CHECKPOINT / CONVERSATION_HISTORY) remain skipped. Tests: flip the ERROR test to assert a paired error output, add closure coverage for empty-output DONE commands and unmapped result types, and a guard that id-less system steps are still skipped. 84 mapper + 102 reader tests pass. Co-authored-by: Bryan Li <15131870+btli@users.noreply.github.com> Co-authored-by: Isaac * fix(antigravity-native): close the turn on a terminal/degenerate planner (P0 #4) The reader opened a turn (RUNNING) on USER_INPUT but only closed it (IDLE) on a DONE PLANNER_RESPONSE that carried assistant text and no tool calls. A turn that ended in any other terminal shape — a terminal-ERROR planner, or a DONE planner with neither text nor a tool call — never fired IDLE, so `turn_active` stuck True: the web/mobile spinner spun forever AND the next turn's USER_INPUT could not re-open RUNNING (it is gated on `not turn_active`), leaving the UI frozen. Add `_is_turn_close_step`, used by `_emit_step` in place of the narrower `_is_assistant_text_close_step`: a turn now also closes on a terminal-ERROR PLANNER_RESPONSE and on a DONE PLANNER_RESPONSE that dispatches no tool call (degenerate end). A planner that DOES dispatch a tool call is still a continuation (never a close), and non-planner/tool-result steps never close (a recovery planner follows). The existing text-close predicate and its tests are unchanged. Known follow-up (out of scope here): a turn interrupted mid-flight from the agy TUI where agy emits no terminal planner step still relies on the next planner to close; a periodic reconciliation against agy's cascade status would cover that fully. Tests: 5 predicate cases + an integration test proving an ERROR-planner turn emits RUNNING then IDLE. 69 reader tests pass. Co-authored-by: Bryan Li <15131870+btli@users.noreply.github.com> Co-authored-by: Isaac * fix(antigravity-native): make agy ask_question round-trip over the web UI (P0 #3) The agy elicitation adapter stamped the question under the params key `ask_question` and expected the web verdict to carry `selectedOptionIds`. But the SPA only renders the interactive AskUserQuestion form off the `ask_user_question` key, and that form posts a flat `{question -> selected label(s)}` map — it never produces `selectedOptionIds`. So an agy ask_question rendered as a generic approve/reject card and, on accept, the adapter received `content=None` and delivered `{"askQuestion": {"responses": []}}` — the user's actual choice was silently dropped. Fix (reuses the existing, tested SPA form — no behavioral frontend change): - `_agy_ask_question_params` now also stamps the question under `ask_user_question` in the Claude AskUserQuestion shape (agy option `text` -> Claude option `label`; each question gets a synthetic string id == its index). The raw agy spec stays under `ask_question` for the reverse mapping. - `_agy_ask_question_response` now consumes the form's answer map (keyed by question id, valued by selected labels / custom text) and maps each label back to its agy option id by matching option `text`; unmatched labels become `writeInResponse`. EVERY question is answered, so the prior single-question limitation is gone — multi-question prompts round-trip fully. - ApprovalCard: title agy prompts "Antigravity needs your input" instead of defaulting to "Claude has questions" (mirrors the codex branch). Tests: rewrote the adapter interaction-payload tests to the real form shape, added `ask_user_question` params coverage + multi-question round-trip, updated the bridge interaction tests, and added a frontend title test. Adapter/interactions (105) + ApprovalCard (35) pass. Co-authored-by: Bryan Li <15131870+btli@users.noreply.github.com> Co-authored-by: Isaac * fix(executor-adapter): drop id-less ToolCallComplete instead of emitting an empty-call_id output (P0 #1) The shared `ExecutorAdapter` replaced the old blanket suppression (`if self._current_ctx is not None: return`) with an id-scoped check (`call_id = ... or ""; if call_id and call_id in self._dispatched_call_ids: return`) so internal-tool executors (antigravity) could surface their own tool outputs. But the `or ""` coercion left the id-less path UNGUARDED: `if call_id and ...` is False for `call_id == ""`, so an id-less `ToolCallComplete` now fell through and emitted a `function_call_output` with `call_id == ""`. `ExecutorAdapter` is shared by every adapter-backed harness. pi emits its `ToolCallRequest`/`ToolCallComplete` with no metadata/call_id at all (omnigent/inner/pi_executor.py:2140,2211), so this fired deterministically: an empty-id output cannot pair (downstream pairs STRICTLY by call_id and discards empty ones) and rendered a stray ghost "Waiting for output" card — a regression vs main, whose blanket rule suppressed these. claude-sdk / cursor / openai-agents are reachable via the same id-less path. Fix: suppress BOTH a dispatched id AND an empty call_id (`if not call_id or call_id in self._dispatched_call_ids: return`). This restores main's suppression for id-less completions while keeping the PR's real-id emission for internal-tool executors (antigravity stamps a real positional id, so its completions still emit and pair). This matches the contract the code comments and the sibling test `test_internal_errored_tool_complete_emits_output_with_real_call_id` already assert ("must NOT carry call_id == ''"). Also fixes the `tool_call` mock harness, which modeled an unrealistic asymmetric shape (request with a real call_id, completion id-less) — a real handles_tools_internally executor stamps the id on both, so the mock now does too, and its observed function_call + function_call_output pair. Tests: add `test_idless_tool_complete_is_suppressed`; the adapter suite + antigravity(sdk/native) + claude-sdk + codex + cursor + copilot + openai-agents + pi executor suites all pass (590 tests). NOTE (for human review): this is shared code across 7 harnesses. Unit suites are green, but a live multi-harness smoke (pi + claude-sdk tool rendering) is worth doing before merge. Co-authored-by: Bryan Li <15131870+btli@users.noreply.github.com> Co-authored-by: Isaac * fix(ci): regen openapi.json, exclude antigravity-native from live matrix, reformat Three failures surfaced once the security gate was waived and the gated jobs ran for the first time: - Pytest `test_openapi_drift`: the committed `openapi.json` was stale. Regenerated via `scripts/dump_openapi.py` so it includes the new `/v1/sessions/{id}/hooks/antigravity-elicitation-request` endpoint (and the `external_output_reasoning_delta` post_event docstring pulled in by the main merge). - E2E `test_run_harness_live_matrix_covers_registered_coding_harnesses`: `antigravity-native` is a registered coding harness but a terminal-first TUI launched via `omnigent antigravity` (not `omnigent run --harness ...`) AND is Gemini-native (no Databricks-gateway probe wiring), so it is excluded from `expected_live_harnesses` like claude-native / goose-native / antigravity. - Pre-commit ruff-format: reformat `tests/test_antigravity_native_interactions.py` (the P0 #3 content-shape edit shortened those calls enough to fit on one line; ruff-format collapses them). Co-authored-by: Bryan Li <15131870+btli@users.noreply.github.com> Co-authored-by: Isaac * fix(antigravity-native): use the functional RPC timeout for model + cascade reads get_available_models and get_all_cascade_trajectories are FUNCTIONAL connect-RPCs but were built on the tight _PROBE_TIMEOUT_S (2s) reserved for port-discovery probes. The module's own timeout policy (antigravity_native_rpc.py:100-115) mandates _RPC_CALL_TIMEOUT_S (30s) for functional calls: a 2s deadline raises an un-retried TimeoutException against a momentarily-busy agy. - get_available_models resolves the per-turn model enum on the send path with no retry (executor._resolve_plan_model); a 2s abort surfaced a spurious "no model" error and failed the turn instead of completing it. - get_all_cascade_trajectories is the /clear-rotation functional poll (morally a step-read, like get_trajectory_steps which already uses 30s). Connection-refused (a force-killed agy port) still raises ConnectError immediately — not subject to the read timeout — so the wider deadline only adds headroom for an alive-but-busy agy; it never delays the dead-port path (verified live: ConnectError in <20ms against a refused port). Discovery probes (_heartbeat_ok, _conversation_matches) keep _PROBE_TIMEOUT_S. Tests updated to assert both functions now use the functional timeout and that the probes are unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(antigravity-native): log the rotation detector's benign ConnectError at DEBUG _watch_for_rotation polls GetAllCascadeTrajectories every few seconds. When the agy port is gone — torn down / rotated / shut down before this fire-and-forget detector is cancelled — each tick raises httpx.ConnectError (connection refused) and was logged at WARNING, spamming the log during an otherwise-clean teardown. Add a ConnectError arm that logs at DEBUG and continues; the broad (httpx.HTTPError, ValueError) arm is unchanged, so a hung-but-listening port (ReadTimeout) and every other fault still WARN. Control flow is identical (both continue). A genuinely dead agy stays loudly visible: the reader BODY (stream + poll-fallback) independently WARNs on the path that matters; this only de-dups the secondary detector's redundant noise. Tests: a real-ConnectError tick logs exactly one DEBUG record and zero WARNINGs while the loop retries; a ReadTimeout tick still logs WARNING. Live-verified through the real _watch_for_rotation against a real OS connection-refused port (2 ConnectError ticks -> 2 DEBUG, 0 WARNING, no rotation, no leak). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test(server): make the top-level-elicitations guard environment-invariant test_top_level_elicitations_route_is_not_mounted asserted a flat 404, but create_app mounts a catch-all SPA (Mount path="") whenever a local web-ui build exists at omnigent/server/static/web-ui/ (a gitignored dev artifact, absent on main/CI). Starlette's StaticFiles matches any path but rejects a non-GET method with 405, so the test passed on CI (404) yet failed in a worktree with a local SPA build (405) — environment-fragile, unrelated to whether the legacy route is mounted. Harden it to express the real contract two complementary ways: - route table (app fixture): no APIRoute serves POST /v1/elicitations/{id} (catches an exact re-mount even if its handler would 404 at runtime). - HTTP (client fixture, same app): status is 404 or 405 — both mean "no handler ran". A re-mounted legacy handler returns 400/501/2xx for this body, never 404/405, so the guard still bites. Passes with and without the local SPA build present. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test(ap-web): render native session for /compact composer tests (#1139 fallout) PR #1139 ("hide /compact for non-native harnesses") gated the /compact slash command behind `showCompact = isNativeWrapper`, but did not update ChatPage.composer.test.tsx — three tests there use /compact as the representative first built-in command (default highlight, ArrowDown target, and the effort-visibility anchor) and render via composerProps() whose default isNativeWrapper is false, so /compact is now hidden and the assertions fail (`Unable to find [data-testid="slash-menu-item-compact"]`). Render those three tests as a native-wrapper session (isNativeWrapper: true) so /compact appears, matching #1139's intent. The default helper is left non-native so the /model-routing test that relies on it is unchanged. Note: this breakage also exists on main (ChatPage.tsx + this test file are identical there); the same fix applies upstream. Co-authored-by: Bryan Li <bryanli@users.noreply.github.com> Co-authored-by: Isaac <isaac@example.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Co-authored-by: SabhyaC26 <sabhyachhabria@gmail.com> Co-authored-by: Bryan Li <15131870+btli@users.noreply.github.com> Co-authored-by: Bryan Li <bryanli@users.noreply.github.com> Co-authored-by: Isaac <isaac@example.com>
243 lines
9.3 KiB
Python
243 lines
9.3 KiB
Python
"""Tests for the post-hoc Antigravity (agy) policy-audit helpers.
|
|
|
|
Pure unit tests for :mod:`omnigent.antigravity_native_audit` — the
|
|
classification/rendering layer of the audit-only governance path. No I/O; the
|
|
async POST + interrupt (in the forwarder) are covered separately.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from typing import Any
|
|
|
|
from omnigent.antigravity_native_audit import (
|
|
DEGRADE_NOTICE_TEXT,
|
|
HARNESS_NAME,
|
|
audit_verdict_is_violation,
|
|
audit_violation_warning_text,
|
|
build_audit_evaluation_request,
|
|
build_degrade_notice_item,
|
|
build_policy_violation_item,
|
|
step_to_audit_tool_calls,
|
|
)
|
|
|
|
_CID = "8ca97c49-4711-4f1c-a4f5-c8d8e4979687"
|
|
|
|
|
|
def _planner_tool_step(name: str = "run_command", **args: Any) -> dict[str, Any]:
|
|
"""
|
|
Build a PLANNER_RESPONSE step with one tool call.
|
|
|
|
:param name: Tool name.
|
|
:param args: Tool ``args`` payload (display keys may be included).
|
|
:returns: A PLANNER_RESPONSE step dict.
|
|
"""
|
|
return {
|
|
"step_index": 2,
|
|
"source": "MODEL",
|
|
"type": "PLANNER_RESPONSE",
|
|
"status": "DONE",
|
|
"content": "Running a command.",
|
|
"tool_calls": [{"name": name, "args": args}],
|
|
}
|
|
|
|
|
|
# ── step_to_audit_tool_calls ───────────────────────────────────────────────
|
|
|
|
|
|
def test_planner_tool_step_yields_neutral_record() -> None:
|
|
"""A PLANNER_RESPONSE tool call maps to a {tool_name, tool_input} record."""
|
|
step = _planner_tool_step(name="run_command", CommandLine="echo hi")
|
|
records = step_to_audit_tool_calls(step)
|
|
assert records == [{"tool_name": "run_command", "tool_input": {"CommandLine": "echo hi"}}]
|
|
|
|
|
|
def test_display_only_args_are_stripped() -> None:
|
|
"""agy's display-only args (toolAction/toolSummary) are dropped from tool_input."""
|
|
step = _planner_tool_step(
|
|
name="list_dir",
|
|
DirectoryPath="/tmp",
|
|
toolAction="Listing",
|
|
toolSummary="List dir",
|
|
)
|
|
records = step_to_audit_tool_calls(step)
|
|
assert records == [{"tool_name": "list_dir", "tool_input": {"DirectoryPath": "/tmp"}}]
|
|
|
|
|
|
def test_non_planner_step_yields_no_tool_calls() -> None:
|
|
"""A tool-result step (MODEL but not PLANNER_RESPONSE) is not a fresh tool call."""
|
|
step = {"step_index": 3, "source": "MODEL", "type": "RUN_COMMAND", "content": "output"}
|
|
assert step_to_audit_tool_calls(step) == []
|
|
|
|
|
|
def test_user_step_yields_no_tool_calls() -> None:
|
|
"""A user-input step initiates no tools."""
|
|
step = {"step_index": 0, "source": "USER_EXPLICIT", "type": "USER_INPUT", "content": "hi"}
|
|
assert step_to_audit_tool_calls(step) == []
|
|
|
|
|
|
def test_tool_call_without_name_is_skipped() -> None:
|
|
"""A tool_calls entry with no usable name is dropped."""
|
|
step = {
|
|
"step_index": 2,
|
|
"source": "MODEL",
|
|
"type": "PLANNER_RESPONSE",
|
|
"tool_calls": [{"args": {"x": 1}}, {"name": "", "args": {}}],
|
|
}
|
|
assert step_to_audit_tool_calls(step) == []
|
|
|
|
|
|
def test_multiple_tool_calls_preserved_in_order() -> None:
|
|
"""Several tool calls in one step are returned in order."""
|
|
step = {
|
|
"step_index": 2,
|
|
"source": "MODEL",
|
|
"type": "PLANNER_RESPONSE",
|
|
"tool_calls": [
|
|
{"name": "a", "args": {"k": 1}},
|
|
{"name": "b", "args": {}},
|
|
],
|
|
}
|
|
records = step_to_audit_tool_calls(step)
|
|
assert [r["tool_name"] for r in records] == ["a", "b"]
|
|
|
|
|
|
# ── build_audit_evaluation_request ─────────────────────────────────────────
|
|
|
|
|
|
def test_audit_request_is_tool_call_phase_with_harness_and_model() -> None:
|
|
"""The audit request lands on PHASE_TOOL_CALL and stamps harness + model."""
|
|
request = build_audit_evaluation_request(
|
|
tool_name="run_command",
|
|
tool_input={"CommandLine": "echo hi"},
|
|
model="gemini-2.5-pro",
|
|
)
|
|
assert request is not None
|
|
event = request["event"]
|
|
assert isinstance(event, dict)
|
|
assert event["type"] == "PHASE_TOOL_CALL"
|
|
assert event["data"] == {"name": "run_command", "arguments": {"CommandLine": "echo hi"}}
|
|
assert event["context"]["harness"] == HARNESS_NAME
|
|
assert event["context"]["model"] == "gemini-2.5-pro"
|
|
|
|
|
|
def test_audit_request_omits_model_when_none() -> None:
|
|
"""``model=None`` omits context.model but still stamps the harness."""
|
|
request = build_audit_evaluation_request(tool_name="run_command", tool_input={}, model=None)
|
|
assert request is not None
|
|
event = request["event"]
|
|
assert isinstance(event, dict)
|
|
assert "model" not in event["context"]
|
|
assert event["context"]["harness"] == HARNESS_NAME
|
|
|
|
|
|
def test_audit_request_skips_omnigent_mcp_tools() -> None:
|
|
"""``mcp__omnigent__*`` tools are relay-enforced; the audit returns None.
|
|
|
|
Only the Omnigent MCP tools are double-counted by the relay path, so
|
|
:func:`build_audit_evaluation_request` (delegating to
|
|
``hook_payload_to_evaluation_request``) skips exactly those.
|
|
"""
|
|
request = build_audit_evaluation_request(
|
|
tool_name="mcp__omnigent__sys_call", tool_input={}, model=None
|
|
)
|
|
assert request is None
|
|
|
|
|
|
def test_audit_request_evaluates_connector_mcp_tools() -> None:
|
|
"""Connector-native MCP tools (e.g. ``mcp__github__*``) still need the gate.
|
|
|
|
Unlike ``mcp__omnigent__*`` (relay-enforced), connector MCP tools are not
|
|
policy-checked elsewhere, so the audit must produce a request for them.
|
|
"""
|
|
request = build_audit_evaluation_request(
|
|
tool_name="mcp__github__create_issue", tool_input={}, model="gemini-2.5-pro"
|
|
)
|
|
assert request is not None
|
|
event = request["event"]
|
|
assert isinstance(event, dict)
|
|
assert event["data"]["name"] == "mcp__github__create_issue"
|
|
assert event["context"]["harness"] == HARNESS_NAME
|
|
|
|
|
|
# ── audit_verdict_is_violation / warning text ──────────────────────────────
|
|
|
|
|
|
def test_deny_is_violation() -> None:
|
|
"""DENY is a violation."""
|
|
assert audit_verdict_is_violation({"result": "POLICY_ACTION_DENY"}) is True
|
|
|
|
|
|
def test_ask_is_violation_deny_style() -> None:
|
|
"""ASK is treated DENY-style (the tool already ran; it cannot be held)."""
|
|
assert audit_verdict_is_violation({"result": "POLICY_ACTION_ASK"}) is True
|
|
|
|
|
|
def test_allow_is_not_violation() -> None:
|
|
"""ALLOW is not a violation."""
|
|
assert audit_verdict_is_violation({"result": "POLICY_ACTION_ALLOW"}) is False
|
|
|
|
|
|
def test_unspecified_is_not_violation() -> None:
|
|
"""UNSPECIFIED (no matching policy) is not a violation."""
|
|
assert audit_verdict_is_violation({"result": "POLICY_ACTION_UNSPECIFIED"}) is False
|
|
|
|
|
|
def test_warning_text_includes_reason_and_post_hoc_framing() -> None:
|
|
"""The warning carries the policy reason and is framed as already-executed."""
|
|
text = audit_violation_warning_text({"result": "POLICY_ACTION_DENY", "reason": "no rm -rf"})
|
|
assert "no rm -rf" in text
|
|
assert "already executed" in text
|
|
assert text.startswith("[Policy violation]")
|
|
|
|
|
|
def test_warning_text_has_fallback_reason() -> None:
|
|
"""A verdict with no reason still renders a sensible warning."""
|
|
text = audit_violation_warning_text({"result": "POLICY_ACTION_ASK"})
|
|
assert "[Policy violation]" in text
|
|
assert "already executed" in text
|
|
|
|
|
|
# ── conversation items ─────────────────────────────────────────────────────
|
|
|
|
|
|
def test_policy_violation_item_is_assistant_message() -> None:
|
|
"""The violation warning is an assistant message namespaced by step + call + policy."""
|
|
item = build_policy_violation_item(
|
|
conversation_id=_CID, step_index=2, call_ordinal=0, text="warn"
|
|
)
|
|
assert item["item_type"] == "message"
|
|
item_data = item["item_data"]
|
|
assert isinstance(item_data, dict)
|
|
assert item_data["role"] == "assistant"
|
|
assert item_data["content"] == [{"type": "output_text", "text": "warn"}]
|
|
assert item["response_id"] == f"agy_{_CID}_2_0_policy"
|
|
|
|
|
|
def test_policy_violation_items_distinct_per_call_ordinal_in_one_step() -> None:
|
|
"""
|
|
Two violations from the SAME step get DISTINCT response ids via the call
|
|
ordinal — keying on step_index alone would collide them onto one id (a single
|
|
PLANNER_RESPONSE step can carry multiple violating tool calls).
|
|
"""
|
|
first = build_policy_violation_item(
|
|
conversation_id=_CID, step_index=2, call_ordinal=0, text="warn-0"
|
|
)
|
|
second = build_policy_violation_item(
|
|
conversation_id=_CID, step_index=2, call_ordinal=1, text="warn-1"
|
|
)
|
|
assert first["response_id"] == f"agy_{_CID}_2_0_policy"
|
|
assert second["response_id"] == f"agy_{_CID}_2_1_policy"
|
|
assert first["response_id"] != second["response_id"]
|
|
|
|
|
|
def test_degrade_notice_item_carries_audit_only_text() -> None:
|
|
"""The one-time degrade notice states enforcement is audit-only."""
|
|
item = build_degrade_notice_item(conversation_id=_CID)
|
|
assert item["item_type"] == "message"
|
|
item_data = item["item_data"]
|
|
assert isinstance(item_data, dict)
|
|
assert item_data["content"] == [{"type": "output_text", "text": DEGRADE_NOTICE_TEXT}]
|
|
assert "audit-only" in DEGRADE_NOTICE_TEXT
|
|
assert "not blocked" in DEGRADE_NOTICE_TEXT
|
|
assert item["response_id"] == f"agy_{_CID}_audit_notice"
|