feat(FN-3008): notify when model settings fall back to defaults

Merges FN-3008 to add a "fallback-used" notification system: the engine now emits events when AI model fallbacks are triggered, dispatches notifications via ntfy/webhook providers, surfaces a session banner in the dashboard, and exposes a settings toggle to enable or disable these alerts.

Fusion-Task-Id: FN-3008
This commit is contained in:
Fusion
2026-05-02 19:51:05 -07:00
committed by gsxdsm
parent 44a996e769
commit 68f34dd0f7
26 changed files with 357 additions and 41 deletions

View File

@@ -7,6 +7,7 @@ import {
buildNtfyClickUrl,
isNtfyEventEnabled,
resolveNtfyEvents,
notifyFallbackUsed,
} from "../notifier.js";
import { NotificationService } from "../notification/notification-service.js";
@@ -68,6 +69,7 @@ describe("Ntfy notifier helpers", () => {
expect(DEFAULT_NTFY_EVENTS).toContain("planning-awaiting-input");
expect(resolveNtfyEvents(undefined)).toContain("planning-awaiting-input");
expect(DEFAULT_NTFY_EVENTS).toContain("gridlock");
expect(DEFAULT_NTFY_EVENTS).toContain("fallback-used");
});
it("checks planning-awaiting-input event enablement", () => {
@@ -1123,6 +1125,35 @@ describe("NtfyNotifier", () => {
await sharedService.stop();
});
it("dispatches and deduplicates fallback-used notifications", async () => {
notifier = new NtfyNotifier(store);
await notifier.start();
await notifyFallbackUsed({
primaryModel: "anthropic/claude-sonnet-4-5",
fallbackModel: "openai/gpt-4o",
triggerPoint: "session-creation",
taskId: "FN-900",
taskTitle: "Fallback task",
});
await notifyFallbackUsed({
primaryModel: "anthropic/claude-sonnet-4-5",
fallbackModel: "openai/gpt-4o",
triggerPoint: "session-creation",
taskId: "FN-900",
taskTitle: "Fallback task",
});
await flushAsyncWork();
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(fetchMock).toHaveBeenCalledWith(
"https://ntfy.sh/test-topic",
expect.objectContaining({
body: expect.stringContaining("switched from anthropic/claude-sonnet-4-5 to openai/gpt-4o"),
}),
);
});
it("allows notifications for different tasks independently", async () => {
notifier = new NtfyNotifier(store);
await notifier.start();

View File

@@ -42,6 +42,7 @@ describe("NtfyNotificationProvider", () => {
["awaiting-approval", "Plan needs approval for FN-1", "needs your approval", "high"],
["awaiting-user-review", "User review needed for FN-1", "needs human review", "high"],
["planning-awaiting-input", "Planning input needed for FN-1", "awaiting your input", "high"],
["fallback-used", "Fallback model used for FN-1", "switched from", "high"],
])("maps %s event correctly", async (event, expectedTitle, messagePart, priority) => {
await provider.sendNotification(event as any, { taskId: "FN-1", taskTitle: "T", event: event as any });
@@ -62,6 +63,7 @@ describe("NtfyNotificationProvider", () => {
expect(provider.isEventSupported("awaiting-approval" as any)).toBe(true);
expect(provider.isEventSupported("awaiting-user-review" as any)).toBe(true);
expect(provider.isEventSupported("planning-awaiting-input" as any)).toBe(true);
expect(provider.isEventSupported("fallback-used" as any)).toBe(true);
expect(provider.isEventSupported("custom-event" as any)).toBe(false);
});

View File

@@ -681,6 +681,93 @@ describe("piLog structured diagnostics", () => {
expect(hasModelLog).toBe(true);
});
it("fires fallback hook on session-creation fallback", async () => {
const createAgentSessionMock = vi.mocked(createAgentSession);
const onFallbackModelUsed = vi.fn();
createAgentSessionMock.mockReset();
createAgentSessionMock
.mockRejectedValueOnce(new Error("429 Too Many Requests"))
.mockResolvedValueOnce({
session: {
model: { provider: "test", id: "fallback-model" },
prompt: vi.fn(),
subscribe: vi.fn(),
dispose: vi.fn(),
setThinkingLevel: vi.fn(),
sessionFile: undefined,
},
} as any);
await createFnAgent({
cwd: "/test/project",
systemPrompt: "Test",
defaultProvider: "test",
defaultModelId: "primary-model",
fallbackProvider: "test",
fallbackModelId: "fallback-model",
taskId: "FN-1",
taskTitle: "My Task",
onFallbackModelUsed,
});
expect(onFallbackModelUsed).toHaveBeenCalledWith(
expect.objectContaining({
triggerPoint: "session-creation",
primaryModel: "test/test-model",
fallbackModel: "test/test-model",
taskId: "FN-1",
}),
);
});
it("fires fallback hook on prompt-time fallback", async () => {
const createAgentSessionMock = vi.mocked(createAgentSession);
const onFallbackModelUsed = vi.fn();
const primarySession = {
model: { provider: "test", id: "primary-model" },
prompt: vi.fn().mockRejectedValue(new Error("429 Too Many Requests")),
subscribe: vi.fn(),
dispose: vi.fn(),
setThinkingLevel: vi.fn(),
sessionFile: undefined,
} as unknown as AgentSession;
const fallbackSession = {
model: { provider: "test", id: "fallback-model" },
prompt: vi.fn().mockResolvedValue(undefined),
subscribe: vi.fn(),
dispose: vi.fn(),
setThinkingLevel: vi.fn(),
sessionFile: undefined,
} as unknown as AgentSession;
createAgentSessionMock.mockReset();
createAgentSessionMock
.mockResolvedValueOnce({ session: primarySession } as any)
.mockResolvedValueOnce({ session: fallbackSession } as any);
const { session } = await createFnAgent({
cwd: "/test/project",
systemPrompt: "Test",
defaultProvider: "test",
defaultModelId: "primary-model",
fallbackProvider: "test",
fallbackModelId: "fallback-model",
taskId: "FN-2",
onFallbackModelUsed,
});
await (session as any).promptWithFallback("prompt text");
expect(onFallbackModelUsed).toHaveBeenCalledWith(
expect.objectContaining({
triggerPoint: "prompt-time",
taskId: "FN-2",
}),
);
});
it("logs warning on primary model failure and fallback attempt", async () => {
const createAgentSessionMock = vi.mocked(createAgentSession);
createAgentSessionMock.mockReset();

View File

@@ -137,6 +137,7 @@ describe("WebhookNotificationProvider", () => {
["awaiting-user-review", "needs human review before it can proceed"],
["planning-awaiting-input", "is awaiting your input during planning"],
["gridlock", "Pipeline gridlocked"],
["fallback-used", "Fusion recovered by switching from"],
["unknown-event", 'Event "unknown-event" for task My Task'],
])("message formatting for %s", async (event, expectedPart) => {
fetchMock.mockResolvedValue({ ok: true, status: 200, statusText: "OK" });

View File

@@ -16,6 +16,7 @@
import type { AgentSession, SessionManager, ToolDefinition } from "@mariozechner/pi-coding-agent";
import type { SkillSelectionContext } from "./skill-resolver.js";
import type { FallbackModelUsedPayload } from "./pi.js";
/**
* Options for creating an agent session.
@@ -69,6 +70,11 @@ export interface AgentRuntimeOptions {
* the runtime's internal setup latency rather than unbounded.
*/
beforeSpawnSession?: () => Promise<void> | void;
/** Callback fired when runtime falls back from primary model to fallback model. */
onFallbackModelUsed?: (payload: FallbackModelUsedPayload) => Promise<void> | void;
/** Optional task context for fallback notifications. */
taskId?: string;
taskTitle?: string;
}
/**

View File

@@ -55,6 +55,7 @@ import {
import { getTaskCompletionBlockerForStore } from "./task-completion.js";
import { createFusionAuthStorage, getModelRegistryModelsPath } from "./auth-storage.js";
import { createRunVerificationTool } from "./run-verification-tool.js";
import { notifyFallbackUsed } from "./notifier.js";
// Re-export for backward compatibility (tests import from executor.ts)
export { summarizeToolArgs } from "./agent-logger.js";
@@ -2746,6 +2747,9 @@ export class TaskExecutor {
sessionManager,
// Skill selection: use assigned agent skills if available, otherwise role fallback
...(skillContext.skillSelectionContext ? { skillSelection: skillContext.skillSelectionContext } : {}),
taskId: task.id,
taskTitle: detail.title,
onFallbackModelUsed: notifyFallbackUsed,
});
if (isResuming) {

View File

@@ -155,6 +155,7 @@ import {
import { describeModel, promptWithFallback } from "./pi.js";
import { accumulateSessionTokenUsage } from "./session-token-usage.js";
import { createResolvedAgentSession, extractRuntimeHint } from "./agent-session-helpers.js";
import { notifyFallbackUsed } from "./notifier.js";
import { buildSessionSkillContext } from "./session-skill-context.js";
import type { WorktreePool } from "./worktree-pool.js";
import { AgentLogger } from "./agent-logger.js";
@@ -2336,6 +2337,8 @@ You are assisting with a paused \`git pull --rebase\`.
? settings.defaultModelIdOverride
: settings.defaultModelId,
defaultThinkingLevel: settings.defaultThinkingLevel,
taskId,
onFallbackModelUsed: notifyFallbackUsed,
});
const prompt = [

View File

@@ -23,6 +23,7 @@ import type {
import { createFnAgent, promptWithFallback, type AgentResult } from "./pi.js";
import { createResolvedAgentSession, extractRuntimeHint } from "./agent-session-helpers.js";
import { createLogger } from "./logger.js";
import { notifyFallbackUsed } from "./notifier.js";
/** Logger for the mission execution loop subsystem. */
export const loopLog = createLogger("mission-loop");
@@ -357,6 +358,9 @@ export class MissionExecutionLoop extends EventEmitter {
onText: (_delta) => {
// Could stream this to a log entry if needed
},
taskId: task?.id,
taskTitle: task?.title,
onFallbackModelUsed: notifyFallbackUsed,
});
session = { session: sessionResult.session, sessionFile: sessionResult.sessionFile };

View File

@@ -228,6 +228,15 @@ export class NotificationService {
);
}
async dispatch(eventType: NotificationEvent, payload: NotificationPayload): Promise<void> {
if (!this.notificationsEnabled) {
return;
}
const dedupTaskId = payload.taskId ?? "global";
this.maybeNotify(dedupTaskId, eventType, payload);
}
private createTaskPayload(task: Task, event: NotificationEvent): NotificationPayload {
return {
taskId: task.id,

View File

@@ -33,7 +33,8 @@ type SupportedNtfyEvent =
| "failed"
| "awaiting-approval"
| "awaiting-user-review"
| "planning-awaiting-input";
| "planning-awaiting-input"
| "fallback-used";
const SUPPORTED_EVENTS = new Set<SupportedNtfyEvent>([
"in-review",
@@ -42,6 +43,7 @@ const SUPPORTED_EVENTS = new Set<SupportedNtfyEvent>([
"awaiting-approval",
"awaiting-user-review",
"planning-awaiting-input",
"fallback-used",
]);
export class NtfyNotificationProvider implements NotificationProvider {
@@ -92,8 +94,9 @@ export class NtfyNotificationProvider implements NotificationProvider {
};
}
const taskId = payload.taskId ?? "unknown-task";
const taskLike = {
id: payload.taskId,
id: taskId,
title: payload.taskTitle,
description: payload.taskDescription ?? "",
} as Pick<Task, "id" | "title" | "description"> as Task;
@@ -107,35 +110,40 @@ export class NtfyNotificationProvider implements NotificationProvider {
const contentByEvent: Record<SupportedNtfyEvent, { title: string; message: string; priority: "default" | "high" }> = {
"in-review": {
title: `Task ${payload.taskId} completed`,
title: `Task ${taskId} completed`,
message: `Task "${identifier}" is ready for review`,
priority: "default",
},
merged: {
title: `Task ${payload.taskId} merged`,
title: `Task ${taskId} merged`,
message: `Task "${identifier}" has been merged to main`,
priority: "default",
},
failed: {
title: `Task ${payload.taskId} failed`,
title: `Task ${taskId} failed`,
message: `Task "${identifier}" has failed and needs attention`,
priority: "high",
},
"awaiting-approval": {
title: `Plan needs approval for ${payload.taskId}`,
title: `Plan needs approval for ${taskId}`,
message: `Task "${identifier}" needs your approval before it can proceed`,
priority: "high",
},
"awaiting-user-review": {
title: `User review needed for ${payload.taskId}`,
title: `User review needed for ${taskId}`,
message: `Task "${identifier}" needs human review before it can proceed`,
priority: "high",
},
"planning-awaiting-input": {
title: `Planning input needed for ${payload.taskId}`,
title: `Planning input needed for ${taskId}`,
message: `Task "${identifier}" is awaiting your input during planning`,
priority: "high",
},
"fallback-used": {
title: `Fallback model used${payload.taskId ? ` for ${payload.taskId}` : ""}`,
message: `Fusion switched from ${String(payload.metadata?.primaryModel ?? "primary model")} to ${String(payload.metadata?.fallbackModel ?? "fallback model")} after a retryable failure (${String(payload.metadata?.triggerPoint ?? "unknown trigger")}).`,
priority: "high",
},
};
const content = contentByEvent[event as SupportedNtfyEvent];

View File

@@ -133,6 +133,8 @@ export class WebhookNotificationProvider implements NotificationProvider {
return `Task "${identifier}" is awaiting your input during planning`;
case "gridlock":
return "Pipeline gridlocked";
case "fallback-used":
return `Fusion recovered by switching from ${String(payload.metadata?.primaryModel ?? "primary model")} to ${String(payload.metadata?.fallbackModel ?? "fallback model")} (${String(payload.metadata?.triggerPoint ?? "unknown trigger")})`;
default:
return `Event "${event}" for task ${identifier}`;
}
@@ -145,7 +147,7 @@ export class WebhookNotificationProvider implements NotificationProvider {
const description = payload.taskDescription ?? "";
const snippet = description.length > 200 ? `${description.slice(0, 200)}...` : description;
return `${payload.taskId}: ${snippet}`;
return `${payload.taskId ?? "unknown-task"}: ${snippet}`;
}
private formatPayload(payload: NotificationPayload, message: string): Record<string, unknown> {

View File

@@ -23,6 +23,7 @@ export const DEFAULT_NTFY_EVENTS: readonly NtfyNotificationEvent[] = [
"awaiting-user-review",
"planning-awaiting-input",
"gridlock",
"fallback-used",
] as const;
export interface NtfyNotificationConfigInput {
@@ -53,7 +54,7 @@ interface NtfyConfig {
/** Event types for task notification deduplication */
type TaskNotificationEvent = "in-review" | "merged" | "failed" | "awaiting-approval" | "awaiting-user-review";
type AnyNotificationEvent = TaskNotificationEvent | "gridlock";
type AnyNotificationEvent = TaskNotificationEvent | "gridlock" | "fallback-used";
/**
* Format a task identifier for notifications.
@@ -166,6 +167,35 @@ export async function sendNtfyNotification({
* It keeps legacy APIs (getConfig, notifyGridlock) while delegating task event
* notifications to the pluggable provider-based notification module.
*/
let activeNotificationService: NotificationService | undefined;
export interface FallbackNotificationInput {
primaryModel: string;
fallbackModel: string;
triggerPoint: "session-creation" | "prompt-time";
taskId?: string;
taskTitle?: string;
timestamp?: string;
}
export async function notifyFallbackUsed(input: FallbackNotificationInput): Promise<void> {
if (!activeNotificationService) {
return;
}
await activeNotificationService.dispatch("fallback-used", {
taskId: input.taskId,
taskTitle: input.taskTitle,
event: "fallback-used",
timestamp: input.timestamp,
metadata: {
primaryModel: input.primaryModel,
fallbackModel: input.fallbackModel,
triggerPoint: input.triggerPoint,
},
});
}
export class NtfyNotifier {
private config: NtfyConfig = {
enabled: false,
@@ -192,6 +222,7 @@ export class NtfyNotifier {
projectId: this.projectId,
ntfyBaseUrl: options.ntfyBaseUrl,
});
activeNotificationService = this.notificationService;
}
async start(): Promise<void> {

View File

@@ -393,6 +393,15 @@ export async function compactSessionContext(
}
}
export interface FallbackModelUsedPayload {
primaryModel: string;
fallbackModel: string;
triggerPoint: "session-creation" | "prompt-time";
taskId?: string;
taskTitle?: string;
timestamp?: string;
}
export interface AgentOptions {
cwd: string;
systemPrompt: string;
@@ -429,6 +438,11 @@ export interface AgentOptions {
/** Last-chance abort hook fired immediately before `createAgentSession`.
* See `AgentRuntimeOptions.beforeSpawnSession`. */
beforeSpawnSession?: () => Promise<void> | void;
/** Callback fired when runtime falls back from primary model to fallback model. */
onFallbackModelUsed?: (payload: FallbackModelUsedPayload) => Promise<void> | void;
/** Optional task context for fallback notifications. */
taskId?: string;
taskTitle?: string;
}
function resolveConfiguredModel(
@@ -1204,6 +1218,20 @@ export async function createFnAgent(options: AgentOptions): Promise<AgentResult>
});
};
const emitFallbackUsed = async (triggerPoint: "session-creation" | "prompt-time"): Promise<void> => {
if (!options.onFallbackModelUsed || !selectedModel || !fallbackModel) {
return;
}
await options.onFallbackModelUsed({
primaryModel: `${selectedModel.provider}/${selectedModel.id}`,
fallbackModel: `${fallbackModel.provider}/${fallbackModel.id}`,
triggerPoint,
taskId: options.taskId,
taskTitle: options.taskTitle,
timestamp: new Date().toISOString(),
});
};
let sessionResult;
let usingFallback = false;
try {
@@ -1217,6 +1245,7 @@ export async function createFnAgent(options: AgentOptions): Promise<AgentResult>
piLog.warn(`Primary model failed (${err.message}), trying fallback`);
usingFallback = true;
sessionResult = await createSessionWithModel(fallbackModel);
await emitFallbackUsed("session-creation");
piLog.log("Fallback session created successfully");
}
@@ -1278,6 +1307,7 @@ export async function createFnAgent(options: AgentOptions): Promise<AgentResult>
}
const fallbackSessionResult = await createSessionWithModel(fallbackModel);
await emitFallbackUsed("prompt-time");
const fallbackSession = fallbackSessionResult.session as PromptableSession;
installToolResultContentGuard(fallbackSession as unknown as AgentToolHookSession);
installMessageContentGuard(

View File

@@ -17,6 +17,7 @@ import { AgentLogger } from "./agent-logger.js";
import { reviewerLog } from "./logger.js";
import { checkSessionError } from "./usage-limit-detector.js";
import { resolveAgentInstructions, buildSystemPromptWithInstructions } from "./agent-instructions.js";
import { notifyFallbackUsed } from "./notifier.js";
import { createMemoryGetTool, createMemorySearchTool } from "./agent-tools.js";
export const REVIEWER_SYSTEM_PROMPT = `You are an independent code and plan reviewer.
@@ -254,6 +255,8 @@ export interface ReviewOptions {
store?: TaskStore;
/** Task ID for agent log persistence. Required alongside `store`. */
taskId?: string;
/** Optional task title for fallback-used notification context. */
taskTitle?: string;
/** Task with optional assignedAgentId for skill selection. */
task?: { assignedAgentId?: string | null };
/** User comments on the task (author === "user"). For spec reviews, the reviewer explicitly checks that every comment is addressed. */
@@ -488,6 +491,9 @@ export async function reviewStep(
defaultThinkingLevel: options.defaultThinkingLevel,
// Skill selection: use assigned agent skills if available, otherwise role fallback
...(skillContext?.skillSelectionContext ? { skillSelection: skillContext.skillSelectionContext } : {}),
taskId: options.taskId,
taskTitle: options.taskTitle,
onFallbackModelUsed: notifyFallbackUsed,
beforeSpawnSession: async () => {
if (!options.store) return;
let finalSettings: Settings | undefined;

View File

@@ -30,6 +30,7 @@ import { AgentSemaphore } from "./concurrency.js";
import { StuckTaskDetector } from "./stuck-task-detector.js";
import { AgentLogger } from "./agent-logger.js";
import { createLogger } from "./logger.js";
import { notifyFallbackUsed } from "./notifier.js";
import { isContextLimitError } from "./context-limit-detector.js";
import { checkSessionError } from "./usage-limit-detector.js";
import {
@@ -1012,6 +1013,9 @@ Follow instructions precisely and avoid unrelated changes.`,
},
// Skill selection from step-session executor options
...(this.options.skillSelection ? { skillSelection: this.options.skillSelection } : {}),
taskId: taskDetail.id,
taskTitle: taskDetail.title,
onFallbackModelUsed: notifyFallbackUsed,
});
session = createResult.session;

View File

@@ -24,6 +24,7 @@ import { buildSessionSkillContext } from "./session-skill-context.js";
import { PRIORITY_SPECIFY, type AgentSemaphore } from "./concurrency.js";
import { AgentLogger } from "./agent-logger.js";
import { resolveAgentInstructions, buildSystemPromptWithInstructions } from "./agent-instructions.js";
import { notifyFallbackUsed } from "./notifier.js";
import { planLog, reviewerLog, formatError } from "./logger.js";
import {
isUsageLimitError,
@@ -1029,6 +1030,9 @@ export class TriageProcessor {
defaultThinkingLevel: settings.defaultThinkingLevel,
// Skill selection: use assigned agent skills if available, otherwise role fallback
...(skillContext.skillSelectionContext ? { skillSelection: skillContext.skillSelectionContext } : {}),
taskId: task.id,
taskTitle: task.title,
onFallbackModelUsed: notifyFallbackUsed,
});
const modelDesc = describeModel(session);
@@ -1226,6 +1230,9 @@ export class TriageProcessor {
defaultModelId: planningFallbackModelId,
defaultThinkingLevel: settings.defaultThinkingLevel,
...(skillContext.skillSelectionContext ? { skillSelection: skillContext.skillSelectionContext } : {}),
taskId: task.id,
taskTitle: task.title,
onFallbackModelUsed: notifyFallbackUsed,
});
session = fallbackResult.session;