From 4ede4eb3be5a6dae9da093be7d1b9a574501f382 Mon Sep 17 00:00:00 2001 From: roboomp Date: Mon, 27 Jul 2026 02:22:23 +0000 Subject: [PATCH] fix(cli): recompute model scope after resume project switch - Re-resolve settings-derived model scope once a resume switches projects. - Extract resolveScopedModels shared across startup, explicit resume, and picker. - Assert destination enabledModels drive the resumed scope. Fixes #6752 --- packages/coding-agent/src/main.ts | 54 ++++++++++++++----- .../test/main-cross-project-resume.test.ts | 44 +++++++++++++++ 2 files changed, 85 insertions(+), 13 deletions(-) diff --git a/packages/coding-agent/src/main.ts b/packages/coding-agent/src/main.ts index fc45e09f4..2880c9f0e 100644 --- a/packages/coding-agent/src/main.ts +++ b/packages/coding-agent/src/main.ts @@ -624,6 +624,29 @@ async function switchToResumedProject( return cwd; } +/** + * Resolve the effective model allow-list from an explicit `--models` scope or, + * failing that, the active project's `enabledModels`. Re-run after a resume + * switches projects so the destination project's settings-derived scope wins + * over the launch directory's. + */ +async function resolveScopedModels( + parsed: Args, + modelRegistry: ModelRegistry, + activeSettings: Settings, +): Promise { + const modelPatterns = parsed.models ?? activeSettings.get("enabledModels"); + if (!modelPatterns || modelPatterns.length === 0) { + return []; + } + return await resolveModelScope( + modelPatterns, + modelRegistry, + getModelMatchPreferences(activeSettings), + activeSettings, + ); +} + async function getChangelogForDisplay(parsed: Args): Promise { if (parsed.continue || parsed.resume) { return undefined; @@ -1242,19 +1265,13 @@ export async function runRootCommand( settingsInstance.get("theme.light"), ); - let scopedModels: ScopedModel[] = []; - const modelPatterns = parsedArgs.models ?? settingsInstance.get("enabledModels"); - const modelMatchPreferences = getModelMatchPreferences(settingsInstance); - if (modelPatterns && modelPatterns.length > 0) { - scopedModels = await logger.time( - "resolveModelScope", - resolveModelScope, - modelPatterns, - modelRegistry, - modelMatchPreferences, - settingsInstance, - ); - } + let scopedModels = await logger.time( + "resolveModelScope", + resolveScopedModels, + parsedArgs, + modelRegistry, + settingsInstance, + ); // Resolve an explicit `--continue ` before extension flags are loaded. // Reading the token immediately after `--continue` distinguishes the session @@ -1286,7 +1303,14 @@ export async function runRootCommand( } if (typeof parsedArgs.resume === "string" && sessionManager) { + const previousCwd = cwd; cwd = await switchToResumedProject(sessionManager.getCwd(), settingsInstance, pluginPreloadPromise); + if (cwd !== previousCwd) { + // Destination project may scope a different `enabledModels`; re-resolve + // so the model UI and session options reflect it (explicit `--models` + // stays fixed inside resolveScopedModels). + scopedModels = await resolveScopedModels(parsedArgs, modelRegistry, settingsInstance); + } } // User declined the missing-directory move prompt — exit cleanly instead of @@ -1332,7 +1356,11 @@ export async function runRootCommand( process.exit(0); } // Re-scope every cwd-derived input before building the resumed session. + const previousCwd = cwd; cwd = await switchToResumedProject(selected.cwd, settingsInstance, pluginPreloadPromise); + if (cwd !== previousCwd) { + scopedModels = await resolveScopedModels(parsedArgs, modelRegistry, settingsInstance); + } sessionManager = await SessionManager.open(selected.path); } diff --git a/packages/coding-agent/test/main-cross-project-resume.test.ts b/packages/coding-agent/test/main-cross-project-resume.test.ts index 7ee527d15..917094346 100644 --- a/packages/coding-agent/test/main-cross-project-resume.test.ts +++ b/packages/coding-agent/test/main-cross-project-resume.test.ts @@ -12,6 +12,7 @@ import * as fsp from "node:fs/promises"; import * as os from "node:os"; import * as path from "node:path"; import { type Args, parseArgs } from "@oh-my-pi/pi-coding-agent/cli/args"; +import * as modelResolverModule from "@oh-my-pi/pi-coding-agent/config/model-resolver"; import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; import { createSessionManager, runRootCommand } from "@oh-my-pi/pi-coding-agent/main"; import { AuthStorage } from "@oh-my-pi/pi-coding-agent/session/auth-storage"; @@ -163,6 +164,49 @@ describe("runRootCommand — cross-project --resume", () => { expect(reloadForCwd).toHaveBeenCalledWith(resumedProject); expect(resumedManager?.getCwd()).toBe(resumedProject); }, 15_000); + + it("re-resolves the model scope from the resumed project's enabledModels after the switch", async () => { + const match = buildGlobalMatch(resumedProject); + vi.spyOn(sessionListingModule, "resolveResumableSession").mockResolvedValue(match); + // enabledModels scoped only to the resumed project: the launch scope + // yields no patterns, so any resolveModelScope call proves the recompute + // ran against the destination settings rather than the launch directory. + const settings = Settings.isolated({ + "marketplace.autoUpdate": "off", + enabledModels: [{ paths: [resumedProject], models: ["model-resumed"] }], + }); + const resolveModelScope = vi.spyOn(modelResolverModule, "resolveModelScope").mockResolvedValue([]); + const authStorage = await AuthStorage.create(path.join(root, "auth.db")); + const parsed = parseArgs(["--resume", "019e84ed", "--print"]); + parsed.noExtensions = true; + parsed.noSkills = true; + parsed.noRules = true; + parsed.noTools = true; + parsed.noLsp = true; + let resumedManager: SessionManager | undefined; + + try { + await runRootCommand(parsed, ["--resume", "019e84ed", "--print"], { + discoverAuthStorage: async () => authStorage, + settings, + createAgentSession: async options => { + if (!options) throw new Error("Expected session options"); + resumedManager = options.sessionManager; + throw new Error("stop after session options"); + }, + }); + } catch (error) { + if (!(error instanceof Error) || error.message !== "stop after session options") throw error; + } finally { + authStorage.close(); + await resumedManager?.close(); + } + + // Launch scope had no patterns, so the only resolution is the post-switch + // one; the pre-fix code never recomputed and would not call it at all. + expect(resolveModelScope).toHaveBeenCalledTimes(1); + expect(resolveModelScope.mock.calls[0]?.[0]).toEqual(["model-resumed"]); + }, 15_000); }); describe("createSessionManager — cross-project --resume relocation (moved worktree)", () => {