@effect-uai/core 0.5.0 → 0.5.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/dist/{Items-DqbaJoz7.d.mts → Items-BH8xUkoR.d.mts} +6 -6
  2. package/dist/{Items-DqbaJoz7.d.mts.map → Items-BH8xUkoR.d.mts.map} +1 -1
  3. package/dist/{Tool-Y0__Py1H.d.mts → Tool-87ViKCCO.d.mts} +2 -2
  4. package/dist/{Tool-Y0__Py1H.d.mts.map → Tool-87ViKCCO.d.mts.map} +1 -1
  5. package/dist/{Turn-ChbL2foc.d.mts → Turn-0CwCAyVe.d.mts} +2 -2
  6. package/dist/{Turn-ChbL2foc.d.mts.map → Turn-0CwCAyVe.d.mts.map} +1 -1
  7. package/dist/domain/Items.d.mts +1 -1
  8. package/dist/domain/Turn.d.mts +1 -1
  9. package/dist/index.d.mts +5 -4
  10. package/dist/index.mjs +2 -1
  11. package/dist/language-model/LanguageModel.d.mts +27 -25
  12. package/dist/language-model/LanguageModel.d.mts.map +1 -1
  13. package/dist/language-model/LanguageModel.mjs +16 -25
  14. package/dist/language-model/LanguageModel.mjs.map +1 -1
  15. package/dist/language-model/LanguageModel.test.mjs +10 -81
  16. package/dist/language-model/LanguageModel.test.mjs.map +1 -1
  17. package/dist/loop/Loop.d.mts +1 -1
  18. package/dist/retry/Retry.d.mts +64 -0
  19. package/dist/retry/Retry.d.mts.map +1 -0
  20. package/dist/retry/Retry.mjs +69 -0
  21. package/dist/retry/Retry.mjs.map +1 -0
  22. package/dist/retry/Retry.test.d.mts +1 -0
  23. package/dist/retry/Retry.test.mjs +142 -0
  24. package/dist/retry/Retry.test.mjs.map +1 -0
  25. package/dist/testing/MockProvider.d.mts +2 -2
  26. package/dist/testing/MockProvider.d.mts.map +1 -1
  27. package/dist/testing/MockProvider.mjs +14 -8
  28. package/dist/testing/MockProvider.mjs.map +1 -1
  29. package/dist/tool/HistoryCheck.d.mts +1 -1
  30. package/dist/tool/Outcome.d.mts +1 -1
  31. package/dist/tool/Resolvers.d.mts +1 -1
  32. package/dist/tool/Tool.d.mts +1 -1
  33. package/dist/tool/Toolkit.d.mts +2 -2
  34. package/package.json +5 -1
  35. package/src/index.ts +1 -0
  36. package/src/language-model/LanguageModel.test.ts +21 -102
  37. package/src/language-model/LanguageModel.ts +33 -56
  38. package/src/retry/Retry.test.ts +163 -0
  39. package/src/retry/Retry.ts +84 -0
  40. package/src/testing/MockProvider.ts +10 -5
