Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
import { Message, MessageBody, MessageContent, MessageMetadata } from 'components/ai-elements/message';

import { ChatMessageActions } from './chat-message-actions';
import { A2AErrorBlock } from './message-blocks/a2a-error-block';
import { ArtifactBlock } from './message-blocks/artifact-block';
import { TaskStatusUpdateBlock } from './message-blocks/task-status-update-block';
import { ToolBlock } from './message-blocks/tool-block';
Expand Down Expand Up @@ -101,6 +102,8 @@ export const ChatMessage = ({ message, isLoading: _isLoading }: ChatMessageProps
timestamp={block.timestamp}
/>
);
case 'a2a-error':
return <A2AErrorBlock error={block.error} key={`${message.id}-error-${index}`} timestamp={block.timestamp} />;
default:
return null;
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
/**
* Copyright 2025 Redpanda Data, Inc.
*
* Use of this software is governed by the Business Source License
* included in the file https://github.com/redpanda-data/redpanda/blob/dev/licenses/bsl.md
*
* As of the Change Date specified in that file, in accordance with
* the Business Source License, use of this software will be governed
* by the Apache License, Version 2.0
*/

import type { JSONRPCError } from '@a2a-js/sdk';
import { Alert, AlertDescription, AlertTitle } from 'components/redpanda-ui/components/alert';
import { Text } from 'components/redpanda-ui/components/typography';
import { AlertCircleIcon } from 'lucide-react';

type A2AErrorBlockProps = {
error: JSONRPCError;
timestamp: Date;
};

/**
* Map JSON-RPC error codes to human-readable names
*/
/**
* Map JSON-RPC/A2A error codes to human-readable names
* Based on a2a-go/a2a/errors.go codeToError mapping
*/
const getErrorCodeName = (code: number): string => {
const errorCodes: Record<number, string> = {
// Standard JSON-RPC 2.0 errors
[-32_700]: 'Parse Error',
[-32_600]: 'Invalid Request',
[-32_601]: 'Method Not Found',
[-32_602]: 'Invalid Params',
[-32_603]: 'Internal Error',
[-32_000]: 'Server Error',
// A2A-specific errors
[-32_001]: 'Task Not Found',
[-32_002]: 'Task Not Cancelable',
[-32_003]: 'Push Notifications Not Supported',
[-32_004]: 'Unsupported Operation',
[-32_005]: 'Content Type Not Supported',
[-32_006]: 'Invalid Agent Response',
[-32_007]: 'Authenticated Extended Card Not Configured',
[-32_008]: 'Authentication Failed',
[-32_009]: 'Forbidden',
};

return errorCodes[code] || `Error ${code}`;
};

/**
* A2A Error Block - displays JSON-RPC errors with full details
*/
export const A2AErrorBlock = ({ error, timestamp }: A2AErrorBlockProps) => {
const time = timestamp.toLocaleTimeString(undefined, {
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
fractionalSecondDigits: 3,
});

const errorCodeName = getErrorCodeName(error.code);
const hasData = error.data && Object.keys(error.data).length > 0;

return (
<Alert className="mb-4" icon={<AlertCircleIcon />} variant="destructive">
<AlertTitle>{errorCodeName}</AlertTitle>
<AlertDescription>
<div className="flex flex-col gap-2">
<Text className="text-destructive/90" variant="body">
{error.message}
</Text>

<div className="mt-1 flex flex-col gap-1 rounded border border-destructive/20 bg-destructive/5 p-3 font-mono text-xs">
<div className="flex gap-2">
<span className="font-semibold text-destructive">code:</span>
<span>{error.code}</span>
</div>
<div className="flex gap-2">
<span className="font-semibold text-destructive">message:</span>
<span>{error.message}</span>
</div>
{Boolean(hasData) && (
<div className="flex flex-col gap-1">
<span className="font-semibold text-destructive">data:</span>
<pre className="overflow-x-auto whitespace-pre-wrap text-xs">{JSON.stringify(error.data, null, 2)}</pre>
</div>
)}
<div className="mt-1 flex gap-2 border-destructive/20 border-t pt-1 text-destructive/60">
<span className="font-semibold">time:</span>
<span>{time}</span>
</div>
</div>
</div>
</AlertDescription>
</Alert>
);
};
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ import { streamMessage } from './use-message-streaming';
import type { ChatMessage } from '../types';
import { createA2AClient } from '../utils/a2a-client';
import { clearChatHistory, deleteMessages, saveMessage } from '../utils/database-operations';
import { createErrorMessage, createUserMessage } from '../utils/message-converter';
import { createUserMessage } from '../utils/message-converter';

