@runtypelabs/persona 3.15.0 → 3.15.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/dist/index.cjs +46 -46
- package/dist/index.cjs.map +1 -1
- package/dist/index.global.js +61 -61
- package/dist/index.global.js.map +1 -1
- package/dist/index.js +46 -46
- package/dist/index.js.map +1 -1
- package/dist/theme-editor.cjs +336 -218
- package/dist/theme-editor.js +336 -218
- package/dist/widget.css +11 -8
- package/package.json +1 -1
- package/src/client.test.ts +361 -0
- package/src/client.ts +183 -156
- package/src/styles/widget.css +11 -8
- package/src/ui.ts +27 -2
- package/src/utils/sequence-buffer.test.ts +256 -0
- package/src/utils/sequence-buffer.ts +130 -0
package/dist/widget.css
CHANGED
|
@@ -1778,10 +1778,6 @@
|
|
|
1778
1778
|
margin-top: 0.5rem;
|
|
1779
1779
|
padding: 0.25rem 0.5rem;
|
|
1780
1780
|
border-top: none;
|
|
1781
|
-
border-radius: var(--persona-radius-md, 0.75rem);
|
|
1782
|
-
background-color: var(--persona-surface, #ffffff);
|
|
1783
|
-
border: 1px solid var(--persona-divider, #f1f5f9);
|
|
1784
|
-
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06);
|
|
1785
1781
|
}
|
|
1786
1782
|
|
|
1787
1783
|
/* Pill alignment in always-visible mode (block flow: use margin to position) */
|
|
@@ -1826,10 +1822,6 @@
|
|
|
1826
1822
|
padding: 0.25rem;
|
|
1827
1823
|
border-top: none;
|
|
1828
1824
|
width: fit-content;
|
|
1829
|
-
background-color: var(--persona-surface, #ffffff);
|
|
1830
|
-
border: 1px solid var(--persona-divider, #f1f5f9);
|
|
1831
|
-
border-radius: var(--persona-radius-md, 0.75rem);
|
|
1832
|
-
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
|
|
1833
1825
|
}
|
|
1834
1826
|
|
|
1835
1827
|
/* Pill layout - position based on alignment */
|
|
@@ -1932,6 +1924,17 @@
|
|
|
1932
1924
|
opacity: 0.9;
|
|
1933
1925
|
}
|
|
1934
1926
|
|
|
1927
|
+
/* Vote pop animation */
|
|
1928
|
+
@keyframes persona-vote-pop {
|
|
1929
|
+
0% { transform: scale(1); }
|
|
1930
|
+
40% { transform: scale(1.25); }
|
|
1931
|
+
100% { transform: scale(1); }
|
|
1932
|
+
}
|
|
1933
|
+
|
|
1934
|
+
.persona-message-action-btn.persona-message-action-pop {
|
|
1935
|
+
animation: persona-vote-pop 0.3s ease;
|
|
1936
|
+
}
|
|
1937
|
+
|
|
1935
1938
|
/* Success state (after copy) */
|
|
1936
1939
|
.persona-message-action-btn.persona-message-action-success {
|
|
1937
1940
|
background-color: #10b981;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@runtypelabs/persona",
|
|
3
|
-
"version": "3.15.
|
|
3
|
+
"version": "3.15.1",
|
|
4
4
|
"description": "Themeable, pluggable 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",
|
package/src/client.test.ts
CHANGED
|
@@ -2130,6 +2130,59 @@ describe('AgentWidgetClient - Out-of-Order Sequence Reordering', () => {
|
|
|
2130
2130
|
expect(lastFinal.message.content).toBe('Hello beautiful world!');
|
|
2131
2131
|
});
|
|
2132
2132
|
|
|
2133
|
+
it('repairs a delayed step_delta that arrives after the gap-timeout flush', async () => {
|
|
2134
|
+
vi.useFakeTimers();
|
|
2135
|
+
const events: AgentWidgetEvent[] = [];
|
|
2136
|
+
|
|
2137
|
+
const client = new AgentWidgetClient({ apiUrl: 'http://localhost:8000' });
|
|
2138
|
+
|
|
2139
|
+
global.fetch = vi.fn().mockImplementation(async () => {
|
|
2140
|
+
const encoder = new TextEncoder();
|
|
2141
|
+
const stream = new ReadableStream({
|
|
2142
|
+
start(controller) {
|
|
2143
|
+
const e = (data: any) => {
|
|
2144
|
+
controller.enqueue(encoder.encode(`data: ${JSON.stringify(data)}\n\n`));
|
|
2145
|
+
};
|
|
2146
|
+
|
|
2147
|
+
e({ type: 'flow_start', flowId: 'f1', flowName: 'Test', totalSteps: 1 });
|
|
2148
|
+
e({ type: 'step_start', id: 's1', name: 'Prompt', stepType: 'prompt', index: 1, totalSteps: 1 });
|
|
2149
|
+
e({ type: 'text_start', partId: 'text_0', messageId: 'msg_1', seq: 1 });
|
|
2150
|
+
e({ type: 'step_delta', id: 's1', text: 'a', partId: 'text_0', messageId: 'msg_1', seq: 2 });
|
|
2151
|
+
// seq=3 is delayed long enough for the reorder buffer to flush seq=4 and seq=5.
|
|
2152
|
+
e({ type: 'step_delta', id: 's1', text: 'c', partId: 'text_0', messageId: 'msg_1', seq: 4 });
|
|
2153
|
+
e({ type: 'text_end', partId: 'text_0', messageId: 'msg_1', seq: 5 });
|
|
2154
|
+
|
|
2155
|
+
setTimeout(() => {
|
|
2156
|
+
e({ type: 'step_delta', id: 's1', text: 'b', partId: 'text_0', messageId: 'msg_1', seq: 3 });
|
|
2157
|
+
}, 60);
|
|
2158
|
+
|
|
2159
|
+
setTimeout(() => {
|
|
2160
|
+
e({ type: 'flow_complete', success: true });
|
|
2161
|
+
controller.close();
|
|
2162
|
+
}, 70);
|
|
2163
|
+
},
|
|
2164
|
+
});
|
|
2165
|
+
return { ok: true, body: stream };
|
|
2166
|
+
});
|
|
2167
|
+
|
|
2168
|
+
const dispatchPromise = client.dispatch({ messages: [] }, (event) => events.push(event));
|
|
2169
|
+
await vi.advanceTimersByTimeAsync(80);
|
|
2170
|
+
await dispatchPromise;
|
|
2171
|
+
vi.useRealTimers();
|
|
2172
|
+
|
|
2173
|
+
const messageEvents = events.filter(
|
|
2174
|
+
(e): e is AgentWidgetEvent & { type: 'message' } => e.type === 'message'
|
|
2175
|
+
);
|
|
2176
|
+
const assistantMessages = messageEvents
|
|
2177
|
+
.filter((e) => e.message.role === 'assistant' && !e.message.variant)
|
|
2178
|
+
.map((e) => e.message);
|
|
2179
|
+
expect(assistantMessages.length).toBeGreaterThan(0);
|
|
2180
|
+
|
|
2181
|
+
const repairedMessage = assistantMessages[assistantMessages.length - 1];
|
|
2182
|
+
expect(repairedMessage.content).toBe('abc');
|
|
2183
|
+
expect(repairedMessage.partId).toBe('text_0');
|
|
2184
|
+
});
|
|
2185
|
+
|
|
2133
2186
|
it('should reorder reason_delta chunks by sequenceIndex', async () => {
|
|
2134
2187
|
const events: AgentWidgetEvent[] = [];
|
|
2135
2188
|
|
|
@@ -2176,6 +2229,62 @@ describe('AgentWidgetClient - Out-of-Order Sequence Reordering', () => {
|
|
|
2176
2229
|
expect(fullReasoning).toBe('I think about this.');
|
|
2177
2230
|
});
|
|
2178
2231
|
|
|
2232
|
+
it('repairs a delayed reason_delta that arrives after the gap-timeout flush', async () => {
|
|
2233
|
+
vi.useFakeTimers();
|
|
2234
|
+
const events: AgentWidgetEvent[] = [];
|
|
2235
|
+
|
|
2236
|
+
const client = new AgentWidgetClient({ apiUrl: 'http://localhost:8000' });
|
|
2237
|
+
|
|
2238
|
+
global.fetch = vi.fn().mockImplementation(async () => {
|
|
2239
|
+
const encoder = new TextEncoder();
|
|
2240
|
+
const stream = new ReadableStream({
|
|
2241
|
+
start(controller) {
|
|
2242
|
+
const e = (data: any) => {
|
|
2243
|
+
controller.enqueue(encoder.encode(`data: ${JSON.stringify(data)}\n\n`));
|
|
2244
|
+
};
|
|
2245
|
+
|
|
2246
|
+
e({ type: 'flow_start', flowId: 'f1', flowName: 'Test', totalSteps: 1 });
|
|
2247
|
+
e({ type: 'step_start', id: 's1', name: 'Prompt', stepType: 'prompt', index: 1, totalSteps: 1 });
|
|
2248
|
+
e({ type: 'reason_start', reasoningId: 'r1', hidden: false, done: false, sequenceIndex: 1 });
|
|
2249
|
+
e({ type: 'reason_delta', reasoningId: 'r1', reasoningText: 'a', hidden: false, done: false, sequenceIndex: 2 });
|
|
2250
|
+
// sequenceIndex=3 is delayed long enough for the gap-timeout to flush sequenceIndex=4
|
|
2251
|
+
e({ type: 'reason_delta', reasoningId: 'r1', reasoningText: 'c', hidden: false, done: false, sequenceIndex: 4 });
|
|
2252
|
+
|
|
2253
|
+
setTimeout(() => {
|
|
2254
|
+
// Late arrival after gap-timeout flush
|
|
2255
|
+
e({ type: 'reason_delta', reasoningId: 'r1', reasoningText: 'b', hidden: false, done: false, sequenceIndex: 3 });
|
|
2256
|
+
}, 60);
|
|
2257
|
+
|
|
2258
|
+
setTimeout(() => {
|
|
2259
|
+
e({ type: 'reason_complete', reasoningId: 'r1', hidden: false, done: true, sequenceIndex: 5 });
|
|
2260
|
+
e({ type: 'step_delta', id: 's1', text: 'Result', partId: 'text_0', sequenceIndex: 6 });
|
|
2261
|
+
e({ type: 'step_complete', id: 's1', name: 'Prompt', success: true });
|
|
2262
|
+
e({ type: 'flow_complete', success: true });
|
|
2263
|
+
controller.close();
|
|
2264
|
+
}, 70);
|
|
2265
|
+
},
|
|
2266
|
+
});
|
|
2267
|
+
return { ok: true, body: stream };
|
|
2268
|
+
});
|
|
2269
|
+
|
|
2270
|
+
const dispatchPromise = client.dispatch({ messages: [] }, (event) => events.push(event));
|
|
2271
|
+
await vi.advanceTimersByTimeAsync(80);
|
|
2272
|
+
await dispatchPromise;
|
|
2273
|
+
vi.useRealTimers();
|
|
2274
|
+
|
|
2275
|
+
const messageEvents = events.filter(
|
|
2276
|
+
(e): e is AgentWidgetEvent & { type: 'message' } => e.type === 'message'
|
|
2277
|
+
);
|
|
2278
|
+
const reasoningMsgs = messageEvents.filter(
|
|
2279
|
+
(e) => e.message.reasoning && e.message.reasoning.chunks.length > 0
|
|
2280
|
+
);
|
|
2281
|
+
expect(reasoningMsgs.length).toBeGreaterThan(0);
|
|
2282
|
+
|
|
2283
|
+
const lastReasoning = reasoningMsgs[reasoningMsgs.length - 1];
|
|
2284
|
+
const fullReasoning = lastReasoning.message.reasoning!.chunks.join('');
|
|
2285
|
+
expect(fullReasoning).toBe('abc');
|
|
2286
|
+
});
|
|
2287
|
+
|
|
2179
2288
|
it('should handle step_delta without seq gracefully (no reordering)', async () => {
|
|
2180
2289
|
const events: AgentWidgetEvent[] = [];
|
|
2181
2290
|
|
|
@@ -2213,5 +2322,257 @@ describe('AgentWidgetClient - Out-of-Order Sequence Reordering', () => {
|
|
|
2213
2322
|
const lastFinal = finalMessages[finalMessages.length - 1];
|
|
2214
2323
|
expect(lastFinal.message.content).toBe('Hello world!');
|
|
2215
2324
|
});
|
|
2325
|
+
|
|
2326
|
+
it('should handle leading-gap arrival (first event is not seq=1)', async () => {
|
|
2327
|
+
const events: AgentWidgetEvent[] = [];
|
|
2328
|
+
|
|
2329
|
+
const client = new AgentWidgetClient({ apiUrl: 'http://localhost:8000' });
|
|
2330
|
+
|
|
2331
|
+
global.fetch = vi.fn().mockImplementation(async () => {
|
|
2332
|
+
const encoder = new TextEncoder();
|
|
2333
|
+
const stream = new ReadableStream({
|
|
2334
|
+
start(controller) {
|
|
2335
|
+
const e = (data: any) => {
|
|
2336
|
+
controller.enqueue(encoder.encode(`data: ${JSON.stringify(data)}\n\n`));
|
|
2337
|
+
};
|
|
2338
|
+
e({ type: 'flow_start', flowId: 'f1', flowName: 'Test', totalSteps: 1 });
|
|
2339
|
+
e({ type: 'step_start', id: 's1', name: 'Prompt', stepType: 'prompt', index: 1, totalSteps: 1 });
|
|
2340
|
+
// seq=3 arrives first (leading gap — seq 1 and 2 arrive later)
|
|
2341
|
+
e({ type: 'step_delta', id: 's1', text: 'c', partId: 'text_0', seq: 3 });
|
|
2342
|
+
e({ type: 'step_delta', id: 's1', text: 'a', partId: 'text_0', seq: 1 });
|
|
2343
|
+
e({ type: 'step_delta', id: 's1', text: 'b', partId: 'text_0', seq: 2 });
|
|
2344
|
+
e({ type: 'step_complete', id: 's1', name: 'Prompt', success: true });
|
|
2345
|
+
e({ type: 'flow_complete', success: true });
|
|
2346
|
+
controller.close();
|
|
2347
|
+
},
|
|
2348
|
+
});
|
|
2349
|
+
return { ok: true, body: stream };
|
|
2350
|
+
});
|
|
2351
|
+
|
|
2352
|
+
await client.dispatch({ messages: [] }, (event) => events.push(event));
|
|
2353
|
+
|
|
2354
|
+
const messageEvents = events.filter(
|
|
2355
|
+
(e): e is AgentWidgetEvent & { type: 'message' } => e.type === 'message'
|
|
2356
|
+
);
|
|
2357
|
+
const finalMessages = messageEvents.filter((e) => !e.message.streaming);
|
|
2358
|
+
expect(finalMessages.length).toBeGreaterThan(0);
|
|
2359
|
+
|
|
2360
|
+
const lastFinal = finalMessages[finalMessages.length - 1];
|
|
2361
|
+
// Must be in seq order, not arrival order
|
|
2362
|
+
expect(lastFinal.message.content).toBe('abc');
|
|
2363
|
+
});
|
|
2364
|
+
|
|
2365
|
+
it('should handle mixed seq + sequenceIndex in one stream', async () => {
|
|
2366
|
+
const events: AgentWidgetEvent[] = [];
|
|
2367
|
+
|
|
2368
|
+
const client = new AgentWidgetClient({ apiUrl: 'http://localhost:8000' });
|
|
2369
|
+
|
|
2370
|
+
global.fetch = vi.fn().mockImplementation(async () => {
|
|
2371
|
+
const encoder = new TextEncoder();
|
|
2372
|
+
const stream = new ReadableStream({
|
|
2373
|
+
start(controller) {
|
|
2374
|
+
const e = (data: any) => {
|
|
2375
|
+
controller.enqueue(encoder.encode(`data: ${JSON.stringify(data)}\n\n`));
|
|
2376
|
+
};
|
|
2377
|
+
e({ type: 'flow_start', flowId: 'f1', flowName: 'Test', totalSteps: 1 });
|
|
2378
|
+
e({ type: 'step_start', id: 's1', name: 'Prompt', stepType: 'prompt', index: 1, totalSteps: 1 });
|
|
2379
|
+
// reason_delta uses sequenceIndex, step_delta uses seq — same counter
|
|
2380
|
+
e({ type: 'reason_start', reasoningId: 'r1', hidden: false, done: false });
|
|
2381
|
+
e({ type: 'reason_delta', reasoningId: 'r1', reasoningText: 'thinking', hidden: false, done: false, sequenceIndex: 1 });
|
|
2382
|
+
e({ type: 'reason_complete', reasoningId: 'r1', hidden: false, done: true });
|
|
2383
|
+
// step_delta seq=2 continues from the same counter
|
|
2384
|
+
e({ type: 'step_delta', id: 's1', text: 'Result', partId: 'text_0', seq: 2 });
|
|
2385
|
+
e({ type: 'step_complete', id: 's1', name: 'Prompt', success: true });
|
|
2386
|
+
e({ type: 'flow_complete', success: true });
|
|
2387
|
+
controller.close();
|
|
2388
|
+
},
|
|
2389
|
+
});
|
|
2390
|
+
return { ok: true, body: stream };
|
|
2391
|
+
});
|
|
2392
|
+
|
|
2393
|
+
await client.dispatch({ messages: [] }, (event) => events.push(event));
|
|
2394
|
+
|
|
2395
|
+
const messageEvents = events.filter(
|
|
2396
|
+
(e): e is AgentWidgetEvent & { type: 'message' } => e.type === 'message'
|
|
2397
|
+
);
|
|
2398
|
+
// Should have both reasoning and text messages, properly ordered
|
|
2399
|
+
const reasoningMsgs = messageEvents.filter(e => e.message.reasoning?.chunks?.length);
|
|
2400
|
+
expect(reasoningMsgs.length).toBeGreaterThan(0);
|
|
2401
|
+
expect(reasoningMsgs[reasoningMsgs.length - 1].message.reasoning!.chunks.join('')).toBe('thinking');
|
|
2402
|
+
|
|
2403
|
+
const textMsgs = messageEvents.filter(e => e.message.role === 'assistant' && !e.message.variant && e.message.content);
|
|
2404
|
+
expect(textMsgs.length).toBeGreaterThan(0);
|
|
2405
|
+
expect(textMsgs[textMsgs.length - 1].message.content).toContain('Result');
|
|
2406
|
+
});
|
|
2407
|
+
|
|
2408
|
+
it('should handle cross-event buffering around tool events', async () => {
|
|
2409
|
+
const events: AgentWidgetEvent[] = [];
|
|
2410
|
+
|
|
2411
|
+
const client = new AgentWidgetClient({ apiUrl: 'http://localhost:8000' });
|
|
2412
|
+
|
|
2413
|
+
global.fetch = vi.fn().mockImplementation(async () => {
|
|
2414
|
+
const encoder = new TextEncoder();
|
|
2415
|
+
const stream = new ReadableStream({
|
|
2416
|
+
start(controller) {
|
|
2417
|
+
const e = (data: any) => {
|
|
2418
|
+
controller.enqueue(encoder.encode(`data: ${JSON.stringify(data)}\n\n`));
|
|
2419
|
+
};
|
|
2420
|
+
e({ type: 'flow_start', flowId: 'f1', flowName: 'Test', totalSteps: 1 });
|
|
2421
|
+
e({ type: 'step_start', id: 's1', name: 'Prompt', stepType: 'prompt', index: 1, totalSteps: 1 });
|
|
2422
|
+
// text_start and step_delta with seq, then tool events (no seq), then more text
|
|
2423
|
+
e({ type: 'text_start', partId: 'text_0', messageId: 'msg_1', seq: 1 });
|
|
2424
|
+
e({ type: 'step_delta', id: 's1', text: 'Before tool ', partId: 'text_0', seq: 2 });
|
|
2425
|
+
e({ type: 'text_end', partId: 'text_0', messageId: 'msg_1', seq: 3 });
|
|
2426
|
+
// Tool events don't carry top-level seq in non-agent flows
|
|
2427
|
+
e({ type: 'tool_start', toolCallId: 'tc1', name: 'fetch', parameters: {} });
|
|
2428
|
+
e({ type: 'tool_complete', toolCallId: 'tc1', name: 'fetch', result: { data: 'ok' }, executionTime: 100 });
|
|
2429
|
+
e({ type: 'text_start', partId: 'text_1', messageId: 'msg_1', seq: 4 });
|
|
2430
|
+
e({ type: 'step_delta', id: 's1', text: 'after tool', partId: 'text_1', seq: 5 });
|
|
2431
|
+
e({ type: 'text_end', partId: 'text_1', messageId: 'msg_1', seq: 6 });
|
|
2432
|
+
e({ type: 'step_complete', id: 's1', name: 'Prompt', success: true });
|
|
2433
|
+
e({ type: 'flow_complete', success: true });
|
|
2434
|
+
controller.close();
|
|
2435
|
+
},
|
|
2436
|
+
});
|
|
2437
|
+
return { ok: true, body: stream };
|
|
2438
|
+
});
|
|
2439
|
+
|
|
2440
|
+
await client.dispatch({ messages: [] }, (event) => events.push(event));
|
|
2441
|
+
|
|
2442
|
+
const messageEvents = events.filter(
|
|
2443
|
+
(e): e is AgentWidgetEvent & { type: 'message' } => e.type === 'message'
|
|
2444
|
+
);
|
|
2445
|
+
// Should have text content from both segments
|
|
2446
|
+
const allContent = messageEvents
|
|
2447
|
+
.filter(e => e.message.role === 'assistant' && !e.message.variant)
|
|
2448
|
+
.map(e => e.message.content);
|
|
2449
|
+
const combinedContent = allContent.join('');
|
|
2450
|
+
expect(combinedContent).toContain('Before tool ');
|
|
2451
|
+
expect(combinedContent).toContain('after tool');
|
|
2452
|
+
});
|
|
2453
|
+
|
|
2454
|
+
it('delivers sequenced events still buffered when the stream closes', async () => {
|
|
2455
|
+
// Regression: if the SSE stream ends while the reorder buffer is still
|
|
2456
|
+
// waiting for a missing seq number, previously those events were silently
|
|
2457
|
+
// dropped (destroy() cancelled the gap timer without flushing). The fix
|
|
2458
|
+
// is an end-of-stream flush + drain; this test guards against regression.
|
|
2459
|
+
const events: AgentWidgetEvent[] = [];
|
|
2460
|
+
|
|
2461
|
+
const client = new AgentWidgetClient({ apiUrl: 'http://localhost:8000' });
|
|
2462
|
+
|
|
2463
|
+
global.fetch = vi.fn().mockImplementation(async () => {
|
|
2464
|
+
const encoder = new TextEncoder();
|
|
2465
|
+
const stream = new ReadableStream({
|
|
2466
|
+
start(controller) {
|
|
2467
|
+
const e = (data: any) => {
|
|
2468
|
+
controller.enqueue(encoder.encode(`data: ${JSON.stringify(data)}\n\n`));
|
|
2469
|
+
};
|
|
2470
|
+
e({ type: 'flow_start', flowId: 'f1', flowName: 'Test', totalSteps: 1 });
|
|
2471
|
+
e({ type: 'step_start', id: 's1', name: 'Prompt', stepType: 'prompt', index: 1, totalSteps: 1 });
|
|
2472
|
+
// Only a seq=3 event arrives — seq=1 and seq=2 are never delivered.
|
|
2473
|
+
// Without the end-of-stream flush, this event would be stranded in
|
|
2474
|
+
// the reorder buffer and never emitted.
|
|
2475
|
+
e({ type: 'step_delta', id: 's1', text: 'tail', partId: 'text_0', seq: 3 });
|
|
2476
|
+
// Stream closes immediately, well inside the 50ms gap timer window.
|
|
2477
|
+
controller.close();
|
|
2478
|
+
},
|
|
2479
|
+
});
|
|
2480
|
+
return { ok: true, body: stream };
|
|
2481
|
+
});
|
|
2482
|
+
|
|
2483
|
+
await client.dispatch({ messages: [] }, (event) => events.push(event));
|
|
2484
|
+
|
|
2485
|
+
const messageEvents = events.filter(
|
|
2486
|
+
(e): e is AgentWidgetEvent & { type: 'message' } => e.type === 'message'
|
|
2487
|
+
);
|
|
2488
|
+
const assistantContent = messageEvents
|
|
2489
|
+
.filter((e) => e.message.role === 'assistant' && !e.message.variant)
|
|
2490
|
+
.map((e) => e.message.content)
|
|
2491
|
+
.join('');
|
|
2492
|
+
|
|
2493
|
+
expect(assistantContent).toContain('tail');
|
|
2494
|
+
});
|
|
2495
|
+
|
|
2496
|
+
it('drains timer-flushed sequenced events before the next SSE chunk arrives', async () => {
|
|
2497
|
+
vi.useFakeTimers();
|
|
2498
|
+
const events: AgentWidgetEvent[] = [];
|
|
2499
|
+
|
|
2500
|
+
const client = new AgentWidgetClient({ apiUrl: 'http://localhost:8000' });
|
|
2501
|
+
|
|
2502
|
+
global.fetch = vi.fn().mockImplementation(async () => {
|
|
2503
|
+
const encoder = new TextEncoder();
|
|
2504
|
+
const stream = new ReadableStream({
|
|
2505
|
+
start(controller) {
|
|
2506
|
+
const e = (data: any) => {
|
|
2507
|
+
controller.enqueue(encoder.encode(`data: ${JSON.stringify(data)}\n\n`));
|
|
2508
|
+
};
|
|
2509
|
+
e({ type: 'flow_start', flowId: 'f1', flowName: 'Test', totalSteps: 1 });
|
|
2510
|
+
e({ type: 'step_start', id: 's1', name: 'Prompt', stepType: 'prompt', index: 1, totalSteps: 1 });
|
|
2511
|
+
e({ type: 'step_delta', id: 's1', text: 'a', partId: 'text_0', seq: 1 });
|
|
2512
|
+
// seq=3 buffers while seq=2 is missing.
|
|
2513
|
+
e({ type: 'step_delta', id: 's1', text: 'c', partId: 'text_0', seq: 3 });
|
|
2514
|
+
|
|
2515
|
+
// Keep the stream open without delivering another SSE event until after
|
|
2516
|
+
// the gap timeout has fired. The buffered seq=3 event should still render.
|
|
2517
|
+
setTimeout(() => {
|
|
2518
|
+
e({ type: 'flow_complete', success: true });
|
|
2519
|
+
controller.close();
|
|
2520
|
+
}, 120);
|
|
2521
|
+
},
|
|
2522
|
+
});
|
|
2523
|
+
return { ok: true, body: stream };
|
|
2524
|
+
});
|
|
2525
|
+
|
|
2526
|
+
const dispatchPromise = client.dispatch({ messages: [] }, (event) => events.push(event));
|
|
2527
|
+
|
|
2528
|
+
await vi.advanceTimersByTimeAsync(60);
|
|
2529
|
+
|
|
2530
|
+
const messageEventsDuringPause = events.filter(
|
|
2531
|
+
(e): e is AgentWidgetEvent & { type: 'message' } => e.type === 'message'
|
|
2532
|
+
);
|
|
2533
|
+
const assistantContentDuringPause = messageEventsDuringPause
|
|
2534
|
+
.filter((e) => e.message.role === 'assistant' && !e.message.variant)
|
|
2535
|
+
.map((e) => e.message.content)
|
|
2536
|
+
.join('');
|
|
2537
|
+
|
|
2538
|
+
expect(assistantContentDuringPause).toContain('ac');
|
|
2539
|
+
|
|
2540
|
+
await vi.advanceTimersByTimeAsync(70);
|
|
2541
|
+
await dispatchPromise;
|
|
2542
|
+
vi.useRealTimers();
|
|
2543
|
+
});
|
|
2544
|
+
|
|
2545
|
+
it('delivers a buffered error event when the stream closes mid-gap', async () => {
|
|
2546
|
+
// Regression: an error event with seq > 1 arriving right before the
|
|
2547
|
+
// stream closes was being swallowed by the reorder buffer, leaving the
|
|
2548
|
+
// widget stuck in a streaming state with no error surfaced.
|
|
2549
|
+
const events: AgentWidgetEvent[] = [];
|
|
2550
|
+
|
|
2551
|
+
const client = new AgentWidgetClient({ apiUrl: 'http://localhost:8000' });
|
|
2552
|
+
|
|
2553
|
+
global.fetch = vi.fn().mockImplementation(async () => {
|
|
2554
|
+
const encoder = new TextEncoder();
|
|
2555
|
+
const stream = new ReadableStream({
|
|
2556
|
+
start(controller) {
|
|
2557
|
+
const e = (data: any) => {
|
|
2558
|
+
controller.enqueue(encoder.encode(`data: ${JSON.stringify(data)}\n\n`));
|
|
2559
|
+
};
|
|
2560
|
+
e({ type: 'flow_start', flowId: 'f1', flowName: 'Test', totalSteps: 1 });
|
|
2561
|
+
// Only sequenced event — but seq > 1 so it would be buffered.
|
|
2562
|
+
e({ type: 'error', error: 'boom', seq: 2 });
|
|
2563
|
+
controller.close();
|
|
2564
|
+
},
|
|
2565
|
+
});
|
|
2566
|
+
return { ok: true, body: stream };
|
|
2567
|
+
});
|
|
2568
|
+
|
|
2569
|
+
await client.dispatch({ messages: [] }, (event) => events.push(event));
|
|
2570
|
+
|
|
2571
|
+
const errorEvents = events.filter((e) => e.type === 'error');
|
|
2572
|
+
expect(errorEvents.length).toBe(1);
|
|
2573
|
+
if (errorEvents[0].type === 'error') {
|
|
2574
|
+
expect(errorEvents[0].error.message).toBe('boom');
|
|
2575
|
+
}
|
|
2576
|
+
});
|
|
2216
2577
|
});
|
|
2217
2578
|
|