From e05021afd6a274095794a8bc54c4f0f4d524d0ec Mon Sep 17 00:00:00 2001 From: Khoi Nguyen Date: Sun, 2 Aug 2026 19:43:49 +0700 Subject: [PATCH 1/5] fix(antigravity): make the agy harness usable from the web UI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Running agy through Omnigent lost most of what agy was doing. This brings the web UI to parity with what the terminal already showed. Every fix below was found by reading live agy RPC traffic and verified against real sessions; the recorded frames are checked in as fixtures. **Plugin skills were missing.** An omnigent-spawned agy gets an isolated `--gemini_dir`, and nothing seeded the user's plugins into it, so `agy plugin list` was empty under Omnigent while identical outside it. The bridge now symlinks `config/plugins` and copies `import_manifest.json`. **The slash menu offered Claude's skills.** The skill-source registry had no antigravity family, so agy sessions fell through to the claude-native provider. agy now has its own five sources, with plugin skills namespaced `:` and enabled only when `plugin.json` is present. **`--dangerously-skip-permissions` was unreachable.** claude-code exposes its bypass in the new-chat dialog; agy had no equivalent, so the flag could only be set by hand-editing launch args. Added as a capability with the same danger banner. **Sub-agents forked duplicate top-level sessions.** agy spawns each sub-agent as its own cascade, and a working sub-agent is always more recently active than the parent idling behind it — so the rotation detector read every spawn as a `/clear` and dragged the pane onto the child. Children are now identified by `trajectoryMetadata` and skipped. **Cold start could bind a stranger's agy.** With several agy processes alive, a session could attach to another one's RPC port and mirror its conversation. Ownership is now confirmed after `StartCascade`. Port attribution also moved from shelling out to `lsof` — an undeclared dependency absent from many images, and unavailable on Windows — to psutil, which is already a dependency, with a `/proc/net/tcp` fallback. **Replies duplicated and truncated.** The streaming reader stamped a constant `"index": 0` on every text delta, and the server discards any chunk whose index does not advance — so the first chunk rendered, the rest were dropped, and the unretired buffer replayed to later subscribers. Deltas now carry a real index, and the live block is closed on both the stream and poll paths. **No tool call was ever mirrored.** agy serves each step at two fidelities: the snapshot RPC carries `metadata.toolCall` and `plannerResponse.toolCalls`, while the live stream strips both (each embeds a `thinkingSignature` blob). The mapper was built against the snapshot, so streamed turns recorded 611 tool outputs against 0 invocations — naked result blobs, most keyed to invented `_orphan_N` ids, with `view_file` and `invoke_subagent` results dropped entirely. Both items now derive from the result step, which both shapes deliver in full, keyed on its own `(trajectory, step)` identity so a stream->poll fallback cannot re-key a pair. **Sub-agent work was invisible.** agy names each sub-agent's cascade, role and type on the parent's `INVOKE_SUBAGENT` step, but nothing mirrored them, so a four-reviewer dispatch showed one opaque tool call and an empty Agents rail. Each child now gets a child session and a mirror loop. `invoke_subagent` is fire-and-forget — its step reaches DONE while the child runs on for minutes — so each mirror ends on its own child's turn closing, with agy's run status as the backstop for a turn that never closes. Test plan: - 730 passed, 1 skipped across the antigravity selection; pre-commit clean. - 6 stream-projection fixtures are verbatim live frames — the shape that had no coverage, which is why the tool-call bug shipped. - Every fix verified end-to-end against a live agy: `agy plugin list` A/B, the `/skills` panel, live SSE captures for the delta index, and a replay of the real conversations for tool calls (18 tool steps -> 18 complete pairs, both RPC shapes agreeing) and sub-agents (children that had recorded 1 item each now mirror their full transcripts and close). Co-Authored-By: Claude Opus 5 (1M context) Signed-off-by: Khoi Nguyen --- omnigent/antigravity_native_bridge.py | 45 + omnigent/antigravity_native_reader.py | 479 ++++++++++- omnigent/antigravity_native_rpc.py | 126 ++- omnigent/antigravity_native_steps.py | 534 ++++++------ omnigent/runner/native/orchestration.py | 54 ++ omnigent/server/routes/_sessions/common.py | 34 + omnigent/server/routes/_sessions/helpers.py | 102 +++ .../server/routes/_sessions/orchestration.py | 64 ++ .../server/routes/sessions/routes_events.py | 11 + omnigent/spec/skill_sources.py | 148 +++- .../steps/stream_generic_done.json | 54 ++ .../steps/stream_list_directory_done.json | 50 ++ .../steps/stream_planner_text_done.json | 42 + .../steps/stream_planner_tool_call.json | 41 + .../steps/stream_run_command_done.json | 47 ++ .../steps/stream_view_file_done.json | 40 + ...t_app_sessions_native_terminals_runtime.py | 138 ++++ .../integration/test_sessions_endpoints.py | 183 +++++ tests/spec/test_skill_sources.py | 205 +++++ tests/test_antigravity_native.py | 4 + tests/test_antigravity_native_bridge.py | 87 ++ tests/test_antigravity_native_reader.py | 772 +++++++++++++++++- tests/test_antigravity_native_rpc.py | 113 +++ tests/test_antigravity_native_steps.py | 613 +++++++------- web/src/lib/nativeCodingAgents.test.ts | 15 + web/src/lib/nativeCodingAgents.ts | 7 +- web/src/shell/NewChatDialog.flow.test.tsx | 63 ++ web/src/shell/NewChatDialog.tsx | 94 ++- 28 files changed, 3532 insertions(+), 633 deletions(-) create mode 100644 tests/fixtures/antigravity/steps/stream_generic_done.json create mode 100644 tests/fixtures/antigravity/steps/stream_list_directory_done.json create mode 100644 tests/fixtures/antigravity/steps/stream_planner_text_done.json create mode 100644 tests/fixtures/antigravity/steps/stream_planner_tool_call.json create mode 100644 tests/fixtures/antigravity/steps/stream_run_command_done.json create mode 100644 tests/fixtures/antigravity/steps/stream_view_file_done.json diff --git a/omnigent/antigravity_native_bridge.py b/omnigent/antigravity_native_bridge.py index d0e9d9fb70..78134c4b75 100644 --- a/omnigent/antigravity_native_bridge.py +++ b/omnigent/antigravity_native_bridge.py @@ -339,6 +339,15 @@ def prepare_bridge_dir(bridge_id: str) -> Path: ) +# agy resolves imported plugins (skills + hooks) relative to its Gemini dir, so a +# bridge-owned ``--gemini_dir`` starts with none of the user's plugins unless they +# are seeded. ``plugins/`` is symlinked rather than copied: the payload is a git +# checkout per plugin, and a link keeps a mid-session ``/plugin install`` or update +# visible instead of pinning a stale copy. +_AGY_PLUGINS_DIR = "plugins" +_AGY_IMPORT_MANIFEST = "import_manifest.json" + + def agy_home_dir(bridge_dir: Path) -> Path: """Return the parent directory for this session's isolated agy state. @@ -534,12 +543,48 @@ def seed_isolated_agy_home( with contextlib.suppress(OSError): (iso_gemini / _MCP_CONFIG_DIR / ".migrated").touch() + _seed_isolated_agy_plugins(real_home, iso_gemini) + if trusted_workspace is not None: _seed_isolated_agy_workspace_trust(iso_gemini, Path(trusted_workspace)) return {} +def _seed_isolated_agy_plugins(real_home: Path, iso_gemini: Path) -> None: + """Expose the user's imported agy plugins under the isolated Gemini dir. + + Links ``config/plugins`` to the real tree and copies ``import_manifest.json`` + (agy needs both: the manifest declares which plugins are imported, the + directory holds their skills and hooks). Without this an omnigent-spawned + session lists zero plugin skills while a direct ``agy`` run lists them all. + + Best-effort: a user with no plugins, or a platform that refuses symlinks, + simply gets a session without them rather than a failed launch. + + :param real_home: The user's real home directory. + :param iso_gemini: The bridge-owned ``--gemini_dir`` being seeded. + """ + real_config = real_home / ".gemini" / _MCP_CONFIG_DIR + iso_config = iso_gemini / _MCP_CONFIG_DIR + + real_plugins = real_config / _AGY_PLUGINS_DIR + if real_plugins.is_dir(): + link = iso_config / _AGY_PLUGINS_DIR + with contextlib.suppress(OSError): + # Re-seeding an existing bridge dir must not fail on the prior link; + # drop a stale or broken one so the target is always current. + if link.is_symlink() or link.is_file(): + link.unlink() + if not link.exists(): + link.symlink_to(real_plugins.resolve(), target_is_directory=True) + + real_manifest = real_config / _AGY_IMPORT_MANIFEST + if real_manifest.is_file(): + with contextlib.suppress(OSError): + (iso_config / _AGY_IMPORT_MANIFEST).write_bytes(real_manifest.read_bytes()) + + # agy's periodic engagement survey ("How's the CLI experience so far?") is gated by # this ``settings.json`` key. Its modal footer line ``esc to cancel`` is identical # to :data:`_AGY_ACTIVE_MARKER` (the running-turn signal the TUI inject path keys diff --git a/omnigent/antigravity_native_reader.py b/omnigent/antigravity_native_reader.py index c5936a11fa..5fb85072f8 100644 --- a/omnigent/antigravity_native_reader.py +++ b/omnigent/antigravity_native_reader.py @@ -81,15 +81,15 @@ stream_agent_state_updates, ) -# ``OutboundEvent`` + ``_ToolCallIdAllocator`` live in the mapper module since the -# Task 12 cutover (relocated from the retired transcript forwarder). The reader -# reuses the SAME event shape and allocator so the mapped events post identically. +# ``OutboundEvent`` lives in the mapper module since the Task 12 cutover +# (relocated from the retired transcript forwarder). The reader reuses the SAME +# event shape so the mapped events post identically. from omnigent.antigravity_native_steps import ( OutboundEvent, PendingInteraction, _execution_discriminator, _step_index, - _ToolCallIdAllocator, + _tool_call_id, _trajectory_id, map_step_to_events, output_reasoning_delta_event, @@ -107,6 +107,17 @@ # keeps the mirror responsive without hammering the loopback server. _DEFAULT_POLL_INTERVAL_S = 0.25 +# Sub-agent mirrors poll their own cascade. Slower than the parent's tick because +# a session can run twenty-odd of them at once (live-verified: 23), and none of +# them streams — they are committed-only, so a sub-second tick buys nothing. +_SUBAGENT_POLL_INTERVAL_S = 1.0 + +# Consecutive no-new-step polls before a sub-agent whose turn never closed is +# checked against agy's own run status. Generous: a child running a long build +# produces no steps meanwhile, and cutting it short would truncate its +# transcript. The cost of waiting is only how long a stuck badge lingers. +_SUBAGENT_QUIESCENT_POLLS = 60 + # Default seconds between Task T-G ``/clear``-rotation checks # (``GetAllCascadeTrajectories``). Coarse on purpose: a ``/clear`` is a rare, # human-initiated event, so a few seconds of detection latency is fine and keeps @@ -436,6 +447,43 @@ def _cascade_is_idle(summaries: dict[str, object], bound_cascade_id: str) -> boo return summary.get("status") == _CASCADE_RUN_STATUS_IDLE +def _summary_is_child_trajectory(cascade_id: str, summary: dict[str, object]) -> bool: + """ + Whether a cascade summary describes a SUBAGENT (child) conversation. + + agy spawns each subagent as its own conversation that reports + ``trajectoryType == CORTEX_TRAJECTORY_TYPE_CASCADE`` — byte-identical to a real + root — so the type alone cannot tell them apart (live-verified, agy 1.1.9). + The distinction lives in ``trajectoryMetadata``, where a child carries a + ``parentConversationId`` and a ``rootConversationId`` pointing at its PARENT, + while a root's ``rootConversationId`` is its own id:: + + root: {"rootConversationId": ""} + child: {"parentConversationId": "", "rootConversationId": "", + "nestingDepth": 1, "subagentSpec": {...}} + + This matters because a subagent is ALWAYS more recently active than the parent + idling while it works, so without this test every spawned subagent looks like a + ``/clear`` rotation — promoting a sub-conversation to a new top-level session + and dragging the agy pane onto it. + + Fail-open: a summary with no (or malformed) metadata is NOT treated as a child, + so a genuine ``/clear`` on an agy build that omits these fields still rotates. + + :param cascade_id: The summary's own key (its conversation id). + :param summary: One ``trajectorySummaries`` entry. + :returns: ``True`` when the entry is a subagent/child trajectory. + """ + metadata = summary.get("trajectoryMetadata") + if not isinstance(metadata, dict): + return False + parent = metadata.get("parentConversationId") + if isinstance(parent, str) and parent: + return True + root = metadata.get("rootConversationId") + return isinstance(root, str) and bool(root) and root != cascade_id + + def _detect_rotated_cascade(summaries: dict[str, object], bound_cascade_id: str) -> str | None: """ Return the id of a newer-active root cascade than the bound one, else ``None``. @@ -487,7 +535,9 @@ def _detect_rotated_cascade(summaries: dict[str, object], bound_cascade_id: str) if not isinstance(summary, dict): continue if summary.get("trajectoryType") != _TRAJECTORY_TYPE_CASCADE: - continue # never rotate to a subagent/child trajectory + continue # never rotate to a non-cascade trajectory + if _summary_is_child_trajectory(cascade_id, summary): + continue # a subagent works FOR the bound cascade; it is not a new one if cascade_id == bound_cascade_id: continue activity = _summary_activity(summary) @@ -905,9 +955,9 @@ async def supervise_reader( De-dup is by ``(trajectory_id, step_index)`` identity in an in-memory seen-set (no durable cursor — retired in Task 12), so re-reading the same - snapshot posts nothing. A single :class:`_ToolCallIdAllocator` is reused - across polls so fallback ids stay stable; real agy tool-call ids (used by the - mapper) make invocation↔output pairing order-independent regardless. + snapshot posts nothing. Tool-call ids are derived from each step's own + ``(trajectory, step)`` identity by the mapper, so a re-read or a fallback to + a different RPC re-derives the same id rather than re-keying the pair. Error handling: an RPC failure on a poll — ``httpx.HTTPError`` (transport AND non-2xx both raise it) or a ``ValueError`` (a non-JSON 200 body) — is logged @@ -960,11 +1010,10 @@ async def supervise_reader( return None cascade_id, port = discovered - # One allocator + one set of cross-poll/cross-frame trackers per reader run, - # shared by BOTH the stream path and the poll fallback so a fall-through after - # a partial stream does not re-post already-mirrored steps or re-open turns. + # One set of cross-poll/cross-frame trackers per reader run, shared by BOTH + # the stream path and the poll fallback so a fall-through after a partial + # stream does not re-post already-mirrored steps or re-open turns. state = _ReaderState( - allocator=_ToolCallIdAllocator(conversation_id=cascade_id), seen=set(), interacted=set(), port=port, @@ -1114,7 +1163,14 @@ async def _run_body() -> None: await asyncio.sleep(0) inflight = [ pending - for pending in (state.interaction_task, *state.interaction_rescans) + for pending in ( + state.interaction_task, + *state.interaction_rescans, + # Sub-agent mirrors poll until their parent step settles; a + # reader teardown mid-flight must not leave them polling a + # cascade whose agy is going away. + *state.subagent_mirrors.values(), + ) if pending is not None and not pending.done() ] if not inflight: @@ -1145,9 +1201,6 @@ class _ReaderState: mirrored over the stream is not re-posted by the poll loop, and an open turn is not re-opened. - :param allocator: Per-run fallback tool-call id allocator (real agy ids are - preferred by the mapper; this only covers resume-mid-turn results that - lack ``metadata.toolCall.id``). :param seen: :func:`_step_key` identities whose COMMITTED items have been posted, so the on-connect snapshot replay (and steady-state re-reads) post nothing. @@ -1197,9 +1250,14 @@ class _ReaderState: :param interaction_rescans: In-flight re-scan tasks scheduled by a bridge's done-callback to surface a WAITING gate deferred while the bridge ran. Held as strong refs so they are not GC'd mid-run; cancelled on teardown. + :param subagent_mirrors: agy child cascade id → the task mirroring that + cascade into its own Omnigent child session. One per sub-agent, started + when the parent's ``INVOKE_SUBAGENT`` step first names the child and + held as a strong ref so it is not GC'd mid-run; cancelled on teardown. + Doubles as the start-once guard — a re-sent step re-enters the handler + every frame while the sub-agents work. """ - allocator: _ToolCallIdAllocator seen: set[_StepKey] interacted: set[_StepKey] prefixes: dict[int, str] = field(default_factory=dict) @@ -1214,6 +1272,7 @@ class _ReaderState: interaction_task: asyncio.Task[None] | None = None surfaced_elicitations: dict[_StepKey, str] = field(default_factory=dict) interaction_rescans: set[asyncio.Task[None]] = field(default_factory=set) + subagent_mirrors: dict[str, asyncio.Task[None]] = field(default_factory=dict) async def _poll_loop( @@ -1429,12 +1488,23 @@ async def _process_committed_step( if key not in state.seen: if _is_settled(step): state.seen.add(key) + # Close any live block for this step BEFORE its committed item. Done + # here rather than in the stream handler because the poll loop commits + # through this same function: closing in only one path leaves every + # poll-committed step permanently in-flight, and the server then + # replays it to each later subscriber as a stale duplicate. + await _close_planner_delta_stream( + step, + client=client, + session_id=session_id, + cascade_id=cascade_id, + prefixes=state.prefixes, + ) state.turn_active = await _emit_step( step, client=client, session_id=session_id, cascade_id=cascade_id, - allocator=state.allocator, turn_active=state.turn_active, ) # Telemetry: model-change detection on USER_INPUT (design §10.4). @@ -1471,6 +1541,291 @@ async def _process_committed_step( session_id=session_id, state=state, ) + # Sub-agents: an INVOKE_SUBAGENT step names its children as soon as they are + # spawned, long before the step settles. Runs unconditionally (like the + # interaction handoff above) so the rail fills WHILE they work rather than + # when they all finish; the dedup lives in ``subagent_mirrors``. + await _maybe_mirror_subagents( + step, + client=client, + session_id=session_id, + cascade_id=cascade_id, + state=state, + ) + + +def _subagent_pairs(step: dict[str, object]) -> list[tuple[dict[str, object], str]]: + """ + Pair an INVOKE_SUBAGENT step's specs with the child cascade ids they spawned. + + agy fills ``invokeSubagent.results[]`` positionally against + ``invokeSubagent.subagents[]``, and populates a result's ``conversationId`` + as soon as that child exists — the step itself stays RUNNING until every + child finishes. A spec whose result has no id yet is skipped and picked up on + a later frame. + + :param step: A step dict, of any type. + :returns: ``(spec, child cascade id)`` pairs, empty for a non-subagent step. + """ + body = step.get("invokeSubagent") + if not isinstance(body, dict): + return [] + specs = body.get("subagents") + results = body.get("results") + if not isinstance(specs, list) or not isinstance(results, list): + return [] + pairs: list[tuple[dict[str, object], str]] = [] + for spec, result in zip(specs, results, strict=False): + if not isinstance(spec, dict) or not isinstance(result, dict): + continue + child_id = result.get("conversationId") + if isinstance(child_id, str) and child_id: + pairs.append((spec, child_id)) + return pairs + + +async def _register_subagent_child( + *, + client: httpx.AsyncClient, + session_id: str, + spec: dict[str, object], + child_cascade_id: str, + tool_call_id: str, +) -> str | None: + """ + Register one agy sub-agent with the server and return its child session id. + + :param client: HTTP client for Omnigent event posts. + :param session_id: PARENT Omnigent conversation id. + :param spec: One ``invokeSubagent.subagents[]`` entry. + :param child_cascade_id: The child's agy conversation id. + :param tool_call_id: The INVOKE_SUBAGENT step's mirrored call id, so the + child links back to the tool card that spawned it. + :returns: The minted child conversation id, or ``None`` when the server + rejected the registration (logged; the sub-agent is simply not mirrored). + """ + role = spec.get("role") + agent_type = spec.get("typeName") + payload: dict[str, object] = { + "type": "external_antigravity_subagent_start", + "data": { + "cascade_id": child_cascade_id, + "role": role if isinstance(role, str) else "", + "agent_type": agent_type if isinstance(agent_type, str) else "", + "tool_call_id": tool_call_id, + }, + } + try: + response = await client.post( + f"/v1/sessions/{url_component(session_id)}/events", json=payload + ) + response.raise_for_status() + body = response.json() + except (httpx.HTTPError, ValueError) as exc: + _logger.warning( + "agy sub-agent registration failed; not mirroring it: parent=%s child=%s error=%r", + session_id, + child_cascade_id, + exc, + ) + return None + child_session_id = body.get("child_session_id") if isinstance(body, dict) else None + if not isinstance(child_session_id, str) or not child_session_id: + _logger.warning( + "agy sub-agent registration returned no child_session_id: parent=%s child=%s", + session_id, + child_cascade_id, + ) + return None + return child_session_id + + +async def _subagent_cascade_is_idle(port: int, child_cascade_id: str) -> bool: + """ + Ask agy whether a sub-agent cascade has stopped running. + + The authoritative completion signal, used only to break a stall: a child that + has gone quiet without a recognizable closing step. Fail-safe on any RPC + trouble — reporting "not idle" keeps mirroring, where a wrong "idle" would + truncate a sub-agent mid-run. + + :param port: agy connect-RPC port. + :param child_cascade_id: The sub-agent's agy conversation id. + :returns: ``True`` only on an explicit idle status for that cascade. + """ + try: + body = await asyncio.to_thread(get_all_cascade_trajectories, port) + except (httpx.HTTPError, ValueError) as exc: + _logger.warning( + "agy sub-agent idle check failed; still mirroring: child=%s error=%r", + child_cascade_id, + exc, + ) + return False + summaries = body.get("trajectorySummaries") + if not isinstance(summaries, dict): + return False + return _cascade_is_idle(summaries, child_cascade_id) + + +async def _mirror_subagent_cascade( + *, + port: int, + child_cascade_id: str, + client: httpx.AsyncClient, + child_session_id: str, +) -> None: + """ + Mirror one sub-agent cascade into its Omnigent child session until it ends. + + A sub-agent cascade is an ordinary cascade on the SAME agy RPC port, so this + is the committed-only poll path pointed at the child — the same mapper, the + same status edges. Its ``_ReaderState`` is private to the child so its + de-dup and turn flag cannot collide with the parent's. + + **The parent's step is not the finish signal.** ``invoke_subagent`` is + fire-and-forget: its step reaches DONE the moment the child is spawned, while + the child runs on for minutes afterwards (live-verified — a child kept + working 23s past its spawning step's ``completedAt``, and accumulated 35 + steps). Stopping on it mirrored only the child's opening prompt and left the + rail spinning forever, so the child's OWN turn decides: + + * the turn opened and then closed — the ordinary path, and the close is what + posts the child's IDLE edge; + * the turn is open but the child has gone quiet for + :data:`_SUBAGENT_QUIESCENT_POLLS` polls AND agy reports the cascade idle — + its closing step was not recognizable, so IDLE is posted here instead. + Without this a child whose turn never closes polls for the whole session + and shows as running forever. + + Sub-agents are headless (they never raise an interaction), so the pending + handler is a no-op rather than the parent's bridge. + + Runs until one of those two ends; the caller cancels it on reader teardown. + + :param port: agy connect-RPC port (shared with the parent). + :param child_cascade_id: The sub-agent's agy conversation id. + :param client: HTTP client for Omnigent event posts. + :param child_session_id: The Omnigent child conversation to mirror into. + :returns: None. + """ + + async def _no_interaction(_cascade: str, _port: int, _pending: PendingInteraction) -> None: + """A sub-agent has no terminal to prompt in; nothing to bridge.""" + return + + child_state = _ReaderState(seen=set(), interacted=set(), port=port) + turn_opened = False + quiet_polls = 0 + while True: + try: + steps = await asyncio.to_thread(get_trajectory_steps, port, child_cascade_id) + except (httpx.HTTPError, ValueError) as exc: + _logger.warning( + "agy sub-agent poll failed; retrying: child=%s error=%r", + child_cascade_id, + exc, + ) + await _sleep(_SUBAGENT_POLL_INTERVAL_S) + continue + mirrored_before = len(child_state.seen) + for step in steps: + if not isinstance(step, dict): + continue + await _process_committed_step( + step, + client=client, + session_id=child_session_id, + cascade_id=child_cascade_id, + state=child_state, + on_pending_interaction=_no_interaction, + ) + # Checked per step, not per poll: a child that finishes between two + # polls delivers its whole transcript in ONE pass, opening and + # closing the turn before the poll returns. + if child_state.turn_active: + turn_opened = True + if turn_opened and not child_state.turn_active: + return + if len(child_state.seen) != mirrored_before: + quiet_polls = 0 + continue + quiet_polls += 1 + if turn_opened and quiet_polls >= _SUBAGENT_QUIESCENT_POLLS: + if await _subagent_cascade_is_idle(port, child_cascade_id): + _logger.info( + "agy sub-agent went quiet with its turn open and agy reports it " + "idle; closing it: child=%s session=%s", + child_cascade_id, + child_session_id, + ) + await _post_event(client, child_session_id, _status_event(_STATUS_IDLE)) + return + quiet_polls = 0 + await _sleep(_SUBAGENT_POLL_INTERVAL_S) + + +async def _maybe_mirror_subagents( + step: dict[str, object], + *, + client: httpx.AsyncClient, + session_id: str, + cascade_id: str, + state: _ReaderState, +) -> None: + """ + Start a mirror for each sub-agent an INVOKE_SUBAGENT step has spawned. + + agy runs each sub-agent as its own cascade rather than inlining it in the + parent transcript, so without this the parent shows one opaque tool call and + the Agents rail stays empty — the sub-agents' work is invisible in the web UI + even though agy hands us their ids. + + Idempotent: a child already being mirrored is skipped, so a re-read of the + step starts nothing new. The step's own status is deliberately NOT consulted: + ``invoke_subagent`` is fire-and-forget, reaching DONE while the child it + spawned is still working, so each mirror decides for itself when its child is + finished. + + :param step: One RPC step dict, of any type. + :param client: HTTP client for Omnigent event posts. + :param session_id: PARENT Omnigent conversation id. + :param cascade_id: PARENT agy cascade id. + :param state: Per-run trackers holding the mirror tasks. + :returns: None. + """ + pairs = _subagent_pairs(step) + if not pairs: + return + tool_call_id = _tool_call_id(step, cascade_id) + for spec, child_cascade_id in pairs: + if child_cascade_id in state.subagent_mirrors: + continue + child_session_id = await _register_subagent_child( + client=client, + session_id=session_id, + spec=spec, + child_cascade_id=child_cascade_id, + tool_call_id=tool_call_id, + ) + if child_session_id is None: + continue + task = asyncio.create_task( + _mirror_subagent_cascade( + port=state.port, + child_cascade_id=child_cascade_id, + client=client, + child_session_id=child_session_id, + ) + ) + state.subagent_mirrors[child_cascade_id] = task + _logger.info( + "agy sub-agent mirroring started: parent=%s child_cascade=%s child_session=%s role=%r", + session_id, + child_cascade_id, + child_session_id, + spec.get("role"), + ) def _is_settled(step: dict[str, object]) -> bool: @@ -1547,6 +1902,8 @@ async def _process_stream_step( ) return + # ``_process_committed_step`` closes the live block before committing, on + # both this path and the poll loop's. await _process_committed_step( step, client=client, @@ -1564,6 +1921,87 @@ async def _process_stream_step( state.reasoning_prefixes.pop(idx, None) +def _committed_planner_text(step: dict[str, object]) -> str | None: + """ + Return a DONE planner step's committed text, or ``None`` when it has none. + + Mirrors the mapper's precedence — ``modifiedResponse`` (post-moderation) over + ``response`` — so the closing delta leaves the server's buffered text + byte-equal to the item the mapper commits. + + :param step: One RPC step dict. + :returns: The committed assistant text, or ``None`` for a non-planner step or + one carrying no text (an intermediate planner that only made tool calls). + """ + if step.get("type") != _TYPE_PLANNER_RESPONSE: + return None + planner = step.get("plannerResponse") + if not isinstance(planner, dict): + return None + for key in ("modifiedResponse", "response"): + text = planner.get(key) + if isinstance(text, str) and text: + return text + return None + + +async def _close_planner_delta_stream( + step: dict[str, object], + *, + client: httpx.AsyncClient, + session_id: str, + cascade_id: str, + prefixes: dict[int, str], +) -> None: + """ + Flush a committed planner step's remaining suffix as a ``final=True`` delta. + + agy's last GENERATING frame usually lags the committed text, so the streamed + prefix is a truncated version of what commits. Emitting the difference here — + and marking the stream final — satisfies both of the server's retirement + conditions, letting the in-flight entry be dropped instead of replayed to + every later subscriber. + + No-ops for a step that never streamed (no prefix tracker) and carries no + committed text, so intermediate tool-call-only planner steps stay silent. + A step whose stream is already byte-complete still gets the empty closing + delta, because ``final`` is what marks it retirable. + + :param step: The committed step being processed. + :param client: HTTP client for Omnigent event posts. + :param session_id: Omnigent conversation id to mirror into. + :param cascade_id: agy cascade id (namespaces the message id). + :param prefixes: Per-step forwarded-text trackers. + """ + idx = _step_index(step) + if idx is None: + return + text = _committed_planner_text(step) + if text is None: + return + forwarded = prefixes.get(idx) + if forwarded is None: + # Never streamed (committed straight from a poll frame): the committed + # item stands alone, so there is no live block to close. + return + suffix = text[len(forwarded) :] if text.startswith(forwarded) else text + await _post_event( + client, + session_id, + output_text_delta_event( + conversation_id=cascade_id, + step_idx=idx, + delta=suffix, + final=True, + # Byte offset of what was already forwarded: strictly greater than + # the previous chunk's index, which is what the server requires to + # accept this closer (and thus retire the message). + index=len(forwarded), + ), + ) + prefixes[idx] = text + + def _is_generating_planner(step: dict[str, object]) -> bool: """ Return whether a step is a PLANNER_RESPONSE still generating its text. @@ -1663,6 +2101,7 @@ async def _emit_partial_delta( step_idx=idx, delta=suffix, final=False, + index=len(forwarded), ), ) prefixes[idx] = text @@ -1735,7 +2174,6 @@ async def _emit_step( client: httpx.AsyncClient, session_id: str, cascade_id: str, - allocator: _ToolCallIdAllocator, turn_active: bool, ) -> bool: """ @@ -1750,7 +2188,6 @@ async def _emit_step( :param client: HTTP client for Omnigent event posts. :param session_id: Omnigent conversation id to mirror into. :param cascade_id: agy cascade id (namespaces response/call ids). - :param allocator: Per-run tool-call id allocator (fallback ids only). :param turn_active: Whether a turn is currently considered open on entry. :returns: The updated ``turn_active`` flag after this step. """ @@ -1758,7 +2195,7 @@ async def _emit_step( turn_active = True await _post_event(client, session_id, _status_event(_STATUS_RUNNING)) - for event in map_step_to_events(step, conversation_id=cascade_id, allocator=allocator): + for event in map_step_to_events(step, conversation_id=cascade_id): await _post_event(client, session_id, event) if _is_turn_close_step(step) and turn_active: diff --git a/omnigent/antigravity_native_rpc.py b/omnigent/antigravity_native_rpc.py index f7e5b2c22b..487a9500e5 100644 --- a/omnigent/antigravity_native_rpc.py +++ b/omnigent/antigravity_native_rpc.py @@ -26,8 +26,9 @@ ``verify=False``. * The ports are ephemeral and not configurable (``ANTIGRAVITY_SIDECAR_WEB_PORT`` is a sidecar-plugin no-op), so they are - discovered from the loopback socket table — ``lsof`` per agy pid, falling back - to ``/proc/net/tcp`` on hosts where ``lsof`` cannot attribute the socket. + discovered from the loopback socket table — psutil per agy pid (cross-platform + and already a hard dependency), falling back to ``lsof`` and then to + ``/proc/net/tcp`` on hosts where the socket cannot be attributed to a pid. * Ownership probe: ``POST .../GetConversationMetadata`` with REQUEST body ``{"conversationId": ""}`` returns HTTP 200 whose RESPONSE echoes that id at ``metadata.rootConversationId`` for a hosted conversation, and HTTP 500 @@ -65,6 +66,7 @@ from urllib.parse import urlparse import httpx +import psutil _logger = logging.getLogger(__name__) @@ -252,7 +254,9 @@ def _run_lsof_listen_ports(pid: int) -> str: Isolated as a seam so tests can stub the subprocess. A non-zero exit (e.g. the process is gone) or a missing ``lsof`` yields ``""`` rather than raising - — discovery treats "no ports" the same as "lsof unavailable". + — discovery treats "no ports" the same as "lsof unavailable". Only a + fallback: :func:`_pid_listen_ports` prefers psutil, so agy discovery does + not require ``lsof`` to be installed. :param pid: agy process id, e.g. ``72753``. :returns: ``lsof`` stdout, or ``""`` on any failure. @@ -271,6 +275,53 @@ def _run_lsof_listen_ports(pid: int) -> str: return completed.stdout +def _is_loopback_ip(host: str) -> bool: + """ + Whether *host* is a loopback literal (``127.0.0.1`` / ``::1``). + + :param host: The address a socket is bound to, e.g. ``"127.0.0.1"``. + :returns: ``True`` for loopback; ``False`` for anything else or unparseable. + """ + try: + return ipaddress.ip_address(host).is_loopback + except ValueError: + return False + + +def _pid_listen_ports(pid: int) -> list[int]: + """ + Return a pid's loopback TCP LISTEN ports, lowest first. + + agy advertises its ephemeral connect-RPC port nowhere, so omnigent has to + attribute the listener back to the process. psutil is the primary source: + it is already a hard dependency and works on Linux, macOS and Windows, so + discovery needs no external binary. ``lsof`` remains a fallback for hosts + where psutil cannot read the process (``AccessDenied``) but lsof can. + + Both blind — a restricted ``/proc`` where agy's listener is held in a + backend the agy process does not own as an fd — yields ``[]``, which callers + already treat as "cannot attribute" and handle without guessing a port. + + :param pid: The agy process id, e.g. ``72753``. + :returns: Sorted loopback LISTEN ports, or ``[]`` when none is attributable. + """ + try: + conns = psutil.Process(pid).net_connections(kind="tcp") + except (psutil.Error, OSError): + conns = None + if conns is not None: + ports = { + conn.laddr.port + for conn in conns + if conn.status == psutil.CONN_LISTEN + and conn.laddr + and _is_loopback_ip(getattr(conn.laddr, "ip", "")) + } + if ports: + return sorted(ports) + return _parse_loopback_listen_ports(_run_lsof_listen_ports(pid)) + + def _parse_loopback_listen_ports(lsof_output: str) -> list[int]: """ Parse ascending unique ``127.0.0.1`` LISTEN ports from ``lsof`` output. @@ -1055,7 +1106,7 @@ def discover_language_server_port(pid: int) -> int | None: no loopback listeners or none answer ``Heartbeat`` (e.g. agy has exited or has not finished binding). """ - ports = _parse_loopback_listen_ports(_run_lsof_listen_ports(pid)) + ports = _pid_listen_ports(pid) for port in ports: if _heartbeat_ok(port): _logger.debug("agy connect-RPC port resolved: pid=%s port=%s", pid, port) @@ -1330,10 +1381,17 @@ def resolve_cold_start_agy_rpc_port( (:func:`resolve_pane_agy_rpc_port_state`), distinguishing three outcomes: 1. **Pane present, our agy found, port resolved** → that scoped port. - 2. **Pane present, our agy found, port not resolvable** (e.g. restricted - ``/proc`` where lsof cannot attribute the listener) → the lowest candidate - (:func:`_candidate_agy_rpc_ports`). agy IS up in this pane, and the hosts - where this happens run one agy per pod, so the lone candidate is ours. + 2. **Pane present, our agy found, port not resolvable** → depends on WHY, via + :func:`_can_attribute_any_agy_port`. When lsof cannot attribute a port + for any agy (restricted ``/proc``, one agy per pod) the lone candidate is + ours → the lowest candidate. When lsof CAN attribute ports for other agy + processes, ours simply has not bound its listener yet (a cold-start fires + ~250ms after launch, while agy takes seconds to boot) → ``None``, keep + polling. Scanning in that window returns a FOREIGN agy and durably + cross-binds the session: the conversation is created inside another + session's agy, the reader then adopts that agy's active cascade, and the + user sees an existing conversation duplicated while their own agy is + orphaned (its replies never arrive). 3. **Pane present, NO agy found yet** (the CLI ``tmux_start_on_attach`` early-poll window: the pane is still the shell, agy not yet ``exec``-ed) → ``None``, so the caller keeps polling. It must NOT fall back to candidates @@ -1361,12 +1419,28 @@ def resolve_cold_start_agy_rpc_port( # NOT fall back to candidates, where a foreign agy could be the only # one and would cross-bind this session. return None - # state 2: our agy IS up but its port is not lsof-attributable (restricted - # /proc; one-agy-per-pod) — the candidate scan below is safe and necessary. - _logger.debug( - "agy cold-start: pane agy found for target=%s but its port is not " - "lsof-attributable; using the host-wide candidate scan (safe — agy is " - "up in this pane)", + # state 2: our agy IS up in the pane but has no attributable port. Two + # very different causes, and only one makes the candidate scan safe: + # + # * lsof CANNOT attribute for any agy (restricted /proc, one agy per + # pod) — the lone candidate is ours; scan. + # * lsof CAN attribute (it found a port for some other agy) — then our + # agy simply has not bound its listener yet, ~250ms into its boot. + # Scanning here returns a FOREIGN agy and durably cross-binds this + # session, so keep polling instead. + if _can_attribute_any_agy_port(): + _logger.info( + "agy cold-start: pane agy for target=%s has not bound its " + "connect-RPC port yet (lsof attributes ports for other agy " + "processes, so attribution works here); polling rather than " + "binding a foreign agy", + tmux_target, + ) + return None + _logger.warning( + "agy cold-start: pane agy found for target=%s but no source attributes a " + "port for ANY agy (restricted /proc); falling back to the host-wide " + "candidate scan — safe only while this host runs a single agy", tmux_target, ) candidates = _candidate_agy_rpc_ports() @@ -1374,7 +1448,7 @@ def resolve_cold_start_agy_rpc_port( return None port = candidates[0] if tmux_socket is None or tmux_target is None: - _logger.debug( + _logger.warning( "agy cold-start: no local tmux pane to scope to; using the lowest " "candidate connect-RPC port %s (single-agy hosts and remote runners " "are unaffected; a multi-agy host risks a wrong-agy bind)", @@ -1383,6 +1457,26 @@ def resolve_cold_start_agy_rpc_port( return port +def _can_attribute_any_agy_port() -> bool: + """ + Whether a listening port is attributable to ANY running agy process. + + The discriminator for the cold-start's state 2. Attribution + (:func:`_pid_listen_ports`) returning nothing for one agy is ambiguous — it + means either that attribution is impossible here (restricted ``/proc``, + where agy holds its listener in a backend the process does not own as an + fd) or simply that this agy has not + bound its listener yet. Asking whether attribution works for any OTHER agy + separates the two: if lsof can name a port for some agy, attribution works + on this host, so a missing port means still-booting. + + :returns: ``True`` when at least one running agy pid has an attributable + loopback LISTEN port. ``False`` when no agy is running, or when no + source can attribute a port to any of them. + """ + return any(_pid_listen_ports(pid) for pid in _list_agy_pids()) + + def _candidate_agy_rpc_ports() -> list[int]: """ Return every live agy connect-RPC port, validated by ``Heartbeat``. @@ -1411,7 +1505,7 @@ def _candidate_agy_rpc_ports() -> list[int]: agy_pids = _list_agy_pids() ports: set[int] = set() for pid in agy_pids: - ports.update(_parse_loopback_listen_ports(_run_lsof_listen_ports(pid))) + ports.update(_pid_listen_ports(pid)) if agy_pids and not ports: loopback = _list_loopback_listen_ports() if len(loopback) > _MAX_FALLBACK_PROBE_PORTS: diff --git a/omnigent/antigravity_native_steps.py b/omnigent/antigravity_native_steps.py index 4cb481fa31..c88fe2e1bb 100644 --- a/omnigent/antigravity_native_steps.py +++ b/omnigent/antigravity_native_steps.py @@ -1,11 +1,10 @@ """Pure step→item mapper for the native Antigravity (agy) RPC stream. This module is the RPC-based read path's mapper, and (since the Task 12 cutover) -the home of the shared event types it produces: :class:`OutboundEvent`, the -:class:`_ToolCallIdAllocator`, and the ``_AGENT_NAME`` / ``_TOOL_ARG_DISPLAY_KEYS`` -constants. These were relocated here from the retired transcript forwarder; the -RPC read driver (:mod:`omnigent.antigravity_native_reader`) imports them from -this module. +the home of the shared event types it produces: :class:`OutboundEvent` and the +``_AGENT_NAME`` / ``_TOOL_ARG_DISPLAY_KEYS`` constants. These were relocated here +from the retired transcript forwarder; the RPC read driver +(:mod:`omnigent.antigravity_native_reader`) imports them from this module. Key differences from the retired transcript-based ``step_to_events`` mapper: @@ -28,14 +27,20 @@ and ``argumentsJson`` (a JSON string) instead of the transcript's flat ``type``, ``content``, and ``tool_calls[].args`` (a dict). -4. **Real agy tool-call ids.** The RPC carries a stable, agy-assigned id on - both the invocation (``plannerResponse.toolCalls[].id``) and the result - (``metadata.toolCall.id``). The mapper uses those ids directly so - ``function_call`` / ``function_call_output`` pairs are keyed by the real - shared id (order-independent), not by FIFO position. The - :class:`_ToolCallIdAllocator` is retained as a fallback only for the - resume-mid-turn case where a result step lacks the ``metadata.toolCall.id`` - field. +4. **The result step is the sole source of a tool-call pair.** agy serves the + same step at two fidelities: ``GetCascadeTrajectorySteps`` (the poll path) + carries ``metadata.toolCall`` and ``plannerResponse.toolCalls``, while the + live ``StreamAgentStateUpdates`` projection strips both — each embeds a + ``thinkingSignature`` blob, and the typed body (``runCommand`` / + ``viewFile`` / …) already describes the call. Keying the pair on the + invocation therefore lost every streamed tool call. + + So both the ``function_call`` and its ``function_call_output`` are derived + from the RESULT step, which both shapes deliver in full: its + ``sourceTrajectoryStepInfo`` supplies a stable ``(trajectory, step)`` call + id, its typed body the arguments and the output. The planner's + ``toolCalls`` is never mirrored, so a stream→poll fallback cannot re-key a + pair mid-conversation. :func:`map_step_to_events` is the public API; all other symbols are private. """ @@ -44,7 +49,7 @@ import json import logging -from dataclasses import dataclass, field +from dataclasses import dataclass from typing import Literal, TypedDict _logger = logging.getLogger(__name__) @@ -85,70 +90,11 @@ class OutboundEvent: step_index: int -@dataclass -class _ToolCallIdAllocator: - """ - Correlate agy tool invocations with their following result steps (fallback). - - Relocated here (Task 12 cutover) from the retired transcript forwarder. The - RPC read path prefers the real agy-assigned ``id`` on both the invocation and - the result, so this positional allocator is used only as a fallback for the - resume-mid-turn case where a result step lacks ``metadata.toolCall.id``. - - The pairing is FIFO: the oldest still-unmatched invocation owns the next - result. Ids are positional (``agy_call__``) and the - invocation counter only advances when an invocation is actually emitted, so - replaying the same step prefix reproduces identical ids and pairings — which - is what dedup needs across a restart. - - A result with no pending invocation (e.g. a transcript that begins mid-turn - on resume) gets its own standalone id so it is never silently dropped. - - :param conversation_id: agy conversation id used to namespace ids, e.g. - ``"8ca97c49-..."``. - :param invocation_count: Number of invocation ids minted so far. - :param orphan_output_count: Number of standalone (unpaired) output ids - minted so far. - :param pending_call_ids: Invocation ids awaiting their result step, oldest - first. - """ - - conversation_id: str - invocation_count: int = 0 - orphan_output_count: int = 0 - pending_call_ids: list[str] = field(default_factory=list) - - def claim_call_id(self) -> str: - """ - Mint and enqueue a call id for one tool invocation. - - :returns: Stable invocation call id, e.g. ``"agy_call_8ca97c49_0"``. - """ - call_id = f"agy_call_{self.conversation_id}_{self.invocation_count}" - self.invocation_count += 1 - self.pending_call_ids.append(call_id) - return call_id - - def match_output_id(self) -> str: - """ - Return the call id for the next tool result, pairing FIFO. - - :returns: The oldest pending invocation's call id, or a fresh standalone - id (``agy_call__orphan_``) when none is pending. - """ - if self.pending_call_ids: - return self.pending_call_ids.pop(0) - call_id = f"agy_call_{self.conversation_id}_orphan_{self.orphan_output_count}" - self.orphan_output_count += 1 - return call_id - - # RPC step type constants (CORTEX_STEP_TYPE_* enum values). _TYPE_USER_INPUT = "CORTEX_STEP_TYPE_USER_INPUT" _TYPE_PLANNER_RESPONSE = "CORTEX_STEP_TYPE_PLANNER_RESPONSE" _TYPE_RUN_COMMAND = "CORTEX_STEP_TYPE_RUN_COMMAND" _TYPE_LIST_DIRECTORY = "CORTEX_STEP_TYPE_LIST_DIRECTORY" -_TYPE_ASK_QUESTION = "CORTEX_STEP_TYPE_ASK_QUESTION" # RPC step status constants (CORTEX_STEP_STATUS_* enum values). _STATUS_DONE = "CORTEX_STEP_STATUS_DONE" @@ -423,39 +369,187 @@ def _strip_tool_display_args(args: dict[str, object]) -> dict[str, object]: return {key: val for key, val in args.items() if key not in _TOOL_ARG_DISPLAY_KEYS} -def _real_call_id(entry: dict[str, object]) -> str | None: +def _tool_call_block(step: dict[str, object]) -> dict[str, object] | None: """ - Extract the agy-assigned tool-call id from an invocation entry. + Return a step's ``metadata.toolCall`` block, or ``None``. - The RPC carries a stable id in ``plannerResponse.toolCalls[].id``; using - it directly makes the invocation↔output pairing order-independent (both - ends share the same id) rather than relying on FIFO position. + Present on every tool step in the poll snapshot, but on the live stream only + for ``GENERIC`` steps — the stream strips it wherever the typed body already + describes the call (see the module docstring). So it is a bonus source of + agy's own tool name and argument JSON, never a requirement. - :param entry: One ``plannerResponse.toolCalls[]`` dict. - :returns: The id string, or ``None`` when absent. + :param step: One step dict from either RPC shape. + :returns: The ``toolCall`` dict, or ``None`` when absent. """ - cid = entry.get("id") - return cid if isinstance(cid, str) and cid else None + metadata = step.get("metadata") + if not isinstance(metadata, dict): + return None + tool_call = metadata.get("toolCall") + return tool_call if isinstance(tool_call, dict) else None -def _result_call_id(step: dict[str, object]) -> str | None: +def _is_tool_step(step: dict[str, object]) -> bool: """ - Extract the agy-assigned tool-call id from a tool-result step. + Return whether a step is a tool invocation (as opposed to system noise). - The RPC carries the id at ``metadata.toolCall.id``; it matches the id on - the invocation step so the pair can be correlated without FIFO ordering. + ``metadata.toolAction`` — agy's human summary of what the tool is doing — + is set on exactly the tool steps and on no other step type, in both RPC + shapes and across agy versions. Classifying on it rather than on an + enumerated type list means a tool type this mapper has never seen still + reaches the web UI, where the previous ``toolCall.id`` test dropped it + silently. - :param step: A tool-result step dict (RUN_COMMAND, LIST_DIRECTORY, etc.). - :returns: The id string, or ``None`` when absent. + :param step: One step dict from ``GetCascadeTrajectorySteps`` or the stream. + :returns: ``True`` when the step represents a tool call. """ metadata = step.get("metadata") - if not isinstance(metadata, dict): - return None - tool_call = metadata.get("toolCall") - if not isinstance(tool_call, dict): - return None - cid = tool_call.get("id") - return cid if isinstance(cid, str) and cid else None + if isinstance(metadata, dict) and isinstance(metadata.get("toolAction"), str): + return True + tool_call = _tool_call_block(step) + return bool(tool_call and tool_call.get("id")) + + +def _tool_call_id(step: dict[str, object], conversation_id: str) -> str: + """ + Build the pairing id for one tool step, from the step's own identity. + + ``(trajectoryId, stepIndex)`` addresses a step uniquely and identically in + both RPC shapes, so the ``function_call`` and its output pair under one id + however the reader observed them — and re-derive the same id on a replay + after a restart. agy's own ``toolCall.id`` is deliberately NOT used: it is + absent from streamed steps, so keying on it would make the id depend on + which RPC delivered the step. + + :param step: The tool step. + :param conversation_id: agy conversation id, used when the step carries no + trajectory id. + :returns: Pairing id, e.g. ``"agy_call_efb134b2-…_6"``. + """ + traj_info = _source_traj_info(step) or {} + trajectory_id = traj_info.get("trajectoryId") + owner = trajectory_id if isinstance(trajectory_id, str) and trajectory_id else conversation_id + step_idx = _step_index(step) + return f"agy_call_{owner}_{step_idx if step_idx is not None else 0}" + + +def _tool_body_key(step_type: str) -> str: + """ + Derive the typed body key a step type carries its call/result under. + + agy names the body in lowerCamelCase after the type suffix: + ``CORTEX_STEP_TYPE_RUN_COMMAND`` → ``runCommand``, + ``CORTEX_STEP_TYPE_LIST_DIRECTORY`` → ``listDirectory``. Derived rather than + tabulated so an unseen tool type resolves too. + + :param step_type: The step's ``CORTEX_STEP_TYPE_*`` string. + :returns: The body key, e.g. ``"runCommand"``. + """ + words = step_type.removeprefix("CORTEX_STEP_TYPE_").split("_") + return "".join( + word.lower() if index == 0 else word.capitalize() for index, word in enumerate(words) + ) + + +def _tool_body(step: dict[str, object], step_type: str) -> dict[str, object] | None: + """ + Return a tool step's typed body (``runCommand``, ``viewFile``, …). + + :param step: The tool step. + :param step_type: The step's ``CORTEX_STEP_TYPE_*`` string. + :returns: The body dict, or ``None`` when absent. + """ + body = step.get(_tool_body_key(step_type)) + return body if isinstance(body, dict) else None + + +def _tool_name(step: dict[str, object], step_type: str) -> str: + """ + Name the tool a step invoked. + + Prefers agy's own ``metadata.toolCall.name`` when the shape carries it; + otherwise derives the name from the step type, which matches agy's naming + for every observed type except ``LIST_DIRECTORY`` (agy calls it + ``list_dir``), so that one is aliased. + + :param step: The tool step. + :param step_type: The step's ``CORTEX_STEP_TYPE_*`` string. + :returns: Tool name for the mirrored ``function_call`` item. + """ + tool_call = _tool_call_block(step) + if tool_call is not None: + name = tool_call.get("name") + if isinstance(name, str) and name: + return name + if step_type == _TYPE_LIST_DIRECTORY: + return "list_dir" + return step_type.removeprefix("CORTEX_STEP_TYPE_").lower() + + +def _arguments_from_body(step: dict[str, object], body: dict[str, object]) -> dict[str, object]: + """ + Recover a streamed step's call arguments from its typed body. + + ``metadata.argumentsOrder`` names the arguments the model passed + (``["CommandLine", "Cwd", …]``) while the body holds their executed values + under lowerCamelCase — sometimes suffixed (``AbsolutePath`` → + ``absolutePathUri``), hence the prefix match. Matching on that list is what + keeps result fields (``combinedOutput``, ``results``) out of the arguments. + + :param step: The tool step (read for ``metadata.argumentsOrder``). + :param body: The step's typed body. + :returns: Argument name → value, empty when nothing matched. + """ + metadata = step.get("metadata") + order = metadata.get("argumentsOrder") if isinstance(metadata, dict) else None + if not isinstance(order, list): + return {} + body_keys = {key.lower(): key for key in body} + args: dict[str, object] = {} + for name in order: + if not isinstance(name, str) or name in _TOOL_ARG_DISPLAY_KEYS: + continue + wanted = name.lower() + match = next( + (key for lowered, key in body_keys.items() if lowered.startswith(wanted)), None + ) + if match is not None: + args[match] = body[match] + return args + + +def _tool_arguments(step: dict[str, object], step_type: str) -> dict[str, object]: + """ + Extract the arguments a tool step was invoked with. + + Three sources, in descending fidelity: agy's verbatim + ``metadata.toolCall.argumentsJson`` (poll shape and streamed ``GENERIC`` + steps); a ``generic`` body's ``args`` dict; else the typed body, narrowed to + the argument fields by :func:`_arguments_from_body`. + + :param step: The tool step. + :param step_type: The step's ``CORTEX_STEP_TYPE_*`` string. + :returns: Arguments dict with agy's display-only keys removed. + """ + tool_call = _tool_call_block(step) + if tool_call is not None: + raw = tool_call.get("argumentsJson") + if isinstance(raw, str): + try: + parsed: object = json.loads(raw) + except json.JSONDecodeError: + _logger.warning( + "agy RPC tool step argumentsJson not valid JSON: type=%s", step_type + ) + parsed = None + if isinstance(parsed, dict): + return _strip_tool_display_args(parsed) + body = _tool_body(step, step_type) + if body is None: + return {} + args = body.get("args") + if isinstance(args, dict): + return _strip_tool_display_args(args) + return _arguments_from_body(step, body) def planner_message_id(conversation_id: str, step_idx: int) -> str: @@ -482,6 +576,7 @@ def output_text_delta_event( step_idx: int, delta: str, final: bool, + index: int, ) -> OutboundEvent: """ Build an incremental assistant ``output_text_delta`` for a planner step. @@ -502,6 +597,14 @@ def output_text_delta_event( :param final: ``True`` only on a terminal delta for the message; the streaming reader emits incremental deltas with ``False`` and relies on the committed ``message`` (not a ``final`` delta) to close the block. + :param index: Chunk ordinal within the message, STRICTLY INCREASING across + the message's deltas. The server's in-flight buffer + (:mod:`omnigent.runtime.inflight_text`) drops any chunk whose index does + not exceed the last one it accepted, so a constant value silences every + delta after the first — the message then keeps a truncated buffer, never + sees its ``final`` chunk, and is replayed to every later subscriber as a + cut-off duplicate. The reader passes the byte offset of the text already + forwarded, which is monotonic by construction. :returns: One ``external_output_text_delta`` event. """ return OutboundEvent( @@ -509,7 +612,7 @@ def output_text_delta_event( data={ "delta": delta, "message_id": planner_message_id(conversation_id, step_idx), - "index": 0, + "index": index, "final": final, }, step_index=step_idx, @@ -694,83 +797,44 @@ def _planner_error_event( ) -def _function_call_events( +def _function_call_event( *, conversation_id: str, step_idx: int, - tool_calls: list[object], - allocator: _ToolCallIdAllocator, -) -> list[OutboundEvent]: + call_id: str, + name: str, + arguments: dict[str, object], +) -> OutboundEvent: """ - Build ``function_call`` items for a PLANNER_RESPONSE's tool calls. + Build the ``function_call`` item mirroring one agy tool invocation. - The RPC ``toolCalls`` entries carry ``id``, ``name``, and ``argumentsJson`` - (a JSON string). The real agy ``id`` is used as the ``call_id`` directly - so the output step can pair by the same id without FIFO ordering. The - allocator is used as a fallback only when the ``id`` field is absent (e.g. - a resume-mid-turn snapshot that pre-dates the id field). - - ``argumentsJson`` is parsed to a dict and display keys are stripped before - re-serializing as the canonical arguments text. + Built from the tool step itself rather than from the planner that requested + it, because the live stream omits ``plannerResponse.toolCalls`` entirely + (module docstring, item 4). :param conversation_id: agy conversation id. - :param step_idx: Owning step index. - :param tool_calls: ``plannerResponse.toolCalls`` list. - :param allocator: Fallback call-id allocator when real id is absent. - :returns: One ``external_conversation_item`` event per valid tool call. - """ - response_id = _response_id(conversation_id, step_idx) - events: list[OutboundEvent] = [] - for entry in tool_calls: - if not isinstance(entry, dict): - continue - name = entry.get("name") - if not isinstance(name, str) or not name: - _logger.warning("agy RPC tool_call missing name: step_idx=%s", step_idx) - continue - raw_args_json = entry.get("argumentsJson") - if isinstance(raw_args_json, str): - try: - raw_args: object = json.loads(raw_args_json) - except json.JSONDecodeError: - _logger.warning( - "agy RPC tool_call argumentsJson not valid JSON: step_idx=%s name=%s", - step_idx, - name, - ) - continue - else: - raw_args = {} - args = raw_args if isinstance(raw_args, dict) else {} - arguments_text = _json_string(_strip_tool_display_args(args)) - if arguments_text is None: - _logger.warning( - "agy RPC tool_call args not JSON serializable: step_idx=%s name=%s", - step_idx, - name, - ) - continue - # Prefer the real agy-assigned id; fall back to the allocator only - # when absent (resume-mid-turn case). - real_id = _real_call_id(entry) - call_id = real_id if real_id is not None else allocator.claim_call_id() - events.append( - OutboundEvent( - event_type="external_conversation_item", - data={ - "item_type": "function_call", - "item_data": { - "agent": _AGENT_NAME, - "name": name, - "arguments": arguments_text, - "call_id": call_id, - }, - "response_id": response_id, - }, - step_index=step_idx, - ) - ) - return events + :param step_idx: Tool step index. + :param call_id: Pairing id shared with the step's output item. + :param name: Tool name. + :param arguments: Invocation arguments. + :returns: One ``external_conversation_item`` event. + """ + return OutboundEvent( + event_type="external_conversation_item", + data={ + "item_type": "function_call", + "item_data": { + "agent": _AGENT_NAME, + "name": name, + # An unserializable argument must not cost the whole tool card: + # the pair still renders, with the arguments omitted. + "arguments": _json_string(arguments) or "{}", + "call_id": call_id, + }, + "response_id": _response_id(conversation_id, step_idx), + }, + step_index=step_idx, + ) def _function_call_output_event( @@ -778,24 +842,17 @@ def _function_call_output_event( conversation_id: str, step_idx: int, output: str, - real_id: str | None, - allocator: _ToolCallIdAllocator, + call_id: str, ) -> OutboundEvent: """ Build a ``function_call_output`` item for one completed agy tool step. - Prefers the real agy ``metadata.toolCall.id`` for pairing; falls back to - the allocator's FIFO match only when the id is absent. - :param conversation_id: agy conversation id. :param step_idx: Tool-result step index. :param output: Human-readable tool result text. - :param real_id: agy-assigned call id from ``metadata.toolCall.id``, or - ``None`` when absent. - :param allocator: Fallback call-id correlator when real id is absent. + :param call_id: Pairing id shared with the step's invocation item. :returns: One ``external_conversation_item`` event. """ - call_id = real_id if real_id is not None else allocator.match_output_id() return OutboundEvent( event_type="external_conversation_item", data={ @@ -836,17 +893,15 @@ def _tool_result_output(step: dict[str, object], step_type: str) -> str | None: """ if step_type == _TYPE_RUN_COMMAND: return _run_command_output(step) - if step_type == _TYPE_LIST_DIRECTORY: - list_dir = step.get("listDirectory") - if not isinstance(list_dir, dict): - return None - return _json_string(list_dir) - if step_type == _TYPE_ASK_QUESTION: - ask = step.get("askQuestion") - if not isinstance(ask, dict): - return None - return _json_string(ask) - return None + body = _tool_body(step, step_type) + if body is None: + return None + # A ``generic`` step nests its payload under ``result``; every other typed + # body IS the result (agy folds the arguments in alongside it). + result = body.get("result") + if isinstance(result, dict): + return _json_string(result) + return _json_string(body) def _tool_error_output(step: dict[str, object]) -> str: @@ -877,7 +932,6 @@ def map_step_to_events( step: dict[str, object], *, conversation_id: str, - allocator: _ToolCallIdAllocator, ) -> list[OutboundEvent]: """ Map one agy RPC step to Omnigent conversation-item events. @@ -898,8 +952,8 @@ def map_step_to_events( this commits exactly once. An empty user turn → ``[]``. * ``CORTEX_STEP_TYPE_PLANNER_RESPONSE`` **at status DONE** → one ``message`` item (role assistant) when ``plannerResponse.modifiedResponse`` (or - ``response``) is non-empty, then one ``function_call`` item per - ``plannerResponse.toolCalls`` entry. A non-DONE (GENERATING) planner → ``[]`` + ``response``) is non-empty. Its ``toolCalls`` are NOT mirrored — the tool + step owns that pair. A non-DONE (GENERATING) planner → ``[]`` here; its partial text is conveyed only via the streaming reader's ``output_text_delta`` events, so committing a message pre-DONE would double-render (and double-post on the poll path). **No ``output_text_delta`` @@ -907,31 +961,27 @@ def map_step_to_events( fix). ``modifiedResponse`` takes precedence over ``response`` because it is the post-moderation text (both fields present in the live DONE fixtures; they are equal when no moderation occurred). - * A tool-result step — a known result type - (``CORTEX_STEP_TYPE_RUN_COMMAND`` / ``LIST_DIRECTORY`` / ``ASK_QUESTION``) - OR any step carrying a ``metadata.toolCall.id`` (generically catches - result types with no dedicated extractor, e.g. VIEW_FILE / CODE_ACTION) — - → one ``function_call_output`` keyed on that id once it reaches a terminal - status. The text is the type-specific extractor's output, an error marker - on ERROR, or an empty string when neither is available. WAITING steps → - ``[]`` (no result yet; Task 5 extracts the pending interaction). Closing - EVERY tool call this way is required because the invocation side emits a - ``function_call`` for every tool unconditionally, and an unpaired one - strands a perpetual in-progress card. - * Any other step type (CHECKPOINT, CONVERSATION_HISTORY, unrecognized - system steps with no ``toolCall.id``) → ``[]`` (system noise; no - conversation content). + * A tool step (:func:`_is_tool_step`) at terminal status → BOTH its + ``function_call`` and the matching ``function_call_output``, sharing the + step-derived :func:`_tool_call_id`. Emitting the pair together is what + guarantees no half-rendered tool card: an invocation is never posted + without its result, nor a result without its invocation. The output text + is the type-specific extractor's, an error marker on ERROR, or an empty + string when neither is available. Non-terminal (WAITING / RUNNING) → + ``[]`` (no result yet; Task 5 extracts the pending interaction). + * Any other step type (CHECKPOINT, CONVERSATION_HISTORY, SYSTEM_MESSAGE, + unrecognized system steps) → ``[]`` (system noise; no conversation + content). Step-index handling: ``sourceTrajectoryStepInfo.stepIndex`` is proto-omitted when zero. A missing index is treated as ``0`` so slot-0 steps (which in practice are the turn-opening USER_INPUT, committed as a ``message`` item) are never silently dropped. - :param step: One step dict from ``GetCascadeTrajectorySteps``. + :param step: One step dict from ``GetCascadeTrajectorySteps`` or from a + ``StreamAgentStateUpdates`` frame. :param conversation_id: agy conversation id (namespaces response ids and call ids). - :param allocator: Fallback tool-call id allocator, used only when a step - lacks the real agy ``id`` field (resume-mid-turn case). :returns: Ordered events to POST for this step (possibly empty). """ step_type = step.get("type") @@ -1012,76 +1062,50 @@ def map_step_to_events( text=planner_text, ) ) - tool_calls = planner.get("toolCalls") - if isinstance(tool_calls, list) and tool_calls: - events.extend( - _function_call_events( - conversation_id=conversation_id, - step_idx=step_idx, - tool_calls=tool_calls, - allocator=allocator, - ) - ) return events - # Tool-result steps. The invocation side (_function_call_events) emits a - # function_call for EVERY entry in plannerResponse.toolCalls regardless of - # tool name, so the result side MUST close every one of them — the reader is - # the SOLE completion signal and the server pairs strictly by call_id, so a - # function_call with no paired function_call_output strands a perpetual - # in-progress tool card and breaks transcript reconstruction. + # Tool steps. BOTH items are emitted here, from this one step: the reader is + # the SOLE completion signal and the server pairs strictly by call_id, so an + # invocation without its output strands a perpetual in-progress tool card, + # and an output without its invocation renders a naked result blob. # - # A step is a tool result iff it is one of the known result types OR agy - # stamped a ``metadata.toolCall.id`` on it (the id that pairs with the - # invocation). The latter generically catches result types this mapper has - # no extractor for (e.g. VIEW_FILE / CODE_ACTION on agy 1.0.10) — system - # steps (CHECKPOINT / CONVERSATION_HISTORY / USER_INPUT) carry no toolCall.id - # and so are NOT treated as tool results. + # The planner step that requested the tool is NOT the source. Its + # ``toolCalls`` is absent from every streamed step (module docstring, item + # 4), which cost the web UI every tool card agy has ever produced. # # Status handling: # * Non-terminal (WAITING / RUNNING / PENDING / GENERATING) → [] — no # result yet (for WAITING the interaction bridge surfaces the prompt; - # the step's later terminal transition closes the call). - # * terminal (DONE / ERROR) → exactly one function_call_output keyed on - # the toolCall.id, with best-effort text: + # the step's later terminal transition emits the pair). + # * terminal (DONE / ERROR) → the pair, with best-effort output text: # - the type-specific extractor when it yields text; # - an explicit error marker on ERROR; # - else an empty string (e.g. a successful RUN_COMMAND whose - # combinedOutput proto-omitted empty output, or an unmapped result - # type) — empty-but-paired beats a dangling call. - result_call_id = _result_call_id(step) - is_known_tool_result = step_type in ( - _TYPE_RUN_COMMAND, - _TYPE_LIST_DIRECTORY, - _TYPE_ASK_QUESTION, - ) - if is_known_tool_result or result_call_id is not None: + # combinedOutput proto-omitted empty output) — an empty result + # beats a dangling call. + if _is_tool_step(step): if status not in (_STATUS_DONE, _STATUS_ERROR): return [] idx = _step_index(step) step_idx = idx if idx is not None else 0 + call_id = _tool_call_id(step, conversation_id) output = _tool_result_output(step, step_type) if output is None: - if status == _STATUS_ERROR: - output = _tool_error_output(step) - else: - output = "" - if not is_known_tool_result: - _logger.warning( - "agy RPC tool-result step has no extractor; closing the " - "call with empty output: type=%s status=%s call_id=%s", - step_type, - status, - result_call_id, - ) + output = _tool_error_output(step) if status == _STATUS_ERROR else "" return [ + _function_call_event( + conversation_id=conversation_id, + step_idx=step_idx, + call_id=call_id, + name=_tool_name(step, step_type), + arguments=_tool_arguments(step, step_type), + ), _function_call_output_event( conversation_id=conversation_id, step_idx=step_idx, output=output, - real_id=result_call_id, - allocator=allocator, - ) + call_id=call_id, + ), ] # CHECKPOINT / CONVERSATION_HISTORY / unrecognized system steps → skip. diff --git a/omnigent/runner/native/orchestration.py b/omnigent/runner/native/orchestration.py index 459495fe22..083b7e2ddd 100644 --- a/omnigent/runner/native/orchestration.py +++ b/omnigent/runner/native/orchestration.py @@ -4491,6 +4491,43 @@ async def _agy_cold_start_poll_sleep(seconds: float) -> None: await asyncio.sleep(seconds) +# How long to wait for agy to write a cold-started conversation into this +# session's Gemini dir before judging it foreign. agy creates the db as part of +# ``StartCascade`` (observed same-second), so this only absorbs filesystem lag. +_AGY_CASCADE_OWNERSHIP_GRACE_S = 3.0 +_AGY_CASCADE_OWNERSHIP_POLL_S = 0.25 + + +async def _agy_cascade_is_locally_owned(bridge_dir: Path, cascade_id: str) -> bool: + """ + Whether *cascade_id* belongs to the agy running under THIS bridge dir. + + agy stores each conversation as + ``/antigravity-cli/conversations/.db``, and a ``StartCascade`` + lands in the store of whichever agy answered the port — so the presence of + that file in OUR Gemini dir is the ownership proof the cold-start cannot get + any other way (no conversation exists yet to check by id). + + Polls up to :data:`_AGY_CASCADE_OWNERSHIP_GRACE_S` so ordinary filesystem lag + is not mistaken for foreign ownership. + + :param bridge_dir: This session's native Antigravity bridge directory. + :param cascade_id: The conversation id just created by ``StartCascade``. + :returns: ``True`` when the conversation db exists in this session's Gemini + dir within the grace window. + """ + from omnigent.antigravity_native_bridge import agy_gemini_dir + + db = agy_gemini_dir(bridge_dir) / "antigravity-cli" / "conversations" / f"{cascade_id}.db" + deadline = time.monotonic() + _AGY_CASCADE_OWNERSHIP_GRACE_S + while True: + if await asyncio.to_thread(db.is_file): + return True + if time.monotonic() >= deadline: + return False + await _agy_cold_start_poll_sleep(_AGY_CASCADE_OWNERSHIP_POLL_S) + + async def _cold_start_agy_conversation( bridge_dir: Path, session_id: str, @@ -4611,6 +4648,23 @@ async def _cold_start_agy_conversation( exc_info=True, ) return None + # Confirm the cascade landed in THIS session's Gemini dir before adopting it. + # ``StartCascade`` against a foreign agy succeeds and returns an id, but that + # agy writes the conversation into its OWN ``--gemini_dir`` — persisting the + # id would durably cross-bind this session (the reader mirrors another + # session's conversation while our agy is orphaned). Belt-and-braces behind + # the port scoping in ``resolve_cold_start_agy_rpc_port``. + if not await _agy_cascade_is_locally_owned(bridge_dir, cascade_id): + _logger.warning( + "Antigravity cold-start: conversation %s created on port %s is NOT in this " + "session's Gemini dir — StartCascade hit a FOREIGN agy. Discarding it and " + "leaving the placeholder for session %s; the reader will bind our agy's own " + "conversation once a turn creates it.", + cascade_id, + port, + session_id, + ) + return None # Persist the real id (replacing the ``agy_conv_*`` placeholder) so # ``read_bridge_state`` returns it and the reader/executor address the # cold-started conversation. Offloaded (file I/O). diff --git a/omnigent/server/routes/_sessions/common.py b/omnigent/server/routes/_sessions/common.py index d61631b988..271c51d213 100644 --- a/omnigent/server/routes/_sessions/common.py +++ b/omnigent/server/routes/_sessions/common.py @@ -184,6 +184,32 @@ _CODEX_NATIVE_SUBAGENT_DISPLAY_FALLBACK = "Codex" +_EXTERNAL_ANTIGRAVITY_SUBAGENT_START_TYPE: str = "external_antigravity_subagent_start" + + +_ANTIGRAVITY_NATIVE_SUBAGENT_WRAPPER_LABEL_VALUE = "antigravity-native-ui-subagent" + + +_ANTIGRAVITY_NATIVE_SUBAGENT_CASCADE_ID_LABEL_KEY = ( + "omnigent.antigravity_native.subagent_cascade_id" +) + + +_ANTIGRAVITY_NATIVE_SUBAGENT_TOOL_CALL_ID_LABEL_KEY = "omnigent.antigravity_native.tool_call_id" + + +_ANTIGRAVITY_NATIVE_SUBAGENT_ROLE_LABEL_KEY = "omnigent.antigravity_native.agent_role" + + +_ANTIGRAVITY_NATIVE_SUBAGENT_TYPE_LABEL_KEY = "omnigent.antigravity_native.agent_type" + + +# Title head for a child whose ``subagentSpec`` named no role. agy always sends +# one in practice; this only keeps the ``":"`` title parseable (the +# Agents rail splits on the first colon) if it ever stops. +_ANTIGRAVITY_NATIVE_SUBAGENT_DISPLAY_FALLBACK = "subagent" + + _LAST_CONTEXT_TOKENS_LABEL_KEY: str = "omnigent.last_context_tokens" @@ -370,6 +396,7 @@ _EXTERNAL_SESSION_TODOS_TYPE, _EXTERNAL_SUBAGENT_START_TYPE, _EXTERNAL_CODEX_SUBAGENT_START_TYPE, + _EXTERNAL_ANTIGRAVITY_SUBAGENT_START_TYPE, _EXTERNAL_CODEX_COLLABORATION_MODE_CHANGE_TYPE, _EXTERNAL_CODEX_APPROVAL_MODE_CHANGE_TYPE, } @@ -713,6 +740,12 @@ def get_server_host_registry() -> HostRegistry | None: "COST_CONTROL_OVERRIDE_VALUES", "_ALLOWED_EVENT_TYPES", "_ANTIGRAVITY_NATIVE_ELICITATION_HOOK_TIMEOUT_S", + "_ANTIGRAVITY_NATIVE_SUBAGENT_CASCADE_ID_LABEL_KEY", + "_ANTIGRAVITY_NATIVE_SUBAGENT_DISPLAY_FALLBACK", + "_ANTIGRAVITY_NATIVE_SUBAGENT_ROLE_LABEL_KEY", + "_ANTIGRAVITY_NATIVE_SUBAGENT_TOOL_CALL_ID_LABEL_KEY", + "_ANTIGRAVITY_NATIVE_SUBAGENT_TYPE_LABEL_KEY", + "_ANTIGRAVITY_NATIVE_SUBAGENT_WRAPPER_LABEL_VALUE", "_APPROVAL_TYPE", "_BROWSER_ACTION_AWAIT_S", "_BROWSER_ACTION_TIMEOUT_RESULT", @@ -753,6 +786,7 @@ def get_server_host_registry() -> HostRegistry | None: "_CURSOR_NATIVE_WRAPPER_LABEL_VALUE", "_DENY_SENTINEL_PREFIX", "_EVALUATE_HOOK_ELICITATION_ID_RE", + "_EXTERNAL_ANTIGRAVITY_SUBAGENT_START_TYPE", "_EXTERNAL_ASSISTANT_MESSAGE_TYPE", "_EXTERNAL_CODEX_APPROVAL_MODE_CHANGE_TYPE", "_EXTERNAL_CODEX_COLLABORATION_MODE_CHANGE_TYPE", diff --git a/omnigent/server/routes/_sessions/helpers.py b/omnigent/server/routes/_sessions/helpers.py index ebc7122c98..a21c9663c1 100644 --- a/omnigent/server/routes/_sessions/helpers.py +++ b/omnigent/server/routes/_sessions/helpers.py @@ -2902,6 +2902,105 @@ async def _persist_external_subagent_start( return child.id +def _antigravity_subagent_title(role: str, cascade_id: str) -> str: + """ + Build the child row title for an agy sub-agent. + + ``":"``. The Agents rail splits a child title on its FIRST + colon into ``tool`` / ``session_name``, so this renders the role as the agent + name and the cascade id as the correlation handle with no rail-side special + case — unlike claude/codex children, which need a display helper each. Any + colon inside the role is folded to a dash so that split lands where intended. + + The cascade id is agy's own stable per-sub-agent id, which also makes the + title the idempotency key: a redelivered start trips the + ``(parent_conversation_id, title)`` unique index instead of minting a second + row for the same sub-agent. + + :param role: agy ``subagentSpec`` role, e.g. ``"App Router Code Reviewer"``. + :param cascade_id: agy child conversation id, e.g. ``"1eca7625-…"``. + :returns: Child conversation title. + """ + head = role.replace(":", "-").strip() or _ANTIGRAVITY_NATIVE_SUBAGENT_DISPLAY_FALLBACK + return f"{head}:{cascade_id}" + + +def _antigravity_subagent_labels_from_body( + cascade_id: str, + body: SessionEventInput, +) -> dict[str, str]: + """ + Build the label dict for an agy sub-agent child row. + + :param cascade_id: agy child conversation id, e.g. ``"1eca7625-…"``. + :param body: Validated ``external_antigravity_subagent_start`` event body. + :returns: Labels to upsert on the child conversation row. + """ + labels: dict[str, str] = { + _CLAUDE_NATIVE_WRAPPER_LABEL_KEY: _ANTIGRAVITY_NATIVE_SUBAGENT_WRAPPER_LABEL_VALUE, + _ANTIGRAVITY_NATIVE_SUBAGENT_CASCADE_ID_LABEL_KEY: cascade_id, + } + for data_key, label_key in ( + ("tool_call_id", _ANTIGRAVITY_NATIVE_SUBAGENT_TOOL_CALL_ID_LABEL_KEY), + ("role", _ANTIGRAVITY_NATIVE_SUBAGENT_ROLE_LABEL_KEY), + ("agent_type", _ANTIGRAVITY_NATIVE_SUBAGENT_TYPE_LABEL_KEY), + ): + value = body.data.get(data_key) + if isinstance(value, str) and value: + labels[label_key] = value + return labels + + +async def _create_and_publish_antigravity_child( + parent_id: str, + parent_conv: Conversation, + title: str, + agent_type: str, + labels: dict[str, str], + conversation_store: ConversationStore, +) -> str: + """ + Create an agy sub-agent child row and publish ``session.created``. + + :param parent_id: Parent antigravity-native conversation id. + :param parent_conv: Parent row whose ``agent_id`` / ``runner_id`` the child + inherits. + :param title: Child title from :func:`_antigravity_subagent_title`. + :param agent_type: agy ``subagentSpec`` type, e.g. ``"research"``. + :param labels: Labels to stamp on the new child row. + :param conversation_store: Store used to create the child row. + :returns: New child conversation id, e.g. ``"conv_child456"``. + """ + try: + child = await asyncio.to_thread( + conversation_store.create_conversation, + kind="sub_agent", + title=title, + parent_conversation_id=parent_id, + agent_id=parent_conv.agent_id, + runner_id=parent_conv.runner_id, + sub_agent_name=agent_type or _ANTIGRAVITY_NATIVE_SUBAGENT_DISPLAY_FALLBACK, + ) + except NameAlreadyExistsError: + # The (parent, title) unique index fired: a concurrent start, or a + # redelivery that arrived before ``set_labels`` ran. Adopt the row and + # upsert labels rather than 500ing the sub-agent out of the rail. + existing = await asyncio.to_thread( + _find_subagent_child_by_title, conversation_store, parent_id, title + ) + if existing is None: + raise + await asyncio.to_thread(conversation_store.set_labels, existing.id, labels) + # An orphaned row's creator died before publishing, so live clients have + # never heard about this child; a duplicate publish in the race case is a + # harmless extra cache invalidation. + _publish_session_created(parent_id, existing.id, parent_conv.agent_id) + return existing.id + await asyncio.to_thread(conversation_store.set_labels, child.id, labels) + _publish_session_created(parent_id, child.id, parent_conv.agent_id) + return child.id + + def _find_codex_native_subagent_child( conversation_store: ConversationStore, parent_id: str, @@ -8649,6 +8748,8 @@ async def _load_model_options_from_host(session_id: str, host_id: str) -> None: "_allow_remember_eligible", "_ancestor_session_ids", "_announce_session_added", + "_antigravity_subagent_labels_from_body", + "_antigravity_subagent_title", "_apply_liveness_to_items", "_apply_pending_policy_ask_writes", "_approval_access_from_grants", @@ -8672,6 +8773,7 @@ async def _load_model_options_from_host(session_id: str, host_id: str) -> None: "_collect_descendant_conversation_ids", "_compact_lock", "_consume_pre_resolved_harness_elicitation", + "_create_and_publish_antigravity_child", "_create_and_publish_codex_child", "_create_session_worktree", "_delete_stored_session_bundle_after_failure", diff --git a/omnigent/server/routes/_sessions/orchestration.py b/omnigent/server/routes/_sessions/orchestration.py index 411d6a5d41..437ed856e4 100644 --- a/omnigent/server/routes/_sessions/orchestration.py +++ b/omnigent/server/routes/_sessions/orchestration.py @@ -1658,6 +1658,69 @@ async def _hold_native_ask_gate_impl( return approved +async def _persist_external_antigravity_subagent_start( + parent_id: str, + parent_conv: Conversation, + body: SessionEventInput, + conversation_store: ConversationStore, +) -> str: + """ + Mint or update a child Conversation for an agy sub-agent. + + agy spawns each sub-agent as its own cascade and names them on the parent's + ``INVOKE_SUBAGENT`` step; the reader posts one of these per named child so + the Agents rail can show it. Unlike claude (which is discovered from an + on-disk ``subagents/`` directory) the child already has a stable id of its + own, so that id is both the label and the title's unique half. + + Idempotent: a redelivered start for the same ``cascade_id`` resolves to the + existing row and upserts labels rather than minting a duplicate. + + :param parent_id: Parent antigravity-native conversation id, e.g. + ``"conv_parent987"``. + :param parent_conv: Pre-fetched parent row. + :param body: POST event body. Required ``data.cascade_id``; optional + ``role``, ``agent_type``, ``tool_call_id``. + :param conversation_store: Store for reading/creating child rows. + :returns: Child conversation id, e.g. ``"conv_child456"``. + :raises OmnigentError: 400 if ``cascade_id`` is missing, or the parent has + no bound agent. + """ + cascade_id = body.data.get("cascade_id") + if not isinstance(cascade_id, str) or not cascade_id: + raise OmnigentError( + "external_antigravity_subagent_start requires non-empty data.cascade_id", + code=ErrorCode.INVALID_INPUT, + ) + if parent_conv.agent_id is None: + raise OmnigentError( + f"parent session {parent_id!r} has no agent_id; cannot " + "create an antigravity-native sub-agent child", + code=ErrorCode.INVALID_INPUT, + ) + role = body.data.get("role") + agent_type = body.data.get("agent_type") + title = _antigravity_subagent_title( + role if isinstance(role, str) else "", + cascade_id, + ) + labels = _antigravity_subagent_labels_from_body(cascade_id, body) + existing = await asyncio.to_thread( + _find_subagent_child_by_title, conversation_store, parent_id, title + ) + if existing is not None: + await asyncio.to_thread(conversation_store.set_labels, existing.id, labels) + return existing.id + return await _create_and_publish_antigravity_child( + parent_id, + parent_conv, + title, + agent_type if isinstance(agent_type, str) else "", + labels, + conversation_store, + ) + + async def _persist_external_codex_subagent_start( parent_id: str, parent_conv: Conversation, @@ -6787,6 +6850,7 @@ async def _get_session_snapshot( "_maybe_wake_stale_resumable_managed_sandbox", "_native_subagent_wrapper_labels", "_native_terminal_runtime", + "_persist_external_antigravity_subagent_start", "_persist_external_codex_subagent_start", "_persist_external_conversation_item", "_persist_external_session_usage", diff --git a/omnigent/server/routes/sessions/routes_events.py b/omnigent/server/routes/sessions/routes_events.py index b64087457f..f4e50b3245 100644 --- a/omnigent/server/routes/sessions/routes_events.py +++ b/omnigent/server/routes/sessions/routes_events.py @@ -294,6 +294,7 @@ async def post_event( _EXTERNAL_SESSION_TODOS_TYPE, _EXTERNAL_SUBAGENT_START_TYPE, _EXTERNAL_CODEX_SUBAGENT_START_TYPE, + _EXTERNAL_ANTIGRAVITY_SUBAGENT_START_TYPE, _EXTERNAL_CODEX_COLLABORATION_MODE_CHANGE_TYPE, _EXTERNAL_CODEX_APPROVAL_MODE_CHANGE_TYPE, ): @@ -956,6 +957,16 @@ async def post_event( # subsequent ``external_conversation_item`` / # ``external_session_status`` events to the child id. return {"queued": False, "child_session_id": child_id} + if body.type == _EXTERNAL_ANTIGRAVITY_SUBAGENT_START_TYPE: + child_id = await _persist_external_antigravity_subagent_start( + session_id, + conv, + body, + conversation_store, + ) + # Returned to the agy reader so it can mirror the child cascade's + # steps into this id. + return {"queued": False, "child_session_id": child_id} if body.type == _EXTERNAL_CODEX_SUBAGENT_START_TYPE: child_id = await _persist_external_codex_subagent_start( session_id, diff --git a/omnigent/spec/skill_sources.py b/omnigent/spec/skill_sources.py index 15b11fc2b0..2506a01d2b 100644 --- a/omnigent/spec/skill_sources.py +++ b/omnigent/spec/skill_sources.py @@ -25,7 +25,12 @@ _log = logging.getLogger(__name__) -_SKILL_FAMILIES = frozenset({"claude", "codex", "cursor", "pi"}) +_SKILL_FAMILIES = frozenset({"claude", "codex", "cursor", "pi", "antigravity"}) + +# The bare ``antigravity`` harness is the in-process Gemini SDK executor, NOT the +# agy CLI. It never launches agy, so ~/.gemini plugin/builtin skills are not its +# skills — it keeps the generic walk, whose skills omnigent resolves+injects. +_ANTIGRAVITY_SDK_HARNESS = "antigravity" def _harness_family(harness: str | None) -> str | None: @@ -49,9 +54,16 @@ def _harness_family(harness: str | None) -> str | None: # SDK harness flows in as ``claude_sdk`` (canonicalize_harness leaves it # unchanged), and without this it would miss the ``claude`` family and # silently lose plugin slash-commands. - parts = harness.replace("_", "-").split("-") + normalized = harness.replace("_", "-") + parts = normalized.split("-") base = parts[1] if parts[0] == "native" and len(parts) > 1 else parts[0] - return base if base in _SKILL_FAMILIES else None + if base not in _SKILL_FAMILIES: + return None + # Unlike claude (where SDK and native share ~/.claude), antigravity's two + # harnesses are different runtimes: only the agy CLI reads ~/.gemini. + if base == _ANTIGRAVITY_SDK_HARNESS and normalized == _ANTIGRAVITY_SDK_HARNESS: + return None + return base @dataclass(frozen=True) @@ -375,6 +387,115 @@ def cursor_host_skills(ctx: SkillSourceContext) -> list[SkillSpec]: return out +def _agy_skill_dirs(ctx: SkillSourceContext) -> list[tuple[Path, str | None]]: + """ + agy's skill directories with their namespace, in discovery order. + + The five sources agy's own ``/skills`` panel lists (live-verified, agy + 1.1.9 — the panel prints these paths verbatim):: + + /.agents/skills//SKILL.md "Workspace" + ~/.gemini/antigravity-cli/skills//SKILL.md "Global" + ~/.gemini/skills//SKILL.md "Shared" + ~/.gemini/config/plugins//skills//… imported plugins + ~/.gemini/antigravity-cli/builtin/skills//… shipped builtins + + Only plugin skills are namespaced — agy registers them as + ``:`` and its TUI accepts only that spelling; every other + source is bare. The workspace source is ``.agents/skills`` (vendor-neutral), + NOT ``.claude/skills``: that is the one directory the old generic fallback + got right for agy. + + A plugin is ENABLED iff it still carries ``plugin.json``: ``agy plugin + disable`` renames that file to ``plugin.json.disabled`` and changes nothing + else — ``config/import_manifest.json`` keeps listing the plugin (so does + ``agy plugin list``) and the ``skills/`` tree stays on disk. The manifest is + therefore NOT a usable enabled-signal; the manifest file's name is. + + :param ctx: Session discovery context. ``home`` is the real user home: agy + runs under a bridge-owned ``--gemini_dir`` whose plugins are linked back + to the real tree, so the real home is the truthful source either way. + :returns: ``(directory, namespace)`` pairs; ``namespace`` is ``None`` for + every source except plugins. + """ + gemini = ctx.home / ".gemini" + dirs: list[tuple[Path, str | None]] = [] + for root in ctx.roots: + workspace_skills = root / ".agents" / "skills" + if workspace_skills.is_dir(): + dirs.append((workspace_skills, None)) + for bare in ( + gemini / "antigravity-cli" / "skills", + gemini / "skills", + ): + if bare.is_dir(): + dirs.append((bare, None)) + plugins_root = gemini / "config" / "plugins" + try: + plugins = sorted(plugins_root.iterdir()) if plugins_root.is_dir() else [] + except OSError as exc: + # An unreadable plugins dir must not 500 the /skills endpoint. + _log.warning("Skipping unreadable agy plugins dir %s: %s", plugins_root, exc) + plugins = [] + for plugin in plugins: + if not (plugin / "plugin.json").is_file(): + continue # absent or renamed to plugin.json.disabled + skills_dir = plugin / "skills" + if skills_dir.is_dir(): + dirs.append((skills_dir, plugin.name)) + builtin = gemini / "antigravity-cli" / "builtin" / "skills" + if builtin.is_dir(): + dirs.append((builtin, None)) + return dirs + + +def antigravity_host_skills(ctx: SkillSourceContext) -> list[SkillSpec]: + """ + agy's own skills: enabled ``~/.gemini`` plugins plus shipped builtins. + + agy owns a host-skill mechanism (a ``/skills`` TUI listing backed by + :func:`_agy_skill_dirs`), and unlike Pi's that mechanism is enumerable — so + agy gets a real provider rather than a no-op. It deliberately does NOT + compose with :func:`_generic_host_skills` (the way ``claude`` does): the + generic walk sources ``~/.claude/skills``, which are Claude's, not agy's. + The menu lists what this agent actually has, matching codex/cursor. + + Honors ``skills_filter`` (``"none"`` hermetic, ``"all"`` everything, a list + selecting by the surfaced name). Names match what agy's TUI accepts — + ``:`` for plugin skills, bare elsewhere — because a native + session's ``/name`` is sent to the vendor CLI as plaintext for it to expand + (there is no server-side resolve+inject on this path), so a label agy does + not recognise would simply fail. + + :param ctx: Session discovery context. + :returns: Parsed skills; unparseable ones are skipped (best-effort). + """ + if ctx.skills_filter == "none": + return [] + filter_names: set[str] | None = ( + set(ctx.skills_filter) if isinstance(ctx.skills_filter, list) else None + ) + out: list[SkillSpec] = [] + for skills_dir, namespace in _agy_skill_dirs(ctx): + try: + children = sorted(skills_dir.iterdir()) + except OSError as exc: + _log.warning("Skipping unreadable agy skills dir %s: %s", skills_dir, exc) + continue + for child in children: + if not child.is_dir() or not (child / "SKILL.md").is_file(): + continue + try: + spec = _parse_skill(child / "SKILL.md") + except (OmnigentError, OSError): # best-effort discovery + continue + name = spec.name if namespace is None else f"{namespace}:{spec.name}" + if filter_names is not None and name not in filter_names: + continue + out.append(spec if namespace is None else replace(spec, name=name)) + return out + + def pi_host_skills(ctx: SkillSourceContext) -> list[SkillSpec]: """ Pi exposes no *extra* discoverable skills to the menu. @@ -404,14 +525,25 @@ def pi_host_skills(ctx: SkillSourceContext) -> list[SkillSpec]: # Keyed by harness family (see _harness_family). A harness with no entry -# (antigravity, qwen, openai-agents, …) falls through to _generic_host_skills -# in resolve_harness_skills — the unchanged pre-existing ~/.claude/skills walk, -# whose skills omnigent resolves+injects regardless of vendor. Pi is the one -# harness that needs an explicit no-op (see pi_host_skills) because it owns a -# host-skill mechanism omnigent can't enumerate. +# (qwen, openai-agents, the in-process antigravity SDK, …) falls through to +# _generic_host_skills in resolve_harness_skills — the ~/.claude/skills walk, +# whose skills omnigent resolves+injects regardless of vendor. +# +# The dividing line is whether the harness owns a host-skill mechanism, and if +# so whether omnigent can enumerate it: +# +# no mechanism -> generic walk (omnigent injects the skill text) +# mechanism, enumerable -> dedicated provider listing what the agent has +# (claude, codex, cursor, antigravity/agy) +# mechanism, unenumerable -> explicit no-op (pi) — listing anything would +# risk surfacing a command the harness can't run +# +# agy moved from the first row to the second when it gained plugin + builtin +# skills; it is enumerable (see antigravity_host_skills), so it lists its own. _SKILL_SOURCES: dict[str | None, SkillSource] = { "claude": claude_host_skills, "codex": codex_host_skills, "cursor": cursor_host_skills, "pi": pi_host_skills, + "antigravity": antigravity_host_skills, } diff --git a/tests/fixtures/antigravity/steps/stream_generic_done.json b/tests/fixtures/antigravity/steps/stream_generic_done.json new file mode 100644 index 0000000000..c26cd26415 --- /dev/null +++ b/tests/fixtures/antigravity/steps/stream_generic_done.json @@ -0,0 +1,54 @@ +{ + "type": "CORTEX_STEP_TYPE_GENERIC", + "status": "CORTEX_STEP_STATUS_DONE", + "metadata": { + "stepGenerationVersion": 1, + "createdAt": "2026-08-02T04:53:21.430824720Z", + "viewableAt": "2026-08-02T04:53:21.448919282Z", + "finishedGeneratingAt": "2026-08-02T04:53:21.448918541Z", + "lastCompletedChunkAt": "2026-08-02T04:53:21.448911958Z", + "startedAt": "2026-08-02T04:53:21.449624836Z", + "completedAt": "2026-08-02T04:53:21.451371948Z", + "source": "CORTEX_STEP_SOURCE_MODEL", + "toolCall": { + "id": "oK1OngcX", + "name": "manage_subagents", + "thinkingSignature": "ErwDCrkDARFNMg9hffoRgjQhUU56sLFS3MLFOO/0C/7Htje7zd+/jFfbtwmqaiCt2FkAhYwrda1DgFvYAKy8nF1CG8iMn5jw4MIr/xKlt9IDhyV6aLDmfZ+wmCjWaimM36dFwuSXSppvvGObe1rjHzAg7V8D0K031u9ackNF9w/JOfnNeYIRkOdsPAAWmcGszKncUBEZEJY8yueKjQyBysy8QfxOWAIONMw2lrQzzIDU1md0grhXQuh3+7nfR1fZs+E0on4rMmKhW9kPQcpfYA5mLY3IjPW0J05qoM3cwfvCGwIqklnXEofuXrd7nekBh63QgGBjurI0D+svR6aqjStd81xdY9mgmW0ACryrVi5QJ201MlmAPpgYmrblq84N9IkTXRjVtVxoZPySSXe57ZK7fL3+EW39OXBgEu1v47k9iu6XfmNBDvLaKXqfr6hKyiQgoWlgX1zga1a2u/Ici7erRETdirzBoZh3GnU62DD1lrjhT3YI0z/16uvdHdeaCmmREUyQ+a99jHBWe/cIQQofC0j2qsIzbPDURMaYFayXbJfbqZAJb1s4QddsmljVB3E1rGwxgwmyEu9ajetI", + "originalName": "manage_subagents" + }, + "argumentsOrder": [ + "Action", + "toolAction", + "toolSummary" + ], + "generatorModel": "MODEL_PLACEHOLDER_M71", + "requestedModel": { + "model": "MODEL_PLACEHOLDER_M71" + }, + "executionId": "a3507592-60aa-4cc8-b489-6ad02e719672", + "toolCallOutputTokens": 26, + "sourceTrajectoryStepInfo": { + "trajectoryId": "efb134b2-d69f-43de-bb54-c9ece346d8a3", + "stepIndex": 40, + "metadataIndex": 18, + "cascadeId": "2399249c-4a48-40f1-bf3b-4c6e5d3a5a0e" + }, + "toolSummary": "List active subagents", + "toolAction": "Checking running subagent statuses" + }, + "generic": { + "args": { + "Action": "list" + }, + "result": { + "result": "You have 4 active subagent(s):\n[{\"role\":\"App Router Code Reviewer\",\"type\":\"research\",\"conversationId\":\"1eca7625-be65-409", + "stepRenderInfo": { + "title": "4 subagents", + "titlePrefix": "Found", + "markdown": "- App Router Code Reviewer\n- Component & 3D WebGL Code Reviewer\n- Database & Schema Code Reviewer\n- State Management & Real-time Sync Reviewer\n", + "icon": "robot_2", + "aggregationKey": "subagents" + } + } + } +} diff --git a/tests/fixtures/antigravity/steps/stream_list_directory_done.json b/tests/fixtures/antigravity/steps/stream_list_directory_done.json new file mode 100644 index 0000000000..229e9a5bec --- /dev/null +++ b/tests/fixtures/antigravity/steps/stream_list_directory_done.json @@ -0,0 +1,50 @@ +{ + "type": "CORTEX_STEP_TYPE_LIST_DIRECTORY", + "status": "CORTEX_STEP_STATUS_DONE", + "metadata": { + "stepGenerationVersion": 1, + "createdAt": "2026-08-02T04:49:09.337765093Z", + "viewableAt": "2026-08-02T04:49:09.352244726Z", + "finishedGeneratingAt": "2026-08-02T04:49:09.352244075Z", + "lastCompletedChunkAt": "2026-08-02T04:49:09.352234937Z", + "startedAt": "2026-08-02T04:49:09.352936764Z", + "completedAt": "2026-08-02T04:49:09.353858276Z", + "source": "CORTEX_STEP_SOURCE_MODEL", + "argumentsOrder": [ + "DirectoryPath", + "toolAction", + "toolSummary" + ], + "generatorModel": "MODEL_PLACEHOLDER_M71", + "requestedModel": { + "model": "MODEL_PLACEHOLDER_M71" + }, + "executionId": "99875cd6-e73c-4e0f-8f1f-eb7505a88330", + "toolCallOutputTokens": 37, + "sourceTrajectoryStepInfo": { + "trajectoryId": "efb134b2-d69f-43de-bb54-c9ece346d8a3", + "stepIndex": 8, + "metadataIndex": 2, + "cascadeId": "2399249c-4a48-40f1-bf3b-4c6e5d3a5a0e" + }, + "toolSummary": "List directory kyoto-studio", + "toolAction": "Listing project directory contents" + }, + "listDirectory": { + "directoryPathUri": "file:///Users/bryanli/.gemini/antigravity-cli/scratch", + "results": [ + { + "name": ".eslintrc.json", + "sizeBytes": "121" + }, + { + "name": ".git", + "isDir": true + }, + { + "name": ".gitignore", + "sizeBytes": "634" + } + ] + } +} diff --git a/tests/fixtures/antigravity/steps/stream_planner_text_done.json b/tests/fixtures/antigravity/steps/stream_planner_text_done.json new file mode 100644 index 0000000000..c94ca9bec1 --- /dev/null +++ b/tests/fixtures/antigravity/steps/stream_planner_text_done.json @@ -0,0 +1,42 @@ +{ + "type": "CORTEX_STEP_TYPE_PLANNER_RESPONSE", + "status": "CORTEX_STEP_STATUS_DONE", + "metadata": { + "stepGenerationVersion": 1, + "createdAt": "2026-08-02T04:49:11.356933257Z", + "viewableAt": "2026-08-02T04:49:12.421632134Z", + "finishedGeneratingAt": "2026-08-02T04:49:12.911533930Z", + "startedAt": "2026-08-02T04:49:12.911533930Z", + "completedAt": "2026-08-02T04:49:12.911533930Z", + "source": "CORTEX_STEP_SOURCE_MODEL", + "modelUsage": { + "model": "MODEL_PLACEHOLDER_M71", + "inputTokens": "13614", + "outputTokens": "472", + "thinkingOutputTokens": "212", + "responseOutputTokens": "260", + "cacheReadTokens": "12205", + "apiProvider": "API_PROVIDER_GOOGLE_GEMINI", + "responseHeader": { + "sessionID": "-3750763034362895579" + }, + "responseId": "R8xuatSTIo6i9tMPnP3OqQU" + }, + "generatorModel": "MODEL_PLACEHOLDER_M71", + "requestedModel": { + "model": "MODEL_PLACEHOLDER_M71" + }, + "executionId": "99875cd6-e73c-4e0f-8f1f-eb7505a88330", + "sourceTrajectoryStepInfo": { + "trajectoryId": "efb134b2-d69f-43de-bb54-c9ece346d8a3", + "stepIndex": 13, + "metadataIndex": 5, + "cascadeId": "2399249c-4a48-40f1-bf3b-4c6e5d3a5a0e" + } + }, + "plannerResponse": { + "modifiedResponse": "Here is a status summary of your workspace:\n\n### \ud83d\udcc1 **Repository & Branch**\n- **Project**: [`kyoto-studio`](file:///Users/bryanli/.gemini/antigravity-cli/scratch) (Next.j", + "messageId": "bot-8aa1ae92-622b-46ad-b13b-f6ee0bbbf11b", + "stopReason": "STOP_REASON_STOP_PATTERN" + } +} diff --git a/tests/fixtures/antigravity/steps/stream_planner_tool_call.json b/tests/fixtures/antigravity/steps/stream_planner_tool_call.json new file mode 100644 index 0000000000..24c34ee288 --- /dev/null +++ b/tests/fixtures/antigravity/steps/stream_planner_tool_call.json @@ -0,0 +1,41 @@ +{ + "type": "CORTEX_STEP_TYPE_PLANNER_RESPONSE", + "status": "CORTEX_STEP_STATUS_DONE", + "metadata": { + "stepGenerationVersion": 1, + "createdAt": "2026-08-02T04:49:07.381592827Z", + "viewableAt": "2026-08-02T04:49:08.449485929Z", + "finishedGeneratingAt": "2026-08-02T04:49:08.464117479Z", + "startedAt": "2026-08-02T04:49:08.464117479Z", + "completedAt": "2026-08-02T04:49:08.464117479Z", + "source": "CORTEX_STEP_SOURCE_MODEL", + "modelUsage": { + "model": "MODEL_PLACEHOLDER_M71", + "inputTokens": "3649", + "outputTokens": "178", + "thinkingOutputTokens": "98", + "responseOutputTokens": "80", + "cacheReadTokens": "20366", + "apiProvider": "API_PROVIDER_GOOGLE_GEMINI", + "responseHeader": { + "sessionID": "-3750763034362895579" + }, + "responseId": "Q8xuarfOJrfN1e8PwJCh8QI" + }, + "generatorModel": "MODEL_PLACEHOLDER_M71", + "requestedModel": { + "model": "MODEL_PLACEHOLDER_M71" + }, + "executionId": "99875cd6-e73c-4e0f-8f1f-eb7505a88330", + "sourceTrajectoryStepInfo": { + "trajectoryId": "efb134b2-d69f-43de-bb54-c9ece346d8a3", + "stepIndex": 5, + "metadataIndex": 1, + "cascadeId": "2399249c-4a48-40f1-bf3b-4c6e5d3a5a0e" + } + }, + "plannerResponse": { + "messageId": "bot-e5322d19-b728-4f43-bd94-6a0b4dae5a87", + "stopReason": "STOP_REASON_STOP_PATTERN" + } +} diff --git a/tests/fixtures/antigravity/steps/stream_run_command_done.json b/tests/fixtures/antigravity/steps/stream_run_command_done.json new file mode 100644 index 0000000000..af37cdf5b9 --- /dev/null +++ b/tests/fixtures/antigravity/steps/stream_run_command_done.json @@ -0,0 +1,47 @@ +{ + "type": "CORTEX_STEP_TYPE_RUN_COMMAND", + "status": "CORTEX_STEP_STATUS_DONE", + "metadata": { + "stepGenerationVersion": 1, + "createdAt": "2026-08-02T04:49:08.449792278Z", + "viewableAt": "2026-08-02T04:49:08.463608627Z", + "finishedGeneratingAt": "2026-08-02T04:49:08.463608176Z", + "lastCompletedChunkAt": "2026-08-02T04:49:08.463603197Z", + "startedAt": "2026-08-02T04:49:08.464314862Z", + "completedAt": "2026-08-02T04:49:08.473788011Z", + "source": "CORTEX_STEP_SOURCE_MODEL", + "argumentsOrder": [ + "CommandLine", + "Cwd", + "WaitMsBeforeAsync", + "toolAction", + "toolSummary" + ], + "generatorModel": "MODEL_PLACEHOLDER_M71", + "requestedModel": { + "model": "MODEL_PLACEHOLDER_M71" + }, + "executionId": "99875cd6-e73c-4e0f-8f1f-eb7505a88330", + "toolCallOutputTokens": 57, + "sourceTrajectoryStepInfo": { + "trajectoryId": "efb134b2-d69f-43de-bb54-c9ece346d8a3", + "stepIndex": 6, + "metadataIndex": 1, + "cascadeId": "2399249c-4a48-40f1-bf3b-4c6e5d3a5a0e" + }, + "toolSummary": "Git status and log", + "toolAction": "Checking git status and recent commit history" + }, + "runCommand": { + "commandLine": "git status && git log -n 5 --oneline", + "proposedCommandLine": "git status && git log -n 5 --oneline", + "cwd": "/Users/bryanli/.gemini/antigravity-cli/scratch", + "waitMsBeforeAsync": "5000", + "blocking": true, + "exitCode": 0, + "combinedOutput": { + "full": "On branch main\r\nYour branch is up to date with 'origin/main'.\r\n\r\nnothing to commit, working tree clean\r\n5be046c0 (HEAD -" + }, + "shellName": "bash" + } +} diff --git a/tests/fixtures/antigravity/steps/stream_view_file_done.json b/tests/fixtures/antigravity/steps/stream_view_file_done.json new file mode 100644 index 0000000000..30c6141d0e --- /dev/null +++ b/tests/fixtures/antigravity/steps/stream_view_file_done.json @@ -0,0 +1,40 @@ +{ + "type": "CORTEX_STEP_TYPE_VIEW_FILE", + "status": "CORTEX_STEP_STATUS_DONE", + "metadata": { + "stepGenerationVersion": 1, + "createdAt": "2026-08-02T04:50:08.027638531Z", + "viewableAt": "2026-08-02T04:50:08.039982336Z", + "finishedGeneratingAt": "2026-08-02T04:50:08.039981605Z", + "lastCompletedChunkAt": "2026-08-02T04:50:08.039977066Z", + "startedAt": "2026-08-02T04:50:08.040902846Z", + "completedAt": "2026-08-02T04:50:08.044622778Z", + "source": "CORTEX_STEP_SOURCE_MODEL", + "argumentsOrder": [ + "AbsolutePath", + "toolAction", + "toolSummary" + ], + "generatorModel": "MODEL_PLACEHOLDER_M71", + "requestedModel": { + "model": "MODEL_PLACEHOLDER_M71" + }, + "executionId": "6c75f268-a175-44c4-a39f-c070a81bf28f", + "toolCallOutputTokens": 40, + "sourceTrajectoryStepInfo": { + "trajectoryId": "efb134b2-d69f-43de-bb54-c9ece346d8a3", + "stepIndex": 16, + "metadataIndex": 6, + "cascadeId": "2399249c-4a48-40f1-bf3b-4c6e5d3a5a0e" + }, + "toolSummary": "Read package.json", + "toolAction": "Reading package.json for tech stack details" + }, + "viewFile": { + "absolutePathUri": "file:///Users/bryanli/.gemini/antigravity-cli/scratch/package.json", + "endLine": 110, + "lineRangeBytes": 3609, + "numLines": 111, + "numBytes": 3609 + } +} diff --git a/tests/runner/test_app_sessions_native_terminals_runtime.py b/tests/runner/test_app_sessions_native_terminals_runtime.py index 33ff1c592b..69055c1a66 100644 --- a/tests/runner/test_app_sessions_native_terminals_runtime.py +++ b/tests/runner/test_app_sessions_native_terminals_runtime.py @@ -1727,6 +1727,7 @@ async def _run_antigravity_auto_create( pane: tuple[Path, str] | None = None, pane_scoped_port: int | None = None, pane_agy_found: bool = True, + lsof_attributes_ports: bool = False, ) -> tuple[Any, list[tuple[int, str]], list[dict[str, Any]], list[tuple[str, dict[str, Any]]]]: """ Drive ``_auto_create_antigravity_terminal`` with every live collaborator faked. @@ -1816,10 +1817,24 @@ async def _no_sleep(_seconds: float) -> None: monkeypatch.setattr(runner_app_mod, "_agy_cold_start_poll_sleep", _no_sleep) monkeypatch.setattr(rpc_mod, "_candidate_agy_rpc_ports", lambda: list(candidate_ports)) + # Pinned so the cold-start's state-2 branch never reads the HOST's process + # table: default False models restricted /proc (attribution impossible for + # anyone), where the lone candidate really is ours. + monkeypatch.setattr(rpc_mod, "_can_attribute_any_agy_port", lambda: lsof_attributes_ports) start_cascade_calls: list[tuple[int, str]] = [] def _fake_start_cascade(port: int, cascade_id: str, **_kwargs: Any) -> None: start_cascade_calls.append((port, cascade_id)) + # Mirror real agy: the conversation db is written into the Gemini dir of + # whichever agy served the call. These fixtures model OUR agy answering, + # so it lands in our bridge — which is the cold-start's ownership proof. + convs = ( + bridge_mod.agy_gemini_dir(bridge_mod.bridge_dir_for_bridge_id(session_id)) + / "antigravity-cli" + / "conversations" + ) + convs.mkdir(parents=True, exist_ok=True) + (convs / f"{cascade_id}.db").write_bytes(b"") monkeypatch.setattr(rpc_mod, "start_cascade", _fake_start_cascade) @@ -2051,6 +2066,44 @@ async def test_auto_create_antigravity_cold_start_falls_back_when_port_unattribu assert patch_calls == [] # cold-start no longer records the phantom cascade (#2 data-loss) +@pytest.mark.asyncio +async def test_auto_create_antigravity_cold_start_waits_out_our_agy_boot_window( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Pane agy present but not listening yet, on a host with ANOTHER live agy. + + The real-world race: the cold-start fires ~250ms after tmux exec-s agy, so + agy IS in the pane subtree (``agy_found=True``) but has not bound its + connect-RPC listener. lsof works fine here — it attributes a port for an + OLDER agy (52548) — so the missing port means still-booting, not + unattributable. + + Binding 52548 would StartCascade inside that other session's agy: the reader + then adopts ITS active cascade, so the user's new chat mirrors an existing + conversation while their own agy is orphaned. The cold-start must instead + time out and leave the placeholder for the reader to resolve. + """ + session_id = "0c1e4c0f6a3b45a2b19d0e6d5c4b3a29" + state, start_cascade_calls, _reader_calls, patch_calls = await _run_antigravity_auto_create( + tmp_path, + monkeypatch, + session_id=session_id, + snapshot={}, + candidate_ports=[52548], # a FOREIGN agy, the only one listening + pane=(tmp_path / "agy.sock", "main"), + pane_agy_found=True, # our agy is exec-ed... + pane_scoped_port=None, # ...but has not bound its port yet + lsof_attributes_ports=True, # lsof works here → still-booting, not restricted + ) + assert start_cascade_calls == [], "must not StartCascade onto a foreign agy" + assert patch_calls == [] + assert state is not None + assert bridge_mod_is_placeholder(state.conversation_id), ( + "the placeholder must survive so the reader binds our own agy's conversation" + ) + + @pytest.mark.asyncio async def test_auto_create_antigravity_resume_skips_cold_start( tmp_path: Path, @@ -2807,3 +2860,88 @@ async def _raise(*_args: object, **_kwargs: object) -> str: # A RuntimeError must never be described as a timeout. if not isinstance(exc, TimeoutError): assert "timed out" not in recorded + + +@pytest.mark.asyncio +async def test_cold_start_agy_conversation_rejects_a_foreign_agy_cascade( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A cascade that did not land in THIS bridge's Gemini dir is not persisted. + + Defense-in-depth behind the port scoping. ``StartCascade`` on a foreign agy + succeeds and returns an id, but agy writes the conversation into ITS OWN + ``--gemini_dir``. Persisting that id cross-binds the session durably: the + reader mirrors another session's conversation (the user sees a duplicate of + an ongoing chat) while this session's own agy is orphaned and its replies + never arrive. Refusing leaves the placeholder, which the reader's own + discovery later resolves correctly. + """ + import omnigent.antigravity_native_rpc as rpc_mod + from omnigent import antigravity_native_bridge as bridge_mod + from omnigent.runner import app as runner_app_mod + + monkeypatch.setattr(bridge_mod, "_BRIDGE_ROOT", tmp_path / "antigravity-native") + session_id = "aa44894f77886259ee71e892a9e2af00" + bridge_dir = bridge_mod.prepare_bridge_dir(session_id) + placeholder = "agy_conv_placeholder" + bridge_mod.write_bridge_state( + bridge_dir, + bridge_mod.AntigravityNativeBridgeState( + session_id=session_id, + conversation_id=placeholder, + ), + ) + + monkeypatch.setattr(rpc_mod, "resolve_cold_start_agy_rpc_port", lambda _s, _t: 34601) + monkeypatch.setattr(rpc_mod, "start_cascade", lambda _port, _cid, **_k: None) + # The conversations dir stays EMPTY: the foreign agy wrote the .db into its + # own Gemini dir, not ours. + (bridge_mod.agy_gemini_dir(bridge_dir) / "antigravity-cli" / "conversations").mkdir( + parents=True, exist_ok=True + ) + + result = await runner_app_mod._cold_start_agy_conversation(bridge_dir, session_id) + + assert result is None, "a foreign cascade must not be adopted" + state = bridge_mod.read_bridge_state(bridge_dir) + assert state is not None + assert state.conversation_id == placeholder, "placeholder must survive for the reader" + + +@pytest.mark.asyncio +async def test_cold_start_agy_conversation_accepts_a_locally_owned_cascade( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """The happy path still persists: our own agy wrote the conversation here.""" + import omnigent.antigravity_native_rpc as rpc_mod + from omnigent import antigravity_native_bridge as bridge_mod + from omnigent.runner import app as runner_app_mod + + monkeypatch.setattr(bridge_mod, "_BRIDGE_ROOT", tmp_path / "antigravity-native") + session_id = "bb44894f77886259ee71e892a9e2af11" + bridge_dir = bridge_mod.prepare_bridge_dir(session_id) + bridge_mod.write_bridge_state( + bridge_dir, + bridge_mod.AntigravityNativeBridgeState( + session_id=session_id, + conversation_id="agy_conv_placeholder", + ), + ) + convs = bridge_mod.agy_gemini_dir(bridge_dir) / "antigravity-cli" / "conversations" + convs.mkdir(parents=True, exist_ok=True) + + monkeypatch.setattr(rpc_mod, "resolve_cold_start_agy_rpc_port", lambda _s, _t: 34601) + + def _start_cascade(_port: int, cascade_id: str, **_kwargs: Any) -> None: + # Our agy owns it: the conversation db lands in OUR Gemini dir. + (convs / f"{cascade_id}.db").write_bytes(b"") + + monkeypatch.setattr(rpc_mod, "start_cascade", _start_cascade) + + result = await runner_app_mod._cold_start_agy_conversation(bridge_dir, session_id) + + assert result is not None + state = bridge_mod.read_bridge_state(bridge_dir) + assert state is not None and state.conversation_id == result diff --git a/tests/server/integration/test_sessions_endpoints.py b/tests/server/integration/test_sessions_endpoints.py index 4157c73a50..02e4faef76 100644 --- a/tests/server/integration/test_sessions_endpoints.py +++ b/tests/server/integration/test_sessions_endpoints.py @@ -7933,6 +7933,189 @@ async def test_external_codex_subagent_start_is_idempotent_and_upserts_labels( ) +# ── POST /v1/sessions/{id}/events external_antigravity_subagent_start ──────── + + +async def test_external_antigravity_subagent_start_mints_child_session( + client: httpx.AsyncClient, +) -> None: + """ + ``external_antigravity_subagent_start`` creates a child session whose rail + row names the sub-agent's role. + + agy runs each sub-agent as its own cascade and names it on the parent's + ``INVOKE_SUBAGENT`` step; the reader posts this so the Agents rail can show + it. Unlike the claude/codex children, the rail's generic + ``":"`` title split does the display work — so this pins that + the title is built to survive that split. + + :param client: The test HTTP client. + """ + agent = await create_test_agent(client) + parent = await _create_session( + client, + agent["id"], + labels={"omnigent.wrapper": "antigravity-native-ui"}, + ) + + resp = await client.post( + f"/v1/sessions/{parent['id']}/events", + json={ + "type": "external_antigravity_subagent_start", + "data": { + "cascade_id": "1eca7625-be65-4092-8718-273c1bc3436b", + "role": "App Router Code Reviewer", + "agent_type": "research", + "tool_call_id": "agy_call_efb134b2_36", + }, + }, + ) + + assert resp.status_code == 202, f"unexpected status {resp.status_code}: {resp.text}" + child_id = resp.json()["child_session_id"] + + children_resp = await client.get(f"/v1/sessions/{parent['id']}/child_sessions") + assert children_resp.status_code == 200 + child = next( + (c for c in children_resp.json()["data"] if c["id"] == child_id), + None, + ) + assert child is not None, "Child session must appear in child_sessions listing" + assert child["tool"] == "App Router Code Reviewer", ( + f"Expected the rail to name the sub-agent's role; got {child['tool']!r}" + ) + assert child["session_name"] == "1eca7625-be65-4092-8718-273c1bc3436b", ( + f"Expected session_name=cascade id; got {child['session_name']!r}" + ) + labels = child["labels"] + assert labels["omnigent.wrapper"] == "antigravity-native-ui-subagent" + assert ( + labels["omnigent.antigravity_native.subagent_cascade_id"] + == "1eca7625-be65-4092-8718-273c1bc3436b" + ) + assert labels["omnigent.antigravity_native.agent_role"] == "App Router Code Reviewer" + assert labels["omnigent.antigravity_native.agent_type"] == "research" + # Links the child back to the tool card that spawned it. + assert labels["omnigent.antigravity_native.tool_call_id"] == "agy_call_efb134b2_36" + + +async def test_external_antigravity_subagent_start_is_idempotent( + client: httpx.AsyncClient, +) -> None: + """ + Re-registering the same agy sub-agent returns the existing child row. + + The reader re-enters its handler on every frame while the sub-agents work + (the owning step stays RUNNING throughout), and a runner restart replays the + step, so this POST is repeated many times per sub-agent. Each repeat must + resolve to the same row rather than filling the rail with duplicates. + + :param client: The test HTTP client. + """ + agent = await create_test_agent(client) + parent = await _create_session( + client, + agent["id"], + labels={"omnigent.wrapper": "antigravity-native-ui"}, + ) + payload = { + "type": "external_antigravity_subagent_start", + "data": { + "cascade_id": "a8009634-3de5-464a-a84c-6f1dafb18942", + "role": "Database & Schema Code Reviewer", + "agent_type": "research", + }, + } + + first = await client.post(f"/v1/sessions/{parent['id']}/events", json=payload) + assert first.status_code == 202, first.text + second = await client.post(f"/v1/sessions/{parent['id']}/events", json=payload) + assert second.status_code == 202, second.text + + assert first.json()["child_session_id"] == second.json()["child_session_id"], ( + "A repeated registration must resolve to the same child session" + ) + children = (await client.get(f"/v1/sessions/{parent['id']}/child_sessions")).json()["data"] + matching = [c for c in children if c["session_name"] == "a8009634-3de5-464a-a84c-6f1dafb18942"] + assert len(matching) == 1, f"Expected exactly 1 child row; got {len(matching)}" + + +async def test_external_antigravity_subagent_start_requires_cascade_id( + client: httpx.AsyncClient, +) -> None: + """ + A registration with no ``cascade_id`` is rejected rather than minting an + unaddressable child. + + The cascade id is what the mirror loop polls and what makes the row + idempotent; a child without one could never be filled in. + + :param client: The test HTTP client. + """ + agent = await create_test_agent(client) + parent = await _create_session( + client, + agent["id"], + labels={"omnigent.wrapper": "antigravity-native-ui"}, + ) + + resp = await client.post( + f"/v1/sessions/{parent['id']}/events", + json={ + "type": "external_antigravity_subagent_start", + "data": {"role": "Nameless Reviewer"}, + }, + ) + assert resp.status_code == 400, ( + f"Expected 400 for a missing cascade_id; got {resp.status_code}: {resp.text[:200]}" + ) + + +async def test_external_antigravity_subagent_start_title_survives_a_colon_in_the_role( + client: httpx.AsyncClient, +) -> None: + """ + A role containing a colon still splits into role / cascade id in the rail. + + The rail splits a child title on its FIRST colon, and agy's roles are + model-authored free text — "Review: routing" would otherwise push the cascade + id out of ``session_name`` and show a truncated role. + + :param client: The test HTTP client. + """ + agent = await create_test_agent(client) + parent = await _create_session( + client, + agent["id"], + labels={"omnigent.wrapper": "antigravity-native-ui"}, + ) + + resp = await client.post( + f"/v1/sessions/{parent['id']}/events", + json={ + "type": "external_antigravity_subagent_start", + "data": { + "cascade_id": "84110421-7cfd-4971-8eaa-8e1f390fc92e", + "role": "Review: routing and auth", + "agent_type": "research", + }, + }, + ) + assert resp.status_code == 202, resp.text + child_id = resp.json()["child_session_id"] + + children = (await client.get(f"/v1/sessions/{parent['id']}/child_sessions")).json()["data"] + child = next(c for c in children if c["id"] == child_id) + assert child["session_name"] == "84110421-7cfd-4971-8eaa-8e1f390fc92e", ( + f"The cascade id must stay in session_name; got {child['session_name']!r}" + ) + assert child["tool"] == "Review- routing and auth", ( + f"Expected the colon folded out of the role; got {child['tool']!r}" + ) + # The unmangled role is still available for any surface that wants it. + assert child["labels"]["omnigent.antigravity_native.agent_role"] == "Review: routing and auth" + + async def test_external_codex_subagent_start_adopts_unlabeled_title_collision( client: httpx.AsyncClient, ) -> None: diff --git a/tests/spec/test_skill_sources.py b/tests/spec/test_skill_sources.py index 3e53eba228..8253cef1cd 100644 --- a/tests/spec/test_skill_sources.py +++ b/tests/spec/test_skill_sources.py @@ -50,7 +50,11 @@ def _ctx(root: Path, home: Path, skills_filter: str | list[str] = "all") -> Skil ("pi-native", "pi"), ("native-pi", "pi"), ("openai-agents", None), + # Only the agy CLI reads ~/.gemini plugins; the in-process Gemini SDK + # harness (bare "antigravity") does not, so it stays unmapped. ("antigravity", None), + ("antigravity-native", "antigravity"), + ("native-antigravity", "antigravity"), ("qwen", None), (None, None), ("", None), @@ -638,3 +642,204 @@ def _boom(self: Path): # Must not raise. out = resolve_harness_skills(_ctx(tmp_path / "ws", home), "cursor-native") assert out == [] + + +# --------------------------------------------------------------------------- +# antigravity (agy CLI) provider +# --------------------------------------------------------------------------- +# +# Layout live-verified against agy 1.1.9: +# ~/.gemini/config/plugins//skills//SKILL.md (imported plugins) +# ~/.gemini/antigravity-cli/builtin/skills//SKILL.md (shipped builtins) +# A plugin is DISABLED by renaming its manifest to ``plugin.json.disabled`` +# (``agy plugin disable`` performs exactly that rename; the import manifest and +# config.json are left untouched, and ``agy plugin list`` still lists it). + + +def _write_agy_plugin(home: Path, plugin: str, *skills: str, enabled: bool = True) -> None: + """Write an agy plugin with *skills*, enabled or disabled via its manifest name.""" + root = home / ".gemini" / "config" / "plugins" / plugin + root.mkdir(parents=True, exist_ok=True) + (root / ("plugin.json" if enabled else "plugin.json.disabled")).write_text('{"name":"x"}') + for skill in skills: + _write_skill(root / "skills", skill) + + +def test_antigravity_provider_surfaces_plugin_and_builtin_skills( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """agy's own skills — imported plugins plus shipped builtins — reach the menu.""" + home = tmp_path / "home" + _write_agy_plugin(home, "superpowers", "brainstorming", "writing-plans") + _write_skill(home / ".gemini" / "antigravity-cli" / "builtin" / "skills", "antigravity-guide") + monkeypatch.setattr("pathlib.Path.home", lambda: home) + + names = [ + s.name for s in resolve_harness_skills(_ctx(tmp_path / "ws", home), "antigravity-native") + ] + # Plugin skills are namespaced : (agy's own /skills panel + # lists them that way and the TUI only accepts that spelling); builtins are + # bare. Live-verified against agy 1.1.9. + assert sorted(names) == [ + "antigravity-guide", + "superpowers:brainstorming", + "superpowers:writing-plans", + ] + + +def test_antigravity_provider_skips_disabled_plugin( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """A plugin disabled via the plugin.json -> plugin.json.disabled rename is hidden. + + The skills stay on disk and the import manifest still lists the plugin, so the + manifest is NOT a usable enabled-signal — the manifest name is. + """ + home = tmp_path / "home" + _write_agy_plugin(home, "superpowers", "brainstorming", enabled=False) + _write_agy_plugin(home, "othertools", "still-on", enabled=True) + monkeypatch.setattr("pathlib.Path.home", lambda: home) + + names = [ + s.name for s in resolve_harness_skills(_ctx(tmp_path / "ws", home), "antigravity-native") + ] + assert "superpowers:brainstorming" not in names + assert "othertools:still-on" in names + + +def test_antigravity_session_does_not_inherit_generic_claude_host_walk( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """An agy session must not surface ~/.claude/skills (those belong to Claude). + + agy owns an enumerable host-skill mechanism, so it lists exactly what agy has + rather than falling through to the generic walk. + """ + home = tmp_path / "home" + _write_skill(home / ".claude" / "skills", "claude-only-skill") + _write_agy_plugin(home, "superpowers", "brainstorming") + monkeypatch.setattr("pathlib.Path.home", lambda: home) + + names = [ + s.name for s in resolve_harness_skills(_ctx(tmp_path / "ws", home), "antigravity-native") + ] + assert "claude-only-skill" not in names + assert "superpowers:brainstorming" in names + + +def test_antigravity_sdk_harness_keeps_the_generic_walk( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """The in-process Gemini SDK harness is NOT agy and keeps the generic fallback. + + It never launches the agy CLI, so ~/.gemini plugins are not its skills; its + skills are the omnigent-injected generic ones. + """ + home = tmp_path / "home" + _write_skill(home / ".claude" / "skills", "claude-only-skill") + _write_agy_plugin(home, "superpowers", "brainstorming") + monkeypatch.setattr("pathlib.Path.home", lambda: home) + + names = [s.name for s in resolve_harness_skills(_ctx(tmp_path / "ws", home), "antigravity")] + assert "claude-only-skill" in names + assert "superpowers:brainstorming" not in names + + +def test_antigravity_provider_filters_user_invocable_false( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """A user-invocable:false agy skill is dropped (consistent across harnesses).""" + home = tmp_path / "home" + _write_agy_plugin(home, "superpowers") + _write_skill(home / ".gemini" / "config" / "plugins" / "superpowers" / "skills", "shown") + _write_skill( + home / ".gemini" / "config" / "plugins" / "superpowers" / "skills", + "internal", + user_invocable=False, + ) + monkeypatch.setattr("pathlib.Path.home", lambda: home) + + names = [ + s.name for s in resolve_harness_skills(_ctx(tmp_path / "ws", home), "antigravity-native") + ] + assert names == ["superpowers:shown"] + + +def test_antigravity_provider_respects_none_filter( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """``skills: none`` in the agent spec suppresses agy's host skills.""" + home = tmp_path / "home" + _write_agy_plugin(home, "superpowers", "brainstorming") + monkeypatch.setattr("pathlib.Path.home", lambda: home) + + out = resolve_harness_skills(_ctx(tmp_path / "ws", home, "none"), "antigravity-native") + assert out == [] + + +def test_antigravity_provider_tolerates_missing_and_unreadable_dirs( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """No ~/.gemini at all (or an unreadable tree) yields [] rather than raising.""" + home = tmp_path / "home" + home.mkdir() + monkeypatch.setattr("pathlib.Path.home", lambda: home) + assert resolve_harness_skills(_ctx(tmp_path / "ws", home), "antigravity-native") == [] + + _write_agy_plugin(home, "superpowers", "brainstorming") + real_iterdir = Path.iterdir + + def _boom(self: Path): + if self.name == "plugins": + raise PermissionError("permission denied") + return real_iterdir(self) + + monkeypatch.setattr("pathlib.Path.iterdir", _boom) + # Must not raise. + assert resolve_harness_skills(_ctx(tmp_path / "ws", home), "antigravity-native") == [] + + +def test_antigravity_provider_surfaces_all_five_agy_sources( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """Every source agy's own /skills panel lists is surfaced. + + Live-verified against agy 1.1.9, whose panel names them: Workspace + (``/.agents/skills``), Global (``antigravity-cli/skills``), Shared + (``.gemini/skills``), plus imported plugins and shipped builtins. + """ + home = tmp_path / "home" + ws = tmp_path / "ws" + _write_skill(ws / ".agents" / "skills", "ws-skill") + _write_skill(home / ".gemini" / "antigravity-cli" / "skills", "global-skill") + _write_skill(home / ".gemini" / "skills", "shared-skill") + _write_agy_plugin(home, "superpowers", "brainstorming") + _write_skill(home / ".gemini" / "antigravity-cli" / "builtin" / "skills", "antigravity-guide") + monkeypatch.setattr("pathlib.Path.home", lambda: home) + + names = sorted(s.name for s in resolve_harness_skills(_ctx(ws, home), "antigravity-native")) + assert names == [ + "antigravity-guide", + "global-skill", + "shared-skill", + "superpowers:brainstorming", + "ws-skill", + ] + + +def test_antigravity_provider_reads_agents_skills_not_claude_skills( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """``.agents/skills`` in the workspace is agy's; ``.claude/skills`` is not. + + The generic walk scans both, which is why agy previously saw Claude's. agy + genuinely reads the vendor-neutral ``.agents/skills``, so that one stays. + """ + home = tmp_path / "home" + ws = tmp_path / "ws" + _write_skill(ws / ".agents" / "skills", "neutral-skill") + _write_skill(ws / ".claude" / "skills", "claude-ws-skill") + monkeypatch.setattr("pathlib.Path.home", lambda: home) + + names = [s.name for s in resolve_harness_skills(_ctx(ws, home), "antigravity-native")] + assert names == ["neutral-skill"] diff --git a/tests/test_antigravity_native.py b/tests/test_antigravity_native.py index 7fae6f41e6..d029bee13b 100644 --- a/tests/test_antigravity_native.py +++ b/tests/test_antigravity_native.py @@ -1219,6 +1219,10 @@ async def test_cli_cold_start_falls_back_when_port_unattributable( "resolve_pane_agy_rpc_port_state", lambda _sock, _tgt: rpc.PaneAgyResolution(agy_found=True, port=None), ) + # Restricted /proc: lsof attributes no port for ANY agy, so the lone + # candidate really is ours. Pinned so the branch never reads the host's + # process table (where a live agy would mean "still booting" instead). + monkeypatch.setattr(rpc, "_can_attribute_any_agy_port", lambda: False) monkeypatch.setattr(rpc, "_candidate_agy_rpc_ports", lambda: [52548]) started: list[tuple[int, str]] = [] monkeypatch.setattr(_mod, "start_cascade", lambda port, cid: started.append((port, cid))) diff --git a/tests/test_antigravity_native_bridge.py b/tests/test_antigravity_native_bridge.py index 811be3c069..5d863aa56d 100644 --- a/tests/test_antigravity_native_bridge.py +++ b/tests/test_antigravity_native_bridge.py @@ -1583,6 +1583,93 @@ def test_seed_isolated_agy_home_tolerates_missing_credential( assert (iso_gemini / "antigravity-cli" / "cache" / "onboarding.json").is_file() +def test_seed_isolated_agy_home_exposes_user_plugins( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """The user's imported plugins (skills + hooks) are visible under --gemini_dir.""" + fake_home = tmp_path / "real-home" + real_config = fake_home / ".gemini" / "config" + (real_config / "plugins" / "superpowers" / "skills").mkdir(parents=True) + (real_config / "plugins" / "superpowers" / "skills" / "brainstorming.md").write_text( + "# brainstorming", encoding="utf-8" + ) + manifest = json.dumps( + {"imports": [{"name": "superpowers", "components": ["skills", "hooks"]}]} + ) + (real_config / "import_manifest.json").write_text(manifest, encoding="utf-8") + monkeypatch.setattr(Path, "home", classmethod(lambda _cls: fake_home)) + + bridge_dir = tmp_path / "bridge" + bridge_dir.mkdir() + seed_isolated_agy_home(bridge_dir) + + iso_config = agy_gemini_dir(bridge_dir) / "config" + # agy resolves plugins relative to --gemini_dir, so both the manifest and the + # plugin payload must be reachable there or the session lists zero skills. + assert json.loads((iso_config / "import_manifest.json").read_text(encoding="utf-8")) == ( + json.loads(manifest) + ) + seeded_skill = iso_config / "plugins" / "superpowers" / "skills" / "brainstorming.md" + assert seeded_skill.read_text(encoding="utf-8") == "# brainstorming" + + +def test_seed_isolated_agy_home_does_not_copy_plugin_payload( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """Plugins are linked, not copied, so plugin updates are picked up live.""" + fake_home = tmp_path / "real-home" + real_plugins = fake_home / ".gemini" / "config" / "plugins" + (real_plugins / "superpowers").mkdir(parents=True) + monkeypatch.setattr(Path, "home", classmethod(lambda _cls: fake_home)) + + bridge_dir = tmp_path / "bridge" + bridge_dir.mkdir() + seed_isolated_agy_home(bridge_dir) + + iso_plugins = agy_gemini_dir(bridge_dir) / "config" / "plugins" + assert iso_plugins.is_symlink() + assert iso_plugins.resolve() == real_plugins.resolve() + # A plugin installed/updated after the session started is still visible. + (real_plugins / "later").mkdir() + assert (iso_plugins / "later").is_dir() + + +def test_seed_isolated_agy_home_plugin_seed_is_idempotent( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """Re-seeding an existing bridge dir does not fail on the existing link.""" + fake_home = tmp_path / "real-home" + (fake_home / ".gemini" / "config" / "plugins").mkdir(parents=True) + monkeypatch.setattr(Path, "home", classmethod(lambda _cls: fake_home)) + + bridge_dir = tmp_path / "bridge" + bridge_dir.mkdir() + seed_isolated_agy_home(bridge_dir) + seed_isolated_agy_home(bridge_dir) + + iso_plugins = agy_gemini_dir(bridge_dir) / "config" / "plugins" + assert iso_plugins.is_symlink() + + +def test_seed_isolated_agy_home_tolerates_absent_plugins( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """A user with no imported plugins seeds cleanly — no link, no manifest.""" + fake_home = tmp_path / "real-home" + (fake_home / ".gemini").mkdir(parents=True) + monkeypatch.setattr(Path, "home", classmethod(lambda _cls: fake_home)) + + bridge_dir = tmp_path / "bridge" + bridge_dir.mkdir() + seed_isolated_agy_home(bridge_dir) + + iso_config = agy_gemini_dir(bridge_dir) / "config" + assert not (iso_config / "plugins").exists() + assert not (iso_config / "import_manifest.json").exists() + # The rest of the seed still landed. + assert (iso_config / ".migrated").is_file() + + def test_agy_home_dir_is_under_bridge_dir(tmp_path: Path) -> None: """The isolated state parent is a child of the per-session bridge dir.""" bridge_dir = tmp_path / "bridge" diff --git a/tests/test_antigravity_native_reader.py b/tests/test_antigravity_native_reader.py index 14638ffe62..308805a5f1 100644 --- a/tests/test_antigravity_native_reader.py +++ b/tests/test_antigravity_native_reader.py @@ -84,6 +84,7 @@ class _PostSink: def __init__(self) -> None: self.posts: list[tuple[str, dict[str, object]]] = [] + self.urls: list[str] = [] async def __call__( self, @@ -100,6 +101,7 @@ async def __call__( ) -> httpx.Response: data = payload.get("data") self.posts.append((event_type, cast(dict[str, object], data))) + self.urls.append(url) return httpx.Response(200, json={"ok": True}) def item_types(self) -> list[str]: @@ -659,7 +661,6 @@ async def test_single_in_flight_guard_skips_second_interaction() -> None: """While one interaction is handled off-loop, a second (e.g. agy's higher-index WAITING retry) is NOT fired again — the in-flight bridge owns the retries.""" state = reader._ReaderState( - allocator=reader._ToolCallIdAllocator(conversation_id=_CASCADE_ID), seen=set(), interacted=set(), port=_PORT, @@ -704,7 +705,6 @@ async def test_interaction_done_callback_clears_slot(monkeypatch: pytest.MonkeyP # so this test stays focused on slot-clearing + the manual re-fire. monkeypatch.setattr(reader, "get_trajectory_steps", lambda _port, _cascade_id: []) state = reader._ReaderState( - allocator=reader._ToolCallIdAllocator(conversation_id=_CASCADE_ID), seen=set(), interacted=set(), port=_PORT, @@ -773,7 +773,6 @@ async def test_clear_slot_resurfaces_deferred_gate(monkeypatch: pytest.MonkeyPat path no further frame carries it, so the clear's re-scan is what surfaces it. """ state = reader._ReaderState( - allocator=reader._ToolCallIdAllocator(conversation_id=_CASCADE_ID), seen=set(), interacted=set(), port=_PORT, @@ -821,7 +820,6 @@ async def test_clear_slot_rescan_empty_then_stream_frame_surfaces( single-shot re-scan does not strand a gate that arrives after it runs. """ state = reader._ReaderState( - allocator=reader._ToolCallIdAllocator(conversation_id=_CASCADE_ID), seen=set(), interacted=set(), port=_PORT, @@ -885,7 +883,6 @@ async def test_rescan_skips_auto_allowed_step_and_surfaces_next_gate( where not every chained command is permission-gated. """ state = reader._ReaderState( - allocator=reader._ToolCallIdAllocator(conversation_id=_CASCADE_ID), seen=set(), interacted=set(), port=_PORT, @@ -948,7 +945,6 @@ async def _noop_sleep(_seconds: float) -> None: monkeypatch.setattr(reader, "get_trajectory_steps", _boom) monkeypatch.setattr(reader, "_sleep", _noop_sleep) # no real backoff in the test state = reader._ReaderState( - allocator=reader._ToolCallIdAllocator(conversation_id=_CASCADE_ID), seen=set(), interacted=set(), port=_PORT, @@ -993,7 +989,6 @@ async def _noop_sleep(_seconds: float) -> None: monkeypatch.setattr(reader, "get_trajectory_steps", _flaky) monkeypatch.setattr(reader, "_sleep", _noop_sleep) # no real backoff in the test state = reader._ReaderState( - allocator=reader._ToolCallIdAllocator(conversation_id=_CASCADE_ID), seen=set(), interacted=set(), port=_PORT, @@ -1265,7 +1260,6 @@ async def test_withdraw_helper_pops_and_posts_once_directly() -> None: waiting["metadata"]["sourceTrajectoryStepInfo"]["stepIndex"], ) state = reader._ReaderState( - allocator=reader._ToolCallIdAllocator(conversation_id=_CASCADE_ID), seen=set(), interacted=set(), port=_PORT, @@ -1302,7 +1296,6 @@ async def test_withdraw_helper_noop_while_still_waiting() -> None: waiting["metadata"]["sourceTrajectoryStepInfo"]["stepIndex"], ) state = reader._ReaderState( - allocator=reader._ToolCallIdAllocator(conversation_id=_CASCADE_ID), seen=set(), interacted=set(), port=_PORT, @@ -1643,6 +1636,212 @@ async def test_stream_done_emits_one_committed_message_after_deltas( assert content[0]["text"] == full +@pytest.mark.asyncio +async def test_stream_done_closes_the_live_block_with_a_final_delta( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + patched_discovery: None, +) -> None: + """A committed planner step closes its stream with ``final=True``. + + The server retires a native in-flight message only when it has seen a + ``final: true`` delta AND the joined deltas are byte-equal to the committed + text (``omnigent.runtime.inflight_text``). agy emitted neither: every delta + was ``final=False``, and the last growth increment before DONE was never + sent, so the buffered text stayed truncated. + + The entry therefore lived forever and was replayed to EVERY new subscriber — + a page load or the next turn re-rendered a stale, cut-off copy of an earlier + answer beside the current one (the duplicated + truncated responses). + + So on commit the reader must flush the remaining suffix and mark the stream + final, leaving the buffered text byte-equal to what it commits. + """ + full = "Hello there, friend." + frames = [ + # The last GENERATING frame is BEHIND the committed text — the real + # trajectory shape, and the reason the buffer stayed truncated. + _frame([_generating_planner("Hello there,")]), + _frame([_done_planner(full)]), + ] + sink = _PostSink() + + await _run_stream( + bridge_dir=_bridge_dir(tmp_path), + sink=sink, + stream=_FrameScript(frames), + poll_steps=_StepScript([[]]), + monkeypatch=monkeypatch, + iterations=1, + ) + + deltas = sink.deltas() + assert deltas, "expected deltas" + # The stream is closed exactly once, and only by the last delta. + finals = [d for d in deltas if d.get("final") is True] + assert len(finals) == 1, f"expected exactly one final delta, got {len(finals)}" + assert deltas[-1].get("final") is True + # Byte-equality with the committed text is the server's other retirement + # condition, so the closing delta must carry the missing suffix. + assert "".join(cast(str, d["delta"]) for d in deltas) == full + # Still exactly one committed message, still after the deltas. + assert sink.item_types() == ["message"] + types = sink.event_types() + assert max(i for i, t in enumerate(types) if t == "external_output_text_delta") < types.index( + "external_conversation_item" + ) + + +@pytest.mark.asyncio +async def test_multi_chunk_answer_leaves_nothing_in_flight_on_the_server( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + patched_discovery: None, +) -> None: + """A long answer streamed over MANY frames still retires server-side. + + The production shape: agy grows ``modifiedResponse`` across many frames, so + the message is several chunks. Every chunk carried ``index: 0``, and the + server drops any chunk whose index does not exceed the last accepted one — + so only the FIRST survived. The buffer kept a truncated prefix, the + ``final`` chunk was discarded, and the message was replayed to every later + subscriber as a cut-off duplicate of the answer just committed. + + A single-chunk answer hid this: its one chunk IS the whole text. + """ + from omnigent.runtime import inflight_text + + growth = ["Kyoto ", "Kyoto Studio ", "Kyoto Studio is ", "Kyoto Studio is a studio app."] + full = growth[-1] + sink = _PostSink() + + await _run_stream( + bridge_dir=_bridge_dir(tmp_path), + sink=sink, + stream=_FrameScript( + [_frame([_generating_planner(t)]) for t in growth] + [_frame([_done_planner(full)])] + ), + poll_steps=_StepScript([[]]), + monkeypatch=monkeypatch, + iterations=1, + ) + + # More than one chunk, and every index strictly increases. + deltas = sink.deltas() + assert len(deltas) > 1, "expected a multi-chunk stream" + indices = [cast(int, d["index"]) for d in deltas] + assert indices == sorted(set(indices)), f"indices must strictly increase, got {indices}" + + inflight_text.reset_for_tests() + conv = "conv_multi_chunk" + for event_type, data in sink.posts: + if event_type == "external_output_text_delta": + inflight_text.record_publish(conv, {"type": "response.output_text.delta", **data}) + elif event_type == "external_conversation_item": + item = cast(dict[str, Any], data.get("item_data") or {}) + if item.get("role") == "assistant": + inflight_text.record_publish( + conv, + { + "type": "response.output_item.done", + "item": { + "id": "ap_1", + "type": "message", + "role": "assistant", + "content": item.get("content"), + }, + }, + ) + + leftover = [ + s + for s in inflight_text.snapshot_for(conv) + if s.get("type") == "response.output_text.delta" + ] + assert leftover == [], f"stale text would be replayed to the next subscriber: {leftover}" + + +@pytest.mark.asyncio +async def test_committed_turn_leaves_nothing_in_flight_on_the_server( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + patched_discovery: None, +) -> None: + """After a committed turn the server has NOTHING left to replay. + + This is the invariant behind the duplicated/cut-off responses: the server + replays any un-retired in-flight message to every new subscriber, so a + leftover entry re-renders a stale copy of an earlier answer beside the + current one on the next page load or turn. + + Asserted end-to-end by feeding the reader's own emitted events through + ``inflight_text`` — the real consumer — rather than by counting deltas, + because the reader commits from two paths (stream frames and the poll loop) + and their interleaving is not fixed. Whatever the order, nothing may + survive the turn. + """ + from omnigent.runtime import inflight_text + + full = "apple banana cherry" + sink = _PostSink() + + class _DeltaThenDrop: + """Streams a partial, then drops — the reader falls back to polling, + which is where the commit lands. This interleaving is the one that + regressed: the close lived only in the stream handler.""" + + def __call__(self, port: int, conversation_id: str) -> AsyncIterator[dict[str, object]]: + async def _gen() -> AsyncIterator[dict[str, object]]: + yield _frame([_generating_planner("apple banana")]) + raise httpx.ReadError("mid-stream drop") + + return _gen() + + await _run_stream( + bridge_dir=_bridge_dir(tmp_path), + sink=sink, + stream=_DeltaThenDrop(), + poll_steps=_StepScript([[_done_planner(full)], [_done_planner(full)]]), + monkeypatch=monkeypatch, + iterations=2, + ) + + inflight_text.reset_for_tests() + conv = "conv_under_test" + for event_type, data in sink.posts: + if event_type == "external_output_text_delta": + # Pass the emitted payload VERBATIM. Reconstructing it by hand is + # what hid this bug: omitting ``index`` skipped the server's + # strictly-increasing-index check, so the test retired messages the + # real server would never retire. + inflight_text.record_publish( + conv, + {"type": "response.output_text.delta", **data}, + ) + elif event_type == "external_conversation_item": + item = cast(dict[str, Any], data.get("item_data") or {}) + if item.get("role") == "assistant": + inflight_text.record_publish( + conv, + { + "type": "response.output_item.done", + "item": { + "id": "ap_1", + "type": "message", + "role": "assistant", + "content": item.get("content"), + }, + }, + ) + + leftover = [ + s + for s in inflight_text.snapshot_for(conv) + if s.get("type") == "response.output_text.delta" + ] + assert leftover == [], f"stale text would be replayed to the next subscriber: {leftover}" + + @pytest.mark.asyncio async def test_stream_generating_emits_incremental_reasoning_deltas( tmp_path: Path, @@ -2181,8 +2380,12 @@ async def _gen() -> AsyncIterator[dict[str, object]]: iterations=2, ) - # The pre-error delta was forwarded. - assert [d["delta"] for d in sink.deltas()] == [full] + # The pre-error delta was forwarded, and the poll-path commit CLOSED the + # live block: it flushes the missing suffix and marks the stream final. + # Without that the server holds a truncated entry in flight for the rest of + # the session and replays it to every later subscriber. + assert [d["delta"] for d in sink.deltas()] == [full, ", now complete."] + assert sink.deltas()[-1]["final"] is True # The poll fallback delivered the committed message. assert sink.item_types() == ["message"] @@ -2994,6 +3197,114 @@ def test_detect_rotation_older_sibling_returns_none() -> None: assert reader._detect_rotated_cascade(summaries, _BOUND_CASCADE) is None +def _child_summary( + *, + parent_cascade_id: str, + last_user_input_time: str = "2026-08-01T10:48:37.887431Z", + include_parent_id: bool = True, +) -> dict[str, Any]: + """Build a real agy SUBAGENT summary (live capture, agy 1.1.9). + + Captured from a live ``GetAllCascadeTrajectories`` while agy ran a subagent: + the child is typed ``CORTEX_TRAJECTORY_TYPE_CASCADE`` — byte-identical to its + parent — and is distinguishable ONLY by ``trajectoryMetadata``, which carries + ``parentConversationId`` / a foreign ``rootConversationId`` / ``nestingDepth`` + / ``subagentSpec``. + + :param parent_cascade_id: The parent (root) conversation id this child hangs off. + :param include_parent_id: When ``False``, omit ``parentConversationId`` so the + foreign ``rootConversationId`` is the only remaining child signal. + """ + metadata: dict[str, Any] = { + "createdAt": "2026-08-01T09:34:51.639408Z", + "initializationStateId": "11e484d0-0000-4000-8000-000000000000", + "rootConversationId": parent_cascade_id, + "nestingDepth": 1, + "subagentSpec": {"typeName": "self", "role": "Task 4 Reviewer", "inherit": True}, + } + if include_parent_id: + metadata["parentConversationId"] = parent_cascade_id + return { + "trajectoryId": "82e9fb54-9f16-46eb-88d7-a84fd40deec3", + "status": "CASCADE_RUN_STATUS_IDLE", + # NOT a distinct type — the same value a real root cascade reports. + "trajectoryType": "CORTEX_TRAJECTORY_TYPE_CASCADE", + "lastUserInputTime": last_user_input_time, + "summary": "MetricsStore Implementation Code Review", + "trajectoryMetadata": metadata, + } + + +def test_detect_rotation_subagent_child_is_never_a_rotation_target() -> None: + """A running agy SUBAGENT must never be rotated onto. + + agy spawns each subagent as a child conversation that reports the SAME + ``trajectoryType`` as a real root, and is always more recently active than the + parent it is working for. Rotating onto it promotes a sub-conversation to a + new top-level Omnigent session (and drags the tmux pane with it), which is + what made a single subagent fan-out explode into a session per agent. + """ + summaries = { + _BOUND_CASCADE: _summary(last_user_input_time="2026-08-01T10:49:38.660037Z"), + _OTHER_CASCADE: _child_summary( + parent_cascade_id=_BOUND_CASCADE, + # Strictly newer than the parent — the subagent is mid-turn while the + # parent sits idle waiting for it, so activity alone always favours it. + last_user_input_time="2026-08-01T10:50:00.000000Z", + ), + } + assert reader._detect_rotated_cascade(summaries, _BOUND_CASCADE) is None + + +def test_detect_rotation_child_detected_without_explicit_parent_id() -> None: + """A foreign ``rootConversationId`` alone marks a child, with no parent field.""" + summaries = { + _BOUND_CASCADE: _summary(last_user_input_time="2026-08-01T10:49:38.660037Z"), + _OTHER_CASCADE: _child_summary( + parent_cascade_id=_BOUND_CASCADE, + last_user_input_time="2026-08-01T10:50:00.000000Z", + include_parent_id=False, + ), + } + assert reader._detect_rotated_cascade(summaries, _BOUND_CASCADE) is None + + +def test_detect_rotation_child_of_a_third_cascade_is_also_excluded() -> None: + """A subagent of some OTHER root is still a child — not a rotation target. + + The child test must not degenerate into "is it my own child": a subagent + belonging to a different root is equally not the user's top-level conversation. + """ + summaries = { + _BOUND_CASCADE: _summary(last_user_input_time="2026-08-01T10:49:38.660037Z"), + _OTHER_CASCADE: _child_summary( + parent_cascade_id="ffffffff-1111-4222-8333-444444444444", + last_user_input_time="2026-08-01T10:50:00.000000Z", + ), + } + assert reader._detect_rotated_cascade(summaries, _BOUND_CASCADE) is None + + +def test_detect_rotation_real_clear_mint_still_rotates_with_self_root_metadata() -> None: + """A genuine /clear rotation still fires when metadata is self-referential. + + The live capture shows a real root carries ``rootConversationId == ``, so the child test must not swallow the /clear case this detector + exists for. + """ + other = dict(_summary(last_user_input_time="2026-08-01T10:50:00.000000Z")) + other["trajectoryMetadata"] = { + "createdAt": "2026-08-01T10:49:34.181600Z", + "initializationStateId": "87966e34-057b-4b63-afa2-a437cd67ea9d", + "rootConversationId": _OTHER_CASCADE, + } + summaries = { + _BOUND_CASCADE: _summary(last_user_input_time="2026-08-01T10:49:38.660037Z"), + _OTHER_CASCADE: other, + } + assert reader._detect_rotated_cascade(summaries, _BOUND_CASCADE) == _OTHER_CASCADE + + def test_detect_rotation_non_cascade_sibling_returns_none() -> None: """A newer NON-cascade (subagent) sibling must not be a rotation target. @@ -3855,3 +4166,442 @@ async def _noop_sleep(_seconds: float) -> None: ) assert calls == [1], "expected exactly one quiescence signal, after two idle ticks" + + +# --------------------------------------------------------------------------- +# Sub-agents: agy runs each as its own cascade, mirrored into a child session +# --------------------------------------------------------------------------- + + +async def _no_sleep(_seconds: float) -> None: + """Collapse the mirror's poll delay so a multi-poll test runs instantly.""" + return + + +async def _never_returns(**_kwargs: Any) -> None: + """Stand in for the mirror loop: stays pending until cancelled.""" + await asyncio.Event().wait() + + +async def _cancel_mirrors(state: reader._ReaderState) -> None: + """Cancel every mirror task a test started so none leaks past it.""" + for task in state.subagent_mirrors.values(): + task.cancel() + for task in state.subagent_mirrors.values(): + with contextlib.suppress(asyncio.CancelledError): + await task + + +def _invoke_subagent_step( + *, + status: str = "CORTEX_STEP_STATUS_RUNNING", + results: list[dict[str, Any]] | None = None, +) -> dict[str, Any]: + """ + An INVOKE_SUBAGENT step naming two sub-agents. + + Shaped after the live frame: ``results`` is filled positionally against + ``subagents`` and a child's ``conversationId`` appears as soon as that child + exists, while the step itself stays RUNNING until every child finishes. + """ + if results is None: + results = [{"conversationId": "child-aaa"}, {"conversationId": "child-bbb"}] + return { + "type": "CORTEX_STEP_TYPE_INVOKE_SUBAGENT", + "status": status, + "metadata": { + "toolAction": "Dispatching reviewers", + "toolSummary": "Review the codebase", + "sourceTrajectoryStepInfo": {"trajectoryId": _CASCADE_ID, "stepIndex": 36}, + }, + "invokeSubagent": { + "subagents": [ + {"typeName": "research", "role": "App Router Reviewer"}, + {"typeName": "research", "role": "Database Reviewer"}, + ], + "results": results, + }, + } + + +def _subagent_client( + child_ids: list[str] | None = None, +) -> tuple[httpx.AsyncClient, list[dict[str, Any]]]: + """A client whose registration POSTs answer with successive child ids.""" + captured: list[dict[str, Any]] = [] + remaining = list(child_ids if child_ids is not None else ["conv_child_a", "conv_child_b"]) + + def _handler(request: httpx.Request) -> httpx.Response: + body = json.loads(request.content) if request.content else {} + captured.append(body) + if body.get("type") == "external_antigravity_subagent_start": + minted = remaining.pop(0) if remaining else "conv_child_x" + return httpx.Response(200, json={"queued": False, "child_session_id": minted}) + return httpx.Response(200, json={"ok": True}) + + client = httpx.AsyncClient(base_url="http://test", transport=httpx.MockTransport(_handler)) + return client, captured + + +def _registrations(captured: list[dict[str, Any]]) -> list[dict[str, Any]]: + """Return the ``data`` of every sub-agent registration POST, in order.""" + return [ + body["data"] + for body in captured + if body.get("type") == "external_antigravity_subagent_start" + ] + + +def test_subagent_pairs_skips_a_child_agy_has_not_named_yet() -> None: + """ + A result slot with no ``conversationId`` is not yet mirrorable. + + agy fills the results list positionally as children spawn, so an early frame + carries a placeholder slot; registering it would mint a child row keyed on + nothing. + """ + step = _invoke_subagent_step(results=[{"conversationId": "child-aaa"}, {}]) + pairs = reader._subagent_pairs(step) + assert [child_id for _spec, child_id in pairs] == ["child-aaa"] + + +def test_subagent_pairs_ignores_a_non_subagent_step() -> None: + """An ordinary tool step names no children.""" + assert reader._subagent_pairs(_load("run_command_done")) == [] + + +async def test_each_subagent_is_registered_once_across_repeated_frames( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """ + REGRESSION: the owning step is re-sent on every frame while sub-agents work. + + ``_maybe_mirror_subagents`` runs unconditionally (the step is not ``seen`` + until it settles), so without the ``subagent_mirrors`` guard each frame would + register the same children again and stack duplicate mirror tasks against + them. + """ + monkeypatch.setattr(reader, "_mirror_subagent_cascade", _never_returns) + client, captured = _subagent_client() + state = reader._ReaderState(seen=set(), interacted=set(), port=4242) + + async with client: + for _ in range(3): + await reader._maybe_mirror_subagents( + _invoke_subagent_step(), + client=client, + session_id=_SESSION_ID, + cascade_id=_CASCADE_ID, + state=state, + ) + registrations = _registrations(captured) + assert [r["cascade_id"] for r in registrations] == ["child-aaa", "child-bbb"], ( + f"Each child must register exactly once; got {registrations}" + ) + assert sorted(state.subagent_mirrors) == ["child-aaa", "child-bbb"] + await _cancel_mirrors(state) + + +async def test_registration_carries_the_role_type_and_spawning_tool_call( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """ + The rail names the sub-agent, and the row links back to its tool card. + + ``tool_call_id`` is the INVOKE_SUBAGENT step's own mirrored call id, so the + child and the tool card that spawned it share one identity. + """ + monkeypatch.setattr(reader, "_mirror_subagent_cascade", _never_returns) + client, captured = _subagent_client() + state = reader._ReaderState(seen=set(), interacted=set(), port=4242) + + async with client: + await reader._maybe_mirror_subagents( + _invoke_subagent_step(), + client=client, + session_id=_SESSION_ID, + cascade_id=_CASCADE_ID, + state=state, + ) + first = _registrations(captured)[0] + assert first["role"] == "App Router Reviewer" + assert first["agent_type"] == "research" + assert first["tool_call_id"] == f"agy_call_{_CASCADE_ID}_36" + await _cancel_mirrors(state) + + +async def test_a_rejected_registration_starts_no_mirror( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """ + A server that will not mint a child leaves nothing polling for it. + + Without this the mirror would poll a cascade forever with nowhere to post. + """ + monkeypatch.setattr(reader, "_mirror_subagent_cascade", _never_returns) + + def _handler(request: httpx.Request) -> httpx.Response: + return httpx.Response(500, json={"error": "nope"}) + + client = httpx.AsyncClient(base_url="http://test", transport=httpx.MockTransport(_handler)) + state = reader._ReaderState(seen=set(), interacted=set(), port=4242) + + async with client: + await reader._maybe_mirror_subagents( + _invoke_subagent_step(), + client=client, + session_id=_SESSION_ID, + cascade_id=_CASCADE_ID, + state=state, + ) + assert state.subagent_mirrors == {} + + +async def test_a_settled_invoke_step_does_not_stop_its_children( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """ + REGRESSION: ``invoke_subagent`` is fire-and-forget, not a wait. + + Its step reaches DONE the moment the child is spawned while the child runs on + for minutes (live-verified: a child kept working 23s past its spawning step's + ``completedAt`` and accumulated 35 steps). Treating that DONE as "the + sub-agents finished" mirrored only each child's opening prompt and left every + row spinning, so the handler must not read the step's status at all. + """ + monkeypatch.setattr(reader, "_mirror_subagent_cascade", _never_returns) + client, _captured = _subagent_client() + state = reader._ReaderState(seen=set(), interacted=set(), port=4242) + + async with client: + await reader._maybe_mirror_subagents( + _invoke_subagent_step(status="CORTEX_STEP_STATUS_DONE"), + client=client, + session_id=_SESSION_ID, + cascade_id=_CASCADE_ID, + state=state, + ) + mirrors = list(state.subagent_mirrors.values()) + assert len(mirrors) == 2, "both children must still be mirrored" + assert not any(task.done() for task in mirrors), ( + "a DONE invoke step must not stop the mirrors it started" + ) + await _cancel_mirrors(state) + + +# Hang guard for the mirror-loop tests. Deliberately far above any healthy run +# (these poll a patched transport — milliseconds locally): a shared CI runner can +# be an order of magnitude slower, and a budget tight enough to catch that is a +# flake, not a signal. Termination is asserted by the loop RETURNING; this only +# stops a genuine hang from wedging the suite. +_MIRROR_HANG_GUARD_S = 60 + +# Polls a "still working" child answers before it finishes, in the quiescence +# test. With the timer patched to 2, this is enough for the status veto to fire +# twice — the behaviour under test — without needing a long loop, which is what +# made the first version of this test time out on CI. +_QUIET_POLLS_BEFORE_FINISHING = 5 + + +def _child_transcript() -> list[dict[str, Any]]: + """A finished sub-agent's steps: its prompt, a tool call, its closing reply.""" + return [ + _load("user_input"), + _load("run_command_done"), + _done_planner("Task complete.", step_index=9), + ] + + +async def test_mirror_posts_the_child_cascade_into_the_child_session( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """ + A sub-agent's own steps reach its child session, and the mirror then stops. + + A sub-agent cascade is an ordinary cascade on the same agy port, so mirroring + is the committed-only poll path pointed at the child. A child that finished + between two polls delivers its whole transcript in ONE pass — opening and + closing its turn before the poll returns — so the turn-open check has to run + per step, not per poll, or the mirror never notices it ended. + """ + monkeypatch.setattr(reader, "get_trajectory_steps", lambda _p, _c: _child_transcript()) + sink = _PostSink() + monkeypatch.setattr(reader, "post_session_event_with_retry", sink) + + client, _captured = _subagent_client() + async with client: + await asyncio.wait_for( + reader._mirror_subagent_cascade( + port=4242, + child_cascade_id="child-aaa", + client=client, + child_session_id="conv_child_a", + ), + timeout=_MIRROR_HANG_GUARD_S, + ) + + assert sink.item_types() == ["message", "function_call", "function_call_output", "message"], ( + f"the child's whole transcript must be mirrored; got {sink.item_types()}" + ) + assert set(sink.urls) == {"/v1/sessions/conv_child_a/events"}, ( + f"the child's work must post to the CHILD session; got {set(sink.urls)}" + ) + # The closing step fires the child's own IDLE edge, clearing the rail badge. + assert sink.statuses() == ["running", "idle"], ( + f"Expected the child's turn to open then close; got {sink.statuses()}" + ) + + +async def test_mirror_keeps_polling_while_the_child_is_still_working( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """ + REGRESSION: the mirror must outlive the child's opening prompt. + + The bug that shipped stopped every mirror as soon as the spawning step went + DONE, so each child recorded exactly its ``USER_INPUT`` prompt — one item + against the 16-101 steps the sub-agents actually ran. Here the child's later + steps only appear on the third poll; a mirror that gives up early misses + them. + """ + working = [_load("user_input")] # turn open, still working + finished = _child_transcript() + polls = 0 + + def _steps(_port: int, _cascade: str) -> list[dict[str, Any]]: + nonlocal polls + polls += 1 + return working if polls < 3 else finished + + monkeypatch.setattr(reader, "get_trajectory_steps", _steps) + monkeypatch.setattr(reader, "_sleep", _no_sleep) + sink = _PostSink() + monkeypatch.setattr(reader, "post_session_event_with_retry", sink) + + client, _captured = _subagent_client() + async with client: + await asyncio.wait_for( + reader._mirror_subagent_cascade( + port=4242, + child_cascade_id="child-aaa", + client=client, + child_session_id="conv_child_a", + ), + timeout=_MIRROR_HANG_GUARD_S, + ) + + assert polls >= 3, f"the mirror stopped after {polls} polls, before the child finished" + assert "function_call" in sink.item_types(), ( + f"the child's later work must be mirrored; got {sink.item_types()}" + ) + + +async def test_a_child_that_goes_quiet_without_closing_is_closed_from_agy_status( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """ + A sub-agent whose turn never closes still clears its rail badge. + + The child's own closing step is the ordinary signal, but a transcript that + ends without a recognizable one would leave the mirror polling for the rest + of the session and the row spinning forever. agy's run status is the + authority for that stall. + """ + monkeypatch.setattr(reader, "get_trajectory_steps", lambda _p, _c: [_load("user_input")]) + monkeypatch.setattr(reader, "_sleep", _no_sleep) + monkeypatch.setattr(reader, "_SUBAGENT_QUIESCENT_POLLS", 2) + monkeypatch.setattr( + reader, + "get_all_cascade_trajectories", + lambda _port: { + "trajectorySummaries": {"child-aaa": {"status": "CASCADE_RUN_STATUS_IDLE"}} + }, + ) + sink = _PostSink() + monkeypatch.setattr(reader, "post_session_event_with_retry", sink) + + client, _captured = _subagent_client() + async with client: + await asyncio.wait_for( + reader._mirror_subagent_cascade( + port=4242, + child_cascade_id="child-aaa", + client=client, + child_session_id="conv_child_a", + ), + timeout=_MIRROR_HANG_GUARD_S, + ) + + assert sink.statuses() == ["running", "idle"], ( + f"a stalled child must still be closed out; got {sink.statuses()}" + ) + + +async def test_a_quiet_child_agy_still_reports_running_is_not_closed( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """ + Quiet is not finished: a sub-agent mid-build produces no steps for minutes. + + Closing on quiescence alone would truncate its transcript, so the status + check must be able to veto — this pins that a non-idle cascade keeps the + mirror alive rather than being closed on the timer. + """ + working = [_load("user_input")] + finished = _child_transcript() # what it returns once the child is done + polls = 0 + idle_checks = 0 + + def _steps(_port: int, _cascade: str) -> list[dict[str, Any]]: + nonlocal polls + polls += 1 + return working if polls <= _QUIET_POLLS_BEFORE_FINISHING else finished + + def _still_running(_port: int) -> dict[str, Any]: + nonlocal idle_checks + idle_checks += 1 + return {"trajectorySummaries": {"child-aaa": {"status": "CASCADE_RUN_STATUS_RUNNING"}}} + + monkeypatch.setattr(reader, "get_trajectory_steps", _steps) + monkeypatch.setattr(reader, "_sleep", _no_sleep) + monkeypatch.setattr(reader, "_SUBAGENT_QUIESCENT_POLLS", 2) + monkeypatch.setattr(reader, "get_all_cascade_trajectories", _still_running) + sink = _PostSink() + monkeypatch.setattr(reader, "post_session_event_with_retry", sink) + + client, _captured = _subagent_client() + async with client: + await asyncio.wait_for( + reader._mirror_subagent_cascade( + port=4242, + child_cascade_id="child-aaa", + client=client, + child_session_id="conv_child_a", + ), + timeout=_MIRROR_HANG_GUARD_S, + ) + + # The veto is the point: the timer fired and agy's status overruled it, more + # than once, and the mirror was still alive to see the child finish. + assert idle_checks >= 2, f"the quiescence timer should have fired twice; got {idle_checks}" + assert polls > _QUIET_POLLS_BEFORE_FINISHING, "the mirror gave up before the child finished" + assert sink.statuses() == ["running", "idle"], ( + f"the child closed on its own step, not the timer; got {sink.statuses()}" + ) + + +async def test_subagent_idle_check_failure_keeps_mirroring( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """ + An RPC hiccup during the idle check must not truncate a sub-agent. + + Fail-safe: a wrong "idle" ends the mirror and loses the rest of the child's + transcript, while a wrong "still running" costs only another poll. + """ + + def _raise(_port: int) -> dict[str, Any]: + raise httpx.ConnectError("agy went away") + + monkeypatch.setattr(reader, "get_all_cascade_trajectories", _raise) + assert await reader._subagent_cascade_is_idle(4242, "child-aaa") is False diff --git a/tests/test_antigravity_native_rpc.py b/tests/test_antigravity_native_rpc.py index 42a9ede9ae..0957bbbce0 100644 --- a/tests/test_antigravity_native_rpc.py +++ b/tests/test_antigravity_native_rpc.py @@ -2055,10 +2055,46 @@ def test_cold_start_port_falls_back_when_agy_found_but_no_port( "resolve_pane_agy_rpc_port_state", lambda _sock, _tgt: rpc.PaneAgyResolution(agy_found=True, port=None), ) + # Restricted /proc: lsof attributes NO port for ANY agy, so the missing port + # really is an attribution limit rather than a still-booting agy. + monkeypatch.setattr(rpc, "_can_attribute_any_agy_port", lambda: False) monkeypatch.setattr(rpc, "_candidate_agy_rpc_ports", lambda: [52548]) assert rpc.resolve_cold_start_agy_rpc_port(Path("/tmp/agy/tmux.sock"), "main") == 52548 +def test_cold_start_port_keeps_polling_when_lsof_works_but_our_agy_has_no_port( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """State 2 split: lsof WORKS here, so "no port for our agy" means not-yet-bound. + + The agy boot window on a normal host: tmux has already exec-ed agy (so it IS + in the pane subtree) but it has not bound its connect-RPC listener yet, ~250ms + after launch. lsof is perfectly able to attribute ports — it attributes one + for a DIFFERENT, older agy — so the absence of a port for ours is evidence it + is still starting, not evidence that attribution is impossible. + + Falling back to the candidate scan here hands us a FOREIGN agy and durably + cross-binds the session: the new conversation is StartCascade-d inside another + session's agy, the reader then adopts that agy's active cascade, and the user + sees their existing conversation duplicated while their own agy is orphaned. + """ + monkeypatch.setattr( + rpc, + "resolve_pane_agy_rpc_port_state", + lambda _sock, _tgt: rpc.PaneAgyResolution(agy_found=True, port=None), + ) + # lsof is functional on this host: it attributes a port for the other agy. + monkeypatch.setattr(rpc, "_can_attribute_any_agy_port", lambda: True) + monkeypatch.setattr( + rpc, + "_candidate_agy_rpc_ports", + lambda: (_ for _ in ()).throw( + AssertionError("lsof works; must keep polling rather than scan candidates") + ), + ) + assert rpc.resolve_cold_start_agy_rpc_port(Path("/tmp/agy/tmux.sock"), "main") is None + + def test_cold_start_port_no_pane_uses_candidate(monkeypatch: pytest.MonkeyPatch) -> None: """No pane supplied (remote runner) → lowest candidate; pane resolver untouched.""" monkeypatch.setattr( @@ -2078,3 +2114,80 @@ def test_cold_start_port_no_pane_none_when_no_candidates( """No pane and no candidates yet → ``None`` (keep polling).""" monkeypatch.setattr(rpc, "_candidate_agy_rpc_ports", list) assert rpc.resolve_cold_start_agy_rpc_port(None, None) is None + + +# --------------------------------------------------------------------------- +# Port attribution without lsof (psutil primary) +# --------------------------------------------------------------------------- +# +# agy binds an ephemeral connect-RPC port and advertises it nowhere, so omnigent +# must reverse-discover it. Shelling out to ``lsof`` made that an undeclared +# system dependency: absent it (minimal Debian, slim containers), attribution +# silently failed and the cold-start bound a FOREIGN agy. psutil is already a +# hard dependency and is cross-platform, so it is the primary path. + + +def test_pid_listen_ports_uses_psutil_without_lsof(monkeypatch: pytest.MonkeyPatch) -> None: + """Loopback LISTEN ports resolve via psutil even when lsof is missing.""" + + class _Addr: + def __init__(self, ip: str, port: int) -> None: + self.ip, self.port = ip, port + + class _Conn: + def __init__(self, ip: str, port: int, status: str) -> None: + self.laddr, self.status = _Addr(ip, port), status + + class _Proc: + def __init__(self, _pid: int) -> None: + pass + + def net_connections(self, kind: str = "tcp") -> list[_Conn]: + import psutil + + return [ + _Conn("127.0.0.1", 44361, psutil.CONN_LISTEN), + _Conn("127.0.0.1", 34601, psutil.CONN_LISTEN), + _Conn("127.0.0.1", 51000, psutil.CONN_ESTABLISHED), # not listening + _Conn("10.0.0.5", 8080, psutil.CONN_LISTEN), # not loopback + ] + + monkeypatch.setattr(rpc.psutil, "Process", _Proc) + # lsof is absent on this host. + monkeypatch.setattr( + rpc, + "_run_lsof_listen_ports", + lambda _pid: (_ for _ in ()).throw(AssertionError("no lsof")), + ) + assert rpc._pid_listen_ports(72753) == [34601, 44361] + + +def test_pid_listen_ports_falls_back_to_lsof_when_psutil_denied( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A psutil failure (AccessDenied / gone) falls back to lsof, not an error.""" + import psutil + + class _Proc: + def __init__(self, _pid: int) -> None: + raise psutil.AccessDenied(72753) + + monkeypatch.setattr(rpc.psutil, "Process", _Proc) + monkeypatch.setattr(rpc, "_run_lsof_listen_ports", lambda _pid: "TCP 127.0.0.1:52548 (LISTEN)") + assert rpc._pid_listen_ports(72753) == [52548] + + +def test_pid_listen_ports_empty_when_neither_source_attributes( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Both sources blind (restricted /proc) → empty, so callers keep their + existing 'cannot attribute' semantics rather than seeing a wrong port.""" + import psutil + + class _Proc: + def __init__(self, _pid: int) -> None: + raise psutil.AccessDenied(72753) + + monkeypatch.setattr(rpc.psutil, "Process", _Proc) + monkeypatch.setattr(rpc, "_run_lsof_listen_ports", lambda _pid: "") + assert rpc._pid_listen_ports(72753) == [] diff --git a/tests/test_antigravity_native_steps.py b/tests/test_antigravity_native_steps.py index 2820d0e589..f24464e296 100644 --- a/tests/test_antigravity_native_steps.py +++ b/tests/test_antigravity_native_steps.py @@ -1,21 +1,21 @@ """Tests for the pure RPC step→item mapper. These exercise :func:`omnigent.antigravity_native_steps.map_step_to_events` -using the real recorded fixtures captured from live agy sessions (Task 1). -No I/O, no live agy: the mapper is driven with fixture dicts and event shapes -are asserted exactly. +using the real recorded fixtures captured from live agy sessions — from BOTH +RPC shapes, since they differ (``stream_*`` fixtures are verbatim +``StreamAgentStateUpdates`` frames, the rest are ``GetCascadeTrajectorySteps`` +snapshots). No I/O, no live agy: the mapper is driven with fixture dicts and +event shapes are asserted exactly. Key assertions: - PLANNER_RESPONSE with text → exactly one ``external_conversation_item`` ``message`` (role assistant, ``output_text`` content). NO ``external_output_text_delta`` / ``output_text_delta`` event. - USER_INPUT → ``[]`` (skipped — fixes user-dup). -- PLANNER_RESPONSE with tool_calls → ``function_call`` item(s) via allocator. -- RUN_COMMAND DONE → ``function_call_output`` carrying - ``runCommand.combinedOutput.full``. -- RUN_COMMAND WAITING → ``function_call`` only (no output yet). -- ASK_QUESTION WAITING → ``function_call`` only (no output yet). -- ASK_QUESTION DONE → ``function_call_output`` carrying the formatted answer. +- PLANNER_RESPONSE with tool_calls → ``[]``; the tool step owns the pair. +- A tool step at terminal status → its ``function_call`` AND the matching + ``function_call_output``, sharing a step-derived call id, on either shape. +- RUN_COMMAND / ASK_QUESTION WAITING → ``[]`` (no result yet). - CHECKPOINT / CONVERSATION_HISTORY → ``[]``. """ @@ -28,7 +28,6 @@ from omnigent.antigravity_native_steps import ( OutboundEvent, _execution_discriminator, - _ToolCallIdAllocator, map_step_to_events, output_reasoning_delta_event, pending_interaction, @@ -41,6 +40,22 @@ _FIXTURES = Path(__file__).parent / "fixtures" / "antigravity" / "steps" _CID = "test-conversation-id" +# Every recorded fixture belongs to this trajectory; tool-call ids are derived +# from ``(trajectory, step index)`` so they are predictable per fixture. +_TRAJ = "efb134b2-d69f-43de-bb54-c9ece346d8a3" + + +def _call_id(step_index: int) -> str: + """The call id the mapper derives for a fixture step in this trajectory.""" + return f"agy_call_{_TRAJ}_{step_index}" + + +def _item(event: OutboundEvent) -> dict[str, Any]: + """Return one event's ``item_data``, asserting it is a dict.""" + data = event.data["item_data"] + assert isinstance(data, dict) + return data + def _load(name: str) -> dict[str, Any]: """Load one step fixture by filename (without extension).""" @@ -48,11 +63,6 @@ def _load(name: str) -> dict[str, Any]: return cast(dict[str, Any], json.loads(path.read_text())) -def _allocator() -> _ToolCallIdAllocator: - """Fresh allocator for each test.""" - return _ToolCallIdAllocator(conversation_id=_CID) - - # --------------------------------------------------------------------------- # Helper: assert no delta event at all # --------------------------------------------------------------------------- @@ -90,7 +100,7 @@ class TestUserInputCommitted: def test_user_input_commits_user_message(self) -> None: """USER_INPUT step → exactly one committed user ``message`` item.""" step = _load("user_input") - events = map_step_to_events(step, conversation_id=_CID, allocator=_allocator()) + events = map_step_to_events(step, conversation_id=_CID) assert len(events) == 1 ev = events[0] assert ev.event_type == "external_conversation_item" @@ -105,13 +115,13 @@ def test_user_input_commits_user_message(self) -> None: def test_user_input_no_delta(self) -> None: """USER_INPUT commits a message but emits no streaming delta events.""" step = _load("user_input") - events = map_step_to_events(step, conversation_id=_CID, allocator=_allocator()) + events = map_step_to_events(step, conversation_id=_CID) _assert_no_delta(events) def test_user_input_without_text_is_skipped(self) -> None: """A USER_INPUT step with no recoverable text emits nothing (no empty bubble).""" step = {"type": "CORTEX_STEP_TYPE_USER_INPUT", "userInput": {}} - events = map_step_to_events(step, conversation_id=_CID, allocator=_allocator()) + events = map_step_to_events(step, conversation_id=_CID) assert events == [] @@ -131,25 +141,25 @@ def test_returns_exactly_one_event(self) -> None: message); the new mapper emits 1. """ step = _load("planner_response_text") - events = map_step_to_events(step, conversation_id=_CID, allocator=_allocator()) + events = map_step_to_events(step, conversation_id=_CID) assert len(events) == 1 def test_event_type_is_conversation_item(self) -> None: """The single event has type ``external_conversation_item``.""" step = _load("planner_response_text") - events = map_step_to_events(step, conversation_id=_CID, allocator=_allocator()) + events = map_step_to_events(step, conversation_id=_CID) assert events[0].event_type == "external_conversation_item" def test_item_type_is_message(self) -> None: """The event's ``item_type`` is ``"message"``.""" step = _load("planner_response_text") - events = map_step_to_events(step, conversation_id=_CID, allocator=_allocator()) + events = map_step_to_events(step, conversation_id=_CID) assert events[0].data["item_type"] == "message" def test_message_role_is_assistant(self) -> None: """The ``message`` item has role ``"assistant"``.""" step = _load("planner_response_text") - events = map_step_to_events(step, conversation_id=_CID, allocator=_allocator()) + events = map_step_to_events(step, conversation_id=_CID) item_data = events[0].data["item_data"] assert isinstance(item_data, dict) assert item_data["role"] == "assistant" @@ -160,7 +170,7 @@ def test_message_content_is_output_text(self) -> None: fixture's ``plannerResponse.response`` text. """ step = _load("planner_response_text") - events = map_step_to_events(step, conversation_id=_CID, allocator=_allocator()) + events = map_step_to_events(step, conversation_id=_CID) item_data = events[0].data["item_data"] assert isinstance(item_data, dict) content = item_data["content"] @@ -180,20 +190,20 @@ def test_no_delta_event(self) -> None: event before the message; the new mapper drops it entirely. """ step = _load("planner_response_text") - events = map_step_to_events(step, conversation_id=_CID, allocator=_allocator()) + events = map_step_to_events(step, conversation_id=_CID) _assert_no_delta(events) def test_step_index_from_fixture(self) -> None: """step_index on the event matches the fixture's sourceTrajectoryStepInfo.stepIndex.""" step = _load("planner_response_text") - events = map_step_to_events(step, conversation_id=_CID, allocator=_allocator()) + events = map_step_to_events(step, conversation_id=_CID) # planner_response_text.json has stepIndex=2 assert events[0].step_index == 2 def test_response_id_stable(self) -> None: """response_id is deterministic: ``agy__``.""" step = _load("planner_response_text") - events = map_step_to_events(step, conversation_id=_CID, allocator=_allocator()) + events = map_step_to_events(step, conversation_id=_CID) assert events[0].data["response_id"] == f"agy_{_CID}_2" @@ -210,7 +220,7 @@ def test_error_planner_emits_one_error_message(self) -> None: step = _load("planner_response_text") step["status"] = "CORTEX_STEP_STATUS_ERROR" step["plannerResponse"] = {} # no text/error detail -> generic marker - events = map_step_to_events(step, conversation_id=_CID, allocator=_allocator()) + events = map_step_to_events(step, conversation_id=_CID) assert len(events) == 1 ev = events[0] assert ev.event_type == "external_conversation_item" @@ -226,7 +236,7 @@ def test_error_planner_includes_error_detail_when_present(self) -> None: step = _load("planner_response_text") step["status"] = "CORTEX_STEP_STATUS_ERROR" step["plannerResponse"] = {"error": "model overloaded (503)"} - events = map_step_to_events(step, conversation_id=_CID, allocator=_allocator()) + events = map_step_to_events(step, conversation_id=_CID) assert "model overloaded (503)" in events[0].data["item_data"]["content"][0]["text"] @@ -235,102 +245,23 @@ def test_error_planner_includes_error_detail_when_present(self) -> None: # --------------------------------------------------------------------------- -class TestPlannerResponseToolCallRunCommand: - """PLANNER_RESPONSE with run_command tool call → function_call event(s).""" - - def test_returns_one_function_call(self) -> None: - """One tool call → one ``function_call`` event.""" - step = _load("planner_response_tool_call_run_command") - events = map_step_to_events(step, conversation_id=_CID, allocator=_allocator()) - assert len(events) == 1 - assert events[0].data["item_type"] == "function_call" - - def test_function_call_name(self) -> None: - """The function_call name matches the fixture's toolCall name.""" - step = _load("planner_response_tool_call_run_command") - events = map_step_to_events(step, conversation_id=_CID, allocator=_allocator()) - item_data = events[0].data["item_data"] - assert isinstance(item_data, dict) - assert item_data["name"] == "run_command" - - def test_function_call_id_is_real_agy_id(self) -> None: - """ - call_id is the real agy-assigned id from plannerResponse.toolCalls[].id. - - The fixture carries id="cbawg2v8"; the mapper must use that directly, - NOT synthesize a positional id from the allocator. - """ - alloc = _allocator() - step = _load("planner_response_tool_call_run_command") - events = map_step_to_events(step, conversation_id=_CID, allocator=alloc) - item_data = events[0].data["item_data"] - assert isinstance(item_data, dict) - # Real agy id from the fixture - assert item_data["call_id"] == "cbawg2v8" - # Allocator must NOT have been advanced (real id was used instead) - assert alloc.invocation_count == 0 - - def test_function_call_arguments_strip_display_keys(self) -> None: - """ - ``toolAction`` and ``toolSummary`` are stripped from the function - arguments; the real command args remain. - """ - step = _load("planner_response_tool_call_run_command") - events = map_step_to_events(step, conversation_id=_CID, allocator=_allocator()) - item_data = events[0].data["item_data"] - assert isinstance(item_data, dict) - args_text = item_data["arguments"] - assert isinstance(args_text, str) - args = json.loads(args_text) - assert "toolAction" not in args - assert "toolSummary" not in args - # Real args remain - assert "CommandLine" in args - - def test_no_delta_event(self) -> None: - """No delta event is emitted for a tool-call-only PLANNER_RESPONSE.""" - step = _load("planner_response_tool_call_run_command") - events = map_step_to_events(step, conversation_id=_CID, allocator=_allocator()) - _assert_no_delta(events) +class TestPlannerToolCallsNotMirrored: + """ + A planner's ``toolCalls`` is never mirrored — the tool step owns the pair. - def test_step_index(self) -> None: - """step_index matches fixture stepIndex=5.""" - step = _load("planner_response_tool_call_run_command") - events = map_step_to_events(step, conversation_id=_CID, allocator=_allocator()) - assert events[0].step_index == 5 + The live stream strips ``plannerResponse.toolCalls`` altogether, so mirroring + from there produced nothing at all for streamed turns. Mapping it on the poll + shape only would post a SECOND, differently-keyed invocation for every tool + the moment the reader fell back to polling. + """ - -class TestPlannerResponseToolCallAskQuestion: - """PLANNER_RESPONSE with ask_question tool call → function_call event.""" - - def test_returns_one_function_call(self) -> None: - """One ask_question tool call → one ``function_call`` event.""" - step = _load("planner_response_tool_call_ask_question") - events = map_step_to_events(step, conversation_id=_CID, allocator=_allocator()) - assert len(events) == 1 - assert events[0].data["item_type"] == "function_call" - - def test_function_call_name(self) -> None: - """The function_call name is ``ask_question``.""" - step = _load("planner_response_tool_call_ask_question") - events = map_step_to_events(step, conversation_id=_CID, allocator=_allocator()) - item_data = events[0].data["item_data"] - assert isinstance(item_data, dict) - assert item_data["name"] == "ask_question" - - def test_function_call_id_is_real_agy_id(self) -> None: - """ - call_id is the real agy-assigned id from plannerResponse.toolCalls[].id. - - The fixture carries id="jfizoalt"; the allocator must NOT advance. - """ - alloc = _allocator() - step = _load("planner_response_tool_call_ask_question") - events = map_step_to_events(step, conversation_id=_CID, allocator=alloc) - item_data = events[0].data["item_data"] - assert isinstance(item_data, dict) - assert item_data["call_id"] == "jfizoalt" - assert alloc.invocation_count == 0 + def test_tool_call_only_planner_emits_nothing(self) -> None: + """A DONE planner with tool calls but no text maps to no items.""" + for name in ( + "planner_response_tool_call_run_command", + "planner_response_tool_call_ask_question", + ): + assert map_step_to_events(_load(name), conversation_id=_CID) == [] # --------------------------------------------------------------------------- @@ -339,25 +270,22 @@ def test_function_call_id_is_real_agy_id(self) -> None: class TestRunCommandDone: - """RUN_COMMAND DONE step → ``function_call_output`` with combinedOutput.""" + """RUN_COMMAND DONE step → the invocation plus its combinedOutput.""" - def test_returns_one_event(self) -> None: - """One DONE run_command → one event.""" + def test_returns_the_pair(self) -> None: + """One DONE run_command → its ``function_call`` and its output.""" step = _load("run_command_done") - events = map_step_to_events(step, conversation_id=_CID, allocator=_allocator()) - assert len(events) == 1 + events = map_step_to_events(step, conversation_id=_CID) + assert [event.data["item_type"] for event in events] == [ + "function_call", + "function_call_output", + ] def test_event_type_is_conversation_item(self) -> None: """event_type is ``external_conversation_item``.""" step = _load("run_command_done") - events = map_step_to_events(step, conversation_id=_CID, allocator=_allocator()) - assert events[0].event_type == "external_conversation_item" - - def test_item_type_is_function_call_output(self) -> None: - """item_type is ``function_call_output``.""" - step = _load("run_command_done") - events = map_step_to_events(step, conversation_id=_CID, allocator=_allocator()) - assert events[0].data["item_type"] == "function_call_output" + events = map_step_to_events(step, conversation_id=_CID) + assert {event.event_type for event in events} == {"external_conversation_item"} def test_output_from_combined_output_full(self) -> None: """ @@ -366,32 +294,25 @@ def test_output_from_combined_output_full(self) -> None: The fixture has ``combinedOutput.full = '/Users/bryanli/...scratch\\n'``. """ step = _load("run_command_done") - events = map_step_to_events(step, conversation_id=_CID, allocator=_allocator()) - item_data = events[0].data["item_data"] - assert isinstance(item_data, dict) - assert item_data["output"] == "/Users/bryanli/.gemini/antigravity-cli/scratch\n" + events = map_step_to_events(step, conversation_id=_CID) + assert _item(events[1])["output"] == "/Users/bryanli/.gemini/antigravity-cli/scratch\n" - def test_call_id_is_real_agy_id(self) -> None: + def test_pair_shares_the_step_derived_call_id(self) -> None: """ - call_id is the real agy-assigned id from metadata.toolCall.id. + Both items are keyed on the step's own ``(trajectory, index)`` identity. - The fixture carries toolCall.id="cbawg2v8", matching the invocation - step's plannerResponse.toolCalls[0].id. The allocator must NOT be - consulted (no pending ids needed). + agy's ``metadata.toolCall.id`` ("cbawg2v8" here) is deliberately not + used: streamed steps carry no such id, so keying on it would make the + id depend on which RPC delivered the step. """ step = _load("run_command_done") - alloc = _allocator() - events = map_step_to_events(step, conversation_id=_CID, allocator=alloc) - item_data = events[0].data["item_data"] - assert isinstance(item_data, dict) - assert item_data["call_id"] == "cbawg2v8" - # Allocator was not used (no orphan id minted) - assert alloc.orphan_output_count == 0 + events = map_step_to_events(step, conversation_id=_CID) + assert {_item(event)["call_id"] for event in events} == {_call_id(6)} def test_step_index(self) -> None: """step_index matches fixture stepIndex=6.""" step = _load("run_command_done") - events = map_step_to_events(step, conversation_id=_CID, allocator=_allocator()) + events = map_step_to_events(step, conversation_id=_CID) assert events[0].step_index == 6 @@ -412,13 +333,13 @@ class TestRunCommandWaiting: def test_waiting_emits_no_output_event(self) -> None: """WAITING run_command → empty list (no function_call_output).""" step = _load("run_command_waiting") - events = map_step_to_events(step, conversation_id=_CID, allocator=_allocator()) + events = map_step_to_events(step, conversation_id=_CID) assert events == [] def test_waiting_no_delta(self) -> None: """No delta event from a WAITING run_command.""" step = _load("run_command_waiting") - events = map_step_to_events(step, conversation_id=_CID, allocator=_allocator()) + events = map_step_to_events(step, conversation_id=_CID) _assert_no_delta(events) @@ -430,41 +351,38 @@ def test_waiting_no_delta(self) -> None: class TestRunCommandError: """A terminal-ERROR tool step must still close its ``function_call``. - The invocation side emits a ``function_call`` for the tool unconditionally, - so an ERROR result (e.g. an ignored/timed-out interactive prompt that flips - WAITING→ERROR) must emit a paired ``function_call_output`` keyed on the same - id, or the web UI strands a perpetual in-progress tool card. + An ERROR result (e.g. an ignored/timed-out interactive prompt that flips + WAITING→ERROR) carries no result text, but the pair must still be emitted + with a marker, or the web UI strands a perpetual in-progress tool card. """ - def test_error_emits_one_output_event(self) -> None: - """A failed (ERROR-status) run_command emits one function_call_output.""" + def test_error_emits_the_pair(self) -> None: + """A failed (ERROR-status) run_command still emits both items.""" step = _load("run_command_error") - events = map_step_to_events(step, conversation_id=_CID, allocator=_allocator()) - assert len(events) == 1 - assert events[0].data["item_type"] == "function_call_output" + events = map_step_to_events(step, conversation_id=_CID) + assert [event.data["item_type"] for event in events] == [ + "function_call", + "function_call_output", + ] - def test_error_output_keyed_on_real_id(self) -> None: - """The error output pairs by the real agy id (matches the invocation).""" + def test_error_output_shares_the_invocation_id(self) -> None: + """The error output pairs with the invocation emitted beside it.""" step = _load("run_command_error") - events = map_step_to_events(step, conversation_id=_CID, allocator=_allocator()) - item_data = cast(dict[str, Any], events[0].data["item_data"]) - # Fixture carries toolCall.id="cbawg2v8" — the same id the planner - # invocation emits, so the pair correlates. - assert item_data["call_id"] == "cbawg2v8" + events = map_step_to_events(step, conversation_id=_CID) + assert {_item(event)["call_id"] for event in events} == {_call_id(6)} def test_error_output_text_is_nonempty_marker(self) -> None: """The output is a non-empty error marker mentioning the ERROR status.""" step = _load("run_command_error") - events = map_step_to_events(step, conversation_id=_CID, allocator=_allocator()) - item_data = cast(dict[str, Any], events[0].data["item_data"]) - output = item_data["output"] + events = map_step_to_events(step, conversation_id=_CID) + output = _item(events[1])["output"] assert isinstance(output, str) and output assert "ERROR" in output def test_error_step_index(self) -> None: """step_index matches fixture stepIndex=6.""" step = _load("run_command_error") - events = map_step_to_events(step, conversation_id=_CID, allocator=_allocator()) + events = map_step_to_events(step, conversation_id=_CID) assert events[0].step_index == 6 @@ -476,45 +394,45 @@ def test_error_step_index(self) -> None: class TestToolResultClosure: """Every tool call is closed, even with empty or unmapped results. - Regression coverage for dangling ``function_call``s: a successful command - whose output proto3-omits empty ``combinedOutput.full``, and a result step - of a type the mapper has no extractor for (e.g. VIEW_FILE / CODE_ACTION), - must each still emit a ``function_call_output`` keyed on the real - ``metadata.toolCall.id`` so the web UI's tool card resolves. + Regression coverage for half-rendered tool cards: a successful command whose + output proto3-omits empty ``combinedOutput.full``, and a step of a type the + mapper has no extractor for, must each still emit the full pair so the web + UI's tool card resolves. """ def test_done_run_command_empty_output_still_closes(self) -> None: - """DONE run_command with no combinedOutput → one event, empty output.""" + """DONE run_command with no combinedOutput → the pair, empty output.""" step = _load("run_command_done") # Proto3 omits empty scalars: drop combinedOutput to simulate a # ``cd`` / ``mkdir`` / redirect that produced no captured output. step["runCommand"].pop("combinedOutput", None) - events = map_step_to_events(step, conversation_id=_CID, allocator=_allocator()) - assert len(events) == 1 - assert events[0].data["item_type"] == "function_call_output" - item_data = cast(dict[str, Any], events[0].data["item_data"]) - assert item_data["call_id"] == "cbawg2v8" - assert item_data["output"] == "" + events = map_step_to_events(step, conversation_id=_CID) + assert [event.data["item_type"] for event in events] == [ + "function_call", + "function_call_output", + ] + assert {_item(event)["call_id"] for event in events} == {_call_id(6)} + assert _item(events[1])["output"] == "" - def test_unmapped_tool_result_type_with_id_closes(self) -> None: - """A result type with no extractor but a toolCall.id still closes the call.""" + def test_tool_step_with_no_typed_body_still_closes(self) -> None: + """A tool step whose body the mapper cannot read still emits the pair.""" step = _load("run_command_done") - # Re-label as a result type the mapper has no extractor for; keep the - # real toolCall.id so the pair still correlates. - step["type"] = "CORTEX_STEP_TYPE_VIEW_FILE" + # Re-label as a type with no matching body, and drop the body itself: + # nothing is extractable, but the card must not be left half-open. + step["type"] = "CORTEX_STEP_TYPE_CODE_ACTION" step.pop("runCommand", None) - events = map_step_to_events(step, conversation_id=_CID, allocator=_allocator()) - assert len(events) == 1 - assert events[0].data["item_type"] == "function_call_output" - item_data = cast(dict[str, Any], events[0].data["item_data"]) - assert item_data["call_id"] == "cbawg2v8" - assert item_data["output"] == "" + events = map_step_to_events(step, conversation_id=_CID) + assert [event.data["item_type"] for event in events] == [ + "function_call", + "function_call_output", + ] + assert {_item(event)["call_id"] for event in events} == {_call_id(6)} + assert _item(events[1])["output"] == "" - def test_system_step_without_tool_id_is_skipped(self) -> None: - """A non-tool step with no toolCall.id is NOT treated as a tool result.""" - step = _load("checkpoint") - events = map_step_to_events(step, conversation_id=_CID, allocator=_allocator()) - assert events == [] + def test_system_step_is_skipped(self) -> None: + """A step agy never marked as a tool action is not a tool result.""" + for name in ("checkpoint", "conversation_history"): + assert map_step_to_events(_load(name), conversation_id=_CID) == [] # --------------------------------------------------------------------------- @@ -523,28 +441,27 @@ def test_system_step_without_tool_id_is_skipped(self) -> None: class TestListDirectoryDone: - """LIST_DIRECTORY DONE step → ``function_call_output``.""" + """LIST_DIRECTORY DONE step → the invocation plus its listing.""" - def test_returns_one_function_call_output(self) -> None: - """DONE list_directory → one function_call_output event.""" + def test_returns_the_pair(self) -> None: + """DONE list_directory → its invocation and its output.""" step = _load("list_directory_done") - events = map_step_to_events(step, conversation_id=_CID, allocator=_allocator()) - assert len(events) == 1 - assert events[0].data["item_type"] == "function_call_output" + events = map_step_to_events(step, conversation_id=_CID) + assert [event.data["item_type"] for event in events] == [ + "function_call", + "function_call_output", + ] - def test_call_id_is_real_agy_id(self) -> None: - """call_id is the real agy-assigned id from metadata.toolCall.id.""" + def test_pair_shares_the_step_derived_call_id(self) -> None: + """Both items key on the step's own identity, not agy's toolCall.id.""" step = _load("list_directory_done") - events = map_step_to_events(step, conversation_id=_CID, allocator=_allocator()) - item_data = events[0].data["item_data"] - assert isinstance(item_data, dict) - # Fixture carries toolCall.id="h510vxi0" - assert item_data["call_id"] == "h510vxi0" + events = map_step_to_events(step, conversation_id=_CID) + assert {_item(event)["call_id"] for event in events} == {_call_id(10)} def test_step_index(self) -> None: """step_index matches fixture stepIndex=10.""" step = _load("list_directory_done") - events = map_step_to_events(step, conversation_id=_CID, allocator=_allocator()) + events = map_step_to_events(step, conversation_id=_CID) assert events[0].step_index == 10 @@ -564,7 +481,7 @@ class TestAskQuestionWaiting: def test_waiting_emits_no_event(self) -> None: """WAITING ask_question → empty list.""" step = _load("ask_question_waiting") - events = map_step_to_events(step, conversation_id=_CID, allocator=_allocator()) + events = map_step_to_events(step, conversation_id=_CID) assert events == [] @@ -574,28 +491,27 @@ def test_waiting_emits_no_event(self) -> None: class TestAskQuestionDone: - """ASK_QUESTION DONE step → function_call_output.""" + """ASK_QUESTION DONE step → the invocation plus the answer.""" - def test_returns_function_call_output(self) -> None: - """DONE ask_question → one function_call_output event.""" + def test_returns_the_pair(self) -> None: + """DONE ask_question → its invocation and its output.""" step = _load("ask_question_done") - events = map_step_to_events(step, conversation_id=_CID, allocator=_allocator()) - assert len(events) == 1 - assert events[0].data["item_type"] == "function_call_output" + events = map_step_to_events(step, conversation_id=_CID) + assert [event.data["item_type"] for event in events] == [ + "function_call", + "function_call_output", + ] - def test_call_id_is_real_agy_id(self) -> None: - """call_id is the real agy-assigned id from metadata.toolCall.id.""" + def test_pair_shares_the_step_derived_call_id(self) -> None: + """Both items key on the step's own identity, not agy's toolCall.id.""" step = _load("ask_question_done") - events = map_step_to_events(step, conversation_id=_CID, allocator=_allocator()) - item_data = events[0].data["item_data"] - assert isinstance(item_data, dict) - # Fixture carries toolCall.id="jfizoalt", matching the planner invocation - assert item_data["call_id"] == "jfizoalt" + events = map_step_to_events(step, conversation_id=_CID) + assert {_item(event)["call_id"] for event in events} == {_call_id(12)} def test_step_index(self) -> None: """step_index matches fixture stepIndex=12.""" step = _load("ask_question_done") - events = map_step_to_events(step, conversation_id=_CID, allocator=_allocator()) + events = map_step_to_events(step, conversation_id=_CID) assert events[0].step_index == 12 @@ -610,13 +526,13 @@ class TestSystemStepsSkipped: def test_checkpoint_returns_empty(self) -> None: """CHECKPOINT step → ``[]``.""" step = _load("checkpoint") - events = map_step_to_events(step, conversation_id=_CID, allocator=_allocator()) + events = map_step_to_events(step, conversation_id=_CID) assert events == [] def test_conversation_history_returns_empty(self) -> None: """CONVERSATION_HISTORY step → ``[]``.""" step = _load("conversation_history") - events = map_step_to_events(step, conversation_id=_CID, allocator=_allocator()) + events = map_step_to_events(step, conversation_id=_CID) assert events == [] @@ -643,7 +559,7 @@ def test_absent_step_index_emits_event_at_zero(self) -> None: assert isinstance(traj_info, dict) traj_info.pop("stepIndex", None) - events = map_step_to_events(step, conversation_id=_CID, allocator=_allocator()) + events = map_step_to_events(step, conversation_id=_CID) assert len(events) == 1 assert events[0].step_index == 0 @@ -657,7 +573,7 @@ def test_string_encoded_step_index_accepted(self) -> None: assert isinstance(traj_info, dict) traj_info["stepIndex"] = "2" # String form - events = map_step_to_events(step, conversation_id=_CID, allocator=_allocator()) + events = map_step_to_events(step, conversation_id=_CID) assert len(events) == 1 assert events[0].step_index == 2 @@ -691,7 +607,7 @@ def test_modified_response_wins_when_different(self) -> None: planner["response"] = "Original text." planner["modifiedResponse"] = "Post-moderation text." - events = map_step_to_events(step, conversation_id=_CID, allocator=_allocator()) + events = map_step_to_events(step, conversation_id=_CID) assert len(events) == 1 item_data = events[0].data["item_data"] assert isinstance(item_data, dict) @@ -711,7 +627,7 @@ def test_response_used_when_modified_absent(self) -> None: planner.pop("modifiedResponse", None) planner["response"] = "Fallback text." - events = map_step_to_events(step, conversation_id=_CID, allocator=_allocator()) + events = map_step_to_events(step, conversation_id=_CID) assert len(events) == 1 item_data = events[0].data["item_data"] assert isinstance(item_data, dict) @@ -730,7 +646,7 @@ def test_response_used_when_modified_empty(self) -> None: planner["modifiedResponse"] = "" planner["response"] = "Non-empty response." - events = map_step_to_events(step, conversation_id=_CID, allocator=_allocator()) + events = map_step_to_events(step, conversation_id=_CID) assert len(events) == 1 item_data = events[0].data["item_data"] assert isinstance(item_data, dict) @@ -744,94 +660,36 @@ def test_response_used_when_modified_empty(self) -> None: # --------------------------------------------------------------------------- -class TestRealIdPairing: +class TestPairingIsOrderIndependent: """ - Verify that real agy tool-call ids are used for invocation↔output pairing. + Each tool step carries its own pair, so arrival order cannot mis-pair. - The RPC carries the same id on both the invocation - (``plannerResponse.toolCalls[].id``) and the result - (``metadata.toolCall.id``). The mapper uses those ids directly — - no FIFO position, no allocator — so pairing is order-independent. + The mapper once correlated invocations to results positionally (FIFO): a + result arriving before an earlier tool finished took the wrong invocation's + id. Deriving both items from one step makes mis-pairing unrepresentable — + there is no cross-step state left to get out of step. """ - def test_planner_then_run_command_done_share_real_id(self) -> None: - """ - PLANNER_RESPONSE and RUN_COMMAND DONE → both carry the same real agy id. + def test_results_delivered_out_of_order_keep_their_own_ids(self) -> None: + """Two tool steps mapped in reverse order still key on themselves.""" + ask_question = map_step_to_events(_load("ask_question_done"), conversation_id=_CID) + run_command = map_step_to_events(_load("run_command_done"), conversation_id=_CID) - The invocation call_id and the output call_id must equal the fixture's - agy-assigned id ("cbawg2v8"), not a positional allocator id. - """ - alloc = _allocator() - planner_step = _load("planner_response_tool_call_run_command") - planner_events = map_step_to_events(planner_step, conversation_id=_CID, allocator=alloc) - assert len(planner_events) == 1 - planner_item_data = planner_events[0].data["item_data"] - assert isinstance(planner_item_data, dict) - invocation_call_id = planner_item_data["call_id"] - assert invocation_call_id == "cbawg2v8" - - result_step = _load("run_command_done") - result_events = map_step_to_events(result_step, conversation_id=_CID, allocator=alloc) - assert len(result_events) == 1 - result_item = result_events[0].data["item_data"] - assert isinstance(result_item, dict) - # Same real id — not a FIFO-synthesized orphan id - assert result_item["call_id"] == invocation_call_id - - def test_two_results_out_of_order_pair_by_real_id(self) -> None: - """ - REGRESSION: two tool-result steps with DIFFERENT real ids delivered - out of order each pair to the correct invocation by real id. - - FIFO would mis-pair: if result-B arrives before result-A, FIFO gives - result-B the call_id of invocation-A and result-A gets invocation-B's - id. Real-id pairing is immune to arrival order. + assert {_item(event)["call_id"] for event in ask_question} == {_call_id(12)} + assert {_item(event)["call_id"] for event in run_command} == {_call_id(6)} - We simulate two consecutive PLANNER_RESPONSE steps each invoking a - different tool (run_command id="cbawg2v8", ask_question id="jfizoalt") - then deliver their result steps OUT OF ORDER (ask_question result first, - run_command result second). + def test_mapping_a_step_twice_is_stable(self) -> None: """ + Re-mapping the same step re-derives identical items. - alloc = _allocator() - - # Emit PLANNER_RESPONSE for run_command (id="cbawg2v8") - rc_planner = _load("planner_response_tool_call_run_command") - rc_planner_events = map_step_to_events(rc_planner, conversation_id=_CID, allocator=alloc) - assert len(rc_planner_events) == 1 - rc_item = rc_planner_events[0].data["item_data"] - assert isinstance(rc_item, dict) - assert rc_item["call_id"] == "cbawg2v8" - - # Emit PLANNER_RESPONSE for ask_question (id="jfizoalt") - aq_planner = _load("planner_response_tool_call_ask_question") - aq_planner_events = map_step_to_events(aq_planner, conversation_id=_CID, allocator=alloc) - assert len(aq_planner_events) == 1 - aq_item = aq_planner_events[0].data["item_data"] - assert isinstance(aq_item, dict) - assert aq_item["call_id"] == "jfizoalt" - - # Now deliver ask_question DONE result FIRST (out of order vs run_command) - aq_done = _load("ask_question_done") - aq_result_events = map_step_to_events(aq_done, conversation_id=_CID, allocator=alloc) - assert len(aq_result_events) == 1 - aq_result_item = aq_result_events[0].data["item_data"] - assert isinstance(aq_result_item, dict) - # Must pair with ask_question id, NOT run_command id - assert aq_result_item["call_id"] == "jfizoalt" - - # Then deliver run_command DONE result - rc_done = _load("run_command_done") - rc_result_events = map_step_to_events(rc_done, conversation_id=_CID, allocator=alloc) - assert len(rc_result_events) == 1 - rc_result_item = rc_result_events[0].data["item_data"] - assert isinstance(rc_result_item, dict) - # Must pair with run_command id, NOT ask_question id - assert rc_result_item["call_id"] == "cbawg2v8" - - # Allocator must not have been used at all (all ids were real) - assert alloc.invocation_count == 0 - assert alloc.orphan_output_count == 0 + The reader replays the on-connect snapshot and re-reads steps across a + stream→poll fallback; ids that drifted between passes would double the + tool card instead of deduping it. + """ + step = _load("run_command_done") + assert map_step_to_events(step, conversation_id=_CID) == map_step_to_events( + step, conversation_id=_CID + ) # --------------------------------------------------------------------------- @@ -1207,7 +1065,7 @@ def test_done_planner_with_thinking_emits_no_reasoning_item(self) -> None: """ step = _load("planner_response_text") cast(dict[str, Any], step["plannerResponse"])["thinking"] = "internal chain of thought" - events = map_step_to_events(step, conversation_id=_CID, allocator=_allocator()) + events = map_step_to_events(step, conversation_id=_CID) for event in events: assert event.event_type != "external_output_reasoning_delta" @@ -1257,3 +1115,120 @@ def test_real_fixture_has_execution_id(self) -> None: step = _load("user_input") # Confirms the live wire shape this fix relies on (per-turn executionId). assert _execution_discriminator(step) == "1df76a5f-0318-4c71-b31d-7e3b51a3d981" + + +# --------------------------------------------------------------------------- +# Stream projection: agy omits toolCall / toolCalls from streamed steps +# --------------------------------------------------------------------------- + + +class TestStreamProjection: + """ + Map tool steps as delivered by ``StreamAgentStateUpdates``. + + agy serves the same step at two fidelities. ``GetCascadeTrajectorySteps`` + (poll) carries ``metadata.toolCall`` and ``plannerResponse.toolCalls``; + the live stream strips both — they embed ``thinkingSignature`` blobs, and + the typed body (``runCommand`` / ``viewFile`` / …) already describes the + call. Every fixture below is a verbatim live stream frame. + + The mapper therefore derives the whole pair from the RESULT step: its + ``sourceTrajectoryStepInfo`` is the call id, its typed body the arguments + and the output. The planner's ``toolCalls`` is never the source, so both + RPC shapes produce identical items. + """ + + def _pair(self, name: str) -> tuple[dict[str, Any], dict[str, Any]]: + """Map a stream fixture and return its (function_call, output) item data.""" + events = map_step_to_events(_load(name), conversation_id=_CID) + kinds = [event.data.get("item_type") for event in events] + assert kinds == ["function_call", "function_call_output"], ( + f"{name} mapped to {kinds}, expected an invocation followed by its output" + ) + call = events[0].data["item_data"] + output = events[1].data["item_data"] + assert isinstance(call, dict) and isinstance(output, dict) + return call, output + + def test_run_command_emits_a_paired_call_and_output(self) -> None: + """A streamed RUN_COMMAND yields an invocation AND its output, paired.""" + call, output = self._pair("stream_run_command_done") + assert call["name"] == "run_command" + assert call["call_id"] == output["call_id"] + assert "git status" in str(call["arguments"]) + assert "On branch main" in str(output["output"]) + + def test_view_file_is_mirrored_rather_than_dropped(self) -> None: + """ + REGRESSION: a streamed VIEW_FILE reached the web UI as nothing at all. + + VIEW_FILE is not one of the mapper's known result types, and the stream + strips the ``metadata.toolCall.id`` that would otherwise classify it — + so the step was read as system noise and silently dropped. Six of them + vanished from one live conversation. + """ + call, output = self._pair("stream_view_file_done") + assert call["name"] == "view_file" + assert call["call_id"] == output["call_id"] + + def test_generic_step_uses_the_agy_tool_name(self) -> None: + """GENERIC steps keep ``toolCall`` in the stream; prefer that real name.""" + call, _ = self._pair("stream_generic_done") + assert call["name"] == "manage_subagents" + + def test_list_directory_uses_the_agy_tool_name(self) -> None: + """agy calls this tool ``list_dir``, not the type-derived ``list_directory``.""" + call, _ = self._pair("stream_list_directory_done") + assert call["name"] == "list_dir" + + def test_call_id_is_identical_across_both_rpc_shapes(self) -> None: + """ + The same step mapped from the stream and from the poll snapshot pairs + under ONE call id. + + Both fixtures are trajectory ``efb134b2…`` step 6. A reader that falls + back from the stream to the poll mid-conversation must not re-key the + pair, or the tool card splits in two. + """ + stream_call, stream_output = self._pair("stream_run_command_done") + snapshot_events = map_step_to_events(_load("run_command_done"), conversation_id=_CID) + snapshot_ids = { + item["call_id"] + for event in snapshot_events + if isinstance(item := event.data["item_data"], dict) and "call_id" in item + } + assert snapshot_ids == {stream_call["call_id"]} == {stream_output["call_id"]} + + def test_planner_never_emits_a_function_call(self) -> None: + """ + The result step is the SOLE source of the pair — on both shapes. + + The poll snapshot's planner still carries ``toolCalls``; emitting from + there too would post a second, differently-keyed invocation for every + tool whenever the reader falls back to polling. + """ + for name in ("stream_planner_tool_call", "planner_response_tool_call_run_command"): + events = map_step_to_events(_load(name), conversation_id=_CID) + kinds = [event.data.get("item_type") for event in events] + assert "function_call" not in kinds, f"{name} emitted an invocation: {kinds}" + + def test_planner_text_still_commits(self) -> None: + """Stripping toolCalls must not disturb the committed assistant message.""" + events = map_step_to_events(_load("stream_planner_text_done"), conversation_id=_CID) + assert [event.data.get("item_type") for event in events] == ["message"] + + def test_no_fixture_produces_an_orphan_call_id(self) -> None: + """ + REGRESSION: streamed results were keyed to invented ``_orphan_N`` ids. + + With no ``toolCall.id`` on the step and no pending invocation to pair + with (the planner never registered one), every streamed result minted a + standalone id. 611 such outputs were recorded across 10 live + conversations, against 0 invocations. + """ + for path in sorted(_FIXTURES.glob("*.json")): + events = map_step_to_events(_load(path.stem), conversation_id=_CID) + for event in events: + item = event.data.get("item_data") + call_id = item.get("call_id", "") if isinstance(item, dict) else "" + assert "orphan" not in str(call_id), f"{path.stem} minted {call_id}" diff --git a/web/src/lib/nativeCodingAgents.test.ts b/web/src/lib/nativeCodingAgents.test.ts index dda5cf6690..110d1f1d54 100644 --- a/web/src/lib/nativeCodingAgents.test.ts +++ b/web/src/lib/nativeCodingAgents.test.ts @@ -4,6 +4,7 @@ import { UI_MODE_TERMINAL_VALUE, WRAPPER_LABEL_KEY, isNativeTerminalSession, + nativeAgentHasCapability, nativeCodingAgentForHarness, nativeWrapperLabelsForAgent, } from "./nativeCodingAgents"; @@ -65,6 +66,20 @@ describe("nativeCodingAgentForHarness", () => { ); }); + // agy's only pre-emptive control is the all-or-nothing bypass, so it must + // declare `skipPermissions` and NOT Claude's graded `permissionMode` — the + // latter would emit `--permission-mode `, a flag agy does not accept. + it("gives antigravity-native the skipPermissions capability, not permissionMode", () => { + const agy = nativeCodingAgentForHarness("antigravity-native"); + expect(agy?.capabilities).toEqual(["skipPermissions"]); + expect( + nativeAgentHasCapability({ name: "antigravity-native-ui", harness: null }, "skipPermissions"), + ).toBe(true); + expect( + nativeAgentHasCapability({ name: "antigravity-native-ui", harness: null }, "permissionMode"), + ).toBe(false); + }); + it("leaves unknown / non-native harnesses unresolved", () => { expect(nativeCodingAgentForHarness("claude-sdk")).toBeUndefined(); // The in-process Antigravity SDK harness is not a native CLI wrapper. diff --git a/web/src/lib/nativeCodingAgents.ts b/web/src/lib/nativeCodingAgents.ts index e7fc538be7..9fdf5eacd1 100644 --- a/web/src/lib/nativeCodingAgents.ts +++ b/web/src/lib/nativeCodingAgents.ts @@ -16,7 +16,8 @@ export type NativeCodingAgentIconKind = | "antigravity" | "kimi" | "hermes"; -export type NativeCodingAgentCapability = "permissionMode" | "approvalMode" | "cursorMode"; +export type NativeCodingAgentCapability = + "permissionMode" | "approvalMode" | "cursorMode" | "skipPermissions"; export interface NativeCodingAgentSpec { key: NativeCodingAgentIconKind; @@ -110,6 +111,10 @@ export const NATIVE_CODING_AGENTS = [ displayName: "Antigravity", iconKind: "antigravity", sortRank: 45, + // agy's only pre-emptive control is the all-or-nothing + // `--dangerously-skip-permissions`, so it gets a two-value toggle rather + // than Claude's graded permissionMode selector. + capabilities: ["skipPermissions"], }, { key: "goose", diff --git a/web/src/shell/NewChatDialog.flow.test.tsx b/web/src/shell/NewChatDialog.flow.test.tsx index f39142da4e..5440764138 100644 --- a/web/src/shell/NewChatDialog.flow.test.tsx +++ b/web/src/shell/NewChatDialog.flow.test.tsx @@ -784,6 +784,69 @@ describe("NewChatLandingScreen create flow", () => { expect(body.terminal_launch_args).toBeUndefined(); }); + it("posts --dangerously-skip-permissions when the bypass is picked for antigravity-native", async () => { + setAgents([ + agent({ id: "ag_agy", name: "antigravity-native-ui", display_name: "Antigravity" }), + ]); + vi.mocked(authenticatedFetch).mockResolvedValueOnce({ + ok: true, + json: async () => ({ id: "conv_agy" }), + } as unknown as Response); + + renderLanding(); + await waitForWorkspaceSeed(); + openAgentConfig("ag_agy"); + pickSelectOption("new-chat-landing-config-agy-skip", "Skip permissions"); + saveConfig(); + typeMessage("go"); + fireEvent.click(screen.getByTestId("new-chat-landing-submit")); + + await waitFor(() => expect(authenticatedFetch).toHaveBeenCalledTimes(1)); + const [, init] = vi.mocked(authenticatedFetch).mock.calls[0] as [string, RequestInit]; + const body = JSON.parse(init.body as string); + // agy's ONLY pre-emptive control, as a bare single token. Claude's + // `["--permission-mode", ...]` pair would be rejected by agy, which has no + // such flag — so assert the exact spelling, not merely "some args". + expect(body.terminal_launch_args).toEqual(["--dangerously-skip-permissions"]); + }); + + it("omits terminal_launch_args when antigravity-native permissions are left at default", async () => { + setAgents([ + agent({ id: "ag_agy", name: "antigravity-native-ui", display_name: "Antigravity" }), + ]); + vi.mocked(authenticatedFetch).mockResolvedValueOnce({ + ok: true, + json: async () => ({ id: "conv_agy" }), + } as unknown as Response); + + renderLanding(); + await waitForWorkspaceSeed(); + typeMessage("go"); + fireEvent.click(screen.getByTestId("new-chat-landing-submit")); + + await waitFor(() => expect(authenticatedFetch).toHaveBeenCalledTimes(1)); + const [, init] = vi.mocked(authenticatedFetch).mock.calls[0] as [string, RequestInit]; + const body = JSON.parse(init.body as string); + // Anchor so the absence check is not vacuous against a malformed body. + expect(body.labels?.["omnigent.wrapper"]).toBe("antigravity-native-ui"); + // Untouched → agy keeps its own request-review prompt. + expect(body.terminal_launch_args).toBeUndefined(); + }); + + it("shows a danger banner while the antigravity-native bypass is selected", async () => { + setAgents([ + agent({ id: "ag_agy", name: "antigravity-native-ui", display_name: "Antigravity" }), + ]); + renderLanding(); + await waitForWorkspaceSeed(); + openAgentConfig("ag_agy"); + // agy exposes no firing pre-tool hook, so Omnigent cannot re-gate tools + // once this is armed — the banner is the only guardrail the user gets. + expect(screen.queryByTestId("new-chat-landing-agy-skip-banner")).toBeNull(); + pickSelectOption("new-chat-landing-config-agy-skip", "Skip permissions"); + expect(screen.getByTestId("new-chat-landing-agy-skip-banner")).toBeTruthy(); + }); + it("omits model + effort on create when the picker is untouched for claude-native", async () => { setAgents([agent({ id: "ag_native", name: "claude-native-ui", display_name: "Claude Code" })]); vi.mocked(authenticatedFetch).mockResolvedValueOnce({ diff --git a/web/src/shell/NewChatDialog.tsx b/web/src/shell/NewChatDialog.tsx index 7e9baf2e4b..98493e13fa 100644 --- a/web/src/shell/NewChatDialog.tsx +++ b/web/src/shell/NewChatDialog.tsx @@ -214,6 +214,33 @@ const CLAUDE_NATIVE_PERMISSION_MODES: { value: string; label: string; descriptio }, ]; +// Antigravity (agy) permission control. agy exposes exactly ONE pre-emptive +// knob — `--dangerously-skip-permissions`, an all-or-nothing bypass — with no +// per-tool equivalent of acceptEdits/plan, so this is a two-value toggle rather +// than Claude's graded selector. "default" sends no flags and leaves agy's own +// request-review prompt in place. Keep in sync with `agy --help`. +const AGY_NATIVE_DEFAULT_SKIP_MODE = "default"; +const AGY_NATIVE_SKIP_VALUE = "skip"; +const AGY_NATIVE_SKIP_MODES: { + value: string; + label: string; + description: string; + args: string[]; +}[] = [ + { + value: AGY_NATIVE_DEFAULT_SKIP_MODE, + label: "Ask every time", + description: "Prompts before each tool runs", + args: [], + }, + { + value: AGY_NATIVE_SKIP_VALUE, + label: "Skip permissions", + description: "Runs everything; no prompts or safety checks", + args: ["--dangerously-skip-permissions"], + }, +]; + // Cursor execution modes. "default" sends no flags; other values map to CLI // args passed via terminal_launch_args. Keep in sync with `cursor-agent --help`. const CURSOR_NATIVE_DEFAULT_EXEC_MODE = "default"; @@ -1242,6 +1269,7 @@ function HarnessConfigModal({ permissionMode, approvalMode, cursorExecMode, + agySkipMode, bypassSandbox, pickedModel, claudeModelOptions, @@ -1254,6 +1282,7 @@ function HarnessConfigModal({ setPermissionMode, setApprovalMode, setCursorExecMode, + setAgySkipMode, setBypassSandbox, setPickedModel, setPickedEffort, @@ -1270,6 +1299,7 @@ function HarnessConfigModal({ permissionMode: string; approvalMode: string; cursorExecMode: string; + agySkipMode: string; bypassSandbox: boolean; pickedModel: string; claudeModelOptions: readonly Pick[]; @@ -1282,6 +1312,7 @@ function HarnessConfigModal({ setPermissionMode: (mode: string) => void; setApprovalMode: (mode: string) => void; setCursorExecMode: (mode: string) => void; + setAgySkipMode: (mode: string) => void; setBypassSandbox: (enabled: boolean) => void; setPickedModel: (model: string) => void; setPickedEffort: (effort: string) => void; @@ -1295,6 +1326,7 @@ function HarnessConfigModal({ const hasPermission = nativeAgentHasCapability(agent, "permissionMode"); const hasApproval = nativeAgentHasCapability(agent, "approvalMode"); const hasCursor = nativeAgentHasCapability(agent, "cursorMode"); + const hasAgySkip = nativeAgentHasCapability(agent, "skipPermissions"); const isCodex = entryHarness === "codex-native"; const modelOptions = isCodex ? codexModelOptions : claudeModelOptions; const modelsLoading = isCodex ? codexModelsLoading : claudeModelsLoading; @@ -1309,6 +1341,7 @@ function HarnessConfigModal({ const [draftPermission, setDraftPermission] = useState(permissionMode); const [draftApproval, setDraftApproval] = useState(approvalMode); const [draftCursor, setDraftCursor] = useState(cursorExecMode); + const [draftAgySkip, setDraftAgySkip] = useState(agySkipMode); const [draftBypass, setDraftBypass] = useState(bypassSandbox); const [draftHarness, setDraftHarness] = useState(pickedHarness); const [draftRouting, setDraftRouting] = useState(costControlMode); @@ -1320,6 +1353,7 @@ function HarnessConfigModal({ setDraftPermission(permissionMode); setDraftApproval(approvalMode); setDraftCursor(cursorExecMode); + setDraftAgySkip(agySkipMode); setDraftBypass(bypassSandbox); setDraftHarness(pickedHarness); setDraftRouting(costControlMode); @@ -1377,6 +1411,9 @@ function HarnessConfigModal({ } else if (hasCursor) { setCursorExecMode(draftCursor); if (entryHarness) writeHarnessOption(entryHarness, { mode: draftCursor }); + } else if (hasAgySkip) { + setAgySkipMode(draftAgySkip); + if (entryHarness) writeHarnessOption(entryHarness, { mode: draftAgySkip }); } else if (brainDefault) { // Picking the spec default clears the override so the session tracks it. setPickedHarness(draftHarness === brainDefault ? null : draftHarness, agent.id); @@ -1596,7 +1633,37 @@ function HarnessConfigModal({ )} - {!hasPermission && !hasApproval && !hasCursor && brainDefault && ( + {hasAgySkip && ( + <> + + + + {/* Persistent danger banner while the bypass is selected. agy has + no firing pre-tool hook, so Omnigent cannot re-gate individual + tools once this is on — the warning is the only guardrail. */} + {draftAgySkip === AGY_NATIVE_SKIP_VALUE && ( +
+ + + Danger: this session runs Antigravity with all tool permission prompts disabled. + It can edit any file and run any command without asking. + +
+ )} + + )} + + {!hasPermission && !hasApproval && !hasCursor && !hasAgySkip && brainDefault && (