Compare commits

...

9 Commits

Author SHA1 Message Date
Pat Sukprasert 7f2f7cdebd fix(codex-native): drop dead CODEX_MODEL env fallback
Live verification against codex-cli 0.140.0-alpha.2 showed codex does not
read a CODEX_MODEL env var (no reference in the native binary), so the
fallback path (set CODEX_MODEL when codex lacks the global --model flag)
was dead code resting on a false premise.

Remove the fallback branch and the _CODEX_MODEL_ENV_VAR constant. On a
codex build without --model the flag is simply not passed (passing an
unknown flag would error); the always-on config.toml model pin still
launches the session on the right model, so nothing is stranded. Updated
comments/docstrings and the plumbing test accordingly. 40 passed.

Co-authored-by: Isaac
2026-06-26 14:13:58 +07:00
Pat Sukprasert 2d806e8bf2 fix(codex-native): spawn app-server via _create_subprocess_exec indirection
The model-flag plumbing tests patched
`omnigent.codex_native_app_server.asyncio.create_subprocess_exec`, which
walks the real asyncio module singleton and leaks the mock across the
process — caught by the `no-global-asyncio-patch` pre-commit hook.

Route start()'s app-server spawn through the module-level
`_create_subprocess_exec` passthrough (already imported and used by the
help probe), and patch THAT in `_patch_start_spawn`. Transparent in
production (the wrapper just forwards to asyncio.create_subprocess_exec);
the other start() tests that spawn for real are unaffected. 40 passed.

Co-authored-by: Isaac
2026-06-26 13:48:46 +07:00
Pat Sukprasert f41bfdf663 style(ap-web): prettier-format RestartWithModelDialog
The new dialog's JSX wrapping didn't match prettier, failing ap-web
format:check (the lint half of the "tests and lints" job). Reflow the
DialogDescription text and the model <label> attributes to prettier's
print width; no behavior change. Full vitest suite stays green
(3120 passed).

Co-authored-by: Isaac
2026-06-26 13:35:09 +07:00
Pat Sukprasert 7db610b3f8 test(e2e-ui): cover the codex-only "Restart with model…" affordance
Satisfies the E2E UI coverage gate for the frontend change. Two browser
tests under tests/e2e_ui/fork_session/:

- test_restart_with_model_forks_codex_session: a codex-native session shows
  the trigger, the dialog gates submit (empty / flag-shaped id disabled,
  valid different id enabled), and submitting forks with the chosen
  model_override and navigates into the clone.
- test_restart_with_model_hidden_for_non_codex: the trigger stays hidden for
  the seeded openai-agents session (per-turn model, no launch restart).

The e2e harness has no codex CLI, so — mirroring test_codex_model_metadata —
this patches only the browser's GET /v1/sessions/{id}/agent to report a codex
harness; the fork POST hits the real server (openai-agents is multi-model so
the family check passes) and the test asserts the request body + navigation.

Co-authored-by: Isaac
2026-06-26 13:18:37 +07:00
Pat Sukprasert 3aec0e8250 fix(codex-native): read --model opt-in flag from os.environ, not cleaned spawn env
The OMNIGENT_CODEX_NATIVE_MODEL_FLAG gate read the opt-in from self.env,
which in production is the cleaned codex spawn env built by
_clean_codex_env(). That filter is a prefix allowlist with no OMNIGENT_
prefix (only exact OMNIGENT), so the flag is always stripped and the
explicit --model launch path could never activate — the feature was
inert in any real deployment. The config.toml model pin still routed the
override, so nothing broke; the new path just did nothing.

Read the flag from the omnigent server's own os.environ (the
_model_flag_enabled default) — it's an operator knob for omnigent, not
something codex consumes.

Tests: the plumbing tests injected the flag via env= (self.env),
bypassing _clean_codex_env, so they passed against the broken gate. Set
the flag via os.environ instead, and add a regression guard
(test_flag_in_spawn_env_alone_does_not_enable) that fails if the gate
ever reverts to reading self.env.

