diff --git a/cmux-tui/crates/acpmux/src/claude_stdio/mod.rs b/cmux-tui/crates/acpmux/src/claude_stdio/mod.rs index 81cc14015b34..9819c84a081d 100644 --- a/cmux-tui/crates/acpmux/src/claude_stdio/mod.rs +++ b/cmux-tui/crates/acpmux/src/claude_stdio/mod.rs @@ -137,6 +137,22 @@ fn effort_value(effort: &str) -> &str { } } +/// The model id Claude Code accepts for `value`. A versioned family name +/// without the `claude-` prefix (`opus-5-5`, `sonnet-5-5[1m]`) is not a +/// Claude Code alias: Claude Code sends it to the API as typed and every turn +/// fails with "There's an issue with the selected model (opus-5-5)". It takes +/// the prefix, as the live list's `launch_id` does. Bare aliases (`opus`, +/// `opusplan`), full ids and other providers' names pass unchanged. +pub fn model_id(value: &str) -> String { + let value = value.trim(); + let versioned = ["opus-", "sonnet-", "haiku-"].iter().any(|family| { + value + .strip_prefix(family) + .is_some_and(|rest| rest.starts_with(|c: char| c.is_ascii_digit())) + }); + if versioned { format!("claude-{value}") } else { value.to_owned() } +} + /// The Reasoning select's value for a session effort: Ultracode runs at xhigh. fn shown_level(effort: &str) -> &str { match effort_value(effort) { @@ -375,7 +391,7 @@ pub fn spawn_plan( // A later --model wins over one the profile pins in its argv. if let Some(m) = model.filter(|m| !m.is_empty() && *m != "default") { args.push("--model".into()); - args.push(m.into()); + args.push(model_id(m)); } // Lets the chat switch to bypassPermissions ("Bypass permissions") later; a // process that cannot take it is never given bypassPermissions at spawn. @@ -593,7 +609,7 @@ impl Translator { session_id: Mutex::new(None), acp_session_id, mode: Mutex::new(mode.to_owned()), - model: Mutex::new(model.to_owned()), + model: Mutex::new(model_id(model)), effort: Mutex::new(effort_value(effort).to_owned()), fast: Mutex::new(false), in_turn: AtomicBool::new(false), diff --git a/cmux-tui/crates/acpmux/src/claude_stdio/outbound.rs b/cmux-tui/crates/acpmux/src/claude_stdio/outbound.rs index a42842db2651..33a278884b4e 100644 --- a/cmux-tui/crates/acpmux/src/claude_stdio/outbound.rs +++ b/cmux-tui/crates/acpmux/src/claude_stdio/outbound.rs @@ -150,6 +150,7 @@ impl Translator { let val = p.get("value").and_then(Value::as_str).unwrap_or("").to_owned(); match cid { "model" => { + let val = super::model_id(&val); self.pending.lock().await.insert( id.to_string(), Pending::Control(Setting::Model, val.clone()), @@ -244,8 +245,8 @@ impl Translator { let val = p .get("modelId") .and_then(Value::as_str) - .unwrap_or("default") - .to_owned(); + .unwrap_or("default"); + let val = super::model_id(val); self.pending .lock() .await diff --git a/cmux-tui/crates/acpmux/src/claude_stdio/tests.rs b/cmux-tui/crates/acpmux/src/claude_stdio/tests.rs index ce07024c63d4..5ca23d94d897 100644 --- a/cmux-tui/crates/acpmux/src/claude_stdio/tests.rs +++ b/cmux-tui/crates/acpmux/src/claude_stdio/tests.rs @@ -572,3 +572,39 @@ async fn modes_are_claude_codes_own_labels_descriptions_and_order() { mode["options"].as_array().unwrap().iter().filter_map(|o| o["value"].as_str()).collect(); assert_eq!(values, ["auto", "default", "acceptEdits", "plan", "bypassPermissions"]); } + +/// A chat started with a versioned family name (`opus-5-5`, as a picker or +/// an agent's `--model` may pass it) failed every turn: Claude Code sent it to +/// the API unchanged ("There's an issue with the selected model (opus-5-5)"). +/// The translator hands Claude Code the full id at spawn and on every switch. +#[tokio::test] +async fn versioned_family_names_reach_claude_code_as_full_ids() { + assert_eq!(model_id("opus-5-5"), "claude-opus-5-5"); + assert_eq!(model_id("sonnet-5-5[1m]"), "claude-sonnet-5-5[1m]"); + for unchanged in ["opus", "opusplan", "opus[1m]", "claude-opus-5-5", "default", "gpt-6.1-sol"] { + assert_eq!(model_id(unchanged), unchanged); + } + + let profile: crate::config::HarnessProfile = + serde_json::from_value(json!({"kind": "claude-stdio", "argv": ["claude"]})).unwrap(); + let plan = spawn_plan(&profile, None, false, None, None, "default", Some("opus-5-5")); + let at = plan.args.iter().position(|a| a == "--model").expect("--model"); + assert_eq!(plan.args[at + 1], "claude-opus-5-5"); + + let t = Translator::new("acp-1".into(), "default", "opus-5-5", "default"); + assert_eq!(*t.model.lock().await, "claude-opus-5-5"); + let req = Message::request( + 3, + method::SESSION_SET_MODEL, + json!({"sessionId": "acp-1", "modelId": "haiku-5-5"}), + ); + let Outbound::Lines(lines) = t.outbound(&req).await else { panic!() }; + assert_eq!(lines[0]["request"]["model"], "claude-haiku-5-5"); + let req = Message::request( + 4, + method::SESSION_SET_CONFIG_OPTION, + json!({"sessionId": "acp-1", "configId": "model", "value": "sonnet-5-5"}), + ); + let Outbound::Lines(lines) = t.outbound(&req).await else { panic!() }; + assert_eq!(lines[0]["request"]["model"], "claude-sonnet-5-5"); +} diff --git a/webviews/src/agent-session/acpmux/ModelPicker.gallery.tsx b/webviews/src/agent-session/acpmux/ModelPicker.gallery.tsx index 740bc6ccf5b5..995950031132 100644 --- a/webviews/src/agent-session/acpmux/ModelPicker.gallery.tsx +++ b/webviews/src/agent-session/acpmux/ModelPicker.gallery.tsx @@ -74,11 +74,7 @@ const curated: ModelPickerProps = { .map((harness) => ({ id: harness.id, name: harness.name, - models: harness.models.map((model) => ({ - id: model.id, - name: model.name, - shortName: model.shortName, - })), + models: harness.models.map((model) => ({ id: model.id, name: model.name, shortName: model.shortName })), ...(topModelsFor(harness.id) ? { top: topModelsFor(harness.id) } : {}), })), }; @@ -185,11 +181,7 @@ const manyHarnesses: ModelPickerProps["catalog"] = [ ["deepagents", "DeepAgents"], ["poolside", "Poolside"], ["qoder", "Qoder CLI"], - ].map(([id, name]) => ({ - id: id!, - name: name!, - models: [{ id: `${id}-default`, name: "Default" }], - })), + ].map(([id, name]) => ({ id: id!, name: name!, models: [{ id: `${id}-default`, name: "Default" }] })), ]; /// The open picker stays within the available anchor room on a tall stage, and its content @@ -302,28 +294,12 @@ const startedFork: ModelPickerProps = { ...quick, catalog: [ ...curated.catalog, - { - ...curated.catalog.find((entry) => entry.id === "claude")!, - id: "claude-work", - name: "Claude (work)", - }, + { ...curated.catalog.find((entry) => entry.id === "claude")!, id: "claude-work", name: "Claude (work)" }, ], harnessFixed: true, fork: { to: () => undefined, ready: true }, }; -const canonicalVariants: ModelPickerProps = { - ...startedFork, - catalog: [ - ...startedFork.catalog, - { - ...curated.catalog.find((entry) => entry.id === "codex")!, - id: "codex-work", - name: "Codex (work)", - }, - ], -}; - /// A short New Tab/chat pane with 48 installed accounts: the menu must cap to its window and /// let the Account list scroll to its last row without an empty spacer. const manyProfiles: ModelPickerProps = { @@ -614,14 +590,6 @@ export default componentEntry({ await ctx.waitFor(() => ctx.document.querySelector(".acpmux-quick-fork") !== null); }, }, - "canonical-harnesses-chat": { - props: canonicalVariants, - play: openQuick, - }, - "canonical-harnesses-new-chat": { - props: { ...canonicalVariants, harnessFixed: false, fork: undefined, onHarness: () => undefined }, - play: openQuick, - }, // Its Account row contains signed-in routes, separate from named launch harnesses. "started-chat-accounts": { props: startedFork, diff --git a/webviews/src/agent-session/acpmux/ModelPicker.tsx b/webviews/src/agent-session/acpmux/ModelPicker.tsx index e920ab7d8a1e..86d54706a3a1 100644 --- a/webviews/src/agent-session/acpmux/ModelPicker.tsx +++ b/webviews/src/agent-session/acpmux/ModelPicker.tsx @@ -41,7 +41,6 @@ import { PickerOptionList, } from "../../ui/PickerPrimitives"; import { ScrollArea } from "../../ui/ScrollArea"; -import { canonicalHarnesses } from "./canonicalHarnesses"; import { quantizePickerRail } from "./pickerRailGeometry"; type HarnessChoice = { @@ -299,8 +298,9 @@ function quickRow(family: string, model: ModelChoice): QuickModel { /// The rail's Starred tab (never a harness id). const STARRED = "\u0000starred"; -/// Build launch entries before folding Claude/Codex variants into their canonical harness tab. -/// Their model catalogs refresh after selection; do not borrow another account's models. +/// Global launch configurations are distinct harness entries, never a Profile submenu. +/// One whose own catalog entry lists models shows them, as any harness tab does; only one with +/// no models of its own launches from its tab. Do not borrow another account's models. function railByHarness( list: QuickFamily[], running: string | undefined, @@ -308,6 +308,10 @@ function railByHarness( signedOut: (id: string) => boolean = () => false, ): QuickFamily[] { const seen = new Set(); + const byId = new Map(list.map((family) => [family.id, family])); + // Only a row that can be picked starts the entry; one whose probe failed keeps its launch tab. + const hasModels = (family: QuickFamily | undefined) => + !!family && [...family.models, ...family.more].some((model) => !model.disabled); return list.flatMap((family) => { if (seen.has(family.id)) return []; seen.add(family.id); @@ -323,7 +327,7 @@ function railByHarness( ); const currentName = names.find((profile) => profile.id === family.id)?.name ?? launchName(family.name, family.name, family.id); - const alternate = launchSuffix(family.id) && family.id !== running; + const alternate = launchSuffix(family.id) && family.id !== running && !hasModels(family); const own = { ...family, name: currentName, ...(alternate ? { launch: true } : {}) }; return [ own, @@ -338,6 +342,8 @@ function railByHarness( ) return []; seen.add(profile.id); + const entry = byId.get(profile.id); + if (hasModels(entry)) return [{ ...entry!, name: profile.name, mark: entry!.mark ?? family.mark }]; return [ { id: profile.id, @@ -929,10 +935,7 @@ export function ModelPicker(props: ModelPickerProps) { : {}), }); } - const rail = canonicalHarnesses( - railByHarness(list, harness ?? current?.id, props.profiles, (id) => signedOut?.[id] !== undefined), - current?.id, - ); + const rail = railByHarness(list, harness ?? current?.id, props.profiles, (id) => signedOut?.[id] !== undefined); if (!harnessFixed) return rail; // A started chat (Leo 2026-10-22: "can expose as fork"): another harness's tab is Fork to it, // Starred keeps the chat's own models, and another launch profile of any harness forks there. @@ -943,7 +946,6 @@ export function ModelPicker(props: ModelPickerProps) { return models.length ? [{ ...family, models }] : []; } if (family.id === current?.id || family.note) return [own]; - if (family.launch) return [{ ...own, models: [], more: [], fork: true }]; return [{ ...own, models: [], more: [], fork: true }]; }); }, [ diff --git a/webviews/src/agent-session/acpmux/QuickModelMenu.tsx b/webviews/src/agent-session/acpmux/QuickModelMenu.tsx index dd81fc8e136c..397c08c9a30c 100644 --- a/webviews/src/agent-session/acpmux/QuickModelMenu.tsx +++ b/webviews/src/agent-session/acpmux/QuickModelMenu.tsx @@ -128,8 +128,6 @@ export type QuickModel = { /// their place (a signed-out harness). `mark` is the AgentMark brand; without one the tab draws /// the name's initial. export type QuickFamily = { - /// Alternate launch configurations inside the canonical harness tab, never duplicate rail tabs. - variants?: QuickFamily[]; id: string; name: string; mark?: string; @@ -785,26 +783,6 @@ export function QuickModelMenu({ )} )} - {!query && shown?.variants && shown.variants.length > 0 && ( - - {shown.variants.map((variant) => ( - { - if (variant.logIn) variant.logIn(); - else families?.onLaunch?.(variant.id); - }} - > - - {families?.onFork ? t("picker.forkTo", { harness: variant.name }) : variant.name} - - - ))} - - )} {!query && onChatTab && effort && diff --git a/webviews/src/agent-session/acpmux/canonicalHarnesses.test.ts b/webviews/src/agent-session/acpmux/canonicalHarnesses.test.ts deleted file mode 100644 index 29d52b332350..000000000000 --- a/webviews/src/agent-session/acpmux/canonicalHarnesses.test.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { expect, test } from "bun:test"; -import { canonicalHarnesses } from "./canonicalHarnesses"; -import type { QuickFamily } from "./QuickModelMenu"; - -const family = (id: string, name: string, mark = id): QuickFamily => ({ - id, - name, - mark, - models: [{ id: `${id}-model`, name: `${name} model`, family: id }], - more: [], -}); - -test("Claude and Codex each have one rail entry, with exact launch IDs retained inside", () => { - const input = [ - family("claude", "Claude Code"), - family("claude-work", "Work", "claude"), - family("codex", "Codex"), - family("codex-team", "Team", "codex"), - family("gemini", "Gemini"), - ]; - const rows = canonicalHarnesses(input, "claude-work"); - expect(rows.map((row) => row.name)).toEqual(["Claude Code", "Codex", "Gemini"]); - expect(rows[0]?.id).toBe("claude-work"); - expect(rows[0]?.models).toEqual(input[1]!.models); - expect(rows[0]?.variants?.map((row) => row.id)).toEqual(["claude"]); - expect(rows[1]?.variants?.map((row) => row.id)).toEqual(["codex-team"]); -}); - -test("a folded profile with an arbitrary id stays under its reported harness", () => { - const rows = canonicalHarnesses( - [ - family("claude", "Claude Code"), - { - ...family("personal", "Personal", "claude"), - models: [], - launch: true, - }, - ], - "claude", - ); - expect(rows).toHaveLength(1); - expect(rows[0]?.variants?.[0]?.id).toBe("personal"); - expect(rows[0]?.launch).toBe(false); -}); - -test("unknown harnesses and Starred keep their identity and order", () => { - const input = [ - { ...family("starred", "Starred"), starred: true }, - family("private", "Private"), - family("gemini-work", "Gemini work", "gemini"), - family("gemini", "Gemini"), - ]; - expect(canonicalHarnesses(input, "private")).toEqual(input); -}); - -test("a harness with only a named launch configuration keeps that launch reachable", () => { - const configured = { ...family("claude-work", "Work", "claude"), launch: true }; - const rows = canonicalHarnesses([configured], "codex"); - expect(rows.map((row) => row.name)).toEqual(["Claude Code"]); - expect(rows[0]?.variants?.map((row) => row.id)).toEqual(["claude-work"]); -}); diff --git a/webviews/src/agent-session/acpmux/canonicalHarnesses.ts b/webviews/src/agent-session/acpmux/canonicalHarnesses.ts deleted file mode 100644 index ae339d14d81b..000000000000 --- a/webviews/src/agent-session/acpmux/canonicalHarnesses.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { agentKey } from "../shared/agentKey"; -import { agentDisplayName } from "./agents"; -import type { QuickFamily } from "./QuickModelMenu"; - -/// Claude and Codex launch configurations share one harness tab. Keep the running -/// configuration's models and exact id; switching variants still uses its original callback. -export function canonicalHarnesses(list: QuickFamily[], running?: string): QuickFamily[] { - const key = (row: QuickFamily) => { - if (row.starred) return undefined; - const id = agentKey(row.id); - const mark = agentKey(row.mark); - if (id === "claude" || mark === "claude") return "claude"; - if (id === "codex" || mark === "codex" || mark === "openai") return "codex"; - return undefined; - }; - const seen = new Set(); - return list.flatMap((row) => { - const brand = key(row); - if (!brand) return [row]; - if (seen.has(brand)) return []; - seen.add(brand); - const members = list.filter((candidate) => key(candidate) === brand); - const selected = - members.find((candidate) => candidate.id === running) ?? - members.find((candidate) => candidate.id === brand) ?? - members.find((candidate) => !candidate.launch) ?? - row; - return [ - { - ...selected, - name: agentDisplayName(brand), - mark: brand, - launch: false, - variants: members.filter((candidate) => candidate.id !== selected.id || selected.launch), - }, - ]; - }); -} diff --git a/webviews/test/model-menu-started-chat-fork.test.tsx b/webviews/test/model-menu-started-chat-fork.test.tsx index 959cd0d6b1a9..d72a9f9b613d 100644 --- a/webviews/test/model-menu-started-chat-fork.test.tsx +++ b/webviews/test/model-menu-started-chat-fork.test.tsx @@ -2,7 +2,7 @@ // down here?", "the harnes picker exists but it's ass and it's not per-account available...?" and // "need to be able to switch to different harnesses, can expose as fork". A started chat's model // menu keeps the New Tab's harness rail: another harness's tab forks the chat there, and each -// launch configuration stays inside its canonical harness, never a duplicate rail entry. The +// launch configuration is a distinct, readable harness entry rather than a Profile submenu. The // real composer, catalog and menu, in JSDOM. import { afterAll, afterEach, beforeAll, expect, test } from "bun:test"; import { JSDOM } from "jsdom"; @@ -35,10 +35,7 @@ let root: Root | undefined; let host: HTMLElement; beforeAll(() => { - dom = new JSDOM("
", { - pretendToBeVisual: true, - url: "https://cmux.test/", - }); + dom = new JSDOM("
", { pretendToBeVisual: true, url: "https://cmux.test/" }); Object.assign(dom.window, { ResizeObserver: class { observe() {} @@ -108,20 +105,8 @@ async function acpmuxCatalog(): Promise { family: harness.families[0], models: harness.models.map((model) => ({ id: model.id, name: model.name })), })), - { - id: "claude-work", - name: "Claude (work)", - family: "claude-work", - icon: "claude", - models: models("claude"), - }, - { - id: "codex-work", - name: "Codex (work)", - family: "codex-work", - icon: "openai", - models: models("codex"), - }, + { id: "claude-work", name: "Claude (work)", family: "claude-work", icon: "claude", models: models("claude") }, + { id: "codex-work", name: "Codex (work)", family: "codex-work", icon: "openai", models: models("codex") }, { id: "unii", name: "UniiChat", family: "unii", models: [] }, ] as Snapshot["catalog"]; } @@ -261,16 +246,20 @@ test("a started chat's model menu keeps the harness rail, and another harness's expect(switched).toEqual([]); }); -test("launch configurations stay inside one Claude and one Codex rail entry", async () => { +test("launch configurations are named rail entries and never a Profile submenu", async () => { const { forked } = await mount(1, { ready: true }); await open(); expect(itemsOf(menu()!).map(labelOf)).not.toContain("Profile"); const labels = railTabs().map((tab) => tab.getAttribute("aria-label")); - expect(labels.filter((name) => name?.startsWith("Claude"))).toEqual(["Claude Code"]); - expect(labels.filter((name) => name?.startsWith("Codex"))).toEqual(["Codex"]); - const launch = itemsOf(menu()!).find((item) => labelOf(item) === "Fork to Claude (work)"); - expect(launch).toBeDefined(); - await act(async () => launch!.click()); + expect(labels).toContain("Claude Code"); + expect(labels).toContain("Claude (work)"); + expect(labels).toContain("Codex (work)"); + await showTab("Claude (work)"); + await act(async () => + itemsOf(menu()!) + .find((item) => labelOf(item) === "Fork to Claude (work)")! + .click(), + ); await settle(); expect(forked).toEqual(["claude-work"]); }); @@ -281,24 +270,35 @@ for (const [id, name, family] of [ ]) test(`a started chat can keyboard-fork to the launch profile ${id}`, async () => { const catalog = [ - { - id: "claude", - name: "Claude Code", - family: "claude", - models: [{ id: "opus", name: "Opus" }], - }, + { id: "claude", name: "Claude Code", family: "claude", models: [{ id: "opus", name: "Opus" }] }, { id: id!, name: name!, family: family!, models: [{ id: "opus", name: "Opus" }] }, ] as Snapshot["catalog"]; const { forked, switched } = await mount(1, { ready: true }, "claude", catalog); await open(); - expect(railTabs().map((tab) => tab.getAttribute("aria-label"))).toEqual(["Claude Code"]); + for (let remaining = railTabs().length; remaining > 0; remaining--) { + if ( + railTabs() + .find((tab) => tab.getAttribute("aria-pressed") === "true") + ?.getAttribute("aria-label") === name + ) + break; + await act(async () => + menu()!.dispatchEvent(new dom.window.KeyboardEvent("keydown", { key: "ArrowRight", bubbles: true })), + ); + await settle(); + } + expect( + railTabs() + .find((tab) => tab.getAttribute("aria-pressed") === "true") + ?.getAttribute("aria-label"), + ).toBe(name); expect(forked).toEqual([]); + expect(menu()!.querySelectorAll('.acpmux-quick-item[role="menuitemradio"]')).toHaveLength(0); expect(itemsOf(menu()!).map(labelOf)).toContain(`Fork to ${name}`); - const variant = itemsOf(menu()!).find((item) => labelOf(item) === `Fork to ${name}`)!; - await act(async () => variant.focus()); - expect(doc().activeElement).toBe(variant); await act(async () => - variant.dispatchEvent(new dom.window.KeyboardEvent("keydown", { key: "Enter", bubbles: true })), + itemsOf(menu()!) + .find((item) => labelOf(item) === `Fork to ${name}`)! + .click(), ); await settle(); expect(forked).toEqual([id!]); @@ -316,16 +316,31 @@ test("while the chat can't fork (a turn running), Fork to is disabled and does n expect(forked).toEqual([]); }); -test("a new chat launches a named variant inside the canonical harness", async () => { +test("a new chat starts a named harness entry from one of its own models", async () => { const { switched } = await mount(0); await open(); expect(itemsOf(menu()!).map(labelOf)).not.toContain("Profile"); - const launch = itemsOf(menu()!).find((item) => labelOf(item) === "Claude (work)"); + await showTab("Claude (work)"); + expect(switched).toEqual([]); + const pick = menu()!.querySelector('.acpmux-quick-item[role="menuitemradio"]'); + expect(pick).not.toBeNull(); + await act(async () => pick!.click()); + await settle(); + expect(switched).toEqual(["claude-work"]); +}); + +test("a new chat still launches a named harness entry that lists no models from its tab", async () => { + const catalog = [ + { id: "claude", name: "Claude Code", family: "claude", models: [{ id: "opus", name: "Opus" }] }, + { id: "claude-work", name: "Claude (work)", family: "claude-work", icon: "claude", models: [] }, + ] as Snapshot["catalog"]; + const { switched } = await mount(0, undefined, "claude", catalog); + await open(); + const launch = railTabs().find((tab) => tab.getAttribute("aria-label") === "Claude (work)"); expect(launch).toBeDefined(); await act(async () => launch!.click()); await settle(); - expect(switched).toHaveLength(1); - expect(switched[0]).toBe("claude-work"); + expect(switched).toEqual(["claude-work"]); }); test("the chat composer forks through Continue In…'s chat.handoff.prepare", async () => { @@ -335,14 +350,9 @@ test("the chat composer forks through Continue In…'s chat.handoff.prepare", as const calls: { method: string; params: unknown }[] = []; (dom.window as unknown as { cmuxAcpmuxActions: unknown }).cmuxAcpmuxActions = new Proxy( {}, - { - get: (_target, method) => async (params: unknown) => void calls.push({ method: String(method), params }), - }, + { get: (_target, method) => async (params: unknown) => void calls.push({ method: String(method), params }) }, ); - const snapshot = chat(await acpmuxCatalog(), 1, { - canHandoff: true, - handoff: { ready: true }, - } as Partial); + const snapshot = chat(await acpmuxCatalog(), 1, { canHandoff: true, handoff: { ready: true } } as Partial); root = createRoot(host); await act(async () => root!.render( @@ -366,29 +376,21 @@ test("the chat composer forks through Continue In…'s chat.handoff.prepare", as }); // Catalog joining folds aliases for model data, while the picker keeps each installed launch -// configuration inside its canonical harness. Both exact launch IDs remain reachable without -// a Profile submenu or duplicate Claude Code rail entries. +// configuration as a named harness entry. With `claude` and `claude-sr` installed, the rail must +// expose both names without a Profile submenu or duplicate generic Claude Code rows. for (const [id, label] of [ ["claude-sr", "Claude Code (sr)"], ["claude_work", "Claude Code (work)"], ]) { test(`a folded family forks to the exact ${id} launch entry`, async () => { const catalog = [ - { - id: "claude", - name: "Claude Code", - family: "claude", - models: [{ id: "opus", name: "Opus" }], - }, + { id: "claude", name: "Claude Code", family: "claude", models: [{ id: "opus", name: "Opus" }] }, { id: id!, name: id!, family: "claude", models: [{ id: "opus", name: "Opus" }] }, ] as Snapshot["catalog"]; const { forked, switched } = await mount(1, { ready: true }, "claude", catalog); await open(); expect(itemsOf(menu()!).map(labelOf)).not.toContain("Profile"); - const variant = itemsOf(menu()!).find((item) => labelOf(item) === `Fork to ${label}`); - expect(variant).toBeDefined(); - await act(async () => variant!.click()); - await settle(); + await showTab(label!); expect(forked).toEqual([id!]); expect(switched).toEqual([]); }); @@ -401,24 +403,16 @@ for (const [first, second, firstLabel, secondLabel] of [ ]) test(`duplicate configured names distinguish ${first} and ${second}`, async () => { const catalog = [ - { - id: "claude", - name: "Claude Code", - family: "claude", - models: [{ id: "opus", name: "Opus" }], - }, + { id: "claude", name: "Claude Code", family: "claude", models: [{ id: "opus", name: "Opus" }] }, { id: first!, name: "Work", family: "claude", models: [] }, { id: second!, name: "Work", family: "claude", models: [] }, ] as Snapshot["catalog"]; const { switched } = await mount(0, undefined, "claude", catalog); await open(); - expect(itemsOf(menu()!).map(labelOf)).toEqual(expect.arrayContaining([firstLabel, secondLabel])); - await act(async () => - itemsOf(menu()!) - .find((item) => labelOf(item) === secondLabel)! - .click(), + expect(railTabs().map((tab) => tab.getAttribute("aria-label"))).toEqual( + expect.arrayContaining([firstLabel, secondLabel]), ); - await settle(); + await showTab(secondLabel!); expect(switched).toEqual([second]); }); @@ -431,7 +425,7 @@ test("a configured suffix collision keeps every launch profile readable", async ] as Snapshot["catalog"]; await mount(0, undefined, "claude", catalog); await open(); - const labels = itemsOf(menu()!).map(labelOf); + const labels = railTabs().map((tab) => tab.getAttribute("aria-label")); expect(labels).toEqual(expect.arrayContaining(["Work (work)", "Work (personal)", "Work (work) (travel)"])); }); @@ -466,9 +460,7 @@ async function mountChat(snapshot: Snapshot) { const calls: { method: string; params: unknown }[] = []; (dom.window as unknown as { cmuxAcpmuxActions: unknown }).cmuxAcpmuxActions = new Proxy( {}, - { - get: (_target, method) => async (params: unknown) => void calls.push({ method: String(method), params }), - }, + { get: (_target, method) => async (params: unknown) => void calls.push({ method: String(method), params }) }, ); root = createRoot(host); await act(async () => @@ -508,10 +500,7 @@ test("when no other harness can take the chat, the composer's Fork to is disable const catalog = (await acpmuxCatalog()).map((harness) => harness.id === "claude" ? harness : { ...harness, unavailable: "probe failed", probeFailed: true }, ) as Snapshot["catalog"]; - const snapshot = chat(catalog, 1, { - canHandoff: true, - handoff: { ready: true }, - } as Partial); + const snapshot = chat(catalog, 1, { canHandoff: true, handoff: { ready: true } } as Partial); await forkToCodexDisabled(await mountChat(snapshot)); }); @@ -544,3 +533,87 @@ test("canContinueIn is Continue In…'s whole check", async () => { ), ).toBe(false); }); + +// Regression (cmux NEXT nightly, 2026-10-29): #26969 folded every harness drawing the Claude or +// Codex mark into one tab, so a custom harness such as claude-sr2 (its own profile, `icon = +// "claude"`) lost its rail tab and its model list. Each harness keeps its own tab and models, +// whichever harness the chat runs. +const sr2Catalog = [ + { id: "claude", name: "Claude Code", family: "claude", models: [{ id: "claude-opus-5-5", name: "Claude Opus 5.5" }] }, + { + id: "claude-sr2", + name: "Claude Code (sr2 internal)", + family: "claude-sr2", + icon: "claude", + models: [ + { id: "claude-opus-5-5", name: "Claude Opus 5.5" }, + { id: "claude-sonnet-5-5", name: "Claude Sonnet 5.5" }, + ], + }, + { id: "codex", name: "Codex", family: "codex", icon: "openai", models: [{ id: "gpt-6", name: "GPT-6" }] }, + { id: "codex-work", name: "Codex (work)", family: "codex-work", icon: "openai", models: [] }, +] as Snapshot["catalog"]; + +for (const running of ["claude-sr2", "claude"]) + test(`a custom Claude-marked harness keeps its own rail tab and lists its models (running ${running})`, async () => { + const { switched } = await mount(0, undefined, running, sr2Catalog); + await open(); + const labels = railTabs().map((tab) => tab.getAttribute("aria-label") ?? ""); + expect(labels).toContain("Claude Code"); + const sr2 = labels.find((label) => label.includes("sr2")); + expect(sr2, labels.join(", ")).toBeDefined(); + expect(labels).toContain("Codex"); + expect(labels).toContain("Codex (work)"); + expect(menu()!.querySelector(".acpmux-quick-variant")).toBeNull(); + await showTab(sr2!); + expect(switched).toEqual([]); + const models = [...menu()!.querySelectorAll('.acpmux-quick-item[role="menuitemradio"]')]; + const sonnet = models.find((item) => (item.textContent ?? "").includes("Sonnet 5.5")); + expect(sonnet, models.map((item) => item.textContent ?? "").join(" | ")).toBeDefined(); + if (running === "claude-sr2") return; + await act(async () => sonnet!.click()); + await settle(); + expect(switched).toEqual(["claude-sr2"]); + }); + +// Two launch entries of one uncatalogued family: the second is a profile of the first, and still +// lists its own catalog models (never the first's) and starts itself. +test("a second launch entry of a family lists its own models and starts itself", async () => { + const catalog = [ + { id: "claude", name: "Claude Code", family: "claude", models: [{ id: "opus", name: "Opus" }] }, + { + id: "claude-sr2", + name: "Claude Code (sr2)", + family: "claude-sr2", + icon: "claude", + models: [{ id: "claude-opus-5-5", name: "Claude Opus 5.5" }], + }, + { + id: "claude-sr2-alt", + name: "Claude Code (sr2 alt)", + family: "claude-sr2", + icon: "claude", + models: [{ id: "claude-haiku-5-5", name: "Claude Haiku 5.5" }], + }, + ] as Snapshot["catalog"]; + const { switched } = await mount(0, undefined, "claude", catalog); + await open(); + const labels = railTabs().map((tab) => tab.getAttribute("aria-label") ?? ""); + const alt = labels.filter((label) => label.includes("alt")); + expect(alt, labels.join(", ")).toHaveLength(1); + await showTab(alt[0]!); + expect(switched).toEqual([]); + const models = [...menu()!.querySelectorAll('.acpmux-quick-item[role="menuitemradio"]')]; + const texts = models.map((item) => item.textContent ?? ""); + expect( + texts.some((text) => text.includes("Haiku 5.5")), + texts.join(" | "), + ).toBe(true); + expect( + texts.some((text) => text.includes("Opus 5.5")), + texts.join(" | "), + ).toBe(false); + await act(async () => models.find((item) => (item.textContent ?? "").includes("Haiku 5.5"))!.click()); + await settle(); + expect(switched).toEqual(["claude-sr2-alt"]); +});