fix(FN-2060): preserve quick chat state on streaming errors

- Trigger onError for unexpected AbortError failures in streamChatResponse while ignoring user-initiated closes
- Keep quick chat user messages on send errors, reset streaming state, and reload messages from the server
- Persist partial assistant output (including thinking-only fallbacks) when AI processing fails mid-stream
- Add regression coverage for stream abort handling, quick chat error UX, and partial-response persistence
This commit is contained in:
Fusion
2026-04-18 19:52:53 -07:00
committed by gsxdsm
parent a32cdd829b
commit 898d4d70a5
7 changed files with 315 additions and 7 deletions

View File

@@ -3594,6 +3594,53 @@ describe("streamChatResponse", () => {
expect(callbacks.done).toEqual([]);
expect(callbacks.error).toEqual([]);
});
it("fires onError when fetch aborts unexpectedly", async () => {
const callbacks = {
error: [] as string[],
};
globalThis.fetch = vi.fn().mockRejectedValue(new DOMException("The operation was aborted", "AbortError"));
const stream = streamChatResponse("chat-1", "hello", {
onError: (data) => callbacks.error.push(data),
});
await new Promise((resolve) => setTimeout(resolve, 25));
stream.close();
expect(callbacks.error).toEqual(["Connection aborted"]);
});
it("does not fire onError when abort is initiated by close", async () => {
const callbacks = {
error: [] as string[],
};
globalThis.fetch = vi.fn().mockImplementation((_, init?: RequestInit) => {
const signal = init?.signal;
return new Promise<Response>((_resolve, reject) => {
if (!signal) {
return;
}
const rejectAbort = () => reject(new DOMException("The operation was aborted", "AbortError"));
if (signal.aborted) {
rejectAbort();
return;
}
signal.addEventListener("abort", rejectAbort, { once: true });
});
});
const stream = streamChatResponse("chat-1", "hello", {
onError: (data) => callbacks.error.push(data),
});
stream.close();
await new Promise((resolve) => setTimeout(resolve, 25));
expect(callbacks.error).toEqual([]);
});
});
describe("fetchMemoryBackendStatus", () => {

View File

@@ -5722,8 +5722,13 @@ export function streamChatResponse(
processLines(decoder.decode(value, { stream: true }));
}
} catch (err: unknown) {
if (err instanceof DOMException && err.name === "AbortError") {
if (!closedByUser) {
handlers.onError?.("Connection aborted");
}
return;
}
if (closedByUser) return;
if (err instanceof DOMException && err.name === "AbortError") return;
handlers.onError?.(err instanceof Error ? err.message : "Connection error");
}
})();

View File

@@ -1007,7 +1007,7 @@ describe("QuickChatFAB", () => {
// Wait for error toast
await waitFor(() => {
expect(addToast).toHaveBeenCalledWith("Failed to send message", "error");
expect(addToast).toHaveBeenCalledWith("Failed to get response", "error");
});
});

View File

