fix(FN-5584): restore planning fallback when primary provider API key is missing

The top-level promptWithFallback bypassed the session-attached rich fallback
path that runs isRetryableModelSelectionError + swapPromptSession, so errors
like "No API key for provider: anthropic" propagated without trying the
configured planning fallback. Restore the dispatch with a WeakSet re-entry
guard that preserves the FN-4900 recursion fix for plugin-runtime sessions.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-05-25 14:36:26 -07:00
parent e4099f2ff5
commit 06a107dcba
3 changed files with 41 additions and 3 deletions

View File

@@ -571,7 +571,14 @@ describe("promptWithFallback auto-compaction", () => {
expect(mockPrompt.mock.calls[1]).toEqual(["test prompt", options]);
});
it("uses standalone prompt path even when session.promptWithFallback exists", async () => {
it("delegates to session.promptWithFallback when available so rich fallback logic runs", async () => {
// The session-attached promptWithFallback (set by createFnAgent at pi.ts:2012)
// is the only path that swaps to the configured fallbackModel on
// isRetryableModelSelectionError matches like "api key", 401/403, rate-limit, etc.
// Bypassing it (as the old standalone-only behavior did) silently dropped
// missing-API-key triage failures with no fallback attempt — see FN-5584.
// A re-entry guard in promptWithFallback prevents the recursion that
// FN-4900 originally guarded against.
const mockSessionPromptWithFallback = vi.fn().mockResolvedValue(undefined);
const mockPrompt = vi.fn().mockResolvedValue(undefined);
const mockCompact = vi.fn();
@@ -583,8 +590,9 @@ describe("promptWithFallback auto-compaction", () => {
await promptWithFallback(session, "test prompt");
expect(mockPrompt).toHaveBeenCalledTimes(1);
expect(mockSessionPromptWithFallback).not.toHaveBeenCalled();
expect(mockSessionPromptWithFallback).toHaveBeenCalledTimes(1);
expect(mockSessionPromptWithFallback).toHaveBeenCalledWith("test prompt", undefined);
expect(mockPrompt).not.toHaveBeenCalled();
});
it("handles context error patterns from various providers", async () => {

View File

@@ -313,7 +313,32 @@ export async function promptSessionAndCheck(session: AgentSession, prompt: strin
}
}
// Re-entry guard for the top-level dispatcher below. When `session.promptWithFallback`
// is attached (e.g. by `createFnAgent` at pi.ts:2012, where the rich model-swap +
// `isRetryableModelSelectionError` path lives), we want top-level callers like
// triage/executor to flow through it so missing-API-key, 401/403, rate-limit, etc.
// trigger the configured `fallbackModel`. The WeakSet prevents infinite recursion
// in case a session-attached `promptWithFallback` ever calls back into the
// top-level export with the same session.
const promptWithFallbackInFlight = new WeakSet<object>();
export async function promptWithFallback(session: AgentSession, prompt: string, options?: unknown): Promise<void> {
const sessionWithDispatch = session as AgentSession & {
promptWithFallback?: (prompt: string, options?: unknown) => Promise<void>;
};
if (
typeof sessionWithDispatch.promptWithFallback === "function" &&
!promptWithFallbackInFlight.has(session as unknown as object)
) {
promptWithFallbackInFlight.add(session as unknown as object);
try {
await sessionWithDispatch.promptWithFallback(prompt, options);
return;
} finally {
promptWithFallbackInFlight.delete(session as unknown as object);
}
}
piLog.log(`promptWithFallback: calling session.prompt (prompt length=${prompt.length})`);
try {
await promptSessionAndCheck(session, prompt, options);