Skip to content

Commit d17da19

Browse files
authored
test(agents-extensions): add AI SDK UI boundary coverage (#1162)
1 parent f6424a3 commit d17da19

3 files changed

Lines changed: 186 additions & 1 deletion

File tree

.changeset/old-camels-fly.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@openai/agents-extensions": patch
3+
---
4+
5+
test: add AI SDK UI boundary coverage

packages/agents-extensions/test/ai-sdk-ui/textStream.test.ts

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { describe, expect, test } from 'vitest';
1+
import { afterEach, describe, expect, test, vi } from 'vitest';
22
import { ReadableStream as NodeReadableStream } from 'node:stream/web';
33
import { createAiSdkTextStreamResponse } from '../../src/ai-sdk-ui/index';
44

@@ -33,6 +33,10 @@ async function readResponseText(response: Response): Promise<string> {
3333
return output;
3434
}
3535

36+
afterEach(() => {
37+
vi.unstubAllGlobals();
38+
});
39+
3640
describe('createAiSdkTextStreamResponse', () => {
3741
test('streams text and applies default headers', async () => {
3842
const response = createAiSdkTextStreamResponse(
@@ -63,4 +67,22 @@ describe('createAiSdkTextStreamResponse', () => {
6367
expect(response.headers.get('cache-control')).toBe('no-store');
6468
await expect(readResponseText(response)).resolves.toBe('One more');
6569
});
70+
71+
test('preserves explicit headers and encodes without TextEncoderStream', async () => {
72+
vi.stubGlobal('TextEncoderStream', undefined);
73+
74+
const response = createAiSdkTextStreamResponse(
75+
stringStream(['Edge case']),
76+
{
77+
headers: [
78+
['Content-Type', 'text/custom'],
79+
['cache-control', 'max-age=60'],
80+
],
81+
},
82+
);
83+
84+
expect(response.headers.get('content-type')).toBe('text/custom');
85+
expect(response.headers.get('cache-control')).toBe('max-age=60');
86+
await expect(readResponseText(response)).resolves.toBe('Edge case');
87+
});
6688
});

packages/agents-extensions/test/ai-sdk-ui/uiMessageStream.test.ts

Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import {
55
RunRawModelStreamEvent,
66
RunMessageOutputItem,
77
RunReasoningItem,
8+
RunToolApprovalItem,
89
RunToolCallItem,
910
RunToolCallOutputItem,
1011
RunToolSearchCallItem,
@@ -985,4 +986,161 @@ describe('createAiSdkUiMessageStreamResponse', () => {
985986
expect(deltas).toHaveLength(1);
986987
expect(deltas[0]).toMatchObject({ delta: 'Hello again' });
987988
});
989+
990+
test('falls back for invalid JSON arguments and maps non-function tool inputs', async () => {
991+
const agent = new Agent({ name: 'Test Agent' });
992+
993+
const invalidFunctionCall = new RunToolCallItem(
994+
{
995+
type: 'function_call',
996+
name: 'broken_json',
997+
arguments: '{not valid json',
998+
} as any,
999+
agent,
1000+
);
1001+
const computerCall = new RunToolCallItem(
1002+
{
1003+
type: 'computer_call',
1004+
callId: 'computer-call-1',
1005+
action: { type: 'click', x: 1, y: 2, button: 'left' },
1006+
} as any,
1007+
agent,
1008+
);
1009+
const shellCall = new RunToolCallItem(
1010+
{
1011+
type: 'shell_call',
1012+
callId: 'shell-call-1',
1013+
action: {
1014+
command: 'pwd',
1015+
cwd: '/tmp',
1016+
},
1017+
} as any,
1018+
agent,
1019+
);
1020+
const applyPatchCall = new RunToolCallItem(
1021+
{
1022+
type: 'apply_patch_call',
1023+
callId: 'apply-patch-call-1',
1024+
operation: '*** Begin Patch\n*** End Patch\n',
1025+
} as any,
1026+
agent,
1027+
);
1028+
1029+
const events = (async function* () {
1030+
yield new RunItemStreamEvent('tool_called', invalidFunctionCall);
1031+
yield new RunItemStreamEvent('tool_called', computerCall);
1032+
yield new RunItemStreamEvent('tool_called', shellCall);
1033+
yield new RunItemStreamEvent('tool_called', applyPatchCall);
1034+
})();
1035+
1036+
const response = createAiSdkUiMessageStreamResponse(events);
1037+
const chunks = await readUiMessageChunks(response);
1038+
1039+
expect(
1040+
chunks.filter((chunk) => chunk.type === 'tool-input-available'),
1041+
).toMatchObject([
1042+
{
1043+
toolCallId: expect.stringMatching(/^broken_json-call-/),
1044+
toolName: 'broken_json',
1045+
input: { raw: '{not valid json' },
1046+
dynamic: true,
1047+
},
1048+
{
1049+
toolCallId: 'computer-call-1',
1050+
toolName: 'computer_call',
1051+
input: { type: 'click', x: 1, y: 2, button: 'left' },
1052+
dynamic: true,
1053+
},
1054+
{
1055+
toolCallId: 'shell-call-1',
1056+
toolName: 'shell_call',
1057+
input: {
1058+
command: 'pwd',
1059+
cwd: '/tmp',
1060+
},
1061+
dynamic: true,
1062+
},
1063+
{
1064+
toolCallId: 'apply-patch-call-1',
1065+
toolName: 'apply_patch_call',
1066+
input: '*** Begin Patch\n*** End Patch\n',
1067+
dynamic: true,
1068+
},
1069+
]);
1070+
});
1071+
1072+
test('emits approval requests with generated fallback ids', async () => {
1073+
const agent = new Agent({ name: 'Test Agent' });
1074+
1075+
const approvalItem = new RunToolApprovalItem(
1076+
{
1077+
type: 'shell_call',
1078+
action: {
1079+
command: 'rm -rf /tmp/nope',
1080+
},
1081+
} as any,
1082+
agent,
1083+
'shell',
1084+
);
1085+
1086+
const events = (async function* () {
1087+
yield new RunItemStreamEvent('tool_approval_requested', approvalItem);
1088+
})();
1089+
1090+
const response = createAiSdkUiMessageStreamResponse(events);
1091+
const chunks = await readUiMessageChunks(response);
1092+
const approvalRequest = chunks.find(
1093+
(chunk) => chunk.type === 'tool-approval-request',
1094+
);
1095+
1096+
expect(approvalRequest).toMatchObject({
1097+
type: 'tool-approval-request',
1098+
toolCallId: expect.stringMatching(/^shell-call-/),
1099+
approvalId: expect.stringMatching(/^shell-call-/),
1100+
});
1101+
});
1102+
1103+
test('closes pending empty steps when response_done arrives before empty message output', async () => {
1104+
const agent = new Agent({ name: 'Test Agent' });
1105+
1106+
const emptyMessageOutput = new RunMessageOutputItem(
1107+
{
1108+
type: 'message',
1109+
role: 'assistant',
1110+
status: 'completed',
1111+
content: [],
1112+
},
1113+
agent,
1114+
);
1115+
1116+
const events = (async function* () {
1117+
yield new RunRawModelStreamEvent({ type: 'response_started' });
1118+
yield new RunRawModelStreamEvent({
1119+
type: 'response_done',
1120+
response: {
1121+
id: 'resp-empty',
1122+
usage: {
1123+
inputTokens: 1,
1124+
outputTokens: 0,
1125+
totalTokens: 1,
1126+
},
1127+
output: [],
1128+
},
1129+
});
1130+
yield new RunItemStreamEvent(
1131+
'message_output_created',
1132+
emptyMessageOutput,
1133+
);
1134+
})();
1135+
1136+
const response = createAiSdkUiMessageStreamResponse(events);
1137+
const chunks = await readUiMessageChunks(response);
1138+
1139+
expect(chunks.map((chunk) => chunk.type)).toEqual([
1140+
'start',
1141+
'start-step',
1142+
'finish-step',
1143+
'finish',
1144+
]);
1145+
});
9881146
});

0 commit comments

Comments
 (0)