@vellumai/assistant 0.3.4 → 0.3.5

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 (122) hide show
  1. package/Dockerfile +2 -0
  2. package/README.md +37 -2
  3. package/package.json +1 -1
  4. package/scripts/ipc/generate-swift.ts +13 -0
  5. package/src/__tests__/__snapshots__/ipc-snapshot.test.ts.snap +100 -0
  6. package/src/__tests__/approval-hardcoded-copy-guard.test.ts +41 -0
  7. package/src/__tests__/approval-message-composer.test.ts +253 -0
  8. package/src/__tests__/call-domain.test.ts +12 -2
  9. package/src/__tests__/call-orchestrator.test.ts +70 -1
  10. package/src/__tests__/call-routes-http.test.ts +27 -2
  11. package/src/__tests__/channel-approval-routes.test.ts +21 -17
  12. package/src/__tests__/channel-approvals.test.ts +48 -1
  13. package/src/__tests__/channel-guardian.test.ts +74 -22
  14. package/src/__tests__/channel-readiness-service.test.ts +257 -0
  15. package/src/__tests__/config-schema.test.ts +2 -1
  16. package/src/__tests__/credential-security-invariants.test.ts +1 -0
  17. package/src/__tests__/daemon-lifecycle.test.ts +13 -12
  18. package/src/__tests__/dictation-mode-detection.test.ts +63 -0
  19. package/src/__tests__/entity-search.test.ts +615 -0
  20. package/src/__tests__/handlers-twilio-config.test.ts +407 -0
  21. package/src/__tests__/ipc-snapshot.test.ts +63 -0
  22. package/src/__tests__/messaging-send-tool.test.ts +65 -0
  23. package/src/__tests__/run-orchestrator-assistant-events.test.ts +4 -0
  24. package/src/__tests__/run-orchestrator.test.ts +22 -0
  25. package/src/__tests__/session-runtime-assembly.test.ts +85 -1
  26. package/src/__tests__/sms-messaging-provider.test.ts +125 -0
  27. package/src/__tests__/twilio-routes.test.ts +39 -3
  28. package/src/__tests__/twitter-cli-error-shaping.test.ts +2 -2
  29. package/src/__tests__/web-search.test.ts +1 -1
  30. package/src/__tests__/work-item-output.test.ts +110 -0
  31. package/src/calls/call-domain.ts +8 -5
  32. package/src/calls/call-orchestrator.ts +22 -11
  33. package/src/calls/twilio-config.ts +17 -11
  34. package/src/calls/twilio-rest.ts +276 -0
  35. package/src/calls/twilio-routes.ts +39 -1
  36. package/src/config/bundled-skills/knowledge-graph/SKILL.md +15 -0
  37. package/src/config/bundled-skills/knowledge-graph/TOOLS.json +56 -0
  38. package/src/config/bundled-skills/knowledge-graph/tools/graph-query.ts +185 -0
  39. package/src/config/bundled-skills/media-processing/SKILL.md +199 -0
  40. package/src/config/bundled-skills/media-processing/TOOLS.json +320 -0
  41. package/src/config/bundled-skills/media-processing/services/capability-registry.ts +137 -0
  42. package/src/config/bundled-skills/media-processing/services/event-detection-service.ts +280 -0
  43. package/src/config/bundled-skills/media-processing/services/feedback-aggregation.ts +144 -0
  44. package/src/config/bundled-skills/media-processing/services/feedback-store.ts +136 -0
  45. package/src/config/bundled-skills/media-processing/services/processing-pipeline.ts +261 -0
  46. package/src/config/bundled-skills/media-processing/services/retrieval-service.ts +95 -0
  47. package/src/config/bundled-skills/media-processing/services/timeline-service.ts +267 -0
  48. package/src/config/bundled-skills/media-processing/tools/analyze-keyframes.ts +301 -0
  49. package/src/config/bundled-skills/media-processing/tools/detect-events.ts +110 -0
  50. package/src/config/bundled-skills/media-processing/tools/extract-keyframes.ts +190 -0
  51. package/src/config/bundled-skills/media-processing/tools/generate-clip.ts +195 -0
  52. package/src/config/bundled-skills/media-processing/tools/ingest-media.ts +197 -0
  53. package/src/config/bundled-skills/media-processing/tools/media-diagnostics.ts +166 -0
  54. package/src/config/bundled-skills/media-processing/tools/media-status.ts +75 -0
  55. package/src/config/bundled-skills/media-processing/tools/query-media-events.ts +300 -0
  56. package/src/config/bundled-skills/media-processing/tools/recalibrate.ts +235 -0
  57. package/src/config/bundled-skills/media-processing/tools/select-tracking-profile.ts +142 -0
  58. package/src/config/bundled-skills/media-processing/tools/submit-feedback.ts +150 -0
  59. package/src/config/bundled-skills/messaging/SKILL.md +21 -6
  60. package/src/config/bundled-skills/messaging/tools/messaging-send.ts +5 -1
  61. package/src/config/bundled-skills/phone-calls/SKILL.md +2 -2
  62. package/src/config/bundled-skills/twitter/SKILL.md +19 -3
  63. package/src/config/defaults.ts +2 -1
  64. package/src/config/schema.ts +9 -3
  65. package/src/config/system-prompt.ts +24 -0
  66. package/src/config/templates/IDENTITY.md +2 -2
  67. package/src/config/vellum-skills/catalog.json +6 -0
  68. package/src/config/vellum-skills/google-oauth-setup/SKILL.md +3 -3
  69. package/src/config/vellum-skills/slack-oauth-setup/SKILL.md +3 -3
  70. package/src/config/vellum-skills/sms-setup/SKILL.md +118 -0
  71. package/src/config/vellum-skills/twilio-setup/SKILL.md +40 -8
  72. package/src/daemon/handlers/config.ts +783 -9
  73. package/src/daemon/handlers/dictation.ts +182 -0
  74. package/src/daemon/handlers/identity.ts +14 -23
  75. package/src/daemon/handlers/index.ts +2 -0
  76. package/src/daemon/handlers/sessions.ts +2 -0
  77. package/src/daemon/handlers/shared.ts +3 -0
  78. package/src/daemon/handlers/work-items.ts +15 -7
  79. package/src/daemon/ipc-contract-inventory.json +10 -0
  80. package/src/daemon/ipc-contract.ts +108 -4
  81. package/src/daemon/lifecycle.ts +2 -0
  82. package/src/daemon/ride-shotgun-handler.ts +1 -1
  83. package/src/daemon/server.ts +6 -2
  84. package/src/daemon/session-agent-loop.ts +5 -1
  85. package/src/daemon/session-runtime-assembly.ts +55 -0
  86. package/src/daemon/session-tool-setup.ts +2 -0
  87. package/src/daemon/session.ts +11 -1
  88. package/src/inbound/public-ingress-urls.ts +3 -3
  89. package/src/memory/channel-guardian-store.ts +2 -1
  90. package/src/memory/db-init.ts +144 -0
  91. package/src/memory/job-handlers/media-processing.ts +100 -0
  92. package/src/memory/jobs-store.ts +2 -1
  93. package/src/memory/jobs-worker.ts +4 -0
  94. package/src/memory/media-store.ts +759 -0
  95. package/src/memory/retriever.ts +6 -1
  96. package/src/memory/schema.ts +98 -0
  97. package/src/memory/search/entity.ts +208 -25
  98. package/src/memory/search/ranking.ts +6 -1
  99. package/src/memory/search/types.ts +24 -0
  100. package/src/messaging/provider-types.ts +2 -0
  101. package/src/messaging/providers/sms/adapter.ts +204 -0
  102. package/src/messaging/providers/sms/client.ts +93 -0
  103. package/src/messaging/providers/sms/types.ts +7 -0
  104. package/src/permissions/checker.ts +16 -2
  105. package/src/runtime/approval-message-composer.ts +143 -0
  106. package/src/runtime/channel-approvals.ts +12 -4
  107. package/src/runtime/channel-guardian-service.ts +44 -18
  108. package/src/runtime/channel-readiness-service.ts +292 -0
  109. package/src/runtime/channel-readiness-types.ts +29 -0
  110. package/src/runtime/http-server.ts +53 -27
  111. package/src/runtime/http-types.ts +3 -0
  112. package/src/runtime/routes/call-routes.ts +2 -1
  113. package/src/runtime/routes/channel-routes.ts +67 -21
  114. package/src/runtime/run-orchestrator.ts +35 -2
  115. package/src/tools/assets/materialize.ts +2 -2
  116. package/src/tools/calls/call-start.ts +1 -0
  117. package/src/tools/credentials/vault.ts +1 -1
  118. package/src/tools/execution-target.ts +11 -1
  119. package/src/tools/network/web-search.ts +1 -1
  120. package/src/tools/types.ts +2 -0
  121. package/src/twitter/router.ts +1 -1
  122. package/src/util/platform.ts +35 -0
