@runtypelabs/persona 3.37.0 → 4.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +0 -1
- package/dist/animations/glyph-cycle.d.cts +1 -1
- package/dist/animations/glyph-cycle.d.ts +1 -1
- package/dist/animations/{types-DgYsuwXL.d.cts → types-B_xbfvR0.d.cts} +263 -181
- package/dist/animations/{types-DgYsuwXL.d.ts → types-B_xbfvR0.d.ts} +263 -181
- package/dist/animations/wipe.d.cts +1 -1
- package/dist/animations/wipe.d.ts +1 -1
- package/dist/codegen.cjs +1 -1
- package/dist/codegen.js +1 -1
- package/dist/index.cjs +52 -52
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +327 -838
- package/dist/index.d.ts +327 -838
- package/dist/index.global.js +39 -39
- package/dist/index.global.js.map +1 -1
- package/dist/index.js +52 -52
- package/dist/index.js.map +1 -1
- package/dist/install.global.js +1 -1
- package/dist/install.global.js.map +1 -1
- package/dist/smart-dom-reader.d.cts +268 -191
- package/dist/smart-dom-reader.d.ts +268 -191
- package/dist/theme-editor-preview.cjs +53 -53
- package/dist/theme-editor-preview.d.cts +268 -191
- package/dist/theme-editor-preview.d.ts +268 -191
- package/dist/theme-editor-preview.js +55 -55
- package/dist/theme-editor.d.cts +268 -191
- package/dist/theme-editor.d.ts +268 -191
- package/package.json +1 -1
- package/src/client.test.ts +446 -1041
- package/src/client.ts +684 -967
- package/src/components/tool-bubble.ts +46 -33
- package/src/generated/runtype-openapi-contract.ts +210 -714
- package/src/index-core.ts +2 -3
- package/src/install.test.ts +1 -34
- package/src/install.ts +1 -26
- package/src/runtime/init.test.ts +8 -67
- package/src/runtime/init.ts +2 -17
- package/src/session.test.ts +8 -8
- package/src/session.ts +1 -1
- package/src/types.ts +11 -12
- package/src/ui.postprocess.test.ts +107 -0
- package/src/ui.ts +40 -5
- package/src/utils/__fixtures__/unified-translator.oracle.ts +62 -14
- package/src/utils/copy-selection.test.ts +37 -0
- package/src/utils/copy-selection.ts +19 -0
- package/src/utils/event-stream-capture.test.ts +9 -64
- package/src/utils/sequence-buffer.test.ts +0 -256
- package/src/utils/sequence-buffer.ts +0 -130
- package/src/utils/unified-event-bridge.test.ts +0 -263
- package/src/utils/unified-event-bridge.ts +0 -431
package/src/index-core.ts
CHANGED
|
@@ -108,11 +108,10 @@ export type {
|
|
|
108
108
|
} from "./types";
|
|
109
109
|
|
|
110
110
|
export type {
|
|
111
|
-
|
|
111
|
+
RuntypeExecutionStreamEvent,
|
|
112
112
|
RuntypeFlowSSEEvent,
|
|
113
|
-
RuntypeDispatchSSEEvent,
|
|
114
113
|
RuntypeStreamEventOf,
|
|
115
|
-
|
|
114
|
+
RuntypeTurnCompleteEvent,
|
|
116
115
|
RuntypeStepCompleteEvent,
|
|
117
116
|
RuntypeStopReasonKind,
|
|
118
117
|
RuntypeClientInitRequest,
|
package/src/install.test.ts
CHANGED
|
@@ -15,7 +15,6 @@ const PERSONA_EVENTS = [
|
|
|
15
15
|
"persona:script-load",
|
|
16
16
|
"persona:launcher-shown",
|
|
17
17
|
"persona:chat-ready",
|
|
18
|
-
"persona:ready",
|
|
19
18
|
"persona:error",
|
|
20
19
|
] as const;
|
|
21
20
|
|
|
@@ -28,7 +27,6 @@ let launcherDestroy: ReturnType<typeof vi.fn>;
|
|
|
28
27
|
let launcherElement: HTMLButtonElement;
|
|
29
28
|
let initAgentWidget: ReturnType<typeof vi.fn>;
|
|
30
29
|
let fakeHandle: FakeHandle;
|
|
31
|
-
let warnSpy: ReturnType<typeof vi.spyOn>;
|
|
32
30
|
let errorSpy: ReturnType<typeof vi.spyOn>;
|
|
33
31
|
|
|
34
32
|
/** Subscribe to every persona:* event and record dispatch order + detail. */
|
|
@@ -128,7 +126,7 @@ beforeEach(() => {
|
|
|
128
126
|
fakeHandle = { open: vi.fn(), close: vi.fn(), on: vi.fn() };
|
|
129
127
|
initAgentWidget = vi.fn(() => fakeHandle);
|
|
130
128
|
|
|
131
|
-
|
|
129
|
+
vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
132
130
|
errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
133
131
|
});
|
|
134
132
|
|
|
@@ -345,37 +343,6 @@ describe("install.ts: deferral gate", () => {
|
|
|
345
343
|
});
|
|
346
344
|
});
|
|
347
345
|
|
|
348
|
-
describe("install.ts: onReady deprecation alias", () => {
|
|
349
|
-
it("still calls the deprecated onReady and warns once", async () => {
|
|
350
|
-
markCssLoaded();
|
|
351
|
-
provideFullBundle();
|
|
352
|
-
const onReady = vi.fn();
|
|
353
|
-
|
|
354
|
-
await install({ config: { apiUrl: "/api", launcher: { enabled: false } }, onReady });
|
|
355
|
-
await flush();
|
|
356
|
-
|
|
357
|
-
expect(onReady).toHaveBeenCalledTimes(1);
|
|
358
|
-
expect(onReady).toHaveBeenCalledWith(fakeHandle);
|
|
359
|
-
expect(warnSpy).toHaveBeenCalledTimes(1);
|
|
360
|
-
expect(String(warnSpy.mock.calls[0]?.[0])).toContain("`onReady` is deprecated");
|
|
361
|
-
});
|
|
362
|
-
|
|
363
|
-
it("prefers onChatReady over onReady and does not warn", async () => {
|
|
364
|
-
markCssLoaded();
|
|
365
|
-
provideFullBundle();
|
|
366
|
-
const onReady = vi.fn();
|
|
367
|
-
const onChatReady = vi.fn();
|
|
368
|
-
|
|
369
|
-
await install({ config: { apiUrl: "/api", launcher: { enabled: false } }, onReady, onChatReady });
|
|
370
|
-
await flush();
|
|
371
|
-
|
|
372
|
-
expect(onChatReady).toHaveBeenCalledTimes(1);
|
|
373
|
-
expect(onChatReady).toHaveBeenCalledWith(fakeHandle);
|
|
374
|
-
expect(onReady).not.toHaveBeenCalled();
|
|
375
|
-
expect(warnSpy).not.toHaveBeenCalled();
|
|
376
|
-
});
|
|
377
|
-
});
|
|
378
|
-
|
|
379
346
|
describe("install.ts: onError", () => {
|
|
380
347
|
it("fires onError with phase 'init' and dispatches persona:error when initialization throws", async () => {
|
|
381
348
|
markCssLoaded();
|
package/src/install.ts
CHANGED
|
@@ -18,9 +18,6 @@ interface SiteAgentInstallConfig {
|
|
|
18
18
|
clientToken?: string;
|
|
19
19
|
flowId?: string;
|
|
20
20
|
apiUrl?: string;
|
|
21
|
-
/** Wire vocabulary requested from the dispatch endpoint; 'unified' opts into the
|
|
22
|
-
* API's neutral event schema (transparently bridged back to the same rendering). */
|
|
23
|
-
events?: "legacy" | "unified";
|
|
24
21
|
// Optional query param key that gates widget installation in preview mode
|
|
25
22
|
previewQueryParam?: string;
|
|
26
23
|
// Shadow DOM option (defaults to false for better CSS compatibility)
|
|
@@ -46,11 +43,6 @@ interface SiteAgentInstallConfig {
|
|
|
46
43
|
* installs: on page load.
|
|
47
44
|
*/
|
|
48
45
|
onChatReady?: (handle: any) => void;
|
|
49
|
-
/**
|
|
50
|
-
* @deprecated Use `onChatReady`. Retained as a working alias; it will be
|
|
51
|
-
* removed in the next major version.
|
|
52
|
-
*/
|
|
53
|
-
onReady?: (handle: any) => void;
|
|
54
46
|
/**
|
|
55
47
|
* Fired when a load step fails (stylesheet, full bundle, or init), so you can
|
|
56
48
|
* detect ad-blocked / timed-out installs instead of failing silently.
|
|
@@ -163,21 +155,6 @@ declare global {
|
|
|
163
155
|
safeCall(config.onError, { phase, error });
|
|
164
156
|
dispatchLifecycle("persona:error", { phase, error });
|
|
165
157
|
};
|
|
166
|
-
// `onReady` is the deprecated alias of `onChatReady`; warn once if it's used.
|
|
167
|
-
let warnedOnReadyDeprecated = false;
|
|
168
|
-
const resolveChatReady = (): ((handle: any) => void) | undefined => {
|
|
169
|
-
if (config.onChatReady) return config.onChatReady;
|
|
170
|
-
if (config.onReady) {
|
|
171
|
-
if (!warnedOnReadyDeprecated) {
|
|
172
|
-
warnedOnReadyDeprecated = true;
|
|
173
|
-
console.warn(
|
|
174
|
-
"[Persona] `onReady` is deprecated: use `onChatReady`. `onReady` still works but is removed in the next major."
|
|
175
|
-
);
|
|
176
|
-
}
|
|
177
|
-
return config.onReady;
|
|
178
|
-
}
|
|
179
|
-
return undefined;
|
|
180
|
-
};
|
|
181
158
|
// True when the config renders a standard floating launcher button: the only
|
|
182
159
|
// case that paints a clickable launcher at load. Shared by the deferral gate
|
|
183
160
|
// and the eager-path `onLauncherShown` so the event name stays honest.
|
|
@@ -380,7 +357,6 @@ declare global {
|
|
|
380
357
|
if (config.apiUrl && !widgetConfig.apiUrl) widgetConfig.apiUrl = config.apiUrl;
|
|
381
358
|
if (config.clientToken && !widgetConfig.clientToken) widgetConfig.clientToken = config.clientToken;
|
|
382
359
|
if (config.flowId && !widgetConfig.flowId) widgetConfig.flowId = config.flowId;
|
|
383
|
-
if (config.events && !widgetConfig.events) widgetConfig.events = config.events;
|
|
384
360
|
|
|
385
361
|
const hasApiConfig = !!(widgetConfig.apiUrl || widgetConfig.clientToken);
|
|
386
362
|
return { target, widgetConfig, hasApiConfig };
|
|
@@ -433,9 +409,8 @@ declare global {
|
|
|
433
409
|
}
|
|
434
410
|
|
|
435
411
|
// The full widget is initialized and its controller API is callable.
|
|
436
|
-
safeCall(
|
|
412
|
+
safeCall(config.onChatReady, handle);
|
|
437
413
|
dispatchLifecycle("persona:chat-ready", handle);
|
|
438
|
-
dispatchLifecycle("persona:ready", handle); // deprecated alias: removed next major
|
|
439
414
|
return handle;
|
|
440
415
|
} catch (error) {
|
|
441
416
|
fail("init", error);
|
package/src/runtime/init.test.ts
CHANGED
|
@@ -122,21 +122,6 @@ describe("initAgentWidget windowKey and ready notifications", () => {
|
|
|
122
122
|
handle.destroy();
|
|
123
123
|
});
|
|
124
124
|
|
|
125
|
-
it("calls onReady after initialization", async () => {
|
|
126
|
-
const { initAgentWidget } = await import("./init");
|
|
127
|
-
document.body.innerHTML = `<div id="target"></div>`;
|
|
128
|
-
|
|
129
|
-
const onReady = vi.fn();
|
|
130
|
-
const handle = initAgentWidget({
|
|
131
|
-
target: "#target",
|
|
132
|
-
onReady,
|
|
133
|
-
config: { launcher: { enabled: false } },
|
|
134
|
-
});
|
|
135
|
-
|
|
136
|
-
expect(onReady).toHaveBeenCalledOnce();
|
|
137
|
-
handle.destroy();
|
|
138
|
-
});
|
|
139
|
-
|
|
140
125
|
it("the window key handle proxies controller methods", async () => {
|
|
141
126
|
const { initAgentWidget } = await import("./init");
|
|
142
127
|
document.body.innerHTML = `<div id="target"></div>`;
|
|
@@ -159,7 +144,7 @@ describe("initAgentWidget windowKey and ready notifications", () => {
|
|
|
159
144
|
});
|
|
160
145
|
});
|
|
161
146
|
|
|
162
|
-
describe("initAgentWidget onChatReady
|
|
147
|
+
describe("initAgentWidget onChatReady", () => {
|
|
163
148
|
beforeEach(() => {
|
|
164
149
|
document.body.innerHTML = "";
|
|
165
150
|
createAgentExperienceMock.mockReset();
|
|
@@ -184,65 +169,21 @@ describe("initAgentWidget onChatReady / onReady deprecation", () => {
|
|
|
184
169
|
handle.destroy();
|
|
185
170
|
warn.mockRestore();
|
|
186
171
|
});
|
|
187
|
-
|
|
188
|
-
it("prefers onChatReady over the deprecated onReady (onReady never fires, no warning)", async () => {
|
|
189
|
-
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
190
|
-
const { initAgentWidget } = await import("./init");
|
|
191
|
-
document.body.innerHTML = `<div id="target"></div>`;
|
|
192
|
-
|
|
193
|
-
const onChatReady = vi.fn();
|
|
194
|
-
const onReady = vi.fn();
|
|
195
|
-
const handle = initAgentWidget({
|
|
196
|
-
target: "#target",
|
|
197
|
-
onChatReady,
|
|
198
|
-
onReady,
|
|
199
|
-
config: { launcher: { enabled: false } },
|
|
200
|
-
});
|
|
201
|
-
|
|
202
|
-
expect(onChatReady).toHaveBeenCalledOnce();
|
|
203
|
-
expect(onReady).not.toHaveBeenCalled();
|
|
204
|
-
expect(warn).not.toHaveBeenCalled();
|
|
205
|
-
|
|
206
|
-
handle.destroy();
|
|
207
|
-
warn.mockRestore();
|
|
208
|
-
});
|
|
209
|
-
|
|
210
|
-
it("still calls the deprecated onReady but warns at most once across multiple widgets", async () => {
|
|
211
|
-
// Fresh module so the once-per-page warn flag starts unset.
|
|
212
|
-
vi.resetModules();
|
|
213
|
-
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
214
|
-
const { initAgentWidget } = await import("./init");
|
|
215
|
-
document.body.innerHTML = `<div id="a"></div><div id="b"></div>`;
|
|
216
|
-
|
|
217
|
-
const onReadyA = vi.fn();
|
|
218
|
-
const onReadyB = vi.fn();
|
|
219
|
-
const a = initAgentWidget({ target: "#a", onReady: onReadyA, config: { launcher: { enabled: false } } });
|
|
220
|
-
const b = initAgentWidget({ target: "#b", onReady: onReadyB, config: { launcher: { enabled: false } } });
|
|
221
|
-
|
|
222
|
-
expect(onReadyA).toHaveBeenCalledOnce();
|
|
223
|
-
expect(onReadyB).toHaveBeenCalledOnce();
|
|
224
|
-
expect(warn).toHaveBeenCalledTimes(1);
|
|
225
|
-
expect(String(warn.mock.calls[0]?.[0])).toContain("`onReady` is deprecated");
|
|
226
|
-
|
|
227
|
-
a.destroy();
|
|
228
|
-
b.destroy();
|
|
229
|
-
warn.mockRestore();
|
|
230
|
-
});
|
|
231
172
|
});
|
|
232
173
|
|
|
233
|
-
describe("install script
|
|
174
|
+
describe("install script persona:chat-ready event", () => {
|
|
234
175
|
beforeEach(() => {
|
|
235
176
|
document.body.innerHTML = "";
|
|
236
177
|
createAgentExperienceMock.mockReset();
|
|
237
178
|
createAgentExperienceMock.mockImplementation((_mount, config) => createMockController(config));
|
|
238
179
|
});
|
|
239
180
|
|
|
240
|
-
it("persona:ready event fires with the handle as detail", async () => {
|
|
181
|
+
it("persona:chat-ready event fires with the handle as detail", async () => {
|
|
241
182
|
const { initAgentWidget } = await import("./init");
|
|
242
183
|
document.body.innerHTML = `<div id="target"></div>`;
|
|
243
184
|
|
|
244
185
|
const eventPromise = new Promise<any>((resolve) => {
|
|
245
|
-
window.addEventListener("persona:ready", (e) => {
|
|
186
|
+
window.addEventListener("persona:chat-ready", (e) => {
|
|
246
187
|
resolve((e as CustomEvent).detail);
|
|
247
188
|
}, { once: true });
|
|
248
189
|
});
|
|
@@ -254,7 +195,7 @@ describe("install script onReady and persona:ready event", () => {
|
|
|
254
195
|
});
|
|
255
196
|
|
|
256
197
|
// Simulate what install.ts does after initAgentWidget returns
|
|
257
|
-
window.dispatchEvent(new CustomEvent("persona:ready", { detail: handle }));
|
|
198
|
+
window.dispatchEvent(new CustomEvent("persona:chat-ready", { detail: handle }));
|
|
258
199
|
|
|
259
200
|
const detail = await eventPromise;
|
|
260
201
|
expect(detail).toBe(handle);
|
|
@@ -264,12 +205,12 @@ describe("install script onReady and persona:ready event", () => {
|
|
|
264
205
|
handle.destroy();
|
|
265
206
|
});
|
|
266
207
|
|
|
267
|
-
it("persona:ready event listener set up before init receives the handle", async () => {
|
|
208
|
+
it("persona:chat-ready event listener set up before init receives the handle", async () => {
|
|
268
209
|
const { initAgentWidget } = await import("./init");
|
|
269
210
|
document.body.innerHTML = `<div id="target"></div>`;
|
|
270
211
|
|
|
271
212
|
const received: any[] = [];
|
|
272
|
-
window.addEventListener("persona:ready", (e) => {
|
|
213
|
+
window.addEventListener("persona:chat-ready", (e) => {
|
|
273
214
|
received.push((e as CustomEvent).detail);
|
|
274
215
|
}, { once: true });
|
|
275
216
|
|
|
@@ -279,7 +220,7 @@ describe("install script onReady and persona:ready event", () => {
|
|
|
279
220
|
});
|
|
280
221
|
|
|
281
222
|
// Simulate install.ts dispatching the event
|
|
282
|
-
window.dispatchEvent(new CustomEvent("persona:ready", { detail: handle }));
|
|
223
|
+
window.dispatchEvent(new CustomEvent("persona:chat-ready", { detail: handle }));
|
|
283
224
|
|
|
284
225
|
expect(received).toHaveLength(1);
|
|
285
226
|
expect(received[0]).toBe(handle);
|
package/src/runtime/init.ts
CHANGED
|
@@ -3,9 +3,6 @@ import { AgentWidgetConfig as _AgentWidgetConfig, AgentWidgetInitOptions, AgentW
|
|
|
3
3
|
import { isComposerBarMountMode, isDockedMountMode } from "../utils/dock";
|
|
4
4
|
import { createWidgetHostLayout } from "./host-layout";
|
|
5
5
|
|
|
6
|
-
// Warn at most once per page when the deprecated `onReady` alias is used.
|
|
7
|
-
let warnedOnReadyDeprecated = false;
|
|
8
|
-
|
|
9
6
|
const ensureTarget = (target: string | HTMLElement): HTMLElement => {
|
|
10
7
|
if (typeof window === "undefined" || typeof document === "undefined") {
|
|
11
8
|
throw new Error("Chat widget can only be mounted in a browser environment");
|
|
@@ -160,20 +157,8 @@ export const initAgentWidget = (
|
|
|
160
157
|
};
|
|
161
158
|
|
|
162
159
|
mountController();
|
|
163
|
-
// Fired when the controller is mounted and its API is callable.
|
|
164
|
-
|
|
165
|
-
if (options.onChatReady) {
|
|
166
|
-
options.onChatReady();
|
|
167
|
-
} else if (options.onReady) {
|
|
168
|
-
if (!warnedOnReadyDeprecated) {
|
|
169
|
-
warnedOnReadyDeprecated = true;
|
|
170
|
-
// eslint-disable-next-line no-console
|
|
171
|
-
console.warn(
|
|
172
|
-
"[Persona] `onReady` is deprecated: use `onChatReady`. `onReady` still works but is removed in the next major."
|
|
173
|
-
);
|
|
174
|
-
}
|
|
175
|
-
options.onReady();
|
|
176
|
-
}
|
|
160
|
+
// Fired when the controller is mounted and its API is callable.
|
|
161
|
+
options.onChatReady?.();
|
|
177
162
|
|
|
178
163
|
const rebuildLayout = (nextConfig?: _AgentWidgetConfig) => {
|
|
179
164
|
destroyCurrentController();
|
package/src/session.test.ts
CHANGED
|
@@ -893,7 +893,7 @@ describe('AgentWidgetSession.resolveApproval', () => {
|
|
|
893
893
|
});
|
|
894
894
|
});
|
|
895
895
|
|
|
896
|
-
describe('AgentWidgetSession - approval context across
|
|
896
|
+
describe('AgentWidgetSession - approval context across approval_complete', () => {
|
|
897
897
|
const sseStream = (events: Array<Record<string, unknown>>): ReadableStream<Uint8Array> => {
|
|
898
898
|
const encoder = new TextEncoder();
|
|
899
899
|
const body = events
|
|
@@ -919,14 +919,14 @@ describe('AgentWidgetSession - approval context across agent_approval_complete',
|
|
|
919
919
|
}
|
|
920
920
|
);
|
|
921
921
|
|
|
922
|
-
// `
|
|
923
|
-
// context fields from `
|
|
924
|
-
//
|
|
925
|
-
//
|
|
926
|
-
//
|
|
922
|
+
// Unified `approval_complete` carries only the resolution: none of the
|
|
923
|
+
// context fields from `approval_start`. The session merge must keep them so
|
|
924
|
+
// a full re-render of the resolved bubble (morph, virtual-scroll re-mount,
|
|
925
|
+
// storage restore) still shows the tool, description, and the agent's stated
|
|
926
|
+
// reason.
|
|
927
927
|
await session.connectStream(sseStream([
|
|
928
928
|
{
|
|
929
|
-
type: '
|
|
929
|
+
type: 'approval_start',
|
|
930
930
|
executionId: 'exec_abc',
|
|
931
931
|
approvalId: 'appr_1',
|
|
932
932
|
toolName: 'send_email',
|
|
@@ -936,7 +936,7 @@ describe('AgentWidgetSession - approval context across agent_approval_complete',
|
|
|
936
936
|
parameters: { to: 'customer@example.com' },
|
|
937
937
|
},
|
|
938
938
|
{
|
|
939
|
-
type: '
|
|
939
|
+
type: 'approval_complete',
|
|
940
940
|
executionId: 'exec_abc',
|
|
941
941
|
approvalId: 'appr_1',
|
|
942
942
|
decision: 'approved',
|
package/src/session.ts
CHANGED
|
@@ -2920,7 +2920,7 @@ export class AgentWidgetSession {
|
|
|
2920
2920
|
awaitingLocalTool: false,
|
|
2921
2921
|
};
|
|
2922
2922
|
}
|
|
2923
|
-
// Approval equivalent: `
|
|
2923
|
+
// Approval equivalent: `approval_complete` carries only the
|
|
2924
2924
|
// resolution (approvalId, decision, resolvedBy): the runtime does not
|
|
2925
2925
|
// re-send toolName/description/toolType/reason/parameters, so client.ts
|
|
2926
2926
|
// rebuilds the approval with empty required fields and the optional
|
package/src/types.ts
CHANGED
|
@@ -3588,6 +3588,11 @@ export type AgentWidgetConfig = {
|
|
|
3588
3588
|
* When provided, the widget uses agent loop execution instead of flow dispatch.
|
|
3589
3589
|
* Mutually exclusive with `flowId`.
|
|
3590
3590
|
*
|
|
3591
|
+
* Note: `@runtypelabs/persona-proxy` rejects a client-supplied `agent` (it was
|
|
3592
|
+
* an open relay). This option is only for endpoints authorized to accept a
|
|
3593
|
+
* client agent — your own backend, or token-scoped direct-to-Runtype dispatch.
|
|
3594
|
+
* For the proxy, pin the agent server-side with `agentConfig`/`agentId`.
|
|
3595
|
+
*
|
|
3591
3596
|
* @example
|
|
3592
3597
|
* ```typescript
|
|
3593
3598
|
* config: {
|
|
@@ -4056,16 +4061,6 @@ export type AgentWidgetConfig = {
|
|
|
4056
4061
|
* ```
|
|
4057
4062
|
*/
|
|
4058
4063
|
parseSSEEvent?: AgentWidgetSSEEventParser;
|
|
4059
|
-
/**
|
|
4060
|
-
* Wire vocabulary requested from the dispatch endpoint.
|
|
4061
|
-
* - `'legacy'` (default): the current `agent_*` / `flow_*` SSE events.
|
|
4062
|
-
* - `'unified'`: opt into the API's neutral unified event vocabulary by sending
|
|
4063
|
-
* `?events=unified`; incoming frames are transparently bridged back to the same
|
|
4064
|
-
* handlers, so rendering is unchanged. Requires an upstream that honors the
|
|
4065
|
-
* param — the widget detects the actual wire mode from the first stream frame
|
|
4066
|
-
* and falls back to legacy automatically if the param was ignored.
|
|
4067
|
-
*/
|
|
4068
|
-
events?: "legacy" | "unified";
|
|
4069
4064
|
/**
|
|
4070
4065
|
* Called for every parsed SSE frame (after JSON parse), before native handling.
|
|
4071
4066
|
* Use for lightweight side effects (e.g. telemetry). Does not replace native
|
|
@@ -4254,6 +4249,12 @@ export type AgentWidgetReasoning = {
|
|
|
4254
4249
|
id: string;
|
|
4255
4250
|
status: "pending" | "streaming" | "complete";
|
|
4256
4251
|
chunks: string[];
|
|
4252
|
+
/**
|
|
4253
|
+
* Reasoning channel scope (unified spec). `"turn"` is ordinary per-turn
|
|
4254
|
+
* thinking; `"loop"` is a cross-iteration agent reflection (the fold that
|
|
4255
|
+
* replaced the legacy `agent_reflection` event). Absent for legacy streams.
|
|
4256
|
+
*/
|
|
4257
|
+
scope?: "turn" | "loop";
|
|
4257
4258
|
startedAt?: number;
|
|
4258
4259
|
completedAt?: number;
|
|
4259
4260
|
durationMs?: number;
|
|
@@ -4633,8 +4634,6 @@ export type AgentWidgetInitOptions = {
|
|
|
4633
4634
|
useShadowDom?: boolean;
|
|
4634
4635
|
/** Fired when the widget controller is mounted and its API is callable. */
|
|
4635
4636
|
onChatReady?: () => void;
|
|
4636
|
-
/** @deprecated Use `onChatReady`. Retained as an alias; removed in the next major. */
|
|
4637
|
-
onReady?: () => void;
|
|
4638
4637
|
windowKey?: string; // If provided, stores the controller on window[windowKey] for global access
|
|
4639
4638
|
debugTools?: boolean;
|
|
4640
4639
|
};
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
|
|
3
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
4
|
+
|
|
5
|
+
import { buildPostprocessor } from "./ui";
|
|
6
|
+
import { escapeHtml } from "./postprocessors";
|
|
7
|
+
import * as parsersLoader from "./markdown-parsers-loader";
|
|
8
|
+
import type { AgentWidgetConfig, AgentWidgetMessage } from "./types";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Regression tests for the degraded (parsers-not-loaded) render path.
|
|
12
|
+
*
|
|
13
|
+
* In the IIFE/CDN build, `marked` + `DOMPurify` load lazily. Until they resolve
|
|
14
|
+
* (or permanently, if the chunk 404s), both the markdown processor and the
|
|
15
|
+
* sanitizer fall back to `escapeHtml`. The old blanket `sanitize(html)` composed
|
|
16
|
+
* the two fallbacks and escaped twice ("I'll" -> "I&#39;"). The bug lives in
|
|
17
|
+
* the COMPOSITION inside `buildPostprocessor`, so these tests drive the transform
|
|
18
|
+
* end-to-end rather than testing the primitives in isolation.
|
|
19
|
+
*
|
|
20
|
+
* `vitest.setup.ts` eager-provides parsers, so `getMarkdownParsersSync()` is
|
|
21
|
+
* non-null by default -> that's the "loaded" path. The degraded path is simulated
|
|
22
|
+
* by spying on the loader and returning null. Each consuming module
|
|
23
|
+
* (`ui.ts`, `sanitize.ts`, `postprocessors.ts`) calls the imported binding
|
|
24
|
+
* directly per invocation, so the spy propagates.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
const SAMPLE = "a & b < c, I'll go";
|
|
28
|
+
|
|
29
|
+
const callTransform = (
|
|
30
|
+
cfg: AgentWidgetConfig | undefined,
|
|
31
|
+
text: string
|
|
32
|
+
): string => {
|
|
33
|
+
const transform = buildPostprocessor(cfg, undefined, undefined);
|
|
34
|
+
const message: AgentWidgetMessage = {
|
|
35
|
+
id: "1",
|
|
36
|
+
role: "assistant",
|
|
37
|
+
content: text,
|
|
38
|
+
createdAt: "2026-06-17T00:00:00.000Z",
|
|
39
|
+
};
|
|
40
|
+
return transform({ text, message, streaming: false });
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
const simulateParsersNotLoaded = () => {
|
|
44
|
+
vi.spyOn(parsersLoader, "getMarkdownParsersSync").mockReturnValue(null);
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
afterEach(() => {
|
|
48
|
+
vi.restoreAllMocks();
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
describe("buildPostprocessor degraded path (parsers not loaded)", () => {
|
|
52
|
+
it("escapes plain text exactly once (no markdown config, default sanitize)", () => {
|
|
53
|
+
simulateParsersNotLoaded();
|
|
54
|
+
const out = callTransform(undefined, SAMPLE);
|
|
55
|
+
|
|
56
|
+
expect(out).toContain("I'll");
|
|
57
|
+
expect(out).toContain("a & b");
|
|
58
|
+
expect(out).not.toContain("&#39;");
|
|
59
|
+
expect(out).not.toContain("&amp;");
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("escapes exactly once when a markdown config is set", () => {
|
|
63
|
+
simulateParsersNotLoaded();
|
|
64
|
+
const out = callTransform({ markdown: {} }, SAMPLE);
|
|
65
|
+
|
|
66
|
+
// Markdown processor falls back to escapeHtml while parsers are not loaded,
|
|
67
|
+
// and the sanitizer is skipped (parsersReady === false) so it stays single.
|
|
68
|
+
expect(out).toBe(escapeHtml(SAMPLE));
|
|
69
|
+
expect(out).not.toContain("&#39;");
|
|
70
|
+
expect(out).not.toContain("&amp;");
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("escapes custom postprocessMessage HTML exactly once (default sanitize)", () => {
|
|
74
|
+
simulateParsersNotLoaded();
|
|
75
|
+
const cfg: AgentWidgetConfig = {
|
|
76
|
+
postprocessMessage: () => "<b>I'll</b>",
|
|
77
|
+
};
|
|
78
|
+
const out = callTransform(cfg, SAMPLE);
|
|
79
|
+
|
|
80
|
+
// The sanitizer's degraded fallback escapes the raw custom HTML — but only
|
|
81
|
+
// once, because custom HTML is NOT pre-escaped.
|
|
82
|
+
expect(out).toBe("<b>I'll</b>");
|
|
83
|
+
expect(out).not.toContain("&#39;");
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it("regression: degraded plain text is single-escaped, never double-applied", () => {
|
|
87
|
+
simulateParsersNotLoaded();
|
|
88
|
+
const out = callTransform(undefined, SAMPLE);
|
|
89
|
+
|
|
90
|
+
expect(out).toBe(escapeHtml(SAMPLE));
|
|
91
|
+
expect(out).not.toBe(escapeHtml(escapeHtml(SAMPLE)));
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
describe("buildPostprocessor loaded path (parsers available)", () => {
|
|
96
|
+
it("renders markdown to real HTML, normalizes apostrophes, strips scripts", () => {
|
|
97
|
+
// No spy: vitest.setup.ts eager-provides parsers, so this is the loaded path.
|
|
98
|
+
const text = "**bold** I'll go\n\n<script>alert(1)</script>";
|
|
99
|
+
const out = callTransform({ markdown: {} }, text);
|
|
100
|
+
|
|
101
|
+
expect(out).toContain("<strong>bold</strong>");
|
|
102
|
+
expect(out).toContain("<p>");
|
|
103
|
+
expect(out).toContain("I'll");
|
|
104
|
+
expect(out).not.toContain("<script");
|
|
105
|
+
expect(out).not.toContain("&#39;");
|
|
106
|
+
});
|
|
107
|
+
});
|
package/src/ui.ts
CHANGED
|
@@ -36,6 +36,7 @@ import { resolveTokenValue } from "./utils/tokens";
|
|
|
36
36
|
import { renderLucideIcon } from "./utils/icons";
|
|
37
37
|
import { createElement, createElementInDocument } from "./utils/dom";
|
|
38
38
|
import { morphMessages } from "./utils/morph";
|
|
39
|
+
import { normalizeCopiedSelectionText } from "./utils/copy-selection";
|
|
39
40
|
import {
|
|
40
41
|
navigateComposerHistory,
|
|
41
42
|
INITIAL_HISTORY_STATE,
|
|
@@ -403,7 +404,7 @@ type Controller = {
|
|
|
403
404
|
) => Promise<void>;
|
|
404
405
|
};
|
|
405
406
|
|
|
406
|
-
const buildPostprocessor = (
|
|
407
|
+
export const buildPostprocessor = (
|
|
407
408
|
cfg: AgentWidgetConfig | undefined,
|
|
408
409
|
actionManager?: ReturnType<typeof createActionManager>,
|
|
409
410
|
onResubmitRequested?: () => void
|
|
@@ -452,21 +453,35 @@ const buildPostprocessor = (
|
|
|
452
453
|
}
|
|
453
454
|
}
|
|
454
455
|
|
|
455
|
-
// Priority: postprocessMessage > markdown config > escapeHtml
|
|
456
|
+
// Priority: postprocessMessage > markdown config > escapeHtml.
|
|
457
|
+
//
|
|
458
|
+
// Degraded path (IIFE/CDN build before `markdown-parsers.js` resolves, or if
|
|
459
|
+
// it never loads): the markdown processor and the sanitizer BOTH fall back to
|
|
460
|
+
// escapeHtml, so the old `sanitize(markdownProcessor(text))` escaped twice and
|
|
461
|
+
// displayed entities literally (I'll -> &#39;). Only run the sanitizer when
|
|
462
|
+
// it can actually parse HTML; escapeHtml output is already inert. Checked per
|
|
463
|
+
// render (not once at setup): the chunk lands later and the self-heal re-renders.
|
|
464
|
+
const parsersReady = getMarkdownParsersSync() !== null;
|
|
456
465
|
let html: string;
|
|
457
466
|
if (cfg?.postprocessMessage) {
|
|
458
|
-
|
|
467
|
+
const out = cfg.postprocessMessage({
|
|
459
468
|
...context,
|
|
460
469
|
text: nextText,
|
|
461
470
|
raw: rawPayload ?? context.text ?? ""
|
|
462
471
|
});
|
|
472
|
+
// Custom HTML is NOT pre-escaped, so this stays a single pass even via the
|
|
473
|
+
// sanitizer's degraded fallback. Honors `sanitize: false` (pass-through) as before.
|
|
474
|
+
html = sanitize ? sanitize(out) : out;
|
|
463
475
|
} else if (markdownProcessor) {
|
|
464
|
-
|
|
476
|
+
// Already escapeHtml(text) (single, safe) while parsers are not loaded.
|
|
477
|
+
const out = markdownProcessor(nextText);
|
|
478
|
+
html = sanitize && parsersReady ? sanitize(out) : out;
|
|
465
479
|
} else {
|
|
480
|
+
// Plain text: escapeHtml output is inert — never re-sanitize (the second escape).
|
|
466
481
|
html = escapeHtml(nextText);
|
|
467
482
|
}
|
|
468
483
|
|
|
469
|
-
return
|
|
484
|
+
return html;
|
|
470
485
|
};
|
|
471
486
|
};
|
|
472
487
|
|
|
@@ -1355,6 +1370,26 @@ export const createAgentExperience = (
|
|
|
1355
1370
|
}
|
|
1356
1371
|
});
|
|
1357
1372
|
|
|
1373
|
+
// Normalize manual (triple-click + Ctrl/Cmd-C) copies of message text. The
|
|
1374
|
+
// browser serializes the DOM selection, and block-level markdown elements
|
|
1375
|
+
// (<p>, <li>, <pre>, …) emit surrounding newlines — so a single-message copy
|
|
1376
|
+
// arrives with stray trailing/leading blank lines. Rewrite the clipboard's
|
|
1377
|
+
// plain text to the trimmed selection so the buffer matches what was visibly
|
|
1378
|
+
// highlighted. (The Copy action button is unaffected; it uses message.content.)
|
|
1379
|
+
messagesWrapper.addEventListener('copy', (event) => {
|
|
1380
|
+
const { clipboardData } = event;
|
|
1381
|
+
if (!clipboardData) return;
|
|
1382
|
+
const root = messagesWrapper.getRootNode() as { getSelection?: () => Selection | null };
|
|
1383
|
+
const selection =
|
|
1384
|
+
typeof root.getSelection === 'function' ? root.getSelection() : window.getSelection();
|
|
1385
|
+
if (!selection || selection.isCollapsed) return;
|
|
1386
|
+
const raw = selection.toString();
|
|
1387
|
+
const normalized = normalizeCopiedSelectionText(raw);
|
|
1388
|
+
if (!normalized || normalized === raw) return;
|
|
1389
|
+
clipboardData.setData('text/plain', normalized);
|
|
1390
|
+
event.preventDefault();
|
|
1391
|
+
});
|
|
1392
|
+
|
|
1358
1393
|
// Add event delegation for message action buttons (upvote, downvote, copy)
|
|
1359
1394
|
// This handles clicks even after idiomorph morphs the DOM and strips inline listeners
|
|
1360
1395
|
const messageVoteState = new Map<string, "upvote" | "downvote">();
|