diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 667dd58d51..561a89f026 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -4,8 +4,12 @@ ### Added +- `/fallback restore` returns the current session to the model and thinking level active before retry fallback, without changing global model defaults. + ### Fixed +- An explicitly requested `/compact` now overrides Claude SDK OAuth's automatic-compaction delegation, providing a manual escape hatch when SDK-native compaction does not fire. + ### New Features ### Breaking Changes diff --git a/packages/coding-agent/src/core/agent-session.ts b/packages/coding-agent/src/core/agent-session.ts index df0249c23c..4f19760139 100644 --- a/packages/coding-agent/src/core/agent-session.ts +++ b/packages/coding-agent/src/core/agent-session.ts @@ -173,7 +173,7 @@ import { expandPromptTemplateWithMetadata, type PromptTemplate } from "./prompt- import { createProviderTimeoutRetryPlan, runBoundedRetryContinuation } from "./provider-timeout-retry.ts"; import type { ResourceExtensionPaths, ResourceLoader } from "./resource-loader.ts"; import { isBillingErrorMessage } from "./retry-fallback/billing.ts"; -import { formatSelector } from "./retry-fallback/chains.ts"; +import { formatSelector, parseFallbackSelector } from "./retry-fallback/chains.ts"; import { RetryFallbackController } from "./retry-fallback/controller.ts"; import { SelectorCooldowns } from "./retry-fallback/cooldown.ts"; import { @@ -6880,6 +6880,17 @@ export class AgentSession { pinned: active.pinned, }; }, + restoreFallbackPrimary: async () => { + const active = this._retryFallback.activeState; + if (!active) return false; + const selector = parseFallbackSelector(active.originalSelector, this._modelRegistry); + const model = selector ? this._modelRegistry.find(selector.provider, selector.id) : undefined; + if (!model) return false; + const originalThinkingLevel = active.originalThinkingLevel; + await this.setSessionModel(model); + if (originalThinkingLevel !== undefined) this.setSessionThinkingLevel(originalThinkingLevel); + return true; + }, }, compact: (options) => { const admission = this._claimPendingCompactionAdmission(); diff --git a/packages/coding-agent/src/core/changes.md b/packages/coding-agent/src/core/changes.md index e9794623f6..26a35d7931 100644 --- a/packages/coding-agent/src/core/changes.md +++ b/packages/coding-agent/src/core/changes.md @@ -1,5 +1,23 @@ # changes +## 2026-09-02 - Restore the pre-fallback session model on request + +### What changed + +- `packages/coding-agent/src/core/agent-session.ts` binds a session-only `restoreFallbackPrimary` operation that resolves the retry controller's original selector, restores that model and its original thinking level, and clears active fallback state without changing global defaults. + +### Why + +- A successful fallback intentionally leaves the session on the fallback model. Users need an explicit, bounded way to return to the pre-fallback model after the incident clears without rewriting their configured defaults. + +### Why an extension could not handle it + +- The retry controller's original selector and thinking level are private session state. The builtin command can request restoration only through a host-bound session-settings operation. + +### Expected merge conflict zones + +- LOW: `packages/coding-agent/src/core/agent-session.ts` in the extension session-settings binding. + ## 2026-08-31 - Session activity contract for host occupancy decisions ### What changed diff --git a/packages/coding-agent/src/core/extensions/builtin/compaction/changes.md b/packages/coding-agent/src/core/extensions/builtin/compaction/changes.md index 7144b2a61f..8c6b5f9e80 100644 --- a/packages/coding-agent/src/core/extensions/builtin/compaction/changes.md +++ b/packages/coding-agent/src/core/extensions/builtin/compaction/changes.md @@ -1,5 +1,49 @@ # changes.md — builtin compaction policy +## An explicitly requested compaction overrides the SDK-native lane opt-out (2026-09-02) + +### What changed + +- `lane-policy.ts` gained `isLaneOverrideReason(reason)`, naming the `CompactionReason` values the + `claude-sdk-oauth` delegation does NOT cover. Today that is `manual` alone. +- The `session_before_compact` guard in `index.ts` now reads + `if (!isLaneOverrideReason(event.reason) && lanePolicy.disablesSenpiCompaction(ctx))`. Every automatic reason + (`threshold`, `overflow`, `pre_prompt`, `branch`, `extension`) is still cancelled with + `rejectionCause: "external-owner"` exactly as before; only an explicitly requested `/compact` proceeds. +- No other lane call site changed: the `before_agent_start`, `agent_end`, `turn_end`, `model_select`, `context` + and `message_end` guards still stand down unconditionally, because none of them carries a user request. + +### Why + +The opt-out added on 2026-08-01 rests on one premise: the Claude Agent SDK runs its own native auto-compaction over the +session it owns, so senpi compacting on top would rewrite a history senpi no longer owns. That premise is about +AUTOMATIC compaction, and senpi cannot observe that the SDK's compaction did not happen. Because the guard ignored +`event.reason`, an explicit `/compact` was cancelled with the same delegation message — and `interactive-mode.ts` +renders a manual rejection as a red error rather than the muted delegation notice. A session whose SDK-side compaction +never fired therefore had no way back under the limit: no automatic path, and no manual one either. + +`manual` is the escape hatch for exactly that blind spot. The lane keeps full ownership in the steady state; the user +keeps a way out when the delegated owner does not deliver. + +### Post-compaction continuity + +A senpi-side compaction taints the SDK binding, so `session-continuity.ts` resolves the next turn to a fork at the last +assistant boundary instead of a delta. That path already existed (`PENDING_FORK_REASONS.compaction`); this change only +makes it reachable from `/compact`. Measured on the lane with `claude-haiku-4-5`: compacted from 64,779 tokens to 368, +and the following turn re-established the lane with 3.4KB re-sent at a 95.4% cache hit. + +### Scope + +- Senpi compaction remains fully active for every non-`claude-sdk-oauth` provider, and every automatic reason on the + lane is still delegated; both stay pinned in `test/claude-sdk-oauth-compaction-alignment.test.ts`. +- Coverage: `test/claude-sdk-oauth-compaction-alignment.test.ts` — "never delegates an explicitly requested manual + compaction to the SDK", beside the existing "cancels a requested senpi compaction with the lane reason". + +### Expected merge conflict zones + +- LOW: `lane-policy.ts` around the new `isLaneOverrideReason` export and its `CompactionReason` type import. +- LOW: `index.ts` at the single `session_before_compact` lane guard. + ## Emergency-prune counter emitted at its one true site (2026-09-01) ### What changed diff --git a/packages/coding-agent/src/core/extensions/builtin/compaction/index.ts b/packages/coding-agent/src/core/extensions/builtin/compaction/index.ts index f1b73a0f80..3cc17a7944 100644 --- a/packages/coding-agent/src/core/extensions/builtin/compaction/index.ts +++ b/packages/coding-agent/src/core/extensions/builtin/compaction/index.ts @@ -22,6 +22,7 @@ import { CLAUDE_SDK_OAUTH_COMPACT_ENTRY_TYPE, collectCompactBoundaryEntries, createCompactionLanePolicy, + isLaneOverrideReason, SDK_NATIVE_LANE_REJECTION_REASON, } from "./lane-policy.ts"; import { type CompactionLogger, createCompactionLogger } from "./log.ts"; @@ -556,7 +557,11 @@ export default function compactionExtension( let warmJobConsumed = false; invalidateSpeculativeCompaction(ctx); try { - if (lanePolicy.disablesSenpiCompaction(ctx)) { + // The lane owns senpi's AUTOMATIC compaction only. An explicitly requested + // compaction overrides the delegation: senpi cannot observe that the SDK + // failed to compact, so `/compact` is the only way back under the limit + // once the delegated owner has not delivered. + if (!isLaneOverrideReason(event.reason) && lanePolicy.disablesSenpiCompaction(ctx)) { return { cancel: true, rejectionCause: "external-owner", diff --git a/packages/coding-agent/src/core/extensions/builtin/compaction/lane-policy.ts b/packages/coding-agent/src/core/extensions/builtin/compaction/lane-policy.ts index d601c2a0b1..0b37b5f5f6 100644 --- a/packages/coding-agent/src/core/extensions/builtin/compaction/lane-policy.ts +++ b/packages/coding-agent/src/core/extensions/builtin/compaction/lane-policy.ts @@ -16,6 +16,7 @@ */ import type { AgentMessage } from "@earendil-works/pi-agent-core"; import type { AssistantMessageDiagnostic } from "@earendil-works/pi-ai"; +import type { CompactionReason } from "../../types.ts"; import { CLAUDE_SDK_OAUTH_PROVIDER_ID } from "../claude-sdk-oauth/account-management.ts"; import type { ClaudeSdkOauthProviderSettings } from "../claude-sdk-oauth/settings.ts"; import { loadClaudeSdkOauthProviderSettingsFromDisk } from "../claude-sdk-oauth/settings.ts"; @@ -32,6 +33,24 @@ export const CLAUDE_SDK_OAUTH_COMPACT_BOUNDARY_DIAGNOSTIC = "claude_sdk_oauth_co export const SDK_NATIVE_LANE_REJECTION_REASON = "the Claude Agent SDK owns compaction for this session"; const COMPACT_BOUNDARY_SCHEMA = "senpi.claude-sdk-oauth.compact-boundary.v1"; +/** + * Compaction reasons the SDK-native delegation does NOT cover. + * + * The stand-down exists because the SDK owns the transcript and runs its own + * native auto-compaction over it, so senpi's AUTOMATIC compaction would rewrite + * a history senpi no longer owns. That argument only holds while the SDK's + * compaction actually happens; senpi cannot observe that it did not. `manual` is + * the escape hatch for exactly that blind spot: an explicitly requested + * `/compact` is the user stating the delegated owner did not deliver, and + * delegating it away leaves the session with no way back under the limit. + * + * Every automatic reason stays delegated, so the lane keeps its ownership in + * the steady state. + */ +export function isLaneOverrideReason(reason: CompactionReason): boolean { + return reason === "manual"; +} + export interface LaneModel { provider?: string; } diff --git a/packages/coding-agent/src/core/extensions/builtin/model-fallback/index.ts b/packages/coding-agent/src/core/extensions/builtin/model-fallback/index.ts index 1aa917b9e9..b7bc2a2d08 100644 --- a/packages/coding-agent/src/core/extensions/builtin/model-fallback/index.ts +++ b/packages/coding-agent/src/core/extensions/builtin/model-fallback/index.ts @@ -13,7 +13,7 @@ export default function modelFallbackExtension(pi: ExtensionAPI): void { }); pi.registerCommand("fallback", { description: "View and manage retry model fallback chains.", - argumentHint: "[target [fallback1 fallback2 ...]]", + argumentHint: "[restore | target fallback1 [fallback2 ...]]", handler: async (rawArgs, ctx) => handleFallbackCommand(rawArgs, ctx), }); } @@ -45,8 +45,16 @@ async function handleFallbackCommand(rawArgs: string, ctx: ExtensionCommandConte }); return; } + if (args.length === 1 && args[0] === "restore") { + const restored = await ctx.sessionSettings.restoreFallbackPrimary(); + ctx.ui.notify( + restored ? "Restored the pre-fallback model for this session." : "This session has no active fallback model.", + restored ? "info" : "warning", + ); + return; + } if (args.length < 2) { - ctx.ui.notify("Usage: /fallback [fallback2 ...]", "error"); + ctx.ui.notify("Usage: /fallback restore | /fallback [fallback2 ...]", "error"); return; } await saveChain(ctx, args[0], args.slice(1)); diff --git a/packages/coding-agent/src/core/extensions/changes.md b/packages/coding-agent/src/core/extensions/changes.md index a7c90e01f7..5d3adf5912 100644 --- a/packages/coding-agent/src/core/extensions/changes.md +++ b/packages/coding-agent/src/core/extensions/changes.md @@ -1,5 +1,25 @@ # Core Extensions Changes +## Session-only fallback restoration seam (2026-09-02) + +### What changed + +- `packages/coding-agent/src/core/extensions/types.ts` adds `restoreFallbackPrimary(): Promise` to the session-settings command surface. +- `packages/coding-agent/src/core/extensions/runner.ts` supplies the inert default until the host binds the real operation. + +### Why + +- The builtin `/fallback restore` command must ask the owning session to restore its private retry state without reaching into `AgentSession` or changing persisted defaults. + +### Why an extension could not handle it + +- The command is an extension, but the original selector, thinking level, and active retry state are host-owned. A narrow bound operation is the extension-safe seam. + +### Expected merge conflict zones + +- LOW: `packages/coding-agent/src/core/extensions/types.ts` on `ExtensionSessionSettings`. +- LOW: `packages/coding-agent/src/core/extensions/runner.ts` in the default session-settings object. + ## Expose the extension event bus for session activity signals (2026-08-31) diff --git a/packages/coding-agent/src/core/extensions/runner.ts b/packages/coding-agent/src/core/extensions/runner.ts index 0888e91a5b..82a08af3ac 100644 --- a/packages/coding-agent/src/core/extensions/runner.ts +++ b/packages/coding-agent/src/core/extensions/runner.ts @@ -329,6 +329,7 @@ function createNoOpSessionSettings(): ExtensionContextActions["sessionSettings"] }, reload: () => settings.reload(), getFallbackStatus: () => undefined, + restoreFallbackPrimary: async () => false, }; } diff --git a/packages/coding-agent/src/core/extensions/types.ts b/packages/coding-agent/src/core/extensions/types.ts index be2a23f4f6..31d6d2e515 100644 --- a/packages/coding-agent/src/core/extensions/types.ts +++ b/packages/coding-agent/src/core/extensions/types.ts @@ -324,6 +324,8 @@ export interface ExtensionSessionSettings { setFallbackRevertPolicy(policy: "cooldown-expiry" | "never"): Promise; reload(): Promise; getFallbackStatus(): RetryFallbackStatus | undefined; + /** Restore this session to the model and thinking level active before fallback. */ + restoreFallbackPrimary(): Promise; } export interface ContextUsage { diff --git a/packages/coding-agent/test/claude-sdk-oauth-compaction-alignment.test.ts b/packages/coding-agent/test/claude-sdk-oauth-compaction-alignment.test.ts index 5cec021c90..a0b402b30d 100644 --- a/packages/coding-agent/test/claude-sdk-oauth-compaction-alignment.test.ts +++ b/packages/coding-agent/test/claude-sdk-oauth-compaction-alignment.test.ts @@ -178,10 +178,10 @@ function beforeAgentStartEvent(): BeforeAgentStartEvent { } as BeforeAgentStartEvent; } -function beforeCompactEvent(): SessionBeforeCompactEvent { +function beforeCompactEvent(reason: SessionBeforeCompactEvent["reason"] = "threshold"): SessionBeforeCompactEvent { return { type: "session_before_compact", - reason: "threshold", + reason, willRetry: false, requestId: "request-1", preparation: { @@ -227,6 +227,17 @@ describe("claude-sdk-oauth lane: senpi compaction stands down", () => { }); }); + it("never delegates an explicitly requested manual compaction to the SDK", async () => { + const harness = createHarness({ provider: "claude-sdk-oauth" }); + + const result = await harness.sessionBeforeCompact(beforeCompactEvent("manual"), harness.ctx); + + // The delegation covers senpi's AUTOMATIC compaction only. `/compact` is the + // user's escape hatch for the case the lane cannot detect: the SDK did not + // compact and the session has no other way back under the limit. + expect(result?.rejectionCause).not.toBe("external-owner"); + }); + it("leaves context messages untouched while the same load reduces them for other providers", () => { const reductionMessages = () => [ { role: "user" as const, content: [{ type: "text" as const, text: "u1" }], timestamp: 1 }, diff --git a/packages/coding-agent/test/helpers/extension-session-settings.ts b/packages/coding-agent/test/helpers/extension-session-settings.ts index 0740ffa346..b57eb47fec 100644 --- a/packages/coding-agent/test/helpers/extension-session-settings.ts +++ b/packages/coding-agent/test/helpers/extension-session-settings.ts @@ -33,5 +33,6 @@ export function createInMemoryExtensionSessionSettings(): ExtensionSessionSettin }, reload: () => settings.reload(), getFallbackStatus: () => undefined, + restoreFallbackPrimary: async () => false, }; } diff --git a/packages/coding-agent/test/suite/model-fallback-command.test.ts b/packages/coding-agent/test/suite/model-fallback-command.test.ts index 7c30468648..c0fcffa2ae 100644 --- a/packages/coding-agent/test/suite/model-fallback-command.test.ts +++ b/packages/coding-agent/test/suite/model-fallback-command.test.ts @@ -155,6 +155,7 @@ async function context( }, reload: () => settings.reload(), getFallbackStatus: () => undefined, + restoreFallbackPrimary: async () => false, }, compact: () => {}, getMessageRevision: () => 0, @@ -185,7 +186,7 @@ describe("model fallback builtin command", () => { it("registers /fallback with its quick-set hint", async () => { const command = (await harness()).get("fallback"); - expect(command?.argumentHint).toBe("[target [fallback1 fallback2 ...]]"); + expect(command?.argumentHint).toBe("[restore | target fallback1 [fallback2 ...]]"); expect(command?.description).toContain("fallback"); }); diff --git a/packages/coding-agent/test/suite/model-fallback-host-wiring.test.ts b/packages/coding-agent/test/suite/model-fallback-host-wiring.test.ts index 9678245d3c..39e3707a5b 100644 --- a/packages/coding-agent/test/suite/model-fallback-host-wiring.test.ts +++ b/packages/coding-agent/test/suite/model-fallback-host-wiring.test.ts @@ -157,4 +157,27 @@ describe("model fallback host wiring", () => { pinned: false, }); }); + + it("restores the pre-fallback model for the current session on command", async () => { + const harness = await createHarness({ + models: [{ id: "faux-1" }, { id: "faux-2" }], + settings: { + retry: { enabled: true, baseDelayMs: 1, maxRetries: 0, fallbackChains: { [primary]: [fallback] } }, + }, + extensionFactories: [{ factory: modelFallbackExtension }], + }); + harnesses.push(harness); + harness.setResponses([ + fauxAssistantMessage("", { stopReason: "error", errorMessage: "overloaded_error" }), + fauxAssistantMessage("fallback response"), + ]); + await harness.session.prompt("enter fallback"); + expect(harness.session.model?.id).toBe("faux-2"); + + const context = harness.getExtensionRunner().createCommandContext(); + await getFallbackCommand(harness).handler("restore", context); + + expect(harness.session.model?.id).toBe("faux-1"); + expect(context.sessionSettings.getFallbackStatus()).toBeUndefined(); + }); });