@vellumai/assistant 0.4.3 → 0.4.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 (183) hide show
  1. package/.env.example +3 -0
  2. package/ARCHITECTURE.md +40 -3
  3. package/README.md +43 -35
  4. package/package.json +1 -1
  5. package/scripts/ipc/generate-swift.ts +1 -0
  6. package/src/__tests__/__snapshots__/ipc-snapshot.test.ts.snap +58 -120
  7. package/src/__tests__/actor-token-service.test.ts +1099 -0
  8. package/src/__tests__/agent-loop.test.ts +51 -0
  9. package/src/__tests__/approval-routes-http.test.ts +2 -0
  10. package/src/__tests__/assistant-events-sse-hardening.test.ts +7 -5
  11. package/src/__tests__/assistant-id-boundary-guard.test.ts +125 -0
  12. package/src/__tests__/call-controller.test.ts +49 -0
  13. package/src/__tests__/call-pointer-message-composer.test.ts +171 -0
  14. package/src/__tests__/call-pointer-messages.test.ts +93 -3
  15. package/src/__tests__/call-pointer-no-hardcoded-copy.guard.test.ts +42 -0
  16. package/src/__tests__/callback-handoff-copy.test.ts +186 -0
  17. package/src/__tests__/channel-approval-routes.test.ts +133 -12
  18. package/src/__tests__/channel-guardian.test.ts +0 -87
  19. package/src/__tests__/channel-readiness-service.test.ts +10 -16
  20. package/src/__tests__/checker.test.ts +33 -12
  21. package/src/__tests__/config-schema.test.ts +4 -0
  22. package/src/__tests__/confirmation-request-guardian-bridge.test.ts +410 -0
  23. package/src/__tests__/conversation-routes-guardian-reply.test.ts +256 -0
  24. package/src/__tests__/conversation-routes.test.ts +12 -3
  25. package/src/__tests__/credential-security-invariants.test.ts +1 -1
  26. package/src/__tests__/daemon-server-session-init.test.ts +4 -0
  27. package/src/__tests__/guardian-actions-endpoint.test.ts +19 -14
  28. package/src/__tests__/guardian-dispatch.test.ts +8 -0
  29. package/src/__tests__/guardian-outbound-http.test.ts +4 -4
  30. package/src/__tests__/guardian-question-mode.test.ts +200 -0
  31. package/src/__tests__/guardian-routing-invariants.test.ts +178 -0
  32. package/src/__tests__/guardian-routing-state.test.ts +525 -0
  33. package/src/__tests__/handle-user-message-secret-resume.test.ts +2 -0
  34. package/src/__tests__/handlers-telegram-config.test.ts +0 -83
  35. package/src/__tests__/handlers-user-message-approval-consumption.test.ts +55 -0
  36. package/src/__tests__/headless-browser-navigate.test.ts +2 -0
  37. package/src/__tests__/ipc-snapshot.test.ts +18 -51
  38. package/src/__tests__/non-member-access-request.test.ts +131 -8
  39. package/src/__tests__/notification-decision-fallback.test.ts +129 -4
  40. package/src/__tests__/notification-decision-strategy.test.ts +62 -2
  41. package/src/__tests__/notification-guardian-path.test.ts +3 -0
  42. package/src/__tests__/recording-intent-handler.test.ts +1 -0
  43. package/src/__tests__/relay-server.test.ts +841 -39
  44. package/src/__tests__/send-endpoint-busy.test.ts +5 -0
  45. package/src/__tests__/session-agent-loop.test.ts +1 -0
  46. package/src/__tests__/session-confirmation-signals.test.ts +523 -0
  47. package/src/__tests__/session-init.benchmark.test.ts +0 -1
  48. package/src/__tests__/session-surfaces-task-progress.test.ts +1 -1
  49. package/src/__tests__/session-tool-setup-app-refresh.test.ts +81 -2
  50. package/src/__tests__/session-tool-setup-memory-scope.test.ts +1 -1
  51. package/src/__tests__/session-tool-setup-side-effect-flag.test.ts +1 -1
  52. package/src/__tests__/tool-executor.test.ts +21 -2
  53. package/src/__tests__/tool-grant-request-escalation.test.ts +333 -27
  54. package/src/__tests__/trusted-contact-approval-notifier.test.ts +678 -0
  55. package/src/__tests__/trusted-contact-inline-approval-integration.test.ts +1064 -0
  56. package/src/__tests__/twilio-config.test.ts +2 -13
  57. package/src/agent/loop.ts +1 -1
  58. package/src/approvals/guardian-decision-primitive.ts +10 -2
  59. package/src/approvals/guardian-request-resolvers.ts +128 -9
  60. package/src/calls/call-constants.ts +21 -0
  61. package/src/calls/call-controller.ts +9 -2
  62. package/src/calls/call-domain.ts +28 -7
  63. package/src/calls/call-pointer-message-composer.ts +154 -0
  64. package/src/calls/call-pointer-messages.ts +106 -27
  65. package/src/calls/guardian-dispatch.ts +4 -2
  66. package/src/calls/relay-server.ts +424 -12
  67. package/src/calls/twilio-config.ts +4 -11
  68. package/src/calls/twilio-routes.ts +1 -1
  69. package/src/calls/types.ts +3 -1
  70. package/src/cli.ts +5 -4
  71. package/src/config/bundled-skills/agentmail/SKILL.md +4 -0
  72. package/src/config/bundled-skills/app-builder/SKILL.md +146 -10
  73. package/src/config/bundled-skills/app-builder/TOOLS.json +1 -1
  74. package/src/config/bundled-skills/email-setup/SKILL.md +1 -1
  75. package/src/config/bundled-skills/google-oauth-setup/SKILL.md +105 -81
  76. package/src/config/bundled-skills/messaging/SKILL.md +61 -12
  77. package/src/config/bundled-skills/messaging/TOOLS.json +58 -0
  78. package/src/config/bundled-skills/messaging/tools/gmail-sender-digest.ts +6 -1
  79. package/src/config/bundled-skills/messaging/tools/messaging-archive-by-sender.ts +35 -0
  80. package/src/config/bundled-skills/messaging/tools/messaging-sender-digest.ts +52 -0
  81. package/src/config/bundled-skills/phone-calls/SKILL.md +30 -39
  82. package/src/config/bundled-skills/twitter/SKILL.md +3 -3
  83. package/src/config/bundled-skills/vercel-token-setup/SKILL.md +1 -0
  84. package/src/config/calls-schema.ts +24 -0
  85. package/src/config/env.ts +22 -0
  86. package/src/config/feature-flag-registry.json +8 -0
  87. package/src/config/schema.ts +2 -2
  88. package/src/config/skills.ts +11 -0
  89. package/src/config/system-prompt.ts +11 -1
  90. package/src/config/templates/SOUL.md +2 -0
  91. package/src/config/vellum-skills/sms-setup/SKILL.md +71 -82
  92. package/src/config/vellum-skills/trusted-contacts/SKILL.md +10 -9
  93. package/src/config/vellum-skills/twilio-setup/SKILL.md +88 -73
  94. package/src/daemon/call-pointer-generators.ts +59 -0
  95. package/src/daemon/computer-use-session.ts +2 -5
  96. package/src/daemon/handlers/apps.ts +76 -20
  97. package/src/daemon/handlers/config-channels.ts +5 -55
  98. package/src/daemon/handlers/config-inbox.ts +9 -3
  99. package/src/daemon/handlers/config-ingress.ts +28 -3
  100. package/src/daemon/handlers/config-telegram.ts +12 -0
  101. package/src/daemon/handlers/config.ts +2 -6
  102. package/src/daemon/handlers/pairing.ts +2 -0
  103. package/src/daemon/handlers/sessions.ts +48 -3
  104. package/src/daemon/handlers/shared.ts +17 -2
  105. package/src/daemon/ipc-contract/integrations.ts +1 -99
  106. package/src/daemon/ipc-contract/messages.ts +47 -1
  107. package/src/daemon/ipc-contract/notifications.ts +11 -0
  108. package/src/daemon/ipc-contract-inventory.json +2 -4
  109. package/src/daemon/lifecycle.ts +17 -0
  110. package/src/daemon/server.ts +14 -1
  111. package/src/daemon/session-agent-loop-handlers.ts +20 -0
  112. package/src/daemon/session-agent-loop.ts +22 -11
  113. package/src/daemon/session-lifecycle.ts +1 -1
  114. package/src/daemon/session-process.ts +11 -1
  115. package/src/daemon/session-runtime-assembly.ts +3 -0
  116. package/src/daemon/session-surfaces.ts +3 -2
  117. package/src/daemon/session.ts +88 -1
  118. package/src/daemon/tool-side-effects.ts +22 -0
  119. package/src/home-base/prebuilt/brain-graph.html +1483 -0
  120. package/src/home-base/prebuilt/index.html +40 -0
  121. package/src/inbound/platform-callback-registration.ts +157 -0
  122. package/src/memory/canonical-guardian-store.ts +1 -1
  123. package/src/memory/db-init.ts +4 -0
  124. package/src/memory/migrations/038-actor-token-records.ts +39 -0
  125. package/src/memory/migrations/index.ts +1 -0
  126. package/src/memory/schema.ts +16 -0
  127. package/src/messaging/provider-types.ts +24 -0
  128. package/src/messaging/provider.ts +7 -0
  129. package/src/messaging/providers/gmail/adapter.ts +127 -0
  130. package/src/messaging/providers/sms/adapter.ts +40 -37
  131. package/src/notifications/adapters/macos.ts +45 -2
  132. package/src/notifications/broadcaster.ts +16 -0
  133. package/src/notifications/copy-composer.ts +39 -1
  134. package/src/notifications/decision-engine.ts +22 -9
  135. package/src/notifications/destination-resolver.ts +16 -2
  136. package/src/notifications/emit-signal.ts +16 -8
  137. package/src/notifications/guardian-question-mode.ts +419 -0
  138. package/src/notifications/signal.ts +14 -3
  139. package/src/permissions/checker.ts +13 -1
  140. package/src/permissions/prompter.ts +14 -0
  141. package/src/providers/anthropic/client.ts +20 -0
  142. package/src/providers/provider-send-message.ts +15 -3
  143. package/src/runtime/access-request-helper.ts +71 -1
  144. package/src/runtime/actor-token-service.ts +234 -0
  145. package/src/runtime/actor-token-store.ts +236 -0
  146. package/src/runtime/channel-approvals.ts +5 -3
  147. package/src/runtime/channel-readiness-service.ts +23 -64
  148. package/src/runtime/channel-readiness-types.ts +3 -4
  149. package/src/runtime/channel-retry-sweep.ts +4 -1
  150. package/src/runtime/confirmation-request-guardian-bridge.ts +197 -0
  151. package/src/runtime/guardian-action-followup-executor.ts +1 -1
  152. package/src/runtime/guardian-context-resolver.ts +82 -0
  153. package/src/runtime/guardian-outbound-actions.ts +0 -3
  154. package/src/runtime/guardian-reply-router.ts +67 -30
  155. package/src/runtime/guardian-vellum-migration.ts +57 -0
  156. package/src/runtime/http-server.ts +65 -12
  157. package/src/runtime/http-types.ts +13 -0
  158. package/src/runtime/invite-redemption-service.ts +8 -0
  159. package/src/runtime/local-actor-identity.ts +76 -0
  160. package/src/runtime/middleware/actor-token.ts +271 -0
  161. package/src/runtime/routes/approval-routes.ts +82 -7
  162. package/src/runtime/routes/brain-graph-routes.ts +222 -0
  163. package/src/runtime/routes/channel-readiness-routes.ts +71 -0
  164. package/src/runtime/routes/conversation-routes.ts +140 -52
  165. package/src/runtime/routes/events-routes.ts +20 -5
  166. package/src/runtime/routes/guardian-action-routes.ts +45 -3
  167. package/src/runtime/routes/guardian-approval-interception.ts +29 -0
  168. package/src/runtime/routes/guardian-bootstrap-routes.ts +145 -0
  169. package/src/runtime/routes/inbound-message-handler.ts +143 -2
  170. package/src/runtime/routes/integration-routes.ts +7 -15
  171. package/src/runtime/routes/pairing-routes.ts +163 -0
  172. package/src/runtime/routes/twilio-routes.ts +934 -0
  173. package/src/runtime/tool-grant-request-helper.ts +3 -1
  174. package/src/security/oauth2.ts +27 -2
  175. package/src/security/token-manager.ts +46 -10
  176. package/src/tools/browser/browser-execution.ts +4 -3
  177. package/src/tools/browser/browser-handoff.ts +10 -18
  178. package/src/tools/browser/browser-manager.ts +80 -25
  179. package/src/tools/browser/browser-screencast.ts +35 -119
  180. package/src/tools/permission-checker.ts +15 -4
  181. package/src/tools/tool-approval-handler.ts +242 -18
  182. package/src/__tests__/handlers-twilio-config.test.ts +0 -1928
  183. package/src/daemon/handlers/config-twilio.ts +0 -1082
