@runtypelabs/persona 3.37.0 → 4.0.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 (50) hide show
  1. package/README.md +0 -1
  2. package/dist/animations/glyph-cycle.d.cts +1 -1
  3. package/dist/animations/glyph-cycle.d.ts +1 -1
  4. package/dist/animations/{types-DgYsuwXL.d.cts → types-B_xbfvR0.d.cts} +263 -181
  5. package/dist/animations/{types-DgYsuwXL.d.ts → types-B_xbfvR0.d.ts} +263 -181
  6. package/dist/animations/wipe.d.cts +1 -1
  7. package/dist/animations/wipe.d.ts +1 -1
  8. package/dist/codegen.cjs +1 -1
  9. package/dist/codegen.js +1 -1
  10. package/dist/index.cjs +52 -52
  11. package/dist/index.cjs.map +1 -1
  12. package/dist/index.d.cts +327 -838
  13. package/dist/index.d.ts +327 -838
  14. package/dist/index.global.js +39 -39
  15. package/dist/index.global.js.map +1 -1
  16. package/dist/index.js +52 -52
  17. package/dist/index.js.map +1 -1
  18. package/dist/install.global.js +1 -1
  19. package/dist/install.global.js.map +1 -1
  20. package/dist/smart-dom-reader.d.cts +268 -191
  21. package/dist/smart-dom-reader.d.ts +268 -191
  22. package/dist/theme-editor-preview.cjs +53 -53
  23. package/dist/theme-editor-preview.d.cts +268 -191
  24. package/dist/theme-editor-preview.d.ts +268 -191
  25. package/dist/theme-editor-preview.js +55 -55
  26. package/dist/theme-editor.d.cts +268 -191
  27. package/dist/theme-editor.d.ts +268 -191
  28. package/package.json +1 -1
  29. package/src/client.test.ts +446 -1041
  30. package/src/client.ts +684 -967
  31. package/src/components/tool-bubble.ts +46 -33
  32. package/src/generated/runtype-openapi-contract.ts +210 -714
  33. package/src/index-core.ts +2 -3
  34. package/src/install.test.ts +1 -34
  35. package/src/install.ts +1 -26
  36. package/src/runtime/init.test.ts +8 -67
  37. package/src/runtime/init.ts +2 -17
  38. package/src/session.test.ts +8 -8
  39. package/src/session.ts +1 -1
  40. package/src/types.ts +11 -12
  41. package/src/ui.postprocess.test.ts +107 -0
  42. package/src/ui.ts +40 -5
  43. package/src/utils/__fixtures__/unified-translator.oracle.ts +62 -14
  44. package/src/utils/copy-selection.test.ts +37 -0
  45. package/src/utils/copy-selection.ts +19 -0
  46. package/src/utils/event-stream-capture.test.ts +9 -64
  47. package/src/utils/sequence-buffer.test.ts +0 -256
  48. package/src/utils/sequence-buffer.ts +0 -130
  49. package/src/utils/unified-event-bridge.test.ts +0 -263
  50. package/src/utils/unified-event-bridge.ts +0 -431
@@ -3,6 +3,7 @@ import { AgentWidgetClient, preferFinalStructuredContent } from './client';
3
3
  import { AgentWidgetEvent, AgentWidgetMessage } from './types';
4
4
  import { createJsonStreamParser } from './utils/formatting';
5
5
  import { VERSION } from './version';
6
+ import { createUnifiedEventWrite } from './utils/__fixtures__/unified-translator.oracle';
6
7
 
