fix(coding-agent): preserved explicit :auto suffix in modelRoles

The model selector's persistence path dropped the `:auto` selector when parsing role values, producing a warning ('Invalid thinking level "auto"') and rendering the badge as `inherit` instead of `auto`. Reload of the default role also lost the auto state whenever the role value carried an explicit `:auto` suffix instead of relying on `defaultThinkingLevel`.

Widen the resolver chain (`parseThinkingSuffix`, `splitThinkingSuffix`, `parseModelString`, `parseModelPattern*`, `ResolvedModelRoleValue`, `ResolvedRoleModel`, `ResolveCliModelResult`) to carry the `AUTO_THINKING` sentinel end to end, and coerce it back to `undefined` at concrete-only boundaries (glob scope patterns, retry fallback, advisor, commit pipeline, guided-goal, bench).

Regression tests cover:

- `resolveModelRoleValue("provider/model:auto")` returns explicit auto without a warning.

- `ModelSelector` renders `DEFAULT (auto)` and `SMOL (auto)` when the role value has `:auto`.

- `cycleRoleModels` activates auto thinking on entering a `:auto` role.

- Startup resume activates auto thinking when `modelRoles.default` carries `:auto`.

Fixes #4128
This commit is contained in:
roboomp
2026-07-01 08:18:42 +00:00
parent f70e4f1570
commit a4ae4c130c
13 changed files with 178 additions and 39 deletions
+4
View File
@@ -2,6 +2,10 @@
## [Unreleased]
### Fixed
- Fixed explicit `provider/model:auto` entries in `modelRoles` collapsing to `inherit` in the model selector and losing their auto state on reload; the `:auto` selector is now preserved as an explicit thinking level end to end ([#4128](https://github.com/can1357/oh-my-pi/issues/4128)).
## [16.2.12] - 2026-07-01
### Breaking Changes
+5 -2
View File
@@ -30,7 +30,7 @@ import { buildServiceTierByFamily, serviceTierForAllFamilies, serviceTierSetting
import { Settings } from "../config/settings";
import benchPrompt from "../prompts/bench.md" with { type: "text" };
import { discoverAuthStorage, loadCliExtensionProviders } from "../sdk";
import { resolveThinkingLevelForModel, shouldDisableReasoning, toReasoningEffort } from "../thinking";
import { AUTO_THINKING, resolveThinkingLevelForModel, shouldDisableReasoning, toReasoningEffort } from "../thinking";
const DEFAULT_RUNS = 10;
const DEFAULT_PAR = 4;
@@ -477,7 +477,10 @@ function resolveBenchModels(
resolved.push({
selector,
model,
thinking: resolveThinkingLevelForModel(model, result.thinkingLevel),
thinking: resolveThinkingLevelForModel(
model,
result.thinkingLevel === AUTO_THINKING ? undefined : result.thinkingLevel,
),
});
}
if (errors.length > 0) {
@@ -11,6 +11,7 @@ import {
import { MODEL_ROLE_IDS } from "../config/model-roles";
import type { Settings } from "../config/settings";
import MODEL_PRIO from "../priority.json" with { type: "json" };
import { AUTO_THINKING, type ConfiguredThinkingLevel } from "../thinking";
export interface ResolvedCommitModel {
model: Model<Api>;
@@ -28,6 +29,13 @@ type CommitModelRegistry = ModelLookupRegistry &
getApiKey: (model: Model<Api>) => Promise<string | undefined>;
};
// Commit-time inference is stateless: session-level auto classification isn't
// available, so an explicit `:auto` selector collapses to "no override" and
// the model's own default level fills in.
function coerceCommitThinkingLevel(level: ConfiguredThinkingLevel | undefined): ThinkingLevel | undefined {
return level === AUTO_THINKING ? undefined : level;
}
export async function resolvePrimaryModel(
override: string | undefined,
settings: Settings,
@@ -49,7 +57,7 @@ export async function resolvePrimaryModel(
return {
model,
apiKey: modelRegistry.resolver(model),
thinkingLevel: resolved?.thinkingLevel,
thinkingLevel: coerceCommitThinkingLevel(resolved?.thinkingLevel),
};
}
@@ -67,7 +75,7 @@ export async function resolveSmolModel(
return {
model: resolvedSmol.model,
apiKey: modelRegistry.resolver(resolvedSmol.model),
thinkingLevel: resolvedSmol.thinkingLevel,
thinkingLevel: coerceCommitThinkingLevel(resolvedSmol.thinkingLevel),
};
}
}
@@ -29,7 +29,12 @@ import { fuzzyMatch } from "@oh-my-pi/pi-tui";
import { logger } from "@oh-my-pi/pi-utils";
import chalk from "chalk";
import MODEL_PRIO from "../priority.json" with { type: "json" };
import { parseThinkingLevel, resolveThinkingLevelForModel } from "../thinking";
import {
AUTO_THINKING,
type ConfiguredThinkingLevel,
parseThinkingLevel,
resolveThinkingLevelForModel,
} from "../thinking";
import { isAuthenticated, kNoAuth, type ModelRegistry } from "./model-registry";
import { MODEL_ROLE_IDS, type ModelRole } from "./model-roles";
import type { Settings } from "./settings";
@@ -81,7 +86,8 @@ interface ModelStringParseOptions extends ThinkingSuffixOptions {
}
const MAX_THINKING_SUFFIX_OPTIONS: ThinkingSuffixOptions = { allowMaxAlias: true };
function parseThinkingSuffix(value: string, options?: ThinkingSuffixOptions): ThinkingLevel | undefined {
function parseThinkingSuffix(value: string, options?: ThinkingSuffixOptions): ConfiguredThinkingLevel | undefined {
if (value === AUTO_THINKING) return AUTO_THINKING;
const level = parseThinkingLevel(value);
if (level !== undefined) return level;
return options?.allowMaxAlias === true && value === "max" ? ThinkingLevel.XHigh : undefined;
@@ -90,17 +96,17 @@ function parseThinkingSuffix(value: string, options?: ThinkingSuffixOptions): Th
/**
* Split a trailing `:<level>` thinking selector off a model pattern.
*
* `level` is set only when the suffix parses as a valid thinking level, in
* which case `base` has the suffix stripped; otherwise `base` is the input.
* `minColonIndex` requires the colon to appear strictly after that index —
* role-alias callers pass `PREFIX_MODEL_ROLE.length` so the base is at least
* as long as the `pi/` prefix.
* `level` is set when the suffix parses as a concrete thinking level or the
* `auto` sentinel, in which case `base` has the suffix stripped; otherwise
* `base` is the input. `minColonIndex` requires the colon to appear strictly
* after that index — role-alias callers pass `PREFIX_MODEL_ROLE.length` so the
* base is at least as long as the `pi/` prefix.
*/
function splitThinkingSuffix(
pattern: string,
minColonIndex = -1,
options?: ThinkingSuffixOptions,
): { base: string; level?: ThinkingLevel } {
): { base: string; level?: ConfiguredThinkingLevel } {
const colonIdx = pattern.lastIndexOf(":");
if (colonIdx <= minColonIndex) return { base: pattern };
const level = parseThinkingSuffix(pattern.slice(colonIdx + 1), options);
@@ -119,12 +125,20 @@ function resolveGlobScopePattern(
pattern: string,
availableModels: readonly Model<Api>[],
): { models: Model<Api>[]; thinkingLevel?: ThinkingLevel; explicitThinkingLevel: boolean } {
// Glob scopes describe which models are enabled, not per-role thinking.
// Coerce the `auto` sentinel to a concrete-only view so scope callers stay
// typed on `ThinkingLevel` and `enabledModels: [\"openai/*:auto\"]` doesn't
// pin a stray per-model level.
const concrete = (level: ConfiguredThinkingLevel | undefined): ThinkingLevel | undefined =>
level === AUTO_THINKING ? undefined : level;
const strictSuffix = splitThinkingSuffix(pattern);
if (strictSuffix.level !== undefined) {
const thinkingLevel = concrete(strictSuffix.level);
return {
models: matchingGlobModels(strictSuffix.base, availableModels),
thinkingLevel: strictSuffix.level,
explicitThinkingLevel: true,
thinkingLevel,
explicitThinkingLevel: thinkingLevel !== undefined,
};
}
@@ -134,10 +148,11 @@ function resolveGlobScopePattern(
if (literalMatches.length > 0) {
return { models: literalMatches, thinkingLevel: undefined, explicitThinkingLevel: false };
}
const thinkingLevel = concrete(maxSuffix.level);
return {
models: matchingGlobModels(maxSuffix.base, availableModels),
thinkingLevel: maxSuffix.level,
explicitThinkingLevel: true,
thinkingLevel,
explicitThinkingLevel: thinkingLevel !== undefined,
};
}
@@ -155,7 +170,7 @@ function resolveGlobScopePattern(
export function parseModelString(
modelStr: string,
options?: ModelStringParseOptions,
): { provider: string; id: string; thinkingLevel?: ThinkingLevel } | undefined {
): { provider: string; id: string; thinkingLevel?: ConfiguredThinkingLevel } | undefined {
const slashIdx = modelStr.indexOf("/");
if (slashIdx <= 0) return undefined;
const id = modelStr.slice(slashIdx + 1);
@@ -208,7 +223,7 @@ export function formatModelStringWithRouting(model: Model<Api>): string {
return upstream ? `${selector}@${upstream}` : selector;
}
export function formatModelSelectorValue(selector: string, thinkingLevel: ThinkingLevel | undefined): string {
export function formatModelSelectorValue(selector: string, thinkingLevel: ConfiguredThinkingLevel | undefined): string {
return thinkingLevel && thinkingLevel !== ThinkingLevel.Inherit ? `${selector}:${thinkingLevel}` : selector;
}
@@ -711,7 +726,7 @@ function matchModel(
export interface ParsedModelResult {
model: Model<Api> | undefined;
/** Thinking level if explicitly specified in pattern, undefined otherwise */
thinkingLevel?: ThinkingLevel;
thinkingLevel?: ConfiguredThinkingLevel;
/** Upstream provider slug from an `@upstream` routing selector, if present. */
upstream?: string;
warning: string | undefined;
@@ -989,7 +1004,7 @@ export function resolveAgentModelPatterns(options: AgentModelPatternResolutionOp
*/
export interface ResolvedModelRoleValue {
model: Model<Api> | undefined;
thinkingLevel?: ThinkingLevel;
thinkingLevel?: ConfiguredThinkingLevel;
explicitThinkingLevel: boolean;
warning: string | undefined;
}
@@ -1021,7 +1036,9 @@ export function resolveModelRoleValue(
return {
model: resolved.model,
thinkingLevel: resolved.explicitThinkingLevel
? (resolveThinkingLevelForModel(resolved.model, resolved.thinkingLevel) ?? resolved.thinkingLevel)
? resolved.thinkingLevel === AUTO_THINKING
? AUTO_THINKING
: (resolveThinkingLevelForModel(resolved.model, resolved.thinkingLevel) ?? resolved.thinkingLevel)
: resolved.thinkingLevel,
explicitThinkingLevel: resolved.explicitThinkingLevel,
warning: resolved.warning,
@@ -1048,7 +1065,7 @@ export function extractExplicitThinkingSelector(
value: string | undefined,
settings?: Settings,
options?: ExplicitThinkingSelectorOptions,
): ThinkingLevel | undefined {
): ConfiguredThinkingLevel | undefined {
if (!value) return undefined;
const normalized = value.trim();
if (!normalized || normalized === DEFAULT_MODEL_ROLE) return undefined;
@@ -1127,7 +1144,7 @@ export function resolveModelOverride(
modelPatterns: string[],
modelRegistry: ModelLookupRegistry,
settings?: Settings,
): { model?: Model<Api>; thinkingLevel?: ThinkingLevel; explicitThinkingLevel: boolean } {
): { model?: Model<Api>; thinkingLevel?: ConfiguredThinkingLevel; explicitThinkingLevel: boolean } {
if (modelPatterns.length === 0) return { explicitThinkingLevel: false };
const availableModels = modelRegistry.getAvailable();
const matchPreferences = getModelMatchPreferences(settings);
@@ -1171,7 +1188,7 @@ export async function resolveModelOverrideWithAuthFallback(
settings?: Settings,
): Promise<{
model?: Model<Api>;
thinkingLevel?: ThinkingLevel;
thinkingLevel?: ConfiguredThinkingLevel;
explicitThinkingLevel: boolean;
authFallbackUsed: boolean;
}> {
@@ -1207,7 +1224,7 @@ export function resolveRoleSelection(
roles: readonly string[],
settings: Settings,
availableModels: Model<Api>[],
): { model: Model<Api>; thinkingLevel?: ThinkingLevel } | undefined {
): { model: Model<Api>; thinkingLevel?: ConfiguredThinkingLevel } | undefined {
const matchPreferences = getModelMatchPreferences(settings);
for (const role of roles) {
const resolved = resolveModelRoleValue(settings.getModelRole(role), availableModels, {
@@ -1232,7 +1249,7 @@ export function resolveRoleSelection(
export function resolveAdvisorRoleSelection(
settings: Settings,
availableModels: Model<Api>[],
): { model: Model<Api>; thinkingLevel?: ThinkingLevel } | undefined {
): { model: Model<Api>; thinkingLevel?: ConfiguredThinkingLevel } | undefined {
const resolved = resolveModelRoleValue(`${PREFIX_MODEL_ROLE}advisor`, availableModels, {
settings,
matchPreferences: getModelMatchPreferences(settings),
@@ -1307,7 +1324,13 @@ export async function resolveModelScope(
continue;
}
addScopedModel(model, thinkingLevel, explicitThinkingLevel);
// Scoped models (Ctrl+P cycling) carry concrete per-model overrides;
// `auto` lives on the session, so drop the sentinel here.
if (thinkingLevel === AUTO_THINKING) {
addScopedModel(model, undefined, false);
} else {
addScopedModel(model, thinkingLevel, explicitThinkingLevel);
}
}
return scopedModels;
@@ -1393,7 +1416,7 @@ export function filterAvailableModelsByEnabledPatterns(
export interface ResolveCliModelResult {
model: Model<Api> | undefined;
selector?: string;
thinkingLevel?: ThinkingLevel;
thinkingLevel?: ConfiguredThinkingLevel;
warning: string | undefined;
error: string | undefined;
}
@@ -5,7 +5,7 @@ import { extractTextContent, extractToolCall, parseJsonPayload } from "../commit
import guidedGoalInterviewPrompt from "../prompts/goals/guided-goal-interview.md" with { type: "text" };
import guidedGoalSystemPrompt from "../prompts/goals/guided-goal-system.md" with { type: "text" };
import type { AgentSession } from "../session/agent-session";
import { shouldDisableReasoning, toReasoningEffort } from "../thinking";
import { AUTO_THINKING, shouldDisableReasoning, toReasoningEffort } from "../thinking";
const RESPOND_TOOL_NAME = "respond";
@@ -102,8 +102,10 @@ export async function runGuidedGoalTurn(
{
apiKey: session.modelRegistry.resolver(resolved.model, session.sessionId),
signal: options.signal,
reasoning: toReasoningEffort(resolved.thinkingLevel),
disableReasoning: shouldDisableReasoning(resolved.thinkingLevel),
reasoning: toReasoningEffort(resolved.thinkingLevel === AUTO_THINKING ? undefined : resolved.thinkingLevel),
disableReasoning: shouldDisableReasoning(
resolved.thinkingLevel === AUTO_THINKING ? undefined : resolved.thinkingLevel,
),
toolChoice: { type: "tool", name: RESPOND_TOOL_NAME },
},
{ telemetry: resolveTelemetry(session.agent.telemetry, session.sessionId), oneshotKind: "guided_goal_setup" },
@@ -912,7 +912,7 @@ export class ModelSelectorComponent extends Container {
}
#getResolvedRoleThinkingLevel(
role: string,
resolved: { explicitThinkingLevel: boolean; thinkingLevel?: ThinkingLevel },
resolved: { explicitThinkingLevel: boolean; thinkingLevel?: ConfiguredThinkingLevel },
): ConfiguredThinkingLevel {
if (resolved.explicitThinkingLevel && resolved.thinkingLevel !== undefined) {
return resolved.thinkingLevel;
+1 -1
View File
@@ -1258,7 +1258,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
? getRestorableSessionModels(existingSession.models, sessionManager.getLastModelChangeRole())
: [];
let restoredSessionModelIndex = -1;
let restoredSessionThinkingLevel: ThinkingLevel | undefined;
let restoredSessionThinkingLevel: ConfiguredThinkingLevel | undefined;
if (!hasExplicitModel && !model && sessionModelStrings.length > 0) {
logger.time("restoreSessionModel", () => {
let failedSessionModel: string | undefined;
@@ -727,7 +727,7 @@ export interface RoleModelCycleResult {
export interface ResolvedRoleModel {
role: string;
model: Model;
thinkingLevel?: ThinkingLevel;
thinkingLevel?: ConfiguredThinkingLevel;
explicitThinkingLevel: boolean;
}
@@ -877,7 +877,7 @@ function parseRetryFallbackSelector(
raw: trimmed,
provider: parsed.provider,
id: parsed.id,
thinkingLevel: parsed.thinkingLevel,
thinkingLevel: parsed.thinkingLevel === AUTO_THINKING ? undefined : parsed.thinkingLevel,
};
}
@@ -2102,7 +2102,7 @@ export class AgentSession {
if (config.model) {
const resolved = resolveModelOverride([config.model], this.#modelRegistry, this.settings);
model = resolved.model;
thinkingLevel = resolved.thinkingLevel;
thinkingLevel = resolved.thinkingLevel === AUTO_THINKING ? undefined : resolved.thinkingLevel;
if (!model) {
this.emitNotice("warning", `Advisor "${config.name}": no model matched "${config.model}"`, "advisor");
continue;
@@ -2116,7 +2116,7 @@ export class AgentSession {
continue;
}
model = sel.model;
thinkingLevel = sel.thinkingLevel;
thinkingLevel = sel.thinkingLevel === AUTO_THINKING ? undefined : sel.thinkingLevel;
}
const advisorModel = model;
const advisorName = config.name;
@@ -12,7 +12,7 @@ import { getModelMatchPreferences, resolveModelRoleValue } from "../config/model
import type { Settings } from "../config/settings";
import MODEL_PRIO from "../priority.json" with { type: "json" };
import commitSystemPrompt from "../prompts/system/commit-message-system.md" with { type: "text" };
import { toReasoningEffort } from "../thinking";
import { AUTO_THINKING, toReasoningEffort } from "../thinking";
const COMMIT_SYSTEM_PROMPT = prompt.render(commitSystemPrompt);
const MAX_DIFF_CHARS = 4000;
@@ -56,7 +56,10 @@ function getSmolModelCandidates(
settings,
matchPreferences,
});
addCandidate(configuredSmol.model, configuredSmol.thinkingLevel);
addCandidate(
configuredSmol.model,
configuredSmol.thinkingLevel === AUTO_THINKING ? undefined : configuredSmol.thinkingLevel,
);
for (const pattern of MODEL_PRIO.smol) {
const needle = pattern.toLowerCase();
@@ -11,6 +11,7 @@ import { AuthStorage } from "@oh-my-pi/pi-coding-agent/session/auth-storage";
import { getRestorableSessionModels } from "@oh-my-pi/pi-coding-agent/session/session-context";
import { EPHEMERAL_MODEL_CHANGE_ROLE } from "@oh-my-pi/pi-coding-agent/session/session-entries";
import { SessionManager } from "@oh-my-pi/pi-coding-agent/session/session-manager";
import { AUTO_THINKING } from "@oh-my-pi/pi-coding-agent/thinking";
import { TempDir } from "@oh-my-pi/pi-utils";
describe("AgentSession model persistence", () => {
@@ -408,6 +409,22 @@ describe("AgentSession model persistence", () => {
expect(result.session.model?.id).toBe(temporaryModel.id);
});
it("activates auto thinking on startup resume when modelRoles.default carries an explicit :auto suffix", async () => {
const defaultModel = getAnthropicModelOrThrow("claude-sonnet-4-5");
const targetSessionFile = await writeRoleModelSession(
modelValue(defaultModel),
modelValue(defaultModel),
"default",
);
const settings = Settings.isolated();
settings.setModelRole("default", `${modelValue(defaultModel)}:auto`);
const result = await createStartupResumeSession(targetSessionFile, settings);
expect(result.session.model?.id).toBe(defaultModel.id);
expect(result.session.configuredThinkingLevel()).toBe(AUTO_THINKING);
});
it("lists restorable temporary model before the default fallback", () => {
expect(
getRestorableSessionModels(
@@ -110,6 +110,25 @@ describe("AgentSession role model thinking behavior", () => {
expect(session.thinkingLevel).toBe("off");
});
it("activates auto thinking when cycling into a role whose value carries an explicit :auto suffix", async () => {
const defaultModel = getAnthropicModelOrThrow("claude-sonnet-4-5");
const smolModel = getAnthropicModelOrThrow("claude-sonnet-4-6");
await createSession({
initialModelId: defaultModel.id,
initialThinkingLevel: Effort.High,
modelRoles: {
default: `${defaultModel.provider}/${defaultModel.id}`,
smol: `${smolModel.provider}/${smolModel.id}:auto`,
},
});
const toSmol = await session.cycleRoleModels(["default", "smol"]);
expect(toSmol?.role).toBe("smol");
expect(toSmol?.model.id).toBe(smolModel.id);
expect(session.configuredThinkingLevel()).toBe(AUTO_THINKING);
});
it("preserves current thinking when switching into default/no-suffix role", async () => {
const defaultModel = getAnthropicModelOrThrow("claude-sonnet-4-5");
const slowModel = getAnthropicModelOrThrow("claude-sonnet-4-6");
@@ -611,6 +611,25 @@ describe("resolveModelRoleValue", () => {
expect(result.thinkingLevel).toBe(Effort.High);
expect(result.explicitThinkingLevel).toBe(true);
});
test("preserves an explicit :auto suffix as an explicit thinking selector", () => {
const result = resolveModelRoleValue("anthropic/claude-sonnet-4-5:auto", allModels);
expect(result.model?.provider).toBe("anthropic");
expect(result.model?.id).toBe("claude-sonnet-4-5");
expect(result.thinkingLevel).toBe("auto");
expect(result.explicitThinkingLevel).toBe(true);
expect(result.warning).toBeUndefined();
});
test("does not clamp :auto against the model's supported efforts", () => {
// claude-sonnet-4-5 caps at "high"; ensure auto isn't collapsed onto it
// by resolveThinkingLevelForModel.
const result = resolveModelRoleValue("anthropic/claude-sonnet-4-5:auto", allModels);
expect(result.thinkingLevel).toBe("auto");
expect(result.explicitThinkingLevel).toBe(true);
});
});
describe("resolveAgentModelPatterns", () => {
test("falls back to the active session model when pi/task is unset", () => {
@@ -174,6 +174,47 @@ describe("ModelSelector role badge thinking display", () => {
expect(rendered).toContain("DEFAULT (auto)");
});
test("renders DEFAULT (auto) when modelRoles.default carries an explicit :auto suffix", async () => {
installTestTheme();
const model = getBundledModel("openai", "gpt-5.5");
if (!model) throw new Error("Expected bundled model openai/gpt-5.5");
const settings = Settings.isolated({
modelRoles: {
default: `${model.provider}/${model.id}:auto`,
},
});
const selector = createSelector(model, settings);
await Bun.sleep(0);
installTestTheme();
const rendered = normalizeRenderedText(selector.render(220).join("\n"));
expect(rendered).toContain("DEFAULT (auto)");
expect(rendered).not.toContain("DEFAULT (inherit)");
});
test("renders SMOL (auto) when modelRoles.smol carries an explicit :auto suffix", async () => {
installTestTheme();
const model = getBundledModel("openai", "gpt-5.5");
if (!model) throw new Error("Expected bundled model openai/gpt-5.5");
const settings = Settings.isolated({
modelRoles: {
default: `${model.provider}/${model.id}`,
smol: `${model.provider}/${model.id}:auto`,
},
});
const selector = createSelector(model, settings);
await Bun.sleep(0);
installTestTheme();
const rendered = normalizeRenderedText(selector.render(220).join("\n"));
expect(rendered).toContain("SMOL (auto)");
expect(rendered).not.toContain("SMOL (inherit)");
});
test("shows compact auto badges for unconfigured role defaults", async () => {
installTestTheme();
const settings = Settings.isolated({});