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:
Fusion
2026-04-18 00:44:27 -07:00
committed by gsxdsm
parent 03f4e7fe44
commit 430f7cf39d
12 changed files with 692 additions and 7 deletions

View File

@@ -555,6 +555,31 @@ describe("ChatStore", () => {
expect(store.getMessages(session2.id)).toHaveLength(1);
expect(store.getMessages(session2.id)[0].content).toBe("Session 2");
});
it("updates the parent session's updatedAt timestamp", async () => {
const session = createTestSession(store);
store.addMessage(session.id, { role: "user", content: "Hello" });
const originalUpdatedAt = store.getSession(session.id)!.updatedAt;
await new Promise((r) => setTimeout(r, 5));
const msg = store.addMessage(session.id, { role: "assistant", content: "Reply" });
const afterAddUpdatedAt = store.getSession(session.id)!.updatedAt;
await new Promise((r) => setTimeout(r, 5));
store.deleteMessage(msg.id);
const afterDeleteUpdatedAt = store.getSession(session.id)!.updatedAt;
// The updatedAt should be newer after adding and after deleting
expect(new Date(afterAddUpdatedAt).getTime()).toBeGreaterThan(
new Date(originalUpdatedAt).getTime(),
);
expect(new Date(afterDeleteUpdatedAt).getTime()).toBeGreaterThan(
new Date(afterAddUpdatedAt).getTime(),
);
});
});
});
@@ -627,6 +652,20 @@ describe("ChatStore", () => {
expect(handler).toHaveBeenCalledWith(message.id);
});
it("deleteMessage emits chat:session:updated for the parent session", () => {
const handler = vi.fn();
store.on("chat:session:updated", handler);
const session = createTestSession(store);
const message = store.addMessage(session.id, { role: "user", content: "Hello" });
handler.mockClear();
store.deleteMessage(message.id);
expect(handler).toHaveBeenCalledTimes(1);
expect(handler.mock.calls[0][0].id).toBe(session.id);
});
it("deleteMessage does NOT emit for non-existent message", () => {
const handler = vi.fn();
store.on("chat:message:deleted", handler);
@@ -636,6 +675,15 @@ describe("ChatStore", () => {
expect(handler).not.toHaveBeenCalled();
});
it("deleteMessage does NOT emit chat:session:updated for non-existent message", () => {
const handler = vi.fn();
store.on("chat:session:updated", handler);
store.deleteMessage("msg-nonexistent");
expect(handler).not.toHaveBeenCalled();
});
it("archiveSession emits chat:session:updated", () => {
const handler = vi.fn();
store.on("chat:session:updated", handler);

View File

@@ -387,9 +387,23 @@ export class ChatStore extends EventEmitter<ChatStoreEvents> {
const existing = this.getMessage(id);
if (!existing) return false;
const sessionId = existing.sessionId;
const now = new Date().toISOString();
this.db.prepare("DELETE FROM chat_messages WHERE id = ?").run(id);
// Update the parent session's updatedAt timestamp
this.db.prepare("UPDATE chat_sessions SET updatedAt = ? WHERE id = ?").run(now, sessionId);
this.db.bumpLastModified();
this.emit("chat:message:deleted", id);
// Emit session:updated for the parent session
const updatedSession = this.getSession(sessionId);
if (updatedSession) {
this.emit("chat:session:updated", updatedSession);
}
return true;
}
}

View File

@@ -45,6 +45,18 @@ export interface ChatSession {
*/
export type ChatSessionSummary = ChatSession;
/**
* Chat session enriched with last message preview data.
* The server enriches sessions with lastMessagePreview and lastMessageAt
* by fetching the most recent message for each session.
*/
export type EnrichedChatSession = ChatSession & {
/** Preview of the last message in the session (truncated to 100 chars) */
lastMessagePreview?: string;
/** Timestamp of the last message in the session */
lastMessageAt?: string;
};
/** A parsed @ mention of an agent in a chat message */
export interface ChatMention {
agentId: string;

View File

@@ -588,6 +588,7 @@ export type {
ChatMessageRole,
ChatSession,
ChatSessionSummary,
EnrichedChatSession,
ChatMention,
ChatMessage,
ChatMessageCreateInput,