@runtypelabs/persona 1.36.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 (61) hide show
  1. package/README.md +1080 -0
  2. package/dist/index.cjs +140 -0
  3. package/dist/index.cjs.map +1 -0
  4. package/dist/index.d.cts +2626 -0
  5. package/dist/index.d.ts +2626 -0
  6. package/dist/index.global.js +1843 -0
  7. package/dist/index.global.js.map +1 -0
  8. package/dist/index.js +140 -0
  9. package/dist/index.js.map +1 -0
  10. package/dist/install.global.js +2 -0
  11. package/dist/install.global.js.map +1 -0
  12. package/dist/widget.css +1627 -0
  13. package/package.json +79 -0
  14. package/src/@types/idiomorph.d.ts +37 -0
  15. package/src/client.test.ts +387 -0
  16. package/src/client.ts +1589 -0
  17. package/src/components/composer-builder.ts +530 -0
  18. package/src/components/feedback.ts +379 -0
  19. package/src/components/forms.ts +170 -0
  20. package/src/components/header-builder.ts +455 -0
  21. package/src/components/header-layouts.ts +303 -0
  22. package/src/components/launcher.ts +193 -0
  23. package/src/components/message-bubble.ts +528 -0
  24. package/src/components/messages.ts +54 -0
  25. package/src/components/panel.ts +204 -0
  26. package/src/components/reasoning-bubble.ts +144 -0
  27. package/src/components/registry.ts +87 -0
  28. package/src/components/suggestions.ts +97 -0
  29. package/src/components/tool-bubble.ts +288 -0
  30. package/src/defaults.ts +321 -0
  31. package/src/index.ts +175 -0
  32. package/src/install.ts +284 -0
  33. package/src/plugins/registry.ts +77 -0
  34. package/src/plugins/types.ts +95 -0
  35. package/src/postprocessors.ts +194 -0
  36. package/src/runtime/init.ts +162 -0
  37. package/src/session.ts +376 -0
  38. package/src/styles/tailwind.css +20 -0
  39. package/src/styles/widget.css +1627 -0
  40. package/src/types.ts +1635 -0
  41. package/src/ui.ts +3341 -0
  42. package/src/utils/actions.ts +227 -0
  43. package/src/utils/attachment-manager.ts +384 -0
  44. package/src/utils/code-generators.test.ts +500 -0
  45. package/src/utils/code-generators.ts +1806 -0
  46. package/src/utils/component-middleware.ts +137 -0
  47. package/src/utils/component-parser.ts +119 -0
  48. package/src/utils/constants.ts +16 -0
  49. package/src/utils/content.ts +306 -0
  50. package/src/utils/dom.ts +25 -0
  51. package/src/utils/events.ts +41 -0
  52. package/src/utils/formatting.test.ts +166 -0
  53. package/src/utils/formatting.ts +470 -0
  54. package/src/utils/icons.ts +92 -0
  55. package/src/utils/message-id.ts +37 -0
  56. package/src/utils/morph.ts +36 -0
  57. package/src/utils/positioning.ts +17 -0
  58. package/src/utils/storage.ts +72 -0
  59. package/src/utils/theme.ts +105 -0
  60. package/src/widget.css +1 -0
  61. package/widget.css +1 -0
