Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
# Changelog

## 0.5.2

### Patch Changes

- Build: pin an explicit `es2020` browser transpile target for all bundles (ESM + both IIFE globals) instead of letting tsdown derive a Node target (`node22.0.0`) from `engines`. A Node target is wrong for a browser `<script>`/ESM embed and is exactly the setting that can silently downlevel the smooth-operator protocol client's async generators / `for await` over the streaming `MessageTurn` (`Symbol.asyncIterator`) into regenerator/helper shims — which mangles the streamed chat turn in stricter engines. Pinning a browser target keeps native async iteration intact and keeps the IIFE global bundle byte-faithful to the ESM build on the streaming path. Verified end-to-end against the live prod operator (`wss://ai.smoo.ai/ws`) in headless Chromium: a full grounded turn streams and renders.

## 0.5.1

### Patch Changes
Expand Down
90 changes: 90 additions & 0 deletions e2e/repro-prod.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
/**
* Live-prod reproduction of the global-bundle streaming bug.
*
* Drives the BUILT dist/chat-widget.global.js against the real prod operator
* (wss://ai.smoo.ai/ws, agent 2590dfd6-…) from a page whose Origin is spoofed to
* https://smoo.ai (so the operator's Origin allowlist passes). It mounts the
* widget, opens the panel, types, clicks Send, and asserts a streamed reply
* renders — the exact path that fails in prod with "Connection issue".
*
* Gated on SMOOTH_AGENT_PROD_E2E=1 (hits live prod, no key needed for this agent).
*/
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { expect, test } from '@playwright/test';

const root = fileURLToPath(new URL('..', import.meta.url));
const GLOBAL_BUNDLE = readFileSync(`${root}/dist/chat-widget.global.js`, 'utf8');

const AGENT_ID = '2590dfd6-7ed5-484b-bfb4-6d83a97d5a8e';
const ENDPOINT = 'wss://ai.smoo.ai/ws';
const ORIGIN = 'https://smoo.ai';

const ENABLED = process.env.SMOOTH_AGENT_PROD_E2E === '1';

test('GLOBAL bundle streams against live prod operator', async ({ page }) => {
test.skip(!ENABLED, 'Set SMOOTH_AGENT_PROD_E2E=1 to hit the live prod operator.');

const consoleLines: string[] = [];
page.on('console', (m) => consoleLines.push(`[console:${m.type()}] ${m.text()}`));
page.on('pageerror', (e) => consoleLines.push(`[pageerror] ${e.message}\n${e.stack ?? ''}`));

// Serve a blank document AT the smoo.ai origin so the WS handshake's Origin
// header is https://smoo.ai (operator allowlist).
await page.route(`${ORIGIN}/`, (route) =>
route.fulfill({ status: 200, contentType: 'text/html', body: '<!doctype html><html><head></head><body></body></html>' }),
);
await page.goto(`${ORIGIN}/`);
await page.addScriptTag({ content: GLOBAL_BUNDLE });

const result = await page.evaluate(
async ({ endpoint, agentId }) => {
const out: { error?: string; text?: string; status?: string; sources?: number } = {};
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
try {
// @ts-expect-error injected global
const el = window.SmoothAgentChat.mount({ endpoint, agentId, greeting: '' });
const r = (el as any).shadowRoot as ShadowRoot;
(r.querySelector('.launcher') as HTMLElement | null)?.click();
for (let i = 0; i < 200; i++) {
const s = (r.querySelector('.status-text') as HTMLElement | null)?.textContent ?? '';
if (/ready|online/i.test(s)) break;
await sleep(100);
}
const input = r.querySelector('textarea') as HTMLTextAreaElement | null;
if (!input) return { error: 'no-input' };
input.value = 'What is SmooAI? Answer briefly.';
input.dispatchEvent(new Event('input', { bubbles: true }));
(r.querySelector('.send') as HTMLElement | null)?.click();

for (let i = 0; i < 300; i++) {
const bubbles = Array.from(r.querySelectorAll('.bubble.assistant'));
const text = bubbles.map((b) => (b as HTMLElement).textContent ?? '').join(' ').trim();
// Stop once we have a non-empty, non-error assistant reply.
if (text.length > 0 && !/connection issue|couldn.t reach|^error:/i.test(text)) {
// give the stream a moment to finish
await sleep(800);
break;
}
if (/connection issue|couldn.t reach|^error:/i.test(text)) break;
await sleep(200);
}
const bubbles = Array.from(r.querySelectorAll('.bubble.assistant'));
out.text = bubbles.map((b) => (b as HTMLElement).textContent ?? '').join(' | ').trim();
out.sources = r.querySelectorAll('.sources a, .source').length;
out.status = (r.querySelector('.status-text') as HTMLElement | null)?.textContent ?? '';
return out;
} catch (e: any) {
return { error: `${e?.name}: ${e?.message}\n${e?.stack}` };
}
},
{ endpoint: ENDPOINT, agentId: AGENT_ID },
);

console.log('PROD result:', JSON.stringify(result, null, 2));
console.log('CONSOLE:\n' + consoleLines.join('\n'));

expect(result.error, `error: ${result.error}`).toBeUndefined();
expect(result.text ?? '', 'assistant reply should not be a connection error').not.toMatch(/connection issue|couldn.t reach/i);
expect((result.text ?? '').length, 'assistant reply should be non-empty').toBeGreaterThan(5);
});
146 changes: 146 additions & 0 deletions e2e/repro-stream-mock.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
/**
* Headless reproduction of the global-bundle streaming bug.
*
* We replace window.WebSocket with a deterministic mock that emits the EXACT
* frame sequence the live operator sends for a grounded turn:
* immediate_response{status:202} (create_conversation_session)
* immediate_response{status:202} (send_message ack)
* stream_token x3
* eventual_response{ data.data.{ response.responseParts, citations } }
*
* The test loads the BUILT bundle into a real Chromium page, mounts the widget,
* drives the REAL shadow-DOM UI (open launcher, type, click send), and asserts
* the streamed assistant text + citation render. Both the IIFE global bundle and
* the ESM build are exercised with the same mock to isolate the divergence.
*/
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { expect, test } from '@playwright/test';

