@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
@@ -461,6 +461,57 @@ describe('AgentLoop', () => {
461
461
  expect(warningBlock).toBeDefined();
462
462
  });
463
463
 
464
+ test('runs without limit when maxToolUseTurns is 0', async () => {
465
+ // Use 20 turns (beyond old default of 8 used in other tests) to verify no cap
466
+ const turnCount = 20;
467
+ const responses: ProviderResponse[] = [];
468
+ for (let i = 0; i < turnCount; i++) {
469
+ responses.push(toolUseResponse(`t${i}`, 'read_file', { path: `/${i}.txt` }));
470
+ }
471
+ responses.push(textResponse('done'));
472
+ const { provider, calls } = createMockProvider(responses);
473
+ const toolExecutor = async () => ({ content: 'data', isError: false });
474
+ const loop = new AgentLoop(
475
+ provider,
476
+ 'system',
477
+ { maxToolUseTurns: 0, minTurnIntervalMs: 0 },
478
+ dummyTools,
479
+ toolExecutor,
480
+ );
481
+
482
+ const events: AgentEvent[] = [];
483
+ await loop.run([userMessage], collectEvents(events));
484
+
485
+ // All 20 tool turns + 1 final text response = 21 provider calls
486
+ expect(calls).toHaveLength(turnCount + 1);
487
+
488
+ // No hard-limit error events should have been emitted
489
+ const errorEvents = events.filter(
490
+ (e): e is Extract<AgentEvent, { type: 'error' }> => e.type === 'error',
491
+ );
492
+ expect(errorEvents).toHaveLength(0);
493
+
494
+ // Progress check reminders should still fire every 5 turns
495
+ const progressChecks = calls.filter((call) => {
496
+ const lastMsg = call.messages[call.messages.length - 1];
497
+ return lastMsg.content.some(
498
+ (b): b is Extract<ContentBlock, { type: 'text' }> =>
499
+ b.type === 'text' && b.text.includes('making meaningful progress'),
500
+ );
501
+ });
502
+ expect(progressChecks.length).toBeGreaterThanOrEqual(3);
503
+
504
+ // No approaching-limit warnings should appear
505
+ const limitWarnings = calls.filter((call) => {
506
+ const lastMsg = call.messages[call.messages.length - 1];
507
+ return lastMsg.content.some(
508
+ (b): b is Extract<ContentBlock, { type: 'text' }> =>
509
+ b.type === 'text' && b.text.includes('approaching the tool-use turn limit'),
510
+ );
511
+ });
512
+ expect(limitWarnings).toHaveLength(0);
513
+ });
514
+
464
515
  // 9. Tool executor error results are forwarded correctly
