@effect-uai/core 0.15.0 → 0.16.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 (91) hide show
  1. package/README.md +30 -16
  2. package/dist/{AiError-eLV9t1lU.d.mts → AiError-DvhLIbmd.d.mts} +15 -4
  3. package/dist/{AiError-eLV9t1lU.d.mts.map → AiError-DvhLIbmd.d.mts.map} +1 -1
  4. package/dist/{Loop-B54AB1ME.d.mts → Loop-ChpaO-ya.d.mts} +2 -2
  5. package/dist/{Loop-B54AB1ME.d.mts.map → Loop-ChpaO-ya.d.mts.map} +1 -1
  6. package/dist/{Toolkit-DAjJ9Tvl.d.mts → Toolkit-tlL0F2YR.d.mts} +2 -2
  7. package/dist/{Toolkit-DAjJ9Tvl.d.mts.map → Toolkit-tlL0F2YR.d.mts.map} +1 -1
  8. package/dist/chunker/Chunker.d.mts +1 -1
  9. package/dist/domain/AiError.d.mts +2 -2
  10. package/dist/domain/AiError.mjs +9 -2
  11. package/dist/domain/AiError.mjs.map +1 -1
  12. package/dist/domain/Realtime.d.mts +616 -0
  13. package/dist/domain/Realtime.d.mts.map +1 -0
  14. package/dist/domain/Realtime.mjs +21 -0
  15. package/dist/domain/Realtime.mjs.map +1 -0
  16. package/dist/domain/Transcript.d.mts +17 -3
  17. package/dist/domain/Transcript.d.mts.map +1 -1
  18. package/dist/domain/Transcript.mjs +30 -1
  19. package/dist/domain/Transcript.mjs.map +1 -1
  20. package/dist/domain/Transcript.test.mjs +110 -0
  21. package/dist/domain/Transcript.test.mjs.map +1 -0
  22. package/dist/embedding-model/EmbeddingModel.d.mts +1 -1
  23. package/dist/image-generator/ImageGenerator.d.mts +1 -1
  24. package/dist/index.d.mts +8 -5
  25. package/dist/index.mjs +6 -3
  26. package/dist/job/Job.d.mts +1 -1
  27. package/dist/language-model/LanguageModel.d.mts +2 -2
  28. package/dist/loop/Loop.d.mts +1 -1
  29. package/dist/music-generator/MusicGenerator.d.mts +1 -1
  30. package/dist/realtime/RealtimeSession.d.mts +50 -0
  31. package/dist/realtime/RealtimeSession.d.mts.map +1 -0
  32. package/dist/realtime/RealtimeSession.mjs +45 -0
  33. package/dist/realtime/RealtimeSession.mjs.map +1 -0
  34. package/dist/realtime/RealtimeSession.test.d.mts +1 -0
  35. package/dist/realtime/RealtimeSession.test.mjs +95 -0
  36. package/dist/realtime/RealtimeSession.test.mjs.map +1 -0
  37. package/dist/reranker/Reranker.d.mts +1 -1
  38. package/dist/research/DeepResearch.d.mts +1 -1
  39. package/dist/retry/Retry.d.mts +1 -1
  40. package/dist/speech-synthesizer/SpeechSynthesizer.d.mts +1 -1
  41. package/dist/streaming/Settle.d.mts +45 -0
  42. package/dist/streaming/Settle.d.mts.map +1 -0
  43. package/dist/streaming/Settle.mjs +95 -0
  44. package/dist/streaming/Settle.mjs.map +1 -0
  45. package/dist/streaming/Settle.test.d.mts +1 -0
  46. package/dist/streaming/Settle.test.mjs +252 -0
  47. package/dist/streaming/Settle.test.mjs.map +1 -0
  48. package/dist/streaming/WebSocketSession.d.mts +36 -0
  49. package/dist/streaming/WebSocketSession.d.mts.map +1 -0
  50. package/dist/streaming/WebSocketSession.mjs +89 -0
  51. package/dist/streaming/WebSocketSession.mjs.map +1 -0
  52. package/dist/streaming/WebSocketSession.test.d.mts +1 -0
  53. package/dist/streaming/WebSocketSession.test.mjs +51 -0
  54. package/dist/streaming/WebSocketSession.test.mjs.map +1 -0
  55. package/dist/testing/FakeWebSocket.d.mts +25 -0
  56. package/dist/testing/FakeWebSocket.d.mts.map +1 -0
  57. package/dist/testing/FakeWebSocket.mjs +79 -0
  58. package/dist/testing/FakeWebSocket.mjs.map +1 -0
  59. package/dist/testing/MockRealtimeSession.d.mts +30 -0
  60. package/dist/testing/MockRealtimeSession.d.mts.map +1 -0
  61. package/dist/testing/MockRealtimeSession.mjs +52 -0
  62. package/dist/testing/MockRealtimeSession.mjs.map +1 -0
  63. package/dist/tool/Toolkit.d.mts +1 -1
  64. package/dist/transcriber/Transcriber.d.mts +1 -1
  65. package/dist/web-read/WebRead.d.mts +1 -1
  66. package/dist/web-read/WebReadTool.d.mts +1 -1
  67. package/dist/web-search/WebSearch.d.mts +1 -1
  68. package/dist/web-search/WebSearchTool.d.mts +1 -1
  69. package/package.json +24 -4
  70. package/src/domain/AiError.ts +11 -0
  71. package/src/domain/Realtime.ts +101 -0
  72. package/src/domain/Transcript.test.ts +98 -0
  73. package/src/domain/Transcript.ts +53 -1
  74. package/src/index.ts +4 -1
  75. package/src/realtime/RealtimeSession.test.ts +100 -0
  76. package/src/realtime/RealtimeSession.ts +77 -0
  77. package/src/streaming/Settle.test.ts +344 -0
  78. package/src/streaming/Settle.ts +196 -0
  79. package/src/streaming/WebSocketSession.test.ts +52 -0
  80. package/src/streaming/WebSocketSession.ts +132 -0
  81. package/src/testing/FakeWebSocket.ts +120 -0
  82. package/src/testing/MockRealtimeSession.ts +92 -0
  83. package/dist/inbox/Inbox.d.mts +0 -16
  84. package/dist/inbox/Inbox.d.mts.map +0 -1
  85. package/dist/inbox/Inbox.mjs +0 -28
  86. package/dist/inbox/Inbox.mjs.map +0 -1
  87. package/dist/inbox/Inbox.test.mjs +0 -42
  88. package/dist/inbox/Inbox.test.mjs.map +0 -1
  89. package/src/inbox/Inbox.test.ts +0 -52
  90. package/src/inbox/Inbox.ts +0 -33
  91. /package/dist/{inbox/Inbox.test.d.mts → domain/Transcript.test.d.mts} +0 -0
