Files
oh-my-pi/packages/coding-agent/test/sdk-model-selection.test.ts
T
roboomp dbdc77697c fix(coding-agent): retried session resume even when initial restore failed
Post-extension session-model retry now covers the case where the initial restore failed entirely (e.g. saved default unavailable, last active role supplied by an extension) and the settings default filled in the active model. Also recomputes thinking-level from full precedence against the reclaimed model so a fallback model's defaultLevel does not become sticky.\n\nFixes #1649
2026-06-02 09:15:45 +02:00

289 lines
9.0 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, test, vi } from "bun:test";
import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
import { getBundledModel } 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 { createAgentSession, type ExtensionFactory } from "@oh-my-pi/pi-coding-agent/sdk";
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 { Snowflake } from "@oh-my-pi/pi-utils";
describe("createAgentSession deferred model pattern resolution", () => {
let tempDir: string;
beforeEach(() => {
tempDir = path.join(os.tmpdir(), `pi-sdk-model-selection-${Snowflake.next()}`);
fs.mkdirSync(tempDir, { recursive: true });
});
afterEach(() => {
if (tempDir && fs.existsSync(tempDir)) {
fs.rmSync(tempDir, { recursive: true, force: true });
}
});
const providerExtension: ExtensionFactory = pi => {
pi.registerProvider("runtime-provider", {
baseUrl: "https://runtime.example.com/v1",
apiKey: "RUNTIME_KEY",
api: "openai-completions",
models: [
{
id: "runtime-model",
name: "Runtime Model",
reasoning: false,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 128000,
maxTokens: 8192,
},
{
id: "runtime-reasoning-model",
name: "Runtime Reasoning Model",
reasoning: true,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 128000,
maxTokens: 8192,
},
],
});
};
function buildSessionOptions(modelPattern: string) {
return {
cwd: tempDir,
agentDir: tempDir,
sessionManager: SessionManager.inMemory(),
disableExtensionDiscovery: true,
extensions: [providerExtension],
skills: [],
contextFiles: [],
promptTemplates: [],
slashCommands: [],
enableMCP: false,
enableLsp: false,
modelPattern,
};
}
test("resolves explicit modelPattern after extension providers register", async () => {
const { session, modelFallbackMessage } = await createAgentSession(
buildSessionOptions("runtime-provider/runtime-model"),
);
expect(session.model).toBeDefined();
expect(session.model?.provider).toBe("runtime-provider");
expect(session.model?.id).toBe("runtime-model");
expect(modelFallbackMessage).toBeUndefined();
});
test("does not silently fallback when explicit modelPattern is unresolved", async () => {
const { session, modelFallbackMessage } = await createAgentSession(
buildSessionOptions("missing-provider/missing-model"),
);
expect(session.model).toBeUndefined();
expect(modelFallbackMessage).toBe('Model "missing-provider/missing-model" not found');
});
test("does not apply default role thinking override when modelPattern is explicit", async () => {
const settings = Settings.isolated({ defaultThinkingLevel: "off" });
settings.setModelRole("smol", "runtime-provider/runtime-reasoning-model");
settings.setModelRole("default", "pi/smol:high");
const { session } = await createAgentSession({
...buildSessionOptions("runtime-provider/runtime-reasoning-model"),
settings,
});
expect(session.model?.provider).toBe("runtime-provider");
expect(session.model?.id).toBe("runtime-reasoning-model");
expect(session.thinkingLevel).toBe("off");
});
test("selects the settings default model without synchronously validating auth", async () => {
const defaultModel = getBundledModel("anthropic", "claude-sonnet-4-5");
if (!defaultModel) {
throw new Error("Expected bundled anthropic default model");
}
const authStorage = await AuthStorage.create(path.join(tempDir, "testauth.db"));
authStorage.setRuntimeApiKey(defaultModel.provider, "test-key");
const modelRegistry = new ModelRegistry(authStorage, path.join(tempDir, "models.yml"));
const settings = Settings.isolated();
settings.setModelRole("default", `${defaultModel.provider}/${defaultModel.id}`);
const getApiKeySpy = vi
.spyOn(modelRegistry, "getApiKey")
.mockRejectedValue(new Error("settings default model should not validate auth during startup"));
try {
const { session } = await createAgentSession({
cwd: tempDir,
agentDir: tempDir,
authStorage,
modelRegistry,
settings,
sessionManager: SessionManager.inMemory(),
disableExtensionDiscovery: true,
skills: [],
contextFiles: [],
promptTemplates: [],
slashCommands: [],
enableMCP: false,
enableLsp: false,
});
try {
expect(session.model?.provider).toBe(defaultModel.provider);
expect(session.model?.id).toBe(defaultModel.id);
expect(getApiKeySpy).not.toHaveBeenCalled();
} finally {
await session.dispose();
}
} finally {
getApiKeySpy.mockRestore();
authStorage.close();
}
});
test("restores role model from extension provider after startup resume", async () => {
const defaultModel = getBundledModel("anthropic", "claude-sonnet-4-5");
if (!defaultModel) {
throw new Error("Expected bundled anthropic default model");
}
const authStorage = await AuthStorage.create(path.join(tempDir, "testauth.db"));
authStorage.setRuntimeApiKey(defaultModel.provider, "test-key");
const modelRegistry = new ModelRegistry(authStorage, path.join(tempDir, "models.yml"));
const targetSessionFile = path.join(tempDir, "resume-extension.jsonl");
const timestamp = "2026-06-01T00:00:00.000Z";
await Bun.write(
targetSessionFile,
`${[
{ type: "session", version: 3, id: "resume-ext", timestamp, cwd: tempDir },
{
type: "model_change",
id: "default-model",
parentId: null,
timestamp,
model: `${defaultModel.provider}/${defaultModel.id}`,
role: "default",
},
{
type: "model_change",
id: "smol-model",
parentId: "default-model",
timestamp,
model: "runtime-provider/runtime-model",
role: "smol",
},
]
.map(entry => JSON.stringify(entry))
.join("\n")}\n`,
);
const sessionManager = await SessionManager.open(targetSessionFile, path.join(tempDir, "sessions"));
const { session } = await createAgentSession({
cwd: tempDir,
agentDir: tempDir,
authStorage,
modelRegistry,
sessionManager,
settings: Settings.isolated(),
disableExtensionDiscovery: true,
extensions: [providerExtension],
skills: [],
contextFiles: [],
promptTemplates: [],
slashCommands: [],
enableMCP: false,
enableLsp: false,
skipPythonPreflight: true,
});
try {
expect(session.model?.provider).toBe("runtime-provider");
expect(session.model?.id).toBe("runtime-model");
} finally {
await session.dispose();
authStorage.close();
}
});
test("restores extension role model when saved default cannot be restored before extensions load", async () => {
const settingsDefaultModel = getBundledModel("anthropic", "claude-sonnet-4-5");
if (!settingsDefaultModel) {
throw new Error("Expected bundled anthropic default model");
}
const authStorage = await AuthStorage.create(path.join(tempDir, "testauth.db"));
authStorage.setRuntimeApiKey(settingsDefaultModel.provider, "test-key");
const modelRegistry = new ModelRegistry(authStorage, path.join(tempDir, "models.yml"));
// Saved default points at a provider that has no usable credentials. The
// last active role (`smol`) is supplied by the inline extension and is
// only resolvable once provider registrations are processed.
const targetSessionFile = path.join(tempDir, "resume-extension-default-missing.jsonl");
const timestamp = "2026-06-01T00:00:00.000Z";
await Bun.write(
targetSessionFile,
`${[
{ type: "session", version: 3, id: "resume-ext-no-default", timestamp, cwd: tempDir },
{
type: "model_change",
id: "default-model",
parentId: null,
timestamp,
model: "anthropic/not-available",
role: "default",
},
{
type: "model_change",
id: "smol-model",
parentId: "default-model",
timestamp,
model: "runtime-provider/runtime-model",
role: "smol",
},
]
.map(entry => JSON.stringify(entry))
.join("\n")}\n`,
);
const sessionManager = await SessionManager.open(targetSessionFile, path.join(tempDir, "sessions-no-default"));
const settings = Settings.isolated();
settings.setModelRole("default", `${settingsDefaultModel.provider}/${settingsDefaultModel.id}`);
const { session } = await createAgentSession({
cwd: tempDir,
agentDir: tempDir,
authStorage,
modelRegistry,
sessionManager,
settings,
disableExtensionDiscovery: true,
extensions: [providerExtension],
skills: [],
contextFiles: [],
promptTemplates: [],
slashCommands: [],
enableMCP: false,
enableLsp: false,
skipPythonPreflight: true,
});
try {
expect(session.model?.provider).toBe("runtime-provider");
expect(session.model?.id).toBe("runtime-model");
} finally {
await session.dispose();
authStorage.close();
}
});
});