feat(FN-3274): preserve first-turn reasoning in planning modal
This merge introduces visibility of the AI's first-turn reasoning in the planning modal (FN-3274), with corresponding regression tests, and removes all PI (Prompt Intelligence) install references and documentation from the codebase (FN-3309). Fusion-Task-Id: FN-3274
This commit is contained in:
@@ -172,6 +172,9 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
|
||||
// target is the overlay and would dismiss the modal mid-resize.
|
||||
const overlayMouseDownOnSelfRef = useRef(false);
|
||||
const thinkingOutputRef = useRef<HTMLDivElement>(null);
|
||||
// Mirrors `streamingOutput` state for reading inside callbacks without
|
||||
// stale closure issues (e.g. capturing reasoning before onQuestion clears it).
|
||||
const streamingOutputRef = useRef<string>("" );
|
||||
const draftSyncTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const lastSyncedDraftRef = useRef<{
|
||||
sessionId: string;
|
||||
@@ -183,6 +186,12 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
|
||||
useModalResizePersist(modalRef, isOpen, "fusion:planning-modal-size");
|
||||
const viewportMode = useViewportMode();
|
||||
|
||||
// Mirror streamingOutput into a ref so SSE handlers can read the latest
|
||||
// value without stale closure issues.
|
||||
useEffect(() => {
|
||||
streamingOutputRef.current = streamingOutput;
|
||||
}, [streamingOutput]);
|
||||
|
||||
// Keep the streaming AI thinking pane pinned to the bottom as new tokens
|
||||
// arrive. If the user has scrolled up to read earlier output, we leave the
|
||||
// scroll position alone — only auto-follow when they're already near the
|
||||
@@ -375,6 +384,23 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
|
||||
setIsReconnecting(false);
|
||||
setIsRetrying(false);
|
||||
clearPlanningDescription(projectId);
|
||||
|
||||
// Preserve reasoning accumulated during the loading turn as a
|
||||
// visible conversation-history entry so the user can expand it
|
||||
// from the question view. Without this, setStreamingOutput("")
|
||||
// would silently discard everything the model produced before the
|
||||
// first question arrived.
|
||||
const capturedThinking = streamingOutputRef.current.trim();
|
||||
if (capturedThinking) {
|
||||
setConversationHistory((prev) => {
|
||||
// De-duplicate: if the last entry already carries this exact
|
||||
// thinking text (e.g. from a prior transition or resume), skip.
|
||||
const lastEntry = prev[prev.length - 1];
|
||||
if (lastEntry?.thinkingOutput === capturedThinking) return prev;
|
||||
return [...prev, { thinkingOutput: capturedThinking }];
|
||||
});
|
||||
}
|
||||
|
||||
setView({
|
||||
type: "question",
|
||||
session: { sessionId, currentQuestion: question, summary: null },
|
||||
@@ -396,6 +422,17 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
|
||||
setIsReconnecting(false);
|
||||
setIsRetrying(false);
|
||||
clearPlanningDescription(projectId);
|
||||
|
||||
// Preserve reasoning accumulated during the loading turn.
|
||||
const capturedThinking = streamingOutputRef.current.trim();
|
||||
if (capturedThinking) {
|
||||
setConversationHistory((prev) => {
|
||||
const lastEntry = prev[prev.length - 1];
|
||||
if (lastEntry?.thinkingOutput === capturedThinking) return prev;
|
||||
return [...prev, { thinkingOutput: capturedThinking }];
|
||||
});
|
||||
}
|
||||
|
||||
setView({
|
||||
type: "summary",
|
||||
session: { sessionId, currentQuestion: null, summary },
|
||||
@@ -661,7 +698,20 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
|
||||
clearPlanningDescription(projectId);
|
||||
const question = JSON.parse(session.currentQuestion);
|
||||
setView({ type: "question", session: { sessionId, currentQuestion: question, summary: null } });
|
||||
if (session.thinkingOutput) setStreamingOutput(session.thinkingOutput);
|
||||
// Transfer persisted thinking into conversation history so it's
|
||||
// visible as expandable reasoning in the question view, instead of
|
||||
// setting streamingOutput which is only rendered in the loading
|
||||
// state.
|
||||
if (session.thinkingOutput) {
|
||||
const trimmed = session.thinkingOutput.trim();
|
||||
if (trimmed) {
|
||||
setConversationHistory((prev) => {
|
||||
const lastEntry = prev[prev.length - 1];
|
||||
if (lastEntry?.thinkingOutput === trimmed) return prev;
|
||||
return [...prev, { thinkingOutput: trimmed }];
|
||||
});
|
||||
}
|
||||
}
|
||||
connectToPlanningStream(sessionId);
|
||||
} else if (session.status === "complete" && session.result) {
|
||||
clearPlanningDescription(projectId);
|
||||
@@ -1174,13 +1224,25 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
|
||||
// the frontend disconnects and reconnects after the API call.
|
||||
|
||||
setResponseHistory((prev) => [...prev, responses]);
|
||||
setConversationHistory((prev) => [
|
||||
...prev,
|
||||
{
|
||||
question: activeQuestion,
|
||||
response: responses,
|
||||
},
|
||||
]);
|
||||
setConversationHistory((prev) => {
|
||||
// Capture any reasoning that accumulated since the last question
|
||||
// (e.g. thinking streamed while the user was reading the question).
|
||||
const currentThinking = streamingOutputRef.current.trim();
|
||||
let updated = prev;
|
||||
if (currentThinking) {
|
||||
const lastEntry = updated[updated.length - 1];
|
||||
if (lastEntry?.thinkingOutput !== currentThinking) {
|
||||
updated = [...updated, { thinkingOutput: currentThinking }];
|
||||
}
|
||||
}
|
||||
return [
|
||||
...updated,
|
||||
{
|
||||
question: activeQuestion,
|
||||
response: responses,
|
||||
},
|
||||
];
|
||||
});
|
||||
setView({ type: "loading" });
|
||||
setStreamingOutput(""); // Clear old thinking output when entering loading state
|
||||
|
||||
@@ -1264,6 +1326,16 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
|
||||
type: "question",
|
||||
session: { sessionId: session.id, currentQuestion: question, summary: null },
|
||||
});
|
||||
if (session.thinkingOutput) {
|
||||
const trimmed = session.thinkingOutput.trim();
|
||||
if (trimmed) {
|
||||
setConversationHistory((prev) => {
|
||||
const lastEntry = prev[prev.length - 1];
|
||||
if (lastEntry?.thinkingOutput === trimmed) return prev;
|
||||
return [...prev, { thinkingOutput: trimmed }];
|
||||
});
|
||||
}
|
||||
}
|
||||
if (!streamConnectionRef.current?.isConnected()) {
|
||||
connectToPlanningStream(session.id);
|
||||
}
|
||||
|
||||
@@ -1576,6 +1576,346 @@ describe("PlanningModeModal", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("Initial-turn reasoning visibility (FN-3274)", () => {
|
||||
it("preserves reasoning in conversation history when first question arrives after thinking", async () => {
|
||||
let streamHandlers: any;
|
||||
mockConnectPlanningStream.mockImplementationOnce((_sessionId: string, _projectId: string | undefined, handlers: any) => {
|
||||
streamHandlers = handlers;
|
||||
return {
|
||||
close: vi.fn(),
|
||||
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"));
|
||||
|
||||
// Wait for loading state to appear
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Generating next question...")).toBeDefined();
|
||||
});
|
||||
|
||||
// Simulate thinking output arriving during loading
|
||||
act(() => {
|
||||
streamHandlers.onThinking?.("Analyzing the plan requirements...");
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("AI is thinking...")).toBeDefined();
|
||||
});
|
||||
|
||||
// Transition to question view
|
||||
act(() => {
|
||||
streamHandlers.onQuestion?.(mockQuestion);
|
||||
});
|
||||
|
||||
// Question should be visible
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("What is the scope?")).toBeDefined();
|
||||
});
|
||||
|
||||
// The reasoning should now be in conversation history as an expandable entry
|
||||
expect(screen.getByTestId("conversation-history")).toBeDefined();
|
||||
expect(screen.getByText("AI Reasoning")).toBeDefined();
|
||||
fireEvent.click(screen.getByRole("button", { name: /Show AI reasoning/i }));
|
||||
expect(screen.getByText("Analyzing the plan requirements...")).toBeDefined();
|
||||
|
||||
// avoid dangling handlers reference lint
|
||||
expect(streamHandlers).toBeDefined();
|
||||
});
|
||||
|
||||
it("preserves reasoning in conversation history when summary arrives after thinking", async () => {
|
||||
let streamHandlers: any;
|
||||
mockConnectPlanningStream.mockImplementationOnce((_sessionId: string, _projectId: string | undefined, handlers: any) => {
|
||||
streamHandlers = handlers;
|
||||
return {
|
||||
close: vi.fn(),
|
||||
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.getByText("Generating next question...")).toBeDefined();
|
||||
});
|
||||
|
||||
// Simulate thinking output arriving
|
||||
act(() => {
|
||||
streamHandlers.onThinking?.("Finalizing the planning summary...");
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("AI is thinking...")).toBeDefined();
|
||||
});
|
||||
|
||||
// Transition directly to summary view
|
||||
act(() => {
|
||||
streamHandlers.onSummary?.(mockSummary);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Planning Complete!")).toBeDefined();
|
||||
});
|
||||
|
||||
// The reasoning should be visible in the Q&A disclosure
|
||||
fireEvent.click(screen.getByRole("button", { name: "Show user Q&A" }));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("conversation-history")).toBeDefined();
|
||||
});
|
||||
expect(screen.getByText("AI Reasoning")).toBeDefined();
|
||||
fireEvent.click(screen.getByRole("button", { name: /Show AI reasoning/i }));
|
||||
expect(screen.getByText("Finalizing the planning summary...")).toBeDefined();
|
||||
|
||||
expect(streamHandlers).toBeDefined();
|
||||
});
|
||||
|
||||
it("restores persisted thinkingOutput as conversation history when resuming awaiting_input session", async () => {
|
||||
mockConnectPlanningStream.mockImplementationOnce(() => ({
|
||||
close: vi.fn(),
|
||||
isConnected: vi.fn().mockReturnValue(true),
|
||||
}));
|
||||
|
||||
const resumedQuestion: PlanningQuestion = {
|
||||
id: "q-current",
|
||||
type: "text",
|
||||
question: "What should we prioritize next?",
|
||||
};
|
||||
|
||||
const restoredHistory = [
|
||||
{
|
||||
question: {
|
||||
id: "q1",
|
||||
type: "single_select",
|
||||
question: "What scope?",
|
||||
options: [{ id: "small", label: "Small" }],
|
||||
},
|
||||
response: { q1: "small" },
|
||||
},
|
||||
];
|
||||
|
||||
mockFetchAiSession.mockResolvedValueOnce({
|
||||
id: "session-awaiting-reasoning",
|
||||
type: "planning",
|
||||
status: "awaiting_input",
|
||||
title: "Resume with reasoning",
|
||||
inputPayload: JSON.stringify({ initialPlan: "Build planning with reasoning" }),
|
||||
conversationHistory: JSON.stringify(restoredHistory),
|
||||
currentQuestion: JSON.stringify(resumedQuestion),
|
||||
result: null,
|
||||
thinkingOutput: "Server-side reasoning captured during generation",
|
||||
error: null,
|
||||
projectId: null,
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||
});
|
||||
|
||||
render(
|
||||
<PlanningModeModal
|
||||
isOpen={true}
|
||||
onClose={mockOnClose}
|
||||
onTaskCreated={mockOnTaskCreated}
|
||||
onTasksCreated={vi.fn()}
|
||||
tasks={mockTasks}
|
||||
resumeSessionId="session-awaiting-reasoning"
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("What should we prioritize next?")).toBeDefined();
|
||||
});
|
||||
|
||||
// The persisted thinkingOutput should appear as a conversation history entry
|
||||
const history = screen.getByTestId("conversation-history");
|
||||
expect(history).toBeDefined();
|
||||
|
||||
// Should show the existing Q&A plus the AI Reasoning entry
|
||||
expect(screen.getByText("What scope?")).toBeDefined();
|
||||
expect(screen.getByText("AI Reasoning")).toBeDefined();
|
||||
fireEvent.click(screen.getByRole("button", { name: /Show AI reasoning/i }));
|
||||
expect(screen.getByText("Server-side reasoning captured during generation")).toBeDefined();
|
||||
});
|
||||
|
||||
it("does not create duplicate reasoning entries on repeated transitions", async () => {
|
||||
let streamHandlers: any;
|
||||
mockConnectPlanningStream.mockImplementation((_sessionId: string, _projectId: string | undefined, handlers: any) => {
|
||||
streamHandlers = handlers;
|
||||
return {
|
||||
close: vi.fn(),
|
||||
isConnected: vi.fn().mockReturnValue(true),
|
||||
};
|
||||
});
|
||||
|
||||
const secondQuestion: PlanningQuestion = {
|
||||
id: "q-second",
|
||||
type: "text",
|
||||
question: "Any additional requirements?",
|
||||
};
|
||||
|
||||
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.getByText("Generating next question...")).toBeDefined();
|
||||
});
|
||||
|
||||
// Emit thinking then question
|
||||
act(() => {
|
||||
streamHandlers.onThinking?.("First reasoning block");
|
||||
});
|
||||
act(() => {
|
||||
streamHandlers.onQuestion?.(mockQuestion);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("What is the scope?")).toBeDefined();
|
||||
});
|
||||
|
||||
// Answer the question
|
||||
fireEvent.click(screen.getByText("Medium"));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Continue" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockRespondToPlanning).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Simulate thinking for second question then emit second question
|
||||
act(() => {
|
||||
streamHandlers.onThinking?.("Second reasoning block");
|
||||
});
|
||||
act(() => {
|
||||
streamHandlers.onQuestion?.(secondQuestion);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Any additional requirements?")).toBeDefined();
|
||||
});
|
||||
|
||||
// Conversation history should contain both reasoning entries without duplicates
|
||||
const history = screen.getByTestId("conversation-history");
|
||||
expect(history).toBeDefined();
|
||||
|
||||
// Should have Q1, reasoning1, reasoning2 entries
|
||||
const reasoningButtons = screen.getAllByRole("button", { name: /Show AI reasoning/i });
|
||||
// First reasoning button should be next to Q1, second should be standalone
|
||||
// There should be exactly 2 reasoning entries (not duplicated)
|
||||
expect(reasoningButtons.length).toBe(2);
|
||||
|
||||
expect(streamHandlers).toBeDefined();
|
||||
});
|
||||
|
||||
it("preserves reasoning when answer submission transitions back to loading then question", async () => {
|
||||
let streamHandlers: any;
|
||||
mockConnectPlanningStream.mockImplementation((_sessionId: string, _projectId: string | undefined, handlers: any) => {
|
||||
streamHandlers = handlers;
|
||||
return {
|
||||
close: vi.fn(),
|
||||
isConnected: vi.fn().mockReturnValue(true),
|
||||
};
|
||||
});
|
||||
|
||||
const secondQuestion: PlanningQuestion = {
|
||||
id: "q-requirements",
|
||||
type: "text",
|
||||
question: "What are the key requirements?",
|
||||
};
|
||||
|
||||
mockRespondToPlanning.mockImplementation(async () => {
|
||||
// Simulate thinking then second question via the existing stream
|
||||
setTimeout(() => {
|
||||
streamHandlers?.onThinking?.("Thinking about requirements...");
|
||||
streamHandlers?.onQuestion?.(secondQuestion);
|
||||
}, 10);
|
||||
return { sessionId: "session-123", currentQuestion: null, summary: null };
|
||||
});
|
||||
|
||||
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"));
|
||||
|
||||
// Wait for first thinking and question
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Generating next question...")).toBeDefined();
|
||||
});
|
||||
|
||||
act(() => {
|
||||
streamHandlers.onThinking?.("Initial analysis...");
|
||||
});
|
||||
act(() => {
|
||||
streamHandlers.onQuestion?.(mockQuestion);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("What is the scope?")).toBeDefined();
|
||||
});
|
||||
|
||||
// Answer the first question
|
||||
fireEvent.click(screen.getByText("Medium"));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Continue" }));
|
||||
|
||||
// Wait for second question to arrive
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("What are the key requirements?")).toBeDefined();
|
||||
}, { timeout: 3000 });
|
||||
|
||||
// Conversation history should contain the first Q&A pair and initial reasoning
|
||||
const history = screen.getByTestId("conversation-history");
|
||||
expect(history).toBeDefined();
|
||||
expect(screen.getByText("What is the scope?")).toBeDefined();
|
||||
expect(screen.getByText("Medium")).toBeDefined();
|
||||
|
||||
expect(streamHandlers).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Question view", () => {
|
||||
it("renders single_select question with options", async () => {
|
||||
const { container } = render(
|
||||
|
||||
Reference in New Issue
Block a user