@runtypelabs/persona 2.1.0 → 2.3.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.
@@ -0,0 +1,84 @@
1
+ // @vitest-environment jsdom
2
+ import { describe, it, expect } from "vitest";
3
+ import {
4
+ createMarkdownProcessor,
5
+ createDirectivePostprocessor,
6
+ escapeHtml,
7
+ } from "./postprocessors";
8
+ import { createDefaultSanitizer } from "./utils/sanitize";
9
+
10
+ describe("markdown + sanitization integration", () => {
11
+ const md = createMarkdownProcessor();
12
+ const sanitize = createDefaultSanitizer();
13
+
14
+ it("strips script tags from markdown output", () => {
15
+ const html = sanitize(md("# Title\n<script>alert(1)</script>"));
16
+ expect(html).toContain("<h1>Title</h1>");
17
+ expect(html).not.toContain("<script>");
18
+ expect(html).not.toContain("alert(1)");
19
+ });
20
+
21
+ it("strips onerror handlers from img tags in markdown", () => {
22
+ const html = sanitize(md('<img src="x" onerror="alert(1)">'));
23
+ expect(html).not.toContain("onerror");
24
+ });
25
+
26
+ it("strips javascript: URIs from markdown links", () => {
27
+ const html = sanitize(md('[click](javascript:alert(1))'));
28
+ expect(html).not.toContain("javascript:");
29
+ });
30
+
31
+ it("preserves safe markdown headings", () => {
32
+ const html = sanitize(md("## Hello\n\nParagraph text."));
33
+ expect(html).toContain("<h2>Hello</h2>");
34
+ expect(html).toContain("<p>Paragraph text.</p>");
35
+ });
36
+
37
+ it("preserves safe markdown code blocks", () => {
38
+ const html = sanitize(md("```js\nconst x = 1;\n```"));
39
+ expect(html).toContain("<code");
40
+ expect(html).toContain("const x = 1;");
41
+ });
42
+
43
+ it("preserves safe links", () => {
44
+ const html = sanitize(md("[example](https://example.com)"));
45
+ expect(html).toContain('href="https://example.com"');
46
+ });
47
+ });
48
+
49
+ describe("directive postprocessor + sanitization", () => {
50
+ const directive = createDirectivePostprocessor();
51
+ const sanitize = createDefaultSanitizer();
52
+
53
+ it("preserves form directive placeholders", () => {
54
+ const html = sanitize(directive('<Form type="init" />'));
55
+ expect(html).toContain('data-tv-form="init"');
56
+ expect(html).toContain("persona-form-directive");
57
+ });
58
+
59
+ it("sanitizes content surrounding directives", () => {
60
+ const html = sanitize(directive('<Form type="init" />\n<script>bad</script>'));
61
+ expect(html).toContain('data-tv-form="init"');
62
+ expect(html).not.toContain("<script>");
63
+ expect(html).not.toContain("bad");
64
+ });
65
+
66
+ it("handles JSON-style directives", () => {
67
+ const html = sanitize(
68
+ directive('<Directive>{"component":"form","type":"contact"}</Directive>')
69
+ );
70
+ expect(html).toContain('data-tv-form="contact"');
71
+ });
72
+ });
73
+
74
+ describe("escapeHtml", () => {
75
+ it("escapes all HTML special characters", () => {
76
+ expect(escapeHtml('<script>alert("xss")&</script>')).toBe(
77
+ "&lt;script&gt;alert(&quot;xss&quot;)&amp;&lt;/script&gt;"
78
+ );
79
+ });
80
+
81
+ it("escapes single quotes", () => {
82
+ expect(escapeHtml("it's")).toBe("it&#39;s");
83
+ });
84
+ });
package/src/presets.ts ADDED
@@ -0,0 +1,127 @@
1
+ import type { AgentWidgetConfig } from './types';
2
+
3
+ /**
4
+ * A named preset containing partial widget configuration.
5
+ * Apply with: `createAgentExperience(el, { ...PRESET_SHOP.config, apiUrl: '...' })`
6
+ * or via IIFE: `{ ...AgentWidget.PRESETS.shop.config, apiUrl: '...' }`
7
+ */
8
+ export interface WidgetPreset {
9
+ id: string;
10
+ label: string;
11
+ config: Partial<AgentWidgetConfig>;
12
+ }
13
+
14
+ /**
15
+ * Shopping / e-commerce preset.
16
+ * Dark header, rounded launchers, shopping-oriented copy.
17
+ */
18
+ export const PRESET_SHOP: WidgetPreset = {
19
+ id: 'shop',
20
+ label: 'Shopping Assistant',
21
+ config: {
22
+ theme: {
23
+ primary: '#111827',
24
+ accent: '#1d4ed8',
25
+ surface: '#ffffff',
26
+ muted: '#6b7280',
27
+ container: '#f8fafc',
28
+ border: '#f1f5f9',
29
+ divider: '#f1f5f9',
30
+ messageBorder: '#f1f5f9',
31
+ inputBackground: '#ffffff',
32
+ callToAction: '#000000',
33
+ callToActionBackground: '#ffffff',
34
+ sendButtonBackgroundColor: '#111827',
35
+ sendButtonTextColor: '#ffffff',
36
+ radiusSm: '0.75rem',
37
+ radiusMd: '1rem',
38
+ radiusLg: '1.5rem',
39
+ launcherRadius: '9999px',
40
+ buttonRadius: '9999px',
41
+ },
42
+ launcher: {
43
+ title: 'Shopping Assistant',
44
+ subtitle: 'Here to help you find what you need',
45
+ agentIconText: '🛍️',
46
+ position: 'bottom-right',
47
+ width: 'min(400px, calc(100vw - 24px))',
48
+ },
49
+ copy: {
50
+ welcomeTitle: 'Welcome to our shop!',
51
+ welcomeSubtitle: 'I can help you find products and answer questions',
52
+ inputPlaceholder: 'Ask me anything...',
53
+ sendButtonLabel: 'Send',
54
+ },
55
+ suggestionChips: [
56
+ 'What can you help me with?',
57
+ 'Tell me about your features',
58
+ 'How does this work?',
59
+ ],
60
+ },
61
+ };
62
+
63
+ /**
64
+ * Minimal preset.
65
+ * Stripped-down header, no launcher button, suitable for inline embeds.
66
+ */
67
+ export const PRESET_MINIMAL: WidgetPreset = {
68
+ id: 'minimal',
69
+ label: 'Minimal',
70
+ config: {
71
+ launcher: {
72
+ enabled: false,
73
+ fullHeight: true,
74
+ },
75
+ layout: {
76
+ header: {
77
+ layout: 'minimal',
78
+ showCloseButton: false,
79
+ },
80
+ messages: {
81
+ layout: 'minimal',
82
+ },
83
+ },
84
+ theme: {
85
+ panelBorderRadius: '0',
86
+ panelShadow: 'none',
87
+ },
88
+ },
89
+ };
90
+
91
+ /**
92
+ * Fullscreen assistant preset.
93
+ * No launcher, content-max-width constrained, minimal header.
94
+ */
95
+ export const PRESET_FULLSCREEN: WidgetPreset = {
96
+ id: 'fullscreen',
97
+ label: 'Fullscreen Assistant',
98
+ config: {
99
+ launcher: {
100
+ enabled: false,
101
+ fullHeight: true,
102
+ },
103
+ layout: {
104
+ header: {
105
+ layout: 'minimal',
106
+ showCloseButton: false,
107
+ },
108
+ contentMaxWidth: '72ch',
109
+ },
110
+ theme: {
111
+ panelBorderRadius: '0',
112
+ panelShadow: 'none',
113
+ },
114
+ },
115
+ };
116
+
117
+ /** All named presets keyed by ID. */
118
+ export const PRESETS: Record<string, WidgetPreset> = {
119
+ shop: PRESET_SHOP,
120
+ minimal: PRESET_MINIMAL,
121
+ fullscreen: PRESET_FULLSCREEN,
122
+ };
123
+
124
+ /** Look up a preset by ID. */
125
+ export function getPreset(id: string): WidgetPreset | undefined {
126
+ return PRESETS[id];
127
+ }
@@ -2064,9 +2064,9 @@
2064
2064
  display: inline-flex;
