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