@@ -0,0 +1 @@
1
+ {"version":3,"file":"Retry.d.mts","names":[],"sources":["../../src/retry/Retry.ts"],"mappings":";;;;;;;;;cACoD,cAAA;;;;;;;;;;;AAAA;;;;;;;;;;;;;;;;;;cA6BvC,SAAA,SAAkB,cAAA;EAAA,SACpB,KAAA,EAAO,WAAA,GAAsB,WAAA,GAAsB,OAAA;AAAA;;cAIjD,WAAA,GACX,CAAA,EAAG,OAAA,KACF,CAAA,IAAK,WAAA,GAAsB,WAAA,GAAsB,OAAA;;;;;;;cAgBvC,MAAA,QACL,QAAA,EAAU,QAAA,CAAS,QAAA,CAAS,GAAA,EAAK,SAAA,aAChC,CAAA,EAAG,MAAA,CAAO,MAAA,CAAO,CAAA,EAAG,OAAA,EAAiB,CAAA,MAAK,MAAA,CAAO,MAAA,CAAO,CAAA,EAAG,OAAA,EAAiB,CAAA;AAzBrF;;;;;;;;;;AAAA,cAkDa,MAAA,QACL,QAAA,EAAU,QAAA,CAAS,QAAA,CAAS,GAAA,EAAK,OAAA,aAChC,GAAA,EAAK,MAAA,CAAO,MAAA,CAAO,CAAA,EAAG,OAAA,EAAiB,CAAA,MAAK,MAAA,CAAO,MAAA,CAAO,CAAA,EAAG,OAAA,EAAiB,CAAA"}
@@ -0,0 +1,69 @@
1
+ import { n as __exportAll } from "../chunk-uyGKjUfl.mjs";
2
+ import { Data, Effect, Stream } from "effect";
3
+ //#region src/retry/Retry.ts
4
+ var Retry_exports = /* @__PURE__ */ __exportAll({
5
+ Retryable: () => Retryable,
6
+ effect: () => effect,
7
+ isRetryable: () => isRetryable,
8
+ stream: () => stream
9
+ });
10
+ /**
11
+ * Subset-aware retry helpers for `AiError`.
12
+ *
13
+ * Effect's own `Effect.retry` / `Stream.retry` apply the schedule to
14
+ * every failure. For AI calls that's the wrong policy — replaying a
15
+ * `ContentFiltered` or `InvalidRequest` won't change the outcome and
16
+ * just wastes quota. These helpers gate the schedule on the
17
+ * {@link Retryable} subset (rate limits, transport hiccups, timeouts);
18
+ * everything else propagates unchanged.
19
+ *
20
+ * Two carriers:
21
+ * - {@link stream} — for `Stream<A, AiError, R>` (e.g. `streamTurn`,
22
+ * `streamSynthesis`, `streamTranscriptionFrom`). Caveat: the entire
23
+ * stream re-runs on retry, so deltas before the failure replay on
24
+ * the next attempt.
25
+ * - {@link effect} — for `Effect<A, AiError, R>` (e.g. `turn`, `embed`,
26
+ * `embedMany`, `synthesize`, `transcribe`). At-most-once.
27
+ *
28
+ * Composes with `Stream.retry` / `Effect.retry` directly (no naming
29
+ * collision since these live under the `Retry` namespace).
30
+ */
31
+ /**
32
+ * Wrapper around the retryable subset of `AiError`. Internal to the
33
+ * lift/retry/unlift dance — exported so callers can build their own
34
+ * variants if needed (e.g. a custom schedule that observes `cause`).
35
+ */
36
+ var Retryable = class extends Data.TaggedError("RetryableAi") {};
37
+ /** Type-narrowing predicate for the retryable subset. */
38
+ const isRetryable = (e) => e._tag === "RateLimited" || e._tag === "Unavailable" || e._tag === "Timeout";
39
+ /**
40
+ * Retry a `Stream<A, AiError, R>` on the retryable subset. Other
41
+ * failures bypass the schedule and propagate unchanged. The whole
42
+ * stream replays on each attempt — deltas emitted before the failure
43
+ * are re-emitted on the next try.
44
+ */
45
+ const stream = (schedule) => (s) => s.pipe(Stream.map((value) => ({
46
+ _tag: "Item",
47
+ value
48
+ })), Stream.catchIf(isRetryable, (cause) => Stream.fail(new Retryable({ cause })), (cause) => Stream.succeed({
49
+ _tag: "Terminal",
50
+ cause
51
+ })), Stream.retry(schedule), Stream.catchTag("RetryableAi", (e) => Stream.fail(e.cause)), Stream.flatMap((item) => item._tag === "Item" ? Stream.succeed(item.value) : Stream.fail(item.cause)));
52
+ /**
53
+ * Retry an `Effect<A, AiError, R>` on the retryable subset. Other
54
+ * failures bypass the schedule and propagate unchanged. At-most-once
55
+ * — the underlying call only re-runs if the previous attempt failed
56
+ * with a retryable tag.
57
+ *
58
+ * Skips the lift/unlift dance the Stream variant needs: `Effect.retry`
59
+ * exposes a `while:` predicate so the error channel stays a plain
60
+ * `AiError` throughout.
61
+ */
62
+ const effect = (schedule) => (eff) => Effect.retry(eff, {
63
+ schedule,
64
+ while: isRetryable
65
+ });
66
+ //#endregion
67
+ export { Retryable, effect, isRetryable, stream, Retry_exports as t };
68
+
69
+ //# sourceMappingURL=Retry.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"Retry.mjs","names":[],"sources":["../../src/retry/Retry.ts"],"sourcesContent":["import { Data, Effect, type Schedule, Stream } from \"effect\"\nimport type * as AiError from \"../domain/AiError.js\"\n\n/**\n * Subset-aware retry helpers for `AiError`.\n *\n * Effect's own `Effect.retry` / `Stream.retry` apply the schedule to\n * every failure. For AI calls that's the wrong policy — replaying a\n * `ContentFiltered` or `InvalidRequest` won't change the outcome and\n * just wastes quota. These helpers gate the schedule on the\n * {@link Retryable} subset (rate limits, transport hiccups, timeouts);\n * everything else propagates unchanged.\n *\n * Two carriers:\n * - {@link stream} — for `Stream<A, AiError, R>` (e.g. `streamTurn`,\n * `streamSynthesis`, `streamTranscriptionFrom`). Caveat: the entire\n * stream re-runs on retry, so deltas before the failure replay on\n * the next attempt.\n * - {@link effect} — for `Effect<A, AiError, R>` (e.g. `turn`, `embed`,\n * `embedMany`, `synthesize`, `transcribe`). At-most-once.\n *\n * Composes with `Stream.retry` / `Effect.retry` directly (no naming\n * collision since these live under the `Retry` namespace).\n */\n\n/**\n * Wrapper around the retryable subset of `AiError`. Internal to the\n * lift/retry/unlift dance — exported so callers can build their own\n * variants if needed (e.g. a custom schedule that observes `cause`).\n */\nexport class Retryable extends Data.TaggedError(\"RetryableAi\")<{\n readonly cause: AiError.RateLimited | AiError.Unavailable | AiError.Timeout\n}> {}\n\n/** Type-narrowing predicate for the retryable subset. */\nexport const isRetryable = (\n e: AiError.AiError,\n): e is AiError.RateLimited | AiError.Unavailable | AiError.Timeout =>\n e._tag === \"RateLimited\" || e._tag === \"Unavailable\" || e._tag === \"Timeout\"\n\n// Lift events to Items, non-retryable failures to Terminal values\n// (escape retry), retryable failures to wrapped errors (only thing\n// retry sees).\ntype Lifted<A> =\n | { readonly _tag: \"Item\"; readonly value: A }\n | { readonly _tag: \"Terminal\"; readonly cause: AiError.AiError }\n\n/**\n * Retry a `Stream<A, AiError, R>` on the retryable subset. Other\n * failures bypass the schedule and propagate unchanged. The whole\n * stream replays on each attempt — deltas emitted before the failure\n * are re-emitted on the next try.\n */\nexport const stream =\n <Out>(schedule: Schedule.Schedule<Out, Retryable>) =>\n <A, R>(s: Stream.Stream<A, AiError.AiError, R>): Stream.Stream<A, AiError.AiError, R> =>\n s.pipe(\n Stream.map((value): Lifted<A> => ({ _tag: \"Item\", value })),\n Stream.catchIf(\n isRetryable,\n (cause) => Stream.fail(new Retryable({ cause })),\n (cause) => Stream.succeed<Lifted<A>>({ _tag: \"Terminal\", cause }),\n ),\n Stream.retry(schedule),\n Stream.catchTag(\"RetryableAi\", (e) => Stream.fail<AiError.AiError>(e.cause)),\n Stream.flatMap((item) =>\n item._tag === \"Item\" ? Stream.succeed(item.value) : Stream.fail(item.cause),\n ),\n )\n\n/**\n * Retry an `Effect<A, AiError, R>` on the retryable subset. Other\n * failures bypass the schedule and propagate unchanged. At-most-once\n * — the underlying call only re-runs if the previous attempt failed\n * with a retryable tag.\n *\n * Skips the lift/unlift dance the Stream variant needs: `Effect.retry`\n * exposes a `while:` predicate so the error channel stays a plain\n * `AiError` throughout.\n */\nexport const effect =\n <Out>(schedule: Schedule.Schedule<Out, AiError.AiError>) =>\n <A, R>(eff: Effect.Effect<A, AiError.AiError, R>): Effect.Effect<A, AiError.AiError, R> =>\n Effect.retry(eff, { schedule, while: isRetryable })\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AA8BA,IAAa,YAAb,cAA+B,KAAK,YAAY,cAAc,CAE3D;;AAGH,MAAa,eACX,MAEA,EAAE,SAAS,iBAAiB,EAAE,SAAS,iBAAiB,EAAE,SAAS;;;;;;;AAerE,MAAa,UACL,cACC,MACL,EAAE,KACA,OAAO,KAAK,WAAsB;CAAE,MAAM;CAAQ;CAAO,EAAE,EAC3D,OAAO,QACL,cACC,UAAU,OAAO,KAAK,IAAI,UAAU,EAAE,OAAO,CAAC,CAAC,GAC/C,UAAU,OAAO,QAAmB;CAAE,MAAM;CAAY;CAAO,CAAC,CAClE,EACD,OAAO,MAAM,SAAS,EACtB,OAAO,SAAS,gBAAgB,MAAM,OAAO,KAAsB,EAAE,MAAM,CAAC,EAC5E,OAAO,SAAS,SACd,KAAK,SAAS,SAAS,OAAO,QAAQ,KAAK,MAAM,GAAG,OAAO,KAAK,KAAK,MAAM,CAC5E,CACF;;;;;;;;;;;AAYL,MAAa,UACL,cACC,QACL,OAAO,MAAM,KAAK;CAAE;CAAU,OAAO;CAAa,CAAC"}
@@ -0,0 +1 @@
1
+ export { };
@@ -0,0 +1,142 @@
1
+ import { ContentFiltered, RateLimited, Timeout, Unavailable } from "../domain/AiError.mjs";
2
+ import { effect, stream } from "./Retry.mjs";
3
+ import { i as it, n as globalExpect, r as describe } from "../dist-DV5ISja1.mjs";
4
+ import { Cause, Effect, Exit, Option, Ref, Schedule, Stream } from "effect";
5
+ //#region src/retry/Retry.test.ts
6
+ const planned = (attempts, plan) => Stream.unwrap(Ref.getAndUpdate(attempts, (n) => n + 1).pipe(Effect.map((n) => plan[Math.min(n, plan.length - 1)])));
7
+ const plannedEffect = (attempts, plan) => Ref.getAndUpdate(attempts, (n) => n + 1).pipe(Effect.flatMap((n) => plan[Math.min(n, plan.length - 1)]));
8
+ describe("Retry.stream", () => {
9
+ it("retries on RateLimited and yields success on the next attempt", async () => {
10
+ const program = Effect.gen(function* () {
11
+ const attempts = yield* Ref.make(0);
12
+ const s = planned(attempts, [Stream.fail(new RateLimited({
13
+ provider: "mock",
14
+ raw: null
15
+ })), Stream.fromIterable(["a", "b"])]).pipe(stream(Schedule.recurs(3)));
16
+ const out = yield* Stream.runCollect(s);
17
+ const count = yield* Ref.get(attempts);
18
+ return {
19
+ out: Array.from(out),
20
+ count
21
+ };
22
+ });
23
+ const { out, count } = await Effect.runPromise(program);
24
+ globalExpect(count).toBe(2);
25
+ globalExpect(out).toEqual(["a", "b"]);
26
+ });
27
+ it("surfaces the underlying retryable failure when retries are exhausted", async () => {
28
+ const cause = new Unavailable({
29
+ provider: "mock",
30
+ raw: null
31
+ });
32
+ const s = Stream.fail(cause).pipe(stream(Schedule.recurs(2)));
33
+ const exit = await Effect.runPromise(Effect.exit(Stream.runCollect(s)));
34
+ globalExpect(Exit.isFailure(exit)).toBe(true);
35
+ if (Exit.isFailure(exit)) globalExpect(Cause.findErrorOption(exit.cause)).toEqual(Option.some(cause));
36
+ });
37
+ it("bypasses retry for non-retryable AiError (ContentFiltered)", async () => {
38
+ const program = Effect.gen(function* () {
39
+ const attempts = yield* Ref.make(0);
40
+ const cause = new ContentFiltered({
41
+ provider: "mock",
42
+ raw: null
43
+ });
44
+ const s = planned(attempts, [Stream.fail(cause)]).pipe(stream(Schedule.recurs(5)));
45
+ return {
46
+ exit: yield* Effect.exit(Stream.runCollect(s)),
47
+ count: yield* Ref.get(attempts),
48
+ cause
49
+ };
50
+ });
51
+ const { exit, count, cause } = await Effect.runPromise(program);
52
+ globalExpect(count).toBe(1);
53
+ globalExpect(Exit.isFailure(exit)).toBe(true);
54
+ if (Exit.isFailure(exit)) globalExpect(Cause.findErrorOption(exit.cause)).toEqual(Option.some(cause));
55
+ });
56
+ it("preserves items emitted before a retryable failure (and replays on retry)", async () => {
57
+ const program = Effect.gen(function* () {
58
+ const s = planned(yield* Ref.make(0), [Stream.concat(Stream.succeed("partial"), Stream.fail(new Timeout({
59
+ provider: "mock",
60
+ raw: null
61
+ }))), Stream.fromIterable(["partial", "done"])]).pipe(stream(Schedule.recurs(1)));
62
+ const out = yield* Stream.runCollect(s);
63
+ return Array.from(out);
64
+ });
65
+ globalExpect(await Effect.runPromise(program)).toEqual([
66
+ "partial",
67
+ "partial",
68
+ "done"
69
+ ]);
70
+ });
71
+ });
72
+ describe("Retry.effect", () => {
73
+ it("retries on RateLimited and yields success on the next attempt", async () => {
74
+ const program = Effect.gen(function* () {
75
+ const attempts = yield* Ref.make(0);
76
+ return {
77
+ value: yield* plannedEffect(attempts, [Effect.fail(new RateLimited({
78
+ provider: "mock",
79
+ raw: null
80
+ })), Effect.succeed("ok")]).pipe(effect(Schedule.recurs(3))),
81
+ count: yield* Ref.get(attempts)
82
+ };
83
+ });
84
+ const { value, count } = await Effect.runPromise(program);
85
+ globalExpect(count).toBe(2);
86
+ globalExpect(value).toBe("ok");
87
+ });
88
+ it("surfaces the underlying retryable failure when retries are exhausted", async () => {
89
+ const cause = new Unavailable({
90
+ provider: "mock",
91
+ raw: null
92
+ });
93
+ const eff = Effect.fail(cause).pipe(effect(Schedule.recurs(2)));
94
+ const exit = await Effect.runPromise(Effect.exit(eff));
95
+ globalExpect(Exit.isFailure(exit)).toBe(true);
96
+ if (Exit.isFailure(exit)) globalExpect(Cause.findErrorOption(exit.cause)).toEqual(Option.some(cause));
97
+ });
98
+ it("bypasses retry for non-retryable AiError (ContentFiltered)", async () => {
99
+ const program = Effect.gen(function* () {
100
+ const attempts = yield* Ref.make(0);
101
+ const cause = new ContentFiltered({
102
+ provider: "mock",
103
+ raw: null
104
+ });
105
+ return {
106
+ exit: yield* Effect.exit(plannedEffect(attempts, [Effect.fail(cause)]).pipe(effect(Schedule.recurs(5)))),
107
+ count: yield* Ref.get(attempts),
108
+ cause
109
+ };
110
+ });
111
+ const { exit, count, cause } = await Effect.runPromise(program);
112
+ globalExpect(count).toBe(1);
113
+ globalExpect(Exit.isFailure(exit)).toBe(true);
114
+ if (Exit.isFailure(exit)) globalExpect(Cause.findErrorOption(exit.cause)).toEqual(Option.some(cause));
115
+ });
116
+ it("does NOT replay successful prior work — at-most-once semantics", async () => {
117
+ const program = Effect.gen(function* () {
118
+ const attempts = yield* Ref.make(0);
119
+ return {
120
+ value: yield* plannedEffect(attempts, [
121
+ Effect.fail(new Timeout({
122
+ provider: "mock",
123
+ raw: null
124
+ })),
125
+ Effect.fail(new Timeout({
126
+ provider: "mock",
127
+ raw: null
128
+ })),
129
+ Effect.succeed("done")
130
+ ]).pipe(effect(Schedule.recurs(5))),
131
+ count: yield* Ref.get(attempts)
132
+ };
133
+ });
134
+ const { value, count } = await Effect.runPromise(program);
135
+ globalExpect(count).toBe(3);
136
+ globalExpect(value).toBe("done");
137
+ });
138
+ });
139
+ //#endregion
140
+ export {};
141
+
142
+ //# sourceMappingURL=Retry.test.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"Retry.test.mjs","names":["AiError.RateLimited","Retry.stream","AiError.Unavailable","AiError.ContentFiltered","AiError.Timeout","Retry.effect"],"sources":["../../src/retry/Retry.test.ts"],"sourcesContent":["import { Cause, Effect, Exit, Option, Ref, Schedule, Stream } from \"effect\"\nimport { describe, expect, it } from \"vitest\"\nimport * as AiError from \"../domain/AiError.js\"\nimport * as Retry from \"./Retry.js\"\n\n// Each call pulls the next plan entry; the last entry repeats forever\n// (so \"succeed on attempt N\" plans naturally re-yield success after).\nconst planned = <A, E>(\n attempts: Ref.Ref<number>,\n plan: ReadonlyArray<Stream.Stream<A, E>>,\n): Stream.Stream<A, E> =>\n Stream.unwrap(\n Ref.getAndUpdate(attempts, (n) => n + 1).pipe(\n Effect.map((n) => plan[Math.min(n, plan.length - 1)]!),\n ),\n )\n\nconst plannedEffect = <A, E>(\n attempts: Ref.Ref<number>,\n plan: ReadonlyArray<Effect.Effect<A, E>>,\n): Effect.Effect<A, E> =>\n Ref.getAndUpdate(attempts, (n) => n + 1).pipe(\n Effect.flatMap((n) => plan[Math.min(n, plan.length - 1)]!),\n )\n\ndescribe(\"Retry.stream\", () => {\n it(\"retries on RateLimited and yields success on the next attempt\", async () => {\n const program = Effect.gen(function* () {\n const attempts = yield* Ref.make(0)\n const s = planned(attempts, [\n Stream.fail<AiError.AiError>(new AiError.RateLimited({ provider: \"mock\", raw: null })),\n Stream.fromIterable([\"a\", \"b\"]),\n ]).pipe(Retry.stream(Schedule.recurs(3)))\n const out = yield* Stream.runCollect(s)\n const count = yield* Ref.get(attempts)\n return { out: Array.from(out), count }\n })\n\n const { out, count } = await Effect.runPromise(program)\n expect(count).toBe(2)\n expect(out).toEqual([\"a\", \"b\"])\n })\n\n it(\"surfaces the underlying retryable failure when retries are exhausted\", async () => {\n const cause = new AiError.Unavailable({ provider: \"mock\", raw: null })\n const s = Stream.fail<AiError.AiError>(cause).pipe(Retry.stream(Schedule.recurs(2)))\n\n const exit = await Effect.runPromise(Effect.exit(Stream.runCollect(s)))\n expect(Exit.isFailure(exit)).toBe(true)\n if (Exit.isFailure(exit)) {\n expect(Cause.findErrorOption(exit.cause)).toEqual(Option.some(cause))\n }\n })\n\n it(\"bypasses retry for non-retryable AiError (ContentFiltered)\", async () => {\n const program = Effect.gen(function* () {\n const attempts = yield* Ref.make(0)\n const cause = new AiError.ContentFiltered({ provider: \"mock\", raw: null })\n const s = planned(attempts, [Stream.fail<AiError.AiError>(cause)]).pipe(\n Retry.stream(Schedule.recurs(5)),\n )\n const exit = yield* Effect.exit(Stream.runCollect(s))\n const count = yield* Ref.get(attempts)\n return { exit, count, cause }\n })\n\n const { exit, count, cause } = await Effect.runPromise(program)\n expect(count).toBe(1)\n expect(Exit.isFailure(exit)).toBe(true)\n if (Exit.isFailure(exit)) {\n expect(Cause.findErrorOption(exit.cause)).toEqual(Option.some(cause))\n }\n })\n\n it(\"preserves items emitted before a retryable failure (and replays on retry)\", async () => {\n // Documents the 'replays on retry' caveat: the entire stream re-runs.\n const program = Effect.gen(function* () {\n const attempts = yield* Ref.make(0)\n const s = planned(attempts, [\n Stream.concat(\n Stream.succeed(\"partial\"),\n Stream.fail<AiError.AiError>(new AiError.Timeout({ provider: \"mock\", raw: null })),\n ),\n Stream.fromIterable([\"partial\", \"done\"]),\n ]).pipe(Retry.stream(Schedule.recurs(1)))\n const out = yield* Stream.runCollect(s)\n return Array.from(out)\n })\n\n const out = await Effect.runPromise(program)\n expect(out).toEqual([\"partial\", \"partial\", \"done\"])\n })\n})\n\ndescribe(\"Retry.effect\", () => {\n it(\"retries on RateLimited and yields success on the next attempt\", async () => {\n const program = Effect.gen(function* () {\n const attempts = yield* Ref.make(0)\n const value = yield* plannedEffect(attempts, [\n Effect.fail<AiError.AiError>(new AiError.RateLimited({ provider: \"mock\", raw: null })),\n Effect.succeed(\"ok\"),\n ]).pipe(Retry.effect(Schedule.recurs(3)))\n const count = yield* Ref.get(attempts)\n return { value, count }\n })\n\n const { value, count } = await Effect.runPromise(program)\n expect(count).toBe(2)\n expect(value).toBe(\"ok\")\n })\n\n it(\"surfaces the underlying retryable failure when retries are exhausted\", async () => {\n const cause = new AiError.Unavailable({ provider: \"mock\", raw: null })\n const eff = Effect.fail<AiError.AiError>(cause).pipe(Retry.effect(Schedule.recurs(2)))\n\n const exit = await Effect.runPromise(Effect.exit(eff))\n expect(Exit.isFailure(exit)).toBe(true)\n if (Exit.isFailure(exit)) {\n expect(Cause.findErrorOption(exit.cause)).toEqual(Option.some(cause))\n }\n })\n\n it(\"bypasses retry for non-retryable AiError (ContentFiltered)\", async () => {\n const program = Effect.gen(function* () {\n const attempts = yield* Ref.make(0)\n const cause = new AiError.ContentFiltered({ provider: \"mock\", raw: null })\n const exit = yield* Effect.exit(\n plannedEffect(attempts, [Effect.fail<AiError.AiError>(cause)]).pipe(\n Retry.effect(Schedule.recurs(5)),\n ),\n )\n const count = yield* Ref.get(attempts)\n return { exit, count, cause }\n })\n\n const { exit, count, cause } = await Effect.runPromise(program)\n expect(count).toBe(1)\n expect(Exit.isFailure(exit)).toBe(true)\n if (Exit.isFailure(exit)) {\n expect(Cause.findErrorOption(exit.cause)).toEqual(Option.some(cause))\n }\n })\n\n it(\"does NOT replay successful prior work — at-most-once semantics\", async () => {\n // Contrast with Retry.stream: an Effect.retry only re-invokes the\n // failing computation. No replay of work done before the failure\n // (there's nothing to replay — Effects are single-shot).\n const program = Effect.gen(function* () {\n const attempts = yield* Ref.make(0)\n const value = yield* plannedEffect(attempts, [\n Effect.fail<AiError.AiError>(new AiError.Timeout({ provider: \"mock\", raw: null })),\n Effect.fail<AiError.AiError>(new AiError.Timeout({ provider: \"mock\", raw: null })),\n Effect.succeed(\"done\"),\n ]).pipe(Retry.effect(Schedule.recurs(5)))\n const count = yield* Ref.get(attempts)\n return { value, count }\n })\n\n const { value, count } = await Effect.runPromise(program)\n expect(count).toBe(3)\n expect(value).toBe(\"done\")\n })\n})\n"],"mappings":"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"}
@@ -1,5 +1,5 @@
1
- import { d as Item } from "../Items-DqbaJoz7.mjs";
2
- import { r as Turn } from "../Turn-ChbL2foc.mjs";
1
+ import { d as Item } from "../Items-BH8xUkoR.mjs";
2
+ import { r as Turn } from "../Turn-0CwCAyVe.mjs";
3
3
  import { LanguageModel, LanguageModelService } from "../language-model/LanguageModel.mjs";
