@alexkroman1/aai-ui 5.14.0 → 6.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/README.md +2 -1
  2. package/dist/_repeat-until.d.ts +30 -0
  3. package/dist/_sse.d.ts +56 -0
  4. package/dist/_workflow-api-ref.d.ts +37 -0
  5. package/dist/audio.js +26 -26
  6. package/dist/{chat-view-CgFytvGy.js → chat-view-CK61bWWx.js} +2 -1
  7. package/dist/components/_form-values.d.ts +19 -0
  8. package/dist/components/chat-view.js +1 -1
  9. package/dist/components/form-types.d.ts +67 -0
  10. package/dist/components/form.d.ts +138 -0
  11. package/dist/components/message-list.js +1 -1
  12. package/dist/components/workflow-fields.d.ts +57 -0
  13. package/dist/components/workflow-progress.d.ts +55 -0
  14. package/dist/default-client/assets/audio-fO7SVU64.js +1 -0
  15. package/dist/default-client/assets/{capture-processor-B_5Ive8e.js → capture-processor-Dmc-KEpb.js} +4 -4
  16. package/dist/default-client/assets/client-audio-constants-Ck0IJO4c.js +1 -0
  17. package/dist/default-client/assets/index-CDugAuLK.css +2 -0
  18. package/dist/default-client/assets/index-DCI51Xz_.js +293 -0
  19. package/dist/default-client/assets/{playback-processor-6L8SIQ_l.js → playback-processor-DwQ9tE7X.js} +16 -13
  20. package/dist/default-client/index.html +3 -2
  21. package/dist/define-client.d.ts +40 -1
  22. package/dist/define-client.js +59 -17
  23. package/dist/index.d.ts +10 -0
  24. package/dist/index.js +1595 -5
  25. package/dist/{message-list-CcjgWRVZ.js → message-list-BwA3rdPi.js} +15 -1
  26. package/dist/page.d.ts +88 -0
  27. package/dist/{session-core-BA8H3qtF.js → session-core-ClKdVgRU.js} +245 -112
  28. package/dist/session-core-dial.d.ts +38 -0
  29. package/dist/session-core-handshake.d.ts +16 -1
  30. package/dist/session-core-messages.d.ts +2 -2
  31. package/dist/session-core-reconnect.d.ts +2 -7
  32. package/dist/session-core.js +1 -1
  33. package/dist/session-resume-store.d.ts +43 -0
  34. package/dist/types.d.ts +1 -1
  35. package/dist/types.js +2 -2
  36. package/dist/use-user-transcript.d.ts +70 -0
  37. package/dist/use-workflow-form.d.ts +136 -0
  38. package/dist/use-workflow-progress.d.ts +100 -0
  39. package/dist/use-workflow-run.d.ts +56 -0
  40. package/dist/use-workflow-runs.d.ts +71 -0
  41. package/dist/workflow-client.d.ts +97 -0
  42. package/dist/workflow-events.d.ts +39 -0
  43. package/dist/worklets/_playback-bench-harness.d.ts +181 -0
  44. package/dist/worklets/_playback-bench-host.d.ts +63 -0
  45. package/dist/worklets/_playback-bench-page.d.ts +65 -0
  46. package/dist/worklets/_tts-trace-harness.d.ts +142 -0
  47. package/dist/worklets/_worklet-test-utils.d.ts +27 -0
  48. package/dist/worklets/playback-processor.d.ts +1 -1
  49. package/dist/worklets/playback-processor.js +15 -12
  50. package/package.json +9 -8
  51. package/dist/default-client/assets/audio-CsQVQn3f.js +0 -1
  52. package/dist/default-client/assets/index-D35_z2WM.js +0 -293
  53. package/dist/default-client/assets/index-DCjB3qtb.css +0 -2
@@ -261,11 +261,25 @@ const DOT_STYLES = [
261
261
  animation: "aai-bounce 1.4s infinite ease-in-out both",
262
262
  animationDelay: `${delay}s`
263
263
  }));
