vanilla-agent 1.6.0 → 1.7.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vanilla-agent",
3
- "version": "1.6.0",
3
+ "version": "1.7.1",
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",
@@ -30,13 +30,15 @@
30
30
  },
31
31
  "devDependencies": {
32
32
  "@types/node": "^20.12.7",
33
+ "@vitest/ui": "^4.0.9",
33
34
  "eslint": "^8.57.0",
34
35
  "eslint-config-prettier": "^9.1.0",
35
36
  "postcss": "^8.4.38",
36
37
  "rimraf": "^5.0.5",
37
38
  "tailwindcss": "^3.4.10",
38
39
  "tsup": "^8.0.1",
39
- "typescript": "^5.4.5"
40
+ "typescript": "^5.4.5",
41
+ "vitest": "^4.0.9"
40
42
  },
41
43
  "engines": {
42
44
  "node": ">=18.17.0"
@@ -58,6 +60,9 @@
58
60
  "build:client": "tsup src/index.ts --format esm,cjs,iife --global-name AgentWidget --minify --sourcemap --splitting false --dts --loader \".css=text\"",
59
61
  "build:installer": "tsup src/install.ts --format iife --global-name SiteAgentInstaller --out-dir dist --minify --sourcemap --no-splitting",
60
62
  "lint": "eslint . --ext .ts",
61
- "typecheck": "tsc --noEmit"
63
+ "typecheck": "tsc --noEmit",
64
+ "test": "vitest",
65
+ "test:ui": "vitest --ui",
66
+ "test:run": "vitest run"
62
67
  }
63
68
  }
@@ -22,7 +22,7 @@ describe('AgentWidgetClient - JSON Streaming', () => {
22
22
  '',
23
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
24
  '',
25
- 'data: {"type":"step_chunk","id":"step_01k9x5db72fzwvmdenryn0qm48","name":"Prompt 1","executionType":"prompt","index":2,"text":"{\n"}',
25
+ 'data: {"type":"step_chunk","id":"step_01k9x5db72fzwvmdenryn0qm48","name":"Prompt 1","executionType":"prompt","index":2,"text":"{\\n"}',
26
26
  '',
27
27
  'data: {"type":"step_chunk","id":"step_01k9x5db72fzwvmdenryn0qm48","name":"Prompt 1","executionType":"prompt","index":2,"text":" "}',
28
28
  '',
package/src/client.ts CHANGED
@@ -640,6 +640,7 @@ export class AgentWidgetClient {
640
640
  // Accumulate raw content for structured format parsing
641
641
  const rawBuffer = rawContentBuffers.get(assistant.id) ?? "";
642
642
  const accumulatedRaw = rawBuffer + chunk;
643
+ // Store raw content for action parsing, but NEVER set assistant.content to raw JSON
643
644
  assistant.rawContent = accumulatedRaw;
644
645
 
645
646
  // Use stream parser to parse
@@ -699,6 +700,7 @@ export class AgentWidgetClient {
699
700
  }
700
701
  }
701
702
  // Otherwise wait for more chunks (incomplete structured format)
703
+ // Don't emit message if parser hasn't extracted text yet
702
704
  }).catch(() => {
703
705
  // On error, treat as plain text
704
706
  assistant.content += chunk;
@@ -727,14 +729,12 @@ export class AgentWidgetClient {
727
729
  emitMessage(assistant);
728
730
  }
729
731
  // Otherwise wait for more chunks (incomplete structured format)
732
+ // Don't emit message if parser hasn't extracted text yet
730
733
  }
731
734
 
732
- // Also check if we already have extracted text from previous chunks
733
- const currentText = parser.getExtractedText();
734
- if (currentText != null && currentText !== "" && currentText !== assistant.content) {
735
- assistant.content = currentText;
736
- emitMessage(assistant);
737
- }
735
+ // IMPORTANT: Don't call getExtractedText() and emit messages here
736
+ // This was causing raw JSON to be displayed because getExtractedText()
737
+ // wasn't extracting the "text" field correctly during streaming
738
738
  }
