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:
@@ -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", () => {
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
})();
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user