@runtypelabs/persona 4.29.0 → 4.29.2
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/dist/animations/glyph-cycle.d.cts +1 -1
- package/dist/animations/glyph-cycle.d.ts +1 -1
- package/dist/animations/wipe.d.cts +1 -1
- package/dist/animations/wipe.d.ts +1 -1
- package/dist/approval-ui.cjs +3 -3
- package/dist/approval-ui.d.cts +1 -1
- package/dist/approval-ui.d.ts +1 -1
- package/dist/approval-ui.js +3 -3
- package/dist/artifacts-ui.d.cts +2 -2
- package/dist/artifacts-ui.d.ts +2 -2
- package/dist/chunk-B1-_pp-Y.js +4 -0
- package/dist/chunk-B2HCRERn.js +9 -0
- package/dist/chunk-Bm2-bemv.js +1 -0
- package/dist/chunk-BvhTpG6k.js +1 -0
- package/dist/{chunk-DjOvgFsn.js → chunk-Car1mORT.js} +1 -1
- package/dist/chunk-D5B629Mj.js +1 -0
- package/dist/chunk-DO9_WSpy.js +1 -0
- package/dist/chunk-DTDHeUsG.js +1 -0
- package/dist/chunk-DjFpGFGA.js +1 -0
- package/dist/chunk-DrpuvY5W.js +1 -0
- package/dist/{chunk-dRBlrLd7.js → chunk-DtmtfIkd.js} +1 -1
- package/dist/client-history.js +1 -1
- package/dist/client-stream.js +1 -1
- package/dist/codegen.cjs +1 -1
- package/dist/codegen.js +1 -1
- package/dist/{composer-input-D3sB8_P_.d.cts → composer-input-C3Xh1Qzj.d.cts} +1 -1
- package/dist/{composer-input-D3sB8_P_.d.ts → composer-input-C3Xh1Qzj.d.ts} +1 -1
- package/dist/{context-mention-manager-D2qxE5op.d.cts → context-mention-manager-BsEiGObk.d.cts} +1 -1
- package/dist/{context-mention-manager-D2qxE5op.d.ts → context-mention-manager-BsEiGObk.d.ts} +1 -1
- package/dist/context-mentions-inline.d.cts +2 -2
- package/dist/context-mentions-inline.d.ts +2 -2
- package/dist/context-mentions.d.cts +3 -3
- package/dist/context-mentions.d.ts +3 -3
- package/dist/{defaults-Dsw66XqE.d.cts → defaults-DN-L67ak.d.cts} +1 -1
- package/dist/{defaults-Dsw66XqE.d.ts → defaults-DN-L67ak.d.ts} +1 -1
- package/dist/event-stream-view.d.cts +1 -1
- package/dist/event-stream-view.d.ts +1 -1
- package/dist/{fallback-speech-engine-DJpJcWAk.d.cts → fallback-speech-engine-Cph8FTux.d.cts} +1 -1
- package/dist/{fallback-speech-engine-DJpJcWAk.d.ts → fallback-speech-engine-Cph8FTux.d.ts} +1 -1
- package/dist/forms-ui.d.cts +2 -2
- package/dist/forms-ui.d.ts +2 -2
- package/dist/history-view.d.cts +1 -1
- package/dist/history-view.d.ts +1 -1
- package/dist/index.cjs +56 -56
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +6 -6
- package/dist/index.d.ts +6 -6
- package/dist/index.global.js +2 -3
- package/dist/index.global.js.map +1 -1
- package/dist/index.js +42 -42
- package/dist/index.js.map +1 -1
- package/dist/{markdown-parsers-loader-CCY7T4Up.d.cts → markdown-parsers-loader-C4Xze-Jw.d.cts} +1 -1
- package/dist/{markdown-parsers-loader-CCY7T4Up.d.ts → markdown-parsers-loader-C4Xze-Jw.d.ts} +1 -1
- package/dist/{preview-DKKpfTo7.d.cts → preview-m9QlqsZi.d.cts} +2 -2
- package/dist/{preview-DKKpfTo7.d.ts → preview-m9QlqsZi.d.ts} +2 -2
- package/dist/{session-DleMr9dZ.d.ts → session-BtvvmWnw.d.cts} +21 -9
- package/dist/{session-DleMr9dZ.d.cts → session-BtvvmWnw.d.ts} +21 -9
- package/dist/session-actions.js +1 -1
- package/dist/session-reconnect.cjs +1 -1
- package/dist/session-reconnect.d.cts +8 -2
- package/dist/session-reconnect.d.ts +8 -2
- package/dist/session-reconnect.js +1 -1
- package/dist/smart-dom-reader.d.cts +1 -1
- package/dist/smart-dom-reader.d.ts +1 -1
- package/dist/theme-editor-preview.cjs +62 -62
- package/dist/theme-editor-preview.d.cts +1 -1
- package/dist/theme-editor-preview.d.ts +1 -1
- package/dist/theme-editor-preview.js +41 -41
- package/dist/theme-editor.d.cts +2 -2
- package/dist/theme-editor.d.ts +2 -2
- package/dist/theme-reference.d.cts +2 -2
- package/dist/theme-reference.d.ts +2 -2
- package/dist/{types-oAZ1VReS.d.cts → types-DL-oBHE1.d.cts} +41 -3
- package/dist/{types-oAZ1VReS.d.ts → types-DL-oBHE1.d.ts} +41 -3
- package/dist/voice-client-tools.d.cts +1 -1
- package/dist/voice-client-tools.d.ts +1 -1
- package/dist/voice-runtime.cjs +3 -3
- package/dist/voice-runtime.d.cts +10 -4
- package/dist/voice-runtime.d.ts +10 -4
- package/dist/voice-runtime.js +5 -5
- package/dist/voice-worklet-player.d.cts +2 -2
- package/dist/voice-worklet-player.d.ts +2 -2
- package/dist/{webmcp-runtime-entry-Deg0NfmB.d.cts → webmcp-runtime-entry-T6SU_TlL.d.cts} +1 -1
- package/dist/{webmcp-runtime-entry-Deg0NfmB.d.ts → webmcp-runtime-entry-T6SU_TlL.d.ts} +1 -1
- package/dist/webmcp-runtime.d.cts +1 -1
- package/dist/webmcp-runtime.d.ts +1 -1
- package/dist/widget.css +1 -1
- package/package.json +1 -1
- package/src/client-approve.test.ts +17 -1
- package/src/client-resume.ts +15 -3
- package/src/client.interrupt.test.ts +21 -0
- package/src/client.ts +24 -4
- package/src/components/activity-row.ts +3 -2
- package/src/components/approval-actions.test.ts +8 -0
- package/src/components/approval-actions.ts +6 -2
- package/src/components/approval-bubble.test.ts +14 -0
- package/src/components/approval-bubble.ts +8 -1
- package/src/components/tool-bubble.ts +8 -5
- package/src/generated/runtype-openapi-contract.ts +7 -1
- package/src/resume-dedupe-local-only.test.ts +273 -0
- package/src/runtime/persist-state.test.ts +86 -0
- package/src/session-actions.ts +20 -6
- package/src/session-reconnect.ts +18 -1
- package/src/session.restored-browser-tools.test.ts +272 -0
- package/src/session.test.ts +23 -0
- package/src/session.ts +211 -46
- package/src/session.voice.test.ts +169 -0
- package/src/styles/widget.css +4 -2
- package/src/types.ts +37 -2
- package/src/ui.restored-browser-tools.test.ts +128 -0
- package/src/ui.scroll.test.ts +7 -2
- package/src/ui.stream-replay.test.ts +86 -0
- package/src/ui.streaming-coalescing.test.ts +183 -0
- package/src/ui.ts +152 -124
- package/src/ui.typing-indicator.test.ts +21 -0
- package/src/utils/formatting.test.ts +9 -1
- package/src/utils/formatting.ts +4 -0
- package/src/utils/local-only-message.ts +15 -0
- package/src/utils/message-fingerprint.test.ts +9 -0
- package/src/utils/message-fingerprint.ts +18 -5
- package/src/utils/morph.ts +10 -0
- package/src/utils/restored-browser-state.ts +50 -0
- package/src/utils/storage.ts +12 -1
- package/src/utils/voice-prior-tool-results.ts +37 -0
- package/src/voice/runtype-voice-provider.ts +13 -8
- package/src/voice/session-voice-wiring.ts +1 -2
- package/src/voice/voice-delegation.ts +10 -3
- package/src/voice/voice.test.ts +38 -2
- package/dist/chunk-B6zZsT6F.js +0 -1
- package/dist/chunk-CIVsNcrk.js +0 -1
- package/dist/chunk-CKdlNyzM.js +0 -9
- package/dist/chunk-DbhxjyOB.js +0 -4
- package/dist/chunk-DsdqLStH.js +0 -1
- package/dist/chunk-GIqbL2Eg.js +0 -1
|
@@ -268,3 +268,89 @@ describe("persistState gates storage adapter", () => {
|
|
|
268
268
|
controller.destroy();
|
|
269
269
|
});
|
|
270
270
|
});
|
|
271
|
+
|
|
272
|
+
describe("default transcript key and local-only notices (runtypelabs/core#9961)", () => {
|
|
273
|
+
afterEach(() => {
|
|
274
|
+
document.body.innerHTML = "";
|
|
275
|
+
try {
|
|
276
|
+
window.localStorage.clear();
|
|
277
|
+
} catch {
|
|
278
|
+
/* jsdom edge cases */
|
|
279
|
+
}
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
const mount = () => {
|
|
283
|
+
const el = document.createElement("div");
|
|
284
|
+
document.body.appendChild(el);
|
|
285
|
+
return el;
|
|
286
|
+
};
|
|
287
|
+
const storedContents = (key: string): string[] =>
|
|
288
|
+
(JSON.parse(window.localStorage.getItem(key) ?? "{}").messages ?? []).map(
|
|
289
|
+
(m: { content?: string }) => m.content
|
|
290
|
+
);
|
|
291
|
+
|
|
292
|
+
it("scopes each widget's transcript by persistState.keyPrefix", () => {
|
|
293
|
+
const a = createAgentExperience(mount(), {
|
|
294
|
+
...baseConfig(),
|
|
295
|
+
persistState: { keyPrefix: "ops-" },
|
|
296
|
+
});
|
|
297
|
+
const b = createAgentExperience(mount(), {
|
|
298
|
+
...baseConfig(),
|
|
299
|
+
persistState: { keyPrefix: "ehr-" },
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
a.injectAssistantMessage({ content: "ops reply" });
|
|
303
|
+
b.injectAssistantMessage({ content: "ehr reply" });
|
|
304
|
+
|
|
305
|
+
expect(storedContents("ops-state")).toEqual(["ops reply"]);
|
|
306
|
+
expect(storedContents("ehr-state")).toEqual(["ehr reply"]);
|
|
307
|
+
expect(window.localStorage.getItem(DEFAULT_KEY)).toBeNull();
|
|
308
|
+
a.destroy();
|
|
309
|
+
b.destroy();
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
it("keeps reading the legacy key only for the default prefix", () => {
|
|
313
|
+
window.localStorage.setItem(
|
|
314
|
+
DEFAULT_KEY,
|
|
315
|
+
JSON.stringify({
|
|
316
|
+
messages: [
|
|
317
|
+
{ id: "legacy-1", role: "assistant", content: "legacy reply", createdAt: "2026-01-01T00:00:00.000Z" },
|
|
318
|
+
],
|
|
319
|
+
})
|
|
320
|
+
);
|
|
321
|
+
|
|
322
|
+
const unprefixed = createAgentExperience(mount(), baseConfig());
|
|
323
|
+
const prefixed = createAgentExperience(mount(), {
|
|
324
|
+
...baseConfig(),
|
|
325
|
+
persistState: { keyPrefix: "ops-" },
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
expect(unprefixed.getMessages().map((m) => m.content)).toEqual(["legacy reply"]);
|
|
329
|
+
expect(prefixed.getMessages().map((m) => m.content)).toEqual([]);
|
|
330
|
+
unprefixed.destroy();
|
|
331
|
+
prefixed.destroy();
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
it("never stores a local-only notice, including legacy reconnect-failure bubbles", () => {
|
|
335
|
+
window.localStorage.setItem(
|
|
336
|
+
DEFAULT_KEY,
|
|
337
|
+
JSON.stringify({
|
|
338
|
+
messages: [
|
|
339
|
+
{ id: "u1", role: "user", content: "hi", createdAt: "2026-01-01T00:00:00.000Z" },
|
|
340
|
+
{
|
|
341
|
+
id: "reconnect-failed-exec_1",
|
|
342
|
+
role: "assistant",
|
|
343
|
+
content: "Connection lost and the response could not be resumed.",
|
|
344
|
+
createdAt: "2026-01-01T00:00:01.000Z",
|
|
345
|
+
},
|
|
346
|
+
],
|
|
347
|
+
})
|
|
348
|
+
);
|
|
349
|
+
const controller = createAgentExperience(mount(), baseConfig());
|
|
350
|
+
|
|
351
|
+
controller.injectAssistantMessage({ content: "kept" });
|
|
352
|
+
|
|
353
|
+
expect(storedContents(DEFAULT_KEY)).toEqual(["hi", "kept"]);
|
|
354
|
+
controller.destroy();
|
|
355
|
+
});
|
|
356
|
+
});
|
package/src/session-actions.ts
CHANGED
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
* a host object (`SessionActionsHost`) that exposes the internals they need.
|
|
11
11
|
*/
|
|
12
12
|
import type { AgentWidgetClient } from "./client";
|
|
13
|
+
import { generateAssistantMessageId } from "./utils/message-id";
|
|
13
14
|
import {
|
|
14
15
|
SUGGEST_REPLIES_TOOL_NAME,
|
|
15
16
|
suggestRepliesToolResult,
|
|
@@ -40,7 +41,7 @@ export type SessionActionsInternals = {
|
|
|
40
41
|
): void;
|
|
41
42
|
connectStream(
|
|
42
43
|
stream: ReadableStream<Uint8Array>,
|
|
43
|
-
options?: { allowReentry?: boolean }
|
|
44
|
+
options?: { allowReentry?: boolean; assistantMessageId?: string }
|
|
44
45
|
): Promise<void>;
|
|
45
46
|
};
|
|
46
47
|
|
|
@@ -83,6 +84,8 @@ export async function resolveApproval(
|
|
|
83
84
|
// 1. Update approval message status immediately for responsive UI
|
|
84
85
|
const approvalMessageId = `approval-${approval.id}`;
|
|
85
86
|
const errorMessageId = `approval-error-${approval.id}`;
|
|
87
|
+
// The client-token server stores the continuation under this id (core#9961).
|
|
88
|
+
const resumedAssistantId = generateAssistantMessageId();
|
|
86
89
|
const requestToken = {};
|
|
87
90
|
h.s.approvalTokens.set(approvalMessageId, requestToken);
|
|
88
91
|
const updatedApproval: AgentWidgetApproval = {
|
|
@@ -141,7 +144,8 @@ export async function resolveApproval(
|
|
|
141
144
|
executionId: approval.executionId,
|
|
142
145
|
approvalId: approval.id,
|
|
143
146
|
},
|
|
144
|
-
decision
|
|
147
|
+
decision,
|
|
148
|
+
{ assistantMessageId: resumedAssistantId }
|
|
145
149
|
);
|
|
146
150
|
}
|
|
147
151
|
|
|
@@ -181,6 +185,7 @@ export async function resolveApproval(
|
|
|
181
185
|
id: errorMessageId,
|
|
182
186
|
role: "assistant",
|
|
183
187
|
content: errorText,
|
|
188
|
+
localOnly: true,
|
|
184
189
|
createdAt: new Date().toISOString(),
|
|
185
190
|
streaming: false,
|
|
186
191
|
sequence: h.nextSequence(),
|
|
@@ -205,7 +210,10 @@ export async function resolveApproval(
|
|
|
205
210
|
}
|
|
206
211
|
|
|
207
212
|
if (stream) {
|
|
208
|
-
await h.s.connectStream(stream, {
|
|
213
|
+
await h.s.connectStream(stream, {
|
|
214
|
+
allowReentry: true,
|
|
215
|
+
assistantMessageId: resumedAssistantId,
|
|
216
|
+
});
|
|
209
217
|
} else {
|
|
210
218
|
if (decision === 'denied') {
|
|
211
219
|
// No stream body for denied: inject a denial message, and settle
|
|
@@ -369,12 +377,14 @@ export async function resolveAskUserQuestion(
|
|
|
369
377
|
}
|
|
370
378
|
|
|
371
379
|
try {
|
|
380
|
+
const assistantMessageId = generateAssistantMessageId();
|
|
372
381
|
const response = await h.s.client.resumeFlow(
|
|
373
382
|
executionId,
|
|
374
383
|
{ [toolName]: answer },
|
|
375
384
|
{
|
|
376
385
|
after:
|
|
377
386
|
h.resumeAfter(executionId),
|
|
387
|
+
assistantMessageId,
|
|
378
388
|
},
|
|
379
389
|
);
|
|
380
390
|
|
|
@@ -386,7 +396,7 @@ export async function resolveAskUserQuestion(
|
|
|
386
396
|
}
|
|
387
397
|
|
|
388
398
|
if (response.body) {
|
|
389
|
-
await h.s.connectStream(response.body, { allowReentry: true });
|
|
399
|
+
await h.s.connectStream(response.body, { allowReentry: true, assistantMessageId });
|
|
390
400
|
} else {
|
|
391
401
|
// No body to pipe: drop the pre-set streaming flag so the indicator
|
|
392
402
|
// doesn't linger forever.
|
|
@@ -685,10 +695,12 @@ export async function resolveWebMcpToolCallBatch(
|
|
|
685
695
|
toolOutputs[r.resumeKey] = r.output;
|
|
686
696
|
}
|
|
687
697
|
|
|
698
|
+
const assistantMessageId = generateAssistantMessageId();
|
|
688
699
|
const response = await h.s.client.resumeFlow(executionId, toolOutputs, {
|
|
689
700
|
signal: batchController.signal,
|
|
690
701
|
after:
|
|
691
702
|
h.resumeAfter(executionId),
|
|
703
|
+
assistantMessageId,
|
|
692
704
|
});
|
|
693
705
|
if (!response.ok) {
|
|
694
706
|
const errorData = await response.json().catch(() => null);
|
|
@@ -729,7 +741,7 @@ export async function resolveWebMcpToolCallBatch(
|
|
|
729
741
|
);
|
|
730
742
|
}
|
|
731
743
|
if (response.body) {
|
|
732
|
-
await h.s.connectStream(response.body, { allowReentry: true });
|
|
744
|
+
await h.s.connectStream(response.body, { allowReentry: true, assistantMessageId });
|
|
733
745
|
}
|
|
734
746
|
} catch (error) {
|
|
735
747
|
const isAbortError =
|
|
@@ -881,6 +893,7 @@ async function resumeWithToolOutput(
|
|
|
881
893
|
output: unknown,
|
|
882
894
|
options?: { onHttpOk?: () => void; signal?: AbortSignal },
|
|
883
895
|
): Promise<void> {
|
|
896
|
+
const assistantMessageId = generateAssistantMessageId();
|
|
884
897
|
const response = await h.s.client.resumeFlow(
|
|
885
898
|
executionId,
|
|
886
899
|
{ [resumeKey]: output },
|
|
@@ -888,6 +901,7 @@ async function resumeWithToolOutput(
|
|
|
888
901
|
signal: options?.signal,
|
|
889
902
|
after:
|
|
890
903
|
h.resumeAfter(executionId),
|
|
904
|
+
assistantMessageId,
|
|
891
905
|
},
|
|
892
906
|
);
|
|
893
907
|
if (!response.ok) {
|
|
@@ -896,7 +910,7 @@ async function resumeWithToolOutput(
|
|
|
896
910
|
}
|
|
897
911
|
options?.onHttpOk?.();
|
|
898
912
|
if (response.body) {
|
|
899
|
-
await h.s.connectStream(response.body, { allowReentry: true });
|
|
913
|
+
await h.s.connectStream(response.body, { allowReentry: true, assistantMessageId });
|
|
900
914
|
} else if (h.s.webMcpResolveControllers.size === 0) {
|
|
901
915
|
// No stream to pipe. Clear streaming only when no WebMCP resolve is in
|
|
902
916
|
// flight: for a WebMCP caller the current resolve's controller is still
|
package/src/session-reconnect.ts
CHANGED
|
@@ -20,6 +20,7 @@ import type {
|
|
|
20
20
|
ResumableHandle,
|
|
21
21
|
} from "./types";
|
|
22
22
|
import type { AgentWidgetSessionStatus } from "./session";
|
|
23
|
+
import { RECONNECT_FAILED_ID_PREFIX } from "./utils/local-only-message";
|
|
23
24
|
|
|
24
25
|
/**
|
|
25
26
|
* The narrow surface the reconnect loop needs from the owning session. The
|
|
@@ -45,6 +46,12 @@ export interface ReconnectHost {
|
|
|
45
46
|
seedContent: string
|
|
46
47
|
): Promise<void>;
|
|
47
48
|
appendMessage(message: AgentWidgetMessage): void;
|
|
49
|
+
/**
|
|
50
|
+
* After an attempt that did not finish the run: if the run is parked on an
|
|
51
|
+
* await (or a reload-interrupted page tool), settle the session quietly and
|
|
52
|
+
* return true so the loop stops; return false for an ordinary drop.
|
|
53
|
+
*/
|
|
54
|
+
settleParked(executionId: string): boolean;
|
|
48
55
|
nextSequence(): number;
|
|
49
56
|
emitReconnect(event: {
|
|
50
57
|
phase: "paused" | "resuming" | "resumed";
|
|
@@ -170,9 +177,10 @@ export function createReconnectController(
|
|
|
170
177
|
);
|
|
171
178
|
if (content) {
|
|
172
179
|
host.appendMessage({
|
|
173
|
-
id:
|
|
180
|
+
id: `${RECONNECT_FAILED_ID_PREFIX}${handle?.executionId ?? host.nextSequence()}`,
|
|
174
181
|
role: "assistant",
|
|
175
182
|
content,
|
|
183
|
+
localOnly: true,
|
|
176
184
|
createdAt: new Date().toISOString(),
|
|
177
185
|
streaming: false,
|
|
178
186
|
sequence: host.nextSequence(),
|
|
@@ -248,6 +256,15 @@ export function createReconnectController(
|
|
|
248
256
|
if (host.getResumable()!.lastEventId !== before) attempt = 0;
|
|
249
257
|
}
|
|
250
258
|
|
|
259
|
+
// A parked run has nothing more to replay; retrying would only end in
|
|
260
|
+
// the "Connection lost" bubble. Only a reconnect that actually reached
|
|
261
|
+
// the server can prove that — a failed request keeps retrying.
|
|
262
|
+
if (body && host.getResumable() && host.settleParked(handle.executionId)) {
|
|
263
|
+
attempt = 0;
|
|
264
|
+
detachListeners();
|
|
265
|
+
return;
|
|
266
|
+
}
|
|
267
|
+
|
|
251
268
|
if (host.getResumable() && attempt < maxAttempts) {
|
|
252
269
|
// Dropped and waiting to retry: surface `paused` so
|
|
253
270
|
// `statusIndicator.pausedText` renders during the backoff sleep.
|
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
import { describe, expect, expectTypeOf, it, vi } from "vitest";
|
|
2
|
+
import { AgentWidgetSession } from "./session";
|
|
3
|
+
import type { AgentWidgetApproval, AgentWidgetConfig, AgentWidgetMessage, AgentWidgetToolCall, WebMcpConfirmInfo } from "./types";
|
|
4
|
+
import { BROWSER_TOOL_INTERRUPTION } from "./utils/restored-browser-state";
|
|
5
|
+
import { createDemoHistoryProvider } from "./internal/demo-history-provider";
|
|
6
|
+
import type { HistoryPageResult } from "./internal/history-provider";
|
|
7
|
+
|
|
8
|
+
const info: WebMcpConfirmInfo = { toolName: "purchase", args: { quantity: 2 }, reason: "gate" };
|
|
9
|
+
const approval = (status: NonNullable<AgentWidgetMessage["approval"]>["status"] = "pending"): AgentWidgetMessage => ({
|
|
10
|
+
id: "approval-webmcp-1", role: "assistant", content: "", createdAt: "2026-01-01",
|
|
11
|
+
variant: "approval", approval: { id: "webmcp-1", status, agentId: "", executionId: "", toolName: info.toolName, toolType: "webmcp", description: "Buy items", parameters: info.args },
|
|
12
|
+
});
|
|
13
|
+
const tool = (): AgentWidgetMessage => ({
|
|
14
|
+
id: "tool-call-1", role: "assistant", content: "", createdAt: "2026-01-01", streaming: true, variant: "tool",
|
|
15
|
+
agentMetadata: { executionId: "exec-1", awaitingLocalTool: true, webMcpToolCallId: "call-1" },
|
|
16
|
+
toolCall: { id: "call-1", name: `webmcp:${info.toolName}`, status: "running", args: info.args, startedAt: 1000, approvalStatus: "pending" },
|
|
17
|
+
});
|
|
18
|
+
const makeSession = (config: AgentWidgetConfig = {}) => new AgentWidgetSession(config, {
|
|
19
|
+
onMessagesChanged: vi.fn(), onStatusChanged: vi.fn(), onStreamingChanged: vi.fn(),
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it("keeps the public approval status unions compatible with existing exhaustive handlers", () => {
|
|
23
|
+
type ExistingStatus = "pending" | "approved" | "denied" | "timeout";
|
|
24
|
+
expectTypeOf<AgentWidgetApproval["status"]>().toEqualTypeOf<ExistingStatus>();
|
|
25
|
+
expectTypeOf<AgentWidgetToolCall["approvalStatus"]>().toEqualTypeOf<ExistingStatus | undefined>();
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
describe.each(["initial", "hydrate"] as const)("browser history via %s", path => {
|
|
29
|
+
const restore = (messages: AgentWidgetMessage[]) => {
|
|
30
|
+
const session = makeSession(path === "initial" ? { initialMessages: messages } : {});
|
|
31
|
+
if (path === "hydrate") session.hydrateMessages(messages);
|
|
32
|
+
return session;
|
|
33
|
+
};
|
|
34
|
+
it("expires the lost callback, settles the tool and preserves the snapshot", () => {
|
|
35
|
+
const snapshot = [approval(), tool()];
|
|
36
|
+
const before = structuredClone(snapshot);
|
|
37
|
+
const session = restore(snapshot);
|
|
38
|
+
expect(session.getMessages()[0].approval).toMatchObject({ status: "timeout", interrupted: true });
|
|
39
|
+
const restored = session.getMessages()[1];
|
|
40
|
+
expect(restored.streaming).toBe(false);
|
|
41
|
+
expect(restored.agentMetadata).toEqual({ ...before[1].agentMetadata, awaitingLocalTool: false });
|
|
42
|
+
expect(restored.toolCall).toEqual({ ...before[1].toolCall, status: "complete", success: false, interrupted: true, error: BROWSER_TOOL_INTERRUPTION, approvalStatus: "timeout", startedAt: undefined });
|
|
43
|
+
expect(restored.toolCall?.durationMs).toBeUndefined();
|
|
44
|
+
expect(restored.toolCall?.completedAt).toBeUndefined();
|
|
45
|
+
session.resolveWebMcpApproval(snapshot[0].id, "approved");
|
|
46
|
+
expect(session.getMessages()[0].approval).toMatchObject({ status: "timeout", interrupted: true });
|
|
47
|
+
expect(snapshot).toEqual(before);
|
|
48
|
+
session.destroy();
|
|
49
|
+
});
|
|
50
|
+
it.each(["timeout", "approved", "denied"] as const)("settles unfinished tools even with a %s approval", status => {
|
|
51
|
+
const session = restore([approval(status), tool()]);
|
|
52
|
+
expect(session.getMessages()[0].approval?.status).toBe(status);
|
|
53
|
+
expect(session.getMessages()[0].approval?.interrupted).toBeUndefined();
|
|
54
|
+
expect(session.getMessages()[1].toolCall?.interrupted).toBe(true);
|
|
55
|
+
session.destroy();
|
|
56
|
+
});
|
|
57
|
+
it("settles cardless and legacy awaits and mixed collections without losing recorded results or timing", () => {
|
|
58
|
+
const message = tool();
|
|
59
|
+
message.toolCall!.status = "complete"; // legacy wire await, not an outcome
|
|
60
|
+
message.tools = [
|
|
61
|
+
{ id: "pending", name: `webmcp:${info.toolName}`, status: "pending", args: { sku: "A" }, durationMs: 200 },
|
|
62
|
+
{ id: "paired", name: `webmcp:${info.toolName}`, status: "running", startedAt: 10, completedAt: 40 },
|
|
63
|
+
{ id: "done", name: `webmcp:${info.toolName}`, status: "complete", success: true, result: { bought: 2 }, startedAt: 10, completedAt: 30, duration: 20 },
|
|
64
|
+
{ id: "server", name: "server_search", status: "running", startedAt: 10 },
|
|
65
|
+
];
|
|
66
|
+
const before = structuredClone(message);
|
|
67
|
+
const session = restore([message]);
|
|
68
|
+
const restored = session.getMessages()[0];
|
|
69
|
+
expect(restored.toolCall?.interrupted).toBe(true);
|
|
70
|
+
expect(restored.tools?.[0]).toMatchObject({ status: "complete", interrupted: true, durationMs: 200 });
|
|
71
|
+
expect(restored.tools?.[1]).toMatchObject({ status: "complete", startedAt: 10, completedAt: 40 });
|
|
72
|
+
expect(restored.tools?.slice(2)).toEqual(before.tools?.slice(2));
|
|
73
|
+
expect(message).toEqual(before);
|
|
74
|
+
session.destroy();
|
|
75
|
+
});
|
|
76
|
+
it("keeps server approval continuations including server gates for WebMCP tools", () => {
|
|
77
|
+
const server = approval();
|
|
78
|
+
server.approval!.executionId = "durable-execution";
|
|
79
|
+
const session = restore([server]);
|
|
80
|
+
expect(session.getMessages()[0].approval).toEqual(server.approval);
|
|
81
|
+
session.destroy();
|
|
82
|
+
});
|
|
83
|
+
it.each(["approved", "denied"] as const)("generates distinct fresh approvals and resolves %s once", async decision => {
|
|
84
|
+
const session = restore([approval()]);
|
|
85
|
+
const pending = session.requestWebMcpApproval(info);
|
|
86
|
+
const fresh = session.getMessages().find(m => m.approval?.status === "pending")!;
|
|
87
|
+
expect(fresh.id).not.toBe("approval-webmcp-1");
|
|
88
|
+
session.resolveWebMcpApproval(fresh.id, decision);
|
|
89
|
+
session.resolveWebMcpApproval(fresh.id, decision === "approved" ? "denied" : "approved");
|
|
90
|
+
expect(await pending).toBe(decision === "approved");
|
|
91
|
+
expect(session.getMessages().find(m => m.id === fresh.id)?.approval?.status).toBe(decision);
|
|
92
|
+
expect(session.getMessages().find(m => m.id === "approval-webmcp-1")?.approval).toMatchObject({ status: "timeout", interrupted: true });
|
|
93
|
+
session.destroy();
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it("conversation switching also retires browser callbacks", async () => {
|
|
98
|
+
const { createDemoHistoryProvider } = await import("./internal/demo-history-provider");
|
|
99
|
+
const provider = createDemoHistoryProvider();
|
|
100
|
+
const summary = { id: "restored", title: "Restored", targetId: "flow", preview: "", createdAt: "2026-01-01", updatedAt: "2026-01-01", messageCount: 2 };
|
|
101
|
+
const snapshot = [approval(), tool()];
|
|
102
|
+
vi.spyOn(provider, "getPage").mockResolvedValue({ summary, messages: snapshot, conversationRevision: "r1", nextCursor: null });
|
|
103
|
+
vi.spyOn(provider, "prepareOpen").mockResolvedValue({ conversationId: summary.id, conversationRevision: "r1", commit: vi.fn(), discard: vi.fn() });
|
|
104
|
+
const session = new AgentWidgetSession({}, {
|
|
105
|
+
onMessagesChanged: vi.fn(), onStatusChanged: vi.fn(), onStreamingChanged: vi.fn(),
|
|
106
|
+
}, { historyProvider: provider });
|
|
107
|
+
await session.openConversation(summary.id);
|
|
108
|
+
expect(session.getMessages()[0].approval).toMatchObject({ status: "timeout", interrupted: true });
|
|
109
|
+
expect(session.getMessages()[1].toolCall?.interrupted).toBe(true);
|
|
110
|
+
expect(snapshot[0].approval?.status).toBe("pending");
|
|
111
|
+
session.destroy();
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
describe("browser state from provider pages", () => {
|
|
115
|
+
const summary = { id: "restored", title: "Restored", targetId: "flow", preview: "", createdAt: "2026-01-01", updatedAt: "2026-01-02", messageCount: 5 };
|
|
116
|
+
const newer: AgentWidgetMessage = { id: "newer", role: "assistant", content: "Newer reply", createdAt: "2026-01-02" };
|
|
117
|
+
const setup = (messages: AgentWidgetMessage[] = [newer]) => {
|
|
118
|
+
const provider = createDemoHistoryProvider();
|
|
119
|
+
const getPage = vi.spyOn(provider, "getPage");
|
|
120
|
+
const session = new AgentWidgetSession({ initialMessages: messages, getStoredConversationId: () => summary.id }, {
|
|
121
|
+
onMessagesChanged: vi.fn(), onStatusChanged: vi.fn(), onStreamingChanged: vi.fn(),
|
|
122
|
+
}, { historyProvider: provider });
|
|
123
|
+
return { session, getPage };
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
it.each(["older", "boot replacement", "boot overlap"] as const)("normalizes the %s page without mutating provider snapshots", async path => {
|
|
127
|
+
const server = approval();
|
|
128
|
+
server.id = "server-gate";
|
|
129
|
+
server.approval = { ...server.approval!, id: "server-gate", executionId: "durable-exec" };
|
|
130
|
+
const done = tool();
|
|
131
|
+
done.id = "completed-tool";
|
|
132
|
+
done.streaming = false;
|
|
133
|
+
done.agentMetadata = { executionId: "done-exec" };
|
|
134
|
+
done.toolCall = { ...done.toolCall!, status: "complete", success: true, result: { bought: 2 }, startedAt: 10, completedAt: 30, durationMs: 20, approvalStatus: "approved" };
|
|
135
|
+
const snapshot = [approval(), tool(), server, done, ...(path === "boot overlap" ? [newer] : [])];
|
|
136
|
+
const before = structuredClone(snapshot);
|
|
137
|
+
const { session, getPage } = setup();
|
|
138
|
+
getPage.mockResolvedValue({ summary, messages: snapshot, conversationRevision: "r1", nextCursor: null });
|
|
139
|
+
if (path === "older") {
|
|
140
|
+
const page = await session.loadOlderMessages(summary.id, "older");
|
|
141
|
+
expect(page.messages.find(m => m.id === approval().id)?.approval).toMatchObject({ status: "timeout", interrupted: true });
|
|
142
|
+
expect(page.messages.find(m => m.id === tool().id)?.toolCall?.interrupted).toBe(true);
|
|
143
|
+
} else {
|
|
144
|
+
await session.reconcileBootConversation();
|
|
145
|
+
}
|
|
146
|
+
const restored = session.getMessages();
|
|
147
|
+
expect(restored.find(m => m.id === approval().id)?.approval).toMatchObject({ status: "timeout", interrupted: true });
|
|
148
|
+
expect(restored.find(m => m.id === tool().id)).toMatchObject({ streaming: false, agentMetadata: { awaitingLocalTool: false }, toolCall: { interrupted: true, status: "complete", success: false } });
|
|
149
|
+
expect(restored.find(m => m.id === server.id)?.approval).toEqual(server.approval);
|
|
150
|
+
expect(restored.find(m => m.id === done.id)?.toolCall).toEqual(done.toolCall);
|
|
151
|
+
expect(restored.some(m => m.id === newer.id)).toBe(path !== "boot replacement");
|
|
152
|
+
expect(snapshot).toEqual(before);
|
|
153
|
+
session.destroy();
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it.each(["older", "boot"] as const)("keeps a live gate usable when the %s page overlaps it", async path => {
|
|
157
|
+
const { session, getPage } = setup();
|
|
158
|
+
const decision = session.requestWebMcpApproval(info);
|
|
159
|
+
const live = session.getMessages().find(m => m.approval?.status === "pending")!;
|
|
160
|
+
getPage.mockResolvedValue({ summary, messages: [structuredClone(live)], conversationRevision: "r1", nextCursor: null });
|
|
161
|
+
if (path === "older") await session.loadOlderMessages(summary.id, "older");
|
|
162
|
+
else await session.reconcileBootConversation();
|
|
163
|
+
expect(session.getMessages().find(m => m.id === live.id)?.approval?.status).toBe("pending");
|
|
164
|
+
session.resolveWebMcpApproval(live.id, "approved");
|
|
165
|
+
expect(await decision).toBe(true);
|
|
166
|
+
session.destroy();
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it("keeps a decision made while an older page was loading", async () => {
|
|
170
|
+
const { session, getPage } = setup();
|
|
171
|
+
const decision = session.requestWebMcpApproval(info);
|
|
172
|
+
const live = session.getMessages().find(m => m.approval?.status === "pending")!;
|
|
173
|
+
const snapshot = structuredClone(live);
|
|
174
|
+
let finish!: (page: HistoryPageResult) => void;
|
|
175
|
+
getPage.mockImplementation(() => new Promise(resolve => { finish = resolve; }));
|
|
176
|
+
const page = session.loadOlderMessages(summary.id, "older");
|
|
177
|
+
session.resolveWebMcpApproval(live.id, "approved");
|
|
178
|
+
expect(await decision).toBe(true);
|
|
179
|
+
finish({ summary, messages: [snapshot], conversationRevision: "r1", nextCursor: null });
|
|
180
|
+
await page;
|
|
181
|
+
expect(session.getMessages().find(m => m.id === live.id)?.approval?.status).toBe("approved");
|
|
182
|
+
expect(snapshot.approval?.status).toBe("pending");
|
|
183
|
+
session.destroy();
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it("keeps a running browser tool and its completed result when older pages overlap", async () => {
|
|
187
|
+
const { session, getPage } = setup();
|
|
188
|
+
const client = (session as unknown as { client: {
|
|
189
|
+
executeWebMcpToolCall: () => Promise<unknown>;
|
|
190
|
+
resumeFlow: () => Promise<Response>;
|
|
191
|
+
} }).client;
|
|
192
|
+
let finish!: (result: unknown) => void;
|
|
193
|
+
client.executeWebMcpToolCall = vi.fn(() => new Promise(resolve => { finish = resolve; }));
|
|
194
|
+
client.resumeFlow = vi.fn(async () => new Response('data: {"type":"execution_complete","success":true,"kind":"agent"}\n\n'));
|
|
195
|
+
const snapshot = tool();
|
|
196
|
+
const execution = session.resolveWebMcpToolCall(snapshot);
|
|
197
|
+
await vi.waitFor(() => expect(client.executeWebMcpToolCall).toHaveBeenCalledTimes(1));
|
|
198
|
+
getPage.mockResolvedValue({ summary, messages: [snapshot], conversationRevision: "r1", nextCursor: null });
|
|
199
|
+
await session.loadOlderMessages(summary.id, "older");
|
|
200
|
+
expect(session.getMessages().find(m => m.id === snapshot.id)?.toolCall).toMatchObject({ status: "running" });
|
|
201
|
+
expect(session.getMessages().find(m => m.id === snapshot.id)?.toolCall?.interrupted).not.toBe(true);
|
|
202
|
+
finish({ content: [{ type: "text", text: "Purchased" }] });
|
|
203
|
+
await execution;
|
|
204
|
+
const completed = session.getMessages().find(m => m.id === snapshot.id)!.toolCall;
|
|
205
|
+
await session.loadOlderMessages(summary.id, "another-page");
|
|
206
|
+
expect(session.getMessages().find(m => m.id === snapshot.id)?.toolCall).toEqual(completed);
|
|
207
|
+
expect(client.executeWebMcpToolCall).toHaveBeenCalledTimes(1);
|
|
208
|
+
expect(client.resumeFlow).toHaveBeenCalledTimes(1);
|
|
209
|
+
session.destroy();
|
|
210
|
+
});
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
it("a reconnect replay uses a fresh gate, executes once, and never re-executes completed history", async () => {
|
|
214
|
+
const frame = (data: object) => `data: ${JSON.stringify(data)}\n\n`;
|
|
215
|
+
const awaitFrame = frame({ type: "await", executionId: "exec-1", awaitReason: "local_tool_required", toolCallId: "call-1", toolName: "webmcp:purchase", parameters: info.args });
|
|
216
|
+
const stream = (text: string) => new ReadableStream<Uint8Array>({ start(c) { c.enqueue(new TextEncoder().encode(text)); c.close(); } });
|
|
217
|
+
const session = makeSession({ apiUrl: "http://test", webmcp: { enabled: true }, reconnectStream: async () => new Response(stream(awaitFrame + awaitFrame)) });
|
|
218
|
+
// Stub only the HTTP and browser boundaries, leaving the real event pipeline.
|
|
219
|
+
const client = (session as unknown as { client: {
|
|
220
|
+
isWebMcpOperational: () => boolean;
|
|
221
|
+
executeWebMcpToolCall: () => Promise<unknown>;
|
|
222
|
+
resumeFlow: () => Promise<Response>;
|
|
223
|
+
} }).client;
|
|
224
|
+
client.isWebMcpOperational = () => true;
|
|
225
|
+
const execute = vi.fn(async () => {
|
|
226
|
+
const allowed = await session.requestWebMcpApproval(info);
|
|
227
|
+
return { content: [{ type: "text", text: allowed ? "Purchased" : "Denied" }], isError: !allowed };
|
|
228
|
+
});
|
|
229
|
+
client.executeWebMcpToolCall = execute;
|
|
230
|
+
const resume = vi.fn(async () => new Response(stream(frame({ type: "execution_complete", success: true, kind: "agent" }))));
|
|
231
|
+
client.resumeFlow = resume;
|
|
232
|
+
session.hydrateMessages([approval(), tool()]);
|
|
233
|
+
expect(execute).not.toHaveBeenCalled();
|
|
234
|
+
session.resumeFromHandle({ executionId: "exec-1", after: "5" });
|
|
235
|
+
await vi.waitFor(() => expect(session.getMessages().some(m => m.approval?.status === "pending")).toBe(true));
|
|
236
|
+
const fresh = session.getMessages().find(m => m.approval?.status === "pending")!;
|
|
237
|
+
expect(fresh.id).not.toBe("approval-webmcp-1");
|
|
238
|
+
expect(resume).not.toHaveBeenCalled();
|
|
239
|
+
session.resolveWebMcpApproval(fresh.id, "approved");
|
|
240
|
+
session.resolveWebMcpApproval(fresh.id, "approved");
|
|
241
|
+
await vi.waitFor(() => expect(resume).toHaveBeenCalledTimes(1));
|
|
242
|
+
expect(execute).toHaveBeenCalledTimes(1);
|
|
243
|
+
await vi.waitFor(() => expect(session.getMessages().some(m => m.toolCall?.result !== undefined)).toBe(true));
|
|
244
|
+
const completedSnapshot = session.getMessages();
|
|
245
|
+
session.hydrateMessages(completedSnapshot);
|
|
246
|
+
await session.connectStream(stream(
|
|
247
|
+
frame({ type: "tool_start", executionId: "exec-1", toolCallId: "call-1", toolName: "webmcp:purchase", parameters: info.args }) +
|
|
248
|
+
frame({ type: "tool_output_delta", executionId: "exec-1", toolCallId: "call-1", delta: "stale output" }) +
|
|
249
|
+
awaitFrame + awaitFrame
|
|
250
|
+
));
|
|
251
|
+
// Microtask batch / lazy action adoption must have had time to run.
|
|
252
|
+
await new Promise(resolve => setTimeout(resolve, 20));
|
|
253
|
+
expect(execute).toHaveBeenCalledTimes(1);
|
|
254
|
+
expect(resume).toHaveBeenCalledTimes(1);
|
|
255
|
+
session.destroy();
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
it("a restored server approval still continues through the configured transport", async () => {
|
|
259
|
+
const server = approval();
|
|
260
|
+
server.approval = { ...server.approval!, id: "server-gate", toolType: "server", agentId: "agent", executionId: "durable-exec" };
|
|
261
|
+
server.id = "approval-server-gate";
|
|
262
|
+
const onDecision = vi.fn(async () => new ReadableStream<Uint8Array>({ start(c) {
|
|
263
|
+
c.enqueue(new TextEncoder().encode('data: {"type":"text_delta","delta":"Server continued"}\n\ndata: {"type":"execution_complete","success":true,"kind":"agent"}\n\n'));
|
|
264
|
+
c.close();
|
|
265
|
+
} }));
|
|
266
|
+
const session = makeSession({ initialMessages: [server], approval: { onDecision } });
|
|
267
|
+
expect(onDecision).not.toHaveBeenCalled();
|
|
268
|
+
await session.resolveApproval(session.getMessages()[0].approval!, "approved");
|
|
269
|
+
expect(onDecision).toHaveBeenCalledWith({ approvalId: "server-gate", executionId: "durable-exec", agentId: "agent", toolName: info.toolName }, "approved", undefined);
|
|
270
|
+
expect(session.getMessages().some(m => m.content === "Server continued")).toBe(true);
|
|
271
|
+
session.destroy();
|
|
272
|
+
});
|
package/src/session.test.ts
CHANGED
|
@@ -226,6 +226,29 @@ describe('AgentWidgetSession - Message Injection', () => {
|
|
|
226
226
|
expect(messages[0].content).toBe('First message');
|
|
227
227
|
expect(messages[1].content).toBe('Second message');
|
|
228
228
|
});
|
|
229
|
+
|
|
230
|
+
it('should re-sort when an update moves a message past its neighbors', () => {
|
|
231
|
+
session.injectMessage({
|
|
232
|
+
id: 'a',
|
|
233
|
+
role: 'user',
|
|
234
|
+
content: 'A',
|
|
235
|
+
createdAt: '2025-01-01T00:00:01.000Z'
|
|
236
|
+
});
|
|
237
|
+
session.injectMessage({
|
|
238
|
+
id: 'b',
|
|
239
|
+
role: 'assistant',
|
|
240
|
+
content: 'B',
|
|
241
|
+
createdAt: '2025-01-01T00:00:02.000Z'
|
|
242
|
+
});
|
|
243
|
+
session.injectMessage({
|
|
244
|
+
id: 'a',
|
|
245
|
+
role: 'user',
|
|
246
|
+
content: 'A, later',
|
|
247
|
+
createdAt: '2025-01-01T00:00:03.000Z'
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
expect(messages.map((message) => message.content)).toEqual(['B', 'A, later']);
|
|
251
|
+
});
|
|
229
252
|
});
|
|
230
253
|
|
|
231
254
|
describe('backward compatibility', () => {
|