@vellumai/assistant 0.3.27 → 0.4.0

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 (247) hide show
  1. package/ARCHITECTURE.md +81 -4
  2. package/Dockerfile +2 -2
  3. package/bun.lock +4 -1
  4. package/docs/trusted-contact-access.md +9 -2
  5. package/package.json +6 -3
  6. package/scripts/ipc/generate-swift.ts +9 -5
  7. package/src/__tests__/__snapshots__/ipc-snapshot.test.ts.snap +80 -0
  8. package/src/__tests__/agent-loop-thinking.test.ts +1 -1
  9. package/src/__tests__/agent-loop.test.ts +119 -0
  10. package/src/__tests__/approval-routes-http.test.ts +13 -5
  11. package/src/__tests__/asset-materialize-tool.test.ts +2 -0
  12. package/src/__tests__/asset-search-tool.test.ts +2 -0
  13. package/src/__tests__/assistant-events-sse-hardening.test.ts +4 -2
  14. package/src/__tests__/attachments-store.test.ts +2 -0
  15. package/src/__tests__/browser-skill-endstate.test.ts +3 -3
  16. package/src/__tests__/bundled-asset.test.ts +107 -0
  17. package/src/__tests__/call-controller.test.ts +30 -29
  18. package/src/__tests__/call-routes-http.test.ts +34 -32
  19. package/src/__tests__/call-start-guardian-guard.test.ts +2 -0
  20. package/src/__tests__/canonical-guardian-store.test.ts +636 -0
  21. package/src/__tests__/channel-approval-routes.test.ts +174 -1
  22. package/src/__tests__/channel-invite-transport.test.ts +6 -6
  23. package/src/__tests__/channel-reply-delivery.test.ts +19 -0
  24. package/src/__tests__/channel-retry-sweep.test.ts +130 -0
  25. package/src/__tests__/clarification-resolver.test.ts +2 -0
  26. package/src/__tests__/claude-code-skill-regression.test.ts +2 -0
  27. package/src/__tests__/claude-code-tool-profiles.test.ts +2 -0
  28. package/src/__tests__/commit-message-enrichment-service.test.ts +9 -1
  29. package/src/__tests__/computer-use-session-lifecycle.test.ts +2 -0
  30. package/src/__tests__/computer-use-session-working-dir.test.ts +1 -0
  31. package/src/__tests__/computer-use-skill-lifecycle-cleanup.test.ts +2 -0
  32. package/src/__tests__/config-schema.test.ts +5 -5
  33. package/src/__tests__/config-watcher.test.ts +3 -1
  34. package/src/__tests__/connection-policy.test.ts +14 -5
  35. package/src/__tests__/contacts-tools.test.ts +3 -1
  36. package/src/__tests__/contradiction-checker.test.ts +2 -0
  37. package/src/__tests__/conversation-pairing.test.ts +10 -0
  38. package/src/__tests__/conversation-routes.test.ts +1 -1
  39. package/src/__tests__/credential-security-invariants.test.ts +16 -6
  40. package/src/__tests__/credential-vault-unit.test.ts +2 -2
  41. package/src/__tests__/credential-vault.test.ts +5 -4
  42. package/src/__tests__/daemon-lifecycle.test.ts +9 -0
  43. package/src/__tests__/daemon-server-session-init.test.ts +27 -0
  44. package/src/__tests__/elevenlabs-config.test.ts +2 -0
  45. package/src/__tests__/emit-signal-routing-intent.test.ts +43 -1
  46. package/src/__tests__/encrypted-store.test.ts +10 -5
  47. package/src/__tests__/followup-tools.test.ts +3 -1
  48. package/src/__tests__/gateway-only-enforcement.test.ts +21 -21
  49. package/src/__tests__/gmail-integration.test.ts +0 -1
  50. package/src/__tests__/guardian-actions-endpoint.test.ts +205 -345
  51. package/src/__tests__/guardian-control-plane-policy.test.ts +19 -19
  52. package/src/__tests__/guardian-decision-primitive-canonical.test.ts +599 -0
  53. package/src/__tests__/guardian-dispatch.test.ts +21 -19
  54. package/src/__tests__/guardian-grant-minting.test.ts +68 -1
  55. package/src/__tests__/guardian-outbound-http.test.ts +12 -9
  56. package/src/__tests__/guardian-routing-invariants.test.ts +1092 -0
  57. package/src/__tests__/handle-user-message-secret-resume.test.ts +1 -0
  58. package/src/__tests__/handlers-slack-config.test.ts +3 -1
  59. package/src/__tests__/handlers-telegram-config.test.ts +3 -1
  60. package/src/__tests__/handlers-twilio-config.test.ts +3 -1
  61. package/src/__tests__/handlers-twitter-config.test.ts +3 -1
  62. package/src/__tests__/handlers-user-message-approval-consumption.test.ts +318 -0
  63. package/src/__tests__/heartbeat-service.test.ts +20 -0
  64. package/src/__tests__/inbound-invite-redemption.test.ts +33 -0
  65. package/src/__tests__/ingress-reconcile.test.ts +3 -1
  66. package/src/__tests__/ingress-routes-http.test.ts +231 -4
  67. package/src/__tests__/intent-routing.test.ts +2 -0
  68. package/src/__tests__/ipc-snapshot.test.ts +13 -0
  69. package/src/__tests__/mcp-cli.test.ts +77 -0
  70. package/src/__tests__/media-generate-image.test.ts +21 -0
  71. package/src/__tests__/media-reuse-story.e2e.test.ts +2 -0
  72. package/src/__tests__/memory-regressions.test.ts +20 -20
  73. package/src/__tests__/non-member-access-request.test.ts +212 -36
  74. package/src/__tests__/notification-decision-fallback.test.ts +63 -3
  75. package/src/__tests__/notification-decision-strategy.test.ts +78 -0
  76. package/src/__tests__/notification-guardian-path.test.ts +15 -15
  77. package/src/__tests__/oauth-connect-handler.test.ts +3 -1
  78. package/src/__tests__/oauth2-gateway-transport.test.ts +2 -0
  79. package/src/__tests__/onboarding-starter-tasks.test.ts +4 -4
  80. package/src/__tests__/onboarding-template-contract.test.ts +116 -21
  81. package/src/__tests__/pairing-routes.test.ts +171 -0
  82. package/src/__tests__/playbook-execution.test.ts +3 -1
  83. package/src/__tests__/playbook-tools.test.ts +3 -1
  84. package/src/__tests__/provider-error-scenarios.test.ts +59 -8
  85. package/src/__tests__/proxy-approval-callback.test.ts +2 -0
  86. package/src/__tests__/recording-handler.test.ts +11 -0
  87. package/src/__tests__/recording-intent-handler.test.ts +15 -0
  88. package/src/__tests__/recording-state-machine.test.ts +13 -2
  89. package/src/__tests__/registry.test.ts +7 -3
  90. package/src/__tests__/relay-server.test.ts +148 -28
  91. package/src/__tests__/runtime-attachment-metadata.test.ts +4 -2
  92. package/src/__tests__/runtime-events-sse-parity.test.ts +21 -0
  93. package/src/__tests__/runtime-events-sse.test.ts +4 -2
  94. package/src/__tests__/sandbox-diagnostics.test.ts +2 -0
  95. package/src/__tests__/schedule-tools.test.ts +3 -1
  96. package/src/__tests__/secret-scanner-executor.test.ts +59 -0
  97. package/src/__tests__/secret-scanner.test.ts +8 -0
  98. package/src/__tests__/send-endpoint-busy.test.ts +4 -0
  99. package/src/__tests__/sensitive-output-placeholders.test.ts +208 -0
  100. package/src/__tests__/session-abort-tool-results.test.ts +23 -0
  101. package/src/__tests__/session-agent-loop.test.ts +16 -0
  102. package/src/__tests__/session-conflict-gate.test.ts +21 -0
  103. package/src/__tests__/session-load-history-repair.test.ts +27 -17
  104. package/src/__tests__/session-pre-run-repair.test.ts +23 -0
  105. package/src/__tests__/session-profile-injection.test.ts +21 -0
  106. package/src/__tests__/session-provider-retry-repair.test.ts +20 -0
  107. package/src/__tests__/session-queue.test.ts +23 -0
  108. package/src/__tests__/session-runtime-assembly.test.ts +126 -59
  109. package/src/__tests__/session-skill-tools.test.ts +27 -5
  110. package/src/__tests__/session-slash-known.test.ts +23 -0
  111. package/src/__tests__/session-slash-queue.test.ts +23 -0
  112. package/src/__tests__/session-slash-unknown.test.ts +23 -0
  113. package/src/__tests__/session-workspace-cache-state.test.ts +7 -0
  114. package/src/__tests__/session-workspace-injection.test.ts +21 -0
  115. package/src/__tests__/session-workspace-tool-tracking.test.ts +21 -0
  116. package/src/__tests__/shell-credential-ref.test.ts +2 -0
  117. package/src/__tests__/skill-feature-flags-integration.test.ts +6 -6
  118. package/src/__tests__/skill-load-feature-flag.test.ts +5 -4
  119. package/src/__tests__/skill-projection-feature-flag.test.ts +22 -0
  120. package/src/__tests__/skills.test.ts +8 -4
  121. package/src/__tests__/slack-channel-config.test.ts +3 -1
  122. package/src/__tests__/subagent-tools.test.ts +19 -0
  123. package/src/__tests__/swarm-recursion.test.ts +2 -0
  124. package/src/__tests__/swarm-session-integration.test.ts +2 -0
  125. package/src/__tests__/swarm-tool.test.ts +2 -0
  126. package/src/__tests__/system-prompt.test.ts +3 -1
  127. package/src/__tests__/task-compiler.test.ts +3 -1
  128. package/src/__tests__/task-management-tools.test.ts +3 -1
  129. package/src/__tests__/task-tools.test.ts +3 -1
  130. package/src/__tests__/terminal-sandbox.test.ts +13 -12
  131. package/src/__tests__/terminal-tools.test.ts +2 -0
  132. package/src/__tests__/tool-approval-handler.test.ts +15 -15
  133. package/src/__tests__/tool-execution-abort-cleanup.test.ts +2 -0
  134. package/src/__tests__/tool-execution-pipeline.benchmark.test.ts +2 -0
  135. package/src/__tests__/tool-grant-request-escalation.test.ts +497 -0
  136. package/src/__tests__/trusted-contact-lifecycle-notifications.test.ts +48 -0
  137. package/src/__tests__/trusted-contact-multichannel.test.ts +22 -19
  138. package/src/__tests__/trusted-contact-verification.test.ts +91 -0
  139. package/src/__tests__/twilio-routes-elevenlabs.test.ts +2 -0
  140. package/src/__tests__/twitter-auth-handler.test.ts +3 -1
  141. package/src/__tests__/twitter-cli-routing.test.ts +3 -1
  142. package/src/__tests__/view-image-tool.test.ts +3 -1
  143. package/src/__tests__/voice-invite-redemption.test.ts +329 -0
  144. package/src/__tests__/voice-scoped-grant-consumer.test.ts +7 -5
  145. package/src/__tests__/voice-session-bridge.test.ts +10 -10
  146. package/src/__tests__/work-item-output.test.ts +3 -1
  147. package/src/__tests__/workspace-lifecycle.test.ts +13 -2
  148. package/src/agent/loop.ts +46 -3
  149. package/src/approvals/guardian-decision-primitive.ts +285 -0
  150. package/src/approvals/guardian-request-resolvers.ts +539 -0
  151. package/src/calls/call-controller.ts +26 -23
  152. package/src/calls/guardian-action-sweep.ts +10 -2
  153. package/src/calls/guardian-dispatch.ts +46 -40
  154. package/src/calls/relay-server.ts +358 -24
  155. package/src/calls/types.ts +1 -1
  156. package/src/calls/voice-session-bridge.ts +3 -3
  157. package/src/cli.ts +12 -0
  158. package/src/config/agent-schema.ts +14 -3
  159. package/src/config/calls-schema.ts +6 -6
  160. package/src/config/core-schema.ts +3 -3
  161. package/src/config/feature-flag-registry.json +8 -0
  162. package/src/config/mcp-schema.ts +1 -1
  163. package/src/config/memory-schema.ts +27 -19
  164. package/src/config/schema.ts +21 -21
  165. package/src/config/skills-schema.ts +7 -7
  166. package/src/config/system-prompt.ts +2 -1
  167. package/src/config/templates/BOOTSTRAP.md +47 -31
  168. package/src/config/templates/USER.md +5 -0
  169. package/src/config/update-bulletin-template-path.ts +4 -1
  170. package/src/config/vellum-skills/trusted-contacts/SKILL.md +149 -21
  171. package/src/daemon/handlers/config-inbox.ts +4 -4
  172. package/src/daemon/handlers/guardian-actions.ts +45 -66
  173. package/src/daemon/handlers/sessions.ts +148 -4
  174. package/src/daemon/ipc-contract/guardian-actions.ts +7 -0
  175. package/src/daemon/ipc-contract/messages.ts +16 -0
  176. package/src/daemon/ipc-contract-inventory.json +1 -0
  177. package/src/daemon/lifecycle.ts +22 -16
  178. package/src/daemon/pairing-store.ts +86 -3
  179. package/src/daemon/server.ts +18 -0
  180. package/src/daemon/session-agent-loop-handlers.ts +5 -4
  181. package/src/daemon/session-agent-loop.ts +33 -6
  182. package/src/daemon/session-lifecycle.ts +25 -17
  183. package/src/daemon/session-memory.ts +2 -2
  184. package/src/daemon/session-process.ts +68 -326
  185. package/src/daemon/session-runtime-assembly.ts +119 -25
  186. package/src/daemon/session-tool-setup.ts +3 -2
  187. package/src/daemon/session.ts +4 -3
  188. package/src/home-base/prebuilt/seed.ts +2 -1
  189. package/src/hooks/templates.ts +2 -1
  190. package/src/memory/canonical-guardian-store.ts +586 -0
  191. package/src/memory/channel-guardian-store.ts +2 -0
  192. package/src/memory/conversation-crud.ts +7 -7
  193. package/src/memory/db-init.ts +20 -0
  194. package/src/memory/embedding-local.ts +257 -39
  195. package/src/memory/embedding-runtime-manager.ts +471 -0
  196. package/src/memory/guardian-action-store.ts +7 -60
  197. package/src/memory/guardian-approvals.ts +9 -4
  198. package/src/memory/guardian-bindings.ts +25 -1
  199. package/src/memory/indexer.ts +3 -3
  200. package/src/memory/ingress-invite-store.ts +45 -0
  201. package/src/memory/job-handlers/backfill.ts +16 -9
  202. package/src/memory/migrations/036-normalize-phone-identities.ts +289 -0
  203. package/src/memory/migrations/037-voice-invite-columns.ts +16 -0
  204. package/src/memory/migrations/118-reminder-routing-intent.ts +3 -3
  205. package/src/memory/migrations/121-canonical-guardian-requests.ts +59 -0
  206. package/src/memory/migrations/122-canonical-guardian-requester-chat-id.ts +15 -0
  207. package/src/memory/migrations/123-canonical-guardian-deliveries-destination-index.ts +15 -0
  208. package/src/memory/migrations/index.ts +5 -0
  209. package/src/memory/migrations/registry.ts +5 -0
  210. package/src/memory/qdrant-client.ts +31 -22
  211. package/src/memory/schema-migration.ts +1 -0
  212. package/src/memory/schema.ts +56 -0
  213. package/src/notifications/copy-composer.ts +31 -4
  214. package/src/notifications/decision-engine.ts +57 -0
  215. package/src/permissions/defaults.ts +2 -0
  216. package/src/runtime/access-request-helper.ts +173 -0
  217. package/src/runtime/actor-trust-resolver.ts +221 -0
  218. package/src/runtime/channel-guardian-service.ts +12 -4
  219. package/src/runtime/channel-invite-transports/voice.ts +58 -0
  220. package/src/runtime/channel-retry-sweep.ts +18 -6
  221. package/src/runtime/guardian-context-resolver.ts +38 -71
  222. package/src/runtime/guardian-decision-types.ts +6 -0
  223. package/src/runtime/guardian-reply-router.ts +717 -0
  224. package/src/runtime/http-server.ts +8 -0
  225. package/src/runtime/ingress-service.ts +80 -3
  226. package/src/runtime/invite-redemption-service.ts +141 -2
  227. package/src/runtime/routes/canonical-guardian-expiry-sweep.ts +116 -0
  228. package/src/runtime/routes/channel-route-shared.ts +1 -1
  229. package/src/runtime/routes/channel-routes.ts +1 -1
  230. package/src/runtime/routes/conversation-routes.ts +20 -2
  231. package/src/runtime/routes/guardian-action-routes.ts +100 -109
  232. package/src/runtime/routes/guardian-approval-interception.ts +17 -6
  233. package/src/runtime/routes/inbound-message-handler.ts +205 -529
  234. package/src/runtime/routes/ingress-routes.ts +52 -4
  235. package/src/runtime/routes/pairing-routes.ts +3 -0
  236. package/src/runtime/tool-grant-request-helper.ts +195 -0
  237. package/src/tools/executor.ts +13 -1
  238. package/src/tools/guardian-control-plane-policy.ts +2 -2
  239. package/src/tools/sensitive-output-placeholders.ts +203 -0
  240. package/src/tools/tool-approval-handler.ts +53 -10
  241. package/src/tools/types.ts +13 -2
  242. package/src/util/bundled-asset.ts +31 -0
  243. package/src/util/canonicalize-identity.ts +52 -0
  244. package/src/util/logger.ts +20 -8
  245. package/src/util/platform.ts +10 -0
  246. package/src/util/voice-code.ts +29 -0
  247. package/src/daemon/guardian-invite-intent.ts +0 -124