4
4
  import { Duration, Effect, Layer } from "effect";
5
5
 
@@ -1 +1 @@
1
- {"version":3,"file":"MockProvider.d.mts","names":[],"sources":["../../src/testing/MockProvider.ts"],"mappings":";;;;;;KAMY,WAAA;;AAAZ;;;;WAMW,aAAA,GAAgB,QAAA,CAAS,KAAA;AAAA;AAAA,KAGxB,IAAA;EAAA,SACD,OAAA,EAAS,aAAA,CAAc,IAAA;EAAA,SACvB,IAAA,EAAM,IAAA;AAAA;;;;;;;KASL,YAAA;EAAA,SACD,KAAA,EAAO,aAAA,CAAc,IAAA;AAAA;;;;;cAqGnB,KAAA,GACX,aAAA,EAAe,aAAA,CAAc,IAAA,GAC7B,OAAA,GAAU,WAAA,KACT,KAAA,CAAM,KAAA,CAAM,aAAA;AAzGf;;;;AAAA,cAqHa,iBAAA,GACX,aAAA,EAAe,aAAA,CAAc,IAAA,GAC7B,OAAA,GAAU,WAAA;EAAA,SAED,KAAA,EAAO,KAAA,CAAM,KAAA,CAAM,aAAA;EAAA,SACnB,QAAA,EAAU,MAAA,CAAO,MAAA,CAAO,YAAA;AAAA;;AApBnC;;;;;cAwCa,IAAA,GACX,aAAA,EAAe,aAAA,CAAc,IAAA,GAC7B,OAAA,GAAU,WAAA;EAAA,SAED,OAAA,EAAS,oBAAA;EAAA,SACT,QAAA,EAAU,MAAA,CAAO,MAAA,CAAO,YAAA;AAAA"}
1
+ {"version":3,"file":"MockProvider.d.mts","names":[],"sources":["../../src/testing/MockProvider.ts"],"mappings":";;;;;;KAUY,WAAA;;AAAZ;;;;WAMW,aAAA,GAAgB,QAAA,CAAS,KAAA;AAAA;AAAA,KAGxB,IAAA;EAAA,SACD,OAAA,EAAS,aAAA,CAAc,IAAA;EAAA,SACvB,IAAA,EAAM,IAAA;AAAA;;;;;;;KASL,YAAA;EAAA,SACD,KAAA,EAAO,aAAA,CAAc,IAAA;AAAA;;;;;cAsGnB,KAAA,GACX,aAAA,EAAe,aAAA,CAAc,IAAA,GAC7B,OAAA,GAAU,WAAA,KACT,KAAA,CAAM,KAAA,CAAM,aAAA;AA1Gf;;;;AAAA,cAsHa,iBAAA,GACX,aAAA,EAAe,aAAA,CAAc,IAAA,GAC7B,OAAA,GAAU,WAAA;EAAA,SAED,KAAA,EAAO,KAAA,CAAM,KAAA,CAAM,aAAA;EAAA,SACnB,QAAA,EAAU,MAAA,CAAO,MAAA,CAAO,YAAA;AAAA;;AApBnC;;;;;cAwCa,IAAA,GACX,aAAA,EAAe,aAAA,CAAc,IAAA,GAC7B,OAAA,GAAU,WAAA;EAAA,SAED,OAAA,EAAS,oBAAA;EAAA,SACT,QAAA,EAAU,MAAA,CAAO,MAAA,CAAO,YAAA;AAAA"}
@@ -1,7 +1,7 @@
1
1
  import { InvalidRequest } from "../domain/AiError.mjs";
