Skip to content

Commit 67be1bd

Browse files
committed
feat(agentStudio): error translations access raw errorMessage safely
1 parent 05086fe commit 67be1bd

5 files changed

Lines changed: 141 additions & 42 deletions

File tree

packages/instantsearch-ui-components/src/components/chat/ChatMessageError.tsx

Lines changed: 19 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -10,14 +10,20 @@ export type ChatMessageErrorTranslations = {
1010
/**
1111
* Error message text
1212
*/
13-
errorMessage: string;
13+
errorMessage:
14+
| string
15+
| ((params: { errorMessage?: string }) => string);
1416
/**
1517
* Retry button text
1618
*/
1719
retryText: string;
1820
};
1921

2022
export type ChatMessageErrorProps = ComponentProps<'article'> & {
23+
/**
24+
* Raw error message received from the API/transport layer.
25+
*/
26+
errorMessage?: string;
2127
/**
2228
* Callback for reload action
2329
*/
@@ -39,17 +45,21 @@ export function createChatMessageErrorComponent({
3945

4046
return function ChatMessageError(userProps: ChatMessageErrorProps) {
4147
const {
48+
errorMessage,
4249
onReload,
4350
actions,
4451
translations: userTranslations,
4552
...props
4653
} = userProps;
47-
const translations: Required<ChatMessageErrorTranslations> = {
48-
errorMessage:
49-
'Sorry, we are not able to generate a response at the moment. Please retry or contact support.',
50-
retryText: 'Retry',
51-
...userTranslations,
52-
};
54+
const defaultErrorMessage =
55+
'Sorry, we are not able to generate a response at the moment. Please retry or contact support.';
56+
const defaultRetryText = 'Retry';
57+
const errorMessageTranslation = userTranslations?.errorMessage;
58+
const resolvedErrorMessage =
59+
typeof errorMessageTranslation === 'function'
60+
? errorMessageTranslation({ errorMessage })
61+
: errorMessageTranslation || defaultErrorMessage;
62+
const retryText = userTranslations?.retryText || defaultRetryText;
5363

5464
return (
5565
<article
@@ -59,7 +69,7 @@ export function createChatMessageErrorComponent({
5969
<div className="ais-ChatMessage-container">
6070
<div className="ais-ChatMessage-content">
6171
<div className="ais-ChatMessage-message">
62-
{translations.errorMessage}
72+
{resolvedErrorMessage}
6373
</div>
6474
{(actions || onReload) && (
6575
<div className="ais-ChatMessage-actions">
@@ -82,7 +92,7 @@ export function createChatMessageErrorComponent({
8292
onClick={() => onReload?.()}
8393
>
8494
<ReloadIcon createElement={createElement} />
85-
{translations.retryText}
95+
{retryText}
8696
</Button>
8797
)}
8898
</div>

packages/instantsearch-ui-components/src/components/chat/ChatMessages.tsx

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -519,11 +519,7 @@ export function createChatMessagesComponent({
519519
{status === 'error' && (
520520
<DefaultError
521521
onReload={onReload}
522-
translations={
523-
error?.message
524-
? { errorMessage: error.message }
525-
: undefined
526-
}
522+
errorMessage={error?.message}
527523
/>
528524
)}
529525
</div>

packages/instantsearch-ui-components/src/components/chat/__tests__/ChatMessages.test.tsx

Lines changed: 66 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,16 @@
55
import { render, screen } from '@testing-library/preact';
66
import { Fragment, createElement } from 'preact';
77

8+
import { createChatMessageErrorComponent } from '../ChatMessageError';
89
import { createChatMessagesComponent } from '../ChatMessages';
910

11+
import type { ChatMessageErrorProps } from '../ChatMessageError';
12+
1013
const ChatMessages = createChatMessagesComponent({
1114
createElement,
1215
Fragment,
1316
});
17+
const ChatMessageError = createChatMessageErrorComponent({ createElement });
1418

1519
describe('ChatMessages', () => {
1620
test('renders with default props', () => {
@@ -256,7 +260,66 @@ describe('ChatMessages', () => {
256260
});
257261
});
258262

259-
test('shows API error message when status is error and error is set', () => {
263+
test('does not expose raw API error message by default', () => {
264+
render(
265+
<ChatMessages
266+
messages={[]}
267+
indexUiState={{}}
268+
setIndexUiState={jest.fn()}
269+
tools={{}}
270+
onReload={jest.fn()}
271+
onClose={jest.fn()}
272+
status="error"
273+
error={new Error('Request blocked for this domain')}
274+
/>
275+
);
276+
277+
expect(
278+
screen.getByText(
279+
'Sorry, we are not able to generate a response at the moment. Please retry or contact support.'
280+
)
281+
).toBeInTheDocument();
282+
expect(
283+
screen.queryByText('Request blocked for this domain')
284+
).not.toBeInTheDocument();
285+
});
286+
287+
test('passes raw error message to custom error component', () => {
288+
const ErrorComponent = jest.fn(() => <span>Custom error</span>);
289+
290+
render(
291+
<ChatMessages
292+
messages={[]}
293+
indexUiState={{}}
294+
setIndexUiState={jest.fn()}
295+
tools={{}}
296+
onReload={jest.fn()}
297+
onClose={jest.fn()}
298+
status="error"
299+
error={new Error('Request blocked for this domain')}
300+
errorComponent={ErrorComponent}
301+
/>
302+
);
303+
304+
expect(ErrorComponent).toHaveBeenCalledWith(
305+
expect.objectContaining({
306+
errorMessage: 'Request blocked for this domain',
307+
}),
308+
{}
309+
);
310+
});
311+
312+
test('allows error translation to use raw error message', () => {
313+
const CustomError = (props: ChatMessageErrorProps) => (
314+
<ChatMessageError
315+
{...props}
316+
translations={{
317+
errorMessage: ({ errorMessage }) =>
318+
errorMessage ? `Friendly: ${errorMessage}` : 'Friendly fallback',
319+
}}
320+
/>
321+
);
322+
260323
render(
261324
<ChatMessages
262325
messages={[]}
@@ -267,11 +330,12 @@ describe('ChatMessages', () => {
267330
onClose={jest.fn()}
268331
status="error"
269332
error={new Error('Request blocked for this domain')}
333+
errorComponent={CustomError}
270334
/>
271335
);
272336

273337
expect(
274-
screen.getByText('Request blocked for this domain')
338+
screen.getByText('Friendly: Request blocked for this domain')
275339
).toBeInTheDocument();
276340
});
277341

packages/instantsearch.js/src/lib/ai-lite/__tests__/utils.test.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,14 @@
44
import { getMessageFromStreamErrorText } from '../utils';
55

66
describe('getMessageFromStreamErrorText', () => {
7+
function encodeAsNestedJson(value: unknown, depth: number): string {
8+
let result = JSON.stringify(value);
9+
for (let i = 0; i < depth; i++) {
10+
result = JSON.stringify(result);
11+
}
12+
return result;
13+
}
14+
715
it('unwraps double-encoded JSON with error field (Agent Studio / AI SDK style)', () => {
816
const inner = {
917
error: 'Max steps per completion limit was reached',
@@ -41,6 +49,17 @@ describe('getMessageFromStreamErrorText', () => {
4149
);
4250
});
4351

52+
it('unwraps deeply nested JSON strings without fixed depth limits', () => {
53+
const errorText = encodeAsNestedJson(
54+
{ error: 'Deep nested error message' },
55+
25
56+
);
57+
58+
expect(getMessageFromStreamErrorText(errorText)).toBe(
59+
'Deep nested error message'
60+
);
61+
});
62+
4463
it('returns Unknown error for whitespace-only input', () => {
4564
expect(getMessageFromStreamErrorText(' ')).toBe('Unknown error');
4665
});

packages/instantsearch.js/src/lib/ai-lite/utils.ts

Lines changed: 36 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,34 @@ function messageFromErrorObject(o: Record<string, unknown>): string | undefined
112112
return undefined;
113113
}
114114

115+
function unwrapNestedJsonString(
116+
value: unknown,
117+
seenStrings: ReadonlySet<string> = new Set()
118+
): unknown {
119+
if (typeof value !== 'string') {
120+
return value;
121+
}
122+
123+
const trimmed = value.trim();
124+
if (!trimmed) {
125+
return trimmed;
126+
}
127+
128+
if (seenStrings.has(trimmed)) {
129+
return trimmed;
130+
}
131+
132+
try {
133+
const parsed = JSON.parse(trimmed) as unknown;
134+
if (typeof parsed === 'string') {
135+
return unwrapNestedJsonString(parsed, new Set([...seenStrings, trimmed]));
136+
}
137+
return parsed;
138+
} catch {
139+
return trimmed;
140+
}
141+
}
142+
115143
/**
116144
* Turns chat stream `error` chunk `errorText` into a short user-facing string.
117145
* Handles plain text, single JSON objects (`error` / `message`), and
@@ -123,34 +151,16 @@ export function getMessageFromStreamErrorText(errorText: string): string {
123151
return 'Unknown error';
124152
}
125153

126-
let remaining: unknown = trimmed;
127-
for (let depth = 0; depth < 5; depth++) {
128-
if (typeof remaining === 'string') {
129-
const s = remaining.trim();
130-
if (!s) {
131-
break;
132-
}
133-
try {
134-
remaining = JSON.parse(s) as unknown;
135-
continue;
136-
} catch {
137-
return s;
138-
}
154+
const remaining = unwrapNestedJsonString(trimmed);
155+
if (remaining && typeof remaining === 'object' && !Array.isArray(remaining)) {
156+
const o = remaining as Record<string, unknown>;
157+
const msg = messageFromErrorObject(o);
158+
if (msg) {
159+
return msg;
139160
}
140-
141-
if (remaining && typeof remaining === 'object' && !Array.isArray(remaining)) {
142-
const o = remaining as Record<string, unknown>;
143-
const msg = messageFromErrorObject(o);
144-
if (msg) {
145-
return msg;
146-
}
147-
if (typeof o.type === 'string' && o.type.trim()) {
148-
return o.type.trim();
149-
}
150-
break;
161+
if (typeof o.type === 'string' && o.type.trim()) {
162+
return o.type.trim();
151163
}
152-
153-
break;
154164
}
155165

156166
if (typeof remaining === 'string') {

0 commit comments

Comments
 (0)