@@ -0,0 +1,51 @@
1
+ import { describe } from "../domain/AiError.mjs";
2
+ import { toAiError } from "./WebSocketSession.mjs";
3
+ import { o as describe$1 } from "../utils-CiH_DjFW.mjs";
4
+ import { n as globalExpect } from "../test.DNmyFkvJ-YPzDR0Co.mjs";
5
+ import { t as it } from "../dist-nohcnkD6.mjs";
6
+ import * as Socket from "effect/unstable/socket/Socket";
7
+ //#region src/streaming/WebSocketSession.test.ts
8
+ const KEY = "AIzaSyEXAMPLEKEY";
9
+ const URL = `wss://generativelanguage.googleapis.com/ws/BidiGenerateContent?key=${KEY}`;
10
+ /**
11
+ * What Bun hands the `error` listener when the upgrade is rejected: the whole
12
+ * socket URL, in prose, on a prototype getter. Node and Deno say less, but the
13
+ * adapter cannot know which runtime it is on.
14
+ */
15
+ var BunErrorEvent = class {
16
+ get message() {
17
+ return `WebSocket connection to '${URL}' failed: Connection ended`;
18
+ }
19
+ };
20
+ /** Deno names the status but not the URL, also on a prototype getter. */
21
+ var DenoErrorEvent = class {
22
+ detail;
23
+ constructor(detail) {
24
+ this.detail = detail;
25
+ }
26
+ get message() {
27
+ return `NetworkError: failed to connect to WebSocket: ${this.detail}`;
28
+ }
29
+ };
30
+ const openFailure = (cause) => new Socket.SocketError({ reason: new Socket.SocketOpenError({
31
+ kind: "Unknown",
32
+ cause
33
+ }) });
34
+ describe$1("WebSocketSession errors", () => {
35
+ it("keeps the key out of the error a rejected upgrade produces", () => {
36
+ const error = toAiError("google")(openFailure(new BunErrorEvent()));
37
+ globalExpect(JSON.stringify(error)).not.toContain(KEY);
38
+ globalExpect(describe(error)).not.toContain(KEY);
39
+ globalExpect(JSON.stringify(error)).toContain("generativelanguage.googleapis.com");
40
+ });
41
+ it("reads the rejected status out of the prose, wherever the runtime put it", () => {
42
+ const forbidden = toAiError("google")(openFailure(new DenoErrorEvent("Invalid status code: 403")));
43
+ const ended = toAiError("google")(openFailure(new BunErrorEvent()));
44
+ globalExpect(forbidden._tag).toBe("AuthFailed");
45
+ globalExpect(ended._tag).toBe("Unavailable");
46
+ });
47
+ });
48
+ //#endregion
49
+ export {};
50
+
51
+ //# sourceMappingURL=WebSocketSession.test.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"WebSocketSession.test.mjs","names":["describe","AiError.describe"],"sources":["../../src/streaming/WebSocketSession.test.ts"],"sourcesContent":["import { describe, it } from \"@effect/vitest\"\nimport * as Socket from \"effect/unstable/socket/Socket\"\nimport { expect } from \"vitest\"\nimport * as AiError from \"../domain/AiError.js\"\nimport { toAiError } from \"./WebSocketSession.js\"\n\nconst KEY = \"AIzaSyEXAMPLEKEY\"\nconst URL = `wss://generativelanguage.googleapis.com/ws/BidiGenerateContent?key=${KEY}`\n\n/**\n * What Bun hands the `error` listener when the upgrade is rejected: the whole\n * socket URL, in prose, on a prototype getter. Node and Deno say less, but the\n * adapter cannot know which runtime it is on.\n */\nclass BunErrorEvent {\n get message(): string {\n return `WebSocket connection to '${URL}' failed: Connection ended`\n }\n}\n\n/** Deno names the status but not the URL, also on a prototype getter. */\nclass DenoErrorEvent {\n constructor(private readonly detail: string) {}\n get message(): string {\n return `NetworkError: failed to connect to WebSocket: ${this.detail}`\n }\n}\n\nconst openFailure = (cause: unknown) =>\n new Socket.SocketError({ reason: new Socket.SocketOpenError({ kind: \"Unknown\", cause }) })\n\ndescribe(\"WebSocketSession errors\", () => {\n it(\"keeps the key out of the error a rejected upgrade produces\", () => {\n const error = toAiError(\"google\")(openFailure(new BunErrorEvent()))\n\n expect(JSON.stringify(error)).not.toContain(KEY)\n expect(AiError.describe(error)).not.toContain(KEY)\n // The host is still there: an error that says nothing is its own problem.\n expect(JSON.stringify(error)).toContain(\"generativelanguage.googleapis.com\")\n })\n\n it(\"reads the rejected status out of the prose, wherever the runtime put it\", () => {\n // Deno's wording. The status is the only hint a rejected upgrade gives.\n const forbidden = toAiError(\"google\")(\n openFailure(new DenoErrorEvent(\"Invalid status code: 403\")),\n )\n const ended = toAiError(\"google\")(openFailure(new BunErrorEvent()))\n\n expect(forbidden._tag).toBe(\"AuthFailed\")\n expect(ended._tag).toBe(\"Unavailable\")\n })\n})\n"],"mappings":";;;;;;;AAMA,MAAM,MAAM;AACZ,MAAM,MAAM,sEAAsE;;;;;;AAOlF,IAAM,gBAAN,MAAoB;CAClB,IAAI,UAAkB;EACpB,OAAO,4BAA4B,IAAI;CACzC;AACF;;AAGA,IAAM,iBAAN,MAAqB;CACU;CAA7B,YAAY,QAAiC;EAAhB,KAAA,SAAA;CAAiB;CAC9C,IAAI,UAAkB;EACpB,OAAO,iDAAiD,KAAK;CAC/D;AACF;AAEA,MAAM,eAAe,UACnB,IAAI,OAAO,YAAY,EAAE,QAAQ,IAAI,OAAO,gBAAgB;CAAE,MAAM;CAAW;AAAM,CAAC,EAAE,CAAC;AAE3FA,WAAS,iCAAiC;CACxC,GAAG,oEAAoE;EACrE,MAAM,QAAQ,UAAU,QAAQ,CAAC,CAAC,YAAY,IAAI,cAAc,CAAC,CAAC;EAElE,aAAO,KAAK,UAAU,KAAK,CAAC,CAAC,CAAC,IAAI,UAAU,GAAG;EAC/C,aAAOC,SAAiB,KAAK,CAAC,CAAC,CAAC,IAAI,UAAU,GAAG;EAEjD,aAAO,KAAK,UAAU,KAAK,CAAC,CAAC,CAAC,UAAU,mCAAmC;CAC7E,CAAC;CAED,GAAG,iFAAiF;EAElF,MAAM,YAAY,UAAU,QAAQ,CAAC,CACnC,YAAY,IAAI,eAAe,0BAA0B,CAAC,CAC5D;EACA,MAAM,QAAQ,UAAU,QAAQ,CAAC,CAAC,YAAY,IAAI,cAAc,CAAC,CAAC;EAElE,aAAO,UAAU,IAAI,CAAC,CAAC,KAAK,YAAY;EACxC,aAAO,MAAM,IAAI,CAAC,CAAC,KAAK,aAAa;CACvC,CAAC;AACH,CAAC"}
@@ -0,0 +1,25 @@
1
+ import { Effect, Layer, Scope } from "effect";
2
+ import * as Socket from "effect/unstable/socket/Socket";
3
+ //#region src/testing/FakeWebSocket.d.ts
4
+ type FakeWebSocketServer = {
5
+ /** Every frame the client has sent, JSON-parsed, oldest first. */
6
+ readonly sent: Effect.Effect<ReadonlyArray<unknown>>;
7
+ /** Deliver one frame to the client. */
8
+ readonly push: (frame: unknown) => Effect.Effect<void>;
9
+ /** Close the socket. 1000, 1001 and 1005 are the clean codes. */
10
+ readonly close: (code?: number) => Effect.Effect<void>;
11
+ /** Hand this to an adapter that takes a socket constructor directly. */
12
+ readonly connect: Socket.WebSocketConstructor["Service"];
13
+ /** Provide this in place of the real constructor. */
14
+ readonly layer: Layer.Layer<Socket.WebSocketConstructor>;
15
+ };
16
+ type FakeWebSocketOptions = {
17
+ /** Pushed once the client subscribes, for a protocol whose server speaks first. */
18
+ readonly greeting?: ReadonlyArray<unknown>;
19
+ /** How the server answers each client frame. */
20
+ readonly reply?: (frame: unknown) => Effect.Effect<ReadonlyArray<unknown>>;
21
+ };
22
+ declare const make: (options?: FakeWebSocketOptions) => Effect.Effect<FakeWebSocketServer, never, Scope.Scope>;
23
+ //#endregion
24
+ export { FakeWebSocketOptions, FakeWebSocketServer, make };
25
+ //# sourceMappingURL=FakeWebSocket.d.mts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"FakeWebSocket.d.mts","names":[],"sources":["../../src/testing/FakeWebSocket.ts"],"mappings":";;;KAeY;;WAED,MAAM,OAAO,OAAO;;WAEpB,OAAO,mBAAmB,OAAO;;WAEjC,QAAQ,kBAAkB,OAAO;;WAEjC,SAAS,OAAO;;WAEhB,OAAO,MAAM,MAAM,OAAO;;KAGzB;;WAED,WAAW;;WAEX,SAAS,mBAAmB,OAAO,OAAO;;cAWxC,OAAI,UACL,yBACT,OAAO,OAAO,4BAA4B,MAAM"}
@@ -0,0 +1,79 @@
1
+ import { Effect, Layer, Queue, Ref, Stream } from "effect";
2
+ import * as Socket from "effect/unstable/socket/Socket";
3
+ //#region src/testing/FakeWebSocket.ts
4
+ /**
5
+ * An in-memory WebSocket for testing socket adapters without a server.
6
+ *
7
+ * Provide `layer` in place of the real `Socket.WebSocketConstructor` and the
8
+ * adapter runs end to end: its own connect, handshake, decode and teardown.
9
+ * The test plays the other end with `reply`, `push`, `close` and `sent`.
10
+ *
11
+ * Client frames land on a queue, so the scripted server is a forked fiber and
12
+ * `reply` is a real Effect. What stays imperative is the listener table the
13
+ * shim registers, since `Socket.makeWebSocket` drives it with plain callbacks.
14
+ * The shim is a plain object, so nothing here touches a platform global.
15
+ */
16
+ const OPEN = 1;
17
+ const CLOSED = 3;
18
+ /** Run after the current call unwinds, so the client is never re-entered. */
19
+ const soon = (run) => void Promise.resolve().then(run);
20
+ const make = (options) => Effect.gen(function* () {
21
+ const inbound = yield* Queue.unbounded();
22
+ const sent = yield* Ref.make([]);
23
+ const wiring = {
24
+ listeners: {},
25
+ readyState: OPEN,
26
+ greeted: false
27
+ };
28
+ const emit = (type, event) => (wiring.listeners[type] ?? []).forEach((listener) => listener(event));
29
+ const deliverSync = (frames) => frames.forEach((f) => emit("message", { data: JSON.stringify(f) }));
30
+ const deliver = (frames) => Effect.sync(() => deliverSync(frames));
31
+ const shutdown = (code) => {
32
+ if (wiring.readyState === CLOSED) return;
33
+ wiring.readyState = CLOSED;
34
+ emit("close", {
35
+ code,
36
+ reason: ""
37
+ });
38
+ };
39
+ yield* Stream.fromQueue(inbound).pipe(Stream.runForEach((frame) => Effect.gen(function* () {
40
+ yield* Ref.update(sent, (frames) => [...frames, frame]);
41
+ const answer = yield* options?.reply?.(frame) ?? Effect.succeed([]);
42
+ yield* deliver(answer);
43
+ })), Effect.forkScoped);
44
+ const socket = {
45
+ get readyState() {
46
+ return wiring.readyState;
47
+ },
48
+ addEventListener: (type, fn) => {
49
+ wiring.listeners = {
50
+ ...wiring.listeners,
51
+ [type]: [...wiring.listeners[type] ?? [], fn]
52
+ };
53
+ if (type === "message" && !wiring.greeted) {
54
+ wiring.greeted = true;
55
+ soon(() => deliverSync(options?.greeting ?? []));
56
+ }
57
+ },
58
+ removeEventListener: (type, fn) => {
59
+ wiring.listeners = {
60
+ ...wiring.listeners,
61
+ [type]: (wiring.listeners[type] ?? []).filter((l) => l !== fn)
62
+ };
63
+ },
64
+ send: (data) => void Queue.offerUnsafe(inbound, JSON.parse(data)),
65
+ close: (code = 1e3) => shutdown(code)
66
+ };
67
+ const connect = () => socket;
68
+ return {
69
+ sent: Ref.get(sent),
70
+ push: (frame) => deliver([frame]),
71
+ close: (code = 1e3) => Effect.sync(() => shutdown(code)),
72
+ connect,
73
+ layer: Layer.succeed(Socket.WebSocketConstructor, connect)
74
+ };
75
+ });
76
+ //#endregion
77
+ export { make };
78
+
79
+ //# sourceMappingURL=FakeWebSocket.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"FakeWebSocket.mjs","names":[],"sources":["../../src/testing/FakeWebSocket.ts"],"sourcesContent":["/**\n * An in-memory WebSocket for testing socket adapters without a server.\n *\n * Provide `layer` in place of the real `Socket.WebSocketConstructor` and the\n * adapter runs end to end: its own connect, handshake, decode and teardown.\n * The test plays the other end with `reply`, `push`, `close` and `sent`.\n *\n * Client frames land on a queue, so the scripted server is a forked fiber and\n * `reply` is a real Effect. What stays imperative is the listener table the\n * shim registers, since `Socket.makeWebSocket` drives it with plain callbacks.\n * The shim is a plain object, so nothing here touches a platform global.\n */\nimport { Effect, Layer, Queue, Ref, type Scope, Stream } from \"effect\"\nimport * as Socket from \"effect/unstable/socket/Socket\"\n\nexport type FakeWebSocketServer = {\n /** Every frame the client has sent, JSON-parsed, oldest first. */\n readonly sent: Effect.Effect<ReadonlyArray<unknown>>\n /** Deliver one frame to the client. */\n readonly push: (frame: unknown) => Effect.Effect<void>\n /** Close the socket. 1000, 1001 and 1005 are the clean codes. */\n readonly close: (code?: number) => Effect.Effect<void>\n /** Hand this to an adapter that takes a socket constructor directly. */\n readonly connect: Socket.WebSocketConstructor[\"Service\"]\n /** Provide this in place of the real constructor. */\n readonly layer: Layer.Layer<Socket.WebSocketConstructor>\n}\n\nexport type FakeWebSocketOptions = {\n /** Pushed once the client subscribes, for a protocol whose server speaks first. */\n readonly greeting?: ReadonlyArray<unknown>\n /** How the server answers each client frame. */\n readonly reply?: (frame: unknown) => Effect.Effect<ReadonlyArray<unknown>>\n}\n\ntype Listener = (event: any) => void\n\nconst OPEN = 1\nconst CLOSED = 3\n\n/** Run after the current call unwinds, so the client is never re-entered. */\nconst soon = (run: () => void): void => void Promise.resolve().then(run)\n\nexport const make = (\n options?: FakeWebSocketOptions,\n): Effect.Effect<FakeWebSocketServer, never, Scope.Scope> =>\n Effect.gen(function* () {\n const inbound = yield* Queue.unbounded<unknown>()\n const sent = yield* Ref.make<ReadonlyArray<unknown>>([])\n\n const wiring = {\n listeners: {} as Record<string, ReadonlyArray<Listener>>,\n readyState: OPEN,\n greeted: false,\n }\n\n const emit = (type: string, event: unknown): void =>\n (wiring.listeners[type] ?? []).forEach((listener) => listener(event))\n\n const deliverSync = (frames: ReadonlyArray<unknown>): void =>\n frames.forEach((f) => emit(\"message\", { data: JSON.stringify(f) }))\n\n const deliver = (frames: ReadonlyArray<unknown>): Effect.Effect<void> =>\n Effect.sync(() => deliverSync(frames))\n\n const shutdown = (code: number): void => {\n if (wiring.readyState === CLOSED) return\n wiring.readyState = CLOSED\n emit(\"close\", { code, reason: \"\" })\n }\n\n // The scripted server. Answers land on their own turn, like a real one.\n yield* Stream.fromQueue(inbound).pipe(\n Stream.runForEach((frame) =>\n Effect.gen(function* () {\n yield* Ref.update(sent, (frames) => [...frames, frame])\n const answer = yield* options?.reply?.(frame) ?? Effect.succeed([])\n yield* deliver(answer)\n }),\n ),\n Effect.forkScoped,\n )\n\n const socket = {\n get readyState() {\n return wiring.readyState\n },\n addEventListener: (type: string, fn: Listener): void => {\n wiring.listeners = {\n ...wiring.listeners,\n [type]: [...(wiring.listeners[type] ?? []), fn],\n }\n // The greeting waits for a subscriber before it lands.\n if (type === \"message\" && !wiring.greeted) {\n wiring.greeted = true\n soon(() => deliverSync(options?.greeting ?? []))\n }\n },\n removeEventListener: (type: string, fn: Listener): void => {\n wiring.listeners = {\n ...wiring.listeners,\n [type]: (wiring.listeners[type] ?? []).filter((l) => l !== fn),\n }\n },\n // Safe from a synchronous callback, and no runtime needed.\n send: (data: string): void => void Queue.offerUnsafe(inbound, JSON.parse(data)),\n close: (code = 1000): void => shutdown(code),\n }\n\n const connect: Socket.WebSocketConstructor[\"Service\"] = () =>\n socket as unknown as globalThis.WebSocket\n\n return {\n sent: Ref.get(sent),\n push: (frame) => deliver([frame]),\n close: (code = 1000) => Effect.sync(() => shutdown(code)),\n connect,\n layer: Layer.succeed(Socket.WebSocketConstructor, connect),\n }\n })\n"],"mappings":";;;;;;;;;;;;;;;AAqCA,MAAM,OAAO;AACb,MAAM,SAAS;;AAGf,MAAM,QAAQ,QAA0B,KAAK,QAAQ,QAAQ,CAAC,CAAC,KAAK,GAAG;AAEvE,MAAa,QACX,YAEA,OAAO,IAAI,aAAa;CACtB,MAAM,UAAU,OAAO,MAAM,UAAmB;CAChD,MAAM,OAAO,OAAO,IAAI,KAA6B,CAAC,CAAC;CAEvD,MAAM,SAAS;EACb,WAAW,CAAC;EACZ,YAAY;EACZ,SAAS;CACX;CAEA,MAAM,QAAQ,MAAc,WACzB,OAAO,UAAU,SAAS,CAAC,EAAA,CAAG,SAAS,aAAa,SAAS,KAAK,CAAC;CAEtE,MAAM,eAAe,WACnB,OAAO,SAAS,MAAM,KAAK,WAAW,EAAE,MAAM,KAAK,UAAU,CAAC,EAAE,CAAC,CAAC;CAEpE,MAAM,WAAW,WACf,OAAO,WAAW,YAAY,MAAM,CAAC;CAEvC,MAAM,YAAY,SAAuB;EACvC,IAAI,OAAO,eAAe,QAAQ;EAClC,OAAO,aAAa;EACpB,KAAK,SAAS;GAAE;GAAM,QAAQ;EAAG,CAAC;CACpC;CAGA,OAAO,OAAO,UAAU,OAAO,CAAC,CAAC,KAC/B,OAAO,YAAY,UACjB,OAAO,IAAI,aAAa;EACtB,OAAO,IAAI,OAAO,OAAO,WAAW,CAAC,GAAG,QAAQ,KAAK,CAAC;EACtD,MAAM,SAAS,OAAO,SAAS,QAAQ,KAAK,KAAK,OAAO,QAAQ,CAAC,CAAC;EAClE,OAAO,QAAQ,MAAM;CACvB,CAAC,CACH,GACA,OAAO,UACT;CAEA,MAAM,SAAS;EACb,IAAI,aAAa;GACf,OAAO,OAAO;EAChB;EACA,mBAAmB,MAAc,OAAuB;GACtD,OAAO,YAAY;IACjB,GAAG,OAAO;KACT,OAAO,CAAC,GAAI,OAAO,UAAU,SAAS,CAAC,GAAI,EAAE;GAChD;GAEA,IAAI,SAAS,aAAa,CAAC,OAAO,SAAS;IACzC,OAAO,UAAU;IACjB,WAAW,YAAY,SAAS,YAAY,CAAC,CAAC,CAAC;GACjD;EACF;EACA,sBAAsB,MAAc,OAAuB;GACzD,OAAO,YAAY;IACjB,GAAG,OAAO;KACT,QAAQ,OAAO,UAAU,SAAS,CAAC,EAAA,CAAG,QAAQ,MAAM,MAAM,EAAE;GAC/D;EACF;EAEA,OAAO,SAAuB,KAAK,MAAM,YAAY,SAAS,KAAK,MAAM,IAAI,CAAC;EAC9E,QAAQ,OAAO,QAAe,SAAS,IAAI;CAC7C;CAEA,MAAM,gBACJ;CAEF,OAAO;EACL,MAAM,IAAI,IAAI,IAAI;EAClB,OAAO,UAAU,QAAQ,CAAC,KAAK,CAAC;EAChC,QAAQ,OAAO,QAAS,OAAO,WAAW,SAAS,IAAI,CAAC;EACxD;EACA,OAAO,MAAM,QAAQ,OAAO,sBAAsB,OAAO;CAC3D;AACF,CAAC"}
@@ -0,0 +1,30 @@
1
+ import { CommonSessionRequest, RealtimeEvent, RealtimeInput } from "../domain/Realtime.mjs";
2
+ import { RealtimeSession, RealtimeVideoInput } from "../realtime/RealtimeSession.mjs";
3
+ import { Effect, Layer } from "effect";
4
+ //#region src/testing/MockRealtimeSession.d.ts
5
+ type MockRealtimeSessionRecorder = {
6
+ readonly openCalls: ReadonlyArray<CommonSessionRequest>;
7
+ readonly sent: ReadonlyArray<RealtimeInput>;
8
+ };
9
+ type MockRealtimeSessionScript = {
10
+ /** Emitted as soon as the session opens. */
11
+ readonly initial?: ReadonlyArray<RealtimeEvent>;
12
+ /** The server's answer to each input. */
13
+ readonly onInput?: (input: RealtimeInput) => ReadonlyArray<RealtimeEvent>;
14
+ };
15
+ /** Scripted session that also ships the `RealtimeVideoInput` marker. */
16
+ declare const layer: (script: MockRealtimeSessionScript) => {
17
+ readonly layer: Layer.Layer<RealtimeSession | RealtimeVideoInput>;
18
+ readonly recorder: Effect.Effect<MockRealtimeSessionRecorder>;
19
+ };
20
+ /**
21
+ * The same without the marker, to test that a caller sending video against an
22
+ * audio-only provider fails to compile.
23
+ */
24
+ declare const layerAudioOnly: (script: MockRealtimeSessionScript) => {
25
+ readonly layer: Layer.Layer<RealtimeSession>;
26
+ readonly recorder: Effect.Effect<MockRealtimeSessionRecorder>;
27
+ };
28
+ //#endregion
29
+ export { MockRealtimeSessionRecorder, MockRealtimeSessionScript, layer, layerAudioOnly };
30
+ //# sourceMappingURL=MockRealtimeSession.d.mts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"MockRealtimeSession.d.mts","names":[],"sources":["../../src/testing/MockRealtimeSession.ts"],"mappings":";;;;KASY;WACD,WAAW,cAAc;WACzB,MAAM,cAAc;;KAGnB;;WAED,UAAU,cAAc;;WAExB,WAAW,OAAO,kBAAkB,cAAc;;;cAgDhD,QAAK,QACR;WAEC,OAAO,MAAM,MAAM,kBAAkB;WACrC,UAAU,OAAO,OAAO;;;;;;cAatB,iBAAc,QACjB;WAEC,OAAO,MAAM,MAAM;WACnB,UAAU,OAAO,OAAO"}
@@ -0,0 +1,52 @@
1
+ import { RealtimeSession, RealtimeVideoInput } from "../realtime/RealtimeSession.mjs";
2
+ import { Effect, Layer, Queue, Ref, Stream } from "effect";
3
+ //#region src/testing/MockRealtimeSession.ts
4
+ const makeService = (script, record) => ({ open: (request) => Effect.gen(function* () {
5
+ yield* record.open(request);
6
+ const queue = yield* Queue.unbounded();
7
+ yield* Effect.addFinalizer(() => Queue.end(queue));
8
+ yield* Queue.offerAll(queue, script.initial ?? []);
9
+ return {
10
+ send: (input) => Effect.andThen(record.send(input), Queue.offerAll(queue, script.onInput?.(input) ?? [])).pipe(Effect.asVoid),
11
+ events: Stream.fromQueue(queue)
12
+ };
13
+ }) });
14
+ const build = (script) => {
15
+ const openCalls = Ref.makeUnsafe([]);
16
+ const sent = Ref.makeUnsafe([]);
17
+ return {
18
+ sessionLayer: Layer.succeed(RealtimeSession, makeService(script, {
19
+ open: (request) => Ref.update(openCalls, (xs) => [...xs, request]),
20
+ send: (input) => Ref.update(sent, (xs) => [...xs, input])
21
+ })),
22
+ recorder: Effect.gen(function* () {
23
+ return {
24
+ openCalls: yield* Ref.get(openCalls),
25
+ sent: yield* Ref.get(sent)
26
+ };
27
+ })
28
+ };
29
+ };
30
+ /** Scripted session that also ships the `RealtimeVideoInput` marker. */
31
+ const layer = (script) => {
32
+ const { sessionLayer, recorder } = build(script);
33
+ return {
34
+ layer: Layer.merge(sessionLayer, Layer.succeed(RealtimeVideoInput, void 0)),
35
+ recorder
36
+ };
37
+ };
38
+ /**
39
+ * The same without the marker, to test that a caller sending video against an
40
+ * audio-only provider fails to compile.
41
+ */
42
+ const layerAudioOnly = (script) => {
43
+ const { sessionLayer, recorder } = build(script);
44
+ return {
45
+ layer: sessionLayer,
46
+ recorder
47
+ };
48
+ };
49
+ //#endregion
50
+ export { layer, layerAudioOnly };
51
+
52
+ //# sourceMappingURL=MockRealtimeSession.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"MockRealtimeSession.mjs","names":[],"sources":["../../src/testing/MockRealtimeSession.ts"],"sourcesContent":["import { Cause, Effect, Layer, Queue, Ref, Stream } from \"effect\"\nimport type { CommonSessionRequest, RealtimeEvent, RealtimeInput } from \"../domain/Realtime.js\"\nimport {\n RealtimeSession,\n RealtimeVideoInput,\n type RealtimeSessionHandle,\n type RealtimeSessionService,\n} from \"../realtime/RealtimeSession.js\"\n\nexport type MockRealtimeSessionRecorder = {\n readonly openCalls: ReadonlyArray<CommonSessionRequest>\n readonly sent: ReadonlyArray<RealtimeInput>\n}\n\nexport type MockRealtimeSessionScript = {\n /** Emitted as soon as the session opens. */\n readonly initial?: ReadonlyArray<RealtimeEvent>\n /** The server's answer to each input. */\n readonly onInput?: (input: RealtimeInput) => ReadonlyArray<RealtimeEvent>\n}\n\ntype Recorders = {\n readonly open: (request: CommonSessionRequest) => Effect.Effect<void>\n readonly send: (input: RealtimeInput) => Effect.Effect<void>\n}\n\nconst makeService = (\n script: MockRealtimeSessionScript,\n record: Recorders,\n): RealtimeSessionService => ({\n open: (request) =>\n Effect.gen(function* () {\n yield* record.open(request)\n const queue = yield* Queue.unbounded<RealtimeEvent, Cause.Done>()\n // Closing the scope ends `events`, leaving anything already queued to drain.\n yield* Effect.addFinalizer(() => Queue.end(queue))\n yield* Queue.offerAll(queue, script.initial ?? [])\n const handle: RealtimeSessionHandle = {\n send: (input) =>\n Effect.andThen(\n record.send(input),\n Queue.offerAll(queue, script.onInput?.(input) ?? []),\n ).pipe(Effect.asVoid),\n events: Stream.fromQueue(queue),\n }\n return handle\n }),\n})\n\nconst build = (script: MockRealtimeSessionScript) => {\n const openCalls = Ref.makeUnsafe<ReadonlyArray<CommonSessionRequest>>([])\n const sent = Ref.makeUnsafe<ReadonlyArray<RealtimeInput>>([])\n const sessionLayer = Layer.succeed(\n RealtimeSession,\n makeService(script, {\n open: (request) => Ref.update(openCalls, (xs) => [...xs, request]),\n send: (input) => Ref.update(sent, (xs) => [...xs, input]),\n }),\n )\n const recorder: Effect.Effect<MockRealtimeSessionRecorder> = Effect.gen(function* () {\n return { openCalls: yield* Ref.get(openCalls), sent: yield* Ref.get(sent) }\n })\n return { sessionLayer, recorder }\n}\n\n/** Scripted session that also ships the `RealtimeVideoInput` marker. */\nexport const layer = (\n script: MockRealtimeSessionScript,\n): {\n readonly layer: Layer.Layer<RealtimeSession | RealtimeVideoInput>\n readonly recorder: Effect.Effect<MockRealtimeSessionRecorder>\n} => {\n const { sessionLayer, recorder } = build(script)\n return {\n layer: Layer.merge(sessionLayer, Layer.succeed(RealtimeVideoInput, undefined)),\n recorder,\n }\n}\n\n/**\n * The same without the marker, to test that a caller sending video against an\n * audio-only provider fails to compile.\n */\nexport const layerAudioOnly = (\n script: MockRealtimeSessionScript,\n): {\n readonly layer: Layer.Layer<RealtimeSession>\n readonly recorder: Effect.Effect<MockRealtimeSessionRecorder>\n} => {\n const { sessionLayer, recorder } = build(script)\n return { layer: sessionLayer, recorder }\n}\n"],"mappings":";;;AA0BA,MAAM,eACJ,QACA,YAC4B,EAC5B,OAAO,YACL,OAAO,IAAI,aAAa;CACtB,OAAO,OAAO,KAAK,OAAO;CAC1B,MAAM,QAAQ,OAAO,MAAM,UAAqC;CAEhE,OAAO,OAAO,mBAAmB,MAAM,IAAI,KAAK,CAAC;CACjD,OAAO,MAAM,SAAS,OAAO,OAAO,WAAW,CAAC,CAAC;CASjD,OAAO;EAPL,OAAO,UACL,OAAO,QACL,OAAO,KAAK,KAAK,GACjB,MAAM,SAAS,OAAO,OAAO,UAAU,KAAK,KAAK,CAAC,CAAC,CACrD,CAAC,CAAC,KAAK,OAAO,MAAM;EACtB,QAAQ,OAAO,UAAU,KAAK;CAEpB;AACd,CAAC,EACL;AAEA,MAAM,SAAS,WAAsC;CACnD,MAAM,YAAY,IAAI,WAAgD,CAAC,CAAC;CACxE,MAAM,OAAO,IAAI,WAAyC,CAAC,CAAC;CAW5D,OAAO;EAAE,cAVY,MAAM,QACzB,iBACA,YAAY,QAAQ;GAClB,OAAO,YAAY,IAAI,OAAO,YAAY,OAAO,CAAC,GAAG,IAAI,OAAO,CAAC;GACjE,OAAO,UAAU,IAAI,OAAO,OAAO,OAAO,CAAC,GAAG,IAAI,KAAK,CAAC;EAC1D,CAAC,CAKiB;EAAG,UAHsC,OAAO,IAAI,aAAa;GACnF,OAAO;IAAE,WAAW,OAAO,IAAI,IAAI,SAAS;IAAG,MAAM,OAAO,IAAI,IAAI,IAAI;GAAE;EAC5E,CAC8B;CAAE;AAClC;;AAGA,MAAa,SACX,WAIG;CACH,MAAM,EAAE,cAAc,aAAa,MAAM,MAAM;CAC/C,OAAO;EACL,OAAO,MAAM,MAAM,cAAc,MAAM,QAAQ,oBAAoB,KAAA,CAAS,CAAC;EAC7E;CACF;AACF;;;;;AAMA,MAAa,kBACX,WAIG;CACH,MAAM,EAAE,cAAc,aAAa,MAAM,MAAM;CAC/C,OAAO;EAAE,OAAO;EAAc;CAAS;AACzC"}
@@ -1,2 +1,2 @@
1
- import { S as wrap, _ as fromArray, a as ToolMap, b as namespace, c as ToolkitR, d as appendToolResults, f as collectResults, g as descriptors, h as describeFailures, i as NoDuplicateNames, m as continueWithResults, n as ExecuteOptions, o as Toolkit, p as compose, r as Middleware, s as ToolkitE, t as DuplicateToolName, u as UniqueTools, v as make, x as run, y as makeNamespaced } from "../Toolkit-DAjJ9Tvl.mjs";
1
+ import { S as wrap, _ as fromArray, a as ToolMap, b as namespace, c as ToolkitR, d as appendToolResults, f as collectResults, g as descriptors, h as describeFailures, i as NoDuplicateNames, m as continueWithResults, n as ExecuteOptions, o as Toolkit, p as compose, r as Middleware, s as ToolkitE, t as DuplicateToolName, u as UniqueTools, v as make, x as run, y as makeNamespaced } from "../Toolkit-tlL0F2YR.mjs";
2
2
  export { DuplicateToolName, ExecuteOptions, Middleware, NoDuplicateNames, ToolMap, Toolkit, ToolkitE, ToolkitR, UniqueTools, appendToolResults, collectResults, compose, continueWithResults, describeFailures, descriptors, fromArray, make, makeNamespaced, namespace, run, wrap };
