pi-mono/packages/coding-agent/test/suite/agent-session-model-extension.test.ts
robopipi[bot] 5732bac060
fix(coding-agent): cycle to the only model in scope when the active model is outside it (#11832)
Ctrl+P did nothing when the scope held exactly one model and the active
model was a different one. It showed "Only one model in scope" and kept
the current model, because `_cycleScopedModel` returned early for any
list with one entry. `_cycleAvailableModel` had the same early return,
so it also failed when the active model was no longer available and one
other model was.

A second bug had the same cause. When the active model was not in the
list, the code treated it as if it were at index 0, so cycling forward
skipped the first scoped model. With `--models A,B` and another model
active, Ctrl+P went to B.

This change adds a small `getNextCycleIndex` helper that both functions
now use:
- When the active model is not in the list, cycling forward selects the
first entry and cycling backward selects the last.
- It returns `undefined` (the "Only one model…" message) only when the
list is empty, or when its single entry is already the active model.

**Testing**
- Added a regression test to
`test/suite/agent-session-model-extension.test.ts`. It covers a
one-model scope, a repeated cycle once that model is active, and forward
and backward cycling into a two-model scope from outside it. The test
fails without the change ("expected undefined to be 'faux-2'") and
passes with it.
- `npm run check` passes.
- Manual check: `pi --model anthropic/claude-sonnet-4-5 --models
anthropic/claude-opus-4-5`, then Ctrl+P.
  - Before: "Only one model in scope".
  - After: "Switched to Claude Opus 4.5".
- With `--models anthropic/claude-opus-4-5,anthropic/claude-haiku-4-5`,
Ctrl+P now selects opus instead of haiku.

Fixes #11806

Requested by @davidbrai

Co-authored-by: triagent <triagent@localhost>
Co-authored-by: David Brailovsky <davidbrai@gmail.com>
2026-11-05 10:39:18 +01:00

559 lines
18 KiB
TypeScript

import type { AgentTool, ThinkingLevel } from "@earendil-works/pi-agent-core";
import {
fauxAssistantMessage,
fauxToolCall,
getCurrentSystemPrompt,
type JsonObject,
type Model,
type Usage,
} from "@earendil-works/pi-ai";
import { Type } from "typebox";
import { afterEach, describe, expect, it } from "vitest";
import type { BuildSystemPromptOptions, ExtensionAPI } from "../../src/index.ts";
import { createHarness, getAssistantTexts, type Harness } from "./harness.ts";
describe("AgentSession model and extension characterization", () => {
const harnesses: Harness[] = [];
afterEach(() => {
while (harnesses.length > 0) {
harnesses.pop()?.cleanup();
}
});
it("setModel saves the model to the session and emits model_select", async () => {
const modelEvents: string[] = [];
const harness = await createHarness({
models: [
{ id: "faux-1", name: "One", reasoning: true },
{ id: "faux-2", name: "Two", reasoning: true },
],
extensionFactories: [
(pi) => {
pi.on("model_select", async (event) => {
modelEvents.push(`${event.previousModel?.id ?? "none"}->${event.model.id}:${event.source}`);
});
},
],
});
harnesses.push(harness);
const nextModel = harness.getModel("faux-2")!;
await harness.session.setModel(nextModel);
expect(harness.session.model?.id).toBe("faux-2");
expect(modelEvents).toEqual(["faux-1->faux-2:set"]);
expect(
harness.sessionManager
.getEntries()
.filter((entry) => entry.type === "model_change")
.map((entry) => `${entry.provider}/${entry.modelId}`),
).toEqual([`${nextModel.provider}/${nextModel.id}`]);
expect(harness.settingsManager.getDefaultProvider()).toBeUndefined();
expect(harness.settingsManager.getDefaultModel()).toBeUndefined();
});
it("only persists model and thinking defaults when requested", async () => {
const harness = await createHarness({
models: [
{ id: "faux-1", name: "One", reasoning: true },
{ id: "faux-2", name: "Two", reasoning: true },
],
});
harnesses.push(harness);
const nextModel = harness.getModel("faux-2")!;
await harness.session.setModel(nextModel);
expect(harness.settingsManager.getDefaultProvider()).toBeUndefined();
expect(harness.settingsManager.getDefaultModel()).toBeUndefined();
harness.session.setThinkingLevel("low");
expect(harness.settingsManager.getDefaultThinkingLevel()).toBeUndefined();
await harness.session.setModel(nextModel, { persist: true });
expect(harness.settingsManager.getDefaultProvider()).toBe(nextModel.provider);
expect(harness.settingsManager.getDefaultModel()).toBe(nextModel.id);
harness.session.setThinkingLevel("high", { persist: true });
expect(harness.settingsManager.getDefaultThinkingLevel()).toBe("high");
});
it("persists the requested default thinking level even when the current model clamps it", async () => {
const harness = await createHarness({ models: [{ id: "faux-1", reasoning: true }] });
harnesses.push(harness);
harness.session.setThinkingLevel("max", { persist: true });
expect(harness.session.thinkingLevel).toBe("high");
expect(harness.settingsManager.getDefaultThinkingLevel()).toBe("max");
});
it("cycleModel and cycleThinkingLevel are session-only by default", async () => {
const harness = await createHarness({
models: [
{ id: "faux-1", name: "One", reasoning: true },
{ id: "faux-2", name: "Two", reasoning: true },
],
settings: {
defaultProvider: "faux",
defaultModel: "faux-1",
defaultThinkingLevel: "low",
},
});
harnesses.push(harness);
await harness.session.cycleModel();
expect(harness.session.model?.id).toBe("faux-2");
expect(harness.settingsManager.getDefaultModel()).toBe("faux-1");
harness.session.setThinkingLevel("off");
expect(harness.session.cycleThinkingLevel()).toBe("minimal");
expect(harness.settingsManager.getDefaultThinkingLevel()).toBe("low");
});
it("applies per-model thinking level override on model switch", async () => {
const harness = await createHarness({
models: [
{ id: "faux-1", name: "One", reasoning: true },
{ id: "faux-2", name: "Two", reasoning: true },
],
settings: { defaultThinkingLevel: "medium" },
});
harnesses.push(harness);
// Set a per-model override for faux-2
harness.settingsManager.setModelThinkingLevel("faux", "faux-2", "low");
// Session starts on faux-1 with default thinking
harness.session.setThinkingLevel("high");
expect(harness.session.thinkingLevel).toBe("high");
// Switch to faux-2 → per-model override should apply
const model2 = harness.getModel("faux-2")!;
await harness.session.setModel(model2);
expect(harness.session.thinkingLevel).toBe("low");
// Switch back to faux-1 → no per-model override, uses global default
const model1 = harness.getModel("faux-1")!;
await harness.session.setModel(model1);
expect(harness.session.thinkingLevel).toBe("medium");
});
it("falls back to current session thinking level when no per-model or global default is configured", async () => {
const harness = await createHarness({
models: [
{ id: "faux-1", name: "One", reasoning: true },
{ id: "faux-2", name: "Two", reasoning: true },
],
});
harnesses.push(harness);
harness.session.setThinkingLevel("high");
await harness.session.setModel(harness.getModel("faux-2")!);
expect(harness.session.thinkingLevel).toBe("high");
});
it("per-model override takes priority over global default during model switch", async () => {
const harness = await createHarness({
models: [
{ id: "faux-1", name: "One", reasoning: true },
{ id: "faux-2", name: "Two", reasoning: true },
],
settings: {
defaultThinkingLevel: "high",
modelThinkingLevels: { "faux/faux-2": "minimal" },
},
});
harnesses.push(harness);
// Start on a non-thinking model, then switch to faux-2
const model2 = harness.getModel("faux-2")!;
await harness.session.setModel(model2);
expect(harness.session.thinkingLevel).toBe("minimal");
});
it("cycles through scoped models and preserves the scoped thinking preference", async () => {
const harness = await createHarness({
models: [
{ id: "faux-1", name: "One", reasoning: true },
{ id: "faux-2", name: "Two", reasoning: false },
],
});
harnesses.push(harness);
const modelOne = harness.getModel("faux-1")!;
const modelTwo = harness.getModel("faux-2")!;
harness.session.setScopedModels([{ model: modelOne, thinkingLevel: "high" }, { model: modelTwo }] as Array<{
model: Model<string>;
thinkingLevel?: ThinkingLevel;
}>);
harness.session.setThinkingLevel("high");
await harness.session.cycleModel();
expect(harness.session.model?.id).toBe("faux-2");
expect(harness.session.thinkingLevel).toBe("off");
await harness.session.cycleModel();
expect(harness.session.model?.id).toBe("faux-1");
expect(harness.session.thinkingLevel).toBe("high");
});
// Regression test for https://github.com/earendil-works/pi/issues/11806
it("cycles to the only scoped model and to the first scoped model from outside the scope", async () => {
const harness = await createHarness({
models: [
{ id: "faux-1", name: "One" },
{ id: "faux-2", name: "Two" },
{ id: "faux-3", name: "Three" },
],
});
harnesses.push(harness);
const modelTwo = harness.getModel("faux-2")!;
const modelThree = harness.getModel("faux-3")!;
harness.session.setScopedModels([{ model: modelTwo }]);
expect((await harness.session.cycleModel())?.model.id).toBe("faux-2");
expect(harness.session.model?.id).toBe("faux-2");
expect(await harness.session.cycleModel()).toBeUndefined();
expect(harness.session.model?.id).toBe("faux-2");
await harness.session.setModel(harness.getModel("faux-1")!);
harness.session.setScopedModels([{ model: modelTwo }, { model: modelThree }]);
await harness.session.cycleModel();
expect(harness.session.model?.id).toBe("faux-2");
await harness.session.setModel(harness.getModel("faux-1")!);
await harness.session.cycleModel("backward");
expect(harness.session.model?.id).toBe("faux-3");
});
it("clamps thinking levels to model capabilities and cycles available levels", async () => {
const harness = await createHarness({ models: [{ id: "faux-1", reasoning: false }] });
harnesses.push(harness);
harness.session.setThinkingLevel("high");
expect(harness.session.thinkingLevel).toBe("off");
expect(harness.session.cycleThinkingLevel()).toBeUndefined();
});
it("cycles xhigh before max when both are supported", async () => {
const harness = await createHarness({ models: [{ id: "faux-1", reasoning: true }] });
harnesses.push(harness);
harness.getModel().thinkingLevelMap = { xhigh: "xhigh", max: "max" };
expect(harness.session.getAvailableThinkingLevels()).toEqual([
"off",
"minimal",
"low",
"medium",
"high",
"xhigh",
"max",
]);
harness.session.setThinkingLevel("high");
expect(harness.session.cycleThinkingLevel()).toBe("xhigh");
expect(harness.session.cycleThinkingLevel()).toBe("max");
expect(harness.session.cycleThinkingLevel()).toBe("off");
});
it("throws when setModel is called without configured auth", async () => {
const harness = await createHarness({
models: [
{ id: "faux-1", name: "One", reasoning: true },
{ id: "faux-2", name: "Two", reasoning: true },
],
withConfiguredAuth: false,
});
harnesses.push(harness);
await expect(harness.session.setModel(harness.getModel("faux-2")!)).rejects.toThrow(
`No API key for ${harness.getModel().provider}/faux-2`,
);
});
it("allows extension tool_call handlers to block tool execution", async () => {
const echoTool: AgentTool = {
name: "echo",
label: "Echo",
description: "Echo text back",
parameters: Type.Object({ text: Type.String() }),
execute: async () => {
throw new Error("tool should have been blocked");
},
};
const harness = await createHarness({
tools: [echoTool],
extensionFactories: [
(pi) => {
pi.on("tool_call", async () => ({ block: true, reason: "Blocked by test" }));
},
],
});
harnesses.push(harness);
harness.setResponses([
fauxAssistantMessage([fauxToolCall("echo", { text: "hello" })], { stopReason: "toolUse" }),
(context) => {
const toolResult = context.messages.find((message) => message.role === "toolResult");
const errorText =
toolResult?.role === "toolResult"
? toolResult.content
.filter((part): part is { type: "text"; text: string } => part.type === "text")
.map((part) => part.text)
.join("\n")
: "";
return fauxAssistantMessage(errorText);
},
]);
await harness.session.prompt("hi");
expect(getAssistantTexts(harness)).toContain("Blocked by test");
expect(
harness.session.messages.find((message) => message.role === "toolResult" && message.isError),
).toBeDefined();
});
it("allows extension tool_result handlers to modify tool results", async () => {
const toolUsage: Usage = {
input: 1,
output: 2,
cacheRead: 3,
cacheWrite: 4,
totalTokens: 10,
cost: { input: 0.1, output: 0.2, cacheRead: 0.3, cacheWrite: 0.4, total: 1 },
};
const patchedToolUsage: Usage = {
input: 5,
output: 6,
cacheRead: 7,
cacheWrite: 8,
totalTokens: 26,
cost: { input: 0.5, output: 0.6, cacheRead: 0.7, cacheWrite: 0.8, total: 2.6 },
};
let observedToolUsage: Usage | undefined;
const echoTool: AgentTool = {
name: "echo",
label: "Echo",
description: "Echo text back",
parameters: Type.Object({ text: Type.String() }),
execute: async (_toolCallId, params) => {
const text = typeof params === "object" && params !== null && "text" in params ? String(params.text) : "";
return { content: [{ type: "text", text }], details: { text }, usage: toolUsage };
},
};
const harness = await createHarness({
tools: [echoTool],
extensionFactories: [
(pi) => {
pi.on("tool_result", async (event) => {
observedToolUsage = event.usage;
return {
content: [{ type: "text", text: "patched result" }],
details: { patched: true },
usage: patchedToolUsage,
};
});
},
],
});
harnesses.push(harness);
harness.setResponses([
fauxAssistantMessage([fauxToolCall("echo", { text: "hello" })], { stopReason: "toolUse" }),
(context) => {
const toolResult = context.messages.find((message) => message.role === "toolResult");
const text =
toolResult?.role === "toolResult"
? toolResult.content
.filter((part): part is { type: "text"; text: string } => part.type === "text")
.map((part) => part.text)
.join("\n")
: "";
return fauxAssistantMessage(text);
},
]);
await harness.session.prompt("hi");
expect(getAssistantTexts(harness)).toContain("patched result");
const toolResult = harness.session.messages.find(
(message) =>
message.role === "toolResult" &&
typeof message.details === "object" &&
message.details !== null &&
!Array.isArray(message.details) &&
(message.details as JsonObject).patched === true,
);
expect(observedToolUsage).toEqual(toolUsage);
expect(toolResult).toBeDefined();
expect(toolResult?.role === "toolResult" ? toolResult.usage : undefined).toEqual(patchedToolUsage);
});
it("allows extension context handlers to modify messages before the LLM call", async () => {
const harness = await createHarness({
extensionFactories: [
(pi) => {
pi.on("context", async (event) => ({
messages: event.messages.map((message) =>
message.role === "user"
? { ...message, content: [{ type: "text", text: "rewritten" }], timestamp: message.timestamp }
: message,
),
}));
},
],
});
harnesses.push(harness);
let providerUserText = "";
harness.setResponses([
(context) => {
const user = context.messages.find((message) => message.role === "user");
providerUserText =
user && typeof user.content !== "string"
? user.content
.filter((part): part is { type: "text"; text: string } => part.type === "text")
.map((part) => part.text)
.join("\n")
: "";
return fauxAssistantMessage("done");
},
]);
await harness.session.prompt("original");
expect(providerUserText).toBe("rewritten");
const storedUserMessage = harness.session.messages.find((message) => message.role === "user");
expect(storedUserMessage?.role).toBe("user");
if (storedUserMessage?.role === "user") {
expect(storedUserMessage.content).toEqual([{ type: "text", text: "original" }]);
}
});
it("allows extension input handlers to transform or handle input", async () => {
let extensionApi: ExtensionAPI | undefined;
const transformedHarness = await createHarness({
extensionFactories: [
(pi) => {
extensionApi = pi;
pi.on("input", async (event) => {
if (event.text === "ping") {
return { action: "handled" };
}
return { action: "transform", text: `transformed:${event.text}` };
});
},
],
});
harnesses.push(transformedHarness);
let providerUserText = "";
transformedHarness.setResponses([
(context) => {
const user = context.messages.find((message) => message.role === "user");
providerUserText =
user && typeof user.content !== "string"
? user.content
.filter((part): part is { type: "text"; text: string } => part.type === "text")
.map((part) => part.text)
.join("\n")
: "";
return fauxAssistantMessage("done");
},
]);
await transformedHarness.session.prompt("hello");
await transformedHarness.session.prompt("ping");
expect(providerUserText).toBe("transformed:hello");
expect(transformedHarness.session.messages.filter((message) => message.role === "user")).toHaveLength(1);
expect(extensionApi).toBeDefined();
});
it("allows extension commands to inspect live system prompt options", async () => {
const seenOptions: BuildSystemPromptOptions[] = [];
const harness = await createHarness({
extensionFactories: [
(pi) => {
pi.registerCommand("inspect-options", {
description: "Inspect system prompt options",
handler: async (_args, ctx) => {
const options = ctx.getSystemPromptOptions();
seenOptions.push(options);
options.selectedTools?.push("mutated_tool");
},
});
},
],
});
harnesses.push(harness);
await harness.session.prompt("/inspect-options");
await harness.session.prompt("/inspect-options");
expect(seenOptions).toHaveLength(2);
expect(seenOptions[0]).toBe(seenOptions[1]);
expect(seenOptions[0]?.cwd).toBe(harness.tempDir);
expect(seenOptions[0]?.selectedTools).toContain("read");
expect(seenOptions[1]?.selectedTools).toContain("mutated_tool");
});
it("allows before_agent_start handlers to inject custom messages and modify the system prompt", async () => {
const harness = await createHarness({
extensionFactories: [
(pi) => {
pi.on("before_agent_start", async (event) => ({
message: {
customType: "before-start",
content: "injected",
display: true,
details: { injected: true },
},
systemPrompt: `${event.systemPrompt}\n\nextra instructions`,
}));
},
],
});
harnesses.push(harness);
let providerSystemPrompt = "";
let sawInjectedUserMessage = false;
harness.setResponses([
(context) => {
providerSystemPrompt = getCurrentSystemPrompt(context.messages);
sawInjectedUserMessage = context.messages.some(
(message) =>
message.role === "user" &&
typeof message.content !== "string" &&
message.content.some((part) => part.type === "text" && part.text === "injected"),
);
return fauxAssistantMessage("done");
},
]);
await harness.session.prompt("hello");
expect(providerSystemPrompt).toContain("extra instructions");
expect(sawInjectedUserMessage).toBe(true);
expect(
harness.session.messages.some((message) => message.role === "custom" && message.customType === "before-start"),
).toBe(true);
});
it("bindExtensions emits session_start and reload emits session_shutdown then session_start", async () => {
const lifecycleEvents: string[] = [];
const harness = await createHarness({
extensionFactories: [
(pi) => {
pi.on("session_start", async (event) => {
lifecycleEvents.push(`start:${event.reason}`);
});
pi.on("session_shutdown", async (event) => {
lifecycleEvents.push(`shutdown:${event.reason}`);
});
},
],
});
harnesses.push(harness);
await harness.session.bindExtensions({ shutdownHandler: () => {} });
await harness.session.reload();
expect(lifecycleEvents).toEqual(["start:startup", "shutdown:reload", "start:reload"]);
});
});