@vellumai/assistant 0.3.3 → 0.3.4

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 (75) hide show
  1. package/README.md +8 -16
  2. package/package.json +1 -1
  3. package/src/__tests__/call-orchestrator.test.ts +321 -0
  4. package/src/__tests__/channel-approval-routes.test.ts +382 -124
  5. package/src/__tests__/channel-approvals.test.ts +51 -2
  6. package/src/__tests__/channel-delivery-store.test.ts +30 -4
  7. package/src/__tests__/channel-guardian.test.ts +187 -0
  8. package/src/__tests__/config-schema.test.ts +1 -1
  9. package/src/__tests__/daemon-lifecycle.test.ts +635 -0
  10. package/src/__tests__/gateway-only-enforcement.test.ts +19 -13
  11. package/src/__tests__/handlers-twilio-config.test.ts +73 -0
  12. package/src/__tests__/secret-scanner.test.ts +223 -0
  13. package/src/__tests__/shell-parser-property.test.ts +357 -2
  14. package/src/__tests__/system-prompt.test.ts +25 -1
  15. package/src/__tests__/tool-executor-lifecycle-events.test.ts +34 -1
  16. package/src/__tests__/user-reference.test.ts +68 -0
  17. package/src/calls/call-orchestrator.ts +63 -11
  18. package/src/cli/map.ts +6 -0
  19. package/src/commands/__tests__/cc-command-registry.test.ts +67 -0
  20. package/src/commands/cc-command-registry.ts +14 -1
  21. package/src/config/bundled-skills/claude-code/TOOLS.json +10 -3
  22. package/src/config/bundled-skills/messaging/SKILL.md +4 -0
  23. package/src/config/defaults.ts +1 -1
  24. package/src/config/schema.ts +3 -3
  25. package/src/config/skills.ts +5 -32
  26. package/src/config/system-prompt.ts +16 -0
  27. package/src/config/user-reference.ts +29 -0
  28. package/src/config/vellum-skills/catalog.json +52 -0
  29. package/src/config/vellum-skills/telegram-setup/SKILL.md +6 -1
  30. package/src/config/vellum-skills/twilio-setup/SKILL.md +38 -0
  31. package/src/daemon/auth-manager.ts +103 -0
  32. package/src/daemon/computer-use-session.ts +8 -1
  33. package/src/daemon/config-watcher.ts +253 -0
  34. package/src/daemon/handlers/config.ts +36 -13
  35. package/src/daemon/handlers/skills.ts +6 -7
  36. package/src/daemon/ipc-contract.ts +6 -0
  37. package/src/daemon/ipc-handler.ts +87 -0
  38. package/src/daemon/lifecycle.ts +16 -4
  39. package/src/daemon/ride-shotgun-handler.ts +11 -1
  40. package/src/daemon/server.ts +105 -502
  41. package/src/daemon/session-agent-loop.ts +5 -14
  42. package/src/daemon/session-runtime-assembly.ts +60 -44
  43. package/src/daemon/session.ts +8 -1
  44. package/src/memory/db-connection.ts +28 -0
  45. package/src/memory/db-init.ts +1019 -0
  46. package/src/memory/db.ts +2 -2007
  47. package/src/memory/embedding-backend.ts +79 -11
  48. package/src/memory/indexer.ts +2 -0
  49. package/src/memory/job-utils.ts +64 -4
  50. package/src/memory/jobs-worker.ts +7 -1
  51. package/src/memory/recall-cache.ts +107 -0
  52. package/src/memory/retriever.ts +30 -1
  53. package/src/memory/schema-migration.ts +984 -0
  54. package/src/memory/schema.ts +1 -0
  55. package/src/memory/search/types.ts +2 -0
  56. package/src/permissions/prompter.ts +14 -3
  57. package/src/permissions/trust-store.ts +7 -0
  58. package/src/runtime/channel-approvals.ts +17 -3
  59. package/src/runtime/gateway-client.ts +2 -1
  60. package/src/runtime/http-server.ts +15 -4
  61. package/src/runtime/routes/channel-routes.ts +172 -84
  62. package/src/runtime/routes/run-routes.ts +7 -1
  63. package/src/runtime/run-orchestrator.ts +8 -1
  64. package/src/security/secret-scanner.ts +218 -0
  65. package/src/skills/frontmatter.ts +63 -0
  66. package/src/skills/slash-commands.ts +23 -0
  67. package/src/skills/vellum-catalog-remote.ts +107 -0
  68. package/src/tools/browser/auto-navigate.ts +132 -24
  69. package/src/tools/browser/browser-manager.ts +67 -61
  70. package/src/tools/claude-code/claude-code.ts +55 -3
  71. package/src/tools/executor.ts +10 -2
  72. package/src/tools/skills/vellum-catalog.ts +61 -156
  73. package/src/tools/terminal/parser.ts +21 -5
  74. package/src/util/platform.ts +8 -1
  75. 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',
