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

@@ -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,