feat(FN-4054): surface inline chat failures with interactive error referenc

Implements inline chat failure rendering in the ChatView with interactive reference affordances, proper attribution preservation in failure bubbles, and aligned error typing across the chat stream pipeline, with corresponding tests and documentation updates.

Fusion-Task-Id: FN-4054
This commit is contained in:
Fusion
2026-05-12 03:43:36 -07:00
committed by gsxdsm
parent 93dc5a7273
commit 0eae54d9b2
12 changed files with 794 additions and 52 deletions

View File

@@ -604,7 +604,7 @@ describe("ChatManager.sendMessage", () => {
unsubscribe();
const assistantCalls = mockChatStore.addMessage.mock.calls.filter((call) => call[1].role === "assistant");
expect(assistantCalls).toHaveLength(1);
expect(assistantCalls).toHaveLength(2);
expect(assistantCalls[0]).toEqual([
"chat-001",
expect.objectContaining({
@@ -614,10 +614,20 @@ describe("ChatManager.sendMessage", () => {
metadata: { interrupted: true },
}),
]);
expect(events).toContainEqual({ type: "error", data: "Tool execution failed" });
expect(assistantCalls[1]).toEqual([
"chat-001",
expect.objectContaining({
role: "assistant",
content: "Tool execution failed",
metadata: expect.objectContaining({
failureInfo: expect.objectContaining({ summary: "Tool execution failed" }),
}),
}),
]);
expect(events).toContainEqual({ type: "error", data: expect.objectContaining({ summary: "Tool execution failed" }) });
});
it("does not persist empty assistant response on immediate failure", async () => {
it("persists a structured assistant failure message on immediate failure", async () => {
__setCreateFnAgent(async () => {
return {
session: {
@@ -632,10 +642,20 @@ describe("ChatManager.sendMessage", () => {
await chatManager.sendMessage("chat-001", "Hello");
const assistantCalls = mockChatStore.addMessage.mock.calls.filter((call) => call[1].role === "assistant");
expect(assistantCalls).toHaveLength(0);
expect(assistantCalls).toHaveLength(1);
expect(assistantCalls[0]).toEqual([
"chat-001",
expect.objectContaining({
role: "assistant",
content: "Immediate failure",
metadata: expect.objectContaining({
failureInfo: expect.objectContaining({ summary: "Immediate failure" }),
}),
}),
]);
});
it("surfaces provider errors stored on session.state.errorMessage instead of persisting a blank assistant reply", async () => {
it("surfaces provider errors stored on session.state.errorMessage and persists a failure bubble", async () => {
const events: Array<{ type: string; data: unknown }> = [];
const unsubscribe = chatStreamManager.subscribe("chat-001", (event) => {
events.push(event);
@@ -657,8 +677,18 @@ describe("ChatManager.sendMessage", () => {
unsubscribe();
const assistantCalls = mockChatStore.addMessage.mock.calls.filter((call) => call[1].role === "assistant");
expect(assistantCalls).toHaveLength(0);
expect(events).toContainEqual({ type: "error", data: "Codex error: provider request failed" });
expect(assistantCalls).toHaveLength(1);
expect(assistantCalls[0]).toEqual([
"chat-001",
expect.objectContaining({
role: "assistant",
content: "Codex error: provider request failed",
metadata: expect.objectContaining({
failureInfo: expect.objectContaining({ summary: "Codex error: provider request failed" }),
}),
}),
]);
expect(events).toContainEqual({ type: "error", data: expect.objectContaining({ summary: "Codex error: provider request failed" }) });
});
it("uses the agent runtime path when the agent has a runtimeHint configured", async () => {
@@ -960,7 +990,7 @@ describe("ChatManager.sendMessage", () => {
await chatManager.sendMessage("chat-001", "Hello");
const assistantCalls = mockChatStore.addMessage.mock.calls.filter((call) => call[1].role === "assistant");
expect(assistantCalls).toHaveLength(1);
expect(assistantCalls).toHaveLength(2);
expect(assistantCalls[0]).toEqual([
"chat-001",
expect.objectContaining({
@@ -970,6 +1000,16 @@ describe("ChatManager.sendMessage", () => {
metadata: { interrupted: true },
}),
]);
expect(assistantCalls[1]).toEqual([
"chat-001",
expect.objectContaining({
role: "assistant",
content: "Interrupted during tool call",
metadata: expect.objectContaining({
failureInfo: expect.objectContaining({ summary: "Interrupted during tool call" }),
}),
}),
]);
});
it("uses accumulated text as primary source over state.messages extraction", async () => {

View File

@@ -1324,6 +1324,43 @@ describe("Chat API Routes", () => {
expect(output).toContain('"content":"Final reply"');
});
it("SSE route forwards structured error payloads", async () => {
mockGetSession.mockReturnValue(sampleSession);
const chatModule = await import("../chat.js");
vi.mocked(chatModule.checkRateLimit).mockReturnValue(true);
mockSendMessage.mockImplementation(async (sessionId: string) => {
mockChatStreamManager.broadcast(sessionId, {
type: "error",
data: {
summary: "Model request failed",
errorClass: "ProviderError",
code: "E_MODEL",
detail: "ProviderError: Model request failed",
},
});
});
const req = createSSERequest();
const { res, chunks } = createSSEResponse();
req.body = { content: "Hello" };
req.params = { id: "chat-abc123" };
req.query = {} as any;
req.headers = {} as any;
req.ip = "127.0.0.1";
req.socket = { remoteAddress: "127.0.0.1" } as any;
await invokeSSEHandler(req, res, store, mockChatStore, mockChatManager);
const output = chunks.join("");
expect(output).toContain("event: error");
expect(output).toContain('"summary":"Model request failed"');
expect(output).toContain('"errorClass":"ProviderError"');
expect(output).toContain('"code":"E_MODEL"');
});
it("uses the same generation id for subscription and sendMessage", async () => {
mockGetSession.mockReturnValue(sampleSession);