@runtypelabs/persona 3.21.2 → 3.22.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 +67 -0
- package/dist/animations/glyph-cycle.d.cts +1 -1
- package/dist/animations/glyph-cycle.d.ts +1 -1
- package/dist/animations/{types-CWPIj66R.d.cts → types-BZVr1YOV.d.cts} +10 -0
- package/dist/animations/{types-CWPIj66R.d.ts → types-BZVr1YOV.d.ts} +10 -0
- package/dist/animations/wipe.d.cts +1 -1
- package/dist/animations/wipe.d.ts +1 -1
- package/dist/index.cjs +50 -43
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +474 -6
- package/dist/index.d.ts +474 -6
- package/dist/index.global.js +98 -88
- package/dist/index.global.js.map +1 -1
- package/dist/index.js +48 -41
- package/dist/index.js.map +1 -1
- package/dist/smart-dom-reader.cjs +1875 -0
- package/dist/smart-dom-reader.d.cts +4521 -0
- package/dist/smart-dom-reader.d.ts +4521 -0
- package/dist/smart-dom-reader.js +1848 -0
- package/dist/theme-editor.cjs +2282 -90
- package/dist/theme-editor.d.cts +348 -1
- package/dist/theme-editor.d.ts +348 -1
- package/dist/theme-editor.js +2267 -90
- package/package.json +9 -2
- package/src/client.test.ts +165 -0
- package/src/client.ts +144 -23
- package/src/components/composer-parts.test.ts +34 -0
- package/src/components/composer-parts.ts +9 -6
- package/src/index.ts +26 -0
- package/src/session.test.ts +258 -0
- package/src/session.ts +886 -30
- package/src/session.webmcp.test.ts +815 -0
- package/src/smart-dom-reader.test.ts +135 -0
- package/src/smart-dom-reader.ts +135 -0
- package/src/theme-editor/color-utils.test.ts +59 -0
- package/src/theme-editor/color-utils.ts +38 -2
- package/src/theme-editor/index.ts +35 -0
- package/src/theme-editor/webmcp/coerce.test.ts +86 -0
- package/src/theme-editor/webmcp/coerce.ts +286 -0
- package/src/theme-editor/webmcp/index.ts +45 -0
- package/src/theme-editor/webmcp/summary.ts +324 -0
- package/src/theme-editor/webmcp/tools.test.ts +205 -0
- package/src/theme-editor/webmcp/tools.ts +795 -0
- package/src/theme-editor/webmcp/types.ts +87 -0
- package/src/types.ts +186 -0
- package/src/ui.composer-keyboard.test.ts +229 -0
- package/src/ui.ts +127 -5
- package/src/utils/composer-history.test.ts +128 -0
- package/src/utils/composer-history.ts +113 -0
- package/src/utils/message-fingerprint.test.ts +20 -0
- package/src/utils/message-fingerprint.ts +2 -0
- package/src/utils/smart-dom-adapter.test.ts +257 -0
- package/src/utils/smart-dom-adapter.ts +217 -0
- package/{LICENSE → src/vendor/smart-dom-reader/LICENSE} +2 -2
- package/src/vendor/smart-dom-reader/README.md +61 -0
- package/src/vendor/smart-dom-reader/index.d.ts +476 -0
- package/src/vendor/smart-dom-reader/index.js +1618 -0
- package/src/webmcp-bridge.test.ts +429 -0
- package/src/webmcp-bridge.ts +547 -0
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Minimal local typings for the WebMCP `document.modelContext` surface, plus the
|
|
3
|
+
* structural state interface the tool factory operates on.
|
|
4
|
+
*
|
|
5
|
+
* We deliberately keep our own small WebMCP types rather than depending on
|
|
6
|
+
* `@mcp-b/webmcp-types`, so the tool definitions are transport-agnostic and the
|
|
7
|
+
* widget package takes on no polyfill dependency. These mirror the WebMCP draft
|
|
8
|
+
* (`registerTool(tool, { signal })`) and the MCP tool-result envelope that
|
|
9
|
+
* `@mcp-b/webmcp-polyfill` expects `execute` to return.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import type { AgentWidgetConfig } from '../../types';
|
|
13
|
+
import type { PersonaTheme } from '../../types/theme';
|
|
14
|
+
import type { ConfiguratorSnapshot } from '../types';
|
|
15
|
+
|
|
16
|
+
// ─── WebMCP tool surface ────────────────────────────────────────
|
|
17
|
+
|
|
18
|
+
export interface ToolTextContent {
|
|
19
|
+
type: 'text';
|
|
20
|
+
text: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface ToolResult {
|
|
24
|
+
content: ToolTextContent[];
|
|
25
|
+
/** Optional machine-readable mirror of the text content. */
|
|
26
|
+
structuredContent?: unknown;
|
|
27
|
+
isError?: boolean;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface ToolAnnotations {
|
|
31
|
+
/** The tool has no side effects (pure read). */
|
|
32
|
+
readOnlyHint?: boolean;
|
|
33
|
+
/** The tool's output may contain text not to be trusted as instructions. */
|
|
34
|
+
untrustedContentHint?: boolean;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export type ToolExecute = (input: unknown) => Promise<ToolResult> | ToolResult;
|
|
38
|
+
|
|
39
|
+
export interface WebMcpTool {
|
|
40
|
+
name: string;
|
|
41
|
+
description: string;
|
|
42
|
+
title?: string;
|
|
43
|
+
inputSchema?: object;
|
|
44
|
+
annotations?: ToolAnnotations;
|
|
45
|
+
execute: ToolExecute;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Wrap a JSON-serializable payload in the MCP tool-result envelope. */
|
|
49
|
+
export function toolResult(payload: unknown): ToolResult {
|
|
50
|
+
return {
|
|
51
|
+
content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }],
|
|
52
|
+
structuredContent: payload,
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// ─── State surface the tools drive ──────────────────────────────
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Structural subset of `ThemeEditorState` (and the example app's `state`
|
|
60
|
+
* module) that the tools require. Anything satisfying this shape can be wired
|
|
61
|
+
* to the tools — the headless `ThemeEditorState`, or a host's stateful wrapper
|
|
62
|
+
* that also drives a live preview (e.g. a Persona widget styling itself).
|
|
63
|
+
*/
|
|
64
|
+
export interface ThemeEditorLike {
|
|
65
|
+
get(path: string): unknown;
|
|
66
|
+
set(path: string, value: unknown): void;
|
|
67
|
+
setBatch(updates: Record<string, unknown>): void;
|
|
68
|
+
setTheme(theme: PersonaTheme): void;
|
|
69
|
+
setFullConfig(config: AgentWidgetConfig, theme?: PersonaTheme): void;
|
|
70
|
+
undo(): void;
|
|
71
|
+
redo(): void;
|
|
72
|
+
canUndo(): boolean;
|
|
73
|
+
canRedo(): boolean;
|
|
74
|
+
getHistoryIndex(): number;
|
|
75
|
+
getTheme(): PersonaTheme;
|
|
76
|
+
getConfig(): AgentWidgetConfig;
|
|
77
|
+
exportSnapshot(): ConfiguratorSnapshot;
|
|
78
|
+
resetToDefaults(): void;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Which theme variant(s) styling tools write to. */
|
|
82
|
+
export type EditTarget = 'light' | 'dark' | 'both';
|
|
83
|
+
|
|
84
|
+
export interface CreateThemeEditorToolsOptions {
|
|
85
|
+
/** Default variant for styling writes. Defaults to `'both'`. */
|
|
86
|
+
editTarget?: EditTarget;
|
|
87
|
+
}
|
package/src/types.ts
CHANGED
|
@@ -99,6 +99,13 @@ export type AgentWidgetRequestPayload = {
|
|
|
99
99
|
metadata?: Record<string, unknown>;
|
|
100
100
|
/** Per-turn template variables for /v1/client/chat (merged as root-level {{var}} in Runtype). */
|
|
101
101
|
inputs?: Record<string, unknown>;
|
|
102
|
+
/**
|
|
103
|
+
* Per-turn page-discovered tools (WebMCP). Sent to Runtype's dispatch so the
|
|
104
|
+
* agent can call them as `webmcp:<name>`. The widget snapshots
|
|
105
|
+
* `document.modelContext.__getRegisteredTools()` each turn and ships only
|
|
106
|
+
* the JSON-serializable surface (no `execute`).
|
|
107
|
+
*/
|
|
108
|
+
clientTools?: ClientToolDefinition[];
|
|
102
109
|
};
|
|
103
110
|
|
|
104
111
|
// ============================================================================
|
|
@@ -194,6 +201,120 @@ export type AgentWidgetAgentRequestPayload = {
|
|
|
194
201
|
options: AgentRequestOptions;
|
|
195
202
|
context?: Record<string, unknown>;
|
|
196
203
|
metadata?: Record<string, unknown>;
|
|
204
|
+
/**
|
|
205
|
+
* Per-turn page-discovered tools (WebMCP) — same shape as
|
|
206
|
+
* `AgentWidgetRequestPayload.clientTools`.
|
|
207
|
+
*/
|
|
208
|
+
clientTools?: ClientToolDefinition[];
|
|
209
|
+
};
|
|
210
|
+
|
|
211
|
+
// ============================================================================
|
|
212
|
+
// WebMCP Types (page-discovered tools shipped per dispatch)
|
|
213
|
+
// ============================================================================
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Wire shape for a single client-discovered tool sent on `dispatch.clientTools[]`.
|
|
217
|
+
*
|
|
218
|
+
* Mirrors the SDK's `ClientToolDefinition` in `@runtypelabs/sdk`. Only the
|
|
219
|
+
* JSON-serializable surface of a WebMCP tool — the `execute` function stays
|
|
220
|
+
* client-side; the server merges these into the agent's tool catalog under
|
|
221
|
+
* the `webmcp:` namespace.
|
|
222
|
+
*/
|
|
223
|
+
export type ClientToolDefinition = {
|
|
224
|
+
/** Bare tool name; the server prepends `webmcp:` on the wire. */
|
|
225
|
+
name: string;
|
|
226
|
+
description: string;
|
|
227
|
+
/** JSON Schema (per WebMCP spec) — passed through as-is. */
|
|
228
|
+
parametersSchema?: object;
|
|
229
|
+
/** Set to `'webmcp'` for tools discovered via the polyfill. */
|
|
230
|
+
origin?: 'webmcp' | 'local';
|
|
231
|
+
/** Origin of the page that registered the tool — for server-side audit. */
|
|
232
|
+
pageOrigin?: string;
|
|
233
|
+
/**
|
|
234
|
+
* WebMCP `Tool.annotations` (spec). Not used for gating server-side; the
|
|
235
|
+
* widget reads these client-side. Forwarded so traces/dashboards can show
|
|
236
|
+
* `readOnlyHint` / `untrustedContentHint` on tool-call records.
|
|
237
|
+
*/
|
|
238
|
+
annotations?: {
|
|
239
|
+
readOnlyHint?: boolean;
|
|
240
|
+
untrustedContentHint?: boolean;
|
|
241
|
+
};
|
|
242
|
+
};
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Information passed to the confirm-bubble handler before a `webmcp:*` tool
|
|
246
|
+
* call executes. Every WebMCP tool routes through this single gate.
|
|
247
|
+
*/
|
|
248
|
+
export type WebMcpConfirmInfo = {
|
|
249
|
+
/** Bare tool name (no `webmcp:` prefix). */
|
|
250
|
+
toolName: string;
|
|
251
|
+
args: unknown;
|
|
252
|
+
description?: string;
|
|
253
|
+
annotations?: {
|
|
254
|
+
readOnlyHint?: boolean;
|
|
255
|
+
untrustedContentHint?: boolean;
|
|
256
|
+
};
|
|
257
|
+
/**
|
|
258
|
+
* Why the confirm was requested. Currently always `'gate'` — the default
|
|
259
|
+
* confirm-by-default gate that fires before every `webmcp:*` call. (The
|
|
260
|
+
* `@mcp-b/webmcp-polyfill` owns the spec's `requestUserInteraction` callback
|
|
261
|
+
* internally, so Persona no longer surfaces a nested in-tool confirm.)
|
|
262
|
+
*/
|
|
263
|
+
reason: 'gate';
|
|
264
|
+
};
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* Resolves to `true` if the user approves the tool call; `false` to decline.
|
|
268
|
+
*/
|
|
269
|
+
export type WebMcpConfirmHandler = (info: WebMcpConfirmInfo) => Promise<boolean>;
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* Persona's normalized tool-result shape sent back to the agent on `/resume`.
|
|
273
|
+
* Mirrors the MCP `CallToolResult` content shape; arbitrary `execute()` return
|
|
274
|
+
* values are wrapped as a single text block at the bridge boundary.
|
|
275
|
+
*/
|
|
276
|
+
export type WebMcpToolResult = {
|
|
277
|
+
content: Array<
|
|
278
|
+
| { type: 'text'; text: string }
|
|
279
|
+
| { type: string;[key: string]: unknown }
|
|
280
|
+
>;
|
|
281
|
+
isError?: boolean;
|
|
282
|
+
/** Pass-through of the tool's `annotations.untrustedContentHint`. */
|
|
283
|
+
annotations?: {
|
|
284
|
+
untrustedContentHint?: boolean;
|
|
285
|
+
};
|
|
286
|
+
};
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* Widget-level WebMCP configuration. Set `enabled: true` to opt in. The
|
|
290
|
+
* surface's server-side `webmcp` policy is the source of truth for which
|
|
291
|
+
* tools are accepted — these client-side options are convenience filters.
|
|
292
|
+
*/
|
|
293
|
+
export type AgentWidgetWebMcpConfig = {
|
|
294
|
+
/** Master switch. Default: `false` (widget never installs the polyfill). */
|
|
295
|
+
enabled?: boolean;
|
|
296
|
+
/**
|
|
297
|
+
* Glob-ish name patterns to include client-side. `'*'` matches any chars
|
|
298
|
+
* except `:`. Patterns are matched against the bare tool name (no `webmcp:`
|
|
299
|
+
* prefix). If unset, all registered tools are included.
|
|
300
|
+
*/
|
|
301
|
+
allowlist?: string[];
|
|
302
|
+
/**
|
|
303
|
+
* Per-tool gate policy. Called before the confirm gate for every
|
|
304
|
+
* `webmcp:*` call; return `true` to approve immediately and skip the
|
|
305
|
+
* confirmation UI entirely. Use this to auto-allow read-only tools (e.g.
|
|
306
|
+
* a catalog search) while still gating mutating ones. Only consulted on
|
|
307
|
+
* the default-UI path — a custom `onConfirm` takes full control instead.
|
|
308
|
+
*/
|
|
309
|
+
autoApprove?: (info: WebMcpConfirmInfo) => boolean;
|
|
310
|
+
/**
|
|
311
|
+
* Confirm gate handler. When omitted, Persona renders its native in-panel
|
|
312
|
+
* approval bubble (the same chrome used for server-driven tool approvals)
|
|
313
|
+
* and resolves on the user's Approve/Deny click. Supply this to override
|
|
314
|
+
* with a custom confirmer (e.g. a route-level modal). The legacy
|
|
315
|
+
* `window.confirm` fallback only applies when no widget UI is attached.
|
|
316
|
+
*/
|
|
317
|
+
onConfirm?: WebMcpConfirmHandler;
|
|
197
318
|
};
|
|
198
319
|
|
|
199
320
|
/**
|
|
@@ -237,6 +358,16 @@ export type AgentMessageMetadata = {
|
|
|
237
358
|
* `POST /v1/dispatch/resume` with the user's answer keyed by tool name.
|
|
238
359
|
*/
|
|
239
360
|
awaitingLocalTool?: boolean;
|
|
361
|
+
/**
|
|
362
|
+
* The provider per-call id (`toolu_…`) carried on the `step_await` /
|
|
363
|
+
* `flow_await` events for a LOCAL tool (core#3878). Present only when the
|
|
364
|
+
* server emits it. Two PARALLEL calls to the same tool in one turn share a
|
|
365
|
+
* `toolName` (and a collapsed `toolId`) but get DISTINCT `webMcpToolCallId`s,
|
|
366
|
+
* so this is the key the widget batches a single `/resume` on — preferred
|
|
367
|
+
* over tool name, which collides for same-tool parallel calls. Absent →
|
|
368
|
+
* fall back to the legacy name-keyed resume contract.
|
|
369
|
+
*/
|
|
370
|
+
webMcpToolCallId?: string;
|
|
240
371
|
/**
|
|
241
372
|
* Set to `true` once the user has picked / typed / dismissed an answer for
|
|
242
373
|
* an `ask_user_question` tool call, so renderers stop re-mounting the
|
|
@@ -897,6 +1028,14 @@ export type AgentWidgetFeatureFlags = {
|
|
|
897
1028
|
showReasoning?: boolean;
|
|
898
1029
|
showToolCalls?: boolean;
|
|
899
1030
|
showEventStreamToggle?: boolean;
|
|
1031
|
+
/**
|
|
1032
|
+
* Up/Down arrow navigation through previously sent user messages in the
|
|
1033
|
+
* composer, for quick re-entry or editing (shell / Slack style). History is
|
|
1034
|
+
* only entered when the caret is at the start of the input, so normal
|
|
1035
|
+
* multi-line cursor movement is preserved. Set to `false` to disable.
|
|
1036
|
+
* @default true
|
|
1037
|
+
*/
|
|
1038
|
+
composerHistory?: boolean;
|
|
900
1039
|
/** Shared transcript + event stream scroll-to-bottom affordance. */
|
|
901
1040
|
scrollToBottom?: AgentWidgetScrollToBottomFeature;
|
|
902
1041
|
/** Collapsed transcript behavior for tool call rows. */
|
|
@@ -2083,6 +2222,8 @@ export type ClientChatRequest = {
|
|
|
2083
2222
|
/** Per-turn inputs for Runtype prompt templates (e.g. {{page_url}}). */
|
|
2084
2223
|
inputs?: Record<string, unknown>;
|
|
2085
2224
|
context?: Record<string, unknown>;
|
|
2225
|
+
/** WebMCP page-discovered tools — same shape as `dispatch.clientTools[]`. */
|
|
2226
|
+
clientTools?: ClientToolDefinition[];
|
|
2086
2227
|
};
|
|
2087
2228
|
|
|
2088
2229
|
/**
|
|
@@ -2916,6 +3057,31 @@ export type AgentWidgetLoadingIndicatorConfig = {
|
|
|
2916
3057
|
export type AgentWidgetConfig = {
|
|
2917
3058
|
apiUrl?: string;
|
|
2918
3059
|
flowId?: string;
|
|
3060
|
+
/**
|
|
3061
|
+
* Override the assistant-bubble copy shown when a dispatch fails before any
|
|
3062
|
+
* response streams back (connection refused, CORS, 4xx/5xx, malformed
|
|
3063
|
+
* stream). Provide a static string, or a function of the error so you can
|
|
3064
|
+
* tailor the message per failure and decide whether to surface the raw
|
|
3065
|
+
* reason. When omitted, a default message is shown that includes the
|
|
3066
|
+
* underlying error detail.
|
|
3067
|
+
*
|
|
3068
|
+
* Returning an empty string suppresses the fallback bubble entirely (the
|
|
3069
|
+
* `onError` callback still fires).
|
|
3070
|
+
*
|
|
3071
|
+
* @example
|
|
3072
|
+
* ```typescript
|
|
3073
|
+
* config: {
|
|
3074
|
+
* // Static
|
|
3075
|
+
* errorMessage: "We're having trouble connecting. Please try again."
|
|
3076
|
+
* // Or dynamic
|
|
3077
|
+
* errorMessage: (error) =>
|
|
3078
|
+
* error.message.includes("Failed to fetch")
|
|
3079
|
+
* ? "You appear to be offline."
|
|
3080
|
+
* : "Something went wrong. Please try again."
|
|
3081
|
+
* }
|
|
3082
|
+
* ```
|
|
3083
|
+
*/
|
|
3084
|
+
errorMessage?: string | ((error: Error) => string);
|
|
2919
3085
|
/**
|
|
2920
3086
|
* Agent configuration for agent execution mode.
|
|
2921
3087
|
* When provided, the widget uses agent loop execution instead of flow dispatch.
|
|
@@ -3147,6 +3313,26 @@ export type AgentWidgetConfig = {
|
|
|
3147
3313
|
* ```
|
|
3148
3314
|
*/
|
|
3149
3315
|
approval?: AgentWidgetApprovalConfig | false;
|
|
3316
|
+
/**
|
|
3317
|
+
* WebMCP — consume page-registered tools (`document.modelContext.registerTool`).
|
|
3318
|
+
* When `enabled`, the widget installs `@mcp-b/webmcp-polyfill`, snapshots the
|
|
3319
|
+
* registry on every dispatch, ships it as `clientTools[]`, and executes
|
|
3320
|
+
* returned `webmcp:*` tool calls with confirm-by-default gating.
|
|
3321
|
+
*
|
|
3322
|
+
* Server-side policy on the chat surface is the source of truth — these
|
|
3323
|
+
* fields layer on top.
|
|
3324
|
+
*
|
|
3325
|
+
* @example
|
|
3326
|
+
* ```typescript
|
|
3327
|
+
* config: {
|
|
3328
|
+
* webmcp: {
|
|
3329
|
+
* enabled: true,
|
|
3330
|
+
* allowlist: ['search_*', 'list_*'],
|
|
3331
|
+
* }
|
|
3332
|
+
* }
|
|
3333
|
+
* ```
|
|
3334
|
+
*/
|
|
3335
|
+
webmcp?: AgentWidgetWebMcpConfig;
|
|
3150
3336
|
postprocessMessage?: (context: {
|
|
3151
3337
|
text: string;
|
|
3152
3338
|
message: AgentWidgetMessage;
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
|
|
3
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
4
|
+
|
|
5
|
+
import { createAgentExperience } from "./ui";
|
|
6
|
+
|
|
7
|
+
const createMount = () => {
|
|
8
|
+
const mount = document.createElement("div");
|
|
9
|
+
document.body.appendChild(mount);
|
|
10
|
+
return mount;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
const flush = async (times = 4) => {
|
|
14
|
+
for (let i = 0; i < times; i += 1) {
|
|
15
|
+
// eslint-disable-next-line no-await-in-loop
|
|
16
|
+
await Promise.resolve();
|
|
17
|
+
}
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
const getTextarea = (mount: HTMLElement) =>
|
|
21
|
+
mount.querySelector<HTMLTextAreaElement>("[data-persona-composer-input]")!;
|
|
22
|
+
|
|
23
|
+
const press = (el: Element, key: string) =>
|
|
24
|
+
el.dispatchEvent(
|
|
25
|
+
new KeyboardEvent("keydown", { key, bubbles: true, cancelable: true })
|
|
26
|
+
);
|
|
27
|
+
|
|
28
|
+
describe("createAgentExperience composer keyboard — Enter / Esc while streaming", () => {
|
|
29
|
+
const originalFetch = global.fetch;
|
|
30
|
+
let capturedSignals: AbortSignal[] = [];
|
|
31
|
+
|
|
32
|
+
beforeEach(() => {
|
|
33
|
+
capturedSignals = [];
|
|
34
|
+
vi.stubGlobal("requestAnimationFrame", (cb: (time: number) => void) => {
|
|
35
|
+
cb(0);
|
|
36
|
+
return 1;
|
|
37
|
+
});
|
|
38
|
+
vi.stubGlobal("cancelAnimationFrame", () => {});
|
|
39
|
+
window.scrollTo = vi.fn();
|
|
40
|
+
|
|
41
|
+
// Fetch hangs until aborted — models an in-flight SSE stream so the widget
|
|
42
|
+
// stays "streaming".
|
|
43
|
+
global.fetch = vi.fn().mockImplementation((_url: string, options: any) => {
|
|
44
|
+
const signal = options.signal as AbortSignal;
|
|
45
|
+
capturedSignals.push(signal);
|
|
46
|
+
return new Promise((_resolve, reject) => {
|
|
47
|
+
signal.addEventListener("abort", () => {
|
|
48
|
+
const err = new Error("aborted");
|
|
49
|
+
err.name = "AbortError";
|
|
50
|
+
reject(err);
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
}) as any;
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
afterEach(() => {
|
|
57
|
+
document.body.innerHTML = "";
|
|
58
|
+
global.fetch = originalFetch;
|
|
59
|
+
vi.restoreAllMocks();
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
const startStreaming = async (mount: HTMLElement) => {
|
|
63
|
+
const textarea = getTextarea(mount);
|
|
64
|
+
textarea.value = "Hello";
|
|
65
|
+
mount
|
|
66
|
+
.querySelector<HTMLButtonElement>("[data-persona-composer-submit]")!
|
|
67
|
+
.click();
|
|
68
|
+
await flush();
|
|
69
|
+
return textarea;
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
it("Enter while streaming does NOT stop the stream and does not send", async () => {
|
|
73
|
+
const mount = createMount();
|
|
74
|
+
const controller = createAgentExperience(mount, {
|
|
75
|
+
apiUrl: "https://api.example.com/chat",
|
|
76
|
+
launcher: { enabled: false },
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
const textarea = await startStreaming(mount);
|
|
80
|
+
expect(controller.getState().streaming).toBe(true);
|
|
81
|
+
expect(capturedSignals).toHaveLength(1);
|
|
82
|
+
|
|
83
|
+
// Type something new, then hit Enter — it must be inert mid-stream.
|
|
84
|
+
textarea.value = "queued text";
|
|
85
|
+
press(textarea, "Enter");
|
|
86
|
+
await flush();
|
|
87
|
+
|
|
88
|
+
expect(controller.getState().streaming).toBe(true);
|
|
89
|
+
expect(capturedSignals[0].aborted).toBe(false);
|
|
90
|
+
// No second request fired (nothing sent).
|
|
91
|
+
expect(capturedSignals).toHaveLength(1);
|
|
92
|
+
|
|
93
|
+
controller.destroy();
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it("Escape while streaming stops the stream", async () => {
|
|
97
|
+
const mount = createMount();
|
|
98
|
+
const controller = createAgentExperience(mount, {
|
|
99
|
+
apiUrl: "https://api.example.com/chat",
|
|
100
|
+
launcher: { enabled: false },
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
const textarea = await startStreaming(mount);
|
|
104
|
+
expect(controller.getState().streaming).toBe(true);
|
|
105
|
+
|
|
106
|
+
press(textarea, "Escape");
|
|
107
|
+
await flush();
|
|
108
|
+
|
|
109
|
+
expect(controller.getState().streaming).toBe(false);
|
|
110
|
+
expect(capturedSignals[0].aborted).toBe(true);
|
|
111
|
+
|
|
112
|
+
controller.destroy();
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it("Escape while NOT streaming does not throw and leaves state idle", async () => {
|
|
116
|
+
const mount = createMount();
|
|
117
|
+
const controller = createAgentExperience(mount, {
|
|
118
|
+
apiUrl: "https://api.example.com/chat",
|
|
119
|
+
launcher: { enabled: false },
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
const textarea = getTextarea(mount);
|
|
123
|
+
press(textarea, "Escape");
|
|
124
|
+
await flush();
|
|
125
|
+
|
|
126
|
+
expect(controller.getState().streaming).toBe(false);
|
|
127
|
+
expect(capturedSignals).toHaveLength(0);
|
|
128
|
+
|
|
129
|
+
controller.destroy();
|
|
130
|
+
});
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
describe("createAgentExperience composer keyboard — Up/Down history", () => {
|
|
134
|
+
beforeEach(() => {
|
|
135
|
+
window.scrollTo = vi.fn();
|
|
136
|
+
try {
|
|
137
|
+
window.localStorage.clear();
|
|
138
|
+
} catch {
|
|
139
|
+
/* jsdom edge cases */
|
|
140
|
+
}
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
afterEach(() => {
|
|
144
|
+
document.body.innerHTML = "";
|
|
145
|
+
vi.restoreAllMocks();
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
const seededConfig = (composerHistory?: boolean) => ({
|
|
149
|
+
apiUrl: "https://api.example.com/chat",
|
|
150
|
+
launcher: { enabled: false } as const,
|
|
151
|
+
...(composerHistory === undefined
|
|
152
|
+
? {}
|
|
153
|
+
: { features: { composerHistory } }),
|
|
154
|
+
initialMessages: [
|
|
155
|
+
{
|
|
156
|
+
id: "u1",
|
|
157
|
+
role: "user" as const,
|
|
158
|
+
content: "first message",
|
|
159
|
+
createdAt: "2026-01-01T00:00:00.000Z",
|
|
160
|
+
},
|
|
161
|
+
{
|
|
162
|
+
id: "a1",
|
|
163
|
+
role: "assistant" as const,
|
|
164
|
+
content: "a reply",
|
|
165
|
+
createdAt: "2026-01-01T00:00:01.000Z",
|
|
166
|
+
},
|
|
167
|
+
{
|
|
168
|
+
id: "u2",
|
|
169
|
+
role: "user" as const,
|
|
170
|
+
content: "second message",
|
|
171
|
+
createdAt: "2026-01-01T00:00:02.000Z",
|
|
172
|
+
},
|
|
173
|
+
],
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it("Up recalls the most recent user message; repeated Up steps older; Down restores the draft", () => {
|
|
177
|
+
const mount = createMount();
|
|
178
|
+
const controller = createAgentExperience(mount, seededConfig());
|
|
179
|
+
const textarea = getTextarea(mount);
|
|
180
|
+
|
|
181
|
+
textarea.value = "draft in progress";
|
|
182
|
+
textarea.setSelectionRange(0, 0); // caret at start
|
|
183
|
+
|
|
184
|
+
press(textarea, "ArrowUp");
|
|
185
|
+
expect(textarea.value).toBe("second message");
|
|
186
|
+
|
|
187
|
+
press(textarea, "ArrowUp");
|
|
188
|
+
expect(textarea.value).toBe("first message");
|
|
189
|
+
|
|
190
|
+
// Down walks back toward the present...
|
|
191
|
+
press(textarea, "ArrowDown");
|
|
192
|
+
expect(textarea.value).toBe("second message");
|
|
193
|
+
|
|
194
|
+
// ...and past the newest entry restores the saved draft.
|
|
195
|
+
press(textarea, "ArrowDown");
|
|
196
|
+
expect(textarea.value).toBe("draft in progress");
|
|
197
|
+
|
|
198
|
+
controller.destroy();
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
it("does not hijack Up when the caret is not at the start (multi-line editing)", () => {
|
|
202
|
+
const mount = createMount();
|
|
203
|
+
const controller = createAgentExperience(mount, seededConfig());
|
|
204
|
+
const textarea = getTextarea(mount);
|
|
205
|
+
|
|
206
|
+
textarea.value = "line one\nline two";
|
|
207
|
+
textarea.setSelectionRange(5, 5); // caret mid-text
|
|
208
|
+
|
|
209
|
+
press(textarea, "ArrowUp");
|
|
210
|
+
// Value unchanged — Up moved the cursor instead of recalling history.
|
|
211
|
+
expect(textarea.value).toBe("line one\nline two");
|
|
212
|
+
|
|
213
|
+
controller.destroy();
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
it("can be disabled via features.composerHistory: false", () => {
|
|
217
|
+
const mount = createMount();
|
|
218
|
+
const controller = createAgentExperience(mount, seededConfig(false));
|
|
219
|
+
const textarea = getTextarea(mount);
|
|
220
|
+
|
|
221
|
+
textarea.value = "";
|
|
222
|
+
textarea.setSelectionRange(0, 0);
|
|
223
|
+
|
|
224
|
+
press(textarea, "ArrowUp");
|
|
225
|
+
expect(textarea.value).toBe("");
|
|
226
|
+
|
|
227
|
+
controller.destroy();
|
|
228
|
+
});
|
|
229
|
+
});
|