@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
package/host/s2s.ts ADDED
@@ -0,0 +1,310 @@
1
+ // Copyright 2025 the AAI authors. MIT license.
2
+ /**
3
+ * Speech-to-Speech WebSocket client for AssemblyAI's S2S API.
4
+ */
5
+
6
+ import type { JSONSchema7 } from "json-schema";
7
+ import { createNanoEvents, type Emitter, type Unsubscribe } from "nanoevents";
8
+ import WsWebSocket from "ws";
9
+ import { z } from "zod";
10
+ import { WS_OPEN } from "../sdk/constants.ts";
11
+ import type { ClientEvent } from "../sdk/protocol.ts";
12
+ import type { Logger, S2SConfig } from "./runtime-config.ts";
13
+ import { consoleLogger } from "./runtime-config.ts";
14
+
15
+ const uint8ToBase64 = (bytes: Uint8Array): string => Buffer.from(bytes).toString("base64");
16
+ const base64ToUint8 = (base64: string): Uint8Array => new Uint8Array(Buffer.from(base64, "base64"));
17
+
18
+ export type S2sWebSocket = {
19
+ readonly readyState: number;
20
+ send(data: string): void;
21
+ close(): void;
22
+ addEventListener(type: "open", listener: () => void): void;
23
+ addEventListener(type: "message", listener: (event: { data: unknown }) => void): void;
24
+ addEventListener(
25
+ type: "close",
26
+ listener: (event: { code?: number; reason?: string }) => void,
27
+ ): void;
28
+ addEventListener(type: "error", listener: (event: { message?: string }) => void): void;
29
+ };
30
+
31
+ export type CreateS2sWebSocket = (
32
+ url: string,
33
+ opts: { headers: Record<string, string> },
34
+ ) => S2sWebSocket;
35
+
36
+ // Node's native WebSocket doesn't support custom headers.
37
+ // Use the `ws` package which accepts { headers } in the constructor.
38
+ export const defaultCreateS2sWebSocket: CreateS2sWebSocket = (url, opts) =>
39
+ new WsWebSocket(url, { headers: opts.headers }) as unknown as S2sWebSocket;
40
+
41
+ // ── Zod schemas for S2S server messages ─────────────────────────────────
42
+
43
+ const S2sMessageSchema = z.discriminatedUnion("type", [
44
+ z.object({ type: z.literal("session.ready"), session_id: z.string() }).passthrough(),
45
+ z.object({ type: z.literal("session.updated") }).passthrough(),
46
+ z.object({ type: z.literal("input.speech.started") }),
47
+ z.object({ type: z.literal("input.speech.stopped") }),
48
+ z.object({ type: z.literal("transcript.user"), item_id: z.string(), text: z.string() }),
49
+ z.object({ type: z.literal("reply.started"), reply_id: z.string() }),
50
+ z.object({
51
+ type: z.literal("transcript.agent"),
52
+ text: z.string(),
53
+ reply_id: z.string().optional().default(""),
54
+ item_id: z.string().optional().default(""),
55
+ interrupted: z.boolean().optional().default(false),
56
+ }),
57
+ z.object({
58
+ type: z.literal("tool.call"),
59
+ call_id: z.string(),
60
+ name: z.string(),
61
+ args: z.record(z.string(), z.unknown()).optional().default({}),
62
+ }),
63
+ z.object({ type: z.literal("reply.done"), status: z.string().optional() }),
64
+ z.object({ type: z.literal("session.error"), code: z.string(), message: z.string() }),
65
+ z.object({ type: z.literal("error"), message: z.string() }),
66
+ ]);
67
+
68
+ type S2sServerMessage = z.infer<typeof S2sMessageSchema>;
69
+
70
+ function parseS2sMessage(obj: Record<string, unknown>): S2sServerMessage | undefined {
71
+ const result = S2sMessageSchema.safeParse(obj);
72
+ return result.success ? result.data : undefined;
73
+ }
74
+
75
+ /**
76
+ * A ClientEvent extended with optional internal metadata for S2S-specific
77
+ * fields that don't appear on the wire protocol (e.g. `interrupted` on
78
+ * `agent_transcript`, which affects conversation history but not the client).
79
+ */
80
+ export type S2sEvent = ClientEvent & { _interrupted?: boolean };
81
+
82
+ function dispatchS2sMessage(emitter: Emitter<S2sEvents>, msg: S2sServerMessage): void {
83
+ switch (msg.type) {
84
+ case "session.ready":
85
+ emitter.emit("ready", { sessionId: msg.session_id });
86
+ break;
87
+ case "session.updated":
88
+ break;
89
+ case "input.speech.started":
90
+ emitter.emit("event", { type: "speech_started" });
91
+ break;
92
+ case "input.speech.stopped":
93
+ emitter.emit("event", { type: "speech_stopped" });
94
+ break;
95
+ case "transcript.user":
96
+ emitter.emit("event", { type: "user_transcript", text: msg.text });
97
+ break;
98
+ case "reply.started":
99
+ emitter.emit("replyStarted", { replyId: msg.reply_id });
100
+ break;
101
+ case "transcript.agent":
102
+ emitter.emit("event", {
103
+ type: "agent_transcript",
104
+ text: msg.text,
105
+ _interrupted: msg.interrupted,
106
+ });
107
+ break;
108
+ case "tool.call":
109
+ emitter.emit("event", {
110
+ type: "tool_call",
111
+ toolCallId: msg.call_id,
112
+ toolName: msg.name,
113
+ args: msg.args,
114
+ });
115
+ break;
116
+ case "reply.done":
117
+ if (msg.status === "interrupted") {
118
+ emitter.emit("event", { type: "cancelled" });
119
+ } else {
120
+ emitter.emit("event", { type: "reply_done" });
121
+ }
122
+ break;
123
+ case "session.error":
124
+ if (msg.code === "session_not_found" || msg.code === "session_forbidden")
125
+ emitter.emit("sessionExpired");
126
+ else emitter.emit("error", new Error(msg.message));
127
+ break;
128
+ case "error":
129
+ emitter.emit("error", new Error(msg.message));
130
+ break;
131
+ default:
132
+ break;
133
+ }
134
+ }
135
+
136
+ export type S2sSessionConfig = {
137
+ systemPrompt: string;
138
+ tools: S2sToolSchema[];
139
+ greeting?: string;
140
+ };
141
+
142
+ export type S2sToolSchema = {
143
+ type: "function";
144
+ name: string;
145
+ description: string;
146
+ parameters: JSONSchema7;
147
+ };
148
+
149
+ export type S2sEvents = {
150
+ ready: (detail: { sessionId: string }) => void;
151
+ replyStarted: (detail: { replyId: string }) => void;
152
+ sessionExpired: () => void;
153
+ event: (event: S2sEvent) => void;
154
+ audio: (detail: { audio: Uint8Array }) => void;
155
+ error: (err: Error) => void;
156
+ close: (code: number, reason: string) => void;
157
+ };
158
+
159
+ export type S2sHandle = {
160
+ on<K extends keyof S2sEvents>(event: K, cb: S2sEvents[K]): Unsubscribe;
161
+ sendAudio(audio: Uint8Array): void;
162
+ sendToolResult(callId: string, result: string): void;
163
+ updateSession(config: S2sSessionConfig): void;
164
+ resumeSession(sessionId: string): void;
165
+ close(): void;
166
+ };
167
+
168
+ export type ConnectS2sOptions = {
169
+ apiKey: string;
170
+ config: S2SConfig;
171
+ createWebSocket: CreateS2sWebSocket;
172
+ logger?: Logger;
173
+ };
174
+
175
+ export function connectS2s(opts: ConnectS2sOptions): Promise<S2sHandle> {
176
+ const { apiKey, config, createWebSocket, logger: log = consoleLogger } = opts;
177
+
178
+ return new Promise((resolve, reject) => {
179
+ log.info("S2S connecting", { url: config.wssUrl });
180
+
181
+ const ws = createWebSocket(config.wssUrl, {
182
+ headers: { Authorization: `Bearer ${apiKey}` },
183
+ });
184
+
185
+ const emitter = createNanoEvents<S2sEvents>();
186
+ let opened = false;
187
+
188
+ function send(msg: { type: string; [key: string]: unknown }): void {
189
+ if (ws.readyState !== WS_OPEN) {
190
+ log.debug("S2S send dropped: socket not open", { type: msg.type });
191
+ return;
192
+ }
193
+ const json = JSON.stringify(msg);
194
+ if (msg.type !== "input.audio") {
195
+ if (msg.type === "session.update") {
196
+ log.info(`S2S >> ${msg.type}`, { payload: json });
197
+ } else {
198
+ log.info(`S2S >> ${msg.type}`);
199
+ }
200
+ }
201
+ ws.send(json);
202
+ }
203
+
204
+ const handle: S2sHandle = {
205
+ on: emitter.on.bind(emitter),
206
+
207
+ sendAudio(audio: Uint8Array): void {
208
+ if (ws.readyState !== WS_OPEN) {
209
+ log.debug("S2S sendAudio dropped: socket not open");
210
+ return;
211
+ }
212
+ ws.send(`{"type":"input.audio","audio":"${uint8ToBase64(audio)}"}`);
213
+ },
214
+
215
+ sendToolResult(callId: string, result: string): void {
216
+ const msg = { type: "tool.result", call_id: callId, result };
217
+ log.info("S2S >> tool.result", { call_id: callId, resultLength: result.length });
218
+ send(msg);
219
+ },
220
+
221
+ updateSession(sessionConfig: S2sSessionConfig): void {
222
+ const { systemPrompt, ...rest } = sessionConfig;
223
+ send({ type: "session.update", session: { system_prompt: systemPrompt, ...rest } });
224
+ },
225
+
226
+ resumeSession(sessionId: string): void {
227
+ send({ type: "session.resume", session_id: sessionId });
228
+ },
229
+
230
+ close(): void {
231
+ log.info("S2S closing");
232
+ ws.close();
233
+ },
234
+ };
235
+
236
+ ws.addEventListener("open", () => {
237
+ opened = true;
238
+ log.info("S2S WebSocket open");
239
+ resolve(handle);
240
+ });
241
+
242
+ function tryParseJson(data: unknown): unknown | undefined {
243
+ try {
244
+ return JSON.parse(String(data));
245
+ } catch {
246
+ log.warn("S2S << invalid JSON", { data: String(data).slice(0, 200) });
247
+ }
248
+ }
249
+
250
+ function handleAudioFastPath(obj: { type?: unknown; data?: unknown }): boolean {
251
+ if (obj.type === "reply.audio" && typeof obj.data === "string") {
252
+ const audioBytes = base64ToUint8(obj.data);
253
+ emitter.emit("audio", { audio: audioBytes });
254
+ return true;
255
+ }
256
+ return false;
257
+ }
258
+
259
+ function logIncoming(obj: { type?: unknown }): void {
260
+ // reply.audio and input.audio are ~95% of traffic — skip logging.
261
+ if (obj.type === "reply.audio" || obj.type === "input.audio") return;
262
+ log.info(`S2S << ${obj.type}`);
263
+ }
264
+
265
+ function handleS2sMessage(ev: { data: unknown }): void {
266
+ const raw = tryParseJson(ev.data);
267
+ if (raw === undefined) return;
268
+
269
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
270
+ log.warn("S2S << non-object JSON message", { type: typeof raw });
271
+ return;
272
+ }
273
+ const obj = raw as Record<string, unknown>;
274
+ logIncoming(obj);
275
+ if (handleAudioFastPath(obj)) return;
276
+
277
+ const parsed = parseS2sMessage(obj);
278
+ if (!parsed) {
279
+ log.warn(
280
+ `S2S << unrecognised message type: ${obj.type ?? JSON.stringify(raw).slice(0, 200)}`,
281
+ );
282
+ return;
283
+ }
284
+ dispatchS2sMessage(emitter, parsed);
285
+ }
286
+
287
+ ws.addEventListener("message", handleS2sMessage);
288
+
289
+ ws.addEventListener("close", (ev) => {
290
+ const code = ev.code ?? 0;
291
+ const reason = ev.reason ?? "";
292
+ log.info("S2S WebSocket closed", { code, reason });
293
+ if (!opened) {
294
+ reject(new Error(`WebSocket closed before open (code: ${code})`));
295
+ }
296
+ emitter.emit("close", code, reason);
297
+ });
298
+
299
+ ws.addEventListener("error", (ev) => {
300
+ const message = typeof ev.message === "string" ? ev.message : "WebSocket error";
301
+ const errObj = new Error(message);
302
+ log.error("S2S WebSocket error", { error: errObj.message });
303
+ if (!opened) {
304
+ reject(errObj);
305
+ } else {
306
+ emitter.emit("error", errObj);
307
+ }
308
+ });
309
+ });
310
+ }
@@ -0,0 +1,76 @@
1
+ // Copyright 2025 the AAI authors. MIT license.
2
+ /**
3
+ * Tests for server shutdown timeout behavior.
4
+ *
5
+ * Creates a mock runtime with controlled shutdown behavior, then exercises
6
+ * the timeout and graceful paths in close().
7
+ */
8
+
9
+ import { afterEach, describe, expect, test, vi } from "vitest";
10
+ import { silentLogger } from "./_test-utils.ts";
11
+ import type { Runtime } from "./runtime.ts";
12
+ import { createServer } from "./server.ts";
13
+
14
+ let mockShutdown = vi.fn<() => Promise<void>>().mockResolvedValue(undefined);
15
+
16
+ function createMockRuntime(): Runtime {
17
+ return {
18
+ executeTool: vi.fn().mockResolvedValue(""),
19
+ toolSchemas: [],
20
+ createSession: vi.fn() as Runtime["createSession"],
21
+ readyConfig: { audioFormat: "pcm16" as const, sampleRate: 16_000, ttsSampleRate: 24_000 },
22
+ startSession: vi.fn(),
23
+ shutdown: (...args: Parameters<Runtime["shutdown"]>) => mockShutdown(...args),
24
+ };
25
+ }
26
+
27
+ describe("server shutdown timeout", () => {
28
+ let server: ReturnType<typeof createServer> | null = null;
29
+
30
+ afterEach(() => {
31
+ mockShutdown = vi.fn<() => Promise<void>>().mockResolvedValue(undefined);
32
+ server = null;
33
+ });
34
+
35
+ test("close calls runtime.shutdown()", async () => {
36
+ mockShutdown = vi.fn<() => Promise<void>>().mockResolvedValue(undefined);
37
+
38
+ server = createServer({
39
+ runtime: createMockRuntime(),
40
+ logger: silentLogger,
41
+ });
42
+ await server.listen(0);
43
+
44
+ await server.close();
45
+ expect(mockShutdown).toHaveBeenCalledOnce();
46
+ }, 10_000);
47
+
48
+ test("close resolves quickly when runtime.shutdown() resolves", async () => {
49
+ mockShutdown = vi.fn<() => Promise<void>>().mockResolvedValue(undefined);
50
+
51
+ server = createServer({
52
+ runtime: createMockRuntime(),
53
+ logger: silentLogger,
54
+ });
55
+ await server.listen(0);
56
+
57
+ const start = Date.now();
58
+ await server.close();
59
+ const elapsed = Date.now() - start;
60
+
61
+ expect(elapsed).toBeLessThan(1000);
62
+ expect(mockShutdown).toHaveBeenCalledOnce();
63
+ }, 10_000);
64
+
65
+ test("close propagates when runtime.shutdown() rejects", async () => {
66
+ mockShutdown = vi.fn<() => Promise<void>>().mockRejectedValue(new Error("boom"));
67
+
68
+ server = createServer({
69
+ runtime: createMockRuntime(),
70
+ logger: silentLogger,
71
+ });
72
+ await server.listen(0);
73
+
74
+ await expect(server.close()).rejects.toThrow("boom");
75
+ }, 10_000);
76
+ });
@@ -0,0 +1,116 @@
1
+ // Copyright 2025 the AAI authors. MIT license.
2
+ import { afterEach, describe, expect, test, vi } from "vitest";
3
+ import { makeAgent, silentLogger } from "./_test-utils.ts";
4
+ import { createRuntime } from "./runtime.ts";
5
+ import { createServer } from "./server.ts";
6
+
7
+ function makeRuntime(opts: { name?: string; shutdownTimeoutMs?: number } = {}) {
8
+ const agent = makeAgent(opts.name ? { name: opts.name } : {});
9
+ return {
10
+ agent,
11
+ runtime: createRuntime({
12
+ agent,
13
+ env: {},
14
+ logger: silentLogger,
15
+ ...(opts.shutdownTimeoutMs ? { shutdownTimeoutMs: opts.shutdownTimeoutMs } : {}),
16
+ }),
17
+ };
18
+ }
19
+
20
+ describe("createServer", () => {
21
+ let server: ReturnType<typeof createServer> | null = null;
22
+
23
+ afterEach(async () => {
24
+ await server?.close();
25
+ server = null;
26
+ });
27
+
28
+ test("returns an object with listen and close", () => {
29
+ const { runtime } = makeRuntime();
30
+ server = createServer({ runtime, logger: silentLogger });
31
+ expect(server).toHaveProperty("listen");
32
+ expect(server).toHaveProperty("close");
33
+ });
34
+
35
+ test("/health returns ok JSON", async () => {
36
+ const { runtime } = makeRuntime({ name: "health-agent" });
37
+ server = createServer({ runtime, name: "health-agent", logger: silentLogger });
38
+ await server.listen(0);
39
+ await server.close();
40
+ server = null;
41
+ });
42
+
43
+ test("listen and close lifecycle works", async () => {
44
+ const { runtime } = makeRuntime();
45
+ server = createServer({ runtime, logger: silentLogger });
46
+ await server.listen(0);
47
+ await server.close();
48
+ server = null;
49
+ });
50
+
51
+ test("/ returns default HTML with escaped agent name", async () => {
52
+ const name = '<script>alert("xss")</script>';
53
+ const { runtime } = makeRuntime({ name });
54
+ server = createServer({ runtime, name, logger: silentLogger });
55
+ await server.listen(0);
56
+
57
+ const res = await fetch(`http://localhost:${server.port}/`);
58
+ const html = await res.text();
59
+ expect(html).toContain("&lt;script&gt;");
60
+ expect(html).not.toContain("<script>");
61
+ expect(html).toContain("Agent server running.");
62
+ });
63
+
64
+ test("/ returns custom clientHtml when provided", async () => {
65
+ const { runtime } = makeRuntime();
66
+ server = createServer({
67
+ runtime,
68
+ clientHtml: "<h1>Custom</h1>",
69
+ logger: silentLogger,
70
+ });
71
+ await server.listen(0);
72
+
73
+ const res = await fetch(`http://localhost:${server.port}/`);
74
+ const html = await res.text();
75
+ expect(html).toBe("<h1>Custom</h1>");
76
+ });
77
+
78
+ test("/health returns JSON with agent name", async () => {
79
+ const { runtime } = makeRuntime({ name: "my-agent" });
80
+ server = createServer({
81
+ runtime,
82
+ name: "my-agent",
83
+ logger: silentLogger,
84
+ });
85
+ await server.listen(0);
86
+
87
+ const res = await fetch(`http://localhost:${server.port}/health`);
88
+ const json = await res.json();
89
+ expect(json).toEqual({ status: "ok", name: "my-agent" });
90
+ });
91
+
92
+ test("404 triggers error-level logging", async () => {
93
+ const { runtime } = makeRuntime();
94
+ server = createServer({
95
+ runtime,
96
+ logger: silentLogger,
97
+ });
98
+ await server.listen(0);
99
+
100
+ await fetch(`http://localhost:${server.port}/nonexistent-path`);
101
+ await vi.waitFor(() => expect(silentLogger.error).toHaveBeenCalled());
102
+ });
103
+
104
+ test("close is safe to call without listen", async () => {
105
+ const { runtime } = makeRuntime();
106
+ server = createServer({ runtime, logger: silentLogger });
107
+ await server.close();
108
+ server = null;
109
+ });
110
+
111
+ test("accepts shutdownTimeoutMs in runtime options", () => {
112
+ const { runtime } = makeRuntime({ shutdownTimeoutMs: 5000 });
113
+ server = createServer({ runtime, logger: silentLogger });
114
+ expect(server).toHaveProperty("close");
115
+ });
116
+ });