Compare commits

...

2 Commits

Author SHA1 Message Date
Pat Sukprasert 80fb12f8a7 Merge remote-tracking branch 'origin/main' into revert-1279
# Conflicts:
#	omnigent/server/routes/sessions.py
#	web/src/components/AgentInfo.tsx
2026-07-08 17:05:00 +08:00
Pat Sukprasert b9f9a7fdf2 revert: back out codex-native --model launch flag + restart-with-model dialog (#1279)
Reverts PR #1279. Model selection can now be done right after fork as a
first action for codex, so the dedicated codex-native --model launch flag
and the "Restart with model…" fork dialog are no longer needed.

Backs out:
- Backend: the OMNIGENT_CODEX_NATIVE_MODEL_FLAG opt-in flag, the
  codex --help --model capability probe, and the explicit --model launch
  plumbing in codex_native_app_server.py; the fork route's model_override
  parameter, validation, and family-check (_agent_harness_id); the
  SessionForkRequest.model_override schema field and its store plumbing.
- Frontend: the codex-only RestartWithModelDialog and the AgentInfo
  "Restart with model…" trigger; forkSession's modelOverride param.
- The associated backend, store, vitest, and e2e-ui tests.

The always-on per-session config.toml `model =` pin and the pre-existing
session-level model_override field are untouched.

Resolved conflicts from the ap-web -> web frontend rename and later
main-branch changes to AgentInfo by re-applying the removal surgically on
top of current main rather than adopting the stale pre-PR text.

Verified: 202 backend tests (fork route, conversation store,
codex_native_app_server), 34 AgentInfo vitest, web tsc, and prettier all pass.

Co-authored-by: Isaac
2026-07-08 16:52:25 +08:00
17 changed files with 9 additions and 1301 deletions
+2 -111
View File
@@ -36,7 +36,6 @@ from omnigent.inner.codex_executor import (
_clean_codex_env,
_codex_cli_version,
_codex_home_config_source_from_env,
_create_subprocess_exec,
_databricks_codex_auth_command,
_databricks_codex_base_url,
_databricks_codex_config_overrides,
@@ -88,84 +87,6 @@ _TRUSTED_HOOK_STATUSES = frozenset({"trusted", "managed"})
# warning rather than crash startup on an un-trustable hook.
_MIN_POLICY_HOOK_CODEX_VERSION = (0, 129, 0)
# Opt-in flag for the explicit ``--model`` launch flag. Off by default: the
# per-session ``config.toml`` ``model =`` pin (``_pin_codex_config_model``)
# already routes the override today, so the explicit flag is a parallel,
# additive path the operator turns on per deployment. Truthy values mirror
# the ``_TRUE_VALUES`` convention used across the codebase
# (``omnigent/_startup_profile.py``, ``omnigent/cli.py``).
_MODEL_FLAG_ENV_VAR = "OMNIGENT_CODEX_NATIVE_MODEL_FLAG"
_MODEL_FLAG_TRUE_VALUES = frozenset({"1", "true", "yes", "on"})
# Timeout for the one-shot ``codex --help`` capability probe. Matches the
# ``codex --version`` probe budget -- a hung help invocation must never block
# app-server startup.
_CODEX_HELP_PROBE_TIMEOUT_SECONDS = 5.0
def _model_flag_enabled(env: dict[str, str] | None = None) -> bool:
"""
Return whether the explicit ``--model`` launch flag is opted in.
The flag is parallel to the always-on ``config.toml`` model pin, so it
defaults OFF: a deployment enables it by setting
:data:`_MODEL_FLAG_ENV_VAR` to a truthy value.
:param env: Environment mapping to inspect; defaults to ``os.environ``.
:returns: ``True`` when the override should also be passed as an
explicit ``--model`` launch flag.
"""
source = os.environ if env is None else env
return source.get(_MODEL_FLAG_ENV_VAR, "").strip().lower() in _MODEL_FLAG_TRUE_VALUES
async def _codex_supports_model_flag(codex_path: str) -> bool:
"""
Detect whether the codex CLI accepts a global ``--model`` flag.
Runs ``codex --help`` and looks for the ``--model`` long option in the
top-level options. Codex exposes ``-m/--model`` as a global flag that
precedes the ``app-server`` subcommand; builds that predate it omit the
option from ``--help``, so the caller skips the flag (passing an unknown
flag would error) and relies on the always-on ``config.toml`` pin.
:param codex_path: Path to the codex CLI, e.g.
``"/usr/local/bin/codex"``.
:returns: ``True`` when ``--model`` appears in ``codex --help`` output;
``False`` when it does not, or the probe cannot be run / times out
(treated conservatively as "unsupported" so the flag is not passed).
"""
try:
proc = await _create_subprocess_exec(
codex_path,
"--help",
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.DEVNULL,
)
except OSError:
return False
try:
stdout, _ = await asyncio.wait_for(
proc.communicate(), timeout=_CODEX_HELP_PROBE_TIMEOUT_SECONDS
)
except asyncio.TimeoutError:
# A hung ``codex --help`` must not block startup: kill it and treat
# the flag as unsupported (the config.toml pin still carries the model).
with contextlib.suppress(ProcessLookupError):
proc.kill()
with contextlib.suppress(Exception):
await proc.wait()
return False
# Match ``--model`` only as an option *definition* line, not anywhere the
# word appears in help prose. Clap renders options as an indented line
# whose first token is the option, e.g. `` -m, --model <MODEL>`` (or a
# long-only `` --model <MODEL>``). Anchor to the start of such a line
# — optional indent, an optional short alias (``-m, ``), then ``--model``
# at an option boundary. This rejects lookalikes (``--model-provider``)
# and descriptions that merely mention ``--model`` mid-sentence, either of
# which would otherwise pass an unsupported flag to the launch.
help_text = stdout.decode("utf-8", errors="replace")
return re.search(r"^\s*(?:-\S+,\s+)?--model(?=[\s=<]|$)", help_text, re.MULTILINE) is not None
def _format_codex_version(version: tuple[int, int, int] | None) -> str:
"""
@@ -649,30 +570,6 @@ class CodexNativeAppServer:
)
reconcile_codex_native_process_registry()
resolved_listen = self.listen_url or f"unix://{self.socket_path}"
proc_env = {**self.env, "CODEX_HOME": str(self.codex_home)}
# Opt-in, additive to the config.toml ``model =`` pin above: when the
# operator enables the flag and a model is pinned, ALSO pass it
# explicitly. ``-m/--model`` is a codex *global* option, so it must
# precede the ``app-server`` subcommand. A codex build that lacks the
# flag simply doesn't get it (passing an unknown flag would error) --
# the config.toml pin remains the primary route, so the session still
# launches on the right model regardless.
# Read the opt-in from the omnigent server's OWN process environment
# (``os.environ``, the default), NOT ``self.env``: ``self.env`` is the
# cleaned codex spawn env from ``_clean_codex_env``, whose prefix
# allowlist strips ``OMNIGENT_*`` keys -- so the flag would never be
# visible there. The flag is an operator knob for omnigent, not
# something codex itself consumes.
model_global_args: list[str] = []
if (
self.pinned_model
and _model_flag_enabled()
and await _codex_supports_model_flag(self.codex_path)
):
model_global_args = ["--model", self.pinned_model]
# argv[0] carries the inert crash-reap marker (the real binary is passed
# via ``executable=`` below); the model global option rides after it so
# codex still parses it ahead of the ``app-server`` subcommand.
self.process_registry_tag = f"codex-native-{uuid.uuid4().hex}"
tagged_argv0 = (
f"{Path(self.codex_path).name} "
@@ -680,22 +577,16 @@ class CodexNativeAppServer:
)
argv = [
tagged_argv0,
*model_global_args,
"app-server",
"--listen",
resolved_listen,
]
for override in self.config_overrides:
argv.extend(["-c", override])
proc_env = {**self.env, "CODEX_HOME": str(self.codex_home)}
self.process_owner_lock = acquire_codex_native_process_owner_lock()
try:
# Spawn through the module-level ``_create_subprocess_exec``
# indirection (a transparent passthrough to
# ``asyncio.create_subprocess_exec``) so tests can stub the spawn
# by patching that name — patching ``…app_server.asyncio.\
# create_subprocess_exec`` would walk into the real asyncio
# singleton and leak the mock across the process.
self.proc = await _create_subprocess_exec(
self.proc = await asyncio.create_subprocess_exec(
*argv,
stdin=asyncio.subprocess.DEVNULL,
stdout=asyncio.subprocess.DEVNULL,
+2 -12
View File
@@ -1084,22 +1084,12 @@ Request body matches `SessionForkRequest`:
source's full native transcript. When null or omitted, the full
history is copied.
model_override (string | null, optional)
Model id to launch the fork on ("restart with model"), e.g.
"databricks-gpt-5-4-mini". Overrides the model the fork would
otherwise inherit from the source; the value is validated and
family-checked against the fork's harness (a cross-family id —
e.g. a Claude model on a codex fork — is rejected with 400).
When null or omitted, the fork keeps the source's model (within
the same provider family).
201 Created — body matches `SessionResponse` (status "idle",
items are the deep-copied items from the source session).
400 Bad Request — source session is a sub-agent session, has
no agent binding, up_to_response_id names no response in
the source session, or model_override is invalid / not in the
fork harness's provider family
no agent binding, or up_to_response_id names no response in
the source session
404 Not Found — no session with that source_id, or the source's
agent row is missing
```
+1 -61
View File
@@ -95,7 +95,7 @@ from omnigent.harness_plugins import (
from omnigent.host.frames import (
HARNESS_NOT_CONFIGURED_ERROR_CODE as _HARNESS_NOT_CONFIGURED_ERROR_CODE,
)
from omnigent.model_override import model_family_mismatch, validate_model_override
from omnigent.model_override import validate_model_override
from omnigent.native_coding_agents import (
native_coding_agent_for_agent_name,
native_coding_agent_for_harness,
@@ -10285,34 +10285,6 @@ def _same_provider_family(a: Agent, b: Agent) -> bool:
return family_a is not None and family_a == _agent_provider_family(b)
def _agent_harness_id(agent: Agent) -> str | None:
"""Return an agent's canonical harness id, or ``None`` when unloadable.
Used to family-check a fork's explicit ``model_override`` against the
harness the fork will actually run (e.g. reject a Claude model on a
codex-native fork). ``None`` when the bundle can't be loaded — the
caller then skips the family guard (the runner's fail-loud launch
remains the safety net) rather than blocking the fork.
:param agent: The agent whose harness to resolve, e.g. the fork's
base agent.
:returns: The canonical harness id, e.g. ``"codex-native"``, or
``None`` when the bundle can't be loaded.
"""
try:
spec = (
get_agent_cache()
.load(agent.id, agent.bundle_location, expand_env=agent.session_id is None)
.spec
)
except Exception: # noqa: BLE001 — unloadable bundle → skip the family guard
return None
from omnigent.harness_aliases import canonicalize_harness
harness_kind = spec.executor.harness_kind
return canonicalize_harness(harness_kind) or harness_kind
def _agent_is_native(agent: Agent) -> bool:
"""Return whether an agent runs a native CLI harness.
@@ -15553,37 +15525,6 @@ def create_sessions_router(
cloned_agent_id = generate_agent_id()
cloned_agent_name = base_agent.name
# An explicit "restart with model" override for the fork. Validated
# (charset/length) and family-checked against the harness the fork
# will actually run, so a bad or cross-family id (e.g. a Claude model
# on a codex-native fork) fails loud here rather than after launch.
# Wins over the source's copied model in the store.
fork_model_override: str | None = None
if body.model_override is not None:
try:
fork_model_override = validate_model_override(body.model_override)
except ValueError as exc:
raise OmnigentError(
f"invalid model_override: {exc}",
code=ErrorCode.INVALID_INPUT,
) from exc
base_harness = await asyncio.to_thread(_agent_harness_id, base_agent)
# Fail CLOSED: if the fork harness can't be resolved we can't
# family-check the override, so reject rather than launch an
# unvalidated cross-family model. (Only when an override was
# actually supplied — a normal fork with no override is
# unaffected by an unloadable bundle here.)
if base_harness is None:
raise OmnigentError(
"cannot validate model_override: the fork's harness could not "
"be resolved. Retry without a model override to keep the "
"source's model.",
code=ErrorCode.INVALID_INPUT,
)
mismatch = model_family_mismatch(base_harness, fork_model_override)
if mismatch is not None:
raise OmnigentError(mismatch, code=ErrorCode.INVALID_INPUT)
# A model id is provider-bound, so the source's model_override /
# reasoning_effort only carry over when the switch stays in the same
# provider family. A cross-family switch (or an undeterminable
@@ -15646,7 +15587,6 @@ def create_sessions_router(
cloned_agent_bundle_location=base_agent.bundle_location,
cloned_agent_description=base_agent.description,
copy_model_settings=copy_model_settings,
model_override=fork_model_override,
carry_history_into_native=carry_history_into_native,
resume_source_native_session=resume_source_native_session,
presentation_labels=presentation_labels,
-7
View File
@@ -2033,18 +2033,11 @@ class SessionForkRequest(BaseModel):
the last item of that response are copied — items after it are
dropped from the fork. When ``None`` (default), the full history
is copied.
:param model_override: Model id to launch the fork on, e.g.
``"databricks-gpt-5-4-mini"`` — the "restart with model" path.
Overrides the model the fork would otherwise inherit from the
source; the value is validated and family-checked against the
fork's harness. When ``None`` (default), the fork keeps the
source's model (within the same provider family).
"""
title: str | None = None
agent_id: str | None = None
up_to_response_id: str | None = None
model_override: str | None = None
model_config = ConfigDict(extra="forbid")
@@ -1205,7 +1205,6 @@ class ConversationStore(ABC):
cloned_agent_bundle_location: str | None = None,
cloned_agent_description: str | None = None,
copy_model_settings: bool = True,
model_override: str | None = None,
carry_history_into_native: bool = False,
resume_source_native_session: bool = True,
presentation_labels: dict[str, str] | None = None,
@@ -1249,10 +1248,6 @@ class ConversationStore(ABC):
the bound agent's defaults — used when the fork switches to
an agent in a different provider family, where the source's
model id is meaningless (a model is provider-bound).
:param model_override: When set, the fork's ``model_override`` is
this value instead of the source's copied one — the "restart
with model" path. Wins over the ``copy_model_settings`` copy;
``None`` (default) leaves the copy behavior unchanged.
:param carry_history_into_native: When ``True``, stamp
:data:`FORK_CARRY_HISTORY_LABEL_KEY` on the fork so a native
target harness rebuilds its transcript (clone the source's
@@ -2391,7 +2391,6 @@ class SqlAlchemyConversationStore(ConversationStore):
cloned_agent_bundle_location: str | None = None,
cloned_agent_description: str | None = None,
copy_model_settings: bool = True,
model_override: str | None = None,
carry_history_into_native: bool = False,
resume_source_native_session: bool = True,
presentation_labels: dict[str, str] | None = None,
@@ -2447,13 +2446,6 @@ class SqlAlchemyConversationStore(ConversationStore):
the bound agent's defaults — used when the fork switches to
an agent in a different provider family, where the source's
model id is meaningless (a model is provider-bound).
:param model_override: When set, the fork's ``model_override`` is
this value instead of the source's copied one — the
"restart with model" path, where the whole point is to launch
the clone on a different model. Wins over the
``copy_model_settings`` copy; ``reasoning_effort`` still follows
``copy_model_settings`` (a same-family model switch keeps the
effort). ``None`` (default) leaves the copy behavior unchanged.
:param carry_history_into_native: When ``True``, stamp
:data:`FORK_CARRY_HISTORY_LABEL_KEY` on the fork so a native
target harness rebuilds its transcript instead of starting
@@ -2526,14 +2518,7 @@ class SqlAlchemyConversationStore(ConversationStore):
else (agent_id if agent_id is not None else source.agent_id)
),
reasoning_effort=source.reasoning_effort if copy_model_settings else None,
# An explicit override wins over the copied value — this is
# the "restart with model" launch model. Otherwise fall back
# to the source's copied model (gated by copy_model_settings).
model_override=(
model_override
if model_override is not None
else (source.model_override if copy_model_settings else None)
),
model_override=source.model_override if copy_model_settings else None,
# The brain-harness override is family-bound like the model,
# so it follows the same copy gate.
harness_override=source.harness_override if copy_model_settings else None,
-12
View File
@@ -3372,18 +3372,6 @@
"description": "Built-in agent to bind the fork to, switching it away from the source's agent/harness (e.g. fork a Claude session into a Codex one, or a Claude-SDK session into Claude Code). When `None`, the fork keeps the source's agent. Must be a built-in agent (one listed by `GET /v1/agents`).",
"title": "Agent Id"
},
"model_override": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"description": "Model id to launch the fork on, e.g. `\"databricks-gpt-5-4-mini\"` \u2014 the \"restart with model\" path. Overrides the model the fork would otherwise inherit from the source; the value is validated and family-checked against the fork's harness. When `None` (default), the fork keeps the source's model (within the same provider family).",
"title": "Model Override"
},
"title": {
"anyOf": [
{
@@ -890,7 +890,6 @@ class SessionsNamespace:
*,
title: str | None = None,
up_to_response_id: str | None = None,
model_override: str | None = None,
) -> dict[str, Any]:
"""
Fork an existing session into a new session.
@@ -907,27 +906,19 @@ class SessionsNamespace:
``"resp_abc123"``. When set, the fork copies history only
up to and including that response; ``None`` copies the
full history.
:param model_override: Optional model id to launch the fork on
("restart with model"), e.g. ``"databricks-gpt-5-4-mini"``.
Overrides the model the fork inherits from the source; the
server validates and family-checks it. ``None`` keeps the
source's model.
:returns: Raw response dict matching the ``SessionResponse``
shape: ``id``, ``agent_id``, ``status``, ``created_at``,
``title``, ``labels``, ``reasoning_effort``, and
``items``.
:raises OmnigentError: 404 if *source_session_id* does
not exist; 400 if the source has no agent binding,
*up_to_response_id* names no response in the source, or
*model_override* is invalid / cross-family for the fork.
not exist; 400 if the source has no agent binding or
*up_to_response_id* names no response in the source.
"""
body: dict[str, Any] = {}
if title is not None:
body["title"] = title
if up_to_response_id is not None:
body["up_to_response_id"] = up_to_response_id
if model_override is not None:
body["model_override"] = model_override
resp = await self._http.post(
f"{self._base}/v1/sessions/{source_session_id}/fork",
json=body,
@@ -1,182 +0,0 @@
"""Browser e2e: the codex-only "Restart with model…" affordance.
Codex applies its model at launch, not mid-turn, so "restart on a different
model" is a fork that carries history: the dialog drives the SAME
``POST /v1/sessions/{id}/fork`` path the Clone dialog uses, with an explicit
``model_override``. Two things only a browser can prove live here:
1. The trigger is gated on the codex (GPT) harness family — visible for a
codex-native session, absent for a non-codex (openai-agents) one.
2. The dialog's client-side validation gates submit (empty / unchanged /
flag-shaped model id all disabled), and on submit it forks with the chosen
``model_override`` and navigates into the clone.
The e2e harness only runs the seeded ``hello_world`` (openai-agents) agent —
there is no codex CLI. So, like ``test_codex_model_metadata.py``, this patches
only the browser's view of ``GET /v1/sessions/{id}/agent`` to report a codex
harness. The fork POST is left to hit the real server (openai-agents is
multi-model, so the server's family check passes), and the test asserts both
the request body and the resulting navigation.
"""
from __future__ import annotations
import json
import re
from urllib.parse import urlparse
from playwright.sync_api import Page, Route, expect
_COMPOSER = "Ask the agent anything…"
def _patch_agent_as_codex(page: Page, session_id: str) -> None:
"""Make the browser see *session_id*'s agent as codex-native.
Fetches the real ``GET /v1/sessions/{id}/agent`` response and overrides
only its ``harness`` so the UI's codex-family gate fires. Everything else
(name, mcp servers, policies) is preserved, so the agent-info popover
renders normally. Server-side behavior is untouched — this patch is
browser-scoped.
:param page: Playwright page, before navigation.
:param session_id: Source session id whose agent to recast, e.g.
``"conv_abc123"``.
"""
def _handle(route: Route) -> None:
request = route.request
if request.method != "GET" or urlparse(request.url).path != (
f"/v1/sessions/{session_id}/agent"
):
route.continue_()
return
response = route.fetch()
payload = response.json()
# The canonical codex-native spelling the family gate accepts.
payload["harness"] = "codex-native"
route.fulfill(
status=200,
headers={**response.headers, "content-type": "application/json"},
body=json.dumps(payload),
)
page.route(f"**/v1/sessions/{session_id}/agent", _handle)
def _capture_fork_requests(page: Page, session_id: str) -> list[dict]:
"""Record fork POST bodies for *session_id*, forwarding them to the server.
:param page: Playwright page, before navigation.
:param session_id: Source session id whose ``/fork`` POSTs to capture.
:returns: A list, appended to with each fork request body.
"""
bodies: list[dict] = []
def _handle(route: Route) -> None:
request = route.request
if request.method == "POST":
bodies.append(json.loads(request.post_data or "{}"))
route.continue_()
page.route(f"**/v1/sessions/{session_id}/fork", _handle)
return bodies
def _open_agent_info(page: Page) -> None:
"""Open the desktop agent-info popover from a known-closed state.
:param page: Playwright page on a ``/c/<id>`` route.
"""
page.keyboard.press("Escape")
trigger = page.get_by_test_id("agent-info-trigger")
expect(trigger).to_be_visible(timeout=30_000)
trigger.click()
# The "Policies" section label proves the popover content mounted.
expect(page.get_by_text("Policies", exact=True)).to_be_visible(timeout=15_000)
def test_restart_with_model_forks_codex_session(
page: Page,
seeded_session: tuple[str, str],
) -> None:
"""Codex session → trigger shows, dialog validates, submit forks + navigates.
Failure modes this catches:
- The codex-only trigger never renders (gate broken) or renders for the
wrong harness.
- The dialog lets an unchanged / flag-shaped model id through the submit
gate (the shared charset guard regressed client-side).
- Submit fails to send ``model_override`` on the fork POST, or doesn't
navigate into the clone.
:param page: Playwright page fixture.
:param seeded_session: ``(base_url, session_id)`` for a real server-backed
session; the browser snapshot is patched to codex-native.
"""
base_url, session_id = seeded_session
_patch_agent_as_codex(page, session_id)
fork_bodies = _capture_fork_requests(page, session_id)
page.goto(f"{base_url}/c/{session_id}")
expect(page.get_by_placeholder(_COMPOSER)).to_be_visible(timeout=30_000)
_open_agent_info(page)
# (1) The codex-only trigger is present for this codex-native session.
trigger = page.get_by_test_id("restart-with-model-trigger")
expect(trigger).to_be_visible(timeout=15_000)
trigger.click()
dialog = page.get_by_test_id("restart-model-dialog")
expect(dialog).to_be_visible(timeout=15_000)
model_input = page.get_by_test_id("restart-model-input")
submit = page.get_by_test_id("restart-model-submit")
# (2) Validation gating — the seeded session has no model override, so the
# field starts empty and submit is disabled.
expect(submit).to_be_disabled()
# A flag-shaped value fails the shared charset guard → still disabled.
model_input.fill("--evil")
expect(submit).to_be_disabled()
# A valid, different codex model id enables submit.
model_input.fill("databricks-gpt-5-4-mini")
expect(submit).to_be_enabled()
# (3) Submit forks with the chosen model and navigates into the clone.
submit.click()
expect(page).to_have_url(
re.compile(rf"/c/(?!{re.escape(session_id)})conv_[0-9a-f]+"),
timeout=30_000,
)
assert fork_bodies, "the dialog never issued a fork POST"
assert fork_bodies[-1].get("model_override") == "databricks-gpt-5-4-mini", (
f"fork must carry the chosen model_override; got {fork_bodies[-1]!r}"
)
def test_restart_with_model_hidden_for_non_codex(
page: Page,
seeded_session: tuple[str, str],
) -> None:
"""A non-codex (openai-agents) session never shows the restart trigger.
The seeded ``hello_world`` agent runs the openai-agents harness, which
applies its model per-turn — there is no launch-time restart. The trigger
must stay hidden so the affordance is offered only where it is honest.
:param page: Playwright page fixture.
:param seeded_session: ``(base_url, session_id)`` for a real server-backed
openai-agents session (left unpatched).
"""
base_url, session_id = seeded_session
page.goto(f"{base_url}/c/{session_id}")
expect(page.get_by_placeholder(_COMPOSER)).to_be_visible(timeout=30_000)
_open_agent_info(page)
# The popover mounted (Policies asserted in _open_agent_info), but the
# codex-only trigger must be absent for this harness.
expect(page.get_by_test_id("restart-with-model-trigger")).to_have_count(0)
-168
View File
@@ -130,7 +130,6 @@ class _ConversationStore:
cloned_agent_bundle_location: str | None = None,
cloned_agent_description: str | None = None,
copy_model_settings: bool = True,
model_override: str | None = None,
carry_history_into_native: bool = False,
resume_source_native_session: bool = True,
presentation_labels: dict[str, str] | None = None,
@@ -150,8 +149,6 @@ class _ConversationStore:
:param cloned_agent_description: Optional clone description.
:param copy_model_settings: Whether the source's model settings
carry over (route passes ``False`` on a cross-family switch).
:param model_override: Explicit "restart with model" override the
route passes through; ``None`` keeps the copied/source model.
:param carry_history_into_native: Whether to mark the fork for
native transcript rebuild (route passes ``True`` for any
native target, regardless of family).
@@ -178,7 +175,6 @@ class _ConversationStore:
"cloned_agent_bundle_location": cloned_agent_bundle_location,
"cloned_agent_description": cloned_agent_description,
"copy_model_settings": copy_model_settings,
"model_override": model_override,
"carry_history_into_native": carry_history_into_native,
"resume_source_native_session": resume_source_native_session,
"presentation_labels": presentation_labels,
@@ -217,11 +213,6 @@ class _ConversationStore:
root_conversation_id=fork_id,
title=title or f"Fork of {src.title}",
agent_id=effective_agent_id,
model_override=(
model_override
if model_override is not None
else (src.model_override if copy_model_settings else None)
),
)
def list_items(
@@ -1088,162 +1079,3 @@ async def test_fork_clone_reuses_source_agent_name_verbatim() -> None:
assert conv_store.fork_calls[0]["cloned_agent_name"] == "claude-native-ui", (
"Fork clone should reuse the source name verbatim, no '(fork …)' suffix"
)
@pytest.mark.asyncio
async def test_fork_with_model_override_passes_through(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""A fork with an explicit model_override plumbs it into the store call.
The "restart with model" path: the override is validated, family-checked
against the fork's (codex-native) harness, and handed to
``fork_conversation`` so the clone launches on the chosen model.
"""
conv = _make_conversation()
conv_store = _ConversationStore(
conversations={"conv_src": conv},
items_by_conv={"conv_src": [_make_item("msg_1", "Hi")]},
)
monkeypatch.setattr(
"omnigent.server.routes.sessions.get_agent_cache",
lambda: _StubAgentCache({"ag_test": "codex-native"}),
)
client = TestClient(_build_app(conv_store))
resp = client.post(
"/v1/sessions/conv_src/fork",
json={"model_override": "databricks-gpt-5-4-mini"},
)
assert resp.status_code == 201, f"Expected 201, got {resp.status_code}: {resp.text}"
fork_call = conv_store.fork_calls[0]
assert fork_call["model_override"] == "databricks-gpt-5-4-mini", (
"The validated override must reach fork_conversation so the clone "
"launches on the chosen model."
)
@pytest.mark.asyncio
async def test_fork_with_invalid_model_override_rejected(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""A shell-/flag-shaped model_override is rejected before any fork."""
conv = _make_conversation()
conv_store = _ConversationStore(
conversations={"conv_src": conv},
items_by_conv={"conv_src": [_make_item("msg_1", "Hi")]},
)
monkeypatch.setattr(
"omnigent.server.routes.sessions.get_agent_cache",
lambda: _StubAgentCache({"ag_test": "codex-native"}),
)
client = TestClient(_build_app(conv_store))
resp = client.post(
"/v1/sessions/conv_src/fork",
json={"model_override": "--evil"},
)
assert resp.status_code == 400, f"Expected 400, got {resp.status_code}: {resp.text}"
assert conv_store.fork_calls == [], "No fork should be created on a bad override."
@pytest.mark.asyncio
async def test_fork_with_cross_family_model_override_rejected(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""A Claude model on a codex-native fork fails the family guard (400).
codex stays single-vendor (GPT-only), so a Claude id can never route —
reject it at the fork gate instead of after a doomed launch.
"""
conv = _make_conversation()
conv_store = _ConversationStore(
conversations={"conv_src": conv},
items_by_conv={"conv_src": [_make_item("msg_1", "Hi")]},
)
monkeypatch.setattr(
"omnigent.server.routes.sessions.get_agent_cache",
lambda: _StubAgentCache({"ag_test": "codex-native"}),
)
client = TestClient(_build_app(conv_store))
resp = client.post(
"/v1/sessions/conv_src/fork",
json={"model_override": "databricks-claude-opus-4-8"},
)
assert resp.status_code == 400, f"Expected 400, got {resp.status_code}: {resp.text}"
assert conv_store.fork_calls == [], "No fork should be created on a family mismatch."
@pytest.mark.asyncio
async def test_fork_model_override_rejected_when_harness_unresolvable(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""An override fork fails CLOSED when the fork harness can't be resolved.
If ``_agent_harness_id`` can't load the fork's bundle it returns ``None``;
the family guard then has nothing to check against. Rather than launch an
unvalidated (possibly cross-family) model, the route must reject — a bad
bundle must not become a hole in the family check.
"""
conv = _make_conversation()
conv_store = _ConversationStore(
conversations={"conv_src": conv},
items_by_conv={"conv_src": [_make_item("msg_1", "Hi")]},
)
# Harness loads fine for the OTHER route paths; only the override family
# check sees None (simulating an unloadable / unresolvable fork bundle).
monkeypatch.setattr(
"omnigent.server.routes.sessions.get_agent_cache",
lambda: _StubAgentCache({"ag_test": "codex-native"}),
)
monkeypatch.setattr(
"omnigent.server.routes.sessions._agent_harness_id",
lambda _agent: None,
)
client = TestClient(_build_app(conv_store))
resp = client.post(
"/v1/sessions/conv_src/fork",
json={"model_override": "databricks-gpt-5-4-mini"},
)
assert resp.status_code == 400, f"Expected 400, got {resp.status_code}: {resp.text}"
assert conv_store.fork_calls == [], (
"No fork should be created when the override can't be family-checked."
)
@pytest.mark.asyncio
async def test_fork_unresolvable_harness_ok_without_model_override(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""A normal fork (no override) is unaffected by an unresolvable harness.
The fail-closed guard only fires when an explicit ``model_override`` is
supplied; a plain fork must still succeed even if the harness id can't be
resolved (it isn't needed without an override to validate).
"""
conv = _make_conversation()
conv_store = _ConversationStore(
conversations={"conv_src": conv},
items_by_conv={"conv_src": [_make_item("msg_1", "Hi")]},
)
monkeypatch.setattr(
"omnigent.server.routes.sessions.get_agent_cache",
lambda: _StubAgentCache({"ag_test": "codex-native"}),
)
monkeypatch.setattr(
"omnigent.server.routes.sessions._agent_harness_id",
lambda _agent: None,
)
client = TestClient(_build_app(conv_store))
resp = client.post("/v1/sessions/conv_src/fork", json={})
assert resp.status_code == 201, f"Expected 201, got {resp.status_code}: {resp.text}"
assert len(conv_store.fork_calls) == 1
assert conv_store.fork_calls[0]["model_override"] is None
-33
View File
@@ -3514,39 +3514,6 @@ def test_fork_conversation_copy_model_settings_false_resets(
assert reloaded_default.reasoning_effort == "high"
def test_fork_conversation_model_override_wins_over_copy(
conversation_store: SqlAlchemyConversationStore,
agent_store: SqlAlchemyAgentStore,
) -> None:
"""An explicit ``model_override`` overrides the source's copied model.
The "restart with model" path: the fork must launch on the requested
model, not the source's. ``reasoning_effort`` still follows
``copy_model_settings`` (a same-family model switch keeps the effort).
"""
agent_store.create(
agent_id="ag_fork_mo",
name="fork-mo",
bundle_location="ag_fork_mo/fakehash",
)
source = conversation_store.create_conversation(agent_id="ag_fork_mo")
conversation_store.update_conversation(
source.id, reasoning_effort="high", model_override="databricks-gpt-5-5"
)
fork = conversation_store.fork_conversation(
source.id, model_override="databricks-gpt-5-4-mini"
)
reloaded = conversation_store.get_conversation(fork.id)
assert reloaded is not None
assert reloaded.model_override == "databricks-gpt-5-4-mini", (
"An explicit model_override must win over the source's copied model."
)
# reasoning_effort still copies (same-family switch keeps the effort).
assert reloaded.reasoning_effort == "high"
def test_fork_conversation_carry_history_into_native_stamps_label(
conversation_store: SqlAlchemyConversationStore,
agent_store: SqlAlchemyAgentStore,
-346
View File
@@ -862,349 +862,3 @@ class TestPinCodexConfigModel:
# read_codex_config_model resolves codex-home under the bridge dir.
_pin_codex_config_model(home, "databricks-gpt-5-4-mini")
assert read_codex_config_model(bridge_dir) == "databricks-gpt-5-4-mini"
class TestModelFlagHelpers:
"""Unit coverage for the explicit ``--model`` launch-flag helpers."""
@pytest.mark.parametrize(
("value", "expected"),
[
("1", True),
("true", True),
("YES", True),
("on", True),
("0", False),
("false", False),
("", False),
("maybe", False),
],
)
def test_model_flag_enabled_reads_truthy_env(self, value: str, expected: bool) -> None:
"""The opt-in flag honors the shared truthy-string convention."""
from omnigent.codex_native_app_server import (
_MODEL_FLAG_ENV_VAR,
_model_flag_enabled,
)
assert _model_flag_enabled({_MODEL_FLAG_ENV_VAR: value}) is expected
def test_model_flag_disabled_when_env_absent(self) -> None:
"""An unset flag defaults OFF (config.toml pin remains the only route)."""
from omnigent.codex_native_app_server import _model_flag_enabled
assert _model_flag_enabled({}) is False
async def test_supports_model_flag_true_when_help_lists_it(
self, monkeypatch: pytest.MonkeyPatch
) -> None:
"""``--model`` in ``codex --help`` output → flag supported."""
from omnigent.codex_native_app_server import _codex_supports_model_flag
async def _fake_exec(*_args: Any, **_kwargs: Any) -> Any:
return _HelpProc(b"Options:\n -m, --model <MODEL>\n Model to use\n")
monkeypatch.setattr("omnigent.codex_native_app_server._create_subprocess_exec", _fake_exec)
assert await _codex_supports_model_flag("/usr/bin/codex") is True
async def test_supports_model_flag_false_when_help_omits_it(
self, monkeypatch: pytest.MonkeyPatch
) -> None:
"""A codex build whose ``--help`` lacks ``--model`` → unsupported."""
from omnigent.codex_native_app_server import _codex_supports_model_flag
async def _fake_exec(*_args: Any, **_kwargs: Any) -> Any:
return _HelpProc(b"Options:\n -c, --config <key=value>\n")
monkeypatch.setattr("omnigent.codex_native_app_server._create_subprocess_exec", _fake_exec)
assert await _codex_supports_model_flag("/usr/bin/codex") is False
async def test_supports_model_flag_false_when_probe_cannot_run(
self, monkeypatch: pytest.MonkeyPatch
) -> None:
"""An OSError spawning the probe is treated as unsupported (flag skipped)."""
from omnigent.codex_native_app_server import _codex_supports_model_flag
async def _fake_exec(*_args: Any, **_kwargs: Any) -> Any:
raise OSError("no codex")
monkeypatch.setattr("omnigent.codex_native_app_server._create_subprocess_exec", _fake_exec)
assert await _codex_supports_model_flag("/usr/bin/codex") is False
async def test_supports_model_flag_ignores_lookalike_options_and_prose(
self, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Only a real ``--model`` option definition counts, not lookalikes.
A build without ``--model`` may still mention a ``--model-provider``
flag or the word in prose; the matcher must not false-positive on
either and pass an unsupported flag to the launch.
"""
from omnigent.codex_native_app_server import _codex_supports_model_flag
async def _fake_exec(*_args: Any, **_kwargs: Any) -> Any:
return _HelpProc(
b"Options:\n"
b" --model-provider <ID>\n"
b" Override the default model provider\n"
b" -c, --config <key=value>\n"
b" e.g. set the --model in config.toml\n"
)
monkeypatch.setattr("omnigent.codex_native_app_server._create_subprocess_exec", _fake_exec)
assert await _codex_supports_model_flag("/usr/bin/codex") is False
@dataclass
class _HelpProc:
"""Minimal fake process for the ``codex --help`` capability probe.
:param out: Bytes returned as the probe's stdout.
"""
out: bytes
async def communicate(self) -> tuple[bytes, bytes]:
"""Return the scripted stdout (stderr is discarded by the probe)."""
return self.out, b""
def kill(self) -> None:
"""No-op kill (the probe only kills on timeout, untested here)."""
async def wait(self) -> int:
"""Return a success exit code."""
return 0
@dataclass
class _SpawnRecorder:
"""Captures the argv + env handed to ``create_subprocess_exec`` in start().
Stands in for the real app-server subprocess so a startup unit test can
assert how the explicit ``--model`` flag is plumbed without spawning
codex. Exposes just the surface ``start`` and ``_stderr_loop`` touch.
"""
argv: tuple[str, ...] | None = None
env: dict[str, str] | None = None
returncode: int | None = None
async def _record(self, *argv: str, env: dict[str, str], **_kwargs: Any) -> _SpawnRecorder:
self.argv = argv
self.env = env
return self
@property
def stderr(self) -> None:
"""No stderr stream — the patched ``_stderr_loop`` never reads it."""
return None
async def wait(self) -> int:
"""Return the (already terminated) exit code."""
return 0
async def _model_flag_app_server(
tmp_path: Path,
*,
codex_path: str,
model: str | None,
env: dict[str, str],
) -> CodexNativeAppServer:
"""Build an app-server wrapper for the ``--model`` launch-flag tests.
:param tmp_path: Test temp dir.
:param codex_path: Codex executable path recorded into the argv.
:param model: Session-pinned model, or ``None``.
:param env: Spawn env (carries the opt-in flag).
:returns: Configured wrapper (not yet started).
"""
codex_home = tmp_path / "codex-home"
bridge_dir = tmp_path / "bridge"
workspace = tmp_path / "workspace"
workspace.mkdir()
return CodexNativeAppServer(
codex_path=codex_path,
socket_path=tmp_path / "codex.sock",
codex_home=codex_home,
env=env,
config_overrides=[],
cwd=workspace,
bridge_dir=bridge_dir,
python_executable="/new/python",
pinned_model=model,
)
def _patch_start_spawn(monkeypatch: pytest.MonkeyPatch, recorder: _SpawnRecorder) -> None:
"""Stub the subprocess spawn + readiness waits used by ``start()``.
The version probe is stubbed to an old (pre-policy-hook) codex so
``start`` skips hook registration — fewer side effects — and so its own
subprocess spawn never reaches the recorder. The recorder is wired only
to the final app-server spawn.
The spawn is captured by patching the module-level
``_create_subprocess_exec`` indirection (which ``start`` now calls),
NOT ``…app_server.asyncio.create_subprocess_exec`` — the latter walks the
real asyncio module singleton and leaks the mock into every other test in
the process.
:param monkeypatch: Pytest monkeypatch fixture.
:param recorder: Recorder whose ``_record`` captures the spawn argv/env.
"""
_disable_codex_startup_rpc(monkeypatch)
_set_codex_version(monkeypatch, (0, 100, 0))
monkeypatch.setattr(
"omnigent.codex_native_app_server._create_subprocess_exec", recorder._record
)
# The crash-reap registration path (added alongside this flag) is exercised
# by the process-registry tests; these flag tests only assert argv/env, so
# skip registration by denying the owner lock (the recorder has no pid).
monkeypatch.setattr(
"omnigent.codex_native_app_server.acquire_codex_native_process_owner_lock",
lambda: None,
)
async def _noop_stderr(self: CodexNativeAppServer) -> None:
return None
monkeypatch.setattr(CodexNativeAppServer, "_stderr_loop", _noop_stderr)
class TestModelFlagPlumbing:
"""``start()`` plumbs the override per the opt-in flag and CLI support."""
async def test_flag_off_omits_model_flag_and_env(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""With the opt-in off, no ``--model`` flag is passed.
The config.toml pin (asserted below) remains the only route.
"""
from omnigent.codex_native_app_server import _MODEL_FLAG_ENV_VAR
# The opt-in is read from the server's own process env (os.environ);
# ensure it isn't ambiently set so "off" is genuinely off.
monkeypatch.delenv(_MODEL_FLAG_ENV_VAR, raising=False)
recorder = _SpawnRecorder()
_patch_start_spawn(monkeypatch, recorder)
server = await _model_flag_app_server(
tmp_path, codex_path="/usr/bin/codex", model="databricks-gpt-5-4-mini", env={}
)
await server.start()
assert recorder.argv is not None
assert "--model" not in recorder.argv
# config.toml pin still seeds the model regardless of the flag.
assert 'model = "databricks-gpt-5-4-mini"' in (
server.codex_home / "config.toml"
).read_text(encoding="utf-8")
async def test_flag_on_with_cli_support_passes_global_model_flag(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Opt-in + a codex that supports ``--model`` → global ``--model <id>``.
The flag must precede the ``app-server`` subcommand (it is a codex
global option).
"""
from omnigent.codex_native_app_server import _MODEL_FLAG_ENV_VAR
async def _supports(_codex_path: str) -> bool:
return True
monkeypatch.setattr(
"omnigent.codex_native_app_server._codex_supports_model_flag", _supports
)
# The opt-in lives in the server's process env, NOT the cleaned codex
# spawn env (env={}): _clean_codex_env strips OMNIGENT_* keys, so a
# flag passed via env= would never be seen in production.
monkeypatch.setenv(_MODEL_FLAG_ENV_VAR, "1")
recorder = _SpawnRecorder()
_patch_start_spawn(monkeypatch, recorder)
server = await _model_flag_app_server(
tmp_path,
codex_path="/usr/bin/codex",
model="databricks-gpt-5-4-mini",
env={},
)
await server.start()
assert recorder.argv is not None
argv = list(recorder.argv)
assert "--model" in argv
model_idx = argv.index("--model")
assert argv[model_idx + 1] == "databricks-gpt-5-4-mini"
# Global option: precedes the subcommand.
assert model_idx < argv.index("app-server")
async def test_flag_on_without_cli_support_skips_model_flag(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Opt-in + a codex lacking ``--model`` -> no flag (config.toml pin carries it).
Passing an unknown flag would error, so an unsupported codex simply
doesn't get ``--model``; the always-on config.toml pin still launches
it on the right model.
"""
from omnigent.codex_native_app_server import _MODEL_FLAG_ENV_VAR
async def _unsupported(_codex_path: str) -> bool:
return False
monkeypatch.setattr(
"omnigent.codex_native_app_server._codex_supports_model_flag", _unsupported
)
# Opt-in lives in the server process env, not the cleaned spawn env.
monkeypatch.setenv(_MODEL_FLAG_ENV_VAR, "1")
recorder = _SpawnRecorder()
_patch_start_spawn(monkeypatch, recorder)
server = await _model_flag_app_server(
tmp_path,
codex_path="/usr/bin/codex",
model="databricks-gpt-5-4-mini",
env={},
)
await server.start()
assert recorder.argv is not None
assert "--model" not in recorder.argv
# config.toml pin still seeds the model regardless of the flag.
assert 'model = "databricks-gpt-5-4-mini"' in (
server.codex_home / "config.toml"
).read_text(encoding="utf-8")
async def test_flag_in_spawn_env_alone_does_not_enable(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""The flag in the cleaned spawn env (``self.env``) must NOT enable it.
Regression guard: ``self.env`` is the ``_clean_codex_env`` output,
whose prefix allowlist strips ``OMNIGENT_*`` keys, so the opt-in can
only arrive via the server's own ``os.environ``. If the gate ever
reverts to reading ``self.env``, this fails: the flag would appear to
work in a unit test that injects it via ``env=`` but be dead in prod.
"""
from omnigent.codex_native_app_server import _MODEL_FLAG_ENV_VAR
async def _supports(_codex_path: str) -> bool:
return True
monkeypatch.setattr(
"omnigent.codex_native_app_server._codex_supports_model_flag", _supports
)
# NOT set in os.environ -- only smuggled into the spawn env.
monkeypatch.delenv(_MODEL_FLAG_ENV_VAR, raising=False)
recorder = _SpawnRecorder()
_patch_start_spawn(monkeypatch, recorder)
server = await _model_flag_app_server(
tmp_path,
codex_path="/usr/bin/codex",
model="databricks-gpt-5-4-mini",
env={_MODEL_FLAG_ENV_VAR: "1"},
)
await server.start()
assert recorder.argv is not None
assert "--model" not in recorder.argv
-40
View File
@@ -684,43 +684,3 @@ describe("agentDisplayLabel", () => {
expect(agentDisplayLabel("polly (fork conv_ab12)")).toBe("Polly");
});
});
// ---------------------------------------------------------------------------
// "Restart with model…" trigger — codex-only affordance gated on harness.
// ---------------------------------------------------------------------------
function renderContentForAgent(agent: Agent, sessionId: string) {
const qc = new QueryClient({
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
});
return render(
<QueryClientProvider client={qc}>
<TooltipProvider>
<AgentInfoContent agent={agent} sessionId={sessionId} />
</TooltipProvider>
</QueryClientProvider>,
);
}
describe("AgentInfoContent restart-with-model trigger", () => {
it("shows the trigger for a codex-native session", () => {
renderContentForAgent(
{ id: "ag_codex", name: "codex-native-ui", harness: "codex-native" },
"conv_codex",
);
expect(screen.getByTestId("restart-with-model-trigger")).toBeInTheDocument();
});
it("hides the trigger for a non-codex (claude) harness", () => {
renderContentForAgent(
{ id: "ag_claude", name: "claude-native-ui", harness: "claude-native" },
"conv_claude",
);
expect(screen.queryByTestId("restart-with-model-trigger")).not.toBeInTheDocument();
});
it("hides the trigger when the harness is unknown (not yet loaded)", () => {
renderContentForAgent({ id: "ag_x", name: "mystery" }, "conv_x");
expect(screen.queryByTestId("restart-with-model-trigger")).not.toBeInTheDocument();
});
});
-39
View File
@@ -52,21 +52,9 @@ import { agentRootName } from "@/lib/forkHarness";
import { nativeCodingAgentForAgentName } from "@/lib/nativeCodingAgents";
import { copyText } from "@/lib/clipboard";
import { useChatStore } from "@/store/chatStore";
import { RestartWithModelDialog } from "@/shell/RestartWithModelDialog";
import { useServerInfo } from "@/lib/CapabilitiesContext";
import { useSessionHostVersion } from "@/hooks/RunnerHealthProvider";
/**
* Whether a harness id is in the codex (GPT) family — the only harness the
* "Restart with model…" affordance is offered for. Both the canonical and
* reversed native spellings count, mirroring the server's
* ``_CODEX_FAMILY_HARNESSES``. ``null`` / undefined (harness not loaded) is
* not codex, so the affordance stays hidden until the harness is known.
*/
function isCodexHarness(harness: string | null | undefined): boolean {
return harness === "codex" || harness === "codex-native" || harness === "native-codex";
}
/**
* Display label for an agent name: the wrapper alias when mapped, else
* the name capital-first (server agent names are lowercase slugs, e.g.
@@ -1131,12 +1119,6 @@ export function AgentInfoContent({ agent, sessionId }: AgentInfoProps) {
// which case the row is omitted rather than showing a placeholder.
const { data: owner } = useSessionOwner(sessionId ?? null);
const viewerId = getCurrentUserId();
// The session's current model override, prefilled into the restart dialog.
const sessionModelOverride = useChatStore((s) => s.sessionModelOverride);
// "Restart with model…" is codex-only: codex applies its model at launch
// (no mid-turn switch), so a model change is a fork that carries history.
const showRestartWithModel = isCodexHarness(agent?.harness) && !!sessionId;
const [restartOpen, setRestartOpen] = useState(false);
// Only surface the owner once the session is actually shared — a private
// solo session has no "owner" worth showing. A non-owner viewer already
// implies a share; the owner needs the grant list (manage-only, readable by
@@ -1237,27 +1219,6 @@ export function AgentInfoContent({ agent, sessionId }: AgentInfoProps) {
)}
</div>
)}
{showRestartWithModel && sessionId && (
<div className="flex flex-col gap-1.5 py-3">
<SectionLabel>Model</SectionLabel>
<Button
type="button"
variant="outline"
size="sm"
data-testid="restart-with-model-trigger"
onClick={() => setRestartOpen(true)}
className="justify-start text-xs"
>
Restart with model
</Button>
<RestartWithModelDialog
sessionId={sessionId}
currentModel={sessionModelOverride}
open={restartOpen}
onOpenChange={setRestartOpen}
/>
</div>
)}
<McpServersSection sessionId={sessionId} servers={servers} editable={mcpEditable} />
{sessionId && <SessionPoliciesSection sessionId={sessionId} />}
{versionFooter && (
+1 -14
View File
@@ -494,24 +494,14 @@ export async function createBundledSession(
* @param upToResponseId - Optional truncation point, e.g. "resp_abc". When
* set, the fork copies history only up to and including that response
* ("fork from here"); omitted, the full history is copied.
* @param modelOverride - Optional model id to launch the fork on, e.g.
* "databricks-gpt-5-4-mini" — the "restart with model" path. Overrides
* the model the fork would inherit from the source; the server validates
* and family-checks it. Omitted → keep the source's model.
*/
export async function forkSession(
sourceId: string,
title?: string,
agentId?: string,
upToResponseId?: string,
modelOverride?: string,
): Promise<Session> {
const body: {
title?: string;
agent_id?: string;
up_to_response_id?: string;
model_override?: string;
} = {};
const body: { title?: string; agent_id?: string; up_to_response_id?: string } = {};
if (title !== undefined) {
body.title = title;
}
@@ -521,9 +511,6 @@ export async function forkSession(
if (upToResponseId !== undefined) {
body.up_to_response_id = upToResponseId;
}
if (modelOverride !== undefined) {
body.model_override = modelOverride;
}
const res = await authenticatedFetch(`/v1/sessions/${encodeURIComponent(sourceId)}/fork`, {
method: "POST",
headers: { "Content-Type": "application/json" },
@@ -1,95 +0,0 @@
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { TooltipProvider } from "@/components/ui/tooltip";
import { RestartWithModelDialog } from "./RestartWithModelDialog";
import { forkSession } from "@/lib/sessionsApi";
const navigateMock = vi.fn();
vi.mock("@/lib/routing", () => ({ useNavigate: () => navigateMock }));
vi.mock("@/lib/sessionsApi", () => ({ forkSession: vi.fn() }));
const forkSessionMock = vi.mocked(forkSession);
function renderDialog(currentModel: string | null = "databricks-gpt-5-5") {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return render(
<QueryClientProvider client={client}>
<TooltipProvider>
<RestartWithModelDialog
sessionId="conv_src"
currentModel={currentModel}
open
onOpenChange={() => {}}
/>
</TooltipProvider>
</QueryClientProvider>,
);
}
describe("RestartWithModelDialog", () => {
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(cleanup);
it("forks with the chosen model_override and navigates into the clone", async () => {
forkSessionMock.mockResolvedValue({ id: "conv_forked" } as Awaited<
ReturnType<typeof forkSession>
>);
renderDialog("databricks-gpt-5-5");
const input = screen.getByTestId("restart-model-input");
fireEvent.change(input, { target: { value: "databricks-gpt-5-4-mini" } });
fireEvent.click(screen.getByTestId("restart-model-submit"));
await waitFor(() => {
expect(forkSessionMock).toHaveBeenCalledWith(
"conv_src",
undefined,
undefined,
undefined,
"databricks-gpt-5-4-mini",
);
});
await waitFor(() => {
expect(navigateMock).toHaveBeenCalledWith("/c/conv_forked");
});
});
it("disables submit until a different, valid model is entered", () => {
renderDialog("databricks-gpt-5-5");
const submit = screen.getByTestId("restart-model-submit");
// Prefilled with the current model → unchanged, so submit is disabled.
expect(submit).toBeDisabled();
// A flag-shaped value fails the charset guard → still disabled.
fireEvent.change(screen.getByTestId("restart-model-input"), {
target: { value: "--evil" },
});
expect(submit).toBeDisabled();
// A different, valid id enables submit.
fireEvent.change(screen.getByTestId("restart-model-input"), {
target: { value: "databricks-gpt-5-4-mini" },
});
expect(submit).not.toBeDisabled();
});
it("surfaces a fork error inline without navigating", async () => {
forkSessionMock.mockRejectedValue(new Error("harness 'codex-native' only runs GPT models"));
renderDialog("databricks-gpt-5-5");
fireEvent.change(screen.getByTestId("restart-model-input"), {
target: { value: "databricks-claude-opus-4-8" },
});
fireEvent.click(screen.getByTestId("restart-model-submit"));
await waitFor(() => {
expect(screen.getByTestId("restart-model-error")).toHaveTextContent("only runs GPT models");
});
expect(navigateMock).not.toHaveBeenCalled();
});
});
-149
View File
@@ -1,149 +0,0 @@
import { useState } from "react";
import { useNavigate } from "@/lib/routing";
import { useQueryClient } from "@tanstack/react-query";
import { InfoIcon } from "lucide-react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { forkSession } from "@/lib/sessionsApi";
// Conservative model-id charset, kept in sync with the server's
// `omnigent.model_override._MODEL_ID_RE`: a leading alphanumeric (so the
// value can never read as a CLI flag) then dots / underscores / colons /
// slashes / brackets / dashes. Catches obvious typos client-side; the
// server re-validates and family-checks regardless.
const MODEL_ID_RE = /^[A-Za-z0-9][A-Za-z0-9._:/[\]-]*$/;
/**
* Compact, codex-only "Restart with model…" dialog.
*
* Codex applies its model at launch, not mid-turn — there is no in-flight
* model switch. So "restarting on a different model" is a fork that carries
* the conversation history: this dialog drives the SAME
* ``POST /v1/sessions/{id}/fork`` path the Clone dialog uses (the server
* deep-copies the transcript and a codex-native target rebuilds its native
* transcript), passing an explicit ``model_override`` so the clone launches
* on the chosen model. The original session is untouched.
*
* Deliberately minimal (Option 1): a single model-id field + honest copy.
* Not the full sidebar kebab menu. The model is a free-text id (e.g.
* ``databricks-gpt-5-4-mini``) validated against the shared model-id charset;
* the server is the authority on whether the id is routable for codex.
*
* @param sessionId - The codex-native session to restart.
* @param currentModel - The session's current model override, prefilled into
* the field (so the user edits rather than retypes). ``null`` starts empty.
* @param open - Whether the dialog is visible.
* @param onOpenChange - Visibility setter (Radix-controlled).
*/
export function RestartWithModelDialog({
sessionId,
currentModel,
open,
onOpenChange,
}: {
sessionId: string;
currentModel?: string | null;
open: boolean;
onOpenChange: (open: boolean) => void;
}) {
const navigate = useNavigate();
const queryClient = useQueryClient();
const [model, setModel] = useState(currentModel ?? "");
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const trimmed = model.trim();
// Enable submit only for a non-empty, charset-valid, *different* model —
// restarting on the identical model is a no-op fork the user didn't mean.
const canSubmit =
trimmed !== "" && MODEL_ID_RE.test(trimmed) && trimmed !== (currentModel ?? "").trim();
async function handleRestart(): Promise<void> {
if (!canSubmit) return;
setSubmitting(true);
setError(null);
try {
// Reuse the fork carry-history path with an explicit model override —
// NOT a new restart mechanism. omit title/agent so the server keeps
// the source's agent and derives "Fork of <title>".
const fork = await forkSession(sessionId, undefined, undefined, undefined, trimmed);
// Fire-and-forget: the sidebar refresh must not gate navigation.
void queryClient.invalidateQueries({ queryKey: ["conversations"] });
onOpenChange(false);
navigate(`/c/${fork.id}`);
} catch (e) {
// Nothing was created — leave the field editable for a resubmit. The
// server's validation / family-mismatch error surfaces here verbatim.
setError(e instanceof Error ? e.message : "Couldn't restart on that model. Try again.");
} finally {
setSubmitting(false);
}
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent data-testid="restart-model-dialog" className="flex flex-col gap-4 sm:max-w-md">
<DialogHeader>
<DialogTitle>Restart with model</DialogTitle>
<DialogDescription>
Starts a new session on the chosen model, carrying this conversation's history. The
model applies at launch — Codex can't switch model mid-turn. Your current session is
left untouched.
</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-1.5">
<label
htmlFor="restart-model-input"
className="text-xs font-medium text-muted-foreground"
>
Model
</label>
<Input
id="restart-model-input"
data-testid="restart-model-input"
value={model}
onChange={(e) => setModel(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && !submitting && canSubmit) handleRestart();
}}
placeholder="databricks-gpt-5-4-mini"
autoFocus
className="font-mono text-xs"
/>
<p className="flex items-start gap-1.5 text-xs text-muted-foreground">
<InfoIcon className="mt-0.5 size-3.5 shrink-0" />
<span>Enter a Codex (GPT) model id. The original session keeps its model.</span>
</p>
</div>
{error !== null && (
<p data-testid="restart-model-error" className="text-xs text-destructive">
{error}
</p>
)}
<DialogFooter>
<Button variant="ghost" onClick={() => onOpenChange(false)} disabled={submitting}>
Cancel
</Button>
<Button
data-testid="restart-model-submit"
onClick={handleRestart}
disabled={submitting || !canSubmit}
>
{submitting ? "Restarting…" : "Restart"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}