diff --git a/packages/agent/CHANGELOG.md b/packages/agent/CHANGELOG.md index b4487a72a..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 diff --git a/packages/agent/src/compaction/compaction-v2-streaming.ts b/packages/agent/src/compaction/compaction-v2-streaming.ts index e14f6b422..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, @@ -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( + throw new AIError.ProviderHttpError( `V2 remote compaction failed (${response.status} ${response.statusText})`, response.status, { headers: response.headers, + cause, }, ); } @@ -560,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" || @@ -567,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/src/compaction/compaction.ts b/packages/agent/src/compaction/compaction.ts index cb1317b7e..e524f251e 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"; @@ -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 { @@ -202,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 // ============================================================================ @@ -725,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 ProviderHttpError(text, response.errorStatus); + return response.errorStatus === undefined + ? new Error(text) + : new AIError.ProviderHttpError(text, response.errorStatus); } function shouldRetryHandoffWithAutoToolChoice(response: AssistantMessage): boolean { @@ -1332,6 +1347,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. @@ -1406,6 +1432,7 @@ export async function compact( ...recentMessages, ]; let usedRemoteCompaction = false; + let nativeCompactionError: unknown; if ( settings.remoteEnabled !== false && settings.remoteStreamingV2Enabled !== false && @@ -1467,7 +1494,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 = 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, provider: model.provider, @@ -1517,7 +1545,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 = selectNativeCompactionError(nativeCompactionError, err); + logger.warn("OpenAI remote compaction failed", { error: err instanceof Error ? err.message : String(err), model: model.id, provider: model.provider, @@ -1526,6 +1555,10 @@ export async function compact( } } + if (!usedRemoteCompaction && nativeCompactionError !== undefined && !summaryOptions.remoteEndpoint) { + throw new NativeCompactionError(nativeCompactionError); + } + // Generate summaries (can be parallel if both needed) and merge into one let summary: string; 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/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/agent/test/remote-compaction.test.ts b/packages/agent/test/remote-compaction.test.ts index 155080b75..fd3879d50 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"; @@ -20,6 +21,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 +735,36 @@ describe("requestCompactionV2Streaming", () => { expect(attempts).toBe(2); }); + + test("does not retry and 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).toHaveBeenCalledTimes(1); + expect(error).toBeInstanceOf(AIError.ProviderHttpError); + expect(AIError.is(AIError.classify(error), AIError.Flag.AuthFailed)).toBe(true); + }); }); describe("Responses Lite remote compaction", () => { @@ -1687,6 +1719,78 @@ 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(); + 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 +1864,54 @@ describe("compact() remote compaction failure handling", () => { }); }); - test("remote compact server failure without abort still falls back to local summarization", async () => { + 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 () => 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 aa10b3f21..33eae9ebe 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -44,6 +44,9 @@ - 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. - Added a `browser.cdpUrl` setting that points browser automation at an already-running CDP endpoint by default, so `app.cdp_url` no longer has to be repeated on every call. Explicit `app` options still take precedence. +### 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 diff --git a/packages/coding-agent/src/session/session-advisors.ts b/packages/coding-agent/src/session/session-advisors.ts index b18d7db08..4365e2582 100644 --- a/packages/coding-agent/src/session/session-advisors.ts +++ b/packages/coding-agent/src/session/session-advisors.ts @@ -16,9 +16,11 @@ import { compactionContextTokens, createCompactionSummaryMessage, estimateTokens, + NativeCompactionError, prepareCompaction, type SessionMessageEntry, shouldCompact, + shouldUseProviderNativeCompaction, } from "@oh-my-pi/pi-agent-core/compaction"; import type { AssistantMessage, @@ -1341,6 +1343,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); @@ -1354,6 +1357,14 @@ 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 || + !shouldUseProviderNativeCompaction(candidate, compactionSettings)) + ) { + throw nativeCompactionFailure.error; + } + // 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, @@ -1385,10 +1396,18 @@ 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)) { + nativeCompactionFailure ??= { error, provider: candidate.provider }; + lastError = nativeCompactionFailure.error; + continue; + } lastError = error; } } + if (!compactResult && 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/src/session/session-maintenance.ts b/packages/coding-agent/src/session/session-maintenance.ts index df65f9a32..6d2ee5db1 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, @@ -34,6 +35,7 @@ import { type SummaryOptions, shouldCompact, shouldUseOpenAiRemoteCompaction, + shouldUseProviderNativeCompaction, } from "@oh-my-pi/pi-agent-core/compaction"; import { DEFAULT_PRUNE_CONFIG, @@ -1458,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( @@ -1499,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(); } @@ -2477,6 +2492,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", @@ -2490,6 +2506,13 @@ export class SessionMaintenance { 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 || + !shouldUseProviderNativeCompaction(candidate, preparation.settings)) + ) { + throw nativeCompactionFailure.error; + } let attempt = 0; while (true) { @@ -2527,22 +2550,33 @@ 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(); + if (!nativeCompactionFailure) lastError = this.#buildCompactionAuthError(); 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; } @@ -2554,7 +2588,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; } @@ -2564,6 +2603,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/advisor-context-maintenance.test.ts b/packages/coding-agent/test/advisor-context-maintenance.test.ts index 423565e7e..a9755db38 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,63 @@ describe("AgentSession advisor context maintenance", () => { }; } + function createAdvisorFallbackHarness(options?: { sameProviderNativeEnabled?: boolean }) { + 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 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"); + } + + 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", `${sameProviderModel.provider}/${sameProviderModel.id}`); + settings.setModelRole("slow", `${crossProviderModel.provider}/${crossProviderModel.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); + 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, apiKeySpy, crossProviderModel, nativeModel, sameProviderModel, settings }; + } + 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 +412,144 @@ describe("AgentSession advisor context maintenance", () => { expect((JSON.parse(userId) as { session_id?: string }).session_id).toBe(advisor.sessionId); } }); + + 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.provider === sameProviderModel.provider) { + throw new compactionModule.NativeCompactionError(new Error("V2 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 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("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("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, + }); + 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) => { + 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 !== 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 session.prompt("small current update"); + + expect(compactSpy.mock.calls.map(([, model]) => `${model.provider}/${model.id}`)).toEqual([ + `${nativeModel.provider}/${nativeModel.id}`, + `${sameProviderModel.provider}/${sameProviderModel.id}`, + `${crossProviderModel.provider}/${crossProviderModel.id}`, + ]); + expect(JSON.stringify(advisor.state.messages)).toContain("authenticated fallback summary"); + }); }); diff --git a/packages/coding-agent/test/agent-session-handoff.test.ts b/packages/coding-agent/test/agent-session-handoff.test.ts index 4c8031068..abb8e84d6 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[] = []; + 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(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(); authStorage.setRuntimeApiKey("openai", "test-key"); 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..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 @@ -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"; @@ -81,25 +83,361 @@ 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 () => { - 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)", - ); + async function createAutoNativeFallbackSession(options?: { sameProviderNativeEnabled?: boolean }) { + const currentModel = getBundledModel("openai", "gpt-5"); + 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"); + } + + 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]); + const apiKeySpy = 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 { apiKeySpy, 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 !== 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 { - summary: "fallback summary", - shortSummary: "fallback short summary", + summary: "cross-provider summary", + shortSummary: "cross-provider", firstKeptEntryId: preparation.firstKeptEntryId, tokensBefore: 42, - details: { provider: model.provider }, }; }); + + await triggerAutoCompaction(); + + expect(attemptedModels).toEqual([ + `${currentModel.provider}/${currentModel.id}`, + `${sameProviderModel.provider}/${sameProviderModel.id}`, + ]); + }); + + 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(); + 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); + 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("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, + }); + 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(); + 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("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; + 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 +447,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}`,