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
This commit is contained in:
roboomp
2026-07-27 02:22:23 +00:00
parent 517b0445a5
commit 4ede4eb3be
2 changed files with 85 additions and 13 deletions
+41 -13
View File
@@ -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<ScopedModel[]> {
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<string | undefined> {
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 <id>` 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);
}
@@ -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)", () => {