From c4cd37c3e94473695ee9e82e4797aeac78ae23ab Mon Sep 17 00:00:00 2001 From: triagent Date: Wed, 4 Nov 2026 17:05:22 +0000 Subject: [PATCH] fix(coding-agent): cycle to the only model in scope when the active model is outside it (#11806) --- packages/coding-agent/CHANGELOG.md | 1 + .../coding-agent/src/core/agent-session.ts | 36 +++++++++++-------- .../agent-session-model-extension.test.ts | 29 +++++++++++++++ 3 files changed, 52 insertions(+), 14 deletions(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index f92a1e3b9..44d95cfcf 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -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 diff --git a/packages/coding-agent/src/core/agent-session.ts b/packages/coding-agent/src/core/agent-session.ts index a0dda03e1..f7dfa4da8 100644 --- a/packages/coding-agent/src/core/agent-session.ts +++ b/packages/coding-agent/src/core/agent-session.ts @@ -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 { 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); diff --git a/packages/coding-agent/test/suite/agent-session-model-extension.test.ts b/packages/coding-agent/test/suite/agent-session-model-extension.test.ts index a9f6a8d0e..0ff658317 100644 --- a/packages/coding-agent/test/suite/agent-session-model-extension.test.ts +++ b/packages/coding-agent/test/suite/agent-session-model-extension.test.ts @@ -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);