Merge PR #6731: fix: preserve provider-native compaction semantics (@usr-bin-roygbiv)
This commit is contained in:
@@ -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 (
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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}`,
|
||||
|
||||
Reference in New Issue
Block a user