@vellumai/assistant 0.3.3 → 0.3.5

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.
Files changed (163) hide show
  1. package/Dockerfile +2 -0
  2. package/README.md +45 -18
  3. package/package.json +1 -1
  4. package/scripts/ipc/generate-swift.ts +13 -0
  5. package/src/__tests__/__snapshots__/ipc-snapshot.test.ts.snap +100 -0
  6. package/src/__tests__/approval-hardcoded-copy-guard.test.ts +41 -0
  7. package/src/__tests__/approval-message-composer.test.ts +253 -0
  8. package/src/__tests__/call-domain.test.ts +12 -2
  9. package/src/__tests__/call-orchestrator.test.ts +391 -1
  10. package/src/__tests__/call-routes-http.test.ts +27 -2
  11. package/src/__tests__/channel-approval-routes.test.ts +397 -135
  12. package/src/__tests__/channel-approvals.test.ts +99 -3
  13. package/src/__tests__/channel-delivery-store.test.ts +30 -4
  14. package/src/__tests__/channel-guardian.test.ts +261 -22
  15. package/src/__tests__/channel-readiness-service.test.ts +257 -0
  16. package/src/__tests__/config-schema.test.ts +2 -1
  17. package/src/__tests__/credential-security-invariants.test.ts +1 -0
  18. package/src/__tests__/daemon-lifecycle.test.ts +636 -0
  19. package/src/__tests__/dictation-mode-detection.test.ts +63 -0
  20. package/src/__tests__/entity-search.test.ts +615 -0
  21. package/src/__tests__/gateway-only-enforcement.test.ts +19 -13
  22. package/src/__tests__/handlers-twilio-config.test.ts +480 -0
  23. package/src/__tests__/ipc-snapshot.test.ts +63 -0
  24. package/src/__tests__/messaging-send-tool.test.ts +65 -0
  25. package/src/__tests__/run-orchestrator-assistant-events.test.ts +4 -0
  26. package/src/__tests__/run-orchestrator.test.ts +22 -0
  27. package/src/__tests__/secret-scanner.test.ts +223 -0
  28. package/src/__tests__/session-runtime-assembly.test.ts +85 -1
  29. package/src/__tests__/shell-parser-property.test.ts +357 -2
  30. package/src/__tests__/sms-messaging-provider.test.ts +125 -0
  31. package/src/__tests__/system-prompt.test.ts +25 -1
  32. package/src/__tests__/tool-executor-lifecycle-events.test.ts +34 -1
  33. package/src/__tests__/twilio-routes.test.ts +39 -3
  34. package/src/__tests__/twitter-cli-error-shaping.test.ts +2 -2
  35. package/src/__tests__/user-reference.test.ts +68 -0
  36. package/src/__tests__/web-search.test.ts +1 -1
  37. package/src/__tests__/work-item-output.test.ts +110 -0
  38. package/src/calls/call-domain.ts +8 -5
  39. package/src/calls/call-orchestrator.ts +85 -22
  40. package/src/calls/twilio-config.ts +17 -11
  41. package/src/calls/twilio-rest.ts +276 -0
  42. package/src/calls/twilio-routes.ts +39 -1
  43. package/src/cli/map.ts +6 -0
  44. package/src/commands/__tests__/cc-command-registry.test.ts +67 -0
  45. package/src/commands/cc-command-registry.ts +14 -1
  46. package/src/config/bundled-skills/claude-code/TOOLS.json +10 -3
  47. package/src/config/bundled-skills/knowledge-graph/SKILL.md +15 -0
  48. package/src/config/bundled-skills/knowledge-graph/TOOLS.json +56 -0
  49. package/src/config/bundled-skills/knowledge-graph/tools/graph-query.ts +185 -0
  50. package/src/config/bundled-skills/media-processing/SKILL.md +199 -0
  51. package/src/config/bundled-skills/media-processing/TOOLS.json +320 -0
  52. package/src/config/bundled-skills/media-processing/services/capability-registry.ts +137 -0
  53. package/src/config/bundled-skills/media-processing/services/event-detection-service.ts +280 -0
  54. package/src/config/bundled-skills/media-processing/services/feedback-aggregation.ts +144 -0
  55. package/src/config/bundled-skills/media-processing/services/feedback-store.ts +136 -0
  56. package/src/config/bundled-skills/media-processing/services/processing-pipeline.ts +261 -0
  57. package/src/config/bundled-skills/media-processing/services/retrieval-service.ts +95 -0
  58. package/src/config/bundled-skills/media-processing/services/timeline-service.ts +267 -0
  59. package/src/config/bundled-skills/media-processing/tools/analyze-keyframes.ts +301 -0
  60. package/src/config/bundled-skills/media-processing/tools/detect-events.ts +110 -0
  61. package/src/config/bundled-skills/media-processing/tools/extract-keyframes.ts +190 -0
  62. package/src/config/bundled-skills/media-processing/tools/generate-clip.ts +195 -0
  63. package/src/config/bundled-skills/media-processing/tools/ingest-media.ts +197 -0
  64. package/src/config/bundled-skills/media-processing/tools/media-diagnostics.ts +166 -0
  65. package/src/config/bundled-skills/media-processing/tools/media-status.ts +75 -0
  66. package/src/config/bundled-skills/media-processing/tools/query-media-events.ts +300 -0
  67. package/src/config/bundled-skills/media-processing/tools/recalibrate.ts +235 -0
  68. package/src/config/bundled-skills/media-processing/tools/select-tracking-profile.ts +142 -0
  69. package/src/config/bundled-skills/media-processing/tools/submit-feedback.ts +150 -0
  70. package/src/config/bundled-skills/messaging/SKILL.md +24 -5
  71. package/src/config/bundled-skills/messaging/tools/messaging-send.ts +5 -1
  72. package/src/config/bundled-skills/phone-calls/SKILL.md +2 -2
  73. package/src/config/bundled-skills/twitter/SKILL.md +19 -3
  74. package/src/config/defaults.ts +2 -1
  75. package/src/config/schema.ts +9 -3
  76. package/src/config/skills.ts +5 -32
  77. package/src/config/system-prompt.ts +40 -0
  78. package/src/config/templates/IDENTITY.md +2 -2
  79. package/src/config/user-reference.ts +29 -0
  80. package/src/config/vellum-skills/catalog.json +58 -0
  81. package/src/config/vellum-skills/google-oauth-setup/SKILL.md +3 -3
  82. package/src/config/vellum-skills/slack-oauth-setup/SKILL.md +3 -3
  83. package/src/config/vellum-skills/sms-setup/SKILL.md +118 -0
  84. package/src/config/vellum-skills/telegram-setup/SKILL.md +6 -1
  85. package/src/config/vellum-skills/twilio-setup/SKILL.md +76 -6
  86. package/src/daemon/auth-manager.ts +103 -0
  87. package/src/daemon/computer-use-session.ts +8 -1
  88. package/src/daemon/config-watcher.ts +253 -0
  89. package/src/daemon/handlers/config.ts +819 -22
  90. package/src/daemon/handlers/dictation.ts +182 -0
  91. package/src/daemon/handlers/identity.ts +14 -23
  92. package/src/daemon/handlers/index.ts +2 -0
  93. package/src/daemon/handlers/sessions.ts +2 -0
  94. package/src/daemon/handlers/shared.ts +3 -0
  95. package/src/daemon/handlers/skills.ts +6 -7
  96. package/src/daemon/handlers/work-items.ts +15 -7
  97. package/src/daemon/ipc-contract-inventory.json +10 -0
  98. package/src/daemon/ipc-contract.ts +114 -4
  99. package/src/daemon/ipc-handler.ts +87 -0
  100. package/src/daemon/lifecycle.ts +18 -4
  101. package/src/daemon/ride-shotgun-handler.ts +11 -1
  102. package/src/daemon/server.ts +111 -504
  103. package/src/daemon/session-agent-loop.ts +10 -15
  104. package/src/daemon/session-runtime-assembly.ts +115 -44
  105. package/src/daemon/session-tool-setup.ts +2 -0
  106. package/src/daemon/session.ts +19 -2
  107. package/src/inbound/public-ingress-urls.ts +3 -3
  108. package/src/memory/channel-guardian-store.ts +2 -1
  109. package/src/memory/db-connection.ts +28 -0
  110. package/src/memory/db-init.ts +1163 -0
  111. package/src/memory/db.ts +2 -2007
  112. package/src/memory/embedding-backend.ts +79 -11
  113. package/src/memory/indexer.ts +2 -0
  114. package/src/memory/job-handlers/media-processing.ts +100 -0
  115. package/src/memory/job-utils.ts +64 -4
  116. package/src/memory/jobs-store.ts +2 -1
  117. package/src/memory/jobs-worker.ts +11 -1
  118. package/src/memory/media-store.ts +759 -0
  119. package/src/memory/recall-cache.ts +107 -0
  120. package/src/memory/retriever.ts +36 -2
  121. package/src/memory/schema-migration.ts +984 -0
  122. package/src/memory/schema.ts +99 -0
  123. package/src/memory/search/entity.ts +208 -25
  124. package/src/memory/search/ranking.ts +6 -1
  125. package/src/memory/search/types.ts +26 -0
  126. package/src/messaging/provider-types.ts +2 -0
  127. package/src/messaging/providers/sms/adapter.ts +204 -0
  128. package/src/messaging/providers/sms/client.ts +93 -0
  129. package/src/messaging/providers/sms/types.ts +7 -0
  130. package/src/permissions/checker.ts +16 -2
  131. package/src/permissions/prompter.ts +14 -3
  132. package/src/permissions/trust-store.ts +7 -0
  133. package/src/runtime/approval-message-composer.ts +143 -0
  134. package/src/runtime/channel-approvals.ts +29 -7
  135. package/src/runtime/channel-guardian-service.ts +44 -18
  136. package/src/runtime/channel-readiness-service.ts +292 -0
  137. package/src/runtime/channel-readiness-types.ts +29 -0
  138. package/src/runtime/gateway-client.ts +2 -1
  139. package/src/runtime/http-server.ts +65 -28
  140. package/src/runtime/http-types.ts +3 -0
  141. package/src/runtime/routes/call-routes.ts +2 -1
  142. package/src/runtime/routes/channel-routes.ts +237 -103
  143. package/src/runtime/routes/run-routes.ts +7 -1
  144. package/src/runtime/run-orchestrator.ts +43 -3
  145. package/src/security/secret-scanner.ts +218 -0
  146. package/src/skills/frontmatter.ts +63 -0
  147. package/src/skills/slash-commands.ts +23 -0
  148. package/src/skills/vellum-catalog-remote.ts +107 -0
  149. package/src/tools/assets/materialize.ts +2 -2
  150. package/src/tools/browser/auto-navigate.ts +132 -24
  151. package/src/tools/browser/browser-manager.ts +67 -61
  152. package/src/tools/calls/call-start.ts +1 -0
  153. package/src/tools/claude-code/claude-code.ts +55 -3
  154. package/src/tools/credentials/vault.ts +1 -1
  155. package/src/tools/execution-target.ts +11 -1
  156. package/src/tools/executor.ts +10 -2
  157. package/src/tools/network/web-search.ts +1 -1
  158. package/src/tools/skills/vellum-catalog.ts +61 -156
  159. package/src/tools/terminal/parser.ts +21 -5
  160. package/src/tools/types.ts +2 -0
  161. package/src/twitter/router.ts +1 -1
  162. package/src/util/platform.ts +43 -1
  163. package/src/util/retry.ts +4 -4
