@effect-uai/core 0.3.0 → 0.5.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 (169) hide show
  1. package/dist/{AiError-CBuPHVKA.d.mts → AiError-CAX_48RU.d.mts} +27 -5
  2. package/dist/{AiError-CBuPHVKA.d.mts.map → AiError-CAX_48RU.d.mts.map} +1 -1
  3. package/dist/Audio-BfCTGnH3.d.mts +61 -0
  4. package/dist/Audio-BfCTGnH3.d.mts.map +1 -0
  5. package/dist/{Image-BZmKfIdq.d.mts → Image-HNmMpMTh.d.mts} +1 -1
  6. package/dist/{Image-BZmKfIdq.d.mts.map → Image-HNmMpMTh.d.mts.map} +1 -1
  7. package/dist/{Items-CB8Bo3FI.d.mts → Items-DqbaJoz7.d.mts} +5 -5
  8. package/dist/{Items-CB8Bo3FI.d.mts.map → Items-DqbaJoz7.d.mts.map} +1 -1
  9. package/dist/{StructuredFormat-BWq5Hd1O.d.mts → StructuredFormat-BbN4dosH.d.mts} +11 -4
  10. package/dist/StructuredFormat-BbN4dosH.d.mts.map +1 -0
  11. package/dist/{Tool-DjVufH7i.d.mts → Tool-Y0__Py1H.d.mts} +20 -4
  12. package/dist/Tool-Y0__Py1H.d.mts.map +1 -0
  13. package/dist/Turn-ChbL2foc.d.mts +388 -0
  14. package/dist/Turn-ChbL2foc.d.mts.map +1 -0
  15. package/dist/domain/AiError.d.mts +2 -2
  16. package/dist/domain/AiError.mjs +19 -3
  17. package/dist/domain/AiError.mjs.map +1 -1
  18. package/dist/domain/Audio.d.mts +2 -0
  19. package/dist/domain/Audio.mjs +14 -0
  20. package/dist/domain/Audio.mjs.map +1 -0
  21. package/dist/domain/Image.d.mts +1 -1
  22. package/dist/domain/Items.d.mts +1 -1
  23. package/dist/domain/Items.mjs +1 -1
  24. package/dist/domain/Items.mjs.map +1 -1
  25. package/dist/domain/Music.d.mts +116 -0
  26. package/dist/domain/Music.d.mts.map +1 -0
  27. package/dist/domain/Music.mjs +29 -0
  28. package/dist/domain/Music.mjs.map +1 -0
  29. package/dist/domain/Transcript.d.mts +95 -0
  30. package/dist/domain/Transcript.d.mts.map +1 -0
  31. package/dist/domain/Transcript.mjs +22 -0
  32. package/dist/domain/Transcript.mjs.map +1 -0
  33. package/dist/domain/Turn.d.mts +2 -2
  34. package/dist/domain/Turn.mjs +22 -4
  35. package/dist/domain/Turn.mjs.map +1 -1
  36. package/dist/domain/Turn.test.d.mts +1 -0
  37. package/dist/domain/Turn.test.mjs +136 -0
  38. package/dist/domain/Turn.test.mjs.map +1 -0
  39. package/dist/embedding-model/Embedding.d.mts +15 -3
  40. package/dist/embedding-model/Embedding.d.mts.map +1 -1
  41. package/dist/embedding-model/Embedding.mjs.map +1 -1
  42. package/dist/embedding-model/EmbeddingModel.d.mts +33 -17
  43. package/dist/embedding-model/EmbeddingModel.d.mts.map +1 -1
  44. package/dist/embedding-model/EmbeddingModel.mjs.map +1 -1
  45. package/dist/embedding-model/EmbeddingModel.test.d.mts +1 -0
  46. package/dist/embedding-model/EmbeddingModel.test.mjs +59 -0
  47. package/dist/embedding-model/EmbeddingModel.test.mjs.map +1 -0
  48. package/dist/index.d.mts +13 -7
  49. package/dist/index.mjs +7 -1
  50. package/dist/language-model/LanguageModel.d.mts +30 -8
  51. package/dist/language-model/LanguageModel.d.mts.map +1 -1
  52. package/dist/language-model/LanguageModel.mjs +33 -3
  53. package/dist/language-model/LanguageModel.mjs.map +1 -1
  54. package/dist/language-model/LanguageModel.test.d.mts +1 -0
  55. package/dist/language-model/LanguageModel.test.mjs +143 -0
  56. package/dist/language-model/LanguageModel.test.mjs.map +1 -0
  57. package/dist/loop/Loop.d.mts +94 -11
  58. package/dist/loop/Loop.d.mts.map +1 -1
  59. package/dist/loop/Loop.mjs +92 -26
  60. package/dist/loop/Loop.mjs.map +1 -1
  61. package/dist/loop/Loop.test.mjs +171 -3
  62. package/dist/loop/Loop.test.mjs.map +1 -1
  63. package/dist/music-generator/MusicGenerator.d.mts +77 -0
  64. package/dist/music-generator/MusicGenerator.d.mts.map +1 -0
  65. package/dist/music-generator/MusicGenerator.mjs +51 -0
  66. package/dist/music-generator/MusicGenerator.mjs.map +1 -0
  67. package/dist/music-generator/MusicGenerator.test.d.mts +1 -0
  68. package/dist/music-generator/MusicGenerator.test.mjs +154 -0
  69. package/dist/music-generator/MusicGenerator.test.mjs.map +1 -0
  70. package/dist/observability/Metrics.d.mts +1 -1
  71. package/dist/observability/Metrics.mjs +1 -1
  72. package/dist/observability/Metrics.mjs.map +1 -1
  73. package/dist/speech-synthesizer/SpeechSynthesizer.d.mts +96 -0
  74. package/dist/speech-synthesizer/SpeechSynthesizer.d.mts.map +1 -0
  75. package/dist/speech-synthesizer/SpeechSynthesizer.mjs +48 -0
  76. package/dist/speech-synthesizer/SpeechSynthesizer.mjs.map +1 -0
  77. package/dist/speech-synthesizer/SpeechSynthesizer.test.d.mts +1 -0
  78. package/dist/speech-synthesizer/SpeechSynthesizer.test.mjs +112 -0
  79. package/dist/speech-synthesizer/SpeechSynthesizer.test.mjs.map +1 -0
  80. package/dist/streaming/JSONL.d.mts +10 -3
  81. package/dist/streaming/JSONL.d.mts.map +1 -1
  82. package/dist/streaming/JSONL.mjs +15 -9
  83. package/dist/streaming/JSONL.mjs.map +1 -1
  84. package/dist/structured-format/StructuredFormat.d.mts +2 -2
  85. package/dist/structured-format/StructuredFormat.mjs +9 -1
  86. package/dist/structured-format/StructuredFormat.mjs.map +1 -1
  87. package/dist/structured-format/StructuredFormat.test.d.mts +1 -0
  88. package/dist/structured-format/StructuredFormat.test.mjs +70 -0
  89. package/dist/structured-format/StructuredFormat.test.mjs.map +1 -0
  90. package/dist/testing/MockMusicGenerator.d.mts +39 -0
  91. package/dist/testing/MockMusicGenerator.d.mts.map +1 -0
  92. package/dist/testing/MockMusicGenerator.mjs +96 -0
  93. package/dist/testing/MockMusicGenerator.mjs.map +1 -0
  94. package/dist/testing/MockProvider.d.mts +23 -18
  95. package/dist/testing/MockProvider.d.mts.map +1 -1
  96. package/dist/testing/MockProvider.mjs +56 -72
  97. package/dist/testing/MockProvider.mjs.map +1 -1
  98. package/dist/testing/MockSpeechSynthesizer.d.mts +37 -0
  99. package/dist/testing/MockSpeechSynthesizer.d.mts.map +1 -0
  100. package/dist/testing/MockSpeechSynthesizer.mjs +95 -0
  101. package/dist/testing/MockSpeechSynthesizer.mjs.map +1 -0
  102. package/dist/testing/MockTranscriber.d.mts +37 -0
  103. package/dist/testing/MockTranscriber.d.mts.map +1 -0
  104. package/dist/testing/MockTranscriber.mjs +77 -0
  105. package/dist/testing/MockTranscriber.mjs.map +1 -0
  106. package/dist/tool/HistoryCheck.d.mts +1 -1
  107. package/dist/tool/Outcome.d.mts +1 -1
  108. package/dist/tool/Resolvers.d.mts +65 -8
  109. package/dist/tool/Resolvers.d.mts.map +1 -1
  110. package/dist/tool/Resolvers.mjs +8 -12
  111. package/dist/tool/Resolvers.mjs.map +1 -1
  112. package/dist/tool/Resolvers.test.mjs +6 -5
  113. package/dist/tool/Resolvers.test.mjs.map +1 -1
  114. package/dist/tool/Tool.d.mts +2 -2
  115. package/dist/tool/Tool.mjs +18 -1
  116. package/dist/tool/Tool.mjs.map +1 -1
  117. package/dist/tool/Tool.test.d.mts +1 -0
  118. package/dist/tool/Tool.test.mjs +66 -0
  119. package/dist/tool/Tool.test.mjs.map +1 -0
  120. package/dist/tool/Toolkit.d.mts +4 -6
  121. package/dist/tool/Toolkit.d.mts.map +1 -1
  122. package/dist/tool/Toolkit.mjs +14 -43
  123. package/dist/tool/Toolkit.mjs.map +1 -1
  124. package/dist/transcriber/Transcriber.d.mts +101 -0
  125. package/dist/transcriber/Transcriber.d.mts.map +1 -0
  126. package/dist/transcriber/Transcriber.mjs +49 -0
  127. package/dist/transcriber/Transcriber.mjs.map +1 -0
  128. package/dist/transcriber/Transcriber.test.d.mts +1 -0
  129. package/dist/transcriber/Transcriber.test.mjs +130 -0
  130. package/dist/transcriber/Transcriber.test.mjs.map +1 -0
  131. package/package.json +37 -1
  132. package/src/domain/AiError.ts +22 -1
  133. package/src/domain/Audio.ts +88 -0
  134. package/src/domain/Items.ts +1 -1
  135. package/src/domain/Music.ts +121 -0
  136. package/src/domain/Transcript.ts +83 -0
  137. package/src/domain/Turn.test.ts +141 -0
  138. package/src/domain/Turn.ts +50 -43
  139. package/src/embedding-model/Embedding.ts +23 -0
  140. package/src/embedding-model/EmbeddingModel.test.ts +92 -0
  141. package/src/embedding-model/EmbeddingModel.ts +30 -20
  142. package/src/index.ts +6 -0
  143. package/src/language-model/LanguageModel.test.ts +170 -0
  144. package/src/language-model/LanguageModel.ts +64 -1
  145. package/src/loop/Loop.test.ts +256 -3
  146. package/src/loop/Loop.ts +225 -49
  147. package/src/music-generator/MusicGenerator.test.ts +170 -0
  148. package/src/music-generator/MusicGenerator.ts +123 -0
  149. package/src/observability/Metrics.ts +1 -1
  150. package/src/speech-synthesizer/SpeechSynthesizer.test.ts +141 -0
  151. package/src/speech-synthesizer/SpeechSynthesizer.ts +131 -0
  152. package/src/streaming/JSONL.ts +16 -13
  153. package/src/structured-format/StructuredFormat.test.ts +105 -0
  154. package/src/structured-format/StructuredFormat.ts +14 -1
  155. package/src/testing/MockMusicGenerator.ts +168 -0
  156. package/src/testing/MockProvider.ts +126 -105
  157. package/src/testing/MockSpeechSynthesizer.ts +163 -0
  158. package/src/testing/MockTranscriber.ts +137 -0
  159. package/src/tool/Resolvers.test.ts +8 -5
  160. package/src/tool/Resolvers.ts +17 -19
  161. package/src/tool/Tool.test.ts +105 -0
  162. package/src/tool/Tool.ts +20 -0
  163. package/src/tool/Toolkit.ts +49 -50
  164. package/src/transcriber/Transcriber.test.ts +125 -0
  165. package/src/transcriber/Transcriber.ts +127 -0
  166. package/dist/StructuredFormat-BWq5Hd1O.d.mts.map +0 -1
  167. package/dist/Tool-DjVufH7i.d.mts.map +0 -1
  168. package/dist/Turn-OPaILVIB.d.mts +0 -194
  169. package/dist/Turn-OPaILVIB.d.mts.map +0 -1
