@effect-uai/core 0.5.2 → 0.6.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 (123) hide show
  1. package/README.md +1 -1
  2. package/dist/{Items-BH8xUkoR.d.mts → Items-pXR5BAgW.d.mts} +12 -12
  3. package/dist/Items-pXR5BAgW.d.mts.map +1 -0
  4. package/dist/SandboxError-BvKypkJm.d.mts +158 -0
  5. package/dist/SandboxError-BvKypkJm.d.mts.map +1 -0
  6. package/dist/{Tool-87ViKCCO.d.mts → Tool-DP-AwKOf.d.mts} +11 -11
  7. package/dist/{Tool-87ViKCCO.d.mts.map → Tool-DP-AwKOf.d.mts.map} +1 -1
  8. package/dist/domain/Items.d.mts +2 -2
  9. package/dist/domain/Items.mjs +15 -15
  10. package/dist/domain/Items.mjs.map +1 -1
  11. package/dist/domain/Turn.d.mts +388 -2
  12. package/dist/domain/Turn.d.mts.map +1 -0
  13. package/dist/domain/Turn.mjs +13 -13
  14. package/dist/domain/Turn.mjs.map +1 -1
  15. package/dist/index.d.mts +11 -6
  16. package/dist/index.mjs +8 -3
  17. package/dist/language-model/LanguageModel.d.mts +5 -5
  18. package/dist/language-model/LanguageModel.d.mts.map +1 -1
  19. package/dist/language-model/LanguageModel.mjs.map +1 -1
  20. package/dist/loop/Loop.d.mts +37 -90
  21. package/dist/loop/Loop.d.mts.map +1 -1
  22. package/dist/loop/Loop.mjs +39 -93
  23. package/dist/loop/Loop.mjs.map +1 -1
  24. package/dist/loop/Loop.test.mjs +46 -54
  25. package/dist/loop/Loop.test.mjs.map +1 -1
  26. package/dist/sandbox/Memory.d.mts +53 -0
  27. package/dist/sandbox/Memory.d.mts.map +1 -0
  28. package/dist/sandbox/Memory.mjs +68 -0
  29. package/dist/sandbox/Memory.mjs.map +1 -0
  30. package/dist/sandbox/Sandbox.d.mts +717 -0
  31. package/dist/sandbox/Sandbox.d.mts.map +1 -0
  32. package/dist/sandbox/Sandbox.mjs +192 -0
  33. package/dist/sandbox/Sandbox.mjs.map +1 -0
  34. package/dist/sandbox/SandboxError.d.mts +2 -0
  35. package/dist/sandbox/SandboxError.mjs +89 -0
  36. package/dist/sandbox/SandboxError.mjs.map +1 -0
  37. package/dist/sandbox/SandboxImage.d.mts +28 -0
  38. package/dist/sandbox/SandboxImage.d.mts.map +1 -0
  39. package/dist/sandbox/SandboxImage.mjs +32 -0
  40. package/dist/sandbox/SandboxImage.mjs.map +1 -0
  41. package/dist/sandbox/SandboxNetwork.d.mts +34 -0
  42. package/dist/sandbox/SandboxNetwork.d.mts.map +1 -0
  43. package/dist/sandbox/SandboxNetwork.mjs +36 -0
  44. package/dist/sandbox/SandboxNetwork.mjs.map +1 -0
  45. package/dist/speech-synthesizer/SpeechSynthesizer.d.mts +98 -2
  46. package/dist/speech-synthesizer/SpeechSynthesizer.d.mts.map +1 -1
  47. package/dist/speech-synthesizer/SpeechSynthesizer.mjs +32 -2
  48. package/dist/speech-synthesizer/SpeechSynthesizer.mjs.map +1 -1
  49. package/dist/speech-synthesizer/SpeechSynthesizer.test.mjs +56 -2
  50. package/dist/speech-synthesizer/SpeechSynthesizer.test.mjs.map +1 -1
  51. package/dist/testing/MockProvider.d.mts +3 -3
  52. package/dist/testing/MockProvider.d.mts.map +1 -1
  53. package/dist/testing/MockProvider.mjs.map +1 -1
  54. package/dist/testing/MockSandbox.d.mts +604 -0
  55. package/dist/testing/MockSandbox.d.mts.map +1 -0
  56. package/dist/testing/MockSandbox.mjs +160 -0
  57. package/dist/testing/MockSandbox.mjs.map +1 -0
  58. package/dist/testing/MockSpeechSynthesizer.d.mts +26 -11
  59. package/dist/testing/MockSpeechSynthesizer.d.mts.map +1 -1
  60. package/dist/testing/MockSpeechSynthesizer.mjs +75 -51
  61. package/dist/testing/MockSpeechSynthesizer.mjs.map +1 -1
  62. package/dist/tool/{Resolvers.d.mts → Approval.d.mts} +27 -27
  63. package/dist/tool/Approval.d.mts.map +1 -0
  64. package/dist/tool/{Resolvers.mjs → Approval.mjs} +18 -18
  65. package/dist/tool/Approval.mjs.map +1 -0
  66. package/dist/tool/{Resolvers.test.mjs → Approval.test.mjs} +40 -40
  67. package/dist/tool/Approval.test.mjs.map +1 -0
  68. package/dist/tool/HistoryCheck.d.mts +6 -6
  69. package/dist/tool/HistoryCheck.d.mts.map +1 -1
  70. package/dist/tool/HistoryCheck.mjs +5 -5
  71. package/dist/tool/HistoryCheck.mjs.map +1 -1
  72. package/dist/tool/Tool.d.mts +2 -2
  73. package/dist/tool/Tool.mjs +4 -4
  74. package/dist/tool/Tool.mjs.map +1 -1
  75. package/dist/tool/ToolEvent.d.mts +19 -19
  76. package/dist/tool/ToolEvent.d.mts.map +1 -1
  77. package/dist/tool/ToolEvent.mjs +8 -8
  78. package/dist/tool/ToolEvent.mjs.map +1 -1
  79. package/dist/tool/{Outcome.d.mts → ToolResult.d.mts} +29 -29
  80. package/dist/tool/ToolResult.d.mts.map +1 -0
  81. package/dist/tool/ToolResult.mjs +61 -0
  82. package/dist/tool/ToolResult.mjs.map +1 -0
  83. package/dist/tool/Toolkit.d.mts +58 -26
  84. package/dist/tool/Toolkit.d.mts.map +1 -1
  85. package/dist/tool/Toolkit.mjs +62 -34
  86. package/dist/tool/Toolkit.mjs.map +1 -1
  87. package/dist/tool/Toolkit.test.mjs +13 -13
  88. package/dist/tool/Toolkit.test.mjs.map +1 -1
  89. package/package.json +31 -7
  90. package/src/domain/Items.ts +9 -9
  91. package/src/domain/Turn.ts +16 -16
  92. package/src/index.ts +7 -2
  93. package/src/language-model/LanguageModel.ts +3 -3
  94. package/src/loop/Loop.test.ts +93 -91
  95. package/src/loop/Loop.ts +72 -170
  96. package/src/sandbox/Memory.ts +128 -0
  97. package/src/sandbox/Sandbox.ts +639 -0
  98. package/src/sandbox/SandboxError.ts +140 -0
  99. package/src/sandbox/SandboxImage.ts +26 -0
  100. package/src/sandbox/SandboxNetwork.ts +34 -0
  101. package/src/speech-synthesizer/SpeechSynthesizer.test.ts +89 -0
  102. package/src/speech-synthesizer/SpeechSynthesizer.ts +124 -0
  103. package/src/testing/MockProvider.ts +21 -20
  104. package/src/testing/MockSandbox.ts +342 -0
  105. package/src/testing/MockSpeechSynthesizer.ts +137 -77
  106. package/src/tool/{Resolvers.test.ts → Approval.test.ts} +45 -54
  107. package/src/tool/{Resolvers.ts → Approval.ts} +25 -25
  108. package/src/tool/HistoryCheck.ts +10 -13
  109. package/src/tool/Tool.ts +13 -15
  110. package/src/tool/ToolEvent.ts +7 -7
  111. package/src/tool/{Outcome.ts → ToolResult.ts} +20 -20
  112. package/src/tool/Toolkit.test.ts +13 -13
  113. package/src/tool/Toolkit.ts +97 -73
  114. package/dist/Items-BH8xUkoR.d.mts.map +0 -1
  115. package/dist/Turn-0CwCAyVe.d.mts +0 -388
  116. package/dist/Turn-0CwCAyVe.d.mts.map +0 -1
  117. package/dist/tool/Outcome.d.mts.map +0 -1
  118. package/dist/tool/Outcome.mjs +0 -61
  119. package/dist/tool/Outcome.mjs.map +0 -1
  120. package/dist/tool/Resolvers.d.mts.map +0 -1
  121. package/dist/tool/Resolvers.mjs.map +0 -1
  122. package/dist/tool/Resolvers.test.mjs.map +0 -1
  123. /package/dist/tool/{Resolvers.test.d.mts → Approval.test.d.mts} +0 -0