@@ -80,10 +80,10 @@ mock.module('../calls/twilio-provider.js', () => ({
80
80
 
81
81
  // Mock Twilio config
82
82
  mock.module('../calls/twilio-config.js', () => ({
83
- getTwilioConfig: () => ({
83
+ getTwilioConfig: (assistantId?: string) => ({
84
84
  accountSid: 'AC_test',
85
85
  authToken: 'test_token',
86
- phoneNumber: '+15550001111',
86
+ phoneNumber: assistantId === 'asst-alpha' ? '+15550009999' : '+15550001111',
87
87
  webhookBaseUrl: 'https://test.example.com',
88
88
  wssBaseUrl: 'wss://test.example.com',
89
89
  }),
@@ -168,6 +168,10 @@ describe('runtime call routes — HTTP layer', () => {
168
168
  return `http://127.0.0.1:${port}/v1/calls${path}`;
169
169
  }
170
170
 
171
+ function assistantCallsUrl(assistantId: string, path = ''): string {
172
+ return `http://127.0.0.1:${port}/v1/assistants/${assistantId}/calls${path}`;
173
+ }
174
+
171
175
  // ── POST /v1/calls/start ────────────────────────────────────────────
172
176
 
173
177
  test('POST /v1/calls/start returns 201 with call session', async () => {
@@ -222,6 +226,27 @@ describe('runtime call routes — HTTP layer', () => {
222
226
  await stopServer();
223
227
  });
224
228
 
229
+ test('POST /v1/assistants/:assistantId/calls/start uses assistant-scoped caller number', async () => {
230
+ await startServer();
231
+ ensureConversation('conv-start-scoped-1');
232
+
233
+ const res = await fetch(assistantCallsUrl('asst-alpha', '/start'), {
234
+ method: 'POST',
235
+ headers: { 'Content-Type': 'application/json', ...AUTH_HEADERS },
236
+ body: JSON.stringify({
237
+ phoneNumber: '+15559997777',
238
+ task: 'Check order status',
239
+ conversationId: 'conv-start-scoped-1',
240
+ }),
241
+ });
242
+
243
+ expect(res.status).toBe(201);
244
+ const body = await res.json() as { fromNumber: string };
245
+ expect(body.fromNumber).toBe('+15550009999');
246
+
247
+ await stopServer();
248
+ });
249
+
225
250
  test('POST /v1/calls/start returns 400 for invalid phone number', async () => {
226
251
  await startServer();
227
252
  ensureConversation('conv-start-2');
@@ -1635,7 +1635,9 @@ describe('SMS guardian verify intercept', () => {
1635
1635
  const replyArgs = deliverSpy.mock.calls[0];
1636
1636
  const replyPayload = replyArgs[1] as { chatId: string; text: string };
1637
1637
  expect(replyPayload.chatId).toBe('sms-chat-verify');
1638
- expect(replyPayload.text).toContain('Guardian verified successfully');
1638
+ expect(typeof replyPayload.text).toBe('string');
1639
+ expect(replyPayload.text.toLowerCase()).toContain('guardian');
1640
+ expect(replyPayload.text.toLowerCase()).toContain('verif');
1639
1641
 
1640
1642
  deliverSpy.mockRestore();
1641
1643
  });
@@ -1668,7 +1670,9 @@ describe('SMS guardian verify intercept', () => {
1668
1670
  expect(deliverSpy).toHaveBeenCalled();
1669
1671
  const replyArgs = deliverSpy.mock.calls[0];
1670
1672
  const replyPayload = replyArgs[1] as { chatId: string; text: string };
1671
- expect(replyPayload.text).toContain('Verification failed');
1673
+ expect(typeof replyPayload.text).toBe('string');
1674
+ expect(replyPayload.text.toLowerCase()).toContain('verif');
1675
+ expect(replyPayload.text.toLowerCase()).toContain('failed');
1672
1676
 
1673
1677
  deliverSpy.mockRestore();
1674
1678
  });
@@ -1944,11 +1948,11 @@ describe('fail-closed guardian gate — unverified channel', () => {
1944
1948
 
1945
1949
  // The deny decision should carry guardian setup context for assistant reply generation.
1946
1950
  expect(typeof decisionArgs[2]).toBe('string');
1947
- expect((decisionArgs[2] as string)).toContain('no guardian is configured');
1951
+ expect((decisionArgs[2] as string).toLowerCase()).toContain('no guardian');
1948
1952
 
1949
1953
  // The runtime should not send a second deterministic denial notice.
1950
1954
  const deterministicNoticeCalls = deliverSpy.mock.calls.filter(
1951
- (call) => typeof call[1] === 'object' && (call[1] as { text?: string }).text?.includes('no guardian has been set up'),
1955
+ (call) => typeof call[1] === 'object' && (call[1] as { text?: string }).text?.toLowerCase().includes('no guardian'),
1952
1956
  );
1953
1957
  expect(deterministicNoticeCalls.length).toBe(0);
1954
1958
 
@@ -2045,11 +2049,11 @@ describe('fail-closed guardian gate — unverified channel', () => {
2045
2049
  const lastDecision = submitCalls[submitCalls.length - 1];
2046
2050
  expect(lastDecision[1]).toBe('deny');
2047
2051
  expect(typeof lastDecision[2]).toBe('string');
2048
- expect((lastDecision[2] as string)).toContain('no guardian is configured');
2052
+ expect((lastDecision[2] as string).toLowerCase()).toContain('no guardian');
2049
2053
 
2050
2054
  // Interception should not emit a separate deterministic denial notice.
2051
2055
  const denialCalls = deliverSpy.mock.calls.filter(
2052
- (call) => typeof call[1] === 'object' && (call[1] as { text?: string }).text?.includes('no guardian has been set up'),
2056
+ (call) => typeof call[1] === 'object' && (call[1] as { text?: string }).text?.toLowerCase().includes('no guardian'),
2053
2057
  );
2054
2058
  expect(denialCalls.length).toBe(0);
2055
2059
 
@@ -2092,9 +2096,9 @@ describe('guardian-with-binding path regression', () => {
2092
2096
  const approvalArgs = approvalSpy.mock.calls[0];
2093
2097
  expect(approvalArgs[1]).toBe('guardian-chat-1');
2094
2098
 
2095
- // Requester should have been notified the request was sent to the guardian
2099
+ // Requester should have been notified the request was forwarded to the guardian
2096
2100
  const notifyCalls = deliverSpy.mock.calls.filter(
2097
- (call) => typeof call[1] === 'object' && (call[1] as { text?: string }).text?.includes('has been sent to the guardian for approval'),
2101
+ (call) => typeof call[1] === 'object' && (call[1] as { text?: string }).text?.toLowerCase().includes('guardian'),
2098
2102
  );
2099
2103
  expect(notifyCalls.length).toBeGreaterThanOrEqual(1);
2100
2104
 
@@ -2223,14 +2227,14 @@ describe('guardian delivery failure → denial', () => {
2223
2227
 
2224
2228
  // Requester should have been notified that delivery failed
2225
2229
  const failureCalls = deliverSpy.mock.calls.filter(
2226
- (call) => typeof call[1] === 'object' && (call[1] as { text?: string }).text?.includes('could not be sent to the guardian for approval'),
2230
+ (call) => typeof call[1] === 'object' && (call[1] as { text?: string }).text?.toLowerCase().includes('denied'),
2227
2231
  );
2228
2232
  expect(failureCalls.length).toBeGreaterThanOrEqual(1);
2229
2233
 
2230
- // The "has been sent to the guardian for approval" success notice should
2231
- // NOT have been delivered (since delivery failed).
2234
+ // The guardian_request_forwarded success notice should NOT have been
2235
+ // delivered (since delivery failed).
2232
2236
  const successCalls = deliverSpy.mock.calls.filter(
2233
- (call) => typeof call[1] === 'object' && (call[1] as { text?: string }).text?.includes('has been sent to the guardian for approval'),
2237
+ (call) => typeof call[1] === 'object' && (call[1] as { text?: string }).text?.toLowerCase().includes('forwarded'),
2234
2238
  );
2235
2239
  expect(successCalls.length).toBe(0);
2236
2240
 
@@ -2487,7 +2491,7 @@ describe('ambiguous plain-text decision with multiple pending requests', () => {
2487
2491
 
2488
2492
  // A disambiguation message should have been sent to the guardian
2489
2493
  const disambigCalls = deliverSpy.mock.calls.filter(
2490
- (call) => typeof call[1] === 'object' && (call[1] as { text?: string }).text?.includes('pending approval requests'),
2494
+ (call) => typeof call[1] === 'object' && (call[1] as { text?: string }).text?.toLowerCase().includes('pending'),
2491
2495
  );
2492
2496
  expect(disambigCalls.length).toBeGreaterThanOrEqual(1);
2493
2497
 
@@ -3178,12 +3182,12 @@ describe('guardian enforcement behavior', () => {
3178
3182
  const lastDecision = submitCalls[submitCalls.length - 1];
3179
3183
  expect(lastDecision[1]).toBe('deny');
3180
3184
  expect(typeof lastDecision[2]).toBe('string');
3181
- expect((lastDecision[2] as string)).toContain('identity could not be verified');
3185
+ expect((lastDecision[2] as string).toLowerCase()).toContain('identity');
3182
3186
 
3183
3187
  // No separate deterministic denial notice should be emitted here.
3184
3188
  const denialCalls = deliverSpy.mock.calls.filter(
3185
3189
  (call) => typeof call[1] === 'object'
3186
- && ((call[1] as { text?: string }).text ?? '').includes('identity could not be determined'),
3190
+ && ((call[1] as { text?: string }).text ?? '').toLowerCase().includes('identity'),
3187
3191
  );
3188
3192
  expect(denialCalls.length).toBe(0);
3189
3193
 
@@ -3217,11 +3221,11 @@ describe('guardian enforcement behavior', () => {
3217
3221
  const lastDecision = submitCalls[submitCalls.length - 1];
3218
3222
  expect(lastDecision[1]).toBe('deny');
3219
3223
  expect(typeof lastDecision[2]).toBe('string');
3220
- expect((lastDecision[2] as string)).toContain('identity could not be verified');
3224
+ expect((lastDecision[2] as string).toLowerCase()).toContain('identity');
3221
3225
 
3222
3226
  const denialCalls = deliverSpy.mock.calls.filter(
3223
3227
  (call) => typeof call[1] === 'object'
3224
- && ((call[1] as { text?: string }).text ?? '').includes('identity could not be determined'),
3228
+ && ((call[1] as { text?: string }).text ?? '').toLowerCase().includes('identity'),
3225
3229
  );
3226
3230
  expect(denialCalls.length).toBe(0);
3227
3231
  expect(approvalSpy).not.toHaveBeenCalled();
@@ -41,6 +41,7 @@ import {
41
41
  buildApprovalUIMetadata,
42
42
  handleChannelDecision,
43
43
  buildReminderPrompt,
44
+ buildGuardianApprovalPrompt,
44
45
  channelSupportsRichApprovalUI,
45
46
  } from '../runtime/channel-approvals.js';
46
47
  import type { ApprovalDecisionResult, ChannelApprovalPrompt } from '../runtime/channel-approval-types.js';
@@ -547,7 +548,53 @@ describe('buildReminderPrompt', () => {
547
548
  });
548
549
 
549
550
  // ═══════════════════════════════════════════════════════════════════════════
550
- // 5. channelSupportsRichApprovalUI
551
+ // 5. buildGuardianApprovalPrompt
552
+ // ═══════════════════════════════════════════════════════════════════════════
553
+
554
+ describe('buildGuardianApprovalPrompt', () => {
555
+ test('prompt includes requester identifier and tool name', () => {
556
+ const runInfo: PendingRunInfo = {
557
+ runId: 'run-g1',
558
+ requestId: 'req-g1',
559
+ toolName: 'deploy',
560
+ input: {},
561
+ riskLevel: 'high',
562
+ };
563
+ const prompt = buildGuardianApprovalPrompt(runInfo, 'alice');
564
+ expect(prompt.promptText).toContain('alice');
565
+ expect(prompt.promptText).toContain('deploy');
566
+ });
567
+
568
+ test('excludes approve_always action', () => {
569
+ const runInfo: PendingRunInfo = {
570
+ runId: 'run-g2',
571
+ requestId: 'req-g2',
572
+ toolName: 'shell',
573
+ input: {},
574
+ riskLevel: 'medium',
575
+ };
576
+ const prompt = buildGuardianApprovalPrompt(runInfo, 'bob');
577
+ expect(prompt.actions.map((a) => a.id)).not.toContain('approve_always');
578
+ expect(prompt.actions.map((a) => a.id)).toContain('approve_once');
579
+ expect(prompt.actions.map((a) => a.id)).toContain('reject');
580
+ });
581
+
582
+ test('plainTextFallback contains parser-compatible keywords', () => {
583
+ const runInfo: PendingRunInfo = {
584
+ runId: 'run-g3',
585
+ requestId: 'req-g3',
586
+ toolName: 'write_file',
587
+ input: {},
588
+ riskLevel: 'high',
589
+ };
590
+ const prompt = buildGuardianApprovalPrompt(runInfo, 'charlie');
591
+ expect(prompt.plainTextFallback).toContain('yes');
592
+ expect(prompt.plainTextFallback).toContain('no');
593
+ });
594
+ });
595
+
596
+ // ═══════════════════════════════════════════════════════════════════════════
597
+ // 6. channelSupportsRichApprovalUI
551
598
  // ═══════════════════════════════════════════════════════════════════════════
552
599
 
553
600
  describe('channelSupportsRichApprovalUI', () => {
@@ -311,28 +311,32 @@ describe('guardian service challenge validation', () => {
311
311
  resetTables();
312
312
  });
313
313
 
314
- test('createVerificationChallenge returns a secret and instruction', () => {
314
+ test('createVerificationChallenge returns a secret, verifyCommand, ttlSeconds, and instruction', () => {
315
315
  const result = createVerificationChallenge('asst-1', 'telegram');
316
316
 
317
317
  expect(result.challengeId).toBeDefined();
318
318
  expect(result.secret).toBeDefined();
319
319
  expect(result.secret.length).toBe(64); // 32 bytes hex-encoded
320
+ expect(result.verifyCommand).toBe(`/guardian_verify ${result.secret}`);
321
+ expect(result.ttlSeconds).toBe(600);
322
+ expect(result.instruction).toBeDefined();
323
+ expect(result.instruction.length).toBeGreaterThan(0);
320
324
  expect(result.instruction).toContain('/guardian_verify');
321
- expect(result.instruction).toContain(result.secret);
322
325
  });
323
326
 
324
- test('createVerificationChallenge instruction mentions Telegram for telegram channel', () => {
327
+ test('createVerificationChallenge produces a non-empty instruction for telegram channel', () => {
325
328
  const result = createVerificationChallenge('asst-1', 'telegram');
326
- expect(result.instruction).toContain('via Telegram');
327
- expect(result.instruction).not.toContain('via SMS');
329
+ expect(result.instruction).toBeDefined();
330
+ expect(result.instruction.length).toBeGreaterThan(0);
331
+ expect(result.instruction).toContain(result.verifyCommand);
328
332
  });
329
333
 
330
- test('createVerificationChallenge instruction mentions SMS for sms channel', () => {
334
+ test('createVerificationChallenge produces a non-empty instruction for sms channel', () => {
331
335
  const result = createVerificationChallenge('asst-1', 'sms');
332
- expect(result.instruction).toContain('via SMS');
333
- expect(result.instruction).not.toContain('via Telegram');
336
+ expect(result.instruction).toBeDefined();
337
+ expect(result.instruction.length).toBeGreaterThan(0);
334
338
  expect(result.instruction).toContain('/guardian_verify');
335
- expect(result.instruction).toContain(result.secret);
339
+ expect(result.instruction).toContain(result.verifyCommand);
336
340
  });
337
341
 
338
342
  test('validateAndConsumeChallenge succeeds with correct secret', () => {
@@ -377,8 +381,10 @@ describe('guardian service challenge validation', () => {
377
381
 
378
382
  expect(result.success).toBe(false);
379
383
  if (!result.success) {
380
- // Generic message to prevent oracle leakage
381
- expect(result.reason).toContain('try again later');
384
+ // Composed failure message check it is non-empty and contains "failed"
385
+ expect(result.reason).toBeDefined();
386
+ expect(result.reason.length).toBeGreaterThan(0);
387
+ expect(result.reason.toLowerCase()).toContain('failed');
382
388
  }
383
389
  });
384
390
 
@@ -404,8 +410,10 @@ describe('guardian service challenge validation', () => {
404
410
 
405
411
  expect(result.success).toBe(false);
406
412
  if (!result.success) {
407
- // Generic message to prevent oracle leakage
408
- expect(result.reason).toContain('try again later');
413
+ // Composed failure message check it is non-empty and contains "failed"
414
+ expect(result.reason).toBeDefined();
415
+ expect(result.reason.length).toBeGreaterThan(0);
416
+ expect(result.reason.toLowerCase()).toContain('failed');
409
417
  }
410
418
  });
411
419
 
@@ -943,7 +951,9 @@ describe('guardian service rate limiting', () => {
943
951
  'asst-1', 'telegram', 'another-wrong', 'user-42', 'chat-42',
944
952
  );
945
953
  expect(result.success).toBe(false);
946
- expect((result as { reason: string }).reason).toContain('try again later');
954
+ expect((result as { reason: string }).reason).toBeDefined();
955
+ expect((result as { reason: string }).reason.length).toBeGreaterThan(0);
956
+ expect((result as { reason: string }).reason.toLowerCase()).toContain('failed');
947
957
 
948
958
  // Verify the rate limit record
949
959
  const rl = getRateLimit('asst-1', 'telegram', 'user-42', 'chat-42');
@@ -975,21 +985,38 @@ describe('guardian service rate limiting', () => {
975
985
  test('rate-limit uses generic failure message (no oracle leakage)', () => {
976
986
  createVerificationChallenge('asst-1', 'telegram');
977
987
 
978
- // Trigger rate limit
979
- for (let i = 0; i < 5; i++) {
988
+ // Capture a normal invalid-code failure response
989
+ const normalFailure = validateAndConsumeChallenge(
990
+ 'asst-1', 'telegram', 'wrong-first', 'user-42', 'chat-42',
991
+ );
992
+ expect(normalFailure.success).toBe(false);
993
+ const normalReason = (normalFailure as { reason: string }).reason;
994
+
995
+ // Trigger rate limit (4 more attempts to reach 5 total)
996
+ for (let i = 0; i < 4; i++) {
980
997
  validateAndConsumeChallenge(
981
998
  'asst-1', 'telegram', `wrong-${i}`, 'user-42', 'chat-42',
982
999
  );
983
1000
  }
984
1001
 
985
- const result = validateAndConsumeChallenge(
1002
+ // Verify lockout is actually active before testing the rate-limited response
1003
+ const rl = getRateLimit('asst-1', 'telegram', 'user-42', 'chat-42');
1004
+ expect(rl).not.toBeNull();
1005
+ expect(rl!.lockedUntil).toBeGreaterThan(Date.now());
1006
+
1007
+ // The rate-limited response should be indistinguishable from normal failure
1008
+ const rateLimitedResult = validateAndConsumeChallenge(
986
1009
  'asst-1', 'telegram', 'anything', 'user-42', 'chat-42',
987
1010
  );
988
- expect(result.success).toBe(false);
989
- // Must NOT reveal "invalid", "expired", or "rate limit" specifically
990
- expect((result as { reason: string }).reason).not.toContain('Invalid');
991
- expect((result as { reason: string }).reason).not.toContain('expired');
992
- expect((result as { reason: string }).reason).not.toContain('rate limit');
1011
+ expect(rateLimitedResult.success).toBe(false);
1012
+ const rateLimitedReason = (rateLimitedResult as { reason: string }).reason;
1013
+
1014
+ // Anti-oracle: both responses must be identical
1015
+ expect(rateLimitedReason).toBe(normalReason);
1016
+
1017
+ // Neither should reveal rate-limiting info
1018
+ expect(rateLimitedReason).not.toContain('rate limit');
1019
+ expect(normalReason).not.toContain('rate limit');
993
1020
  });
994
1021
 
995
1022
  test('rate limit does not affect different actors', () => {
@@ -1295,6 +1322,31 @@ describe('IPC handler channel-aware guardian status', () => {
1295
1322
  expect(resp!.assistantId).toBe('self');
1296
1323
  });
1297
1324
 
1325
+ test('status action returns guardian username/displayName from binding metadata', () => {
1326
+ createBinding({
1327
+ assistantId: 'self',
1328
+ channel: 'telegram',
1329
+ guardianExternalUserId: 'user-43',
1330
+ guardianDeliveryChatId: 'chat-43',
1331
+ metadataJson: JSON.stringify({ username: 'guardian_handle', displayName: 'Guardian Name' }),
1332
+ });
1333
+
1334
+ const { ctx, lastResponse } = createMockCtx();
1335
+ const msg: GuardianVerificationRequest = {
1336
+ type: 'guardian_verification',
1337
+ action: 'status',
1338
+ channel: 'telegram',
1339
+ assistantId: 'self',
1340
+ };
1341
+
1342
+ handleGuardianVerification(msg, mockSocket, ctx);
1343
+
1344
+ const resp = lastResponse();
1345
+ expect(resp).not.toBeNull();
1346
+ expect(resp!.guardianUsername).toBe('guardian_handle');
1347
+ expect(resp!.guardianDisplayName).toBe('Guardian Name');
1348
+ });
1349
+
1298
1350
  test('status action defaults channel to telegram when omitted (backward compat)', () => {
1299
1351
  const { ctx, lastResponse } = createMockCtx();
1300
1352
  const msg: GuardianVerificationRequest = {
@@ -0,0 +1,257 @@
1
+ import { describe, test, expect, beforeEach, mock } from 'bun:test';
2
+ import { ChannelReadinessService, REMOTE_TTL_MS } from '../runtime/channel-readiness-service.js';
3
+ import type { ChannelProbe, ReadinessCheckResult } from '../runtime/channel-readiness-types.js';
4
+
5
+ // ── Test helpers ────────────────────────────────────────────────────────────
6
+
7
+ function makeProbe(
8
+ channel: string,
9
+ localResults: ReadinessCheckResult[],
10
+ remoteResults?: ReadinessCheckResult[],
11
+ ): ChannelProbe & { localCallCount: number; remoteCallCount: number } {
12
+ const probe = {
13
+ channel,
14
+ localCallCount: 0,
15
+ remoteCallCount: 0,
16
+ runLocalChecks(): ReadinessCheckResult[] {
17
+ probe.localCallCount++;
18
+ return localResults;
19
+ },
20
+ ...(remoteResults !== undefined
21
+ ? {
22
+ async runRemoteChecks(): Promise<ReadinessCheckResult[]> {
23
+ probe.remoteCallCount++;
24
+ return remoteResults;
25
+ },
26
+ }
27
+ : {}),
28
+ };
29
+ return probe;
30
+ }
31
+
32
+ // ── Tests ───────────────────────────────────────────────────────────────────
33
+
34
+ describe('ChannelReadinessService', () => {
35
+ let service: ChannelReadinessService;
36
+
37
+ beforeEach(() => {
38
+ service = new ChannelReadinessService();
39
+ });
40
+
41
+ test('local checks run on every call (no caching of local results)', async () => {
42
+ const probe = makeProbe('sms', [
43
+ { name: 'creds', passed: true, message: 'ok' },
44
+ ]);
45
+ service.registerProbe(probe);
46
+
47
+ await service.getReadiness('sms');
48
+ await service.getReadiness('sms');
49
+
50
+ expect(probe.localCallCount).toBe(2);
51
+ });
52
+
53
+ test('cache miss runs local checks and returns snapshot', async () => {
54
+ const probe = makeProbe('sms', [
55
+ { name: 'creds', passed: true, message: 'ok' },
56
+ { name: 'phone', passed: false, message: 'missing' },
57
+ ]);
58
+ service.registerProbe(probe);
59
+
60
+ const [snapshot] = await service.getReadiness('sms');
61
+
62
+ expect(probe.localCallCount).toBe(1);
63
+ expect(snapshot.channel).toBe('sms');
64
+ expect(snapshot.ready).toBe(false);
65
+ expect(snapshot.localChecks).toHaveLength(2);
66
+ expect(snapshot.reasons).toEqual([
67
+ { code: 'phone', text: 'missing' },
68
+ ]);
69
+ });
70
+
71
+ test('includeRemote=true runs remote checks on cache miss', async () => {
72
+ const probe = makeProbe(
73
+ 'sms',
74
+ [{ name: 'creds', passed: true, message: 'ok' }],
75
+ [{ name: 'api_check', passed: true, message: 'remote ok' }],
76
+ );
77
+ service.registerProbe(probe);
78
+
79
+ const [snapshot] = await service.getReadiness('sms', true);
80
+
81
+ expect(probe.remoteCallCount).toBe(1);
82
+ expect(snapshot.remoteChecks).toHaveLength(1);
83
+ expect(snapshot.remoteChecks![0].passed).toBe(true);
84
+ });
85
+
86
+ test('cached remote checks reused within TTL', async () => {
87
+ const probe = makeProbe(
88
+ 'sms',
89
+ [{ name: 'creds', passed: true, message: 'ok' }],
90
+ [{ name: 'api_check', passed: true, message: 'remote ok' }],
91
+ );
92
+ service.registerProbe(probe);
93
+
94
+ // First call populates cache
95
+ await service.getReadiness('sms', true);
96
+ expect(probe.remoteCallCount).toBe(1);
97
+
98
+ // Second call within TTL should reuse cache
99
+ const [snapshot] = await service.getReadiness('sms', true);
100
+ expect(probe.remoteCallCount).toBe(1);
101
+ expect(snapshot.remoteChecks).toHaveLength(1);
102
+ });
103
+
104
+ test('stale cache triggers remote check re-run', async () => {
105
+ const probe = makeProbe(
106
+ 'sms',
107
+ [{ name: 'creds', passed: true, message: 'ok' }],
108
+ [{ name: 'api_check', passed: true, message: 'remote ok' }],
109
+ );
110
+ service.registerProbe(probe);
111
+
112
+ // First call
113
+ await service.getReadiness('sms', true);
114
+ expect(probe.remoteCallCount).toBe(1);
115
+
116
+ // Manually age the cached snapshot beyond TTL
117
+ const cached = (service as unknown as { snapshots: Map<string, { checkedAt: number }> }).snapshots.get('sms')!;
118
+ cached.checkedAt = Date.now() - REMOTE_TTL_MS - 1;
119
+
120
+ // Second call should re-run remote checks
121
+ await service.getReadiness('sms', true);
122
+ expect(probe.remoteCallCount).toBe(2);
123
+ });
124
+
125
+ test('invalidateChannel clears cache for specific channel', async () => {
126
+ const probe = makeProbe(
127
+ 'sms',
128
+ [{ name: 'creds', passed: true, message: 'ok' }],
129
+ [{ name: 'api_check', passed: true, message: 'remote ok' }],
130
+ );
131
+ service.registerProbe(probe);
132
+
133
+ await service.getReadiness('sms', true);
134
+ expect(probe.remoteCallCount).toBe(1);
135
+
136
+ service.invalidateChannel('sms');
137
+
138
+ // After invalidation, remote checks should run again
139
+ await service.getReadiness('sms', true);
140
+ expect(probe.remoteCallCount).toBe(2);
141
+ });
142
+
143
+ test('invalidateAll clears all cached snapshots', async () => {
144
+ const smsProbe = makeProbe(
145
+ 'sms',
146
+ [{ name: 'creds', passed: true, message: 'ok' }],
147
+ [{ name: 'api', passed: true, message: 'ok' }],
148
+ );
149
+ const telegramProbe = makeProbe(
150
+ 'telegram',
151
+ [{ name: 'token', passed: true, message: 'ok' }],
152
+ [{ name: 'webhook', passed: true, message: 'ok' }],
153
+ );
154
+ service.registerProbe(smsProbe);
155
+ service.registerProbe(telegramProbe);
156
+
157
+ await service.getReadiness(undefined, true);
158
+ expect(smsProbe.remoteCallCount).toBe(1);
159
+ expect(telegramProbe.remoteCallCount).toBe(1);
160
+
161
+ service.invalidateAll();
162
+
163
+ await service.getReadiness(undefined, true);
164
+ expect(smsProbe.remoteCallCount).toBe(2);
165
+ expect(telegramProbe.remoteCallCount).toBe(2);
166
+ });
167
+
168
+ test('unknown channel returns unsupported_channel reason', async () => {
169
+ const [snapshot] = await service.getReadiness('carrier_pigeon');
170
+
171
+ expect(snapshot.channel).toBe('carrier_pigeon');
172
+ expect(snapshot.ready).toBe(false);
173
+ expect(snapshot.reasons).toEqual([
174
+ { code: 'unsupported_channel', text: 'Channel carrier_pigeon is not supported' },
175
+ ]);
176
+ expect(snapshot.localChecks).toHaveLength(0);
177
+ });
178
+
179
+ test('all checks passing yields ready=true', async () => {
180
+ const probe = makeProbe('test', [
181
+ { name: 'a', passed: true, message: 'ok' },
182
+ { name: 'b', passed: true, message: 'ok' },
183
+ ]);
184
+ service.registerProbe(probe);
185
+
186
+ const [snapshot] = await service.getReadiness('test');
187
+
188
+ expect(snapshot.ready).toBe(true);
189
+ expect(snapshot.reasons).toHaveLength(0);
190
+ });
191
+
192
+ test('getReadiness with no channel returns all registered channels', async () => {
193
+ service.registerProbe(makeProbe('sms', [{ name: 'a', passed: true, message: 'ok' }]));
194
+ service.registerProbe(makeProbe('telegram', [{ name: 'b', passed: true, message: 'ok' }]));
195
+
196
+ const snapshots = await service.getReadiness();
197
+
198
+ expect(snapshots).toHaveLength(2);
199
+ const channels = snapshots.map((s) => s.channel).sort();
200
+ expect(channels).toEqual(['sms', 'telegram']);
201
+ });
202
+
203
+ test('cached remote checks preserve original checkedAt (TTL not reset on reuse)', async () => {
204
+ const probe = makeProbe(
205
+ 'sms',
206
+ [{ name: 'creds', passed: true, message: 'ok' }],
207
+ [{ name: 'api_check', passed: true, message: 'remote ok' }],
208
+ );
209
+ service.registerProbe(probe);
210
+
211
+ // First call populates cache with freshly fetched remote checks
212
+ const [first] = await service.getReadiness('sms', true);
213
+ const originalCheckedAt = first.checkedAt;
214
+ expect(probe.remoteCallCount).toBe(1);
215
+
216
+ // Second call within TTL reuses cache — checkedAt must stay at the original value
217
+ const [second] = await service.getReadiness('sms', true);
218
+ expect(probe.remoteCallCount).toBe(1);
219
+ expect(second.checkedAt).toBe(originalCheckedAt);
220
+ });
221
+
222
+ test('includeRemote runs remote checks when cache exists without remote data', async () => {
223
+ const probe = makeProbe(
224
+ 'sms',
225
+ [{ name: 'creds', passed: true, message: 'ok' }],
226
+ [{ name: 'api_check', passed: true, message: 'remote ok' }],
227
+ );
228
+ service.registerProbe(probe);
229
+
230
+ // First call without includeRemote — cache has no remote data
231
+ await service.getReadiness('sms', false);
232
+ expect(probe.remoteCallCount).toBe(0);
233
+
234
+ // Second call with includeRemote — should run remote checks even though
235
+ // the cached snapshot exists (because it has no remoteChecks)
236
+ const [snapshot] = await service.getReadiness('sms', true);
237
+ expect(probe.remoteCallCount).toBe(1);
238
+ expect(snapshot.remoteChecks).toHaveLength(1);
239
+ expect(snapshot.remoteChecks![0].passed).toBe(true);
240
+ });
241
+
242
+ test('failed remote check makes channel not ready', async () => {
243
+ const probe = makeProbe(
244
+ 'sms',
245
+ [{ name: 'creds', passed: true, message: 'ok' }],
246
+ [{ name: 'api_check', passed: false, message: 'API unreachable' }],
247
+ );
248
+ service.registerProbe(probe);
249
+
250
+ const [snapshot] = await service.getReadiness('sms', true);
251
+
252
+ expect(snapshot.ready).toBe(false);
253
+ expect(snapshot.reasons).toEqual([
254
+ { code: 'api_check', text: 'API unreachable' },
255
+ ]);
256
+ });
257
+ });
@@ -206,6 +206,7 @@ describe('AssistantConfigSchema', () => {
206
206
  maxEdges: 40,
207
207
  neighborScoreMultiplier: 0.7,
208
208
  maxDepth: 3,
209
+ depthDecay: true,
209
210
  });
210
211
  });
211
212
 
@@ -681,7 +682,7 @@ describe('AssistantConfigSchema', () => {
681
682
  userConsultTimeoutSeconds: 120,
682
683
  disclosure: {
683
684
  enabled: true,
684
- text: 'At the very beginning of the call, introduce yourself as an assistant calling on behalf of the user.',
685
+ text: 'At the very beginning of the call, introduce yourself as an assistant calling on behalf of the person you represent. Do not say "AI assistant".',
685
686
  },
686
687
  safety: {
687
688
  denyCategories: [],