diff --git a/.changeset/fn-6218-pi-upgrade-regressions.md b/.changeset/fn-6218-pi-upgrade-regressions.md new file mode 100644 index 0000000000..a77abe4d9c --- /dev/null +++ b/.changeset/fn-6218-pi-upgrade-regressions.md @@ -0,0 +1,5 @@ +--- +"@runfusion/fusion": patch +--- + +Fix pi 0.79 extension discovery compatibility and retry stale title-summarizer model ids with automatic model resolution. diff --git a/docs/settings-reference.md b/docs/settings-reference.md index 60dfbafcf0..4cd8d73b6e 100644 --- a/docs/settings-reference.md +++ b/docs/settings-reference.md @@ -837,6 +837,8 @@ Project-scoped model lane used for task title auto-summarization, GitHub trackin 5. Global `defaultProvider` + `defaultModelId` 6. Automatic provider/model resolution +If the configured title summarizer provider/model is stale and no longer exists in the pi model registry, title generation logs a warning with the stale id and retries once with automatic provider/model resolution. Other AI failures (auth, empty output, unavailable engine) still fail normally. + > **Note:** Runtime fallback precedence logic is implemented in engine and dashboard routes. The hierarchies above reflect current runtime behavior. --- diff --git a/docs/task-management.md b/docs/task-management.md index effb77e145..bf30bdbf28 100644 --- a/docs/task-management.md +++ b/docs/task-management.md @@ -855,6 +855,8 @@ Users can apply presets at task creation; manual model selection can override th When `autoSummarizeTitles` is enabled and a task has a long untitled description, Fusion can auto-generate a concise title. This applies to tasks created from the dashboard/API as well as tasks created by agents and tooling flows (`fn_task_create`, delegated tasks, and triage-created child tasks). GitHub tracking now waits for the `createTask`-level summarizer (explicit or auto-attached from settings) to settle before filing, then uses that resulting title and falls back to deterministic description-derived title generation only when summarization is unavailable. +If a configured title summarizer model is stale after a pi upgrade, Fusion logs a warning naming that provider/model and retries once with automatic model resolution before falling back to deterministic title generation. Genuine AI-service failures are not masked by this retry. + ## Screenshots ### Board/task cards + quick entry diff --git a/packages/cli/src/commands/__tests__/provider-settings.test.ts b/packages/cli/src/commands/__tests__/provider-settings.test.ts index e8c2c0e54c..1b9c17d109 100644 --- a/packages/cli/src/commands/__tests__/provider-settings.test.ts +++ b/packages/cli/src/commands/__tests__/provider-settings.test.ts @@ -46,6 +46,22 @@ describe("createReadOnlyProviderSettingsView", () => { shared: "fusion", }); expect(view.getNpmCommand()).toEqual(["pnpm"]); + expect(view.isProjectTrusted()).toBe(true); + }); + + it("exposes project trust for pi package-manager discovery consumers", async () => { + const root = tempWorkspace("fusion-provider-settings-"); + const cwd = join(root, "project"); + const agentDir = join(root, "agent"); + mkdirSync(agentDir, { recursive: true }); + + const view = createReadOnlyProviderSettingsView(cwd, agentDir); + const discoveryConsumer = { + resolve: vi.fn(async () => view.isProjectTrusted()), + }; + + await expect(discoveryConsumer.resolve()).resolves.toBe(true); + expect(typeof view.isProjectTrusted()).toBe("boolean"); }); it("returns empty project settings when .fusion/settings.json does not exist", () => { diff --git a/packages/cli/src/commands/provider-settings.ts b/packages/cli/src/commands/provider-settings.ts index ab8e10b1c8..b3650a2ecd 100644 --- a/packages/cli/src/commands/provider-settings.ts +++ b/packages/cli/src/commands/provider-settings.ts @@ -5,6 +5,7 @@ export interface PackageManagerSettingsView { getGlobalSettings(): Record; getProjectSettings(): Record; getNpmCommand(): string[] | undefined; + isProjectTrusted(): boolean; } function siblingAgentDir(agentDir: string, siblingRoot: ".fusion" | ".pi"): string | undefined { @@ -47,6 +48,10 @@ export function createReadOnlyProviderSettingsView(cwd: string, agentDir: string getNpmCommand: () => Array.isArray(mergedSettings.npmCommand) ? [...mergedSettings.npmCommand] : undefined, + // Pi's SettingsManager defaults projects to trusted. Fusion workspaces are + // user-owned, so preserve pre-upgrade behavior and keep project-scoped + // .fusion resources loadable through the read-only settings view. + isProjectTrusted: () => true, }; } diff --git a/packages/core/src/__tests__/ai-summarize.test.ts b/packages/core/src/__tests__/ai-summarize.test.ts index d29c4437b6..9c2fbbbf7f 100644 --- a/packages/core/src/__tests__/ai-summarize.test.ts +++ b/packages/core/src/__tests__/ai-summarize.test.ts @@ -255,6 +255,108 @@ describe("ai-summarize", () => { const title = await summarizeTitle("a".repeat(201), "/tmp"); expect(title).toBe("Refactor merger title fallback"); }); + + it("retries stale configured model ids with automatic resolution and logs the stale id", async () => { + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + const createFnAgent = vi + .fn() + .mockRejectedValueOnce(new Error( + "Configured model fireworksai/accounts/fireworks/routers/kimi-k2p5-turbo (primary selection) " + + "was not found in the pi model registry.", + )) + .mockResolvedValueOnce({ + session: { + prompt: vi.fn().mockResolvedValue(undefined), + dispose: vi.fn(), + state: { + messages: [{ role: "assistant", content: "Fix pi upgrade regressions" }], + }, + }, + }); + getFnAgentMock.mockResolvedValue(createFnAgent); + + const title = await summarizeTitle( + "a".repeat(201), + "/tmp", + "fireworksai", + "accounts/fireworks/routers/kimi-k2p5-turbo", + ); + + expect(title).toBe("Fix pi upgrade regressions"); + expect(createFnAgent).toHaveBeenNthCalledWith(1, expect.objectContaining({ + defaultProvider: "fireworksai", + defaultModelId: "accounts/fireworks/routers/kimi-k2p5-turbo", + })); + expect(createFnAgent).toHaveBeenNthCalledWith(2, expect.not.objectContaining({ + defaultProvider: expect.any(String), + defaultModelId: expect.any(String), + })); + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("fireworksai/accounts/fireworks/routers/kimi-k2p5-turbo")); + warnSpy.mockRestore(); + }); + + it("keeps valid configured model ids on the primary summarizer path", async () => { + const createFnAgent = vi.fn().mockResolvedValue({ + session: { + prompt: vi.fn().mockResolvedValue(undefined), + dispose: vi.fn(), + state: { + messages: [{ role: "assistant", content: "Keep configured model" }], + }, + }, + }); + getFnAgentMock.mockResolvedValue(createFnAgent); + + const title = await summarizeTitle("a".repeat(201), "/tmp", "anthropic", "claude-sonnet-4-5"); + + expect(title).toBe("Keep configured model"); + expect(createFnAgent).toHaveBeenCalledTimes(1); + expect(createFnAgent).toHaveBeenCalledWith(expect.objectContaining({ + defaultProvider: "anthropic", + defaultModelId: "claude-sonnet-4-5", + })); + }); + + it("returns null when stale-model automatic resolution also fails", async () => { + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + const createFnAgent = vi + .fn() + .mockRejectedValueOnce(new Error( + "Configured model fireworksai/accounts/fireworks/routers/kimi-k2p5-turbo (primary selection) " + + "was not found in the pi model registry.", + )) + .mockRejectedValueOnce(new Error("No model selected")); + getFnAgentMock.mockResolvedValue(createFnAgent); + + await expect(summarizeTitle( + "a".repeat(201), + "/tmp", + "fireworksai", + "accounts/fireworks/routers/kimi-k2p5-turbo", + )).resolves.toBeNull(); + expect(createFnAgent).toHaveBeenCalledTimes(2); + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("retrying with automatic model resolution")); + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("Automatic title summarizer fallback")); + warnSpy.mockRestore(); + }); + + it("does not mask genuine AI service errors", async () => { + getFnAgentMock.mockResolvedValue(() => + Promise.resolve({ + session: { + prompt: vi.fn().mockResolvedValue(undefined), + dispose: vi.fn(), + state: { + error: "authentication failed", + messages: [], + }, + }, + }) + ); + + await expect(summarizeTitle("a".repeat(201), "/tmp", "anthropic", "claude-sonnet-4-5")) + .rejects.toThrow("AI session error: authentication failed"); + }); }); describe("sanitizeTitle", () => { diff --git a/packages/core/src/ai-summarize.ts b/packages/core/src/ai-summarize.ts index 6d191d4797..a15990bad7 100644 --- a/packages/core/src/ai-summarize.ts +++ b/packages/core/src/ai-summarize.ts @@ -198,31 +198,22 @@ export function validateDescription(description: unknown): string { /** Debug flag for AI operations */ const DEBUG = process.env.FUSION_DEBUG_AI === "true"; -/** - * Summarize a task description into a concise title using AI. - * @param description - The task description to summarize (must be 201-2000 chars) - * @param rootDir - Project root directory for AI agent context - * @param provider - Optional AI model provider (e.g., "anthropic") - * @param modelId - Optional AI model ID (e.g., "claude-sonnet-4-5") - * @returns The generated title (guaranteed ≤60 characters), or null if validation fails - */ -export async function summarizeTitle( +function isConfiguredModelNotFoundError(error: unknown): boolean { + const message = error instanceof Error ? error.message : String(error); + return /Configured model .+ was not found in the pi model registry/.test(message); +} + +function formatConfiguredModel(provider?: string, modelId?: string): string { + return provider && modelId ? `${provider}/${modelId}` : "unknown configured model"; +} + +async function runTitleSummarizer( + createFnAgent: NonNullable>>, description: string, rootDir: string, provider?: string, - modelId?: string -): Promise { - // Validate description length first - if (description.length <= 200) { - return null; // Too short for summarization - } - - const createFnAgent = await getFnAgent(); - if (!createFnAgent) { - if (DEBUG) console.log("[ai-summarize] AI engine not available"); - throw new AiServiceError("AI engine not available"); - } - + modelId?: string, +): Promise { const agentOptions: { cwd: string; systemPrompt: string; @@ -324,6 +315,56 @@ export async function summarizeTitle( } } +/** + * Summarize a task description into a concise title using AI. + * @param description - The task description to summarize (must be 201-2000 chars) + * @param rootDir - Project root directory for AI agent context + * @param provider - Optional AI model provider (e.g., "anthropic") + * @param modelId - Optional AI model ID (e.g., "claude-sonnet-4-5") + * @returns The generated title (guaranteed ≤60 characters), or null if validation fails + */ +export async function summarizeTitle( + description: string, + rootDir: string, + provider?: string, + modelId?: string +): Promise { + // Validate description length first + if (description.length <= 200) { + return null; // Too short for summarization + } + + const createFnAgent = await getFnAgent(); + if (!createFnAgent) { + if (DEBUG) console.log("[ai-summarize] AI engine not available"); + throw new AiServiceError("AI engine not available"); + } + + try { + return await runTitleSummarizer(createFnAgent, description, rootDir, provider, modelId); + } catch (err) { + if (!provider || !modelId || !isConfiguredModelNotFoundError(err)) { + throw err; + } + + const staleModel = formatConfiguredModel(provider, modelId); + console.warn( + `[ai-summarize] Configured title summarizer model ${staleModel} was not found in the pi model registry; ` + + "retrying with automatic model resolution.", + ); + + try { + return await runTitleSummarizer(createFnAgent, description, rootDir); + } catch (retryError) { + const message = retryError instanceof Error ? retryError.message : String(retryError); + console.warn( + `[ai-summarize] Automatic title summarizer fallback after stale model ${staleModel} failed: ${message}`, + ); + return null; + } + } +} + /** System prompt for AI merge commit summary generation. */ export const MERGE_COMMIT_SUMMARIZE_SYSTEM_PROMPT = `You summarize merge commits for a task management system. diff --git a/packages/engine/src/__tests__/pi-create-fn-agent.test.ts b/packages/engine/src/__tests__/pi-create-fn-agent.test.ts index 24ea03ded5..5ffa69f693 100644 --- a/packages/engine/src/__tests__/pi-create-fn-agent.test.ts +++ b/packages/engine/src/__tests__/pi-create-fn-agent.test.ts @@ -30,6 +30,7 @@ const readFileSyncMock = vi.fn((_path?: any) => "{}"); const realpathSyncNativeMock = vi.fn((path: PathLike) => String(path)); const readCustomProvidersMock = vi.fn(() => []); const packageManagerCwdCapture = vi.fn(); +const packageManagerSettingsCapture = vi.fn(); // Route async `exec` through the `execSync` mock so the promisify bridge works. // Use Symbol.for("nodejs.util.promisify.custom") directly to avoid async imports @@ -107,10 +108,15 @@ vi.mock("@earendil-works/pi-coding-agent", () => ({ } }, DefaultPackageManager: class { + private readonly settingsManager: any; + constructor(options: any) { packageManagerCwdCapture(options?.cwd); + packageManagerSettingsCapture(options?.settingsManager); + this.settingsManager = options?.settingsManager; } async resolve() { + this.settingsManager.isProjectTrusted(); return packageManagerResolveMock(); } }, @@ -1272,6 +1278,32 @@ describe("createFnAgent", () => { expect(createAgentSessionMock).toHaveBeenCalledTimes(1); }); + it("exposes project trust on the read-only pi settings view", async () => { + const { createReadOnlyPiSettingsView } = await import("../pi.js"); + + const view = createReadOnlyPiSettingsView("/tmp", "/mock-agent-dir"); + + expect(() => view.isProjectTrusted()).not.toThrow(); + expect(view.isProjectTrusted()).toBe(true); + expect(typeof view.isProjectTrusted()).toBe("boolean"); + }); + + it("passes a project-trusted settings view through package-manager discovery", async () => { + const { createFnAgent } = await import("../pi.js"); + + await createFnAgent({ + cwd: "/tmp", + systemPrompt: "test", + tools: "readonly", + }); + + const settingsView = packageManagerSettingsCapture.mock.calls.at(-1)?.[0]; + expect(settingsView).toEqual(expect.objectContaining({ isProjectTrusted: expect.any(Function) })); + expect(settingsView.isProjectTrusted()).toBe(true); + expect(packageManagerResolveMock).toHaveBeenCalled(); + expect(createAgentSessionMock).toHaveBeenCalledTimes(1); + }); + it("registers extension providers before resolving configured models", async () => { packageManagerResolveMock.mockResolvedValueOnce({ extensions: [{ enabled: true, path: "/extensions/zai-provider" }], diff --git a/packages/engine/src/pi.ts b/packages/engine/src/pi.ts index c5e7646afd..790c863501 100644 --- a/packages/engine/src/pi.ts +++ b/packages/engine/src/pi.ts @@ -1080,6 +1080,7 @@ interface PackageManagerSettingsView { getGlobalSettings(): Record; getProjectSettings(): Record; getNpmCommand(): string[] | undefined; + isProjectTrusted(): boolean; } function readJsonObject(path: string): Record { @@ -1258,7 +1259,7 @@ function siblingAgentDir(agentDir: string, siblingRoot: ".fusion" | ".pi"): stri return join(dirname(dirname(agentDir)), siblingRoot, "agent"); } -function createReadOnlyPiSettingsView(cwd: string, agentDir: string): PackageManagerSettingsView { +export function createReadOnlyPiSettingsView(cwd: string, agentDir: string): PackageManagerSettingsView { const projectRoot = resolvePiExtensionProjectRoot(cwd); const fusionAgentDir = agentDir.includes(`${join(".fusion", "agent")}`) ? agentDir @@ -1279,6 +1280,10 @@ function createReadOnlyPiSettingsView(cwd: string, agentDir: string): PackageMan getNpmCommand: () => Array.isArray(mergedSettings.npmCommand) ? [...mergedSettings.npmCommand] : undefined, + // Pi's SettingsManager defaults projects to trusted. Fusion workspaces are + // user-owned, so preserve pre-upgrade behavior and keep project-scoped + // .fusion resources loadable through the read-only settings view. + isProjectTrusted: () => true, }; }