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:
Fusion
2026-05-03 13:37:03 -07:00
committed by gsxdsm
parent 8675b8c196
commit d2e438a050
4 changed files with 427 additions and 8 deletions

View File

@@ -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);
}

View File

@@ -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(