@vellumai/assistant 0.3.19 → 0.3.21

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 (199) hide show
  1. package/ARCHITECTURE.md +151 -15
  2. package/Dockerfile +1 -0
  3. package/README.md +40 -4
  4. package/bun.lock +139 -2
  5. package/docs/architecture/integrations.md +7 -11
  6. package/package.json +2 -1
  7. package/src/__tests__/__snapshots__/ipc-snapshot.test.ts.snap +54 -0
  8. package/src/__tests__/approval-primitive.test.ts +540 -0
  9. package/src/__tests__/assistant-feature-flag-guard.test.ts +206 -0
  10. package/src/__tests__/assistant-feature-flag-guardrails.test.ts +198 -0
  11. package/src/__tests__/assistant-feature-flags-integration.test.ts +272 -0
  12. package/src/__tests__/call-controller.test.ts +439 -108
  13. package/src/__tests__/channel-invite-transport.test.ts +264 -0
  14. package/src/__tests__/cli.test.ts +42 -1
  15. package/src/__tests__/config-schema.test.ts +11 -127
  16. package/src/__tests__/config-watcher.test.ts +0 -8
  17. package/src/__tests__/daemon-lifecycle.test.ts +1 -0
  18. package/src/__tests__/daemon-server-session-init.test.ts +8 -2
  19. package/src/__tests__/diff.test.ts +22 -0
  20. package/src/__tests__/guardian-action-copy-generator.test.ts +5 -0
  21. package/src/__tests__/guardian-action-grant-mint-consume.test.ts +300 -32
  22. package/src/__tests__/guardian-action-late-reply.test.ts +546 -1
  23. package/src/__tests__/guardian-actions-endpoint.test.ts +774 -0
  24. package/src/__tests__/guardian-control-plane-policy.test.ts +36 -3
  25. package/src/__tests__/guardian-dispatch.test.ts +124 -0
  26. package/src/__tests__/guardian-grant-minting.test.ts +6 -17
  27. package/src/__tests__/inbound-invite-redemption.test.ts +367 -0
  28. package/src/__tests__/invite-redemption-service.test.ts +306 -0
  29. package/src/__tests__/ipc-snapshot.test.ts +57 -0
  30. package/src/__tests__/notification-decision-fallback.test.ts +88 -0
  31. package/src/__tests__/sandbox-diagnostics.test.ts +6 -249
  32. package/src/__tests__/sandbox-host-parity.test.ts +6 -13
  33. package/src/__tests__/scoped-approval-grants.test.ts +6 -6
  34. package/src/__tests__/scoped-grant-security-matrix.test.ts +5 -4
  35. package/src/__tests__/script-proxy-session-manager.test.ts +1 -19
  36. package/src/__tests__/session-load-history-repair.test.ts +169 -2
  37. package/src/__tests__/session-runtime-assembly.test.ts +33 -5
  38. package/src/__tests__/skill-feature-flags-integration.test.ts +171 -0
  39. package/src/__tests__/skill-feature-flags.test.ts +188 -0
  40. package/src/__tests__/skill-load-feature-flag.test.ts +141 -0
  41. package/src/__tests__/skill-mirror-parity.test.ts +1 -0
  42. package/src/__tests__/skill-projection-feature-flag.test.ts +363 -0
  43. package/src/__tests__/system-prompt.test.ts +1 -1
  44. package/src/__tests__/terminal-sandbox.test.ts +142 -9
  45. package/src/__tests__/terminal-tools.test.ts +2 -93
  46. package/src/__tests__/thread-seed-composer.test.ts +18 -0
  47. package/src/__tests__/tool-approval-handler.test.ts +350 -0
  48. package/src/__tests__/trusted-contact-lifecycle-notifications.test.ts +8 -10
  49. package/src/__tests__/voice-scoped-grant-consumer.test.ts +46 -84
  50. package/src/agent/loop.ts +36 -1
  51. package/src/approvals/approval-primitive.ts +381 -0
  52. package/src/approvals/guardian-decision-primitive.ts +191 -0
  53. package/src/calls/call-controller.ts +252 -209
  54. package/src/calls/call-domain.ts +44 -6
  55. package/src/calls/guardian-dispatch.ts +48 -0
  56. package/src/calls/types.ts +1 -1
  57. package/src/calls/voice-session-bridge.ts +46 -30
  58. package/src/cli/core-commands.ts +0 -4
  59. package/src/cli/mcp.ts +58 -0
  60. package/src/cli.ts +76 -34
  61. package/src/config/__tests__/feature-flag-registry-guard.test.ts +179 -0
  62. package/src/config/assistant-feature-flags.ts +162 -0
  63. package/src/config/bundled-skills/api-mapping/icon.svg +18 -0
  64. package/src/config/bundled-skills/messaging/TOOLS.json +30 -0
  65. package/src/config/bundled-skills/messaging/tools/slack-delete-message.ts +24 -0
  66. package/src/config/bundled-skills/notifications/SKILL.md +1 -1
  67. package/src/config/bundled-skills/reminder/SKILL.md +49 -2
  68. package/src/config/bundled-skills/time-based-actions/SKILL.md +49 -2
  69. package/src/config/bundled-skills/voice-setup/SKILL.md +122 -0
  70. package/src/config/core-schema.ts +1 -1
  71. package/src/config/env-registry.ts +10 -0
  72. package/src/config/feature-flag-registry.json +61 -0
  73. package/src/config/loader.ts +22 -1
  74. package/src/config/mcp-schema.ts +46 -0
  75. package/src/config/sandbox-schema.ts +0 -39
  76. package/src/config/schema.ts +18 -2
  77. package/src/config/skill-state.ts +34 -0
  78. package/src/config/skills-schema.ts +0 -1
  79. package/src/config/skills.ts +9 -0
  80. package/src/config/system-prompt.ts +110 -46
  81. package/src/config/templates/SOUL.md +1 -1
  82. package/src/config/types.ts +19 -1
  83. package/src/config/vellum-skills/catalog.json +1 -1
  84. package/src/config/vellum-skills/guardian-verify-setup/SKILL.md +1 -0
  85. package/src/config/vellum-skills/sms-setup/SKILL.md +1 -1
  86. package/src/config/vellum-skills/telegram-setup/SKILL.md +6 -5
  87. package/src/config/vellum-skills/trusted-contacts/SKILL.md +105 -3
  88. package/src/config/vellum-skills/twilio-setup/SKILL.md +1 -1
  89. package/src/daemon/config-watcher.ts +0 -1
  90. package/src/daemon/daemon-control.ts +1 -1
  91. package/src/daemon/guardian-invite-intent.ts +124 -0
  92. package/src/daemon/handlers/avatar.ts +68 -0
  93. package/src/daemon/handlers/browser.ts +2 -2
  94. package/src/daemon/handlers/guardian-actions.ts +120 -0
  95. package/src/daemon/handlers/index.ts +4 -0
  96. package/src/daemon/handlers/sessions.ts +19 -0
  97. package/src/daemon/handlers/shared.ts +3 -1
  98. package/src/daemon/install-cli-launchers.ts +58 -13
  99. package/src/daemon/ipc-contract/guardian-actions.ts +53 -0
  100. package/src/daemon/ipc-contract/sessions.ts +8 -2
  101. package/src/daemon/ipc-contract/settings.ts +25 -2
  102. package/src/daemon/ipc-contract-inventory.json +10 -0
  103. package/src/daemon/ipc-contract.ts +4 -0
  104. package/src/daemon/lifecycle.ts +14 -2
  105. package/src/daemon/main.ts +1 -0
  106. package/src/daemon/providers-setup.ts +26 -1
  107. package/src/daemon/server.ts +1 -0
  108. package/src/daemon/session-lifecycle.ts +52 -7
  109. package/src/daemon/session-memory.ts +45 -0
  110. package/src/daemon/session-process.ts +258 -432
  111. package/src/daemon/session-runtime-assembly.ts +12 -0
  112. package/src/daemon/session-skill-tools.ts +14 -1
  113. package/src/daemon/session-tool-setup.ts +5 -0
  114. package/src/daemon/session.ts +11 -0
  115. package/src/daemon/shutdown-handlers.ts +11 -0
  116. package/src/daemon/tool-side-effects.ts +35 -9
  117. package/src/index.ts +2 -2
  118. package/src/mcp/client.ts +152 -0
  119. package/src/mcp/manager.ts +139 -0
  120. package/src/memory/conversation-display-order-migration.ts +44 -0
  121. package/src/memory/conversation-queries.ts +2 -0
  122. package/src/memory/conversation-store.ts +91 -0
  123. package/src/memory/db-init.ts +5 -1
  124. package/src/memory/embedding-local.ts +13 -8
  125. package/src/memory/guardian-action-store.ts +125 -2
  126. package/src/memory/ingress-invite-store.ts +95 -1
  127. package/src/memory/migrations/035-guardian-action-supersession.ts +23 -0
  128. package/src/memory/migrations/index.ts +2 -1
  129. package/src/memory/schema.ts +5 -1
  130. package/src/memory/scoped-approval-grants.ts +14 -5
  131. package/src/messaging/providers/slack/client.ts +12 -0
  132. package/src/messaging/providers/slack/types.ts +5 -0
  133. package/src/notifications/decision-engine.ts +49 -12
  134. package/src/notifications/emit-signal.ts +7 -0
  135. package/src/notifications/signal.ts +7 -0
  136. package/src/notifications/thread-seed-composer.ts +2 -1
  137. package/src/runtime/channel-approval-types.ts +16 -6
  138. package/src/runtime/channel-approvals.ts +19 -15
  139. package/src/runtime/channel-invite-transport.ts +85 -0
  140. package/src/runtime/channel-invite-transports/telegram.ts +105 -0
  141. package/src/runtime/guardian-action-grant-minter.ts +92 -35
  142. package/src/runtime/guardian-action-message-composer.ts +30 -0
  143. package/src/runtime/guardian-decision-types.ts +91 -0
  144. package/src/runtime/http-server.ts +23 -1
  145. package/src/runtime/ingress-service.ts +22 -0
  146. package/src/runtime/invite-redemption-service.ts +181 -0
  147. package/src/runtime/invite-redemption-templates.ts +39 -0
  148. package/src/runtime/routes/call-routes.ts +2 -1
  149. package/src/runtime/routes/guardian-action-routes.ts +206 -0
  150. package/src/runtime/routes/guardian-approval-interception.ts +66 -190
  151. package/src/runtime/routes/identity-routes.ts +73 -0
  152. package/src/runtime/routes/inbound-message-handler.ts +486 -394
  153. package/src/runtime/routes/pairing-routes.ts +4 -0
  154. package/src/security/encrypted-store.ts +31 -17
  155. package/src/security/keychain.ts +176 -2
  156. package/src/security/secure-keys.ts +97 -0
  157. package/src/security/tool-approval-digest.ts +1 -1
  158. package/src/tools/browser/browser-execution.ts +2 -2
  159. package/src/tools/browser/browser-manager.ts +46 -32
  160. package/src/tools/browser/browser-screencast.ts +2 -2
  161. package/src/tools/calls/call-start.ts +1 -1
  162. package/src/tools/executor.ts +22 -17
  163. package/src/tools/mcp/mcp-tool-factory.ts +100 -0
  164. package/src/tools/network/script-proxy/session-manager.ts +1 -5
  165. package/src/tools/registry.ts +64 -1
  166. package/src/tools/skills/load.ts +22 -8
  167. package/src/tools/system/avatar-generator.ts +119 -0
  168. package/src/tools/system/navigate-settings.ts +65 -0
  169. package/src/tools/system/open-system-settings.ts +75 -0
  170. package/src/tools/system/voice-config.ts +121 -32
  171. package/src/tools/terminal/backends/native.ts +40 -19
  172. package/src/tools/terminal/backends/types.ts +3 -3
  173. package/src/tools/terminal/parser.ts +1 -1
  174. package/src/tools/terminal/sandbox-diagnostics.ts +6 -87
  175. package/src/tools/terminal/sandbox.ts +1 -12
  176. package/src/tools/terminal/shell.ts +3 -31
  177. package/src/tools/tool-approval-handler.ts +141 -3
  178. package/src/tools/tool-manifest.ts +6 -0
  179. package/src/tools/types.ts +10 -2
  180. package/src/util/diff.ts +36 -13
  181. package/Dockerfile.sandbox +0 -5
  182. package/src/__tests__/doordash-client.test.ts +0 -187
  183. package/src/__tests__/doordash-session.test.ts +0 -154
  184. package/src/__tests__/signup-e2e.test.ts +0 -354
  185. package/src/__tests__/terminal-sandbox-docker.test.ts +0 -1065
  186. package/src/__tests__/terminal-sandbox.integration.test.ts +0 -180
  187. package/src/cli/doordash.ts +0 -1057
  188. package/src/config/bundled-skills/doordash/SKILL.md +0 -163
  189. package/src/config/templates/LOOKS.md +0 -25
  190. package/src/doordash/cart-queries.ts +0 -787
  191. package/src/doordash/client.ts +0 -1016
  192. package/src/doordash/order-queries.ts +0 -85
  193. package/src/doordash/queries.ts +0 -13
  194. package/src/doordash/query-extractor.ts +0 -94
  195. package/src/doordash/search-queries.ts +0 -203
  196. package/src/doordash/session.ts +0 -84
  197. package/src/doordash/store-queries.ts +0 -246
  198. package/src/doordash/types.ts +0 -367
  199. package/src/tools/terminal/backends/docker.ts +0 -379
