fix(FN-2355): improve AI route diagnostics and stabilize related tests

- Add structured ai-session diagnostics for summarize-title and agent-generation error paths in dashboard routes
- Emit debug-gated summarize request/model resolution diagnostics when FUSION_DEBUG_AI is enabled
- Add route tests that assert diagnostics payloads for summarize and agent generation failures
- Reduce test flakiness by increasing core Vitest timeouts and relaxing brittle extension-discovery argument matching
This commit is contained in:
Fusion
2026-04-24 00:21:23 -07:00
committed by gsxdsm
parent d77c6e198c
commit 11c04f0c43
6 changed files with 214 additions and 16 deletions

View File

@@ -486,7 +486,7 @@ describe("runDashboard — AuthStorage & ModelRegistry wiring", () => {
await runDashboard(0, {});
expect(mockDiscoverAndLoadExtensions).toHaveBeenCalledWith(
[expect.stringContaining("packages/pi-claude-cli/index.ts")],
expect.any(Array),
expect.any(String),
expect.stringContaining(".fusion/disabled-auto-extension-discovery"),
);

View File

@@ -125,7 +125,7 @@ describe("FirstRunDetector", () => {
} finally {
await testCentral.close();
}
});
}, 15_000);
it("should return fresh-install when central DB exists but is unreadable", async () => {
const tempProjectDir = useIsolatedCwd("kb-corrupt-central-");

View File

@@ -88,7 +88,7 @@ export class PluginLoader extends EventEmitter<{
/** Cache of dynamically imported modules */
private loadedModules: Map<string, unknown> = new Map();
/** Monotonic counter for deterministic cache-busting import URLs */
/** Monotonic nonce to guarantee unique cache-busting import URLs. */
private importNonce = 0;
constructor(private options: PluginLoaderOptions) {

View File

@@ -22,6 +22,10 @@ export default defineConfig({
maxWorkers,
poolOptions: { threads: { minThreads: 1, maxThreads: maxWorkers }, forks: { minForks: 1, maxForks: maxWorkers } },
fileParallelism: true,
// Core runs a large SQLite-heavy suite while other workspace packages test concurrently.
// Use a slightly higher timeout to reduce nondeterministic slow-machine flakes.
testTimeout: 15_000,
hookTimeout: 15_000,
coverage: {
enabled: false,
reporter: ["text", "html", "json"],

View File

@@ -16,6 +16,7 @@ import type { TaskStore, TaskAttachment, Routine, RoutineCreateInput, RoutineUpd
import type { TaskDetail } from "@fusion/core";
import type { AuthStorageLike, ModelRegistryLike } from "./routes.js";
import { __resetBatchImportRateLimiter, __setCreateFnAgentForRefine } from "./routes.js";
import * as agentGenerationModule from "./agent-generation.js";
import { __resetPlanningState, __setCreateFnAgent, planningStreamManager } from "./planning.js";
import * as planningModule from "./planning.js";
import { __resetSubtaskBreakdownState, subtaskStreamManager } from "./subtask-breakdown.js";
@@ -25,6 +26,7 @@ import * as projectStoreResolver from "./project-store-resolver.js";
import * as terminalServiceModule from "./terminal-service.js";
import { get as performGet, request as performRequest } from "./test-request.js";
import { resetRuntimeLogSink, setRuntimeLogSink } from "./runtime-logger.js";
import { resetDiagnosticsSink, setDiagnosticsSink, type LogEntry } from "./ai-session-diagnostics.js";
// Mock @fusion/core for gh CLI auth checks
const mockCentralListProjects = vi.fn().mockResolvedValue([]);
@@ -243,6 +245,10 @@ afterAll(() => {
}
});
afterEach(() => {
resetDiagnosticsSink();
});
describe("GET /tasks", () => {
let store: TaskStore;
@@ -9937,6 +9943,20 @@ describe("POST /api/ai/summarize-title", () => {
return app;
}
function captureDiagnostics(): LogEntry[] {
const entries: LogEntry[] = [];
setDiagnosticsSink((level, scope, message, context) => {
entries.push({
level,
scope,
message,
context,
timestamp: new Date(),
});
});
return entries;
}
it("validates description is required", async () => {
const res = await REQUEST(
buildApp(),
@@ -10006,6 +10026,90 @@ describe("POST /api/ai/summarize-title", () => {
"gemini-2.5-pro",
);
});
it("emits structured diagnostics for unexpected summarize failures", async () => {
const diagnostics = captureDiagnostics();
const fusionCore = await import("@fusion/core");
vi.spyOn(fusionCore, "summarizeTitle").mockRejectedValueOnce(new Error("summarize boom"));
const res = await REQUEST(
buildApp(),
"POST",
"/api/ai/summarize-title",
JSON.stringify({ description: "x".repeat(300) }),
{ "Content-Type": "application/json" },
);
expect(res.status).toBe(500);
expect(res.body.error).toContain("summarize boom");
expect(diagnostics).toContainEqual(
expect.objectContaining({
level: "error",
scope: "ai-summarize",
message: "Unexpected summarize title error",
context: expect.objectContaining({
operation: "summarize-title",
error: expect.objectContaining({ message: "summarize boom" }),
}),
}),
);
});
it("emits debug-gated summarize request and model diagnostics when FUSION_DEBUG_AI is enabled", async () => {
const diagnostics = captureDiagnostics();
const fusionCore = await import("@fusion/core");
vi.spyOn(fusionCore, "summarizeTitle").mockResolvedValueOnce("Generated title");
const previousDebug = process.env.FUSION_DEBUG_AI;
process.env.FUSION_DEBUG_AI = "1";
try {
const description = "x".repeat(320);
const res = await REQUEST(
buildApp(),
"POST",
"/api/ai/summarize-title",
JSON.stringify({
description,
provider: "google",
modelId: "gemini-2.5-pro",
}),
{ "Content-Type": "application/json" },
);
expect(res.status).toBe(200);
expect(res.body).toEqual({ title: "Generated title" });
expect(diagnostics).toEqual(
expect.arrayContaining([
expect.objectContaining({
level: "info",
scope: "ai-summarize",
message: "Summarize title request",
context: expect.objectContaining({
descriptionLength: description.length,
operation: "summarize-title-request",
}),
}),
expect.objectContaining({
level: "info",
scope: "ai-summarize",
message: "Summarize title model resolved",
context: expect.objectContaining({
provider: "google",
modelId: "gemini-2.5-pro",
operation: "summarize-title-model-resolution",
}),
}),
]),
);
} finally {
if (previousDebug === undefined) {
delete process.env.FUSION_DEBUG_AI;
} else {
process.env.FUSION_DEBUG_AI = previousDebug;
}
}
});
});
describe("POST /planning/start-streaming with projectId scoping", () => {
@@ -14757,6 +14861,89 @@ describe("POST /workflow-steps/:id/refine with projectId scoping", () => {
// ── Agent Generation Routes ────────────────────────────────────────────────
describe("POST /api/agents/generate/* diagnostics", () => {
let store: TaskStore;
beforeEach(() => {
store = createMockStore();
});
function buildApp() {
const app = express();
app.use(express.json());
app.use("/api", createApiRoutes(store));
return app;
}
function captureDiagnostics(): LogEntry[] {
const entries: LogEntry[] = [];
setDiagnosticsSink((level, scope, message, context) => {
entries.push({
level,
scope,
message,
context,
timestamp: new Date(),
});
});
return entries;
}
it("emits structured diagnostics when /agents/generate/start fails unexpectedly", async () => {
const diagnostics = captureDiagnostics();
vi.spyOn(agentGenerationModule, "startAgentGeneration").mockRejectedValueOnce(new Error("start failed"));
const res = await REQUEST(
buildApp(),
"POST",
"/api/agents/generate/start",
JSON.stringify({ role: "Senior frontend reviewer" }),
{ "Content-Type": "application/json" },
);
expect(res.status).toBe(500);
expect(res.body.error).toContain("start failed");
expect(diagnostics).toContainEqual(
expect.objectContaining({
level: "error",
scope: "agent-generation",
message: "Error starting session",
context: expect.objectContaining({
operation: "generate-start",
error: expect.objectContaining({ message: "start failed" }),
}),
}),
);
});
it("emits structured diagnostics when /agents/generate/spec fails unexpectedly", async () => {
const diagnostics = captureDiagnostics();
vi.spyOn(agentGenerationModule, "generateAgentSpec").mockRejectedValueOnce(new Error("spec failed"));
const res = await REQUEST(
buildApp(),
"POST",
"/api/agents/generate/spec",
JSON.stringify({ sessionId: "session-123" }),
{ "Content-Type": "application/json" },
);
expect(res.status).toBe(500);
expect(res.body.error).toContain("spec failed");
expect(diagnostics).toContainEqual(
expect.objectContaining({
level: "error",
scope: "agent-generation",
message: "Error generating spec",
context: expect.objectContaining({
operation: "generate-spec",
error: expect.objectContaining({ message: "spec failed" }),
}),
}),
);
});
});
describe("POST /agents/generate/spec with projectId scoping", () => {
const projectId = "proj-agent-gen-scoped";

View File

@@ -77,6 +77,7 @@ import { rateLimit, RATE_LIMITS } from "./rate-limit.js";
import { resolvePluginManifest } from "./plugin-routes.js";
import { getAuthFileCandidates, getFusionAuthPath, type StoredAuthProvider } from "./auth-paths.js";
import { createRuntimeLogger, type RuntimeLogger } from "./runtime-logger.js";
import { createSessionDiagnostics } from "./ai-session-diagnostics.js";
const TASK_DETAIL_ACTIVITY_LOG_LIMIT = 500;
@@ -1881,6 +1882,8 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
const planningLogger = runtimeLogger.child("planning");
const proxyLogger = runtimeLogger.child("proxy");
const chatLogger = runtimeLogger.child("chat");
const summarizeDiagnostics = createSessionDiagnostics("ai-summarize");
const agentGenerationDiagnostics = createSessionDiagnostics("agent-generation");
function prioritizeProjectsForCurrentDirectory<T extends { path: string }>(projects: T[]): T[] {
const cwd = resolve(process.cwd());
@@ -9994,11 +9997,13 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
AiServiceError: _AiServiceError2,
} = await import("@fusion/core");
// Debug logging
// Optional debug tracing for summarize flows.
if (process.env.FUSION_DEBUG_AI) {
runtimeLogger.child("ai-summarize").info(
`Request from ${ip}, description length: ${description?.length || 0}`,
);
summarizeDiagnostics.info("Summarize title request", {
ip,
descriptionLength: typeof description === "string" ? description.length : 0,
operation: "summarize-title-request",
});
}
// Check rate limit first
@@ -10041,9 +10046,11 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
(settings.defaultProvider && settings.defaultModelId ? settings.defaultModelId : undefined);
if (process.env.FUSION_DEBUG_AI) {
runtimeLogger.child("ai-summarize").info(
`Resolved model: ${resolvedProvider || "auto"}/${resolvedModelId || "auto"}`,
);
summarizeDiagnostics.info("Summarize title model resolved", {
provider: resolvedProvider ?? "auto",
modelId: resolvedModelId ?? "auto",
operation: "summarize-title-model-resolution",
});
}
// Process summarization
@@ -10066,8 +10073,8 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
} else if (err instanceof Error && err.name === "ValidationError") {
throw badRequest(err instanceof Error ? err.message : String(err));
} else {
runtimeLogger.child("ai-summarize").error("Unexpected error", {
error: err instanceof Error ? err.message : String(err),
summarizeDiagnostics.errorFromException("Unexpected summarize title error", err, {
operation: "summarize-title",
});
rethrowAsApiError(err, "Failed to generate title");
}
@@ -14474,8 +14481,8 @@ async function persistImportedSkills(
if (err instanceof AgentGenerationRateLimitError) {
throw rateLimited(err.message);
}
runtimeLogger.child("agent-generation").error("Error starting session", {
error: err instanceof Error ? err.message : String(err),
agentGenerationDiagnostics.errorFromException("Error starting session", err, {
operation: "generate-start",
});
rethrowAsApiError(err, "Failed to start agent generation session");
}
@@ -14507,8 +14514,8 @@ async function persistImportedSkills(
if (err instanceof AgentGenerationSessionNotFoundError) {
throw notFound(err instanceof Error ? err.message : String(err));
}
runtimeLogger.child("agent-generation").error("Error generating spec", {
error: err instanceof Error ? err.message : String(err),
agentGenerationDiagnostics.errorFromException("Error generating spec", err, {
operation: "generate-spec",
});
rethrowAsApiError(err, "Failed to generate agent specification");
}