@alexkroman1/aai 1.7.1 → 1.8.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 (133) hide show
  1. package/.turbo/turbo-build.log +11 -9
  2. package/CHANGELOG.md +10 -0
  3. package/dist/{_internal-types-CrnTi9Ew.js → _internal-types-CfOAbK6V.js} +22 -35
  4. package/dist/constants-y68COEGj.js +29 -0
  5. package/dist/host/_base64.d.ts +2 -0
  6. package/dist/host/_mock-ws.d.ts +0 -61
  7. package/dist/host/_pipeline-test-fakes.d.ts +7 -4
  8. package/dist/host/_run-code.d.ts +0 -25
  9. package/dist/host/_runtime-conformance.d.ts +3 -34
  10. package/dist/host/memory-vector.d.ts +0 -11
  11. package/dist/host/providers/resolve-kv.d.ts +0 -7
  12. package/dist/host/providers/resolve-vector.d.ts +0 -8
  13. package/dist/host/providers/stt/assemblyai.d.ts +0 -14
  14. package/dist/host/providers/stt/deepgram.d.ts +2 -14
  15. package/dist/host/providers/stt/soniox.d.ts +0 -22
  16. package/dist/host/providers/tts/rime.d.ts +10 -31
  17. package/dist/host/runtime-barrel.js +619 -630
  18. package/dist/host/runtime-config.d.ts +9 -6
  19. package/dist/host/runtime.d.ts +3 -0
  20. package/dist/host/to-vercel-tools.d.ts +3 -33
  21. package/dist/host/transports/openai-realtime-transport.d.ts +43 -0
  22. package/dist/host/unstorage-kv.d.ts +0 -26
  23. package/dist/index.js +3 -3
  24. package/dist/openai-realtime-cjPAHMMx.js +10 -0
  25. package/dist/sdk/_internal-types.d.ts +6 -55
  26. package/dist/sdk/allowed-hosts.d.ts +4 -3
  27. package/dist/sdk/constants.d.ts +4 -29
  28. package/dist/sdk/define.d.ts +7 -4
  29. package/dist/sdk/kv.d.ts +13 -37
  30. package/dist/sdk/manifest-barrel.js +1 -1
  31. package/dist/sdk/manifest.d.ts +8 -2
  32. package/dist/sdk/protocol.js +1 -1
  33. package/dist/sdk/providers/s2s/openai-realtime.d.ts +17 -0
  34. package/dist/sdk/providers/s2s-barrel.d.ts +9 -0
  35. package/dist/sdk/providers/s2s-barrel.js +2 -0
  36. package/dist/sdk/providers/tts/rime.d.ts +1 -1
  37. package/dist/sdk/providers.d.ts +6 -2
  38. package/dist/sdk/types.d.ts +7 -1
  39. package/dist/{types-KUgezM6u.js → types-DOWVZhb9.js} +1 -7
  40. package/dist/{ws-upgrade-BeOQ7fXL.js → ws-upgrade-CG8-by1n.js} +2 -3
  41. package/host/_base64.ts +9 -0
  42. package/host/_mock-ws.ts +0 -65
  43. package/host/_pipeline-test-fakes.ts +19 -31
  44. package/host/_run-code.ts +10 -53
  45. package/host/_runtime-conformance.ts +3 -44
  46. package/host/_test-utils.ts +20 -42
  47. package/host/builtin-tools.test.ts +127 -222
  48. package/host/builtin-tools.ts +6 -10
  49. package/host/cleanup.test.ts +30 -73
  50. package/host/integration/pipeline-reference.integration.test.ts +12 -17
  51. package/host/integration.test.ts +0 -7
  52. package/host/memory-vector.test.ts +3 -1
  53. package/host/memory-vector.ts +16 -21
  54. package/host/pinecone-vector.test.ts +14 -17
  55. package/host/pinecone-vector.ts +10 -19
  56. package/host/providers/providers.test-d.ts +5 -3
  57. package/host/providers/resolve-kv.ts +23 -41
  58. package/host/providers/resolve-vector.ts +3 -12
  59. package/host/providers/resolve.test.ts +15 -28
  60. package/host/providers/resolve.ts +24 -24
  61. package/host/providers/stt/assemblyai.test.ts +2 -14
  62. package/host/providers/stt/assemblyai.ts +12 -35
  63. package/host/providers/stt/deepgram.test.ts +23 -83
  64. package/host/providers/stt/deepgram.ts +15 -40
  65. package/host/providers/stt/elevenlabs.test.ts +26 -38
  66. package/host/providers/stt/elevenlabs.ts +10 -9
  67. package/host/providers/stt/soniox.test.ts +35 -85
  68. package/host/providers/stt/soniox.ts +8 -53
  69. package/host/providers/tts/cartesia.test.ts +19 -58
  70. package/host/providers/tts/cartesia.ts +36 -66
  71. package/host/providers/tts/rime.test.ts +12 -38
  72. package/host/providers/tts/rime.ts +23 -86
  73. package/host/runtime-config.test.ts +9 -9
  74. package/host/runtime-config.ts +16 -22
  75. package/host/runtime.test.ts +111 -73
  76. package/host/runtime.ts +138 -86
  77. package/host/s2s.test.ts +92 -191
  78. package/host/s2s.ts +55 -49
  79. package/host/server-shutdown.test.ts +9 -30
  80. package/host/server.test.ts +2 -13
  81. package/host/server.ts +85 -100
  82. package/host/session-core.test.ts +15 -30
  83. package/host/session-core.ts +10 -13
  84. package/host/session-prompt.test.ts +1 -5
  85. package/host/to-vercel-tools.test.ts +53 -72
  86. package/host/to-vercel-tools.ts +9 -39
  87. package/host/tool-executor.test.ts +25 -51
  88. package/host/tool-executor.ts +18 -12
  89. package/host/transports/openai-realtime-transport.test.ts +371 -0
  90. package/host/transports/openai-realtime-transport.ts +319 -0
  91. package/host/transports/pipeline-transport.test.ts +125 -298
  92. package/host/transports/pipeline-transport.ts +20 -68
  93. package/host/transports/s2s-transport-fixtures.test.ts +31 -92
  94. package/host/transports/s2s-transport.test.ts +65 -134
  95. package/host/transports/s2s-transport.ts +15 -43
  96. package/host/transports/types.test.ts +4 -8
  97. package/host/unstorage-kv.test.ts +3 -2
  98. package/host/unstorage-kv.ts +5 -35
  99. package/host/ws-handler.test.ts +72 -176
  100. package/host/ws-handler.ts +6 -12
  101. package/package.json +6 -1
  102. package/sdk/__snapshots__/exports.test.ts.snap +7 -0
  103. package/sdk/__snapshots__/schema-shapes.test.ts.snap +1 -0
  104. package/sdk/_internal-types.test.ts +6 -9
  105. package/sdk/_internal-types.ts +16 -57
  106. package/sdk/_test-matchers.ts +25 -15
  107. package/sdk/allowed-hosts.test.ts +50 -114
  108. package/sdk/allowed-hosts.ts +8 -14
  109. package/sdk/constants.ts +5 -52
  110. package/sdk/define.test.ts +7 -6
  111. package/sdk/define.ts +7 -3
  112. package/sdk/exports.test.ts +6 -1
  113. package/sdk/kv.ts +13 -37
  114. package/sdk/manifest.test-d.ts +5 -0
  115. package/sdk/manifest.test.ts +61 -9
  116. package/sdk/manifest.ts +11 -11
  117. package/sdk/protocol-compat.test.ts +66 -98
  118. package/sdk/protocol-snapshot.test.ts +2 -16
  119. package/sdk/protocol.test.ts +13 -22
  120. package/sdk/providers/s2s/openai-realtime.ts +36 -0
  121. package/sdk/providers/s2s-barrel.ts +12 -0
  122. package/sdk/providers/tts/rime.ts +1 -1
  123. package/sdk/providers.ts +24 -5
  124. package/sdk/schema-alignment.test.ts +25 -73
  125. package/sdk/schema-shapes.test.ts +1 -29
  126. package/sdk/system-prompt.test.ts +0 -1
  127. package/sdk/system-prompt.ts +17 -19
  128. package/sdk/types-inference.test.ts +10 -36
  129. package/sdk/types.ts +7 -0
  130. package/sdk/ws-upgrade.test.ts +24 -23
  131. package/sdk/ws-upgrade.ts +2 -3
  132. package/tsdown.config.ts +8 -11
  133. package/dist/constants-C2nirZUI.js +0 -54
