feat(FN-1145): harden dashboard SSE streams with replay and reconnect

- Add a shared SSE event buffer utility and wire it into planning, subtask, mission interview, and task stream routes
- Support Last-Event-ID replay semantics and robust event serialization so clients can recover missed stream events
- Add a resilient client-side reconnect wrapper and surface reconnecting state in planning, subtask breakdown, and mission interview modals
- Expand test coverage across API routes, SSE buffering behavior, reconnect handling, and mission/planning stream flows
This commit is contained in:
gsxdsm
2026-04-08 06:02:28 -07:00
parent af57015181
commit 90fbf3e398
20 changed files with 1682 additions and 296 deletions

View File

@@ -8,6 +8,7 @@ import {
getCurrentQuestion,
getSummary,
cleanupSession,
planningStreamManager,
checkRateLimit,
getRateLimitResetTime,
__resetPlanningState,
@@ -702,6 +703,70 @@ describe("planning module", () => {
});
});
describe("PlanningStreamManager buffering", () => {
it("stores broadcast events and returns buffered events since id", () => {
const sessionId = "stream-session-1";
const received: Array<{ type: string; id?: number }> = [];
const unsubscribe = planningStreamManager.subscribe(sessionId, (event, eventId) => {
received.push({ type: event.type, id: eventId });
});
const firstId = planningStreamManager.broadcast(sessionId, {
type: "thinking",
data: "delta-1",
});
const secondId = planningStreamManager.broadcast(sessionId, {
type: "question",
data: {
id: "q-1",
type: "text",
question: "Question?",
description: "desc",
},
});
expect(firstId).toBe(1);
expect(secondId).toBe(2);
expect(received).toEqual([
{ type: "thinking", id: 1 },
{ type: "question", id: 2 },
]);
const buffered = planningStreamManager.getBufferedEvents(sessionId, 1);
expect(buffered).toHaveLength(1);
expect(buffered[0]).toMatchObject({ id: 2, event: "question" });
unsubscribe();
});
it("broadcast buffers events even with no subscribers", () => {
const sessionId = "stream-session-2";
const eventId = planningStreamManager.broadcast(sessionId, {
type: "complete",
});
expect(eventId).toBe(1);
const buffered = planningStreamManager.getBufferedEvents(sessionId, 0);
expect(buffered).toHaveLength(1);
expect(buffered[0]).toMatchObject({ id: 1, event: "complete", data: "{}" });
});
it("cleanupSession clears buffered events", () => {
const sessionId = "stream-session-3";
planningStreamManager.broadcast(sessionId, {
type: "thinking",
data: "delta",
});
expect(planningStreamManager.getBufferedEvents(sessionId, 0)).toHaveLength(1);
planningStreamManager.cleanupSession(sessionId);
expect(planningStreamManager.getBufferedEvents(sessionId, 0)).toEqual([]);
});
});
describe("generateSubtasksFromPlanning", () => {
/** Helper: create a session and complete it to get a summary */
async function createCompletedSession(