@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,410 @@
1
+ /**
2
+ * Tests for the confirmation-request -> guardian.question notification bridge.
3
+ *
4
+ * Verifies that:
5
+ * 1. Trusted-contact confirmation_requests emit guardian.question notifications
6
+ * 2. Canonical delivery rows are persisted for guardian destinations
7
+ * 3. Guardian and unknown actor sessions are correctly skipped
8
+ * 4. Missing guardian binding causes a skip
9
+ */
10
+
11
+ import { mkdtempSync, rmSync } from 'node:fs';
12
+ import { tmpdir } from 'node:os';
13
+ import { join } from 'node:path';
14
+
15
+ import { afterAll, beforeEach, describe, expect, mock, test } from 'bun:test';
16
+
17
+ const testDir = mkdtempSync(join(tmpdir(), 'confirmation-bridge-test-'));
18
+
19
+ mock.module('../util/platform.js', () => ({
20
+ getDataDir: () => testDir,
21
+ isMacOS: () => process.platform === 'darwin',
22
+ isLinux: () => process.platform === 'linux',
23
+ isWindows: () => process.platform === 'win32',
24
+ getSocketPath: () => join(testDir, 'test.sock'),
25
+ getPidPath: () => join(testDir, 'test.pid'),
26
+ getDbPath: () => join(testDir, 'test.db'),
27
+ getLogPath: () => join(testDir, 'test.log'),
28
+ ensureDataDir: () => {},
29
+ migrateToDataLayout: () => {},
30
+ migrateToWorkspaceLayout: () => {},
31
+ }));
32
+
33
+ mock.module('../util/logger.js', () => ({
34
+ getLogger: () =>
35
+ new Proxy({} as Record<string, unknown>, {
36
+ get: () => () => {},
37
+ }),
38
+ isDebug: () => false,
39
+ truncateForLog: (value: string) => value,
40
+ }));
41
+
42
+ // Mock notification emission — capture calls without running the full pipeline
43
+ const emittedSignals: Array<Record<string, unknown>> = [];
44
+ const mockOnThreadCreatedCallbacks: Array<(info: { conversationId: string; title: string; sourceEventName: string }) => void> = [];
45
+ mock.module('../notifications/emit-signal.js', () => ({
46
+ emitNotificationSignal: async (params: Record<string, unknown>) => {
47
+ emittedSignals.push(params);
48
+ // Capture onThreadCreated callback so tests can invoke it
49
+ if (typeof params.onThreadCreated === 'function') {
50
+ mockOnThreadCreatedCallbacks.push(params.onThreadCreated as (info: { conversationId: string; title: string; sourceEventName: string }) => void);
51
+ }
52
+ return {
53
+ signalId: 'test-signal',
54
+ deduplicated: false,
55
+ dispatched: true,
56
+ reason: 'ok',
57
+ deliveryResults: [
58
+ { channel: 'telegram', destination: 'guardian-chat-1', success: true },
59
+ ],
60
+ };
61
+ },
62
+ registerBroadcastFn: () => {},
63
+ }));
64
+
65
+ // Mock channel guardian service — provide a guardian binding for 'self' + 'telegram'
66
+ mock.module('../runtime/channel-guardian-service.js', () => ({
67
+ getGuardianBinding: (assistantId: string, channel: string) => {
68
+ if (assistantId === 'self' && channel === 'telegram') {
69
+ return {
70
+ id: 'binding-1',
71
+ assistantId: 'self',
72
+ channel: 'telegram',
73
+ guardianExternalUserId: 'guardian-1',
74
+ guardianDeliveryChatId: 'guardian-chat-1',
75
+ status: 'active',
76
+ };
77
+ }
78
+ return null;
79
+ },
80
+ }));
81
+
82
+ import type { GuardianRuntimeContext } from '../daemon/session-runtime-assembly.js';
83
+ import {
84
+ createCanonicalGuardianRequest,
85
+ generateCanonicalRequestCode,
86
+ listCanonicalGuardianDeliveries,
87
+ } from '../memory/canonical-guardian-store.js';
88
+ import { getDb, initializeDb, resetDb } from '../memory/db.js';
89
+ import { bridgeConfirmationRequestToGuardian } from '../runtime/confirmation-request-guardian-bridge.js';
90
+
91
+ initializeDb();
92
+
93
+ function resetTables(): void {
94
+ const db = getDb();
95
+ db.run('DELETE FROM canonical_guardian_deliveries');
96
+ db.run('DELETE FROM canonical_guardian_requests');
97
+ }
98
+
99
+ afterAll(() => {
100
+ resetDb();
101
+ try {
102
+ rmSync(testDir, { recursive: true });
103
+ } catch {
104
+ /* best effort */
105
+ }
106
+ });
107
+
108
+ // ---------------------------------------------------------------------------
109
+ // Helpers
110
+ // ---------------------------------------------------------------------------
111
+
112
+ function makeCanonicalRequest(overrides: Record<string, unknown> = {}) {
113
+ return createCanonicalGuardianRequest({
114
+ id: `req-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`,
115
+ kind: 'tool_approval',
116
+ sourceType: 'channel',
117
+ sourceChannel: 'telegram',
118
+ conversationId: 'conv-1',
119
+ requesterExternalUserId: 'requester-1',
120
+ guardianExternalUserId: 'guardian-1',
121
+ toolName: 'bash',
122
+ status: 'pending',
123
+ requestCode: generateCanonicalRequestCode(),
124
+ expiresAt: new Date(Date.now() + 5 * 60 * 1000).toISOString(),
125
+ ...overrides,
126
+ });
127
+ }
128
+
129
+ function makeTrustedContactContext(overrides: Partial<GuardianRuntimeContext> = {}): GuardianRuntimeContext {
130
+ return {
131
+ sourceChannel: 'telegram',
132
+ trustClass: 'trusted_contact',
133
+ guardianExternalUserId: 'guardian-1',
134
+ guardianChatId: 'guardian-chat-1',
135
+ requesterExternalUserId: 'requester-1',
136
+ requesterChatId: 'requester-chat-1',
137
+ requesterIdentifier: '@requester',
138
+ ...overrides,
139
+ };
140
+ }
141
+
142
+ // ===========================================================================
143
+ // TESTS
144
+ // ===========================================================================
145
+
146
+ describe('bridgeConfirmationRequestToGuardian', () => {
147
+ beforeEach(() => {
148
+ resetTables();
149
+ emittedSignals.length = 0;
150
+ mockOnThreadCreatedCallbacks.length = 0;
151
+ });
152
+
153
+ test('emits guardian.question for trusted-contact sessions', () => {
154
+ const canonicalRequest = makeCanonicalRequest();
155
+ const guardianContext = makeTrustedContactContext();
156
+
157
+ const result = bridgeConfirmationRequestToGuardian({
158
+ canonicalRequest,
159
+ guardianContext,
160
+ conversationId: 'conv-1',
161
+ toolName: 'bash',
162
+ });
163
+
164
+ expect('bridged' in result && result.bridged).toBe(true);
165
+ expect(emittedSignals).toHaveLength(1);
166
+ expect(emittedSignals[0].sourceEventName).toBe('guardian.question');
167
+ expect(emittedSignals[0].sourceChannel).toBe('telegram');
168
+ expect(emittedSignals[0].sourceSessionId).toBe('conv-1');
169
+
170
+ const payload = emittedSignals[0].contextPayload as Record<string, unknown>;
171
+ expect(payload.requestId).toBe(canonicalRequest.id);
172
+ expect(payload.requestCode).toBe(canonicalRequest.requestCode);
173
+ expect(payload.toolName).toBe('bash');
174
+ expect(payload.requesterExternalUserId).toBe('requester-1');
175
+ expect(payload.requesterIdentifier).toBe('@requester');
176
+ });
177
+
178
+ test('skips guardian actor sessions (self-approve)', () => {
179
+ const canonicalRequest = makeCanonicalRequest();
180
+ const guardianContext: GuardianRuntimeContext = {
181
+ sourceChannel: 'telegram',
182
+ trustClass: 'guardian',
183
+ guardianExternalUserId: 'guardian-1',
184
+ };
185
+
186
+ const result = bridgeConfirmationRequestToGuardian({
187
+ canonicalRequest,
188
+ guardianContext,
189
+ conversationId: 'conv-1',
190
+ toolName: 'bash',
191
+ });
192
+
193
+ expect('skipped' in result && result.skipped).toBe(true);
194
+ if ('skipped' in result) {
195
+ expect(result.reason).toBe('not_trusted_contact');
196
+ }
197
+ expect(emittedSignals).toHaveLength(0);
198
+ });
199
+
200
+ test('skips unknown actor sessions', () => {
201
+ const canonicalRequest = makeCanonicalRequest();
202
+ const guardianContext: GuardianRuntimeContext = {
203
+ sourceChannel: 'telegram',
204
+ trustClass: 'unknown',
205
+ };
206
+
207
+ const result = bridgeConfirmationRequestToGuardian({
208
+ canonicalRequest,
209
+ guardianContext,
210
+ conversationId: 'conv-1',
211
+ toolName: 'bash',
212
+ });
213
+
214
+ expect('skipped' in result && result.skipped).toBe(true);
215
+ if ('skipped' in result) {
216
+ expect(result.reason).toBe('not_trusted_contact');
217
+ }
218
+ expect(emittedSignals).toHaveLength(0);
219
+ });
220
+
221
+ test('skips when guardian identity is missing', () => {
222
+ const canonicalRequest = makeCanonicalRequest();
223
+ const guardianContext = makeTrustedContactContext({
224
+ guardianExternalUserId: undefined,
225
+ });
226
+
227
+ const result = bridgeConfirmationRequestToGuardian({
228
+ canonicalRequest,
229
+ guardianContext,
230
+ conversationId: 'conv-1',
231
+ toolName: 'bash',
232
+ });
233
+
234
+ expect('skipped' in result && result.skipped).toBe(true);
235
+ if ('skipped' in result) {
236
+ expect(result.reason).toBe('missing_guardian_identity');
237
+ }
238
+ expect(emittedSignals).toHaveLength(0);
239
+ });
240
+
241
+ test('skips when no guardian binding exists for channel', () => {
242
+ const canonicalRequest = makeCanonicalRequest({ sourceChannel: 'sms' });
243
+ const guardianContext = makeTrustedContactContext({
244
+ sourceChannel: 'sms',
245
+ });
246
+
247
+ const result = bridgeConfirmationRequestToGuardian({
248
+ canonicalRequest,
249
+ guardianContext,
250
+ conversationId: 'conv-1',
251
+ toolName: 'bash',
252
+ });
253
+
254
+ expect('skipped' in result && result.skipped).toBe(true);
255
+ if ('skipped' in result) {
256
+ expect(result.reason).toBe('no_guardian_binding');
257
+ }
258
+ expect(emittedSignals).toHaveLength(0);
259
+ });
260
+
261
+ test('sets correct attention hints for urgency', () => {
262
+ const canonicalRequest = makeCanonicalRequest();
263
+ const guardianContext = makeTrustedContactContext();
264
+
265
+ bridgeConfirmationRequestToGuardian({
266
+ canonicalRequest,
267
+ guardianContext,
268
+ conversationId: 'conv-1',
269
+ toolName: 'bash',
270
+ });
271
+
272
+ const hints = emittedSignals[0].attentionHints as Record<string, unknown>;
273
+ expect(hints.requiresAction).toBe(true);
274
+ expect(hints.urgency).toBe('high');
275
+ expect(hints.isAsyncBackground).toBe(false);
276
+ expect(hints.visibleInSourceNow).toBe(false);
277
+ });
278
+
279
+ test('uses dedupe key scoped to canonical request ID', () => {
280
+ const canonicalRequest = makeCanonicalRequest();
281
+ const guardianContext = makeTrustedContactContext();
282
+
283
+ bridgeConfirmationRequestToGuardian({
284
+ canonicalRequest,
285
+ guardianContext,
286
+ conversationId: 'conv-1',
287
+ toolName: 'bash',
288
+ });
289
+
290
+ expect(emittedSignals[0].dedupeKey).toBe(`tc-confirmation-request:${canonicalRequest.id}`);
291
+ });
292
+
293
+ test('creates vellum delivery row via onThreadCreated callback', () => {
294
+ const canonicalRequest = makeCanonicalRequest();
295
+ const guardianContext = makeTrustedContactContext();
296
+
297
+ bridgeConfirmationRequestToGuardian({
298
+ canonicalRequest,
299
+ guardianContext,
300
+ conversationId: 'conv-1',
301
+ toolName: 'bash',
302
+ });
303
+
304
+ expect(mockOnThreadCreatedCallbacks).toHaveLength(1);
305
+
306
+ // Simulate the broadcaster invoking onThreadCreated
307
+ mockOnThreadCreatedCallbacks[0]({
308
+ conversationId: 'guardian-thread-1',
309
+ title: 'Guardian question',
310
+ sourceEventName: 'guardian.question',
311
+ });
312
+
313
+ const deliveries = listCanonicalGuardianDeliveries(canonicalRequest.id);
314
+ expect(deliveries).toHaveLength(1);
315
+ expect(deliveries[0].destinationChannel).toBe('vellum');
316
+ expect(deliveries[0].destinationConversationId).toBe('guardian-thread-1');
317
+ });
318
+
319
+ test('uses custom assistantId when provided', () => {
320
+ const canonicalRequest = makeCanonicalRequest();
321
+ const guardianContext = makeTrustedContactContext();
322
+
323
+ bridgeConfirmationRequestToGuardian({
324
+ canonicalRequest,
325
+ guardianContext,
326
+ conversationId: 'conv-1',
327
+ toolName: 'bash',
328
+ assistantId: 'custom-assistant',
329
+ });
330
+
331
+ // The mock only returns a binding for 'self', so 'custom-assistant'
332
+ // should fail with no_guardian_binding.
333
+ // Actually let's verify the signal uses the right assistantId.
334
+ // Since mock only has binding for 'self', this will skip.
335
+ expect(emittedSignals).toHaveLength(0);
336
+ });
337
+
338
+ test('passes assistantId to notification signal', () => {
339
+ const canonicalRequest = makeCanonicalRequest();
340
+ const guardianContext = makeTrustedContactContext();
341
+
342
+ // Use default assistantId 'self' which has a binding
343
+ bridgeConfirmationRequestToGuardian({
344
+ canonicalRequest,
345
+ guardianContext,
346
+ conversationId: 'conv-1',
347
+ toolName: 'bash',
348
+ });
349
+
350
+ expect(emittedSignals[0].assistantId).toBe('self');
351
+ });
352
+
353
+ test('includes requesterChatId as null when not provided', () => {
354
+ const canonicalRequest = makeCanonicalRequest();
355
+ const guardianContext = makeTrustedContactContext({
356
+ requesterChatId: undefined,
357
+ });
358
+
359
+ bridgeConfirmationRequestToGuardian({
360
+ canonicalRequest,
361
+ guardianContext,
362
+ conversationId: 'conv-1',
363
+ toolName: 'bash',
364
+ });
365
+
366
+ const payload = emittedSignals[0].contextPayload as Record<string, unknown>;
367
+ expect(payload.requesterChatId).toBeNull();
368
+ });
369
+
370
+ test('skips when binding guardian identity does not match canonical request guardian', () => {
371
+ // Create a canonical request where guardianExternalUserId differs from the
372
+ // binding's guardianExternalUserId ('guardian-1' in the mock).
373
+ const canonicalRequest = makeCanonicalRequest({
374
+ guardianExternalUserId: 'old-guardian-who-was-rebound',
375
+ });
376
+ const guardianContext = makeTrustedContactContext();
377
+
378
+ const result = bridgeConfirmationRequestToGuardian({
379
+ canonicalRequest,
380
+ guardianContext,
381
+ conversationId: 'conv-1',
382
+ toolName: 'bash',
383
+ });
384
+
385
+ expect('skipped' in result && result.skipped).toBe(true);
386
+ if ('skipped' in result) {
387
+ expect(result.reason).toBe('binding_identity_mismatch');
388
+ }
389
+ expect(emittedSignals).toHaveLength(0);
390
+ });
391
+
392
+ test('does not skip when canonical request guardian identity is null', () => {
393
+ // When guardianExternalUserId is null on the canonical request (e.g. desktop
394
+ // flow), the identity check should be skipped and the bridge should proceed.
395
+ const canonicalRequest = makeCanonicalRequest({
396
+ guardianExternalUserId: null,
397
+ });
398
+ const guardianContext = makeTrustedContactContext();
399
+
400
+ const result = bridgeConfirmationRequestToGuardian({
401
+ canonicalRequest,
402
+ guardianContext,
403
+ conversationId: 'conv-1',
404
+ toolName: 'bash',
405
+ });
406
+
407
+ expect('bridged' in result && result.bridged).toBe(true);
408
+ expect(emittedSignals).toHaveLength(1);
409
+ });
410
+ });
@@ -0,0 +1,256 @@
1
+ import { beforeEach, describe, expect, mock, test } from 'bun:test';
2
+
3
+ const routeGuardianReplyMock = mock(async () => ({
4
+ consumed: false,
5
+ decisionApplied: false,
6
+ type: 'not_consumed' as const,
7
+ })) as any;
8
+ const listPendingByDestinationMock = mock((_conversationId: string, _sourceChannel?: string) => [] as Array<{ id: string; kind?: string }>);
9
+ const listCanonicalMock = mock((_filters?: Record<string, unknown>) => [] as Array<{ id: string }>);
10
+ const addMessageMock = mock(async (_conversationId: string, role: string, _content?: string, _metadata?: Record<string, unknown>) => ({
11
+ id: role === 'user' ? 'persisted-user-id' : 'persisted-assistant-id',
12
+ }));
13
+
14
+ mock.module('../util/logger.js', () => ({
15
+ getLogger: () => new Proxy({} as Record<string, unknown>, {
16
+ get: () => () => {},
17
+ }),
18
+ }));
19
+
20
+ mock.module('../memory/conversation-key-store.js', () => ({
21
+ getOrCreateConversation: () => ({ conversationId: 'conv-canonical-reply' }),
22
+ getConversationByKey: () => null,
23
+ }));
24
+
25
+ mock.module('../memory/attachments-store.js', () => ({
26
+ getAttachmentsByIds: () => [],
27
+ }));
28
+
29
+ mock.module('../runtime/guardian-reply-router.js', () => ({
30
+ routeGuardianReply: routeGuardianReplyMock,
31
+ }));
32
+
33
+ mock.module('../memory/canonical-guardian-store.js', () => ({
34
+ createCanonicalGuardianRequest: () => ({ id: 'canonical-id', requestCode: 'ABC123' }),
35
+ generateCanonicalRequestCode: () => 'ABC123',
36
+ listPendingCanonicalGuardianRequestsByDestinationConversation: (
37
+ conversationId: string,
38
+ sourceChannel?: string,
39
+ ) => listPendingByDestinationMock(conversationId, sourceChannel),
40
+ listCanonicalGuardianRequests: (filters?: Record<string, unknown>) =>
41
+ listCanonicalMock(filters),
42
+ }));
43
+
44
+ mock.module('../runtime/confirmation-request-guardian-bridge.js', () => ({
45
+ bridgeConfirmationRequestToGuardian: async () => undefined,
46
+ }));
47
+
48
+ mock.module('../memory/conversation-store.js', () => ({
49
+ addMessage: (
50
+ conversationId: string,
51
+ role: string,
52
+ content: string,
53
+ metadata?: Record<string, unknown>,
54
+ ) => addMessageMock(conversationId, role, content, metadata),
55
+ }));
56
+
57
+ mock.module('../runtime/local-actor-identity.js', () => ({
58
+ resolveLocalIpcGuardianContext: () => ({ trustClass: 'guardian', sourceChannel: 'vellum' }),
59
+ }));
60
+
61
+ import { handleSendMessage } from '../runtime/routes/conversation-routes.js';
62
+
63
+ const testServer = {
64
+ requestIP: () => ({ address: '127.0.0.1' }),
65
+ } as unknown as import('../runtime/middleware/actor-token.js').ServerWithRequestIP;
66
+
67
+ describe('handleSendMessage canonical guardian reply interception', () => {
68
+ beforeEach(() => {
69
+ routeGuardianReplyMock.mockClear();
70
+ listPendingByDestinationMock.mockClear();
71
+ listCanonicalMock.mockClear();
72
+ addMessageMock.mockClear();
73
+ });
74
+
75
+ test('consumes access-request code replies on desktop HTTP path without pending confirmations', async () => {
76
+ listPendingByDestinationMock.mockReturnValue([{ id: 'access-req-1' }]);
77
+ listCanonicalMock.mockReturnValue([]);
78
+ routeGuardianReplyMock.mockResolvedValue({
79
+ consumed: true,
80
+ decisionApplied: true,
81
+ type: 'canonical_decision_applied',
82
+ requestId: 'access-req-1',
83
+ replyText: 'Access approved. Verification code: 123456.',
84
+ });
85
+
86
+ const persistUserMessage = mock(async () => 'should-not-be-called');
87
+ const runAgentLoop = mock(async () => undefined);
88
+ const session = {
89
+ setGuardianContext: () => {},
90
+ setStateSignalListener: () => {},
91
+ emitConfirmationStateChanged: () => {},
92
+ emitActivityState: () => {},
93
+ setTurnChannelContext: () => {},
94
+ setTurnInterfaceContext: () => {},
95
+ isProcessing: () => false,
96
+ hasAnyPendingConfirmation: () => false,
97
+ denyAllPendingConfirmations: () => {},
98
+ enqueueMessage: () => ({ queued: true, requestId: 'queued-id' }),
99
+ persistUserMessage,
100
+ runAgentLoop,
101
+ getMessages: () => [] as unknown[],
102
+ assistantId: 'self',
103
+ guardianContext: undefined,
104
+ hasPendingConfirmation: () => false,
105
+ } as unknown as import('../daemon/session.js').Session;
106
+
107
+ const req = new Request('http://localhost/v1/messages', {
108
+ method: 'POST',
109
+ headers: { 'Content-Type': 'application/json' },
110
+ body: JSON.stringify({
111
+ conversationKey: 'guardian-thread-key',
112
+ content: '05BECB approve',
113
+ sourceChannel: 'vellum',
114
+ interface: 'macos',
115
+ }),
116
+ });
117
+
118
+ const res = await handleSendMessage(req, {
119
+ sendMessageDeps: {
120
+ getOrCreateSession: async () => session,
121
+ assistantEventHub: { publish: async () => {} } as any,
122
+ resolveAttachments: () => [],
123
+ },
124
+ }, testServer);
125
+
126
+ expect(res.status).toBe(202);
127
+ const body = await res.json() as { accepted: boolean; messageId?: string };
128
+ expect(body.accepted).toBe(true);
129
+ expect(body.messageId).toBe('persisted-user-id');
130
+
131
+ expect(routeGuardianReplyMock).toHaveBeenCalledTimes(1);
132
+ const routerCall = (routeGuardianReplyMock as any).mock.calls[0][0] as Record<string, unknown>;
133
+ expect(routerCall.messageText).toBe('05BECB approve');
134
+ expect(routerCall.pendingRequestIds).toEqual(['access-req-1']);
135
+ expect(addMessageMock).toHaveBeenCalledTimes(2);
136
+ expect(persistUserMessage).toHaveBeenCalledTimes(0);
137
+ expect(runAgentLoop).toHaveBeenCalledTimes(0);
138
+ });
139
+
140
+ test('passes undefined pendingRequestIds when no canonical hints are found', async () => {
141
+ listPendingByDestinationMock.mockReturnValue([]);
142
+ listCanonicalMock.mockReturnValue([]);
143
+ routeGuardianReplyMock.mockResolvedValue({
144
+ consumed: false,
145
+ decisionApplied: false,
146
+ type: 'not_consumed',
147
+ });
148
+
149
+ const persistUserMessage = mock(async () => 'persisted-user-id');
150
+ const runAgentLoop = mock(async () => undefined);
151
+ const session = {
152
+ setGuardianContext: () => {},
153
+ setStateSignalListener: () => {},
154
+ emitConfirmationStateChanged: () => {},
155
+ emitActivityState: () => {},
156
+ setTurnChannelContext: () => {},
157
+ setTurnInterfaceContext: () => {},
158
+ isProcessing: () => false,
159
+ hasAnyPendingConfirmation: () => false,
160
+ denyAllPendingConfirmations: () => {},
161
+ enqueueMessage: () => ({ queued: true, requestId: 'queued-id' }),
162
+ persistUserMessage,
163
+ runAgentLoop,
164
+ getMessages: () => [] as unknown[],
165
+ assistantId: 'self',
166
+ guardianContext: undefined,
167
+ hasPendingConfirmation: () => false,
168
+ } as unknown as import('../daemon/session.js').Session;
169
+
170
+ const req = new Request('http://localhost/v1/messages', {
171
+ method: 'POST',
172
+ headers: { 'Content-Type': 'application/json' },
173
+ body: JSON.stringify({
174
+ conversationKey: 'guardian-thread-key',
175
+ content: 'hello there',
176
+ sourceChannel: 'vellum',
177
+ interface: 'macos',
178
+ }),
179
+ });
180
+
181
+ const res = await handleSendMessage(req, {
182
+ sendMessageDeps: {
183
+ getOrCreateSession: async () => session,
184
+ assistantEventHub: { publish: async () => {} } as any,
185
+ resolveAttachments: () => [],
186
+ },
187
+ }, testServer);
188
+
189
+ expect(res.status).toBe(202);
190
+ expect(routeGuardianReplyMock).toHaveBeenCalledTimes(1);
191
+ const routerCall = (routeGuardianReplyMock as any).mock.calls[0][0] as Record<string, unknown>;
192
+ expect(routerCall.pendingRequestIds).toBeUndefined();
193
+ expect(persistUserMessage).toHaveBeenCalledTimes(1);
194
+ expect(runAgentLoop).toHaveBeenCalledTimes(1);
195
+ });
196
+
197
+ test('excludes stale tool_approval hints without a live pending confirmation', async () => {
198
+ listPendingByDestinationMock.mockReturnValue([
199
+ { id: 'tool-approval-live', kind: 'tool_approval' },
200
+ { id: 'tool-approval-stale', kind: 'tool_approval' },
201
+ { id: 'access-req-1', kind: 'access_request' },
202
+ ]);
203
+ listCanonicalMock.mockReturnValue([]);
204
+ routeGuardianReplyMock.mockResolvedValue({
205
+ consumed: false,
206
+ decisionApplied: false,
207
+ type: 'not_consumed',
208
+ });
209
+
210
+ const persistUserMessage = mock(async () => 'persisted-user-id');
211
+ const runAgentLoop = mock(async () => undefined);
212
+ const session = {
213
+ setGuardianContext: () => {},
214
+ setStateSignalListener: () => {},
215
+ emitConfirmationStateChanged: () => {},
216
+ emitActivityState: () => {},
217
+ setTurnChannelContext: () => {},
218
+ setTurnInterfaceContext: () => {},
219
+ isProcessing: () => false,
220
+ hasAnyPendingConfirmation: () => true,
221
+ denyAllPendingConfirmations: () => {},
222
+ enqueueMessage: () => ({ queued: true, requestId: 'queued-id' }),
223
+ persistUserMessage,
224
+ runAgentLoop,
225
+ getMessages: () => [] as unknown[],
226
+ assistantId: 'self',
227
+ guardianContext: undefined,
228
+ hasPendingConfirmation: (requestId: string) => requestId === 'tool-approval-live',
229
+ } as unknown as import('../daemon/session.js').Session;
230
+
231
+ const req = new Request('http://localhost/v1/messages', {
232
+ method: 'POST',
233
+ headers: { 'Content-Type': 'application/json' },
234
+ body: JSON.stringify({
235
+ conversationKey: 'guardian-thread-key',
236
+ content: 'approve',
237
+ sourceChannel: 'vellum',
238
+ interface: 'macos',
239
+ }),
240
+ });
241
+
242
+ const res = await handleSendMessage(req, {
243
+ sendMessageDeps: {
244
+ getOrCreateSession: async () => session,
245
+ assistantEventHub: { publish: async () => {} } as any,
246
+ resolveAttachments: () => [],
247
+ },
248
+ }, testServer);
249
+
250
+ expect(res.status).toBe(202);
251
+ expect(routeGuardianReplyMock).toHaveBeenCalledTimes(1);
252
+ const routerCall = (routeGuardianReplyMock as any).mock.calls[0][0] as Record<string, unknown>;
253
+ expect(routerCall.pendingRequestIds).toEqual(['tool-approval-live', 'access-req-1']);
254
+ expect((routerCall.pendingRequestIds as string[]).includes('tool-approval-stale')).toBe(false);
255
+ });
256
+ });
@@ -17,8 +17,17 @@ mock.module('../memory/attachments-store.js', () => ({
17
17
  getAttachmentsByIds: () => [],
18
18
  }));
