vanilla-agent 1.3.0 → 1.4.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/dist/widget.css CHANGED
@@ -56,6 +56,14 @@
56
56
  justify-content: center;
57
57
  }
58
58
 
59
+ .tvw-justify-between {
60
+ justify-content: space-between;
61
+ }
62
+
63
+ .tvw-justify-end {
64
+ justify-content: flex-end;
65
+ }
66
+
59
67
  .tvw-gap-1 {
60
68
  gap: 0.25rem;
61
69
  }
@@ -160,6 +168,10 @@
160
168
  background-color: var(--cw-primary, #111827);
161
169
  }
162
170
 
171
+ .tvw-italic {
172
+ font-style: italic;
173
+ }
174
+
163
175
  .tvw-text-base {
164
176
  font-size: 1rem;
165
177
  line-height: 1.5rem;
@@ -823,3 +835,12 @@ form:focus-within textarea {
823
835
  margin: 0.5rem 0;
824
836
  border-radius: 0.375rem;
825
837
  }
838
+
839
+ /* Ensure links in user messages match the text color */
840
+ #vanilla-agent-root .tvw-text-white a,
841
+ #vanilla-agent-root .tvw-text-white a:visited,
842
+ #vanilla-agent-root .tvw-text-white a:hover,
843
+ #vanilla-agent-root .tvw-text-white a:active {
844
+ color: inherit;
845
+ text-decoration: underline;
846
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vanilla-agent",
3
- "version": "1.3.0",
3
+ "version": "1.4.0",
4
4
  "description": "Themeable, plugable streaming agent widget for websites, in plain JS with support for voice input and reasoning / tool output.",
5
5
  "type": "module",
6
6
  "main": "dist/index.cjs",
@@ -24,7 +24,9 @@
24
24
  ],
25
25
  "dependencies": {
26
26
  "lucide": "^0.552.0",
27
- "marked": "^12.0.2"
27
+ "marked": "^12.0.2",
28
+ "partial-json": "^0.1.7",
29
+ "zod": "^3.22.4"
28
30
  },