@@ -1570,7 +1680,6 @@ describe('SMS guardian verify intercept', () => {
1570
1680
 
1571
1681
  describe('SMS non-guardian actor gating', () => {
1572
1682
  beforeEach(() => {
1573
- process.env.CHANNEL_APPROVALS_ENABLED = 'true';
1574
1683
  });
1575
1684
 
1576
1685
  test('non-guardian SMS actor gets stricter controls when guardian binding exists', async () => {
@@ -1644,7 +1753,18 @@ describe('SMS non-guardian actor gating', () => {
1644
1753
 
1645
1754
  describe('plain-text fallback surfacing for non-rich channels', () => {
1646
1755
  beforeEach(() => {
1647
- process.env.CHANNEL_APPROVALS_ENABLED = 'true';
1756
+ createBinding({
1757
+ assistantId: 'self',
1758
+ channel: 'telegram',
1759
+ guardianExternalUserId: 'telegram-user-default',
1760
+ guardianDeliveryChatId: 'chat-123',
1761
+ });
1762
+ createBinding({
1763
+ assistantId: 'self',
1764
+ channel: 'http-api',
1765
+ guardianExternalUserId: 'telegram-user-default',
1766
+ guardianDeliveryChatId: 'chat-123',
1767
+ });
1648
1768
  });
1649
1769
 
1650
1770
  test('reminder prompt includes plainTextFallback for non-rich channel (http-api)', async () => {
@@ -1800,10 +1920,9 @@ function makeSensitiveOrchestrator(opts: {
1800
1920
 
1801
1921
  describe('fail-closed guardian gate — unverified channel', () => {
1802
1922
  beforeEach(() => {
1803
- process.env.CHANNEL_APPROVALS_ENABLED = 'true';
1804
1923
  });
1805
1924
 
1806
- test('no binding + sensitive action → auto-deny and setup notice', async () => {
1925
+ test('no binding + sensitive action → auto-deny with contextual assistant guidance', async () => {
1807
1926
  const deliverSpy = spyOn(gatewayClient, 'deliverChannelReply').mockResolvedValue(undefined);
1808
1927
  const approvalSpy = spyOn(gatewayClient, 'deliverApprovalPrompt').mockResolvedValue(undefined);
1809
1928
 
@@ -1823,11 +1942,15 @@ describe('fail-closed guardian gate — unverified channel', () => {
1823
1942
  const decisionArgs = (orchestrator.submitDecision as ReturnType<typeof mock>).mock.calls[0];
1824
1943
  expect(decisionArgs[1]).toBe('deny');
1825
1944
 
1826
- // The requester should have been notified about missing guardian setup
1827
- const replyCalls = deliverSpy.mock.calls.filter(
1945
+ // The deny decision should carry guardian setup context for assistant reply generation.
1946
+ expect(typeof decisionArgs[2]).toBe('string');
1947
+ expect((decisionArgs[2] as string)).toContain('no guardian is configured');
1948
+
1949
+ // The runtime should not send a second deterministic denial notice.
1950
+ const deterministicNoticeCalls = deliverSpy.mock.calls.filter(
1828
1951
  (call) => typeof call[1] === 'object' && (call[1] as { text?: string }).text?.includes('no guardian has been set up'),
1829
1952
  );
1830
- expect(replyCalls.length).toBeGreaterThanOrEqual(1);
1953
+ expect(deterministicNoticeCalls.length).toBe(0);
1831
1954
 
1832
1955
  // No approval prompt should have been sent to a guardian (none exists)
1833
1956
  expect(approvalSpy).not.toHaveBeenCalled();
@@ -1916,11 +2039,19 @@ describe('fail-closed guardian gate — unverified channel', () => {
1916
2039
  expect(body.accepted).toBe(true);
1917
2040
  expect(body.approval).toBe('decision_applied');
1918
2041
 
1919
- // The denial notice should have been sent
2042
+ // The deny decision should carry guardian setup context for the assistant.
2043
+ const submitCalls = (orchestrator.submitDecision as ReturnType<typeof mock>).mock.calls;
2044
+ expect(submitCalls.length).toBeGreaterThanOrEqual(1);
2045
+ const lastDecision = submitCalls[submitCalls.length - 1];
2046
+ expect(lastDecision[1]).toBe('deny');
2047
+ expect(typeof lastDecision[2]).toBe('string');
2048
+ expect((lastDecision[2] as string)).toContain('no guardian is configured');
2049
+
2050
+ // Interception should not emit a separate deterministic denial notice.
1920
2051
  const denialCalls = deliverSpy.mock.calls.filter(
1921
2052
  (call) => typeof call[1] === 'object' && (call[1] as { text?: string }).text?.includes('no guardian has been set up'),
1922
2053
  );
1923
- expect(denialCalls.length).toBeGreaterThanOrEqual(1);
2054
+ expect(denialCalls.length).toBe(0);
1924
2055
 
1925
2056
  deliverSpy.mockRestore();
1926
2057
  });
@@ -1932,7 +2063,6 @@ describe('fail-closed guardian gate — unverified channel', () => {
1932
2063
 
1933
2064
  describe('guardian-with-binding path regression', () => {
1934
2065
  beforeEach(() => {
1935
- process.env.CHANNEL_APPROVALS_ENABLED = 'true';
1936
2066
  });
1937
2067
 
1938
2068
  test('non-guardian with binding routes approval to guardian chat', async () => {
@@ -2004,6 +2134,53 @@ describe('guardian-with-binding path regression', () => {
2004
2134
  deliverSpy.mockRestore();
2005
2135
  approvalSpy.mockRestore();
2006
2136
  });
2137
+
2138
+ test('guardian callback for own pending run is handled by standard interception', async () => {
2139
+ createBinding({
2140
+ assistantId: 'self',
2141
+ channel: 'telegram',
2142
+ guardianExternalUserId: 'guardian-user-self-callback',
2143
+ guardianDeliveryChatId: 'chat-123',
2144
+ });
2145
+
2146
+ const orchestrator = makeMockOrchestrator();
2147
+ const deliverSpy = spyOn(gatewayClient, 'deliverChannelReply').mockResolvedValue(undefined);
2148
+
2149
+ // Establish the conversation mapping for chat-123.
2150
+ const initReq = makeInboundRequest({
2151
+ content: 'init',
2152
+ senderExternalUserId: 'guardian-user-self-callback',
2153
+ externalChatId: 'chat-123',
2154
+ });
2155
+ await handleChannelInbound(initReq, noopProcessMessage, 'token', orchestrator);
2156
+
2157
+ const db = getDb();
2158
+ const events = db.$client.prepare('SELECT conversation_id FROM channel_inbound_events').all() as Array<{ conversation_id: string }>;
2159
+ const conversationId = events[0]?.conversation_id;
2160
+ ensureConversation(conversationId!);
2161
+
2162
+ const run = createRun(conversationId!);
2163
+ setRunConfirmation(run.id, sampleConfirmation);
2164
+
2165
+ // Button callback includes a runId but there is no guardian approval request
2166
+ // because this is the guardian's own approval flow.
2167
+ const req = makeInboundRequest({
2168
+ content: '',
2169
+ senderExternalUserId: 'guardian-user-self-callback',
2170
+ externalChatId: 'chat-123',
2171
+ callbackData: `apr:${run.id}:approve_once`,
2172
+ });
2173
+
2174
+ const res = await handleChannelInbound(req, noopProcessMessage, 'token', orchestrator);
2175
+ const body = await res.json() as Record<string, unknown>;
2176
+
2177
+ expect(body.accepted).toBe(true);
2178
+ expect(body.approval).toBe('decision_applied');
2179
+ expect(orchestrator.submitDecision).toHaveBeenCalledWith(run.id, 'allow');
2180
+ expect(getPendingApprovalForRun(run.id)).toBeNull();
2181
+
2182
+ deliverSpy.mockRestore();
2183
+ });
2007
2184
  });
2008
2185
 
2009
2186
  // ═══════════════════════════════════════════════════════════════════════════
@@ -2012,7 +2189,6 @@ describe('guardian-with-binding path regression', () => {
2012
2189
 
2013
2190
  describe('guardian delivery failure → denial', () => {
2014
2191
  beforeEach(() => {
2015
- process.env.CHANNEL_APPROVALS_ENABLED = 'true';
2016
2192
  });
2017
2193
 
2018
2194
  test('delivery failure denies run and notifies requester', async () => {
@@ -2108,7 +2284,6 @@ describe('guardian delivery failure → denial', () => {
2108
2284
 
2109
2285
  describe('standard approval prompt delivery failure → auto-deny', () => {
2110
2286
  beforeEach(() => {
2111
- process.env.CHANNEL_APPROVALS_ENABLED = 'true';
2112
2287
  });
2113
2288
 
2114
2289
  test('standard prompt delivery failure auto-denies the run (fail-closed)', async () => {
@@ -2154,7 +2329,6 @@ describe('standard approval prompt delivery failure → auto-deny', () => {
2154
2329
 
2155
2330
  describe('guardian decision scoping — multiple pending approvals', () => {
2156
2331
  beforeEach(() => {
2157
- process.env.CHANNEL_APPROVALS_ENABLED = 'true';
2158
2332
  });
2159
2333
 
2160
2334
  test('callback for older run resolves to the correct approval request', async () => {
@@ -2241,7 +2415,6 @@ describe('guardian decision scoping — multiple pending approvals', () => {
2241
2415
 
2242
2416
  describe('ambiguous plain-text decision with multiple pending requests', () => {
2243
2417
  beforeEach(() => {
2244
- process.env.CHANNEL_APPROVALS_ENABLED = 'true';
2245
2418
  });
2246
2419
 
2247
2420
  test('does not apply plain-text decision to wrong run when multiple pending', async () => {
@@ -2328,7 +2501,6 @@ describe('ambiguous plain-text decision with multiple pending requests', () => {
2328
2501
 
2329
2502
  describe('expired guardian approval auto-denies via sweep', () => {
2330
2503
  beforeEach(() => {
2331
- process.env.CHANNEL_APPROVALS_ENABLED = 'true';
2332
2504
  });
2333
2505
 
2334
2506
  test('sweepExpiredGuardianApprovals auto-denies and notifies both parties', async () => {
@@ -2472,7 +2644,12 @@ describe('deliver-once idempotency guard', () => {
2472
2644
 
2473
2645
  describe('final reply idempotency — no duplicate delivery', () => {
2474
2646
  beforeEach(() => {
2475
- process.env.CHANNEL_APPROVALS_ENABLED = 'true';
2647
+ createBinding({
2648
+ assistantId: 'self',
2649
+ channel: 'telegram',
2650
+ guardianExternalUserId: 'telegram-user-default',
2651
+ guardianDeliveryChatId: 'chat-123',
2652
+ });
2476
2653
  });
2477
2654
 
2478
2655
  test('main poll wins: deliverChannelReply called exactly once when main poll delivers first', async () => {
@@ -2766,7 +2943,6 @@ describe('assistant-scoped guardian verification via handleChannelInbound', () =
2766
2943
  });
2767
2944
 
2768
2945
  test('actor role resolution uses threaded assistantId', async () => {
2769
- process.env.CHANNEL_APPROVALS_ENABLED = 'true';
2770
2946
 
2771
2947
  // Create guardian binding for asst-role-X
2772
2948
  createBinding({
@@ -2800,7 +2976,6 @@ describe('assistant-scoped guardian verification via handleChannelInbound', () =
2800
2976
  });
2801
2977
 
2802
2978
  test('same user is guardian for one assistant but not another', async () => {
2803
- process.env.CHANNEL_APPROVALS_ENABLED = 'true';
2804
2979
 
2805
2980
  // user-multi is guardian for asst-M1 but not asst-M2
2806
2981
  createBinding({
@@ -2856,16 +3031,87 @@ describe('assistant-scoped guardian verification via handleChannelInbound', () =
2856
3031
  deliverSpy.mockRestore();
2857
3032
  approvalSpy.mockRestore();
2858
3033
  });
3034
+
3035
+ test('non-self assistant inbound does not mutate assistant-agnostic external bindings', async () => {
3036
+ const db = getDb();
3037
+ const now = Date.now();
3038
+ ensureConversation('conv-existing-binding');
3039
+ db.insert(externalConversationBindings).values({
3040
+ conversationId: 'conv-existing-binding',
3041
+ sourceChannel: 'telegram',
3042
+ externalChatId: 'chat-123',
3043
+ externalUserId: 'existing-user',
3044
+ createdAt: now,
3045
+ updatedAt: now,
3046
+ lastInboundAt: now,
3047
+ }).run();
3048
+
3049
+ const req = makeInboundRequest({
3050
+ content: 'hello from non-self assistant',
3051
+ senderExternalUserId: 'incoming-user',
3052
+ });
3053
+
3054
+ const res = await handleChannelInbound(req, undefined, 'token', undefined, 'asst-non-self');
3055
+ expect(res.status).toBe(200);
3056
+
3057
+ const binding = db
3058
+ .select()
3059
+ .from(externalConversationBindings)
3060
+ .where(eq(externalConversationBindings.conversationId, 'conv-existing-binding'))
3061
+ .get();
3062
+ expect(binding).not.toBeNull();
3063
+ expect(binding!.externalUserId).toBe('existing-user');
3064
+ });
2859
3065
  });
2860
3066
 
2861
3067
  // ═══════════════════════════════════════════════════════════════════════════
2862
- // 27. Guardian enforcement decoupled from CHANNEL_APPROVALS_ENABLED
3068
+ // 27. Guardian enforcement behavior
2863
3069
  // ═══════════════════════════════════════════════════════════════════════════
2864
3070
 
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;
3071
+ describe('guardian enforcement behavior', () => {
3072
+ test('guardian sender on telegram uses approval-aware path', async () => {
3073
+
3074
+ // Default senderExternalUserId in makeInboundRequest is telegram-user-default.
3075
+ createBinding({
3076
+ assistantId: 'self',
3077
+ channel: 'telegram',
3078
+ guardianExternalUserId: 'telegram-user-default',
3079
+ guardianDeliveryChatId: 'chat-123',
3080
+ });
3081
+
3082
+ const processSpy = mock(async () => ({ messageId: 'msg-bg-guardian' }));
3083
+ const approvalSpy = spyOn(gatewayClient, 'deliverApprovalPrompt').mockResolvedValue(undefined);
3084
+ const deliverSpy = spyOn(gatewayClient, 'deliverChannelReply').mockResolvedValue(undefined);
3085
+
3086
+ const orchestrator = makeSensitiveOrchestrator({
3087
+ runId: 'run-guardian-flag-off-telegram',
3088
+ terminalStatus: 'completed',
3089
+ });
3090
+
3091
+ const req = makeInboundRequest({
3092
+ content: 'place a call',
3093
+ senderExternalUserId: 'telegram-user-default',
3094
+ sourceChannel: 'telegram',
3095
+ });
3096
+
3097
+ const res = await handleChannelInbound(req, processSpy, 'token', orchestrator);
3098
+ expect(res.status).toBe(200);
3099
+
3100
+ await new Promise((resolve) => setTimeout(resolve, 1200));
3101
+
3102
+ // Regression guard: this must use the orchestrator approval path, not
3103
+ // fire-and-forget processMessage, otherwise prompts can time out.
3104
+ expect(orchestrator.startRun).toHaveBeenCalled();
3105
+ expect(processSpy).not.toHaveBeenCalled();
2868
3106
 
3107
+ // Guardian self-approval prompt should be delivered to the requester's chat.
3108
+ expect(approvalSpy).toHaveBeenCalled();
3109
+
3110
+ approvalSpy.mockRestore();
3111
+ deliverSpy.mockRestore();
3112
+ });
3113
+
3114
+ test('non-guardian sensitive action routes approval to guardian', async () => {
2869
3115
  // Create a guardian binding — user-guardian is the guardian
2870
3116
  createBinding({
2871
3117
  assistantId: 'self',
@@ -2874,31 +3120,28 @@ describe('guardian enforcement independence from approval flag', () => {
2874
3120
  guardianDeliveryChatId: 'chat-guardian',
2875
3121
  });
2876
3122
 
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();
3123
+ const orchestrator = makeSensitiveOrchestrator({ runId: 'run-flag-off-guardian', terminalStatus: 'completed' });
2881
3124
  const deliverSpy = spyOn(gatewayClient, 'deliverChannelReply').mockResolvedValue(undefined);
3125
+ const approvalSpy = spyOn(gatewayClient, 'deliverApprovalPrompt').mockResolvedValue(undefined);
2882
3126
 
2883
3127
  const req = makeInboundRequest({
2884
- content: 'hello world',
3128
+ content: 'do something dangerous',
2885
3129
  senderExternalUserId: 'user-non-guardian',
2886
3130
  });
2887
3131
 
2888
3132
  const res = await handleChannelInbound(req, noopProcessMessage, 'token', orchestrator);
2889
3133
  expect(res.status).toBe(200);
3134
+ await new Promise((resolve) => setTimeout(resolve, 1200));
2890
3135
 
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);
3136
+ expect(approvalSpy).toHaveBeenCalled();
3137
+ const approvalArgs = approvalSpy.mock.calls[0];
3138
+ expect(approvalArgs[1]).toBe('chat-guardian');
2896
3139
 
2897
3140
  deliverSpy.mockRestore();
3141
+ approvalSpy.mockRestore();
2898
3142
  });
2899
3143
 
2900
3144
  test('missing senderExternalUserId with guardian binding fails closed', async () => {
2901
- process.env.CHANNEL_APPROVALS_ENABLED = 'true';
2902
3145
 
2903
3146
  // Create a guardian binding — guardian enforcement is active
2904
3147
  createBinding({
@@ -2928,20 +3171,21 @@ describe('guardian enforcement independence from approval flag', () => {
2928
3171
  // Wait for background processing
2929
3172
  await new Promise((resolve) => setTimeout(resolve, 1200));
2930
3173
 
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();
3174
+ // The unknown actor should be treated as unverified_channel and denied,
3175
+ // with context passed into the tool-denial response for assistant phrasing.
3176
+ const submitCalls = (orchestrator.submitDecision as ReturnType<typeof mock>).mock.calls;
3177
+ expect(submitCalls.length).toBeGreaterThanOrEqual(1);
3178
+ const lastDecision = submitCalls[submitCalls.length - 1];
3179
+ expect(lastDecision[1]).toBe('deny');
3180
+ expect(typeof lastDecision[2]).toBe('string');
3181
+ expect((lastDecision[2] as string)).toContain('identity could not be verified');
3182
+
3183
+ // No separate deterministic denial notice should be emitted here.
2936
3184
  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
- },
3185
+ (call) => typeof call[1] === 'object'
3186
+ && ((call[1] as { text?: string }).text ?? '').includes('identity could not be determined'),
2943
3187
  );
2944
- expect(denialCalls.length).toBeGreaterThanOrEqual(1);
3188
+ expect(denialCalls.length).toBe(0);
2945
3189
 
2946
3190
  // Auto-deny path should never prompt for approval
2947
3191
  expect(approvalSpy).not.toHaveBeenCalled();
@@ -2950,25 +3194,40 @@ describe('guardian enforcement independence from approval flag', () => {
2950
3194
  approvalSpy.mockRestore();
2951
3195
  });
2952
3196
 
2953
- test('missing senderExternalUserId without guardian binding uses default flow', async () => {
2954
- process.env.CHANNEL_APPROVALS_ENABLED = 'true';
3197
+ test('missing senderExternalUserId without guardian binding fails closed', async () => {
2955
3198
 
2956
- // No guardian binding exists default behavior should be preserved
2957
- const orchestrator = makeMockOrchestrator();
3199
+ // No guardian binding exists, but identity is missing treat sender as
3200
+ // unverified_channel and auto-deny sensitive actions.
3201
+ const orchestrator = makeSensitiveOrchestrator({ runId: 'run-failclosed-noid-nobinding', terminalStatus: 'failed' });
2958
3202
  const deliverSpy = spyOn(gatewayClient, 'deliverChannelReply').mockResolvedValue(undefined);
3203
+ const approvalSpy = spyOn(gatewayClient, 'deliverApprovalPrompt').mockResolvedValue(undefined);
2959
3204
 
2960
3205
  const req = makeInboundRequest({
2961
- content: 'hello world',
3206
+ content: 'do something dangerous',
2962
3207
  senderExternalUserId: undefined,
2963
3208
  });
2964
3209
 
2965
3210
  const res = await handleChannelInbound(req, noopProcessMessage, 'token', orchestrator);
2966
3211
  expect(res.status).toBe(200);
2967
3212
 
2968
- const body = await res.json() as Record<string, unknown>;
2969
- expect(body.accepted).toBe(true);
3213
+ await new Promise((resolve) => setTimeout(resolve, 1200));
3214
+
3215
+ const submitCalls = (orchestrator.submitDecision as ReturnType<typeof mock>).mock.calls;
3216
+ expect(submitCalls.length).toBeGreaterThanOrEqual(1);
3217
+ const lastDecision = submitCalls[submitCalls.length - 1];
3218
+ expect(lastDecision[1]).toBe('deny');
3219
+ expect(typeof lastDecision[2]).toBe('string');
3220
+ expect((lastDecision[2] as string)).toContain('identity could not be verified');
3221
+
3222
+ const denialCalls = deliverSpy.mock.calls.filter(
3223
+ (call) => typeof call[1] === 'object'
3224
+ && ((call[1] as { text?: string }).text ?? '').includes('identity could not be determined'),
3225
+ );
3226
+ expect(denialCalls.length).toBe(0);
3227
+ expect(approvalSpy).not.toHaveBeenCalled();
2970
3228
 
2971
3229
  deliverSpy.mockRestore();
3230
+ approvalSpy.mockRestore();
2972
3231
  });
2973
3232
  });
2974
3233
 
@@ -3119,7 +3378,6 @@ describe('handleChannelInbound gatewayOriginSecret integration', () => {
3119
3378
 
3120
3379
  describe('unknown actor identity — forceStrictSideEffects', () => {
3121
3380
  beforeEach(() => {
3122
- process.env.CHANNEL_APPROVALS_ENABLED = 'true';
3123
3381
  });
3124
3382
 
3125
3383
  test('unknown sender (no senderExternalUserId) with guardian binding gets forceStrictSideEffects', async () => {