Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7f2f7cdebd | |||
| 2d806e8bf2 | |||
| f41bfdf663 | |||
| 7db610b3f8 | |||
| 3aec0e8250 | |||
| 7bd2f59e2b | |||
| 3945ea6fb0 | |||
| 9e2ef9ddb5 | |||
| 8d008571ed |
@@ -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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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} />}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
```
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user