@@ -0,0 +1,678 @@
1
+ /**
2
+ * Tests for the trusted-contact pending-approval requester notification.
3
+ *
4
+ * Verifies that:
5
+ * 1. Trusted contacts receive a one-shot "waiting for guardian approval" message
6
+ * 2. The message mentions the guardian by name when available
7
+ * 3. Messages are deduplicated by requestId (no repeated spam)
8
+ * 4. Guardian and unknown actors do NOT receive the notification
9
+ * 5. Delivery failures allow retry on next poll
10
+ */
11
+
12
+ import { mkdtempSync, rmSync } from 'node:fs';
13
+ import { tmpdir } from 'node:os';
14
+ import { join } from 'node:path';
15
+
16
+ import { afterAll, beforeEach, describe, expect, mock, test } from 'bun:test';
17
+
18
+ const testDir = mkdtempSync(join(tmpdir(), 'tc-approval-notifier-test-'));
19
+
20
+ // ── Platform mock ──
21
+ mock.module('../util/platform.js', () => ({
22
+ getDataDir: () => testDir,
23
+ isMacOS: () => process.platform === 'darwin',
24
+ isLinux: () => process.platform === 'linux',
25
+ isWindows: () => process.platform === 'win32',
26
+ getSocketPath: () => join(testDir, 'test.sock'),
27
+ getPidPath: () => join(testDir, 'test.pid'),
28
+ getDbPath: () => join(testDir, 'test.db'),
29
+ getLogPath: () => join(testDir, 'test.log'),
30
+ readHttpToken: () => 'test-token',
31
+ ensureDataDir: () => {},
32
+ migrateToDataLayout: () => {},
33
+ migrateToWorkspaceLayout: () => {},
34
+ normalizeAssistantId: (id: string) => id === 'self' || id === '' ? 'self' : id,
35
+ }));
36
+
37
+ // ── Logger mock ──
38
+ mock.module('../util/logger.js', () => ({
39
+ getLogger: () =>
40
+ new Proxy({} as Record<string, unknown>, {
41
+ get: () => () => {},
42
+ }),
43
+ isDebug: () => false,
44
+ truncateForLog: (value: string) => value,
45
+ }));
46
+
47
+ // ── Notification signal mock ──
48
+ mock.module('../notifications/emit-signal.js', () => ({
49
+ emitNotificationSignal: async () => ({
50
+ signalId: 'test-signal',
51
+ deduplicated: false,
52
+ dispatched: true,
53
+ reason: 'ok',
54
+ deliveryResults: [],
55
+ }),
56
+ registerBroadcastFn: () => {},
57
+ }));
58
+
59
+ // ── Gateway client mock ──
60
+ // Track all deliverChannelReply calls for assertions
61
+ const deliveredReplies: Array<{
62
+ url: string;
63
+ payload: Record<string, unknown>;
64
+ bearerToken?: string;
65
+ }> = [];
66
+ let deliverShouldFail = false;
67
+
68
+ mock.module('../runtime/gateway-client.js', () => ({
69
+ deliverChannelReply: async (
70
+ url: string,
71
+ payload: Record<string, unknown>,
72
+ bearerToken?: string,
73
+ ) => {
74
+ if (deliverShouldFail) {
75
+ throw new Error('Delivery failed');
76
+ }
77
+ deliveredReplies.push({ url, payload, bearerToken });
78
+ return { ok: true };
79
+ },
80
+ }));
81
+
82
+ // ── Guardian binding mock ──
83
+ let mockGuardianBinding: Record<string, unknown> | null = null;
84
+
85
+ mock.module('../runtime/channel-guardian-service.js', () => ({
86
+ getGuardianBinding: () => mockGuardianBinding,
87
+ // Re-export stubs for other functions to prevent import errors
88
+ bindSessionIdentity: () => {},
89
+ createOutboundSession: () => ({}),
90
+ findActiveSession: () => null,
91
+ getGuardianBindingForChannel: () => null,
92
+ getPendingChallenge: () => null,
93
+ isGuardian: () => false,
94
+ resolveBootstrapToken: () => null,
95
+ updateSessionDelivery: () => {},
96
+ updateSessionStatus: () => {},
97
+ validateAndConsumeChallenge: () => ({ success: false, reason: 'no_challenge' }),
98
+ }));
99
+
100
+ // ── Pending interactions mock ──
101
+ let mockPendingApprovals: Array<{
102
+ requestId: string;
103
+ toolName: string;
104
+ input: Record<string, unknown>;
105
+ riskLevel: string;
106
+ }> = [];
107
+
108
+ mock.module('../runtime/channel-approvals.js', () => ({
109
+ getApprovalInfoByConversation: () => mockPendingApprovals,
110
+ getChannelApprovalPrompt: () => null,
111
+ buildApprovalUIMetadata: () => ({}),
112
+ }));
113
+
114
+ // ── Config env mock ──
115
+ mock.module('../config/env.js', () => ({
116
+ getGatewayInternalBaseUrl: () => 'http://localhost:3000',
117
+ }));
118
+
119
+ // Import module under test AFTER mocks are set up
120
+ import type { ChannelId } from '../channels/types.js';
121
+ import type { GuardianContext } from '../runtime/guardian-context-resolver.js';
122
+
123
+ // We need to test the private functions by importing the module.
124
+ // Since startTrustedContactApprovalNotifier is not exported, we test it
125
+ // indirectly through handleChannelInbound via processChannelMessageInBackground.
126
+ //
127
+ // However, to test the notifier function in isolation, we extract the
128
+ // logic into a helper that we can call directly.
129
+
130
+ // For unit testing, we replicate the core logic here to verify behavior.
131
+ // The integration is tested by verifying deliverChannelReply calls.
132
+
133
+ // ---------------------------------------------------------------------------
134
+ // Helpers
135
+ // ---------------------------------------------------------------------------
136
+
137
+ /**
138
+ * Simulates the core logic of the trusted-contact approval notifier.
139
+ * This mirrors the implementation in inbound-message-handler.ts.
140
+ *
141
+ * Uses a Map<requestId, conversationId> for deduplication so that cleanup
142
+ * is scoped to the owning conversation — concurrent pollers for different
143
+ * conversations will not evict each other's entries.
144
+ */
145
+ async function simulateNotifierPoll(params: {
146
+ conversationId: string;
147
+ sourceChannel: ChannelId;
148
+ externalChatId: string;
149
+ guardianTrustClass: GuardianContext['trustClass'];
150
+ guardianExternalUserId?: string;
151
+ replyCallbackUrl: string;
152
+ bearerToken?: string;
153
+ assistantId?: string;
154
+ notifiedRequestIds: Map<string, string>;
155
+ }): Promise<boolean> {
156
+ const {
157
+ conversationId,
158
+ guardianTrustClass,
159
+ guardianExternalUserId,
160
+ notifiedRequestIds,
161
+ } = params;
162
+
163
+ // Gate check: only trusted contacts with guardian route
164
+ if (guardianTrustClass !== 'trusted_contact' || !guardianExternalUserId) {
165
+ return false;
166
+ }
167
+
168
+ const { getApprovalInfoByConversation } = await import('../runtime/channel-approvals.js');
169
+ const { deliverChannelReply } = await import('../runtime/gateway-client.js');
170
+ const { getGuardianBinding } = await import('../runtime/channel-guardian-service.js');
171
+
172
+ const pending = getApprovalInfoByConversation(params.conversationId);
173
+ const info = pending[0];
174
+
175
+ // Clean up resolved requests — only for THIS conversation's entries.
176
+ const currentPendingIds = new Set(pending.map(p => p.requestId));
177
+ for (const [rid, cid] of notifiedRequestIds) {
178
+ if (cid === conversationId && !currentPendingIds.has(rid)) {
179
+ notifiedRequestIds.delete(rid);
180
+ }
181
+ }
182
+
183
+ if (!info || notifiedRequestIds.has(info.requestId)) {
184
+ return false;
185
+ }
186
+
187
+ notifiedRequestIds.set(info.requestId, conversationId);
188
+
189
+ // Resolve guardian name
190
+ let guardianName: string | undefined;
191
+ const binding = getGuardianBinding(params.assistantId ?? 'self', params.sourceChannel);
192
+ if (binding?.metadataJson) {
193
+ try {
194
+ const parsed = JSON.parse(binding.metadataJson as string) as Record<string, unknown>;
195
+ if (typeof parsed.displayName === 'string' && parsed.displayName.trim().length > 0) {
196
+ guardianName = parsed.displayName.trim();
197
+ } else if (typeof parsed.username === 'string' && parsed.username.trim().length > 0) {
198
+ guardianName = `@${parsed.username.trim()}`;
199
+ }
200
+ } catch {
201
+ // ignore
202
+ }
203
+ }
204
+
205
+ const waitingText = guardianName
206
+ ? `Waiting for ${guardianName}'s approval...`
207
+ : 'Waiting for your guardian\'s approval...';
208
+
209
+ try {
210
+ await deliverChannelReply(params.replyCallbackUrl, {
211
+ chatId: params.externalChatId,
212
+ text: waitingText,
213
+ assistantId: params.assistantId ?? 'self',
214
+ }, params.bearerToken);
215
+ return true;
216
+ } catch {
217
+ notifiedRequestIds.delete(info.requestId);
218
+ return false;
219
+ }
220
+ }
221
+
222
+ // ===========================================================================
223
+ // TESTS
224
+ // ===========================================================================
225
+
226
+ describe('trusted-contact pending-approval notifier', () => {
227
+ beforeEach(() => {
228
+ deliveredReplies.length = 0;
229
+ deliverShouldFail = false;
230
+ mockPendingApprovals = [];
231
+ mockGuardianBinding = null;
232
+ });
233
+
234
+ afterAll(() => {
235
+ try {
236
+ rmSync(testDir, { recursive: true });
237
+ } catch {
238
+ /* best effort */
239
+ }
240
+ });
241
+
242
+ test('sends waiting message to trusted contact when pending approval exists', async () => {
243
+ mockPendingApprovals = [{
244
+ requestId: 'req-1',
245
+ toolName: 'bash',
246
+ input: { command: 'ls' },
247
+ riskLevel: 'medium',
248
+ }];
249
+
250
+ mockGuardianBinding = {
251
+ id: 'binding-1',
252
+ metadataJson: JSON.stringify({ displayName: 'Mom' }),
253
+ };
254
+
255
+ const notified = new Map<string, string>();
256
+ const sent = await simulateNotifierPoll({
257
+ conversationId: 'conv-1',
258
+ sourceChannel: 'telegram',
259
+ externalChatId: 'chat-123',
260
+ guardianTrustClass: 'trusted_contact',
261
+ guardianExternalUserId: 'guardian-1',
262
+ replyCallbackUrl: 'http://localhost:3000/deliver/telegram',
263
+ bearerToken: 'test-token',
264
+ assistantId: 'self',
265
+ notifiedRequestIds: notified,
266
+ });
267
+
268
+ expect(sent).toBe(true);
269
+ expect(deliveredReplies).toHaveLength(1);
270
+ expect(deliveredReplies[0].payload.text).toBe("Waiting for Mom's approval...");
271
+ expect(deliveredReplies[0].payload.chatId).toBe('chat-123');
272
+ expect(notified.has('req-1')).toBe(true);
273
+ });
274
+
275
+ test('uses username with @ prefix when display name is not available', async () => {
276
+ mockPendingApprovals = [{
277
+ requestId: 'req-2',
278
+ toolName: 'bash',
279
+ input: {},
280
+ riskLevel: 'medium',
281
+ }];
282
+
283
+ mockGuardianBinding = {
284
+ id: 'binding-1',
285
+ metadataJson: JSON.stringify({ username: 'guardian_user' }),
286
+ };
287
+
288
+ const notified = new Map<string, string>();
289
+ await simulateNotifierPoll({
290
+ conversationId: 'conv-1',
291
+ sourceChannel: 'telegram',
292
+ externalChatId: 'chat-123',
293
+ guardianTrustClass: 'trusted_contact',
294
+ guardianExternalUserId: 'guardian-1',
295
+ replyCallbackUrl: 'http://localhost:3000/deliver/telegram',
296
+ notifiedRequestIds: notified,
297
+ });
298
+
299
+ expect(deliveredReplies).toHaveLength(1);
300
+ expect(deliveredReplies[0].payload.text).toBe("Waiting for @guardian_user's approval...");
301
+ });
302
+
303
+ test('uses generic phrasing when no guardian name is available', async () => {
304
+ mockPendingApprovals = [{
305
+ requestId: 'req-3',
306
+ toolName: 'bash',
307
+ input: {},
308
+ riskLevel: 'medium',
309
+ }];
310
+
311
+ // No binding metadata
312
+ mockGuardianBinding = {
313
+ id: 'binding-1',
314
+ metadataJson: null,
315
+ };
316
+
317
+ const notified = new Map<string, string>();
318
+ await simulateNotifierPoll({
319
+ conversationId: 'conv-1',
320
+ sourceChannel: 'telegram',
321
+ externalChatId: 'chat-123',
322
+ guardianTrustClass: 'trusted_contact',
323
+ guardianExternalUserId: 'guardian-1',
324
+ replyCallbackUrl: 'http://localhost:3000/deliver/telegram',
325
+ notifiedRequestIds: notified,
326
+ });
327
+
328
+ expect(deliveredReplies).toHaveLength(1);
329
+ expect(deliveredReplies[0].payload.text).toBe("Waiting for your guardian's approval...");
330
+ });
331
+
332
+ test('uses generic phrasing when no guardian binding exists', async () => {
333
+ mockPendingApprovals = [{
334
+ requestId: 'req-4',
335
+ toolName: 'bash',
336
+ input: {},
337
+ riskLevel: 'medium',
338
+ }];
339
+
340
+ mockGuardianBinding = null;
341
+
342
+ const notified = new Map<string, string>();
343
+ await simulateNotifierPoll({
344
+ conversationId: 'conv-1',
345
+ sourceChannel: 'telegram',
346
+ externalChatId: 'chat-123',
347
+ guardianTrustClass: 'trusted_contact',
348
+ guardianExternalUserId: 'guardian-1',
349
+ replyCallbackUrl: 'http://localhost:3000/deliver/telegram',
350
+ notifiedRequestIds: notified,
351
+ });
352
+
353
+ expect(deliveredReplies).toHaveLength(1);
354
+ expect(deliveredReplies[0].payload.text).toBe("Waiting for your guardian's approval...");
355
+ });
356
+
357
+ test('deduplicates by requestId — does not send twice for same request', async () => {
358
+ mockPendingApprovals = [{
359
+ requestId: 'req-5',
360
+ toolName: 'bash',
361
+ input: {},
362
+ riskLevel: 'medium',
363
+ }];
364
+
365
+ mockGuardianBinding = {
366
+ id: 'binding-1',
367
+ metadataJson: JSON.stringify({ displayName: 'Guardian' }),
368
+ };
369
+
370
+ const notified = new Map<string, string>();
371
+ const baseParams = {
372
+ conversationId: 'conv-1',
373
+ sourceChannel: 'telegram' as ChannelId,
374
+ externalChatId: 'chat-123',
375
+ guardianTrustClass: 'trusted_contact' as const,
376
+ guardianExternalUserId: 'guardian-1',
377
+ replyCallbackUrl: 'http://localhost:3000/deliver/telegram',
378
+ notifiedRequestIds: notified,
379
+ };
380
+
381
+ // First poll: should send
382
+ const sent1 = await simulateNotifierPoll(baseParams);
383
+ expect(sent1).toBe(true);
384
+ expect(deliveredReplies).toHaveLength(1);
385
+
386
+ // Second poll: same requestId, should NOT send
387
+ const sent2 = await simulateNotifierPoll(baseParams);
388
+ expect(sent2).toBe(false);
389
+ expect(deliveredReplies).toHaveLength(1); // Still just 1
390
+ });
391
+
392
+ test('sends separate messages for different requestIds', async () => {
393
+ mockGuardianBinding = {
394
+ id: 'binding-1',
395
+ metadataJson: JSON.stringify({ displayName: 'Guardian' }),
396
+ };
397
+
398
+ const notified = new Map<string, string>();
399
+ const baseParams = {
400
+ conversationId: 'conv-1',
401
+ sourceChannel: 'telegram' as ChannelId,
402
+ externalChatId: 'chat-123',
403
+ guardianTrustClass: 'trusted_contact' as const,
404
+ guardianExternalUserId: 'guardian-1',
405
+ replyCallbackUrl: 'http://localhost:3000/deliver/telegram',
406
+ notifiedRequestIds: notified,
407
+ };
408
+
409
+ // First request
410
+ mockPendingApprovals = [{
411
+ requestId: 'req-A',
412
+ toolName: 'bash',
413
+ input: {},
414
+ riskLevel: 'medium',
415
+ }];
416
+ await simulateNotifierPoll(baseParams);
417
+ expect(deliveredReplies).toHaveLength(1);
418
+
419
+ // Second request (different requestId)
420
+ mockPendingApprovals = [{
421
+ requestId: 'req-B',
422
+ toolName: 'read_file',
423
+ input: {},
424
+ riskLevel: 'low',
425
+ }];
426
+ await simulateNotifierPoll(baseParams);
427
+ expect(deliveredReplies).toHaveLength(2);
428
+ });
429
+
430
+ test('concurrent pollers for different conversations do not evict each other', async () => {
431
+ mockGuardianBinding = {
432
+ id: 'binding-1',
433
+ metadataJson: JSON.stringify({ displayName: 'Guardian' }),
434
+ };
435
+
436
+ // Shared dedupe map simulating the module-level global
437
+ const notified = new Map<string, string>();
438
+
439
+ // Conversation A gets a pending approval and notifies
440
+ mockPendingApprovals = [{
441
+ requestId: 'req-convA',
442
+ toolName: 'bash',
443
+ input: {},
444
+ riskLevel: 'medium',
445
+ }];
446
+ const sentA = await simulateNotifierPoll({
447
+ conversationId: 'conv-A',
448
+ sourceChannel: 'telegram',
449
+ externalChatId: 'chat-A',
450
+ guardianTrustClass: 'trusted_contact',
451
+ guardianExternalUserId: 'guardian-1',
452
+ replyCallbackUrl: 'http://localhost:3000/deliver/telegram',
453
+ notifiedRequestIds: notified,
454
+ });
455
+ expect(sentA).toBe(true);
456
+ expect(deliveredReplies).toHaveLength(1);
457
+
458
+ // Conversation B polls with no pending approvals — its cleanup must
459
+ // NOT evict conv-A's entry from the shared map.
460
+ mockPendingApprovals = [];
461
+ await simulateNotifierPoll({
462
+ conversationId: 'conv-B',
463
+ sourceChannel: 'telegram',
464
+ externalChatId: 'chat-B',
465
+ guardianTrustClass: 'trusted_contact',
466
+ guardianExternalUserId: 'guardian-1',
467
+ replyCallbackUrl: 'http://localhost:3000/deliver/telegram',
468
+ notifiedRequestIds: notified,
469
+ });
470
+
471
+ // req-convA should still be in the notified map (not evicted by conv-B)
472
+ expect(notified.has('req-convA')).toBe(true);
473
+
474
+ // Re-poll conversation A with the same pending approval — should NOT
475
+ // re-send because the entry was preserved.
476
+ mockPendingApprovals = [{
477
+ requestId: 'req-convA',
478
+ toolName: 'bash',
479
+ input: {},
480
+ riskLevel: 'medium',
481
+ }];
482
+ const sentA2 = await simulateNotifierPoll({
483
+ conversationId: 'conv-A',
484
+ sourceChannel: 'telegram',
485
+ externalChatId: 'chat-A',
486
+ guardianTrustClass: 'trusted_contact',
487
+ guardianExternalUserId: 'guardian-1',
488
+ replyCallbackUrl: 'http://localhost:3000/deliver/telegram',
489
+ notifiedRequestIds: notified,
490
+ });
491
+ expect(sentA2).toBe(false);
492
+ expect(deliveredReplies).toHaveLength(1); // Still just 1 — no duplicate
493
+ });
494
+
495
+ test('does not activate for guardian actors', async () => {
496
+ mockPendingApprovals = [{
497
+ requestId: 'req-6',
498
+ toolName: 'bash',
499
+ input: {},
500
+ riskLevel: 'medium',
501
+ }];
502
+
503
+ const notified = new Map<string, string>();
504
+ const sent = await simulateNotifierPoll({
505
+ conversationId: 'conv-1',
506
+ sourceChannel: 'telegram',
507
+ externalChatId: 'chat-123',
508
+ guardianTrustClass: 'guardian',
509
+ guardianExternalUserId: 'guardian-1',
510
+ replyCallbackUrl: 'http://localhost:3000/deliver/telegram',
511
+ notifiedRequestIds: notified,
512
+ });
513
+
514
+ expect(sent).toBe(false);
515
+ expect(deliveredReplies).toHaveLength(0);
516
+ });
517
+
518
+ test('does not activate for unknown actors', async () => {
519
+ mockPendingApprovals = [{
520
+ requestId: 'req-7',
521
+ toolName: 'bash',
522
+ input: {},
523
+ riskLevel: 'medium',
524
+ }];
525
+
526
+ const notified = new Map<string, string>();
527
+ const sent = await simulateNotifierPoll({
528
+ conversationId: 'conv-1',
529
+ sourceChannel: 'telegram',
530
+ externalChatId: 'chat-123',
531
+ guardianTrustClass: 'unknown',
532
+ replyCallbackUrl: 'http://localhost:3000/deliver/telegram',
533
+ notifiedRequestIds: notified,
534
+ });
535
+
536
+ expect(sent).toBe(false);
537
+ expect(deliveredReplies).toHaveLength(0);
538
+ });
539
+
540
+ test('does not activate for trusted contact without guardian identity', async () => {
541
+ mockPendingApprovals = [{
542
+ requestId: 'req-8',
543
+ toolName: 'bash',
544
+ input: {},
545
+ riskLevel: 'medium',
546
+ }];
547
+
548
+ const notified = new Map<string, string>();
549
+ const sent = await simulateNotifierPoll({
550
+ conversationId: 'conv-1',
551
+ sourceChannel: 'telegram',
552
+ externalChatId: 'chat-123',
553
+ guardianTrustClass: 'trusted_contact',
554
+ guardianExternalUserId: undefined,
555
+ replyCallbackUrl: 'http://localhost:3000/deliver/telegram',
556
+ notifiedRequestIds: notified,
557
+ });
558
+
559
+ expect(sent).toBe(false);
560
+ expect(deliveredReplies).toHaveLength(0);
561
+ });
562
+
563
+ test('retries delivery on failure — removes requestId from notified set', async () => {
564
+ mockPendingApprovals = [{
565
+ requestId: 'req-9',
566
+ toolName: 'bash',
567
+ input: {},
568
+ riskLevel: 'medium',
569
+ }];
570
+
571
+ mockGuardianBinding = {
572
+ id: 'binding-1',
573
+ metadataJson: JSON.stringify({ displayName: 'Guardian' }),
574
+ };
575
+
576
+ const notified = new Map<string, string>();
577
+ const baseParams = {
578
+ conversationId: 'conv-1',
579
+ sourceChannel: 'telegram' as ChannelId,
580
+ externalChatId: 'chat-123',
581
+ guardianTrustClass: 'trusted_contact' as const,
582
+ guardianExternalUserId: 'guardian-1',
583
+ replyCallbackUrl: 'http://localhost:3000/deliver/telegram',
584
+ notifiedRequestIds: notified,
585
+ };
586
+
587
+ // First attempt: delivery fails
588
+ deliverShouldFail = true;
589
+ const sent1 = await simulateNotifierPoll(baseParams);
590
+ expect(sent1).toBe(false);
591
+ expect(notified.has('req-9')).toBe(false); // Removed for retry
592
+
593
+ // Second attempt: delivery succeeds
594
+ deliverShouldFail = false;
595
+ const sent2 = await simulateNotifierPoll(baseParams);
596
+ expect(sent2).toBe(true);
597
+ expect(deliveredReplies).toHaveLength(1);
598
+ expect(notified.has('req-9')).toBe(true);
599
+ });
600
+
601
+ test('does not send when no pending approvals exist', async () => {
602
+ mockPendingApprovals = [];
603
+
604
+ const notified = new Map<string, string>();
605
+ const sent = await simulateNotifierPoll({
606
+ conversationId: 'conv-1',
607
+ sourceChannel: 'telegram',
608
+ externalChatId: 'chat-123',
609
+ guardianTrustClass: 'trusted_contact',
610
+ guardianExternalUserId: 'guardian-1',
611
+ replyCallbackUrl: 'http://localhost:3000/deliver/telegram',
612
+ notifiedRequestIds: notified,
613
+ });
614
+
615
+ expect(sent).toBe(false);
616
+ expect(deliveredReplies).toHaveLength(0);
617
+ });
618
+
619
+ test('prefers displayName over username when both are present', async () => {
620
+ mockPendingApprovals = [{
621
+ requestId: 'req-10',
622
+ toolName: 'bash',
623
+ input: {},
624
+ riskLevel: 'medium',
625
+ }];
626
+
627
+ mockGuardianBinding = {
628
+ id: 'binding-1',
629
+ metadataJson: JSON.stringify({
630
+ displayName: 'Sarah',
631
+ username: 'sarah_bot',
632
+ }),
633
+ };
634
+
635
+ const notified = new Map<string, string>();
636
+ await simulateNotifierPoll({
637
+ conversationId: 'conv-1',
638
+ sourceChannel: 'telegram',
639
+ externalChatId: 'chat-123',
640
+ guardianTrustClass: 'trusted_contact',
641
+ guardianExternalUserId: 'guardian-1',
642
+ replyCallbackUrl: 'http://localhost:3000/deliver/telegram',
643
+ notifiedRequestIds: notified,
644
+ });
645
+
646
+ expect(deliveredReplies).toHaveLength(1);
647
+ expect(deliveredReplies[0].payload.text).toBe("Waiting for Sarah's approval...");
648
+ });
649
+
650
+ test('handles malformed metadataJson gracefully', async () => {
651
+ mockPendingApprovals = [{
652
+ requestId: 'req-11',
653
+ toolName: 'bash',
654
+ input: {},
655
+ riskLevel: 'medium',
656
+ }];
657
+
658
+ mockGuardianBinding = {
659
+ id: 'binding-1',
660
+ metadataJson: 'not-valid-json{{{',
661
+ };
662
+
663
+ const notified = new Map<string, string>();
664
+ await simulateNotifierPoll({
665
+ conversationId: 'conv-1',
666
+ sourceChannel: 'telegram',
667
+ externalChatId: 'chat-123',
668
+ guardianTrustClass: 'trusted_contact',
669
+ guardianExternalUserId: 'guardian-1',
670
+ replyCallbackUrl: 'http://localhost:3000/deliver/telegram',
671
+ notifiedRequestIds: notified,
672
+ });
673
+
674
+ expect(deliveredReplies).toHaveLength(1);
675
+ // Falls back to generic phrasing
676
+ expect(deliveredReplies[0].payload.text).toBe("Waiting for your guardian's approval...");
677
+ });
678
+ });