@alexkroman1/aai-ui 4.0.0 → 5.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.
@@ -1,4 +1,4 @@
1
- import{i as e}from"./index-CkZ6t_eJ.js";import{t}from"./_module-url-BX0RuRU2.js";var n=t(`
1
+ import{i as e}from"./index-0-DXv-S4.js";import{t}from"./_module-url-BX0RuRU2.js";var n=t(`
2
2
  class PlaybackProcessor extends AudioWorkletProcessor {
3
3
  constructor(options) {
4
4
  super();
@@ -6,7 +6,7 @@
6
6
  <title>aai</title>
7
7
  <link rel="icon" href="./favicon.ico" />
8
8
  <style>html, body { background: #FBF8F2; margin: 0; }</style>
9
- <script type="module" crossorigin src="./assets/index-CkZ6t_eJ.js"></script>
9
+ <script type="module" crossorigin src="./assets/index-0-DXv-S4.js"></script>
10
10
  <link rel="stylesheet" crossorigin href="./assets/index-CRfdZPH1.css">
11
11
  </head>
12
12
  <body>
@@ -46,7 +46,18 @@ type ConfigTier = BaseOptions & {
46
46
  type ComponentTier = BaseOptions & {
47
47
  /** Full custom component to render instead of the default shell. */
48
48
  component: ComponentType;
49
- name?: never;
49
+ /**
50
+ * Agent name. With a custom component there is no shell header to put it
51
+ * in, so it becomes the page title.
52
+ *
53
+ * Allowed here rather than `never` because `client({ name, component })` is
54
+ * the natural thing to write and two different models wrote it. As `never`
55
+ * it failed with *"Type 'string' is not assignable to type 'undefined'"*,
56
+ * which explains nothing, and cost a build round each time. There is a real
57
+ * use for the value — a custom-UI page otherwise inherits whatever title
58
+ * the HTML shell shipped with — so it is honoured instead of banned.
59
+ */
60
+ name?: string;
50
61
  sidebar?: never;
51
62
  sidebarWidth?: never;
52
63
  tools?: never;
@@ -1,5 +1,121 @@
1
- import { t as client } from "./define-client-DqRs1XPd.js";
2
- import "./context.js";
3
- import "./components/sidebar-layout.js";
4
- import "./components/start-screen.js";
1
+ import { r as fetchClientConfig, t as createSessionCore } from "./session-core-Bjkr4PAP.js";
2
+ import { SessionProvider, ThemeProvider } from "./context.js";
3
+ import { t as ChatView } from "./chat-view-CW-tZCMm.js";
4
+ import { n as ToolConfigContext } from "./tool-call-block-DIxpG8GM.js";
5
+ import { SidebarLayout } from "./components/sidebar-layout.js";
6
+ import { StartScreen } from "./components/start-screen.js";
7
+ import { createElement, useEffect, useState } from "react";
8
+ import { jsx } from "react/jsx-runtime";
9
+ import { flushSync } from "react-dom";
10
+ import { createRoot } from "react-dom/client";
11
+ //#region define-client.tsx
12
+ /** @jsxImportSource react */
13
+ function resolveContainer(target = "#app") {
14
+ if (typeof target !== "string") return target;
15
+ const el = document.querySelector(target);
16
+ if (!el) throw new Error(`Element not found: ${target}`);
17
+ return el;
18
+ }
19
+ /**
20
+ * Default shell rendered in config tier.
21
+ * Wraps StartScreen → (SidebarLayout →) ChatView.
22
+ */
23
+ function DefaultShell({ name, Sidebar, sidebarWidth }) {
24
+ const chat = /* @__PURE__ */ jsx(ChatView, { title: name });
25
+ return /* @__PURE__ */ jsx(StartScreen, {
26
+ title: name,
27
+ children: Sidebar ? /* @__PURE__ */ jsx(SidebarLayout, {
28
+ sidebar: /* @__PURE__ */ jsx(Sidebar, {}),
29
+ sidebarWidth,
30
+ children: chat
31
+ }) : chat
32
+ });
33
+ }
34
+ /**
35
+ * Config-tier root: fetches the server-declared display name via
36
+ * `GET client-config` and renders the chat shell. The shell renders
37
+ * immediately — optimistically — while the lookup is in flight; servers
38
+ * without the endpoint (every lookup failure resolves to the empty default)
39
+ * work exactly as before.
40
+ */
41
+ function DefaultRoot({ platformUrl, name, Sidebar, sidebarWidth }) {
42
+ const [resolved, setResolved] = useState(null);
43
+ useEffect(() => {
44
+ let cancelled = false;
45
+ fetchClientConfig(platformUrl).then((cfg) => {
46
+ if (!cancelled) setResolved(cfg);
47
+ });
48
+ return () => {
49
+ cancelled = true;
50
+ };
51
+ }, [platformUrl]);
52
+ return /* @__PURE__ */ jsx(DefaultShell, {
53
+ name: name ?? resolved?.name,
54
+ Sidebar,
55
+ sidebarWidth
56
+ });
57
+ }
58
+ /**
59
+ * Define and mount a client UI for a voice agent.
60
+ *
61
+ * **Tier 1 (config-only):** Pass options without `component` to get the
62
+ * default shell (StartScreen + ChatView, optional sidebar).
63
+ *
64
+ * **Tier 2 (custom component):** Pass `component` to render a fully custom
65
+ * root component inside the providers.
66
+ *
67
+ * @example Tier 1
68
+ * ```tsx
69
+ * client({
70
+ * name: "Pizza Ordering",
71
+ * theme: { bg: "#1a1a1a", primary: "#e55" },
72
+ * sidebar: OrderPanel,
73
+ * tools: { add_pizza: { icon: "🍕", label: "Adding pizza" } },
74
+ * });
75
+ * ```
76
+ *
77
+ * @example Tier 2
78
+ * ```tsx
79
+ * client({ component: MyCustomApp });
80
+ * ```
81
+ *
82
+ * @returns A {@link ClientHandle} for cleanup.
83
+ * @throws If the target element is not found in the DOM.
84
+ *
85
+ * @public
86
+ */
87
+ function client(config) {
88
+ const container = resolveContainer(config.target);
89
+ const platformUrl = config.platformUrl ?? globalThis.location.origin + globalThis.location.pathname;
90
+ const session = createSessionCore({
91
+ platformUrl,
92
+ onSessionId: config.onSessionId,
93
+ resumeSessionId: config.resumeSessionId,
94
+ WebSocket: config.WebSocket
95
+ });
96
+ if (config.name && typeof document !== "undefined") document.title = config.name;
97
+ const rootNode = config.component ? createElement(config.component) : createElement(DefaultRoot, {
98
+ platformUrl,
99
+ name: config.name,
100
+ Sidebar: config.sidebar,
101
+ sidebarWidth: config.sidebarWidth
102
+ });
103
+ const toolConfig = config.tools ?? {};
104
+ const root = createRoot(container);
105
+ flushSync(() => {
106
+ root.render(createElement(ToolConfigContext.Provider, { value: toolConfig }, createElement(ThemeProvider, { value: config.theme }, createElement(SessionProvider, { value: session }, rootNode))));
107
+ });
108
+ const handle = {
109
+ session,
110
+ dispose() {
111
+ root.unmount();
112
+ session[Symbol.dispose]();
113
+ },
114
+ [Symbol.dispose]() {
115
+ handle.dispose();
116
+ }
117
+ };
118
+ return handle;
119
+ }
120
+ //#endregion
5
121
  export { client };
package/dist/hooks.d.ts CHANGED
@@ -1,6 +1,44 @@
1
+ import type { DefaultToolResult } from "@alexkroman1/aai";
1
2
  import type { ToolCallInfo } from "./types.ts";
2
- export declare function useToolResult<R = unknown>(toolName: string, callback: (result: R, toolCall: ToolCallInfo) => void): void;
3
- export declare function useToolResult(callback: (name: string, result: unknown, toolCall: ToolCallInfo) => void): void;
3
+ /**
4
+ * Fire a callback when a tool call settles, with the tool's JSON result.
5
+ *
6
+ * @typeParam R - The result shape. Defaults to {@link DefaultToolResult} —
7
+ * `any`, for the same reason `ctx.state` is: the value is the author's own
8
+ * tool output round-tripped through JSON, so `unknown` (which this was) made
9
+ * the ordinary spelling
10
+ *
11
+ * ```tsx
12
+ * useToolResult("get_quote", (r) => setPrice(r.price))
13
+ * ```
14
+ *
15
+ * a compile error on a client that runs correctly, and `aai build` refuses
16
+ * to publish it. Pass the shape — `useToolResult<Quote>(…)` — to get real
17
+ * checking, which also documents what the tool returns.
18
+ */
19
+ export declare function useToolResult<R = DefaultToolResult>(toolName: string, callback: (result: R, toolCall: ToolCallInfo) => void): void;
20
+ export declare function useToolResult<R = DefaultToolResult>(callback: (name: string, result: R, toolCall: ToolCallInfo) => void): void;
21
+ /**
22
+ * The agent's projected session state, or `null` before the first push.
23
+ *
24
+ * The counterpart to `syncState` on the agent: whatever that projection
25
+ * returns is what arrives here. It replaces the pattern this exists to
26
+ * remove — returning a state snapshot from every tool, declaring a type for
27
+ * what those tools happen to return, and mirroring it into `useState` through
28
+ * `useToolResult`, which was three things to keep in step and which 58% of
29
+ * measured generated agents built by hand.
30
+ *
31
+ * ```tsx
32
+ * const cart = useAgentState<{ cart: Item[] }>();
33
+ * return <Cart items={cart?.cart ?? []} />;
34
+ * ```
35
+ *
36
+ * Typed by the caller for the same reason `useToolResult` is: the shape is
37
+ * the author's own projection, which the framework cannot see. It is
38
+ * nullable on purpose — nothing has been pushed before the first tool call,
39
+ * and a UI has to render that moment.
40
+ */
41
+ export declare function useAgentState<S = DefaultToolResult>(): S | null;
4
42
  export declare function useEvent<T = unknown>(event: string, callback: (data: T) => void): void;
5
43
  export declare function useToolCallStart(toolName: string, callback: (toolCall: ToolCallInfo) => void): void;
6
44
  export declare function useToolCallStart(callback: (toolCall: ToolCallInfo) => void): void;
package/dist/hooks.js CHANGED
@@ -77,6 +77,29 @@ function useToolResult(...args) {
77
77
  else callback(tc.name, parsed, tc);
78
78
  });
79
79
  }
80
+ /**
81
+ * The agent's projected session state, or `null` before the first push.
82
+ *
83
+ * The counterpart to `syncState` on the agent: whatever that projection
84
+ * returns is what arrives here. It replaces the pattern this exists to
85
+ * remove — returning a state snapshot from every tool, declaring a type for
86
+ * what those tools happen to return, and mirroring it into `useState` through
87
+ * `useToolResult`, which was three things to keep in step and which 58% of
88
+ * measured generated agents built by hand.
89
+ *
90
+ * ```tsx
91
+ * const cart = useAgentState<{ cart: Item[] }>();
92
+ * return <Cart items={cart?.cart ?? []} />;
93
+ * ```
94
+ *
95
+ * Typed by the caller for the same reason `useToolResult` is: the shape is
96
+ * the author's own projection, which the framework cannot see. It is
97
+ * nullable on purpose — nothing has been pushed before the first tool call,
98
+ * and a UI has to render that moment.
99
+ */
100
+ function useAgentState() {
101
+ return useSessionSelector((snapshot) => snapshot.agentState);
102
+ }
80
103
  function useEvent(event, callback) {
81
104
  const customEvents = useSessionSelector((s) => s.customEvents);
82
105
  const watermarkRef = useRef(0);
@@ -102,4 +125,4 @@ function useToolCallStart(...args) {
102
125
  });
103
126
  }
104
127
  //#endregion
105
- export { useEvent, useToolCallStart, useToolResult };
128
+ export { useAgentState, useEvent, useToolCallStart, useToolResult };
package/dist/index.d.ts CHANGED
@@ -13,8 +13,8 @@ export type { Session } from "./context.ts";
13
13
  export { SessionProvider, ThemeProvider, useSession, useSessionSelector, useTheme, } from "./context.ts";
14
14
  export type { ClientConfig, ClientHandle, } from "./define-client.tsx";
15
15
  export { client } from "./define-client.tsx";
16
- export { useEvent, useToolCallStart, useToolResult } from "./hooks.ts";
16
+ export { useAgentState, useEvent, useToolCallStart, useToolResult } from "./hooks.ts";
17
17
  export { createSessionCore } from "./session-core.ts";
18
18
  export type { CustomEvent, SessionCore, SessionCoreOptions, SessionSnapshot, } from "./session-core-types.ts";
19
- export type { AgentState, ChatMessage, ClientTheme, SessionError, SessionErrorCode, ToolCallInfo, VoiceSessionOptions, WebSocketConstructor, } from "./types.ts";
19
+ export type { AgentState, ChatMessage, ClientTheme, SessionError, SessionErrorCode, ToolCallInfo, VoiceSessionOptions, } from "./types.ts";
20
20
  export { VOICE_CAPTURE_CONSTRAINTS } from "./types.ts";
package/dist/index.js CHANGED
@@ -1,4 +1,4 @@
1
- import { n as buildAgentUrl, r as fetchClientConfig, t as client } from "./define-client-DqRs1XPd.js";
1
+ import { n as buildAgentUrl, r as fetchClientConfig, t as createSessionCore } from "./session-core-Bjkr4PAP.js";
2
2
  import { SessionProvider, ThemeProvider, useSession, useSessionSelector, useTheme } from "./context.js";
3
3
  import { Button } from "./components/button.js";
4
4
  import { t as ChatView } from "./chat-view-CW-tZCMm.js";
@@ -7,7 +7,7 @@ import { n as Markdown, t as MessageList } from "./message-list-q2lTiFhF.js";
7
7
  import { n as ToolConfigContext } from "./tool-call-block-DIxpG8GM.js";
8
8
  import { SidebarLayout } from "./components/sidebar-layout.js";
9
9
  import { StartScreen } from "./components/start-screen.js";
10
- import { t as createSessionCore } from "./session-core-CkZ8LyEA.js";
11
10
  import { VOICE_CAPTURE_CONSTRAINTS } from "./types.js";
12
- import { useEvent, useToolCallStart, useToolResult } from "./hooks.js";
13
- export { ApiUrlChip, Button, ChatView, Controls, Markdown, MessageList, SessionProvider, SessionUrlChips, SidebarLayout, StartScreen, ThemeProvider, ToolConfigContext, UiUrlChip, VOICE_CAPTURE_CONSTRAINTS, buildAgentUrl, client, createSessionCore, fetchClientConfig, useEvent, useSession, useSessionSelector, useTheme, useToolCallStart, useToolResult };
11
+ import { client } from "./define-client.js";
12
+ import { useAgentState, useEvent, useToolCallStart, useToolResult } from "./hooks.js";
13
+ export { ApiUrlChip, Button, ChatView, Controls, Markdown, MessageList, SessionProvider, SessionUrlChips, SidebarLayout, StartScreen, ThemeProvider, ToolConfigContext, UiUrlChip, VOICE_CAPTURE_CONSTRAINTS, buildAgentUrl, client, createSessionCore, fetchClientConfig, useAgentState, useEvent, useSession, useSessionSelector, useTheme, useToolCallStart, useToolResult };
@@ -1,7 +1,36 @@
1
1
  import { MIC_SEND_MAX_BUFFERED_BYTES } from "./types.js";
2
- import { ServerMessageSchema, lenientParse } from "@alexkroman1/aai/protocol";
3
- import { DEFAULT_MAX_HISTORY, WS_OPEN, errorMessage, safeJsonParse } from "@alexkroman1/aai";
2
+ import { CLIENT_CONFIG_PATH, ClientConfigResponseSchema, ServerMessageSchema, lenientParse } from "@alexkroman1/aai/protocol";
3
+ import { DEFAULT_MAX_HISTORY, WS_OPEN, createEpoch, errorMessage, safeJsonParse } from "@alexkroman1/aai";
4
4
  import ReconnectingWebSocket from "partysocket/ws";
5
+ //#region client-config.ts
6
+ /**
7
+ * Pre-connection client-config lookup.
8
+ *
9
+ * `GET client-config` (relative to the agent's base URL — see
10
+ * `sdk/client-config.ts` in `@alexkroman1/aai`) gives the default client the
11
+ * agent's display name and greeting before any connection exists. Every
12
+ * failure path — network error, 404 from an older server, malformed body —
13
+ * degrades to the empty default, so this lookup can never break an existing
14
+ * agent.
15
+ */
16
+ /** Resolve a relative endpoint path against the agent's base URL. */
17
+ function buildAgentUrl(platformUrl, endpointPath) {
18
+ return new URL(endpointPath, platformUrl.endsWith("/") ? platformUrl : `${platformUrl}/`);
19
+ }
20
+ const AGENT_DEFAULT = {};
21
+ /** Fetch the agent's client config; any failure yields the agent default. */
22
+ async function fetchClientConfig(platformUrl, fetchFn) {
23
+ const doFetch = fetchFn ?? ((input, init) => globalThis.fetch(input, init));
24
+ try {
25
+ const resp = await doFetch(buildAgentUrl(platformUrl, CLIENT_CONFIG_PATH).href);
26
+ if (!resp.ok) return AGENT_DEFAULT;
27
+ const parsed = ClientConfigResponseSchema.safeParse(await resp.json());
28
+ return parsed.success ? parsed.data : AGENT_DEFAULT;
29
+ } catch {
30
+ return AGENT_DEFAULT;
31
+ }
32
+ }
33
+ //#endregion
5
34
  //#region session-core-audio-setup.ts
6
35
  /**
7
36
  * Audio-path initialization for the voice session core.
@@ -29,8 +58,8 @@ import ReconnectingWebSocket from "partysocket/ws";
29
58
  async function initAudioCapture(conn, msg, deps) {
30
59
  if (conn.audioSetupInFlight) return;
31
60
  conn.audioSetupInFlight = true;
32
- const gen = conn.generation;
33
- const stale = () => conn.generation !== gen || !conn.ws || conn.ws.readyState !== WS_OPEN;
61
+ const gen = conn.generation.current();
62
+ const stale = () => !(conn.generation.isCurrent(gen) && conn.ws) || conn.ws.readyState !== WS_OPEN;
34
63
  const reportAudioFailure = (message) => {
35
64
  deps.cleanupAudio();
36
65
  deps.updateState({
@@ -68,7 +97,7 @@ async function initAudioCapture(conn, msg, deps) {
68
97
  console.warn("[aai-ui] microphone is delivering only silence — check the selected input device");
69
98
  },
70
99
  onError: (err) => {
71
- if (conn.generation !== gen) return;
100
+ if (!conn.generation.isCurrent(gen)) return;
72
101
  reportAudioFailure(err.message);
73
102
  }
74
103
  });
@@ -92,7 +121,7 @@ async function initAudioCapture(conn, msg, deps) {
92
121
  if (stale()) return;
93
122
  reportAudioFailure(`Microphone access failed: ${errorMessage(err)}`);
94
123
  } finally {
95
- if (conn.generation === gen) conn.audioSetupInFlight = false;
124
+ if (conn.generation.isCurrent(gen)) conn.audioSetupInFlight = false;
96
125
  }
97
126
  }
98
127
  //#endregion
@@ -124,6 +153,7 @@ const CLEARED_SESSION_STATE = {
124
153
  messages: [],
125
154
  toolCalls: [],
126
155
  customEvents: [],
156
+ agentState: null,
127
157
  userTranscript: null,
128
158
  agentTranscript: null,
129
159
  error: null
@@ -137,14 +167,14 @@ function appendCapped(list, item, cap) {
137
167
  /**
138
168
  * Create the server→client message handlers for one session core.
139
169
  *
140
- * Encapsulates the two turn-boundary counters (`handlerGeneration` for
170
+ * Encapsulates the two turn-boundary counters (`turnEpoch` for
141
171
  * discarding stale async audio completions, `customEventSeq` for event
142
172
  * dedup) that previously lived as closure locals in `createSessionCore`.
143
173
  */
144
174
  function createMessageHandlers(deps) {
145
175
  const { getSnapshot, updateState, conn, cleanupAudio } = deps;
146
- /** Incremented on each turn boundary -- stale async callbacks compare against this. */
147
- let handlerGeneration = 0;
176
+ /** Bumped on each turn boundary -- stale async callbacks check against this. */
177
+ const turnEpoch = createEpoch();
148
178
  /** Monotonically increasing counter for custom events -- used by useEvent to deduplicate. */
149
179
  let customEventSeq = 0;
150
180
  /** Monotonically increasing counter for chat messages -- stable render keys
@@ -161,7 +191,7 @@ function createMessageHandlers(deps) {
161
191
  }, MAX_CUSTOM_EVENTS) });
162
192
  }
163
193
  function handleUserTranscriptEvent(text) {
164
- handlerGeneration++;
194
+ turnEpoch.bump();
165
195
  updateState({
166
196
  userTranscript: null,
167
197
  messages: appendCapped(getSnapshot().messages, {
@@ -221,7 +251,7 @@ function createMessageHandlers(deps) {
221
251
  } });
222
252
  else {
223
253
  cleanupAudio();
224
- conn.generation++;
254
+ conn.generation.bump();
225
255
  updateState({
226
256
  state: "error",
227
257
  error: {
@@ -280,7 +310,7 @@ function createMessageHandlers(deps) {
280
310
  updateState({ state: "listening" });
281
311
  break;
282
312
  case "cancelled":
283
- handlerGeneration++;
313
+ turnEpoch.bump();
284
314
  conn.voiceIO?.flush();
285
315
  commitAgentTranscript();
286
316
  updateState({
@@ -289,7 +319,7 @@ function createMessageHandlers(deps) {
289
319
  });
290
320
  break;
291
321
  case "reset":
292
- handlerGeneration++;
322
+ turnEpoch.bump();
293
323
  conn.voiceIO?.flush();
294
324
  updateState({
295
325
  ...CLEARED_SESSION_STATE,
@@ -299,6 +329,9 @@ function createMessageHandlers(deps) {
299
329
  case "custom_event":
300
330
  appendCustomEvent(e.event, e.data);
301
331
  break;
332
+ case "agent_state":
333
+ updateState({ agentState: e.state });
334
+ break;
302
335
  case "error":
303
336
  handleErrorEvent(e);
304
337
  break;
@@ -314,12 +347,12 @@ function createMessageHandlers(deps) {
314
347
  else if (conn.preInitAudio.length < MAX_PREINIT_AUDIO_CHUNKS) conn.preInitAudio.push(chunk);
315
348
  }
316
349
  /** See {@link MessageHandlers.settleWhenAudioDrained}. Captures
317
- * `handlerGeneration` so a completion (or failure) that lands after a turn
350
+ * `turnEpoch` so a completion (or failure) that lands after a turn
318
351
  * boundary is discarded instead of overwriting the newer turn's state. */
319
352
  function settleWhenAudioDrained(io) {
320
- const gen = handlerGeneration;
353
+ const gen = turnEpoch.current();
321
354
  io.done().then(() => {
322
- if (handlerGeneration !== gen) return;
355
+ if (!turnEpoch.isCurrent(gen)) return;
323
356
  updateState({ state: "listening" });
324
357
  }).catch((err) => {
325
358
  console.warn("Audio playback done failed:", err);
@@ -328,7 +361,7 @@ function createMessageHandlers(deps) {
328
361
  /**
329
362
  * Signal that the server has finished sending audio for this turn.
330
363
  * Waits for the audio queue to drain, then transitions state to `"listening"`.
331
- * Uses the `handlerGeneration` counter to discard stale completions from interrupted turns.
364
+ * Uses the `turnEpoch` epoch to discard stale completions from interrupted turns.
332
365
  */
333
366
  function playAudioDone() {
334
367
  const io = conn.voiceIO;
@@ -395,8 +428,9 @@ const RECONNECT_OPTIONS = {
395
428
  };
396
429
  /**
397
430
  * Open partysocket's reconnecting WebSocket. The URL is a *provider*,
398
- * re-evaluated on every attempt, so each retry picks up the current resume
399
- * URL rather than the one the session started with.
431
+ * re-evaluated on every attempt (async supported), so each retry picks up
432
+ * the current broker-named endpoint and resume URL rather than the ones the
433
+ * session started with.
400
434
  */
401
435
  function openReconnectingSocket(urlProvider) {
402
436
  return new ReconnectingWebSocket(urlProvider, void 0, RECONNECT_OPTIONS);
@@ -414,8 +448,21 @@ function reconnectPending(socket) {
414
448
  //#region session-core-url.ts
415
449
  /** Build the session WebSocket URL from the platform URL and resume state. */
416
450
  function buildWsUrl(platformUrl, resume, sessionId) {
417
- const wsUrl = new URL("websocket", platformUrl.endsWith("/") ? platformUrl : `${platformUrl}/`);
418
- wsUrl.protocol = wsUrl.protocol === "https:" ? "wss:" : "ws:";
451
+ return applyResumeParams(new URL("websocket", platformUrl.endsWith("/") ? platformUrl : `${platformUrl}/`), resume, sessionId);
452
+ }
453
+ /**
454
+ * Turn a broker-provided session URL (`sessionUrl` from `GET client-config`
455
+ * — the agent's live sandbox endpoint) into this attempt's connect URL.
456
+ */
457
+ function buildBrokeredWsUrl(sessionUrl, resume, sessionId) {
458
+ return applyResumeParams(new URL(sessionUrl), resume, sessionId);
459
+ }
460
+ const WS_PROTOCOLS = {
461
+ "https:": "wss:",
462
+ "http:": "ws:"
463
+ };
464
+ function applyResumeParams(wsUrl, resume, sessionId) {
465
+ wsUrl.protocol = WS_PROTOCOLS[wsUrl.protocol] ?? wsUrl.protocol;
419
466
  if (sessionId) wsUrl.searchParams.set("sessionId", sessionId);
420
467
  else if (resume) wsUrl.searchParams.set("resume", "1");
421
468
  return wsUrl;
@@ -491,7 +538,7 @@ function createSessionCore(options) {
491
538
  ws: null,
492
539
  voiceIO: null,
493
540
  audioSetupInFlight: false,
494
- generation: 0,
541
+ generation: createEpoch(),
495
542
  preInitAudio: [],
496
543
  preInitDone: false
497
544
  };
@@ -506,6 +553,15 @@ function createSessionCore(options) {
506
553
  * agent's context, greeting suppression aside.
507
554
  */
508
555
  let sessionId = options.resumeSessionId;
556
+ /**
557
+ * Whether `platformUrl` is a broker (its `client-config` names a
558
+ * `sessionUrl`). A server is one or it isn't — it never flips mid-session
559
+ * — so once a non-broker is observed, later reconnects skip the
560
+ * `client-config` re-fetch that would only fall through to `buildWsUrl`
561
+ * (every reconnect on `aai dev` / self-hosted otherwise pays a wasted GET).
562
+ * `undefined` until the first fetch settles.
563
+ */
564
+ let serverIsBroker;
509
565
  function cleanupAudio() {
510
566
  conn.audioSetupInFlight = false;
511
567
  conn.voiceIO?.close().catch(() => {});
@@ -565,19 +621,34 @@ function createSessionCore(options) {
565
621
  }
566
622
  /**
567
623
  * The WebSocket URL for the *next* connection attempt. Evaluated per
568
- * attempt (partysocket takes it as a URL provider), so once the first
569
- * `config` arrives, every reconnect — automatic or explicit — carries
570
- * `?sessionId=<id>` and the server resumes the SAME session (id, tool
571
- * state) instead of minting a new one. `resume=1` remains only as the
572
- * greeting-suppression fallback for a server whose config carried no id.
624
+ * attempt (partysocket takes it as an async URL provider):
625
+ *
626
+ * - `GET client-config` is re-fetched every attempt. When it names a
627
+ * `sessionUrl` — the platform's broker pointing at the agent's live
628
+ * sandbox — the session connects DIRECTLY there. The URL changes when
629
+ * the sandbox is replaced (idle eviction, redeploy), which is exactly
630
+ * when a reconnect happens, so per-attempt brokering is what makes
631
+ * reconnects land on the replacement. Without one (`aai dev`, older
632
+ * servers), the same-origin `websocket` path is used.
633
+ * - Once the first `config` arrives, every reconnect carries
634
+ * `?sessionId=<id>` and the server resumes the SAME session (id, tool
635
+ * state) instead of minting a new one. `resume=1` remains only as the
636
+ * greeting-suppression fallback for a server whose config carried no id.
573
637
  */
574
- function currentWsUrl() {
575
- return buildWsUrl(options.platformUrl, hasConnected, sessionId).toString();
638
+ async function currentWsUrl() {
639
+ const cfg = serverIsBroker === false ? {} : await fetchClientConfig(options.platformUrl);
640
+ serverIsBroker = cfg.sessionUrl !== void 0;
641
+ const url = cfg.sessionUrl ? buildBrokeredWsUrl(cfg.sessionUrl, hasConnected, sessionId) : buildWsUrl(options.platformUrl, hasConnected, sessionId);
642
+ const display = new URL(url);
643
+ display.search = "";
644
+ if (display.toString() !== currentSnapshot.apiUrl) updateState({ apiUrl: display.toString() });
645
+ return url.toString();
576
646
  }
577
- /** Open a socket: an injected constructor as-is (tests), or partysocket's
578
- * reconnecting WebSocket — same interface, plus reconnect-on-close. */
647
+ /** Open a socket: an injected constructor as-is (tests — connects to the
648
+ * same-origin path, no brokering), or partysocket's reconnecting
649
+ * WebSocket — same interface, plus reconnect-on-close. */
579
650
  function openSocket() {
580
- if (options.WebSocket) return new options.WebSocket(currentWsUrl());
651
+ if (options.WebSocket) return new options.WebSocket(buildWsUrl(options.platformUrl, hasConnected, sessionId).toString());
581
652
  return openReconnectingSocket(currentWsUrl);
582
653
  }
583
654
  function connect(opts) {
@@ -590,7 +661,7 @@ function createSessionCore(options) {
590
661
  error: null
591
662
  });
592
663
  teardownConnection();
593
- conn.generation++;
664
+ conn.generation.bump();
594
665
  const controller = new AbortController();
595
666
  connectionController = controller;
596
667
  const { signal: sig } = controller;
@@ -613,7 +684,7 @@ function createSessionCore(options) {
613
684
  if (sig.aborted) return;
614
685
  cleanupAudio();
615
686
  if (reconnectPending(socket)) {
616
- conn.generation++;
687
+ conn.generation.bump();
617
688
  socketErrored = false;
618
689
  updateState({
619
690
  state: "connecting",
@@ -700,4 +771,4 @@ function createSessionCore(options) {
700
771
  };
701
772
  }
702
773
  //#endregion
703
- export { createSessionCore as t };
774
+ export { buildAgentUrl as n, fetchClientConfig as r, createSessionCore as t };
@@ -9,6 +9,7 @@ export declare const CLEARED_SESSION_STATE: {
9
9
  messages: never[];
10
10
  toolCalls: never[];
11
11
  customEvents: never[];
12
+ agentState: null;
12
13
  userTranscript: null;
13
14
  agentTranscript: null;
14
15
  error: null;
@@ -50,7 +51,7 @@ type MessageHandlers = {
50
51
  /**
51
52
  * Create the server→client message handlers for one session core.
52
53
  *
53
- * Encapsulates the two turn-boundary counters (`handlerGeneration` for
54
+ * Encapsulates the two turn-boundary counters (`turnEpoch` for
54
55
  * discarding stale async audio completions, `customEventSeq` for event
55
56
  * dedup) that previously lived as closure locals in `createSessionCore`.
56
57
  */
@@ -6,10 +6,11 @@
6
6
  import ReconnectingWebSocket from "partysocket/ws";
7
7
  /**
8
8
  * Open partysocket's reconnecting WebSocket. The URL is a *provider*,
9
- * re-evaluated on every attempt, so each retry picks up the current resume
10
- * URL rather than the one the session started with.
9
+ * re-evaluated on every attempt (async supported), so each retry picks up
10
+ * the current broker-named endpoint and resume URL rather than the ones the
11
+ * session started with.
11
12
  */
12
- export declare function openReconnectingSocket(urlProvider: () => string): ReconnectingWebSocket;
13
+ export declare function openReconnectingSocket(urlProvider: () => Promise<string>): ReconnectingWebSocket;
13
14
  /**
14
15
  * True while `socket` is a reconnecting socket that will retry after the
15
16
  * close event currently being handled. partysocket schedules the retry
@@ -3,6 +3,7 @@
3
3
  *
4
4
  * Split out of `session-core.ts` to keep that module focused on behaviour.
5
5
  */
6
+ import type { Epoch } from "@alexkroman1/aai";
6
7
  import type { VoiceIO } from "./audio.ts";
7
8
  import type { AgentState, ChatMessage, SessionError, ToolCallInfo, VoiceSessionOptions, WebSocketConstructor } from "./types.ts";
8
9
  /**
@@ -43,6 +44,12 @@ export type SessionSnapshot = {
43
44
  readonly messages: ChatMessage[];
44
45
  readonly toolCalls: ToolCallInfo[];
45
46
  readonly customEvents: CustomEvent[];
47
+ /**
48
+ * Latest state the agent projected via `syncState`, or `null` before the
49
+ * first push. A value, not a log — a component that mounts mid-session
50
+ * reads current state rather than replaying events it missed.
51
+ */
52
+ readonly agentState: unknown;
46
53
  readonly userTranscript: string | null;
47
54
  readonly agentTranscript: string | null;
48
55
  readonly error: SessionError | null;
@@ -97,9 +104,10 @@ export type ConnState = {
97
104
  ws: InstanceType<WebSocketConstructor> | null;
98
105
  voiceIO: VoiceIO | null;
99
106
  audioSetupInFlight: boolean;
100
- /** Monotonically increasing counter bumped on each connect(). Prevents a stale
101
- * initAudioCapture from assigning its voiceIO to a newer connection. */
102
- generation: number;
107
+ /** Connection epoch, bumped on each connect()/retry (see `createEpoch`).
108
+ * Prevents a stale initAudioCapture from assigning its voiceIO to a newer
109
+ * connection. */
110
+ generation: Epoch;
103
111
  /** Audio chunks that arrived before `voiceIO` was initialized — drained into
104
112
  * the playback worklet once init completes. Closes the race between the
105
113
  * server starting greeting audio (immediately on S2S connect) and the
@@ -1,2 +1,7 @@
1
1
  /** Build the session WebSocket URL from the platform URL and resume state. */
2
2
  export declare function buildWsUrl(platformUrl: string, resume: boolean, sessionId?: string): URL;
3
+ /**
4
+ * Turn a broker-provided session URL (`sessionUrl` from `GET client-config`
5
+ * — the agent's live sandbox endpoint) into this attempt's connect URL.
6
+ */
7
+ export declare function buildBrokeredWsUrl(sessionUrl: string, resume: boolean, sessionId?: string): URL;
@@ -1,2 +1,2 @@
1
- import { t as createSessionCore } from "./session-core-CkZ8LyEA.js";
1
+ import { t as createSessionCore } from "./session-core-Bjkr4PAP.js";
2
2
  export { createSessionCore };