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 586d9b33b1
commit b6fbadec4d
20 changed files with 1682 additions and 296 deletions

View File

@@ -2,6 +2,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import {
fetchTaskDetail,
updateTask,
connectPlanningStream,
assignTask,
fetchAgentTasks,
archiveTask,
@@ -2718,3 +2719,134 @@ describe("Mission mutation coverage with 204 responses", () => {
await expect(deleteMission("bad-id")).rejects.toThrow("Invalid mission ID format");
});
});
describe("resilient SSE reconnect", () => {
const OriginalEventSource = globalThis.EventSource;
class ControlledEventSource {
static instances: ControlledEventSource[] = [];
static CONNECTING = 0;
static OPEN = 1;
static CLOSED = 2;
readyState = ControlledEventSource.OPEN;
onopen: ((event: Event) => void) | null = null;
onerror: ((event: Event) => void) | null = null;
onmessage: ((event: MessageEvent) => void) | null = null;
readonly listeners = new Map<string, Set<(event: MessageEvent) => void>>();
constructor(public readonly url: string) {
ControlledEventSource.instances.push(this);
}
addEventListener(eventName: string, listener: (event: MessageEvent) => void): void {
if (!this.listeners.has(eventName)) {
this.listeners.set(eventName, new Set());
}
this.listeners.get(eventName)!.add(listener);
}
removeEventListener(eventName: string, listener: (event: MessageEvent) => void): void {
this.listeners.get(eventName)?.delete(listener);
}
close(): void {
this.readyState = ControlledEventSource.CLOSED;
}
emitOpen(): void {
this.readyState = ControlledEventSource.OPEN;
this.onopen?.(new Event("open"));
}
emitConnectionError(state: number): void {
this.readyState = state;
this.onerror?.(new Event("error"));
}
emitEvent(eventName: string, data: string, lastEventId = ""): void {
const event = { data, lastEventId } as MessageEvent;
for (const listener of this.listeners.get(eventName) ?? []) {
listener(event);
}
}
}
beforeEach(() => {
vi.useFakeTimers();
ControlledEventSource.instances = [];
(globalThis as any).EventSource = ControlledEventSource;
});
afterEach(() => {
vi.useRealTimers();
(globalThis as any).EventSource = OriginalEventSource;
});
it("reconnects with backoff and deduplicates replayed events", () => {
const onThinking = vi.fn();
const onState = vi.fn();
connectPlanningStream("session-1", undefined, {
onThinking,
onConnectionStateChange: onState,
});
const firstConnection = ControlledEventSource.instances[0]!;
firstConnection.emitOpen();
firstConnection.emitEvent("thinking", JSON.stringify("first"), "1");
firstConnection.emitConnectionError(ControlledEventSource.CLOSED);
expect(onState).toHaveBeenCalledWith("reconnecting");
vi.advanceTimersByTime(1000);
const secondConnection = ControlledEventSource.instances[1]!;
secondConnection.emitOpen();
// Duplicate replayed event should be ignored by lastEventId tracking.
secondConnection.emitEvent("thinking", JSON.stringify("first"), "1");
secondConnection.emitEvent("thinking", JSON.stringify("second"), "2");
expect(onThinking).toHaveBeenCalledTimes(2);
expect(onThinking).toHaveBeenNthCalledWith(1, "first");
expect(onThinking).toHaveBeenNthCalledWith(2, "second");
expect(secondConnection.url).toContain("lastEventId=1");
});
it("stops reconnecting after max attempts and reports fatal error", () => {
const onError = vi.fn();
connectPlanningStream(
"session-2",
undefined,
{ onError },
{ maxReconnectAttempts: 2 },
);
const first = ControlledEventSource.instances[0]!;
first.emitConnectionError(ControlledEventSource.CLOSED);
vi.advanceTimersByTime(1000);
const second = ControlledEventSource.instances[1]!;
second.emitConnectionError(ControlledEventSource.CLOSED);
vi.advanceTimersByTime(2000);
const third = ControlledEventSource.instances[2]!;
third.emitConnectionError(ControlledEventSource.CLOSED);
expect(onError).toHaveBeenCalledWith("Connection lost");
});
it("manual close cancels pending reconnect", () => {
const connection = connectPlanningStream("session-3", undefined, {});
const first = ControlledEventSource.instances[0]!;
first.emitConnectionError(ControlledEventSource.CLOSED);
connection.close();
vi.advanceTimersByTime(30_000);
expect(ControlledEventSource.instances).toHaveLength(1);
});
});

View File

