@runtypelabs/persona 3.19.0 → 3.20.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/src/session.ts CHANGED
@@ -11,6 +11,7 @@ import {
11
11
  InjectAssistantMessageOptions,
12
12
  InjectUserMessageOptions,
13
13
  InjectSystemMessageOptions,
14
+ InjectComponentDirectiveOptions,
14
15
  PersonaArtifactRecord,
15
16
  PersonaArtifactManualUpsert
16
17
  } from "./types";
@@ -572,7 +573,8 @@ export class AgentWidgetSession {
572
573
  createdAt,
573
574
  sequence,
574
575
  streaming = false,
575
- voiceProcessing
576
+ voiceProcessing,
577
+ rawContent
576
578
  } = options;
577
579
 
578
580
  // Generate appropriate ID based on role
@@ -594,7 +596,8 @@ export class AgentWidgetSession {
594
596
  // Only include optional fields if provided
595
597
  ...(llmContent !== undefined && { llmContent }),
596
598
  ...(contentParts !== undefined && { contentParts }),
597
- ...(voiceProcessing !== undefined && { voiceProcessing })
599
+ ...(voiceProcessing !== undefined && { voiceProcessing }),
600
+ ...(rawContent !== undefined && { rawContent })
598
601
  };
599
602
 
600
603
  // Use upsert to handle both new messages and updates (streaming)
@@ -673,7 +676,9 @@ export class AgentWidgetSession {
673
676
  id,
674
677
  createdAt,
675
678
  sequence,
676
- streaming = false
679
+ streaming = false,
680
+ voiceProcessing,
681
+ rawContent
677
682
  } = options;
678
683
 
679
684
  const messageId =
@@ -692,7 +697,9 @@ export class AgentWidgetSession {
692
697
  sequence: sequence ?? this.nextSequence(),
693
698
  streaming,
694
699
  ...(llmContent !== undefined && { llmContent }),
695
- ...(contentParts !== undefined && { contentParts })
700
+ ...(contentParts !== undefined && { contentParts }),
701
+ ...(voiceProcessing !== undefined && { voiceProcessing }),
702
+ ...(rawContent !== undefined && { rawContent })
696
703
  };
697
704
 
698
705
  results.push(message);
@@ -705,6 +712,53 @@ export class AgentWidgetSession {
705
712
  return results;
706
713
  }
707
714
 