2
2
  import { isOutputText } from "../domain/Items.mjs";
3
3
  import { TurnEvent } from "../domain/Turn.mjs";
4
- import { LanguageModel } from "../language-model/LanguageModel.mjs";
4
+ import { LanguageModel, turnFromStream } from "../language-model/LanguageModel.mjs";
5
5
  import { Array, Effect, Layer, Match, Option, Ref, Schedule, Stream } from "effect";
6
6
  //#region src/testing/MockProvider.ts
7
7
  const itemToDeltas = Match.type().pipe(Match.discriminators("type")({
@@ -29,13 +29,19 @@ const exhausted = (n, attempt) => new InvalidRequest({
29
29
  raw: `MockProvider exhausted: ${n} turns scripted, but call ${attempt} was made`
30
30
  });
31
31
  const noRecord = (_) => Effect.void;
32
- const buildService = (scriptedTurns, options, cursor, record) => ({ streamTurn: (request) => Stream.unwrap(Ref.getAndUpdate(cursor, (n) => n + 1).pipe(Effect.flatMap((i) => Option.match(Array.get(scriptedTurns, i), {
33
- onNone: () => Effect.succeed(Stream.fail(exhausted(scriptedTurns.length, i + 1))),
34
- onSome: (turn) => record({
35
- history: request.history,
36
- turn
37
- }).pipe(Effect.as(pacedDeltas(turn, options)))
38
- })))) });
32
+ const buildService = (scriptedTurns, options, cursor, record) => {
33
+ const streamTurn = (request) => Stream.unwrap(Ref.getAndUpdate(cursor, (n) => n + 1).pipe(Effect.flatMap((i) => Option.match(Array.get(scriptedTurns, i), {
34
+ onNone: () => Effect.succeed(Stream.fail(exhausted(scriptedTurns.length, i + 1))),
35
+ onSome: (turn) => record({
36
+ history: request.history,
37
+ turn
38
+ }).pipe(Effect.as(pacedDeltas(turn, options)))
39
+ }))));
40
+ return {
41
+ streamTurn,
42
+ turn: turnFromStream(streamTurn)
43
+ };
44
+ };
39
45
  const makeRecorderUnsafe = () => {
40
46
  const ref = Ref.makeUnsafe([]);
41
47
  return {
@@ -1 +1 @@
1
- {"version":3,"file":"MockProvider.mjs","names":["AiError.InvalidRequest","Arr"],"sources":["../../src/testing/MockProvider.ts"],"sourcesContent":["import { Array as Arr, Duration, Effect, Layer, Match, Option, Ref, Schedule, Stream } from \"effect\"\nimport * as AiError from \"../domain/AiError.js\"\nimport { type Item, isOutputText } from \"../domain/Items.js\"\nimport { LanguageModel, type LanguageModelService } from \"../language-model/LanguageModel.js\"\nimport { type Turn, TurnEvent } from \"../domain/Turn.js\"\n\nexport type MockOptions = {\n /**\n * If set, deltas of each scripted turn are spaced by this duration via\n * `Schedule.spaced`. Combine with `TestClock.adjust` for deterministic\n * timing in tests.\n */\n readonly deltaInterval?: Duration.Input\n}\n\nexport type Call = {\n readonly history: ReadonlyArray<Item>\n readonly turn: Turn\n}\n\n/**\n * A scripted mock provider. Pre-canned `Turn` outputs are returned in order,\n * one per call to `streamTurn`. Each scripted turn is split into synthetic\n * deltas (text → ToolCallStart → ToolCallArgsDelta → ... → TurnComplete)\n * so streaming consumers can see realistic delta shapes.\n */\nexport type MockRecorder = {\n readonly calls: ReadonlyArray<Call>\n}\n\n// ---------------------------------------------------------------------------\n// Pure projection: Turn → ReadonlyArray<TurnEvent>\n// ---------------------------------------------------------------------------\n\nconst itemToDeltas: (item: Item) => ReadonlyArray<TurnEvent> = Match.type<Item>().pipe(\n Match.discriminators(\"type\")({\n message: (m): ReadonlyArray<TurnEvent> =>\n m.role === \"assistant\"\n ? m.content.filter(isOutputText).map((b) => TurnEvent.TextDelta({ text: b.text }))\n : [],\n function_call: (fc) => [\n TurnEvent.ToolCallStart({ call_id: fc.call_id, name: fc.name }),\n TurnEvent.ToolCallArgsDelta({ call_id: fc.call_id, delta: fc.arguments }),\n ],\n function_call_output: () => [],\n reasoning: (r) =>\n r.summary !== undefined\n ? [TurnEvent.ReasoningDelta({ text: r.summary, kind: \"summary\" as const })]\n : [],\n }),\n Match.exhaustive,\n)\n\nconst turnToDeltas = (turn: Turn): ReadonlyArray<TurnEvent> => [\n ...turn.items.flatMap(itemToDeltas),\n TurnEvent.TurnComplete({ turn }),\n]\n\nconst pacedDeltas = (turn: Turn, options?: MockOptions): Stream.Stream<TurnEvent> => {\n const base = Stream.fromIterable(turnToDeltas(turn))\n return options?.deltaInterval === undefined\n ? base\n : base.pipe(Stream.schedule(Schedule.spaced(options.deltaInterval)))\n}\n\n// ---------------------------------------------------------------------------\n// Canonical service factory. One implementation; sync/Layer/recorder\n// variants below are just different ways to wire the cursor + record hook.\n// ---------------------------------------------------------------------------\n\nconst exhausted = (n: number, attempt: number): AiError.AiError =>\n new AiError.InvalidRequest({\n provider: \"mock\",\n raw: `MockProvider exhausted: ${n} turns scripted, but call ${attempt} was made`,\n })\n\nconst noRecord = (_: Call): Effect.Effect<void> => Effect.void\n\nconst buildService = (\n scriptedTurns: ReadonlyArray<Turn>,\n options: MockOptions | undefined,\n cursor: Ref.Ref<number>,\n record: (call: Call) => Effect.Effect<void>,\n): LanguageModelService => ({\n streamTurn: (request) =>\n Stream.unwrap(\n Ref.getAndUpdate(cursor, (n) => n + 1).pipe(\n Effect.flatMap(\n (i): Effect.Effect<Stream.Stream<TurnEvent, AiError.AiError>> =>\n Option.match(Arr.get(scriptedTurns, i), {\n onNone: () => Effect.succeed(Stream.fail(exhausted(scriptedTurns.length, i + 1))),\n onSome: (turn) =>\n record({ history: request.history, turn }).pipe(\n Effect.as(pacedDeltas(turn, options)),\n ),\n }),\n ),\n ),\n ),\n})\n\n// ---------------------------------------------------------------------------\n// Recorder handle. Unsafe Ref is local: it backs both the `record` write\n// hook (called inside the service) and the `recorder` read effect (called\n// by the test). Both close over the same cell.\n// ---------------------------------------------------------------------------\n\ntype RecorderHandle = {\n readonly record: (call: Call) => Effect.Effect<void>\n readonly recorder: Effect.Effect<MockRecorder>\n}\n\nconst makeRecorderUnsafe = (): RecorderHandle => {\n const ref = Ref.makeUnsafe<ReadonlyArray<Call>>([])\n return {\n record: (call) => Ref.update(ref, Arr.append(call)),\n recorder: Ref.get(ref).pipe(Effect.map((calls): MockRecorder => ({ calls }))),\n }\n}\n\n// ---------------------------------------------------------------------------\n// Public API\n// ---------------------------------------------------------------------------\n\n/**\n * Layer that registers a `MockProvider` against the `LanguageModel` tag.\n * Calls beyond the scripted turn count fail with `InvalidRequest`.\n */\nexport const layer = (\n scriptedTurns: ReadonlyArray<Turn>,\n options?: MockOptions,\n): Layer.Layer<LanguageModel> =>\n Layer.effect(\n LanguageModel,\n Ref.make(0).pipe(\n Effect.map((cursor) => buildService(scriptedTurns, options, cursor, noRecord)),\n ),\n )\n\n/**\n * Like `layer`, but also exposes a recorder that captures every call\n * (history + returned turn).\n */\nexport const layerWithRecorder = (\n scriptedTurns: ReadonlyArray<Turn>,\n options?: MockOptions,\n): {\n readonly layer: Layer.Layer<LanguageModel>\n readonly recorder: Effect.Effect<MockRecorder>\n} => {\n const { record, recorder } = makeRecorderUnsafe()\n return {\n layer: Layer.effect(\n LanguageModel,\n Ref.make(0).pipe(\n Effect.map((cursor) => buildService(scriptedTurns, options, cursor, record)),\n ),\n ),\n recorder,\n }\n}\n\n/**\n * Build the `LanguageModelService` value directly (no Layer), plus a\n * recorder. Use this when you want to swap models mid-program via\n * `Effect.provideService` instead of providing one model for the whole\n * program via `Layer`.\n */\nexport const make = (\n scriptedTurns: ReadonlyArray<Turn>,\n options?: MockOptions,\n): {\n readonly service: LanguageModelService\n readonly recorder: Effect.Effect<MockRecorder>\n} => {\n const cursor = Ref.makeUnsafe(0)\n const { record, recorder } = makeRecorderUnsafe()\n return {\n service: buildService(scriptedTurns, options, cursor, record),\n recorder,\n }\n}\n"],"mappings":";;;;;;AAkCA,MAAM,eAAyD,MAAM,MAAY,CAAC,KAChF,MAAM,eAAe,OAAO,CAAC;CAC3B,UAAU,MACR,EAAE,SAAS,cACP,EAAE,QAAQ,OAAO,aAAa,CAAC,KAAK,MAAM,UAAU,UAAU,EAAE,MAAM,EAAE,MAAM,CAAC,CAAC,GAChF,EAAE;CACR,gBAAgB,OAAO,CACrB,UAAU,cAAc;EAAE,SAAS,GAAG;EAAS,MAAM,GAAG;EAAM,CAAC,EAC/D,UAAU,kBAAkB;EAAE,SAAS,GAAG;EAAS,OAAO,GAAG;EAAW,CAAC,CAC1E;CACD,4BAA4B,EAAE;CAC9B,YAAY,MACV,EAAE,YAAY,KAAA,IACV,CAAC,UAAU,eAAe;EAAE,MAAM,EAAE;EAAS,MAAM;EAAoB,CAAC,CAAC,GACzE,EAAE;CACT,CAAC,EACF,MAAM,WACP;AAED,MAAM,gBAAgB,SAAyC,CAC7D,GAAG,KAAK,MAAM,QAAQ,aAAa,EACnC,UAAU,aAAa,EAAE,MAAM,CAAC,CACjC;AAED,MAAM,eAAe,MAAY,YAAoD;CACnF,MAAM,OAAO,OAAO,aAAa,aAAa,KAAK,CAAC;AACpD,QAAO,SAAS,kBAAkB,KAAA,IAC9B,OACA,KAAK,KAAK,OAAO,SAAS,SAAS,OAAO,QAAQ,cAAc,CAAC,CAAC;;AAQxE,MAAM,aAAa,GAAW,YAC5B,IAAIA,eAAuB;CACzB,UAAU;CACV,KAAK,2BAA2B,EAAE,4BAA4B,QAAQ;CACvE,CAAC;AAEJ,MAAM,YAAY,MAAiC,OAAO;AAE1D,MAAM,gBACJ,eACA,SACA,QACA,YAC0B,EAC1B,aAAa,YACX,OAAO,OACL,IAAI,aAAa,SAAS,MAAM,IAAI,EAAE,CAAC,KACrC,OAAO,SACJ,MACC,OAAO,MAAMC,MAAI,IAAI,eAAe,EAAE,EAAE;CACtC,cAAc,OAAO,QAAQ,OAAO,KAAK,UAAU,cAAc,QAAQ,IAAI,EAAE,CAAC,CAAC;CACjF,SAAS,SACP,OAAO;EAAE,SAAS,QAAQ;EAAS;EAAM,CAAC,CAAC,KACzC,OAAO,GAAG,YAAY,MAAM,QAAQ,CAAC,CACtC;CACJ,CAAC,CACL,CACF,CACF,EACJ;AAaD,MAAM,2BAA2C;CAC/C,MAAM,MAAM,IAAI,WAAgC,EAAE,CAAC;AACnD,QAAO;EACL,SAAS,SAAS,IAAI,OAAO,KAAKA,MAAI,OAAO,KAAK,CAAC;EACnD,UAAU,IAAI,IAAI,IAAI,CAAC,KAAK,OAAO,KAAK,WAAyB,EAAE,OAAO,EAAE,CAAC;EAC9E;;;;;;AAWH,MAAa,SACX,eACA,YAEA,MAAM,OACJ,eACA,IAAI,KAAK,EAAE,CAAC,KACV,OAAO,KAAK,WAAW,aAAa,eAAe,SAAS,QAAQ,SAAS,CAAC,CAC/E,CACF;;;;;AAMH,MAAa,qBACX,eACA,YAIG;CACH,MAAM,EAAE,QAAQ,aAAa,oBAAoB;AACjD,QAAO;EACL,OAAO,MAAM,OACX,eACA,IAAI,KAAK,EAAE,CAAC,KACV,OAAO,KAAK,WAAW,aAAa,eAAe,SAAS,QAAQ,OAAO,CAAC,CAC7E,CACF;EACD;EACD;;;;;;;;AASH,MAAa,QACX,eACA,YAIG;CACH,MAAM,SAAS,IAAI,WAAW,EAAE;CAChC,MAAM,EAAE,QAAQ,aAAa,oBAAoB;AACjD,QAAO;EACL,SAAS,aAAa,eAAe,SAAS,QAAQ,OAAO;EAC7D;EACD"}
1
+ {"version":3,"file":"MockProvider.mjs","names":["AiError.InvalidRequest","Arr"],"sources":["../../src/testing/MockProvider.ts"],"sourcesContent":["import { Array as Arr, Duration, Effect, Layer, Match, Option, Ref, Schedule, Stream } from \"effect\"\nimport * as AiError from \"../domain/AiError.js\"\nimport { type Item, isOutputText } from \"../domain/Items.js\"\nimport {\n LanguageModel,\n type LanguageModelService,\n turnFromStream,\n} from \"../language-model/LanguageModel.js\"\nimport { type Turn, TurnEvent } from \"../domain/Turn.js\"\n\nexport type MockOptions = {\n /**\n * If set, deltas of each scripted turn are spaced by this duration via\n * `Schedule.spaced`. Combine with `TestClock.adjust` for deterministic\n * timing in tests.\n */\n readonly deltaInterval?: Duration.Input\n}\n\nexport type Call = {\n readonly history: ReadonlyArray<Item>\n readonly turn: Turn\n}\n\n/**\n * A scripted mock provider. Pre-canned `Turn` outputs are returned in order,\n * one per call to `streamTurn`. Each scripted turn is split into synthetic\n * deltas (text → ToolCallStart → ToolCallArgsDelta → ... → TurnComplete)\n * so streaming consumers can see realistic delta shapes.\n */\nexport type MockRecorder = {\n readonly calls: ReadonlyArray<Call>\n}\n\n// ---------------------------------------------------------------------------\n// Pure projection: Turn → ReadonlyArray<TurnEvent>\n// ---------------------------------------------------------------------------\n\nconst itemToDeltas: (item: Item) => ReadonlyArray<TurnEvent> = Match.type<Item>().pipe(\n Match.discriminators(\"type\")({\n message: (m): ReadonlyArray<TurnEvent> =>\n m.role === \"assistant\"\n ? m.content.filter(isOutputText).map((b) => TurnEvent.TextDelta({ text: b.text }))\n : [],\n function_call: (fc) => [\n TurnEvent.ToolCallStart({ call_id: fc.call_id, name: fc.name }),\n TurnEvent.ToolCallArgsDelta({ call_id: fc.call_id, delta: fc.arguments }),\n ],\n function_call_output: () => [],\n reasoning: (r) =>\n r.summary !== undefined\n ? [TurnEvent.ReasoningDelta({ text: r.summary, kind: \"summary\" as const })]\n : [],\n }),\n Match.exhaustive,\n)\n\nconst turnToDeltas = (turn: Turn): ReadonlyArray<TurnEvent> => [\n ...turn.items.flatMap(itemToDeltas),\n TurnEvent.TurnComplete({ turn }),\n]\n\nconst pacedDeltas = (turn: Turn, options?: MockOptions): Stream.Stream<TurnEvent> => {\n const base = Stream.fromIterable(turnToDeltas(turn))\n return options?.deltaInterval === undefined\n ? base\n : base.pipe(Stream.schedule(Schedule.spaced(options.deltaInterval)))\n}\n\n// ---------------------------------------------------------------------------\n// Canonical service factory. One implementation; sync/Layer/recorder\n// variants below are just different ways to wire the cursor + record hook.\n// ---------------------------------------------------------------------------\n\nconst exhausted = (n: number, attempt: number): AiError.AiError =>\n new AiError.InvalidRequest({\n provider: \"mock\",\n raw: `MockProvider exhausted: ${n} turns scripted, but call ${attempt} was made`,\n })\n\nconst noRecord = (_: Call): Effect.Effect<void> => Effect.void\n\nconst buildService = (\n scriptedTurns: ReadonlyArray<Turn>,\n options: MockOptions | undefined,\n cursor: Ref.Ref<number>,\n record: (call: Call) => Effect.Effect<void>,\n): LanguageModelService => {\n const streamTurn: LanguageModelService[\"streamTurn\"] = (request) =>\n Stream.unwrap(\n Ref.getAndUpdate(cursor, (n) => n + 1).pipe(\n Effect.flatMap(\n (i): Effect.Effect<Stream.Stream<TurnEvent, AiError.AiError>> =>\n Option.match(Arr.get(scriptedTurns, i), {\n onNone: () => Effect.succeed(Stream.fail(exhausted(scriptedTurns.length, i + 1))),\n onSome: (turn) =>\n record({ history: request.history, turn }).pipe(\n Effect.as(pacedDeltas(turn, options)),\n ),\n }),\n ),\n ),\n )\n return { streamTurn, turn: turnFromStream(streamTurn) }\n}\n\n// ---------------------------------------------------------------------------\n// Recorder handle. Unsafe Ref is local: it backs both the `record` write\n// hook (called inside the service) and the `recorder` read effect (called\n// by the test). Both close over the same cell.\n// ---------------------------------------------------------------------------\n\ntype RecorderHandle = {\n readonly record: (call: Call) => Effect.Effect<void>\n readonly recorder: Effect.Effect<MockRecorder>\n}\n\nconst makeRecorderUnsafe = (): RecorderHandle => {\n const ref = Ref.makeUnsafe<ReadonlyArray<Call>>([])\n return {\n record: (call) => Ref.update(ref, Arr.append(call)),\n recorder: Ref.get(ref).pipe(Effect.map((calls): MockRecorder => ({ calls }))),\n }\n}\n\n// ---------------------------------------------------------------------------\n// Public API\n// ---------------------------------------------------------------------------\n\n/**\n * Layer that registers a `MockProvider` against the `LanguageModel` tag.\n * Calls beyond the scripted turn count fail with `InvalidRequest`.\n */\nexport const layer = (\n scriptedTurns: ReadonlyArray<Turn>,\n options?: MockOptions,\n): Layer.Layer<LanguageModel> =>\n Layer.effect(\n LanguageModel,\n Ref.make(0).pipe(\n Effect.map((cursor) => buildService(scriptedTurns, options, cursor, noRecord)),\n ),\n )\n\n/**\n * Like `layer`, but also exposes a recorder that captures every call\n * (history + returned turn).\n */\nexport const layerWithRecorder = (\n scriptedTurns: ReadonlyArray<Turn>,\n options?: MockOptions,\n): {\n readonly layer: Layer.Layer<LanguageModel>\n readonly recorder: Effect.Effect<MockRecorder>\n} => {\n const { record, recorder } = makeRecorderUnsafe()\n return {\n layer: Layer.effect(\n LanguageModel,\n Ref.make(0).pipe(\n Effect.map((cursor) => buildService(scriptedTurns, options, cursor, record)),\n ),\n ),\n recorder,\n }\n}\n\n/**\n * Build the `LanguageModelService` value directly (no Layer), plus a\n * recorder. Use this when you want to swap models mid-program via\n * `Effect.provideService` instead of providing one model for the whole\n * program via `Layer`.\n */\nexport const make = (\n scriptedTurns: ReadonlyArray<Turn>,\n options?: MockOptions,\n): {\n readonly service: LanguageModelService\n readonly recorder: Effect.Effect<MockRecorder>\n} => {\n const cursor = Ref.makeUnsafe(0)\n const { record, recorder } = makeRecorderUnsafe()\n return {\n service: buildService(scriptedTurns, options, cursor, record),\n recorder,\n }\n}\n"],"mappings":";;;;;;AAsCA,MAAM,eAAyD,MAAM,MAAY,CAAC,KAChF,MAAM,eAAe,OAAO,CAAC;CAC3B,UAAU,MACR,EAAE,SAAS,cACP,EAAE,QAAQ,OAAO,aAAa,CAAC,KAAK,MAAM,UAAU,UAAU,EAAE,MAAM,EAAE,MAAM,CAAC,CAAC,GAChF,EAAE;CACR,gBAAgB,OAAO,CACrB,UAAU,cAAc;EAAE,SAAS,GAAG;EAAS,MAAM,GAAG;EAAM,CAAC,EAC/D,UAAU,kBAAkB;EAAE,SAAS,GAAG;EAAS,OAAO,GAAG;EAAW,CAAC,CAC1E;CACD,4BAA4B,EAAE;CAC9B,YAAY,MACV,EAAE,YAAY,KAAA,IACV,CAAC,UAAU,eAAe;EAAE,MAAM,EAAE;EAAS,MAAM;EAAoB,CAAC,CAAC,GACzE,EAAE;CACT,CAAC,EACF,MAAM,WACP;AAED,MAAM,gBAAgB,SAAyC,CAC7D,GAAG,KAAK,MAAM,QAAQ,aAAa,EACnC,UAAU,aAAa,EAAE,MAAM,CAAC,CACjC;AAED,MAAM,eAAe,MAAY,YAAoD;CACnF,MAAM,OAAO,OAAO,aAAa,aAAa,KAAK,CAAC;AACpD,QAAO,SAAS,kBAAkB,KAAA,IAC9B,OACA,KAAK,KAAK,OAAO,SAAS,SAAS,OAAO,QAAQ,cAAc,CAAC,CAAC;;AAQxE,MAAM,aAAa,GAAW,YAC5B,IAAIA,eAAuB;CACzB,UAAU;CACV,KAAK,2BAA2B,EAAE,4BAA4B,QAAQ;CACvE,CAAC;AAEJ,MAAM,YAAY,MAAiC,OAAO;AAE1D,MAAM,gBACJ,eACA,SACA,QACA,WACyB;CACzB,MAAM,cAAkD,YACtD,OAAO,OACL,IAAI,aAAa,SAAS,MAAM,IAAI,EAAE,CAAC,KACrC,OAAO,SACJ,MACC,OAAO,MAAMC,MAAI,IAAI,eAAe,EAAE,EAAE;EACtC,cAAc,OAAO,QAAQ,OAAO,KAAK,UAAU,cAAc,QAAQ,IAAI,EAAE,CAAC,CAAC;EACjF,SAAS,SACP,OAAO;GAAE,SAAS,QAAQ;GAAS;GAAM,CAAC,CAAC,KACzC,OAAO,GAAG,YAAY,MAAM,QAAQ,CAAC,CACtC;EACJ,CAAC,CACL,CACF,CACF;AACH,QAAO;EAAE;EAAY,MAAM,eAAe,WAAW;EAAE;;AAczD,MAAM,2BAA2C;CAC/C,MAAM,MAAM,IAAI,WAAgC,EAAE,CAAC;AACnD,QAAO;EACL,SAAS,SAAS,IAAI,OAAO,KAAKA,MAAI,OAAO,KAAK,CAAC;EACnD,UAAU,IAAI,IAAI,IAAI,CAAC,KAAK,OAAO,KAAK,WAAyB,EAAE,OAAO,EAAE,CAAC;EAC9E;;;;;;AAWH,MAAa,SACX,eACA,YAEA,MAAM,OACJ,eACA,IAAI,KAAK,EAAE,CAAC,KACV,OAAO,KAAK,WAAW,aAAa,eAAe,SAAS,QAAQ,SAAS,CAAC,CAC/E,CACF;;;;;AAMH,MAAa,qBACX,eACA,YAIG;CACH,MAAM,EAAE,QAAQ,aAAa,oBAAoB;AACjD,QAAO;EACL,OAAO,MAAM,OACX,eACA,IAAI,KAAK,EAAE,CAAC,KACV,OAAO,KAAK,WAAW,aAAa,eAAe,SAAS,QAAQ,OAAO,CAAC,CAC7E,CACF;EACD;EACD;;;;;;;;AASH,MAAa,QACX,eACA,YAIG;CACH,MAAM,SAAS,IAAI,WAAW,EAAE;CAChC,MAAM,EAAE,QAAQ,aAAa,oBAAoB;AACjD,QAAO;EACL,SAAS,aAAa,eAAe,SAAS,QAAQ,OAAO;EAC7D;EACD"}
@@ -1,4 +1,4 @@
1
- import { d as Item, o as FunctionCall } from "../Items-DqbaJoz7.mjs";
1
+ import { d as Item, o as FunctionCall } from "../Items-BH8xUkoR.mjs";
2
2
  import { ToolResult } from "./Outcome.mjs";
3
3
 
4
4
  //#region src/tool/HistoryCheck.d.ts
@@ -1,4 +1,4 @@
1
- import { o as FunctionCall, s as FunctionCallOutput } from "../Items-DqbaJoz7.mjs";
1
+ import { o as FunctionCall, s as FunctionCallOutput } from "../Items-BH8xUkoR.mjs";
2
2
  import { Data } from "effect";
3
3
  import * as _$effect_Unify0 from "effect/Unify";
4
4
 
@@ -1,4 +1,4 @@
1
- import { o as FunctionCall } from "../Items-DqbaJoz7.mjs";
1
+ import { o as FunctionCall } from "../Items-BH8xUkoR.mjs";
2
2
  import { ToolResult } from "./Outcome.mjs";
3
3
  import { ToolEvent } from "./ToolEvent.mjs";
4
4
  import { Data, Effect, Queue, Scope, Stream } from "effect";
@@ -1,2 +1,2 @@
1
- import { a as Tool, c as ToolInputSchema, d as fromEffectSchema, f as fromStandardSchema, g as toDescriptors, h as streaming, i as StreamingTool, m as make, n as AnyPlainTool, o as ToolDescriptor, p as isStreamingTool, r as AnyStreamingTool, s as ToolError, t as AnyKindTool, u as execute } from "../Tool-Y0__Py1H.mjs";
1
+ import { a as Tool, c as ToolInputSchema, d as fromEffectSchema, f as fromStandardSchema, g as toDescriptors, h as streaming, i as StreamingTool, m as make, n as AnyPlainTool, o as ToolDescriptor, p as isStreamingTool, r as AnyStreamingTool, s as ToolError, t as AnyKindTool, u as execute } from "../Tool-87ViKCCO.mjs";
2
2
  export { AnyKindTool, AnyPlainTool, AnyStreamingTool, StreamingTool, Tool, ToolDescriptor, ToolError, ToolInputSchema, execute, fromEffectSchema, fromStandardSchema, isStreamingTool, make, streaming, toDescriptors };
@@ -1,5 +1,5 @@
1
- import { o as FunctionCall } from "../Items-DqbaJoz7.mjs";
2
- import { a as Tool, i as StreamingTool, o as ToolDescriptor, t as AnyKindTool } from "../Tool-Y0__Py1H.mjs";
1
+ import { o as FunctionCall } from "../Items-BH8xUkoR.mjs";
2
+ import { a as Tool, i as StreamingTool, o as ToolDescriptor, t as AnyKindTool } from "../Tool-87ViKCCO.mjs";
3
3
  import { Event } from "../loop/Loop.mjs";
4
4
  import { ToolResult } from "./Outcome.mjs";
5
5
  import { ToolEvent } from "./ToolEvent.mjs";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@effect-uai/core",
3
- "version": "0.5.0",
3
+ "version": "0.5.2",
4
4
  "description": "Low-level primitives (loop, conversation, items, tools, streaming codecs) for building AI agents with Effect.",
5
5
  "keywords": [
6
6
  "agents",
@@ -94,6 +94,10 @@
94
94
  "types": "./dist/loop/Loop.d.mts",
95
95
  "import": "./dist/loop/Loop.mjs"
96
96
  },
97
+ "./Retry": {
98
+ "types": "./dist/retry/Retry.d.mts",
99
+ "import": "./dist/retry/Retry.mjs"
100
+ },
97
101
  "./Vector": {
98
102
  "types": "./dist/math/Vector.d.mts",
99
103
  "import": "./dist/math/Vector.mjs"
package/src/index.ts CHANGED
@@ -14,6 +14,7 @@ export * as SpeechSynthesizer from "./speech-synthesizer/SpeechSynthesizer.js"
14
14
  export * as Transcriber from "./transcriber/Transcriber.js"
15
15
  export * as Vector from "./math/Vector.js"
16
16
  export * as Loop from "./loop/Loop.js"
17
+ export * as Retry from "./retry/Retry.js"
17
18
  export * as Tool from "./tool/Tool.js"
18
19
  export * as Toolkit from "./tool/Toolkit.js"
19
20
  export * as Outcome from "./tool/Outcome.js"
@@ -1,9 +1,13 @@
1
- import { Cause, Effect, Exit, Layer, Option, Ref, Schedule, Stream } from "effect"
1
+ import { Cause, Effect, Exit, Layer, Option, Stream } from "effect"
2
2
  import { describe, expect, it } from "vitest"
3
3
  import * as AiError from "../domain/AiError.js"
4
4
  import * as Items from "../domain/Items.js"
5
5
  import { type Turn, TurnEvent } from "../domain/Turn.js"
6
- import { LanguageModel, retry, turn } from "./LanguageModel.js"
6
+ import { LanguageModel, type LanguageModelService, turn, turnFromStream } from "./LanguageModel.js"
7
+
8
+ const serviceFromStream = (
9
+ streamTurn: LanguageModelService["streamTurn"],
10
+ ): LanguageModelService => ({ streamTurn, turn: turnFromStream(streamTurn) })
7
11
  import * as MockProvider from "../testing/MockProvider.js"
8
12
 
9
13
  const oneTextTurn = (text: string): Turn => ({
@@ -26,9 +30,12 @@ describe("LanguageModel.turn", () => {
26
30
 
27
31
  it("fails with IncompleteTurn when the stream ends without TurnComplete", async () => {
28
32
  // Custom service whose stream emits a single TextDelta and then ends.
29
- const broken = Layer.succeed(LanguageModel, {
30
- streamTurn: () => Stream.fromIterable<TurnEvent>([TurnEvent.TextDelta({ text: "partial" })]),
31
- })
33
+ const broken = Layer.succeed(
34
+ LanguageModel,
35
+ serviceFromStream(() =>
36
+ Stream.fromIterable<TurnEvent>([TurnEvent.TextDelta({ text: "partial" })]),
37
+ ),
38
+ )
32
39
 
33
40
  const program = turn({ history: [Items.userText("hi")], model: "mock" })
34
41
  const exit = await Effect.runPromise(Effect.exit(program.pipe(Effect.provide(broken))))
@@ -45,9 +52,10 @@ describe("LanguageModel.turn", () => {
45
52
 
46
53
  it("propagates an AiError raised by streamTurn", async () => {
47
54
  const rateLimited = new AiError.RateLimited({ provider: "mock", raw: null })
48
- const failing = Layer.succeed(LanguageModel, {
49
- streamTurn: () => Stream.fail<AiError.AiError>(rateLimited),
50
- })
55
+ const failing = Layer.succeed(
56
+ LanguageModel,
57
+ serviceFromStream(() => Stream.fail<AiError.AiError>(rateLimited)),
58
+ )
51
59
 
52
60
  const program = turn({ history: [], model: "mock" })
53
61
  const exit = await Effect.runPromise(Effect.exit(program.pipe(Effect.provide(failing))))
@@ -63,13 +71,15 @@ describe("LanguageModel.turn", () => {
63
71
  // should pick the last one (the most recent assembled Turn).
64
72
  const first = oneTextTurn("first")
65
73
  const second = oneTextTurn("second")
66
- const weird = Layer.succeed(LanguageModel, {
67
- streamTurn: () =>
74
+ const weird = Layer.succeed(
75
+ LanguageModel,
76
+ serviceFromStream(() =>
68
77
  Stream.fromIterable<TurnEvent>([
69
78
  TurnEvent.TurnComplete({ turn: first }),
70
79
  TurnEvent.TurnComplete({ turn: second }),
71
80
  ]),
72
- })
81
+ ),
82
+ )
73
83
 
74
84
  const program = turn({ history: [], model: "mock" })
75
85
  const result = await Effect.runPromise(program.pipe(Effect.provide(weird)))
@@ -77,94 +87,3 @@ describe("LanguageModel.turn", () => {
77
87
  expect(result).toEqual(second)
78
88
  })
79
89
  })
80
-
81
- describe("LanguageModel.retry", () => {
82
- const textDelta = (text: string): TurnEvent => TurnEvent.TextDelta({ text })
83
- const textTurn = (text: string): Turn => ({
84
- items: [Items.assistantText(text)],
85
- usage: { input_tokens: 0, output_tokens: 0 },
86
- stop_reason: "stop",
87
- })
88
- const completeEvent = (text: string): TurnEvent =>
89
- TurnEvent.TurnComplete({ turn: textTurn(text) })
90
-
91
- // Builds a stream that emits a failure or success based on attempt counter.
92
- // Each call to the returned Effect produces a fresh attempt stream.
93
- const attemptStream = (
94
- attempts: Ref.Ref<number>,
95
- plan: ReadonlyArray<Stream.Stream<TurnEvent, AiError.AiError>>,
96
- ): Stream.Stream<TurnEvent, AiError.AiError> =>
97
- Stream.unwrap(
98
- Ref.getAndUpdate(attempts, (n) => n + 1).pipe(
99
- Effect.map((n) => plan[Math.min(n, plan.length - 1)]!),
100
- ),
101
- )
102
-
103
- it("retries on RateLimited and yields the success on the next attempt", async () => {
104
- const program = Effect.gen(function* () {
105
- const attempts = yield* Ref.make(0)
106
- const stream = attemptStream(attempts, [
107
- Stream.fail(new AiError.RateLimited({ provider: "mock", raw: null })),
108
- Stream.fromIterable([textDelta("ok"), completeEvent("ok")]),
109
- ]).pipe(retry(Schedule.recurs(3)))
110
- const events = yield* Stream.runCollect(stream)
111
- const count = yield* Ref.get(attempts)
112
- return { events: Array.from(events), count }
113
- })
114
-
115
- const { events, count } = await Effect.runPromise(program)
116
- expect(count).toBe(2)
117
- expect(events.map((e) => e._tag)).toEqual(["TextDelta", "TurnComplete"])
118
- })
119
-
120
- it("surfaces the underlying retryable failure when retries are exhausted", async () => {
121
- const cause = new AiError.Unavailable({ provider: "mock", raw: null })
122
- const stream = Stream.fail<AiError.AiError>(cause).pipe(retry(Schedule.recurs(2)))
123
-
124
- const exit = await Effect.runPromise(Effect.exit(Stream.runCollect(stream)))
125
- expect(Exit.isFailure(exit)).toBe(true)
126
- if (Exit.isFailure(exit)) {
127
- expect(Cause.findErrorOption(exit.cause)).toEqual(Option.some(cause))
128
- }
129
- })
130
-
131
- it("bypasses retry for non-retryable AiError (ContentFiltered)", async () => {
132
- const program = Effect.gen(function* () {
133
- const attempts = yield* Ref.make(0)
134
- const cause = new AiError.ContentFiltered({ provider: "mock", raw: null })
135
- const stream = attemptStream(attempts, [Stream.fail(cause)]).pipe(retry(Schedule.recurs(5)))
136
- const exit = yield* Effect.exit(Stream.runCollect(stream))
137
- const count = yield* Ref.get(attempts)
138
- return { exit, count, cause }
139
- })
140
-
141
- const { exit, count, cause } = await Effect.runPromise(program)
142
- expect(count).toBe(1) // no retry happened
143
- expect(Exit.isFailure(exit)).toBe(true)
144
- if (Exit.isFailure(exit)) {
145
- expect(Cause.findErrorOption(exit.cause)).toEqual(Option.some(cause))
146
- }
147
- })
148
-
149
- it("preserves deltas emitted before a retryable failure (and replays on retry)", async () => {
150
- // Documents the 'replays on retry' caveat in the JSDoc — first attempt
151
- // emits a delta then fails; second attempt is a clean success. Consumer
152
- // sees the first delta twice (once from the failed attempt, once from
153
- // the replay).
154
- const program = Effect.gen(function* () {
155
- const attempts = yield* Ref.make(0)
156
- const stream = attemptStream(attempts, [
157
- Stream.concat(
158
- Stream.succeed<TurnEvent>(textDelta("partial")),
159
- Stream.fail(new AiError.Timeout({ provider: "mock", raw: null })),
160
- ),
161
- Stream.fromIterable([textDelta("partial"), completeEvent("done")]),
162
- ]).pipe(retry(Schedule.recurs(1)))
163
- const events = yield* Stream.runCollect(stream)
164
- return Array.from(events)
165
- })
166
-
167
- const events = await Effect.runPromise(program)
168
- expect(events.map((e) => e._tag)).toEqual(["TextDelta", "TextDelta", "TurnComplete"])
169
- })
170
- })