@effect-uai/core 0.15.0 → 0.17.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/decision-model/Decision.d.mts +155 -0
- package/dist/decision-model/Decision.d.mts.map +1 -0
- package/dist/decision-model/Decision.mjs +118 -0
- package/dist/decision-model/Decision.mjs.map +1 -0
- package/dist/decision-model/Decision.test.mjs +164 -0
- package/dist/decision-model/Decision.test.mjs.map +1 -0
- package/dist/decision-model/DecisionModel.d.mts +45 -0
- package/dist/decision-model/DecisionModel.d.mts.map +1 -0
- package/dist/decision-model/DecisionModel.mjs +25 -0
- package/dist/decision-model/DecisionModel.mjs.map +1 -0
- 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.d.mts +1 -0
- 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 +11 -5
- package/dist/index.mjs +9 -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/math/Probability.d.mts +21 -0
- package/dist/math/Probability.d.mts.map +1 -0
- package/dist/math/Probability.mjs +31 -0
- package/dist/math/Probability.mjs.map +1 -0
- package/dist/math/Probability.test.d.mts +1 -0
- package/dist/math/Probability.test.mjs +95 -0
- package/dist/math/Probability.test.mjs.map +1 -0
- 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 +36 -4
- package/src/decision-model/Decision.test.ts +146 -0
- package/src/decision-model/Decision.ts +273 -0
- package/src/decision-model/DecisionModel.ts +53 -0
- 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 +7 -1
- package/src/math/Probability.test.ts +61 -0
- package/src/math/Probability.ts +44 -0
- 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 → decision-model/Decision.test.d.mts} +0 -0
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One JSON-over-WebSocket session: connect, read decoded frames off a queue,
|
|
3
|
+
* write frames back.
|
|
4
|
+
*
|
|
5
|
+
* For duplex protocols where the caller drives the conversation frame by
|
|
6
|
+
* frame. The stream-shaped adapters (realtime STT and TTS) still carry their
|
|
7
|
+
* own copies of this.
|
|
8
|
+
*/
|
|
9
|
+
import {
|
|
10
|
+
Cause,
|
|
11
|
+
type Duration,
|
|
12
|
+
Effect,
|
|
13
|
+
Match,
|
|
14
|
+
Option,
|
|
15
|
+
Predicate,
|
|
16
|
+
Queue,
|
|
17
|
+
Schema,
|
|
18
|
+
type Scope,
|
|
19
|
+
} from "effect"
|
|
20
|
+
import * as Socket from "effect/unstable/socket/Socket"
|
|
21
|
+
import * as AiError from "../domain/AiError.js"
|
|
22
|
+
import * as JSONL from "./JSONL.js"
|
|
23
|
+
|
|
24
|
+
export type WebSocketSession<A> = {
|
|
25
|
+
/** Fails `Unavailable` once the socket is closed. */
|
|
26
|
+
readonly send: (frame: string) => Effect.Effect<void, AiError.AiError>
|
|
27
|
+
/** Ends when the socket closes cleanly, fails when it does not. */
|
|
28
|
+
readonly frames: Queue.Dequeue<A, AiError.AiError | Cause.Done>
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export type OpenOptions<A, I> = {
|
|
32
|
+
readonly url: string
|
|
33
|
+
readonly provider: string
|
|
34
|
+
/** Frames that do not decode are dropped, and logged at debug level. */
|
|
35
|
+
readonly schema: Schema.Codec<A, I>
|
|
36
|
+
readonly openTimeout?: Duration.Input
|
|
37
|
+
readonly capacity?: number
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* The cause of a failed open is a DOM `ErrorEvent`, not an `Error`, and its
|
|
42
|
+
* `message` is a prototype getter, so a structural decode does not see it.
|
|
43
|
+
*/
|
|
44
|
+
const describeCause = (cause: unknown): string =>
|
|
45
|
+
Predicate.hasProperty(cause, "message") && Predicate.isString(cause.message)
|
|
46
|
+
? cause.message
|
|
47
|
+
: String(cause)
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Some wires authenticate with a key in the query string, and some runtimes
|
|
51
|
+
* name the socket URL in their open error (Bun does). Everything taken off a
|
|
52
|
+
* socket error goes through this.
|
|
53
|
+
*/
|
|
54
|
+
export const redactUrl = (text: string): string =>
|
|
55
|
+
text.replace(/(\b(?:wss?|https?):\/\/[^\s"']*?)\?[^\s"']*/gi, "$1?<redacted>")
|
|
56
|
+
|
|
57
|
+
const detailOf = Match.type<Socket.SocketError["reason"]>().pipe(
|
|
58
|
+
Match.tag("SocketCloseError", (reason) => reason.message),
|
|
59
|
+
Match.orElse((reason) => describeCause(reason.cause)),
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
/** Never the `SocketError` itself: callers print `raw` with `Cause.pretty`. */
|
|
63
|
+
const rawOf = (error: Socket.SocketError): string =>
|
|
64
|
+
redactUrl(`${error.reason._tag}: ${detailOf(error.reason)}`)
|
|
65
|
+
|
|
66
|
+
/** A rejected WS upgrade surfaces only as prose, so the status is read from it. */
|
|
67
|
+
const isAuthRejection = (text: string): boolean => /\b40[13]\b/.test(text)
|
|
68
|
+
|
|
69
|
+
export const toAiError =
|
|
70
|
+
(provider: string) =>
|
|
71
|
+
(error: Socket.SocketError): AiError.AiError => {
|
|
72
|
+
const raw = rawOf(error)
|
|
73
|
+
return Match.value(error.reason).pipe(
|
|
74
|
+
Match.tag("SocketOpenError", (reason) =>
|
|
75
|
+
Match.value(reason.kind).pipe(
|
|
76
|
+
Match.when("Timeout", (): AiError.AiError => new AiError.Timeout({ provider, raw })),
|
|
77
|
+
Match.orElse((): AiError.AiError =>
|
|
78
|
+
isAuthRejection(describeCause(reason.cause))
|
|
79
|
+
? new AiError.AuthFailed({ provider, subtype: "auth", raw })
|
|
80
|
+
: new AiError.Unavailable({ provider, raw }),
|
|
81
|
+
),
|
|
82
|
+
),
|
|
83
|
+
),
|
|
84
|
+
Match.orElse((): AiError.AiError => new AiError.Unavailable({ provider, raw })),
|
|
85
|
+
)
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Connect for the lifetime of the surrounding `Scope`. The reader runs in a
|
|
90
|
+
* forked fiber; a dirty close reaches the consumer as a failed queue rather
|
|
91
|
+
* than a clean end.
|
|
92
|
+
*/
|
|
93
|
+
export const open = <A, I>(
|
|
94
|
+
options: OpenOptions<A, I>,
|
|
95
|
+
): Effect.Effect<WebSocketSession<A>, AiError.AiError, Scope.Scope | Socket.WebSocketConstructor> =>
|
|
96
|
+
Effect.gen(function* () {
|
|
97
|
+
const asAiError = toAiError(options.provider)
|
|
98
|
+
const decode = Schema.decodeUnknownEffect(options.schema)
|
|
99
|
+
const socket = yield* Socket.makeWebSocket(options.url, {
|
|
100
|
+
// Effect's Socket treats every close code as an error by default.
|
|
101
|
+
closeCodeIsError: (code) => code !== 1000 && code !== 1001 && code !== 1005,
|
|
102
|
+
...(options.openTimeout !== undefined && { openTimeout: options.openTimeout }),
|
|
103
|
+
})
|
|
104
|
+
const frames = yield* Queue.bounded<A, AiError.AiError | Cause.Done>(options.capacity ?? 64)
|
|
105
|
+
const write = yield* socket.writer
|
|
106
|
+
|
|
107
|
+
const onFrame = (raw: string) =>
|
|
108
|
+
Effect.gen(function* () {
|
|
109
|
+
const json = yield* JSONL.parseSafe(raw)
|
|
110
|
+
if (json === undefined) return
|
|
111
|
+
const decoded = yield* decode(json).pipe(Effect.option)
|
|
112
|
+
if (Option.isNone(decoded)) {
|
|
113
|
+
yield* Effect.logDebug(`[${options.provider}] dropped frame`, { frame: json })
|
|
114
|
+
return
|
|
115
|
+
}
|
|
116
|
+
yield* Queue.offer(frames, decoded.value)
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
yield* socket.runString(onFrame).pipe(
|
|
120
|
+
Effect.mapError(asAiError),
|
|
121
|
+
// `Queue.end` on a clean close so the consumer drains what is queued;
|
|
122
|
+
// a failure must not arrive looking like the end of the conversation.
|
|
123
|
+
Effect.matchCauseEffect({
|
|
124
|
+
onFailure: (cause) =>
|
|
125
|
+
Cause.hasFails(cause) ? Queue.failCause(frames, cause) : Queue.end(frames),
|
|
126
|
+
onSuccess: () => Queue.end(frames),
|
|
127
|
+
}),
|
|
128
|
+
Effect.forkScoped,
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
return { send: (frame: string) => write(frame).pipe(Effect.mapError(asAiError)), frames }
|
|
132
|
+
})
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* An in-memory WebSocket for testing socket adapters without a server.
|
|
3
|
+
*
|
|
4
|
+
* Provide `layer` in place of the real `Socket.WebSocketConstructor` and the
|
|
5
|
+
* adapter runs end to end: its own connect, handshake, decode and teardown.
|
|
6
|
+
* The test plays the other end with `reply`, `push`, `close` and `sent`.
|
|
7
|
+
*
|
|
8
|
+
* Client frames land on a queue, so the scripted server is a forked fiber and
|
|
9
|
+
* `reply` is a real Effect. What stays imperative is the listener table the
|
|
10
|
+
* shim registers, since `Socket.makeWebSocket` drives it with plain callbacks.
|
|
11
|
+
* The shim is a plain object, so nothing here touches a platform global.
|
|
12
|
+
*/
|
|
13
|
+
import { Effect, Layer, Queue, Ref, type Scope, Stream } from "effect"
|
|
14
|
+
import * as Socket from "effect/unstable/socket/Socket"
|
|
15
|
+
|
|
16
|
+
export type FakeWebSocketServer = {
|
|
17
|
+
/** Every frame the client has sent, JSON-parsed, oldest first. */
|
|
18
|
+
readonly sent: Effect.Effect<ReadonlyArray<unknown>>
|
|
19
|
+
/** Deliver one frame to the client. */
|
|
20
|
+
readonly push: (frame: unknown) => Effect.Effect<void>
|
|
21
|
+
/** Close the socket. 1000, 1001 and 1005 are the clean codes. */
|
|
22
|
+
readonly close: (code?: number) => Effect.Effect<void>
|
|
23
|
+
/** Hand this to an adapter that takes a socket constructor directly. */
|
|
24
|
+
readonly connect: Socket.WebSocketConstructor["Service"]
|
|
25
|
+
/** Provide this in place of the real constructor. */
|
|
26
|
+
readonly layer: Layer.Layer<Socket.WebSocketConstructor>
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export type FakeWebSocketOptions = {
|
|
30
|
+
/** Pushed once the client subscribes, for a protocol whose server speaks first. */
|
|
31
|
+
readonly greeting?: ReadonlyArray<unknown>
|
|
32
|
+
/** How the server answers each client frame. */
|
|
33
|
+
readonly reply?: (frame: unknown) => Effect.Effect<ReadonlyArray<unknown>>
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
type Listener = (event: any) => void
|
|
37
|
+
|
|
38
|
+
const OPEN = 1
|
|
39
|
+
const CLOSED = 3
|
|
40
|
+
|
|
41
|
+
/** Run after the current call unwinds, so the client is never re-entered. */
|
|
42
|
+
const soon = (run: () => void): void => void Promise.resolve().then(run)
|
|
43
|
+
|
|
44
|
+
export const make = (
|
|
45
|
+
options?: FakeWebSocketOptions,
|
|
46
|
+
): Effect.Effect<FakeWebSocketServer, never, Scope.Scope> =>
|
|
47
|
+
Effect.gen(function* () {
|
|
48
|
+
const inbound = yield* Queue.unbounded<unknown>()
|
|
49
|
+
const sent = yield* Ref.make<ReadonlyArray<unknown>>([])
|
|
50
|
+
|
|
51
|
+
const wiring = {
|
|
52
|
+
listeners: {} as Record<string, ReadonlyArray<Listener>>,
|
|
53
|
+
readyState: OPEN,
|
|
54
|
+
greeted: false,
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const emit = (type: string, event: unknown): void =>
|
|
58
|
+
(wiring.listeners[type] ?? []).forEach((listener) => listener(event))
|
|
59
|
+
|
|
60
|
+
const deliverSync = (frames: ReadonlyArray<unknown>): void =>
|
|
61
|
+
frames.forEach((f) => emit("message", { data: JSON.stringify(f) }))
|
|
62
|
+
|
|
63
|
+
const deliver = (frames: ReadonlyArray<unknown>): Effect.Effect<void> =>
|
|
64
|
+
Effect.sync(() => deliverSync(frames))
|
|
65
|
+
|
|
66
|
+
const shutdown = (code: number): void => {
|
|
67
|
+
if (wiring.readyState === CLOSED) return
|
|
68
|
+
wiring.readyState = CLOSED
|
|
69
|
+
emit("close", { code, reason: "" })
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// The scripted server. Answers land on their own turn, like a real one.
|
|
73
|
+
yield* Stream.fromQueue(inbound).pipe(
|
|
74
|
+
Stream.runForEach((frame) =>
|
|
75
|
+
Effect.gen(function* () {
|
|
76
|
+
yield* Ref.update(sent, (frames) => [...frames, frame])
|
|
77
|
+
const answer = yield* options?.reply?.(frame) ?? Effect.succeed([])
|
|
78
|
+
yield* deliver(answer)
|
|
79
|
+
}),
|
|
80
|
+
),
|
|
81
|
+
Effect.forkScoped,
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
const socket = {
|
|
85
|
+
get readyState() {
|
|
86
|
+
return wiring.readyState
|
|
87
|
+
},
|
|
88
|
+
addEventListener: (type: string, fn: Listener): void => {
|
|
89
|
+
wiring.listeners = {
|
|
90
|
+
...wiring.listeners,
|
|
91
|
+
[type]: [...(wiring.listeners[type] ?? []), fn],
|
|
92
|
+
}
|
|
93
|
+
// The greeting waits for a subscriber before it lands.
|
|
94
|
+
if (type === "message" && !wiring.greeted) {
|
|
95
|
+
wiring.greeted = true
|
|
96
|
+
soon(() => deliverSync(options?.greeting ?? []))
|
|
97
|
+
}
|
|
98
|
+
},
|
|
99
|
+
removeEventListener: (type: string, fn: Listener): void => {
|
|
100
|
+
wiring.listeners = {
|
|
101
|
+
...wiring.listeners,
|
|
102
|
+
[type]: (wiring.listeners[type] ?? []).filter((l) => l !== fn),
|
|
103
|
+
}
|
|
104
|
+
},
|
|
105
|
+
// Safe from a synchronous callback, and no runtime needed.
|
|
106
|
+
send: (data: string): void => void Queue.offerUnsafe(inbound, JSON.parse(data)),
|
|
107
|
+
close: (code = 1000): void => shutdown(code),
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const connect: Socket.WebSocketConstructor["Service"] = () =>
|
|
111
|
+
socket as unknown as globalThis.WebSocket
|
|
112
|
+
|
|
113
|
+
return {
|
|
114
|
+
sent: Ref.get(sent),
|
|
115
|
+
push: (frame) => deliver([frame]),
|
|
116
|
+
close: (code = 1000) => Effect.sync(() => shutdown(code)),
|
|
117
|
+
connect,
|
|
118
|
+
layer: Layer.succeed(Socket.WebSocketConstructor, connect),
|
|
119
|
+
}
|
|
120
|
+
})
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { Cause, Effect, Layer, Queue, Ref, Stream } from "effect"
|
|
2
|
+
import type { CommonSessionRequest, RealtimeEvent, RealtimeInput } from "../domain/Realtime.js"
|
|
3
|
+
import {
|
|
4
|
+
RealtimeSession,
|
|
5
|
+
RealtimeVideoInput,
|
|
6
|
+
type RealtimeSessionHandle,
|
|
7
|
+
type RealtimeSessionService,
|
|
8
|
+
} from "../realtime/RealtimeSession.js"
|
|
9
|
+
|
|
10
|
+
export type MockRealtimeSessionRecorder = {
|
|
11
|
+
readonly openCalls: ReadonlyArray<CommonSessionRequest>
|
|
12
|
+
readonly sent: ReadonlyArray<RealtimeInput>
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export type MockRealtimeSessionScript = {
|
|
16
|
+
/** Emitted as soon as the session opens. */
|
|
17
|
+
readonly initial?: ReadonlyArray<RealtimeEvent>
|
|
18
|
+
/** The server's answer to each input. */
|
|
19
|
+
readonly onInput?: (input: RealtimeInput) => ReadonlyArray<RealtimeEvent>
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
type Recorders = {
|
|
23
|
+
readonly open: (request: CommonSessionRequest) => Effect.Effect<void>
|
|
24
|
+
readonly send: (input: RealtimeInput) => Effect.Effect<void>
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const makeService = (
|
|
28
|
+
script: MockRealtimeSessionScript,
|
|
29
|
+
record: Recorders,
|
|
30
|
+
): RealtimeSessionService => ({
|
|
31
|
+
open: (request) =>
|
|
32
|
+
Effect.gen(function* () {
|
|
33
|
+
yield* record.open(request)
|
|
34
|
+
const queue = yield* Queue.unbounded<RealtimeEvent, Cause.Done>()
|
|
35
|
+
// Closing the scope ends `events`, leaving anything already queued to drain.
|
|
36
|
+
yield* Effect.addFinalizer(() => Queue.end(queue))
|
|
37
|
+
yield* Queue.offerAll(queue, script.initial ?? [])
|
|
38
|
+
const handle: RealtimeSessionHandle = {
|
|
39
|
+
send: (input) =>
|
|
40
|
+
Effect.andThen(
|
|
41
|
+
record.send(input),
|
|
42
|
+
Queue.offerAll(queue, script.onInput?.(input) ?? []),
|
|
43
|
+
).pipe(Effect.asVoid),
|
|
44
|
+
events: Stream.fromQueue(queue),
|
|
45
|
+
}
|
|
46
|
+
return handle
|
|
47
|
+
}),
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
const build = (script: MockRealtimeSessionScript) => {
|
|
51
|
+
const openCalls = Ref.makeUnsafe<ReadonlyArray<CommonSessionRequest>>([])
|
|
52
|
+
const sent = Ref.makeUnsafe<ReadonlyArray<RealtimeInput>>([])
|
|
53
|
+
const sessionLayer = Layer.succeed(
|
|
54
|
+
RealtimeSession,
|
|
55
|
+
makeService(script, {
|
|
56
|
+
open: (request) => Ref.update(openCalls, (xs) => [...xs, request]),
|
|
57
|
+
send: (input) => Ref.update(sent, (xs) => [...xs, input]),
|
|
58
|
+
}),
|
|
59
|
+
)
|
|
60
|
+
const recorder: Effect.Effect<MockRealtimeSessionRecorder> = Effect.gen(function* () {
|
|
61
|
+
return { openCalls: yield* Ref.get(openCalls), sent: yield* Ref.get(sent) }
|
|
62
|
+
})
|
|
63
|
+
return { sessionLayer, recorder }
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Scripted session that also ships the `RealtimeVideoInput` marker. */
|
|
67
|
+
export const layer = (
|
|
68
|
+
script: MockRealtimeSessionScript,
|
|
69
|
+
): {
|
|
70
|
+
readonly layer: Layer.Layer<RealtimeSession | RealtimeVideoInput>
|
|
71
|
+
readonly recorder: Effect.Effect<MockRealtimeSessionRecorder>
|
|
72
|
+
} => {
|
|
73
|
+
const { sessionLayer, recorder } = build(script)
|
|
74
|
+
return {
|
|
75
|
+
layer: Layer.merge(sessionLayer, Layer.succeed(RealtimeVideoInput, undefined)),
|
|
76
|
+
recorder,
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* The same without the marker, to test that a caller sending video against an
|
|
82
|
+
* audio-only provider fails to compile.
|
|
83
|
+
*/
|
|
84
|
+
export const layerAudioOnly = (
|
|
85
|
+
script: MockRealtimeSessionScript,
|
|
86
|
+
): {
|
|
87
|
+
readonly layer: Layer.Layer<RealtimeSession>
|
|
88
|
+
readonly recorder: Effect.Effect<MockRealtimeSessionRecorder>
|
|
89
|
+
} => {
|
|
90
|
+
const { sessionLayer, recorder } = build(script)
|
|
91
|
+
return { layer: sessionLayer, recorder }
|
|
92
|
+
}
|
package/dist/inbox/Inbox.d.mts
DELETED
|
@@ -1,16 +0,0 @@
|
|
|
1
|
-
import { Array, Duration, Effect, Queue } from "effect";
|
|
2
|
-
declare namespace Inbox_d_exports {
|
|
3
|
-
export { drainBurst };
|
|
4
|
-
}
|
|
5
|
-
/**
|
|
6
|
-
* Block for the first item, then keep taking while the next one lands within
|
|
7
|
-
* `settle` of the previous. The window resets on every arrival, so a burst
|
|
8
|
-
* becomes one batch and a lone message followed by silence returns at once.
|
|
9
|
-
*
|
|
10
|
-
* A take that loses to the window is interrupted before it removes anything,
|
|
11
|
-
* so late arrivals stay queued for the next drain.
|
|
12
|
-
*/
|
|
13
|
-
declare const drainBurst: <A, E>(queue: Queue.Dequeue<A, E>, settle: Duration.Input) => Effect.Effect<Array.NonEmptyReadonlyArray<A>, E>;
|
|
14
|
-
//#endregion
|
|
15
|
-
export { drainBurst, Inbox_d_exports as t };
|
|
16
|
-
//# sourceMappingURL=Inbox.d.mts.map
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"Inbox.d.mts","names":[],"sources":["../../src/inbox/Inbox.ts"],"mappings":";;;;;;;;;;;;cAea,aAAc,GAAG,GAAC,OACtB,MAAM,QAAQ,GAAG,IAAE,QAClB,SAAS,UAChB,OAAO,OAAO,MAAI,sBAAsB,IAAI"}
|
package/dist/inbox/Inbox.mjs
DELETED
|
@@ -1,28 +0,0 @@
|
|
|
1
|
-
import { n as __exportAll } from "../rolldown-runtime-EAsCxrDo.mjs";
|
|
2
|
-
import { Array, Effect, Option, Queue } from "effect";
|
|
3
|
-
//#region src/inbox/Inbox.ts
|
|
4
|
-
/**
|
|
5
|
-
* Input a long-lived agent reads between turns. A chat user rarely sends one
|
|
6
|
-
* tidy message: they type three lines in a row, then go quiet. The loop wants
|
|
7
|
-
* that as one batch, taken at a clean turn boundary.
|
|
8
|
-
*/
|
|
9
|
-
var Inbox_exports = /* @__PURE__ */ __exportAll({ drainBurst: () => drainBurst });
|
|
10
|
-
/**
|
|
11
|
-
* Block for the first item, then keep taking while the next one lands within
|
|
12
|
-
* `settle` of the previous. The window resets on every arrival, so a burst
|
|
13
|
-
* becomes one batch and a lone message followed by silence returns at once.
|
|
14
|
-
*
|
|
15
|
-
* A take that loses to the window is interrupted before it removes anything,
|
|
16
|
-
* so late arrivals stay queued for the next drain.
|
|
17
|
-
*/
|
|
18
|
-
const drainBurst = (queue, settle) => {
|
|
19
|
-
const settled = (batch) => Queue.take(queue).pipe(Effect.timeoutOption(settle), Effect.flatMap(Option.match({
|
|
20
|
-
onNone: () => Effect.succeed(batch),
|
|
21
|
-
onSome: (item) => settled(Array.append(batch, item))
|
|
22
|
-
})));
|
|
23
|
-
return Effect.flatMap(Queue.take(queue), (first) => settled([first]));
|
|
24
|
-
};
|
|
25
|
-
//#endregion
|
|
26
|
-
export { drainBurst, Inbox_exports as t };
|
|
27
|
-
|
|
28
|
-
//# sourceMappingURL=Inbox.mjs.map
|
package/dist/inbox/Inbox.mjs.map
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"Inbox.mjs","names":["Arr"],"sources":["../../src/inbox/Inbox.ts"],"sourcesContent":["/**\n * Input a long-lived agent reads between turns. A chat user rarely sends one\n * tidy message: they type three lines in a row, then go quiet. The loop wants\n * that as one batch, taken at a clean turn boundary.\n */\nimport { Array as Arr, type Duration, Effect, Option, Queue } from \"effect\"\n\n/**\n * Block for the first item, then keep taking while the next one lands within\n * `settle` of the previous. The window resets on every arrival, so a burst\n * becomes one batch and a lone message followed by silence returns at once.\n *\n * A take that loses to the window is interrupted before it removes anything,\n * so late arrivals stay queued for the next drain.\n */\nexport const drainBurst = <A, E>(\n queue: Queue.Dequeue<A, E>,\n settle: Duration.Input,\n): Effect.Effect<Arr.NonEmptyReadonlyArray<A>, E> => {\n const settled = (\n batch: Arr.NonEmptyReadonlyArray<A>,\n ): Effect.Effect<Arr.NonEmptyReadonlyArray<A>, E> =>\n Queue.take(queue).pipe(\n Effect.timeoutOption(settle),\n Effect.flatMap(\n Option.match({\n onNone: () => Effect.succeed(batch),\n onSome: (item) => settled(Arr.append(batch, item)),\n }),\n ),\n )\n return Effect.flatMap(Queue.take(queue), (first) => settled([first]))\n}\n"],"mappings":";;;;;;;;;;;;;;;;;AAeA,MAAa,cACX,OACA,WACmD;CACnD,MAAM,WACJ,UAEA,MAAM,KAAK,KAAK,CAAC,CAAC,KAChB,OAAO,cAAc,MAAM,GAC3B,OAAO,QACL,OAAO,MAAM;EACX,cAAc,OAAO,QAAQ,KAAK;EAClC,SAAS,SAAS,QAAQA,MAAI,OAAO,OAAO,IAAI,CAAC;CACnD,CAAC,CACH,CACF;CACF,OAAO,OAAO,QAAQ,MAAM,KAAK,KAAK,IAAI,UAAU,QAAQ,CAAC,KAAK,CAAC,CAAC;AACtE"}
|
|
@@ -1,42 +0,0 @@
|
|
|
1
|
-
import { drainBurst } from "./Inbox.mjs";
|
|
2
|
-
import { o as describe } from "../utils-CiH_DjFW.mjs";
|
|
3
|
-
import { n as globalExpect } from "../test.DNmyFkvJ-YPzDR0Co.mjs";
|
|
4
|
-
import { t as it } from "../dist-nohcnkD6.mjs";
|
|
5
|
-
import { Effect, Fiber, Option, Queue } from "effect";
|
|
6
|
-
import { TestClock } from "effect/testing";
|
|
7
|
-
//#region src/inbox/Inbox.test.ts
|
|
8
|
-
describe("drainBurst", () => {
|
|
9
|
-
it.effect("resets the window on every arrival, so a burst outlives one settle", () => Effect.gen(function* () {
|
|
10
|
-
const queue = yield* Queue.unbounded();
|
|
11
|
-
const burst = yield* Effect.forkChild(drainBurst(queue, "1 second"));
|
|
12
|
-
yield* Queue.offer(queue, "a");
|
|
13
|
-
yield* TestClock.adjust("900 millis");
|
|
14
|
-
yield* Queue.offer(queue, "b");
|
|
15
|
-
yield* TestClock.adjust("900 millis");
|
|
16
|
-
globalExpect(burst.pollUnsafe()).toBeUndefined();
|
|
17
|
-
yield* TestClock.adjust("1 second");
|
|
18
|
-
globalExpect(yield* Fiber.join(burst)).toEqual(["a", "b"]);
|
|
19
|
-
}));
|
|
20
|
-
it.effect("waits for the first item without a deadline", () => Effect.gen(function* () {
|
|
21
|
-
const queue = yield* Queue.unbounded();
|
|
22
|
-
const burst = yield* Effect.forkChild(drainBurst(queue, "1 second"));
|
|
23
|
-
yield* TestClock.adjust("1 hour");
|
|
24
|
-
globalExpect(burst.pollUnsafe()).toBeUndefined();
|
|
25
|
-
yield* Queue.offer(queue, "late");
|
|
26
|
-
yield* TestClock.adjust("1 second");
|
|
27
|
-
globalExpect(yield* Fiber.join(burst)).toEqual(["late"]);
|
|
28
|
-
}));
|
|
29
|
-
it.effect("ends on the first quiet gap and leaves later items for the next drain", () => Effect.gen(function* () {
|
|
30
|
-
const queue = yield* Queue.unbounded();
|
|
31
|
-
const burst = yield* Effect.forkChild(drainBurst(queue, "1 second"));
|
|
32
|
-
yield* Queue.offer(queue, "first");
|
|
33
|
-
yield* TestClock.adjust("1 second");
|
|
34
|
-
globalExpect(yield* Fiber.join(burst)).toEqual(["first"]);
|
|
35
|
-
yield* Queue.offer(queue, "second");
|
|
36
|
-
globalExpect(yield* Queue.poll(queue)).toEqual(Option.some("second"));
|
|
37
|
-
}));
|
|
38
|
-
});
|
|
39
|
-
//#endregion
|
|
40
|
-
export {};
|
|
41
|
-
|
|
42
|
-
//# sourceMappingURL=Inbox.test.mjs.map
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"Inbox.test.mjs","names":[],"sources":["../../src/inbox/Inbox.test.ts"],"sourcesContent":["import { describe, it } from \"@effect/vitest\"\nimport { Effect, Fiber, Option, Queue } from \"effect\"\nimport { TestClock } from \"effect/testing\"\nimport { expect } from \"vitest\"\nimport { drainBurst } from \"./Inbox.js\"\n\ndescribe(\"drainBurst\", () => {\n it.effect(\"resets the window on every arrival, so a burst outlives one settle\", () =>\n Effect.gen(function* () {\n const queue = yield* Queue.unbounded<string>()\n const burst = yield* Effect.forkChild(drainBurst(queue, \"1 second\"))\n\n yield* Queue.offer(queue, \"a\")\n yield* TestClock.adjust(\"900 millis\")\n yield* Queue.offer(queue, \"b\")\n yield* TestClock.adjust(\"900 millis\")\n // 1.8s elapsed, more than one settle, and the burst is still open.\n expect(burst.pollUnsafe()).toBeUndefined()\n\n yield* TestClock.adjust(\"1 second\")\n expect(yield* Fiber.join(burst)).toEqual([\"a\", \"b\"])\n }),\n )\n\n it.effect(\"waits for the first item without a deadline\", () =>\n Effect.gen(function* () {\n const queue = yield* Queue.unbounded<string>()\n const burst = yield* Effect.forkChild(drainBurst(queue, \"1 second\"))\n\n yield* TestClock.adjust(\"1 hour\")\n expect(burst.pollUnsafe()).toBeUndefined()\n\n yield* Queue.offer(queue, \"late\")\n yield* TestClock.adjust(\"1 second\")\n expect(yield* Fiber.join(burst)).toEqual([\"late\"])\n }),\n )\n\n it.effect(\"ends on the first quiet gap and leaves later items for the next drain\", () =>\n Effect.gen(function* () {\n const queue = yield* Queue.unbounded<string>()\n const burst = yield* Effect.forkChild(drainBurst(queue, \"1 second\"))\n\n yield* Queue.offer(queue, \"first\")\n yield* TestClock.adjust(\"1 second\")\n expect(yield* Fiber.join(burst)).toEqual([\"first\"])\n\n yield* Queue.offer(queue, \"second\")\n expect(yield* Queue.poll(queue)).toEqual(Option.some(\"second\"))\n }),\n )\n})\n"],"mappings":";;;;;;;AAMA,SAAS,oBAAoB;CAC3B,GAAG,OAAO,4EACR,OAAO,IAAI,aAAa;EACtB,MAAM,QAAQ,OAAO,MAAM,UAAkB;EAC7C,MAAM,QAAQ,OAAO,OAAO,UAAU,WAAW,OAAO,UAAU,CAAC;EAEnE,OAAO,MAAM,MAAM,OAAO,GAAG;EAC7B,OAAO,UAAU,OAAO,YAAY;EACpC,OAAO,MAAM,MAAM,OAAO,GAAG;EAC7B,OAAO,UAAU,OAAO,YAAY;EAEpC,aAAO,MAAM,WAAW,CAAC,CAAC,CAAC,cAAc;EAEzC,OAAO,UAAU,OAAO,UAAU;EAClC,aAAO,OAAO,MAAM,KAAK,KAAK,CAAC,CAAC,CAAC,QAAQ,CAAC,KAAK,GAAG,CAAC;CACrD,CAAC,CACH;CAEA,GAAG,OAAO,qDACR,OAAO,IAAI,aAAa;EACtB,MAAM,QAAQ,OAAO,MAAM,UAAkB;EAC7C,MAAM,QAAQ,OAAO,OAAO,UAAU,WAAW,OAAO,UAAU,CAAC;EAEnE,OAAO,UAAU,OAAO,QAAQ;EAChC,aAAO,MAAM,WAAW,CAAC,CAAC,CAAC,cAAc;EAEzC,OAAO,MAAM,MAAM,OAAO,MAAM;EAChC,OAAO,UAAU,OAAO,UAAU;EAClC,aAAO,OAAO,MAAM,KAAK,KAAK,CAAC,CAAC,CAAC,QAAQ,CAAC,MAAM,CAAC;CACnD,CAAC,CACH;CAEA,GAAG,OAAO,+EACR,OAAO,IAAI,aAAa;EACtB,MAAM,QAAQ,OAAO,MAAM,UAAkB;EAC7C,MAAM,QAAQ,OAAO,OAAO,UAAU,WAAW,OAAO,UAAU,CAAC;EAEnE,OAAO,MAAM,MAAM,OAAO,OAAO;EACjC,OAAO,UAAU,OAAO,UAAU;EAClC,aAAO,OAAO,MAAM,KAAK,KAAK,CAAC,CAAC,CAAC,QAAQ,CAAC,OAAO,CAAC;EAElD,OAAO,MAAM,MAAM,OAAO,QAAQ;EAClC,aAAO,OAAO,MAAM,KAAK,KAAK,CAAC,CAAC,CAAC,QAAQ,OAAO,KAAK,QAAQ,CAAC;CAChE,CAAC,CACH;AACF,CAAC"}
|
package/src/inbox/Inbox.test.ts
DELETED
|
@@ -1,52 +0,0 @@
|
|
|
1
|
-
import { describe, it } from "@effect/vitest"
|
|
2
|
-
import { Effect, Fiber, Option, Queue } from "effect"
|
|
3
|
-
import { TestClock } from "effect/testing"
|
|
4
|
-
import { expect } from "vitest"
|
|
5
|
-
import { drainBurst } from "./Inbox.js"
|
|
6
|
-
|
|
7
|
-
describe("drainBurst", () => {
|
|
8
|
-
it.effect("resets the window on every arrival, so a burst outlives one settle", () =>
|
|
9
|
-
Effect.gen(function* () {
|
|
10
|
-
const queue = yield* Queue.unbounded<string>()
|
|
11
|
-
const burst = yield* Effect.forkChild(drainBurst(queue, "1 second"))
|
|
12
|
-
|
|
13
|
-
yield* Queue.offer(queue, "a")
|
|
14
|
-
yield* TestClock.adjust("900 millis")
|
|
15
|
-
yield* Queue.offer(queue, "b")
|
|
16
|
-
yield* TestClock.adjust("900 millis")
|
|
17
|
-
// 1.8s elapsed, more than one settle, and the burst is still open.
|
|
18
|
-
expect(burst.pollUnsafe()).toBeUndefined()
|
|
19
|
-
|
|
20
|
-
yield* TestClock.adjust("1 second")
|
|
21
|
-
expect(yield* Fiber.join(burst)).toEqual(["a", "b"])
|
|
22
|
-
}),
|
|
23
|
-
)
|
|
24
|
-
|
|
25
|
-
it.effect("waits for the first item without a deadline", () =>
|
|
26
|
-
Effect.gen(function* () {
|
|
27
|
-
const queue = yield* Queue.unbounded<string>()
|
|
28
|
-
const burst = yield* Effect.forkChild(drainBurst(queue, "1 second"))
|
|
29
|
-
|
|
30
|
-
yield* TestClock.adjust("1 hour")
|
|
31
|
-
expect(burst.pollUnsafe()).toBeUndefined()
|
|
32
|
-
|
|
33
|
-
yield* Queue.offer(queue, "late")
|
|
34
|
-
yield* TestClock.adjust("1 second")
|
|
35
|
-
expect(yield* Fiber.join(burst)).toEqual(["late"])
|
|
36
|
-
}),
|
|
37
|
-
)
|
|
38
|
-
|
|
39
|
-
it.effect("ends on the first quiet gap and leaves later items for the next drain", () =>
|
|
40
|
-
Effect.gen(function* () {
|
|
41
|
-
const queue = yield* Queue.unbounded<string>()
|
|
42
|
-
const burst = yield* Effect.forkChild(drainBurst(queue, "1 second"))
|
|
43
|
-
|
|
44
|
-
yield* Queue.offer(queue, "first")
|
|
45
|
-
yield* TestClock.adjust("1 second")
|
|
46
|
-
expect(yield* Fiber.join(burst)).toEqual(["first"])
|
|
47
|
-
|
|
48
|
-
yield* Queue.offer(queue, "second")
|
|
49
|
-
expect(yield* Queue.poll(queue)).toEqual(Option.some("second"))
|
|
50
|
-
}),
|
|
51
|
-
)
|
|
52
|
-
})
|
package/src/inbox/Inbox.ts
DELETED
|
@@ -1,33 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Input a long-lived agent reads between turns. A chat user rarely sends one
|
|
3
|
-
* tidy message: they type three lines in a row, then go quiet. The loop wants
|
|
4
|
-
* that as one batch, taken at a clean turn boundary.
|
|
5
|
-
*/
|
|
6
|
-
import { Array as Arr, type Duration, Effect, Option, Queue } from "effect"
|
|
7
|
-
|
|
8
|
-
/**
|
|
9
|
-
* Block for the first item, then keep taking while the next one lands within
|
|
10
|
-
* `settle` of the previous. The window resets on every arrival, so a burst
|
|
11
|
-
* becomes one batch and a lone message followed by silence returns at once.
|
|
12
|
-
*
|
|
13
|
-
* A take that loses to the window is interrupted before it removes anything,
|
|
14
|
-
* so late arrivals stay queued for the next drain.
|
|
15
|
-
*/
|
|
16
|
-
export const drainBurst = <A, E>(
|
|
17
|
-
queue: Queue.Dequeue<A, E>,
|
|
18
|
-
settle: Duration.Input,
|
|
19
|
-
): Effect.Effect<Arr.NonEmptyReadonlyArray<A>, E> => {
|
|
20
|
-
const settled = (
|
|
21
|
-
batch: Arr.NonEmptyReadonlyArray<A>,
|
|
22
|
-
): Effect.Effect<Arr.NonEmptyReadonlyArray<A>, E> =>
|
|
23
|
-
Queue.take(queue).pipe(
|
|
24
|
-
Effect.timeoutOption(settle),
|
|
25
|
-
Effect.flatMap(
|
|
26
|
-
Option.match({
|
|
27
|
-
onNone: () => Effect.succeed(batch),
|
|
28
|
-
onSome: (item) => settled(Arr.append(batch, item)),
|
|
29
|
-
}),
|
|
30
|
-
),
|
|
31
|
-
)
|
|
32
|
-
return Effect.flatMap(Queue.take(queue), (first) => settled([first]))
|
|
33
|
-
}
|
|
File without changes
|