@vellumai/assistant 0.4.2 → 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 (221) hide show
  1. package/.env.example +3 -0
  2. package/ARCHITECTURE.md +124 -10
  3. package/README.md +43 -35
  4. package/docs/trusted-contact-access.md +20 -0
  5. package/package.json +1 -1
  6. package/scripts/ipc/generate-swift.ts +1 -0
  7. package/src/__tests__/__snapshots__/ipc-snapshot.test.ts.snap +58 -120
  8. package/src/__tests__/access-request-decision.test.ts +0 -1
  9. package/src/__tests__/actor-token-service.test.ts +1099 -0
  10. package/src/__tests__/agent-loop.test.ts +51 -0
  11. package/src/__tests__/approval-routes-http.test.ts +2 -0
  12. package/src/__tests__/assistant-events-sse-hardening.test.ts +7 -5
  13. package/src/__tests__/assistant-id-boundary-guard.test.ts +415 -0
  14. package/src/__tests__/call-controller.test.ts +49 -0
  15. package/src/__tests__/call-pointer-message-composer.test.ts +171 -0
  16. package/src/__tests__/call-pointer-messages.test.ts +93 -3
  17. package/src/__tests__/call-pointer-no-hardcoded-copy.guard.test.ts +42 -0
  18. package/src/__tests__/call-routes-http.test.ts +0 -25
  19. package/src/__tests__/callback-handoff-copy.test.ts +186 -0
  20. package/src/__tests__/channel-approval-routes.test.ts +133 -12
  21. package/src/__tests__/channel-guardian.test.ts +0 -86
  22. package/src/__tests__/channel-readiness-service.test.ts +10 -16
  23. package/src/__tests__/checker.test.ts +33 -12
  24. package/src/__tests__/config-schema.test.ts +6 -0
  25. package/src/__tests__/confirmation-request-guardian-bridge.test.ts +410 -0
  26. package/src/__tests__/conversation-routes-guardian-reply.test.ts +256 -0
  27. package/src/__tests__/conversation-routes.test.ts +12 -3
  28. package/src/__tests__/credential-security-invariants.test.ts +1 -1
  29. package/src/__tests__/daemon-server-session-init.test.ts +4 -0
  30. package/src/__tests__/deterministic-verification-control-plane.test.ts +0 -1
  31. package/src/__tests__/guardian-actions-endpoint.test.ts +39 -13
  32. package/src/__tests__/guardian-dispatch.test.ts +8 -0
  33. package/src/__tests__/guardian-outbound-http.test.ts +4 -5
  34. package/src/__tests__/guardian-question-mode.test.ts +200 -0
  35. package/src/__tests__/guardian-routing-invariants.test.ts +178 -0
  36. package/src/__tests__/guardian-routing-state.test.ts +525 -0
  37. package/src/__tests__/handle-user-message-secret-resume.test.ts +2 -0
  38. package/src/__tests__/handlers-telegram-config.test.ts +0 -83
  39. package/src/__tests__/handlers-user-message-approval-consumption.test.ts +55 -0
  40. package/src/__tests__/headless-browser-navigate.test.ts +2 -0
  41. package/src/__tests__/inbound-invite-redemption.test.ts +0 -1
  42. package/src/__tests__/ingress-routes-http.test.ts +55 -0
  43. package/src/__tests__/ipc-snapshot.test.ts +18 -51
  44. package/src/__tests__/non-member-access-request.test.ts +159 -9
  45. package/src/__tests__/notification-decision-fallback.test.ts +129 -4
  46. package/src/__tests__/notification-decision-strategy.test.ts +106 -2
  47. package/src/__tests__/notification-guardian-path.test.ts +3 -0
  48. package/src/__tests__/recording-intent-handler.test.ts +1 -0
  49. package/src/__tests__/relay-server.test.ts +1475 -33
  50. package/src/__tests__/send-endpoint-busy.test.ts +5 -0
  51. package/src/__tests__/session-agent-loop.test.ts +1 -0
  52. package/src/__tests__/session-confirmation-signals.test.ts +523 -0
  53. package/src/__tests__/session-init.benchmark.test.ts +0 -2
  54. package/src/__tests__/session-runtime-assembly.test.ts +4 -1
  55. package/src/__tests__/session-surfaces-task-progress.test.ts +44 -1
  56. package/src/__tests__/session-tool-setup-app-refresh.test.ts +81 -2
  57. package/src/__tests__/session-tool-setup-memory-scope.test.ts +1 -1
  58. package/src/__tests__/session-tool-setup-side-effect-flag.test.ts +1 -1
  59. package/src/__tests__/tool-executor.test.ts +21 -2
  60. package/src/__tests__/tool-grant-request-escalation.test.ts +333 -27
  61. package/src/__tests__/trusted-contact-approval-notifier.test.ts +678 -0
  62. package/src/__tests__/trusted-contact-inline-approval-integration.test.ts +1064 -0
  63. package/src/__tests__/trusted-contact-lifecycle-notifications.test.ts +11 -1
  64. package/src/__tests__/trusted-contact-multichannel.test.ts +0 -1
  65. package/src/__tests__/trusted-contact-verification.test.ts +0 -1
  66. package/src/__tests__/twilio-config.test.ts +2 -13
  67. package/src/__tests__/twilio-routes.test.ts +4 -3
  68. package/src/__tests__/update-bulletin.test.ts +0 -1
  69. package/src/agent/loop.ts +1 -1
  70. package/src/approvals/guardian-decision-primitive.ts +12 -3
  71. package/src/approvals/guardian-request-resolvers.ts +169 -11
  72. package/src/calls/call-constants.ts +29 -0
  73. package/src/calls/call-controller.ts +11 -3
  74. package/src/calls/call-domain.ts +33 -11
  75. package/src/calls/call-pointer-message-composer.ts +154 -0
  76. package/src/calls/call-pointer-messages.ts +106 -27
  77. package/src/calls/guardian-dispatch.ts +4 -2
  78. package/src/calls/relay-server.ts +921 -112
  79. package/src/calls/twilio-config.ts +4 -11
  80. package/src/calls/twilio-routes.ts +4 -6
  81. package/src/calls/types.ts +3 -1
  82. package/src/calls/voice-session-bridge.ts +4 -3
  83. package/src/cli/core-commands.ts +7 -4
  84. package/src/cli.ts +5 -4
  85. package/src/config/bundled-skills/agentmail/SKILL.md +4 -0
  86. package/src/config/bundled-skills/app-builder/SKILL.md +309 -10
  87. package/src/config/bundled-skills/app-builder/TOOLS.json +1 -1
  88. package/src/config/bundled-skills/email-setup/SKILL.md +1 -1
  89. package/src/config/bundled-skills/google-oauth-setup/SKILL.md +105 -81
  90. package/src/config/bundled-skills/messaging/SKILL.md +61 -12
  91. package/src/config/bundled-skills/messaging/TOOLS.json +58 -0
  92. package/src/config/bundled-skills/messaging/tools/gmail-sender-digest.ts +6 -1
  93. package/src/config/bundled-skills/messaging/tools/messaging-archive-by-sender.ts +35 -0
  94. package/src/config/bundled-skills/messaging/tools/messaging-sender-digest.ts +52 -0
  95. package/src/config/bundled-skills/phone-calls/SKILL.md +30 -39
  96. package/src/config/bundled-skills/twitter/SKILL.md +3 -3
  97. package/src/config/bundled-skills/vercel-token-setup/SKILL.md +215 -0
  98. package/src/config/calls-schema.ts +36 -0
  99. package/src/config/env.ts +22 -0
  100. package/src/config/feature-flag-registry.json +8 -8
  101. package/src/config/schema.ts +2 -2
  102. package/src/config/skills.ts +11 -0
  103. package/src/config/system-prompt.ts +11 -1
  104. package/src/config/templates/SOUL.md +2 -0
  105. package/src/config/vellum-skills/sms-setup/SKILL.md +71 -82
  106. package/src/config/vellum-skills/trusted-contacts/SKILL.md +8 -1
  107. package/src/config/vellum-skills/twilio-setup/SKILL.md +88 -73
  108. package/src/daemon/call-pointer-generators.ts +59 -0
  109. package/src/daemon/computer-use-session.ts +2 -5
  110. package/src/daemon/handlers/apps.ts +76 -20
  111. package/src/daemon/handlers/config-channels.ts +9 -61
  112. package/src/daemon/handlers/config-inbox.ts +11 -3
  113. package/src/daemon/handlers/config-ingress.ts +28 -3
  114. package/src/daemon/handlers/config-telegram.ts +12 -0
  115. package/src/daemon/handlers/config.ts +2 -6
  116. package/src/daemon/handlers/index.ts +2 -1
  117. package/src/daemon/handlers/pairing.ts +2 -0
  118. package/src/daemon/handlers/publish.ts +11 -46
  119. package/src/daemon/handlers/sessions.ts +59 -5
  120. package/src/daemon/handlers/shared.ts +17 -2
  121. package/src/daemon/ipc-contract/apps.ts +1 -0
  122. package/src/daemon/ipc-contract/inbox.ts +4 -0
  123. package/src/daemon/ipc-contract/integrations.ts +1 -97
  124. package/src/daemon/ipc-contract/messages.ts +47 -1
  125. package/src/daemon/ipc-contract/notifications.ts +11 -0
  126. package/src/daemon/ipc-contract-inventory.json +2 -4
  127. package/src/daemon/lifecycle.ts +17 -0
  128. package/src/daemon/server.ts +16 -2
  129. package/src/daemon/session-agent-loop-handlers.ts +20 -0
  130. package/src/daemon/session-agent-loop.ts +24 -12
  131. package/src/daemon/session-lifecycle.ts +1 -1
  132. package/src/daemon/session-process.ts +11 -1
  133. package/src/daemon/session-runtime-assembly.ts +6 -1
  134. package/src/daemon/session-surfaces.ts +32 -3
  135. package/src/daemon/session.ts +88 -1
  136. package/src/daemon/tool-side-effects.ts +22 -0
  137. package/src/home-base/prebuilt/brain-graph.html +1483 -0
  138. package/src/home-base/prebuilt/index.html +40 -0
  139. package/src/inbound/platform-callback-registration.ts +157 -0
  140. package/src/memory/canonical-guardian-store.ts +1 -1
  141. package/src/memory/conversation-crud.ts +2 -1
  142. package/src/memory/conversation-title-service.ts +16 -2
  143. package/src/memory/db-init.ts +8 -0
  144. package/src/memory/delivery-crud.ts +2 -1
  145. package/src/memory/guardian-action-store.ts +2 -1
  146. package/src/memory/guardian-approvals.ts +3 -2
  147. package/src/memory/ingress-invite-store.ts +12 -2
  148. package/src/memory/ingress-member-store.ts +4 -3
  149. package/src/memory/migrations/038-actor-token-records.ts +39 -0
  150. package/src/memory/migrations/124-voice-invite-display-metadata.ts +14 -0
  151. package/src/memory/migrations/index.ts +2 -0
  152. package/src/memory/schema.ts +26 -5
  153. package/src/messaging/provider-types.ts +24 -0
  154. package/src/messaging/provider.ts +7 -0
  155. package/src/messaging/providers/gmail/adapter.ts +127 -0
  156. package/src/messaging/providers/sms/adapter.ts +40 -37
  157. package/src/notifications/adapters/macos.ts +45 -2
  158. package/src/notifications/broadcaster.ts +16 -0
  159. package/src/notifications/copy-composer.ts +50 -2
  160. package/src/notifications/decision-engine.ts +22 -9
  161. package/src/notifications/destination-resolver.ts +16 -2
  162. package/src/notifications/emit-signal.ts +18 -9
  163. package/src/notifications/guardian-question-mode.ts +419 -0
  164. package/src/notifications/signal.ts +14 -3
  165. package/src/permissions/checker.ts +13 -1
  166. package/src/permissions/prompter.ts +14 -0
  167. package/src/providers/anthropic/client.ts +20 -0
  168. package/src/providers/provider-send-message.ts +15 -3
  169. package/src/runtime/access-request-helper.ts +82 -4
  170. package/src/runtime/actor-token-service.ts +234 -0
  171. package/src/runtime/actor-token-store.ts +236 -0
  172. package/src/runtime/actor-trust-resolver.ts +2 -2
  173. package/src/runtime/assistant-scope.ts +10 -0
  174. package/src/runtime/channel-approvals.ts +5 -3
  175. package/src/runtime/channel-readiness-service.ts +23 -64
  176. package/src/runtime/channel-readiness-types.ts +3 -4
  177. package/src/runtime/channel-retry-sweep.ts +4 -1
  178. package/src/runtime/confirmation-request-guardian-bridge.ts +197 -0
  179. package/src/runtime/guardian-action-followup-executor.ts +1 -1
  180. package/src/runtime/guardian-context-resolver.ts +82 -0
  181. package/src/runtime/guardian-outbound-actions.ts +5 -7
  182. package/src/runtime/guardian-reply-router.ts +67 -30
  183. package/src/runtime/guardian-vellum-migration.ts +57 -0
  184. package/src/runtime/http-server.ts +75 -31
  185. package/src/runtime/http-types.ts +13 -0
  186. package/src/runtime/ingress-service.ts +14 -0
  187. package/src/runtime/invite-redemption-service.ts +10 -1
  188. package/src/runtime/local-actor-identity.ts +76 -0
  189. package/src/runtime/middleware/actor-token.ts +271 -0
  190. package/src/runtime/middleware/twilio-validation.ts +2 -4
  191. package/src/runtime/routes/approval-routes.ts +82 -7
  192. package/src/runtime/routes/brain-graph-routes.ts +222 -0
  193. package/src/runtime/routes/call-routes.ts +2 -1
  194. package/src/runtime/routes/channel-readiness-routes.ts +71 -0
  195. package/src/runtime/routes/channel-route-shared.ts +3 -3
  196. package/src/runtime/routes/conversation-attention-routes.ts +2 -1
  197. package/src/runtime/routes/conversation-routes.ts +142 -53
  198. package/src/runtime/routes/events-routes.ts +22 -8
  199. package/src/runtime/routes/guardian-action-routes.ts +45 -3
  200. package/src/runtime/routes/guardian-approval-interception.ts +29 -0
  201. package/src/runtime/routes/guardian-bootstrap-routes.ts +145 -0
  202. package/src/runtime/routes/inbound-conversation.ts +4 -3
  203. package/src/runtime/routes/inbound-message-handler.ts +147 -5
  204. package/src/runtime/routes/ingress-routes.ts +2 -0
  205. package/src/runtime/routes/integration-routes.ts +7 -15
  206. package/src/runtime/routes/pairing-routes.ts +163 -0
  207. package/src/runtime/routes/twilio-routes.ts +934 -0
  208. package/src/runtime/tool-grant-request-helper.ts +3 -1
  209. package/src/security/oauth2.ts +27 -2
  210. package/src/security/token-manager.ts +46 -10
  211. package/src/tools/browser/browser-execution.ts +4 -3
  212. package/src/tools/browser/browser-handoff.ts +10 -18
  213. package/src/tools/browser/browser-manager.ts +80 -25
  214. package/src/tools/browser/browser-screencast.ts +35 -119
  215. package/src/tools/calls/call-start.ts +2 -1
  216. package/src/tools/permission-checker.ts +15 -4
  217. package/src/tools/terminal/parser.ts +12 -0
  218. package/src/tools/tool-approval-handler.ts +244 -19
  219. package/src/workspace/git-service.ts +19 -0
  220. package/src/__tests__/handlers-twilio-config.test.ts +0 -1928
  221. package/src/daemon/handlers/config-twilio.ts +0 -1082
