Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
33 changes: 33 additions & 0 deletions apps/agentstack-sdk-ts/src/client/a2a/extensions/ui/error.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
/**
* Copyright 2025 © BeeAI a Series of LF Projects, LLC
* SPDX-License-Identifier: Apache-2.0
*/

import z from 'zod';

import type { A2AUiExtension } from '../types';

const URI = 'https://a2a-extensions.agentstack.beeai.dev/ui/error/v1';

const errorSchema = z.object({
title: z.string(),
message: z.string(),
});

const errorGroupSchema = z.object({
message: z.string(),
errors: z.array(errorSchema),
});

const schema = z.object({
error: z.union([errorSchema, errorGroupSchema]),
context: z.record(z.string(), z.unknown()).nullish(),
stack_trace: z.string().nullish(),
});

export type ErrorMetadata = z.infer<typeof schema>;

export const errorExtension: A2AUiExtension<typeof URI, ErrorMetadata> = {
getMessageMetadataSchema: () => z.object({ [URI]: schema }).partial(),
getUri: () => URI,
};
2 changes: 1 addition & 1 deletion apps/agentstack-sdk-ts/src/client/api/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
* SPDX-License-Identifier: Apache-2.0
*/

import { z } from 'zod';
import z from 'zod';

export const contextSchema = z.object({
id: z.string(),
Expand Down
1 change: 1 addition & 0 deletions apps/agentstack-sdk-ts/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ export * from './client/a2a/extensions/services/secrets';
export * from './client/a2a/extensions/types';
export * from './client/a2a/extensions/ui/agent-detail';
export * from './client/a2a/extensions/ui/citation';
export * from './client/a2a/extensions/ui/error';
export * from './client/a2a/extensions/ui/form-request';
export * from './client/a2a/extensions/ui/oauth';
export * from './client/a2a/extensions/ui/settings';
Expand Down
9 changes: 7 additions & 2 deletions apps/agentstack-ui/src/api/a2a/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import { handleAgentCard, handleInputRequired, handleTaskStatusUpdate } from 'ag
import { defaultIfEmpty, filter, lastValueFrom, Subject } from 'rxjs';
import { match } from 'ts-pattern';

import { UnauthenticatedError } from '#api/errors.ts';
import { A2AExtensionError, UnauthenticatedError } from '#api/errors.ts';
import type { UIMessagePart } from '#modules/messages/types.ts';
import type { TaskId } from '#modules/tasks/api/types.ts';
import { getBaseUrl } from '#utils/api/getBaseUrl.ts';
Expand All @@ -18,15 +18,20 @@ import { AGENT_ERROR_MESSAGE } from './constants';
import { processMessageMetadata, processParts } from './part-processors';
import type { ChatResult, TaskStatusUpdateResultWithTaskId } from './types';
import { type ChatParams, type ChatRun, RunResultType } from './types';
import { createUserMessage, extractTextFromMessage } from './utils';
import { createUserMessage, extractErrorExtension, extractTextFromMessage } from './utils';

function handleStatusUpdate<UIGenericPart = never>(
event: TaskStatusUpdateEvent,
onStatusUpdate?: (event: TaskStatusUpdateEvent) => UIGenericPart[],
): (UIMessagePart | UIGenericPart)[] {
const { message, state } = event.status;
const extensionError = extractErrorExtension(message?.metadata);

if (state === 'failed' || state === 'rejected') {
if (extensionError) {
throw new A2AExtensionError(extensionError);
}

const errorMessage = extractTextFromMessage(message) ?? AGENT_ERROR_MESSAGE;

throw new Error(errorMessage);
Expand Down
2 changes: 2 additions & 0 deletions apps/agentstack-ui/src/api/a2a/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import type { FilePart, FileWithUri, Message, Part, TextPart } from '@a2a-js/sdk
import {
type Citation,
citationExtension,
errorExtension,
extractUiExtensionData,
trajectoryExtension,
type TrajectoryMetadata,
Expand All @@ -30,6 +31,7 @@ import { PLATFORM_FILE_CONTENT_URL_BASE } from './constants';

export const extractCitation = extractUiExtensionData(citationExtension);
export const extractTrajectory = extractUiExtensionData(trajectoryExtension);
export const extractErrorExtension = extractUiExtensionData(errorExtension);

export function extractTextFromMessage(message: Message | undefined) {
const text = message?.parts
Expand Down
22 changes: 21 additions & 1 deletion apps/agentstack-ui/src/api/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,13 @@
* SPDX-License-Identifier: Apache-2.0
*/

import type { ApiErrorCode, ApiErrorResponse, ApiValidationErrorResponse, StreamErrorResponse } from './types';
import type {
A2AErrorMetadata,
ApiErrorCode,
ApiErrorResponse,
ApiValidationErrorResponse,
StreamErrorResponse,
} from './types';

export class ErrorWithResponse extends Error {
name: string;
Expand Down Expand Up @@ -64,3 +70,17 @@ export class StreamError extends ErrorWithResponse {
}

export class UnauthenticatedError extends ErrorWithResponse {}

export class A2AExtensionError extends Error {
error: A2AErrorMetadata['error'];
context: A2AErrorMetadata['context'];
stackTrace: A2AErrorMetadata['stack_trace'];

constructor({ error, context, stack_trace }: A2AErrorMetadata) {
super(error.message);

this.error = error;
this.context = context;
this.stackTrace = stack_trace;
}
}
2 changes: 2 additions & 0 deletions apps/agentstack-ui/src/api/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@

import type { components } from './schema';

export type { ErrorMetadata as A2AErrorMetadata } from 'agentstack-sdk';

export type ApiErrorResponse = {
code: string;
message?: string;
Expand Down
69 changes: 67 additions & 2 deletions apps/agentstack-ui/src/api/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,14 @@ import type { ServerSentEventMessage } from 'fetch-event-stream';
import type { FetchResponse } from 'openapi-fetch';
import type { MediaType } from 'openapi-typescript-helpers';

import type { QueryMetadataError } from '#contexts/QueryProvider/types.ts';
import type { Toast } from '#contexts/Toast/toast-context.ts';
import type { Agent } from '#modules/agents/api/types.ts';
import { NEXTAUTH_URL, TRUST_PROXY_HEADERS } from '#utils/constants.ts';
import { isNotNull } from '#utils/helpers.ts';
import { createMarkdownCodeBlock, createMarkdownSection, joinMarkdownSections } from '#utils/markdown.ts';

import { ApiError, ApiValidationError, HttpError, UnauthenticatedError } from './errors';
import { A2AExtensionError, ApiError, ApiValidationError, HttpError, UnauthenticatedError } from './errors';
import type { ApiErrorCode, ApiErrorResponse, ApiValidationErrorResponse } from './types';

export function ensureData<T extends Record<string | number, unknown>, O, M extends MediaType>(
Expand Down Expand Up @@ -65,7 +68,23 @@ export async function handleStream<T>({
}
}

export function getErrorMessage(error: unknown) {
export function getErrorTitle(error: unknown) {
if (error instanceof A2AExtensionError) {
return 'errors' in error.error ? 'Multiple errors occurred' : error.error.title;
}

return typeof error === 'object' && isNotNull(error) && 'title' in error ? (error.title as string) : undefined;
}

export function getErrorMessage(error: unknown, includeMessage = true) {
if (!includeMessage) {
return;
}

if (error instanceof A2AExtensionError) {
return createA2AErrorMessage(error);
}

return typeof error === 'object' && isNotNull(error) && 'message' in error ? (error.message as string) : undefined;
}

Expand Down Expand Up @@ -111,3 +130,49 @@ export async function getProxyHeaders(headers: Headers, url?: URL) {

return { forwardedHost, forwardedProto, forwarded };
}

export function buildErrorToast({ metadata = {}, error }: { metadata?: QueryMetadataError; error: unknown }): Toast {
const { title = 'An error occurred', includeErrorMessage } = metadata;

return {
kind: 'error',
title: getErrorTitle(error) ?? title,
message: joinMarkdownSections([metadata.message, getErrorMessage(error, includeErrorMessage)]),
renderMarkdown: true,
};
}

export function createA2AErrorMessage(error: A2AExtensionError) {
const {
error: { message: errorMessage },
context,
stackTrace,
} = error;

const errors = 'errors' in error.error ? error.error.errors : [];
const errorMessages = joinMarkdownSections(
errors.map(({ title, message }) => createMarkdownSection({ heading: title, content: message })),
);

const contextMessage = context
? createMarkdownSection({
heading: 'Context',
content: createMarkdownCodeBlock({
snippet: JSON.stringify(context, null, 2),
language: 'json',
}),
})
: undefined;
const stackTraceMessage = stackTrace
? createMarkdownSection({
heading: 'Stack Trace',
content: createMarkdownCodeBlock({
snippet: stackTrace,
}),
})
: undefined;

const message = joinMarkdownSections([errorMessage, errorMessages, contextMessage, stackTraceMessage]);

return message;
}
Original file line number Diff line number Diff line change
Expand Up @@ -16,17 +16,26 @@
}

.copyButton {
@include hide-popover();
position: absolute;
inset-block-start: $spacing-03;
inset-inline-end: $spacing-03;
:global(.cds--btn):hover {
background-color: $layer-03;
:global(.cds--btn) {
&:disabled {
color: currentColor;
}

&:hover {
--cds-icon-primary: #{$text-inverse};

background-color: $text-dark;
}
}
}

.content {
pre {
white-space: pre-wrap;
overflow-wrap: break-word;
overflow-wrap: anywhere;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,6 @@

min-block-size: rem(48px);
border: none;
border-radius: 0;
}

&.blog {
Expand All @@ -29,13 +28,18 @@
@include type-style(label-01);
min-inline-size: 0;
white-space: pre-wrap;
overflow-wrap: break-word;
overflow-wrap: anywhere;
padding: rem(11px) rem(15px);
flex-grow: 1;

.block & {
padding: unset;
}

pre {
border-start-start-radius: $border-radius;
border-end-start-radius: $border-radius;
}
}

.button {
Expand All @@ -48,6 +52,8 @@
.block & {
border: none;
margin: rem(8px) rem(8px) auto 0;
position: sticky;
inset-block-start: rem(8px);
}

> div,
Expand Down
15 changes: 6 additions & 9 deletions apps/agentstack-ui/src/components/ErrorMessage/ErrorMessage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,33 +4,30 @@
*/

import { ActionableNotification, Button, InlineLoading } from '@carbon/react';
import type { ReactNode } from 'react';

import { MarkdownContent } from '#components/MarkdownContent/MarkdownContent.tsx';
import { NotificationMarkdownContent } from '#components/NotificationMarkdownContent/NotificationMarkdownContent.tsx';

import classes from './ErrorMessage.module.scss';

interface Props {
title?: string;
subtitle?: string;
onRetry?: () => void;
message?: string;
isRefetching?: boolean;
children?: ReactNode;
onRetry?: () => void;
}

export function ErrorMessage({ title, subtitle, onRetry, isRefetching, children }: Props) {
export function ErrorMessage({ title, message, isRefetching, onRetry }: Props) {
return (
<ActionableNotification title={title} kind="error" lowContrast hideCloseButton>
{(subtitle || onRetry) && (
{(message || onRetry) && (
<div className={classes.body}>
{subtitle && <MarkdownContent>{subtitle}</MarkdownContent>}
{message && <NotificationMarkdownContent>{message}</NotificationMarkdownContent>}

{onRetry && (
<Button size="sm" onClick={() => onRetry()} disabled={isRefetching}>
{!isRefetching ? 'Retry' : <InlineLoading description="Retrying&hellip;" />}
</Button>
)}
{children}
</div>
)}
</ActionableNotification>
Expand Down
19 changes: 16 additions & 3 deletions apps/agentstack-ui/src/components/LineClampText/LineClampText.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ interface Props {
className?: string;
buttonClassName?: string;
useBlockElement?: boolean;
autoExpandOnContentChange?: boolean;
}

export function LineClampText({
Expand All @@ -27,16 +28,18 @@ export function LineClampText({
className,
buttonClassName,
useBlockElement,
autoExpandOnContentChange,
children,
}: PropsWithChildren<Props>) {
const id = useId();
const textRef = useRef<HTMLDivElement>(null);
const sentinelRef = useRef<HTMLSpanElement>(null);
const initialOverflowRef = useRef<boolean | null>(null);

const [isExpanded, setIsExpanded] = useState(false);
const [overflowDetected, setOverflowDetected] = useState(false);

const showButton = isExpanded || overflowDetected;
const showButton = (isExpanded || overflowDetected) && (initialOverflowRef.current ?? true);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's a bit hard to read what initialOverflowRef controls and how at first, I'd consider using descriptive states like: unset, overflowing, not-overflowing.
This condition could be then clearer - initialOverflowRef.current !== 'not-overflowing'. That doesn't seem right though, is it?:)


const Component = useBlockElement ? 'div' : 'span';
const buttonProps = {
Expand All @@ -56,7 +59,17 @@ export function LineClampText({

const observer = new IntersectionObserver(
([entry]) => {
setOverflowDetected(!entry.isIntersecting);
const isOverflowing = !entry.isIntersecting;

setOverflowDetected(isOverflowing);

if (initialOverflowRef.current === null) {
initialOverflowRef.current = isOverflowing;
}

if (autoExpandOnContentChange && initialOverflowRef.current === false && isOverflowing) {
setIsExpanded(true);
}
},
{
root: textElement,
Expand All @@ -69,7 +82,7 @@ export function LineClampText({
return () => {
observer.disconnect();
};
}, [isExpanded]);
}, [autoExpandOnContentChange, isExpanded]);

return (
<Component className={clsx(classes.root, className)}>
Expand Down
Loading