fix(KB-217): resolve planning mode hanging race condition

- Fix race condition causing planning mode to hang after submitting answers
- Keep SSE connection alive during response submission to prevent premature disconnect
- Add scrollable CSS styling for AI thinking output display in planning mode
- Add tests for race condition fix ensuring subsequent questions are received
- Include changeset for planning mode patch release
This commit is contained in:
gsxdsm
2026-03-30 18:12:49 -07:00
parent 0a71659fbb
commit ffe42cbb6c
4 changed files with 152 additions and 47 deletions

View File

@@ -302,6 +302,78 @@ describe("PlanningModeModal", () => {
expect(container.querySelector(".planning-question-form > .planning-view-scroll")).not.toBeNull();
expect(container.querySelector(".planning-question-form > .planning-actions")).not.toBeNull();
});
it("receives second question after answering first without hanging (race condition fix)", async () => {
const secondQuestion: PlanningQuestion = {
id: "q-requirements",
type: "text",
question: "What are the key requirements?",
description: "Describe the requirements",
};
// Track how many times connectPlanningStream is called
let streamConnectionCount = 0;
let streamHandlers: any = null;
mockConnectPlanningStream.mockImplementation((sessionId: string, handlers: any) => {
streamConnectionCount++;
streamHandlers = handlers;
// Only send first question on initial connection
if (streamConnectionCount === 1) {
setTimeout(() => {
handlers.onQuestion?.(mockQuestion);
}, 10);
}
return {
close: vi.fn(),
isConnected: vi.fn().mockReturnValue(true),
};
});
mockRespondToPlanning.mockImplementation(async () => {
// Simulate server broadcasting second question via the existing SSE connection
// This should use the same handlers from the initial connection
setTimeout(() => {
if (streamHandlers) {
streamHandlers.onQuestion?.(secondQuestion);
}
}, 5);
return { sessionId: "session-123", currentQuestion: null, summary: null };
});
render(
<PlanningModeModal
isOpen={true}
onClose={mockOnClose}
onTaskCreated={mockOnTaskCreated}
tasks={mockTasks}
/>
);
const textarea = screen.getByPlaceholderText(/e.g., Build a user authentication/);
fireEvent.change(textarea, { target: { value: "Build auth system" } });
fireEvent.click(screen.getByText("Start Planning"));
// Wait for first question
await waitFor(() => {
expect(screen.getByText("What is the scope?")).toBeDefined();
});
// Answer the first question
fireEvent.click(screen.getByText("Medium"));
fireEvent.click(screen.getByText("Continue"));
// Wait for second question to appear (should NOT hang)
await waitFor(() => {
expect(screen.getByText("What are the key requirements?")).toBeDefined();
}, { timeout: 3000 });
// Verify SSE connection was established only ONCE (not reconnected)
// This confirms the race condition fix - the same connection is reused
expect(streamConnectionCount).toBe(1);
});
});
describe("Summary view", () => {

View File

@@ -237,57 +237,21 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, tasks, initi
const { session } = view;
const sessionId = session.sessionId;
setError(null);
setStreamingOutput("");
// Keep the existing SSE connection alive - do NOT close it!
// The connection established in handleStartPlanning will continue
// to receive events (thinking, question, summary) throughout the session.
// This prevents the race condition where events are missed because
// the frontend disconnects and reconnects after the API call.
setView({ type: "loading" });
setStreamingOutput(""); // Clear old thinking output when entering loading state
try {
// Close previous connection if any
streamConnectionRef.current?.close();
// Submit response - this will trigger the AI to process and stream
const updatedSession = await respondToPlanning(sessionId, responses);
// Submit response - AI will broadcast events via the already-connected stream
await respondToPlanning(sessionId, responses);
setResponseHistory((prev) => [...prev, responses]);
// If we got an immediate response (non-streaming mode), use it
if (updatedSession.summary) {
setView({ type: "summary", session: updatedSession, summary: updatedSession.summary });
setEditedSummary(updatedSession.summary);
return;
}
if (updatedSession.currentQuestion) {
setView({ type: "question", session: updatedSession });
return;
}
// Otherwise, set up streaming for the next question
const connection = connectPlanningStream(sessionId, {
onThinking: (data) => {
setStreamingOutput((prev) => prev + data);
},
onQuestion: (question) => {
setView({
type: "question",
session: { sessionId, currentQuestion: question, summary: null },
});
setStreamingOutput("");
},
onSummary: (summary) => {
setView({
type: "summary",
session: { sessionId, currentQuestion: null, summary },
summary,
});
setEditedSummary(summary);
setStreamingOutput("");
},
onError: (message) => {
setError(message);
setView({ type: "question", session });
setStreamingOutput("");
},
});
streamConnectionRef.current = connection;
// Events (question/summary) will arrive via the existing SSE stream
} catch (err: any) {
setError(err.message || "Failed to submit response");
setView({ type: "question", session });

View File

@@ -6918,6 +6918,56 @@ html .column.drag-over * {
color: var(--text-muted);
}
/* AI Thinking Output Display */
.planning-thinking-container {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
width: 100%;
max-width: 600px;
}
.planning-thinking-toggle {
padding: 6px 12px;
font-size: 13px;
font-weight: 500;
color: var(--text-muted);
background: var(--card);
border: 1px solid var(--border);
border-radius: var(--radius-md);
cursor: pointer;
transition: all 0.15s ease;
}
.planning-thinking-toggle:hover {
background: var(--card-hover);
color: var(--text);
}
.planning-thinking-output {
width: 100%;
max-height: 300px;
overflow-y: auto;
overflow-x: hidden;
background: var(--card);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
padding: 16px;
text-align: left;
}
.planning-thinking-output pre {
margin: 0;
white-space: pre-wrap;
word-wrap: break-word;
overflow-wrap: break-word;
font-family: var(--font-mono);
font-size: 13px;
line-height: 1.6;
color: var(--text);
}
/* Responsive */
@media (max-width: 768px) {
.planning-modal {
@@ -6996,6 +7046,20 @@ html .column.drag-over * {
max-width: none;
}
/* Planning thinking output mobile adjustments */
.planning-thinking-container {
max-width: 100%;
}
.planning-thinking-output {
max-height: 200px;
padding: 12px;
}
.planning-thinking-output pre {
font-size: 12px;
}
/* Prevent mobile zoom on focus for planning text inputs (16px minimum) */
.planning-textarea {
font-size: 16px;