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
@@ -1,10 +1,21 @@
import { ReadableStream } from 'node:stream/web';
import { TextDecoder, TextEncoder } from 'node:util';
import { deserialize, serialize } from 'node:v8';

import { Message } from '@ai-sdk/ui-utils';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { act, renderHook, waitFor } from '@testing-library/react';

import {
isImagesSkippedEvent,
stampImagesSkippedOnLatestUserMessage,
useChat,
} from '../useChat';

jest.mock('@/api', () => ({
fetchAPI: jest.fn(),
}));

describe('isImagesSkippedEvent', () => {
it('accepts a chat_notice event', () => {
expect(
Expand Down Expand Up @@ -151,3 +162,78 @@ describe('stampImagesSkippedOnLatestUserMessage', () => {
).toEqual({ reason: 'model_text_only' });
});
});

// jsdom ships none of the globals the SDK uses to read a streamed response.
// v8 serialize/deserialize stands in for structuredClone (it keeps the Date on
// `createdAt`, which a JSON round-trip would flatten to a string).
Object.assign(globalThis, {
ReadableStream,
TextDecoder,
TextEncoder,
structuredClone: <T,>(value: T): T => deserialize(serialize(value)) as T,
});

const CHAT_API = 'chats/conv-1/conversation/';

// A turn cut short right after the `summarize` tool returned: the summary
// landed, the answer never started, and no `f:` (start_step) closes the
// message. That shape is what makes the SDK's multi-step continuation kick in.
const INTERRUPTED_AFTER_SUMMARY = [
'9:{"toolCallId":"c1","toolName":"summarize","args":{"state":"running","summary_scope":"conversation"}}\n',
'a:{"toolCallId":"c1","result":{"state":"done"}}\n',
].join('');

const streamOf = (payload: string) =>
new ReadableStream({
start(controller) {
controller.enqueue(new TextEncoder().encode(payload));
controller.close();
},
});

describe('useChat multi-step continuation', () => {
const fetchAPIMock = jest.requireMock('@/api').fetchAPI as jest.Mock;

const wrapper = ({ children }: { children: React.ReactNode }) => (
<QueryClientProvider
client={
new QueryClient({ defaultOptions: { queries: { retry: false } } })
}
>
{children}
</QueryClientProvider>
);

it('does not re-POST the turn when a stream ends on a resolved tool call', async () => {
const chatCalls: string[] = [];
fetchAPIMock.mockImplementation((url: string) => {
if (url.startsWith('chat-cooldown')) {
return Promise.resolve({
ok: true,
json: () => Promise.resolve({ cooldown_seconds: 0 }),
});
}
chatCalls.push(url);
return Promise.resolve({
ok: true,
body: streamOf(INTERRUPTED_AFTER_SUMMARY),
});
});

const onError = jest.fn();
const { result } = renderHook(
() => useChat({ id: 'conv-1', api: CHAT_API, onError }),
{ wrapper },
);

await act(async () => {
await result.current.append({ role: 'user', content: 'hello' });
});
await waitFor(() => expect(result.current.status).toBe('ready'));
expect(onError).not.toHaveBeenCalled();

// A second POST would carry an assistant-terminated message list, which the
// backend answers with an empty stream — leaving the bubble blank.
expect(chatCalls).toEqual([CHAT_API]);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -157,7 +157,16 @@ export function useChat(options: Omit<UseChatOptions, 'fetch'>) {

const result = useAiSdkChat({
...restOptions,
maxSteps: 3,
// Single step: every tool loop runs server-side inside the agent, and we
// register no client-side tool (no `onToolCall`/`addToolResult`), so there
// is nothing for the client to continue. With maxSteps > 1 the SDK
// auto-resubmits whenever a stream ends with a resolved tool invocation and
// no trailing `start_step` — which is what an interrupted turn looks like
// once the `summarize` tool has returned. That resubmit flips the status
// away from `error`, hiding the failure, and the backend answers an
// assistant-terminated message list with an empty stream: the bubble stays
// blank instead of showing an error and a retry.
maxSteps: 1,
fetch: fetchAPIAdapter,
onFinish: (message, finishOptions) => {
if (message.annotations?.length) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -304,6 +304,23 @@ const MessageItemComponent: React.FC<MessageItemProps> = ({
chatErrorType === 'summarization_failed' &&
!!conversationSummarizeInvocation;

const [isSummarizationBarHidden, setIsSummarizationBarHidden] =
React.useState(false);
const handleSummarizationBarHidden = React.useCallback(
() => setIsSummarizationBarHidden(true),
[],
);

// Once the summary lands, the turn keeps streaming with an empty bubble until
// the model emits its first answer token. Without this the progress bar just
// vanishes and nothing replaces it, which reads as a stall.
const showPostSummarizationLoader =
isCurrentlyStreaming &&
status === 'streaming' &&
isSummarizationBarHidden &&
!message.content &&
!activeToolInvocation;

// Memoize the streaming content split to avoid recreating components in JSX
const { completedBlocks, pending } = React.useMemo(() => {
// When not streaming, everything is completed as a single block array
Expand Down Expand Up @@ -434,9 +451,29 @@ const MessageItemComponent: React.FC<MessageItemProps> = ({
>
<SummarizationProgress
done={conversationSummarizeInvocation.state === 'result'}
onHidden={handleSummarizationBarHidden}
/>
</Box>
)}
{showPostSummarizationLoader && (
<Box
$direction="row"
$align="center"
$gap="6px"
$width="100%"
$maxWidth="var(--chat-content-max-width, 750px)"
$margin={{
all: 'auto',
top: 'base',
bottom: 'md',
}}
>
<Loader />
<Text $variation="600" $size="md">
{t('Thinking...')}
</Text>
</Box>
)}
{summarizationFailed && onRetry && (
<Box
$width="100%"
Expand Down Expand Up @@ -584,6 +621,17 @@ const MessageItemComponent: React.FC<MessageItemProps> = ({

MessageItemComponent.displayName = 'MessageItem';

// Tool invocations go from `call` to `result` in place, without changing the
// parts count, so their states need their own signature: the summarization
// progress bar and the loader that replaces it hang on that transition.
const getToolInvocationStates = (message: Message): string =>
(message.parts ?? [])
.filter(
(part): part is ToolInvocationUIPart => part.type === 'tool-invocation',
)
.map((part) => part.toolInvocation.state)
.join(',');

// Custom comparison function for React.memo
// Only re-render when props that affect rendering change
const arePropsEqual = (
Expand Down Expand Up @@ -614,6 +662,12 @@ const arePropsEqual = (
if (prevPartsLength !== nextPartsLength) {
return false;
}
if (
getToolInvocationStates(prevProps.message) !==
getToolInvocationStates(nextProps.message)
) {
return false;
}

// Check attachments
const prevAttachmentsLength =
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,16 @@ const HIDE_DELAY_MS = 400;

interface SummarizationProgressProps {
done: boolean;
/** Called once the bar has finished its completion animation and hidden
* itself, so the caller can take over the slot without overlapping it.
* Must be referentially stable (the hide timer restarts otherwise). */
onHidden?: () => void;
}

export const SummarizationProgress = ({ done }: SummarizationProgressProps) => {
export const SummarizationProgress = ({
done,
onHidden,
}: SummarizationProgressProps) => {
const { t } = useTranslation();
const [progress, setProgress] = React.useState(0);
const [hidden, setHidden] = React.useState(false);
Expand All @@ -38,9 +45,12 @@ export const SummarizationProgress = ({ done }: SummarizationProgressProps) => {
return;
}
setProgress(1);
const timeout = setTimeout(() => setHidden(true), HIDE_DELAY_MS);
const timeout = setTimeout(() => {
setHidden(true);
onHidden?.();
}, HIDE_DELAY_MS);
return () => clearTimeout(timeout);
}, [done]);
}, [done, onHidden]);

if (hidden) {
return null;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -381,14 +381,16 @@ describe('MessageItem', () => {
getMetadata: jest.fn(),
};

const withProviders = (ui: React.ReactNode) => (
<CunninghamProvider>
<ToastProvider>
<Suspense fallback={null}>{ui}</Suspense>
</ToastProvider>
</CunninghamProvider>
);

const renderWithProviders = (ui: React.ReactNode) => {
return render(
<CunninghamProvider>
<ToastProvider>
<Suspense fallback={null}>{ui}</Suspense>
</ToastProvider>
</CunninghamProvider>,
);
return render(withProviders(ui));
};

beforeEach(() => {
Expand Down Expand Up @@ -610,6 +612,10 @@ describe('MessageItem', () => {
});

describe('summarization progress', () => {
afterEach(() => {
jest.useRealTimers();
});

const conversationSummarizeMessage = {
id: 'msg-sum',
role: 'assistant' as const,
Expand Down Expand Up @@ -704,6 +710,101 @@ describe('MessageItem', () => {
expect(onRetry).toHaveBeenCalledTimes(1);
});

it('takes over with a spinner once the progress bar has hidden itself', async () => {
jest.useFakeTimers();
const summarizedMessage = {
...conversationSummarizeMessage,
parts: [
{
type: 'tool-invocation' as const,
toolInvocation: {
toolCallId: 'call-1',
toolName: 'summarize',
state: 'result' as const,
args: { state: 'running', summary_scope: 'conversation' },
result: { state: 'done' },
},
},
],
};

const { rerender } = renderWithProviders(
<MessageItem
{...defaultProps}
message={conversationSummarizeMessage}
status="streaming"
isLastAssistantMessage={true}
/>,
);

// The summary result landed: the bar completes but is still on screen.
rerender(
withProviders(
<MessageItem
{...defaultProps}
message={summarizedMessage}
status="streaming"
isLastAssistantMessage={true}
/>,
),
);
expect(screen.getByTestId('summarization-progress')).toBeInTheDocument();
expect(screen.queryByText('Thinking...')).not.toBeInTheDocument();

// Once it hides, the spinner covers the wait for the first answer token.
act(() => {
jest.advanceTimersByTime(400);
});
expect(
screen.queryByTestId('summarization-progress'),
).not.toBeInTheDocument();
expect(screen.getByText('Thinking...')).toBeInTheDocument();
});

it('drops the spinner once the answer starts streaming', async () => {
jest.useFakeTimers();
const summarizedMessage = {
...conversationSummarizeMessage,
parts: [
{
type: 'tool-invocation' as const,
toolInvocation: {
toolCallId: 'call-1',
toolName: 'summarize',
state: 'result' as const,
args: { state: 'running', summary_scope: 'conversation' },
result: { state: 'done' },
},
},
],
};

const { rerender } = renderWithProviders(
<MessageItem
{...defaultProps}
message={summarizedMessage}
status="streaming"
isLastAssistantMessage={true}
/>,
);
act(() => {
jest.advanceTimersByTime(400);
});
expect(screen.getByText('Thinking...')).toBeInTheDocument();

rerender(
withProviders(
<MessageItem
{...defaultProps}
message={{ ...summarizedMessage, content: 'Here is the answer' }}
status="streaming"
isLastAssistantMessage={true}
/>,
),
);
expect(screen.queryByText('Thinking...')).not.toBeInTheDocument();
});

it('does not render the summarization error for other error types', async () => {
await act(async () => {
renderWithProviders(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -60,4 +60,19 @@ describe('SummarizationProgress', () => {
screen.queryByTestId('summarization-progress'),
).not.toBeInTheDocument();
});

it('calls onHidden once the bar has hidden itself', () => {
const onHidden = jest.fn();
const { rerender } = render(
<SummarizationProgress done={false} onHidden={onHidden} />,
);

rerender(<SummarizationProgress done={true} onHidden={onHidden} />);
expect(onHidden).not.toHaveBeenCalled();

act(() => {
jest.advanceTimersByTime(400);
});
expect(onHidden).toHaveBeenCalledTimes(1);
});
});
Loading