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

@@ -7,7 +7,7 @@
// @vitest-environment node
import { describe, it, expect, vi } from "vitest";
import { beforeEach, describe, expect, it, vi } from "vitest";
import express from "express";
import { createMissionRouter } from "./mission-routes.js";
import { request, get } from "./test-request.js";
@@ -19,6 +19,11 @@ import type {
MissionFeature,
MissionWithHierarchy,
} from "@fusion/core";
import {
__resetMissionInterviewState,
createMissionInterviewSession,
missionInterviewStreamManager,
} from "./mission-interview.js";
// Mock MissionStore factory
function createMockMissionStore() {
@@ -701,6 +706,10 @@ describe("Mission API", () => {
});
describe("Interview endpoints", () => {
beforeEach(() => {
__resetMissionInterviewState();
});
it("should return 400 when missionTitle is missing on interview start", async () => {
const { app } = buildApp();
const res = await request(
@@ -740,6 +749,79 @@ describe("Mission API", () => {
expect(res.body.error).toContain("sessionId");
});
it("replays buffered interview events when Last-Event-ID is provided", async () => {
const { app } = buildApp();
const sessionId = await createMissionInterviewSession("127.0.0.1", "Replay Mission", "/tmp/project");
missionInterviewStreamManager.broadcast(sessionId, { type: "thinking", data: "first" });
missionInterviewStreamManager.broadcast(sessionId, { type: "thinking", data: "second" });
setTimeout(() => {
missionInterviewStreamManager.broadcast(sessionId, { type: "complete" });
}, 0);
const res = await request(
app,
"GET",
`/api/missions/interview/${sessionId}/stream`,
undefined,
{ "last-event-id": "1" },
);
expect(res.status).toBe(200);
expect(res.body).toContain("id: 2");
expect(res.body).toContain("event: thinking");
expect(res.body).toContain("id: 3");
expect(res.body).toContain("event: complete");
expect(res.body).not.toContain("id: 1\nevent: thinking");
});
it("does not replay buffered interview events when Last-Event-ID is missing", async () => {
const { app } = buildApp();
const sessionId = await createMissionInterviewSession("127.0.0.1", "No Replay Mission", "/tmp/project");
missionInterviewStreamManager.broadcast(sessionId, { type: "thinking", data: "first" });
setTimeout(() => {
missionInterviewStreamManager.broadcast(sessionId, { type: "complete" });
}, 0);
const res = await request(
app,
"GET",
`/api/missions/interview/${sessionId}/stream`,
);
expect(res.status).toBe(200);
expect(res.body).not.toContain("id: 1\nevent: thinking");
expect(res.body).toContain("id: 2");
expect(res.body).toContain("event: complete");
});
it("gracefully ignores invalid Last-Event-ID values for interview streams", async () => {
const { app } = buildApp();
const sessionId = await createMissionInterviewSession("127.0.0.1", "Invalid Replay Mission", "/tmp/project");
missionInterviewStreamManager.broadcast(sessionId, { type: "thinking", data: "first" });
setTimeout(() => {
missionInterviewStreamManager.broadcast(sessionId, { type: "complete" });
}, 0);
const res = await request(
app,
"GET",
`/api/missions/interview/${sessionId}/stream`,
undefined,
{ "last-event-id": "not-a-number" },
);
expect(res.status).toBe(200);
expect(res.body).not.toContain("id: 1\nevent: thinking");
expect(res.body).toContain("id: 2");
expect(res.body).toContain("event: complete");
});
it("should return 400 when sessionId is missing on create-mission", async () => {
const { app } = buildApp();
const res = await request(