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:
5
.changeset/fix-planning-mode-hanging.md
Normal file
5
.changeset/fix-planning-mode-hanging.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"@dustinbyrne/kb": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
Fix planning mode hanging after answering questions, and improve AI thinking output display with proper scrollable styling.
|
||||||
@@ -302,6 +302,78 @@ describe("PlanningModeModal", () => {
|
|||||||
expect(container.querySelector(".planning-question-form > .planning-view-scroll")).not.toBeNull();
|
expect(container.querySelector(".planning-question-form > .planning-view-scroll")).not.toBeNull();
|
||||||
expect(container.querySelector(".planning-question-form > .planning-actions")).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", () => {
|
describe("Summary view", () => {
|
||||||
|
|||||||
@@ -237,57 +237,21 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, tasks, initi
|
|||||||
const { session } = view;
|
const { session } = view;
|
||||||
const sessionId = session.sessionId;
|
const sessionId = session.sessionId;
|
||||||
setError(null);
|
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" });
|
setView({ type: "loading" });
|
||||||
|
setStreamingOutput(""); // Clear old thinking output when entering loading state
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Close previous connection if any
|
// Submit response - AI will broadcast events via the already-connected stream
|
||||||
streamConnectionRef.current?.close();
|
await respondToPlanning(sessionId, responses);
|
||||||
|
|
||||||
// Submit response - this will trigger the AI to process and stream
|
|
||||||
const updatedSession = await respondToPlanning(sessionId, responses);
|
|
||||||
setResponseHistory((prev) => [...prev, responses]);
|
setResponseHistory((prev) => [...prev, responses]);
|
||||||
|
// Events (question/summary) will arrive via the existing SSE stream
|
||||||
// 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;
|
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
setError(err.message || "Failed to submit response");
|
setError(err.message || "Failed to submit response");
|
||||||
setView({ type: "question", session });
|
setView({ type: "question", session });
|
||||||
|
|||||||
@@ -6918,6 +6918,56 @@ html .column.drag-over * {
|
|||||||
color: var(--text-muted);
|
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 */
|
/* Responsive */
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.planning-modal {
|
.planning-modal {
|
||||||
@@ -6996,6 +7046,20 @@ html .column.drag-over * {
|
|||||||
max-width: none;
|
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) */
|
/* Prevent mobile zoom on focus for planning text inputs (16px minimum) */
|
||||||
.planning-textarea {
|
.planning-textarea {
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
|
|||||||
Reference in New Issue
Block a user