const root = fileURLToPath(new URL('..', import.meta.url));
const GLOBAL_BUNDLE = readFileSync(`${root}/dist/chat-widget.global.js`, 'utf8');

const AGENT_ID = '2590dfd6-7ed5-484b-bfb4-6d83a97d5a8e';
const ENDPOINT = 'wss://ai.smoo.ai/ws';

// Mock WebSocket installed before any widget code runs. It parses outbound
// frames, correlates by requestId, and replays the operator's frame sequence.
const MOCK_WS = `
(() => {
const TOKENS = ['Hello', ', this is ', 'a streamed reply.'];
class MockWS {
constructor(url) {
this.url = url;
this.readyState = 0; // CONNECTING
this._listeners = { open: [], message: [], close: [], error: [] };
setTimeout(() => {
this.readyState = 1; // OPEN
this._emit('open', {});
}, 5);
}
addEventListener(type, fn) { (this._listeners[type] ||= []).push(fn); }
removeEventListener(type, fn) {
const a = this._listeners[type]; if (!a) return;
const i = a.indexOf(fn); if (i >= 0) a.splice(i, 1);
}
_emit(type, ev) { for (const fn of (this._listeners[type] || []).slice()) fn(ev); }
_msg(obj) { this._emit('message', { data: JSON.stringify(obj) }); }
send(raw) {
let frame; try { frame = JSON.parse(raw); } catch { return; }
const requestId = frame.requestId;
if (frame.action === 'create_conversation_session') {
this._msg({ type: 'immediate_response', requestId, status: 202,
data: { sessionId: 'sess-mock-1', agentId: frame.agentId } });
return;
}
if (frame.action === 'send_message') {
this._msg({ type: 'immediate_response', requestId, status: 202, data: {} });
let i = 0;
const next = () => {
if (i < TOKENS.length) {
this._msg({ type: 'stream_token', requestId, token: TOKENS[i] });
i++;
setTimeout(next, 5);
} else {
this._msg({ type: 'eventual_response', requestId, status: 200, data: { data: {
response: { responseParts: ['Hello, this is a streamed reply.'] },
citations: [{ id: 'c1', title: 'Knowledge Doc', snippet: 'grounding', score: 0.9, url: 'https://smoo.ai/doc' }],
} } });
}
};
setTimeout(next, 5);
return;
}
}
close() { this.readyState = 3; this._emit('close', { code: 1000, reason: '' }); }
}
MockWS.CONNECTING = 0; MockWS.OPEN = 1; MockWS.CLOSING = 2; MockWS.CLOSED = 3;
window.WebSocket = MockWS;
})();
`;

