feat(FN-3843): add chat stream reattach support

Adds the changeset for FN-3843, completing the chat stream reattach feature for the `@runfusion/fusion` CLI package.

Fusion-Task-Id: FN-3843
This commit is contained in:
Fusion
2026-05-09 10:40:55 -07:00
committed by gsxdsm
parent a04fefd234
commit 80f65de994
11 changed files with 684 additions and 88 deletions

View File

@@ -17,6 +17,7 @@ vi.mock("../../api", () => ({
updateChatSession: vi.fn(),
deleteChatSession: vi.fn(),
streamChatResponse: vi.fn(),
attachChatStream: vi.fn(),
cancelChatResponse: vi.fn(),
fetchAgents: vi.fn().mockResolvedValue([
{ id: "agent-001", name: "Alpha", role: "executor", state: "idle", icon: undefined, createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z", metadata: {} },
@@ -50,6 +51,7 @@ const mockFetchChatMessages = vi.mocked(apiModule.fetchChatMessages);
const mockUpdateChatSession = vi.mocked(apiModule.updateChatSession);
const mockDeleteChatSession = vi.mocked(apiModule.deleteChatSession);
const mockStreamChatResponse = vi.mocked(apiModule.streamChatResponse);
const mockAttachChatStream = vi.mocked(apiModule.attachChatStream);
const mockCancelChatResponse = vi.mocked(apiModule.cancelChatResponse);
const mockFetchAgents = vi.mocked(apiModule.fetchAgents);
@@ -100,6 +102,7 @@ describe("useChat", () => {
});
mockDeleteChatSession.mockResolvedValue({ success: true });
mockStreamChatResponse.mockReturnValue({ close: vi.fn(), isConnected: () => true });
mockAttachChatStream.mockReturnValue({ close: vi.fn(), isConnected: () => true });
mockCancelChatResponse.mockResolvedValue({ success: true });
});
@@ -2066,18 +2069,23 @@ describe("useChat", () => {
});
});
it("clears recovery streaming state when SSE delivers assistant message", async () => {
let subscribeHandler: Record<string, (event: MessageEvent) => void> = {};
mockSubscribeSse.mockImplementation((_url, options) => {
if (options?.events) {
subscribeHandler = options.events as typeof subscribeHandler;
}
return () => {};
});
it("clears recovery streaming state when attach stream completes", async () => {
const session = { ...makeSession({ id: "session-001", agentId: "agent-001" }), isGenerating: true };
mockFetchChatSessions.mockResolvedValueOnce({ sessions: [session] });
mockFetchChatMessages.mockResolvedValue({ messages: [] });
mockFetchChatMessages.mockResolvedValue({
messages: [
makeMessage({
id: "msg-assistant-001",
sessionId: "session-001",
role: "assistant",
content: "Generated response",
}),
],
});
mockAttachChatStream.mockImplementation((_sessionId, handlers) => {
setTimeout(() => handlers.onDone?.({ messageId: "msg-assistant-001" }), 0);
return { close: vi.fn(), isConnected: () => true };
});
const { result } = renderHook(() => useChat("proj-123"));
@@ -2089,24 +2097,6 @@ describe("useChat", () => {
result.current.selectSession("session-001");
});
await waitFor(() => {
expect(result.current.isStreaming).toBe(true);
});
// Simulate SSE delivering the completed assistant message
const assistantMessage = makeMessage({
id: "msg-assistant-001",
sessionId: "session-001",
role: "assistant",
content: "Generated response",
});
act(() => {
subscribeHandler["chat:message:added"](
new MessageEvent("chat:message:added", { data: JSON.stringify(assistantMessage) }),
);
});
await waitFor(() => {
expect(result.current.isStreaming).toBe(false);
expect(result.current.streamingText).toBe("");

View File

@@ -11,6 +11,7 @@ vi.mock("../../api", () => ({
createChatSession: vi.fn(),
fetchChatMessages: vi.fn(),
streamChatResponse: vi.fn(),
attachChatStream: vi.fn(),
cancelChatResponse: vi.fn(),
}));
@@ -20,6 +21,7 @@ const mockFetchChatSession = vi.mocked(apiModule.fetchChatSession);
const mockCreateChatSession = vi.mocked(apiModule.createChatSession);
const mockFetchChatMessages = vi.mocked(apiModule.fetchChatMessages);
const mockStreamChatResponse = vi.mocked(apiModule.streamChatResponse);
const mockAttachChatStream = vi.mocked(apiModule.attachChatStream);
const mockCancelChatResponse = vi.mocked(apiModule.cancelChatResponse);
function makeSession(overrides: Partial<ChatSession> & Pick<ChatSession, "id" | "agentId">): ChatSession {
@@ -57,6 +59,7 @@ describe("useQuickChat", () => {
session: { ...makeSession({ id: "session-001", agentId: "agent-001" }), isGenerating: false },
});
mockStreamChatResponse.mockReturnValue({ close: vi.fn(), isConnected: () => true });
mockAttachChatStream.mockReturnValue({ close: vi.fn(), isConnected: () => true });
mockCancelChatResponse.mockResolvedValue({ success: true });
});
@@ -1305,22 +1308,18 @@ describe("useQuickChat", () => {
});
});
it("clears recovery streaming state when polling detects generation complete", async () => {
vi.useFakeTimers({ shouldAdvanceTime: true });
it("clears recovery streaming state when attach stream completes", async () => {
const session = { ...makeSession({ id: "session-001", agentId: "agent-001" }), isGenerating: true };
mockFetchResumeChatSession.mockResolvedValue({ session });
mockFetchChatMessages.mockResolvedValue({ messages: [] });
// After first poll, server reports generation is done and has a new assistant message
mockFetchChatSession.mockResolvedValue({
session: { ...makeSession({ id: "session-001", agentId: "agent-001" }), isGenerating: false },
});
mockFetchChatMessages.mockResolvedValue({
messages: [
{ id: "msg-1", sessionId: "session-001", role: "assistant", content: "Done", thinkingOutput: null, metadata: null, createdAt: new Date().toISOString() },
],
});
mockAttachChatStream.mockImplementation((_sessionId, handlers) => {
setTimeout(() => handlers.onDone?.({ messageId: "msg-1" }), 0);
return { close: vi.fn(), isConnected: () => true };
});
const { result } = renderHook(() => useQuickChat("proj-123"));
@@ -1328,22 +1327,11 @@ describe("useQuickChat", () => {
await result.current.switchSession("agent-001");
});
await waitFor(() => {
expect(result.current.isStreaming).toBe(true);
});
// Advance time to trigger the polling interval (3s)
await act(async () => {
vi.advanceTimersByTime(3500);
});
await waitFor(() => {
expect(result.current.isStreaming).toBe(false);
expect(result.current.streamingText).toBe("");
expect(result.current.messages.some((m) => m.id === "msg-1")).toBe(true);
});
vi.useRealTimers();
});
});
});