@@ -6,19 +6,21 @@ import { describe, expect,test } from 'bun:test';
6
6
  const templatesDir = join(import.meta.dirname, '..', 'config', 'templates');
7
7
  const bootstrap = readFileSync(join(templatesDir, 'BOOTSTRAP.md'), 'utf-8');
8
8
  const identity = readFileSync(join(templatesDir, 'IDENTITY.md'), 'utf-8');
9
+ const user = readFileSync(join(templatesDir, 'USER.md'), 'utf-8');
9
10
 
10
11
  describe('onboarding template contracts', () => {
11
12
  describe('BOOTSTRAP.md', () => {
12
13
  test('contains identity question prompts', () => {
13
14
  const lower = bootstrap.toLowerCase();
14
15
  expect(lower).toContain('who am i');
15
- expect(lower).toContain('who are you');
16
16
  });
17
17
 
18
- test('uses "personality" for the personality step', () => {
19
- expect(bootstrap).toContain('What is my personality?');
20
- // Should not use "character" or "vibe" as a field/step label
21
- expect(bootstrap).not.toMatch(/what is my (character|vibe)/i);
18
+ test('infers personality indirectly instead of asking directly', () => {
19
+ const lower = bootstrap.toLowerCase();
20
+ // Personality step must instruct indirect/organic discovery
21
+ expect(lower).toContain('personality');
22
+ expect(lower).toContain('indirectly');
23
+ expect(lower).toContain('vibe');
22
24
  });
23
25
 
24
26
  test('contains emoji auto-selection with change-later instruction', () => {
@@ -27,30 +29,106 @@ describe('onboarding template contracts', () => {
27
29
  expect(lower).toContain('change it later');
28
30
  });
29
31
 
30
- test('contains the Home Base handoff format', () => {
31
- expect(bootstrap).toMatch(/came up with X ideas/i);
32
- expect(bootstrap).toMatch(/check this out/i);
33
- });
34
-
35
- test('mentions avatar evolution instruction', () => {
32
+ test('creates Home Base silently in the background', () => {
36
33
  const lower = bootstrap.toLowerCase();
37
- expect(lower).toContain('avatar will start to reflect');
38
- expect(lower).toContain('happens automatically');
34
+ expect(lower).toContain('app_create');
35
+ expect(lower).toContain('set_as_home_base');
36
+ // Must NOT open or announce it
37
+ expect(lower).toContain('do not open it with `app_open`');
38
+ expect(lower).toContain('do not announce it');
39
39
  });
40
40
 
41
41
  test('contains naming intent markers so the first reply includes naming cues', () => {
42
42
  const lower = bootstrap.toLowerCase();
43
43
  // The template must prompt the assistant to ask about names.
44
- // These keywords align with the client-side naming intent heuristic
45
- // (ChatViewModel.replyContainsNamingIntent) so that the first reply
46
- // naturally passes the quality check without triggering a corrective nudge.
47
44
  expect(lower).toContain('name');
48
- expect(lower).toContain('call');
49
- // The example first message should include a naming question
50
- expect(lower).toContain('what should i call myself');
51
- // The conversation sequence must include identity/naming as the first step
45
+ // The first step should be about locking in the assistant's name
46
+ expect(lower).toContain('lock in your name');
47
+ // The conversation sequence must include identity/naming
52
48
  expect(lower).toContain('who am i');
53
- expect(lower).toContain('who are you');
49
+ });
50
+
51
+ test('asks user name AFTER assistant identity is established', () => {
52
+ // Step 1 is locking in the assistant's name, step 3 is asking the user's name
53
+ const assistantNameIdx = bootstrap.indexOf('Lock in your name.');
54
+ const userNameIdx = bootstrap.indexOf('who am I talking to?');
55
+ expect(assistantNameIdx).toBeGreaterThan(-1);
56
+ expect(userNameIdx).toBeGreaterThan(-1);
57
+ expect(assistantNameIdx).toBeLessThan(userNameIdx);
58
+ });
59
+
60
+ test('gathers user context: work role, hobbies, daily tools', () => {
61
+ const lower = bootstrap.toLowerCase();
62
+ expect(lower).toContain('work');
63
+ expect(lower).toContain('hobbies');
64
+ expect(lower).toContain('tools');
65
+ });
66
+
67
+ test('shows exactly 2 suggestions via ui_show card with relay_prompt actions', () => {
68
+ expect(bootstrap).toContain('ui_show');
69
+ expect(bootstrap).toContain('exactly 2');
70
+ // Must use card surface with relay_prompt action buttons
71
+ expect(bootstrap).toContain('surface_type: "card"');
72
+ expect(bootstrap).toContain('relay_prompt');
73
+ });
74
+
75
+ test('contains completion gate with all required conditions', () => {
76
+ const lower = bootstrap.toLowerCase();
77
+ expect(lower).toContain('completion gate');
78
+ expect(lower).toContain('do not delete this file');
79
+ // Assistant name is hard-required
80
+ expect(lower).toContain('you have a name');
81
+ expect(lower).toContain('hard requirement');
82
+ expect(lower).toContain('vibe');
83
+ // User detail fields must be resolved (provided, inferred, or declined)
84
+ expect(lower).toContain('resolved');
85
+ expect(lower).toContain('work role');
86
+ expect(lower).toContain('2 suggestions shown');
87
+ expect(lower).toContain('selected one, deferred both');
88
+ expect(lower).toContain('home base');
89
+ });
90
+
91
+ test('contains privacy/refusal policy', () => {
92
+ const lower = bootstrap.toLowerCase();
93
+ // Must have a privacy section
94
+ expect(lower).toContain('privacy');
95
+ // Assistant name is hard-required, user details are best-effort
96
+ expect(lower).toContain('hard-required');
97
+ expect(lower).toContain('best-effort');
98
+ // Refusal is a valid resolution
99
+ expect(lower).toContain('declined');
100
+ expect(lower).toContain('do not push');
101
+ });
102
+
103
+ test('defines resolved as provided, inferred, or declined', () => {
104
+ const lower = bootstrap.toLowerCase();
105
+ // The template must define what "resolved" means
106
+ expect(lower).toContain('resolved');
107
+ expect(lower).toContain('inferred');
108
+ expect(lower).toContain('declined');
109
+ });
110
+
111
+ test('preserves no em dashes instruction', () => {
112
+ const lower = bootstrap.toLowerCase();
113
+ expect(lower).toContain('em dashes');
114
+ });
115
+
116
+ test('preserves no technical jargon instruction', () => {
117
+ const lower = bootstrap.toLowerCase();
118
+ expect(lower).toContain('technical jargon');
119
+ expect(lower).toContain('system internals');
120
+ });
121
+
122
+ test('preserves comment line format instruction', () => {
123
+ // The template must start with the comment format explanation
124
+ expect(bootstrap).toMatch(/^_ Lines starting with _/);
125
+ });
126
+
127
+ test('instructs saving to IDENTITY.md, USER.md, and SOUL.md via file_edit', () => {
128
+ expect(bootstrap).toContain('IDENTITY.md');
129
+ expect(bootstrap).toContain('USER.md');
130
+ expect(bootstrap).toContain('SOUL.md');
131
+ expect(bootstrap).toContain('file_edit');
54
132
  });
55
133
  });
56
134
 
@@ -71,4 +149,21 @@ describe('onboarding template contracts', () => {
71
149
  expect(identity).toContain('**Style tendency:**');
72
150
  });
73
151
  });
152
+
153
+ describe('USER.md', () => {
154
+ test('contains onboarding snapshot with all required fields', () => {
155
+ expect(user).toContain('Preferred name/reference:');
156
+ expect(user).toContain('Goals:');
157
+ expect(user).toContain('Locale:');
158
+ expect(user).toContain('Work role:');
159
+ expect(user).toContain('Hobbies/fun:');
160
+ expect(user).toContain('Daily tools:');
161
+ });
162
+
163
+ test('documents resolved-field status conventions', () => {
164
+ const lower = user.toLowerCase();
165
+ expect(lower).toContain('declined_by_user');
166
+ expect(lower).toContain('resolved');
167
+ });
168
+ });
74
169
  });
@@ -0,0 +1,171 @@
1
+ /**
2
+ * API-level tests for the device pairing routes.
3
+ *
4
+ * Validates that handlePairingRequest correctly prevents a second device
5
+ * from hijacking an existing pairing request, while allowing the same
6
+ * device to call the endpoint idempotently.
7
+ */
8
+
9
+ import { beforeEach, describe, expect, mock, test } from 'bun:test';
10
+
11
+ import { PairingStore } from '../daemon/pairing-store.js';
12
+ import type { PairingHandlerContext } from '../runtime/routes/pairing-routes.js';
13
+ import { handlePairingRequest } from '../runtime/routes/pairing-routes.js';
14
+
15
+ // ── Helpers ──────────────────────────────────────────────────────────────
16
+
17
+ const TEST_PAIRING_ID = 'pair-test-001';
18
+ const TEST_SECRET = 'super-secret-value';
19
+ const GATEWAY_URL = 'https://gateway.test';
20
+
21
+ function makeContext(store: PairingStore): PairingHandlerContext {
22
+ return {
23
+ pairingStore: store,
24
+ bearerToken: 'test-bearer-token',
25
+ featureFlagToken: undefined,
26
+ pairingBroadcast: mock(() => {}),
27
+ };
28
+ }
29
+
30
+ function makePairingRequest(overrides: Record<string, unknown> = {}): Request {
31
+ const body = {
32
+ pairingRequestId: TEST_PAIRING_ID,
33
+ pairingSecret: TEST_SECRET,
34
+ deviceId: 'device-A',
35
+ deviceName: 'iPhone A',
36
+ ...overrides,
37
+ };
38
+ return new Request('http://localhost/v1/pairing/request', {
39
+ method: 'POST',
40
+ headers: { 'Content-Type': 'application/json' },
41
+ body: JSON.stringify(body),
42
+ });
43
+ }
44
+
45
+ // ── Tests ────────────────────────────────────────────────────────────────
46
+
47
+ describe('handlePairingRequest — device binding', () => {
48
+ let store: PairingStore;
49
+ let ctx: PairingHandlerContext;
50
+
51
+ beforeEach(() => {
52
+ store = new PairingStore();
53
+ store.start();
54
+ ctx = makeContext(store);
55
+
56
+ // Pre-register the pairing request (simulating QR code display)
57
+ store.register({
58
+ pairingRequestId: TEST_PAIRING_ID,
59
+ pairingSecret: TEST_SECRET,
60
+ gatewayUrl: GATEWAY_URL,
61
+ });
62
+ });
63
+
64
+ test('rejects a second device attempting to pair with the same pairing ID', async () => {
65
+ /**
66
+ * Tests that once a device has initiated pairing, a different device
67
+ * cannot hijack the same pairing request.
68
+ */
69
+
70
+ // GIVEN device A has already initiated pairing
71
+ const firstReq = makePairingRequest({
72
+ deviceId: 'device-A',
73
+ deviceName: 'iPhone A',
74
+ });
75
+ const firstRes = await handlePairingRequest(firstReq, ctx);
76
+ expect(firstRes.status).toBe(200);
77
+
78
+ // WHEN device B tries to pair with the same pairing ID and secret
79
+ const secondReq = makePairingRequest({
80
+ deviceId: 'device-B',
81
+ deviceName: 'iPhone B',
82
+ });
83
+ const secondRes = await handlePairingRequest(secondReq, ctx);
84
+
85
+ // THEN the request is rejected with 409 Conflict
86
+ expect(secondRes.status).toBe(409);
87
+ const body = (await secondRes.json()) as { error: { code: string; message: string } };
88
+ expect(body.error.code).toBe('CONFLICT');
89
+ expect(body.error.message).toContain('already bound to another device');
90
+ });
91
+
92
+ test('allows the same device to call pairing request idempotently', async () => {
93
+ /**
94
+ * Tests that calling pairing request twice from the same device
95
+ * succeeds both times without error.
96
+ */
97
+
98
+ // GIVEN device A has already initiated pairing
99
+ const firstReq = makePairingRequest({
100
+ deviceId: 'device-A',
101
+ deviceName: 'iPhone A',
102
+ });
103
+ const firstRes = await handlePairingRequest(firstReq, ctx);
104
+ expect(firstRes.status).toBe(200);
105
+
106
+ // WHEN device A calls pairing request again with the same credentials
107
+ const secondReq = makePairingRequest({
108
+ deviceId: 'device-A',
109
+ deviceName: 'iPhone A',
110
+ });
111
+ const secondRes = await handlePairingRequest(secondReq, ctx);
112
+
113
+ // THEN it succeeds (idempotent)
114
+ expect(secondRes.status).toBe(200);
115
+ });
116
+
117
+ test('allows the same device to retrieve token after approval', async () => {
118
+ /**
119
+ * Tests that once a pairing request is approved, the same device
120
+ * can call the endpoint again and receive the bearer token.
121
+ */
122
+
123
+ // GIVEN device A has initiated pairing
124
+ const firstReq = makePairingRequest({
125
+ deviceId: 'device-A',
126
+ deviceName: 'iPhone A',
127
+ });
128
+ const firstRes = await handlePairingRequest(firstReq, ctx);
129
+ expect(firstRes.status).toBe(200);
130
+
131
+ // AND the pairing request has been approved
132
+ store.approve(TEST_PAIRING_ID, 'test-bearer-token');
133
+
134
+ // WHEN device A calls pairing request again
135
+ const secondReq = makePairingRequest({
136
+ deviceId: 'device-A',
137
+ deviceName: 'iPhone A',
138
+ });
139
+ const secondRes = await handlePairingRequest(secondReq, ctx);
140
+
141
+ // THEN the request succeeds (status stays approved, device matches)
142
+ expect(secondRes.status).toBe(200);
143
+ });
144
+
145
+ test('rejects a different device even after the first device was approved', async () => {
146
+ /**
147
+ * Tests that a different device cannot hijack a pairing request
148
+ * even after the original device's request has been approved.
149
+ */
150
+
151
+ // GIVEN device A has paired and been approved
152
+ const firstReq = makePairingRequest({
153
+ deviceId: 'device-A',
154
+ deviceName: 'iPhone A',
155
+ });
156
+ await handlePairingRequest(firstReq, ctx);
157
+ store.approve(TEST_PAIRING_ID, 'test-bearer-token');
158
+
159
+ // WHEN device B tries to use the same pairing request
160
+ const hijackReq = makePairingRequest({
161
+ deviceId: 'device-B',
162
+ deviceName: 'Attacker Phone',
163
+ });
164
+ const hijackRes = await handlePairingRequest(hijackReq, ctx);
165
+
166
+ // THEN it is rejected
167
+ expect(hijackRes.status).toBe(409);
168
+ const body = (await hijackRes.json()) as { error: { code: string; message: string } };
169
+ expect(body.error.code).toBe('CONFLICT');
170
+ });
171
+ });
@@ -27,7 +27,9 @@ mock.module('../util/logger.js', () => ({
27
27
  }));
28
28
 
29
29
  mock.module('../config/loader.js', () => ({
30
- getConfig: () => ({ memory: {} }),
30
+ getConfig: () => ({
31
+ ui: {},
32
+ memory: {} }),
31
33
  }));
32
34
 
33
35
  mock.module('../memory/jobs-store.js', () => ({
@@ -27,7 +27,9 @@ mock.module('../util/logger.js', () => ({
27
27
  }));
28
28
 
29
29
  mock.module('../config/loader.js', () => ({
30
- getConfig: () => ({ memory: {} }),
30
+ getConfig: () => ({
31
+ ui: {},
32
+ memory: {} }),
31
33
  }));
32
34
 
33
35
  // Stub memory job queue to avoid side effects
@@ -1,21 +1,72 @@
1
- import { resolve } from 'node:path';
2
-
3
1
  import { describe, expect, mock,test } from 'bun:test';
4
2
 
5
- const retryModulePath = resolve(import.meta.dir, '../util/retry.ts');
6
-
7
3
  mock.module('../util/logger.js', () => ({
8
4
  getLogger: () =>
9
5
  new Proxy({} as Record<string, unknown>, { get: () => () => {} }),
10
6
  isDebug: () => false,
11
7
  }));
12
8
 
13
- // Only mock sleep so retries complete instantly; keep real retry logic
14
- mock.module('../util/retry.js', async () => {
15
- const real = await import(retryModulePath);
9
+ // Only mock sleep so retries complete instantly; keep real retry logic.
10
+ // NOTE: We must NOT use `await import()` inside mock.module it deadlocks
11
+ // bun's module resolver. Instead, inline the real exports and only replace sleep.
12
+ mock.module('../util/retry.js', () => {
13
+ const DEFAULT_MAX_RETRIES = 3;
14
+ const DEFAULT_BASE_DELAY_MS = 1000;
15
+
16
+ function computeRetryDelay(attempt: number, baseDelayMs = DEFAULT_BASE_DELAY_MS): number {
17
+ const cap = baseDelayMs * Math.pow(2, attempt);
18
+ const half = cap / 2;
19
+ return half + Math.random() * half;
20
+ }
21
+
22
+ function parseRetryAfterMs(value: string): number | undefined {
23
+ const seconds = Number(value);
24
+ if (!isNaN(seconds)) return seconds * 1000;
25
+ const dateMs = Date.parse(value);
26
+ if (!isNaN(dateMs)) return Math.max(0, dateMs - Date.now());
27
+ return undefined;
28
+ }
29
+
30
+ function getHttpRetryDelay(
31
+ response: Response,
32
+ attempt: number,
33
+ baseDelayMs = DEFAULT_BASE_DELAY_MS,
34
+ ): number {
35
+ const retryAfter = response.headers.get('retry-after');
36
+ if (retryAfter) {
37
+ const parsed = parseRetryAfterMs(retryAfter);
38
+ if (parsed !== undefined) return parsed;
39
+ }
40
+ const effectiveBase = attempt === 0 ? baseDelayMs * 2 : baseDelayMs;
41
+ return Math.max(baseDelayMs, computeRetryDelay(attempt, effectiveBase));
42
+ }
43
+
44
+ function isRetryableStatus(status: number): boolean {
45
+ return status === 429 || status >= 500;
46
+ }
47
+
48
+ function isRetryableNetworkError(error: unknown): boolean {
49
+ if (!(error instanceof Error)) return false;
50
+ const retryableCodes = new Set(['ECONNRESET', 'ECONNREFUSED', 'ETIMEDOUT', 'EPIPE']);
51
+ const code = (error as NodeJS.ErrnoException).code;
52
+ if (code && retryableCodes.has(code)) return true;
53
+ if (error.cause instanceof Error) {
54
+ const causeCode = (error.cause as NodeJS.ErrnoException).code;
55
+ if (causeCode && retryableCodes.has(causeCode)) return true;
56
+ }
57
+ return false;
58
+ }
59
+
16
60
  return {
17
- ...real,
61
+ DEFAULT_MAX_RETRIES,
62
+ DEFAULT_BASE_DELAY_MS,
63
+ computeRetryDelay,
64
+ parseRetryAfterMs,
65
+ getHttpRetryDelay,
66
+ isRetryableStatus,
67
+ isRetryableNetworkError,
18
68
  sleep: () => Promise.resolve(),
69
+ abortableSleep: () => Promise.resolve(),
19
70
  };
20
71
  });
21
72
 
@@ -17,6 +17,8 @@ mock.module('../permissions/trust-store.js', () => ({
17
17
 
18
18
  mock.module('../config/loader.js', () => ({
19
19
  getConfig: () => ({
20
+ ui: {},
21
+
20
22
  provider: 'mock-provider',
21
23
  timeouts: { permissionTimeoutSec: 5 },
22
24
  permissions: { mode: 'legacy' },
@@ -17,6 +17,8 @@ mock.module('../util/logger.js', () => ({
17
17
 
18
18
  mock.module('../config/loader.js', () => ({
19
19
  getConfig: () => ({
20
+ ui: {},
21
+
20
22
  daemon: { standaloneRecording: true },
21
23
  provider: 'mock-provider',
22
24
  permissions: { mode: 'legacy' },
@@ -49,6 +51,15 @@ const mockMessages: Array<{ id: string; role: string; content: string }> = [];
49
51
  let mockMessageIdCounter = 0;
50
52
 
51
53
  mock.module('../memory/conversation-store.js', () => ({
54
+ getConversationThreadType: () => 'default',
55
+ setConversationOriginChannelIfUnset: () => {},
56
+ updateConversationContextWindow: () => {},
57
+ deleteMessageById: () => {},
58
+ updateConversationTitle: () => {},
59
+ updateConversationUsage: () => {},
60
+ provenanceFromGuardianContext: () => ({ source: 'user', guardianContext: undefined }),
61
+ getConversationOriginInterface: () => null,
62
+ getConversationOriginChannel: () => null,
52
63
  getMessages: () => mockMessages,
53
64
  addMessage: (_convId: string, role: string, content: string) => {
54
65
  const msg = { id: `msg-${++mockMessageIdCounter}`, role, content };
@@ -19,6 +19,8 @@ mock.module('../util/logger.js', () => ({
19
19
 
20
20
  mock.module('../config/loader.js', () => ({
21
21
  getConfig: () => ({
22
+ ui: {},
23
+
22
24
  daemon: { standaloneRecording: true },
23
25
  provider: 'mock-provider',
24
26
  model: 'mock-model',
@@ -233,6 +235,14 @@ mock.module('../daemon/handlers/recording.js', () => ({
233
235
  // ── Mock conversation store ────────────────────────────────────────────────
234
236
 
235
237
  mock.module('../memory/conversation-store.js', () => ({
238
+ getConversationThreadType: () => 'default',
239
+ setConversationOriginChannelIfUnset: () => {},
240
+ updateConversationContextWindow: () => {},
241
+ deleteMessageById: () => {},
242
+ updateConversationUsage: () => {},
243
+ provenanceFromGuardianContext: () => ({ source: 'user', guardianContext: undefined }),
244
+ getConversationOriginInterface: () => null,
245
+ getConversationOriginChannel: () => null,
236
246
  getMessages: () => [],
237
247
  addMessage: () => ({ id: 'msg-mock', role: 'assistant', content: '' }),
238
248
  createConversation: (titleOrOpts?: string | { title?: string }) => {
@@ -269,6 +279,7 @@ mock.module('../security/secret-ingress.js', () => ({
269
279
 
270
280
  mock.module('../security/secret-scanner.js', () => ({
271
281
  redactSecrets: (text: string) => text,
282
+ compileCustomPatterns: () => [],
272
283
  }));
273
284
 
274
285
  // ── Mock classifier (for task_submit fallthrough) ──────────────────────────
@@ -307,6 +318,7 @@ mock.module('../providers/provider-send-message.js', () => ({
307
318
 
308
319
  mock.module('../memory/external-conversation-store.js', () => ({
309
320
  getBindingsForConversations: () => new Map(),
321
+ upsertBinding: () => {},
310
322
  }));
311
323
 
312
324
  // ── Mock subagent manager ──────────────────────────────────────────────────
@@ -376,6 +388,7 @@ function createCtx(overrides?: Partial<HandlerContext>): {
376
388
  setTurnChannelContext: noop,
377
389
  setTurnInterfaceContext: noop,
378
390
  setAssistantId: noop,
391
+ setChannelCapabilities: noop,
379
392
  setGuardianContext: noop,
380
393
  setCommandIntent: noop,
381
394
  processMessage: async () => {},
@@ -386,6 +399,8 @@ function createCtx(overrides?: Partial<HandlerContext>): {
386
399
  dispose: noop,
387
400
  hasPendingConfirmation: () => false,
388
401
  hasPendingSecret: () => false,
402
+ isProcessing: () => false,
403
+ messages: [] as any[],
389
404
  };
390
405
 
391
406
  const sessions = new Map<string, any>();
@@ -16,6 +16,8 @@ mock.module('../util/logger.js', () => ({
16
16
 
17
17
  mock.module('../config/loader.js', () => ({
18
18
  getConfig: () => ({
19
+ ui: {},
20
+
19
21
  daemon: { standaloneRecording: true },
20
22
  provider: 'mock-provider',
21
23
  permissions: { mode: 'legacy' },
@@ -48,6 +50,15 @@ const mockMessages: Array<{ id: string; role: string; content: string }> = [];
48
50
  let mockMessageIdCounter = 0;
49
51
 
50
52
  mock.module('../memory/conversation-store.js', () => ({
53
+ getConversationThreadType: () => 'default',
54
+ setConversationOriginChannelIfUnset: () => {},
55
+ updateConversationContextWindow: () => {},
56
+ deleteMessageById: () => {},
57
+ updateConversationTitle: () => {},
58
+ updateConversationUsage: () => {},
59
+ provenanceFromGuardianContext: () => ({ source: 'user', guardianContext: undefined }),
60
+ getConversationOriginInterface: () => null,
61
+ getConversationOriginChannel: () => null,
51
62
  getMessages: () => mockMessages,
52
63
  addMessage: (_convId: string, role: string, content: string) => {
53
64
  const msg = { id: `msg-${++mockMessageIdCounter}`, role, content };
@@ -417,7 +428,7 @@ describe('stale completion guard (operation token)', () => {
417
428
  expect(getActiveRestartToken()).toBeNull();
418
429
  });
419
430
 
420
- test('allows tokenless recording_status during active restart (old recording ack)', () => {
431
+ test('allows tokenless recording_status during active restart (old recording ack)', async () => {
421
432
  const { ctx, sent, fakeSocket } = createCtx();
422
433
  const conversationId = 'conv-tokenless-1';
423
434
  ctx.socketToSession.set(fakeSocket, conversationId);
@@ -442,7 +453,7 @@ describe('stale completion guard (operation token)', () => {
442
453
  attachToConversationId: conversationId,
443
454
  // No operationToken — from old recording, should be allowed
444
455
  };
445
- recordingHandlers.recording_status(tokenlessStatus, fakeSocket, ctx);
456
+ await recordingHandlers.recording_status(tokenlessStatus, fakeSocket, ctx);
446
457
 
447
458
  // Should have triggered the deferred restart start
448
459
  const newStartMsgs = sent.filter((m) => m.type === 'recording_start');
@@ -160,15 +160,19 @@ describe('tool manifest', () => {
160
160
  });
161
161
 
162
162
  test('manifest declares expected core lazy tools', () => {
163
+ // bash and swarm_delegate moved from lazy to eager registration
163
164
  const lazyNames = new Set(lazyTools.map((t) => t.name));
164
- expect(lazyNames.has('bash')).toBe(true);
165
+ expect(lazyNames.has('bash')).toBe(false);
165
166
  expect(lazyNames.has('evaluate_typescript_code')).toBe(false);
166
167
  expect(lazyNames.has('claude_code')).toBe(false);
167
- expect(lazyNames.has('swarm_delegate')).toBe(true);
168
+ expect(lazyNames.has('swarm_delegate')).toBe(false);
169
+ // Verify they are in eager tools instead
170
+ expect(eagerModuleToolNames).toContain('bash');
171
+ expect(eagerModuleToolNames).toContain('swarm_delegate');
168
172
  });
169
173
 
170
174
  test('eager module tool names list contains expected count', () => {
171
- expect(eagerModuleToolNames.length).toBe(16);
175
+ expect(eagerModuleToolNames.length).toBe(15);
172
176
  });
173
177
 
174
178
  test('explicit tools list includes memory, credential, watch, and catalog tools', () => {