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:
@@ -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 {
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user