264
- /** Animated three-dot "thinking" indicator. @internal */
264
+ /**
265
+ * Animated three-dot "thinking" indicator.
266
+ *
267
+ * `role="status"` with a label, for the same reason `ConsoleShell` announces
268
+ * its error banner: three animated dots are the only signal that the agent is
269
+ * working on a reply, and to a screen reader they are three empty `<div>`s.
270
+ * It is also the indicator's semantic handle — a spec asserting its presence by
271
+ * counting `.rounded-full` elements breaks when the three dots become a spinner
272
+ * (correct behaviour, red test) and again when any sibling row gains a round
273
+ * badge (wrong behaviour, green test).
274
+ *
275
+ * @internal
276
+ */
265
277
  function ThinkingDots() {
266
278
  const theme = useTheme();
267
279
  const muted = inkTint(theme.text, theme.surface, 75);
268
280
  return /* @__PURE__ */ jsx("div", {
281
+ role: "status",
282
+ "aria-label": "Thinking",
269
283
  className: "flex items-center gap-2 text-sm font-medium min-h-5",
270
284
  style: { color: muted },
271
285
  children: DOT_STYLES.map((style, i) => /* @__PURE__ */ jsx("div", {
package/dist/page.d.ts ADDED
@@ -0,0 +1,88 @@
1
+ /** @jsxImportSource react */
2
+ /**
3
+ * `page()` — mount a WORKFLOW APP's UI: React, theme, no session.
4
+ *
5
+ * The twin of `client()` for an agent whose front door is a form rather than a
6
+ * microphone (`workflowApp()`). It is a separate entry rather than
7
+ * an option on `client()` because of what `client()` unavoidably does: it
8
+ * constructs a `SessionCore`, which owns a WebSocket URL provider, an audio
9
+ * graph, and a microphone request. A flag would have to make all of that
10
+ * conditional, and every session hook would then have to answer "what does this
11
+ * mean with no session?" — so the honest split is two mounts. A page that wants
12
+ * voice uses `client()`; a page that wants neither audio nor a socket uses this.
13
+ *
14
+ * Authoring is otherwise identical — the file is still `client.tsx`, still
15
+ * React, still Tailwind, still the same theme tokens — so a workflow app reads
16
+ * like every other agent. What it reaches for instead of `useSession()` is
17
+ * `createWorkflowApi()` / `useWorkflowRun()`.
18
+ */
19
+ import { type ComponentType } from "react";
20
+ import type { ClientTheme } from "./types.ts";
21
+ /**
22
+ * Configuration for {@link page}.
23
+ *
24
+ * @public
25
+ */
26
+ export type PageConfig = {
27
+ /**
28
+ * The root component. Required — a workflow app has no default shell to fall
29
+ * back to, because there is no session for one to render.
30
+ */
31
+ component: ComponentType;
32
+ /** CSS selector or DOM element to render into. Defaults to `"#app"`. */
33
+ target?: string | HTMLElement;
34
+ /**
35
+ * Page title. Set only when given, so a title the HTML shell declared is never
36
+ * clobbered — the same rule `client()`'s custom-component tier follows.
37
+ */
38
+ name?: string;
39
+ /** Theme color overrides, read by the same tokens the voice components use. */
40
+ theme?: ClientTheme;
41
+ };
42
+ /**
43
+ * Handle returned by {@link page}. `Disposable`, so `using` works.
44
+ *
45
+ * @public
46
+ */
47
+ export type PageHandle = {
48
+ /** Unmount the React tree. */
49
+ dispose(): void;
50
+ /** Alias for `dispose` for use with `using`. */
51
+ [Symbol.dispose](): void;
52
+ };
53
+ /**
54
+ * Mount a page for an agent whose work happens in workflows.
55
+ *
56
+ * There is deliberately no session, no microphone, and no socket: the component
57
+ * talks to the agent over the workflow HTTP API
58
+ * (`createWorkflowApi`/`useWorkflowRun`), which is durable and outlives the tab.
59
+ *
60
+ * @example
61
+ * ```tsx
62
+ * import { createWorkflowApi, page, useWorkflowRun } from "@alexkroman1/aai-ui";
63
+ * import { useState } from "react";
64
+ *
65
+ * // Hoisted: a client built in render is a new object every render.
66
+ * const api = createWorkflowApi();
67
+ *
68
+ * function App() {
69
+ * const [runId, setRunId] = useState<string>();
70
+ * const { run } = useWorkflowRun(runId, { api });
71
+ * return (
72
+ * <button
73
+ * type="button"
74
+ * onClick={() => void api.start("digest", { topic: "ai" }).then(setRunId)}
75
+ * >
76
+ * {run ? run.status : "Start"}
77
+ * </button>
78
+ * );
79
+ * }
80
+ *
81
+ * page({ name: "Digest", component: App });
82
+ * ```
83
+ *
84
+ * @throws If the target element is not found in the DOM.
85
+ *
86
+ * @public
87
+ */
88
+ export declare function page(config: PageConfig): PageHandle;
@@ -1,7 +1,8 @@
1
1
  import { MIC_SEND_MAX_BUFFERED_BYTES } from "./types.js";
2
2
  import { CLIENT_CONFIG_PATH, ClientConfigResponseSchema, ServerMessageSchema, lenientParse } from "@alexkroman1/aai/protocol";
3
- import { DEFAULT_MAX_HISTORY, WS_OPEN, errorMessage, safeJsonParse, toArgsRecord } from "@alexkroman1/aai";
4
- import { createEpoch } from "@alexkroman1/aai/internal";
3
+ import { DEFAULT_MAX_HISTORY, errorMessage, safeJsonParse } from "@alexkroman1/aai";
4
+ import { omitUndefined, toArgsRecord } from "@alexkroman1/aai/utils";
5
+ import { WS_OPEN, createEpoch } from "@alexkroman1/aai/internal";
5
6
  import ReconnectingWebSocket from "partysocket/ws";
6
7
  //#region client-config.ts
7
8
  /**
@@ -252,6 +253,180 @@ function reconnectPending(socket) {
252
253
  return socket instanceof ReconnectingWebSocket && socket.shouldReconnect && socket.retryCount < RECONNECT_OPTIONS.maxRetries;
253
254
  }
254
255
  //#endregion
256
+ //#region session-core-url.ts
257
+ /** Build the session WebSocket URL from the platform URL and resume state. */
258
+ function buildWsUrl(platformUrl, resume, sessionId) {
259
+ return applyResumeParams(buildAgentUrl(platformUrl, "websocket"), resume, sessionId);
260
+ }
261
+ /**
262
+ * Turn a broker-provided session URL (`sessionUrl` from `GET client-config`
263
+ * — the agent's live sandbox endpoint) into this attempt's connect URL.
264
+ */
265
+ function buildBrokeredWsUrl(sessionUrl, resume, sessionId) {
266
+ return applyResumeParams(new URL(sessionUrl), resume, sessionId);
267
+ }
268
+ const WS_PROTOCOLS = {
269
+ "https:": "wss:",
270
+ "http:": "ws:"
271
+ };
272
+ function applyResumeParams(wsUrl, resume, sessionId) {
273
+ wsUrl.protocol = WS_PROTOCOLS[wsUrl.protocol] ?? wsUrl.protocol;
274
+ if (sessionId) wsUrl.searchParams.set("sessionId", sessionId);
275
+ else if (resume) wsUrl.searchParams.set("resume", "1");
276
+ return wsUrl;
277
+ }
278
+ //#endregion
279
+ //#region session-resume-store.ts
280
+ /**
281
+ * Where a session id survives a page RELOAD.
282
+ *
283
+ * The id is what `?sessionId=` presents on reconnect, and it is the key the
284
+ * agent's slot state and event log live under — so a reload that cannot produce
285
+ * it starts a brand-new session, and a UI driven by `useAgentState` comes back
286
+ * empty even though the agent still holds the cart. The server side of the
287
+ * reconstitution was already built (`pushStateSnapshot` force-pushes the
288
+ * projection after hydration on every start, `state.updated` lands in
289
+ * `agentState`); what was missing is that nothing in the browser remembered the
290
+ * id across a reload. `onSessionId`/`resumeSessionId` let a client wire it by
291
+ * hand and exactly one of fourteen templates did, which is the shape of a
292
+ * default in the wrong place.
293
+ *
294
+ * **`sessionStorage`, deliberately, and this is the opposite call from the
295
+ * studio's session token.** A reload and a same-tab navigation survive it; a new
296
+ * tab and a visit tomorrow do not, which is what we want here rather than a
297
+ * limitation: presenting a day-old id suppresses the greeting
298
+ * (`parseWsUpgradeParams` keys that off the id's mere presence) and rejoins a
299
+ * conversation whose context is long gone. The studio token is a credential
300
+ * whose value is not being asked to sign out; this is a pointer into a live call.
301
+ *
302
+ * Keyed by the agent's own URL, so two agents served from one origin — which is
303
+ * every deployed agent, at `/:slug/` — cannot inherit each other's session.
304
+ *
305
+ * Every access is guarded: storage throws outright in some contexts (Safari
306
+ * private mode, storage blocked by policy), and a session that cannot be
307
+ * remembered must degrade to today's behaviour rather than failing to start.
308
+ */
309
+ const PREFIX = "aai:session:";
310
+ /** One agent's slot in storage. */
311
+ function keyFor(platformUrl) {
312
+ try {
313
+ return `${PREFIX}${new URL(platformUrl, globalThis.location?.href).href}`;
314
+ } catch {
315
+ return `${PREFIX}${platformUrl}`;
316
+ }
317
+ }
318
+ /** The stored session id for this agent, or undefined. @internal */
319
+ function readStoredSessionId(platformUrl) {
320
+ try {
321
+ return globalThis.sessionStorage?.getItem(keyFor(platformUrl)) ?? void 0;
322
+ } catch {
323
+ return;
324
+ }
325
+ }
326
+ /** Remember this agent's session id for the next load. @internal */
327
+ function writeStoredSessionId(platformUrl, sessionId) {
328
+ try {
329
+ globalThis.sessionStorage?.setItem(keyFor(platformUrl), sessionId);
330
+ } catch {}
331
+ }
332
+ /**
333
+ * Forget it, so the next load is a NEW session.
334
+ *
335
+ * Called from `end()`, which is the clear-and-forget the "New Conversation"
336
+ * button runs: leaving the id behind there would have the next load rejoin the
337
+ * conversation the user just discarded, greeting suppressed.
338
+ *
339
+ * @internal
340
+ */
341
+ function clearStoredSessionId(platformUrl) {
342
+ try {
343
+ globalThis.sessionStorage?.removeItem(keyFor(platformUrl));
344
+ } catch {}
345
+ }
346
+ //#endregion
347
+ //#region session-core-dial.ts
348
+ /**
349
+ * How the next connection attempt is DIALLED, and the resume identity it dials
350
+ * with.
351
+ *
352
+ * Split out of `session-core.ts` at the 500-line cap, along the seam that file
353
+ * already established when it moved socket plumbing into
354
+ * `session-core-reconnect.ts`: the state machine there reads as protocol logic,
355
+ * and this is the address it sends it to. What makes it one module rather than
356
+ * three extracted functions is that the three pieces of mutable state involved —
357
+ * the session id, whether this connection has ever completed a handshake, and
358
+ * whether the server is a broker — are read by nothing else in the core, and
359
+ * every one of them is only meaningful in the sentence "the URL for the next
360
+ * attempt".
361
+ */
362
+ /** @internal */
363
+ function createDialer(options) {
364
+ /**
365
+ * The session ID to resume: seeded from `options.resumeSessionId`, else from
366
+ * what a previous LOAD of this page stored, then kept current from every
367
+ * `config` frame. Reconnect URLs carry it as `?sessionId=<id>` so the server
368
+ * re-registers the SAME session id — that key is what the session's slot state
369
+ * and event log live under, so an attempt that omits it gets a fresh session
370
+ * with none of the agent's context.
371
+ *
372
+ * Reading it from storage is what makes a page RELOAD resume, and so what makes
373
+ * the server's `syncState` push reach a UI that would otherwise come back
374
+ * empty. See `session-resume-store.ts`.
375
+ */
376
+ let sessionId = options.resumeSessionId ?? readStoredSessionId(options.platformUrl);
377
+ /** Whether a handshake has completed on this core — the `resume=1` fallback. */
378
+ let hasConnected = false;
379
+ /**
380
+ * Whether `platformUrl` is a broker (its `client-config` names a
381
+ * `sessionUrl`). A server is one or it isn't — it never flips mid-session — so
382
+ * once a non-broker is observed, later reconnects skip the `client-config`
383
+ * re-fetch that would only fall through to `buildWsUrl` (every reconnect on
384
+ * `aai dev` / self-hosted otherwise pays a wasted GET). `undefined` until the
385
+ * first fetch settles.
386
+ */
387
+ let serverIsBroker;
388
+ /**
389
+ * The WebSocket URL for the *next* connection attempt. Evaluated per attempt
390
+ * (partysocket takes it as an async URL provider):
391
+ *
392
+ * - `GET client-config` is re-fetched every attempt. When it names a
393
+ * `sessionUrl` — the platform's broker pointing at the agent's live sandbox
394
+ * — the session connects DIRECTLY there. The URL changes when the sandbox is
395
+ * replaced (idle eviction, redeploy), which is exactly when a reconnect
396
+ * happens, so per-attempt brokering is what makes reconnects land on the
397
+ * replacement. Without one (`aai dev`, older servers), the same-origin
398
+ * `websocket` path is used.
399
+ * - Once the first `config` arrives, every reconnect carries `?sessionId=<id>`
400
+ * and the server resumes the SAME session (id, tool state) instead of minting
401
+ * a new one. `resume=1` remains only as the greeting-suppression fallback for
402
+ * a server whose config carried no id.
403
+ */
404
+ async function url() {
405
+ const cfg = serverIsBroker === false ? null : await loadClientConfig(options.platformUrl);
406
+ if (cfg) serverIsBroker = cfg.sessionUrl !== void 0;
407
+ return (cfg?.sessionUrl ? buildBrokeredWsUrl(cfg.sessionUrl, hasConnected, sessionId) : buildWsUrl(options.platformUrl, hasConnected, sessionId)).toString();
408
+ }
409
+ return {
410
+ url,
411
+ open: () => {
412
+ if (options.WebSocket) return new options.WebSocket(buildWsUrl(options.platformUrl, hasConnected, sessionId).toString());
413
+ return openReconnectingSocket(url);
414
+ },
415
+ configured: (sid) => {
416
+ if (sid) {
417
+ sessionId = sid;
418
+ writeStoredSessionId(options.platformUrl, sid);
419
+ }
420
+ hasConnected = true;
421
+ },
422
+ forget: () => {
423
+ sessionId = void 0;
424
+ clearStoredSessionId(options.platformUrl);
425
+ hasConnected = false;
426
+ }
427
+ };
428
+ }
429
+ //#endregion
255
430
  //#region session-core-handshake.ts
256
431
  /**
257
432
  * The deadline on a socket that opened but never became a session.
@@ -287,6 +462,8 @@ const HANDSHAKE_TIMEOUT_MS = 1e4;
287
462
  * budget for this failure mode — without one, a permanently wedged peer would
288
463
  * be re-dialed every ~10s forever, which is the unbounded retry loop
289
464
  * `RECONNECT_OPTIONS.maxRetries` exists to prevent.
465
+ *
466
+ * CONSECUTIVE is the whole of it, and only `succeeded()` says so — see its doc.
290
467
  */
291
468
  const MAX_HANDSHAKE_TIMEOUTS = 3;
292
469
  /**
@@ -322,7 +499,11 @@ function createHandshakeGuard(opts) {
322
499
  disarm();
323
500
  timer = setTimeout(fire, HANDSHAKE_TIMEOUT_MS);
324
501
  },
325
- disarm
502
+ disarm,
503
+ succeeded() {
504
+ disarm();
505
+ timeouts = 0;
506
+ }
326
507
  };
327
508
  }
328
509
  //#endregion
@@ -346,7 +527,7 @@ const MAX_MESSAGES = DEFAULT_MAX_HISTORY;
346
527
  const MAX_PREINIT_AUDIO_CHUNKS = 100;
347
528
  /**
348
529
  * Snapshot fields cleared when a session's conversation state is wiped —
349
- * shared by the initial snapshot, `resetState()`, and the server `reset` event.
530
+ * shared by the initial snapshot, `resetState()`, and `session.reset`.
350
531
  * The empty arrays are safe to share: snapshot collections are never mutated
351
532
  * in place, only replaced.
352
533
  */
@@ -404,7 +585,7 @@ function createMessageHandlers(deps) {
404
585
  });
405
586
  }
406
587
  /**
407
- * `agent_transcript` carries the reply's text so far as a full-replacement
588
+ * The agent-transcript events carry the reply's text so far as a full-replacement
408
589
  * snapshot (see the protocol schema), so it renders as the live assistant
409
590
  * bubble and only becomes a message when the reply closes. Pipeline mode sends
410
591
  * one per piece of speech, so appending each would break a single reply into a
@@ -418,7 +599,7 @@ function createMessageHandlers(deps) {
418
599
  updateState({ agentTranscript: text });
419
600
  }
420
601
  /**
421
- * The reply is over (`reply_done`, or `cancelled` for a barge-in): move
602
+ * The reply is over (`reply.completed`, or `reply.cancelled` for a barge-in): move
422
603
  * whatever was spoken into the conversation. A cancelled reply still keeps its
423
604
  * text — the caller heard that much, and dropping it would leave the
424
605
  * transcript claiming the agent never spoke.
@@ -459,7 +640,8 @@ function createMessageHandlers(deps) {
459
640
  /**
460
641
  * Return to "listening" at a turn boundary — unless the session is over.
461
642
  *
462
- * `reply_done`, `cancelled` and `reset` each wrote `state: "listening"`
643
+ * `reply.completed`, `reply.cancelled` and `session.reset` each wrote
644
+ * `state: "listening"`
463
645
  * unconditionally, which is the second half of the same bug
464
646
  * `clearRecoveredError`'s latch covers: the host's fatal paths all call
465
647
  * `terminate()`, and terminating emits `onCancelled()`. So the frame that
@@ -495,22 +677,23 @@ function createMessageHandlers(deps) {
495
677
  }
496
678
  /** Single entry point for all server->client session events. */
497
679
  function handleEvent(e) {
498
- if (e.type !== "error") clearRecoveredError();
680
+ if (e.type !== "error.reported") clearRecoveredError();
499
681
  switch (e.type) {
500
- case "speech_started":
682
+ case "speech.started":
501
683
  updateState({ userTranscript: "" });
502
684
  break;
503
- case "speech_stopped": break;
504
- case "user_transcript":
685
+ case "speech.stopped": break;
686
+ case "user-transcript.committed":
505
687
  handleUserTranscriptEvent(e.text);
506
688
  break;
507
- case "user_transcript_partial":
689
+ case "user-transcript.updated":
508
690
  updateState({ userTranscript: e.text });
509
691
  break;
510
- case "agent_transcript":
692
+ case "agent-transcript.updated":
693
+ case "agent-transcript.committed":
511
694
  handleAgentTranscriptEvent(e.text);
512
695
  break;
513
- case "tool_call":
696
+ case "tool.called":
514
697
  updateState({ toolCalls: appendCapped(getSnapshot().toolCalls, {
515
698
  callId: e.toolCallId,
516
699
  name: e.toolName,
@@ -520,7 +703,7 @@ function createMessageHandlers(deps) {
520
703
  afterMessageId: getSnapshot().messages.at(-1)?.id ?? -1
521
704
  }, MAX_MESSAGES) });
522
705
  break;
523
- case "tool_call_done": {
706
+ case "tool.completed": {
524
707
  const tcs = getSnapshot().toolCalls;
525
708
  const idx = tcs.findIndex((tc) => tc.callId === e.toolCallId);
526
709
  if (idx !== -1) {
@@ -535,31 +718,53 @@ function createMessageHandlers(deps) {
535
718
  }
536
719
  break;
537
720
  }
538
- case "reply_done":
721
+ case "reply.completed":
539
722
  commitAgentTranscript();
540
723
  toListening();
541
724
  break;
542
- case "cancelled":
725
+ case "reply.cancelled":
543
726
  conn.turn.bump();
544
727
  conn.voiceIO?.flush();
545
728
  commitAgentTranscript();
546
729
  toListening({ userTranscript: null });
547
730
  break;
548
- case "reset":
731
+ case "session.reset":
549
732
  conn.turn.bump();
550
733
  conn.voiceIO?.flush();
551
734
  toListening(conn.fatalError ? {} : CLEARED_SESSION_STATE);
552
735
  break;
553
- case "custom_event":
736
+ case "custom.emitted":
554
737
  appendCustomEvent(e.event, e.data);
555
738
  break;
556
- case "agent_state":
739
+ case "state.updated":
557
740
  updateState({ agentState: e.state });
558
741
  break;
559
- case "error":
742
+ case "history.restored": {
743
+ messageSeq = 0;
744
+ toolCallSeq = 0;
745
+ const restored = e.messages.slice(-MAX_MESSAGES).map((m) => ({
746
+ id: ++messageSeq,
747
+ role: m.role,
748
+ content: m.content
749
+ }));
750
+ updateState({
751
+ messages: restored,
752
+ toolCalls: e.toolCalls.slice(-MAX_MESSAGES).map((tc) => ({
753
+ callId: tc.callId,
754
+ name: tc.name,
755
+ args: toArgsRecord(tc.args),
756
+ status: tc.status,
757
+ ...omitUndefined({ result: tc.result }),
758
+ seq: ++toolCallSeq,
759
+ afterMessageId: restored[tc.afterMessageIndex]?.id ?? -1
760
+ }))
761
+ });
762
+ break;
763
+ }
764
+ case "error.reported":
560
765
  handleErrorEvent(e);
561
766
  break;
562
- case "idle_timeout":
767
+ case "session.timed-out":
563
768
  deps.conn.retiredByServer = true;
564
769
  break;
565
770
  default: break;
@@ -619,7 +824,7 @@ function createMessageHandlers(deps) {
619
824
  return;
620
825
  }
621
826
  const msg = parsed.data;
622
- if (msg.type === "config") {
827
+ if (msg.type === "session.configured") {
623
828
  conn.fatalError = false;
624
829
  return {
625
830
  sampleRate: msg.sampleRate,
@@ -627,7 +832,7 @@ function createMessageHandlers(deps) {
627
832
  sid: msg.sessionId
628
833
  };
629
834
  }
630
- if (msg.type === "audio_done") {
835
+ if (msg.type === "audio.completed") {
631
836
  playAudioDone();
632
837
  return;
633
838
  }
@@ -639,29 +844,6 @@ function createMessageHandlers(deps) {
639
844
  };
640
845
  }
641
846
  //#endregion
642
- //#region session-core-url.ts
643
- /** Build the session WebSocket URL from the platform URL and resume state. */
644
- function buildWsUrl(platformUrl, resume, sessionId) {
645
- return applyResumeParams(buildAgentUrl(platformUrl, "websocket"), resume, sessionId);
646
- }
647
- /**
648
- * Turn a broker-provided session URL (`sessionUrl` from `GET client-config`
649
- * — the agent's live sandbox endpoint) into this attempt's connect URL.
650
- */
651
- function buildBrokeredWsUrl(sessionUrl, resume, sessionId) {
652
- return applyResumeParams(new URL(sessionUrl), resume, sessionId);
653
- }
654
- const WS_PROTOCOLS = {
655
- "https:": "wss:",
656
- "http:": "ws:"
657
- };
658
- function applyResumeParams(wsUrl, resume, sessionId) {
659
- wsUrl.protocol = WS_PROTOCOLS[wsUrl.protocol] ?? wsUrl.protocol;
660
- if (sessionId) wsUrl.searchParams.set("sessionId", sessionId);
661
- else if (resume) wsUrl.searchParams.set("resume", "1");
662
- return wsUrl;
663
- }
664
- //#endregion
665
847
  //#region session-core.ts
666
848
  /**
667
849
  * Framework-agnostic voice session core.
@@ -755,25 +937,7 @@ function createSessionCore(options) {
755
937
  preInitDone: false
756
938
  };
757
939
  let connectionController = null;
758
- let hasConnected = false;
759
- /**
760
- * The session ID to resume: seeded from `options.resumeSessionId`, then
761
- * kept current from every `config` frame. Reconnect URLs carry it as
762
- * `?sessionId=<id>` so the server re-registers the SAME session id —
763
- * that key is what per-session tool state (`ctx.state`) lives under, so
764
- * a reconnect that omits it gets a fresh session with none of the
765
- * agent's context, greeting suppression aside.
766
- */
767
- let sessionId = options.resumeSessionId;
768
- /**
769
- * Whether `platformUrl` is a broker (its `client-config` names a
770
- * `sessionUrl`). A server is one or it isn't — it never flips mid-session
771
- * — so once a non-broker is observed, later reconnects skip the
772
- * `client-config` re-fetch that would only fall through to `buildWsUrl`
773
- * (every reconnect on `aai dev` / self-hosted otherwise pays a wasted GET).
774
- * `undefined` until the first fetch settles.
775
- */
776
- let serverIsBroker;
940
+ const dialer = createDialer(options);
777
941
  function cleanupAudio() {
778
942
  conn.audioSetupInFlight = false;
779
943
  conn.turn.bump();
@@ -814,51 +978,21 @@ function createSessionCore(options) {
814
978
  conn.ws?.close();
815
979
  conn.ws = null;
816
980
  }
817
- /** React to the server's `config` message: record it, set up the audio
818
- * path for the session's mode, and replay history on reconnect. */
819
- function onServerConfig(config) {
820
- if (config.sid) {
821
- sessionId = config.sid;
822
- options.onSessionId?.(config.sid);
823
- }
824
- const isReconnect = hasConnected;
825
- hasConnected = true;
826
- initAudioCapture(conn, config, audioDeps);
827
- if (isReconnect && currentSnapshot.messages.length > 0) sendJson({
828
- type: "history",
829
- messages: currentSnapshot.messages.map((m) => ({
830
- role: m.role,
831
- content: m.content
832
- }))
833
- });
834
- }
835
981
  /**
836
- * The WebSocket URL for the *next* connection attempt. Evaluated per
837
- * attempt (partysocket takes it as an async URL provider):
982
+ * React to the server's `session.configured` frame: record it and set up the
983
+ * session's audio path.
838
984
  *
839
- * - `GET client-config` is re-fetched every attempt. When it names a
840
- * `sessionUrl` — the platform's broker pointing at the agent's live
841
- * sandbox — the session connects DIRECTLY there. The URL changes when
842
- * the sandbox is replaced (idle eviction, redeploy), which is exactly
843
- * when a reconnect happens, so per-attempt brokering is what makes
844
- * reconnects land on the replacement. Without one (`aai dev`, older
845
- * servers), the same-origin `websocket` path is used.
846
- * - Once the first `config` arrives, every reconnect carries
847
- * `?sessionId=<id>` and the server resumes the SAME session (id, tool
848
- * state) instead of minting a new one. `resume=1` remains only as the
849
- * greeting-suppression fallback for a server whose config carried no id.
985
+ * **It no longer replays history, and the deletion is the point.** A reconnect
986
+ * used to push this snapshot's `messages` back, making the CLIENT the authority
987
+ * on the agent's memory; the server restores the conversation from its own
988
+ * retained event stream now, which also covers what a client cannot — a second
989
+ * tab, a call resuming onto a replacement sandbox, a reopened tab. This
990
+ * snapshot's `messages` are untouched: nothing clears the transcript on screen.
850
991
  */
851
- async function currentWsUrl() {
852
- const cfg = serverIsBroker === false ? null : await loadClientConfig(options.platformUrl);
853
- if (cfg) serverIsBroker = cfg.sessionUrl !== void 0;
854
- return (cfg?.sessionUrl ? buildBrokeredWsUrl(cfg.sessionUrl, hasConnected, sessionId) : buildWsUrl(options.platformUrl, hasConnected, sessionId)).toString();
855
- }
856
- /** Open a socket: an injected constructor as-is (tests — connects to the
857
- * same-origin path, no brokering), or partysocket's reconnecting
858
- * WebSocket — same interface, plus reconnect-on-close. */
859
- function openSocket() {
860
- if (options.WebSocket) return new options.WebSocket(buildWsUrl(options.platformUrl, hasConnected, sessionId).toString());
861
- return openReconnectingSocket(currentWsUrl);
992
+ function onServerConfig(config) {
993
+ dialer.configured(config.sid);
994
+ if (config.sid) options.onSessionId?.(config.sid);
995
+ initAudioCapture(conn, config, audioDeps);
862
996
  }
863
997
  function connect(opts) {
864
998
  if (opts?.signal?.aborted) {
@@ -877,7 +1011,7 @@ function createSessionCore(options) {
877
1011
  connectionController = controller;
878
1012
  const { signal: sig } = controller;
879
1013
  if (opts?.signal) opts.signal.addEventListener("abort", () => disconnect(), { signal: sig });
880
- const socket = openSocket();
1014
+ const socket = dialer.open();
881
1015
  socket.binaryType = "arraybuffer";
882
1016
  conn.ws = socket;
883
1017
  let socketErrored = false;
@@ -912,7 +1046,7 @@ function createSessionCore(options) {
912
1046
  socket.addEventListener("message", (event) => {
913
1047
  const config = handleMessage(event.data);
914
1048
  if (!config) return;
915
- handshake.disarm();
1049
+ handshake.succeeded();
916
1050
  onServerConfig(config);
917
1051
  }, { signal: sig });
918
1052
  socket.addEventListener("error", () => {
@@ -996,8 +1130,7 @@ function createSessionCore(options) {
996
1130
  }
997
1131
  function end() {
998
1132
  teardownConnection();
999
- sessionId = void 0;
1000
- hasConnected = false;
1133
+ dialer.forget();
1001
1134
  updateState({
1002
1135
  ...CLEARED_SESSION_STATE,
1003
1136
  state: "disconnected",
@@ -0,0 +1,38 @@
1
+ /**
2
+ * How the next connection attempt is DIALLED, and the resume identity it dials
3
+ * with.
4
+ *
5
+ * Split out of `session-core.ts` at the 500-line cap, along the seam that file
6
+ * already established when it moved socket plumbing into
7
+ * `session-core-reconnect.ts`: the state machine there reads as protocol logic,
8
+ * and this is the address it sends it to. What makes it one module rather than
9
+ * three extracted functions is that the three pieces of mutable state involved —
10
+ * the session id, whether this connection has ever completed a handshake, and
11
+ * whether the server is a broker — are read by nothing else in the core, and
12
+ * every one of them is only meaningful in the sentence "the URL for the next
13
+ * attempt".
14
+ */
15
+ import type { WebSocketConstructor } from "./types.ts";
16
+ /** What the dialer needs from the session's options. */
17
+ export type DialOptions = {
18
+ platformUrl: string;
19
+ /** Tests inject one; it connects to the same-origin path and never reconnects. */
20
+ WebSocket?: WebSocketConstructor | undefined;
21
+ /** An id the caller manages itself — wins over what a previous load stored. */
22
+ resumeSessionId?: string | undefined;
23
+ };
24
+ export type Dialer = {
25
+ /** The URL for the next attempt — partysocket's async provider. */
26
+ url(): Promise<string>;
27
+ /** A socket for this attempt. */
28
+ open(): InstanceType<WebSocketConstructor>;
29
+ /**
30
+ * A completed handshake: adopt the server's session id and record that this
31
+ * connection has been established, so every later attempt resumes.
32
+ */
33
+ configured(sid: string | undefined): void;
34
+ /** Drop the resume identity, so the next connect is a NEW session. */
35
+ forget(): void;
36
+ };
37
+ /** @internal */
38
+ export declare function createDialer(options: DialOptions): Dialer;