diff --git a/.changeset/fn-6443.md b/.changeset/fn-6443.md new file mode 100644 index 0000000000..d36af32a9f --- /dev/null +++ b/.changeset/fn-6443.md @@ -0,0 +1,5 @@ +--- +"@runfusion/fusion": patch +--- + +Persist planning-session response history before agent continuation so retry/replay and SQLite session recovery retain answered turns when generation errors or transitions complete. diff --git a/packages/dashboard/src/__tests__/session-cross-tab.test.ts b/packages/dashboard/src/__tests__/session-cross-tab.test.ts index b4350f8f2f..8506b3c68d 100644 --- a/packages/dashboard/src/__tests__/session-cross-tab.test.ts +++ b/packages/dashboard/src/__tests__/session-cross-tab.test.ts @@ -10,8 +10,9 @@ import { beforeEach, afterEach, describe, expect, it } from "vitest"; import { mkdtempSync } from "node:fs"; import { rm } from "node:fs/promises"; import { tmpdir } from "node:os"; +import { setImmediate } from "node:timers"; import { join } from "node:path"; -import { TaskStore } from "@fusion/core"; +import { Database, TaskStore } from "@fusion/core"; import { AiSessionStore, type AiSessionRow } from "../ai-session-store.js"; import { createApiRoutes } from "../routes.js"; import { request } from "../test-request.js"; @@ -41,6 +42,7 @@ function makeRow(id: string, overrides: Partial = {}): AiSessionRo describe("cross-tab session locking", () => { let tmpRoot: string; let taskStore: TaskStore; + let db: Database; let aiSessionStore: AiSessionStore; let app: express.Express; @@ -48,7 +50,13 @@ describe("cross-tab session locking", () => { tmpRoot = mkdtempSync(join(tmpdir(), "kb-session-cross-tab-")); taskStore = new TaskStore(tmpRoot, join(tmpRoot, ".fusion-global-settings"), { inMemoryDb: true }); await taskStore.init(); - aiSessionStore = new AiSessionStore(taskStore.getDatabase()); + /* + FNXC:DashboardSessionTests 2026-06-14-09:10: + AiSessionStore uses SQLite files that must be closed independently before tmpRoot cleanup. Keep it on a dedicated Database handle outside TaskStore's .fusion directory so TaskStore teardown cannot leave session-store writers racing recursive rm. + */ + db = new Database(join(tmpRoot, ".fusion-ai-sessions")); + db.init(); + aiSessionStore = new AiSessionStore(db); app = express(); app.use(express.json()); @@ -61,6 +69,13 @@ describe("cross-tab session locking", () => { } catch { // no-op } + try { + db.close(); + } catch { + // no-op + } + // FNXC:DashboardSessionTests 2026-06-14-09:20: TaskStore.close() closes watcher/database handles synchronously but their filesystem close callbacks settle on the next event-loop turn; drain that turn before deleting .fusion. + await new Promise((resolve) => setImmediate(resolve)); await rm(tmpRoot, { recursive: true, force: true }); }); @@ -109,10 +124,7 @@ describe("cross-tab session locking", () => { aiSessionStore.acquireLock("lock-expiry", "tab-expired"); const staleTimestamp = new Date(Date.now() - 31 * 60 * 1000).toISOString(); - taskStore - .getDatabase() - .prepare("UPDATE ai_sessions SET lockedAt = ? WHERE id = ?") - .run(staleTimestamp, "lock-expiry"); + db.prepare("UPDATE ai_sessions SET lockedAt = ? WHERE id = ?").run(staleTimestamp, "lock-expiry"); const released = aiSessionStore.releaseStaleLocks(30 * 60 * 1000); @@ -152,14 +164,12 @@ describe("cross-tab session locking", () => { const stale = new Date(Date.now() - 8 * 24 * 60 * 60 * 1000).toISOString(); const fresh = new Date(Date.now() - 60 * 1000).toISOString(); - taskStore - .getDatabase() - .prepare("UPDATE ai_sessions SET updatedAt = ? WHERE id IN (?, ?)") - .run(stale, "stale-generating", "stale-awaiting"); - taskStore - .getDatabase() - .prepare("UPDATE ai_sessions SET updatedAt = ? WHERE id = ?") - .run(fresh, "fresh-generating"); + db.prepare("UPDATE ai_sessions SET updatedAt = ? WHERE id IN (?, ?)").run( + stale, + "stale-generating", + "stale-awaiting", + ); + db.prepare("UPDATE ai_sessions SET updatedAt = ? WHERE id = ?").run(fresh, "fresh-generating"); const summary = aiSessionStore.cleanupStaleSessions(7 * 24 * 60 * 60 * 1000); diff --git a/packages/dashboard/src/__tests__/session-error-recovery.test.ts b/packages/dashboard/src/__tests__/session-error-recovery.test.ts index 30351454de..78f6c4c28c 100644 --- a/packages/dashboard/src/__tests__/session-error-recovery.test.ts +++ b/packages/dashboard/src/__tests__/session-error-recovery.test.ts @@ -46,6 +46,8 @@ const { mockCreateFnAgent } = vi.hoisted(() => ({ vi.mock("@fusion/engine", () => ({ listCliAdapterDescriptors: () => [], + // FNXC:DashboardSessionTests 2026-06-14-09:06: planning.ts spreads createWorkflowAuthoringTools into agent customTools; this focused engine mock must export it to keep AI-session tests aligned with production planning setup. + createWorkflowAuthoringTools: vi.fn(() => []), createFnAgent: mockCreateFnAgent, })); diff --git a/packages/dashboard/src/__tests__/session-persistence-roundtrip.test.ts b/packages/dashboard/src/__tests__/session-persistence-roundtrip.test.ts index cc906b8d74..8d3dc3f886 100644 --- a/packages/dashboard/src/__tests__/session-persistence-roundtrip.test.ts +++ b/packages/dashboard/src/__tests__/session-persistence-roundtrip.test.ts @@ -39,6 +39,8 @@ const { mockCreateFnAgent } = vi.hoisted(() => ({ vi.mock("@fusion/engine", () => ({ listCliAdapterDescriptors: () => [], + // FNXC:DashboardSessionTests 2026-06-14-09:06: planning.ts spreads createWorkflowAuthoringTools into agent customTools; this focused engine mock must export it to keep AI-session tests aligned with production planning setup. + createWorkflowAuthoringTools: vi.fn(() => []), createFnAgent: mockCreateFnAgent, })); diff --git a/packages/dashboard/src/__tests__/session-reconnect.test.ts b/packages/dashboard/src/__tests__/session-reconnect.test.ts index 885fcaa314..a5a0dbde4a 100644 --- a/packages/dashboard/src/__tests__/session-reconnect.test.ts +++ b/packages/dashboard/src/__tests__/session-reconnect.test.ts @@ -9,9 +9,10 @@ import express from "express"; import { mkdtempSync } from "node:fs"; import { rm } from "node:fs/promises"; import { tmpdir } from "node:os"; +import { setImmediate } from "node:timers"; import { join } from "node:path"; import { beforeEach, afterEach, describe, expect, it, vi } from "vitest"; -import { TaskStore } from "@fusion/core"; +import { Database, TaskStore } from "@fusion/core"; import { createApiRoutes } from "../routes.js"; import { request, get } from "../test-request.js"; import { AiSessionStore, type AiSessionRow } from "../ai-session-store.js"; @@ -42,6 +43,8 @@ const { mockCreateFnAgent } = vi.hoisted(() => ({ vi.mock("@fusion/engine", () => ({ listCliAdapterDescriptors: () => [], + // FNXC:DashboardSessionTests 2026-06-14-09:06: planning.ts spreads createWorkflowAuthoringTools into agent customTools; this focused engine mock must export it to keep AI-session tests aligned with production planning setup. + createWorkflowAuthoringTools: vi.fn(() => []), createFnAgent: mockCreateFnAgent, createResolvedAgentSession: vi.fn(async () => ({ session: { state: { messages: [] }, prompt: vi.fn(), dispose: vi.fn() }, @@ -99,6 +102,7 @@ function extractEventId(body: string, eventName: string): number { describe("session reconnect + replay", () => { let tmpRoot: string; let store: TaskStore; + let db: Database; let aiSessionStore: AiSessionStore; let app: express.Express; @@ -111,7 +115,13 @@ describe("session reconnect + replay", () => { tmpRoot = mkdtempSync(join(tmpdir(), "kb-session-reconnect-")); store = new TaskStore(tmpRoot, join(tmpRoot, ".fusion-global-settings"), { inMemoryDb: true }); await store.init(); - aiSessionStore = new AiSessionStore(store.getDatabase()); + /* + FNXC:DashboardSessionTests 2026-06-14-09:10: + Reconnect tests exercise persisted SSE replay through AiSessionStore; use a dedicated Database handle outside TaskStore's .fusion directory and close it before tmpRoot cleanup so session SQLite files are not removed while writers are still open. + */ + db = new Database(join(tmpRoot, ".fusion-ai-sessions")); + db.init(); + aiSessionStore = new AiSessionStore(db); setPlanningAiSessionStore(aiSessionStore); setSubtaskAiSessionStore(aiSessionStore); @@ -133,6 +143,13 @@ describe("session reconnect + replay", () => { } catch { // no-op } + try { + db.close(); + } catch { + // no-op + } + // FNXC:DashboardSessionTests 2026-06-14-09:20: TaskStore.close() closes watcher/database handles synchronously but their filesystem close callbacks settle on the next event-loop turn; drain that turn before deleting .fusion. + await new Promise((resolve) => setImmediate(resolve)); await rm(tmpRoot, { recursive: true, force: true }); }); diff --git a/packages/dashboard/src/planning.ts b/packages/dashboard/src/planning.ts index 6ccf89f44c..6e2c18d2d3 100644 --- a/packages/dashboard/src/planning.ts +++ b/packages/dashboard/src/planning.ts @@ -2026,18 +2026,19 @@ export async function submitResponse( }; session.error = undefined; + /* + FNXC:DashboardSessionPersistence 2026-06-14-09:09: + Persist the user's answered planning turn before the agent generates the next question or errors. AiSessionStore snapshots happen inside continueAgentConversation, so history must already include the submitted answer for retry replay and SQLite round-trip tests to observe durable state. + */ + session.history.push(historyEntry); persistSession(session, "generating"); if (!session.agent) { - await ensureSessionAgent(session, rootDir, session.history, promptOverrides, store); + await ensureSessionAgent(session, rootDir, session.history.slice(0, -1), promptOverrides, store); } const message = formatResponseForAgent(currentQuestion, responses); await continueAgentConversation(session, message); - - if (!session.error) { - session.history.push(historyEntry); - } } // Return the current state (will be updated via SSE) diff --git a/scripts/lib/dashboard-curated-skiplist.json b/scripts/lib/dashboard-curated-skiplist.json index 3a2f36163b..5a3b89016e 100644 --- a/scripts/lib/dashboard-curated-skiplist.json +++ b/scripts/lib/dashboard-curated-skiplist.json @@ -37,22 +37,6 @@ "file": "packages/dashboard/src/__tests__/routes-run-cited-goals.test.ts", "reason": "pre-existing failure (orphaned from curated gate, never previously executed in CI; tracked as FN-6444)" }, - { - "file": "packages/dashboard/src/__tests__/session-cross-tab.test.ts", - "reason": "pre-existing failure (orphaned from curated gate, never previously executed in CI; tracked as FN-6443)" - }, - { - "file": "packages/dashboard/src/__tests__/session-error-recovery.test.ts", - "reason": "pre-existing failure (orphaned from curated gate, never previously executed in CI; tracked as FN-6443)" - }, - { - "file": "packages/dashboard/src/__tests__/session-persistence-roundtrip.test.ts", - "reason": "pre-existing failure (orphaned from curated gate, never previously executed in CI; tracked as FN-6443)" - }, - { - "file": "packages/dashboard/src/__tests__/session-reconnect.test.ts", - "reason": "pre-existing failure (orphaned from curated gate, never previously executed in CI; tracked as FN-6443)" - }, { "file": "packages/dashboard/src/__tests__/chat-manager.test.ts", "reason": "pre-existing exclusion from dashboard-api-quality-backfill; tracked for rescue in FN-6444"