Merge PR #6731: fix: preserve provider-native compaction semantics (@usr-bin-roygbiv)

This commit is contained in:
can1357
2026-07-30 01:48:51 +02:00
12 changed files with 871 additions and 39 deletions
+4
View File
@@ -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
@@ -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<string, unknown>, 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 (
+37 -4
View File
@@ -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<CompactionSettings, "remoteEnabled" | "remoteStreamingV2Enabled">,
): 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;
+16
View File
@@ -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
+4 -2
View File
@@ -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,
},
);
}
+149 -7
View File
@@ -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();
});
});
+3
View File
@@ -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
@@ -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;
@@ -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,
@@ -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");
});
});
@@ -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");
@@ -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<void> => {
const { promise, resolve } = Promise.withResolvers<void>();
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}`,