test('GLOBAL bundle streams a grounded turn end-to-end (real UI)', async ({ page }) => {
const pageErrors: string[] = [];
page.on('pageerror', (e) => pageErrors.push(`${e.name}: ${e.message}\n${e.stack ?? ''}`));
page.on('console', (m) => {
if (m.type() === 'error') pageErrors.push(`console.error: ${m.text()}`);
});

await page.addInitScript(MOCK_WS);
await page.goto('about:blank');
await page.addScriptTag({ content: GLOBAL_BUNDLE });

// Mount + drive the REAL shadow-DOM UI exactly as a visitor would.
const result = await page.evaluate(
async ({ endpoint, agentId }) => {
const out: { error?: string; text?: string; citations?: number; status?: string } = {};
try {
// @ts-expect-error injected global
const el = window.SmoothAgentChat.mount({ endpoint, agentId, greeting: '' });
const root = (el as any).shadowRoot as ShadowRoot;
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));

// Open the panel.
(root.querySelector('.launcher') as HTMLElement | null)?.click();
// Wait for status to reach ready (session created).
for (let i = 0; i < 100; i++) {
const status = (root.querySelector('.status-text') as HTMLElement | null)?.textContent ?? '';
if (/ready|online/i.test(status)) break;
await sleep(50);
}
const input = root.querySelector('textarea') as HTMLTextAreaElement | null;
if (!input) {
out.error = 'no-input';
return out;
}
input.value = 'hi';
input.dispatchEvent(new Event('input', { bubbles: true }));
(root.querySelector('.send') as HTMLElement | null)?.click();

// Wait for the streamed assistant text to settle.
for (let i = 0; i < 100; i++) {
const bubbles = Array.from(root.querySelectorAll('.bubble.assistant'));
const text = bubbles.map((b) => b.textContent ?? '').join(' ');
if (/streamed reply/.test(text)) break;
await sleep(50);
}
const bubbles = Array.from(root.querySelectorAll('.bubble.assistant'));
out.text = bubbles.map((b) => (b as HTMLElement).textContent ?? '').join(' ');
out.citations = root.querySelectorAll('.sources a, .source, .citation').length;
out.status = (root.querySelector('.status-text') as HTMLElement | null)?.textContent ?? '';
return out;
} catch (e: any) {
out.error = `${e?.name}: ${e?.message}\n${e?.stack}`;
return out;
}
},
{ endpoint: ENDPOINT, agentId: AGENT_ID },
);

console.log('GLOBAL result:', JSON.stringify(result, null, 2));
console.log('PAGE ERRORS:', JSON.stringify(pageErrors, null, 2));

expect(pageErrors, `page errors:\n${pageErrors.join('\n---\n')}`).toEqual([]);
expect(result.error, `controller error: ${result.error}`).toBeUndefined();
expect(result.text ?? '').toContain('streamed reply');
});
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@smooai/chat-widget",
"version": "0.5.1",
"version": "0.5.2",
"description": "Embeddable AI chat as a framework-light web component — the Aurora Glass design, streaming replies, grounded sources, and per-brand theming. Speaks the smooth-operator WebSocket protocol.",
"license": "MIT",
"author": "SmooAI",
Expand Down
14 changes: 14 additions & 0 deletions tsdown.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,17 @@ const agentClientEntry = fileURLToPath(
new URL('./node_modules/@smooai/smooth-operator/dist/client.js', import.meta.url),
);

// Explicit BROWSER transpile target for every output. Without this, tsdown
// derives `node22.0.0` from the package `engines` — a *Node* target for what is
// actually a browser `<script>`/ESM bundle. A Node target is the kind of setting
// that can silently downlevel the protocol client's async generators / `for await`
// over the streaming `MessageTurn` (`Symbol.asyncIterator`) into regenerator/helper
// shims, which mangles the streamed chat turn in older or stricter engines. Pinning
// an explicit `es2020` browser target keeps native async iteration intact (es2018+
// has `for await` / async iterators) and matches what the ESM build emits, so the
// IIFE global bundle and the ESM build stay byte-faithful on the streaming path.
const browserTarget = 'es2020';

export default defineConfig([
// ESM library entry — for bundler-based hosts that `import` the widget and
// call `defineChatWidget()` / `mountChatWidget(...)` programmatically. The
Expand All @@ -18,6 +29,7 @@ export default defineConfig([
entry: { index: 'src/index.ts' },
format: ['esm'],
platform: 'browser',
target: browserTarget,
dts: true,
sourcemap: true,
clean: true,
Expand All @@ -31,6 +43,7 @@ export default defineConfig([
entry: { 'chat-widget': 'src/standalone.ts' },
format: ['iife'],
platform: 'browser',
target: browserTarget,
globalName: 'SmoothAgentChat',
deps: { alwaysBundle: [/@smooai\/smooth-operator/] },
alias: {
Expand All @@ -53,6 +66,7 @@ export default defineConfig([
entry: { 'chat-widget-loader': 'src/loader.ts' },
format: ['iife'],
platform: 'browser',
target: browserTarget,
globalName: 'SmoothAgentChatLoader',
dts: false,
sourcemap: true,
Expand Down
Loading