@alexkroman1/aai-ui 6.11.0 → 7.0.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 (66) hide show
  1. package/README.md +104 -3
  2. package/dist/_run-controls.d.ts +33 -0
  3. package/dist/{chat-view-ByQFf94G.js → chat-view-BKsFfFZJ.js} +57 -11
  4. package/dist/client-config-B4nznRvH.js +134 -0
  5. package/dist/client-config.d.ts +45 -2
  6. package/dist/client-dir.d.ts +3 -1
  7. package/dist/client-dir.js +3 -1
  8. package/dist/components/auto-scroll.d.ts +24 -13
  9. package/dist/components/button.d.ts +8 -4
  10. package/dist/components/button.js +4 -4
  11. package/dist/components/chat-view.d.ts +4 -3
  12. package/dist/components/chat-view.js +1 -1
  13. package/dist/components/console-shell.d.ts +71 -10
  14. package/dist/components/controls.d.ts +15 -4
  15. package/dist/components/controls.js +48 -2
  16. package/dist/components/form-fields.d.ts +142 -0
  17. package/dist/components/form-types.d.ts +6 -0
  18. package/dist/components/form.d.ts +38 -80
  19. package/dist/components/markdown.d.ts +31 -5
  20. package/dist/components/message-list.d.ts +21 -4
  21. package/dist/components/message-list.js +1 -1
  22. package/dist/components/sidebar-layout.d.ts +11 -0
  23. package/dist/components/sidebar-layout.js +2 -0
  24. package/dist/components/start-screen.d.ts +8 -0
  25. package/dist/components/start-screen.js +2 -0
  26. package/dist/components/tool-call-block.js +1 -1
  27. package/dist/components/tool-call-row.d.ts +24 -0
  28. package/dist/components/upload-progress.d.ts +17 -7
  29. package/dist/components/workflow-fields.d.ts +10 -22
  30. package/dist/components/workflow-progress.d.ts +29 -10
  31. package/dist/context.d.ts +36 -0
  32. package/dist/context.js +105 -14
  33. package/dist/default-client/assets/index-S5fkKi6B.css +2 -0
  34. package/dist/default-client/assets/index-fEkrcZgo.js +293 -0
  35. package/dist/default-client/index.html +2 -2
  36. package/dist/define-client.d.ts +67 -62
  37. package/dist/define-client.js +56 -22
  38. package/dist/hooks.d.ts +88 -3
  39. package/dist/index.d.ts +12 -11
  40. package/dist/index.js +501 -167
  41. package/dist/internal.d.ts +40 -0
  42. package/dist/internal.js +6 -0
  43. package/dist/{message-list-CpPV7dGx.js → message-list-DHddO4QC.js} +217 -72
  44. package/dist/{session-core-CAfYmUbg.js → session-core-C2JtLArh.js} +267 -165
  45. package/dist/session-core-audio-setup.d.ts +3 -0
  46. package/dist/session-core-messages.d.ts +3 -0
  47. package/dist/session-core-state.d.ts +146 -0
  48. package/dist/session-core-types.d.ts +77 -32
  49. package/dist/session-core.d.ts +3 -2
  50. package/dist/session-core.js +1 -1
  51. package/dist/{tool-call-block-D6pTEPrT.js → tool-call-block-DoF-cSIZ.js} +27 -19
  52. package/dist/tool-config-context-DzAofqi_.js +19 -0
  53. package/dist/types.d.ts +31 -5
  54. package/dist/types.js +5 -4
  55. package/dist/{controls-CjG91QJ4.js → url-chips-DpM7Oocj.js} +3 -46
  56. package/dist/use-conversation.d.ts +122 -0
  57. package/dist/use-download-url.d.ts +83 -0
  58. package/dist/use-workflow-form.d.ts +59 -3
  59. package/dist/use-workflow-run.d.ts +35 -0
  60. package/dist/use-workflow-stream.d.ts +36 -62
  61. package/dist/workflow-client.d.ts +36 -11
  62. package/dist/workflow-status-labels.d.ts +35 -0
  63. package/package.json +10 -5
  64. package/styles.css +14 -0
  65. package/dist/default-client/assets/index-DTLrhtTF.css +0 -2
  66. package/dist/default-client/assets/index-DXODx_9r.js +0 -293
