fix(planning): restore stopped generation state

Track elapsed generation time per planning session and return users to the prior editable step when generation is stopped.
This commit is contained in:
gsxdsm
2026-07-21 00:32:27 -07:00
parent 1249086b32
commit 3c22c133e7
5 changed files with 168 additions and 31 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: Keep planning timers session-specific and return cleanly from stopped generations.
category: fix
dev: Persists each generation's start time and restores the prior planning step when stopped.

View File

@@ -811,16 +811,20 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
return;
}
const startedAt = Date.now();
setGenerationStartTime(startedAt);
setElapsedSeconds(0);
const startedAt = generationStartTime ?? Date.now();
if (generationStartTime === null) {
setGenerationStartTime(startedAt);
}
const timer = setInterval(() => {
const updateElapsed = () => {
setElapsedSeconds(Math.max(0, Math.floor((Date.now() - startedAt) / 1000)));
}, 1000);
};
updateElapsed();
const timer = setInterval(updateElapsed, 1000);
return () => clearInterval(timer);
}, [view.type]);
}, [generationStartTime, view.type]);
// Fallback for missed SSE 'question'/'summary' events: when the loading
// state lingers, periodically refetch the session and transition the view
@@ -1218,6 +1222,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
setIsRetrying(!options.auto);
setIsAutoRetrying(options.auto);
setStreamingOutput("");
setGenerationStartTime(Date.now());
setView({ type: "loading" });
currentSessionIdRef.current = retryTarget.sessionId;
@@ -1366,6 +1371,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
refineSummaryInFlightRef.current = false;
setGenerationActivity("initial_plan");
savePlanningDescription(startedPlan, projectId);
setGenerationStartTime(Date.now());
setView({ type: "loading" });
try {
@@ -1499,6 +1505,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
setIsRetrying(false);
setIsRefiningSummary(false);
refineSummaryInFlightRef.current = false;
setGenerationStartTime(null);
setView({ type: "loading" });
try {
@@ -1544,6 +1551,14 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
// An unavailable payload cannot provide a safe copy target.
}
setActivePlanPrompt(typeof inputPayload?.initialPlan === "string" ? inputPayload.initialPlan : "");
const persistedGenerationStartedAt = typeof inputPayload?.generationStartedAt === "string"
? Date.parse(inputPayload.generationStartedAt)
: Number.NaN;
setGenerationStartTime(
session.status === "generating" && Number.isFinite(persistedGenerationStartedAt)
? persistedGenerationStartedAt
: null,
);
if (inputPayload?.generationPurpose === "plan_update" || inputPayload?.generationPurpose === "question" || inputPayload?.generationPurpose === "initial_plan") {
setGenerationActivity(inputPayload.generationPurpose);
} else if (session.status === "generating") {
@@ -2250,6 +2265,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
setConversationHistory(optimisticHistory);
resetPlanningAutoRetryBudget();
setGenerationActivity("plan_update");
setGenerationStartTime(Date.now());
setView({ type: "loading" });
setStreamingOutput(""); // Clear old thinking output when entering loading state
liveGenerationSessionIdRef.current = sessionId;
@@ -2341,6 +2357,9 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
if (!sessionId) {
return;
}
const priorQuestion = workspaceQuestion;
const summary = runningSummaryRef.current;
const history = conversationHistoryRef.current;
try {
await stopPlanningGeneration(sessionId, projectId);
@@ -2354,13 +2373,31 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
setIsAutoRetrying(false);
setIsRefiningSummary(false);
refineSummaryInFlightRef.current = false;
setView({
type: "error",
session: { sessionId, currentQuestion: null, summary: null },
errorMessage: t("planning.generationStopped", "Generation stopped by user. You can retry or start a new session."),
});
setError(null);
setStreamingOutput("");
}, [projectId, t]);
setGenerationStartTime(null);
if (priorQuestion) {
const answered = history.some((entry) => entry.question?.id === priorQuestion.id && entry.response);
setEditingQuestionId(answered ? priorQuestion.id : null);
setWorkspaceQuestion(priorQuestion);
setView({
type: "question",
session: { sessionId, currentQuestion: priorQuestion, summary },
});
} else if (summary) {
setWorkspaceQuestion(null);
setView({
type: "plan_review",
session: { sessionId, currentQuestion: null, summary },
summary,
});
} else {
draftSessionIdRef.current = sessionId;
setInitialPlan(_activePlanPrompt);
setView({ type: "initial" });
}
}, [_activePlanPrompt, projectId, workspaceQuestion]);
const handleRetryFromError = useCallback(async () => {
if (view.type !== "error") {
@@ -2383,6 +2420,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
setError(null);
setGenerationActivity("question");
setIsRefineMenuOpen(false);
setGenerationStartTime(Date.now());
setView({ type: "loading" });
try {
const response = await respondToPlanning(sessionId, { refine: true, focus: refinementInstructions }, projectId);

View File

@@ -1,7 +1,7 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { PlanningModeModal } from "../PlanningModeModal";
import { mockCreatePlanningDraft, mockFetchAiSession, mockFetchAiSessions, mockRespondToPlanning, mockStartPlanningStreaming, mockValidatePlanningSession, mockCreateTaskFromPlanning, mockTasks, mockSummary } from "./PlanningModeModal.test-helpers";
import { mockCreatePlanningDraft, mockFetchAiSession, mockFetchAiSessions, mockRespondToPlanning, mockStartPlanningStreaming, mockStopPlanningGeneration, mockValidatePlanningSession, mockCreateTaskFromPlanning, mockTasks, mockSummary } from "./PlanningModeModal.test-helpers";
const mockViewportMode = vi.hoisted(() => vi.fn(() => "desktop" as "desktop" | "mobile"));
const mockConnectPlanningStream = vi.hoisted(() => vi.fn());
@@ -24,7 +24,7 @@ vi.mock("../../api", () => {
fetchAiSession: (...args: unknown[]) => mockFetchAiSession(...args), fetchAiSessions: (...args: unknown[]) => mockFetchAiSessions(...args),
respondToPlanning: (...args: unknown[]) => mockRespondToPlanning(...args), validatePlanningSession: (...args: unknown[]) => mockValidatePlanningSession(...args), createTaskFromPlanning: (...args: unknown[]) => mockCreateTaskFromPlanning(...args),
fetchSettings: fn().mockResolvedValue({ modelPresets: [], autoSelectModelPreset: false, defaultPresetBySize: {} }), fetchGlobalSettings: fn().mockResolvedValue({}), fetchModels: fn().mockResolvedValue([]), fetchWorkflowSteps: fn().mockResolvedValue([]), fetchBoardWorkflows: fn().mockResolvedValue({ workflows: [] }),
startPlanning: fn(), startPlanningStreaming: (...args: unknown[]) => mockStartPlanningStreaming(...args), createPlanningDraft: (...args: unknown[]) => mockCreatePlanningDraft(...args), connectPlanningStream: (...args: unknown[]) => mockConnectPlanningStream(...args), rewindPlanningSession: fn(), retryPlanningSession: fn(), cancelPlanning: fn(), stopPlanningGeneration: fn(), updatePlanningSessionDraft: fn(), updatePlanningSessionTitle: fn(), startPlanningBreakdown: fn(), createTasksFromPlanning: fn(), parseConversationHistory: (raw: string) => JSON.parse(raw || "[]"), acquireSessionLock: fn(), releaseSessionLock: fn(), forceAcquireSessionLock: fn(), uploadAttachment: fn(), deleteAttachment: fn(), updateTask: fn(), pauseTask: fn(), unpauseTask: fn(), fetchTaskDetail: fn(), requestSpecRevision: fn(), approvePlan: fn(), rejectPlan: fn(), refineTask: fn(), deleteAiSession: fn(), refineText: fn(), getRefineErrorMessage: (error: Error) => error.message,
startPlanning: fn(), startPlanningStreaming: (...args: unknown[]) => mockStartPlanningStreaming(...args), createPlanningDraft: (...args: unknown[]) => mockCreatePlanningDraft(...args), connectPlanningStream: (...args: unknown[]) => mockConnectPlanningStream(...args), rewindPlanningSession: fn(), retryPlanningSession: fn(), cancelPlanning: fn(), stopPlanningGeneration: (...args: unknown[]) => mockStopPlanningGeneration(...args), updatePlanningSessionDraft: fn(), updatePlanningSessionTitle: fn(), startPlanningBreakdown: fn(), createTasksFromPlanning: fn(), parseConversationHistory: (raw: string) => JSON.parse(raw || "[]"), acquireSessionLock: fn(), releaseSessionLock: fn(), forceAcquireSessionLock: fn(), uploadAttachment: fn(), deleteAttachment: fn(), updateTask: fn(), pauseTask: fn(), unpauseTask: fn(), fetchTaskDetail: fn(), requestSpecRevision: fn(), approvePlan: fn(), rejectPlan: fn(), refineTask: fn(), deleteAiSession: fn(), refineText: fn(), getRefineErrorMessage: (error: Error) => error.message,
};
});
@@ -39,7 +39,7 @@ const summaryWithRefinements = {
};
describe("PlanningModeModal sequential flow", () => {
beforeEach(() => { vi.clearAllMocks(); localStorage.clear(); mockPlanningSse.events = null; mockViewportMode.mockReturnValue("desktop"); mockFetchAiSessions.mockResolvedValue([]); mockCreatePlanningDraft.mockResolvedValue({ sessionId: "draft-1", title: "Secure plan" }); mockStartPlanningStreaming.mockResolvedValue({ sessionId: "draft-1" }); mockValidatePlanningSession.mockResolvedValue({ summary: mockSummary, validated: true }); mockCreateTaskFromPlanning.mockResolvedValue({ id: "FN-8442" }); });
beforeEach(() => { vi.clearAllMocks(); localStorage.clear(); mockPlanningSse.events = null; mockViewportMode.mockReturnValue("desktop"); mockFetchAiSessions.mockResolvedValue([]); mockCreatePlanningDraft.mockResolvedValue({ sessionId: "draft-1", title: "Secure plan" }); mockStartPlanningStreaming.mockResolvedValue({ sessionId: "draft-1" }); mockStopPlanningGeneration.mockResolvedValue({ success: true }); mockValidatePlanningSession.mockResolvedValue({ summary: mockSummary, validated: true }); mockCreateTaskFromPlanning.mockResolvedValue({ id: "FN-8442" }); });
it("persists a draft before generation and immediately shows initial-plan progress", async () => {
render(<PlanningModeModal isOpen onClose={vi.fn()} onTaskCreated={vi.fn()} onTasksCreated={vi.fn()} tasks={mockTasks} projectId="project-1" />);
fireEvent.change(screen.getByLabelText("What do you want to build?"), { target: { value: "Build secure accounts" } });
@@ -357,6 +357,49 @@ describe("PlanningModeModal sequential flow", () => {
await waitFor(() => expect(mockConnectPlanningStream).toHaveBeenCalledTimes(1));
expect(mockConnectPlanningStream).toHaveBeenCalledWith("session-1", "project-1", expect.any(Object));
});
it("keeps elapsed thinking time scoped to each generating session", async () => {
const now = Date.parse("2026-07-21T08:00:30.000Z");
const dateNow = vi.spyOn(Date, "now").mockReturnValue(now);
mockFetchAiSession.mockImplementation(async (sessionId: string) => ({
...base,
id: sessionId,
status: "generating",
currentQuestion: null,
result: JSON.stringify(summaryWithRefinements),
inputPayload: JSON.stringify({
generationPurpose: "plan_update",
generationStartedAt: new Date(now - (sessionId === "session-1" ? 25_000 : 7_000)).toISOString(),
}),
}));
const props = { isOpen: true, onClose: vi.fn(), onTaskCreated: vi.fn(), onTasksCreated: vi.fn(), tasks: mockTasks, projectId: "project-1" };
const { rerender } = render(<PlanningModeModal {...props} resumeSessionId="session-1" />);
expect(await screen.findByText("Thinking… (25s)")).toBeInTheDocument();
rerender(<PlanningModeModal {...props} resumeSessionId="session-2" />);
expect(await screen.findByText("Thinking… (7s)")).toBeInTheDocument();
dateNow.mockRestore();
});
it("returns to the prior question without an error when generation is stopped", async () => {
const priorQuestion = { id: "q-prior", type: "text", question: "What should change?" };
mockFetchAiSession.mockResolvedValue({
...base,
status: "generating",
currentQuestion: null,
result: JSON.stringify(summaryWithRefinements),
conversationHistory: JSON.stringify([{ question: priorQuestion, response: { "q-prior": "Preserve drafts" } }]),
inputPayload: JSON.stringify({ generationPurpose: "plan_update", generationStartedAt: new Date().toISOString() }),
});
renderSession({});
await waitFor(() => expect(mockConnectPlanningStream).toHaveBeenCalledWith("session-1", "project-1", expect.any(Object)));
fireEvent.click(await screen.findByRole("button", { name: "Stop" }));
await waitFor(() => expect(mockStopPlanningGeneration).toHaveBeenCalledWith("session-1", "project-1"));
expect(await screen.findByText("What should change?")).toBeInTheDocument();
expect(screen.getByPlaceholderText("Type your answer here...")).toHaveValue("Preserve drafts");
expect(screen.queryByText(/Generation stopped by user/i)).toBeNull();
});
it("renders exactly one write-your-own choice for normalized select questions", async () => {
mockFetchAiSession.mockResolvedValue({
...base,

View File

@@ -28,6 +28,7 @@ import {
const MOCK_TASK_STORE = {
listTasks: vi.fn(async () => []),
getSettings: vi.fn(async () => ({})),
getTask: vi.fn(async () => {
throw new Error("not found");
}),
@@ -74,7 +75,7 @@ describe("planning generation cancellation", () => {
expect(promptSignal?.aborted).toBe(true);
expect(dispose).toHaveBeenCalledTimes(1);
expect((await getSession(sessionId))?.error).toMatch(/stopped by user/i);
expect((await getSession(sessionId))?.error).toBeUndefined();
resolveHungPrompt?.();
await new Promise((resolve) => setTimeout(resolve, 0));

View File

@@ -304,6 +304,8 @@ export const DRAFT_PLACEHOLDER_TITLE = "New planning session";
export interface DraftInputPayload {
initialPlan?: string;
generationPurpose?: "initial_plan" | "plan_update" | "question";
generationStartedAt?: string;
generationReturnQuestion?: PlanningQuestion;
clarificationEnabled?: boolean;
lastMailboxNotifiedQuestionKey?: string;
modelProvider?: string;
@@ -341,7 +343,6 @@ export const GENERATION_LOOP_REPEAT_LIMIT = 8;
const PLANNING_STUCK_ERROR_MESSAGE = "AI generation appears stuck with no new output. You can retry or start a new session.";
const PLANNING_LOOP_ERROR_MESSAGE = "AI generation appears stuck repeating the same output. You can retry or start a new session.";
const PLANNING_USER_STOP_ERROR_MESSAGE = "Generation stopped by user. You can retry or start a new session.";
// ── Types ───────────────────────────────────────────────────────────────────
@@ -401,6 +402,10 @@ interface Session {
claimStartedAt?: string;
/** Whether the current generation must end at plan review rather than a question. */
generationPurpose?: "initial_plan" | "plan_update" | "question";
/** Durable start time for the active turn so each concurrent session owns its elapsed clock. */
generationStartedAt?: string;
/** Question restored when the user stops the active turn. */
generationReturnQuestion?: PlanningQuestion;
/** Last terminal error for retry UX */
error?: string;
/** AI agent session for real-time interaction */
@@ -612,6 +617,8 @@ function persistSession(session: Session, status: "generating" | "awaiting_input
? { clarificationEnabled: session.clarificationEnabled }
: {}),
...(session.generationPurpose ? { generationPurpose: session.generationPurpose } : {}),
...(session.generationStartedAt ? { generationStartedAt: session.generationStartedAt } : {}),
...(session.generationReturnQuestion ? { generationReturnQuestion: session.generationReturnQuestion } : {}),
...(session.lastMailboxNotifiedQuestionKey ? { lastMailboxNotifiedQuestionKey: session.lastMailboxNotifiedQuestionKey } : {}),
}),
conversationHistory: JSON.stringify(session.history),
@@ -676,6 +683,21 @@ function unpersistSession(sessionId: string): Promise<void> {
return queued;
}
/*
FNXC:PlanningMode 2026-07-21-00:25:
Each active planning turn owns a durable clock and return point. A modal-level Date.now()
clock makes concurrent sessions appear synchronized, while clearing the question before
generation leaves Stop with nowhere safe to return. Persist both at the turn boundary.
*/
function beginPlanningGeneration(
session: Session,
purpose: NonNullable<Session["generationPurpose"]>,
): void {
session.generationPurpose = purpose;
session.generationStartedAt = new Date().toISOString();
session.generationReturnQuestion = session.currentQuestion ?? session.generationReturnQuestion;
}
/** Release in-memory planning runtime state while keeping persisted history. */
export function releaseSession(sessionId: string): void {
cleanupInMemorySession(sessionId);
@@ -752,6 +774,12 @@ function buildSessionFromRow(row: AiSessionRow): Session {
|| payload.generationPurpose === "question"
? payload.generationPurpose
: undefined,
generationStartedAt: typeof payload.generationStartedAt === "string"
? payload.generationStartedAt
: undefined,
generationReturnQuestion: payload.generationReturnQuestion && typeof payload.generationReturnQuestion === "object"
? normalizePlanningQuestion(payload.generationReturnQuestion, payload.initialPlan ?? row.title)
: undefined,
lastMailboxNotifiedQuestionKey: typeof payload.lastMailboxNotifiedQuestionKey === "string"
? payload.lastMailboxNotifiedQuestionKey
: undefined,
@@ -1083,6 +1111,7 @@ export async function createSession(
};
sessions.set(sessionId, session);
beginPlanningGeneration(session, "initial_plan");
persistSession(session, "generating");
const systemPrompt = await resolvePlanningModeSystemPrompt(store, promptOverrides, session.workflowId);
@@ -1588,7 +1617,7 @@ export async function startExistingSession(
session.ntfyConfig = runtimeOptions.ntfyConfig;
session.messageStore = runtimeOptions.messageStore;
}
session.generationPurpose = "initial_plan";
beginPlanningGeneration(session, "initial_plan");
await persistSession(session, "generating");
planningStreamManager.registerInitialTurn(sessionId, () => {
session.pluginRunner = pluginRunner;
@@ -1672,7 +1701,7 @@ export async function createSessionWithAgent(
};
sessions.set(sessionId, session);
session.generationPurpose = "initial_plan";
beginPlanningGeneration(session, "initial_plan");
await persistSession(session, "generating");
planningStreamManager.registerInitialTurn(sessionId, () => {
@@ -2104,14 +2133,6 @@ async function runGenerationWithTimeout<T>(session: Session, operation: (abortSi
try {
return await Promise.race([operation(abortController.signal), abortPromise]);
} catch (error) {
if (error instanceof Error && error.name === "AbortError") {
const reason = generationRecord.abortReason;
if (reason === "user-stop" && !session.error) {
setSessionError(session, PLANNING_USER_STOP_ERROR_MESSAGE);
}
}
throw error;
} finally {
clearTimeout(generationRecord.timer);
if (activeGenerations.get(session.id) === generationRecord) {
@@ -2480,6 +2501,8 @@ async function continueAgentConversation(session: Session, message: string): Pro
session.lastGeneratedThinking = session.thinkingOutput;
session.updatedAt = new Date();
session.generationPurpose = undefined;
session.generationStartedAt = undefined;
session.generationReturnQuestion = undefined;
session.currentQuestion = coerceQuestionResponse(parsed, session);
await persistSession(session, "awaiting_input");
planningStreamManager.broadcast(session.id, { type: "summary", data: session.summary });
@@ -2835,8 +2858,8 @@ export async function submitResponse(
if (isRefineRequest(responses) && session.summary) {
// Refinement steers which question comes next; it is never an answer to the
// currently displayed question and therefore must not create a history entry.
beginPlanningGeneration(session, "question");
session.currentQuestion = undefined;
session.generationPurpose = "question";
session.error = undefined;
await persistSession(session, "generating");
@@ -2877,8 +2900,8 @@ export async function submitResponse(
// Clear the answered question while generation is active so reconnects cannot replay it.
// The completed turn persists and broadcasts exactly one newly generated question.
beginPlanningGeneration(session, "plan_update");
session.currentQuestion = undefined;
session.generationPurpose = "plan_update";
await persistSession(session, "generating");
if (!session.agent) {
// An edited older answer must be replayed in its original position with every
@@ -2962,7 +2985,7 @@ export async function retrySession(
*/
session.currentQuestion = undefined;
session.updatedAt = new Date();
session.generationPurpose = session.history.length === 0 ? "initial_plan" : "plan_update";
beginPlanningGeneration(session, session.history.length === 0 ? "initial_plan" : "plan_update");
await persistSession(session, "generating");
if (session.history.length === 0) {
@@ -3058,7 +3081,32 @@ export function stopGeneration(sessionId: string): boolean {
activeGeneration.abortController.abort();
activeGenerations.delete(sessionId);
setSessionError(session, PLANNING_USER_STOP_ERROR_MESSAGE);
const returnQuestion = session.generationReturnQuestion;
const stoppedPurpose = session.generationPurpose;
session.error = undefined;
session.thinkingOutput = "";
session.generationPurpose = undefined;
session.generationStartedAt = undefined;
session.generationReturnQuestion = undefined;
session.updatedAt = new Date();
if (returnQuestion) {
session.currentQuestion = returnQuestion;
session.editingQuestionId = session.history.some((entry) => entry.question.id === returnQuestion.id)
? returnQuestion.id
: undefined;
session.summary = buildRunningSummary(session.initialPlan, session.history, session.summary);
void persistSession(session, "awaiting_input");
planningStreamManager.broadcast(session.id, { type: "summary", data: session.summary });
planningStreamManager.broadcast(session.id, { type: "question", data: returnQuestion });
} else {
session.currentQuestion = undefined;
session.editingQuestionId = undefined;
void persistSession(session, stoppedPurpose === "initial_plan" ? "draft" : "awaiting_input");
if (session.summary && stoppedPurpose !== "initial_plan") {
planningStreamManager.broadcast(session.id, { type: "summary", data: session.summary });
}
}
return true;
}