@alexkroman1/aai 1.0.6 → 1.2.0

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 (54) hide show
  1. package/.turbo/turbo-build.log +11 -11
  2. package/CHANGELOG.md +22 -0
  3. package/dist/_internal-types-CoDTiBd1.js +61 -0
  4. package/dist/host/_mock-ws.d.ts +0 -24
  5. package/dist/host/runtime-barrel.d.ts +0 -1
  6. package/dist/host/runtime-barrel.js +55 -5
  7. package/dist/host/runtime.d.ts +2 -0
  8. package/dist/host/tool-executor.d.ts +1 -0
  9. package/dist/host/ws-handler.d.ts +2 -0
  10. package/dist/index.d.ts +1 -0
  11. package/dist/index.js +90 -1
  12. package/dist/sdk/allowed-hosts.d.ts +34 -0
  13. package/dist/sdk/manifest-barrel.d.ts +3 -5
  14. package/dist/sdk/manifest-barrel.js +2 -52
  15. package/dist/sdk/manifest.d.ts +2 -0
  16. package/dist/sdk/protocol.d.ts +11 -28
  17. package/dist/sdk/protocol.js +6 -3
  18. package/dist/sdk/types.d.ts +2 -0
  19. package/host/_mock-ws.ts +0 -50
  20. package/host/_test-utils.ts +1 -0
  21. package/host/runtime-barrel.ts +0 -1
  22. package/host/runtime.ts +13 -1
  23. package/host/session-ctx.test.ts +387 -0
  24. package/host/session-fixture-replay.test.ts +2 -10
  25. package/host/session.test.ts +19 -41
  26. package/host/tool-executor.test.ts +36 -0
  27. package/host/tool-executor.ts +4 -0
  28. package/host/ws-handler.ts +3 -0
  29. package/index.ts +1 -0
  30. package/package.json +1 -1
  31. package/sdk/__snapshots__/exports.test.ts.snap +79 -0
  32. package/sdk/__snapshots__/schema-shapes.test.ts.snap +187 -0
  33. package/sdk/_test-matchers.test.ts +75 -0
  34. package/sdk/_test-matchers.ts +73 -0
  35. package/sdk/allowed-hosts.test.ts +236 -0
  36. package/sdk/allowed-hosts.ts +113 -0
  37. package/sdk/exports.test.ts +31 -0
  38. package/sdk/manifest-barrel.ts +13 -7
  39. package/sdk/manifest.test.ts +103 -2
  40. package/sdk/manifest.ts +19 -0
  41. package/sdk/protocol-compat.test.ts +0 -6
  42. package/sdk/protocol-snapshot.test.ts +7 -5
  43. package/sdk/protocol.test.ts +107 -21
  44. package/sdk/protocol.ts +7 -15
  45. package/sdk/schema-alignment.test.ts +1 -27
  46. package/sdk/schema-shapes.test.ts +103 -0
  47. package/sdk/tsconfig.json +1 -1
  48. package/sdk/types.test.ts +56 -1
  49. package/sdk/types.ts +2 -0
  50. package/sdk/ws-upgrade.test.ts +8 -8
  51. package/tsconfig.build.json +8 -1
  52. package/tsconfig.json +1 -1
  53. package/vitest.config.ts +1 -0
  54. package/dist/system-prompt-nik_iavo.js +0 -92
@@ -83,14 +83,14 @@ describe("createS2sSession", () => {
83
83
  const { session, client } = setup();
84
84
  await session.start();
85
85
  session.onCancel();
86
- expect(client.events).toContainEqual(expect.objectContaining({ type: "cancelled" }));
86
+ expect(client.events).toContainEvent("cancelled");
87
87
  });
88
88
 
89
89
  test("onReset clears state and emits reset event", async () => {
90
90
  const { session, client, mockHandle } = setup();
91
91
  await session.start();
92
92
  session.onReset();
93
- expect(client.events).toContainEqual(expect.objectContaining({ type: "reset" }));
93
+ expect(client.events).toContainEvent("reset");
94
94
  expect(mockHandle.close).toHaveBeenCalled();
95
95
  });
96
96
 