@@ -1,98 +1,11 @@
1
+ import { r as loadClientConfig, t as buildAgentUrl } from "./client-config-B4nznRvH.js";
1
2
  import { MIC_SEND_MAX_BUFFERED_BYTES } from "./types.js";
2
- import { CLIENT_CONFIG_PATH, ClientConfigResponseSchema, ServerMessageSchema, lenientParse } from "@alexkroman1/aai/protocol";
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";
3
+ import { ServerMessageSchema, lenientParse } from "@alexkroman1/aai/protocol";
4
+ import { errorMessage, safeJsonParse } from "@alexkroman1/aai";
5
+ import { omitUndefined } from "@alexkroman1/aai/utils";
6
+ import { DEFAULT_MAX_HISTORY, WS_OPEN, createEpoch, toArgsRecord } from "@alexkroman1/aai/internal";
6
7
  import ReconnectingWebSocket from "partysocket/ws";
7
- //#region client-config.ts
8
- /**
9
- * Pre-connection client-config lookup.
10
- *
11
- * `GET client-config` (relative to the agent's base URL — see
12
- * `sdk/client-config.ts` in `@alexkroman1/aai`) gives the default client the
13
- * agent's display name and greeting before any connection exists. For that
14
- * use every failure path — network error, 404 from an older server,
15
- * malformed body — degrades to the empty default (`fetchClientConfig`), so
16
- * the lookup can never break an existing agent.
17
- *
18
- * The session's broker decision needs the opposite: `loadClientConfig`
19
- * keeps "the lookup failed" (`null`) distinct from "the server answered and
20
- * named no sessionUrl" (`{}`). See its doc comment.
21
- */
22
- /**
23
- * Resolve a relative endpoint path against the agent's base URL.
24
- *
25
- * @internal
26
- */
27
- function buildAgentUrl(platformUrl, endpointPath) {
28
- return new URL(endpointPath, platformUrl.endsWith("/") ? platformUrl : `${platformUrl}/`);
29
- }
30
- const AGENT_DEFAULT = {};
31
- /**
32
- * Per-attempt deadline for the `client-config` lookup.
33
- *
34
- * A request issued while the platform is restarting or saturated can HANG
35
- * rather than fail — the proxy holds the socket open — and a browser fetch
36
- * has no timeout of its own. Every other failure here is already handled
37
- * (`null`, then the same-origin fallback), but a hang is not a failure: the
38
- * promise simply never settles.
39
- *
40
- * That is unrecoverable rather than merely slow, because this lookup runs
41
- * inside the session's WebSocket URL *provider*. partysocket awaits the
42
- * provider under `_connectLock` and arms its own `connectionTimeout` only
43
- * AFTER the URL resolves, so a hung lookup means no socket is ever
44
- * constructed, no `error`/`close` ever fires, and none of the 10 reconnect
45
- * attempts ever happen — the session sits on "connecting" forever, and stays
46
- * there long after the server is back. Reproduced: zero sockets opened.
47
- *
48
- * A timed-out attempt therefore degrades exactly like any other failed one —
49
- * `null`, so `serverIsBroker` stays unlatched and the attempt falls through
50
- * to the same-origin `websocket` path, whose failure re-enters the normal
51
- * backoff and re-fetches this on the next attempt.
52
- *
53
- * Sized well above the real work (one same-origin JSON GET that reads the
54
- * agent's row) and well under a user's patience — the same 10s the studio's
55
- * gating reads use for the identical hazard.
56
- *
57
- * @internal
58
- */
59
- const CLIENT_CONFIG_ATTEMPT_TIMEOUT_MS = 1e4;
60
- /**
61
- * Fetch the agent's client config, reporting `null` when the lookup did not
62
- * produce an answer (network error, non-2xx, unparsable body).
63
- *
64
- * The distinction from `fetchClientConfig` matters for exactly one caller:
65
- * the session's per-attempt broker decision. A config that ARRIVED and named
66
- * no `sessionUrl` means "this server is not a broker" (`aai dev`, an older
67
- * server) — a durable fact worth latching. A lookup that FAILED means
68
- * nothing about the server, and treating the two alike is how a single 503
69
- * (a sandbox mid-boot, or one that failed to start) pinned a session to the
70
- * platform's `/:slug/websocket` — a WebSocket redirect browsers don't
71
- * follow, so every retry failed with no re-brokering even after the agent
72
- * recovered.
73
- *
74
- * @internal
75
- */
76
- async function loadClientConfig(platformUrl, fetchFn) {
77
- const doFetch = fetchFn ?? ((input, init) => globalThis.fetch(input, init));
78
- try {
79
- const resp = await doFetch(buildAgentUrl(platformUrl, CLIENT_CONFIG_PATH).href, { signal: AbortSignal.timeout(CLIENT_CONFIG_ATTEMPT_TIMEOUT_MS) });
80
- if (!resp.ok) return null;
81
- const parsed = ClientConfigResponseSchema.safeParse(await resp.json());
82
- return parsed.success ? parsed.data : null;
83
- } catch {
84
- return null;
85
- }
86
- }
87
- /**
88
- * Fetch the agent's client config; any failure yields the agent default.
89
- *
90
- * @internal
91
- */
92
- async function fetchClientConfig(platformUrl, fetchFn) {
93
- return await loadClientConfig(platformUrl, fetchFn) ?? AGENT_DEFAULT;
94
- }
95
- //#endregion
8
+ import { and, assign, createActor, not, setup, stateIn } from "xstate";
96
9
  //#region session-core-audio-setup.ts