@@ -59,21 +59,22 @@ describe("agent()", () => {
59
59
  expect(def.builtinTools).toEqual(["web_search"]);
60
60
  });
61
61
 
62
- test("preserves stt/llm/tts providers on the returned def", () => {
62
+ function pipelineAgent() {
63
63
  const stt = assemblyAI({ model: "u3pro-rt" });
64
64
  const tts = cartesia({ voice: "v" });
65
65
  const llm = anthropic({ model: "claude-haiku-4-5" });
66
- const def = agent({ name: "t", systemPrompt: "p", stt, llm, tts });
66
+ return { stt, llm, tts, def: agent({ name: "t", systemPrompt: "p", stt, llm, tts }) };
67
+ }
68
+
69
+ test("preserves stt/llm/tts providers on the returned def", () => {
70
+ const { stt, llm, tts, def } = pipelineAgent();
67
71
  expect(def.stt).toBe(stt);
68
72
  expect(def.llm).toBe(llm);
69
73
  expect(def.tts).toBe(tts);
70
74
  });
71
75
 
72
76
  test("stt/llm/tts flow through parseManifest to mode 'pipeline'", () => {
73
- const stt = assemblyAI({ model: "u3pro-rt" });
74
- const tts = cartesia({ voice: "v" });
75
- const llm = anthropic({ model: "claude-haiku-4-5" });
76
- const def = agent({ name: "t", systemPrompt: "p", stt, llm, tts });
77
+ const { stt, llm, tts, def } = pipelineAgent();
77
78
  const parsed = parseManifest(def);
78
79
  expect(parsed.mode).toBe("pipeline");
79
80
  expect(parsed.stt).toStrictEqual(stt);
package/sdk/define.ts CHANGED
@@ -1,12 +1,10 @@
1
1
  // Copyright 2025 the AAI authors. MIT license.
2
- /**
3
- * Helper functions for defining agents and tools with full type inference.
4
- */
5
2
 
6
3
  import type { z } from "zod";
7
4
  import type {
8
5
  KvProvider,
9
6
  LlmProvider,
7
+ S2sProvider,
10
8
  SttProvider,
11
9
  TtsProvider,
12
10
  VectorProvider,
@@ -104,6 +102,12 @@ export function agent(def: {
104
102
  * enable pipeline mode.
105
103
  */
106
104
  tts?: TtsProvider;
105
+ /**
106
+ * Pluggable S2S provider descriptor. When set, overrides the implicit
107
+ * AssemblyAI default. Mutually exclusive with the `stt`/`llm`/`tts`
108
+ * pipeline triple.
109
+ */
110
+ s2s?: S2sProvider;
107
111
  /** Pluggable KV backend. Falls back to platform default when omitted. */
108
112
  kv?: KvProvider;
109
113
  /** Pluggable Vector backend. Falls back to platform default when omitted. */
@@ -1,6 +1,6 @@
1
1
  // Copyright 2025 the AAI authors. MIT license.
2
2
  /**
3
- * Export surface snapshot tests for all four aai subpath exports.
3
+ * Export surface snapshot tests for all five aai subpath exports.
4
4
  *
5
5
  * These tests catch accidental export additions or removals. If a snapshot
6
6
  * breaks, it signals a potentially breaking API change that should be
@@ -28,4 +28,9 @@ describe("export surface stability", () => {
28
28
  const mod = await import("@alexkroman1/aai/runtime");
29
29
  expect(Object.keys(mod).sort()).toMatchSnapshot();
30
30
  });
31
+
32
+ test("@alexkroman1/aai/s2s export", async () => {
33
+ const mod = await import("@alexkroman1/aai/s2s");
34
+ expect(Object.keys(mod).sort()).toMatchSnapshot();
35
+ });
31
36
  });
package/sdk/kv.ts CHANGED
@@ -1,60 +1,36 @@
1
1
  // Copyright 2025 the AAI authors. MIT license.
2
- /**
3
- * Key-value storage interface and shared utilities.
4
- */
5
2
 
6
3
  /**
7
4
  * Async key-value store interface used by agents.
8
5
  *
9
- * Agents access the KV store via `ToolContext.kv`. Values are JSON-serialized and stored as
10
- * strings with an optional TTL.
6
+ * Agents access the KV store via `ToolContext.kv`. Values are JSON-serialized
7
+ * and stored as strings with an optional TTL.
11
8
  *
12
9
  * @example
13
10
  * ```ts
14
- * // Inside a tool execute function:
15
- * const myTool = {
16
- * description: "Save and retrieve data",
17
- * execute: async (_args: unknown, ctx: { kv: Kv }) => {
18
- * await ctx.kv.set("user:name", "Alice", { expireIn: 60_000 });
19
- * const name = await ctx.kv.get<string>("user:name");
20
- * return name; // "Alice"
21
- * },
22
- * };
11
+ * await ctx.kv.set("user:name", "Alice", { expireIn: 60_000 });
12
+ * const name = await ctx.kv.get<string>("user:name"); // "Alice"
23
13
  * ```
24
14
  *
25
15
  * @public
26
16
  */
27
17
  export type Kv = {
28
- /**
29
- * Get a value by key, or `null` if not found.
30
- *
31
- * @typeParam T - The expected type of the stored value.
32
- * @param key - The key to look up.
33
- * @returns The deserialized value, or `null` if the key does not exist
34
- * or has expired.
35
- */
18
+ /** Get a value by key. Returns `null` if missing or expired. */
36
19
  get<T = unknown>(key: string): Promise<T | null>;
37
20
  /**
38
- * Set a value, optionally with a TTL in milliseconds.
21
+ * Set a value, optionally with a TTL.
39
22
  *
40
- * @param key - The key to store the value under.
41
- * @param value - The value to store. Must be JSON-serializable.
42
- * @param options - Optional settings. `expireIn` sets the time-to-live in **milliseconds**
43
- * (e.g. `60_000` for 1 minute). The entry is automatically removed after this duration.
44
- * @throws Throws an Error if the serialized value exceeds 65,536 bytes.
23
+ * @param options.expireIn - Time-to-live in **milliseconds** (e.g. `60_000`
24
+ * for 1 minute). The entry is automatically removed after this duration.
25
+ * @throws If the serialized value exceeds 65,536 bytes.
45
26
  */
46
27
  set(key: string, value: unknown, options?: { expireIn?: number }): Promise<void>;
47
- /**
48
- * Delete one or more keys.
49
- *
50
- * @param keys - A single key or array of keys to delete. No-op for keys that do not exist.
51
- */
28
+ /** Delete one or more keys. No-op for keys that do not exist. */
52
29
  delete(keys: string | string[]): Promise<void>;
53
30
  /**
54
- * Close the KV store, releasing any resources (intervals, database handles).
55
- *
56
- * After calling `close()`, the store must not be used. This is a no-op
57
- * for implementations that hold no resources (e.g. in-memory stores).
31
+ * Release any held resources (intervals, database handles). After calling,
32
+ * the store must not be used. No-op for stateless implementations (e.g.
33
+ * in-memory stores).
58
34
  */
59
35
  close?(): void;
60
36
  };
@@ -1,6 +1,7 @@
1
1
  // Copyright 2025 the AAI authors. MIT license.
2
2
  import { expectTypeOf, test } from "vitest";
3
3
  import type { Manifest } from "./manifest.ts";
4
+ import type { S2sProvider } from "./providers.ts";
4
5
 
5
6
  test("Manifest.stt/llm/tts are optional", () => {
6
7
  expectTypeOf<Manifest["stt"]>().toBeNullable();
@@ -8,6 +9,10 @@ test("Manifest.stt/llm/tts are optional", () => {
8
9
  expectTypeOf<Manifest["tts"]>().toBeNullable();
9
10
  });
10
11
 
12
+ test("Manifest.s2s is optional and typed as S2sProvider", () => {
13
+ expectTypeOf<Manifest["s2s"]>().toEqualTypeOf<S2sProvider | undefined>();
14
+ });
15
+
11
16
  test("parseManifest return includes mode", () => {
12
17
  type Parsed = ReturnType<typeof import("./manifest.ts").parseManifest>;
13
18
  expectTypeOf<Parsed["mode"]>().toEqualTypeOf<"s2s" | "pipeline">();
@@ -9,6 +9,7 @@ import { anthropic } from "./providers/llm/anthropic.ts";
9
9
  import { assemblyAI } from "./providers/stt/assemblyai.ts";
10
10
  import { cartesia } from "./providers/tts/cartesia.ts";
11
11
  import { pinecone } from "./providers/vector/pinecone.ts";
12
+ import { assertProviderTriple } from "./providers.ts";
12
13
 
13
14
  describe("parseManifest", () => {
14
15
  test("minimal manifest requires only name", () => {
@@ -102,15 +103,16 @@ describe("parseManifest", () => {
102
103
  });
103
104
  });
104
105
 
105
- // ── Property-based tests ─────────────────────────────────────────────────
106
-
107
106
  describe("property: parseManifest", () => {
107
+ const optString = fc.option(fc.string(), { nil: undefined });
108
+ const optMaxSteps = fc.option(fc.integer({ min: 1, max: 100 }), { nil: undefined });
109
+
108
110
  test("valid manifests always parse", () => {
109
111
  const validManifestArb = fc.record({
110
112
  name: fc.string({ minLength: 1 }),
111
- systemPrompt: fc.option(fc.string(), { nil: undefined }),
112
- greeting: fc.option(fc.string(), { nil: undefined }),
113
- maxSteps: fc.option(fc.integer({ min: 1, max: 100 }), { nil: undefined }),
113
+ systemPrompt: optString,
114
+ greeting: optString,
115
+ maxSteps: optMaxSteps,
114
116
  toolChoice: fc.option(fc.constantFrom("auto" as const, "required" as const), {
115
117
  nil: undefined,
116
118
  }),
@@ -127,11 +129,10 @@ describe("property: parseManifest", () => {
127
129
  });
128
130
 
129
131
  test("missing name throws", () => {
130
- // Generate objects that never have a `name` field
131
132
  const noNameArb = fc.record({
132
- systemPrompt: fc.option(fc.string(), { nil: undefined }),
133
- greeting: fc.option(fc.string(), { nil: undefined }),
134
- maxSteps: fc.option(fc.integer({ min: 1, max: 100 }), { nil: undefined }),
133
+ systemPrompt: optString,
134
+ greeting: optString,
135
+ maxSteps: optMaxSteps,
135
136
  });
136
137
 
137
138
  fc.assert(
@@ -229,3 +230,54 @@ describe("parseManifest — mode classification", () => {
229
230
  ).toThrow(/stt, llm, and tts must be set together/);
230
231
  });
231
232
  });
233
+
234
+ describe("parseManifest s2s", () => {
235
+ test("parseManifest accepts s2s descriptor", () => {
236
+ const m = parseManifest({
237
+ name: "x",
238
+ s2s: { kind: "openai-realtime", options: { model: "gpt-realtime" } },
239
+ });
240
+ expect(m.s2s).toEqual({
241
+ kind: "openai-realtime",
242
+ options: { model: "gpt-realtime" },
243
+ });
244
+ expect(m.mode).toBe("s2s");
245
+ });
246
+
247
+ test("parseManifest rejects s2s combined with pipeline triple", () => {
248
+ expect(() =>
249
+ parseManifest({
250
+ name: "x",
251
+ s2s: { kind: "openai-realtime", options: {} },
252
+ stt: { kind: "assemblyai", options: {} },
253
+ llm: { kind: "openai", options: {} },
254
+ tts: { kind: "cartesia", options: {} },
255
+ }),
256
+ ).toThrow(/s2s.*pipeline|cannot.*together/i);
257
+ });
258
+ });
259
+
260
+ describe("assertProviderTriple with s2s", () => {
261
+ test("returns 's2s' when s2s descriptor is set and pipeline triple is empty", () => {
262
+ const s2s = { kind: "openai-realtime", options: {} };
263
+ expect(assertProviderTriple(undefined, undefined, undefined, s2s)).toBe("s2s");
264
+ });
265
+
266
+ test("returns 's2s' when nothing is set (default fallback)", () => {
267
+ expect(assertProviderTriple(undefined, undefined, undefined, undefined)).toBe("s2s");
268
+ });
269
+
270
+ test("returns 'pipeline' when triple is set and s2s is not", () => {
271
+ const stt = { kind: "x", options: {} };
272
+ expect(assertProviderTriple(stt, stt, stt, undefined)).toBe("pipeline");
273
+ });
274
+
275
+ test("rejects setting s2s alongside any pipeline field", () => {
276
+ const d = { kind: "x", options: {} };
277
+ expect(() => assertProviderTriple(d, undefined, undefined, d)).toThrow(
278
+ /s2s.*pipeline|cannot.*together/i,
279
+ );
280
+ expect(() => assertProviderTriple(undefined, d, undefined, d)).toThrow();
281
+ expect(() => assertProviderTriple(undefined, undefined, d, d)).toThrow();
282
+ });
283
+ });
package/sdk/manifest.ts CHANGED
@@ -12,6 +12,7 @@ import {
12
12
  assertProviderTriple,
13
13
  type KvProvider,
14
14
  type LlmProvider,
15
+ type S2sProvider,
15
16
  type SessionMode,
16
17
  type SttProvider,
17
18
  type TtsProvider,
@@ -70,13 +71,19 @@ export type Manifest = {
70
71
  * enable pipeline mode.
71
72
  */
72
73
  tts?: TtsProvider | undefined;
74
+ /**
75
+ * Pluggable S2S provider descriptor. When set, overrides the implicit
76
+ * AssemblyAI default. Mutually exclusive with the `stt`/`llm`/`tts`
77
+ * pipeline triple.
78
+ */
79
+ s2s?: S2sProvider | undefined;
73
80
  /** Pluggable KV backend descriptor. Falls back to platform default when omitted. */
74
81
  kv?: KvProvider | undefined;
75
82
  /** Pluggable Vector backend descriptor. Falls back to platform default when omitted. */
76
83
  vector?: VectorProvider | undefined;
77
84
  /**
78
85
  * Session mode derived from provider fields:
79
- * - `"s2s"` (default): AssemblyAI Streaming Speech-to-Speech path (no stt/llm/tts set).
86
+ * - `"s2s"`: speech-to-speech path (default when no stt/llm/tts set, or when `s2s` is set).
80
87
  * - `"pipeline"`: pluggable STT → LLM → TTS path (stt + llm + tts all set).
81
88
  */
82
89
  mode: SessionMode;
@@ -127,6 +134,7 @@ const ManifestSchema = z.object({
127
134
  stt: ProviderDescriptorSchema.optional(),
128
135
  llm: ProviderDescriptorSchema.optional(),
129
136
  tts: ProviderDescriptorSchema.optional(),
137
+ s2s: ProviderDescriptorSchema.optional(),
130
138
  kv: ProviderDescriptorSchema.optional(),
131
139
  vector: ProviderDescriptorSchema.optional(),
132
140
  });
@@ -142,24 +150,16 @@ const ManifestSchema = z.object({
142
150
  */
143
151
  export function parseManifest(input: unknown): Manifest {
144
152
  const parsed = ManifestSchema.parse(input);
145
- const mode = assertProviderTriple(parsed.stt, parsed.llm, parsed.tts);
153
+ const mode = assertProviderTriple(parsed.stt, parsed.llm, parsed.tts, parsed.s2s);
146
154
  return {
147
- name: parsed.name,
155
+ ...parsed,
148
156
  systemPrompt: parsed.systemPrompt ?? DEFAULT_SYSTEM_PROMPT,
149
157
  greeting: parsed.greeting ?? DEFAULT_GREETING,
150
- sttPrompt: parsed.sttPrompt,
151
158
  builtinTools: parsed.builtinTools ?? [],
152
159
  maxSteps: parsed.maxSteps ?? 5,
153
160
  toolChoice: parsed.toolChoice ?? "auto",
154
- idleTimeoutMs: parsed.idleTimeoutMs,
155
- theme: parsed.theme,
156
161
  tools: parsed.tools ?? {},
157
162
  allowedHosts: parsed.allowedHosts ?? [],
158
- stt: parsed.stt,
159
- llm: parsed.llm,
160
- tts: parsed.tts,
161
- kv: parsed.kv,
162
- vector: parsed.vector,
163
163
  mode,
164
164
  };
165
165
  }
@@ -9,6 +9,7 @@
9
9
  import { readdirSync, readFileSync } from "node:fs";
10
10
  import { join } from "node:path";
11
11
  import { describe, expect, test } from "vitest";
12
+ import type { ZodTypeAny } from "zod";
12
13
  import {
13
14
  DEFAULT_STT_SAMPLE_RATE,
14
15
  DEFAULT_TTS_SAMPLE_RATE,
@@ -21,20 +22,7 @@ import {
21
22
  SessionErrorCodeSchema,
22
23
  } from "./protocol.ts";
23
24
 
24
- // ── Load fixtures ─────────────────────────────────────────────────────────
25
-
26
25
  const FIXTURE_DIR = join(import.meta.dirname, "compat-fixtures");
27
- // Only load compat fixtures that have the expected schema-compat structure
28
- // (ServerMessage, ClientMessage, KvRequest, constants). Wire-format fixtures
29
- // like wire-v1.json use a different shape and are tested by wire.test.ts.
30
- const fixtureFiles = readdirSync(FIXTURE_DIR)
31
- .filter((f) => f.endsWith(".json"))
32
- .filter((f) => {
33
- const raw = readFileSync(join(FIXTURE_DIR, f), "utf-8");
34
- const parsed = JSON.parse(raw) as Record<string, unknown>;
35
- return "ServerMessage" in parsed && "ClientMessage" in parsed;
36
- })
37
- .sort();
38
26
 
39
27
  type Fixture = {
40
28
  version: number;
@@ -53,6 +41,16 @@ function loadFixture(filename: string): Fixture {
53
41
  return JSON.parse(readFileSync(join(FIXTURE_DIR, filename), "utf-8"));
54
42
  }
55
43
 
44
+ // Wire-format fixtures (e.g. wire-v1.json) use a different shape and live in
45
+ // wire.test.ts; filter them out by checking for the schema-compat structure.
46
+ const fixtureFiles = readdirSync(FIXTURE_DIR)
47
+ .filter((f) => f.endsWith(".json"))
48
+ .filter((f) => {
49
+ const parsed = loadFixture(f) as unknown as Record<string, unknown>;
50
+ return "ServerMessage" in parsed && "ClientMessage" in parsed;
51
+ })
52
+ .sort();
53
+
56
54
  function compatError(fixture: string, schema: string, msg: unknown, zodError: string): string {
57
55
  return [
58
56
  `PROTOCOL COMPATIBILITY BREAK (${fixture}, ${schema}):`,
@@ -68,104 +66,74 @@ function compatError(fixture: string, schema: string, msg: unknown, zodError: st
68
66
  ].join("\n");
69
67
  }
70
68
 
71
- /**
72
- * Check if a discriminated union schema accepts a given discriminant value
73
- * by testing whether a minimal object with that value passes the first
74
- * discriminant check (the full parse may fail, but the type/op is recognized).
75
- */
76
- function schemaAcceptsType(
77
- schema: typeof ServerMessageSchema | typeof ClientMessageSchema,
78
- type: string,
79
- ): boolean {
80
- // Parse a minimal object with just the discriminant. If the discriminant
81
- // is unrecognized, the error includes "invalid_union_discriminator" or
82
- // similar. Any other failure (missing fields) means the variant exists.
83
- const result = schema.safeParse({ type });
84
- if (result.success) return true;
85
- // Check error messages — the Zod issue code for discriminated union
86
- // mismatches varies across versions, so we check the message text.
87
- return !result.error.issues.some((i) => i.message.includes("Invalid discriminator"));
88
- }
89
-
90
- function kvSchemaAcceptsOp(op: string): boolean {
91
- const result = KvRequestSchema.safeParse({ op });
69
+ // A minimal-discriminant parse fails either with "Invalid discriminator" (variant
70
+ // removed) or with missing-field errors (variant exists). The Zod issue code for
71
+ // discriminated-union mismatches varies across versions, so match on message text.
72
+ function schemaAcceptsDiscriminant(schema: ZodTypeAny, value: Record<string, unknown>): boolean {
73
+ const result = schema.safeParse(value);
92
74
  if (result.success) return true;
93
75
  return !result.error.issues.some((i) => i.message.includes("Invalid discriminator"));
94
76
  }
95
77
 
96
- // ── Tests ─────────────────────────────────────────────────────────────────
78
+ type CompatGroup = {
79
+ label: string;
80
+ schema: ZodTypeAny;
81
+ messages: Record<string, unknown>[];
82
+ discriminant: "type" | "op";
83
+ };
97
84
 
98
85
  describe.each(fixtureFiles)("compat fixture: %s", (filename) => {
99
86
  const fixture = loadFixture(filename);
100
87
 
101
- // ── Backward compat: every fixture message must still parse ──────
102
-
103
- describe("ServerMessage backward compat", () => {
104
- test.each(
105
- fixture.ServerMessage.map((m, i) => [`${(m as { type: string }).type}#${i}`, m]),
106
- )("%s parses against current schema", (_label, msg) => {
107
- const result = ServerMessageSchema.safeParse(msg);
108
- if (!result.success) {
109
- throw new Error(compatError(filename, "ServerMessage", msg, result.error.message));
110
- }
111
- });
112
- });
113
-
114
- describe("ClientMessage backward compat", () => {
115
- test.each(
116
- fixture.ClientMessage.map((m, i) => [`${(m as { type: string }).type}#${i}`, m]),
117
- )("%s parses against current schema", (_label, msg) => {
118
- const result = ClientMessageSchema.safeParse(msg);
119
- if (!result.success) {
120
- throw new Error(compatError(filename, "ClientMessage", msg, result.error.message));
121
- }
88
+ const groups: CompatGroup[] = [
89
+ {
90
+ label: "ServerMessage",
91
+ schema: ServerMessageSchema,
92
+ messages: fixture.ServerMessage,
93
+ discriminant: "type",
94
+ },
95
+ {
96
+ label: "ClientMessage",
97
+ schema: ClientMessageSchema,
98
+ messages: fixture.ClientMessage,
99
+ discriminant: "type",
100
+ },
101
+ {
102
+ label: "KvRequest",
103
+ schema: KvRequestSchema,
104
+ messages: fixture.KvRequest,
105
+ discriminant: "op",
106
+ },
107
+ ];
108
+
109
+ for (const { label, schema, messages, discriminant } of groups) {
110
+ describe(`${label} backward compat`, () => {
111
+ test.each(
112
+ messages.map((m, i) => [`${m[discriminant] as string}#${i}`, m]),
113
+ )("%s parses against current schema", (_label, msg) => {
114
+ const result = schema.safeParse(msg);
115
+ if (!result.success) {
116
+ throw new Error(compatError(filename, label, msg, result.error.message));
117
+ }
118
+ });
122
119
  });
123
- });
124
-
125
- describe("KvRequest backward compat", () => {
126
- test.each(
127
- fixture.KvRequest.map((m, i) => [`${(m as { op: string }).op}#${i}`, m]),
128
- )("%s parses against current schema", (_label, msg) => {
129
- const result = KvRequestSchema.safeParse(msg);
130
- if (!result.success) {
131
- throw new Error(compatError(filename, "KvRequest", msg, result.error.message));
132
- }
133
- });
134
- });
135
-
136
- // ── Variant coverage: no types/ops removed ──────────────────────
120
+ }
137
121
 
138
122
  describe("variant coverage", () => {
139
- test("no ServerMessage types removed", () => {
140
- const fixtureTypes = new Set(fixture.ServerMessage.map((m) => (m as { type: string }).type));
141
- for (const t of fixtureTypes) {
142
- expect(
143
- schemaAcceptsType(ServerMessageSchema, t),
144
- `ServerMessage variant "${t}" was removed`,
145
- ).toBe(true);
146
- }
147
- });
148
-
149
- test("no ClientMessage types removed", () => {
150
- const fixtureTypes = new Set(fixture.ClientMessage.map((m) => (m as { type: string }).type));
151
- for (const t of fixtureTypes) {
152
- expect(
153
- schemaAcceptsType(ClientMessageSchema, t),
154
- `ClientMessage variant "${t}" was removed`,
155
- ).toBe(true);
156
- }
157
- });
158
-
159
- test("no KvRequest ops removed", () => {
160
- const fixtureOps = new Set(fixture.KvRequest.map((m) => (m as { op: string }).op));
161
- for (const op of fixtureOps) {
162
- expect(kvSchemaAcceptsOp(op), `KvRequest op "${op}" was removed`).toBe(true);
163
- }
164
- });
123
+ for (const { label, schema, messages, discriminant } of groups) {
124
+ const noun = discriminant === "type" ? "variant" : "op";
125
+ test(`no ${label} ${noun}s removed`, () => {
126
+ const seen = new Set(messages.map((m) => m[discriminant] as string));
127
+ for (const value of seen) {
128
+ expect(
129
+ schemaAcceptsDiscriminant(schema, { [discriminant]: value }),
130
+ `${label} ${noun} "${value}" was removed`,
131
+ ).toBe(true);
132
+ }
133
+ });
134
+ }
165
135
  });
166
136
 
167
- // ── Constants stability ─────────────────────────────────────────
168
-
169
137
  describe("constants stability", () => {
170
138
  test("DEFAULT_STT_SAMPLE_RATE unchanged", () => {
171
139
  expect(DEFAULT_STT_SAMPLE_RATE).toBe(fixture.constants.DEFAULT_STT_SAMPLE_RATE);
@@ -21,8 +21,6 @@ import {
21
21
  VectorRequestSchema,
22
22
  } from "./protocol.ts";
23
23
 
24
- // ── Constants ────────────────────────────────────────────────────────────
25
-
26
24
  describe("protocol constants", () => {
27
25
  test("sample rates", () => {
28
26
  expect(DEFAULT_STT_SAMPLE_RATE).toMatchInlineSnapshot("16000");
@@ -49,8 +47,6 @@ describe("protocol constants", () => {
49
47
  });
50
48
  });
51
49
 
52
- // ── Server → Client events (ClientEventSchema) ──────────────────────────
53
-
54
50
  describe("server→client event wire format", () => {
55
51
  const valid: [string, ClientEvent][] = [
56
52
  ["speech_started", { type: "speech_started" }],
@@ -77,8 +73,7 @@ describe("server→client event wire format", () => {
77
73
  ];
78
74
 
79
75
  test.each(valid)("%s parses successfully", (_label, event) => {
80
- const result = ClientEventSchema.safeParse(event);
81
- expect(result.success).toBe(true);
76
+ expect(ClientEventSchema.safeParse(event).success).toBe(true);
82
77
  });
83
78
 
84
79
  test("rejects unknown event type", () => {
@@ -108,8 +103,6 @@ describe("server→client event wire format", () => {
108
103
  });
109
104
  });
110
105
 
111
- // ── Client → Server messages (ClientMessageSchema) ──────────────────────
112
-
113
106
  describe("client→server message wire format", () => {
114
107
  const valid: [string, ClientMessage][] = [
115
108
  ["audio_ready", { type: "audio_ready" }],
@@ -128,8 +121,7 @@ describe("client→server message wire format", () => {
128
121
  ];
129
122
 
130
123
  test.each(valid)("%s parses successfully", (_label, msg) => {
131
- const result = ClientMessageSchema.safeParse(msg);
132
- expect(result.success).toBe(true);
124
+ expect(ClientMessageSchema.safeParse(msg).success).toBe(true);
133
125
  });
134
126
 
135
127
  test("rejects unknown message type", () => {
@@ -154,8 +146,6 @@ describe("client→server message wire format", () => {
154
146
  });
155
147
  });
156
148
 
157
- // ── ServerMessage union (type check) ────────────────────────────────────
158
-
159
149
  describe("ServerMessage type covers all variants", () => {
160
150
  test("config message shape", () => {
161
151
  const msg: ServerMessage = {
@@ -178,8 +168,6 @@ describe("ServerMessage type covers all variants", () => {
178
168
  });
179
169
  });
180
170
 
181
- // ── KvRequestSchema ─────────────────────────────────────────────────────
182
-
183
171
  describe("KvRequest wire format", () => {
184
172
  const valid = [
185
173
  ["get", { op: "get", key: "k1" }],
@@ -201,8 +189,6 @@ describe("KvRequest wire format", () => {
201
189
  });
202
190
  });
203
191
 
204
- // ── VectorRequestSchema ─────────────────────────────────────────────────
205
-
206
192
  describe("VectorRequest wire format", () => {
207
193
  const valid = [
208
194
  ["upsert", { op: "upsert", id: "doc-1", text: "hello" }],
@@ -70,17 +70,19 @@ describe("KvRequestSchema", () => {
70
70
  });
71
71
  });
72
72
 
73
+ const ERROR_CODES = [
74
+ "stt",
75
+ "llm",
76
+ "tts",
77
+ "tool",
78
+ "protocol",
79
+ "connection",
80
+ "audio",
81
+ "internal",
82
+ ] as const;
83
+
73
84
  describe("SessionErrorCodeSchema", () => {
74
- test.each([
75
- "stt",
76
- "llm",
77
- "tts",
78
- "tool",
79
- "protocol",
80
- "connection",
81
- "audio",
82
- "internal",
83
- ])("accepts valid code: %s", (code) => {
85
+ test.each(ERROR_CODES)("accepts valid code: %s", (code) => {
84
86
  expect(SessionErrorCodeSchema.safeParse(code).success).toBe(true);
85
87
  });
86
88
 
@@ -173,17 +175,6 @@ describe("property: lenientParse", () => {
173
175
  });
174
176
 
175
177
  test("valid ClientEvents round-trip through parse", () => {
176
- const errorCodes = [
177
- "stt",
178
- "llm",
179
- "tts",
180
- "tool",
181
- "protocol",
182
- "connection",
183
- "audio",
184
- "internal",
185
- ] as const;
186
-
187
178
  const speechStartedArb = fc.constant({ type: "speech_started" as const });
188
179
 
189
180
  const userTranscriptArb = fc.record({
@@ -193,7 +184,7 @@ describe("property: lenientParse", () => {
193
184
 
194
185
  const errorEventArb = fc.record({
195
186
  type: fc.constant("error" as const),
196
- code: fc.constantFrom(...errorCodes),
187
+ code: fc.constantFrom(...ERROR_CODES),
197
188
  message: fc.string(),
198
189
  });
199
190