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:
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user