Files
oh-my-pi/packages/coding-agent/test/auto-thinking-classifier.test.ts
T
Éverton Toffanetto fbd3ebffc6 feat(coding-agent): add opt-in max ceiling for auto thinking
`max` became a first-class effort tier in d435385a, but the `auto`
classifier prompt still offers only `low|medium|high|xhigh`. On a model
that exposes the tier, `auto` can therefore never reach it — only the
`ultrathink` keyword can, because it bypasses the classifier entirely.

`providers.autoThinkingMaxEffort` (`xhigh` | `max`, default `xhigh`) lifts
that ceiling. Opting in adds `max` to the classifier vocabulary, gated on
the target model actually supporting the tier, and scopes the tie-break
exception to that prompt variant so the default renders byte-for-byte as
before. A classification above the configured ceiling is clamped before
the model clamp, so a hallucinated `max` cannot cross a ceiling the user
did not opt into. The on-device 3-bucket classifier stays capped at
`xhigh`, and the provisional/fallback level still never provisions `max`.

Also corrects the two `Auto-detect per prompt (low-xhigh)` labels and the
stale `xhigh auto ceiling` comment, which the new setting makes wrong.
2026-07-26 03:16:56 -03:00

458 lines
18 KiB
TypeScript

import { afterEach, describe, expect, it, vi } from "bun:test";
import * as path from "node:path";
import { ThinkingLevel } from "@oh-my-pi/pi-agent-core";
import * as ai from "@oh-my-pi/pi-ai";
import { Effort, type Model } from "@oh-my-pi/pi-ai";
import { buildModel } from "@oh-my-pi/pi-catalog/build";
import { getBundledModel } from "@oh-my-pi/pi-catalog/models";
import {
classifyDifficulty,
parseDifficultyBucket,
parseDifficultyLevel,
} from "@oh-my-pi/pi-coding-agent/auto-thinking/classifier";
import { ModelRegistry } from "@oh-my-pi/pi-coding-agent/config/model-registry";
import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings";
import { AuthStorage } from "@oh-my-pi/pi-coding-agent/session/auth-storage";
import {
AUTO_THINKING,
clampAutoThinkingEffort,
parseCliThinkingLevel,
parseConfiguredThinkingLevel,
parseEffort,
parseThinkingLevel,
resolveProvisionalAutoLevel,
resolveTaskEffortLevel,
} from "@oh-my-pi/pi-coding-agent/thinking";
import type { TinyMemoryLocalModelKey } from "@oh-my-pi/pi-coding-agent/tiny/models";
import { tinyModelClient } from "@oh-my-pi/pi-coding-agent/tiny/title-client";
import { TempDir } from "@oh-my-pi/pi-utils";
describe("auto thinking classifier helpers", () => {
afterEach(() => {
vi.restoreAllMocks();
});
interface LocalClassifierFixture {
settings: Settings;
registry: ModelRegistry;
model: Model;
cleanup: () => void;
}
async function createLocalClassifierFixture(
autoThinkingModel: TinyMemoryLocalModelKey,
): Promise<LocalClassifierFixture> {
const tempDir = TempDir.createSync("@pi-auto-thinking-classifier-");
const authStorage = await AuthStorage.create(path.join(tempDir.path(), "auth.db"));
const model = getBundledModel("anthropic", "claude-sonnet-4-6");
if (!model) {
authStorage.close();
tempDir.removeSync();
throw new Error("Expected bundled Claude Sonnet 4.6 model");
}
return {
settings: Settings.isolated({ "providers.autoThinkingModel": autoThinkingModel }),
registry: new ModelRegistry(authStorage, path.join(tempDir.path(), "models.yml")),
model,
cleanup: () => {
authStorage.close();
tempDir.removeSync();
},
};
}
it("parses configured thinking without widening provider-facing thinking selectors", () => {
expect(parseConfiguredThinkingLevel(AUTO_THINKING)).toBe(AUTO_THINKING);
expect(parseConfiguredThinkingLevel(Effort.High)).toBe(Effort.High);
expect(parseConfiguredThinkingLevel("bogus")).toBeUndefined();
expect(parseThinkingLevel(AUTO_THINKING)).toBeUndefined();
expect(parseThinkingLevel(ThinkingLevel.Off)).toBe(ThinkingLevel.Off);
});
it("parses CLI --thinking selectors while rejecting inherit", () => {
expect(parseCliThinkingLevel(ThinkingLevel.Off)).toBe(ThinkingLevel.Off);
expect(parseCliThinkingLevel(AUTO_THINKING)).toBe(AUTO_THINKING);
expect(parseCliThinkingLevel("max")).toBe(ThinkingLevel.Max);
expect(parseCliThinkingLevel(ThinkingLevel.Inherit)).toBeUndefined();
expect(parseCliThinkingLevel("bogus")).toBeUndefined();
});
it("maps online level labels to effort levels", () => {
expect(parseDifficultyLevel("x-high")).toBe(Effort.XHigh);
expect(parseDifficultyLevel("The answer is HIGH.")).toBe(Effort.High);
expect(parseDifficultyLevel("med")).toBe(Effort.Medium);
expect(parseDifficultyLevel("low")).toBe(Effort.Low);
expect(parseDifficultyLevel("max")).toBe(Effort.Max);
expect(parseDifficultyLevel("unknown")).toBeUndefined();
});
it("maps local 3-bucket labels to coarse effort levels", () => {
expect(parseDifficultyBucket("trivial")).toBe(Effort.Low);
expect(parseDifficultyBucket("moderate")).toBe(Effort.High);
expect(parseDifficultyBucket("hard")).toBe(Effort.XHigh);
expect(parseDifficultyBucket("medium")).toBeUndefined();
});
it("expands the local reasoning classifier budget", async () => {
let maxTokens: number | undefined;
const fixture = await createLocalClassifierFixture("qwen3-1.7b");
vi.spyOn(tinyModelClient, "complete").mockImplementation(async (_modelKey, _prompt, options) => {
maxTokens = options?.maxTokens;
return "moderate";
});
try {
const effort = await classifyDifficulty("fix the local classifier token budget", {
settings: fixture.settings,
registry: fixture.registry,
model: fixture.model,
});
expect(effort).toBe(Effort.High);
expect(maxTokens).toBe(1024);
} finally {
fixture.cleanup();
}
});
it("uses a larger local non-reasoning classifier floor", async () => {
let maxTokens: number | undefined;
const fixture = await createLocalClassifierFixture("qwen2.5-1.5b");
vi.spyOn(tinyModelClient, "complete").mockImplementation(async (_modelKey, _prompt, options) => {
maxTokens = options?.maxTokens;
return "moderate";
});
try {
const effort = await classifyDifficulty("rename a local helper", {
settings: fixture.settings,
registry: fixture.registry,
model: fixture.model,
});
expect(effort).toBe(Effort.High);
expect(maxTokens).toBe(16);
} finally {
fixture.cleanup();
}
});
it("uses shared tiny-message preprocessing before local classification", async () => {
let classifierPrompt = "";
const fixture = await createLocalClassifierFixture("qwen2.5-1.5b");
vi.spyOn(tinyModelClient, "complete").mockImplementation(async (_modelKey, promptText) => {
classifierPrompt = promptText;
return "moderate";
});
try {
await classifyDifficulty(
"\u001b[31minvestigate failure\u001b[0m 54783db3f0f17c74cae81976f0e825a909deb71e\n```\nnoisy code\n```",
{
settings: fixture.settings,
registry: fixture.registry,
model: fixture.model,
},
);
expect(classifierPrompt).toContain("investigate failure 54783db");
expect(classifierPrompt).not.toContain("54783db3f0f17c74cae81976f0e825a909deb71e");
expect(classifierPrompt).not.toContain("noisy code");
} finally {
fixture.cleanup();
}
});
it("uses a reasoning-safe online classifier budget when the catalog disables reasoning", async () => {
const baseModel = getBundledModel("anthropic", "claude-sonnet-4-6");
if (!baseModel) throw new Error("Expected bundled Claude Sonnet 4.6 model");
const classifierModel = { ...baseModel, reasoning: false };
const settings = {
get(path: string) {
if (path === "providers.autoThinkingModel") return "online";
return undefined;
},
getModelRole(role: string) {
return role === "smol" ? `${classifierModel.provider}/${classifierModel.id}` : undefined;
},
getStorage() {
return undefined;
},
} as never;
const registry = {
getAvailable: () => [classifierModel],
getApiKey: async () => "test-key",
resolver: () => async () => "test-key",
} as never;
const completeSimpleMock = vi.spyOn(ai, "completeSimple").mockResolvedValue({
stopReason: "stop",
content: [{ type: "text", text: "high" }],
} as never);
const effort = await classifyDifficulty("add validation around the retry path", {
settings,
registry,
model: baseModel,
});
const options = completeSimpleMock.mock.calls[0]?.[2] as
| { disableReasoning?: boolean; maxTokens?: number }
| undefined;
expect(effort).toBe(Effort.High);
expect(options).toMatchObject({ disableReasoning: true, maxTokens: 1024 });
});
function createOnlineFixture(targetModel: Model, answer: string, maxEffort: "xhigh" | "max" = "xhigh") {
const classifierModel = getBundledModel("anthropic", "claude-sonnet-4-6");
if (!classifierModel) throw new Error("Expected bundled Claude Sonnet 4.6 model");
const settings = {
get(path: string) {
if (path === "providers.autoThinkingModel") return "online";
return path === "providers.autoThinkingMaxEffort" ? maxEffort : undefined;
},
getModelRole(role: string) {
return role === "smol" ? `${classifierModel.provider}/${classifierModel.id}` : undefined;
},
getStorage() {
return undefined;
},
} as never;
const registry = {
getAvailable: () => [classifierModel],
getApiKey: async () => "test-key",
resolver: () => async () => "test-key",
} as never;
const completeSimpleMock = vi.spyOn(ai, "completeSimple").mockResolvedValue({
stopReason: "stop",
content: [{ type: "text", text: answer }],
} as never);
return { deps: { settings, registry, model: targetModel }, completeSimpleMock };
}
function buildLadderModel(id: string, efforts: Effort[]): Model {
return buildModel({
id,
name: id,
api: "openai-completions",
provider: "mock",
baseUrl: "https://example.com",
reasoning: true,
thinking: { mode: "effort", efforts },
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 128_000,
maxTokens: 4096,
});
}
const MAX_LADDER = [Effort.Low, Effort.Medium, Effort.High, Effort.XHigh, Effort.Max];
const XHIGH_LADDER = [Effort.Low, Effort.Medium, Effort.High, Effort.XHigh];
it("offers the max label only when opted in on a model that exposes the tier", async () => {
const optedIn = createOnlineFixture(buildLadderModel("mock-max", MAX_LADDER), "high", "max");
await classifyDifficulty("refactor the scheduler", optedIn.deps);
const optedInRequest = optedIn.completeSimpleMock.mock.calls[0]?.[1] as { systemPrompt: string[] };
expect(optedInRequest.systemPrompt[0]).toContain("`max`");
vi.restoreAllMocks();
const defaulted = createOnlineFixture(buildLadderModel("mock-max", MAX_LADDER), "high");
await classifyDifficulty("refactor the scheduler", defaulted.deps);
const defaultedRequest = defaulted.completeSimpleMock.mock.calls[0]?.[1] as { systemPrompt: string[] };
expect(defaultedRequest.systemPrompt[0]).not.toContain("`max`");
expect(defaultedRequest.systemPrompt[0]).toContain("`xhigh`");
vi.restoreAllMocks();
const unsupported = createOnlineFixture(buildLadderModel("mock-xhigh", XHIGH_LADDER), "high", "max");
await classifyDifficulty("refactor the scheduler", unsupported.deps);
const unsupportedRequest = unsupported.completeSimpleMock.mock.calls[0]?.[1] as { systemPrompt: string[] };
expect(unsupportedRequest.systemPrompt[0]).not.toContain("`max`");
});
it("keeps the default prompt byte-identical to the pre-max wording", async () => {
const fixture = createOnlineFixture(buildLadderModel("mock-max", MAX_LADDER), "high");
await classifyDifficulty("refactor the scheduler", fixture.deps);
const request = fixture.completeSimpleMock.mock.calls[0]?.[1] as { systemPrompt: string[] };
expect(request.systemPrompt[0]).toBe(
[
"You are a difficulty classifier for a coding agent. Read the user's request and decide how much reasoning effort the agent should spend on it this turn.",
"",
"Reply with exactly one word — one of: `low`, `medium`, `high`, `xhigh`. No punctuation, no explanation, no other text.",
"",
"Levels:",
"",
"- `low` — Trivial or mechanical. A rename, a typo, a one-line edit, a formatting tweak, a direct factual question, or a request whose solution is obvious.",
"- `medium` — A localized change that needs some reasoning. A small self-contained feature, a straightforward bug fix in one place, or explaining a moderate piece of code.",
"- `high` — A non-trivial change. Spans multiple files or callers, requires real debugging, a moderate design decision, or a refactor with several moving parts.",
"- `xhigh` — Deep or open-ended. Subtle concurrency or algorithmic problems, cross-system reasoning, ambiguous requirements, large or risky refactors, or hard root-cause debugging.",
"",
"Judge the inherent difficulty of the task, not how politely or verbosely it is phrased. When torn between two levels, choose the lower one.",
].join("\n"),
);
});
it("resolves max only when opted in, and snaps it to the ceiling otherwise", async () => {
const optedIn = createOnlineFixture(buildLadderModel("mock-max", MAX_LADDER), "max", "max");
expect(await classifyDifficulty("untangle this cross-service race", optedIn.deps)).toBe(Effort.Max);
vi.restoreAllMocks();
// Hallucinated `max` on a max-capable model must not cross the default ceiling.
const defaulted = createOnlineFixture(buildLadderModel("mock-max", MAX_LADDER), "max");
expect(await classifyDifficulty("untangle this cross-service race", defaulted.deps)).toBe(Effort.XHigh);
});
it("resolves the sparse ladder's max tier when opted in", async () => {
const fixture = createOnlineFixture(buildLadderModel("mock-sparse", [Effort.High, Effort.Max]), "max", "max");
expect(await classifyDifficulty("cut over the storage layer", fixture.deps)).toBe(Effort.Max);
});
it("takes the first label when the classifier echoes several", async () => {
// `earliest()` is deliberately conservative: an echoed list resolves to the
// lowest-positioned label rather than the model's final word.
const fixture = createOnlineFixture(
buildLadderModel("mock-max", MAX_LADDER),
"low, medium, high, xhigh, max",
"max",
);
expect(await classifyDifficulty("rename a helper", fixture.deps)).toBe(Effort.Low);
});
it("snaps a hallucinated max back to the model's ceiling instead of failing the turn", async () => {
const fixture = createOnlineFixture(buildLadderModel("mock-xhigh", XHIGH_LADDER), "max");
expect(await classifyDifficulty("untangle this cross-service race", fixture.deps)).toBe(Effort.XHigh);
});
it("keeps the provisional auto level below max even when the model defaults to it", () => {
const maxDefaultModel = buildModel({
id: "mock-max-default",
name: "mock-max-default",
api: "openai-completions",
provider: "mock",
baseUrl: "https://example.com",
reasoning: true,
thinking: { mode: "effort", efforts: MAX_LADDER, defaultLevel: Effort.Max },
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 128_000,
maxTokens: 4096,
});
expect(resolveProvisionalAutoLevel(maxDefaultModel)).toBe(Effort.XHigh);
});
it("clamps auto effort to model support while never resolving below low", () => {
const model = getBundledModel("anthropic", "claude-sonnet-4-6");
if (!model) throw new Error("Expected bundled Claude Sonnet 4.6 model");
expect(clampAutoThinkingEffort(model, Effort.XHigh)).toBe(Effort.High);
expect(clampAutoThinkingEffort(model, Effort.Minimal)).toBe(Effort.Low);
});
it("clamps max down to the ladder ceiling on models without a max tier", () => {
const xhighCeilingModel = buildModel({
id: "mock-xhigh-ceiling",
name: "Mock XHigh Ceiling",
api: "openai-completions",
provider: "mock",
baseUrl: "https://example.com",
reasoning: true,
thinking: { mode: "effort", efforts: [Effort.Low, Effort.Medium, Effort.High, Effort.XHigh] },
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 128_000,
maxTokens: 4096,
});
expect(clampAutoThinkingEffort(xhighCeilingModel, Effort.Max)).toBe(Effort.XHigh);
});
it("returns undefined for reasoning models without controllable efforts (devin-agent shape)", () => {
// Repro for https://github.com/can1357/oh-my-pi/issues/3356 — Devin
// models report `reasoning: true` but expose no `thinking.efforts` (Cascade
// selects effort by routing to sibling model ids). `auto` must not invent
// a concrete effort here, or `requireSupportedEffort` throws in stream.ts.
const devinModel = {
id: "glm-5-2",
name: "GLM-5.2",
api: "devin-agent",
provider: "devin",
baseUrl: "https://server.codeium.com",
reasoning: true,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 128_000,
maxTokens: 4096,
} as Model;
expect(clampAutoThinkingEffort(devinModel, Effort.Low)).toBeUndefined();
expect(clampAutoThinkingEffort(devinModel, Effort.XHigh)).toBeUndefined();
expect(clampAutoThinkingEffort(devinModel, Effort.Max)).toBeUndefined();
expect(resolveProvisionalAutoLevel(devinModel)).toBeUndefined();
});
it("parses max as a real thinking level", () => {
expect(parseEffort("max")).toBe(Effort.Max);
expect(parseThinkingLevel("max")).toBe(ThinkingLevel.Max);
expect(parseConfiguredThinkingLevel("max")).toBe(ThinkingLevel.Max);
});
it("maps task effort selectors onto each model's supported thinking range", () => {
const xhighCeilingModel = buildModel({
id: "mock-xhigh-ceiling",
name: "Mock XHigh Ceiling",
api: "openai-completions",
provider: "mock",
baseUrl: "https://example.com",
reasoning: true,
thinking: { mode: "effort", efforts: [Effort.Low, Effort.Medium, Effort.High, Effort.XHigh] },
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 128_000,
maxTokens: 4096,
});
// hi = whatever the model tops out at; lo = its floor; med = middle of
// the supported range (lower-middle for an even-sized range).
expect(resolveTaskEffortLevel(xhighCeilingModel, "hi")).toBe(Effort.XHigh);
expect(resolveTaskEffortLevel(xhighCeilingModel, "lo")).toBe(Effort.Low);
expect(resolveTaskEffortLevel(xhighCeilingModel, "med")).toBe(Effort.Medium);
const sonnet = getBundledModel("anthropic", "claude-sonnet-4-6");
if (!sonnet) throw new Error("Expected bundled Claude Sonnet 4.6 model");
const sonnetEfforts = sonnet.thinking?.efforts ?? [];
expect(resolveTaskEffortLevel(sonnet, "hi")).toBe(sonnetEfforts[sonnetEfforts.length - 1]);
expect(resolveTaskEffortLevel(sonnet, "lo")).toBe(sonnetEfforts[0]);
// No controllable effort surface (devin-agent shape) → undefined, so the
// spawn falls back to its default selector instead of forcing an effort.
const devinModel = {
id: "glm-5-2",
name: "GLM-5.2",
api: "devin-agent",
provider: "devin",
baseUrl: "https://server.codeium.com",
reasoning: true,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 128_000,
maxTokens: 4096,
} as Model;
expect(resolveTaskEffortLevel(devinModel, "hi")).toBeUndefined();
// No model at all → full canonical range.
expect(resolveTaskEffortLevel(undefined, "lo")).toBe(Effort.Minimal);
expect(resolveTaskEffortLevel(undefined, "hi")).toBe(Effort.Max);
});
it("rejects inherited object keys as thinking selectors", () => {
for (const selector of ["toString", "constructor", "__proto__"]) {
expect(parseEffort(selector)).toBeUndefined();
expect(parseThinkingLevel(selector)).toBeUndefined();
expect(parseConfiguredThinkingLevel(selector)).toBeUndefined();
}
});
});