@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.
@@ -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,6 +1,6 @@
1
1
  {
2
2
  "name": "@vellumai/assistant",
3
- "version": "0.11.9-dev.202609080045.119baa5",
3
+ "version": "0.11.9-dev.202609080628.cb7bdcf",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "exports": {
@@ -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
- import * as registration from "../inbound/platform-callback-registration.js";
14
- import { resolveWebhookUrl } from "../plugin-api/webhook-url.js";
15
- import { runInPluginContext } from "../plugins/plugin-execution-context.js";
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({