465
516
  test('forwards tool error results to provider', async () => {
466
517
  const { provider, calls } = createMockProvider([
@@ -83,6 +83,7 @@ function makeIdleSession(opts?: {
83
83
  setCommandIntent: () => {},
84
84
  setTurnChannelContext: () => {},
85
85
  setTurnInterfaceContext: () => {},
86
+ setStateSignalListener: () => {},
86
87
  updateClient: () => {},
87
88
  enqueueMessage: () => ({ queued: false, requestId: 'noop' }),
88
89
  hasAnyPendingConfirmation: () => false,
@@ -125,6 +126,7 @@ function makeConfirmationEmittingSession(opts?: {
125
126
  setCommandIntent: () => {},
126
127
  setTurnChannelContext: () => {},
127
128
  setTurnInterfaceContext: () => {},
129
+ setStateSignalListener: () => {},
128
130
  updateClient: () => {},
129
131
  enqueueMessage: () => ({ queued: false, requestId: 'noop' }),
130
132
  hasAnyPendingConfirmation: () => false,
@@ -150,7 +150,7 @@ describe('SSE route — capacity limit', () => {
150
150
 
151
151
  test('new connection evicts oldest and returns 200', async () => {
152
152
  const hub = new AssistantEventHub({ maxSubscribers: 1 });
153
- const opts = { hub, heartbeatIntervalMs: 60_000 };
153
+ const opts = { hub, heartbeatIntervalMs: 60_000, skipActorVerification: true as const };
154
154
 
155
155
  const ac1 = new AbortController();
156
156
  const req1 = new Request('http://localhost/v1/events?conversationKey=evict-a', { signal: ac1.signal });
@@ -181,7 +181,7 @@ describe('SSE route — capacity limit', () => {
181
181
  { signal: new AbortController().signal },
182
182
  );
183
183
 
184
- const response = handleSubscribeAssistantEvents(req, new URL(req.url), { hub });
184
+ const response = handleSubscribeAssistantEvents(req, new URL(req.url), { hub, skipActorVerification: true });
185
185
  expect(response.status).toBe(503);
186
186
  const body = await response.json() as { error: { message: string; code?: string } };
187
187
  expect(body.error.message).toMatch(/Too many concurrent connections/);
@@ -195,7 +195,7 @@ describe('SSE route — capacity limit', () => {
195
195
  { signal: ac.signal },
196
196
  );
197
197
 
198
- const response = handleSubscribeAssistantEvents(req, new URL(req.url), { hub });
198
+ const response = handleSubscribeAssistantEvents(req, new URL(req.url), { hub, skipActorVerification: true });
199
199
 
200
200
  expect(response.status).toBe(200);
201
201
  ac.abort(); // clean up the subscription
@@ -218,6 +218,7 @@ describe('SSE route — heartbeat', () => {
218
218
  const response = handleSubscribeAssistantEvents(req, new URL(req.url), {
219
219
  hub,
220
220
  heartbeatIntervalMs: 10,
221
+ skipActorVerification: true,
221
222
  });
222
223
 
223
224
  // Wait for at least one heartbeat interval to fire.
@@ -243,6 +244,7 @@ describe('SSE route — heartbeat', () => {
243
244
  const response = handleSubscribeAssistantEvents(req, new URL(req.url), {
244
245
  hub,
245
246
  heartbeatIntervalMs: 10,
247
+ skipActorVerification: true,
246
248
  });
247
249
 
248
250
  // Wait for several intervals.
@@ -283,7 +285,7 @@ describe('SSE route — disconnect cleanup', () => {
283
285
  { signal: ac.signal },
284
286
  );
285
287
 
286
- handleSubscribeAssistantEvents(req, new URL(req.url), { hub });
288
+ handleSubscribeAssistantEvents(req, new URL(req.url), { hub, skipActorVerification: true });
287
289
 
288
290
  expect(hub.subscriberCount()).toBe(1);
289
291
 
@@ -303,7 +305,7 @@ describe('SSE route — disconnect cleanup', () => {
303
305
  { signal: ac.signal },
304
306
  );
305
307
 
306
- const response = handleSubscribeAssistantEvents(req, new URL(req.url), { hub });
308
+ const response = handleSubscribeAssistantEvents(req, new URL(req.url), { hub, skipActorVerification: true });
307
309
 
308
310
  expect(hub.subscriberCount()).toBe(1);
309
311
 
@@ -0,0 +1,415 @@
1
+ import { execFileSync } from 'node:child_process';
2
+ import { readFileSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+
5
+ import { describe, expect, test } from 'bun:test';
6
+
7
+ import { DAEMON_INTERNAL_ASSISTANT_ID } from '../runtime/assistant-scope.js';
8
+
9
+ /**
10
+ * Guard tests for the assistant identity boundary.
11
+ *
12
+ * The daemon uses a fixed internal scope constant (`DAEMON_INTERNAL_ASSISTANT_ID`)
13
+ * for all assistant-scoped storage. Public assistant IDs are an edge concern
14
+ * handled by the gateway/platform layer — they must not leak into daemon
15
+ * scoping logic.
16
+ *
17
+ * These tests prevent regressions by scanning source files for banned patterns:
18
+ * - No `normalizeAssistantId` usage in daemon/runtime scoping modules
19
+ * - No assistant-scoped route handlers in the daemon HTTP server
20
+ * - No hardcoded `'self'` string for assistant scoping (use the constant)
21
+ * - The constant itself equals `'self'`
22
+ */
23
+
24
+ // ---------------------------------------------------------------------------
25
+ // Helpers
26
+ // ---------------------------------------------------------------------------
27
+
28
+ /** Resolve repo root (tests run from assistant/). */
29
+ function getRepoRoot(): string {
30
+ return join(process.cwd(), '..');
31
+ }
32
+
33
+ /**
34
+ * Directories containing daemon/runtime source files that must not reference
35
+ * `normalizeAssistantId` or hardcode assistant scope strings.
36
+ *
37
+ * Each directory gets both a `*.ts` glob (top-level files) and a `**\/*.ts`
38
+ * glob (nested files) so that `git grep` matches at all directory depths.
39
+ */
40
+ const SCANNED_DIRS = [
41
+ 'assistant/src/runtime',
42
+ 'assistant/src/daemon',
43
+ 'assistant/src/memory',
44
+ 'assistant/src/approvals',
45
+ 'assistant/src/calls',
46
+ 'assistant/src/tools',
47
+ ];
48
+
49
+ const SCANNED_DIR_GLOBS = SCANNED_DIRS.flatMap((dir) => [`${dir}/*.ts`, `${dir}/**/*.ts`]);
50
+
51
+ function isTestFile(filePath: string): boolean {
52
+ return (
53
+ filePath.includes('/__tests__/') ||
54
+ filePath.endsWith('.test.ts') ||
55
+ filePath.endsWith('.test.js') ||
56
+ filePath.endsWith('.spec.ts') ||
57
+ filePath.endsWith('.spec.js')
58
+ );
59
+ }
60
+
61
+ function isMigrationFile(filePath: string): boolean {
62
+ return filePath.includes('/migrations/');
63
+ }
64
+
65
+ // ---------------------------------------------------------------------------
66
+ // Tests
67
+ // ---------------------------------------------------------------------------
68
+
69
+ describe('assistant ID boundary', () => {
70
+ // -------------------------------------------------------------------------
71
+ // Rule (d): The DAEMON_INTERNAL_ASSISTANT_ID constant equals 'self'
72
+ // -------------------------------------------------------------------------
73
+
74
+ test('DAEMON_INTERNAL_ASSISTANT_ID equals "self"', () => {
75
+ expect(DAEMON_INTERNAL_ASSISTANT_ID).toBe('self');
76
+ });
77
+
78
+ // -------------------------------------------------------------------------
79
+ // Rule (a): No normalizeAssistantId in daemon scoping paths — spot check
80
+ // -------------------------------------------------------------------------
81
+
82
+ test('no normalizeAssistantId imports in daemon scoping paths', () => {
83
+ // Key daemon/runtime files that previously used normalizeAssistantId
84
+ // should now use DAEMON_INTERNAL_ASSISTANT_ID instead.
85
+ const daemonScopingFiles = [
86
+ 'runtime/actor-trust-resolver.ts',
87
+ 'runtime/guardian-outbound-actions.ts',
88
+ 'daemon/handlers/config-channels.ts',
89
+ 'runtime/routes/channel-route-shared.ts',
90
+ 'calls/relay-server.ts',
91
+ ];
92
+
93
+ const srcDir = join(import.meta.dir, '..');
94
+ for (const relPath of daemonScopingFiles) {
95
+ const content = readFileSync(join(srcDir, relPath), 'utf-8');
96
+ expect(content).not.toContain("import { normalizeAssistantId }");
97
+ expect(content).not.toContain("import { normalizeAssistantId,");
98
+ expect(content).not.toContain("normalizeAssistantId(");
99
+ }
100
+ });
101
+
102
+ // -------------------------------------------------------------------------
103
+ // Rule (a): No normalizeAssistantId in daemon/runtime directories — broad scan
104
+ // -------------------------------------------------------------------------
105
+
106
+ test('no normalizeAssistantId usage across daemon/runtime source directories', () => {
107
+ const repoRoot = getRepoRoot();
108
+
109
+ // Scan all daemon/runtime source directories for any reference to
110
+ // normalizeAssistantId. The function is defined in util/platform.ts for
111
+ // gateway use — it must not appear in daemon scoping modules.
112
+ let grepOutput = '';
113
+ try {
114
+ grepOutput = execFileSync(
115
+ 'git',
116
+ ['grep', '-lE', 'normalizeAssistantId', '--', ...SCANNED_DIR_GLOBS],
117
+ { encoding: 'utf-8', cwd: repoRoot },
118
+ ).trim();
119
+ } catch (err) {
120
+ // Exit code 1 means no matches — happy path
121
+ if ((err as { status?: number }).status === 1) {
122
+ return;
123
+ }
124
+ throw err;
125
+ }
126
+
127
+ const files = grepOutput.split('\n').filter((f) => f.length > 0);
128
+ const violations = files.filter((f) => !isTestFile(f));
129
+
130
+ if (violations.length > 0) {
131
+ const message = [
132
+ 'Found daemon/runtime source files that reference `normalizeAssistantId`.',
133
+ 'Daemon code should use the `DAEMON_INTERNAL_ASSISTANT_ID` constant instead.',
134
+ 'The `normalizeAssistantId` function is for gateway/platform use only (defined in util/platform.ts).',
135
+ '',
136
+ 'Violations:',
137
+ ...violations.map((f) => ` - ${f}`),
138
+ ].join('\n');
139
+
140
+ expect(violations, message).toEqual([]);
141
+ }
142
+ });
143
+
144
+ // -------------------------------------------------------------------------
145
+ // Rule (b): No assistant-scoped route registration in daemon HTTP server
146
+ // -------------------------------------------------------------------------
147
+
148
+ test('no /v1/assistants/:assistantId/ route handler registration in daemon HTTP server', () => {
149
+ const httpServerPath = join(import.meta.dir, '..', 'runtime', 'http-server.ts');
150
+ const content = readFileSync(httpServerPath, 'utf-8');
151
+
152
+ // The daemon HTTP server must not contain any assistant-scoped route
153
+ // patterns. All routes use flat /v1/<endpoint> paths; the gateway handles
154
+ // legacy assistant-scoped URL rewriting in its runtime proxy layer.
155
+
156
+ // Check that there's no regex extracting assistantId from a /v1/assistants/ path.
157
+ // Match both literal slashes (/v1/assistants/([) and escaped slashes in regex
158
+ // literals (\/v1\/assistants\/([) so we catch patterns like:
159
+ // endpoint.match(/^\/v1\/assistants\/([^/]+)\/(.+)$/)
160
+ const routeHandlerRegex = /\\?\/v1\\?\/assistants\\?\/\(\[/;
161
+ const match = content.match(routeHandlerRegex);
162
+ expect(
163
+ match,
164
+ 'Found a route pattern matching /v1/assistants/([^/]+)/... that extracts an assistantId. ' +
165
+ 'The daemon HTTP server should not have assistant-scoped route handlers — ' +
166
+ 'use flat /v1/<endpoint> paths instead.',
167
+ ).toBeNull();
168
+
169
+ // Scan the entire file for assistant-scoped path literals. No references
170
+ // to /v1/assistants/ should exist — the daemon uses flat paths only.
171
+ const lines = content.split('\n');
172
+ const violations: string[] = [];
173
+
174
+ for (let i = 0; i < lines.length; i++) {
175
+ const line = lines[i];
176
+ // Match both literal /v1/assistants/ and escaped \/v1\/assistants\/
177
+ if (line.includes('/v1/assistants/') || line.includes('\\/v1\\/assistants\\/')) {
178
+ violations.push(` line ${i + 1}: ${line.trim()}`);
179
+ }
180
+ }
181
+
182
+ expect(
183
+ violations,
184
+ 'Found /v1/assistants/ references in the daemon HTTP server — ' +
185
+ 'the daemon should not have assistant-scoped path literals.\n' +
186
+ violations.join('\n'),
187
+ ).toEqual([]);
188
+
189
+ // Guard against prefix-less assistants/ route patterns that extract an
190
+ // assistantId. dispatchEndpoint receives the endpoint *after* the /v1/
191
+ // prefix has been stripped, so a regex like `assistants\/([^/]+)` would
192
+ // capture an external assistant ID from the path — violating the
193
+ // assistant-scoping boundary.
194
+ const prefixLessViolations: string[] = [];
195
+ for (let i = 0; i < lines.length; i++) {
196
+ const line = lines[i];
197
+ // Match regex patterns like assistants\/([^/]+) that capture the ID
198
+ // segment. We look for the escaped-slash form used inside JS regex
199
+ // literals (e.g. /^assistants\/([^/]+)\//).
200
+ if (/assistants\\\/\(\[/.test(line)) {
201
+ prefixLessViolations.push(` line ${i + 1}: ${line.trim()}`);
202
+ }
203
+ }
204
+
205
+ expect(
206
+ prefixLessViolations,
207
+ 'Found prefix-less assistants/([^/]+) route pattern that extracts an assistantId. ' +
208
+ 'The daemon should not parse assistant IDs from URL paths — use ' +
209
+ 'DAEMON_INTERNAL_ASSISTANT_ID instead.\n' +
210
+ prefixLessViolations.join('\n'),
211
+ ).toEqual([]);
212
+ });
213
+
214
+ // -------------------------------------------------------------------------
215
+ // Rule (c): No hardcoded 'self' for assistant scoping in daemon files
216
+ // -------------------------------------------------------------------------
217
+
218
+ test('no hardcoded \'self\' string for assistant scoping in daemon source files', () => {
219
+ const repoRoot = getRepoRoot();
220
+
221
+ // Search for patterns where 'self' is used as an assistant ID value.
222
+ // We look for assignment / default / comparison patterns that suggest
223
+ // using the raw string instead of the DAEMON_INTERNAL_ASSISTANT_ID constant.
224
+ //
225
+ // Patterns matched:
226
+ // assistantId: 'self'
227
+ // assistantId = 'self'
228
+ // assistantId ?? 'self'
229
+ // ?? 'self' (fallback to self)
230
+ // || 'self' (fallback to self)
231
+ //
232
+ // Excluded:
233
+ // - Test files (they may legitimately assert against the value)
234
+ // - Migration files (SQL literals like DEFAULT 'self' are fine)
235
+ // - IPC contract files (comments documenting default values are fine)
236
+ // - CSP headers ('self' in Content-Security-Policy has nothing to do with assistant IDs)
237
+ const pattern = `(assistantId|assistant_id).*['"]self['"]`;
238
+
239
+ let grepOutput = '';
240
+ try {
241
+ grepOutput = execFileSync(
242
+ 'git',
243
+ ['grep', '-nE', pattern, '--', ...SCANNED_DIR_GLOBS],
244
+ { encoding: 'utf-8', cwd: repoRoot },
245
+ ).trim();
246
+ } catch (err) {
247
+ // Exit code 1 means no matches — happy path
248
+ if ((err as { status?: number }).status === 1) {
249
+ return;
250
+ }
251
+ throw err;
252
+ }
253
+
254
+ const lines = grepOutput.split('\n').filter((l) => l.length > 0);
255
+ const violations = lines.filter((line) => {
256
+ const filePath = line.split(':')[0];
257
+ if (isTestFile(filePath)) return false;
258
+ if (isMigrationFile(filePath)) return false;
259
+
260
+ // Allow comments (lines where the code portion starts with //)
261
+ const parts = line.split(':');
262
+ // parts[0] = file, parts[1] = line number, rest = content
263
+ const content = parts.slice(2).join(':').trim();
264
+ if (content.startsWith('//') || content.startsWith('*') || content.startsWith('/*')) {
265
+ return false;
266
+ }
267
+
268
+ return true;
269
+ });
270
+
271
+ if (violations.length > 0) {
272
+ const message = [
273
+ "Found daemon/runtime source files with hardcoded 'self' for assistant scoping.",
274
+ 'Use the `DAEMON_INTERNAL_ASSISTANT_ID` constant from `runtime/assistant-scope.ts` instead.',
275
+ '',
276
+ 'Violations:',
277
+ ...violations.map((v) => ` - ${v}`),
278
+ ].join('\n');
279
+
280
+ expect(violations, message).toEqual([]);
281
+ }
282
+ });
283
+
284
+ // -------------------------------------------------------------------------
285
+ // Rule (d): Daemon storage keys don't contain external assistant IDs
286
+ // (verified by the constant value test above — if the constant is 'self',
287
+ // all daemon storage keyed by DAEMON_INTERNAL_ASSISTANT_ID uses the fixed
288
+ // internal value rather than externally-provided IDs).
289
+ // -------------------------------------------------------------------------
290
+
291
+ // -------------------------------------------------------------------------
292
+ // Rule (e): No assistantId on daemon control-plane request/param types
293
+ //
294
+ // Daemon IPC contracts and guardian outbound param interfaces must not
295
+ // accept an assistantId field -- the daemon always uses
296
+ // DAEMON_INTERNAL_ASSISTANT_ID internally. Accepting assistantId on these
297
+ // surfaces invites callers to pass external IDs into daemon scoping.
298
+ // -------------------------------------------------------------------------
299
+
300
+ test('IPC contract types do not contain assistantId for guardian requests', () => {
301
+ const ipcContractPath = join(import.meta.dir, '..', 'daemon', 'ipc-contract', 'integrations.ts');
302
+ const content = readFileSync(ipcContractPath, 'utf-8');
303
+
304
+ // Extract the interface blocks for the request types and verify
305
+ // none of them declare an assistantId property.
306
+ const requestTypeNames = [
307
+ 'GuardianVerificationRequest',
308
+ ];
309
+
310
+ for (const typeName of requestTypeNames) {
311
+ // Find the interface/type block — match from the type name to the next
312
+ // closing brace at the same indentation level. We use a simple heuristic:
313
+ // find the line declaring the type, then scan forward to the closing '}'.
314
+ const typeIndex = content.indexOf(typeName);
315
+ expect(typeIndex, `Expected to find ${typeName} in IPC contract`).toBeGreaterThan(-1);
316
+
317
+ // Extract from the type declaration to the next '}' line
318
+ const blockStart = content.indexOf('{', typeIndex);
319
+ if (blockStart === -1) continue;
320
+ let braceDepth = 0;
321
+ let blockEnd = blockStart;
322
+ for (let i = blockStart; i < content.length; i++) {
323
+ if (content[i] === '{') braceDepth++;
324
+ if (content[i] === '}') braceDepth--;
325
+ if (braceDepth === 0) {
326
+ blockEnd = i + 1;
327
+ break;
328
+ }
329
+ }
330
+ const block = content.slice(blockStart, blockEnd);
331
+
332
+ // The block should not contain an assistantId property declaration
333
+ // (matches "assistantId?" or "assistantId:" on a non-comment line)
334
+ const lines = block.split('\n');
335
+ for (const line of lines) {
336
+ const trimmed = line.trim();
337
+ if (trimmed.startsWith('//') || trimmed.startsWith('*') || trimmed.startsWith('/*')) continue;
338
+ expect(
339
+ /\bassistantId\s*[?:]/.test(trimmed),
340
+ `${typeName} must not declare an assistantId property. Found: "${trimmed}"`,
341
+ ).toBe(false);
342
+ }
343
+ }
344
+ });
345
+
346
+ test('guardian outbound param interfaces do not contain assistantId', () => {
347
+ const actionsPath = join(import.meta.dir, '..', 'runtime', 'guardian-outbound-actions.ts');
348
+ const content = readFileSync(actionsPath, 'utf-8');
349
+
350
+ const interfaceNames = [
351
+ 'StartOutboundParams',
352
+ 'ResendOutboundParams',
353
+ 'CancelOutboundParams',
354
+ ];
355
+
356
+ for (const name of interfaceNames) {
357
+ const idx = content.indexOf(name);
358
+ expect(idx, `Expected to find ${name} in guardian-outbound-actions.ts`).toBeGreaterThan(-1);
359
+
360
+ const blockStart = content.indexOf('{', idx);
361
+ if (blockStart === -1) continue;
362
+ let braceDepth = 0;
363
+ let blockEnd = blockStart;
364
+ for (let i = blockStart; i < content.length; i++) {
365
+ if (content[i] === '{') braceDepth++;
366
+ if (content[i] === '}') braceDepth--;
367
+ if (braceDepth === 0) {
368
+ blockEnd = i + 1;
369
+ break;
370
+ }
371
+ }
372
+ const block = content.slice(blockStart, blockEnd);
373
+
374
+ const lines = block.split('\n');
375
+ for (const line of lines) {
376
+ const trimmed = line.trim();
377
+ if (trimmed.startsWith('//') || trimmed.startsWith('*') || trimmed.startsWith('/*')) continue;
378
+ expect(
379
+ /\bassistantId\s*[?:]/.test(trimmed),
380
+ `${name} must not declare an assistantId property. Found: "${trimmed}"`,
381
+ ).toBe(false);
382
+ }
383
+ }
384
+ });
385
+
386
+ test('channel readiness service does not accept assistantId parameter', () => {
387
+ const servicePath = join(import.meta.dir, '..', 'runtime', 'channel-readiness-service.ts');
388
+ const content = readFileSync(servicePath, 'utf-8');
389
+
390
+ // getReadiness and invalidateChannel signatures must not include assistantId
391
+ const signaturePatterns = [
392
+ /getReadiness\([^)]*assistantId/,
393
+ /invalidateChannel\([^)]*assistantId/,
394
+ ];
395
+ for (const pattern of signaturePatterns) {
396
+ expect(
397
+ pattern.test(content),
398
+ `Channel readiness service must not accept assistantId parameter (matched: ${pattern})`,
399
+ ).toBe(false);
400
+ }
401
+
402
+ // ChannelProbeContext must not have assistantId.
403
+ // The interface is declared in channel-readiness-types.ts, not the service file.
404
+ const typesPath = join(import.meta.dir, '..', 'runtime', 'channel-readiness-types.ts');
405
+ const typesContent = readFileSync(typesPath, 'utf-8');
406
+ const probeContextMatch = typesContent.match(/interface\s+ChannelProbeContext\s*\{([^}]*)\}/);
407
+ expect(probeContextMatch, 'Expected to find ChannelProbeContext interface in channel-readiness-types.ts').not.toBeNull();
408
+ if (probeContextMatch) {
409
+ expect(
410
+ probeContextMatch[1],
411
+ 'ChannelProbeContext must not contain assistantId',
412
+ ).not.toContain('assistantId');
413
+ }
414
+ });
415
+ });
@@ -56,10 +56,12 @@ mock.module('../config/loader.js', () => ({
56
56
  // ── Call constants mock ──────────────────────────────────────────────
57
57
 
58
58
  let mockConsultationTimeoutMs = 90_000;
59
+ let mockSilenceTimeoutMs = 30_000;
59
60
 
60
61
  mock.module('../calls/call-constants.js', () => ({
61
62
  getMaxCallDurationMs: () => 12 * 60 * 1000,
62
63
  getUserConsultationTimeoutMs: () => mockConsultationTimeoutMs,
64
+ getSilenceTimeoutMs: () => mockSilenceTimeoutMs,
63
65
  SILENCE_TIMEOUT_MS: 30_000,
64
66
  MAX_CALL_DURATION_MS: 3600 * 1000,
65
67
  USER_CONSULTATION_TIMEOUT_MS: 120 * 1000,
@@ -154,6 +156,7 @@ interface MockRelay extends RelayConnection {
154
156
  sentTokens: Array<{ token: string; last: boolean }>;
155
157
  endCalled: boolean;
156
158
  endReason: string | undefined;
159
+ mockConnectionState: string;
157
160
  }
158
161
 
159
162
  function createMockRelay(): MockRelay {
@@ -161,12 +164,15 @@ function createMockRelay(): MockRelay {
161
164
  sentTokens: [] as Array<{ token: string; last: boolean }>,
162
165
  _endCalled: false,
163
166
  _endReason: undefined as string | undefined,
167
+ _connectionState: 'connected',
164
168
  };
165
169
 
166
170
  return {
167
171
  get sentTokens() { return state.sentTokens; },
168
172
  get endCalled() { return state._endCalled; },
169
173
  get endReason() { return state._endReason; },
174
+ get mockConnectionState() { return state._connectionState; },
175
+ set mockConnectionState(v: string) { state._connectionState = v; },
170
176
  sendTextToken(token: string, last: boolean) {
171
177
  state.sentTokens.push({ token, last });
172
178
  },
@@ -174,6 +180,9 @@ function createMockRelay(): MockRelay {
174
180
  state._endCalled = true;
175
181
  state._endReason = reason;
176
182
  },
183
+ getConnectionState() {
184
+ return state._connectionState;
185
+ },
177
186
  } as unknown as MockRelay;
178
187
  }
179
188
 
@@ -236,6 +245,7 @@ describe('call-controller', () => {
236
245
  mockStartVoiceTurn.mockImplementation(createMockVoiceTurn(['Hello', ' there']));
237
246
  // Reset consultation timeout to the default (long) value
238
247
  mockConsultationTimeoutMs = 90_000;
248
+ mockSilenceTimeoutMs = 30_000;
239
249
  });
240
250
 
241
251
  // ── handleCallerUtterance ─────────────────────────────────────────
@@ -1697,4 +1707,43 @@ describe('call-controller', () => {
1697
1707
 
1698
1708
  controller.destroy();
1699
1709
  });
1710
+
1711
+ // ── Silence suppression during guardian wait ──────────────────────
1712
+
1713
+ test('silence timeout suppressed during guardian wait: does not say "Are you still there?"', async () => {
1714
+ mockSilenceTimeoutMs = 50; // Short timeout for testing
1715
+ const { relay, controller } = setupController();
1716
+
1717
+ // Simulate guardian wait state on the relay
1718
+ relay.mockConnectionState = 'awaiting_guardian_decision';
1719
+
1720
+ // Wait for the silence timeout to fire
1721
+ await new Promise((r) => setTimeout(r, 200));
1722
+
1723
+ // "Are you still there?" should NOT have been sent
1724
+ const silenceTokens = relay.sentTokens.filter((t) =>
1725
+ t.token.includes('Are you still there?'),
1726
+ );
1727
+ expect(silenceTokens.length).toBe(0);
1728
+
1729
+ controller.destroy();
1730
+ });
1731
+
1732
+ test('silence timeout fires normally when not in guardian wait', async () => {
1733
+ mockSilenceTimeoutMs = 50; // Short timeout for testing
1734
+ const { relay, controller } = setupController();
1735
+
1736
+ // Default connection state is 'connected' (not guardian wait)
1737
+
1738
+ // Wait for the silence timeout to fire
1739
+ await new Promise((r) => setTimeout(r, 200));
1740
+
1741
+ // "Are you still there?" SHOULD have been sent
1742
+ const silenceTokens = relay.sentTokens.filter((t) =>
1743
+ t.token.includes('Are you still there?'),
1744
+ );
1745
+ expect(silenceTokens.length).toBe(1);
1746
+
1747
+ controller.destroy();
1748
+ });
1700
1749
  });