@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
package/Dockerfile CHANGED
@@ -42,11 +42,13 @@ RUN apt-get update && apt-get install -y \
42
42
  fonts-freefont-ttf \
43
43
  make \
44
44
  g++ \
45
+ git \
45
46
  sudo \
46
47
  && rm -rf /var/lib/apt/lists/*
47
48
 
48
49
  # Copy bun binary from builder instead of re-installing
49
50
  COPY --from=builder /root/.bun/bin/bun /usr/local/bin/bun
51
+ RUN ln -sf /usr/local/bin/bun /usr/local/bin/bunx
50
52
 
51
53
  # Create non-root user that also has sudo access so it can like install stuff
52
54
  RUN groupadd --system --gid 1001 assistant && \
package/README.md CHANGED
@@ -194,7 +194,7 @@ The `/channels/inbound` endpoint requires a valid `X-Gateway-Origin` header to p
194
194
 
195
195
  ## Twilio Setup Primitive
196
196
 
197
- Twilio is the shared telephony provider for both voice calls and SMS messaging. Configuration is managed through the `twilio_config` IPC contract and the `twilio-setup` skill.
197
+ Twilio is the shared telephony provider for both voice calls and SMS messaging. Configuration is managed through the `twilio_config` IPC contract and the `twilio-setup` skill. For SMS-specific onboarding (including compliance verification and test sending), the `sms-setup` skill provides a guided conversational flow that layers on top of `twilio-setup`.
198
198
 
199
199
  ### `twilio_config` IPC Contract
200
200
 
@@ -208,8 +208,15 @@ The daemon handles `twilio_config` messages with the following actions:
208
208
  | `provision_number` | Purchases a new phone number via the Twilio API. Accepts optional `areaCode` and `country` (ISO 3166-1 alpha-2, default `US`). Auto-assigns the number to the assistant (persists to config and secure storage) and configures Twilio webhooks (voice, status callback, SMS) when a public ingress URL is available. |
209
209
  | `assign_number` | Assigns an existing Twilio phone number (E.164 format) to the assistant and auto-configures webhooks when ingress is available |
210
210
  | `list_numbers` | Lists all incoming phone numbers on the Twilio account with their capabilities (voice, SMS) |
211
+ | `sms_compliance_status` | Returns the SMS compliance posture for the assigned phone number. Determines number type (toll-free vs local 10DLC) and retrieves toll-free verification status from Twilio. |
212
+ | `sms_submit_tollfree_verification` | Submits a new toll-free verification request to Twilio. Validates required fields and enum values. Defaults `businessType` to `SOLE_PROPRIETOR`. |
213
+ | `sms_update_tollfree_verification` | Updates an existing toll-free verification by SID. Requires `verificationSid`. |
214
+ | `sms_delete_tollfree_verification` | Deletes a toll-free verification by SID. Includes warning about queue priority reset. |
215
+ | `release_number` | Releases (deletes) a phone number from the Twilio account. Clears the number from config and secure storage. Includes warning about toll-free verification context loss. |
216
+ | `sms_send_test` | Sends a test SMS to the specified `phoneNumber` with the given `text`, polls Twilio for delivery status (up to 3 retries at 2-second intervals), and returns the result in `testResult`. Stores the last result in memory for use by `sms_doctor`. |
217
+ | `sms_doctor` | Runs a comprehensive SMS health diagnostic. Checks channel readiness, compliance/toll-free verification status, and the last `sms_send_test` result. Returns structured diagnostics in `diagnostics` with an overall `status` ("healthy", "degraded", or "unhealthy") and actionable `items`. |
211
218
 
212
- Response type: `twilio_config_response` with `success`, `hasCredentials`, optional `phoneNumber`, optional `numbers` array, optional `error`, and optional `warning` (for non-fatal webhook sync failures).
219
+ Response type: `twilio_config_response` with `success`, `hasCredentials`, optional `phoneNumber`, optional `numbers` array, optional `error`, optional `warning` (for non-fatal webhook sync failures), optional `compliance` object (for compliance status actions, containing `numberType`, `verificationSid`, `verificationStatus`, `rejectionReason`, `rejectionReasons`, `errorCode`, `editAllowed`, `editExpiration`), optional `testResult` (for `sms_send_test`), and optional `diagnostics` (for `sms_doctor`).
213
220
 
214
221
  ### Ingress Webhook Reconciliation
215
222
 
@@ -251,6 +258,34 @@ Guardian bindings, verification challenges, and approval requests are all scoped
251
258
 
252
259
  The channel guardian service generates verification challenge instructions with channel-appropriate wording. The `channelLabel()` function maps `sourceChannel` values to human-readable labels (e.g., `"telegram"` -> `"Telegram"`, `"sms"` -> `"SMS"`), so challenge prompts reference the correct channel name.
253
260
 
261
+ ## Channel Readiness
262
+
263
+ The `channel_readiness` IPC contract provides a unified way to check whether a channel (SMS, Telegram, etc.) is fully configured and operational. It runs local checks (credential presence, phone number assignment, ingress config) synchronously and optional remote checks (API reachability) asynchronously with a 5-minute TTL cache.
264
+
265
+ ### `channel_readiness` IPC Contract
266
+
267
+ | Action | Description |
268
+ |--------|-------------|
269
+ | `get` | Returns readiness snapshots for the specified channel (or all channels if omitted). Local checks always run; remote checks run only when `includeRemote=true` and cache is stale. |
270
+ | `refresh` | Invalidates the cache for the specified channel (or all channels), then returns fresh snapshots. |
271
+
272
+ Request fields: `action` (required), `channel` (optional filter), `assistantId` (optional), `includeRemote` (optional boolean).
273
+
274
+ Response type: `channel_readiness_response` with `success`, optional `snapshots` array (each with `channel`, `ready`, `checkedAt`, `stale`, `reasons`, `localChecks`, optional `remoteChecks`), and optional `error`.
275
+
276
+ ### Built-in Channel Probes
277
+
278
+ - **SMS**: Checks Twilio credentials, phone number assignment, and public ingress URL.
279
+ - **Telegram**: Checks bot token, webhook secret, and public ingress URL.
280
+
281
+ ### Key modules
282
+
283
+ | File | Purpose |
284
+ |------|---------|
285
+ | `src/runtime/channel-readiness-types.ts` | Shared types: `ChannelId`, `ReadinessCheckResult`, `ChannelReadinessSnapshot`, `ChannelProbe` |
286
+ | `src/runtime/channel-readiness-service.ts` | Service class with probe registration, cached readiness evaluation, and built-in SMS/Telegram probes |
287
+ | `src/daemon/handlers/config.ts` | `handleChannelReadiness` — IPC handler for `channel_readiness` messages |
288
+
254
289
  ## Database
255
290
 
256
291
  SQLite via Drizzle ORM, stored at `~/.vellum/workspace/data/db/assistant.db`. Key tables include conversations, messages, tool invocations, attachments, memory segments (with FTS5), memory items, entities, reminders, and recurrence schedules (cron + RRULE).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vellumai/assistant",
3
- "version": "0.3.4",
3
+ "version": "0.3.5",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "vellum": "./src/index.ts"
@@ -405,6 +405,19 @@ function emitStruct(s: SwiftStruct): string {
405
405
  lines.push(` public let ${p.swiftName}: ${p.swiftType}`);
406
406
  }
407
407
 
408
+ // Emit public memberwise init (Swift only auto-generates internal inits for public structs)
409
+ if (s.properties.length > 0) {
410
+ lines.push('');
411
+ const params = s.properties
412
+ .map((p) => `${p.swiftName}: ${p.swiftType}${p.isOptional ? ' = nil' : ''}`)
413
+ .join(', ');
414
+ lines.push(` public init(${params}) {`);
415
+ for (const p of s.properties) {
416
+ lines.push(` self.${p.swiftName} = ${p.swiftName}`);
417
+ }
418
+ lines.push(' }');
419
+ }
420
+
408
421
  if (needsCodingKeys(s.properties)) {
409
422
  lines.push('');
410
423
  lines.push(' private enum CodingKeys: String, CodingKey {');
@@ -601,6 +601,15 @@ exports[`IPC message snapshots ClientMessage types twilio_config serializes to e
601
601
  }
602
602
  `;
603
603
 
604
+ exports[`IPC message snapshots ClientMessage types channel_readiness serializes to expected JSON 1`] = `
605
+ {
606
+ "action": "get",
607
+ "channel": "sms",
608
+ "includeRemote": true,
609
+ "type": "channel_readiness",
610
+ }
611
+ `;
612
+
604
613
  exports[`IPC message snapshots ClientMessage types guardian_verification serializes to expected JSON 1`] = `
605
614
  {
606
615
  "action": "create_challenge",
@@ -908,6 +917,20 @@ exports[`IPC message snapshots ClientMessage types tool_names_list serializes to
908
917
  }
909
918
  `;
910
919
 
920
+ exports[`IPC message snapshots ClientMessage types dictation_request serializes to expected JSON 1`] = `
921
+ {
922
+ "context": {
923
+ "appName": "Example App",
924
+ "bundleIdentifier": "com.example.app",
925
+ "cursorInTextField": true,
926
+ "selectedText": "some selected text",
927
+ "windowTitle": "Main Window",
928
+ },
929
+ "transcription": "Hello world",
930
+ "type": "dictation_request",
931
+ }
932
+ `;
933
+
911
934
  exports[`IPC message snapshots ServerMessage types auth_result serializes to expected JSON 1`] = `
912
935
  {
913
936
  "success": true,
@@ -1323,6 +1346,14 @@ exports[`IPC message snapshots ServerMessage types task_routed serializes to exp
1323
1346
  }
1324
1347
  `;
1325
1348
 
1349
+ exports[`IPC message snapshots ServerMessage types ride_shotgun_progress serializes to expected JSON 1`] = `
1350
+ {
1351
+ "message": "Observing user activity...",
1352
+ "type": "ride_shotgun_progress",
1353
+ "watchId": "watch-shotgun-001",
1354
+ }
1355
+ `;
1356
+
1326
1357
  exports[`IPC message snapshots ServerMessage types ride_shotgun_result serializes to expected JSON 1`] = `
1327
1358
  {
1328
1359
  "observationCount": 5,
@@ -1929,13 +1960,74 @@ exports[`IPC message snapshots ServerMessage types telegram_config_response seri
1929
1960
 
1930
1961
  exports[`IPC message snapshots ServerMessage types twilio_config_response serializes to expected JSON 1`] = `
1931
1962
  {
1963
+ "compliance": {
1964
+ "numberType": "toll_free",
1965
+ "verificationSid": "TF_VER_001",
1966
+ "verificationStatus": "TWILIO_APPROVED",
1967
+ },
1968
+ "diagnostics": {
1969
+ "actionItems": [],
1970
+ "compliance": {
1971
+ "detail": "Toll-free verification: TWILIO_APPROVED",
1972
+ "status": "TWILIO_APPROVED",
1973
+ },
1974
+ "overallStatus": "healthy",
1975
+ "readiness": {
1976
+ "issues": [],
1977
+ "ready": true,
1978
+ },
1979
+ },
1932
1980
  "hasCredentials": true,
1933
1981
  "phoneNumber": "+15551234567",
1934
1982
  "success": true,
1983
+ "testResult": {
1984
+ "finalStatus": "delivered",
1985
+ "initialStatus": "queued",
1986
+ "messageSid": "SM-test-001",
1987
+ "to": "+15559876543",
1988
+ },
1935
1989
  "type": "twilio_config_response",
1936
1990
  }
1937
1991
  `;
1938
1992
 
1993
+ exports[`IPC message snapshots ServerMessage types channel_readiness_response serializes to expected JSON 1`] = `
1994
+ {
1995
+ "snapshots": [
1996
+ {
1997
+ "channel": "sms",
1998
+ "checkedAt": 1700000000000,
1999
+ "localChecks": [
2000
+ {
2001
+ "message": "Twilio credentials are not configured",
2002
+ "name": "twilio_credentials",
2003
+ "passed": false,
2004
+ },
2005
+ {
2006
+ "message": "Phone number is assigned",
2007
+ "name": "phone_number",
2008
+ "passed": true,
2009
+ },
2010
+ {
2011
+ "message": "Public ingress URL is configured",
2012
+ "name": "ingress",
2013
+ "passed": true,
2014
+ },
2015
+ ],
2016
+ "ready": false,
2017
+ "reasons": [
2018
+ {
2019
+ "code": "twilio_credentials",
2020
+ "text": "Twilio credentials are not configured",
2021
+ },
2022
+ ],
2023
+ "stale": false,
2024
+ },
2025
+ ],
2026
+ "success": true,
2027
+ "type": "channel_readiness_response",
2028
+ }
2029
+ `;
2030
+
1939
2031
  exports[`IPC message snapshots ServerMessage types guardian_verification_response serializes to expected JSON 1`] = `
1940
2032
  {
1941
2033
  "instruction": "Send this code to the Telegram bot",
@@ -2499,3 +2591,11 @@ exports[`IPC message snapshots ServerMessage types tool_names_list_response seri
2499
2591
  "type": "tool_names_list_response",
2500
2592
  }
2501
2593
  `;
2594
+
2595
+ exports[`IPC message snapshots ServerMessage types dictation_response serializes to expected JSON 1`] = `
2596
+ {
2597
+ "mode": "dictation",
2598
+ "text": "Hello world",
2599
+ "type": "dictation_response",
2600
+ }
2601
+ `;
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Guard test: ensures no production hard-coded approval lifecycle copy creeps
3
+ * back into route/service files outside of the composer module.
4
+ *
5
+ * The composer file (`approval-message-composer.ts`) is intentionally excluded
6
+ * since that is where deterministic fallback copy legitimately lives.
7
+ */
8
+
9
+ import { readFileSync } from 'fs';
10
+ import { join } from 'path';
11
+ import { describe, test, expect } from 'bun:test';
12
+
13
+ const SCANNED_FILES = [
14
+ 'runtime/channel-approvals.ts',
15
+ 'runtime/routes/channel-routes.ts',
16
+ 'runtime/channel-guardian-service.ts',
17
+ ];
18
+
19
+ const BANNED_PATTERNS: { pattern: RegExp; description: string }[] = [
20
+ { pattern: /The assistant wants to use/i, description: 'old standard prompt' },
21
+ { pattern: /Do you want to allow this/i, description: 'old approval question' },
22
+ { pattern: /['"`]I'm still waiting/i, description: 'old reminder prefix (string literal)' },
23
+ { pattern: /['"`].*is requesting to run/i, description: 'old guardian prompt (string literal)' },
24
+ { pattern: /['"`]Sent to guardian/i, description: 'old forwarding notice (string literal)' },
25
+ { pattern: /['"`]Guardian verified successfully/i, description: 'old verify success (string literal)' },
26
+ { pattern: /['"`]Verification failed/i, description: 'old verify failure (string literal)' },
27
+ { pattern: /['"`]Your request has been sent/i, description: 'old request forwarded notice (string literal)' },
28
+ { pattern: /['"`]No guardian is configured/i, description: 'old no-binding notice (string literal)' },
29
+ ];
30
+
31
+ describe('approval hardcoded copy guard', () => {
32
+ for (const file of SCANNED_FILES) {
33
+ test(`${file} does not contain banned approval copy literals`, () => {
34
+ const content = readFileSync(join(__dirname, '..', file), 'utf-8');
35
+ for (const { pattern, description: _description } of BANNED_PATTERNS) {
36
+ const match = content.match(pattern);
37
+ expect(match).toBeNull();
38
+ }
39
+ });
40
+ }
41
+ });
@@ -0,0 +1,253 @@
1
+ import { describe, test, expect } from 'bun:test';
2
+ import {
3
+ composeApprovalMessage,
4
+ getFallbackMessage,
5
+ } from '../runtime/approval-message-composer.js';
6
+ import type {
7
+ ApprovalMessageScenario,
8
+ ApprovalMessageContext,
9
+ } from '../runtime/approval-message-composer.js';
10
+
11
+ // ---------------------------------------------------------------------------
12
+ // Every scenario must produce a non-empty string
13
+ // ---------------------------------------------------------------------------
14
+
15
+ const ALL_SCENARIOS: ApprovalMessageScenario[] = [
16
+ 'standard_prompt',
17
+ 'guardian_prompt',
18
+ 'reminder_prompt',
19
+ 'guardian_delivery_failed',
20
+ 'guardian_request_forwarded',
21
+ 'guardian_disambiguation',
22
+ 'guardian_identity_mismatch',
23
+ 'request_pending_guardian',
24
+ 'guardian_decision_outcome',
25
+ 'guardian_expired_requester',
26
+ 'guardian_expired_guardian',
27
+ 'guardian_verify_success',
28
+ 'guardian_verify_failed',
29
+ 'guardian_verify_challenge_setup',
30
+ 'guardian_verify_status_bound',
31
+ 'guardian_verify_status_unbound',
32
+ 'guardian_deny_no_identity',
33
+ 'guardian_deny_no_binding',
34
+ ];
35
+
36
+ describe('approval-message-composer', () => {
37
+ // -----------------------------------------------------------------------
38
+ // Fallback messages — every scenario produces non-empty output
39
+ // -----------------------------------------------------------------------
40
+
41
+ describe('getFallbackMessage', () => {
42
+ for (const scenario of ALL_SCENARIOS) {
43
+ test(`scenario "${scenario}" produces a non-empty string`, () => {
44
+ const msg = getFallbackMessage({ scenario });
45
+ expect(typeof msg).toBe('string');
46
+ expect(msg.trim().length).toBeGreaterThan(0);
47
+ });
48
+ }
49
+
50
+ test('standard_prompt includes toolName when provided', () => {
51
+ const msg = getFallbackMessage({
52
+ scenario: 'standard_prompt',
53
+ toolName: 'execute_shell',
54
+ });
55
+ expect(msg).toContain('execute_shell');
56
+ });
57
+
58
+ test('guardian_prompt includes requester identifier and toolName', () => {
59
+ const msg = getFallbackMessage({
60
+ scenario: 'guardian_prompt',
61
+ toolName: 'write_file',
62
+ requesterIdentifier: 'alice',
63
+ });
64
+ expect(msg).toContain('alice');
65
+ expect(msg).toContain('write_file');
66
+ });
67
+
68
+ test('guardian_delivery_failed includes toolName when provided', () => {
69
+ const msg = getFallbackMessage({
70
+ scenario: 'guardian_delivery_failed',
71
+ toolName: 'execute_shell',
72
+ });
73
+ expect(msg).toContain('execute_shell');
74
+ });
75
+
76
+ test('guardian_request_forwarded includes toolName', () => {
77
+ const msg = getFallbackMessage({
78
+ scenario: 'guardian_request_forwarded',
79
+ toolName: 'execute_shell',
80
+ });
81
+ expect(msg).toContain('execute_shell');
82
+ });
83
+
84
+ test('guardian_disambiguation includes pendingCount', () => {
85
+ const msg = getFallbackMessage({
86
+ scenario: 'guardian_disambiguation',
87
+ pendingCount: 3,
88
+ });
89
+ expect(msg).toContain('3');
90
+ });
91
+
92
+ test('guardian_decision_outcome includes decision and toolName', () => {
93
+ const msg = getFallbackMessage({
94
+ scenario: 'guardian_decision_outcome',
95
+ decision: 'approved',
96
+ toolName: 'read_file',
97
+ });
98
+ expect(msg).toContain('approved');
99
+ expect(msg).toContain('read_file');
100
+ });
101
+
102
+ test('guardian_expired_requester includes toolName', () => {
103
+ const msg = getFallbackMessage({
104
+ scenario: 'guardian_expired_requester',
105
+ toolName: 'deploy',
106
+ });
107
+ expect(msg).toContain('deploy');
108
+ expect(msg).toContain('expired');
109
+ });
110
+
111
+ test('guardian_expired_guardian includes requester and toolName', () => {
112
+ const msg = getFallbackMessage({
113
+ scenario: 'guardian_expired_guardian',
114
+ requesterIdentifier: 'bob',
115
+ toolName: 'delete_file',
116
+ });
117
+ expect(msg).toContain('bob');
118
+ expect(msg).toContain('delete_file');
119
+ });
120
+
121
+ test('guardian_verify_failed includes failureReason', () => {
122
+ const msg = getFallbackMessage({
123
+ scenario: 'guardian_verify_failed',
124
+ failureReason: 'Code did not match.',
125
+ });
126
+ expect(msg).toContain('Code did not match.');
127
+ });
128
+
129
+ test('guardian_verify_challenge_setup includes verifyCommand and ttlSeconds', () => {
130
+ const msg = getFallbackMessage({
131
+ scenario: 'guardian_verify_challenge_setup',
132
+ verifyCommand: '/verify abc123',
133
+ ttlSeconds: 30,
134
+ });
135
+ expect(msg).toContain('/verify abc123');
136
+ expect(msg).toContain('30');
137
+ });
138
+ });
139
+
140
+ // -----------------------------------------------------------------------
141
+ // composeApprovalMessage — layered source selection
142
+ // -----------------------------------------------------------------------
143
+
144
+ describe('composeApprovalMessage', () => {
145
+ test('returns assistantPreface when provided (primary source)', () => {
146
+ const preface = 'The assistant already said something helpful.';
147
+ const msg = composeApprovalMessage({
148
+ scenario: 'standard_prompt',
149
+ toolName: 'execute_shell',
150
+ assistantPreface: preface,
151
+ });
152
+ expect(msg).toBe(preface);
153
+ });
154
+
155
+ test('ignores empty assistantPreface and falls back to template', () => {
156
+ const msg = composeApprovalMessage({
157
+ scenario: 'standard_prompt',
158
+ toolName: 'execute_shell',
159
+ assistantPreface: '',
160
+ });
161
+ expect(msg).toContain('execute_shell');
162
+ expect(msg).not.toBe('');
163
+ });
164
+
165
+ test('ignores whitespace-only assistantPreface', () => {
166
+ const msg = composeApprovalMessage({
167
+ scenario: 'standard_prompt',
168
+ toolName: 'execute_shell',
169
+ assistantPreface: ' ',
170
+ });
171
+ expect(msg).toContain('execute_shell');
172
+ });
173
+
174
+ test('falls back to deterministic template when no assistantPreface', () => {
175
+ const msg = composeApprovalMessage({
176
+ scenario: 'guardian_prompt',
177
+ toolName: 'write_file',
178
+ requesterIdentifier: 'charlie',
179
+ });
180
+ expect(msg).toContain('charlie');
181
+ expect(msg).toContain('write_file');
182
+ });
183
+
184
+ test('fallback matches getFallbackMessage output', () => {
185
+ const ctx: ApprovalMessageContext = {
186
+ scenario: 'reminder_prompt',
187
+ };
188
+ expect(composeApprovalMessage(ctx)).toBe(getFallbackMessage(ctx));
189
+ });
190
+ });
191
+
192
+ // -----------------------------------------------------------------------
193
+ // Verification scenario resilience — composed messages contain key facts
194
+ // -----------------------------------------------------------------------
195
+
196
+ describe('verification scenario resilience', () => {
197
+ test('guardian_verify_challenge_setup includes verify command and TTL', () => {
198
+ const msg = composeApprovalMessage({
199
+ scenario: 'guardian_verify_challenge_setup',
200
+ verifyCommand: '/guardian_verify abc123def456',
201
+ ttlSeconds: 600,
202
+ });
203
+ expect(typeof msg).toBe('string');
204
+ expect(msg.length).toBeGreaterThan(0);
205
+ expect(msg).toContain('/guardian_verify abc123def456');
206
+ expect(msg).toContain('600');
207
+ });
208
+
209
+ test('guardian_verify_failed includes failure reason', () => {
210
+ const msg = composeApprovalMessage({
211
+ scenario: 'guardian_verify_failed',
212
+ failureReason: 'Too many attempts. Please try again later.',
213
+ });
214
+ expect(typeof msg).toBe('string');
215
+ expect(msg.length).toBeGreaterThan(0);
216
+ expect(msg).toContain('Too many attempts');
217
+ });
218
+
219
+ test('guardian_verify_failed with invalid-or-expired reason includes that reason', () => {
220
+ const msg = composeApprovalMessage({
221
+ scenario: 'guardian_verify_failed',
222
+ failureReason: 'The verification code is invalid or has expired.',
223
+ });
224
+ expect(typeof msg).toBe('string');
225
+ expect(msg.length).toBeGreaterThan(0);
226
+ expect(msg).toContain('invalid or has expired');
227
+ });
228
+
229
+ test('guardian_verify_success produces a non-empty success message', () => {
230
+ const msg = composeApprovalMessage({
231
+ scenario: 'guardian_verify_success',
232
+ });
233
+ expect(typeof msg).toBe('string');
234
+ expect(msg.length).toBeGreaterThan(0);
235
+ });
236
+
237
+ test('guardian_verify_status_bound produces a non-empty message', () => {
238
+ const msg = composeApprovalMessage({
239
+ scenario: 'guardian_verify_status_bound',
240
+ });
241
+ expect(typeof msg).toBe('string');
242
+ expect(msg.length).toBeGreaterThan(0);
243
+ });
244
+
245
+ test('guardian_verify_status_unbound produces a non-empty message', () => {
246
+ const msg = composeApprovalMessage({
247
+ scenario: 'guardian_verify_status_unbound',
248
+ });
249
+ expect(typeof msg).toBe('string');
250
+ expect(msg.length).toBeGreaterThan(0);
251
+ });
252
+ });
253
+ });
@@ -34,10 +34,10 @@ mock.module('../util/logger.js', () => ({
34
34
  }));
35
35
 
36
36
  mock.module('../calls/twilio-config.js', () => ({
37
- getTwilioConfig: () => ({
37
+ getTwilioConfig: (assistantId?: string) => ({
38
38
  accountSid: 'AC_test',
39
39
  authToken: 'test_token',
40
- phoneNumber: '+15550001111',
40
+ phoneNumber: assistantId === 'ast-alpha' ? '+15550003333' : '+15550001111',
41
41
  webhookBaseUrl: 'https://test.example.com',
42
42
  wssBaseUrl: 'wss://test.example.com',
43
43
  }),
@@ -97,6 +97,16 @@ describe('resolveCallerIdentity — strict implicit-default policy', () => {
97
97
  }
98
98
  });
99
99
 
100
+ test('assistant_number resolves from assistant-scoped Twilio number when assistantId is provided', async () => {
101
+ const result = await resolveCallerIdentity(makeConfig(), undefined, 'ast-alpha');
102
+ expect(result.ok).toBe(true);
103
+ if (result.ok) {
104
+ expect(result.mode).toBe('assistant_number');
105
+ expect(result.fromNumber).toBe('+15550003333');
106
+ expect(result.source).toBe('implicit_default');
107
+ }
108
+ });
109
+
100
110
  test('explicit user_number succeeds when eligible', async () => {
101
111
  const result = await resolveCallerIdentity(
102
112
  makeConfig({ userNumber: '+15550002222' }),
@@ -38,6 +38,7 @@ mock.module('../config/user-reference.js', () => ({
38
38
  // ── Config mock ─────────────────────────────────────────────────────
39
39
 
40
40
  let mockCallModel: string | undefined = undefined;
41
+ let mockDisclosure: { enabled: boolean; text: string } = { enabled: false, text: '' };
41
42
 
42
43
  mock.module('../config/loader.js', () => ({
43
44
  getConfig: () => ({
@@ -49,7 +50,7 @@ mock.module('../config/loader.js', () => ({
49
50
  userConsultTimeoutSeconds: 90,
50
51
  userConsultationTimeoutSeconds: 90,
51
52
  silenceTimeoutSeconds: 30,
52
- disclosure: { enabled: false, text: '' },
53
+ disclosure: mockDisclosure,
53
54
  safety: { denyCategories: [] },
54
55
  model: mockCallModel,
55
56
  },
@@ -206,6 +207,7 @@ describe('call-orchestrator', () => {
206
207
  resetTables();
207
208
  mockCallModel = undefined;
208
209
  mockUserReference = 'my human';
210
+ mockDisclosure = { enabled: false, text: '' };
209
211
  // Reset the stream mock to default behaviour
210
212
  mockStreamFn.mockImplementation(() => createMockStream(['Hello', ' there']));
211
213
  });
@@ -289,6 +291,30 @@ describe('call-orchestrator', () => {
289
291
  orchestrator.destroy();
290
292
  });
291
293
 
294
+ test('strips USER_ANSWERED and USER_INSTRUCTION markers from spoken output', async () => {
295
+ mockStreamFn.mockImplementation(() =>
296
+ createMockStream([
297
+ 'Thanks for waiting. ',
298
+ '[USER_ANSWERED: The guardian said 3 PM works.] ',
299
+ '[USER_INSTRUCTION: Keep this short.] ',
300
+ 'I can confirm 3 PM works.',
301
+ ]),
302
+ );
303
+ const { relay, orchestrator } = setupOrchestrator();
304
+
305
+ await orchestrator.handleCallerUtterance('Any update?');
306
+
307
+ const allText = relay.sentTokens.map((t) => t.token).join('');
308
+ expect(allText).toContain('Thanks for waiting.');
309
+ expect(allText).toContain('I can confirm 3 PM works.');
310
+ expect(allText).not.toContain('[USER_ANSWERED:');
311
+ expect(allText).not.toContain('[USER_INSTRUCTION:');
312
+ expect(allText).not.toContain('USER_ANSWERED');
313
+ expect(allText).not.toContain('USER_INSTRUCTION');
314
+
315
+ orchestrator.destroy();
316
+ });
317
+
292
318
  // ── END_CALL pattern ──────────────────────────────────────────────
293
319
 
294
320
  test('END_CALL pattern: detects marker, calls endSession, updates status to completed', async () => {
@@ -943,4 +969,47 @@ describe('call-orchestrator', () => {
943
969
  await orchestrator.handleCallerUtterance('Test');
944
970
  orchestrator.destroy();
945
971
  });
972
+
973
+ test('system prompt uses disclosure text when disclosure is enabled', async () => {
974
+ mockDisclosure = {
975
+ enabled: true,
976
+ 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".',
977
+ };
978
+ mockStreamFn.mockImplementation((...args: unknown[]) => {
979
+ const firstArg = args[0] as { system: string };
980
+ expect(firstArg.system).toContain('introduce yourself as an assistant calling on behalf of the person you represent');
981
+ expect(firstArg.system).toContain('Do not say "AI assistant"');
982
+ return createMockStream(['Hello, I am calling on behalf of my human.']);
983
+ });
984
+
985
+ const { orchestrator } = setupOrchestrator();
986
+ await orchestrator.handleCallerUtterance('Who is this?');
987
+ orchestrator.destroy();
988
+ });
989
+
990
+ test('system prompt falls back to "Begin the conversation naturally" when disclosure is disabled', async () => {
991
+ mockDisclosure = { enabled: false, text: '' };
992
+ mockStreamFn.mockImplementation((...args: unknown[]) => {
993
+ const firstArg = args[0] as { system: string };
994
+ expect(firstArg.system).toContain('Begin the conversation naturally');
995
+ expect(firstArg.system).not.toContain('introduce yourself as an assistant calling on behalf of the person');
996
+ return createMockStream(['Hello there.']);
997
+ });
998
+
999
+ const { orchestrator } = setupOrchestrator();
1000
+ await orchestrator.handleCallerUtterance('Hi');
1001
+ orchestrator.destroy();
1002
+ });
1003
+
1004
+ test('system prompt does not use "AI assistant" as a self-identity label', async () => {
1005
+ mockStreamFn.mockImplementation((...args: unknown[]) => {
1006
+ const firstArg = args[0] as { system: string };
1007
+ expect(firstArg.system).not.toMatch(/(?:you are|call yourself|introduce yourself as).*AI assistant/i);
1008
+ return createMockStream(['Got it.']);
1009
+ });
1010
+
1011
+ const { orchestrator } = setupOrchestrator();
1012
+ await orchestrator.handleCallerUtterance('Hello');
1013
+ orchestrator.destroy();
1014
+ });
946
1015
  });