FN-6218: restore pi upgrade compatibility
Restore Pi-upgraded workspaces to keep Fusion resources accessible and recover title generation when configured models go stale. - Mark read-only Fusion/Pi provider settings views as project-trusted so extension loading keeps working after Pi upgrades. - Retry task title summarization with automatic model resolution when the configured provider model is missing from the Pi registry. - Cover provider settings trust behavior and stale summarizer fallback paths with regression tests. - Document the read-only settings trust contract and add a patch changeset. Files changed: .changeset/fn-6218-pi-upgrade-regressions.md | 5 + docs/settings-reference.md | 2 + docs/task-management.md | 2 + .../commands/__tests__/provider-settings.test.ts | 16 ++++ packages/cli/src/commands/provider-settings.ts | 5 + packages/core/src/__tests__/ai-summarize.test.ts | 102 +++++++++++++++++++++ packages/core/src/ai-summarize.ts | 85 ++++++++++++----- .../src/__tests__/pi-create-fn-agent.test.ts | 32 +++++++ packages/engine/src/pi.ts | 7 +- 9 files changed, 233 insertions(+), 23 deletions(-) Fusion-Task-Id: FN-6218 Fusion-Task-Lineage: 3d6aca32-a56a-43a0-a57b-8e6abc839ce9
This commit is contained in:
@@ -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", () => {
|
||||
|
||||
@@ -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<Awaited<ReturnType<typeof getFnAgent>>>,
|
||||
description: string,
|
||||
rootDir: string,
|
||||
provider?: string,
|
||||
modelId?: string
|
||||
): Promise<string | null> {
|
||||
// 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<string> {
|
||||
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<string | null> {
|
||||
// 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.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user