@@ -0,0 +1,168 @@
1
+ import { Effect, Layer, Ref, Stream } from "effect"
2
+ import * as AiError from "../domain/AiError.js"
3
+ import type { AudioChunk } from "../domain/Audio.js"
4
+ import type {
5
+ CommonGenerateMusicRequest,
6
+ CommonStreamGenerateMusicRequest,
7
+ MusicResult,
8
+ MusicSessionInput,
9
+ } from "../domain/Music.js"
10
+ import {
11
+ MusicGenerator,
12
+ MusicInteractiveSession,
13
+ type MusicGeneratorService,
14
+ } from "../music-generator/MusicGenerator.js"
15
+
16
+ export type MockMusicGeneratorRecorder = {
17
+ readonly generateCalls: ReadonlyArray<CommonGenerateMusicRequest>
18
+ readonly streamGenerationCalls: ReadonlyArray<CommonStreamGenerateMusicRequest>
19
+ readonly streamGenerationFromCalls: ReadonlyArray<CommonStreamGenerateMusicRequest>
20
+ }
21
+
22
+ export type MockMusicGeneratorScript = {
23
+ /** One result per `generate` call, consumed in order. */
24
+ readonly results?: ReadonlyArray<MusicResult>
25
+ /** One chunk-list per `streamGeneration` call, consumed in order. */
26
+ readonly streamGenerationChunks?: ReadonlyArray<ReadonlyArray<AudioChunk>>
27
+ /** One chunk-list per `streamGenerationFrom` call, consumed in order. */
28
+ readonly streamGenerationFromChunks?: ReadonlyArray<ReadonlyArray<AudioChunk>>
29
+ }
30
+
31
+ const makeService = (
32
+ script: MockMusicGeneratorScript,
33
+ record: {
34
+ readonly generate: (req: CommonGenerateMusicRequest) => Effect.Effect<void>
35
+ readonly streamGeneration: (req: CommonStreamGenerateMusicRequest) => Effect.Effect<void>
36
+ readonly streamGenerationFrom: (req: CommonStreamGenerateMusicRequest) => Effect.Effect<void>
37
+ },
38
+ ) =>
39
+ Effect.gen(function* () {
40
+ const gCursor = yield* Ref.make(0)
41
+ const sgCursor = yield* Ref.make(0)
42
+ const sgfCursor = yield* Ref.make(0)
43
+ const service: MusicGeneratorService = {
44
+ generate: (request) =>
45
+ Effect.gen(function* () {
46
+ yield* record.generate(request)
47
+ const i = yield* Ref.getAndUpdate(gCursor, (n) => n + 1)
48
+ const scripted = script.results ?? []
49
+ if (i >= scripted.length) {
50
+ return yield* new AiError.InvalidRequest({
51
+ provider: "mock",
52
+ raw: `MockMusicGenerator exhausted: ${scripted.length} results scripted, but call ${i + 1} was made`,
53
+ })
54
+ }
55
+ return scripted[i]!
56
+ }),
57
+ streamGeneration: (request) =>
58
+ Stream.unwrap(
59
+ Effect.gen(function* () {
60
+ yield* record.streamGeneration(request)
61
+ const i = yield* Ref.getAndUpdate(sgCursor, (n) => n + 1)
62
+ const scripted = script.streamGenerationChunks ?? []
63
+ if (i >= scripted.length) {
64
+ return Stream.fail(
65
+ new AiError.InvalidRequest({
66
+ provider: "mock",
67
+ raw: `MockMusicGenerator exhausted: ${scripted.length} streamGeneration lists scripted, but call ${i + 1} was made`,
68
+ }),
69
+ )
70
+ }
71
+ return Stream.fromIterable(scripted[i]!)
72
+ }),
73
+ ),
74
+ streamGenerationFrom: <E, R>(
75
+ input: Stream.Stream<MusicSessionInput, E, R>,
76
+ request: CommonStreamGenerateMusicRequest,
77
+ ): Stream.Stream<AudioChunk, AiError.AiError | E, R> =>
78
+ Stream.unwrap(
79
+ Effect.gen(function* () {
80
+ yield* record.streamGenerationFrom(request)
81
+ const i = yield* Ref.getAndUpdate(sgfCursor, (n) => n + 1)
82
+ const scripted = script.streamGenerationFromChunks ?? []
83
+ if (i >= scripted.length) {
84
+ const exhausted: Stream.Stream<AudioChunk, AiError.AiError | E, R> = Stream.fail(
85
+ new AiError.InvalidRequest({
86
+ provider: "mock",
87
+ raw: `MockMusicGenerator exhausted: ${scripted.length} streamGenerationFrom lists scripted, but call ${i + 1} was made`,
88
+ }),
89
+ )
90
+ return exhausted
91
+ }
92
+ // Drain the input fully before emitting scripted audio chunks,
93
+ // so consumers can assert on what session messages were pushed.
94
+ return Stream.drain(input).pipe(Stream.concat(Stream.fromIterable(scripted[i]!)))
95
+ }),
96
+ ),
97
+ }
98
+ return service
99
+ })
100
+
101
+ /**
102
+ * Layer providing the `MusicGenerator` service AND the
103
+ * `MusicInteractiveSession` capability marker. Use for the common case
104
+ * where code under test exercises `streamGenerationFrom`.
105
+ */
106
+ export const layer = (
107
+ script: MockMusicGeneratorScript,
108
+ ): {
109
+ readonly layer: Layer.Layer<MusicGenerator | MusicInteractiveSession>
110
+ readonly recorder: Effect.Effect<MockMusicGeneratorRecorder>
111
+ } => {
112
+ const gCalls = Ref.makeUnsafe<ReadonlyArray<CommonGenerateMusicRequest>>([])
113
+ const sgCalls = Ref.makeUnsafe<ReadonlyArray<CommonStreamGenerateMusicRequest>>([])
114
+ const sgfCalls = Ref.makeUnsafe<ReadonlyArray<CommonStreamGenerateMusicRequest>>([])
115
+ const generatorLayer = Layer.effect(
116
+ MusicGenerator,
117
+ makeService(script, {
118
+ generate: (req) => Ref.update(gCalls, (xs) => [...xs, req]),
119
+ streamGeneration: (req) => Ref.update(sgCalls, (xs) => [...xs, req]),
120
+ streamGenerationFrom: (req) => Ref.update(sgfCalls, (xs) => [...xs, req]),
121
+ }),
122
+ )
123
+ const live = Layer.merge(generatorLayer, Layer.succeed(MusicInteractiveSession, undefined))
124
+ return {
125
+ layer: live,
126
+ recorder: Effect.gen(function* () {
127
+ const generateCalls = yield* Ref.get(gCalls)
128
+ const streamGenerationCalls = yield* Ref.get(sgCalls)
129
+ const streamGenerationFromCalls = yield* Ref.get(sgfCalls)
130
+ return { generateCalls, streamGenerationCalls, streamGenerationFromCalls }
131
+ }),
132
+ }
133
+ }
134
+
135
+ /**
136
+ * Variant that omits the `MusicInteractiveSession` marker — simulates a
137
+ * provider without bidirectional support (Lyria 3 sync, ElevenLabs,
138
+ * Mureka, MiniMax, Stable Audio, Suno). Calls to
139
+ * `streamGenerationFrom` in code under test should be a compile-time
140
+ * error against this Layer alone.
141
+ */
142
+ export const layerWithoutInteractive = (
143
+ script: MockMusicGeneratorScript,
144
+ ): {
145
+ readonly layer: Layer.Layer<MusicGenerator>
146
+ readonly recorder: Effect.Effect<MockMusicGeneratorRecorder>
147
+ } => {
148
+ const gCalls = Ref.makeUnsafe<ReadonlyArray<CommonGenerateMusicRequest>>([])
149
+ const sgCalls = Ref.makeUnsafe<ReadonlyArray<CommonStreamGenerateMusicRequest>>([])
150
+ const sgfCalls = Ref.makeUnsafe<ReadonlyArray<CommonStreamGenerateMusicRequest>>([])
151
+ const live = Layer.effect(
152
+ MusicGenerator,
153
+ makeService(script, {
154
+ generate: (req) => Ref.update(gCalls, (xs) => [...xs, req]),
155
+ streamGeneration: (req) => Ref.update(sgCalls, (xs) => [...xs, req]),
156
+ streamGenerationFrom: (req) => Ref.update(sgfCalls, (xs) => [...xs, req]),
157
+ }),
158
+ )
159
+ return {
160
+ layer: live,
161
+ recorder: Effect.gen(function* () {
162
+ const generateCalls = yield* Ref.get(gCalls)
163
+ const streamGenerationCalls = yield* Ref.get(sgCalls)
164
+ const streamGenerationFromCalls = yield* Ref.get(sgfCalls)
165
+ return { generateCalls, streamGenerationCalls, streamGenerationFromCalls }
166
+ }),
167
+ }
168
+ }
@@ -1,8 +1,8 @@
1
- import { Duration, Effect, Layer, Ref, Schedule, Stream } from "effect"
1
+ import { Array as Arr, Duration, Effect, Layer, Match, Option, Ref, Schedule, Stream } from "effect"
2
2
  import * as AiError from "../domain/AiError.js"
