From 1424e6752d86941252277dde9affa47677ab9119 Mon Sep 17 00:00:00 2001 From: jaeyunha Date: Wed, 2 Sep 2026 08:23:45 +0900 Subject: [PATCH 1/6] fix(coding-agent): recover oversized session resumes --- changes.md | 11 ++ packages/coding-agent/CHANGELOG.md | 6 + packages/coding-agent/changes.md | 8 + packages/coding-agent/src/changes.md | 24 +++ .../coding-agent/src/core/agent-session.ts | 37 ++++- packages/coding-agent/src/core/changes.md | 28 ++++ packages/coding-agent/src/core/sdk.ts | 66 +++++++- packages/coding-agent/src/main.ts | 30 +++- .../test/suite/model-usability-budget.test.ts | 144 ++++++++++++++++- ...del-usability-budget-startup-error.test.ts | 146 ++++++++++++++++++ 10 files changed, 486 insertions(+), 14 deletions(-) create mode 100644 packages/coding-agent/test/suite/regressions/model-usability-budget-startup-error.test.ts diff --git a/changes.md b/changes.md index d73e5f99ea..2b0a426010 100644 --- a/changes.md +++ b/changes.md @@ -1,5 +1,16 @@ # changes — senpi-monorepo root +## Oversized saved-session recovery (2026-09-01) + +Coding-agent bootstrap now distinguishes implicit saved-model restoration from +explicit model selection. A restored session that outgrew its saved model picks +the authenticated candidate with the greatest verified remaining budget, +records the session-local model change, and opens normally. Explicit selections +remain fail-closed; no-capable-model failures render as clean typed CLI errors. + +This is core startup behavior because the failure occurs before extensions or +interactive UI activation. + ## Shared-host rendering isolation (2026-08-30) Shared socket clients now register `rendered_components` through additive `set_client_info` capabilities. Factory-rendered component records are filtered per connection, including capability-aware snapshot replay. Capabilities remain connection-wide across sessions and are cleared only on socket release; explicit close removes only the closing width. Shared bindings retain factories while disposing live renderers and footer providers when no capable connection remains, recreating them for later capable joiners. diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 555b91dc6d..73a6ae094b 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -6,6 +6,12 @@ ### Fixed +- Existing sessions whose restored transcript no longer fits their saved model + now reopen on the configured authenticated model with the largest usable + remaining context budget. Explicit `--model` selections stay strict, and a + session with no capable model exits with actionable budget guidance instead + of an uncaught stack trace. + - `/quit` and `/exit` submitted while startup is still finishing (managed-tool downloads) now quit instead of being parked back in the editor behind a "Startup is still in progress" notice. Parking the text also disabled the Ctrl+D quit escape, which only fires on an empty editor, so the usual way out was a dead end until the line was cleared by hand. - An extension calling `ctx.shutdown()` while the session is idle now shuts down immediately instead of waiting for an `agent_settled` event that an idle session never emits, which previously stranded the request until the user happened to run another turn. diff --git a/packages/coding-agent/changes.md b/packages/coding-agent/changes.md index 43416f6ff7..680d762b0b 100644 --- a/packages/coding-agent/changes.md +++ b/packages/coding-agent/changes.md @@ -1,5 +1,13 @@ # Local fork changes +## 2026-09-01 - Recover oversized session resumes + +- Implicit saved-model restores now recover onto the authenticated model with + the largest usable remaining context budget while preserving explicit model + admission checks. +- When no recovery model exists, CLI startup prints the typed budget guidance + and exits cleanly instead of exposing an uncaught exception stack. + ## 2026-09-01 - Acknowledge RPC abort before quiesce - The RPC `abort` command now acknowledges immediately after dispatching the abort signal, while observing quiesce failures through the existing `rpc_error` event path. diff --git a/packages/coding-agent/src/changes.md b/packages/coding-agent/src/changes.md index 70551aa9d0..88eed2f0f4 100644 --- a/packages/coding-agent/src/changes.md +++ b/packages/coding-agent/src/changes.md @@ -1,5 +1,29 @@ # changes +## 2026-09-01 - Make oversized sessions resumable + +### What changed + +- Session bootstrap now recovers an unusable implicit saved model onto the + authenticated model with the largest verified remaining context budget. +- CLI startup renders a no-capable-model budget failure as an actionable error + without leaking an uncaught Node stack. + +### Why + +- Recent long-running sessions could no longer reopen after their restored + transcript outgrew the saved model, even though a larger configured model was + available. + +### Why an extension could not handle it + +- Both the saved-model restore and startup usability gate precede extension + activation and TUI construction. + +### Expected merge conflict zones + +- MEDIUM: `core/sdk.ts` and the runtime creation boundary in `main.ts`. + ## 2026-09-01 - Negotiate RPC session auto-titling ### What changed diff --git a/packages/coding-agent/src/core/agent-session.ts b/packages/coding-agent/src/core/agent-session.ts index df0249c23c..806c4a08ec 100644 --- a/packages/coding-agent/src/core/agent-session.ts +++ b/packages/coding-agent/src/core/agent-session.ts @@ -4583,6 +4583,40 @@ export class AgentSession { return this._setModel(model, false); } + /** + * Recover an existing session whose restored model cannot carry its live + * context. Startup owns candidate selection and full-budget admission; this + * seam records the recovered model without changing global defaults or + * treating the restore as a manual fallback reset. + * @internal + */ + async setStartupRecoveryModel( + model: Model, + liveContextTokens: number, + ): Promise { + this.assertModelUsable(model, liveContextTokens); + if (!(await this._modelRuntime.checkAuth(model.provider))) { + throw new Error(`No API key for ${model.provider}/${model.id}`); + } + const previousModel = this.model; + const systemPromptChange = await this._switchActiveModel(model, { + persistDefault: false, + appendSessionEntry: false, + emitModelSelect: true, + modelSelectSource: "restore", + invalidateCompaction: true, + liveContextTokens, + }); + this.sessionManager.appendModelChange( + model.provider, + model.id, + undefined, + previousModel?.provider, + previousModel?.id, + ); + return systemPromptChange; + } + private async _setModel( model: Model, updateGlobalDefaults: boolean, @@ -4640,6 +4674,7 @@ export class AgentSession { modelSelectSource: ModelSelectSource; invalidateCompaction: boolean; ephemeralThinkingLevel?: ThinkingLevel; + liveContextTokens?: number; }, ): Promise { const previousModel = this.model; @@ -4652,7 +4687,7 @@ export class AgentSession { this._invalidateCompactionForModelSelection(); } const thinking = this._getThinkingForModelSwitch(model, opts.ephemeralThinkingLevel); - const liveContextTokens = this._getDownswitchLiveContextTokens(model); + const liveContextTokens = opts.liveContextTokens ?? this._getDownswitchLiveContextTokens(model); this.agent.state.model = model; this.agent.abortServerSideFallback = this.settingsManager.getAbortServerSideFallback() && this._retryFallback.hasConfiguredChain(); diff --git a/packages/coding-agent/src/core/changes.md b/packages/coding-agent/src/core/changes.md index e9794623f6..dcf0a961b4 100644 --- a/packages/coding-agent/src/core/changes.md +++ b/packages/coding-agent/src/core/changes.md @@ -1,5 +1,33 @@ # changes +## 2026-09-01 - Recover oversized saved sessions onto a usable model + +### What changed + +- `sdk.ts` now distinguishes an implicit saved-model restore from an explicit + startup model selection when the assembled runtime fails the live-context + usability projection. +- Implicit restores deterministically select the authenticated candidate with + the greatest remaining context budget, persist that change only in the + session history, and return a visible fallback notice. +- Explicit startup models remain fail-closed, and sessions with no capable + authenticated recovery model keep the typed `ModelUsabilityBudgetError`. + +### Why + +- The restored-transcript admission guard correctly prevented undersized model + switches, but it also made long existing sessions impossible to reopen from + the normal session picker even when a larger configured model was available. + +### Why an extension could not handle it + +- Model restoration and the first usability assertion happen before extensions + receive a live session surface. + +### Expected merge conflict zones + +- MEDIUM: `sdk.ts` startup model selection and final usability assertion. + ## 2026-08-31 - Session activity contract for host occupancy decisions ### What changed diff --git a/packages/coding-agent/src/core/sdk.ts b/packages/coding-agent/src/core/sdk.ts index b6a8489125..4536f0a744 100644 --- a/packages/coding-agent/src/core/sdk.ts +++ b/packages/coding-agent/src/core/sdk.ts @@ -1,6 +1,6 @@ import { join } from "node:path"; import { Agent, type AgentMessage, setDefaultStreamFn, type ThinkingLevel } from "@earendil-works/pi-agent-core"; -import type { ThinkingSelection } from "@earendil-works/pi-ai"; +import type { Api, ThinkingSelection } from "@earendil-works/pi-ai"; import { type Message, type Model, streamSimple } from "@earendil-works/pi-ai/compat"; import { getAgentDir } from "../config.ts"; import { resolvePath } from "../utils/paths.ts"; @@ -10,6 +10,11 @@ import { AuthStorage } from "./auth-storage.ts"; import { estimateTokens } from "./compaction/compaction.ts"; import { createSessionCursorExecBridge } from "./cursor-exec-bridge-session.ts"; import { DEFAULT_THINKING_LEVEL } from "./defaults.ts"; +import { + ModelUsabilityBudgetError, + type ModelUsabilityBudgetProjection, + projectModelUsabilityBudget, +} from "./extensions/builtin/compaction/model-usability-budget.ts"; import type { ServiceTier } from "./extensions/builtin/service-tier.ts"; import type { ExtensionRunner, LoadExtensionsResult, SessionStartEvent, ToolDefinition } from "./extensions/index.ts"; import { convertToLlmForTransport, TRANSPORT_IMAGE_BUDGET_BYTES } from "./messages.ts"; @@ -132,6 +137,45 @@ export interface CreateAgentSessionResult { modelFallbackMessage?: string; } +interface StartupRecoveryModel { + model: Model; + projection: ModelUsabilityBudgetProjection; + order: number; +} + +function findStartupRecoveryModel( + session: AgentSession, + modelRuntime: ModelRuntime, + settingsManager: SettingsManager, + liveContextTokens: number, +): StartupRecoveryModel | undefined { + const currentModel = session.model; + if (!currentModel) return undefined; + + const candidates = modelRuntime + .getAvailableSnapshot() + .map((model, order): StartupRecoveryModel | undefined => { + if (model.contextWindow <= 0 || (model.provider === currentModel.provider && model.id === currentModel.id)) { + return undefined; + } + const projection = projectModelUsabilityBudget({ + model, + systemPrompt: session.agent.state.systemPrompt, + tools: session.agent.state.tools, + liveContextTokens, + compaction: settingsManager.getCompactionSettings(), + }); + return projection.usable ? { model, projection, order } : undefined; + }) + .filter((candidate): candidate is StartupRecoveryModel => candidate !== undefined); + + return candidates.sort((left, right) => { + const leftRemaining = left.projection.contextWindow - left.projection.requiredTokens; + const rightRemaining = right.projection.contextWindow - right.projection.requiredTokens; + return rightRemaining - leftRemaining || left.order - right.order; + })[0]; +} + // Re-exports export * from "./agent-session-runtime.ts"; @@ -519,7 +563,25 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {} const liveContextTokens = hasExistingSession ? existingSession.messages.reduce((total, message) => total + estimateTokens(message), 0) : 0; - session.assertModelUsable(undefined, liveContextTokens); + try { + session.assertModelUsable(undefined, liveContextTokens); + } catch (error) { + if (options.model !== undefined || !hasExistingSession || !(error instanceof ModelUsabilityBudgetError)) { + throw error; + } + const recovery = findStartupRecoveryModel(session, modelRuntime, settingsManager, liveContextTokens); + if (!recovery) throw error; + + const restoredModel = session.model; + if (!restoredModel) throw error; + await session.setStartupRecoveryModel(recovery.model, liveContextTokens); + session.assertModelUsable(undefined, liveContextTokens); + const recoveryMessage = + `Restored context exceeds ${restoredModel.provider}/${restoredModel.id}'s usable budget. ` + + `Using ${recovery.projection.model} for this session with ` + + `${recovery.projection.contextWindow - recovery.projection.requiredTokens} tokens of remaining budget.`; + modelFallbackMessage = modelFallbackMessage ? `${modelFallbackMessage}. ${recoveryMessage}` : recoveryMessage; + } cursorBridgeSessionRef.current = session; const extensionsResult = resourceLoader.getExtensions(); diff --git a/packages/coding-agent/src/main.ts b/packages/coding-agent/src/main.ts index 86e791f72c..e038370258 100644 --- a/packages/coding-agent/src/main.ts +++ b/packages/coding-agent/src/main.ts @@ -42,7 +42,11 @@ import { } from "./cli/startup-loading-indicator.ts"; import { shouldRunFirstTimeSetup, showFirstTimeSetup, showStartupSelector } from "./cli/startup-ui.ts"; import { APP_NAME, DISPLAY_VERSION, ENV_SESSION_DIR, expandTildePath, getAgentDir, getPackageDir } from "./config.ts"; -import { type CreateAgentSessionRuntimeFactory, createAgentSessionRuntime } from "./core/agent-session-runtime.ts"; +import { + type AgentSessionRuntime, + type CreateAgentSessionRuntimeFactory, + createAgentSessionRuntime, +} from "./core/agent-session-runtime.ts"; import { type AgentSessionRuntimeDiagnostic, createAgentSessionFromServices, @@ -53,6 +57,7 @@ import { AuthStorage, ReadOnlyAuthStorage } from "./core/auth-storage.ts"; import { envValue } from "./core/brand.ts"; import { type CredentialAccountSummary, summarizeCredentialAccounts } from "./core/credential-accounts.ts"; import { exportFromFile } from "./core/export-html/index.ts"; +import { ModelUsabilityBudgetError } from "./core/extensions/builtin/compaction/model-usability-budget.ts"; import type { InlineExtension } from "./core/extensions/types.ts"; import { applyHttpProxySettings, configureHttpDispatcher } from "./core/http-dispatcher.ts"; import { @@ -1113,13 +1118,22 @@ export async function main(args: string[], options?: MainOptions) { listen: parsed.listen, }); } - const runtime = await createAgentSessionRuntime(createRuntime, { - cwd: sessionManager.getCwd(), - agentDir, - sessionManager, - }).finally(() => { - startupLoadingIndicator.stop(); - }); + let runtime: AgentSessionRuntime; + try { + runtime = await createAgentSessionRuntime(createRuntime, { + cwd: sessionManager.getCwd(), + agentDir, + sessionManager, + }).finally(() => { + startupLoadingIndicator.stop(); + }); + } catch (error) { + if (error instanceof ModelUsabilityBudgetError) { + console.error(chalk.red(error.message)); + process.exit(1); + } + throw error; + } time("createAgentSessionRuntime"); let selectedRuntime = runtime; if (isTruthyEnvFlag(envValue("DISABLE_SHARED_HOST"))) { diff --git a/packages/coding-agent/test/suite/model-usability-budget.test.ts b/packages/coding-agent/test/suite/model-usability-budget.test.ts index 72153545af..1bba0ce74b 100644 --- a/packages/coding-agent/test/suite/model-usability-budget.test.ts +++ b/packages/coding-agent/test/suite/model-usability-budget.test.ts @@ -6,6 +6,8 @@ import { } from "../../src/core/extensions/builtin/compaction/model-usability-budget.ts"; import { createAgentSession } from "../../src/core/sdk.ts"; import { SessionManager } from "../../src/core/session-manager.ts"; +import { initTheme } from "../../src/modes/interactive/theme/theme.ts"; +import { createTestExtensionsResult, createTestResourceLoader } from "../utilities.ts"; import { createHarness, type Harness } from "./harness.ts"; function seedLiveContext(harness: Harness, tokens: number): void { @@ -213,6 +215,53 @@ describe("model usability budget", () => { }); }); + it("recovers an oversized implicit saved-model restore onto the candidate with the largest remaining budget", async () => { + // given + const harness = await createHarness({ + models: [ + { id: "saved-small", contextWindow: 100_000, maxTokens: 4_000 }, + { id: "usable-medium", contextWindow: 600_000, maxTokens: 32_000 }, + { id: "usable-largest", contextWindow: 1_000_000, maxTokens: 32_000 }, + ], + }); + harnesses.push(harness); + const sessionManager = harness.sessionManager; + sessionManager.appendModelChange("faux", "saved-small"); + sessionManager.appendMessage({ + role: "user", + content: [{ type: "text", text: "restored transcript ".repeat(80_000) }], + timestamp: Date.now(), + }); + const originalEntries = sessionManager.getEntries(); + + // when + const resumed = await createAgentSession({ + cwd: harness.tempDir, + agentDir: join(harness.tempDir, "sdk-agent"), + authStorage: harness.authStorage, + modelRuntime: harness.session.modelRuntime, + sessionManager, + settingsManager: harness.settingsManager, + noTools: "all", + }); + + // then + expect(resumed.session.model?.id).toBe("usable-largest"); + expect(resumed.modelFallbackMessage).toContain("faux/saved-small"); + expect(resumed.modelFallbackMessage).toContain("faux/usable-largest"); + expect(sessionManager.getEntries().slice(0, originalEntries.length)).toEqual(originalEntries); + expect(sessionManager.getEntries().filter((entry) => entry.type === "model_change")).toMatchObject([ + { provider: "faux", modelId: "saved-small" }, + { + provider: "faux", + modelId: "usable-largest", + originalProvider: "faux", + originalModelId: "saved-small", + }, + ]); + resumed.session.dispose(); + }); + it("rejects a resumed session whose restored transcript exceeds the startup budget", async () => { // given const harness = await createHarness({ @@ -220,19 +269,22 @@ describe("model usability budget", () => { }); harnesses.push(harness); const sessionManager = harness.sessionManager; + sessionManager.appendModelChange("faux", "startup"); sessionManager.appendMessage({ role: "user", content: [{ type: "text", text: "restored transcript ".repeat(200_000) }], timestamp: Date.now(), }); - const model = harness.getModel(); // when / then const error = await createAgentSession({ cwd: harness.tempDir, agentDir: join(harness.tempDir, "sdk-agent"), - model, + authStorage: harness.authStorage, + modelRuntime: harness.session.modelRuntime, sessionManager, + settingsManager: harness.settingsManager, + noTools: "all", }).then( () => undefined, (reason: unknown) => reason, @@ -240,13 +292,99 @@ describe("model usability budget", () => { expect(error).toMatchObject({ name: "ModelUsabilityBudgetError", projection: { - model: `${model.provider}/${model.id}`, + model: "faux/startup", liveContextTokens: expect.any(Number), usable: false, }, }); }); + it("keeps an explicit oversized startup model strict instead of selecting a recovery model", async () => { + // given + const harness = await createHarness({ + models: [ + { id: "explicit-small", contextWindow: 100_000, maxTokens: 4_000 }, + { id: "available-large", contextWindow: 1_000_000, maxTokens: 32_000 }, + ], + }); + harnesses.push(harness); + const sessionManager = harness.sessionManager; + sessionManager.appendModelChange("faux", "available-large"); + sessionManager.appendMessage({ + role: "user", + content: [{ type: "text", text: "restored transcript ".repeat(80_000) }], + timestamp: Date.now(), + }); + + // when / then + await expect( + createAgentSession({ + cwd: harness.tempDir, + agentDir: join(harness.tempDir, "sdk-agent"), + authStorage: harness.authStorage, + model: harness.getModel("explicit-small"), + modelRuntime: harness.session.modelRuntime, + sessionManager, + settingsManager: harness.settingsManager, + noTools: "all", + }), + ).rejects.toMatchObject({ + name: "ModelUsabilityBudgetError", + projection: { + model: "faux/explicit-small", + usable: false, + }, + }); + expect(sessionManager.getEntries().filter((entry) => entry.type === "model_change")).toMatchObject([ + { provider: "faux", modelId: "available-large" }, + ]); + }); + + it("does not persist a recovery model when model-select admission rejects it", async () => { + // given + initTheme("dark"); + const harness = await createHarness({ + models: [ + { id: "saved-small", contextWindow: 100_000, maxTokens: 4_000 }, + { id: "candidate-large", contextWindow: 600_000, maxTokens: 32_000 }, + ], + }); + harnesses.push(harness); + const sessionManager = harness.sessionManager; + sessionManager.appendModelChange("faux", "saved-small"); + sessionManager.appendMessage({ + role: "user", + content: [{ type: "text", text: "restored transcript ".repeat(80_000) }], + timestamp: Date.now(), + }); + const originalModelChanges = sessionManager.getEntries().filter((entry) => entry.type === "model_change"); + const extensionsResult = await createTestExtensionsResult( + [ + (pi) => { + pi.on("model_select", () => ({ systemPrompt: "oversized model prompt ".repeat(80_000) })); + }, + ], + harness.tempDir, + ); + + // when / then + await expect( + createAgentSession({ + cwd: harness.tempDir, + agentDir: join(harness.tempDir, "sdk-agent"), + authStorage: harness.authStorage, + modelRuntime: harness.session.modelRuntime, + resourceLoader: createTestResourceLoader({ extensionsResult }), + sessionManager, + settingsManager: harness.settingsManager, + noTools: "all", + }), + ).rejects.toBeInstanceOf(ModelUsabilityBudgetError); + expect(sessionManager.getEntries().filter((entry) => entry.type === "model_change")).toEqual( + originalModelChanges, + ); + }); + it("keeps fresh and fitting resumed sessions accepted", async () => { // given const harness = await createHarness({ diff --git a/packages/coding-agent/test/suite/regressions/model-usability-budget-startup-error.test.ts b/packages/coding-agent/test/suite/regressions/model-usability-budget-startup-error.test.ts new file mode 100644 index 0000000000..82aa5b5a83 --- /dev/null +++ b/packages/coding-agent/test/suite/regressions/model-usability-budget-startup-error.test.ts @@ -0,0 +1,146 @@ +import { type ChildProcess, spawn } from "node:child_process"; +import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { ENV_AGENT_DIR } from "../../../src/config.ts"; +import { assertWorkspaceBuildPrerequisite } from "../../support/workspace-build-prerequisite.ts"; + +assertWorkspaceBuildPrerequisite(import.meta.url); + +const cliPath = resolve(__dirname, "../../../src/cli.ts"); +const sessionId = "0197f6e4-4cf9-7f44-a2d8-f8f7f49ee9d4"; +const childTimeoutMs = 20_000; +const ansiPattern = new RegExp(`${String.fromCharCode(27)}\\[[0-9;]*[A-Za-z]`, "g"); +const liveChildren = new Set(); +const tempRoots: string[] = []; + +interface CliFixture { + agentDir: string; + projectDir: string; + sessionDir: string; +} + +function createFixture(): CliFixture { + const root = realpathSync(mkdtempSync(join(tmpdir(), "senpi-startup-budget-"))); + tempRoots.push(root); + const agentDir = join(root, "agent"); + const projectDir = join(root, "project"); + const sessionDir = join(root, "sessions"); + mkdirSync(agentDir, { recursive: true }); + mkdirSync(projectDir, { recursive: true }); + mkdirSync(sessionDir, { recursive: true }); + + writeFileSync( + join(agentDir, "models.json"), + `${JSON.stringify({ + providers: { + "faux-tiny": { + baseUrl: "https://example.test/v1", + api: "openai-completions", + apiKey: "test-key", + models: [{ id: "tiny-ctx", contextWindow: 16_000, maxTokens: 4_000 }], + }, + }, + })}\n`, + ); + writeFileSync( + join(sessionDir, `${sessionId}.jsonl`), + `${[ + { + type: "session", + version: 3, + id: sessionId, + timestamp: "2026-08-07T00:00:00.000Z", + cwd: projectDir, + }, + { + type: "message", + id: "m1", + parentId: null, + timestamp: "2026-08-07T00:00:01.000Z", + message: { + role: "user", + content: [{ type: "text", text: "oversized restored context ".repeat(20_000) }], + timestamp: Date.parse("2026-08-07T00:00:01.000Z"), + }, + }, + ] + .map((entry) => JSON.stringify(entry)) + .join("\n")}\n`, + ); + return { agentDir, projectDir, sessionDir }; +} + +function killChild(child: ChildProcess): void { + if (child.exitCode !== null || child.killed) return; + child.kill("SIGKILL"); +} + +async function runCli(args: string[], fixture: CliFixture): Promise<{ code: number | null; output: string }> { + const child = spawn(process.execPath, args, { + cwd: fixture.projectDir, + env: { + ...process.env, + [ENV_AGENT_DIR]: fixture.agentDir, + PI_OFFLINE: "1", + }, + stdio: ["ignore", "pipe", "pipe"], + }); + liveChildren.add(child); + let output = ""; + child.stdout?.on("data", (chunk: Buffer) => { + output += chunk.toString(); + }); + child.stderr?.on("data", (chunk: Buffer) => { + output += chunk.toString(); + }); + const timeout = setTimeout(() => killChild(child), childTimeoutMs); + try { + const code = await new Promise((resolveExit, rejectSpawn) => { + child.on("error", rejectSpawn); + child.on("close", resolveExit); + }); + return { code, output: output.replace(ansiPattern, "") }; + } finally { + clearTimeout(timeout); + killChild(child); + liveChildren.delete(child); + } +} + +afterEach(() => { + for (const child of liveChildren) killChild(child); + liveChildren.clear(); + for (const root of tempRoots.splice(0)) rmSync(root, { recursive: true, force: true }); +}); + +describe("model usability budget startup errors", () => { + it("prints the actionable error without an uncaught stack when no recovery model is usable", async () => { + const fixture = createFixture(); + + const result = await runCli( + [ + cliPath, + "--session-dir", + fixture.sessionDir, + "--session", + sessionId, + "--provider", + "faux-tiny", + "--model", + "tiny-ctx", + "-p", + "continue", + ], + fixture, + ); + + expect(result.code).toBe(1); + expect(result.output).toContain("cannot switch: target context window 16000 tokens"); + expect(result.output).toContain("Compact the session, then revalidate and retry the model switch."); + expect(result.output).not.toContain("at AgentSession.assertModelUsable"); + expect(result.output).not.toContain("at createAgentSession"); + expect(result.output).not.toContain("Node.js v"); + }); +}); From b5d6c84edd24a6cbc2ed90236bc0a4b51cf73c42 Mon Sep 17 00:00:00 2001 From: jaeyunha Date: Wed, 2 Sep 2026 08:52:29 +0900 Subject: [PATCH 2/6] fix(coding-agent): harden resume model recovery --- changes.md | 3 +- packages/coding-agent/CHANGELOG.md | 7 ++- packages/coding-agent/changes.md | 2 + packages/coding-agent/src/changes.md | 3 + .../coding-agent/src/core/agent-session.ts | 4 +- packages/coding-agent/src/core/changes.md | 3 + packages/coding-agent/src/core/sdk.ts | 19 +++++-- .../test/suite/model-usability-budget.test.ts | 57 ++++++++++++++++++- 8 files changed, 85 insertions(+), 13 deletions(-) diff --git a/changes.md b/changes.md index 2b0a426010..9483c2d75a 100644 --- a/changes.md +++ b/changes.md @@ -5,7 +5,8 @@ Coding-agent bootstrap now distinguishes implicit saved-model restoration from explicit model selection. A restored session that outgrew its saved model picks the authenticated candidate with the greatest verified remaining budget, -records the session-local model change, and opens normally. Explicit selections +skips unavailable providers, records model-specific selection history only +after extension admission succeeds, and opens normally. Explicit selections remain fail-closed; no-capable-model failures render as clean typed CLI errors. This is core startup behavior because the failure occurs before extensions or diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 73a6ae094b..73a69a69a1 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -8,9 +8,10 @@ - Existing sessions whose restored transcript no longer fits their saved model now reopen on the configured authenticated model with the largest usable - remaining context budget. Explicit `--model` selections stay strict, and a - session with no capable model exits with actionable budget guidance instead - of an uncaught stack trace. + remaining context budget, skipping unavailable providers without leaving + partial model or thinking history. Explicit `--model` selections stay strict, + and a session with no capable model exits with actionable budget guidance + instead of an uncaught stack trace. - `/quit` and `/exit` submitted while startup is still finishing (managed-tool downloads) now quit instead of being parked back in the editor behind a "Startup is still in progress" notice. Parking the text also disabled the Ctrl+D quit escape, which only fires on an empty editor, so the usual way out was a dead end until the line was cleared by hand. diff --git a/packages/coding-agent/changes.md b/packages/coding-agent/changes.md index 680d762b0b..e1f1291d17 100644 --- a/packages/coding-agent/changes.md +++ b/packages/coding-agent/changes.md @@ -5,6 +5,8 @@ - Implicit saved-model restores now recover onto the authenticated model with the largest usable remaining context budget while preserving explicit model admission checks. +- Recovery skips unavailable providers and commits model-specific selection + history only after extension admission succeeds. - When no recovery model exists, CLI startup prints the typed budget guidance and exits cleanly instead of exposing an uncaught exception stack. diff --git a/packages/coding-agent/src/changes.md b/packages/coding-agent/src/changes.md index 88eed2f0f4..f15806780b 100644 --- a/packages/coding-agent/src/changes.md +++ b/packages/coding-agent/src/changes.md @@ -6,6 +6,9 @@ - Session bootstrap now recovers an unusable implicit saved model onto the authenticated model with the largest verified remaining context budget. +- An unavailable highest-capacity provider no longer aborts recovery; startup + tries the next usable provider, and failed model-select admission leaves no + model-specific thinking or model history behind. - CLI startup renders a no-capable-model budget failure as an actionable error without leaking an uncaught Node stack. diff --git a/packages/coding-agent/src/core/agent-session.ts b/packages/coding-agent/src/core/agent-session.ts index 806c4a08ec..f2f3ba8b7f 100644 --- a/packages/coding-agent/src/core/agent-session.ts +++ b/packages/coding-agent/src/core/agent-session.ts @@ -4595,9 +4595,6 @@ export class AgentSession { liveContextTokens: number, ): Promise { this.assertModelUsable(model, liveContextTokens); - if (!(await this._modelRuntime.checkAuth(model.provider))) { - throw new Error(`No API key for ${model.provider}/${model.id}`); - } const previousModel = this.model; const systemPromptChange = await this._switchActiveModel(model, { persistDefault: false, @@ -4605,6 +4602,7 @@ export class AgentSession { emitModelSelect: true, modelSelectSource: "restore", invalidateCompaction: true, + ephemeralThinkingLevel: this.thinkingLevel, liveContextTokens, }); this.sessionManager.appendModelChange( diff --git a/packages/coding-agent/src/core/changes.md b/packages/coding-agent/src/core/changes.md index dcf0a961b4..02af949530 100644 --- a/packages/coding-agent/src/core/changes.md +++ b/packages/coding-agent/src/core/changes.md @@ -10,6 +10,9 @@ - Implicit restores deterministically select the authenticated candidate with the greatest remaining context budget, persist that change only in the session history, and return a visible fallback notice. +- Recovery validates candidate credentials in budget order, skips unavailable + providers, and defers both model and model-specific thinking persistence + until extension admission succeeds. - Explicit startup models remain fail-closed, and sessions with no capable authenticated recovery model keep the typed `ModelUsabilityBudgetError`. diff --git a/packages/coding-agent/src/core/sdk.ts b/packages/coding-agent/src/core/sdk.ts index 4536f0a744..701d57e42d 100644 --- a/packages/coding-agent/src/core/sdk.ts +++ b/packages/coding-agent/src/core/sdk.ts @@ -143,14 +143,14 @@ interface StartupRecoveryModel { order: number; } -function findStartupRecoveryModel( +function findStartupRecoveryModels( session: AgentSession, modelRuntime: ModelRuntime, settingsManager: SettingsManager, liveContextTokens: number, -): StartupRecoveryModel | undefined { +): StartupRecoveryModel[] { const currentModel = session.model; - if (!currentModel) return undefined; + if (!currentModel) return []; const candidates = modelRuntime .getAvailableSnapshot() @@ -173,7 +173,7 @@ function findStartupRecoveryModel( const leftRemaining = left.projection.contextWindow - left.projection.requiredTokens; const rightRemaining = right.projection.contextWindow - right.projection.requiredTokens; return rightRemaining - leftRemaining || left.order - right.order; - })[0]; + }); } // Re-exports @@ -569,7 +569,16 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {} if (options.model !== undefined || !hasExistingSession || !(error instanceof ModelUsabilityBudgetError)) { throw error; } - const recovery = findStartupRecoveryModel(session, modelRuntime, settingsManager, liveContextTokens); + const unavailableProviders = new Set(); + let recovery: StartupRecoveryModel | undefined; + for (const candidate of findStartupRecoveryModels(session, modelRuntime, settingsManager, liveContextTokens)) { + if (unavailableProviders.has(candidate.model.provider)) continue; + if (await modelRuntime.checkAuth(candidate.model.provider)) { + recovery = candidate; + break; + } + unavailableProviders.add(candidate.model.provider); + } if (!recovery) throw error; const restoredModel = session.model; diff --git a/packages/coding-agent/test/suite/model-usability-budget.test.ts b/packages/coding-agent/test/suite/model-usability-budget.test.ts index 1bba0ce74b..f11e8b212e 100644 --- a/packages/coding-agent/test/suite/model-usability-budget.test.ts +++ b/packages/coding-agent/test/suite/model-usability-budget.test.ts @@ -1,5 +1,5 @@ import { join } from "node:path"; -import { afterEach, describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { ModelUsabilityBudgetError, projectModelUsabilityBudget, @@ -262,6 +262,61 @@ describe("model usability budget", () => { resumed.session.dispose(); }); + it("skips an unauthenticated largest candidate and recovers onto the next capable provider", async () => { + // given + const harness = await createHarness({ + models: [ + { id: "saved-small", contextWindow: 100_000, maxTokens: 4_000 }, + { id: "candidate-medium", contextWindow: 600_000, maxTokens: 32_000 }, + { id: "candidate-largest", contextWindow: 1_000_000, maxTokens: 32_000 }, + ], + }); + harnesses.push(harness); + const sessionManager = harness.sessionManager; + sessionManager.appendModelChange("faux", "saved-small"); + sessionManager.appendMessage({ + role: "user", + content: [{ type: "text", text: "restored transcript ".repeat(80_000) }], + timestamp: Date.now(), + }); + const modelRuntime = harness.session.modelRuntime; + const saved = harness.getModel("saved-small"); + const mediumSource = harness.getModel("candidate-medium"); + const largestSource = harness.getModel("candidate-largest"); + if (!saved || !mediumSource || !largestSource) throw new Error("missing auth recovery model fixture"); + const medium = { ...mediumSource, provider: "available-provider" }; + const largest = { ...largestSource, provider: "expired-provider" }; + vi.spyOn(modelRuntime, "getAvailableSnapshot").mockReturnValue([saved, medium, largest]); + const checkAuth = vi + .spyOn(modelRuntime, "checkAuth") + .mockImplementation(async (provider) => (provider === "available-provider" ? { type: "api_key" } : undefined)); + + // when + const resumed = await createAgentSession({ + cwd: harness.tempDir, + agentDir: join(harness.tempDir, "sdk-agent"), + authStorage: harness.authStorage, + modelRuntime, + sessionManager, + settingsManager: harness.settingsManager, + noTools: "all", + }); + + // then + expect(checkAuth.mock.calls.map(([provider]) => provider)).toEqual(["expired-provider", "available-provider"]); + expect(resumed.session.model).toMatchObject({ provider: "available-provider", id: "candidate-medium" }); + expect(sessionManager.getEntries().filter((entry) => entry.type === "model_change")).toMatchObject([ + { provider: "faux", modelId: "saved-small" }, + { + provider: "available-provider", + modelId: "candidate-medium", + originalProvider: "faux", + originalModelId: "saved-small", + }, + ]); + resumed.session.dispose(); + }); + it("rejects a resumed session whose restored transcript exceeds the startup budget", async () => { // given const harness = await createHarness({ From 0b3be0a23f5f67ea0f02e7874b910c3f8416f24b Mon Sep 17 00:00:00 2001 From: jaeyunha Date: Wed, 2 Sep 2026 09:10:50 +0900 Subject: [PATCH 3/6] fix(coding-agent): finalize resume recovery admission --- .../coding-agent/src/core/agent-session.ts | 5 +++- packages/coding-agent/src/core/sdk.ts | 27 ++++++++++++++++--- .../test/suite/model-usability-budget.test.ts | 6 ++++- 3 files changed, 32 insertions(+), 6 deletions(-) diff --git a/packages/coding-agent/src/core/agent-session.ts b/packages/coding-agent/src/core/agent-session.ts index f2f3ba8b7f..5e033f84ae 100644 --- a/packages/coding-agent/src/core/agent-session.ts +++ b/packages/coding-agent/src/core/agent-session.ts @@ -4602,7 +4602,7 @@ export class AgentSession { emitModelSelect: true, modelSelectSource: "restore", invalidateCompaction: true, - ephemeralThinkingLevel: this.thinkingLevel, + persistThinkingLevel: false, liveContextTokens, }); this.sessionManager.appendModelChange( @@ -4672,6 +4672,7 @@ export class AgentSession { modelSelectSource: ModelSelectSource; invalidateCompaction: boolean; ephemeralThinkingLevel?: ThinkingLevel; + persistThinkingLevel?: boolean; liveContextTokens?: number; }, ): Promise { @@ -4709,6 +4710,8 @@ export class AgentSession { if (opts.ephemeralThinkingLevel !== undefined) { this._applyEphemeralThinkingLevel(thinking.level); + } else if (opts.persistThinkingLevel === false) { + this._applyEphemeralThinkingLevel(thinking.level); } else { this._setThinkingLevel(thinking.level, false, thinking.selection); } diff --git a/packages/coding-agent/src/core/sdk.ts b/packages/coding-agent/src/core/sdk.ts index 701d57e42d..c420adac18 100644 --- a/packages/coding-agent/src/core/sdk.ts +++ b/packages/coding-agent/src/core/sdk.ts @@ -143,6 +143,16 @@ interface StartupRecoveryModel { order: number; } +class SessionResumeModelUnavailableError extends ModelUsabilityBudgetError { + constructor(error: ModelUsabilityBudgetError) { + super(error.projection); + this.name = "SessionResumeModelUnavailableError"; + this.message = + "Cannot resume this session: no authenticated model has enough usable context budget for the restored history. " + + "Configure a larger-context model or start a new session."; + } +} + function findStartupRecoveryModels( session: AgentSession, modelRuntime: ModelRuntime, @@ -579,16 +589,25 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {} } unavailableProviders.add(candidate.model.provider); } - if (!recovery) throw error; + if (!recovery) throw new SessionResumeModelUnavailableError(error); const restoredModel = session.model; if (!restoredModel) throw error; await session.setStartupRecoveryModel(recovery.model, liveContextTokens); - session.assertModelUsable(undefined, liveContextTokens); + const admittedProjection = projectModelUsabilityBudget({ + model: recovery.model, + systemPrompt: session.agent.state.systemPrompt, + tools: session.agent.state.tools, + liveContextTokens, + compaction: settingsManager.getCompactionSettings(), + }); + if (!admittedProjection.usable) { + throw new ModelUsabilityBudgetError(admittedProjection); + } const recoveryMessage = `Restored context exceeds ${restoredModel.provider}/${restoredModel.id}'s usable budget. ` + - `Using ${recovery.projection.model} for this session with ` + - `${recovery.projection.contextWindow - recovery.projection.requiredTokens} tokens of remaining budget.`; + `Using ${admittedProjection.model} for this session with ` + + `${admittedProjection.contextWindow - admittedProjection.requiredTokens} tokens of remaining budget.`; modelFallbackMessage = modelFallbackMessage ? `${modelFallbackMessage}. ${recoveryMessage}` : recoveryMessage; } cursorBridgeSessionRef.current = session; diff --git a/packages/coding-agent/test/suite/model-usability-budget.test.ts b/packages/coding-agent/test/suite/model-usability-budget.test.ts index f11e8b212e..36e23b95bf 100644 --- a/packages/coding-agent/test/suite/model-usability-budget.test.ts +++ b/packages/coding-agent/test/suite/model-usability-budget.test.ts @@ -345,7 +345,10 @@ describe("model usability budget", () => { (reason: unknown) => reason, ); expect(error).toMatchObject({ - name: "ModelUsabilityBudgetError", + name: "SessionResumeModelUnavailableError", + message: + "Cannot resume this session: no authenticated model has enough usable context budget for the restored history. " + + "Configure a larger-context model or start a new session.", projection: { model: "faux/startup", liveContextTokens: expect.any(Number), @@ -438,6 +441,7 @@ describe("model usability budget", () => { expect(sessionManager.getEntries().filter((entry) => entry.type === "model_change")).toEqual( originalModelChanges, ); + expect(sessionManager.getEntries().filter((entry) => entry.type === "thinking_level_change")).toHaveLength(1); }); it("keeps fresh and fitting resumed sessions accepted", async () => { From 83a032d4194b3c8b4c8bd91e4960335b337e38ae Mon Sep 17 00:00:00 2001 From: jaeyunha Date: Wed, 2 Sep 2026 09:48:00 +0900 Subject: [PATCH 4/6] fix(coding-agent): continue resume candidate admission --- changes.md | 3 +- packages/coding-agent/CHANGELOG.md | 3 +- packages/coding-agent/changes.md | 2 + packages/coding-agent/src/changes.md | 3 +- packages/coding-agent/src/core/changes.md | 2 + packages/coding-agent/src/core/sdk.ts | 47 ++++++++++------- .../test/suite/model-usability-budget.test.ts | 52 +++++++++++++++++++ 7 files changed, 89 insertions(+), 23 deletions(-) diff --git a/changes.md b/changes.md index 9483c2d75a..c5b81db18d 100644 --- a/changes.md +++ b/changes.md @@ -7,7 +7,8 @@ explicit model selection. A restored session that outgrew its saved model picks the authenticated candidate with the greatest verified remaining budget, skips unavailable providers, records model-specific selection history only after extension admission succeeds, and opens normally. Explicit selections -remain fail-closed; no-capable-model failures render as clean typed CLI errors. +remain fail-closed; post-select candidate failures fall through; no-capable- +model failures render as clean typed CLI errors. This is core startup behavior because the failure occurs before extensions or interactive UI activation. diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 73a69a69a1..60cb4a3e51 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -9,7 +9,8 @@ - Existing sessions whose restored transcript no longer fits their saved model now reopen on the configured authenticated model with the largest usable remaining context budget, skipping unavailable providers without leaving - partial model or thinking history. Explicit `--model` selections stay strict, + partial model or thinking history persisted. Explicit `--model` selections + stay strict, later candidates are tried after post-select admission failures, and a session with no capable model exits with actionable budget guidance instead of an uncaught stack trace. diff --git a/packages/coding-agent/changes.md b/packages/coding-agent/changes.md index e1f1291d17..8d01d135f5 100644 --- a/packages/coding-agent/changes.md +++ b/packages/coding-agent/changes.md @@ -7,6 +7,8 @@ admission checks. - Recovery skips unavailable providers and commits model-specific selection history only after extension admission succeeds. +- A candidate rejected by a model-select budget hook no longer prevents later + capable candidates from being tried. - When no recovery model exists, CLI startup prints the typed budget guidance and exits cleanly instead of exposing an uncaught exception stack. diff --git a/packages/coding-agent/src/changes.md b/packages/coding-agent/src/changes.md index f15806780b..8fbd43d517 100644 --- a/packages/coding-agent/src/changes.md +++ b/packages/coding-agent/src/changes.md @@ -8,7 +8,8 @@ authenticated model with the largest verified remaining context budget. - An unavailable highest-capacity provider no longer aborts recovery; startup tries the next usable provider, and failed model-select admission leaves no - model-specific thinking or model history behind. + model-specific thinking or model history persisted. +- A post-select budget rejection also falls through to the next capable model. - CLI startup renders a no-capable-model budget failure as an actionable error without leaking an uncaught Node stack. diff --git a/packages/coding-agent/src/core/changes.md b/packages/coding-agent/src/core/changes.md index 02af949530..3069816e7b 100644 --- a/packages/coding-agent/src/core/changes.md +++ b/packages/coding-agent/src/core/changes.md @@ -13,6 +13,8 @@ - Recovery validates candidate credentials in budget order, skips unavailable providers, and defers both model and model-specific thinking persistence until extension admission succeeds. +- If a model-select hook makes the highest-capacity candidate unusable, startup + continues through the remaining budget-ranked candidates. - Explicit startup models remain fail-closed, and sessions with no capable authenticated recovery model keep the typed `ModelUsabilityBudgetError`. diff --git a/packages/coding-agent/src/core/sdk.ts b/packages/coding-agent/src/core/sdk.ts index c420adac18..ea70e5e1cc 100644 --- a/packages/coding-agent/src/core/sdk.ts +++ b/packages/coding-agent/src/core/sdk.ts @@ -579,35 +579,42 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {} if (options.model !== undefined || !hasExistingSession || !(error instanceof ModelUsabilityBudgetError)) { throw error; } + const restoredModel = session.model; + if (!restoredModel) throw error; const unavailableProviders = new Set(); - let recovery: StartupRecoveryModel | undefined; + let recovery: ModelUsabilityBudgetProjection | undefined; + let lastRecoveryError: ModelUsabilityBudgetError | undefined; for (const candidate of findStartupRecoveryModels(session, modelRuntime, settingsManager, liveContextTokens)) { if (unavailableProviders.has(candidate.model.provider)) continue; - if (await modelRuntime.checkAuth(candidate.model.provider)) { - recovery = candidate; + if (!(await modelRuntime.checkAuth(candidate.model.provider))) { + unavailableProviders.add(candidate.model.provider); + continue; + } + try { + await session.setStartupRecoveryModel(candidate.model, liveContextTokens); + const admittedProjection = projectModelUsabilityBudget({ + model: candidate.model, + systemPrompt: session.agent.state.systemPrompt, + tools: session.agent.state.tools, + liveContextTokens, + compaction: settingsManager.getCompactionSettings(), + }); + if (!admittedProjection.usable) { + throw new ModelUsabilityBudgetError(admittedProjection); + } + recovery = admittedProjection; break; + } catch (candidateError) { + if (!(candidateError instanceof ModelUsabilityBudgetError)) throw candidateError; + lastRecoveryError = candidateError; } - unavailableProviders.add(candidate.model.provider); } - if (!recovery) throw new SessionResumeModelUnavailableError(error); + if (!recovery) throw new SessionResumeModelUnavailableError(lastRecoveryError ?? error); - const restoredModel = session.model; - if (!restoredModel) throw error; - await session.setStartupRecoveryModel(recovery.model, liveContextTokens); - const admittedProjection = projectModelUsabilityBudget({ - model: recovery.model, - systemPrompt: session.agent.state.systemPrompt, - tools: session.agent.state.tools, - liveContextTokens, - compaction: settingsManager.getCompactionSettings(), - }); - if (!admittedProjection.usable) { - throw new ModelUsabilityBudgetError(admittedProjection); - } const recoveryMessage = `Restored context exceeds ${restoredModel.provider}/${restoredModel.id}'s usable budget. ` + - `Using ${admittedProjection.model} for this session with ` + - `${admittedProjection.contextWindow - admittedProjection.requiredTokens} tokens of remaining budget.`; + `Using ${recovery.model} for this session with ` + + `${recovery.contextWindow - recovery.requiredTokens} tokens of remaining budget.`; modelFallbackMessage = modelFallbackMessage ? `${modelFallbackMessage}. ${recoveryMessage}` : recoveryMessage; } cursorBridgeSessionRef.current = session; diff --git a/packages/coding-agent/test/suite/model-usability-budget.test.ts b/packages/coding-agent/test/suite/model-usability-budget.test.ts index 36e23b95bf..0506cfc258 100644 --- a/packages/coding-agent/test/suite/model-usability-budget.test.ts +++ b/packages/coding-agent/test/suite/model-usability-budget.test.ts @@ -317,6 +317,58 @@ describe("model usability budget", () => { resumed.session.dispose(); }); + it("continues after a larger candidate fails post-select budget admission", async () => { + // given + initTheme("dark"); + const harness = await createHarness({ + models: [ + { id: "saved-small", contextWindow: 100_000, maxTokens: 4_000 }, + { id: "candidate-medium", contextWindow: 600_000, maxTokens: 32_000 }, + { id: "candidate-largest", contextWindow: 1_000_000, maxTokens: 32_000 }, + ], + }); + harnesses.push(harness); + const extensionsResult = await createTestExtensionsResult( + [ + (pi) => { + pi.on("model_select", (event) => + event.model.id === "candidate-largest" + ? { systemPrompt: "oversized model prompt ".repeat(300_000) } + : undefined, + ); + }, + ], + harness.tempDir, + ); + const sessionManager = harness.sessionManager; + sessionManager.appendModelChange("faux", "saved-small"); + sessionManager.appendMessage({ + role: "user", + content: [{ type: "text", text: "restored transcript ".repeat(80_000) }], + timestamp: Date.now(), + }); + + // when + const resumed = await createAgentSession({ + cwd: harness.tempDir, + agentDir: join(harness.tempDir, "sdk-agent"), + authStorage: harness.authStorage, + modelRuntime: harness.session.modelRuntime, + resourceLoader: createTestResourceLoader({ extensionsResult }), + sessionManager, + settingsManager: harness.settingsManager, + noTools: "all", + }); + + // then + expect(resumed.session.model).toMatchObject({ id: "candidate-medium" }); + expect(sessionManager.getEntries().filter((entry) => entry.type === "model_change")).toMatchObject([ + { provider: "faux", modelId: "saved-small" }, + { provider: "faux", modelId: "candidate-medium" }, + ]); + resumed.session.dispose(); + }); + it("rejects a resumed session whose restored transcript exceeds the startup budget", async () => { // given const harness = await createHarness({ From 71e65e0fbd50d76a8fd554ad9a05355469c0ddef Mon Sep 17 00:00:00 2001 From: jaeyunha Date: Wed, 2 Sep 2026 11:10:20 +0900 Subject: [PATCH 5/6] fix(coding-agent): isolate resume candidate admission --- changes.md | 5 +- packages/coding-agent/CHANGELOG.md | 6 +- packages/coding-agent/changes.md | 3 +- packages/coding-agent/src/changes.md | 4 +- .../coding-agent/src/core/agent-session.ts | 108 +++++++++++++++++- packages/coding-agent/src/core/changes.md | 3 +- packages/coding-agent/src/core/sdk.ts | 11 +- .../test/suite/model-usability-budget.test.ts | 85 ++++++++++++++ 8 files changed, 208 insertions(+), 17 deletions(-) diff --git a/changes.md b/changes.md index c5b81db18d..368b2ae618 100644 --- a/changes.md +++ b/changes.md @@ -7,8 +7,9 @@ explicit model selection. A restored session that outgrew its saved model picks the authenticated candidate with the greatest verified remaining budget, skips unavailable providers, records model-specific selection history only after extension admission succeeds, and opens normally. Explicit selections -remain fail-closed; post-select candidate failures fall through; no-capable- -model failures render as clean typed CLI errors. +remain fail-closed; post-select candidate failures roll back their runtime +state before falling through; each provider is authenticated once per recovery; +no-capable-model failures render as clean typed CLI errors. This is core startup behavior because the failure occurs before extensions or interactive UI activation. diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 60cb4a3e51..481e3a6f82 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -10,9 +10,9 @@ now reopen on the configured authenticated model with the largest usable remaining context budget, skipping unavailable providers without leaving partial model or thinking history persisted. Explicit `--model` selections - stay strict, later candidates are tried after post-select admission failures, - and a session with no capable model exits with actionable budget guidance - instead of an uncaught stack trace. + stay strict, rejected candidates roll back their model-specific runtime state + before later candidates are tried, and a session with no capable model exits + with actionable budget guidance instead of an uncaught stack trace. - `/quit` and `/exit` submitted while startup is still finishing (managed-tool downloads) now quit instead of being parked back in the editor behind a "Startup is still in progress" notice. Parking the text also disabled the Ctrl+D quit escape, which only fires on an empty editor, so the usual way out was a dead end until the line was cleared by hand. diff --git a/packages/coding-agent/changes.md b/packages/coding-agent/changes.md index 8d01d135f5..0b01bedccc 100644 --- a/packages/coding-agent/changes.md +++ b/packages/coding-agent/changes.md @@ -8,7 +8,8 @@ - Recovery skips unavailable providers and commits model-specific selection history only after extension admission succeeds. - A candidate rejected by a model-select budget hook no longer prevents later - capable candidates from being tried. + capable candidates from being tried; rejected candidate runtime state is + rolled back and each provider is authenticated once per recovery. - When no recovery model exists, CLI startup prints the typed budget guidance and exits cleanly instead of exposing an uncaught exception stack. diff --git a/packages/coding-agent/src/changes.md b/packages/coding-agent/src/changes.md index 8fbd43d517..93d8951b21 100644 --- a/packages/coding-agent/src/changes.md +++ b/packages/coding-agent/src/changes.md @@ -9,7 +9,9 @@ - An unavailable highest-capacity provider no longer aborts recovery; startup tries the next usable provider, and failed model-select admission leaves no model-specific thinking or model history persisted. -- A post-select budget rejection also falls through to the next capable model. +- A post-select budget rejection rolls back candidate-specific runtime state + before falling through to the next capable model; provider authentication is + cached for that recovery attempt. - CLI startup renders a no-capable-model budget failure as an actionable error without leaking an uncaught Node stack. diff --git a/packages/coding-agent/src/core/agent-session.ts b/packages/coding-agent/src/core/agent-session.ts index 5e033f84ae..076fb46967 100644 --- a/packages/coding-agent/src/core/agent-session.ts +++ b/packages/coding-agent/src/core/agent-session.ts @@ -1154,6 +1154,8 @@ export class AgentSession { private _currentServiceTier: ServiceTier | undefined = undefined; private _sessionFastMode = false; private readonly _shownHighReasoningWarningKeys = new Set(); + /** Buffers public events until transactional model admission commits. */ + private _deferredSessionEvents?: AgentSessionEvent[]; // Widened with the upstream BuildSystemPromptOptions user-override fields so // extensions (prompt-preset) can see CLI/SDK custom prompts via // before_agent_start/model_select systemPromptOptions and ctx.getSystemPromptOptions(). @@ -1613,6 +1615,10 @@ export class AgentSession { } private _emit(event: AgentSessionEvent): void { + if (this._deferredSessionEvents) { + this._deferredSessionEvents.push(event); + return; + } this._logSessionEvent(event); for (const l of this._eventListeners) { l(event); @@ -4493,6 +4499,7 @@ export class AgentSession { nextModel: Model, previousModel: Model | undefined, source: ModelSelectSource, + publish = true, ): Promise { this.syncPromptCacheSafeWaitEnv(); if (!this._modelSelectionChangesContext(previousModel, nextModel)) return undefined; @@ -4526,9 +4533,13 @@ export class AgentSession { if (result.systemPromptName) { event.systemPromptName = result.systemPromptName; } + if (publish) await this._publishSystemPromptChange(event); + return event; + } + + private async _publishSystemPromptChange(event: SystemPromptChangeEvent): Promise { await this._extensionRunner.emit(event); this._emit(event); - return event; } /** @@ -4604,6 +4615,7 @@ export class AgentSession { invalidateCompaction: true, persistThinkingLevel: false, liveContextTokens, + transactionalModelSelect: true, }); this.sessionManager.appendModelChange( model.provider, @@ -4674,9 +4686,76 @@ export class AgentSession { ephemeralThinkingLevel?: ThinkingLevel; persistThinkingLevel?: boolean; liveContextTokens?: number; + transactionalModelSelect?: boolean; }, ): Promise { const previousModel = this.model; + const snapshot = opts.transactionalModelSelect + ? { + model: this.agent.state.model, + systemPrompt: this.agent.state.systemPrompt, + tools: this.agent.state.tools, + thinkingLevel: this.agent.state.thinkingLevel, + thinkingSelection: this.agent.state.thinkingSelection, + abortServerSideFallback: this.agent.abortServerSideFallback, + currentServiceTier: this._currentServiceTier, + sessionFastMode: this._sessionFastMode, + baseSystemPrompt: this._baseSystemPrompt, + baseSystemPromptOptions: { ...this._baseSystemPromptOptions }, + systemPromptOverride: this._systemPromptOverride, + requestedActiveToolNames: this._requestedActiveToolNames + ? [...this._requestedActiveToolNames] + : undefined, + withheldEvalOnlyToolNames: new Set(this._withheldEvalOnlyToolNames), + publishedEvalOnlyHintNames: new Set(this._publishedEvalOnlyHintNames), + removedToolHints: { ...this.agent.removedToolHints }, + toolRegistry: new Map(this._toolRegistry), + toolDefinitions: new Map(this._toolDefinitions), + toolPromptSnippets: new Map(this._toolPromptSnippets), + toolPromptGuidelines: new Map(this._toolPromptGuidelines), + shownHighReasoningWarningKeys: new Set(this._shownHighReasoningWarningKeys), + } + : undefined; + const ownsDeferredEvents = opts.transactionalModelSelect && this._deferredSessionEvents === undefined; + if (ownsDeferredEvents) this._deferredSessionEvents = []; + const restoreSnapshot = () => { + if (!snapshot) return; + this.agent.state.model = snapshot.model; + this.agent.state.systemPrompt = snapshot.systemPrompt; + this.agent.state.tools = snapshot.tools; + this.agent.state.thinkingLevel = snapshot.thinkingLevel; + this.agent.state.thinkingSelection = snapshot.thinkingSelection; + this.agent.abortServerSideFallback = snapshot.abortServerSideFallback; + this._currentServiceTier = snapshot.currentServiceTier; + this._sessionFastMode = snapshot.sessionFastMode; + this._baseSystemPrompt = snapshot.baseSystemPrompt; + this._baseSystemPromptOptions = snapshot.baseSystemPromptOptions; + this._systemPromptOverride = snapshot.systemPromptOverride; + this._requestedActiveToolNames = snapshot.requestedActiveToolNames; + this._withheldEvalOnlyToolNames.clear(); + for (const toolName of snapshot.withheldEvalOnlyToolNames) { + this._withheldEvalOnlyToolNames.add(toolName); + } + this._publishedEvalOnlyHintNames.clear(); + for (const toolName of snapshot.publishedEvalOnlyHintNames) { + this._publishedEvalOnlyHintNames.add(toolName); + } + this.agent.removedToolHints = snapshot.removedToolHints; + this._toolRegistry = snapshot.toolRegistry; + this._toolDefinitions = snapshot.toolDefinitions; + this._toolPromptSnippets = snapshot.toolPromptSnippets; + this._toolPromptGuidelines = snapshot.toolPromptGuidelines; + this._shownHighReasoningWarningKeys.clear(); + for (const key of snapshot.shownHighReasoningWarningKeys) { + this._shownHighReasoningWarningKeys.add(key); + } + }; + const flushDeferredEvents = () => { + if (!ownsDeferredEvents) return; + const deferredEvents = this._deferredSessionEvents ?? []; + this._deferredSessionEvents = undefined; + for (const event of deferredEvents) this._emit(event); + }; if ( opts.invalidateCompaction && (this._modelSelectionChangesContext(previousModel, model) || @@ -4730,13 +4809,32 @@ export class AgentSession { if (!opts.emitModelSelect) return undefined; const previousSystemPrompt = this.agent.state.systemPrompt; try { - const systemPromptChange = await this._emitModelSelect(model, previousModel, opts.modelSelectSource); + const systemPromptChange = await this._emitModelSelect( + model, + previousModel, + opts.modelSelectSource, + !opts.transactionalModelSelect, + ); this.assertModelUsable(model, liveContextTokens); + flushDeferredEvents(); + if (systemPromptChange && opts.transactionalModelSelect) { + await this._publishSystemPromptChange(systemPromptChange); + } return systemPromptChange; } catch (error) { - if (previousModel) this.agent.state.model = previousModel; - else delete (this.agent.state as { model?: Model }).model; - this.agent.state.systemPrompt = previousSystemPrompt; + if (snapshot) { + restoreSnapshot(); + try { + if (previousModel) await this._emitModelSelect(previousModel, model, opts.modelSelectSource, false); + } finally { + restoreSnapshot(); + } + } else { + if (previousModel) this.agent.state.model = previousModel; + else delete (this.agent.state as { model?: Model }).model; + this.agent.state.systemPrompt = previousSystemPrompt; + } + if (ownsDeferredEvents) this._deferredSessionEvents = undefined; throw error; } } diff --git a/packages/coding-agent/src/core/changes.md b/packages/coding-agent/src/core/changes.md index 3069816e7b..f1bc2edb28 100644 --- a/packages/coding-agent/src/core/changes.md +++ b/packages/coding-agent/src/core/changes.md @@ -14,7 +14,8 @@ providers, and defers both model and model-specific thinking persistence until extension admission succeeds. - If a model-select hook makes the highest-capacity candidate unusable, startup - continues through the remaining budget-ranked candidates. + rolls back its runtime state before continuing through the remaining + budget-ranked candidates, and provider authentication is cached per recovery. - Explicit startup models remain fail-closed, and sessions with no capable authenticated recovery model keep the typed `ModelUsabilityBudgetError`. diff --git a/packages/coding-agent/src/core/sdk.ts b/packages/coding-agent/src/core/sdk.ts index ea70e5e1cc..0a79e899e3 100644 --- a/packages/coding-agent/src/core/sdk.ts +++ b/packages/coding-agent/src/core/sdk.ts @@ -581,13 +581,16 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {} } const restoredModel = session.model; if (!restoredModel) throw error; - const unavailableProviders = new Set(); + const providerAuthentication = new Map(); let recovery: ModelUsabilityBudgetProjection | undefined; let lastRecoveryError: ModelUsabilityBudgetError | undefined; for (const candidate of findStartupRecoveryModels(session, modelRuntime, settingsManager, liveContextTokens)) { - if (unavailableProviders.has(candidate.model.provider)) continue; - if (!(await modelRuntime.checkAuth(candidate.model.provider))) { - unavailableProviders.add(candidate.model.provider); + let authenticated = providerAuthentication.get(candidate.model.provider); + if (authenticated === undefined) { + authenticated = Boolean(await modelRuntime.checkAuth(candidate.model.provider)); + providerAuthentication.set(candidate.model.provider, authenticated); + } + if (!authenticated) { continue; } try { diff --git a/packages/coding-agent/test/suite/model-usability-budget.test.ts b/packages/coding-agent/test/suite/model-usability-budget.test.ts index 0506cfc258..33e3b4e89b 100644 --- a/packages/coding-agent/test/suite/model-usability-budget.test.ts +++ b/packages/coding-agent/test/suite/model-usability-budget.test.ts @@ -1,4 +1,5 @@ import { join } from "node:path"; +import { Type } from "typebox"; import { afterEach, describe, expect, it, vi } from "vitest"; import { ModelUsabilityBudgetError, @@ -347,6 +348,7 @@ describe("model usability budget", () => { content: [{ type: "text", text: "restored transcript ".repeat(80_000) }], timestamp: Date.now(), }); + const authCheck = vi.spyOn(harness.session.modelRuntime, "checkAuth"); // when const resumed = await createAgentSession({ @@ -366,6 +368,89 @@ describe("model usability budget", () => { { provider: "faux", modelId: "saved-small" }, { provider: "faux", modelId: "candidate-medium" }, ]); + expect(authCheck.mock.calls.filter(([provider]) => provider === "faux")).toHaveLength(1); + resumed.session.dispose(); + }); + + it("rolls back rejected candidate tools and extension state before trying the next model", async () => { + // given + initTheme("dark"); + const harness = await createHarness({ + models: [ + { id: "saved-small", contextWindow: 100_000, maxTokens: 4_000 }, + { id: "candidate-medium", contextWindow: 600_000, maxTokens: 32_000 }, + { id: "candidate-largest", contextWindow: 1_000_000, maxTokens: 32_000 }, + ], + }); + harnesses.push(harness); + const extensionsResult = await createTestExtensionsResult( + [ + (pi) => { + let rejectedCandidateState = false; + pi.registerTool({ + name: "stable-tool", + label: "Stable tool", + description: "Tool for the restored and accepted models", + parameters: Type.Object({}), + execute: async () => ({ content: [{ type: "text", text: "stable" }], details: {} }), + }); + pi.registerTool({ + name: "candidate-tool", + label: "Candidate tool", + description: "Tool exposed only by the rejected candidate", + parameters: Type.Object({}), + execute: async () => ({ content: [{ type: "text", text: "candidate" }], details: {} }), + }); + pi.on("session_start", () => { + pi.setActiveTools(["stable-tool"]); + }); + pi.on("model_select", (event) => { + if (event.model.id === "candidate-largest") { + rejectedCandidateState = true; + pi.setActiveTools(["candidate-tool"]); + return { systemPrompt: "oversized model prompt ".repeat(300_000) }; + } + if (event.model.id === "saved-small") { + rejectedCandidateState = false; + pi.setActiveTools(["stable-tool"]); + return undefined; + } + if (event.model.id === "candidate-medium" && rejectedCandidateState) { + return { systemPrompt: "leaked extension state ".repeat(300_000) }; + } + return undefined; + }); + }, + ], + harness.tempDir, + ); + const sessionManager = harness.sessionManager; + sessionManager.appendModelChange("faux", "saved-small"); + sessionManager.appendMessage({ + role: "user", + content: [{ type: "text", text: "restored transcript ".repeat(80_000) }], + timestamp: Date.now(), + }); + + // when + const resumed = await createAgentSession({ + cwd: harness.tempDir, + agentDir: join(harness.tempDir, "sdk-agent"), + authStorage: harness.authStorage, + modelRuntime: harness.session.modelRuntime, + resourceLoader: createTestResourceLoader({ extensionsResult }), + sessionManager, + settingsManager: harness.settingsManager, + tools: ["stable-tool"], + }); + + // then + expect(resumed.session.model).toMatchObject({ id: "candidate-medium" }); + expect(resumed.session.getActiveToolNames()).toEqual(["stable-tool"]); + expect(sessionManager.getEntries().filter((entry) => entry.type === "model_change")).toMatchObject([ + { provider: "faux", modelId: "saved-small" }, + { provider: "faux", modelId: "candidate-medium" }, + ]); resumed.session.dispose(); }); From 6dc052a97f343b86a0dd64830300c197c76f6a8c Mon Sep 17 00:00:00 2001 From: jaeyunha Date: Wed, 2 Sep 2026 11:37:39 +0900 Subject: [PATCH 6/6] fix(coding-agent): defer recovery side effects --- changes.md | 5 ++-- packages/coding-agent/CHANGELOG.md | 5 ++-- packages/coding-agent/changes.md | 3 ++- packages/coding-agent/src/changes.md | 3 ++- .../coding-agent/src/core/agent-session.ts | 16 +++++++++---- packages/coding-agent/src/core/changes.md | 3 ++- .../session-registry-wiring.ts | 1 + .../coding-agent/src/core/extensions/types.ts | 5 ++++ ...aude-sdk-oauth-binding-persistence.test.ts | 24 +++++++++++++++++++ .../test/suite/model-usability-budget.test.ts | 8 +++++++ 10 files changed, 62 insertions(+), 11 deletions(-) diff --git a/changes.md b/changes.md index 368b2ae618..06d82693d4 100644 --- a/changes.md +++ b/changes.md @@ -7,8 +7,9 @@ explicit model selection. A restored session that outgrew its saved model picks the authenticated candidate with the greatest verified remaining budget, skips unavailable providers, records model-specific selection history only after extension admission succeeds, and opens normally. Explicit selections -remain fail-closed; post-select candidate failures roll back their runtime -state before falling through; each provider is authenticated once per recovery; +remain fail-closed; candidate probes are marked provisional so Claude SDK +continuity is not invalidated before admission, post-select failures roll back +runtime state before falling through, and each provider is authenticated once; no-capable-model failures render as clean typed CLI errors. This is core startup behavior because the failure occurs before extensions or diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index f08775bfb7..c69c93f616 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -11,8 +11,9 @@ remaining context budget, skipping unavailable providers without leaving partial model or thinking history persisted. Explicit `--model` selections stay strict, rejected candidates roll back their model-specific runtime state - before later candidates are tried, and a session with no capable model exits - with actionable budget guidance instead of an uncaught stack trace. + before later candidates are tried, Claude SDK continuity ignores provisional + candidate probes, and a session with no capable model exits with actionable + budget guidance instead of an uncaught stack trace. ### New Features diff --git a/packages/coding-agent/changes.md b/packages/coding-agent/changes.md index 0b01bedccc..f7c418c5cf 100644 --- a/packages/coding-agent/changes.md +++ b/packages/coding-agent/changes.md @@ -9,7 +9,8 @@ history only after extension admission succeeds. - A candidate rejected by a model-select budget hook no longer prevents later capable candidates from being tried; rejected candidate runtime state is - rolled back and each provider is authenticated once per recovery. + rolled back, Claude SDK continuity ignores provisional candidate probes, and + each provider is authenticated once per recovery. - When no recovery model exists, CLI startup prints the typed budget guidance and exits cleanly instead of exposing an uncaught exception stack. diff --git a/packages/coding-agent/src/changes.md b/packages/coding-agent/src/changes.md index 93d8951b21..b0aacd6a75 100644 --- a/packages/coding-agent/src/changes.md +++ b/packages/coding-agent/src/changes.md @@ -11,7 +11,8 @@ model-specific thinking or model history persisted. - A post-select budget rejection rolls back candidate-specific runtime state before falling through to the next capable model; provider authentication is - cached for that recovery attempt. + cached for that recovery attempt and Claude SDK continuity ignores + provisional candidate probes. - CLI startup renders a no-capable-model budget failure as an actionable error without leaking an uncaught Node stack. diff --git a/packages/coding-agent/src/core/agent-session.ts b/packages/coding-agent/src/core/agent-session.ts index 076fb46967..0b2c0f1192 100644 --- a/packages/coding-agent/src/core/agent-session.ts +++ b/packages/coding-agent/src/core/agent-session.ts @@ -4500,6 +4500,7 @@ export class AgentSession { previousModel: Model | undefined, source: ModelSelectSource, publish = true, + provisional = false, ): Promise { this.syncPromptCacheSafeWaitEnv(); if (!this._modelSelectionChangesContext(previousModel, nextModel)) return undefined; @@ -4508,6 +4509,7 @@ export class AgentSession { model: nextModel, previousModel, source, + ...(provisional ? { provisional: true } : {}), systemPrompt: this.agent.state.systemPrompt, systemPromptOptions: this._baseSystemPromptOptions, }); @@ -4814,18 +4816,24 @@ export class AgentSession { previousModel, opts.modelSelectSource, !opts.transactionalModelSelect, + opts.transactionalModelSelect, ); this.assertModelUsable(model, liveContextTokens); + const committedSystemPromptChange = opts.transactionalModelSelect + ? await this._emitModelSelect(model, previousModel, opts.modelSelectSource, false) + : systemPromptChange; + this.assertModelUsable(model, liveContextTokens); flushDeferredEvents(); - if (systemPromptChange && opts.transactionalModelSelect) { - await this._publishSystemPromptChange(systemPromptChange); + if (committedSystemPromptChange && opts.transactionalModelSelect) { + await this._publishSystemPromptChange(committedSystemPromptChange); } - return systemPromptChange; + return committedSystemPromptChange; } catch (error) { if (snapshot) { restoreSnapshot(); try { - if (previousModel) await this._emitModelSelect(previousModel, model, opts.modelSelectSource, false); + if (previousModel) + await this._emitModelSelect(previousModel, model, opts.modelSelectSource, false, true); } finally { restoreSnapshot(); } diff --git a/packages/coding-agent/src/core/changes.md b/packages/coding-agent/src/core/changes.md index f1bc2edb28..1ae16cf9c8 100644 --- a/packages/coding-agent/src/core/changes.md +++ b/packages/coding-agent/src/core/changes.md @@ -15,7 +15,8 @@ until extension admission succeeds. - If a model-select hook makes the highest-capacity candidate unusable, startup rolls back its runtime state before continuing through the remaining - budget-ranked candidates, and provider authentication is cached per recovery. + budget-ranked candidates; Claude SDK continuity ignores provisional candidate + probes and provider authentication is cached per recovery. - Explicit startup models remain fail-closed, and sessions with no capable authenticated recovery model keep the typed `ModelUsabilityBudgetError`. diff --git a/packages/coding-agent/src/core/extensions/builtin/claude-sdk-oauth/session-registry-wiring.ts b/packages/coding-agent/src/core/extensions/builtin/claude-sdk-oauth/session-registry-wiring.ts index 33eb062079..90cf9731e8 100644 --- a/packages/coding-agent/src/core/extensions/builtin/claude-sdk-oauth/session-registry-wiring.ts +++ b/packages/coding-agent/src/core/extensions/builtin/claude-sdk-oauth/session-registry-wiring.ts @@ -98,6 +98,7 @@ export function registerSessionRegistry( await invalidateBinding(pi, ctx, "tree_changed"); }); pi.on("model_select", async (event, ctx) => { + if (event.provisional) return; const sessionId = ctx.sessionManager.getSessionId(); if (event.model?.provider !== CLAUDE_SDK_OAUTH_PROVIDER_ID) { closeSession(sessionId, "model_selected"); diff --git a/packages/coding-agent/src/core/extensions/types.ts b/packages/coding-agent/src/core/extensions/types.ts index be2a23f4f6..85a150b608 100644 --- a/packages/coding-agent/src/core/extensions/types.ts +++ b/packages/coding-agent/src/core/extensions/types.ts @@ -1125,6 +1125,11 @@ export interface ModelSelectEvent { model: Model; previousModel: Model | undefined; source: ModelSelectSource; + /** + * A startup-recovery candidate probe. Handlers may adjust in-memory prompt or + * tool state, but must defer external side effects until the committed event. + */ + provisional?: boolean; /** The active system prompt before model_select handlers run. */ systemPrompt: string; /** Structured options used to build the base system prompt. */ diff --git a/packages/coding-agent/test/claude-sdk-oauth-binding-persistence.test.ts b/packages/coding-agent/test/claude-sdk-oauth-binding-persistence.test.ts index b21a13f733..901e880a89 100644 --- a/packages/coding-agent/test/claude-sdk-oauth-binding-persistence.test.ts +++ b/packages/coding-agent/test/claude-sdk-oauth-binding-persistence.test.ts @@ -191,6 +191,30 @@ describe("Claude SDK OAuth persisted binding lifecycle", () => { expect(getBinding(SESSION_ID)).toBeUndefined(); }); + + it("does not invalidate continuity during a provisional recovery model probe", async () => { + const { sessionFile } = sessionFixture(); + const binding = bindingFromStored(stored(sessionFile)); + rememberBinding(binding); + const extension = fakeExtension(); + registerSessionRegistry(extension.api); + + await emit( + extension.handlers, + "model_select", + { + type: "model_select", + model: { provider: "openai", id: "candidate" }, + previousModel: { provider: "claude-sdk-oauth", id: "claude-test" }, + source: "restore", + provisional: true, + }, + context(sessionFile), + ); + + expect(getBinding(SESSION_ID)).toEqual(binding); + expect(extension.persisted).toEqual([]); + }); }); function bindingFromStored(record: StoredBinding): ContinuityBinding { diff --git a/packages/coding-agent/test/suite/model-usability-budget.test.ts b/packages/coding-agent/test/suite/model-usability-budget.test.ts index 33e3b4e89b..bdb7882ac5 100644 --- a/packages/coding-agent/test/suite/model-usability-budget.test.ts +++ b/packages/coding-agent/test/suite/model-usability-budget.test.ts @@ -383,6 +383,7 @@ describe("model usability budget", () => { ], }); harnesses.push(harness); + const selections: Array<{ id: string; provisional: boolean | undefined }> = []; const extensionsResult = await createTestExtensionsResult( [ (pi) => { @@ -405,6 +406,7 @@ describe("model usability budget", () => { pi.setActiveTools(["stable-tool"]); }); pi.on("model_select", (event) => { + selections.push({ id: event.model.id, provisional: event.provisional }); if (event.model.id === "candidate-largest") { rejectedCandidateState = true; pi.setActiveTools(["candidate-tool"]); @@ -451,6 +453,12 @@ describe("model usability budget", () => { { provider: "faux", modelId: "saved-small" }, { provider: "faux", modelId: "candidate-medium" }, ]); + expect(selections).toEqual([ + { id: "candidate-largest", provisional: true }, + { id: "saved-small", provisional: true }, + { id: "candidate-medium", provisional: true }, + { id: "candidate-medium", provisional: undefined }, + ]); resumed.session.dispose(); });