@@ -0,0 +1,525 @@
1
+ import { mkdtempSync, rmSync } from 'node:fs';
2
+ import { tmpdir } from 'node:os';
3
+ import { join } from 'node:path';
4
+
5
+ import { afterAll, beforeEach, describe, expect, mock, test } from 'bun:test';
6
+ import { eq } from 'drizzle-orm';
7
+
8
+ // ---------------------------------------------------------------------------
9
+ // Test isolation: in-memory SQLite via temp directory
10
+ // ---------------------------------------------------------------------------
11
+
12
+ const testDir = mkdtempSync(join(tmpdir(), 'guardian-routing-state-test-'));
13
+
14
+ mock.module('../util/platform.js', () => ({
15
+ getRootDir: () => testDir,
16
+ getDataDir: () => testDir,
17
+ isMacOS: () => process.platform === 'darwin',
18
+ isLinux: () => process.platform === 'linux',
19
+ isWindows: () => process.platform === 'win32',
20
+ getSocketPath: () => join(testDir, 'test.sock'),
21
+ getPidPath: () => join(testDir, 'test.pid'),
22
+ getDbPath: () => join(testDir, 'test.db'),
23
+ getLogPath: () => join(testDir, 'test.log'),
24
+ ensureDataDir: () => {},
25
+ }));
26
+
27
+ mock.module('../util/logger.js', () => ({
28
+ getLogger: () => new Proxy({} as Record<string, unknown>, {
29
+ get: () => () => {},
30
+ }),
31
+ }));
32
+
33
+ // Mock security check to always pass
34
+ mock.module('../security/secret-ingress.js', () => ({
35
+ checkIngressForSecrets: () => ({ blocked: false }),
36
+ }));
37
+
38
+ // Mock ingress member store with a configurable member lookup.
39
+ // By default returns an active member so ACL passes.
40
+ let mockFindMember: (() => unknown) | null = null;
41
+ mock.module('../memory/ingress-member-store.js', () => ({
42
+ findMember: (..._args: unknown[]) => {
43
+ if (mockFindMember) return mockFindMember();
44
+ return {
45
+ id: 'member-test-default',
46
+ assistantId: 'self',
47
+ sourceChannel: 'telegram',
48
+ externalUserId: 'telegram-user-default',
49
+ externalChatId: null,
50
+ displayName: null,
51
+ username: null,
52
+ status: 'active',
53
+ policy: 'allow',
54
+ inviteId: null,
55
+ createdBySessionId: null,
56
+ revokedReason: null,
57
+ blockedReason: null,
58
+ lastSeenAt: null,
59
+ createdAt: Date.now(),
60
+ updatedAt: Date.now(),
61
+ };
62
+ },
63
+ updateLastSeen: () => {},
64
+ upsertMember: () => {},
65
+ }));
66
+
67
+ import * as channelDeliveryStore from '../memory/channel-delivery-store.js';
68
+ import { createBinding } from '../memory/channel-guardian-store.js';
69
+ import { getDb, initializeDb, resetDb } from '../memory/db.js';
70
+ import { channelInboundEvents, messages } from '../memory/schema.js';
71
+ import { sweepFailedEvents } from '../runtime/channel-retry-sweep.js';
72
+ import {
73
+ type GuardianContext,
74
+ resolveRoutingState,
75
+ resolveRoutingStateFromRuntime,
76
+ } from '../runtime/guardian-context-resolver.js';
77
+ import { handleChannelInbound } from '../runtime/routes/channel-routes.js';
78
+
79
+ initializeDb();
80
+
81
+ afterAll(() => {
82
+ resetDb();
83
+ try { rmSync(testDir, { recursive: true }); } catch { /* best effort */ }
84
+ });
85
+
86
+ function resetTables(): void {
87
+ const db = getDb();
88
+ db.run('DELETE FROM channel_inbound_events');
89
+ db.run('DELETE FROM channel_guardian_bindings');
90
+ db.run('DELETE FROM channel_guardian_approval_requests');
91
+ db.run('DELETE FROM canonical_guardian_requests');
92
+ db.run('DELETE FROM conversation_keys');
93
+ db.run('DELETE FROM messages');
94
+ db.run('DELETE FROM conversations');
95
+ db.run('DELETE FROM assistant_ingress_members');
96
+ db.run('DELETE FROM external_conversation_bindings');
97
+ }
98
+
99
+ // ═══════════════════════════════════════════════════════════════════════════
100
+ // Unit tests: resolveRoutingState
101
+ // ═══════════════════════════════════════════════════════════════════════════
102
+
103
+ describe('resolveRoutingState', () => {
104
+ test('guardian actors are always interactive and route-resolvable', () => {
105
+ const ctx: GuardianContext = {
106
+ trustClass: 'guardian',
107
+ guardianExternalUserId: 'guardian-123',
108
+ guardianChatId: 'chat-123',
109
+ };
110
+ const state = resolveRoutingState(ctx);
111
+ expect(state).toEqual({
112
+ canBeInteractive: true,
113
+ guardianRouteResolvable: true,
114
+ promptWaitingAllowed: true,
115
+ });
116
+ });
117
+
118
+ test('guardian actors are interactive even without guardianExternalUserId', () => {
119
+ // Edge case: guardian is chatting in their own chat, no separate binding needed
120
+ const ctx: GuardianContext = {
121
+ trustClass: 'guardian',
122
+ };
123
+ const state = resolveRoutingState(ctx);
124
+ expect(state.canBeInteractive).toBe(true);
125
+ expect(state.promptWaitingAllowed).toBe(true);
126
+ });
127
+
128
+ test('trusted contact with resolvable guardian route is interactive', () => {
129
+ const ctx: GuardianContext = {
130
+ trustClass: 'trusted_contact',
131
+ guardianExternalUserId: 'guardian-456',
132
+ guardianChatId: 'guardian-chat-456',
133
+ };
134
+ const state = resolveRoutingState(ctx);
135
+ expect(state).toEqual({
136
+ canBeInteractive: true,
137
+ guardianRouteResolvable: true,
138
+ promptWaitingAllowed: true,
139
+ });
140
+ });
141
+
142
+ test('trusted contact without guardian route is NOT interactive (fail-fast)', () => {
143
+ const ctx: GuardianContext = {
144
+ trustClass: 'trusted_contact',
145
+ // No guardianExternalUserId — no guardian binding for this channel
146
+ };
147
+ const state = resolveRoutingState(ctx);
148
+ expect(state).toEqual({
149
+ canBeInteractive: true,
150
+ guardianRouteResolvable: false,
151
+ promptWaitingAllowed: false,
152
+ });
153
+ });
154
+
155
+ test('unknown actors are never interactive regardless of guardian route', () => {
156
+ const withRoute: GuardianContext = {
157
+ trustClass: 'unknown',
158
+ guardianExternalUserId: 'guardian-789',
159
+ };
160
+ const withoutRoute: GuardianContext = {
161
+ trustClass: 'unknown',
162
+ };
163
+
164
+ expect(resolveRoutingState(withRoute).promptWaitingAllowed).toBe(false);
165
+ expect(resolveRoutingState(withRoute).canBeInteractive).toBe(false);
166
+ expect(resolveRoutingState(withoutRoute).promptWaitingAllowed).toBe(false);
167
+ });
168
+ });
169
+
170
+ describe('resolveRoutingStateFromRuntime', () => {
171
+ test('produces same result as resolveRoutingState for guardian runtime context', () => {
172
+ const runtimeCtx = {
173
+ sourceChannel: 'telegram' as const,
174
+ trustClass: 'trusted_contact' as const,
175
+ guardianExternalUserId: 'guardian-rt-1',
176
+ };
177
+ const state = resolveRoutingStateFromRuntime(runtimeCtx);
178
+ expect(state.promptWaitingAllowed).toBe(true);
179
+ expect(state.guardianRouteResolvable).toBe(true);
180
+ });
181
+
182
+ test('trusted contact runtime context without guardian binding is not interactive', () => {
183
+ const runtimeCtx = {
184
+ sourceChannel: 'telegram' as const,
185
+ trustClass: 'trusted_contact' as const,
186
+ // No guardianExternalUserId
187
+ };
188
+ const state = resolveRoutingStateFromRuntime(runtimeCtx);
189
+ expect(state.promptWaitingAllowed).toBe(false);
190
+ expect(state.guardianRouteResolvable).toBe(false);
191
+ });
192
+ });
193
+
194
+ // ═══════════════════════════════════════════════════════════════════════════
195
+ // Integration tests: inbound message handler interactivity
196
+ // ═══════════════════════════════════════════════════════════════════════════
197
+
198
+ describe('inbound-message-handler trusted-contact interactivity', () => {
199
+ beforeEach(() => {
200
+ resetTables();
201
+ mockFindMember = null;
202
+ });
203
+
204
+ function makeInboundRequest(overrides: Record<string, unknown> = {}): Request {
205
+ return new Request('http://localhost/channels/inbound', {
206
+ method: 'POST',
207
+ headers: {
208
+ 'Content-Type': 'application/json',
209
+ 'X-Gateway-Origin': 'test-token',
210
+ },
211
+ body: JSON.stringify({
212
+ sourceChannel: 'telegram',
213
+ interface: 'telegram',
214
+ externalChatId: 'chat-123',
215
+ externalMessageId: `msg-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
216
+ content: 'hello',
217
+ senderExternalUserId: 'telegram-user-default',
218
+ replyCallbackUrl: 'https://gateway.test/deliver/telegram',
219
+ ...overrides,
220
+ }),
221
+ });
222
+ }
223
+
224
+ test('trusted contact with guardian binding gets interactive turn', async () => {
225
+ // Create guardian binding so the trusted contact has a resolvable route
226
+ createBinding({
227
+ assistantId: 'self',
228
+ channel: 'telegram',
229
+ guardianExternalUserId: 'guardian-user-for-tc',
230
+ guardianDeliveryChatId: 'guardian-chat-for-tc',
231
+ });
232
+
233
+ const processCalls: Array<{ options?: Record<string, unknown> }> = [];
234
+ const processMessage = mock(async (
235
+ conversationId: string,
236
+ _content: string,
237
+ _attachmentIds?: string[],
238
+ options?: Record<string, unknown>,
239
+ ) => {
240
+ processCalls.push({ options });
241
+ const messageId = `msg-tc-interactive-${Date.now()}`;
242
+ const db = getDb();
243
+ db.insert(messages).values({
244
+ id: messageId,
245
+ conversationId,
246
+ role: 'user',
247
+ content: JSON.stringify([{ type: 'text', text: 'hello' }]),
248
+ createdAt: Date.now(),
249
+ }).run();
250
+ return { messageId };
251
+ });
252
+
253
+ const req = makeInboundRequest({
254
+ externalMessageId: `msg-tc-interactive-${Date.now()}`,
255
+ });
256
+
257
+ const res = await handleChannelInbound(req, processMessage as any, 'test-token');
258
+ const body = await res.json() as Record<string, unknown>;
259
+ expect(body.accepted).toBe(true);
260
+
261
+ // Wait for background processing
262
+ await new Promise((resolve) => setTimeout(resolve, 300));
263
+
264
+ expect(processCalls.length).toBeGreaterThan(0);
265
+ expect(processCalls[0].options?.isInteractive).toBe(true);
266
+ });
267
+
268
+ test('trusted contact WITHOUT guardian binding gets non-interactive turn (fail-fast)', async () => {
269
+ // No guardian binding created — trusted contact has no guardian route
270
+ // but findMember still returns an active member (trusted_contact trust class)
271
+
272
+ const processCalls: Array<{ options?: Record<string, unknown> }> = [];
273
+ const processMessage = mock(async (
274
+ conversationId: string,
275
+ _content: string,
276
+ _attachmentIds?: string[],
277
+ options?: Record<string, unknown>,
278
+ ) => {
279
+ processCalls.push({ options });
280
+ const messageId = `msg-tc-noroute-${Date.now()}`;
281
+ const db = getDb();
282
+ db.insert(messages).values({
283
+ id: messageId,
284
+ conversationId,
285
+ role: 'user',
286
+ content: JSON.stringify([{ type: 'text', text: 'hello' }]),
287
+ createdAt: Date.now(),
288
+ }).run();
289
+ return { messageId };
290
+ });
291
+
292
+ const req = makeInboundRequest({
293
+ externalMessageId: `msg-tc-noroute-${Date.now()}`,
294
+ });
295
+
296
+ const res = await handleChannelInbound(req, processMessage as any, 'test-token');
297
+ const body = await res.json() as Record<string, unknown>;
298
+ expect(body.accepted).toBe(true);
299
+
300
+ await new Promise((resolve) => setTimeout(resolve, 300));
301
+
302
+ expect(processCalls.length).toBeGreaterThan(0);
303
+ // Trusted contact without a guardian binding should NOT be interactive
304
+ // to prevent dead-end 300s prompt waits
305
+ expect(processCalls[0].options?.isInteractive).toBe(false);
306
+ });
307
+
308
+ test('guardian actors remain interactive regardless', async () => {
309
+ // Guardian binding matches the sender
310
+ createBinding({
311
+ assistantId: 'self',
312
+ channel: 'telegram',
313
+ guardianExternalUserId: 'telegram-user-default',
314
+ guardianDeliveryChatId: 'chat-123',
315
+ });
316
+
317
+ const processCalls: Array<{ options?: Record<string, unknown> }> = [];
318
+ const processMessage = mock(async (
319
+ conversationId: string,
320
+ _content: string,
321
+ _attachmentIds?: string[],
322
+ options?: Record<string, unknown>,
323
+ ) => {
324
+ processCalls.push({ options });
325
+ const messageId = `msg-guardian-${Date.now()}`;
326
+ const db = getDb();
327
+ db.insert(messages).values({
328
+ id: messageId,
329
+ conversationId,
330
+ role: 'user',
331
+ content: JSON.stringify([{ type: 'text', text: 'hello' }]),
332
+ createdAt: Date.now(),
333
+ }).run();
334
+ return { messageId };
335
+ });
336
+
337
+ const req = makeInboundRequest({
338
+ externalMessageId: `msg-guardian-${Date.now()}`,
339
+ });
340
+
341
+ const res = await handleChannelInbound(req, processMessage as any, 'test-token');
342
+ const body = await res.json() as Record<string, unknown>;
343
+ expect(body.accepted).toBe(true);
344
+
345
+ await new Promise((resolve) => setTimeout(resolve, 300));
346
+
347
+ expect(processCalls.length).toBeGreaterThan(0);
348
+ expect(processCalls[0].options?.isInteractive).toBe(true);
349
+ });
350
+
351
+ test('unknown actors remain non-interactive', async () => {
352
+ // No guardian binding, no member record => unknown trust class
353
+ mockFindMember = () => null;
354
+
355
+ const processCalls: Array<{ options?: Record<string, unknown> }> = [];
356
+ const processMessage = mock(async (
357
+ conversationId: string,
358
+ _content: string,
359
+ _attachmentIds?: string[],
360
+ options?: Record<string, unknown>,
361
+ ) => {
362
+ processCalls.push({ options });
363
+ const messageId = `msg-unknown-${Date.now()}`;
364
+ const db = getDb();
365
+ db.insert(messages).values({
366
+ id: messageId,
367
+ conversationId,
368
+ role: 'user',
369
+ content: JSON.stringify([{ type: 'text', text: 'hello' }]),
370
+ createdAt: Date.now(),
371
+ }).run();
372
+ return { messageId };
373
+ });
374
+
375
+ const req = makeInboundRequest({
376
+ externalMessageId: `msg-unknown-${Date.now()}`,
377
+ // No senderExternalUserId => unknown trust class
378
+ senderExternalUserId: undefined,
379
+ });
380
+
381
+ const res = await handleChannelInbound(req, processMessage as any, 'test-token');
382
+ const body = await res.json() as Record<string, unknown>;
383
+ expect(body.accepted).toBe(true);
384
+
385
+ await new Promise((resolve) => setTimeout(resolve, 300));
386
+
387
+ expect(processCalls.length).toBeGreaterThan(0);
388
+ expect(processCalls[0].options?.isInteractive).toBe(false);
389
+ });
390
+ });
391
+
392
+ // ═══════════════════════════════════════════════════════════════════════════
393
+ // Integration tests: channel-retry-sweep routing state
394
+ // ═══════════════════════════════════════════════════════════════════════════
395
+
396
+ describe('channel-retry-sweep routing state', () => {
397
+ beforeEach(() => {
398
+ resetTables();
399
+ mockFindMember = null;
400
+ });
401
+
402
+ function seedFailedEvent(trustClass: 'guardian' | 'trusted_contact' | 'unknown', guardianExternalUserId?: string): string {
403
+ const inbound = channelDeliveryStore.recordInbound('telegram', `chat-${trustClass}`, `msg-${trustClass}-${Date.now()}`);
404
+ channelDeliveryStore.storePayload(inbound.eventId, {
405
+ content: 'retry me',
406
+ sourceChannel: 'telegram',
407
+ interface: 'telegram',
408
+ guardianCtx: {
409
+ trustClass,
410
+ sourceChannel: 'telegram',
411
+ requesterExternalUserId: 'test-user',
412
+ requesterChatId: `chat-${trustClass}`,
413
+ ...(guardianExternalUserId ? { guardianExternalUserId } : {}),
414
+ },
415
+ });
416
+
417
+ const db = getDb();
418
+ db.update(channelInboundEvents)
419
+ .set({
420
+ processingStatus: 'failed',
421
+ processingAttempts: 1,
422
+ retryAfter: Date.now() - 1,
423
+ })
424
+ .where(eq(channelInboundEvents.id, inbound.eventId))
425
+ .run();
426
+
427
+ return inbound.eventId;
428
+ }
429
+
430
+ test('trusted_contact with guardian binding replays as interactive', async () => {
431
+ seedFailedEvent('trusted_contact', 'guardian-for-sweep');
432
+ let capturedOptions: { isInteractive?: boolean } | undefined;
433
+
434
+ await sweepFailedEvents(
435
+ async (conversationId, _content, _attachmentIds, options) => {
436
+ capturedOptions = options as { isInteractive?: boolean };
437
+ const messageId = `message-tc-sweep-${Date.now()}`;
438
+ const db = getDb();
439
+ db.insert(messages).values({
440
+ id: messageId,
441
+ conversationId,
442
+ role: 'user',
443
+ content: JSON.stringify([{ type: 'text', text: 'retry me' }]),
444
+ createdAt: Date.now(),
445
+ }).run();
446
+ return { messageId };
447
+ },
448
+ undefined,
449
+ );
450
+
451
+ expect(capturedOptions?.isInteractive).toBe(true);
452
+ });
453
+
454
+ test('trusted_contact without guardian binding replays as non-interactive', async () => {
455
+ seedFailedEvent('trusted_contact');
456
+ let capturedOptions: { isInteractive?: boolean } | undefined;
457
+
458
+ await sweepFailedEvents(
459
+ async (conversationId, _content, _attachmentIds, options) => {
460
+ capturedOptions = options as { isInteractive?: boolean };
461
+ const messageId = `message-tc-no-binding-${Date.now()}`;
462
+ const db = getDb();
463
+ db.insert(messages).values({
464
+ id: messageId,
465
+ conversationId,
466
+ role: 'user',
467
+ content: JSON.stringify([{ type: 'text', text: 'retry me' }]),
468
+ createdAt: Date.now(),
469
+ }).run();
470
+ return { messageId };
471
+ },
472
+ undefined,
473
+ );
474
+
475
+ expect(capturedOptions?.isInteractive).toBe(false);
476
+ });
477
+
478
+ test('guardian replays as interactive', async () => {
479
+ seedFailedEvent('guardian', 'guardian-self');
480
+ let capturedOptions: { isInteractive?: boolean } | undefined;
481
+
482
+ await sweepFailedEvents(
483
+ async (conversationId, _content, _attachmentIds, options) => {
484
+ capturedOptions = options as { isInteractive?: boolean };
485
+ const messageId = `message-guardian-sweep-${Date.now()}`;
486
+ const db = getDb();
487
+ db.insert(messages).values({
488
+ id: messageId,
489
+ conversationId,
490
+ role: 'user',
491
+ content: JSON.stringify([{ type: 'text', text: 'retry me' }]),
492
+ createdAt: Date.now(),
493
+ }).run();
494
+ return { messageId };
495
+ },
496
+ undefined,
497
+ );
498
+
499
+ expect(capturedOptions?.isInteractive).toBe(true);
500
+ });
501
+
502
+ test('unknown replays as non-interactive', async () => {
503
+ seedFailedEvent('unknown');
504
+ let capturedOptions: { isInteractive?: boolean } | undefined;
505
+
506
+ await sweepFailedEvents(
507
+ async (conversationId, _content, _attachmentIds, options) => {
508
+ capturedOptions = options as { isInteractive?: boolean };
509
+ const messageId = `message-unknown-sweep-${Date.now()}`;
510
+ const db = getDb();
511
+ db.insert(messages).values({
512
+ id: messageId,
513
+ conversationId,
514
+ role: 'user',
515
+ content: JSON.stringify([{ type: 'text', text: 'retry me' }]),
516
+ createdAt: Date.now(),
517
+ }).run();
518
+ return { messageId };
519
+ },
520
+ undefined,
521
+ );
522
+
523
+ expect(capturedOptions?.isInteractive).toBe(false);
524
+ });
525
+ });
@@ -40,6 +40,8 @@ describe('handleUserMessage secret redirect continuation', () => {
40
40
  setChannelCapabilities: () => {},
41
41
  setGuardianContext: () => {},
42
42
  setCommandIntent: () => {},
43
+ updateClient: () => {},
44
+ emitActivityState: () => {},
43
45
  processMessage: (content: string, _attachments: unknown[], _onEvent: unknown, requestId: string) => {
44
46
  processCalls.push({ content, requestId });
45
47
  return Promise.resolve();
@@ -908,8 +908,6 @@ describe('Telegram config handler', () => {
908
908
 
909
909
  import { handleGuardianVerification } from '../daemon/handlers/config.js';
910
910
  import type { GuardianVerificationRequest } from '../daemon/ipc-contract.js';
911
- import { createBinding } from '../memory/channel-guardian-store.js';
912
-
913
911
  describe('Guardian verification IPC actions', () => {
914
912
  beforeEach(() => {
915
913
  secureKeyStore = {};
@@ -971,85 +969,4 @@ describe('Guardian verification IPC actions', () => {
971
969
  expect(res.error).toContain('Unknown action');
972
970
  });
973
971
 
974
- test('create_challenge with explicit assistantId scopes challenge to that assistant', () => {
975
- const msg: GuardianVerificationRequest = {
976
- type: 'guardian_verification',
977
- action: 'create_challenge',
978
- channel: 'telegram',
979
- assistantId: 'asst-ipc-X',
980
- };
981
-
982
- const { ctx, sent } = createTestContext();
983
- handleGuardianVerification(msg, {} as net.Socket, ctx);
984
-
985
- expect(sent).toHaveLength(1);
986
- const res = sent[0] as { type: string; success: boolean; secret?: string; instruction?: string };
987
- expect(res.success).toBe(true);
988
- expect(res.secret).toBeDefined();
989
- expect(res.instruction).toContain('send');
990
- expect(res.instruction).toContain('code');
991
- });
992
-
993
- test('status action with explicit assistantId checks binding for that assistant', () => {
994
- // Create a control binding for a known assistant so we can verify
995
- // that querying a *different* assistantId actually returns bound=false
996
- // (not just because no bindings exist at all).
997
- createBinding({
998
- assistantId: 'asst-ipc-bound',
999
- channel: 'telegram',
1000
- guardianExternalUserId: 'guardian-user-1',
1001
- guardianDeliveryChatId: 'guardian-chat-1',
1002
- });
1003
-
1004
- // Querying a different assistant should return bound=false
1005
- const unboundMsg: GuardianVerificationRequest = {
1006
- type: 'guardian_verification',
1007
- action: 'status',
1008
- channel: 'telegram',
1009
- assistantId: 'asst-ipc-Y',
1010
- };
1011
-
1012
- const { ctx: ctx1, sent: sent1 } = createTestContext();
1013
- handleGuardianVerification(unboundMsg, {} as net.Socket, ctx1);
1014
-
1015
- expect(sent1).toHaveLength(1);
1016
- const unboundRes = sent1[0] as { type: string; success: boolean; bound: boolean };
1017
- expect(unboundRes.success).toBe(true);
1018
- expect(unboundRes.bound).toBe(false);
1019
-
1020
- // Querying the bound assistant should return bound=true
1021
- const boundMsg: GuardianVerificationRequest = {
1022
- type: 'guardian_verification',
1023
- action: 'status',
1024
- channel: 'telegram',
1025
- assistantId: 'asst-ipc-bound',
1026
- };
1027
-
1028
- const { ctx: ctx2, sent: sent2 } = createTestContext();
1029
- handleGuardianVerification(boundMsg, {} as net.Socket, ctx2);
1030
-
1031
- expect(sent2).toHaveLength(1);
1032
- const boundRes = sent2[0] as { type: string; success: boolean; bound: boolean; guardianExternalUserId?: string };
1033
- expect(boundRes.success).toBe(true);
1034
- expect(boundRes.bound).toBe(true);
1035
- expect(boundRes.guardianExternalUserId).toBe('guardian-user-1');
1036
- });
1037
-
1038
- test('assistantId defaults to "self" when not provided', () => {
1039
- // create_challenge without assistantId should scope to 'self'
1040
- const createMsg: GuardianVerificationRequest = {
1041
- type: 'guardian_verification',
1042
- action: 'create_challenge',
1043
- channel: 'telegram',
1044
- // assistantId intentionally omitted
1045
- };
1046
-
1047
- const { ctx: ctx1, sent: sent1 } = createTestContext();
1048
- handleGuardianVerification(createMsg, {} as net.Socket, ctx1);
1049
-
1050
- expect(sent1).toHaveLength(1);
1051
- const createRes = sent1[0] as { type: string; success: boolean; secret?: string };
1052
- expect(createRes.success).toBe(true);
1053
- expect(createRes.secret).toBeDefined();
1054
- });
1055
972
  });