@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.
Files changed (59) hide show
  1. package/README.md +67 -0
  2. package/dist/animations/glyph-cycle.d.cts +1 -1
  3. package/dist/animations/glyph-cycle.d.ts +1 -1
  4. package/dist/animations/{types-CWPIj66R.d.cts → types-BZVr1YOV.d.cts} +10 -0
  5. package/dist/animations/{types-CWPIj66R.d.ts → types-BZVr1YOV.d.ts} +10 -0
  6. package/dist/animations/wipe.d.cts +1 -1
  7. package/dist/animations/wipe.d.ts +1 -1
  8. package/dist/index.cjs +50 -43
  9. package/dist/index.cjs.map +1 -1
  10. package/dist/index.d.cts +474 -6
  11. package/dist/index.d.ts +474 -6
  12. package/dist/index.global.js +98 -88
  13. package/dist/index.global.js.map +1 -1
  14. package/dist/index.js +48 -41
  15. package/dist/index.js.map +1 -1
  16. package/dist/smart-dom-reader.cjs +1875 -0
  17. package/dist/smart-dom-reader.d.cts +4521 -0
  18. package/dist/smart-dom-reader.d.ts +4521 -0
  19. package/dist/smart-dom-reader.js +1848 -0
  20. package/dist/theme-editor.cjs +2282 -90
  21. package/dist/theme-editor.d.cts +348 -1
  22. package/dist/theme-editor.d.ts +348 -1
  23. package/dist/theme-editor.js +2267 -90
  24. package/package.json +9 -2
  25. package/src/client.test.ts +165 -0
  26. package/src/client.ts +144 -23
  27. package/src/components/composer-parts.test.ts +34 -0
  28. package/src/components/composer-parts.ts +9 -6
  29. package/src/index.ts +26 -0
  30. package/src/session.test.ts +258 -0
  31. package/src/session.ts +886 -30
  32. package/src/session.webmcp.test.ts +815 -0
  33. package/src/smart-dom-reader.test.ts +135 -0
  34. package/src/smart-dom-reader.ts +135 -0
  35. package/src/theme-editor/color-utils.test.ts +59 -0
  36. package/src/theme-editor/color-utils.ts +38 -2
  37. package/src/theme-editor/index.ts +35 -0
  38. package/src/theme-editor/webmcp/coerce.test.ts +86 -0
  39. package/src/theme-editor/webmcp/coerce.ts +286 -0
  40. package/src/theme-editor/webmcp/index.ts +45 -0
  41. package/src/theme-editor/webmcp/summary.ts +324 -0
  42. package/src/theme-editor/webmcp/tools.test.ts +205 -0
  43. package/src/theme-editor/webmcp/tools.ts +795 -0
  44. package/src/theme-editor/webmcp/types.ts +87 -0
  45. package/src/types.ts +186 -0
  46. package/src/ui.composer-keyboard.test.ts +229 -0
  47. package/src/ui.ts +127 -5
  48. package/src/utils/composer-history.test.ts +128 -0
  49. package/src/utils/composer-history.ts +113 -0
  50. package/src/utils/message-fingerprint.test.ts +20 -0
  51. package/src/utils/message-fingerprint.ts +2 -0
  52. package/src/utils/smart-dom-adapter.test.ts +257 -0
  53. package/src/utils/smart-dom-adapter.ts +217 -0
  54. package/{LICENSE → src/vendor/smart-dom-reader/LICENSE} +2 -2
  55. package/src/vendor/smart-dom-reader/README.md +61 -0
  56. package/src/vendor/smart-dom-reader/index.d.ts +476 -0
  57. package/src/vendor/smart-dom-reader/index.js +1618 -0
  58. package/src/webmcp-bridge.test.ts +429 -0
  59. 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
+ });