@runtypelabs/persona 4.28.2 → 4.29.1
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-DrYAfpvk.js → chunk-BcLIgi1h.js} +1 -1
- package/dist/chunk-BkW8s3fj.js +2 -0
- package/dist/chunk-Bm2-bemv.js +1 -0
- package/dist/chunk-BvhTpG6k.js +1 -0
- package/dist/chunk-Csks3Qw1.js +1 -0
- package/dist/chunk-D5B629Mj.js +1 -0
- package/dist/chunk-DGlFKA0r.js +9 -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-dRBlrLd7.js → chunk-DtmtfIkd.js} +1 -1
- package/dist/chunk-lA5CJCgK.js +1 -0
- 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-C39npJaz.d.cts → composer-input-B0P4OtNi.d.cts} +1 -1
- package/dist/{composer-input-C39npJaz.d.ts → composer-input-B0P4OtNi.d.ts} +1 -1
- package/dist/{context-mention-manager-DLCB1l9E.d.cts → context-mention-manager-DmLI_RM6.d.cts} +1 -1
- package/dist/{context-mention-manager-DLCB1l9E.d.ts → context-mention-manager-DmLI_RM6.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-DiAgWHXy.d.cts → defaults-DVo1wbZs.d.cts} +1 -1
- package/dist/{defaults-DiAgWHXy.d.ts → defaults-DVo1wbZs.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-C5_PUZjH.d.cts → fallback-speech-engine-BMnlmKnY.d.cts} +1 -1
- package/dist/{fallback-speech-engine-C5_PUZjH.d.ts → fallback-speech-engine-BMnlmKnY.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 +50 -49
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +7 -7
- package/dist/index.d.ts +7 -7
- package/dist/index.global.js +2 -3
- package/dist/index.global.js.map +1 -1
- package/dist/index.js +38 -37
- package/dist/index.js.map +1 -1
- package/dist/{markdown-parsers-loader-BBdyIEU7.d.cts → markdown-parsers-loader-CeovcyYf.d.cts} +1 -1
- package/dist/{markdown-parsers-loader-BBdyIEU7.d.ts → markdown-parsers-loader-CeovcyYf.d.ts} +1 -1
- package/dist/{preview-Chjb0bmI.d.cts → preview-Bkb4UrQC.d.cts} +2 -2
- package/dist/{preview-Chjb0bmI.d.ts → preview-Bkb4UrQC.d.ts} +2 -2
- package/dist/session-actions.js +1 -1
- package/dist/{session-a_qIp4xp.d.ts → session-iLyKipoM.d.cts} +28 -10
- package/dist/{session-a_qIp4xp.d.cts → session-iLyKipoM.d.ts} +28 -10
- 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 +61 -60
- package/dist/theme-editor-preview.d.cts +1 -1
- package/dist/theme-editor-preview.d.ts +1 -1
- package/dist/theme-editor-preview.js +40 -40
- 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-BdO1jNRm.d.cts → types-BuDirJkQ.d.cts} +200 -23
- package/dist/{types-BdO1jNRm.d.ts → types-BuDirJkQ.d.ts} +200 -23
- package/dist/voice-client-tools.cjs +1 -0
- package/dist/voice-client-tools.d.cts +13 -0
- package/dist/voice-client-tools.d.ts +13 -0
- package/dist/voice-client-tools.js +1 -0
- package/dist/voice-runtime.cjs +3 -3
- package/dist/voice-runtime.d.cts +15 -5
- package/dist/voice-runtime.d.ts +15 -5
- 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-B6bpVWSa.d.ts → webmcp-runtime-entry-CXLjRt1k.d.cts} +26 -5
- package/dist/{webmcp-runtime-entry-B6bpVWSa.d.cts → webmcp-runtime-entry-CXLjRt1k.d.ts} +26 -5
- package/dist/webmcp-runtime.cjs +2 -1
- package/dist/webmcp-runtime.d.cts +1 -1
- package/dist/webmcp-runtime.d.ts +1 -1
- package/dist/webmcp-runtime.js +2 -1
- package/dist/widget.css +1 -1
- package/package.json +6 -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 +61 -4
- package/src/client.voice-tools.test.ts +65 -0
- 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 +39 -1
- package/src/index-core.ts +11 -0
- package/src/index-global.ts +6 -0
- 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 +26 -0
- package/src/session.ts +185 -31
- package/src/session.voice.test.ts +212 -0
- package/src/styles/widget.css +4 -2
- package/src/types.ts +178 -22
- package/src/ui.restored-browser-tools.test.ts +128 -0
- package/src/ui.ts +32 -18
- 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/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 +94 -7
- package/src/voice/session-voice-wiring.ts +27 -3
- package/src/voice/voice-client-tools-session.ts +116 -0
- package/src/voice/voice-delegation.ts +19 -3
- package/src/voice/voice.test.ts +396 -0
- package/src/voice-client-tools-bundle.test.ts +37 -0
- package/src/voice-client-tools-loader.ts +24 -0
- package/src/voice-client-tools.ts +9 -0
- package/src/webmcp-bridge.test.ts +152 -1
- package/src/webmcp-bridge.ts +5 -6
- package/src/webmcp-polyfill.integration.test.ts +5 -1
- package/src/webmcp-runtime-entry.ts +151 -26
- package/dist/chunk-B6zZsT6F.js +0 -1
- package/dist/chunk-CztSjKHK.js +0 -9
- package/dist/chunk-DbhxjyOB.js +0 -4
- package/dist/chunk-DsdqLStH.js +0 -1
- package/dist/chunk-GIqbL2Eg.js +0 -1
- package/dist/chunk-HUOUif-P.js +0 -1
- package/dist/chunk-UlcxdHef.js +0 -1
|
@@ -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
|
@@ -1259,4 +1259,30 @@ describe('AgentWidgetSession - WebMCP native approval gate', () => {
|
|
|
1259
1259
|
getMessages().find((m) => m.variant === 'approval')?.approval?.status
|
|
1260
1260
|
).toBe('approved');
|
|
1261
1261
|
});
|
|
1262
|
+
|
|
1263
|
+
it('closes the bubble and resolves false when the call signal aborts, and ignores a later click', async () => {
|
|
1264
|
+
const { session, getMessages } = makeSession();
|
|
1265
|
+
const abort = new AbortController();
|
|
1266
|
+
const promise = session.requestWebMcpApproval({
|
|
1267
|
+
toolName: 'edit_slide',
|
|
1268
|
+
args: {},
|
|
1269
|
+
reason: 'gate',
|
|
1270
|
+
signal: abort.signal,
|
|
1271
|
+
});
|
|
1272
|
+
const bubble = getMessages().find((m) => m.variant === 'approval')!;
|
|
1273
|
+
expect(bubble.approval?.status).toBe('pending');
|
|
1274
|
+
abort.abort();
|
|
1275
|
+
await expect(promise).resolves.toBe(false);
|
|
1276
|
+
expect(getMessages().find((m) => m.variant === 'approval')?.approval?.status).toBe('timeout');
|
|
1277
|
+
session.resolveWebMcpApproval(bubble.id, 'approved');
|
|
1278
|
+
expect(getMessages().find((m) => m.variant === 'approval')?.approval?.status).toBe('timeout');
|
|
1279
|
+
});
|
|
1280
|
+
|
|
1281
|
+
it('shows no bubble for a call whose signal already aborted', async () => {
|
|
1282
|
+
const { session, getMessages } = makeSession();
|
|
1283
|
+
await expect(
|
|
1284
|
+
session.requestWebMcpApproval({ toolName: 'edit_slide', args: {}, reason: 'gate', signal: AbortSignal.abort() })
|
|
1285
|
+
).resolves.toBe(false);
|
|
1286
|
+
expect(getMessages().some((m) => m.variant === 'approval')).toBe(false);
|
|
1287
|
+
});
|
|
1262
1288
|
});
|