@runtypelabs/persona 4.6.0 → 4.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/client.ts CHANGED
@@ -17,6 +17,7 @@ import {
17
17
  ClientSession,
18
18
  ClientInitResponse,
19
19
  ClientChatRequest,
20
+ ClientToolDefinition,
20
21
  ClientFeedbackRequest,
21
22
  ClientFeedbackType,
22
23
  PersonaArtifactKind,
@@ -180,6 +181,14 @@ export class AgentWidgetClient {
180
181
  // (silent re-init / expiry) forces a fresh full send.
181
182
  private lastSentClientToolsFingerprint: string | null = null;
182
183
  private clientToolsFingerprintSessionId: string | null = null;
184
+ // Session under which a non-empty clientTools[] was last committed and not
185
+ // yet confirmed cleared server-side. Distinct from the fingerprint above:
186
+ // an empty-tool chat turn commits a null fingerprint, but OMITTING the
187
+ // fields on chat doesn't clear the tools persisted for a still-paused
188
+ // execution — so a later resume with an empty registry must still send the
189
+ // explicit `clientTools: []` replace. Reset only by an explicit [] replace,
190
+ // a session change, or a conversation reset.
191
+ private sentNonEmptyClientToolsSessionId: string | null = null;
183
192
 
184
193
  // WebMCP: page-discovered tool consumption (see ./webmcp-bridge).
185
194
  // Constructed lazily: null when `config.webmcp?.enabled !== true`.
@@ -438,6 +447,7 @@ export class AgentWidgetClient {
438
447
  public resetClientToolsFingerprint(): void {
439
448
  this.lastSentClientToolsFingerprint = null;
440
449
  this.clientToolsFingerprintSessionId = null;
450
+ this.sentNonEmptyClientToolsSessionId = null;
441
451
  }
442
452
 
443
453
  /**
@@ -614,6 +624,7 @@ export class AgentWidgetClient {
614
624
  * Send a message - handles both proxy and client token modes
615
625
  */
616
626
  public async dispatch(options: DispatchOptions, onEvent: SSEHandler) {
627
+ options.signal?.throwIfAborted();
617
628
  if (this.isClientTokenMode()) {
618
629
  return this.dispatchClientToken(options, onEvent);
619
630
  }
@@ -627,11 +638,6 @@ export class AgentWidgetClient {
627
638
  * Client token mode dispatch
628
639
  */
629
640
  private async dispatchClientToken(options: DispatchOptions, onEvent: SSEHandler) {
630
- const controller = new AbortController();
631
- if (options.signal) {
632
- options.signal.addEventListener("abort", () => controller.abort());
633
- }
634
-
635
641
  onEvent({ type: "status", status: "connecting" });
636
642
 
637
643
  try {
@@ -678,71 +684,32 @@ export class AgentWidgetClient {
678
684
  };
679
685
 
680
686
  // Diff-only / send-once WebMCP tool dispatch. `buildPayload()` already
681
- // snapshotted the full set; decide whether to ship it again or just its
682
- // fingerprint. First turn of a session, or a changed set, sends the full
683
- // array; an unchanged set sends only the fingerprint and the server
684
- // reuses its stored copy. The cache is committed only after a successful
685
- // stream start (below), so a 409/failure leaves it untouched.
686
- const fullClientTools = basePayload.clientTools;
687
- const hasClientTools = !!(fullClientTools && fullClientTools.length > 0);
688
- const clientToolsFingerprint = hasClientTools
689
- ? computeClientToolsFingerprint(fullClientTools!)
690
- : undefined;
691
- const sameSession = this.clientToolsFingerprintSessionId === session.sessionId;
692
- const unchanged =
693
- hasClientTools && sameSession && this.lastSentClientToolsFingerprint === clientToolsFingerprint;
694
-
695
- // `forceFull` flips to true after a 409 cache-miss so the single retry
696
- // resends the full list. Capture any error body read inside the loop so
697
- // the `!response.ok` handler below doesn't re-consume the stream.
698
- let forceFull = false;
699
- let errorData: { error?: string; hint?: string } | null = null;
700
- let response: Response;
701
- for (let attempt = 0; ; attempt++) {
702
- const sendFull = hasClientTools && (forceFull || !unchanged);
703
- const chatRequest: ClientChatRequest = {
704
- ...baseChatRequest,
705
- ...(sendFull && fullClientTools ? { clientTools: fullClientTools } : {}),
706
- ...(clientToolsFingerprint ? { clientToolsFingerprint } : {}),
707
- };
708
-
709
- if (this.debug) {
710
- // eslint-disable-next-line no-console
711
- console.debug("[AgentWidgetClient] client token dispatch", chatRequest);
712
- }
687
+ // snapshotted the full set; `sendWithClientToolsDiff` decides whether to
688
+ // ship it again or just its fingerprint (retrying once on a 409 registry
689
+ // miss). The cache is committed only after a successful stream start
690
+ // (below), so a 409/failure leaves it untouched.
691
+ const { response, commit: commitClientToolsFingerprint } =
692
+ await this.sendWithClientToolsDiff(session.sessionId, basePayload.clientTools, (toolFields) => {
693
+ const chatRequest: ClientChatRequest = { ...baseChatRequest, ...toolFields };
694
+
695
+ if (this.debug) {
696
+ // eslint-disable-next-line no-console
697
+ console.debug("[AgentWidgetClient] client token dispatch", chatRequest);
698
+ }
713
699
 
714
- response = await fetch(this.getClientApiUrl('chat'), {
715
- method: 'POST',
716
- headers: {
717
- 'Content-Type': 'application/json',
718
- 'X-Persona-Version': VERSION,
719
- },
720
- body: JSON.stringify(chatRequest),
721
- signal: controller.signal,
700
+ return fetch(this.getClientApiUrl('chat'), {
701
+ method: 'POST',
702
+ headers: {
703
+ 'Content-Type': 'application/json',
704
+ 'X-Persona-Version': VERSION,
705
+ },
706
+ body: JSON.stringify(chatRequest),
707
+ signal: options.signal,
708
+ });
722
709
  });
723
710
 
724
- // Diff-only cache miss: the server has no stored tool set matching our
725
- // fingerprint. Retry exactly once with the full list. A second miss
726
- // falls through to the normal error handling below (no infinite loop).
727
- if (response.status === 409 && attempt === 0 && hasClientTools) {
728
- const body = (await response.json().catch(() => null)) as
729
- | { error?: string; hint?: string }
730
- | null;
731
- if (body?.error === 'client_tools_resend_required') {
732
- forceFull = true;
733
- // Invalidate so future turns also resend until a clean success
734
- // commits a fresh fingerprint.
735
- this.lastSentClientToolsFingerprint = null;
736
- continue;
737
- }
738
- // Some other 409: keep the parsed body for the handler below.
739
- errorData = body ?? { error: 'Chat request failed' };
740
- }
741
- break;
742
- }
743
-
744
711
  if (!response.ok) {
745
- const data = errorData ?? (await response.json().catch(() => ({ error: 'Chat request failed' })));
712
+ const data = await response.json().catch(() => ({ error: 'Chat request failed' }));
746
713
 
747
714
  if (response.status === 401) {
748
715
  // Session expired
@@ -773,8 +740,7 @@ export class AgentWidgetClient {
773
740
  // Stream is good: the server now holds this tool set under this
774
741
  // fingerprint for the session. Commit the cache so unchanged follow-up
775
742
  // turns can send fingerprint-only.
776
- this.lastSentClientToolsFingerprint = clientToolsFingerprint ?? null;
777
- this.clientToolsFingerprintSessionId = session.sessionId;
743
+ commitClientToolsFingerprint();
778
744
 
779
745
  onEvent({ type: "status", status: "connected" });
780
746
 
@@ -798,11 +764,6 @@ export class AgentWidgetClient {
798
764
  * Proxy mode dispatch (original implementation)
799
765
  */
800
766
  private async dispatchProxy(options: DispatchOptions, onEvent: SSEHandler) {
801
- const controller = new AbortController();
802
- if (options.signal) {
803
- options.signal.addEventListener("abort", () => controller.abort());
804
- }
805
-
806
767
  onEvent({ type: "status", status: "connecting" });
807
768
 
808
769
  const payload = await this.buildPayload(options.messages);
@@ -836,7 +797,7 @@ export class AgentWidgetClient {
836
797
  method: "POST",
837
798
  headers,
838
799
  body: JSON.stringify(payload),
839
- signal: controller.signal
800
+ signal: options.signal
840
801
  },
841
802
  payload
842
803
  );
@@ -850,7 +811,7 @@ export class AgentWidgetClient {
850
811
  method: "POST",
851
812
  headers,
852
813
  body: JSON.stringify(payload),
853
- signal: controller.signal
814
+ signal: options.signal
854
815
  });
855
816
  }
856
817
 
@@ -874,11 +835,6 @@ export class AgentWidgetClient {
874
835
  * Agent mode dispatch
875
836
  */
876
837
  private async dispatchAgent(options: DispatchOptions, onEvent: SSEHandler) {
877
- const controller = new AbortController();
878
- if (options.signal) {
879
- options.signal.addEventListener("abort", () => controller.abort());
880
- }
881
-
882
838
  onEvent({ type: "status", status: "connecting" });
883
839
 
884
840
  const payload = await this.buildAgentPayload(options.messages);
@@ -912,7 +868,7 @@ export class AgentWidgetClient {
912
868
  method: "POST",
913
869
  headers,
914
870
  body: JSON.stringify(payload),
915
- signal: controller.signal
871
+ signal: options.signal
916
872
  },
917
873
  payload as unknown as AgentWidgetRequestPayload
918
874
  );
@@ -926,7 +882,7 @@ export class AgentWidgetClient {
926
882
  method: "POST",
927
883
  headers,
928
884
  body: JSON.stringify(payload),
929
- signal: controller.signal
885
+ signal: options.signal
930
886
  });
931
887
  }
932
888
 
@@ -1007,8 +963,13 @@ export class AgentWidgetClient {
1007
963
  * - **client-token mode**: POST `${apiBase}/v1/client/resume` (the
1008
964
  * session-authenticated sibling of `/v1/client/chat`; runtypelabs/core#3889),
1009
965
  * with the active `sessionId` in the body and no Bearer key: a browser
1010
- * client-token page holds no secret. `clientTools` are already persisted
1011
- * server-side from the dispatch turn, so only `toolOutputs` is re-sent.
966
+ * client-token page holds no secret. The page's tool registry is
967
+ * re-snapshotted and sent alongside `toolOutputs` via the same diff-only
968
+ * `clientTools` / `clientToolsFingerprint` protocol as `/v1/client/chat`
969
+ * (runtypelabs/core#5361), so tools registered by a mid-run page
970
+ * navigation replace the run's dispatch-time set and become callable on
971
+ * the next model turn. Old servers strip the unknown fields and keep the
972
+ * frozen-at-dispatch behavior.
1012
973
  * - **dispatch / proxy mode**: POST `${apiUrl}/resume`: Runtype mounts
1013
974
  * resume as a child of `/v1/dispatch`, so the URL is `${apiUrl}/resume`,
1014
975
  * and proxies follow the same shape (`/api/chat/dispatch/resume`).
@@ -1020,6 +981,113 @@ export class AgentWidgetClient {
1020
981
  * @param toolOutputs - Map keyed by per-call `toolCallId` (core#3878),
1021
982
  * falling back to tool name for legacy servers → the tool's result value.
1022
983
  */
984
+ /**
985
+ * Diff-only / send-once WebMCP clientTools transport, shared by the
986
+ * client-token chat (`/v1/client/chat`) and resume (`/v1/client/resume`)
987
+ * paths — both routes speak the same protocol (runtypelabs/core#5361).
988
+ *
989
+ * Decides, against the shared fingerprint cache, whether this request ships
990
+ * the full `clientTools[]` + fingerprint (first send under this session, or
991
+ * a changed set) or the fingerprint alone (unchanged set; the server reuses
992
+ * its stored copy). Runs `doFetch` with the chosen fields and retries
993
+ * EXACTLY once with the full array on a
994
+ * `409 { error: 'client_tools_resend_required' }` registry miss — the retry
995
+ * is 409-*triggered*, never 409-*expected*, so servers predating the
996
+ * protocol (which strip the unknown fields and never 409) work unchanged.
997
+ * The 409 body is probed on a `clone()` so the original response body stays
998
+ * readable by the caller's error handling.
999
+ *
1000
+ * The cache is NOT committed here: callers invoke the returned `commit()`
1001
+ * only after the server has accepted the request (response OK / stream
1002
+ * started), so a failed request can never record a fingerprint the server
1003
+ * never stored. A resend-required miss invalidates the cached fingerprint
1004
+ * immediately so later turns keep resending in full until a clean success
1005
+ * commits a fresh one.
1006
+ *
1007
+ * `emptyMeansReplace` (resume only): when the live snapshot is empty but a
1008
+ * non-empty set was committed under this session and never explicitly
1009
+ * cleared (the paused tool navigated to a page with no tool registry), ship
1010
+ * an explicit `clientTools: []` so the server REPLACES the persisted
1011
+ * dispatch-time set with nothing and clears its stored registry. Chat keeps
1012
+ * its omit-when-empty behavior: on `/chat`, absent fields already mean "no
1013
+ * tools this turn", whereas on `/resume` absence means "keep the frozen
1014
+ * dispatch-time set".
1015
+ */
1016
+ private async sendWithClientToolsDiff(
1017
+ sessionId: string,
1018
+ fullClientTools: ClientToolDefinition[] | undefined,
1019
+ doFetch: (
1020
+ toolFields: Pick<ClientChatRequest, 'clientTools' | 'clientToolsFingerprint'>
1021
+ ) => Promise<Response>,
1022
+ opts?: { emptyMeansReplace?: boolean }
1023
+ ): Promise<{ response: Response; commit: () => void }> {
1024
+ const hasClientTools = !!(fullClientTools && fullClientTools.length > 0);
1025
+ const clientToolsFingerprint = hasClientTools
1026
+ ? computeClientToolsFingerprint(fullClientTools!)
1027
+ : undefined;
1028
+ const sameSession = this.clientToolsFingerprintSessionId === sessionId;
1029
+ const unchanged =
1030
+ hasClientTools && sameSession && this.lastSentClientToolsFingerprint === clientToolsFingerprint;
1031
+ // Keyed on `sentNonEmptyClientToolsSessionId`, NOT the fingerprint: an
1032
+ // interleaved empty-tool chat turn commits a null fingerprint without
1033
+ // clearing the tools persisted for a still-paused execution, so the
1034
+ // fingerprint alone would lose the pending clear. The dedicated flag
1035
+ // survives omitted-empty commits and is reset only once an explicit []
1036
+ // replace is confirmed.
1037
+ const sendEmptyReplace =
1038
+ !hasClientTools &&
1039
+ opts?.emptyMeansReplace === true &&
1040
+ this.sentNonEmptyClientToolsSessionId === sessionId;
1041
+
1042
+ // `forceFull` flips to true after a 409 cache-miss so the single retry
1043
+ // resends the full list.
1044
+ let forceFull = false;
1045
+ let response: Response;
1046
+ for (let attempt = 0; ; attempt++) {
1047
+ const sendFull = hasClientTools && (forceFull || !unchanged);
1048
+ response = await doFetch({
1049
+ ...(sendFull && fullClientTools ? { clientTools: fullClientTools } : {}),
1050
+ ...(sendEmptyReplace ? { clientTools: [] } : {}),
1051
+ ...(clientToolsFingerprint ? { clientToolsFingerprint } : {}),
1052
+ });
1053
+
1054
+ // Diff-only cache miss: the server has no stored tool set matching our
1055
+ // fingerprint. Retry exactly once with the full list. A second miss
1056
+ // falls through to the caller's normal error handling (no infinite loop).
1057
+ if (response.status === 409 && attempt === 0 && hasClientTools) {
1058
+ const body = (await response
1059
+ .clone()
1060
+ .json()
1061
+ .catch(() => null)) as { error?: string } | null;
1062
+ if (body?.error === 'client_tools_resend_required') {
1063
+ forceFull = true;
1064
+ // Invalidate so future turns also resend until a clean success
1065
+ // commits a fresh fingerprint.
1066
+ this.lastSentClientToolsFingerprint = null;
1067
+ continue;
1068
+ }
1069
+ }
1070
+ break;
1071
+ }
1072
+
1073
+ return {
1074
+ response,
1075
+ commit: () => {
1076
+ this.lastSentClientToolsFingerprint = clientToolsFingerprint ?? null;
1077
+ this.clientToolsFingerprintSessionId = sessionId;
1078
+ if (hasClientTools) {
1079
+ this.sentNonEmptyClientToolsSessionId = sessionId;
1080
+ } else if (sendEmptyReplace) {
1081
+ // The explicit [] replaced the persisted set server-side; the
1082
+ // pending clear is done.
1083
+ this.sentNonEmptyClientToolsSessionId = null;
1084
+ }
1085
+ // Omitted-empty commits (chat with zero tools) leave the flag set:
1086
+ // they don't touch tools persisted for a paused execution.
1087
+ },
1088
+ };
1089
+ }
1090
+
1023
1091
  public async resumeFlow(
1024
1092
  executionId: string,
1025
1093
  toolOutputs: Record<string, unknown>,
@@ -1060,6 +1128,47 @@ export class AgentWidgetClient {
1060
1128
  body.sessionId = resumeSessionId;
1061
1129
  }
1062
1130
 
1131
+ if (isClientToken && resumeSessionId) {
1132
+ // Mid-run WebMCP tool refresh (runtypelabs/core#5361): the paused tool
1133
+ // may have navigated the page, so the dispatch-time snapshot the server
1134
+ // persisted can be stale. Re-snapshot the registry — the same built-in +
1135
+ // bridge composition as the payload builders, so fingerprints computed
1136
+ // here and on chat turns describe the same tool space — and ship it via
1137
+ // the shared diff-only protocol. `emptyMeansReplace` sends an explicit
1138
+ // `clientTools: []` when the registry vanished after a non-empty send,
1139
+ // so the server replaces the persisted set instead of keeping it frozen.
1140
+ const fullClientTools = [
1141
+ ...builtInClientToolsForDispatch(this.config),
1142
+ ...((await this.webMcpBridge?.snapshotForDispatch()) ?? []),
1143
+ ];
1144
+ const { response, commit } = await this.sendWithClientToolsDiff(
1145
+ resumeSessionId,
1146
+ fullClientTools,
1147
+ (toolFields) => {
1148
+ const resumeRequest = { ...body, ...toolFields };
1149
+ if (this.debug) {
1150
+ // eslint-disable-next-line no-console
1151
+ console.debug("[AgentWidgetClient] client token resume", resumeRequest);
1152
+ }
1153
+ return fetch(url, {
1154
+ method: 'POST',
1155
+ headers,
1156
+ body: JSON.stringify(resumeRequest),
1157
+ signal: options?.signal,
1158
+ });
1159
+ },
1160
+ { emptyMeansReplace: true }
1161
+ );
1162
+ // The server stores the refreshed registry before running the
1163
+ // continuation pipeline, so an OK response means it holds this set under
1164
+ // this fingerprint. Mirror chat's commit-on-success discipline: a failed
1165
+ // resume must not record a fingerprint the server never stored.
1166
+ if (response.ok) {
1167
+ commit();
1168
+ }
1169
+ return response;
1170
+ }
1171
+
1063
1172
  return fetch(url, {
1064
1173
  method: 'POST',
1065
1174
  headers,
@@ -2562,7 +2671,11 @@ export class AgentWidgetClient {
2562
2671
  const message =
2563
2672
  typeof e === "string" && e !== ""
2564
2673
  ? e
2565
- : e != null && typeof e === "object" && "message" in e
2674
+ : // Reflect.has, not `in`: the `in` operator inside an arrow body can
2675
+ // be minified into a `for(init;;)` head, which Oxc mis-parses and
2676
+ // Rolldown (Vite 8) silently emits as an empty chunk. Same
2677
+ // [[HasProperty]] semantics. Enforced by scripts/check-dist-no-in-for-init.mjs.
2678
+ e != null && typeof e === "object" && Reflect.has(e, "message")
2566
2679
  ? String((e as { message?: unknown }).message ?? "Step failed")
2567
2680
  : "Step failed";
2568
2681
  onEvent({ type: "error", error: new Error(message) });
@@ -3145,7 +3258,8 @@ export class AgentWidgetClient {
3145
3258
  const e = payload.error;
3146
3259
  if (typeof e === "string" && e !== "") {
3147
3260
  resolvedError = new Error(e);
3148
- } else if (e != null && typeof e === "object" && "message" in e) {
3261
+ } else if (e != null && typeof e === "object" && Reflect.has(e, "message")) {
3262
+ // Reflect.has, not `in` — see the note on the equivalent guard above.
3149
3263
  resolvedError = new Error(String((e as { message?: unknown }).message ?? e));
3150
3264
  }
3151
3265
  }
@@ -41,6 +41,7 @@ export type RuntypeExecutionStreamEvent = ({
41
41
  };
42
42
  type: "execution_complete";
43
43
  }) | ({
44
+ blockReason?: string;
44
45
  code?: string;
45
46
  completedAt?: string;
46
47
  error: string | {
@@ -295,6 +296,7 @@ export type RuntypeExecutionStreamEvent = ({
295
296
  startedAt?: string;
296
297
  subagent?: {
297
298
  agentName?: string;
299
+ parentToolCallId?: string;
298
300
  toolName: string;
299
301
  };
300
302
  timeout?: number;
@@ -389,6 +391,7 @@ export type RuntypeFlowSSEEvent = {
389
391
  totalTokensUsed?: number;
390
392
  type: "flow_complete";
391
393
  } | ({
394
+ blockReason?: string;
392
395
  code?: string;
393
396
  error: string | {
394
397
  code: string;
@@ -409,6 +412,7 @@ export type RuntypeFlowSSEEvent = {
409
412
  type: "flow_error";
410
413
  upgradeUrl?: string;
411
414
  }) | ({
415
+ approvalId?: string;
412
416
  awaitReason?: string;
413
417
  awaitedAt: string;
414
418
  crawlId?: string;
@@ -419,6 +423,7 @@ export type RuntypeFlowSSEEvent = {
419
423
  parameters?: Record<string, unknown>;
420
424
  seq?: number;
421
425
  stepId?: string;
426
+ timeout?: number;
422
427
  toolCallId?: string;
423
428
  toolId?: string;
424
429
  toolName?: string;
@@ -673,7 +678,7 @@ export type RuntypeClientInitResponse = {
673
678
  welcomeMessage?: string | null;
674
679
  };
675
680
  expiresAt: string;
676
- flow: {
681
+ flow?: {
677
682
  description?: string | null;
678
683
  id: string;
679
684
  name?: string | null;
@@ -695,6 +700,10 @@ export type RuntypeClientChatRequest = {
695
700
  untrustedContentHint?: boolean;
696
701
  }>;
697
702
  clientToolsFingerprint?: string;
703
+ identityProof?: {
704
+ provider: string;
705
+ token: string;
706
+ };
698
707
  inputs?: Record<string, unknown>;
699
708
  messages: Array<{
700
709
  content: string | (Array<{
@@ -16,6 +16,23 @@ const baseConfig = () => ({
16
16
  const inject = (controller: ReturnType<typeof createAgentExperience>) =>
17
17
  controller.injectAssistantMessage({ content: "hello world" });
18
18
 
19
+ const deferred = () => {
20
+ let resolve!: () => void;
21
+ let reject!: (error: unknown) => void;
22
+ const promise = new Promise<void>((resolvePromise, rejectPromise) => {
23
+ resolve = resolvePromise;
24
+ reject = rejectPromise;
25
+ });
26
+ return { promise, resolve, reject };
27
+ };
28
+
29
+ const flushMicrotasks = async (times = 4) => {
30
+ for (let index = 0; index < times; index += 1) {
31
+ // eslint-disable-next-line no-await-in-loop
32
+ await Promise.resolve();
33
+ }
34
+ };
35
+
19
36
  describe("persistState gates storage adapter", () => {
20
37
  afterEach(() => {
21
38
  document.body.innerHTML = "";
@@ -149,4 +166,105 @@ describe("persistState gates storage adapter", () => {
149
166
  expect(controller.getMessages()).toEqual([]);
150
167
  controller.destroy();
151
168
  });
169
+
170
+ it("serializes async saves and preserves the newest transcript", async () => {
171
+ const mount = document.createElement("div");
172
+ document.body.appendChild(mount);
173
+ const firstSave = deferred();
174
+ const payloads: Array<{ messages?: Array<{ content?: string }> }> = [];
175
+ const save = vi.fn((state: { messages?: Array<{ content?: string }> }) => {
176
+ payloads.push(state);
177
+ return save.mock.calls.length === 1 ? firstSave.promise : Promise.resolve();
178
+ });
179
+ const controller = createAgentExperience(mount, {
180
+ ...baseConfig(),
181
+ storageAdapter: { save },
182
+ });
183
+
184
+ controller.injectAssistantMessage({ content: "first" });
185
+ controller.injectAssistantMessage({ content: "second" });
186
+
187
+ expect(save).toHaveBeenCalledTimes(1);
188
+ firstSave.resolve();
189
+ await flushMicrotasks();
190
+
191
+ expect(save).toHaveBeenCalledTimes(2);
192
+ expect(payloads[1].messages?.map((message) => message.content)).toEqual([
193
+ "first",
194
+ "second",
195
+ ]);
196
+ controller.destroy();
197
+ });
198
+
199
+ it("orders clear after pending saves so stale state cannot be resurrected", async () => {
200
+ const mount = document.createElement("div");
201
+ document.body.appendChild(mount);
202
+ const firstSave = deferred();
203
+ const operations: string[] = [];
204
+ const save = vi.fn(() => {
205
+ operations.push("save");
206
+ return save.mock.calls.length === 1 ? firstSave.promise : Promise.resolve();
207
+ });
208
+ const clear = vi.fn(() => {
209
+ operations.push("clear");
210
+ return Promise.resolve();
211
+ });
212
+ const controller = createAgentExperience(mount, {
213
+ ...baseConfig(),
214
+ storageAdapter: { save, clear },
215
+ });
216
+
217
+ inject(controller);
218
+ controller.clearChat();
219
+
220
+ expect(operations).toEqual(["save"]);
221
+ firstSave.resolve();
222
+ await flushMicrotasks(20);
223
+
224
+ expect(operations).toEqual(["save", "save", "clear"]);
225
+ expect(clear).toHaveBeenCalledTimes(1);
226
+ controller.destroy();
227
+ });
228
+
229
+ it("continues queued storage mutations after an async save rejects", async () => {
230
+ const mount = document.createElement("div");
231
+ document.body.appendChild(mount);
232
+ const firstSave = deferred();
233
+ const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
234
+ const save = vi.fn(() =>
235
+ save.mock.calls.length === 1 ? firstSave.promise : Promise.resolve()
236
+ );
237
+ const controller = createAgentExperience(mount, {
238
+ ...baseConfig(),
239
+ storageAdapter: { save },
240
+ });
241
+
242
+ controller.injectAssistantMessage({ content: "first" });
243
+ controller.injectAssistantMessage({ content: "second" });
244
+ firstSave.reject(new Error("storage unavailable"));
245
+ await flushMicrotasks(8);
246
+
247
+ expect(save).toHaveBeenCalledTimes(2);
248
+ expect(errorSpy).toHaveBeenCalledWith(
249
+ "[AgentWidget] Failed to persist state:",
250
+ expect.any(Error)
251
+ );
252
+ errorSpy.mockRestore();
253
+ controller.destroy();
254
+ });
255
+
256
+ it("keeps synchronous adapters synchronous", () => {
257
+ const mount = document.createElement("div");
258
+ document.body.appendChild(mount);
259
+ const save = vi.fn();
260
+ const controller = createAgentExperience(mount, {
261
+ ...baseConfig(),
262
+ storageAdapter: { save },
263
+ });
264
+
265
+ inject(controller);
266
+
267
+ expect(save).toHaveBeenCalledTimes(1);
268
+ controller.destroy();
269
+ });
152
270
  });
@@ -30,7 +30,7 @@ const getLiveRegion = (mount: HTMLElement) =>
30
30
  const installRafMock = () => {
31
31
  let nextId = 1;
32
32
  const callbacks = new Map<number, RafCallback>();
33
- let now = 0;
33
+ let now = performance.now();
34
34
  vi.stubGlobal("requestAnimationFrame", (cb: RafCallback) => {
35
35
  const id = nextId++;
36
36
  callbacks.set(id, cb);