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:
@@ -1253,6 +1253,19 @@ export function respondToPlanning(
|
||||
});
|
||||
}
|
||||
|
||||
/** Retry a failed planning session turn */
|
||||
export function retryPlanningSession(
|
||||
sessionId: string,
|
||||
projectId?: string,
|
||||
): Promise<{ success: boolean; sessionId: string }> {
|
||||
return api<{ success: boolean; sessionId: string }>(
|
||||
withProjectId(`/planning/${encodeURIComponent(sessionId)}/retry`, projectId),
|
||||
{
|
||||
method: "POST",
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/** Cancel an active planning session */
|
||||
export function cancelPlanning(sessionId: string, projectId?: string): Promise<void> {
|
||||
return api<void>(withProjectId("/planning/cancel", projectId), {
|
||||
@@ -1803,6 +1816,18 @@ export function startSubtaskBreakdown(description: string, projectId?: string):
|
||||
});
|
||||
}
|
||||
|
||||
export function retrySubtaskSession(
|
||||
sessionId: string,
|
||||
projectId?: string,
|
||||
): Promise<{ success: boolean; sessionId: string }> {
|
||||
return api<{ success: boolean; sessionId: string }>(
|
||||
withProjectId(`/subtasks/${encodeURIComponent(sessionId)}/retry`, projectId),
|
||||
{
|
||||
method: "POST",
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export function getSubtaskStreamUrl(sessionId: string, projectId?: string): string {
|
||||
return buildApiUrl(withProjectId(`/subtasks/${encodeURIComponent(sessionId)}/stream`, projectId));
|
||||
}
|
||||
@@ -3235,6 +3260,19 @@ export function respondToMissionInterview(
|
||||
});
|
||||
}
|
||||
|
||||
/** Retry a failed mission interview turn */
|
||||
export function retryMissionInterviewSession(
|
||||
sessionId: string,
|
||||
projectId?: string,
|
||||
): Promise<{ success: boolean; sessionId: string }> {
|
||||
return api<{ success: boolean; sessionId: string }>(
|
||||
withProjectId(`/missions/interview/${encodeURIComponent(sessionId)}/retry`, projectId),
|
||||
{
|
||||
method: "POST",
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/** Cancel an active mission interview session */
|
||||
export function cancelMissionInterview(sessionId: string, projectId?: string): Promise<void> {
|
||||
return api<void>(withProjectId("/missions/interview/cancel", projectId), {
|
||||
|
||||
@@ -4,6 +4,7 @@ import { MissionInterviewModal } from "./MissionInterviewModal";
|
||||
|
||||
const mockStartMissionInterview = vi.fn();
|
||||
const mockRespondToMissionInterview = vi.fn();
|
||||
const mockRetryMissionInterviewSession = vi.fn();
|
||||
const mockCancelMissionInterview = vi.fn();
|
||||
const mockCreateMissionFromInterview = vi.fn();
|
||||
const mockConnectMissionInterviewStream = vi.fn();
|
||||
@@ -13,6 +14,7 @@ const mockParseConversationHistory = vi.fn();
|
||||
vi.mock("../api", () => ({
|
||||
startMissionInterview: (...args: any[]) => mockStartMissionInterview(...args),
|
||||
respondToMissionInterview: (...args: any[]) => mockRespondToMissionInterview(...args),
|
||||
retryMissionInterviewSession: (...args: any[]) => mockRetryMissionInterviewSession(...args),
|
||||
cancelMissionInterview: (...args: any[]) => mockCancelMissionInterview(...args),
|
||||
createMissionFromInterview: (...args: any[]) => mockCreateMissionFromInterview(...args),
|
||||
connectMissionInterviewStream: (...args: any[]) => mockConnectMissionInterviewStream(...args),
|
||||
@@ -45,6 +47,7 @@ describe("MissionInterviewModal", () => {
|
||||
streamHandlers = undefined;
|
||||
|
||||
mockStartMissionInterview.mockResolvedValue({ sessionId: "mission-session-1" });
|
||||
mockRetryMissionInterviewSession.mockResolvedValue({ success: true, sessionId: "mission-session-1" });
|
||||
mockFetchAiSession.mockResolvedValue(null);
|
||||
mockParseConversationHistory.mockImplementation((raw: string) => {
|
||||
if (!raw) return [];
|
||||
@@ -135,4 +138,62 @@ describe("MissionInterviewModal", () => {
|
||||
expect(screen.getByText("Reconnecting…")).toBeInTheDocument();
|
||||
expect(screen.getByText("Analyzing mission goals...")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows error panel with retry action when stream fails", async () => {
|
||||
renderModal();
|
||||
|
||||
fireEvent.change(screen.getByLabelText("What do you want to build?"), {
|
||||
target: { value: "Build a mission planning workflow" },
|
||||
});
|
||||
fireEvent.click(screen.getByText("Start Interview"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(streamHandlers).toBeDefined();
|
||||
});
|
||||
|
||||
act(() => {
|
||||
streamHandlers.onError?.("Temporary outage");
|
||||
});
|
||||
|
||||
expect(await screen.findByText("Temporary outage")).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Retry" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("retries interview session from error view", async () => {
|
||||
let attempt = 0;
|
||||
mockConnectMissionInterviewStream.mockImplementation((_sessionId, _projectId, handlers) => {
|
||||
streamHandlers = handlers;
|
||||
attempt += 1;
|
||||
if (attempt === 1) {
|
||||
setTimeout(() => handlers.onError?.("Try again"), 10);
|
||||
} else {
|
||||
setTimeout(() => handlers.onQuestion?.(SAMPLE_QUESTION), 10);
|
||||
}
|
||||
return {
|
||||
close: vi.fn(),
|
||||
isConnected: vi.fn().mockReturnValue(true),
|
||||
};
|
||||
});
|
||||
|
||||
renderModal();
|
||||
|
||||
fireEvent.change(screen.getByLabelText("What do you want to build?"), {
|
||||
target: { value: "Build a mission planning workflow" },
|
||||
});
|
||||
fireEvent.click(screen.getByText("Start Interview"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Try again")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Retry" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockRetryMissionInterviewSession).toHaveBeenCalledWith("mission-session-1", undefined);
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("What is the target scope?")).toBeInTheDocument();
|
||||
});
|
||||
expect(mockConnectMissionInterviewStream).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { PlanningQuestion } from "@fusion/core";
|
||||
import {
|
||||
startMissionInterview,
|
||||
respondToMissionInterview,
|
||||
retryMissionInterviewSession,
|
||||
cancelMissionInterview,
|
||||
createMissionFromInterview,
|
||||
connectMissionInterviewStream,
|
||||
@@ -36,6 +37,7 @@ import {
|
||||
Plus,
|
||||
Trash2,
|
||||
Minimize2,
|
||||
RefreshCw,
|
||||
} from "lucide-react";
|
||||
import { ConversationHistory } from "./ConversationHistory";
|
||||
|
||||
@@ -56,7 +58,8 @@ type ViewState =
|
||||
| { type: "initial" }
|
||||
| { type: "loading" }
|
||||
| { type: "question"; sessionId: string; question: PlanningQuestion }
|
||||
| { type: "summary"; sessionId: string; summary: MissionPlanSummary };
|
||||
| { type: "summary"; sessionId: string; summary: MissionPlanSummary }
|
||||
| { type: "error"; sessionId: string; errorMessage: string };
|
||||
|
||||
const EXAMPLE_MISSIONS = [
|
||||
"Build a real-time collaborative document editor",
|
||||
@@ -84,11 +87,61 @@ export function MissionInterviewModal({
|
||||
const [streamingOutput, setStreamingOutput] = useState("");
|
||||
const [showThinking, setShowThinking] = useState(true);
|
||||
const [isReconnecting, setIsReconnecting] = useState(false);
|
||||
const [isRetrying, setIsRetrying] = useState(false);
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const streamConnectionRef = useRef<{ close: () => void; isConnected: () => boolean } | null>(null);
|
||||
const currentSessionIdRef = useRef<string | null>(null);
|
||||
|
||||
const connectToMissionInterviewStream = useCallback(
|
||||
(sessionId: string) => {
|
||||
streamConnectionRef.current?.close();
|
||||
const connection = connectMissionInterviewStream(sessionId, projectId, {
|
||||
onThinking: (data) => {
|
||||
setStreamingOutput((prev) => prev + data);
|
||||
},
|
||||
onQuestion: (question) => {
|
||||
setIsReconnecting(false);
|
||||
setIsRetrying(false);
|
||||
clearMissionGoal(projectId);
|
||||
setView({ type: "question", sessionId, question });
|
||||
setStreamingOutput("");
|
||||
setHasProgress(true);
|
||||
},
|
||||
onSummary: (summary) => {
|
||||
setIsReconnecting(false);
|
||||
setIsRetrying(false);
|
||||
clearMissionGoal(projectId);
|
||||
setView({ type: "summary", sessionId, summary });
|
||||
setEditedSummary(summary);
|
||||
setStreamingOutput("");
|
||||
setHasProgress(true);
|
||||
},
|
||||
onError: (message) => {
|
||||
const errorMessage = message || "Session failed while contacting the AI.";
|
||||
setIsReconnecting(false);
|
||||
setIsRetrying(false);
|
||||
setError(null);
|
||||
setView({ type: "error", sessionId, errorMessage });
|
||||
setStreamingOutput("");
|
||||
setHasProgress(true);
|
||||
currentSessionIdRef.current = sessionId;
|
||||
},
|
||||
onComplete: () => {
|
||||
setIsReconnecting(false);
|
||||
setIsRetrying(false);
|
||||
currentSessionIdRef.current = null;
|
||||
},
|
||||
onConnectionStateChange: (state) => {
|
||||
setIsReconnecting(state === "reconnecting");
|
||||
},
|
||||
});
|
||||
|
||||
streamConnectionRef.current = connection;
|
||||
},
|
||||
[projectId],
|
||||
);
|
||||
|
||||
const handleStartInterview = useCallback(
|
||||
async (goalOverride?: string) => {
|
||||
const goal = goalOverride ?? missionGoal;
|
||||
@@ -106,42 +159,7 @@ export function MissionInterviewModal({
|
||||
currentSessionIdRef.current = sessionId;
|
||||
clearMissionGoal(projectId);
|
||||
|
||||
const connection = connectMissionInterviewStream(sessionId, projectId, {
|
||||
onThinking: (data) => {
|
||||
setStreamingOutput((prev) => prev + data);
|
||||
},
|
||||
onQuestion: (question) => {
|
||||
setIsReconnecting(false);
|
||||
clearMissionGoal(projectId);
|
||||
setView({ type: "question", sessionId, question });
|
||||
setStreamingOutput("");
|
||||
setHasProgress(true);
|
||||
},
|
||||
onSummary: (summary) => {
|
||||
setIsReconnecting(false);
|
||||
clearMissionGoal(projectId);
|
||||
setView({ type: "summary", sessionId, summary });
|
||||
setEditedSummary(summary);
|
||||
setStreamingOutput("");
|
||||
setHasProgress(true);
|
||||
},
|
||||
onError: (message) => {
|
||||
setIsReconnecting(false);
|
||||
setError(message);
|
||||
setView({ type: "initial" });
|
||||
setStreamingOutput("");
|
||||
currentSessionIdRef.current = null;
|
||||
},
|
||||
onComplete: () => {
|
||||
setIsReconnecting(false);
|
||||
currentSessionIdRef.current = null;
|
||||
},
|
||||
onConnectionStateChange: (state) => {
|
||||
setIsReconnecting(state === "reconnecting");
|
||||
},
|
||||
});
|
||||
|
||||
streamConnectionRef.current = connection;
|
||||
connectToMissionInterviewStream(sessionId);
|
||||
setResponseHistory([]);
|
||||
} catch (err: any) {
|
||||
setIsReconnecting(false);
|
||||
@@ -150,7 +168,7 @@ export function MissionInterviewModal({
|
||||
currentSessionIdRef.current = null;
|
||||
}
|
||||
},
|
||||
[missionGoal, projectId]
|
||||
[connectToMissionInterviewStream, missionGoal, projectId]
|
||||
);
|
||||
|
||||
// Focus textarea when opening
|
||||
@@ -182,6 +200,7 @@ export function MissionInterviewModal({
|
||||
if (!isOpen) {
|
||||
hasAutoStartedRef.current = false;
|
||||
setIsReconnecting(false);
|
||||
setIsRetrying(false);
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
@@ -232,43 +251,16 @@ export function MissionInterviewModal({
|
||||
setStreamingOutput(session.thinkingOutput);
|
||||
}
|
||||
setView({ type: "loading" });
|
||||
|
||||
const connection = connectMissionInterviewStream(session.id, projectId, {
|
||||
onThinking: (data) => {
|
||||
setStreamingOutput((prev) => prev + data);
|
||||
},
|
||||
onQuestion: (question) => {
|
||||
setIsReconnecting(false);
|
||||
clearMissionGoal(projectId);
|
||||
setView({ type: "question", sessionId: session.id, question });
|
||||
setStreamingOutput("");
|
||||
},
|
||||
onSummary: (summary) => {
|
||||
setIsReconnecting(false);
|
||||
clearMissionGoal(projectId);
|
||||
setView({ type: "summary", sessionId: session.id, summary });
|
||||
setEditedSummary(summary);
|
||||
setStreamingOutput("");
|
||||
},
|
||||
onError: (message) => {
|
||||
setIsReconnecting(false);
|
||||
setError(message);
|
||||
setView({ type: "initial" });
|
||||
setStreamingOutput("");
|
||||
currentSessionIdRef.current = null;
|
||||
},
|
||||
onComplete: () => {
|
||||
setIsReconnecting(false);
|
||||
currentSessionIdRef.current = null;
|
||||
},
|
||||
onConnectionStateChange: (state) => {
|
||||
setIsReconnecting(state === "reconnecting");
|
||||
},
|
||||
});
|
||||
|
||||
streamConnectionRef.current = connection;
|
||||
connectToMissionInterviewStream(session.id);
|
||||
} else if (session.status === "error") {
|
||||
setError(session.error ?? "The session encountered an error.");
|
||||
currentSessionIdRef.current = session.id;
|
||||
setHasProgress(true);
|
||||
setError(null);
|
||||
setView({
|
||||
type: "error",
|
||||
sessionId: session.id,
|
||||
errorMessage: session.error ?? "The session encountered an error.",
|
||||
});
|
||||
}
|
||||
}).catch(() => {
|
||||
if (!cancelled) setError("Failed to resume session.");
|
||||
@@ -277,7 +269,7 @@ export function MissionInterviewModal({
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [isOpen, resumeSessionId, view.type, projectId]);
|
||||
}, [connectToMissionInterviewStream, isOpen, resumeSessionId, view.type, projectId]);
|
||||
|
||||
// Cleanup stream on unmount
|
||||
useEffect(() => {
|
||||
@@ -324,7 +316,7 @@ export function MissionInterviewModal({
|
||||
streamConnectionRef.current?.close();
|
||||
streamConnectionRef.current = null;
|
||||
|
||||
if (view.type === "question" || view.type === "summary") {
|
||||
if (view.type === "question" || view.type === "summary" || view.type === "error") {
|
||||
try {
|
||||
await cancelMissionInterview(view.sessionId, projectId);
|
||||
} catch {
|
||||
@@ -340,6 +332,7 @@ export function MissionInterviewModal({
|
||||
setEditedSummary(null);
|
||||
setStreamingOutput("");
|
||||
setIsReconnecting(false);
|
||||
setIsRetrying(false);
|
||||
setHasProgress(false);
|
||||
setIsCreating(false);
|
||||
currentSessionIdRef.current = null;
|
||||
@@ -394,6 +387,35 @@ export function MissionInterviewModal({
|
||||
[view, projectId]
|
||||
);
|
||||
|
||||
const handleRetryFromError = useCallback(async () => {
|
||||
if (view.type !== "error") {
|
||||
return;
|
||||
}
|
||||
|
||||
const retrySessionId = view.sessionId;
|
||||
setError(null);
|
||||
setIsRetrying(true);
|
||||
setStreamingOutput("");
|
||||
setView({ type: "loading" });
|
||||
connectToMissionInterviewStream(retrySessionId);
|
||||
|
||||
try {
|
||||
currentSessionIdRef.current = retrySessionId;
|
||||
await retryMissionInterviewSession(retrySessionId, projectId);
|
||||
} catch (err: any) {
|
||||
streamConnectionRef.current?.close();
|
||||
streamConnectionRef.current = null;
|
||||
setView({
|
||||
type: "error",
|
||||
sessionId: retrySessionId,
|
||||
errorMessage: err?.message || "Retry failed. Please try again.",
|
||||
});
|
||||
setIsReconnecting(false);
|
||||
} finally {
|
||||
setIsRetrying(false);
|
||||
}
|
||||
}, [connectToMissionInterviewStream, projectId, view]);
|
||||
|
||||
const handleApprovePlan = useCallback(async () => {
|
||||
if (view.type !== "summary") return;
|
||||
|
||||
@@ -415,6 +437,7 @@ export function MissionInterviewModal({
|
||||
setEditedSummary(null);
|
||||
setStreamingOutput("");
|
||||
setIsReconnecting(false);
|
||||
setIsRetrying(false);
|
||||
setHasProgress(false);
|
||||
setIsCreating(false);
|
||||
currentSessionIdRef.current = null;
|
||||
@@ -433,7 +456,7 @@ export function MissionInterviewModal({
|
||||
};
|
||||
|
||||
const showSendToBackgroundButton =
|
||||
view.type === "loading" || view.type === "question" || view.type === "summary";
|
||||
view.type === "loading" || view.type === "question" || view.type === "summary" || view.type === "error";
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
@@ -548,6 +571,42 @@ export function MissionInterviewModal({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{view.type === "error" && (
|
||||
<div className="planning-summary">
|
||||
<div className="planning-view-scroll planning-summary-scroll">
|
||||
{conversationHistory.length > 0 && (
|
||||
<>
|
||||
<ConversationHistory entries={conversationHistory} />
|
||||
<div className="conversation-separator" />
|
||||
</>
|
||||
)}
|
||||
|
||||
<div
|
||||
className="ai-error-panel"
|
||||
role="alert"
|
||||
style={{
|
||||
border: "1px solid var(--color-error, #dc2626)",
|
||||
borderRadius: "10px",
|
||||
background: "color-mix(in srgb, var(--color-error, #dc2626) 10%, transparent)",
|
||||
padding: "14px",
|
||||
display: "grid",
|
||||
gap: "10px",
|
||||
}}
|
||||
>
|
||||
<div className="ai-error-icon" style={{ fontSize: "20px" }}>⚠️</div>
|
||||
<div className="ai-error-message">{view.errorMessage}</div>
|
||||
<div className="ai-error-actions" style={{ display: "flex", gap: "8px" }}>
|
||||
<button className="btn btn-primary" onClick={() => void handleRetryFromError()} disabled={isRetrying}>
|
||||
{isRetrying ? <Loader2 size={14} className="spin" /> : <RefreshCw size={14} />}
|
||||
<span style={{ marginLeft: "6px" }}>{isRetrying ? "Retrying..." : "Retry"}</span>
|
||||
</button>
|
||||
<button className="btn" onClick={handleCancel} disabled={isRetrying}>Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{view.type === "question" && (
|
||||
<InterviewQuestionForm
|
||||
question={view.question}
|
||||
|
||||
@@ -9,6 +9,7 @@ const mockStartPlanning = vi.fn();
|
||||
const mockStartPlanningStreaming = vi.fn();
|
||||
const mockConnectPlanningStream = vi.fn();
|
||||
const mockRespondToPlanning = vi.fn();
|
||||
const mockRetryPlanningSession = vi.fn();
|
||||
const mockCancelPlanning = vi.fn();
|
||||
const mockCreateTaskFromPlanning = vi.fn();
|
||||
const mockStartPlanningBreakdown = vi.fn();
|
||||
@@ -32,6 +33,7 @@ vi.mock("../api", () => ({
|
||||
startPlanningStreaming: (...args: any[]) => mockStartPlanningStreaming(...args),
|
||||
connectPlanningStream: (...args: any[]) => mockConnectPlanningStream(...args),
|
||||
respondToPlanning: (...args: any[]) => mockRespondToPlanning(...args),
|
||||
retryPlanningSession: (...args: any[]) => mockRetryPlanningSession(...args),
|
||||
cancelPlanning: (...args: any[]) => mockCancelPlanning(...args),
|
||||
createTaskFromPlanning: (...args: any[]) => mockCreateTaskFromPlanning(...args),
|
||||
startPlanningBreakdown: (...args: any[]) => mockStartPlanningBreakdown(...args),
|
||||
@@ -134,6 +136,7 @@ describe("PlanningModeModal", () => {
|
||||
|
||||
// Default mock for streaming
|
||||
mockStartPlanningStreaming.mockResolvedValue({ sessionId: "session-123" });
|
||||
mockRetryPlanningSession.mockResolvedValue({ success: true, sessionId: "session-123" });
|
||||
mockStartPlanningBreakdown.mockResolvedValue({ sessionId: "session-123", subtasks: [] });
|
||||
mockFetchAiSession.mockResolvedValue(null);
|
||||
mockParseConversationHistory.mockImplementation((raw: string) => {
|
||||
@@ -428,6 +431,53 @@ describe("PlanningModeModal", () => {
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Rate limit exceeded")).toBeDefined();
|
||||
});
|
||||
expect(screen.getByRole("button", { name: "Retry" })).toBeDefined();
|
||||
});
|
||||
|
||||
it("retries from error state and reconnects stream", async () => {
|
||||
let streamAttempt = 0;
|
||||
mockConnectPlanningStream.mockImplementation((_sessionId: string, _projectId: string | undefined, handlers: any) => {
|
||||
streamAttempt += 1;
|
||||
if (streamAttempt === 1) {
|
||||
setTimeout(() => handlers.onError?.("Temporary failure"), 10);
|
||||
} else {
|
||||
setTimeout(() => handlers.onQuestion?.(mockQuestion), 10);
|
||||
}
|
||||
|
||||
return {
|
||||
close: vi.fn(),
|
||||
isConnected: vi.fn().mockReturnValue(true),
|
||||
};
|
||||
});
|
||||
|
||||
render(
|
||||
<PlanningModeModal
|
||||
isOpen={true}
|
||||
onClose={mockOnClose}
|
||||
onTaskCreated={mockOnTaskCreated}
|
||||
onTasksCreated={vi.fn()}
|
||||
tasks={mockTasks}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText(/e.g., Build a user authentication/), {
|
||||
target: { value: "Build auth system" },
|
||||
});
|
||||
fireEvent.click(screen.getByText("Start Planning"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Temporary failure")).toBeDefined();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Retry" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockRetryPlanningSession).toHaveBeenCalledWith("session-123", undefined);
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("What is the scope?")).toBeDefined();
|
||||
});
|
||||
expect(mockConnectPlanningStream).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -482,6 +532,40 @@ describe("PlanningModeModal", () => {
|
||||
expect(screen.getByText("Deliverable B")).toBeDefined();
|
||||
});
|
||||
|
||||
it("shows retry panel when resuming an errored session", async () => {
|
||||
mockFetchAiSession.mockResolvedValueOnce({
|
||||
id: "session-error-1",
|
||||
type: "planning",
|
||||
status: "error",
|
||||
title: "Errored planning",
|
||||
inputPayload: JSON.stringify({ initialPlan: "Recover planning" }),
|
||||
conversationHistory: "[]",
|
||||
currentQuestion: null,
|
||||
result: null,
|
||||
thinkingOutput: "",
|
||||
error: "Session interrupted",
|
||||
projectId: null,
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||
});
|
||||
|
||||
render(
|
||||
<PlanningModeModal
|
||||
isOpen={true}
|
||||
onClose={mockOnClose}
|
||||
onTaskCreated={mockOnTaskCreated}
|
||||
onTasksCreated={vi.fn()}
|
||||
tasks={mockTasks}
|
||||
resumeSessionId="session-error-1"
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Session interrupted")).toBeDefined();
|
||||
});
|
||||
expect(screen.getByRole("button", { name: "Retry" })).toBeDefined();
|
||||
});
|
||||
|
||||
it("creates a task from a resumed complete session", async () => {
|
||||
const resumedSummary: PlanningSummary = {
|
||||
title: "Resume-to-task",
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { Task, PlanningQuestion, PlanningSummary } from "@fusion/core";
|
||||
import {
|
||||
startPlanningStreaming,
|
||||
respondToPlanning,
|
||||
retryPlanningSession,
|
||||
createTaskFromPlanning,
|
||||
connectPlanningStream,
|
||||
fetchAiSession,
|
||||
@@ -20,7 +21,7 @@ import {
|
||||
getPlanningDescription,
|
||||
clearPlanningDescription,
|
||||
} from "../hooks/modalPersistence";
|
||||
import { Lightbulb, X, Loader2, CheckCircle, ArrowLeft, ArrowRight, Sparkles, ListTree, GripVertical, ArrowUp, ArrowDown, Plus, Trash2, Minimize2 } from "lucide-react";
|
||||
import { Lightbulb, X, Loader2, CheckCircle, ArrowLeft, ArrowRight, Sparkles, ListTree, GripVertical, ArrowUp, ArrowDown, Plus, Trash2, Minimize2, RefreshCw } from "lucide-react";
|
||||
import { CustomModelDropdown } from "./CustomModelDropdown";
|
||||
import { ConversationHistory } from "./ConversationHistory";
|
||||
|
||||
@@ -44,6 +45,7 @@ type ViewState =
|
||||
| { type: "initial" }
|
||||
| { type: "question"; session: PlanningSession }
|
||||
| { type: "summary"; session: PlanningSession; summary: PlanningSummary }
|
||||
| { type: "error"; session: PlanningSession; errorMessage: string }
|
||||
| { type: "breakdown"; sessionId: string; subtasks: SubtaskItem[]; dirty: boolean }
|
||||
| { type: "loading" }
|
||||
| { type: "creating" };
|
||||
@@ -90,6 +92,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
|
||||
const [streamingOutput, setStreamingOutput] = useState<string>("");
|
||||
const [showThinking, setShowThinking] = useState(true);
|
||||
const [isReconnecting, setIsReconnecting] = useState(false);
|
||||
const [isRetrying, setIsRetrying] = useState(false);
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const streamConnectionRef = useRef<{ close: () => void; isConnected: () => boolean } | null>(null);
|
||||
const currentSessionIdRef = useRef<string | null>(null);
|
||||
@@ -140,6 +143,68 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
|
||||
);
|
||||
}, []);
|
||||
|
||||
const connectToPlanningStream = useCallback(
|
||||
(sessionId: string) => {
|
||||
streamConnectionRef.current?.close();
|
||||
const connection = connectPlanningStream(sessionId, projectId, {
|
||||
onThinking: (data) => {
|
||||
setStreamingOutput((prev) => prev + data);
|
||||
},
|
||||
onQuestion: (question) => {
|
||||
setIsReconnecting(false);
|
||||
setIsRetrying(false);
|
||||
clearPlanningDescription(projectId);
|
||||
setView({
|
||||
type: "question",
|
||||
session: { sessionId, currentQuestion: question, summary: null },
|
||||
});
|
||||
setStreamingOutput("");
|
||||
},
|
||||
onSummary: (summary) => {
|
||||
setIsReconnecting(false);
|
||||
setIsRetrying(false);
|
||||
clearPlanningDescription(projectId);
|
||||
setView({
|
||||
type: "summary",
|
||||
session: { sessionId, currentQuestion: null, summary },
|
||||
summary,
|
||||
});
|
||||
setEditedSummary(summary);
|
||||
setStreamingOutput("");
|
||||
},
|
||||
onError: (message) => {
|
||||
const errorMessage = message || "Session failed while contacting the AI.";
|
||||
setIsReconnecting(false);
|
||||
setIsRetrying(false);
|
||||
setError(null);
|
||||
setView((prev) => {
|
||||
if (prev.type === "question" || prev.type === "summary" || prev.type === "error") {
|
||||
return { type: "error", session: prev.session, errorMessage };
|
||||
}
|
||||
return {
|
||||
type: "error",
|
||||
session: { sessionId, currentQuestion: null, summary: null },
|
||||
errorMessage,
|
||||
};
|
||||
});
|
||||
setStreamingOutput("");
|
||||
currentSessionIdRef.current = sessionId;
|
||||
},
|
||||
onComplete: () => {
|
||||
setIsReconnecting(false);
|
||||
setIsRetrying(false);
|
||||
currentSessionIdRef.current = null;
|
||||
},
|
||||
onConnectionStateChange: (state) => {
|
||||
setIsReconnecting(state === "reconnecting");
|
||||
},
|
||||
});
|
||||
|
||||
streamConnectionRef.current = connection;
|
||||
},
|
||||
[projectId],
|
||||
);
|
||||
|
||||
const handleStartPlanning = useCallback(async (planOverride?: string) => {
|
||||
const plan = planOverride ?? initialPlan;
|
||||
if (!plan.trim()) return;
|
||||
@@ -161,48 +226,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
|
||||
const { sessionId } = await startPlanningStreaming(plan.trim(), projectId, modelOverride);
|
||||
currentSessionIdRef.current = sessionId;
|
||||
|
||||
// Connect to SSE stream
|
||||
const connection = connectPlanningStream(sessionId, projectId, {
|
||||
onThinking: (data) => {
|
||||
setStreamingOutput((prev) => prev + data);
|
||||
},
|
||||
onQuestion: (question) => {
|
||||
setIsReconnecting(false);
|
||||
clearPlanningDescription(projectId);
|
||||
setView({
|
||||
type: "question",
|
||||
session: { sessionId, currentQuestion: question, summary: null },
|
||||
});
|
||||
setStreamingOutput("");
|
||||
},
|
||||
onSummary: (summary) => {
|
||||
setIsReconnecting(false);
|
||||
clearPlanningDescription(projectId);
|
||||
setView({
|
||||
type: "summary",
|
||||
session: { sessionId, currentQuestion: null, summary },
|
||||
summary,
|
||||
});
|
||||
setEditedSummary(summary);
|
||||
setStreamingOutput("");
|
||||
},
|
||||
onError: (message) => {
|
||||
setIsReconnecting(false);
|
||||
setError(message);
|
||||
setView({ type: "initial" });
|
||||
setStreamingOutput("");
|
||||
currentSessionIdRef.current = null;
|
||||
},
|
||||
onComplete: () => {
|
||||
setIsReconnecting(false);
|
||||
currentSessionIdRef.current = null;
|
||||
},
|
||||
onConnectionStateChange: (state) => {
|
||||
setIsReconnecting(state === "reconnecting");
|
||||
},
|
||||
});
|
||||
|
||||
streamConnectionRef.current = connection;
|
||||
connectToPlanningStream(sessionId);
|
||||
setResponseHistory([]);
|
||||
} catch (err: any) {
|
||||
setIsReconnecting(false);
|
||||
@@ -210,7 +234,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
|
||||
setView({ type: "initial" });
|
||||
currentSessionIdRef.current = null;
|
||||
}
|
||||
}, [initialPlan, planningModelId, planningModelProvider, projectId]);
|
||||
}, [connectToPlanningStream, initialPlan, planningModelId, planningModelProvider, projectId]);
|
||||
|
||||
// Focus textarea when opening
|
||||
useEffect(() => {
|
||||
@@ -279,52 +303,28 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
|
||||
} else if (session.status === "generating") {
|
||||
setView({ type: "loading" });
|
||||
if (session.thinkingOutput) setStreamingOutput(session.thinkingOutput);
|
||||
// Connect to live SSE stream to pick up new events
|
||||
const connection = connectPlanningStream(resumeSessionId, projectId, {
|
||||
onThinking: (data) => setStreamingOutput((prev) => prev + data),
|
||||
onQuestion: (question) => {
|
||||
setIsReconnecting(false);
|
||||
clearPlanningDescription(projectId);
|
||||
setView({ type: "question", session: { sessionId: resumeSessionId, currentQuestion: question, summary: null } });
|
||||
setStreamingOutput("");
|
||||
},
|
||||
onSummary: (summary) => {
|
||||
setIsReconnecting(false);
|
||||
clearPlanningDescription(projectId);
|
||||
setView({ type: "summary", session: { sessionId: resumeSessionId, currentQuestion: null, summary }, summary });
|
||||
setEditedSummary(summary);
|
||||
setStreamingOutput("");
|
||||
},
|
||||
onError: (message) => {
|
||||
setIsReconnecting(false);
|
||||
setError(message);
|
||||
setView({ type: "initial" });
|
||||
},
|
||||
onComplete: () => {
|
||||
setIsReconnecting(false);
|
||||
currentSessionIdRef.current = null;
|
||||
},
|
||||
onConnectionStateChange: (state) => {
|
||||
setIsReconnecting(state === "reconnecting");
|
||||
},
|
||||
});
|
||||
streamConnectionRef.current = connection;
|
||||
connectToPlanningStream(resumeSessionId);
|
||||
} else if (session.status === "error") {
|
||||
setError(session.error || "Session failed");
|
||||
setView({ type: "initial" });
|
||||
setError(null);
|
||||
setView({
|
||||
type: "error",
|
||||
session: { sessionId: resumeSessionId, currentQuestion: null, summary: null },
|
||||
errorMessage: session.error || "Session failed",
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
setError("Failed to resume session");
|
||||
}
|
||||
})();
|
||||
return () => { cancelled = true; };
|
||||
}, [isOpen, resumeSessionId, view.type, projectId]);
|
||||
}, [connectToPlanningStream, isOpen, resumeSessionId, view.type, projectId]);
|
||||
|
||||
// Reset hasAutoStarted when modal closes
|
||||
useEffect(() => {
|
||||
if (!isOpen) {
|
||||
hasAutoStartedRef.current = false;
|
||||
setIsReconnecting(false);
|
||||
setIsRetrying(false);
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
@@ -374,6 +374,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
|
||||
setEditedSummary(null);
|
||||
setStreamingOutput("");
|
||||
setIsReconnecting(false);
|
||||
setIsRetrying(false);
|
||||
setPlanningModelProvider(undefined);
|
||||
setPlanningModelId(undefined);
|
||||
currentSessionIdRef.current = null;
|
||||
@@ -437,6 +438,36 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
|
||||
[projectId, view]
|
||||
);
|
||||
|
||||
const handleRetryFromError = useCallback(async () => {
|
||||
if (view.type !== "error") {
|
||||
return;
|
||||
}
|
||||
|
||||
const retryTarget = view.session;
|
||||
setError(null);
|
||||
setIsRetrying(true);
|
||||
setStreamingOutput("");
|
||||
setView({ type: "loading" });
|
||||
|
||||
connectToPlanningStream(retryTarget.sessionId);
|
||||
|
||||
try {
|
||||
currentSessionIdRef.current = retryTarget.sessionId;
|
||||
await retryPlanningSession(retryTarget.sessionId, projectId);
|
||||
} catch (err: any) {
|
||||
streamConnectionRef.current?.close();
|
||||
streamConnectionRef.current = null;
|
||||
setView({
|
||||
type: "error",
|
||||
session: retryTarget,
|
||||
errorMessage: err?.message || "Retry failed. Please try again.",
|
||||
});
|
||||
setIsReconnecting(false);
|
||||
} finally {
|
||||
setIsRetrying(false);
|
||||
}
|
||||
}, [connectToPlanningStream, projectId, view]);
|
||||
|
||||
const handleCreateTask = useCallback(async () => {
|
||||
if (view.type !== "summary") return;
|
||||
|
||||
@@ -519,7 +550,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
|
||||
};
|
||||
|
||||
const showSendToBackgroundButton =
|
||||
view.type === "loading" || view.type === "question" || view.type === "summary";
|
||||
view.type === "loading" || view.type === "question" || view.type === "summary" || view.type === "error";
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
@@ -701,6 +732,42 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
|
||||
</div>
|
||||
)}
|
||||
|
||||
{view.type === "error" && (
|
||||
<div className="planning-summary">
|
||||
<div className="planning-view-scroll planning-summary-scroll">
|
||||
{conversationHistory.length > 0 && (
|
||||
<>
|
||||
<ConversationHistory entries={conversationHistory} />
|
||||
<div className="conversation-separator" />
|
||||
</>
|
||||
)}
|
||||
|
||||
<div
|
||||
className="ai-error-panel"
|
||||
role="alert"
|
||||
style={{
|
||||
border: "1px solid var(--color-error, #dc2626)",
|
||||
borderRadius: "10px",
|
||||
background: "color-mix(in srgb, var(--color-error, #dc2626) 10%, transparent)",
|
||||
padding: "14px",
|
||||
display: "grid",
|
||||
gap: "10px",
|
||||
}}
|
||||
>
|
||||
<div className="ai-error-icon" style={{ fontSize: "20px" }}>⚠️</div>
|
||||
<div className="ai-error-message">{view.errorMessage}</div>
|
||||
<div className="ai-error-actions" style={{ display: "flex", gap: "8px" }}>
|
||||
<button className="btn btn-primary" onClick={() => void handleRetryFromError()} disabled={isRetrying}>
|
||||
{isRetrying ? <Loader2 size={14} className="spin" /> : <RefreshCw size={14} />}
|
||||
<span style={{ marginLeft: "6px" }}>{isRetrying ? "Retrying..." : "Retry"}</span>
|
||||
</button>
|
||||
<button className="btn" onClick={handleCancel} disabled={isRetrying}>Dismiss</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{view.type === "creating" && (
|
||||
<div className="planning-loading">
|
||||
<Loader2 size={40} className="spin" style={{ color: "var(--todo)" }} />
|
||||
|
||||
@@ -3,12 +3,14 @@ import { act, render, screen, fireEvent, waitFor } from "@testing-library/react"
|
||||
import { SubtaskBreakdownModal } from "./SubtaskBreakdownModal";
|
||||
|
||||
const mockStartSubtaskBreakdown = vi.fn();
|
||||
const mockRetrySubtaskSession = vi.fn();
|
||||
const mockConnectSubtaskStream = vi.fn();
|
||||
const mockCreateTasksFromBreakdown = vi.fn();
|
||||
const mockCancelSubtaskBreakdown = vi.fn();
|
||||
|
||||
vi.mock("../api", () => ({
|
||||
startSubtaskBreakdown: (...args: any[]) => mockStartSubtaskBreakdown(...args),
|
||||
retrySubtaskSession: (...args: any[]) => mockRetrySubtaskSession(...args),
|
||||
connectSubtaskStream: (...args: any[]) => mockConnectSubtaskStream(...args),
|
||||
createTasksFromBreakdown: (...args: any[]) => mockCreateTasksFromBreakdown(...args),
|
||||
cancelSubtaskBreakdown: (...args: any[]) => mockCancelSubtaskBreakdown(...args),
|
||||
@@ -40,6 +42,7 @@ describe("SubtaskBreakdownModal", () => {
|
||||
vi.clearAllMocks();
|
||||
streamHandlers = undefined;
|
||||
mockStartSubtaskBreakdown.mockResolvedValue({ sessionId: "session-123" });
|
||||
mockRetrySubtaskSession.mockResolvedValue({ success: true, sessionId: "session-123" });
|
||||
mockConnectSubtaskStream.mockImplementation((_sessionId, _projectId, handlers) => {
|
||||
streamHandlers = handlers;
|
||||
return { close: vi.fn(), isConnected: () => true };
|
||||
@@ -483,6 +486,20 @@ describe("SubtaskBreakdownModal", () => {
|
||||
expect(await screen.findByText("Something went wrong")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("retries after an error and reconnects stream", async () => {
|
||||
renderModal();
|
||||
await waitFor(() => expect(streamHandlers).toBeDefined());
|
||||
streamHandlers.onError("Something went wrong");
|
||||
|
||||
const retryButton = await screen.findByRole("button", { name: "Retry" });
|
||||
fireEvent.click(retryButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockRetrySubtaskSession).toHaveBeenCalledWith("session-123", undefined);
|
||||
});
|
||||
expect(mockConnectSubtaskStream).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("shows Stream error fallback when receiving empty error", async () => {
|
||||
renderModal();
|
||||
await waitFor(() => expect(streamHandlers).toBeDefined());
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import type { Task } from "@fusion/core";
|
||||
import {
|
||||
startSubtaskBreakdown,
|
||||
retrySubtaskSession,
|
||||
connectSubtaskStream,
|
||||
createTasksFromBreakdown,
|
||||
cancelSubtaskBreakdown,
|
||||
@@ -15,7 +16,7 @@ import {
|
||||
getSubtaskDescription,
|
||||
clearSubtaskDescription,
|
||||
} from "../hooks/modalPersistence";
|
||||
import { CheckCircle, Loader2, ListTree, Plus, Trash2, X, GripVertical, ArrowUp, ArrowDown, Minimize2 } from "lucide-react";
|
||||
import { CheckCircle, Loader2, ListTree, Plus, Trash2, X, GripVertical, ArrowUp, ArrowDown, Minimize2, RefreshCw } from "lucide-react";
|
||||
import { ConversationHistory } from "./ConversationHistory";
|
||||
|
||||
interface SubtaskBreakdownModalProps {
|
||||
@@ -32,6 +33,7 @@ type ViewState =
|
||||
| { type: "initial" }
|
||||
| { type: "generating"; sessionId: string }
|
||||
| { type: "editing"; sessionId: string }
|
||||
| { type: "error"; sessionId: string; errorMessage: string }
|
||||
| { type: "creating"; sessionId: string };
|
||||
|
||||
function createEmptySubtask(index: number): SubtaskItem {
|
||||
@@ -71,6 +73,7 @@ export function SubtaskBreakdownModal({ isOpen, onClose, initialDescription, onT
|
||||
const [thinkingOutput, setThinkingOutput] = useState("");
|
||||
const [showThinking, setShowThinking] = useState(true);
|
||||
const [isReconnecting, setIsReconnecting] = useState(false);
|
||||
const [isRetrying, setIsRetrying] = useState(false);
|
||||
// Local description: synced from prop, can fall back to localStorage
|
||||
const [localDescription, setLocalDescription] = useState(initialDescription);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@@ -85,7 +88,7 @@ export function SubtaskBreakdownModal({ isOpen, onClose, initialDescription, onT
|
||||
const titleRefs = useRef<Array<HTMLInputElement | null>>([]);
|
||||
const autoStartedRef = useRef(false);
|
||||
|
||||
const sessionId = view.type === "generating" || view.type === "editing" || view.type === "creating"
|
||||
const sessionId = view.type === "generating" || view.type === "editing" || view.type === "creating" || view.type === "error"
|
||||
? view.sessionId
|
||||
: null;
|
||||
|
||||
@@ -95,7 +98,7 @@ export function SubtaskBreakdownModal({ isOpen, onClose, initialDescription, onT
|
||||
return hasDependencyCycle(subtasks);
|
||||
}, [subtasks]);
|
||||
|
||||
const showSendToBackgroundButton = view.type === "generating" || view.type === "editing";
|
||||
const showSendToBackgroundButton = view.type === "generating" || view.type === "editing" || view.type === "error";
|
||||
|
||||
const resetState = useCallback(() => {
|
||||
// Save to localStorage before cleanup (preserve for re-entry)
|
||||
@@ -110,6 +113,7 @@ export function SubtaskBreakdownModal({ isOpen, onClose, initialDescription, onT
|
||||
setThinkingOutput("");
|
||||
setShowThinking(true);
|
||||
setIsReconnecting(false);
|
||||
setIsRetrying(false);
|
||||
setError(null);
|
||||
setDirty(false);
|
||||
autoStartedRef.current = false;
|
||||
@@ -136,6 +140,34 @@ export function SubtaskBreakdownModal({ isOpen, onClose, initialDescription, onT
|
||||
onClose();
|
||||
}, [dirty, onClose, resetState, sessionId, view.type, projectId]);
|
||||
|
||||
const connectToSubtaskStream = useCallback(
|
||||
(activeSessionId: string) => {
|
||||
streamRef.current?.close();
|
||||
streamRef.current = connectSubtaskStream(activeSessionId, projectId, {
|
||||
onThinking: (data) => setThinkingOutput((prev) => prev + data),
|
||||
onSubtasks: (items) => {
|
||||
setIsReconnecting(false);
|
||||
setIsRetrying(false);
|
||||
clearSubtaskDescription(projectId);
|
||||
setSubtasks(items);
|
||||
setView({ type: "editing", sessionId: activeSessionId });
|
||||
setDirty(false);
|
||||
},
|
||||
onError: (message) => {
|
||||
const errorMessage = message || "Session failed while contacting the AI.";
|
||||
setIsReconnecting(false);
|
||||
setIsRetrying(false);
|
||||
setError(null);
|
||||
setView({ type: "error", sessionId: activeSessionId, errorMessage });
|
||||
},
|
||||
onConnectionStateChange: (state) => {
|
||||
setIsReconnecting(state === "reconnecting");
|
||||
},
|
||||
});
|
||||
},
|
||||
[projectId],
|
||||
);
|
||||
|
||||
const beginBreakdown = useCallback(async () => {
|
||||
if (!localDescription.trim()) return;
|
||||
setError(null);
|
||||
@@ -146,30 +178,12 @@ export function SubtaskBreakdownModal({ isOpen, onClose, initialDescription, onT
|
||||
try {
|
||||
const { sessionId } = await startSubtaskBreakdown(localDescription.trim(), projectId);
|
||||
setView({ type: "generating", sessionId });
|
||||
streamRef.current?.close();
|
||||
streamRef.current = connectSubtaskStream(sessionId, projectId, {
|
||||
onThinking: (data) => setThinkingOutput((prev) => prev + data),
|
||||
onSubtasks: (items) => {
|
||||
setIsReconnecting(false);
|
||||
clearSubtaskDescription(projectId);
|
||||
setSubtasks(items);
|
||||
setView({ type: "editing", sessionId });
|
||||
setDirty(false);
|
||||
},
|
||||
onError: (message) => {
|
||||
setIsReconnecting(false);
|
||||
setError(message);
|
||||
setView({ type: "initial" });
|
||||
},
|
||||
onConnectionStateChange: (state) => {
|
||||
setIsReconnecting(state === "reconnecting");
|
||||
},
|
||||
});
|
||||
connectToSubtaskStream(sessionId);
|
||||
} catch (err: any) {
|
||||
setError(err.message || "Failed to start subtask breakdown");
|
||||
setView({ type: "initial" });
|
||||
}
|
||||
}, [localDescription, projectId]);
|
||||
}, [connectToSubtaskStream, localDescription, projectId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) {
|
||||
@@ -204,38 +218,25 @@ export function SubtaskBreakdownModal({ isOpen, onClose, initialDescription, onT
|
||||
if (session.status === "generating" || session.status === "awaiting_input") {
|
||||
setThinkingOutput(session.thinkingOutput ?? "");
|
||||
setView({ type: "generating", sessionId: resumeSessionId });
|
||||
streamRef.current?.close();
|
||||
streamRef.current = connectSubtaskStream(resumeSessionId, projectId, {
|
||||
onThinking: (data) => setThinkingOutput((prev) => prev + data),
|
||||
onSubtasks: (items) => {
|
||||
setIsReconnecting(false);
|
||||
clearSubtaskDescription(projectId);
|
||||
setSubtasks(items);
|
||||
setView({ type: "editing", sessionId: resumeSessionId });
|
||||
setDirty(false);
|
||||
},
|
||||
onError: (message) => {
|
||||
setIsReconnecting(false);
|
||||
setError(message);
|
||||
setView({ type: "initial" });
|
||||
},
|
||||
onConnectionStateChange: (state) => {
|
||||
setIsReconnecting(state === "reconnecting");
|
||||
},
|
||||
});
|
||||
connectToSubtaskStream(resumeSessionId);
|
||||
} else if (session.status === "complete" && session.result) {
|
||||
clearSubtaskDescription(projectId);
|
||||
const items = JSON.parse(session.result) as SubtaskItem[];
|
||||
setSubtasks(items);
|
||||
setView({ type: "editing", sessionId: resumeSessionId });
|
||||
} else if (session.status === "error") {
|
||||
setError(session.error ?? "Session encountered an error");
|
||||
setError(null);
|
||||
setView({
|
||||
type: "error",
|
||||
sessionId: resumeSessionId,
|
||||
errorMessage: session.error ?? "Session encountered an error",
|
||||
});
|
||||
}
|
||||
} catch (err: any) {
|
||||
setError(err.message || "Failed to resume session");
|
||||
}
|
||||
})();
|
||||
}, [isOpen, resumeSessionId, view.type, projectId]);
|
||||
}, [connectToSubtaskStream, isOpen, resumeSessionId, view.type, projectId]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
@@ -375,6 +376,34 @@ export function SubtaskBreakdownModal({ isOpen, onClose, initialDescription, onT
|
||||
}
|
||||
}, [isInvalid, onClose, onTasksCreated, parentTaskId, projectId, resetState, sessionId, subtasks]);
|
||||
|
||||
const handleRetry = useCallback(async () => {
|
||||
if (view.type !== "error") {
|
||||
return;
|
||||
}
|
||||
|
||||
const retrySessionId = view.sessionId;
|
||||
setError(null);
|
||||
setIsRetrying(true);
|
||||
setThinkingOutput("");
|
||||
setView({ type: "generating", sessionId: retrySessionId });
|
||||
connectToSubtaskStream(retrySessionId);
|
||||
|
||||
try {
|
||||
await retrySubtaskSession(retrySessionId, projectId);
|
||||
} catch (err: any) {
|
||||
streamRef.current?.close();
|
||||
streamRef.current = null;
|
||||
setView({
|
||||
type: "error",
|
||||
sessionId: retrySessionId,
|
||||
errorMessage: err?.message || "Retry failed. Please try again.",
|
||||
});
|
||||
setIsReconnecting(false);
|
||||
} finally {
|
||||
setIsRetrying(false);
|
||||
}
|
||||
}, [connectToSubtaskStream, projectId, view]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
@@ -438,6 +467,41 @@ export function SubtaskBreakdownModal({ isOpen, onClose, initialDescription, onT
|
||||
</div>
|
||||
)}
|
||||
|
||||
{view.type === "error" && (
|
||||
<div className="planning-summary">
|
||||
<div className="planning-view-scroll planning-summary-scroll">
|
||||
{conversationHistory.length > 0 && (
|
||||
<>
|
||||
<ConversationHistory entries={conversationHistory} defaultShowThinking={true} />
|
||||
<div className="conversation-separator" />
|
||||
</>
|
||||
)}
|
||||
<div
|
||||
className="ai-error-panel"
|
||||
role="alert"
|
||||
style={{
|
||||
border: "1px solid var(--color-error, #dc2626)",
|
||||
borderRadius: "10px",
|
||||
background: "color-mix(in srgb, var(--color-error, #dc2626) 10%, transparent)",
|
||||
padding: "14px",
|
||||
display: "grid",
|
||||
gap: "10px",
|
||||
}}
|
||||
>
|
||||
<div className="ai-error-icon" style={{ fontSize: "20px" }}>⚠️</div>
|
||||
<div className="ai-error-message">{view.errorMessage}</div>
|
||||
<div className="ai-error-actions" style={{ display: "flex", gap: "8px" }}>
|
||||
<button className="btn btn-primary" onClick={() => void handleRetry()} disabled={isRetrying}>
|
||||
{isRetrying ? <Loader2 size={14} className="spin" /> : <RefreshCw size={14} />}
|
||||
<span style={{ marginLeft: "6px" }}>{isRetrying ? "Retrying..." : "Retry"}</span>
|
||||
</button>
|
||||
<button className="btn" onClick={() => void handleClose()} disabled={isRetrying}>Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(view.type === "editing" || view.type === "creating") && (
|
||||
<div className="planning-summary">
|
||||
<div className="planning-view-scroll planning-summary-scroll">
|
||||
|
||||
@@ -40,11 +40,12 @@ export function useBackgroundSessions(projectId?: string): UseBackgroundSessions
|
||||
next[idx] = updated;
|
||||
return next;
|
||||
}
|
||||
// New session — only add if active
|
||||
// New session — include in-progress, complete, and retryable error sessions
|
||||
if (
|
||||
updated.status === "generating" ||
|
||||
updated.status === "awaiting_input" ||
|
||||
updated.status === "complete"
|
||||
updated.status === "complete" ||
|
||||
updated.status === "error"
|
||||
) {
|
||||
return [updated, ...prev];
|
||||
}
|
||||
@@ -77,7 +78,11 @@ export function useBackgroundSessions(projectId?: string): UseBackgroundSessions
|
||||
|
||||
// Filter to only active sessions
|
||||
const active = sessions.filter(
|
||||
(s) => s.status === "generating" || s.status === "awaiting_input" || s.status === "complete"
|
||||
(s) =>
|
||||
s.status === "generating" ||
|
||||
s.status === "awaiting_input" ||
|
||||
s.status === "complete" ||
|
||||
s.status === "error",
|
||||
);
|
||||
|
||||
const planningSessions = active.filter((s) => s.type === "planning");
|
||||
|
||||
@@ -180,7 +180,7 @@ describe("AiSessionStore", () => {
|
||||
expect(store.get("S-broken")?.error).toBe("Session interrupted — please restart");
|
||||
});
|
||||
|
||||
it("listActive only returns generating/awaiting_input sessions", () => {
|
||||
it("listActive returns generating/awaiting_input/error sessions", () => {
|
||||
seedSession({ id: "S-generating", status: "generating" });
|
||||
seedSession({ id: "S-awaiting", status: "awaiting_input" });
|
||||
seedSession({ id: "S-complete", status: "complete" });
|
||||
@@ -188,20 +188,21 @@ describe("AiSessionStore", () => {
|
||||
|
||||
const active = store.listActive();
|
||||
|
||||
expect(active.map((session) => session.status).sort()).toEqual(["awaiting_input", "generating"]);
|
||||
expect(active.map((session) => session.id).sort()).toEqual(["S-awaiting", "S-generating"]);
|
||||
expect(active.map((session) => session.status).sort()).toEqual(["awaiting_input", "error", "generating"]);
|
||||
expect(active.map((session) => session.id).sort()).toEqual(["S-awaiting", "S-error", "S-generating"]);
|
||||
});
|
||||
|
||||
it("listActive filters by projectId", () => {
|
||||
seedSession({ id: "S-a1", status: "generating", projectId: "project-a" });
|
||||
seedSession({ id: "S-a2", status: "awaiting_input", projectId: "project-a" });
|
||||
seedSession({ id: "S-a3", status: "error", projectId: "project-a" });
|
||||
seedSession({ id: "S-b1", status: "awaiting_input", projectId: "project-b" });
|
||||
seedSession({ id: "S-a-done", status: "complete", projectId: "project-a" });
|
||||
|
||||
const projectA = store.listActive("project-a");
|
||||
|
||||
expect(projectA).toHaveLength(2);
|
||||
expect(projectA.map((session) => session.id).sort()).toEqual(["S-a1", "S-a2"]);
|
||||
expect(projectA).toHaveLength(3);
|
||||
expect(projectA.map((session) => session.id).sort()).toEqual(["S-a1", "S-a2", "S-a3"]);
|
||||
expect(projectA.every((session) => session.projectId === "project-a")).toBe(true);
|
||||
});
|
||||
|
||||
@@ -229,6 +230,36 @@ describe("AiSessionStore", () => {
|
||||
expect(onUpdated).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("updateStatus atomically transitions status and clears error when omitted", () => {
|
||||
seedSession({ id: "S-retry", status: "error", error: "Transient failure" });
|
||||
|
||||
const onUpdated = vi.fn();
|
||||
store.on("ai_session:updated", onUpdated);
|
||||
|
||||
const updated = store.updateStatus("S-retry", "generating");
|
||||
|
||||
expect(updated).toBe(true);
|
||||
expect(store.get("S-retry")?.status).toBe("generating");
|
||||
expect(store.get("S-retry")?.error).toBeNull();
|
||||
expect(onUpdated).toHaveBeenCalledTimes(1);
|
||||
expect(onUpdated).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
id: "S-retry",
|
||||
status: "generating",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("updateStatus sets explicit error and returns false for missing session", () => {
|
||||
seedSession({ id: "S-failed", status: "generating" });
|
||||
|
||||
expect(store.updateStatus("S-failed", "error", "Agent crashed")).toBe(true);
|
||||
expect(store.get("S-failed")?.status).toBe("error");
|
||||
expect(store.get("S-failed")?.error).toBe("Agent crashed");
|
||||
|
||||
expect(store.updateStatus("S-missing", "error", "Nope")).toBe(false);
|
||||
});
|
||||
|
||||
it("listRecoverable returns awaiting_input and generating sessions", () => {
|
||||
seedSession({ id: "S-generating", status: "generating", ageMs: 3_000 });
|
||||
seedSession({ id: "S-awaiting", status: "awaiting_input", ageMs: 1_000 });
|
||||
|
||||
@@ -145,6 +145,33 @@ export class AiSessionStore extends EventEmitter<AiSessionStoreEvents> {
|
||||
return row ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Atomically update only status/error for an existing session.
|
||||
* Returns false when the session does not exist.
|
||||
*/
|
||||
updateStatus(id: string, status: AiSessionStatus, error?: string): boolean {
|
||||
const now = new Date().toISOString();
|
||||
const result = this.db
|
||||
.prepare(
|
||||
`UPDATE ai_sessions
|
||||
SET status = ?, error = ?, updatedAt = ?
|
||||
WHERE id = ?`,
|
||||
)
|
||||
.run(status, error ?? null, now, id) as { changes?: number };
|
||||
|
||||
const changed = Number(result.changes ?? 0) > 0;
|
||||
if (!changed) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const row = this.get(id);
|
||||
if (row) {
|
||||
this.emit("ai_session:updated", toSummary(row, row.updatedAt));
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lightweight heartbeat for active sessions.
|
||||
* Updates only `updatedAt` and intentionally does NOT emit
|
||||
@@ -160,7 +187,7 @@ export class AiSessionStore extends EventEmitter<AiSessionStoreEvents> {
|
||||
}
|
||||
|
||||
/**
|
||||
* List active sessions (generating or awaiting_input).
|
||||
* List active/retryable sessions (generating, awaiting_input, or error).
|
||||
* Optionally filtered by projectId.
|
||||
*/
|
||||
listActive(projectId?: string): AiSessionSummary[] {
|
||||
@@ -168,7 +195,7 @@ export class AiSessionStore extends EventEmitter<AiSessionStoreEvents> {
|
||||
return this.db
|
||||
.prepare(
|
||||
`SELECT id, type, status, title, projectId, updatedAt FROM ai_sessions
|
||||
WHERE status IN ('generating', 'awaiting_input') AND projectId = ?
|
||||
WHERE status IN ('generating', 'awaiting_input', 'error') AND projectId = ?
|
||||
ORDER BY updatedAt DESC`,
|
||||
)
|
||||
.all(projectId) as unknown as AiSessionSummary[];
|
||||
@@ -176,7 +203,7 @@ export class AiSessionStore extends EventEmitter<AiSessionStoreEvents> {
|
||||
return this.db
|
||||
.prepare(
|
||||
`SELECT id, type, status, title, projectId, updatedAt FROM ai_sessions
|
||||
WHERE status IN ('generating', 'awaiting_input')
|
||||
WHERE status IN ('generating', 'awaiting_input', 'error')
|
||||
ORDER BY updatedAt DESC`,
|
||||
)
|
||||
.all() as unknown as AiSessionSummary[];
|
||||
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
getMissionInterviewSession,
|
||||
submitMissionInterviewResponse,
|
||||
} from "./mission-interview.js";
|
||||
import * as missionInterviewModule from "./mission-interview.js";
|
||||
|
||||
// Mock MissionStore factory
|
||||
function createMockMissionStore() {
|
||||
@@ -421,6 +422,7 @@ function createMockMissionStore() {
|
||||
function createMockStore(): TaskStore {
|
||||
return {
|
||||
getMissionStore: vi.fn().mockReturnValue(createMockMissionStore()),
|
||||
getRootDir: vi.fn().mockReturnValue("/fake/root"),
|
||||
pauseTask: vi.fn(),
|
||||
} as unknown as TaskStore;
|
||||
}
|
||||
@@ -490,7 +492,7 @@ class MockAiSessionStore {
|
||||
|
||||
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",
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1794,6 +1796,43 @@ describe("Mission API", () => {
|
||||
expect(res.body.error).toContain("sessionId");
|
||||
});
|
||||
|
||||
it("retries a failed interview session", async () => {
|
||||
const retrySpy = vi
|
||||
.spyOn(missionInterviewModule, "retryMissionInterviewSession")
|
||||
.mockResolvedValueOnce(undefined);
|
||||
|
||||
const { app } = buildApp();
|
||||
const res = await request(app, "POST", "/api/missions/interview/session-1/retry");
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual({ success: true, sessionId: "session-1" });
|
||||
expect(retrySpy).toHaveBeenCalledWith("session-1", "/fake/root");
|
||||
});
|
||||
|
||||
it("returns 404 when interview retry session is missing", async () => {
|
||||
vi.spyOn(missionInterviewModule, "retryMissionInterviewSession").mockRejectedValueOnce(
|
||||
new missionInterviewModule.SessionNotFoundError("Interview session missing"),
|
||||
);
|
||||
|
||||
const { app } = buildApp();
|
||||
const res = await request(app, "POST", "/api/missions/interview/session-404/retry");
|
||||
|
||||
expect(res.status).toBe(404);
|
||||
expect(res.body.error).toContain("Interview session missing");
|
||||
});
|
||||
|
||||
it("returns 400 when interview retry session is not in error state", async () => {
|
||||
vi.spyOn(missionInterviewModule, "retryMissionInterviewSession").mockRejectedValueOnce(
|
||||
new missionInterviewModule.InvalidSessionStateError("Session is not in an error state"),
|
||||
);
|
||||
|
||||
const { app } = buildApp();
|
||||
const res = await request(app, "POST", "/api/missions/interview/session-400/retry");
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toContain("not in an error state");
|
||||
});
|
||||
|
||||
it("replays buffered interview events when Last-Event-ID is provided", async () => {
|
||||
const { app } = buildApp();
|
||||
const sessionId = await createMissionInterviewSession("127.0.0.1", "Replay Mission", "/tmp/project");
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
checkRateLimit,
|
||||
cleanupMissionInterviewSession,
|
||||
createMissionInterviewSession,
|
||||
retryMissionInterviewSession,
|
||||
getMissionInterviewSession,
|
||||
getMissionInterviewSummary,
|
||||
getRateLimitResetTime,
|
||||
@@ -116,7 +117,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",
|
||||
);
|
||||
}
|
||||
|
||||
@@ -406,6 +407,81 @@ describe("mission-interview module", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("retryMissionInterviewSession", () => {
|
||||
it("rehydrates errored sessions and retries the last response", async () => {
|
||||
const store = new MockAiSessionStore();
|
||||
const row = buildMissionRow({
|
||||
id: "mission-retry-1",
|
||||
status: "error",
|
||||
error: "Transient outage",
|
||||
conversationHistory: JSON.stringify([
|
||||
{
|
||||
question: {
|
||||
id: "q-1",
|
||||
type: "text",
|
||||
question: "What is your goal?",
|
||||
description: "scope",
|
||||
},
|
||||
response: { "q-1": "Ship a dashboard" },
|
||||
},
|
||||
]),
|
||||
});
|
||||
store.rows.set(row.id, row);
|
||||
setAiSessionStore(store as any);
|
||||
|
||||
const resumedAgent = createMockAgent([createQuestionJson("q-retry")]);
|
||||
mockCreateKbAgent.mockImplementationOnce(async () => resumedAgent);
|
||||
|
||||
await retryMissionInterviewSession(row.id, "/tmp/project");
|
||||
|
||||
expect(resumedAgent.session.prompt).toHaveBeenCalledTimes(1);
|
||||
expect(resumedAgent.session.prompt.mock.calls[0]?.[0]).toContain("What is your goal?");
|
||||
expect(resumedAgent.session.prompt.mock.calls[0]?.[0]).toContain("Ship a dashboard");
|
||||
|
||||
expect(getMissionInterviewSession(row.id)?.currentQuestion?.id).toBe("q-retry");
|
||||
expect(store.get(row.id)?.status).toBe("awaiting_input");
|
||||
expect(store.get(row.id)?.error).toBeNull();
|
||||
});
|
||||
|
||||
it("replays the initial mission prompt when history is empty", async () => {
|
||||
const store = new MockAiSessionStore();
|
||||
const row = buildMissionRow({
|
||||
id: "mission-retry-2",
|
||||
status: "error",
|
||||
error: "First turn failed",
|
||||
inputPayload: JSON.stringify({
|
||||
ip: "127.0.0.1",
|
||||
missionId: "mission-999",
|
||||
missionTitle: "Launch alpha",
|
||||
}),
|
||||
conversationHistory: "[]",
|
||||
currentQuestion: null,
|
||||
});
|
||||
store.rows.set(row.id, row);
|
||||
setAiSessionStore(store as any);
|
||||
|
||||
const resumedAgent = createMockAgent([createQuestionJson("q-first")]);
|
||||
mockCreateKbAgent.mockImplementationOnce(async () => resumedAgent);
|
||||
|
||||
await retryMissionInterviewSession(row.id, "/tmp/project");
|
||||
|
||||
expect(resumedAgent.session.prompt).toHaveBeenCalledTimes(1);
|
||||
expect(resumedAgent.session.prompt.mock.calls[0]?.[0]).toContain('I want to plan a mission: "Launch alpha"');
|
||||
expect(store.get(row.id)?.status).toBe("awaiting_input");
|
||||
});
|
||||
|
||||
it("throws when retrying a non-error mission session", async () => {
|
||||
const store = new MockAiSessionStore();
|
||||
const row = buildMissionRow({ id: "mission-not-error", status: "awaiting_input" });
|
||||
store.rows.set(row.id, row);
|
||||
setAiSessionStore(store as any);
|
||||
|
||||
await expect(retryMissionInterviewSession(row.id, "/tmp/project")).rejects.toBeInstanceOf(
|
||||
InvalidSessionStateError,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("stream manager", () => {
|
||||
it("subscribes, broadcasts, unsubscribes, and cleans up", () => {
|
||||
const callback = vi.fn();
|
||||
|
||||
@@ -169,6 +169,8 @@ interface MissionInterviewSession {
|
||||
history: MissionInterviewHistoryEntry[];
|
||||
currentQuestion?: PlanningQuestion;
|
||||
summary?: MissionPlanSummary;
|
||||
/** Last terminal error for retry UX */
|
||||
error?: string;
|
||||
agent?: AgentResult;
|
||||
thinkingOutput: string;
|
||||
/** Thinking output generated while producing currentQuestion */
|
||||
@@ -312,6 +314,7 @@ function buildMissionInterviewSessionFromRow(row: AiSessionRow): MissionIntervie
|
||||
: undefined,
|
||||
thinkingOutput: row.thinkingOutput,
|
||||
lastGeneratedThinking: row.thinkingOutput || "",
|
||||
error: row.error ?? undefined,
|
||||
createdAt,
|
||||
updatedAt,
|
||||
agent: undefined,
|
||||
@@ -654,6 +657,30 @@ function formatResponseForAgent(
|
||||
}
|
||||
}
|
||||
|
||||
function coerceResponseRecord(question: PlanningQuestion, response: unknown): Record<string, unknown> {
|
||||
if (response && typeof response === "object" && !Array.isArray(response)) {
|
||||
return response as Record<string, unknown>;
|
||||
}
|
||||
|
||||
return {
|
||||
[question.id]: response,
|
||||
};
|
||||
}
|
||||
|
||||
function disposeMissionAgentForRetry(session: MissionInterviewSession): void {
|
||||
if (!session.agent) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
session.agent.session.dispose?.();
|
||||
} catch (error) {
|
||||
console.error(`[mission-interview] Error disposing agent for retry in session ${session.id}:`, error);
|
||||
}
|
||||
|
||||
session.agent = undefined;
|
||||
}
|
||||
|
||||
// ── AI Agent Integration ───────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
@@ -670,10 +697,14 @@ async function initializeAgent(session: MissionInterviewSession, rootDir: string
|
||||
`I want to plan a mission: "${session.missionTitle}". Interview me to understand what I need, then produce a structured plan.`,
|
||||
);
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : "Failed to initialize AI agent";
|
||||
console.error(`[mission-interview] Agent initialization error for session ${session.id}:`, err);
|
||||
session.error = errorMessage;
|
||||
session.updatedAt = new Date();
|
||||
persistMissionSession(session, "error", errorMessage);
|
||||
missionInterviewStreamManager.broadcast(session.id, {
|
||||
type: "error",
|
||||
data: err instanceof Error ? err.message : "Failed to initialize AI agent",
|
||||
data: errorMessage,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -843,17 +874,21 @@ async function continueAgentConversation(session: MissionInterviewSession, messa
|
||||
}
|
||||
|
||||
if (!parsed) {
|
||||
const errorMsg = lastError?.message || "Failed to parse AI response";
|
||||
const errorMsg = `${lastError?.message || "Failed to parse AI response"} You can try responding again or start a new session.`;
|
||||
console.error(`[mission-interview] All parse attempts exhausted for session ${session.id}:`, errorMsg);
|
||||
session.error = errorMsg;
|
||||
session.updatedAt = new Date();
|
||||
persistMissionSession(session, "error", errorMsg);
|
||||
missionInterviewStreamManager.broadcast(session.id, {
|
||||
type: "error",
|
||||
data: `${errorMsg} You can try responding again or start a new session.`,
|
||||
data: errorMsg,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (parsed.type === "question") {
|
||||
session.currentQuestion = parsed.data;
|
||||
session.error = undefined;
|
||||
session.lastGeneratedThinking = session.thinkingOutput;
|
||||
session.updatedAt = new Date();
|
||||
persistMissionSession(session, "awaiting_input");
|
||||
@@ -864,6 +899,7 @@ async function continueAgentConversation(session: MissionInterviewSession, messa
|
||||
} else if (parsed.type === "complete") {
|
||||
session.summary = parsed.data;
|
||||
session.currentQuestion = undefined;
|
||||
session.error = undefined;
|
||||
session.updatedAt = new Date();
|
||||
persistMissionSession(session, "complete");
|
||||
missionInterviewStreamManager.broadcast(session.id, {
|
||||
@@ -873,11 +909,14 @@ async function continueAgentConversation(session: MissionInterviewSession, messa
|
||||
missionInterviewStreamManager.broadcast(session.id, { type: "complete" });
|
||||
}
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : "AI processing failed";
|
||||
console.error(`[mission-interview] Agent conversation error for session ${session.id}:`, err);
|
||||
persistMissionSession(session, "error", err instanceof Error ? err.message : "AI processing failed");
|
||||
session.error = errorMessage;
|
||||
session.updatedAt = new Date();
|
||||
persistMissionSession(session, "error", errorMessage);
|
||||
missionInterviewStreamManager.broadcast(session.id, {
|
||||
type: "error",
|
||||
data: err instanceof Error ? err.message : "AI processing failed",
|
||||
data: errorMessage,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -955,6 +994,7 @@ export async function submitMissionInterviewResponse(
|
||||
response: responses,
|
||||
thinkingOutput: session.lastGeneratedThinking || "",
|
||||
});
|
||||
session.error = undefined;
|
||||
persistMissionSession(session, "generating");
|
||||
|
||||
if (!session.agent) {
|
||||
@@ -983,6 +1023,49 @@ export async function submitMissionInterviewResponse(
|
||||
};
|
||||
}
|
||||
|
||||
export async function retryMissionInterviewSession(sessionId: string, rootDir: string): Promise<void> {
|
||||
const session = getMissionInterviewSession(sessionId);
|
||||
if (!session) {
|
||||
throw new SessionNotFoundError(`Mission interview session ${sessionId} not found or expired`);
|
||||
}
|
||||
|
||||
const persisted = _aiSessionStore?.get(sessionId);
|
||||
if (persisted && persisted.type !== "mission_interview") {
|
||||
throw new SessionNotFoundError(`Mission interview session ${sessionId} not found or expired`);
|
||||
}
|
||||
|
||||
const inErrorState = persisted ? persisted.status === "error" : Boolean(session.error);
|
||||
if (!inErrorState) {
|
||||
throw new InvalidSessionStateError(`Mission interview session ${sessionId} is not in an error state`);
|
||||
}
|
||||
|
||||
disposeMissionAgentForRetry(session);
|
||||
|
||||
session.error = undefined;
|
||||
session.summary = undefined;
|
||||
session.updatedAt = new Date();
|
||||
persistMissionSession(session, "generating");
|
||||
|
||||
if (session.history.length === 0) {
|
||||
await ensureMissionInterviewAgent(session, rootDir, []);
|
||||
await continueAgentConversation(
|
||||
session,
|
||||
`I want to plan a mission: "${session.missionTitle}". Interview me to understand what I need, then produce a structured plan.`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const replayHistory = session.history.slice(0, -1);
|
||||
const lastEntry = session.history[session.history.length - 1];
|
||||
|
||||
await ensureMissionInterviewAgent(session, rootDir, replayHistory);
|
||||
const replayMessage = formatResponseForAgent(
|
||||
lastEntry.question,
|
||||
coerceResponseRecord(lastEntry.question, lastEntry.response),
|
||||
);
|
||||
await continueAgentConversation(session, replayMessage);
|
||||
}
|
||||
|
||||
export async function cancelMissionInterviewSession(sessionId: string): Promise<void> {
|
||||
const removed = cleanupInMemoryMissionSession(sessionId);
|
||||
if (!removed) {
|
||||
|
||||
@@ -376,6 +376,41 @@ export function createMissionRouter(
|
||||
})
|
||||
);
|
||||
|
||||
/**
|
||||
* POST /api/missions/interview/:sessionId/retry
|
||||
* Retry a failed interview session by replaying the last user interaction.
|
||||
*/
|
||||
router.post(
|
||||
"/interview/:sessionId/retry",
|
||||
catchTypedHandler(async (req, res) => {
|
||||
const { sessionId } = req.params;
|
||||
|
||||
if (!sessionId || typeof sessionId !== "string") {
|
||||
throw badRequest("sessionId is required");
|
||||
}
|
||||
|
||||
try {
|
||||
const {
|
||||
retryMissionInterviewSession,
|
||||
SessionNotFoundError,
|
||||
InvalidSessionStateError,
|
||||
} = await import("./mission-interview.js");
|
||||
|
||||
const rootDir = await getRootDirForRequest(req);
|
||||
await retryMissionInterviewSession(sessionId, rootDir);
|
||||
res.json({ success: true, sessionId });
|
||||
} catch (err: any) {
|
||||
if (err.name === "SessionNotFoundError") {
|
||||
throw notFound(err.message);
|
||||
} else if (err.name === "InvalidSessionStateError") {
|
||||
throw badRequest(err.message);
|
||||
} else {
|
||||
throw internalError(err.message || "Failed to retry interview session");
|
||||
}
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
/**
|
||||
* POST /api/missions/interview/cancel
|
||||
* Cancel and cleanup an interview session.
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -133,6 +133,8 @@ interface Session {
|
||||
history: PlanningHistoryEntry[];
|
||||
currentQuestion?: PlanningQuestion;
|
||||
summary?: PlanningSummary;
|
||||
/** Last terminal error for retry UX */
|
||||
error?: string;
|
||||
/** AI agent session for real-time interaction */
|
||||
agent?: AgentResult;
|
||||
/** Callback for streaming events to SSE clients */
|
||||
@@ -287,6 +289,7 @@ function buildSessionFromRow(row: AiSessionRow): Session {
|
||||
: undefined,
|
||||
thinkingOutput: row.thinkingOutput,
|
||||
lastGeneratedThinking: row.thinkingOutput || "",
|
||||
error: row.error ?? undefined,
|
||||
createdAt,
|
||||
updatedAt,
|
||||
agent: undefined,
|
||||
@@ -767,10 +770,14 @@ async function initializeAgent(
|
||||
// Send initial message to get first question
|
||||
await continueAgentConversation(session, session.initialPlan);
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : "Failed to initialize AI agent";
|
||||
console.error(`[planning] Agent initialization error for session ${session.id}:`, err);
|
||||
session.error = errorMessage;
|
||||
session.updatedAt = new Date();
|
||||
persistSession(session, "error", undefined, errorMessage);
|
||||
planningStreamManager.broadcast(session.id, {
|
||||
type: "error",
|
||||
data: err instanceof Error ? err.message : "Failed to initialize AI agent",
|
||||
data: errorMessage,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -949,20 +956,24 @@ async function continueAgentConversation(session: Session, message: string): Pro
|
||||
|
||||
if (!parsed) {
|
||||
// All attempts exhausted — emit actionable error
|
||||
const errorMsg = lastError?.message || "Failed to parse AI response";
|
||||
const errorMsg = `${lastError?.message || "Failed to parse AI response"} You can try responding again or start a new planning session.`;
|
||||
console.error(
|
||||
`[planning] All parse attempts exhausted for session ${session.id}:`,
|
||||
errorMsg
|
||||
);
|
||||
session.error = errorMsg;
|
||||
session.updatedAt = new Date();
|
||||
persistSession(session, "error", undefined, errorMsg);
|
||||
planningStreamManager.broadcast(session.id, {
|
||||
type: "error",
|
||||
data: `${errorMsg} You can try responding again or start a new planning session.`,
|
||||
data: errorMsg,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (parsed.type === "question") {
|
||||
session.currentQuestion = parsed.data;
|
||||
session.error = undefined;
|
||||
session.lastGeneratedThinking = session.thinkingOutput;
|
||||
session.updatedAt = new Date();
|
||||
persistSession(session, "awaiting_input");
|
||||
@@ -973,6 +984,7 @@ async function continueAgentConversation(session: Session, message: string): Pro
|
||||
} else if (parsed.type === "complete") {
|
||||
session.summary = parsed.data;
|
||||
session.currentQuestion = undefined;
|
||||
session.error = undefined;
|
||||
session.updatedAt = new Date();
|
||||
persistSession(session, "complete");
|
||||
planningStreamManager.broadcast(session.id, {
|
||||
@@ -982,11 +994,14 @@ async function continueAgentConversation(session: Session, message: string): Pro
|
||||
planningStreamManager.broadcast(session.id, { type: "complete" });
|
||||
}
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : "AI processing failed";
|
||||
console.error(`[planning] Agent conversation error for session ${session.id}:`, err);
|
||||
persistSession(session, "error", undefined, err instanceof Error ? err.message : "AI processing failed");
|
||||
session.error = errorMessage;
|
||||
session.updatedAt = new Date();
|
||||
persistSession(session, "error", undefined, errorMessage);
|
||||
planningStreamManager.broadcast(session.id, {
|
||||
type: "error",
|
||||
data: err instanceof Error ? err.message : "AI processing failed",
|
||||
data: errorMessage,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1202,6 +1217,7 @@ export async function submitResponse(
|
||||
response: responses,
|
||||
thinkingOutput: session.lastGeneratedThinking || "",
|
||||
});
|
||||
session.error = undefined;
|
||||
persistSession(session, "generating");
|
||||
|
||||
if (!session.agent) {
|
||||
@@ -1224,6 +1240,46 @@ export async function submitResponse(
|
||||
throw new InvalidSessionStateError("AI agent did not return a question or summary");
|
||||
}
|
||||
|
||||
export async function retrySession(sessionId: string, rootDir: string): Promise<void> {
|
||||
const session = getSession(sessionId);
|
||||
if (!session) {
|
||||
throw new SessionNotFoundError(`Planning session ${sessionId} not found or expired`);
|
||||
}
|
||||
|
||||
const persisted = _aiSessionStore?.get(sessionId);
|
||||
if (persisted && persisted.type !== "planning") {
|
||||
throw new SessionNotFoundError(`Planning session ${sessionId} not found or expired`);
|
||||
}
|
||||
|
||||
const inErrorState = persisted ? persisted.status === "error" : Boolean(session.error);
|
||||
if (!inErrorState) {
|
||||
throw new InvalidSessionStateError(`Planning session ${sessionId} is not in an error state`);
|
||||
}
|
||||
|
||||
disposeSessionAgentForRetry(session);
|
||||
|
||||
session.error = undefined;
|
||||
session.summary = undefined;
|
||||
session.updatedAt = new Date();
|
||||
persistSession(session, "generating");
|
||||
|
||||
if (session.history.length === 0) {
|
||||
await ensureSessionAgent(session, rootDir, []);
|
||||
await continueAgentConversation(session, session.initialPlan);
|
||||
return;
|
||||
}
|
||||
|
||||
const replayHistory = session.history.slice(0, -1);
|
||||
const lastEntry = session.history[session.history.length - 1];
|
||||
|
||||
await ensureSessionAgent(session, rootDir, replayHistory);
|
||||
const replayMessage = formatResponseForAgent(
|
||||
lastEntry.question,
|
||||
coerceResponseRecord(lastEntry.question, lastEntry.response),
|
||||
);
|
||||
await continueAgentConversation(session, replayMessage);
|
||||
}
|
||||
|
||||
/**
|
||||
* Format user response as a message for the AI agent.
|
||||
*/
|
||||
@@ -1262,6 +1318,30 @@ function formatResponseForAgent(
|
||||
}
|
||||
}
|
||||
|
||||
function coerceResponseRecord(question: PlanningQuestion, response: unknown): Record<string, unknown> {
|
||||
if (response && typeof response === "object" && !Array.isArray(response)) {
|
||||
return response as Record<string, unknown>;
|
||||
}
|
||||
|
||||
return {
|
||||
[question.id]: response,
|
||||
};
|
||||
}
|
||||
|
||||
function disposeSessionAgentForRetry(session: Session): void {
|
||||
if (!session.agent) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
session.agent.session.dispose?.();
|
||||
} catch (error) {
|
||||
console.error(`[planning] Error disposing agent for retry in session ${session.id}:`, error);
|
||||
}
|
||||
|
||||
session.agent = undefined;
|
||||
}
|
||||
|
||||
function formatInterviewAnswer(question: PlanningQuestion, responseValue: unknown): string {
|
||||
switch (question.type) {
|
||||
case "text":
|
||||
|
||||
@@ -659,6 +659,38 @@ describe("POST /subtasks/*", () => {
|
||||
expect(typeof res.body.sessionId).toBe("string");
|
||||
});
|
||||
|
||||
it("retries a failed subtask session", async () => {
|
||||
const retrySpy = vi.spyOn(subtaskBreakdownModule, "retrySubtaskSession").mockResolvedValue();
|
||||
|
||||
const res = await REQUEST(buildApp(), "POST", "/api/subtasks/session-123/retry");
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual({ success: true, sessionId: "session-123" });
|
||||
expect(retrySpy).toHaveBeenCalledWith("session-123", "/fake/root");
|
||||
});
|
||||
|
||||
it("returns 404 when subtask retry session does not exist", async () => {
|
||||
vi.spyOn(subtaskBreakdownModule, "retrySubtaskSession").mockRejectedValueOnce(
|
||||
new subtaskBreakdownModule.SessionNotFoundError("Subtask session not found"),
|
||||
);
|
||||
|
||||
const res = await REQUEST(buildApp(), "POST", "/api/subtasks/session-404/retry");
|
||||
|
||||
expect(res.status).toBe(404);
|
||||
expect(res.body.error).toContain("Subtask session not found");
|
||||
});
|
||||
|
||||
it("returns 400 when subtask retry session is not in error state", async () => {
|
||||
vi.spyOn(subtaskBreakdownModule, "retrySubtaskSession").mockRejectedValueOnce(
|
||||
new subtaskBreakdownModule.InvalidSessionStateError("Session is not in error state"),
|
||||
);
|
||||
|
||||
const res = await REQUEST(buildApp(), "POST", "/api/subtasks/session-400/retry");
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toContain("not in error state");
|
||||
});
|
||||
|
||||
it("replays buffered subtask events using lastEventId query param", async () => {
|
||||
const start = await REQUEST(
|
||||
buildApp(),
|
||||
@@ -6503,6 +6535,40 @@ describe("Git Management endpoints", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /planning/:sessionId/retry", () => {
|
||||
it("retries a failed planning session", async () => {
|
||||
const retrySpy = vi.spyOn(planningModule, "retrySession").mockResolvedValue();
|
||||
|
||||
const res = await REQUEST(buildApp(), "POST", "/api/planning/session-123/retry");
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual({ success: true, sessionId: "session-123" });
|
||||
expect(retrySpy).toHaveBeenCalledWith("session-123", expect.any(String));
|
||||
});
|
||||
|
||||
it("returns 404 when planning retry session is missing", async () => {
|
||||
vi.spyOn(planningModule, "retrySession").mockRejectedValueOnce(
|
||||
new planningModule.SessionNotFoundError("Planning session missing"),
|
||||
);
|
||||
|
||||
const res = await REQUEST(buildApp(), "POST", "/api/planning/session-404/retry");
|
||||
|
||||
expect(res.status).toBe(404);
|
||||
expect(res.body.error).toContain("Planning session missing");
|
||||
});
|
||||
|
||||
it("returns 400 when planning retry session is not in error state", async () => {
|
||||
vi.spyOn(planningModule, "retrySession").mockRejectedValueOnce(
|
||||
new planningModule.InvalidSessionStateError("Planning session is not in an error state"),
|
||||
);
|
||||
|
||||
const res = await REQUEST(buildApp(), "POST", "/api/planning/session-400/retry");
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toContain("not in an error state");
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /planning/cancel", () => {
|
||||
it("cancels an active session", async () => {
|
||||
// Create a session first
|
||||
|
||||
@@ -1400,6 +1400,7 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
|
||||
"POST /planning/start",
|
||||
"POST /planning/start-streaming",
|
||||
"POST /planning/respond",
|
||||
"POST /planning/:sessionId/retry",
|
||||
"POST /planning/cancel",
|
||||
"POST /planning/create-task",
|
||||
"POST /planning/start-breakdown",
|
||||
@@ -5862,6 +5863,31 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
|
||||
}
|
||||
});
|
||||
|
||||
router.post("/subtasks/:sessionId/retry", async (req, res) => {
|
||||
try {
|
||||
const { sessionId } = req.params;
|
||||
if (!sessionId || typeof sessionId !== "string") {
|
||||
throw badRequest("sessionId is required");
|
||||
}
|
||||
|
||||
const scopedStore = await getScopedStore(req);
|
||||
const { retrySubtaskSession } = await import("./subtask-breakdown.js");
|
||||
await retrySubtaskSession(sessionId, scopedStore.getRootDir());
|
||||
res.json({ success: true, sessionId });
|
||||
} catch (err: any) {
|
||||
if (err instanceof ApiError) {
|
||||
throw err;
|
||||
}
|
||||
if (err.name === "SessionNotFoundError") {
|
||||
throw notFound(err.message);
|
||||
} else if (err.name === "InvalidSessionStateError") {
|
||||
throw badRequest(err.message);
|
||||
} else {
|
||||
rethrowAsApiError(err, "Failed to retry subtask session");
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/planning/start
|
||||
* Start a new planning session.
|
||||
@@ -5988,6 +6014,31 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
|
||||
}
|
||||
});
|
||||
|
||||
router.post("/planning/:sessionId/retry", async (req, res) => {
|
||||
try {
|
||||
const { sessionId } = req.params;
|
||||
if (!sessionId || typeof sessionId !== "string") {
|
||||
throw badRequest("sessionId is required");
|
||||
}
|
||||
|
||||
const scopedStore = await getScopedStore(req);
|
||||
const { retrySession } = await import("./planning.js");
|
||||
await retrySession(sessionId, scopedStore.getRootDir());
|
||||
res.json({ success: true, sessionId });
|
||||
} catch (err: any) {
|
||||
if (err instanceof ApiError) {
|
||||
throw err;
|
||||
}
|
||||
if (err.name === "SessionNotFoundError") {
|
||||
throw notFound(err.message);
|
||||
} else if (err.name === "InvalidSessionStateError") {
|
||||
throw badRequest(err.message);
|
||||
} else {
|
||||
rethrowAsApiError(err, "Failed to retry planning session");
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/planning/cancel
|
||||
* Cancel and cleanup a planning session.
|
||||
|
||||
@@ -2,7 +2,16 @@
|
||||
|
||||
import { EventEmitter } from "node:events";
|
||||
import ts from "typescript";
|
||||
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const { mockCreateKbAgent } = vi.hoisted(() => ({
|
||||
mockCreateKbAgent: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@fusion/engine", () => ({
|
||||
createKbAgent: mockCreateKbAgent,
|
||||
}));
|
||||
|
||||
import type { AiSessionRow } from "./ai-session-store.js";
|
||||
// @ts-expect-error Vite raw loader import for source-level utility tests
|
||||
import subtaskBreakdownSource from "./subtask-breakdown.ts?raw";
|
||||
@@ -11,9 +20,11 @@ import {
|
||||
cancelSubtaskSession,
|
||||
cleanupSubtaskSession,
|
||||
createSubtaskSession,
|
||||
retrySubtaskSession,
|
||||
getSubtaskSession,
|
||||
rehydrateFromStore,
|
||||
SessionNotFoundError,
|
||||
InvalidSessionStateError,
|
||||
setAiSessionStore,
|
||||
SubtaskStreamManager,
|
||||
} from "./subtask-breakdown.js";
|
||||
@@ -111,16 +122,66 @@ async function loadInternalSubtaskFunctions(): Promise<InternalSubtaskFns> {
|
||||
|
||||
let internalFns: InternalSubtaskFns;
|
||||
|
||||
function createMockSubtaskAgent(responseText?: string) {
|
||||
const messages: Array<{ role: string; content: string }> = [];
|
||||
const response =
|
||||
responseText ??
|
||||
JSON.stringify({
|
||||
subtasks: [
|
||||
{
|
||||
id: "subtask-1",
|
||||
title: "Define implementation approach",
|
||||
description: "Plan the implementation details",
|
||||
suggestedSize: "S",
|
||||
dependsOn: [],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
return {
|
||||
session: {
|
||||
state: { messages },
|
||||
prompt: vi.fn(async (message: string) => {
|
||||
messages.push({ role: "user", content: message });
|
||||
messages.push({ role: "assistant", content: response });
|
||||
}),
|
||||
dispose: vi.fn(),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
class MockAiSessionStore extends EventEmitter {
|
||||
rows = new Map<string, AiSessionRow>();
|
||||
|
||||
upsert(row: AiSessionRow): void {
|
||||
this.rows.set(row.id, row);
|
||||
}
|
||||
|
||||
updateThinking(id: string, thinkingOutput: string): void {
|
||||
const row = this.rows.get(id);
|
||||
if (!row) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.rows.set(id, {
|
||||
...row,
|
||||
thinkingOutput,
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
delete(id: string): void {
|
||||
this.rows.delete(id);
|
||||
this.emit("ai_session:deleted", id);
|
||||
}
|
||||
|
||||
get(id: string): AiSessionRow | null {
|
||||
return this.rows.get(id) ?? null;
|
||||
}
|
||||
|
||||
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",
|
||||
);
|
||||
}
|
||||
|
||||
@@ -169,6 +230,11 @@ beforeAll(async () => {
|
||||
internalFns = await loadInternalSubtaskFunctions();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
mockCreateKbAgent.mockReset();
|
||||
mockCreateKbAgent.mockImplementation(async () => createMockSubtaskAgent());
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
__resetSubtaskBreakdownState();
|
||||
vi.restoreAllMocks();
|
||||
@@ -394,6 +460,38 @@ describe("subtask session lifecycle", () => {
|
||||
expect(session).not.toHaveProperty("thinkingOutput");
|
||||
});
|
||||
|
||||
it("retrySubtaskSession retries errored sessions restored from SQLite", async () => {
|
||||
const store = new MockAiSessionStore();
|
||||
const row = buildSubtaskRow({
|
||||
id: "subtask-retry-1",
|
||||
status: "error",
|
||||
error: "Transient failure",
|
||||
result: null,
|
||||
});
|
||||
store.rows.set(row.id, row);
|
||||
setAiSessionStore(store as any);
|
||||
|
||||
await retrySubtaskSession(row.id, "/tmp/project");
|
||||
|
||||
const session = getSubtaskSession(row.id);
|
||||
expect(session).toBeDefined();
|
||||
expect(session?.status).toBe("complete");
|
||||
expect(session?.subtasks.length).toBeGreaterThan(0);
|
||||
expect(store.get(row.id)?.status).toBe("complete");
|
||||
expect(store.get(row.id)?.error).toBeNull();
|
||||
});
|
||||
|
||||
it("retrySubtaskSession rejects non-error sessions", async () => {
|
||||
const store = new MockAiSessionStore();
|
||||
const row = buildSubtaskRow({ id: "subtask-retry-2", status: "generating" });
|
||||
store.rows.set(row.id, row);
|
||||
setAiSessionStore(store as any);
|
||||
|
||||
await expect(retrySubtaskSession(row.id, "/tmp/project")).rejects.toBeInstanceOf(
|
||||
InvalidSessionStateError,
|
||||
);
|
||||
});
|
||||
|
||||
it("cancelSubtaskSession throws SessionNotFoundError for unknown session", async () => {
|
||||
await expect(cancelSubtaskSession("missing-session")).rejects.toMatchObject({
|
||||
name: "SessionNotFoundError",
|
||||
|
||||
@@ -339,15 +339,7 @@ export async function createSubtaskSession(initialDescription: string, _store?:
|
||||
persistSubtaskSession(session, "generating");
|
||||
|
||||
const cwd = rootDir ?? process.cwd();
|
||||
generateSubtasks(sessionId, cwd).catch((err) => {
|
||||
const existing = sessions.get(sessionId);
|
||||
if (!existing) return;
|
||||
existing.status = "error";
|
||||
existing.error = err instanceof Error ? (err.message || "Unknown error") : "Failed to generate subtasks";
|
||||
existing.updatedAt = new Date();
|
||||
persistSubtaskSession(existing, "error", existing.error);
|
||||
subtaskStreamManager.broadcast(sessionId, { type: "error", data: existing.error });
|
||||
});
|
||||
void startSubtaskGeneration(sessionId, cwd);
|
||||
|
||||
return {
|
||||
sessionId,
|
||||
@@ -358,6 +350,20 @@ export async function createSubtaskSession(initialDescription: string, _store?:
|
||||
};
|
||||
}
|
||||
|
||||
async function startSubtaskGeneration(sessionId: string, cwd: string): Promise<void> {
|
||||
try {
|
||||
await generateSubtasks(sessionId, cwd);
|
||||
} catch (err) {
|
||||
const existing = sessions.get(sessionId);
|
||||
if (!existing) return;
|
||||
existing.status = "error";
|
||||
existing.error = err instanceof Error ? (err.message || "Unknown error") : "Failed to generate subtasks";
|
||||
existing.updatedAt = new Date();
|
||||
persistSubtaskSession(existing, "error", existing.error);
|
||||
subtaskStreamManager.broadcast(sessionId, { type: "error", data: existing.error });
|
||||
}
|
||||
}
|
||||
|
||||
async function generateSubtasks(sessionId: string, cwd: string): Promise<void> {
|
||||
const session = sessions.get(sessionId);
|
||||
if (!session) throw new SessionNotFoundError(`Subtask session ${sessionId} not found`);
|
||||
@@ -466,6 +472,48 @@ function completeSession(sessionId: string, subtasks: SubtaskItem[]): void {
|
||||
subtaskStreamManager.broadcast(sessionId, { type: "complete" });
|
||||
}
|
||||
|
||||
function disposeSubtaskAgentForRetry(session: SubtaskInternalSession): void {
|
||||
try {
|
||||
session.agent?.session?.dispose?.();
|
||||
} catch {
|
||||
// ignore cleanup errors
|
||||
}
|
||||
session.agent = undefined;
|
||||
}
|
||||
|
||||
export async function retrySubtaskSession(sessionId: string, rootDir: string): Promise<void> {
|
||||
const visibleSession = getSubtaskSession(sessionId);
|
||||
if (!visibleSession) {
|
||||
throw new SessionNotFoundError(`Subtask session ${sessionId} not found or expired`);
|
||||
}
|
||||
|
||||
const persisted = _aiSessionStore?.get(sessionId);
|
||||
if (persisted && persisted.type !== "subtask") {
|
||||
throw new SessionNotFoundError(`Subtask session ${sessionId} not found or expired`);
|
||||
}
|
||||
|
||||
const session = sessions.get(sessionId);
|
||||
if (!session) {
|
||||
throw new SessionNotFoundError(`Subtask session ${sessionId} not found or expired`);
|
||||
}
|
||||
|
||||
const inErrorState = persisted ? persisted.status === "error" : visibleSession.status === "error";
|
||||
if (!inErrorState) {
|
||||
throw new InvalidSessionStateError(`Subtask session ${sessionId} is not in an error state`);
|
||||
}
|
||||
|
||||
disposeSubtaskAgentForRetry(session);
|
||||
|
||||
session.status = "generating";
|
||||
session.error = undefined;
|
||||
session.subtasks = [];
|
||||
session.thinkingOutput = "";
|
||||
session.updatedAt = new Date();
|
||||
persistSubtaskSession(session, "generating");
|
||||
|
||||
await startSubtaskGeneration(sessionId, rootDir);
|
||||
}
|
||||
|
||||
export function getSubtaskSession(sessionId: string): SubtaskSession | undefined {
|
||||
const inMemory = sessions.get(sessionId);
|
||||
if (inMemory) {
|
||||
@@ -524,3 +572,10 @@ export class SessionNotFoundError extends Error {
|
||||
this.name = "SessionNotFoundError";
|
||||
}
|
||||
}
|
||||
|
||||
export class InvalidSessionStateError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = "InvalidSessionStateError";
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user