@@ -199,4 +199,155 @@ describe("useQuickChat", () => {
expect(mockFetchChatMessages).toHaveBeenCalledWith("session-existing", { limit: 50 }, "proj-123");
});
});
it("onError does not remove user message from local state", async () => {
const existingSession = makeSession({ id: "session-existing", agentId: "agent-001" });
let onErrorHandler: ((data: string) => void) | undefined;
mockFetchChatSessions.mockResolvedValueOnce({ sessions: [existingSession] });
mockFetchChatMessages
.mockResolvedValueOnce({ messages: [] })
.mockResolvedValueOnce({
messages: [
{
id: "msg-user-1",
sessionId: existingSession.id,
role: "user",
content: "Hello",
createdAt: new Date().toISOString(),
},
],
});
mockStreamChatResponse.mockImplementation((_sessionId, _content, handlers) => {
onErrorHandler = handlers.onError;
return { close: vi.fn(), isConnected: () => true };
});
const { result } = renderHook(() => useQuickChat("proj-123"));
await act(async () => {
await result.current.switchSession("agent-001");
});
act(() => {
result.current.sendMessage("Hello");
});
expect(result.current.messages.some((message) => message.role === "user" && message.content === "Hello")).toBe(true);
act(() => {
onErrorHandler?.("Connection aborted");
});
await waitFor(() => {
expect(result.current.messages.some((message) => message.role === "user" && message.content === "Hello")).toBe(true);
});
});
it("onError reloads messages from server", async () => {
const existingSession = makeSession({ id: "session-existing", agentId: "agent-001" });
let onErrorHandler: ((data: string) => void) | undefined;
mockFetchChatSessions.mockResolvedValueOnce({ sessions: [existingSession] });
mockFetchChatMessages.mockResolvedValue({ messages: [] });
mockStreamChatResponse.mockImplementation((_sessionId, _content, handlers) => {
onErrorHandler = handlers.onError;
return { close: vi.fn(), isConnected: () => true };
});
const { result } = renderHook(() => useQuickChat("proj-123"));
await act(async () => {
await result.current.switchSession("agent-001");
});
act(() => {
result.current.sendMessage("Hello");
});
act(() => {
onErrorHandler?.("Connection aborted");
});
await waitFor(() => {
expect(mockFetchChatMessages).toHaveBeenCalledTimes(2);
expect(mockFetchChatMessages).toHaveBeenLastCalledWith("session-existing", { limit: 50 }, "proj-123");
});
});
it("onError resets streaming state", async () => {
const existingSession = makeSession({ id: "session-existing", agentId: "agent-001" });
let onErrorHandler: ((data: string) => void) | undefined;
let onTextHandler: ((data: string) => void) | undefined;
let onThinkingHandler: ((data: string) => void) | undefined;
mockFetchChatSessions.mockResolvedValueOnce({ sessions: [existingSession] });
mockFetchChatMessages.mockResolvedValue({ messages: [] });
mockStreamChatResponse.mockImplementation((_sessionId, _content, handlers) => {
onErrorHandler = handlers.onError;
onTextHandler = handlers.onText;
onThinkingHandler = handlers.onThinking;
return { close: vi.fn(), isConnected: () => true };
});
const { result } = renderHook(() => useQuickChat("proj-123"));
await act(async () => {
await result.current.switchSession("agent-001");
});
act(() => {
result.current.sendMessage("Hello");
onTextHandler?.("Partial answer");
onThinkingHandler?.("Thinking...");
});
await waitFor(() => {
expect(result.current.isStreaming).toBe(true);
expect(result.current.streamingText).toBe("Partial answer");
expect(result.current.streamingThinking).toBe("Thinking...");
});
act(() => {
onErrorHandler?.("Connection aborted");
});
await waitFor(() => {
expect(result.current.isStreaming).toBe(false);
expect(result.current.streamingText).toBe("");
expect(result.current.streamingThinking).toBe("");
});
});
it("onError shows toast with failed response message", async () => {
const existingSession = makeSession({ id: "session-existing", agentId: "agent-001" });
const addToast = vi.fn();
let onErrorHandler: ((data: string) => void) | undefined;
mockFetchChatSessions.mockResolvedValueOnce({ sessions: [existingSession] });
mockFetchChatMessages.mockResolvedValue({ messages: [] });
mockStreamChatResponse.mockImplementation((_sessionId, _content, handlers) => {
onErrorHandler = handlers.onError;
return { close: vi.fn(), isConnected: () => true };
});
const { result } = renderHook(() => useQuickChat("proj-123", addToast));
await act(async () => {
await result.current.switchSession("agent-001");
});
act(() => {
result.current.sendMessage("Hello");
onErrorHandler?.("Connection aborted");
});
await waitFor(() => {
expect(addToast).toHaveBeenCalledWith("Failed to get response", "error");
});
});
});

View File

@@ -305,20 +305,19 @@ export function useQuickChat(
streamRef.current = null;
},
onError: (data: string) => {
// Remove the optimistic user message on error
setMessages((prev) => prev.filter((m) => m.id !== tempId));
setStreamingText("");
setStreamingThinking("");
setIsStreaming(false);
streamRef.current = null;
console.error("[useQuickChat] Stream error:", data);
addToast?.("Failed to send message", "error");
addToast?.("Failed to get response", "error");
void reloadMessages();
},
};
streamRef.current = streamChatResponse(activeSession.id, content, textHandlers, projectId);
},
[activeSession, projectId, addToast],
[activeSession, projectId, addToast, reloadMessages],
);
// Cleanup on unmount

