@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,273 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { AgentWidgetClient } from "./client";
|
|
3
|
+
import { AgentWidgetSession, type AgentWidgetSessionStatus } from "./session";
|
|
4
|
+
import type { AgentWidgetConfig, AgentWidgetMessage, ResumableHandle } from "./types";
|
|
5
|
+
|
|
6
|
+
// runtypelabs/core#9961: resumed replies must keep the id the server stores
|
|
7
|
+
// them under, and widget failure notices must never become history.
|
|
8
|
+
|
|
9
|
+
const originalFetch = global.fetch;
|
|
10
|
+
afterEach(() => {
|
|
11
|
+
global.fetch = originalFetch;
|
|
12
|
+
vi.restoreAllMocks();
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
const enc = new TextEncoder();
|
|
16
|
+
const frame = (data: Record<string, unknown>) => `data: ${JSON.stringify(data)}\n\n`;
|
|
17
|
+
const sseStream = (text: string) =>
|
|
18
|
+
new ReadableStream<Uint8Array>({
|
|
19
|
+
start(c) {
|
|
20
|
+
if (text) c.enqueue(enc.encode(text));
|
|
21
|
+
c.close();
|
|
22
|
+
},
|
|
23
|
+
});
|
|
24
|
+
const replyFrames = (text: string) =>
|
|
25
|
+
frame({ type: "text_start", id: "t1" }) +
|
|
26
|
+
frame({ type: "text_delta", id: "t1", delta: text }) +
|
|
27
|
+
frame({ type: "text_complete", id: "t1" }) +
|
|
28
|
+
frame({ type: "execution_complete", kind: "agent", success: true });
|
|
29
|
+
|
|
30
|
+
type ClientInternals = {
|
|
31
|
+
clientSession: { sessionId: string; expiresAt: Date };
|
|
32
|
+
isWebMcpOperational: () => boolean;
|
|
33
|
+
executeWebMcpToolCall: () => Promise<unknown>;
|
|
34
|
+
resumeFlow: (...args: unknown[]) => Promise<Response>;
|
|
35
|
+
};
|
|
36
|
+
const internals = (session: AgentWidgetSession) =>
|
|
37
|
+
(session as unknown as { client: ClientInternals }).client;
|
|
38
|
+
|
|
39
|
+
const track = (config: AgentWidgetConfig) => {
|
|
40
|
+
const state = {
|
|
41
|
+
messages: [] as AgentWidgetMessage[],
|
|
42
|
+
status: "idle" as AgentWidgetSessionStatus,
|
|
43
|
+
errors: [] as Error[],
|
|
44
|
+
};
|
|
45
|
+
const session = new AgentWidgetSession(config, {
|
|
46
|
+
onMessagesChanged: (m) => {
|
|
47
|
+
state.messages = m;
|
|
48
|
+
},
|
|
49
|
+
onStatusChanged: (s) => {
|
|
50
|
+
state.status = s;
|
|
51
|
+
},
|
|
52
|
+
onStreamingChanged: () => {},
|
|
53
|
+
onError: (e) => state.errors.push(e),
|
|
54
|
+
});
|
|
55
|
+
return { session, state };
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
const awaitingWebMcpTool = (): AgentWidgetMessage => ({
|
|
59
|
+
id: "tool-call-1",
|
|
60
|
+
role: "assistant",
|
|
61
|
+
content: "",
|
|
62
|
+
createdAt: "2026-01-01T00:00:01.000Z",
|
|
63
|
+
streaming: true,
|
|
64
|
+
variant: "tool",
|
|
65
|
+
agentMetadata: { executionId: "exec-1", awaitingLocalTool: true, webMcpToolCallId: "call-1" },
|
|
66
|
+
toolCall: { id: "call-1", name: "webmcp:purchase", status: "running", args: { quantity: 1 } },
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
describe("resumed reply ids", () => {
|
|
70
|
+
const tokenClient = () => {
|
|
71
|
+
const client = new AgentWidgetClient({ clientToken: "ct_live_demo", apiUrl: "https://api.runtype.com" });
|
|
72
|
+
(client as unknown as ClientInternals).clientSession = {
|
|
73
|
+
sessionId: "cs_123",
|
|
74
|
+
expiresAt: new Date(Date.now() + 60_000),
|
|
75
|
+
};
|
|
76
|
+
return client;
|
|
77
|
+
};
|
|
78
|
+
const captureBodies = () => {
|
|
79
|
+
const bodies: Record<string, unknown>[] = [];
|
|
80
|
+
global.fetch = vi.fn(async (_url: string, init: RequestInit) => {
|
|
81
|
+
bodies.push(JSON.parse(init.body as string));
|
|
82
|
+
return new Response(sseStream(""));
|
|
83
|
+
}) as unknown as typeof fetch;
|
|
84
|
+
return bodies;
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
it("sends the local bubble id as assistantMessageId on a client-token /resume", async () => {
|
|
88
|
+
const bodies = captureBodies();
|
|
89
|
+
await tokenClient().resumeFlow("exec_1", { call_1: "ok" }, { assistantMessageId: "ast_local" });
|
|
90
|
+
expect(bodies[0]).toMatchObject({ executionId: "exec_1", sessionId: "cs_123", assistantMessageId: "ast_local" });
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("keeps the proxy /resume body unchanged", async () => {
|
|
94
|
+
const bodies = captureBodies();
|
|
95
|
+
const client = new AgentWidgetClient({ apiUrl: "https://proxy.example.com/chat" });
|
|
96
|
+
await client.resumeFlow("exec_1", { call_1: "ok" }, { assistantMessageId: "ast_local" });
|
|
97
|
+
expect(bodies[0].assistantMessageId).toBeUndefined();
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it("renders a resumed WebMCP leg into the bubble whose id it sent", async () => {
|
|
101
|
+
const { session, state } = track({ apiUrl: "http://test", webmcp: { enabled: true } });
|
|
102
|
+
const client = internals(session);
|
|
103
|
+
client.isWebMcpOperational = () => true;
|
|
104
|
+
client.executeWebMcpToolCall = vi.fn(async () => ({ content: [{ type: "text", text: "Purchased" }] }));
|
|
105
|
+
const resume = vi.fn(async () => new Response(sseStream(replyFrames("Your order is in."))));
|
|
106
|
+
client.resumeFlow = resume;
|
|
107
|
+
|
|
108
|
+
await session.resolveWebMcpToolCall(awaitingWebMcpTool());
|
|
109
|
+
|
|
110
|
+
expect(resume).toHaveBeenCalledTimes(1);
|
|
111
|
+
const sentId = (resume.mock.calls[0] as unknown[])[2] as { assistantMessageId?: string };
|
|
112
|
+
expect(sentId.assistantMessageId).toMatch(/^ast_/);
|
|
113
|
+
const reply = state.messages.find((m) => m.content === "Your order is in.");
|
|
114
|
+
expect(reply?.id).toBe(sentId.assistantMessageId);
|
|
115
|
+
session.destroy();
|
|
116
|
+
});
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
describe("local-only failure notices", () => {
|
|
120
|
+
it("shows a dispatch failure but never sends it as history", async () => {
|
|
121
|
+
const payloads: Array<{ messages: Array<{ role: string; content: unknown }> }> = [];
|
|
122
|
+
let calls = 0;
|
|
123
|
+
const { session, state } = track({
|
|
124
|
+
apiUrl: "http://test",
|
|
125
|
+
customFetch: async (_url, _init, payload) => {
|
|
126
|
+
payloads.push(payload as (typeof payloads)[number]);
|
|
127
|
+
calls += 1;
|
|
128
|
+
if (calls === 1) throw new Error("upstream unavailable");
|
|
129
|
+
return new Response(sseStream(replyFrames("Back online.")));
|
|
130
|
+
},
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
await session.sendMessage("first");
|
|
134
|
+
const notice = state.messages.at(-1)!;
|
|
135
|
+
expect(notice.role).toBe("assistant");
|
|
136
|
+
expect(notice.localOnly).toBe(true);
|
|
137
|
+
|
|
138
|
+
await session.sendMessage("second");
|
|
139
|
+
expect(payloads[1].messages.map((m) => m.content)).toEqual(["first", "second"]);
|
|
140
|
+
session.destroy();
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it("drops reconnect-failure bubbles stored before the flag existed", async () => {
|
|
144
|
+
const payloads: Array<{ messages: Array<{ content: unknown }> }> = [];
|
|
145
|
+
const { session } = track({
|
|
146
|
+
apiUrl: "http://test",
|
|
147
|
+
initialMessages: [
|
|
148
|
+
{ id: "u1", role: "user", content: "hi", createdAt: "2026-01-01T00:00:00.000Z" },
|
|
149
|
+
{
|
|
150
|
+
id: "reconnect-failed-exec_1",
|
|
151
|
+
role: "assistant",
|
|
152
|
+
content: "Connection lost and the response could not be resumed.",
|
|
153
|
+
createdAt: "2026-01-01T00:00:01.000Z",
|
|
154
|
+
},
|
|
155
|
+
],
|
|
156
|
+
customFetch: async (_url, _init, payload) => {
|
|
157
|
+
payloads.push(payload as (typeof payloads)[number]);
|
|
158
|
+
return new Response(sseStream(replyFrames("ok")));
|
|
159
|
+
},
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
await session.sendMessage("again");
|
|
163
|
+
expect(payloads[0].messages.map((m) => m.content)).toEqual(["hi", "again"]);
|
|
164
|
+
session.destroy();
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it("marks the reconnect give-up bubble local-only", async () => {
|
|
168
|
+
const { session, state } = track({
|
|
169
|
+
apiUrl: "http://test",
|
|
170
|
+
reconnectStream: async () => {
|
|
171
|
+
throw new Error("network down");
|
|
172
|
+
},
|
|
173
|
+
reconnect: { backoffMs: [1], maxAttempts: 1 },
|
|
174
|
+
});
|
|
175
|
+
session.hydrateMessages([
|
|
176
|
+
{ id: "u1", role: "user", content: "tell me", createdAt: "2026-01-01T00:00:00.000Z" },
|
|
177
|
+
{ id: "a1", role: "assistant", content: "Once", createdAt: "2026-01-01T00:00:01.000Z" },
|
|
178
|
+
]);
|
|
179
|
+
|
|
180
|
+
session.resumeFromHandle({ executionId: "exec_5", after: "5" });
|
|
181
|
+
await vi.waitFor(() => expect(state.errors).toHaveLength(1));
|
|
182
|
+
|
|
183
|
+
const notice = state.messages.find((m) => m.id === "reconnect-failed-exec_5");
|
|
184
|
+
expect(notice?.localOnly).toBe(true);
|
|
185
|
+
session.destroy();
|
|
186
|
+
});
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
describe("boot resume of a run parked on an await", () => {
|
|
190
|
+
it("keeps retrying when the reconnect request itself fails", async () => {
|
|
191
|
+
let calls = 0;
|
|
192
|
+
const reconnectStream = vi.fn(async () => {
|
|
193
|
+
calls += 1;
|
|
194
|
+
if (calls === 1) throw new Error("network blip");
|
|
195
|
+
return new Response(sseStream(""));
|
|
196
|
+
});
|
|
197
|
+
const { session, state } = track({
|
|
198
|
+
apiUrl: "http://test",
|
|
199
|
+
webmcp: { enabled: true },
|
|
200
|
+
reconnectStream,
|
|
201
|
+
reconnect: { backoffMs: [1, 1, 1, 1, 1], maxAttempts: 5 },
|
|
202
|
+
});
|
|
203
|
+
session.hydrateMessages([
|
|
204
|
+
{ id: "u1", role: "user", content: "buy one", createdAt: "2026-01-01T00:00:00.000Z" },
|
|
205
|
+
{ id: "a1", role: "assistant", content: "Buying it now.", createdAt: "2026-01-01T00:00:00.500Z" },
|
|
206
|
+
awaitingWebMcpTool(),
|
|
207
|
+
]);
|
|
208
|
+
|
|
209
|
+
session.resumeFromHandle({ executionId: "exec-1", after: "7" });
|
|
210
|
+
await vi.waitFor(() => expect(state.status).toBe("idle"));
|
|
211
|
+
await new Promise((r) => setTimeout(r, 20));
|
|
212
|
+
|
|
213
|
+
expect(reconnectStream).toHaveBeenCalledTimes(2);
|
|
214
|
+
expect(state.errors).toEqual([]);
|
|
215
|
+
expect(session.getResumableHandle()).toBeNull();
|
|
216
|
+
session.destroy();
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
it("ends quietly when the reload interrupted the WebMCP call and nothing replays", async () => {
|
|
220
|
+
const executionStates: Array<ResumableHandle | null> = [];
|
|
221
|
+
const reconnectStream = vi.fn(async () => new Response(sseStream("")));
|
|
222
|
+
const { session, state } = track({
|
|
223
|
+
apiUrl: "http://test",
|
|
224
|
+
webmcp: { enabled: true },
|
|
225
|
+
reconnectStream,
|
|
226
|
+
reconnect: { backoffMs: [1, 1, 1, 1, 1], maxAttempts: 5 },
|
|
227
|
+
onExecutionState: (h) => executionStates.push(h),
|
|
228
|
+
});
|
|
229
|
+
session.hydrateMessages([
|
|
230
|
+
{ id: "u1", role: "user", content: "buy one", createdAt: "2026-01-01T00:00:00.000Z" },
|
|
231
|
+
{ id: "a1", role: "assistant", content: "Buying it now.", createdAt: "2026-01-01T00:00:00.500Z" },
|
|
232
|
+
awaitingWebMcpTool(),
|
|
233
|
+
]);
|
|
234
|
+
|
|
235
|
+
session.resumeFromHandle({ executionId: "exec-1", after: "7" });
|
|
236
|
+
await vi.waitFor(() => expect(state.status).toBe("idle"));
|
|
237
|
+
await new Promise((r) => setTimeout(r, 20));
|
|
238
|
+
|
|
239
|
+
expect(reconnectStream).toHaveBeenCalledTimes(1);
|
|
240
|
+
expect(state.errors).toEqual([]);
|
|
241
|
+
expect(state.messages.some((m) => m.id.startsWith("reconnect-failed-"))).toBe(false);
|
|
242
|
+
expect(state.messages.every((m) => !m.streaming)).toBe(true);
|
|
243
|
+
expect(state.messages.map((m) => m.id)).toEqual(["u1", "a1", "tool-call-1"]);
|
|
244
|
+
expect(session.getResumableHandle()).toBeNull();
|
|
245
|
+
expect(executionStates.at(-1)).toBeNull();
|
|
246
|
+
session.destroy();
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
it("keeps the cursor for a pending ask_user_question card instead of reconnecting", async () => {
|
|
250
|
+
const reconnectStream = vi.fn(async () => new Response(sseStream("")));
|
|
251
|
+
const { session, state } = track({ apiUrl: "http://test", reconnectStream });
|
|
252
|
+
session.hydrateMessages([
|
|
253
|
+
{ id: "u1", role: "user", content: "plan a trip", createdAt: "2026-01-01T00:00:00.000Z" },
|
|
254
|
+
{
|
|
255
|
+
id: "tool-ask-1",
|
|
256
|
+
role: "assistant",
|
|
257
|
+
content: "",
|
|
258
|
+
createdAt: "2026-01-01T00:00:01.000Z",
|
|
259
|
+
variant: "tool",
|
|
260
|
+
agentMetadata: { executionId: "exec-2", awaitingLocalTool: true },
|
|
261
|
+
toolCall: { id: "ask-1", name: "ask_user_question", status: "running" },
|
|
262
|
+
},
|
|
263
|
+
]);
|
|
264
|
+
|
|
265
|
+
session.resumeFromHandle({ executionId: "exec-2", after: "4" });
|
|
266
|
+
await new Promise((r) => setTimeout(r, 20));
|
|
267
|
+
|
|
268
|
+
expect(reconnectStream).not.toHaveBeenCalled();
|
|
269
|
+
expect(state.status).toBe("idle");
|
|
270
|
+
expect(session.getResumableHandle()).toMatchObject({ executionId: "exec-2", lastEventId: "4" });
|
|
271
|
+
session.destroy();
|
|
272
|
+
});
|
|
273
|
+
});
|
|
@@ -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.
|