@wibly/sdk 0.1.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/src/client.ts ADDED
@@ -0,0 +1,650 @@
1
+ /**
2
+ * The Studio SDK's `createSession` factory.
3
+ *
4
+ * Per chunk B7 build: "client.ts — `createSession({ wsUrl,
5
+ * sessionId, auth, onConsentRequired })` returns a `Session` with
6
+ * the typed API used by the Player shell (B6) and any future Studio
7
+ * SDK consumer."
8
+ *
9
+ * The factory composes the dependencies — transport, store,
10
+ * event bus, lifecycle bindings — into a single `Session` value and
11
+ * is the only place anything in the SDK constructs a transport or a
12
+ * store. Consumers never see the internals; they get a documented
13
+ * surface.
14
+ *
15
+ * **Single store per session.** The chunk-B7 trap is "do not let the
16
+ * AI inline state stores per call site". The factory enforces it:
17
+ * the returned `Session.store` is a getter that reads from the
18
+ * factory-owned store. React hooks (in `react.ts`) read from the
19
+ * same store via `useSyncExternalStore`.
20
+ *
21
+ * **Auth pass-through.** The `auth` field is the bearer token the
22
+ * Runtime expects on the WebSocket subprotocol (chunk B8a). It is
23
+ * threaded into the WebSocket URL's `Sec-WebSocket-Protocol` header
24
+ * the way `apps-shells/player-web` already does. The SDK never
25
+ * logs it.
26
+ *
27
+ * **Consent callback.** When the Runtime emits a `consent_required`
28
+ * event, the SDK calls the registered callback and (on
29
+ * `accepted`) calls `session.requestConsent(...)` to forward the
30
+ * grant to the User Portal endpoint (chunk B17a).
31
+ */
32
+
33
+ import { ok, type Result } from '@wibly/internal-shared';
34
+ import type {
35
+ ErrorPayload,
36
+ EventPayload,
37
+ LifecyclePayload,
38
+ MessageId,
39
+ ServerMessage,
40
+ SessionId,
41
+ StateDiffPayload,
42
+ } from '@wibly/internal-protocol';
43
+
44
+ import {
45
+ CONSENT_REQUIRED_EVENT_TYPE,
46
+ type ConsentDecision,
47
+ type ConsentRequiredCallback,
48
+ type ConsentRequiredPayload,
49
+ } from './consent.js';
50
+ import { createEventBus, type EventBus } from './events.js';
51
+ import {
52
+ createLifecycleBindings,
53
+ type LifecycleBindings,
54
+ } from './lifecycle.js';
55
+ import { createSessionStore, type SessionStore } from './store.js';
56
+ import {
57
+ buildEmitPayload,
58
+ buildSubmitPayload,
59
+ type EmitArgs,
60
+ type PendingSubmit,
61
+ type SubmitArgs,
62
+ } from './submit.js';
63
+ import { buildHostEmitPayload, HOST_EVENT_TYPES } from './control.js';
64
+ import {
65
+ buildInferenceRequest,
66
+ type InferenceCallInput,
67
+ type SessionInference,
68
+ INFERENCE_EVENT_PREFIX,
69
+ } from './inference.js';
70
+ import {
71
+ type SessionVoice,
72
+ type SpeakSuccess,
73
+ VOICE_EVENT_PREFIX,
74
+ } from './voice.js';
75
+ import {
76
+ createServerTimeSource,
77
+ type ServerTimeSource,
78
+ } from './time.js';
79
+ import {
80
+ createTransport,
81
+ type Transport,
82
+ type TransportConfig,
83
+ type WebSocketFactory,
84
+ } from './transport.js';
85
+ import { registerProjection } from './predictive.js';
86
+ import { formatSdkError, type SdkError } from './errors.js';
87
+ import type { ZodTypeAny } from 'zod';
88
+ import type { ConnectionState, OptimisticProjection } from './store.js';
89
+ import type {
90
+ InferenceCallSuccess,
91
+ } from './inference.js';
92
+
93
+ export type SessionConfig = {
94
+ readonly wsUrl: string;
95
+ readonly sessionId: SessionId;
96
+ readonly auth?: string;
97
+ readonly onConsentRequired?: ConsentRequiredCallback;
98
+ /** Test seam — pass a fake WebSocket constructor. */
99
+ readonly factory?: WebSocketFactory;
100
+ /** Test seam for transport timing. */
101
+ readonly schedule?: TransportConfig['schedule'];
102
+ /** Test seam for `Math.random` (used in backoff jitter). */
103
+ readonly random?: () => number;
104
+ /** Test seam for `Date.now`. */
105
+ readonly now?: () => number;
106
+ /**
107
+ * Submit confirmation timeout. Default 30s. See `submit.ts`.
108
+ */
109
+ readonly submitTimeoutMs?: number;
110
+ /**
111
+ * Persistence endpoint for accepted consents. When the player
112
+ * accepts a consent prompt, the SDK invokes `persistConsent` to
113
+ * write the grant. Chunk B17a wires the real implementation
114
+ * against the User Portal; MVP leaves it `undefined` and
115
+ * `requestConsent` returns `consent_persistence_not_wired`.
116
+ */
117
+ readonly persistConsent?: (
118
+ payload: ConsentRequiredPayload,
119
+ ) => Promise<Result<void, SdkError>>;
120
+ };
121
+
122
+ export type SessionState = {
123
+ readonly connectionState: ConnectionState;
124
+ /** Authoritative server projection. */
125
+ readonly state: unknown;
126
+ /** Projected state — server + active optimistic projections. */
127
+ readonly projectedState: unknown;
128
+ /** Last applied server seq. */
129
+ readonly appliedSeq: number;
130
+ /** Current phase id from the most recent snapshot. */
131
+ readonly phaseId: string | null;
132
+ };
133
+
134
+ export type Session = {
135
+ readonly sessionId: SessionId;
136
+ /** Current snapshot — calling getter triggers no work. */
137
+ readonly getState: () => SessionState;
138
+ /** Reactive subscription. The callback fires on store changes. */
139
+ readonly subscribe: (listener: () => void) => () => void;
140
+ /** Submit an Active input. */
141
+ readonly submit: (args: SubmitArgs) => PendingSubmit;
142
+ /** Emit a non-Active wire event. */
143
+ readonly emit: (args: EmitArgs) => Result<{ readonly id: string }, SdkError>;
144
+ /** Host-only control verbs. The Runtime gates host privilege. */
145
+ readonly host: {
146
+ readonly pause: () => Result<{ readonly id: string }, SdkError>;
147
+ readonly resume: () => Result<{ readonly id: string }, SdkError>;
148
+ readonly advancePhase: (
149
+ detail?: unknown,
150
+ ) => Result<{ readonly id: string }, SdkError>;
151
+ readonly reclaim: () => Result<{ readonly id: string }, SdkError>;
152
+ };
153
+ readonly inference: SessionInference;
154
+ readonly voice: SessionVoice;
155
+ readonly events: Pick<EventBus, 'onEvent' | 'onAnyEvent'>;
156
+ readonly lifecycle: LifecycleBindings;
157
+ /** Server-time helper. See `time.ts`. */
158
+ readonly time: Pick<ServerTimeSource, 'serverNow' | 'recordEvent'>;
159
+ /** Internal — the live store; exported for `react.ts`. */
160
+ readonly store: SessionStore;
161
+ /**
162
+ * Forward an accepted consent decision to the persistence path.
163
+ * Returns `consent_persistence_not_wired` until chunk B17a binds
164
+ * the User Portal endpoint.
165
+ */
166
+ readonly requestConsent: (
167
+ payload: ConsentRequiredPayload,
168
+ ) => Promise<Result<void, SdkError>>;
169
+ /** Close the underlying WebSocket and dispose subscriptions. */
170
+ readonly close: () => void;
171
+ };
172
+
173
+ type PendingRecord = {
174
+ readonly id: MessageId;
175
+ readonly resolve: (r: Result<void, SdkError>) => void;
176
+ readonly projectionId?: string;
177
+ readonly timer?: () => void;
178
+ };
179
+
180
+ export const createSession = (config: SessionConfig): Session => {
181
+ const store = createSessionStore();
182
+ const bus = createEventBus();
183
+ const time = createServerTimeSource({ now: config.now });
184
+ const lifecycle = createLifecycleBindings(bus);
185
+ const pendingSubmits = new Map<MessageId, PendingRecord>();
186
+ const pendingInference = new Map<
187
+ MessageId,
188
+ (r: Result<unknown, SdkError>) => void
189
+ >();
190
+ const pendingVoice = new Map<
191
+ MessageId,
192
+ (r: Result<unknown, SdkError>) => void
193
+ >();
194
+
195
+ const handleStateDiff = (payload: StateDiffPayload): void => {
196
+ const action = store.applyDiff(
197
+ payload.patches,
198
+ payload.fromSeq,
199
+ payload.toSeq,
200
+ );
201
+ if (action === 'resync_required') {
202
+ // The seq window slipped — ask the Runtime for a fresh
203
+ // snapshot. The transport's `resubscribe` reuses the current
204
+ // resumeFromSeq; the server falls back to `snapshot` when it
205
+ // can't satisfy the resume from its buffer.
206
+ transport.resubscribe();
207
+ }
208
+ };
209
+
210
+ const handleEvent = (payload: EventPayload): void => {
211
+ bus.dispatchEvent(payload);
212
+ if (payload.eventType.startsWith(INFERENCE_EVENT_PREFIX)) {
213
+ maybeResolveInference(payload);
214
+ } else if (payload.eventType.startsWith(VOICE_EVENT_PREFIX)) {
215
+ maybeResolveVoice(payload);
216
+ } else if (payload.eventType === CONSENT_REQUIRED_EVENT_TYPE) {
217
+ maybeHandleConsent(payload);
218
+ }
219
+ };
220
+
221
+ const handleLifecycle = (payload: LifecyclePayload): void => {
222
+ bus.dispatchLifecycle(payload);
223
+ };
224
+
225
+ const handleError = (payload: ErrorPayload): void => {
226
+ if (payload.causeMessageId === undefined) return;
227
+ const causeId = payload.causeMessageId as unknown as MessageId;
228
+ const submit = pendingSubmits.get(causeId);
229
+ if (submit !== undefined) {
230
+ if (submit.projectionId !== undefined) {
231
+ store.removeProjection(submit.projectionId);
232
+ }
233
+ submit.timer?.();
234
+ pendingSubmits.delete(causeId);
235
+ submit.resolve({
236
+ ok: false,
237
+ error: {
238
+ kind: 'submit_rejected',
239
+ code: payload.code,
240
+ message: payload.message,
241
+ causeMessageId: causeId,
242
+ },
243
+ });
244
+ transport.confirmSend(causeId);
245
+ return;
246
+ }
247
+ const infer = pendingInference.get(causeId);
248
+ if (infer !== undefined) {
249
+ pendingInference.delete(causeId);
250
+ transport.confirmSend(causeId);
251
+ infer({
252
+ ok: false,
253
+ error: {
254
+ kind: 'submit_rejected',
255
+ code: payload.code,
256
+ message: payload.message,
257
+ causeMessageId: causeId,
258
+ },
259
+ });
260
+ return;
261
+ }
262
+ const voice = pendingVoice.get(causeId);
263
+ if (voice !== undefined) {
264
+ pendingVoice.delete(causeId);
265
+ transport.confirmSend(causeId);
266
+ voice({
267
+ ok: false,
268
+ error: {
269
+ kind: 'submit_rejected',
270
+ code: payload.code,
271
+ message: payload.message,
272
+ causeMessageId: causeId,
273
+ },
274
+ });
275
+ }
276
+ };
277
+
278
+ const transport: Transport = createTransport(
279
+ {
280
+ url: buildUrl(config),
281
+ sessionId: config.sessionId,
282
+ ...(config.factory ? { factory: config.factory } : {}),
283
+ ...(config.schedule ? { schedule: config.schedule } : {}),
284
+ ...(config.random ? { random: config.random } : {}),
285
+ ...(config.now ? { now: config.now } : {}),
286
+ },
287
+ {
288
+ onServerMessage: (msg: ServerMessage) => {
289
+ switch (msg.kind) {
290
+ case 'snapshot':
291
+ store.applySnapshot(
292
+ msg.payload.state,
293
+ msg.payload.phaseId,
294
+ msg.payload.baseSeq,
295
+ );
296
+ break;
297
+ case 'state_diff':
298
+ handleStateDiff(msg.payload);
299
+ // A state_diff implicitly confirms every pending submit
300
+ // whose id <= toSeq's caused-by set (which we can't see
301
+ // from this side). For MVP we resolve the *oldest*
302
+ // pending submit on each diff; the chunk B8a Runtime
303
+ // emits a dedicated `submit.confirmed` event on the same
304
+ // wire kind that supersedes this heuristic once it ships.
305
+ confirmOldestPendingSubmit();
306
+ break;
307
+ case 'event':
308
+ handleEvent(msg.payload);
309
+ break;
310
+ case 'lifecycle':
311
+ handleLifecycle(msg.payload);
312
+ break;
313
+ case 'error':
314
+ handleError(msg.payload);
315
+ break;
316
+ case 'pong': {
317
+ const echo = msg.payload.echoTs;
318
+ const now = (config.now ?? Date.now)();
319
+ // Clock skew estimate: midpoint of round trip.
320
+ time.updateSkew(Math.round(msg.ts - (now + echo) / 2));
321
+ break;
322
+ }
323
+ }
324
+ },
325
+ onDecodeError: () => {
326
+ // Drop silently. The Runtime emits well-formed frames; a
327
+ // decode failure here is either a corrupt buffer or a
328
+ // forward-compat artifact. Either way the SDK can't
329
+ // recover from a malformed frame.
330
+ },
331
+ onConnectionState: (next) => {
332
+ store.setConnectionState(next);
333
+ },
334
+ onReconnectReady: () => {
335
+ // The transport's onopen handler already sent the subscribe.
336
+ // Hook reserved for future bootstrap; intentionally empty.
337
+ },
338
+ getResumeFromSeq: () => {
339
+ const snap = store.getSnapshot();
340
+ return snap.appliedSeq > 0 ? snap.appliedSeq : undefined;
341
+ },
342
+ },
343
+ );
344
+
345
+ const confirmOldestPendingSubmit = (): void => {
346
+ const next = pendingSubmits.entries().next();
347
+ if (next.done) return;
348
+ const [id, record] = next.value;
349
+ if (record.projectionId !== undefined) {
350
+ store.removeProjection(record.projectionId);
351
+ }
352
+ record.timer?.();
353
+ pendingSubmits.delete(id);
354
+ transport.confirmSend(id);
355
+ record.resolve(ok(undefined));
356
+ };
357
+
358
+ const maybeResolveInference = (payload: EventPayload): void => {
359
+ const data = payload.data as { causeMessageId?: string } | null;
360
+ if (!data || typeof data.causeMessageId !== 'string') return;
361
+ const causeId = data.causeMessageId as unknown as MessageId;
362
+ const cb = pendingInference.get(causeId);
363
+ if (cb === undefined) return;
364
+ pendingInference.delete(causeId);
365
+ transport.confirmSend(causeId);
366
+ cb(ok(payload.data));
367
+ };
368
+
369
+ const maybeResolveVoice = (payload: EventPayload): void => {
370
+ const data = payload.data as { causeMessageId?: string } | null;
371
+ if (!data || typeof data.causeMessageId !== 'string') return;
372
+ const causeId = data.causeMessageId as unknown as MessageId;
373
+ const cb = pendingVoice.get(causeId);
374
+ if (cb === undefined) return;
375
+ pendingVoice.delete(causeId);
376
+ transport.confirmSend(causeId);
377
+ cb(ok(payload.data));
378
+ };
379
+
380
+ const maybeHandleConsent = (payload: EventPayload): void => {
381
+ if (config.onConsentRequired === undefined) return;
382
+ const data = payload.data as ConsentRequiredPayload | null;
383
+ if (!data || typeof data !== 'object') return;
384
+ void (async () => {
385
+ let decision: ConsentDecision = 'declined';
386
+ try {
387
+ decision = await config.onConsentRequired!(data);
388
+ } catch {
389
+ decision = 'declined';
390
+ }
391
+ if (decision !== 'accepted') return;
392
+ const result = await session.requestConsent(data);
393
+ // The bridge informs the runtime of the outcome via an
394
+ // `emit('consent.decision', …)`. The Runtime forwards the
395
+ // grant to the Persona Service (chunk B3) on its next read.
396
+ transport.send(
397
+ 'emit',
398
+ buildEmitPayload(config.sessionId, {
399
+ eventType: 'consent.decision',
400
+ data: {
401
+ personaId: data.personaId,
402
+ scope: data.scope,
403
+ decision,
404
+ persistOk: result.ok,
405
+ persistError: result.ok ? null : result.error.kind,
406
+ },
407
+ }),
408
+ );
409
+ })();
410
+ };
411
+
412
+ const session: Session = {
413
+ sessionId: config.sessionId,
414
+ getState: () => {
415
+ const snap = store.getSnapshot();
416
+ return {
417
+ connectionState: snap.connectionState,
418
+ state: snap.state,
419
+ projectedState: store.getProjectedState(),
420
+ appliedSeq: snap.appliedSeq,
421
+ phaseId: snap.phaseId,
422
+ };
423
+ },
424
+ subscribe: (listener) => store.subscribe(listener),
425
+ submit: (args) => {
426
+ const payload = buildSubmitPayload(config.sessionId, args);
427
+ const sent = transport.send('submit', payload);
428
+ const id = sent.id;
429
+ let projectionId: string | undefined;
430
+ if (args.predictive !== undefined) {
431
+ projectionId = id as unknown as string;
432
+ registerProjection(
433
+ store,
434
+ projectionId,
435
+ args.predictive as OptimisticProjection,
436
+ );
437
+ }
438
+ let resolveFn!: (r: Result<void, SdkError>) => void;
439
+ const promise = new Promise<Result<void, SdkError>>((res) => {
440
+ resolveFn = res;
441
+ });
442
+ const timeoutMs = args.timeoutMs ?? config.submitTimeoutMs ?? 30_000;
443
+ const timer = scheduleTimeout(timeoutMs, () => {
444
+ if (!pendingSubmits.has(id)) return;
445
+ const record = pendingSubmits.get(id);
446
+ pendingSubmits.delete(id);
447
+ if (record?.projectionId !== undefined) {
448
+ store.removeProjection(record.projectionId);
449
+ }
450
+ resolveFn({ ok: false, error: { kind: 'timeout' } });
451
+ });
452
+ pendingSubmits.set(id, {
453
+ id,
454
+ resolve: resolveFn,
455
+ ...(projectionId ? { projectionId } : {}),
456
+ timer,
457
+ });
458
+ return {
459
+ id: id as unknown as string,
460
+ promise,
461
+ cancel: () => {
462
+ const record = pendingSubmits.get(id);
463
+ if (record === undefined) return;
464
+ pendingSubmits.delete(id);
465
+ if (record.projectionId !== undefined) {
466
+ store.removeProjection(record.projectionId);
467
+ }
468
+ record.timer?.();
469
+ resolveFn({ ok: false, error: { kind: 'timeout' } });
470
+ },
471
+ };
472
+ },
473
+ emit: (args) => {
474
+ const payload = buildEmitPayload(config.sessionId, args);
475
+ const sent = transport.send('emit', payload);
476
+ return ok({ id: sent.id as unknown as string });
477
+ },
478
+ host: {
479
+ pause: () => {
480
+ const payload = buildHostEmitPayload(
481
+ config.sessionId,
482
+ HOST_EVENT_TYPES.pause,
483
+ );
484
+ const sent = transport.send('emit', payload);
485
+ return ok({ id: sent.id as unknown as string });
486
+ },
487
+ resume: () => {
488
+ const payload = buildHostEmitPayload(
489
+ config.sessionId,
490
+ HOST_EVENT_TYPES.resume,
491
+ );
492
+ const sent = transport.send('emit', payload);
493
+ return ok({ id: sent.id as unknown as string });
494
+ },
495
+ advancePhase: (detail?: unknown) => {
496
+ const payload = buildHostEmitPayload(
497
+ config.sessionId,
498
+ HOST_EVENT_TYPES.advancePhase,
499
+ detail ?? {},
500
+ );
501
+ const sent = transport.send('emit', payload);
502
+ return ok({ id: sent.id as unknown as string });
503
+ },
504
+ reclaim: () => {
505
+ const payload = buildHostEmitPayload(
506
+ config.sessionId,
507
+ HOST_EVENT_TYPES.reclaim,
508
+ );
509
+ const sent = transport.send('emit', payload);
510
+ return ok({ id: sent.id as unknown as string });
511
+ },
512
+ },
513
+ inference: buildInferenceVerbs(
514
+ config.sessionId,
515
+ transport,
516
+ pendingInference,
517
+ ),
518
+ voice: buildVoiceVerbs(config.sessionId, transport, pendingVoice),
519
+ events: { onEvent: bus.onEvent, onAnyEvent: bus.onAnyEvent },
520
+ lifecycle,
521
+ time: { serverNow: time.serverNow, recordEvent: time.recordEvent },
522
+ store,
523
+ requestConsent: async (payload) => {
524
+ if (config.persistConsent === undefined) {
525
+ return { ok: false, error: { kind: 'consent_persistence_not_wired' } };
526
+ }
527
+ return config.persistConsent(payload);
528
+ },
529
+ close: () => {
530
+ transport.close(1000, 'sdk close');
531
+ for (const [, record] of pendingSubmits) {
532
+ record.timer?.();
533
+ record.resolve({ ok: false, error: { kind: 'not_connected' } });
534
+ }
535
+ pendingSubmits.clear();
536
+ for (const [, cb] of pendingInference) {
537
+ cb({ ok: false, error: { kind: 'not_connected' } });
538
+ }
539
+ pendingInference.clear();
540
+ for (const [, cb] of pendingVoice) {
541
+ cb({ ok: false, error: { kind: 'not_connected' } });
542
+ }
543
+ pendingVoice.clear();
544
+ bus.dispose();
545
+ },
546
+ };
547
+
548
+ transport.start();
549
+ return session;
550
+ };
551
+
552
+ /**
553
+ * Build a wire-form URL with the bearer token threaded onto the
554
+ * query string. The Runtime accepts the token via either
555
+ * `Sec-WebSocket-Protocol` or `?token=` for browser compatibility
556
+ * (browsers can't set arbitrary request headers on `new WebSocket`).
557
+ *
558
+ * The token is excluded from any URL the SDK logs — `formatSdkError`
559
+ * never includes it. Operators should still treat the URL as
560
+ * sensitive.
561
+ */
562
+ const buildUrl = (config: SessionConfig): string => {
563
+ if (config.auth === undefined) return config.wsUrl;
564
+ const sep = config.wsUrl.includes('?') ? '&' : '?';
565
+ return `${config.wsUrl}${sep}token=${encodeURIComponent(config.auth)}`;
566
+ };
567
+
568
+ const scheduleTimeout = (
569
+ ms: number,
570
+ cb: () => void,
571
+ ): (() => void) => {
572
+ const handle = setTimeout(cb, ms);
573
+ return () => clearTimeout(handle);
574
+ };
575
+
576
+ const buildInferenceVerbs = (
577
+ sessionId: SessionId,
578
+ transport: Transport,
579
+ pending: Map<MessageId, (r: Result<unknown, SdkError>) => void>,
580
+ ): SessionInference => {
581
+ const call = async <TOutput extends ZodTypeAny>(
582
+ input: InferenceCallInput<TOutput>,
583
+ ): Promise<Result<InferenceCallSuccess<TOutput>, SdkError>> => {
584
+ const serialised = buildInferenceRequest(input);
585
+ let resolveFn!: (r: Result<unknown, SdkError>) => void;
586
+ const promise = new Promise<Result<unknown, SdkError>>((res) => {
587
+ resolveFn = res;
588
+ });
589
+ const sent = transport.send('emit', {
590
+ sessionId,
591
+ eventType: `${INFERENCE_EVENT_PREFIX}${input.callKind}`,
592
+ data: serialised,
593
+ });
594
+ pending.set(sent.id, resolveFn);
595
+ // Until chunk B8a wires the runtime, the call never resolves
596
+ // organically. Emit a runtime_not_wired error after a tick so
597
+ // the surface is observable without flapping.
598
+ setTimeout(() => {
599
+ const cb = pending.get(sent.id);
600
+ if (cb === undefined) return;
601
+ pending.delete(sent.id);
602
+ transport.confirmSend(sent.id);
603
+ cb({ ok: false, error: { kind: 'runtime_not_wired' } });
604
+ }, 0);
605
+ const result = await promise;
606
+ return result as Result<InferenceCallSuccess<TOutput>, SdkError>;
607
+ };
608
+ return {
609
+ call,
610
+ host: (input) => call({ ...input, callKind: 'host_open_phase' }),
611
+ judge: (input) => call({ ...input, callKind: 'judge_submissions' }),
612
+ classify: (input) => call({ ...input, callKind: 'classify' }),
613
+ };
614
+ };
615
+
616
+ const buildVoiceVerbs = (
617
+ sessionId: SessionId,
618
+ transport: Transport,
619
+ pending: Map<MessageId, (r: Result<unknown, SdkError>) => void>,
620
+ ): SessionVoice => ({
621
+ speak: async (input) => {
622
+ let resolveFn!: (r: Result<unknown, SdkError>) => void;
623
+ const promise = new Promise<Result<unknown, SdkError>>((res) => {
624
+ resolveFn = res;
625
+ });
626
+ const sent = transport.send('emit', {
627
+ sessionId,
628
+ eventType: `${VOICE_EVENT_PREFIX}speak`,
629
+ data: {
630
+ personaId: input.personaId,
631
+ text: input.text,
632
+ options: input.options ?? {},
633
+ caption: input.caption === null ? null : (input.caption ?? input.text),
634
+ },
635
+ });
636
+ pending.set(sent.id, resolveFn);
637
+ setTimeout(() => {
638
+ const cb = pending.get(sent.id);
639
+ if (cb === undefined) return;
640
+ pending.delete(sent.id);
641
+ transport.confirmSend(sent.id);
642
+ cb({ ok: false, error: { kind: 'runtime_not_wired' } });
643
+ }, 0);
644
+ const result = await promise;
645
+ return result as Result<SpeakSuccess, SdkError>;
646
+ },
647
+ });
648
+
649
+ // Re-export for consumers that want to format SdkError directly.
650
+ export { formatSdkError };