Co-authored-by: Isaac
2026-06-26 13:08:51 +07:00
Pat Sukprasert 7bd2f59e2b Merge origin/main into polly/wsE-model-override
Re-resolve AgentInfo.tsx import-region conflict after an overnight main change
added useServerInfo / useSessionHostVersion imports alongside #1279's
RestartWithModelDialog import + isCodexHarness helper. Keep all three imports
and the helper. Verified: 45 AgentInfo vitest + ap-web tsc pass.

Co-authored-by: Isaac
2026-06-26 07:08:56 +07:00
Pat Sukprasert 3945ea6fb0 Merge origin/main into polly/wsE-model-override
Resolve conflicts after #1252 (crash-safe teardown) and #1250 landed:

- codex_native_app_server.py: combine the #1279 --model launch flag with
  #1252's crash-reap spawn. argv[0] keeps #1252's inert tagged marker (real
  binary via executable=); the model global option rides after it so codex
  still parses it before the app-server subcommand. Single shared proc_env
  (may carry the CODEX_MODEL fallback); keep #1252's owner-lock + register
  spawn block.
- AgentInfo.tsx: keep both new panel sections (Restart-with-model and
  IntelligentRoutingSection).
- AgentInfo.test.tsx: keep both describe blocks (close the restart-with-model
  block at the join; the shared trailing braces close the routing block).
- test_codex_native_app_server.py: the model-flag plumbing tests only assert
  argv/env, so deny the owner lock to skip #1252's registration path (whose
  recorder stub has no pid); registration itself is covered by the
  process-registry tests.

Verified: 39 app_server + 173 fork/store backend tests, 42 AgentInfo vitest,
and ap-web tsc all pass.

Co-authored-by: Isaac
2026-06-25 23:26:20 +07:00
Pat Sukprasert 9e2ef9ddb5 fix(codex-native): fail closed when fork model_override can't be family-checked
The fork route's `model_family_mismatch` guard only ran when `_agent_harness_id`
resolved the fork's harness; when the bundle was unloadable it returned None and
the family check was skipped, letting an explicit `model_override` fork proceed
UNVALIDATED (a fail-open hole). Now, when an override is supplied AND the fork
harness can't be resolved, the route rejects with a 400 instead of launching an
unvalidated (possibly cross-family) model. A normal fork with no override is
unaffected.

Also tightens `_codex_supports_model_flag` to match `--model` only as an
option-definition line (anchored, optional short alias) rather than a loose
substring, so help prose / `--model-provider` lookalikes don't false-positive
into passing an unsupported flag.

Tests: route rejects an override fork when the harness is unresolvable, and a
no-override fork still succeeds; help-probe ignores lookalike options/prose;
AgentInfo shows the restart trigger only for codex harnesses (hidden for
claude / unknown).

Co-authored-by: Isaac
2026-06-25 20:25:35 +07:00
Pat Sukprasert 8d008571ed feat(codex-native): explicit --model launch flag + restart-with-model dialog
Adds a feature-flagged, explicit `--model` launch flag for codex-native,
parallel to the existing per-session config.toml `model =` pin (which stays
the always-on primary route). The flag is opt-in via
`OMNIGENT_CODEX_NATIVE_MODEL_FLAG`; when on and a model is pinned, the
app-server launch passes `--model <id>` as a codex global option (probed via
`codex --help`), falling back to a `CODEX_MODEL` env var when the CLI build
lacks the flag.

Adds a compact, codex-only "Restart with model…" dialog that reuses the
existing `POST /sessions/{id}/fork` carry-history path with an explicit
`model_override` — no new restart mechanism. Codex applies its model at
launch (not mid-turn), so the dialog copy is honest about that and the
original session is untouched. The override is validated and family-checked
against the fork's harness server-side.

Backend tests: flag detection, plumbing, env fallback (codex_native_app_server);
fork model_override pass-through / invalid / cross-family rejection (route);
override-wins-over-copy (store). FE test: the dialog forks with the chosen
model, gates submit, and surfaces errors inline.

