feat(FN-1152): add retry flows for failed AI planning sessions

- Keep errored AI sessions retryable in the session store and add backend retry handlers for planning, subtask breakdown, and mission interview flows
- Add retry API routes and dashboard API client helpers for planning, subtask, and mission interview session retries
- Update PlanningModeModal, SubtaskBreakdownModal, MissionInterviewModal, and background session handling to show error states with retry/cancel UX
- Expand unit and integration tests across store, services, routes, and modal components to cover retry success and failure paths
This commit is contained in:
gsxdsm
2026-04-08 15:10:46 -07:00
parent d0578a589b
commit 0ff6d42c0f
20 changed files with 1368 additions and 234 deletions

View File

@@ -4,6 +4,7 @@ import {
createSession,
createSessionWithAgent,
submitResponse,
retrySession,
cancelSession,
getSession,
getCurrentQuestion,
@@ -179,7 +180,7 @@ class MockAiSessionStore extends EventEmitter {
listRecoverable(): AiSessionRow[] {
return [...this.rows.values()].filter(
(row) => row.status === "awaiting_input" || row.status === "generating",
(row) => row.status === "awaiting_input" || row.status === "generating" || row.status === "error",
);
}
@@ -612,6 +613,103 @@ describe("planning module", () => {
});
});
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",
},
}),
]);
__setCreateKbAgent(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",
},
}),
]);
__setCreateKbAgent(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);
});
});
describe("cancelSession", () => {
it("removes an active session", async () => {
const mockIp = getUniqueIp();