@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.
- package/README.md +30 -16
- package/dist/{AiError-eLV9t1lU.d.mts → AiError-DvhLIbmd.d.mts} +15 -4
- package/dist/{AiError-eLV9t1lU.d.mts.map → AiError-DvhLIbmd.d.mts.map} +1 -1
- package/dist/{Loop-B54AB1ME.d.mts → Loop-ChpaO-ya.d.mts} +2 -2
- package/dist/{Loop-B54AB1ME.d.mts.map → Loop-ChpaO-ya.d.mts.map} +1 -1
- package/dist/{Toolkit-DAjJ9Tvl.d.mts → Toolkit-tlL0F2YR.d.mts} +2 -2
- package/dist/{Toolkit-DAjJ9Tvl.d.mts.map → Toolkit-tlL0F2YR.d.mts.map} +1 -1
- package/dist/chunker/Chunker.d.mts +1 -1
- package/dist/domain/AiError.d.mts +2 -2
- package/dist/domain/AiError.mjs +9 -2
- package/dist/domain/AiError.mjs.map +1 -1
- package/dist/domain/Realtime.d.mts +616 -0
- package/dist/domain/Realtime.d.mts.map +1 -0
- package/dist/domain/Realtime.mjs +21 -0
- package/dist/domain/Realtime.mjs.map +1 -0
- package/dist/domain/Transcript.d.mts +17 -3
- package/dist/domain/Transcript.d.mts.map +1 -1
- package/dist/domain/Transcript.mjs +30 -1
- package/dist/domain/Transcript.mjs.map +1 -1
- package/dist/domain/Transcript.test.mjs +110 -0
- package/dist/domain/Transcript.test.mjs.map +1 -0
- package/dist/embedding-model/EmbeddingModel.d.mts +1 -1
- package/dist/image-generator/ImageGenerator.d.mts +1 -1
- package/dist/index.d.mts +8 -5
- package/dist/index.mjs +6 -3
- package/dist/job/Job.d.mts +1 -1
- package/dist/language-model/LanguageModel.d.mts +2 -2
- package/dist/loop/Loop.d.mts +1 -1
- package/dist/music-generator/MusicGenerator.d.mts +1 -1
- package/dist/realtime/RealtimeSession.d.mts +50 -0
- package/dist/realtime/RealtimeSession.d.mts.map +1 -0
- package/dist/realtime/RealtimeSession.mjs +45 -0
- package/dist/realtime/RealtimeSession.mjs.map +1 -0
- package/dist/realtime/RealtimeSession.test.d.mts +1 -0
- package/dist/realtime/RealtimeSession.test.mjs +95 -0
- package/dist/realtime/RealtimeSession.test.mjs.map +1 -0
- package/dist/reranker/Reranker.d.mts +1 -1
- package/dist/research/DeepResearch.d.mts +1 -1
- package/dist/retry/Retry.d.mts +1 -1
- package/dist/speech-synthesizer/SpeechSynthesizer.d.mts +1 -1
- package/dist/streaming/Settle.d.mts +45 -0
- package/dist/streaming/Settle.d.mts.map +1 -0
- package/dist/streaming/Settle.mjs +95 -0
- package/dist/streaming/Settle.mjs.map +1 -0
- package/dist/streaming/Settle.test.d.mts +1 -0
- package/dist/streaming/Settle.test.mjs +252 -0
- package/dist/streaming/Settle.test.mjs.map +1 -0
- package/dist/streaming/WebSocketSession.d.mts +36 -0
- package/dist/streaming/WebSocketSession.d.mts.map +1 -0
- package/dist/streaming/WebSocketSession.mjs +89 -0
- package/dist/streaming/WebSocketSession.mjs.map +1 -0
- package/dist/streaming/WebSocketSession.test.d.mts +1 -0
- package/dist/streaming/WebSocketSession.test.mjs +51 -0
- package/dist/streaming/WebSocketSession.test.mjs.map +1 -0
- package/dist/testing/FakeWebSocket.d.mts +25 -0
- package/dist/testing/FakeWebSocket.d.mts.map +1 -0
- package/dist/testing/FakeWebSocket.mjs +79 -0
- package/dist/testing/FakeWebSocket.mjs.map +1 -0
- package/dist/testing/MockRealtimeSession.d.mts +30 -0
- package/dist/testing/MockRealtimeSession.d.mts.map +1 -0
- package/dist/testing/MockRealtimeSession.mjs +52 -0
- package/dist/testing/MockRealtimeSession.mjs.map +1 -0
- package/dist/tool/Toolkit.d.mts +1 -1
- package/dist/transcriber/Transcriber.d.mts +1 -1
- package/dist/web-read/WebRead.d.mts +1 -1
- package/dist/web-read/WebReadTool.d.mts +1 -1
- package/dist/web-search/WebSearch.d.mts +1 -1
- package/dist/web-search/WebSearchTool.d.mts +1 -1
- package/package.json +24 -4
- package/src/domain/AiError.ts +11 -0
- package/src/domain/Realtime.ts +101 -0
- package/src/domain/Transcript.test.ts +98 -0
- package/src/domain/Transcript.ts +53 -1
- package/src/index.ts +4 -1
- package/src/realtime/RealtimeSession.test.ts +100 -0
- package/src/realtime/RealtimeSession.ts +77 -0
- package/src/streaming/Settle.test.ts +344 -0
- package/src/streaming/Settle.ts +196 -0
- package/src/streaming/WebSocketSession.test.ts +52 -0
- package/src/streaming/WebSocketSession.ts +132 -0
- package/src/testing/FakeWebSocket.ts +120 -0
- package/src/testing/MockRealtimeSession.ts +92 -0
- package/dist/inbox/Inbox.d.mts +0 -16
- package/dist/inbox/Inbox.d.mts.map +0 -1
- package/dist/inbox/Inbox.mjs +0 -28
- package/dist/inbox/Inbox.mjs.map +0 -1
- package/dist/inbox/Inbox.test.mjs +0 -42
- package/dist/inbox/Inbox.test.mjs.map +0 -1
- package/src/inbox/Inbox.test.ts +0 -52
- package/src/inbox/Inbox.ts +0 -33
- /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"}
|
package/dist/tool/Toolkit.d.mts
CHANGED
|
@@ -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-
|
|
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-
|
|
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-
|
|
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-
|
|
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-
|
|
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-
|
|
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.
|
|
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
|
-
"./
|
|
182
|
-
"types": "./dist/
|
|
183
|
-
"import": "./dist/
|
|
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"
|
package/src/domain/AiError.ts
CHANGED
|
@@ -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
|
+
})
|