97
10
  /**
98
11
  * Audio-path initialization for the voice session core.
@@ -143,11 +56,13 @@ async function initAudioCapture(conn, msg, deps) {
143
56
  const reportAudioFailure = (message) => {
144
57
  deps.cleanupAudio();
145
58
  deps.updateState({
146
- state: "error",
147
- error: {
148
- code: "audio",
149
- message
150
- },
59
+ ...deps.agentState.apply({
60
+ type: "FAILED",
61
+ error: {
62
+ code: "audio",
63
+ message
64
+ }
65
+ }),
151
66
  running: false,
152
67
  recording: false
153
68
  });
@@ -195,7 +110,7 @@ async function initAudioCapture(conn, msg, deps) {
195
110
  if (conn.preInitDone) {
196
111
  conn.preInitDone = false;
197
112
  deps.settleWhenAudioDrained(io);
198
- } else deps.updateState({ state: "listening" });
113
+ } else deps.updateState(deps.agentState.apply({ type: "LISTEN" }));
199
114
  } catch (err) {
200
115
  if (stale()) return;
201
116
  reportAudioFailure(`Microphone access failed: ${errorMessage(err)}`);
@@ -556,7 +471,7 @@ function appendCapped(list, item, cap) {
556
471
  * `ConnState.turn`).
557
472
  */
