fix(coding-agent): cycle to the only model in scope when the active model is outside it (#11806)

This commit is contained in:
triagent 2026-11-04 17:05:22 +00:00
parent 4d72d05bcd
commit c4cd37c3e9
3 changed files with 52 additions and 14 deletions

View file

@ -22,6 +22,7 @@
- Fixed RPC mode cutting off shutdown cleanup, such as a pending MCP token refresh, when stdin closed and SIGTERM arrived together, which process managers like Paseo and systemd do. Pi lost the refreshed token and the MCP server revoked the sign-in. Once a shutdown has started, a stdin EOF or a single signal no longer interrupts it; a second signal still exits at once. Print mode likewise no longer dies from a signal that arrives during its final cleanup ([#11599](https://github.com/earendil-works/pi/issues/11599))
- Fixed the grep tool reading every matched file into memory when `context` was set, which ran out of memory on large files; context now comes from ripgrep, which also fixes UTF-16 files and lone CRs in matched lines, and the match limit notice no longer appears when exactly `limit` matches exist ([#9276](https://github.com/earendil-works/pi/issues/9276), [#11423](https://github.com/earendil-works/pi/issues/11423))
- Fixed the find tool reporting the result limit when exactly `limit` files match, and `limit: 0` in find and ls returning every result or an empty listing; limits below 1 now mean 1 ([#7121](https://github.com/earendil-works/pi/issues/7121))
- Fixed Ctrl+P not switching to the only model in scope (or the only available model) when the active model is a different one; it showed "Only one model in scope" instead. Cycling forward from a model outside the scope now also selects the first scoped model instead of skipping it ([#11806](https://github.com/earendil-works/pi/issues/11806))
## [1.5.0] - 2026-11-02

View file

@ -390,6 +390,22 @@ function estimateMessagesTokens(messages: AgentMessage[]): number {
// AgentSession Class
// ============================================================================
/**
* Index of the model to cycle to, or undefined when there is nothing to switch to.
* When the current model is not in the list (currentIndex -1), forward picks the first
* entry and backward the last, so a one-entry list still selects its only model.
*/
function getNextCycleIndex(
currentIndex: number,
length: number,
direction: "forward" | "backward",
): number | undefined {
if (length === 0) return undefined;
if (currentIndex === -1) return direction === "forward" ? 0 : length - 1;
if (length === 1) return undefined;
return direction === "forward" ? (currentIndex + 1) % length : (currentIndex - 1 + length) % length;
}
export class AgentSession {
readonly agent: Agent;
readonly sessionManager: SessionManager;
@ -2662,14 +2678,10 @@ export class AgentSession {
const scopedModels = this._scopedModels.filter((scoped) =>
availableIds.has(`${scoped.model.provider}\0${scoped.model.id}`),
);
if (scopedModels.length <= 1) return undefined;
const currentModel = this.model;
let currentIndex = scopedModels.findIndex((sm) => modelsAreEqual(sm.model, currentModel));
if (currentIndex === -1) currentIndex = 0;
const len = scopedModels.length;
const nextIndex = direction === "forward" ? (currentIndex + 1) % len : (currentIndex - 1 + len) % len;
const currentIndex = scopedModels.findIndex((sm) => modelsAreEqual(sm.model, currentModel));
const nextIndex = getNextCycleIndex(currentIndex, scopedModels.length, direction);
if (nextIndex === undefined) return undefined;
const next = scopedModels[nextIndex];
const thinkingLevel = this._getThinkingLevelForModelSwitch(next.model, next.thinkingLevel);
@ -2698,14 +2710,10 @@ export class AgentSession {
options: ModelMutationOptions,
): Promise<ModelCycleResult | undefined> {
const availableModels = this._modelRuntime.getAvailableSnapshot();
if (availableModels.length <= 1) return undefined;
const currentModel = this.model;
let currentIndex = availableModels.findIndex((m) => modelsAreEqual(m, currentModel));
if (currentIndex === -1) currentIndex = 0;
const len = availableModels.length;
const nextIndex = direction === "forward" ? (currentIndex + 1) % len : (currentIndex - 1 + len) % len;
const currentIndex = availableModels.findIndex((m) => modelsAreEqual(m, currentModel));
const nextIndex = getNextCycleIndex(currentIndex, availableModels.length, direction);
if (nextIndex === undefined) return undefined;
const nextModel = availableModels[nextIndex];
const thinkingLevel = this._getThinkingLevelForModelSwitch(nextModel);

View file

@ -197,6 +197,35 @@ describe("AgentSession model and extension characterization", () => {
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);