@@ -1301,6 +1301,135 @@ export function createTasksFromPlanning(
}
type StreamConnectionState = "connected" | "reconnecting";
interface ResilientEventSourceOptions {
maxReconnectAttempts?: number;
onConnectionStateChange?: (state: StreamConnectionState) => void;
onFatalError?: (message: string) => void;
}
interface ResilientEventHandlers {
onOpen?: () => void;
onMessage?: (event: MessageEvent) => void;
events?: Record<string, (event: MessageEvent) => void>;
}
function appendLastEventId(url: string, lastEventId: number | null): string {
if (lastEventId === null || lastEventId <= 0) {
return url;
}
const separator = url.includes("?") ? "&" : "?";
return `${url}${separator}lastEventId=${encodeURIComponent(String(lastEventId))}`;
}
function createResilientEventSource(
url: string,
handlers: ResilientEventHandlers,
options: ResilientEventSourceOptions = {},
): { close: () => void; isConnected: () => boolean } {
const maxReconnectAttempts = options.maxReconnectAttempts ?? 10;
let eventSource: EventSource | null = null;
let closedByUser = false;
let reconnectAttempts = 0;
let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
let lastSeenEventId: number | null = null;
let reconnectingNotified = false;
const shouldDispatch = (event: MessageEvent): boolean => {
const rawId = event.lastEventId;
if (!rawId) {
return true;
}
const parsedId = Number.parseInt(rawId, 10);
if (!Number.isFinite(parsedId)) {
return true;
}
if (lastSeenEventId !== null && parsedId <= lastSeenEventId) {
return false;
}
lastSeenEventId = parsedId;
return true;
};
const connect = (): void => {
if (closedByUser) return;
const nextUrl = appendLastEventId(url, lastSeenEventId);
const source = new EventSource(nextUrl);
eventSource = source;
source.onopen = () => {
reconnectAttempts = 0;
reconnectingNotified = false;
options.onConnectionStateChange?.("connected");
handlers.onOpen?.();
};
source.onmessage = (event) => {
const messageEvent = event as MessageEvent;
if (!shouldDispatch(messageEvent)) return;
handlers.onMessage?.(messageEvent);
};
for (const [eventName, handler] of Object.entries(handlers.events ?? {})) {
source.addEventListener(eventName, (event: Event) => {
const messageEvent = event as MessageEvent;
if (!shouldDispatch(messageEvent)) return;
handler(messageEvent);
});
}
source.onerror = () => {
if (closedByUser || eventSource !== source) return;
const readyState = source.readyState;
if (readyState === EventSource.CONNECTING) {
if (!reconnectingNotified) {
reconnectingNotified = true;
options.onConnectionStateChange?.("reconnecting");
}
return;
}
source.close();
if (reconnectAttempts >= maxReconnectAttempts) {
options.onFatalError?.("Connection lost");
return;
}
reconnectingNotified = true;
options.onConnectionStateChange?.("reconnecting");
reconnectAttempts += 1;
const delayMs = Math.min(1000 * 2 ** (reconnectAttempts - 1), 30000);
reconnectTimer = setTimeout(() => {
reconnectTimer = null;
connect();
}, delayMs);
};
};
connect();
return {
close: () => {
closedByUser = true;
if (reconnectTimer) {
clearTimeout(reconnectTimer);
reconnectTimer = null;
}
eventSource?.close();
},
isConnected: () => !closedByUser && eventSource?.readyState === EventSource.OPEN,
};
}
/** Get the SSE stream URL for a planning session */
export function getPlanningStreamUrl(sessionId: string, projectId?: string): string {
return buildApiUrl(withProjectId(`/planning/${encodeURIComponent(sessionId)}/stream`, projectId));
@@ -1310,7 +1439,6 @@ export function getPlanningStreamUrl(sessionId: string, projectId?: string): str
*
* Returns an object with:
* - close: function to close the connection
* - reconnect: function to reconnect after error
*/
export function connectPlanningStream(
sessionId: string,
@@ -1321,89 +1449,67 @@ export function connectPlanningStream(
onSummary?: (data: PlanningSummary) => void;
onError?: (data: string) => void;
onComplete?: () => void;
}
onConnectionStateChange?: (state: StreamConnectionState) => void;
},
options?: { maxReconnectAttempts?: number },
): { close: () => void; isConnected: () => boolean } {
const url = getPlanningStreamUrl(sessionId, projectId);
const eventSource = new EventSource(url);
let isClosed = false;
let connection: { close: () => void; isConnected: () => boolean } | null = null;
eventSource.onopen = () => {
isClosed = false;
};
const resilient = createResilientEventSource(
url,
{
onMessage: (event) => {
if (event.data.startsWith(":")) return;
},
events: {
thinking: (event) => {
try {
handlers.onThinking?.(JSON.parse(event.data));
} catch {
handlers.onThinking?.(event.data);
}
},
question: (event) => {
try {
handlers.onQuestion?.(JSON.parse(event.data) as PlanningQuestion);
} catch (err) {
console.error("[planning] Failed to parse question event:", err);
}
},
summary: (event) => {
try {
handlers.onSummary?.(JSON.parse(event.data) as PlanningSummary);
} catch (err) {
console.error("[planning] Failed to parse summary event:", err);
}
},
error: (event) => {
try {
const parsed = JSON.parse(event.data);
handlers.onError?.(parsed.message || parsed);
} catch {
handlers.onError?.(event.data || "Stream error");
}
connection?.close();
},
complete: () => {
handlers.onComplete?.();
connection?.close();
},
},
},
{
maxReconnectAttempts: options?.maxReconnectAttempts,
onConnectionStateChange: handlers.onConnectionStateChange,
onFatalError: (message) => {
handlers.onError?.(message);
},
},
);
eventSource.onmessage = (event) => {
// Handle comment events (heartbeats)
if (event.data.startsWith(":")) return;
};
// Handle specific event types
eventSource.addEventListener("thinking", (event: Event) => {
try {
const messageEvent = event as MessageEvent;
const data = JSON.parse(messageEvent.data);
handlers.onThinking?.(data);
} catch {
const messageEvent = event as MessageEvent;
handlers.onThinking?.(messageEvent.data);
}
});
eventSource.addEventListener("question", (event: Event) => {
try {
const messageEvent = event as MessageEvent;
const data = JSON.parse(messageEvent.data) as PlanningQuestion;
handlers.onQuestion?.(data);
} catch (err) {
console.error("[planning] Failed to parse question event:", err);
}
});
eventSource.addEventListener("summary", (event: Event) => {
try {
const messageEvent = event as MessageEvent;
const data = JSON.parse(messageEvent.data) as PlanningSummary;
handlers.onSummary?.(data);
} catch (err) {
console.error("[planning] Failed to parse summary event:", err);
}
});
eventSource.addEventListener("error", (event: Event) => {
try {
const messageEvent = event as MessageEvent;
const data = JSON.parse(messageEvent.data);
handlers.onError?.(data.message || data);
} catch {
const messageEvent = event as MessageEvent;
handlers.onError?.(messageEvent.data || "Stream error");
}
close();
});
eventSource.addEventListener("complete", () => {
handlers.onComplete?.();
close();
});
// Handle connection errors
eventSource.onerror = () => {
if (!isClosed) {
handlers.onError?.("Connection lost");
close();
}
};
function close() {
if (!isClosed) {
isClosed = true;
eventSource.close();
}
}
return {
close,
isConnected: () => !isClosed && eventSource.readyState === EventSource.OPEN,
};
connection = resilient;
return resilient;
}
// ── Automation / Scheduled Tasks ──────────────────────────────────
@@ -1672,71 +1778,57 @@ export function connectSubtaskStream(
onSubtasks?: (data: SubtaskItem[]) => void;
onError?: (data: string) => void;
onComplete?: () => void;
}
onConnectionStateChange?: (state: StreamConnectionState) => void;
},
options?: { maxReconnectAttempts?: number },
): { close: () => void; isConnected: () => boolean } {
const eventSource = new EventSource(getSubtaskStreamUrl(sessionId, projectId));
let isClosed = false;
let connection: { close: () => void; isConnected: () => boolean } | null = null;
eventSource.onopen = () => {
isClosed = false;
};
eventSource.addEventListener("thinking", (event: Event) => {
const messageEvent = event as MessageEvent;
try {
handlers.onThinking?.(JSON.parse(messageEvent.data));
} catch {
handlers.onThinking?.(messageEvent.data);
}
});
eventSource.addEventListener("subtasks", (event: Event) => {
try {
const messageEvent = event as MessageEvent;
handlers.onSubtasks?.(JSON.parse(messageEvent.data) as SubtaskItem[]);
} catch (err) {
console.error("[subtasks] Failed to parse subtasks event:", err);
}
});
eventSource.addEventListener("error", (event: Event) => {
try {
const messageEvent = event as MessageEvent;
const parsedData = JSON.parse(messageEvent.data);
const errorMessage = typeof parsedData === "string" && parsedData.length > 0 ? parsedData : null;
if (errorMessage) {
handlers.onError?.(errorMessage);
} else {
handlers.onError?.("Stream error");
}
} catch {
handlers.onError?.("Stream error");
}
isClosed = true;
eventSource.close();
});
eventSource.addEventListener("complete", () => {
handlers.onComplete?.();
isClosed = true;
eventSource.close();
});
eventSource.onerror = () => {
if (!isClosed) {
handlers.onError?.("Connection lost");
}
isClosed = true;
eventSource.close();
};
return {
close: () => {
isClosed = true;
eventSource.close();
const resilient = createResilientEventSource(
getSubtaskStreamUrl(sessionId, projectId),
{
events: {
thinking: (event) => {
try {
handlers.onThinking?.(JSON.parse(event.data));
} catch {
handlers.onThinking?.(event.data);
}
},
subtasks: (event) => {
try {
handlers.onSubtasks?.(JSON.parse(event.data) as SubtaskItem[]);
} catch (err) {
console.error("[subtasks] Failed to parse subtasks event:", err);
}
},
error: (event) => {
try {
const parsedData = JSON.parse(event.data);
const errorMessage = typeof parsedData === "string" && parsedData.length > 0 ? parsedData : null;
handlers.onError?.(errorMessage || "Stream error");
} catch {
handlers.onError?.("Stream error");
}
connection?.close();
},
complete: () => {
handlers.onComplete?.();
connection?.close();
},
},
},
isConnected: () => !isClosed,
};
{
maxReconnectAttempts: options?.maxReconnectAttempts,
onConnectionStateChange: handlers.onConnectionStateChange,
onFatalError: (message) => {
handlers.onError?.(message);
},
},
);
connection = resilient;
return resilient;
}
export function createTasksFromBreakdown(
@@ -3005,86 +3097,67 @@ export function connectMissionInterviewStream(
onSummary?: (data: MissionPlanSummary) => void;
onError?: (data: string) => void;
onComplete?: () => void;
}
onConnectionStateChange?: (state: StreamConnectionState) => void;
},
options?: { maxReconnectAttempts?: number },
): { close: () => void; isConnected: () => boolean } {
const url = buildApiUrl(withProjectId(`/missions/interview/${encodeURIComponent(sessionId)}/stream`, projectId));
const eventSource = new EventSource(url);
let isClosed = false;
let connection: { close: () => void; isConnected: () => boolean } | null = null;
eventSource.onopen = () => {
isClosed = false;
};
const resilient = createResilientEventSource(
url,
{
onMessage: (event) => {
if (event.data.startsWith(":")) return;
},
events: {
thinking: (event) => {
try {
handlers.onThinking?.(JSON.parse(event.data));
} catch {
handlers.onThinking?.(event.data);
}
},
question: (event) => {
try {
handlers.onQuestion?.(JSON.parse(event.data) as PlanningQuestion);
} catch (err) {
console.error("[mission-interview] Failed to parse question event:", err);
}
},
summary: (event) => {
try {
handlers.onSummary?.(JSON.parse(event.data) as MissionPlanSummary);
} catch (err) {
console.error("[mission-interview] Failed to parse summary event:", err);
}
},
error: (event) => {
try {
const parsed = JSON.parse(event.data);
handlers.onError?.(parsed.message || parsed);
} catch {
handlers.onError?.(event.data || "Stream error");
}
connection?.close();
},
complete: () => {
handlers.onComplete?.();
connection?.close();
},
},
},
{
maxReconnectAttempts: options?.maxReconnectAttempts,
onConnectionStateChange: handlers.onConnectionStateChange,
onFatalError: (message) => {
handlers.onError?.(message);
},
},
);
eventSource.onmessage = (event) => {
if (event.data.startsWith(":")) return;
};
eventSource.addEventListener("thinking", (event: Event) => {
try {
const messageEvent = event as MessageEvent;
const data = JSON.parse(messageEvent.data);
handlers.onThinking?.(data);
} catch {
const messageEvent = event as MessageEvent;
handlers.onThinking?.(messageEvent.data);
}
});
eventSource.addEventListener("question", (event: Event) => {
try {
const messageEvent = event as MessageEvent;
const data = JSON.parse(messageEvent.data) as PlanningQuestion;
handlers.onQuestion?.(data);
} catch (err) {
console.error("[mission-interview] Failed to parse question event:", err);
}
});
eventSource.addEventListener("summary", (event: Event) => {
try {
const messageEvent = event as MessageEvent;
const data = JSON.parse(messageEvent.data) as MissionPlanSummary;
handlers.onSummary?.(data);
} catch (err) {
console.error("[mission-interview] Failed to parse summary event:", err);
}
});
eventSource.addEventListener("error", (event: Event) => {
try {
const messageEvent = event as MessageEvent;
const data = JSON.parse(messageEvent.data);
handlers.onError?.(data.message || data);
} catch {
const messageEvent = event as MessageEvent;
handlers.onError?.(messageEvent.data || "Stream error");
}
close();
});
eventSource.addEventListener("complete", () => {
handlers.onComplete?.();
close();
});
eventSource.onerror = () => {
if (!isClosed) {
handlers.onError?.("Connection lost");
close();
}
};
function close() {
if (!isClosed) {
isClosed = true;
eventSource.close();
}
}
return {
close,
isConnected: () => !isClosed && eventSource.readyState === EventSource.OPEN,
};
connection = resilient;
return resilient;
}
// ── AI Sessions (Background Tasks) ─────────────────────────────────────────

View File

