@vellumai/assistant 0.11.2-dev.202608071521.2d2f93e → 0.11.2-dev.202608071719.8f7a9f1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vellumai/assistant",
3
- "version": "0.11.2-dev.202608071521.2d2f93e",
3
+ "version": "0.11.2-dev.202608071719.8f7a9f1",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "exports": {
@@ -86,6 +86,7 @@ import {
86
86
  MessageQueue,
87
87
  type QueuedMessage,
88
88
  } from "../daemon/conversation-queue-manager.js";
89
+ import type { TrustContext } from "../daemon/trust-context-types.js";
89
90
 
90
91
  // ---------------------------------------------------------------------------
91
92
  // Fake context — captures preactivation calls, satisfies the bare minimum
@@ -172,6 +173,7 @@ function makeQueuedMessage(opts: {
172
173
  content?: string;
173
174
  turnInterfaceContext?: TurnInterfaceContext;
174
175
  sourceActorPrincipalId?: string;
176
+ trustContext?: TrustContext;
175
177
  }): QueuedMessage {
176
178
  return {
177
179
  content: opts.content ?? "follow up",
@@ -185,6 +187,7 @@ function makeQueuedMessage(opts: {
185
187
  authContext: opts.sourceActorPrincipalId
186
188
  ? ({ actorPrincipalId: opts.sourceActorPrincipalId } as never)
187
189
  : undefined,
190
+ trustContext: opts.trustContext,
188
191
  };
189
192
  }
190
193
 
@@ -371,6 +374,126 @@ describe("drainQueue preactivation re-add for host-proxy interfaces", () => {
371
374
  expect(ctx.currentTurnSourceActorPrincipalId).toBe("trusted-contact-user");
372
375
  });
373
376
 
377
+ test("drainSingleMessage runs the turn under the queued sender's trust, not the live slot", async () => {
378
+ // The conversation-level slot holds whichever actor sent most recently.
379
+ // A message that waited while someone else sent must still run as its own
380
+ // sender: trust decides `trustClass`, `executionChannel`, and
381
+ // `requesterExternalUserId`, so reading the slot hands the whole
382
+ // tool-approval path the wrong identity in both directions -- a contact
383
+ // inheriting guardian self-approval, or a guardian's own call escalating
384
+ // back to her as a contact's grant request.
385
+ const contactTrust: TrustContext = {
386
+ trustClass: "trusted_contact",
387
+ sourceChannel: "slack",
388
+ requesterExternalUserId: "U-contact",
389
+ };
390
+ const queue = new MessageQueue();
391
+ queue.push(
392
+ makeQueuedMessage({
393
+ requestId: "req-contact",
394
+ trustContext: contactTrust,
395
+ }),
396
+ );
397
+ const ctx = makeFakeContext({ queue });
398
+ // Someone else sent after this message was queued, moving the slot.
399
+ ctx.trustContext = {
400
+ trustClass: "guardian",
401
+ sourceChannel: "vellum",
402
+ requesterExternalUserId: "guardian-principal",
403
+ };
404
+
405
+ await drainQueue(ctx);
406
+
407
+ expect(ctx.currentTurnTrustContext?.trustClass).toBe("trusted_contact");
408
+ expect(ctx.currentTurnTrustContext?.requesterExternalUserId).toBe(
409
+ "U-contact",
410
+ );
411
+ expect(ctx.currentTurnTrustContext?.sourceChannel).toBe("slack");
412
+ // The slot itself is left alone; only the turn's view is corrected.
413
+ expect(ctx.trustContext?.trustClass).toBe("guardian");
414
+ });
415
+
416
+ test("buildPassthroughBatch refuses to coalesce two channel senders", async () => {
417
+ // Channel senders carry no principal, so the `sourceActorPrincipalId`
418
+ // boundary sees `undefined === undefined` and would batch two different
419
+ // Slack contacts into one turn running under the head's trust. The batch
420
+ // must split on the sender's trust identity instead.
421
+ const ifCtx: TurnInterfaceContext = {
422
+ userMessageInterface: "web",
423
+ assistantMessageInterface: "web",
424
+ };
425
+ const queue = new MessageQueue();
426
+ queue.push(
427
+ makeQueuedMessage({
428
+ requestId: "req-contact-a",
429
+ content: "from A",
430
+ turnInterfaceContext: ifCtx,
431
+ trustContext: {
432
+ trustClass: "trusted_contact",
433
+ sourceChannel: "slack",
434
+ requesterExternalUserId: "U-alex",
435
+ },
436
+ }),
437
+ );
438
+ queue.push(
439
+ makeQueuedMessage({
440
+ requestId: "req-contact-b",
441
+ content: "from B",
442
+ turnInterfaceContext: ifCtx,
443
+ trustContext: {
444
+ trustClass: "trusted_contact",
445
+ sourceChannel: "slack",
446
+ requesterExternalUserId: "U-blake",
447
+ },
448
+ }),
449
+ );
450
+ const ctx = makeFakeContext({ queue, turnInterfaceContext: ifCtx });
451
+
452
+ await drainQueue(ctx);
453
+
454
+ // B stays queued: it gets its own turn under its own trust.
455
+ expect(queue.length).toBe(1);
456
+ expect(queue.peek(0)?.requestId).toBe("req-contact-b");
457
+ expect(ctx.currentTurnTrustContext?.requesterExternalUserId).toBe("U-alex");
458
+ });
459
+
460
+ test("buildPassthroughBatch still coalesces two messages from the same sender", async () => {
461
+ // Sensitivity check on the split above: identical trust must still batch,
462
+ // otherwise the boundary would be splitting on something incidental and
463
+ // the test above would pass for the wrong reason.
464
+ const ifCtx: TurnInterfaceContext = {
465
+ userMessageInterface: "web",
466
+ assistantMessageInterface: "web",
467
+ };
468
+ const sameTrust: TrustContext = {
469
+ trustClass: "trusted_contact",
470
+ sourceChannel: "slack",
471
+ requesterExternalUserId: "U-alex",
472
+ };
473
+ const queue = new MessageQueue();
474
+ queue.push(
475
+ makeQueuedMessage({
476
+ requestId: "req-a1",
477
+ content: "first",
478
+ turnInterfaceContext: ifCtx,
479
+ trustContext: { ...sameTrust },
480
+ }),
481
+ );
482
+ queue.push(
483
+ makeQueuedMessage({
484
+ requestId: "req-a2",
485
+ content: "second",
486
+ turnInterfaceContext: ifCtx,
487
+ trustContext: { ...sameTrust },
488
+ }),
489
+ );
490
+ const ctx = makeFakeContext({ queue, turnInterfaceContext: ifCtx });
491
+
492
+ await drainQueue(ctx);
493
+
494
+ expect(queue.length).toBe(0);
495
+ });
496
+
374
497
  test("drainSingleMessage does NOT re-add 'app-control' for web-sourced message when no capable client is connected", async () => {
375
498
  // mockCapabilityClients remains [] (reset by afterEach from prior test)
376
499
  const queue = new MessageQueue();
@@ -556,6 +556,98 @@ describe("Conversation message queue", () => {
556
556
  await new Promise((r) => setTimeout(r, 10));
557
557
  });
558
558
 
559
+ test("enqueueMessage captures the sender's trust, immune to a later slot change", async () => {
560
+ // Trust must ride with the queued message. The conversation-level slot is
561
+ // rewritten by whoever sends next, so a message that reads it at drain time
562
+ // would run as the wrong actor. Capturing at enqueue is what makes the
563
+ // drain's identity independent of who sent afterwards.
564
+ const conversation = makeConversation();
565
+ await conversation.loadFromDb();
566
+
567
+ conversation.setTrustContext({
568
+ trustClass: "trusted_contact",
569
+ sourceChannel: "slack",
570
+ requesterExternalUserId: "U-contact",
571
+ });
572
+
573
+ const p1 = conversation.processMessage({
574
+ content: "msg-1",
575
+ attachments: [],
576
+ onEvent: () => {},
577
+ requestId: "req-1",
578
+ });
579
+ await waitForPendingRun(1);
580
+
581
+ conversation.enqueueMessage({ content: "msg-2", requestId: "req-2" });
582
+
583
+ // A different actor sends while the message waits, moving the slot.
584
+ conversation.setTrustContext({
585
+ trustClass: "guardian",
586
+ sourceChannel: "vellum",
587
+ requesterExternalUserId: "guardian-principal",
588
+ });
589
+
590
+ const queued = conversation.queue.peek(0);
591
+ expect(queued?.requestId).toBe("req-2");
592
+ expect(queued?.trustContext?.trustClass).toBe("trusted_contact");
593
+ expect(queued?.trustContext?.requesterExternalUserId).toBe("U-contact");
594
+
595
+ await resolveRun(0);
596
+ await p1;
597
+ await new Promise((r) => setTimeout(r, 10));
598
+ });
599
+
600
+ test("a drained turn keeps its sender's trust once the agent loop is running", async () => {
601
+ // Exercises the real drain -> runAgentLoop ordering against a live
602
+ // Conversation. The loop re-initializes the per-turn trust snapshot on
603
+ // entry, so a drain that only stamped the field before calling it would
604
+ // have that stamp silently undone and the turn would execute as whoever
605
+ // sent most recently. Asserting inside the run is the point: checking
606
+ // before the loop starts passes either way.
607
+ const conversation = makeConversation();
608
+ await conversation.loadFromDb();
609
+
610
+ conversation.setTrustContext({
611
+ trustClass: "trusted_contact",
612
+ sourceChannel: "slack",
613
+ requesterExternalUserId: "U-contact",
614
+ });
615
+
616
+ const p1 = conversation.processMessage({
617
+ content: "msg-1",
618
+ attachments: [],
619
+ onEvent: () => {},
620
+ requestId: "req-1",
621
+ });
622
+ await waitForPendingRun(1);
623
+
624
+ conversation.enqueueMessage({ content: "msg-2", requestId: "req-2" });
625
+
626
+ // The guardian sends while the contact's message waits, moving the slot.
627
+ conversation.setTrustContext({
628
+ trustClass: "guardian",
629
+ sourceChannel: "vellum",
630
+ requesterExternalUserId: "guardian-principal",
631
+ });
632
+
633
+ // Finish the first turn so the queue drains into a second run.
634
+ await resolveRun(0);
635
+ await p1;
636
+ await waitForPendingRun(2);
637
+
638
+ // Read while run 2 is in flight: this is what tool setup and the
639
+ // guardian-request producers resolve against.
640
+ expect(conversation.currentTurnTrustContext?.trustClass).toBe(
641
+ "trusted_contact",
642
+ );
643
+ expect(conversation.currentTurnTrustContext?.requesterExternalUserId).toBe(
644
+ "U-contact",
645
+ );
646
+
647
+ await resolveRun(1);
648
+ await new Promise((r) => setTimeout(r, 10));
649
+ });
650
+
559
651
  test("[experimental] queued passthrough siblings drain as a single batched run", async () => {
560
652
  const conversation = makeConversation();
561
653
  await conversation.loadFromDb();
@@ -1,7 +1,44 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { dirname, join } from "node:path";
1
3
  import { describe, expect, test } from "bun:test";
2
4
 
3
5
  import { PROVIDER_SEED_DATA } from "../oauth/seed-providers.js";
4
6
 
7
+ const REPO_ROOT = dirname(dirname(dirname(import.meta.dir)));
8
+ const WEB_PUBLIC_DIR = join(REPO_ROOT, "clients/web/public");
9
+ const INTEGRATION_ICON_SOURCE = join(
10
+ REPO_ROOT,
11
+ "clients/web/src/components/integrations/integration-icon.tsx",
12
+ );
13
+
14
+ /**
15
+ * Provider keys the web client draws without a `BUNDLED_LOGO_URLS` entry.
16
+ * Google's mark is multi-colour, so it ships as the `GoogleLogo` component
17
+ * and is returned before the bundled-asset lookup runs.
18
+ */
19
+ const COMPONENT_RENDERED_PROVIDERS = new Set(["google"]);
20
+
21
+ /** `publicAsset("/images/...")` paths from `BUNDLED_LOGO_URLS`, by key. */
22
+ function readBundledLogoMap(): Map<string, string> {
23
+ const source = readFileSync(INTEGRATION_ICON_SOURCE, "utf8");
24
+ const block = source.match(
25
+ /const BUNDLED_LOGO_URLS: Record<string, string> = \{([\s\S]*?)\n\};/,
26
+ );
27
+ if (!block) {
28
+ throw new Error(
29
+ `Could not find BUNDLED_LOGO_URLS in ${INTEGRATION_ICON_SOURCE}. If it ` +
30
+ `was renamed or restructured, update this test rather than deleting it.`,
31
+ );
32
+ }
33
+ const entries = new Map<string, string>();
34
+ for (const [, key, path] of block[1]!.matchAll(
35
+ /(\w+):\s*publicAsset\("([^"]+)"\)/g,
36
+ )) {
37
+ entries.set(key!, path!);
38
+ }
39
+ return entries;
40
+ }
41
+
5
42
  /**
6
43
  * Allowed CDN prefixes for the ``logoUrl`` field on ``PROVIDER_SEED_DATA``
7
44
  * (``assistant/src/oauth/seed-providers.ts``):
@@ -41,3 +78,31 @@ describe("PROVIDER_SEED_DATA logo URLs", () => {
41
78
  expect(invalid).toEqual([]);
42
79
  });
43
80
  });
81
+
82
+ /**
83
+ * The `logoUrl` above is a fallback for providers registered at runtime. Every
84
+ * provider we seed ourselves must draw from an asset we ship, because an icon
85
+ * CDN can drop a brand at any time and the client then degrades to an initials
86
+ * avatar with nothing in CI to notice: the prefix check above passes just as
87
+ * happily for a URL that 404s. Simple Icons hosts no Microsoft mark and no
88
+ * Slack mark, so the seeded URLs for those brands do not resolve there.
89
+ */
90
+ describe("PROVIDER_SEED_DATA bundled logo coverage", () => {
91
+ test("every seeded provider renders from a bundled asset", () => {
92
+ const bundled = readBundledLogoMap();
93
+ const uncovered = Object.keys(PROVIDER_SEED_DATA).filter(
94
+ (provider) =>
95
+ !bundled.has(provider) && !COMPONENT_RENDERED_PROVIDERS.has(provider),
96
+ );
97
+
98
+ expect(uncovered).toEqual([]);
99
+ });
100
+
101
+ test("every bundled logo path resolves to a file on disk", () => {
102
+ const missingFiles = [...readBundledLogoMap()]
103
+ .filter(([, path]) => !existsSync(join(WEB_PUBLIC_DIR, path)))
104
+ .map(([provider, path]) => ({ provider, path }));
105
+
106
+ expect(missingFiles).toEqual([]);
107
+ });
108
+ });
@@ -101,17 +101,20 @@ import {
101
101
  getRegisteredKinds,
102
102
  getResolver,
103
103
  } from "../approvals/guardian-request-resolvers.js";
104
+ import { getConfig } from "../config/loader.js";
104
105
  import { getDb } from "../persistence/db-connection.js";
105
106
  import { getSqlite } from "../persistence/db-connection.js";
106
107
  import { initializeDb } from "../persistence/db-init.js";
107
108
  import { scopedApprovalGrants } from "../persistence/schema/index.js";
108
109
  import {
110
+ TC_GRANT_WAIT_MAX_MS,
109
111
  ToolApprovalHandler,
110
112
  waitForInlineGrant,
111
113
  } from "../tools/tool-approval-handler.js";
112
114
  import type { ToolContext } from "../tools/types.js";
115
+ import { setConfig } from "./helpers/set-config.js";
113
116
 
114
- /** Short wait config for tests — avoids blocking test suite on the 60s default. */
117
+ /** Short wait config for tests: keeps escalation cases off the real budget. */
115
118
  const TEST_INLINE_WAIT_CONFIG = { maxWaitMs: 100, intervalMs: 20 };
116
119
 
117
120
  await initializeDb();
@@ -490,6 +493,42 @@ describe("inline wait-and-resume", () => {
490
493
  expect(elapsed).toBeLessThan(500);
491
494
  });
492
495
 
496
+ test("waitForInlineGrant spends timeouts.permissionTimeoutSec when no explicit budget is given", async () => {
497
+ // The guardian's escalation window must track the configured approval
498
+ // budget rather than a compiled-in constant, so an operator raising
499
+ // permissionTimeoutSec actually widens the window a guardian has to
500
+ // answer in. Omitting maxWaitMs is what production does.
501
+ const priorTimeouts = getConfig().timeouts;
502
+ setConfig("timeouts", {
503
+ ...priorTimeouts,
504
+ permissionTimeoutSec: 0.15,
505
+ });
506
+ try {
507
+ const req = seedGrantRequest("sha256:configbudget");
508
+
509
+ const start = Date.now();
510
+ const result = await waitForInlineGrant(
511
+ req.id,
512
+ {
513
+ toolName: "bash",
514
+ inputDigest: "sha256:configbudget",
515
+ consumingRequestId: "consume-config-budget",
516
+ },
517
+ { intervalMs: 20 },
518
+ );
519
+ const elapsed = Date.now() - start;
520
+
521
+ expect(result.outcome).toBe("timeout");
522
+ // Bounded well under TC_GRANT_WAIT_MAX_MS: a regression that ignores
523
+ // config and falls back to the constant blows this ceiling by ~400x.
524
+ expect(elapsed).toBeGreaterThanOrEqual(120);
525
+ expect(elapsed).toBeLessThan(3_000);
526
+ expect(elapsed).toBeLessThan(TC_GRANT_WAIT_MAX_MS);
527
+ } finally {
528
+ setConfig("timeouts", priorTimeouts);
529
+ }
530
+ }, 10_000);
531
+
493
532
  test("waitForInlineGrant returns aborted when signal fires during wait", async () => {
494
533
  const req = seedGrantRequest("sha256:abortwait");
495
534
 
@@ -170,6 +170,7 @@ mock.module("../channels/gateway-guardian-requests.js", () => sim.module);
170
170
  import { applyGuardianDecision } from "../approvals/guardian-decision-primitive.js";
171
171
  import type { ActorContext } from "../approvals/guardian-request-resolvers.js";
172
172
  import { getResolver } from "../approvals/guardian-request-resolvers.js";
173
+ import { getConfig } from "../config/loader.js";
173
174
  import type { TrustContext } from "../daemon/trust-context-types.js";
174
175
  import { getDb } from "../persistence/db-connection.js";
175
176
  import { initializeDb } from "../persistence/db-init.js";
@@ -177,12 +178,14 @@ import { scopedApprovalGrants } from "../persistence/schema/index.js";
177
178
  import { bridgeConfirmationRequestToGuardian } from "../runtime/confirmation-request-guardian-bridge.js";
178
179
  import { resolveRoutingState } from "../runtime/trust-context-resolver.js";
179
180
  import {
181
+ resolveInlineGrantWaitMs,
180
182
  TC_GRANT_WAIT_MAX_MS,
181
183
  ToolApprovalHandler,
182
184
  waitForInlineGrant,
183
185
  } from "../tools/tool-approval-handler.js";
184
186
  import type { ToolContext } from "../tools/types.js";
185
187
  import { seedContactChannel } from "./helpers/seed-contact-channel.js";
188
+ import { setConfig } from "./helpers/set-config.js";
186
189
 
187
190
  await initializeDb();
188
191
 
@@ -754,7 +757,10 @@ describe("(f) timeout/stale flow: stale guardian decision after inline wait time
754
757
  test("inline_wait_active staleness guard: expired marker allows retry notification", async () => {
755
758
  // Create a guardian request with a stale inline_wait_active marker
756
759
  // that simulates a daemon crash during the wait.
757
- const staleTimestamp = Date.now() - TC_GRANT_WAIT_MAX_MS - 60_000;
760
+ // Age the marker past the real wait budget, which the resolver's
761
+ // staleness threshold tracks. Deriving it from the same helper the
762
+ // waiter uses keeps this case meaningful if the budget changes.
763
+ const staleTimestamp = Date.now() - resolveInlineGrantWaitMs() - 60_000;
758
764
  const req = sim.seedRequest({
759
765
  id: `req-stale-${Date.now()}`,
760
766
  kind: "tool_grant_request",
@@ -802,6 +808,67 @@ describe("(f) timeout/stale flow: stale guardian decision after inline wait time
802
808
  expect(retryNotifications.length).toBeGreaterThan(0);
803
809
  });
804
810
 
811
+ test("inline_wait_active marker older than the fallback constant but inside the real budget still suppresses retry", async () => {
812
+ // The staleness threshold must track the wait budget, not the fallback
813
+ // constant. A marker aged past TC_GRANT_WAIT_MAX_MS + buffer but still
814
+ // well inside the configured budget belongs to a waiter that is very
815
+ // much alive: telling the requester to retry would race a call that is
816
+ // about to resume, and the retry then fails against the one-time grant
817
+ // the live waiter consumes.
818
+ const priorTimeouts = getConfig().timeouts;
819
+ // Seed the budget rather than leaning on the ambient default, so the
820
+ // window this case probes exists no matter what the suite's config holds.
821
+ setConfig("timeouts", { ...priorTimeouts, permissionTimeoutSec: 300 });
822
+ try {
823
+ const budgetMs = resolveInlineGrantWaitMs();
824
+ // Comfortably past the old 90s threshold, comfortably short of the budget.
825
+ const markerAgeMs = TC_GRANT_WAIT_MAX_MS + 45_000;
826
+ const liveTimestamp = Date.now() - markerAgeMs;
827
+ expect(markerAgeMs).toBeLessThan(budgetMs);
828
+
829
+ const req = sim.seedRequest({
830
+ id: `req-live-${Date.now()}`,
831
+ kind: "tool_grant_request",
832
+ sourceType: "channel",
833
+ sourceChannel: "telegram",
834
+ sourceConversationId: "conv-live-1",
835
+ requesterExternalUserId: "requester-1",
836
+ requesterChatId: "requester-chat-1",
837
+ guardianExternalUserId: "guardian-1",
838
+ guardianPrincipalId: "test-principal-id",
839
+ toolName: "bash",
840
+ inputDigest: "sha256:livewait",
841
+ expiresAt: Date.now() + 60_000,
842
+ });
843
+
844
+ await sim.module.updateGuardianRequest(req.id, {
845
+ followupState: `inline_wait_active:${liveTimestamp}`,
846
+ });
847
+
848
+ deliveredReplies.length = 0;
849
+ const approvalResult = await applyGuardianDecision({
850
+ requestId: req.id,
851
+ action: "approve_once",
852
+ actorContext: guardianActor(),
853
+ channelDeliveryContext: {
854
+ replyCallbackUrl: "http://localhost:3000/reply",
855
+ guardianChatId: "guardian-chat-1",
856
+ assistantId: "self",
857
+ },
858
+ });
859
+ expect(approvalResult.applied).toBe(true);
860
+
861
+ const retryNotifications = deliveredReplies.filter(
862
+ (r) =>
863
+ typeof r.payload.text === "string" &&
864
+ (r.payload.text as string).includes("Please retry"),
865
+ );
866
+ expect(retryNotifications.length).toBe(0);
867
+ } finally {
868
+ setConfig("timeouts", priorTimeouts);
869
+ }
870
+ });
871
+
805
872
  test("fresh inline_wait_active marker suppresses retry notification", async () => {
806
873
  // Create a request with a FRESH inline_wait_active marker
807
874
  const freshTimestamp = Date.now();
@@ -54,7 +54,7 @@ import {
54
54
  readBatchMetadata,
55
55
  resolvePendingQuestion,
56
56
  } from "../runtime/question-resolution.js";
57
- import { TC_GRANT_WAIT_MAX_MS } from "../tools/tool-approval-handler.js";
57
+ import { resolveInlineGrantWaitMs } from "../tools/tool-approval-handler.js";
58
58
  import { getLogger } from "../util/logger.js";
59
59
  import {
60
60
  channelCanAddressOneReaderInBand,
@@ -1587,7 +1587,10 @@ const toolGrantRequestResolver: GuardianRequestResolver = {
1587
1587
  // outlive the actual waiter if the daemon crashes or restarts during
1588
1588
  // the wait. To avoid permanently suppressing the retry notification, we
1589
1589
  // treat the marker as stale if the encoded start timestamp is older than
1590
- // the maximum wait budget plus a 30s buffer.
1590
+ // the maximum wait budget plus a 30s buffer. The budget is read from the
1591
+ // same resolver the waiter itself uses, so a config change moves both
1592
+ // together; sizing this off a constant would let it fall below the real
1593
+ // wait and declare a live waiter dead.
1591
1594
  const INLINE_WAIT_STALENESS_BUFFER_MS = 30_000;
1592
1595
  const freshRequest = await getGuardianRequestOrNull(request.id);
1593
1596
  const followupState = freshRequest?.followupState ?? "";
@@ -1604,7 +1607,7 @@ const toolGrantRequestResolver: GuardianRequestResolver = {
1604
1607
  ? Date.now() - waitStartMs
1605
1608
  : Infinity; // Treat unparseable timestamps as stale for safety.
1606
1609
  const stalenessThresholdMs =
1607
- TC_GRANT_WAIT_MAX_MS + INLINE_WAIT_STALENESS_BUFFER_MS;
1610
+ resolveInlineGrantWaitMs() + INLINE_WAIT_STALENESS_BUFFER_MS;
1608
1611
  if (markerAgeMs > stalenessThresholdMs) {
1609
1612
  log.warn(
1610
1613
  {
@@ -26,7 +26,7 @@ export const TimeoutConfigSchema = z
26
26
  .positive("timeouts.permissionTimeoutSec must be a positive number")
27
27
  .default(300)
28
28
  .describe(
29
- "How long to wait for user permission approval before timing out (seconds)",
29
+ "How long to wait for a human to approve a tool call before timing out (seconds). Spent by both approval paths: the interactive permission prompt, and the inline wait when a contact's sensitive tool call is escalated to the guardian.",
30
30
  ),
31
31
  questionResponseTimeoutSec: z
32
32
  .number({ error: "timeouts.questionResponseTimeoutSec must be a number" })
@@ -366,6 +366,13 @@ export async function runAgentLoopImpl(
366
366
  * scheduled execute turn attributes its LLM spend to that firing.
367
367
  */
368
368
  cronRunId?: string | null;
369
+ /**
370
+ * Trust this turn runs under. Queue drains pass the trust captured from
371
+ * the sender at enqueue; without it the initialization below would reset
372
+ * the turn to the conversation slot, which holds whichever actor sent
373
+ * most recently rather than the one this turn belongs to.
374
+ */
375
+ turnTrustContext?: TrustContext;
369
376
  },
370
377
  ): Promise<void> {
371
378
  if (!ctx.abortController) {
@@ -376,9 +383,12 @@ export async function runAgentLoopImpl(
376
383
  // voice-session-bridge, regenerate, etc.) that invoke runAgentLoop directly
377
384
  // without going through processMessage/drainQueue. This ensures the system
378
385
  // prompt callback always reads a valid snapshot rather than undefined.
379
- // processMessage/drainQueue set these fields before calling runAgentLoop;
380
- // those existing assignments remain correct and are merely redundant here.
381
- ctx.currentTurnTrustContext = ctx.trustContext;
386
+ //
387
+ // A queue drain runs a message whose sender may not be the conversation's
388
+ // most recent one, so it passes that sender's trust explicitly. Falling back
389
+ // to the slot here would undo the drain's stamp and hand the turn back to
390
+ // whoever sent last.
391
+ ctx.currentTurnTrustContext = options?.turnTrustContext ?? ctx.trustContext;
382
392
  ctx.currentTurnChannelCapabilities = ctx.channelCapabilities;
383
393
 
384
394
  // Re-resolve the system prompt under the snapshots just set and push it into
@@ -565,6 +565,12 @@ export interface EnqueueMessageOptions {
565
565
  sourceActorPrincipalId?: string;
566
566
  /** Auth context snapshot captured for queued turn-scoped authorization. */
567
567
  authContext?: AuthContext;
568
+ /**
569
+ * Sender's trust, for the drain to run this message under. Defaults to the
570
+ * conversation's trust at enqueue time, which the sending route has just
571
+ * set to this sender.
572
+ */
573
+ trustContext?: TrustContext;
568
574
  }
569
575
 
570
576
  // ── enqueueMessage ───────────────────────────────────────────────────
@@ -593,6 +599,9 @@ export function enqueueMessage(
593
599
  options.sourceActorPrincipalId ??
594
600
  ctx.currentTurnSourceActorPrincipalId ??
595
601
  queuedAuthContext?.actorPrincipalId;
602
+ // Deliberately not falling back to `currentTurnTrustContext`: that is the
603
+ // in-flight turn's actor, which is precisely who this message is not from.
604
+ const queuedTrustContext = options.trustContext ?? ctx.trustContext;
596
605
 
597
606
  if (!ctx.isProcessing()) {
598
607
  return { queued: false, requestId };
@@ -619,6 +628,7 @@ export function enqueueMessage(
619
628
  isInteractive,
620
629
  sourceActorPrincipalId,
621
630
  authContext: queuedAuthContext,
631
+ trustContext: queuedTrustContext,
622
632
  transport,
623
633
  displayContent,
624
634
  sentAt: Date.now(),
@@ -668,6 +678,14 @@ export interface PersistMessageOptions {
668
678
  metadata?: Record<string, unknown>;
669
679
  displayContent?: string;
670
680
  clientMessageId?: string;
681
+ /**
682
+ * Trust to attribute the stored row to. Queue drains pass the sender's
683
+ * captured trust so persisted provenance names the same actor the turn
684
+ * executes as; the conversation slot may by then hold someone else.
685
+ * Defaults to the conversation's trust, which is correct for callers
686
+ * persisting a message the current actor just sent.
687
+ */
688
+ trustContext?: TrustContext;
671
689
  /**
672
690
  * Persist the row without indexing it (no memory segments, embeddings, or
673
691
  * lexical-index entry). For machine-authored prompts that must not enter
@@ -844,7 +862,9 @@ export async function persistQueuedMessageBody(
844
862
  extractTurnChannelContext(metadata) ?? ctx.getTurnChannelContext();
845
863
  const turnIfCtx =
846
864
  extractTurnInterfaceContext(metadata) ?? ctx.getTurnInterfaceContext();
847
- const provenance = provenanceFromTrustContext(ctx.trustContext);
865
+ const provenance = provenanceFromTrustContext(
866
+ options.trustContext ?? ctx.trustContext,
867
+ );
848
868
  const imageSourcePaths = extractImageSourcePaths(attachments);
849
869
 
850
870
  // Strip the transient `slackInbound` carrier key from the persisted