FN-5799: preserve first-turn planning thinking output

Ensure first-question planning UI reliably shows streamed reasoning before question transitions.

- Keep streamingOutputRef synchronized during onThinking updates so back-to-back thinking/question events preserve full reasoning text.
- Expand PlanningModeModal question-flow tests to cover same-tick buffered replay and loading-view visibility for first-turn and follow-up thinking output.
- Update assertions to verify reasoning content remains visible in conversation history after question handoff.

Files changed:
 .../dashboard/app/components/PlanningModeModal.tsx |  9 +++-
 .../__tests__/PlanningModeModal.questions.test.tsx | 62 ++++++++++++++++++++--
 2 files changed, 66 insertions(+), 5 deletions(-)

Fusion-Task-Id: FN-5799
Fusion-Task-Lineage: 41677c47-4380-4145-a3d2-0e928fc40042
This commit is contained in:
gsxdsm
2026-05-31 21:43:13 -07:00
parent 944c03d495
commit 0a16fc690a
2 changed files with 66 additions and 5 deletions

View File

@@ -474,7 +474,14 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
const connection = connectPlanningStream(sessionId, projectId, {
onThinking: (data) => {
if (isStaleEvent()) return;
setStreamingOutput((prev) => prev + data);
setStreamingOutput((prev) => {
const next = prev + data;
// Keep the ref synchronized inside the event handler so
// back-to-back thinking/question events in the same flush can
// still preserve the full reasoning payload.
streamingOutputRef.current = next;
return next;
});
broadcastUpdate({
sessionId,
status: "generating",

View File

@@ -172,7 +172,7 @@ describe("PlanningModeModal", () => {
});
describe("Initial-turn reasoning visibility (FN-3274)", () => {
it("preserves reasoning in conversation history when first question arrives after thinking", async () => {
it("shows first-turn thinking in loading view and preserves it when question follows immediately", async () => {
let streamHandlers: any;
mockConnectPlanningStream.mockImplementationOnce((_sessionId: string, _projectId: string | undefined, handlers: any) => {
streamHandlers = handlers;
@@ -202,17 +202,19 @@ describe("PlanningModeModal", () => {
expect(screen.getByText("Generating next question...")).toBeDefined();
});
// Simulate thinking output arriving during loading
// Simulate buffered first-turn replay where thinking and question can
// arrive back-to-back in the same flush.
act(() => {
streamHandlers.onThinking?.("Analyzing the plan requirements...");
});
await waitFor(() => {
expect(screen.getByText("AI is thinking...")).toBeDefined();
expect(screen.getByText("Analyzing the plan requirements...")).toBeDefined();
});
// Transition to question view
act(() => {
streamHandlers.onThinking?.(" Buffered follow-up.");
streamHandlers.onQuestion?.(mockQuestion);
});
@@ -225,12 +227,58 @@ describe("PlanningModeModal", () => {
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();
expect(screen.getByText("Analyzing the plan requirements... Buffered follow-up.")).toBeDefined();
// avoid dangling handlers reference lint
expect(streamHandlers).toBeDefined();
});
it("shows first-turn thinking before question when replay arrives in same connect tick", async () => {
vi.useFakeTimers();
mockConnectPlanningStream.mockImplementationOnce((_sessionId: string, _projectId: string | undefined, handlers: any) => {
handlers.onThinking?.("Synchronous buffered reasoning");
handlers.onQuestion?.(mockQuestion);
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("AI is thinking...")).toBeDefined();
expect(screen.getByText("Synchronous buffered reasoning")).toBeDefined();
});
expect(screen.queryByText("What is the scope?")).toBeNull();
act(() => {
vi.runOnlyPendingTimers();
});
await waitFor(() => {
expect(screen.getByText("What is the scope?")).toBeDefined();
});
fireEvent.click(screen.getByRole("button", { name: /Show AI reasoning/i }));
expect(screen.getByText("Synchronous buffered reasoning")).toBeDefined();
vi.useRealTimers();
});
it("preserves reasoning in conversation history when summary arrives after thinking", async () => {
let streamHandlers: any;
mockConnectPlanningStream.mockImplementationOnce((_sessionId: string, _projectId: string | undefined, handlers: any) => {
@@ -496,6 +544,12 @@ describe("PlanningModeModal", () => {
fireEvent.click(screen.getByText("Medium"));
fireEvent.click(screen.getByRole("button", { name: "Continue" }));
// Second-turn parity: thinking should stream in loading view before the next question.
await waitFor(() => {
expect(screen.getByText("AI is thinking...")).toBeDefined();
expect(screen.getByText("Thinking about requirements...")).toBeDefined();
});
// Wait for second question to arrive
await waitFor(() => {
expect(screen.getByText("What are the key requirements?")).toBeDefined();