346ae48b0c
- Restricted `setModel` to persist settings only when `persist: true` is passed; all runtime switches (Ctrl+P, `--model`, `/model`, model picker temp selections) no longer overwrite `modelRoles.default`.
- Changed `cycleRoleModels` to accept a direction ("forward"/"backward") instead of a `temporary` flag; both directions now use `applyRoleModel` without persisting.
- Added `persist: true` exclusively to the model picker's "Set as default" action in `SelectorController`.
- Added test suite covering persistence behavior for `setModel`, `cycleRoleModels`, and `cycleModel`.
191 lines
6.7 KiB
TypeScript
191 lines
6.7 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, it } from "bun:test";
|
|
import * as path from "node:path";
|
|
import { Agent } from "@oh-my-pi/pi-agent-core";
|
|
import { type Api, Effort, getBundledModel, type Model } from "@oh-my-pi/pi-ai";
|
|
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("AgentSession model persistence", () => {
|
|
let tempDir: TempDir;
|
|
let session: AgentSession | undefined;
|
|
let sessionSettings: Settings;
|
|
const authStorages: AuthStorage[] = [];
|
|
|
|
beforeEach(() => {
|
|
tempDir = TempDir.createSync("@pi-model-persistence-");
|
|
});
|
|
|
|
afterEach(async () => {
|
|
if (session) {
|
|
await session.dispose();
|
|
session = undefined;
|
|
}
|
|
for (const authStorage of authStorages.splice(0)) {
|
|
authStorage.close();
|
|
}
|
|
tempDir.removeSync();
|
|
});
|
|
|
|
function getAnthropicModelOrThrow(id: string): Model<Api> {
|
|
const model = getBundledModel("anthropic", id);
|
|
if (!model) throw new Error(`Expected anthropic model ${id} to exist`);
|
|
return model;
|
|
}
|
|
|
|
function modelValue(model: Model<Api>): string {
|
|
return `${model.provider}/${model.id}`;
|
|
}
|
|
|
|
async function createSession(options?: {
|
|
initialModel?: Model<Api>;
|
|
selectInitialModel?: (availableModels: Model<Api>[]) => Model<Api>;
|
|
modelRoles?: Record<string, string>;
|
|
}): Promise<{ modelRegistry: ModelRegistry; settings: Settings; session: AgentSession }> {
|
|
const authStorage = await AuthStorage.create(path.join(tempDir.path(), `testauth-${authStorages.length}.db`));
|
|
authStorages.push(authStorage);
|
|
authStorage.setRuntimeApiKey("anthropic", "test-key");
|
|
const modelRegistry = new ModelRegistry(
|
|
authStorage,
|
|
path.join(tempDir.path(), `models-${authStorages.length}.yml`),
|
|
);
|
|
const model =
|
|
options?.initialModel ??
|
|
options?.selectInitialModel?.(modelRegistry.getAvailable()) ??
|
|
getAnthropicModelOrThrow("claude-sonnet-4-5");
|
|
const agent = new Agent({
|
|
initialState: {
|
|
model,
|
|
systemPrompt: ["Test"],
|
|
tools: [],
|
|
messages: [],
|
|
thinkingLevel: Effort.Medium,
|
|
},
|
|
});
|
|
|
|
sessionSettings = Settings.isolated();
|
|
const modelRoles = options?.modelRoles;
|
|
if (modelRoles) {
|
|
for (const role in modelRoles) {
|
|
const modelRoleValue = modelRoles[role];
|
|
if (modelRoleValue !== undefined) {
|
|
sessionSettings.setModelRole(role, modelRoleValue);
|
|
}
|
|
}
|
|
}
|
|
session = new AgentSession({
|
|
agent,
|
|
sessionManager: SessionManager.inMemory(),
|
|
settings: sessionSettings,
|
|
modelRegistry,
|
|
});
|
|
|
|
return { modelRegistry, settings: sessionSettings, session };
|
|
}
|
|
|
|
it("switches the active model without persisting by default", async () => {
|
|
const defaultModel = getAnthropicModelOrThrow("claude-sonnet-4-5");
|
|
const nextModel = getAnthropicModelOrThrow("claude-sonnet-4-6");
|
|
const defaultRoleValue = modelValue(defaultModel);
|
|
|
|
const created = await createSession({
|
|
initialModel: defaultModel,
|
|
modelRoles: { default: defaultRoleValue },
|
|
});
|
|
|
|
await created.session.setModel(nextModel);
|
|
|
|
expect(created.session.model?.id).toBe(nextModel.id);
|
|
expect(created.settings.getModelRole("default")).toBe(defaultRoleValue);
|
|
});
|
|
|
|
it("persists the default role when explicitly requested", async () => {
|
|
const defaultModel = getAnthropicModelOrThrow("claude-sonnet-4-5");
|
|
const nextModel = getAnthropicModelOrThrow("claude-sonnet-4-6");
|
|
|
|
const created = await createSession({
|
|
initialModel: defaultModel,
|
|
modelRoles: { default: modelValue(defaultModel) },
|
|
});
|
|
|
|
await created.session.setModel(nextModel, "default", { persist: true });
|
|
|
|
expect(created.session.model?.id).toBe(nextModel.id);
|
|
expect(created.settings.getModelRole("default")).toBe(modelValue(nextModel));
|
|
});
|
|
|
|
it("cycles role models without rewriting configured roles", async () => {
|
|
const defaultModel = getAnthropicModelOrThrow("claude-sonnet-4-5");
|
|
const slowModel = getAnthropicModelOrThrow("claude-sonnet-4-6");
|
|
const defaultRoleValue = modelValue(defaultModel);
|
|
const slowRoleValue = `${modelValue(slowModel)}:high`;
|
|
|
|
const created = await createSession({
|
|
initialModel: defaultModel,
|
|
modelRoles: {
|
|
default: defaultRoleValue,
|
|
slow: slowRoleValue,
|
|
},
|
|
});
|
|
|
|
const result = await created.session.cycleRoleModels(["default", "slow"]);
|
|
|
|
expect(result?.role).toBe("slow");
|
|
expect(result?.model.id).toBe(slowModel.id);
|
|
expect(created.session.model?.id).toBe(slowModel.id);
|
|
expect(created.settings.getModelRole("default")).toBe(defaultRoleValue);
|
|
expect(created.settings.getModelRole("slow")).toBe(slowRoleValue);
|
|
});
|
|
|
|
it("cycles role models backward from the current role", async () => {
|
|
const defaultModel = getAnthropicModelOrThrow("claude-sonnet-4-5");
|
|
const slowModel = getAnthropicModelOrThrow("claude-sonnet-4-6");
|
|
const defaultRoleValue = modelValue(defaultModel);
|
|
const slowRoleValue = modelValue(slowModel);
|
|
|
|
const created = await createSession({
|
|
initialModel: defaultModel,
|
|
modelRoles: {
|
|
default: defaultRoleValue,
|
|
slow: slowRoleValue,
|
|
},
|
|
});
|
|
|
|
const forward = await created.session.cycleRoleModels(["default", "slow"], "forward");
|
|
const backward = await created.session.cycleRoleModels(["default", "slow"], "backward");
|
|
|
|
expect(forward?.role).toBe("slow");
|
|
expect(backward?.role).toBe("default");
|
|
expect(created.session.model?.id).toBe(defaultModel.id);
|
|
expect(created.settings.getModelRole("default")).toBe(defaultRoleValue);
|
|
expect(created.settings.getModelRole("slow")).toBe(slowRoleValue);
|
|
});
|
|
|
|
it("cycles available models without persisting the default role", async () => {
|
|
const created = await createSession({
|
|
selectInitialModel: availableModels => {
|
|
if (availableModels.length <= 1 || !availableModels[0]) {
|
|
throw new Error("Expected at least two available models");
|
|
}
|
|
return availableModels[0];
|
|
},
|
|
});
|
|
const initialModel = created.session.model;
|
|
if (!initialModel) throw new Error("Expected initial model to be set");
|
|
const defaultRoleValue = modelValue(initialModel);
|
|
created.settings.setModelRole("default", defaultRoleValue);
|
|
|
|
const result = await created.session.cycleModel();
|
|
|
|
if (!result) throw new Error("Expected cycleModel to return a new model");
|
|
expect(modelValue(result.model)).not.toBe(defaultRoleValue);
|
|
const activeModel = created.session.model;
|
|
if (!activeModel) throw new Error("Expected active model after cycleModel");
|
|
expect(modelValue(activeModel)).toBe(modelValue(result.model));
|
|
expect(created.settings.getModelRole("default")).toBe(defaultRoleValue);
|
|
});
|
|
});
|