feat(FN-1975): stream chat session updates through SSE
- Emit chat:session:updated from ChatStore when message deletion mutates a session and cover deleteMessage false returns - Pass ChatStore into SSE setup and forward chat session update events to connected clients - Update useChat to consume SSE updates in real time with safer EnrichedChatSession typing - Add core and dashboard tests for chat-store emissions, SSE forwarding, route wiring, and hook behavior
This commit is contained in:
@@ -928,6 +928,21 @@ describe("Chat API Routes", () => {
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
});
|
||||
|
||||
it("returns 404 when deleteMessage returns false", async () => {
|
||||
mockGetSession.mockReturnValue(sampleSession);
|
||||
mockGetMessage.mockReturnValue(sampleMessage);
|
||||
mockDeleteMessage.mockReturnValue(false);
|
||||
|
||||
const response = await request(
|
||||
app,
|
||||
"DELETE",
|
||||
"/api/chat/sessions/chat-abc123/messages/msg-xyz789",
|
||||
);
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect(mockDeleteMessage).toHaveBeenCalledWith("msg-xyz789");
|
||||
});
|
||||
});
|
||||
|
||||
// ── SSE Streaming Tests ────────────────────────────────────────────────────
|
||||
|
||||
@@ -355,6 +355,132 @@ describe("createSSE", () => {
|
||||
|
||||
// ── Plugin Lifecycle Event Tests ─────────────────────────────────────────────
|
||||
|
||||
describe("chat store events", () => {
|
||||
it("relays chat:session:created events when chatStore is provided", () => {
|
||||
const chatStore = createMockStore();
|
||||
const req = createMockRequest();
|
||||
const { res, chunks } = createMockResponse();
|
||||
createSSE(store, undefined, undefined, undefined, undefined, undefined, undefined, chatStore)(req, res);
|
||||
|
||||
const session = {
|
||||
id: "chat-abc123",
|
||||
agentId: "agent-001",
|
||||
title: "Test Session",
|
||||
status: "active",
|
||||
projectId: null,
|
||||
modelProvider: null,
|
||||
modelId: null,
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||
};
|
||||
chatStore.emit("chat:session:created", session);
|
||||
|
||||
const sseMsg = chunks.find((c) => c.includes("event: chat:session:created"));
|
||||
expect(sseMsg).toBeDefined();
|
||||
expect(extractSSEPayload(sseMsg!).id).toBe("chat-abc123");
|
||||
});
|
||||
|
||||
it("relays chat:session:updated events", () => {
|
||||
const chatStore = createMockStore();
|
||||
const req = createMockRequest();
|
||||
const { res, chunks } = createMockResponse();
|
||||
createSSE(store, undefined, undefined, undefined, undefined, undefined, undefined, chatStore)(req, res);
|
||||
|
||||
const session = {
|
||||
id: "chat-abc123",
|
||||
agentId: "agent-001",
|
||||
title: "Updated Title",
|
||||
status: "active",
|
||||
projectId: null,
|
||||
modelProvider: null,
|
||||
modelId: null,
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
updatedAt: "2026-01-02T00:00:00.000Z",
|
||||
};
|
||||
chatStore.emit("chat:session:updated", session);
|
||||
|
||||
const sseMsg = chunks.find((c) => c.includes("event: chat:session:updated"));
|
||||
expect(sseMsg).toBeDefined();
|
||||
const payload = extractSSEPayload(sseMsg!);
|
||||
expect(payload.id).toBe("chat-abc123");
|
||||
expect(payload.title).toBe("Updated Title");
|
||||
});
|
||||
|
||||
it("relays chat:session:deleted events with session ID", () => {
|
||||
const chatStore = createMockStore();
|
||||
const req = createMockRequest();
|
||||
const { res, chunks } = createMockResponse();
|
||||
createSSE(store, undefined, undefined, undefined, undefined, undefined, undefined, chatStore)(req, res);
|
||||
|
||||
chatStore.emit("chat:session:deleted", "chat-abc123");
|
||||
|
||||
const sseMsg = chunks.find((c) => c.includes("event: chat:session:deleted"));
|
||||
expect(sseMsg).toBeDefined();
|
||||
expect(extractSSEPayload(sseMsg!).id).toBe("chat-abc123");
|
||||
});
|
||||
|
||||
it("relays chat:message:added events with full message", () => {
|
||||
const chatStore = createMockStore();
|
||||
const req = createMockRequest();
|
||||
const { res, chunks } = createMockResponse();
|
||||
createSSE(store, undefined, undefined, undefined, undefined, undefined, undefined, chatStore)(req, res);
|
||||
|
||||
const message = {
|
||||
id: "msg-xyz789",
|
||||
sessionId: "chat-abc123",
|
||||
role: "user",
|
||||
content: "Hello, how are you?",
|
||||
thinkingOutput: null,
|
||||
metadata: null,
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
};
|
||||
chatStore.emit("chat:message:added", message);
|
||||
|
||||
const sseMsg = chunks.find((c) => c.includes("event: chat:message:added"));
|
||||
expect(sseMsg).toBeDefined();
|
||||
const payload = extractSSEPayload(sseMsg!);
|
||||
expect(payload.id).toBe("msg-xyz789");
|
||||
expect(payload.sessionId).toBe("chat-abc123");
|
||||
expect(payload.content).toBe("Hello, how are you?");
|
||||
});
|
||||
|
||||
it("relays chat:message:deleted events with message ID", () => {
|
||||
const chatStore = createMockStore();
|
||||
const req = createMockRequest();
|
||||
const { res, chunks } = createMockResponse();
|
||||
createSSE(store, undefined, undefined, undefined, undefined, undefined, undefined, chatStore)(req, res);
|
||||
|
||||
chatStore.emit("chat:message:deleted", "msg-xyz789");
|
||||
|
||||
const sseMsg = chunks.find((c) => c.includes("event: chat:message:deleted"));
|
||||
expect(sseMsg).toBeDefined();
|
||||
expect(extractSSEPayload(sseMsg!).id).toBe("msg-xyz789");
|
||||
});
|
||||
|
||||
it("cleans up chat store listeners on disconnect", () => {
|
||||
const chatStore = createMockStore();
|
||||
const req = createMockRequest();
|
||||
const { res } = createMockResponse();
|
||||
createSSE(store, undefined, undefined, undefined, undefined, undefined, undefined, chatStore)(req, res);
|
||||
|
||||
expect(chatStore.listenerCount("chat:session:created")).toBe(1);
|
||||
expect(chatStore.listenerCount("chat:session:updated")).toBe(1);
|
||||
expect(chatStore.listenerCount("chat:session:deleted")).toBe(1);
|
||||
expect(chatStore.listenerCount("chat:message:added")).toBe(1);
|
||||
expect(chatStore.listenerCount("chat:message:deleted")).toBe(1);
|
||||
|
||||
req.emit("close");
|
||||
|
||||
expect(chatStore.listenerCount("chat:session:created")).toBe(0);
|
||||
expect(chatStore.listenerCount("chat:session:updated")).toBe(0);
|
||||
expect(chatStore.listenerCount("chat:session:deleted")).toBe(0);
|
||||
expect(chatStore.listenerCount("chat:message:added")).toBe(0);
|
||||
expect(chatStore.listenerCount("chat:message:deleted")).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Plugin Lifecycle Event Tests ─────────────────────────────────────────────
|
||||
|
||||
describe("plugin lifecycle events", () => {
|
||||
it("emits plugin:lifecycle event for plugin:registered (installing transition)", () => {
|
||||
const pluginStore = createMockStore();
|
||||
|
||||
@@ -17,7 +17,7 @@ import { tmpdir } from "node:os";
|
||||
import * as nodeFs from "node:fs";
|
||||
|
||||
import { promisify } from "node:util";
|
||||
import type { TaskStore, Column, ScheduleType, ActivityEventType, ModelPreset, MessageType, ParticipantType, RoutineTriggerType, ProjectSettings } from "@fusion/core";
|
||||
import type { TaskStore, Column, ScheduleType, ActivityEventType, ModelPreset, MessageType, ParticipantType, RoutineTriggerType, ProjectSettings, EnrichedChatSession } from "@fusion/core";
|
||||
import { COLUMNS, VALID_TRANSITIONS, GLOBAL_SETTINGS_KEYS, type BatchStatusEntry, type BatchStatusResponse, type BatchStatusResult, type IssueInfo, type PrInfo, type Task, type PiExtensionEntry, type PiExtensionSettings, getCurrentRepo, isGhAuthenticated, AutomationStore, validateBackupSchedule, validateBackupRetention, validateBackupDir, syncBackupRoutine, exportSettings, importSettings, validateImportData, MessageStore, RoutineStore, isWebhookTrigger, resolveMemoryBackend, getMemoryBackendCapabilities, listMemoryBackendTypes, listProjectMemoryFiles, readProjectMemoryFile, readProjectMemoryFileContent, writeProjectMemoryFile, readMemory, writeMemory, searchProjectMemory, isQmdAvailable, installQmd, refreshQmdProjectMemoryIndex, QMD_INSTALL_COMMAND, MemoryBackendError, scheduleQmdProjectMemoryRefresh, discoverPiExtensions, updatePiExtensionDisabledIds, getFusionAgentDir, getLegacyPiAgentDir, ensureMemoryFileWithBackend } from "@fusion/core";
|
||||
import type { ServerOptions } from "./server.js";
|
||||
import { GitHubClient, parseBadgeUrl } from "./github.js";
|
||||
@@ -8770,9 +8770,10 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
|
||||
if (lastMessage) {
|
||||
// Truncate content to 100 chars for preview
|
||||
const content = lastMessage.content || "";
|
||||
(session as any).lastMessagePreview =
|
||||
const enriched: EnrichedChatSession = session;
|
||||
enriched.lastMessagePreview =
|
||||
content.length > 100 ? content.slice(0, 100) + "…" : content;
|
||||
(session as any).lastMessageAt = lastMessage.createdAt;
|
||||
enriched.lastMessageAt = lastMessage.createdAt;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -413,6 +413,9 @@ export function createServer(store: TaskStore, options?: ServerOptions): ReturnT
|
||||
app.use(express.static(clientDir));
|
||||
}
|
||||
|
||||
// Create ChatStore for chat session management (available for SSE event forwarding)
|
||||
const chatStore = options?.chatStore ?? new ChatStore(store.getFusionDir(), store.getDatabase());
|
||||
|
||||
// Rate limiting — stricter limit on SSE connections
|
||||
app.get("/api/events", rateLimit(RATE_LIMITS.sse), async (req, res) => {
|
||||
const projectId = typeof req.query.projectId === "string" ? req.query.projectId : undefined;
|
||||
@@ -432,6 +435,7 @@ export function createServer(store: TaskStore, options?: ServerOptions): ReturnT
|
||||
undefined,
|
||||
defaultAgentStore,
|
||||
defaultMessageStore,
|
||||
chatStore,
|
||||
)(req, res);
|
||||
return;
|
||||
}
|
||||
@@ -468,6 +472,7 @@ export function createServer(store: TaskStore, options?: ServerOptions): ReturnT
|
||||
},
|
||||
agentStore,
|
||||
messageStore,
|
||||
chatStore,
|
||||
)(req, res);
|
||||
} catch (err: unknown) {
|
||||
sendErrorResponse(res, 500, err instanceof Error ? err.message : "Failed to open project event stream");
|
||||
@@ -655,9 +660,6 @@ export function createServer(store: TaskStore, options?: ServerOptions): ReturnT
|
||||
);
|
||||
}
|
||||
|
||||
// Create ChatStore for chat session management
|
||||
const chatStore = options?.chatStore ?? new ChatStore(store.getFusionDir(), store.getDatabase());
|
||||
|
||||
// Create AgentStore for chat prompt enrichment (initialized lazily by ChatManager)
|
||||
const chatAgentStore = new AgentStore({ rootDir: store.getFusionDir() });
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import type {
|
||||
MessageStore,
|
||||
MissionValidatorRun,
|
||||
FixFeatureCreatedPayload,
|
||||
ChatStore,
|
||||
} from "@fusion/core";
|
||||
import type { AiSessionStore } from "./ai-session-store.js";
|
||||
|
||||
@@ -191,6 +192,7 @@ export function createSSE(
|
||||
options?: CreateSSEOptions,
|
||||
agentStore?: AgentStore,
|
||||
messageStore?: MessageStore,
|
||||
chatStore?: ChatStore,
|
||||
) {
|
||||
const { projectId } = options ?? {};
|
||||
|
||||
@@ -389,6 +391,27 @@ export function createSSE(
|
||||
send(`event: message:deleted\ndata: ${JSON.stringify({ id: messageId })}\n\n`);
|
||||
};
|
||||
|
||||
// --- Chat store event handlers ---
|
||||
const onChatSessionCreated = (session: any) => {
|
||||
send(`event: chat:session:created\ndata: ${JSON.stringify(session)}\n\n`);
|
||||
};
|
||||
|
||||
const onChatSessionUpdated = (session: any) => {
|
||||
send(`event: chat:session:updated\ndata: ${JSON.stringify(session)}\n\n`);
|
||||
};
|
||||
|
||||
const onChatSessionDeleted = (sessionId: string) => {
|
||||
send(`event: chat:session:deleted\ndata: ${JSON.stringify({ id: sessionId })}\n\n`);
|
||||
};
|
||||
|
||||
const onChatMessageAdded = (message: any) => {
|
||||
send(`event: chat:message:added\ndata: ${JSON.stringify(message)}\n\n`);
|
||||
};
|
||||
|
||||
const onChatMessageDeleted = (messageId: string) => {
|
||||
send(`event: chat:message:deleted\ndata: ${JSON.stringify({ id: messageId })}\n\n`);
|
||||
};
|
||||
|
||||
// --- Cleanup (all handlers are defined above, safe to reference) ---
|
||||
|
||||
let cleaned = false;
|
||||
@@ -452,6 +475,13 @@ export function createSSE(
|
||||
messageStore.off("message:read", onMessageRead);
|
||||
messageStore.off("message:deleted", onMessageDeleted);
|
||||
}
|
||||
if (chatStore) {
|
||||
chatStore.off("chat:session:created", onChatSessionCreated);
|
||||
chatStore.off("chat:session:updated", onChatSessionUpdated);
|
||||
chatStore.off("chat:session:deleted", onChatSessionDeleted);
|
||||
chatStore.off("chat:message:added", onChatMessageAdded);
|
||||
chatStore.off("chat:message:deleted", onChatMessageDeleted);
|
||||
}
|
||||
};
|
||||
|
||||
// --- Subscribe ---
|
||||
@@ -517,6 +547,14 @@ export function createSSE(
|
||||
messageStore.on("message:deleted", onMessageDeleted);
|
||||
}
|
||||
|
||||
if (chatStore) {
|
||||
chatStore.on("chat:session:created", onChatSessionCreated);
|
||||
chatStore.on("chat:session:updated", onChatSessionUpdated);
|
||||
chatStore.on("chat:session:deleted", onChatSessionDeleted);
|
||||
chatStore.on("chat:message:added", onChatMessageAdded);
|
||||
chatStore.on("chat:message:deleted", onChatMessageDeleted);
|
||||
}
|
||||
|
||||
// Heartbeat every 30s to keep connection alive.
|
||||
// Sent as a named event so the client's EventSource can detect it
|
||||
// (SSE comments starting with ":" are silently consumed and never
|
||||
|
||||
Reference in New Issue
Block a user