5860ae08f0
* fix: let a healthy route finish before the routing hook gives up The first-message ladder was sized from the routing call alone, but the server prepares the candidate catalog before it calls the router — about three seconds on a first message. A healthy route therefore cost ~4.8s against a 7s relay budget that started earlier, so the runner abandoned verdicts that did arrive: the attempt was wasted, the prompt was replayed a second time, and the transcript showed it twice. Each hop now covers preparation plus the call, with the hook budget at the 15s ceiling and the harness kill still under Claude Code's own 30s UserPromptSubmit default. A wedged router costs 15s instead of the 45s it cost before this ladder existed. The magnitude test gains a floor as well as a ceiling, so a future tightening cannot re-open the gap. Co-authored-by: Isaac Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com> * fix(web): say claude and codex on spawn chips, without the native suffix A spawn chip's harness id is how the spawn runs, not something the chip needs to spell out; the native suffix reads as noise there. SDK-brain sub-agents (a bundle agent's codex / claude-sdk children) carry no suffix and render unchanged, as do the session's own session/turn chips. Co-authored-by: Isaac Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com> * test: align the spawn-gate budget assertion with the widened ladder Co-authored-by: Isaac Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com> * fix(routing): keep a pinned session's spawns in its own family at the source A pinned Smart Routing session was offered every agent by ``sys_agent_list``, so a codex session could stand up a claude-native child and only then have routing decline it. Refuse the spawn before it happens instead: - ``sys_agent_list`` drops built-ins outside the caller's family when the caller routes its spawns and is not auto-harness. - ``POST /v1/sessions`` refuses an out-of-family child of such a parent, naming the rule. Auto-harness parents still cross families (the router owns theirs), and a plain session sees and spawns exactly what it did before. The routing decline stays as the fail-safe for a pane that exists anyway. Co-authored-by: Isaac Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com> * fix(routing): decline a route-turn whose parent routes another family ``route_turn_hook`` routed a pane's first typed prompt in the pane's own family with no look at its parent, so a child pane on another family's CLI could be pinned to a model its parent's family serves and the pane cannot speak. The policy now declines (fail-open, nothing pinned, no chip) when the pane's parent is a pinned Smart Routing session of another family. The create gate refuses such a pane outright, so this only catches a row that predates it — hence non-terminal, and the parent's switch stays togglable. Co-authored-by: Isaac Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com> * fix(routing): a failed auto-harness route must not claim the route-once label The auto-harness path stamped the routing-decision label on its own "unavailable" card, and that label is the route-once gate — so a router that happened to be down when the session started made every later in-harness prompt decline as "already routed". Leave the label unclaimed on failure, the way the turn, native-pane and child-spawn paths already do; the declined card still says what happened. Co-authored-by: Isaac Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com> * fix(routing): stop routing a Smart Routing create's prompt twice A native Smart Routing create routes the landing screen's prompt and pins what it picked; the harness then submits that same prompt, and the first-prompt hook scored it again — a second judge call tens of seconds later, for the verdict the pane was already running on, and a needless block-and-replay of the turn. The create now fingerprints the prompt it routed (a hash: the label is metadata, and the user's prompt does not belong there). When the hook sees that prompt again it claims the create's decision instead of making a new one — one router call, one chip. A prompt the user edited before sending does not match and still routes on its own, as does the first prompt of a session whose create-time route failed. Co-authored-by: Isaac Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com> * perf(routing): take catalog preparation off the turn path A first routed message spent ~3.2s preparing routing candidates before the routes:select POST went out, and nothing in the logs named where it went. Two runner-derived catalogs were being resolved while the user's prompt was held: the claude-native picker vocabulary, whose stale entry the turn path awaits for up to _ROUTING_CATALOG_WAIT_S (3.0s) while the fetch retries a booting runner, and the runner model catalog, a round trip per turn for every pane that has no picker vocabulary of its own. Warm both when the runner binds instead. _on_runner_connect now calls prefetch_session_routing_catalogs once the session-init handshake has created the terminal, so the catalogs land before the first prompt rather than under it. The runner catalog also gains a per-session cache behind _fetch_runner_catalog (single-flight, 5-minute backstop TTL) whose entries drop through the seam that already invalidates runner-derived snapshot overlays — a rebind or relaunch can change which models a pane accepts, so it must not keep routing off the previous runner's list. A cold cache still takes the inline fetch, so nothing depends on the prefetch having run. route_turn now logs its two phases separately (prep vs router) and the stale catalog refresh logs what it waited, so the timeout ladder can be revisited against measurements instead of a guess. The ladder constants are unchanged here. Co-authored-by: Isaac Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com> * fix(codex): check a routed slug is reachable before switching the pane The routing verdict comes from a server-side gateway map that can go stale, so the routed model is not necessarily one this pane's gateway serves. The hook switched onto it regardless: codex accepted the id, the next turn failed, and nothing anywhere said why — the failure mode the #4074 review flagged. The pane's live model/list is the only authority on what it can be moved onto, and the hook already reads it to translate the routed id into codex's spelling. Make that read the reachability check too: codex_model_slug becomes codex_reachable_model_slug and answers None when no row names the model, and _apply_thread_model returns a decline reason instead of a bare bool. An unreachable pick leaves the pane on its own model, writes no marker, blocks nothing, and records "routed model not in this pane's catalog" to the routing trace and stderr — the same fail-open shape the claude side uses when a routed model has no spelling its picker accepts. A model/list that cannot be read is now distinguished from an empty catalog and also declines: an unreadable catalog is not evidence of reachability, and declining costs a turn of routing where switching blind costs the turn itself. Co-authored-by: Isaac Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com> * fix(auth): one workspace identity, and a refresh that can fall back Two credential faults that made a healthy workspace look unreachable. **One identity.** A pane and the server could authenticate as different ~/.databrickscfg profiles for the same host. The server's router client uses the config's `kind: databricks` provider profile; the claude-native pane installed ucode's recorded token command, which selects the workspace however ucode was set up — usually by host. Two profiles on one host are two identities, so re-authing one left the other's token expired and the two halves disagreed about whether the workspace was up. The named profile is now the authority on both sides: the pane's apiKeyHelper is regenerated against it (only for the recognizable `databricks auth token` shape — an enterprise deployment's own token command has a selector we have no business guessing at), and a `routing:` block that names no profile falls back to the provider block's rather than to the ambient SDK chain. Host selection stays the fallback for when nothing names a profile. **A refresh that can fall back.** The generated helper forced a refresh on every call. The reason is real — `--force-refresh` renews a still-valid token and keeps a long gateway session off a mid-session 401 — but it fails outright once the refresh token has gone stale, which turned a perfectly usable cached access token into a hard auth failure (twice in one day). The forced attempt is now speculative: its output is captured, its stderr dropped, and an empty result falls back to plain `auth token`, which serves the cached token and renews it near expiry. The fallback keeps its stderr so a genuine auth failure is still visible. Both harnesses generated this command separately, so the shape now has one definition (databricks_bearer_token_command) and the claude and codex helpers delegate to it. Co-authored-by: Isaac Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com> * test: align both hook-budget assertions with the widened ladder Co-authored-by: Isaac Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com> * test: keep the catalog-cache reset import-free; cover the spawn chip in e2e_ui The autouse cache-reset fixture imported omnigent.server.smart_routing in every teardown, which detonated inside the spec suite's import-blocker test and taxed lanes that never load the server. A sys.modules lookup clears the cache only where it exists. The new Playwright case pins the shortened spawn-chip harness label the UI judge flagged. Co-authored-by: Isaac Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com> * fix: leave a visible declined chip when the turn hook's routing call fails The create and dispatch paths already card a failed route; the in-harness first-message hook failed open silently, so a router 401 looked like the session simply ignoring Smart Routing. The hook now persists the same unavailable card with the cause, without claiming the route-once label — the next prompt can still route. Benign allows (already routed, routing off, the family guard) are not failures and stay chipless. Co-authored-by: Isaac Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com> * feat(cli): drop create-time Smart Routing; keep first-message routing The CLI can only route a prompt it never shows: `--smart-routing -p` picked a model (and, on `run`, a harness) before the TUI existed, so the user typed at a session whose pick they could neither see nor change. The web UI is the surface that can do that. So the CLI keeps the one routing shape a terminal can honour — arm the session, let the harness's own hook route the first message typed — and rejects the rest. `omni claude|codex --smart-routing` stay, bare only. `-p` alongside them is now a usage error pointing at the TUI or the web UI, and `run --smart-routing` (with it the CLI's auto-harness route) is rejected outright; its flag stays hidden purely to say where routing moved, and comes out in 0.11. That leaves nothing behind the create-time path: the routed create no longer sends a message or the `auto` sentinel, reads back no verdict, and the launch-side plumbing that applied one is gone. `create_smart_routing_session` becomes `arm_smart_routing_session` and `RoutingDecision` becomes `ArmedSession` (session id + fail-open notice), because neither decides anything any more. The preflight gate, the `--resume` rejection and every server-side create path are untouched. Co-authored-by: Isaac Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com> * fix(web): drop "-native" from every routing chip, not just spawn chips A session-scope chip read "codex-native", which leaks how the pane runs into a label that only needs to name the brain. The shortening was scoped to sub-agent decisions; it belongs on every chip, so harnessDisplayLabel no longer takes a scope and always trims the trailing suffix. SDK ids (codex / claude-sdk / auto) carry no suffix and render unchanged. The e2e session-chip assertion now also pins the negative: a bare "claude" substring-matches "claude-native", so only not_to_contain_text catches a regression. Same for the card unit test, which anchors on the full label. Co-authored-by: Isaac Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com> * fix(web): render an auto-harness create chip below its prompt A session created with Smart Routing as both the model AND the harness records the pick as a `session` chip at create time, and its first turn routes again and records a `turn` chip — so two chips sit above the session's first user message. `deferredRoutingChips` only paired a chip whose immediate next content block was that message, so the first of the two was left in place and rendered ABOVE the prompt, reading as a preamble instead of the verdict on it. It only looked right when the two verdicts matched and the create chip was dropped by the collapse. Look forward past the sibling chips waiting on the same message (and past superseded ones, which render nothing) and defer them all below the message, in transcript order. A sub-agent chip still stops the scan: it renders standalone where it occurred, and stepping over it would reorder the two. The cache's pending-pair guard learns the same rule so the pair stays stable frame by frame. Co-authored-by: Isaac Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com> * perf(runner): skip the sys_agent_list routing lookup on plain sessions Family confinement made every sys_agent_list pay a serial GET /v1/sessions/{id} with a 30s budget before discovering the session was not routed at all. Plain sessions — the overwhelming majority — carried seconds of fan-out latency for a feature they never use, and a wedged server stalled the listing for the full 30s. Read the runner-local routing class first: a session with no routing armed, or an auto-harness one, answers without a server hop. Only a locally pinned routed session spends the lookup, now on a 5s budget that fails open to the unfiltered listing, and its answer is cached for the session (routing state is fixed at create). The create-path gate still refuses out-of-family creates, so a fail-open listing stays safe. Co-authored-by: Isaac Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com> * fix(auth): fall back to ucode's recorded token command Pinning the pane's apiKeyHelper to the config-named Databricks profile fixed one outage and opened its mirror image: when the named profile holds no usable credential — a config naming DEFAULT while the user authenticated under another profile on the same host — the helper now prints nothing and every turn 401s, where before the rewrite ucode's own recorded command served a working token. The named profile stays the preferred identity; the recorded command becomes the helper's last resort, after the forced refresh and the cached token have both come up empty. An injected DATABRICKS_BEARER still short-circuits everything. Co-authored-by: Isaac Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com> * perf(routing): only warm catalogs for routed, live sessions A runner reconnect walks every session bound to that runner, and the catalog prefetch fired for all of them — archived rows included — with no Smart Routing gate. One host's tunnel flap with ~25 plain codex panes launched 50 fire-and-forget tasks whose provider listings run on worker threads, so the session re-init running alongside them timed out and the panes came back stranded, all to warm a cache only Smart Routing reads. Gate the prefetch on the canonical routing reader (routing_class_from_snapshot), skip archived sessions, cap concurrent warm-ups with a small semaphore, and have each task retrieve its own exception: a tunnel dropped mid-prefetch raised RuntimeError that nothing ever retrieved, which surfaced only as asyncio unretrieved-exception noise. Co-authored-by: Isaac Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com> * feat(routing): route a pinned native create before its pane launches Picking Claude Code or Codex with Smart Routing as the model created the session with no prompt to route on, so routing fell through to the in-pane first-message hook: the prompt was blocked, routed, switched with `/model` and replayed. The user watched their own message disappear for seconds, and the composer's model pill stayed stale because the pin landed mid-turn instead of before the snapshot bound. The web create now sends `smart_routing_message` for a pinned claude-native / codex-native pane too, whenever routing owns the model. The server already routes the MODEL only on that path and pins `model_override` before the terminal launches; the client still delivers the real first message after navigation, exactly as the auto path does. Bundle agents are untouched — their harness isn't decided until the first message event, so there is nothing to route at create. With the model pinned and the routing-decision label stamped before the pane exists, the `UserPromptSubmit` turn-routing hook has no answer left but "already routed" — paid for with a held prompt and a round trip per prompt. The session's routing class now carries a `turn_routing` flag that drops to false once the row has a routing decision, and the native launch skips the loopback router; the absent advertisement is what leaves the hook out of the generated settings. A create whose routing failed stamps nothing and keeps its hook, so the first message is still its retry, and spawn routing plus the extended catalog are untouched. Co-authored-by: Isaac Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com> * fix(web): keep a create-time routing chip below the prompt it decides A pinned Smart Routing create routes at create time, so the session-scope decision is persisted before the pane launches while the landing composer's prompt is only posted after navigation. The prompt is on screen the whole time, but as an optimistic `pendingUserMessages` entry merged in AFTER the bubble walk — never a `user_message` block — so `pairableMessageAfter` cannot see it and the chip renders above the message until the server persists it, then visibly moves below. Splice the pending prompt above a run of session-scope chips that opens the committed timeline, matching the position `buildBubbles` gives the chip once the message is persisted. The chip renders once, below the prompt, and stays put across the pending → committed swap. Chips anywhere else (paired with their message, or a standalone sub-agent spawn) keep their place, and a chip with no message — including a declined create route — still renders. Co-authored-by: Isaac Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com> * chore: trigger CI on the rebased tip The rebase onto main and the chip-ordering fix never ran the test lanes; only CodeQL and DCO reported. Co-authored-by: Isaac Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com> --------- Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>
155 lines
6.2 KiB
Python
155 lines
6.2 KiB
Python
from __future__ import annotations
|
|
|
|
import pytest
|
|
|
|
from omnigent.claude_model_vocabulary import _CATALOG_PREFIXES as _CLAUDE_PREFIXES
|
|
from omnigent.codex_model_vocabulary import (
|
|
_CATALOG_PREFIXES,
|
|
EXTENDED_CATALOG_MODELS,
|
|
EXTENDED_MODEL_DEFAULT_EFFORT,
|
|
EXTENDED_MODEL_EFFORTS,
|
|
clamp_spawn_effort,
|
|
codex_reachable_model_slug,
|
|
codex_spawn_model,
|
|
comparable_model_id,
|
|
)
|
|
from omnigent.reasoning_effort import clamp_effort_for_model
|
|
|
|
# A live ``model/list`` response from a databricks-gateway codex session:
|
|
# codex spells versions with dots, and the extended-catalog row keeps its
|
|
# catalog spelling because that IS codex's id for it.
|
|
_CATALOG: list[dict[str, object]] = [
|
|
{"id": "gpt-5.6-sol", "model": "gpt-5.6-sol", "isDefault": True},
|
|
{"id": "gpt-5.6-luna", "model": "gpt-5.6-luna"},
|
|
{"id": "system.ai.glm-5-2", "model": "system.ai.glm-5-2"},
|
|
{"id": "gpt-5.5", "model": "gpt-5.5"},
|
|
]
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("model", "expected"),
|
|
[
|
|
# Codex dots the version segment and keeps the tier hyphenated.
|
|
("databricks-gpt-5-6-luna", "gpt-5.6-luna"),
|
|
("databricks-gpt-5-6-sol", "gpt-5.6-sol"),
|
|
("databricks-gpt-5-6-terra", "gpt-5.6-terra"),
|
|
("databricks-gpt-5-5", "gpt-5.5"),
|
|
("databricks-gpt-5-2", "gpt-5.2"),
|
|
# Already a slug, so translating is a no-op.
|
|
("gpt-5.6-luna", "gpt-5.6-luna"),
|
|
# GLM is spawnable only under the id the gateway serves it as, which
|
|
# is the slug omnigent writes into the session's catalog.
|
|
("databricks-glm-5-2", "system.ai.glm-5-2"),
|
|
("system.ai.glm-5-2", "system.ai.glm-5-2"),
|
|
("glm-5-2", "system.ai.glm-5-2"),
|
|
# No slug at all: the caller falls open rather than sending a value
|
|
# codex rejects client-side.
|
|
("databricks-claude-sonnet-5", None),
|
|
("databricks-kimi-k2-6", None),
|
|
("", None),
|
|
],
|
|
)
|
|
def test_codex_spawn_model_speaks_the_spawn_tools_vocabulary(
|
|
model: str,
|
|
expected: str | None,
|
|
) -> None:
|
|
assert codex_spawn_model(model) == expected
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("effort", "model", "expected"),
|
|
[
|
|
# Codex refuses xhigh/max for glm, so a session default coerces down.
|
|
("xhigh", "system.ai.glm-5-2", "medium"),
|
|
("max", "system.ai.glm-5-2", "medium"),
|
|
# Inside the ladder: the caller's pick is kept.
|
|
("high", "system.ai.glm-5-2", "high"),
|
|
("low", "system.ai.glm-5-2", "low"),
|
|
# A model with no declared ladder is not second-guessed.
|
|
("xhigh", "gpt-5.6-luna", "xhigh"),
|
|
# Nothing to clamp.
|
|
(None, "system.ai.glm-5-2", None),
|
|
("xhigh", None, "xhigh"),
|
|
],
|
|
)
|
|
def test_clamp_spawn_effort_keeps_the_pairing_servable(
|
|
effort: str | None,
|
|
model: str | None,
|
|
expected: str | None,
|
|
) -> None:
|
|
assert clamp_spawn_effort(effort, model) == expected
|
|
|
|
|
|
def test_codex_effort_clamp_matches_the_runtime_clamp() -> None:
|
|
# The hook's stdlib-only copy must agree with the runtime's clamp, or a
|
|
# spawn and a turn on the same model would disagree about the effort.
|
|
for model in EXTENDED_CATALOG_MODELS.values():
|
|
for effort in ("low", "medium", "high", "xhigh", "max"):
|
|
assert clamp_spawn_effort(effort, model) == clamp_effort_for_model(effort, model)
|
|
|
|
|
|
def test_every_extended_model_declares_a_ladder_and_a_fallback() -> None:
|
|
# A model added to codex's catalog without both is one codex will refuse
|
|
# at some effort with nothing to coerce to.
|
|
for bare in EXTENDED_CATALOG_MODELS:
|
|
assert EXTENDED_MODEL_EFFORTS.get(bare)
|
|
fallback = EXTENDED_MODEL_DEFAULT_EFFORT.get(bare)
|
|
assert fallback in EXTENDED_MODEL_EFFORTS[bare]
|
|
|
|
|
|
def test_comparable_model_id_folds_prefix_dots_and_case() -> None:
|
|
assert comparable_model_id("databricks-gpt-5-6-luna") == "gpt-5-6-luna"
|
|
assert comparable_model_id("gpt-5.6-luna") == "gpt-5-6-luna"
|
|
assert comparable_model_id("system.ai.glm-5-2") == "glm-5-2"
|
|
assert comparable_model_id("Databricks-GPT-5-6-Sol[1M]") == "gpt-5-6-sol"
|
|
|
|
|
|
def test_catalog_id_translates_to_the_codex_slug() -> None:
|
|
assert codex_reachable_model_slug("databricks-gpt-5-6-luna", _CATALOG) == "gpt-5.6-luna"
|
|
assert codex_reachable_model_slug("databricks-gpt-5-6-sol", _CATALOG) == "gpt-5.6-sol"
|
|
assert codex_reachable_model_slug("databricks-gpt-5-5", _CATALOG) == "gpt-5.5"
|
|
|
|
|
|
def test_a_codex_slug_translates_to_itself() -> None:
|
|
assert codex_reachable_model_slug("gpt-5.6-luna", _CATALOG) == "gpt-5.6-luna"
|
|
|
|
|
|
def test_an_extended_catalog_id_is_already_the_slug() -> None:
|
|
"""glm is listed under its catalog spelling, so it must survive intact."""
|
|
assert codex_reachable_model_slug("system.ai.glm-5-2", _CATALOG) == "system.ai.glm-5-2"
|
|
assert codex_reachable_model_slug("glm-5-2", _CATALOG) == "system.ai.glm-5-2"
|
|
|
|
|
|
def test_an_id_no_row_names_is_not_reachable() -> None:
|
|
"""A pick outside the live catalog is a decline, not a switch to attempt."""
|
|
assert codex_reachable_model_slug("databricks-claude-opus-5", _CATALOG) is None
|
|
# An empty catalog names nothing, so nothing is reachable through it.
|
|
assert codex_reachable_model_slug("databricks-gpt-5-6-luna", []) is None
|
|
assert codex_reachable_model_slug("", _CATALOG) is None
|
|
|
|
|
|
def test_a_row_naming_a_separate_servable_id_matches_on_either_side() -> None:
|
|
options = [{"id": "gpt-5.5", "model": "databricks-gpt-5-5"}]
|
|
|
|
assert codex_reachable_model_slug("databricks-gpt-5-5", options) == "gpt-5.5"
|
|
assert codex_reachable_model_slug("gpt-5.5", options) == "gpt-5.5"
|
|
|
|
|
|
def test_malformed_rows_are_skipped() -> None:
|
|
options: list[object] = ["nonsense", {"model": "gpt-5.6-luna"}, {"id": " "}]
|
|
|
|
assert codex_reachable_model_slug("databricks-gpt-5-6-luna", options) is None # type: ignore[arg-type]
|
|
|
|
|
|
def test_catalog_prefixes_match_the_claude_vocabulary() -> None:
|
|
# Both hook-side vocabularies strip the same prefixes; a drift would make
|
|
# one harness resolve an id the other cannot.
|
|
assert _CATALOG_PREFIXES == _CLAUDE_PREFIXES
|
|
|
|
|
|
def test_catalog_prefixes_match_the_routing_defaults() -> None:
|
|
"""This module duplicates the prefix list to stay stdlib-only; keep it equal."""
|
|
from omnigent.server.smart_routing import MODEL_ID_PREFIXES
|
|
|
|
assert _CATALOG_PREFIXES == MODEL_ID_PREFIXES
|