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:
7
.changeset/fix-planning-session-timers.md
Normal file
7
.changeset/fix-planning-session-timers.md
Normal 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.
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user