@alexkroman1/aai 0.12.3 → 1.0.2

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.
Files changed (135) hide show
  1. package/.turbo/turbo-build.log +20 -0
  2. package/CHANGELOG.md +174 -0
  3. package/dist/constants-VTFoymJ-.js +47 -0
  4. package/dist/host/_run-code.d.ts +1 -1
  5. package/dist/host/_runtime-conformance.d.ts +4 -5
  6. package/dist/host/builtin-tools.d.ts +11 -9
  7. package/dist/host/runtime-barrel.d.ts +15 -0
  8. package/dist/{direct-executor-DRRrZUp0.js → host/runtime-barrel.js} +453 -348
  9. package/dist/host/runtime-config.d.ts +42 -0
  10. package/dist/host/runtime.d.ts +119 -35
  11. package/dist/host/s2s.d.ts +14 -38
  12. package/dist/host/server.d.ts +16 -8
  13. package/dist/host/session-ctx.d.ts +55 -0
  14. package/dist/host/session.d.ts +20 -70
  15. package/dist/host/tool-executor.d.ts +20 -0
  16. package/dist/host/unstorage-kv.d.ts +1 -1
  17. package/dist/host/ws-handler.d.ts +4 -2
  18. package/dist/index.d.ts +9 -20
  19. package/dist/index.js +63 -2
  20. package/dist/{isolate → sdk}/_internal-types.d.ts +5 -9
  21. package/dist/{isolate → sdk}/constants.d.ts +6 -4
  22. package/dist/sdk/define.d.ts +66 -0
  23. package/dist/{isolate → sdk}/kv.d.ts +1 -49
  24. package/dist/sdk/manifest-barrel.d.ts +8 -0
  25. package/dist/sdk/manifest-barrel.js +52 -0
  26. package/dist/sdk/manifest.d.ts +50 -0
  27. package/dist/{isolate → sdk}/protocol.d.ts +59 -36
  28. package/dist/sdk/protocol.js +163 -0
  29. package/dist/{isolate → sdk}/system-prompt.d.ts +2 -2
  30. package/dist/sdk/types.d.ts +201 -0
  31. package/dist/sdk/ws-upgrade.d.ts +5 -0
  32. package/dist/{system-prompt-DYAYFW99.js → system-prompt-nik_iavo.js} +10 -10
  33. package/dist/types-Cfx_4QDK.js +39 -0
  34. package/dist/ws-upgrade-BeOQ7fXL.js +30 -0
  35. package/exports-no-dev-deps.test.ts +62 -0
  36. package/host/_mock-ws.ts +185 -0
  37. package/host/_run-code.ts +217 -0
  38. package/host/_runtime-conformance.ts +143 -0
  39. package/host/_test-utils.ts +276 -0
  40. package/host/builtin-tools.test.ts +774 -0
  41. package/host/builtin-tools.ts +255 -0
  42. package/host/cleanup.test.ts +422 -0
  43. package/host/fixture-replay.test.ts +463 -0
  44. package/host/fixtures/README.md +40 -0
  45. package/host/fixtures/greeting-session-sequence.json +40 -0
  46. package/host/fixtures/reply-audio-samples.json +42 -0
  47. package/host/fixtures/reply-lifecycle.json +21 -0
  48. package/host/fixtures/session-ready.json +48 -0
  49. package/host/fixtures/session-updated.json +45 -0
  50. package/host/fixtures/simple-question-sequence.json +73 -0
  51. package/host/fixtures/tool-call-sequence.json +114 -0
  52. package/host/fixtures/tool-calls.json +11 -0
  53. package/host/fixtures/tool-config-session-sequence.json +51 -0
  54. package/host/fixtures/user-speech-recognition.json +30 -0
  55. package/host/fixtures/web-search-sequence.json +122 -0
  56. package/host/integration.test.ts +222 -0
  57. package/host/runtime-barrel.ts +25 -0
  58. package/host/runtime-config.test.ts +71 -0
  59. package/host/runtime-config.ts +99 -0
  60. package/host/runtime.test.ts +641 -0
  61. package/host/runtime.ts +308 -0
  62. package/host/s2s-fixtures.test.ts +237 -0
  63. package/host/s2s.test.ts +562 -0
  64. package/host/s2s.ts +310 -0
  65. package/host/server-shutdown.test.ts +76 -0
  66. package/host/server.test.ts +116 -0
  67. package/host/server.ts +223 -0
  68. package/host/session-ctx.ts +107 -0
  69. package/host/session-fixture-replay.test.ts +136 -0
  70. package/host/session-prompt.test.ts +77 -0
  71. package/host/session.test.ts +590 -0
  72. package/host/session.ts +370 -0
  73. package/host/tool-executor.test.ts +124 -0
  74. package/host/tool-executor.ts +80 -0
  75. package/host/unstorage-kv.test.ts +99 -0
  76. package/host/unstorage-kv.ts +69 -0
  77. package/host/ws-handler.test.ts +739 -0
  78. package/host/ws-handler.ts +255 -0
  79. package/index.ts +16 -0
  80. package/package.json +24 -72
  81. package/sdk/_internal-types.test.ts +34 -0
  82. package/sdk/_internal-types.ts +115 -0
  83. package/sdk/compat-fixtures/README.md +26 -0
  84. package/sdk/compat-fixtures/v1.json +68 -0
  85. package/sdk/constants.ts +77 -0
  86. package/sdk/define.test.ts +57 -0
  87. package/sdk/define.ts +88 -0
  88. package/sdk/kv.ts +60 -0
  89. package/sdk/manifest-barrel.ts +12 -0
  90. package/sdk/manifest.test.ts +56 -0
  91. package/sdk/manifest.ts +89 -0
  92. package/sdk/protocol-compat.test.ts +187 -0
  93. package/sdk/protocol-snapshot.test.ts +199 -0
  94. package/sdk/protocol.test.ts +170 -0
  95. package/sdk/protocol.ts +223 -0
  96. package/sdk/schema-alignment.test.ts +191 -0
  97. package/sdk/system-prompt.test.ts +111 -0
  98. package/sdk/system-prompt.ts +74 -0
  99. package/sdk/tsconfig.json +12 -0
  100. package/sdk/types-inference.test.ts +122 -0
  101. package/sdk/types.test.ts +14 -0
  102. package/sdk/types.ts +226 -0
  103. package/sdk/utils.test.ts +52 -0
  104. package/sdk/utils.ts +20 -0
  105. package/sdk/ws-upgrade.test.ts +48 -0
  106. package/sdk/ws-upgrade.ts +13 -0
  107. package/tsconfig.build.json +14 -0
  108. package/tsconfig.json +10 -0
  109. package/tsdown.config.ts +26 -0
  110. package/vitest.config.ts +17 -0
  111. package/dist/host/_test-utils.d.ts +0 -73
  112. package/dist/host/direct-executor.d.ts +0 -130
  113. package/dist/host/index.d.ts +0 -19
  114. package/dist/host/index.js +0 -165
  115. package/dist/host/matchers.d.ts +0 -20
  116. package/dist/host/matchers.js +0 -41
  117. package/dist/host/server.js +0 -164
  118. package/dist/host/testing.d.ts +0 -294
  119. package/dist/host/testing.js +0 -2
  120. package/dist/host/vite-plugin.d.ts +0 -15
  121. package/dist/host/vite-plugin.js +0 -83
  122. package/dist/isolate/_kv-utils.d.ts +0 -10
  123. package/dist/isolate/_utils.js +0 -17
  124. package/dist/isolate/hooks.d.ts +0 -44
  125. package/dist/isolate/hooks.js +0 -58
  126. package/dist/isolate/index.d.ts +0 -18
  127. package/dist/isolate/index.js +0 -6
  128. package/dist/isolate/kv.js +0 -1
  129. package/dist/isolate/protocol.js +0 -2
  130. package/dist/isolate/types.d.ts +0 -418
  131. package/dist/isolate/types.js +0 -175
  132. package/dist/protocol-rcOrz7T3.js +0 -183
  133. package/dist/testing-BreLdpq-.js +0 -513
  134. package/dist/types.test-d.d.ts +0 -7
  135. /package/dist/{isolate/_utils.d.ts → sdk/utils.d.ts} +0 -0
