diff --git a/packages/coding-agent/src/session/session-advisors.ts b/packages/coding-agent/src/session/session-advisors.ts index 8d09bb6f2..4365e2582 100644 --- a/packages/coding-agent/src/session/session-advisors.ts +++ b/packages/coding-agent/src/session/session-advisors.ts @@ -1355,6 +1355,8 @@ export class SessionAdvisors { }); for (const candidate of candidates) { + const apiKey = await this.#host.modelRegistry.getApiKey(candidate, advisorProviderSessionId, { signal }); + if (!apiKey) continue; if ( nativeCompactionFailure && (candidate.provider !== nativeCompactionFailure.provider || @@ -1362,8 +1364,7 @@ export class SessionAdvisors { ) { throw nativeCompactionFailure.error; } - const apiKey = await this.#host.modelRegistry.getApiKey(candidate, advisorProviderSessionId, { signal }); - if (!apiKey) continue; + // The advisor overflow-compaction one-shot bypasses the advisor `Agent`, // so its installed metadata resolver never runs. Emit the same // `metadata.user_id` identity here (resolved per candidate provider, diff --git a/packages/coding-agent/src/session/session-maintenance.ts b/packages/coding-agent/src/session/session-maintenance.ts index 78c532578..19cd40efa 100644 --- a/packages/coding-agent/src/session/session-maintenance.ts +++ b/packages/coding-agent/src/session/session-maintenance.ts @@ -2490,6 +2490,9 @@ export class SessionMaintenance { for (let candidateIndex = 0; candidateIndex < candidates.length; candidateIndex++) { const candidate = candidates[candidateIndex]; + const hasMoreCandidates = candidateIndex < candidates.length - 1; + const apiKey = await this.#host.modelRegistry.getApiKey(candidate, this.#host.sessionId()); + if (!apiKey) continue; if ( nativeCompactionFailure && (candidate.provider !== nativeCompactionFailure.provider || @@ -2497,9 +2500,6 @@ export class SessionMaintenance { ) { throw nativeCompactionFailure.error; } - const hasMoreCandidates = candidateIndex < candidates.length - 1; - const apiKey = await this.#host.modelRegistry.getApiKey(candidate, this.#host.sessionId()); - if (!apiKey) continue; let attempt = 0; while (true) { diff --git a/packages/coding-agent/test/advisor-context-maintenance.test.ts b/packages/coding-agent/test/advisor-context-maintenance.test.ts index 575579d68..a9755db38 100644 --- a/packages/coding-agent/test/advisor-context-maintenance.test.ts +++ b/packages/coding-agent/test/advisor-context-maintenance.test.ts @@ -172,13 +172,13 @@ describe("AgentSession advisor context maintenance", () => { const advisor = session.getAdvisorAgent(); if (!advisor) throw new Error("Expected advisor agent to be active"); advisor.setModel(nativeModel); - vi.spyOn(modelRegistry, "getApiKey").mockResolvedValue("test-key"); + const apiKeySpy = vi.spyOn(modelRegistry, "getApiKey").mockResolvedValue("test-key"); vi.spyOn(modelRegistry, "getAvailable").mockReturnValue([nativeModel, sameProviderModel, crossProviderModel]); advisor.state.messages.push( usageAnchor(advisorMock, Date.now() - 2_000), usageAnchor(advisorMock, Date.now() - 1_000), ); - return { advisor, crossProviderModel, nativeModel, sameProviderModel }; + return { advisor, apiKeySpy, crossProviderModel, nativeModel, sameProviderModel, settings }; } it("maintains a 371,200-token cached advisor context before the 372,000-token window", async () => { @@ -467,6 +467,38 @@ describe("AgentSession advisor context maintenance", () => { expect(JSON.stringify(advisor.state.messages)).toContain("same-provider native summary"); }); + it("skips unauthenticated advisor candidates before enforcing the native boundary", async () => { + const { advisor, apiKeySpy, crossProviderModel, nativeModel, sameProviderModel, settings } = + createAdvisorFallbackHarness(); + settings.setModelRole("smol", `${crossProviderModel.provider}/${crossProviderModel.id}`); + settings.setModelRole("slow", `${sameProviderModel.provider}/${sameProviderModel.id}`); + apiKeySpy.mockImplementation(async model => + model.provider === crossProviderModel.provider && model.id === crossProviderModel.id ? undefined : "test-key", + ); + const compactSpy = vi.spyOn(compactionModule, "compact").mockImplementation(async (preparation, model) => { + if (model.provider === nativeModel.provider && model.id === nativeModel.id) { + throw new compactionModule.NativeCompactionError(new Error("V2 native compaction transport failed")); + } + if (model.provider === sameProviderModel.provider && model.id === sameProviderModel.id) { + return { + summary: "authenticated same-provider advisor summary", + shortSummary: "authenticated same-provider advisor", + firstKeptEntryId: preparation.firstKeptEntryId, + tokensBefore: 42, + }; + } + throw new Error(`Unexpected advisor compaction model ${model.provider}/${model.id}`); + }); + + await session.prompt("small current update"); + + expect(compactSpy.mock.calls.map(([, model]) => `${model.provider}/${model.id}`)).toEqual([ + `${nativeModel.provider}/${nativeModel.id}`, + `${sameProviderModel.provider}/${sameProviderModel.id}`, + ]); + expect(JSON.stringify(advisor.state.messages)).toContain("authenticated same-provider advisor summary"); + }); + it("stops before a same-provider advisor candidate with native compaction disabled", async () => { const { advisor, nativeModel, sameProviderModel } = createAdvisorFallbackHarness({ sameProviderNativeEnabled: false, diff --git a/packages/coding-agent/test/issue-986-compaction-auth-fallback.test.ts b/packages/coding-agent/test/issue-986-compaction-auth-fallback.test.ts index ace5f61f7..1c8f1c51c 100644 --- a/packages/coding-agent/test/issue-986-compaction-auth-fallback.test.ts +++ b/packages/coding-agent/test/issue-986-compaction-auth-fallback.test.ts @@ -130,7 +130,7 @@ describe("issue #986 compaction auth fallback", () => { session.sessionManager.appendMessage(assistant); } vi.spyOn(modelRegistry, "getAvailable").mockReturnValue([currentModel, sameProviderModel, crossProviderModel]); - vi.spyOn(modelRegistry, "getApiKey").mockResolvedValue("test-key"); + const apiKeySpy = vi.spyOn(modelRegistry, "getApiKey").mockResolvedValue("test-key"); const triggerAutoCompaction = async (): Promise => { const { promise, resolve } = Promise.withResolvers(); @@ -159,7 +159,7 @@ describe("issue #986 compaction auth fallback", () => { await session.waitForIdle(); }; - return { crossProviderModel, currentModel, sameProviderModel, triggerAutoCompaction }; + return { apiKeySpy, crossProviderModel, currentModel, sameProviderModel, triggerAutoCompaction }; } it("continues same-provider native candidates but stops before crossing providers on non-auth failure", async () => { @@ -190,6 +190,39 @@ describe("issue #986 compaction auth fallback", () => { ]); }); + it("skips unauthenticated cross-provider candidates before enforcing the native boundary", async () => { + const { apiKeySpy, crossProviderModel, currentModel, sameProviderModel, triggerAutoCompaction } = + await createAutoNativeFallbackSession(); + session.settings.setModelRole("smol", `${crossProviderModel.provider}/${crossProviderModel.id}`); + session.settings.setModelRole("slow", `${sameProviderModel.provider}/${sameProviderModel.id}`); + apiKeySpy.mockImplementation(async model => + model.provider === crossProviderModel.provider ? undefined : "test-key", + ); + const attemptedModels: string[] = []; + vi.spyOn(compactionModule, "compact").mockImplementation(async (preparation, model) => { + attemptedModels.push(`${model.provider}/${model.id}`); + if (model.provider === currentModel.provider && model.id === currentModel.id) { + throw new compactionModule.NativeCompactionError(new Error("native compaction transport failed")); + } + if (model.provider === sameProviderModel.provider && model.id === sameProviderModel.id) { + return { + summary: "authenticated same-provider summary", + shortSummary: "authenticated same-provider", + firstKeptEntryId: preparation.firstKeptEntryId, + tokensBefore: 42, + }; + } + throw new Error(`Unexpected compaction model ${model.provider}/${model.id}`); + }); + + await triggerAutoCompaction(); + + expect(attemptedModels).toEqual([ + `${currentModel.provider}/${currentModel.id}`, + `${sameProviderModel.provider}/${sameProviderModel.id}`, + ]); + }); + it("retries a transient native compaction failure on the same candidate", async () => { const { currentModel, triggerAutoCompaction } = await createAutoNativeFallbackSession(); session.settings.set("retry.enabled", true);