@@ -46,20 +46,21 @@ mock.module('../util/logger.js', () => ({
46
46
 
47
47
  // ── Imports (after mocks) ───────────────────────────────────────────
48
48
 
49
+ import { createCallSession, createPendingQuestion } from '../calls/call-store.js';
50
+ import { getDb, initializeDb, resetDb } from '../memory/db.js';
49
51
  import {
50
52
  createGuardianActionRequest,
51
53
  resolveGuardianActionRequest,
52
54
  } from '../memory/guardian-action-store.js';
55
+ import { conversations, scopedApprovalGrants } from '../memory/schema.js';
53
56
  import {
54
- consumeScopedApprovalGrantByToolSignature,
55
- type CreateScopedApprovalGrantParams,
56
- createScopedApprovalGrant,
57
+ _internal,
57
58
  } from '../memory/scoped-approval-grants.js';
58
- import { getDb, initializeDb, resetDb } from '../memory/db.js';
59
- import { conversations, scopedApprovalGrants } from '../memory/schema.js';
60
- import { computeToolApprovalDigest } from '../security/tool-approval-digest.js';
59
+
60
+ const { consumeScopedApprovalGrantByToolSignature } = _internal;
61
61
  import { tryMintGuardianActionGrant } from '../runtime/guardian-action-grant-minter.js';
62
- import { createCallSession, createPendingQuestion } from '../calls/call-store.js';
62
+ import type { ApprovalConversationGenerator } from '../runtime/http-types.js';
63
+ import { computeToolApprovalDigest } from '../security/tool-approval-digest.js';
63
64
 
64
65
  initializeDb();
65
66
 
@@ -109,7 +110,7 @@ function ensureFkParents(): void {
109
110
  // Pre-create enough pending questions for all tests in a suite run
110
111
  PENDING_QUESTION_IDS = [];
111
112
  pqIndex = 0;
112
- for (let i = 0; i < 10; i++) {
113
+ for (let i = 0; i < 20; i++) {
113
114
  const pq = createPendingQuestion(session.id, `Question ${i}`);
114
115
  PENDING_QUESTION_IDS.push(pq.id);
115
116
  }
@@ -166,7 +167,7 @@ describe('guardian-action grant mint -> voice consume integration', () => {
166
167
  ensureFkParents();
167
168
  });
168
169
 
169
- test('full flow: resolve guardian action with tool metadata -> mint grant -> voice consume succeeds once', () => {
170
+ test('full flow: resolve guardian action with tool metadata -> mint grant -> voice consume succeeds once', async () => {
170
171
  const inputDigest = computeToolApprovalDigest(TOOL_NAME, TOOL_INPUT);
171
172
 
172
173
  // Step 1: Create a guardian action request with tool metadata
@@ -199,8 +200,8 @@ describe('guardian-action grant mint -> voice consume integration', () => {
199
200
  expect(resolved!.status).toBe('answered');
200
201
 
201
202
  // Step 3: Mint a scoped grant from the resolved request
202
- tryMintGuardianActionGrant({
203
- resolvedRequest: resolved!,
203
+ await tryMintGuardianActionGrant({
204
+ request: resolved!,
204
205
  answerText: 'yes',
205
206
  decisionChannel: 'telegram',
206
207
  guardianExternalUserId: 'guardian-user-123',
@@ -249,7 +250,7 @@ describe('guardian-action grant mint -> voice consume integration', () => {
249
250
  expect(secondConsume.grant).toBeNull();
250
251
  });
251
252
 
252
- test('grant minted for one assistantId cannot be consumed by another', () => {
253
+ test('grant minted for one assistantId cannot be consumed by another', async () => {
253
254
  const inputDigest = computeToolApprovalDigest(TOOL_NAME, TOOL_INPUT);
254
255
 
255
256
  const request = createGuardianActionRequest({
@@ -268,8 +269,8 @@ describe('guardian-action grant mint -> voice consume integration', () => {
268
269
  const resolved = resolveGuardianActionRequest(request.id, 'Yes', 'telegram');
269
270
  expect(resolved).not.toBeNull();
270
271
 
271
- tryMintGuardianActionGrant({
272
- resolvedRequest: resolved!,
272
+ await tryMintGuardianActionGrant({
273
+ request: resolved!,
273
274
  answerText: 'Yes',
274
275
  decisionChannel: 'telegram',
275
276
  });
@@ -299,7 +300,7 @@ describe('guardian-action grant mint -> voice consume integration', () => {
299
300
  expect(correctAssistant.ok).toBe(true);
300
301
  });
301
302
 
302
- test('no grant minted when guardian action request lacks tool metadata', () => {
303
+ test('no grant minted when guardian action request lacks tool metadata', async () => {
303
304
  // Create a request without toolName/inputDigest (informational consult)
304
305
  const request = createGuardianActionRequest({
305
306
  assistantId: ASSISTANT_ID,
@@ -316,8 +317,8 @@ describe('guardian-action grant mint -> voice consume integration', () => {
316
317
  const resolved = resolveGuardianActionRequest(request.id, 'Tell them to call back', 'vellum');
317
318
  expect(resolved).not.toBeNull();
318
319
 
319
- tryMintGuardianActionGrant({
320
- resolvedRequest: resolved!,
320
+ await tryMintGuardianActionGrant({
321
+ request: resolved!,
321
322
  answerText: 'Tell them to call back',
322
323
  decisionChannel: 'vellum',
323
324
  });
@@ -331,7 +332,7 @@ describe('guardian-action grant mint -> voice consume integration', () => {
331
332
  expect(grants.length).toBe(0);
332
333
  });
333
334
 
334
- test('grant minted via desktop/vellum channel also consumable by voice', () => {
335
+ test('grant minted via desktop/vellum channel also consumable by voice', async () => {
335
336
  const inputDigest = computeToolApprovalDigest(TOOL_NAME, TOOL_INPUT);
336
337
 
337
338
  const request = createGuardianActionRequest({
@@ -352,8 +353,8 @@ describe('guardian-action grant mint -> voice consume integration', () => {
352
353
  expect(resolved).not.toBeNull();
353
354
 
354
355
  // Mint with decisionChannel: 'vellum' (desktop path)
355
- tryMintGuardianActionGrant({
356
- resolvedRequest: resolved!,
356
+ await tryMintGuardianActionGrant({
357
+ request: resolved!,
357
358
  answerText: 'approve',
358
359
  decisionChannel: 'vellum',
359
360
  });
@@ -371,7 +372,7 @@ describe('guardian-action grant mint -> voice consume integration', () => {
371
372
  expect(consumeResult.ok).toBe(true);
372
373
  });
373
374
 
374
- test('no grant minted when guardian answer is a denial', () => {
375
+ test('no grant minted when guardian answer is a denial', async () => {
375
376
  const inputDigest = computeToolApprovalDigest(TOOL_NAME, TOOL_INPUT);
376
377
 
377
378
  const request = createGuardianActionRequest({
@@ -391,8 +392,8 @@ describe('guardian-action grant mint -> voice consume integration', () => {
391
392
  const resolved = resolveGuardianActionRequest(request.id, 'No', 'telegram', 'guardian-user-456');
392
393
  expect(resolved).not.toBeNull();
393
394
 
394
- tryMintGuardianActionGrant({
395
- resolvedRequest: resolved!,
395
+ await tryMintGuardianActionGrant({
396
+ request: resolved!,
396
397
  answerText: 'No',
397
398
  decisionChannel: 'telegram',
398
399
  guardianExternalUserId: 'guardian-user-456',
@@ -407,7 +408,7 @@ describe('guardian-action grant mint -> voice consume integration', () => {
407
408
  expect(grants.length).toBe(0);
408
409
  });
409
410
 
410
- test.each(['no', 'reject', 'deny', 'cancel'])('no grant minted for denial keyword: %s', (denialWord) => {
411
+ test.each(['no', 'reject', 'deny', 'cancel'])('no grant minted for denial keyword: %s', async (denialWord) => {
411
412
  const inputDigest = computeToolApprovalDigest(TOOL_NAME, TOOL_INPUT);
412
413
 
413
414
  const request = createGuardianActionRequest({
@@ -426,8 +427,8 @@ describe('guardian-action grant mint -> voice consume integration', () => {
426
427
  const resolved = resolveGuardianActionRequest(request.id, denialWord, 'telegram');
427
428
  expect(resolved).not.toBeNull();
428
429
 
429
- tryMintGuardianActionGrant({
430
- resolvedRequest: resolved!,
430
+ await tryMintGuardianActionGrant({
431
+ request: resolved!,
431
432
  answerText: denialWord,
432
433
  decisionChannel: 'telegram',
433
434
  });
@@ -440,7 +441,7 @@ describe('guardian-action grant mint -> voice consume integration', () => {
440
441
  expect(grants.length).toBe(0);
441
442
  });
442
443
 
443
- test('no grant minted for unrecognised free-form answer (fail-closed)', () => {
444
+ test('no grant minted for unrecognised free-form answer without generator (fail-closed)', async () => {
444
445
  const inputDigest = computeToolApprovalDigest(TOOL_NAME, TOOL_INPUT);
445
446
 
446
447
  const request = createGuardianActionRequest({
@@ -460,8 +461,8 @@ describe('guardian-action grant mint -> voice consume integration', () => {
460
461
  const resolved = resolveGuardianActionRequest(request.id, 'Sure, go ahead and run it', 'telegram');
461
462
  expect(resolved).not.toBeNull();
462
463
 
463
- tryMintGuardianActionGrant({
464
- resolvedRequest: resolved!,
464
+ await tryMintGuardianActionGrant({
465
+ request: resolved!,
465
466
  answerText: 'Sure, go ahead and run it',
466
467
  decisionChannel: 'telegram',
467
468
  });
@@ -475,7 +476,7 @@ describe('guardian-action grant mint -> voice consume integration', () => {
475
476
  expect(grants.length).toBe(0);
476
477
  });
477
478
 
478
- test.each(['yes', 'approve', 'approve once', 'allow', 'go ahead'])('grant IS minted for approval keyword: %s', (approveWord) => {
479
+ test.each(['yes', 'approve', 'approve once', 'allow', 'go ahead'])('grant IS minted for approval keyword: %s', async (approveWord) => {
479
480
  const inputDigest = computeToolApprovalDigest(TOOL_NAME, TOOL_INPUT);
480
481
 
481
482
  const request = createGuardianActionRequest({
@@ -494,8 +495,8 @@ describe('guardian-action grant mint -> voice consume integration', () => {
494
495
  const resolved = resolveGuardianActionRequest(request.id, approveWord, 'telegram');
495
496
  expect(resolved).not.toBeNull();
496
497
 
497
- tryMintGuardianActionGrant({
498
- resolvedRequest: resolved!,
498
+ await tryMintGuardianActionGrant({
499
+ request: resolved!,
499
500
  answerText: approveWord,
500
501
  decisionChannel: 'telegram',
501
502
  });
@@ -509,3 +510,270 @@ describe('guardian-action grant mint -> voice consume integration', () => {
509
510
  expect(grants[0].toolName).toBe(TOOL_NAME);
510
511
  });
511
512
  });
513
+
514
+ // ---------------------------------------------------------------------------
515
+ // LLM fallback two-tier classification tests
516
+ // ---------------------------------------------------------------------------
517
+
518
+ describe('guardian-action grant minter: two-tier classification (deterministic + LLM fallback)', () => {
519
+ beforeEach(() => {
520
+ clearTables();
521
+ ensureFkParents();
522
+ });
523
+
524
+ test('deterministic parser works for exact phrases without needing the generator', async () => {
525
+ const inputDigest = computeToolApprovalDigest(TOOL_NAME, TOOL_INPUT);
526
+
527
+ const request = createGuardianActionRequest({
528
+ assistantId: ASSISTANT_ID,
529
+ kind: 'ask_guardian',
530
+ sourceChannel: 'voice',
531
+ sourceConversationId: CONVERSATION_ID,
532
+ callSessionId: CALL_SESSION_ID,
533
+ pendingQuestionId: nextPendingQuestionId(),
534
+ questionText: 'Can I run the command?',
535
+ expiresAt: Date.now() + 60_000,
536
+ toolName: TOOL_NAME,
537
+ inputDigest,
538
+ });
539
+
540
+ const resolved = resolveGuardianActionRequest(request.id, 'yes', 'telegram');
541
+ expect(resolved).not.toBeNull();
542
+
543
+ // Provide a generator that should NOT be called (deterministic match first)
544
+ const generatorSpy: ApprovalConversationGenerator = async () => {
545
+ throw new Error('Generator should not be called for exact phrase match');
546
+ };
547
+
548
+ await tryMintGuardianActionGrant({
549
+ request: resolved!,
550
+ answerText: 'yes',
551
+ decisionChannel: 'telegram',
552
+ approvalConversationGenerator: generatorSpy,
553
+ });
554
+
555
+ const db = getDb();
556
+ const grants = db.select().from(scopedApprovalGrants).all();
557
+ expect(grants.length).toBe(1);
558
+ expect(grants[0].toolName).toBe(TOOL_NAME);
559
+ });
560
+
561
+ test('free-form approval via LLM fallback mints a grant', async () => {
562
+ const inputDigest = computeToolApprovalDigest(TOOL_NAME, TOOL_INPUT);
563
+
564
+ const request = createGuardianActionRequest({
565
+ assistantId: ASSISTANT_ID,
566
+ kind: 'ask_guardian',
567
+ sourceChannel: 'voice',
568
+ sourceConversationId: CONVERSATION_ID,
569
+ callSessionId: CALL_SESSION_ID,
570
+ pendingQuestionId: nextPendingQuestionId(),
571
+ questionText: 'Can I run the command?',
572
+ expiresAt: Date.now() + 60_000,
573
+ toolName: TOOL_NAME,
574
+ inputDigest,
575
+ });
576
+
577
+ const resolved = resolveGuardianActionRequest(request.id, 'Sure, go ahead and run it', 'telegram');
578
+ expect(resolved).not.toBeNull();
579
+
580
+ const mockGenerator: ApprovalConversationGenerator = async () => ({
581
+ disposition: 'approve_once',
582
+ replyText: 'Approved.',
583
+ });
584
+
585
+ await tryMintGuardianActionGrant({
586
+ request: resolved!,
587
+ answerText: 'Sure, go ahead and run it',
588
+ decisionChannel: 'telegram',
589
+ approvalConversationGenerator: mockGenerator,
590
+ });
591
+
592
+ const db = getDb();
593
+ const grants = db.select().from(scopedApprovalGrants).all();
594
+ expect(grants.length).toBe(1);
595
+ expect(grants[0].toolName).toBe(TOOL_NAME);
596
+ });
597
+
598
+ test('ambiguous text returns keep_pending from generator, no grant minted', async () => {
599
+ const inputDigest = computeToolApprovalDigest(TOOL_NAME, TOOL_INPUT);
600
+
601
+ const request = createGuardianActionRequest({
602
+ assistantId: ASSISTANT_ID,
603
+ kind: 'ask_guardian',
604
+ sourceChannel: 'voice',
605
+ sourceConversationId: CONVERSATION_ID,
606
+ callSessionId: CALL_SESSION_ID,
607
+ pendingQuestionId: nextPendingQuestionId(),
608
+ questionText: 'Can I run the command?',
609
+ expiresAt: Date.now() + 60_000,
610
+ toolName: TOOL_NAME,
611
+ inputDigest,
612
+ });
613
+
614
+ const resolved = resolveGuardianActionRequest(request.id, "I'm not sure about this", 'telegram');
615
+ expect(resolved).not.toBeNull();
616
+
617
+ const mockGenerator: ApprovalConversationGenerator = async () => ({
618
+ disposition: 'keep_pending',
619
+ replyText: 'Could you clarify?',
620
+ });
621
+
622
+ await tryMintGuardianActionGrant({
623
+ request: resolved!,
624
+ answerText: "I'm not sure about this",
625
+ decisionChannel: 'telegram',
626
+ approvalConversationGenerator: mockGenerator,
627
+ });
628
+
629
+ const db = getDb();
630
+ const grants = db.select().from(scopedApprovalGrants).all();
631
+ expect(grants.length).toBe(0);
632
+ });
633
+
634
+ test('generator failure falls back to no grant (fail-closed)', async () => {
635
+ const inputDigest = computeToolApprovalDigest(TOOL_NAME, TOOL_INPUT);
636
+
637
+ const request = createGuardianActionRequest({
638
+ assistantId: ASSISTANT_ID,
639
+ kind: 'ask_guardian',
640
+ sourceChannel: 'voice',
641
+ sourceConversationId: CONVERSATION_ID,
642
+ callSessionId: CALL_SESSION_ID,
643
+ pendingQuestionId: nextPendingQuestionId(),
644
+ questionText: 'Can I run the command?',
645
+ expiresAt: Date.now() + 60_000,
646
+ toolName: TOOL_NAME,
647
+ inputDigest,
648
+ });
649
+
650
+ const resolved = resolveGuardianActionRequest(request.id, 'Sure, go ahead and run it', 'telegram');
651
+ expect(resolved).not.toBeNull();
652
+
653
+ const failingGenerator: ApprovalConversationGenerator = async () => {
654
+ throw new Error('LLM provider unavailable');
655
+ };
656
+
657
+ await tryMintGuardianActionGrant({
658
+ request: resolved!,
659
+ answerText: 'Sure, go ahead and run it',
660
+ decisionChannel: 'telegram',
661
+ approvalConversationGenerator: failingGenerator,
662
+ });
663
+
664
+ const db = getDb();
665
+ const grants = db.select().from(scopedApprovalGrants).all();
666
+ expect(grants.length).toBe(0);
667
+ });
668
+
669
+ test('no generator provided and unrecognised text produces no grant', async () => {
670
+ const inputDigest = computeToolApprovalDigest(TOOL_NAME, TOOL_INPUT);
671
+
672
+ const request = createGuardianActionRequest({
673
+ assistantId: ASSISTANT_ID,
674
+ kind: 'ask_guardian',
675
+ sourceChannel: 'voice',
676
+ sourceConversationId: CONVERSATION_ID,
677
+ callSessionId: CALL_SESSION_ID,
678
+ pendingQuestionId: nextPendingQuestionId(),
679
+ questionText: 'Can I run the command?',
680
+ expiresAt: Date.now() + 60_000,
681
+ toolName: TOOL_NAME,
682
+ inputDigest,
683
+ });
684
+
685
+ const resolved = resolveGuardianActionRequest(request.id, 'Sure, go ahead and run it', 'telegram');
686
+ expect(resolved).not.toBeNull();
687
+
688
+ // No generator provided — behaves like before, no LLM fallback
689
+ await tryMintGuardianActionGrant({
690
+ request: resolved!,
691
+ answerText: 'Sure, go ahead and run it',
692
+ decisionChannel: 'telegram',
693
+ });
694
+
695
+ const db = getDb();
696
+ const grants = db.select().from(scopedApprovalGrants).all();
697
+ expect(grants.length).toBe(0);
698
+ });
699
+
700
+ test('deterministic "approve always" still mints a one-time grant (normalized to approve_once semantics)', async () => {
701
+ const inputDigest = computeToolApprovalDigest(TOOL_NAME, TOOL_INPUT);
702
+
703
+ const request = createGuardianActionRequest({
704
+ assistantId: ASSISTANT_ID,
705
+ kind: 'ask_guardian',
706
+ sourceChannel: 'voice',
707
+ sourceConversationId: CONVERSATION_ID,
708
+ callSessionId: CALL_SESSION_ID,
709
+ pendingQuestionId: nextPendingQuestionId(),
710
+ questionText: 'Can I run the command?',
711
+ expiresAt: Date.now() + 60_000,
712
+ toolName: TOOL_NAME,
713
+ inputDigest,
714
+ });
715
+
716
+ const resolved = resolveGuardianActionRequest(request.id, 'approve always', 'telegram');
717
+ expect(resolved).not.toBeNull();
718
+
719
+ // Generator should NOT be called -- deterministic parser matches "approve always"
720
+ const generatorSpy: ApprovalConversationGenerator = async () => {
721
+ throw new Error('Generator should not be called for deterministic match');
722
+ };
723
+
724
+ await tryMintGuardianActionGrant({
725
+ request: resolved!,
726
+ answerText: 'approve always',
727
+ decisionChannel: 'telegram',
728
+ approvalConversationGenerator: generatorSpy,
729
+ });
730
+
731
+ // Grant is minted (approve_always treated as approval), but it is still
732
+ // a one-time tool_signature grant -- no broader privilege is granted.
733
+ const db = getDb();
734
+ const grants = db.select().from(scopedApprovalGrants).all();
735
+ expect(grants.length).toBe(1);
736
+ expect(grants[0].scopeMode).toBe('tool_signature');
737
+ });
738
+
739
+ test('LLM fallback allowedActions excludes approve_always (guardian invariant)', async () => {
740
+ const inputDigest = computeToolApprovalDigest(TOOL_NAME, TOOL_INPUT);
741
+
742
+ const request = createGuardianActionRequest({
743
+ assistantId: ASSISTANT_ID,
744
+ kind: 'ask_guardian',
745
+ sourceChannel: 'voice',
746
+ sourceConversationId: CONVERSATION_ID,
747
+ callSessionId: CALL_SESSION_ID,
748
+ pendingQuestionId: nextPendingQuestionId(),
749
+ questionText: 'Can I run the command?',
750
+ expiresAt: Date.now() + 60_000,
751
+ toolName: TOOL_NAME,
752
+ inputDigest,
753
+ });
754
+
755
+ const resolved = resolveGuardianActionRequest(request.id, 'Sure, go ahead and run it', 'telegram');
756
+ expect(resolved).not.toBeNull();
757
+
758
+ // Generator returns approve_always -- but the allowedActions constraint
759
+ // in the minter restricts to approve_once/reject, so the approval-
760
+ // conversation-turn layer will normalize this to keep_pending.
761
+ const mockGenerator: ApprovalConversationGenerator = async () => ({
762
+ disposition: 'approve_always',
763
+ replyText: 'Approved permanently.',
764
+ });
765
+
766
+ await tryMintGuardianActionGrant({
767
+ request: resolved!,
768
+ answerText: 'Sure, go ahead and run it',
769
+ decisionChannel: 'telegram',
770
+ approvalConversationGenerator: mockGenerator,
771
+ });
772
+
773
+ // No grant -- approve_always is not in LLM fallback allowedActions,
774
+ // so the disposition gets normalized to keep_pending (fail-closed).
775
+ const db = getDb();
776
+ const grants = db.select().from(scopedApprovalGrants).all();
777
+ expect(grants.length).toBe(0);
778
+ });
779
+ });