@@ -0,0 +1,342 @@
1
+ import { Array as Arr, Data, Effect, Layer, Ref, Stream } from "effect"
2
+ import {
3
+ ExecEvent,
4
+ Sandbox,
5
+ SandboxCustomImage,
6
+ SandboxHostnameAllowlist,
7
+ SandboxKernelSession,
8
+ SandboxPauseResume,
9
+ SandboxPortExposure,
10
+ SandboxPty,
11
+ SandboxSecretInjection,
12
+ SandboxSnapshots,
13
+ SandboxVolumes,
14
+ type CommonCreateRequest,
15
+ type CommonExecRequest,
16
+ type CommonSpawnRequest,
17
+ type ExecResult,
18
+ type FileEntry,
19
+ type ProcessHandle,
20
+ SandboxId,
21
+ type SandboxInstance,
22
+ type SandboxRef,
23
+ type SandboxService,
24
+ SnapshotId,
25
+ VolumeId,
26
+ } from "../sandbox/Sandbox.js"
27
+ import * as SandboxError from "../sandbox/SandboxError.js"
28
+
29
+ // ---------------------------------------------------------------------------
30
+ // Call log
31
+ // ---------------------------------------------------------------------------
32
+
33
+ /**
34
+ * One entry per side-effecting method call. Tests filter via
35
+ * `Call.$is(...)` / `Call.$match`.
36
+ */
37
+ export type Call = Data.TaggedEnum<{
38
+ Create: { readonly request: CommonCreateRequest }
39
+ Attach: { readonly id: SandboxId }
40
+ Destroy: { readonly id: SandboxId }
41
+ Exec: { readonly id: SandboxId; readonly request: CommonExecRequest }
42
+ ExecStream: { readonly id: SandboxId; readonly request: CommonExecRequest }
43
+ Spawn: { readonly id: SandboxId; readonly request: CommonSpawnRequest }
44
+ FileRead: { readonly id: SandboxId; readonly path: string }
45
+ FileWrite: {
46
+ readonly id: SandboxId
47
+ readonly path: string
48
+ readonly contents: Uint8Array | string
49
+ }
50
+ FileRemove: { readonly id: SandboxId; readonly path: string }
51
+ FileMkdir: { readonly id: SandboxId; readonly path: string }
52
+ FileList: { readonly id: SandboxId; readonly path: string }
53
+ FileExists: { readonly id: SandboxId; readonly path: string }
54
+ ExposePort: { readonly id: SandboxId; readonly port: number }
55
+ SnapshotCreate: { readonly id: SandboxId; readonly name?: string }
56
+ SnapshotDestroy: { readonly id: SnapshotId }
57
+ VolumeCreate: { readonly name: string; readonly quotaBytes?: number }
58
+ VolumeDestroy: { readonly id: VolumeId }
59
+ }>
60
+
61
+ export const Call = Data.taggedEnum<Call>()
62
+
63
+ export type MockSandboxRecorder = {
64
+ readonly calls: ReadonlyArray<Call>
65
+ }
66
+
67
+ // ---------------------------------------------------------------------------
68
+ // Script
69
+ // ---------------------------------------------------------------------------
70
+
71
+ /**
72
+ * What this mock should hand back per call. Each method has its own
73
+ * scripted queue and a sensible default when the queue is empty.
74
+ * The filesystem is an in-memory mutable map seeded by `files`.
75
+ */
76
+ export type MockSandboxScript = {
77
+ readonly id?: string
78
+ readonly execs?: ReadonlyArray<ExecResult>
79
+ readonly streams?: ReadonlyArray<ReadonlyArray<ExecEvent>>
80
+ readonly spawns?: ReadonlyArray<ProcessHandle>
81
+ readonly files?: Readonly<Record<string, Uint8Array | string>>
82
+ readonly sandboxes?: ReadonlyArray<SandboxRef>
83
+ readonly snapshots?: ReadonlyArray<{ readonly id: SnapshotId; readonly name?: string }>
84
+ readonly volumes?: ReadonlyArray<{ readonly id: VolumeId; readonly name: string }>
85
+ readonly portUrl?: (port: number) => string
86
+ }
87
+
88
+ // ---------------------------------------------------------------------------
89
+ // Helpers
90
+ // ---------------------------------------------------------------------------
91
+
92
+ const defaultExec: ExecResult = { exitCode: 0, stdout: "", stderr: "", durationMs: 0 }
93
+
94
+ const toBytes = (contents: Uint8Array | string) =>
95
+ typeof contents === "string" ? new TextEncoder().encode(contents) : contents
96
+
97
+ const seedFs = (files: MockSandboxScript["files"]) =>
98
+ new Map(Object.entries(files ?? {}).map(([k, v]) => [k, toBytes(v)]))
99
+
100
+ const exhausted = (method: string, scripted: number, attempt: number) =>
101
+ new SandboxError.SandboxInvalidRequest({
102
+ provider: "mock",
103
+ reason: `MockSandbox exhausted: ${scripted} ${method}(s) scripted, but call ${attempt} was made`,
104
+ })
105
+
106
+ const consume = <A>(
107
+ cursor: Ref.Ref<number>,
108
+ scripted: ReadonlyArray<A>,
109
+ onMiss: (attempt: number) => Effect.Effect<A, SandboxError.SandboxError>,
110
+ ) =>
111
+ Ref.getAndUpdate(cursor, (n) => n + 1).pipe(
112
+ Effect.flatMap((i) => (i < scripted.length ? Effect.succeed(scripted[i]!) : onMiss(i + 1))),
113
+ )
114
+
115
+ // ---------------------------------------------------------------------------
116
+ // Service builder
117
+ // ---------------------------------------------------------------------------
118
+
119
+ const buildInstance = (
120
+ id: SandboxId,
121
+ script: MockSandboxScript,
122
+ fs: Ref.Ref<Map<string, Uint8Array>>,
123
+ execCursor: Ref.Ref<number>,
124
+ streamCursor: Ref.Ref<number>,
125
+ spawnCursor: Ref.Ref<number>,
126
+ record: (call: Call) => Effect.Effect<void>,
127
+ ): SandboxInstance => ({
128
+ id,
129
+
130
+ exec: (request) =>
131
+ record(Call.Exec({ id, request })).pipe(
132
+ Effect.andThen(consume(execCursor, script.execs ?? [], () => Effect.succeed(defaultExec))),
133
+ ),
134
+
135
+ execStream: (request) =>
136
+ Stream.unwrap(
137
+ record(Call.ExecStream({ id, request })).pipe(
138
+ Effect.andThen(
139
+ consume(streamCursor, script.streams ?? [], () =>
140
+ Effect.succeed<ReadonlyArray<ExecEvent>>([
141
+ ExecEvent.Complete({ exitCode: 0, durationMs: 0 }),
142
+ ]),
143
+ ),
144
+ ),
145
+ Effect.map(
146
+ (events) =>
147
+ Stream.fromIterable(events) as Stream.Stream<ExecEvent, SandboxError.SandboxError>,
148
+ ),
149
+ ),
150
+ ),
151
+
152
+ spawn: (request) =>
153
+ record(Call.Spawn({ id, request })).pipe(
154
+ Effect.andThen(
155
+ consume(spawnCursor, script.spawns ?? [], (n) =>
156
+ Effect.fail(exhausted("spawn", (script.spawns ?? []).length, n)),
157
+ ),
158
+ ),
159
+ Effect.tap((handle) => Effect.addFinalizer(() => Effect.orDie(handle.kill))),
160
+ ),
161
+
162
+ files: {
163
+ read: (path) =>
164
+ record(Call.FileRead({ id, path })).pipe(
165
+ Effect.andThen(Ref.get(fs)),
166
+ Effect.flatMap((map) =>
167
+ map.has(path)
168
+ ? Effect.succeed(map.get(path)!)
169
+ : Effect.fail(
170
+ new SandboxError.SandboxExecFailed({
171
+ provider: "mock",
172
+ reason: `ENOENT: ${path}`,
173
+ raw: undefined,
174
+ }),
175
+ ),
176
+ ),
177
+ ),
178
+
179
+ write: (path, contents) =>
180
+ record(Call.FileWrite({ id, path, contents })).pipe(
181
+ Effect.andThen(Ref.update(fs, (m) => new Map(m).set(path, toBytes(contents)))),
182
+ ),
183
+
184
+ remove: (path) =>
185
+ record(Call.FileRemove({ id, path })).pipe(
186
+ Effect.andThen(
187
+ Ref.update(fs, (m) => {
188
+ const next = new Map(m)
189
+ next.delete(path)
190
+ return next
191
+ }),
192
+ ),
193
+ ),
194
+
195
+ mkdir: (path) => record(Call.FileMkdir({ id, path })),
196
+
197
+ list: (path) =>
198
+ record(Call.FileList({ id, path })).pipe(
199
+ Effect.andThen(Ref.get(fs)),
200
+ Effect.map((map) => {
201
+ const prefix = path.endsWith("/") ? path : `${path}/`
202
+ const out: Array<FileEntry> = []
203
+ for (const k of map.keys()) {
204
+ if (k.startsWith(prefix)) out.push({ path: k, kind: "file" })
205
+ }
206
+ return out
207
+ }),
208
+ ),
209
+
210
+ exists: (path) =>
211
+ record(Call.FileExists({ id, path })).pipe(
212
+ Effect.andThen(Ref.get(fs)),
213
+ Effect.map((map) => map.has(path)),
214
+ ),
215
+ },
216
+ })
217
+
218
+ const buildService = (
219
+ script: MockSandboxScript,
220
+ record: (call: Call) => Effect.Effect<void>,
221
+ ): SandboxService => {
222
+ const id = SandboxId(script.id ?? "mock-sandbox")
223
+
224
+ const acquireInstance = (sandboxId: SandboxId, onClose: Effect.Effect<void>) =>
225
+ Effect.gen(function* () {
226
+ const fs = yield* Ref.make(seedFs(script.files))
227
+ const execCursor = yield* Ref.make(0)
228
+ const streamCursor = yield* Ref.make(0)
229
+ const spawnCursor = yield* Ref.make(0)
230
+ yield* Effect.addFinalizer(() => onClose)
231
+ return buildInstance(sandboxId, script, fs, execCursor, streamCursor, spawnCursor, record)
232
+ })
233
+
234
+ return {
235
+ create: (request) =>
236
+ record(Call.Create({ request })).pipe(
237
+ Effect.andThen(acquireInstance(id, record(Call.Destroy({ id })))),
238
+ ),
239
+
240
+ attach: (sandboxId) =>
241
+ record(Call.Attach({ id: sandboxId })).pipe(
242
+ // Attach detaches on scope close (no destroy).
243
+ Effect.andThen(acquireInstance(sandboxId, Effect.void)),
244
+ ),
245
+
246
+ list: Effect.succeed(script.sandboxes ?? []),
247
+
248
+ destroy: (sandboxId) => record(Call.Destroy({ id: sandboxId })),
249
+
250
+ snapshots: {
251
+ create: (_from, name) =>
252
+ record(
253
+ name === undefined ? Call.SnapshotCreate({ id }) : Call.SnapshotCreate({ id, name }),
254
+ ).pipe(
255
+ Effect.as(SnapshotId(`mock-snapshot-${name ?? Math.random().toString(36).slice(2, 8)}`)),
256
+ ),
257
+ destroy: (sid) => record(Call.SnapshotDestroy({ id: sid })),
258
+ list: Effect.succeed(script.snapshots ?? []),
259
+ },
260
+
261
+ volumes: {
262
+ create: (name, options) =>
263
+ record(
264
+ options?.quotaBytes === undefined
265
+ ? Call.VolumeCreate({ name })
266
+ : Call.VolumeCreate({ name, quotaBytes: options.quotaBytes }),
267
+ ).pipe(Effect.as(VolumeId(`mock-volume-${name}`))),
268
+ destroy: (vid) => record(Call.VolumeDestroy({ id: vid })),
269
+ list: Effect.succeed(script.volumes ?? []),
270
+ },
271
+
272
+ ports: {
273
+ expose: (instance, port) =>
274
+ record(Call.ExposePort({ id: instance.id, port })).pipe(
275
+ Effect.as({ url: (script.portUrl ?? ((p) => `http://mock.local:${p}`))(port) }),
276
+ ),
277
+ },
278
+ }
279
+ }
280
+
281
+ // ---------------------------------------------------------------------------
282
+ // Recorder
283
+ // ---------------------------------------------------------------------------
284
+
285
+ const makeRecorderUnsafe = () => {
286
+ const ref = Ref.makeUnsafe<ReadonlyArray<Call>>([])
287
+ return {
288
+ record: (call: Call) => Ref.update(ref, Arr.append(call)),
289
+ recorder: Ref.get(ref).pipe(Effect.map((calls): MockSandboxRecorder => ({ calls }))),
290
+ }
291
+ }
292
+
293
+ // ---------------------------------------------------------------------------
294
+ // Public API
295
+ // ---------------------------------------------------------------------------
296
+
297
+ const allCapabilities = Layer.mergeAll(
298
+ Layer.succeed(SandboxHostnameAllowlist, undefined),
299
+ Layer.succeed(SandboxSecretInjection, undefined),
300
+ Layer.succeed(SandboxSnapshots, undefined),
301
+ Layer.succeed(SandboxPauseResume, undefined),
302
+ Layer.succeed(SandboxVolumes, undefined),
303
+ Layer.succeed(SandboxKernelSession, undefined),
304
+ Layer.succeed(SandboxPty, undefined),
305
+ Layer.succeed(SandboxCustomImage, undefined),
306
+ Layer.succeed(SandboxPortExposure, undefined),
307
+ )
308
+
309
+ /**
310
+ * Layer that registers a `MockSandbox` against the `Sandbox` tag, with
311
+ * every capability marker shipped — use when the code under test
312
+ * exercises any of them.
313
+ */
314
+ export const layer = (script: MockSandboxScript = {}) => {
315
+ const { record, recorder } = makeRecorderUnsafe()
316
+ return {
317
+ layer: Layer.merge(Layer.succeed(Sandbox, buildService(script, record)), allCapabilities),
318
+ recorder,
319
+ }
320
+ }
321
+
322
+ /**
323
+ * Layer with the `Sandbox` service but no capability markers. Use to
324
+ * verify that consumers calling `Sandbox.snapshot` / `createVolume` /
325
+ * etc. fail at `Effect.provide` with a type error.
326
+ */
327
+ export const layerWithoutCapabilities = (script: MockSandboxScript = {}) => {
328
+ const { record, recorder } = makeRecorderUnsafe()
329
+ return {
330
+ layer: Layer.succeed(Sandbox, buildService(script, record)),
331
+ recorder,
332
+ }
333
+ }
334
+
335
+ /** Bare service value + recorder. Use with `Effect.provideService`. */
336
+ export const make = (script: MockSandboxScript = {}) => {
337
+ const { record, recorder } = makeRecorderUnsafe()
338
+ return {
339
+ service: buildService(script, record),
340
+ recorder,
341
+ }
342
+ }
@@ -2,17 +2,21 @@ import { Effect, Layer, Ref, Stream } from "effect"
2
2
  import type { AudioBlob, AudioChunk } from "../domain/Audio.js"
