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 06feb61686
commit 10cc5d08ce
12 changed files with 794 additions and 52 deletions

View File

@@ -8422,6 +8422,82 @@ export function cancelChatResponse(
* When attachments are provided, the request body is sent as multipart form data;
* otherwise it uses the existing JSON payload path.
*/
export interface ChatFailureReference {
kind: string;
id: string;
label?: string;
}
export interface ChatFailureInfo {
summary: string;
errorClass?: string;
code?: string;
detail?: string;
reference?: ChatFailureReference;
}
function extractChatFailureInfo(value: unknown): ChatFailureInfo | null {
if (!value || typeof value !== "object") {
return null;
}
const record = value as Record<string, unknown>;
const summary = typeof record.summary === "string" ? record.summary.trim() : "";
if (!summary) {
return null;
}
const reference = (() => {
const rawReference = record.reference;
if (!rawReference || typeof rawReference !== "object") {
return undefined;
}
const referenceRecord = rawReference as Record<string, unknown>;
const kind = typeof referenceRecord.kind === "string" ? referenceRecord.kind.trim() : "";
const id = typeof referenceRecord.id === "string" ? referenceRecord.id.trim() : "";
if (!kind || !id) {
return undefined;
}
return {
kind,
id,
...(typeof referenceRecord.label === "string" && referenceRecord.label.trim()
? { label: referenceRecord.label.trim() }
: {}),
} satisfies ChatFailureReference;
})();
return {
summary,
...(typeof record.errorClass === "string" && record.errorClass.trim()
? { errorClass: record.errorClass.trim() }
: {}),
...(typeof record.code === "string" && record.code.trim()
? { code: record.code.trim() }
: {}),
...(typeof record.detail === "string" && record.detail.trim()
? { detail: record.detail.trim() }
: {}),
...(reference ? { reference } : {}),
};
}
function parseChatErrorPayload(rawData: string): string | ChatFailureInfo {
try {
const parsed = JSON.parse(rawData);
const structured = extractChatFailureInfo(parsed);
if (structured) {
return structured;
}
if (parsed && typeof parsed === "object" && typeof (parsed as { message?: unknown }).message === "string") {
return (parsed as { message: string }).message;
}
return typeof parsed === "string" ? parsed : rawData || "Stream error";
} catch {
return rawData || "Stream error";
}
}
export interface ChatStreamHandlers {
onThinking?: (data: string) => void;
onText?: (data: string) => void;
@@ -8429,7 +8505,7 @@ export interface ChatStreamHandlers {
onToolEnd?: (data: { toolName: string; isError: boolean; result?: unknown }) => void;
onFallback?: (data: { primaryModel: string; fallbackModel: string; triggerPoint: "session-creation" | "prompt-time" }) => void;
onDone?: (data: { messageId: string; message?: ChatMessage }) => void;
onError?: (data: string) => void;
onError?: (data: string | ChatFailureInfo) => void;
onConnectionStateChange?: (state: StreamConnectionState) => void;
}
@@ -8522,12 +8598,7 @@ export function streamChatResponse(
break;
case "error":
terminated = true;
try {
const parsed = JSON.parse(rawData);
handlers.onError?.(parsed.message || parsed);
} catch {
handlers.onError?.(rawData || "Stream error");
}
handlers.onError?.(parseChatErrorPayload(rawData));
break;
}
};
@@ -8744,12 +8815,7 @@ export function attachChatStream(
break;
case "error":
terminated = true;
try {
const parsed = JSON.parse(rawData);
handlers.onError?.(parsed.message || parsed);
} catch {
handlers.onError?.(rawData || "Stream error");
}
handlers.onError?.(parseChatErrorPayload(rawData));
break;
}
};

View File

