Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions server/agentArgs.gemini.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ fs.writeFileSync(
gemini_default: { command: "gemini" },
codex_agent: { command: "codex", model: "gpt-5" },
claude_agent: { command: "claude", model: "opus" },
claude_opus5_agent: { command: "claude", model: "claude-opus-5" },
},
},
],
Expand Down Expand Up @@ -87,6 +88,17 @@ async function runTests() {
assert(modelArg(args) === "opus", "claude still forwards --model <slug>");
}

{
// #1018: a Claude agent pinned to claude-opus-5 launches with that exact
// slug, and the pin does not disturb the permission / MCP arguments that
// share this branch.
const { args } = await buildAgentArgs("p1", "claude_opus5_agent");
assert(modelArg(args) === "claude-opus-5", "#1018: claude forwards the claude-opus-5 pin verbatim as --model <slug>");
assert(args.includes("--dangerously-skip-permissions"), "#1018: the pinned claude agent keeps its permission flag");
assert(args.includes("--mcp-config"), "#1018: the pinned claude agent keeps its --mcp-config MCP argument");
assert(args.indexOf("--mcp-config") + 1 < args.length && args[args.indexOf("--mcp-config") + 1].endsWith(".json"), "#1018: --mcp-config still points at a written config path");
}

try { fs.rmSync(TMP_HOME, { recursive: true, force: true }); } catch {}
console.log(`\n${passed} passed, ${failed} failed\n`);
process.exit(failed > 0 ? 1 : 0);
Expand Down
14 changes: 14 additions & 0 deletions server/agentModels.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ ok(optionsForBackend("codex").some((o) => o.value === ""), "optionsForBackend(co
ok(optionsForBackend("codex").some((o) => o.value === "gpt-5.4"), "optionsForBackend(codex) lists codex models");
ok(["gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna"].every((s) => optionsForBackend("codex").some((o) => o.value === s)), "#999: optionsForBackend(codex) includes the GPT-5.6 Sol/Terra/Luna slugs");
ok(optionsForBackend("nope").length === 1 && optionsForBackend("nope")[0].value === "", "optionsForBackend(unknown) → single CLI-default row");
ok(optionsForBackend("claude").some((o) => o.value === "claude-opus-5"), "#1018: optionsForBackend(claude) includes the claude-opus-5 pin (both selection surfaces read this list)");

// ── modelsForBackend: concrete (non-"") options, claude fallback ──
ok(modelsForBackend("codex").every((o) => o.value !== ""), "modelsForBackend(codex) strips the (CLI default) row");
Expand All @@ -48,13 +49,25 @@ ok(effectiveModel("codex", "sonnet") === "gpt-5.4", "effectiveModel: a stale Cla
ok(effectiveModel("codex", "") === "gpt-5.4", "effectiveModel: unset model defaults to the backend's first option (#931 AC2 — not 'sonnet')");
ok(effectiveModel("claude", undefined) === "opus", "effectiveModel: undefined model defaults to the backend's first option");
ok(effectiveModel("gemini", "gpt-5") === "gemini-2.5-pro", "effectiveModel: a cross-backend value resolves to the gemini first option");
ok(effectiveModel("claude", "claude-opus-5") === "claude-opus-5", "#1018: effectiveModel displays the claude-opus-5 pin as-is (not the opus alias)");

// ── #1018: deterministic placement. The pin must sit immediately after
// claude-fable-5 and before the 4.x pins (newest pin first) — and never first,
// so the #931 default/heal target above stays "opus".
{
const claude = modelsForBackend("claude").map((o) => o.value);
ok(claude[claude.indexOf("claude-fable-5") + 1] === "claude-opus-5", "#1018: claude-opus-5 sits immediately after claude-fable-5");
ok(claude.indexOf("claude-opus-5") < claude.indexOf("claude-opus-4-8"), "#1018: claude-opus-5 precedes the Opus 4.x pins");
ok(claude.indexOf("opus") === 0, "#1018: the opus alias remains the first concrete Claude option");
}

// ── sanitizeModel: what gets PERSISTED on save ──
ok(sanitizeModel("codex", "sonnet") === "gpt-5.4", "#931 core: saving a codex agent with 'sonnet' persists the first valid codex model");
ok(sanitizeModel("gemini", "opus") === "gemini-2.5-pro", "sanitizeModel heals a Claude model on a gemini agent");
ok(sanitizeModel("codex", "gpt-4o") === "gpt-4o", "sanitizeModel keeps an already-valid codex model");
ok(sanitizeModel("claude", "claude-opus-4-8") === "claude-opus-4-8", "sanitizeModel keeps a valid pinned claude model");
ok(sanitizeModel("claude", "claude-fable-5") === "claude-fable-5", "#958: sanitizeModel keeps claude-fable-5 (new family, not covered by the opus/sonnet aliases)");
ok(sanitizeModel("claude", "claude-opus-5") === "claude-opus-5", "#1018: sanitizeModel keeps the claude-opus-5 pin (a saved pin is never healed to the alias)");
ok(sanitizeModel("codex", "") === "", "sanitizeModel keeps '' (CLI default) — valid for every CLI, never clobbered");
ok(sanitizeModel("gemini", undefined) === "", "sanitizeModel maps undefined → '' (CLI default), not a fabricated model");

Expand All @@ -73,6 +86,7 @@ ok(normalizeButler({ command: "codex", model: "" }).model === "", "#935: save()
ok(normalizeButler({ command: "claude", model: "opus" }).model === "opus", "#935: save() keeps an already-valid butler model");
ok(normalizeButler({ command: "codex" }).model === "", "#935: save() maps an unset butler model → '' (CLI default), not a fabricated model");
ok(normalizeButler(undefined) === undefined, "#935: save() leaves a missing butler config untouched");
ok(normalizeButler({ command: "claude", model: "claude-opus-5" }).model === "claude-opus-5", "#1018: save() preserves a butler pinned to claude-opus-5 (Butler is the React-only surface, so this mirror is its only regression guard)");

console.log(`\n${passed} passed, ${failed} failed\n`);
process.exit(failed > 0 ? 1 : 0);
5 changes: 5 additions & 0 deletions src/lib/agentModels.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,11 @@ export const MODEL_OPTIONS: Record<string, { value: string; label: string }[]> =
// first concrete row: modelsForBackend("claude")[0] ("opus") is the
// default/heal target and must stay put (#931).
{ value: "claude-fable-5", label: "claude-fable-5" },
// #1018: Claude Opus 5 pin for operators who need a reproducible version
// rather than the moving `opus` alias (which already resolves to it). Sits
// after `claude-fable-5` and before the 4.x pins — newest pin first — and
// never first, so modelsForBackend("claude")[0] stays "opus" (#931).
{ value: "claude-opus-5", label: "claude-opus-5" },
{ value: "claude-opus-4-8", label: "claude-opus-4-8" },
{ value: "claude-opus-4-7", label: "claude-opus-4-7" },
{ value: "claude-opus-4-6", label: "claude-opus-4-6" },
Expand Down
Loading