@runtypelabs/persona 4.27.0 → 4.28.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +25 -0
- package/dist/{approval-ui-QAKWNFNM.js → approval-ui-OBBI62ES.js} +1 -1
- package/dist/approval-ui.cjs +2 -2
- package/dist/approval-ui.d.cts +20 -5
- package/dist/approval-ui.d.ts +20 -5
- package/dist/approval-ui.js +2 -2
- package/dist/artifacts-ui.d.cts +20 -5
- package/dist/artifacts-ui.d.ts +20 -5
- package/dist/{chunk-NQLDCJAP.js → chunk-D4TDKCT5.js} +2 -2
- package/dist/chunk-FNXK3VTC.js +1 -0
- package/dist/{client-history-HTRKHBAF.js → client-history-QGAFMCBE.js} +1 -1
- package/dist/client-history.js +1 -1
- package/dist/{client-stream-N22JHTLE.js → client-stream-WXD2FL23.js} +1 -1
- package/dist/client-stream.js +3 -3
- package/dist/codegen.cjs +4 -4
- package/dist/codegen.js +5 -5
- package/dist/context-mentions.d.cts +20 -5
- package/dist/context-mentions.d.ts +20 -5
- package/dist/event-stream-view.d.cts +20 -5
- package/dist/event-stream-view.d.ts +20 -5
- package/dist/forms-ui.d.cts +26 -5
- package/dist/forms-ui.d.ts +26 -5
- package/dist/index.cjs +41 -41
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +59 -30
- package/dist/index.d.ts +59 -30
- package/dist/index.global.js +1 -1
- package/dist/index.global.js.map +1 -1
- package/dist/index.js +41 -41
- package/dist/index.js.map +1 -1
- package/dist/session-reconnect.d.cts +20 -5
- package/dist/session-reconnect.d.ts +20 -5
- package/dist/smart-dom-reader.d.cts +20 -5
- package/dist/smart-dom-reader.d.ts +20 -5
- package/dist/theme-editor-preview.cjs +63 -63
- package/dist/theme-editor-preview.d.cts +20 -5
- package/dist/theme-editor-preview.d.ts +20 -5
- package/dist/theme-editor-preview.js +6 -6
- package/dist/theme-editor.d.cts +21 -6
- package/dist/theme-editor.d.ts +21 -6
- package/dist/voice-runtime-TWFJ4UUS.js +17 -0
- package/dist/voice-runtime.cjs +8 -8
- package/dist/voice-runtime.d.cts +31 -5
- package/dist/voice-runtime.d.ts +31 -5
- package/dist/voice-runtime.js +8 -8
- package/dist/webmcp-runtime-5Q3IHAHP.js +6 -0
- package/dist/webmcp-runtime.cjs +2 -2
- package/dist/webmcp-runtime.d.cts +22 -26
- package/dist/webmcp-runtime.d.ts +22 -26
- package/dist/webmcp-runtime.js +2 -2
- package/package.json +5 -2
- package/src/client.interrupt.test.ts +18 -0
- package/src/client.ts +7 -0
- package/src/generated/runtype-openapi-contract.ts +3 -0
- package/src/session.ts +11 -3
- package/src/session.voice.test.ts +140 -0
- package/src/theme-editor/webmcp/types.ts +1 -1
- package/src/types.ts +26 -5
- package/src/voice/runtype-voice-provider.ts +93 -11
- package/src/voice/voice-clause-buffer.test.ts +143 -0
- package/src/voice/voice-clause-buffer.ts +187 -0
- package/src/voice/voice-delegation.ts +85 -3
- package/src/voice/voice.test.ts +257 -2
- package/src/webmcp-bridge.test.ts +87 -21
- package/src/webmcp-bridge.ts +21 -26
- package/src/webmcp-polyfill.integration.test.ts +339 -0
- package/src/webmcp-polyfill.ts +7 -4
- package/src/webmcp-runtime-bundle.test.ts +1 -1
- package/src/webmcp-runtime-entry.ts +34 -19
- package/dist/chunk-3255TNY7.js +0 -1
- package/dist/voice-runtime-RHRX4F3T.js +0 -17
- package/dist/webmcp-runtime-V6ZWW3PI.js +0 -6
package/src/voice/voice.test.ts
CHANGED
|
@@ -896,7 +896,7 @@ describe('RuntypeVoiceProvider (realtime streaming)', () => {
|
|
|
896
896
|
|
|
897
897
|
it('declares client_delegation (never the kebab form) only with a bridge and the switch on', async () => {
|
|
898
898
|
const { ws } = await startDelegatedCall();
|
|
899
|
-
expect(ws.url).toBe(voiceUrl('wss://api.example.com', 'a1', [...BASE_CAPS, 'client_delegation', 'delegation_update', 'delegation_read_back']));
|
|
899
|
+
expect(ws.url).toBe(voiceUrl('wss://api.example.com', 'a1', [...BASE_CAPS, 'client_delegation', 'delegation_update', 'delegation_read_back', 'delegation_stream']));
|
|
900
900
|
expect(ws.url).not.toContain('client-delegation');
|
|
901
901
|
await startDelegatedCall({ clientDelegation: false });
|
|
902
902
|
expect(lastWs().url).toBe(voiceUrl('wss://api.example.com', 'a1', BASE_CAPS));
|
|
@@ -1268,6 +1268,261 @@ describe('RuntypeVoiceProvider (realtime streaming)', () => {
|
|
|
1268
1268
|
expect(sentJson(ws)).toEqual([]);
|
|
1269
1269
|
});
|
|
1270
1270
|
|
|
1271
|
+
describe('with delegation_stream negotiated', () => {
|
|
1272
|
+
const STREAM_CAPS = [...ALL_CAPS, 'delegation_stream', 'delegation_read_back'];
|
|
1273
|
+
const progress = (ws: MockWebSocket, delegationId: string, text = 'chunk') =>
|
|
1274
|
+
ws.triggerMessage(JSON.stringify({ type: 'delegation_progress', delegationId, text }));
|
|
1275
|
+
|
|
1276
|
+
it('sends delegation_delta pieces in order, then the result with streamedChars', async () => {
|
|
1277
|
+
const { ws, calls, pending } = await startDelegatedCall({}, { capabilities: STREAM_CAPS });
|
|
1278
|
+
delegate(ws, 'd1', 'hours?');
|
|
1279
|
+
await flush();
|
|
1280
|
+
expect(typeof calls[0].stream).toBe('function');
|
|
1281
|
+
calls[0].stream!('We open at eight. ');
|
|
1282
|
+
calls[0].stream!('We close at six.');
|
|
1283
|
+
await flush();
|
|
1284
|
+
expect(sentJson(ws)).toEqual([
|
|
1285
|
+
{ type: 'delegation_delta', delegationId: 'd1', text: 'We open at eight. ' },
|
|
1286
|
+
{ type: 'delegation_delta', delegationId: 'd1', text: 'We close at six.' },
|
|
1287
|
+
]);
|
|
1288
|
+
pending[0]({ status: 'completed', text: 'We open at eight. We close at six.', streamedChars: 34 });
|
|
1289
|
+
await flush();
|
|
1290
|
+
expect(sentJson(ws).at(-1)).toEqual({
|
|
1291
|
+
type: 'delegation_result',
|
|
1292
|
+
delegationId: 'd1',
|
|
1293
|
+
status: 'completed',
|
|
1294
|
+
text: 'We open at eight. We close at six.',
|
|
1295
|
+
streamedChars: 34,
|
|
1296
|
+
});
|
|
1297
|
+
});
|
|
1298
|
+
|
|
1299
|
+
it('omits streamedChars when nothing streamed, and puts it on a parked turn\'s update', async () => {
|
|
1300
|
+
const { ws, calls, pending } = await startDelegatedCall({}, { capabilities: STREAM_CAPS });
|
|
1301
|
+
delegate(ws, 'd1', 'q');
|
|
1302
|
+
await flush();
|
|
1303
|
+
pending[0]({ status: 'completed', text: 'Answer.', streamedChars: 0 });
|
|
1304
|
+
await flush();
|
|
1305
|
+
expect(sentJson(ws)).toEqual([{ type: 'delegation_result', delegationId: 'd1', status: 'completed', text: 'Answer.' }]);
|
|
1306
|
+
|
|
1307
|
+
delegate(ws, 'd2', 'order');
|
|
1308
|
+
await flush();
|
|
1309
|
+
calls[1].stream!('I can place that order. ');
|
|
1310
|
+
pending[1]({
|
|
1311
|
+
status: 'pending_approval',
|
|
1312
|
+
text: 'I can place that order.\n\nThis action needs approval.',
|
|
1313
|
+
streamedChars: 23,
|
|
1314
|
+
followUp: () => new Promise(() => {}),
|
|
1315
|
+
});
|
|
1316
|
+
await flush();
|
|
1317
|
+
expect(sentJson(ws).slice(1)).toEqual([
|
|
1318
|
+
{ type: 'delegation_delta', delegationId: 'd2', text: 'I can place that order. ' },
|
|
1319
|
+
{
|
|
1320
|
+
type: 'delegation_update',
|
|
1321
|
+
delegationId: 'd2',
|
|
1322
|
+
status: 'pending_approval',
|
|
1323
|
+
text: 'I can place that order.\n\nThis action needs approval.',
|
|
1324
|
+
streamedChars: 23,
|
|
1325
|
+
},
|
|
1326
|
+
]);
|
|
1327
|
+
});
|
|
1328
|
+
|
|
1329
|
+
it('does not stream when delegation_read_back is missing (a multi-utterance read-back could not stay folded)', async () => {
|
|
1330
|
+
const { ws, calls, pending } = await startDelegatedCall({}, { capabilities: [...ALL_CAPS, 'delegation_stream'] });
|
|
1331
|
+
delegate(ws, 'd1', 'q');
|
|
1332
|
+
await flush();
|
|
1333
|
+
expect(calls[0]).not.toHaveProperty('stream');
|
|
1334
|
+
pending[0]({ status: 'completed', text: 'Answer.', streamedChars: 7 });
|
|
1335
|
+
await flush();
|
|
1336
|
+
expect(sentJson(ws)).toEqual([{ type: 'delegation_result', delegationId: 'd1', status: 'completed', text: 'Answer.' }]);
|
|
1337
|
+
});
|
|
1338
|
+
|
|
1339
|
+
it('a cancellation after progress disarms that delegation\'s fold, not another\'s', async () => {
|
|
1340
|
+
const { ws, calls, transcripts } = await startDelegatedCall({}, { capabilities: STREAM_CAPS });
|
|
1341
|
+
delegate(ws, 'd1', 'q');
|
|
1342
|
+
await flush();
|
|
1343
|
+
calls[0].stream!('It is sunny. ');
|
|
1344
|
+
await flush();
|
|
1345
|
+
progress(ws, 'd1');
|
|
1346
|
+
ws.triggerMessage(JSON.stringify({ type: 'delegation_cancelled', delegationId: 'dx', reason: 'deadline' }));
|
|
1347
|
+
ws.triggerMessage(JSON.stringify({ type: 'delegation_cancelled', delegationId: 'd1', reason: 'provider_cancelled' }));
|
|
1348
|
+
ws.triggerMessage(update('assistant', 'Something unrelated.', 'x1')); // read-back never started: renders
|
|
1349
|
+
progress(ws, 'd1'); // cancelled: no frames, and no re-arm
|
|
1350
|
+
ws.triggerMessage(update('assistant', 'Still here.', 'x2'));
|
|
1351
|
+
expect(transcripts.map((t) => t[1])).toEqual(['Something unrelated.', 'Still here.']);
|
|
1352
|
+
});
|
|
1353
|
+
|
|
1354
|
+
it('a refusing warning after progress disarms the fold too', async () => {
|
|
1355
|
+
const { ws, calls, transcripts } = await startDelegatedCall({}, { capabilities: STREAM_CAPS });
|
|
1356
|
+
delegate(ws, 'd1', 'q');
|
|
1357
|
+
await flush();
|
|
1358
|
+
calls[0].stream!('It is sunny. ');
|
|
1359
|
+
await flush();
|
|
1360
|
+
progress(ws, 'd1');
|
|
1361
|
+
ws.triggerMessage(JSON.stringify({ type: 'warning', code: 'UNKNOWN_DELEGATION', delegationId: 'd1' }));
|
|
1362
|
+
ws.triggerMessage(update('assistant', 'Something unrelated.', 'x1'));
|
|
1363
|
+
expect(transcripts.map((t) => t[1])).toEqual(['Something unrelated.']);
|
|
1364
|
+
});
|
|
1365
|
+
|
|
1366
|
+
it('streams nothing (and sends no streamedChars) when the server did not grant it', async () => {
|
|
1367
|
+
const { ws, calls, pending } = await startDelegatedCall();
|
|
1368
|
+
delegate(ws, 'd1', 'q');
|
|
1369
|
+
await flush();
|
|
1370
|
+
expect(calls[0]).not.toHaveProperty('stream');
|
|
1371
|
+
pending[0]({ status: 'completed', text: 'Answer.', streamedChars: 7 });
|
|
1372
|
+
await flush();
|
|
1373
|
+
expect(sentJson(ws)).toEqual([{ type: 'delegation_result', delegationId: 'd1', status: 'completed', text: 'Answer.' }]);
|
|
1374
|
+
});
|
|
1375
|
+
|
|
1376
|
+
it('sends deltas after a slow call context, and nothing once the delegation is cancelled', async () => {
|
|
1377
|
+
let resolveContext!: (text: string) => void;
|
|
1378
|
+
const { ws, calls, pending } = await startDelegatedCall(
|
|
1379
|
+
{ callContext: () => new Promise<string>((resolve) => (resolveContext = resolve)) },
|
|
1380
|
+
{ capabilities: STREAM_CAPS },
|
|
1381
|
+
);
|
|
1382
|
+
delegate(ws, 'd1', 'q');
|
|
1383
|
+
await flush();
|
|
1384
|
+
calls[0].stream!('First piece. ');
|
|
1385
|
+
await flush();
|
|
1386
|
+
expect(sentJson(ws)).toEqual([]);
|
|
1387
|
+
resolveContext('On /docs.');
|
|
1388
|
+
await flush();
|
|
1389
|
+
expect(sentJson(ws).map((f) => f.type)).toEqual(['context', 'delegation_delta']);
|
|
1390
|
+
ws.triggerMessage(JSON.stringify({ type: 'delegation_cancelled', delegationId: 'd1', reason: 'provider_cancelled' }));
|
|
1391
|
+
calls[0].stream!('Second piece.');
|
|
1392
|
+
pending[0]({ status: 'completed', text: 'First piece. Second piece.', streamedChars: 26 });
|
|
1393
|
+
await flush();
|
|
1394
|
+
expect(sentJson(ws).map((f) => f.type)).toEqual(['context', 'delegation_delta']);
|
|
1395
|
+
});
|
|
1396
|
+
|
|
1397
|
+
it('folds the read-back on the first delegation_progress, once per spoken phase', async () => {
|
|
1398
|
+
const { provider, ws, calls, transcripts, pending } = await startDelegatedCall({}, { capabilities: STREAM_CAPS });
|
|
1399
|
+
const statuses: string[] = [];
|
|
1400
|
+
provider.onStatusChange((s) => statuses.push(s));
|
|
1401
|
+
ws.triggerMessage(update('assistant', 'Let me check.', 'f1'));
|
|
1402
|
+
delegate(ws, 'd1', 'q');
|
|
1403
|
+
await flush();
|
|
1404
|
+
calls[0].stream!('It is sunny. ');
|
|
1405
|
+
await flush();
|
|
1406
|
+
progress(ws, 'd1', 'It is sunny.');
|
|
1407
|
+
ws.triggerMessage(update('assistant', 'Let me check. Okay.', 'f1')); // the filler keeps updating
|
|
1408
|
+
ws.triggerMessage(update('assistant', 'It is sunny.', 'r1')); // read-back: folded
|
|
1409
|
+
progress(ws, 'd1', 'And warm.'); // a later chunk of the same phase
|
|
1410
|
+
ws.triggerMessage(update('assistant', 'It is sunny. And warm.', 'r1'));
|
|
1411
|
+
pending[0]({ status: 'completed', text: 'It is sunny. And warm.', streamedChars: 13 });
|
|
1412
|
+
await flush();
|
|
1413
|
+
completed(ws, 'd1'); // the phase ends: no second fold
|
|
1414
|
+
ws.triggerMessage(update('assistant', 'Anything else?', 'a2'));
|
|
1415
|
+
expect(transcripts.map((t) => t[1])).toEqual(['Let me check.', 'Let me check. Okay.', 'Anything else?']);
|
|
1416
|
+
// Progress keeps the call "processing" until delegation_completed.
|
|
1417
|
+
expect(statuses.filter((s) => s !== 'processing')).toEqual(['listening']);
|
|
1418
|
+
});
|
|
1419
|
+
|
|
1420
|
+
it('folds a single-shot result\'s read-back on progress, and ignores progress for delegations it did not answer', async () => {
|
|
1421
|
+
const { ws, transcripts, pending } = await startDelegatedCall({}, { capabilities: STREAM_CAPS });
|
|
1422
|
+
progress(ws, 'dx');
|
|
1423
|
+
ws.triggerMessage(update('assistant', 'Hello there.', 'g1')); // unrelated: renders
|
|
1424
|
+
delegate(ws, 'd1', 'q');
|
|
1425
|
+
await flush();
|
|
1426
|
+
pending[0]({ status: 'completed', text: 'It is sunny.' });
|
|
1427
|
+
await flush();
|
|
1428
|
+
progress(ws, 'd1');
|
|
1429
|
+
ws.triggerMessage(update('assistant', 'It is sunny.', 'r1'));
|
|
1430
|
+
ws.triggerMessage(update('user', 'thanks', 'u2'));
|
|
1431
|
+
ws.triggerMessage(update('assistant', 'You are welcome.', 'a2'));
|
|
1432
|
+
expect(transcripts.map((t) => t[1])).toEqual(['Hello there.', 'thanks', 'You are welcome.']);
|
|
1433
|
+
});
|
|
1434
|
+
|
|
1435
|
+
it('folds again for a later spoken phase (the approval outcome)', async () => {
|
|
1436
|
+
const { ws, transcripts, pending } = await startDelegatedCall({}, { capabilities: STREAM_CAPS });
|
|
1437
|
+
let settle!: (f: VoiceDelegationFollowUp | null) => void;
|
|
1438
|
+
delegate(ws, 'd1', 'order');
|
|
1439
|
+
await flush();
|
|
1440
|
+
pending[0]({ status: 'pending_approval', text: 'Approve it.', followUp: () => new Promise((r) => (settle = r)) });
|
|
1441
|
+
await flush();
|
|
1442
|
+
progress(ws, 'd1');
|
|
1443
|
+
ws.triggerMessage(update('assistant', 'Please approve it.', 'r1'));
|
|
1444
|
+
completed(ws, 'd1', false);
|
|
1445
|
+
settle({ status: 'completed', text: 'Order placed.' });
|
|
1446
|
+
await flush();
|
|
1447
|
+
progress(ws, 'd1');
|
|
1448
|
+
ws.triggerMessage(update('assistant', 'Your order is placed.', 'r2'));
|
|
1449
|
+
completed(ws, 'd1');
|
|
1450
|
+
ws.triggerMessage(update('assistant', 'Anything else?', 'a3'));
|
|
1451
|
+
expect(transcripts.map((t) => t[1])).toEqual(['Anything else?']);
|
|
1452
|
+
});
|
|
1453
|
+
|
|
1454
|
+
it('folds an untagged read-back after delegation_progress on a tag-negotiated server (the cut-filler continuation)', async () => {
|
|
1455
|
+
const { ws, calls, transcripts, pending } = await startDelegatedCall({}, { capabilities: STREAM_CAPS });
|
|
1456
|
+
const frame = (turnId: string, text: string, final: boolean, delegationId?: string) =>
|
|
1457
|
+
ws.triggerMessage(
|
|
1458
|
+
JSON.stringify({ type: 'transcript_update', role: 'assistant', text, utteranceId: turnId, final, delegationId }),
|
|
1459
|
+
);
|
|
1460
|
+
// GPT-Live's filler A is streaming when the delegation starts.
|
|
1461
|
+
frame('a1', 'Let me check', false);
|
|
1462
|
+
delegate(ws, 'd1', 'hours?');
|
|
1463
|
+
await flush();
|
|
1464
|
+
calls[0].stream!("We're open Monday to Friday from 8 to 6. ");
|
|
1465
|
+
await flush();
|
|
1466
|
+
expect(sentJson(ws).map((f) => f.type)).toEqual(['delegation_delta']);
|
|
1467
|
+
// Core cuts A (its final carries the start of the answer), reports progress...
|
|
1468
|
+
frame('a1', "Let me check on that for you. We're open Monday", true);
|
|
1469
|
+
progress(ws, 'd1');
|
|
1470
|
+
// ...and GPT-Live continues in a NEW utterance B that nobody tagged: folded.
|
|
1471
|
+
frame('b1', 'to Friday from 8 to 6.', false);
|
|
1472
|
+
frame('b1', 'to Friday from 8 to 6. Saturday 9 to 4.', true);
|
|
1473
|
+
pending[0]({ status: 'completed', text: "We're open Monday to Friday from 8 to 6.", streamedChars: 40 });
|
|
1474
|
+
await flush();
|
|
1475
|
+
completed(ws, 'd1'); // the tag server's completion arms nothing
|
|
1476
|
+
frame('n1', 'Anything else?', true); // renders
|
|
1477
|
+
// A later read-back tagged with the delegation folds by its tag.
|
|
1478
|
+
ws.triggerMessage(update('user', 'and sundays', 'u2'));
|
|
1479
|
+
frame('c1', 'Closed on Sundays.', true, 'd1');
|
|
1480
|
+
expect(transcripts.map((t) => t[1])).toEqual([
|
|
1481
|
+
'Let me check',
|
|
1482
|
+
"Let me check on that for you. We're open Monday",
|
|
1483
|
+
'Anything else?',
|
|
1484
|
+
'and sundays',
|
|
1485
|
+
]);
|
|
1486
|
+
});
|
|
1487
|
+
|
|
1488
|
+
it('a user transcript clears a progress-armed fold, and a tag fold consumes it (tag-negotiated server)', async () => {
|
|
1489
|
+
const { ws, calls, transcripts, pending } = await startDelegatedCall({}, { capabilities: STREAM_CAPS });
|
|
1490
|
+
const frame = (turnId: string, text: string, delegationId?: string) =>
|
|
1491
|
+
ws.triggerMessage(
|
|
1492
|
+
JSON.stringify({ type: 'transcript_update', role: 'assistant', text, utteranceId: turnId, final: true, delegationId }),
|
|
1493
|
+
);
|
|
1494
|
+
delegate(ws, 'd1', 'order');
|
|
1495
|
+
await flush();
|
|
1496
|
+
calls[0].stream!('I can do that. ');
|
|
1497
|
+
await flush();
|
|
1498
|
+
progress(ws, 'd1');
|
|
1499
|
+
ws.triggerMessage(update('user', 'wait', 'u1')); // the visitor spoke first: disarmed
|
|
1500
|
+
frame('x1', 'Sure, go ahead.'); // renders
|
|
1501
|
+
progress(ws, 'd1'); // same phase: no re-arm
|
|
1502
|
+
frame('x2', 'Still here.'); // renders
|
|
1503
|
+
// The approval ask ends that phase; the outcome's phase arms again.
|
|
1504
|
+
pending[0]({ status: 'pending_approval', text: 'I can do that.\n\nApprove it.', streamedChars: 14, followUp: () => new Promise(() => {}) });
|
|
1505
|
+
await flush();
|
|
1506
|
+
completed(ws, 'd1', false);
|
|
1507
|
+
progress(ws, 'd1');
|
|
1508
|
+
frame('r2', 'Your order is placed.', 'd1'); // folded by its tag, consuming the armed fold
|
|
1509
|
+
frame('x3', 'Anything else?'); // renders
|
|
1510
|
+
expect(transcripts.map((t) => t[1])).toEqual(['wait', 'Sure, go ahead.', 'Still here.', 'Anything else?']);
|
|
1511
|
+
});
|
|
1512
|
+
|
|
1513
|
+
it('folds a tagged read-back that starts while the answer is still streaming (delegation_read_back)', async () => {
|
|
1514
|
+
const { ws, calls, transcripts } = await startDelegatedCall({}, { capabilities: STREAM_CAPS });
|
|
1515
|
+
delegate(ws, 'd1', 'q');
|
|
1516
|
+
await flush();
|
|
1517
|
+
calls[0].stream!('It is sunny. ');
|
|
1518
|
+
await flush();
|
|
1519
|
+
ws.triggerMessage(
|
|
1520
|
+
JSON.stringify({ type: 'transcript_update', role: 'assistant', text: 'It is sunny.', utteranceId: 'r1', final: false, delegationId: 'd1' }),
|
|
1521
|
+
);
|
|
1522
|
+
expect(transcripts).toEqual([]);
|
|
1523
|
+
});
|
|
1524
|
+
});
|
|
1525
|
+
|
|
1271
1526
|
describe('approval lifecycle (delegation_update, then one terminal delegation_result)', () => {
|
|
1272
1527
|
/** A parked result whose follow-up the test settles, recording the options it got. */
|
|
1273
1528
|
const parked = () => {
|
|
@@ -1838,7 +2093,7 @@ describe('RuntypeVoiceProvider prewarm', () => {
|
|
|
1838
2093
|
provider.setSessionBridge({ getHistory: () => [], runDelegatedTurn: async () => ({ status: 'completed', text: '' }) });
|
|
1839
2094
|
provider.prewarm();
|
|
1840
2095
|
expect(lastWs().url).toBe(
|
|
1841
|
-
voiceUrl('wss://api.example.com', 'agent%2F1', ['attach', ...BASE_CAPS, 'client_delegation', 'delegation_update', 'delegation_read_back']),
|
|
2096
|
+
voiceUrl('wss://api.example.com', 'agent%2F1', ['attach', ...BASE_CAPS, 'client_delegation', 'delegation_update', 'delegation_read_back', 'delegation_stream']),
|
|
1842
2097
|
);
|
|
1843
2098
|
});
|
|
1844
2099
|
|
|
@@ -8,13 +8,13 @@ import type {
|
|
|
8
8
|
|
|
9
9
|
// ---------------------------------------------------------------------------
|
|
10
10
|
// Mock the strict @mcp-b/webmcp-polyfill. The bridge dynamically imports it and
|
|
11
|
-
// calls `initializeWebMCPPolyfill()`
|
|
11
|
+
// calls `initializeWebMCPPolyfill()` for the v5 automatic fallback.
|
|
12
12
|
// We stub the install as a no-op and provide `document.modelContext` ourselves,
|
|
13
13
|
// modeling the strict producer-preview surface the bridge consumes:
|
|
14
14
|
// - getTools(): async; inputSchema in either generation (see
|
|
15
15
|
// `registry.schemaShape`); annotations only when the tool declared them
|
|
16
|
-
// - executeTool(info,
|
|
17
|
-
// returns JSON.stringify(rawResult)
|
|
16
|
+
// - executeTool(info, input, { signal }): async; runs execute(),
|
|
17
|
+
// returns JSON.stringify(rawResult); honors the abort signal.
|
|
18
18
|
// ---------------------------------------------------------------------------
|
|
19
19
|
|
|
20
20
|
const polyfillMock = { initThrows: false };
|
|
@@ -39,7 +39,7 @@ import {
|
|
|
39
39
|
} from "./webmcp-bridge";
|
|
40
40
|
import type { ClientToolDefinition } from "./types";
|
|
41
41
|
|
|
42
|
-
type MockClient = {
|
|
42
|
+
type MockClient = { signal: AbortSignal };
|
|
43
43
|
|
|
44
44
|
type MockTool = {
|
|
45
45
|
name: string;
|
|
@@ -85,22 +85,22 @@ const makeModelContext = () => ({
|
|
|
85
85
|
: JSON.stringify(t.inputSchema),
|
|
86
86
|
}),
|
|
87
87
|
title: t.title ?? "",
|
|
88
|
+
window,
|
|
89
|
+
origin: location.origin,
|
|
88
90
|
...(t.annotations === undefined ? {} : { annotations: t.annotations }),
|
|
89
91
|
}));
|
|
90
92
|
},
|
|
91
93
|
async executeTool(
|
|
92
94
|
info: { name: string },
|
|
93
|
-
|
|
95
|
+
input: object,
|
|
94
96
|
options?: { signal?: AbortSignal },
|
|
95
97
|
): Promise<string | null> {
|
|
96
98
|
if (options?.signal?.aborted) throw new Error("Tool execution was cancelled");
|
|
97
99
|
const tool = registry.tools.find((t) => t.name === info.name);
|
|
98
100
|
if (!tool) throw new Error(`Tool not found: ${info.name}`);
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
const client: MockClient = {
|
|
102
|
-
requestUserInteraction: async (cb) => cb(),
|
|
103
|
-
};
|
|
101
|
+
if (!input || typeof input !== "object") throw new TypeError("inputObject must be an object");
|
|
102
|
+
const args = input as Record<string, unknown>;
|
|
103
|
+
const client: MockClient = { signal: options?.signal ?? new AbortController().signal };
|
|
104
104
|
const execPromise = Promise.resolve(tool.execute(args, client));
|
|
105
105
|
const raced = options?.signal
|
|
106
106
|
? Promise.race<unknown>([
|
|
@@ -118,7 +118,9 @@ const makeModelContext = () => ({
|
|
|
118
118
|
])
|
|
119
119
|
: execPromise;
|
|
120
120
|
const raw = await raced;
|
|
121
|
-
|
|
121
|
+
const json = JSON.stringify(raw);
|
|
122
|
+
if (json === undefined) throw new Error("Tool execution failed");
|
|
123
|
+
return json;
|
|
122
124
|
},
|
|
123
125
|
});
|
|
124
126
|
|
|
@@ -137,6 +139,7 @@ beforeEach(() => {
|
|
|
137
139
|
polyfillMock.initThrows = false;
|
|
138
140
|
registry.tools = [];
|
|
139
141
|
registry.schemaShape = "object";
|
|
142
|
+
vi.stubGlobal("window", {});
|
|
140
143
|
// location is read by snapshotForDispatch; document.modelContext is the
|
|
141
144
|
// consumer surface. Both are absent in the Node test environment.
|
|
142
145
|
vi.stubGlobal("location", { origin: "https://example.test" });
|
|
@@ -183,7 +186,7 @@ describe("WebMcpBridge.snapshotForDispatch", () => {
|
|
|
183
186
|
expect(bridge.isOperational()).toBe(false);
|
|
184
187
|
});
|
|
185
188
|
|
|
186
|
-
it("returns empty when
|
|
189
|
+
it("returns empty when the fallback initializer throws", async () => {
|
|
187
190
|
// No pre-existing modelContext: otherwise install() short-circuits
|
|
188
191
|
// before importing the polyfill and the throwing init never runs.
|
|
189
192
|
vi.stubGlobal("document", {});
|
|
@@ -331,7 +334,7 @@ describe("setWebMcpPolyfillLoader", () => {
|
|
|
331
334
|
vi.stubGlobal("document", doc);
|
|
332
335
|
registry.tools = [fakeTool({ name: "search" })];
|
|
333
336
|
const loader = vi.fn(async () => ({
|
|
334
|
-
|
|
337
|
+
installWebMCP: () => {
|
|
335
338
|
doc.modelContext = makeModelContext();
|
|
336
339
|
},
|
|
337
340
|
}));
|
|
@@ -457,7 +460,7 @@ describe("WebMcpBridge.executeToolCall", () => {
|
|
|
457
460
|
it("warns once and degrades cleanly when document.modelContext is present but incompatible", async () => {
|
|
458
461
|
// A different / older WebMCP polyfill (or divergent native draft) squats
|
|
459
462
|
// document.modelContext without the strict getTools()/executeTool() surface.
|
|
460
|
-
// @mcp-b's
|
|
463
|
+
// @mcp-b's installWebMCP correctly declines to overwrite it, so
|
|
461
464
|
// Persona must (a) report non-operational, (b) surface an actionable error
|
|
462
465
|
// distinct from "not available", and (c) warn exactly once.
|
|
463
466
|
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => undefined);
|
|
@@ -531,32 +534,31 @@ describe("WebMcpBridge.executeToolCall", () => {
|
|
|
531
534
|
expect(r).toEqual(shaped);
|
|
532
535
|
});
|
|
533
536
|
|
|
534
|
-
it("
|
|
537
|
+
it("returns an error for a non-serializable undefined result", async () => {
|
|
535
538
|
registry.tools = [fakeTool({ name: "act", execute: () => undefined })];
|
|
536
539
|
const bridge = new WebMcpBridge({ enabled: true, onConfirm: allowAll });
|
|
537
540
|
const r = await bridge.executeToolCall("webmcp:act", {});
|
|
538
|
-
expect(r).
|
|
541
|
+
expect(r.isError).toBe(true);
|
|
539
542
|
});
|
|
540
543
|
|
|
541
|
-
it("
|
|
544
|
+
it("passes an invocation signal with a single confirmation", async () => {
|
|
542
545
|
const confirmSpy = vi.fn(async () => true);
|
|
543
546
|
registry.tools = [
|
|
544
547
|
fakeTool({
|
|
545
548
|
name: "sensitive",
|
|
546
549
|
execute: async (_args, client) => {
|
|
547
|
-
|
|
548
|
-
return { ack };
|
|
550
|
+
return { aborted: client.signal.aborted };
|
|
549
551
|
},
|
|
550
552
|
}),
|
|
551
553
|
];
|
|
552
554
|
const bridge = new WebMcpBridge({ enabled: true, onConfirm: confirmSpy });
|
|
553
555
|
const r = await bridge.executeToolCall("webmcp:sensitive", {});
|
|
554
|
-
// Only the single outer gate fires
|
|
556
|
+
// Only the single outer gate fires; the callback receives a cancellation signal.
|
|
555
557
|
expect(confirmSpy).toHaveBeenCalledTimes(1);
|
|
556
558
|
expect(r.isError).toBeUndefined();
|
|
557
559
|
expect(r.content[0]).toEqual({
|
|
558
560
|
type: "text",
|
|
559
|
-
text: JSON.stringify({
|
|
561
|
+
text: JSON.stringify({ aborted: false }),
|
|
560
562
|
});
|
|
561
563
|
});
|
|
562
564
|
|
|
@@ -745,3 +747,67 @@ describe("computeClientToolsFingerprint: diff-only / send-once", () => {
|
|
|
745
747
|
expect(fp.length).toBeLessThanOrEqual(128);
|
|
746
748
|
});
|
|
747
749
|
});
|
|
750
|
+
|
|
751
|
+
describe("v6 local-document boundary", () => {
|
|
752
|
+
it("installs via the current lazy-module export", async () => {
|
|
753
|
+
const context = makeModelContext();
|
|
754
|
+
registry.tools = [fakeTool({ name: "local" })];
|
|
755
|
+
vi.stubGlobal("document", {});
|
|
756
|
+
setWebMcpPolyfillLoader(async () => ({
|
|
757
|
+
installWebMCP: () => { vi.stubGlobal("document", { modelContext: context }); },
|
|
758
|
+
}));
|
|
759
|
+
const snapshot = await new WebMcpBridge({ enabled: true }).snapshotForDispatch();
|
|
760
|
+
expect(snapshot.map(tool => tool.name)).toEqual(["local"]);
|
|
761
|
+
});
|
|
762
|
+
|
|
763
|
+
it("excludes other frames from snapshots and same-name execution", async () => {
|
|
764
|
+
const localWindow = {};
|
|
765
|
+
vi.stubGlobal("window", localWindow);
|
|
766
|
+
const local = { name: "shared", description: "Local", title: "Local title", origin: location.origin, window: localWindow };
|
|
767
|
+
const remote = { ...local, description: "Remote", title: "Remote title", window: {} };
|
|
768
|
+
vi.stubGlobal("document", { modelContext: {
|
|
769
|
+
getTools: async () => [remote, local, { ...remote, name: "remote_only" }],
|
|
770
|
+
executeTool: async (info: unknown) => JSON.stringify(info === local ? "local" : "remote"),
|
|
771
|
+
} });
|
|
772
|
+
const consumer = new WebMcpBridge({ enabled: true, onConfirm: allowAll });
|
|
773
|
+
const snapshot = await consumer.snapshotForDispatch();
|
|
774
|
+
expect(snapshot.map(tool => tool.name)).toEqual(["shared"]);
|
|
775
|
+
expect(getWebMcpToolDisplayTitle("shared")).toBe("Local title");
|
|
776
|
+
expect(await consumer.executeToolCall("webmcp:shared", {})).toEqual({ content: [{ type: "text", text: "local" }] });
|
|
777
|
+
expect((await consumer.executeToolCall("webmcp:remote_only", {})).isError).toBe(true);
|
|
778
|
+
});
|
|
779
|
+
});
|
|
780
|
+
|
|
781
|
+
describe("polyfill initializer compatibility", () => {
|
|
782
|
+
it("accepts the legacy initializer used by the automatic fallback", async () => {
|
|
783
|
+
vi.stubGlobal("document", {});
|
|
784
|
+
const initializeWebMCPPolyfill = vi.fn(() => {
|
|
785
|
+
vi.stubGlobal("document", { modelContext: makeModelContext() });
|
|
786
|
+
});
|
|
787
|
+
setWebMcpPolyfillLoader(async () => ({ initializeWebMCPPolyfill }));
|
|
788
|
+
const bridge = new WebMcpBridge({ enabled: true });
|
|
789
|
+
await bridge.snapshotForDispatch();
|
|
790
|
+
expect(initializeWebMCPPolyfill).toHaveBeenCalledTimes(1);
|
|
791
|
+
expect(bridge.isOperational()).toBe(true);
|
|
792
|
+
});
|
|
793
|
+
});
|
|
794
|
+
|
|
795
|
+
describe("native preview argument conversion", () => {
|
|
796
|
+
it("supports DOMString coercion without relying on polyfill metadata", async () => {
|
|
797
|
+
const nativeExecute = vi.fn(async (_tool: unknown, input: unknown) => {
|
|
798
|
+
// WebIDL converts an object to DOMString before the native JSON parser.
|
|
799
|
+
return JSON.stringify({ received: JSON.parse(String(input)) });
|
|
800
|
+
});
|
|
801
|
+
vi.stubGlobal("document", {
|
|
802
|
+
modelContext: {
|
|
803
|
+
getTools: async () => [{ name: "native", description: "Native preview tool" }],
|
|
804
|
+
executeTool: nativeExecute,
|
|
805
|
+
},
|
|
806
|
+
});
|
|
807
|
+
const consumer = new WebMcpBridge({ enabled: true, onConfirm: allowAll });
|
|
808
|
+
expect(await consumer.executeToolCall("webmcp:native", { count: 2 })).toEqual({
|
|
809
|
+
content: [{ type: "text", text: '{"received":{"count":2}}' }],
|
|
810
|
+
});
|
|
811
|
+
expect(nativeExecute).toHaveBeenCalledTimes(1);
|
|
812
|
+
});
|
|
813
|
+
});
|
package/src/webmcp-bridge.ts
CHANGED
|
@@ -14,26 +14,19 @@
|
|
|
14
14
|
* enforcement live on the Runtype API; this bridge mirrors those checks
|
|
15
15
|
* client-side as a usability convenience, not a security boundary.
|
|
16
16
|
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
* - `getTools()`: async; returns `{ name, description, inputSchema }` where
|
|
23
|
-
* `inputSchema` is a JSON *string*. Annotations are not exposed here.
|
|
24
|
-
* - `executeTool(toolInfo, inputArgsJson, { signal })`: async; validates args
|
|
25
|
-
* against the tool's schema, runs `execute()`, and returns the raw result as
|
|
26
|
-
* a JSON *string* (or `null` for `undefined`). Honors `signal` for abort.
|
|
17
|
+
* Supports v5's JSON-string input and v6's object input with a single call:
|
|
18
|
+
* the runtime passes a JSON object that also string-coerces to JSON text.
|
|
19
|
+
* Never infer the execution contract from metadata or retry a failed call.
|
|
20
|
+
* Only tools owned by this document are exposed: the wire addresses tools by
|
|
21
|
+
* name, not by frame. Callbacks must validate their own input.
|
|
27
22
|
*
|
|
28
|
-
* The
|
|
29
|
-
*
|
|
30
|
-
|
|
31
|
-
*
|
|
23
|
+
* The automatic fallback stays on v5 to preserve its browser requirements and
|
|
24
|
+
* result semantics. Host pages may install v5 or v6 themselves; Persona does
|
|
25
|
+
* not replace an existing context. v5's initializeWebMCPPolyfill() and v6's
|
|
26
|
+
* installWebMCP() are both accepted by custom loaders.
|
|
32
27
|
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
* the spec's `client.requestUserInteraction` callback internally; Persona cannot
|
|
36
|
-
* inject a nested confirm there, so the single outer gate is the whole story.)
|
|
28
|
+
* Every call passes one confirm gate before execution, regardless of
|
|
29
|
+
* annotations.readOnlyHint. v6 callbacks also receive an invocation signal.
|
|
37
30
|
*/
|
|
38
31
|
|
|
39
32
|
import type { ClientToolDefinition } from "./types";
|
|
@@ -59,18 +52,20 @@ export interface ModelContextToolInfo {
|
|
|
59
52
|
* object. `@mcp-b/webmcp-polyfill` made the switch in v5, and Chrome ships it
|
|
60
53
|
* from 154.0.8013 (cross-document tools first) -- but Chrome 149-153, most of
|
|
61
54
|
* the Origin Trial population, and 154's same-document tools still emit the
|
|
62
|
-
* string. Both
|
|
55
|
+
* string. Both input execution contracts are supported independently of
|
|
56
|
+
* schema representation.
|
|
63
57
|
*
|
|
64
58
|
* Absent when the tool registered no schema: v5 omits the member, where v4
|
|
65
59
|
* always emitted a `{"type":"object"}` string.
|
|
66
60
|
*/
|
|
67
61
|
inputSchema?: object | string;
|
|
62
|
+
/** Owning frame and origin, when supplied by the registry. Never sent on the wire. */
|
|
63
|
+
window?: Window;
|
|
64
|
+
origin?: string;
|
|
68
65
|
/**
|
|
69
66
|
* Display title declared on the tool (`ToolDescriptor.title` in the WebMCP
|
|
70
67
|
* spec). The polyfill returns `""` when the tool didn't declare one. Note:
|
|
71
|
-
* `
|
|
72
|
-
* this strict consumer surface: top-level `title` is the only display-name
|
|
73
|
-
* channel available to us.
|
|
68
|
+
* top-level `title` is the display-name channel, not `annotations.title`.
|
|
74
69
|
*/
|
|
75
70
|
title?: string;
|
|
76
71
|
/**
|
|
@@ -89,7 +84,7 @@ export interface ModelContextCoreLike {
|
|
|
89
84
|
getTools(): Promise<ModelContextToolInfo[]>;
|
|
90
85
|
executeTool(
|
|
91
86
|
tool: ModelContextToolInfo,
|
|
92
|
-
|
|
87
|
+
input: object | string,
|
|
93
88
|
options?: { signal?: AbortSignal },
|
|
94
89
|
): Promise<string | null>;
|
|
95
90
|
}
|
|
@@ -132,9 +127,9 @@ export const getWebMcpToolDisplayTitle = (
|
|
|
132
127
|
): string | undefined => webMcpToolDisplayTitles.get(stripWebMcpPrefix(toolName));
|
|
133
128
|
|
|
134
129
|
/** The slice of `@mcp-b/webmcp-polyfill` the bridge consumes on install. */
|
|
135
|
-
export type WebMcpPolyfillModule =
|
|
136
|
-
initializeWebMCPPolyfill: () => void
|
|
137
|
-
};
|
|
130
|
+
export type WebMcpPolyfillModule =
|
|
131
|
+
| { initializeWebMCPPolyfill: () => void }
|
|
132
|
+
| { installWebMCP: () => void };
|
|
138
133
|
|
|
139
134
|
/**
|
|
140
135
|
* Override how the polyfill module is obtained. By default the bridge does
|