feat(FN-3447): add planning session rewind capability with modal back actio

This merge delivers four major features: a planning session rewind system (FN-3447, steps 1–4) with a new backend route for rolling back sessions, modal back-action wiring, and updated typing; workspace verification gates (FN-3385) for agent prompt editing; an agents view org chart spacing rework (F

Fusion-Task-Id: FN-3447
This commit is contained in:
Fusion
2026-05-04 19:56:48 -07:00
committed by gsxdsm
parent 79bf77bd5e
commit cb3ab98e70
9 changed files with 261 additions and 10 deletions

View File

@@ -2704,6 +2704,21 @@ export function respondToPlanning(
});
}
/** Rewind a planning session to the previous answered question */
export function rewindPlanningSession(
sessionId: string,
projectId?: string,
tabId?: string,
): Promise<{ currentQuestion: PlanningQuestion; history: Array<{ question: PlanningQuestion; response: unknown; thinkingOutput?: string }> }> {
return api<{ currentQuestion: PlanningQuestion; history: Array<{ question: PlanningQuestion; response: unknown; thinkingOutput?: string }> }>(
withProjectId(`/planning/${encodeURIComponent(sessionId)}/back`, projectId),
{
method: "POST",
...(tabId ? { body: JSON.stringify({ tabId }) } : {}),
},
);
}
/** Retry a failed planning session turn */
export function retryPlanningSession(
sessionId: string,
@@ -5308,6 +5323,7 @@ export interface ProjectCreateInput {
}
export type DockerNodeConfigInfo = DockerNodeConfig;
export type { DockerNodeConfig };
/** Node information returned by node endpoints */
export interface NodeInfo {

View File

@@ -6,6 +6,7 @@ import {
startPlanningStreaming,
createPlanningDraft,
respondToPlanning,
rewindPlanningSession,
retryPlanningSession,
createTaskFromPlanning,
connectPlanningStream,
@@ -1499,16 +1500,45 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
}
}, [broadcastCompleted, handleClose, view, onTasksCreated, projectId]);
const handleBack = useCallback(() => {
if (view.type === "question" && responseHistory.length > 0) {
// Remove last response and go back
const previousResponses = responseHistory.slice(0, -1);
setResponseHistory(previousResponses);
// Note: We don't actually have a way to go back in the backend,
// so we just reset to the question from the initial session
const handleBack = useCallback(async () => {
if (view.type !== "question" || responseHistory.length === 0) {
return;
}
const sessionId = view.session.sessionId;
setError(null);
setView({ type: "loading" });
try {
const rewound = await rewindPlanningSession(sessionId, projectId, sessionTabId);
setResponseHistory(rewound.history.map((entry) => {
if (entry.response && typeof entry.response === "object" && !Array.isArray(entry.response)) {
return entry.response as QuestionResponse;
}
return { [entry.question.id]: entry.response };
}));
setConversationHistory(rewound.history.map((entry) => ({
question: entry.question,
response:
entry.response && typeof entry.response === "object" && !Array.isArray(entry.response)
? (entry.response as Record<string, unknown>)
: { [entry.question.id]: entry.response },
thinkingOutput: entry.thinkingOutput,
})));
setStreamingOutput("");
setView({
type: "question",
session: {
...view.session,
currentQuestion: rewound.currentQuestion,
summary: null,
},
});
} catch (err) {
setError(getErrorMessage(err) || "Failed to go back to the previous question");
setView({ type: "question", session: view.session });
}
}, [view, responseHistory]);
}, [projectId, responseHistory.length, sessionTabId, view]);
const getProgress = () => {
if (view.type === "question") {

View File

@@ -12,6 +12,7 @@ import {
mockCreatePlanningDraft,
mockConnectPlanningStream,
mockRespondToPlanning,
mockRewindPlanningSession,
mockRetryPlanningSession,
mockCancelPlanning,
mockStopPlanningGeneration,
@@ -55,8 +56,8 @@ vi.mock("../../api", () => ({
createPlanningDraft: (...args: any[]) => mockCreatePlanningDraft(...args),
connectPlanningStream: (...args: any[]) => mockConnectPlanningStream(...args),
respondToPlanning: (...args: any[]) => mockRespondToPlanning(...args),
retryPlanningSession: (...args: any[]) => mockRetryPlanningSession(...args),
cancelPlanning: (...args: any[]) => mockCancelPlanning(...args),
rewindPlanningSession: (...args: any[]) => mockRewindPlanningSession(...args),
retryPlanningSession: (...args: any[]) => mockRetryPlanningSession(...args), cancelPlanning: (...args: any[]) => mockCancelPlanning(...args),
stopPlanningGeneration: (...args: any[]) => mockStopPlanningGeneration(...args),
updatePlanningSessionDraft: (...args: any[]) => mockUpdatePlanningSessionDraft(...args),
createTaskFromPlanning: (...args: any[]) => mockCreateTaskFromPlanning(...args),
@@ -121,6 +122,7 @@ describe("PlanningModeModal", () => {
// the sidebar render rule (preview while title === placeholder) behaves
// realistically in tests.
mockCreatePlanningDraft.mockResolvedValue({ sessionId: "draft-123", title: "New planning session" });
mockRewindPlanningSession.mockResolvedValue({ currentQuestion: mockQuestion, history: [] });
mockRetryPlanningSession.mockResolvedValue({ success: true, sessionId: "session-123" });
mockStartPlanningBreakdown.mockResolvedValue({ sessionId: "session-123", subtasks: [] });
mockFetchAiSession.mockResolvedValue(null);
@@ -583,6 +585,66 @@ describe("PlanningModeModal", () => {
expect(screen.queryByPlaceholderText("Add any extra context or direction...")).not.toBeInTheDocument();
});
it("rewinds to the previous question when Back is clicked", async () => {
let streamHandlers: any;
const secondQuestion: PlanningQuestion = {
id: "q-requirements",
type: "text",
question: "What are the key requirements?",
};
mockConnectPlanningStream.mockImplementationOnce((_sessionId: string, _projectId: string | undefined, handlers: any) => {
streamHandlers = handlers;
setTimeout(() => {
handlers.onQuestion?.(mockQuestion);
}, 10);
return {
close: vi.fn(),
isConnected: vi.fn().mockReturnValue(true),
};
});
mockRespondToPlanning.mockImplementationOnce(async () => {
setTimeout(() => {
streamHandlers?.onQuestion?.(secondQuestion);
}, 10);
return { type: "question", data: secondQuestion };
});
mockRewindPlanningSession.mockResolvedValueOnce({
currentQuestion: mockQuestion,
history: [],
});
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 screen.findByText("What is the scope?");
fireEvent.click(screen.getByText("Medium"));
fireEvent.click(screen.getByRole("button", { name: "Continue" }));
await screen.findByText("What are the key requirements?");
fireEvent.click(screen.getByRole("button", { name: "Back" }));
await waitFor(() => {
expect(mockRewindPlanningSession).toHaveBeenCalledWith("session-123", undefined, expect.any(String));
});
expect(await screen.findByText("What is the scope?")).toBeInTheDocument();
expect(screen.queryByText("What are the key requirements?")).toBeNull();
});
it("includes _comment in response when comment is filled", async () => {
render(
<PlanningModeModal

View File

@@ -6,6 +6,7 @@ export const mockStartPlanningStreaming = vi.fn();
export const mockCreatePlanningDraft = vi.fn();
export const mockConnectPlanningStream = vi.fn();
export const mockRespondToPlanning = vi.fn();
export const mockRewindPlanningSession = vi.fn();
export const mockRetryPlanningSession = vi.fn();
export const mockCancelPlanning = vi.fn();
export const mockStopPlanningGeneration = vi.fn();

View File

@@ -13,6 +13,7 @@ import {
startExistingSession,
submitResponse,
retrySession,
rewindSession,
cancelSession,
stopGeneration,
getSession,
@@ -997,6 +998,26 @@ describe("planning module", () => {
});
});
describe("rewindSession", () => {
it("rewinds to the previous question and trims history", async () => {
const { sessionId } = await createSession(getUniqueIp(), initialPlan, undefined, TEST_ROOT_DIR);
await submitResponse(sessionId, { "q-scope": "medium" }, TEST_ROOT_DIR);
const rewound = await rewindSession(sessionId, TEST_ROOT_DIR);
expect(rewound.currentQuestion.id).toBe("q-scope");
expect(rewound.history).toHaveLength(0);
const session = getSession(sessionId);
expect(session?.currentQuestion?.id).toBe("q-scope");
expect(session?.history).toHaveLength(0);
});
it("throws when no answered question exists", async () => {
const { sessionId } = await createSession(getUniqueIp(), initialPlan, undefined, TEST_ROOT_DIR);
await expect(rewindSession(sessionId, TEST_ROOT_DIR)).rejects.toThrow(InvalidSessionStateError);
});
});
describe("retrySession", () => {
it("rehydrates errored sessions and replays the last user response", async () => {
const store = new MockAiSessionStore();

View File

@@ -1031,6 +1031,37 @@ describe("Planning Mode Routes", () => {
});
});
describe("POST /planning/:sessionId/back", () => {
it("rewinds an active planning session", async () => {
const rewindSpy = vi.spyOn(planningModule, "rewindSession").mockResolvedValue({
currentQuestion: {
id: "q-scope",
type: "single_select",
question: "What is the scope of this plan?",
options: [],
},
history: [],
});
const res = await REQUEST(buildApp(), "POST", "/api/planning/session-123/back");
expect(res.status).toBe(200);
expect(res.body.currentQuestion.id).toBe("q-scope");
expect(rewindSpy).toHaveBeenCalledWith("session-123", expect.any(String), undefined);
});
it("returns 400 when there is no previous question", async () => {
vi.spyOn(planningModule, "rewindSession").mockRejectedValueOnce(
new planningModule.InvalidSessionStateError("Planning session has no previous question to rewind to"),
);
const res = await REQUEST(buildApp(), "POST", "/api/planning/session-400/back");
expect(res.status).toBe(400);
expect(res.body.error).toContain("no previous question");
});
});
describe("POST /planning/:sessionId/retry", () => {
it("retries a failed planning session", async () => {
const retrySpy = vi.spyOn(planningModule, "retrySession").mockResolvedValue();

View File

@@ -1923,6 +1923,52 @@ export async function retrySession(
await continueAgentConversation(session, replayMessage);
}
export interface PlanningRewindResult {
currentQuestion: PlanningQuestion;
history: PlanningHistoryEntry[];
}
export async function rewindSession(
sessionId: string,
rootDir?: string,
promptOverrides?: PromptOverrideMap,
): Promise<PlanningRewindResult> {
const session = getSession(sessionId);
if (!session) {
throw new SessionNotFoundError(`Planning session ${sessionId} not found or expired`);
}
if (session.history.length === 0) {
throw new InvalidSessionStateError("Planning session has no previous question to rewind to");
}
const rewindEntry = session.history.pop();
if (!rewindEntry) {
throw new InvalidSessionStateError("Planning session has no previous question to rewind to");
}
disposeSessionAgentForRetry(session);
session.currentQuestion = rewindEntry.question;
session.summary = undefined;
session.error = undefined;
session.lastGeneratedThinking = session.history[session.history.length - 1]?.thinkingOutput ?? "";
session.thinkingOutput = "";
session.updatedAt = new Date();
if (!session.agent && rootDir) {
await ensureSessionAgent(session, rootDir, session.history, promptOverrides);
}
persistSession(session, "awaiting_input");
planningStreamManager.broadcast(session.id, { type: "question", data: rewindEntry.question });
return {
currentQuestion: rewindEntry.question,
history: [...session.history],
};
}
export function stopGeneration(sessionId: string): boolean {
const session = sessions.get(sessionId);
const activeGeneration = activeGenerations.get(sessionId);

View File

@@ -728,6 +728,48 @@ export function registerPlanningSubtaskRoutes(ctx: ApiRoutesContext, deps: Plann
}
});
router.post("/planning/:sessionId/back", 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 { store: scopedStore } = await getProjectContext(req);
const settings = await scopedStore.getSettings();
const { rewindSession } = await import("../planning.js");
const rewound = await rewindSession(
sessionId,
scopedStore.getRootDir(),
settings.promptOverrides,
);
res.json(rewound);
} catch (err: unknown) {
if (err instanceof ApiError) {
throw err;
}
if (err instanceof Error && err.name === "SessionNotFoundError") {
throw notFound(err instanceof Error ? err.message : String(err));
} else if (err instanceof Error && err.name === "InvalidSessionStateError") {
throw badRequest(err instanceof Error ? err.message : String(err));
} else {
rethrowAsApiError(err, "Failed to rewind planning session");
}
}
});
/**
* POST /api/planning/:sessionId/retry
* Retry a failed planning session.
@@ -1317,6 +1359,7 @@ export function registerPlanningSubtaskRoutes(ctx: ApiRoutesContext, deps: Plann
"POST /planning/start",
"POST /planning/start-streaming",
"POST /planning/respond",
"POST /planning/:sessionId/back",
"POST /planning/:sessionId/retry",
"POST /planning/:sessionId/stop",
"POST /planning/cancel",