@@ -586,6 +586,11 @@
border-bottom-left-radius: var(--radius-sm);
}
.chat-message--failure {
background: var(--status-error-bg);
border: var(--btn-border-width) solid var(--status-error-bg-deep);
}
.chat-message-avatar {
display: flex;
align-items: center;
@@ -670,6 +675,153 @@
overflow-wrap: anywhere;
}
.chat-message-content--failure {
display: flex;
flex-direction: column;
gap: var(--space-sm);
}
.chat-message-failure-summary-row {
display: flex;
align-items: center;
gap: var(--space-sm);
color: var(--color-error);
font-weight: 600;
}
.chat-message-failure-label {
min-width: 0;
}
.chat-message-failure-summary {
color: var(--text);
white-space: pre-wrap;
}
.chat-message-failure-badges {
display: flex;
flex-wrap: wrap;
gap: var(--space-xs);
}
.chat-message-failure-badge {
display: inline-flex;
align-items: center;
min-height: calc(var(--space-lg) * 1.5);
padding: 0 var(--space-sm);
border-radius: var(--radius-pill);
background: var(--status-error-bg-deep);
color: var(--color-error);
font-family: var(--font-mono);
font-size: calc(var(--space-sm) + var(--space-xs));
}
.chat-message-failure-details {
border: var(--btn-border-width) solid var(--status-error-bg-deep);
border-radius: var(--radius-md);
background: var(--status-error-bg-deep);
}
.chat-message-failure-details summary {
display: flex;
align-items: center;
gap: var(--space-sm);
cursor: pointer;
list-style: none;
padding: var(--space-sm) var(--space-md);
color: var(--text-muted);
}
.chat-message-failure-details summary::-webkit-details-marker {
display: none;
}
.chat-message-failure-details summary:focus-visible {
outline: none;
box-shadow: var(--focus-ring-strong);
}
.chat-message-failure-detail {
margin: 0;
padding: 0 var(--space-md) var(--space-md);
color: var(--text);
font-family: var(--font-mono);
white-space: pre-wrap;
}
.chat-message-failure-reference {
display: flex;
flex-wrap: wrap;
gap: var(--space-sm);
align-items: center;
padding: 0 var(--space-md) var(--space-md);
color: var(--text-muted);
}
.chat-message-failure-reference-label {
font-weight: 600;
}
.chat-message-failure-reference-value {
min-width: 0;
overflow-wrap: anywhere;
}
.chat-message-failure-reference-link {
margin-left: auto;
border-color: var(--status-error-bg-deep);
background: var(--status-error-bg-deep);
color: var(--color-error);
text-decoration: none;
}
.chat-message-failure-reference-link:hover,
.chat-message-failure-reference-link:focus-visible {
border-color: var(--color-error);
background: var(--status-error-bg);
color: var(--color-error);
}
.chat-message-failure-reference-details {
margin-left: auto;
}
.chat-message-failure-reference-details summary {
list-style: none;
}
.chat-message-failure-reference-details summary::-webkit-details-marker {
display: none;
}
.chat-message-failure-reference-meta {
display: grid;
gap: var(--space-sm);
margin: var(--space-sm) 0 0;
padding: var(--space-sm);
border: var(--btn-border-width) solid var(--status-error-bg-deep);
border-radius: var(--radius-md);
background: var(--status-error-bg);
}
.chat-message-failure-reference-meta div {
display: grid;
gap: var(--space-xs);
}
.chat-message-failure-reference-meta dt {
color: var(--text-muted);
font-size: calc(var(--space-sm) + var(--space-xs));
font-weight: 600;
}
.chat-message-failure-reference-meta dd {
margin: 0;
color: var(--text);
font-family: var(--font-mono);
overflow-wrap: anywhere;
}
.chat-message-copy-action {
display: flex;
align-items: center;
@@ -1554,6 +1706,16 @@
max-width: 90%;
}
.chat-message-failure-details summary,
.chat-message-failure-reference {
align-items: flex-start;
flex-wrap: wrap;
}
.chat-message-failure-reference-link {
margin-left: 0;
}
.chat-new-dialog {
width: 95vw;
margin: var(--space-lg);

View File

@@ -22,8 +22,9 @@ import {
ChevronDown,
Copy,
Check,
TriangleAlert,
} from "lucide-react";
import { useChat, type ChatMessageInfo, type ToolCallInfo } from "../hooks/useChat";
import { useChat, type ChatMessageInfo, type FailureInfo, type ToolCallInfo } from "../hooks/useChat";
import { useChatRooms } from "../hooks/useChatRooms";
import { useViewportMode } from "./Header";
import { fetchAgents, fetchDiscoveredSkills, fetchModels, updateGlobalSettings } from "../api";
@@ -166,6 +167,66 @@ function formatToolResultSummary(result: unknown): string | null {
}
}
function buildFailureReferenceHref(reference: FailureInfo["reference"]): string | null {
if (!reference) {
return null;
}
if (reference.kind === "mailbox" || reference.kind === "mailbox-message") {
const pathname = typeof window === "undefined" ? "/" : window.location.pathname || "/";
const params = new URLSearchParams(typeof window === "undefined" ? "" : window.location.search);
params.set("view", "mailbox");
params.set("mailbox-message", reference.id);
return `${pathname}?${params.toString()}#message-${encodeURIComponent(reference.id)}`;
}
return null;
}
function renderFailureReference(reference: FailureInfo["reference"]): ReactNode {
if (!reference) {
return null;
}
const referenceLabel = reference.label ?? `${reference.kind} ${reference.id}`;
const referenceHref = buildFailureReferenceHref(reference);
const referenceDetailsId = `chat-failure-reference-${reference.kind}-${reference.id}`
.replace(/[^a-zA-Z0-9_-]+/g, "-")
.toLowerCase();
return (
<div className="chat-message-failure-reference">
<span className="chat-message-failure-reference-label">Reference</span>
<span className="chat-message-failure-reference-value">{referenceLabel}</span>
{referenceHref ? (
<a className="btn btn-sm chat-message-failure-reference-link" href={referenceHref}>
Open mailbox message
</a>
) : (
<details className="chat-message-failure-reference-details">
<summary className="btn btn-sm chat-message-failure-reference-link">View failure details</summary>
<dl className="chat-message-failure-reference-meta" id={referenceDetailsId}>
<div>
<dt>Kind</dt>
<dd>{reference.kind}</dd>
</div>
<div>
<dt>ID</dt>
<dd>{reference.id}</dd>
</div>
{reference.label && (
<div>
<dt>Label</dt>
<dd>{reference.label}</dd>
</div>
)}
</dl>
</details>
)}
</div>
);
}
function renderToolCalls(toolCalls?: ToolCallInfo[]): ReactNode {
if (!toolCalls || toolCalls.length === 0) return null;
@@ -599,6 +660,8 @@ const ChatMessageItem = memo(function ChatMessageItem({
copyAction,
}: ChatMessageItemProps) {
const isAssistantMessage = message.role === "assistant";
const failureInfo = isAssistantMessage ? message.failureInfo : undefined;
const showAssistantIdentity = isAssistantMessage && (!hideAssistantIdentity || Boolean(failureInfo));
const renderedUserContent = useMemo<ReactNode>(() => {
if (isAssistantMessage) return null;
@@ -684,6 +747,33 @@ const ChatMessageItem = memo(function ChatMessageItem({
const assistantBody = useMemo<ReactNode>(() => {
if (!isAssistantMessage) return null;
if (failureInfo) {
return (
<div className="chat-message-content chat-message-content--failure">
<div className="chat-message-failure-summary-row">
<span className="status-dot status-dot--error" aria-hidden="true" />
<span className="chat-message-failure-label">Response failed</span>
</div>
<div className="chat-message-failure-summary">{failureInfo.summary}</div>
{(failureInfo.errorClass || failureInfo.code) && (
<div className="chat-message-failure-badges">
{failureInfo.errorClass && <span className="chat-message-failure-badge">{failureInfo.errorClass}</span>}
{failureInfo.code && <span className="chat-message-failure-badge">{failureInfo.code}</span>}
</div>
)}
{(failureInfo.detail || failureInfo.reference) && (
<details className="chat-message-failure-details">
<summary>
<TriangleAlert size={14} aria-hidden="true" />
<span>Failure details</span>
</summary>
{failureInfo.detail && <pre className="chat-message-failure-detail">{failureInfo.detail}</pre>}
{renderFailureReference(failureInfo.reference)}
</details>
)}
</div>
);
}
if (forcePlain) {
return <div className="chat-message-content chat-message-content--plain">{message.content}</div>;
}
@@ -694,14 +784,14 @@ const ChatMessageItem = memo(function ChatMessageItem({
</ReactMarkdown>
</div>
);
}, [isAssistantMessage, forcePlain, message.content]);
}, [failureInfo, forcePlain, isAssistantMessage, message.content]);
return (
<div
className={`chat-message chat-message--${message.role}`}
className={`chat-message chat-message--${message.role}${failureInfo ? " chat-message--failure" : ""}`}
data-testid={`chat-message-${message.id}`}
>
{isAssistantMessage && !hideAssistantIdentity && (
{showAssistantIdentity && (
<div className="chat-message-avatar">
{activeModelProvider ? <ProviderIcon provider={activeModelProvider} size="sm" /> : <Bot size={14} />}
<span>{agentName}</span>
@@ -711,7 +801,7 @@ const ChatMessageItem = memo(function ChatMessageItem({
{isAssistantMessage
? assistantBody
: <div className="chat-message-content">{renderedUserContent}</div>}
{copyAction}
{!failureInfo && copyAction}
{renderToolCalls(message.toolCalls)}
{message.thinkingOutput && (
<details className="chat-message-thinking">

View File

@@ -1009,6 +1009,95 @@ describe("ChatView", () => {
expect(screen.getByLabelText("Copy failed")).toBeInTheDocument();
});
it("renders assistant failure bubbles inline with detail affordances", async () => {
setupMockChat({
activeSession: {
id: "session-001",
agentId: "__fn_agent__",
status: "active",
title: "Fusion Chat",
modelProvider: "anthropic",
modelId: "claude-sonnet-4-5",
createdAt: "2026-04-08T00:00:00.000Z",
updatedAt: "2026-04-08T00:00:00.000Z",
},
messages: [
{
id: "msg-failure",
sessionId: "session-001",
role: "assistant",
content: "Model request failed",
failureInfo: {
summary: "Model request failed",
errorClass: "ProviderError",
code: "E_MODEL",
detail: "ProviderError: Model request failed",
reference: { kind: "mailbox", id: "msg-42", label: "Mailbox message msg-42" },
},
createdAt: "2026-04-08T00:00:01.000Z",
},
],
});
render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
const messageBubble = screen.getByTestId("chat-message-msg-failure");
expect(messageBubble).toHaveClass("chat-message--failure");
expect(within(messageBubble).getByText("Claude Sonnet 4.5")).toBeInTheDocument();
expect(within(messageBubble).getByText("Response failed")).toBeInTheDocument();
expect(within(messageBubble).getByText("ProviderError")).toBeInTheDocument();
expect(within(messageBubble).getByText("E_MODEL")).toBeInTheDocument();
expect(within(messageBubble).queryByTestId("chat-copy-response-msg-failure")).not.toBeInTheDocument();
await userEvent.click(within(messageBubble).getByText("Failure details"));
expect(within(messageBubble).getByText("ProviderError: Model request failed")).toBeInTheDocument();
expect(within(messageBubble).getByText("Mailbox message msg-42")).toBeInTheDocument();
expect(within(messageBubble).getByRole("link", { name: "Open mailbox message" })).toHaveAttribute(
"href",
"/?view=mailbox&mailbox-message=msg-42#message-msg-42",
);
expect(messageBubble.querySelector(".status-dot.status-dot--error")).toBeInTheDocument();
});
it("renders a generic failure reference details affordance for non-mailbox references", async () => {
setupMockChat({
activeSession: {
id: "session-001",
agentId: "agent-001",
status: "active",
title: "Agent Chat",
createdAt: "2026-04-08T00:00:00.000Z",
updatedAt: "2026-04-08T00:00:00.000Z",
},
messages: [
{
id: "msg-run-failure",
sessionId: "session-001",
role: "assistant",
content: "Run failed",
failureInfo: {
summary: "Run failed",
reference: { kind: "agent-run", id: "run-42", label: "Agent run 42" },
},
createdAt: "2026-04-08T00:00:02.000Z",
},
],
});
render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
const messageBubble = screen.getByTestId("chat-message-msg-run-failure");
await userEvent.click(within(messageBubble).getByText("Failure details"));
await userEvent.click(within(messageBubble).getByText("View failure details"));
expect(within(messageBubble).getAllByText("Agent run 42")).toHaveLength(2);
expect(within(messageBubble).getByText("Kind")).toBeInTheDocument();
expect(within(messageBubble).getByText("agent-run")).toBeInTheDocument();
expect(within(messageBubble).getByText("ID")).toBeInTheDocument();
expect(within(messageBubble).getByText("run-42")).toBeInTheDocument();
});
it("shows streaming copy action for provider chats", () => {
setupMockChat({
activeSession: {
@@ -2431,6 +2520,23 @@ describe("Chat Session Delete Button", () => {
});
});
describe("ChatView CSS — failure bubble contracts", () => {
const css = loadAllAppCss();
it("uses shared error surface tokens for failure bubbles and detail affordances", () => {
const bubbleMatch = css.match(/\.chat-message--failure\s*\{([^}]*)\}/);
const badgeMatch = css.match(/\.chat-message-failure-badge\s*\{([^}]*)\}/);
const detailsMatch = css.match(/\.chat-message-failure-details\s*\{([^}]*)\}/);
const linkMatch = css.match(/\.chat-message-failure-reference-link\s*\{([^}]*)\}/);
expect(bubbleMatch?.[1]).toContain("background: var(--status-error-bg)");
expect(bubbleMatch?.[1]).toContain("border: var(--btn-border-width) solid var(--status-error-bg-deep)");
expect(badgeMatch?.[1]).toContain("background: var(--status-error-bg-deep)");
expect(detailsMatch?.[1]).toContain("background: var(--status-error-bg-deep)");
expect(linkMatch?.[1]).toContain("background: var(--status-error-bg-deep)");
});
});
describe("FN-3911 chat session list layout", () => {
const css = loadAllAppCss();

View File

@@ -320,6 +320,53 @@ describe("useChat", () => {
});
});
it("rehydrates persisted failure metadata when loading message history", async () => {
const session = makeSession({ id: "session-001", agentId: "agent-001" });
mockFetchChatSessions.mockResolvedValueOnce({ sessions: [session] });
mockFetchChatMessages.mockResolvedValueOnce({
messages: [
makeMessage({
id: "msg-failure",
sessionId: "session-001",
role: "assistant",
content: "Model request failed",
metadata: {
failureInfo: {
summary: "Model request failed",
errorClass: "ProviderError",
code: "E_MODEL",
detail: "ProviderError: Model request failed",
},
},
}),
],
});
const { result } = renderHook(() => useChat());
await waitFor(() => {
expect(result.current.sessions).toHaveLength(1);
});
act(() => {
result.current.selectSession("session-001");
});
await waitFor(() => {
expect(result.current.messages).toHaveLength(1);
});
expect(result.current.messages[0]).toEqual(expect.objectContaining({
id: "msg-failure",
failureInfo: {
summary: "Model request failed",
errorClass: "ProviderError",
code: "E_MODEL",
detail: "ProviderError: Model request failed",
},
}));
});
it("creates a new session and selects it", async () => {
const newSession = makeSession({ id: "session-new", agentId: "agent-001", title: "Test Chat" });
mockCreateChatSession.mockResolvedValueOnce({ session: newSession });
@@ -606,13 +653,13 @@ describe("useChat", () => {
});
});
it("handles stream errors and surfaces them to the user", async () => {
it("handles stream errors, appends a failure bubble, and surfaces them to the user", async () => {
const session = makeSession({ id: "session-001", agentId: "agent-001" });
mockFetchChatSessions.mockResolvedValueOnce({ sessions: [session] });
mockFetchChatMessages.mockResolvedValueOnce({ messages: [] });
const addToast = vi.fn();
let errorHandler: ((data: string) => void) | undefined;
let errorHandler: ((data: string | apiModule.ChatFailureInfo) => void) | undefined;
mockStreamChatResponse.mockImplementation((_sessionId, _content, handlers) => {
errorHandler = handlers.onError;
return { close: vi.fn(), isConnected: () => true };
@@ -636,14 +683,18 @@ describe("useChat", () => {
await result.current.sendMessage("Hello!");
});
// Simulate error
await act(async () => {
errorHandler?.("Stream connection failed");
});
await waitFor(() => {
expect(result.current.isStreaming).toBe(false);
expect(result.current.messages).toHaveLength(0);
expect(result.current.messages).toHaveLength(1);
expect(result.current.messages[0]).toEqual(expect.objectContaining({
role: "assistant",
content: "Stream connection failed",
failureInfo: { summary: "Stream connection failed" },
}));
expect(addToast).toHaveBeenCalledWith("Stream connection failed", "error");
});
});
@@ -654,7 +705,7 @@ describe("useChat", () => {
mockFetchChatMessages.mockResolvedValue({ messages: [] });
const addToast = vi.fn();
let errorHandler: ((data: string) => void) | undefined;
let errorHandler: ((data: string | apiModule.ChatFailureInfo) => void) | undefined;
mockStreamChatResponse.mockImplementation((_sessionId, _content, handlers) => {
errorHandler = handlers.onError;
return { close: vi.fn(), isConnected: () => true };
@@ -704,7 +755,7 @@ describe("useChat", () => {
mockFetchChatMessages.mockResolvedValue({ messages: [] });
const addToast = vi.fn();
let errorHandler: ((data: string) => void) | undefined;
let errorHandler: ((data: string | apiModule.ChatFailureInfo) => void) | undefined;
mockStreamChatResponse.mockImplementation((_sessionId, _content, handlers) => {
errorHandler = handlers.onError;
return { close: vi.fn(), isConnected: () => true };
@@ -750,7 +801,7 @@ describe("useChat", () => {
mockFetchChatMessages.mockResolvedValue({ messages: [] });
const addToast = vi.fn();
let errorHandler: ((data: string) => void) | undefined;
let errorHandler: ((data: string | apiModule.ChatFailureInfo) => void) | undefined;
mockStreamChatResponse.mockImplementation((_sessionId, _content, handlers) => {
errorHandler = handlers.onError;
return { close: vi.fn(), isConnected: () => true };
@@ -787,7 +838,7 @@ describe("useChat", () => {
mockFetchChatMessages.mockResolvedValue({ messages: [] });
const addToast = vi.fn();
let errorHandler: ((data: string) => void) | undefined;
let errorHandler: ((data: string | apiModule.ChatFailureInfo) => void) | undefined;
mockStreamChatResponse.mockImplementation((_sessionId, _content, handlers) => {
errorHandler = handlers.onError;
return { close: vi.fn(), isConnected: () => true };
@@ -825,7 +876,7 @@ describe("useChat", () => {
mockFetchChatMessages.mockResolvedValue({ messages: [] });
const addToast = vi.fn();
let errorHandler: ((data: string) => void) | undefined;
let errorHandler: ((data: string | apiModule.ChatFailureInfo) => void) | undefined;
mockStreamChatResponse.mockImplementation((_sessionId, _content, handlers) => {
errorHandler = handlers.onError;
return { close: vi.fn(), isConnected: () => true };

View File

@@ -20,6 +20,20 @@ export interface FallbackInfo {
triggerPoint: "session-creation" | "prompt-time";
}
export interface FailureReferenceInfo {
kind: string;
id: string;
label?: string;
}
export interface FailureInfo {
summary: string;
errorClass?: string;
code?: string;
detail?: string;
reference?: FailureReferenceInfo;
}
export interface ChatMessageInfo {
id: string;
sessionId: string;
@@ -28,6 +42,7 @@ export interface ChatMessageInfo {
thinkingOutput?: string | null;
toolCalls?: ToolCallInfo[];
fallbackInfo?: FallbackInfo;
failureInfo?: FailureInfo;
attachments?: Array<{
id: string;
filename: string;

View File

@@ -1,5 +1,6 @@
import type { ChatMessage } from "@fusion/core";
import type { Dispatch, RefObject, SetStateAction } from "react";
import type { ChatFailureInfo } from "../api";
import type { ChatMessageInfo, FallbackInfo, ToolCallInfo } from "./chatTypes";
/**
@@ -45,7 +46,7 @@ export interface CreateChatStreamHandlersOptions {
fallbackInfo?: FallbackInfo;
};
}) => void;
onError: (data: string, tempUserMessageId: string) => void;
onError: (data: string | ChatFailureInfo, tempUserMessageId: string) => void;
/**
* Fallback-model side effect for the parent (e.g. updating the session list
* or the active session's model fields). The factory still emits the toast.
@@ -60,7 +61,7 @@ export interface ChatStreamHandlers {
onToolEnd: (data: { toolName: string; isError: boolean; result?: unknown }) => void;
onFallback: (data: FallbackInfo) => void;
onDone: (data: { messageId: string; message?: ChatMessage }) => void;
onError: (data: string) => void;
onError: (data: string | ChatFailureInfo) => void;
}
export interface CreateChatStreamHandlersResult {
@@ -200,7 +201,7 @@ export function createChatStreamHandlers(
},
});
},
onError: (data: string) => {
onError: (data: string | ChatFailureInfo) => {
cancelFlushes();
onError(data, tempUserMessageId);
},

View File

@@ -9,6 +9,7 @@ import {
streamChatResponse,
cancelChatResponse,
fetchAgents,
type ChatFailureInfo,
type ChatSessionListResponse,
} from "../api";
import { subscribeSse } from "../sse-bus";
@@ -34,8 +35,8 @@ export interface ChatSessionInfo {
// Re-export shared chat types so existing consumers (`import { ChatMessageInfo } from "../hooks/useChat"`)
// keep working — single source of truth lives in chatTypes.ts.
export type { ChatMessageInfo, FallbackInfo, ToolCallInfo } from "./chatTypes";
import type { ChatMessageInfo, FallbackInfo, ToolCallInfo } from "./chatTypes";
export type { ChatMessageInfo, FailureInfo, FallbackInfo, ToolCallInfo } from "./chatTypes";
import type { ChatMessageInfo, FailureInfo, FallbackInfo, ToolCallInfo } from "./chatTypes";
import { createChatStreamHandlers } from "./createChatStreamHandlers";
import { isLikelyTabSuspensionError, useTabVisibilitySuspension } from "./visibilitySuspension";
@@ -148,6 +149,78 @@ function extractFallbackInfo(metadata: Record<string, unknown> | null | undefine
};
}
function extractFailureInfo(metadata: Record<string, unknown> | null | undefined): FailureInfo | undefined {
const rawFailure = metadata?.failureInfo;
if (!rawFailure || typeof rawFailure !== "object") {
return undefined;
}
const record = rawFailure as Record<string, unknown>;
const summary = typeof record.summary === "string" ? record.summary.trim() : "";
if (!summary) {
return undefined;
}
const reference = (() => {
const rawReference = record.reference;
if (!rawReference || typeof rawReference !== "object") {
return undefined;
}
const referenceRecord = rawReference as Record<string, unknown>;
const kind = typeof referenceRecord.kind === "string" ? referenceRecord.kind.trim() : "";
const id = typeof referenceRecord.id === "string" ? referenceRecord.id.trim() : "";
if (!kind || !id) {
return undefined;
}
return {
kind,
id,
...(typeof referenceRecord.label === "string" && referenceRecord.label.trim()
? { label: referenceRecord.label.trim() }
: {}),
};
})();
return {
summary,
...(typeof record.errorClass === "string" && record.errorClass.trim()
? { errorClass: record.errorClass.trim() }
: {}),
...(typeof record.code === "string" && record.code.trim()
? { code: record.code.trim() }
: {}),
...(typeof record.detail === "string" && record.detail.trim()
? { detail: record.detail.trim() }
: {}),
...(reference ? { reference } : {}),
};
}
function normalizeFailureInfo(data: string | ChatFailureInfo): FailureInfo {
if (typeof data === "string") {
const summary = data.trim() || "Failed to get response";
return { summary };
}
const summary = typeof data.summary === "string" && data.summary.trim()
? data.summary.trim()
: "Failed to get response";
return {
summary,
...(typeof data.errorClass === "string" && data.errorClass.trim()
? { errorClass: data.errorClass.trim() }
: {}),
...(typeof data.code === "string" && data.code.trim()
? { code: data.code.trim() }
: {}),
...(typeof data.detail === "string" && data.detail.trim()
? { detail: data.detail.trim() }
: {}),
...(data.reference ? { reference: data.reference } : {}),
};
}
function mapChatMessageToInfo(message: ChatMessage): ChatMessageInfo {
return {
id: message.id,
@@ -157,6 +230,7 @@ function mapChatMessageToInfo(message: ChatMessage): ChatMessageInfo {
thinkingOutput: message.thinkingOutput,
toolCalls: extractCompletedToolCalls(message.metadata),
fallbackInfo: extractFallbackInfo(message.metadata),
failureInfo: extractFailureInfo(message.metadata),
attachments: message.attachments,
createdAt: message.createdAt,
};
@@ -375,8 +449,8 @@ export function useChat(
setIsStreaming(false);
isStreamingRef.current = false;
streamRef.current = null;
const errorMessage = typeof data === "string" && data.trim() ? data : "Failed to get response";
addToast?.(errorMessage, "error");
const failureInfo = normalizeFailureInfo(data);
addToast?.(failureInfo.summary, "error");
void loadMessages(sessionId);
},
});
@@ -644,7 +718,28 @@ export function useChat(
}
},
onError: (data, tempUserMessageId) => {
setMessages((prev) => prev.filter((m) => m.id !== tempUserMessageId));
const failureInfo = normalizeFailureInfo(data);
const shouldSuppressSuspensionError = typeof data === "string"
&& isLikelyTabSuspensionError(data)
&& (visibilitySuspension.isHiddenNow() || visibilitySuspension.wasRecentlyHidden(5000));
setMessages((prev) => {
const nextMessages = prev.filter((message) => message.id !== tempUserMessageId);
if (shouldSuppressSuspensionError) {
return nextMessages;
}
return [
...nextMessages,
{
id: `error-${Date.now()}`,
sessionId: activeSession.id,
role: "assistant",
content: failureInfo.summary,
failureInfo,
createdAt: new Date().toISOString(),
},
];
});
setStreamingText("");
setStreamingThinking("");
setStreamingToolCalls([]);
@@ -652,10 +747,6 @@ export function useChat(
isStreamingRef.current = false;
streamRef.current = null;
console.error("[useChat] Stream error:", data);
const errorMessage = typeof data === "string" && data.trim() ? data : "Failed to get response";
const shouldSuppressSuspensionError = typeof data === "string"
&& isLikelyTabSuspensionError(data)
&& (visibilitySuspension.isHiddenNow() || visibilitySuspension.wasRecentlyHidden(5000));
if (shouldSuppressSuspensionError) {
console.info("[useChat] Suppressed tab-suspension stream error:", data);
@@ -667,7 +758,8 @@ export function useChat(
}
}
} else {
addToast?.(errorMessage, "error");
addToast?.(failureInfo.summary, "error");
void refreshSessions();
}
if (!cancelledByUserRef.current) {

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);

View File

@@ -148,8 +148,75 @@ function formatAttachmentSize(size: number): string {
return `${(size / (1024 * 1024)).toFixed(1)}MB`;
}
function normalizeFailureCode(code: unknown): string | undefined {
if (typeof code === "string" && code.trim()) {
return code.trim();
}
if (typeof code === "number" && Number.isFinite(code)) {
return String(code);
}
return undefined;
}
function buildChatFailureInfo(error: unknown, fallbackSummary = "AI processing failed"): ChatFailureInfo {
if (typeof error === "string") {
const summary = error.trim() || fallbackSummary;
return { summary };
}
if (error && typeof error === "object") {
const record = error as Record<string, unknown>;
const summary = typeof record.message === "string" && record.message.trim()
? record.message.trim()
: fallbackSummary;
const detail = typeof record.stack === "string" && record.stack.trim() && record.stack.trim() !== summary
? record.stack.trim()
: undefined;
return {
summary,
...(typeof record.name === "string" && record.name.trim() && record.name.trim() !== "Error"
? { errorClass: record.name.trim() }
: {}),
...(normalizeFailureCode(record.code) ? { code: normalizeFailureCode(record.code) } : {}),
...(detail ? { detail } : {}),
};
}
return { summary: fallbackSummary };
}
function persistFailureMessage(
chatStore: ChatStore,
sessionId: string,
failureInfo: ChatFailureInfo,
metadata?: Record<string, unknown>,
) {
return chatStore.addMessage(sessionId, {
role: "assistant",
content: failureInfo.summary,
metadata: {
failureInfo,
...(metadata ?? {}),
},
});
}
// ── Types ───────────────────────────────────────────────────────────────────
export interface ChatFailureReference {
kind: string;
id: string;
label?: string;
}
export interface ChatFailureInfo {
summary: string;
errorClass?: string;
code?: string;
detail?: string;
reference?: ChatFailureReference;
}
/** SSE event types for chat streaming */
export type ChatStreamEvent =
| { type: "thinking"; data: string }
@@ -174,7 +241,7 @@ export type ChatStreamEvent =
attachments?: ChatAttachment[];
};
}
| { type: "error"; data: string };
| { type: "error"; data: string | ChatFailureInfo };
/** Callback function for streaming events */
export type ChatStreamCallback = (event: ChatStreamEvent, eventId?: number) => void;
@@ -1478,9 +1545,12 @@ export class ChatManager {
const sessionErrorMessage = (agentResult.session.state as { errorMessage?: unknown }).errorMessage;
if (typeof sessionErrorMessage === "string" && sessionErrorMessage.trim().length > 0
&& !accumulatedText && !accumulatedThinking && toolCallsAccum.length === 0) {
const failureInfo = buildChatFailureInfo(sessionErrorMessage, "Model response failed");
persistFailureMessage(this.chatStore, sessionId, failureInfo);
this.flushInFlightGenerationPersist(sessionId, null);
chatStreamManager.broadcast(sessionId, {
type: "error",
data: sessionErrorMessage,
data: failureInfo,
}, broadcastOptions);
return;
}
@@ -1555,7 +1625,7 @@ export class ChatManager {
return;
}
const errorMessage = err instanceof Error ? err.message : "AI processing failed";
const failureInfo = buildChatFailureInfo(err, "AI processing failed");
diagnostics.error(`Error in sendMessage for session ${sessionId}:`, err);
if (accumulatedText || accumulatedThinking || toolCallsAccum.length > 0) {
@@ -1575,11 +1645,17 @@ export class ChatManager {
}
}
try {
persistFailureMessage(this.chatStore, sessionId, failureInfo, fallbackInfo ? { fallback: fallbackInfo } : undefined);
} catch (persistErr) {
diagnostics.error(`Failed to persist failure message for session ${sessionId}:`, persistErr);
}
this.flushInFlightGenerationPersist(sessionId, null);
chatStreamManager.broadcast(sessionId, {
type: "error",
data: errorMessage,
data: failureInfo,
}, broadcastOptions);
} finally {
// Only clear the active-generation slot if it still belongs to us. If a