type UseChatActionsParams = {
agentId: string;
Expand Down Expand Up @@ -91,7 +91,7 @@ export const useChatActions = ({
await saveMessage(userMessage, agentId);

// Stream assistant response
const result = await streamMessage({
await streamMessage({
prompt: prompt || 'Sent with attachments',
agentId,
agentCardUrl,
Expand All @@ -108,13 +108,6 @@ export const useChatActions = ({
},
});

// Handle error if streaming failed
if (!result.success) {
const errorMessage = createErrorMessage(contextId);
setMessages((prev) => [...prev, errorMessage]);
await saveMessage(errorMessage, agentId, { failure: true });
}

setIsLoading(false);
},
[agentId, agentCardUrl, contextId, editingMessageId, messages, model, setMessages]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,9 @@
* by the Apache License, Version 2.0
*/

import type { Task, TaskArtifactUpdateEvent, TaskStatusUpdateEvent } from '@a2a-js/sdk';
import { ConnectError } from '@connectrpc/connect';
import type { JSONRPCError, Task, TaskArtifactUpdateEvent, TaskStatusUpdateEvent } from '@a2a-js/sdk';
import { streamText } from 'ai';
import { config } from 'config';
import { toast } from 'sonner';
import { formatToastErrorMessageGRPC } from 'utils/toast.utils';

import {
handleArtifactUpdateEvent,
Expand All @@ -26,10 +23,77 @@ import {
import { buildMessageWithContentBlocks, closeActiveTextBlock } from './message-builder';
import type { ResponseMetadataEvent, StreamChunk, StreamingState, TextDeltaEvent } from './streaming-types';
import { a2a } from '../../a2a-provider';
import type { ChatMessage } from '../types';
import type { ChatMessage, ContentBlock } from '../types';
import { saveMessage, updateMessage } from '../utils/database-operations';
import { createAssistantMessage } from '../utils/message-converter';

/**
* Regex patterns for parsing JSON-RPC error details from error messages.
*
* Why regex? The a2a-js SDK throws plain Error objects with formatted strings
* instead of structured error objects. The SDK has access to the structured
* JSON-RPC error (code, message, data) but serializes it into the error message:
*
* // a2a-js/src/client/transports/json_rpc_transport.ts
* if ('error' in a2aStreamResponse) {
* const err = a2aStreamResponse.error;
* throw new Error(
* `SSE event contained an error: ${err.message} (Code: ${err.code}) Data: ${JSON.stringify(err.data || {})}`
* );
* }
*
* Until the SDK exposes structured error data, we parse it back out.
*/
const JSON_RPC_CODE_REGEX = /\(Code:\s*(-?\d+)\)/i;
const JSON_RPC_DATA_REGEX = /Data:\s*(\{[^}]*\})/i;
const JSON_RPC_MESSAGE_REGEX = /error:\s*([^(]+)\s*\(Code:/i;
const ERROR_PREFIX_STREAMING_REGEX = /^Error during streaming[^:]*:\s*/i;
const ERROR_PREFIX_SSE_REGEX = /^SSE event contained an error:\s*/i;
const ERROR_SUFFIX_CODE_REGEX = /\s*\(Code:\s*-?\d+\).*$/i;

/**
* Parse A2A/JSON-RPC error details from an error message string.
*/
const parseA2AError = (error: unknown): JSONRPCError => {
const errorMessage = error instanceof Error ? error.message : String(error);

// Try to parse JSON-RPC error from the error message
// Format: "SSE event contained an error: <message> (Code: <code>) Data: <json> (code: <connect_code>)"
const jsonRpcMatch = errorMessage.match(JSON_RPC_CODE_REGEX);
const dataMatch = errorMessage.match(JSON_RPC_DATA_REGEX);
const messageMatch = errorMessage.match(JSON_RPC_MESSAGE_REGEX);

// Extract just the core error message without wrapper text
let message = errorMessage;
if (messageMatch?.[1]) {
message = messageMatch[1].trim();
} else {
// Remove common prefixes
message = message
.replace(ERROR_PREFIX_STREAMING_REGEX, '')
.replace(ERROR_PREFIX_SSE_REGEX, '')
.replace(ERROR_SUFFIX_CODE_REGEX, '')
.trim();
}

const code = jsonRpcMatch?.[1] ? Number.parseInt(jsonRpcMatch[1], 10) : -1;

let data: Record<string, unknown> | undefined;
if (dataMatch?.[1]) {
try {
data = JSON.parse(dataMatch[1]);
} catch {
// Invalid JSON in data field
}
}

return {
code,
message: message || 'Unknown error',
data,
};
};

type StreamMessageParams = {
prompt: string;
agentId: string;
Expand Down Expand Up @@ -196,10 +260,38 @@ export const streamMessage = async ({
success: true,
};
} catch (error) {
const connectError = ConnectError.from(error);
toast.error(formatToastErrorMessageGRPC({ error: connectError, action: 'stream', entity: 'message' }));
// Parse JSON-RPC error details
const a2aError = parseA2AError(error);

// Create error content block
const errorBlock: ContentBlock = {
type: 'a2a-error',
error: a2aError,
timestamp: new Date(),
};

// Build message with error block
const errorMessage = buildMessageWithContentBlocks({
baseMessage: assistantMessage,
contentBlocks: [errorBlock],
taskId: undefined,
taskState: 'failed',
taskStartIndex: undefined,
});

// Update database with error
await updateMessage(assistantMessage.id, {
content: '',
isStreaming: false,
taskState: 'failed',
contentBlocks: [errorBlock],
});

// Notify caller about error message
onMessageUpdate(errorMessage);

return {
assistantMessage,
assistantMessage: errorMessage,
success: false,
};
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
* by the Apache License, Version 2.0
*/

import type { TaskState } from '@a2a-js/sdk';
import type { JSONRPCError, TaskState } from '@a2a-js/sdk';
import type { AIAgent } from 'protogen/redpanda/api/dataplane/v1alpha3/ai_agent_pb';

/**
Expand Down Expand Up @@ -55,6 +55,11 @@ export type ContentBlock =
final: boolean;
timestamp: Date;
usage?: MessageUsageMetadata;
}
| {
type: 'a2a-error';
error: JSONRPCError;
timestamp: Date;
};

// Message-level usage metadata (stored in database)
Expand Down
Loading