View File

@@ -4,7 +4,13 @@
*/
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { ChatManager, __setBuildAgentChatPrompt, __setCreateKbAgent, __resetChatState } from "../chat.js";
import {
ChatManager,
__setBuildAgentChatPrompt,
__setCreateKbAgent,
__resetChatState,
chatStreamManager,
} from "../chat.js";
// ── Mock Setup ──────────────────────────────────────────────────────────────
@@ -355,6 +361,92 @@ describe("ChatManager.sendMessage", () => {
expect(assistantCall?.[1].thinkingOutput).toBe("Thinking...");
});
it("persists partial assistant response when AI processing fails after streaming text", async () => {
const events: Array<{ type: string; data: unknown }> = [];
const unsubscribe = chatStreamManager.subscribe("chat-001", (event) => {
events.push(event);
});
__setCreateKbAgent(async (options: any) => {
return {
session: {
prompt: vi.fn().mockImplementation(async () => {
options.onThinking?.("Thinking...");
options.onText?.("Partial answer");
throw new Error("Tool execution failed");
}),
dispose: vi.fn(),
state: { messages: [] },
},
};
});
const chatManager = createChatManager();
await chatManager.sendMessage("chat-001", "Hello");
unsubscribe();
const assistantCalls = mockChatStore.addMessage.mock.calls.filter((call) => call[1].role === "assistant");
expect(assistantCalls).toHaveLength(1);
expect(assistantCalls[0]).toEqual([
"chat-001",
expect.objectContaining({
role: "assistant",
content: "Partial answer",
thinkingOutput: "Thinking...",
metadata: { interrupted: true },
}),
]);
expect(events).toContainEqual({ type: "error", data: "Tool execution failed" });
});
it("does not persist empty assistant response on immediate failure", async () => {
__setCreateKbAgent(async () => {
return {
session: {
prompt: vi.fn().mockRejectedValue(new Error("Immediate failure")),
dispose: vi.fn(),
state: { messages: [] },
},
};
});
const chatManager = createChatManager();
await chatManager.sendMessage("chat-001", "Hello");
const assistantCalls = mockChatStore.addMessage.mock.calls.filter((call) => call[1].role === "assistant");
expect(assistantCalls).toHaveLength(0);
});
it("persists thinking output even when no text was generated", async () => {
__setCreateKbAgent(async (options: any) => {
return {
session: {
prompt: vi.fn().mockImplementation(async () => {
options.onThinking?.("Working through tools");
throw new Error("Interrupted during tool call");
}),
dispose: vi.fn(),
state: { messages: [] },
},
};
});
const chatManager = createChatManager();
await chatManager.sendMessage("chat-001", "Hello");
const assistantCalls = mockChatStore.addMessage.mock.calls.filter((call) => call[1].role === "assistant");
expect(assistantCalls).toHaveLength(1);
expect(assistantCalls[0]).toEqual([
"chat-001",
expect.objectContaining({
role: "assistant",
content: "(response interrupted before text generation)",
thinkingOutput: "Working through tools",
metadata: { interrupted: true },
}),
]);
});
it("uses accumulated text as primary source over state.messages extraction", async () => {
__setCreateKbAgent(async (options: any) => {
return {

View File

@@ -709,6 +709,20 @@ export class ChatManager {
} catch (err) {
const errorMessage = err instanceof Error ? err.message : "AI processing failed";
console.error(`[chat] Error in sendMessage for session ${sessionId}:`, err);
if (accumulatedText || accumulatedThinking) {
try {
this.chatStore.addMessage(sessionId, {
role: "assistant",
content: accumulatedText || "(response interrupted before text generation)",
thinkingOutput: accumulatedThinking || undefined,
metadata: { interrupted: true },
});
} catch (persistErr) {
console.error(`[chat] Failed to persist partial response for session ${sessionId}:`, persistErr);
}
}
chatStreamManager.broadcast(sessionId, {
type: "error",
data: errorMessage,