@@ -0,0 +1,127 @@
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { MissionInterviewModal } from "./MissionInterviewModal";
const mockStartMissionInterview = vi.fn();
const mockRespondToMissionInterview = vi.fn();
const mockCancelMissionInterview = vi.fn();
const mockCreateMissionFromInterview = vi.fn();
const mockConnectMissionInterviewStream = vi.fn();
const mockFetchAiSession = vi.fn();
vi.mock("../api", () => ({
startMissionInterview: (...args: any[]) => mockStartMissionInterview(...args),
respondToMissionInterview: (...args: any[]) => mockRespondToMissionInterview(...args),
cancelMissionInterview: (...args: any[]) => mockCancelMissionInterview(...args),
createMissionFromInterview: (...args: any[]) => mockCreateMissionFromInterview(...args),
connectMissionInterviewStream: (...args: any[]) => mockConnectMissionInterviewStream(...args),
fetchAiSession: (...args: any[]) => mockFetchAiSession(...args),
}));
vi.mock("../hooks/modalPersistence", () => ({
saveMissionGoal: vi.fn(),
getMissionGoal: vi.fn(() => ""),
clearMissionGoal: vi.fn(),
}));
const SAMPLE_QUESTION = {
id: "scope",
type: "single_select" as const,
question: "What is the target scope?",
description: "Pick the size for this mission.",
options: [
{ id: "mvp", label: "MVP" },
{ id: "full", label: "Full" },
],
};
describe("MissionInterviewModal", () => {
let streamHandlers: any;
beforeEach(() => {
vi.clearAllMocks();
streamHandlers = undefined;
mockStartMissionInterview.mockResolvedValue({ sessionId: "mission-session-1" });
mockFetchAiSession.mockResolvedValue(null);
mockConnectMissionInterviewStream.mockImplementation((_sessionId, _projectId, handlers) => {
streamHandlers = handlers;
return {
close: vi.fn(),
isConnected: vi.fn().mockReturnValue(true),
};
});
});
function renderModal() {
return render(
<MissionInterviewModal
isOpen={true}
onClose={vi.fn()}
onMissionCreated={vi.fn()}
/>,
);
}
it("shows reconnecting indicator without clearing current question", async () => {
renderModal();
fireEvent.change(screen.getByLabelText("What do you want to build?"), {
target: { value: "Build a mission planning workflow" },
});
fireEvent.click(screen.getByText("Start Interview"));
await waitFor(() => {
expect(mockStartMissionInterview).toHaveBeenCalledWith("Build a mission planning workflow", undefined);
expect(streamHandlers).toBeDefined();
});
act(() => {
streamHandlers.onQuestion?.(SAMPLE_QUESTION);
});
expect(await screen.findByText("What is the target scope?")).toBeInTheDocument();
act(() => {
streamHandlers.onConnectionStateChange?.("reconnecting");
});
expect(screen.getByText("Reconnecting…")).toBeInTheDocument();
expect(screen.getByText("What is the target scope?")).toBeInTheDocument();
act(() => {
streamHandlers.onConnectionStateChange?.("connected");
});
await waitFor(() => {
expect(screen.queryByText("Reconnecting…")).not.toBeInTheDocument();
});
expect(screen.getByText("What is the target scope?")).toBeInTheDocument();
});
it("preserves streaming thinking output while reconnecting", async () => {
renderModal();
fireEvent.change(screen.getByLabelText("What do you want to build?"), {
target: { value: "Build a mission planning workflow" },
});
fireEvent.click(screen.getByText("Start Interview"));
await waitFor(() => {
expect(streamHandlers).toBeDefined();
});
act(() => {
streamHandlers.onThinking?.("Analyzing mission goals...");
});
expect(await screen.findByText("Analyzing mission goals...")).toBeInTheDocument();
act(() => {
streamHandlers.onConnectionStateChange?.("reconnecting");
});
expect(screen.getByText("Reconnecting…")).toBeInTheDocument();
expect(screen.getByText("Analyzing mission goals...")).toBeInTheDocument();
});
});

View File

