diff --git a/changes.md b/changes.md index d73e5f99ea..06d82693d4 100644 --- a/changes.md +++ b/changes.md @@ -1,5 +1,20 @@ # 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, +skips unavailable providers, records model-specific selection history only +after extension admission succeeds, and opens normally. Explicit selections +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 +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 667dd58d51..c69c93f616 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -6,6 +6,15 @@ ### 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, 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, 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 ### Breaking Changes diff --git a/packages/coding-agent/changes.md b/packages/coding-agent/changes.md index 43416f6ff7..f7c418c5cf 100644 --- a/packages/coding-agent/changes.md +++ b/packages/coding-agent/changes.md @@ -1,5 +1,19 @@ # 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. +- 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; rejected candidate runtime state is + 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. + ## 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..b0aacd6a75 100644 --- a/packages/coding-agent/src/changes.md +++ b/packages/coding-agent/src/changes.md @@ -1,5 +1,36 @@ # 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. +- 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 rolls back candidate-specific runtime state + before falling through to the next capable model; provider authentication is + 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. + +### 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..0b2c0f1192 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,8 @@ export class AgentSession { nextModel: Model, previousModel: Model | undefined, source: ModelSelectSource, + publish = true, + provisional = false, ): Promise { this.syncPromptCacheSafeWaitEnv(); if (!this._modelSelectionChangesContext(previousModel, nextModel)) return undefined; @@ -4501,6 +4509,7 @@ export class AgentSession { model: nextModel, previousModel, source, + ...(provisional ? { provisional: true } : {}), systemPrompt: this.agent.state.systemPrompt, systemPromptOptions: this._baseSystemPromptOptions, }); @@ -4526,9 +4535,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; } /** @@ -4583,6 +4596,39 @@ 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); + const previousModel = this.model; + const systemPromptChange = await this._switchActiveModel(model, { + persistDefault: false, + appendSessionEntry: false, + emitModelSelect: true, + modelSelectSource: "restore", + invalidateCompaction: true, + persistThinkingLevel: false, + liveContextTokens, + transactionalModelSelect: true, + }); + this.sessionManager.appendModelChange( + model.provider, + model.id, + undefined, + previousModel?.provider, + previousModel?.id, + ); + return systemPromptChange; + } + private async _setModel( model: Model, updateGlobalDefaults: boolean, @@ -4640,9 +4686,78 @@ export class AgentSession { modelSelectSource: ModelSelectSource; invalidateCompaction: boolean; 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) || @@ -4652,7 +4767,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(); @@ -4676,6 +4791,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); } @@ -4694,13 +4811,38 @@ 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, + opts.transactionalModelSelect, + ); + this.assertModelUsable(model, liveContextTokens); + const committedSystemPromptChange = opts.transactionalModelSelect + ? await this._emitModelSelect(model, previousModel, opts.modelSelectSource, false) + : systemPromptChange; this.assertModelUsable(model, liveContextTokens); - return systemPromptChange; + flushDeferredEvents(); + if (committedSystemPromptChange && opts.transactionalModelSelect) { + await this._publishSystemPromptChange(committedSystemPromptChange); + } + return committedSystemPromptChange; } 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, true); + } 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 e9794623f6..1ae16cf9c8 100644 --- a/packages/coding-agent/src/core/changes.md +++ b/packages/coding-agent/src/core/changes.md @@ -1,5 +1,40 @@ # 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. +- 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 + rolls back its runtime state before continuing through the remaining + 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`. + +### 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/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/src/core/sdk.ts b/packages/coding-agent/src/core/sdk.ts index b6a8489125..0a79e899e3 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,55 @@ export interface CreateAgentSessionResult { modelFallbackMessage?: string; } +interface StartupRecoveryModel { + model: Model; + projection: ModelUsabilityBudgetProjection; + 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, + settingsManager: SettingsManager, + liveContextTokens: number, +): StartupRecoveryModel[] { + const currentModel = session.model; + if (!currentModel) return []; + + 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; + }); +} + // Re-exports export * from "./agent-session-runtime.ts"; @@ -519,7 +573,53 @@ 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 restoredModel = session.model; + if (!restoredModel) throw error; + const providerAuthentication = new Map(); + let recovery: ModelUsabilityBudgetProjection | undefined; + let lastRecoveryError: ModelUsabilityBudgetError | undefined; + for (const candidate of findStartupRecoveryModels(session, modelRuntime, settingsManager, liveContextTokens)) { + 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 { + 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; + } + } + if (!recovery) throw new SessionResumeModelUnavailableError(lastRecoveryError ?? error); + + const recoveryMessage = + `Restored context exceeds ${restoredModel.provider}/${restoredModel.id}'s usable budget. ` + + `Using ${recovery.model} for this session with ` + + `${recovery.contextWindow - recovery.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/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 72153545af..bdb7882ac5 100644 --- a/packages/coding-agent/test/suite/model-usability-budget.test.ts +++ b/packages/coding-agent/test/suite/model-usability-budget.test.ts @@ -1,11 +1,14 @@ import { join } from "node:path"; -import { afterEach, describe, expect, it } from "vitest"; +import { Type } from "typebox"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { ModelUsabilityBudgetError, projectModelUsabilityBudget, } 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 +216,252 @@ 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("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("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(), + }); + const authCheck = vi.spyOn(harness.session.modelRuntime, "checkAuth"); + + // 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" }, + ]); + 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 selections: Array<{ id: string; provisional: boolean | undefined }> = []; + 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) => { + selections.push({ id: event.model.id, provisional: event.provisional }); + 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" }, + ]); + 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(); + }); + it("rejects a resumed session whose restored transcript exceeds the startup budget", async () => { // given const harness = await createHarness({ @@ -220,33 +469,126 @@ 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, ); 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: `${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, + ); + expect(sessionManager.getEntries().filter((entry) => entry.type === "thinking_level_change")).toHaveLength(1); + }); + 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"); + }); +});