From 5ce80fdedc9664f0a760ad019d53ddd98f37bd63 Mon Sep 17 00:00:00 2001 From: Roy Date: Sun, 26 Jul 2026 16:35:57 +0000 Subject: [PATCH 01/22] fix: preserve provider-native compaction semantics (cherry picked from commit 426ac1e147c08092c7d0b6c4a7af5f2e09eaf941) --- packages/agent/CHANGELOG.md | 3 + packages/agent/src/compaction/compaction.ts | 11 ++- packages/agent/test/remote-compaction.test.ts | 39 ++++++-- packages/coding-agent/CHANGELOG.md | 3 + .../src/session/session-maintenance.ts | 60 +++++++++--- ...issue-986-compaction-auth-fallback.test.ts | 8 +- ...tive-compaction-provider-isolation.test.ts | 97 +++++++++++++++++++ 7 files changed, 196 insertions(+), 25 deletions(-) create mode 100644 packages/coding-agent/test/native-compaction-provider-isolation.test.ts diff --git a/packages/agent/CHANGELOG.md b/packages/agent/CHANGELOG.md index b4487a72a..6a8e07bed 100644 --- a/packages/agent/CHANGELOG.md +++ b/packages/agent/CHANGELOG.md @@ -23,6 +23,9 @@ ### Fixed - Fixed proxy-stream clients dropping finalized provider-only content blocks, including Anthropic native web-search history, by allowing `done` and `error` events to carry terminal assistant content while retaining delta-reconstructed content from older proxy servers that omit it ([#6703](https://github.com/can1357/oh-my-pi/issues/6703)). +### Fixed + +- Provider-native compaction failures now surface their transport error instead of silently switching to generic summarization; streaming V2 still falls back to native V1 when available. ## [17.1.4] - 2026-07-26 diff --git a/packages/agent/src/compaction/compaction.ts b/packages/agent/src/compaction/compaction.ts index cb1317b7e..0563cd046 100644 --- a/packages/agent/src/compaction/compaction.ts +++ b/packages/agent/src/compaction/compaction.ts @@ -1406,6 +1406,7 @@ export async function compact( ...recentMessages, ]; let usedRemoteCompaction = false; + let nativeCompactionError: unknown; if ( settings.remoteEnabled !== false && settings.remoteStreamingV2Enabled !== false && @@ -1467,7 +1468,8 @@ export async function compact( // swallowing it here would downgrade Esc into "fall back to local // summarization" and keep compaction running on an aborted signal. if (signal?.aborted) throw err; - logger.warn("OpenAI V2 remote compaction failed, falling back to V1/local summarization", { + nativeCompactionError = err; + logger.warn("OpenAI V2 remote compaction failed, falling back to V1 remote compaction", { error: err instanceof Error ? err.message : String(err), model: model.id, provider: model.provider, @@ -1517,7 +1519,8 @@ export async function compact( // swallowing it here would downgrade Esc into "fall back to local // summarization" and keep compaction running on an aborted signal. if (signal?.aborted) throw err; - logger.warn("OpenAI remote compaction failed, falling back to local summarization", { + nativeCompactionError = err; + logger.warn("OpenAI remote compaction failed", { error: err instanceof Error ? err.message : String(err), model: model.id, provider: model.provider, @@ -1526,6 +1529,10 @@ export async function compact( } } + if (!usedRemoteCompaction && nativeCompactionError !== undefined) { + throw nativeCompactionError; + } + // Generate summaries (can be parallel if both needed) and merge into one let summary: string; diff --git a/packages/agent/test/remote-compaction.test.ts b/packages/agent/test/remote-compaction.test.ts index 155080b75..18d4e48f9 100644 --- a/packages/agent/test/remote-compaction.test.ts +++ b/packages/agent/test/remote-compaction.test.ts @@ -1687,6 +1687,31 @@ describe("compact() remote compaction failure handling", () => { expect(JSON.stringify(sameProviderActive?.messagesToSummarize ?? [])).not.toContain("ORIGINAL ALPHA port 4242"); }); + test("V2 native failure falls back to V1 without generic summarization", async () => { + const completeSpy = vi.spyOn(ai, "completeSimple").mockResolvedValue(localSummaryMessage("local summary")); + const preparation = makePreparation(); + preparation.settings = { ...preparation.settings, remoteStreamingV2Enabled: true }; + const model = makeOpenAiModel({ + remoteCompaction: { enabled: true, v2StreamingEnabled: true }, + }); + const requestedUrls: string[] = []; + const fetchMock: FetchImpl = async input => { + const url = String(input); + requestedUrls.push(url); + if (url.endsWith("/responses/compact")) { + return Response.json({ output: [{ type: "compaction", encrypted_content: "enc-v1" }] }); + } + return new Response("V2 unavailable", { status: 502, statusText: "Bad Gateway" }); + }; + + const result = await compact(preparation, model, "test-key", undefined, undefined, { fetch: fetchMock }); + + expect(requestedUrls.some(url => url.endsWith("/responses"))).toBe(true); + expect(requestedUrls.some(url => url.endsWith("/responses/compact"))).toBe(true); + expect(result.shortSummary).toBe("Remote compaction"); + expect(completeSpy).not.toHaveBeenCalled(); + }); + test("user abort during the remote compact request rejects without falling back to local summarization", async () => { // Contract: Esc is a cancellation, not a remote failure. Before the fix // the AbortError was swallowed by the fallback catch and compaction kept @@ -1760,16 +1785,16 @@ describe("compact() remote compaction failure handling", () => { }); }); - test("remote compact server failure without abort still falls back to local summarization", async () => { + test("native compaction server failure rejects without generic summarization", async () => { const completeSpy = vi.spyOn(ai, "completeSimple").mockResolvedValue(localSummaryMessage("local summary")); const fetchMock: FetchImpl = async () => new Response("nope", { status: 500, statusText: "Internal Server Error" }); - const result = await compact(makePreparation(), makeOpenAiModel(), "test-key", undefined, undefined, { - fetch: fetchMock, - }); - - expect(result.summary).toContain("local summary"); - expect(completeSpy).toHaveBeenCalled(); + await expect( + compact(makePreparation(), makeOpenAiModel(), "test-key", undefined, undefined, { + fetch: fetchMock, + }), + ).rejects.toThrow("Remote compaction failed"); + expect(completeSpy).not.toHaveBeenCalled(); }); }); diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 6859f52d6..6350cfa33 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -161,6 +161,9 @@ - Fixed MiMo models using hashline edit mode by default despite needing the same replace-mode fallback as Kimi. ([#3772](https://github.com/can1357/oh-my-pi/issues/3772)) - Fixed `omp` refusing to start on Windows when no `bash.exe` is discoverable — most visibly with scoop-installed Git, whose manifest shims `sh.exe`/`git.exe` but never `bash.exe`, so PATH lookup missed it. Startup threw `No bash shell found` while merely building the bash tool description, even though bash tool commands always execute in the embedded brush-core shell and need no host bash. Shell discovery now also checks `GIT_INSTALL_ROOT`, scoop and per-user Git for Windows install roots, and `sh.exe` on PATH, then falls back to `cmd.exe` for the spawn-only paths (interactive PTY, ACP client terminals) instead of failing; the cmd fallback is never used to wrap user-shell commands — brush runs the POSIX line directly. - Added a selectable voice setting for `/live` realtime sessions ([#6566](https://github.com/can1357/oh-my-pi/issues/6566)). +### Fixed + +- Native compaction now keeps implicit role and largest-context fallbacks on the active provider, preventing a provider-native request from silently becoming another provider's generic summary. Explicit compaction models and soft compaction retain their existing fallback behavior. ## [17.1.4] - 2026-07-26 diff --git a/packages/coding-agent/src/session/session-maintenance.ts b/packages/coding-agent/src/session/session-maintenance.ts index df65f9a32..5222c8508 100644 --- a/packages/coding-agent/src/session/session-maintenance.ts +++ b/packages/coding-agent/src/session/session-maintenance.ts @@ -34,6 +34,7 @@ import { type SummaryOptions, shouldCompact, shouldUseOpenAiRemoteCompaction, + shouldUseCompactionV2Streaming, } from "@oh-my-pi/pi-agent-core/compaction"; import { DEFAULT_PRUNE_CONFIG, @@ -567,6 +568,7 @@ export class SessionMaintenance { let compactionCandidates = this.#getCompactionModelCandidates( availableModels, requireProviderRemote ? shouldUseOpenAiRemoteCompaction : undefined, + effectiveSettings, ); if (requireProviderRemote && compactionCandidates.length === 0) { this.#host.emitNotice( @@ -574,7 +576,7 @@ export class SessionMaintenance { `remote compaction is unavailable for ${this.#model.id} (no remote endpoint configured and no provider-native remote-capable model in the fallback chain) — using a local summary instead`, "compaction", ); - compactionCandidates = this.#getCompactionModelCandidates(availableModels); + compactionCandidates = this.#getCompactionModelCandidates(availableModels, undefined, effectiveSettings); } const pathEntries = this.#host.sessionManager.getBranch(); const preparation = prepareCompaction(pathEntries, effectiveSettings, this.#model); @@ -1390,46 +1392,73 @@ export class SessionMaintenance { return candidate; } - #getCompactionModelCandidates(availableModels: Model[], filter?: (model: Model) => boolean): Model[] { - return this.resolveCompactionModelCandidates(this.#model, availableModels, filter); + #getCompactionModelCandidates( + availableModels: Model[], + filter: ((model: Model) => boolean) | undefined, + settings: Pick, + ): Model[] { + return this.resolveCompactionModelCandidates( + this.#model, + availableModels, + filter, + settings.remoteEnabled !== false, + settings.remoteStreamingV2Enabled !== false, + ); } resolveCompactionModelCandidates( preferredModel: Model | null | undefined, availableModels: Model[], filter?: (model: Model) => boolean, + remoteEnabled = this.#host.settings.getGroup("compaction").remoteEnabled !== false, + remoteStreamingV2Enabled = + this.#host.settings.getGroup("compaction").remoteStreamingV2Enabled !== false, ): Model[] { const candidates: Model[] = []; const seen = new Set(); + const hasEffectiveNativeCompaction = (model: Model): boolean => + remoteEnabled && + (shouldUseOpenAiRemoteCompaction(model) || + (remoteStreamingV2Enabled && shouldUseCompactionV2Streaming(model))); + const nativeProvider = + preferredModel && hasEffectiveNativeCompaction(preferredModel) ? preferredModel.provider : undefined; - const addCandidate = (model: Model | undefined): void => { + const addCandidate = (model: Model | undefined, source: "explicit" | "current" | "implicit"): void => { if (!model) return; const key = `${model.provider}/${model.id}`; if (seen.has(key)) return; seen.add(key); - // `seen` still tracks rejected models so the largest-context fallback - // scan below doesn't reintroduce them; the filter just suppresses - // inclusion in this caller's candidate chain. + // Explicit targets and the active model retain their established + // semantics. Implicit role/context fallbacks must not turn a native + // compaction request into a different provider's generic summary. + if ( + source === "implicit" && + nativeProvider !== undefined && + (model.provider !== nativeProvider || !hasEffectiveNativeCompaction(model)) + ) { + return; + } if (filter && !filter(model)) return; candidates.push(model); }; if (preferredModel) { - addCandidate(resolveCompactionConfiguredTarget(preferredModel, availableModels)); + addCandidate(resolveCompactionConfiguredTarget(preferredModel, availableModels), "explicit"); } - addCandidate(preferredModel ?? undefined); + addCandidate(preferredModel ?? undefined, "current"); for (const role of MODEL_ROLE_IDS) { addCandidate( resolveRoleModelFull(this.#host.settings, role, availableModels, preferredModel ?? undefined).model, + "implicit", ); } const sortedByContext = [...availableModels].sort((a, b) => (b.contextWindow ?? 0) - (a.contextWindow ?? 0)); for (const model of sortedByContext) { - if (!seen.has(`${model.provider}/${model.id}`)) { - addCandidate(model); - break; - } + if (seen.has(`${model.provider}/${model.id}`)) continue; + const candidateCount = candidates.length; + addCandidate(model, "implicit"); + if (candidates.length > candidateCount) break; } return candidates; @@ -1456,7 +1485,8 @@ export class SessionMaintenance { precomputedCandidates?: Model[], ): Promise { const candidates = - precomputedCandidates ?? this.#getCompactionModelCandidates(this.#host.modelRegistry.getAvailable()); + precomputedCandidates ?? + this.#getCompactionModelCandidates(this.#host.modelRegistry.getAvailable(), undefined, preparation.settings); const telemetry = resolveTelemetry(this.#host.agent.telemetry, this.#host.sessionId()); for (const candidate of candidates) { @@ -2472,7 +2502,7 @@ export class SessionMaintenance { details = snapcompactResult.details; preserveData = { ...(compactionPrep.preserveData ?? {}), ...(snapcompactResult.preserveData ?? {}) }; } else { - const candidates = this.#getCompactionModelCandidates(availableModels); + const candidates = this.#getCompactionModelCandidates(availableModels, undefined, compactionSettings); const retrySettings = this.#host.settings.getGroup("retry"); const telemetry = resolveTelemetry(this.#host.agent.telemetry, this.#host.sessionId()); let compactResult: CompactionResult | undefined; diff --git a/packages/coding-agent/test/issue-986-compaction-auth-fallback.test.ts b/packages/coding-agent/test/issue-986-compaction-auth-fallback.test.ts index e49551a73..2022a1c19 100644 --- a/packages/coding-agent/test/issue-986-compaction-auth-fallback.test.ts +++ b/packages/coding-agent/test/issue-986-compaction-auth-fallback.test.ts @@ -37,7 +37,13 @@ describe("issue #986 compaction auth fallback", () => { throw new Error("Expected bundled test models to exist"); } - const settings = Settings.isolated({ "compaction.keepRecentTokens": 1, "compaction.strategy": "context-full" }); + const settings = Settings.isolated({ + "compaction.keepRecentTokens": 1, + "compaction.strategy": "context-full", + // This suite covers the portable summarizer's auth fallback. Native + // compaction keeps its implicit candidate chain provider-isolated. + "compaction.remoteEnabled": false, + }); if (options?.fallbackModelRole) { settings.setModelRole(options.fallbackModelRole, `${fallbackModel.provider}/${fallbackModel.id}`); } diff --git a/packages/coding-agent/test/native-compaction-provider-isolation.test.ts b/packages/coding-agent/test/native-compaction-provider-isolation.test.ts new file mode 100644 index 000000000..e6dd2dda1 --- /dev/null +++ b/packages/coding-agent/test/native-compaction-provider-isolation.test.ts @@ -0,0 +1,97 @@ +import { describe, expect, it } from "bun:test"; +import type { Model } from "@oh-my-pi/pi-ai"; +import { buildModel } from "@oh-my-pi/pi-catalog/build"; +import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; +import { + SessionMaintenance, + type SessionMaintenanceHost, +} from "@oh-my-pi/pi-coding-agent/session/session-maintenance"; + +function model( + id: string, + provider: string, + contextWindow: number, + remoteCompaction?: Model["remoteCompaction"], +): Model { + return buildModel({ + id, + name: id, + api: "openai-responses", + provider, + baseUrl: "https://example.test/v1", + reasoning: false, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow, + maxTokens: 4096, + remoteCompaction, + }); +} + +function maintenance(settings: Settings): SessionMaintenance { + return new SessionMaintenance({ settings } as SessionMaintenanceHost); +} + +describe("native compaction provider isolation", () => { + it("keeps implicit role and context fallbacks on the current native provider", () => { + const settings = Settings.isolated(); + const current = model("native-current", "native-provider", 100_000, { enabled: true }); + const genericRole = model("generic-role", "generic-provider", 90_000); + const nativeFallback = model("native-fallback", "native-provider", 80_000, { enabled: true }); + settings.setModelRole("smol", `${genericRole.provider}/${genericRole.id}`); + + const candidates = maintenance(settings).resolveCompactionModelCandidates( + current, + [current, genericRole, nativeFallback], + undefined, + true, + true, + ); + + expect(candidates.map(candidate => `${candidate.provider}/${candidate.id}`)).toEqual([ + "native-provider/native-current", + "native-provider/native-fallback", + ]); + }); + + it("retains generic implicit fallbacks when provider-native compaction is disabled", () => { + const settings = Settings.isolated(); + const current = model("native-current", "native-provider", 100_000, { enabled: true }); + const genericRole = model("generic-role", "generic-provider", 90_000); + settings.setModelRole("smol", `${genericRole.provider}/${genericRole.id}`); + + const candidates = maintenance(settings).resolveCompactionModelCandidates( + current, + [current, genericRole], + undefined, + false, + true, + ); + + expect(candidates.map(candidate => `${candidate.provider}/${candidate.id}`)).toEqual([ + "native-provider/native-current", + "generic-provider/generic-role", + ]); + }); + + it("recognizes V2-only native capability under the effective streaming setting", () => { + const settings = Settings.isolated(); + const current = model("v2-current", "v2-provider", 100_000, { v2StreamingEnabled: true }); + const genericRole = model("generic-role", "generic-provider", 90_000); + const nativeFallback = model("v2-fallback", "v2-provider", 80_000, { v2StreamingEnabled: true }); + settings.setModelRole("smol", `${genericRole.provider}/${genericRole.id}`); + + const candidates = maintenance(settings).resolveCompactionModelCandidates( + current, + [current, genericRole, nativeFallback], + undefined, + true, + true, + ); + + expect(candidates.map(candidate => `${candidate.provider}/${candidate.id}`)).toEqual([ + "v2-provider/v2-current", + "v2-provider/v2-fallback", + ]); + }); +}); From a8349d4deab3fb053e3856f1e6abad7296b5345b Mon Sep 17 00:00:00 2001 From: usr-bin-roygbiv Date: Sun, 26 Jul 2026 20:59:45 +0000 Subject: [PATCH 02/22] fix: retain native compaction auth fallback (cherry picked from commit 164d77ee90ed109b62ed7f1b6afcf644d26abe12) --- packages/coding-agent/CHANGELOG.md | 2 +- .../src/session/session-maintenance.ts | 60 +++--------- ...issue-986-compaction-auth-fallback.test.ts | 10 +- ...tive-compaction-provider-isolation.test.ts | 97 ------------------- 4 files changed, 18 insertions(+), 151 deletions(-) delete mode 100644 packages/coding-agent/test/native-compaction-provider-isolation.test.ts diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 6350cfa33..eba47b6e5 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -163,7 +163,7 @@ - Added a selectable voice setting for `/live` realtime sessions ([#6566](https://github.com/can1357/oh-my-pi/issues/6566)). ### Fixed -- Native compaction now keeps implicit role and largest-context fallbacks on the active provider, preventing a provider-native request from silently becoming another provider's generic summary. Explicit compaction models and soft compaction retain their existing fallback behavior. +- Native compaction preserves provider-native success and non-authentication failure semantics while retaining authenticated cross-provider fallback when the native provider rejects credentials. ## [17.1.4] - 2026-07-26 diff --git a/packages/coding-agent/src/session/session-maintenance.ts b/packages/coding-agent/src/session/session-maintenance.ts index 5222c8508..df65f9a32 100644 --- a/packages/coding-agent/src/session/session-maintenance.ts +++ b/packages/coding-agent/src/session/session-maintenance.ts @@ -34,7 +34,6 @@ import { type SummaryOptions, shouldCompact, shouldUseOpenAiRemoteCompaction, - shouldUseCompactionV2Streaming, } from "@oh-my-pi/pi-agent-core/compaction"; import { DEFAULT_PRUNE_CONFIG, @@ -568,7 +567,6 @@ export class SessionMaintenance { let compactionCandidates = this.#getCompactionModelCandidates( availableModels, requireProviderRemote ? shouldUseOpenAiRemoteCompaction : undefined, - effectiveSettings, ); if (requireProviderRemote && compactionCandidates.length === 0) { this.#host.emitNotice( @@ -576,7 +574,7 @@ export class SessionMaintenance { `remote compaction is unavailable for ${this.#model.id} (no remote endpoint configured and no provider-native remote-capable model in the fallback chain) — using a local summary instead`, "compaction", ); - compactionCandidates = this.#getCompactionModelCandidates(availableModels, undefined, effectiveSettings); + compactionCandidates = this.#getCompactionModelCandidates(availableModels); } const pathEntries = this.#host.sessionManager.getBranch(); const preparation = prepareCompaction(pathEntries, effectiveSettings, this.#model); @@ -1392,73 +1390,46 @@ export class SessionMaintenance { return candidate; } - #getCompactionModelCandidates( - availableModels: Model[], - filter: ((model: Model) => boolean) | undefined, - settings: Pick, - ): Model[] { - return this.resolveCompactionModelCandidates( - this.#model, - availableModels, - filter, - settings.remoteEnabled !== false, - settings.remoteStreamingV2Enabled !== false, - ); + #getCompactionModelCandidates(availableModels: Model[], filter?: (model: Model) => boolean): Model[] { + return this.resolveCompactionModelCandidates(this.#model, availableModels, filter); } resolveCompactionModelCandidates( preferredModel: Model | null | undefined, availableModels: Model[], filter?: (model: Model) => boolean, - remoteEnabled = this.#host.settings.getGroup("compaction").remoteEnabled !== false, - remoteStreamingV2Enabled = - this.#host.settings.getGroup("compaction").remoteStreamingV2Enabled !== false, ): Model[] { const candidates: Model[] = []; const seen = new Set(); - const hasEffectiveNativeCompaction = (model: Model): boolean => - remoteEnabled && - (shouldUseOpenAiRemoteCompaction(model) || - (remoteStreamingV2Enabled && shouldUseCompactionV2Streaming(model))); - const nativeProvider = - preferredModel && hasEffectiveNativeCompaction(preferredModel) ? preferredModel.provider : undefined; - const addCandidate = (model: Model | undefined, source: "explicit" | "current" | "implicit"): void => { + const addCandidate = (model: Model | undefined): void => { if (!model) return; const key = `${model.provider}/${model.id}`; if (seen.has(key)) return; seen.add(key); - // Explicit targets and the active model retain their established - // semantics. Implicit role/context fallbacks must not turn a native - // compaction request into a different provider's generic summary. - if ( - source === "implicit" && - nativeProvider !== undefined && - (model.provider !== nativeProvider || !hasEffectiveNativeCompaction(model)) - ) { - return; - } + // `seen` still tracks rejected models so the largest-context fallback + // scan below doesn't reintroduce them; the filter just suppresses + // inclusion in this caller's candidate chain. if (filter && !filter(model)) return; candidates.push(model); }; if (preferredModel) { - addCandidate(resolveCompactionConfiguredTarget(preferredModel, availableModels), "explicit"); + addCandidate(resolveCompactionConfiguredTarget(preferredModel, availableModels)); } - addCandidate(preferredModel ?? undefined, "current"); + addCandidate(preferredModel ?? undefined); for (const role of MODEL_ROLE_IDS) { addCandidate( resolveRoleModelFull(this.#host.settings, role, availableModels, preferredModel ?? undefined).model, - "implicit", ); } const sortedByContext = [...availableModels].sort((a, b) => (b.contextWindow ?? 0) - (a.contextWindow ?? 0)); for (const model of sortedByContext) { - if (seen.has(`${model.provider}/${model.id}`)) continue; - const candidateCount = candidates.length; - addCandidate(model, "implicit"); - if (candidates.length > candidateCount) break; + if (!seen.has(`${model.provider}/${model.id}`)) { + addCandidate(model); + break; + } } return candidates; @@ -1485,8 +1456,7 @@ export class SessionMaintenance { precomputedCandidates?: Model[], ): Promise { const candidates = - precomputedCandidates ?? - this.#getCompactionModelCandidates(this.#host.modelRegistry.getAvailable(), undefined, preparation.settings); + precomputedCandidates ?? this.#getCompactionModelCandidates(this.#host.modelRegistry.getAvailable()); const telemetry = resolveTelemetry(this.#host.agent.telemetry, this.#host.sessionId()); for (const candidate of candidates) { @@ -2502,7 +2472,7 @@ export class SessionMaintenance { details = snapcompactResult.details; preserveData = { ...(compactionPrep.preserveData ?? {}), ...(snapcompactResult.preserveData ?? {}) }; } else { - const candidates = this.#getCompactionModelCandidates(availableModels, undefined, compactionSettings); + const candidates = this.#getCompactionModelCandidates(availableModels); const retrySettings = this.#host.settings.getGroup("retry"); const telemetry = resolveTelemetry(this.#host.agent.telemetry, this.#host.sessionId()); let compactResult: CompactionResult | undefined; diff --git a/packages/coding-agent/test/issue-986-compaction-auth-fallback.test.ts b/packages/coding-agent/test/issue-986-compaction-auth-fallback.test.ts index 2022a1c19..d463df2e1 100644 --- a/packages/coding-agent/test/issue-986-compaction-auth-fallback.test.ts +++ b/packages/coding-agent/test/issue-986-compaction-auth-fallback.test.ts @@ -37,13 +37,7 @@ describe("issue #986 compaction auth fallback", () => { throw new Error("Expected bundled test models to exist"); } - const settings = Settings.isolated({ - "compaction.keepRecentTokens": 1, - "compaction.strategy": "context-full", - // This suite covers the portable summarizer's auth fallback. Native - // compaction keeps its implicit candidate chain provider-isolated. - "compaction.remoteEnabled": false, - }); + const settings = Settings.isolated({ "compaction.keepRecentTokens": 1, "compaction.strategy": "context-full" }); if (options?.fallbackModelRole) { settings.setModelRole(options.fallbackModelRole, `${fallbackModel.provider}/${fallbackModel.id}`); } @@ -87,7 +81,7 @@ describe("issue #986 compaction auth fallback", () => { return { currentModel, fallbackModel }; } - it("falls back to an authenticated role model when the current provider returns auth_unavailable", async () => { + it("falls back across providers when native compaction returns auth_unavailable", async () => { const { currentModel, fallbackModel } = await createSession({ fallbackModelRole: "smol" }); const compactSpy = vi.spyOn(compactionModule, "compact").mockImplementation(async (preparation, model) => { if (model.provider === currentModel.provider && model.id === currentModel.id) { diff --git a/packages/coding-agent/test/native-compaction-provider-isolation.test.ts b/packages/coding-agent/test/native-compaction-provider-isolation.test.ts deleted file mode 100644 index e6dd2dda1..000000000 --- a/packages/coding-agent/test/native-compaction-provider-isolation.test.ts +++ /dev/null @@ -1,97 +0,0 @@ -import { describe, expect, it } from "bun:test"; -import type { Model } from "@oh-my-pi/pi-ai"; -import { buildModel } from "@oh-my-pi/pi-catalog/build"; -import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; -import { - SessionMaintenance, - type SessionMaintenanceHost, -} from "@oh-my-pi/pi-coding-agent/session/session-maintenance"; - -function model( - id: string, - provider: string, - contextWindow: number, - remoteCompaction?: Model["remoteCompaction"], -): Model { - return buildModel({ - id, - name: id, - api: "openai-responses", - provider, - baseUrl: "https://example.test/v1", - reasoning: false, - input: ["text"], - cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - contextWindow, - maxTokens: 4096, - remoteCompaction, - }); -} - -function maintenance(settings: Settings): SessionMaintenance { - return new SessionMaintenance({ settings } as SessionMaintenanceHost); -} - -describe("native compaction provider isolation", () => { - it("keeps implicit role and context fallbacks on the current native provider", () => { - const settings = Settings.isolated(); - const current = model("native-current", "native-provider", 100_000, { enabled: true }); - const genericRole = model("generic-role", "generic-provider", 90_000); - const nativeFallback = model("native-fallback", "native-provider", 80_000, { enabled: true }); - settings.setModelRole("smol", `${genericRole.provider}/${genericRole.id}`); - - const candidates = maintenance(settings).resolveCompactionModelCandidates( - current, - [current, genericRole, nativeFallback], - undefined, - true, - true, - ); - - expect(candidates.map(candidate => `${candidate.provider}/${candidate.id}`)).toEqual([ - "native-provider/native-current", - "native-provider/native-fallback", - ]); - }); - - it("retains generic implicit fallbacks when provider-native compaction is disabled", () => { - const settings = Settings.isolated(); - const current = model("native-current", "native-provider", 100_000, { enabled: true }); - const genericRole = model("generic-role", "generic-provider", 90_000); - settings.setModelRole("smol", `${genericRole.provider}/${genericRole.id}`); - - const candidates = maintenance(settings).resolveCompactionModelCandidates( - current, - [current, genericRole], - undefined, - false, - true, - ); - - expect(candidates.map(candidate => `${candidate.provider}/${candidate.id}`)).toEqual([ - "native-provider/native-current", - "generic-provider/generic-role", - ]); - }); - - it("recognizes V2-only native capability under the effective streaming setting", () => { - const settings = Settings.isolated(); - const current = model("v2-current", "v2-provider", 100_000, { v2StreamingEnabled: true }); - const genericRole = model("generic-role", "generic-provider", 90_000); - const nativeFallback = model("v2-fallback", "v2-provider", 80_000, { v2StreamingEnabled: true }); - settings.setModelRole("smol", `${genericRole.provider}/${genericRole.id}`); - - const candidates = maintenance(settings).resolveCompactionModelCandidates( - current, - [current, genericRole, nativeFallback], - undefined, - true, - true, - ); - - expect(candidates.map(candidate => `${candidate.provider}/${candidate.id}`)).toEqual([ - "v2-provider/v2-current", - "v2-provider/v2-fallback", - ]); - }); -}); From 2fe31c61b6e0d28aaa735a94844c7fb9d13e6316 Mon Sep 17 00:00:00 2001 From: usr-bin-roygbiv Date: Sun, 26 Jul 2026 21:35:13 +0000 Subject: [PATCH 03/22] fix: preserve native compaction auth errors (cherry picked from commit ab8a823e1de425735825392db7d98b33905f0dc3) --- packages/agent/src/compaction/openai.ts | 6 ++- ...issue-986-compaction-auth-fallback.test.ts | 54 ++++++++++++------- 2 files changed, 40 insertions(+), 20 deletions(-) diff --git a/packages/agent/src/compaction/openai.ts b/packages/agent/src/compaction/openai.ts index df3b8d4f2..ea5e181bf 100644 --- a/packages/agent/src/compaction/openai.ts +++ b/packages/agent/src/compaction/openai.ts @@ -38,6 +38,7 @@ import { getOpenAIResponsesHistoryPayload, normalizeResponsesToolCallId, } from "@oh-my-pi/pi-ai/utils"; +import { captureOpenAIHttpError } from "@oh-my-pi/pi-ai/utils/openai-http"; import { CODEX_BASE_URL, getCodexAccountId, @@ -840,18 +841,19 @@ export async function requestOpenAiRemoteCompaction( }); if (!response.ok) { - const errorText = await response.text().catch(() => ""); + const cause = await captureOpenAIHttpError(response); logger.warn("OpenAI remote compaction failed", { endpoint, status: response.status, statusText: response.statusText, - errorText, + errorText: cause.captured.bodyText ?? "", }); throw new ProviderHttpError( `Remote compaction failed (${response.status} ${response.statusText})`, response.status, { headers: response.headers, + cause, }, ); } diff --git a/packages/coding-agent/test/issue-986-compaction-auth-fallback.test.ts b/packages/coding-agent/test/issue-986-compaction-auth-fallback.test.ts index d463df2e1..ed4c9e5ba 100644 --- a/packages/coding-agent/test/issue-986-compaction-auth-fallback.test.ts +++ b/packages/coding-agent/test/issue-986-compaction-auth-fallback.test.ts @@ -81,25 +81,42 @@ describe("issue #986 compaction auth fallback", () => { return { currentModel, fallbackModel }; } - it("falls back across providers when native compaction returns auth_unavailable", async () => { + it("falls back across providers when native compaction receives auth_unavailable", async () => { const { currentModel, fallbackModel } = await createSession({ fallbackModelRole: "smol" }); - const compactSpy = vi.spyOn(compactionModule, "compact").mockImplementation(async (preparation, model) => { - if (model.provider === currentModel.provider && model.id === currentModel.id) { - throw new Error( - "Turn prefix summarization failed: 503 auth_unavailable: no auth available (providers=codex, model=gpt-5.4-mini)", - ); - } - if (model.provider !== fallbackModel.provider || model.id !== fallbackModel.id) { - throw new Error(`Unexpected compaction model ${model.provider}/${model.id}`); - } - return { - summary: "fallback summary", - shortSummary: "fallback short summary", - firstKeptEntryId: preparation.firstKeptEntryId, - tokensBefore: 42, - details: { provider: model.provider }, - }; - }); + const originalCompact = compactionModule.compact; + const fetchMock = vi.fn(async () => + Response.json( + { error: { type: "auth_unavailable", message: "no auth available for codex" } }, + { status: 503, statusText: "Service Unavailable" }, + ), + ); + const compactSpy = vi + .spyOn(compactionModule, "compact") + .mockImplementation(async (preparation, model, apiKey, customInstructions, signal, options) => { + if (model.provider === currentModel.provider && model.id === currentModel.id) { + return originalCompact( + { + ...preparation, + settings: { ...preparation.settings, remoteStreamingV2Enabled: false }, + }, + model, + apiKey, + customInstructions, + signal, + { ...options, fetch: fetchMock }, + ); + } + if (model.provider !== fallbackModel.provider || model.id !== fallbackModel.id) { + throw new Error(`Unexpected compaction model ${model.provider}/${model.id}`); + } + return { + summary: "fallback summary", + shortSummary: "fallback short summary", + firstKeptEntryId: preparation.firstKeptEntryId, + tokensBefore: 42, + details: { provider: model.provider }, + }; + }); vi.spyOn(modelRegistry, "getApiKey").mockImplementation(async model => { if (model.provider === currentModel.provider && model.id === currentModel.id) return "codex-token"; if (model.provider === fallbackModel.provider && model.id === fallbackModel.id) return "anthropic-token"; @@ -109,6 +126,7 @@ describe("issue #986 compaction auth fallback", () => { const result = await session.compact(); expect(result.summary).toBe("fallback summary"); + expect(fetchMock).toHaveBeenCalled(); expect(compactSpy).toHaveBeenCalledTimes(2); expect(compactSpy.mock.calls.map(([, model]) => `${model.provider}/${model.id}`)).toEqual([ `${currentModel.provider}/${currentModel.id}`, From 4c2b40f8b3d2617af56f2003d2fffef5ec951bd9 Mon Sep 17 00:00:00 2001 From: usr-bin-roygbiv Date: Sun, 26 Jul 2026 21:37:38 +0000 Subject: [PATCH 04/22] fix: preserve streaming compaction auth errors (cherry picked from commit 6fe25c6a08e169a15fca057a56e1ce36f46bc641) --- .../src/compaction/compaction-v2-streaming.ts | 6 ++-- packages/agent/test/remote-compaction.test.ts | 31 +++++++++++++++++++ 2 files changed, 35 insertions(+), 2 deletions(-) diff --git a/packages/agent/src/compaction/compaction-v2-streaming.ts b/packages/agent/src/compaction/compaction-v2-streaming.ts index e14f6b422..5511e2ff6 100644 --- a/packages/agent/src/compaction/compaction-v2-streaming.ts +++ b/packages/agent/src/compaction/compaction-v2-streaming.ts @@ -21,6 +21,7 @@ import { parseAzureDeploymentNameMap, resolveOpenAIRequestSetup, } from "@oh-my-pi/pi-ai/providers/openai-shared"; +import { captureOpenAIHttpError } from "@oh-my-pi/pi-ai/utils/openai-http"; import { CODEX_BASE_URL, getCodexAccountId, @@ -334,18 +335,19 @@ async function attemptCompactionV2Streaming( }); if (!response.ok) { - const errorText = await response.text().catch(() => ""); + const cause = await captureOpenAIHttpError(response); logger.warn("V2 remote compaction failed", { endpoint, status: response.status, statusText: response.statusText, - errorText, + errorText: cause.captured.bodyText ?? "", }); throw new ProviderHttpError( `V2 remote compaction failed (${response.status} ${response.statusText})`, response.status, { headers: response.headers, + cause, }, ); } diff --git a/packages/agent/test/remote-compaction.test.ts b/packages/agent/test/remote-compaction.test.ts index 18d4e48f9..b23127307 100644 --- a/packages/agent/test/remote-compaction.test.ts +++ b/packages/agent/test/remote-compaction.test.ts @@ -20,6 +20,7 @@ import { trimRemoteCompactionInputToContextWindow, } from "@oh-my-pi/pi-agent-core/compaction/openai"; import * as ai from "@oh-my-pi/pi-ai"; +import * as AIError from "@oh-my-pi/pi-ai/error"; import { getOpenAICodexTransportDetails } from "@oh-my-pi/pi-ai/providers/openai-codex-responses"; import type { AssistantMessage, @@ -733,6 +734,36 @@ describe("requestCompactionV2Streaming", () => { expect(attempts).toBe(2); }); + + test("preserves auth_unavailable from V2 HTTP failures", async () => { + const model = makeOpenAiModel({ + remoteCompaction: { + enabled: true, + v2StreamingEnabled: true, + v2Endpoint: "https://compact.example/v1/responses", + }, + }); + const request = buildCompactionV2Request( + model, + [{ type: "message", role: "user", content: [{ type: "input_text", text: "real user" }] }], + "instructions", + ); + const fetchMock = vi.fn(async () => + Response.json( + { error: { type: "auth_unavailable", message: "no auth available for codex" } }, + { status: 503, statusText: "Service Unavailable" }, + ), + ); + + const error = await requestCompactionV2Streaming(model, "test-key", request, undefined, { + fetch: fetchMock, + retryWait: async () => {}, + }).catch(cause => cause); + + expect(fetchMock).toHaveBeenCalled(); + expect(error).toBeInstanceOf(AIError.ProviderHttpError); + expect(AIError.is(AIError.classify(error), AIError.Flag.AuthFailed)).toBe(true); + }); }); describe("Responses Lite remote compaction", () => { From 3abf17e7b950570b9787262393c7f9d7b20ca3d7 Mon Sep 17 00:00:00 2001 From: usr-bin-roygbiv Date: Tue, 28 Jul 2026 07:42:12 +0000 Subject: [PATCH 05/22] fix(compaction): stop cross-provider native fallback (cherry picked from commit 01455dc1e681e385d0a46e64a72009364e8f510e) --- packages/agent/src/compaction/compaction.ts | 3 ++- packages/agent/src/compaction/errors.ts | 16 ++++++++++++ .../src/session/session-maintenance.ts | 4 +++ .../test/agent-session-handoff.test.ts | 25 +++++++++++++++++++ 4 files changed, 47 insertions(+), 1 deletion(-) diff --git a/packages/agent/src/compaction/compaction.ts b/packages/agent/src/compaction/compaction.ts index 0563cd046..a2b8daffc 100644 --- a/packages/agent/src/compaction/compaction.ts +++ b/packages/agent/src/compaction/compaction.ts @@ -43,6 +43,7 @@ import { V2_RETAINED_MESSAGE_TOKEN_BUDGET, } from "./compaction-v2-streaming"; import type { CompactionEntry, SessionEntry } from "./entries"; +import { NativeCompactionError } from "./errors"; import { isEstimateCacheable, readEstimateCache, writeEstimateCache } from "./message-cache"; import { type ConvertToLlm, createBranchSummaryMessage, createCustomMessage, defaultConvertToLlm } from "./messages"; import { @@ -1530,7 +1531,7 @@ export async function compact( } if (!usedRemoteCompaction && nativeCompactionError !== undefined) { - throw nativeCompactionError; + throw new NativeCompactionError(nativeCompactionError); } // Generate summaries (can be parallel if both needed) and merge into one diff --git a/packages/agent/src/compaction/errors.ts b/packages/agent/src/compaction/errors.ts index f60d0926e..ed34484dd 100644 --- a/packages/agent/src/compaction/errors.ts +++ b/packages/agent/src/compaction/errors.ts @@ -18,6 +18,22 @@ export class CompactionCancelledError extends Error { } } +/** + * A provider-native compaction request failed after every native protocol + * available for the selected model was exhausted. + * + * The cause stays attached so AI error classification can still recognize + * authentication failures. Non-auth failures remain distinguishable from + * ordinary summarization errors and must not fall through to another provider. + */ +export class NativeCompactionError extends Error { + readonly name = "NativeCompactionError" as const; + + constructor(cause: unknown) { + super(cause instanceof Error ? cause.message : String(cause), { cause }); + } +} + /** * Outcome of a compaction attempt, surfaced by `CommandController.executeCompaction` * so callers (e.g. the plan-mode approval flow) can distinguish a deliberate abort diff --git a/packages/coding-agent/src/session/session-maintenance.ts b/packages/coding-agent/src/session/session-maintenance.ts index df65f9a32..c1a13a4a3 100644 --- a/packages/coding-agent/src/session/session-maintenance.ts +++ b/packages/coding-agent/src/session/session-maintenance.ts @@ -26,6 +26,7 @@ import { DEFAULT_SHAKE_CONFIG, effectiveReserveTokens, estimateTokens, + NativeCompactionError, prepareCompaction, resolveBudgetReserveTokens, resolveThresholdTokens, @@ -2532,6 +2533,9 @@ export class SessionMaintenance { lastError = this.#buildCompactionAuthError(); break; } + if (error instanceof NativeCompactionError) { + throw error; + } if (AIError.is(id, AIError.Flag.Timeout)) { logger.warn( hasMoreCandidates diff --git a/packages/coding-agent/test/agent-session-handoff.test.ts b/packages/coding-agent/test/agent-session-handoff.test.ts index 4c8031068..a5f805895 100644 --- a/packages/coding-agent/test/agent-session-handoff.test.ts +++ b/packages/coding-agent/test/agent-session-handoff.test.ts @@ -846,6 +846,31 @@ describe("AgentSession handoff", () => { expect(fallbackCandidateKey).toBeDefined(); expect(promptSpy).toHaveBeenCalledTimes(1); }); + + it("does not switch providers after provider-native auto-compaction fails", async () => { + session.settings.set("compaction.strategy", "context-full"); + session.settings.set("compaction.thresholdTokens", 50); + session.settings.set("compaction.keepRecentTokens", 1); + session.settings.set("contextPromotion.enabled", false); + + const attemptedCandidates: string[] = []; + const compactSpy = vi.spyOn(compactionModule, "compact").mockImplementation(async (_preparation, candidate) => { + attemptedCandidates.push(`${candidate.provider}/${candidate.id}`); + throw new compactionModule.NativeCompactionError(new Error("native compaction transport failed")); + }); + + await session.prompt("pending prompt ".repeat(120)); + await waitFor(() => + events.some( + event => + event.type === "auto_compaction_end" && + event.errorMessage?.includes("native compaction transport failed") === true, + ), + ); + + expect(compactSpy).toHaveBeenCalledTimes(1); + expect(attemptedCandidates).toHaveLength(1); + }); it("keeps pre-prompt context-full checks aligned with provider-anchored usage", async () => { await session.dispose(); authStorage.setRuntimeApiKey("openai", "test-key"); From b927da95e61f3bb5c8cf22db5625033ab4f03b9f Mon Sep 17 00:00:00 2001 From: usr-bin-roygbiv Date: Tue, 28 Jul 2026 10:09:01 +0000 Subject: [PATCH 06/22] fix(agent): preserve native compaction error precedence (cherry picked from commit 3b1c1a420132485ac4f5a4803615a70d1e05c794) --- packages/agent/src/compaction/compaction.ts | 19 ++++++-- packages/agent/test/remote-compaction.test.ts | 48 +++++++++++++++++++ 2 files changed, 63 insertions(+), 4 deletions(-) diff --git a/packages/agent/src/compaction/compaction.ts b/packages/agent/src/compaction/compaction.ts index a2b8daffc..6f9999cde 100644 --- a/packages/agent/src/compaction/compaction.ts +++ b/packages/agent/src/compaction/compaction.ts @@ -22,7 +22,7 @@ import { type Usage, withAuth, } from "@oh-my-pi/pi-ai"; -import { ProviderHttpError } from "@oh-my-pi/pi-ai/error"; +import * as AIError from "@oh-my-pi/pi-ai/error"; import { createOpenAICodexCompactionRequestContext } from "@oh-my-pi/pi-ai/providers/openai-codex-responses"; import { convertTools } from "@oh-my-pi/pi-ai/providers/openai-responses"; import { buildResponsesInput, resolveOpenAICompatPolicy } from "@oh-my-pi/pi-ai/providers/openai-shared"; @@ -726,7 +726,7 @@ function resolveCompactionEffort(model: Model, level: ThinkingLevel | undefined) */ function createSummarizationError(prefix: string, response: AssistantMessage): Error { const text = `${prefix}: ${response.errorMessage || "Unknown error"}`; - return response.errorStatus === undefined ? new Error(text) : new ProviderHttpError(text, response.errorStatus); + return response.errorStatus === undefined ? new Error(text) : new AIError.ProviderHttpError(text, response.errorStatus); } function shouldRetryHandoffWithAutoToolChoice(response: AssistantMessage): boolean { @@ -1333,6 +1333,17 @@ function buildCompactionV2Reasoning( return { effort: reasoning.wireEffort ?? reasoning.requestedEffort, summary: "auto" }; } +/** + * Keep any non-auth native protocol failure ahead of authentication failures. + * Downstream may retry compaction with another provider only when every native + * protocol failed authentication, so a later auth error must not hide an + * earlier transport or protocol failure. + */ +function selectNativeCompactionError(previousError: unknown, nextError: unknown): unknown { + if (previousError === undefined) return nextError; + return AIError.is(AIError.classify(previousError), AIError.Flag.AuthFailed) ? nextError : previousError; +} + /** * Generate summaries for compaction using prepared data. * Returns CompactionResult - SessionManager adds id/parentId when saving. @@ -1469,7 +1480,7 @@ export async function compact( // swallowing it here would downgrade Esc into "fall back to local // summarization" and keep compaction running on an aborted signal. if (signal?.aborted) throw err; - nativeCompactionError = err; + nativeCompactionError = selectNativeCompactionError(nativeCompactionError, err); logger.warn("OpenAI V2 remote compaction failed, falling back to V1 remote compaction", { error: err instanceof Error ? err.message : String(err), model: model.id, @@ -1520,7 +1531,7 @@ export async function compact( // swallowing it here would downgrade Esc into "fall back to local // summarization" and keep compaction running on an aborted signal. if (signal?.aborted) throw err; - nativeCompactionError = err; + nativeCompactionError = selectNativeCompactionError(nativeCompactionError, err); logger.warn("OpenAI remote compaction failed", { error: err instanceof Error ? err.message : String(err), model: model.id, diff --git a/packages/agent/test/remote-compaction.test.ts b/packages/agent/test/remote-compaction.test.ts index b23127307..36a7141c8 100644 --- a/packages/agent/test/remote-compaction.test.ts +++ b/packages/agent/test/remote-compaction.test.ts @@ -4,6 +4,7 @@ import { compact, createFileOps, DEFAULT_COMPACTION_SETTINGS, + NativeCompactionError, prepareCompaction, type SessionEntry, } from "@oh-my-pi/pi-agent-core/compaction"; @@ -1718,6 +1719,53 @@ describe("compact() remote compaction failure handling", () => { expect(JSON.stringify(sameProviderActive?.messagesToSummarize ?? [])).not.toContain("ORIGINAL ALPHA port 4242"); }); + test("retains the V2 non-auth failure when the V1 fallback fails authentication", async () => { + const preparation = makePreparation(); + preparation.settings = { ...preparation.settings, remoteStreamingV2Enabled: true }; + const model = makeOpenAiModel({ + remoteCompaction: { enabled: true, v2StreamingEnabled: true }, + }); + const requestedUrls: string[] = []; + const fetchMock: FetchImpl = async input => { + const url = String(input); + requestedUrls.push(url); + return url.endsWith("/responses/compact") + ? new Response("authentication failed", { status: 401, statusText: "Unauthorized" }) + : new Response("V2 transport failed", { status: 400, statusText: "Bad Request" }); + }; + + const error = await compact(preparation, model, "test-key", undefined, undefined, { fetch: fetchMock }).catch( + cause => cause, + ); + + expect(requestedUrls.map(url => new URL(url).pathname)).toEqual(["/v1/responses", "/v1/responses/compact"]); + expect(error).toBeInstanceOf(NativeCompactionError); + expect(error).toMatchObject({ cause: { status: 400 } }); + expect(AIError.is(AIError.classify(error), AIError.Flag.AuthFailed)).toBe(false); + }); + + test("keeps native compaction auth-classified when every attempted protocol fails authentication", async () => { + const preparation = makePreparation(); + preparation.settings = { ...preparation.settings, remoteStreamingV2Enabled: true }; + const model = makeOpenAiModel({ + remoteCompaction: { enabled: true, v2StreamingEnabled: true }, + }); + const requestedUrls: string[] = []; + const fetchMock: FetchImpl = async input => { + requestedUrls.push(String(input)); + return new Response("authentication failed", { status: 401, statusText: "Unauthorized" }); + }; + + const error = await compact(preparation, model, "test-key", undefined, undefined, { fetch: fetchMock }).catch( + cause => cause, + ); + + expect(requestedUrls.map(url => new URL(url).pathname)).toEqual(["/v1/responses", "/v1/responses/compact"]); + expect(error).toBeInstanceOf(NativeCompactionError); + expect(error).toMatchObject({ cause: { status: 401 } }); + expect(AIError.is(AIError.classify(error), AIError.Flag.AuthFailed)).toBe(true); + }); + test("V2 native failure falls back to V1 without generic summarization", async () => { const completeSpy = vi.spyOn(ai, "completeSimple").mockResolvedValue(localSummaryMessage("local summary")); const preparation = makePreparation(); From 0c31095d34809914a4c0f65a89344261440554e7 Mon Sep 17 00:00:00 2001 From: usr-bin-roygbiv Date: Tue, 28 Jul 2026 10:23:50 +0000 Subject: [PATCH 07/22] fix(agent): skip V2 retries for compaction auth errors (cherry picked from commit 07a54771834435b7897208ab9d6d42401f0203e1) --- .../agent/src/compaction/compaction-v2-streaming.ts | 12 ++++++++---- packages/agent/test/remote-compaction.test.ts | 4 ++-- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/packages/agent/src/compaction/compaction-v2-streaming.ts b/packages/agent/src/compaction/compaction-v2-streaming.ts index 5511e2ff6..997e43f00 100644 --- a/packages/agent/src/compaction/compaction-v2-streaming.ts +++ b/packages/agent/src/compaction/compaction-v2-streaming.ts @@ -8,7 +8,7 @@ */ import type { Api, CodexCompactionContext, FetchImpl, Model, ProviderSessionState } from "@oh-my-pi/pi-ai"; -import { isTransientStatus, ProviderHttpError } from "@oh-my-pi/pi-ai/error"; +import * as AIError from "@oh-my-pi/pi-ai/error"; import { applyCodexResponsesLiteShape } from "@oh-my-pi/pi-ai/providers/openai-codex/request-transformer"; import { createOpenAICodexCompactionRequestContext, @@ -342,7 +342,7 @@ async function attemptCompactionV2Streaming( statusText: response.statusText, errorText: cause.captured.bodyText ?? "", }); - throw new ProviderHttpError( + throw new AIError.ProviderHttpError( `V2 remote compaction failed (${response.status} ${response.statusText})`, response.status, { @@ -562,6 +562,10 @@ function formatCompactionV2Failure(event: Record, type: string) } function isRetryableCompactionError(error: Error): boolean { + // The gateway's synthetic auth_unavailable is an HTTP 503, but the + // captured response cause classifies it as auth. Let provider fallback run + // immediately instead of spending the transient retry budget. + if (AIError.is(AIError.classify(error), AIError.Flag.AuthFailed)) return false; if ( error.name === "AbortError" || error.name === "TimeoutError" || @@ -569,8 +573,8 @@ function isRetryableCompactionError(error: Error): boolean { ) { return true; } - if (error instanceof ProviderHttpError) { - return isTransientStatus(error.status); + if (error instanceof AIError.ProviderHttpError) { + return AIError.isTransientStatus(error.status); } const message = error.message.toLowerCase(); return ( diff --git a/packages/agent/test/remote-compaction.test.ts b/packages/agent/test/remote-compaction.test.ts index 36a7141c8..c8f5947a1 100644 --- a/packages/agent/test/remote-compaction.test.ts +++ b/packages/agent/test/remote-compaction.test.ts @@ -736,7 +736,7 @@ describe("requestCompactionV2Streaming", () => { expect(attempts).toBe(2); }); - test("preserves auth_unavailable from V2 HTTP failures", async () => { + test("does not retry and preserves auth_unavailable from V2 HTTP failures", async () => { const model = makeOpenAiModel({ remoteCompaction: { enabled: true, @@ -761,7 +761,7 @@ describe("requestCompactionV2Streaming", () => { retryWait: async () => {}, }).catch(cause => cause); - expect(fetchMock).toHaveBeenCalled(); + expect(fetchMock).toHaveBeenCalledTimes(1); expect(error).toBeInstanceOf(AIError.ProviderHttpError); expect(AIError.is(AIError.classify(error), AIError.Flag.AuthFailed)).toBe(true); }); From eeb542b2ef4781246438098e4c812504f980cfaf Mon Sep 17 00:00:00 2001 From: usr-bin-roygbiv Date: Tue, 28 Jul 2026 10:44:40 +0000 Subject: [PATCH 08/22] fix(advisor): stop provider fallback on native compaction failure (cherry picked from commit c636aa6be3f12eed9475bb2e1aa6a03749049523) --- .../src/session/session-advisors.ts | 3 + .../test/advisor-context-maintenance.test.ts | 104 ++++++++++++++++++ 2 files changed, 107 insertions(+) diff --git a/packages/coding-agent/src/session/session-advisors.ts b/packages/coding-agent/src/session/session-advisors.ts index b18d7db08..695d7c95a 100644 --- a/packages/coding-agent/src/session/session-advisors.ts +++ b/packages/coding-agent/src/session/session-advisors.ts @@ -16,6 +16,7 @@ import { compactionContextTokens, createCompactionSummaryMessage, estimateTokens, + NativeCompactionError, prepareCompaction, type SessionMessageEntry, shouldCompact, @@ -1385,6 +1386,8 @@ export class SessionAdvisors { break; } catch (error) { if (signal.aborted) throw error; + const id = AIError.classify(error, candidate.api); + if (error instanceof NativeCompactionError && !AIError.is(id, AIError.Flag.AuthFailed)) throw error; lastError = error; } } diff --git a/packages/coding-agent/test/advisor-context-maintenance.test.ts b/packages/coding-agent/test/advisor-context-maintenance.test.ts index 423565e7e..d37c6b8a7 100644 --- a/packages/coding-agent/test/advisor-context-maintenance.test.ts +++ b/packages/coding-agent/test/advisor-context-maintenance.test.ts @@ -1,8 +1,10 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "bun:test"; import { Agent, type AgentMessage, type CompactionSummaryMessage, countTokens } from "@oh-my-pi/pi-agent-core"; +import * as compactionModule from "@oh-my-pi/pi-agent-core/compaction"; import { calculateContextTokens, estimateTokens, resolveThresholdTokens } from "@oh-my-pi/pi-agent-core/compaction"; import type { AssistantMessage } from "@oh-my-pi/pi-ai"; import { createMockModel, type MockModel, registerMockApi } from "@oh-my-pi/pi-ai/providers/mock"; +import { getBundledModel } from "@oh-my-pi/pi-catalog/models"; import { ModelRegistry } from "@oh-my-pi/pi-coding-agent/config/model-registry"; import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; import { estimateToolSchemaTokens } from "@oh-my-pi/pi-coding-agent/modes/utils/context-usage"; @@ -122,6 +124,55 @@ describe("AgentSession advisor context maintenance", () => { }; } + function createAdvisorFallbackHarness() { + const primaryMock = createMockModel({ + provider: "anthropic", + responses: [{ content: ["primary complete"] }], + }); + const advisorMock = createMockModel({ + provider: "openai", + responses: [{ content: ["advisor reviewed current update"] }], + }); + const nativeModel = getBundledModel("openai", "gpt-5"); + const fallbackModel = getBundledModel("anthropic", "claude-sonnet-4-5"); + if (!nativeModel || !fallbackModel) throw new Error("Expected bundled compaction models"); + + authStorage.setRuntimeApiKey(nativeModel.provider, "openai-key"); + const modelRegistry = new ModelRegistry(authStorage, tempDir.join("models.yml")); + const settings = Settings.isolated({ + "advisor.syncBacklog": "1", + "compaction.enabled": true, + "compaction.strategy": "context-full", + "contextPromotion.enabled": false, + }); + settings.setModelRole("advisor", `${nativeModel.provider}/${nativeModel.id}`); + settings.setModelRole("smol", `${fallbackModel.provider}/${fallbackModel.id}`); + const agent = new Agent({ + getApiKey: () => "test-key", + initialState: { model: primaryMock, systemPrompt: [], tools: [] }, + streamFn: primaryMock.stream, + }); + session = new AgentSession({ + agent, + sessionManager: SessionManager.inMemory(), + settings, + modelRegistry, + advisorTools: [], + advisorStreamFn: advisorMock.stream, + }); + expect(session.setAdvisorEnabled(true)).toBe(true); + const advisor = session.getAdvisorAgent(); + if (!advisor) throw new Error("Expected advisor agent to be active"); + advisor.setModel(nativeModel); + vi.spyOn(modelRegistry, "getApiKey").mockResolvedValue("test-key"); + vi.spyOn(modelRegistry, "getAvailable").mockReturnValue([nativeModel, fallbackModel]); + advisor.state.messages.push( + usageAnchor(advisorMock, Date.now() - 2_000), + usageAnchor(advisorMock, Date.now() - 1_000), + ); + return { advisor, fallbackModel, nativeModel }; + } + it("maintains a 371,200-token cached advisor context before the 372,000-token window", async () => { const { advisor, advisorMock, settings } = createHarness(); const anchor = usageAnchor(advisorMock, Date.now() - 1_000, 0.5); @@ -353,4 +404,57 @@ describe("AgentSession advisor context maintenance", () => { expect((JSON.parse(userId) as { session_id?: string }).session_id).toBe(advisor.sessionId); } }); + + it("does not cross providers after a non-auth native advisor compaction failure", async () => { + const { advisor, fallbackModel, nativeModel } = createAdvisorFallbackHarness(); + const compactSpy = vi.spyOn(compactionModule, "compact").mockImplementation(async (preparation, model) => { + if (model.provider === nativeModel.provider && model.id === nativeModel.id) { + throw new compactionModule.NativeCompactionError(new Error("V2 native compaction transport failed")); + } + if (model.provider !== fallbackModel.provider || model.id !== fallbackModel.id) { + throw new Error(`Unexpected compaction model ${model.provider}/${model.id}`); + } + return { + summary: "cross-provider summary", + shortSummary: "cross-provider", + firstKeptEntryId: preparation.firstKeptEntryId, + tokensBefore: 42, + }; + }); + + await session.prompt("small current update"); + + expect(compactSpy.mock.calls.map(([, model]) => `${model.provider}/${model.id}`)).toEqual([ + `${nativeModel.provider}/${nativeModel.id}`, + ]); + expect(JSON.stringify(advisor.state.messages)).toContain("prior advisor output"); + }); + + it("allows advisor compaction to try another provider after an auth-classified native failure", async () => { + const { advisor, fallbackModel, nativeModel } = createAdvisorFallbackHarness(); + const compactSpy = vi.spyOn(compactionModule, "compact").mockImplementation(async (preparation, model) => { + if (model.provider === nativeModel.provider && model.id === nativeModel.id) { + throw new compactionModule.NativeCompactionError( + Object.assign(new Error("native compaction authentication failed"), { status: 401 }), + ); + } + if (model.provider !== fallbackModel.provider || model.id !== fallbackModel.id) { + throw new Error(`Unexpected compaction model ${model.provider}/${model.id}`); + } + return { + summary: "authenticated fallback summary", + shortSummary: "authenticated fallback", + firstKeptEntryId: preparation.firstKeptEntryId, + tokensBefore: 42, + }; + }); + + await session.prompt("small current update"); + + expect(compactSpy.mock.calls.map(([, model]) => `${model.provider}/${model.id}`)).toEqual([ + `${nativeModel.provider}/${nativeModel.id}`, + `${fallbackModel.provider}/${fallbackModel.id}`, + ]); + expect(JSON.stringify(advisor.state.messages)).toContain("authenticated fallback summary"); + }); }); From ba3f1898ca9edf62e450c89420da677397415f82 Mon Sep 17 00:00:00 2001 From: usr-bin-roygbiv Date: Tue, 28 Jul 2026 10:58:52 +0000 Subject: [PATCH 09/22] fix(compaction): keep native fallback within provider (cherry picked from commit d7f2cc59eb9138942924259677ff81b9535e4661) --- .../src/session/session-maintenance.ts | 8 +- ...issue-986-compaction-auth-fallback.test.ts | 138 ++++++++++++++++++ 2 files changed, 145 insertions(+), 1 deletion(-) diff --git a/packages/coding-agent/src/session/session-maintenance.ts b/packages/coding-agent/src/session/session-maintenance.ts index c1a13a4a3..02c152362 100644 --- a/packages/coding-agent/src/session/session-maintenance.ts +++ b/packages/coding-agent/src/session/session-maintenance.ts @@ -2478,6 +2478,7 @@ export class SessionMaintenance { const telemetry = resolveTelemetry(this.#host.agent.telemetry, this.#host.sessionId()); let compactResult: CompactionResult | undefined; let lastError: unknown; + let nativeCompactionFailure: { error: NativeCompactionError; provider: string } | undefined; codexCompaction = createCodexCompactionContext({ trigger: "auto", reason: "context_limit", @@ -2488,6 +2489,9 @@ export class SessionMaintenance { for (let candidateIndex = 0; candidateIndex < candidates.length; candidateIndex++) { const candidate = candidates[candidateIndex]; + if (nativeCompactionFailure && candidate.provider !== nativeCompactionFailure.provider) { + throw nativeCompactionFailure.error; + } const hasMoreCandidates = candidateIndex < candidates.length - 1; const apiKey = await this.#host.modelRegistry.getApiKey(candidate, this.#host.sessionId()); if (!apiKey) continue; @@ -2534,7 +2538,9 @@ export class SessionMaintenance { break; } if (error instanceof NativeCompactionError) { - throw error; + nativeCompactionFailure ??= { error, provider: candidate.provider }; + lastError = nativeCompactionFailure.error; + break; } if (AIError.is(id, AIError.Flag.Timeout)) { logger.warn( diff --git a/packages/coding-agent/test/issue-986-compaction-auth-fallback.test.ts b/packages/coding-agent/test/issue-986-compaction-auth-fallback.test.ts index ed4c9e5ba..87a826c96 100644 --- a/packages/coding-agent/test/issue-986-compaction-auth-fallback.test.ts +++ b/packages/coding-agent/test/issue-986-compaction-auth-fallback.test.ts @@ -81,6 +81,144 @@ describe("issue #986 compaction auth fallback", () => { return { currentModel, fallbackModel }; } + async function createAutoNativeFallbackSession() { + const currentModel = getBundledModel("openai", "gpt-5"); + const sameProviderModel = getBundledModel("openai", "gpt-5-mini"); + const crossProviderModel = getBundledModel("anthropic", "claude-sonnet-4-5"); + if (!currentModel || !sameProviderModel || !crossProviderModel) { + throw new Error("Expected bundled native fallback test models"); + } + + const settings = Settings.isolated({ + "compaction.autoContinue": false, + "compaction.keepRecentTokens": 1, + "compaction.strategy": "context-full", + "contextPromotion.enabled": false, + }); + settings.setModelRole("smol", `${sameProviderModel.provider}/${sameProviderModel.id}`); + settings.setModelRole("slow", `${crossProviderModel.provider}/${crossProviderModel.id}`); + const agent = new Agent({ + initialState: { model: currentModel, systemPrompt: ["Test"], tools: [], messages: [] }, + }); + + authStorage = await AuthStorage.create(path.join(tempDir.path(), "testauth.db")); + authStorage.setRuntimeApiKey(currentModel.provider, "openai-token"); + authStorage.setRuntimeApiKey(crossProviderModel.provider, "anthropic-token"); + modelRegistry = new ModelRegistry(authStorage, path.join(tempDir.path(), "models.yml")); + session = new AgentSession({ + agent, + sessionManager: SessionManager.inMemory(), + settings, + modelRegistry, + }); + session.subscribe(() => {}); + for (const [userText, assistantText] of [ + ["first question", "first answer"], + ["second question", "second answer"], + ] as const) { + const user = userMsg(userText); + const assistant = assistantMsg(assistantText); + session.agent.appendMessage(user); + session.sessionManager.appendMessage(user); + session.agent.appendMessage(assistant); + session.sessionManager.appendMessage(assistant); + } + vi.spyOn(modelRegistry, "getAvailable").mockReturnValue([ + currentModel, + sameProviderModel, + crossProviderModel, + ]); + vi.spyOn(modelRegistry, "getApiKey").mockResolvedValue("test-key"); + + const triggerAutoCompaction = async (): Promise => { + const { promise, resolve } = Promise.withResolvers(); + session.subscribe(event => { + if (event.type === "auto_compaction_end") resolve(); + }); + const contextWindow = currentModel.contextWindow; + if (!contextWindow) throw new Error("Expected current model context window"); + const assistant = { + ...assistantMsg("threshold reached"), + api: currentModel.api, + provider: currentModel.provider, + model: currentModel.id, + usage: { + input: contextWindow, + output: 1, + cacheRead: 0, + cacheWrite: 0, + totalTokens: contextWindow + 1, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }, + }; + session.agent.emitExternalEvent({ type: "message_end", message: assistant }); + session.agent.emitExternalEvent({ type: "agent_end", messages: [assistant] }); + await promise; + await session.waitForIdle(); + }; + + return { crossProviderModel, currentModel, sameProviderModel, triggerAutoCompaction }; + } + + it("continues same-provider native candidates but stops before crossing providers on non-auth failure", async () => { + const { crossProviderModel, currentModel, sameProviderModel, triggerAutoCompaction } = + await createAutoNativeFallbackSession(); + const attemptedModels: string[] = []; + vi.spyOn(compactionModule, "compact").mockImplementation(async (preparation, model) => { + attemptedModels.push(`${model.provider}/${model.id}`); + if (model.provider === currentModel.provider || model.provider === sameProviderModel.provider) { + throw new compactionModule.NativeCompactionError(new Error("native compaction transport failed")); + } + if (model.provider !== crossProviderModel.provider || model.id !== crossProviderModel.id) { + throw new Error(`Unexpected compaction model ${model.provider}/${model.id}`); + } + return { + summary: "cross-provider summary", + shortSummary: "cross-provider", + firstKeptEntryId: preparation.firstKeptEntryId, + tokensBefore: 42, + }; + }); + + await triggerAutoCompaction(); + + expect(attemptedModels).toEqual([ + `${currentModel.provider}/${currentModel.id}`, + `${sameProviderModel.provider}/${sameProviderModel.id}`, + ]); + }); + + it("preserves cross-provider auto-compaction fallback for auth-classified native failures", async () => { + const { crossProviderModel, currentModel, sameProviderModel, triggerAutoCompaction } = + await createAutoNativeFallbackSession(); + const attemptedModels: string[] = []; + vi.spyOn(compactionModule, "compact").mockImplementation(async (preparation, model) => { + attemptedModels.push(`${model.provider}/${model.id}`); + if (model.provider === currentModel.provider || model.provider === sameProviderModel.provider) { + throw new compactionModule.NativeCompactionError( + Object.assign(new Error("native compaction authentication failed"), { status: 401 }), + ); + } + if (model.provider !== crossProviderModel.provider || model.id !== crossProviderModel.id) { + throw new Error(`Unexpected compaction model ${model.provider}/${model.id}`); + } + return { + summary: "authenticated fallback summary", + shortSummary: "authenticated fallback", + firstKeptEntryId: preparation.firstKeptEntryId, + tokensBefore: 42, + }; + }); + + await triggerAutoCompaction(); + + expect(attemptedModels).toEqual([ + `${currentModel.provider}/${currentModel.id}`, + `${sameProviderModel.provider}/${sameProviderModel.id}`, + `${crossProviderModel.provider}/${crossProviderModel.id}`, + ]); + }); + it("falls back across providers when native compaction receives auth_unavailable", async () => { const { currentModel, fallbackModel } = await createSession({ fallbackModelRole: "smol" }); const originalCompact = compactionModule.compact; From b8f8485ee4a7938faea40e0ea363efa475b4f4e0 Mon Sep 17 00:00:00 2001 From: usr-bin-roygbiv Date: Tue, 28 Jul 2026 10:59:43 +0000 Subject: [PATCH 10/22] style(coding-agent): format compaction fallback tests (cherry picked from commit 75613b4e82ba839ccb6f4ccb846eb8753672614d) --- .../test/issue-986-compaction-auth-fallback.test.ts | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/packages/coding-agent/test/issue-986-compaction-auth-fallback.test.ts b/packages/coding-agent/test/issue-986-compaction-auth-fallback.test.ts index 87a826c96..b683f1efe 100644 --- a/packages/coding-agent/test/issue-986-compaction-auth-fallback.test.ts +++ b/packages/coding-agent/test/issue-986-compaction-auth-fallback.test.ts @@ -123,11 +123,7 @@ describe("issue #986 compaction auth fallback", () => { session.agent.appendMessage(assistant); session.sessionManager.appendMessage(assistant); } - vi.spyOn(modelRegistry, "getAvailable").mockReturnValue([ - currentModel, - sameProviderModel, - crossProviderModel, - ]); + vi.spyOn(modelRegistry, "getAvailable").mockReturnValue([currentModel, sameProviderModel, crossProviderModel]); vi.spyOn(modelRegistry, "getApiKey").mockResolvedValue("test-key"); const triggerAutoCompaction = async (): Promise => { From 28e738830d69bb8d079c4cbc02277162b00f6a20 Mon Sep 17 00:00:00 2001 From: usr-bin-roygbiv Date: Tue, 28 Jul 2026 11:13:23 +0000 Subject: [PATCH 11/22] fix(compaction): align advisor provider boundary (cherry picked from commit 771a9917caa2630b96e8fd8f6f8d73d5462242d3) --- packages/agent/CHANGELOG.md | 7 ++-- packages/coding-agent/CHANGELOG.md | 6 +-- .../src/session/session-advisors.ts | 12 +++++- .../test/advisor-context-maintenance.test.ts | 38 ++++++++++++------- 4 files changed, 42 insertions(+), 21 deletions(-) diff --git a/packages/agent/CHANGELOG.md b/packages/agent/CHANGELOG.md index 6a8e07bed..e97272a65 100644 --- a/packages/agent/CHANGELOG.md +++ b/packages/agent/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +- Provider-native compaction failures now surface their transport error instead of silently switching to generic summarization; streaming V2 still falls back to native V1 when available. + ## [17.1.7] - 2026-07-27 ### Changed @@ -23,9 +27,6 @@ ### Fixed - Fixed proxy-stream clients dropping finalized provider-only content blocks, including Anthropic native web-search history, by allowing `done` and `error` events to carry terminal assistant content while retaining delta-reconstructed content from older proxy servers that omit it ([#6703](https://github.com/can1357/oh-my-pi/issues/6703)). -### Fixed - -- Provider-native compaction failures now surface their transport error instead of silently switching to generic summarization; streaming V2 still falls back to native V1 when available. ## [17.1.4] - 2026-07-26 diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index eba47b6e5..ca75f75cf 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -77,6 +77,9 @@ - Fixed omp worktree clear prematurely deleting active task-isolation sandboxes owned by running subagents. - Fixed /vibe mode preventing the director from completing parent tasks after verifying worker results by keeping the built-in todo tool active. - Fixed numeric GitHub issue and pull request autocomplete being suppressed inside skill slash-command arguments. +### Fixed + +- Native compaction preserves provider-native success and non-authentication failure semantics while retaining authenticated cross-provider fallback when the native provider rejects credentials. ## [17.1.7] - 2026-07-27 @@ -161,9 +164,6 @@ - Fixed MiMo models using hashline edit mode by default despite needing the same replace-mode fallback as Kimi. ([#3772](https://github.com/can1357/oh-my-pi/issues/3772)) - Fixed `omp` refusing to start on Windows when no `bash.exe` is discoverable — most visibly with scoop-installed Git, whose manifest shims `sh.exe`/`git.exe` but never `bash.exe`, so PATH lookup missed it. Startup threw `No bash shell found` while merely building the bash tool description, even though bash tool commands always execute in the embedded brush-core shell and need no host bash. Shell discovery now also checks `GIT_INSTALL_ROOT`, scoop and per-user Git for Windows install roots, and `sh.exe` on PATH, then falls back to `cmd.exe` for the spawn-only paths (interactive PTY, ACP client terminals) instead of failing; the cmd fallback is never used to wrap user-shell commands — brush runs the POSIX line directly. - Added a selectable voice setting for `/live` realtime sessions ([#6566](https://github.com/can1357/oh-my-pi/issues/6566)). -### Fixed - -- Native compaction preserves provider-native success and non-authentication failure semantics while retaining authenticated cross-provider fallback when the native provider rejects credentials. ## [17.1.4] - 2026-07-26 diff --git a/packages/coding-agent/src/session/session-advisors.ts b/packages/coding-agent/src/session/session-advisors.ts index 695d7c95a..b127e823e 100644 --- a/packages/coding-agent/src/session/session-advisors.ts +++ b/packages/coding-agent/src/session/session-advisors.ts @@ -1342,6 +1342,7 @@ export class SessionAdvisors { let compactResult: CompactionResult | undefined; let lastError: unknown; + let nativeCompactionFailure: { error: NativeCompactionError; provider: string } | undefined; // Instrument the advisor's overflow-compaction one-shot like the primary // compaction path so the advisor model's maintenance call also emits spans. const telemetry = resolveTelemetry(agent.telemetry, advisorProviderSessionId); @@ -1353,6 +1354,9 @@ export class SessionAdvisors { }); for (const candidate of candidates) { + if (nativeCompactionFailure && candidate.provider !== nativeCompactionFailure.provider) { + throw nativeCompactionFailure.error; + } const apiKey = await this.#host.modelRegistry.getApiKey(candidate, advisorProviderSessionId, { signal }); if (!apiKey) continue; // The advisor overflow-compaction one-shot bypasses the advisor `Agent`, @@ -1387,11 +1391,17 @@ export class SessionAdvisors { } catch (error) { if (signal.aborted) throw error; const id = AIError.classify(error, candidate.api); - if (error instanceof NativeCompactionError && !AIError.is(id, AIError.Flag.AuthFailed)) throw error; + if (error instanceof NativeCompactionError && !AIError.is(id, AIError.Flag.AuthFailed)) { + nativeCompactionFailure ??= { error, provider: candidate.provider }; + lastError = nativeCompactionFailure.error; + continue; + } lastError = error; } } + if (nativeCompactionFailure) throw nativeCompactionFailure.error; + if (!compactResult) { logger.warn("Advisor compaction failed, falling back to re-prime", { error: String(lastError) }); return true; diff --git a/packages/coding-agent/test/advisor-context-maintenance.test.ts b/packages/coding-agent/test/advisor-context-maintenance.test.ts index d37c6b8a7..46dd84ee2 100644 --- a/packages/coding-agent/test/advisor-context-maintenance.test.ts +++ b/packages/coding-agent/test/advisor-context-maintenance.test.ts @@ -134,8 +134,11 @@ describe("AgentSession advisor context maintenance", () => { responses: [{ content: ["advisor reviewed current update"] }], }); const nativeModel = getBundledModel("openai", "gpt-5"); - const fallbackModel = getBundledModel("anthropic", "claude-sonnet-4-5"); - if (!nativeModel || !fallbackModel) throw new Error("Expected bundled compaction models"); + const sameProviderModel = getBundledModel("openai", "gpt-5-mini"); + const crossProviderModel = getBundledModel("anthropic", "claude-sonnet-4-5"); + if (!nativeModel || !sameProviderModel || !crossProviderModel) { + throw new Error("Expected bundled compaction models"); + } authStorage.setRuntimeApiKey(nativeModel.provider, "openai-key"); const modelRegistry = new ModelRegistry(authStorage, tempDir.join("models.yml")); @@ -146,7 +149,8 @@ describe("AgentSession advisor context maintenance", () => { "contextPromotion.enabled": false, }); settings.setModelRole("advisor", `${nativeModel.provider}/${nativeModel.id}`); - settings.setModelRole("smol", `${fallbackModel.provider}/${fallbackModel.id}`); + settings.setModelRole("smol", `${sameProviderModel.provider}/${sameProviderModel.id}`); + settings.setModelRole("slow", `${crossProviderModel.provider}/${crossProviderModel.id}`); const agent = new Agent({ getApiKey: () => "test-key", initialState: { model: primaryMock, systemPrompt: [], tools: [] }, @@ -165,12 +169,16 @@ describe("AgentSession advisor context maintenance", () => { if (!advisor) throw new Error("Expected advisor agent to be active"); advisor.setModel(nativeModel); vi.spyOn(modelRegistry, "getApiKey").mockResolvedValue("test-key"); - vi.spyOn(modelRegistry, "getAvailable").mockReturnValue([nativeModel, fallbackModel]); + vi.spyOn(modelRegistry, "getAvailable").mockReturnValue([ + nativeModel, + sameProviderModel, + crossProviderModel, + ]); advisor.state.messages.push( usageAnchor(advisorMock, Date.now() - 2_000), usageAnchor(advisorMock, Date.now() - 1_000), ); - return { advisor, fallbackModel, nativeModel }; + return { advisor, crossProviderModel, nativeModel, sameProviderModel }; } it("maintains a 371,200-token cached advisor context before the 372,000-token window", async () => { @@ -405,13 +413,13 @@ describe("AgentSession advisor context maintenance", () => { } }); - it("does not cross providers after a non-auth native advisor compaction failure", async () => { - const { advisor, fallbackModel, nativeModel } = createAdvisorFallbackHarness(); + it("continues same-provider advisor candidates but stops before crossing providers on non-auth failure", async () => { + const { advisor, crossProviderModel, nativeModel, sameProviderModel } = createAdvisorFallbackHarness(); const compactSpy = vi.spyOn(compactionModule, "compact").mockImplementation(async (preparation, model) => { - if (model.provider === nativeModel.provider && model.id === nativeModel.id) { + if (model.provider === nativeModel.provider || model.provider === sameProviderModel.provider) { throw new compactionModule.NativeCompactionError(new Error("V2 native compaction transport failed")); } - if (model.provider !== fallbackModel.provider || model.id !== fallbackModel.id) { + if (model.provider !== crossProviderModel.provider || model.id !== crossProviderModel.id) { throw new Error(`Unexpected compaction model ${model.provider}/${model.id}`); } return { @@ -426,19 +434,20 @@ describe("AgentSession advisor context maintenance", () => { expect(compactSpy.mock.calls.map(([, model]) => `${model.provider}/${model.id}`)).toEqual([ `${nativeModel.provider}/${nativeModel.id}`, + `${sameProviderModel.provider}/${sameProviderModel.id}`, ]); expect(JSON.stringify(advisor.state.messages)).toContain("prior advisor output"); }); - it("allows advisor compaction to try another provider after an auth-classified native failure", async () => { - const { advisor, fallbackModel, nativeModel } = createAdvisorFallbackHarness(); + it("allows advisor compaction to cross providers after auth-classified native failures", async () => { + const { advisor, crossProviderModel, nativeModel, sameProviderModel } = createAdvisorFallbackHarness(); const compactSpy = vi.spyOn(compactionModule, "compact").mockImplementation(async (preparation, model) => { - if (model.provider === nativeModel.provider && model.id === nativeModel.id) { + if (model.provider === nativeModel.provider || model.provider === sameProviderModel.provider) { throw new compactionModule.NativeCompactionError( Object.assign(new Error("native compaction authentication failed"), { status: 401 }), ); } - if (model.provider !== fallbackModel.provider || model.id !== fallbackModel.id) { + if (model.provider !== crossProviderModel.provider || model.id !== crossProviderModel.id) { throw new Error(`Unexpected compaction model ${model.provider}/${model.id}`); } return { @@ -453,7 +462,8 @@ describe("AgentSession advisor context maintenance", () => { expect(compactSpy.mock.calls.map(([, model]) => `${model.provider}/${model.id}`)).toEqual([ `${nativeModel.provider}/${nativeModel.id}`, - `${fallbackModel.provider}/${fallbackModel.id}`, + `${sameProviderModel.provider}/${sameProviderModel.id}`, + `${crossProviderModel.provider}/${crossProviderModel.id}`, ]); expect(JSON.stringify(advisor.state.messages)).toContain("authenticated fallback summary"); }); From fe59a27cc82f2373ab9b5d114df48b16327aee07 Mon Sep 17 00:00:00 2001 From: usr-bin-roygbiv Date: Tue, 28 Jul 2026 11:14:20 +0000 Subject: [PATCH 12/22] style(coding-agent): format advisor fallback tests (cherry picked from commit 09cf89422eaa39319652d55a502412125c94856e) --- .../coding-agent/test/advisor-context-maintenance.test.ts | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/packages/coding-agent/test/advisor-context-maintenance.test.ts b/packages/coding-agent/test/advisor-context-maintenance.test.ts index 46dd84ee2..cfa8efbd2 100644 --- a/packages/coding-agent/test/advisor-context-maintenance.test.ts +++ b/packages/coding-agent/test/advisor-context-maintenance.test.ts @@ -169,11 +169,7 @@ describe("AgentSession advisor context maintenance", () => { if (!advisor) throw new Error("Expected advisor agent to be active"); advisor.setModel(nativeModel); vi.spyOn(modelRegistry, "getApiKey").mockResolvedValue("test-key"); - vi.spyOn(modelRegistry, "getAvailable").mockReturnValue([ - nativeModel, - sameProviderModel, - crossProviderModel, - ]); + vi.spyOn(modelRegistry, "getAvailable").mockReturnValue([nativeModel, sameProviderModel, crossProviderModel]); advisor.state.messages.push( usageAnchor(advisorMock, Date.now() - 2_000), usageAnchor(advisorMock, Date.now() - 1_000), From c83e506954bfd7e1f22086c301585acf1e467439 Mon Sep 17 00:00:00 2001 From: usr-bin-roygbiv Date: Tue, 28 Jul 2026 11:33:06 +0000 Subject: [PATCH 13/22] fix(compaction): require native-capable fallbacks (cherry picked from commit 6ac4efd9d2fe37fb50be6e9ba6664e8924bce602) --- packages/agent/src/compaction/compaction.ts | 12 ++++ .../src/session/session-advisors.ts | 9 ++- .../src/session/session-maintenance.ts | 7 ++- .../test/advisor-context-maintenance.test.ts | 59 ++++++++++++++++++- ...issue-986-compaction-auth-fallback.test.ts | 32 +++++++++- 5 files changed, 112 insertions(+), 7 deletions(-) diff --git a/packages/agent/src/compaction/compaction.ts b/packages/agent/src/compaction/compaction.ts index 6f9999cde..2030cca54 100644 --- a/packages/agent/src/compaction/compaction.ts +++ b/packages/agent/src/compaction/compaction.ts @@ -203,6 +203,18 @@ export const DEFAULT_COMPACTION_SETTINGS: CompactionSettings = { v2RetainedMessageBudget: V2_RETAINED_MESSAGE_TOKEN_BUDGET, }; +/** Whether a compaction candidate preserves provider-native transport under the effective settings. */ +export function shouldUseProviderNativeCompaction( + model: Model, + settings: Pick, +): boolean { + if (settings.remoteEnabled === false) return false; + return ( + shouldUseOpenAiRemoteCompaction(model) || + (settings.remoteStreamingV2Enabled !== false && shouldUseCompactionV2Streaming(model)) + ); +} + // ============================================================================ // Token calculation // ============================================================================ diff --git a/packages/coding-agent/src/session/session-advisors.ts b/packages/coding-agent/src/session/session-advisors.ts index b127e823e..8d09bb6f2 100644 --- a/packages/coding-agent/src/session/session-advisors.ts +++ b/packages/coding-agent/src/session/session-advisors.ts @@ -20,6 +20,7 @@ import { prepareCompaction, type SessionMessageEntry, shouldCompact, + shouldUseProviderNativeCompaction, } from "@oh-my-pi/pi-agent-core/compaction"; import type { AssistantMessage, @@ -1354,7 +1355,11 @@ export class SessionAdvisors { }); for (const candidate of candidates) { - if (nativeCompactionFailure && candidate.provider !== nativeCompactionFailure.provider) { + if ( + nativeCompactionFailure && + (candidate.provider !== nativeCompactionFailure.provider || + !shouldUseProviderNativeCompaction(candidate, compactionSettings)) + ) { throw nativeCompactionFailure.error; } const apiKey = await this.#host.modelRegistry.getApiKey(candidate, advisorProviderSessionId, { signal }); @@ -1400,7 +1405,7 @@ export class SessionAdvisors { } } - if (nativeCompactionFailure) throw nativeCompactionFailure.error; + if (!compactResult && nativeCompactionFailure) throw nativeCompactionFailure.error; if (!compactResult) { logger.warn("Advisor compaction failed, falling back to re-prime", { error: String(lastError) }); diff --git a/packages/coding-agent/src/session/session-maintenance.ts b/packages/coding-agent/src/session/session-maintenance.ts index 02c152362..d3a83d903 100644 --- a/packages/coding-agent/src/session/session-maintenance.ts +++ b/packages/coding-agent/src/session/session-maintenance.ts @@ -35,6 +35,7 @@ import { type SummaryOptions, shouldCompact, shouldUseOpenAiRemoteCompaction, + shouldUseProviderNativeCompaction, } from "@oh-my-pi/pi-agent-core/compaction"; import { DEFAULT_PRUNE_CONFIG, @@ -2489,7 +2490,11 @@ export class SessionMaintenance { for (let candidateIndex = 0; candidateIndex < candidates.length; candidateIndex++) { const candidate = candidates[candidateIndex]; - if (nativeCompactionFailure && candidate.provider !== nativeCompactionFailure.provider) { + if ( + nativeCompactionFailure && + (candidate.provider !== nativeCompactionFailure.provider || + !shouldUseProviderNativeCompaction(candidate, preparation.settings)) + ) { throw nativeCompactionFailure.error; } const hasMoreCandidates = candidateIndex < candidates.length - 1; diff --git a/packages/coding-agent/test/advisor-context-maintenance.test.ts b/packages/coding-agent/test/advisor-context-maintenance.test.ts index cfa8efbd2..ec2380cea 100644 --- a/packages/coding-agent/test/advisor-context-maintenance.test.ts +++ b/packages/coding-agent/test/advisor-context-maintenance.test.ts @@ -124,7 +124,7 @@ describe("AgentSession advisor context maintenance", () => { }; } - function createAdvisorFallbackHarness() { + function createAdvisorFallbackHarness(options?: { sameProviderNativeEnabled?: boolean }) { const primaryMock = createMockModel({ provider: "anthropic", responses: [{ content: ["primary complete"] }], @@ -134,7 +134,11 @@ describe("AgentSession advisor context maintenance", () => { responses: [{ content: ["advisor reviewed current update"] }], }); const nativeModel = getBundledModel("openai", "gpt-5"); - const sameProviderModel = getBundledModel("openai", "gpt-5-mini"); + const sameProviderBase = getBundledModel("openai", "gpt-5-mini"); + const sameProviderModel = + sameProviderBase && options?.sameProviderNativeEnabled === false + ? { ...sameProviderBase, remoteCompaction: { ...sameProviderBase.remoteCompaction, enabled: false } } + : sameProviderBase; const crossProviderModel = getBundledModel("anthropic", "claude-sonnet-4-5"); if (!nativeModel || !sameProviderModel || !crossProviderModel) { throw new Error("Expected bundled compaction models"); @@ -435,6 +439,57 @@ describe("AgentSession advisor context maintenance", () => { expect(JSON.stringify(advisor.state.messages)).toContain("prior advisor output"); }); + it("applies a successful same-provider native advisor fallback", async () => { + const { advisor, crossProviderModel, nativeModel, sameProviderModel } = createAdvisorFallbackHarness(); + const compactSpy = vi.spyOn(compactionModule, "compact").mockImplementation(async (preparation, model) => { + if (model.provider === nativeModel.provider && model.id === nativeModel.id) { + throw new compactionModule.NativeCompactionError(new Error("V2 native compaction transport failed")); + } + if (model.provider === sameProviderModel.provider && model.id === sameProviderModel.id) { + return { + summary: "same-provider native summary", + shortSummary: "same-provider native", + firstKeptEntryId: preparation.firstKeptEntryId, + tokensBefore: 42, + }; + } + throw new Error(`Unexpected cross-provider compaction ${crossProviderModel.provider}/${crossProviderModel.id}`); + }); + + await session.prompt("small current update"); + + expect(compactSpy.mock.calls.map(([, model]) => `${model.provider}/${model.id}`)).toEqual([ + `${nativeModel.provider}/${nativeModel.id}`, + `${sameProviderModel.provider}/${sameProviderModel.id}`, + ]); + expect(JSON.stringify(advisor.state.messages)).toContain("same-provider native summary"); + }); + + it("stops before a same-provider advisor candidate with native compaction disabled", async () => { + const { advisor, nativeModel, sameProviderModel } = createAdvisorFallbackHarness({ + sameProviderNativeEnabled: false, + }); + const compactSpy = vi.spyOn(compactionModule, "compact").mockImplementation(async (preparation, model) => { + if (model.provider === nativeModel.provider && model.id === nativeModel.id) { + throw new compactionModule.NativeCompactionError(new Error("V2 native compaction transport failed")); + } + return { + summary: "generic same-provider summary", + shortSummary: "generic same-provider", + firstKeptEntryId: preparation.firstKeptEntryId, + tokensBefore: 42, + }; + }); + + await session.prompt("small current update"); + + expect(compactSpy.mock.calls.map(([, model]) => `${model.provider}/${model.id}`)).toEqual([ + `${nativeModel.provider}/${nativeModel.id}`, + ]); + expect(JSON.stringify(advisor.state.messages)).not.toContain("generic same-provider summary"); + expect(sameProviderModel.remoteCompaction?.enabled).toBe(false); + }); + it("allows advisor compaction to cross providers after auth-classified native failures", async () => { const { advisor, crossProviderModel, nativeModel, sameProviderModel } = createAdvisorFallbackHarness(); const compactSpy = vi.spyOn(compactionModule, "compact").mockImplementation(async (preparation, model) => { diff --git a/packages/coding-agent/test/issue-986-compaction-auth-fallback.test.ts b/packages/coding-agent/test/issue-986-compaction-auth-fallback.test.ts index b683f1efe..8423cbe8c 100644 --- a/packages/coding-agent/test/issue-986-compaction-auth-fallback.test.ts +++ b/packages/coding-agent/test/issue-986-compaction-auth-fallback.test.ts @@ -81,9 +81,13 @@ describe("issue #986 compaction auth fallback", () => { return { currentModel, fallbackModel }; } - async function createAutoNativeFallbackSession() { + async function createAutoNativeFallbackSession(options?: { sameProviderNativeEnabled?: boolean }) { const currentModel = getBundledModel("openai", "gpt-5"); - const sameProviderModel = getBundledModel("openai", "gpt-5-mini"); + const sameProviderBase = getBundledModel("openai", "gpt-5-mini"); + const sameProviderModel = + sameProviderBase && options?.sameProviderNativeEnabled === false + ? { ...sameProviderBase, remoteCompaction: { ...sameProviderBase.remoteCompaction, enabled: false } } + : sameProviderBase; const crossProviderModel = getBundledModel("anthropic", "claude-sonnet-4-5"); if (!currentModel || !sameProviderModel || !crossProviderModel) { throw new Error("Expected bundled native fallback test models"); @@ -184,6 +188,30 @@ describe("issue #986 compaction auth fallback", () => { ]); }); + it("stops auto-compaction before a same-provider candidate with native compaction disabled", async () => { + const { currentModel, sameProviderModel, triggerAutoCompaction } = await createAutoNativeFallbackSession({ + sameProviderNativeEnabled: false, + }); + const attemptedModels: string[] = []; + vi.spyOn(compactionModule, "compact").mockImplementation(async (preparation, model) => { + attemptedModels.push(`${model.provider}/${model.id}`); + if (model.provider === currentModel.provider && model.id === currentModel.id) { + throw new compactionModule.NativeCompactionError(new Error("native compaction transport failed")); + } + return { + summary: "generic same-provider summary", + shortSummary: "generic same-provider", + firstKeptEntryId: preparation.firstKeptEntryId, + tokensBefore: 42, + }; + }); + + await triggerAutoCompaction(); + + expect(attemptedModels).toEqual([`${currentModel.provider}/${currentModel.id}`]); + expect(sameProviderModel.remoteCompaction?.enabled).toBe(false); + }); + it("preserves cross-provider auto-compaction fallback for auth-classified native failures", async () => { const { crossProviderModel, currentModel, sameProviderModel, triggerAutoCompaction } = await createAutoNativeFallbackSession(); From 66c8f27d23ebe9e8f8985caf881f10f7b06bf925 Mon Sep 17 00:00:00 2001 From: usr-bin-roygbiv Date: Tue, 28 Jul 2026 11:33:59 +0000 Subject: [PATCH 14/22] style(compaction): format fallback repairs (cherry picked from commit bf909c728adf80b1ad4c70f79701f6057138c6d9) --- packages/agent/src/compaction/compaction.ts | 4 +++- .../coding-agent/test/advisor-context-maintenance.test.ts | 4 +++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/agent/src/compaction/compaction.ts b/packages/agent/src/compaction/compaction.ts index 2030cca54..3de6e3165 100644 --- a/packages/agent/src/compaction/compaction.ts +++ b/packages/agent/src/compaction/compaction.ts @@ -738,7 +738,9 @@ function resolveCompactionEffort(model: Model, level: ThinkingLevel | undefined) */ function createSummarizationError(prefix: string, response: AssistantMessage): Error { const text = `${prefix}: ${response.errorMessage || "Unknown error"}`; - return response.errorStatus === undefined ? new Error(text) : new AIError.ProviderHttpError(text, response.errorStatus); + return response.errorStatus === undefined + ? new Error(text) + : new AIError.ProviderHttpError(text, response.errorStatus); } function shouldRetryHandoffWithAutoToolChoice(response: AssistantMessage): boolean { diff --git a/packages/coding-agent/test/advisor-context-maintenance.test.ts b/packages/coding-agent/test/advisor-context-maintenance.test.ts index ec2380cea..575579d68 100644 --- a/packages/coding-agent/test/advisor-context-maintenance.test.ts +++ b/packages/coding-agent/test/advisor-context-maintenance.test.ts @@ -453,7 +453,9 @@ describe("AgentSession advisor context maintenance", () => { tokensBefore: 42, }; } - throw new Error(`Unexpected cross-provider compaction ${crossProviderModel.provider}/${crossProviderModel.id}`); + throw new Error( + `Unexpected cross-provider compaction ${crossProviderModel.provider}/${crossProviderModel.id}`, + ); }); await session.prompt("small current update"); From 792e8a06b16233c1a17dda86c703fee9e09bfb43 Mon Sep 17 00:00:00 2001 From: usr-bin-roygbiv Date: Tue, 28 Jul 2026 12:32:16 +0000 Subject: [PATCH 15/22] docs: keep changes under unreleased (cherry picked from commit 8ac4d11932c01d37881963f7bc97cb9a0436ae11) --- packages/coding-agent/CHANGELOG.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index ca75f75cf..47e9b8ee2 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -42,6 +42,9 @@ - Added `fastModeEnabled` and `fastModeActive` to RPC `get_state` responses. - Fixed RPC fast-mode state reporting after direct Anthropic rejects `speed: "fast"`, while allowing explicit re-enable requests to retry priority service. - Added opt-in subagent access to `checkpoint`, `rewind`, `learn`, and `manage_skill` when explicitly listed in an agent definition's `tools:` frontmatter. Listing one of `checkpoint`/`rewind` auto-includes the other. Settings (`checkpoint.enabled`, `autolearn.enabled`) remain master toggles. +### Fixed + +- Native compaction preserves provider-native success and non-authentication failure semantics while retaining authenticated cross-provider fallback when the native provider rejects credentials. ## [17.1.8] - 2026-07-28 @@ -77,9 +80,6 @@ - Fixed omp worktree clear prematurely deleting active task-isolation sandboxes owned by running subagents. - Fixed /vibe mode preventing the director from completing parent tasks after verifying worker results by keeping the built-in todo tool active. - Fixed numeric GitHub issue and pull request autocomplete being suppressed inside skill slash-command arguments. -### Fixed - -- Native compaction preserves provider-native success and non-authentication failure semantics while retaining authenticated cross-provider fallback when the native provider rejects credentials. ## [17.1.7] - 2026-07-27 From d084697faeb0c05de31ac0739bfc652d986203cf Mon Sep 17 00:00:00 2001 From: usr-bin-roygbiv Date: Tue, 28 Jul 2026 13:16:41 +0000 Subject: [PATCH 16/22] fix(compaction): retry transient native failures (cherry picked from commit 293c96dfa479de13995d581caec33b3c90b5b3ca) --- .../src/session/session-maintenance.ts | 22 ++++++++---- ...issue-986-compaction-auth-fallback.test.ts | 35 +++++++++++++++++++ 2 files changed, 50 insertions(+), 7 deletions(-) diff --git a/packages/coding-agent/src/session/session-maintenance.ts b/packages/coding-agent/src/session/session-maintenance.ts index d3a83d903..e0c3a9f86 100644 --- a/packages/coding-agent/src/session/session-maintenance.ts +++ b/packages/coding-agent/src/session/session-maintenance.ts @@ -2537,16 +2537,14 @@ export class SessionMaintenance { } const message = error instanceof Error ? error.message : String(error); - const id = AIError.classify(error, candidate.api); + const id = AIError.classify( + error instanceof NativeCompactionError ? error.cause : error, + candidate.api, + ); if (AIError.is(id, AIError.Flag.AuthFailed)) { lastError = this.#buildCompactionAuthError(); break; } - if (error instanceof NativeCompactionError) { - nativeCompactionFailure ??= { error, provider: candidate.provider }; - lastError = nativeCompactionFailure.error; - break; - } if (AIError.is(id, AIError.Flag.Timeout)) { logger.warn( hasMoreCandidates @@ -2569,7 +2567,12 @@ export class SessionMaintenance { AIError.is(id, AIError.Flag.Transient) || AIError.is(id, AIError.Flag.UsageLimit)); if (!shouldRetry) { - lastError = error; + if (error instanceof NativeCompactionError) { + nativeCompactionFailure ??= { error, provider: candidate.provider }; + lastError = nativeCompactionFailure.error; + } else { + lastError = error; + } break; } @@ -2579,6 +2582,11 @@ export class SessionMaintenance { // If retry delay is too long (>30s), try next candidate instead of waiting const maxAcceptableDelayMs = 30_000; if (delayMs > maxAcceptableDelayMs && hasMoreCandidates) { + if (error instanceof NativeCompactionError) { + nativeCompactionFailure ??= { error, provider: candidate.provider }; + lastError = nativeCompactionFailure.error; + break; + } logger.warn("Auto-compaction retry delay too long, trying next model", { delayMs, retryAfterMs, diff --git a/packages/coding-agent/test/issue-986-compaction-auth-fallback.test.ts b/packages/coding-agent/test/issue-986-compaction-auth-fallback.test.ts index 8423cbe8c..a05649a3b 100644 --- a/packages/coding-agent/test/issue-986-compaction-auth-fallback.test.ts +++ b/packages/coding-agent/test/issue-986-compaction-auth-fallback.test.ts @@ -1,7 +1,9 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "bun:test"; import * as path from "node:path"; +import { scheduler } from "node:timers/promises"; import { Agent } from "@oh-my-pi/pi-agent-core"; import * as compactionModule from "@oh-my-pi/pi-agent-core/compaction"; +import * as AIError from "@oh-my-pi/pi-ai/error"; import { getBundledModel } from "@oh-my-pi/pi-catalog/models"; import { ModelRegistry } from "@oh-my-pi/pi-coding-agent/config/model-registry"; import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; @@ -188,6 +190,39 @@ describe("issue #986 compaction auth fallback", () => { ]); }); + it("retries a transient native compaction failure on the same candidate", async () => { + const { currentModel, triggerAutoCompaction } = await createAutoNativeFallbackSession(); + session.settings.set("retry.enabled", true); + session.settings.set("retry.baseDelayMs", 1); + session.settings.set("retry.maxRetries", 1); + const waitSpy = vi.spyOn(scheduler, "wait").mockResolvedValue(undefined); + const attemptedModels: string[] = []; + vi.spyOn(compactionModule, "compact").mockImplementation(async (preparation, model) => { + attemptedModels.push(`${model.provider}/${model.id}`); + if (model.provider !== currentModel.provider || model.id !== currentModel.id) { + throw new Error(`Unexpected compaction model ${model.provider}/${model.id}`); + } + if (attemptedModels.length === 1) { + throw new compactionModule.NativeCompactionError( + new AIError.ProviderHttpError("native compaction temporarily unavailable", 503), + ); + } + return { + summary: "native retry summary", + shortSummary: "native retry", + firstKeptEntryId: preparation.firstKeptEntryId, + tokensBefore: 42, + }; + }); + + await triggerAutoCompaction(); + + expect(attemptedModels).toEqual([ + `${currentModel.provider}/${currentModel.id}`, + `${currentModel.provider}/${currentModel.id}`, + ]); + expect(waitSpy).toHaveBeenCalledTimes(1); + }); it("stops auto-compaction before a same-provider candidate with native compaction disabled", async () => { const { currentModel, sameProviderModel, triggerAutoCompaction } = await createAutoNativeFallbackSession({ sameProviderNativeEnabled: false, From 4d3f2e32b3798ece7ab59036f29fac0836d2f7ca Mon Sep 17 00:00:00 2001 From: usr-bin-roygbiv Date: Tue, 28 Jul 2026 13:27:52 +0000 Subject: [PATCH 17/22] fix(compaction): preserve native timeout boundaries (cherry picked from commit 2a5950036f6c671004f2ddf1d2054a481a53cfe8) --- .../src/session/session-maintenance.ts | 16 +++++++--- ...issue-986-compaction-auth-fallback.test.ts | 29 +++++++++++++++++++ 2 files changed, 41 insertions(+), 4 deletions(-) diff --git a/packages/coding-agent/src/session/session-maintenance.ts b/packages/coding-agent/src/session/session-maintenance.ts index e0c3a9f86..78c532578 100644 --- a/packages/coding-agent/src/session/session-maintenance.ts +++ b/packages/coding-agent/src/session/session-maintenance.ts @@ -2546,16 +2546,24 @@ export class SessionMaintenance { break; } if (AIError.is(id, AIError.Flag.Timeout)) { + const nativeFailure = error instanceof NativeCompactionError; logger.warn( - hasMoreCandidates - ? "Auto-compaction summarization timed out, trying next model" - : "Auto-compaction summarization timed out, not retrying same model", + nativeFailure + ? "Provider-native auto-compaction timed out, preserving native failure" + : hasMoreCandidates + ? "Auto-compaction summarization timed out, trying next model" + : "Auto-compaction summarization timed out, not retrying same model", { error: message, model: `${candidate.provider}/${candidate.id}`, }, ); - lastError = error; + if (nativeFailure) { + nativeCompactionFailure ??= { error, provider: candidate.provider }; + lastError = nativeCompactionFailure.error; + } else { + lastError = error; + } break; } diff --git a/packages/coding-agent/test/issue-986-compaction-auth-fallback.test.ts b/packages/coding-agent/test/issue-986-compaction-auth-fallback.test.ts index a05649a3b..ace5f61f7 100644 --- a/packages/coding-agent/test/issue-986-compaction-auth-fallback.test.ts +++ b/packages/coding-agent/test/issue-986-compaction-auth-fallback.test.ts @@ -223,6 +223,35 @@ describe("issue #986 compaction auth fallback", () => { ]); expect(waitSpy).toHaveBeenCalledTimes(1); }); + + it("preserves native timeout failures before crossing providers", async () => { + const { crossProviderModel, currentModel, sameProviderModel, triggerAutoCompaction } = + await createAutoNativeFallbackSession(); + const attemptedModels: string[] = []; + vi.spyOn(compactionModule, "compact").mockImplementation(async (preparation, model) => { + attemptedModels.push(`${model.provider}/${model.id}`); + if (model.provider === currentModel.provider || model.provider === sameProviderModel.provider) { + throw new compactionModule.NativeCompactionError(new Error("provider stream stall timeout")); + } + if (model.provider !== crossProviderModel.provider || model.id !== crossProviderModel.id) { + throw new Error(`Unexpected compaction model ${model.provider}/${model.id}`); + } + return { + summary: "cross-provider summary", + shortSummary: "cross-provider", + firstKeptEntryId: preparation.firstKeptEntryId, + tokensBefore: 42, + }; + }); + + await triggerAutoCompaction(); + + expect(attemptedModels).toEqual([ + `${currentModel.provider}/${currentModel.id}`, + `${sameProviderModel.provider}/${sameProviderModel.id}`, + ]); + }); + it("stops auto-compaction before a same-provider candidate with native compaction disabled", async () => { const { currentModel, sameProviderModel, triggerAutoCompaction } = await createAutoNativeFallbackSession({ sameProviderNativeEnabled: false, From 7fff8869a0648cc96c0eb0b0caa6a774a75861d2 Mon Sep 17 00:00:00 2001 From: usr-bin-roygbiv Date: Tue, 28 Jul 2026 14:47:57 +0000 Subject: [PATCH 18/22] fix: skip unauthenticated compaction candidates (cherry picked from commit fa5f7d73ec1f169f9b9952648195057caa24657e) --- .../src/session/session-advisors.ts | 5 ++- .../src/session/session-maintenance.ts | 6 +-- .../test/advisor-context-maintenance.test.ts | 36 +++++++++++++++++- ...issue-986-compaction-auth-fallback.test.ts | 37 ++++++++++++++++++- 4 files changed, 75 insertions(+), 9 deletions(-) diff --git a/packages/coding-agent/src/session/session-advisors.ts b/packages/coding-agent/src/session/session-advisors.ts index 8d09bb6f2..4365e2582 100644 --- a/packages/coding-agent/src/session/session-advisors.ts +++ b/packages/coding-agent/src/session/session-advisors.ts @@ -1355,6 +1355,8 @@ export class SessionAdvisors { }); for (const candidate of candidates) { + const apiKey = await this.#host.modelRegistry.getApiKey(candidate, advisorProviderSessionId, { signal }); + if (!apiKey) continue; if ( nativeCompactionFailure && (candidate.provider !== nativeCompactionFailure.provider || @@ -1362,8 +1364,7 @@ export class SessionAdvisors { ) { throw nativeCompactionFailure.error; } - const apiKey = await this.#host.modelRegistry.getApiKey(candidate, advisorProviderSessionId, { signal }); - if (!apiKey) continue; + // The advisor overflow-compaction one-shot bypasses the advisor `Agent`, // so its installed metadata resolver never runs. Emit the same // `metadata.user_id` identity here (resolved per candidate provider, diff --git a/packages/coding-agent/src/session/session-maintenance.ts b/packages/coding-agent/src/session/session-maintenance.ts index 78c532578..19cd40efa 100644 --- a/packages/coding-agent/src/session/session-maintenance.ts +++ b/packages/coding-agent/src/session/session-maintenance.ts @@ -2490,6 +2490,9 @@ export class SessionMaintenance { for (let candidateIndex = 0; candidateIndex < candidates.length; candidateIndex++) { const candidate = candidates[candidateIndex]; + const hasMoreCandidates = candidateIndex < candidates.length - 1; + const apiKey = await this.#host.modelRegistry.getApiKey(candidate, this.#host.sessionId()); + if (!apiKey) continue; if ( nativeCompactionFailure && (candidate.provider !== nativeCompactionFailure.provider || @@ -2497,9 +2500,6 @@ export class SessionMaintenance { ) { throw nativeCompactionFailure.error; } - const hasMoreCandidates = candidateIndex < candidates.length - 1; - const apiKey = await this.#host.modelRegistry.getApiKey(candidate, this.#host.sessionId()); - if (!apiKey) continue; let attempt = 0; while (true) { diff --git a/packages/coding-agent/test/advisor-context-maintenance.test.ts b/packages/coding-agent/test/advisor-context-maintenance.test.ts index 575579d68..a9755db38 100644 --- a/packages/coding-agent/test/advisor-context-maintenance.test.ts +++ b/packages/coding-agent/test/advisor-context-maintenance.test.ts @@ -172,13 +172,13 @@ describe("AgentSession advisor context maintenance", () => { const advisor = session.getAdvisorAgent(); if (!advisor) throw new Error("Expected advisor agent to be active"); advisor.setModel(nativeModel); - vi.spyOn(modelRegistry, "getApiKey").mockResolvedValue("test-key"); + const apiKeySpy = vi.spyOn(modelRegistry, "getApiKey").mockResolvedValue("test-key"); vi.spyOn(modelRegistry, "getAvailable").mockReturnValue([nativeModel, sameProviderModel, crossProviderModel]); advisor.state.messages.push( usageAnchor(advisorMock, Date.now() - 2_000), usageAnchor(advisorMock, Date.now() - 1_000), ); - return { advisor, crossProviderModel, nativeModel, sameProviderModel }; + return { advisor, apiKeySpy, crossProviderModel, nativeModel, sameProviderModel, settings }; } it("maintains a 371,200-token cached advisor context before the 372,000-token window", async () => { @@ -467,6 +467,38 @@ describe("AgentSession advisor context maintenance", () => { expect(JSON.stringify(advisor.state.messages)).toContain("same-provider native summary"); }); + it("skips unauthenticated advisor candidates before enforcing the native boundary", async () => { + const { advisor, apiKeySpy, crossProviderModel, nativeModel, sameProviderModel, settings } = + createAdvisorFallbackHarness(); + settings.setModelRole("smol", `${crossProviderModel.provider}/${crossProviderModel.id}`); + settings.setModelRole("slow", `${sameProviderModel.provider}/${sameProviderModel.id}`); + apiKeySpy.mockImplementation(async model => + model.provider === crossProviderModel.provider && model.id === crossProviderModel.id ? undefined : "test-key", + ); + const compactSpy = vi.spyOn(compactionModule, "compact").mockImplementation(async (preparation, model) => { + if (model.provider === nativeModel.provider && model.id === nativeModel.id) { + throw new compactionModule.NativeCompactionError(new Error("V2 native compaction transport failed")); + } + if (model.provider === sameProviderModel.provider && model.id === sameProviderModel.id) { + return { + summary: "authenticated same-provider advisor summary", + shortSummary: "authenticated same-provider advisor", + firstKeptEntryId: preparation.firstKeptEntryId, + tokensBefore: 42, + }; + } + throw new Error(`Unexpected advisor compaction model ${model.provider}/${model.id}`); + }); + + await session.prompt("small current update"); + + expect(compactSpy.mock.calls.map(([, model]) => `${model.provider}/${model.id}`)).toEqual([ + `${nativeModel.provider}/${nativeModel.id}`, + `${sameProviderModel.provider}/${sameProviderModel.id}`, + ]); + expect(JSON.stringify(advisor.state.messages)).toContain("authenticated same-provider advisor summary"); + }); + it("stops before a same-provider advisor candidate with native compaction disabled", async () => { const { advisor, nativeModel, sameProviderModel } = createAdvisorFallbackHarness({ sameProviderNativeEnabled: false, diff --git a/packages/coding-agent/test/issue-986-compaction-auth-fallback.test.ts b/packages/coding-agent/test/issue-986-compaction-auth-fallback.test.ts index ace5f61f7..1c8f1c51c 100644 --- a/packages/coding-agent/test/issue-986-compaction-auth-fallback.test.ts +++ b/packages/coding-agent/test/issue-986-compaction-auth-fallback.test.ts @@ -130,7 +130,7 @@ describe("issue #986 compaction auth fallback", () => { session.sessionManager.appendMessage(assistant); } vi.spyOn(modelRegistry, "getAvailable").mockReturnValue([currentModel, sameProviderModel, crossProviderModel]); - vi.spyOn(modelRegistry, "getApiKey").mockResolvedValue("test-key"); + const apiKeySpy = vi.spyOn(modelRegistry, "getApiKey").mockResolvedValue("test-key"); const triggerAutoCompaction = async (): Promise => { const { promise, resolve } = Promise.withResolvers(); @@ -159,7 +159,7 @@ describe("issue #986 compaction auth fallback", () => { await session.waitForIdle(); }; - return { crossProviderModel, currentModel, sameProviderModel, triggerAutoCompaction }; + return { apiKeySpy, crossProviderModel, currentModel, sameProviderModel, triggerAutoCompaction }; } it("continues same-provider native candidates but stops before crossing providers on non-auth failure", async () => { @@ -190,6 +190,39 @@ describe("issue #986 compaction auth fallback", () => { ]); }); + it("skips unauthenticated cross-provider candidates before enforcing the native boundary", async () => { + const { apiKeySpy, crossProviderModel, currentModel, sameProviderModel, triggerAutoCompaction } = + await createAutoNativeFallbackSession(); + session.settings.setModelRole("smol", `${crossProviderModel.provider}/${crossProviderModel.id}`); + session.settings.setModelRole("slow", `${sameProviderModel.provider}/${sameProviderModel.id}`); + apiKeySpy.mockImplementation(async model => + model.provider === crossProviderModel.provider ? undefined : "test-key", + ); + const attemptedModels: string[] = []; + vi.spyOn(compactionModule, "compact").mockImplementation(async (preparation, model) => { + attemptedModels.push(`${model.provider}/${model.id}`); + if (model.provider === currentModel.provider && model.id === currentModel.id) { + throw new compactionModule.NativeCompactionError(new Error("native compaction transport failed")); + } + if (model.provider === sameProviderModel.provider && model.id === sameProviderModel.id) { + return { + summary: "authenticated same-provider summary", + shortSummary: "authenticated same-provider", + firstKeptEntryId: preparation.firstKeptEntryId, + tokensBefore: 42, + }; + } + throw new Error(`Unexpected compaction model ${model.provider}/${model.id}`); + }); + + await triggerAutoCompaction(); + + expect(attemptedModels).toEqual([ + `${currentModel.provider}/${currentModel.id}`, + `${sameProviderModel.provider}/${sameProviderModel.id}`, + ]); + }); + it("retries a transient native compaction failure on the same candidate", async () => { const { currentModel, triggerAutoCompaction } = await createAutoNativeFallbackSession(); session.settings.set("retry.enabled", true); From 9dd8d3c6ceea9c8600a6bca6ceb28ed556c78c7f Mon Sep 17 00:00:00 2001 From: usr-bin-roygbiv Date: Tue, 28 Jul 2026 15:27:55 +0000 Subject: [PATCH 19/22] fix: preserve native compaction failures (cherry picked from commit d13e9f30a06cad347226d2fa377cab8c086debc3) --- .../src/session/session-maintenance.ts | 2 +- ...issue-986-compaction-auth-fallback.test.ts | 33 +++++++++++++++++++ 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/packages/coding-agent/src/session/session-maintenance.ts b/packages/coding-agent/src/session/session-maintenance.ts index 19cd40efa..dc24d4dfe 100644 --- a/packages/coding-agent/src/session/session-maintenance.ts +++ b/packages/coding-agent/src/session/session-maintenance.ts @@ -2542,7 +2542,7 @@ export class SessionMaintenance { candidate.api, ); if (AIError.is(id, AIError.Flag.AuthFailed)) { - lastError = this.#buildCompactionAuthError(); + if (!nativeCompactionFailure) lastError = this.#buildCompactionAuthError(); break; } if (AIError.is(id, AIError.Flag.Timeout)) { diff --git a/packages/coding-agent/test/issue-986-compaction-auth-fallback.test.ts b/packages/coding-agent/test/issue-986-compaction-auth-fallback.test.ts index 1c8f1c51c..b5b0ed692 100644 --- a/packages/coding-agent/test/issue-986-compaction-auth-fallback.test.ts +++ b/packages/coding-agent/test/issue-986-compaction-auth-fallback.test.ts @@ -190,6 +190,39 @@ describe("issue #986 compaction auth fallback", () => { ]); }); + it("preserves a native transport failure when a later same-provider candidate fails authentication", async () => { + const { apiKeySpy, crossProviderModel, currentModel, sameProviderModel, triggerAutoCompaction } = + await createAutoNativeFallbackSession(); + apiKeySpy.mockImplementation(async model => + model.provider === crossProviderModel.provider ? undefined : "test-key", + ); + const attemptedModels: string[] = []; + let errorMessage: string | undefined; + session.subscribe(event => { + if (event.type === "auto_compaction_end") errorMessage = event.errorMessage; + }); + vi.spyOn(compactionModule, "compact").mockImplementation(async (_preparation, model) => { + attemptedModels.push(`${model.provider}/${model.id}`); + if (model.provider === currentModel.provider && model.id === currentModel.id) { + throw new compactionModule.NativeCompactionError(new Error("native compaction transport failed")); + } + if (model.provider === sameProviderModel.provider && model.id === sameProviderModel.id) { + throw new compactionModule.NativeCompactionError( + Object.assign(new Error("native compaction authentication failed"), { status: 401 }), + ); + } + throw new Error(`Unexpected compaction model ${model.provider}/${model.id}`); + }); + + await triggerAutoCompaction(); + + expect(attemptedModels).toEqual([ + `${currentModel.provider}/${currentModel.id}`, + `${sameProviderModel.provider}/${sameProviderModel.id}`, + ]); + expect(errorMessage).toContain("native compaction transport failed"); + }); + it("skips unauthenticated cross-provider candidates before enforcing the native boundary", async () => { const { apiKeySpy, crossProviderModel, currentModel, sameProviderModel, triggerAutoCompaction } = await createAutoNativeFallbackSession(); From 64234e05c93f5825cf77637092e95584a53c4e13 Mon Sep 17 00:00:00 2001 From: usr-bin-roygbiv Date: Tue, 28 Jul 2026 15:46:12 +0000 Subject: [PATCH 20/22] fix: align manual native compaction fallback (cherry picked from commit 2d5397a52f6feaeee4136fe4e7d8271309043b75) --- .../src/session/session-maintenance.ts | 17 +++++++++-- ...issue-986-compaction-auth-fallback.test.ts | 29 +++++++++++++++++++ 2 files changed, 44 insertions(+), 2 deletions(-) diff --git a/packages/coding-agent/src/session/session-maintenance.ts b/packages/coding-agent/src/session/session-maintenance.ts index dc24d4dfe..6d2ee5db1 100644 --- a/packages/coding-agent/src/session/session-maintenance.ts +++ b/packages/coding-agent/src/session/session-maintenance.ts @@ -1460,10 +1460,18 @@ export class SessionMaintenance { const candidates = precomputedCandidates ?? this.#getCompactionModelCandidates(this.#host.modelRegistry.getAvailable()); const telemetry = resolveTelemetry(this.#host.agent.telemetry, this.#host.sessionId()); + let nativeCompactionFailure: { error: NativeCompactionError; provider: string } | undefined; for (const candidate of candidates) { const apiKey = await this.#host.modelRegistry.getApiKey(candidate, this.#host.sessionId()); if (!apiKey) continue; + if ( + nativeCompactionFailure && + (candidate.provider !== nativeCompactionFailure.provider || + !shouldUseProviderNativeCompaction(candidate, preparation.settings)) + ) { + throw nativeCompactionFailure.error; + } try { return await compact( @@ -1501,12 +1509,17 @@ export class SessionMaintenance { }, ); } catch (error) { - if (!AIError.is(AIError.classify(error, candidate.api), AIError.Flag.AuthFailed)) { - throw error; + const id = AIError.classify(error instanceof NativeCompactionError ? error.cause : error, candidate.api); + if (AIError.is(id, AIError.Flag.AuthFailed)) continue; + if (error instanceof NativeCompactionError) { + nativeCompactionFailure ??= { error, provider: candidate.provider }; + continue; } + throw error; } } + if (nativeCompactionFailure) throw nativeCompactionFailure.error; throw this.#buildCompactionAuthError(); } diff --git a/packages/coding-agent/test/issue-986-compaction-auth-fallback.test.ts b/packages/coding-agent/test/issue-986-compaction-auth-fallback.test.ts index b5b0ed692..27c5fe1b4 100644 --- a/packages/coding-agent/test/issue-986-compaction-auth-fallback.test.ts +++ b/packages/coding-agent/test/issue-986-compaction-auth-fallback.test.ts @@ -373,6 +373,35 @@ describe("issue #986 compaction auth fallback", () => { ]); }); + it("tries same-provider native candidates during manual compaction before crossing providers", async () => { + const { crossProviderModel, currentModel, sameProviderModel } = await createAutoNativeFallbackSession(); + const attemptedModels: string[] = []; + vi.spyOn(compactionModule, "compact").mockImplementation(async (preparation, model) => { + attemptedModels.push(`${model.provider}/${model.id}`); + if (model.provider === currentModel.provider && model.id === currentModel.id) { + throw new compactionModule.NativeCompactionError(new Error("native manual compaction failed")); + } + if (model.provider === sameProviderModel.provider && model.id === sameProviderModel.id) { + return { + summary: "same-provider manual summary", + shortSummary: "same-provider manual", + firstKeptEntryId: preparation.firstKeptEntryId, + tokensBefore: 42, + }; + } + throw new Error(`Unexpected compaction model ${model.provider}/${model.id}`); + }); + + const result = await session.compact(); + + expect(result.summary).toBe("same-provider manual summary"); + expect(attemptedModels).toEqual([ + `${currentModel.provider}/${currentModel.id}`, + `${sameProviderModel.provider}/${sameProviderModel.id}`, + ]); + expect(attemptedModels).not.toContain(`${crossProviderModel.provider}/${crossProviderModel.id}`); + }); + it("falls back across providers when native compaction receives auth_unavailable", async () => { const { currentModel, fallbackModel } = await createSession({ fallbackModelRole: "smol" }); const originalCompact = compactionModule.compact; From 17e678fa2d5105581f9d1d0de0ae26df0670a3be Mon Sep 17 00:00:00 2001 From: usr-bin-roygbiv Date: Tue, 28 Jul 2026 16:21:32 +0000 Subject: [PATCH 21/22] fix(agent): honor explicit compaction endpoint (cherry picked from commit 3bc5c578ddd2e626eda2049a660de7bffb674b52) --- packages/agent/src/compaction/compaction.ts | 2 +- packages/agent/test/remote-compaction.test.ts | 38 +++++++++++++++++++ 2 files changed, 39 insertions(+), 1 deletion(-) diff --git a/packages/agent/src/compaction/compaction.ts b/packages/agent/src/compaction/compaction.ts index 3de6e3165..e524f251e 100644 --- a/packages/agent/src/compaction/compaction.ts +++ b/packages/agent/src/compaction/compaction.ts @@ -1555,7 +1555,7 @@ export async function compact( } } - if (!usedRemoteCompaction && nativeCompactionError !== undefined) { + if (!usedRemoteCompaction && nativeCompactionError !== undefined && !summaryOptions.remoteEndpoint) { throw new NativeCompactionError(nativeCompactionError); } diff --git a/packages/agent/test/remote-compaction.test.ts b/packages/agent/test/remote-compaction.test.ts index c8f5947a1..fd3879d50 100644 --- a/packages/agent/test/remote-compaction.test.ts +++ b/packages/agent/test/remote-compaction.test.ts @@ -1864,6 +1864,44 @@ describe("compact() remote compaction failure handling", () => { }); }); + test("uses an explicit remote endpoint after provider-native compaction fails", async () => { + const completeSpy = vi.spyOn(ai, "completeSimple").mockResolvedValue(localSummaryMessage("local fallback")); + const preparation = makePreparation(); + preparation.settings = { + ...preparation.settings, + remoteEndpoint: "http://summary.test/v1/chat/completions", + remoteStreamingV2Enabled: true, + }; + const model = makeOpenAiModel({ + remoteCompaction: { enabled: true, v2StreamingEnabled: true }, + }); + const requestedUrls: string[] = []; + const fetchMock: FetchImpl = async input => { + const url = String(input); + requestedUrls.push(url); + if (url === preparation.settings.remoteEndpoint) { + const summary = + requestedUrls.filter(requested => requested === url).length === 1 + ? "configured remote history summary" + : "configured remote short summary"; + return Response.json({ choices: [{ message: { content: summary } }] }); + } + return new Response("native compaction unavailable", { status: 400, statusText: "Bad Request" }); + }; + + const result = await compact(preparation, model, "test-key", undefined, undefined, { fetch: fetchMock }); + + expect(requestedUrls.map(url => new URL(url).pathname)).toEqual([ + "/v1/responses", + "/v1/responses/compact", + "/v1/chat/completions", + "/v1/chat/completions", + ]); + expect(result.summary).toContain("configured remote history summary"); + expect(result.shortSummary).toBe("configured remote short summary"); + expect(completeSpy).not.toHaveBeenCalled(); + }); + test("native compaction server failure rejects without generic summarization", async () => { const completeSpy = vi.spyOn(ai, "completeSimple").mockResolvedValue(localSummaryMessage("local summary")); const fetchMock: FetchImpl = async () => From 28b33dd858c1142b9d57515c531ffc25e0f03774 Mon Sep 17 00:00:00 2001 From: can1357 Date: Thu, 30 Jul 2026 01:45:03 +0200 Subject: [PATCH 22/22] test(compaction): aligned provider isolation assertion --- packages/coding-agent/test/agent-session-handoff.test.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/coding-agent/test/agent-session-handoff.test.ts b/packages/coding-agent/test/agent-session-handoff.test.ts index a5f805895..abb8e84d6 100644 --- a/packages/coding-agent/test/agent-session-handoff.test.ts +++ b/packages/coding-agent/test/agent-session-handoff.test.ts @@ -854,7 +854,7 @@ describe("AgentSession handoff", () => { session.settings.set("contextPromotion.enabled", false); const attemptedCandidates: string[] = []; - const compactSpy = vi.spyOn(compactionModule, "compact").mockImplementation(async (_preparation, candidate) => { + vi.spyOn(compactionModule, "compact").mockImplementation(async (_preparation, candidate) => { attemptedCandidates.push(`${candidate.provider}/${candidate.id}`); throw new compactionModule.NativeCompactionError(new Error("native compaction transport failed")); }); @@ -868,8 +868,8 @@ describe("AgentSession handoff", () => { ), ); - expect(compactSpy).toHaveBeenCalledTimes(1); - expect(attemptedCandidates).toHaveLength(1); + expect(attemptedCandidates.length).toBeGreaterThan(0); + expect(new Set(attemptedCandidates.map(candidate => candidate.split("/", 1)[0]))).toHaveLength(1); }); it("keeps pre-prompt context-full checks aligned with provider-anchored usage", async () => { await session.dispose();