import { describe, it, expect, beforeEach, vi, afterEach } from "vitest"; import { EventEmitter } from "node:events"; import { mkdtempSync } from "node:fs"; import { rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import express from "express"; import { Database, TaskStore } from "@fusion/core"; import { createSession, createSessionWithAgent, createDraftSession, startExistingSession, submitResponse, retrySession, rewindSession, cancelSession, stopGeneration, getSession, getCurrentQuestion, getSummary, cleanupSession, planningStreamManager, checkRateLimit, getRateLimitResetTime, __resetPlanningState, __setCreateFnAgent, __setPlanningDiagnostics, __setPlanningNtfyHelpers, rehydrateFromStore, setAiSessionStore, RateLimitError, SessionNotFoundError, InvalidSessionStateError, parseAgentResponse, buildDepthPromptSuffix, generateSubtasksFromPlanning, mergePlanningSubtaskDrafts, formatInterviewQA, SESSION_TTL_MS, GENERATION_TIMEOUT_MS, } from "../planning.js"; import { createApiRoutes } from "../routes.js"; import { request, get } from "../test-request.js"; import type { PlanningQuestion, PlanningSummary } from "@fusion/core"; import { AiSessionStore, type AiSessionRow } from "../ai-session-store.js"; // ── Mock Agent Factory ────────────────────────────────────────────────────── /** * Creates a mock AI agent that responds with predefined JSON responses. * Each call to `prompt()` consumes the next response in the array. */ function createMockAgent(responses: string[]) { const messages: Array<{ role: string; content: string }> = []; let callIndex = 0; return { session: { state: { messages }, prompt: vi.fn(async (msg: string) => { messages.push({ role: "user", content: msg }); const response = responses[callIndex++] ?? responses[responses.length - 1]; messages.push({ role: "assistant", content: response }); }), dispose: vi.fn(), }, }; } /** Standard AI responses for a 3-question flow */ const STANDARD_QUESTION_RESPONSES = [ JSON.stringify({ type: "question", data: { id: "q-scope", type: "single_select", question: "What is the scope of this plan?", description: "This helps estimate the size and complexity of the task.", options: [ { id: "small", label: "Small", description: "Quick" }, { id: "medium", label: "Medium", description: "Standard" }, { id: "large", label: "Large", description: "Complex" }, ], }, }), JSON.stringify({ type: "question", data: { id: "q-requirements", type: "text", question: "What are the key requirements?", description: "List acceptance criteria.", }, }), JSON.stringify({ type: "question", data: { id: "q-confirm", type: "confirm", question: "Are there specific technologies to use?", description: "Answer yes if you have preferences.", }, }), JSON.stringify({ type: "complete", data: { title: "Build Auth System", description: "Build a user authentication system\n\nRequirements: Standard implementation\n\nGenerated via Planning Mode", suggestedSize: "M", suggestedDependencies: [], keyDeliverables: ["Implementation", "Tests", "Documentation"], }, }), ]; /** Root dir for all test sessions */ const TEST_ROOT_DIR = "/test/project"; // Counter for unique IPs per test let ipCounter = 0; function getUniqueIp(): string { return `127.0.0.${++ipCounter}`; } async function flushAsyncWork(): Promise { await vi.waitFor(() => { expect(true).toBe(true); }); } /** * Helper: set up a fresh mock agent for the next createSession call. * Returns the agent so tests can inspect `.session.prompt` calls. */ function setupMockAgent(responses?: string[]) { const agent = createMockAgent(responses ?? STANDARD_QUESTION_RESPONSES); __setCreateFnAgent(async () => agent); return agent; } function setupMockStreamingAgent(options?: { responses?: string[]; thinkingPerPrompt?: string[]; }) { const responses = options?.responses ?? STANDARD_QUESTION_RESPONSES; const thinkingPerPrompt = options?.thinkingPerPrompt ?? []; let promptIndex = 0; const createFnAgentSpy = vi.fn(async (agentOptions?: { onThinking?: (delta: string) => void }) => { const messages: Array<{ role: string; content: string }> = []; return { session: { state: { messages }, prompt: vi.fn(async (message: string) => { messages.push({ role: "user", content: message }); const thinking = thinkingPerPrompt[promptIndex]; if (thinking) { agentOptions?.onThinking?.(thinking); } const response = responses[promptIndex] ?? responses[responses.length - 1]; messages.push({ role: "assistant", content: response }); promptIndex += 1; }), dispose: vi.fn(), }, }; }); __setCreateFnAgent(createFnAgentSpy as any); return { createFnAgentSpy }; } function setupMockPlanningNtfyHelpers(options?: { enabledEvent?: boolean; clickUrl?: string }) { const sendNtfyNotification = vi.fn(async () => undefined); const isNtfyEventEnabled = vi.fn(() => options?.enabledEvent ?? true); const buildNtfyClickUrl = vi.fn(() => options?.clickUrl ?? "http://localhost:4040/?project=proj-123"); __setPlanningNtfyHelpers({ sendNtfyNotification, isNtfyEventEnabled, buildNtfyClickUrl, }); return { sendNtfyNotification, isNtfyEventEnabled, buildNtfyClickUrl }; } class MockAiSessionStore extends EventEmitter { rows = new Map(); upsert(row: AiSessionRow): void { this.rows.set(row.id, row); } updateThinking(id: string, thinkingOutput: string): void { const row = this.rows.get(id); if (!row) { return; } this.rows.set(id, { ...row, thinkingOutput, updatedAt: new Date().toISOString(), }); } delete(id: string): void { this.rows.delete(id); this.emit("ai_session:deleted", id); } get(id: string): AiSessionRow | null { return this.rows.get(id) ?? null; } listRecoverable(): AiSessionRow[] { return [...this.rows.values()].filter( (row) => row.status === "awaiting_input" || row.status === "generating" || row.status === "error", ); } on(event: "ai_session:deleted", listener: (sessionId: string) => void): this { return super.on(event, listener); } off(event: "ai_session:deleted", listener: (sessionId: string) => void): this { return super.off(event, listener); } } function buildPlanningRow( overrides: Partial & Pick, ): AiSessionRow { const now = new Date().toISOString(); return { id: overrides.id, type: "planning", status: overrides.status, title: overrides.title ?? "Recovered planning session", inputPayload: overrides.inputPayload ?? JSON.stringify({ ip: "127.0.0.1", initialPlan: "Recovered planning session" }), conversationHistory: overrides.conversationHistory ?? JSON.stringify([ { question: { id: "q-existing", type: "text", question: "What should we build?", description: "baseline", }, response: { "q-existing": "A useful feature" }, }, ]), currentQuestion: overrides.currentQuestion ?? JSON.stringify({ id: "q-next", type: "text", question: "Any constraints?", description: "detail", }), result: overrides.result ?? null, thinkingOutput: overrides.thinkingOutput ?? "thinking", error: overrides.error ?? null, projectId: overrides.projectId ?? null, createdAt: overrides.createdAt ?? now, updatedAt: overrides.updatedAt ?? now, }; } describe("planning module", () => { const initialPlan = "Build a user authentication system"; // Ensure the engine is loaded before any tests run. // The module-level `engineReady` promise may still be resolving // (importing @fusion/engine) when the first test starts. // We set the mock BEFORE awaiting, so initEngine skips the real import // on subsequent calls (though the first call may already be in-flight). beforeAll(async () => { // Wait for the initial engine load to complete (could be real or failed) // by importing the module and waiting for its side effects. // Then set our mock which will take effect for all test calls. setupMockAgent(); }); beforeEach(() => { __resetPlanningState(); setupMockAgent(); }); afterEach(() => { __setCreateFnAgent(undefined as any); __setPlanningNtfyHelpers(undefined); }); describe("createSession", () => { it("creates a session with valid initial plan", async () => { const mockIp = getUniqueIp(); const result = await createSession(mockIp, initialPlan, undefined, TEST_ROOT_DIR); expect(result.sessionId).toBeDefined(); expect(typeof result.sessionId).toBe("string"); expect(result.firstQuestion).toBeDefined(); expect(result.firstQuestion.id).toBe("q-scope"); expect(result.firstQuestion.type).toBe("single_select"); }); it("throws if rootDir is not provided", async () => { const mockIp = getUniqueIp(); await expect(createSession(mockIp, initialPlan)).rejects.toThrow("rootDir is required"); }); it("enforces rate limiting", async () => { const mockIp = getUniqueIp(); // Create max sessions (1000 per hour) for (let i = 0; i < 1000; i++) { await createSession(mockIp, `${initialPlan} ${i}`, undefined, TEST_ROOT_DIR); } // 1001st session should fail await expect(createSession(mockIp, initialPlan, undefined, TEST_ROOT_DIR)).rejects.toThrow(RateLimitError); }); it("allows new sessions after rate limit window expires", async () => { vi.useFakeTimers({ shouldAdvanceTime: true }); try { const mockIp = getUniqueIp(); // Create max sessions for (let i = 0; i < 1000; i++) { await createSession(mockIp, `${initialPlan} ${i}`, undefined, TEST_ROOT_DIR); } // Advance time by 1 hour + 1 minute vi.advanceTimersByTime(61 * 60 * 1000); // Should now be able to create a new session const result = await createSession(mockIp, "New plan after reset", undefined, TEST_ROOT_DIR); expect(result.sessionId).toBeDefined(); } finally { vi.useRealTimers(); } }); it("generates different session IDs for each session", async () => { const mockIp = getUniqueIp(); const result1 = await createSession(mockIp, "Plan 1", undefined, TEST_ROOT_DIR); const result2 = await createSession(mockIp, "Plan 2", undefined, TEST_ROOT_DIR); expect(result1.sessionId).not.toBe(result2.sessionId); }); it("stores the AI agent on the session", async () => { const mockIp = getUniqueIp(); const { sessionId } = await createSession(mockIp, initialPlan, undefined, TEST_ROOT_DIR); const session = getSession(sessionId); expect(session).toBeDefined(); expect(session?.agent).toBeDefined(); }); it("passes builtin web tool allowlist when creating non-streaming planning agent", async () => { const createFnAgentSpy = vi.fn(async () => createMockAgent(STANDARD_QUESTION_RESPONSES)); __setCreateFnAgent(createFnAgentSpy as any); await createSession(getUniqueIp(), initialPlan, undefined, TEST_ROOT_DIR); expect(createFnAgentSpy).toHaveBeenCalledWith(expect.objectContaining({ tools: "readonly", builtinToolsAllowlist: ["WebSearch", "WebFetch"], })); }); it("cleans up session on agent failure", async () => { __setCreateFnAgent(async () => { throw new Error("Agent creation failed"); }); const mockIp = getUniqueIp(); await expect(createSession(mockIp, initialPlan, undefined, TEST_ROOT_DIR)).rejects.toThrow( "Agent creation failed" ); }); it("cleans up session when AI returns unparseable output", async () => { setupMockAgent(["I am not JSON at all"]); const mockIp = getUniqueIp(); await expect(createSession(mockIp, initialPlan, undefined, TEST_ROOT_DIR)).rejects.toThrow( "Failed to get first question from AI" ); }); it("handles AI returning a summary instead of a first question", async () => { setupMockAgent([ JSON.stringify({ type: "complete", data: { title: "Auth System", description: "Build auth", suggestedSize: "M", suggestedDependencies: [], keyDeliverables: ["Login"], }, }), ]); const mockIp = getUniqueIp(); const result = await createSession(mockIp, initialPlan, undefined, TEST_ROOT_DIR); // Should return a confirm question wrapping the summary expect(result.firstQuestion.type).toBe("confirm"); expect(result.firstQuestion.id).toBe("q-direct-summary"); expect(result.firstQuestion.question).toContain("Auth System"); }); }); describe("createSessionWithAgent", () => { it("passes planning model override to createFnAgent when provided", async () => { const createFnAgentSpy = vi.fn(async () => createMockAgent(STANDARD_QUESTION_RESPONSES)); __setCreateFnAgent(createFnAgentSpy as any); const sessionId = await createSessionWithAgent( getUniqueIp(), "Build auth system", TEST_ROOT_DIR, "google", "gemini-2.5-pro", ); expect(sessionId).toBeDefined(); await vi.waitFor(() => { expect(createFnAgentSpy).toHaveBeenCalledTimes(1); }, { timeout: 10000 }); expect(createFnAgentSpy).toHaveBeenCalledWith( expect.objectContaining({ defaultProvider: "google", defaultModelId: "gemini-2.5-pro", }), ); }); it("creates agent without model overrides when none provided", async () => { const createFnAgentSpy = vi.fn(async () => createMockAgent(STANDARD_QUESTION_RESPONSES)); __setCreateFnAgent(createFnAgentSpy as any); const sessionId = await createSessionWithAgent(getUniqueIp(), "Build auth system", TEST_ROOT_DIR); expect(sessionId).toBeDefined(); await vi.waitFor(() => { expect(createFnAgentSpy).toHaveBeenCalledTimes(1); }, { timeout: 10000 }); const callArg = createFnAgentSpy.mock.calls[0]?.[0] as Record; expect(callArg?.defaultProvider).toBeUndefined(); expect(callArg?.defaultModelId).toBeUndefined(); expect(callArg?.builtinToolsAllowlist).toEqual(["WebSearch", "WebFetch"]); }); it("uses custom prompt from promptOverrides when provided", async () => { const createFnAgentSpy = vi.fn(async () => createMockAgent(STANDARD_QUESTION_RESPONSES)); __setCreateFnAgent(createFnAgentSpy as any); const customPrompt = "Custom planning prompt with specific guidelines..."; const promptOverrides = { "planning-system": customPrompt }; const sessionId = await createSessionWithAgent( getUniqueIp(), "Build auth system", TEST_ROOT_DIR, undefined, undefined, promptOverrides, ); expect(sessionId).toBeDefined(); await vi.waitFor(() => { expect(createFnAgentSpy).toHaveBeenCalledTimes(1); }, { timeout: 10000 }); const callArg = createFnAgentSpy.mock.calls[0]?.[0] as Record; expect(callArg?.systemPrompt).toBe(customPrompt); }); it("falls back to default prompt when promptOverrides is undefined", async () => { const createFnAgentSpy = vi.fn(async () => createMockAgent(STANDARD_QUESTION_RESPONSES)); __setCreateFnAgent(createFnAgentSpy as any); const sessionId = await createSessionWithAgent( getUniqueIp(), "Build auth system", TEST_ROOT_DIR, undefined, undefined, undefined, ); expect(sessionId).toBeDefined(); await vi.waitFor(() => { expect(createFnAgentSpy).toHaveBeenCalledTimes(1); }, { timeout: 10000 }); const callArg = createFnAgentSpy.mock.calls[0]?.[0] as Record; expect(callArg?.systemPrompt).toContain("planning assistant"); }); it("falls back to default prompt when promptOverrides does not contain planning key", async () => { const createFnAgentSpy = vi.fn(async () => createMockAgent(STANDARD_QUESTION_RESPONSES)); __setCreateFnAgent(createFnAgentSpy as any); // Provide an override for a different key const promptOverrides = { "triage-welcome": "Some other prompt" }; const sessionId = await createSessionWithAgent( getUniqueIp(), "Build auth system", TEST_ROOT_DIR, undefined, undefined, promptOverrides, ); expect(sessionId).toBeDefined(); await vi.waitFor(() => { expect(createFnAgentSpy).toHaveBeenCalledTimes(1); }, { timeout: 10000 }); const callArg = createFnAgentSpy.mock.calls[0]?.[0] as Record; expect(callArg?.systemPrompt).toContain("planning assistant"); }); it("logs error diagnostic when agent initialization fails and preserves error state", async () => { // Import the shared helper for diagnostics capture const { setDiagnosticsSink, resetDiagnosticsSink } = await import("../ai-session-diagnostics.js"); let loggedErrors: Array<{ level: string; scope: string; message: string; context: Record }> = []; setDiagnosticsSink((level, scope, message, context) => { loggedErrors.push({ level, scope, message, context }); }); try { __setCreateFnAgent(async () => { throw new Error("Agent creation failed"); }); const sessionId = await createSessionWithAgent( getUniqueIp(), "Build auth system", TEST_ROOT_DIR, ); // Wait for the async initialization to complete (errors are logged in initializeAgent's catch block) await vi.waitFor( () => { return loggedErrors.some( (e) => e.message === "Agent initialization error for session" && e.context.sessionId === sessionId ); }, { timeout: 10000 }, ); // Verify the error was logged with correct structure await vi.waitFor( () => { const agentError = loggedErrors.find( (e) => e.message === "Agent initialization error for session" && e.context.sessionId === sessionId ); expect(agentError).toBeDefined(); expect(agentError?.level).toBe("error"); expect(agentError?.scope).toBe("planning"); }, { timeout: 5000 }, ); const agentError = loggedErrors.find( (e) => e.message === "Agent initialization error for session" && e.context.sessionId === sessionId ); expect(agentError?.context.error).toBeDefined(); expect((agentError?.context.error as { message: string }).message).toBe("Agent creation failed"); expect(agentError?.context.operation).toBe("initialize-agent"); // Verify session is in error state const session = getSession(sessionId); expect(session?.error).toContain("Agent creation failed"); } finally { resetDiagnosticsSink(); } }); it("persists projectId across planning session state transitions", async () => { const store = new MockAiSessionStore(); setAiSessionStore(store as any); setupMockStreamingAgent({ responses: STANDARD_QUESTION_RESPONSES }); const sessionId = await createSessionWithAgent( getUniqueIp(), "Build auth system", TEST_ROOT_DIR, undefined, undefined, undefined, { projectId: "proj-123", ntfyConfig: { enabled: false, topic: "planning-topic" }, }, ); await vi.waitFor(() => { expect(store.get(sessionId)?.status).toBe("awaiting_input"); }); expect(store.get(sessionId)?.projectId).toBe("proj-123"); await submitResponse(sessionId, { "q-scope": "medium" }, TEST_ROOT_DIR); await vi.waitFor(() => { expect(store.get(sessionId)?.status).toBe("awaiting_input"); }); expect(store.get(sessionId)?.projectId).toBe("proj-123"); await submitResponse(sessionId, { "q-requirements": "Must support SSO" }, TEST_ROOT_DIR); await submitResponse(sessionId, { "q-confirm": true }, TEST_ROOT_DIR); await vi.waitFor(() => { expect(store.get(sessionId)?.status).toBe("complete"); }); expect(store.get(sessionId)?.projectId).toBe("proj-123"); }); it("sends planning awaiting-input notifications once per question and allows later distinct questions", async () => { const firstQuestion = JSON.stringify({ type: "question", data: { id: "q-1", type: "text", question: "First question?", description: "one" }, }); const repeatedQuestion = JSON.stringify({ type: "question", data: { id: "q-1", type: "text", question: "First question?", description: "one" }, }); const secondQuestion = JSON.stringify({ type: "question", data: { id: "q-2", type: "text", question: "Second question?", description: "two" }, }); setupMockStreamingAgent({ responses: [firstQuestion, repeatedQuestion, secondQuestion] }); const { sendNtfyNotification, isNtfyEventEnabled, buildNtfyClickUrl } = setupMockPlanningNtfyHelpers({ enabledEvent: true, clickUrl: "http://localhost:4040/?project=proj-123", }); const sessionId = await createSessionWithAgent( getUniqueIp(), "Build auth system", TEST_ROOT_DIR, undefined, undefined, undefined, { projectId: "proj-123", ntfyConfig: { enabled: true, topic: "planning-topic", dashboardHost: "http://localhost:4040/", events: ["planning-awaiting-input"], }, }, ); await vi.waitFor(() => { expect(sendNtfyNotification).toHaveBeenCalledTimes(1); }); await submitResponse(sessionId, { "q-1": "answer one" }, TEST_ROOT_DIR); await flushAsyncWork(); expect(sendNtfyNotification).toHaveBeenCalledTimes(1); await submitResponse(sessionId, { "q-1": "answer two" }, TEST_ROOT_DIR); await vi.waitFor(() => { expect(sendNtfyNotification).toHaveBeenCalledTimes(2); }); expect(isNtfyEventEnabled).toHaveBeenCalledWith(["planning-awaiting-input"], "planning-awaiting-input"); expect(buildNtfyClickUrl).toHaveBeenCalledWith({ dashboardHost: "http://localhost:4040/", projectId: "proj-123", }); expect(sendNtfyNotification).toHaveBeenNthCalledWith( 1, expect.objectContaining({ topic: "planning-topic", priority: "high", clickUrl: "http://localhost:4040/?project=proj-123", }), ); }); it("detects NotificationService abstraction in engine while using ntfy helpers for planning notifications", async () => { const firstQuestion = JSON.stringify({ type: "question", data: { id: "q-1", type: "text", question: "First question?", description: "one" }, }); setupMockStreamingAgent({ responses: [firstQuestion] }); const { sendNtfyNotification, isNtfyEventEnabled, buildNtfyClickUrl } = setupMockPlanningNtfyHelpers({ enabledEvent: true, clickUrl: "http://localhost:4040/?project=proj-123", }); await createSessionWithAgent( getUniqueIp(), "Build auth system", TEST_ROOT_DIR, undefined, undefined, undefined, { projectId: "proj-123", ntfyConfig: { enabled: true, topic: "planning-topic", dashboardHost: "http://localhost:4040/", events: ["planning-awaiting-input"], }, }, ); await vi.waitFor(() => { expect(sendNtfyNotification).toHaveBeenCalledTimes(1); }); expect(sendNtfyNotification).toHaveBeenNthCalledWith( 1, expect.objectContaining({ topic: "planning-topic", priority: "high", clickUrl: "http://localhost:4040/?project=proj-123", }), ); expect(isNtfyEventEnabled).toHaveBeenCalledWith(["planning-awaiting-input"], "planning-awaiting-input"); expect(buildNtfyClickUrl).toHaveBeenCalledWith({ dashboardHost: "http://localhost:4040/", projectId: "proj-123", }); }); it("suppresses planning awaiting-input notifications when event is disabled", async () => { setupMockStreamingAgent({ responses: STANDARD_QUESTION_RESPONSES }); const { sendNtfyNotification } = setupMockPlanningNtfyHelpers({ enabledEvent: false }); await createSessionWithAgent( getUniqueIp(), "Build auth system", TEST_ROOT_DIR, undefined, undefined, undefined, { projectId: "proj-123", ntfyConfig: { enabled: true, topic: "planning-topic", dashboardHost: "http://localhost:4040/", events: ["failed"], }, }, ); await flushAsyncWork(); expect(sendNtfyNotification).not.toHaveBeenCalled(); }); }); describe("draft session helpers", () => { it("creates a draft session with draft status", async () => { const session = await createDraftSession( getUniqueIp(), "Draft plan text for the planning modal", TEST_ROOT_DIR, ); expect(session.sessionId).toBeDefined(); expect(session.title).toBe("New planning session"); expect(getSession(session.sessionId)?.id).toBe(session.sessionId); }); it("starts an existing draft session and moves it into active flow", async () => { setupMockStreamingAgent({ responses: STANDARD_QUESTION_RESPONSES }); const draft = await createDraftSession( getUniqueIp(), "Draft plan reused by start", TEST_ROOT_DIR, ); await startExistingSession(draft.sessionId, TEST_ROOT_DIR); await vi.waitFor(() => { expect(getSession(draft.sessionId)?.currentQuestion?.id).toBe("q-scope"); }); }); it("throws when starting a missing draft session", async () => { await expect(startExistingSession("missing-session", TEST_ROOT_DIR)).rejects.toThrow( SessionNotFoundError, ); }); }); describe("submitResponse", () => { it("processes response and returns next question", async () => { const mockIp = getUniqueIp(); const { sessionId } = await createSession(mockIp, initialPlan, undefined, TEST_ROOT_DIR); const response = await submitResponse(sessionId, { scope: "medium" }); expect(response.type).toBe("question"); if (response.type === "question") { expect(response.data.type).toBe("text"); } }); it("returns summary after multiple responses", async () => { const mockIp = getUniqueIp(); const { sessionId } = await createSession(mockIp, initialPlan, undefined, TEST_ROOT_DIR); // Submit first response const response1 = await submitResponse(sessionId, { scope: "medium" }); expect(response1.type).toBe("question"); // Submit second response const response2 = await submitResponse(sessionId, { requirements: "Must have login and logout" }); expect(response2.type).toBe("question"); // Submit third response - should get summary const response3 = await submitResponse(sessionId, { confirm: true }); expect(response3.type).toBe("complete"); if (response3.type === "complete") { expect(response3.data.title).toBeDefined(); expect(response3.data.description).toBeDefined(); expect(response3.data.suggestedSize).toBeDefined(); expect(response3.data.keyDeliverables).toBeInstanceOf(Array); } }); it("throws SessionNotFoundError for invalid session ID", async () => { await expect(submitResponse("invalid-session-id", {})).rejects.toThrow(SessionNotFoundError); }); it("throws InvalidSessionStateError when no active question and not refining", async () => { const mockIp = getUniqueIp(); const { sessionId } = await createSession(mockIp, initialPlan, undefined, TEST_ROOT_DIR); // Complete the session await submitResponse(sessionId, { scope: "small" }); await submitResponse(sessionId, { requirements: "test" }); await submitResponse(sessionId, { confirm: true }); // Try to submit another response await expect(submitResponse(sessionId, {})).rejects.toThrow(InvalidSessionStateError); }); it("continues from summary when refine is requested", async () => { const mockIp = getUniqueIp(); setupMockAgent([ ...STANDARD_QUESTION_RESPONSES, JSON.stringify({ type: "question", data: { id: "q-refine", type: "text", question: "What should we tighten in this plan?", description: "Refine follow-up", }, }), ]); const { sessionId } = await createSession(mockIp, initialPlan, undefined, TEST_ROOT_DIR); await submitResponse(sessionId, { scope: "small" }, TEST_ROOT_DIR); await submitResponse(sessionId, { requirements: "test" }, TEST_ROOT_DIR); await submitResponse(sessionId, { confirm: true }, TEST_ROOT_DIR); const response = await submitResponse(sessionId, { refine: true }, TEST_ROOT_DIR); expect(response.type).toBe("question"); if (response.type === "question") { expect(response.data.id).toBe("q-refine"); } expect(getSummary(sessionId)).toBeUndefined(); }); it("rehydrates a completed persisted session and refines from summary", async () => { const store = new MockAiSessionStore(); const summary = { title: "Recovered summary", description: "Recovered summary description", suggestedSize: "M", suggestedDependencies: [], keyDeliverables: ["Deliverable"], }; const row = buildPlanningRow({ id: "planning-complete-refine", status: "complete", conversationHistory: JSON.stringify([ { question: { id: "q-existing", type: "text", question: "What should we build?", description: "baseline", }, response: { "q-existing": "A useful feature" }, }, ]), currentQuestion: "null", result: JSON.stringify(summary), }); store.rows.set(row.id, row); setAiSessionStore(store as any); const resumedAgent = createMockAgent([ JSON.stringify({ type: "question", data: { id: "q-refine-rehydrated", type: "text", question: "Any additional constraints?", description: "Refine resumed", }, }), ]); const createFnAgentSpy = vi.fn(async () => resumedAgent); __setCreateFnAgent(createFnAgentSpy as any); const response = await submitResponse(row.id, { refine: true }, TEST_ROOT_DIR); expect(response.type).toBe("question"); if (response.type === "question") { expect(response.data.id).toBe("q-refine-rehydrated"); } expect(createFnAgentSpy).toHaveBeenCalledTimes(1); expect(resumedAgent.session.prompt).toHaveBeenCalledTimes(2); expect(resumedAgent.session.prompt.mock.calls[0]?.[0]).toContain("Previous conversation summary"); expect(resumedAgent.session.prompt.mock.calls[1]?.[0]).toContain("Refine Further"); }); it("reconstructs agent for a rehydrated session and continues conversation", async () => { const store = new MockAiSessionStore(); const row = buildPlanningRow({ id: "planning-rehydrated-1", status: "awaiting_input", conversationHistory: JSON.stringify([ { question: { id: "q-1", type: "text", question: "What should we build?", description: "scope", }, response: { "q-1": "Authentication" }, }, ]), currentQuestion: JSON.stringify({ id: "q-2", type: "text", question: "Any constraints?", description: "details", }), }); store.rows.set(row.id, row); setAiSessionStore(store as any); expect(rehydrateFromStore(store as any)).toBe(1); const resumedAgent = createMockAgent([ JSON.stringify({ type: "question", data: { id: "q-3", type: "text", question: "Do you need tests?", description: "quality", }, }), ]); const createFnAgentSpy = vi.fn(async () => resumedAgent); __setCreateFnAgent(createFnAgentSpy as any); const response = await submitResponse( row.id, { "q-2": "Must run on mobile" }, TEST_ROOT_DIR, ); expect(response.type).toBe("question"); if (response.type === "question") { expect(response.data.id).toBe("q-3"); } expect(createFnAgentSpy).toHaveBeenCalledWith( expect.objectContaining({ cwd: TEST_ROOT_DIR, systemPrompt: expect.stringContaining("planning assistant"), }), ); expect(resumedAgent.session.prompt).toHaveBeenCalledTimes(2); expect(resumedAgent.session.prompt.mock.calls[0]?.[0]).toContain("Previous conversation summary"); expect(resumedAgent.session.prompt.mock.calls[1]?.[0]).toContain("Any constraints?"); expect(getSession(row.id)?.agent).toBeDefined(); }); it("throws InvalidSessionStateError when resuming without project context", async () => { const store = new MockAiSessionStore(); const row = buildPlanningRow({ id: "planning-rehydrated-2", status: "awaiting_input" }); store.rows.set(row.id, row); setAiSessionStore(store as any); rehydrateFromStore(store as any); await expect(submitResponse(row.id, { "q-next": "answer" })).rejects.toThrow( "cannot be resumed without project context", ); }); it("captures first generated question thinking in lastGeneratedThinking", async () => { setupMockStreamingAgent({ responses: STANDARD_QUESTION_RESPONSES, thinkingPerPrompt: ["First question reasoning"], }); const sessionId = await createSessionWithAgent(getUniqueIp(), initialPlan, TEST_ROOT_DIR); await vi.waitFor(() => { expect(getSession(sessionId)?.currentQuestion?.id).toBe("q-scope"); }); expect(getSession(sessionId)?.lastGeneratedThinking).toBe("First question reasoning"); }); it("stores per-turn thinking output in history entries", async () => { setupMockStreamingAgent({ responses: STANDARD_QUESTION_RESPONSES, thinkingPerPrompt: ["First question thinking", "Second question thinking"], }); const sessionId = await createSessionWithAgent(getUniqueIp(), initialPlan, TEST_ROOT_DIR); await vi.waitFor(() => { expect(getSession(sessionId)?.currentQuestion?.id).toBe("q-scope"); }); const response = await submitResponse(sessionId, { "q-scope": "medium" }, TEST_ROOT_DIR); expect(response.type).toBe("question"); const session = getSession(sessionId); expect(session?.history[0]).toMatchObject({ question: expect.objectContaining({ id: "q-scope" }), response: { "q-scope": "medium" }, thinkingOutput: "First question thinking", }); }); it("persists per-turn thinking in conversationHistory JSON", async () => { const store = new MockAiSessionStore(); setAiSessionStore(store as any); setupMockStreamingAgent({ responses: STANDARD_QUESTION_RESPONSES, thinkingPerPrompt: ["Persisted first-turn thinking", "Persisted second-turn thinking"], }); const sessionId = await createSessionWithAgent(getUniqueIp(), initialPlan, TEST_ROOT_DIR); await vi.waitFor(() => { expect(getSession(sessionId)?.currentQuestion?.id).toBe("q-scope"); }); await submitResponse(sessionId, { "q-scope": "medium" }, TEST_ROOT_DIR); const row = store.get(sessionId); expect(row).not.toBeNull(); const persistedHistory = JSON.parse(row!.conversationHistory) as Array<{ question: PlanningQuestion; response: Record; thinkingOutput?: string; }>; expect(persistedHistory[0]).toMatchObject({ question: expect.objectContaining({ id: "q-scope" }), response: { "q-scope": "medium" }, thinkingOutput: "Persisted first-turn thinking", }); }); }); describe("rewindSession", () => { it("rewinds to the previous question and trims history", async () => { const { sessionId } = await createSession(getUniqueIp(), initialPlan, undefined, TEST_ROOT_DIR); await submitResponse(sessionId, { "q-scope": "medium" }, TEST_ROOT_DIR); const rewound = await rewindSession(sessionId, TEST_ROOT_DIR); expect(rewound.currentQuestion.id).toBe("q-scope"); expect(rewound.history).toHaveLength(0); const session = getSession(sessionId); expect(session?.currentQuestion?.id).toBe("q-scope"); expect(session?.history).toHaveLength(0); }); it("throws when no answered question exists", async () => { const { sessionId } = await createSession(getUniqueIp(), initialPlan, undefined, TEST_ROOT_DIR); await expect(rewindSession(sessionId, TEST_ROOT_DIR)).rejects.toThrow(InvalidSessionStateError); }); }); describe("retrySession", () => { it("rehydrates errored sessions and replays the last user response", async () => { const store = new MockAiSessionStore(); const row = buildPlanningRow({ id: "planning-error-retry-1", status: "error", error: "Transient model failure", conversationHistory: JSON.stringify([ { question: { id: "q-1", type: "text", question: "What should we build?", description: "scope", }, response: { "q-1": "Authentication" }, }, ]), currentQuestion: JSON.stringify({ id: "q-2", type: "text", question: "Any constraints?", description: "details", }), }); store.rows.set(row.id, row); setAiSessionStore(store as any); const resumedAgent = createMockAgent([ JSON.stringify({ type: "question", data: { id: "q-retry", type: "text", question: "Any delivery deadline?", description: "timing", }, }), ]); __setCreateFnAgent(async () => resumedAgent); await retrySession(row.id, TEST_ROOT_DIR); expect(resumedAgent.session.prompt).toHaveBeenCalledTimes(1); expect(resumedAgent.session.prompt.mock.calls[0]?.[0]).toContain("What should we build?"); expect(resumedAgent.session.prompt.mock.calls[0]?.[0]).toContain("Authentication"); const session = getSession(row.id); expect(session?.currentQuestion?.id).toBe("q-retry"); expect(session?.error).toBeUndefined(); expect(store.get(row.id)?.status).toBe("awaiting_input"); expect(store.get(row.id)?.error).toBeNull(); }); it("replays the initial plan when no history exists", async () => { const store = new MockAiSessionStore(); const row = buildPlanningRow({ id: "planning-error-retry-2", status: "error", error: "First turn failed", inputPayload: JSON.stringify({ ip: "127.0.0.9", initialPlan: "Ship notifications" }), conversationHistory: "[]", currentQuestion: null, }); store.rows.set(row.id, row); setAiSessionStore(store as any); const resumedAgent = createMockAgent([ JSON.stringify({ type: "question", data: { id: "q-first", type: "text", question: "Who is the target user?", description: "audience", }, }), ]); __setCreateFnAgent(async () => resumedAgent); await retrySession(row.id, TEST_ROOT_DIR); expect(resumedAgent.session.prompt).toHaveBeenCalledTimes(1); expect(resumedAgent.session.prompt.mock.calls[0]?.[0]).toBe("Ship notifications"); expect(store.get(row.id)?.status).toBe("awaiting_input"); }); it("throws when retrying a non-error session", async () => { const store = new MockAiSessionStore(); const row = buildPlanningRow({ id: "planning-not-error", status: "awaiting_input" }); store.rows.set(row.id, row); setAiSessionStore(store as any); await expect(retrySession(row.id, TEST_ROOT_DIR)).rejects.toThrow(InvalidSessionStateError); }); it("uses custom prompt from promptOverrides on retry", async () => { const store = new MockAiSessionStore(); const row = buildPlanningRow({ id: "planning-retry-with-override", status: "error", error: "Transient failure", conversationHistory: JSON.stringify([ { question: { id: "q-1", type: "text", question: "What to build?", description: "scope" }, response: { "q-1": "Auth" }, }, ]), currentQuestion: JSON.stringify({ id: "q-2", type: "text", question: "Any constraints?", description: "details", }), }); store.rows.set(row.id, row); setAiSessionStore(store as any); const customPrompt = "Custom retry prompt..."; const promptOverrides = { "planning-system": customPrompt }; const resumedAgent = createMockAgent([ JSON.stringify({ type: "question", data: { id: "q-retry", type: "text", question: "Deadline?", description: "timing", }, }), ]); const createFnAgentSpy = vi.fn(async () => resumedAgent); __setCreateFnAgent(createFnAgentSpy as any); await retrySession(row.id, TEST_ROOT_DIR, promptOverrides); expect(createFnAgentSpy).toHaveBeenCalledTimes(1); const callArg = createFnAgentSpy.mock.calls[0]?.[0] as Record; expect(callArg?.systemPrompt).toBe(customPrompt); }); it("falls back to default prompt on retry when promptOverrides is undefined", async () => { const store = new MockAiSessionStore(); const row = buildPlanningRow({ id: "planning-retry-no-override", status: "error", error: "Transient failure", conversationHistory: JSON.stringify([ { question: { id: "q-1", type: "text", question: "What to build?", description: "scope" }, response: { "q-1": "Auth" }, }, ]), currentQuestion: JSON.stringify({ id: "q-2", type: "text", question: "Any constraints?", description: "details", }), }); store.rows.set(row.id, row); setAiSessionStore(store as any); const resumedAgent = createMockAgent([ JSON.stringify({ type: "question", data: { id: "q-retry", type: "text", question: "Deadline?", description: "timing", }, }), ]); const createFnAgentSpy = vi.fn(async () => resumedAgent); __setCreateFnAgent(createFnAgentSpy as any); await retrySession(row.id, TEST_ROOT_DIR); expect(createFnAgentSpy).toHaveBeenCalledTimes(1); const callArg = createFnAgentSpy.mock.calls[0]?.[0] as Record; expect(callArg?.systemPrompt).toContain("planning assistant"); }); }); describe("cancelSession", () => { it("removes an active session", async () => { const mockIp = getUniqueIp(); const { sessionId } = await createSession(mockIp, initialPlan, undefined, TEST_ROOT_DIR); await cancelSession(sessionId); // Should not be able to find the session anymore expect(getSession(sessionId)).toBeUndefined(); }); it("throws SessionNotFoundError for non-existent session", async () => { await expect(cancelSession("non-existent-id")).rejects.toThrow(SessionNotFoundError); }); }); describe("generation controls", () => { it("returns false when stopping unknown session", () => { expect(stopGeneration("missing-session")).toBe(false); }); it("stops in-flight generation and sets user-visible error", async () => { let resolvePrompt: (() => void) | undefined; const hangingAgent = { session: { state: { messages: [] as Array<{ role: string; content: string }> }, prompt: vi.fn( () => new Promise((resolve) => { resolvePrompt = resolve; }), ), dispose: vi.fn(), }, }; __setCreateFnAgent(async () => hangingAgent as any); const sessionId = await createSessionWithAgent(getUniqueIp(), initialPlan, TEST_ROOT_DIR); await vi.waitFor(() => { expect(hangingAgent.session.prompt).toHaveBeenCalledTimes(1); }); const stopped = stopGeneration(sessionId); expect(stopped).toBe(true); expect(hangingAgent.session.dispose).toHaveBeenCalled(); await flushAsyncWork(); expect(getSession(sessionId)?.error).toContain("Generation stopped by user"); resolvePrompt?.(); }); it("times out stalled generation and transitions session to error", async () => { vi.useFakeTimers(); try { const hangingAgent = { session: { state: { messages: [] as Array<{ role: string; content: string }> }, prompt: vi.fn(() => new Promise(() => {})), dispose: vi.fn(), }, }; __setCreateFnAgent(async () => hangingAgent as any); const sessionId = await createSessionWithAgent(getUniqueIp(), initialPlan, TEST_ROOT_DIR); await vi.advanceTimersByTimeAsync(GENERATION_TIMEOUT_MS + 10); await flushAsyncWork(); expect(getSession(sessionId)?.error).toContain("timed out"); } finally { vi.useRealTimers(); } }); }); describe("rehydrateFromStore", () => { it("rehydrates planning sessions from SQLite rows", () => { const store = new MockAiSessionStore(); const planningRow = buildPlanningRow({ id: "planning-row-1", status: "awaiting_input" }); const subtaskRow: AiSessionRow = { ...buildPlanningRow({ id: "subtask-row-1", status: "awaiting_input" }), type: "subtask", }; store.rows.set(planningRow.id, planningRow); store.rows.set(subtaskRow.id, subtaskRow); const rehydrated = rehydrateFromStore(store as any); expect(rehydrated).toBe(1); const session = getSession(planningRow.id); expect(session).toBeDefined(); expect(session?.id).toBe(planningRow.id); expect(session?.ip).toBe("127.0.0.1"); expect(session?.currentQuestion?.id).toBe("q-next"); expect(session?.thinkingOutput).toBe("thinking"); }); it("skips corrupted rows and continues rehydrating valid sessions", async () => { // Import the shared helper for diagnostics capture const { setDiagnosticsSink, resetDiagnosticsSink } = await import("../ai-session-diagnostics.js"); const store = new MockAiSessionStore(); const goodRow = buildPlanningRow({ id: "planning-good", status: "awaiting_input" }); const badRow = buildPlanningRow({ id: "planning-bad", status: "awaiting_input", conversationHistory: "{bad-json", }); store.rows.set(goodRow.id, goodRow); store.rows.set(badRow.id, badRow); let loggedErrors: Array<{ level: string; scope: string; message: string; context: Record }> = []; setDiagnosticsSink((level, scope, message, context) => { loggedErrors.push({ level, scope, message, context }); }); try { const rehydrated = rehydrateFromStore(store as any); expect(rehydrated).toBe(1); expect(getSession(goodRow.id)).toBeDefined(); expect(getSession(badRow.id)).toBeUndefined(); expect(loggedErrors).toContainEqual( expect.objectContaining({ level: "error", scope: "planning", message: "Failed to rehydrate session", context: expect.objectContaining({ sessionId: "planning-bad", operation: "rehydrate", }), }) ); } finally { resetDiagnosticsSink(); } }); }); describe("getSession", () => { it("returns session for valid ID", async () => { const mockIp = getUniqueIp(); const { sessionId } = await createSession(mockIp, initialPlan, undefined, TEST_ROOT_DIR); const session = getSession(sessionId); expect(session).toBeDefined(); expect(session?.id).toBe(sessionId); expect(session?.initialPlan).toBe(initialPlan); expect(session?.ip).toBe(mockIp); }); it("returns session from memory before SQLite", async () => { const store = new MockAiSessionStore(); const getSpy = vi.spyOn(store, "get"); const mockIp = getUniqueIp(); const { sessionId } = await createSession(mockIp, initialPlan, undefined, TEST_ROOT_DIR); store.rows.set( sessionId, buildPlanningRow({ id: sessionId, status: "awaiting_input", inputPayload: JSON.stringify({ ip: "10.0.0.1", initialPlan: "sqlite-plan" }), }), ); setAiSessionStore(store as any); const session = getSession(sessionId); expect(session?.initialPlan).toBe(initialPlan); expect(session?.ip).toBe(mockIp); expect(getSpy).not.toHaveBeenCalled(); }); it("falls through to SQLite when session is missing in memory", () => { const store = new MockAiSessionStore(); const row = buildPlanningRow({ id: "planning-fallthrough", status: "awaiting_input" }); store.rows.set(row.id, row); setAiSessionStore(store as any); const session = getSession(row.id); expect(session).toBeDefined(); expect(session?.id).toBe(row.id); expect(session?.initialPlan).toBe("Recovered planning session"); expect(session?.agent).toBeUndefined(); }); it("returns undefined when session exists nowhere", () => { const store = new MockAiSessionStore(); setAiSessionStore(store as any); expect(getSession("invalid-id")).toBeUndefined(); }); }); describe("getCurrentQuestion", () => { it("returns current question for active session", async () => { const mockIp = getUniqueIp(); const { sessionId, firstQuestion } = await createSession(mockIp, initialPlan, undefined, TEST_ROOT_DIR); const question = getCurrentQuestion(sessionId); expect(question).toEqual(firstQuestion); }); it("returns undefined for completed session", async () => { const mockIp = getUniqueIp(); const { sessionId } = await createSession(mockIp, initialPlan, undefined, TEST_ROOT_DIR); // Complete the session await submitResponse(sessionId, { scope: "small" }); await submitResponse(sessionId, { requirements: "test" }); await submitResponse(sessionId, { confirm: true }); const question = getCurrentQuestion(sessionId); expect(question).toBeUndefined(); }); }); describe("getSummary", () => { it("returns summary for completed session", async () => { const mockIp = getUniqueIp(); const { sessionId } = await createSession(mockIp, initialPlan, undefined, TEST_ROOT_DIR); // Complete the session await submitResponse(sessionId, { scope: "small" }); await submitResponse(sessionId, { requirements: "test" }); const response = await submitResponse(sessionId, { confirm: true }); if (response.type === "complete") { const summary = getSummary(sessionId); expect(summary).toEqual(response.data); } }); it("returns undefined for incomplete session", async () => { const mockIp = getUniqueIp(); const { sessionId } = await createSession(mockIp, initialPlan, undefined, TEST_ROOT_DIR); const summary = getSummary(sessionId); expect(summary).toBeUndefined(); }); }); describe("cleanupSession", () => { it("removes a session from memory", async () => { const mockIp = getUniqueIp(); const { sessionId } = await createSession(mockIp, initialPlan, undefined, TEST_ROOT_DIR); cleanupSession(sessionId); expect(getSession(sessionId)).toBeUndefined(); }); }); describe("rate limiting", () => { it("checkRateLimit returns true for first request", () => { const result = checkRateLimit(getUniqueIp()); expect(result).toBe(true); }); it("getRateLimitResetTime returns null for unknown IP", () => { const resetTime = getRateLimitResetTime("unknown-ip"); expect(resetTime).toBeNull(); }); it("getRateLimitResetTime returns Date for rate limited IP", async () => { const mockIp = getUniqueIp(); // Max out the rate limit for (let i = 0; i < 5; i++) { await createSession(mockIp, `Plan ${i}`, undefined, TEST_ROOT_DIR); } const resetTime = getRateLimitResetTime(mockIp); expect(resetTime).toBeInstanceOf(Date); expect(resetTime!.getTime()).toBeGreaterThan(Date.now()); }); }); describe("session TTL", () => { it("uses a 7-day TTL constant", () => { expect(SESSION_TTL_MS).toBe(7 * 24 * 60 * 60 * 1000); }); it("does not expire sessions within the old 30-minute window", async () => { vi.useFakeTimers({ shouldAdvanceTime: true }); try { const mockIp = getUniqueIp(); const { sessionId } = await createSession(mockIp, initialPlan, undefined, TEST_ROOT_DIR); // Advance beyond the old 30-minute TTL used prior to FN-1146. vi.advanceTimersByTime(31 * 60 * 1000); expect(getSession(sessionId)).toBeDefined(); } finally { vi.useRealTimers(); } }); }); describe("buildDepthPromptSuffix", () => { it("returns small depth guidance", () => { expect(buildDepthPromptSuffix("small")).toContain("Ask exactly 1-2 focused questions"); }); it("returns large depth guidance", () => { expect(buildDepthPromptSuffix("large")).toContain("Ask 5-8 thorough questions"); }); it("returns custom count guidance", () => { expect(buildDepthPromptSuffix(undefined, 5)).toBe( "Ask exactly 5 questions. Adjust depth and breadth to fit within that count.", ); }); it("prioritizes custom count over depth guidance", () => { expect(buildDepthPromptSuffix("medium", 7)).toBe( "Ask exactly 7 questions. Adjust depth and breadth to fit within that count.", ); }); }); describe("parseAgentResponse", () => { it("parses clean JSON question response", () => { const input = '{"type":"question","data":{"id":"q-1","type":"text","question":"What scope?"}}'; const result = parseAgentResponse(input); expect(result.type).toBe("question"); if (result.type === "question") { expect(result.data.id).toBe("q-1"); expect(result.data.question).toBe("What scope?"); } }); it("parses clean JSON complete response", () => { const input = '{"type":"complete","data":{"title":"My Task","description":"A task","suggestedSize":"M","suggestedDependencies":[],"keyDeliverables":["Code"]}}'; const result = parseAgentResponse(input); expect(result.type).toBe("complete"); if (result.type === "complete") { expect(result.data.title).toBe("My Task"); } }); it("extracts JSON from markdown code block", () => { const input = 'Here is the question:\n```json\n{"type":"question","data":{"id":"q-1","type":"text","question":"What scope?"}}\n```\nLet me know!'; const result = parseAgentResponse(input); expect(result.type).toBe("question"); }); it("extracts JSON from markdown code block without language tag", () => { const input = 'Some preamble\n```\n{"type":"question","data":{"id":"q-1","type":"text","question":"Hello?"}}\n```\nPostamble'; const result = parseAgentResponse(input); expect(result.type).toBe("question"); }); it("extracts JSON surrounded by prose", () => { const input = 'I think the best question is:\n{"type":"question","data":{"id":"q-1","type":"text","question":"What is the scope?"}}\nThat should help clarify.'; const result = parseAgentResponse(input); expect(result.type).toBe("question"); }); it("repairs truncated JSON with missing closing braces", () => { const input = '{"type":"question","data":{"id":"q-1","type":"text","question":"What scope?"'; // Missing closing "}} at the end — repairJson should add them const result = parseAgentResponse(input); expect(result.type).toBe("question"); }); it("repairs JSON with trailing comma", () => { const input = '{"type":"question","data":{"id":"q-1","type":"text","question":"Scope?",},}'; const result = parseAgentResponse(input); expect(result.type).toBe("question"); }); it("repairs truncated JSON causing Unexpected end of JSON input", () => { // Simulate the exact error described in the issue: // "Failed to parse AI response: Unexpected end of JSON input" const input = '{"type":"question","data":{"id":"q-1","type":"text","question":"What is the overall'; // The string value is incomplete (missing closing quote and braces) const result = parseAgentResponse(input); expect(result.type).toBe("question"); if (result.type === "question") { expect(result.data.id).toBe("q-1"); } }); it("throws with actionable error for non-JSON text", () => { const input = "I'm not sure what to ask about this project."; expect(() => parseAgentResponse(input)).toThrow("no valid JSON"); }); it("throws with actionable error for invalid structure", () => { const input = '{"type":"unknown","data":null}'; expect(() => parseAgentResponse(input)).toThrow("invalid response structure"); }); it("throws with actionable error for missing data field", () => { const input = '{"type":"question"}'; expect(() => parseAgentResponse(input)).toThrow("invalid response structure"); }); it("handles JSON embedded inside a longer text with multiple braces", () => { const input = "Here's my analysis:\n" + "Some text with {nested} braces that aren't JSON\n" + '{"type":"complete","data":{"title":"Auth System","description":"Build auth","suggestedSize":"M","suggestedDependencies":[],"keyDeliverables":["Login"]}}' + "\nThat should work!"; const result = parseAgentResponse(input); expect(result.type).toBe("complete"); }); it("picks the largest valid JSON object when multiple exist", () => { // Two valid JSON objects — the larger (complete) one should win const input = '{"type":"question","data":{"id":"q-1","type":"text","question":"Hi?"}} ' + 'and then {"type":"complete","data":{"title":"Full Task","description":"Do everything","suggestedSize":"L","suggestedDependencies":[],"keyDeliverables":["All the things"]}}'; const result = parseAgentResponse(input); expect(result.type).toBe("complete"); }); it("logs error diagnostic when no JSON candidate found before throwing", async () => { // Import the shared helper for diagnostics capture const { setDiagnosticsSink, resetDiagnosticsSink } = await import("../ai-session-diagnostics.js"); let loggedErrors: Array<{ level: string; scope: string; message: string; context: Record }> = []; setDiagnosticsSink((level, scope, message, context) => { loggedErrors.push({ level, scope, message, context }); }); try { const input = "I'm not sure what to ask about this project."; expect(() => parseAgentResponse(input)).toThrow("no valid JSON"); expect(loggedErrors).toContainEqual( expect.objectContaining({ level: "error", scope: "planning", message: "No JSON candidate found in agent response", context: expect.objectContaining({ inputSnippet: expect.stringContaining("I'm not sure"), operation: "parse-json", }), }) ); } finally { resetDiagnosticsSink(); } }); it("logs error diagnostic when repair also fails before throwing", async () => { // Import the shared helper for diagnostics capture const { setDiagnosticsSink, resetDiagnosticsSink } = await import("../ai-session-diagnostics.js"); let loggedErrors: Array<{ level: string; scope: string; message: string; context: Record }> = []; setDiagnosticsSink((level, scope, message, context) => { loggedErrors.push({ level, scope, message, context }); }); try { // Invalid JSON that repair cannot fix (missing quotes around values, unclosed objects) const input = '{"type":"question","data":{"id":q-1,"question":"What is this?'; expect(() => parseAgentResponse(input)).toThrow("Failed to parse AI response"); expect(loggedErrors).toContainEqual( expect.objectContaining({ level: "error", scope: "planning", message: "Failed to parse agent response (repair also failed)", context: expect.objectContaining({ inputSnippet: expect.stringContaining('{"type":"question"'), operation: "parse-json-repair", }), }) ); } finally { resetDiagnosticsSink(); } }); it("logs error diagnostic for invalid response structure before throwing", async () => { // Import the shared helper for diagnostics capture const { setDiagnosticsSink, resetDiagnosticsSink } = await import("../ai-session-diagnostics.js"); let loggedErrors: Array<{ level: string; scope: string; message: string; context: Record }> = []; setDiagnosticsSink((level, scope, message, context) => { loggedErrors.push({ level, scope, message, context }); }); try { const input = '{"type":"unknown","data":null}'; expect(() => parseAgentResponse(input)).toThrow("invalid response structure"); expect(loggedErrors).toContainEqual( expect.objectContaining({ level: "error", scope: "planning", message: "Invalid response structure from AI", context: expect.objectContaining({ parsedSnippet: expect.stringContaining('"type":"unknown"'), operation: "parse-validate", }), }) ); } finally { resetDiagnosticsSink(); } }); }); describe("formatInterviewQA", () => { it("returns empty string for empty history", () => { expect(formatInterviewQA([])).toBe(""); }); it("formats text, single_select, multi_select, and confirm responses", () => { const history: Array<{ question: PlanningQuestion; response: unknown }> = [ { question: { id: "q-text", type: "text", question: "What constraints should we consider?", }, response: { "q-text": "Must support offline mode" }, }, { question: { id: "q-single", type: "single_select", question: "What is the target scope?", options: [ { id: "small", label: "Small" }, { id: "medium", label: "Medium" }, ], }, response: { "q-single": "medium" }, }, { question: { id: "q-multi", type: "multi_select", question: "Which platforms are required?", options: [ { id: "web", label: "Web" }, { id: "ios", label: "iOS" }, { id: "android", label: "Android" }, ], }, response: { "q-multi": ["web", "android"] }, }, { question: { id: "q-confirm", type: "confirm", question: "Should we include backward compatibility?", }, response: { "q-confirm": true }, }, ]; expect(formatInterviewQA(history)).toBe( [ "## Planning Interview Context", "", "**Q: What constraints should we consider?**", "A: Must support offline mode", "", "**Q: What is the target scope?**", "A: Medium", "", "**Q: Which platforms are required?**", "A: Web, Android", "", "**Q: Should we include backward compatibility?**", "A: Yes", ].join("\n") ); }); it("handles missing options gracefully", () => { const history: Array<{ question: PlanningQuestion; response: unknown }> = [ { question: { id: "q-single", type: "single_select", question: "Which tier?", options: [{ id: "starter", label: "Starter" }], }, response: { "q-single": "enterprise" }, }, { question: { id: "q-multi", type: "multi_select", question: "Which integrations?", options: [{ id: "slack", label: "Slack" }], }, response: { "q-multi": ["slack", "jira"] }, }, ]; const formatted = formatInterviewQA(history); expect(formatted).toContain("A: enterprise"); expect(formatted).toContain("A: Slack, jira"); }); }); describe("PlanningStreamManager buffering", () => { it("stores broadcast events and returns buffered events since id", () => { const sessionId = "stream-session-1"; const received: Array<{ type: string; id?: number }> = []; const unsubscribe = planningStreamManager.subscribe(sessionId, (event, eventId) => { received.push({ type: event.type, id: eventId }); }); const firstId = planningStreamManager.broadcast(sessionId, { type: "thinking", data: "delta-1", }); const secondId = planningStreamManager.broadcast(sessionId, { type: "question", data: { id: "q-1", type: "text", question: "Question?", description: "desc", }, }); expect(firstId).toBe(1); expect(secondId).toBe(2); expect(received).toEqual([ { type: "thinking", id: 1 }, { type: "question", id: 2 }, ]); const buffered = planningStreamManager.getBufferedEvents(sessionId, 1); expect(buffered).toHaveLength(1); expect(buffered[0]).toMatchObject({ id: 2, event: "question" }); unsubscribe(); }); it("broadcast buffers events even with no subscribers", () => { const sessionId = "stream-session-2"; const eventId = planningStreamManager.broadcast(sessionId, { type: "complete", }); expect(eventId).toBe(1); const buffered = planningStreamManager.getBufferedEvents(sessionId, 0); expect(buffered).toHaveLength(1); expect(buffered[0]).toMatchObject({ id: 1, event: "complete", data: "{}" }); }); it("cleanupSession clears buffered events", () => { const sessionId = "stream-session-3"; planningStreamManager.broadcast(sessionId, { type: "thinking", data: "delta", }); expect(planningStreamManager.getBufferedEvents(sessionId, 0)).toHaveLength(1); planningStreamManager.cleanupSession(sessionId); expect(planningStreamManager.getBufferedEvents(sessionId, 0)).toEqual([]); }); it("broadcast callback throw logs error but broadcast continues and buffer remains valid", async () => { // Import the shared helper for diagnostics capture const { setDiagnosticsSink, resetDiagnosticsSink } = await import("../ai-session-diagnostics.js"); const sessionId = "stream-session-throw"; let loggedErrors: Array<{ level: string; scope: string; message: string; context: Record }> = []; setDiagnosticsSink((level, scope, message, context) => { loggedErrors.push({ level, scope, message, context }); }); try { let otherCallbackCalled = false; const failingCallback = () => { throw new Error("Callback failed"); }; const workingCallback = () => { otherCallbackCalled = true; }; planningStreamManager.subscribe(sessionId, failingCallback); planningStreamManager.subscribe(sessionId, workingCallback); const eventId = planningStreamManager.broadcast(sessionId, { type: "thinking", data: "test", }); // Broadcast should continue despite callback failure expect(eventId).toBe(1); expect(otherCallbackCalled).toBe(true); // Buffer should still be valid const buffered = planningStreamManager.getBufferedEvents(sessionId, 0); expect(buffered).toHaveLength(1); expect(buffered[0]).toMatchObject({ id: 1, event: "thinking" }); // Error should be logged with correct structure expect(loggedErrors).toContainEqual( expect.objectContaining({ level: "error", scope: "planning", message: "Error broadcasting to client", context: expect.objectContaining({ sessionId, operation: "broadcast", }), }) ); } finally { resetDiagnosticsSink(); } }); }); describe("generateSubtasksFromPlanning", () => { /** Helper: create a session and complete it to get a summary */ async function createCompletedSession( ip: string, plan: string ): Promise { const { sessionId } = await createSession(ip, plan, undefined, TEST_ROOT_DIR); // Complete the session by submitting 3 responses await submitResponse(sessionId, { "q-scope": "medium" }); await submitResponse(sessionId, { "q-requirements": "Test requirements" }); await submitResponse(sessionId, { "q-confirm": true }); return sessionId; } it("returns empty array if session not found", () => { const result = generateSubtasksFromPlanning("non-existent-session-id"); expect(result).toEqual([]); }); it("returns empty array if session has no summary (not complete)", async () => { const mockIp = getUniqueIp(); const { sessionId } = await createSession(mockIp, "Incomplete session", undefined, TEST_ROOT_DIR); const result = generateSubtasksFromPlanning(sessionId); expect(result).toEqual([]); }); it("generates subtasks from keyDeliverables", async () => { const mockIp = getUniqueIp(); const sessionId = await createCompletedSession(mockIp, "Build auth system"); const result = generateSubtasksFromPlanning(sessionId); // The AI-generated session produces 3 key deliverables: // "Implementation", "Tests", "Documentation" expect(result.length).toBe(3); // First subtask has no dependencies expect(result[0]).toEqual({ id: "subtask-1", title: "Implementation", description: expect.any(String), suggestedSize: "S", priority: "normal", dependsOn: [], }); // Second subtask depends on first expect(result[1]).toEqual({ id: "subtask-2", title: "Tests", description: expect.any(String), suggestedSize: "M", priority: "normal", dependsOn: ["subtask-1"], }); // Third subtask depends on second expect(result[2]).toEqual({ id: "subtask-3", title: "Documentation", description: expect.any(String), suggestedSize: "S", priority: "normal", dependsOn: ["subtask-2"], }); }); it("inherits summary priority for generated subtasks", async () => { const mockIp = getUniqueIp(); const sessionId = await createCompletedSession(mockIp, "Build auth with urgent priority"); const session = getSession(sessionId); if (!session?.summary) { throw new Error("Expected summary to exist for completed session"); } session.summary.priority = "urgent"; const result = generateSubtasksFromPlanning(sessionId); expect(result.length).toBeGreaterThan(0); expect(result.every((subtask) => subtask.priority === "urgent")).toBe(true); }); it("generates deliverable subtasks with distinct lead guidance plus separate plan context", async () => { const mockIp = getUniqueIp(); const sessionId = await createCompletedSession(mockIp, "Build auth system with context"); const result = generateSubtasksFromPlanning(sessionId); expect(result.length).toBe(3); expect(result[0]?.description).toContain('Implement "Implementation" as this subtask\'s primary outcome.'); expect(result[1]?.description).toContain('Implement "Tests" as this subtask\'s primary outcome.'); expect(result[2]?.description).toContain('Implement "Documentation" as this subtask\'s primary outcome.'); expect(result[0]?.description).toContain("## Larger Plan Context"); expect(result[0]?.description).toContain("## Planning Interview Context"); expect(result[0]?.description).toContain("**Q: What is the scope of this plan?**"); expect(result[0]?.description).toContain("A: Medium"); expect(result[0]?.description).toContain("**Q: What are the key requirements?**"); expect(result[0]?.description).toContain("A: Test requirements"); expect(result[0]?.description).toContain("**Q: Are there specific technologies to use?**"); expect(result[0]?.description).toContain("A: Yes"); }); it("keeps larger-plan context section when history is empty", async () => { const mockIp = getUniqueIp(); const sessionId = await createCompletedSession(mockIp, "Build auth without context"); const session = getSession(sessionId); expect(session?.summary).toBeDefined(); if (!session?.summary) { throw new Error("Expected summary to exist for completed session"); } session.history = []; const result = generateSubtasksFromPlanning(sessionId); expect(result.length).toBeGreaterThan(0); for (const subtask of result) { expect(subtask.description).toContain("## Larger Plan Context"); expect(subtask.description).toContain(session.summary.description); expect(subtask.description).not.toContain("## Planning Interview Context"); } }); it("generates fallback subtasks when keyDeliverables is empty", async () => { const mockIp = getUniqueIp(); const { sessionId } = await createSession(mockIp, "Fallback test", undefined, TEST_ROOT_DIR); // Complete the session normally, then manually clear keyDeliverables await submitResponse(sessionId, { scope: "small" }); await submitResponse(sessionId, { requirements: "test" }); await submitResponse(sessionId, { confirm: true }); // Get the session and manually clear keyDeliverables to test fallback const session = getSession(sessionId); expect(session).toBeDefined(); if (session?.summary) { session.summary.keyDeliverables = []; } const result = generateSubtasksFromPlanning(sessionId); expect(result.length).toBe(3); expect(result[0]).toEqual({ id: "subtask-1", title: "Define implementation approach", description: expect.any(String), suggestedSize: "S", priority: "normal", dependsOn: [], }); expect(result[1]).toEqual({ id: "subtask-2", title: "Implement core changes", description: expect.any(String), suggestedSize: "M", priority: "normal", dependsOn: ["subtask-1"], }); expect(result[2]).toEqual({ id: "subtask-3", title: "Verify and polish", description: expect.any(String), suggestedSize: "S", priority: "normal", dependsOn: ["subtask-2"], }); expect(result[0]?.description).toContain("Define the implementation approach for the plan"); expect(result[1]?.description).toContain("Implement the core code changes described by the plan"); expect(result[2]?.description).toContain("Verify the implementation end-to-end"); expect(result[0]?.description).toContain("## Larger Plan Context"); }); it("assigns correct sizes based on deliverable position", async () => { const mockIp = getUniqueIp(); const { sessionId } = await createSession(mockIp, "Multi-deliverable test", undefined, TEST_ROOT_DIR); // Complete the session await submitResponse(sessionId, { scope: "large" }); await submitResponse(sessionId, { requirements: "many things" }); await submitResponse(sessionId, { confirm: true }); // Modify to have 5 deliverables for size variety const session = getSession(sessionId); if (session?.summary) { session.summary.keyDeliverables = [ "Setup project structure", "Build feature A", "Build feature B", "Build feature C", "Integration tests", ]; } const result = generateSubtasksFromPlanning(sessionId); expect(result.length).toBe(5); // First: S, Middle: M, Last: S expect(result[0]?.suggestedSize).toBe("S"); expect(result[1]?.suggestedSize).toBe("M"); expect(result[2]?.suggestedSize).toBe("M"); expect(result[3]?.suggestedSize).toBe("M"); expect(result[4]?.suggestedSize).toBe("S"); }); it("uses sequential dependencies between subtasks", async () => { const mockIp = getUniqueIp(); const sessionId = await createCompletedSession(mockIp, "Dependency test"); const result = generateSubtasksFromPlanning(sessionId); // Each subtask depends on the previous one for (let i = 1; i < result.length; i++) { expect(result[i]?.dependsOn).toEqual([`subtask-${i}`]); } }); it("merges compact subtask drafts onto generated planning subtasks", async () => { const mockIp = getUniqueIp(); const sessionId = await createCompletedSession(mockIp, "Compact draft merge test"); const generated = generateSubtasksFromPlanning(sessionId); const merged = mergePlanningSubtaskDrafts(sessionId, [ { id: generated[0]!.id }, { id: generated[1]!.id, title: "Edited tests deliverable", description: "Edited description", suggestedSize: "L", priority: "urgent", dependsOn: [generated[0]!.id], }, { id: generated[2]!.id, dependsOn: [generated[0]!.id, generated[1]!.id], }, ]); expect(merged[0]).toEqual(generated[0]); expect(merged[1]).toEqual({ ...generated[1], title: "Edited tests deliverable", description: "Edited description", suggestedSize: "L", priority: "urgent", }); expect(merged[2]).toEqual({ ...generated[2], dependsOn: [generated[0]!.id, generated[1]!.id], }); }); it("preserves client-added subtasks when merging compact drafts", async () => { const mockIp = getUniqueIp(); const sessionId = await createCompletedSession(mockIp, "Client-added compact draft test"); const merged = mergePlanningSubtaskDrafts(sessionId, [ { id: "subtask-1" }, { id: "subtask-99", title: "New client-added subtask", description: "Create docs and rollout notes", suggestedSize: "S", priority: "high", dependsOn: ["subtask-1"], }, ]); expect(merged[1]).toEqual({ id: "subtask-99", title: "New client-added subtask", description: "Create docs and rollout notes", suggestedSize: "S", priority: "high", dependsOn: ["subtask-1"], }); }); it("throws when a client-added compact subtask draft omits its title", async () => { const mockIp = getUniqueIp(); const sessionId = await createCompletedSession(mockIp, "Unknown compact draft test"); expect(() => mergePlanningSubtaskDrafts(sessionId, [{ id: "subtask-999" }])).toThrow( "Client-added subtask must have a title: subtask-999", ); }); }); }); describe("AiSessionStore locking", () => { let tmpRoot: string; let db: Database; let store: AiSessionStore; function makeSessionRow( id: string, status: AiSessionRow["status"] = "awaiting_input", ): AiSessionRow { const now = new Date().toISOString(); return { id, type: "planning", status, title: `Session ${id}`, inputPayload: JSON.stringify({ initialPlan: "Locking test" }), conversationHistory: "[]", currentQuestion: null, result: null, thinkingOutput: "", error: null, projectId: null, createdAt: now, updatedAt: now, lockedByTab: null, lockedAt: null, }; } beforeEach(() => { tmpRoot = mkdtempSync(join(tmpdir(), "kb-session-lock-")); db = new Database(join(tmpRoot, ".fusion")); db.init(); store = new AiSessionStore(db); store.upsert(makeSessionRow("session-lock-1")); }); afterEach(async () => { store.stopScheduledCleanup(); try { db.close(); } catch { // no-op } await rm(tmpRoot, { recursive: true, force: true }); }); it("acquires lock, detects conflicts, and allows re-entrant acquire", () => { const firstAcquire = store.acquireLock("session-lock-1", "tab-a"); expect(firstAcquire).toEqual({ acquired: true, currentHolder: null }); const holderAfterAcquire = store.getLockHolder("session-lock-1"); expect(holderAfterAcquire.tabId).toBe("tab-a"); expect(holderAfterAcquire.lockedAt).toBeTruthy(); const conflict = store.acquireLock("session-lock-1", "tab-b"); expect(conflict).toEqual({ acquired: false, currentHolder: "tab-a" }); const reentrant = store.acquireLock("session-lock-1", "tab-a"); expect(reentrant).toEqual({ acquired: true, currentHolder: null }); expect(store.getLockHolder("session-lock-1").tabId).toBe("tab-a"); }); it("releases locks only for the current owner", () => { store.acquireLock("session-lock-1", "tab-a"); const nonOwnerRelease = store.releaseLock("session-lock-1", "tab-b"); expect(nonOwnerRelease).toBe(false); expect(store.getLockHolder("session-lock-1").tabId).toBe("tab-a"); const ownerRelease = store.releaseLock("session-lock-1", "tab-a"); expect(ownerRelease).toBe(true); expect(store.getLockHolder("session-lock-1")).toEqual({ tabId: null, lockedAt: null }); }); it("force acquires lock and clears stale locks", () => { store.acquireLock("session-lock-1", "tab-a"); store.forceAcquireLock("session-lock-1", "tab-b"); expect(store.getLockHolder("session-lock-1").tabId).toBe("tab-b"); const staleTimestamp = new Date(Date.now() - 35 * 60 * 1000).toISOString(); db.prepare("UPDATE ai_sessions SET lockedAt = ? WHERE id = ?").run(staleTimestamp, "session-lock-1"); const releasedCount = store.releaseStaleLocks(); expect(releasedCount).toBe(1); expect(store.getLockHolder("session-lock-1")).toEqual({ tabId: null, lockedAt: null }); }); it("emits ai_session:updated events on lock changes", () => { const onUpdated = vi.fn(); store.on("ai_session:updated", onUpdated); store.acquireLock("session-lock-1", "tab-a"); store.releaseLock("session-lock-1", "tab-a"); store.forceAcquireLock("session-lock-1", "tab-b"); const staleTimestamp = new Date(Date.now() - 35 * 60 * 1000).toISOString(); db.prepare("UPDATE ai_sessions SET lockedAt = ? WHERE id = ?").run(staleTimestamp, "session-lock-1"); store.releaseStaleLocks(); expect(onUpdated).toHaveBeenCalled(); const emittedLocks = onUpdated.mock.calls .map(([summary]) => summary.lockedByTab) .filter((value) => value !== undefined); expect(emittedLocks).toContain("tab-a"); expect(emittedLocks).toContain("tab-b"); expect(emittedLocks).toContain(null); }); it("preserves lock state in upsert update events", () => { store.acquireLock("session-lock-1", "tab-a"); const onUpdated = vi.fn(); store.on("ai_session:updated", onUpdated); store.upsert({ ...makeSessionRow("session-lock-1", "generating"), lockedByTab: null, lockedAt: null, }); const latestSummary = onUpdated.mock.calls.at(-1)?.[0]; expect(latestSummary?.lockedByTab).toBe("tab-a"); }); }); describe("planning routes lock enforcement", () => { let tmpRoot: string; let taskStore: TaskStore; let db: Database; let aiSessionStore: AiSessionStore; let app: express.Express; function makePersistedRow(id: string, type: AiSessionRow["type"] = "planning"): AiSessionRow { const now = new Date().toISOString(); return { id, type, status: "awaiting_input", title: `Session ${id}`, inputPayload: JSON.stringify({ initialPlan: "Route lock test" }), conversationHistory: "[]", currentQuestion: null, result: null, thinkingOutput: "", error: null, projectId: null, createdAt: now, updatedAt: now, lockedByTab: null, lockedAt: null, }; } beforeEach(async () => { __resetPlanningState(); setupMockAgent(); tmpRoot = mkdtempSync(join(tmpdir(), "kb-planning-lock-routes-")); taskStore = new TaskStore(tmpRoot, join(tmpRoot, ".fusion-global-settings"), { inMemoryDb: true }); await taskStore.init(); db = new Database(join(tmpRoot, ".fusion-locks")); db.init(); aiSessionStore = new AiSessionStore(db); setAiSessionStore(aiSessionStore as any); app = express(); app.use(express.json()); app.use("/api", createApiRoutes(taskStore, { aiSessionStore })); }); afterEach(async () => { __setCreateFnAgent(undefined as any); __resetPlanningState(); try { taskStore.close(); } catch { // no-op } try { db.close(); } catch { // no-op } await rm(tmpRoot, { recursive: true, force: true }); }); it("acquires and releases locks via API routes", async () => { aiSessionStore.upsert(makePersistedRow("session-route-lock")); const acquire = await request( app, "POST", "/api/ai-sessions/session-route-lock/lock", JSON.stringify({ tabId: "tab-a" }), { "content-type": "application/json" }, ); expect(acquire.status).toBe(200); expect(acquire.body).toEqual({ acquired: true }); const conflictAcquire = await request( app, "POST", "/api/ai-sessions/session-route-lock/lock", JSON.stringify({ tabId: "tab-b" }), { "content-type": "application/json" }, ); expect(conflictAcquire.status).toBe(200); expect(conflictAcquire.body).toEqual({ acquired: false, currentHolder: "tab-a" }); const release = await request( app, "DELETE", "/api/ai-sessions/session-route-lock/lock", JSON.stringify({ tabId: "tab-a" }), { "content-type": "application/json" }, ); expect(release.status).toBe(200); expect(release.body).toEqual({ success: true }); const forceAcquire = await request( app, "POST", "/api/ai-sessions/session-route-lock/lock/force", JSON.stringify({ tabId: "tab-c" }), { "content-type": "application/json" }, ); expect(forceAcquire.status).toBe(200); expect(forceAcquire.body).toEqual({ success: true }); const beaconRelease = await request( app, "DELETE", "/api/ai-sessions/session-route-lock/lock/beacon?tabId=tab-c", ); expect(beaconRelease.status).toBe(200); }); it("returns 409 for planning/respond when another tab holds the lock and allows legacy requests without tabId", async () => { const { sessionId } = await createSession(getUniqueIp(), "Route lock planning", taskStore, tmpRoot); aiSessionStore.acquireLock(sessionId, "tab-owner"); const conflictResponse = await request( app, "POST", "/api/planning/respond", JSON.stringify({ sessionId, responses: { "q-scope": "small" }, tabId: "tab-other" }), { "content-type": "application/json" }, ); expect(conflictResponse.status).toBe(409); expect(conflictResponse.body).toEqual({ error: "Session locked by another tab", lockedByTab: "tab-owner", }); const legacyResponse = await request( app, "POST", "/api/planning/respond", JSON.stringify({ sessionId, responses: { "q-scope": "small" } }), { "content-type": "application/json" }, ); expect(legacyResponse.status).toBe(200); expect((legacyResponse.body as { type: string }).type).toBe("question"); }); it("returns 409 for subtasks/cancel when lock is held by another tab", async () => { aiSessionStore.upsert(makePersistedRow("subtask-route-lock", "subtask")); aiSessionStore.acquireLock("subtask-route-lock", "tab-a"); const response = await request( app, "POST", "/api/subtasks/cancel", JSON.stringify({ sessionId: "subtask-route-lock", tabId: "tab-b" }), { "content-type": "application/json" }, ); expect(response.status).toBe(409); expect(response.body).toEqual({ error: "Session locked by another tab", lockedByTab: "tab-a", }); }); it("returns 409 for retry endpoints when lock is held by another tab", async () => { aiSessionStore.upsert(makePersistedRow("planning-route-retry", "planning")); aiSessionStore.acquireLock("planning-route-retry", "tab-a"); const planningRetry = await request( app, "POST", "/api/planning/planning-route-retry/retry", JSON.stringify({ tabId: "tab-b" }), { "content-type": "application/json" }, ); expect(planningRetry.status).toBe(409); expect(planningRetry.body).toEqual({ error: "Session locked by another tab", lockedByTab: "tab-a", }); aiSessionStore.upsert(makePersistedRow("subtask-route-retry", "subtask")); aiSessionStore.acquireLock("subtask-route-retry", "tab-a"); const subtaskRetry = await request( app, "POST", "/api/subtasks/subtask-route-retry/retry", JSON.stringify({ tabId: "tab-b" }), { "content-type": "application/json" }, ); expect(subtaskRetry.status).toBe(409); expect(subtaskRetry.body).toEqual({ error: "Session locked by another tab", lockedByTab: "tab-a", }); }); it("creates a draft planning session via route and persists draft status", async () => { const response = await request( app, "POST", "/api/planning/create-draft", JSON.stringify({ initialPlan: "Build a dashboard settings wizard with guided onboarding steps" }), { "content-type": "application/json" }, ); expect(response.status).toBe(201); expect(response.body).toMatchObject({ sessionId: expect.any(String), title: "New planning session", }); expect(response.body.sessionId).toMatch( /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i, ); const persisted = aiSessionStore.get(response.body.sessionId as string); expect(persisted?.status).toBe("draft"); }); it("returns 400 for draft creation without non-empty initialPlan", async () => { const missing = await request( app, "POST", "/api/planning/create-draft", JSON.stringify({}), { "content-type": "application/json" }, ); expect(missing.status).toBe(400); const empty = await request( app, "POST", "/api/planning/create-draft", JSON.stringify({ initialPlan: "" }), { "content-type": "application/json" }, ); expect(empty.status).toBe(400); }); it("returns 429 when draft creation rate limit is exceeded", async () => { for (let i = 0; i < 1000; i++) { const created = await request( app, "POST", "/api/planning/create-draft", JSON.stringify({ initialPlan: `Rate-limited draft ${i}` }), { "content-type": "application/json" }, ); expect(created.status).toBe(201); } const rateLimited = await request( app, "POST", "/api/planning/create-draft", JSON.stringify({ initialPlan: "This draft should hit the rate limit" }), { "content-type": "application/json" }, ); expect(rateLimited.status).toBe(429); expect(String(rateLimited.body?.error ?? "")).toContain("Rate limit exceeded"); }); it("reuses existing draft session when starting streaming", async () => { const draft = await request( app, "POST", "/api/planning/create-draft", JSON.stringify({ initialPlan: "Plan draft to be reused by start-streaming" }), { "content-type": "application/json" }, ); expect(draft.status).toBe(201); const draftSessionId = draft.body.sessionId as string; const startExisting = await request( app, "POST", "/api/planning/start-streaming", JSON.stringify({ initialPlan: "Plan draft to be reused by start-streaming", existingSessionId: draftSessionId, }), { "content-type": "application/json" }, ); expect(startExisting.status).toBe(201); expect(startExisting.body).toEqual({ sessionId: draftSessionId }); expect(aiSessionStore.get(draftSessionId)?.status).toBe("awaiting_input"); const startNew = await request( app, "POST", "/api/planning/start-streaming", JSON.stringify({ initialPlan: "Plan without existing draft" }), { "content-type": "application/json" }, ); expect(startNew.status).toBe(201); expect(startNew.body.sessionId).toMatch( /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i, ); expect(startNew.body.sessionId).not.toBe(draftSessionId); }); it("uses the freshest initialPlan when start-streaming races a pending draft sync", async () => { // Simulate the race: draft was created with stale text, the latest debounced // PATCH /draft hasn't arrived yet, and the user clicks Start Planning whose // request body carries the up-to-date textarea contents. The agent must // receive the body's text, not whatever was last persisted to SQLite. const draft = await request( app, "POST", "/api/planning/create-draft", JSON.stringify({ initialPlan: "Stale draft prefix from first keystroke" }), { "content-type": "application/json" }, ); expect(draft.status).toBe(201); const draftSessionId = draft.body.sessionId as string; const freshPlan = "Stale draft prefix from first keystroke followed by everything the user typed after the debounce window closed"; const start = await request( app, "POST", "/api/planning/start-streaming", JSON.stringify({ initialPlan: freshPlan, existingSessionId: draftSessionId }), { "content-type": "application/json" }, ); expect(start.status).toBe(201); const persisted = aiSessionStore.get(draftSessionId); expect(persisted?.inputPayload).toBe(JSON.stringify({ initialPlan: freshPlan })); }); it("re-summarizes the draft title on each call so blur-then-edit doesn't strand stale text", async () => { // For short input (≤200 chars) summarizeTitle returns null, so // summarizeDraftTitle uses its trimmed-text fallback. That's enough to // exercise the regression: the helper used to bail once `title !== // DRAFT_PLACEHOLDER_TITLE`, which would lock in the first fallback and // ignore the user's subsequent edits even though they were persisted. const draft = await request( app, "POST", "/api/planning/create-draft", JSON.stringify({ initialPlan: "Initial partial draft text" }), { "content-type": "application/json" }, ); const draftSessionId = draft.body.sessionId as string; const firstBlur = await request( app, "POST", `/api/planning/${draftSessionId}/summarize-draft-title`, JSON.stringify({}), { "content-type": "application/json" }, ); expect(firstBlur.status).toBe(200); expect(firstBlur.body).toEqual({ title: "Initial partial draft text" }); expect(aiSessionStore.get(draftSessionId)?.title).toBe("Initial partial draft text"); await request( app, "PATCH", `/api/ai-sessions/${draftSessionId}/draft`, JSON.stringify({ initialPlan: "Final draft text after the user kept typing" }), { "content-type": "application/json" }, ); const secondBlur = await request( app, "POST", `/api/planning/${draftSessionId}/summarize-draft-title`, JSON.stringify({}), { "content-type": "application/json" }, ); expect(secondBlur.status).toBe(200); expect(secondBlur.body).toEqual({ title: "Final draft text after the user kept typing" }); expect(aiSessionStore.get(draftSessionId)?.title).toBe( "Final draft text after the user kept typing", ); }); it("persists the model override on draft create and round-trips it through inputPayload", async () => { const draft = await request( app, "POST", "/api/planning/create-draft", JSON.stringify({ initialPlan: "Plan that needs a specific model", planningModelProvider: "anthropic", planningModelId: "claude-opus-4-7", }), { "content-type": "application/json" }, ); expect(draft.status).toBe(201); const draftSessionId = draft.body.sessionId as string; // The draft row's inputPayload must carry the model override so the // frontend reopen path can restore it into modal state and so a later // summarize call uses it instead of falling back to project defaults. const persisted = aiSessionStore.get(draftSessionId); const payload = JSON.parse(persisted?.inputPayload ?? "{}"); expect(payload.modelProvider).toBe("anthropic"); expect(payload.modelId).toBe("claude-opus-4-7"); // PATCH /draft can also update the override (user switched models mid-edit). await request( app, "PATCH", `/api/ai-sessions/${draftSessionId}/draft`, JSON.stringify({ initialPlan: "Plan that needs a specific model", modelProvider: "openai", modelId: "gpt-5", }), { "content-type": "application/json" }, ); const updatedPayload = JSON.parse(aiSessionStore.get(draftSessionId)?.inputPayload ?? "{}"); expect(updatedPayload.modelProvider).toBe("openai"); expect(updatedPayload.modelId).toBe("gpt-5"); // A half-set override on PATCH clears the persisted override entirely // rather than landing in a half-configured state the start path rejects. await request( app, "PATCH", `/api/ai-sessions/${draftSessionId}/draft`, JSON.stringify({ initialPlan: "Plan that needs a specific model", modelProvider: "openai", }), { "content-type": "application/json" }, ); const clearedPayload = JSON.parse(aiSessionStore.get(draftSessionId)?.inputPayload ?? "{}"); expect(clearedPayload.modelProvider).toBeUndefined(); expect(clearedPayload.modelId).toBeUndefined(); }); it("skips re-summarize on start when blur/close already summarized the same final text", async () => { // Sequence the bug guards: // 1. Create a draft. // 2. Blur → summarizeDraftTitle runs against the persisted text and // records `summarizedFor` so the start path knows the title is // up-to-date for that exact text. // 3. Click Start with the same text → startExistingSession should // skip its own summarize and leave the title from step 2 intact. const draft = await request( app, "POST", "/api/planning/create-draft", JSON.stringify({ initialPlan: "Stable plan body the user already finished writing" }), { "content-type": "application/json" }, ); const draftSessionId = draft.body.sessionId as string; const blur = await request( app, "POST", `/api/planning/${draftSessionId}/summarize-draft-title`, JSON.stringify({}), { "content-type": "application/json" }, ); expect(blur.status).toBe(200); const titleAfterBlur = blur.body.title as string; expect(titleAfterBlur).not.toBe("New planning session"); setupMockStreamingAgent({ responses: STANDARD_QUESTION_RESPONSES }); const start = await request( app, "POST", "/api/planning/start-streaming", JSON.stringify({ initialPlan: "Stable plan body the user already finished writing", existingSessionId: draftSessionId, }), { "content-type": "application/json" }, ); expect(start.status).toBe(201); // Title is preserved exactly — no overwrite from a second summarize call. expect(aiSessionStore.get(draftSessionId)?.title).toBe(titleAfterBlur); // And the persisted summarizedFor still equals the final initialPlan // so a future restart wouldn't re-summarize either. const payload = JSON.parse(aiSessionStore.get(draftSessionId)?.inputPayload ?? "{}"); expect(payload.summarizedFor).toBe("Stable plan body the user already finished writing"); }); it("re-summarizes on start when the user typed more after the last blur", async () => { // Counterpart to the dedup test: if the persisted text is now different // from what was last summarized, the start path must re-summarize so // the sidebar doesn't show a stale title once the session is running. const draft = await request( app, "POST", "/api/planning/create-draft", JSON.stringify({ initialPlan: "Initial plan body before the late edits" }), { "content-type": "application/json" }, ); const draftSessionId = draft.body.sessionId as string; await request( app, "POST", `/api/planning/${draftSessionId}/summarize-draft-title`, JSON.stringify({}), { "content-type": "application/json" }, ); const blurredPayload = JSON.parse(aiSessionStore.get(draftSessionId)?.inputPayload ?? "{}"); expect(blurredPayload.summarizedFor).toBe("Initial plan body before the late edits"); // User keeps typing — sync the new text via PATCH /draft. This must // preserve summarizedFor only if it still equals the new initialPlan; // since the text just changed, summarizedFor becomes stale. await request( app, "PATCH", `/api/ai-sessions/${draftSessionId}/draft`, JSON.stringify({ initialPlan: "Initial plan body before the late edits and now with extra detail" }), { "content-type": "application/json" }, ); const updatedPayload = JSON.parse(aiSessionStore.get(draftSessionId)?.inputPayload ?? "{}"); expect(updatedPayload.summarizedFor).toBeUndefined(); setupMockStreamingAgent({ responses: STANDARD_QUESTION_RESPONSES }); await request( app, "POST", "/api/planning/start-streaming", JSON.stringify({ initialPlan: "Initial plan body before the late edits and now with extra detail", existingSessionId: draftSessionId, }), { "content-type": "application/json" }, ); // Start path summarized again (or fell back to truncation) against the // new text. summarizeTitle returns null for short text so the fallback // is the first 60 chars of the trimmed plan; the key assertion is that // the title now reflects the post-edit text, not the stale prefix it // had after the original blur. const finalTitle = aiSessionStore.get(draftSessionId)?.title ?? ""; const expectedFallback = "Initial plan body before the late edits and now with extra detail".slice(0, 60).trim(); expect(finalTitle).toBe(expectedFallback); expect(finalTitle).not.toBe("Initial plan body before the late edits"); }); it("re-summarizes on start when the model changed since the last summarize, even if text is identical", async () => { // Defeats a subtle dedup loophole: blur produces a title under model A; // the user switches to model B without editing text; clicking Start // would otherwise reuse A's summary. updateDraft must invalidate // summarizedFor on a model change so the start path summarizes again // under model B. const draft = await request( app, "POST", "/api/planning/create-draft", JSON.stringify({ initialPlan: "Plan body that does not change between blur and start", planningModelProvider: "anthropic", planningModelId: "claude-opus-4-7", }), { "content-type": "application/json" }, ); const draftSessionId = draft.body.sessionId as string; const blur = await request( app, "POST", `/api/planning/${draftSessionId}/summarize-draft-title`, JSON.stringify({}), { "content-type": "application/json" }, ); expect(blur.status).toBe(200); const titleAfterBlur = blur.body.title as string; const blurredPayload = JSON.parse(aiSessionStore.get(draftSessionId)?.inputPayload ?? "{}"); expect(blurredPayload.summarizedFor).toBe("Plan body that does not change between blur and start"); expect(blurredPayload.modelProvider).toBe("anthropic"); // User switches model without editing text — the modal calls // updatePlanningSessionDraft with the new override. await request( app, "PATCH", `/api/ai-sessions/${draftSessionId}/draft`, JSON.stringify({ initialPlan: "Plan body that does not change between blur and start", modelProvider: "openai", modelId: "gpt-5", }), { "content-type": "application/json" }, ); // summarizedFor must be cleared even though the text is identical — // the prior summary was produced by a different model. const switchedPayload = JSON.parse(aiSessionStore.get(draftSessionId)?.inputPayload ?? "{}"); expect(switchedPayload.modelProvider).toBe("openai"); expect(switchedPayload.modelId).toBe("gpt-5"); expect(switchedPayload.summarizedFor).toBeUndefined(); // The dropped summarizedFor above is the load-bearing assertion: it // means startExistingSession's skip condition // (persistedSummarizedFor === trimmed) evaluates to false, so the // re-summarize path runs under the new model on Start. Title equality // can't distinguish "skipped" from "re-summarized to the same fallback" // for short text, so we verify the upstream signal that drives the // decision rather than asserting on the resulting title string. void titleAfterBlur; }); it("starts a draft that survived a backend restart by lazily rebuilding from SQLite", async () => { // Recreate the post-restart state: draft persisted in SQLite but the // in-memory sessions map is empty (rehydrateFromStore skips drafts since // listRecoverable only returns generating/awaiting_input rows). const draft = await request( app, "POST", "/api/planning/create-draft", JSON.stringify({ initialPlan: "Plan that should outlive a server restart" }), { "content-type": "application/json" }, ); expect(draft.status).toBe(201); const draftSessionId = draft.body.sessionId as string; // Wipe in-memory state to simulate a backend restart, then re-wire the // SQLite-backed store. The SQLite draft row survives; the in-memory // sessions map is empty because rehydrateFromStore intentionally skips // drafts (it only recovers in-flight generating/awaiting_input rows). __resetPlanningState(); setAiSessionStore(aiSessionStore as any); expect(aiSessionStore.get(draftSessionId)?.status).toBe("draft"); const start = await request( app, "POST", "/api/planning/start-streaming", JSON.stringify({ initialPlan: "Plan that should outlive a server restart", existingSessionId: draftSessionId, }), { "content-type": "application/json" }, ); expect(start.status).toBe(201); expect(start.body).toEqual({ sessionId: draftSessionId }); expect(getSession(draftSessionId)?.id).toBe(draftSessionId); expect(aiSessionStore.get(draftSessionId)?.status).toBe("awaiting_input"); }); it("keeps planning SSE stream read-only and unaffected by locks", async () => { const { sessionId } = await createSession(getUniqueIp(), "SSE lock check", taskStore, tmpRoot); await submitResponse(sessionId, { "q-scope": "small" }, tmpRoot); await submitResponse(sessionId, { "q-requirements": "Need auth" }, tmpRoot); await submitResponse(sessionId, { "q-confirm": true }, tmpRoot); aiSessionStore.acquireLock(sessionId, "tab-owner"); const streamResponse = await get(app, `/api/planning/${sessionId}/stream`); expect(streamResponse.status).toBe(200); expect(String(streamResponse.body)).toContain("event: summary"); expect(String(streamResponse.body)).toContain("event: complete"); }); }); // ── Thinking-Block Response Extraction Tests (FN-3300) ───────────────────── describe("FN-3300: thinking-block response extraction", () => { /** * Creates a mock agent that returns array content blocks (thinking + text). * This simulates Claude-style extended thinking responses. */ function createMockAgentWithBlocks( responses: Array< | string | Array<{ type: string; text?: string; thinking?: string }> >, ) { const messages: Array<{ role: string; content: | string | Array<{ type: string; text?: string; thinking?: string }>; }> = []; let callIndex = 0; return { session: { state: { messages }, prompt: vi.fn(async (msg: string) => { messages.push({ role: "user", content: msg }); const response = responses[callIndex++] ?? responses[responses.length - 1]; messages.push({ role: "assistant", content: response }); }), dispose: vi.fn(), }, }; } /** * Creates a mock streaming agent with array content blocks and callbacks. */ function setupMockStreamingAgentWithBlocks(options: { contentBlocks: Array< | string | Array<{ type: string; text?: string; thinking?: string }> >; thinkingOutputPerPrompt?: string[]; }) { const contentBlocks = options.contentBlocks; const thinkingOutputPerPrompt = options.thinkingOutputPerPrompt ?? []; let promptIndex = 0; const createFnAgentSpy = vi.fn( async (agentOptions?: { onThinking?: (delta: string) => void; onText?: (delta: string) => void; }) => { const messages: Array<{ role: string; content: | string | Array<{ type: string; text?: string; thinking?: string }>; }> = []; return { session: { state: { messages }, prompt: vi.fn(async (message: string) => { messages.push({ role: "user", content: message }); const thinking = thinkingOutputPerPrompt[promptIndex]; if (thinking) { agentOptions?.onText?.(thinking); } const response = contentBlocks[promptIndex] ?? contentBlocks[contentBlocks.length - 1]; messages.push({ role: "assistant", content: response }); promptIndex += 1; }), dispose: vi.fn(), }, }; }, ); __setCreateFnAgent(createFnAgentSpy as any); return { createFnAgentSpy }; } const questionJson = JSON.stringify({ type: "question", data: { id: "q-scope", type: "single_select", question: "What is the scope?", description: "Describe the scope.", options: [ { id: "small", label: "Small", description: "Quick" }, { id: "medium", label: "Medium", description: "Standard" }, { id: "large", label: "Large", description: "Complex" }, ], }, }); beforeEach(() => { __resetPlanningState(); }); describe("continueAgentConversation (streaming path)", () => { it("falls back to thinkingOutput when message content has only thinking blocks", async () => { // The streaming agent accumulates text via onText callback into thinkingOutput. // When the message content array has only thinking-type blocks, the // text blocks filter yields empty string. The fix ensures we fall back // to the accumulated thinkingOutput instead of overwriting with "". setupMockStreamingAgentWithBlocks({ contentBlocks: [ // First prompt: only thinking blocks in message content [{ type: "thinking", thinking: "Let me think about this..." }], // Retry prompt: valid text response questionJson, ], // The actual JSON was accumulated via onText callback during streaming thinkingOutputPerPrompt: [questionJson, questionJson], }); const sessionId = await createSessionWithAgent( getUniqueIp(), "Test plan", TEST_ROOT_DIR, ); await vi.waitFor(() => { expect(getSession(sessionId)?.currentQuestion?.id).toBe("q-scope"); }); // Submit response to trigger continueAgentConversation const result = await submitResponse(sessionId, { "q-scope": "medium" }, TEST_ROOT_DIR); expect(result.type).toBe("question"); if (result.type === "question") { expect(result.data.id).toBe("q-scope"); } }); it("prefers text blocks over thinkingOutput when both are present", async () => { const differentJson = JSON.stringify({ type: "question", data: { id: "q-from-text-block", type: "text", question: "What do you need?", description: "Describe.", }, }); setupMockStreamingAgentWithBlocks({ contentBlocks: [ // Message has both thinking AND text blocks [ { type: "thinking", thinking: "Thinking about the response..." }, { type: "text", text: differentJson }, ], ], // thinkingOutput has something different — should NOT be used thinkingOutputPerPrompt: ["old-thinking-output"], }); const sessionId = await createSessionWithAgent( getUniqueIp(), "Test plan", TEST_ROOT_DIR, ); await vi.waitFor(() => { expect(getSession(sessionId)?.currentQuestion?.id).toBe("q-from-text-block"); }); const result = await submitResponse(sessionId, { "q-from-text-block": "value" }, TEST_ROOT_DIR); expect(result.type).toBe("question"); if (result.type === "question") { expect(result.data.id).toBe("q-from-text-block"); } }); }); describe("getFirstQuestionFromAgent (non-streaming path)", () => { it("extracts thinking block content when no text blocks are present", async () => { // Non-streaming path: createSession uses createMockAgent which returns // array content blocks. When only thinking blocks exist, extract their text. const agent = createMockAgentWithBlocks([ // Only thinking blocks — the JSON is inside the thinking text [{ type: "thinking", thinking: questionJson }], ]); __setCreateFnAgent(async () => agent); const result = await createSession( getUniqueIp(), "Test plan", undefined, TEST_ROOT_DIR, ); expect(result.firstQuestion).toBeDefined(); expect(result.firstQuestion.id).toBe("q-scope"); }); it("prefers text blocks over thinking blocks when both present", async () => { const textBlockJson = JSON.stringify({ type: "question", data: { id: "q-from-text", type: "text", question: "Text block question?", description: "From text block.", }, }); const agent = createMockAgentWithBlocks([ [ { type: "thinking", thinking: questionJson }, { type: "text", text: textBlockJson }, ], ]); __setCreateFnAgent(async () => agent); const result = await createSession( getUniqueIp(), "Test plan", undefined, TEST_ROOT_DIR, ); expect(result.firstQuestion).toBeDefined(); expect(result.firstQuestion.id).toBe("q-from-text"); }); }); describe("diagnostics logging for empty response text", () => { it("logs warning when response text is empty after extraction", async () => { const { setDiagnosticsSink, resetDiagnosticsSink: resetSink } = await import( "../ai-session-diagnostics.js" ); const warnings: Array<{ level: string; scope: string; message: string; context: Record; }> = []; setDiagnosticsSink((level, scope, message, context) => { warnings.push({ level, scope, message, context }); }); try { // Agent returns content array with only thinking blocks, and no // thinking text in them either — truly empty thinking blocks const agent = createMockAgentWithBlocks([ [{ type: "thinking", thinking: "" }], ]); __setCreateFnAgent(async () => agent); await expect( createSession(getUniqueIp(), "Test plan", undefined, TEST_ROOT_DIR), ).rejects.toThrow("Failed to get first question from AI"); // Should have logged a warning about empty response text const extractionWarning = warnings.find( (w) => w.message === "Response text is empty or very short before parse" && w.context.operation === "response-extraction", ); expect(extractionWarning).toBeDefined(); expect(extractionWarning!.context.contentBlockTypes).toEqual([ "thinking", ]); } finally { resetSink(); } }); }); });