fix(claude-native): cold-resume a session on its persisted canonical model (#5167)

A pane's /model persists the exact id it runs (claude-opus-4-8), but the launch gate only accepted an exact catalog row, and a direct-login catalog spells that family as alias rows (opus -> claude-opus-5, the appended claude-opus-4-8[1m] default). Every live switch to a non-default model therefore armed a resume failure: "not in this host's current model list".

Accept a canonical Anthropic id when the endpoint serves canonical spellings and the catalog lists the id's family — the same fold /model already applies to an unpinned canonical id — and keep refusing gateways, Bedrock, and unlisted families so a stale pick still fails fast.

Covers the cold resume of a persisted pick for claude and codex in the live model-flows suite (red on claude before the fix, green after; codex has no alias layer and passes both ways), plus unit coverage of the fold and the launch gate.

Closes #5158
This commit is contained in:
Corey Zumar
2026-08-21 00:47:49 -07:00
committed by GitHub
parent 67f9db8bc8
commit 3d537bba59
6 changed files with 536 additions and 7 deletions
+51
View File
@@ -415,6 +415,57 @@ def _serves_canonical_anthropic_ids(claude_config: ClaudeNativeUcodeConfig) -> b
return host == "anthropic.com" or host.endswith(".anthropic.com")
def _claude_family(token: str) -> str | None:
"""
The family alias a model id or alias folds onto, bracket markers dropped.
:param token: A picker id or model id, e.g. ``"opus[1m]"``,
``"claude-opus-4-8"``.
:returns: The family alias, e.g. ``"opus"``, or ``None`` for none.
"""
from omnigent.claude_model_vocabulary import claude_model_alias
alias = claude_model_alias(token, {})
return alias.partition("[")[0] if alias else None
def claude_catalog_serves_model(
rows: list[dict[str, object]],
model: str,
claude_config: ClaudeNativeUcodeConfig | None,
) -> bool:
"""
Whether a launch of *model* is backed by this config's catalog.
An exact row — a picker id or its wire model — always serves. A canonical
Anthropic id no row spells exactly still launches when the endpoint takes
canonical spellings (``--model`` passes any string through, and a pane's
``/model`` persists exactly this id) and the catalog lists the id's
family: the same family fold ``/model`` applies to an unpinned canonical
id. A gateway that routes only its own ids, and a family the catalog
does not list, refuse — a genuinely stale pick still fails fast.
:param rows: Catalog rows, e.g.
``[{"id": "opus", "model": "claude-opus-5"}]``.
:param model: A picker id or model id, e.g. ``"claude-opus-4-8"``.
:param claude_config: The resolved launch config, or ``None`` (Claude's
own login).
:returns: ``True`` when the launch can run *model* against this catalog.
"""
from omnigent.model_catalog_store import catalog_contains
if catalog_contains(rows, model):
return True
if claude_config is not None and not _serves_canonical_anthropic_ids(claude_config):
return False
if not model.lower().startswith("claude-"):
return False
family = _claude_family(model)
return family is not None and any(
_claude_family(str(row.get("id") or row.get("model") or "")) == family for row in rows
)
def resolve_claude_native_model_selection(
model: str | None,
claude_config: ClaudeNativeUcodeConfig | None,
+6 -4
View File
@@ -6486,8 +6486,8 @@ async def _auto_create_claude_terminal(
# or to resolve a Default launch that would otherwise pass no ``--model``
# and leave the model to invisible CLI-private state.
if session_model_override or launch_model is None:
from omnigent.claude_native import claude_launch_catalog
from omnigent.model_catalog_store import catalog_contains, default_row
from omnigent.claude_native import claude_catalog_serves_model, claude_launch_catalog
from omnigent.model_catalog_store import default_row
launch_catalog: list[dict[str, object]] | None = None
try:
@@ -6501,9 +6501,11 @@ async def _auto_create_claude_terminal(
resolve_claude_native_model_selection(session_model_override, claude_config)
or session_model_override
)
# A pane's ``/model`` persists the exact id it runs; the catalog
# may spell that model only by its family alias.
if not (
catalog_contains(launch_catalog, session_model_override)
or catalog_contains(launch_catalog, resolved_request)
claude_catalog_serves_model(launch_catalog, session_model_override, claude_config)
or claude_catalog_serves_model(launch_catalog, resolved_request, claude_config)
):
raise click.ClickException(
f"the requested model {session_model_override!r} is not in this "
+110 -1
View File
@@ -12,7 +12,8 @@ the sandbox config per test group, mirroring how ``omnigent setup`` flips the
Every test drives the product the way a person uses it: the browser drives the
rig server's real SPA (Playwright sync API), and harness truth is read where a
user reads it — the tmux pane and the harness's own on-disk state. REST reads
are secondary probes only.
are secondary probes only, and REST writes are reserved for the rows whose
actor is not the browser (a REPL ``/model``, a routing pin, an API client).
"""
from __future__ import annotations
@@ -522,6 +523,24 @@ class PaneWatcher:
)
def kill_pane(pane: PaneWatcher) -> None:
"""
End the session's pane the way an idle reap or a host restart does.
Kills the pane's whole tmux server, so the harness process goes with it
and the runner's next turn has to re-create the terminal (its cold-resume
launch path).
:param pane: The session's discovered pane.
"""
assert pane.socket is not None, "call wait_for_pane first"
subprocess.run(
["tmux", "-S", str(pane.socket), "kill-server"],
capture_output=True,
timeout=15,
)
def codex_config_copy_model(session_id: str) -> str | None:
"""Return the ``model =`` line of a codex session's private config copy.
@@ -552,6 +571,96 @@ def codex_config_copy_model(session_id: str) -> str | None:
return None
# ---------------------------------------------------------------------------
# REST driving (the SPA's own calls, for rows whose actor is not the browser)
# ---------------------------------------------------------------------------
def rest_create_session(
base_url: str,
*,
agent_name: str,
host_id: str,
workspace: Path,
terminal_launch_args: list[str] | None = None,
) -> str:
"""
Create a host-spawned native session with the create call the SPA makes.
:param base_url: Rig server base URL.
:param agent_name: Built-in wrapper agent, e.g. ``"claude-native-ui"``.
:param host_id: Host to launch on.
:param workspace: Absolute workspace path on that host.
:param terminal_launch_args: Pass-through CLI args, e.g. :func:`bypass_args`.
:returns: The new session id.
"""
agents = httpx.get(f"{base_url}/v1/agents", timeout=30)
agents.raise_for_status()
agent_id = next((a["id"] for a in agents.json()["data"] if a["name"] == agent_name), None)
assert agent_id is not None, f"{agent_name!r} is not registered on the rig server"
body: dict[str, Any] = {"agent_id": agent_id, "host_id": host_id, "workspace": str(workspace)}
if terminal_launch_args:
body["terminal_launch_args"] = list(terminal_launch_args)
resp = httpx.post(f"{base_url}/v1/sessions", json=body, timeout=120)
assert resp.status_code < 400, f"create failed {resp.status_code}: {resp.text[:2000]}"
return str(resp.json()["id"])
def rest_patch_session(base_url: str, session_id: str, **fields: Any) -> dict[str, Any]:
"""
PATCH session fields — a REPL ``/model`` and an API client write this way.
A native model change is forwarded to the pane and answered only once the
harness confirmed it, so the call may take a while.
:param base_url: Rig server base URL.
:param session_id: Session id.
:param fields: Wire fields, e.g. ``model_override="claude-opus-4-8"``.
:returns: The updated session payload.
"""
resp = httpx.patch(f"{base_url}/v1/sessions/{session_id}", json=fields, timeout=180)
assert resp.status_code < 400, (
f"PATCH {sorted(fields)} failed {resp.status_code}: {resp.text[:2000]}"
)
return dict(resp.json())
def rest_post_user_message(base_url: str, session_id: str, text: str) -> None:
"""
POST a user message the way the composer does.
:param base_url: Rig server base URL.
:param session_id: Session id.
:param text: The message text.
"""
resp = httpx.post(
f"{base_url}/v1/sessions/{session_id}/events",
json={
"type": "message",
"data": {"role": "user", "content": [{"type": "input_text", "text": text}]},
},
timeout=60,
)
assert resp.status_code < 400, f"message POST failed {resp.status_code}: {resp.text[:1000]}"
def assistant_message_count(snapshot: dict[str, Any]) -> int:
"""
Count the assistant messages a session snapshot carries.
:param snapshot: A ``GET /v1/sessions/{id}`` payload.
:returns: The number of assistant ``message`` items.
"""
count = 0
for item in snapshot.get("items") or []:
if not isinstance(item, dict) or item.get("type") != "message":
continue
data = item.get("data") if isinstance(item.get("data"), dict) else item
if data.get("role") == "assistant":
count += 1
return count
# ---------------------------------------------------------------------------
# Browser driving (sync Playwright over the rig server's real SPA)
# ---------------------------------------------------------------------------
+174 -2
View File
@@ -1,8 +1,9 @@
"""Live model-flow CUJs (model-flows-design.md §10.1, the "UI-live" tier).
Every test drives the product the way a person uses it — the browser drives the
rig server's real SPA, and harness truth is read from the tmux pane — with REST
snapshots as secondary probes only. Assertions encode the DESIGN's target
rig server's real SPA (or, for the rows whose actor is a REPL, a routing pin,
or an API client, the same REST calls), and harness truth is read from the
tmux pane — with REST snapshots as secondary probes only. Assertions encode the DESIGN's target
behavior, so this suite is red on unmodified main (and partially red on the PR
branch) in exactly the ways `model-flows-report.md` cites, and goes green as
the landing-order steps land. Run one row's red twin with::
@@ -18,20 +19,30 @@ from __future__ import annotations
import re
import time
from collections.abc import Callable, Iterator
from pathlib import Path
from typing import Any
import httpx
import pytest
from omnigent.native_coding_agents import CLAUDE_NATIVE_AGENT_NAME, CODEX_NATIVE_AGENT_NAME
from tests.e2e.omnigent._model_flows_rig import (
ModelFlowsRig,
PaneWatcher,
Ui,
assistant_message_count,
booted_rig,
browser_ui,
bypass_args,
codex_config_copy_model,
dismiss_blocking_dialogs,
host_model_options,
kill_pane,
require_clis,
require_opt_in,
rest_create_session,
rest_patch_session,
rest_post_user_message,
session_snapshot,
shape_providers,
)
@@ -551,3 +562,164 @@ def test_row9_codex_subscription_default_first_turn_succeeds(
f"stale-config-line class (finding B). Pane:\n{settled[-600:]}\n"
f"(config-copy pin: {codex_config_copy_model(session_id)!r})"
)
# ---------------------------------------------------------------------------
# Cold resume: the persisted pick survives the pane (claude + codex)
# ---------------------------------------------------------------------------
#: Host shape, wrapper agent, and pane-ready marker per harness.
_COLD_RESUME_HARNESSES: dict[str, tuple[str, str, str]] = {
"claude-native": ("claude-subscription", CLAUDE_NATIVE_AGENT_NAME, r""),
"codex-native": ("codex-subscription", CODEX_NATIVE_AGENT_NAME, r""),
}
def _resume_override(harness: str, rows: list[dict[str, Any]]) -> str | None:
"""
The model to persist, spelled the way the harness itself reports it.
claude: a canonical Anthropic id no row spells exactly — the plain twin of
a listed ``[1m]`` model. The endpoint serves it (the 1M marker is a request
flag on the same model) and the pane reports exactly that id after a
``/model`` to it; only the catalog's alias rows know its family.
codex: a non-default row id — codex has no alias layer, so the harness's
own report IS a catalog id.
:param harness: ``"claude-native"`` or ``"codex-native"``.
:param rows: The host's pre-launch catalog rows.
:returns: The override to persist, or ``None`` when the catalog offers
no such spelling.
"""
listed = {str(v) for row in rows for v in (row.get("id"), row.get("model")) if v}
if harness == "claude-native":
for row in rows:
model = str(row.get("model") or "")
if model.startswith("claude-") and model.endswith("[1m]") and model[:-4] not in listed:
return model[:-4]
return None
for row in rows:
if row.get("isDefault") is True or row.get("hidden") is True:
continue
row_id = row.get("id")
if isinstance(row_id, str) and row_id:
return row_id
return None
def _same_model(reported: object, wanted: str) -> bool:
"""
Whether a harness report names *wanted* (a dated suffix allowed)."""
if not isinstance(reported, str) or not reported:
return False
lhs, rhs = reported.lower(), wanted.lower()
return lhs == rhs or lhs.startswith(f"{rhs}-")
@pytest.mark.timeout(900)
@pytest.mark.parametrize("harness", sorted(_COLD_RESUME_HARNESSES))
def test_row18_cold_resume_honors_the_persisted_model_override(
rig: ModelFlowsRig,
shaped_host: Callable[[str], str],
harness: str,
tmp_path: Path,
) -> None:
"""
A session resumes on the persisted model the harness was already running.
The pane exits (idle reap, host restart) and the next message re-creates
the terminal. The relaunch must pass the persisted model straight through
— the host demonstrably served it moments earlier — instead of refusing
it as "not in this host's current model list" because the catalog spells
the same model differently (an alias row carries the family, the override
the canonical id).
"""
shape, agent_name, ready = _COLD_RESUME_HARNESSES[harness]
require_clis(harness.split("-")[0])
host_id = shaped_host(shape)
def _rows() -> list[dict[str, Any]] | None:
try:
payload = host_model_options(rig.base_url, host_id, harness)
except httpx.HTTPError:
return None
return payload.get("models") or None
rows = wait_for(_rows, timeout=180.0, what=f"the boot-warmed {harness} catalog")
override = _resume_override(harness, rows)
if override is None:
pytest.skip(f"this {harness} catalog offers no spelling to exercise: {rows}")
workspace = tmp_path / "ws"
workspace.mkdir()
(workspace / "README.md").write_text("# cold resume workspace\n")
pane = PaneWatcher()
pane.arm()
session_id = rest_create_session(
rig.base_url,
agent_name=agent_name,
host_id=host_id,
workspace=workspace,
terminal_launch_args=bypass_args(harness),
)
rest_post_user_message(rig.base_url, session_id, "Reply with exactly: ok. Nothing else.")
pane.wait_for_pane()
pane.wait_for_text(ready)
dismiss_blocking_dialogs(pane)
def _replies(at_least: int) -> Callable[[], int | None]:
def _count() -> int | None:
snapshot = session_snapshot(rig.base_url, session_id)
assert snapshot.get("status") != "failed", (
f"session {session_id} failed: "
f"{snapshot.get('last_task_error') or snapshot.get('error')}"
)
count = assistant_message_count(snapshot)
return count if count >= at_least else None
return _count
def _reported_override() -> str | None:
reported = session_snapshot(rig.base_url, session_id).get("llm_model")
return str(reported) if _same_model(reported, override) else None
wait_for(_replies(1), timeout=150.0, what="the first reply")
# The actor is a REPL ``/model``, a routing pin, or an API client: they
# persist the harness's own spelling. The pane switches live — this host
# serves the model — and reports it back verbatim.
rest_patch_session(rig.base_url, session_id, model_override=override)
rest_post_user_message(rig.base_url, session_id, "Reply with exactly: switched. Nothing else.")
wait_for(_replies(2), timeout=150.0, what="the reply on the switched model")
wait_for(_reported_override, timeout=60.0, what=f"the harness to report {override!r}")
# The pane exits; the next message is the resume.
kill_pane(pane)
resumed = PaneWatcher()
resumed.arm()
rest_post_user_message(rig.base_url, session_id, "Reply with exactly: back. Nothing else.")
wait_for(_replies(3), timeout=240.0, what="the reply after the cold resume")
snapshot = session_snapshot(rig.base_url, session_id)
assert snapshot.get("model_override") == override, (
f"the resume rewrote the persisted pick: {snapshot.get('model_override')!r}"
)
assert _same_model(snapshot.get("llm_model"), override), (
f"the resumed pane runs {snapshot.get('llm_model')!r}, not the persisted "
f"{override!r} (session {session_id})"
)
resumed.wait_for_pane()
pane_text = resumed.wait_for_text(ready)
if harness == "codex-native":
pinned = codex_config_copy_model(session_id)
assert pinned == override, (
f"the relaunch pinned {pinned!r} in the config copy, not {override!r}"
)
else:
family = override.removeprefix("claude-").split("-")[0]
footer = next((line for line in pane_text.splitlines() if "" in line), "")
assert family in footer.lower(), (
f"the resumed pane footer {footer!r} does not name the {family} family "
f"of {override!r} (session {session_id})"
)
@@ -10,6 +10,7 @@ from dataclasses import dataclass
from pathlib import Path
from typing import Any
import click
import httpx
import pytest
@@ -3182,3 +3183,122 @@ def test_routed_spawn_launch_args_need_a_router() -> None:
assert note and tools
assert _routed_spawn_launch_args(True, router_started=False) == (None, ())
assert _routed_spawn_launch_args(False) == (None, ())
@pytest.mark.asyncio
@pytest.mark.parametrize("endpoint", ["subscription", "gateway"])
async def test_auto_create_claude_terminal_launch_gate_folds_a_canonical_override(
endpoint: str,
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""
A persisted canonical id the catalog lists only by family still launches.
A live ``/model`` persists the pane's exact id (``claude-opus-4-8``) while
the catalog spells that family as alias rows and the 1M default. On a
canonical endpoint the relaunch must pass the id through as ``--model``
rather than refuse the resume; a gateway, which routes only its own
spellings, keeps refusing it.
"""
from omnigent.claude_native import ClaudeNativeUcodeConfig
monkeypatch.setattr(claude_native_bridge, "_TRUSTED_PARENT", tmp_path)
monkeypatch.setattr(claude_native_bridge, "_BRIDGE_ROOT", tmp_path / "root")
monkeypatch.setenv("RUNNER_SERVER_URL", "http://127.0.0.1:8000")
async def _no_op_forwarder(**kwargs: Any) -> None:
del kwargs
monkeypatch.setattr(
"omnigent.claude_native_forwarder.supervise_forwarder",
_no_op_forwarder,
)
prefix = "" if endpoint == "subscription" else "system.ai."
catalog = [
{"id": "opus", "model": f"{prefix}claude-opus-5", "displayName": "Opus 5"},
{
"id": f"{prefix}claude-opus-4-8[1m]",
"model": f"{prefix}claude-opus-4-8[1m]",
"displayName": "Opus 4.8 (1M context)",
"isDefault": True,
},
]
async def _catalog(config: object) -> list[dict[str, object]]:
del config
return catalog
monkeypatch.setattr("omnigent.claude_native.claude_launch_catalog", _catalog)
captured: dict[str, Any] = {}
class _FakeResourceRegistry:
"""Captures the launched terminal spec."""
terminal_registry = None
async def launch_required_terminal(
self,
*,
session_id: str,
terminal_name: str,
session_key: str,
spec: Any,
resource_role: str | None = None,
parent_os_env: Any = None,
) -> SessionResourceView:
"""Record the spec and return a terminal resource view."""
del terminal_name, session_key
captured["spec"] = spec
return SessionResourceView(
id="terminal_claude_main",
type="terminal",
session_id=session_id,
name="claude:main",
metadata={"terminal_name": "claude", "session_key": "main", "running": True},
)
def _handle_request(_request: httpx.Request) -> httpx.Response:
return httpx.Response(200, json={"model_override": "claude-opus-4-8", "labels": {}})
fake_client = httpx.AsyncClient(
base_url="http://test-server",
transport=httpx.MockTransport(_handle_request),
)
config = (
None
if endpoint == "subscription"
else ClaudeNativeUcodeConfig(
env={"ANTHROPIC_BASE_URL": "https://gateway.example/anthropic"},
api_key_helper="printf %s sk-gateway",
model="system.ai.claude-opus-5",
)
)
async def _resolve() -> ClaudeNativeUcodeConfig | None:
return config
session_id = "0f2d3d5c9a6b4e1f8c7d6e5f4a3b2c1d"
if endpoint == "subscription":
await _auto_create_claude_terminal(
session_id,
_FakeResourceRegistry(),
lambda _sid, _evt: None,
server_client=fake_client,
resolve_launch_config=_resolve,
)
args = captured["spec"].args
assert args[args.index("--model") + 1] == "claude-opus-4-8"
else:
with pytest.raises(click.ClickException, match="not in this host's current model list"):
await _auto_create_claude_terminal(
session_id,
_FakeResourceRegistry(),
lambda _sid, _evt: None,
server_client=fake_client,
resolve_launch_config=_resolve,
)
assert "spec" not in captured, "a refused launch must not start a terminal"
await fake_client.aclose()
+75
View File
@@ -9570,3 +9570,78 @@ async def test_probe_claude_model_options_returns_none_on_probe_failure(
monkeypatch.setattr("asyncio.create_subprocess_exec", _fake_exec)
assert await claude_native.probe_claude_model_options(_gateway_probe_config()) is None
def _subscription_catalog() -> list[dict[str, object]]:
"""
A direct-login catalog: alias rows plus the appended settings default."""
return [
{"id": "sonnet", "model": "claude-sonnet-5", "displayName": "Sonnet 5"},
{"id": "opus", "model": "claude-opus-5", "displayName": "Opus 5"},
{"id": "fable", "model": "fable", "displayName": "fable"},
{"id": "opus[1m]", "model": "claude-opus-5[1m]", "displayName": "Opus 5 (1M context)"},
{
"id": "claude-opus-4-8[1m]",
"model": "claude-opus-4-8[1m]",
"displayName": "Opus 4.8 (1M context)",
"isDefault": True,
},
]
@pytest.mark.parametrize(
("model", "config", "served"),
[
# Exact rows always serve: a picker id, a wire model, the appended default.
("opus", None, True),
("claude-sonnet-5", None, True),
("claude-opus-4-8[1m]", None, True),
# A canonical id the endpoint serves but no row spells: the default's
# plain twin, an older generation, the bare id behind a bare alias row,
# a 1M request on a listed family.
("claude-opus-4-8", None, True),
("claude-sonnet-4-5-20250929", None, True),
("claude-fable-5", None, True),
("claude-sonnet-5[1m]", None, True),
# Anthropic's own endpoint behind a key serves canonical ids too.
(
"claude-opus-4-8",
claude_native.ClaudeNativeUcodeConfig(
env={"ANTHROPIC_BASE_URL": "https://api.anthropic.com"},
api_key_helper="printf sk-key",
),
True,
),
# A family the catalog does not list is a genuinely stale pick.
("claude-haiku-4-5", None, False),
("claude-mythos-5", None, False),
# Not a canonical Anthropic id: only an exact row could serve it.
("gpt-5.4", None, False),
("", None, False),
# Gateways and Bedrock route their own spellings only.
(
"claude-opus-4-8",
claude_native.ClaudeNativeUcodeConfig(
env={"ANTHROPIC_BASE_URL": "https://gateway.example/anthropic"},
api_key_helper="printf sk-key",
),
False,
),
(
"claude-opus-4-8",
claude_native.ClaudeNativeUcodeConfig(
env={"ANTHROPIC_BEDROCK_BASE_URL": "https://bedrock.example"},
api_key_helper=None,
),
False,
),
],
)
def test_claude_catalog_serves_model(
model: str, config: claude_native.ClaudeNativeUcodeConfig | None, served: bool
) -> None:
"""
Exact rows serve; a canonical id serves on a canonical endpoint when its family is listed."""
assert (
claude_native.claude_catalog_serves_model(_subscription_catalog(), model, config) is served
)