Skip to content
Open
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
Original file line number Diff line number Diff line change
Expand Up @@ -36,10 +36,11 @@ vi.mock("@multica/core/api", () => ({

import { ThinkingPropRow } from "./thinking-prop-row";

// Claude entries carry no `default`: which model an unset agent runs on is
// resolved by Claude Code from its own config, not advertised to us.
const CLAUDE_MODEL: RuntimeModel = {
id: "claude-sonnet-4-6",
label: "Claude Sonnet 4.6",
default: true,
thinking: {
supported_levels: [
{ value: "none", label: "None" },
Expand All @@ -51,6 +52,22 @@ const CLAUDE_MODEL: RuntimeModel = {
},
};

// Opus advertises the Opus-only `xhigh` on top of the shared levels, so it
// is what makes a union-vs-single-entry preview observable.
const CLAUDE_OPUS_MODEL: RuntimeModel = {
id: "claude-opus-4-8",
label: "Claude Opus 4.8",
thinking: {
supported_levels: [
{ value: "low", label: "Low" },
{ value: "medium", label: "Medium" },
{ value: "high", label: "High" },
{ value: "xhigh", label: "Extra high" },
],
default_level: "medium",
},
};

// Model without thinking metadata — what the row sees when the agent's
// model swap landed on a non-thinking runtime, or when the daemon catalog
// shrank and stopped emitting `thinking` for this id.
Expand Down Expand Up @@ -258,14 +275,40 @@ describe("ThinkingPropRow", () => {
expect(onChange).toHaveBeenCalledWith("");
});

it("still previews the Default model's levels for an empty non-codex model", async () => {
// Non-codex providers keep the existing behavior: an empty model previews
// the flagged Default entry's catalog. Only codex is fenced off, because
// only its empty-model resolution is config-driven and unknowable here.
it("previews the catalog union for an empty claude model, not one entry's levels", async () => {
// Claude Code resolves an unset model from its own settings / env, so no
// single catalog entry can stand in for it. Offering only Sonnet's levels
// hid `xhigh` from users whose CLI is configured to Opus — the picker has
// to show the union and let the CLI arbitrate.
mockInitiateListModels.mockResolvedValue(
listResult([CLAUDE_MODEL, CLAUDE_OPUS_MODEL]),
);
mockGetListModelsResult.mockResolvedValue(
listResult([CLAUDE_MODEL, CLAUDE_OPUS_MODEL]),
);
renderRow({ provider: "claude", model: "", value: "" });

await screen.findByText("Thinking");
// CLAUDE_MODEL (Default) advertises Low/Medium/High — the picker shows them.
expect((await screen.findAllByText("Follow CLI config")).length).toBeGreaterThan(0);
fireEvent.click(screen.getByRole("button"));
// `xhigh` comes only from the Opus entry; `low` only via the shared rows.
expect(await screen.findByText("Extra high")).toBeInTheDocument();
expect(await screen.findByText("Low")).toBeInTheDocument();
});

it("previews only the selected model's levels once a claude model is explicit", async () => {
// The union is strictly a fallback for "we can't know". With Sonnet named
// explicitly, Opus-only `xhigh` must not be offered.
mockInitiateListModels.mockResolvedValue(
listResult([CLAUDE_MODEL, CLAUDE_OPUS_MODEL]),
);
mockGetListModelsResult.mockResolvedValue(
listResult([CLAUDE_MODEL, CLAUDE_OPUS_MODEL]),
);
renderRow({ provider: "claude", model: "claude-sonnet-4-6", value: "" });

await screen.findByText("Thinking");
fireEvent.click(screen.getByRole("button"));
expect(await screen.findByText("High")).toBeInTheDocument();
expect(screen.queryByText("Extra high")).not.toBeInTheDocument();
});
});
53 changes: 40 additions & 13 deletions packages/views/agents/components/inspector/thinking-prop-row.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,10 @@

import { useQuery } from "@tanstack/react-query";
import type { ReactNode } from "react";
import type { RuntimeModel } from "@multica/core/types";
import type {
RuntimeModel,
RuntimeModelThinkingLevel,
} from "@multica/core/types";
import { runtimeModelsOptions } from "@multica/core/runtimes";
import { PropRow } from "../../../common/prop-row";
import { SettingsRow } from "../../../settings/components/settings-layout";
Expand Down Expand Up @@ -38,8 +41,8 @@ export function ThinkingPropRow({
}: {
runtimeId: string | null;
runtimeOnline: boolean;
/** Runtime provider type (e.g. "codex", "claude"). Used to decide whether an
* empty model can safely preview a default model's effort catalog. */
/** Runtime provider type (e.g. "codex", "claude"). Decides how an empty
* model resolves to a level set — see `pickLevels`. */
provider: string;
model: string;
value: string;
Expand All @@ -52,8 +55,7 @@ export function ThinkingPropRow({
);

const models = modelsQuery.data?.models ?? [];
const entry = pickModelEntry(models, model, provider);
const levels = entry?.thinking?.supported_levels ?? [];
const levels = pickLevels(models, model, provider);
if (levels.length === 0 && !value) return null;

return (
Expand Down Expand Up @@ -92,8 +94,7 @@ export function ThinkingSettingField({
runtimeModelsOptions(runtimeOnline ? runtimeId : null),
);
const models = modelsQuery.data?.models ?? [];
const entry = pickModelEntry(models, model, provider);
const levels = entry?.thinking?.supported_levels ?? [];
const levels = pickLevels(models, model, provider);

if (levels.length === 0 && !value) return null;

Expand All @@ -111,19 +112,45 @@ export function ThinkingSettingField({
);
}

function pickModelEntry(
// Effort levels to offer for a (model, provider) pair. Mirrors the backend
// ValidateThinkingLevel so the picker never offers a level the daemon would
// drop, and never hides one it would accept.
function pickLevels(
models: RuntimeModel[],
model: string,
provider: string,
): RuntimeModel | undefined {
if (model) return models.find((m) => m.id === model);
): RuntimeModelThinkingLevel[] {
if (model) {
return (
models.find((m) => m.id === model)?.thinking?.supported_levels ?? []
);
}
// Empty model = "follow the runtime's own default". For codex that default
// comes from the local config.toml and can be any installed model, so we
// must NOT preview the flagged Default entry's effort catalog — gpt-5.6-sol
// alone advertises `ultra`, which the actually-configured model may not
// support. Fail closed (no preview): the row hides unless a stale level is
// persisted, in which case it still renders so the orphan can be cleared.
// Mirrors the backend ValidateThinkingLevel. (MUL-4347)
if (provider === "codex") return undefined;
return models.find((m) => m.default) ?? models[0];
// (MUL-4347)
if (provider === "codex") return [];
// claude / opencode resolve an unset model from local configuration we
// can't read, so no single entry can stand in for it. Offer the union of
// what the runtime advertises and let the CLI arbitrate — picking one
// entry here would hide `xhigh` from a user whose CLI is set to Opus.
return unionLevels(models);
}

// Distinct levels across every advertised model, in first-seen catalog order
// so the runtime's own low→high ordering survives.
function unionLevels(models: RuntimeModel[]): RuntimeModelThinkingLevel[] {
const seen = new Set<string>();
const out: RuntimeModelThinkingLevel[] = [];
for (const m of models) {
for (const level of m.thinking?.supported_levels ?? []) {
if (seen.has(level.value)) continue;
seen.add(level.value);
out.push(level);
}
}
return out;
}
14 changes: 11 additions & 3 deletions server/pkg/agent/models.go
Original file line number Diff line number Diff line change
Expand Up @@ -310,12 +310,20 @@ func discoveryCacheKey(providerType, executablePath string) string {

// claudeStaticModels reflects the Claude Code CLI's accepted --model
// values. Keep this list short and current; stale entries here
// mislead users more than they help. Default = Sonnet because it's
// the everyday workhorse (Opus is reserved for advisor-style flows).
// mislead users more than they help.
//
// No entry is flagged Default on purpose. Claude Code resolves an unset
// model from the user's own configuration — settings files, managed policy,
// ANTHROPIC_MODEL — and exposes no headless query for the result, so any
// flag here would be Multica asserting a default it cannot know. The picker
// already renders an unset model as "Default" (meaning "whatever the CLI
// picks") rather than badging a row, and ValidateThinkingLevel takes the
// catalog-union path for an empty claude model instead of borrowing one
// entry's effort catalog.
func claudeStaticModels() []Model {
return []Model{
{ID: "claude-sonnet-5", Label: "Claude Sonnet 5", Provider: "anthropic"},
{ID: "claude-sonnet-4-6", Label: "Claude Sonnet 4.6", Provider: "anthropic", Default: true},
{ID: "claude-sonnet-4-6", Label: "Claude Sonnet 4.6", Provider: "anthropic"},
{ID: "claude-fable-5", Label: "Claude Fable 5", Provider: "anthropic"},
{ID: "claude-opus-4-8", Label: "Claude Opus 4.8", Provider: "anthropic"},
{ID: "claude-opus-4-7", Label: "Claude Opus 4.7", Provider: "anthropic"},
Expand Down
8 changes: 4 additions & 4 deletions server/pkg/agent/models_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -88,8 +88,8 @@ func TestClaudeStaticModelsExposesFable5(t *testing.T) {
if fable.Label != "Claude Fable 5" || fable.Provider != "anthropic" || fable.Default {
t.Errorf("unexpected Fable entry: %+v", fable)
}
if defaults != 1 || !ids["claude-sonnet-4-6"].Default {
t.Errorf("expected Sonnet 4.6 to remain the sole default, got defaults=%d models=%+v", defaults, models)
if defaults != 0 {
t.Errorf("claude catalog must flag no Default: the CLI resolves an unset model from its own config, got defaults=%d models=%+v", defaults, models)
}
}

Expand All @@ -111,8 +111,8 @@ func TestClaudeStaticModelsExposesSonnet5(t *testing.T) {
if sonnet.Label != "Claude Sonnet 5" || sonnet.Provider != "anthropic" || sonnet.Default {
t.Errorf("unexpected Sonnet 5 entry: %+v", sonnet)
}
if defaults != 1 || !ids["claude-sonnet-4-6"].Default {
t.Errorf("expected Sonnet 4.6 to remain the sole default, got defaults=%d models=%+v", defaults, models)
if defaults != 0 {
t.Errorf("claude catalog must flag no Default: the CLI resolves an unset model from its own config, got defaults=%d models=%+v", defaults, models)
}
}

Expand Down
25 changes: 20 additions & 5 deletions server/pkg/agent/thinking.go
Original file line number Diff line number Diff line change
Expand Up @@ -590,11 +590,22 @@ func parseCodebuddyEffortHelp(helpText string) []string {
// fails closed: the daemon drops the level rather than injecting one that
// may not fit. Users who need a specific effort must pick an explicit
// model. (MUL-4347 review.)
// - claude and opencode: neither exposes a headless "which model would you
// resolve to" query, so Multica cannot know the effective model. Claude
// Code takes its default from the user's own settings files / env
// (`~/.claude/settings.json`, project settings, managed policy,
// ANTHROPIC_MODEL) and dropped its `config` subcommand, so a catalog-side
// Default flag would be Multica guessing on the CLI's behalf — it would
// reject `xhigh` for a user whose CLI is configured to Opus. Both
// therefore accept a level any advertised model supports and let the CLI
// arbitrate, which matches the daemon guard's own fail-open philosophy
// ("if we can't tell, keep the level and let the CLI object"). This only
// ever widens: nothing that validates today starts failing.
// - other providers: empty model resolves to the catalog's Default entry
// so a default-model task with a valid thinking_level isn't misjudged as
// "unknown model → reject" (the misjudgement flagged in an earlier
// review). opencode has no single default, so it accepts a level any
// advertised model supports.
// review). That entry is discovered from the runtime itself (e.g. an ACP
// `currentModelId`), not hand-maintained.
//
// The lookup goes through ListModels so it sees the *current* CLI
// catalog (including dynamic discovery for codex), not just a static
Expand All @@ -618,6 +629,13 @@ func ValidateThinkingLevel(ctx context.Context, providerType, executablePath, mo
}
target := model
if target == "" {
// Providers whose unset model resolves from local configuration we
// cannot read: accept anything the advertised catalog supports and let
// the CLI arbitrate, rather than substituting a model of our own
// choosing (see doc comment).
if providerType == "claude" || providerType == "opencode" {
return anyModelSupportsThinkingValue(models, value), nil
}
// Default model = the entry the catalog marks as Default. If no
// entry is flagged, fall through to the no-match return; that
// matches the existing semantics where an unknown model fails
Expand All @@ -629,9 +647,6 @@ func ValidateThinkingLevel(ctx context.Context, providerType, executablePath, mo
}
}
if target == "" {
if providerType == "opencode" {
return anyModelSupportsThinkingValue(models, value), nil
}
return false, nil
}
}
Expand Down
44 changes: 30 additions & 14 deletions server/pkg/agent/thinking_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -492,13 +492,17 @@ func TestCodexAdvertisedLevelsArePersistable(t *testing.T) {
// ── ValidateThinkingLevel default-model handling ─────────────────────
//
// Elon's PR1 review called out that an empty model on a default-model
// task must not be misjudged as "unknown model → reject". The fix is to
// resolve empty model to the catalog's default entry inside the
// validator. Both the daemon's per-model guard and the server's API
// layer call this; if it gets default-model wrong, any agent without an
// explicit model set would have its thinking_level dropped silently.
// task must not be misjudged as "unknown model → reject". Both the daemon's
// per-model guard and the server's API layer call this; if it gets
// default-model wrong, any agent without an explicit model set would have
// its thinking_level dropped silently.
//
// For claude the resolution is the catalog union, not a Multica-chosen
// default: Claude Code reads its own settings / ANTHROPIC_MODEL and offers
// no headless query for the result, so picking one entry here would reject
// levels that the user's actually-configured model supports.

func TestValidateThinkingLevel_EmptyModelResolvesToDefault(t *testing.T) {
func TestValidateThinkingLevel_EmptyClaudeModelAcceptsCatalogUnion(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("shell-script fake binary requires a POSIX shell")
}
Expand All @@ -515,10 +519,7 @@ func TestValidateThinkingLevel_EmptyModelResolvesToDefault(t *testing.T) {

ctx := context.Background()

t.Run("valid level on default model passes", func(t *testing.T) {
// Claude's catalog flags Sonnet 4.6 as Default. Sonnet supports
// low/medium/high/max (no xhigh) per claudeModelEffortAllow, so
// "high" must round-trip when model is left empty.
t.Run("level any advertised model supports passes", func(t *testing.T) {
ok, err := ValidateThinkingLevel(ctx, "claude", fakeClaude, "", "high")
if err != nil {
t.Fatalf("unexpected err: %v", err)
Expand All @@ -528,15 +529,30 @@ func TestValidateThinkingLevel_EmptyModelResolvesToDefault(t *testing.T) {
}
})

t.Run("invalid level on default model fails", func(t *testing.T) {
// "xhigh" is opus-only; resolving "" to default (sonnet 4.6)
// should reject it, not silently accept.
t.Run("opus-only level is not blocked by an unset model", func(t *testing.T) {
// The regression this pins: `xhigh` is Opus-only, and Claude Code's
// unset model can perfectly well resolve to Opus via the user's own
// settings. Validating against a Multica-chosen Sonnet default used
// to drop `--effort xhigh` for those users, silently downgrading the
// run to the CLI's own effort with only a daemon-log warning.
ok, err := ValidateThinkingLevel(ctx, "claude", fakeClaude, "", "xhigh")
if err != nil {
t.Fatalf("unexpected err: %v", err)
}
if !ok {
t.Errorf("xhigh must survive an unset claude model; got false")
}
})

t.Run("token no advertised model supports still fails", func(t *testing.T) {
// Widening to the union is not the same as accepting anything: a
// token outside the whole Claude catalog must still fail closed.
ok, err := ValidateThinkingLevel(ctx, "claude", fakeClaude, "", "ultra")
if err != nil {
t.Fatalf("unexpected err: %v", err)
}
if ok {
t.Errorf("xhigh should be invalid on sonnet (the default model); got true")
t.Errorf("`ultra` is a Codex-only token; must not validate for claude")
}
})

Expand Down
Loading