When entering plan mode, the thinking level configured on the plan role (e.g., 'anthropic/claude-sonnet-4-5:xhigh') was discarded because #resolveRoleModel() called resolveModelRoleValue() but only returned .model, dropping the thinking level. This is the same class of bug that was fixed in cycleRoleModels (which correctly preserves and applies thinking level from role configs). #applyPlanModeModel was the remaining unfixed call site. Changes: - Refactor #resolveRoleModel to #resolveRoleModelFull returning the full ResolvedModelRoleValue instead of just Model - Add resolveRoleModelWithThinking() public method exposing the full resolution result - Add optional thinkingLevel param to setModelTemporary() for atomic model+thinking switches (eliminates two-step set-then-apply pattern) - Bundle model+thinking into paired state objects in InteractiveMode (#planModePreviousModelState, #pendingModelSwitch) — eliminates 4 parallel fields that could desync - Simplify #applyPlanModeModel, flushPendingModelSwitch, #exitPlanMode to single setModelTemporary calls - Collapse cycleRoleModels two-step into single setModelTemporary call - Add 6 tests for resolveRoleModelWithThinking
112 lines
4.1 KiB
TypeScript
112 lines
4.1 KiB
TypeScript
/**
|
|
* Tests for plan mode thinking level propagation.
|
|
*
|
|
* Bug: When entering plan mode, the thinking level configured on the plan role
|
|
* (e.g., "anthropic/claude-sonnet-4-5:xhigh") is discarded. resolveRoleModel()
|
|
* calls resolveModelRoleValue() but only returns .model, dropping the thinking level.
|
|
* #applyPlanModeModel() therefore has no thinking level to apply.
|
|
*/
|
|
import { afterEach, beforeEach, describe, expect, it } from "bun:test";
|
|
import * as path from "node:path";
|
|
import { Agent, ThinkingLevel } from "@oh-my-pi/pi-agent-core";
|
|
import { ModelRegistry } from "@oh-my-pi/pi-coding-agent/config/model-registry";
|
|
import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings";
|
|
import { AgentSession } from "@oh-my-pi/pi-coding-agent/session/agent-session";
|
|
import { AuthStorage } from "@oh-my-pi/pi-coding-agent/session/auth-storage";
|
|
import { SessionManager } from "@oh-my-pi/pi-coding-agent/session/session-manager";
|
|
import { TempDir } from "@oh-my-pi/pi-utils";
|
|
|
|
describe("plan mode thinking level", () => {
|
|
let tempDir: TempDir;
|
|
let session: AgentSession;
|
|
let modelRegistry: ModelRegistry;
|
|
let authStorage: AuthStorage;
|
|
|
|
beforeEach(async () => {
|
|
tempDir = TempDir.createSync("@pi-plan-thinking-");
|
|
authStorage = await AuthStorage.create(path.join(tempDir.path(), "testauth.db"));
|
|
modelRegistry = new ModelRegistry(authStorage);
|
|
});
|
|
|
|
afterEach(async () => {
|
|
if (session) {
|
|
await session.dispose();
|
|
}
|
|
authStorage.close();
|
|
tempDir.removeSync();
|
|
});
|
|
|
|
function createSessionWithRoles(modelRoles: Record<string, string>): AgentSession {
|
|
const sonnet = modelRegistry.find("anthropic", "claude-sonnet-4-5");
|
|
if (!sonnet) throw new Error("Expected claude-sonnet-4-5 to exist in registry");
|
|
|
|
session = new AgentSession({
|
|
agent: new Agent({
|
|
initialState: { model: sonnet, systemPrompt: "Test", tools: [], messages: [] },
|
|
}),
|
|
sessionManager: SessionManager.inMemory(),
|
|
settings: Settings.isolated({ modelRoles }),
|
|
modelRegistry,
|
|
});
|
|
return session;
|
|
}
|
|
|
|
describe("resolveRoleModelWithThinking", () => {
|
|
it("returns thinking level when plan role includes a thinking suffix", () => {
|
|
createSessionWithRoles({ plan: "anthropic/claude-sonnet-4-5:xhigh" });
|
|
|
|
const result = session.resolveRoleModelWithThinking("plan");
|
|
|
|
expect(result.model).toBeDefined();
|
|
expect(result.model!.provider).toBe("anthropic");
|
|
expect(result.model!.id).toBe("claude-sonnet-4-5");
|
|
expect(result.thinkingLevel).toBe(ThinkingLevel.XHigh);
|
|
expect(result.explicitThinkingLevel).toBe(true);
|
|
});
|
|
|
|
it("returns no explicit thinking level when plan role has no thinking suffix", () => {
|
|
createSessionWithRoles({ plan: "anthropic/claude-sonnet-4-5" });
|
|
|
|
const result = session.resolveRoleModelWithThinking("plan");
|
|
|
|
expect(result.model).toBeDefined();
|
|
expect(result.model!.id).toBe("claude-sonnet-4-5");
|
|
expect(result.explicitThinkingLevel).toBe(false);
|
|
});
|
|
|
|
it("returns no model when no plan role is configured", () => {
|
|
createSessionWithRoles({});
|
|
|
|
const result = session.resolveRoleModelWithThinking("plan");
|
|
|
|
expect(result.model).toBeUndefined();
|
|
});
|
|
|
|
it("returns thinking level for different levels", () => {
|
|
createSessionWithRoles({ plan: "anthropic/claude-sonnet-4-5:high" });
|
|
|
|
const result = session.resolveRoleModelWithThinking("plan");
|
|
expect(result.thinkingLevel).toBe(ThinkingLevel.High);
|
|
expect(result.explicitThinkingLevel).toBe(true);
|
|
});
|
|
|
|
it("works with the default role", () => {
|
|
createSessionWithRoles({ default: "anthropic/claude-sonnet-4-5:medium" });
|
|
|
|
const result = session.resolveRoleModelWithThinking("default");
|
|
expect(result.model!.id).toBe("claude-sonnet-4-5");
|
|
expect(result.thinkingLevel).toBe(ThinkingLevel.Medium);
|
|
expect(result.explicitThinkingLevel).toBe(true);
|
|
});
|
|
|
|
it("resolveRoleModel still returns just the model (backward compat)", () => {
|
|
createSessionWithRoles({ plan: "anthropic/claude-sonnet-4-5:xhigh" });
|
|
|
|
const model = session.resolveRoleModel("plan");
|
|
expect(model).toBeDefined();
|
|
expect(model!.provider).toBe("anthropic");
|
|
expect(model!.id).toBe("claude-sonnet-4-5");
|
|
});
|
|
});
|
|
});
|