715
+ /**
716
+ * Convenience method for injecting a registered component directive as
717
+ * an assistant message — the same shape Persona produces from a streamed
718
+ * `{ "text": "...", "component": "...", "props": {...} }` payload.
719
+ *
720
+ * Sets `content` to `text`, `rawContent` to the JSON directive (so
721
+ * `extractComponentDirectiveFromMessage` can find it), and forwards
722
+ * `llmContent` / `id` / `createdAt` / `sequence`.
723
+ *
724
+ * @example
725
+ * session.injectComponentDirective({
726
+ * component: "DynamicForm",
727
+ * props: { title: "Book a demo", fields: [...] },
728
+ * text: "Share your details to book a demo.",
729
+ * llmContent: "[Showed booking form]"
730
+ * });
731
+ */
732
+ public injectComponentDirective(
733
+ options: InjectComponentDirectiveOptions
734
+ ): AgentWidgetMessage {
735
+ const {
736
+ component,
737
+ props = {},
738
+ text = "",
739
+ llmContent,
740
+ id,
741
+ createdAt,
742
+ sequence
743
+ } = options;
744
+
745
+ const directive: { text: string; component: string; props: Record<string, unknown> } = {
746
+ text,
747
+ component,
748
+ props
749
+ };
750
+
751
+ return this.injectMessage({
752
+ role: "assistant",
753
+ content: text,
754
+ rawContent: JSON.stringify(directive),
755
+ ...(llmContent !== undefined && { llmContent }),
756
+ ...(id !== undefined && { id }),
757
+ ...(createdAt !== undefined && { createdAt }),
758
+ ...(sequence !== undefined && { sequence })
759
+ });
760
+ }
761
+
708
762
  public async sendMessage(
709
763
  rawInput: string,
710
764
  options?: {
package/src/types.ts CHANGED
@@ -3768,6 +3768,23 @@ export type InjectMessageOptions = {
3768
3768
  * Consumers can detect this in `messageTransform` to render custom UI.
3769
3769
  */
3770
3770
  voiceProcessing?: boolean;
3771
+
3772
+ /**
3773
+ * Raw structured payload (typically a JSON string) representing the
3774
+ * full directive that produced this message — e.g. `{ "text": "...",
3775
+ * "component": "Foo", "props": {...} }`.
3776
+ *
3777
+ * Mirrors the field populated by stream parsers during normal LLM
3778
+ * responses. Set this when injecting a message that should render as a
3779
+ * component directive (`hasComponentDirective` /
3780
+ * `extractComponentDirectiveFromMessage` look at `rawContent` first).
3781
+ *
3782
+ * Priority for the API payload remains:
3783
+ * `contentParts > llmContent > rawContent > content`. Pass `llmContent`
3784
+ * alongside `rawContent` if the LLM should see something other than the
3785
+ * raw directive.
3786
+ */
3787
+ rawContent?: string;
3771
3788
  };
3772
3789
 
3773
3790
  /**
@@ -3788,6 +3805,64 @@ export type InjectUserMessageOptions = Omit<InjectMessageOptions, "role">;
3788
3805
  */
3789
3806
  export type InjectSystemMessageOptions = Omit<InjectMessageOptions, "role">;
3790
3807
 
3808
+ /**
3809
+ * Options for injecting an assistant message that renders as a component
3810
+ * directive — sugar over `injectAssistantMessage` for the common case of
3811
+ * "render this registered component, same as if the LLM had emitted it".
3812
+ *
3813
+ * Equivalent to calling `injectAssistantMessage({ content: text, rawContent:
3814
+ * JSON.stringify({ text, component, props }), llmContent })`.
3815
+ *
3816
+ * @example
3817
+ * widget.injectComponentDirective({
3818
+ * component: "DynamicForm",
3819
+ * props: { title: "Book a demo", fields: [...] },
3820
+ * text: "Share your details to book a demo.",
3821
+ * llmContent: "[Showed booking form]"
3822
+ * });
3823
+ */
3824
+ export type InjectComponentDirectiveOptions = {
3825
+ /**
3826
+ * Name of a renderer registered via `componentRegistry.register(...)`.
3827
+ */
3828
+ component: string;
3829
+
3830
+ /**
3831
+ * Props passed to the component renderer.
3832
+ */
3833
+ props?: Record<string, unknown>;
3834
+
3835
+ /**
3836
+ * Bubble copy displayed above (or with) the rendered component.
3837
+ * Mirrors the `text` field in a streamed JSON directive.
3838
+ * @default ""
3839
+ */
3840
+ text?: string;
3841
+
3842
+ /**
3843
+ * Content sent to the LLM in API requests. When omitted, the raw
3844
+ * directive JSON is what the LLM would see (per the standard
3845
+ * priority chain). Provide a redacted/short version to avoid sending
3846
+ * the full directive in subsequent turns.
3847
+ */
3848
+ llmContent?: string;
3849
+
3850
+ /**
3851
+ * Optional message ID. If omitted, an assistant id is auto-generated.
3852
+ */
3853
+ id?: string;
3854
+
3855
+ /**
3856
+ * Optional creation timestamp (ISO string). If omitted, uses current time.
3857
+ */
3858
+ createdAt?: string;
3859
+
3860
+ /**
3861
+ * Optional sequence number for ordering.
3862
+ */
3863
+ sequence?: number;
3864
+ };
3865
+
3791
3866
  export type PersonaArtifactRecord = {
3792
3867
  id: string;
3793
3868
  artifactType: PersonaArtifactKind;
package/src/ui.ts CHANGED
@@ -19,6 +19,7 @@ import {
19
19
  InjectAssistantMessageOptions,
20
20
  InjectUserMessageOptions,
21
21
  InjectSystemMessageOptions,
22
+ InjectComponentDirectiveOptions,
22
23
  LoadingIndicatorRenderContext,
23
24
  IdleIndicatorRenderContext,
24
25
  VoiceStatus,
@@ -296,6 +297,14 @@ type Controller = {
296
297
  * Inject multiple messages in a single batch with one sort and one render pass.
297
298
  */
298
299
  injectMessageBatch: (optionsList: InjectMessageOptions[]) => AgentWidgetMessage[];
300
+ /**
301
+ * Convenience method for injecting an assistant message that renders as a
302
+ * registered component — same shape Persona produces from a streamed
303
+ * `{ "text": "...", "component": "...", "props": {...} }` payload.
304
+ */
305
+ injectComponentDirective: (
306
+ options: InjectComponentDirectiveOptions
307
+ ) => AgentWidgetMessage;
299
308
  /**
300
309
  * @deprecated Use injectMessage() instead.
301
310
  */
@@ -7030,6 +7039,15 @@ export const createAgentExperience = (
7030
7039
  }
7031
7040
  return session.injectMessageBatch(optionsList);
7032
7041
  },
7042
+ injectComponentDirective(
7043
+ options: InjectComponentDirectiveOptions
7044
+ ): AgentWidgetMessage {
7045
+ // Auto-open widget if closed and the panel is toggleable
7046
+ if (!open && isPanelToggleable()) {
7047
+ setOpenState(true, "system");
7048
+ }
7049
+ return session.injectComponentDirective(options);
7050
+ },
7033
7051
  /** @deprecated Use injectMessage() instead */
7034
7052
  injectTestMessage(event: AgentWidgetEvent) {
7035
7053
  // Auto-open widget if closed and the panel is toggleable
@@ -0,0 +1,134 @@
1
+ import { describe, it, expect } from "vitest";
2
+
3
+ import {
4
+ extractComponentDirectiveFromMessage,
5
+ hasComponentDirective
6
+ } from "./component-middleware";
7
+ import type { AgentWidgetMessage } from "../types";
8
+
9
+ const baseMessage = (overrides: Partial<AgentWidgetMessage>): AgentWidgetMessage => ({
10
+ id: "msg-1",
11
+ role: "assistant",
12
+ content: "",
13
+ createdAt: "2026-01-01T00:00:00.000Z",
14
+ ...overrides
15
+ });
16
+
17
+ describe("extractComponentDirectiveFromMessage", () => {
18
+ it("extracts directive from rawContent (streamed path)", () => {
19
+ const directive = {
20
+ text: "Booking form",
21
+ component: "DynamicForm",
22
+ props: { title: "Book a demo" }
23
+ };
24
+ const message = baseMessage({
25
+ content: "Booking form",
26
+ rawContent: JSON.stringify(directive)
27
+ });
28
+
29
+ const result = extractComponentDirectiveFromMessage(message);
30
+
31
+ expect(result).not.toBeNull();
32
+ expect(result?.component).toBe("DynamicForm");
33
+ expect(result?.props).toEqual({ title: "Book a demo" });
34
+ expect(result?.raw).toBe(JSON.stringify(directive));
35
+ });
36
+
37
+ it("falls back to content when rawContent is missing and content looks like JSON", () => {
38
+ const directive = {
39
+ text: "Booking form",
40
+ component: "DynamicForm",
41
+ props: { title: "Book a demo" }
42
+ };
43
+ const message = baseMessage({
44
+ content: JSON.stringify(directive)
45
+ });
46
+
47
+ const result = extractComponentDirectiveFromMessage(message);
48
+
49
+ expect(result).not.toBeNull();
50
+ expect(result?.component).toBe("DynamicForm");
51
+ expect(result?.props).toEqual({ title: "Book a demo" });
52
+ });
53
+
54
+ it("prefers rawContent over content when both are present", () => {
55
+ const message = baseMessage({
56
+ rawContent: JSON.stringify({
57
+ text: "Raw form",
58
+ component: "RawComponent",
59
+ props: { source: "raw" }
60
+ }),
61
+ content: JSON.stringify({
62
+ text: "Content form",
63
+ component: "ContentComponent",
64
+ props: { source: "content" }
65
+ })
66
+ });
67
+
68
+ const result = extractComponentDirectiveFromMessage(message);
69
+
70
+ expect(result?.component).toBe("RawComponent");
71
+ expect(result?.props).toEqual({ source: "raw" });
72
+ });
73
+
74
+ it("returns null for plain-text content", () => {
75
+ const message = baseMessage({ content: "Hello, how can I help?" });
76
+ expect(extractComponentDirectiveFromMessage(message)).toBeNull();
77
+ });
78
+
79
+ it("returns null when content is JSON without a component field", () => {
80
+ const message = baseMessage({
81
+ content: JSON.stringify({ text: "Just text", foo: "bar" })
82
+ });
83
+ expect(extractComponentDirectiveFromMessage(message)).toBeNull();
84
+ });
85
+
86
+ it("returns null for empty rawContent and empty content", () => {
87
+ const message = baseMessage({ rawContent: "", content: "" });
88
+ expect(extractComponentDirectiveFromMessage(message)).toBeNull();
89
+ });
90
+
91
+ it("returns null when JSON is malformed", () => {
92
+ const message = baseMessage({ rawContent: '{"component": "Foo"' });
93
+ expect(extractComponentDirectiveFromMessage(message)).toBeNull();
94
+ });
95
+
96
+ it("defaults props to {} when the directive omits or nulls them", () => {
97
+ const message = baseMessage({
98
+ rawContent: JSON.stringify({ text: "x", component: "Foo" })
99
+ });
100
+ const result = extractComponentDirectiveFromMessage(message);
101
+ expect(result?.props).toEqual({});
102
+
103
+ const messageNullProps = baseMessage({
104
+ rawContent: JSON.stringify({ text: "x", component: "Foo", props: null })
105
+ });
106
+ expect(extractComponentDirectiveFromMessage(messageNullProps)?.props).toEqual({});
107
+ });
108
+ });
109
+
110
+ describe("hasComponentDirective", () => {
111
+ it("returns true when rawContent carries a directive", () => {
112
+ const message = baseMessage({
113
+ rawContent: JSON.stringify({ text: "x", component: "Foo", props: {} })
114
+ });
115
+ expect(hasComponentDirective(message)).toBe(true);
116
+ });
117
+
118
+ it("returns true when only content carries a directive", () => {
119
+ const message = baseMessage({
120
+ content: JSON.stringify({ text: "x", component: "Foo", props: {} })
121
+ });
122
+ expect(hasComponentDirective(message)).toBe(true);
123
+ });
124
+
125
+ it("returns false for plain content", () => {
126
+ const message = baseMessage({ content: "Hello!" });
127
+ expect(hasComponentDirective(message)).toBe(false);
128
+ });
129
+
130
+ it("returns false for malformed JSON", () => {
131
+ const message = baseMessage({ rawContent: "{not json" });
132
+ expect(hasComponentDirective(message)).toBe(false);
133
+ });
134
+ });
@@ -87,18 +87,42 @@ export function createComponentMiddleware() {
87
87
  }
88
88
 
89
89
  /**
90
- * Checks if a message contains a component directive in its raw content
90
+ * Picks the field that may carry a JSON directive payload. Streamed messages
91
+ * populate `rawContent`; manually injected messages may pass the JSON via
92
+ * `content` directly. We try `rawContent` first, then fall back to `content`
93
+ * when it looks like JSON, so both code paths render the same way.
94
+ */
95
+ function selectDirectiveSource(message: AgentWidgetMessage): string | null {
96
+ if (typeof message.rawContent === "string" && message.rawContent.length > 0) {
97
+ return message.rawContent;
98
+ }
99
+ if (typeof message.content === "string") {
100
+ const trimmed = message.content.trim();
101
+ if (trimmed.startsWith("{") || trimmed.startsWith("[")) {
102
+ return message.content;
103
+ }
104
+ }
105
+ return null;
106
+ }
107
+
108
+ /**
109
+ * Checks if a message contains a component directive.
110
+ *
111
+ * Looks at `rawContent` first (the field set by stream parsers); falls back
112
+ * to `content` when it looks like JSON, so injected messages that pass the
113
+ * directive via `content` (or have no `rawContent`) are still recognized.
91
114
  */
92
115
  export function hasComponentDirective(message: AgentWidgetMessage): boolean {
93
- if (!message.rawContent) return false;
94
-
116
+ const source = selectDirectiveSource(message);
117
+ if (!source) return false;
118
+
95
119
  try {
96
- const parsed = JSON.parse(message.rawContent);
120
+ const parsed = JSON.parse(source);
97
121
  return (
98
122
  typeof parsed === "object" &&
99
123
  parsed !== null &&
100
124
  "component" in parsed &&
101
- typeof parsed.component === "string"
125
+ typeof (parsed as { component: unknown }).component === "string"
102
126
  );
103
127
  } catch {
104
128
  return false;
@@ -106,27 +130,34 @@ export function hasComponentDirective(message: AgentWidgetMessage): boolean {
106
130
  }
107
131
 
108
132
  /**
109
- * Extracts component directive from a complete message
133
+ * Extracts component directive from a complete message.
134
+ *
135
+ * Looks at `rawContent` first (the field set by stream parsers); falls back
136
+ * to `content` when it looks like JSON, so injected messages that pass the
137
+ * directive via `content` (or have no `rawContent`) render the same as
138
+ * streamed ones.
110
139
  */
111
140
  export function extractComponentDirectiveFromMessage(
112
141
  message: AgentWidgetMessage
113
142
  ): ComponentDirective | null {
114
- if (!message.rawContent) return null;
143
+ const source = selectDirectiveSource(message);
144
+ if (!source) return null;
115
145
 
116
146
  try {
117
- const parsed = JSON.parse(message.rawContent);
147
+ const parsed = JSON.parse(source);
118
148
  if (
119
149
  typeof parsed === "object" &&
120
150
  parsed !== null &&
121
151
  "component" in parsed &&
122
- typeof parsed.component === "string"
152
+ typeof (parsed as { component: unknown }).component === "string"
123
153
  ) {
154
+ const directive = parsed as { component: string; props?: unknown };
124
155
  return {
125
- component: parsed.component,
126
- props: (parsed.props && typeof parsed.props === "object" && parsed.props !== null
127
- ? parsed.props
156
+ component: directive.component,
157
+ props: (directive.props && typeof directive.props === "object" && directive.props !== null
158
+ ? directive.props
128
159
  : {}) as Record<string, unknown>,
129
- raw: message.rawContent
160
+ raw: source
130
161
  };
131
162
  }
132
163
  } catch {