558
473
  function createMessageHandlers(deps) {
559
- const { getSnapshot, updateState, conn, cleanupAudio } = deps;
474
+ const { getSnapshot, updateState, conn, agentState, cleanupAudio } = deps;
560
475
  /** Monotonically increasing counter for custom events -- used by useEvent to deduplicate. */
561
476
  let customEventSeq = 0;
562
477
  /** Monotonically increasing counter for chat messages -- stable render keys
@@ -581,7 +496,7 @@ function createMessageHandlers(deps) {
581
496
  role: "user",
582
497
  content: text
583
498
  }, MAX_MESSAGES),
584
- state: "thinking"
499
+ ...agentState.apply({ type: "THINK" })
585
500
  });
586
501
  }
587
502
  /**
@@ -622,54 +537,53 @@ function createMessageHandlers(deps) {
622
537
  /** Clear error state when a non-error event arrives — proves the session
623
538
  * is functional (e.g. audio init failed but WebSocket still works).
624
539
  *
625
- * A FATAL error is exempt, and that exemption is the whole reason the flag
626
- * exists (see `ConnState.fatalError`): the host's teardown emits, so the
627
- * frames that follow a fatal error are a consequence of it rather than
628
- * evidence against it. Recovering on them left the one message that says
629
- * what to fix — a missing provider key — on screen for a fraction of a
630
- * second, over a session that could no longer hear anyone. */
540
+ * A FATAL session is exempt, and that exemption is the whole reason the
541
+ * `fatal` region exists: the host's teardown emits, so the frames that
542
+ * follow a fatal error are a consequence of it rather than evidence
543
+ * against it. Recovering on them left the one message that says what to
544
+ * fix — a missing provider key — on screen for a fraction of a second,
545
+ * over a session that could no longer hear anyone. */
631
546
  function clearRecoveredError() {
632
- if (conn.fatalError) return;
633
- const snap = getSnapshot();
634
- if (snap.state === "error") updateState({
635
- state: "listening",
636
- error: null
637
- });
638
- else if (snap.error !== null) updateState({ error: null });
547
+ updateState(agentState.apply({ type: "ACTIVITY" }));
639
548
  }
640
549
  /**
641
550
  * Return to "listening" at a turn boundary — unless the session is over.
642
551
  *
643
552
  * `reply.completed`, `reply.cancelled` and `session.reset` each wrote
644
- * `state: "listening"`
645
- * unconditionally, which is the second half of the same bug
646
- * `clearRecoveredError`'s latch covers: the host's fatal paths all call
553
+ * `state: "listening"` unconditionally, which is the second half of the same
554
+ * bug `clearRecoveredError` covers: the host's fatal paths all call
647
555
  * `terminate()`, and terminating emits `onCancelled()`. So the frame that
648
556
  * ANNOUNCES the session's death was also the frame that painted a live-mic
649
557
  * state over the error it had just reported.
558
+ *
559
+ * The exemption is not restated here: `LISTEN` is declined while the `fatal`
560
+ * region says so, which is what makes this the whole of the rule rather than
561
+ * one of four sites that had to remember it.
650
562
  */
651
563
  function toListening(extra = {}) {
652
- updateState(conn.fatalError ? extra : {
564
+ updateState({
653
565
  ...extra,
654
- state: "listening"
566
+ ...agentState.apply({ type: "LISTEN" })
655
567
  });
656
568
  }
657
569
  function handleErrorEvent(e) {
658
570
  console.error("Agent error:", e.message);
659
- if (e.fatal === false) updateState({ error: {
571
+ const error = {
660
572
  code: e.code,
661
573
  message: e.message
662
- } });
574
+ };
575
+ if (e.fatal === false) updateState(agentState.apply({
576
+ type: "TURN_ERROR",
577
+ error
578
+ }));
663
579
  else {
664
580
  cleanupAudio();
665
581
  conn.generation.bump();
666
- conn.fatalError = true;
667
582
  updateState({
668
- state: "error",
669
- error: {
670
- code: e.code,
671
- message: e.message
672
- },
583
+ ...agentState.apply({
584
+ type: "FATAL",
585
+ error
586
+ }),
673
587
  running: false,
674
588
  recording: false
675
589
  });
@@ -727,11 +641,16 @@ function createMessageHandlers(deps) {
727
641
  commitAgentTranscript();
728
642
  toListening({ userTranscript: null });
729
643
  break;
730
- case "session.reset":
644
+ case "session.reset": {
731
645
  conn.turn.bump();
732
646
  conn.voiceIO?.flush();
733
- toListening(conn.fatalError ? {} : CLEARED_SESSION_STATE);
647
+ const next = agentState.apply({ type: "RESET" });
648
+ updateState(agentState.fatal() ? next : {
649
+ ...CLEARED_SESSION_STATE,
650
+ ...next
651
+ });
734
652
  break;
653
+ }
735
654
  case "custom.emitted":
736
655
  appendCustomEvent(e.event, e.data);
737
656
  break;
@@ -771,9 +690,7 @@ function createMessageHandlers(deps) {
771
690
  }
772
691
  /** Enqueue a PCM16 audio chunk for playback. Transitions state to `"speaking"` on the first chunk. */
773
692
  function playAudioChunk(chunk) {
774
- const snap = getSnapshot();
775
- if (snap.state === "error" || snap.state === "disconnected" && snap.error !== null) return;
776
- if (snap.state !== "speaking") updateState({ state: "speaking" });
693
+ updateState(agentState.apply({ type: "SPEAK" }));
777
694
  if (conn.voiceIO) conn.voiceIO.enqueue(chunk);
778
695
  else if (conn.preInitAudio.length < MAX_PREINIT_AUDIO_CHUNKS) conn.preInitAudio.push(chunk);
779
696
  }
@@ -785,7 +702,7 @@ function createMessageHandlers(deps) {
785
702
  const gen = conn.turn.current();
786
703
  io.done().then(() => {
787
704
  if (!conn.turn.isCurrent(gen)) return;
788
- updateState({ state: "listening" });
705
+ updateState(agentState.apply({ type: "LISTEN" }));
789
706
  }).catch((err) => {
790
707
  console.warn("Audio playback done failed:", err);
791
708
  });
@@ -800,7 +717,7 @@ function createMessageHandlers(deps) {
800
717
  if (io) settleWhenAudioDrained(io);
801
718
  else {
802
719
  conn.preInitDone = true;
803
- updateState({ state: "listening" });
720
+ updateState(agentState.apply({ type: "LISTEN" }));
804
721
  }
805
722
  }
806
723
  function handleMessage(data) {
@@ -824,7 +741,7 @@ function createMessageHandlers(deps) {
824
741
  }
825
742
  const msg = parsed.data;
826
743
  if (msg.type === "session.configured") {
827
- conn.fatalError = false;
744
+ agentState.apply({ type: "HANDSHAKE_COMPLETE" });
828
745
  return {
829
746
  sampleRate: msg.sampleRate,
830
747
  ttsSampleRate: msg.ttsSampleRate,
@@ -843,6 +760,188 @@ function createMessageHandlers(deps) {
843
760
  };
844
761
  }
845
762
  //#endregion
763
+ //#region session-core-state.ts
764
+ /**
765
+ * The browser session's {@link AgentState}, and the error beside it, as a
766
+ * statechart.
767
+ *
768
+ * These two were written independently from thirteen sites across
769
+ * `session-core.ts`, `session-core-messages.ts` and
770
+ * `session-core-audio-setup.ts`, each one deciding for itself whether its write
771
+ * was legal by reading the snapshot back first. Three shipped bugs came out of
772
+ * that, and all three are the same shape — a transition nothing forbade:
773
+ *
774
+ * - **A straggler audio chunk flipped an errored session to `"speaking"`.**
775
+ * Guarded by hand at the call site
776
+ * (`if (snap.state === "error" || (snap.state === "disconnected" && …))`).
777
+ * Here `error` simply does not handle `SPEAK`.
778
+ * - **The frame that announced a session's death also wiped the banner
779
+ * reporting it.** Every fatal path in the host tears the transport down, and
780
+ * tearing down EMITS — so `reply.cancelled` arrived right behind the error and
781
+ * `toListening()` painted a live-mic state over it. A missing provider key is
782
+ * the case that made it visible: the one message that says exactly what to fix
783
+ * was on screen for a few hundred milliseconds and left a session that looked
784
+ * live and was deaf.
785
+ * - **A later frame RECOVERED the state.** `clearRecoveredError` reads a
786
+ * non-error frame as proof the session works, which is right for a
787
+ * turn-level failure and wrong for a server error that ended the call.
788
+ *
789
+ * The second and third were fixed with a `conn.fatalError` boolean that every
790
+ * writer had to remember to consult. It is the `fatal` region here, so
791
+ * forgetting is not available: `LISTEN` and `ACTIVITY` are declined in one
792
+ * place rather than at each of their five call sites.
793
+ *
794
+ * ## Two regions, because the fatal latch OUTLIVES the error state
795
+ *
796
+ * `fatal` is not a substate of `error`, tempting as that looks. It is cleared by
797
+ * exactly one thing — the next `config` frame, i.e. a completed handshake — and
798
+ * that is per CONNECTION rather than per session, so a reconnect that really
799
+ * works is not pinned to a dead session's banner. Between the error and that
800
+ * frame the phase runs `error → connecting → ready` while the latch stays set,
801
+ * which a substate cannot express.
802
+ *
803
+ * ## The published surface is unchanged
804
+ *
805
+ * {@link AgentState} is a public type in the versioned `aai-ui:session`
806
+ * capability, so {@link AgentStateSnapshot.state} is one of its seven names and
807
+ * nothing here widens it. The internal distinctions live in the second region
808
+ * and in context, not in the projection.
809
+ */
810
+ /**
811
+ * Not fatally over — so a working state may be painted.
812
+ *
813
+ * `stateIn` reads the sibling REGION rather than a mirror of it in context,
814
+ * which is what keeps `fatal` the one place the latch lives. The three events
815
+ * this guards are the three that used to consult `conn.fatalError` by hand at
816
+ * five call sites; `THINK` is a fourth that did not, and should have — the doc
817
+ * on that flag says outright that "no later frame may take its banner off the
818
+ * screen", and a `user-transcript.committed` arriving behind a fatal error
819
+ * painted `"thinking"` over it.
820
+ */
821
+ const NOT_FATAL = not(stateIn({ fatal: "yes" }));
822
+ const sessionStateMachine = setup({
823
+ types: {},
824
+ guards: {
825
+ /** A banner is still up from a failure the session survived. */
826
+ hasError: ({ context }) => context.error !== null },
827
+ actions: {
828
+ clearError: assign({ error: null }),
829
+ setError: assign({ error: ({ context, event }) => event.type === "TURN_ERROR" || event.type === "FATAL" || event.type === "FAILED" ? event.error : context.error })
830
+ }
831
+ }).createMachine({
832
+ id: "sessionState",
833
+ type: "parallel",
834
+ context: { error: null },
835
+ states: {
836
+ /** The seven names {@link AgentState} publishes. */
837
+ phase: {
838
+ initial: "disconnected",
839
+ on: {
840
+ CONNECT: {
841
+ target: ".connecting",
842
+ actions: "clearError"
843
+ },
844
+ CLOSED: {
845
+ target: ".disconnected",
846
+ actions: "clearError"
847
+ },
848
+ DISCONNECT: ".disconnected",
849
+ END: {
850
+ target: ".disconnected",
851
+ actions: "clearError"
852
+ },
853
+ TURN_ERROR: { actions: "setError" },
854
+ FATAL: {
855
+ target: ".error",
856
+ actions: "setError"
857
+ },
858
+ FAILED: {
859
+ target: ".error",
860
+ actions: "setError"
861
+ },
862
+ LISTEN: {
863
+ guard: NOT_FATAL,
864
+ target: ".listening"
865
+ },
866
+ RESET: {
867
+ guard: NOT_FATAL,
868
+ target: ".listening",
869
+ actions: "clearError"
870
+ },
871
+ THINK: {
872
+ guard: NOT_FATAL,
873
+ target: ".thinking"
874
+ },
875
+ ACTIVITY: {
876
+ guard: and([NOT_FATAL, "hasError"]),
877
+ actions: "clearError"
878
+ }
879
+ },
880
+ states: {
881
+ disconnected: { on: { SPEAK: {
882
+ guard: not("hasError"),
883
+ target: "speaking"
884
+ } } },
885
+ connecting: { on: { SOCKET_OPEN: "ready" } },
886
+ ready: { on: { SPEAK: "speaking" } },
887
+ listening: { on: { SPEAK: "speaking" } },
888
+ thinking: { on: { SPEAK: "speaking" } },
889
+ speaking: {},
890
+ /**
891
+ * A failure is on screen.
892
+ *
893
+ * `SPEAK` is absent deliberately — that is half the guard
894
+ * `playAudioChunk` used to spell, and the other half is `disconnected`'s
895
+ * above. `ACTIVITY` recovers to `listening` rather than merely clearing
896
+ * the banner: the socket is demonstrably open (we are handling a server
897
+ * event), so `disconnected` would misreport a live session.
898
+ */
899
+ error: { on: {
900
+ ACTIVITY: {
901
+ guard: NOT_FATAL,
902
+ target: "listening",
903
+ actions: "clearError"
904
+ },
905
+ CLOSED: {}
906
+ } }
907
+ }
908
+ },
909
+ /**
910
+ * Whether the session is fatally over.
911
+ *
912
+ * Separate from `phase` because it OUTLIVES the `error` state: only a
913
+ * completed handshake clears it, and the phase moves through `connecting`
914
+ * and `ready` on the way to one. A substate of `error` could not say that.
915
+ */
916
+ fatal: {
917
+ initial: "no",
918
+ states: {
919
+ no: { on: { FATAL: "yes" } },
920
+ yes: { on: { HANDSHAKE_COMPLETE: "no" } }
921
+ }
922
+ }
923
+ }
924
+ });
925
+ /** Create the state machine for one browser session. */
926
+ function createSessionStateMachine() {
927
+ const actor = createActor(sessionStateMachine).start();
928
+ function snapshot() {
929
+ const at = actor.getSnapshot();
930
+ return {
931
+ state: at.value.phase,
932
+ error: at.context.error
933
+ };
934
+ }
935
+ return {
936
+ snapshot,
937
+ apply(event) {
938
+ actor.send(event);
939
+ return snapshot();
940
+ },
941
+ fatal: () => actor.getSnapshot().matches({ fatal: "yes" })
942
+ };
943
+ }
944
+ //#endregion
846
945
  //#region session-core.ts
847
946
  /**
848
947
  * Framework-agnostic voice session core.
@@ -904,7 +1003,12 @@ function createSessionCore(options) {
904
1003
  "userTranscript",
905
1004
  "agentTranscript"
906
1005
  ];
1006
+ /** Does `partial` leave this field exactly as it already is? */
1007
+ function isUnchanged(key, partial) {
1008
+ return partial[key] === currentSnapshot[key];
1009
+ }
907
1010
  function updateState(partial) {
1011
+ if (Object.keys(partial).every((key) => isUnchanged(key, partial))) return;
908
1012
  currentSnapshot = contentKeys.some((key) => key in partial && partial[key] !== currentSnapshot[key]) ? {
909
1013
  ...currentSnapshot,
910
1014
  ...partial,
@@ -924,10 +1028,15 @@ function createSessionCore(options) {
924
1028
  subscribers.delete(callback);
925
1029
  };
926
1030
  }
1031
+ /**
1032
+ * The session's `state` and `error`, as one fact rather than two fields
1033
+ * thirteen call sites wrote independently — see `session-core-state.ts`,
1034
+ * which carries the three shipped bugs that arrangement produced.
1035
+ */
1036
+ const agentState = createSessionStateMachine();
927
1037
  const conn = {
928
1038
  ws: null,
929
1039
  retiredByServer: false,
930
- fatalError: false,
931
1040
  voiceIO: null,
932
1041
  audioSetupInFlight: false,
933
1042
  generation: createEpoch(),
@@ -971,12 +1080,14 @@ function createSessionCore(options) {
971
1080
  getSnapshot,
972
1081
  updateState,
973
1082
  conn,
1083
+ agentState,
974
1084
  cleanupAudio
975
1085
  });
976
1086
  const audioDeps = {
977
1087
  sendJson,
978
1088
  sendAudio,
979
1089
  updateState,
1090
+ agentState,
980
1091
  settleWhenAudioDrained,
981
1092
  cleanupAudio
982
1093
  };
@@ -1009,10 +1120,7 @@ function createSessionCore(options) {
1009
1120
  disconnect();
1010
1121
  return;
1011
1122
  }
1012
- updateState({
1013
- state: "connecting",
1014
- error: null
1015
- });
1123
+ updateState(agentState.apply({ type: "CONNECT" }));
1016
1124
  loadAudioModules().catch(() => {});
1017
1125
  teardownConnection();
1018
1126
  conn.generation.bump();
@@ -1032,7 +1140,7 @@ function createSessionCore(options) {
1032
1140
  cleanupAudio();
1033
1141
  conn.generation.bump();
1034
1142
  updateState({
1035
- state: "connecting",
1143
+ ...agentState.apply({ type: "CONNECT" }),
1036
1144
  recording: false
1037
1145
  });
1038
1146
  },
@@ -1042,15 +1150,17 @@ function createSessionCore(options) {
1042
1150
  socket.close();
1043
1151
  conn.ws = null;
1044
1152
  updateState({
1045
- state: "error",
1046
- error: HANDSHAKE_ERROR,
1153
+ ...agentState.apply({
1154
+ type: "FAILED",
1155
+ error: HANDSHAKE_ERROR
1156
+ }),
1047
1157
  running: false,
1048
1158
  recording: false
1049
1159
  });
1050
1160
  }
1051
1161
  });
1052
1162
  socket.addEventListener("open", () => {
1053
- updateState({ state: "ready" });
1163
+ updateState(agentState.apply({ type: "SOCKET_OPEN" }));
1054
1164
  handshake.arm();
1055
1165
  }, { signal: sig });
1056
1166
  socket.addEventListener("message", (event) => {
@@ -1070,7 +1180,7 @@ function createSessionCore(options) {
1070
1180
  conn.generation.bump();
1071
1181
  socketErrored = false;
1072
1182
  updateState({
1073
- state: "connecting",
1183
+ ...agentState.apply({ type: "CONNECT" }),
1074
1184
  recording: false
1075
1185
  });
1076
1186
  return;
@@ -1078,22 +1188,14 @@ function createSessionCore(options) {
1078
1188
  controller.abort();
1079
1189
  socket.close();
1080
1190
  conn.ws = null;
1081
- if (socketErrored) updateState({
1082
- state: "error",
1083
- error: {
1084
- code: "connection",
1085
- message: "WebSocket connection error"
1086
- },
1087
- running: false,
1088
- recording: false
1089
- });
1090
- else if (currentSnapshot.state === "error") updateState({
1091
- running: false,
1092
- recording: false
1093
- });
1094
- else updateState({
1095
- state: "disconnected",
1096
- error: null,
1191
+ updateState({
1192
+ ...socketErrored ? agentState.apply({
1193
+ type: "FAILED",
1194
+ error: {
1195
+ code: "connection",
1196
+ message: "WebSocket connection error"
1197
+ }
1198
+ }) : agentState.apply({ type: "CLOSED" }),
1097
1199
  running: false,
1098
1200
  recording: false
1099
1201
  });
@@ -1103,7 +1205,7 @@ function createSessionCore(options) {
1103
1205
  if (!openSocket()) return;
1104
1206
  conn.turn.bump();
1105
1207
  conn.voiceIO?.flush();
1106
- updateState({ state: "listening" });
1208
+ updateState(agentState.apply({ type: "LISTEN" }));
1107
1209
  sendJson({ type: "cancel" });
1108
1210
  }
1109
1211
  function reset() {
@@ -1119,7 +1221,7 @@ function createSessionCore(options) {
1119
1221
  function disconnect() {
1120
1222
  teardownConnection();
1121
1223
  updateState({
1122
- state: "disconnected",
1224
+ ...agentState.apply({ type: "DISCONNECT" }),
1123
1225
  running: false,
1124
1226
  recording: false
1125
1227
  });
@@ -1143,7 +1245,7 @@ function createSessionCore(options) {
1143
1245
  dialer.forget();
1144
1246
  updateState({
1145
1247
  ...CLEARED_SESSION_STATE,
1146
- state: "disconnected",
1248
+ ...agentState.apply({ type: "END" }),
1147
1249
  started: false,
1148
1250
  running: false,
1149
1251
  recording: false
@@ -1166,4 +1268,4 @@ function createSessionCore(options) {
1166
1268
  };
1167
1269
  }
1168
1270
  //#endregion
1169
- export { loadClientConfig as i, buildAgentUrl as n, fetchClientConfig as r, createSessionCore as t };
1271
+ export { createSessionCore as t };
@@ -1,11 +1,14 @@
1
1
  import type { ClientMessage } from "@alexkroman1/aai/protocol";
2
2
  import type { VoiceIO } from "./audio.ts";
3
+ import type { SessionStateMachine } from "./session-core-state.ts";
3
4
  import type { ConnState, SessionSnapshot } from "./session-core-types.ts";
4
5
  /** Dependencies `initAudioCapture` needs from the owning session core. */
5
6
  export type AudioSetupDeps = {
6
7
  sendJson: (msg: ClientMessage) => void;
7
8
  sendAudio: (bytes: ArrayBuffer) => void;
8
9
  updateState: (partial: Partial<SessionSnapshot>) => void;
10
+ /** The session's state and error, as one fact — see `session-core-state.ts`. */
11
+ agentState: SessionStateMachine;
9
12
  /** Turn-boundary-guarded drain from the message handlers — replays a
10
13
  * buffered `audio_done` without stomping a barge-in's state. */
11
14
  settleWhenAudioDrained: (io: VoiceIO) => void;
@@ -1,3 +1,4 @@
1
+ import type { SessionStateMachine } from "./session-core-state.ts";
1
2
  import type { ConnState, SessionSnapshot } from "./session-core-types.ts";
2
3
  /**
3
4
  * Snapshot fields cleared when a session's conversation state is wiped —
@@ -25,6 +26,8 @@ type MessageHandlerDeps = {
25
26
  getSnapshot: () => SessionSnapshot;
26
27
  updateState: (partial: Partial<SessionSnapshot>) => void;
27
28
  conn: ConnState;
29
+ /** The session's state and error, as one fact — see `session-core-state.ts`. */
30
+ agentState: SessionStateMachine;
28
31
  /** Release the microphone/VoiceIO (the session core's `cleanupAudio`). */
29
32
  cleanupAudio: () => void;
30
33
  };