@@ -1,7 +1,8 @@
1
- import { describe, test, expect, beforeEach, afterAll, afterEach, mock, spyOn } from 'bun:test';
1
+ import { describe, test, expect, beforeEach, afterAll, mock, spyOn } from 'bun:test';
2
2
  import { mkdtempSync, rmSync } from 'node:fs';
3
3
  import { tmpdir } from 'node:os';
4
4
  import { join } from 'node:path';
5
+ import { eq } from 'drizzle-orm';
5
6
 
6
7
  // ---------------------------------------------------------------------------
7
8
  // Test isolation: in-memory SQLite via temp directory
@@ -41,12 +42,13 @@ mock.module('../daemon/handlers.js', () => ({
41
42
  }));
42
43
 
43
44
  import { initializeDb, getDb, resetDb } from '../memory/db.js';
44
- import { conversations } from '../memory/schema.js';
45
+ import { conversations, externalConversationBindings } from '../memory/schema.js';
45
46
  import {
46
47
  createRun,
47
48
  setRunConfirmation,
48
49
  } from '../memory/runs-store.js';
49
50
  import type { PendingConfirmation } from '../memory/runs-store.js';
51
+ import { setConversationKeyIfAbsent } from '../memory/conversation-key-store.js';
50
52
  import * as channelDeliveryStore from '../memory/channel-delivery-store.js';
51
53
  import * as conversationStore from '../memory/conversation-store.js';