3
3
  import * as AiError from "../domain/AiError.js"
4
4
  import {
5
- SpeechSynthesizer,
6
- TtsIncrementalText,
7
5
  type CommonStreamSynthesizeRequest,
6
+ type CommonSynthesizeDialogueRequest,
8
7
  type CommonSynthesizeRequest,
8
+ MultiSpeakerTts,
9
+ SpeechSynthesizer,
9
10
  type SpeechSynthesizerService,
11
+ TtsIncrementalText,
10
12
  } from "../speech-synthesizer/SpeechSynthesizer.js"
11
13
 
12
14
  export type MockSynthesizerRecorder = {
13
15
  readonly synthesizeCalls: ReadonlyArray<CommonSynthesizeRequest>
14
16
  readonly streamSynthesisCalls: ReadonlyArray<CommonSynthesizeRequest>
15
17
  readonly streamSynthesisFromCalls: ReadonlyArray<CommonStreamSynthesizeRequest>
18
+ readonly synthesizeDialogueCalls: ReadonlyArray<CommonSynthesizeDialogueRequest>
19
+ readonly streamSynthesizeDialogueCalls: ReadonlyArray<CommonSynthesizeDialogueRequest>
16
20
  }
17
21
 
18
22
  export type MockSynthesizerScript = {
@@ -22,32 +26,40 @@ export type MockSynthesizerScript = {
22
26
  readonly streamSynthesisChunks?: ReadonlyArray<ReadonlyArray<AudioChunk>>
23
27
  /** One chunk-list per `streamSynthesisFrom` call, consumed in order. */
24
28
  readonly streamSynthesisFromChunks?: ReadonlyArray<ReadonlyArray<AudioChunk>>
29
+ /** One blob per `synthesizeDialogue` call, consumed in order. */
30
+ readonly dialogueBlobs?: ReadonlyArray<AudioBlob>
31
+ /** One chunk-list per `streamSynthesizeDialogue` call, consumed in order. */
32
+ readonly streamSynthesizeDialogueChunks?: ReadonlyArray<ReadonlyArray<AudioChunk>>
25
33
  }
26
34
 
27
- const makeService = (
28
- script: MockSynthesizerScript,
29
- record: {
30
- readonly synthesize: (req: CommonSynthesizeRequest) => Effect.Effect<void>
31
- readonly streamSynthesis: (req: CommonSynthesizeRequest) => Effect.Effect<void>
32
- readonly streamSynthesisFrom: (req: CommonStreamSynthesizeRequest) => Effect.Effect<void>
33
- },
34
- ) =>
35
+ type Record = {
36
+ readonly synthesize: (req: CommonSynthesizeRequest) => Effect.Effect<void>
37
+ readonly streamSynthesis: (req: CommonSynthesizeRequest) => Effect.Effect<void>
38
+ readonly streamSynthesisFrom: (req: CommonStreamSynthesizeRequest) => Effect.Effect<void>
39
+ readonly synthesizeDialogue: (req: CommonSynthesizeDialogueRequest) => Effect.Effect<void>
40
+ readonly streamSynthesizeDialogue: (req: CommonSynthesizeDialogueRequest) => Effect.Effect<void>
41
+ }
42
+
43
+ const exhausted = (label: string, scripted: number, call: number): AiError.AiError =>
44
+ new AiError.InvalidRequest({
45
+ provider: "mock",
46
+ raw: `MockSpeechSynthesizer exhausted: ${scripted} ${label} scripted, but call ${call} was made`,
47
+ })
48
+
49
+ const makeService = (script: MockSynthesizerScript, record: Record) =>
35
50
  Effect.gen(function* () {
36
51
  const bCursor = yield* Ref.make(0)
37
52
  const ssCursor = yield* Ref.make(0)
38
53
  const ssfCursor = yield* Ref.make(0)
54
+ const sdCursor = yield* Ref.make(0)
55
+ const ssdCursor = yield* Ref.make(0)
39
56
  const service: SpeechSynthesizerService = {
40
57
  synthesize: (request) =>
41
58
  Effect.gen(function* () {
42
59
  yield* record.synthesize(request)
43
60
  const i = yield* Ref.getAndUpdate(bCursor, (n) => n + 1)
44
61
  const scripted = script.blobs ?? []
45
- if (i >= scripted.length) {
46
- return yield* new AiError.InvalidRequest({
47
- provider: "mock",
48
- raw: `MockSpeechSynthesizer exhausted: ${scripted.length} blobs scripted, but call ${i + 1} was made`,
49
- })
50
- }
62
+ if (i >= scripted.length) return yield* exhausted("blobs", scripted.length, i + 1)
51
63
  return scripted[i]!
52
64
  }),
53
65
  streamSynthesis: (request) =>
@@ -57,12 +69,7 @@ const makeService = (
57
69
  const i = yield* Ref.getAndUpdate(ssCursor, (n) => n + 1)
58
70
  const scripted = script.streamSynthesisChunks ?? []
59
71
  if (i >= scripted.length) {
60
- return Stream.fail(
61
- new AiError.InvalidRequest({
62
- provider: "mock",
63
- raw: `MockSpeechSynthesizer exhausted: ${scripted.length} streamSynthesis lists scripted, but call ${i + 1} was made`,
64
- }),
65
- )
72
+ return Stream.fail(exhausted("streamSynthesis lists", scripted.length, i + 1))
66
73
  }
67
74
  return Stream.fromIterable(scripted[i]!)
68
75
  }),
@@ -77,87 +84,140 @@ const makeService = (
77
84
  const i = yield* Ref.getAndUpdate(ssfCursor, (n) => n + 1)
78
85
  const scripted = script.streamSynthesisFromChunks ?? []
79
86
  if (i >= scripted.length) {
80
- const exhausted: Stream.Stream<AudioChunk, AiError.AiError | E, R> = Stream.fail(
81
- new AiError.InvalidRequest({
82
- provider: "mock",
83
- raw: `MockSpeechSynthesizer exhausted: ${scripted.length} streamSynthesisFrom lists scripted, but call ${i + 1} was made`,
84
- }),
87
+ const fail: Stream.Stream<AudioChunk, AiError.AiError | E, R> = Stream.fail(
88
+ exhausted("streamSynthesisFrom lists", scripted.length, i + 1),
85
89
  )
86
- return exhausted
90
+ return fail
87
91
  }
88
- // Drain the input text fully before emitting scripted audio chunks,
89
- // so consumers can assert on what text was pushed.
90
92
  return Stream.drain(textIn).pipe(Stream.concat(Stream.fromIterable(scripted[i]!)))
91
93
  }),
92
94
  ),
95
+ synthesizeDialogue: (request) =>
96
+ Effect.gen(function* () {
97
+ yield* record.synthesizeDialogue(request)
98
+ const i = yield* Ref.getAndUpdate(sdCursor, (n) => n + 1)
99
+ const scripted = script.dialogueBlobs ?? []
100
+ if (i >= scripted.length) {
101
+ return yield* exhausted("dialogueBlobs", scripted.length, i + 1)
102
+ }
103
+ return scripted[i]!
104
+ }),
105
+ streamSynthesizeDialogue: (request) =>
106
+ Stream.unwrap(
107
+ Effect.gen(function* () {
108
+ yield* record.streamSynthesizeDialogue(request)
109
+ const i = yield* Ref.getAndUpdate(ssdCursor, (n) => n + 1)
110
+ const scripted = script.streamSynthesizeDialogueChunks ?? []
111
+ if (i >= scripted.length) {
112
+ return Stream.fail(
113
+ exhausted("streamSynthesizeDialogue lists", scripted.length, i + 1),
114
+ )
115
+ }
116
+ return Stream.fromIterable(scripted[i]!)
117
+ }),
118
+ ),
93
119
  }
94
120
  return service
95
121
  })
