@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.
- package/README.md +0 -1
- package/dist/animations/glyph-cycle.d.cts +1 -1
- package/dist/animations/glyph-cycle.d.ts +1 -1
- package/dist/animations/{types-DgYsuwXL.d.cts → types-B_xbfvR0.d.cts} +263 -181
- package/dist/animations/{types-DgYsuwXL.d.ts → types-B_xbfvR0.d.ts} +263 -181
- package/dist/animations/wipe.d.cts +1 -1
- package/dist/animations/wipe.d.ts +1 -1
- package/dist/codegen.cjs +1 -1
- package/dist/codegen.js +1 -1
- package/dist/index.cjs +52 -52
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +327 -838
- package/dist/index.d.ts +327 -838
- package/dist/index.global.js +39 -39
- package/dist/index.global.js.map +1 -1
- package/dist/index.js +52 -52
- package/dist/index.js.map +1 -1
- package/dist/install.global.js +1 -1
- package/dist/install.global.js.map +1 -1
- package/dist/smart-dom-reader.d.cts +268 -191
- package/dist/smart-dom-reader.d.ts +268 -191
- package/dist/theme-editor-preview.cjs +53 -53
- package/dist/theme-editor-preview.d.cts +268 -191
- package/dist/theme-editor-preview.d.ts +268 -191
- package/dist/theme-editor-preview.js +55 -55
- package/dist/theme-editor.d.cts +268 -191
- package/dist/theme-editor.d.ts +268 -191
- package/package.json +1 -1
- package/src/client.test.ts +446 -1041
- package/src/client.ts +684 -967
- package/src/components/tool-bubble.ts +46 -33
- package/src/generated/runtype-openapi-contract.ts +210 -714
- package/src/index-core.ts +2 -3
- package/src/install.test.ts +1 -34
- package/src/install.ts +1 -26
- package/src/runtime/init.test.ts +8 -67
- package/src/runtime/init.ts +2 -17
- package/src/session.test.ts +8 -8
- package/src/session.ts +1 -1
- package/src/types.ts +11 -12
- package/src/ui.postprocess.test.ts +107 -0
- package/src/ui.ts +40 -5
- package/src/utils/__fixtures__/unified-translator.oracle.ts +62 -14
- package/src/utils/copy-selection.test.ts +37 -0
- package/src/utils/copy-selection.ts +19 -0
- package/src/utils/event-stream-capture.test.ts +9 -64
- package/src/utils/sequence-buffer.test.ts +0 -256
- package/src/utils/sequence-buffer.ts +0 -130
- package/src/utils/unified-event-bridge.test.ts +0 -263
- package/src/utils/unified-event-bridge.ts +0 -431
package/src/client.test.ts
CHANGED
|
@@ -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
|
-
//
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
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
|
-
*
|
|
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
|
|
590
|
-
|
|
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
|
|
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
|
-
//
|
|
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.
|
|
1245
|
+
.filter(m => m.variant === 'reasoning' && m.reasoning?.scope === 'loop');
|
|
1230
1246
|
expect(reflectionMessages.length).toBe(1);
|
|
1231
|
-
expect(reflectionMessages[0].
|
|
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
|
|
1307
|
+
it('should split flow text segments at a tool boundary', async () => {
|
|
1514
1308
|
const events: AgentWidgetEvent[] = [];
|
|
1515
1309
|
|
|
1516
|
-
|
|
1517
|
-
|
|
1518
|
-
|
|
1519
|
-
|
|
1520
|
-
|
|
1521
|
-
|
|
1522
|
-
|
|
1523
|
-
|
|
1524
|
-
|
|
1525
|
-
|
|
1526
|
-
|
|
1527
|
-
|
|
1528
|
-
|
|
1529
|
-
|
|
1530
|
-
|
|
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
|
-
|
|
1583
|
-
|
|
1584
|
-
|
|
1585
|
-
|
|
1586
|
-
|
|
1587
|
-
|
|
1588
|
-
|
|
1589
|
-
|
|
1590
|
-
|
|
1591
|
-
|
|
1592
|
-
|
|
1593
|
-
|
|
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
|
-
|
|
1641
|
-
|
|
1642
|
-
|
|
1643
|
-
|
|
1644
|
-
|
|
1645
|
-
|
|
1646
|
-
|
|
1647
|
-
|
|
1648
|
-
|
|
1649
|
-
|
|
1650
|
-
|
|
1651
|
-
|
|
1652
|
-
|
|
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
|
|
1451
|
+
it('should keep consecutive deltas in one bubble when there is no segment boundary', async () => {
|
|
1687
1452
|
const events: AgentWidgetEvent[] = [];
|
|
1688
1453
|
|
|
1689
|
-
|
|
1690
|
-
global.fetch =
|
|
1691
|
-
|
|
1692
|
-
|
|
1693
|
-
|
|
1694
|
-
|
|
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
|
-
|
|
1731
|
-
|
|
1732
|
-
|
|
1733
|
-
|
|
1734
|
-
|
|
1735
|
-
|
|
1736
|
-
|
|
1737
|
-
|
|
1738
|
-
|
|
1739
|
-
|
|
1740
|
-
|
|
1741
|
-
|
|
1742
|
-
|
|
1743
|
-
|
|
1744
|
-
|
|
1745
|
-
|
|
1746
|
-
|
|
1747
|
-
|
|
1748
|
-
|
|
1749
|
-
|
|
1750
|
-
|
|
1751
|
-
|
|
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
|
-
|
|
1802
|
-
|
|
1803
|
-
|
|
1804
|
-
|
|
1805
|
-
|
|
1806
|
-
|
|
1807
|
-
|
|
1808
|
-
|
|
1809
|
-
|
|
1810
|
-
|
|
1811
|
-
|
|
1812
|
-
|
|
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 +
|
|
1848
|
-
expect(assistantTexts[1].id).toBe('
|
|
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
|
|
1600
|
+
it('should not overwrite last segment content with the full step response (flow)', async () => {
|
|
1855
1601
|
const events: AgentWidgetEvent[] = [];
|
|
1856
1602
|
|
|
1857
|
-
|
|
1858
|
-
|
|
1859
|
-
|
|
1860
|
-
|
|
1861
|
-
|
|
1862
|
-
|
|
1863
|
-
|
|
1864
|
-
|
|
1865
|
-
|
|
1866
|
-
|
|
1867
|
-
|
|
1868
|
-
|
|
1869
|
-
|
|
1870
|
-
|
|
1871
|
-
|
|
1872
|
-
|
|
1873
|
-
})
|
|
1874
|
-
|
|
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
|
-
|
|
1906
|
-
|
|
1907
|
-
|
|
1908
|
-
|
|
1909
|
-
|
|
1910
|
-
|
|
1911
|
-
|
|
1912
|
-
|
|
1913
|
-
|
|
1914
|
-
|
|
1915
|
-
|
|
1916
|
-
|
|
1917
|
-
|
|
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
|
-
|
|
1984
|
-
|
|
1985
|
-
|
|
1986
|
-
|
|
1987
|
-
|
|
1988
|
-
|
|
1989
|
-
|
|
1990
|
-
|
|
1991
|
-
|
|
1992
|
-
|
|
1993
|
-
|
|
1994
|
-
|
|
1995
|
-
|
|
1996
|
-
|
|
1997
|
-
|
|
1998
|
-
|
|
1999
|
-
|
|
2000
|
-
|
|
2001
|
-
|
|
2002
|
-
|
|
2003
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2421
|
-
|
|
2422
|
-
|
|
2423
|
-
|
|
2424
|
-
|
|
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
|
-
|
|
1821
|
+
const topLevel = assistantTexts.find((m) => !m.agentMetadata?.parentToolId);
|
|
1822
|
+
const nested = assistantTexts.find((m) => m.agentMetadata?.parentToolId === 'tool_nested_1');
|
|
2444
1823
|
|
|
2445
|
-
|
|
2446
|
-
|
|
2447
|
-
);
|
|
2448
|
-
|
|
2449
|
-
|
|
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('
|
|
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
|
-
|
|
2464
|
-
|
|
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
|
-
|
|
2496
|
-
const
|
|
2497
|
-
|
|
2498
|
-
|
|
2499
|
-
|
|
2500
|
-
expect(
|
|
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('
|
|
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
|
-
|
|
2514
|
-
|
|
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
|
|
2539
|
-
|
|
2540
|
-
|
|
2541
|
-
|
|
2542
|
-
|
|
2543
|
-
expect(
|
|
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
|
-
|
|
2547
|
-
|
|
2548
|
-
|
|
1879
|
+
describe('preferFinalStructuredContent', () => {
|
|
1880
|
+
it('returns finalString when rawBuffer is undefined', () => {
|
|
1881
|
+
expect(preferFinalStructuredContent(undefined, 'hello')).toBe('hello');
|
|
1882
|
+
});
|
|
2549
1883
|
|
|
2550
|
-
|
|
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
|
-
|
|
2553
|
-
|
|
2554
|
-
|
|
2555
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2581
|
-
|
|
2582
|
-
);
|
|
2583
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2591
|
-
|
|
2592
|
-
|
|
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('
|
|
2596
|
-
|
|
2597
|
-
|
|
2598
|
-
|
|
2599
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2604
|
-
|
|
2605
|
-
|
|
2606
|
-
|
|
2607
|
-
|
|
2608
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2622
|
-
expect(
|
|
2623
|
-
|
|
2624
|
-
|
|
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:
|
|
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:
|
|
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
|
-
|
|
3466
|
-
|
|
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
|
|
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(
|
|
3508
|
-
const parts = mediaMessages[0]
|
|
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 (
|
|
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('
|
|
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=
|
|
3697
|
+
expect(capturedUrl).not.toContain('events=');
|
|
4260
3698
|
});
|
|
4261
3699
|
|
|
4262
|
-
it('renders a unified stream
|
|
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 =
|
|
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
|
});
|