7
8
  describe('AgentWidgetClient - Empty Message Filtering', () => {
8
9
  let client: AgentWidgetClient;
@@ -468,22 +469,17 @@ describe('AgentWidgetClient - JSON Streaming', () => {
468
469
  'data: {"type":"flow_complete","flowId":"flow_01k9pfnztzfag9tfz4t65c9c5q","success":true,"duration":2968,"completedAt":"2025-11-12T23:47:42.234Z","totalTokensUsed":0}'
469
470
  ];
470
471
 
471
- // Create a ReadableStream from the SSE events
472
- const encoder = new TextEncoder();
473
- const stream = new ReadableStream({
474
- start(controller) {
475
- for (const event of sseEvents) {
476
- controller.enqueue(encoder.encode(event + '\n'));
477
- }
478
- controller.close();
479
- }
480
- });
481
-
482
- // Mock fetch to return our stream
483
- global.fetch = async () => ({
484
- ok: true,
485
- body: stream
486
- }) as any;
472
+ // Route the legacy step_chunk fixtures through the oracle as the 4.0 unified
473
+ // wire (step_chunk step_delta → text_delta), exercising the structured
474
+ // JSON parser on the unified flow path: incremental text extraction, never
475
+ // showing raw JSON, with the assembled response reconciled at step_complete.
476
+ global.fetch = createRawStreamFetch(
477
+ toUnifiedFrames(
478
+ sseEvents
479
+ .filter((f) => f.startsWith('data:'))
480
+ .map((f) => f.replace('"type":"step_chunk"', '"type":"step_delta"') + '\n\n')
481
+ )
482
+ );
487
483
 
488
484
  // Dispatch and collect events
489
485
  await client.dispatch(
@@ -584,16 +580,28 @@ function sseEvent(eventType: string, data: Record<string, unknown>): string {
584
580
  }
585
581
 
586
582
  /**
587
- * Helper to create a mock fetch that returns an SSE stream
583
+ * Re-encode legacy `agent_*` / `flow_*` / `step_*` / `tool_*` SSE frames into the
584
+ * neutral UNIFIED wire the 4.0 API now emits, using the same encoder the API uses
585
+ * (the vendored `createUnifiedEventWrite` oracle). The 4.0 widget only consumes the
586
+ * unified vocabulary, so these handler tests author the rendering intent in the
587
+ * (more readable) legacy frames and inject exactly what the client sees off the
588
+ * wire — the bridge translates it straight back before the dispatch chain renders.
588
589
  */
589
- function createAgentStreamFetch(events: string[]) {
590
- return vi.fn().mockImplementation(async (_url: string, _options: any) => {
590
+ function toUnifiedFrames(legacyFrames: string[]): string[] {
591
+ const out: string[] = [];
592
+ const write = createUnifiedEventWrite((chunk) => out.push(chunk));
593
+ for (const frame of legacyFrames) write(frame);
594
+ return out;
595
+ }
596
+
597
+ /** Stream pre-built SSE frames verbatim (no re-encode) — for fixtures already in
598
+ * the unified vocabulary. */
599
+ function createRawStreamFetch(frames: string[]) {
600
+ return vi.fn().mockImplementation(async (_url?: string, _options?: any) => {
591
601
  const encoder = new TextEncoder();
592
602
  const stream = new ReadableStream({
593
603
  start(controller) {
594
- for (const event of events) {
595
- controller.enqueue(encoder.encode(event));
596
- }
604
+ for (const frame of frames) controller.enqueue(encoder.encode(frame));
597
605
  controller.close();
598
606
  }
599
607
  });
@@ -601,6 +609,13 @@ function createAgentStreamFetch(events: string[]) {
601
609
  });
602
610
  }
603
611
 
612
+ /**
613
+ * Mock fetch that streams the given legacy events as the 4.0 unified wire.
614
+ */
615
+ function createAgentStreamFetch(events: string[]) {
616
+ return createRawStreamFetch(toUnifiedFrames(events));
617
+ }
618
+
604
619
  describe('AgentWidgetClient - Agent Mode Detection', () => {
605
620
  it('should detect agent mode when agent config is provided', () => {
606
621
  const client = new AgentWidgetClient({
@@ -1224,12 +1239,12 @@ describe('AgentWidgetClient - Agent Event Streaming', () => {
1224
1239
  }
1225
1240
  }
1226
1241
 
1227
- // Should have a reflection message
1242
+ // Reflection now folds into a loop-scoped reasoning bubble (unified spec):
1243
+ // `agent_reflection` → reasoning_start{scope:"loop"} + reasoning_complete{text}.
1228
1244
  const reflectionMessages = Array.from(messagesById.values())
1229
- .filter(m => m.variant === 'reasoning' && m.id.includes('reflection'));
1245
+ .filter(m => m.variant === 'reasoning' && m.reasoning?.scope === 'loop');
1230
1246
  expect(reflectionMessages.length).toBe(1);
1231
- expect(reflectionMessages[0].content).toBe('I should try a different approach.');
1232
- expect(reflectionMessages[0].reasoning?.chunks).toContain('I should try a different approach.');
1247
+ expect(reflectionMessages[0].reasoning?.chunks.join('')).toBe('I should try a different approach.');
1233
1248
  });
1234
1249
 
1235
1250
  it('should handle agent_ping events gracefully', async () => {
@@ -1284,259 +1299,29 @@ describe('AgentWidgetClient - Agent Event Streaming', () => {
1284
1299
  // Unified Event Name Support (chunk → delta, agent_tool_* → tool_* with agentContext)
1285
1300
  // ============================================================================
1286
1301
 
1287
- describe('AgentWidgetClient - Unified Event Names', () => {
1288
- it('should handle step_delta as alias for step_chunk', async () => {
1289
- const events: AgentWidgetEvent[] = [];
1290
-
1291
- const encoder = new TextEncoder();
1292
- global.fetch = vi.fn().mockImplementation(async () => {
1293
- const stream = new ReadableStream({
1294
- start(controller) {
1295
- controller.enqueue(encoder.encode('data: {"type":"flow_start","flowId":"f1","flowName":"Test","totalSteps":1}\n\n'));
1296
- controller.enqueue(encoder.encode('data: {"type":"step_start","id":"s1","name":"Prompt","stepType":"prompt","index":1,"totalSteps":1}\n\n'));
1297
- controller.enqueue(encoder.encode('data: {"type":"step_delta","id":"s1","name":"Prompt","executionType":"prompt","text":"Hello "}\n\n'));
1298
- controller.enqueue(encoder.encode('data: {"type":"step_delta","id":"s1","name":"Prompt","executionType":"prompt","text":"world"}\n\n'));
1299
- controller.enqueue(encoder.encode('data: {"type":"step_complete","id":"s1","name":"Prompt"}\n\n'));
1300
- controller.enqueue(encoder.encode('data: {"type":"flow_complete","success":true}\n\n'));
1301
- controller.close();
1302
- }
1303
- });
1304
- return { ok: true, body: stream };
1305
- });
1306
-
1307
- const client = new AgentWidgetClient({ apiUrl: 'http://localhost:8000' });
1308
- await client.dispatch(
1309
- { messages: [{ id: 'usr_1', role: 'user', content: 'Hi', createdAt: new Date().toISOString() }] },
1310
- (event) => events.push(event)
1311
- );
1312
-
1313
- const messageEvents = events.filter(e => e.type === 'message');
1314
- const assistantMessages = messageEvents
1315
- .map(e => e.type === 'message' ? e.message : null)
1316
- .filter((m): m is AgentWidgetMessage => m !== null && m.role === 'assistant' && !m.variant);
1317
- expect(assistantMessages.length).toBeGreaterThan(0);
1318
- const final = assistantMessages[assistantMessages.length - 1];
1319
- expect(final.content).toContain('Hello ');
1320
- expect(final.content).toContain('world');
1321
- });
1322
-
1323
- it('should handle tool_delta as alias for tool_chunk', async () => {
1324
- const events: AgentWidgetEvent[] = [];
1325
-
1326
- const encoder = new TextEncoder();
1327
- global.fetch = vi.fn().mockImplementation(async () => {
1328
- const stream = new ReadableStream({
1329
- start(controller) {
1330
- controller.enqueue(encoder.encode('data: {"type":"flow_start","flowId":"f1","flowName":"Test","totalSteps":1}\n\n'));
1331
- controller.enqueue(encoder.encode('data: {"type":"tool_start","toolCallId":"tc_1","toolName":"search"}\n\n'));
1332
- controller.enqueue(encoder.encode('data: {"type":"tool_delta","toolCallId":"tc_1","delta":"Searching..."}\n\n'));
1333
- controller.enqueue(encoder.encode('data: {"type":"tool_complete","toolCallId":"tc_1","toolName":"search","result":{"found":true},"duration":100}\n\n'));
1334
- controller.enqueue(encoder.encode('data: {"type":"flow_complete","success":true}\n\n'));
1335
- controller.close();
1336
- }
1337
- });
1338
- return { ok: true, body: stream };
1339
- });
1340
-
1341
- const client = new AgentWidgetClient({ apiUrl: 'http://localhost:8000' });
1342
- await client.dispatch(
1343
- { messages: [{ id: 'usr_1', role: 'user', content: 'Search', createdAt: new Date().toISOString() }] },
1344
- (event) => events.push(event)
1345
- );
1346
-
1347
- const messageEvents = events.filter(e => e.type === 'message');
1348
- const messagesById = new Map<string, AgentWidgetMessage>();
1349
- for (const event of messageEvents) {
1350
- if (event.type === 'message') messagesById.set(event.message.id, event.message);
1351
- }
1352
-
1353
- const toolMessages = Array.from(messagesById.values()).filter(m => m.variant === 'tool');
1354
- expect(toolMessages.length).toBe(1);
1355
- expect(toolMessages[0].toolCall?.name).toBe('search');
1356
- expect(toolMessages[0].toolCall?.chunks).toContain('Searching...');
1357
- expect(toolMessages[0].toolCall?.status).toBe('complete');
1358
- });
1359
-
1360
- it('should handle tool_start with agentContext (unified agent tool events)', async () => {
1361
- const events: AgentWidgetEvent[] = [];
1362
- const execId = 'exec_unified_1';
1363
-
1364
- global.fetch = createAgentStreamFetch([
1365
- sseEvent('agent_start', {
1366
- executionId: execId, agentId: 'virtual', agentName: 'Test',
1367
- maxTurns: 1, startedAt: new Date().toISOString(), seq: 1,
1368
- }),
1369
- sseEvent('agent_iteration_start', {
1370
- executionId: execId, iteration: 1, maxTurns: 1,
1371
- startedAt: new Date().toISOString(), seq: 2,
1372
- }),
1373
- sseEvent('tool_start', {
1374
- toolCallId: 'tc_1', toolName: 'search', parameters: { query: 'weather' },
1375
- agentContext: { executionId: execId, iteration: 1, seq: 3 },
1376
- }),
1377
- sseEvent('tool_delta', {
1378
- toolCallId: 'tc_1', delta: 'Searching...',
1379
- agentContext: { executionId: execId, iteration: 1, seq: 4 },
1380
- }),
1381
- sseEvent('tool_complete', {
1382
- toolCallId: 'tc_1', toolName: 'search', result: { temperature: 72 },
1383
- executionTime: 150,
1384
- agentContext: { executionId: execId, iteration: 1, seq: 5 },
1385
- }),
1386
- sseEvent('agent_turn_delta', {
1387
- executionId: execId, iteration: 1, delta: 'The weather is 72F.',
1388
- contentType: 'text', turnId: 'turn_1', seq: 6,
1389
- }),
1390
- sseEvent('agent_complete', {
1391
- executionId: execId, agentId: 'virtual', success: true,
1392
- iterations: 1, stopReason: 'max_iterations',
1393
- completedAt: new Date().toISOString(), seq: 7,
1394
- }),
1395
- ]);
1396
-
1397
- const client = new AgentWidgetClient({
1398
- apiUrl: 'http://localhost:8000',
1399
- agent: { name: 'Test', model: 'openai:gpt-4o-mini', systemPrompt: 'test' },
1400
- });
1401
-
1402
- await client.dispatch(
1403
- { messages: [{ id: 'usr_1', role: 'user', content: 'Weather?', createdAt: new Date().toISOString() }] },
1404
- (event) => events.push(event)
1405
- );
1406
-
1407
- const messageEvents = events.filter(e => e.type === 'message');
1408
- const messagesById = new Map<string, AgentWidgetMessage>();
1409
- for (const event of messageEvents) {
1410
- if (event.type === 'message') messagesById.set(event.message.id, event.message);
1411
- }
1412
-
1413
- const toolMessages = Array.from(messagesById.values()).filter(m => m.variant === 'tool');
1414
- expect(toolMessages.length).toBe(1);
1415
- expect(toolMessages[0].toolCall?.name).toBe('search');
1416
- expect(toolMessages[0].toolCall?.status).toBe('complete');
1417
- expect(toolMessages[0].toolCall?.result).toEqual({ temperature: 72 });
1418
- expect(toolMessages[0].toolCall?.durationMs).toBe(150);
1419
- expect(toolMessages[0].agentMetadata?.executionId).toBe(execId);
1420
- expect(toolMessages[0].agentMetadata?.iteration).toBe(1);
1421
-
1422
- const assistantMessages = Array.from(messagesById.values())
1423
- .filter(m => m.role === 'assistant' && !m.variant);
1424
- expect(assistantMessages.length).toBe(1);
1425
- expect(assistantMessages[0].content).toBe('The weather is 72F.');
1426
- });
1427
-
1428
- it('should handle agent_reflect as alias for agent_reflection', async () => {
1429
- const events: AgentWidgetEvent[] = [];
1430
- const execId = 'exec_reflect_1';
1431
-
1432
- global.fetch = createAgentStreamFetch([
1433
- sseEvent('agent_start', {
1434
- executionId: execId, agentId: 'virtual', agentName: 'Test',
1435
- maxTurns: 2, startedAt: new Date().toISOString(), seq: 1,
1436
- }),
1437
- sseEvent('agent_reflect', {
1438
- executionId: execId, iteration: 1,
1439
- reflection: 'Let me reconsider.', seq: 2,
1440
- }),
1441
- sseEvent('agent_complete', {
1442
- executionId: execId, agentId: 'virtual', success: true,
1443
- iterations: 2, stopReason: 'max_iterations',
1444
- completedAt: new Date().toISOString(), seq: 3,
1445
- }),
1446
- ]);
1447
-
1448
- const client = new AgentWidgetClient({
1449
- apiUrl: 'http://localhost:8000',
1450
- agent: { name: 'Test', model: 'openai:gpt-4o-mini', systemPrompt: 'test' },
1451
- });
1452
-
1453
- await client.dispatch(
1454
- { messages: [{ id: 'usr_1', role: 'user', content: 'Hi', createdAt: new Date().toISOString() }] },
1455
- (event) => events.push(event)
1456
- );
1457
-
1458
- const messageEvents = events.filter(e => e.type === 'message');
1459
- const messagesById = new Map<string, AgentWidgetMessage>();
1460
- for (const event of messageEvents) {
1461
- if (event.type === 'message') messagesById.set(event.message.id, event.message);
1462
- }
1463
-
1464
- const reflectionMessages = Array.from(messagesById.values())
1465
- .filter(m => m.variant === 'reasoning' && m.id.includes('reflection'));
1466
- expect(reflectionMessages.length).toBe(1);
1467
- expect(reflectionMessages[0].content).toBe('Let me reconsider.');
1468
- expect(reflectionMessages[0].reasoning?.chunks).toContain('Let me reconsider.');
1469
- });
1470
-
1471
- it('should handle reason_delta as canonical event (with reason_chunk as legacy alias)', async () => {
1472
- const events: AgentWidgetEvent[] = [];
1473
-
1474
- const encoder = new TextEncoder();
1475
- global.fetch = vi.fn().mockImplementation(async () => {
1476
- const stream = new ReadableStream({
1477
- start(controller) {
1478
- controller.enqueue(encoder.encode('data: {"type":"flow_start","flowId":"f1","flowName":"Test","totalSteps":1}\n\n'));
1479
- controller.enqueue(encoder.encode('data: {"type":"reason_start","reasoningId":"r1"}\n\n'));
1480
- controller.enqueue(encoder.encode('data: {"type":"reason_delta","reasoningId":"r1","reasoningText":"Thinking..."}\n\n'));
1481
- controller.enqueue(encoder.encode('data: {"type":"reason_complete","reasoningId":"r1"}\n\n'));
1482
- controller.enqueue(encoder.encode('data: {"type":"flow_complete","success":true}\n\n'));
1483
- controller.close();
1484
- }
1485
- });
1486
- return { ok: true, body: stream };
1487
- });
1488
-
1489
- const client = new AgentWidgetClient({ apiUrl: 'http://localhost:8000' });
1490
- await client.dispatch(
1491
- { messages: [{ id: 'usr_1', role: 'user', content: 'Think', createdAt: new Date().toISOString() }] },
1492
- (event) => events.push(event)
1493
- );
1494
-
1495
- const messageEvents = events.filter(e => e.type === 'message');
1496
- const messagesById = new Map<string, AgentWidgetMessage>();
1497
- for (const event of messageEvents) {
1498
- if (event.type === 'message') messagesById.set(event.message.id, event.message);
1499
- }
1500
-
1501
- const reasoningMessages = Array.from(messagesById.values())
1502
- .filter(m => m.variant === 'reasoning');
1503
- expect(reasoningMessages.length).toBe(1);
1504
- expect(reasoningMessages[0].reasoning?.chunks).toContain('Thinking...');
1505
- });
1506
- });
1507
-
1508
1302
  // ============================================================================
1509
1303
  // Text/Tool Interleaving via partId Segmentation
1510
1304
  // ============================================================================
1511
1305
 
1512
1306
  describe('AgentWidgetClient - partId Text/Tool Interleaving', () => {
1513
- it('should split assistant messages at tool boundaries using partId on step_delta', async () => {
1307
+ it('should split flow text segments at a tool boundary', async () => {
1514
1308
  const events: AgentWidgetEvent[] = [];
1515
1309
 
1516
- const encoder = new TextEncoder();
1517
- global.fetch = vi.fn().mockImplementation(async () => {
1518
- const stream = new ReadableStream({
1519
- start(controller) {
1520
- const e = (data: Record<string, unknown>) =>
1521
- controller.enqueue(encoder.encode(`data: ${JSON.stringify(data)}\n\n`));
1522
-
1523
- e({ type: 'flow_start', flowId: 'f1', flowName: 'Test', totalSteps: 1 });
1524
- e({ type: 'step_start', id: 's1', name: 'Prompt', stepType: 'prompt', index: 0, totalSteps: 1 });
1525
- // First text segment
1526
- e({ type: 'step_delta', id: 's1', text: 'Let me search', partId: 'text_0', messageId: 'msg_s1' });
1527
- e({ type: 'step_delta', id: 's1', text: ' for that!', partId: 'text_0', messageId: 'msg_s1' });
1528
- // Tool call
1529
- e({ type: 'tool_start', toolId: 'tc_1', name: 'search', toolType: 'mcp', startedAt: new Date().toISOString() });
1530
- e({ type: 'tool_complete', toolId: 'tc_1', name: 'search', result: { found: true }, success: true, completedAt: new Date().toISOString(), executionTime: 200 });
1531
- // Second text segment (different partId)
1532
- e({ type: 'step_delta', id: 's1', text: 'Found it! Here', partId: 'text_1', messageId: 'msg_s1' });
1533
- e({ type: 'step_delta', id: 's1', text: ' are the results.', partId: 'text_1', messageId: 'msg_s1' });
1534
- e({ type: 'flow_complete', success: true });
1535
- controller.close();
1536
- }
1537
- });
1538
- return { ok: true, body: stream };
1539
- });
1310
+ global.fetch = createAgentStreamFetch([
1311
+ sseEvent('flow_start', { flowId: 'f1', flowName: 'Test', totalSteps: 1 }),
1312
+ sseEvent('step_start', { id: 's1', name: 'Prompt', stepType: 'prompt', index: 0, totalSteps: 1 }),
1313
+ sseEvent('text_start', { messageId: 'msg_s1' }),
1314
+ sseEvent('step_delta', { id: 's1', text: 'Let me search' }),
1315
+ sseEvent('step_delta', { id: 's1', text: ' for that!' }),
1316
+ sseEvent('text_end', { messageId: 'msg_s1' }),
1317
+ sseEvent('tool_start', { toolId: 'tc_1', name: 'search', toolType: 'mcp', startedAt: new Date().toISOString() }),
1318
+ sseEvent('tool_complete', { toolId: 'tc_1', name: 'search', result: { found: true }, success: true, completedAt: new Date().toISOString(), executionTime: 200 }),
1319
+ sseEvent('text_start', { messageId: 'msg_s1' }),
1320
+ sseEvent('step_delta', { id: 's1', text: 'Found it! Here' }),
1321
+ sseEvent('step_delta', { id: 's1', text: ' are the results.' }),
1322
+ sseEvent('text_end', { messageId: 'msg_s1' }),
1323
+ sseEvent('flow_complete', { success: true }),
1324
+ ]);
1540
1325
 
1541
1326
  const client = new AgentWidgetClient({ apiUrl: 'http://localhost:8000' });
1542
1327
  await client.dispatch(
@@ -1579,28 +1364,18 @@ describe('AgentWidgetClient - partId Text/Tool Interleaving', () => {
1579
1364
  it('should split assistant messages using text_start/text_end lifecycle events', async () => {
1580
1365
  const events: AgentWidgetEvent[] = [];
1581
1366
 
1582
- const encoder = new TextEncoder();
1583
- global.fetch = vi.fn().mockImplementation(async () => {
1584
- const stream = new ReadableStream({
1585
- start(controller) {
1586
- const e = (eventType: string, data: Record<string, unknown>) =>
1587
- controller.enqueue(encoder.encode(`event: ${eventType}\ndata: ${JSON.stringify({ type: eventType, ...data })}\n\n`));
1588
-
1589
- e('flow_start', { flowId: 'f1', flowName: 'Test', totalSteps: 1 });
1590
- e('text_start', { partId: 'text_0', messageId: 'msg_s1', seq: 1 });
1591
- e('step_delta', { id: 's1', text: 'Preamble text.', partId: 'text_0', messageId: 'msg_s1', seq: 2 });
1592
- e('text_end', { partId: 'text_0', messageId: 'msg_s1', seq: 3 });
1593
- e('tool_start', { toolId: 'tc_1', name: 'get_weather', toolType: 'builtin', startedAt: new Date().toISOString() });
1594
- e('tool_complete', { toolId: 'tc_1', name: 'get_weather', result: { temp: 72 }, success: true, completedAt: new Date().toISOString(), executionTime: 100 });
1595
- e('text_start', { partId: 'text_1', messageId: 'msg_s1', seq: 6 });
1596
- e('step_delta', { id: 's1', text: 'The weather is 72F.', partId: 'text_1', messageId: 'msg_s1', seq: 7 });
1597
- e('text_end', { partId: 'text_1', messageId: 'msg_s1', seq: 8 });
1598
- e('flow_complete', { success: true });
1599
- controller.close();
1600
- }
1601
- });
1602
- return { ok: true, body: stream };
1603
- });
1367
+ global.fetch = createAgentStreamFetch([
1368
+ sseEvent('flow_start', { flowId: 'f1', flowName: 'Test', totalSteps: 1 }),
1369
+ sseEvent('text_start', { messageId: 'msg_s1' }),
1370
+ sseEvent('step_delta', { id: 's1', text: 'Preamble text.' }),
1371
+ sseEvent('text_end', { messageId: 'msg_s1' }),
1372
+ sseEvent('tool_start', { toolId: 'tc_1', name: 'get_weather', toolType: 'builtin', startedAt: new Date().toISOString() }),
1373
+ sseEvent('tool_complete', { toolId: 'tc_1', name: 'get_weather', result: { temp: 72 }, success: true, completedAt: new Date().toISOString(), executionTime: 100 }),
1374
+ sseEvent('text_start', { messageId: 'msg_s1' }),
1375
+ sseEvent('step_delta', { id: 's1', text: 'The weather is 72F.' }),
1376
+ sseEvent('text_end', { messageId: 'msg_s1' }),
1377
+ sseEvent('flow_complete', { success: true }),
1378
+ ]);
1604
1379
 
1605
1380
  const client = new AgentWidgetClient({ apiUrl: 'http://localhost:8000' });
1606
1381
  await client.dispatch(
@@ -1637,29 +1412,19 @@ describe('AgentWidgetClient - partId Text/Tool Interleaving', () => {
1637
1412
  it('should not emit a whitespace-only assistant bubble before a leading tool call', async () => {
1638
1413
  const events: AgentWidgetEvent[] = [];
1639
1414
 
1640
- const encoder = new TextEncoder();
1641
- global.fetch = vi.fn().mockImplementation(async () => {
1642
- const stream = new ReadableStream({
1643
- start(controller) {
1644
- const e = (eventType: string, data: Record<string, unknown>) =>
1645
- controller.enqueue(encoder.encode(`event: ${eventType}\ndata: ${JSON.stringify({ type: eventType, ...data })}\n\n`));
1646
-
1647
- e('flow_start', { flowId: 'f1', flowName: 'Test', totalSteps: 1 });
1648
- e('step_start', { id: 's1', name: 'Prompt', stepType: 'prompt', index: 1, totalSteps: 1 });
1649
- // Tool UI is the first meaningful output. Some providers still emit
1650
- // newline-only text lifecycle events around the tool boundary; those
1651
- // must not become an empty assistant message bubble.
1652
- e('tool_start', { toolId: 'tc_1', name: 'add_to_cart', toolType: 'local', sequenceIndex: 4 });
1653
- e('text_start', { partId: 'text_0', messageId: 'msg_s1', seq: 1 });
1654
- e('step_delta', { id: 's1', text: '\n', partId: 'text_0', messageId: 'msg_s1', seq: 2 });
1655
- e('text_end', { partId: 'text_0', messageId: 'msg_s1', seq: 3 });
1656
- e('tool_complete', { toolId: 'tc_1', name: 'add_to_cart', success: true, completedAt: new Date().toISOString(), executionTime: 20, sequenceIndex: 5 });
1657
- e('flow_complete', { success: true });
1658
- controller.close();
1659
- }
1660
- });
1661
- return { ok: true, body: stream };
1662
- });
1415
+ // Tool UI is the first meaningful output. Some providers still emit
1416
+ // newline-only text lifecycle events around the tool boundary; those must
1417
+ // not become an empty assistant message bubble.
1418
+ global.fetch = createAgentStreamFetch([
1419
+ sseEvent('flow_start', { flowId: 'f1', flowName: 'Test', totalSteps: 1 }),
1420
+ sseEvent('step_start', { id: 's1', name: 'Prompt', stepType: 'prompt', index: 1, totalSteps: 1 }),
1421
+ sseEvent('tool_start', { toolId: 'tc_1', name: 'add_to_cart', toolType: 'local' }),
1422
+ sseEvent('text_start', { messageId: 'msg_s1' }),
1423
+ sseEvent('step_delta', { id: 's1', text: '\n' }),
1424
+ sseEvent('text_end', { messageId: 'msg_s1' }),
1425
+ sseEvent('tool_complete', { toolId: 'tc_1', name: 'add_to_cart', success: true, completedAt: new Date().toISOString(), executionTime: 20 }),
1426
+ sseEvent('flow_complete', { success: true }),
1427
+ ]);
1663
1428
 
1664
1429
  const client = new AgentWidgetClient({ apiUrl: 'http://localhost:8000' });
1665
1430
  await client.dispatch(
@@ -1683,25 +1448,16 @@ describe('AgentWidgetClient - partId Text/Tool Interleaving', () => {
1683
1448
  expect(toolMsgs[0].toolCall?.status).toBe('complete');
1684
1449
  });
1685
1450
 
1686
- it('should not split when partId is absent (backward compatible)', async () => {
1451
+ it('should keep consecutive deltas in one bubble when there is no segment boundary', async () => {
1687
1452
  const events: AgentWidgetEvent[] = [];
1688
1453
 
1689
- const encoder = new TextEncoder();
1690
- global.fetch = vi.fn().mockImplementation(async () => {
1691
- const stream = new ReadableStream({
1692
- start(controller) {
1693
- const e = (data: Record<string, unknown>) =>
1694
- controller.enqueue(encoder.encode(`data: ${JSON.stringify(data)}\n\n`));
1695
-
1696
- e({ type: 'flow_start', flowId: 'f1', flowName: 'Test', totalSteps: 1 });
1697
- e({ type: 'step_delta', id: 's1', text: 'Hello ' });
1698
- e({ type: 'step_delta', id: 's1', text: 'world' });
1699
- e({ type: 'flow_complete', success: true });
1700
- controller.close();
1701
- }
1702
- });
1703
- return { ok: true, body: stream };
1704
- });
1454
+ // No text_end / tool boundary between the two deltas → one text block → one bubble.
1455
+ global.fetch = createAgentStreamFetch([
1456
+ sseEvent('flow_start', { flowId: 'f1', flowName: 'Test', totalSteps: 1 }),
1457
+ sseEvent('step_delta', { id: 's1', text: 'Hello ' }),
1458
+ sseEvent('step_delta', { id: 's1', text: 'world' }),
1459
+ sseEvent('flow_complete', { success: true }),
1460
+ ]);
1705
1461
 
1706
1462
  const client = new AgentWidgetClient({ apiUrl: 'http://localhost:8000' });
1707
1463
  await client.dispatch(
@@ -1727,32 +1483,28 @@ describe('AgentWidgetClient - partId Text/Tool Interleaving', () => {
1727
1483
  it('should handle multiple tool calls with proper text interleaving', async () => {
1728
1484
  const events: AgentWidgetEvent[] = [];
1729
1485
 
1730
- const encoder = new TextEncoder();
1731
- global.fetch = vi.fn().mockImplementation(async () => {
1732
- const stream = new ReadableStream({
1733
- start(controller) {
1734
- const e = (data: Record<string, unknown>) =>
1735
- controller.enqueue(encoder.encode(`data: ${JSON.stringify(data)}\n\n`));
1736
-
1737
- e({ type: 'flow_start', flowId: 'f1', flowName: 'Test', totalSteps: 1 });
1738
- // text_0: preamble
1739
- e({ type: 'step_delta', id: 's1', text: 'Searching...', partId: 'text_0' });
1740
- // tool 1
1741
- e({ type: 'tool_start', toolId: 'tc_1', name: 'search', toolType: 'mcp' });
1742
- e({ type: 'tool_complete', toolId: 'tc_1', name: 'search', result: { id: 27 }, success: true, executionTime: 100 });
1743
- // text_1: between tools
1744
- e({ type: 'step_delta', id: 's1', text: 'Adding to cart...', partId: 'text_1' });
1745
- // tool 2
1746
- e({ type: 'tool_start', toolId: 'tc_2', name: 'add_to_cart', toolType: 'mcp' });
1747
- e({ type: 'tool_complete', toolId: 'tc_2', name: 'add_to_cart', result: { success: true }, success: true, executionTime: 50 });
1748
- // text_2: final
1749
- e({ type: 'step_delta', id: 's1', text: 'Done! Item added.', partId: 'text_2' });
1750
- e({ type: 'flow_complete', success: true });
1751
- controller.close();
1752
- }
1753
- });
1754
- return { ok: true, body: stream };
1755
- });
1486
+ global.fetch = createAgentStreamFetch([
1487
+ sseEvent('flow_start', { flowId: 'f1', flowName: 'Test', totalSteps: 1 }),
1488
+ // preamble segment
1489
+ sseEvent('text_start', { messageId: 'msg_s1' }),
1490
+ sseEvent('step_delta', { id: 's1', text: 'Searching...' }),
1491
+ sseEvent('text_end', { messageId: 'msg_s1' }),
1492
+ // tool 1
1493
+ sseEvent('tool_start', { toolId: 'tc_1', name: 'search', toolType: 'mcp' }),
1494
+ sseEvent('tool_complete', { toolId: 'tc_1', name: 'search', result: { id: 27 }, success: true, executionTime: 100 }),
1495
+ // between-tools segment
1496
+ sseEvent('text_start', { messageId: 'msg_s1' }),
1497
+ sseEvent('step_delta', { id: 's1', text: 'Adding to cart...' }),
1498
+ sseEvent('text_end', { messageId: 'msg_s1' }),
1499
+ // tool 2
1500
+ sseEvent('tool_start', { toolId: 'tc_2', name: 'add_to_cart', toolType: 'mcp' }),
1501
+ sseEvent('tool_complete', { toolId: 'tc_2', name: 'add_to_cart', result: { success: true }, success: true, executionTime: 50 }),
1502
+ // final segment
1503
+ sseEvent('text_start', { messageId: 'msg_s1' }),
1504
+ sseEvent('step_delta', { id: 's1', text: 'Done! Item added.' }),
1505
+ sseEvent('text_end', { messageId: 'msg_s1' }),
1506
+ sseEvent('flow_complete', { success: true }),
1507
+ ]);
1756
1508
 
1757
1509
  const client = new AgentWidgetClient({ apiUrl: 'http://localhost:8000' });
1758
1510
  await client.dispatch(
@@ -1798,24 +1550,18 @@ describe('AgentWidgetClient - partId Text/Tool Interleaving', () => {
1798
1550
  it('should give split messages unique IDs even when assistantMessageId is provided', async () => {
1799
1551
  const events: AgentWidgetEvent[] = [];
1800
1552
 
1801
- const encoder = new TextEncoder();
1802
- global.fetch = vi.fn().mockImplementation(async () => {
1803
- const stream = new ReadableStream({
1804
- start(controller) {
1805
- const e = (data: Record<string, unknown>) =>
1806
- controller.enqueue(encoder.encode(`data: ${JSON.stringify(data)}\n\n`));
1807
-
1808
- e({ type: 'flow_start', flowId: 'f1', flowName: 'Test', totalSteps: 1 });
1809
- e({ type: 'step_delta', id: 's1', text: 'Before tool.', partId: 'text_0' });
1810
- e({ type: 'tool_start', toolId: 'tc_1', name: 'lookup', toolType: 'mcp' });
1811
- e({ type: 'tool_complete', toolId: 'tc_1', name: 'lookup', result: {}, success: true, executionTime: 50 });
1812
- e({ type: 'step_delta', id: 's1', text: 'After tool.', partId: 'text_1' });
1813
- e({ type: 'flow_complete', success: true });
1814
- controller.close();
1815
- }
1816
- });
1817
- return { ok: true, body: stream };
1818
- });
1553
+ global.fetch = createAgentStreamFetch([
1554
+ sseEvent('flow_start', { flowId: 'f1', flowName: 'Test', totalSteps: 1 }),
1555
+ sseEvent('text_start', { messageId: 'msg_s1' }),
1556
+ sseEvent('step_delta', { id: 's1', text: 'Before tool.' }),
1557
+ sseEvent('text_end', { messageId: 'msg_s1' }),
1558
+ sseEvent('tool_start', { toolId: 'tc_1', name: 'lookup', toolType: 'mcp' }),
1559
+ sseEvent('tool_complete', { toolId: 'tc_1', name: 'lookup', result: {}, success: true, executionTime: 50 }),
1560
+ sseEvent('text_start', { messageId: 'msg_s1' }),
1561
+ sseEvent('step_delta', { id: 's1', text: 'After tool.' }),
1562
+ sseEvent('text_end', { messageId: 'msg_s1' }),
1563
+ sseEvent('flow_complete', { success: true }),
1564
+ ]);
1819
1565
 
1820
1566
  // Use agent mode so assistantMessageId is forwarded to streamResponse
1821
1567
  const client = new AgentWidgetClient({
@@ -1844,35 +1590,34 @@ describe('AgentWidgetClient - partId Text/Tool Interleaving', () => {
1844
1590
  expect(assistantTexts.length).toBe(2);
1845
1591
  // First message uses the provided ID
1846
1592
  expect(assistantTexts[0].id).toBe('ast_pre_generated_id');
1847
- // Second message composes baseId + partId for traceability
1848
- expect(assistantTexts[1].id).toBe('ast_pre_generated_id_text_1');
1593
+ // Second message composes baseId + the unified text block id for traceability
1594
+ expect(assistantTexts[1].id).toBe('ast_pre_generated_id_text_2');
1849
1595
  // Content is correct per segment
1850
1596
  expect(assistantTexts[0].content).toBe('Before tool.');
1851
1597
  expect(assistantTexts[1].content).toBe('After tool.');
1852
1598
  });
1853
1599
 
1854
- it('should not overwrite last segment content with full response in flow_complete', async () => {
1600
+ it('should not overwrite last segment content with the full step response (flow)', async () => {
1855
1601
  const events: AgentWidgetEvent[] = [];
1856
1602
 
1857
- const encoder = new TextEncoder();
1858
- global.fetch = vi.fn().mockImplementation(async () => {
1859
- const stream = new ReadableStream({
1860
- start(controller) {
1861
- const e = (data: Record<string, unknown>) =>
1862
- controller.enqueue(encoder.encode(`data: ${JSON.stringify(data)}\n\n`));
1863
-
1864
- e({ type: 'flow_start', flowId: 'f1', flowName: 'Test', totalSteps: 1 });
1865
- e({ type: 'step_delta', id: 's1', text: 'First part.', partId: 'text_0' });
1866
- e({ type: 'tool_start', toolId: 'tc_1', name: 'action', toolType: 'mcp' });
1867
- e({ type: 'tool_complete', toolId: 'tc_1', name: 'action', result: {}, success: true, executionTime: 10 });
1868
- e({ type: 'step_delta', id: 's1', text: 'Second part.', partId: 'text_1' });
1869
- // flow_complete with full concatenated response
1870
- e({ type: 'flow_complete', success: true, result: { response: 'First part.Second part.' } });
1871
- controller.close();
1872
- }
1873
- });
1874
- return { ok: true, body: stream };
1875
- });
1603
+ // Unified wire: two flow text segments split by a tool, each its own block
1604
+ // (sealed by text_end → text_complete). The step's full structured response
1605
+ // (`step_complete.result.response`) reconciles rawContent without clobbering
1606
+ // either sealed bubble's displayed content.
1607
+ global.fetch = createAgentStreamFetch([
1608
+ sseEvent('flow_start', { flowId: 'f1', flowName: 'Test', totalSteps: 1, executionId: 'exec_f1' }),
1609
+ sseEvent('step_start', { id: 's1', name: 'Prompt', stepType: 'prompt', index: 0, totalSteps: 1 }),
1610
+ sseEvent('text_start', { messageId: 'msg_s1' }),
1611
+ sseEvent('step_delta', { id: 's1', text: 'First part.' }),
1612
+ sseEvent('text_end', { messageId: 'msg_s1' }),
1613
+ sseEvent('tool_start', { toolId: 'tc_1', name: 'action', toolType: 'mcp', startedAt: new Date().toISOString() }),
1614
+ sseEvent('tool_complete', { toolId: 'tc_1', name: 'action', result: {}, success: true, completedAt: new Date().toISOString(), executionTime: 10 }),
1615
+ sseEvent('text_start', { messageId: 'msg_s1' }),
1616
+ sseEvent('step_delta', { id: 's1', text: 'Second part.' }),
1617
+ sseEvent('text_end', { messageId: 'msg_s1' }),
1618
+ sseEvent('step_complete', { id: 's1', name: 'Prompt', stepType: 'prompt', success: true, result: { response: 'First part.Second part.' }, executionTime: 500 }),
1619
+ sseEvent('flow_complete', { success: true }),
1620
+ ]);
1876
1621
 
1877
1622
  const client = new AgentWidgetClient({ apiUrl: 'http://localhost:8000' });
1878
1623
  await client.dispatch(
@@ -1902,29 +1647,19 @@ describe('AgentWidgetClient - partId Text/Tool Interleaving', () => {
1902
1647
  it('should not duplicate text when step_complete follows text_end', async () => {
1903
1648
  const events: AgentWidgetEvent[] = [];
1904
1649
 
1905
- const encoder = new TextEncoder();
1906
- global.fetch = vi.fn().mockImplementation(async () => {
1907
- const stream = new ReadableStream({
1908
- start(controller) {
1909
- const e = (eventType: string, data: Record<string, unknown>) =>
1910
- controller.enqueue(encoder.encode(`event: ${eventType}\ndata: ${JSON.stringify({ type: eventType, ...data })}\n\n`));
1911
-
1912
- e('flow_start', { flowId: 'f1', flowName: 'Test', totalSteps: 1 });
1913
- // Tools fire first (no text before them)
1914
- e('tool_start', { toolId: 'tc_1', name: 'test_tool', toolType: 'custom', startedAt: new Date().toISOString() });
1915
- e('tool_complete', { toolId: 'tc_1', name: 'test_tool', success: true, completedAt: new Date().toISOString(), executionTime: 0 });
1916
- // Then text segment
1917
- e('text_start', { partId: 'text_1', messageId: 'msg_s1', seq: 1 });
1918
- e('step_delta', { id: 's1', text: 'Tool returned a result.', partId: 'text_1', messageId: 'msg_s1', seq: 2 });
1919
- e('text_end', { partId: 'text_1', messageId: 'msg_s1', seq: 3 });
1920
- // step_complete with full response (should NOT create a duplicate)
1921
- e('step_complete', { id: 's1', name: 'Response', stepType: 'prompt', success: true, result: { response: 'Tool returned a result.' }, executionTime: 500 });
1922
- e('flow_complete', { success: true });
1923
- controller.close();
1924
- }
1925
- });
1926
- return { ok: true, body: stream };
1927
- });
1650
+ global.fetch = createAgentStreamFetch([
1651
+ sseEvent('flow_start', { flowId: 'f1', flowName: 'Test', totalSteps: 1 }),
1652
+ // Tools fire first (no text before them)
1653
+ sseEvent('tool_start', { toolId: 'tc_1', name: 'test_tool', toolType: 'custom', startedAt: new Date().toISOString() }),
1654
+ sseEvent('tool_complete', { toolId: 'tc_1', name: 'test_tool', success: true, completedAt: new Date().toISOString(), executionTime: 0 }),
1655
+ // Then text segment
1656
+ sseEvent('text_start', { messageId: 'msg_s1' }),
1657
+ sseEvent('step_delta', { id: 's1', text: 'Tool returned a result.' }),
1658
+ sseEvent('text_end', { messageId: 'msg_s1' }),
1659
+ // step_complete with full response (should NOT create a duplicate)
1660
+ sseEvent('step_complete', { id: 's1', name: 'Response', stepType: 'prompt', success: true, result: { response: 'Tool returned a result.' }, executionTime: 500 }),
1661
+ sseEvent('flow_complete', { success: true }),
1662
+ ]);
1928
1663
 
1929
1664
  const client = new AgentWidgetClient({ apiUrl: 'http://localhost:8000' });
1930
1665
  await client.dispatch(
@@ -1980,39 +1715,27 @@ describe('AgentWidgetClient - partId Text/Tool Interleaving', () => {
1980
1715
  };
1981
1716
  };
1982
1717
 
1983
- const encoder = new TextEncoder();
1984
- global.fetch = vi.fn().mockImplementation(async () => {
1985
- const stream = new ReadableStream({
1986
- start(controller) {
1987
- const e = (eventType: string, data: Record<string, unknown>) =>
1988
- controller.enqueue(encoder.encode(`event: ${eventType}\ndata: ${JSON.stringify({ type: eventType, ...data })}\n\n`));
1989
-
1990
- e('flow_start', { flowId: 'f1', flowName: 'Test', totalSteps: 1 });
1991
- e('tool_start', { toolId: 'tc_1', name: 'test_tool', toolType: 'custom', startedAt: new Date().toISOString() });
1992
- e('tool_complete', { toolId: 'tc_1', name: 'test_tool', success: true, completedAt: new Date().toISOString(), executionTime: 0 });
1993
- e('text_start', { partId: 'text_1', messageId: 'msg_s1', seq: 1 });
1994
- e('step_delta', {
1995
- id: 's1',
1996
- text: '{"text":"Tool returned a re',
1997
- partId: 'text_1',
1998
- messageId: 'msg_s1',
1999
- seq: 2
2000
- });
2001
- e('text_end', { partId: 'text_1', messageId: 'msg_s1', seq: 3 });
2002
- e('step_complete', {
2003
- id: 's1',
2004
- name: 'Response',
2005
- stepType: 'prompt',
2006
- success: true,
2007
- result: { response: opts.stepCompleteResponse },
2008
- executionTime: 500
2009
- });
2010
- e('flow_complete', { success: true });
2011
- controller.close();
2012
- }
2013
- });
2014
- return { ok: true, body: stream };
2015
- });
1718
+ // Unified wire (via the oracle): a single flow text block carrying partial
1719
+ // structured JSON, sealed by text_end, then the authoritative final structured
1720
+ // response on step_complete. Exercises the async structured-content parser +
1721
+ // sealed-segment reconciliation on the unified flow path.
1722
+ global.fetch = createAgentStreamFetch([
1723
+ sseEvent('flow_start', { flowId: 'f1', flowName: 'Test', totalSteps: 1 }),
1724
+ sseEvent('tool_start', { toolId: 'tc_1', name: 'test_tool', toolType: 'custom', startedAt: new Date().toISOString() }),
1725
+ sseEvent('tool_complete', { toolId: 'tc_1', name: 'test_tool', success: true, completedAt: new Date().toISOString(), executionTime: 0 }),
1726
+ sseEvent('text_start', { messageId: 'msg_s1' }),
1727
+ sseEvent('step_delta', { id: 's1', text: '{"text":"Tool returned a re' }),
1728
+ sseEvent('text_end', { messageId: 'msg_s1' }),
1729
+ sseEvent('step_complete', {
1730
+ id: 's1',
1731
+ name: 'Response',
1732
+ stepType: 'prompt',
1733
+ success: true,
1734
+ result: { response: opts.stepCompleteResponse },
1735
+ executionTime: 500
1736
+ }),
1737
+ sseEvent('flow_complete', { success: true }),
1738
+ ]);
2016
1739
 
2017
1740
  const client = new AgentWidgetClient({
2018
1741
  apiUrl: 'http://localhost:8000',
@@ -2052,577 +1775,182 @@ describe('AgentWidgetClient - partId Text/Tool Interleaving', () => {
2052
1775
  });
2053
1776
  });
2054
1777
 
2055
- it('should prefer the authoritative final structured response when reconciling a sealed segment', async () => {
2056
- await runSealedSegmentReconciliationTest({
2057
- parserMatchContent: '{"text":"Tool returned a result."}',
2058
- stepCompleteResponse: '{"text":"Tool returned a result."}',
2059
- expectedRawContent: '{"text":"Tool returned a result."}',
2060
- });
2061
- });
2062
- });
2063
-
2064
- describe('preferFinalStructuredContent', () => {
2065
- it('returns finalString when rawBuffer is undefined', () => {
2066
- expect(preferFinalStructuredContent(undefined, 'hello')).toBe('hello');
2067
- });
2068
-
2069
- it('returns finalString when rawBuffer is empty/whitespace', () => {
2070
- expect(preferFinalStructuredContent('', 'hello')).toBe('hello');
2071
- expect(preferFinalStructuredContent(' ', 'hello')).toBe('hello');
2072
- });
2073
-
2074
- it('returns rawBuffer when finalString is empty/whitespace', () => {
2075
- expect(preferFinalStructuredContent('{"text":"hi"}', '')).toBe('{"text":"hi"}');
2076
- expect(preferFinalStructuredContent('{"text":"hi"}', ' ')).toBe('{"text":"hi"}');
2077
- });
2078
-
2079
- it('returns rawBuffer when final is plain text (not structured)', () => {
2080
- expect(preferFinalStructuredContent('{"text":"hi"}', 'plain text')).toBe('{"text":"hi"}');
2081
- });
2082
-
2083
- it('returns finalString when raw is plain text but final is structured', () => {
2084
- expect(preferFinalStructuredContent('partial plain', '{"text":"hi"}')).toBe('{"text":"hi"}');
2085
- });
2086
-
2087
- it('returns finalString when both are identical structured content', () => {
2088
- const json = '{"text":"hello"}';
2089
- expect(preferFinalStructuredContent(json, json)).toBe(json);
2090
- });
2091
-
2092
- it('returns finalString when final is a superset of the raw buffer', () => {
2093
- expect(preferFinalStructuredContent(
2094
- '{"text":"hel',
2095
- '{"text":"hello"}'
2096
- )).toBe('{"text":"hello"}');
2097
- });
2098
-
2099
- it('returns finalString when final is parseable JSON but raw is not', () => {
2100
- expect(preferFinalStructuredContent(
2101
- '{"text":"hel',
2102
- '{"text":"hello"}'
2103
- )).toBe('{"text":"hello"}');
2104
- });
2105
-
2106
- it('returns rawBuffer when both are structured but neither is a prefix of the other and both parse', () => {
2107
- expect(preferFinalStructuredContent(
2108
- '{"text":"segment two"}',
2109
- '{"text":"full response with segment one and two"}'
2110
- )).toBe('{"text":"segment two"}');
2111
- });
2112
-
2113
- it('returns rawBuffer when both are structured, different, and raw parses', () => {
2114
- expect(preferFinalStructuredContent(
2115
- '{"text":"short"}',
2116
- '{"text":"different content"}'
2117
- )).toBe('{"text":"short"}');
2118
- });
2119
-
2120
- it('returns finalString when final parses but raw does not (partial JSON)', () => {
2121
- expect(preferFinalStructuredContent(
2122
- '{"text":"incomp',
2123
- '{"text":"complete"}'
2124
- )).toBe('{"text":"complete"}');
2125
- });
2126
-
2127
- it('handles XML-shaped content as structured', () => {
2128
- expect(preferFinalStructuredContent(
2129
- '<response>partial',
2130
- '<response>full</response>'
2131
- )).toBe('<response>partial');
2132
- });
2133
-
2134
- it('handles array-shaped JSON content as structured', () => {
2135
- expect(preferFinalStructuredContent(
2136
- '[{"text":"par',
2137
- '[{"text":"partial"}]'
2138
- )).toBe('[{"text":"partial"}]');
2139
- });
2140
- });
2141
-
2142
- describe('AgentWidgetClient - Out-of-Order Sequence Reordering', () => {
2143
- it('should reorder step_delta chunks by seq when events arrive out of order', async () => {
2144
- const events: AgentWidgetEvent[] = [];
2145
-
2146
- const client = new AgentWidgetClient({ apiUrl: 'http://localhost:8000' });
2147
-
2148
- global.fetch = vi.fn().mockImplementation(async () => {
2149
- const encoder = new TextEncoder();
2150
- const stream = new ReadableStream({
2151
- start(controller) {
2152
- const e = (data: any) => {
2153
- controller.enqueue(encoder.encode(`data: ${JSON.stringify(data)}\n\n`));
2154
- };
2155
- e({ type: 'flow_start', flowId: 'f1', flowName: 'Test', totalSteps: 1 });
2156
- e({ type: 'step_start', id: 's1', name: 'Prompt', stepType: 'prompt', index: 1, totalSteps: 1 });
2157
- // Send chunks out of order (seq 3 before seq 2)
2158
- e({ type: 'step_delta', id: 's1', text: 'Hello', partId: 'text_0', seq: 1 });
2159
- e({ type: 'step_delta', id: 's1', text: ' world', partId: 'text_0', seq: 3 });
2160
- e({ type: 'step_delta', id: 's1', text: ' beautiful', partId: 'text_0', seq: 2 });
2161
- e({ type: 'step_delta', id: 's1', text: '!', partId: 'text_0', seq: 4 });
2162
- e({ type: 'step_complete', id: 's1', name: 'Prompt', success: true });
2163
- e({ type: 'flow_complete', success: true });
2164
- controller.close();
2165
- },
2166
- });
2167
- return { ok: true, body: stream };
2168
- });
2169
-
2170
- await client.dispatch({ messages: [] }, (event) => events.push(event));
2171
-
2172
- const messageEvents = events.filter(
2173
- (e): e is AgentWidgetEvent & { type: 'message' } => e.type === 'message'
2174
- );
2175
- const finalMessages = messageEvents.filter((e) => !e.message.streaming);
2176
- expect(finalMessages.length).toBeGreaterThan(0);
2177
-
2178
- const lastFinal = finalMessages[finalMessages.length - 1];
2179
- // Content should be in seq order, not arrival order
2180
- expect(lastFinal.message.content).toBe('Hello beautiful world!');
2181
- });
2182
-
2183
- it('repairs a delayed step_delta that arrives after the gap-timeout flush', async () => {
2184
- vi.useFakeTimers();
2185
- const events: AgentWidgetEvent[] = [];
2186
-
2187
- const client = new AgentWidgetClient({ apiUrl: 'http://localhost:8000' });
2188
-
2189
- global.fetch = vi.fn().mockImplementation(async () => {
2190
- const encoder = new TextEncoder();
2191
- const stream = new ReadableStream({
2192
- start(controller) {
2193
- const e = (data: any) => {
2194
- controller.enqueue(encoder.encode(`data: ${JSON.stringify(data)}\n\n`));
2195
- };
2196
-
2197
- e({ type: 'flow_start', flowId: 'f1', flowName: 'Test', totalSteps: 1 });
2198
- e({ type: 'step_start', id: 's1', name: 'Prompt', stepType: 'prompt', index: 1, totalSteps: 1 });
2199
- e({ type: 'text_start', partId: 'text_0', messageId: 'msg_1', seq: 1 });
2200
- e({ type: 'step_delta', id: 's1', text: 'a', partId: 'text_0', messageId: 'msg_1', seq: 2 });
2201
- // seq=3 is delayed long enough for the reorder buffer to flush seq=4 and seq=5.
2202
- e({ type: 'step_delta', id: 's1', text: 'c', partId: 'text_0', messageId: 'msg_1', seq: 4 });
2203
- e({ type: 'text_end', partId: 'text_0', messageId: 'msg_1', seq: 5 });
2204
-
2205
- setTimeout(() => {
2206
- e({ type: 'step_delta', id: 's1', text: 'b', partId: 'text_0', messageId: 'msg_1', seq: 3 });
2207
- }, 60);
2208
-
2209
- setTimeout(() => {
2210
- e({ type: 'flow_complete', success: true });
2211
- controller.close();
2212
- }, 70);
2213
- },
2214
- });
2215
- return { ok: true, body: stream };
2216
- });
2217
-
2218
- const dispatchPromise = client.dispatch({ messages: [] }, (event) => events.push(event));
2219
- await vi.advanceTimersByTimeAsync(80);
2220
- await dispatchPromise;
2221
- vi.useRealTimers();
2222
-
2223
- const messageEvents = events.filter(
2224
- (e): e is AgentWidgetEvent & { type: 'message' } => e.type === 'message'
2225
- );
2226
- const assistantMessages = messageEvents
2227
- .filter((e) => e.message.role === 'assistant' && !e.message.variant)
2228
- .map((e) => e.message);
2229
- expect(assistantMessages.length).toBeGreaterThan(0);
2230
-
2231
- const repairedMessage = assistantMessages[assistantMessages.length - 1];
2232
- expect(repairedMessage.content).toBe('abc');
2233
- expect(repairedMessage.partId).toBe('text_0');
2234
- });
2235
-
2236
- it('should reorder reason_delta chunks by sequenceIndex', async () => {
2237
- const events: AgentWidgetEvent[] = [];
2238
-
2239
- const client = new AgentWidgetClient({ apiUrl: 'http://localhost:8000' });
2240
-
2241
- global.fetch = vi.fn().mockImplementation(async () => {
2242
- const encoder = new TextEncoder();
2243
- const stream = new ReadableStream({
2244
- start(controller) {
2245
- const e = (data: any) => {
2246
- controller.enqueue(encoder.encode(`data: ${JSON.stringify(data)}\n\n`));
2247
- };
2248
- e({ type: 'flow_start', flowId: 'f1', flowName: 'Test', totalSteps: 1 });
2249
- e({ type: 'step_start', id: 's1', name: 'Prompt', stepType: 'prompt', index: 1, totalSteps: 1 });
2250
- e({ type: 'reason_start', reasoningId: 'r1', hidden: false, done: false });
2251
- // Send reasoning chunks out of order
2252
- e({ type: 'reason_delta', reasoningId: 'r1', reasoningText: 'I ', hidden: false, done: false, sequenceIndex: 1 });
2253
- e({ type: 'reason_delta', reasoningId: 'r1', reasoningText: 'about', hidden: false, done: false, sequenceIndex: 3 });
2254
- e({ type: 'reason_delta', reasoningId: 'r1', reasoningText: 'think ', hidden: false, done: false, sequenceIndex: 2 });
2255
- e({ type: 'reason_delta', reasoningId: 'r1', reasoningText: ' this.', hidden: false, done: false, sequenceIndex: 4 });
2256
- e({ type: 'reason_complete', reasoningId: 'r1', hidden: false, done: true });
2257
- e({ type: 'step_delta', id: 's1', text: 'Result', partId: 'text_0' });
2258
- e({ type: 'step_complete', id: 's1', name: 'Prompt', success: true });
2259
- e({ type: 'flow_complete', success: true });
2260
- controller.close();
2261
- },
2262
- });
2263
- return { ok: true, body: stream };
2264
- });
2265
-
2266
- await client.dispatch({ messages: [] }, (event) => events.push(event));
2267
-
2268
- const messageEvents = events.filter(
2269
- (e): e is AgentWidgetEvent & { type: 'message' } => e.type === 'message'
2270
- );
2271
- const reasoningMsgs = messageEvents.filter(
2272
- (e) => e.message.reasoning && e.message.reasoning.chunks.length > 0
2273
- );
2274
- expect(reasoningMsgs.length).toBeGreaterThan(0);
2275
-
2276
- const lastReasoning = reasoningMsgs[reasoningMsgs.length - 1];
2277
- // Reasoning chunks should be in sequenceIndex order
2278
- const fullReasoning = lastReasoning.message.reasoning!.chunks.join('');
2279
- expect(fullReasoning).toBe('I think about this.');
2280
- });
2281
-
2282
- it('repairs a delayed reason_delta that arrives after the gap-timeout flush', async () => {
2283
- vi.useFakeTimers();
2284
- const events: AgentWidgetEvent[] = [];
2285
-
2286
- const client = new AgentWidgetClient({ apiUrl: 'http://localhost:8000' });
2287
-
2288
- global.fetch = vi.fn().mockImplementation(async () => {
2289
- const encoder = new TextEncoder();
2290
- const stream = new ReadableStream({
2291
- start(controller) {
2292
- const e = (data: any) => {
2293
- controller.enqueue(encoder.encode(`data: ${JSON.stringify(data)}\n\n`));
2294
- };
2295
-
2296
- e({ type: 'flow_start', flowId: 'f1', flowName: 'Test', totalSteps: 1 });
2297
- e({ type: 'step_start', id: 's1', name: 'Prompt', stepType: 'prompt', index: 1, totalSteps: 1 });
2298
- e({ type: 'reason_start', reasoningId: 'r1', hidden: false, done: false, sequenceIndex: 1 });
2299
- e({ type: 'reason_delta', reasoningId: 'r1', reasoningText: 'a', hidden: false, done: false, sequenceIndex: 2 });
2300
- // sequenceIndex=3 is delayed long enough for the gap-timeout to flush sequenceIndex=4
2301
- e({ type: 'reason_delta', reasoningId: 'r1', reasoningText: 'c', hidden: false, done: false, sequenceIndex: 4 });
2302
-
2303
- setTimeout(() => {
2304
- // Late arrival after gap-timeout flush
2305
- e({ type: 'reason_delta', reasoningId: 'r1', reasoningText: 'b', hidden: false, done: false, sequenceIndex: 3 });
2306
- }, 60);
2307
-
2308
- setTimeout(() => {
2309
- e({ type: 'reason_complete', reasoningId: 'r1', hidden: false, done: true, sequenceIndex: 5 });
2310
- e({ type: 'step_delta', id: 's1', text: 'Result', partId: 'text_0', sequenceIndex: 6 });
2311
- e({ type: 'step_complete', id: 's1', name: 'Prompt', success: true });
2312
- e({ type: 'flow_complete', success: true });
2313
- controller.close();
2314
- }, 70);
2315
- },
2316
- });
2317
- return { ok: true, body: stream };
2318
- });
2319
-
2320
- const dispatchPromise = client.dispatch({ messages: [] }, (event) => events.push(event));
2321
- await vi.advanceTimersByTimeAsync(80);
2322
- await dispatchPromise;
2323
- vi.useRealTimers();
2324
-
2325
- const messageEvents = events.filter(
2326
- (e): e is AgentWidgetEvent & { type: 'message' } => e.type === 'message'
2327
- );
2328
- const reasoningMsgs = messageEvents.filter(
2329
- (e) => e.message.reasoning && e.message.reasoning.chunks.length > 0
2330
- );
2331
- expect(reasoningMsgs.length).toBeGreaterThan(0);
2332
-
2333
- const lastReasoning = reasoningMsgs[reasoningMsgs.length - 1];
2334
- const fullReasoning = lastReasoning.message.reasoning!.chunks.join('');
2335
- expect(fullReasoning).toBe('abc');
2336
- });
2337
-
2338
- it('should handle step_delta without seq gracefully (no reordering)', async () => {
2339
- const events: AgentWidgetEvent[] = [];
2340
-
2341
- const client = new AgentWidgetClient({ apiUrl: 'http://localhost:8000' });
2342
-
2343
- global.fetch = vi.fn().mockImplementation(async () => {
2344
- const encoder = new TextEncoder();
2345
- const stream = new ReadableStream({
2346
- start(controller) {
2347
- const e = (data: any) => {
2348
- controller.enqueue(encoder.encode(`data: ${JSON.stringify(data)}\n\n`));
2349
- };
2350
- e({ type: 'flow_start', flowId: 'f1', flowName: 'Test', totalSteps: 1 });
2351
- e({ type: 'step_start', id: 's1', name: 'Prompt', stepType: 'prompt', index: 1, totalSteps: 1 });
2352
- // No seq field: should append in arrival order
2353
- e({ type: 'step_delta', id: 's1', text: 'Hello ' });
2354
- e({ type: 'step_delta', id: 's1', text: 'world' });
2355
- e({ type: 'step_delta', id: 's1', text: '!' });
2356
- e({ type: 'step_complete', id: 's1', name: 'Prompt', success: true });
2357
- e({ type: 'flow_complete', success: true });
2358
- controller.close();
2359
- },
2360
- });
2361
- return { ok: true, body: stream };
2362
- });
2363
-
2364
- await client.dispatch({ messages: [] }, (event) => events.push(event));
2365
-
2366
- const messageEvents = events.filter(
2367
- (e): e is AgentWidgetEvent & { type: 'message' } => e.type === 'message'
2368
- );
2369
- const finalMessages = messageEvents.filter((e) => !e.message.streaming);
2370
- expect(finalMessages.length).toBeGreaterThan(0);
2371
-
2372
- const lastFinal = finalMessages[finalMessages.length - 1];
2373
- expect(lastFinal.message.content).toBe('Hello world!');
2374
- });
2375
-
2376
- it('should handle leading-gap arrival (first event is not seq=1)', async () => {
2377
- const events: AgentWidgetEvent[] = [];
2378
-
2379
- const client = new AgentWidgetClient({ apiUrl: 'http://localhost:8000' });
2380
-
2381
- global.fetch = vi.fn().mockImplementation(async () => {
2382
- const encoder = new TextEncoder();
2383
- const stream = new ReadableStream({
2384
- start(controller) {
2385
- const e = (data: any) => {
2386
- controller.enqueue(encoder.encode(`data: ${JSON.stringify(data)}\n\n`));
2387
- };
2388
- e({ type: 'flow_start', flowId: 'f1', flowName: 'Test', totalSteps: 1 });
2389
- e({ type: 'step_start', id: 's1', name: 'Prompt', stepType: 'prompt', index: 1, totalSteps: 1 });
2390
- // seq=3 arrives first (leading gap: seq 1 and 2 arrive later)
2391
- e({ type: 'step_delta', id: 's1', text: 'c', partId: 'text_0', seq: 3 });
2392
- e({ type: 'step_delta', id: 's1', text: 'a', partId: 'text_0', seq: 1 });
2393
- e({ type: 'step_delta', id: 's1', text: 'b', partId: 'text_0', seq: 2 });
2394
- e({ type: 'step_complete', id: 's1', name: 'Prompt', success: true });
2395
- e({ type: 'flow_complete', success: true });
2396
- controller.close();
2397
- },
2398
- });
2399
- return { ok: true, body: stream };
2400
- });
2401
-
2402
- await client.dispatch({ messages: [] }, (event) => events.push(event));
2403
-
2404
- const messageEvents = events.filter(
2405
- (e): e is AgentWidgetEvent & { type: 'message' } => e.type === 'message'
2406
- );
2407
- const finalMessages = messageEvents.filter((e) => !e.message.streaming);
2408
- expect(finalMessages.length).toBeGreaterThan(0);
2409
-
2410
- const lastFinal = finalMessages[finalMessages.length - 1];
2411
- // Must be in seq order, not arrival order
2412
- expect(lastFinal.message.content).toBe('abc');
1778
+ it('should prefer the authoritative final structured response when reconciling a sealed segment', async () => {
1779
+ await runSealedSegmentReconciliationTest({
1780
+ parserMatchContent: '{"text":"Tool returned a result."}',
1781
+ stepCompleteResponse: '{"text":"Tool returned a result."}',
1782
+ expectedRawContent: '{"text":"Tool returned a result."}',
1783
+ });
2413
1784
  });
1785
+ });
2414
1786
 
2415
- it('should handle mixed seq + sequenceIndex in one stream', async () => {
1787
+ describe('AgentWidgetClient - nested flow-as-tool (parentToolCallId)', () => {
1788
+ // PR #4602: a flow running as a tool enriches its streamed text/reasoning with
1789
+ // toolContext.toolId; the unified wire surfaces it as text_start/reasoning_start
1790
+ // .parentToolCallId, and the widget routes that block into the parent tool's row.
1791
+ it('routes nested flow text into the parent tool row, not the top-level assistant', async () => {
2416
1792
  const events: AgentWidgetEvent[] = [];
1793
+ global.fetch = createAgentStreamFetch([
1794
+ sseEvent('flow_start', { flowId: 'f1', flowName: 'Test', totalSteps: 1 }),
1795
+ // top-level parent text
1796
+ sseEvent('text_start', { messageId: 'msg_parent' }),
1797
+ sseEvent('step_delta', { id: 's1', text: 'Parent says hi.' }),
1798
+ sseEvent('text_end', { messageId: 'msg_parent' }),
1799
+ // a nested flow runs as a tool
1800
+ sseEvent('tool_start', { toolId: 'tool_nested_1', name: 'run_subflow', toolType: 'flow', startedAt: new Date().toISOString() }),
1801
+ // nested flow text, enriched with toolContext.toolId
1802
+ sseEvent('text_start', { messageId: 'msg_nested', toolContext: { toolId: 'tool_nested_1' } }),
1803
+ sseEvent('step_delta', { id: 's2', text: 'Nested result.', toolContext: { toolId: 'tool_nested_1' } }),
1804
+ sseEvent('text_end', { messageId: 'msg_nested', toolContext: { toolId: 'tool_nested_1' } }),
1805
+ sseEvent('tool_complete', { toolId: 'tool_nested_1', name: 'run_subflow', success: true, executionTime: 100 }),
1806
+ sseEvent('flow_complete', { success: true }),
1807
+ ]);
2417
1808
 
2418
1809
  const client = new AgentWidgetClient({ apiUrl: 'http://localhost:8000' });
1810
+ await client.dispatch(
1811
+ { messages: [{ id: 'usr_1', role: 'user', content: 'Go', createdAt: new Date().toISOString() }] },
1812
+ (e) => events.push(e)
1813
+ );
2419
1814
 
2420
- global.fetch = vi.fn().mockImplementation(async () => {
2421
- const encoder = new TextEncoder();
2422
- const stream = new ReadableStream({
2423
- start(controller) {
2424
- const e = (data: any) => {
2425
- controller.enqueue(encoder.encode(`data: ${JSON.stringify(data)}\n\n`));
2426
- };
2427
- e({ type: 'flow_start', flowId: 'f1', flowName: 'Test', totalSteps: 1 });
2428
- e({ type: 'step_start', id: 's1', name: 'Prompt', stepType: 'prompt', index: 1, totalSteps: 1 });
2429
- // reason_delta uses sequenceIndex, step_delta uses seq: same counter
2430
- e({ type: 'reason_start', reasoningId: 'r1', hidden: false, done: false });
2431
- e({ type: 'reason_delta', reasoningId: 'r1', reasoningText: 'thinking', hidden: false, done: false, sequenceIndex: 1 });
2432
- e({ type: 'reason_complete', reasoningId: 'r1', hidden: false, done: true });
2433
- // step_delta seq=2 continues from the same counter
2434
- e({ type: 'step_delta', id: 's1', text: 'Result', partId: 'text_0', seq: 2 });
2435
- e({ type: 'step_complete', id: 's1', name: 'Prompt', success: true });
2436
- e({ type: 'flow_complete', success: true });
2437
- controller.close();
2438
- },
2439
- });
2440
- return { ok: true, body: stream };
2441
- });
1815
+ const byId = new Map<string, AgentWidgetMessage>();
1816
+ for (const ev of events) if (ev.type === 'message') byId.set(ev.message.id, ev.message);
1817
+ const all = Array.from(byId.values());
1818
+ const assistantTexts = all.filter((m) => m.role === 'assistant' && !m.variant);
1819
+ const tools = all.filter((m) => m.variant === 'tool');
2442
1820
 
2443
- await client.dispatch({ messages: [] }, (event) => events.push(event));
1821
+ const topLevel = assistantTexts.find((m) => !m.agentMetadata?.parentToolId);
1822
+ const nested = assistantTexts.find((m) => m.agentMetadata?.parentToolId === 'tool_nested_1');
2444
1823
 
2445
- const messageEvents = events.filter(
2446
- (e): e is AgentWidgetEvent & { type: 'message' } => e.type === 'message'
2447
- );
2448
- // Should have both reasoning and text messages, properly ordered
2449
- const reasoningMsgs = messageEvents.filter(e => e.message.reasoning?.chunks?.length);
2450
- expect(reasoningMsgs.length).toBeGreaterThan(0);
2451
- expect(reasoningMsgs[reasoningMsgs.length - 1].message.reasoning!.chunks.join('')).toBe('thinking');
2452
-
2453
- const textMsgs = messageEvents.filter(e => e.message.role === 'assistant' && !e.message.variant && e.message.content);
2454
- expect(textMsgs.length).toBeGreaterThan(0);
2455
- expect(textMsgs[textMsgs.length - 1].message.content).toContain('Result');
1824
+ expect(topLevel?.content).toBe('Parent says hi.');
1825
+ expect(nested).toBeDefined();
1826
+ expect(nested?.content).toBe('Nested result.');
1827
+ expect(nested?.streaming).toBe(false);
1828
+ expect(tools.some((t) => t.toolCall?.name === 'run_subflow')).toBe(true);
2456
1829
  });
2457
1830
 
2458
- it('should handle cross-event buffering around tool events', async () => {
1831
+ it('does not tag top-level flow text with a parentToolId', async () => {
2459
1832
  const events: AgentWidgetEvent[] = [];
2460
-
1833
+ global.fetch = createAgentStreamFetch([
1834
+ sseEvent('flow_start', { flowId: 'f1', flowName: 'Test', totalSteps: 1 }),
1835
+ sseEvent('text_start', { messageId: 'msg_1' }),
1836
+ sseEvent('step_delta', { id: 's1', text: 'Just top-level.' }),
1837
+ sseEvent('text_end', { messageId: 'msg_1' }),
1838
+ sseEvent('flow_complete', { success: true }),
1839
+ ]);
2461
1840
  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
- // text_start and step_delta with seq, then tool events (no seq), then more text
2473
- e({ type: 'text_start', partId: 'text_0', messageId: 'msg_1', seq: 1 });
2474
- e({ type: 'step_delta', id: 's1', text: 'Before tool ', partId: 'text_0', seq: 2 });
2475
- e({ type: 'text_end', partId: 'text_0', messageId: 'msg_1', seq: 3 });
2476
- // Tool events don't carry top-level seq in non-agent flows
2477
- e({ type: 'tool_start', toolCallId: 'tc1', name: 'fetch', parameters: {} });
2478
- e({ type: 'tool_complete', toolCallId: 'tc1', name: 'fetch', result: { data: 'ok' }, executionTime: 100 });
2479
- e({ type: 'text_start', partId: 'text_1', messageId: 'msg_1', seq: 4 });
2480
- e({ type: 'step_delta', id: 's1', text: 'after tool', partId: 'text_1', seq: 5 });
2481
- e({ type: 'text_end', partId: 'text_1', messageId: 'msg_1', seq: 6 });
2482
- e({ type: 'step_complete', id: 's1', name: 'Prompt', success: true });
2483
- e({ type: 'flow_complete', success: true });
2484
- controller.close();
2485
- },
2486
- });
2487
- return { ok: true, body: stream };
2488
- });
2489
-
2490
- await client.dispatch({ messages: [] }, (event) => events.push(event));
2491
-
2492
- const messageEvents = events.filter(
2493
- (e): e is AgentWidgetEvent & { type: 'message' } => e.type === 'message'
1841
+ await client.dispatch(
1842
+ { messages: [{ id: 'u', role: 'user', content: 'Hi', createdAt: new Date().toISOString() }] },
1843
+ (e) => events.push(e)
2494
1844
  );
2495
- // Should have text content from both segments
2496
- const allContent = messageEvents
2497
- .filter(e => e.message.role === 'assistant' && !e.message.variant)
2498
- .map(e => e.message.content);
2499
- const combinedContent = allContent.join('');
2500
- expect(combinedContent).toContain('Before tool ');
2501
- expect(combinedContent).toContain('after tool');
1845
+ const byId = new Map<string, AgentWidgetMessage>();
1846
+ for (const ev of events) if (ev.type === 'message') byId.set(ev.message.id, ev.message);
1847
+ const texts = Array.from(byId.values()).filter((m) => m.role === 'assistant' && !m.variant);
1848
+ expect(texts.length).toBe(1);
1849
+ expect(texts[0].content).toBe('Just top-level.');
1850
+ expect(texts[0].agentMetadata?.parentToolId).toBeUndefined();
2502
1851
  });
2503
1852
 
2504
- it('delivers sequenced events still buffered when the stream closes', async () => {
2505
- // Regression: if the SSE stream ends while the reorder buffer is still
2506
- // waiting for a missing seq number, previously those events were silently
2507
- // dropped (destroy() cancelled the gap timer without flushing). The fix
2508
- // is an end-of-stream flush + drain; this test guards against regression.
1853
+ it('routes nested flow reasoning into the parent tool row', async () => {
2509
1854
  const events: AgentWidgetEvent[] = [];
2510
-
1855
+ global.fetch = createAgentStreamFetch([
1856
+ sseEvent('flow_start', { flowId: 'f1', flowName: 'Test', totalSteps: 1 }),
1857
+ sseEvent('tool_start', { toolId: 'tool_nested_2', name: 'run_subflow', toolType: 'flow', startedAt: new Date().toISOString() }),
1858
+ sseEvent('reason_start', { toolContext: { toolId: 'tool_nested_2' } }),
1859
+ sseEvent('reason_delta', { reasoningText: 'thinking nested', toolContext: { toolId: 'tool_nested_2' } }),
1860
+ sseEvent('reason_complete', { toolContext: { toolId: 'tool_nested_2' } }),
1861
+ sseEvent('tool_complete', { toolId: 'tool_nested_2', name: 'run_subflow', success: true, executionTime: 50 }),
1862
+ sseEvent('flow_complete', { success: true }),
1863
+ ]);
2511
1864
  const client = new AgentWidgetClient({ apiUrl: 'http://localhost:8000' });
2512
-
2513
- global.fetch = vi.fn().mockImplementation(async () => {
2514
- const encoder = new TextEncoder();
2515
- const stream = new ReadableStream({
2516
- start(controller) {
2517
- const e = (data: any) => {
2518
- controller.enqueue(encoder.encode(`data: ${JSON.stringify(data)}\n\n`));
2519
- };
2520
- e({ type: 'flow_start', flowId: 'f1', flowName: 'Test', totalSteps: 1 });
2521
- e({ type: 'step_start', id: 's1', name: 'Prompt', stepType: 'prompt', index: 1, totalSteps: 1 });
2522
- // Only a seq=3 event arrives: seq=1 and seq=2 are never delivered.
2523
- // Without the end-of-stream flush, this event would be stranded in
2524
- // the reorder buffer and never emitted.
2525
- e({ type: 'step_delta', id: 's1', text: 'tail', partId: 'text_0', seq: 3 });
2526
- // Stream closes immediately, well inside the 50ms gap timer window.
2527
- controller.close();
2528
- },
2529
- });
2530
- return { ok: true, body: stream };
2531
- });
2532
-
2533
- await client.dispatch({ messages: [] }, (event) => events.push(event));
2534
-
2535
- const messageEvents = events.filter(
2536
- (e): e is AgentWidgetEvent & { type: 'message' } => e.type === 'message'
1865
+ await client.dispatch(
1866
+ { messages: [{ id: 'u', role: 'user', content: 'Hi', createdAt: new Date().toISOString() }] },
1867
+ (e) => events.push(e)
2537
1868
  );
2538
- const assistantContent = messageEvents
2539
- .filter((e) => e.message.role === 'assistant' && !e.message.variant)
2540
- .map((e) => e.message.content)
2541
- .join('');
2542
-
2543
- expect(assistantContent).toContain('tail');
1869
+ const byId = new Map<string, AgentWidgetMessage>();
1870
+ for (const ev of events) if (ev.type === 'message') byId.set(ev.message.id, ev.message);
1871
+ const reasoning = Array.from(byId.values()).filter((m) => m.variant === 'reasoning');
1872
+ const nested = reasoning.find((m) => m.agentMetadata?.parentToolId === 'tool_nested_2');
1873
+ expect(nested).toBeDefined();
1874
+ expect(nested?.reasoning?.chunks.join('')).toBe('thinking nested');
1875
+ expect(nested?.streaming).toBe(false);
2544
1876
  });
1877
+ });
2545
1878
 
2546
- it('drains timer-flushed sequenced events before the next SSE chunk arrives', async () => {
2547
- vi.useFakeTimers();
2548
- const events: AgentWidgetEvent[] = [];
1879
+ describe('preferFinalStructuredContent', () => {
1880
+ it('returns finalString when rawBuffer is undefined', () => {
1881
+ expect(preferFinalStructuredContent(undefined, 'hello')).toBe('hello');
1882
+ });
2549
1883
 
2550
- const client = new AgentWidgetClient({ apiUrl: 'http://localhost:8000' });
1884
+ it('returns finalString when rawBuffer is empty/whitespace', () => {
1885
+ expect(preferFinalStructuredContent('', 'hello')).toBe('hello');
1886
+ expect(preferFinalStructuredContent(' ', 'hello')).toBe('hello');
1887
+ });
2551
1888
 
2552
- global.fetch = vi.fn().mockImplementation(async () => {
2553
- const encoder = new TextEncoder();
2554
- const stream = new ReadableStream({
2555
- start(controller) {
2556
- const e = (data: any) => {
2557
- controller.enqueue(encoder.encode(`data: ${JSON.stringify(data)}\n\n`));
2558
- };
2559
- e({ type: 'flow_start', flowId: 'f1', flowName: 'Test', totalSteps: 1 });
2560
- e({ type: 'step_start', id: 's1', name: 'Prompt', stepType: 'prompt', index: 1, totalSteps: 1 });
2561
- e({ type: 'step_delta', id: 's1', text: 'a', partId: 'text_0', seq: 1 });
2562
- // seq=3 buffers while seq=2 is missing.
2563
- e({ type: 'step_delta', id: 's1', text: 'c', partId: 'text_0', seq: 3 });
2564
-
2565
- // Keep the stream open without delivering another SSE event until after
2566
- // the gap timeout has fired. The buffered seq=3 event should still render.
2567
- setTimeout(() => {
2568
- e({ type: 'flow_complete', success: true });
2569
- controller.close();
2570
- }, 120);
2571
- },
2572
- });
2573
- return { ok: true, body: stream };
2574
- });
1889
+ it('returns rawBuffer when finalString is empty/whitespace', () => {
1890
+ expect(preferFinalStructuredContent('{"text":"hi"}', '')).toBe('{"text":"hi"}');
1891
+ expect(preferFinalStructuredContent('{"text":"hi"}', ' ')).toBe('{"text":"hi"}');
1892
+ });
2575
1893
 
2576
- const dispatchPromise = client.dispatch({ messages: [] }, (event) => events.push(event));
1894
+ it('returns rawBuffer when final is plain text (not structured)', () => {
1895
+ expect(preferFinalStructuredContent('{"text":"hi"}', 'plain text')).toBe('{"text":"hi"}');
1896
+ });
2577
1897
 
2578
- await vi.advanceTimersByTimeAsync(60);
1898
+ it('returns finalString when raw is plain text but final is structured', () => {
1899
+ expect(preferFinalStructuredContent('partial plain', '{"text":"hi"}')).toBe('{"text":"hi"}');
1900
+ });
2579
1901
 
2580
- const messageEventsDuringPause = events.filter(
2581
- (e): e is AgentWidgetEvent & { type: 'message' } => e.type === 'message'
2582
- );
2583
- const assistantContentDuringPause = messageEventsDuringPause
2584
- .filter((e) => e.message.role === 'assistant' && !e.message.variant)
2585
- .map((e) => e.message.content)
2586
- .join('');
1902
+ it('returns finalString when both are identical structured content', () => {
1903
+ const json = '{"text":"hello"}';
1904
+ expect(preferFinalStructuredContent(json, json)).toBe(json);
1905
+ });
2587
1906
 
2588
- expect(assistantContentDuringPause).toContain('ac');
1907
+ it('returns finalString when final is a superset of the raw buffer', () => {
1908
+ expect(preferFinalStructuredContent(
1909
+ '{"text":"hel',
1910
+ '{"text":"hello"}'
1911
+ )).toBe('{"text":"hello"}');
1912
+ });
2589
1913
 
2590
- await vi.advanceTimersByTimeAsync(70);
2591
- await dispatchPromise;
2592
- vi.useRealTimers();
1914
+ it('returns finalString when final is parseable JSON but raw is not', () => {
1915
+ expect(preferFinalStructuredContent(
1916
+ '{"text":"hel',
1917
+ '{"text":"hello"}'
1918
+ )).toBe('{"text":"hello"}');
2593
1919
  });
2594
1920
 
2595
- it('delivers a buffered error event when the stream closes mid-gap', async () => {
2596
- // Regression: an error event with seq > 1 arriving right before the
2597
- // stream closes was being swallowed by the reorder buffer, leaving the
2598
- // widget stuck in a streaming state with no error surfaced.
2599
- const events: AgentWidgetEvent[] = [];
1921
+ it('returns rawBuffer when both are structured but neither is a prefix of the other and both parse', () => {
1922
+ expect(preferFinalStructuredContent(
1923
+ '{"text":"segment two"}',
1924
+ '{"text":"full response with segment one and two"}'
1925
+ )).toBe('{"text":"segment two"}');
1926
+ });
2600
1927
 
2601
- const client = new AgentWidgetClient({ apiUrl: 'http://localhost:8000' });
1928
+ it('returns rawBuffer when both are structured, different, and raw parses', () => {
1929
+ expect(preferFinalStructuredContent(
1930
+ '{"text":"short"}',
1931
+ '{"text":"different content"}'
1932
+ )).toBe('{"text":"short"}');
1933
+ });
2602
1934
 
2603
- global.fetch = vi.fn().mockImplementation(async () => {
2604
- const encoder = new TextEncoder();
2605
- const stream = new ReadableStream({
2606
- start(controller) {
2607
- const e = (data: any) => {
2608
- controller.enqueue(encoder.encode(`data: ${JSON.stringify(data)}\n\n`));
2609
- };
2610
- e({ type: 'flow_start', flowId: 'f1', flowName: 'Test', totalSteps: 1 });
2611
- // Only sequenced event, but seq > 1 so it would be buffered.
2612
- e({ type: 'error', error: 'boom', seq: 2 });
2613
- controller.close();
2614
- },
2615
- });
2616
- return { ok: true, body: stream };
2617
- });
1935
+ it('returns finalString when final parses but raw does not (partial JSON)', () => {
1936
+ expect(preferFinalStructuredContent(
1937
+ '{"text":"incomp',
1938
+ '{"text":"complete"}'
1939
+ )).toBe('{"text":"complete"}');
1940
+ });
2618
1941
 
2619
- await client.dispatch({ messages: [] }, (event) => events.push(event));
1942
+ it('handles XML-shaped content as structured', () => {
1943
+ expect(preferFinalStructuredContent(
1944
+ '<response>partial',
1945
+ '<response>full</response>'
1946
+ )).toBe('<response>partial');
1947
+ });
2620
1948
 
2621
- const errorEvents = events.filter((e) => e.type === 'error');
2622
- expect(errorEvents.length).toBe(1);
2623
- if (errorEvents[0].type === 'error') {
2624
- expect(errorEvents[0].error.message).toBe('boom');
2625
- }
1949
+ it('handles array-shaped JSON content as structured', () => {
1950
+ expect(preferFinalStructuredContent(
1951
+ '[{"text":"par',
1952
+ '[{"text":"partial"}]'
1953
+ )).toBe('[{"text":"partial"}]');
2626
1954
  });
2627
1955
  });
2628
1956
 
@@ -3006,6 +2334,20 @@ describe('AgentWidgetClient - agent_turn text/tool interleaving', () => {
3006
2334
  // ============================================================================
3007
2335
 
3008
2336
  describe('AgentWidgetClient: step_await parsing', () => {
2337
+ // Unified collapses the legacy flow `step_await` (a `local_tool_required` pause)
2338
+ // into the neutral `await` event the native handler consumes.
2339
+ const buildUnifiedAwaitStream = (payload: Record<string, unknown>): ReadableStream<Uint8Array> => {
2340
+ const encoder = new TextEncoder();
2341
+ const body = `event: await\ndata: ${JSON.stringify({ type: 'await', ...payload })}\n\n`;
2342
+ return new ReadableStream({
2343
+ start(controller) {
2344
+ controller.enqueue(encoder.encode(body));
2345
+ controller.close();
2346
+ },
2347
+ });
2348
+ };
2349
+ // Legacy raw `step_await` frame — still exercised by the approval-reason guard
2350
+ // below until Phase C removes the legacy approval branch from the handler.
3009
2351
  const buildStepAwaitStream = (payload: Record<string, unknown>): ReadableStream<Uint8Array> => {
3010
2352
  const encoder = new TextEncoder();
3011
2353
  const body = `event: step_await\ndata: ${JSON.stringify({ type: 'step_await', ...payload })}\n\n`;
@@ -3020,7 +2362,7 @@ describe('AgentWidgetClient: step_await parsing', () => {
3020
2362
  it('emits a complete tool message with awaitingLocalTool=true for local_tool_required', async () => {
3021
2363
  global.fetch = vi.fn().mockResolvedValue({
3022
2364
  ok: true,
3023
- body: buildStepAwaitStream({
2365
+ body: buildUnifiedAwaitStream({
3024
2366
  awaitReason: 'local_tool_required',
3025
2367
  id: 'step-1',
3026
2368
  name: 'Test Step',
@@ -3060,7 +2402,7 @@ describe('AgentWidgetClient: step_await parsing', () => {
3060
2402
  it('emits a running tool message for WebMCP local_tool_required until the browser tool resolves', async () => {
3061
2403
  global.fetch = vi.fn().mockResolvedValue({
3062
2404
  ok: true,
3063
- body: buildStepAwaitStream({
2405
+ body: buildUnifiedAwaitStream({
3064
2406
  awaitReason: 'local_tool_required',
3065
2407
  id: 'step-1',
3066
2408
  name: 'Test Step',
@@ -3124,6 +2466,101 @@ describe('AgentWidgetClient: step_await parsing', () => {
3124
2466
  });
3125
2467
  });
3126
2468
 
2469
+ // ============================================================================
2470
+ // agent_await (AGENT-dispatch LOCAL tool pause) — resolves through the same
2471
+ // path as step_await; carries a bare tool name + origin instead of a webmcp:
2472
+ // prefix + awaitReason.
2473
+ // ============================================================================
2474
+
2475
+ describe('AgentWidgetClient: agent_await parsing', () => {
2476
+ // Unified collapses the legacy `step_await`/`agent_await` pair into one `await`
2477
+ // event; the dispatch origin survives as the `origin` field on the payload.
2478
+ const buildAgentAwaitStream = (payload: Record<string, unknown>): ReadableStream<Uint8Array> => {
2479
+ const encoder = new TextEncoder();
2480
+ const body = `event: await\ndata: ${JSON.stringify({ type: 'await', ...payload })}\n\n`;
2481
+ return new ReadableStream({
2482
+ start(controller) {
2483
+ controller.enqueue(encoder.encode(body));
2484
+ controller.close();
2485
+ },
2486
+ });
2487
+ };
2488
+
2489
+ it('normalizes a WebMCP agent_await (origin "webmcp") to a running webmcp: tool message', async () => {
2490
+ global.fetch = vi.fn().mockResolvedValue({
2491
+ ok: true,
2492
+ body: buildAgentAwaitStream({
2493
+ executionId: 'exec_abc',
2494
+ toolId: 'runtime_get_product_by_url_1',
2495
+ toolCallId: 'tc_webmcp_1',
2496
+ toolName: 'get_product_by_url',
2497
+ origin: 'webmcp',
2498
+ awaitedAt: 1234,
2499
+ parameters: { path: '/jade/' },
2500
+ }),
2501
+ });
2502
+
2503
+ const client = new AgentWidgetClient({ apiUrl: 'http://localhost:8000' });
2504
+ const events: AgentWidgetEvent[] = [];
2505
+ await client.dispatch(
2506
+ { messages: [{ id: 'u1', role: 'user', content: 'hi', createdAt: new Date().toISOString() }] },
2507
+ (e) => events.push(e)
2508
+ );
2509
+
2510
+ const toolMsg = events
2511
+ .filter((e) => e.type === 'message')
2512
+ .map((e) => (e as { message: AgentWidgetMessage }).message)
2513
+ .find((m) => m.variant === 'tool' && m.toolCall?.name === 'webmcp:get_product_by_url');
2514
+
2515
+ expect(toolMsg).toBeDefined();
2516
+ // bare wire name is normalized to the webmcp: prefix
2517
+ expect(toolMsg!.toolCall!.name).toBe('webmcp:get_product_by_url');
2518
+ expect(toolMsg!.toolCall!.id).toBe('tc_webmcp_1');
2519
+ expect(toolMsg!.toolCall!.status).toBe('running');
2520
+ expect(toolMsg!.toolCall!.startedAt).toBe(1234);
2521
+ expect(toolMsg!.toolCall!.completedAt).toBeUndefined();
2522
+ expect(toolMsg!.toolCall!.args).toEqual({ path: '/jade/' });
2523
+ expect(toolMsg!.agentMetadata?.executionId).toBe('exec_abc');
2524
+ expect(toolMsg!.agentMetadata?.awaitingLocalTool).toBe(true);
2525
+ expect(toolMsg!.agentMetadata?.webMcpToolCallId).toBe('tc_webmcp_1');
2526
+ });
2527
+
2528
+ it('emits a complete tool message for a non-WebMCP agent_await (origin "sdk")', async () => {
2529
+ global.fetch = vi.fn().mockResolvedValue({
2530
+ ok: true,
2531
+ body: buildAgentAwaitStream({
2532
+ executionId: 'exec_abc',
2533
+ toolId: 'runtime_ask_user_question_123',
2534
+ toolName: 'ask_user_question',
2535
+ origin: 'sdk',
2536
+ awaitedAt: 1234,
2537
+ parameters: {
2538
+ questions: [{ question: 'Who?', options: [{ label: 'A' }, { label: 'B' }] }],
2539
+ },
2540
+ }),
2541
+ });
2542
+
2543
+ const client = new AgentWidgetClient({ apiUrl: 'http://localhost:8000' });
2544
+ const events: AgentWidgetEvent[] = [];
2545
+ await client.dispatch(
2546
+ { messages: [{ id: 'u1', role: 'user', content: 'hi', createdAt: new Date().toISOString() }] },
2547
+ (e) => events.push(e)
2548
+ );
2549
+
2550
+ const toolMsg = events
2551
+ .filter((e) => e.type === 'message')
2552
+ .map((e) => (e as { message: AgentWidgetMessage }).message)
2553
+ .find((m) => m.variant === 'tool' && m.toolCall?.name === 'ask_user_question');
2554
+
2555
+ expect(toolMsg).toBeDefined();
2556
+ // sdk-origin local tool keeps its bare name (no webmcp: prefix)
2557
+ expect(toolMsg!.toolCall!.name).toBe('ask_user_question');
2558
+ expect(toolMsg!.toolCall!.status).toBe('complete');
2559
+ expect(toolMsg!.agentMetadata?.executionId).toBe('exec_abc');
2560
+ expect(toolMsg!.agentMetadata?.awaitingLocalTool).toBe(true);
2561
+ });
2562
+ });
2563
+
3127
2564
  describe('AgentWidgetClient.resumeFlow', () => {
3128
2565
  it('POSTs to ${apiUrl}/resume with the expected body shape', async () => {
3129
2566
  let capturedUrl: string | undefined;
@@ -3462,8 +2899,11 @@ describe('AgentWidgetClient - agent_media events', () => {
3462
2899
  (e) => events.push(e)
3463
2900
  );
3464
2901
 
3465
- const parts = collectMediaMessages(events)[0]!.contentParts!;
3466
- expect(parts).toHaveLength(3);
2902
+ // Unified streams each media item as its own block (media_start/complete),
2903
+ // so each renders as its own synthetic message with a single content part.
2904
+ const mediaMessages = collectMediaMessages(events);
2905
+ expect(mediaMessages).toHaveLength(3);
2906
+ const parts = mediaMessages.map((m) => m.contentParts![0]);
3467
2907
  expect(parts[0].type).toBe('audio');
3468
2908
  expect(parts[1].type).toBe('video');
3469
2909
  expect(parts[2].type).toBe('file');
@@ -3476,7 +2916,7 @@ describe('AgentWidgetClient - agent_media events', () => {
3476
2916
  }
3477
2917
  });
3478
2918
 
3479
- it('renders mixed media parts in a single message', async () => {
2919
+ it('renders each mixed media part as its own synthetic message', async () => {
3480
2920
  const execId = 'exec_media_mixed';
3481
2921
  global.fetch = createAgentStreamFetch([
3482
2922
  sseEvent('agent_start', { executionId: execId, agentId: 'virtual', agentName: 'Test', maxTurns: 1, startedAt: new Date().toISOString(), seq: 1 }),
@@ -3504,9 +2944,8 @@ describe('AgentWidgetClient - agent_media events', () => {
3504
2944
  );
3505
2945
 
3506
2946
  const mediaMessages = collectMediaMessages(events);
3507
- expect(mediaMessages).toHaveLength(1);
3508
- const parts = mediaMessages[0]!.contentParts!;
3509
- expect(parts).toHaveLength(3);
2947
+ expect(mediaMessages).toHaveLength(3);
2948
+ const parts = mediaMessages.map((m) => m.contentParts![0]);
3510
2949
  expect(parts[0].type).toBe('image');
3511
2950
  expect(parts[1].type).toBe('image');
3512
2951
  expect(parts[2].type).toBe('file');
@@ -4221,7 +3660,7 @@ describe('AgentWidgetClient - version header', () => {
4221
3660
  });
4222
3661
  });
4223
3662
 
4224
- describe('AgentWidgetClient - Unified event vocabulary (events: "unified")', () => {
3663
+ describe('AgentWidgetClient - Unified event vocabulary (default in 4.0)', () => {
4225
3664
  const unifiedTextStream = (execId: string) => [
4226
3665
  sseEvent('execution_start', { kind: 'agent', executionId: execId, agentId: 'virtual', agentName: 'Test', maxTurns: 1, startedAt: new Date().toISOString(), seq: 1 }),
4227
3666
  sseEvent('turn_start', { executionId: execId, id: 'turn_1', iteration: 1, role: 'assistant', seq: 2 }),
@@ -4233,7 +3672,7 @@ describe('AgentWidgetClient - Unified event vocabulary (events: "unified")', ()
4233
3672
  sseEvent('execution_complete', { kind: 'agent', executionId: execId, success: true, completedAt: new Date().toISOString(), seq: 8 }),
4234
3673
  ];
4235
3674
 
4236
- it('appends ?events=unified to the dispatch URL when opted in', async () => {
3675
+ it('does not append an events param to the dispatch URL (unified is the default wire)', async () => {
4237
3676
  let capturedUrl = '';
4238
3677
  global.fetch = vi.fn().mockImplementation(async (url: string) => {
4239
3678
  capturedUrl = url;
@@ -4249,24 +3688,22 @@ describe('AgentWidgetClient - Unified event vocabulary (events: "unified")', ()
4249
3688
 
4250
3689
  const client = new AgentWidgetClient({
4251
3690
  apiUrl: 'http://localhost:8000',
4252
- events: 'unified',
4253
3691
  agent: { name: 'Test', model: 'openai:gpt-4o-mini', systemPrompt: 'test' },
4254
3692
  });
4255
3693
  await client.dispatch(
4256
3694
  { messages: [{ id: 'u1', role: 'user', content: 'Hi', createdAt: new Date().toISOString() }] },
4257
3695
  () => {}
4258
3696
  );
4259
- expect(capturedUrl).toContain('events=unified');
3697
+ expect(capturedUrl).not.toContain('events=');
4260
3698
  });
4261
3699
 
4262
- it('renders a unified stream by bridging it back to the legacy handlers', async () => {
3700
+ it('renders a unified stream through the internal handlers with no config flag', async () => {
4263
3701
  const events: AgentWidgetEvent[] = [];
4264
3702
  const execId = 'exec_u1';
4265
- global.fetch = createAgentStreamFetch(unifiedTextStream(execId));
3703
+ global.fetch = createRawStreamFetch(unifiedTextStream(execId));
4266
3704
 
4267
3705
  const client = new AgentWidgetClient({
4268
3706
  apiUrl: 'http://localhost:8000',
4269
- events: 'unified',
4270
3707
  agent: { name: 'Test', model: 'openai:gpt-4o-mini', systemPrompt: 'test' },
4271
3708
  });
4272
3709
  await client.dispatch(
@@ -4285,36 +3722,4 @@ describe('AgentWidgetClient - Unified event vocabulary (events: "unified")', ()
4285
3722
  expect(last.message.agentMetadata?.executionId).toBe(execId);
4286
3723
  }
4287
3724
  });
4288
-
4289
- it('auto-falls-back to legacy when "unified" was requested but the API streamed legacy frames', async () => {
4290
- const events: AgentWidgetEvent[] = [];
4291
- const execId = 'exec_legacy_fallback';
4292
- // Same content, LEGACY vocabulary — the API ignored ?events=unified.
4293
- global.fetch = createAgentStreamFetch([
4294
- sseEvent('agent_start', { executionId: execId, agentId: 'virtual', agentName: 'Test', maxTurns: 1, startedAt: new Date().toISOString(), seq: 1 }),
4295
- sseEvent('agent_turn_start', { executionId: execId, iteration: 1, turnId: 'turn_1', role: 'assistant', seq: 2 }),
4296
- sseEvent('agent_turn_delta', { executionId: execId, iteration: 1, delta: 'Hello', contentType: 'text', turnId: 'turn_1', seq: 3 }),
4297
- sseEvent('agent_turn_delta', { executionId: execId, iteration: 1, delta: ' World', contentType: 'text', turnId: 'turn_1', seq: 4 }),
4298
- sseEvent('agent_turn_complete', { executionId: execId, iteration: 1, turnId: 'turn_1', completedAt: new Date().toISOString(), seq: 5 }),
4299
- sseEvent('agent_complete', { executionId: execId, success: true, stopReason: 'max_iterations', completedAt: new Date().toISOString(), seq: 6 }),
4300
- ]);
4301
-
4302
- const client = new AgentWidgetClient({
4303
- apiUrl: 'http://localhost:8000',
4304
- events: 'unified',
4305
- agent: { name: 'Test', model: 'openai:gpt-4o-mini', systemPrompt: 'test' },
4306
- });
4307
- await client.dispatch(
4308
- { messages: [{ id: 'u1', role: 'user', content: 'Hi', createdAt: new Date().toISOString() }] },
4309
- (e) => events.push(e)
4310
- );
4311
-
4312
- const messageEvents = events.filter((e) => e.type === 'message');
4313
- const last = messageEvents[messageEvents.length - 1];
4314
- expect(last.type).toBe('message');
4315
- if (last.type === 'message') {
4316
- expect(last.message.content).toBe('Hello World');
4317
- expect(last.message.streaming).toBe(false);
4318
- }
4319
- });
4320
3725
  });