96
122
 
123
+ type CallBuffers = {
124
+ readonly bCalls: Ref.Ref<ReadonlyArray<CommonSynthesizeRequest>>
125
+ readonly ssCalls: Ref.Ref<ReadonlyArray<CommonSynthesizeRequest>>
126
+ readonly ssfCalls: Ref.Ref<ReadonlyArray<CommonStreamSynthesizeRequest>>
127
+ readonly sdCalls: Ref.Ref<ReadonlyArray<CommonSynthesizeDialogueRequest>>
128
+ readonly ssdCalls: Ref.Ref<ReadonlyArray<CommonSynthesizeDialogueRequest>>
129
+ }
130
+
131
+ const newBuffers = (): CallBuffers => ({
132
+ bCalls: Ref.makeUnsafe<ReadonlyArray<CommonSynthesizeRequest>>([]),
133
+ ssCalls: Ref.makeUnsafe<ReadonlyArray<CommonSynthesizeRequest>>([]),
134
+ ssfCalls: Ref.makeUnsafe<ReadonlyArray<CommonStreamSynthesizeRequest>>([]),
135
+ sdCalls: Ref.makeUnsafe<ReadonlyArray<CommonSynthesizeDialogueRequest>>([]),
136
+ ssdCalls: Ref.makeUnsafe<ReadonlyArray<CommonSynthesizeDialogueRequest>>([]),
137
+ })
138
+
139
+ const recordFor = (b: CallBuffers): Record => ({
140
+ synthesize: (req) => Ref.update(b.bCalls, (xs) => [...xs, req]),
141
+ streamSynthesis: (req) => Ref.update(b.ssCalls, (xs) => [...xs, req]),
142
+ streamSynthesisFrom: (req) => Ref.update(b.ssfCalls, (xs) => [...xs, req]),
143
+ synthesizeDialogue: (req) => Ref.update(b.sdCalls, (xs) => [...xs, req]),
144
+ streamSynthesizeDialogue: (req) => Ref.update(b.ssdCalls, (xs) => [...xs, req]),
145
+ })
146
+
147
+ const recorderEffect = (b: CallBuffers): Effect.Effect<MockSynthesizerRecorder> =>
148
+ Effect.gen(function* () {
149
+ const synthesizeCalls = yield* Ref.get(b.bCalls)
150
+ const streamSynthesisCalls = yield* Ref.get(b.ssCalls)
151
+ const streamSynthesisFromCalls = yield* Ref.get(b.ssfCalls)
152
+ const synthesizeDialogueCalls = yield* Ref.get(b.sdCalls)
153
+ const streamSynthesizeDialogueCalls = yield* Ref.get(b.ssdCalls)
154
+ return {
155
+ synthesizeCalls,
156
+ streamSynthesisCalls,
157
+ streamSynthesisFromCalls,
158
+ synthesizeDialogueCalls,
159
+ streamSynthesizeDialogueCalls,
160
+ }
161
+ })
162
+
97
163
  /**
98
- * Layer providing the `SpeechSynthesizer` service AND the
99
- * `TtsIncrementalText` capability marker. Use for the common case
100
- * where code under test exercises `streamSynthesisFrom`.
164
+ * Layer providing `SpeechSynthesizer` plus both capability markers
165
+ * (`TtsIncrementalText` and `MultiSpeakerTts`). Use for the common case
166
+ * where code under test exercises any of the optional methods.
101
167
  */