52
54
  import {
@@ -58,7 +60,6 @@ import {
58
60
  import type { RunOrchestrator } from '../runtime/run-orchestrator.js';
59
61
  import {
60
62
  handleChannelInbound,
61
- isChannelApprovalsEnabled,
62
63
  sweepExpiredGuardianApprovals,
63
64
  verifyGatewayOrigin,
64
65
  _setTestPollMaxWait,
@@ -94,6 +95,7 @@ function resetTables(): void {
94
95
  db.run('DELETE FROM channel_guardian_approval_requests');
95
96
  db.run('DELETE FROM channel_guardian_verification_challenges');
96
97
  db.run('DELETE FROM channel_guardian_bindings');
98
+ db.run('DELETE FROM conversation_keys');
97
99
  db.run('DELETE FROM message_runs');
98
100
  db.run('DELETE FROM channel_inbound_events');
99
101
  db.run('DELETE FROM messages');
@@ -141,6 +143,7 @@ function makeInboundRequest(overrides: Record<string, unknown> = {}): Request {
141
143
  const body = {
142
144
  sourceChannel: 'telegram',
143
145
  externalChatId: 'chat-123',
146
+ senderExternalUserId: 'telegram-user-default',
144
147
  externalMessageId: `msg-${Date.now()}-${Math.random()}`,
145
148
  content: 'hello',
146
149
  replyCallbackUrl: 'https://gateway.test/deliver',
@@ -158,53 +161,12 @@ function makeInboundRequest(overrides: Record<string, unknown> = {}): Request {
158
161
 
159
162
  const noopProcessMessage = mock(async () => ({ messageId: 'msg-1' }));
160
163
 
161
- // ---------------------------------------------------------------------------
162
- // Set up / tear down feature flag for each test
163
- // ---------------------------------------------------------------------------
164
-
165
- let originalEnv: string | undefined;
166
-
167
164
  beforeEach(() => {
168
165
  resetTables();
169
- originalEnv = process.env.CHANNEL_APPROVALS_ENABLED;
170
166
  noopProcessMessage.mockClear();
171
167
  });
172
-
173
- afterEach(() => {
174
- if (originalEnv === undefined) {
175
- delete process.env.CHANNEL_APPROVALS_ENABLED;
176
- } else {
177
- process.env.CHANNEL_APPROVALS_ENABLED = originalEnv;
178
- }
179
- });
180
-
181
- // ═══════════════════════════════════════════════════════════════════════════
182
- // 1. Feature flag gating
183
- // ═══════════════════════════════════════════════════════════════════════════
184
-
185
- describe('isChannelApprovalsEnabled', () => {
186
- test('returns false when env var is not set', () => {
187
- delete process.env.CHANNEL_APPROVALS_ENABLED;
188
- expect(isChannelApprovalsEnabled()).toBe(false);
189
- });
190
-
191
- test('returns false when env var is "false"', () => {
192
- process.env.CHANNEL_APPROVALS_ENABLED = 'false';
193
- expect(isChannelApprovalsEnabled()).toBe(false);
194
- });
195
-
196
- test('returns true when env var is "true"', () => {
197
- process.env.CHANNEL_APPROVALS_ENABLED = 'true';
198
- expect(isChannelApprovalsEnabled()).toBe(true);
199
- });
200
- });
201
-
202
- describe('feature flag disabled → normal flow', () => {
203
- beforeEach(() => {
204
- delete process.env.CHANNEL_APPROVALS_ENABLED;
205
- });
206
-
207
- test('proceeds normally even when pending approvals exist', async () => {
168
+ describe('stale callback handling without matching pending approval', () => {
169
+ test('ignores stale callback payloads even when pending approvals exist', async () => {
208
170
  ensureConversation('conv-1');
209
171
  const run = createRun('conv-1');
210
172
  setRunConfirmation(run.id, sampleConfirmation);
@@ -218,9 +180,10 @@ describe('feature flag disabled → normal flow', () => {
218
180
  const res = await handleChannelInbound(req, noopProcessMessage, undefined, orchestrator);
219
181
  const body = await res.json() as Record<string, unknown>;
220
182
 
221
- // Should proceed normally no approval interception
183
+ // Callback payloads without a matching pending approval are treated as
184
+ // stale and ignored.
222
185
  expect(body.accepted).toBe(true);
223
- expect(body.approval).toBeUndefined();
186
+ expect(body.approval).toBe('stale_ignored');
224
187
  });
225
188
  });
226
189
 
@@ -230,7 +193,12 @@ describe('feature flag disabled → normal flow', () => {
230
193
 
231
194
  describe('inbound callback metadata triggers decision handling', () => {
232
195
  beforeEach(() => {
233
- process.env.CHANNEL_APPROVALS_ENABLED = 'true';
196
+ createBinding({
197
+ assistantId: 'self',
198
+ channel: 'telegram',
199
+ guardianExternalUserId: 'telegram-user-default',
200
+ guardianDeliveryChatId: 'chat-123',
201
+ });
234
202
  });
235
203
 
236
204
  test('callback data "apr:<runId>:approve_once" is parsed and applied', async () => {
@@ -322,7 +290,12 @@ describe('inbound callback metadata triggers decision handling', () => {
322
290
 
323
291
  describe('inbound text matching approval phrases triggers decision handling', () => {
324
292
  beforeEach(() => {
325
- process.env.CHANNEL_APPROVALS_ENABLED = 'true';
293
+ createBinding({
294
+ assistantId: 'self',
295
+ channel: 'telegram',
296
+ guardianExternalUserId: 'telegram-user-default',
297
+ guardianDeliveryChatId: 'chat-123',
298
+ });
326
299
  });
327
300
 
328
301
  test('text "approve" triggers approve_once decision', async () => {
@@ -387,7 +360,12 @@ describe('inbound text matching approval phrases triggers decision handling', ()
387
360
 
388
361
  describe('non-decision messages during pending approval trigger reminder', () => {
389
362
  beforeEach(() => {
390
- process.env.CHANNEL_APPROVALS_ENABLED = 'true';
363
+ createBinding({
364
+ assistantId: 'self',
365
+ channel: 'telegram',
366
+ guardianExternalUserId: 'telegram-user-default',
367
+ guardianDeliveryChatId: 'chat-123',
368
+ });
391
369
  });
392
370
 
393
371
  test('sends a reminder prompt when message is not a decision', async () => {
@@ -435,7 +413,6 @@ describe('non-decision messages during pending approval trigger reminder', () =>
435
413
 
436
414
  describe('messages without pending approval proceed normally', () => {
437
415
  beforeEach(() => {
438
- process.env.CHANNEL_APPROVALS_ENABLED = 'true';
439
416
  });
440
417
 
441
418
  test('proceeds to normal processing when no pending approval exists', async () => {
@@ -479,7 +456,6 @@ describe('empty content with callbackData bypasses validation', () => {
479
456
  });
480
457
 
481
458
  test('allows empty content when callbackData is present', async () => {
482
- process.env.CHANNEL_APPROVALS_ENABLED = 'true';
483
459
  const orchestrator = makeMockOrchestrator();
484
460
 
485
461
  // Establish the conversation first
@@ -512,7 +488,6 @@ describe('empty content with callbackData bypasses validation', () => {
512
488
  });
513
489
 
514
490
  test('allows undefined content when callbackData is present', async () => {
515
- process.env.CHANNEL_APPROVALS_ENABLED = 'true';
516
491
  const orchestrator = makeMockOrchestrator();
517
492
 
518
493
  // Establish the conversation first
@@ -561,7 +536,12 @@ describe('empty content with callbackData bypasses validation', () => {
561
536
 
562
537
  describe('callback run ID validation', () => {
563
538
  beforeEach(() => {
564
- process.env.CHANNEL_APPROVALS_ENABLED = 'true';
539
+ createBinding({
540
+ assistantId: 'self',
541
+ channel: 'telegram',
542
+ guardianExternalUserId: 'telegram-user-default',
543
+ guardianDeliveryChatId: 'chat-123',
544
+ });
565
545
  });
566
546
 
567
547
  test('ignores stale callback when run ID does not match pending run', async () => {
@@ -669,7 +649,6 @@ describe('callback run ID validation', () => {
669
649
 
670
650
  describe('linkMessage in approval-aware processing path', () => {
671
651
  beforeEach(() => {
672
- process.env.CHANNEL_APPROVALS_ENABLED = 'true';
673
652
  });
674
653
 
675
654
  test('linkMessage is called when run has a messageId and reaches terminal state', async () => {
@@ -726,7 +705,6 @@ describe('linkMessage in approval-aware processing path', () => {
726
705
 
727
706
  describe('terminal state check before markProcessed', () => {
728
707
  beforeEach(() => {
729
- process.env.CHANNEL_APPROVALS_ENABLED = 'true';
730
708
  });
731
709
 
732
710
  test('records processing failure when run disappears (non-approval non-terminal state)', async () => {
@@ -865,7 +843,12 @@ describe('terminal state check before markProcessed', () => {
865
843
 
866
844
  describe('no immediate reply after approval decision', () => {
867
845
  beforeEach(() => {
868
- process.env.CHANNEL_APPROVALS_ENABLED = 'true';
846
+ createBinding({
847
+ assistantId: 'self',
848
+ channel: 'telegram',
849
+ guardianExternalUserId: 'telegram-user-default',
850
+ guardianDeliveryChatId: 'chat-123',
851
+ });
869
852
  });
870
853
 
871
854
  test('deliverChannelReply is NOT called from interception after decision is applied', async () => {
@@ -943,7 +926,6 @@ describe('no immediate reply after approval decision', () => {
943
926
 
944
927
  describe('stale callback handling', () => {
945
928
  beforeEach(() => {
946
- process.env.CHANNEL_APPROVALS_ENABLED = 'true';
947
929
  });
948
930
 
949
931
  test('callback with no pending approval returns stale_ignored and does not start a run', async () => {
@@ -1007,7 +989,6 @@ describe('stale callback handling', () => {
1007
989
 
1008
990
  describe('poll timeout handling by run state', () => {
1009
991
  beforeEach(() => {
1010
- process.env.CHANNEL_APPROVALS_ENABLED = 'true';
1011
992
  });
1012
993
 
1013
994
  test('records processing failure when run disappears (getRun returns null) before terminal state', async () => {
@@ -1060,8 +1041,8 @@ describe('poll timeout handling by run state', () => {
1060
1041
 
1061
1042
  test('marks event as processed when run is in needs_confirmation state after poll timeout', async () => {
1062
1043
  // Use a short poll timeout so the test can exercise the timeout path
1063
- // without waiting 5 minutes.
1064
- _setTestPollMaxWait(600);
1044
+ // without waiting 5 minutes. Must exceed one poll interval (500ms).
1045
+ _setTestPollMaxWait(700);
1065
1046
 
1066
1047
  const linkSpy = spyOn(channelDeliveryStore, 'linkMessage').mockImplementation(() => {});
1067
1048
  const markSpy = spyOn(channelDeliveryStore, 'markProcessed');
@@ -1083,20 +1064,26 @@ describe('poll timeout handling by run state', () => {
1083
1064
  updatedAt: Date.now(),
1084
1065
  };
1085
1066
 
1086
- // getRun returns needs_confirmation run is waiting for approval decision.
1087
- // The event should be marked as processed because the post-decision delivery
1088
- // in handleApprovalInterception will deliver the reply when the user decides.
1067
+ // getRun returns null on the first call (causing the poll loop to break
1068
+ // immediately), then returns needs_confirmation on the post-loop call.
1069
+ // This exercises the timeout path deterministically without spinning for
1070
+ // the full poll duration.
1071
+ let getRunCalls = 0;
1089
1072
  const orchestrator = {
1090
1073
  submitDecision: mock(() => 'applied' as const),
1091
- getRun: mock(() => ({ ...mockRun, status: 'needs_confirmation' as const })),
1074
+ getRun: mock(() => {
1075
+ getRunCalls++;
1076
+ if (getRunCalls <= 1) return null;
1077
+ return { ...mockRun, status: 'needs_confirmation' as const };
1078
+ }),
1092
1079
  startRun: mock(async () => mockRun),
1093
1080
  } as unknown as RunOrchestrator;
1094
1081
 
1095
1082
  const req = makeInboundRequest({ content: 'hello needs_confirm' });
1096
1083
  await handleChannelInbound(req, noopProcessMessage, 'token', orchestrator);
1097
1084
 
1098
- // Wait for the background async to complete (poll timeout is 600ms + one poll interval of 500ms)
1099
- await new Promise((resolve) => setTimeout(resolve, 1500));
1085
+ // Wait for the background async to complete
1086
+ await new Promise((resolve) => setTimeout(resolve, 800));
1100
1087
 
1101
1088
  // markProcessed SHOULD have been called — the run is waiting for approval,
1102
1089
  // and the post-decision delivery path will handle the final reply.
@@ -1112,6 +1099,125 @@ describe('poll timeout handling by run state', () => {
1112
1099
  _setTestPollMaxWait(null);
1113
1100
  });
1114
1101
 
1102
+ test('marks event as processed when run transitions from needs_confirmation to running at poll timeout', async () => {
1103
+ // When an approval is applied near the poll deadline, the run transitions
1104
+ // from needs_confirmation to running. The post-decision delivery path
1105
+ // in handleApprovalInterception handles the final reply, so the main poll
1106
+ // should mark the event as processed rather than recording a failure.
1107
+ //
1108
+ // The hasPostDecisionDelivery flag is only set when the approval prompt
1109
+ // is actually delivered successfully — not in auto-deny paths. This test
1110
+ // sets up a guardian actor with a real DB run so the standard approval
1111
+ // prompt is delivered and the flag is set.
1112
+ _setTestPollMaxWait(100);
1113
+
1114
+ const linkSpy = spyOn(channelDeliveryStore, 'linkMessage').mockImplementation(() => {});
1115
+ const markSpy = spyOn(channelDeliveryStore, 'markProcessed');
1116
+ const failureSpy = spyOn(channelDeliveryStore, 'recordProcessingFailure').mockImplementation(() => {});
1117
+ const deliverSpy = spyOn(gatewayClient, 'deliverChannelReply').mockResolvedValue(undefined);
1118
+ const approvalSpy = spyOn(gatewayClient, 'deliverApprovalPrompt').mockResolvedValue(undefined);
1119
+
1120
+ // Set up a guardian binding so the sender is a guardian (standard approval
1121
+ // path, not auto-deny). This ensures the approval prompt is delivered and
1122
+ // hasPostDecisionDelivery is set to true.
1123
+ createBinding({
1124
+ assistantId: 'self',
1125
+ channel: 'telegram',
1126
+ guardianExternalUserId: 'telegram-user-default',
1127
+ guardianDeliveryChatId: 'chat-123',
1128
+ });
1129
+
1130
+ const conversationId = `conv-post-approval-${Date.now()}`;
1131
+ ensureConversation(conversationId);
1132
+ setConversationKeyIfAbsent('asst:self:telegram:chat-123', conversationId);
1133
+ setConversationKeyIfAbsent('telegram:chat-123', conversationId);
1134
+
1135
+ let realRunId: string | undefined;
1136
+
1137
+ // Simulate a run that transitions from needs_confirmation back to running
1138
+ // (approval applied) before the poll exits, then stays running past timeout.
1139
+ let getRunCalls = 0;
1140
+ const orchestrator = {
1141
+ submitDecision: mock(() => 'applied' as const),
1142
+ getRun: mock(() => {
1143
+ getRunCalls++;
1144
+ // First call inside the loop: needs_confirmation (triggers approval prompt delivery)
1145
+ if (getRunCalls <= 1) return {
1146
+ id: realRunId ?? 'run-post-approval',
1147
+ conversationId,
1148
+ messageId: 'user-msg-203',
1149
+ status: 'needs_confirmation' as const,
1150
+ pendingConfirmation: sampleConfirmation,
1151
+ pendingSecret: null,
1152
+ inputTokens: 0,
1153
+ outputTokens: 0,
1154
+ estimatedCost: 0,
1155
+ error: null,
1156
+ createdAt: Date.now(),
1157
+ updatedAt: Date.now(),
1158
+ };
1159
+ // Subsequent calls: running (approval was applied, run resumed)
1160
+ return {
1161
+ id: realRunId ?? 'run-post-approval',
1162
+ conversationId,
1163
+ messageId: 'user-msg-203',
1164
+ status: 'running' as const,
1165
+ pendingConfirmation: null,
1166
+ pendingSecret: null,
1167
+ inputTokens: 0,
1168
+ outputTokens: 0,
1169
+ estimatedCost: 0,
1170
+ error: null,
1171
+ createdAt: Date.now(),
1172
+ updatedAt: Date.now(),
1173
+ };
1174
+ }),
1175
+ startRun: mock(async (_convId: string) => {
1176
+ const run = createRun(conversationId);
1177
+ realRunId = run.id;
1178
+ setRunConfirmation(run.id, sampleConfirmation);
1179
+ return {
1180
+ id: run.id,
1181
+ conversationId,
1182
+ messageId: null,
1183
+ status: 'running' as const,
1184
+ pendingConfirmation: null,
1185
+ pendingSecret: null,
1186
+ inputTokens: 0,
1187
+ outputTokens: 0,
1188
+ estimatedCost: 0,
1189
+ error: null,
1190
+ createdAt: Date.now(),
1191
+ updatedAt: Date.now(),
1192
+ };
1193
+ }),
1194
+ } as unknown as RunOrchestrator;
1195
+
1196
+ const req = makeInboundRequest({ content: 'hello post-approval running' });
1197
+ await handleChannelInbound(req, noopProcessMessage, 'token', orchestrator);
1198
+
1199
+ // Wait for background async to complete (poll timeout + buffer)
1200
+ await new Promise((resolve) => setTimeout(resolve, 1500));
1201
+
1202
+ // The approval prompt should have been delivered (standard path for guardian actor)
1203
+ expect(approvalSpy).toHaveBeenCalled();
1204
+
1205
+ // markProcessed SHOULD have been called — the approval prompt was delivered
1206
+ // (hasPostDecisionDelivery is true) and the run transitioned to running
1207
+ // (post-approval), so the post-decision delivery path handles the final reply.
1208
+ expect(markSpy).toHaveBeenCalled();
1209
+
1210
+ // recordProcessingFailure should NOT have been called
1211
+ expect(failureSpy).not.toHaveBeenCalled();
1212
+
1213
+ linkSpy.mockRestore();
1214
+ markSpy.mockRestore();
1215
+ failureSpy.mockRestore();
1216
+ deliverSpy.mockRestore();
1217
+ approvalSpy.mockRestore();
1218
+ _setTestPollMaxWait(null);
1219
+ });
1220
+
1115
1221
  test('does NOT call recordProcessingFailure when run reaches terminal state', async () => {
1116
1222
  const linkSpy = spyOn(channelDeliveryStore, 'linkMessage').mockImplementation(() => {});
1117
1223
  const markSpy = spyOn(channelDeliveryStore, 'markProcessed');
@@ -1165,7 +1271,6 @@ describe('poll timeout handling by run state', () => {
1165
1271
 
1166
1272
  describe('post-decision delivery after poll timeout', () => {
1167
1273
  beforeEach(() => {
1168
- process.env.CHANNEL_APPROVALS_ENABLED = 'true';
1169
1274
  });
1170
1275
 
1171
1276
  test('delivers reply via callback after a late approval decision', async () => {
@@ -1279,7 +1384,6 @@ describe('post-decision delivery after poll timeout', () => {
1279
1384
 
1280
1385
  describe('sourceChannel passed to orchestrator.startRun', () => {
1281
1386
  beforeEach(() => {
1282
- process.env.CHANNEL_APPROVALS_ENABLED = 'true';
1283
1387
  });
1284
1388
 
1285
1389
  test('startRun is called with sourceChannel from inbound event', async () => {
@@ -1337,13 +1441,19 @@ describe('sourceChannel passed to orchestrator.startRun', () => {
1337
1441
 
1338
1442
  describe('SMS channel approval decisions', () => {
1339
1443
  beforeEach(() => {
1340
- process.env.CHANNEL_APPROVALS_ENABLED = 'true';
1444
+ createBinding({
1445
+ assistantId: 'self',
1446
+ channel: 'sms',
1447
+ guardianExternalUserId: 'sms-user-default',
1448
+ guardianDeliveryChatId: 'sms-chat-123',
1449
+ });
1341
1450
  });
1342
1451
 
1343
1452
  function makeSmsInboundRequest(overrides: Record<string, unknown> = {}): Request {
1344
1453
  const body = {
1345
1454
  sourceChannel: 'sms',
1346
1455
  externalChatId: 'sms-chat-123',
1456
+ senderExternalUserId: 'sms-user-default',
1347
1457
  externalMessageId: `msg-${Date.now()}-${Math.random()}`,
1348
1458
  content: 'hello',
1349
1459
  replyCallbackUrl: 'https://gateway.test/deliver',
@@ -1525,7 +1635,9 @@ describe('SMS guardian verify intercept', () => {
1525
1635
  const replyArgs = deliverSpy.mock.calls[0];
1526
1636
  const replyPayload = replyArgs[1] as { chatId: string; text: string };
1527
1637
  expect(replyPayload.chatId).toBe('sms-chat-verify');
1528
- expect(replyPayload.text).toContain('Guardian verified successfully');
1638
+ expect(typeof replyPayload.text).toBe('string');
1639
+ expect(replyPayload.text.toLowerCase()).toContain('guardian');
1640
+ expect(replyPayload.text.toLowerCase()).toContain('verif');
1529
1641
 
1530
1642
  deliverSpy.mockRestore();
1531
1643
  });
@@ -1558,7 +1670,9 @@ describe('SMS guardian verify intercept', () => {
1558
1670
  expect(deliverSpy).toHaveBeenCalled();
1559
1671
  const replyArgs = deliverSpy.mock.calls[0];
1560
1672
  const replyPayload = replyArgs[1] as { chatId: string; text: string };
1561
- expect(replyPayload.text).toContain('Verification failed');
1673
+ expect(typeof replyPayload.text).toBe('string');
1674
+ expect(replyPayload.text.toLowerCase()).toContain('verif');
1675
+ expect(replyPayload.text.toLowerCase()).toContain('failed');
1562
1676
 
1563
1677
  deliverSpy.mockRestore();
1564
1678
  });
@@ -1570,7 +1684,6 @@ describe('SMS guardian verify intercept', () => {
1570
1684
 
1571
1685
  describe('SMS non-guardian actor gating', () => {
1572
1686
  beforeEach(() => {
1573
- process.env.CHANNEL_APPROVALS_ENABLED = 'true';
1574
1687
  });
1575
1688
 
1576
1689
  test('non-guardian SMS actor gets stricter controls when guardian binding exists', async () => {
@@ -1644,7 +1757,18 @@ describe('SMS non-guardian actor gating', () => {
1644
1757
 
1645
1758
  describe('plain-text fallback surfacing for non-rich channels', () => {
1646
1759
  beforeEach(() => {
1647
- process.env.CHANNEL_APPROVALS_ENABLED = 'true';
1760
+ createBinding({
1761
+ assistantId: 'self',
1762
+ channel: 'telegram',
1763
+ guardianExternalUserId: 'telegram-user-default',
1764
+ guardianDeliveryChatId: 'chat-123',
1765
+ });
1766
+ createBinding({
1767
+ assistantId: 'self',
1768
+ channel: 'http-api',
1769
+ guardianExternalUserId: 'telegram-user-default',
1770
+ guardianDeliveryChatId: 'chat-123',
1771
+ });
1648
1772
  });
1649
1773
 
1650
1774
  test('reminder prompt includes plainTextFallback for non-rich channel (http-api)', async () => {
@@ -1800,10 +1924,9 @@ function makeSensitiveOrchestrator(opts: {
1800
1924
 
1801
1925
  describe('fail-closed guardian gate — unverified channel', () => {
1802
1926
  beforeEach(() => {
1803
- process.env.CHANNEL_APPROVALS_ENABLED = 'true';
1804
1927
  });
1805
1928
 
1806
- test('no binding + sensitive action → auto-deny and setup notice', async () => {
1929
+ test('no binding + sensitive action → auto-deny with contextual assistant guidance', async () => {
1807
1930
  const deliverSpy = spyOn(gatewayClient, 'deliverChannelReply').mockResolvedValue(undefined);
1808
1931
  const approvalSpy = spyOn(gatewayClient, 'deliverApprovalPrompt').mockResolvedValue(undefined);
1809
1932
 
@@ -1823,11 +1946,15 @@ describe('fail-closed guardian gate — unverified channel', () => {
1823
1946
  const decisionArgs = (orchestrator.submitDecision as ReturnType<typeof mock>).mock.calls[0];
1824
1947
  expect(decisionArgs[1]).toBe('deny');
1825
1948
 
1826
- // The requester should have been notified about missing guardian setup
1827
- const replyCalls = deliverSpy.mock.calls.filter(
1828
- (call) => typeof call[1] === 'object' && (call[1] as { text?: string }).text?.includes('no guardian has been set up'),
1949
+ // The deny decision should carry guardian setup context for assistant reply generation.
1950
+ expect(typeof decisionArgs[2]).toBe('string');
1951
+ expect((decisionArgs[2] as string).toLowerCase()).toContain('no guardian');
1952
+
1953
+ // The runtime should not send a second deterministic denial notice.
1954
+ const deterministicNoticeCalls = deliverSpy.mock.calls.filter(
1955
+ (call) => typeof call[1] === 'object' && (call[1] as { text?: string }).text?.toLowerCase().includes('no guardian'),
1829
1956
  );
1830
- expect(replyCalls.length).toBeGreaterThanOrEqual(1);
1957
+ expect(deterministicNoticeCalls.length).toBe(0);
1831
1958
 
1832
1959
  // No approval prompt should have been sent to a guardian (none exists)
1833
1960
  expect(approvalSpy).not.toHaveBeenCalled();
@@ -1916,11 +2043,19 @@ describe('fail-closed guardian gate — unverified channel', () => {
1916
2043
  expect(body.accepted).toBe(true);
1917
2044
  expect(body.approval).toBe('decision_applied');
1918
2045
 
1919
- // The denial notice should have been sent
2046
+ // The deny decision should carry guardian setup context for the assistant.
2047
+ const submitCalls = (orchestrator.submitDecision as ReturnType<typeof mock>).mock.calls;
2048
+ expect(submitCalls.length).toBeGreaterThanOrEqual(1);
2049
+ const lastDecision = submitCalls[submitCalls.length - 1];
2050
+ expect(lastDecision[1]).toBe('deny');
2051
+ expect(typeof lastDecision[2]).toBe('string');
2052
+ expect((lastDecision[2] as string).toLowerCase()).toContain('no guardian');
2053
+
2054
+ // Interception should not emit a separate deterministic denial notice.
1920
2055
  const denialCalls = deliverSpy.mock.calls.filter(
1921
- (call) => typeof call[1] === 'object' && (call[1] as { text?: string }).text?.includes('no guardian has been set up'),
2056
+ (call) => typeof call[1] === 'object' && (call[1] as { text?: string }).text?.toLowerCase().includes('no guardian'),
1922
2057
  );
1923
- expect(denialCalls.length).toBeGreaterThanOrEqual(1);
2058
+ expect(denialCalls.length).toBe(0);
1924
2059
 
1925
2060
  deliverSpy.mockRestore();
1926
2061
  });
@@ -1932,7 +2067,6 @@ describe('fail-closed guardian gate — unverified channel', () => {
1932
2067
 
1933
2068
  describe('guardian-with-binding path regression', () => {
1934
2069
  beforeEach(() => {
1935
- process.env.CHANNEL_APPROVALS_ENABLED = 'true';
1936
2070
  });
1937
2071
 
1938
2072
  test('non-guardian with binding routes approval to guardian chat', async () => {
@@ -1962,9 +2096,9 @@ describe('guardian-with-binding path regression', () => {
1962
2096
  const approvalArgs = approvalSpy.mock.calls[0];
1963
2097
  expect(approvalArgs[1]).toBe('guardian-chat-1');
1964
2098
 
1965
- // Requester should have been notified the request was sent to the guardian
2099
+ // Requester should have been notified the request was forwarded to the guardian
1966
2100
  const notifyCalls = deliverSpy.mock.calls.filter(
1967
- (call) => typeof call[1] === 'object' && (call[1] as { text?: string }).text?.includes('has been sent to the guardian for approval'),
2101
+ (call) => typeof call[1] === 'object' && (call[1] as { text?: string }).text?.toLowerCase().includes('guardian'),
1968
2102
  );
1969
2103
  expect(notifyCalls.length).toBeGreaterThanOrEqual(1);
1970
2104
 
@@ -2004,6 +2138,53 @@ describe('guardian-with-binding path regression', () => {
2004
2138
  deliverSpy.mockRestore();
2005
2139
  approvalSpy.mockRestore();
2006
2140
  });
2141
+
2142
+ test('guardian callback for own pending run is handled by standard interception', async () => {
2143
+ createBinding({
2144
+ assistantId: 'self',
2145
+ channel: 'telegram',
2146
+ guardianExternalUserId: 'guardian-user-self-callback',
2147
+ guardianDeliveryChatId: 'chat-123',
2148
+ });
2149
+
2150
+ const orchestrator = makeMockOrchestrator();
2151
+ const deliverSpy = spyOn(gatewayClient, 'deliverChannelReply').mockResolvedValue(undefined);
2152
+
2153
+ // Establish the conversation mapping for chat-123.
2154
+ const initReq = makeInboundRequest({
2155
+ content: 'init',
2156
+ senderExternalUserId: 'guardian-user-self-callback',
2157
+ externalChatId: 'chat-123',
2158
+ });
2159
+ await handleChannelInbound(initReq, noopProcessMessage, 'token', orchestrator);
2160
+
2161
+ const db = getDb();
2162
+ const events = db.$client.prepare('SELECT conversation_id FROM channel_inbound_events').all() as Array<{ conversation_id: string }>;
2163
+ const conversationId = events[0]?.conversation_id;
2164
+ ensureConversation(conversationId!);
2165
+
2166
+ const run = createRun(conversationId!);
2167
+ setRunConfirmation(run.id, sampleConfirmation);
2168
+
2169
+ // Button callback includes a runId but there is no guardian approval request
2170
+ // because this is the guardian's own approval flow.
2171
+ const req = makeInboundRequest({
2172
+ content: '',
2173
+ senderExternalUserId: 'guardian-user-self-callback',
2174
+ externalChatId: 'chat-123',
2175
+ callbackData: `apr:${run.id}:approve_once`,
2176
+ });
2177
+
2178
+ const res = await handleChannelInbound(req, noopProcessMessage, 'token', orchestrator);
2179
+ const body = await res.json() as Record<string, unknown>;
2180
+
2181
+ expect(body.accepted).toBe(true);
2182
+ expect(body.approval).toBe('decision_applied');
2183
+ expect(orchestrator.submitDecision).toHaveBeenCalledWith(run.id, 'allow');
2184
+ expect(getPendingApprovalForRun(run.id)).toBeNull();
2185
+
2186
+ deliverSpy.mockRestore();
2187
+ });
2007
2188
  });
2008
2189
 
2009
2190
  // ═══════════════════════════════════════════════════════════════════════════
@@ -2012,7 +2193,6 @@ describe('guardian-with-binding path regression', () => {
2012
2193
 
2013
2194
  describe('guardian delivery failure → denial', () => {
2014
2195
  beforeEach(() => {
2015
- process.env.CHANNEL_APPROVALS_ENABLED = 'true';
2016
2196
  });
2017
2197
 
2018
2198
  test('delivery failure denies run and notifies requester', async () => {
@@ -2047,14 +2227,14 @@ describe('guardian delivery failure → denial', () => {
2047
2227
 
2048
2228
  // Requester should have been notified that delivery failed
2049
2229
  const failureCalls = deliverSpy.mock.calls.filter(
2050
- (call) => typeof call[1] === 'object' && (call[1] as { text?: string }).text?.includes('could not be sent to the guardian for approval'),
2230
+ (call) => typeof call[1] === 'object' && (call[1] as { text?: string }).text?.toLowerCase().includes('denied'),
2051
2231
  );
2052
2232
  expect(failureCalls.length).toBeGreaterThanOrEqual(1);
2053
2233
 
2054
- // The "has been sent to the guardian for approval" success notice should
2055
- // NOT have been delivered (since delivery failed).
2234
+ // The guardian_request_forwarded success notice should NOT have been
2235
+ // delivered (since delivery failed).
2056
2236
  const successCalls = deliverSpy.mock.calls.filter(
2057
- (call) => typeof call[1] === 'object' && (call[1] as { text?: string }).text?.includes('has been sent to the guardian for approval'),
2237
+ (call) => typeof call[1] === 'object' && (call[1] as { text?: string }).text?.toLowerCase().includes('forwarded'),
2058
2238
  );
2059
2239
  expect(successCalls.length).toBe(0);
2060
2240
 
@@ -2108,7 +2288,6 @@ describe('guardian delivery failure → denial', () => {
2108
2288
 
2109
2289
  describe('standard approval prompt delivery failure → auto-deny', () => {
2110
2290
  beforeEach(() => {
2111
- process.env.CHANNEL_APPROVALS_ENABLED = 'true';
2112
2291
  });
2113
2292
 
2114
2293
  test('standard prompt delivery failure auto-denies the run (fail-closed)', async () => {
@@ -2154,7 +2333,6 @@ describe('standard approval prompt delivery failure → auto-deny', () => {
2154
2333
 
2155
2334
  describe('guardian decision scoping — multiple pending approvals', () => {
2156
2335
  beforeEach(() => {
2157
- process.env.CHANNEL_APPROVALS_ENABLED = 'true';
2158
2336
  });
2159
2337
 
2160
2338
  test('callback for older run resolves to the correct approval request', async () => {
@@ -2241,7 +2419,6 @@ describe('guardian decision scoping — multiple pending approvals', () => {
2241
2419
 
2242
2420
  describe('ambiguous plain-text decision with multiple pending requests', () => {
2243
2421
  beforeEach(() => {
2244
- process.env.CHANNEL_APPROVALS_ENABLED = 'true';
2245
2422
  });
2246
2423
 
2247
2424
  test('does not apply plain-text decision to wrong run when multiple pending', async () => {
@@ -2314,7 +2491,7 @@ describe('ambiguous plain-text decision with multiple pending requests', () => {
2314
2491
 
2315
2492
  // A disambiguation message should have been sent to the guardian
2316
2493
  const disambigCalls = deliverSpy.mock.calls.filter(
2317
- (call) => typeof call[1] === 'object' && (call[1] as { text?: string }).text?.includes('pending approval requests'),
2494
+ (call) => typeof call[1] === 'object' && (call[1] as { text?: string }).text?.toLowerCase().includes('pending'),
2318
2495
  );
2319
2496
  expect(disambigCalls.length).toBeGreaterThanOrEqual(1);
2320
2497
 
@@ -2328,7 +2505,6 @@ describe('ambiguous plain-text decision with multiple pending requests', () => {
2328
2505
 
2329
2506
  describe('expired guardian approval auto-denies via sweep', () => {
2330
2507
  beforeEach(() => {
2331
- process.env.CHANNEL_APPROVALS_ENABLED = 'true';
2332
2508
  });
2333
2509
 
2334
2510
  test('sweepExpiredGuardianApprovals auto-denies and notifies both parties', async () => {
@@ -2472,7 +2648,12 @@ describe('deliver-once idempotency guard', () => {
2472
2648
 
2473
2649
  describe('final reply idempotency — no duplicate delivery', () => {
2474
2650
  beforeEach(() => {
2475
- process.env.CHANNEL_APPROVALS_ENABLED = 'true';
2651
+ createBinding({
2652
+ assistantId: 'self',
2653
+ channel: 'telegram',
2654
+ guardianExternalUserId: 'telegram-user-default',
2655
+ guardianDeliveryChatId: 'chat-123',
2656
+ });
2476
2657
  });
2477
2658
 
2478
2659
  test('main poll wins: deliverChannelReply called exactly once when main poll delivers first', async () => {
@@ -2766,7 +2947,6 @@ describe('assistant-scoped guardian verification via handleChannelInbound', () =
2766
2947
  });
2767
2948
 
2768
2949
  test('actor role resolution uses threaded assistantId', async () => {
2769
- process.env.CHANNEL_APPROVALS_ENABLED = 'true';
2770
2950
 
2771
2951
  // Create guardian binding for asst-role-X
2772
2952
  createBinding({
@@ -2800,7 +2980,6 @@ describe('assistant-scoped guardian verification via handleChannelInbound', () =
2800
2980
  });
2801
2981
 
2802
2982
  test('same user is guardian for one assistant but not another', async () => {
2803
- process.env.CHANNEL_APPROVALS_ENABLED = 'true';
2804
2983
 
2805
2984
  // user-multi is guardian for asst-M1 but not asst-M2
2806
2985
  createBinding({
@@ -2856,16 +3035,87 @@ describe('assistant-scoped guardian verification via handleChannelInbound', () =
2856
3035
  deliverSpy.mockRestore();
2857
3036
  approvalSpy.mockRestore();
2858
3037
  });
3038
+
3039
+ test('non-self assistant inbound does not mutate assistant-agnostic external bindings', async () => {
3040
+ const db = getDb();
3041
+ const now = Date.now();
3042
+ ensureConversation('conv-existing-binding');
3043
+ db.insert(externalConversationBindings).values({
3044
+ conversationId: 'conv-existing-binding',
3045
+ sourceChannel: 'telegram',
3046
+ externalChatId: 'chat-123',
3047
+ externalUserId: 'existing-user',
3048
+ createdAt: now,
3049
+ updatedAt: now,
3050
+ lastInboundAt: now,
3051
+ }).run();
3052
+
3053
+ const req = makeInboundRequest({
3054
+ content: 'hello from non-self assistant',
3055
+ senderExternalUserId: 'incoming-user',
3056
+ });
3057
+
3058
+ const res = await handleChannelInbound(req, undefined, 'token', undefined, 'asst-non-self');
3059
+ expect(res.status).toBe(200);
3060
+
3061
+ const binding = db
3062
+ .select()
3063
+ .from(externalConversationBindings)
3064
+ .where(eq(externalConversationBindings.conversationId, 'conv-existing-binding'))
3065
+ .get();
3066
+ expect(binding).not.toBeNull();
3067
+ expect(binding!.externalUserId).toBe('existing-user');
3068
+ });
2859
3069
  });
2860
3070
 
2861
3071
  // ═══════════════════════════════════════════════════════════════════════════
2862
- // 27. Guardian enforcement decoupled from CHANNEL_APPROVALS_ENABLED
3072
+ // 27. Guardian enforcement behavior
2863
3073
  // ═══════════════════════════════════════════════════════════════════════════
2864
3074
 
2865
- describe('guardian enforcement independence from approval flag', () => {
2866
- test('actor role resolution runs when CHANNEL_APPROVALS_ENABLED is off', async () => {
2867
- delete process.env.CHANNEL_APPROVALS_ENABLED;
3075
+ describe('guardian enforcement behavior', () => {
3076
+ test('guardian sender on telegram uses approval-aware path', async () => {
3077
+
3078
+ // Default senderExternalUserId in makeInboundRequest is telegram-user-default.
3079
+ createBinding({
3080
+ assistantId: 'self',
3081
+ channel: 'telegram',
3082
+ guardianExternalUserId: 'telegram-user-default',
3083
+ guardianDeliveryChatId: 'chat-123',
3084
+ });
3085
+
3086
+ const processSpy = mock(async () => ({ messageId: 'msg-bg-guardian' }));
3087
+ const approvalSpy = spyOn(gatewayClient, 'deliverApprovalPrompt').mockResolvedValue(undefined);
3088
+ const deliverSpy = spyOn(gatewayClient, 'deliverChannelReply').mockResolvedValue(undefined);
3089
+
3090
+ const orchestrator = makeSensitiveOrchestrator({
3091
+ runId: 'run-guardian-flag-off-telegram',
3092
+ terminalStatus: 'completed',
3093
+ });
3094
+
3095
+ const req = makeInboundRequest({
3096
+ content: 'place a call',
3097
+ senderExternalUserId: 'telegram-user-default',
3098
+ sourceChannel: 'telegram',
3099
+ });
3100
+
3101
+ const res = await handleChannelInbound(req, processSpy, 'token', orchestrator);
3102
+ expect(res.status).toBe(200);
3103
+
3104
+ await new Promise((resolve) => setTimeout(resolve, 1200));
3105
+
3106
+ // Regression guard: this must use the orchestrator approval path, not
3107
+ // fire-and-forget processMessage, otherwise prompts can time out.
3108
+ expect(orchestrator.startRun).toHaveBeenCalled();
3109
+ expect(processSpy).not.toHaveBeenCalled();
2868
3110
 
3111
+ // Guardian self-approval prompt should be delivered to the requester's chat.
3112
+ expect(approvalSpy).toHaveBeenCalled();
3113
+
3114
+ approvalSpy.mockRestore();
3115
+ deliverSpy.mockRestore();
3116
+ });
3117
+
3118
+ test('non-guardian sensitive action routes approval to guardian', async () => {
2869
3119
  // Create a guardian binding — user-guardian is the guardian
2870
3120
  createBinding({
2871
3121
  assistantId: 'self',
@@ -2874,31 +3124,28 @@ describe('guardian enforcement independence from approval flag', () => {
2874
3124
  guardianDeliveryChatId: 'chat-guardian',
2875
3125
  });
2876
3126
 
2877
- // A non-guardian user sends a message with approvals disabled.
2878
- // Actor role resolution should still classify them as non-guardian
2879
- // even though the approval UX is off.
2880
- const orchestrator = makeMockOrchestrator();
3127
+ const orchestrator = makeSensitiveOrchestrator({ runId: 'run-flag-off-guardian', terminalStatus: 'completed' });
2881
3128
  const deliverSpy = spyOn(gatewayClient, 'deliverChannelReply').mockResolvedValue(undefined);
3129
+ const approvalSpy = spyOn(gatewayClient, 'deliverApprovalPrompt').mockResolvedValue(undefined);
2882
3130
 
2883
3131
  const req = makeInboundRequest({
2884
- content: 'hello world',
3132
+ content: 'do something dangerous',
2885
3133
  senderExternalUserId: 'user-non-guardian',
2886
3134
  });
2887
3135
 
2888
3136
  const res = await handleChannelInbound(req, noopProcessMessage, 'token', orchestrator);
2889
3137
  expect(res.status).toBe(200);
3138
+ await new Promise((resolve) => setTimeout(resolve, 1200));
2890
3139
 
2891
- // The message should proceed normally since approval UX is off,
2892
- // but actor role resolution still ran (verified by the fact that
2893
- // the message processed successfully without error)
2894
- const body = await res.json() as Record<string, unknown>;
2895
- expect(body.accepted).toBe(true);
3140
+ expect(approvalSpy).toHaveBeenCalled();
3141
+ const approvalArgs = approvalSpy.mock.calls[0];
3142
+ expect(approvalArgs[1]).toBe('chat-guardian');
2896
3143
 
2897
3144
  deliverSpy.mockRestore();
3145
+ approvalSpy.mockRestore();
2898
3146
  });
2899
3147
 
2900
3148
  test('missing senderExternalUserId with guardian binding fails closed', async () => {
2901
- process.env.CHANNEL_APPROVALS_ENABLED = 'true';
2902
3149
 
2903
3150
  // Create a guardian binding — guardian enforcement is active
2904
3151
  createBinding({
@@ -2928,20 +3175,21 @@ describe('guardian enforcement independence from approval flag', () => {
2928
3175
  // Wait for background processing
2929
3176
  await new Promise((resolve) => setTimeout(resolve, 1200));
2930
3177
 
2931
- // The unknown actor should be treated as unverified_channel and
2932
- // sensitive actions should be auto-denied via the no_identity branch.
2933
- // deliverChannelReply args: (callbackUrl, payload, bearerToken?)
2934
- // The denial notice is in payload.text (index 1 of the call args).
2935
- expect(deliverSpy).toHaveBeenCalled();
3178
+ // The unknown actor should be treated as unverified_channel and denied,
3179
+ // with context passed into the tool-denial response for assistant phrasing.
3180
+ const submitCalls = (orchestrator.submitDecision as ReturnType<typeof mock>).mock.calls;
3181
+ expect(submitCalls.length).toBeGreaterThanOrEqual(1);
3182
+ const lastDecision = submitCalls[submitCalls.length - 1];
3183
+ expect(lastDecision[1]).toBe('deny');
3184
+ expect(typeof lastDecision[2]).toBe('string');
3185
+ expect((lastDecision[2] as string).toLowerCase()).toContain('identity');
3186
+
3187
+ // No separate deterministic denial notice should be emitted here.
2936
3188
  const denialCalls = deliverSpy.mock.calls.filter(
2937
- (call) => {
2938
- if (typeof call[1] !== 'object') return false;
2939
- const text = (call[1] as { text?: string }).text ?? '';
2940
- return text.includes('requires guardian approval') &&
2941
- (text.includes('identity could not be determined') || text.includes('no guardian has been set up'));
2942
- },
3189
+ (call) => typeof call[1] === 'object'
3190
+ && ((call[1] as { text?: string }).text ?? '').toLowerCase().includes('identity'),
2943
3191
  );
2944
- expect(denialCalls.length).toBeGreaterThanOrEqual(1);
3192
+ expect(denialCalls.length).toBe(0);
2945
3193
 
2946
3194
  // Auto-deny path should never prompt for approval
2947
3195
  expect(approvalSpy).not.toHaveBeenCalled();
@@ -2950,25 +3198,40 @@ describe('guardian enforcement independence from approval flag', () => {
2950
3198
  approvalSpy.mockRestore();
2951
3199
  });
2952
3200
 
2953
- test('missing senderExternalUserId without guardian binding uses default flow', async () => {
2954
- process.env.CHANNEL_APPROVALS_ENABLED = 'true';
3201
+ test('missing senderExternalUserId without guardian binding fails closed', async () => {
2955
3202
 
2956
- // No guardian binding exists default behavior should be preserved
2957
- const orchestrator = makeMockOrchestrator();
3203
+ // No guardian binding exists, but identity is missing treat sender as
3204
+ // unverified_channel and auto-deny sensitive actions.
3205
+ const orchestrator = makeSensitiveOrchestrator({ runId: 'run-failclosed-noid-nobinding', terminalStatus: 'failed' });
2958
3206
  const deliverSpy = spyOn(gatewayClient, 'deliverChannelReply').mockResolvedValue(undefined);
3207
+ const approvalSpy = spyOn(gatewayClient, 'deliverApprovalPrompt').mockResolvedValue(undefined);
2959
3208
 
2960
3209
  const req = makeInboundRequest({
2961
- content: 'hello world',
3210
+ content: 'do something dangerous',
2962
3211
  senderExternalUserId: undefined,
2963
3212
  });
2964
3213
 
2965
3214
  const res = await handleChannelInbound(req, noopProcessMessage, 'token', orchestrator);
2966
3215
  expect(res.status).toBe(200);
2967
3216
 
2968
- const body = await res.json() as Record<string, unknown>;
2969
- expect(body.accepted).toBe(true);
3217
+ await new Promise((resolve) => setTimeout(resolve, 1200));
3218
+
3219
+ const submitCalls = (orchestrator.submitDecision as ReturnType<typeof mock>).mock.calls;
3220
+ expect(submitCalls.length).toBeGreaterThanOrEqual(1);
3221
+ const lastDecision = submitCalls[submitCalls.length - 1];
3222
+ expect(lastDecision[1]).toBe('deny');
3223
+ expect(typeof lastDecision[2]).toBe('string');
3224
+ expect((lastDecision[2] as string).toLowerCase()).toContain('identity');
3225
+
3226
+ const denialCalls = deliverSpy.mock.calls.filter(
3227
+ (call) => typeof call[1] === 'object'
3228
+ && ((call[1] as { text?: string }).text ?? '').toLowerCase().includes('identity'),
3229
+ );
3230
+ expect(denialCalls.length).toBe(0);
3231
+ expect(approvalSpy).not.toHaveBeenCalled();
2970
3232
 
2971
3233
  deliverSpy.mockRestore();
3234
+ approvalSpy.mockRestore();
2972
3235
  });
2973
3236
  });
2974
3237
 
@@ -3119,7 +3382,6 @@ describe('handleChannelInbound gatewayOriginSecret integration', () => {
3119
3382
 
3120
3383
  describe('unknown actor identity — forceStrictSideEffects', () => {
3121
3384
  beforeEach(() => {
3122
- process.env.CHANNEL_APPROVALS_ENABLED = 'true';
3123
3385
  });
3124
3386
 
3125
3387
  test('unknown sender (no senderExternalUserId) with guardian binding gets forceStrictSideEffects', async () => {