From dbdc77697c41fc60696f6ed7b129e28b1caefcf3 Mon Sep 17 00:00:00 2001 From: roboomp Date: Tue, 2 Jun 2026 07:12:54 +0000 Subject: [PATCH] fix(coding-agent): retried session resume even when initial restore failed Post-extension session-model retry now covers the case where the initial restore failed entirely (e.g. saved default unavailable, last active role supplied by an extension) and the settings default filled in the active model. Also recomputes thinking-level from full precedence against the reclaimed model so a fallback model's defaultLevel does not become sticky.\n\nFixes #1649 --- packages/coding-agent/src/sdk.ts | 66 ++++++++++------- .../test/sdk-model-selection.test.ts | 71 +++++++++++++++++++ 2 files changed, 110 insertions(+), 27 deletions(-) diff --git a/packages/coding-agent/src/sdk.ts b/packages/coding-agent/src/sdk.ts index b4f013569..4bd16d8a7 100644 --- a/packages/coding-agent/src/sdk.ts +++ b/packages/coding-agent/src/sdk.ts @@ -1056,26 +1056,29 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {} const taskDepth = options.taskDepth ?? 0; - let thinkingLevel = options.thinkingLevel; - - // If session has data and includes a thinking entry, restore it - if (thinkingLevel === undefined && hasExistingSession && hasThinkingEntry) { - thinkingLevel = parseThinkingLevel(existingSession.thinkingLevel); - } - - if (thinkingLevel === undefined && !hasExplicitModel && !hasThinkingEntry && defaultRoleSpec.explicitThinkingLevel) { - thinkingLevel = defaultRoleSpec.thinkingLevel; - } - - // Prefer the selected model's configured defaultLevel, otherwise fall back - // to the global settings default. - if (thinkingLevel === undefined && model?.thinking?.defaultLevel !== undefined) { - thinkingLevel = model.thinking.defaultLevel; - } - if (thinkingLevel === undefined) { - thinkingLevel = settings.get("defaultThinkingLevel"); - } - const autoThinking = thinkingLevel === AUTO_THINKING; + // Resolves the session/agent thinking level using the same precedence we + // apply at startup: explicit option → persisted session entry → default + // role's explicit selector → selected model's defaultLevel → global + // settings default. Run again after extension role reclaim so the final + // model's own defaults aren't masked by an earlier fallback model's. + const pickInitialThinkingLevel = (selectedModel: Model | undefined): ConfiguredThinkingLevel | undefined => { + let level = options.thinkingLevel; + if (level === undefined && hasExistingSession && hasThinkingEntry) { + level = parseThinkingLevel(existingSession.thinkingLevel); + } + if (level === undefined && !hasExplicitModel && !hasThinkingEntry && defaultRoleSpec.explicitThinkingLevel) { + level = defaultRoleSpec.thinkingLevel; + } + if (level === undefined && selectedModel?.thinking?.defaultLevel !== undefined) { + level = selectedModel.thinking.defaultLevel; + } + if (level === undefined) { + level = settings.get("defaultThinkingLevel"); + } + return level; + }; + let thinkingLevel = pickInitialThinkingLevel(model); + let autoThinking = thinkingLevel === AUTO_THINKING; // Concrete level the agent/session start with. With `auto` this is the // provisional level shown until the first per-turn classification resolves; // `auto` itself stays a session-only concept handled by AgentSession. @@ -1461,13 +1464,16 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {} extensionsResult.runtime.pendingProviderRegistrations = []; } - // Retry preferred session-model candidates now that extension providers - // are registered. The initial restore above runs before extensions load, - // so a role model supplied by an extension would have fallen back to the - // session's saved default; reclaim it here so resume honors the last - // active role. - if (!hasExplicitModel && restoredSessionModelIndex > 0 && sessionModelStrings.length > 0) { - for (let i = 0; i < restoredSessionModelIndex; i++) { + // Retry session-model candidates now that extension providers are + // registered. The initial restore runs before extensions load, so a role + // model supplied by an extension would have either fallen back to the + // saved default (`restoredSessionModelIndex > 0`) or failed entirely + // (`restoredSessionModelIndex === -1`, with the settings default or + // downstream fallback filling `model`). Reclaim it here so resume + // honors the last active role in either case. + const sessionRetryLimit = restoredSessionModelIndex >= 0 ? restoredSessionModelIndex : sessionModelStrings.length; + if (!hasExplicitModel && sessionRetryLimit > 0) { + for (let i = 0; i < sessionRetryLimit; i++) { const sessionModelStr = sessionModelStrings[i]; const parsedModel = parseModelString(sessionModelStr); if (!parsedModel) continue; @@ -1476,6 +1482,12 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {} model = restoredModel; modelFallbackMessage = undefined; restoredSessionModelIndex = i; + // Recompute thinking-level from scratch against the reclaimed + // model: any value derived from the earlier fallback model's + // `thinking.defaultLevel` must not become sticky. + thinkingLevel = pickInitialThinkingLevel(restoredModel); + autoThinking = thinkingLevel === AUTO_THINKING; + effectiveThinkingLevel = thinkingLevel === AUTO_THINKING ? undefined : thinkingLevel; effectiveThinkingLevel = logger.time("resolveThinkingLevelForModel", () => autoThinking ? resolveProvisionalAutoLevel(restoredModel) diff --git a/packages/coding-agent/test/sdk-model-selection.test.ts b/packages/coding-agent/test/sdk-model-selection.test.ts index a88ed3a22..c702edf29 100644 --- a/packages/coding-agent/test/sdk-model-selection.test.ts +++ b/packages/coding-agent/test/sdk-model-selection.test.ts @@ -214,4 +214,75 @@ describe("createAgentSession deferred model pattern resolution", () => { authStorage.close(); } }); + + test("restores extension role model when saved default cannot be restored before extensions load", async () => { + const settingsDefaultModel = getBundledModel("anthropic", "claude-sonnet-4-5"); + if (!settingsDefaultModel) { + throw new Error("Expected bundled anthropic default model"); + } + + const authStorage = await AuthStorage.create(path.join(tempDir, "testauth.db")); + authStorage.setRuntimeApiKey(settingsDefaultModel.provider, "test-key"); + const modelRegistry = new ModelRegistry(authStorage, path.join(tempDir, "models.yml")); + + // Saved default points at a provider that has no usable credentials. The + // last active role (`smol`) is supplied by the inline extension and is + // only resolvable once provider registrations are processed. + const targetSessionFile = path.join(tempDir, "resume-extension-default-missing.jsonl"); + const timestamp = "2026-06-01T00:00:00.000Z"; + await Bun.write( + targetSessionFile, + `${[ + { type: "session", version: 3, id: "resume-ext-no-default", timestamp, cwd: tempDir }, + { + type: "model_change", + id: "default-model", + parentId: null, + timestamp, + model: "anthropic/not-available", + role: "default", + }, + { + type: "model_change", + id: "smol-model", + parentId: "default-model", + timestamp, + model: "runtime-provider/runtime-model", + role: "smol", + }, + ] + .map(entry => JSON.stringify(entry)) + .join("\n")}\n`, + ); + const sessionManager = await SessionManager.open(targetSessionFile, path.join(tempDir, "sessions-no-default")); + + const settings = Settings.isolated(); + settings.setModelRole("default", `${settingsDefaultModel.provider}/${settingsDefaultModel.id}`); + + const { session } = await createAgentSession({ + cwd: tempDir, + agentDir: tempDir, + authStorage, + modelRegistry, + sessionManager, + settings, + disableExtensionDiscovery: true, + extensions: [providerExtension], + skills: [], + contextFiles: [], + promptTemplates: [], + slashCommands: [], + enableMCP: false, + enableLsp: false, + skipPythonPreflight: true, + }); + + try { + expect(session.model?.provider).toBe("runtime-provider"); + expect(session.model?.id).toBe("runtime-model"); + } finally { + await session.dispose(); + authStorage.close(); + } + }); });