102
168
  export const layer = (
103
169
  script: MockSynthesizerScript,
104
170
  ): {
105
- readonly layer: Layer.Layer<SpeechSynthesizer | TtsIncrementalText>
171
+ readonly layer: Layer.Layer<SpeechSynthesizer | TtsIncrementalText | MultiSpeakerTts>
106
172
  readonly recorder: Effect.Effect<MockSynthesizerRecorder>
107
173
  } => {
108
- const bCalls = Ref.makeUnsafe<ReadonlyArray<CommonSynthesizeRequest>>([])
109
- const ssCalls = Ref.makeUnsafe<ReadonlyArray<CommonSynthesizeRequest>>([])
110
- const ssfCalls = Ref.makeUnsafe<ReadonlyArray<CommonStreamSynthesizeRequest>>([])
111
- const synthesizerLayer = Layer.effect(
112
- SpeechSynthesizer,
113
- makeService(script, {
114
- synthesize: (req) => Ref.update(bCalls, (xs) => [...xs, req]),
115
- streamSynthesis: (req) => Ref.update(ssCalls, (xs) => [...xs, req]),
116
- streamSynthesisFrom: (req) => Ref.update(ssfCalls, (xs) => [...xs, req]),
117
- }),
174
+ const buffers = newBuffers()
175
+ const synthesizerLayer = Layer.effect(SpeechSynthesizer, makeService(script, recordFor(buffers)))
176
+ const live = Layer.mergeAll(
177
+ synthesizerLayer,
178
+ Layer.succeed(TtsIncrementalText, undefined),
179
+ Layer.succeed(MultiSpeakerTts, undefined),
118
180
  )
119
- const live = Layer.merge(synthesizerLayer, Layer.succeed(TtsIncrementalText, undefined))
120
- return {
121
- layer: live,
122
- recorder: Effect.gen(function* () {
123
- const synthesizeCalls = yield* Ref.get(bCalls)
124
- const streamSynthesisCalls = yield* Ref.get(ssCalls)
125
- const streamSynthesisFromCalls = yield* Ref.get(ssfCalls)
126
- return { synthesizeCalls, streamSynthesisCalls, streamSynthesisFromCalls }
127
- }),
128
- }
181
+ return { layer: live, recorder: recorderEffect(buffers) }
129
182
  }
130
183
 
131
184
  /**
132
185
  * Variant that omits the `TtsIncrementalText` marker — simulates a
133
- * provider without incremental-text-in support (e.g. OpenAI, AWS
134
- * Polly non-Generative). Calls to `streamSynthesisFrom` in code under
135
- * test should be a compile-time error.
186
+ * provider without incremental-text-in support (e.g. OpenAI). Calls to
187
+ * `streamSynthesisFrom` in code under test should be a compile-time
188
+ * error. `MultiSpeakerTts` is still provided.
136
189
  */
137
190
  export const layerWithoutIncremental = (
138
191
  script: MockSynthesizerScript,
139
192
  ): {
140
- readonly layer: Layer.Layer<SpeechSynthesizer>
193
+ readonly layer: Layer.Layer<SpeechSynthesizer | MultiSpeakerTts>
194
+ readonly recorder: Effect.Effect<MockSynthesizerRecorder>
195
+ } => {
196
+ const buffers = newBuffers()
197
+ const live = Layer.merge(
198
+ Layer.effect(SpeechSynthesizer, makeService(script, recordFor(buffers))),
199
+ Layer.succeed(MultiSpeakerTts, undefined),
200
+ )
201
+ return { layer: live, recorder: recorderEffect(buffers) }
202
+ }
203
+
204
+ /**
205
+ * Variant that omits the `MultiSpeakerTts` marker — simulates a
206
+ * provider without multi-speaker dialogue support (e.g. OpenAI,
207
+ * Inworld). Calls to `synthesizeDialogue` /
208
+ * `streamSynthesizeDialogue` in code under test should be a
209
+ * compile-time error.
210
+ */
211
+ export const layerWithoutMultiSpeaker = (
212
+ script: MockSynthesizerScript,
213
+ ): {
214
+ readonly layer: Layer.Layer<SpeechSynthesizer | TtsIncrementalText>
141
215
  readonly recorder: Effect.Effect<MockSynthesizerRecorder>
142
216
  } => {
143
- const bCalls = Ref.makeUnsafe<ReadonlyArray<CommonSynthesizeRequest>>([])
144
- const ssCalls = Ref.makeUnsafe<ReadonlyArray<CommonSynthesizeRequest>>([])
145
- const ssfCalls = Ref.makeUnsafe<ReadonlyArray<CommonStreamSynthesizeRequest>>([])
146
- const live = Layer.effect(
147
- SpeechSynthesizer,
148
- makeService(script, {
149
- synthesize: (req) => Ref.update(bCalls, (xs) => [...xs, req]),
150
- streamSynthesis: (req) => Ref.update(ssCalls, (xs) => [...xs, req]),
151
- streamSynthesisFrom: (req) => Ref.update(ssfCalls, (xs) => [...xs, req]),
152
- }),
217
+ const buffers = newBuffers()
218
+ const live = Layer.merge(
219
+ Layer.effect(SpeechSynthesizer, makeService(script, recordFor(buffers))),
220
+ Layer.succeed(TtsIncrementalText, undefined),
153
221
  )
154
- return {
155
- layer: live,
156
- recorder: Effect.gen(function* () {
157
- const synthesizeCalls = yield* Ref.get(bCalls)
158
- const streamSynthesisCalls = yield* Ref.get(ssCalls)
159
- const streamSynthesisFromCalls = yield* Ref.get(ssfCalls)
160
- return { synthesizeCalls, streamSynthesisCalls, streamSynthesisFromCalls }
161
- }),
162
- }
222
+ return { layer: live, recorder: recorderEffect(buffers) }
163
223
  }