3
- import type { Item } from "../domain/Items.js"
3
+ import { type Item, isOutputText } from "../domain/Items.js"
4
4
  import { LanguageModel, type LanguageModelService } from "../language-model/LanguageModel.js"
5
- import type { Turn, TurnEvent } from "../domain/Turn.js"
5
+ import { type Turn, TurnEvent } from "../domain/Turn.js"
6
6
 
7
7
  export type MockOptions = {
8
8
  /**
@@ -13,46 +13,48 @@ export type MockOptions = {
13
13
  readonly deltaInterval?: Duration.Input
14
14
  }
15
15
 
16
+ export type Call = {
17
+ readonly history: ReadonlyArray<Item>
18
+ readonly turn: Turn
19
+ }
20
+
16
21
  /**
17
22
  * A scripted mock provider. Pre-canned `Turn` outputs are returned in order,
18
23
  * one per call to `streamTurn`. Each scripted turn is split into synthetic
19
- * deltas (text → tool_call_starttool_call_args_delta → ... → turn_complete)
24
+ * deltas (text → ToolCallStartToolCallArgsDelta → ... → TurnComplete)
20
25
  * so streaming consumers can see realistic delta shapes.
21
26
  */
22
27
  export type MockRecorder = {
23
- readonly calls: ReadonlyArray<{
24
- readonly history: ReadonlyArray<Item>
25
- readonly turn: Turn
26
- }>
28
+ readonly calls: ReadonlyArray<Call>
27
29
  }
28
30
 
29
- const turnToDeltas = (turn: Turn): ReadonlyArray<TurnEvent> => {
30
- const deltas: TurnEvent[] = []
31
- for (const item of turn.items) {
32
- if (item.type === "message" && item.role === "assistant") {
33
- for (const block of item.content) {
34
- if (block.type === "output_text") {
35
- deltas.push({ type: "text_delta", text: block.text })
36
- }
37
- }
38
- } else if (item.type === "function_call") {
39
- deltas.push({
40
- type: "tool_call_start",
41
- call_id: item.call_id,
42
- name: item.name,
43
- })
44
- deltas.push({
45
- type: "tool_call_args_delta",
46
- call_id: item.call_id,
47
- delta: item.arguments,
48
- })
49
- } else if (item.type === "reasoning" && item.summary !== undefined) {
50
- deltas.push({ type: "reasoning_delta", text: item.summary, kind: "summary" })
51
- }
52
- }
53
- deltas.push({ type: "turn_complete", turn })
54
- return deltas
55
- }
31
+ // ---------------------------------------------------------------------------
32
+ // Pure projection: Turn ReadonlyArray<TurnEvent>
33
+ // ---------------------------------------------------------------------------
34
+
35
+ const itemToDeltas: (item: Item) => ReadonlyArray<TurnEvent> = Match.type<Item>().pipe(
36
+ Match.discriminators("type")({
37
+ message: (m): ReadonlyArray<TurnEvent> =>
38
+ m.role === "assistant"
39
+ ? m.content.filter(isOutputText).map((b) => TurnEvent.TextDelta({ text: b.text }))
40
+ : [],
41
+ function_call: (fc) => [
42
+ TurnEvent.ToolCallStart({ call_id: fc.call_id, name: fc.name }),
43
+ TurnEvent.ToolCallArgsDelta({ call_id: fc.call_id, delta: fc.arguments }),
44
+ ],
45
+ function_call_output: () => [],
46
+ reasoning: (r) =>
47
+ r.summary !== undefined
48
+ ? [TurnEvent.ReasoningDelta({ text: r.summary, kind: "summary" as const })]
49
+ : [],
50
+ }),
51
+ Match.exhaustive,
52
+ )
53
+
54
+ const turnToDeltas = (turn: Turn): ReadonlyArray<TurnEvent> => [
55
+ ...turn.items.flatMap(itemToDeltas),
56
+ TurnEvent.TurnComplete({ turn }),
57
+ ]
56
58
 
57
59
  const pacedDeltas = (turn: Turn, options?: MockOptions): Stream.Stream<TurnEvent> => {
58
60
  const base = Stream.fromIterable(turnToDeltas(turn))
@@ -61,101 +63,120 @@ const pacedDeltas = (turn: Turn, options?: MockOptions): Stream.Stream<TurnEvent
61
63
  : base.pipe(Stream.schedule(Schedule.spaced(options.deltaInterval)))
62
64
  }
63
65
 
64
- const makeService = (
66
+ // ---------------------------------------------------------------------------
67
+ // Canonical service factory. One implementation; sync/Layer/recorder
68
+ // variants below are just different ways to wire the cursor + record hook.
69
+ // ---------------------------------------------------------------------------
70
+
71
+ const exhausted = (n: number, attempt: number): AiError.AiError =>
72
+ new AiError.InvalidRequest({
73
+ provider: "mock",
74
+ raw: `MockProvider exhausted: ${n} turns scripted, but call ${attempt} was made`,
75
+ })
76
+
77
+ const noRecord = (_: Call): Effect.Effect<void> => Effect.void
78
+
79
+ const buildService = (
65
80
  scriptedTurns: ReadonlyArray<Turn>,
66
- options?: MockOptions,
67
- recordCall?: (history: ReadonlyArray<Item>, turn: Turn) => Effect.Effect<void>,
68
- ) =>
69
- Effect.gen(function* () {
70
- const cursor = yield* Ref.make(0)
71
- return LanguageModel.of({
72
- streamTurn: (request) =>
73
- Stream.unwrap(
74
- Effect.gen(function* () {
75
- const i = yield* Ref.getAndUpdate(cursor, (n) => n + 1)
76
- if (i >= scriptedTurns.length) {
77
- return Stream.fail(
78
- new AiError.InvalidRequest({
79
- provider: "mock",
80
- raw: `MockProvider exhausted: ${scriptedTurns.length} turns scripted, but call ${i + 1} was made`,
81
- }),
82
- )
83
- }
84
- const turn = scriptedTurns[i]!
85
- if (recordCall !== undefined) {
86
- yield* recordCall(request.history, turn)
87
- }
88
- return pacedDeltas(turn, options)
89
- }),
81
+ options: MockOptions | undefined,
82
+ cursor: Ref.Ref<number>,
83
+ record: (call: Call) => Effect.Effect<void>,
84
+ ): LanguageModelService => ({
85
+ streamTurn: (request) =>
86
+ Stream.unwrap(
87
+ Ref.getAndUpdate(cursor, (n) => n + 1).pipe(
88
+ Effect.flatMap(
89
+ (i): Effect.Effect<Stream.Stream<TurnEvent, AiError.AiError>> =>
90
+ Option.match(Arr.get(scriptedTurns, i), {
91
+ onNone: () => Effect.succeed(Stream.fail(exhausted(scriptedTurns.length, i + 1))),
92
+ onSome: (turn) =>
93
+ record({ history: request.history, turn }).pipe(
94
+ Effect.as(pacedDeltas(turn, options)),
95
+ ),
96
+ }),
90
97
  ),
91
- })
92
- })
98
+ ),
99
+ ),
100
+ })
101
+
102
+ // ---------------------------------------------------------------------------
103
+ // Recorder handle. Unsafe Ref is local: it backs both the `record` write
104
+ // hook (called inside the service) and the `recorder` read effect (called
105
+ // by the test). Both close over the same cell.
106
+ // ---------------------------------------------------------------------------
107
+
108
+ type RecorderHandle = {
109
+ readonly record: (call: Call) => Effect.Effect<void>
110
+ readonly recorder: Effect.Effect<MockRecorder>
111
+ }
93
112
 
113
+ const makeRecorderUnsafe = (): RecorderHandle => {
114
+ const ref = Ref.makeUnsafe<ReadonlyArray<Call>>([])
115
+ return {
116
+ record: (call) => Ref.update(ref, Arr.append(call)),
117
+ recorder: Ref.get(ref).pipe(Effect.map((calls): MockRecorder => ({ calls }))),
118
+ }
119
+ }
120
+
121
+ // ---------------------------------------------------------------------------
122
+ // Public API
123
+ // ---------------------------------------------------------------------------
124
+
125
+ /**
126
+ * Layer that registers a `MockProvider` against the `LanguageModel` tag.
127
+ * Calls beyond the scripted turn count fail with `InvalidRequest`.
128
+ */
94
129
  export const layer = (
95
130
  scriptedTurns: ReadonlyArray<Turn>,
96
131
  options?: MockOptions,
97
- ): Layer.Layer<LanguageModel> => Layer.effect(LanguageModel, makeService(scriptedTurns, options))
132
+ ): Layer.Layer<LanguageModel> =>
133
+ Layer.effect(
134
+ LanguageModel,
135
+ Ref.make(0).pipe(
136
+ Effect.map((cursor) => buildService(scriptedTurns, options, cursor, noRecord)),
137
+ ),
138
+ )
98
139
 
99
140
  /**
100
- * Synchronous constructor that returns the `LanguageModelService` value
101
- * directly, plus a recorder. Use this when you want to swap models
102
- * mid-stream via `Effect.provideService` instead of providing one model
103
- * for the whole program via `Layer`.
141
+ * Like `layer`, but also exposes a recorder that captures every call
142
+ * (history + returned turn).
104
143
  */
105
- export const make = (
144
+ export const layerWithRecorder = (
106
145
  scriptedTurns: ReadonlyArray<Turn>,
107
146
  options?: MockOptions,
108
147
  ): {
109
- readonly service: LanguageModelService
148
+ readonly layer: Layer.Layer<LanguageModel>
110
149
  readonly recorder: Effect.Effect<MockRecorder>
111
150
  } => {
112
- const cursor = Ref.makeUnsafe(0)
113
- const callsRef = Ref.makeUnsafe<ReadonlyArray<{ history: ReadonlyArray<Item>; turn: Turn }>>([])
114
- const service: LanguageModelService = {
115
- streamTurn: (request) =>
116
- Stream.unwrap(
117
- Effect.gen(function* () {
118
- const i = yield* Ref.getAndUpdate(cursor, (n) => n + 1)
119
- if (i >= scriptedTurns.length) {
120
- return Stream.fail(
121
- new AiError.InvalidRequest({
122
- provider: "mock",
123
- raw: `MockProvider exhausted: ${scriptedTurns.length} turns scripted, but call ${i + 1} was made`,
124
- }),
125
- )
126
- }
127
- const turn = scriptedTurns[i]!
128
- yield* Ref.update(callsRef, (xs) => [...xs, { history: request.history, turn }])
129
- return pacedDeltas(turn, options)
130
- }),
131
- ),
132
- }
151
+ const { record, recorder } = makeRecorderUnsafe()
133
152
  return {
134
- service,
135
- recorder: Ref.get(callsRef).pipe(Effect.map((calls) => ({ calls }))),
153
+ layer: Layer.effect(
154
+ LanguageModel,
155
+ Ref.make(0).pipe(
156
+ Effect.map((cursor) => buildService(scriptedTurns, options, cursor, record)),
157
+ ),
158
+ ),
159
+ recorder,
136
160
  }
137
161
  }
138
162
 
139
163
  /**
140
- * Same as `layer`, but also exposes a recorder that captures every call
141
- * (history + returned turn).
164
+ * Build the `LanguageModelService` value directly (no Layer), plus a
165
+ * recorder. Use this when you want to swap models mid-program via
166
+ * `Effect.provideService` instead of providing one model for the whole
167
+ * program via `Layer`.
142
168
  */
143
- export const layerWithRecorder = (
169
+ export const make = (
144
170
  scriptedTurns: ReadonlyArray<Turn>,
145
171
  options?: MockOptions,
146
172
  ): {
147
- readonly layer: Layer.Layer<LanguageModel>
173
+ readonly service: LanguageModelService
148
174
  readonly recorder: Effect.Effect<MockRecorder>
149
175
  } => {
150
- const callsRef = Ref.makeUnsafe<ReadonlyArray<{ history: ReadonlyArray<Item>; turn: Turn }>>([])
151
- const live = Layer.effect(
152
- LanguageModel,
153
- makeService(scriptedTurns, options, (history, turn) =>
154
- Ref.update(callsRef, (xs) => [...xs, { history, turn }]),
155
- ),
156
- )
176
+ const cursor = Ref.makeUnsafe(0)
177
+ const { record, recorder } = makeRecorderUnsafe()
157
178
  return {
158
- layer: live,
159
- recorder: Ref.get(callsRef).pipe(Effect.map((calls) => ({ calls }))),
179
+ service: buildService(scriptedTurns, options, cursor, record),
180
+ recorder,
160
181
  }
161
182
  }
@@ -0,0 +1,163 @@
1
+ import { Effect, Layer, Ref, Stream } from "effect"
2
+ import type { AudioBlob, AudioChunk } from "../domain/Audio.js"
3
+ import * as AiError from "../domain/AiError.js"
4
+ import {
5
+ SpeechSynthesizer,
6
+ TtsIncrementalText,
7
+ type CommonStreamSynthesizeRequest,
8
+ type CommonSynthesizeRequest,
9
+ type SpeechSynthesizerService,
10
+ } from "../speech-synthesizer/SpeechSynthesizer.js"
11
+
12
+ export type MockSynthesizerRecorder = {
13
+ readonly synthesizeCalls: ReadonlyArray<CommonSynthesizeRequest>
14
+ readonly streamSynthesisCalls: ReadonlyArray<CommonSynthesizeRequest>
15
+ readonly streamSynthesisFromCalls: ReadonlyArray<CommonStreamSynthesizeRequest>
16
+ }
17
+
18
+ export type MockSynthesizerScript = {
19
+ /** One blob per `synthesize` call, consumed in order. */
20
+ readonly blobs?: ReadonlyArray<AudioBlob>
21
+ /** One chunk-list per `streamSynthesis` call, consumed in order. */
22
+ readonly streamSynthesisChunks?: ReadonlyArray<ReadonlyArray<AudioChunk>>
23
+ /** One chunk-list per `streamSynthesisFrom` call, consumed in order. */
24
+ readonly streamSynthesisFromChunks?: ReadonlyArray<ReadonlyArray<AudioChunk>>
25
+ }
26
+
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
+ Effect.gen(function* () {
36
+ const bCursor = yield* Ref.make(0)
37
+ const ssCursor = yield* Ref.make(0)
38
+ const ssfCursor = yield* Ref.make(0)
39
+ const service: SpeechSynthesizerService = {
40
+ synthesize: (request) =>
41
+ Effect.gen(function* () {
42
+ yield* record.synthesize(request)
43
+ const i = yield* Ref.getAndUpdate(bCursor, (n) => n + 1)
44
+ 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
+ }
51
+ return scripted[i]!
52
+ }),
53
+ streamSynthesis: (request) =>
54
+ Stream.unwrap(
55
+ Effect.gen(function* () {
56
+ yield* record.streamSynthesis(request)
57
+ const i = yield* Ref.getAndUpdate(ssCursor, (n) => n + 1)
58
+ const scripted = script.streamSynthesisChunks ?? []
59
+ 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
+ )
66
+ }
67
+ return Stream.fromIterable(scripted[i]!)
68
+ }),
69
+ ),
70
+ streamSynthesisFrom: <E, R>(
71
+ textIn: Stream.Stream<string, E, R>,
72
+ request: CommonStreamSynthesizeRequest,
73
+ ): Stream.Stream<AudioChunk, AiError.AiError | E, R> =>
74
+ Stream.unwrap(
75
+ Effect.gen(function* () {
76
+ yield* record.streamSynthesisFrom(request)
77
+ const i = yield* Ref.getAndUpdate(ssfCursor, (n) => n + 1)
78
+ const scripted = script.streamSynthesisFromChunks ?? []
79
+ 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
+ }),
85
+ )
86
+ return exhausted
87
+ }
88
+ // Drain the input text fully before emitting scripted audio chunks,
89
+ // so consumers can assert on what text was pushed.
90
+ return Stream.drain(textIn).pipe(Stream.concat(Stream.fromIterable(scripted[i]!)))
91
+ }),
92
+ ),
93
+ }
94
+ return service
95
+ })
96
+
97
+ /**
98
+ * Layer providing the `SpeechSynthesizer` service AND the
99
+ * `TtsIncrementalText` capability marker. Use for the common case
100
+ * where code under test exercises `streamSynthesisFrom`.
101
+ */
102
+ export const layer = (
103
+ script: MockSynthesizerScript,
104
+ ): {
105
+ readonly layer: Layer.Layer<SpeechSynthesizer | TtsIncrementalText>
106
+ readonly recorder: Effect.Effect<MockSynthesizerRecorder>
107
+ } => {
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
+ }),
118
+ )
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
+ }
129
+ }
130
+
131
+ /**
132
+ * 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.
136
+ */
137
+ export const layerWithoutIncremental = (
138
+ script: MockSynthesizerScript,
139
+ ): {
140
+ readonly layer: Layer.Layer<SpeechSynthesizer>
141
+ readonly recorder: Effect.Effect<MockSynthesizerRecorder>
142
+ } => {
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
+ }),
153
+ )
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
+ }
163
+ }