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:
@@ -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"),
|
||||
);
|
||||
|
||||
@@ -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-");
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user