FN-6443: rescue dashboard session tests
Rescue skipped dashboard session tests by making planning responses durable before continuation. - Persist planning-session response history before agent continuation so retry and replay state survives generation errors. - Isolate dashboard session tests on dedicated SQLite handles and close them before temp cleanup. - Restore engine mocks for workflow authoring tools and remove the rescued session tests from the curated dashboard skiplist. - Add a patch changeset for the published CLI bundle. Files changed: .changeset/fn-6443.md | 5 +++ .../src/__tests__/session-cross-tab.test.ts | 38 ++++++++++++++-------- .../src/__tests__/session-error-recovery.test.ts | 2 ++ .../session-persistence-roundtrip.test.ts | 2 ++ .../src/__tests__/session-reconnect.test.ts | 21 ++++++++++-- packages/dashboard/src/planning.ts | 11 ++++--- scripts/lib/dashboard-curated-skiplist.json | 16 --------- 7 files changed, 58 insertions(+), 37 deletions(-) Fusion-Task-Id: FN-6443 Fusion-Task-Lineage: 953534e7-6857-4cd5-9a5a-5772354cac5a
This commit is contained in:
5
.changeset/fn-6443.md
Normal file
5
.changeset/fn-6443.md
Normal file
@@ -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.
|
||||||
@@ -10,8 +10,9 @@ import { beforeEach, afterEach, describe, expect, it } from "vitest";
|
|||||||
import { mkdtempSync } from "node:fs";
|
import { mkdtempSync } from "node:fs";
|
||||||
import { rm } from "node:fs/promises";
|
import { rm } from "node:fs/promises";
|
||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
|
import { setImmediate } from "node:timers";
|
||||||
import { join } from "node:path";
|
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 { AiSessionStore, type AiSessionRow } from "../ai-session-store.js";
|
||||||
import { createApiRoutes } from "../routes.js";
|
import { createApiRoutes } from "../routes.js";
|
||||||
import { request } from "../test-request.js";
|
import { request } from "../test-request.js";
|
||||||
@@ -41,6 +42,7 @@ function makeRow(id: string, overrides: Partial<AiSessionRow> = {}): AiSessionRo
|
|||||||
describe("cross-tab session locking", () => {
|
describe("cross-tab session locking", () => {
|
||||||
let tmpRoot: string;
|
let tmpRoot: string;
|
||||||
let taskStore: TaskStore;
|
let taskStore: TaskStore;
|
||||||
|
let db: Database;
|
||||||
let aiSessionStore: AiSessionStore;
|
let aiSessionStore: AiSessionStore;
|
||||||
let app: express.Express;
|
let app: express.Express;
|
||||||
|
|
||||||
@@ -48,7 +50,13 @@ describe("cross-tab session locking", () => {
|
|||||||
tmpRoot = mkdtempSync(join(tmpdir(), "kb-session-cross-tab-"));
|
tmpRoot = mkdtempSync(join(tmpdir(), "kb-session-cross-tab-"));
|
||||||
taskStore = new TaskStore(tmpRoot, join(tmpRoot, ".fusion-global-settings"), { inMemoryDb: true });
|
taskStore = new TaskStore(tmpRoot, join(tmpRoot, ".fusion-global-settings"), { inMemoryDb: true });
|
||||||
await taskStore.init();
|
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 = express();
|
||||||
app.use(express.json());
|
app.use(express.json());
|
||||||
@@ -61,6 +69,13 @@ describe("cross-tab session locking", () => {
|
|||||||
} catch {
|
} catch {
|
||||||
// no-op
|
// 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<void>((resolve) => setImmediate(resolve));
|
||||||
await rm(tmpRoot, { recursive: true, force: true });
|
await rm(tmpRoot, { recursive: true, force: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -109,10 +124,7 @@ describe("cross-tab session locking", () => {
|
|||||||
aiSessionStore.acquireLock("lock-expiry", "tab-expired");
|
aiSessionStore.acquireLock("lock-expiry", "tab-expired");
|
||||||
|
|
||||||
const staleTimestamp = new Date(Date.now() - 31 * 60 * 1000).toISOString();
|
const staleTimestamp = new Date(Date.now() - 31 * 60 * 1000).toISOString();
|
||||||
taskStore
|
db.prepare("UPDATE ai_sessions SET lockedAt = ? WHERE id = ?").run(staleTimestamp, "lock-expiry");
|
||||||
.getDatabase()
|
|
||||||
.prepare("UPDATE ai_sessions SET lockedAt = ? WHERE id = ?")
|
|
||||||
.run(staleTimestamp, "lock-expiry");
|
|
||||||
|
|
||||||
const released = aiSessionStore.releaseStaleLocks(30 * 60 * 1000);
|
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 stale = new Date(Date.now() - 8 * 24 * 60 * 60 * 1000).toISOString();
|
||||||
const fresh = new Date(Date.now() - 60 * 1000).toISOString();
|
const fresh = new Date(Date.now() - 60 * 1000).toISOString();
|
||||||
taskStore
|
db.prepare("UPDATE ai_sessions SET updatedAt = ? WHERE id IN (?, ?)").run(
|
||||||
.getDatabase()
|
stale,
|
||||||
.prepare("UPDATE ai_sessions SET updatedAt = ? WHERE id IN (?, ?)")
|
"stale-generating",
|
||||||
.run(stale, "stale-generating", "stale-awaiting");
|
"stale-awaiting",
|
||||||
taskStore
|
);
|
||||||
.getDatabase()
|
db.prepare("UPDATE ai_sessions SET updatedAt = ? WHERE id = ?").run(fresh, "fresh-generating");
|
||||||
.prepare("UPDATE ai_sessions SET updatedAt = ? WHERE id = ?")
|
|
||||||
.run(fresh, "fresh-generating");
|
|
||||||
|
|
||||||
const summary = aiSessionStore.cleanupStaleSessions(7 * 24 * 60 * 60 * 1000);
|
const summary = aiSessionStore.cleanupStaleSessions(7 * 24 * 60 * 60 * 1000);
|
||||||
|
|
||||||
|
|||||||
@@ -46,6 +46,8 @@ const { mockCreateFnAgent } = vi.hoisted(() => ({
|
|||||||
|
|
||||||
vi.mock("@fusion/engine", () => ({
|
vi.mock("@fusion/engine", () => ({
|
||||||
listCliAdapterDescriptors: () => [],
|
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,
|
createFnAgent: mockCreateFnAgent,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|||||||
@@ -39,6 +39,8 @@ const { mockCreateFnAgent } = vi.hoisted(() => ({
|
|||||||
|
|
||||||
vi.mock("@fusion/engine", () => ({
|
vi.mock("@fusion/engine", () => ({
|
||||||
listCliAdapterDescriptors: () => [],
|
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,
|
createFnAgent: mockCreateFnAgent,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|||||||
@@ -9,9 +9,10 @@ import express from "express";
|
|||||||
import { mkdtempSync } from "node:fs";
|
import { mkdtempSync } from "node:fs";
|
||||||
import { rm } from "node:fs/promises";
|
import { rm } from "node:fs/promises";
|
||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
|
import { setImmediate } from "node:timers";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
import { beforeEach, afterEach, describe, expect, it, vi } from "vitest";
|
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 { createApiRoutes } from "../routes.js";
|
||||||
import { request, get } from "../test-request.js";
|
import { request, get } from "../test-request.js";
|
||||||
import { AiSessionStore, type AiSessionRow } from "../ai-session-store.js";
|
import { AiSessionStore, type AiSessionRow } from "../ai-session-store.js";
|
||||||
@@ -42,6 +43,8 @@ const { mockCreateFnAgent } = vi.hoisted(() => ({
|
|||||||
|
|
||||||
vi.mock("@fusion/engine", () => ({
|
vi.mock("@fusion/engine", () => ({
|
||||||
listCliAdapterDescriptors: () => [],
|
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,
|
createFnAgent: mockCreateFnAgent,
|
||||||
createResolvedAgentSession: vi.fn(async () => ({
|
createResolvedAgentSession: vi.fn(async () => ({
|
||||||
session: { state: { messages: [] }, prompt: vi.fn(), dispose: vi.fn() },
|
session: { state: { messages: [] }, prompt: vi.fn(), dispose: vi.fn() },
|
||||||
@@ -99,6 +102,7 @@ function extractEventId(body: string, eventName: string): number {
|
|||||||
describe("session reconnect + replay", () => {
|
describe("session reconnect + replay", () => {
|
||||||
let tmpRoot: string;
|
let tmpRoot: string;
|
||||||
let store: TaskStore;
|
let store: TaskStore;
|
||||||
|
let db: Database;
|
||||||
let aiSessionStore: AiSessionStore;
|
let aiSessionStore: AiSessionStore;
|
||||||
let app: express.Express;
|
let app: express.Express;
|
||||||
|
|
||||||
@@ -111,7 +115,13 @@ describe("session reconnect + replay", () => {
|
|||||||
tmpRoot = mkdtempSync(join(tmpdir(), "kb-session-reconnect-"));
|
tmpRoot = mkdtempSync(join(tmpdir(), "kb-session-reconnect-"));
|
||||||
store = new TaskStore(tmpRoot, join(tmpRoot, ".fusion-global-settings"), { inMemoryDb: true });
|
store = new TaskStore(tmpRoot, join(tmpRoot, ".fusion-global-settings"), { inMemoryDb: true });
|
||||||
await store.init();
|
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);
|
setPlanningAiSessionStore(aiSessionStore);
|
||||||
setSubtaskAiSessionStore(aiSessionStore);
|
setSubtaskAiSessionStore(aiSessionStore);
|
||||||
@@ -133,6 +143,13 @@ describe("session reconnect + replay", () => {
|
|||||||
} catch {
|
} catch {
|
||||||
// no-op
|
// 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<void>((resolve) => setImmediate(resolve));
|
||||||
await rm(tmpRoot, { recursive: true, force: true });
|
await rm(tmpRoot, { recursive: true, force: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -2026,18 +2026,19 @@ export async function submitResponse(
|
|||||||
};
|
};
|
||||||
|
|
||||||
session.error = undefined;
|
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");
|
persistSession(session, "generating");
|
||||||
|
|
||||||
if (!session.agent) {
|
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);
|
const message = formatResponseForAgent(currentQuestion, responses);
|
||||||
await continueAgentConversation(session, message);
|
await continueAgentConversation(session, message);
|
||||||
|
|
||||||
if (!session.error) {
|
|
||||||
session.history.push(historyEntry);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Return the current state (will be updated via SSE)
|
// Return the current state (will be updated via SSE)
|
||||||
|
|||||||
@@ -37,22 +37,6 @@
|
|||||||
"file": "packages/dashboard/src/__tests__/routes-run-cited-goals.test.ts",
|
"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)"
|
"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",
|
"file": "packages/dashboard/src/__tests__/chat-manager.test.ts",
|
||||||
"reason": "pre-existing exclusion from dashboard-api-quality-backfill; tracked for rescue in FN-6444"
|
"reason": "pre-existing exclusion from dashboard-api-quality-backfill; tracked for rescue in FN-6444"
|
||||||
|
|||||||
Reference in New Issue
Block a user