@alexkroman1/aai 1.7.1 → 1.8.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.turbo/turbo-build.log +11 -9
- package/CHANGELOG.md +23 -0
- package/dist/{_internal-types-CrnTi9Ew.js → _internal-types-CfOAbK6V.js} +22 -35
- package/dist/constants-y68COEGj.js +29 -0
- package/dist/host/_base64.d.ts +2 -0
- package/dist/host/_mock-ws.d.ts +0 -61
- package/dist/host/_pipeline-test-fakes.d.ts +7 -4
- package/dist/host/_run-code.d.ts +0 -25
- package/dist/host/_runtime-conformance.d.ts +3 -34
- package/dist/host/memory-vector.d.ts +0 -11
- package/dist/host/providers/resolve-kv.d.ts +0 -7
- package/dist/host/providers/resolve-vector.d.ts +0 -8
- package/dist/host/providers/stt/assemblyai.d.ts +0 -14
- package/dist/host/providers/stt/deepgram.d.ts +2 -14
- package/dist/host/providers/stt/soniox.d.ts +0 -22
- package/dist/host/providers/tts/rime.d.ts +10 -31
- package/dist/host/runtime-barrel.js +670 -630
- package/dist/host/runtime-config.d.ts +9 -6
- package/dist/host/runtime.d.ts +3 -0
- package/dist/host/to-vercel-tools.d.ts +3 -33
- package/dist/host/transports/openai-realtime-transport.d.ts +45 -0
- package/dist/host/unstorage-kv.d.ts +0 -26
- package/dist/index.js +3 -3
- package/dist/openai-realtime-cjPAHMMx.js +10 -0
- package/dist/sdk/_internal-types.d.ts +6 -55
- package/dist/sdk/allowed-hosts.d.ts +4 -3
- package/dist/sdk/constants.d.ts +4 -29
- package/dist/sdk/define.d.ts +7 -4
- package/dist/sdk/kv.d.ts +13 -37
- package/dist/sdk/manifest-barrel.js +1 -1
- package/dist/sdk/manifest.d.ts +8 -2
- package/dist/sdk/protocol.js +1 -1
- package/dist/sdk/providers/s2s/openai-realtime.d.ts +17 -0
- package/dist/sdk/providers/s2s-barrel.d.ts +9 -0
- package/dist/sdk/providers/s2s-barrel.js +2 -0
- package/dist/sdk/providers/tts/rime.d.ts +1 -1
- package/dist/sdk/providers.d.ts +6 -2
- package/dist/sdk/types.d.ts +7 -1
- package/dist/{types-KUgezM6u.js → types-DOWVZhb9.js} +1 -7
- package/dist/{ws-upgrade-BeOQ7fXL.js → ws-upgrade-CG8-by1n.js} +2 -3
- package/host/_base64.ts +9 -0
- package/host/_mock-ws.ts +0 -65
- package/host/_pipeline-test-fakes.ts +19 -31
- package/host/_run-code.ts +10 -53
- package/host/_runtime-conformance.ts +3 -44
- package/host/_test-utils.ts +20 -42
- package/host/builtin-tools.test.ts +127 -222
- package/host/builtin-tools.ts +6 -10
- package/host/cleanup.test.ts +30 -73
- package/host/integration/pipeline-reference.integration.test.ts +12 -17
- package/host/integration.test.ts +0 -7
- package/host/memory-vector.test.ts +3 -1
- package/host/memory-vector.ts +16 -21
- package/host/pinecone-vector.test.ts +14 -17
- package/host/pinecone-vector.ts +10 -19
- package/host/providers/providers.test-d.ts +5 -3
- package/host/providers/resolve-kv.ts +23 -41
- package/host/providers/resolve-vector.ts +3 -12
- package/host/providers/resolve.test.ts +15 -28
- package/host/providers/resolve.ts +24 -24
- package/host/providers/stt/assemblyai.test.ts +2 -14
- package/host/providers/stt/assemblyai.ts +12 -35
- package/host/providers/stt/deepgram.test.ts +23 -83
- package/host/providers/stt/deepgram.ts +15 -40
- package/host/providers/stt/elevenlabs.test.ts +26 -38
- package/host/providers/stt/elevenlabs.ts +10 -9
- package/host/providers/stt/soniox.test.ts +35 -85
- package/host/providers/stt/soniox.ts +8 -53
- package/host/providers/tts/cartesia.test.ts +19 -58
- package/host/providers/tts/cartesia.ts +36 -66
- package/host/providers/tts/rime.test.ts +12 -38
- package/host/providers/tts/rime.ts +23 -86
- package/host/runtime-config.test.ts +9 -9
- package/host/runtime-config.ts +16 -22
- package/host/runtime.test.ts +111 -73
- package/host/runtime.ts +139 -86
- package/host/s2s.test.ts +92 -191
- package/host/s2s.ts +55 -49
- package/host/server-shutdown.test.ts +9 -30
- package/host/server.test.ts +2 -13
- package/host/server.ts +85 -100
- package/host/session-core.test.ts +15 -30
- package/host/session-core.ts +10 -13
- package/host/session-prompt.test.ts +1 -5
- package/host/to-vercel-tools.test.ts +53 -72
- package/host/to-vercel-tools.ts +9 -39
- package/host/tool-executor.test.ts +25 -51
- package/host/tool-executor.ts +18 -12
- package/host/transports/openai-realtime-transport.test.ts +439 -0
- package/host/transports/openai-realtime-transport.ts +371 -0
- package/host/transports/pipeline-transport.test.ts +125 -298
- package/host/transports/pipeline-transport.ts +20 -68
- package/host/transports/s2s-transport-fixtures.test.ts +31 -92
- package/host/transports/s2s-transport.test.ts +65 -134
- package/host/transports/s2s-transport.ts +15 -43
- package/host/transports/types.test.ts +4 -8
- package/host/unstorage-kv.test.ts +3 -2
- package/host/unstorage-kv.ts +5 -35
- package/host/ws-handler.test.ts +72 -176
- package/host/ws-handler.ts +6 -12
- package/package.json +6 -1
- package/sdk/__snapshots__/exports.test.ts.snap +7 -0
- package/sdk/__snapshots__/schema-shapes.test.ts.snap +1 -0
- package/sdk/_internal-types.test.ts +6 -9
- package/sdk/_internal-types.ts +16 -57
- package/sdk/_test-matchers.ts +25 -15
- package/sdk/allowed-hosts.test.ts +50 -114
- package/sdk/allowed-hosts.ts +8 -14
- package/sdk/constants.ts +5 -52
- package/sdk/define.test.ts +7 -6
- package/sdk/define.ts +7 -3
- package/sdk/exports.test.ts +6 -1
- package/sdk/kv.ts +13 -37
- package/sdk/manifest.test-d.ts +5 -0
- package/sdk/manifest.test.ts +61 -9
- package/sdk/manifest.ts +11 -11
- package/sdk/protocol-compat.test.ts +66 -98
- package/sdk/protocol-snapshot.test.ts +2 -16
- package/sdk/protocol.test.ts +13 -22
- package/sdk/providers/s2s/openai-realtime.ts +36 -0
- package/sdk/providers/s2s-barrel.ts +12 -0
- package/sdk/providers/tts/rime.ts +1 -1
- package/sdk/providers.ts +24 -5
- package/sdk/schema-alignment.test.ts +25 -73
- package/sdk/schema-shapes.test.ts +1 -29
- package/sdk/system-prompt.test.ts +0 -1
- package/sdk/system-prompt.ts +17 -19
- package/sdk/types-inference.test.ts +10 -36
- package/sdk/types.ts +7 -0
- package/sdk/ws-upgrade.test.ts +24 -23
- package/sdk/ws-upgrade.ts +2 -3
- package/tsdown.config.ts +8 -11
- package/dist/constants-C2nirZUI.js +0 -54
|
@@ -0,0 +1,439 @@
|
|
|
1
|
+
// Copyright 2026 the AAI authors. MIT license.
|
|
2
|
+
import { describe, expect, test, vi } from "vitest";
|
|
3
|
+
import { silentLogger } from "../_test-utils.ts";
|
|
4
|
+
import {
|
|
5
|
+
createOpenaiRealtimeTransport,
|
|
6
|
+
type OpenaiRealtimeWebSocket,
|
|
7
|
+
} from "./openai-realtime-transport.ts";
|
|
8
|
+
import type { TransportCallbacks } from "./types.ts";
|
|
9
|
+
|
|
10
|
+
function noopCallbacks(): TransportCallbacks {
|
|
11
|
+
return {
|
|
12
|
+
onReplyStarted: vi.fn(),
|
|
13
|
+
onReplyDone: vi.fn(),
|
|
14
|
+
onCancelled: vi.fn(),
|
|
15
|
+
onAudioChunk: vi.fn(),
|
|
16
|
+
onAudioDone: vi.fn(),
|
|
17
|
+
onUserTranscript: vi.fn(),
|
|
18
|
+
onAgentTranscript: vi.fn(),
|
|
19
|
+
onToolCall: vi.fn(),
|
|
20
|
+
onError: vi.fn(),
|
|
21
|
+
onSpeechStarted: vi.fn(),
|
|
22
|
+
onSpeechStopped: vi.fn(),
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
type Listener = (ev: unknown) => void;
|
|
27
|
+
|
|
28
|
+
function makeFakeWs() {
|
|
29
|
+
const listeners: Record<string, Listener[]> = {
|
|
30
|
+
open: [],
|
|
31
|
+
message: [],
|
|
32
|
+
close: [],
|
|
33
|
+
error: [],
|
|
34
|
+
};
|
|
35
|
+
const sent: string[] = [];
|
|
36
|
+
const ws: OpenaiRealtimeWebSocket = {
|
|
37
|
+
readyState: 1,
|
|
38
|
+
send(data: string) {
|
|
39
|
+
sent.push(data);
|
|
40
|
+
},
|
|
41
|
+
close() {
|
|
42
|
+
for (const fn of listeners.close ?? []) fn({ code: 1000, reason: "" });
|
|
43
|
+
},
|
|
44
|
+
addEventListener(type: string, fn: Listener) {
|
|
45
|
+
(listeners[type] ?? []).push(fn);
|
|
46
|
+
},
|
|
47
|
+
} as OpenaiRealtimeWebSocket;
|
|
48
|
+
return Object.assign(ws, {
|
|
49
|
+
fire(type: "open" | "message" | "close" | "error", ev?: unknown) {
|
|
50
|
+
for (const fn of listeners[type] ?? []) fn(ev);
|
|
51
|
+
},
|
|
52
|
+
sent,
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function startedTransport() {
|
|
57
|
+
const fake = makeFakeWs();
|
|
58
|
+
const cbs = noopCallbacks();
|
|
59
|
+
const transport = createOpenaiRealtimeTransport({
|
|
60
|
+
apiKey: "sk",
|
|
61
|
+
options: {},
|
|
62
|
+
sessionConfig: { systemPrompt: "" },
|
|
63
|
+
toolSchemas: [],
|
|
64
|
+
toolChoice: "auto",
|
|
65
|
+
callbacks: cbs,
|
|
66
|
+
sid: "s",
|
|
67
|
+
agent: "a",
|
|
68
|
+
createWebSocket: () => fake,
|
|
69
|
+
logger: silentLogger,
|
|
70
|
+
});
|
|
71
|
+
const ready = transport.start();
|
|
72
|
+
fake.fire("open");
|
|
73
|
+
return { fake, cbs, transport, ready };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
describe("openai-realtime-transport: connect and session.update", () => {
|
|
77
|
+
test("opens WS with auth headers and sends session.update on open", async () => {
|
|
78
|
+
const fake = makeFakeWs();
|
|
79
|
+
const createWs = vi.fn(() => fake);
|
|
80
|
+
|
|
81
|
+
const transport = createOpenaiRealtimeTransport({
|
|
82
|
+
apiKey: "sk-test",
|
|
83
|
+
options: { model: "gpt-realtime", voice: "cedar" },
|
|
84
|
+
sessionConfig: {
|
|
85
|
+
systemPrompt: "Be terse.",
|
|
86
|
+
tools: [],
|
|
87
|
+
},
|
|
88
|
+
toolSchemas: [
|
|
89
|
+
{
|
|
90
|
+
type: "function",
|
|
91
|
+
name: "lookup",
|
|
92
|
+
description: "look up something",
|
|
93
|
+
parameters: { type: "object", properties: {} },
|
|
94
|
+
},
|
|
95
|
+
],
|
|
96
|
+
toolChoice: "auto",
|
|
97
|
+
callbacks: noopCallbacks(),
|
|
98
|
+
sid: "sid-1",
|
|
99
|
+
agent: "test-agent",
|
|
100
|
+
createWebSocket: createWs,
|
|
101
|
+
logger: silentLogger,
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
const startP = transport.start();
|
|
105
|
+
fake.fire("open");
|
|
106
|
+
await startP;
|
|
107
|
+
|
|
108
|
+
expect(createWs).toHaveBeenCalledWith(
|
|
109
|
+
"wss://api.openai.com/v1/realtime?model=gpt-realtime",
|
|
110
|
+
expect.objectContaining({
|
|
111
|
+
headers: { Authorization: "Bearer sk-test" },
|
|
112
|
+
}),
|
|
113
|
+
);
|
|
114
|
+
|
|
115
|
+
expect(fake.sent.length).toBe(1);
|
|
116
|
+
const first = fake.sent[0];
|
|
117
|
+
if (first === undefined) throw new Error("expected one send");
|
|
118
|
+
const msg = JSON.parse(first);
|
|
119
|
+
expect(msg.type).toBe("session.update");
|
|
120
|
+
expect(msg.session.type).toBe("realtime");
|
|
121
|
+
expect(msg.session.output_modalities).toEqual(["audio"]);
|
|
122
|
+
expect(msg.session.instructions).toBe("Be terse.");
|
|
123
|
+
expect(msg.session.audio.input.format).toEqual({ type: "audio/pcm", rate: 24_000 });
|
|
124
|
+
expect(msg.session.audio.input.turn_detection.type).toBe("server_vad");
|
|
125
|
+
expect(msg.session.audio.input.transcription).toEqual({ model: "whisper-1" });
|
|
126
|
+
expect(msg.session.audio.output.format).toEqual({ type: "audio/pcm", rate: 24_000 });
|
|
127
|
+
expect(msg.session.audio.output.voice).toBe("cedar");
|
|
128
|
+
expect(msg.session.tools).toEqual([
|
|
129
|
+
expect.objectContaining({ type: "function", name: "lookup" }),
|
|
130
|
+
]);
|
|
131
|
+
expect(msg.session.tool_choice).toBe("auto");
|
|
132
|
+
});
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
describe("greeting", () => {
|
|
136
|
+
function makeWithGreeting(args: { greeting?: string; skipGreeting?: boolean }) {
|
|
137
|
+
const fake = makeFakeWs();
|
|
138
|
+
const transport = createOpenaiRealtimeTransport({
|
|
139
|
+
apiKey: "sk",
|
|
140
|
+
options: {},
|
|
141
|
+
sessionConfig: {
|
|
142
|
+
systemPrompt: "",
|
|
143
|
+
...(args.greeting !== undefined ? { greeting: args.greeting } : {}),
|
|
144
|
+
},
|
|
145
|
+
toolSchemas: [],
|
|
146
|
+
toolChoice: "auto",
|
|
147
|
+
callbacks: noopCallbacks(),
|
|
148
|
+
sid: "s",
|
|
149
|
+
agent: "a",
|
|
150
|
+
...(args.skipGreeting !== undefined ? { skipGreeting: args.skipGreeting } : {}),
|
|
151
|
+
createWebSocket: () => fake,
|
|
152
|
+
logger: silentLogger,
|
|
153
|
+
});
|
|
154
|
+
const ready = transport.start();
|
|
155
|
+
fake.fire("open");
|
|
156
|
+
return { fake, ready };
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
test("sends response.create with quoted greeting after session.update", async () => {
|
|
160
|
+
const { fake, ready } = makeWithGreeting({ greeting: 'Hello, "friend".' });
|
|
161
|
+
await ready;
|
|
162
|
+
expect(fake.sent.length).toBe(2);
|
|
163
|
+
expect(JSON.parse(fake.sent[0] ?? "{}").type).toBe("session.update");
|
|
164
|
+
const greetingMsg = JSON.parse(fake.sent[1] ?? "{}");
|
|
165
|
+
expect(greetingMsg.type).toBe("response.create");
|
|
166
|
+
// JSON.stringify quotes the greeting and escapes any embedded quotes —
|
|
167
|
+
// protects against prompt-injection by closing the instruction string.
|
|
168
|
+
expect(greetingMsg.response.instructions).toBe('Say exactly: "Hello, \\"friend\\"."');
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
test("no greeting send when greeting is undefined", async () => {
|
|
172
|
+
const { fake, ready } = makeWithGreeting({});
|
|
173
|
+
await ready;
|
|
174
|
+
expect(fake.sent.length).toBe(1);
|
|
175
|
+
expect(JSON.parse(fake.sent[0] ?? "{}").type).toBe("session.update");
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
test("skipGreeting suppresses the greeting send", async () => {
|
|
179
|
+
const { fake, ready } = makeWithGreeting({ greeting: "Hi.", skipGreeting: true });
|
|
180
|
+
await ready;
|
|
181
|
+
expect(fake.sent.length).toBe(1);
|
|
182
|
+
expect(JSON.parse(fake.sent[0] ?? "{}").type).toBe("session.update");
|
|
183
|
+
});
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
describe("audio in/out", () => {
|
|
187
|
+
test("sendUserAudio sends input_audio_buffer.append with base64 payload", async () => {
|
|
188
|
+
const { fake, transport, ready } = startedTransport();
|
|
189
|
+
await ready;
|
|
190
|
+
fake.sent.length = 0;
|
|
191
|
+
transport.sendUserAudio(new Uint8Array([1, 2, 3, 4]));
|
|
192
|
+
expect(fake.sent.length).toBe(1);
|
|
193
|
+
const first = fake.sent[0];
|
|
194
|
+
if (first === undefined) throw new Error("expected one send");
|
|
195
|
+
const msg = JSON.parse(first);
|
|
196
|
+
expect(msg.type).toBe("input_audio_buffer.append");
|
|
197
|
+
expect(typeof msg.audio).toBe("string");
|
|
198
|
+
expect(Buffer.from(msg.audio, "base64")).toEqual(Buffer.from([1, 2, 3, 4]));
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
test.each([
|
|
202
|
+
["response.audio.delta"],
|
|
203
|
+
["response.output_audio.delta"],
|
|
204
|
+
])("%s calls onAudioChunk with decoded bytes", async (type) => {
|
|
205
|
+
const { fake, cbs, ready } = startedTransport();
|
|
206
|
+
await ready;
|
|
207
|
+
const audio = Buffer.from([5, 6, 7, 8]).toString("base64");
|
|
208
|
+
fake.fire("message", { data: JSON.stringify({ type, delta: audio }) });
|
|
209
|
+
expect(cbs.onAudioChunk).toHaveBeenCalledTimes(1);
|
|
210
|
+
expect(cbs.onAudioChunk).toHaveBeenCalledWith(new Uint8Array([5, 6, 7, 8]));
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
test.each([
|
|
214
|
+
["response.audio.done"],
|
|
215
|
+
["response.output_audio.done"],
|
|
216
|
+
])("%s calls onAudioDone", async (type) => {
|
|
217
|
+
const { fake, cbs, ready } = startedTransport();
|
|
218
|
+
await ready;
|
|
219
|
+
fake.fire("message", { data: JSON.stringify({ type }) });
|
|
220
|
+
expect(cbs.onAudioDone).toHaveBeenCalledTimes(1);
|
|
221
|
+
});
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
describe("VAD, user transcript, reply lifecycle, agent transcript", () => {
|
|
225
|
+
test("speech_started/stopped routed to callbacks", async () => {
|
|
226
|
+
const { fake, cbs, ready } = startedTransport();
|
|
227
|
+
await ready;
|
|
228
|
+
fake.fire("message", {
|
|
229
|
+
data: JSON.stringify({ type: "input_audio_buffer.speech_started" }),
|
|
230
|
+
});
|
|
231
|
+
fake.fire("message", {
|
|
232
|
+
data: JSON.stringify({ type: "input_audio_buffer.speech_stopped" }),
|
|
233
|
+
});
|
|
234
|
+
expect(cbs.onSpeechStarted).toHaveBeenCalledTimes(1);
|
|
235
|
+
expect(cbs.onSpeechStopped).toHaveBeenCalledTimes(1);
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
test("user transcription completed routes to onUserTranscript", async () => {
|
|
239
|
+
const { fake, cbs, ready } = startedTransport();
|
|
240
|
+
await ready;
|
|
241
|
+
fake.fire("message", {
|
|
242
|
+
data: JSON.stringify({
|
|
243
|
+
type: "conversation.item.input_audio_transcription.completed",
|
|
244
|
+
transcript: "hello world",
|
|
245
|
+
}),
|
|
246
|
+
});
|
|
247
|
+
expect(cbs.onUserTranscript).toHaveBeenCalledWith("hello world");
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
test("response.created → onReplyStarted; response.done → onReplyDone", async () => {
|
|
251
|
+
const { fake, cbs, ready } = startedTransport();
|
|
252
|
+
await ready;
|
|
253
|
+
fake.fire("message", {
|
|
254
|
+
data: JSON.stringify({ type: "response.created", response: { id: "resp_1" } }),
|
|
255
|
+
});
|
|
256
|
+
expect(cbs.onReplyStarted).toHaveBeenCalledWith("resp_1");
|
|
257
|
+
fake.fire("message", { data: JSON.stringify({ type: "response.done" }) });
|
|
258
|
+
expect(cbs.onReplyDone).toHaveBeenCalledTimes(1);
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
test.each([
|
|
262
|
+
["response.audio_transcript", "legacy"],
|
|
263
|
+
["response.output_audio_transcript", "GA"],
|
|
264
|
+
])("agent transcript (%s): deltas accumulated, emitted on done", async (prefix) => {
|
|
265
|
+
const { fake, cbs, ready } = startedTransport();
|
|
266
|
+
await ready;
|
|
267
|
+
const item_id = "item_x";
|
|
268
|
+
fake.fire("message", {
|
|
269
|
+
data: JSON.stringify({ type: `${prefix}.delta`, item_id, delta: "Hi " }),
|
|
270
|
+
});
|
|
271
|
+
fake.fire("message", {
|
|
272
|
+
data: JSON.stringify({ type: `${prefix}.delta`, item_id, delta: "there." }),
|
|
273
|
+
});
|
|
274
|
+
expect(cbs.onAgentTranscript).not.toHaveBeenCalled();
|
|
275
|
+
fake.fire("message", {
|
|
276
|
+
data: JSON.stringify({ type: `${prefix}.done`, item_id }),
|
|
277
|
+
});
|
|
278
|
+
expect(cbs.onAgentTranscript).toHaveBeenCalledWith("Hi there.", false);
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
test("agent transcript: done with no buffered deltas does not emit", async () => {
|
|
282
|
+
const { fake, cbs, ready } = startedTransport();
|
|
283
|
+
await ready;
|
|
284
|
+
fake.fire("message", {
|
|
285
|
+
data: JSON.stringify({
|
|
286
|
+
type: "response.audio_transcript.done",
|
|
287
|
+
item_id: "empty",
|
|
288
|
+
}),
|
|
289
|
+
});
|
|
290
|
+
expect(cbs.onAgentTranscript).not.toHaveBeenCalled();
|
|
291
|
+
});
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
describe("tool calls", () => {
|
|
295
|
+
test("function_call_arguments deltas accumulate; .done emits onToolCall", async () => {
|
|
296
|
+
const { fake, cbs, ready } = startedTransport();
|
|
297
|
+
await ready;
|
|
298
|
+
const item_id = "item_t";
|
|
299
|
+
fake.fire("message", {
|
|
300
|
+
data: JSON.stringify({
|
|
301
|
+
type: "response.output_item.added",
|
|
302
|
+
item: { id: item_id, type: "function_call", name: "lookup", call_id: "call_1" },
|
|
303
|
+
}),
|
|
304
|
+
});
|
|
305
|
+
fake.fire("message", {
|
|
306
|
+
data: JSON.stringify({
|
|
307
|
+
type: "response.function_call_arguments.delta",
|
|
308
|
+
item_id,
|
|
309
|
+
delta: '{"q":',
|
|
310
|
+
}),
|
|
311
|
+
});
|
|
312
|
+
fake.fire("message", {
|
|
313
|
+
data: JSON.stringify({
|
|
314
|
+
type: "response.function_call_arguments.delta",
|
|
315
|
+
item_id,
|
|
316
|
+
delta: '"hi"}',
|
|
317
|
+
}),
|
|
318
|
+
});
|
|
319
|
+
fake.fire("message", {
|
|
320
|
+
data: JSON.stringify({
|
|
321
|
+
type: "response.function_call_arguments.done",
|
|
322
|
+
item_id,
|
|
323
|
+
call_id: "call_1",
|
|
324
|
+
name: "lookup",
|
|
325
|
+
arguments: '{"q":"hi"}',
|
|
326
|
+
}),
|
|
327
|
+
});
|
|
328
|
+
expect(cbs.onToolCall).toHaveBeenCalledWith("call_1", "lookup", { q: "hi" });
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
test("done with empty/invalid args still calls onToolCall with {}", async () => {
|
|
332
|
+
const { fake, cbs, ready } = startedTransport();
|
|
333
|
+
await ready;
|
|
334
|
+
const item_id = "item_e";
|
|
335
|
+
fake.fire("message", {
|
|
336
|
+
data: JSON.stringify({
|
|
337
|
+
type: "response.output_item.added",
|
|
338
|
+
item: { id: item_id, type: "function_call", name: "noop", call_id: "call_e" },
|
|
339
|
+
}),
|
|
340
|
+
});
|
|
341
|
+
fake.fire("message", {
|
|
342
|
+
data: JSON.stringify({
|
|
343
|
+
type: "response.function_call_arguments.done",
|
|
344
|
+
item_id,
|
|
345
|
+
call_id: "call_e",
|
|
346
|
+
name: "noop",
|
|
347
|
+
arguments: "",
|
|
348
|
+
}),
|
|
349
|
+
});
|
|
350
|
+
expect(cbs.onToolCall).toHaveBeenCalledWith("call_e", "noop", {});
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
test("sendToolResult sends conversation.item.create + response.create", async () => {
|
|
354
|
+
const { fake, transport, ready } = startedTransport();
|
|
355
|
+
await ready;
|
|
356
|
+
fake.sent.length = 0; // drop session.update
|
|
357
|
+
transport.sendToolResult("call_1", '{"ok":true}');
|
|
358
|
+
// function_call_output is sent immediately; response.create is queued.
|
|
359
|
+
expect(fake.sent.length).toBe(1);
|
|
360
|
+
const m1 = JSON.parse(fake.sent[0] ?? "{}");
|
|
361
|
+
expect(m1.type).toBe("conversation.item.create");
|
|
362
|
+
expect(m1.item.type).toBe("function_call_output");
|
|
363
|
+
expect(m1.item.call_id).toBe("call_1");
|
|
364
|
+
expect(m1.item.output).toBe('{"ok":true}');
|
|
365
|
+
await new Promise((r) => queueMicrotask(() => r(undefined)));
|
|
366
|
+
expect(fake.sent.length).toBe(2);
|
|
367
|
+
const m2 = JSON.parse(fake.sent[1] ?? "{}");
|
|
368
|
+
expect(m2.type).toBe("response.create");
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
test("multiple sendToolResult calls coalesce into a single response.create", async () => {
|
|
372
|
+
const { fake, transport, ready } = startedTransport();
|
|
373
|
+
await ready;
|
|
374
|
+
fake.sent.length = 0;
|
|
375
|
+
// Synchronous burst — session-core flushes pending tool results in a loop.
|
|
376
|
+
transport.sendToolResult("call_1", '{"a":1}');
|
|
377
|
+
transport.sendToolResult("call_2", '{"b":2}');
|
|
378
|
+
transport.sendToolResult("call_3", '{"c":3}');
|
|
379
|
+
// Three function_call_outputs sent immediately, no response.create yet.
|
|
380
|
+
expect(fake.sent.length).toBe(3);
|
|
381
|
+
expect(fake.sent.every((s) => JSON.parse(s).type === "conversation.item.create")).toBe(true);
|
|
382
|
+
await new Promise((r) => queueMicrotask(() => r(undefined)));
|
|
383
|
+
// After the microtask, exactly one response.create — second one would be
|
|
384
|
+
// rejected as `conversation_already_has_active_response`.
|
|
385
|
+
expect(fake.sent.length).toBe(4);
|
|
386
|
+
expect(JSON.parse(fake.sent[3] ?? "{}").type).toBe("response.create");
|
|
387
|
+
});
|
|
388
|
+
});
|
|
389
|
+
|
|
390
|
+
describe("cancel, error, close", () => {
|
|
391
|
+
test("cancelReply sends response.cancel only when a reply is in flight", async () => {
|
|
392
|
+
const { fake, transport, ready } = startedTransport();
|
|
393
|
+
await ready;
|
|
394
|
+
fake.sent.length = 0;
|
|
395
|
+
// No reply yet — cancel should be a no-op
|
|
396
|
+
transport.cancelReply();
|
|
397
|
+
expect(fake.sent.length).toBe(0);
|
|
398
|
+
|
|
399
|
+
fake.fire("message", {
|
|
400
|
+
data: JSON.stringify({ type: "response.created", response: { id: "r1" } }),
|
|
401
|
+
});
|
|
402
|
+
transport.cancelReply();
|
|
403
|
+
expect(fake.sent.length).toBe(1);
|
|
404
|
+
expect(JSON.parse(fake.sent[0] ?? "{}").type).toBe("response.cancel");
|
|
405
|
+
});
|
|
406
|
+
|
|
407
|
+
test("cancelReply also fires onCancelled", async () => {
|
|
408
|
+
const { fake, cbs, transport, ready } = startedTransport();
|
|
409
|
+
await ready;
|
|
410
|
+
fake.fire("message", {
|
|
411
|
+
data: JSON.stringify({ type: "response.created", response: { id: "r2" } }),
|
|
412
|
+
});
|
|
413
|
+
transport.cancelReply();
|
|
414
|
+
expect(cbs.onCancelled).toHaveBeenCalledTimes(1);
|
|
415
|
+
});
|
|
416
|
+
|
|
417
|
+
test("error event routes to onError with internal code", async () => {
|
|
418
|
+
const { fake, cbs, ready } = startedTransport();
|
|
419
|
+
await ready;
|
|
420
|
+
fake.fire("message", {
|
|
421
|
+
data: JSON.stringify({ type: "error", error: { message: "boom" } }),
|
|
422
|
+
});
|
|
423
|
+
expect(cbs.onError).toHaveBeenCalledWith("internal", "boom");
|
|
424
|
+
});
|
|
425
|
+
|
|
426
|
+
test("error event with missing message uses fallback", async () => {
|
|
427
|
+
const { fake, cbs, ready } = startedTransport();
|
|
428
|
+
await ready;
|
|
429
|
+
fake.fire("message", { data: JSON.stringify({ type: "error" }) });
|
|
430
|
+
expect(cbs.onError).toHaveBeenCalledWith("internal", expect.any(String));
|
|
431
|
+
});
|
|
432
|
+
|
|
433
|
+
test("unexpected close routes to onError with connection code", async () => {
|
|
434
|
+
const { fake, cbs, ready } = startedTransport();
|
|
435
|
+
await ready;
|
|
436
|
+
fake.fire("close", { code: 1006, reason: "" });
|
|
437
|
+
expect(cbs.onError).toHaveBeenCalledWith("connection", expect.stringMatching(/closed/i));
|
|
438
|
+
});
|
|
439
|
+
});
|