@@ -0,0 +1,255 @@
1
+ // Copyright 2025 the AAI authors. MIT license.
2
+ /**
3
+ * Built-in tool definitions for the AAI agent SDK.
4
+ *
5
+ * In self-hosted mode, these run in-process alongside custom tools.
6
+ * In platform mode, they run on the host process outside the sandbox.
7
+ * Network requests go through the host's fetch proxy (with SSRF protection).
8
+ */
9
+
10
+ import { convert } from "html-to-text";
11
+ import { z } from "zod";
12
+ import { EMPTY_PARAMS, type ToolSchema } from "../sdk/_internal-types.ts";
13
+ import { FETCH_TIMEOUT_MS, MAX_HTML_BYTES, MAX_PAGE_CHARS } from "../sdk/constants.ts";
14
+ import type { ToolDef } from "../sdk/types.ts";
15
+ import { createRunCode } from "./_run-code.ts";
16
+
17
+ export { executeInIsolate } from "./_run-code.ts";
18
+
19
+ const fetchSignal = () => AbortSignal.timeout(FETCH_TIMEOUT_MS);
20
+
21
+ const htmlToText = (html: string): string => convert(html, { wordwrap: false });
22
+
23
+ // ─── web_search ────────────────────────────────────────────────────────────
24
+
25
+ const webSearchParams = z.object({
26
+ query: z.string().describe("The search query"),
27
+ max_results: z.number().describe("Maximum number of results to return (default 5)").optional(),
28
+ });
29
+
30
+ const BRAVE_SEARCH_URL = "https://api.search.brave.com/res/v1/web/search";
31
+
32
+ const BraveSearchResponseSchema = z.object({
33
+ web: z
34
+ .object({
35
+ results: z.array(
36
+ z.object({
37
+ title: z.string(),
38
+ url: z.string(),
39
+ description: z.string(),
40
+ }),
41
+ ),
42
+ })
43
+ .optional(),
44
+ });
45
+
46
+ function createWebSearch(
47
+ fetchFn = globalThis.fetch,
48
+ ): ToolDef<typeof webSearchParams> & { guidance: string } {
49
+ return {
50
+ guidance:
51
+ "Use web_search for factual questions, current events, or anything you are unsure about. " +
52
+ "Search first rather than guessing.",
53
+ description:
54
+ "Search the web for current information, facts, news, or answers to questions. Returns a list of results with title, URL, and description. Use this when the user asks about something you don't know, need up-to-date information, or want to verify facts.",
55
+ parameters: webSearchParams,
56
+ async execute(args, ctx) {
57
+ const { query, max_results: maxResults = 5 } = args;
58
+ const apiKey = ctx.env.BRAVE_API_KEY ?? "";
59
+ if (!apiKey) {
60
+ return { error: "BRAVE_API_KEY is not set — web search unavailable" };
61
+ }
62
+ const url = `${BRAVE_SEARCH_URL}?${new URLSearchParams({
63
+ q: query,
64
+ count: String(maxResults),
65
+ text_decorations: "false",
66
+ })}`;
67
+ const resp = await fetchFn(url, {
68
+ headers: { "X-Subscription-Token": apiKey },
69
+ signal: fetchSignal(),
70
+ });
71
+ if (!resp.ok) {
72
+ return { error: `Search request failed: ${resp.status} ${resp.statusText}` };
73
+ }
74
+ const raw = await resp.json();
75
+ const data = BraveSearchResponseSchema.safeParse(raw);
76
+ if (!data.success) {
77
+ return { error: "Unexpected search response format" };
78
+ }
79
+ return (data.data.web?.results ?? []).slice(0, maxResults).map((r) => ({
80
+ title: r.title,
81
+ url: r.url,
82
+ description: r.description,
83
+ }));
84
+ },
85
+ };
86
+ }
87
+
88
+ // ─── visit_webpage ─────────────────────────────────────────────────────────
89
+
90
+ const visitWebpageParams = z.object({
91
+ url: z.string().describe("The full URL to fetch (e.g., 'https://example.com/page')"),
92
+ });
93
+
94
+ function createVisitWebpage(
95
+ fetchFn = globalThis.fetch,
96
+ ): ToolDef<typeof visitWebpageParams> & { guidance: string } {
97
+ return {
98
+ guidance:
99
+ "Use visit_webpage to read the full content of a URL when search snippets are not detailed enough.",
100
+ description:
101
+ "Fetch a webpage and return its content as clean text. Use this to read the full content of a URL found via web_search, or any link the user shares. Good for reading articles, documentation, blog posts, or product pages.",
102
+ parameters: visitWebpageParams,
103
+ async execute(args, _ctx) {
104
+ const { url } = args;
105
+ const resp = await fetchFn(url, {
106
+ headers: {
107
+ "User-Agent":
108
+ "Mozilla/5.0 (compatible; VoiceAgent/1.0; +https://github.com/AssemblyAI/aai)",
109
+ Accept: "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
110
+ },
111
+ signal: fetchSignal(),
112
+ });
113
+ if (!resp.ok) {
114
+ return { error: `Failed to fetch: ${resp.status} ${resp.statusText}`, url };
115
+ }
116
+ const htmlContent = await resp.text();
117
+ const trimmedHtml =
118
+ htmlContent.length > MAX_HTML_BYTES ? htmlContent.slice(0, MAX_HTML_BYTES) : htmlContent;
119
+ const text = htmlToText(trimmedHtml);
120
+ const truncated = text.length > MAX_PAGE_CHARS;
121
+ const content = truncated ? text.slice(0, MAX_PAGE_CHARS) : text;
122
+ return {
123
+ url,
124
+ content,
125
+ ...(truncated ? { truncated: true, totalChars: text.length } : {}),
126
+ };
127
+ },
128
+ };
129
+ }
130
+
131
+ // ─── fetch_json ────────────────────────────────────────────────────────────
132
+
133
+ const fetchJsonParams = z.object({
134
+ url: z.string().describe("The URL to fetch JSON from"),
135
+ headers: z
136
+ .record(z.string(), z.string())
137
+ .describe(
138
+ "Optional HTTP headers to include in the request (only safe headers like Accept, Content-Type are allowed)",
139
+ )
140
+ .optional(),
141
+ });
142
+
143
+ /** Headers the LLM must never control — could exfiltrate credentials or manipulate routing. */
144
+ const BLOCKED_FETCH_HEADERS = new Set([
145
+ "authorization",
146
+ "cookie",
147
+ "set-cookie",
148
+ "host",
149
+ "proxy-authorization",
150
+ "x-forwarded-for",
151
+ "x-forwarded-host",
152
+ "x-forwarded-proto",
153
+ "x-real-ip",
154
+ "cf-connecting-ip",
155
+ "fly-client-ip",
156
+ ]);
157
+
158
+ function sanitizeHeaders(
159
+ raw: Record<string, string> | undefined,
160
+ ): Record<string, string> | undefined {
161
+ if (!raw) return;
162
+ const safe: Record<string, string> = {};
163
+ for (const [key, value] of Object.entries(raw)) {
164
+ if (!BLOCKED_FETCH_HEADERS.has(key.toLowerCase())) safe[key] = value;
165
+ }
166
+ return Object.keys(safe).length > 0 ? safe : undefined;
167
+ }
168
+
169
+ function createFetchJson(
170
+ fetchFn = globalThis.fetch,
171
+ ): ToolDef<typeof fetchJsonParams> & { guidance: string } {
172
+ return {
173
+ guidance: "Use fetch_json to call REST APIs and retrieve structured JSON data.",
174
+ description:
175
+ "Call a REST API endpoint via HTTP GET and return the JSON response. Use this to fetch structured data from APIs — for example, weather data, stock prices, exchange rates, or any public JSON API. Supports custom headers for authenticated APIs.",
176
+ parameters: fetchJsonParams,
177
+ async execute(args, _ctx) {
178
+ const { url, headers } = args;
179
+ const safeHeaders = sanitizeHeaders(headers);
180
+ const resp = await fetchFn(url, {
181
+ ...(safeHeaders && { headers: safeHeaders }),
182
+ signal: fetchSignal(),
183
+ });
184
+ if (!resp.ok) {
185
+ return { error: `HTTP ${resp.status} ${resp.statusText}`, url };
186
+ }
187
+ try {
188
+ return await resp.json();
189
+ } catch {
190
+ return { error: "Response was not valid JSON", url };
191
+ }
192
+ },
193
+ };
194
+ }
195
+
196
+ // ─── Public API ────────────────────────────────────────────────────────────
197
+
198
+ /** Options for creating built-in tool definitions. */
199
+ export type BuiltinToolOptions = {
200
+ /** Override fetch implementation (defaults to globalThis.fetch). For testing. */
201
+ fetch?: typeof globalThis.fetch;
202
+ };
203
+
204
+ type ToolDefRecord = Record<string, ToolDef<z.ZodObject<z.ZodRawShape>>>;
205
+
206
+ /** Resolve a builtin name to an array of [toolName, ToolDef] pairs. */
207
+ function resolveBuiltin(name: string, opts?: BuiltinToolOptions): [string, ToolDef][] {
208
+ switch (name) {
209
+ case "web_search":
210
+ return [["web_search", createWebSearch(opts?.fetch)]];
211
+ case "visit_webpage":
212
+ return [["visit_webpage", createVisitWebpage(opts?.fetch)]];
213
+ case "fetch_json":
214
+ return [["fetch_json", createFetchJson(opts?.fetch)]];
215
+ case "run_code":
216
+ return [["run_code", createRunCode()]];
217
+ default:
218
+ return [];
219
+ }
220
+ }
221
+
222
+ /** Resolved builtins with defs, schemas, and guidance computed in a single pass. */
223
+ export type ResolvedBuiltins = {
224
+ defs: ToolDefRecord;
225
+ schemas: ToolSchema[];
226
+ guidance: string[];
227
+ };
228
+
229
+ /**
230
+ * Resolve all builtin tools in one pass, returning defs, schemas, and guidance.
231
+ * Avoids redundant calls to `resolveBuiltin` and `z.toJSONSchema`.
232
+ */
233
+ export function resolveAllBuiltins(
234
+ names: readonly string[],
235
+ opts?: BuiltinToolOptions,
236
+ ): ResolvedBuiltins {
237
+ const defs: ToolDefRecord = {};
238
+ const schemas: ToolSchema[] = [];
239
+ const guidance: string[] = [];
240
+
241
+ for (const name of names) {
242
+ for (const [toolName, def] of resolveBuiltin(name, opts)) {
243
+ defs[toolName] = def;
244
+ schemas.push({
245
+ name: toolName,
246
+ description: def.description,
247
+ parameters: z.toJSONSchema(def.parameters ?? EMPTY_PARAMS) as ToolSchema["parameters"],
248
+ });
249
+ const g = (def as { guidance?: string }).guidance;
250
+ if (g) guidance.push(g);
251
+ }
252
+ }
253
+
254
+ return { defs, schemas, guidance };
255
+ }
@@ -0,0 +1,422 @@
1
+ // Copyright 2025 the AAI authors. MIT license.
2
+ /**
3
+ * Resource cleanup and leak detection tests for server-side components.
4
+ *
5
+ * Verifies that WebSocket connections, S2S handles, timers,
6
+ * message buffers, and hook promises are properly cleaned up on disconnect,
7
+ * error, and reset to prevent memory leaks in long-running processes.
8
+ */
9
+
10
+ import { afterEach, describe, expect, test, vi } from "vitest";
11
+ import { MockWebSocket } from "./_mock-ws.ts";
12
+ import {
13
+ makeClient,
14
+ makeMockHandle,
15
+ makeSessionOpts,
16
+ makeStubSession,
17
+ silentLogger,
18
+ } from "./_test-utils.ts";
19
+ import type { S2sHandle } from "./s2s.ts";
20
+ import type { Session } from "./session.ts";
21
+ import { _internals, createS2sSession, type S2sSessionOptions } from "./session.ts";
22
+ import { wireSessionSocket } from "./ws-handler.ts";
23
+
24
+ const defaultConfig = { audioFormat: "pcm16" as const, sampleRate: 16_000, ttsSampleRate: 24_000 };
25
+
26
+ // ─── wireSessionSocket cleanup tests ─────────────────────────────────────────
27
+
28
+ describe("wireSessionSocket resource cleanup", () => {
29
+ test("session.stop() is called exactly once on normal close", async () => {
30
+ const session = makeStubSession();
31
+ const ws = new MockWebSocket("ws://test");
32
+ ws.readyState = MockWebSocket.OPEN;
33
+
34
+ wireSessionSocket(ws, {
35
+ sessions: new Map(),
36
+ createSession: () => session,
37
+ readyConfig: defaultConfig,
38
+ logger: silentLogger,
39
+ });
40
+
41
+ ws.close();
42
+
43
+ await vi.waitFor(() => {
44
+ expect(session.stop).toHaveBeenCalledOnce();
45
+ });
46
+ });
47
+
48
+ test("session is removed from sessions map even when stop() rejects", async () => {
49
+ const sessions = new Map<string, Session>();
50
+ const session = makeStubSession();
51
+ session.stop = vi.fn(() => Promise.reject(new Error("stop failed")));
52
+
53
+ const ws = new MockWebSocket("ws://test");
54
+ ws.readyState = MockWebSocket.OPEN;
55
+
56
+ wireSessionSocket(ws, {
57
+ sessions,
58
+ createSession: () => session,
59
+ readyConfig: defaultConfig,
60
+ logger: silentLogger,
61
+ });
62
+
63
+ expect(sessions.size).toBe(1);
64
+ ws.close();
65
+
66
+ await vi.waitFor(() => {
67
+ expect(sessions.size).toBe(0);
68
+ });
69
+ });
70
+
71
+ test("message buffer is cleared when start() fails", async () => {
72
+ const session = makeStubSession();
73
+ session.start = vi.fn(() => Promise.reject(new Error("start failed")));
74
+ const sessions = new Map<string, Session>();
75
+
76
+ const ws = new MockWebSocket("ws://test");
77
+ ws.readyState = MockWebSocket.OPEN;
78
+
79
+ wireSessionSocket(ws, {
80
+ sessions,
81
+ createSession: () => session,
82
+ readyConfig: defaultConfig,
83
+ logger: silentLogger,
84
+ });
85
+
86
+ // Send messages while start is failing
87
+ ws.simulateMessage(JSON.stringify({ type: "audio_ready" }));
88
+
89
+ await vi.waitFor(() => {
90
+ expect(sessions.size).toBe(0);
91
+ });
92
+
93
+ // Session is null, further messages should be silently ignored (no throw)
94
+ ws.simulateMessage(JSON.stringify({ type: "audio_ready" }));
95
+ ws.simulateMessage(new ArrayBuffer(4));
96
+ });
97
+
98
+ test("multiple rapid closes don't double-invoke stop()", async () => {
99
+ const session = makeStubSession();
100
+ session.stop = vi.fn(() => new Promise<void>((r) => setTimeout(r, 50)));
101
+ const sessions = new Map<string, Session>();
102
+
103
+ const ws = new MockWebSocket("ws://test");
104
+ ws.readyState = MockWebSocket.OPEN;
105
+
106
+ wireSessionSocket(ws, {
107
+ sessions,
108
+ createSession: () => session,
109
+ readyConfig: defaultConfig,
110
+ logger: silentLogger,
111
+ });
112
+
113
+ ws.close();
114
+
115
+ // Even if close event fires again, stop should only be called once
116
+ // because the session reference is captured on first close
117
+ await vi.waitFor(() => {
118
+ expect(session.stop).toHaveBeenCalledOnce();
119
+ });
120
+ });
121
+
122
+ test("close before open does not throw or leak", () => {
123
+ const ws = new MockWebSocket("ws://test");
124
+ ws.readyState = MockWebSocket.CONNECTING;
125
+ const sessions = new Map<string, Session>();
126
+
127
+ wireSessionSocket(ws, {
128
+ sessions,
129
+ createSession: () => makeStubSession(),
130
+ readyConfig: defaultConfig,
131
+ logger: silentLogger,
132
+ });
133
+
134
+ // Close before open — session is null, should not throw
135
+ ws.close();
136
+ expect(sessions.size).toBe(0);
137
+ });
138
+
139
+ test("error event after close does not throw", async () => {
140
+ const session = makeStubSession();
141
+ const ws = new MockWebSocket("ws://test");
142
+ ws.readyState = MockWebSocket.OPEN;
143
+
144
+ wireSessionSocket(ws, {
145
+ sessions: new Map(),
146
+ createSession: () => session,
147
+ readyConfig: defaultConfig,
148
+ logger: silentLogger,
149
+ });
150
+
151
+ ws.close();
152
+ await vi.waitFor(() => {
153
+ expect(session.stop).toHaveBeenCalled();
154
+ });
155
+
156
+ // Error after close should not throw
157
+ ws.dispatchEvent(new Event("error"));
158
+ });
159
+ });
160
+
161
+ // ─── createS2sSession cleanup tests ──────────────────────────────────────────
162
+
163
+ describe("createS2sSession resource cleanup", () => {
164
+ let connectSpy: ReturnType<typeof vi.spyOn>;
165
+ let mockHandle: ReturnType<typeof makeMockHandle>;
166
+
167
+ function setup(overrides?: Partial<S2sSessionOptions>) {
168
+ mockHandle = makeMockHandle();
169
+ connectSpy = vi.spyOn(_internals, "connectS2s").mockResolvedValue(mockHandle);
170
+ const client = makeClient();
171
+ const opts = makeSessionOpts({ client, ...overrides });
172
+ const session = createS2sSession(opts);
173
+ return { session, client, opts, mockHandle };
174
+ }
175
+
176
+ afterEach(() => {
177
+ connectSpy?.mockRestore();
178
+ });
179
+
180
+ test("stop() closes S2S handle and waits for in-flight turn", async () => {
181
+ let resolveToolCall!: (value: string) => void;
182
+ const executeTool = vi.fn(
183
+ () =>
184
+ new Promise<string>((r) => {
185
+ resolveToolCall = r;
186
+ }),
187
+ );
188
+ const { session, mockHandle } = setup({ executeTool });
189
+ await session.start();
190
+
191
+ // Start a tool call
192
+ mockHandle._fire("replyStarted", { replyId: "r1" });
193
+ mockHandle._fire("event", { type: "tool_call", toolCallId: "c1", toolName: "t1", args: {} });
194
+ await vi.waitFor(() => expect(executeTool).toHaveBeenCalled());
195
+
196
+ // Stop while tool is in-flight
197
+ const stopPromise = session.stop();
198
+ resolveToolCall("done");
199
+ await stopPromise;
200
+
201
+ expect(mockHandle.close).toHaveBeenCalled();
202
+ });
203
+
204
+ test("onReset clears pendingTools and conversation messages", async () => {
205
+ const executeTool = vi.fn(async () => "result");
206
+ const { session, mockHandle } = setup({ executeTool });
207
+ await session.start();
208
+
209
+ // Accumulate some tool calls
210
+ mockHandle._fire("replyStarted", { replyId: "r1" });
211
+ mockHandle._fire("event", { type: "tool_call", toolCallId: "c1", toolName: "t1", args: {} });
212
+ await session.waitForTurn();
213
+
214
+ // Send a user transcript to add conversation messages
215
+ mockHandle._fire("event", { type: "user_transcript", text: "Hello" });
216
+
217
+ // Reset — should clear pending tools and conversation
218
+ session.onReset();
219
+
220
+ // Verify old handle was closed
221
+ expect(mockHandle.close).toHaveBeenCalled();
222
+ });
223
+
224
+ test("onReset invalidates currentReplyId to discard stale tool results", async () => {
225
+ let resolveToolCall!: (value: string) => void;
226
+ const executeTool = vi.fn(
227
+ () =>
228
+ new Promise<string>((r) => {
229
+ resolveToolCall = r;
230
+ }),
231
+ );
232
+ const handles: ReturnType<typeof makeMockHandle>[] = [];
233
+ const spy = vi.spyOn(_internals, "connectS2s").mockImplementation(async () => {
234
+ const h = makeMockHandle();
235
+ handles.push(h);
236
+ return h;
237
+ });
238
+
239
+ const client = makeClient();
240
+ const session = createS2sSession(makeSessionOpts({ client, executeTool }));
241
+ await session.start();
242
+
243
+ // biome-ignore lint/style/noNonNullAssertion: test assertions after length check
244
+ const firstHandle = handles[0]!;
245
+
246
+ // Start a tool call on the first handle
247
+ firstHandle._fire("replyStarted", { replyId: "r1" });
248
+ firstHandle._fire("event", { type: "tool_call", toolCallId: "c1", toolName: "t1", args: {} });
249
+ await vi.waitFor(() => expect(executeTool).toHaveBeenCalled());
250
+
251
+ // Reset while tool is in-flight
252
+ session.onReset();
253
+
254
+ // Tool finishes late — result should be discarded due to generation mismatch
255
+ resolveToolCall("stale-result");
256
+ await session.waitForTurn();
257
+
258
+ // New handle should not receive the stale result
259
+ const newHandle = handles[1];
260
+ expect(newHandle?.sendToolResult).not.toHaveBeenCalled();
261
+
262
+ spy.mockRestore();
263
+ });
264
+
265
+ test("stop() is safe to call without start()", async () => {
266
+ const client = makeClient();
267
+ const session = createS2sSession(makeSessionOpts({ client }));
268
+ // stop() without start() — should not throw
269
+ await session.stop();
270
+ });
271
+
272
+ test("stop() prevents orphaned S2S connection when called during start()", async () => {
273
+ let resolveConnect!: (value: S2sHandle) => void;
274
+ const handle = makeMockHandle();
275
+ const spy = vi.spyOn(_internals, "connectS2s").mockImplementation(
276
+ () =>
277
+ new Promise((r) => {
278
+ resolveConnect = r as (value: S2sHandle) => void;
279
+ }),
280
+ );
281
+
282
+ const client = makeClient();
283
+ const session = createS2sSession(makeSessionOpts({ client }));
284
+
285
+ const startPromise = session.start();
286
+ const stopPromise = session.stop();
287
+
288
+ // Connection resolves after stop — handle must be closed immediately
289
+ resolveConnect(handle);
290
+ await startPromise;
291
+ await stopPromise;
292
+
293
+ expect(handle.close).toHaveBeenCalled();
294
+ spy.mockRestore();
295
+ });
296
+
297
+ test("S2S error event closes handle and emits error to client", async () => {
298
+ const { session, client, mockHandle } = setup();
299
+ await session.start();
300
+
301
+ mockHandle._fire("error", new Error("S2S crashed"));
302
+
303
+ expect(mockHandle.close).toHaveBeenCalled();
304
+ expect(client.events).toContainEqual({
305
+ type: "error",
306
+ code: "internal",
307
+ message: "S2S crashed",
308
+ });
309
+ });
310
+
311
+ test("S2S close event nullifies the handle reference", async () => {
312
+ const { session, mockHandle } = setup();
313
+ await session.start();
314
+
315
+ // Simulate S2S WebSocket close
316
+ mockHandle._fire("close", 1000, "normal");
317
+
318
+ // Sending audio after close should not throw (no-ops via ?. on null s2s)
319
+ session.onAudio(new Uint8Array([1, 2, 3]));
320
+ });
321
+
322
+ test("sessionExpired event closes the S2S handle", async () => {
323
+ const { session, mockHandle } = setup();
324
+ await session.start();
325
+
326
+ mockHandle._fire("sessionExpired");
327
+ // The handler calls handle.close() directly
328
+ expect(mockHandle.close).toHaveBeenCalled();
329
+ });
330
+
331
+ test("rapid resets close all stale connections", async () => {
332
+ const handles: ReturnType<typeof makeMockHandle>[] = [];
333
+ const resolvers: ((h: S2sHandle) => void)[] = [];
334
+
335
+ const spy = vi.spyOn(_internals, "connectS2s").mockImplementation(
336
+ () =>
337
+ new Promise<S2sHandle>((resolve) => {
338
+ const h = makeMockHandle();
339
+ handles.push(h);
340
+ resolvers.push(resolve as (value: S2sHandle) => void);
341
+ }),
342
+ );
343
+
344
+ const client = makeClient();
345
+ const session = createS2sSession(makeSessionOpts({ client }));
346
+
347
+ const startPromise = session.start();
348
+ session.onReset();
349
+ session.onReset();
350
+
351
+ expect(resolvers.length).toBe(3);
352
+
353
+ // Resolve in order — first two are stale
354
+ // biome-ignore lint/style/noNonNullAssertion: test assertions after length check
355
+ resolvers[0]?.(handles[0]!);
356
+ // biome-ignore lint/style/noNonNullAssertion: test assertions after length check
357
+ resolvers[1]?.(handles[1]!);
358
+ // biome-ignore lint/style/noNonNullAssertion: test assertions after length check
359
+ resolvers[2]?.(handles[2]!);
360
+
361
+ await startPromise;
362
+
363
+ await vi.waitFor(() => {
364
+ expect(handles[0]?.close).toHaveBeenCalled();
365
+ expect(handles[1]?.close).toHaveBeenCalled();
366
+ });
367
+ expect(handles[2]?.close).not.toHaveBeenCalled();
368
+
369
+ spy.mockRestore();
370
+ });
371
+
372
+ test("concurrent tool calls all complete before stop() resolves", async () => {
373
+ const resolvers: ((value: string) => void)[] = [];
374
+ const executeTool = vi.fn(
375
+ () =>
376
+ new Promise<string>((r) => {
377
+ resolvers.push(r);
378
+ }),
379
+ );
380
+ const { session, mockHandle } = setup({ executeTool });
381
+ await session.start();
382
+
383
+ mockHandle._fire("replyStarted", { replyId: "r1" });
384
+ mockHandle._fire("event", { type: "tool_call", toolCallId: "c1", toolName: "t1", args: {} });
385
+ mockHandle._fire("event", { type: "tool_call", toolCallId: "c2", toolName: "t2", args: {} });
386
+
387
+ await vi.waitFor(() => expect(executeTool).toHaveBeenCalledTimes(2));
388
+
389
+ // Stop while both tools are in-flight
390
+ const stopPromise = session.stop();
391
+
392
+ // Resolve both tools
393
+ resolvers[0]?.("result-1");
394
+ resolvers[1]?.("result-2");
395
+
396
+ await stopPromise;
397
+ // If we get here, turnPromise was properly awaited
398
+ expect(mockHandle.close).toHaveBeenCalled();
399
+ });
400
+
401
+ test("connectS2s failure does not leak resources", async () => {
402
+ const spy = vi.spyOn(_internals, "connectS2s").mockRejectedValue(new Error("network error"));
403
+ const client = makeClient();
404
+ const session = createS2sSession(makeSessionOpts({ client }));
405
+
406
+ await session.start();
407
+
408
+ // Client should get error event
409
+ expect(client.events).toContainEqual(
410
+ expect.objectContaining({
411
+ type: "error",
412
+ code: "internal",
413
+ message: "network error",
414
+ }),
415
+ );
416
+
417
+ // stop() should not throw even after failed start
418
+ await session.stop();
419
+
420
+ spy.mockRestore();
421
+ });
422
+ });