29
31
  "devDependencies": {
30
32
  "@types/node": "^20.12.7",
@@ -0,0 +1,197 @@
1
+ import { describe, it, expect, beforeEach } from 'vitest';
2
+ import { AgentWidgetClient } from './client';
3
+ import { AgentWidgetEvent, AgentWidgetMessage } from './types';
4
+ import { createJsonStreamParser } from './utils/formatting';
5
+
6
+ describe('AgentWidgetClient - JSON Streaming', () => {
7
+ let client: AgentWidgetClient;
8
+ let events: AgentWidgetEvent[] = [];
9
+
10
+ beforeEach(() => {
11
+ events = [];
12
+ client = new AgentWidgetClient({
13
+ apiUrl: 'http://localhost:8000',
14
+ streamParser: createJsonStreamParser
15
+ });
16
+ });
17
+
18
+ it('should stream text incrementally and not show raw JSON at the end', async () => {
19
+ // Simulate the SSE stream from the user's example
20
+ const sseEvents = [
21
+ 'data: {"type":"flow_start","flowId":"flow_01k9pfnztzfag9tfz4t65c9c5q","flowName":"Shopping Assistant","totalSteps":1,"startedAt":"2025-11-12T23:47:39.565Z","executionId":"exec_standalone_1762991259266_7wz736k7n","executionContext":{"source":"standalone","record":{"id":"-1","name":"Streaming Chat Widget","created":false},"flow":{"id":"flow_01k9pfnztzfag9tfz4t65c9c5q","name":"Shopping Assistant","created":false}}}',
22
+ '',
23
+ 'data: {"type":"step_start","id":"step_01k9x5db72fzwvmdenryn0qm48","name":"Prompt 1","stepType":"prompt","index":1,"totalSteps":1,"startedAt":"2025-11-12T23:47:39.565Z"}',
24
+ '',
25
+ 'data: {"type":"step_chunk","id":"step_01k9x5db72fzwvmdenryn0qm48","name":"Prompt 1","executionType":"prompt","index":2,"text":"{\n"}',
26
+ '',
27
+ 'data: {"type":"step_chunk","id":"step_01k9x5db72fzwvmdenryn0qm48","name":"Prompt 1","executionType":"prompt","index":2,"text":" "}',
28
+ '',
29
+ 'data: {"type":"step_chunk","id":"step_01k9x5db72fzwvmdenryn0qm48","name":"Prompt 1","executionType":"prompt","index":2,"text":" \\""}',
30
+ '',
31
+ 'data: {"type":"step_chunk","id":"step_01k9x5db72fzwvmdenryn0qm48","name":"Prompt 1","executionType":"prompt","index":2,"text":"action"}',
32
+ '',
33
+ 'data: {"type":"step_chunk","id":"step_01k9x5db72fzwvmdenryn0qm48","name":"Prompt 1","executionType":"prompt","index":2,"text":"\\":"}',
34
+ '',
35
+ 'data: {"type":"step_chunk","id":"step_01k9x5db72fzwvmdenryn0qm48","name":"Prompt 1","executionType":"prompt","index":2,"text":" \\""}',
36
+ '',
37
+ 'data: {"type":"step_chunk","id":"step_01k9x5db72fzwvmdenryn0qm48","name":"Prompt 1","executionType":"prompt","index":2,"text":"message"}',
38
+ '',
39
+ 'data: {"type":"step_chunk","id":"step_01k9x5db72fzwvmdenryn0qm48","name":"Prompt 1","executionType":"prompt","index":2,"text":"\\",\\n"}',
40
+ '',
41
+ 'data: {"type":"step_chunk","id":"step_01k9x5db72fzwvmdenryn0qm48","name":"Prompt 1","executionType":"prompt","index":2,"text":" "}',
42
+ '',
43
+ 'data: {"type":"step_chunk","id":"step_01k9x5db72fzwvmdenryn0qm48","name":"Prompt 1","executionType":"prompt","index":2,"text":" \\""}',
44
+ '',
45
+ 'data: {"type":"step_chunk","id":"step_01k9x5db72fzwvmdenryn0qm48","name":"Prompt 1","executionType":"prompt","index":2,"text":"text"}',
46
+ '',
47
+ 'data: {"type":"step_chunk","id":"step_01k9x5db72fzwvmdenryn0qm48","name":"Prompt 1","executionType":"prompt","index":2,"text":"\\":"}',
48
+ '',
49
+ 'data: {"type":"step_chunk","id":"step_01k9x5db72fzwvmdenryn0qm48","name":"Prompt 1","executionType":"prompt","index":2,"text":" \\""}',
50
+ '',
51
+ 'data: {"type":"step_chunk","id":"step_01k9x5db72fzwvmdenryn0qm48","name":"Prompt 1","executionType":"prompt","index":2,"text":"Great"}',
52
+ '',
53
+ 'data: {"type":"step_chunk","id":"step_01k9x5db72fzwvmdenryn0qm48","name":"Prompt 1","executionType":"prompt","index":2,"text":"!"}',
54
+ '',
55
+ 'data: {"type":"step_chunk","id":"step_01k9x5db72fzwvmdenryn0qm48","name":"Prompt 1","executionType":"prompt","index":2,"text":" If"}',
56
+ '',
57
+ 'data: {"type":"step_chunk","id":"step_01k9x5db72fzwvmdenryn0qm48","name":"Prompt 1","executionType":"prompt","index":2,"text":" you"}',
58
+ '',
59
+ 'data: {"type":"step_chunk","id":"step_01k9x5db72fzwvmdenryn0qm48","name":"Prompt 1","executionType":"prompt","index":2,"text":" have"}',
60
+ '',
61
+ 'data: {"type":"step_chunk","id":"step_01k9x5db72fzwvmdenryn0qm48","name":"Prompt 1","executionType":"prompt","index":2,"text":" any"}',
62
+ '',
63
+ 'data: {"type":"step_chunk","id":"step_01k9x5db72fzwvmdenryn0qm48","name":"Prompt 1","executionType":"prompt","index":2,"text":" questions"}',
64
+ '',
65
+ 'data: {"type":"step_chunk","id":"step_01k9x5db72fzwvmdenryn0qm48","name":"Prompt 1","executionType":"prompt","index":2,"text":" or"}',
66
+ '',
67
+ 'data: {"type":"step_chunk","id":"step_01k9x5db72fzwvmdenryn0qm48","name":"Prompt 1","executionType":"prompt","index":2,"text":" need"}',
68
+ '',
69
+ 'data: {"type":"step_chunk","id":"step_01k9x5db72fzwvmdenryn0qm48","name":"Prompt 1","executionType":"prompt","index":2,"text":" help"}',
70
+ '',
71
+ 'data: {"type":"step_chunk","id":"step_01k9x5db72fzwvmdenryn0qm48","name":"Prompt 1","executionType":"prompt","index":2,"text":" finding"}',
72
+ '',
73
+ 'data: {"type":"step_chunk","id":"step_01k9x5db72fzwvmdenryn0qm48","name":"Prompt 1","executionType":"prompt","index":2,"text":" something"}',
74
+ '',
75
+ 'data: {"type":"step_chunk","id":"step_01k9x5db72fzwvmdenryn0qm48","name":"Prompt 1","executionType":"prompt","index":2,"text":","}',
76
+ '',
77
+ 'data: {"type":"step_chunk","id":"step_01k9x5db72fzwvmdenryn0qm48","name":"Prompt 1","executionType":"prompt","index":2,"text":" just"}',
78
+ '',
79
+ 'data: {"type":"step_chunk","id":"step_01k9x5db72fzwvmdenryn0qm48","name":"Prompt 1","executionType":"prompt","index":2,"text":" let"}',
80
+ '',
81
+ 'data: {"type":"step_chunk","id":"step_01k9x5db72fzwvmdenryn0qm48","name":"Prompt 1","executionType":"prompt","index":2,"text":" me"}',
82
+ '',
83
+ 'data: {"type":"step_chunk","id":"step_01k9x5db72fzwvmdenryn0qm48","name":"Prompt 1","executionType":"prompt","index":2,"text":" know"}',
84
+ '',
85
+ 'data: {"type":"step_chunk","id":"step_01k9x5db72fzwvmdenryn0qm48","name":"Prompt 1","executionType":"prompt","index":2,"text":"!\\"\\n"}',
86
+ '',
87
+ 'data: {"type":"step_chunk","id":"step_01k9x5db72fzwvmdenryn0qm48","name":"Prompt 1","executionType":"prompt","index":2,"text":"}"}',
88
+ '',
89
+ 'data: {"type":"step_complete","id":"step_01k9x5db72fzwvmdenryn0qm48","name":"Prompt 1","executionType":"prompt","index":1,"success":true,"result":{"promptId":"step_01k9x5db72fzwvmdenryn0qm48","promptName":"Prompt 1","processedPrompt":"ok","response":"{\\"\\n \\"action\\": \\"message\\",\\n \\"text\\": \\"Great! If you have any questions or need help finding something, just let me know!\\"\\n}","tokens":{"input":1833,"output":34,"total":1867},"cost":0.000700125,"executionTime":2222,"order":2},"executionTime":2222}',
90
+ '',
91
+ 'data: {"type":"flow_complete","flowId":"flow_01k9pfnztzfag9tfz4t65c9c5q","success":true,"duration":2968,"completedAt":"2025-11-12T23:47:42.234Z","totalTokensUsed":0}'
92
+ ];
93
+
94
+ // Create a ReadableStream from the SSE events
95
+ const encoder = new TextEncoder();
96
+ const stream = new ReadableStream({
97
+ start(controller) {
98
+ for (const event of sseEvents) {
99
+ controller.enqueue(encoder.encode(event + '\n'));
100
+ }
101
+ controller.close();
102
+ }
103
+ });
104
+
105
+ // Mock fetch to return our stream
106
+ global.fetch = async () => ({
107
+ ok: true,
108
+ body: stream
109
+ }) as any;
110
+
111
+ // Dispatch and collect events
112
+ await client.dispatch(
113
+ {
114
+ messages: [{ role: 'user', content: 'ok' }]
115
+ },
116
+ (event) => {
117
+ events.push(event);
118
+ if (event.type === 'message') {
119
+ console.log('Message event:', {
120
+ content: event.message.content,
121
+ streaming: event.message.streaming,
122
+ contentLength: event.message.content.length
123
+ });
124
+ }
125
+ }
126
+ );
127
+
128
+ // Filter for assistant message events
129
+ const messageEvents = events.filter(
130
+ (e) => e.type === 'message' && e.message.role === 'assistant'
131
+ ) as Extract<AgentWidgetEvent, { type: 'message' }>[];
132
+
133
+ // Validate behavior
134
+ expect(messageEvents.length).toBeGreaterThan(0);
135
+
136
+ // 1. Check that text starts streaming incrementally (not all at once)
137
+ const streamingMessages = messageEvents.filter((e) => e.message.streaming);
138
+ expect(streamingMessages.length).toBeGreaterThan(1);
139
+ console.log(`Found ${streamingMessages.length} streaming message events`);
140
+
141
+ // 2. Check that text content appears progressively
142
+ let hasPartialText = false;
143
+ const expectedFinalText = "Great! If you have any questions or need help finding something, just let me know!";
144
+
145
+ for (const msgEvent of streamingMessages) {
146
+ const content = msgEvent.message.content;
147
+
148
+ // Should not contain raw JSON during streaming
149
+ if (content.includes('"action"') || content.includes('"text"')) {
150
+ console.error('Found raw JSON in streaming content:', content);
151
+ }
152
+ expect(content).not.toMatch(/"action"|"text":/);
153
+
154
+ // Check for partial text (text that's incomplete)
155
+ if (content.length > 0 && content.length < expectedFinalText.length) {
156
+ hasPartialText = true;
157
+ // Partial text should be a prefix of the final text
158
+ expect(expectedFinalText.startsWith(content)).toBe(true);
159
+ }
160
+ }
161
+
162
+ expect(hasPartialText).toBe(true);
163
+ console.log('✓ Text streamed incrementally with partial values');
164
+
165
+ // 3. Check final message (streaming: false)
166
+ const finalMessages = messageEvents.filter((e) => !e.message.streaming);
167
+ expect(finalMessages.length).toBeGreaterThan(0);
168
+
169
+ const finalMessage = finalMessages[finalMessages.length - 1].message;
170
+ console.log('Final message content:', finalMessage.content);
171
+
172
+ // Final content should be ONLY the extracted text, not raw JSON
173
+ expect(finalMessage.content).toBe(expectedFinalText);
174
+ expect(finalMessage.content).not.toContain('"action"');
175
+ expect(finalMessage.content).not.toContain('"text"');
176
+ expect(finalMessage.content).not.toContain('{\n');
177
+
178
+ console.log('✓ Final message contains only extracted text, no raw JSON');
179
+
180
+ // 4. Verify no raw JSON was ever displayed
181
+ const allContents = messageEvents.map((e) => e.message.content);
182
+ const hasRawJson = allContents.some(
183
+ (content) => content.includes('{\n "action": "message"')
184
+ );
185
+
186
+ if (hasRawJson) {
187
+ const rawJsonMessage = allContents.find((content) =>
188
+ content.includes('{\n "action": "message"')
189
+ );
190
+ console.error('Found raw JSON in message content:', rawJsonMessage);
191
+ }
192
+
193
+ expect(hasRawJson).toBe(false);
194
+ console.log('✓ No raw JSON was displayed at any point');
195
+ });
196
+ });
197
+
package/src/client.ts CHANGED
@@ -1,4 +1,5 @@
1
- import { AgentWidgetConfig, AgentWidgetMessage, AgentWidgetEvent } from "./types";
1
+ import { AgentWidgetConfig, AgentWidgetMessage, AgentWidgetEvent, AgentWidgetStreamParser } from "./types";
2
+ import { extractTextFromJson, createPlainTextParser } from "./utils/formatting";
2
3
 
3
4
  type DispatchOptions = {
4
5
  messages: AgentWidgetMessage[];
@@ -13,6 +14,7 @@ export class AgentWidgetClient {
13
14
  private readonly apiUrl: string;
14
15
  private readonly headers: Record<string, string>;
15
16
  private readonly debug: boolean;
17
+ private readonly createStreamParser: () => AgentWidgetStreamParser;
16
18
 
17
19
  constructor(private config: AgentWidgetConfig = {}) {
18
20
  this.apiUrl = config.apiUrl ?? DEFAULT_ENDPOINT;
@@ -21,6 +23,8 @@ export class AgentWidgetClient {
21
23
  ...config.headers
22
24
  };
23
25
  this.debug = Boolean(config.debug);
26
+ // Use custom stream parser from config, or fall back to plain text parser
27
+ this.createStreamParser = config.streamParser ?? createPlainTextParser;
24
28
  }
25
29
 
26
30
  public async dispatch(options: DispatchOptions, onEvent: SSEHandler) {
@@ -322,6 +326,26 @@ export class AgentWidgetClient {
322
326
  return Date.now();
323
327
  };
324
328
 
329
+ const ensureStringContent = (value: unknown): string => {
330
+ if (typeof value === "string") {
331
+ return value;
332
+ }
333
+ if (value === null || value === undefined) {
334
+ return "";
335
+ }
336
+ // Convert objects/arrays to JSON string
337
+ try {
338
+ return JSON.stringify(value);
339
+ } catch {
340
+ return String(value);
341
+ }
342
+ };
343
+
344
+ // Maintain stateful stream parsers per message for incremental parsing
345
+ const streamParsers = new Map<string, AgentWidgetStreamParser>();
346
+ // Track accumulated raw content for structured formats (JSON, XML, etc.)
347
+ const rawContentBuffers = new Map<string, string>();
348
+
325
349
  while (true) {
326
350
  const { done, value } = await reader.read();
327
351
  if (done) break;
@@ -517,48 +541,339 @@ export class AgentWidgetClient {
517
541
  toolContext.byCall.delete(callKey);
518
542
  }
519
543
  } else if (payloadType === "step_chunk") {
544
+ // Only process chunks for prompt steps, not tool/context steps
545
+ const stepType = (payload as any).stepType;
546
+ const executionType = (payload as any).executionType;
547
+ if (stepType === "tool" || executionType === "context") {
548
+ // Skip tool-related chunks - they're handled by tool_start/tool_complete
549
+ continue;
550
+ }
520
551
  const assistant = ensureAssistantMessage();
521
552
  const chunk = payload.text ?? payload.delta ?? payload.content ?? "";
522
553
  if (chunk) {
523
- assistant.content += chunk;
524
- emitMessage(assistant);
554
+ // Accumulate raw content for structured format parsing
555
+ const rawBuffer = rawContentBuffers.get(assistant.id) ?? "";
556
+ const accumulatedRaw = rawBuffer + chunk;
557
+
558
+ // Use stream parser to parse
559
+ if (!streamParsers.has(assistant.id)) {
560
+ streamParsers.set(assistant.id, this.createStreamParser());
561
+ }
562
+ const parser = streamParsers.get(assistant.id)!;
563
+
564
+ // Check if content looks like JSON
565
+ const looksLikeJson = accumulatedRaw.trim().startsWith('{') || accumulatedRaw.trim().startsWith('[');
566
+
567
+ // Store raw buffer before processing (needed for step_complete handler)
568
+ if (looksLikeJson) {
569
+ rawContentBuffers.set(assistant.id, accumulatedRaw);
570
+ }
571
+
572
+ // Check if this is a plain text parser (marked with __isPlainTextParser)
573
+ const isPlainTextParser = (parser as any).__isPlainTextParser === true;
574
+
575
+ // If plain text parser, just append the chunk directly
576
+ if (isPlainTextParser) {
577
+ assistant.content += chunk;
578
+ // Clear any raw buffer/parser since we're in plain text mode
579
+ rawContentBuffers.delete(assistant.id);
580
+ streamParsers.delete(assistant.id);
581
+ emitMessage(assistant);
582
+ continue;
583
+ }
584
+
585
+ // Try to parse with the parser (for structured parsers)
586
+ const parsedResult = parser.processChunk(accumulatedRaw);
587
+
588
+ // Handle async parser result
589
+ if (parsedResult instanceof Promise) {
590
+ parsedResult.then((result) => {
591
+ // Extract text from result (could be string or object)
592
+ const text = typeof result === 'string' ? result : result?.text ?? null;
593
+
594
+ if (text !== null && text.trim() !== "") {
595
+ // Parser successfully extracted text
596
+ // Update the message content with extracted text
597
+ const currentAssistant = assistantMessage;
598
+ if (currentAssistant && currentAssistant.id === assistant.id) {
599
+ currentAssistant.content = text;
600
+ emitMessage(currentAssistant);
601
+ }
602
+ } else if (!looksLikeJson && !accumulatedRaw.trim().startsWith('<')) {
603
+ // Not a structured format - show as plain text
604
+ const currentAssistant = assistantMessage;
605
+ if (currentAssistant && currentAssistant.id === assistant.id) {
606
+ currentAssistant.content += chunk;
607
+ rawContentBuffers.delete(currentAssistant.id);
608
+ streamParsers.delete(currentAssistant.id);
609
+ emitMessage(currentAssistant);
610
+ }
611
+ }
612
+ // Otherwise wait for more chunks (incomplete structured format)
613
+ }).catch(() => {
614
+ // On error, treat as plain text
615
+ assistant.content += chunk;
616
+ rawContentBuffers.delete(assistant.id);
617
+ streamParsers.delete(assistant.id);
618
+ emitMessage(assistant);
619
+ });
620
+ } else {
621
+ // Synchronous parser result
622
+ // Extract text from result (could be string, null, or object)
623
+ const text = typeof parsedResult === 'string' ? parsedResult : parsedResult?.text ?? null;
624
+
625
+ if (text !== null && text.trim() !== "") {
626
+ // Parser successfully extracted text
627
+ // Buffer is already set above
628
+ assistant.content = text;
629
+ emitMessage(assistant);
630
+ } else if (!looksLikeJson && !accumulatedRaw.trim().startsWith('<')) {
631
+ // Not a structured format - show as plain text
632
+ assistant.content += chunk;
633
+ // Clear any raw buffer/parser if we were in structured format mode
634
+ rawContentBuffers.delete(assistant.id);
635
+ streamParsers.delete(assistant.id);
636
+ emitMessage(assistant);
637
+ }
638
+ // Otherwise wait for more chunks (incomplete structured format)
639
+ }
640
+
641
+ // Also check if we already have extracted text from previous chunks
642
+ const currentText = parser.getExtractedText();
643
+ if (currentText != null && currentText !== "" && currentText !== assistant.content) {
644
+ assistant.content = currentText;
645
+ emitMessage(assistant);
646
+ }
525
647
  }
526
648
  if (payload.isComplete) {
527
649
  const finalContent = payload.result?.response ?? assistant.content;
528
650
  if (finalContent) {
529
- assistant.content = finalContent;
651
+ // Check if we have raw content buffer that needs final processing
652
+ const rawBuffer = rawContentBuffers.get(assistant.id);
653
+ const contentToProcess = rawBuffer ?? ensureStringContent(finalContent);
654
+
655
+ // Try to extract text from final structured content
656
+ const parser = streamParsers.get(assistant.id);
657
+ let extractedText: string | null = null;
658
+
659
+ if (parser) {
660
+ // First check if parser already has extracted text
661
+ extractedText = parser.getExtractedText();
662
+
663
+ if (extractedText === null) {
664
+ // Try extracting with regex
665
+ extractedText = extractTextFromJson(contentToProcess);
666
+ }
667
+
668
+ if (extractedText === null) {
669
+ // Try parser.processChunk as last resort
670
+ const parsedResult = parser.processChunk(contentToProcess);
671
+ if (parsedResult instanceof Promise) {
672
+ parsedResult.then((result) => {
673
+ // Extract text from result (could be string or object)
674
+ const text = typeof result === 'string' ? result : result?.text ?? null;
675
+ if (text !== null) {
676
+ const currentAssistant = assistantMessage;
677
+ if (currentAssistant && currentAssistant.id === assistant.id) {
678
+ currentAssistant.content = text;
679
+ currentAssistant.streaming = false;
680
+ emitMessage(currentAssistant);
681
+ }
682
+ }
683
+ });
684
+ } else {
685
+ // Extract text from synchronous result
686
+ extractedText = typeof parsedResult === 'string' ? parsedResult : parsedResult?.text ?? null;
687
+ }
688
+ }
689
+ }
690
+
691
+ // Set content: use extracted text if available, otherwise use raw content
692
+ if (extractedText !== null && extractedText.trim() !== "") {
693
+ assistant.content = extractedText;
694
+ } else if (!rawContentBuffers.has(assistant.id)) {
695
+ // Only use raw final content if we didn't accumulate chunks
696
+ assistant.content = ensureStringContent(finalContent);
697
+ }
698
+
699
+ // Clean up parser and buffer
700
+ const parserToClose = streamParsers.get(assistant.id);
701
+ if (parserToClose) {
702
+ const closeResult = parserToClose.close?.();
703
+ if (closeResult instanceof Promise) {
704
+ closeResult.catch(() => {});
705
+ }
706
+ streamParsers.delete(assistant.id);
707
+ }
708
+ rawContentBuffers.delete(assistant.id);
530
709
  assistant.streaming = false;
531
710
  emitMessage(assistant);
532
711
  }
533
712
  }
534
713
  } else if (payloadType === "step_complete") {
714
+ // Only process completions for prompt steps, not tool/context steps
715
+ const stepType = (payload as any).stepType;
716
+ const executionType = (payload as any).executionType;
717
+ if (stepType === "tool" || executionType === "context") {
718
+ // Skip tool-related completions - they're handled by tool_complete
719
+ continue;
720
+ }
535
721
  const finalContent = payload.result?.response;
536
722
  const assistant = ensureAssistantMessage();
537
- if (finalContent) {
538
- assistant.content = finalContent;
723
+ if (finalContent !== undefined && finalContent !== null) {
724
+ // Check if we already have extracted text from streaming
725
+ const parser = streamParsers.get(assistant.id);
726
+ let hasExtractedText = false;
727
+
728
+ if (parser) {
729
+ // First check if parser already extracted text during streaming
730
+ const currentExtractedText = parser.getExtractedText();
731
+ if (currentExtractedText !== null && currentExtractedText.trim() !== "") {
732
+ // We already have extracted text from streaming - use it
733
+ assistant.content = currentExtractedText;
734
+ hasExtractedText = true;
735
+ } else {
736
+ // No extracted text yet - try to extract from final content
737
+ const rawBuffer = rawContentBuffers.get(assistant.id);
738
+ const contentToProcess = rawBuffer ?? ensureStringContent(finalContent);
739
+
740
+ // Try fast path first
741
+ const extractedText = extractTextFromJson(contentToProcess);
742
+ if (extractedText !== null) {
743
+ assistant.content = extractedText;
744
+ hasExtractedText = true;
745
+ } else {
746
+ // Try parser
747
+ const parsedResult = parser.processChunk(contentToProcess);
748
+ if (parsedResult instanceof Promise) {
749
+ parsedResult.then((result) => {
750
+ // Extract text from result (could be string or object)
751
+ const text = typeof result === 'string' ? result : result?.text ?? null;
752
+
753
+ if (text !== null && text.trim() !== "") {
754
+ const currentAssistant = assistantMessage;
755
+ if (currentAssistant && currentAssistant.id === assistant.id) {
756
+ currentAssistant.content = text;
757
+ currentAssistant.streaming = false;
758
+ emitMessage(currentAssistant);
759
+ }
760
+ } else {
761
+ // No extracted text - check if we should show raw content
762
+ const finalExtractedText = parser.getExtractedText();
763
+ if (finalExtractedText === null || finalExtractedText.trim() === "") {
764
+ // No extracted text available - show raw content only if no streaming happened
765
+ const currentAssistant = assistantMessage;
766
+ if (currentAssistant && currentAssistant.id === assistant.id) {
767
+ // Only show raw content if we never had any extracted text
768
+ if (!rawContentBuffers.has(assistant.id)) {
769
+ currentAssistant.content = ensureStringContent(finalContent);
770
+ }
771
+ currentAssistant.streaming = false;
772
+ emitMessage(currentAssistant);
773
+ }
774
+ }
775
+ }
776
+ });
777
+ } else {
778
+ // Extract text from synchronous result
779
+ const text = typeof parsedResult === 'string' ? parsedResult : parsedResult?.text ?? null;
780
+
781
+ if (text !== null && text.trim() !== "") {
782
+ assistant.content = text;
783
+ hasExtractedText = true;
784
+ } else {
785
+ // Check stub one more time
786
+ const finalExtractedText = parser.getExtractedText();
787
+ if (finalExtractedText !== null && finalExtractedText.trim() !== "") {
788
+ assistant.content = finalExtractedText;
789
+ hasExtractedText = true;
790
+ }
791
+ }
792
+ }
793
+ }
794
+ }
795
+ }
796
+
797
+ // Only show raw content if we never extracted any text and no buffer was used
798
+ if (!hasExtractedText && !rawContentBuffers.has(assistant.id)) {
799
+ // No extracted text and no streaming happened - show raw content
800
+ assistant.content = ensureStringContent(finalContent);
801
+ }
802
+
803
+ // Clean up parser and buffer
804
+ if (parser) {
805
+ const closeResult = parser.close?.();
806
+ if (closeResult instanceof Promise) {
807
+ closeResult.catch(() => {});
808
+ }
809
+ }
810
+ streamParsers.delete(assistant.id);
811
+ rawContentBuffers.delete(assistant.id);
539
812
  assistant.streaming = false;
540
813
  emitMessage(assistant);
541
814
  } else {
542
- // No final content, just mark as complete
815
+ // No final content, just mark as complete and clean up
816
+ streamParsers.delete(assistant.id);
817
+ rawContentBuffers.delete(assistant.id);
543
818
  assistant.streaming = false;
544
819
  emitMessage(assistant);
545
820
  }
546
821
  } else if (payloadType === "flow_complete") {
547
822
  const finalContent = payload.result?.response;
548
- if (finalContent) {
823
+ if (finalContent !== undefined && finalContent !== null) {
549
824
  const assistant = ensureAssistantMessage();
550
- if (finalContent !== assistant.content) {
551
- assistant.content = finalContent;
825
+ // Check if we have raw content buffer that needs final processing
826
+ const rawBuffer = rawContentBuffers.get(assistant.id);
827
+ const stringContent = rawBuffer ?? ensureStringContent(finalContent);
828
+ // Try to extract text from structured content
829
+ let displayContent = ensureStringContent(finalContent);
830
+ const parser = streamParsers.get(assistant.id);
831
+ if (parser) {
832
+ const extractedText = extractTextFromJson(stringContent);
833
+ if (extractedText !== null) {
834
+ displayContent = extractedText;
835
+ } else {
836
+ // Try parser if it exists
837
+ const parsedResult = parser.processChunk(stringContent);
838
+ if (parsedResult instanceof Promise) {
839
+ parsedResult.then((result) => {
840
+ // Extract text from result (could be string or object)
841
+ const text = typeof result === 'string' ? result : result?.text ?? null;
842
+ if (text !== null) {
843
+ const currentAssistant = assistantMessage;
844
+ if (currentAssistant && currentAssistant.id === assistant.id) {
845
+ currentAssistant.content = text;
846
+ currentAssistant.streaming = false;
847
+ emitMessage(currentAssistant);
848
+ }
849
+ }
850
+ });
851
+ }
852
+ const currentText = parser.getExtractedText();
853
+ if (currentText !== null) {
854
+ displayContent = currentText;
855
+ }
856
+ }
857
+ }
858
+ // Clean up parser and buffer
859
+ streamParsers.delete(assistant.id);
860
+ rawContentBuffers.delete(assistant.id);
861
+ if (displayContent !== assistant.content) {
862
+ assistant.content = displayContent;
552
863
  emitMessage(assistant);
553
864
  }
554
865
  assistant.streaming = false;
555
866
  emitMessage(assistant);
556
867
  } else {
557
- const existingAssistant = assistantMessage;
558
- if (existingAssistant) {
559
- const assistantFinal = existingAssistant as AgentWidgetMessage;
560
- assistantFinal.streaming = false;
561
- emitMessage(assistantFinal);
868
+ // No final content, just mark as complete and clean up
869
+ if (assistantMessage !== null) {
870
+ // Clean up any remaining parsers/buffers
871
+ // TypeScript narrowing issue - assistantMessage is checked for null above
872
+ const msg: AgentWidgetMessage = assistantMessage;
873
+ streamParsers.delete(msg.id);
874
+ rawContentBuffers.delete(msg.id);
875
+ msg.streaming = false;
876
+ emitMessage(msg);
562
877
  }
563
878
  }
564
879
  onEvent({ type: "status", status: "idle" });
@@ -165,3 +165,5 @@ export const enhanceWithForms = (
165
165
 
166
166
 
167
167
 
168
+
169
+