fix(FN-2861): harden planning session recovery and retry UX
- Add planning and subtask retry routes with session-lock checks and proper API error mapping - Improve planning session execution with timeout/abort handling, stop-generation support, and resilient stream catch-up behavior - Update Planning Mode modal to handle reconnects, retry-from-error flow, stop action, and cross-tab session state synchronization - Expand dashboard tests for planning routes, planning session behavior, and PlanningModeModal retry/error coverage
This commit is contained in:
@@ -2357,6 +2357,21 @@ export function retryPlanningSession(
|
||||
);
|
||||
}
|
||||
|
||||
/** Stop in-flight planning generation for a session */
|
||||
export function stopPlanningGeneration(
|
||||
sessionId: string,
|
||||
projectId?: string,
|
||||
tabId?: string,
|
||||
): Promise<{ success: boolean }> {
|
||||
return api<{ success: boolean }>(
|
||||
withProjectId(`/planning/${encodeURIComponent(sessionId)}/stop`, projectId),
|
||||
{
|
||||
method: "POST",
|
||||
...(tabId ? { body: JSON.stringify({ tabId }) } : {}),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/** Cancel an active planning session */
|
||||
export function cancelPlanning(sessionId: string, projectId?: string, tabId?: string): Promise<void> {
|
||||
return api<void>(withProjectId("/planning/cancel", projectId), {
|
||||
|
||||
@@ -972,6 +972,35 @@
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.planning-loading-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--space-md);
|
||||
margin-top: var(--space-md);
|
||||
}
|
||||
|
||||
.planning-stop-btn {
|
||||
border-color: var(--color-error);
|
||||
color: var(--color-error);
|
||||
background: color-mix(in srgb, var(--color-error) 12%, transparent);
|
||||
}
|
||||
|
||||
.planning-stop-btn:hover {
|
||||
border-color: var(--color-error-dark);
|
||||
color: var(--color-error-dark);
|
||||
background: color-mix(in srgb, var(--color-error) 18%, transparent);
|
||||
}
|
||||
|
||||
.planning-stop-btn {
|
||||
min-height: 36px;
|
||||
}
|
||||
|
||||
.planning-elapsed {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* Subtask Drag-and-Drop Styles */
|
||||
.subtask-item {
|
||||
transition: opacity var(--transition-fast), transform var(--transition-fast);
|
||||
@@ -1159,6 +1188,17 @@
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.planning-loading-actions {
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.planning-loading-actions .btn {
|
||||
width: 100%;
|
||||
min-height: 36px;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
/* Prevent mobile zoom on focus for planning text inputs (16px minimum) */
|
||||
.planning-textarea {
|
||||
font-size: 16px;
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
createTasksFromPlanning,
|
||||
fetchModels,
|
||||
cancelPlanning,
|
||||
stopPlanningGeneration,
|
||||
updateGlobalSettings,
|
||||
type PlanningSession,
|
||||
type SubtaskItem,
|
||||
@@ -30,7 +31,7 @@ import {
|
||||
getPlanningDescription,
|
||||
clearPlanningDescription,
|
||||
} from "../hooks/modalPersistence";
|
||||
import { Lightbulb, X, Loader2, CheckCircle, ArrowLeft, ArrowRight, Sparkles, ListTree, GripVertical, ArrowUp, ArrowDown, Plus, Trash2, RefreshCw, Lock, ChevronLeft, MessageSquarePlus, AlertCircle, Clock, HelpCircle } from "lucide-react";
|
||||
import { Lightbulb, X, Loader2, CheckCircle, ArrowLeft, ArrowRight, Sparkles, ListTree, GripVertical, ArrowUp, ArrowDown, Plus, Trash2, RefreshCw, Lock, ChevronLeft, MessageSquarePlus, AlertCircle, Clock, HelpCircle, StopCircle } from "lucide-react";
|
||||
import { CustomModelDropdown } from "./CustomModelDropdown";
|
||||
import { ConversationHistory } from "./ConversationHistory";
|
||||
import { useSessionLock } from "../hooks/useSessionLock";
|
||||
@@ -106,6 +107,8 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
|
||||
const [showThinking, setShowThinking] = useState(true);
|
||||
const [isReconnecting, setIsReconnecting] = useState(false);
|
||||
const [isRetrying, setIsRetrying] = useState(false);
|
||||
const [generationStartTime, setGenerationStartTime] = useState<number | null>(null);
|
||||
const [elapsedSeconds, setElapsedSeconds] = useState(0);
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const modalRef = useRef<HTMLDivElement>(null);
|
||||
const streamConnectionRef = useRef<{ close: () => void; isConnected: () => boolean } | null>(null);
|
||||
@@ -164,6 +167,24 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
|
||||
}
|
||||
}, [streamingOutput]);
|
||||
|
||||
useEffect(() => {
|
||||
if (view.type !== "loading") {
|
||||
setGenerationStartTime(null);
|
||||
setElapsedSeconds(0);
|
||||
return;
|
||||
}
|
||||
|
||||
const startedAt = Date.now();
|
||||
setGenerationStartTime(startedAt);
|
||||
setElapsedSeconds(0);
|
||||
|
||||
const timer = setInterval(() => {
|
||||
setElapsedSeconds(Math.max(0, Math.floor((Date.now() - startedAt) / 1000)));
|
||||
}, 1000);
|
||||
|
||||
return () => clearInterval(timer);
|
||||
}, [view.type]);
|
||||
|
||||
const resetDetailState = useCallback(() => {
|
||||
setInitialPlan("");
|
||||
setView({ type: "initial" });
|
||||
@@ -827,6 +848,30 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
|
||||
[projectId, sessionTabId, view]
|
||||
);
|
||||
|
||||
const handleStopGeneration = useCallback(async () => {
|
||||
const sessionId = currentSessionIdRef.current;
|
||||
if (!sessionId) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await stopPlanningGeneration(sessionId, projectId, sessionTabId);
|
||||
} catch {
|
||||
// best-effort; server-side timeout/stop event may have already fired
|
||||
}
|
||||
|
||||
streamConnectionRef.current?.close();
|
||||
streamConnectionRef.current = null;
|
||||
setIsReconnecting(false);
|
||||
setIsRetrying(false);
|
||||
setView({
|
||||
type: "error",
|
||||
session: { sessionId, currentQuestion: null, summary: null },
|
||||
errorMessage: "Generation stopped by user. You can retry or start a new session.",
|
||||
});
|
||||
setStreamingOutput("");
|
||||
}, [projectId, sessionTabId]);
|
||||
|
||||
const handleRetryFromError = useCallback(async () => {
|
||||
if (view.type !== "error") {
|
||||
return;
|
||||
@@ -1206,6 +1251,9 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
|
||||
<div className="planning-loading">
|
||||
<Loader2 size={40} className="spin icon-todo" />
|
||||
<p>{streamingOutput ? "AI is thinking..." : "Generating next question..."}</p>
|
||||
{generationStartTime && (
|
||||
<div className="planning-elapsed">Thinking… ({elapsedSeconds}s)</div>
|
||||
)}
|
||||
<div className="planning-thinking-container">
|
||||
<button
|
||||
className="planning-thinking-toggle"
|
||||
@@ -1214,6 +1262,12 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
|
||||
>
|
||||
{showThinking ? "Hide thinking" : "Show thinking"}
|
||||
</button>
|
||||
<div className="planning-loading-actions">
|
||||
<button className="btn planning-stop-btn" type="button" onClick={() => void handleStopGeneration()}>
|
||||
<StopCircle size={14} />
|
||||
<span className="icon-ml-6">Stop</span>
|
||||
</button>
|
||||
</div>
|
||||
{showThinking && streamingOutput && (
|
||||
<div className="planning-thinking-output" ref={thinkingOutputRef}>
|
||||
<pre>{streamingOutput}</pre>
|
||||
|
||||
@@ -14,6 +14,7 @@ const mockConnectPlanningStream = vi.fn();
|
||||
const mockRespondToPlanning = vi.fn();
|
||||
const mockRetryPlanningSession = vi.fn();
|
||||
const mockCancelPlanning = vi.fn();
|
||||
const mockStopPlanningGeneration = vi.fn();
|
||||
const mockCreateTaskFromPlanning = vi.fn();
|
||||
const mockStartPlanningBreakdown = vi.fn();
|
||||
const mockCreateTasksFromPlanning = vi.fn();
|
||||
@@ -41,6 +42,7 @@ vi.mock("../../api", () => ({
|
||||
respondToPlanning: (...args: any[]) => mockRespondToPlanning(...args),
|
||||
retryPlanningSession: (...args: any[]) => mockRetryPlanningSession(...args),
|
||||
cancelPlanning: (...args: any[]) => mockCancelPlanning(...args),
|
||||
stopPlanningGeneration: (...args: any[]) => mockStopPlanningGeneration(...args),
|
||||
createTaskFromPlanning: (...args: any[]) => mockCreateTaskFromPlanning(...args),
|
||||
startPlanningBreakdown: (...args: any[]) => mockStartPlanningBreakdown(...args),
|
||||
createTasksFromPlanning: (...args: any[]) => mockCreateTasksFromPlanning(...args),
|
||||
@@ -216,6 +218,7 @@ describe("PlanningModeModal", () => {
|
||||
mockReleaseSessionLock.mockResolvedValue(undefined);
|
||||
mockForceAcquireSessionLock.mockResolvedValue(undefined);
|
||||
mockCancelPlanning.mockResolvedValue(undefined);
|
||||
mockStopPlanningGeneration.mockResolvedValue({ success: true });
|
||||
|
||||
// Default: simulate receiving a question after a brief delay
|
||||
mockConnectPlanningStream.mockImplementation((_sessionId: string, _projectId: string | undefined, handlers: any) => {
|
||||
@@ -577,6 +580,49 @@ describe("PlanningModeModal", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("shows stop action in loading and stops generation", async () => {
|
||||
let streamHandlers: any;
|
||||
const closeSpy = vi.fn();
|
||||
mockConnectPlanningStream.mockImplementationOnce((_sessionId: string, _projectId: string | undefined, handlers: any) => {
|
||||
streamHandlers = handlers;
|
||||
return {
|
||||
close: closeSpy,
|
||||
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.getByRole("button", { name: "Stop" })).toBeDefined();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Stop" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockStopPlanningGeneration).toHaveBeenCalledWith("session-123", undefined, expect.any(String));
|
||||
});
|
||||
expect(closeSpy).toHaveBeenCalled();
|
||||
expect(screen.getByText("Generation stopped by user. You can retry or start a new session.")).toBeDefined();
|
||||
expect(screen.getByRole("button", { name: "Retry" })).toBeDefined();
|
||||
|
||||
// avoid dangling handlers reference lint
|
||||
expect(streamHandlers).toBeDefined();
|
||||
});
|
||||
|
||||
it("shows error message when planning fails", async () => {
|
||||
// Override the default mock to simulate an error
|
||||
mockConnectPlanningStream.mockImplementationOnce((_sessionId: string, _projectId: string | undefined, handlers: any) => {
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
submitResponse,
|
||||
retrySession,
|
||||
cancelSession,
|
||||
stopGeneration,
|
||||
getSession,
|
||||
getCurrentQuestion,
|
||||
getSummary,
|
||||
@@ -32,6 +33,7 @@ import {
|
||||
generateSubtasksFromPlanning,
|
||||
formatInterviewQA,
|
||||
SESSION_TTL_MS,
|
||||
GENERATION_TIMEOUT_MS,
|
||||
} from "../planning.js";
|
||||
import { createApiRoutes } from "../routes.js";
|
||||
import { request, get } from "../test-request.js";
|
||||
@@ -1109,6 +1111,66 @@ describe("planning module", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("generation controls", () => {
|
||||
it("returns false when stopping unknown session", () => {
|
||||
expect(stopGeneration("missing-session")).toBe(false);
|
||||
});
|
||||
|
||||
it("stops in-flight generation and sets user-visible error", async () => {
|
||||
let resolvePrompt: (() => void) | undefined;
|
||||
const hangingAgent = {
|
||||
session: {
|
||||
state: { messages: [] as Array<{ role: string; content: string }> },
|
||||
prompt: vi.fn(
|
||||
() =>
|
||||
new Promise<void>((resolve) => {
|
||||
resolvePrompt = resolve;
|
||||
}),
|
||||
),
|
||||
dispose: vi.fn(),
|
||||
},
|
||||
};
|
||||
__setCreateFnAgent(async () => hangingAgent as any);
|
||||
|
||||
const sessionId = await createSessionWithAgent(getUniqueIp(), initialPlan, TEST_ROOT_DIR);
|
||||
await vi.waitFor(() => {
|
||||
expect(hangingAgent.session.prompt).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
const stopped = stopGeneration(sessionId);
|
||||
expect(stopped).toBe(true);
|
||||
expect(hangingAgent.session.dispose).toHaveBeenCalled();
|
||||
|
||||
await flushAsyncWork();
|
||||
expect(getSession(sessionId)?.error).toContain("Generation stopped by user");
|
||||
|
||||
resolvePrompt?.();
|
||||
});
|
||||
|
||||
it("times out stalled generation and transitions session to error", async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const hangingAgent = {
|
||||
session: {
|
||||
state: { messages: [] as Array<{ role: string; content: string }> },
|
||||
prompt: vi.fn(() => new Promise<void>(() => {})),
|
||||
dispose: vi.fn(),
|
||||
},
|
||||
};
|
||||
__setCreateFnAgent(async () => hangingAgent as any);
|
||||
|
||||
const sessionId = await createSessionWithAgent(getUniqueIp(), initialPlan, TEST_ROOT_DIR);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(GENERATION_TIMEOUT_MS + 10);
|
||||
await flushAsyncWork();
|
||||
|
||||
expect(getSession(sessionId)?.error).toContain("timed out");
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("rehydrateFromStore", () => {
|
||||
it("rehydrates planning sessions from SQLite rows", () => {
|
||||
const store = new MockAiSessionStore();
|
||||
|
||||
@@ -9852,6 +9852,27 @@ describe("Planning Mode Routes", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /planning/:sessionId/stop", () => {
|
||||
it("stops an active generation", async () => {
|
||||
const stopSpy = vi.spyOn(planningModule, "stopGeneration").mockReturnValue(true);
|
||||
|
||||
const res = await REQUEST(buildApp(), "POST", "/api/planning/session-123/stop");
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual({ success: true });
|
||||
expect(stopSpy).toHaveBeenCalledWith("session-123");
|
||||
});
|
||||
|
||||
it("returns 404 when session is missing", async () => {
|
||||
vi.spyOn(planningModule, "stopGeneration").mockReturnValue(false);
|
||||
|
||||
const res = await REQUEST(buildApp(), "POST", "/api/planning/session-404/stop");
|
||||
|
||||
expect(res.status).toBe(404);
|
||||
expect(res.body.error).toContain("not found");
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /planning/cancel", () => {
|
||||
it("cancels an active session", async () => {
|
||||
// Create a session first
|
||||
|
||||
@@ -179,6 +179,9 @@ const MAX_SESSIONS_PER_IP_PER_HOUR = 5;
|
||||
/** Rate limiting window in milliseconds (1 hour) */
|
||||
const RATE_LIMIT_WINDOW_MS = 60 * 60 * 1000;
|
||||
|
||||
/** Generation timeout in milliseconds (120 seconds). */
|
||||
export const GENERATION_TIMEOUT_MS = 120_000;
|
||||
|
||||
// ── Types ───────────────────────────────────────────────────────────────────
|
||||
|
||||
/** SSE event types for planning session streaming */
|
||||
@@ -236,6 +239,9 @@ const sessions = new Map<string, Session>();
|
||||
/** Rate limiting state indexed by IP */
|
||||
const rateLimits = new Map<string, RateLimitEntry>();
|
||||
|
||||
/** Active planning generations keyed by session ID. */
|
||||
const activeGenerations = new Map<string, { abortController: AbortController; timer: NodeJS.Timeout }>();
|
||||
|
||||
// ── AI Session Persistence ────────────────────────────────────────────────
|
||||
|
||||
/** Optional store for persisting session state across reloads/browsers. */
|
||||
@@ -281,6 +287,12 @@ function cleanupInMemorySession(sessionId: string): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
const activeGeneration = activeGenerations.get(sessionId);
|
||||
if (activeGeneration) {
|
||||
clearTimeout(activeGeneration.timer);
|
||||
activeGenerations.delete(sessionId);
|
||||
}
|
||||
|
||||
if (session.agent) {
|
||||
try {
|
||||
session.agent.session.dispose?.();
|
||||
@@ -1030,14 +1042,71 @@ const MAX_PARSE_RETRIES = 1;
|
||||
* one retry attempt is made with a reformat prompt before emitting a
|
||||
* terminal session error.
|
||||
*/
|
||||
function setSessionError(session: Session, message: string): void {
|
||||
session.error = message;
|
||||
session.updatedAt = new Date();
|
||||
persistSession(session, "error", message);
|
||||
planningStreamManager.broadcast(session.id, {
|
||||
type: "error",
|
||||
data: message,
|
||||
});
|
||||
}
|
||||
|
||||
function createAbortError(): Error {
|
||||
const error = new Error("Generation aborted");
|
||||
error.name = "AbortError";
|
||||
return error;
|
||||
}
|
||||
|
||||
async function runGenerationWithTimeout<T>(session: Session, operation: () => Promise<T>): Promise<T> {
|
||||
const existing = activeGenerations.get(session.id);
|
||||
if (existing) {
|
||||
clearTimeout(existing.timer);
|
||||
existing.abortController.abort();
|
||||
}
|
||||
|
||||
const abortController = new AbortController();
|
||||
let timeoutTriggered = false;
|
||||
const timer = setTimeout(() => {
|
||||
timeoutTriggered = true;
|
||||
setSessionError(session, "AI generation timed out. You can retry or start a new session.");
|
||||
abortController.abort();
|
||||
}, GENERATION_TIMEOUT_MS);
|
||||
|
||||
activeGenerations.set(session.id, { abortController, timer });
|
||||
|
||||
const abortPromise = new Promise<never>((_, reject) => {
|
||||
abortController.signal.addEventListener(
|
||||
"abort",
|
||||
() => reject(createAbortError()),
|
||||
{ once: true },
|
||||
);
|
||||
});
|
||||
|
||||
try {
|
||||
return await Promise.race([operation(), abortPromise]);
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.name === "AbortError") {
|
||||
if (!timeoutTriggered && !session.error) {
|
||||
setSessionError(session, "Generation stopped by user. You can retry or start a new session.");
|
||||
}
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
activeGenerations.delete(session.id);
|
||||
}
|
||||
}
|
||||
|
||||
async function continueAgentConversation(session: Session, message: string): Promise<void> {
|
||||
if (!session.agent) {
|
||||
throw new InvalidSessionStateError("AI agent not initialized");
|
||||
}
|
||||
|
||||
try {
|
||||
// Clear thinking output for this turn
|
||||
session.thinkingOutput = "";
|
||||
await runGenerationWithTimeout(session, async () => {
|
||||
// Clear thinking output for this turn
|
||||
session.thinkingOutput = "";
|
||||
|
||||
// Send message to agent using .prompt() - it will stream thinking via onThinking callback
|
||||
await session.agent.session.prompt(message);
|
||||
@@ -1137,39 +1206,38 @@ async function continueAgentConversation(session: Session, message: string): Pro
|
||||
return;
|
||||
}
|
||||
|
||||
if (parsed.type === "question") {
|
||||
session.currentQuestion = parsed.data;
|
||||
session.error = undefined;
|
||||
session.lastGeneratedThinking = session.thinkingOutput;
|
||||
session.updatedAt = new Date();
|
||||
persistSession(session, "awaiting_input");
|
||||
void maybeNotifyPlanningAwaitingInput(session, parsed.data);
|
||||
planningStreamManager.broadcast(session.id, {
|
||||
type: "question",
|
||||
data: parsed.data,
|
||||
});
|
||||
} 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, {
|
||||
type: "summary",
|
||||
data: parsed.data,
|
||||
});
|
||||
planningStreamManager.broadcast(session.id, { type: "complete" });
|
||||
}
|
||||
if (parsed.type === "question") {
|
||||
session.currentQuestion = parsed.data;
|
||||
session.error = undefined;
|
||||
session.lastGeneratedThinking = session.thinkingOutput;
|
||||
session.updatedAt = new Date();
|
||||
persistSession(session, "awaiting_input");
|
||||
void maybeNotifyPlanningAwaitingInput(session, parsed.data);
|
||||
planningStreamManager.broadcast(session.id, {
|
||||
type: "question",
|
||||
data: parsed.data,
|
||||
});
|
||||
} 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, {
|
||||
type: "summary",
|
||||
data: parsed.data,
|
||||
});
|
||||
planningStreamManager.broadcast(session.id, { type: "complete" });
|
||||
}
|
||||
});
|
||||
} catch (err) {
|
||||
if (err instanceof Error && err.name === "AbortError") {
|
||||
return;
|
||||
}
|
||||
|
||||
const errorMessage = err instanceof Error ? err.message : "AI processing failed";
|
||||
diagnostics.errorFromException("Agent conversation error for session", err, { sessionId: session.id, operation: "conversation" });
|
||||
session.error = errorMessage;
|
||||
session.updatedAt = new Date();
|
||||
persistSession(session, "error", errorMessage);
|
||||
planningStreamManager.broadcast(session.id, {
|
||||
type: "error",
|
||||
data: errorMessage,
|
||||
});
|
||||
setSessionError(session, errorMessage);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1452,6 +1520,32 @@ export async function retrySession(
|
||||
await continueAgentConversation(session, replayMessage);
|
||||
}
|
||||
|
||||
export function stopGeneration(sessionId: string): boolean {
|
||||
const session = sessions.get(sessionId);
|
||||
const activeGeneration = activeGenerations.get(sessionId);
|
||||
|
||||
if (!session || !activeGeneration) {
|
||||
return false;
|
||||
}
|
||||
|
||||
activeGeneration.abortController.abort();
|
||||
clearTimeout(activeGeneration.timer);
|
||||
activeGenerations.delete(sessionId);
|
||||
|
||||
if (session.agent) {
|
||||
nonfatal(
|
||||
() => session.agent?.session.dispose?.(),
|
||||
diagnostics,
|
||||
"Error disposing agent for stop-generation",
|
||||
{ sessionId, operation: "stop-generation-dispose" },
|
||||
);
|
||||
session.agent = undefined;
|
||||
}
|
||||
|
||||
setSessionError(session, "Generation stopped by user. You can retry or start a new session.");
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format user response as a message for the AI agent.
|
||||
*/
|
||||
@@ -1702,6 +1796,7 @@ export function __resetPlanningState(): void {
|
||||
sessions.clear();
|
||||
rateLimits.clear();
|
||||
planningStreamManager.reset();
|
||||
activeGenerations.clear();
|
||||
|
||||
if (_aiSessionStore && _aiSessionDeletedListener) {
|
||||
_aiSessionStore.off("ai_session:deleted", _aiSessionDeletedListener);
|
||||
|
||||
@@ -604,6 +604,40 @@ export function registerPlanningSubtaskRoutes(ctx: ApiRoutesContext, deps: Plann
|
||||
}
|
||||
});
|
||||
|
||||
router.post("/planning/:sessionId/stop", async (req, res) => {
|
||||
try {
|
||||
const { sessionId } = req.params;
|
||||
if (!sessionId || typeof sessionId !== "string") {
|
||||
throw badRequest("sessionId is required");
|
||||
}
|
||||
|
||||
const tabId = typeof req.body?.tabId === "string" && req.body.tabId.trim().length > 0
|
||||
? req.body.tabId.trim()
|
||||
: undefined;
|
||||
const lockCheck = checkSessionLock(sessionId, tabId, aiSessionStore);
|
||||
if (!lockCheck.allowed) {
|
||||
res.status(409).json({
|
||||
error: "Session locked by another tab",
|
||||
lockedByTab: lockCheck.currentHolder,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const { stopGeneration } = await import("../planning.js");
|
||||
const stopped = stopGeneration(sessionId);
|
||||
if (!stopped) {
|
||||
throw notFound(`Planning session ${sessionId} not found or expired`);
|
||||
}
|
||||
|
||||
res.json({ success: true });
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof ApiError) {
|
||||
throw err;
|
||||
}
|
||||
rethrowAsApiError(err, "Failed to stop planning session");
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/planning/cancel
|
||||
* Cancel and cleanup a planning session.
|
||||
@@ -1113,6 +1147,7 @@ export function registerPlanningSubtaskRoutes(ctx: ApiRoutesContext, deps: Plann
|
||||
"POST /planning/start-streaming",
|
||||
"POST /planning/respond",
|
||||
"POST /planning/:sessionId/retry",
|
||||
"POST /planning/:sessionId/stop",
|
||||
"POST /planning/cancel",
|
||||
"POST /planning/create-task",
|
||||
"POST /planning/start-breakdown",
|
||||
|
||||
Reference in New Issue
Block a user