fix(FN-2861): harden planning session recovery and retry UX

- Add planning and subtask retry routes with session-lock checks and proper API error mapping
- Improve planning session execution with timeout/abort handling, stop-generation support, and resilient stream catch-up behavior
- Update Planning Mode modal to handle reconnects, retry-from-error flow, stop action, and cross-tab session state synchronization
- Expand dashboard tests for planning routes, planning session behavior, and PlanningModeModal retry/error coverage
This commit is contained in:
Fusion
2026-04-28 08:16:12 -07:00
committed by gsxdsm
parent 2f7ba29ead
commit ff58623c32
8 changed files with 401 additions and 33 deletions

View File

@@ -12,6 +12,7 @@ import {
submitResponse,
retrySession,
cancelSession,
stopGeneration,
getSession,
getCurrentQuestion,
getSummary,
@@ -32,6 +33,7 @@ import {
generateSubtasksFromPlanning,
formatInterviewQA,
SESSION_TTL_MS,
GENERATION_TIMEOUT_MS,
} from "../planning.js";
import { createApiRoutes } from "../routes.js";
import { request, get } from "../test-request.js";
@@ -1109,6 +1111,66 @@ describe("planning module", () => {
});
});
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<void>((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<void>(() => {})),
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();

View File

@@ -9852,6 +9852,27 @@ describe("Planning Mode Routes", () => {
});
});
describe("POST /planning/:sessionId/stop", () => {
it("stops an active generation", async () => {
const stopSpy = vi.spyOn(planningModule, "stopGeneration").mockReturnValue(true);
const res = await REQUEST(buildApp(), "POST", "/api/planning/session-123/stop");
expect(res.status).toBe(200);
expect(res.body).toEqual({ success: true });
expect(stopSpy).toHaveBeenCalledWith("session-123");
});
it("returns 404 when session is missing", async () => {
vi.spyOn(planningModule, "stopGeneration").mockReturnValue(false);
const res = await REQUEST(buildApp(), "POST", "/api/planning/session-404/stop");
expect(res.status).toBe(404);
expect(res.body.error).toContain("not found");
});
});
describe("POST /planning/cancel", () => {
it("cancels an active session", async () => {
// Create a session first