@vellumai/assistant 0.11.9-dev.202609080045.119baa5 → 0.11.9-dev.202609080628.cb7bdcf
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/node_modules/@vellumai/gateway-client/src/gateway-ipc-contracts.ts +72 -0
- package/package.json +1 -1
- package/src/__tests__/platform-callback-registration.test.ts +162 -3
- package/src/__tests__/plugin-api-webhook-url.test.ts +66 -4
- package/src/calls/__tests__/voice-session-bridge.test.ts +166 -0
- package/src/calls/voice-session-bridge.ts +91 -0
- package/src/cli/commands/__tests__/conversations-slack.test.ts +12 -0
- package/src/config/__tests__/webhook-routing.test.ts +42 -0
- package/src/config/feature-flag-registry.json +8 -0
- package/src/config/webhook-routing.ts +12 -2
- package/src/daemon/__tests__/turn-actor.test.ts +68 -0
- package/src/daemon/conversation-messaging.ts +3 -2
- package/src/daemon/conversation-surfaces.ts +3 -8
- package/src/daemon/conversation.ts +36 -6
- package/src/daemon/turn-actor.ts +46 -0
- package/src/inbound/platform-callback-registration.ts +77 -11
- package/src/inbound/pod-webhook-claim.ts +35 -0
- package/src/inbound/velay-webhooks-gate.ts +20 -0
- package/src/ipc/gateway-client.ts +91 -0
- package/src/live-voice/__tests__/live-voice-session-guardian.test.ts +127 -0
- package/src/live-voice/live-voice-connection.ts +14 -1
- package/src/live-voice/live-voice-session-manager.ts +10 -1
- package/src/live-voice/live-voice-session.ts +119 -28
- package/src/plugin-api/webhook-url.ts +18 -4
- package/src/runtime/http-server.ts +23 -0
- package/src/runtime/routes/__tests__/webhook-routes.test.ts +83 -0
- package/src/runtime/routes/webhook-routes.ts +24 -2
|
@@ -401,6 +401,78 @@ export type ContactsIdentitySnapshotIpcResponse = z.infer<
|
|
|
401
401
|
typeof ContactsIdentitySnapshotIpcResponseSchema
|
|
402
402
|
>;
|
|
403
403
|
|
|
404
|
+
// ── webhook ingress routes ───────────────────────────────────────────────────
|
|
405
|
+
// The gateway owns the registry of subpaths this assistant answers from
|
|
406
|
+
// outside; the daemon claims, drops, and inspects them over IPC. Both sides
|
|
407
|
+
// read these schemas so a change to the stored row has to travel through the
|
|
408
|
+
// contract.
|
|
409
|
+
|
|
410
|
+
/**
|
|
411
|
+
* Feature flag gating the whole webhook ingress registry. The gateway reads it
|
|
412
|
+
* to decide whether to accept a claim and how to advertise its allowed paths,
|
|
413
|
+
* and the daemon reads it to decide whether to claim at all, so both sides have
|
|
414
|
+
* to name the same key.
|
|
415
|
+
*/
|
|
416
|
+
export const WebhookIngressRouteSchema = z.object({
|
|
417
|
+
path: z.string(),
|
|
418
|
+
type: z.string(),
|
|
419
|
+
source: z.string().nullable(),
|
|
420
|
+
match: z.literal("exact"),
|
|
421
|
+
createdAt: z.number(),
|
|
422
|
+
lastRegisteredAt: z.number(),
|
|
423
|
+
});
|
|
424
|
+
|
|
425
|
+
export type WebhookIngressRoute = z.infer<typeof WebhookIngressRouteSchema>;
|
|
426
|
+
|
|
427
|
+
export const RegisterWebhookRouteIpcParamsSchema = z.object({
|
|
428
|
+
/** Exact subpath, leading slash included, under `/webhooks/`. */
|
|
429
|
+
path: z.string().min(1),
|
|
430
|
+
type: z.string().min(1),
|
|
431
|
+
source: z.string().nullish(),
|
|
432
|
+
});
|
|
433
|
+
|
|
434
|
+
export type RegisterWebhookRouteIpcParams = z.infer<
|
|
435
|
+
typeof RegisterWebhookRouteIpcParamsSchema
|
|
436
|
+
>;
|
|
437
|
+
|
|
438
|
+
/**
|
|
439
|
+
* A refusal is a normal result rather than an error: the daemon reads
|
|
440
|
+
* `disabled` as "this assistant is not serving its own webhooks" and falls
|
|
441
|
+
* back to the platform's callback routes.
|
|
442
|
+
*/
|
|
443
|
+
export const RegisterWebhookRouteIpcResponseSchema = z.union([
|
|
444
|
+
z.object({ disabled: z.literal(true) }),
|
|
445
|
+
z.object({ disabled: z.literal(false), route: WebhookIngressRouteSchema }),
|
|
446
|
+
]);
|
|
447
|
+
|
|
448
|
+
export type RegisterWebhookRouteIpcResponse = z.infer<
|
|
449
|
+
typeof RegisterWebhookRouteIpcResponseSchema
|
|
450
|
+
>;
|
|
451
|
+
|
|
452
|
+
export const UnregisterWebhookRouteIpcParamsSchema = z.object({
|
|
453
|
+
path: z.string().min(1),
|
|
454
|
+
});
|
|
455
|
+
|
|
456
|
+
export type UnregisterWebhookRouteIpcParams = z.infer<
|
|
457
|
+
typeof UnregisterWebhookRouteIpcParamsSchema
|
|
458
|
+
>;
|
|
459
|
+
|
|
460
|
+
export const UnregisterWebhookRouteIpcResponseSchema = z.object({
|
|
461
|
+
removed: z.boolean(),
|
|
462
|
+
});
|
|
463
|
+
|
|
464
|
+
export type UnregisterWebhookRouteIpcResponse = z.infer<
|
|
465
|
+
typeof UnregisterWebhookRouteIpcResponseSchema
|
|
466
|
+
>;
|
|
467
|
+
|
|
468
|
+
export const ListWebhookRoutesIpcResponseSchema = z.object({
|
|
469
|
+
routes: z.array(WebhookIngressRouteSchema),
|
|
470
|
+
});
|
|
471
|
+
|
|
472
|
+
export type ListWebhookRoutesIpcResponse = z.infer<
|
|
473
|
+
typeof ListWebhookRoutesIpcResponseSchema
|
|
474
|
+
>;
|
|
475
|
+
|
|
404
476
|
// ── classify_risk ────────────────────────────────────────────────────────────
|
|
405
477
|
// Risk classification is gateway-owned; the assistant sends one request per
|
|
406
478
|
// tool invocation and reads the whole answer back. The gateway validates the
|
package/package.json
CHANGED
|
@@ -1,15 +1,35 @@
|
|
|
1
1
|
import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
|
|
2
2
|
|
|
3
|
+
import type { RegisterWebhookRouteIpcParams } from "@vellumai/gateway-client";
|
|
4
|
+
|
|
3
5
|
import { setIngressPublicBaseUrl } from "../config/env.js";
|
|
6
|
+
import type { IpcRegisterWebhookRouteResult } from "../ipc/gateway-client.js";
|
|
4
7
|
import { credentialKey } from "../security/credential-key.js";
|
|
5
8
|
|
|
6
9
|
let mockIsPlatform = true;
|
|
10
|
+
let mockVelayWebhooksEnabled = false;
|
|
7
11
|
let mockPlatformBaseUrl = "";
|
|
8
12
|
let mockPlatformAssistantId = "";
|
|
9
13
|
let mockSecureKeys: Record<string, string> = {};
|
|
10
14
|
let mockConfig: { ingress?: { publicBaseUrl?: string; enabled?: boolean } } =
|
|
11
15
|
{};
|
|
12
16
|
|
|
17
|
+
const REGISTERED_ROUTE = {
|
|
18
|
+
path: "/webhooks/twilio/voice",
|
|
19
|
+
type: "twilio_voice",
|
|
20
|
+
source: null,
|
|
21
|
+
match: "exact" as const,
|
|
22
|
+
createdAt: 1,
|
|
23
|
+
lastRegisteredAt: 1,
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
let mockRegisterWebhookRouteResult: IpcRegisterWebhookRouteResult = {
|
|
27
|
+
ok: true,
|
|
28
|
+
disabled: false,
|
|
29
|
+
route: REGISTERED_ROUTE,
|
|
30
|
+
};
|
|
31
|
+
let ipcRegisterCalls: RegisterWebhookRouteIpcParams[] = [];
|
|
32
|
+
|
|
13
33
|
// Bun shares mocked modules across test files in a combined run, so each mock
|
|
14
34
|
// spreads the real module and overrides only what this file drives. Replacing
|
|
15
35
|
// a module wholesale drops the exports peer tests import from it and breaks
|
|
@@ -20,6 +40,12 @@ mock.module("../config/env-registry.js", () => ({
|
|
|
20
40
|
getIsPlatform: () => mockIsPlatform,
|
|
21
41
|
}));
|
|
22
42
|
|
|
43
|
+
const actualVelayGate = await import("../inbound/velay-webhooks-gate.js");
|
|
44
|
+
mock.module("../inbound/velay-webhooks-gate.js", () => ({
|
|
45
|
+
...actualVelayGate,
|
|
46
|
+
isVelayWebhooksEnabled: () => mockVelayWebhooksEnabled,
|
|
47
|
+
}));
|
|
48
|
+
|
|
23
49
|
const actualEnv = await import("../config/env.js");
|
|
24
50
|
mock.module("../config/env.js", () => ({
|
|
25
51
|
...actualEnv,
|
|
@@ -27,6 +53,15 @@ mock.module("../config/env.js", () => ({
|
|
|
27
53
|
getPlatformAssistantId: () => mockPlatformAssistantId,
|
|
28
54
|
}));
|
|
29
55
|
|
|
56
|
+
const actualGatewayClient = await import("../ipc/gateway-client.js");
|
|
57
|
+
mock.module("../ipc/gateway-client.js", () => ({
|
|
58
|
+
...actualGatewayClient,
|
|
59
|
+
ipcRegisterWebhookRoute: async (input: RegisterWebhookRouteIpcParams) => {
|
|
60
|
+
ipcRegisterCalls.push(input);
|
|
61
|
+
return mockRegisterWebhookRouteResult;
|
|
62
|
+
},
|
|
63
|
+
}));
|
|
64
|
+
|
|
30
65
|
const actualSecureKeys = await import("../security/secure-keys.js");
|
|
31
66
|
mock.module("../security/secure-keys.js", () => ({
|
|
32
67
|
...actualSecureKeys,
|
|
@@ -207,9 +242,7 @@ describe("platform callback registration", () => {
|
|
|
207
242
|
|
|
208
243
|
await expect(
|
|
209
244
|
registerCallbackRoute("webhooks/telegram", "telegram"),
|
|
210
|
-
).resolves.toBe(
|
|
211
|
-
"https://my-assistant.example.com/v1/gateway/callbacks/x/",
|
|
212
|
-
);
|
|
245
|
+
).resolves.toBe("https://my-assistant.example.com/v1/gateway/callbacks/x/");
|
|
213
246
|
});
|
|
214
247
|
|
|
215
248
|
test("self-hosted registerCallbackRoute sends detected module-level callback_base_url", async () => {
|
|
@@ -373,6 +406,7 @@ describe("resolveCallbackUrl resolution order", () => {
|
|
|
373
406
|
|
|
374
407
|
beforeEach(() => {
|
|
375
408
|
mockIsPlatform = false;
|
|
409
|
+
mockVelayWebhooksEnabled = false;
|
|
376
410
|
mockPlatformBaseUrl = "";
|
|
377
411
|
mockPlatformAssistantId = "";
|
|
378
412
|
mockSecureKeys = {};
|
|
@@ -380,6 +414,12 @@ describe("resolveCallbackUrl resolution order", () => {
|
|
|
380
414
|
setIngressPublicBaseUrl(undefined);
|
|
381
415
|
delete process.env.ASSISTANT_API_KEY;
|
|
382
416
|
registerCalls = 0;
|
|
417
|
+
ipcRegisterCalls = [];
|
|
418
|
+
mockRegisterWebhookRouteResult = {
|
|
419
|
+
ok: true,
|
|
420
|
+
disabled: false,
|
|
421
|
+
route: REGISTERED_ROUTE,
|
|
422
|
+
};
|
|
383
423
|
globalThis.fetch = mock(async () => {
|
|
384
424
|
registerCalls++;
|
|
385
425
|
return new Response(
|
|
@@ -408,6 +448,124 @@ describe("resolveCallbackUrl resolution order", () => {
|
|
|
408
448
|
resolveCallbackUrl(noIngress, "webhooks/twilio/voice", "twilio_voice"),
|
|
409
449
|
).resolves.toBe(PLATFORM_URL);
|
|
410
450
|
expect(registerCalls).toBe(1);
|
|
451
|
+
expect(ipcRegisterCalls).toEqual([]);
|
|
452
|
+
});
|
|
453
|
+
|
|
454
|
+
test("velay-webhooks on: a pod claims the subpath and uses the published URL", async () => {
|
|
455
|
+
mockIsPlatform = true;
|
|
456
|
+
mockVelayWebhooksEnabled = true;
|
|
457
|
+
seedPlatformCredentials();
|
|
458
|
+
|
|
459
|
+
await expect(
|
|
460
|
+
resolveCallbackUrl(
|
|
461
|
+
() => "https://velay.example.com/assistant-1/webhooks/twilio/voice",
|
|
462
|
+
"webhooks/twilio/voice",
|
|
463
|
+
"twilio_voice",
|
|
464
|
+
{ callSessionId: "conv-xyz" },
|
|
465
|
+
"+15555550142",
|
|
466
|
+
),
|
|
467
|
+
).resolves.toBe(
|
|
468
|
+
"https://velay.example.com/assistant-1/webhooks/twilio/voice",
|
|
469
|
+
);
|
|
470
|
+
expect(registerCalls).toBe(0);
|
|
471
|
+
expect(ipcRegisterCalls).toEqual([
|
|
472
|
+
{
|
|
473
|
+
path: "/webhooks/twilio/voice",
|
|
474
|
+
type: "twilio_voice",
|
|
475
|
+
source: "+15555550142",
|
|
476
|
+
},
|
|
477
|
+
]);
|
|
478
|
+
});
|
|
479
|
+
|
|
480
|
+
test("velay-webhooks on: a refused claim falls back to the platform", async () => {
|
|
481
|
+
mockIsPlatform = true;
|
|
482
|
+
mockVelayWebhooksEnabled = true;
|
|
483
|
+
mockRegisterWebhookRouteResult = { ok: true, disabled: true };
|
|
484
|
+
seedPlatformCredentials();
|
|
485
|
+
|
|
486
|
+
await expect(
|
|
487
|
+
resolveCallbackUrl(
|
|
488
|
+
() => "https://velay.example.com/assistant-1/webhooks/twilio/voice",
|
|
489
|
+
"webhooks/twilio/voice",
|
|
490
|
+
"twilio_voice",
|
|
491
|
+
),
|
|
492
|
+
).resolves.toBe(PLATFORM_URL);
|
|
493
|
+
expect(registerCalls).toBe(1);
|
|
494
|
+
expect(ipcRegisterCalls).toHaveLength(1);
|
|
495
|
+
});
|
|
496
|
+
|
|
497
|
+
test("velay-webhooks on: an unreachable gateway falls back to the platform", async () => {
|
|
498
|
+
mockIsPlatform = true;
|
|
499
|
+
mockVelayWebhooksEnabled = true;
|
|
500
|
+
mockRegisterWebhookRouteResult = { ok: false, reason: "no_response" };
|
|
501
|
+
seedPlatformCredentials();
|
|
502
|
+
|
|
503
|
+
await expect(
|
|
504
|
+
resolveCallbackUrl(
|
|
505
|
+
() => "https://velay.example.com/assistant-1/webhooks/twilio/voice",
|
|
506
|
+
"webhooks/twilio/voice",
|
|
507
|
+
"twilio_voice",
|
|
508
|
+
),
|
|
509
|
+
).resolves.toBe(PLATFORM_URL);
|
|
510
|
+
expect(registerCalls).toBe(1);
|
|
511
|
+
expect(ipcRegisterCalls).toHaveLength(1);
|
|
512
|
+
});
|
|
513
|
+
|
|
514
|
+
test("velay-webhooks on: a pod with no published URL still registers with the platform", async () => {
|
|
515
|
+
mockIsPlatform = true;
|
|
516
|
+
mockVelayWebhooksEnabled = true;
|
|
517
|
+
seedPlatformCredentials();
|
|
518
|
+
|
|
519
|
+
await expect(
|
|
520
|
+
resolveCallbackUrl(noIngress, "webhooks/twilio/voice", "twilio_voice"),
|
|
521
|
+
).resolves.toBe(PLATFORM_URL);
|
|
522
|
+
expect(registerCalls).toBe(1);
|
|
523
|
+
expect(ipcRegisterCalls).toEqual([]);
|
|
524
|
+
});
|
|
525
|
+
|
|
526
|
+
test("velay-webhooks on: a pod with ingress disabled falls back instead of throwing", async () => {
|
|
527
|
+
mockIsPlatform = true;
|
|
528
|
+
mockVelayWebhooksEnabled = true;
|
|
529
|
+
seedPlatformCredentials();
|
|
530
|
+
|
|
531
|
+
await expect(
|
|
532
|
+
resolveCallbackUrl(
|
|
533
|
+
ingressDisabled,
|
|
534
|
+
"webhooks/twilio/voice",
|
|
535
|
+
"twilio_voice",
|
|
536
|
+
),
|
|
537
|
+
).resolves.toBe(PLATFORM_URL);
|
|
538
|
+
expect(registerCalls).toBe(1);
|
|
539
|
+
expect(ipcRegisterCalls).toEqual([]);
|
|
540
|
+
});
|
|
541
|
+
|
|
542
|
+
test("velay-webhooks on: self-hosted opt-out still surfaces", async () => {
|
|
543
|
+
mockVelayWebhooksEnabled = true;
|
|
544
|
+
seedPlatformCredentials();
|
|
545
|
+
|
|
546
|
+
await expect(
|
|
547
|
+
resolveCallbackUrl(
|
|
548
|
+
ingressDisabled,
|
|
549
|
+
"webhooks/twilio/voice",
|
|
550
|
+
"twilio_voice",
|
|
551
|
+
),
|
|
552
|
+
).rejects.toThrow("Public ingress is disabled");
|
|
553
|
+
expect(registerCalls).toBe(0);
|
|
554
|
+
expect(ipcRegisterCalls).toEqual([]);
|
|
555
|
+
});
|
|
556
|
+
|
|
557
|
+
test("velay-webhooks on: a self-hosted ingress URL is not claimed on the gateway", async () => {
|
|
558
|
+
mockVelayWebhooksEnabled = true;
|
|
559
|
+
seedPlatformCredentials();
|
|
560
|
+
|
|
561
|
+
await expect(
|
|
562
|
+
resolveCallbackUrl(
|
|
563
|
+
() => "https://tunnel.example.com/webhooks/twilio/voice",
|
|
564
|
+
"webhooks/twilio/voice",
|
|
565
|
+
"twilio_voice",
|
|
566
|
+
),
|
|
567
|
+
).resolves.toBe("https://tunnel.example.com/webhooks/twilio/voice");
|
|
568
|
+
expect(ipcRegisterCalls).toEqual([]);
|
|
411
569
|
});
|
|
412
570
|
|
|
413
571
|
test("a configured ingress wins over platform connectivity", async () => {
|
|
@@ -421,6 +579,7 @@ describe("resolveCallbackUrl resolution order", () => {
|
|
|
421
579
|
),
|
|
422
580
|
).resolves.toBe("https://tunnel.example.com/webhooks/twilio/voice");
|
|
423
581
|
expect(registerCalls).toBe(0);
|
|
582
|
+
expect(ipcRegisterCalls).toEqual([]);
|
|
424
583
|
});
|
|
425
584
|
|
|
426
585
|
test("a platform-connected assistant with no ingress registers with the platform", async () => {
|
|
@@ -8,13 +8,29 @@
|
|
|
8
8
|
* registration type it derives, and what it refuses.
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
|
-
import { describe, expect, spyOn, test } from "bun:test";
|
|
11
|
+
import { afterEach, describe, expect, mock, spyOn, test } from "bun:test";
|
|
12
12
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
13
|
+
let mockIsPlatform = false;
|
|
14
|
+
|
|
15
|
+
// Bun shares mocked modules across test files in a combined run, so the mock
|
|
16
|
+
// spreads the real module and overrides only what this file drives.
|
|
17
|
+
const actualEnvRegistry = await import("../config/env-registry.js");
|
|
18
|
+
mock.module("../config/env-registry.js", () => ({
|
|
19
|
+
...actualEnvRegistry,
|
|
20
|
+
getIsPlatform: () => mockIsPlatform,
|
|
21
|
+
}));
|
|
22
|
+
|
|
23
|
+
const loader = await import("../config/loader.js");
|
|
24
|
+
const registration =
|
|
25
|
+
await import("../inbound/platform-callback-registration.js");
|
|
26
|
+
const { resolveWebhookUrl } = await import("../plugin-api/webhook-url.js");
|
|
27
|
+
const { runInPluginContext } =
|
|
28
|
+
await import("../plugins/plugin-execution-context.js");
|
|
16
29
|
|
|
17
30
|
describe("resolveWebhookUrl", () => {
|
|
31
|
+
afterEach(() => {
|
|
32
|
+
mockIsPlatform = false;
|
|
33
|
+
});
|
|
18
34
|
test("composes the plugin's namespaced path and delegates the tier choice", async () => {
|
|
19
35
|
const spy = spyOn(registration, "resolveCallbackUrl").mockResolvedValue(
|
|
20
36
|
"https://callbacks.vellum.ai/abc/webhooks/plugins/imessage/events-photon",
|
|
@@ -143,6 +159,52 @@ describe("resolveWebhookUrl", () => {
|
|
|
143
159
|
spy.mockRestore();
|
|
144
160
|
});
|
|
145
161
|
|
|
162
|
+
test("hands out an ingress URL under exactly the path it claimed", async () => {
|
|
163
|
+
// The Velay tunnel forwards the request path verbatim and the gateway
|
|
164
|
+
// admits it only when it matches a claimed path byte for byte. A trailing
|
|
165
|
+
// slash on this URL would be rejected before the gateway ever saw it.
|
|
166
|
+
mockIsPlatform = true;
|
|
167
|
+
const configSpy = spyOn(loader, "getConfig").mockReturnValue({
|
|
168
|
+
ingress: { publicBaseUrl: "https://velay.vellum.ai/assistant-abc" },
|
|
169
|
+
} as ReturnType<typeof loader.getConfig>);
|
|
170
|
+
const spy = spyOn(registration, "resolveCallbackUrl").mockImplementation(
|
|
171
|
+
async (directUrl) => directUrl(),
|
|
172
|
+
);
|
|
173
|
+
|
|
174
|
+
const url = await resolveWebhookUrl({
|
|
175
|
+
plugin: "imessage",
|
|
176
|
+
path: "events-photon",
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
const claimedPath = `/${spy.mock.calls[0]![1]}`;
|
|
180
|
+
expect(claimedPath).toBe("/webhooks/plugins/imessage/events-photon");
|
|
181
|
+
expect(new URL(url).pathname).toBe(`/assistant-abc${claimedPath}`);
|
|
182
|
+
spy.mockRestore();
|
|
183
|
+
configSpy.mockRestore();
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
test("keeps the trailing slash on a self-hosted ingress URL", async () => {
|
|
187
|
+
// Off a pod the direct URL never rides the tunnel, so the spelling stays
|
|
188
|
+
// what it always was.
|
|
189
|
+
const configSpy = spyOn(loader, "getConfig").mockReturnValue({
|
|
190
|
+
ingress: { publicBaseUrl: "https://assistant.example.test" },
|
|
191
|
+
} as ReturnType<typeof loader.getConfig>);
|
|
192
|
+
const spy = spyOn(registration, "resolveCallbackUrl").mockImplementation(
|
|
193
|
+
async (directUrl) => directUrl(),
|
|
194
|
+
);
|
|
195
|
+
|
|
196
|
+
const url = await resolveWebhookUrl({
|
|
197
|
+
plugin: "imessage",
|
|
198
|
+
path: "events-photon",
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
expect(url).toBe(
|
|
202
|
+
"https://assistant.example.test/webhooks/plugins/imessage/events-photon/",
|
|
203
|
+
);
|
|
204
|
+
spy.mockRestore();
|
|
205
|
+
configSpy.mockRestore();
|
|
206
|
+
});
|
|
207
|
+
|
|
146
208
|
test("leaves a URL carrying a query string alone", async () => {
|
|
147
209
|
// Appending there would cut into the query rather than the path.
|
|
148
210
|
const spy = spyOn(registration, "resolveCallbackUrl").mockResolvedValue(
|
|
@@ -310,6 +310,7 @@ interface FakeTurnState {
|
|
|
310
310
|
assistantId: string | undefined;
|
|
311
311
|
callSessionId: string | undefined;
|
|
312
312
|
trustContext: unknown;
|
|
313
|
+
actorPrincipalId: string | undefined;
|
|
313
314
|
commandIntent: unknown;
|
|
314
315
|
turnChannelContext: unknown;
|
|
315
316
|
turnInterfaceContext: unknown;
|
|
@@ -332,6 +333,8 @@ function wireTurnState(
|
|
|
332
333
|
const conv = fake as FakeConversation & {
|
|
333
334
|
assistantId?: string;
|
|
334
335
|
trustContext?: unknown;
|
|
336
|
+
currentTurnSourceActorPrincipalId?: string;
|
|
337
|
+
currentTurnActorStampGeneration?: number;
|
|
335
338
|
commandIntent?: unknown;
|
|
336
339
|
channelCapabilities?: unknown;
|
|
337
340
|
voiceCallControlPrompt?: string;
|
|
@@ -341,6 +344,22 @@ function wireTurnState(
|
|
|
341
344
|
conv.assistantId = initial.assistantId;
|
|
342
345
|
conv.callSessionId = initial.callSessionId;
|
|
343
346
|
conv.trustContext = initial.trustContext;
|
|
347
|
+
// The real Conversation counts every write to the actor stamp behind an
|
|
348
|
+
// accessor (see `currentTurnActorStampGeneration`), which is what lets a
|
|
349
|
+
// turn tell its own stamp from a concurrent writer's. The fake counts them
|
|
350
|
+
// the same way, so the bridge's check is exercised here rather than being
|
|
351
|
+
// trivially true against a plain property.
|
|
352
|
+
let actorPrincipal = initial.actorPrincipalId;
|
|
353
|
+
conv.currentTurnActorStampGeneration = 0;
|
|
354
|
+
Object.defineProperty(conv, "currentTurnSourceActorPrincipalId", {
|
|
355
|
+
configurable: true,
|
|
356
|
+
get: () => actorPrincipal,
|
|
357
|
+
set: (value: string | undefined) => {
|
|
358
|
+
actorPrincipal = value;
|
|
359
|
+
conv.currentTurnActorStampGeneration =
|
|
360
|
+
(conv.currentTurnActorStampGeneration ?? 0) + 1;
|
|
361
|
+
},
|
|
362
|
+
});
|
|
344
363
|
conv.commandIntent = initial.commandIntent;
|
|
345
364
|
conv.channelCapabilities = initial.channelCapabilities;
|
|
346
365
|
conv.voiceCallControlPrompt = initial.voiceCallControlPrompt;
|
|
@@ -374,6 +393,7 @@ function wireTurnState(
|
|
|
374
393
|
assistantId: conv.assistantId,
|
|
375
394
|
callSessionId: conv.callSessionId,
|
|
376
395
|
trustContext: conv.trustContext,
|
|
396
|
+
actorPrincipalId: conv.currentTurnSourceActorPrincipalId,
|
|
377
397
|
commandIntent: conv.commandIntent,
|
|
378
398
|
turnChannelContext,
|
|
379
399
|
turnInterfaceContext,
|
|
@@ -393,6 +413,7 @@ function makeWinnerState(): FakeTurnState {
|
|
|
393
413
|
assistantId: "assistant-winner",
|
|
394
414
|
callSessionId: "session-winner",
|
|
395
415
|
trustContext: { sourceChannel: "imessage", trustClass: "trusted_contact" },
|
|
416
|
+
actorPrincipalId: "principal-winner",
|
|
396
417
|
commandIntent: undefined,
|
|
397
418
|
turnChannelContext: {
|
|
398
419
|
userMessageChannel: "imessage",
|
|
@@ -1489,6 +1510,103 @@ describe("startVoiceTurn queued-message drain race", () => {
|
|
|
1489
1510
|
});
|
|
1490
1511
|
});
|
|
1491
1512
|
|
|
1513
|
+
describe("startVoiceTurn actor principal", () => {
|
|
1514
|
+
// The turn's actor is what host proxies match connected desktop clients
|
|
1515
|
+
// against (`pickSameUserAutoResolve`). A turn that carries none matches no
|
|
1516
|
+
// client, so every computer-use call it makes is refused however healthy
|
|
1517
|
+
// the connected client is.
|
|
1518
|
+
|
|
1519
|
+
test("stamps the caller's actor for the duration of the turn", async () => {
|
|
1520
|
+
const statesAtPersist: FakeTurnState[] = [];
|
|
1521
|
+
const fake = makeFakeConversation({
|
|
1522
|
+
processing: false,
|
|
1523
|
+
onPersist: () => {
|
|
1524
|
+
statesAtPersist.push(readState());
|
|
1525
|
+
},
|
|
1526
|
+
});
|
|
1527
|
+
const readState = wireTurnState(fake.conversation, {});
|
|
1528
|
+
fakeConversation = fake.conversation;
|
|
1529
|
+
|
|
1530
|
+
await startVoiceTurn({
|
|
1531
|
+
...makeTurnOptions(undefined, "conv-actor-stamp"),
|
|
1532
|
+
actorPrincipalId: "principal-guardian",
|
|
1533
|
+
});
|
|
1534
|
+
|
|
1535
|
+
expect(statesAtPersist.length).toBe(1);
|
|
1536
|
+
expect(statesAtPersist[0]!.actorPrincipalId).toBe("principal-guardian");
|
|
1537
|
+
});
|
|
1538
|
+
|
|
1539
|
+
/**
|
|
1540
|
+
* A phone caller is whoever dialled in. Resolving them to a desktop client
|
|
1541
|
+
* would hand an inbound caller the owner's machine, so the telephony path
|
|
1542
|
+
* passes no actor and the turn must not invent one.
|
|
1543
|
+
*/
|
|
1544
|
+
test("a turn with no caller actor leaves the conversation without one", async () => {
|
|
1545
|
+
const statesAtPersist: FakeTurnState[] = [];
|
|
1546
|
+
const fake = makeFakeConversation({
|
|
1547
|
+
processing: false,
|
|
1548
|
+
onPersist: () => {
|
|
1549
|
+
statesAtPersist.push(readState());
|
|
1550
|
+
},
|
|
1551
|
+
});
|
|
1552
|
+
const readState = wireTurnState(fake.conversation, {});
|
|
1553
|
+
fakeConversation = fake.conversation;
|
|
1554
|
+
|
|
1555
|
+
await startVoiceTurn(makeTurnOptions(undefined, "conv-actor-absent"));
|
|
1556
|
+
|
|
1557
|
+
expect(statesAtPersist.length).toBe(1);
|
|
1558
|
+
expect(statesAtPersist[0]!.actorPrincipalId).toBeUndefined();
|
|
1559
|
+
});
|
|
1560
|
+
|
|
1561
|
+
/** The stamp is the turn's, so it goes when the turn does. */
|
|
1562
|
+
test("releases the actor when the turn ends", async () => {
|
|
1563
|
+
const fake = makeFakeConversation({ processing: false });
|
|
1564
|
+
const readState = wireTurnState(fake.conversation, {});
|
|
1565
|
+
fakeConversation = fake.conversation;
|
|
1566
|
+
|
|
1567
|
+
const handle = await startVoiceTurn({
|
|
1568
|
+
...makeTurnOptions(undefined, "conv-actor-release"),
|
|
1569
|
+
actorPrincipalId: "principal-guardian",
|
|
1570
|
+
});
|
|
1571
|
+
handle.abort();
|
|
1572
|
+
await flushMicrotasks();
|
|
1573
|
+
|
|
1574
|
+
expect(readState().actorPrincipalId).toBeUndefined();
|
|
1575
|
+
});
|
|
1576
|
+
|
|
1577
|
+
/**
|
|
1578
|
+
* The release is not unconditional. `runAgentLoopImpl` gives up the
|
|
1579
|
+
* processing claim before the turn-boundary commit is awaited, so a retry
|
|
1580
|
+
* can take the conversation and stamp its own actor while this turn is
|
|
1581
|
+
* still unwinding. The retry route installs no auth-context fallback, so a
|
|
1582
|
+
* clear on the way out would leave it with no actor at all and its
|
|
1583
|
+
* host-proxy calls refused.
|
|
1584
|
+
*/
|
|
1585
|
+
test("does not clear an actor another turn stamped while this one unwound", async () => {
|
|
1586
|
+
let conv: { currentTurnSourceActorPrincipalId?: string } | null = null;
|
|
1587
|
+
const fake = makeFakeConversation({
|
|
1588
|
+
processing: false,
|
|
1589
|
+
runAgentLoop: async () => {
|
|
1590
|
+
conv!.currentTurnSourceActorPrincipalId = "principal-retry";
|
|
1591
|
+
},
|
|
1592
|
+
});
|
|
1593
|
+
conv = fake.conversation as {
|
|
1594
|
+
currentTurnSourceActorPrincipalId?: string;
|
|
1595
|
+
};
|
|
1596
|
+
const readState = wireTurnState(fake.conversation, {});
|
|
1597
|
+
fakeConversation = fake.conversation;
|
|
1598
|
+
|
|
1599
|
+
const handle = await startVoiceTurn({
|
|
1600
|
+
...makeTurnOptions(undefined, "conv-actor-release-guard"),
|
|
1601
|
+
actorPrincipalId: "principal-guardian",
|
|
1602
|
+
});
|
|
1603
|
+
handle.abort();
|
|
1604
|
+
await flushMicrotasks();
|
|
1605
|
+
|
|
1606
|
+
expect(readState().actorPrincipalId).toBe("principal-retry");
|
|
1607
|
+
});
|
|
1608
|
+
});
|
|
1609
|
+
|
|
1492
1610
|
describe("startVoiceTurn race-loss state restore", () => {
|
|
1493
1611
|
// A busy persist means a concurrent turn (the lock winner) is running with
|
|
1494
1612
|
// per-turn state it installed. Every race-loss path must put the winner's
|
|
@@ -1547,6 +1665,54 @@ describe("startVoiceTurn race-loss state restore", () => {
|
|
|
1547
1665
|
expect(retryState.voiceCallControlPrompt).toContain("voice_call_control");
|
|
1548
1666
|
});
|
|
1549
1667
|
|
|
1668
|
+
/**
|
|
1669
|
+
* The winner here is an ordinary message turn, which stamps the actor
|
|
1670
|
+
* directly (`conversation-routes`, `conversation-process`) rather than
|
|
1671
|
+
* through this bridge. It shares the guardian, so it writes the identical
|
|
1672
|
+
* string this turn did and the field cannot say which of them wrote it.
|
|
1673
|
+
* Reverting on that would hand the winner a principal from an earlier turn,
|
|
1674
|
+
* and automatic host-client resolution would then go looking for that other
|
|
1675
|
+
* principal's desktop.
|
|
1676
|
+
*/
|
|
1677
|
+
test("an ordinary turn's actor stamp is not reverted by a losing voice turn", async () => {
|
|
1678
|
+
const statesDuringWait: FakeTurnState[] = [];
|
|
1679
|
+
const fake = makeFakeConversation({
|
|
1680
|
+
processing: false,
|
|
1681
|
+
waitForIdle: async () => {
|
|
1682
|
+
statesDuringWait.push(readState());
|
|
1683
|
+
fake.setProcessingFlag(false);
|
|
1684
|
+
return true;
|
|
1685
|
+
},
|
|
1686
|
+
onPersist: (attempt) => {
|
|
1687
|
+
if (attempt === 1) {
|
|
1688
|
+
// The winner takes the conversation and stamps the same guardian,
|
|
1689
|
+
// the way an ordinary turn does: a direct write, counted but
|
|
1690
|
+
// otherwise invisible to this bridge.
|
|
1691
|
+
(
|
|
1692
|
+
fake.conversation as { currentTurnSourceActorPrincipalId?: string }
|
|
1693
|
+
).currentTurnSourceActorPrincipalId = "principal-guardian";
|
|
1694
|
+
fake.setProcessingFlag(true);
|
|
1695
|
+
throw new Error("Conversation is already processing a message");
|
|
1696
|
+
}
|
|
1697
|
+
},
|
|
1698
|
+
});
|
|
1699
|
+
// What an earlier turn left resident, and what a value-based revert would
|
|
1700
|
+
// wrongly put back.
|
|
1701
|
+
const readState = wireTurnState(fake.conversation, {
|
|
1702
|
+
actorPrincipalId: "principal-other",
|
|
1703
|
+
});
|
|
1704
|
+
fakeConversation = fake.conversation;
|
|
1705
|
+
|
|
1706
|
+
await startVoiceTurn({
|
|
1707
|
+
...makeTurnOptions(undefined, "conv-race-ordinary-winner"),
|
|
1708
|
+
callSessionId: "session-voice-loser",
|
|
1709
|
+
actorPrincipalId: "principal-guardian",
|
|
1710
|
+
});
|
|
1711
|
+
|
|
1712
|
+
expect(statesDuringWait.length).toBe(1);
|
|
1713
|
+
expect(statesDuringWait[0]!.actorPrincipalId).toBe("principal-guardian");
|
|
1714
|
+
});
|
|
1715
|
+
|
|
1550
1716
|
test("a busy persist whose retry wait exhausts the budget leaves the winner's values in place", async () => {
|
|
1551
1717
|
const winnerState = makeWinnerState();
|
|
1552
1718
|
const fake = makeFakeConversation({
|