464 lines
17 KiB
TypeScript
464 lines
17 KiB
TypeScript
import { afterAll, afterEach, beforeAll, 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, type Model } from "@oh-my-pi/pi-ai";
|
|
import { getBundledModel } from "@oh-my-pi/pi-catalog/models";
|
|
import { ModelRegistry } from "@oh-my-pi/pi-coding-agent/config/model-registry";
|
|
import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings";
|
|
import { type CreateAgentSessionResult, createAgentSession } from "@oh-my-pi/pi-coding-agent/sdk";
|
|
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 { 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", () => {
|
|
let tempDir: TempDir;
|
|
let session: AgentSession | undefined;
|
|
let sessionSettings: Settings;
|
|
// Auth storage (SQLite DB) and the model registry are immutable across these tests:
|
|
// every test sets the same anthropic runtime key and only ever reads the bundled model
|
|
// list. Building them once avoids ~12 SQLite opens + registry constructions.
|
|
let sharedDir: TempDir;
|
|
let sharedAuthStorage: AuthStorage;
|
|
let sharedModelRegistry: ModelRegistry;
|
|
|
|
beforeAll(async () => {
|
|
sharedDir = TempDir.createSync("@pi-model-persistence-shared-");
|
|
sharedAuthStorage = await AuthStorage.create(path.join(sharedDir.path(), "auth.db"));
|
|
sharedAuthStorage.setRuntimeApiKey("anthropic", "test-key");
|
|
sharedModelRegistry = new ModelRegistry(sharedAuthStorage, path.join(sharedDir.path(), "models.yml"));
|
|
});
|
|
|
|
afterAll(() => {
|
|
sharedAuthStorage.close();
|
|
sharedDir.removeSync();
|
|
});
|
|
|
|
beforeEach(() => {
|
|
tempDir = TempDir.createSync("@pi-model-persistence-");
|
|
});
|
|
|
|
afterEach(async () => {
|
|
if (session) {
|
|
await session.dispose();
|
|
session = undefined;
|
|
}
|
|
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 writeRoleModelSession(
|
|
defaultRoleValue: string,
|
|
smolRoleValue: string,
|
|
lastRole = "smol",
|
|
): Promise<string> {
|
|
const targetSessionFile = path.join(tempDir.path(), `target-${Bun.nanoseconds()}.jsonl`);
|
|
const timestamp = "2026-06-01T00:00:00.000Z";
|
|
await Bun.write(
|
|
targetSessionFile,
|
|
`${[
|
|
{ type: "session", version: 3, id: "target-session", timestamp, cwd: tempDir.path() },
|
|
{
|
|
type: "model_change",
|
|
id: "default-model",
|
|
parentId: null,
|
|
timestamp,
|
|
model: defaultRoleValue,
|
|
role: "default",
|
|
},
|
|
{
|
|
type: "model_change",
|
|
id: "smol-model",
|
|
parentId: "default-model",
|
|
timestamp,
|
|
model: smolRoleValue,
|
|
role: lastRole,
|
|
},
|
|
]
|
|
.map(entry => JSON.stringify(entry))
|
|
.join("\n")}\n`,
|
|
);
|
|
return targetSessionFile;
|
|
}
|
|
async function createSession(options?: {
|
|
initialModel?: Model<Api>;
|
|
selectInitialModel?: (availableModels: Model<Api>[]) => Model<Api>;
|
|
modelRoles?: Record<string, string>;
|
|
persist?: boolean;
|
|
}): Promise<{ modelRegistry: ModelRegistry; settings: Settings; session: AgentSession }> {
|
|
const modelRegistry = sharedModelRegistry;
|
|
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: options?.persist
|
|
? SessionManager.create(tempDir.path(), path.join(tempDir.path(), "active"))
|
|
: SessionManager.inMemory(),
|
|
settings: sessionSettings,
|
|
modelRegistry,
|
|
});
|
|
|
|
return { modelRegistry, settings: sessionSettings, session };
|
|
}
|
|
|
|
async function createStartupResumeSession(
|
|
targetSessionFile: string,
|
|
settings: Settings = Settings.isolated(),
|
|
): Promise<CreateAgentSessionResult> {
|
|
const sessionManager = await SessionManager.open(targetSessionFile, path.join(tempDir.path(), "startup"));
|
|
const result = await createAgentSession({
|
|
cwd: tempDir.path(),
|
|
agentDir: tempDir.path(),
|
|
authStorage: sharedAuthStorage,
|
|
modelRegistry: sharedModelRegistry,
|
|
sessionManager,
|
|
settings,
|
|
disableExtensionDiscovery: true,
|
|
skills: [],
|
|
contextFiles: [],
|
|
promptTemplates: [],
|
|
slashCommands: [],
|
|
enableMCP: false,
|
|
enableLsp: false,
|
|
skipPythonPreflight: true,
|
|
});
|
|
session = result.session;
|
|
return result;
|
|
}
|
|
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("switches the active model even when the live context is over the target window", 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) },
|
|
});
|
|
|
|
const targetWindow = nextModel.contextWindow ?? 0;
|
|
expect(targetWindow).toBeGreaterThan(0);
|
|
const overflowTokens = targetWindow + 1;
|
|
|
|
const result = await created.session.setModel(nextModel, "default", {
|
|
persist: true,
|
|
currentContextTokens: overflowTokens,
|
|
});
|
|
|
|
expect(result).toEqual({ switched: 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);
|
|
});
|
|
|
|
it("restores the last active role model when switching sessions", async () => {
|
|
const defaultModel = getAnthropicModelOrThrow("claude-sonnet-4-5");
|
|
const smolModel = getAnthropicModelOrThrow("claude-sonnet-4-6");
|
|
const defaultRoleValue = modelValue(defaultModel);
|
|
const smolRoleValue = modelValue(smolModel);
|
|
|
|
const targetSessionFile = await writeRoleModelSession(defaultRoleValue, smolRoleValue);
|
|
|
|
const created = await createSession({
|
|
initialModel: defaultModel,
|
|
modelRoles: { default: defaultRoleValue, smol: smolRoleValue },
|
|
persist: true,
|
|
});
|
|
|
|
await expect(created.session.switchSession(targetSessionFile)).resolves.toBe(true);
|
|
expect(created.session.model?.id).toBe(smolModel.id);
|
|
});
|
|
|
|
it("restores the last active role model during startup resume", async () => {
|
|
const defaultModel = getAnthropicModelOrThrow("claude-sonnet-4-5");
|
|
const smolModel = getAnthropicModelOrThrow("claude-sonnet-4-6");
|
|
const defaultRoleValue = modelValue(defaultModel);
|
|
const smolRoleValue = modelValue(smolModel);
|
|
const targetSessionFile = await writeRoleModelSession(defaultRoleValue, smolRoleValue);
|
|
|
|
const result = await createStartupResumeSession(targetSessionFile);
|
|
|
|
expect(result.session.model?.id).toBe(smolModel.id);
|
|
});
|
|
|
|
it("falls back to the saved default model when switch-session role restore is unavailable", async () => {
|
|
const defaultModel = getAnthropicModelOrThrow("claude-sonnet-4-5");
|
|
const previousModel = getAnthropicModelOrThrow("claude-sonnet-4-6");
|
|
const defaultRoleValue = modelValue(defaultModel);
|
|
const targetSessionFile = await writeRoleModelSession(defaultRoleValue, "anthropic/not-loaded-anymore");
|
|
|
|
const created = await createSession({
|
|
initialModel: previousModel,
|
|
modelRoles: { default: defaultRoleValue },
|
|
persist: true,
|
|
});
|
|
|
|
await expect(created.session.switchSession(targetSessionFile)).resolves.toBe(true);
|
|
expect(created.session.model?.id).toBe(defaultModel.id);
|
|
});
|
|
|
|
it("restores the saved default model when switch-session last role is fallback", async () => {
|
|
const defaultModel = getAnthropicModelOrThrow("claude-sonnet-4-5");
|
|
const fallbackModel = getAnthropicModelOrThrow("claude-sonnet-4-6");
|
|
const defaultRoleValue = modelValue(defaultModel);
|
|
const targetSessionFile = await writeRoleModelSession(
|
|
defaultRoleValue,
|
|
modelValue(fallbackModel),
|
|
EPHEMERAL_MODEL_CHANGE_ROLE,
|
|
);
|
|
|
|
const created = await createSession({
|
|
initialModel: fallbackModel,
|
|
modelRoles: { default: defaultRoleValue },
|
|
persist: true,
|
|
});
|
|
|
|
await expect(created.session.switchSession(targetSessionFile)).resolves.toBe(true);
|
|
expect(created.session.model?.id).toBe(defaultModel.id);
|
|
});
|
|
|
|
it("falls back to the saved default model when startup role restore is unavailable", async () => {
|
|
const defaultModel = getAnthropicModelOrThrow("claude-sonnet-4-5");
|
|
const settingsFallbackModel = getAnthropicModelOrThrow("claude-sonnet-4-6");
|
|
const defaultRoleValue = modelValue(defaultModel);
|
|
const targetSessionFile = await writeRoleModelSession(defaultRoleValue, "anthropic/not-loaded-anymore");
|
|
const settings = Settings.isolated();
|
|
settings.setModelRole("default", modelValue(settingsFallbackModel));
|
|
|
|
const result = await createStartupResumeSession(targetSessionFile, settings);
|
|
|
|
expect(result.session.model?.id).toBe(defaultModel.id);
|
|
});
|
|
|
|
it("restores the saved default model when startup last role is fallback", async () => {
|
|
const defaultModel = getAnthropicModelOrThrow("claude-sonnet-4-5");
|
|
const fallbackModel = getAnthropicModelOrThrow("claude-sonnet-4-6");
|
|
const defaultRoleValue = modelValue(defaultModel);
|
|
const targetSessionFile = await writeRoleModelSession(
|
|
defaultRoleValue,
|
|
modelValue(fallbackModel),
|
|
EPHEMERAL_MODEL_CHANGE_ROLE,
|
|
);
|
|
const settings = Settings.isolated();
|
|
settings.setModelRole("default", modelValue(fallbackModel));
|
|
|
|
const result = await createStartupResumeSession(targetSessionFile, settings);
|
|
|
|
expect(result.session.model?.id).toBe(defaultModel.id);
|
|
});
|
|
|
|
it("restores a temporary model when switching sessions", async () => {
|
|
const defaultModel = getAnthropicModelOrThrow("claude-sonnet-4-5");
|
|
const temporaryModel = getAnthropicModelOrThrow("claude-sonnet-4-6");
|
|
const defaultRoleValue = modelValue(defaultModel);
|
|
const targetSessionFile = await writeRoleModelSession(defaultRoleValue, modelValue(temporaryModel), "temporary");
|
|
|
|
const created = await createSession({
|
|
initialModel: defaultModel,
|
|
modelRoles: { default: defaultRoleValue },
|
|
persist: true,
|
|
});
|
|
|
|
await expect(created.session.switchSession(targetSessionFile)).resolves.toBe(true);
|
|
expect(created.session.model?.id).toBe(temporaryModel.id);
|
|
});
|
|
|
|
it("restores a temporary model during startup resume", async () => {
|
|
const defaultModel = getAnthropicModelOrThrow("claude-sonnet-4-5");
|
|
const temporaryModel = getAnthropicModelOrThrow("claude-sonnet-4-6");
|
|
const defaultRoleValue = modelValue(defaultModel);
|
|
const targetSessionFile = await writeRoleModelSession(defaultRoleValue, modelValue(temporaryModel), "temporary");
|
|
const settings = Settings.isolated();
|
|
settings.setModelRole("default", defaultRoleValue);
|
|
|
|
const result = await createStartupResumeSession(targetSessionFile, settings);
|
|
|
|
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(
|
|
{
|
|
default: "anthropic/claude-sonnet-4-5",
|
|
temporary: "anthropic/claude-sonnet-4-6",
|
|
},
|
|
"temporary",
|
|
),
|
|
).toEqual(["anthropic/claude-sonnet-4-6", "anthropic/claude-sonnet-4-5"]);
|
|
});
|
|
|
|
it("lists only the default model for ephemeral fallback restores", () => {
|
|
expect(
|
|
getRestorableSessionModels(
|
|
{
|
|
default: "anthropic/claude-sonnet-4-5",
|
|
[EPHEMERAL_MODEL_CHANGE_ROLE]: "anthropic/claude-sonnet-4-6",
|
|
},
|
|
EPHEMERAL_MODEL_CHANGE_ROLE,
|
|
),
|
|
).toEqual(["anthropic/claude-sonnet-4-5"]);
|
|
});
|
|
|
|
it("lists a named role model before the default fallback", () => {
|
|
expect(
|
|
getRestorableSessionModels(
|
|
{
|
|
default: "anthropic/claude-sonnet-4-5",
|
|
smol: "anthropic/claude-sonnet-4-6",
|
|
},
|
|
"smol",
|
|
),
|
|
).toEqual(["anthropic/claude-sonnet-4-6", "anthropic/claude-sonnet-4-5"]);
|
|
});
|
|
});
|