@@ -119,10 +119,7 @@ describe("createS2sSession", () => {
119
119
  mockHandle._fire("event", { type: "user_transcript", text: "Hello there" });
120
120
  await flush();
121
121
 
122
- expect(client.events).toContainEqual({
123
- type: "user_transcript",
124
- text: "Hello there",
125
- });
122
+ expect(client.events).toContainEvent("user_transcript", { text: "Hello there" });
126
123
  });
127
124
 
128
125
  test("audio event forwards audio to client", async () => {
@@ -145,10 +142,7 @@ describe("createS2sSession", () => {
145
142
  _interrupted: false,
146
143
  });
147
144
 
148
- expect(client.events).toContainEqual({
149
- type: "agent_transcript",
150
- text: "Full response",
151
- });
145
+ expect(client.events).toContainEvent("agent_transcript", { text: "Full response" });
152
146
  });
153
147
 
154
148
  test("speech_started and speech_stopped events are forwarded", async () => {
@@ -158,8 +152,8 @@ describe("createS2sSession", () => {
158
152
  mockHandle._fire("event", { type: "speech_started" });
159
153
  mockHandle._fire("event", { type: "speech_stopped" });
160
154
 
161
- expect(client.events).toContainEqual({ type: "speech_started" });
162
- expect(client.events).toContainEqual({ type: "speech_stopped" });
155
+ expect(client.events).toContainEvent("speech_started");
156
+ expect(client.events).toContainEvent("speech_stopped");
163
157
  });
164
158
 
165
159
  test("reply_started resets tool call count", async () => {
@@ -177,7 +171,7 @@ describe("createS2sSession", () => {
177
171
  mockHandle._fire("event", { type: "reply_done" });
178
172
 
179
173
  expect(client.audioDoneCount).toBe(1);
180
- expect(client.events).toContainEqual({ type: "reply_done" });
174
+ expect(client.events).toContainEvent("reply_done");
181
175
  });
182
176
 
183
177
  test("cancelled event emits cancelled", async () => {
@@ -186,7 +180,7 @@ describe("createS2sSession", () => {
186
180
 
187
181
  mockHandle._fire("event", { type: "cancelled" });
188
182
 
189
- expect(client.events).toContainEqual({ type: "cancelled" });
183
+ expect(client.events).toContainEvent("cancelled");
190
184
  });
191
185
 
192
186
  test("error event emits error to client and closes handle", async () => {
@@ -195,11 +189,7 @@ describe("createS2sSession", () => {
195
189
 
196
190
  mockHandle._fire("error", new Error("Something broke"));
197
191
 
198
- expect(client.events).toContainEqual({
199
- type: "error",
200
- code: "internal",
201
- message: "Something broke",
202
- });
192
+ expect(client.events).toContainEvent("error", { code: "internal", message: "Something broke" });
203
193
  expect(mockHandle.close).toHaveBeenCalled();
204
194
  });
205
195
 
@@ -227,14 +217,12 @@ describe("createS2sSession", () => {
227
217
  "session-1",
228
218
  expect.any(Array),
229
219
  );
230
- expect(client.events).toContainEqual({
231
- type: "tool_call",
220
+ expect(client.events).toContainEvent("tool_call", {
232
221
  toolCallId: "call-1",
233
222
  toolName: "my_tool",
234
223
  args: { key: "value" },
235
224
  });
236
- expect(client.events).toContainEqual({
237
- type: "tool_call_done",
225
+ expect(client.events).toContainEvent("tool_call_done", {
238
226
  toolCallId: "call-1",
239
227
  result: "tool-output",
240
228
  });
@@ -318,13 +306,7 @@ describe("createS2sSession", () => {
318
306
 
319
307
  await session.start();
320
308
 
321
- expect(client.events).toContainEqual(
322
- expect.objectContaining({
323
- type: "error",
324
- code: "internal",
325
- message: "connect failed",
326
- }),
327
- );
309
+ expect(client.events).toContainEvent("error", { code: "internal", message: "connect failed" });
328
310
 
329
311
  spy.mockRestore();
330
312
  });
@@ -503,10 +485,6 @@ describe("createS2sSession", () => {
503
485
 
504
486
  // ─── Idle timeout tests ──────────────────────────────────────────────
505
487
 
506
- function hasIdleTimeout(events: unknown[]): boolean {
507
- return events.some((e) => (e as Record<string, unknown>).type === "idle_timeout");
508
- }
509
-
510
488
  test("idle timeout fires after configured period of inactivity", async () => {
511
489
  vi.useFakeTimers();
512
490
  const { session, client, mockHandle } = setup({
@@ -519,7 +497,7 @@ describe("createS2sSession", () => {
519
497
  });
520
498
  await session.start();
521
499
  vi.advanceTimersByTime(10_000);
522
- expect(hasIdleTimeout(client.events)).toBe(true);
500
+ expect(client.events).toContainEvent("idle_timeout");
523
501
  expect(mockHandle.close).toHaveBeenCalled();
524
502
  vi.useRealTimers();
525
503
  });
@@ -538,9 +516,9 @@ describe("createS2sSession", () => {
538
516
  vi.advanceTimersByTime(8000);
539
517
  session.onAudio(new Uint8Array([1, 2, 3]));
540
518
  vi.advanceTimersByTime(8000);
541
- expect(hasIdleTimeout(client.events)).toBe(false);
519
+ expect(client.events).not.toContainEvent("idle_timeout");
542
520
  vi.advanceTimersByTime(2000);
543
- expect(hasIdleTimeout(client.events)).toBe(true);
521
+ expect(client.events).toContainEvent("idle_timeout");
544
522
  vi.useRealTimers();
545
523
  });
546
524
 
@@ -556,7 +534,7 @@ describe("createS2sSession", () => {
556
534
  });
557
535
  await session.start();
558
536
  vi.advanceTimersByTime(600_000);
559
- expect(hasIdleTimeout(client.events)).toBe(false);
537
+ expect(client.events).not.toContainEvent("idle_timeout");
560
538
  vi.useRealTimers();
561
539
  });
562
540
 
@@ -573,7 +551,7 @@ describe("createS2sSession", () => {
573
551
  await session.start();
574
552
  await session.stop();
575
553
  vi.advanceTimersByTime(20_000);
576
- expect(hasIdleTimeout(client.events)).toBe(false);
554
+ expect(client.events).not.toContainEvent("idle_timeout");
577
555
  vi.useRealTimers();
578
556
  });
579
557
 
@@ -582,9 +560,9 @@ describe("createS2sSession", () => {
582
560
  const { session, client } = setup();
583
561
  await session.start();
584
562
  vi.advanceTimersByTime(240_000);
585
- expect(hasIdleTimeout(client.events)).toBe(false);
563
+ expect(client.events).not.toContainEvent("idle_timeout");
586
564
  vi.advanceTimersByTime(60_000);
587
- expect(hasIdleTimeout(client.events)).toBe(true);
565
+ expect(client.events).toContainEvent("idle_timeout");
588
566
  vi.useRealTimers();
589
567
  });
590
568
  });
@@ -121,4 +121,40 @@ describe("executeToolCall", () => {
121
121
  expect(result).toBe(JSON.stringify({ error: 'Tool "slow" timed out after 30000ms' }));
122
122
  vi.useRealTimers();
123
123
  });
124
+
125
+ test("ctx.send calls the send callback", async () => {
126
+ const sends: Array<{ event: string; data: unknown }> = [];
127
+ const tool: ToolDef = {
128
+ description: "sends an event",
129
+ parameters: z.object({}),
130
+ execute: (_args, ctx) => {
131
+ ctx.send("game_state", { hp: 10 });
132
+ return "ok";
133
+ },
134
+ };
135
+ const result = await executeToolCall(
136
+ "sender",
137
+ {},
138
+ {
139
+ tool,
140
+ env: {},
141
+ send: (event, data) => sends.push({ event, data }),
142
+ },
143
+ );
144
+ expect(result).toBe("ok");
145
+ expect(sends).toEqual([{ event: "game_state", data: { hp: 10 } }]);
146
+ });
147
+
148
+ test("ctx.send is a no-op when no send callback provided", async () => {
149
+ const tool: ToolDef = {
150
+ description: "sends an event",
151
+ parameters: z.object({}),
152
+ execute: (_args, ctx) => {
153
+ ctx.send("test", {});
154
+ return "ok";
155
+ },
156
+ };
157
+ const result = await executeToolCall("sender", {}, { tool, env: {} });
158
+ expect(result).toBe("ok");
159
+ });
124
160
  });
@@ -27,6 +27,7 @@ export type ExecuteToolCallOptions = {
27
27
  kv?: Kv | undefined;
28
28
  messages?: readonly Message[] | undefined;
29
29
  logger?: Logger | undefined;
30
+ send?: ((event: string, data: unknown) => void) | undefined;
30
31
  };
31
32
 
32
33
  function buildToolContext(opts: ExecuteToolCallOptions): ToolContext {
@@ -40,6 +41,9 @@ function buildToolContext(opts: ExecuteToolCallOptions): ToolContext {
40
41
  },
41
42
  messages: messages ?? [],
42
43
  sessionId: sessionId ?? "",
44
+ send(event: string, data: unknown): void {
45
+ opts.send?.(event, data);
46
+ },
43
47
  };
44
48
  }
45
49
 
@@ -47,6 +47,8 @@ export type WsSessionOptions = {
47
47
  onClose?: () => void;
48
48
  /** Callback invoked with the session ID after session cleanup. */
49
49
  onSessionEnd?: (sessionId: string) => void;
50
+ /** Callback invoked with the session ID and client sink after session setup. */
51
+ onSinkCreated?: (sessionId: string, sink: ClientSink) => void;
50
52
  /** Logger instance. Defaults to console. */
51
53
  logger?: Logger;
52
54
  /** Timeout in ms for session.start(). Defaults to 10 000 (10s). */
@@ -184,6 +186,7 @@ export function wireSessionSocket(ws: SessionWebSocket, opts: WsSessionOptions):
184
186
  const client = createClientSink(ws, log);
185
187
  session = opts.createSession(sessionId, client);
186
188
  sessions.set(sessionId, session);
189
+ opts.onSinkCreated?.(sessionId, client);
187
190
 
188
191
  // Send config immediately — zero RTT. Include sessionId so the client
189
192
  // can reconnect with ?sessionId=<id> to resume a persisted session.
package/index.ts CHANGED
@@ -8,6 +8,7 @@
8
8
 
9
9
  // biome-ignore-all lint/performance/noReExportAll: barrel file by design
10
10
 
11
+ export * from "./sdk/allowed-hosts.ts";
11
12
  export * from "./sdk/constants.ts";
12
13
  export * from "./sdk/define.ts";
13
14
  export * from "./sdk/kv.ts";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@alexkroman1/aai",
3
- "version": "1.0.6",
3
+ "version": "1.2.0",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  ".": {
@@ -0,0 +1,79 @@
1
+ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
2
+
3
+ exports[`export surface stability > @alexkroman1/aai main export 1`] = `
4
+ [
5
+ "AGENT_CSP",
6
+ "BuiltinToolSchema",
7
+ "DEFAULT_GREETING",
8
+ "DEFAULT_IDLE_TIMEOUT_MS",
9
+ "DEFAULT_MAX_HISTORY",
10
+ "DEFAULT_SESSION_START_TIMEOUT_MS",
11
+ "DEFAULT_SHUTDOWN_TIMEOUT_MS",
12
+ "DEFAULT_STT_SAMPLE_RATE",
13
+ "DEFAULT_SYSTEM_PROMPT",
14
+ "DEFAULT_TTS_SAMPLE_RATE",
15
+ "FETCH_TIMEOUT_MS",
16
+ "MAX_HTML_BYTES",
17
+ "MAX_MESSAGE_BUFFER_SIZE",
18
+ "MAX_PAGE_CHARS",
19
+ "MAX_TOOL_RESULT_CHARS",
20
+ "MAX_VALUE_SIZE",
21
+ "MAX_WS_PAYLOAD_BYTES",
22
+ "RUN_CODE_TIMEOUT_MS",
23
+ "TOOL_EXECUTION_TIMEOUT_MS",
24
+ "ToolChoiceSchema",
25
+ "WS_OPEN",
26
+ "agent",
27
+ "errorDetail",
28
+ "errorMessage",
29
+ "matchesAllowedHost",
30
+ "parseWsUpgradeParams",
31
+ "tool",
32
+ "toolError",
33
+ "validateAllowedHostPattern",
34
+ ]
35
+ `;
36
+
37
+ exports[`export surface stability > @alexkroman1/aai/manifest export 1`] = `
38
+ [
39
+ "AgentConfigSchema",
40
+ "EMPTY_PARAMS",
41
+ "ToolSchemaSchema",
42
+ "agentToolsToSchemas",
43
+ "toAgentConfig",
44
+ ]
45
+ `;
46
+
47
+ exports[`export surface stability > @alexkroman1/aai/protocol export 1`] = `
48
+ [
49
+ "ClientEventSchema",
50
+ "ClientMessageSchema",
51
+ "KvDelSchema",
52
+ "KvGetSchema",
53
+ "KvRequestSchema",
54
+ "KvSetSchema",
55
+ "ReadyConfigSchema",
56
+ "ServerMessageSchema",
57
+ "SessionErrorCodeSchema",
58
+ "buildReadyConfig",
59
+ "lenientParse",
60
+ ]
61
+ `;
62
+
63
+ exports[`export surface stability > @alexkroman1/aai/runtime export 1`] = `
64
+ [
65
+ "DEFAULT_S2S_CONFIG",
66
+ "_internals",
67
+ "buildCtx",
68
+ "consoleLogger",
69
+ "createRuntime",
70
+ "createS2sSession",
71
+ "createServer",
72
+ "createUnstorageKv",
73
+ "executeInIsolate",
74
+ "executeToolCall",
75
+ "jsonLogger",
76
+ "resolveAllBuiltins",
77
+ "wireSessionSocket",
78
+ ]
79
+ `;
@@ -0,0 +1,187 @@
1
+ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
2
+
3
+ exports[`manifest schema shapes > AgentConfigSchema shape 1`] = `
4
+ [
5
+ "builtinTools",
6
+ "greeting",
7
+ "idleTimeoutMs",
8
+ "maxSteps",
9
+ "name",
10
+ "sttPrompt",
11
+ "systemPrompt",
12
+ "toolChoice",
13
+ ]
14
+ `;
15
+
16
+ exports[`manifest schema shapes > ToolSchemaSchema shape 1`] = `
17
+ [
18
+ "description",
19
+ "name",
20
+ "parameters",
21
+ ]
22
+ `;
23
+
24
+ exports[`protocol schema shapes > ClientEventSchema option shapes 1`] = `
25
+ {
26
+ "agent_transcript": [
27
+ "text",
28
+ "type",
29
+ ],
30
+ "cancelled": [
31
+ "type",
32
+ ],
33
+ "custom_event": [
34
+ "data",
35
+ "event",
36
+ "type",
37
+ ],
38
+ "error": [
39
+ "code",
40
+ "message",
41
+ "type",
42
+ ],
43
+ "idle_timeout": [
44
+ "type",
45
+ ],
46
+ "reply_done": [
47
+ "type",
48
+ ],
49
+ "reset": [
50
+ "type",
51
+ ],
52
+ "speech_started": [
53
+ "type",
54
+ ],
55
+ "speech_stopped": [
56
+ "type",
57
+ ],
58
+ "tool_call": [
59
+ "args",
60
+ "toolCallId",
61
+ "toolName",
62
+ "type",
63
+ ],
64
+ "tool_call_done": [
65
+ "result",
66
+ "toolCallId",
67
+ "type",
68
+ ],
69
+ "user_transcript": [
70
+ "text",
71
+ "turnOrder",
72
+ "type",
73
+ ],
74
+ }
75
+ `;
76
+
77
+ exports[`protocol schema shapes > ClientMessageSchema option shapes 1`] = `
78
+ {
79
+ "audio_ready": [
80
+ "type",
81
+ ],
82
+ "cancel": [
83
+ "type",
84
+ ],
85
+ "history": [
86
+ "messages",
87
+ "type",
88
+ ],
89
+ "reset": [
90
+ "type",
91
+ ],
92
+ }
93
+ `;
94
+
95
+ exports[`protocol schema shapes > KvDelSchema shape 1`] = `
96
+ [
97
+ "key",
98
+ "op",
99
+ ]
100
+ `;
101
+
102
+ exports[`protocol schema shapes > KvGetSchema shape 1`] = `
103
+ [
104
+ "key",
105
+ "op",
106
+ ]
107
+ `;
108
+
109
+ exports[`protocol schema shapes > KvSetSchema shape 1`] = `
110
+ [
111
+ "expireIn",
112
+ "key",
113
+ "op",
114
+ "value",
115
+ ]
116
+ `;
117
+
118
+ exports[`protocol schema shapes > ReadyConfigSchema shape 1`] = `
119
+ [
120
+ "audioFormat",
121
+ "sampleRate",
122
+ "ttsSampleRate",
123
+ ]
124
+ `;
125
+
126
+ exports[`protocol schema shapes > ServerMessageSchema option shapes 1`] = `
127
+ {
128
+ "agent_transcript": [
129
+ "text",
130
+ "type",
131
+ ],
132
+ "audio_done": [
133
+ "type",
134
+ ],
135
+ "cancelled": [
136
+ "type",
137
+ ],
138
+ "config": [
139
+ "audioFormat",
140
+ "sampleRate",
141
+ "sessionId",
142
+ "ttsSampleRate",
143
+ "type",
144
+ ],
145
+ "custom_event": [
146
+ "data",
147
+ "event",
148
+ "type",
149
+ ],
150
+ "error": [
151
+ "code",
152
+ "message",
153
+ "type",
154
+ ],
155
+ "idle_timeout": [
156
+ "type",
157
+ ],
158
+ "reply_done": [
159
+ "type",
160
+ ],
161
+ "reset": [
162
+ "type",
163
+ ],
164
+ "speech_started": [
165
+ "type",
166
+ ],
167
+ "speech_stopped": [
168
+ "type",
169
+ ],
170
+ "tool_call": [
171
+ "args",
172
+ "toolCallId",
173
+ "toolName",
174
+ "type",
175
+ ],
176
+ "tool_call_done": [
177
+ "result",
178
+ "toolCallId",
179
+ "type",
180
+ ],
181
+ "user_transcript": [
182
+ "text",
183
+ "turnOrder",
184
+ "type",
185
+ ],
186
+ }
187
+ `;
@@ -0,0 +1,75 @@
1
+ // Copyright 2025 the AAI authors. MIT license.
2
+
3
+ import { describe, expect, it } from "vitest";
4
+ import "./_test-matchers.ts";
5
+
6
+ describe("toBeValidClientEvent", () => {
7
+ it("passes for a valid event", () => {
8
+ expect({ type: "speech_started" }).toBeValidClientEvent();
9
+ });
10
+
11
+ it("passes for a valid event with fields", () => {
12
+ expect({
13
+ type: "user_transcript",
14
+ text: "hello world",
15
+ }).toBeValidClientEvent();
16
+ });
17
+
18
+ it("fails for an invalid event", () => {
19
+ expect(() => {
20
+ expect({ type: "not_a_real_event" }).toBeValidClientEvent();
21
+ }).toThrow(/expected value to be a valid ClientEvent/);
22
+ });
23
+
24
+ it("fails for a missing type field", () => {
25
+ expect(() => {
26
+ expect({ text: "no type" }).toBeValidClientEvent();
27
+ }).toThrow(/expected value to be a valid ClientEvent/);
28
+ });
29
+ });
30
+
31
+ describe("toContainEvent", () => {
32
+ const events = [
33
+ { type: "speech_started" },
34
+ { type: "user_transcript", text: "hello" },
35
+ { type: "tool_call", toolCallId: "tc1", toolName: "search", args: { q: "test" } },
36
+ { type: "reply_done" },
37
+ ];
38
+
39
+ it("finds a matching event by type", () => {
40
+ expect(events).toContainEvent("speech_started");
41
+ });
42
+
43
+ it("finds a matching event with fields", () => {
44
+ expect(events).toContainEvent("tool_call", { toolName: "search" });
45
+ });
46
+
47
+ it("matches a subset of fields", () => {
48
+ expect(events).toContainEvent("tool_call", {
49
+ toolName: "search",
50
+ args: { q: "test" },
51
+ });
52
+ });
53
+
54
+ it("fails when event type is not found", () => {
55
+ expect(() => {
56
+ expect(events).toContainEvent("cancelled");
57
+ }).toThrow(/expected array to contain event of type "cancelled"/);
58
+ });
59
+
60
+ it("fails when fields do not match", () => {
61
+ expect(() => {
62
+ expect(events).toContainEvent("tool_call", { toolName: "visit_webpage" });
63
+ }).toThrow(/expected array to contain event of type "tool_call"/);
64
+ });
65
+
66
+ it("fails when received value is not an array", () => {
67
+ expect(() => {
68
+ expect("not-an-array").toContainEvent("speech_started");
69
+ }).toThrow(/expected an array of events/);
70
+ });
71
+
72
+ it("supports .not negation", () => {
73
+ expect(events).not.toContainEvent("cancelled");
74
+ });
75
+ });
@@ -0,0 +1,73 @@
1
+ // Copyright 2025 the AAI authors. MIT license.
2
+ /**
3
+ * Custom Vitest matchers for AAI domain types.
4
+ *
5
+ * Registered via `expect.extend()` — add this file to the vitest `setupFiles`
6
+ * for the `aai` project so matchers are available in every test.
7
+ */
8
+
9
+ import { isDeepStrictEqual } from "node:util";
10
+ import { expect } from "vitest";
11
+ import { ClientEventSchema } from "./protocol.ts";
12
+
13
+ type MatcherResult = { pass: boolean; message: () => string };
14
+
15
+ // ─── Matcher implementations ────────────────────────────────────────────────
16
+
17
+ function toBeValidClientEvent(received: unknown): MatcherResult {
18
+ const result = ClientEventSchema.safeParse(received);
19
+ return {
20
+ pass: result.success,
21
+ message: () =>
22
+ result.success
23
+ ? "expected value NOT to be a valid ClientEvent, but it parsed successfully"
24
+ : `expected value to be a valid ClientEvent\n\nZod errors:\n${result.error.issues.map((i) => ` - ${i.path.join(".")}: ${i.message}`).join("\n")}`,
25
+ };
26
+ }
27
+
28
+ function toContainEvent(
29
+ received: unknown,
30
+ type: string,
31
+ fields?: Record<string, unknown>,
32
+ ): MatcherResult {
33
+ if (!Array.isArray(received)) {
34
+ return {
35
+ pass: false,
36
+ message: () => `expected an array of events, but received ${typeof received}`,
37
+ };
38
+ }
39
+
40
+ const match = received.some((event: Record<string, unknown>) => {
41
+ if (event?.type !== type) return false;
42
+ if (!fields) return true;
43
+ return Object.entries(fields).every(([key, value]) => isDeepStrictEqual(event[key], value));
44
+ });
45
+
46
+ return {
47
+ pass: match,
48
+ message: () =>
49
+ match
50
+ ? `expected array NOT to contain event of type "${type}"${fields ? ` with fields ${JSON.stringify(fields)}` : ""}`
51
+ : `expected array to contain event of type "${type}"${fields ? ` with fields ${JSON.stringify(fields)}` : ""}\n\nReceived event types: [${received.map((e: Record<string, unknown>) => `"${e?.type}"`).join(", ")}]`,
52
+ };
53
+ }
54
+
55
+ // ─── Register matchers ──────────────────────────────────────────────────────
56
+
57
+ expect.extend({
58
+ toBeValidClientEvent,
59
+ toContainEvent,
60
+ });
61
+
62
+ // ─── Type augmentation ──────────────────────────────────────────────────────
63
+
64
+ declare module "vitest" {
65
+ interface Assertion<T> {
66
+ toBeValidClientEvent(): void;
67
+ toContainEvent(type: string, fields?: Record<string, unknown>): void;
68
+ }
69
+ interface AsymmetricMatchersContaining {
70
+ toBeValidClientEvent(): void;
71
+ toContainEvent(type: string, fields?: Record<string, unknown>): void;
72
+ }
73
+ }