@@ -1,4 +1,4 @@
1
- import { t as AiError } from "../AiError-eLV9t1lU.mjs";
1
+ import { t as AiError } from "../AiError-DvhLIbmd.mjs";
2
2
  import { a as AudioSource, r as AudioFormat } from "../Audio-_nGR4V0X.mjs";
3
3
  import { TranscriptEvent, TranscriptResult } from "../domain/Transcript.mjs";
4
4
  import { Context, Effect, Stream } from "effect";
@@ -1,4 +1,4 @@
1
- import { t as AiError } from "../AiError-eLV9t1lU.mjs";
1
+ import { t as AiError } from "../AiError-DvhLIbmd.mjs";
2
2
  import { Context, Duration, Effect } from "effect";
3
3
  declare namespace WebRead_d_exports {
4
4
  export { CommonReadRequest, ReadFormat, ReadResponse, WebRead, WebReadService, read };
@@ -1,5 +1,5 @@
1
1
  import { l as Tool } from "../Tool-BF78QWtn.mjs";
2
- import { t as AiError } from "../AiError-eLV9t1lU.mjs";
2
+ import { t as AiError } from "../AiError-DvhLIbmd.mjs";
3
3
  import { ReadFormat, ReadResponse, WebRead } from "./WebRead.mjs";
4
4
  import { Schema } from "effect";
5
5
  declare namespace WebReadTool_d_exports {
@@ -1,4 +1,4 @@
1
- import { t as AiError } from "../AiError-eLV9t1lU.mjs";
1
+ import { t as AiError } from "../AiError-DvhLIbmd.mjs";
2
2
  import { Context, DateTime, Effect, Schema } from "effect";
3
3
  declare namespace WebSearch_d_exports {
4
4
  export { CommonSearchRequest, SearchRecency, SearchResponse, SearchResult, WebSearch, WebSearchService, search };
@@ -1,5 +1,5 @@
1
1
  import { l as Tool } from "../Tool-BF78QWtn.mjs";
2
- import { t as AiError } from "../AiError-eLV9t1lU.mjs";
2
+ import { t as AiError } from "../AiError-DvhLIbmd.mjs";
3
3
  import { SearchResult, WebSearch } from "./WebSearch.mjs";
4
4
  import { Schema } from "effect";
5
5
  declare namespace WebSearchTool_d_exports {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@effect-uai/core",
3
- "version": "0.15.0",
3
+ "version": "0.16.0",
4
4
  "description": "Low-level primitives (loop, conversation, items, tools, streaming codecs) for building AI agents with Effect.",
5
5
  "keywords": [
6
6
  "agents",
@@ -90,6 +90,14 @@
90
90
  "types": "./dist/domain/Music.d.mts",
91
91
  "import": "./dist/domain/Music.mjs"
92
92
  },
93
+ "./Realtime": {
94
+ "types": "./dist/domain/Realtime.d.mts",
95
+ "import": "./dist/domain/Realtime.mjs"
96
+ },
97
+ "./RealtimeSession": {
98
+ "types": "./dist/realtime/RealtimeSession.d.mts",
99
+ "import": "./dist/realtime/RealtimeSession.mjs"
100
+ },
93
101
  "./Transcript": {
94
102
  "types": "./dist/domain/Transcript.d.mts",
95
103
  "import": "./dist/domain/Transcript.mjs"
@@ -178,9 +186,13 @@
178
186
  "types": "./dist/web-search/WebSearchTool.d.mts",
179
187
  "import": "./dist/web-search/WebSearchTool.mjs"
180
188
  },
181
- "./Inbox": {
182
- "types": "./dist/inbox/Inbox.d.mts",
183
- "import": "./dist/inbox/Inbox.mjs"
189
+ "./WebSocketSession": {
190
+ "types": "./dist/streaming/WebSocketSession.d.mts",
191
+ "import": "./dist/streaming/WebSocketSession.mjs"
192
+ },
193
+ "./Settle": {
194
+ "types": "./dist/streaming/Settle.d.mts",
195
+ "import": "./dist/streaming/Settle.mjs"
184
196
  },
185
197
  "./Loop": {
186
198
  "types": "./dist/loop/Loop.d.mts",
@@ -270,6 +282,14 @@
270
282
  "types": "./dist/testing/MockSandbox.d.mts",
271
283
  "import": "./dist/testing/MockSandbox.mjs"
272
284
  },
285
+ "./testing/FakeWebSocket": {
286
+ "types": "./dist/testing/FakeWebSocket.d.mts",
287
+ "import": "./dist/testing/FakeWebSocket.mjs"
288
+ },
289
+ "./testing/MockRealtimeSession": {
290
+ "types": "./dist/testing/MockRealtimeSession.d.mts",
291
+ "import": "./dist/testing/MockRealtimeSession.mjs"
292
+ },
273
293
  "./testing/MockTranscriber": {
274
294
  "types": "./dist/testing/MockTranscriber.d.mts",
275
295
  "import": "./dist/testing/MockTranscriber.mjs"
@@ -100,6 +100,15 @@ export class Unsupported extends Data.TaggedError("Unsupported")<{
100
100
  reason?: string
101
101
  }> {}
102
102
 
103
+ /**
104
+ * A long-lived session hit the provider's lifetime cap. Distinct from
105
+ * `Unavailable`: reconnecting works, retrying the same session does not.
106
+ */
107
+ export class SessionExpired extends Data.TaggedError("SessionExpired")<{
108
+ provider: string
109
+ raw?: unknown
110
+ }> {}
111
+
103
112
  export type AiError =
104
113
  | RateLimited
105
114
  | Unavailable
@@ -112,6 +121,7 @@ export type AiError =
112
121
  | IncompleteTurn
113
122
  | GenerationFailed
114
123
  | Unsupported
124
+ | SessionExpired
115
125
 
116
126
  const withReason = (base: string, reason: string | undefined): string =>
117
127
  reason === undefined ? base : `${base}: ${reason}`
@@ -136,5 +146,6 @@ export const describe: (e: AiError) => string = Match.type<AiError>().pipe(
136
146
  IncompleteTurn: () => "the provider stream ended without completing",
137
147
  GenerationFailed: (e) => e.message ?? `${e.provider} failed mid-generation`,
138
148
  Unsupported: (e) => withReason(`${e.provider} does not support ${e.capability}`, e.reason),
149
+ SessionExpired: (e) => `the ${e.provider} session reached its lifetime limit`,
139
150
  }),
140
151
  )
@@ -0,0 +1,101 @@
1
+ /**
2
+ * Types for a duplex speech-to-speech session: one long-lived connection the
3
+ * server drives, rather than a caller-driven turn.
4
+ *
5
+ * Only what both OpenAI Realtime and Gemini Live do natively lives here. VAD
6
+ * knobs, truncation, resumption, thinking, grounding and noise reduction are
7
+ * provider-typed on each adapter's request.
8
+ */
9
+ import { Data, type Duration } from "effect"
10
+ import type { AudioFormat } from "./Audio.js"
11
+ import type { ImageSource } from "./Image.js"
12
+ import type { HistoryItem, ToolCall, ToolCallOutput, Usage } from "./Items.js"
13
+ import type { ToolDescriptor } from "../tool/Tool.js"
14
+
15
+ /**
16
+ * What the caller pushes into a live session.
17
+ *
18
+ * - `Audio`: encoded per `request.inputFormat`. The adapter neither paces nor
19
+ * buffers; send at real time.
20
+ * - `Text`: role `system` maps to a system item where the provider has one and
21
+ * fails `Unsupported` where it does not.
22
+ * - `Interrupt`: cancel the response in flight.
23
+ * - `VideoFrame`: gated by the `RealtimeVideoInput` marker.
24
+ * - `ActivityStart` / `ActivityEnd`: manual turn detection only; `ActivityEnd`
25
+ * also asks for a response. Dropped with a warning on a `"server"` session.
26
+ * - `PlaybackPosition`: how much of a response the user actually heard, so the
27
+ * adapter can trim the rest from context. A no-op where the wire has no
28
+ * truncate.
29
+ */
30
+ export type RealtimeInput = Data.TaggedEnum<{
31
+ Audio: { readonly bytes: Uint8Array }
32
+ Text: { readonly text: string; readonly role?: "user" | "system" }
33
+ ToolResult: { readonly output: ToolCallOutput }
34
+ Interrupt: {}
35
+ VideoFrame: { readonly frame: ImageSource }
36
+ ActivityStart: {}
37
+ ActivityEnd: {}
38
+ PlaybackPosition: { readonly responseId: string; readonly playedMs: number }
39
+ }>
40
+
41
+ export const RealtimeInput = Data.taggedEnum<RealtimeInput>()
42
+
43
+ /**
44
+ * What the session emits. Every `AudioDelta` and `OutputTranscriptDelta` is
45
+ * bracketed by `ResponseStarted` and `ResponseDone` carrying the same
46
+ * `responseId`.
47
+ *
48
+ * - `InputTranscript`: transcription of the user's own speech.
49
+ * - `SpeechStarted` / `SpeechStopped`: sparse. OpenAI emits them, Gemini
50
+ * does not.
51
+ * - `Interrupted`: stop and flush playback now. Always precedes the
52
+ * `ResponseDone { reason: "interrupted" }` for that response.
53
+ * - `ToolCallCancelled`: calls of an interrupted response that will never be
54
+ * answered.
55
+ * - `ResumptionHandle`: Gemini only; pass it back as `request.resume`.
56
+ * - `SessionEnding`: the connection is about to close on the server's terms.
57
+ * - `Error`: non-fatal. A fatal error fails the stream instead.
58
+ */
59
+ export type RealtimeEvent = Data.TaggedEnum<{
60
+ ResponseStarted: { readonly responseId: string }
61
+ AudioDelta: { readonly responseId: string; readonly bytes: Uint8Array }
62
+ OutputTranscriptDelta: { readonly responseId: string; readonly text: string }
63
+ InputTranscript: { readonly text: string; readonly final: boolean }
64
+ SpeechStarted: {}
65
+ SpeechStopped: {}
66
+ ToolCall: { readonly responseId: string; readonly call: ToolCall }
67
+ ToolCallCancelled: { readonly callIds: ReadonlyArray<string> }
68
+ Interrupted: { readonly responseId: string }
69
+ ResponseDone: {
70
+ readonly responseId: string
71
+ readonly reason: "complete" | "interrupted" | "cancelled" | "error"
72
+ readonly usage?: Usage
73
+ }
74
+ ResumptionHandle: { readonly handle: string }
75
+ SessionEnding: { readonly timeLeft?: Duration.Duration }
76
+ Error: { readonly code?: string; readonly message: string }
77
+ }>
78
+
79
+ export const RealtimeEvent = Data.taggedEnum<RealtimeEvent>()
80
+
81
+ /**
82
+ * Cross-provider session setup. Each adapter extends this, narrowing `model`
83
+ * and adding its own knobs.
84
+ */
85
+ export type CommonSessionRequest = {
86
+ readonly model: string
87
+ readonly instructions?: string
88
+ readonly voiceId?: string
89
+ /** `Toolkit.descriptors(toolkit)`. Immutable for the connection on Gemini. */
90
+ readonly tools?: ReadonlyArray<ToolDescriptor>
91
+ readonly inputFormat: AudioFormat
92
+ readonly outputFormat: AudioFormat
93
+ /** Who decides a turn ended. Defaults to `"server"`. */
94
+ readonly turnDetection?: "server" | "manual"
95
+ /** Transcribe the user's own audio. Defaults to `true`. */
96
+ readonly transcribeInput?: boolean
97
+ /** Text-only conversation seed. */
98
+ readonly history?: ReadonlyArray<HistoryItem>
99
+ /** A `ResumptionHandle` from a previous session. `Unsupported` off Gemini. */
100
+ readonly resume?: string
101
+ }
@@ -0,0 +1,98 @@
1
+ import { describe, it } from "@effect/vitest"
2
+ import { Cause, Effect, Queue, Ref, Stream } from "effect"
3
+ import { TestClock } from "effect/testing"
4
+ import { expect } from "vitest"
5
+ import { accumulatePartials, type TranscriptEvent } from "./Transcript.js"
6
+
7
+ const collecting = (stream: Stream.Stream<TranscriptEvent, never>) =>
8
+ Effect.gen(function* () {
9
+ const seen = yield* Ref.make<ReadonlyArray<TranscriptEvent>>([])
10
+ yield* Effect.forkChild(Stream.runForEach(stream, (e) => Ref.update(seen, (xs) => [...xs, e])))
11
+ return seen
12
+ })
13
+
14
+ /** Deltas arriving faster than any settle window. */
15
+ const speak = (
16
+ queue: Queue.Queue<TranscriptEvent, Cause.Done>,
17
+ tokens: ReadonlyArray<string>,
18
+ ): Effect.Effect<void> =>
19
+ Effect.forEach(
20
+ tokens,
21
+ (text) =>
22
+ Effect.andThen(Queue.offer(queue, { _tag: "partial", text }), TestClock.adjust("100 millis")),
23
+ { discard: true },
24
+ )
25
+
26
+ const opened = Effect.gen(function* () {
27
+ const queue = yield* Queue.unbounded<TranscriptEvent, Cause.Done>()
28
+ const seen = yield* collecting(
29
+ Stream.fromQueue(queue).pipe(accumulatePartials({ silence: "1 second" })),
30
+ )
31
+ return { queue, seen }
32
+ })
33
+
34
+ describe("accumulatePartials", () => {
35
+ it.effect("joins deltas into the running hypothesis, forwarding each as it arrives", () =>
36
+ Effect.gen(function* () {
37
+ const { queue, seen } = yield* opened
38
+
39
+ yield* speak(queue, [" Hi", ",", " how"])
40
+
41
+ // Three in, three out, each the whole sentence so far, none delayed.
42
+ expect(yield* Ref.get(seen)).toEqual([
43
+ { _tag: "partial", text: "Hi" },
44
+ { _tag: "partial", text: "Hi," },
45
+ { _tag: "partial", text: "Hi, how" },
46
+ ])
47
+ }),
48
+ )
49
+
50
+ it.effect("commits on silence and starts the next utterance from empty", () =>
51
+ Effect.gen(function* () {
52
+ const { queue, seen } = yield* opened
53
+
54
+ yield* speak(queue, [" Hi", " there"])
55
+ yield* TestClock.adjust("3 seconds")
56
+ yield* speak(queue, [" Bye"])
57
+ yield* TestClock.adjust("3 seconds")
58
+
59
+ expect(yield* Ref.get(seen)).toEqual([
60
+ { _tag: "partial", text: "Hi" },
61
+ { _tag: "partial", text: "Hi there" },
62
+ { _tag: "final", text: "Hi there" },
63
+ { _tag: "partial", text: "Bye" },
64
+ { _tag: "final", text: "Bye" },
65
+ ])
66
+ }),
67
+ )
68
+
69
+ it.effect("a provider's own final resets the accumulator, so silence adds no duplicate", () =>
70
+ Effect.gen(function* () {
71
+ const { queue, seen } = yield* opened
72
+
73
+ yield* speak(queue, [" Hi"])
74
+ yield* Queue.offer(queue, { _tag: "final", text: "Hi." })
75
+ yield* TestClock.adjust("3 seconds")
76
+
77
+ expect(yield* Ref.get(seen)).toEqual([
78
+ { _tag: "partial", text: "Hi" },
79
+ { _tag: "final", text: "Hi." },
80
+ ])
81
+ }),
82
+ )
83
+
84
+ it.effect("commits the pending text when the stream ends mid-utterance", () =>
85
+ Effect.gen(function* () {
86
+ const { queue, seen } = yield* opened
87
+
88
+ yield* speak(queue, [" Half a sen"])
89
+ yield* Queue.end(queue)
90
+ yield* TestClock.adjust("10 millis")
91
+
92
+ expect(yield* Ref.get(seen)).toEqual([
93
+ { _tag: "partial", text: "Half a sen" },
94
+ { _tag: "final", text: "Half a sen" },
95
+ ])
96
+ }),
97
+ )
98
+ })