@@ -78,6 +78,7 @@ export function MissionInterviewModal({
const hasAutoStartedRef = useRef(false);
const [streamingOutput, setStreamingOutput] = useState("");
const [showThinking, setShowThinking] = useState(true);
const [isReconnecting, setIsReconnecting] = useState(false);
const [isCreating, setIsCreating] = useState(false);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const streamConnectionRef = useRef<{ close: () => void; isConnected: () => boolean } | null>(null);
@@ -90,6 +91,7 @@ export function MissionInterviewModal({
setError(null);
setStreamingOutput("");
setIsReconnecting(false);
setView({ type: "loading" });
try {
@@ -102,12 +104,14 @@ export function MissionInterviewModal({
setStreamingOutput((prev) => prev + data);
},
onQuestion: (question) => {
setIsReconnecting(false);
clearMissionGoal();
setView({ type: "question", sessionId, question });
setStreamingOutput("");
setHasProgress(true);
},
onSummary: (summary) => {
setIsReconnecting(false);
clearMissionGoal();
setView({ type: "summary", sessionId, summary });
setEditedSummary(summary);
@@ -115,19 +119,25 @@ export function MissionInterviewModal({
setHasProgress(true);
},
onError: (message) => {
setIsReconnecting(false);
setError(message);
setView({ type: "initial" });
setStreamingOutput("");
currentSessionIdRef.current = null;
},
onComplete: () => {
setIsReconnecting(false);
currentSessionIdRef.current = null;
},
onConnectionStateChange: (state) => {
setIsReconnecting(state === "reconnecting");
},
});
streamConnectionRef.current = connection;
setResponseHistory([]);
} catch (err: any) {
setIsReconnecting(false);
setError(err.message || "Failed to start interview session");
setView({ type: "initial" });
currentSessionIdRef.current = null;
@@ -164,6 +174,7 @@ export function MissionInterviewModal({
useEffect(() => {
if (!isOpen) {
hasAutoStartedRef.current = false;
setIsReconnecting(false);
}
}, [isOpen]);
@@ -210,25 +221,32 @@ export function MissionInterviewModal({
setStreamingOutput((prev) => prev + data);
},
onQuestion: (question) => {
setIsReconnecting(false);
clearMissionGoal();
setView({ type: "question", sessionId: session.id, question });
setStreamingOutput("");
},
onSummary: (summary) => {
setIsReconnecting(false);
clearMissionGoal();
setView({ type: "summary", sessionId: session.id, summary });
setEditedSummary(summary);
setStreamingOutput("");
},
onError: (message) => {
setIsReconnecting(false);
setError(message);
setView({ type: "initial" });
setStreamingOutput("");
currentSessionIdRef.current = null;
},
onComplete: () => {
setIsReconnecting(false);
currentSessionIdRef.current = null;
},
onConnectionStateChange: (state) => {
setIsReconnecting(state === "reconnecting");
},
});
streamConnectionRef.current = connection;
@@ -297,6 +315,7 @@ export function MissionInterviewModal({
setResponseHistory([]);
setEditedSummary(null);
setStreamingOutput("");
setIsReconnecting(false);
setHasProgress(false);
setIsCreating(false);
currentSessionIdRef.current = null;
@@ -363,6 +382,7 @@ export function MissionInterviewModal({
setResponseHistory([]);
setEditedSummary(null);
setStreamingOutput("");
setIsReconnecting(false);
setHasProgress(false);
setIsCreating(false);
currentSessionIdRef.current = null;
@@ -397,6 +417,7 @@ export function MissionInterviewModal({
<div className="planning-modal-body">
{error && <div className="form-error planning-error">{error}</div>}
{isReconnecting && <div className="form-hint text-muted">Reconnecting</div>}
{view.type === "initial" && (
<div className="planning-initial">

View File

@@ -1,5 +1,5 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, fireEvent, waitFor, within } from "@testing-library/react";
import { act, render, screen, fireEvent, waitFor, within } from "@testing-library/react";
import { PlanningModeModal } from "./PlanningModeModal";
import { TaskDetailModal } from "./TaskDetailModal";
import type { Task, TaskDetail, PlanningQuestion, PlanningSummary, MergeResult } from "@fusion/core";
@@ -544,6 +544,55 @@ describe("PlanningModeModal", () => {
expect(container.querySelector(".planning-question-form > .planning-actions")).not.toBeNull();
});
it("shows reconnecting indicator without clearing current question state", async () => {
let streamHandlers: any;
mockConnectPlanningStream.mockImplementationOnce((_sessionId: string, _projectId: string | undefined, handlers: any) => {
streamHandlers = handlers;
setTimeout(() => {
handlers.onQuestion?.(mockQuestion);
}, 10);
return {
close: vi.fn(),
isConnected: vi.fn().mockReturnValue(true),
};
});
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"));
await waitFor(() => {
expect(screen.getByText("What is the scope?")).toBeDefined();
});
act(() => {
streamHandlers.onConnectionStateChange?.("reconnecting");
});
expect(screen.getByText("Reconnecting…")).toBeDefined();
expect(screen.getByText("What is the scope?")).toBeDefined();
act(() => {
streamHandlers.onConnectionStateChange?.("connected");
});
await waitFor(() => {
expect(screen.queryByText("Reconnecting…")).toBeNull();
});
expect(screen.getByText("What is the scope?")).toBeDefined();
});
it("receives second question after answering first without hanging (race condition fix)", async () => {
const secondQuestion: PlanningQuestion = {
id: "q-requirements",

View File

@@ -85,6 +85,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
const hasAutoStartedRef = useRef(false);
const [streamingOutput, setStreamingOutput] = useState<string>("");
const [showThinking, setShowThinking] = useState(true);
const [isReconnecting, setIsReconnecting] = useState(false);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const streamConnectionRef = useRef<{ close: () => void; isConnected: () => boolean } | null>(null);
const currentSessionIdRef = useRef<string | null>(null);
@@ -141,6 +142,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
setError(null);
setStreamingOutput("");
setIsReconnecting(false);
setView({ type: "loading" });
try {
@@ -159,6 +161,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
setStreamingOutput((prev) => prev + data);
},
onQuestion: (question) => {
setIsReconnecting(false);
clearPlanningDescription();
setView({
type: "question",
@@ -167,6 +170,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
setStreamingOutput("");
},
onSummary: (summary) => {
setIsReconnecting(false);
clearPlanningDescription();
setView({
type: "summary",
@@ -177,19 +181,25 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
setStreamingOutput("");
},
onError: (message) => {
setIsReconnecting(false);
setError(message);
setView({ type: "initial" });
setStreamingOutput("");
currentSessionIdRef.current = null;
},
onComplete: () => {
setIsReconnecting(false);
currentSessionIdRef.current = null;
},
onConnectionStateChange: (state) => {
setIsReconnecting(state === "reconnecting");
},
});
streamConnectionRef.current = connection;
setResponseHistory([]);
} catch (err: any) {
setIsReconnecting(false);
setError(err.message || "Failed to start planning session");
setView({ type: "initial" });
currentSessionIdRef.current = null;
@@ -258,18 +268,30 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
const connection = connectPlanningStream(resumeSessionId, projectId, {
onThinking: (data) => setStreamingOutput((prev) => prev + data),
onQuestion: (question) => {
setIsReconnecting(false);
clearPlanningDescription();
setView({ type: "question", session: { sessionId: resumeSessionId, currentQuestion: question, summary: null } });
setStreamingOutput("");
},
onSummary: (summary) => {
setIsReconnecting(false);
clearPlanningDescription();
setView({ type: "summary", session: { sessionId: resumeSessionId, currentQuestion: null, summary }, summary });
setEditedSummary(summary);
setStreamingOutput("");
},
onError: (message) => { setError(message); setView({ type: "initial" }); },
onComplete: () => { currentSessionIdRef.current = null; },
onError: (message) => {
setIsReconnecting(false);
setError(message);
setView({ type: "initial" });
},
onComplete: () => {
setIsReconnecting(false);
currentSessionIdRef.current = null;
},
onConnectionStateChange: (state) => {
setIsReconnecting(state === "reconnecting");
},
});
streamConnectionRef.current = connection;
} else if (session.status === "error") {
@@ -287,6 +309,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
useEffect(() => {
if (!isOpen) {
hasAutoStartedRef.current = false;
setIsReconnecting(false);
}
}, [isOpen]);
@@ -328,6 +351,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
setResponseHistory([]);
setEditedSummary(null);
setStreamingOutput("");
setIsReconnecting(false);
setPlanningModelProvider(undefined);
setPlanningModelId(undefined);
currentSessionIdRef.current = null;
@@ -475,6 +499,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
<div className="planning-modal-body">
{error && <div className="form-error planning-error">{error}</div>}
{isReconnecting && <div className="form-hint text-muted">Reconnecting</div>}
{view.type === "initial" && (
<div className="planning-initial">

View File

@@ -1,5 +1,5 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
import { act, render, screen, fireEvent, waitFor } from "@testing-library/react";
import { SubtaskBreakdownModal } from "./SubtaskBreakdownModal";
const mockStartSubtaskBreakdown = vi.fn();
@@ -78,6 +78,48 @@ describe("SubtaskBreakdownModal", () => {
expect(screen.getByDisplayValue("Do second")).toBeInTheDocument();
});
it("shows reconnecting indicator without clearing subtask state", async () => {
renderModal();
await waitFor(() => expect(streamHandlers).toBeDefined());
streamHandlers.onSubtasks(SAMPLE_SUBTASKS);
expect(await screen.findByDisplayValue("First")).toBeInTheDocument();
act(() => {
streamHandlers.onConnectionStateChange?.("reconnecting");
});
await waitFor(() => {
expect(screen.getByText("Reconnecting…")).toBeInTheDocument();
});
expect(screen.getByDisplayValue("First")).toBeInTheDocument();
act(() => {
streamHandlers.onConnectionStateChange?.("connected");
});
await waitFor(() => {
expect(screen.queryByText("Reconnecting…")).not.toBeInTheDocument();
});
expect(screen.getByDisplayValue("First")).toBeInTheDocument();
});
it("preserves thinking output while reconnecting in generating state", async () => {
renderModal();
await waitFor(() => expect(streamHandlers).toBeDefined());
act(() => {
streamHandlers.onThinking?.("Generating subtasks...");
});
expect(await screen.findByText("Generating subtasks...")).toBeInTheDocument();
act(() => {
streamHandlers.onConnectionStateChange?.("reconnecting");
});
await waitFor(() => {
expect(screen.getByText("Reconnecting…")).toBeInTheDocument();
});
expect(screen.getByText("Generating subtasks...")).toBeInTheDocument();
});
it("adds and removes subtasks", async () => {
renderModal();
await waitFor(() => expect(streamHandlers).toBeDefined());

View File

@@ -66,6 +66,7 @@ export function SubtaskBreakdownModal({ isOpen, onClose, initialDescription, onT
const [subtasks, setSubtasks] = useState<SubtaskItem[]>([]);
const [thinkingOutput, setThinkingOutput] = useState("");
const [showThinking, setShowThinking] = useState(true);
const [isReconnecting, setIsReconnecting] = useState(false);
// Local description: synced from prop, can fall back to localStorage
const [localDescription, setLocalDescription] = useState(initialDescription);
const [error, setError] = useState<string | null>(null);
@@ -101,6 +102,7 @@ export function SubtaskBreakdownModal({ isOpen, onClose, initialDescription, onT
setSubtasks([]);
setThinkingOutput("");
setShowThinking(true);
setIsReconnecting(false);
setError(null);
setDirty(false);
autoStartedRef.current = false;
@@ -125,6 +127,7 @@ export function SubtaskBreakdownModal({ isOpen, onClose, initialDescription, onT
if (!localDescription.trim()) return;
setError(null);
setThinkingOutput("");
setIsReconnecting(false);
try {
const { sessionId } = await startSubtaskBreakdown(localDescription.trim(), projectId);
@@ -133,15 +136,20 @@ export function SubtaskBreakdownModal({ isOpen, onClose, initialDescription, onT
streamRef.current = connectSubtaskStream(sessionId, projectId, {
onThinking: (data) => setThinkingOutput((prev) => prev + data),
onSubtasks: (items) => {
setIsReconnecting(false);
clearSubtaskDescription();
setSubtasks(items);
setView({ type: "editing", sessionId });
setDirty(false);
},
onError: (message) => {
setIsReconnecting(false);
setError(message);
setView({ type: "initial" });
},
onConnectionStateChange: (state) => {
setIsReconnecting(state === "reconnecting");
},
});
} catch (err: any) {
setError(err.message || "Failed to start subtask breakdown");
@@ -182,15 +190,20 @@ export function SubtaskBreakdownModal({ isOpen, onClose, initialDescription, onT
streamRef.current = connectSubtaskStream(resumeSessionId, projectId, {
onThinking: (data) => setThinkingOutput((prev) => prev + data),
onSubtasks: (items) => {
setIsReconnecting(false);
clearSubtaskDescription();
setSubtasks(items);
setView({ type: "editing", sessionId: resumeSessionId });
setDirty(false);
},
onError: (message) => {
setIsReconnecting(false);
setError(message);
setView({ type: "initial" });
},
onConnectionStateChange: (state) => {
setIsReconnecting(state === "reconnecting");
},
});
} else if (session.status === "complete" && session.result) {
clearSubtaskDescription();
@@ -361,6 +374,7 @@ export function SubtaskBreakdownModal({ isOpen, onClose, initialDescription, onT
<div className="planning-modal-body">
{error && <div className="form-error planning-error">{error}</div>}
{isReconnecting && <div className="form-hint text-muted">Reconnecting</div>}
{view.type === "initial" && (
<div className="planning-initial">

View File

@@ -0,0 +1,69 @@
import { describe, expect, it } from "vitest";
import { SessionEventBuffer } from "../sse-buffer.js";
describe("SessionEventBuffer", () => {
it("push assigns monotonically increasing ids", () => {
const buffer = new SessionEventBuffer(10);
const id1 = buffer.push("thinking", JSON.stringify("a"));
const id2 = buffer.push("question", JSON.stringify({ id: "q-1" }));
expect(id1).toBe(1);
expect(id2).toBe(2);
});
it("getEventsSince returns only events newer than lastEventId", () => {
const buffer = new SessionEventBuffer(10);
buffer.push("thinking", JSON.stringify("a"));
buffer.push("thinking", JSON.stringify("b"));
buffer.push("question", JSON.stringify({ id: "q-1" }));
const events = buffer.getEventsSince(1);
expect(events.map((event) => event.id)).toEqual([2, 3]);
});
it("drops oldest events when capacity overflows", () => {
const buffer = new SessionEventBuffer(2);
buffer.push("thinking", JSON.stringify("a"));
buffer.push("thinking", JSON.stringify("b"));
buffer.push("question", JSON.stringify({ id: "q-1" }));
const events = buffer.getEventsSince(0);
expect(events).toHaveLength(2);
expect(events[0]?.id).toBe(2);
expect(events[1]?.id).toBe(3);
});
it("returns empty array for an empty buffer", () => {
const buffer = new SessionEventBuffer(10);
expect(buffer.getEventsSince(0)).toEqual([]);
});
it("returns all buffered events for non-finite lastEventId", () => {
const buffer = new SessionEventBuffer(10);
buffer.push("thinking", JSON.stringify("a"));
buffer.push("complete", JSON.stringify({}));
expect(buffer.getEventsSince(Number.NaN)).toHaveLength(2);
});
it("supports interleaved push/read access without duplicate ids", async () => {
const buffer = new SessionEventBuffer(20);
await Promise.all(
Array.from({ length: 10 }, (_, index) =>
Promise.resolve().then(() => {
const eventId = buffer.push("thinking", JSON.stringify(`event-${index + 1}`));
const snapshot = buffer.getEventsSince(Math.max(0, eventId - 1));
expect(snapshot[snapshot.length - 1]?.id).toBe(eventId);
}),
),
);
const events = buffer.getEventsSince(0);
expect(events).toHaveLength(10);
expect(events.map((event) => event.id)).toEqual([1, 2, 3, 4, 5, 6, 7, 8, 9, 10]);
});
});

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(

View File

@@ -183,8 +183,9 @@ describe("mission-interview module", () => {
expect(missionInterviewStreamManager.hasSubscribers("session-1")).toBe(true);
missionInterviewStreamManager.broadcast("session-1", { type: "thinking", data: "analyzing" });
expect(callback).toHaveBeenCalledWith({ type: "thinking", data: "analyzing" });
const eventId = missionInterviewStreamManager.broadcast("session-1", { type: "thinking", data: "analyzing" });
expect(eventId).toBe(1);
expect(callback).toHaveBeenCalledWith({ type: "thinking", data: "analyzing" }, 1);
unsubscribe();
expect(missionInterviewStreamManager.hasSubscribers("session-1")).toBe(false);
@@ -192,6 +193,28 @@ describe("mission-interview module", () => {
missionInterviewStreamManager.cleanupSession("session-1");
expect(missionInterviewStreamManager.hasSubscribers("session-1")).toBe(false);
});
it("returns buffered events since last event id", () => {
const sessionId = "session-buffered";
missionInterviewStreamManager.broadcast(sessionId, { type: "thinking", data: "delta-1" });
missionInterviewStreamManager.broadcast(sessionId, { type: "thinking", data: "delta-2" });
missionInterviewStreamManager.broadcast(sessionId, { type: "complete" });
const buffered = missionInterviewStreamManager.getBufferedEvents(sessionId, 1);
expect(buffered).toHaveLength(2);
expect(buffered.map((event) => event.id)).toEqual([2, 3]);
expect(buffered[1]).toMatchObject({ event: "complete", data: "{}" });
});
it("clears buffered events on cleanup", () => {
const sessionId = "session-cleanup";
missionInterviewStreamManager.broadcast(sessionId, { type: "thinking", data: "delta" });
expect(missionInterviewStreamManager.getBufferedEvents(sessionId, 0)).toHaveLength(1);
missionInterviewStreamManager.cleanupSession(sessionId);
expect(missionInterviewStreamManager.getBufferedEvents(sessionId, 0)).toEqual([]);
});
});
describe("response parsing", () => {

View File

@@ -18,6 +18,7 @@ import type { PlanningQuestion } from "@fusion/core";
import { randomUUID } from "node:crypto";
import { EventEmitter } from "node:events";
import type { AiSessionStore, AiSessionRow } from "./ai-session-store.js";
import { SessionEventBuffer, type SessionBufferedEvent } from "./sse-buffer.js";
// Dynamic import for @fusion/engine to avoid resolution issues in test environment
// eslint-disable-next-line @typescript-eslint/consistent-type-imports, @typescript-eslint/no-explicit-any
@@ -151,7 +152,7 @@ export type MissionInterviewStreamEvent =
| { type: "complete" };
/** Callback function for streaming events */
export type MissionInterviewStreamCallback = (event: MissionInterviewStreamEvent) => void;
export type MissionInterviewStreamCallback = (event: MissionInterviewStreamEvent, eventId?: number) => void;
/** In-memory interview session */
interface MissionInterviewSession {
@@ -242,7 +243,12 @@ process.on("beforeExit", () => clearInterval(cleanupInterval));
// ── Stream Manager ──────────────────────────────────────────────────────────
export class MissionInterviewStreamManager extends EventEmitter {
private sessions = new Map<string, Set<MissionInterviewStreamCallback>>();
private readonly sessions = new Map<string, Set<MissionInterviewStreamCallback>>();
private readonly buffers = new Map<string, SessionEventBuffer>();
constructor(private readonly bufferSize = 100) {
super();
}
subscribe(sessionId: string, callback: MissionInterviewStreamCallback): () => void {
if (!this.sessions.has(sessionId)) {
@@ -258,16 +264,38 @@ export class MissionInterviewStreamManager extends EventEmitter {
};
}
broadcast(sessionId: string, event: MissionInterviewStreamEvent): void {
private getBuffer(sessionId: string): SessionEventBuffer {
let buffer = this.buffers.get(sessionId);
if (!buffer) {
buffer = new SessionEventBuffer(this.bufferSize);
this.buffers.set(sessionId, buffer);
}
return buffer;
}
broadcast(sessionId: string, event: MissionInterviewStreamEvent): number {
const serialized = JSON.stringify((event as { data?: unknown }).data ?? {});
const eventData = typeof serialized === "string" ? serialized : "{}";
const eventId = this.getBuffer(sessionId).push(event.type, eventData);
const callbacks = this.sessions.get(sessionId);
if (!callbacks) return;
if (!callbacks) return eventId;
for (const callback of callbacks) {
try {
callback(event);
callback(event, eventId);
} catch (err) {
console.error(`[mission-interview] Error broadcasting to client for session ${sessionId}:`, err);
}
}
return eventId;
}
getBufferedEvents(sessionId: string, sinceId: number): SessionBufferedEvent[] {
const buffer = this.buffers.get(sessionId);
if (!buffer) return [];
return buffer.getEventsSince(sinceId);
}
hasSubscribers(sessionId: string): boolean {
@@ -277,6 +305,13 @@ export class MissionInterviewStreamManager extends EventEmitter {
cleanupSession(sessionId: string): void {
this.sessions.delete(sessionId);
this.buffers.delete(sessionId);
}
reset(): void {
this.sessions.clear();
this.buffers.clear();
this.removeAllListeners();
}
}
@@ -807,7 +842,7 @@ export function __resetMissionInterviewState(): void {
}
sessions.clear();
rateLimits.clear();
missionInterviewStreamManager.removeAllListeners();
missionInterviewStreamManager.reset();
}
// ── Custom Errors ───────────────────────────────────────────────────────────

View File

@@ -39,6 +39,7 @@ import {
FEATURE_STATUSES,
INTERVIEW_STATES,
} from "@fusion/core";
import { writeSSEEvent } from "./sse-buffer.js";
// ── Validation Utilities ────────────────────────────────────────────────────
@@ -141,6 +142,34 @@ function asyncHandler(fn: (req: TypedRequest, res: Response, next: NextFunction)
// ── Router Factory ──────────────────────────────────────────────────────────
function parseLastEventId(req: Request): number | undefined {
const rawHeader = req.headers["last-event-id"];
const rawQuery = req.query.lastEventId;
const raw = Array.isArray(rawHeader)
? rawHeader[0]
: (typeof rawHeader === "string" ? rawHeader : Array.isArray(rawQuery) ? rawQuery[0] : rawQuery);
if (raw === undefined || raw === null) return undefined;
const parsed = Number.parseInt(String(raw), 10);
if (!Number.isFinite(parsed) || parsed < 0) return undefined;
return parsed;
}
function replayBufferedSSE(
res: Response,
bufferedEvents: Array<{ id: number; event: string; data: string }>,
): boolean {
for (const bufferedEvent of bufferedEvents) {
if (!writeSSEEvent(res, bufferedEvent.event, bufferedEvent.data, bufferedEvent.id)) {
return false;
}
}
return true;
}
export function createMissionRouter(
store: TaskStore,
missionAutopilot?: {
@@ -407,25 +436,60 @@ export function createMissionRouter(
// Verify session exists
const session = getMissionInterviewSession(sessionId);
if (!session) {
res.write(`event: error\ndata: ${JSON.stringify({ message: "Session not found or expired" })}\n\n`);
writeSSEEvent(res, "error", JSON.stringify({ message: "Session not found or expired" }));
res.end();
return;
}
const lastEventId = parseLastEventId(req);
if (lastEventId !== undefined) {
const buffered = missionInterviewStreamManager.getBufferedEvents(sessionId, lastEventId);
if (!replayBufferedSSE(res, buffered)) {
res.end();
return;
}
}
if (session.summary) {
const existing = missionInterviewStreamManager.getBufferedEvents(sessionId, 0);
const lastSummaryEvent = [...existing].reverse().find((event) => event.event === "summary");
const summaryEventId = lastSummaryEvent?.id
?? missionInterviewStreamManager.broadcast(sessionId, {
type: "summary",
data: session.summary,
});
if (lastEventId === undefined || summaryEventId > lastEventId) {
if (!writeSSEEvent(res, "summary", JSON.stringify(session.summary), summaryEventId)) {
res.end();
return;
}
}
const lastCompleteEvent = [...existing].reverse().find((event) => event.event === "complete");
const completeEventId = lastCompleteEvent?.id
?? missionInterviewStreamManager.broadcast(sessionId, { type: "complete" });
if (lastEventId === undefined || completeEventId > lastEventId) {
writeSSEEvent(res, "complete", JSON.stringify({}), completeEventId);
}
res.end();
return;
}
// Subscribe to session events
const unsubscribe = missionInterviewStreamManager.subscribe(sessionId, (event) => {
try {
const data = (event as { data?: unknown }).data;
res.write(`event: ${event.type}\ndata: ${JSON.stringify(data ?? {})}\n\n`);
// End stream on complete or error
if (event.type === "complete" || event.type === "error") {
unsubscribe();
res.end();
}
} catch {
// Client disconnected
const unsubscribe = missionInterviewStreamManager.subscribe(sessionId, (event, eventId) => {
const data = (event as { data?: unknown }).data;
if (!writeSSEEvent(res, event.type, JSON.stringify(data ?? {}), eventId)) {
unsubscribe();
return;
}
// End stream on complete or error
if (event.type === "complete" || event.type === "error") {
unsubscribe();
res.end();
}
});
@@ -447,7 +511,7 @@ export function createMissionRouter(
clearInterval(heartbeat);
});
} catch (err: any) {
res.write(`event: error\ndata: ${JSON.stringify({ message: err.message || "Stream error" })}\n\n`);
writeSSEEvent(res, "error", JSON.stringify({ message: err.message || "Stream error" }));
res.end();
}
})

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(

View File

@@ -22,6 +22,7 @@ import type { SubtaskItem } from "./subtask-breakdown.js";
import { randomUUID } from "node:crypto";
import { EventEmitter } from "node:events";
import type { AiSessionStore, AiSessionRow } from "./ai-session-store.js";
import { SessionEventBuffer, type SessionBufferedEvent } from "./sse-buffer.js";
// Dynamic import for @fusion/engine to avoid resolution issues in test environment
// eslint-disable-next-line @typescript-eslint/consistent-type-imports, @typescript-eslint/no-explicit-any
@@ -117,7 +118,7 @@ export type PlanningStreamEvent =
| { type: "complete" };
/** Callback function for streaming events */
export type PlanningStreamCallback = (event: PlanningStreamEvent) => void;
export type PlanningStreamCallback = (event: PlanningStreamEvent, eventId?: number) => void;
interface Session {
id: string;
@@ -241,7 +242,12 @@ process.on("beforeExit", () => {
* Each session can have multiple connected clients receiving streaming updates.
*/
export class PlanningStreamManager extends EventEmitter {
private sessions = new Map<string, Set<PlanningStreamCallback>>();
private readonly sessions = new Map<string, Set<PlanningStreamCallback>>();
private readonly buffers = new Map<string, SessionEventBuffer>();
constructor(private readonly bufferSize = 100) {
super();
}
/**
* Register a client callback for a planning session.
@@ -251,11 +257,10 @@ export class PlanningStreamManager extends EventEmitter {
if (!this.sessions.has(sessionId)) {
this.sessions.set(sessionId, new Set());
}
const callbacks = this.sessions.get(sessionId)!;
callbacks.add(callback);
// Return unsubscribe function
return () => {
callbacks.delete(callback);
if (callbacks.size === 0) {
@@ -264,20 +269,45 @@ export class PlanningStreamManager extends EventEmitter {
};
}
private getBuffer(sessionId: string): SessionEventBuffer {
let buffer = this.buffers.get(sessionId);
if (!buffer) {
buffer = new SessionEventBuffer(this.bufferSize);
this.buffers.set(sessionId, buffer);
}
return buffer;
}
/**
* Broadcast an event to all clients subscribed to a session.
* Every event is buffered and assigned a monotonically increasing id.
*/
broadcast(sessionId: string, event: PlanningStreamEvent): void {
broadcast(sessionId: string, event: PlanningStreamEvent): number {
const serialized = JSON.stringify((event as { data?: unknown }).data ?? {});
const eventData = typeof serialized === "string" ? serialized : "{}";
const eventId = this.getBuffer(sessionId).push(event.type, eventData);
const callbacks = this.sessions.get(sessionId);
if (!callbacks) return;
if (!callbacks) return eventId;
for (const callback of callbacks) {
try {
callback(event);
callback(event, eventId);
} catch (err) {
console.error(`[planning] Error broadcasting to client for session ${sessionId}:`, err);
}
}
return eventId;
}
/**
* Get buffered events with id > sinceId for the session.
*/
getBufferedEvents(sessionId: string, sinceId: number): SessionBufferedEvent[] {
const buffer = this.buffers.get(sessionId);
if (!buffer) return [];
return buffer.getEventsSince(sinceId);
}
/**
@@ -296,10 +326,20 @@ export class PlanningStreamManager extends EventEmitter {
}
/**
* Clean up all subscriptions for a session.
* Clean up all subscriptions and buffered events for a session.
*/
cleanupSession(sessionId: string): void {
this.sessions.delete(sessionId);
this.buffers.delete(sessionId);
}
/**
* Reset all subscriptions and buffers (test helper).
*/
reset(): void {
this.sessions.clear();
this.buffers.clear();
this.removeAllListeners();
}
}
@@ -1239,7 +1279,7 @@ export function __resetPlanningState(): void {
}
sessions.clear();
rateLimits.clear();
planningStreamManager.removeAllListeners();
planningStreamManager.reset();
}
/**

View File

@@ -15,8 +15,8 @@ import type { TaskStore, TaskAttachment } from "@fusion/core";
import type { TaskDetail } from "@fusion/core";
import type { AuthStorageLike, ModelRegistryLike } from "./routes.js";
import { __resetBatchImportRateLimiter } from "./routes.js";
import { __resetPlanningState, __setCreateKbAgent } from "./planning.js";
import { __resetSubtaskBreakdownState } from "./subtask-breakdown.js";
import { __resetPlanningState, __setCreateKbAgent, planningStreamManager } from "./planning.js";
import { __resetSubtaskBreakdownState, subtaskStreamManager } from "./subtask-breakdown.js";
import * as terminalServiceModule from "./terminal-service.js";
import { get as performGet, request as performRequest } from "./test-request.js";
@@ -603,6 +603,136 @@ describe("POST /subtasks/*", () => {
expect(typeof res.body.sessionId).toBe("string");
});
it("replays buffered subtask events using lastEventId query param", async () => {
const start = await REQUEST(
buildApp(),
"POST",
"/api/subtasks/start-streaming",
JSON.stringify({ description: "Replay buffered subtask stream" }),
{ "Content-Type": "application/json" },
);
const sessionId = start.body.sessionId as string;
// Reset any initial stream manager state from background generation.
subtaskStreamManager.cleanupSession(sessionId);
subtaskStreamManager.broadcast(sessionId, { type: "thinking", data: "first" });
subtaskStreamManager.broadcast(sessionId, { type: "thinking", data: "second" });
setTimeout(() => {
subtaskStreamManager.broadcast(sessionId, { type: "complete" });
}, 0);
const streamRes = await REQUEST(
buildApp(),
"GET",
`/api/subtasks/${sessionId}/stream?lastEventId=1`,
);
expect(streamRes.status).toBe(200);
expect(streamRes.body).toContain("id: 2");
expect(streamRes.body).toContain("event: thinking");
expect(streamRes.body).toContain("event: complete");
expect(streamRes.body).not.toContain("id: 1\nevent: thinking");
});
it("replays buffered subtask events using Last-Event-ID header", async () => {
const start = await REQUEST(
buildApp(),
"POST",
"/api/subtasks/start-streaming",
JSON.stringify({ description: "Replay buffered subtask stream from header" }),
{ "Content-Type": "application/json" },
);
const sessionId = start.body.sessionId as string;
subtaskStreamManager.cleanupSession(sessionId);
subtaskStreamManager.broadcast(sessionId, { type: "thinking", data: "first" });
subtaskStreamManager.broadcast(sessionId, { type: "thinking", data: "second" });
setTimeout(() => {
subtaskStreamManager.broadcast(sessionId, { type: "complete" });
}, 0);
const streamRes = await REQUEST(
buildApp(),
"GET",
`/api/subtasks/${sessionId}/stream`,
undefined,
{ "Last-Event-ID": "1" },
);
expect(streamRes.status).toBe(200);
expect(streamRes.body).toContain("id: 2");
expect(streamRes.body).toContain("event: thinking");
expect(streamRes.body).toContain("event: complete");
expect(streamRes.body).not.toContain("id: 1\nevent: thinking");
});
it("skips subtask replay when Last-Event-ID is missing", async () => {
const start = await REQUEST(
buildApp(),
"POST",
"/api/subtasks/start-streaming",
JSON.stringify({ description: "No subtask replay without header" }),
{ "Content-Type": "application/json" },
);
const sessionId = start.body.sessionId as string;
subtaskStreamManager.cleanupSession(sessionId);
subtaskStreamManager.broadcast(sessionId, { type: "thinking", data: "first" });
setTimeout(() => {
subtaskStreamManager.broadcast(sessionId, { type: "complete" });
}, 0);
const streamRes = await REQUEST(
buildApp(),
"GET",
`/api/subtasks/${sessionId}/stream`,
);
expect(streamRes.status).toBe(200);
expect(streamRes.body).not.toContain("id: 1\nevent: thinking");
expect(streamRes.body).toContain("id: 2");
expect(streamRes.body).toContain("event: complete");
});
it("gracefully ignores invalid Last-Event-ID values for subtask streams", async () => {
const start = await REQUEST(
buildApp(),
"POST",
"/api/subtasks/start-streaming",
JSON.stringify({ description: "Invalid subtask last event id" }),
{ "Content-Type": "application/json" },
);
const sessionId = start.body.sessionId as string;
subtaskStreamManager.cleanupSession(sessionId);
subtaskStreamManager.broadcast(sessionId, { type: "thinking", data: "first" });
setTimeout(() => {
subtaskStreamManager.broadcast(sessionId, { type: "complete" });
}, 0);
const streamRes = await REQUEST(
buildApp(),
"GET",
`/api/subtasks/${sessionId}/stream`,
undefined,
{ "Last-Event-ID": "not-a-number" },
);
expect(streamRes.status).toBe(200);
expect(streamRes.body).not.toContain("id: 1\nevent: thinking");
expect(streamRes.body).toContain("id: 2");
expect(streamRes.body).toContain("event: complete");
});
it("creates tasks from a breakdown and resolves dependencies", async () => {
(store.createTask as ReturnType<typeof vi.fn>)
.mockResolvedValueOnce({ ...FAKE_TASK_DETAIL, id: "FN-101", title: "First", column: "triage" })
@@ -5801,6 +5931,99 @@ describe("Git Management endpoints", () => {
});
});
describe("GET /planning/:sessionId/stream", () => {
it("replays buffered events when Last-Event-ID header is provided", async () => {
const startRes = await REQUEST(
buildApp(),
"POST",
"/api/planning/start",
JSON.stringify({ initialPlan: "Reconnect planning stream" }),
{ "Content-Type": "application/json" },
);
const sessionId = startRes.body.sessionId as string;
planningStreamManager.broadcast(sessionId, { type: "thinking", data: "first" });
planningStreamManager.broadcast(sessionId, { type: "thinking", data: "second" });
setTimeout(() => {
planningStreamManager.broadcast(sessionId, { type: "complete" });
}, 0);
const streamRes = await REQUEST(
buildApp(),
"GET",
`/api/planning/${sessionId}/stream`,
undefined,
{ "Last-Event-ID": "1" },
);
expect(streamRes.status).toBe(200);
expect(typeof streamRes.body).toBe("string");
expect(streamRes.body).toContain("id: 2");
expect(streamRes.body).toContain("event: thinking");
expect(streamRes.body).toContain("id: 3");
expect(streamRes.body).toContain("event: complete");
expect(streamRes.body).not.toContain("id: 1\nevent: thinking");
});
it("skips replay when Last-Event-ID is missing", async () => {
const startRes = await REQUEST(
buildApp(),
"POST",
"/api/planning/start",
JSON.stringify({ initialPlan: "No replay planning stream" }),
{ "Content-Type": "application/json" },
);
const sessionId = startRes.body.sessionId as string;
planningStreamManager.broadcast(sessionId, { type: "thinking", data: "first" });
setTimeout(() => {
planningStreamManager.broadcast(sessionId, { type: "complete" });
}, 0);
const streamRes = await REQUEST(buildApp(), "GET", `/api/planning/${sessionId}/stream`);
expect(streamRes.status).toBe(200);
expect(streamRes.body).not.toContain("id: 1\nevent: thinking");
expect(streamRes.body).toContain("id: 2");
expect(streamRes.body).toContain("event: complete");
});
it("gracefully ignores invalid Last-Event-ID values", async () => {
const startRes = await REQUEST(
buildApp(),
"POST",
"/api/planning/start",
JSON.stringify({ initialPlan: "Invalid last event id" }),
{ "Content-Type": "application/json" },
);
const sessionId = startRes.body.sessionId as string;
planningStreamManager.broadcast(sessionId, { type: "thinking", data: "first" });
setTimeout(() => {
planningStreamManager.broadcast(sessionId, { type: "complete" });
}, 0);
const streamRes = await REQUEST(
buildApp(),
"GET",
`/api/planning/${sessionId}/stream`,
undefined,
{ "Last-Event-ID": "not-a-number" },
);
expect(streamRes.status).toBe(200);
expect(streamRes.body).not.toContain("id: 1\nevent: thinking");
expect(streamRes.body).toContain("id: 2");
expect(streamRes.body).toContain("event: complete");
});
});
describe("POST /planning/respond", () => {
it("processes response and returns next question", async () => {
// First create a session

View File

@@ -37,6 +37,7 @@ import {
SessionNotFoundError as AgentGenerationSessionNotFoundError,
} from "./agent-generation.js";
import { getMissionInterviewSession, cleanupMissionInterviewSession } from "./mission-interview.js";
import { writeSSEEvent } from "./sse-buffer.js";
/**
* Minimal interface matching pi-coding-agent's ModelRegistry API surface
@@ -1266,6 +1267,34 @@ export function __resetBatchImportRateLimiter(): void {
}
}
function parseLastEventId(req: Request): number | undefined {
const rawHeader = req.headers["last-event-id"];
const rawQuery = req.query.lastEventId;
const raw = Array.isArray(rawHeader)
? rawHeader[0]
: (typeof rawHeader === "string" ? rawHeader : Array.isArray(rawQuery) ? rawQuery[0] : rawQuery);
if (raw === undefined || raw === null) return undefined;
const parsed = Number.parseInt(String(raw), 10);
if (!Number.isFinite(parsed) || parsed < 0) return undefined;
return parsed;
}
function replayBufferedSSE(
res: Response,
bufferedEvents: Array<{ id: number; event: string; data: string }>,
): boolean {
for (const bufferedEvent of bufferedEvents) {
if (!writeSSEEvent(res, bufferedEvent.event, bufferedEvent.data, bufferedEvent.id)) {
return false;
}
}
return true;
}
export function createApiRoutes(store: TaskStore, options?: ServerOptions): Router {
const router = Router();
@@ -5387,39 +5416,80 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
const { subtaskStreamManager, getSubtaskSession } = await import("./subtask-breakdown.js");
const session = getSubtaskSession(sessionId);
if (!session) {
res.write(`event: error\ndata: ${JSON.stringify("Session not found or expired")}\n\n`);
writeSSEEvent(res, "error", JSON.stringify("Session not found or expired"));
res.end();
return;
}
const unsubscribe = subtaskStreamManager.subscribe(sessionId, (event) => {
try {
const data = (event as { data?: unknown }).data;
res.write(`event: ${event.type}\ndata: ${JSON.stringify(data ?? {})}\n\n`);
if (event.type === "complete" || event.type === "error") {
unsubscribe();
res.end();
}
} catch {
unsubscribe();
const lastEventId = parseLastEventId(req);
if (lastEventId !== undefined) {
const buffered = subtaskStreamManager.getBufferedEvents(sessionId, lastEventId);
if (!replayBufferedSSE(res, buffered)) {
res.end();
return;
}
});
}
if (session.status === "complete") {
res.write(`event: subtasks\ndata: ${JSON.stringify(session.subtasks)}\n\n`);
res.write("event: complete\ndata: {}\n\n");
unsubscribe();
const existing = subtaskStreamManager.getBufferedEvents(sessionId, 0);
const lastSubtasksEvent = [...existing].reverse().find((event) => event.event === "subtasks");
const subtasksEventId = lastSubtasksEvent?.id
?? subtaskStreamManager.broadcast(sessionId, {
type: "subtasks",
data: session.subtasks,
});
if (lastEventId === undefined || subtasksEventId > lastEventId) {
if (!writeSSEEvent(res, "subtasks", JSON.stringify(session.subtasks), subtasksEventId)) {
res.end();
return;
}
}
const lastCompleteEvent = [...existing].reverse().find((event) => event.event === "complete");
const completeEventId = lastCompleteEvent?.id
?? subtaskStreamManager.broadcast(sessionId, { type: "complete" });
if (lastEventId === undefined || completeEventId > lastEventId) {
writeSSEEvent(res, "complete", JSON.stringify({}), completeEventId);
}
res.end();
return;
}
if (session.status === "error") {
res.write(`event: error\ndata: ${JSON.stringify(String(session.error || "Unknown error"))}\n\n`);
unsubscribe();
const errorMessage = String(session.error || "Unknown error");
const existing = subtaskStreamManager.getBufferedEvents(sessionId, 0);
const lastErrorEvent = [...existing].reverse().find((event) => event.event === "error");
const errorEventId = lastErrorEvent?.id
?? subtaskStreamManager.broadcast(sessionId, {
type: "error",
data: errorMessage,
});
if (lastEventId === undefined || errorEventId > lastEventId) {
writeSSEEvent(res, "error", JSON.stringify(errorMessage), errorEventId);
}
res.end();
return;
}
const unsubscribe = subtaskStreamManager.subscribe(sessionId, (event, eventId) => {
const data = (event as { data?: unknown }).data;
if (!writeSSEEvent(res, event.type, JSON.stringify(data ?? {}), eventId)) {
unsubscribe();
return;
}
if (event.type === "complete" || event.type === "error") {
unsubscribe();
res.end();
}
});
const heartbeat = setInterval(() => {
if (res.writableEnded) {
clearInterval(heartbeat);
@@ -5433,7 +5503,7 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
unsubscribe();
});
} catch (err: any) {
res.write(`event: error\ndata: ${JSON.stringify(String(err?.message) || "Unknown error")}\n\n`);
writeSSEEvent(res, "error", JSON.stringify(String(err?.message) || "Unknown error"));
res.end();
}
});
@@ -6002,30 +6072,65 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
res.write(": connected\n\n");
try {
const { planningStreamManager, getSession, SessionNotFoundError } = await import("./planning.js");
const { planningStreamManager, getSession } = await import("./planning.js");
// Verify session exists
const session = getSession(sessionId);
if (!session) {
res.write(`event: error\ndata: ${JSON.stringify({ message: "Session not found or expired" })}\n\n`);
writeSSEEvent(res, "error", JSON.stringify({ message: "Session not found or expired" }));
res.end();
return;
}
const lastEventId = parseLastEventId(req);
if (lastEventId !== undefined) {
const buffered = planningStreamManager.getBufferedEvents(sessionId, lastEventId);
if (!replayBufferedSSE(res, buffered)) {
res.end();
return;
}
}
if (session.summary) {
const existing = planningStreamManager.getBufferedEvents(sessionId, 0);
const lastSummaryEvent = [...existing].reverse().find((event) => event.event === "summary");
const summaryEventId = lastSummaryEvent?.id
?? planningStreamManager.broadcast(sessionId, {
type: "summary",
data: session.summary,
});
if (lastEventId === undefined || summaryEventId > lastEventId) {
if (!writeSSEEvent(res, "summary", JSON.stringify(session.summary), summaryEventId)) {
res.end();
return;
}
}
const lastCompleteEvent = [...existing].reverse().find((event) => event.event === "complete");
const completeEventId = lastCompleteEvent?.id
?? planningStreamManager.broadcast(sessionId, { type: "complete" });
if (lastEventId === undefined || completeEventId > lastEventId) {
writeSSEEvent(res, "complete", JSON.stringify({}), completeEventId);
}
res.end();
return;
}
// Subscribe to session events
const unsubscribe = planningStreamManager.subscribe(sessionId, (event) => {
try {
const data = (event as { data?: unknown }).data;
res.write(`event: ${event.type}\ndata: ${JSON.stringify(data ?? {})}\n\n`);
// End stream on complete or error
if (event.type === "complete" || event.type === "error") {
unsubscribe();
res.end();
}
} catch (err) {
// Client disconnected
const unsubscribe = planningStreamManager.subscribe(sessionId, (event, eventId) => {
const data = (event as { data?: unknown }).data;
if (!writeSSEEvent(res, event.type, JSON.stringify(data ?? {}), eventId)) {
unsubscribe();
return;
}
// End stream on complete or error
if (event.type === "complete" || event.type === "error") {
unsubscribe();
res.end();
}
});
@@ -6047,7 +6152,7 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
clearInterval(heartbeat);
});
} catch (err: any) {
res.write(`event: error\ndata: ${JSON.stringify({ message: err.message || "Stream error" })}\n\n`);
writeSSEEvent(res, "error", JSON.stringify({ message: err.message || "Stream error" }));
res.end();
}
});

View File

@@ -0,0 +1,89 @@
import type { Response } from "express";
export interface SessionBufferedEvent {
id: number;
event: string;
data: string;
}
/**
* Per-session in-memory ring buffer for SSE events.
*
* Stores only the last N events and assigns monotonically increasing IDs.
*/
export class SessionEventBuffer {
private events: SessionBufferedEvent[] = [];
private nextId = 1;
constructor(private readonly maxCapacity = 100) {
if (!Number.isFinite(maxCapacity) || maxCapacity <= 0) {
throw new Error("maxCapacity must be a positive finite number");
}
}
/**
* Push an event into the buffer and return the assigned event id.
*/
push(event: string, data: string): number {
const id = this.nextId++;
this.events.push({ id, event, data });
if (this.events.length > this.maxCapacity) {
this.events.splice(0, this.events.length - this.maxCapacity);
}
return id;
}
/**
* Return all buffered events with id > lastEventId.
*/
getEventsSince(lastEventId: number): SessionBufferedEvent[] {
if (!Number.isFinite(lastEventId)) {
return [...this.events];
}
return this.events.filter((event) => event.id > lastEventId);
}
clear(): void {
this.events = [];
}
size(): number {
return this.events.length;
}
}
/**
* Render one SSE event payload (with optional id field).
*/
export function formatSSEEvent(event: string, data: string, id?: number): string {
const idLine = id !== undefined ? `id: ${id}\n` : "";
return `${idLine}event: ${event}\ndata: ${data}\n\n`;
}
/**
* Safely write to an SSE response stream.
*/
export function safeWriteSSE(res: Pick<Response, "write" | "writableEnded" | "destroyed">, payload: string): boolean {
try {
if (res.writableEnded || res.destroyed) return false;
res.write(payload);
return true;
} catch {
return false;
}
}
/**
* Write one SSE event to response with optional id field.
*/
export function writeSSEEvent(
res: Pick<Response, "write" | "writableEnded" | "destroyed">,
event: string,
data: string,
id?: number,
): boolean {
return safeWriteSSE(res, formatSSEEvent(event, data, id));
}

View File

@@ -0,0 +1,73 @@
// @vitest-environment node
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
__resetSubtaskBreakdownState,
subtaskStreamManager,
} from "./subtask-breakdown.js";
describe("subtask-breakdown stream buffering", () => {
beforeEach(() => {
__resetSubtaskBreakdownState();
});
it("buffers broadcast events and forwards ids to subscribers", () => {
const sessionId = "subtask-session-1";
const callback = vi.fn();
const unsubscribe = subtaskStreamManager.subscribe(sessionId, callback);
const firstId = subtaskStreamManager.broadcast(sessionId, {
type: "thinking",
data: "delta-1",
});
const secondId = subtaskStreamManager.broadcast(sessionId, {
type: "subtasks",
data: [
{
id: "subtask-1",
title: "Title",
description: "Description",
suggestedSize: "S",
dependsOn: [],
},
],
});
expect(firstId).toBe(1);
expect(secondId).toBe(2);
expect(callback).toHaveBeenNthCalledWith(1, { type: "thinking", data: "delta-1" }, 1);
expect(callback).toHaveBeenNthCalledWith(2, expect.objectContaining({ type: "subtasks" }), 2);
const buffered = subtaskStreamManager.getBufferedEvents(sessionId, 1);
expect(buffered).toHaveLength(1);
expect(buffered[0]).toMatchObject({ id: 2, event: "subtasks" });
unsubscribe();
});
it("buffers complete events without subscribers", () => {
const sessionId = "subtask-session-2";
const eventId = subtaskStreamManager.broadcast(sessionId, { type: "complete" });
expect(eventId).toBe(1);
expect(subtaskStreamManager.getBufferedEvents(sessionId, 0)).toEqual([
{ id: 1, event: "complete", data: "{}" },
]);
});
it("clears subscriptions and buffered events on cleanupSession", () => {
const sessionId = "subtask-session-3";
const callback = vi.fn();
subtaskStreamManager.subscribe(sessionId, callback);
subtaskStreamManager.broadcast(sessionId, { type: "thinking", data: "delta" });
expect(subtaskStreamManager.getBufferedEvents(sessionId, 0)).toHaveLength(1);
subtaskStreamManager.cleanupSession(sessionId);
expect(subtaskStreamManager.getBufferedEvents(sessionId, 0)).toEqual([]);
});
});

View File

@@ -2,6 +2,7 @@ import type { TaskStore } from "@fusion/core";
import { randomUUID } from "node:crypto";
import { EventEmitter } from "node:events";
import type { AiSessionStore, AiSessionRow } from "./ai-session-store.js";
import { SessionEventBuffer, type SessionBufferedEvent } from "./sse-buffer.js";
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let createKbAgent: any;
@@ -43,7 +44,7 @@ export type SubtaskStreamEvent =
| { type: "error"; data: string }
| { type: "complete" };
export type SubtaskStreamCallback = (event: SubtaskStreamEvent) => void;
export type SubtaskStreamCallback = (event: SubtaskStreamEvent, eventId?: number) => void;
const SESSION_TTL_MS = 30 * 60 * 1000;
const CLEANUP_INTERVAL_MS = 5 * 60 * 1000;
@@ -141,7 +142,12 @@ process.on("beforeExit", () => {
});
export class SubtaskStreamManager extends EventEmitter {
private sessions = new Map<string, Set<SubtaskStreamCallback>>();
private readonly sessions = new Map<string, Set<SubtaskStreamCallback>>();
private readonly buffers = new Map<string, SessionEventBuffer>();
constructor(private readonly bufferSize = 100) {
super();
}
subscribe(sessionId: string, callback: SubtaskStreamCallback): () => void {
if (!this.sessions.has(sessionId)) {
@@ -157,20 +163,49 @@ export class SubtaskStreamManager extends EventEmitter {
};
}
broadcast(sessionId: string, event: SubtaskStreamEvent): void {
private getBuffer(sessionId: string): SessionEventBuffer {
let buffer = this.buffers.get(sessionId);
if (!buffer) {
buffer = new SessionEventBuffer(this.bufferSize);
this.buffers.set(sessionId, buffer);
}
return buffer;
}
broadcast(sessionId: string, event: SubtaskStreamEvent): number {
const serialized = JSON.stringify((event as { data?: unknown }).data ?? {});
const eventData = typeof serialized === "string" ? serialized : "{}";
const eventId = this.getBuffer(sessionId).push(event.type, eventData);
const callbacks = this.sessions.get(sessionId);
if (!callbacks) return;
if (!callbacks) return eventId;
for (const callback of callbacks) {
try {
callback(event);
callback(event, eventId);
} catch {
// ignore subscriber failures
}
}
return eventId;
}
getBufferedEvents(sessionId: string, sinceId: number): SessionBufferedEvent[] {
const buffer = this.buffers.get(sessionId);
if (!buffer) return [];
return buffer.getEventsSince(sinceId);
}
cleanupSession(sessionId: string): void {
this.sessions.delete(sessionId);
this.buffers.delete(sessionId);
}
reset(): void {
this.sessions.clear();
this.buffers.clear();
this.removeAllListeners();
}
}
@@ -367,7 +402,7 @@ export function __resetSubtaskBreakdownState(): void {
}
}
sessions.clear();
subtaskStreamManager.removeAllListeners();
subtaskStreamManager.reset();
}
export class SessionNotFoundError extends Error {