b268130340
* feat(telemetry): routing decision and setting-change events Routing needs to be answerable after the fact: which arm the router picked, whether it was applied, and what the user changed. Adds ``RoutingDecisionEvent`` and ``RoutingSettingChangedEvent`` plus a ``model_labels`` helper that reduces a model id to a family/tier pair, so records stay useful without carrying raw model ids. Co-authored-by: Isaac Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com> * feat(sessions): persist routing decisions and session warnings A routing decision has to survive the turn that produced it, so the UI can show what the router chose and — crucially — whether it was actually applied. Adds ``RoutingDecisionData`` to the conversation entity with store support, and a ``session_warnings`` module for the non-fatal routing conditions a session needs to surface (router unreachable, verdict not applied) without failing the turn. Records are honest by construction: a decision that could not be applied is stored with ``applied=false`` and its reason rather than being dropped or reported as a success. Co-authored-by: Isaac Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com> * feat(routing): session-start smart routing core Adds the server-side routing core behind Smart Routing: an external ``task_v1`` route-options seam that offers the router the frozen arm menu its scenario requires, maps a pick back onto a servable catalog id via nearest-cost substitution, and derives the harness that can actually run it. Routing settings become one value object on ``RuntimeCaps`` so every consumer reads the same knobs instead of re-parsing config. Databricks model discovery resolves catalog spellings deterministically so the same endpoint is named the same way on every path. Reconciled against main's catalog-driven routing: - Main's ``_fetch_runner_catalog`` / ``_RunnerModel`` plumbing and its cost-tier ordering are the single source of live model availability; ``fetch_runner_models`` remains the id-only adapter over it. - Main's ``ModelIntent``-parameterized judge rubric replaces the family-specific tier hints. - Main's catalog wire-API check survives as ``_redirect_wire_incompatible_pick``, layered after the static ``_HARNESS_EXCLUDED_MODELS`` bar list. The two cover different things: the catalog knows what an endpoint advertises, the bar list knows the client-side rejections it does not. - ``model_family_token`` defers to ``is_codex_compatible_model`` so the GLM/Kimi delegate arms read as the codex family everywhere. The static ``MODEL_LISTS`` table is retained, unlike main, because the nearest-cost substitution needs a family cost ordering on paths with no catalog in reach (hook scripts, pre-session creates). Co-authored-by: Isaac Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com> * feat(server): route sessions at start and expose the decision Wires the routing core into session lifecycle. A session created in Smart Routing mode is routed once, at start, from the first user message: the verdict picks the harness and the model before the runner launches, and pre-launch host model options supply the candidate catalog when no runner exists yet. Later turns never re-route — a session's harness is settled once so a conversation cannot change identity underneath the user. The decision is exposed on the session snapshot and event stream with its applied state, so the UI can distinguish "the router picked X and we are running X" from "the router picked X and we could not apply it", rather than silently showing the request as the outcome. Co-authored-by: Isaac Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com> * feat(claude): apply a routed model to Claude Code A routed arm only matters if the harness actually runs it. Adds a Claude model vocabulary that maps between router arm ids, catalog spellings, and the ``/model`` names Claude Code accepts, and pins the CLI's family aliases to the frozen task_v1 Claude arms at launch so the first turn's switch can reach whatever the router picked. The vocabulary reads its catalog prefixes from one definition shared with the server seam, so the hook path — which cannot read server config — cannot drift from it. Co-authored-by: Isaac Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com> * feat(codex): apply a routed model to Codex The Codex side of the apply layer: the native app server and executor accept a routed model override and enforce it on the session they launch, so a verdict that names a GLM/Kimi delegate arm reaches the CLI instead of being dropped for the harness default. Codex spawns with no routable signal skip the router outright rather than routing on an empty prompt and recording a decision nobody asked for. Co-authored-by: Isaac Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com> * feat(routing): route sub-agent spawns from harness hooks Sub-agents spawned by a native CLI never pass through the server's session-create path, so they were unroutable. Adds hook scripts the Claude and Codex CLIs invoke at spawn time, plus a runner-side router that answers them, so a spawned child is routed on its own task text and launched on the chosen model. A child is only ever offered its parent's harness family: routing may change which model a sub-agent runs, never which vendor it belongs to. Hook commands run under ``python -I`` so a repo-local module on the CLI's cwd cannot shadow the interpreter's own imports. Co-authored-by: Isaac Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com> * feat(web): surface routing decisions and Smart Routing controls Adds the Smart Routing harness option to new-chat, a routing chip that shows the routed model on the session, a sub-agent routing row, and a warning banner for the non-fatal routing conditions the server reports. The chip reports what actually happened. When a decision could not be applied it says so and names the model in use, instead of showing the router's request as though it were the outcome. Co-authored-by: Isaac Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com> * test(routing): cover the routing apply layer end to end Adds the remaining routing coverage: the CLI's routing-client build, the native Smart Routing create path, an end-to-end routing integration test, and the discovery/override unit tests. Also updates the existing native bridge, forwarder, and launch-arg tests for the model-override plumbing. Co-authored-by: Isaac Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com> * docs(routing): record the routing design and verification state Captures the plan the implementation followed, the per-CUJ verification status, and the observed live-model state the harness bar list is derived from — the gateway rejections that catalog metadata does not advertise. Co-authored-by: Isaac Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com> * docs: registry stamps — rebased-tree battery green, session-start verified live Co-authored-by: Isaac Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com> * docs: re-sync CUJ walkthrough with the rebased tree Co-authored-by: Isaac Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com> * feat(routing): offer Smart Routing only where the apply layer can work Smart Routing rewrites a launch's model through the Databricks AI Gateway, so a host whose claude-native or codex inference resolves anywhere else (Bedrock, a plain API key, the vendor CLI's own login) got an option that could never take effect. Gate each surface on the fact that decides it. The host already resolves this at launch, so reuse those resolutions as a cheap config-only check — no process launch, no network — and report a `gateway_inference` map alongside `configured_harnesses` on registration and every readiness refresh. It rides the host frames into the store and out through GET /v1/hosts. A host that never reports it sends `null`, and `null` means unknown: nothing is gated away on older host builds. Web gates the three surfaces independently, classified in the single `smartRoutingAvailability` point as a new `not-gateway-backed` cause: Configure Claude Code's Model row needs the claude family, Configure Codex's needs the codex family, and the top-level Smart Routing harness row needs both (it drives the five-arm menu). Co-authored-by: Isaac Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com> * docs(routing): record the gateway-backed availability decision Plan §10 gains decision 9 (Smart Routing offered only where the apply layer can work, with the per-surface rule and the absent-means-unknown compatibility contract), and §8 gains the two follow-ups it defers: a liveness probe, and moving the routes:select call host-side so routing auth/workspace always matches the host's inference. CUJ_STATUS gains recipe R9 (point a host at a non-AIGW config and assert the option disappears) plus one pending check row per gated surface. Co-authored-by: Isaac Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com> * docs: rewrite the CUJ walkthrough in simplified technical English Rewrite designs/CUJ_IMPLEMENTATION.md in ASD-STE100-inspired Simplified Technical English so every sentence parses one way only: active voice with a named actor, simple tenses, one statement per sentence, noun clusters of at most three words, and lists for any sequence of three or more steps. Add a six-term glossary (arm, seam, pane, rollout, canary, spelling) to the intro. Remove the hard 80-column wrapping so each paragraph is one soft-wrapped line. No facts change: every sha citation and every file:line reference is byte-identical tobc4b6c0. Co-authored-by: Isaac Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com> * docs: stamp the gateway-inference positive half Co-authored-by: Isaac Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com> * docs: keep the routing design docs local-only The four routing design documents (plan, test registry, CUJ walkthrough, live model state) stay on disk for local reference but leave version control — they are working notes, not reviewable deliverables. Co-authored-by: Isaac Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com> * fix(routing): serve turn routing the launch-exact claude vocabulary Two claude-path defects from the live verification round. Turn-1 routing on a claude-native pane could substitute the routed arm. `_native_turn_catalog` read `_model_options_cache` without consulting `_model_options_stale`, so a catalog hydrated from the session's *host* before launch (whose family aliases carry the workspace default) became the offered vocabulary. With the launch pinning `opus -> databricks-claude-opus-4-8` and turn 1 routing ~100ms later, the pinned arm had no spelling on offer and the router substituted sonnet. Turn routing now awaits a refetch from the bound runner's `claude-model-options` endpoint — which reports the launch-pinned aliases — whenever the cached entry is stale, and falls back to the stale catalog when no runner can answer. Every claude-native turn also 400'd with `invalid beta flag`: the ucode gateway launch env never set `CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS`, and Claude Code 2.1.220 sends three flags the Databricks gateway rejects (`prompt-caching-scope-2026-01-05`, `advisor-tool-2026-03-01` and, under `ENABLE_TOOL_SEARCH`, `advanced-tool-use-2025-11-20`), which fails the whole request. Set the knob on that path too. Co-authored-by: Isaac Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com> * fix(routing): no substitution arrow for prefix-only subagent raw picks Co-authored-by: Isaac Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com> * fix(web): float the session warning banner over the chat The session warning strip rendered in-flow between the chat header and <main>, so a warning arriving mid-session pushed the whole conversation down. Render it as an overlay instead, on the same positioning contract as the chat header: anchored inside the chat column, below the header, stopping short of the workspace panel via --workspace-panel-offset, and transparent to pointer events outside its own rows so the chat stays scrollable. Multiple warnings stack downward inside the overlay. Co-authored-by: Isaac Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com> * fix(routing): gate the codex canary check on a real turn, clear it per launch `subagent_routing_unenforced` was posted on codex-native sessions whose routing hooks were in fact trusted and running. Codex dispatches `SessionStart` (the canary) when a thread's *first turn* begins, but the enforcement watcher's first-turn gate was released by any `thread/status/changed → active` or `item/*` event — and the MCP startup round activates the thread and emits items without running a turn. So a session that had not been asked anything yet (or whose first turn was interrupted before it started) failed the canary check 30s later. Live evidence (session e6074fb1...): thread activated by the MCP startup round at 13:58:06, warning posted at 13:58:36, and the canary file for that same session/app-server finally appeared at 14:01:36 when a real turn ran — proving the hooks were trusted and effective. The stale warning stuck only because the runner was stopped before the repair tick. Direct probes against `codex app-server` (isolated CODEX_HOME) also disprove the "codex captures hook trust at process start" theory: trust written after the spawn (the shipped ordering) takes effect, even for a turn already in flight when `config/batchWrite` lands. The real invariant is that trust must land before the first *turn*, which `start()` already guarantees — now written down where it can be broken. Second fix: the canary is the proof that *this* launch's hooks ran, so `clear_bridge_state` now drops it. The per-workspace bridge dir is reused across launches, and a canary left by an earlier launch masked a genuine fail-open for the rest of the session. Transition-only posting still clears a previous launch's warning on the new forwarder's first check. Co-authored-by: Isaac Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com> * fix(routing): clear the codex spawn audit per launch too Same staleness class as the canary (51e36c8c): the audit is reconciled against the routing decisions *this* launch's endpoint relayed, so a line left by a previous launch — whose approving decision lives in that launch's router — reads as a spawn the router never approved. The per-workspace bridge dir is reused across launches, so `clear_bridge_state` now drops the audit alongside the canary. Co-authored-by: Isaac Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com> * fix(routing): apply the glm arm under the gateway's model route The task_v1 codex arm `glm-5-2` resolved to the catalog's `databricks-glm-5-2`, which the codex turn then failed to serve: that serving endpoint advertises chat-completions only and 400s on `/codex/v1`. Probes on staging and prod (2026-08-01) show the Responses API does serve GLM — but only under the gateway model route `system.ai.glm-5-2`. GLM appears in no discovery listing, so the working name can only be pinned, not discovered. Add a per-model servable-alias map next to the arm tables and consult it when an arm resolves to a servable id, so the codex apply layer writes `system.ai.glm-5-2`. Subagent candidates are offered under the same spelling, so a rewrite spawns with the id routing resolves to. The router's arm id stays `glm-5-2`, and the alias strips to the same bare id so decision records show no substitution. Co-authored-by: Isaac Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com> * docs: track the routing design docs again Re-adds the plan (with the decision log), the test registry, the enumerated CUJ walkthrough, and the codex model-state notes, all current as of the post-verification state. Co-authored-by: Isaac Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com> * feat(routing): route the model at create time for a fixed native harness A native terminal launches with the session row and its turns originate in the TUI, so the server never sees the first message pre-inference — the turn gate that routes a plain claude/codex session never fires for a CLI-driven one. Create-time routing existed only on the `harness_override: "auto"` path, which picks harness AND model. A create that carries `cost_control_mode_override: "on"`, a non-empty `smart_routing_message`, and a FIXED native harness (claude-native / codex-native, via the wrapper agent, `harness_override`, or the spec) now routes its MODEL during the create: candidates come from the host's pre-launch catalog for that one harness, the pick is constrained to it, and the routed id is persisted as `model_override` with the routing-decision label plus a session-scoped decision record. Fails open — an unconfigured router, or a pick the harness cannot run, pins nothing and records the reason, so the session still opens on the CLI's default model. Session-start cadence is unchanged: the pinned model closes the per-turn gate exactly as the auto path's create pin does. The branch is skipped for SDK harnesses (which still route on their first turn), child and sub-agent sessions, and a create that pinned its own model. Co-authored-by: Isaac Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com> * feat(cli): route the model (and harness) before a native TUI launch Smart Routing was web-only: a CLI user who wanted the server to pick a model had to start the session in the browser. Add the two launch surfaces Bryan asked for, both of which route *before* anything starts — the harness pick is physical (a session is a live claude/codex process) and the model is applied as a launch flag, so there is nothing to change after the fact. - `omnigent claude|codex --smart-routing -p "<prompt>"` and `run --harness <native> --smart-routing -p ...` route the model and keep the requested harness. - `omnigent run --smart-routing -p "<prompt>"` (no --harness, or `--harness auto`) routes harness *and* model, then launches that wrapper. One session, routed at create: the CLI creates it through the standard JSON `POST /v1/sessions` (bound to the host it will run on, whose model options are the router's candidate catalog) and the wrapper ATTACHES to it instead of bundling its own. The row the server writes already carries the agent binding, the wrapper's presentation labels, the routed model and the decision card, so a routed CLI launch gets the same chip and provenance the web UI does. The resolved harness is read from `SessionResponse.harness`; native rows leave `harness_override` null on purpose. `--smart-routing` requires `-p`: routing needs text, and the degraded route-on-turn-2 mode is not shipping, so an empty invocation is a usage error pointing at `-p` or the web UI. It also rejects an AGENT, the REPL-only flags, and `--resume`/`--continue` (routing is a create-time decision, so a routed launch is always a new session). Preflight (`smart_routing_enabled` plus the host's per-harness `gateway_inference`) is a hard error naming the reason, because a routed model the pane cannot reach is worse than no pick; the create itself always fails open — the wrapper then starts a plain session behind one notice line. `omnigent claude` also gains `-p`, and claude/codex now accept a prompt through `run --harness <native> -p` instead of rejecting it. The prompt travels as argv (Claude Code's positional prompt; Codex keeps its existing first-turn delivery), so multi-line prompts survive intact. Co-authored-by: Isaac Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com> * fix(cli): resolve the claude agent name from harness_plugins on this branch Co-authored-by: Isaac Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com> * docs: PR rewrite plan — cut list, commit series, CLI integration Co-authored-by: Isaac Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com> * chore: track the isolated dev-stack scripts the test registry references Co-authored-by: Isaac Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com> * docs: cover the glm gateway-route fix907f8886pins the id the glm arm is applied under: the gateway serves GLM on the Responses API only as the model route `system.ai.glm-5-2`, so the catalog's `databricks-glm-5-2` row 400s every codex turn. Record the mechanics in CUJ_IMPLEMENTATION.md §3.5h (with the §1.3 spelling note and the residual "pinned, not discovered" open item), and close the C1 / §2.8 blocker in CUJ_STATUS.md against the live session 80fb6d1f: config mirror and every rollout turn context on system.ai.glm-5-2, zero BAD_REQUEST, real generation. The only error left on that thread is a gateway-capacity 429, which is load and not routing. Co-authored-by: Isaac Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com> * docs: cover the CLI smart-routing entry points `omnigent claude|codex --smart-routing -p` (tier 2) and `omnigent run --smart-routing -p` (tier 3) were undocumented. Record the fourth surface: CUJ_IMPLEMENTATION.md gains §6 (commands and tiers, prompt delivery, preflight, the create-time MODEL route for a fixed native harness, the create the CLI drives, rejected combinations, the routed launch, decision persistence, and the agent-name import fix), and known-open moves to §7. CUJ_STATUS.md gains recipe R10 and §2.10 — unit rows stamped from the three suites that pass at HEAD, every process-truth row ⬜ because no routed CLI launch has run live yet. PR_REWRITE_PLAN.md §2d/§5 corrected: both CLI halves have merged, and the tier-2 server half is already its own commit, so the commit-3/commit-8 split is mechanical. The CLI commit did not extend `_resolve_native_smart_routing` — the fixed-harness route is a parallel path — but it does share the auto path's lifted `_routing_host_for_create` helper, which the assembler must keep. Co-authored-by: Isaac Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com> * docs: track the PR review fix list (rounds 1-2, all items addressed) Co-authored-by: Isaac Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com> * docs: high-level routing system map for slimming iteration Add designs/ROUTING_OVERVIEW.md: a one-altitude map of the Smart Routing feature — the four user journeys, the fifteen subsystems with size and rewrite fate, the invariants that must survive any cut, and the five open decisions. Written in ASD-STE100 style with block IDs so the slimming pass can cut and keep by reference. Co-authored-by: Isaac Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com> * docs: fold Bryan's critique decisions into the rewrite plan Co-authored-by: Isaac Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com> * docs: fold the model-resolution rulings into the plans; STE pass on the rewrite plan Bryan ruled on the three open resolution questions (2026-08-01): revert the resolution machinery to main's shape (cut MODEL_LISTS, the cost table, the allowlist), drop pi from the routed set for now (bar list goes with it), and use one fixed fallback model per family (claude -> sonnet, gpt -> terra) with an honest decline behind it. The rewrite plan is now fully decided and rewritten in ASD-STE100 style; the overview's subsystem fates, invariants, and decision records match. Co-authored-by: Isaac Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com> * docs: finish the STE pass, restructure 3i to the three rulings, pin the fallback-id assumptions Reconciles the fold-agent's late completion (it amended0baeea1clocally; this lands the same tree as a follow-up commit instead of a force-push). The whole plan now meets the STE caps, 3i lists Bryan's three rulings as ruled (pi had been displaced by a mechanism bullet), and the open-assumption list grows to three: glm declines with no fallback; terra is today only a pi-exclusion entry, so the code must add it as a servable target; sonnet pins to databricks-claude-sonnet-5. Co-authored-by: Isaac Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com> * docs: luna is the gpt+glm fallback, sonnet follows the alias pin; add verification criteria (6c-6e) Bryan's final fallback rulings (2026-08-01): the gpt and glm families both fall back to luna (databricks-gpt-5-6-luna, itself a frozen arm, so a glm fallback never leaves the codex harness), and the claude fallback is whatever the sonnet alias pin resolves to rather than a hardcoded id. Terra is out; glm no longer declines. No open assumptions remain in the plan. New plan blocks 6c-6e state the verification criteria: the evidence bars per layer, the registry recipe handles (R0-R10; R8 dies with the enforcement cut), and the per-slice verification gates for the fleet. Co-authored-by: Isaac Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com> * docs: switch the plan to a from-scratch rewrite (7g) Bryan chose a complete rewrite from scratch (2026-08-02) to keep the new code as clean as possible, reversing the plan's earlier 'assemble, do not re-implement' constraint. The scope decisions all survive; the method and the safety net change. New blocks: 0c names the three inputs an agent must read before it writes a slice (the behavior inventory, the trap list, and the reference implementation on routing-mvp-v1), 0d says to rewrite the shape but transcribe the empirically-derived constants, 3l reframes the cut list as 'do not build', 4e contains the integration risk that moves to the end, 6f records that no evidence transfers, and 7g is the decision itself. 3j becomes a ceiling rather than a subtraction, which also retires its old arithmetic gap, and 5b turns the two CLI commits into specifications rather than patches to apply. Co-authored-by: Isaac Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com> * docs: request-time managed flag, parallel wave plan, and four scope reversals Bryan's review of the rewrite plan (2026-08-02) produced five changes. The managed preview flag is evaluated per request, not at construction, and it moves out of 2a into its own block 2f: flag off routes through the naive LLM judge, flag on routes through the AI Gateway, so a flag-off workspace degrades rather than loses the feature. That also dissolves the managed-swap report's objection. The glm gateway route is codex work, not CLI work, and the Smart Routing harness inherits it because it runs codex underneath. Cross-harness spawning is reinstated: harness agents get sys_session_create instead of a deny message (3c, 7i). Telemetry leaves the PR entirely for a follow-up Bryan owns (3e, 7j). The design docs ride the branch for his reference and a final commit deletes them before merge, so no docs PR exists (3a, 7j). Execution is now three waves of five or six parallel workstreams on one branch, preceded by a lead-authored wave-0 contract commit that declares every shared signature and pre-creates every shared touch point (4a, 4b, 4e, 6a, 6e, 7k). Size is a preference for reviewability, not a target (3j). Co-authored-by: Isaac Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com> * docs: make the rewrite plan readable without session context The plan hands off to a fresh fleet that has none of this session's history, so the spec sections (0-6) now read as instructions rather than as diffs against earlier drafts. Removed the negations of assumptions a new reader never held (the glm route is "not CLI work", managed readiness is "not 2a", 3c "reverses the earlier cut"), the RESOLVED-with-date tags inside spec blocks, and references only this session could resolve. Section 7 keeps the full decision record, which is its job. Empirical findings survive the trim: the A-sub deny-message result, the zero-live-triggers evidence, and the authorization-order trap now cite the document that records them. Wave design is now the lead's rather than a placeholder: a wave-0 contract commit, 7 foundation streams, 6 integration streams, and a 4-stream closure wave. The turn gate and the create paths move into separate modules so they stop colliding in orchestration.py; web and CLI move into wave 2 behind the wave-0 HTTP contract, which keeps the two largest surfaces off the critical path. Barrier 1 gains a real check (apply a hardcoded model to a claude pane and a codex session with no router involved) and barrier 3 gains the flag-off backend row. Co-authored-by: Isaac Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com> * docs: clear the last session-only references from the plan 3g was still written as "rewrite, not transplant" against a suite the fleet never sees, and it cited a commit's method rather than a rule. It now states the rule directly: start from the behavior inventory in CUJ_STATUS.md section 2, one test per behavior, coverage as the gate. The reference suite is described as what not to copy and why. Also replaced the two remaining "three review waves" references, which name history a fresh reader cannot resolve, with "the reference implementation". Co-authored-by: Isaac Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com> * docs: close the cold-read audit's blockers on the rewrite plan A subagent with no context from this session read the plan as an executor would and found that its load-bearing inputs are unreachable from the branch it tells you to start on. Confirmed and fixed. Blockers: - routing-mvp-v1 was an aspiration, not a branch. It now exists, pinned atf200a8bd, and 0c/1a cite the sha. - None of the required-reading docs, and none of the R0/R6/R9/R10 verification harness, exists on origin/main. Wave 0 now carries all twelve paths across, or every stream stops at its first instruction and both live barriers have no stack to run on. - 2f never named the preview flag. It is managed-side (databricks.mas.omnigent.intelligentRouting, default off), so OSS gets a per-request predicate the deployment supplies, plus a default; stream 2 builds the seam, not a flag system. - The migration had two owners. Wave 0 creates the empty revision and stream 4 fills it. - The file partition existed only as a promise, and where implied it double-booked subagent_routing.py. New block 4f is the table, with named modules for the transport/policy and turn-gate/create-path splits, and cli.py declared lead-owned. Also: new 2g records what main already ships (both routing clients and the wire-compat redirect), which shrinks stream 2; wave 0 slims the registry so waves 1-2 are gated on a true list; 6d had R5 and R6 transposed; 6e dropped row B3 and now names CUJ_STATUS as the row authority; barrier-1's apply script has an owner; the UI acceptance names Bryan, since no agent can close it; and the size figures in 1a and 3h are re-measured (29,924/155, and web/src minus its lockfile). One gap only Bryan can close, now flagged in 6e: INTELLIGENT_ROUTING_ PLAN.md section 11.1 does not embed the P-SOL prompt, and rows A3, B2, C2 need it. Co-authored-by: Isaac Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com> * docs: add LOCAL_SETUP.md; drop the stray npm lockfile R0 documented how to run the stack but not how to build it, and two things stopped a fresh machine cold: .omnigent-local/config.yaml is gitignored, so run-server.sh exits immediately with nothing explaining what belongs in it, and run-frontend.sh hardcoded this machine's nvm path. LOCAL_SETUP.md now covers prerequisites, uv sync + pnpm install, the databricks profile the router needs, the config template (with the two details that break things quietly: system.ai. keeps its trailing dot, and router_name must be task_v1), bring-up, a health check, the known local quirks, and teardown. R0 points at it and wave 0 carries it across. run-frontend.sh now resolves node from PATH, falling back to the newest nvm install, and fails with a pointer if pnpm is missing. Separately: web/package-lock.json was tracked again after the rebase. The repo uses pnpm (pnpm-lock.yaml, packageManager pnpm@11.15.1) and main has no npm lockfile, so this was 3,451 lines of generated wrong-package-manager noise in the PR diff. Untracked, deleted, and gitignored so it cannot come back. Co-authored-by: Isaac Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com> * docs: record the personal CLI setup and the provider topology LOCAL_SETUP.md covered the repo, but a fresh clone still does not reproduce the environment: the whole Claude Code and Codex setup lives in $HOME. New section 9 carries it - the three personal ~/.claude files, the model-serving proxy mode and its refresh hook, the Codex Databricks provider block and the five personal hooks that Omnigent's generated hooks.json must merge with, the two secrets that have to move out of band, and the transfer order. Section 9.5 records the provider topology, which is easy to misread: the global config's default provider is a Claude subscription, its AIGW provider (the /ai-gateway/anthropic route, which is the Gateway despite the path) is not default, and the worktree config is a separate staging workspace. Measured with omnigent.gateway_inference: global reports False for both families, the worktree True for both. That measurement surfaced a real defect, now recorded in plan block 3f: the codex check reads the base URL Omnigent resolves, so a kind: cli-config provider (which defers to the user's own ~/.codex/config.toml) yields None and is reported as not-backed rather than unknown. False hides the Smart Routing option; unknown does not. The rewrite must read the delegated config or report unknown. Co-authored-by: Isaac Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com> * Trim routing PR: cut enforcement/telemetry/machinery, fix GLM effort + blank page Wave-1 trim of the routing reference implementation, plus two live-caught bug fixes and test trims from a parallel cleanup pass. Cuts (per designs/PR_REWRITE_PLAN.md §3): - Enforcement stack: canary, watcher, spawn-audit, warning banner, session_warnings (3b). Hook generation + trust handshake kept. - Routing telemetry: telemetry/routing.py, model_labels.py (3e). - Fork-spawn exemption from the hook script (3d). - Model-resolution machinery in smart_routing.py: MODEL_LISTS cost-ladder (_cost_position, _ARM_SUBSTITUTES) replaced by a fixed per-family fallback (claude->sonnet, gpt/glm->luna) + honest decline (3i). The static infer_models catalog is kept: subagent_routing.py consumes it. Fixes: - GLM reasoning effort: GLM rejects xhigh; a routed GLM codex turn now clamps effort to medium at every config-write and thread-settings point (clamp_effort_for_model / effort_for_model_switch). Locked down in tests/test_reasoning_effort.py. - Blank-page crash: chipPendingBeforeRegion indexed past a shortened block array on a stale cache (session switch / history reload), reading undefined.type and unmounting ChatPage. Guarded + regression-tested. Tests trimmed to the surviving surface; suites collect clean (2277) and the core routing sets pass (266). Co-authored-by: Isaac Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com> * Remove unused `act` import left by the warning-banner test cut The enforcement/banner cut removed the AppShell test cases that used `act`, but left the import — oxlint (a pre-commit + CI gate) fails on it. Co-authored-by: Isaac Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com> * Substitute an unservable arm within its model tier before the family fallback task_v1's frozen arms name a model *tier* (claude-opus-4-8 is the opus tier, gpt-5-6-sol the sol tier), not a specific servable id. When the workspace serves a different model of the same tier — claude-opus-5 for a claude-opus-4-8 pick — that model is the arm the router meant, so substitute_model now applies it (highest version within the tier) ahead of the family fallback. Only when no same-tier model is servable does it fall to the per-family fallback, then decline. Still no cost walk: an unservable pick never slides down to a cheaper tier. Adds _model_tier (the id's last alphabetic segment, None for a bare generation id like gpt-5-5) and _version_key (numeric version, higher = newer) to rank within a tier. Co-authored-by: Isaac Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com> * Route unnamed codex subagent spawns on a placeholder instead of inheriting Codex encrypts the spawn message, so an unnamed codex spawn carries no prompt to route on. It previously fell through to allow-on-the-parent-model ("No routable signal … inherits the session model"). Route it on a fixed "Codex subagent task" placeholder instead, so it lands on the router's floor arm rather than the parent's possibly-expensive model — matching ucode PR 251's default_task_label. Precedence is unchanged: a real prompt (claude) wins, then task_name/agent_name, then the placeholder. Tradeoff, recorded honestly: every unnamed spawn scores the same placeholder and so gets the same floor arm — a cheap sensible default, not per-spawn routing. A named spawn still routes on its task_name; empirically that field has been null on every observed codex spawn, so the placeholder is the whole fix in practice. Per-prompt codex subagent routing is not reachable while the message is encrypted. Co-authored-by: Isaac Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com> * docs: design plan for in-harness first-message routing (follow-up) Route the main agent's model on the FIRST real user message via a UserPromptSubmit hook + loopback callback (the route-subagent pattern), so a bare `omni codex` / `omni claude` launch still routes, and web UI and TUI share one mechanism. Marker = conv.model_override (authoritative, existing cadence semantics) + a bridge-dir fast-skip file. Apply reuses the verified composer forward path: thread/settings/update-then-turn/start for codex, locked /model-injection-then-send-keys for claude (block-and-replay). Cross-harness selection stays outside; create-time routing stays for prompt-ful launches and composes via the marker. Grounded in LIVE_MODEL_STATE.md probes and the official Claude Code hook docs (block erases the prompt and injected input then proceeds; no hook output can change the model; 30s synchronous timeout). Four spikes ordered before any product code. Not part of the trim PR. Co-authored-by: Isaac Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com> * docs: record the conservative ruling on in-harness routing Bryan's decision (2026-08-03): keep both paths. The server/create-time path is the UI path and stays as the primary; the in-harness hook is additive, covering only what the server cannot see (a prompt typed into the TUI on a bare launch). One decision seam, three triggers, arbitrated by model_override so exactly one fires per session. The outside path also stays because it shares route_session_harness with cross-harness selection - it is the cross-harness code, not a parallel implementation. The maximal collapse (hook as sole trigger, CLI tier-2 entry machinery deleted) is recorded as a deferred phase gated on determinism evidence from the spikes plus live use, requiring an explicit go. Co-authored-by: Isaac Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com> * Point a routed spawn at a tool the session actually has, and say why The redirect told the model to "Use sys_session_send with args.harness=, args.model=" — parameters that do not exist on the tool it holds. Those are sys_session_send's named-spawn mode, which ToolManager only advertises for a spec with declared sub-agents; the native harnesses declare none, so their send tool exposes only {args, session_id} and the instruction was unfollowable. Matrix row A-sub recorded the result: the model read the deny and abandoned the spawn. Name sys_session_create instead, which a spawn:True harness does hold (both claude-native and codex-native set it) and whose schema really does take model, message, and agent_id. Lead with the user's own choice to enable Smart Routing and state that the sub-task is approved, so the deny reads as an authorized re-route rather than a refusal, and close with the concrete call to make. The same instruction now backs the deny branch when the verdict names a model, instead of a bare "Spawn denied by Omnigent smart routing." The redirect tests assert the properties that matter — denies, names the routed model, names sys_session_create, never names sys_session_send — rather than pinning the prose. Co-authored-by: Isaac Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com> * docs: register the bundle-agent and GLM-subagent CUJs (2.11, 2.12) Two new surfaces enter the registry per Bryan. 2.11: Smart Routing on bundle agents (debby/polly) reaches routing only through the gear config's brain-harness override - a different code path from the native Model row, previously untested; rows cover the menu render, the right model/harness selection, and the live apply. 2.12: codex GLM subagents, which ucode PR 251 explicitly skips; rows track the three blockers (static candidates, placeholder floor-arm, and the effort wall) with the sys_session_create child path recorded as already working. Co-authored-by: Isaac Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com> * fix(web): keep the bundle-agent harness row visible under Smart Routing Two bugs in the debby/polly gear-config flow when Smart Routing is picked as the brain harness: - Picking Smart Routing unmounted the Agent Harness dropdown that made the pick (it was gated on !autoRouting), leaving a lone locked Permissions row with no way to read the pick back or switch away without Cancel. The row now stays rendered, ordered above Permissions, and the gear tooltip mirrors both rows. - A remembered fully-auto pick had no degrade path when the server turns smart routing off: the modal showed a blank harness select while the create still sent harness_override "auto". The bundle flavor now drops the pick quietly, matching the top-level auto-native rule, and keeps the stored pick in case routing returns. Adds 15 vitest cases on real debby/polly (claude-sdk) fixtures covering menu shape, pick persistence, payloads, per-agent memory, and the degrade; updates the one existing test that encoded the unmount bug. NewChatDialog.test.tsx 228/228; shell suite 1754 pass; tsc/oxlint/ prettier clean. Co-authored-by: Isaac Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com> * docs: flip the §2.11 bundle-agent rows to vitest-backed The gear-config menu bugs are fixed and covered (1f99705f); the two render rows move to 🟡 pending a user eyeball, and the first-turn row records the payload half as vitest-verified with the live end-to-end still owed. Co-authored-by: Isaac Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com> * docs: record the spawn-family policy in the GLM-subagent CUJ section Subagent spawns stay within the parent harness family; GLM is codex-family (all codex subagents may spawn gpt and glm arms when smart routing is on); the auto harness alone spawns cross-family. Co-authored-by: Isaac Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com> * feat: let codex sessions spawn GLM subagents GLM belongs to the codex spawn family: with smart routing on, every codex spawn may target both the gpt arms and glm-5-2 (the auto harness alone spawns cross-family; claude parents stay claude-only). Three layers had to move: - Catalog: databricks-glm-5-2 joins _CURRENT_GENERATION_MODELS[gpt], so infer_models offers it and a routed glm pick resolves exactly instead of substituting down to luna (this also removes the create-path C1 substitution arrow). Since no discovery listing ever advertises glm, a live catalog row would still hide it — candidate_models now tops up known-unadvertised arms for the gpt family only, nested spawns included, without widening multi-model harnesses like pi. - Vocabulary: codex's spawn_agent validates model ids client-side against a closed enum of its own slugs, which silently killed EVERY catalog-id rewrite, not just glm. New codex_model_vocabulary maps catalog ids to codex slugs (databricks-gpt-5-6-luna -> gpt-5.6-luna) and clamps spawn effort in agreement with clamp_effort_for_model; the router hook rewrites through it and falls open when no slug exists. - Catalog file: glm has no codex slug at all, so the executor reads the installed CLI's own catalog (codex debug models, cached per binary and CODEX_HOME per host process) and writes the session's private model_catalog_json with a glm entry cloned from the cheapest arm, carrying its own low/medium/high effort ladder — codex then clamps an inherited xhigh instead of refusing the spawn. Every failure path leaves codex on its bundled catalog. Live-proven on the local stack: a native spawn_agent glm subagent off an xhigh codex parent ran at system.ai.glm-5-2/medium and completed, with a luna sibling in the same turn unaffected. Family policy pinned by tests in both directions and both modes. Co-authored-by: Isaac Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com> * feat: give codex spawn routing a real signal and honor explicit asks Live verification exposed that no codex spawn could ever land glm even with it offered: this codex's spawn_agent has no task-name field, the spawn message was withheld from the router on a disproven encryption premise, and an explicit model in the spawn arguments was overridden by the placeholder-scored default. Every spawn therefore routed on the 19-char placeholder and landed the default arm (verified live: three spawns, including one explicitly asking for system.ai.glm-5-2, all ran gpt-5.6-sol). - The codex hook now forwards the spawn message (plaintext in hook payloads — measured) as the routing prompt via a new prompt_keys seam, so the router scores the actual task and can pick delegate arms. - The hook also forwards an explicit spawn model as requested_model. The server honors the ask when it is an arm the spawn's own harness could have been routed to (bare-arm match, so any spelling lands the servable one); a cross-family or unoffered ask is routed over and recorded truthfully as attempted_override. The honor is restricted to the requesting harness's candidate row because a rewrite runs in-place — an auto-harness session must not hand codex a claude arm. Co-authored-by: Isaac Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com> * fix: carry requested_model across the runner relay hop The relay resolver rebuilds the route-subagent body field by field, so the new requested_model never reached the server: live, a spawn that explicitly asked for system.ai.glm-5-2 was routed to luna with no attempted_override recorded. The relay test now pins every routing input surviving the hop. Co-authored-by: Isaac Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com> * docs: close the §2.12 GLM-subagent rows with live evidence All four layers verified on the shipping path 2026-08-04: glm in the live spawn menus, exact in-family resolution, and a live glm subagent (turn_context system.ai.glm-5-2/medium off an xhigh parent). Records the two extra layers live testing surfaced: message-as-signal (spawn_agent has no task-name field here) and honoring explicit in-family model asks. Co-authored-by: Isaac Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com> * fix(web): scope a bundle agent's Smart Routing brain to that agent Picking Smart Routing as Debby/Polly's brain-harness renamed the whole composer selection — chip, tooltip, and modal title all flipped to "Smart Routing" as if the top-level auto harness had been picked, and re-clicking the agent's own row silently dropped the brain. The two flavors share no state (auto vs auto-native sentinels, per-agent memory), but the derived autoRoutingSelected union was used for identity, not just row gating. Identity readers (agentLabel, triggerTooltip, configSummary, modal title) now key on smartRoutingHarnessSelected alone; the union keeps its one honest reader (the routing-seed skip) and a comment stating the rule. The bundle modal shows the Agent Harness row alone (locked Permissions belongs to the top-level flavor whose creates actually send permission fields), the permission-reset effect and handleSelectAgent key on the top-level sentinel only, and create payloads are byte-identical in all four flavor combinations. Tests: 292 pass across the three NewChatDialog suites — includes a new "Smart Routing flavors are scoped separately" describe (mixed fixture) pinning both leak directions, plain-create isolation, and the brain surviving a re-pick; the old chip test that encoded the leak now pins the fix; the two locked-Permissions tests moved to the top-level flavor's describe. tsc/oxlint/prettier clean. Co-authored-by: Isaac Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com> * docs: add CUJ_MASTER.md, the consolidated routing CUJ registry One doc merging the full CUJ_STATUS registry (matrix, recipes, tiers, all section areas), the v4 in-harness routing phases (phase 1 landed with evidence; phase 2 blockers), tonight's six live-feedback rows, and a new adversarial section: 23 Breakage CUJs (X1-X23) grounding how this setup fails for other people — missing/old CLIs, non-AIGW credentials, router timeouts vs the hook ladder, hook-merge precedence, shared bridge roots across worktrees, and the static glm fallback offering an arm a workspace may not serve. Includes stack bring-up with a pinned random-port convention, the R11 bare-launch recipe, a 112-row registry, and a revisit list split by needs-human vs headless. Co-authored-by: Isaac Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com> * fix(web): honest subagent-routing display — fresh reads and gated chips The gear modal's Subagent routing row could show Inherit while "on" was stored: the override hydrates only at session bind (no SSE event carries it, the session query never refetches), and the modal seeded its draft once per open — so the row displayed a stale value and Save could PATCH a value the user never picked. The row now holds a pick that reads through to the live store value until touched, save() writes only a pick that still differs from a fresh store read, opening the gear re-reads the two override switches (refreshSessionOverrides — slim snapshot only, so it cannot trigger the sticky-model PATCH), and a session switch under an open modal re-seeds instead of writing the old session's drafts onto the new one. Per the user's ruling, native_subagent routing chips now render only when the override is explicitly "on": on Inherit (or off) the chip would advertise a setting the user didn't choose. Display gate only — the decision rows stay persisted as the audit trail, and an inheriting session's spawns are still routed server-side. Flip-side caveat, deliberate: toggling the setting retro-hides/reveals historical chips. 453 tests pass across the three touched suites (display/write matrix, stale-under-open-modal regression proven failing pre-fix, chip-gate scope table); tsc/oxlint/prettier clean. Co-authored-by: Isaac Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com> * test(web): unit-cover the sub-agent routing chip gate Pins stripGatedSubagentRoutingChips at the unit level alongside the composer-level coverage: explicit "on" keeps spawn chips, Inherit hides them while the session's own (and legacy scope-less) decisions stay. Co-authored-by: Isaac Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com> * feat: gate Smart Routing per harness on AI-Gateway backing A harness whose CLI runs off a personal subscription (ChatGPT codex, Bedrock claude) cannot run a routed pick — routing rewrites the launch model to a gateway catalog id. Verified across four mocked credential states (neither/claude-only/codex-only/both backed) and closed the holes where routing could still be reached: - gateway_inference: gateway_inference_state / not_gateway_backed read a host's reported map under any harness spelling; unknown (older host, unevaluable family) never gates. - server create: the auto path refuses to route when either arm is unbacked (no safe half-menu — the pick lands after the create commits), and an explicit routing-on create pinned to an unbacked native harness 400s with the way out named, instead of minting a session whose routing silently never applies. Children and subagent sessions stay with their parents' spawn/turn gates. - CLI preflight: --smart-routing consulted only the server's host row and silently proceeded when no host had registered — pinning a databricks model onto a ChatGPT-backed pane. The launch always runs on this machine, so the local gateway-inference map is now the authoritative first gate, with the host row as fallback; the two failure modes get distinct messages (no routing model configured vs not AI-Gateway-backed). 328 tests pass across the CLI/gateway/create/routing suites, including a parametrized A-D truth table over both arms and the auto route. Co-authored-by: Isaac Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com> * fix(web): require gateway backing for the bundle-agent Smart Routing brain The Debby/Polly Agent Harness menu offered Smart Routing whenever the server flag was on, even when this host backs only one model family with the AI Gateway — the router could then land the session's work on an arm that cannot run its routed model (a codex pane on a ChatGPT subscription). The auto option now requires both families gateway-backed, mirroring the server-side create gate. Gateway backing only: unlike the top-level harness row, the bundle brain routes across SDK harnesses, so native wrappers/CLIs are deliberately not required. The gate drops only the OPTIONS entry — membership checks and the summary label for an existing pick keep the unfiltered map, so a saved pick still reads back honestly. 235 NewChatDialog tests pass, including the new offers/hides matrix per gateway state; tsc/oxlint/prettier clean. Co-authored-by: Isaac Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com> * fix: make cross-harness spawn redirects actionable in native sessions An auto-harness claude session's redirected spawn was denied with an instruction naming sys_session_create — a tool the model could not find (claude spells MCP tools mcp__omnigent__<tool>, schemas are deferred behind tool search, and no allowlist pre-approved them), so it treated the deny reason as prompt injection and refused. The omnigent MCP was attached all along; the actuation was unreachable. - The deny/redirect reason now names the requesting harness's own spelling (claude: mcp__omnigent__sys_session_create; codex: the bare name plus its omnigent.<tool> display form — verified empirically against codex-cli 0.145: the flattened omnigentsys_session_create is log-only and not callable), notes the tools come from the attached omnigent server and may need a tool search, and degrades gracefully — when the session's relay does not advertise the spawn tool, it tells the model to do the sub-task itself instead of naming a tool that is not there. - Auto-harness claude launches (label or harness_override 'auto', both metadata loaders) add --append-system-prompt with the routing note and an --allowedTools list of the four redirect-loop tools (sys_session_create/sys_agent_list/sys_session_send/sys_read_inbox — the inbox read was live-proven required to close the loop); pinned launches stay byte-identical, pinned sessions never see redirects. - Auto-harness codex launches get the note as developer_instructions (through the reversible sidecar sync) and per-tool approval_mode=approve tables in the generated mcp_servers section. Live-proven on the incident's exact shape: auto-harness claude parent, spawn redirected to gpt-5-6-sol/codex-native, model called sys_agent_list then sys_session_create, child session created on the codex arm with parent linkage, result returned via the inbox, parent reported it. Control session (pinned) carried neither flag. 229 tests pass across the five touched suites. Co-authored-by: Isaac Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com> * docs: record the e2e sweep's evidence across the CUJ_MASTER registry Overnight sweep on both stacks: 9/9 create matrix exact (the C1 glm arrow is gone), GLM subagent rows live-proven including the effort clamp firing, cross-harness redirect actuation end to end, codex bare-launch 8/8 including crash durability, gating row 65 closed live, 1,627 pytest + 1,446 vitest with only the two accepted baseline failures. Registry corrections from false greens the sweep caught: deleting the routing block does not disable routing (only provider:none does), the audit/canary rows are unreproducible since the machinery was cut, the turn-path fail-open is silent, and several recipe spellings fixed. Co-authored-by: Isaac Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com> * fix: switch claude models via the picker, never the global-default arg form Every routed claude-native switch (and the web model picker) typed '/model <arg>' + Enter into the pane — claude's arg form saves that model as the user's GLOBAL default in ~/.claude/settings.json, caught live rewriting the file during the e2e sweep. Ported the v4 actuator: inject_model_selection submits bare /model, polls for the picker, walks the cursor onto the target row, and presses 's' (session-only — proven to leave the file byte-identical; Enter and digit keys both save the default and are never sent), resolving exact catalog-id matches across all rows before any alias match so a workspace serving two generations of one tier lands the right row. auto_confirm's fixed 0.3s sleep is now a dialog poll with a deadline. The web path needed more than the executor's targets, caught live: the picker dropdown sends tier ids, and this workspace serves two Opus generations — 'opus' alias-matched the wrong row and the custom slot (labelled by display name) was unreachable. Targets now come from the session's resolved launch-config env (alias pins + custom slot + slot name) merged under the bridge record; both cases verified live ('opus' -> Opus 4.8, the custom tier -> Opus 5, each session-only). Live proof on the running stack, no restart (runners spawn per session from disk): a routed opus-5 -> sonnet-5 switch and two web switches, panes showing bare /model + 'for this session only', zero 'saved as your default' lines in full scrollback, and ~/.claude/settings.json md5-identical throughout. 640 tests pass across the seven touched suites, including a tripwire that fails if the arg form ever returns. Co-authored-by: Isaac Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com> * feat: let a spec hand its brain harness to Smart Routing A spec that pins executor.config.harness also pins the family its sub-agents are routed within, so a two-headed agent loses the head that lives in the other family: debby's `gpt` sub-agent, declared on codex, was rerouted onto claude-sdk and both heads answered as Claude. Add executor.config.smart_routing_harness: auto, which opts a spec out of its own pin for a Smart Routing session and converges on the "auto" sentinel path the brain-harness picker already offers by hand. Gated to Smart Routing creates only, and never over a client's explicit harness or model pick, so a spec carrying the key is inert with routing off. Set it on debby and polly, whose sub-agents span harness families. Co-authored-by: Isaac Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com> * feat: two-state subagent routing, stamped at create — Inherit is gone Per the user's ruling: a session that starts with Smart Routing routes the subagents it spawns; everything else is Default, meaning whatever the harness natively does. The tri-state inherit (unset resolving to the session's own cost-control state) produced displays the user never picked and a chip gate that disagreed with behavior. - subagent_routing_enabled is now exactly override == "on"; the spawn gate reads one explicit switch instead of re-deriving parent state. - The server create handler stamps "on" once, for every path that starts routed: top-level auto harness, bundle-agent auto brain, fixed native harness with routing on, CLI --smart-routing (including v4's bare in-harness creates, which send cost_control on), and children of a routed parent. Unrouted creates store nothing; an explicit caller value always wins; only "on" is ever stamped so ordinary creates cost no extra write. - One-time data migration stamps "on" onto existing rows exactly where the old inherit rule resolved to routed (146 of 158 live rows), so sessions in flight keep routing their spawns across the deploy; downgrade is a documented no-op. - The gear row offers exactly two options — Smart Routing / Default — reading through to the stored value; a legacy null displays Default and re-picking it writes nothing. PATCH keeps accepting explicit null as an API-level clear; the UI never sends it. The chip gate's logic is unchanged and is now an exact mirror of behavior. 181 python + 642 web tests pass across the touched suites (stamp matrix, migration up/down, two-option UI, PATCH back-compat); tsc/oxlint/prettier and ruff clean. Co-authored-by: Isaac Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com> * feat: the router always decides a requested-model spawn — honor only on match A spawn naming a model bypassed routing entirely ('honored — it is a routable arm'), so the parent model's habit of writing a model field starved the delegate arms: a dry-run subtask that the router scores to glm ran on sol because the router was never asked. Per the user's ruling, the requested model never short-circuits: the router is always called, 'honored' appears only when its pick matches the ask (bare-id normalized, [1m] folded), and a mismatch applies the router's pick with the ask recorded as attempted_override — struck through on the chip next to the applied model — and named in the codex parent's notice so it does not silently re-spawn. Claude-side asks now resolve through the session's alias pins before comparison (a bare 'opus' never matched its own pinned arm and logged a spurious override on every named spawn); inherit/default sentinels carry no ask. The sys_session_send path's raw string compare gets the same normalizer (a servable-alias respelling is not an override). On router outage the spawn still runs on the ask (fail-open unchanged) and the record now says so. Accepted cost, signed off: an explicit ask — including a user-authored 'use glm' — is honored only when the router independently lands the same arm; task_v1 exposes no requested-model input (live-probed: config hints ignored, narrowed menus rejected). Follow-ups if wanted: a requested_model field in the routing proto, or a session-level pin. 197 python + 40 web tests across the touched suites; live-probed against the real router with match, mismatch, and no-ask shapes. Co-authored-by: Isaac Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com> * feat: session Smart Routing is a create-time choice; the gear keeps one knob Custom/SDK agents (Polly, Debby, and any non-native agent session) lose the in-session Smart Routing toggle. It was already a near-no-op for the session's own turns — the first routed turn pins model_override, after which the toggle changed nothing — and its only live effect was gating child spawns through a field the visible Subagent routing row did not control. Per the user's ruling, Smart Routing for a session's own turns happens once, at session start. The Subagent routing row (identical copy, options, and testids to native sessions) is now the single in-session routing control, and the three server-side child-spawn gates (_force_auto_for_child, the SDK and native parent-routing turn gates) plus the child create-stamp's parent clause read the subagent-routing switch instead of parent cost-control. Behavior-identical for every existing row via the create-stamp and the e6f7a8b9c0d1 backfill (live DB verified: zero stranded cc-on/sr-unset rows) — and picking Default now genuinely stops a bundle's spawns from being routed, which the old pair of knobs never delivered. isSubagentRoutingSession widens to all non-native top-level agent sessions (their spawns go through the create path, which is harness-independent), closing the pi-brain gap where the row vanished mid-session. The gear tooltip drops its standalone Smart Routing line, matching native. 189 python + 293 web tests across the touched suites, including gate-flip cases proven to fail against the reverted server edits; full web suite unchanged at 5005 passing. Co-authored-by: Isaac Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com> * spike: codex UserPromptSubmit routing probe (S1/S2 scaffolding) A marker-gated spike-userprompt subcommand on the codex policy hook: logs every UserPromptSubmit payload to the bridge dir, and (behind a one-shot marker file) fires thread/settings/update on the live thread via the app-server websocket, optionally blocking the prompt. Inert without the marker files. Kept as the working reference for the real route-turn hook: the ws:// client framing, the second-command-per-event wiring, and the trusted-module trick are all proven here. Co-authored-by: Isaac Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com> * docs: record the spike verdicts - Variant B disproven, Variant A verified S1 FAIL, 3 runs with a bogus-model positive control: codex binds the turn model at turn/start and writes turn_context before UserPromptSubmit runs, so an in-window thread/settings/update only lands on the NEXT turn. Variant A (block -> settings update -> replay) was then verified end-to-end on codex: clean 1.08s abort, routed turn_context on the replay, re-entrancy marker held, and the forwarder self-pins model_override off thread_settings_applied. S2 PASS: UserPromptSubmit fires for turn/start RPC turns with payloads byte-identical to TUI-typed input; payload carries prompt + LIVE model + codex thread id (not the omnigent session id). S4 PASS: full hook chain 0.37-0.78s; the settings call 26-77ms, wide margin under the 30s budget. New trap recorded: never read the live model from config.toml (stale on every read during the spike); take it from the hook payload. S3 (claude block-and-replay UX) is the only spike still open. Co-authored-by: Isaac Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com> * spike: claude UserPromptSubmit block-and-replay probe (S3 scaffolding) Marker-gated spike-userprompt subcommand on the claude policy hook plus a second UserPromptSubmit command in the bridge's settings generation. Inert without the marker file. Kept as the working reference for the real route-turn hook on claude: it is what proved the block leaves a clean slate and the bracketed-paste replay is byte-exact. Co-authored-by: Isaac Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com> * docs: S3 passes - claude block-and-replay verified, all spikes closed Block is cleaner than documented: input erased, reason shown, and nothing persists (transcript logs only an informational preventContinuation row - no user row, no model call; the omnigent conversation records nothing for the blocked prompt). Replay is byte-exact including a real multi-line prompt, submitted as one turn by the existing bracketed-paste injector. The replay's fresh UserPromptSubmit no-ops on the consumed marker, and /model does not fire UserPromptSubmit so the switch cannot self-trigger. Three routed turns landed three different arms. Visible gap ~3-4s, the /model settle dominating. No turn-2 fallback needed. Records the actuator spec (poll for the Switch model? dialog, settle on context.json - never fixed sleeps) and four claude-specific findings: the hook payload has no model field, /model <arg> rewrites the user's GLOBAL default (product blocker for the actuator, needs a decision), the /model echo can make a weak model refuse the replayed prompt, and this deployment's /model vocabulary is full catalog ids rather than bare aliases. Also flags a pre-existing defect that bites the current branch independently: inject_slash_command(auto_confirm=True) confirms the switch dialog after a fixed 0.3s sleep, but the dialog took 1.861s with cached history - the Enter is dropped and the next injection times out. Co-authored-by: Isaac Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com> * feat: in-harness first-message routing for codex (phase 1) A bare 'omni codex' launch now routes on its first prompt, wherever that prompt comes from (TUI-typed or RPC-delivered) — the spike-verified block-and-replay variant, productionized: - omnigent/runner/turn_routing.py: the decision seam (wire types, the route-once policy, loopback relay with advertisement + live-pid check, and the runner-side replay that waits on the hook's done-marker and the blocked turn clearing before redelivering through the normal events path, which re-checks the gate and records no second decision). - codex hook 'route-turn' subcommand: fast-skip on the marker, POST to the loopback, thread/settings/update + config mirror, then block. - POST /v1/sessions/{id}/hooks/route-turn mirroring route-subagent, reusing route_turn / catalog / decision-chip plumbing. - Registered as a second UserPromptSubmit command in the trusted policy hook module; started/torn down beside the subagent router at launch. - write_advertisement/read_router_endpoint gain a filename kwarg so the loopback plumbing is shared with subagent routing, not copied. The route-once gate is the routing-decision label, not model_override: the codex forwarder mirrors config.toml's stale model into model_override at the first turn/started, beating the hook, so presence can't distinguish a real pin from the mirror. Residual gap (documented in already_routed): a manual pin with Smart Routing on gets hook-routed once; closing it needs pin provenance, left for phase 2. Live-verified on the :64688 stack: trivial->luna, sprawling->sol, one decision row and one user turn each; second turn fast-skips with zero network. Spike scaffolding (spike-userprompt) removed. 96+69 tests pass under the sanitized env run. Co-authored-by: Isaac Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com> * fix: make the blocked first prompt durable across runner crashes Between the hook's block and the replay delivery the prompt existed only as an in-memory asyncio task — a runner crash in that window lost it forever while the decision chip, model_override pin, and done-marker all said routing succeeded (exactly the dead-session shape reported from live testing, reproduced with a SIGKILL at the marker write). The relay resolver now writes turn_replay_pending.json before handing the verdict back (on disk before the hook can block), clears it on delivery or when the hook is known to have fallen open, and keeps it on a failed delivery. On the next launch schedule_pending_replay_recovery drains a leftover record: it requires the marker (proof the hook blocked), waits for the relaunched thread, and only delivers after confirming via the item history that the prompt never ran — an unreadable session leaves the record for a later launch rather than risking a double-run. A session_id match guards forks sharing a bridge dir. Adds a turn_routing.log hook trace for diagnosability. Live-proven on the spike stack: four fresh sessions routed on their first prompt with turn-2 fast-skips, plus a crash-recovery run (SIGKILL at the marker; relaunch recovered and replayed the prompt on the routed model, record cleared). Investigation of the reported dead sessions showed no prompt ever reached them (no UserPromptSubmit, no events, empty rollouts) — the durability gap was the adjacent real defect. 101 tests pass across the turn-routing and codex hook suites. Co-authored-by: Isaac Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com> * feat: in-harness first-message routing for claude (phase 2) A bare 'omni claude --smart-routing' launch now routes on its first typed prompt, mirroring codex phase 1 through the same turn_routing seam: claude-native joins _TURN_HOOK_HARNESSES, the claude hook gains a route-turn subcommand (marker fast-skip, loopback POST with the live model read from context.json, block), and the runner performs the model switch inside the replay via _apply_routed_model — the composer gate only forwards model_override in-band when it just routed, so a hook-routed replay previously arrived with no model and ran on the launch model. The switch actuator drives the /model PICKER instead of '/model <arg>': sandbox-proven that the arg form saves the pick as the user's GLOBAL default in settings.json, while walking the picker with arrows and pressing 's' switches 'for this session only' with the file md5-identical across idle soak and clean exit (digit keys also save the default and are never sent). inject_model_selection resolves exact catalog-id matches across all rows before any alias match — a workspace serving two opus generations otherwise lands the wrong row. The routed composer path switches through the same picker, closing the global default rewrite on every routed turn; auto_confirm's fixed sleep is replaced by a dialog poll with a deadline. CLI: --smart-routing without -p now creates the bare routed session (cost_control on, no create-time route) and launches the TUI for harnesses with in-harness routing; auto/no-harness still requires -p. Spike scaffolding (spike-userprompt) deleted. Live-proven on an isolated stack: five bare claude launches, trivial prompts routing to sonnet-5 and a narrow task escalating to opus-4-8 (the pane held opus-4-8 AND opus-5 rows — the id-first matcher picked right), one decision and one user message each, second prompts fast-skipping with zero network, and ~/.claude/settings.json md5-unchanged after every run. 364 tests pass across the touched suites. Co-authored-by: Isaac Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com> * fix: drop the vestigial turn_router_dir kwarg that broke claude launches A merge-resolution leftover passed turn_router_dir to augment_claude_args, whose merged signature never gained the parameter (the claude route-turn hook registers via bridge_dir and self-gates on the advertisement at fire time) — every claude-native launch on this branch died with a TypeError before the pane existed. Caught by the e2e sweep's bare-launch row. Co-authored-by: Isaac Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com> * fix: apply the routed model to the codex thread in codex's own slug The route-turn actuator sent thread/settings/update the raw catalog id (databricks-gpt-5-6-luna). The turn ran — the gateway serves the id — but codex has no catalog metadata for that spelling, so the pane warned 'Model metadata not found, defaulting to fallback' and /model kept highlighting the launch slug, which reads as routing not working. New codex_model_vocabulary (shaped like claude_model_vocabulary): comparable_model_id folds catalog prefixes, the [1m] suffix, and dot/dash spelling; codex_model_slug resolves the routed id against codex's live model/list rows, so codex stays the vocabulary authority with no hardcoded table. The actuator lists models on the client it already holds, sends the matched slug, and mirrors the same spelling into config.toml so the forwarder cannot flip-flop between spellings; model/list failure or an unmatched id falls back to the id verbatim. The decision row keeps the catalog id. Live-proven: thread_settings_applied carries gpt-5.6-luna, zero catalog-id spellings in the rollout, /model shows the routed row as (current), no metadata warning for the routed model, one decision, turn-2 fast-skip. 122 tests across the four touched suites. Known siblings left for follow-up: thread/start still passes the catalog id (the remaining launch-model metadata warning), and the codex spawn path injects catalog ids verbatim. Co-authored-by: Isaac Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com> * feat: gateway backing selects the router; the chip discloses the source Gateway inference stops being a hide gate and becomes a source selector. Every Smart Routing surface stays available; the AIGW conditions decide which router answers each decision: the external task_v1 client when it is configured and every family the decision involves is AI-Gateway-backed, else the built-in judge (LLMRoutingClient) when the server has one, else today's errors — now reworded to name the real neither-source cause. - New routing_backend seam: RoutingBackends holds both clients; select_router picks per decision; caps carry both (routing_client stays the primary for un-migrated readers). The CLI builds both, so a Databricks deployment keeps its judge as the fallback. - Off-gateway decisions never see the static databricks-* tables: allow_static_fallback gates the infer_models fallback/top-up, and the route declines rather than offer an id the pane cannot run (the two hazard tests pin this seam-first). - Decisions persist router_source ('databricks-aigw' | 'oss-llm'); /v1/info exposes smart_routing_sources; older servers degrade to both-mirror-smart_routing_enabled in the CLI and web alike. - The chip carries a small Databricks mark only when the AI Gateway router answered ('Routed by the Databricks AI Gateway'); OSS and legacy rows carry none; pickers are never branded. - CLI preflight on an off-gateway family with a judge available prints one informational downgrade line and proceeds instead of erroring. - Setup doc and routing overview updated to the source-table semantics. 696 python + 336 web tests across the touched suites (21-test selector truth table, the create-refusal splits, the /v1/info matrix, badge render cases); ruff/tsc/oxlint/prettier clean. The 9 wider-run failures are pre-existing snapshot-cache pollution, reproduced identically on the clean parent. Co-authored-by: Isaac Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com> * test: apply the routing test-suite overhaul and refresh the CUJ registry Registry (designs/CUJ_MASTER.md): 4 rows + 1 recipe cut as fixed or contradicted; the spawn-audit/canary rows retired-with-reason (the machinery went with 484f7300 — deliberately out of scope, named in the PR); row 95 re-entered as a picker regression row; ~22 rows updated to today's ground truth (codex slug comparisons via comparable_model_id, strict adherence, the spec-declared auto brain, the deleted standalone toggle, source-selector semantics); 19 new rows in area O covering the create-stamp matrix through the off-gateway static-menu decline. Suites: the turn-gate tests renamed test_turn_routing_enabled_* so they stop reading as the two-state spawn gate; the matching-ask pair and five integration duplicates folded into their parametrized seam tests with per-item duplication proof (122 -> 119 cases, no coverage lost). 25 new targeted cases: an AST-based guard module pinning that no claude routing path builds '/model <arg>', the switch path holds no fixed sleeps, the picker reads only the user settings file, and cursor/kiro remain the only (documented) arg-form senders; hook-settings cases pinning both routing hooks' timeouts above their script budgets and coexistence with the policy hooks; the turn-routing timeout ladder strictly decreasing and the router client inside the hook budget; the two-concurrent-first-prompts and manual-pin-routed-once gaps pinned as recorded decisions; migration edge cases (unparseable blobs, dangling parents, idempotent re-upgrade). 744 + 364 + 192 sanitized pytest passes across the routing slice; web suites re-confirmed green as baseline; ruff and pre-commit clean. Co-authored-by: Isaac Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com> * test: add the e2e routing CUJ suite behind a mocked router Five end-to-end CUJs — claude and codex from session start (API) and from a typed first message (TUI), plus the auto-harness cross-family redirect — each asserting the routing artifacts (decision rows and their router_source, the pinned model, marker files, thread settings in codex's own slug, pane state, message counts) and never answer content. Two properties make it CI-shaped. The routing API is mocked: a deterministic routes:select service replays the live router's own rule traces (trivial -> cheapest arm, delegate-class -> glm, crosscutting -> default/escalate) and keeps the real contract honest by rejecting a narrowed menu exactly as staging does — proven against the real ExternalRoutingClient over HTTP, not a hand-written body. And subagent spawns are asserted as issued-and-routed rather than awaited, so no test waits on a child's output or an inbox return. 21 pass in ~5 minutes; the suite is opt-in (smart_routing marker plus OMNIGENT_E2E_SMART_ROUTING=1) and skips with a named reason when the CLIs, tmux, or a provider config are absent. Each test boots its own ephemeral server, host, temp DB and temp config home; the developer's settings files are left untouched, which CUJs 1/3/5 assert by digest. The CLIs are launched with their trust-bypass flag through terminal_launch_args (the pattern tests/e2e/test_comment_tools_claude_native.py already uses) because a fresh temp workspace otherwise blocks the input box on a trust dialog before any hook can fire. Co-authored-by: Isaac Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com> * chore: remove development-session scaffolding from the PR Working docs (CUJ registries, plan documents, session setup notes), the personal dev scripts (dev-env/run-server/run-host/run-frontend and the routing-API probe), and their allowlist rows were session tooling, not product: several named internal staging workspaces and proxy endpoints, and none of them belong in a public repo. A test fixture's profile string is generified for the same reason. The user-facing routing documentation moves to the omnigent-site docs (PR #446 there). Co-authored-by: Isaac Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com> * fix: settle the rebase against main's session-routes and model-picker work Main split the session routes into explicit imports and grew a host-resolved Codex launch-model catalog while this branch was out; the replay needed both re-applied by hand. - Import the names the routing paths use explicitly (`_logger`, `_get_runner_client`, `_spawn_gateway_backed`, the validators) now that `routes_hooks` / `routes_core` no longer star-import them. - Keep the pre-existing `native_policy_not_enforced` banner: the trim commit dropped its server half, but the runner still reports the degrade reason, and main re-exports the helpers. - Codex's Model row now carries the host's real catalog alongside the Smart Routing sentinel instead of replacing it, with the resolved default label back via a `defaultLabel` prop on `RoutingModelSelect`. - Refresh the tests those two changes made stale, and re-apply the hook timeout the dropped merge commits had fixed in place. Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com> * fix: apply the external-review fixes and drop both new migrations - Turn dedup compares decoded user-message text, not a JSON dump - A no-op model pick is terminal: pinned and recorded without replay - Child sessions route once; follow-ups cannot flip harness_override - The turn marker is scoped to {session, decision}; the claude hook reads the live session id, so /clear cannot reuse a stale marker - Hook relays require LEVEL_EDIT; rationales log at DEBUG - The turn router registers only when routing is enabled; codex model catalog population runs off the event loop with a 60s failure TTL; hook timeouts sit 10s above the inner HTTP timeout - gateway_inference moves off the hosts table onto the host connect handshake, held in server memory (unknown-is-backed until a host re-reports); both alembic migrations are deleted — the PR adds zero migrations - Routing availability checks unified on the routing_backend helpers Co-authored-by: Isaac Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com> * test: gate the router's ambient-credential tests on the databricks extra The new ambient workspace-credential tests patch ``databricks.sdk.config.Config``, but ``tests/server`` runs on a lean CI lane that neither installs the ``databricks`` extra nor deselects marked tests, so all eight failed collection with ``ModuleNotFoundError: No module named 'databricks'``. Mark them the way the repo already gates SDK-coupled tests, and list ``tests/server/test_smart_routing.py`` on the databricks lane — a marked test in a path that lane does not cover would otherwise run nowhere. Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com> * revert: switch claude models with `/model <id>`, not the picker Switching a live claude-native pane through Claude Code's interactive `/model` picker took ~530 lines of tmux screen-scraping to avoid one side effect: the argument form also saves the pick as the person's global default in `~/.claude/settings.json`. The repo owner has accepted that write, and an external review found the picker path fragile in ways the argument form has no equivalent of — a 5s server forward budget against a ~35s worst-case automation whose result was discarded, an applied-check that could return before the ~1.9s "Switch model?" dialog rendered, a next-message-swallowed-by-dialog hazard, no busy-pane gate, no scroll handling, and no concurrency lock. So every claude model-switch call site goes back to injecting the text `/model <id>` plus Enter through `inject_slash_command`, with `auto_confirm=True` so the cache-invalidation dialog is still answered: - the web/API `model_change` endpoint (`runner/app.py`), - the first-message turn-routing switch (`runner/turn_routing.py`), - the per-turn executor switch (`inner/claude_native_executor.py`). Fail-open semantics are unchanged: a failed injection is logged and the turn still runs on the pane's current model. Deleted with their last caller: `inject_model_selection`, the picker's open/apply poll ladders, the row regex and row scanner, the row-matching and row-picking helpers, the session-only key, and the two runner-side target-spelling resolvers. Kept: `inject_slash_command` and the polling `_confirm_tui_dialog` (shared with `/effort`, and a real improvement over the fixed 0.3s sleep it replaced), plus a single picker-footer string the pane-readiness gate uses to notice a picker the person opened by hand. The AST guards that forbade the argument form are gone; the "omnigent never writes the user's settings file" and "no fixed sleeps on the switch path" guards stay, since both still guard live code. The e2e settings guard now compares everything in `~/.claude/settings.json` except the `model` key Claude Code itself moves. Co-authored-by: Isaac Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com> * fix(web): one routing chip per pick, hydrate the gear modal's Model row A Smart Routing create routes twice: once at create time (recorded as a `session`-scope chip) and again on the session's first turn (a `turn`-scope chip). Both land before the user's message, both resolve to the same model and harness, and both render the identical "Smart routing · applied · claude-native" card — one above the message, one below. The transcript opened on a duplicate. Collapse them in the block walker: a `session` chip whose next content block is a `turn` chip with the same model, harness, applied flag, and agent renders nothing, and the turn chip (the one that pairs below the message) stands for the pair. Both rows stay persisted as the audit trail, and a create-time pick the turn CHANGES — or a failed create-time route, recorded as an unapplied `"unavailable"` row — still renders its own chip, because those two chips say different things. Also in the gear modal, the Model row rendered blank on a routed session. Routing pins the router's fully-qualified pick (`databricks-claude-opus-4-8`), which the harness catalog carries only under an alias (`opus`) — so no option declared the Select's value and Radix fell back to its empty placeholder. The live model now rides as its own option, labelled exactly as the status label below the composer. An untouched row still submits nothing: `save` re-pins only a draft that actually changed. Three review findings: - `useSession` asks for `refresh_state=true` on every fetch again. Narrowing it to the cache-cold fetch meant an invalidation refetch — how switching a session's agent reloads the snapshot — came back off the runner's process cache, leaving the PREVIOUS agent's model catalog on screen until a hard reload. - Drop the 30s snapshot poll every open session ran. Its only consumer was the session warning banner, which the enforcement-stack trim removed; nothing reads a field the poll refreshes, so the poll and its opt-in options go with it. That also makes the unconditional refresh above safe — nothing re-asks often enough to thrash the runner's caches. - `refreshSessionOverrides` no longer fetches through the query client. It reads two plain DB columns, but writing the reply into the shared `["session", id]` cache replaced every other surface's refreshed snapshot with an unrefreshed one, dropping the `model_options` the model picker renders from. Co-authored-by: Isaac Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com> * fix: scope the codex routing extras to the sessions that need them Three session classes now decide what a codex home carries: a plain session gets a byte-identical pre-routing home (bundled catalog, symlinked hooks.json, no spawn gate, no extra tool approvals); a pinned-harness Smart Routing session adds only the extended model catalog; an auto-harness session that routes to codex adds the spawn gate and the cross-session tool approvals. The subagent router endpoint starts only where something consumes it. The catalog probe validates its payload and holds a lock across concurrent boots. Dispatch validation accepts gpt substrings again and localizes glm/kimi ids mechanically. The codex env filter now lets the router and catalog launch signals through — the SDK-codex hook path was silently dead without them. Co-authored-by: Isaac Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com> * fix: keep pinned codex launches free of routed-spawn extras The runner passed `developer_instructions` to `build_codex_native_server` for every codex terminal (with a `None` value on pinned sessions), which changed the launch call shape for sessions Smart Routing does not own. Pass the kwarg only for auto-harness sessions. The claude-native launch-args tests handed a raw `tmp_path` to `augment_claude_args`, which validates the bridge dir against the real bridge root; point the bridge root at the test temp dir the way the bridge's own tests do so the tests pass under any TMPDIR. Co-authored-by: Isaac Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com> * fix: satisfy the type and hardcoded-model gates pyrefly on the pre-commit gate rejected five shapes the routing work introduced: an inferred `dict[str, int | str]` hook literal that could not take the route-turn entry, two `Awaitable` resolver results handed to `asyncio.run_coroutine_threadsafe` (which takes coroutines only), and two locals — `_parent_conv`, `_auto_harness` — read on paths where only a narrower branch had assigned them. It also flagged the create path rebinding `conv` from `get_conversation` without a `None` check, which made every later attribute read an error; it now raises the same `INTERNAL_ERROR` its sibling label writes do. The router's static model tables moved to `omnigent/model_fallbacks.py` as owned `StaticModelFallback` records — the repo's only sanctioned home for a static model id, per the `no-hardcoded-models` lint. Ids that are composed from the gateway's model-route prefix (GLM's `system.ai.` spelling) are now spelled that way instead of restated. Co-authored-by: Isaac Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com> * test: cover the Smart Routing UI in the Playwright suite The web changes add user-visible routing surfaces with no e2e_ui coverage, which the E2E UI Required gate flags. Two specs, following the suite's established stub patterns: - `start_session/test_smart_routing.py` — the landing picker's Smart Routing row (create sends `harness_override: "auto"` + `smart_routing_message`, and none of the placeholder wrapper's knobs), Smart Routing as the gear modal's Model choice (create sends `cost_control_mode_override: "on"`, no pinned model), and the negative gate: a server with routing off offers neither. - `chat/test_smart_routing_session.py` — a routed session's two audit rows (create-time `session` chip + first-turn `turn` chip) render as ONE chip with the Databricks mark, and the session gear modal's Model row names the router's fully-qualified pick instead of rendering blank. Both run against the suite's spawned server with `/v1/info`, `/v1/hosts` and `/v1/agents` stubbed, so neither needs gateway credentials. Co-authored-by: Isaac Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com> * fix: match the gateway's trusted parents on DNS labels The AI Gateway trust check compared the parsed hostname against dot-prefixed domain suffixes with `str.endswith`. Correct as written (the leading dot is what rejects `evilcloud.databricks.com`), but the safety rests on a spelling convention in a constant, and a string-suffix test on a domain literal is exactly the shape static analysis flags as incomplete URL sanitization. Compare whole DNS labels from the right instead, requiring at least one label of the host's own in front of the parent domain. Same verdicts, with the boundary now structural, and tests pinning both look-alike classes: a trusted domain that only appears mid-host, and a label that merely ends in one (`evilcloud.databricks.com`, `ai-gateway.notazuredatabricks.net`). Co-authored-by: Isaac Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com> * fix: stop the routing hook's codex floor from blocking every launch Raising `_CODEX_MIN_VERSION` to 0.145.0 for the routing PreToolUse hook made `harness_cli_installed("openai")` report `version-too-low` on 0.137–0.144, which makes `harness_is_configured("codex")` false, which makes the host refuse EVERY codex launch — plain sessions included — with a misleading "run omni setup". CI pins codex 0.139.0, so the e2e lane failed on it too. Restore 0.137.0 as the launch floor and enforce 0.145.0 only where the spawn gate is actually registered: both codex hook writers now probe `codex --version` and, on an older CLI, log one line and drop the routing bridge dir so no hooks are generated at all. Routing no-ops instead of blocking, and the user's hooks.json stays symlinked. Co-authored-by: Isaac Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com> * fix: confirm the /effort dialog instead of hanging on its title `inject_slash_command(auto_confirm=True)` polled `capture-pane` for the hardcoded "Switch model?" and sent Enter only on a match. The web UI's effort change injects `/effort <level>`, whose confirmation dialog is not titled that — so it never matched, the dialog stayed open, the change never committed and the pane was wedged for the next injection. The no-dialog case also spent the whole 4s poll budget where the previous code spent 0.3s. Make the hint a per-command parameter and keep an unconditional confirm Enter as the floor, which is what the code did before the poll was introduced: on the no-dialog case it lands on an empty prompt and is a no-op. The three `/model` sites pass the title they know and keep their fast path; `/effort` passes none, settles briefly and confirms blind. Co-authored-by: Isaac Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com> * fix: keep the spawn-routing apparatus off plain claude sessions claude-native passed `auto_harness=True` hardcoded and the SDK path started the router for every claude session, so a plain claude session carried a loopback HTTP server, its thread, a bearer token on disk, and a `Task` PreToolUse hook — a subprocess cold start on native, in-process on the SDK — on every spawn, with a 30-40s worst case when the endpoint is wedged. All of it for a verdict the server would never route. Gate both starts on the session's routing class, the same one the codex paths already read. A plain claude session now gets no router, no hook and no token file, matching plain codex; a routed session (pinned or auto — claude routes spawns in both) keeps everything, and the per-spawn server-side gate stays as defense in depth. Accepted consequence: the class is stamped at create, so flipping the gear's Subagent-routing toggle on for a plain-created claude session is inert until the session is recreated. That matches the stamped-at-create design the codex paths already follow. Co-authored-by: Isaac Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com> * fix: stop plain launches from displacing the model picker slot `claude_config_with_launch_model_pinned` ran on every claude-native launch. Whenever the launch model is an exact id no family alias points at — a user picking an older generation of a family the workspace still serves — it overwrote `ANTHROPIC_CUSTOM_MODEL_OPTION`, taking the workspace's own picker row with it. The slot exists so a routed session can return to the model routing picked for it. Nothing re-picks the launch model on a plain session, so gate the pin to routed launches and leave a plain launch's env untouched. Co-authored-by: Isaac Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com> * test: restore main's spawn-env secret-leak canary The trim commit deleted this file by name collision with the routing spawn-audit canary; it is main's own guard for clean_agent_env and was never part of this PR's machinery. Co-authored-by: Isaac Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com> * fix: keep the router rendezvous out of logs The subagent- and turn-router startup logs printed the handle's url, and the hook's rejection diagnostics echoed the url read out of the advertisement. Both values travel with the bearer token that authorizes the loopback endpoint, so a log line was enough to point a reader at the secret's neighbourhood; static analysis flagged the four sites as clear-text logging of sensitive data. Drop the url from all four: the session id and the bridge directory (or the advertisement's file name) identify the rendezvous well enough, and the advertisement itself is on disk for anyone debugging it. Co-authored-by: Isaac Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com> * fix: confirm an effort dialog that renders after the blind Enter A command whose dialog text we cannot recognise — ``/effort`` — settled 0.3s and then Entered blind. On a warm session the confirmation renders about 1.9s in, so that Enter landed on an idle prompt and the dialog that arrived afterwards stayed open: the person's next message was typed into the modal and swallowed. Keep the blind Enter as the fast path, then keep watching the pane for a dialog until the confirm timeout and Enter again if one turns up. With no dialog text to match on, the watch uses a structural signal — a framed menu of at least two numbered choices with one selected — which also recognises the ``/model`` picker and steps around a composer draft that merely starts with ``2. ``. A dialog already showing at the settle skips the watch, so the common cases still cost one capture. Co-authored-by: Isaac Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com> * fix: derive claude launch routing state through the shared class Both claude-native launch-metadata builders hand-derived ``routing_enabled`` from ``cost_control_mode_override`` alone, while ``routing_class_from_snapshot`` deliberately ORs in the auto-harness signal. A sub-agent child of a routed parent is created with ``harness_override="auto"`` and the auto-harness label but no cost-control stamp, so it launched ``routing_enabled=False`` with ``auto_harness=True``: no pinned arms, no launch-model pin, no turn router and no subagent router — yet still carrying the routed-spawn system-prompt note and the four pre-approved ``sys_*`` tools. Claude was told to hand its spawns to a hook nothing answered. Route both builders through ``routing_class_from_snapshot`` so the class is derived in one place, and require the spawn router to have actually started before the note and pre-approvals go onto the argv. Co-authored-by: Isaac Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com> * fix: stop offering subagent routing where it cannot work The create path stamped ``subagent_routing_override="on"`` on every session that started on Smart Routing, and the gear offered the Subagent-routing select to every native Claude/Codex session. On a session pinned to codex neither is real: spawn routing there needs the generated ``hooks.json`` and the routed-spawn tool pre-approvals that only an auto-harness launch installs, so the switch read "on" with nothing consuming it. The same went for a plain native session of either family, whose apparatus is fixed at create. Leave the stamp off for a pinned codex create, and hide the row wherever the session's class has no spawn-routing machinery — a claude-family routed session and any auto-harness session keep both. Non-native SDK/bundle sessions are untouched: their children go through the session-create path, which re-reads the switch per spawn. Subagent routing is now launch-time-fixed for codex. Co-authored-by: Isaac Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com> * fix: make the model switch land once, or say why it did not Three faults left over from reverting the interactive ``/model`` picker. The web/API model-change handler typed the resolved catalog id straight into ``/model``, which takes only the pane's own picker vocabulary. An id outside it left the pane on its old model while the handler reported success. Translate through ``claude_model_command_arg`` like the routed turn path and the executor already do, and fail with a clear 503 when the picker has no spelling for the model. A routed first message switched twice. The turn router blocks the prompt, types the switch and replays the prompt with the same override, but the executor seeded its baseline from ``launch_model`` — written once at bridge prepare — so the replay compared against the pre-switch model and typed a second, redundant ``/model``. Seed from the live statusLine model instead, and compare normalized. A dropped forward was invisible. The PATCH persisted ``model_override`` and discarded the forward's result, so on a native pane — where the injection is the only thing that moves the model — the row and picker claimed a model the terminal was never on. Publish a visible notice and log the reason. The forward budget also went up: the ``/model`` and ``/effort`` injectors can legitimately spend ~5s waiting on the pane and its confirm dialog, which the old 5s budget would have reported as a failure. Co-authored-by: Isaac Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com> * fix: clear the routing punch list's small residuals - The install and credential routes recorded ``gateway_inference`` straight off the host's RPC reply, so a host answering with anything other than a string→bool object 500'd them inside ``dict(...)``. Decode through the same tolerant reader the tunnel path uses, where a non-mapping is "unknown". - Reworded the routing docstrings that cited design documents no longer in the repo; the behaviour they described is stated inline, and the e2e suite in tests/e2e/routing/ is the executable reference. - ``routing_enabled(caps=)`` read the routing backends directly, which misses the managed arm where only a policy-LLM factory is registered and the routing client arrives later. It goes through ``routing_available`` now, the same gate the rest of the server uses. - The codex model-catalog cache was keyed on binary path plus codex home, so an in-place upgrade (same path, new bytes) served the previous codex's catalog for the life of the host process. The binary's mtime and size are part of the key now. Co-authored-by: Isaac Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com> * docs: match the gear's comments to the narrowed subagent gate The two comments still described the old "every native Claude/Codex session" rule. Say which classes carry the apparatus and which the row is hidden for. Co-authored-by: Isaac Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com> * test: pin that the late-dialog Enter only answers our own dialog The extra Enter is scoped to a dialog that appeared after the settle, so a menu already open when the command was injected — a live permission prompt, say — still takes only the single blind Enter this seam always sent. That property is what makes widening the confirm window safe, so it gets a test and a note rather than living in the reviewer's head. Co-authored-by: Isaac Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com> * fix: answer the effort dialog by name, not by shape The effort confirm watch Entered on any dialog that turned up during its 4s poll, so a ``/model`` picker the person opened by hand — or a tool permission prompt that rendered mid-turn — took the Enter too: the first silently rewrites their global default model, the second silently approves the tool. Claude Code titles both cache-invalidation confirmations from one component, so ``/effort`` has a title to poll for just like ``/model``: "Change effort level?". Pass it as the effort call's ``confirm_hint`` and drop the shape-matching watch — ``auto_confirm`` now requires a hint. The timeout Enter stays, so a title that drifts in a future release does not wedge the pane, but is withheld when the pane shows a picker or a permission prompt. The readiness gate learns the effort title too, so an open effort dialog no longer reads as "an injection may land". Co-authored-by: Isaac Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com> * fix: suppress the codex subagent stamp only where it is inert The create-time subagent_routing_override stamp was skipped for anything whose harness family is "gpt". That also caught an SDK/bundle agent whose brain is codex or openai-agents — and those spawn their children through the session-create path, which re-reads the switch per spawn, so the stamp is exactly what gives them default child routing. Skipping it took that away, and disagreed with the gear, which offers the row on every non-native session. Suppress only where the switch really has nothing behind it: a NATIVE codex terminal, whose spawn routing comes from the hooks.json and tool pre-approvals an auto-harness launch installs. The server and the gear now agree class by class: native pinned-codex hides the row and writes no stamp; a codex-brained bundle keeps both. The old fixture had no spec harness, so it never reached the family check; the new case pins a codex-brained bundle on both sides. Co-authored-by: Isaac Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com> * fix: clear the routing punch list's last three residuals - The "terminal was not switched" banner fired on stopped and detached native sessions too, where nothing was running to diverge from: the relaunch reads model_override off the row. Surface it only when a runner actually answered and refused, which is the reachability the /health liveness field reports. - Add the credential route the tolerance test the install route got: a host reply whose gateway_inference is a list must read as "unknown", not 500 with the credential already written. The install test never proved that — its garbled value was dropped by the fixture before it reached the frame — so both now inject at the proxy's return, past the decoder that would otherwise normalise it away. Co-authored-by: Isaac Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com> * test: drive the gateway-flip repush through the readiness loop Upstream moved readiness refresh into its own task; the flip test now exercises that loop directly instead of the removed tunnel helper. Co-authored-by: Isaac Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com> * fix: log nothing that addresses the router rendezvous The redaction kept the session id and bridge path, which still name the loopback endpoint whose advertisement carries the bearer token. The start-up lines and the marker-failure notice now carry no values at all. Co-authored-by: Isaac Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com> * fix: make routing fail open in seconds, not in half a minute Routing was already advisory everywhere it mattered, but the budgets meant a wedged router still stalled the work it was supposed to get out of the way of: a subagent spawn sat behind a 30s request inside a 40s hook kill, and a first typed prompt sat behind 25s inside 45s. A fail-open that takes that long is blocking in practice — the user cannot tell it apart from a hang, and the turn they were promised runs no sooner for the wait. Retune every routing ladder around one number: the routing call itself gets 5s, sized from the observed round trip (healthy routes:select answers in ~1.4-3s; the slowest sample on record was a gateway 500, not a verdict). Each hop above it takes one more second, out to the harness-registered kill at 15s (spawn gate 12s), which is now the only budget above single digits. One attempt, no retry: a second try on an interactive path only doubles the stall. Two budgets on these paths were unbounded rather than merely long. The built-in judge inherited the server `llm:` block's 300s request timeout, multiplied by every configured fallback model, so picking the OSS router as the source turned a fail-open into a multi-minute hang; it now shares the external router's 5s. And the stale native model-options refresh, awaited only to sharpen a routing candidate list, retries a booting runner for ~30s; routing now waits 3s for it and lets the single-flight finish filling the cache on its own. The CLI's preflight reads move off the create's 60s read budget too. They answer in milliseconds and every failure already degrades to "unknown", which does not gate, so there was nothing to win by waiting. The create's own budget is left alone: that one is a session create, not a routing call. Co-authored-by: Isaac Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com> * fix: stop a routing outage from 500ing the turn it was routing `route_turn` was the one routing seam that let its failure out. Its two callers on the message path did not guard it, so a client that raised instead of declining — a gateway 500 surfacing as HTTPStatusError, a read timeout, a garbled body, a 401 — propagated to `POST /v1/sessions/{id}/ events` as a 500. By then the user's message had already been persisted, so the turn was not merely unrouted: it was persisted and abandoned. Its sibling `route_session_harness` has always returned an `error` string for exactly this, which is what made the asymmetry easy to miss. Add `route_turn_or_decline` as the turn path's fail-open boundary, in the same `(model, verdict, error)` shape, and take the visible half of failing open with it: the declined `routing_decision` card the auto-harness path already emitted ("unavailable", applied=False) now covers the turn and the native-pane paths too, so a session does not quietly ignore the toggle the user turned on. A failure deliberately does NOT stamp the routing-decision label. That label is the route-once gate, so claiming it would turn one outage into the reason the session never routes again — the failure is a card, not a decision. Everything else audited on the routing paths was already fail-open and stays untouched: the CLI's routed create and its auto-harness fallback, the create-time server paths, the spawn-gate relay, both first-message hooks, the loopback relays, both clients, and the model-switch application step. The precondition gates that decline before anything starts are also left alone — those are config rejections the owner asked for, not call failures. Regression coverage for both properties (work proceeds, budget respected) across gateway 500 / timeout / malformed body / 401 / unreachable relay, at every call site: the SDK turn path, the native pane path, the spawn relay, the first-message relay, both create paths, both hook scripts, both clients, and the CLI's non-routing-400 fallback notice. Timing assertions are against the ladder constants, never a wall clock. Co-authored-by: Isaac Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com> * fix: give a child spawn's failed route the same visible decline `route_session_harness` returns its reason as an `error` string, and the child-spawn branch of the message path unpacked it into `_route_err` and then never read it. So the last routing path that could not route left no card at all: the spawn ran on whatever the orchestrator had asked for, which is right, but from the transcript "the router was down" and "the router had no opinion" were the same thing. Emit the same "unavailable" card the auto-harness and turn paths emit. Set last, after the branch's own pin and publish, so nothing upstream can pin or announce the placeholder — and leave the route-once label unclaimed, because a child routes per spawn and `_child_routed_before` reads that label, so stamping it on a failure would stop the child from ever being routed again. The flag is renamed `_route_failed` now that both branches set it. Also covers the bounded catalog wait: a stale-catalog refetch that never finishes serves the stale vocabulary within `_ROUTING_CATALOG_WAIT_S` and leaves the single-flight running to fill the cache for the next turn. Co-authored-by: Isaac Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com> * fix: let a pinned Smart Routing codex session actually spawn Suppressing the create-time subagent-routing stamp for a native pinned-codex session was justified on the theory that the switch would be inert there. It was worse than inert: the pinned class was also withheld the spawn-routing advertisement, and on codex that advertisement is what turns on the generated ``hooks.json`` ``spawn_agent`` gate AND the four routed-spawn tool pre-approvals. A pinned Smart Routing codex session therefore had no spawn gate and no pre-approved cross-session spawn tools, so its spawns did not merely go unrouted — they stalled on an approval prompt nobody was watching. Stamp every routed create again, and start the endpoint for a routed codex-native launch whether or not the harness was auto-picked, which brings the gate and the approvals with it. The codex SDK arm keeps the auto-harness requirement: its spawns go through the session-create path, which already routes off the stamped switch, so an in-harness gate would only add a round trip. Plain sessions still get none of it. What separates pinned from auto-harness is not whether spawns route but where they may land: ``cross_harness`` stays ``auto_harness_session``, so a pinned codex spawn is offered codex arms only and a claude pick is denied. The web predicate now shows the gear's Subagent-routing row for exactly the classes the server stamps. Co-authored-by: Isaac Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com> * fix: collapse a repeated routing verdict into one chip again A Smart Routing create records its pick as a session-scope chip and the first turn records the identical pick as a turn-scope chip; only the turn chip should render. The pairing test asked whether the two decisions were ADJACENT, using the same neighbour walk that decides where a chip sits relative to the message it routes. That walk steps over exactly the blocks allowed between a chip and its message, so anything else a booting session emitted between the two decisions — narration, an earlier message, a whole finished response — read as "unrelated" and both chips rendered. Pair them by decision order instead: the next routing decision anywhere later, across intervening blocks and turn-group boundaries. A turn chip that CHANGED the pick, a declined create-time route followed by an applied one, and a spawn's deny-then-honor pair all still render as two — the first two because the verdicts differ, the last because a subagent-scope decision is never the supersessor. The incremental path had its own hole: the create chip is finalized into the cached prefix frames before the turn chip exists, and the drop was computed only from the walk's resume point, so a chip already in the prefix could never be removed. The verdict set is now resolved over the whole transcript and remembered on the cache, and a disagreement over the prefix forces the single rebuild that removes the stale chip. For the record, the resource_event in the reported transcript is not the mechanism: an unknown item type yields no block from itemsToBlocks and session_resource_created adds none on the live path, so it never separated the two. The wire rows are kept as a funnel regression test regardless. Co-authored-by: Isaac Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com> * fix: keep a pinned session's spawns in its own harness family A pinned Smart Routing codex session spawned a claude child and the router pinned it to claude-sonnet-5: the in-harness spawn gate holds the in-family line (candidate_models(cross_harness=False)) but the child-session route on the native-terminal dispatch path had no such rule. It routed whatever family the child's own pane ran, so an orchestrator that named another family's wrapper agent got a cross-family spawn blessed by routing — against the standing ruling that only an auto-harness session may cross. The native child path now asks the same predicate the spawn gate does (auto_harness_session(conv, parent)) and, for a pinned parent whose child runs another family's CLI, routes nothing: no pin, no in-band /model, and a declined chip naming the rule. The spawn itself still runs, on its CLI's own model. Also resolve a native pane's family from the terminal it is actually running rather than an unresolved "auto" sentinel. The sentinel carries no family, so a forced-auto child was offered every model its gateway serves and could be pinned to one its running CLI cannot speak. Co-authored-by: Isaac Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com> * fix: render one routing chip per spawn, not two One spawn produces two decisions — the in-harness gate sizes the task, then the child session it created routes its own first message — and the transcript showed both: one chip labelled "Session" (the gate row carries no agent name) and one naming the spawned agent, with the same rationale. To the owner that is one decision about one spawn. The pair now collapses onto the child-session row, which is the informative one: it names the spawned agent and the arm that actually ran, keeping the gate's own pick visible as the router's raw verdict when a tier substitution moved it (opus-4-8 -> opus-5). The two rows share no spawn id — different decision ids, no agent on the gate row, minutes apart — so the pairing key is the verdict: the same non-empty rationale AND the child running the arm the gate picked. A deny-then-honor pair, two independent spawns, and two genuinely different verdicts all still render as two chips. Co-authored-by: Isaac Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com> * fix: name the cause on a routing decline that had none A live decline read "Routing unavailable (router request failed: )" — a dangling colon with the reason missing. httpx's timeouts stringify to the empty string, so the exception the fail-open budget produces most often was also the one that said nothing. Every routing failure string now falls back to the exception class ("router request failed: ReadTimeout"), which is what a 5s budget firing looks like. The subagent gate had a second way to lose the cause: a client that raises before it can record its own last_error left the chip saying only "router returned no verdict", with the real failure in the server log alone. It now carries the raised cause when the client reported none. Co-authored-by: Isaac Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com> --------- Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>
3184 lines
120 KiB
Python
3184 lines
120 KiB
Python
"""Tests for native session lifecycle, status, interrupt, and stop events."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import uuid
|
|
from collections.abc import Mapping
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
import pytest
|
|
|
|
from omnigent import (
|
|
claude_native_bridge,
|
|
codex_native_bridge,
|
|
cursor_native,
|
|
cursor_native_bridge,
|
|
kiro_native,
|
|
kiro_native_bridge,
|
|
)
|
|
from omnigent.claude_native_bridge import (
|
|
bridge_dir_for_conversation_id,
|
|
)
|
|
from omnigent.entities.session_resources import SessionResourceView
|
|
from omnigent.runner import create_runner_app
|
|
from omnigent.runner.resource_registry import (
|
|
KIRO_NATIVE_TERMINAL_ROLE,
|
|
)
|
|
from omnigent.spec.types import AgentSpec, ExecutorSpec
|
|
from omnigent.terminals import TerminalRegistry
|
|
from tests.runner.conftest import (
|
|
_drain_session_event_queue,
|
|
_FakeProcessManager,
|
|
_runner_client,
|
|
_ScriptedHarnessClient,
|
|
)
|
|
from tests.runner.helpers import NullServerClient
|
|
|
|
|
|
class _EventRecordingServerClient(NullServerClient):
|
|
"""Records Omnigent ``external_*`` event POSTs for assertion.
|
|
|
|
Subclasses :class:`NullServerClient` so all other runner→AP calls still
|
|
succeed silently; captures ``external_conversation_item`` bodies so a
|
|
test can assert that NO interrupt marker was persisted, and
|
|
``external_mcp_startup`` bodies so Stop tests can assert the cancelled
|
|
MCP map was published.
|
|
"""
|
|
|
|
def __init__(self) -> None:
|
|
self.posted_items: list[dict[str, Any]] = []
|
|
self.posted_mcp_startup: list[dict[str, Any]] = []
|
|
|
|
async def post(self, url: str, **kwargs: Any) -> NullServerClient._Response:
|
|
"""Record ``external_conversation_item`` / ``external_mcp_startup`` bodies."""
|
|
del url
|
|
body = kwargs.get("json")
|
|
if isinstance(body, dict) and body.get("type") == "external_conversation_item":
|
|
self.posted_items.append(body.get("data") or {})
|
|
if isinstance(body, dict) and body.get("type") == "external_mcp_startup":
|
|
self.posted_mcp_startup.append(body.get("data") or {})
|
|
return self._Response()
|
|
|
|
|
|
class _RecordingCodexAppServerClient:
|
|
"""
|
|
Test double for Codex app-server JSON-RPC controls.
|
|
|
|
:param transport: Transport passed to
|
|
:func:`omnigent.codex_native_app_server.client_for_transport`, e.g.
|
|
``"ws://127.0.0.1:1234"``.
|
|
:param client_name: App-server client name, e.g.
|
|
``"omnigent-codex-native-runner"``.
|
|
"""
|
|
|
|
def __init__(self, transport: str, client_name: str) -> None:
|
|
self.transport = transport
|
|
self.client_name = client_name
|
|
self.connected = False
|
|
self.closed = False
|
|
self.requests: list[tuple[str, dict[str, Any]]] = []
|
|
self.model_list_responses: list[dict[str, Any]] = []
|
|
|
|
async def connect(self) -> None:
|
|
"""
|
|
Mark the fake client connected.
|
|
|
|
:returns: None.
|
|
"""
|
|
self.connected = True
|
|
|
|
async def request(self, method: str, params: dict[str, Any]) -> dict[str, Any]:
|
|
"""
|
|
Capture a JSON-RPC request.
|
|
|
|
:param method: JSON-RPC method, e.g. ``"turn/interrupt"``.
|
|
:param params: JSON-RPC params, e.g.
|
|
``{"threadId": "thread_123", "turnId": "turn_123"}``.
|
|
:returns: Empty successful JSON-RPC result.
|
|
"""
|
|
self.requests.append((method, params))
|
|
if method == "model/list" and self.model_list_responses:
|
|
return self.model_list_responses.pop(0)
|
|
return {"result": {}}
|
|
|
|
async def close(self) -> None:
|
|
"""
|
|
Mark the fake client closed.
|
|
|
|
:returns: None.
|
|
"""
|
|
self.closed = True
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize(
|
|
"event_payload,expected_params",
|
|
[
|
|
(
|
|
{"type": "model_change", "model": "gpt-5.4"},
|
|
{"threadId": "thread_codex", "model": "gpt-5.4"},
|
|
),
|
|
(
|
|
{"type": "effort_change", "effort": "xhigh"},
|
|
{"threadId": "thread_codex", "effort": "xhigh"},
|
|
),
|
|
(
|
|
{"type": "plan_mode_change", "enabled": True},
|
|
{
|
|
"threadId": "thread_codex",
|
|
"collaborationMode": {
|
|
"mode": "plan",
|
|
"settings": {
|
|
"model": "gpt-5.4",
|
|
"reasoning_effort": None,
|
|
"developer_instructions": None,
|
|
},
|
|
},
|
|
},
|
|
),
|
|
],
|
|
ids=["model_change", "effort_change", "plan_mode_change"],
|
|
)
|
|
async def test_events_codex_native_settings_change_uses_thread_settings_update(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
tmp_path: Path,
|
|
event_payload: dict[str, Any],
|
|
expected_params: dict[str, Any],
|
|
) -> None:
|
|
"""
|
|
Codex-native model / effort updates call ``thread/settings/update``.
|
|
|
|
The web UI persists model and effort through Omnigent's normal session
|
|
PATCH path. The runner must translate the forwarded control event into
|
|
Codex app-server's structured settings RPC, not type into the terminal or
|
|
204 as a no-op. The update is a next-turn setting: it is valid even when
|
|
no active turn id is recorded.
|
|
"""
|
|
from omnigent import codex_native_app_server
|
|
from omnigent.spec.types import ExecutorSpec
|
|
|
|
conv_id = "524fe55f9d5a7f66fec5c5401a930b84"
|
|
monkeypatch.setattr(codex_native_bridge, "_BRIDGE_ROOT", tmp_path / "codex-bridge")
|
|
bridge_dir = codex_native_bridge.bridge_dir_for_bridge_id(conv_id)
|
|
codex_native_bridge.write_bridge_state(
|
|
bridge_dir,
|
|
codex_native_bridge.CodexNativeBridgeState(
|
|
session_id=conv_id,
|
|
socket_path="ws://127.0.0.1:43210",
|
|
thread_id="thread_codex",
|
|
codex_home=str(tmp_path / "codex-home"),
|
|
active_turn_id=None,
|
|
),
|
|
)
|
|
|
|
fake_client = _RecordingCodexAppServerClient(
|
|
transport="ws://127.0.0.1:43210",
|
|
client_name="omnigent-codex-native-runner",
|
|
)
|
|
|
|
def _fake_client_for_transport(
|
|
transport: str,
|
|
*,
|
|
client_name: str = "omnigent",
|
|
) -> _RecordingCodexAppServerClient:
|
|
"""
|
|
Return the fake Codex app-server client for the recorded bridge state.
|
|
|
|
:param transport: App-server transport from bridge state, e.g.
|
|
``"ws://127.0.0.1:43210"``.
|
|
:param client_name: Client name supplied by the runner, e.g.
|
|
``"omnigent-codex-native-runner"``.
|
|
:returns: Fake client that records JSON-RPC calls.
|
|
"""
|
|
assert transport == fake_client.transport
|
|
assert client_name == fake_client.client_name
|
|
return fake_client
|
|
|
|
monkeypatch.setattr(
|
|
codex_native_app_server,
|
|
"client_for_transport",
|
|
_fake_client_for_transport,
|
|
)
|
|
|
|
codex_native_spec = AgentSpec(
|
|
spec_version=1,
|
|
name="t",
|
|
executor=ExecutorSpec(
|
|
type="omnigent",
|
|
config={"harness": "codex-native", "model": "gpt-5.4"},
|
|
),
|
|
)
|
|
|
|
async def _resolver(agent_id: str, session_id: str | None = None) -> AgentSpec:
|
|
"""Return the codex-native spec for any agent_id."""
|
|
del agent_id, session_id
|
|
return codex_native_spec
|
|
|
|
pm = _FakeProcessManager(_ScriptedHarnessClient([]))
|
|
app = create_runner_app(
|
|
process_manager=pm, # type: ignore[arg-type]
|
|
spec_resolver=_resolver,
|
|
server_client=NullServerClient(), # type: ignore[arg-type]
|
|
)
|
|
|
|
async with _runner_client(app) as client:
|
|
create_resp = await client.post(
|
|
"/v1/sessions",
|
|
json={"session_id": conv_id, "agent_id": "880b5afda28ad55ff74cbeb9b5fc67fb"},
|
|
)
|
|
assert create_resp.status_code == 201, create_resp.text
|
|
|
|
resp = await client.post(
|
|
f"/v1/sessions/{conv_id}/events",
|
|
json=event_payload,
|
|
)
|
|
|
|
assert resp.status_code == 204, (
|
|
f"codex-native {event_payload['type']} must return 204; "
|
|
f"got {resp.status_code}: {resp.text}"
|
|
)
|
|
assert fake_client.connected
|
|
assert fake_client.closed
|
|
assert fake_client.requests == [
|
|
("thread/settings/update", expected_params),
|
|
], (
|
|
f"codex-native {event_payload['type']} must call thread/settings/update "
|
|
f"with next-turn settings; got {fake_client.requests!r}."
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_kiro_native_model_options_use_cli_catalog(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
conv_id = "a7e721bf0e124d2fb5bc1bc36772864e"
|
|
expected = [
|
|
{
|
|
"id": "provider-latest",
|
|
"displayName": "Provider Latest",
|
|
"isDefault": True,
|
|
}
|
|
]
|
|
monkeypatch.setattr(kiro_native, "list_kiro_cli_model_options", lambda: expected)
|
|
spec = AgentSpec(
|
|
spec_version=1,
|
|
name="t",
|
|
executor=ExecutorSpec(type="omnigent", config={"harness": "kiro-native"}),
|
|
)
|
|
|
|
async def _resolver(agent_id: str, session_id: str | None = None) -> AgentSpec:
|
|
del agent_id, session_id
|
|
return spec
|
|
|
|
app = create_runner_app(
|
|
process_manager=_FakeProcessManager(_ScriptedHarnessClient([])), # type: ignore[arg-type]
|
|
spec_resolver=_resolver,
|
|
server_client=NullServerClient(), # type: ignore[arg-type]
|
|
)
|
|
async with _runner_client(app) as client:
|
|
create_resp = await client.post(
|
|
"/v1/sessions",
|
|
json={"session_id": conv_id, "agent_id": "ag_1"},
|
|
)
|
|
assert create_resp.status_code == 201, create_resp.text
|
|
response = await client.get(f"/v1/sessions/{conv_id}/kiro-model-options")
|
|
|
|
assert response.status_code == 200
|
|
assert response.json() == {"models": expected}
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_kiro_native_model_options_failure_is_retryable(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""Discovery failures return 503 so the server leaves its cache cold."""
|
|
conv_id = "b29b45fd569245b2bc0dd79694e73886"
|
|
|
|
def _fail_discovery() -> list[dict[str, object]]:
|
|
raise RuntimeError("catalog unavailable")
|
|
|
|
monkeypatch.setattr(kiro_native, "list_kiro_cli_model_options", _fail_discovery)
|
|
spec = AgentSpec(
|
|
spec_version=1,
|
|
name="t",
|
|
executor=ExecutorSpec(type="omnigent", config={"harness": "kiro-native"}),
|
|
)
|
|
|
|
async def _resolver(agent_id: str, session_id: str | None = None) -> AgentSpec:
|
|
del agent_id, session_id
|
|
return spec
|
|
|
|
app = create_runner_app(
|
|
process_manager=_FakeProcessManager(_ScriptedHarnessClient([])), # type: ignore[arg-type]
|
|
spec_resolver=_resolver,
|
|
server_client=NullServerClient(), # type: ignore[arg-type]
|
|
)
|
|
async with _runner_client(app) as client:
|
|
create_resp = await client.post(
|
|
"/v1/sessions",
|
|
json={"session_id": conv_id, "agent_id": "ag_1"},
|
|
)
|
|
assert create_resp.status_code == 201, create_resp.text
|
|
response = await client.get(f"/v1/sessions/{conv_id}/kiro-model-options")
|
|
|
|
assert response.status_code == 503, response.text
|
|
assert response.json()["error"] == "kiro_native_model_options_failed"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_cursor_native_model_options_use_cli_catalog(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
conv_id = "c7e721bf0e124d2fb5bc1bc36772864e"
|
|
expected = [
|
|
{
|
|
"id": "provider-latest",
|
|
"displayName": "Provider Latest",
|
|
"isDefault": True,
|
|
"isCurrent": False,
|
|
}
|
|
]
|
|
monkeypatch.setattr(cursor_native, "list_cursor_cli_model_options", lambda: expected)
|
|
injected: list[tuple[str, str | None]] = []
|
|
|
|
def _inject_model(
|
|
_bridge_dir: Path,
|
|
*,
|
|
model: str,
|
|
expected_display_name: str | None,
|
|
timeout_s: float,
|
|
) -> None:
|
|
del timeout_s
|
|
injected.append((model, expected_display_name))
|
|
|
|
monkeypatch.setattr(cursor_native_bridge, "inject_model_command", _inject_model)
|
|
spec = AgentSpec(
|
|
spec_version=1,
|
|
name="t",
|
|
executor=ExecutorSpec(type="omnigent", config={"harness": "cursor-native"}),
|
|
)
|
|
|
|
async def _resolver(agent_id: str, session_id: str | None = None) -> AgentSpec:
|
|
del agent_id, session_id
|
|
return spec
|
|
|
|
app = create_runner_app(
|
|
process_manager=_FakeProcessManager(_ScriptedHarnessClient([])), # type: ignore[arg-type]
|
|
spec_resolver=_resolver,
|
|
server_client=NullServerClient(), # type: ignore[arg-type]
|
|
)
|
|
async with _runner_client(app) as client:
|
|
create_resp = await client.post(
|
|
"/v1/sessions",
|
|
json={"session_id": conv_id, "agent_id": "ag_1"},
|
|
)
|
|
assert create_resp.status_code == 201, create_resp.text
|
|
response = await client.get(f"/v1/sessions/{conv_id}/cursor-model-options")
|
|
event_response = await client.post(
|
|
f"/v1/sessions/{conv_id}/events",
|
|
json={"type": "model_change", "model": "provider-latest"},
|
|
)
|
|
|
|
assert response.status_code == 200
|
|
assert response.json() == {"models": expected}
|
|
assert event_response.status_code == 204
|
|
assert injected == [("provider-latest", "Provider Latest")]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_cursor_native_model_options_failure_is_retryable(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""Discovery failures return 503 so the server leaves its cache cold."""
|
|
conv_id = "d29b45fd569245b2bc0dd79694e73886"
|
|
|
|
def _fail_discovery() -> list[dict[str, object]]:
|
|
raise RuntimeError("catalog unavailable")
|
|
|
|
monkeypatch.setattr(cursor_native, "list_cursor_cli_model_options", _fail_discovery)
|
|
spec = AgentSpec(
|
|
spec_version=1,
|
|
name="t",
|
|
executor=ExecutorSpec(type="omnigent", config={"harness": "cursor-native"}),
|
|
)
|
|
|
|
async def _resolver(agent_id: str, session_id: str | None = None) -> AgentSpec:
|
|
del agent_id, session_id
|
|
return spec
|
|
|
|
app = create_runner_app(
|
|
process_manager=_FakeProcessManager(_ScriptedHarnessClient([])), # type: ignore[arg-type]
|
|
spec_resolver=_resolver,
|
|
server_client=NullServerClient(), # type: ignore[arg-type]
|
|
)
|
|
async with _runner_client(app) as client:
|
|
create_resp = await client.post(
|
|
"/v1/sessions",
|
|
json={"session_id": conv_id, "agent_id": "ag_1"},
|
|
)
|
|
assert create_resp.status_code == 201, create_resp.text
|
|
response = await client.get(f"/v1/sessions/{conv_id}/cursor-model-options")
|
|
|
|
assert response.status_code == 503, response.text
|
|
assert response.json()["error"] == "cursor_native_model_options_failed"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_opencode_native_model_options_uses_cli_catalog(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
tmp_path: Path,
|
|
) -> None:
|
|
from omnigent import opencode_native_app_server, opencode_native_bridge
|
|
from omnigent.opencode_native_bridge import OpenCodeNativeBridgeState
|
|
from omnigent.spec.types import ExecutorSpec
|
|
|
|
conv_id = "conv_opencode_native_model_options"
|
|
monkeypatch.setattr(opencode_native_bridge, "_BRIDGE_ROOT", tmp_path)
|
|
monkeypatch.setattr(
|
|
opencode_native_bridge,
|
|
"read_bridge_state",
|
|
lambda _dir: OpenCodeNativeBridgeState(
|
|
session_id=conv_id,
|
|
server_base_url="http://127.0.0.1:49231",
|
|
opencode_session_id="ses_1",
|
|
),
|
|
)
|
|
captured_envs: list[Mapping[str, str] | None] = []
|
|
|
|
def _fake_list_options(*, env: Mapping[str, str] | None = None) -> list[dict[str, object]]:
|
|
captured_envs.append(env)
|
|
return [{"id": "opencode-go/glm-5.2", "displayName": "opencode-go/glm-5.2"}]
|
|
|
|
monkeypatch.setattr(
|
|
opencode_native_app_server,
|
|
"list_opencode_cli_model_options",
|
|
_fake_list_options,
|
|
)
|
|
spec = AgentSpec(
|
|
spec_version=1,
|
|
name="t",
|
|
executor=ExecutorSpec(type="omnigent", config={"harness": "opencode-native"}),
|
|
)
|
|
|
|
async def _resolver(agent_id: str, session_id: str | None = None) -> AgentSpec:
|
|
del agent_id, session_id
|
|
return spec
|
|
|
|
app = create_runner_app(
|
|
process_manager=_FakeProcessManager(_ScriptedHarnessClient([])), # type: ignore[arg-type]
|
|
spec_resolver=_resolver,
|
|
server_client=NullServerClient(), # type: ignore[arg-type]
|
|
)
|
|
async with _runner_client(app) as client:
|
|
create_resp = await client.post(
|
|
"/v1/sessions",
|
|
json={"session_id": conv_id, "agent_id": "ag_1"},
|
|
)
|
|
assert create_resp.status_code == 201, create_resp.text
|
|
response = await client.get(f"/v1/sessions/{conv_id}/codex-model-options")
|
|
|
|
assert response.status_code == 200
|
|
assert response.json() == {
|
|
"models": [{"id": "opencode-go/glm-5.2", "displayName": "opencode-go/glm-5.2"}]
|
|
}
|
|
assert len(captured_envs) == 1
|
|
cli_env = captured_envs[0]
|
|
assert cli_env is not None
|
|
bridge_dir = opencode_native_bridge.bridge_dir_for_bridge_id(conv_id)
|
|
assert cli_env["XDG_DATA_HOME"] == str(bridge_dir / "xdg-data")
|
|
assert cli_env["XDG_CONFIG_HOME"] == str(bridge_dir / "xdg-config")
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_codex_native_model_options_returns_503_until_bridge_state_exists(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
tmp_path: Path,
|
|
) -> None:
|
|
"""
|
|
Runner model-options endpoint is retryable before Codex bridge startup.
|
|
|
|
The AP server caches successful runner responses. A codex-native runner
|
|
must therefore not return ``200 {"models": []}`` while the Codex terminal
|
|
is still creating its app-server bridge; that would permanently hide the
|
|
Web UI model picker for the session.
|
|
"""
|
|
from omnigent import codex_native_app_server
|
|
|
|
conv_id = "d2f0a2d856bc03c1674d3d634b4f250c"
|
|
monkeypatch.setattr(codex_native_bridge, "_BRIDGE_ROOT", tmp_path / "codex-bridge")
|
|
|
|
def _client_for_transport(
|
|
transport: str,
|
|
*,
|
|
client_name: str = "omnigent",
|
|
) -> _RecordingCodexAppServerClient:
|
|
"""
|
|
Fail the test if the endpoint reaches Codex without bridge state.
|
|
|
|
:param transport: App-server transport from bridge state, e.g.
|
|
``"ws://127.0.0.1:43210"``.
|
|
:param client_name: Client name supplied by the runner, e.g.
|
|
``"omnigent-codex-native-runner"``.
|
|
:returns: Never returns; raises if called.
|
|
"""
|
|
raise AssertionError(
|
|
f"client_for_transport must not be called before bridge state exists: "
|
|
f"{transport=} {client_name=}"
|
|
)
|
|
|
|
monkeypatch.setattr(
|
|
codex_native_app_server,
|
|
"client_for_transport",
|
|
_client_for_transport,
|
|
)
|
|
|
|
codex_native_spec = AgentSpec(
|
|
spec_version=1,
|
|
name="t",
|
|
executor=ExecutorSpec(type="omnigent", config={"harness": "codex-native"}),
|
|
)
|
|
|
|
async def _resolver(agent_id: str, session_id: str | None = None) -> AgentSpec:
|
|
"""Return the codex-native spec for any agent_id."""
|
|
del agent_id, session_id
|
|
return codex_native_spec
|
|
|
|
app = create_runner_app(
|
|
process_manager=_FakeProcessManager(_ScriptedHarnessClient([])), # type: ignore[arg-type]
|
|
spec_resolver=_resolver,
|
|
server_client=NullServerClient(), # type: ignore[arg-type]
|
|
)
|
|
|
|
async with _runner_client(app) as client:
|
|
create_resp = await client.post(
|
|
"/v1/sessions",
|
|
json={"session_id": conv_id, "agent_id": "880b5afda28ad55ff74cbeb9b5fc67fb"},
|
|
)
|
|
assert create_resp.status_code == 201, create_resp.text
|
|
|
|
resp = await client.get(f"/v1/sessions/{conv_id}/codex-model-options")
|
|
|
|
# A retryable 503 keeps the AP server from caching an empty model list;
|
|
# returning 200 here would recreate the missing-picker regression.
|
|
assert resp.status_code == 503, resp.text
|
|
assert resp.json() == {
|
|
"error": "codex_native_model_options_failed",
|
|
"detail": "Codex-native model options are not ready yet.",
|
|
}
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_codex_native_model_options_query_model_list(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
tmp_path: Path,
|
|
) -> None:
|
|
"""
|
|
Runner model-options endpoint queries Codex ``model/list``.
|
|
|
|
The Web UI must not carry its own Codex model / effort catalog. The
|
|
runner is the process that can reach the session's Codex app-server, so
|
|
this endpoint should ask Codex for models and return those model objects
|
|
unchanged for the AP snapshot.
|
|
"""
|
|
from omnigent import codex_native_app_server
|
|
from omnigent.spec.types import ExecutorSpec
|
|
|
|
conv_id = "68ba0a62ebe928d26adf37c8974ce1eb"
|
|
monkeypatch.setattr(codex_native_bridge, "_BRIDGE_ROOT", tmp_path / "codex-bridge")
|
|
bridge_dir = codex_native_bridge.bridge_dir_for_bridge_id(conv_id)
|
|
codex_native_bridge.write_bridge_state(
|
|
bridge_dir,
|
|
codex_native_bridge.CodexNativeBridgeState(
|
|
session_id=conv_id,
|
|
socket_path="ws://127.0.0.1:43210",
|
|
thread_id="thread_codex",
|
|
codex_home=str(tmp_path / "codex-home"),
|
|
active_turn_id=None,
|
|
),
|
|
)
|
|
|
|
fake_client = _RecordingCodexAppServerClient(
|
|
transport="ws://127.0.0.1:43210",
|
|
client_name="omnigent-codex-native-runner",
|
|
)
|
|
fake_client.model_list_responses = [
|
|
{
|
|
"result": {
|
|
"data": [
|
|
{
|
|
"id": "gpt-5.5",
|
|
"model": "databricks-gpt-5-5",
|
|
"displayName": "GPT-5.5",
|
|
"defaultReasoningEffort": "high",
|
|
"supportedReasoningEfforts": [
|
|
{"reasoningEffort": "low", "description": "Low"},
|
|
{"reasoningEffort": "medium", "description": "Medium"},
|
|
],
|
|
"isDefault": True,
|
|
}
|
|
],
|
|
"nextCursor": "next-page",
|
|
}
|
|
},
|
|
{
|
|
"result": {
|
|
"data": [
|
|
{
|
|
"id": "gpt-5.4-mini",
|
|
"model": "databricks-gpt-5-4-mini",
|
|
"displayName": "GPT-5.4 mini",
|
|
"defaultReasoningEffort": "medium",
|
|
"supportedReasoningEfforts": [
|
|
{"reasoningEffort": "minimal", "description": "Minimal"}
|
|
],
|
|
"isDefault": False,
|
|
}
|
|
],
|
|
"nextCursor": None,
|
|
}
|
|
},
|
|
]
|
|
|
|
def _fake_client_for_transport(
|
|
transport: str,
|
|
*,
|
|
client_name: str = "omnigent",
|
|
) -> _RecordingCodexAppServerClient:
|
|
"""
|
|
Return the fake Codex app-server client for the recorded bridge state.
|
|
|
|
:param transport: App-server transport from bridge state, e.g.
|
|
``"ws://127.0.0.1:43210"``.
|
|
:param client_name: Client name supplied by the runner, e.g.
|
|
``"omnigent-codex-native-runner"``.
|
|
:returns: Fake client scripted with ``model/list`` pages.
|
|
"""
|
|
assert transport == fake_client.transport
|
|
assert client_name == fake_client.client_name
|
|
return fake_client
|
|
|
|
monkeypatch.setattr(
|
|
codex_native_app_server,
|
|
"client_for_transport",
|
|
_fake_client_for_transport,
|
|
)
|
|
|
|
codex_native_spec = AgentSpec(
|
|
spec_version=1,
|
|
name="t",
|
|
executor=ExecutorSpec(type="omnigent", config={"harness": "codex-native"}),
|
|
)
|
|
|
|
async def _resolver(agent_id: str, session_id: str | None = None) -> AgentSpec:
|
|
"""Return the codex-native spec for any agent_id."""
|
|
del agent_id, session_id
|
|
return codex_native_spec
|
|
|
|
app = create_runner_app(
|
|
process_manager=_FakeProcessManager(_ScriptedHarnessClient([])), # type: ignore[arg-type]
|
|
spec_resolver=_resolver,
|
|
server_client=NullServerClient(), # type: ignore[arg-type]
|
|
)
|
|
|
|
async with _runner_client(app) as client:
|
|
create_resp = await client.post(
|
|
"/v1/sessions",
|
|
json={"session_id": conv_id, "agent_id": "880b5afda28ad55ff74cbeb9b5fc67fb"},
|
|
)
|
|
assert create_resp.status_code == 201, create_resp.text
|
|
|
|
resp = await client.get(f"/v1/sessions/{conv_id}/codex-model-options")
|
|
|
|
assert resp.status_code == 200, resp.text
|
|
assert resp.json() == {
|
|
"models": [
|
|
{
|
|
"id": "gpt-5.5",
|
|
"model": "databricks-gpt-5-5",
|
|
"displayName": "GPT-5.5",
|
|
"defaultReasoningEffort": "high",
|
|
"supportedReasoningEfforts": [
|
|
{"reasoningEffort": "low", "description": "Low"},
|
|
{"reasoningEffort": "medium", "description": "Medium"},
|
|
],
|
|
"isDefault": True,
|
|
},
|
|
{
|
|
"id": "gpt-5.4-mini",
|
|
"model": "databricks-gpt-5-4-mini",
|
|
"displayName": "GPT-5.4 mini",
|
|
"defaultReasoningEffort": "medium",
|
|
"supportedReasoningEfforts": [
|
|
{"reasoningEffort": "minimal", "description": "Minimal"}
|
|
],
|
|
"isDefault": False,
|
|
},
|
|
]
|
|
}
|
|
assert fake_client.requests == [
|
|
("model/list", {"includeHidden": False}),
|
|
("model/list", {"includeHidden": False, "cursor": "next-page"}),
|
|
]
|
|
assert fake_client.connected
|
|
assert fake_client.closed
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_claude_native_model_options_use_session_launch_catalog(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""The runner exposes friendly aliases from one cached Claude config."""
|
|
from omnigent.claude_native import ClaudeNativeUcodeConfig
|
|
|
|
conv_id = "6a416804870ed618cc8908f5cebab937"
|
|
claude_spec = AgentSpec(
|
|
spec_version=1,
|
|
name="t",
|
|
executor=ExecutorSpec(type="omnigent", config={"harness": "claude-native"}),
|
|
)
|
|
|
|
async def _resolver(agent_id: str, session_id: str | None = None) -> AgentSpec:
|
|
del agent_id, session_id
|
|
return claude_spec
|
|
|
|
config = ClaudeNativeUcodeConfig(
|
|
env={
|
|
"ANTHROPIC_DEFAULT_OPUS_MODEL": "system.ai.claude-opus-4-10",
|
|
"ANTHROPIC_DEFAULT_HAIKU_MODEL": "system.ai.claude-haiku-4-5",
|
|
},
|
|
api_key_helper="printf token",
|
|
model="system.ai.claude-opus-4-10",
|
|
)
|
|
resolved_specs: list[AgentSpec | None] = []
|
|
|
|
def _resolve(*, spec: AgentSpec | None) -> ClaudeNativeUcodeConfig:
|
|
resolved_specs.append(spec)
|
|
return config
|
|
|
|
monkeypatch.setattr("omnigent.claude_native.resolve_native_claude_config", _resolve)
|
|
|
|
async def _fake_auto_create(
|
|
session_id: str,
|
|
resource_registry: Any,
|
|
publish_event: Any,
|
|
**kwargs: Any,
|
|
) -> SessionResourceView:
|
|
del resource_registry, publish_event
|
|
resolver = kwargs.get("resolve_launch_config")
|
|
recorder = kwargs.get("record_launch_config")
|
|
assert callable(resolver)
|
|
assert callable(recorder)
|
|
recorder(session_id, await resolver())
|
|
return SessionResourceView(
|
|
id="terminal_claude_main",
|
|
type="terminal",
|
|
session_id=session_id,
|
|
name="claude:main",
|
|
metadata={"terminal_name": "claude", "session_key": "main", "running": True},
|
|
)
|
|
|
|
monkeypatch.setattr(
|
|
"omnigent.runner.native.orchestration._auto_create_claude_terminal", _fake_auto_create
|
|
)
|
|
app = create_runner_app(
|
|
process_manager=_FakeProcessManager(_ScriptedHarnessClient([])), # type: ignore[arg-type]
|
|
spec_resolver=_resolver,
|
|
server_client=NullServerClient(), # type: ignore[arg-type]
|
|
)
|
|
|
|
async with _runner_client(app) as client:
|
|
create_resp = await client.post(
|
|
"/v1/sessions",
|
|
json={"session_id": conv_id, "agent_id": "880b5afda28ad55ff74cbeb9b5fc67fb"},
|
|
)
|
|
assert create_resp.status_code == 201, create_resp.text
|
|
first = await client.get(f"/v1/sessions/{conv_id}/claude-model-options")
|
|
second = await client.get(f"/v1/sessions/{conv_id}/claude-model-options")
|
|
|
|
expected = {
|
|
"models": [
|
|
{
|
|
"id": "opus",
|
|
"model": "system.ai.claude-opus-4-10",
|
|
"displayName": "Opus 4.10",
|
|
"isDefault": True,
|
|
},
|
|
{
|
|
"id": "haiku",
|
|
"model": "system.ai.claude-haiku-4-5",
|
|
"displayName": "Haiku 4.5",
|
|
"isDefault": False,
|
|
},
|
|
]
|
|
}
|
|
assert first.status_code == 200
|
|
assert first.json() == expected
|
|
assert second.json() == expected
|
|
# Auto-create and both UI reads shared one launch-time live query.
|
|
assert resolved_specs == [claude_spec]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_claude_native_model_options_config_error_is_not_retryable(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""An authoritative-empty catalog answers 424, not the retryable 503.
|
|
|
|
The AP server treats 503 as a "runner still booting" retry window; a
|
|
configuration failure (workspace exposes no Claude models) can't be
|
|
retried away, so it must use a distinct status.
|
|
"""
|
|
import click
|
|
|
|
from omnigent.claude_native import ClaudeNativeUcodeConfig
|
|
|
|
conv_id = "7b527915981fe729dd9a19a6dfcbca48"
|
|
claude_spec = AgentSpec(
|
|
spec_version=1,
|
|
name="t",
|
|
executor=ExecutorSpec(type="omnigent", config={"harness": "claude-native"}),
|
|
)
|
|
|
|
async def _resolver(agent_id: str, session_id: str | None = None) -> AgentSpec:
|
|
del agent_id, session_id
|
|
return claude_spec
|
|
|
|
def _resolve(*, spec: AgentSpec | None) -> ClaudeNativeUcodeConfig:
|
|
del spec
|
|
raise click.ClickException("Databricks profile 'p' exposes no Claude model services.")
|
|
|
|
monkeypatch.setattr("omnigent.claude_native.resolve_native_claude_config", _resolve)
|
|
|
|
async def _fake_auto_create(
|
|
session_id: str,
|
|
resource_registry: Any,
|
|
publish_event: Any,
|
|
**kwargs: Any,
|
|
) -> SessionResourceView:
|
|
del resource_registry, publish_event, kwargs
|
|
return SessionResourceView(
|
|
id="terminal_claude_main",
|
|
type="terminal",
|
|
session_id=session_id,
|
|
name="claude:main",
|
|
metadata={"terminal_name": "claude", "session_key": "main", "running": True},
|
|
)
|
|
|
|
monkeypatch.setattr(
|
|
"omnigent.runner.native.orchestration._auto_create_claude_terminal", _fake_auto_create
|
|
)
|
|
app = create_runner_app(
|
|
process_manager=_FakeProcessManager(_ScriptedHarnessClient([])), # type: ignore[arg-type]
|
|
spec_resolver=_resolver,
|
|
server_client=NullServerClient(), # type: ignore[arg-type]
|
|
)
|
|
|
|
async with _runner_client(app) as client:
|
|
create_resp = await client.post(
|
|
"/v1/sessions",
|
|
json={"session_id": conv_id, "agent_id": "880b5afda28ad55ff74cbeb9b5fc67fb"},
|
|
)
|
|
assert create_resp.status_code == 201, create_resp.text
|
|
resp = await client.get(f"/v1/sessions/{conv_id}/claude-model-options")
|
|
|
|
assert resp.status_code == 424
|
|
body = resp.json()
|
|
assert body["error"] == "claude_native_model_options_config"
|
|
assert "exposes no Claude model services" in body["detail"]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_events_codex_native_plan_mode_requires_loaded_bridge(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
tmp_path: Path,
|
|
) -> None:
|
|
"""
|
|
Codex-native Plan-mode updates fail when no Codex bridge is loaded.
|
|
|
|
The AP server treats a 2xx runner response as proof that the UI can show
|
|
Plan mode. Returning 204 when no bridge state exists would therefore
|
|
persist a false Plan indicator even though Codex app-server never received
|
|
``thread/settings/update``.
|
|
"""
|
|
conv_id = "290b63ecec11a7ae5b93da19be2d9195"
|
|
monkeypatch.setattr(codex_native_bridge, "_BRIDGE_ROOT", tmp_path / "codex-bridge")
|
|
|
|
codex_native_spec = AgentSpec(
|
|
spec_version=1,
|
|
name="t",
|
|
executor=ExecutorSpec(
|
|
type="omnigent",
|
|
config={"harness": "codex-native", "model": "gpt-5.4"},
|
|
),
|
|
)
|
|
|
|
async def _resolver(agent_id: str, session_id: str | None = None) -> AgentSpec:
|
|
"""
|
|
Return the codex-native spec for any agent id.
|
|
|
|
:param agent_id: Agent identifier, e.g. ``"880b5afda28ad55ff74cbeb9b5fc67fb"``.
|
|
:param session_id: Session identifier, e.g. ``"d1f9214d74c38b9f9a9db17ed8352dc4"``.
|
|
:returns: Codex-native agent spec.
|
|
"""
|
|
del agent_id, session_id
|
|
return codex_native_spec
|
|
|
|
pm = _FakeProcessManager(_ScriptedHarnessClient([]))
|
|
app = create_runner_app(
|
|
process_manager=pm, # type: ignore[arg-type]
|
|
spec_resolver=_resolver,
|
|
server_client=NullServerClient(), # type: ignore[arg-type]
|
|
)
|
|
|
|
async with _runner_client(app) as client:
|
|
create_resp = await client.post(
|
|
"/v1/sessions",
|
|
json={"session_id": conv_id, "agent_id": "880b5afda28ad55ff74cbeb9b5fc67fb"},
|
|
)
|
|
assert create_resp.status_code == 201, create_resp.text
|
|
|
|
resp = await client.post(
|
|
f"/v1/sessions/{conv_id}/events",
|
|
json={"type": "plan_mode_change", "enabled": True},
|
|
)
|
|
|
|
assert resp.status_code == 503, resp.text
|
|
assert "loaded Codex bridge" in resp.text
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_events_interrupt_on_codex_native_uses_turn_interrupt_without_marker(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
tmp_path: Path,
|
|
) -> None:
|
|
"""
|
|
POST ``/events`` interrupt on a codex-native session calls
|
|
Codex app-server ``turn/interrupt``.
|
|
|
|
Codex's TUI interrupt key is only a UI shortcut for the structured
|
|
app-server call. The runner/web path must use the app-server protocol
|
|
directly so Codex validates the active turn id and returns only after the
|
|
abort is accepted. Codex records the interrupt as a turn-status edge, not
|
|
as a message, so the runner still must not synthesize a
|
|
``[System: interrupted]`` bubble.
|
|
|
|
Pins:
|
|
1. ``turn/interrupt`` is sent with the recorded thread/turn ids.
|
|
2. NO ``[System: interrupted]`` marker is persisted to AP.
|
|
3. The session is NOT added to ``_interrupted_sessions``; no marker in
|
|
``_session_histories``.
|
|
"""
|
|
from omnigent import codex_native_app_server
|
|
from omnigent.runner.app import _session_histories_ref
|
|
from omnigent.spec.types import ExecutorSpec
|
|
|
|
conv_id = "83d1472d16e3e635c84ca44f29624fca"
|
|
monkeypatch.setattr(codex_native_bridge, "_BRIDGE_ROOT", tmp_path / "codex-bridge")
|
|
bridge_dir = codex_native_bridge.bridge_dir_for_bridge_id(conv_id)
|
|
codex_native_bridge.write_bridge_state(
|
|
bridge_dir,
|
|
codex_native_bridge.CodexNativeBridgeState(
|
|
session_id=conv_id,
|
|
socket_path="ws://127.0.0.1:43210",
|
|
thread_id="thread_codex",
|
|
codex_home=str(tmp_path / "codex-home"),
|
|
active_turn_id="turn_codex",
|
|
),
|
|
)
|
|
|
|
fake_client = _RecordingCodexAppServerClient(
|
|
transport="ws://127.0.0.1:43210",
|
|
client_name="omnigent-codex-native-runner",
|
|
)
|
|
|
|
def _fake_client_for_transport(
|
|
transport: str,
|
|
*,
|
|
client_name: str = "omnigent",
|
|
) -> _RecordingCodexAppServerClient:
|
|
"""
|
|
Return the fake Codex app-server client for the recorded bridge state.
|
|
|
|
:param transport: App-server transport from bridge state, e.g.
|
|
``"ws://127.0.0.1:43210"``.
|
|
:param client_name: Client name supplied by the runner, e.g.
|
|
``"omnigent-codex-native-runner"``.
|
|
:returns: Fake client that records JSON-RPC calls.
|
|
"""
|
|
assert transport == fake_client.transport
|
|
assert client_name == fake_client.client_name
|
|
return fake_client
|
|
|
|
monkeypatch.setattr(
|
|
codex_native_app_server,
|
|
"client_for_transport",
|
|
_fake_client_for_transport,
|
|
)
|
|
|
|
codex_native_spec = AgentSpec(
|
|
spec_version=1,
|
|
name="t",
|
|
executor=ExecutorSpec(type="omnigent", config={"harness": "codex-native"}),
|
|
)
|
|
|
|
async def _resolver(agent_id: str, session_id: str | None = None) -> AgentSpec:
|
|
"""Return the codex-native spec for any agent_id."""
|
|
del agent_id, session_id
|
|
return codex_native_spec
|
|
|
|
server_client = _EventRecordingServerClient()
|
|
pm = _FakeProcessManager(_ScriptedHarnessClient([]))
|
|
app = create_runner_app(
|
|
process_manager=pm, # type: ignore[arg-type]
|
|
spec_resolver=_resolver,
|
|
server_client=server_client, # type: ignore[arg-type]
|
|
)
|
|
|
|
async with _runner_client(app) as client:
|
|
# Seeds _session_spec_cache so the dispatch detects "codex-native".
|
|
create_resp = await client.post(
|
|
"/v1/sessions",
|
|
json={"session_id": conv_id, "agent_id": "880b5afda28ad55ff74cbeb9b5fc67fb"},
|
|
)
|
|
assert create_resp.status_code == 201, create_resp.text
|
|
|
|
int_resp = await client.post(
|
|
f"/v1/sessions/{conv_id}/events",
|
|
json={"type": "interrupt"},
|
|
)
|
|
|
|
captured_history = list(_session_histories_ref.get(conv_id, []))
|
|
flagged = conv_id in app.state.interrupted_sessions
|
|
|
|
assert int_resp.status_code == 204, (
|
|
f"codex-native interrupt must return 204; got {int_resp.status_code}: {int_resp.text}"
|
|
)
|
|
|
|
# 1) The runner reached Codex app-server's structured interrupt path. If
|
|
# this is empty, the handler regressed to a terminal-only or no-op cancel.
|
|
assert fake_client.connected
|
|
assert fake_client.closed
|
|
assert fake_client.requests == [
|
|
(
|
|
"turn/interrupt",
|
|
{
|
|
"threadId": "thread_codex",
|
|
"turnId": "turn_codex",
|
|
},
|
|
)
|
|
], (
|
|
f"codex-native interrupt must call turn/interrupt with the active "
|
|
f"thread/turn ids; got {fake_client.requests!r}."
|
|
)
|
|
|
|
# 2) NO marker persisted — a synthesized [System: interrupted] would diverge
|
|
# the web UI from Codex's own session (the mismatch this revert removes).
|
|
marker_texts = [
|
|
b.get("text")
|
|
for data in server_client.posted_items
|
|
for b in (data.get("item_data") or {}).get("content", [])
|
|
if isinstance(b, dict)
|
|
]
|
|
assert not any("interrupted" in (t or "").lower() for t in marker_texts), (
|
|
f"codex-native interrupt must NOT persist an interrupted marker; "
|
|
f"posted item texts were {marker_texts!r}."
|
|
)
|
|
|
|
# 3) Not flagged, and nothing leaks into the runner's in-memory history.
|
|
assert not flagged, f"codex-native session {conv_id!r} must not be flagged interrupted."
|
|
assert all(
|
|
not (
|
|
h.get("role") == "user"
|
|
and any("interrupted" in (b.get("text") or "").lower() for b in h.get("content", []))
|
|
)
|
|
for h in captured_history
|
|
), f"no interrupt marker should enter _session_histories; got {captured_history!r}"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_events_stop_session_on_codex_native_uses_turn_interrupt_without_marker(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
tmp_path: Path,
|
|
) -> None:
|
|
"""
|
|
POST ``/events`` ``stop_session`` on codex-native interrupts the active turn.
|
|
|
|
Regression guard for the cancel-floor work: ``stop_session`` only
|
|
special-cased claude-native, so codex-native fell into
|
|
``_cancel_inprocess_turn``, which flags the session interrupted and (on the
|
|
next turn or a live-task race) synthesizes the ``[System: interrupted]``
|
|
marker Codex never emits. codex-native must reach the same app-server
|
|
``turn/interrupt`` path as the interrupt branch.
|
|
|
|
Pins (sister to ``...interrupt_on_codex_native...``):
|
|
1. ``turn/interrupt`` is sent with the recorded thread/turn ids.
|
|
2. NO ``[System: interrupted]`` marker is persisted to AP.
|
|
3. The session is NOT added to ``_interrupted_sessions``; no marker leaks
|
|
into ``_session_histories``.
|
|
"""
|
|
from omnigent import codex_native_app_server
|
|
from omnigent.runner.app import _session_histories_ref
|
|
from omnigent.spec.types import ExecutorSpec
|
|
|
|
conv_id = "fa87fda193a47e99e6a2599e44032807"
|
|
monkeypatch.setattr(codex_native_bridge, "_BRIDGE_ROOT", tmp_path / "codex-bridge")
|
|
bridge_dir = codex_native_bridge.bridge_dir_for_bridge_id(conv_id)
|
|
codex_native_bridge.write_bridge_state(
|
|
bridge_dir,
|
|
codex_native_bridge.CodexNativeBridgeState(
|
|
session_id=conv_id,
|
|
socket_path="ws://127.0.0.1:43211",
|
|
thread_id="thread_codex_stop",
|
|
codex_home=str(tmp_path / "codex-home"),
|
|
active_turn_id="turn_codex_stop",
|
|
),
|
|
)
|
|
|
|
fake_client = _RecordingCodexAppServerClient(
|
|
transport="ws://127.0.0.1:43211",
|
|
client_name="omnigent-codex-native-runner",
|
|
)
|
|
|
|
def _fake_client_for_transport(
|
|
transport: str,
|
|
*,
|
|
client_name: str = "omnigent",
|
|
) -> _RecordingCodexAppServerClient:
|
|
"""
|
|
Return the fake Codex app-server client for the stop-session path.
|
|
|
|
:param transport: App-server transport from bridge state, e.g.
|
|
``"ws://127.0.0.1:43211"``.
|
|
:param client_name: Client name supplied by the runner, e.g.
|
|
``"omnigent-codex-native-runner"``.
|
|
:returns: Fake client that records JSON-RPC calls.
|
|
"""
|
|
assert transport == fake_client.transport
|
|
assert client_name == fake_client.client_name
|
|
return fake_client
|
|
|
|
monkeypatch.setattr(
|
|
codex_native_app_server,
|
|
"client_for_transport",
|
|
_fake_client_for_transport,
|
|
)
|
|
|
|
codex_native_spec = AgentSpec(
|
|
spec_version=1,
|
|
name="t",
|
|
executor=ExecutorSpec(type="omnigent", config={"harness": "codex-native"}),
|
|
)
|
|
|
|
async def _resolver(agent_id: str, session_id: str | None = None) -> AgentSpec:
|
|
"""Return the codex-native spec for any agent_id."""
|
|
del agent_id, session_id
|
|
return codex_native_spec
|
|
|
|
server_client = _EventRecordingServerClient()
|
|
pm = _FakeProcessManager(_ScriptedHarnessClient([]))
|
|
app = create_runner_app(
|
|
process_manager=pm, # type: ignore[arg-type]
|
|
spec_resolver=_resolver,
|
|
server_client=server_client, # type: ignore[arg-type]
|
|
)
|
|
|
|
async with _runner_client(app) as client:
|
|
create_resp = await client.post(
|
|
"/v1/sessions",
|
|
json={"session_id": conv_id, "agent_id": "880b5afda28ad55ff74cbeb9b5fc67fb"},
|
|
)
|
|
assert create_resp.status_code == 201, create_resp.text
|
|
|
|
stop_resp = await client.post(
|
|
f"/v1/sessions/{conv_id}/events",
|
|
json={"type": "stop_session"},
|
|
)
|
|
|
|
captured_history = list(_session_histories_ref.get(conv_id, []))
|
|
flagged = conv_id in app.state.interrupted_sessions
|
|
|
|
assert stop_resp.status_code == 204, (
|
|
f"codex-native stop_session must return 204; got {stop_resp.status_code}: {stop_resp.text}"
|
|
)
|
|
|
|
# 1) The runner reached Codex app-server's structured interrupt path. If
|
|
# this is empty, stop_session regressed to the in-process cancel floor or
|
|
# the old terminal-key path.
|
|
assert fake_client.connected
|
|
assert fake_client.closed
|
|
assert fake_client.requests == [
|
|
(
|
|
"turn/interrupt",
|
|
{
|
|
"threadId": "thread_codex_stop",
|
|
"turnId": "turn_codex_stop",
|
|
},
|
|
)
|
|
], (
|
|
f"codex-native stop_session must call turn/interrupt with the active "
|
|
f"thread/turn ids; got {fake_client.requests!r}."
|
|
)
|
|
|
|
# 2) NO marker persisted — the in-process floor would have synthesized one.
|
|
marker_texts = [
|
|
b.get("text")
|
|
for data in server_client.posted_items
|
|
for b in (data.get("item_data") or {}).get("content", [])
|
|
if isinstance(b, dict)
|
|
]
|
|
assert not any("interrupted" in (t or "").lower() for t in marker_texts), (
|
|
f"codex-native stop_session must NOT persist an interrupted marker; "
|
|
f"posted item texts were {marker_texts!r}."
|
|
)
|
|
|
|
# 3) Not flagged (the in-process floor's _interrupted_sessions.add never ran),
|
|
# and nothing leaks into the runner's in-memory history.
|
|
assert not flagged, (
|
|
f"codex-native session {conv_id!r} must not be flagged interrupted — a "
|
|
f"stale flag would taint the next turn with a bogus marker."
|
|
)
|
|
assert all(
|
|
not (
|
|
h.get("role") == "user"
|
|
and any("interrupted" in (b.get("text") or "").lower() for b in h.get("content", []))
|
|
)
|
|
for h in captured_history
|
|
), f"no interrupt marker should enter _session_histories; got {captured_history!r}"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize("event_type", ["interrupt", "stop_session"])
|
|
async def test_events_stop_on_codex_native_cancels_mcp_startup_without_active_turn(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
tmp_path: Path,
|
|
event_type: str,
|
|
) -> None:
|
|
"""
|
|
Stop/interrupt with no active turn cancels in-flight MCP startup.
|
|
|
|
During codex-native startup no turn id is recorded yet, so Stop used to
|
|
204 no-op while Codex sat wedged on a slow or failing MCP server
|
|
(issue #2058). The handler must flip the bridge's pending servers to
|
|
``cancelled`` (unblocking the executor's first-turn gate) and send the
|
|
Codex TUI's startup interrupt — ``turn/interrupt`` with an empty turn
|
|
id — instead of doing nothing.
|
|
"""
|
|
from omnigent import codex_native_app_server
|
|
from omnigent.spec.types import ExecutorSpec
|
|
|
|
conv_id = f"36ea25fd09df4a2d85136100fbecd3e9{event_type}"
|
|
monkeypatch.setattr(codex_native_bridge, "_BRIDGE_ROOT", tmp_path / "codex-bridge")
|
|
# Abort the session-create auto-terminal path before it reaches
|
|
# ``clear_bridge_state`` — otherwise the seeded bridge state below is
|
|
# wiped on hosts where the codex CLI/provider config exist (in CI the
|
|
# auto-create aborts on its own before the clear).
|
|
from omnigent.runner import app as runner_app_module
|
|
|
|
async def _fail_launch_config(**kwargs: Any) -> None:
|
|
"""Abort codex auto-create before it clears bridge state."""
|
|
del kwargs
|
|
raise RuntimeError("launch config disabled in test")
|
|
|
|
monkeypatch.setattr(runner_app_module, "_codex_native_launch_config", _fail_launch_config)
|
|
bridge_dir = codex_native_bridge.bridge_dir_for_bridge_id(conv_id)
|
|
codex_native_bridge.write_bridge_state(
|
|
bridge_dir,
|
|
codex_native_bridge.CodexNativeBridgeState(
|
|
session_id=conv_id,
|
|
socket_path="ws://127.0.0.1:43212",
|
|
thread_id="thread_codex_mcp",
|
|
codex_home=str(tmp_path / "codex-home"),
|
|
active_turn_id=None,
|
|
),
|
|
)
|
|
codex_native_bridge.update_mcp_server_startup(bridge_dir, "storage-console", "starting")
|
|
|
|
fake_client = _RecordingCodexAppServerClient(
|
|
transport="ws://127.0.0.1:43212",
|
|
client_name="omnigent-codex-native-runner",
|
|
)
|
|
|
|
def _fake_client_for_transport(
|
|
transport: str,
|
|
*,
|
|
client_name: str = "omnigent",
|
|
) -> _RecordingCodexAppServerClient:
|
|
"""
|
|
Return the fake Codex app-server client for the startup-cancel path.
|
|
|
|
:param transport: App-server transport from bridge state, e.g.
|
|
``"ws://127.0.0.1:43212"``.
|
|
:param client_name: Client name supplied by the runner, e.g.
|
|
``"omnigent-codex-native-runner"``.
|
|
:returns: Fake client that records JSON-RPC calls.
|
|
"""
|
|
assert transport == fake_client.transport
|
|
assert client_name == fake_client.client_name
|
|
return fake_client
|
|
|
|
monkeypatch.setattr(
|
|
codex_native_app_server,
|
|
"client_for_transport",
|
|
_fake_client_for_transport,
|
|
)
|
|
|
|
codex_native_spec = AgentSpec(
|
|
spec_version=1,
|
|
name="t",
|
|
executor=ExecutorSpec(type="omnigent", config={"harness": "codex-native"}),
|
|
)
|
|
|
|
async def _resolver(agent_id: str, session_id: str | None = None) -> AgentSpec:
|
|
"""Return the codex-native spec for any agent_id."""
|
|
del agent_id, session_id
|
|
return codex_native_spec
|
|
|
|
server_client = _EventRecordingServerClient()
|
|
pm = _FakeProcessManager(_ScriptedHarnessClient([]))
|
|
app = create_runner_app(
|
|
process_manager=pm, # type: ignore[arg-type]
|
|
spec_resolver=_resolver,
|
|
server_client=server_client, # type: ignore[arg-type]
|
|
)
|
|
|
|
async with _runner_client(app) as client:
|
|
create_resp = await client.post(
|
|
"/v1/sessions",
|
|
json={"session_id": conv_id, "agent_id": "880b5afda28ad55ff74cbeb9b5fc67fb"},
|
|
)
|
|
assert create_resp.status_code == 201, create_resp.text
|
|
|
|
stop_resp = await client.post(
|
|
f"/v1/sessions/{conv_id}/events",
|
|
json={"type": event_type},
|
|
)
|
|
|
|
assert stop_resp.status_code == 204, (
|
|
f"codex-native {event_type} must return 204; got {stop_resp.status_code}: {stop_resp.text}"
|
|
)
|
|
# The Codex TUI's startup interrupt shape: turn/interrupt with an
|
|
# EMPTY turn id (its ``startup_interrupt``); a recorded-turn shape
|
|
# here would be rejected by the app-server mid-startup.
|
|
assert fake_client.requests == [
|
|
(
|
|
"turn/interrupt",
|
|
{"threadId": "thread_codex_mcp", "turnId": ""},
|
|
)
|
|
], (
|
|
f"codex-native {event_type} during MCP startup must send the startup "
|
|
f"interrupt (empty turnId); got {fake_client.requests!r}."
|
|
)
|
|
# The local flip is authoritative even if Codex never acknowledges
|
|
# the interrupt.
|
|
assert codex_native_bridge.read_mcp_startup(bridge_dir) == {
|
|
"storage-console": {"status": "cancelled", "error": None}
|
|
}
|
|
# And the flipped map is PUBLISHED: the forwarder only reposts when it
|
|
# changes the map itself and codex's cancelled edges are owner-only,
|
|
# so without this post the web band would stay stuck on "starting".
|
|
assert server_client.posted_mcp_startup == [
|
|
{"servers": {"storage-console": {"status": "cancelled", "error": None}}}
|
|
], (
|
|
f"codex-native {event_type} must publish the cancelled MCP map; "
|
|
f"got {server_client.posted_mcp_startup!r}."
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_events_interrupt_on_codex_native_with_turn_and_mcp_stops_both(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
tmp_path: Path,
|
|
) -> None:
|
|
"""
|
|
Stop during a startup-deferred turn interrupts the turn AND the startup.
|
|
|
|
Codex accepts ``turn/start`` mid-MCP-startup and defers its execution
|
|
until the round settles, so a Stop pressed in that window finds an
|
|
active turn id recorded. Interrupting only the turn would leave the
|
|
user watching a startup they asked to stop — the handler must also
|
|
send the startup interrupt (empty turn id, best-effort, first) and
|
|
flip the bridge's pending servers to ``cancelled``.
|
|
"""
|
|
from omnigent import codex_native_app_server
|
|
from omnigent.spec.types import ExecutorSpec
|
|
|
|
conv_id = "14fb6a0dde97fc0f7a58a84e1be2c538"
|
|
monkeypatch.setattr(codex_native_bridge, "_BRIDGE_ROOT", tmp_path / "codex-bridge")
|
|
# Keep the seeded bridge state alive through session create (see the
|
|
# sister startup-cancel test for why auto-create must abort early).
|
|
from omnigent.runner import app as runner_app_module
|
|
|
|
async def _fail_launch_config(**kwargs: Any) -> None:
|
|
"""Abort codex auto-create before it clears bridge state."""
|
|
del kwargs
|
|
raise RuntimeError("launch config disabled in test")
|
|
|
|
monkeypatch.setattr(runner_app_module, "_codex_native_launch_config", _fail_launch_config)
|
|
bridge_dir = codex_native_bridge.bridge_dir_for_bridge_id(conv_id)
|
|
codex_native_bridge.write_bridge_state(
|
|
bridge_dir,
|
|
codex_native_bridge.CodexNativeBridgeState(
|
|
session_id=conv_id,
|
|
socket_path="ws://127.0.0.1:43214",
|
|
thread_id="thread_codex_dual",
|
|
codex_home=str(tmp_path / "codex-home"),
|
|
active_turn_id="turn_deferred",
|
|
),
|
|
)
|
|
codex_native_bridge.update_mcp_server_startup(bridge_dir, "storage-console", "starting")
|
|
|
|
fake_client = _RecordingCodexAppServerClient(
|
|
transport="ws://127.0.0.1:43214",
|
|
client_name="omnigent-codex-native-runner",
|
|
)
|
|
|
|
def _fake_client_for_transport(
|
|
transport: str,
|
|
*,
|
|
client_name: str = "omnigent",
|
|
) -> _RecordingCodexAppServerClient:
|
|
"""
|
|
Return the fake Codex app-server client for the dual-stop path.
|
|
|
|
:param transport: App-server transport from bridge state, e.g.
|
|
``"ws://127.0.0.1:43214"``.
|
|
:param client_name: Client name supplied by the runner, e.g.
|
|
``"omnigent-codex-native-runner"``.
|
|
:returns: Fake client that records JSON-RPC calls.
|
|
"""
|
|
assert transport == fake_client.transport
|
|
assert client_name == fake_client.client_name
|
|
return fake_client
|
|
|
|
monkeypatch.setattr(
|
|
codex_native_app_server,
|
|
"client_for_transport",
|
|
_fake_client_for_transport,
|
|
)
|
|
|
|
codex_native_spec = AgentSpec(
|
|
spec_version=1,
|
|
name="t",
|
|
executor=ExecutorSpec(type="omnigent", config={"harness": "codex-native"}),
|
|
)
|
|
|
|
async def _resolver(agent_id: str, session_id: str | None = None) -> AgentSpec:
|
|
"""Return the codex-native spec for any agent_id."""
|
|
del agent_id, session_id
|
|
return codex_native_spec
|
|
|
|
server_client = _EventRecordingServerClient()
|
|
pm = _FakeProcessManager(_ScriptedHarnessClient([]))
|
|
app = create_runner_app(
|
|
process_manager=pm, # type: ignore[arg-type]
|
|
spec_resolver=_resolver,
|
|
server_client=server_client, # type: ignore[arg-type]
|
|
)
|
|
|
|
async with _runner_client(app) as client:
|
|
create_resp = await client.post(
|
|
"/v1/sessions",
|
|
json={"session_id": conv_id, "agent_id": "880b5afda28ad55ff74cbeb9b5fc67fb"},
|
|
)
|
|
assert create_resp.status_code == 201, create_resp.text
|
|
|
|
int_resp = await client.post(
|
|
f"/v1/sessions/{conv_id}/events",
|
|
json={"type": "interrupt"},
|
|
)
|
|
|
|
assert int_resp.status_code == 204, int_resp.text
|
|
# Startup interrupt (empty turnId) first — best-effort — then the
|
|
# recorded turn's interrupt.
|
|
assert fake_client.requests == [
|
|
("turn/interrupt", {"threadId": "thread_codex_dual", "turnId": ""}),
|
|
("turn/interrupt", {"threadId": "thread_codex_dual", "turnId": "turn_deferred"}),
|
|
], f"dual stop must send startup interrupt then turn interrupt; got {fake_client.requests!r}."
|
|
assert codex_native_bridge.read_mcp_startup(bridge_dir) == {
|
|
"storage-console": {"status": "cancelled", "error": None}
|
|
}
|
|
# The cancelled map is published to the session (band + snapshot update).
|
|
assert server_client.posted_mcp_startup == [
|
|
{"servers": {"storage-console": {"status": "cancelled", "error": None}}}
|
|
]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_events_interrupt_on_codex_native_without_turn_or_mcp_is_noop(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
tmp_path: Path,
|
|
) -> None:
|
|
"""
|
|
Stop with no active turn and no pending MCP startup stays a 204 no-op.
|
|
|
|
An idle codex-native session must not send spurious ``turn/interrupt``
|
|
requests to the app-server on every Stop press.
|
|
"""
|
|
from omnigent import codex_native_app_server
|
|
from omnigent.spec.types import ExecutorSpec
|
|
|
|
conv_id = "5cb0fd92163581dee07e5462a93d5021"
|
|
monkeypatch.setattr(codex_native_bridge, "_BRIDGE_ROOT", tmp_path / "codex-bridge")
|
|
# Keep the seeded bridge state alive through session create (see the
|
|
# sister startup-cancel test for why auto-create must abort early).
|
|
from omnigent.runner import app as runner_app_module
|
|
|
|
async def _fail_launch_config(**kwargs: Any) -> None:
|
|
"""Abort codex auto-create before it clears bridge state."""
|
|
del kwargs
|
|
raise RuntimeError("launch config disabled in test")
|
|
|
|
monkeypatch.setattr(runner_app_module, "_codex_native_launch_config", _fail_launch_config)
|
|
bridge_dir = codex_native_bridge.bridge_dir_for_bridge_id(conv_id)
|
|
codex_native_bridge.write_bridge_state(
|
|
bridge_dir,
|
|
codex_native_bridge.CodexNativeBridgeState(
|
|
session_id=conv_id,
|
|
socket_path="ws://127.0.0.1:43213",
|
|
thread_id="thread_codex_idle",
|
|
codex_home=str(tmp_path / "codex-home"),
|
|
active_turn_id=None,
|
|
),
|
|
)
|
|
|
|
def _fail_client_for_transport(
|
|
transport: str,
|
|
*,
|
|
client_name: str = "omnigent",
|
|
) -> _RecordingCodexAppServerClient:
|
|
"""Fail the test if the runner opens an app-server connection."""
|
|
raise AssertionError(
|
|
f"idle codex-native interrupt must not reach the app-server; "
|
|
f"attempted connect to {transport!r} as {client_name!r}"
|
|
)
|
|
|
|
monkeypatch.setattr(
|
|
codex_native_app_server,
|
|
"client_for_transport",
|
|
_fail_client_for_transport,
|
|
)
|
|
|
|
codex_native_spec = AgentSpec(
|
|
spec_version=1,
|
|
name="t",
|
|
executor=ExecutorSpec(type="omnigent", config={"harness": "codex-native"}),
|
|
)
|
|
|
|
async def _resolver(agent_id: str, session_id: str | None = None) -> AgentSpec:
|
|
"""Return the codex-native spec for any agent_id."""
|
|
del agent_id, session_id
|
|
return codex_native_spec
|
|
|
|
server_client = _EventRecordingServerClient()
|
|
pm = _FakeProcessManager(_ScriptedHarnessClient([]))
|
|
app = create_runner_app(
|
|
process_manager=pm, # type: ignore[arg-type]
|
|
spec_resolver=_resolver,
|
|
server_client=server_client, # type: ignore[arg-type]
|
|
)
|
|
|
|
async with _runner_client(app) as client:
|
|
create_resp = await client.post(
|
|
"/v1/sessions",
|
|
json={"session_id": conv_id, "agent_id": "880b5afda28ad55ff74cbeb9b5fc67fb"},
|
|
)
|
|
assert create_resp.status_code == 201, create_resp.text
|
|
|
|
int_resp = await client.post(
|
|
f"/v1/sessions/{conv_id}/events",
|
|
json={"type": "interrupt"},
|
|
)
|
|
|
|
assert int_resp.status_code == 204, int_resp.text
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize("event_type", ["interrupt", "stop_session"])
|
|
async def test_events_interrupt_and_stop_on_pi_native_enqueue_bridge_interrupt(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
tmp_path: Path,
|
|
event_type: str,
|
|
) -> None:
|
|
"""
|
|
POST ``/events`` interrupt / stop_session on a pi-native session queues an
|
|
interrupt payload to the Pi extension inbox.
|
|
|
|
A pi-native turn runs inside the resident Pi TUI process; the runner's
|
|
harness task only enqueues the user message and returns, so the in-process
|
|
cancel floor has nothing to cancel. Both the ``interrupt`` and
|
|
``stop_session`` dispatch must route to ``_handle_pi_native_interrupt``,
|
|
which drops an ``interrupt`` payload into the bridge inbox for the extension
|
|
to consume via ``ExtensionContext.abort()``.
|
|
|
|
Regression guard: both branches originally enumerated only claude-native
|
|
and codex-native, so pi-native silently fell through to the no-op
|
|
``_cancel_inprocess_turn`` floor — clicking Stop on a Pi turn did nothing.
|
|
|
|
Pins:
|
|
1. 204 returned.
|
|
2. An ``interrupt_*`` payload is written to the session's bridge inbox.
|
|
3. NO ``[System: interrupted]`` marker is persisted (the floor never ran).
|
|
"""
|
|
import omnigent.pi_native_bridge as pi_native_bridge
|
|
from omnigent.runner.app import _session_histories_ref
|
|
from omnigent.spec.types import ExecutorSpec
|
|
|
|
conv_id = uuid.uuid4().hex
|
|
monkeypatch.setattr(pi_native_bridge, "_BRIDGE_ROOT", tmp_path / "pi-bridge")
|
|
|
|
pi_native_spec = AgentSpec(
|
|
spec_version=1,
|
|
name="t",
|
|
executor=ExecutorSpec(type="omnigent", config={"harness": "pi-native"}),
|
|
)
|
|
|
|
async def _resolver(agent_id: str, session_id: str | None = None) -> AgentSpec:
|
|
"""Return the pi-native spec for any agent_id."""
|
|
del agent_id, session_id
|
|
return pi_native_spec
|
|
|
|
server_client = _EventRecordingServerClient()
|
|
pm = _FakeProcessManager(_ScriptedHarnessClient([]))
|
|
app = create_runner_app(
|
|
process_manager=pm, # type: ignore[arg-type]
|
|
spec_resolver=_resolver,
|
|
server_client=server_client, # type: ignore[arg-type]
|
|
)
|
|
|
|
async with _runner_client(app) as client:
|
|
# Seeds _session_spec_cache so the dispatch detects "pi-native".
|
|
create_resp = await client.post(
|
|
"/v1/sessions",
|
|
json={"session_id": conv_id, "agent_id": "880b5afda28ad55ff74cbeb9b5fc67fb"},
|
|
)
|
|
assert create_resp.status_code == 201, create_resp.text
|
|
|
|
resp = await client.post(
|
|
f"/v1/sessions/{conv_id}/events",
|
|
json={"type": event_type},
|
|
)
|
|
|
|
captured_history = list(_session_histories_ref.get(conv_id, []))
|
|
flagged = conv_id in app.state.interrupted_sessions
|
|
|
|
assert resp.status_code == 204, (
|
|
f"pi-native {event_type} must return 204; got {resp.status_code}: {resp.text}"
|
|
)
|
|
|
|
# 1) The request reached the bridge inbox (the extension's abort channel). If
|
|
# empty, the dispatch fell through to the no-op in-process cancel floor
|
|
# instead of _handle_pi_native_interrupt.
|
|
inbox = pi_native_bridge.bridge_dir_for_session_id(conv_id) / "inbox"
|
|
queued = sorted(p.name for p in inbox.glob("*.json")) if inbox.exists() else []
|
|
assert any("interrupt_" in name for name in queued), (
|
|
f"pi-native {event_type} must enqueue an interrupt payload to the bridge "
|
|
f"inbox; inbox contained {queued!r}."
|
|
)
|
|
|
|
# 2) No synthesized marker — pi-native never goes through the in-process floor.
|
|
marker_texts = [
|
|
b.get("text")
|
|
for data in server_client.posted_items
|
|
for b in (data.get("item_data") or {}).get("content", [])
|
|
if isinstance(b, dict)
|
|
]
|
|
assert not any("interrupted" in (t or "").lower() for t in marker_texts), (
|
|
f"pi-native {event_type} must NOT persist an interrupted marker; got {marker_texts!r}."
|
|
)
|
|
|
|
# 3) Not flagged, and nothing leaks into the runner's in-memory history.
|
|
assert not flagged, f"pi-native session {conv_id!r} must not be flagged interrupted."
|
|
assert all(
|
|
not (
|
|
h.get("role") == "user"
|
|
and any("interrupted" in (b.get("text") or "").lower() for b in h.get("content", []))
|
|
)
|
|
for h in captured_history
|
|
), f"no interrupt marker should enter _session_histories; got {captured_history!r}"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_events_model_change_on_pi_native_enqueues_bridge_model_change(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
tmp_path: Path,
|
|
) -> None:
|
|
"""
|
|
POST ``/events`` ``model_change`` on a pi-native session queues a
|
|
``model_change`` payload to the Pi extension inbox.
|
|
|
|
A pi-native turn runs inside the resident Pi TUI process, and the
|
|
``--model`` launch flag is baked in at spawn. The dispatch must route to
|
|
``_handle_pi_native_model_change``, which drops a ``model_change`` payload
|
|
the extension applies live via Pi's ``setModel``.
|
|
|
|
Regression guard: the ``model_change`` branch originally enumerated only
|
|
claude/codex/cursor/opencode/kiro, so pi-native fell through to the no-op
|
|
and a web-picked model never reached the running Pi process.
|
|
|
|
Pins:
|
|
1. 204 returned.
|
|
2. A ``model_change_*`` payload carrying the model id is written to the
|
|
session's bridge inbox.
|
|
"""
|
|
import json as _json
|
|
|
|
import omnigent.pi_native_bridge as pi_native_bridge
|
|
from omnigent.spec.types import ExecutorSpec
|
|
|
|
conv_id = "conv_pi_native_model_change"
|
|
monkeypatch.setattr(pi_native_bridge, "_BRIDGE_ROOT", tmp_path / "pi-bridge")
|
|
|
|
pi_native_spec = AgentSpec(
|
|
spec_version=1,
|
|
name="t",
|
|
executor=ExecutorSpec(type="omnigent", config={"harness": "pi-native"}),
|
|
)
|
|
|
|
async def _resolver(agent_id: str, session_id: str | None = None) -> AgentSpec:
|
|
del agent_id, session_id
|
|
return pi_native_spec
|
|
|
|
server_client = _EventRecordingServerClient()
|
|
pm = _FakeProcessManager(_ScriptedHarnessClient([]))
|
|
app = create_runner_app(
|
|
process_manager=pm, # type: ignore[arg-type]
|
|
spec_resolver=_resolver,
|
|
server_client=server_client, # type: ignore[arg-type]
|
|
)
|
|
|
|
async with _runner_client(app) as client:
|
|
create_resp = await client.post(
|
|
"/v1/sessions",
|
|
json={"session_id": conv_id, "agent_id": "ag_1"},
|
|
)
|
|
assert create_resp.status_code == 201, create_resp.text
|
|
|
|
resp = await client.post(
|
|
f"/v1/sessions/{conv_id}/events",
|
|
json={"type": "model_change", "model": "databricks-claude-opus-4-1"},
|
|
)
|
|
|
|
assert resp.status_code == 204, (
|
|
f"pi-native model_change must return 204; got {resp.status_code}: {resp.text}"
|
|
)
|
|
|
|
inbox = pi_native_bridge.bridge_dir_for_session_id(conv_id) / "inbox"
|
|
payloads = [
|
|
_json.loads(p.read_text(encoding="utf-8"))
|
|
for p in (inbox.glob("*.json") if inbox.exists() else [])
|
|
]
|
|
model_changes = [p for p in payloads if p.get("type") == "model_change"]
|
|
assert len(model_changes) == 1, (
|
|
f"pi-native model_change must enqueue exactly one payload; got {payloads!r}."
|
|
)
|
|
assert model_changes[0]["model"] == "databricks-claude-opus-4-1"
|
|
assert model_changes[0]["id"].startswith("model_change_")
|
|
|
|
|
|
def test_interrupted_sessions_isolated_per_app_instance() -> None:
|
|
"""
|
|
Each ``create_runner_app()`` gets its own ``_interrupted_sessions`` set.
|
|
|
|
Regression guard: when ``_interrupted_sessions`` was a module-global,
|
|
interrupt flags leaked between distinct app instances in the same
|
|
process — app1 flagging a conv made app2 append a bogus
|
|
``[System: interrupted]`` marker on a normal turn for the same conv id.
|
|
Keeping the set closure-local (exposed on ``app.state`` only for test
|
|
inspection) prevents that.
|
|
"""
|
|
app1 = create_runner_app(server_client=NullServerClient()) # type: ignore[arg-type]
|
|
app2 = create_runner_app(server_client=NullServerClient()) # type: ignore[arg-type]
|
|
|
|
# Distinct objects: a shared module-global would make these identical, so
|
|
# a flag added to one app would be visible from the other.
|
|
assert app1.state.interrupted_sessions is not app2.state.interrupted_sessions, (
|
|
"Each app instance must own its _interrupted_sessions set; if they are "
|
|
"the same object, the set is module-global again and flags leak across apps."
|
|
)
|
|
|
|
app1.state.interrupted_sessions.add("8af356d908005a65f872c246158c6293")
|
|
assert "8af356d908005a65f872c246158c6293" not in app2.state.interrupted_sessions, (
|
|
"app2 must not observe app1's interrupt flag. If it does, "
|
|
"_interrupted_sessions is shared process-global state and a stale flag "
|
|
"would fire a bogus [System: interrupted] marker on app2's next turn."
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_events_stop_session_on_native_kills_tmux_and_publishes_idle(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""
|
|
POST ``/events`` ``{"type": "stop_session"}`` on a claude-native
|
|
session kills the tmux session and clears the spinner.
|
|
|
|
"Stop session" is the web UI affordance for terminating a
|
|
claude-native session without re-attaching to tmux. Unlike
|
|
``interrupt`` (a single Escape that cancels the current response
|
|
but leaves the session alive), it must:
|
|
|
|
1. Call ``kill_session`` with the bridge dir derived from the
|
|
conversation id and the snappy 1.0s timeout — this is what
|
|
actually ends the ``claude`` process.
|
|
2. Enqueue exactly one ``session.status: idle`` event so the web
|
|
UI's "Working…" spinner clears immediately (Claude's ``Stop``
|
|
hook never fires on a hard kill).
|
|
3. NOT append a ``[System: interrupted]`` marker — the session is
|
|
being torn down, not interrupted mid-turn. A stray marker would
|
|
be the interrupt handler leaking into the stop path.
|
|
"""
|
|
from omnigent.runner.app import _session_event_queues_ref, _session_histories_ref
|
|
from omnigent.spec.types import ExecutorSpec
|
|
|
|
captured_kill: list[Any] = []
|
|
|
|
def _fake_kill(bridge_dir: Any, *, timeout_s: float) -> None:
|
|
"""Record the call and return without touching tmux."""
|
|
captured_kill.append((bridge_dir, timeout_s))
|
|
|
|
monkeypatch.setattr(claude_native_bridge, "kill_session", _fake_kill)
|
|
|
|
native_spec = AgentSpec(
|
|
spec_version=1,
|
|
name="t",
|
|
executor=ExecutorSpec(type="omnigent", config={"harness": "claude-native"}),
|
|
)
|
|
|
|
async def _resolver(agent_id: str, session_id: str | None = None) -> AgentSpec:
|
|
"""Return the native spec for any agent_id."""
|
|
del agent_id
|
|
return native_spec
|
|
|
|
pm = _FakeProcessManager(_ScriptedHarnessClient([]))
|
|
app = create_runner_app(
|
|
process_manager=pm, # type: ignore[arg-type]
|
|
spec_resolver=_resolver,
|
|
server_client=NullServerClient(), # type: ignore[arg-type]
|
|
)
|
|
|
|
async with _runner_client(app) as client:
|
|
create_resp = await client.post(
|
|
"/v1/sessions",
|
|
json={
|
|
"session_id": "1fb90dd3b9d3f24e2356ace505314db1",
|
|
"agent_id": "880b5afda28ad55ff74cbeb9b5fc67fb",
|
|
},
|
|
)
|
|
assert create_resp.status_code == 201, create_resp.text
|
|
|
|
stop_resp = await client.post(
|
|
"/v1/sessions/1fb90dd3b9d3f24e2356ace505314db1/events",
|
|
json={"type": "stop_session"},
|
|
)
|
|
|
|
captured_history = list(_session_histories_ref.get("1fb90dd3b9d3f24e2356ace505314db1", []))
|
|
queue = _session_event_queues_ref.get("1fb90dd3b9d3f24e2356ace505314db1")
|
|
assert queue is not None, (
|
|
"Session creation should have initialized the event queue "
|
|
"for ``1fb90dd3b9d3f24e2356ace505314db1``; without it ``_publish_event`` had "
|
|
"nowhere to land its idle event."
|
|
)
|
|
queued_events: list[dict[str, Any]] = []
|
|
while not queue.empty():
|
|
item = queue.get_nowait()
|
|
if isinstance(item, dict):
|
|
queued_events.append(item)
|
|
|
|
# 1) 204 + exactly one kill_session call on the conversation's
|
|
# bridge dir. 0 = the dispatch fell through to the generic
|
|
# forward-to-harness path (which 404s for native — silent
|
|
# regression); 2+ = the handler ran twice.
|
|
assert stop_resp.status_code == 204, (
|
|
f"Native stop_session must return 204 from /events; "
|
|
f"got {stop_resp.status_code}: {stop_resp.text}"
|
|
)
|
|
assert len(captured_kill) == 1, (
|
|
f"Expected one kill_session call, got {len(captured_kill)}. "
|
|
f"If 0, the dispatch in /events did not route to the native "
|
|
f"stop handler — possibly _session_harness_name returned the "
|
|
f"wrong canonical name."
|
|
)
|
|
bridge_dir, timeout_s = captured_kill[0]
|
|
assert bridge_dir == bridge_dir_for_conversation_id("1fb90dd3b9d3f24e2356ace505314db1")
|
|
# 1.0s short timeout: the UI stop must feel snappy. The helper's
|
|
# 30s default would hang the user's click on a missing tmux.json.
|
|
assert timeout_s == 1.0
|
|
|
|
# 2) session.status: idle enqueued exactly once so the spinner
|
|
# clears. 0 = _publish_event was skipped; 2+ = double-publish.
|
|
status_idle = [
|
|
e for e in queued_events if e.get("type") == "session.status" and e.get("status") == "idle"
|
|
]
|
|
assert len(status_idle) == 1, (
|
|
f"Expected exactly one session.status: idle event after a "
|
|
f"native stop, got {len(status_idle)}. Full queue: {queued_events!r}."
|
|
)
|
|
|
|
# 3) No [System: interrupted] marker — stop is a teardown, not a
|
|
# mid-turn interrupt. A marker here means the interrupt handler's
|
|
# _append_cancellation_items leaked into the stop path.
|
|
markers = [
|
|
h
|
|
for h in captured_history
|
|
if h.get("type") == "message"
|
|
and h.get("role") == "user"
|
|
and any("interrupted" in (b.get("text") or "").lower() for b in h.get("content", []))
|
|
]
|
|
assert markers == [], (
|
|
f"stop_session must not append a [System: interrupted] marker; "
|
|
f"got {markers!r}. If non-empty, the stop handler is reusing the "
|
|
f"interrupt cleanup path."
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_stop_session_on_native_subagent_reclaims_work_entry(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""
|
|
Hard-stopping a claude-native SUB-AGENT worker reclaims its work entry.
|
|
|
|
When the stopped session is a tracked sub-agent, ``_handle_claude_native_stop``
|
|
must mark the work entry ``cancelled`` and deliver a terminal payload to the
|
|
parent's inbox — so the orchestrator (via ``sys_cancel_task`` → ``stop_session``)
|
|
learns the worker is gone instead of waiting on the wrapper's reconnect loop.
|
|
Pre-fix the kill happened but the entry was never reclaimed (the parent could
|
|
hang thinking the worker was still running).
|
|
"""
|
|
from omnigent.runner import app as runner_app
|
|
from omnigent.spec.types import ExecutorSpec
|
|
|
|
parent_id = "c4315225d4a12d320df065ed1ac8baad"
|
|
worker_id = "8dcfd4c64c7a29cddaefa4af686da1da"
|
|
session_inbox: asyncio.Queue[dict[str, Any]] = asyncio.Queue()
|
|
|
|
monkeypatch.setattr(claude_native_bridge, "kill_session", lambda *a, **k: None)
|
|
|
|
native_spec = AgentSpec(
|
|
spec_version=1,
|
|
name="t",
|
|
executor=ExecutorSpec(type="omnigent", config={"harness": "claude-native"}),
|
|
)
|
|
|
|
async def _resolver(agent_id: str, session_id: str | None = None) -> AgentSpec:
|
|
del agent_id, session_id
|
|
return native_spec
|
|
|
|
pm = _FakeProcessManager(_ScriptedHarnessClient([]))
|
|
app = create_runner_app(
|
|
process_manager=pm, # type: ignore[arg-type]
|
|
spec_resolver=_resolver,
|
|
server_client=NullServerClient(), # type: ignore[arg-type]
|
|
)
|
|
|
|
runner_app._session_inboxes_ref[parent_id] = session_inbox
|
|
runner_app.register_subagent_work(
|
|
parent_session_id=parent_id,
|
|
child_session_id=worker_id,
|
|
agent="claude_code",
|
|
title="task",
|
|
)
|
|
|
|
try:
|
|
async with _runner_client(app) as client:
|
|
create_resp = await client.post(
|
|
"/v1/sessions",
|
|
json={"session_id": worker_id, "agent_id": "880b5afda28ad55ff74cbeb9b5fc67fb"},
|
|
)
|
|
assert create_resp.status_code == 201, create_resp.text
|
|
stop_resp = await client.post(
|
|
f"/v1/sessions/{worker_id}/events",
|
|
json={"type": "stop_session"},
|
|
)
|
|
assert stop_resp.status_code == 204, stop_resp.text
|
|
finally:
|
|
runner_app.unregister_subagent_work(worker_id)
|
|
runner_app._session_inboxes_ref.pop(parent_id, None)
|
|
|
|
# The killed worker's entry was reclaimed: a single cancelled completion
|
|
# landed in the parent's inbox. If 0, the stop path killed the pane but
|
|
# left the parent thinking the worker was still running (the bug).
|
|
assert session_inbox.qsize() == 1, (
|
|
f"Expected one cancelled completion in the parent inbox after stopping "
|
|
f"the worker, got {session_inbox.qsize()}."
|
|
)
|
|
delivered = session_inbox.get_nowait()
|
|
assert delivered["status"] == "cancelled"
|
|
assert delivered["task_id"] == worker_id
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_stop_session_on_native_subagent_without_parent_inbox_returns_204(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""
|
|
Hard-stopping a tracked native sub-agent succeeds after the kill lands.
|
|
|
|
``stop_session`` is user-initiated stop orchestration, not the native
|
|
terminal-status ACK path. Once the pane is killed, the runner must return
|
|
204 so Omnigent can finish host-runner teardown and write the deliberate-stop
|
|
label even if parent delivery cannot be confirmed.
|
|
"""
|
|
from omnigent.runner import app as runner_app
|
|
from omnigent.spec.types import ExecutorSpec
|
|
|
|
parent_id = "a87dd01585f0c6f0f82f73d74e4124c0"
|
|
worker_id = "d2af8cd6293253c5937d8c7d35fb3d6b"
|
|
|
|
monkeypatch.setattr(claude_native_bridge, "kill_session", lambda *a, **k: None)
|
|
|
|
native_spec = AgentSpec(
|
|
spec_version=1,
|
|
name="t",
|
|
executor=ExecutorSpec(type="omnigent", config={"harness": "claude-native"}),
|
|
)
|
|
|
|
async def _resolver(agent_id: str, session_id: str | None = None) -> AgentSpec:
|
|
"""
|
|
Resolve every test session to a claude-native spec.
|
|
|
|
:param agent_id: Agent id requested by the runner.
|
|
:param session_id: Optional session id being spawned.
|
|
:returns: Native executor spec for the test.
|
|
"""
|
|
del agent_id, session_id
|
|
return native_spec
|
|
|
|
pm = _FakeProcessManager(_ScriptedHarnessClient([]))
|
|
app = create_runner_app(
|
|
process_manager=pm, # type: ignore[arg-type]
|
|
spec_resolver=_resolver,
|
|
server_client=NullServerClient(), # type: ignore[arg-type]
|
|
)
|
|
runner_app.register_subagent_work(
|
|
parent_session_id=parent_id,
|
|
child_session_id=worker_id,
|
|
agent="claude_code",
|
|
title="task",
|
|
)
|
|
|
|
try:
|
|
async with _runner_client(app) as client:
|
|
create_resp = await client.post(
|
|
"/v1/sessions",
|
|
json={"session_id": worker_id, "agent_id": "880b5afda28ad55ff74cbeb9b5fc67fb"},
|
|
)
|
|
assert create_resp.status_code == 201, create_resp.text
|
|
stop_resp = await client.post(
|
|
f"/v1/sessions/{worker_id}/events",
|
|
json={"type": "stop_session"},
|
|
)
|
|
entry = runner_app.get_subagent_work(worker_id)
|
|
finally:
|
|
runner_app.unregister_subagent_work(worker_id)
|
|
|
|
assert stop_resp.status_code == 204, stop_resp.text
|
|
assert entry is not None
|
|
# The worker was marked cancelled, but delivery is still unconfirmed. The
|
|
# external_session_status path remains responsible for enforcing delivery
|
|
# ACK failures; explicit stop must not report a failed kill after success.
|
|
assert entry.status == "cancelled"
|
|
assert entry.delivered is False
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_events_stop_session_on_native_returns_503_when_kill_fails(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""
|
|
POST ``/events`` stop_session returns 503 when ``kill_session``
|
|
can't reach tmux, and publishes no idle.
|
|
|
|
Sister to the happy-path test. If the runner can't deliver the
|
|
kill (tmux pane gone, bridge dir not yet advertised) it must
|
|
surface a 503 rather than lie to the web UI with a 204 + idle
|
|
that says "stopped" while the session may still be alive.
|
|
"""
|
|
from omnigent.runner.app import _session_event_queues_ref
|
|
from omnigent.spec.types import ExecutorSpec
|
|
|
|
def _fake_kill(bridge_dir: Any, *, timeout_s: float) -> None:
|
|
"""Simulate the bridge-not-ready path."""
|
|
del bridge_dir, timeout_s
|
|
raise RuntimeError("tmux target is not advertised")
|
|
|
|
monkeypatch.setattr(claude_native_bridge, "kill_session", _fake_kill)
|
|
|
|
native_spec = AgentSpec(
|
|
spec_version=1,
|
|
name="t",
|
|
executor=ExecutorSpec(type="omnigent", config={"harness": "claude-native"}),
|
|
)
|
|
|
|
async def _resolver(agent_id: str, session_id: str | None = None) -> AgentSpec:
|
|
"""Return the native spec for any agent_id."""
|
|
del agent_id
|
|
return native_spec
|
|
|
|
pm = _FakeProcessManager(_ScriptedHarnessClient([]))
|
|
app = create_runner_app(
|
|
process_manager=pm, # type: ignore[arg-type]
|
|
spec_resolver=_resolver,
|
|
server_client=NullServerClient(), # type: ignore[arg-type]
|
|
)
|
|
|
|
async with _runner_client(app) as client:
|
|
create_resp = await client.post(
|
|
"/v1/sessions",
|
|
json={
|
|
"session_id": "baabd23def56efdbe0b84b9c924aa6a6",
|
|
"agent_id": "880b5afda28ad55ff74cbeb9b5fc67fb",
|
|
},
|
|
)
|
|
assert create_resp.status_code == 201, create_resp.text
|
|
|
|
stop_resp = await client.post(
|
|
"/v1/sessions/baabd23def56efdbe0b84b9c924aa6a6/events",
|
|
json={"type": "stop_session"},
|
|
)
|
|
|
|
queue = _session_event_queues_ref.get("baabd23def56efdbe0b84b9c924aa6a6")
|
|
assert queue is not None
|
|
queued_events: list[dict[str, Any]] = []
|
|
while not queue.empty():
|
|
item = queue.get_nowait()
|
|
if isinstance(item, dict):
|
|
queued_events.append(item)
|
|
|
|
assert stop_resp.status_code == 503, (
|
|
f"Native stop_session with kill failure must return 503; "
|
|
f"got {stop_resp.status_code}: {stop_resp.text}"
|
|
)
|
|
body = stop_resp.json()
|
|
assert body.get("error") == "claude_native_stop_failed", (
|
|
f"503 body must carry the stop-failure error code; got {body!r}"
|
|
)
|
|
# No idle on the failure path — clearing the spinner would tell the
|
|
# UI the session stopped when the kill didn't actually land.
|
|
status_idle = [
|
|
e for e in queued_events if e.get("type") == "session.status" and e.get("status") == "idle"
|
|
]
|
|
assert status_idle == [], (
|
|
f"No session.status: idle should be enqueued when kill_session "
|
|
f"failed; got {status_idle!r}."
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_events_stop_session_on_non_native_session_is_204_noop(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""
|
|
Non-native sessions accept stop_session and 204 without killing tmux.
|
|
|
|
In-process harnesses have no external tmux process for the runner to
|
|
kill: stop cancels the in-flight turn via the cancel floor, or — with
|
|
no turn in flight, as here — is a clean 204 no-op. The Omnigent server is
|
|
harness-agnostic and forwards stop_session for any session, so the
|
|
runner must accept it and 204 — never reach ``kill_session``.
|
|
"""
|
|
from omnigent.spec.types import ExecutorSpec
|
|
|
|
def _fake_kill(bridge_dir: Any, *, timeout_s: float) -> None:
|
|
"""Fail the test if a non-native session reaches the killer."""
|
|
del bridge_dir, timeout_s
|
|
raise AssertionError(
|
|
"kill_session must never be called for non-native sessions — "
|
|
"stop_session is a no-op for in-process harnesses."
|
|
)
|
|
|
|
monkeypatch.setattr(claude_native_bridge, "kill_session", _fake_kill)
|
|
|
|
# Default harness (in-process LLM loop), NOT claude-native.
|
|
default_spec = AgentSpec(
|
|
spec_version=1,
|
|
name="t",
|
|
executor=ExecutorSpec(type="omnigent", config={}),
|
|
)
|
|
|
|
async def _resolver(agent_id: str, session_id: str | None = None) -> AgentSpec:
|
|
"""Return the default spec for any agent_id."""
|
|
del agent_id
|
|
return default_spec
|
|
|
|
pm = _FakeProcessManager(_ScriptedHarnessClient([]))
|
|
app = create_runner_app(
|
|
process_manager=pm, # type: ignore[arg-type]
|
|
spec_resolver=_resolver,
|
|
server_client=NullServerClient(), # type: ignore[arg-type]
|
|
)
|
|
|
|
async with _runner_client(app) as client:
|
|
create_resp = await client.post(
|
|
"/v1/sessions",
|
|
json={
|
|
"session_id": "aec413c7f4d6fc308bcaa55ad32c3b98",
|
|
"agent_id": "880b5afda28ad55ff74cbeb9b5fc67fb",
|
|
},
|
|
)
|
|
assert create_resp.status_code == 201, create_resp.text
|
|
|
|
resp = await client.post(
|
|
"/v1/sessions/aec413c7f4d6fc308bcaa55ad32c3b98/events",
|
|
json={"type": "stop_session"},
|
|
)
|
|
|
|
# 204 = dispatch saw a non-native harness and short-circuited
|
|
# before any kill. Anything else means the event leaked into a
|
|
# code path it shouldn't reach.
|
|
assert resp.status_code == 204, (
|
|
f"Non-native stop_session must return 204 no-op; got {resp.status_code}: {resp.text}"
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_events_stop_session_closes_terminal_and_publishes_deleted(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
tmp_path: Path,
|
|
) -> None:
|
|
"""
|
|
Native stop tears the session's terminal resource down.
|
|
|
|
A host-spawned (web-UI-created) claude-native session has no CLI
|
|
wrapper watching the pane, so after ``kill_session`` ends ``claude``
|
|
nothing else removes the terminal resource — the web UI keeps showing
|
|
a live terminal for the stopped session (the user-reported bug). The
|
|
stop handler must therefore close each of the session's terminals and
|
|
publish ``session.resource.deleted`` so connected clients drop them.
|
|
"""
|
|
from omnigent.runner.app import _session_event_queues_ref
|
|
from omnigent.spec.types import ExecutorSpec
|
|
from tests.runner.helpers import make_test_terminal_instance
|
|
|
|
def _fake_kill(bridge_dir: Any, *, timeout_s: float) -> None:
|
|
"""Record nothing; the stub terminal needs no real tmux kill."""
|
|
del bridge_dir, timeout_s
|
|
|
|
monkeypatch.setattr(claude_native_bridge, "kill_session", _fake_kill)
|
|
|
|
# Seed the runner's terminal registry with the session's live
|
|
# ``claude:main`` terminal, mirroring what the host-spawned
|
|
# auto-create path leaves behind. Private-attr seed matches the
|
|
# existing resource-registry test convention (no real tmux).
|
|
conv_id = "778e0486ee2f733acdf021ca8334d0bd"
|
|
terminal_registry = TerminalRegistry(
|
|
conversation_link_base_url="http://127.0.0.1:8000",
|
|
)
|
|
instance = make_test_terminal_instance("claude", "main", tmp_path)
|
|
terminal_registry._by_conversation.setdefault(conv_id, {})[("claude", "main")] = instance
|
|
|
|
native_spec = AgentSpec(
|
|
spec_version=1,
|
|
name="t",
|
|
executor=ExecutorSpec(type="omnigent", config={"harness": "claude-native"}),
|
|
)
|
|
|
|
async def _resolver(agent_id: str, session_id: str | None = None) -> AgentSpec:
|
|
"""Return the native spec for any agent_id."""
|
|
del agent_id, session_id
|
|
return native_spec
|
|
|
|
pm = _FakeProcessManager(_ScriptedHarnessClient([]))
|
|
app = create_runner_app(
|
|
process_manager=pm, # type: ignore[arg-type]
|
|
spec_resolver=_resolver,
|
|
server_client=NullServerClient(), # type: ignore[arg-type]
|
|
terminal_registry=terminal_registry,
|
|
)
|
|
|
|
async with _runner_client(app) as client:
|
|
create_resp = await client.post(
|
|
"/v1/sessions",
|
|
json={"session_id": conv_id, "agent_id": "880b5afda28ad55ff74cbeb9b5fc67fb"},
|
|
)
|
|
assert create_resp.status_code == 201, create_resp.text
|
|
# Precondition: the terminal is live before the stop, so a later
|
|
# absence proves the stop closed it (not that it was never there).
|
|
assert terminal_registry.get(conv_id, "claude", "main") is not None
|
|
|
|
stop_resp = await client.post(
|
|
f"/v1/sessions/{conv_id}/events",
|
|
json={"type": "stop_session"},
|
|
)
|
|
|
|
queue = _session_event_queues_ref.get(conv_id)
|
|
queued_events: list[dict[str, Any]] = []
|
|
while queue is not None and not queue.empty():
|
|
item = queue.get_nowait()
|
|
if isinstance(item, dict):
|
|
queued_events.append(item)
|
|
|
|
assert stop_resp.status_code == 204, stop_resp.text
|
|
|
|
# The terminal is gone from the registry → the resource list the web
|
|
# UI reads no longer shows a live terminal. Still present = the stop
|
|
# handler skipped teardown (the bug this guards against).
|
|
assert terminal_registry.get(conv_id, "claude", "main") is None, (
|
|
"stop_session must close the session's terminal; it is still "
|
|
"registered, so the web UI would keep showing a live terminal."
|
|
)
|
|
|
|
# Exactly one session.resource.deleted for the claude terminal so
|
|
# connected clients drop it live (the server relay also persists it).
|
|
# 0 = teardown didn't publish (UI never updates); 2+ = double-publish.
|
|
deleted = [e for e in queued_events if e.get("type") == "session.resource.deleted"]
|
|
assert deleted == [
|
|
{
|
|
"type": "session.resource.deleted",
|
|
"resource_id": "terminal_claude_main",
|
|
"resource_type": "terminal",
|
|
"session_id": conv_id,
|
|
}
|
|
], f"expected one terminal session.resource.deleted event, got {deleted!r}"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_required_terminal_exit_publishes_deleted_and_failed(tmp_path: Path) -> None:
|
|
"""
|
|
A required terminal disappearing fails the owning session.
|
|
|
|
This uses a generic ``worker`` terminal name to pin the lifecycle rule,
|
|
not a Claude-specific branch: if the terminal was registered as required,
|
|
the runner must publish both resource deletion and ``session.status:
|
|
failed`` when its watcher reports that tmux disappeared.
|
|
|
|
:param tmp_path: Temporary directory for fake terminal paths.
|
|
"""
|
|
from omnigent.runner import app as runner_app
|
|
from omnigent.runner.app import _session_event_queues_ref
|
|
from tests.runner.helpers import make_test_terminal_instance
|
|
|
|
parent_id = uuid.uuid4().hex
|
|
conv_id = uuid.uuid4().hex
|
|
parent_inbox: asyncio.Queue[dict[str, Any]] = asyncio.Queue()
|
|
terminal_registry = TerminalRegistry()
|
|
instance = make_test_terminal_instance("worker", "main", tmp_path)
|
|
instance.command = "worker-cli"
|
|
instance.args = ["--profile", "test"]
|
|
instance.launch_cwd = str(tmp_path)
|
|
instance._remember_pane_snapshot("startup failed\ncomplete setup first")
|
|
terminal_registry._by_conversation.setdefault(conv_id, {})[("worker", "main")] = instance
|
|
callbacks: dict[str, Any] = {}
|
|
|
|
def _capture_watcher(
|
|
on_idle: object | None = None,
|
|
*,
|
|
on_activity: object | None = None,
|
|
on_exit: object | None = None,
|
|
on_tick: object | None = None,
|
|
idle_threshold_s: float | None = None,
|
|
poll_interval_s: float | None = None,
|
|
replace: bool = False,
|
|
) -> None:
|
|
del on_idle, on_activity, on_tick, idle_threshold_s, poll_interval_s
|
|
callbacks["on_exit"] = on_exit
|
|
callbacks["replace"] = replace
|
|
|
|
instance.start_idle_watcher_thread = _capture_watcher # type: ignore[method-assign]
|
|
pm = _FakeProcessManager(_ScriptedHarnessClient([]))
|
|
pm._sessions.add(conv_id)
|
|
app = create_runner_app(
|
|
process_manager=pm, # type: ignore[arg-type]
|
|
server_client=NullServerClient(), # type: ignore[arg-type]
|
|
terminal_registry=terminal_registry,
|
|
)
|
|
resource_registry = app.state.session_resource_registry
|
|
runner_app._session_inboxes_ref[parent_id] = parent_inbox
|
|
runner_app.register_child_session(
|
|
conv_id,
|
|
parent_session_id=parent_id,
|
|
title="worker:main",
|
|
tool="worker",
|
|
session_name="main",
|
|
)
|
|
runner_app.register_subagent_work(
|
|
parent_session_id=parent_id,
|
|
child_session_id=conv_id,
|
|
agent="worker",
|
|
title="main",
|
|
)
|
|
|
|
async def _collect_exit_events() -> list[dict[str, Any]]:
|
|
while True:
|
|
queue = _session_event_queues_ref.get(conv_id)
|
|
if queue is not None and queue.qsize() >= 2:
|
|
events: list[dict[str, Any]] = []
|
|
while not queue.empty():
|
|
item = queue.get_nowait()
|
|
if isinstance(item, dict):
|
|
events.append(item)
|
|
return events
|
|
await asyncio.sleep(0)
|
|
|
|
try:
|
|
await resource_registry.observe_required_terminal(
|
|
conv_id,
|
|
"worker",
|
|
"main",
|
|
instance,
|
|
)
|
|
on_exit = callbacks.get("on_exit")
|
|
assert callable(on_exit)
|
|
on_exit()
|
|
queued_events = await asyncio.wait_for(_collect_exit_events(), timeout=1.0)
|
|
for _ in range(100):
|
|
if pm.released:
|
|
break
|
|
await asyncio.sleep(0)
|
|
parent_events = _drain_session_event_queue(_session_event_queues_ref.get(parent_id))
|
|
finally:
|
|
_session_event_queues_ref.pop(conv_id, None)
|
|
_session_event_queues_ref.pop(parent_id, None)
|
|
runner_app.unregister_subagent_work(conv_id)
|
|
runner_app.unregister_child_session(conv_id)
|
|
runner_app._session_inboxes_ref.pop(parent_id, None)
|
|
|
|
assert terminal_registry.get(conv_id, "worker", "main") is None
|
|
assert {
|
|
"type": "session.resource.deleted",
|
|
"resource_id": "terminal_worker_main",
|
|
"resource_type": "terminal",
|
|
"session_id": conv_id,
|
|
} in queued_events
|
|
failed_events = [
|
|
event
|
|
for event in queued_events
|
|
if event.get("type") == "session.status" and event.get("status") == "failed"
|
|
]
|
|
assert len(failed_events) == 1, f"expected one failed status, got {queued_events!r}"
|
|
assert failed_events[0]["error"]["code"] == "required_terminal_exited"
|
|
assert "Required terminal exited unexpectedly" in failed_events[0]["error"]["message"]
|
|
assert parent_events == [
|
|
{
|
|
"type": "session.child_session.updated",
|
|
"conversation_id": parent_id,
|
|
"child_session_id": conv_id,
|
|
"child": {
|
|
"id": conv_id,
|
|
"title": "worker:main",
|
|
"tool": "worker",
|
|
"session_name": "main",
|
|
"busy": False,
|
|
"current_task_status": "failed",
|
|
"last_task_error": failed_events[0]["error"],
|
|
},
|
|
}
|
|
]
|
|
assert pm.released == [conv_id]
|
|
inbox_item = parent_inbox.get_nowait()
|
|
assert inbox_item["status"] == "failed"
|
|
assert "Required terminal exited unexpectedly" in inbox_item["output"]
|
|
assert "command: worker-cli (2 args; argv omitted" in inbox_item["output"]
|
|
assert f"cwd: {tmp_path}" in inbox_item["output"]
|
|
assert "startup failed\ncomplete setup first" in inbox_item["output"]
|
|
assert "Suggested next checks" not in inbox_item["output"]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_required_terminal_exit_while_idle_does_not_fail_session(tmp_path: Path) -> None:
|
|
"""
|
|
A required terminal that exits while the session is idle is a clean shutdown.
|
|
|
|
The native agent terminal is long-lived and goes ``idle`` once its turn
|
|
completes. When the pane then disappears, the work for that turn was already
|
|
delivered, so the runner must NOT publish ``session.status: failed`` — doing
|
|
so was the source of spurious "failed" chats in the UI. The terminal
|
|
resource is still removed and the harness subprocess released; the runner
|
|
going offline is surfaced separately via liveness, not a failure.
|
|
|
|
:param tmp_path: Temporary directory for fake terminal paths.
|
|
"""
|
|
from omnigent.runner import app as runner_app
|
|
from omnigent.runner.app import _session_event_queues_ref
|
|
from tests.runner.helpers import make_test_terminal_instance
|
|
|
|
parent_id = uuid.uuid4().hex
|
|
conv_id = uuid.uuid4().hex
|
|
parent_inbox: asyncio.Queue[dict[str, Any]] = asyncio.Queue()
|
|
terminal_registry = TerminalRegistry()
|
|
instance = make_test_terminal_instance("worker", "main", tmp_path)
|
|
instance.command = "worker-cli"
|
|
instance.launch_cwd = str(tmp_path)
|
|
terminal_registry._by_conversation.setdefault(conv_id, {})[("worker", "main")] = instance
|
|
callbacks: dict[str, Any] = {}
|
|
|
|
def _capture_watcher(
|
|
on_idle: object | None = None,
|
|
*,
|
|
on_activity: object | None = None,
|
|
on_exit: object | None = None,
|
|
on_tick: object | None = None,
|
|
idle_threshold_s: float | None = None,
|
|
poll_interval_s: float | None = None,
|
|
replace: bool = False,
|
|
) -> None:
|
|
del on_idle, on_activity, on_tick, idle_threshold_s, poll_interval_s, replace
|
|
callbacks["on_exit"] = on_exit
|
|
|
|
instance.start_idle_watcher_thread = _capture_watcher # type: ignore[method-assign]
|
|
pm = _FakeProcessManager(_ScriptedHarnessClient([]))
|
|
pm._sessions.add(conv_id)
|
|
app = create_runner_app(
|
|
process_manager=pm, # type: ignore[arg-type]
|
|
server_client=NullServerClient(), # type: ignore[arg-type]
|
|
terminal_registry=terminal_registry,
|
|
)
|
|
resource_registry = app.state.session_resource_registry
|
|
runner_app._session_inboxes_ref[parent_id] = parent_inbox
|
|
runner_app.register_child_session(
|
|
conv_id,
|
|
parent_session_id=parent_id,
|
|
title="worker:main",
|
|
tool="worker",
|
|
session_name="main",
|
|
)
|
|
runner_app.register_subagent_work(
|
|
parent_session_id=parent_id,
|
|
child_session_id=conv_id,
|
|
agent="worker",
|
|
title="main",
|
|
)
|
|
|
|
try:
|
|
await resource_registry.observe_required_terminal(
|
|
conv_id,
|
|
"worker",
|
|
"main",
|
|
instance,
|
|
)
|
|
# The session reached idle (turn completed) before the pane vanished.
|
|
resource_registry._last_session_status[conv_id] = "idle"
|
|
on_exit = callbacks.get("on_exit")
|
|
assert callable(on_exit)
|
|
on_exit()
|
|
# Await terminal-exit cleanup deterministically instead of polling;
|
|
# then await any pending harness-release task so ``pm.released`` is set.
|
|
await resource_registry.wait_for_terminal_exit_cleanup()
|
|
release_task_name = f"required-terminal-release:{conv_id}"
|
|
pending_release = [
|
|
task
|
|
for task in asyncio.all_tasks()
|
|
if task.get_name() == release_task_name and not task.done()
|
|
]
|
|
if pending_release:
|
|
await asyncio.gather(*pending_release)
|
|
|
|
deleted_event = {
|
|
"type": "session.resource.deleted",
|
|
"resource_id": "terminal_worker_main",
|
|
"resource_type": "terminal",
|
|
"session_id": conv_id,
|
|
}
|
|
queued_events = _drain_session_event_queue(_session_event_queues_ref.get(conv_id))
|
|
parent_events = _drain_session_event_queue(_session_event_queues_ref.get(parent_id))
|
|
finally:
|
|
_session_event_queues_ref.pop(conv_id, None)
|
|
_session_event_queues_ref.pop(parent_id, None)
|
|
runner_app.unregister_subagent_work(conv_id)
|
|
runner_app.unregister_child_session(conv_id)
|
|
runner_app._session_inboxes_ref.pop(parent_id, None)
|
|
|
|
# The terminal resource is still removed...
|
|
assert terminal_registry.get(conv_id, "worker", "main") is None
|
|
assert deleted_event in queued_events
|
|
# ...but no failure is published, and the parent is not woken as failed.
|
|
assert [
|
|
event
|
|
for event in queued_events
|
|
if event.get("type") == "session.status" and event.get("status") == "failed"
|
|
] == []
|
|
assert parent_events == []
|
|
assert parent_inbox.empty()
|
|
# The harness subprocess is still released — the terminal is gone.
|
|
assert pm.released == [conv_id]
|
|
|
|
|
|
@pytest.mark.parametrize("terminal_name", ["qwen", "antigravity"])
|
|
@pytest.mark.asyncio
|
|
async def test_required_terminal_clean_quit_publishes_idle_not_failed(
|
|
terminal_name: str,
|
|
) -> None:
|
|
"""A clean ``/quit`` of qwen/antigravity-native is not a crash.
|
|
|
|
Both harnesses leave the exit-classification memo stuck on ``running`` at
|
|
quit time — qwen's "powering down" redraw trips the PTY-activity watcher,
|
|
and antigravity-native is deliberately excluded from the PTY ``emit_status``
|
|
role set (the RPC reader owns working-status). So ``session_was_idle`` is
|
|
``False`` even though the user quit normally. The runner must special-case
|
|
these terminals: publish a final ``idle`` (to clear the web "Working…"
|
|
spinner) and release the harness, but never render the spurious red
|
|
``required_terminal_exited`` failure card.
|
|
|
|
:param terminal_name: The native terminal that the user quit cleanly.
|
|
"""
|
|
from omnigent.runner import app as runner_app
|
|
from omnigent.runner.app import _session_event_queues_ref
|
|
from omnigent.runner.resource_registry import (
|
|
TerminalExitEvent,
|
|
TerminalLifecycle,
|
|
)
|
|
|
|
conv_id = uuid.uuid4().hex
|
|
pm = _FakeProcessManager(_ScriptedHarnessClient([]))
|
|
pm._sessions.add(conv_id)
|
|
app = create_runner_app(
|
|
process_manager=pm, # type: ignore[arg-type]
|
|
server_client=NullServerClient(), # type: ignore[arg-type]
|
|
)
|
|
resource_registry = app.state.session_resource_registry
|
|
# Grab the runner's terminal-exit publisher (the branch under test) and
|
|
# drive it directly, mimicking the registry firing on a clean quit.
|
|
publish_exit = resource_registry._terminal_exit_publisher
|
|
assert callable(publish_exit)
|
|
|
|
try:
|
|
publish_exit(
|
|
TerminalExitEvent(
|
|
session_id=conv_id,
|
|
terminal_id=f"terminal_{terminal_name}_main",
|
|
terminal_name=terminal_name,
|
|
session_key="main",
|
|
lifecycle=TerminalLifecycle.REQUIRED,
|
|
# The memo never flipped to idle, so the generic guard would
|
|
# otherwise misclassify this normal quit as a crash.
|
|
session_was_idle=False,
|
|
)
|
|
)
|
|
queued_events: list[dict[str, Any]] = []
|
|
for _ in range(1000):
|
|
queued_events.extend(
|
|
_drain_session_event_queue(_session_event_queues_ref.get(conv_id))
|
|
)
|
|
if pm.released:
|
|
break
|
|
await asyncio.sleep(0)
|
|
finally:
|
|
_session_event_queues_ref.pop(conv_id, None)
|
|
runner_app.unregister_child_session(conv_id)
|
|
|
|
# The terminal resource is removed and a final idle clears the spinner...
|
|
assert {
|
|
"type": "session.resource.deleted",
|
|
"resource_id": f"terminal_{terminal_name}_main",
|
|
"resource_type": "terminal",
|
|
"session_id": conv_id,
|
|
} in queued_events
|
|
assert {"type": "session.status", "status": "idle"} in queued_events
|
|
# ...but no spurious failure card renders — the user quit normally.
|
|
assert [
|
|
event
|
|
for event in queued_events
|
|
if event.get("type") == "session.status" and event.get("status") == "failed"
|
|
] == []
|
|
# The harness subprocess is still released — the terminal is gone.
|
|
assert pm.released == [conv_id]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_external_idle_status_makes_required_terminal_exit_clean(tmp_path: Path) -> None:
|
|
"""
|
|
A structured native ``idle`` status prevents a later pane close from failing.
|
|
|
|
Kiro completion is observed from its persisted JSONL session, not only from
|
|
PTY diff-idle. After a web turn marks the required terminal ``running``, the
|
|
forwarded ``external_session_status: idle`` must update the same exit memo
|
|
used by the required-terminal watcher; otherwise a normal user close after
|
|
Kiro answered is misclassified as ``required_terminal_exited``.
|
|
|
|
:param tmp_path: Temporary directory for fake terminal paths.
|
|
"""
|
|
from omnigent.runner.app import _session_event_queues_ref
|
|
from tests.runner.helpers import make_test_terminal_instance
|
|
|
|
conv_id = uuid.uuid4().hex
|
|
terminal_registry = TerminalRegistry()
|
|
instance = make_test_terminal_instance("kiro", "main", tmp_path)
|
|
terminal_registry._by_conversation.setdefault(conv_id, {})[("kiro", "main")] = instance
|
|
callbacks: dict[str, Any] = {}
|
|
|
|
def _capture_watcher(
|
|
on_idle: object | None = None,
|
|
*,
|
|
on_activity: object | None = None,
|
|
on_exit: object | None = None,
|
|
on_tick: object | None = None,
|
|
idle_threshold_s: float | None = None,
|
|
poll_interval_s: float | None = None,
|
|
replace: bool = False,
|
|
) -> None:
|
|
del on_idle, on_activity, on_tick, idle_threshold_s, poll_interval_s, replace
|
|
callbacks["on_exit"] = on_exit
|
|
|
|
instance.start_idle_watcher_thread = _capture_watcher # type: ignore[method-assign]
|
|
pm = _FakeProcessManager(_ScriptedHarnessClient([]))
|
|
pm._sessions.add(conv_id)
|
|
app = create_runner_app(
|
|
process_manager=pm, # type: ignore[arg-type]
|
|
server_client=NullServerClient(), # type: ignore[arg-type]
|
|
terminal_registry=terminal_registry,
|
|
)
|
|
resource_registry = app.state.session_resource_registry
|
|
|
|
try:
|
|
await resource_registry.observe_required_terminal(
|
|
conv_id,
|
|
"kiro",
|
|
"main",
|
|
instance,
|
|
resource_role=KIRO_NATIVE_TERMINAL_ROLE,
|
|
)
|
|
resource_registry.note_session_turn_started(conv_id)
|
|
async with _runner_client(app) as client:
|
|
status_resp = await client.post(
|
|
f"/v1/sessions/{conv_id}/events",
|
|
json={"type": "external_session_status", "data": {"status": "idle"}},
|
|
)
|
|
assert status_resp.status_code == 204, status_resp.text
|
|
|
|
on_exit = callbacks.get("on_exit")
|
|
assert callable(on_exit)
|
|
on_exit()
|
|
# Await terminal-exit cleanup deterministically instead of polling;
|
|
# then await any pending harness-release task so ``pm.released`` is set.
|
|
await resource_registry.wait_for_terminal_exit_cleanup()
|
|
release_task_name = f"required-terminal-release:{conv_id}"
|
|
pending_release = [
|
|
task
|
|
for task in asyncio.all_tasks()
|
|
if task.get_name() == release_task_name and not task.done()
|
|
]
|
|
if pending_release:
|
|
await asyncio.gather(*pending_release)
|
|
|
|
deleted_event = {
|
|
"type": "session.resource.deleted",
|
|
"resource_id": "terminal_kiro_main",
|
|
"resource_type": "terminal",
|
|
"session_id": conv_id,
|
|
}
|
|
queued_events = _drain_session_event_queue(_session_event_queues_ref.get(conv_id))
|
|
finally:
|
|
_session_event_queues_ref.pop(conv_id, None)
|
|
|
|
assert terminal_registry.get(conv_id, "kiro", "main") is None
|
|
assert deleted_event in queued_events
|
|
assert [
|
|
event
|
|
for event in queued_events
|
|
if event.get("type") == "session.status" and event.get("status") == "failed"
|
|
] == []
|
|
assert pm.released == [conv_id]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_events_effort_change_on_native_session_types_slash_command(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""
|
|
POST ``/events`` with ``{"type":"effort_change","effort":"high"}``
|
|
on a claude-native session injects ``/effort high`` into tmux.
|
|
|
|
With the unified-effort refactor Omnigent server no longer POSTs to
|
|
``/claude-native-effort`` — every PATCH effort goes through the
|
|
generic ``/events`` path. The runner's ``/events`` dispatch must
|
|
recognize the native harness and route to
|
|
``_handle_claude_native_effort_change``, which assembles the
|
|
slash command and types it into the pane.
|
|
|
|
A regression in the dispatch (wrong harness name, missing branch)
|
|
would fall through to the generic harness-forward and 404, leaving
|
|
the dropdown click silently ineffective.
|
|
"""
|
|
from omnigent.runner.app import _session_event_queues_ref
|
|
from omnigent.spec.types import ExecutorSpec
|
|
|
|
captured: list[Any] = []
|
|
|
|
def _fake_inject(
|
|
bridge_dir: Any,
|
|
*,
|
|
command: str,
|
|
timeout_s: float,
|
|
auto_confirm: bool = False,
|
|
confirm_hint: str | None = None,
|
|
) -> None:
|
|
"""Record the call and return without touching tmux."""
|
|
captured.append((bridge_dir, command, timeout_s, confirm_hint))
|
|
|
|
monkeypatch.setattr(claude_native_bridge, "inject_slash_command", _fake_inject)
|
|
|
|
native_spec = AgentSpec(
|
|
spec_version=1,
|
|
name="t",
|
|
executor=ExecutorSpec(type="omnigent", config={"harness": "claude-native"}),
|
|
)
|
|
|
|
async def _resolver(agent_id: str, session_id: str | None = None) -> AgentSpec:
|
|
"""Return the native spec for any agent_id."""
|
|
del agent_id
|
|
return native_spec
|
|
|
|
pm = _FakeProcessManager(_ScriptedHarnessClient([]))
|
|
app = create_runner_app(
|
|
process_manager=pm, # type: ignore[arg-type]
|
|
spec_resolver=_resolver,
|
|
server_client=NullServerClient(), # type: ignore[arg-type]
|
|
)
|
|
|
|
async with _runner_client(app) as client:
|
|
# Seed _session_spec_cache so /events can detect "claude-native".
|
|
create_resp = await client.post(
|
|
"/v1/sessions",
|
|
json={
|
|
"session_id": "c7e9584b9bb34910a0068521106c1abc",
|
|
"agent_id": "880b5afda28ad55ff74cbeb9b5fc67fb",
|
|
},
|
|
)
|
|
assert create_resp.status_code == 201, create_resp.text
|
|
# Drain creation-time events (the claude-native auto-create path
|
|
# enqueues session.terminal_pending) so the post-effort_change
|
|
# drain below isolates only what the control event emits.
|
|
_drain_session_event_queue(
|
|
_session_event_queues_ref.get("c7e9584b9bb34910a0068521106c1abc")
|
|
)
|
|
|
|
resp = await client.post(
|
|
"/v1/sessions/c7e9584b9bb34910a0068521106c1abc/events",
|
|
json={"type": "effort_change", "effort": "high"},
|
|
)
|
|
|
|
# Drain the event queue before delete clears it, so we can
|
|
# assert that effort_change does NOT enqueue spurious events
|
|
# (it's a control signal, not a session-state change).
|
|
queue = _session_event_queues_ref.get("c7e9584b9bb34910a0068521106c1abc")
|
|
queued_events: list[dict[str, Any]] = []
|
|
if queue is not None:
|
|
while not queue.empty():
|
|
item = queue.get_nowait()
|
|
if isinstance(item, dict):
|
|
queued_events.append(item)
|
|
|
|
# 1) 204 = the dispatch correctly routed to the native handler and
|
|
# the handler completed cleanly. 404 would mean the dispatch fell
|
|
# through to the generic harness-forward.
|
|
assert resp.status_code == 204, (
|
|
f"Native effort_change must return 204 from /events; got {resp.status_code}: {resp.text}"
|
|
)
|
|
# 2) Exactly one inject call. 0 = native dispatch missed (likely
|
|
# _session_harness_name returned the wrong canonical name); 2+ =
|
|
# the handler ran twice.
|
|
assert len(captured) == 1, (
|
|
f"Expected one inject_slash_command call from native effort_change, got {len(captured)}."
|
|
)
|
|
bridge_dir, command, timeout_s, confirm_hint = captured[0]
|
|
assert bridge_dir == bridge_dir_for_conversation_id("c7e9584b9bb34910a0068521106c1abc")
|
|
# The effort dialog's own title, not "Switch model?". Watching for the wrong
|
|
# one would leave the pane wedged behind an unconfirmed modal; watching for
|
|
# "any dialog" would answer a foreign one (a permission prompt, a picker the
|
|
# person opened) that rendered while the poll was running.
|
|
assert confirm_hint == claude_native_bridge.EFFORT_DIALOG_HINT
|
|
# Body contract: ``/effort high`` is the literal Claude Code's TUI
|
|
# accepts. A regression in shape (``/efforthigh``, ``effort high``,
|
|
# missing leading slash) would either 404 on the slash router or
|
|
# land as plain text in the prompt.
|
|
assert command == "/effort high", f"Expected '/effort high' literal, got {command!r}."
|
|
# 1.0s short timeout: missing tmux.json means the pane isn't
|
|
# attached; persisted effort still applies on next spawn. A 30s
|
|
# default would hang the Omnigent PATCH whenever the pane is detached.
|
|
assert timeout_s == 1.0
|
|
# 3) effort_change is a control signal, not a state change.
|
|
# Any session.status enqueued here would mislead the Omnigent relay.
|
|
assert queued_events == [], (
|
|
f"effort_change must not publish session events; got "
|
|
f"{queued_events!r}. If non-empty, the native handler is "
|
|
f"emitting spurious status events."
|
|
)
|
|
|
|
|
|
async def test_events_interrupt_on_kiro_native_routes_to_escape(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""interrupt on a kiro-native session sends Escape via the kiro bridge.
|
|
|
|
Regression for #1137: kiro-native had no entry in the interrupt dispatch
|
|
ladder, so the web Stop button fell through to the in-process cancel floor —
|
|
a no-op for a TUI turn the harness task already returned from — and silently
|
|
did nothing. This pins that the dispatch routes kiro-native to
|
|
``kiro_native_bridge.inject_interrupt`` with the snappy 1.0s timeout.
|
|
"""
|
|
from omnigent.spec.types import ExecutorSpec
|
|
|
|
captured: list[Any] = []
|
|
monkeypatch.setattr(
|
|
kiro_native_bridge,
|
|
"inject_interrupt",
|
|
lambda bridge_dir, *, timeout_s: captured.append((bridge_dir, timeout_s)),
|
|
)
|
|
|
|
native_spec = AgentSpec(
|
|
spec_version=1,
|
|
name="t",
|
|
executor=ExecutorSpec(type="omnigent", config={"harness": "kiro-native"}),
|
|
)
|
|
|
|
async def _resolver(agent_id: str, session_id: str | None = None) -> AgentSpec:
|
|
del agent_id
|
|
return native_spec
|
|
|
|
pm = _FakeProcessManager(_ScriptedHarnessClient([]))
|
|
app = create_runner_app(
|
|
process_manager=pm, # type: ignore[arg-type]
|
|
spec_resolver=_resolver,
|
|
server_client=NullServerClient(), # type: ignore[arg-type]
|
|
)
|
|
|
|
async with _runner_client(app) as client:
|
|
create_resp = await client.post(
|
|
"/v1/sessions",
|
|
json={
|
|
"session_id": "cd6b589814147431cc1a92ec2c979998",
|
|
"agent_id": "880b5afda28ad55ff74cbeb9b5fc67fb",
|
|
},
|
|
)
|
|
assert create_resp.status_code == 201, create_resp.text
|
|
int_resp = await client.post(
|
|
"/v1/sessions/cd6b589814147431cc1a92ec2c979998/events",
|
|
json={"type": "interrupt"},
|
|
)
|
|
|
|
assert int_resp.status_code == 204, int_resp.text
|
|
# 0 = the dispatch fell through to the generic path (the silent no-op bug);
|
|
# 2+ = the handler ran twice.
|
|
assert len(captured) == 1, (
|
|
f"Expected one inject_interrupt call, got {len(captured)}. If 0, the "
|
|
f"kiro-native interrupt dispatch entry is missing."
|
|
)
|
|
bridge_dir, timeout_s = captured[0]
|
|
assert bridge_dir == kiro_native_bridge.bridge_dir_for_session_id(
|
|
"cd6b589814147431cc1a92ec2c979998"
|
|
)
|
|
assert timeout_s == 1.0
|
|
|
|
|
|
async def test_events_stop_session_on_kiro_native_kills_tmux_and_publishes_idle(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""stop_session on a kiro-native session kills the tmux pane and clears the spinner.
|
|
|
|
Mirrors the goose/claude-native stop path: route to
|
|
``kiro_native_bridge.kill_session`` and enqueue exactly one
|
|
``session.status: idle`` (kiro-cli has no Stop hook on a hard kill).
|
|
"""
|
|
from omnigent.runner.app import _session_event_queues_ref
|
|
from omnigent.spec.types import ExecutorSpec
|
|
|
|
captured: list[Any] = []
|
|
monkeypatch.setattr(
|
|
kiro_native_bridge,
|
|
"kill_session",
|
|
lambda bridge_dir, *, timeout_s: captured.append((bridge_dir, timeout_s)),
|
|
)
|
|
|
|
native_spec = AgentSpec(
|
|
spec_version=1,
|
|
name="t",
|
|
executor=ExecutorSpec(type="omnigent", config={"harness": "kiro-native"}),
|
|
)
|
|
|
|
async def _resolver(agent_id: str, session_id: str | None = None) -> AgentSpec:
|
|
del agent_id
|
|
return native_spec
|
|
|
|
pm = _FakeProcessManager(_ScriptedHarnessClient([]))
|
|
app = create_runner_app(
|
|
process_manager=pm, # type: ignore[arg-type]
|
|
spec_resolver=_resolver,
|
|
server_client=NullServerClient(), # type: ignore[arg-type]
|
|
)
|
|
|
|
async with _runner_client(app) as client:
|
|
create_resp = await client.post(
|
|
"/v1/sessions",
|
|
json={
|
|
"session_id": "cd2a2b575af18bbc3a38fd025e379be0",
|
|
"agent_id": "880b5afda28ad55ff74cbeb9b5fc67fb",
|
|
},
|
|
)
|
|
assert create_resp.status_code == 201, create_resp.text
|
|
stop_resp = await client.post(
|
|
"/v1/sessions/cd2a2b575af18bbc3a38fd025e379be0/events",
|
|
json={"type": "stop_session"},
|
|
)
|
|
queue = _session_event_queues_ref.get("cd2a2b575af18bbc3a38fd025e379be0")
|
|
queued_events: list[dict[str, Any]] = []
|
|
while queue is not None and not queue.empty():
|
|
item = queue.get_nowait()
|
|
if isinstance(item, dict):
|
|
queued_events.append(item)
|
|
|
|
assert stop_resp.status_code == 204, stop_resp.text
|
|
assert len(captured) == 1, (
|
|
f"Expected one kill_session call, got {len(captured)}. If 0, the "
|
|
f"kiro-native stop dispatch entry is missing."
|
|
)
|
|
bridge_dir, timeout_s = captured[0]
|
|
assert bridge_dir == kiro_native_bridge.bridge_dir_for_session_id(
|
|
"cd2a2b575af18bbc3a38fd025e379be0"
|
|
)
|
|
assert timeout_s == 1.0
|
|
idle_events = [
|
|
e for e in queued_events if e.get("type") == "session.status" and e.get("status") == "idle"
|
|
]
|
|
assert len(idle_events) == 1, f"stop must publish exactly one idle; got {queued_events!r}"
|
|
|
|
|
|
async def test_events_interrupt_on_kiro_native_503_skips_idle_when_inject_fails(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""interrupt returns 503 and publishes no idle when Escape can't reach tmux.
|
|
|
|
Failure-path parity with the sibling harnesses (e.g.
|
|
``..._interrupt_on_native_session_503_skips_cleanup_when_inject_fails``): if
|
|
the bridge can't deliver Escape (pane gone, bridge dir not advertised), the
|
|
runner must surface a 503 and must NOT publish ``session.status: idle`` — idle
|
|
would clear the web-UI spinner while the kiro turn keeps generating. Guards
|
|
against a reorder that moves the idle publish ahead of the ``try``.
|
|
"""
|
|
from omnigent.runner.app import _session_event_queues_ref
|
|
from omnigent.spec.types import ExecutorSpec
|
|
|
|
def _fake_inject(bridge_dir: Any, *, timeout_s: float) -> None:
|
|
"""Simulate the bridge-not-ready path."""
|
|
del bridge_dir, timeout_s
|
|
raise RuntimeError("tmux target is not advertised")
|
|
|
|
monkeypatch.setattr(kiro_native_bridge, "inject_interrupt", _fake_inject)
|
|
|
|
native_spec = AgentSpec(
|
|
spec_version=1,
|
|
name="t",
|
|
executor=ExecutorSpec(type="omnigent", config={"harness": "kiro-native"}),
|
|
)
|
|
|
|
async def _resolver(agent_id: str, session_id: str | None = None) -> AgentSpec:
|
|
del agent_id
|
|
return native_spec
|
|
|
|
pm = _FakeProcessManager(_ScriptedHarnessClient([]))
|
|
app = create_runner_app(
|
|
process_manager=pm, # type: ignore[arg-type]
|
|
spec_resolver=_resolver,
|
|
server_client=NullServerClient(), # type: ignore[arg-type]
|
|
)
|
|
|
|
async with _runner_client(app) as client:
|
|
create_resp = await client.post(
|
|
"/v1/sessions",
|
|
json={
|
|
"session_id": "b756aafcdc68c0ed2cf92b34085be5bb",
|
|
"agent_id": "880b5afda28ad55ff74cbeb9b5fc67fb",
|
|
},
|
|
)
|
|
assert create_resp.status_code == 201, create_resp.text
|
|
int_resp = await client.post(
|
|
"/v1/sessions/b756aafcdc68c0ed2cf92b34085be5bb/events",
|
|
json={"type": "interrupt"},
|
|
)
|
|
queue = _session_event_queues_ref.get("b756aafcdc68c0ed2cf92b34085be5bb")
|
|
queued_events: list[dict[str, Any]] = []
|
|
while queue is not None and not queue.empty():
|
|
item = queue.get_nowait()
|
|
if isinstance(item, dict):
|
|
queued_events.append(item)
|
|
|
|
assert int_resp.status_code == 503, (
|
|
f"kiro-native interrupt with inject_interrupt failure must return 503; "
|
|
f"got {int_resp.status_code}: {int_resp.text}"
|
|
)
|
|
body = int_resp.json()
|
|
assert body.get("error") == "kiro_native_interrupt_failed", (
|
|
f"503 body must carry the bridge-failure error code; got {body!r}"
|
|
)
|
|
status_idle = [
|
|
e for e in queued_events if e.get("type") == "session.status" and e.get("status") == "idle"
|
|
]
|
|
assert status_idle == [], (
|
|
f"No session.status: idle should be enqueued when Escape injection "
|
|
f"failed; got {status_idle!r}."
|
|
)
|
|
|
|
|
|
async def test_events_stop_session_on_kiro_native_503_when_kill_fails(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""stop_session returns 503 and publishes no idle when the kill can't reach tmux.
|
|
|
|
Failure-path parity with ``..._stop_session_on_native_returns_503_when_kill_fails``:
|
|
a failed kill must surface 503 rather than lie to the web UI with 204 + idle
|
|
while the ``kiro-cli`` process may still be alive.
|
|
"""
|
|
from omnigent.runner.app import _session_event_queues_ref
|
|
from omnigent.spec.types import ExecutorSpec
|
|
|
|
def _fake_kill(bridge_dir: Any, *, timeout_s: float) -> None:
|
|
"""Simulate the bridge-not-ready path."""
|
|
del bridge_dir, timeout_s
|
|
raise RuntimeError("tmux target is not advertised")
|
|
|
|
monkeypatch.setattr(kiro_native_bridge, "kill_session", _fake_kill)
|
|
|
|
native_spec = AgentSpec(
|
|
spec_version=1,
|
|
name="t",
|
|
executor=ExecutorSpec(type="omnigent", config={"harness": "kiro-native"}),
|
|
)
|
|
|
|
async def _resolver(agent_id: str, session_id: str | None = None) -> AgentSpec:
|
|
del agent_id
|
|
return native_spec
|
|
|
|
pm = _FakeProcessManager(_ScriptedHarnessClient([]))
|
|
app = create_runner_app(
|
|
process_manager=pm, # type: ignore[arg-type]
|
|
spec_resolver=_resolver,
|
|
server_client=NullServerClient(), # type: ignore[arg-type]
|
|
)
|
|
|
|
async with _runner_client(app) as client:
|
|
create_resp = await client.post(
|
|
"/v1/sessions",
|
|
json={
|
|
"session_id": "695c27b61206353f312efe5f6a7ca0f6",
|
|
"agent_id": "880b5afda28ad55ff74cbeb9b5fc67fb",
|
|
},
|
|
)
|
|
assert create_resp.status_code == 201, create_resp.text
|
|
stop_resp = await client.post(
|
|
"/v1/sessions/695c27b61206353f312efe5f6a7ca0f6/events",
|
|
json={"type": "stop_session"},
|
|
)
|
|
queue = _session_event_queues_ref.get("695c27b61206353f312efe5f6a7ca0f6")
|
|
queued_events: list[dict[str, Any]] = []
|
|
while queue is not None and not queue.empty():
|
|
item = queue.get_nowait()
|
|
if isinstance(item, dict):
|
|
queued_events.append(item)
|
|
|
|
assert stop_resp.status_code == 503, (
|
|
f"kiro-native stop_session with kill failure must return 503; "
|
|
f"got {stop_resp.status_code}: {stop_resp.text}"
|
|
)
|
|
body = stop_resp.json()
|
|
assert body.get("error") == "kiro_native_stop_failed", (
|
|
f"503 body must carry the stop-failure error code; got {body!r}"
|
|
)
|
|
status_idle = [
|
|
e for e in queued_events if e.get("type") == "session.status" and e.get("status") == "idle"
|
|
]
|
|
assert status_idle == [], (
|
|
f"No session.status: idle should be enqueued when kill_session failed; "
|
|
f"got {status_idle!r}."
|
|
)
|