739
739
  if (payload.isComplete) {
740
740
  const finalContent = payload.result?.response ?? assistant.content;
@@ -820,15 +820,18 @@ export class AgentWidgetClient {
820
820
  if (parser) {
821
821
  // First check if parser already extracted text during streaming
822
822
  const currentExtractedText = parser.getExtractedText();
823
+ const rawBuffer = rawContentBuffers.get(assistant.id);
824
+ const contentToProcess = rawBuffer ?? ensureStringContent(finalContent);
825
+
826
+ // Always set rawContent so action parsers can access the raw JSON
827
+ assistant.rawContent = contentToProcess;
828
+
823
829
  if (currentExtractedText !== null && currentExtractedText.trim() !== "") {
824
830
  // We already have extracted text from streaming - use it
825
831
  assistant.content = currentExtractedText;
826
832
  hasExtractedText = true;
827
833
  } else {
828
834
  // No extracted text yet - try to extract from final content
829
- const rawBuffer = rawContentBuffers.get(assistant.id);
830
- const contentToProcess = rawBuffer ?? ensureStringContent(finalContent);
831
- assistant.rawContent = contentToProcess;
832
835
 
833
836
  // Try fast path first
834
837
  const extractedText = extractTextFromJson(contentToProcess);
@@ -887,6 +890,12 @@ export class AgentWidgetClient {
887
890
  }
888
891
  }
889
892
 
893
+ // Ensure rawContent is set even if there's no parser (for action parsing)
894
+ if (!assistant.rawContent) {
895
+ const rawBuffer = rawContentBuffers.get(assistant.id);
896
+ assistant.rawContent = rawBuffer ?? ensureStringContent(finalContent);
897
+ }
898
+
890
899
  // Only show raw content if we never extracted any text and no buffer was used
891
900
  if (!hasExtractedText && !rawContentBuffers.has(assistant.id)) {
892
901
  // No extracted text and no streaming happened - show raw content
package/src/index.ts CHANGED
@@ -39,6 +39,7 @@ export {
39
39
  export {
40
40
  createPlainTextParser,
41
41
  createJsonStreamParser,
42
+ createFlexibleJsonStreamParser,
42
43
  createRegexJsonParser,
43
44
  createXmlParser
44
45
  } from "./utils/formatting";
@@ -45,8 +45,10 @@ describe("JSON Stream Parser", () => {
45
45
  accumulatedContent += chunk;
46
46
  const result = parser.processChunk(accumulatedContent);
47
47
 
48
- if (result !== null) {
49
- extractedTexts.push(result);
48
+ // Extract text from result (can be string or object with text property)
49
+ const text = typeof result === 'string' ? result : result?.text ?? null;
50
+ if (text !== null) {
51
+ extractedTexts.push(text);
50
52
  }
51
53
 
52
54
  // Also check getExtractedText
@@ -101,7 +103,9 @@ describe("JSON Stream Parser", () => {
101
103
  const parser = createJsonStreamParser();
102
104
  const result = parser.processChunk(completeJson);
103
105
 
104
- expect(result).toBe("Hello world!");
106
+ // Extract text from result (can be string or object with text property)
107
+ const text = typeof result === 'string' ? result : result?.text ?? null;
108
+ expect(text).toBe("Hello world!");
105
109
  expect(parser.getExtractedText()).toBe("Hello world!");
106
110
  });
107
111
 
@@ -146,7 +150,9 @@ describe("JSON Stream Parser", () => {
146
150
  for (const chunk of textChunks) {
147
151
  accumulated += chunk;
148
152
  const result = parser.processChunk(accumulated);
149
- allExtractedTexts.push(result);
153
+ // Extract text from result (can be string or object with text property)
154
+ const text = typeof result === 'string' ? result : result?.text ?? null;
155
+ allExtractedTexts.push(text);
150
156
  }
151
157
 
152
158
  // Should have many non-null results (incremental updates)
@@ -301,6 +301,91 @@ export const createJsonStreamParser = (): AgentWidgetStreamParser => {
301
301
  };
302
302
  };
303
303
 
304
+ /**
305
+ * Flexible JSON stream parser that can extract text from various field names.
306
+ * This parser looks for display text in multiple possible fields, making it
307
+ * compatible with different JSON response formats.
308
+ *
309
+ * @param textExtractor Optional function to extract display text from parsed JSON.
310
+ * If not provided, looks for common text fields.
311
+ */
312
+ export const createFlexibleJsonStreamParser = (
313
+ textExtractor?: (parsed: any) => string | null
314
+ ): AgentWidgetStreamParser => {
315
+ let extractedText: string | null = null;
316
+ let processedLength = 0;
317
+
318
+ // Default text extractor that handles common patterns
319
+ const defaultExtractor = (parsed: any): string | null => {
320
+ if (!parsed || typeof parsed !== "object") return null;
321
+
322
+ // Check for action-based text fields
323
+ if (parsed.action) {
324
+ switch (parsed.action) {
325
+ case 'nav_then_click':
326
+ return parsed.on_load_text || parsed.text || null;
327
+ case 'message':
328
+ case 'message_and_click':
329
+ case 'checkout':
330
+ return parsed.text || null;
331
+ default:
332
+ return parsed.text || parsed.display_text || parsed.message || null;
333
+ }
334
+ }
335
+
336
+ // Fallback to common text field names
337
+ return parsed.text || parsed.display_text || parsed.message || parsed.content || null;
338
+ };
339
+
340
+ const extractText = textExtractor || defaultExtractor;
341
+
342
+ return {
343
+ getExtractedText: () => extractedText,
344
+ processChunk: (accumulatedContent: string): AgentWidgetStreamParserResult | string | null => {
345
+ // Validate that the accumulated content looks like JSON
346
+ const trimmed = accumulatedContent.trim();
347
+ if (!trimmed.startsWith('{') && !trimmed.startsWith('[')) {
348
+ return null;
349
+ }
350
+
351
+ // Skip if no new content
352
+ if (accumulatedContent.length <= processedLength) {
353
+ return extractedText !== null
354
+ ? { text: extractedText, raw: accumulatedContent }
355
+ : null;
356
+ }
357
+
358
+ try {
359
+ // Parse partial JSON - allow partial strings and objects
360
+ // STR | OBJ allows incomplete strings and objects during streaming
361
+ const parsed = parsePartialJson(accumulatedContent, STR | OBJ);
362
+
363
+ // Extract text using the provided or default extractor
364
+ const newText = extractText(parsed);
365
+ if (newText !== null) {
366
+ extractedText = newText;
367
+ }
368
+ } catch (error) {
369
+ // If parsing fails completely, keep the last extracted text
370
+ // This can happen with very malformed JSON
371
+ }
372
+
373
+ // Update processed length
374
+ processedLength = accumulatedContent.length;
375
+
376
+ // Always return the raw JSON for action parsing
377
+ // Text may be null during early streaming, that's ok
378
+ return {
379
+ text: extractedText || "",
380
+ raw: accumulatedContent
381
+ };
382
+ },
383
+ close: () => {
384
+ // No cleanup needed
385
+ }
386
+ };
387
+ };
388
+
304
389
  /**
305
390
  * XML stream parser.
306
391
  * Extracts text from <text>...</text> tags in XML responses.