package/src/client.ts ADDED
@@ -0,0 +1,1589 @@
1
+ import {
2
+ AgentWidgetConfig,
3
+ AgentWidgetMessage,
4
+ AgentWidgetEvent,
5
+ AgentWidgetStreamParser,
6
+ AgentWidgetContextProvider,
7
+ AgentWidgetRequestMiddleware,
8
+ AgentWidgetRequestPayload,
9
+ AgentWidgetCustomFetch,
10
+ AgentWidgetSSEEventParser,
11
+ AgentWidgetHeadersFunction,
12
+ AgentWidgetSSEEventResult,
13
+ ClientSession,
14
+ ClientInitResponse,
15
+ ClientChatRequest,
16
+ ClientFeedbackRequest,
17
+ ClientFeedbackType
18
+ } from "./types";
19
+ import {
20
+ extractTextFromJson,
21
+ createPlainTextParser,
22
+ createJsonStreamParser,
23
+ createRegexJsonParser,
24
+ createXmlParser
25
+ } from "./utils/formatting";
26
+
27
+ type DispatchOptions = {
28
+ messages: AgentWidgetMessage[];
29
+ signal?: AbortSignal;
30
+ /** Pre-generated ID for the expected assistant response (for feedback tracking) */
31
+ assistantMessageId?: string;
32
+ };
33
+
34
+ type SSEHandler = (event: AgentWidgetEvent) => void;
35
+
36
+ const DEFAULT_ENDPOINT = "https://api.travrse.ai/v1/dispatch";
37
+ const DEFAULT_CLIENT_API_BASE = "https://api.travrse.ai";
38
+
39
+ /**
40
+ * Check if a message has valid (non-empty) content for sending to the API.
41
+ * Filters out messages with empty content that would cause validation errors.
42
+ *
43
+ * @see https://github.com/anthropics/claude-code/issues/XXX - Empty assistant messages from failed requests
44
+ */
45
+ const hasValidContent = (message: AgentWidgetMessage): boolean => {
46
+ // Check contentParts (multi-modal content)
47
+ if (message.contentParts && message.contentParts.length > 0) {
48
+ return true;
49
+ }
50
+ // Check rawContent
51
+ if (message.rawContent && message.rawContent.trim().length > 0) {
52
+ return true;
53
+ }
54
+ // Check content
55
+ if (message.content && message.content.trim().length > 0) {
56
+ return true;
57
+ }
58
+ return false;
59
+ };
60
+
61
+ /**
62
+ * Maps parserType string to the corresponding parser factory function
63
+ */
64
+ function getParserFromType(parserType?: "plain" | "json" | "regex-json" | "xml"): () => AgentWidgetStreamParser {
65
+ switch (parserType) {
66
+ case "json":
67
+ return createJsonStreamParser;
68
+ case "regex-json":
69
+ return createRegexJsonParser;
70
+ case "xml":
71
+ return createXmlParser;
72
+ case "plain":
73
+ default:
74
+ return createPlainTextParser;
75
+ }
76
+ }
77
+
78
+ export class AgentWidgetClient {
79
+ private readonly apiUrl: string;
80
+ private readonly headers: Record<string, string>;
81
+ private readonly debug: boolean;
82
+ private readonly createStreamParser: () => AgentWidgetStreamParser;
83
+ private readonly contextProviders: AgentWidgetContextProvider[];
84
+ private readonly requestMiddleware?: AgentWidgetRequestMiddleware;
85
+ private readonly customFetch?: AgentWidgetCustomFetch;
86
+ private readonly parseSSEEvent?: AgentWidgetSSEEventParser;
87
+ private readonly getHeaders?: AgentWidgetHeadersFunction;
88
+
89
+ // Client token mode properties
90
+ private clientSession: ClientSession | null = null;
91
+ private sessionInitPromise: Promise<ClientSession> | null = null;
92
+
93
+ constructor(private config: AgentWidgetConfig = {}) {
94
+ this.apiUrl = config.apiUrl ?? DEFAULT_ENDPOINT;
95
+ this.headers = {
96
+ "Content-Type": "application/json",
97
+ ...config.headers
98
+ };
99
+ this.debug = Boolean(config.debug);
100
+ // Use custom stream parser if provided, otherwise use parserType, or fall back to plain text parser
101
+ this.createStreamParser = config.streamParser ?? getParserFromType(config.parserType);
102
+ this.contextProviders = config.contextProviders ?? [];
103
+ this.requestMiddleware = config.requestMiddleware;
104
+ this.customFetch = config.customFetch;
105
+ this.parseSSEEvent = config.parseSSEEvent;
106
+ this.getHeaders = config.getHeaders;
107
+ }
108
+
109
+ /**
110
+ * Check if running in client token mode
111
+ */
112
+ public isClientTokenMode(): boolean {
113
+ return !!this.config.clientToken;
114
+ }
115
+
116
+ /**
117
+ * Get the appropriate API URL based on mode
118
+ */
119
+ private getClientApiUrl(endpoint: 'init' | 'chat'): string {
120
+ const baseUrl = this.config.apiUrl?.replace(/\/+$/, '').replace(/\/v1\/dispatch$/, '') || DEFAULT_CLIENT_API_BASE;
121
+ return endpoint === 'init'
122
+ ? `${baseUrl}/v1/client/init`
123
+ : `${baseUrl}/v1/client/chat`;
124
+ }
125
+
126
+ /**
127
+ * Get the current client session (if any)
128
+ */
129
+ public getClientSession(): ClientSession | null {
130
+ return this.clientSession;
131
+ }
132
+
133
+ /**
134
+ * Initialize session for client token mode.
135
+ * Called automatically on first message if not already initialized.
136
+ */
137
+ public async initSession(): Promise<ClientSession> {
138
+ if (!this.isClientTokenMode()) {
139
+ throw new Error('initSession() only available in client token mode');
140
+ }
141
+
142
+ // Return existing session if valid
143
+ if (this.clientSession && new Date() < this.clientSession.expiresAt) {
144
+ return this.clientSession;
145
+ }
146
+
147
+ // Deduplicate concurrent init calls
148
+ if (this.sessionInitPromise) {
149
+ return this.sessionInitPromise;
150
+ }
151
+
152
+ this.sessionInitPromise = this._doInitSession();
153
+ try {
154
+ const session = await this.sessionInitPromise;
155
+ this.clientSession = session;
156
+ this.config.onSessionInit?.(session);
157
+ return session;
158
+ } finally {
159
+ this.sessionInitPromise = null;
160
+ }
161
+ }
162
+
163
+ private async _doInitSession(): Promise<ClientSession> {
164
+ // Get stored session_id if available (for session resumption)
165
+ const storedSessionId = this.config.getStoredSessionId?.() || null;
166
+
167
+ const requestBody: Record<string, unknown> = {
168
+ token: this.config.clientToken,
169
+ ...(this.config.flowId && { flow_id: this.config.flowId }),
170
+ ...(storedSessionId && { session_id: storedSessionId }),
171
+ };
172
+
173
+ const response = await fetch(this.getClientApiUrl('init'), {
174
+ method: 'POST',
175
+ headers: {
176
+ 'Content-Type': 'application/json',
177
+ },
178
+ body: JSON.stringify(requestBody),
179
+ });
180
+
181
+ if (!response.ok) {
182
+ const error = await response.json().catch(() => ({ error: 'Session initialization failed' }));
183
+ if (response.status === 401) {
184
+ throw new Error(`Invalid client token: ${error.hint || error.error}`);
185
+ }
186
+ if (response.status === 403) {
187
+ throw new Error(`Origin not allowed: ${error.hint || error.error}`);
188
+ }
189
+ throw new Error(error.error || 'Failed to initialize session');
190
+ }
191
+
192
+ const data: ClientInitResponse = await response.json();
193
+
194
+ // Store the new session_id for future resumption
195
+ if (this.config.setStoredSessionId) {
196
+ this.config.setStoredSessionId(data.session_id);
197
+ }
198
+
199
+ return {
200
+ sessionId: data.session_id,
201
+ expiresAt: new Date(data.expires_at),
202
+ flow: data.flow,
203
+ config: {
204
+ welcomeMessage: data.config.welcome_message,
205
+ placeholder: data.config.placeholder,
206
+ theme: data.config.theme,
207
+ },
208
+ };
209
+ }
210
+
211
+ /**
212
+ * Clear the current client session
213
+ */
214
+ public clearClientSession(): void {
215
+ this.clientSession = null;
216
+ this.sessionInitPromise = null;
217
+ }
218
+
219
+ /**
220
+ * Get the feedback API URL
221
+ */
222
+ private getFeedbackApiUrl(): string {
223
+ const baseUrl = this.config.apiUrl?.replace(/\/+$/, '').replace(/\/v1\/dispatch$/, '') || DEFAULT_CLIENT_API_BASE;
224
+ return `${baseUrl}/v1/client/feedback`;
225
+ }
226
+
227
+ /**
228
+ * Send feedback for a message (client token mode only).
229
+ * Supports upvote, downvote, copy, csat, and nps feedback types.
230
+ *
231
+ * @param feedback - The feedback request payload
232
+ * @returns Promise that resolves when feedback is sent successfully
233
+ * @throws Error if not in client token mode or if session is invalid
234
+ *
235
+ * @example
236
+ * ```typescript
237
+ * // Message feedback (upvote/downvote/copy)
238
+ * await client.sendFeedback({
239
+ * session_id: sessionId,
240
+ * message_id: messageId,
241
+ * type: 'upvote'
242
+ * });
243
+ *
244
+ * // CSAT feedback (1-5 rating)
245
+ * await client.sendFeedback({
246
+ * session_id: sessionId,
247
+ * type: 'csat',
248
+ * rating: 5,
249
+ * comment: 'Great experience!'
250
+ * });
251
+ *
252
+ * // NPS feedback (0-10 rating)
253
+ * await client.sendFeedback({
254
+ * session_id: sessionId,
255
+ * type: 'nps',
256
+ * rating: 9
257
+ * });
258
+ * ```
259
+ */
260
+ public async sendFeedback(feedback: ClientFeedbackRequest): Promise<void> {
261
+ if (!this.isClientTokenMode()) {
262
+ throw new Error('sendFeedback() only available in client token mode');
263
+ }
264
+
265
+ const session = this.getClientSession();
266
+ if (!session) {
267
+ throw new Error('No active session. Please initialize session first.');
268
+ }
269
+
270
+ // Validate message_id is provided for message-level feedback types
271
+ const messageFeedbackTypes: ClientFeedbackType[] = ['upvote', 'downvote', 'copy'];
272
+ if (messageFeedbackTypes.includes(feedback.type) && !feedback.message_id) {
273
+ throw new Error(`message_id is required for ${feedback.type} feedback type`);
274
+ }
275
+
276
+ // Validate rating is provided for csat/nps feedback types
277
+ if (feedback.type === 'csat') {
278
+ if (feedback.rating === undefined || feedback.rating < 1 || feedback.rating > 5) {
279
+ throw new Error('CSAT rating must be between 1 and 5');
280
+ }
281
+ }
282
+ if (feedback.type === 'nps') {
283
+ if (feedback.rating === undefined || feedback.rating < 0 || feedback.rating > 10) {
284
+ throw new Error('NPS rating must be between 0 and 10');
285
+ }
286
+ }
287
+
288
+ if (this.debug) {
289
+ // eslint-disable-next-line no-console
290
+ console.debug("[AgentWidgetClient] sending feedback", feedback);
291
+ }
292
+
293
+ const response = await fetch(this.getFeedbackApiUrl(), {
294
+ method: 'POST',
295
+ headers: {
296
+ 'Content-Type': 'application/json',
297
+ },
298
+ body: JSON.stringify(feedback),
299
+ });
300
+
301
+ if (!response.ok) {
302
+ const errorData = await response.json().catch(() => ({ error: 'Feedback submission failed' }));
303
+
304
+ if (response.status === 401) {
305
+ this.clientSession = null;
306
+ this.config.onSessionExpired?.();
307
+ throw new Error('Session expired. Please refresh to continue.');
308
+ }
309
+
310
+ throw new Error(errorData.error || 'Failed to submit feedback');
311
+ }
312
+ }
313
+
314
+ /**
315
+ * Submit message feedback (upvote, downvote, or copy).
316
+ * Convenience method for sendFeedback with message-level feedback.
317
+ *
318
+ * @param messageId - The ID of the message to provide feedback for
319
+ * @param type - The feedback type: 'upvote', 'downvote', or 'copy'
320
+ */
321
+ public async submitMessageFeedback(
322
+ messageId: string,
323
+ type: 'upvote' | 'downvote' | 'copy'
324
+ ): Promise<void> {
325
+ const session = this.getClientSession();
326
+ if (!session) {
327
+ throw new Error('No active session. Please initialize session first.');
328
+ }
329
+
330
+ return this.sendFeedback({
331
+ session_id: session.sessionId,
332
+ message_id: messageId,
333
+ type,
334
+ });
335
+ }
336
+
337
+ /**
338
+ * Submit CSAT (Customer Satisfaction) feedback.
339
+ * Convenience method for sendFeedback with CSAT feedback.
340
+ *
341
+ * @param rating - Rating from 1 to 5
342
+ * @param comment - Optional comment
343
+ */
344
+ public async submitCSATFeedback(rating: number, comment?: string): Promise<void> {
345
+ const session = this.getClientSession();
346
+ if (!session) {
347
+ throw new Error('No active session. Please initialize session first.');
348
+ }
349
+
350
+ return this.sendFeedback({
351
+ session_id: session.sessionId,
352
+ type: 'csat',
353
+ rating,
354
+ comment,
355
+ });
356
+ }
357
+
358
+ /**
359
+ * Submit NPS (Net Promoter Score) feedback.
360
+ * Convenience method for sendFeedback with NPS feedback.
361
+ *
362
+ * @param rating - Rating from 0 to 10
363
+ * @param comment - Optional comment
364
+ */
365
+ public async submitNPSFeedback(rating: number, comment?: string): Promise<void> {
366
+ const session = this.getClientSession();
367
+ if (!session) {
368
+ throw new Error('No active session. Please initialize session first.');
369
+ }
370
+
371
+ return this.sendFeedback({
372
+ session_id: session.sessionId,
373
+ type: 'nps',
374
+ rating,
375
+ comment,
376
+ });
377
+ }
378
+
379
+ /**
380
+ * Send a message - handles both proxy and client token modes
381
+ */
382
+ public async dispatch(options: DispatchOptions, onEvent: SSEHandler) {
383
+ if (this.isClientTokenMode()) {
384
+ return this.dispatchClientToken(options, onEvent);
385
+ }
386
+ return this.dispatchProxy(options, onEvent);
387
+ }
388
+
389
+ /**
390
+ * Client token mode dispatch
391
+ */
392
+ private async dispatchClientToken(options: DispatchOptions, onEvent: SSEHandler) {
393
+ const controller = new AbortController();
394
+ if (options.signal) {
395
+ options.signal.addEventListener("abort", () => controller.abort());
396
+ }
397
+
398
+ onEvent({ type: "status", status: "connecting" });
399
+
400
+ try {
401
+ // Ensure session is initialized
402
+ const session = await this.initSession();
403
+
404
+ // Check if session is about to expire (within 1 minute)
405
+ if (new Date() >= new Date(session.expiresAt.getTime() - 60000)) {
406
+ // Session expired or expiring soon
407
+ this.clientSession = null;
408
+ this.config.onSessionExpired?.();
409
+ const error = new Error('Session expired. Please refresh to continue.');
410
+ onEvent({ type: "error", error });
411
+ throw error;
412
+ }
413
+
414
+ // Build the standard payload to get context/metadata from middleware
415
+ const basePayload = await this.buildPayload(options.messages);
416
+
417
+ // Build the chat request payload with message IDs for feedback tracking
418
+ // Filter out session_id from metadata if present (it's only for local storage)
419
+ const sanitizedMetadata = basePayload.metadata
420
+ ? Object.fromEntries(
421
+ Object.entries(basePayload.metadata).filter(([key]) => key !== 'session_id')
422
+ )
423
+ : undefined;
424
+
425
+ const chatRequest: ClientChatRequest = {
426
+ session_id: session.sessionId,
427
+ // Filter out messages with empty content to prevent validation errors
428
+ messages: options.messages.filter(hasValidContent).map(m => ({
429
+ id: m.id, // Include message ID for tracking
430
+ role: m.role,
431
+ // Use contentParts for multi-modal messages, otherwise fall back to string content
432
+ content: m.contentParts ?? m.rawContent ?? m.content,
433
+ })),
434
+ // Include pre-generated assistant message ID if provided
435
+ ...(options.assistantMessageId && { assistant_message_id: options.assistantMessageId }),
436
+ // Include metadata/context from middleware if present (excluding session_id)
437
+ ...(sanitizedMetadata && Object.keys(sanitizedMetadata).length > 0 && { metadata: sanitizedMetadata }),
438
+ ...(basePayload.context && { context: basePayload.context }),
439
+ };
440
+
441
+ if (this.debug) {
442
+ // eslint-disable-next-line no-console
443
+ console.debug("[AgentWidgetClient] client token dispatch", chatRequest);
444
+ }
445
+
446
+ const response = await fetch(this.getClientApiUrl('chat'), {
447
+ method: 'POST',
448
+ headers: {
449
+ 'Content-Type': 'application/json',
450
+ },
451
+ body: JSON.stringify(chatRequest),
452
+ signal: controller.signal,
453
+ });
454
+
455
+ if (!response.ok) {
456
+ const errorData = await response.json().catch(() => ({ error: 'Chat request failed' }));
457
+
458
+ if (response.status === 401) {
459
+ // Session expired
460
+ this.clientSession = null;
461
+ this.config.onSessionExpired?.();
462
+ const error = new Error('Session expired. Please refresh to continue.');
463
+ onEvent({ type: "error", error });
464
+ throw error;
465
+ }
466
+
467
+ if (response.status === 429) {
468
+ const error = new Error(errorData.hint || 'Message limit reached for this session.');
469
+ onEvent({ type: "error", error });
470
+ throw error;
471
+ }
472
+
473
+ const error = new Error(errorData.error || 'Failed to send message');
474
+ onEvent({ type: "error", error });
475
+ throw error;
476
+ }
477
+
478
+ if (!response.body) {
479
+ const error = new Error('No response body received');
480
+ onEvent({ type: "error", error });
481
+ throw error;
482
+ }
483
+
484
+ onEvent({ type: "status", status: "connected" });
485
+
486
+ // Stream the response (same SSE handling as proxy mode)
487
+ try {
488
+ await this.streamResponse(response.body, onEvent, options.assistantMessageId);
489
+ } finally {
490
+ onEvent({ type: "status", status: "idle" });
491
+ }
492
+ } catch (error) {
493
+ const err = error instanceof Error ? error : new Error(String(error));
494
+ // Only emit error if it wasn't already emitted
495
+ if (!err.message.includes('Session expired') && !err.message.includes('Message limit')) {
496
+ onEvent({ type: "error", error: err });
497
+ }
498
+ throw err;
499
+ }
500
+ }
501
+
502
+ /**
503
+ * Proxy mode dispatch (original implementation)
504
+ */
505
+ private async dispatchProxy(options: DispatchOptions, onEvent: SSEHandler) {
506
+ const controller = new AbortController();
507
+ if (options.signal) {
508
+ options.signal.addEventListener("abort", () => controller.abort());
509
+ }
510
+
511
+ onEvent({ type: "status", status: "connecting" });
512
+
513
+ const payload = await this.buildPayload(options.messages);
514
+
515
+ if (this.debug) {
516
+ // eslint-disable-next-line no-console
517
+ console.debug("[AgentWidgetClient] dispatch payload", payload);
518
+ }
519
+
520
+ // Build headers - merge static headers with dynamic headers if provided
521
+ let headers = { ...this.headers };
522
+ if (this.getHeaders) {
523
+ try {
524
+ const dynamicHeaders = await this.getHeaders();
525
+ headers = { ...headers, ...dynamicHeaders };
526
+ } catch (error) {
527
+ if (typeof console !== "undefined") {
528
+ // eslint-disable-next-line no-console
529
+ console.error("[AgentWidget] getHeaders error:", error);
530
+ }
531
+ }
532
+ }
533
+
534
+ // Use customFetch if provided, otherwise use default fetch
535
+ let response: Response;
536
+ if (this.customFetch) {
537
+ try {
538
+ response = await this.customFetch(
539
+ this.apiUrl,
540
+ {
541
+ method: "POST",
542
+ headers,
543
+ body: JSON.stringify(payload),
544
+ signal: controller.signal
545
+ },
546
+ payload
547
+ );
548
+ } catch (error) {
549
+ const err = error instanceof Error ? error : new Error(String(error));
550
+ onEvent({ type: "error", error: err });
551
+ throw err;
552
+ }
553
+ } else {
554
+ response = await fetch(this.apiUrl, {
555
+ method: "POST",
556
+ headers,
557
+ body: JSON.stringify(payload),
558
+ signal: controller.signal
559
+ });
560
+ }
561
+
562
+ if (!response.ok || !response.body) {
563
+ const error = new Error(
564
+ `Chat backend request failed: ${response.status} ${response.statusText}`
565
+ );
566
+ onEvent({ type: "error", error });
567
+ throw error;
568
+ }
569
+
570
+ onEvent({ type: "status", status: "connected" });
571
+ try {
572
+ await this.streamResponse(response.body, onEvent);
573
+ } finally {
574
+ onEvent({ type: "status", status: "idle" });
575
+ }
576
+ }
577
+
578
+ private async buildPayload(
579
+ messages: AgentWidgetMessage[]
580
+ ): Promise<AgentWidgetRequestPayload> {
581
+ // Filter out messages with empty content to prevent validation errors
582
+ const normalizedMessages = messages
583
+ .slice()
584
+ .filter(hasValidContent)
585
+ .sort((a, b) => {
586
+ const timeA = new Date(a.createdAt).getTime();
587
+ const timeB = new Date(b.createdAt).getTime();
588
+ return timeA - timeB;
589
+ })
590
+ .map((message) => ({
591
+ role: message.role,
592
+ // Use contentParts for multi-modal messages, otherwise fall back to string content
593
+ content: message.contentParts ?? message.rawContent ?? message.content,
594
+ createdAt: message.createdAt
595
+ }));
596
+
597
+ const payload: AgentWidgetRequestPayload = {
598
+ messages: normalizedMessages,
599
+ ...(this.config.flowId && { flowId: this.config.flowId })
600
+ };
601
+
602
+ if (this.contextProviders.length) {
603
+ const contextAggregate: Record<string, unknown> = {};
604
+ await Promise.all(
605
+ this.contextProviders.map(async (provider) => {
606
+ try {
607
+ const result = await provider({
608
+ messages,
609
+ config: this.config
610
+ });
611
+ if (result && typeof result === "object") {
612
+ Object.assign(contextAggregate, result);
613
+ }
614
+ } catch (error) {
615
+ if (typeof console !== "undefined") {
616
+ // eslint-disable-next-line no-console
617
+ console.warn("[AgentWidget] Context provider failed:", error);
618
+ }
619
+ }
620
+ })
621
+ );
622
+
623
+ if (Object.keys(contextAggregate).length) {
624
+ payload.context = contextAggregate;
625
+ }
626
+ }
627
+
628
+ if (this.requestMiddleware) {
629
+ try {
630
+ const result = await this.requestMiddleware({
631
+ payload: { ...payload },
632
+ config: this.config
633
+ });
634
+ if (result && typeof result === "object") {
635
+ return result as AgentWidgetRequestPayload;
636
+ }
637
+ } catch (error) {
638
+ if (typeof console !== "undefined") {
639
+ // eslint-disable-next-line no-console
640
+ console.error("[AgentWidget] Request middleware error:", error);
641
+ }
642
+ }
643
+ }
644
+
645
+ return payload;
646
+ }
647
+
648
+ /**
649
+ * Handle custom SSE event parsing via parseSSEEvent callback
650
+ * Returns true if event was handled, false otherwise
651
+ */
652
+ private async handleCustomSSEEvent(
653
+ payload: unknown,
654
+ onEvent: SSEHandler,
655
+ assistantMessageRef: { current: AgentWidgetMessage | null },
656
+ emitMessage: (msg: AgentWidgetMessage) => void,
657
+ nextSequence: () => number
658
+ ): Promise<boolean> {
659
+ if (!this.parseSSEEvent) return false;
660
+
661
+ try {
662
+ const result = await this.parseSSEEvent(payload);
663
+ if (result === null) return false; // Event should be ignored
664
+
665
+ const ensureAssistant = () => {
666
+ if (assistantMessageRef.current) return assistantMessageRef.current;
667
+ const msg: AgentWidgetMessage = {
668
+ id: `assistant-${Date.now()}-${Math.random().toString(16).slice(2)}`,
669
+ role: "assistant",
670
+ content: "",
671
+ createdAt: new Date().toISOString(),
672
+ streaming: true,
673
+ variant: "assistant",
674
+ sequence: nextSequence()
675
+ };
676
+ assistantMessageRef.current = msg;
677
+ emitMessage(msg);
678
+ return msg;
679
+ };
680
+
681
+ if (result.text !== undefined) {
682
+ const assistant = ensureAssistant();
683
+ assistant.content += result.text;
684
+ emitMessage(assistant);
685
+ }
686
+
687
+ if (result.done) {
688
+ if (assistantMessageRef.current) {
689
+ assistantMessageRef.current.streaming = false;
690
+ emitMessage(assistantMessageRef.current);
691
+ }
692
+ onEvent({ type: "status", status: "idle" });
693
+ }
694
+
695
+ if (result.error) {
696
+ onEvent({
697
+ type: "error",
698
+ error: new Error(result.error)
699
+ });
700
+ }
701
+
702
+ return true; // Event was handled
703
+ } catch (error) {
704
+ if (typeof console !== "undefined") {
705
+ // eslint-disable-next-line no-console
706
+ console.error("[AgentWidget] parseSSEEvent error:", error);
707
+ }
708
+ return false;
709
+ }
710
+ }
711
+
712
+ private async streamResponse(
713
+ body: ReadableStream<Uint8Array>,
714
+ onEvent: SSEHandler,
715
+ assistantMessageId?: string
716
+ ) {
717
+ const reader = body.getReader();
718
+ const decoder = new TextDecoder();
719
+ let buffer = "";
720
+
721
+ const baseSequence = Date.now();
722
+ let sequenceCounter = 0;
723
+ const nextSequence = () => baseSequence + sequenceCounter++;
724
+
725
+ const cloneMessage = (msg: AgentWidgetMessage): AgentWidgetMessage => {
726
+ const reasoning = msg.reasoning
727
+ ? {
728
+ ...msg.reasoning,
729
+ chunks: [...msg.reasoning.chunks]
730
+ }
731
+ : undefined;
732
+ const toolCall = msg.toolCall
733
+ ? {
734
+ ...msg.toolCall,
735
+ chunks: msg.toolCall.chunks ? [...msg.toolCall.chunks] : undefined
736
+ }
737
+ : undefined;
738
+ const tools = msg.tools
739
+ ? msg.tools.map((tool) => ({
740
+ ...tool,
741
+ chunks: tool.chunks ? [...tool.chunks] : undefined
742
+ }))
743
+ : undefined;
744
+
745
+ return {
746
+ ...msg,
747
+ reasoning,
748
+ toolCall,
749
+ tools
750
+ };
751
+ };
752
+
753
+ const emitMessage = (msg: AgentWidgetMessage) => {
754
+ onEvent({
755
+ type: "message",
756
+ message: cloneMessage(msg)
757
+ });
758
+ };
759
+
760
+ let assistantMessage: AgentWidgetMessage | null = null;
761
+ // Reference to track assistant message for custom event handler
762
+ const assistantMessageRef = { current: null as AgentWidgetMessage | null };
763
+ const reasoningMessages = new Map<string, AgentWidgetMessage>();
764
+ const toolMessages = new Map<string, AgentWidgetMessage>();
765
+ const reasoningContext = {
766
+ lastId: null as string | null,
767
+ byStep: new Map<string, string>()
768
+ };
769
+ const toolContext = {
770
+ lastId: null as string | null,
771
+ byCall: new Map<string, string>()
772
+ };
773
+
774
+ const normalizeKey = (value: unknown): string | null => {
775
+ if (value === null || value === undefined) return null;
776
+ try {
777
+ return String(value);
778
+ } catch (error) {
779
+ return null;
780
+ }
781
+ };
782
+
783
+ const getStepKey = (payload: Record<string, any>) =>
784
+ normalizeKey(
785
+ payload.stepId ??
786
+ payload.step_id ??
787
+ payload.step ??
788
+ payload.parentId ??
789
+ payload.flowStepId ??
790
+ payload.flow_step_id
791
+ );
792
+
793
+ const getToolCallKey = (payload: Record<string, any>) =>
794
+ normalizeKey(
795
+ payload.callId ??
796
+ payload.call_id ??
797
+ payload.requestId ??
798
+ payload.request_id ??
799
+ payload.toolCallId ??
800
+ payload.tool_call_id ??
801
+ payload.stepId ??
802
+ payload.step_id
803
+ );
804
+
805
+ const ensureAssistantMessage = () => {
806
+ if (assistantMessage) return assistantMessage;
807
+ assistantMessage = {
808
+ // Use pre-generated ID if provided, otherwise generate one
809
+ id: assistantMessageId ?? `assistant-${Date.now()}-${Math.random().toString(16).slice(2)}`,
810
+ role: "assistant",
811
+ content: "",
812
+ createdAt: new Date().toISOString(),
813
+ streaming: true,
814
+ sequence: nextSequence()
815
+ };
816
+ emitMessage(assistantMessage);
817
+ return assistantMessage;
818
+ };
819
+
820
+ const trackReasoningId = (stepKey: string | null, id: string) => {
821
+ reasoningContext.lastId = id;
822
+ if (stepKey) {
823
+ reasoningContext.byStep.set(stepKey, id);
824
+ }
825
+ };
826
+
827
+ const resolveReasoningId = (
828
+ payload: Record<string, any>,
829
+ allowCreate: boolean
830
+ ): string | null => {
831
+ const rawId = payload.reasoningId ?? payload.id;
832
+ const stepKey = getStepKey(payload);
833
+ if (rawId) {
834
+ const resolved = String(rawId);
835
+ trackReasoningId(stepKey, resolved);
836
+ return resolved;
837
+ }
838
+ if (stepKey) {
839
+ const existing = reasoningContext.byStep.get(stepKey);
840
+ if (existing) {
841
+ reasoningContext.lastId = existing;
842
+ return existing;
843
+ }
844
+ }
845
+ if (reasoningContext.lastId && !allowCreate) {
846
+ return reasoningContext.lastId;
847
+ }
848
+ if (!allowCreate) {
849
+ return null;
850
+ }
851
+ const generated = `reason-${nextSequence()}`;
852
+ trackReasoningId(stepKey, generated);
853
+ return generated;
854
+ };
855
+
856
+ const ensureReasoningMessage = (reasoningId: string) => {
857
+ const existing = reasoningMessages.get(reasoningId);
858
+ if (existing) {
859
+ return existing;
860
+ }
861
+
862
+ const message: AgentWidgetMessage = {
863
+ id: `reason-${reasoningId}`,
864
+ role: "assistant",
865
+ content: "",
866
+ createdAt: new Date().toISOString(),
867
+ streaming: true,
868
+ variant: "reasoning",
869
+ sequence: nextSequence(),
870
+ reasoning: {
871
+ id: reasoningId,
872
+ status: "streaming",
873
+ chunks: []
874
+ }
875
+ };
876
+
877
+ reasoningMessages.set(reasoningId, message);
878
+ emitMessage(message);
879
+ return message;
880
+ };
881
+
882
+ const trackToolId = (callKey: string | null, id: string) => {
883
+ toolContext.lastId = id;
884
+ if (callKey) {
885
+ toolContext.byCall.set(callKey, id);
886
+ }
887
+ };
888
+
889
+ const resolveToolId = (
890
+ payload: Record<string, any>,
891
+ allowCreate: boolean
892
+ ): string | null => {
893
+ const rawId = payload.toolId ?? payload.id;
894
+ const callKey = getToolCallKey(payload);
895
+ if (rawId) {
896
+ const resolved = String(rawId);
897
+ trackToolId(callKey, resolved);
898
+ return resolved;
899
+ }
900
+ if (callKey) {
901
+ const existing = toolContext.byCall.get(callKey);
902
+ if (existing) {
903
+ toolContext.lastId = existing;
904
+ return existing;
905
+ }
906
+ }
907
+ if (toolContext.lastId && !allowCreate) {
908
+ return toolContext.lastId;
909
+ }
910
+ if (!allowCreate) {
911
+ return null;
912
+ }
913
+ const generated = `tool-${nextSequence()}`;
914
+ trackToolId(callKey, generated);
915
+ return generated;
916
+ };
917
+
918
+ const ensureToolMessage = (toolId: string) => {
919
+ const existing = toolMessages.get(toolId);
920
+ if (existing) {
921
+ return existing;
922
+ }
923
+
924
+ const message: AgentWidgetMessage = {
925
+ id: `tool-${toolId}`,
926
+ role: "assistant",
927
+ content: "",
928
+ createdAt: new Date().toISOString(),
929
+ streaming: true,
930
+ variant: "tool",
931
+ sequence: nextSequence(),
932
+ toolCall: {
933
+ id: toolId,
934
+ status: "pending"
935
+ }
936
+ };
937
+
938
+ toolMessages.set(toolId, message);
939
+ emitMessage(message);
940
+ return message;
941
+ };
942
+
943
+ const resolveTimestamp = (value: unknown) => {
944
+ if (typeof value === "number" && Number.isFinite(value)) {
945
+ return value;
946
+ }
947
+ if (typeof value === "string") {
948
+ const parsed = Number(value);
949
+ if (!Number.isNaN(parsed) && Number.isFinite(parsed)) {
950
+ return parsed;
951
+ }
952
+ const dateParsed = Date.parse(value);
953
+ if (!Number.isNaN(dateParsed)) {
954
+ return dateParsed;
955
+ }
956
+ }
957
+ return Date.now();
958
+ };
959
+
960
+ const ensureStringContent = (value: unknown): string => {
961
+ if (typeof value === "string") {
962
+ return value;
963
+ }
964
+ if (value === null || value === undefined) {
965
+ return "";
966
+ }
967
+ // Convert objects/arrays to JSON string
968
+ try {
969
+ return JSON.stringify(value);
970
+ } catch {
971
+ return String(value);
972
+ }
973
+ };
974
+
975
+ // Maintain stateful stream parsers per message for incremental parsing
976
+ const streamParsers = new Map<string, AgentWidgetStreamParser>();
977
+ // Track accumulated raw content for structured formats (JSON, XML, etc.)
978
+ const rawContentBuffers = new Map<string, string>();
979
+
980
+ while (true) {
981
+ const { done, value } = await reader.read();
982
+ if (done) break;
983
+
984
+ buffer += decoder.decode(value, { stream: true });
985
+ const events = buffer.split("\n\n");
986
+ buffer = events.pop() ?? "";
987
+
988
+ for (const event of events) {
989
+ const lines = event.split("\n");
990
+ let eventType = "message";
991
+ let data = "";
992
+
993
+ for (const line of lines) {
994
+ if (line.startsWith("event:")) {
995
+ eventType = line.replace("event:", "").trim();
996
+ } else if (line.startsWith("data:")) {
997
+ data += line.replace("data:", "").trim();
998
+ }
999
+ }
1000
+
1001
+ if (!data) continue;
1002
+ let payload: any;
1003
+ try {
1004
+ payload = JSON.parse(data);
1005
+ } catch (error) {
1006
+ onEvent({
1007
+ type: "error",
1008
+ error:
1009
+ error instanceof Error
1010
+ ? error
1011
+ : new Error("Failed to parse chat stream payload")
1012
+ });
1013
+ continue;
1014
+ }
1015
+
1016
+ const payloadType =
1017
+ eventType !== "message" ? eventType : payload.type ?? "message";
1018
+
1019
+ // If custom SSE event parser is provided, try it first
1020
+ if (this.parseSSEEvent) {
1021
+ // Keep assistant message ref in sync
1022
+ assistantMessageRef.current = assistantMessage;
1023
+ const handled = await this.handleCustomSSEEvent(
1024
+ payload,
1025
+ onEvent,
1026
+ assistantMessageRef,
1027
+ emitMessage,
1028
+ nextSequence
1029
+ );
1030
+ // Update assistantMessage from ref (in case it was created)
1031
+ if (assistantMessageRef.current && !assistantMessage) {
1032
+ assistantMessage = assistantMessageRef.current;
1033
+ }
1034
+ if (handled) continue; // Skip default handling if custom handler processed it
1035
+ }
1036
+
1037
+ if (payloadType === "reason_start") {
1038
+ const reasoningId =
1039
+ resolveReasoningId(payload, true) ?? `reason-${nextSequence()}`;
1040
+ const reasoningMessage = ensureReasoningMessage(reasoningId);
1041
+ reasoningMessage.reasoning = reasoningMessage.reasoning ?? {
1042
+ id: reasoningId,
1043
+ status: "streaming",
1044
+ chunks: []
1045
+ };
1046
+ reasoningMessage.reasoning.startedAt =
1047
+ reasoningMessage.reasoning.startedAt ??
1048
+ resolveTimestamp(payload.startedAt ?? payload.timestamp);
1049
+ reasoningMessage.reasoning.completedAt = undefined;
1050
+ reasoningMessage.reasoning.durationMs = undefined;
1051
+ reasoningMessage.streaming = true;
1052
+ reasoningMessage.reasoning.status = "streaming";
1053
+ emitMessage(reasoningMessage);
1054
+ } else if (payloadType === "reason_chunk") {
1055
+ const reasoningId =
1056
+ resolveReasoningId(payload, false) ??
1057
+ resolveReasoningId(payload, true) ??
1058
+ `reason-${nextSequence()}`;
1059
+ const reasoningMessage = ensureReasoningMessage(reasoningId);
1060
+ reasoningMessage.reasoning = reasoningMessage.reasoning ?? {
1061
+ id: reasoningId,
1062
+ status: "streaming",
1063
+ chunks: []
1064
+ };
1065
+ reasoningMessage.reasoning.startedAt =
1066
+ reasoningMessage.reasoning.startedAt ??
1067
+ resolveTimestamp(payload.startedAt ?? payload.timestamp);
1068
+ const chunk =
1069
+ payload.reasoningText ??
1070
+ payload.text ??
1071
+ payload.delta ??
1072
+ "";
1073
+ if (chunk && payload.hidden !== true) {
1074
+ reasoningMessage.reasoning.chunks.push(String(chunk));
1075
+ }
1076
+ reasoningMessage.reasoning.status = payload.done ? "complete" : "streaming";
1077
+ if (payload.done) {
1078
+ reasoningMessage.reasoning.completedAt = resolveTimestamp(
1079
+ payload.completedAt ?? payload.timestamp
1080
+ );
1081
+ const start = reasoningMessage.reasoning.startedAt ?? Date.now();
1082
+ reasoningMessage.reasoning.durationMs = Math.max(
1083
+ 0,
1084
+ (reasoningMessage.reasoning.completedAt ?? Date.now()) - start
1085
+ );
1086
+ }
1087
+ reasoningMessage.streaming = reasoningMessage.reasoning.status !== "complete";
1088
+ emitMessage(reasoningMessage);
1089
+ } else if (payloadType === "reason_complete") {
1090
+ const reasoningId =
1091
+ resolveReasoningId(payload, false) ??
1092
+ resolveReasoningId(payload, true) ??
1093
+ `reason-${nextSequence()}`;
1094
+ const reasoningMessage = reasoningMessages.get(reasoningId);
1095
+ if (reasoningMessage?.reasoning) {
1096
+ reasoningMessage.reasoning.status = "complete";
1097
+ reasoningMessage.reasoning.completedAt = resolveTimestamp(
1098
+ payload.completedAt ?? payload.timestamp
1099
+ );
1100
+ const start = reasoningMessage.reasoning.startedAt ?? Date.now();
1101
+ reasoningMessage.reasoning.durationMs = Math.max(
1102
+ 0,
1103
+ (reasoningMessage.reasoning.completedAt ?? Date.now()) - start
1104
+ );
1105
+ reasoningMessage.streaming = false;
1106
+ emitMessage(reasoningMessage);
1107
+ }
1108
+ const stepKey = getStepKey(payload);
1109
+ if (stepKey) {
1110
+ reasoningContext.byStep.delete(stepKey);
1111
+ }
1112
+ } else if (payloadType === "tool_start") {
1113
+ const toolId =
1114
+ resolveToolId(payload, true) ?? `tool-${nextSequence()}`;
1115
+ const toolMessage = ensureToolMessage(toolId);
1116
+ const tool = toolMessage.toolCall ?? {
1117
+ id: toolId,
1118
+ status: "pending"
1119
+ };
1120
+ tool.name = payload.toolName ?? tool.name;
1121
+ tool.status = "running";
1122
+ if (payload.args !== undefined) {
1123
+ tool.args = payload.args;
1124
+ }
1125
+ tool.startedAt =
1126
+ tool.startedAt ??
1127
+ resolveTimestamp(payload.startedAt ?? payload.timestamp);
1128
+ tool.completedAt = undefined;
1129
+ tool.durationMs = undefined;
1130
+ toolMessage.toolCall = tool;
1131
+ toolMessage.streaming = true;
1132
+ emitMessage(toolMessage);
1133
+ } else if (payloadType === "tool_chunk") {
1134
+ const toolId =
1135
+ resolveToolId(payload, false) ??
1136
+ resolveToolId(payload, true) ??
1137
+ `tool-${nextSequence()}`;
1138
+ const toolMessage = ensureToolMessage(toolId);
1139
+ const tool = toolMessage.toolCall ?? {
1140
+ id: toolId,
1141
+ status: "running"
1142
+ };
1143
+ tool.startedAt =
1144
+ tool.startedAt ??
1145
+ resolveTimestamp(payload.startedAt ?? payload.timestamp);
1146
+ const chunkText =
1147
+ payload.text ?? payload.delta ?? payload.message ?? "";
1148
+ if (chunkText) {
1149
+ tool.chunks = tool.chunks ?? [];
1150
+ tool.chunks.push(String(chunkText));
1151
+ }
1152
+ tool.status = "running";
1153
+ toolMessage.toolCall = tool;
1154
+ toolMessage.streaming = true;
1155
+ emitMessage(toolMessage);
1156
+ } else if (payloadType === "tool_complete") {
1157
+ const toolId =
1158
+ resolveToolId(payload, false) ??
1159
+ resolveToolId(payload, true) ??
1160
+ `tool-${nextSequence()}`;
1161
+ const toolMessage = ensureToolMessage(toolId);
1162
+ const tool = toolMessage.toolCall ?? {
1163
+ id: toolId,
1164
+ status: "running"
1165
+ };
1166
+ tool.status = "complete";
1167
+ if (payload.result !== undefined) {
1168
+ tool.result = payload.result;
1169
+ }
1170
+ if (typeof payload.duration === "number") {
1171
+ tool.duration = payload.duration;
1172
+ }
1173
+ tool.completedAt = resolveTimestamp(
1174
+ payload.completedAt ?? payload.timestamp
1175
+ );
1176
+ if (typeof payload.duration === "number") {
1177
+ tool.durationMs = payload.duration;
1178
+ } else {
1179
+ const start = tool.startedAt ?? Date.now();
1180
+ tool.durationMs = Math.max(
1181
+ 0,
1182
+ (tool.completedAt ?? Date.now()) - start
1183
+ );
1184
+ }
1185
+ toolMessage.toolCall = tool;
1186
+ toolMessage.streaming = false;
1187
+ emitMessage(toolMessage);
1188
+ const callKey = getToolCallKey(payload);
1189
+ if (callKey) {
1190
+ toolContext.byCall.delete(callKey);
1191
+ }
1192
+ } else if (payloadType === "step_chunk") {
1193
+ // Only process chunks for prompt steps, not tool/context steps
1194
+ const stepType = (payload as any).stepType;
1195
+ const executionType = (payload as any).executionType;
1196
+ if (stepType === "tool" || executionType === "context") {
1197
+ // Skip tool-related chunks - they're handled by tool_start/tool_complete
1198
+ continue;
1199
+ }
1200
+ const assistant = ensureAssistantMessage();
1201
+ // Support various field names: text, delta, content, chunk (Travrse uses 'chunk')
1202
+ const chunk = payload.text ?? payload.delta ?? payload.content ?? payload.chunk ?? "";
1203
+ if (chunk) {
1204
+ // Accumulate raw content for structured format parsing
1205
+ const rawBuffer = rawContentBuffers.get(assistant.id) ?? "";
1206
+ const accumulatedRaw = rawBuffer + chunk;
1207
+ // Store raw content for action parsing, but NEVER set assistant.content to raw JSON
1208
+ assistant.rawContent = accumulatedRaw;
1209
+
1210
+ // Use stream parser to parse
1211
+ if (!streamParsers.has(assistant.id)) {
1212
+ streamParsers.set(assistant.id, this.createStreamParser());
1213
+ }
1214
+ const parser = streamParsers.get(assistant.id)!;
1215
+
1216
+ // Check if content looks like JSON
1217
+ const looksLikeJson = accumulatedRaw.trim().startsWith('{') || accumulatedRaw.trim().startsWith('[');
1218
+
1219
+ // Store raw buffer before processing (needed for step_complete handler)
1220
+ if (looksLikeJson) {
1221
+ rawContentBuffers.set(assistant.id, accumulatedRaw);
1222
+ }
1223
+
1224
+ // Check if this is a plain text parser (marked with __isPlainTextParser)
1225
+ const isPlainTextParser = (parser as any).__isPlainTextParser === true;
1226
+
1227
+ // If plain text parser, just append the chunk directly
1228
+ if (isPlainTextParser) {
1229
+ assistant.content += chunk;
1230
+ // Clear any raw buffer/parser since we're in plain text mode
1231
+ rawContentBuffers.delete(assistant.id);
1232
+ streamParsers.delete(assistant.id);
1233
+ assistant.rawContent = undefined;
1234
+ emitMessage(assistant);
1235
+ continue;
1236
+ }
1237
+
1238
+ // Try to parse with the parser (for structured parsers)
1239
+ const parsedResult = parser.processChunk(accumulatedRaw);
1240
+
1241
+ // Handle async parser result
1242
+ if (parsedResult instanceof Promise) {
1243
+ parsedResult.then((result) => {
1244
+ // Extract text from result (could be string or object)
1245
+ const text = typeof result === 'string' ? result : result?.text ?? null;
1246
+
1247
+ if (text !== null && text.trim() !== "") {
1248
+ // Parser successfully extracted text
1249
+ // Update the message content with extracted text
1250
+ const currentAssistant = assistantMessage;
1251
+ if (currentAssistant && currentAssistant.id === assistant.id) {
1252
+ currentAssistant.content = text;
1253
+ emitMessage(currentAssistant);
1254
+ }
1255
+ } else if (!looksLikeJson && !accumulatedRaw.trim().startsWith('<')) {
1256
+ // Not a structured format - show as plain text
1257
+ const currentAssistant = assistantMessage;
1258
+ if (currentAssistant && currentAssistant.id === assistant.id) {
1259
+ currentAssistant.content += chunk;
1260
+ rawContentBuffers.delete(currentAssistant.id);
1261
+ streamParsers.delete(currentAssistant.id);
1262
+ currentAssistant.rawContent = undefined;
1263
+ emitMessage(currentAssistant);
1264
+ }
1265
+ }
1266
+ // Otherwise wait for more chunks (incomplete structured format)
1267
+ // Don't emit message if parser hasn't extracted text yet
1268
+ }).catch(() => {
1269
+ // On error, treat as plain text
1270
+ assistant.content += chunk;
1271
+ rawContentBuffers.delete(assistant.id);
1272
+ streamParsers.delete(assistant.id);
1273
+ assistant.rawContent = undefined;
1274
+ emitMessage(assistant);
1275
+ });
1276
+ } else {
1277
+ // Synchronous parser result
1278
+ // Extract text from result (could be string, null, or object)
1279
+ const text = typeof parsedResult === 'string' ? parsedResult : parsedResult?.text ?? null;
1280
+
1281
+ if (text !== null && text.trim() !== "") {
1282
+ // Parser successfully extracted text
1283
+ // Buffer is already set above
1284
+ assistant.content = text;
1285
+ emitMessage(assistant);
1286
+ } else if (!looksLikeJson && !accumulatedRaw.trim().startsWith('<')) {
1287
+ // Not a structured format - show as plain text
1288
+ assistant.content += chunk;
1289
+ // Clear any raw buffer/parser if we were in structured format mode
1290
+ rawContentBuffers.delete(assistant.id);
1291
+ streamParsers.delete(assistant.id);
1292
+ assistant.rawContent = undefined;
1293
+ emitMessage(assistant);
1294
+ }
1295
+ // Otherwise wait for more chunks (incomplete structured format)
1296
+ // Don't emit message if parser hasn't extracted text yet
1297
+ }
1298
+
1299
+ // IMPORTANT: Don't call getExtractedText() and emit messages here
1300
+ // This was causing raw JSON to be displayed because getExtractedText()
1301
+ // wasn't extracting the "text" field correctly during streaming
1302
+ }
1303
+ if (payload.isComplete) {
1304
+ const finalContent = payload.result?.response ?? assistant.content;
1305
+ if (finalContent) {
1306
+ // Check if we have raw content buffer that needs final processing
1307
+ const rawBuffer = rawContentBuffers.get(assistant.id);
1308
+ const contentToProcess = rawBuffer ?? ensureStringContent(finalContent);
1309
+ assistant.rawContent = contentToProcess;
1310
+
1311
+ // Try to extract text from final structured content
1312
+ const parser = streamParsers.get(assistant.id);
1313
+ let extractedText: string | null = null;
1314
+ let asyncPending = false;
1315
+
1316
+ if (parser) {
1317
+ // First check if parser already has extracted text
1318
+ extractedText = parser.getExtractedText();
1319
+
1320
+ if (extractedText === null) {
1321
+ // Try extracting with regex
1322
+ extractedText = extractTextFromJson(contentToProcess);
1323
+ }
1324
+
1325
+ if (extractedText === null) {
1326
+ // Try parser.processChunk as last resort
1327
+ const parsedResult = parser.processChunk(contentToProcess);
1328
+ if (parsedResult instanceof Promise) {
1329
+ asyncPending = true;
1330
+ parsedResult.then((result) => {
1331
+ // Extract text from result (could be string or object)
1332
+ const text = typeof result === 'string' ? result : result?.text ?? null;
1333
+ if (text !== null) {
1334
+ const currentAssistant = assistantMessage;
1335
+ if (currentAssistant && currentAssistant.id === assistant.id) {
1336
+ currentAssistant.content = text;
1337
+ currentAssistant.streaming = false;
1338
+ // Clean up
1339
+ streamParsers.delete(currentAssistant.id);
1340
+ rawContentBuffers.delete(currentAssistant.id);
1341
+ emitMessage(currentAssistant);
1342
+ }
1343
+ }
1344
+ });
1345
+ } else {
1346
+ // Extract text from synchronous result
1347
+ extractedText = typeof parsedResult === 'string' ? parsedResult : parsedResult?.text ?? null;
1348
+ }
1349
+ }
1350
+ }
1351
+
1352
+ // Skip sync emit if we're waiting on async parser
1353
+ if (!asyncPending) {
1354
+ // Set content: use extracted text if available, otherwise use raw content
1355
+ if (extractedText !== null && extractedText.trim() !== "") {
1356
+ assistant.content = extractedText;
1357
+ } else if (!rawContentBuffers.has(assistant.id)) {
1358
+ // Only use raw final content if we didn't accumulate chunks
1359
+ assistant.content = ensureStringContent(finalContent);
1360
+ }
1361
+
1362
+ // Clean up parser and buffer
1363
+ const parserToClose = streamParsers.get(assistant.id);
1364
+ if (parserToClose) {
1365
+ const closeResult = parserToClose.close?.();
1366
+ if (closeResult instanceof Promise) {
1367
+ closeResult.catch(() => {});
1368
+ }
1369
+ streamParsers.delete(assistant.id);
1370
+ }
1371
+ rawContentBuffers.delete(assistant.id);
1372
+ assistant.streaming = false;
1373
+ emitMessage(assistant);
1374
+ }
1375
+ }
1376
+ }
1377
+ } else if (payloadType === "step_complete") {
1378
+ // Only process completions for prompt steps, not tool/context steps
1379
+ const stepType = (payload as any).stepType;
1380
+ const executionType = (payload as any).executionType;
1381
+ if (stepType === "tool" || executionType === "context") {
1382
+ // Skip tool-related completions - they're handled by tool_complete
1383
+ continue;
1384
+ }
1385
+ const finalContent = payload.result?.response;
1386
+ const assistant = ensureAssistantMessage();
1387
+ if (finalContent !== undefined && finalContent !== null) {
1388
+ // Check if we already have extracted text from streaming
1389
+ const parser = streamParsers.get(assistant.id);
1390
+ let hasExtractedText = false;
1391
+ let asyncPending = false;
1392
+
1393
+ if (parser) {
1394
+ // First check if parser already extracted text during streaming
1395
+ const currentExtractedText = parser.getExtractedText();
1396
+ const rawBuffer = rawContentBuffers.get(assistant.id);
1397
+ const contentToProcess = rawBuffer ?? ensureStringContent(finalContent);
1398
+
1399
+ // Always set rawContent so action parsers can access the raw JSON
1400
+ assistant.rawContent = contentToProcess;
1401
+
1402
+ if (currentExtractedText !== null && currentExtractedText.trim() !== "") {
1403
+ // We already have extracted text from streaming - use it
1404
+ assistant.content = currentExtractedText;
1405
+ hasExtractedText = true;
1406
+ } else {
1407
+ // No extracted text yet - try to extract from final content
1408
+
1409
+ // Try fast path first
1410
+ const extractedText = extractTextFromJson(contentToProcess);
1411
+ if (extractedText !== null) {
1412
+ assistant.content = extractedText;
1413
+ hasExtractedText = true;
1414
+ } else {
1415
+ // Try parser
1416
+ const parsedResult = parser.processChunk(contentToProcess);
1417
+ if (parsedResult instanceof Promise) {
1418
+ asyncPending = true;
1419
+ parsedResult.then((result) => {
1420
+ // Extract text from result (could be string or object)
1421
+ const text = typeof result === 'string' ? result : result?.text ?? null;
1422
+
1423
+ if (text !== null && text.trim() !== "") {
1424
+ const currentAssistant = assistantMessage;
1425
+ if (currentAssistant && currentAssistant.id === assistant.id) {
1426
+ currentAssistant.content = text;
1427
+ currentAssistant.streaming = false;
1428
+ // Clean up
1429
+ streamParsers.delete(currentAssistant.id);
1430
+ rawContentBuffers.delete(currentAssistant.id);
1431
+ emitMessage(currentAssistant);
1432
+ }
1433
+ } else {
1434
+ // No extracted text - check if we should show raw content
1435
+ const finalExtractedText = parser.getExtractedText();
1436
+ const currentAssistant = assistantMessage;
1437
+ if (currentAssistant && currentAssistant.id === assistant.id) {
1438
+ if (finalExtractedText !== null && finalExtractedText.trim() !== "") {
1439
+ currentAssistant.content = finalExtractedText;
1440
+ } else if (!rawContentBuffers.has(currentAssistant.id)) {
1441
+ // Only show raw content if we never had any extracted text
1442
+ currentAssistant.content = ensureStringContent(finalContent);
1443
+ }
1444
+ currentAssistant.streaming = false;
1445
+ // Clean up
1446
+ streamParsers.delete(currentAssistant.id);
1447
+ rawContentBuffers.delete(currentAssistant.id);
1448
+ emitMessage(currentAssistant);
1449
+ }
1450
+ }
1451
+ });
1452
+ } else {
1453
+ // Extract text from synchronous result
1454
+ const text = typeof parsedResult === 'string' ? parsedResult : parsedResult?.text ?? null;
1455
+
1456
+ if (text !== null && text.trim() !== "") {
1457
+ assistant.content = text;
1458
+ hasExtractedText = true;
1459
+ } else {
1460
+ // Check stub one more time
1461
+ const finalExtractedText = parser.getExtractedText();
1462
+ if (finalExtractedText !== null && finalExtractedText.trim() !== "") {
1463
+ assistant.content = finalExtractedText;
1464
+ hasExtractedText = true;
1465
+ }
1466
+ }
1467
+ }
1468
+ }
1469
+ }
1470
+ }
1471
+
1472
+ // Skip sync emit if we're waiting on async parser
1473
+ if (!asyncPending) {
1474
+ // Ensure rawContent is set even if there's no parser (for action parsing)
1475
+ if (!assistant.rawContent) {
1476
+ const rawBuffer = rawContentBuffers.get(assistant.id);
1477
+ assistant.rawContent = rawBuffer ?? ensureStringContent(finalContent);
1478
+ }
1479
+
1480
+ // Only show raw content if we never extracted any text and no buffer was used
1481
+ if (!hasExtractedText && !rawContentBuffers.has(assistant.id)) {
1482
+ // No extracted text and no streaming happened - show raw content
1483
+ assistant.content = ensureStringContent(finalContent);
1484
+ }
1485
+
1486
+ // Clean up parser and buffer
1487
+ if (parser) {
1488
+ const closeResult = parser.close?.();
1489
+ if (closeResult instanceof Promise) {
1490
+ closeResult.catch(() => {});
1491
+ }
1492
+ }
1493
+ streamParsers.delete(assistant.id);
1494
+ rawContentBuffers.delete(assistant.id);
1495
+ assistant.streaming = false;
1496
+ emitMessage(assistant);
1497
+ }
1498
+ } else {
1499
+ // No final content, just mark as complete and clean up
1500
+ streamParsers.delete(assistant.id);
1501
+ rawContentBuffers.delete(assistant.id);
1502
+ assistant.streaming = false;
1503
+ emitMessage(assistant);
1504
+ }
1505
+ } else if (payloadType === "flow_complete") {
1506
+ const finalContent = payload.result?.response;
1507
+ if (finalContent !== undefined && finalContent !== null) {
1508
+ const assistant = ensureAssistantMessage();
1509
+ // Check if we have raw content buffer that needs final processing
1510
+ const rawBuffer = rawContentBuffers.get(assistant.id);
1511
+ const stringContent = rawBuffer ?? ensureStringContent(finalContent);
1512
+ assistant.rawContent = stringContent;
1513
+ // Try to extract text from structured content
1514
+ let displayContent = ensureStringContent(finalContent);
1515
+ const parser = streamParsers.get(assistant.id);
1516
+ if (parser) {
1517
+ const extractedText = extractTextFromJson(stringContent);
1518
+ if (extractedText !== null) {
1519
+ displayContent = extractedText;
1520
+ } else {
1521
+ // Try parser if it exists
1522
+ const parsedResult = parser.processChunk(stringContent);
1523
+ if (parsedResult instanceof Promise) {
1524
+ parsedResult.then((result) => {
1525
+ // Extract text from result (could be string or object)
1526
+ const text = typeof result === 'string' ? result : result?.text ?? null;
1527
+ if (text !== null) {
1528
+ const currentAssistant = assistantMessage;
1529
+ if (currentAssistant && currentAssistant.id === assistant.id) {
1530
+ currentAssistant.content = text;
1531
+ currentAssistant.streaming = false;
1532
+ emitMessage(currentAssistant);
1533
+ }
1534
+ }
1535
+ });
1536
+ }
1537
+ const currentText = parser.getExtractedText();
1538
+ if (currentText !== null) {
1539
+ displayContent = currentText;
1540
+ }
1541
+ }
1542
+ }
1543
+ // Clean up parser and buffer
1544
+ streamParsers.delete(assistant.id);
1545
+ rawContentBuffers.delete(assistant.id);
1546
+
1547
+ // Only emit if something actually changed to avoid flicker
1548
+ const contentChanged = displayContent !== assistant.content;
1549
+ const streamingChanged = assistant.streaming !== false;
1550
+
1551
+ if (contentChanged) {
1552
+ assistant.content = displayContent;
1553
+ }
1554
+ assistant.streaming = false;
1555
+
1556
+ // Only emit if content or streaming state changed
1557
+ if (contentChanged || streamingChanged) {
1558
+ emitMessage(assistant);
1559
+ }
1560
+ } else {
1561
+ // No final content, just mark as complete and clean up
1562
+ if (assistantMessage !== null) {
1563
+ // Clean up any remaining parsers/buffers
1564
+ // TypeScript narrowing issue - assistantMessage is checked for null above
1565
+ const msg: AgentWidgetMessage = assistantMessage;
1566
+ streamParsers.delete(msg.id);
1567
+ rawContentBuffers.delete(msg.id);
1568
+
1569
+ // Only emit if streaming state changed
1570
+ if (msg.streaming !== false) {
1571
+ msg.streaming = false;
1572
+ emitMessage(msg);
1573
+ }
1574
+ }
1575
+ }
1576
+ onEvent({ type: "status", status: "idle" });
1577
+ } else if (payloadType === "error" && payload.error) {
1578
+ onEvent({
1579
+ type: "error",
1580
+ error:
1581
+ payload.error instanceof Error
1582
+ ? payload.error
1583
+ : new Error(String(payload.error))
1584
+ });
1585
+ }
1586
+ }
1587
+ }
1588
+ }
1589
+ }