19
19
 
20
+ mock.module('../runtime/local-actor-identity.js', () => ({
21
+ resolveLocalIpcGuardianContext: (sourceChannel: string) => ({ trustClass: 'guardian', sourceChannel }),
22
+ }));
23
+
24
+ import type { ServerWithRequestIP } from '../runtime/middleware/actor-token.js';
20
25
  import { handleSendMessage } from '../runtime/routes/conversation-routes.js';
21
26
 
27
+ const mockLoopbackServer: ServerWithRequestIP = {
28
+ requestIP: () => ({ address: '127.0.0.1', family: 'IPv4', port: 0 }),
29
+ };
30
+
22
31
  describe('handleSendMessage', () => {
23
32
  test('legacy fallback passes guardian context to processor', async () => {
24
33
  let capturedOptions: RuntimeMessageSessionOptions | undefined;
@@ -41,16 +50,16 @@ describe('handleSendMessage', () => {
41
50
  capturedSourceChannel = sourceChannel;
42
51
  return { messageId: 'msg-legacy-fallback' };
43
52
  },
44
- });
53
+ }, mockLoopbackServer);
45
54
 
46
55
  const body = await res.json() as { accepted: boolean; messageId: string };
47
56
  expect(res.status).toBe(202);
48
57
  expect(body.accepted).toBe(true);
49
58
  expect(body.messageId).toBe('msg-legacy-fallback');
50
59
  expect(capturedSourceChannel).toBe('telegram');
51
- expect(capturedOptions?.guardianContext).toEqual({
60
+ expect(capturedOptions?.guardianContext).toEqual(expect.objectContaining({
52
61
  trustClass: 'guardian',
53
62
  sourceChannel: 'telegram',
54
- });
63
+ }));
55
64
  });
56
65
  });