Co-authored-by: Isaac
2026-06-25 20:09:26 +07:00
17 changed files with 1306 additions and 9 deletions
+45
View File
@@ -51,6 +51,11 @@ vi.mock("@/lib/identity", async (importOriginal) => ({
getCurrentUserId: () => viewerData.current,
}));
vi.mock("@/lib/clipboard", () => ({ copyText: copyTextMock }));
// The codex-only "Restart with model…" dialog mounts (closed) inside
// AgentInfoContent for codex sessions; stub its routing + fork deps so it
// renders without a Router/network in jsdom.
vi.mock("@/lib/routing", () => ({ useNavigate: () => vi.fn() }));
vi.mock("@/lib/sessionsApi", () => ({ forkSession: vi.fn() }));
// The version footer reads the server version (capabilities probe) and the
// per-session host version (health poll). Mock both hooks so the footer
@@ -676,6 +681,46 @@ describe("agentDisplayLabel", () => {
});
});
// ---------------------------------------------------------------------------
// "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();
});
});
// ---------------------------------------------------------------------------
// Intelligent routing section
// ---------------------------------------------------------------------------
+39
View File
@@ -57,9 +57,21 @@ 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.
@@ -1164,6 +1176,12 @@ export function AgentInfoContent({
// 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);
useEffect(() => {
return () => {
@@ -1250,6 +1268,27 @@ export function AgentInfoContent({
{sessionId && usageByModel != null && Object.keys(usageByModel).length > 0 && (
<ModelUsageBreakdown usageByModel={usageByModel} />
)}
{showRestartWithModel && sessionId && (
<div className="flex flex-col gap-1.5">
<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>
)}
{showIntelligentRouting && sessionId && <IntelligentRoutingSection sessionId={sessionId} />}
<McpServersSection sessionId={sessionId} servers={servers} editable={mcpEditable} />
{sessionId && <SessionPoliciesSection sessionId={sessionId} />}
+14 -1
View File
@@ -480,14 +480,24 @@ 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 } = {};
const body: {
title?: string;
agent_id?: string;
up_to_response_id?: string;
model_override?: string;
} = {};
if (title !== undefined) {
body.title = title;
}
@@ -497,6 +507,9 @@ 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" },
@@ -0,0 +1,95 @@
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
@@ -0,0 +1,149 @@
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>
);
}
+111 -2
View File
@@ -36,6 +36,7 @@ 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,
@@ -87,6 +88,84 @@ _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:
"""
@@ -570,6 +649,30 @@ 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} "
@@ -577,16 +680,22 @@ 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:
self.proc = await asyncio.create_subprocess_exec(
# 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(
*argv,
stdin=asyncio.subprocess.DEVNULL,
stdout=asyncio.subprocess.DEVNULL,
+12 -2
View File
@@ -1084,12 +1084,22 @@ 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, or up_to_response_id names no response in
the source session
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
404 Not Found — no session with that source_id, or the source's
agent row is missing
```
+61 -1
View File
@@ -87,7 +87,7 @@ from omnigent.errors import ErrorCode, OmnigentError
from omnigent.host.frames import (
HARNESS_NOT_CONFIGURED_ERROR_CODE as _HARNESS_NOT_CONFIGURED_ERROR_CODE,
)
from omnigent.model_override import validate_model_override
from omnigent.model_override import model_family_mismatch, validate_model_override
from omnigent.native_coding_agents import (
CLAUDE_NATIVE_CODING_AGENT,
CODEX_NATIVE_CODING_AGENT,
@@ -9677,6 +9677,34 @@ 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.
@@ -14524,6 +14552,37 @@ 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
@@ -14586,6 +14645,7 @@ 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
@@ -1903,11 +1903,18 @@ 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")
@@ -1055,6 +1055,7 @@ 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,
@@ -1098,6 +1099,10 @@ 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
@@ -2116,6 +2116,7 @@ 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,
@@ -2171,6 +2172,13 @@ 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
@@ -2243,7 +2251,14 @@ 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,
model_override=source.model_override 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)
),
# 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
@@ -3252,6 +3252,18 @@
"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,6 +890,7 @@ 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.
@@ -906,19 +907,27 @@ 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 or
*up_to_response_id* names no response in the source.
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.
"""
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,
@@ -0,0 +1,182 @@
"""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,6 +130,7 @@ 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,
@@ -149,6 +150,8 @@ 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).
@@ -175,6 +178,7 @@ 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,
@@ -213,6 +217,11 @@ 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(
@@ -1065,3 +1074,162 @@ 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
@@ -3478,6 +3478,39 @@ 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
@@ -793,3 +793,349 @@ 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