2065
2065
  align-items: center;
2066
2066
  justify-content: center;
2067
- padding: 0.25rem;
2068
- border-radius: var(--persona-radius-md, 0.375rem);
2069
- border: 1px solid var(--persona-border, #e5e7eb);
2067
+ padding: var(--persona-artifact-toolbar-icon-padding, 0.25rem);
2068
+ border-radius: var(--persona-artifact-toolbar-icon-radius, var(--persona-radius-md, 0.375rem));
2069
+ border: var(--persona-artifact-toolbar-icon-border, 1px solid var(--persona-border, #e5e7eb));
2070
2070
  background: var(--persona-surface, #ffffff);
2071
2071
  color: var(--persona-artifact-doc-toolbar-icon-color, var(--persona-text, #111827));
2072
2072
  cursor: pointer;
@@ -2074,7 +2074,8 @@
2074
2074
  }
2075
2075
 
2076
2076
  #persona-root .persona-artifact-toolbar-document .persona-artifact-doc-icon-btn:hover {
2077
- background: var(--persona-container, #f3f4f6);
2077
+ color: var(--persona-artifact-toolbar-icon-hover-color, inherit);
2078
+ background: var(--persona-artifact-toolbar-icon-hover-bg, var(--persona-container, #f3f4f6));
2078
2079
  }
2079
2080
 
2080
2081
  #persona-root .persona-artifact-toolbar-document .persona-artifact-doc-icon-btn[aria-pressed="true"] {
@@ -2086,24 +2087,53 @@
2086
2087
  display: inline-flex;
2087
2088
  align-items: center;
2088
2089
  gap: 0.35rem;
2089
- padding: 0.25rem 0.5rem;
2090
- border-radius: var(--persona-radius-md, 0.375rem);
2091
- border: 1px solid var(--persona-border, #e5e7eb);
2092
- background: var(--persona-surface, #ffffff);
2093
- color: var(--persona-artifact-doc-toolbar-icon-color, var(--persona-text, #111827));
2090
+ padding: var(--persona-artifact-toolbar-copy-padding, 0.25rem 0.5rem);
2091
+ border-radius: var(--persona-artifact-toolbar-copy-radius, var(--persona-radius-md, 0.375rem));
2092
+ border: var(--persona-artifact-toolbar-copy-border, 1px solid var(--persona-border, #e5e7eb));
2093
+ background: var(--persona-artifact-toolbar-copy-bg, var(--persona-surface, #ffffff));
2094
+ color: var(--persona-artifact-toolbar-copy-color, var(--persona-artifact-doc-toolbar-icon-color, var(--persona-text, #111827)));
2094
2095
  cursor: pointer;
2095
2096
  font-size: 0.75rem;
2096
2097
  line-height: 1.25;
2097
2098
  }
2098
2099
 
2099
2100
  #persona-root .persona-artifact-toolbar-document .persona-artifact-doc-copy-btn:hover {
2100
- background: var(--persona-container, #f3f4f6);
2101
+ background: var(--persona-artifact-toolbar-icon-hover-bg, var(--persona-container, #f3f4f6));
2101
2102
  }
2102
2103
 
2103
2104
  #persona-root .persona-artifact-toolbar-document .persona-artifact-doc-copy-label {
2104
2105
  font-weight: 500;
2105
2106
  }
2106
2107
 
2108
+ /* Copy menu dropdown theming */
2109
+ #persona-root .persona-artifact-doc-copy-menu {
2110
+ background: var(--persona-artifact-toolbar-copy-menu-bg, var(--persona-surface, #fff));
2111
+ border: var(--persona-artifact-toolbar-copy-menu-border, 1px solid var(--persona-border, #e5e7eb));
2112
+ box-shadow: var(--persona-artifact-toolbar-copy-menu-shadow, 0 4px 6px -1px rgba(0,0,0,.1));
2113
+ border-radius: var(--persona-artifact-toolbar-copy-menu-radius, 0.375rem);
2114
+ }
2115
+
2116
+ #persona-root .persona-artifact-doc-copy-menu button:hover {
2117
+ background: var(--persona-artifact-toolbar-copy-menu-item-hover-bg, var(--persona-container, #f3f4f6));
2118
+ }
2119
+
2120
+ /* Artifact tab theming */
2121
+ #persona-root .persona-artifact-tab {
2122
+ background: var(--persona-artifact-tab-bg, transparent);
2123
+ border-radius: var(--persona-artifact-tab-radius, 0.5rem);
2124
+ color: var(--persona-artifact-tab-color, inherit);
2125
+ }
2126
+
2127
+ #persona-root .persona-artifact-tab.persona-bg-persona-container {
2128
+ background: var(--persona-artifact-tab-active-bg, var(--persona-container, #f3f4f6));
2129
+ border-color: var(--persona-artifact-tab-active-border, var(--persona-border, #e5e7eb));
2130
+ }
2131
+
2132
+ /* Artifact toolbar background theming */
2133
+ #persona-root .persona-artifact-toolbar {
2134
+ background: var(--persona-artifact-toolbar-bg, var(--persona-surface, #fff));
2135
+ }
2136
+
2107
2137
  /* Draggable split handle (desktop split only; hidden in drawer / narrow host / small viewport) */
2108
2138
  #persona-root .persona-artifact-split-handle {
2109
2139
  width: 6px;
@@ -298,6 +298,41 @@ export interface ComposerChromeTokens {
298
298
  shadow: string;
299
299
  }
300
300
 
301
+ /** Artifact toolbar chrome. */
302
+ export interface ArtifactToolbarTokens {
303
+ iconHoverColor?: string;
304
+ iconHoverBackground?: string;
305
+ iconPadding?: string;
306
+ iconBorderRadius?: string;
307
+ iconBorder?: string;
308
+ toggleGroupGap?: string;
309
+ toggleBorderRadius?: string;
310
+ copyBackground?: string;
311
+ copyBorder?: string;
312
+ copyColor?: string;
313
+ copyBorderRadius?: string;
314
+ copyPadding?: string;
315
+ copyMenuBackground?: string;
316
+ copyMenuBorder?: string;
317
+ copyMenuShadow?: string;
318
+ copyMenuBorderRadius?: string;
319
+ copyMenuItemHoverBackground?: string;
320
+ }
321
+
322
+ /** Artifact tab strip chrome. */
323
+ export interface ArtifactTabTokens {
324
+ background?: string;
325
+ activeBackground?: string;
326
+ activeBorder?: string;
327
+ borderRadius?: string;
328
+ textColor?: string;
329
+ }
330
+
331
+ /** Artifact pane chrome. */
332
+ export interface ArtifactPaneTokens {
333
+ toolbarBackground?: string;
334
+ }
335
+
301
336
  export interface ComponentTokens {
302
337
  button: ButtonTokens;
303
338
  input: InputTokens;
@@ -313,6 +348,12 @@ export interface ComponentTokens {
313
348
  toolBubble: ToolBubbleTokens;
314
349
  reasoningBubble: ReasoningBubbleTokens;
315
350
  composer: ComposerChromeTokens;
351
+ /** Artifact toolbar, tab strip, and pane chrome. */
352
+ artifact?: {
353
+ toolbar?: ArtifactToolbarTokens;
354
+ tab?: ArtifactTabTokens;
355
+ pane?: ArtifactPaneTokens;
356
+ };
316
357
  }
317
358
 
318
359
  export interface PaletteExtras {
package/src/types.ts CHANGED
@@ -525,6 +525,14 @@ export type AgentWidgetArtifactsFeature = {
525
525
  allowedTypes?: PersonaArtifactKind[];
526
526
  /** Split / drawer dimensions and launcher widen behavior */
527
527
  layout?: AgentWidgetArtifactsLayoutConfig;
528
+ /**
529
+ * Called when an artifact card action is triggered (open, download).
530
+ * Return `true` to prevent the default behavior.
531
+ */
532
+ onArtifactAction?: (action: {
533
+ type: 'open' | 'download';
534
+ artifactId: string;
535
+ }) => boolean | void;
528
536
  };
529
537
 
530
538
  export type AgentWidgetFeatureFlags = {
@@ -1452,6 +1460,12 @@ export type AgentWidgetHeaderLayoutConfig = {
1452
1460
  trailingActions?: AgentWidgetHeaderTrailingAction[];
1453
1461
  /** Called when a `trailingActions` button is clicked. */
1454
1462
  onAction?: (actionId: string) => void;
1463
+ /**
1464
+ * Called when the header title row is clicked.
1465
+ * Useful for dropdown menus or navigation triggered from the header.
1466
+ * When set, the title row becomes visually interactive (cursor: pointer).
1467
+ */
1468
+ onTitleClick?: () => void;
1455
1469
  };
1456
1470
 
1457
1471
  /**
@@ -2557,7 +2571,21 @@ export type AgentWidgetConfig = {
2557
2571
  * ```
2558
2572
  */
2559
2573
  markdown?: AgentWidgetMarkdownConfig;
2560
-
2574
+
2575
+ /**
2576
+ * HTML sanitization for rendered message content.
2577
+ *
2578
+ * The widget renders AI-generated markdown as HTML. By default, all HTML
2579
+ * output is sanitized using DOMPurify to prevent XSS attacks.
2580
+ *
2581
+ * - `true` (default): sanitize using built-in DOMPurify
2582
+ * - `false`: disable sanitization (only use with fully trusted content sources)
2583
+ * - `(html: string) => string`: custom sanitizer function
2584
+ *
2585
+ * @default true
2586
+ */
2587
+ sanitize?: boolean | ((html: string) => string);
2588
+
2561
2589
  /**
2562
2590
  * Configuration for message action buttons (copy, upvote, downvote).
2563
2591
  * Shows action buttons on assistant messages for user feedback.
package/src/ui.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { escapeHtml, createMarkdownProcessorFromConfig } from "./postprocessors";
2
+ import { resolveSanitizer } from "./utils/sanitize";
2
3
  import { AgentWidgetSession, AgentWidgetSessionStatus } from "./session";
3
4
  import {
4
5
  AgentWidgetConfig,
@@ -321,6 +322,9 @@ const buildPostprocessor = (
321
322
  ? createMarkdownProcessorFromConfig(cfg.markdown)
322
323
  : null;
323
324
 
325
+ // Resolve sanitizer: enabled by default, can be disabled or replaced
326
+ const sanitize = resolveSanitizer(cfg?.sanitize);
327
+
324
328
  return (context) => {
325
329
  let nextText = context.text ?? "";
326
330
  const rawPayload = context.message.rawContent ?? null;
@@ -347,20 +351,20 @@ const buildPostprocessor = (
347
351
  }
348
352
 
349
353
  // Priority: postprocessMessage > markdown config > escapeHtml
354
+ let html: string;
350
355
  if (cfg?.postprocessMessage) {
351
- return cfg.postprocessMessage({
356
+ html = cfg.postprocessMessage({
352
357
  ...context,
353
358
  text: nextText,
354
359
  raw: rawPayload ?? context.text ?? ""
355
360
  });
361
+ } else if (markdownProcessor) {
362
+ html = markdownProcessor(nextText);
363
+ } else {
364
+ html = escapeHtml(nextText);
356
365
  }
357
366
 
358
- // Use markdown processor if markdown config is provided
359
- if (markdownProcessor) {
360
- return markdownProcessor(nextText);
361
- }
362
-
363
- return escapeHtml(nextText);
367
+ return sanitize ? sanitize(html) : html;
364
368
  };
365
369
  };
366
370
 
@@ -1142,6 +1146,9 @@ export const createAgentExperience = (
1142
1146
  event.stopPropagation();
1143
1147
  const artifactId = dlBtn.getAttribute('data-download-artifact');
1144
1148
  if (!artifactId) return;
1149
+ // Let integrator intercept
1150
+ const dlPrevented = config.features?.artifacts?.onArtifactAction?.({ type: 'download', artifactId });
1151
+ if (dlPrevented === true) return;
1145
1152
  // Try session state first, fall back to content stored in the card's rawContent props
1146
1153
  const artifact = session.getArtifactById(artifactId);
1147
1154
  let markdown = artifact?.markdown;
@@ -1180,6 +1187,9 @@ export const createAgentExperience = (
1180
1187
  if (!card) return;
1181
1188
  const artifactId = card.getAttribute('data-open-artifact');
1182
1189
  if (!artifactId) return;
1190
+ // Let integrator intercept
1191
+ const openPrevented = config.features?.artifacts?.onArtifactAction?.({ type: 'open', artifactId });
1192
+ if (openPrevented === true) return;
1183
1193
  event.preventDefault();
1184
1194
  event.stopPropagation();
1185
1195
  session.selectArtifact(artifactId);
@@ -3560,6 +3570,8 @@ export const createAgentExperience = (
3560
3570
  const previousMessageActions = config.messageActions;
3561
3571
  const previousLayoutMessages = config.layout?.messages;
3562
3572
  const previousColorScheme = config.colorScheme;
3573
+ const previousLoadingIndicator = config.loadingIndicator;
3574
+ const previousIterationDisplay = config.iterationDisplay;
3563
3575
  config = { ...config, ...nextConfig };
3564
3576
  // applyFullHeightStyles resets mount.style.cssText, so call it before applyThemeVariables
3565
3577
  applyFullHeightStyles();
@@ -3790,7 +3802,12 @@ export const createAgentExperience = (
3790
3802
  const toolCallConfigChanged = JSON.stringify(nextConfig.toolCall) !== JSON.stringify(previousToolCallConfig);
3791
3803
  const messageActionsChanged = JSON.stringify(config.messageActions) !== JSON.stringify(previousMessageActions);
3792
3804
  const layoutMessagesChanged = JSON.stringify(config.layout?.messages) !== JSON.stringify(previousLayoutMessages);
3793
- const messagesConfigChanged = toolCallConfigChanged || messageActionsChanged || layoutMessagesChanged;
3805
+ const loadingIndicatorChanged = config.loadingIndicator?.render !== previousLoadingIndicator?.render
3806
+ || config.loadingIndicator?.renderIdle !== previousLoadingIndicator?.renderIdle
3807
+ || config.loadingIndicator?.showBubble !== previousLoadingIndicator?.showBubble;
3808
+ const iterationDisplayChanged = config.iterationDisplay !== previousIterationDisplay;
3809
+ const messagesConfigChanged = toolCallConfigChanged || messageActionsChanged || layoutMessagesChanged
3810
+ || loadingIndicatorChanged || iterationDisplayChanged;
3794
3811
  if (messagesConfigChanged && session) {
3795
3812
  configVersion++;
3796
3813
  renderMessagesWithPlugins(messagesWrapper, session.getMessages(), postprocess);
@@ -0,0 +1,114 @@
1
+ import { describe, it, expect, vi } from "vitest";
2
+ import {
3
+ defaultJsonActionParser,
4
+ defaultActionHandlers,
5
+ createActionManager,
6
+ } from "./actions";
7
+ import type { AgentWidgetMessage } from "../types";
8
+
9
+ const makeMessage = (overrides: Partial<AgentWidgetMessage> = {}): AgentWidgetMessage => ({
10
+ id: "msg-1",
11
+ role: "assistant",
12
+ content: "",
13
+ createdAt: new Date().toISOString(),
14
+ ...overrides,
15
+ });
16
+
17
+ describe("defaultJsonActionParser", () => {
18
+ it("parses valid action JSON", () => {
19
+ const result = defaultJsonActionParser({
20
+ text: '{"action":"message","text":"hi"}',
21
+ message: makeMessage(),
22
+ });
23
+ expect(result).toEqual({
24
+ type: "message",
25
+ payload: { text: "hi" },
26
+ raw: { action: "message", text: "hi" },
27
+ });
28
+ });
29
+
30
+ it("returns null for non-action JSON", () => {
31
+ const result = defaultJsonActionParser({
32
+ text: '{"foo":"bar"}',
33
+ message: makeMessage(),
34
+ });
35
+ expect(result).toBeNull();
36
+ });
37
+
38
+ it("returns null for non-JSON text", () => {
39
+ const result = defaultJsonActionParser({
40
+ text: "hello world",
41
+ message: makeMessage(),
42
+ });
43
+ expect(result).toBeNull();
44
+ });
45
+
46
+ it("returns null for empty text", () => {
47
+ expect(defaultJsonActionParser({ text: "", message: makeMessage() })).toBeNull();
48
+ });
49
+
50
+ it("strips code fences before parsing", () => {
51
+ const text = '```json\n{"action":"message","text":"fenced"}\n```';
52
+ const result = defaultJsonActionParser({ text, message: makeMessage() });
53
+ expect(result).toEqual({
54
+ type: "message",
55
+ payload: { text: "fenced" },
56
+ raw: { action: "message", text: "fenced" },
57
+ });
58
+ });
59
+ });
60
+
61
+ describe("createActionManager.process", () => {
62
+ const makeManager = (overrides?: Record<string, unknown>) => {
63
+ let metadata: Record<string, unknown> = {};
64
+ return createActionManager({
65
+ parsers: [defaultJsonActionParser],
66
+ handlers: [defaultActionHandlers.message],
67
+ getSessionMetadata: () => metadata,
68
+ updateSessionMetadata: (updater) => { metadata = updater(metadata); },
69
+ emit: vi.fn(),
70
+ documentRef: null,
71
+ ...overrides,
72
+ });
73
+ };
74
+
75
+ it("skips streaming messages", () => {
76
+ const manager = makeManager();
77
+ const result = manager.process({
78
+ text: '{"action":"message","text":"hi"}',
79
+ message: makeMessage(),
80
+ streaming: true,
81
+ });
82
+ expect(result).toBeNull();
83
+ });
84
+
85
+ it("skips non-assistant messages", () => {
86
+ const manager = makeManager();
87
+ const result = manager.process({
88
+ text: '{"action":"message","text":"hi"}',
89
+ message: makeMessage({ role: "user" }),
90
+ streaming: false,
91
+ });
92
+ expect(result).toBeNull();
93
+ });
94
+
95
+ it("deduplicates by message ID", () => {
96
+ const manager = makeManager();
97
+ const msg = makeMessage({ content: '{"action":"message","text":"hi"}' });
98
+ const first = manager.process({ text: msg.content, message: msg, streaming: false });
99
+ expect(first).not.toBeNull();
100
+
101
+ const second = manager.process({ text: msg.content, message: msg, streaming: false });
102
+ expect(second).toBeNull();
103
+ });
104
+
105
+ it("processes valid action and returns display text", () => {
106
+ const manager = makeManager();
107
+ const result = manager.process({
108
+ text: '{"action":"message","text":"hello"}',
109
+ message: makeMessage(),
110
+ streaming: false,
111
+ });
112
+ expect(result).toEqual({ text: "hello", persist: true, resubmit: undefined });
113
+ });
114
+ });