vellum 0.2.13 → 0.2.14

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 (207) hide show
  1. package/README.md +32 -0
  2. package/bun.lock +2 -2
  3. package/docs/skills.md +4 -4
  4. package/package.json +2 -2
  5. package/src/__tests__/__snapshots__/ipc-snapshot.test.ts.snap +213 -3
  6. package/src/__tests__/app-git-history.test.ts +176 -0
  7. package/src/__tests__/app-git-service.test.ts +169 -0
  8. package/src/__tests__/assistant-events-sse-hardening.test.ts +315 -0
  9. package/src/__tests__/browser-skill-baseline-tool-payload.test.ts +8 -8
  10. package/src/__tests__/browser-skill-endstate.test.ts +6 -6
  11. package/src/__tests__/call-bridge.test.ts +105 -13
  12. package/src/__tests__/call-domain.test.ts +163 -0
  13. package/src/__tests__/call-orchestrator.test.ts +113 -0
  14. package/src/__tests__/call-routes-http.test.ts +246 -6
  15. package/src/__tests__/channel-approval-routes.test.ts +438 -0
  16. package/src/__tests__/channel-approval.test.ts +266 -0
  17. package/src/__tests__/channel-approvals.test.ts +393 -0
  18. package/src/__tests__/channel-delivery-store.test.ts +447 -0
  19. package/src/__tests__/checker.test.ts +607 -1048
  20. package/src/__tests__/cli.test.ts +1 -56
  21. package/src/__tests__/config-schema.test.ts +137 -18
  22. package/src/__tests__/conflict-intent-tokenization.test.ts +141 -0
  23. package/src/__tests__/conflict-policy.test.ts +121 -0
  24. package/src/__tests__/conflict-store.test.ts +2 -0
  25. package/src/__tests__/contacts-tools.test.ts +3 -3
  26. package/src/__tests__/contradiction-checker.test.ts +99 -1
  27. package/src/__tests__/credential-security-invariants.test.ts +22 -6
  28. package/src/__tests__/credential-vault-unit.test.ts +780 -0
  29. package/src/__tests__/elevenlabs-client.test.ts +62 -0
  30. package/src/__tests__/ephemeral-permissions.test.ts +73 -23
  31. package/src/__tests__/filesystem-tools.test.ts +579 -0
  32. package/src/__tests__/gateway-only-enforcement.test.ts +114 -4
  33. package/src/__tests__/handlers-add-trust-rule-metadata.test.ts +202 -0
  34. package/src/__tests__/handlers-cu-observation-blob.test.ts +2 -1
  35. package/src/__tests__/handlers-ipc-blob-probe.test.ts +2 -1
  36. package/src/__tests__/handlers-slack-config.test.ts +2 -1
  37. package/src/__tests__/handlers-telegram-config.test.ts +855 -0
  38. package/src/__tests__/handlers-twitter-config.test.ts +141 -1
  39. package/src/__tests__/hooks-runner.test.ts +6 -2
  40. package/src/__tests__/host-file-edit-tool.test.ts +124 -0
  41. package/src/__tests__/host-file-read-tool.test.ts +62 -0
  42. package/src/__tests__/host-file-write-tool.test.ts +59 -0
  43. package/src/__tests__/host-shell-tool.test.ts +251 -0
  44. package/src/__tests__/ingress-reconcile.test.ts +581 -0
  45. package/src/__tests__/ipc-snapshot.test.ts +100 -41
  46. package/src/__tests__/ipc-validate.test.ts +50 -0
  47. package/src/__tests__/key-migration.test.ts +23 -0
  48. package/src/__tests__/memory-regressions.test.ts +99 -0
  49. package/src/__tests__/memory-retrieval.benchmark.test.ts +1 -1
  50. package/src/__tests__/oauth-callback-registry.test.ts +11 -4
  51. package/src/__tests__/playbook-execution.test.ts +502 -0
  52. package/src/__tests__/playbook-tools.test.ts +4 -6
  53. package/src/__tests__/public-ingress-urls.test.ts +34 -0
  54. package/src/__tests__/qdrant-manager.test.ts +267 -0
  55. package/src/__tests__/recurrence-engine-rruleset.test.ts +97 -0
  56. package/src/__tests__/recurrence-engine.test.ts +9 -0
  57. package/src/__tests__/recurrence-types.test.ts +8 -0
  58. package/src/__tests__/registry.test.ts +1 -1
  59. package/src/__tests__/runtime-runs.test.ts +1 -25
  60. package/src/__tests__/schedule-store.test.ts +16 -14
  61. package/src/__tests__/schedule-tools.test.ts +83 -0
  62. package/src/__tests__/scheduler-recurrence.test.ts +111 -10
  63. package/src/__tests__/secret-allowlist.test.ts +18 -17
  64. package/src/__tests__/secret-ingress-handler.test.ts +11 -0
  65. package/src/__tests__/secret-scanner.test.ts +43 -0
  66. package/src/__tests__/session-conflict-gate.test.ts +442 -6
  67. package/src/__tests__/session-init.benchmark.test.ts +3 -0
  68. package/src/__tests__/session-process-bridge.test.ts +242 -0
  69. package/src/__tests__/session-skill-tools.test.ts +1 -1
  70. package/src/__tests__/shell-identity.test.ts +256 -0
  71. package/src/__tests__/skill-projection.benchmark.test.ts +11 -1
  72. package/src/__tests__/subagent-tools.test.ts +637 -54
  73. package/src/__tests__/task-management-tools.test.ts +936 -0
  74. package/src/__tests__/task-runner.test.ts +2 -2
  75. package/src/__tests__/terminal-tools.test.ts +840 -0
  76. package/src/__tests__/tool-executor-shell-integration.test.ts +301 -0
  77. package/src/__tests__/tool-executor.test.ts +85 -151
  78. package/src/__tests__/tool-permission-simulate-handler.test.ts +336 -0
  79. package/src/__tests__/trust-store.test.ts +27 -453
  80. package/src/__tests__/twilio-provider.test.ts +153 -3
  81. package/src/__tests__/twilio-routes-elevenlabs.test.ts +375 -0
  82. package/src/__tests__/twilio-routes-twiml.test.ts +4 -4
  83. package/src/__tests__/twilio-routes.test.ts +17 -262
  84. package/src/__tests__/twitter-auth-handler.test.ts +2 -1
  85. package/src/__tests__/twitter-cli-error-shaping.test.ts +208 -0
  86. package/src/__tests__/twitter-cli-routing.test.ts +252 -0
  87. package/src/__tests__/twitter-oauth-client.test.ts +209 -0
  88. package/src/__tests__/workspace-policy.test.ts +213 -0
  89. package/src/calls/call-bridge.ts +92 -19
  90. package/src/calls/call-domain.ts +157 -5
  91. package/src/calls/call-orchestrator.ts +93 -7
  92. package/src/calls/call-store.ts +6 -0
  93. package/src/calls/elevenlabs-client.ts +8 -0
  94. package/src/calls/elevenlabs-config.ts +7 -5
  95. package/src/calls/twilio-provider.ts +91 -0
  96. package/src/calls/twilio-routes.ts +32 -37
  97. package/src/calls/types.ts +3 -1
  98. package/src/calls/voice-quality.ts +29 -7
  99. package/src/cli/twitter.ts +200 -21
  100. package/src/cli.ts +1 -20
  101. package/src/config/bundled-skills/contacts/tools/contact-merge.ts +52 -4
  102. package/src/config/bundled-skills/contacts/tools/contact-search.ts +55 -4
  103. package/src/config/bundled-skills/contacts/tools/contact-upsert.ts +61 -4
  104. package/src/config/bundled-skills/messaging/SKILL.md +17 -2
  105. package/src/config/bundled-skills/messaging/tools/messaging-reply.ts +4 -1
  106. package/src/config/bundled-skills/messaging/tools/messaging-send.ts +5 -1
  107. package/src/config/bundled-skills/messaging/tools/shared.ts +5 -0
  108. package/src/config/bundled-skills/phone-calls/SKILL.md +142 -34
  109. package/src/config/bundled-skills/playbooks/tools/playbook-create.ts +95 -6
  110. package/src/config/bundled-skills/playbooks/tools/playbook-delete.ts +51 -6
  111. package/src/config/bundled-skills/playbooks/tools/playbook-list.ts +73 -6
  112. package/src/config/bundled-skills/playbooks/tools/playbook-update.ts +110 -6
  113. package/src/config/bundled-skills/public-ingress/SKILL.md +22 -5
  114. package/src/config/bundled-skills/twitter/SKILL.md +103 -17
  115. package/src/config/defaults.ts +10 -4
  116. package/src/config/schema.ts +80 -21
  117. package/src/config/types.ts +1 -0
  118. package/src/config/vellum-skills/telegram-setup/SKILL.md +56 -61
  119. package/src/daemon/assistant-attachments.ts +4 -2
  120. package/src/daemon/handlers/apps.ts +69 -0
  121. package/src/daemon/handlers/config.ts +543 -24
  122. package/src/daemon/handlers/index.ts +1 -0
  123. package/src/daemon/handlers/sessions.ts +22 -6
  124. package/src/daemon/handlers/shared.ts +2 -1
  125. package/src/daemon/handlers/skills.ts +5 -20
  126. package/src/daemon/ipc-contract-inventory.json +28 -0
  127. package/src/daemon/ipc-contract.ts +168 -10
  128. package/src/daemon/ipc-validate.ts +17 -0
  129. package/src/daemon/lifecycle.ts +2 -0
  130. package/src/daemon/server.ts +78 -72
  131. package/src/daemon/session-attachments.ts +1 -1
  132. package/src/daemon/session-conflict-gate.ts +62 -6
  133. package/src/daemon/session-notifiers.ts +1 -1
  134. package/src/daemon/session-process.ts +62 -3
  135. package/src/daemon/session-tool-setup.ts +1 -2
  136. package/src/daemon/tls-certs.ts +189 -0
  137. package/src/daemon/video-thumbnail.ts +5 -3
  138. package/src/hooks/manager.ts +5 -9
  139. package/src/memory/app-git-service.ts +295 -0
  140. package/src/memory/app-store.ts +21 -0
  141. package/src/memory/conflict-intent.ts +47 -4
  142. package/src/memory/conflict-policy.ts +73 -0
  143. package/src/memory/conflict-store.ts +9 -1
  144. package/src/memory/contradiction-checker.ts +28 -0
  145. package/src/memory/conversation-key-store.ts +15 -0
  146. package/src/memory/db.ts +81 -0
  147. package/src/memory/embedding-local.ts +3 -13
  148. package/src/memory/external-conversation-store.ts +234 -0
  149. package/src/memory/job-handlers/conflict.ts +22 -2
  150. package/src/memory/jobs-worker.ts +67 -28
  151. package/src/memory/runs-store.ts +54 -7
  152. package/src/memory/schema.ts +20 -0
  153. package/src/messaging/provider.ts +9 -0
  154. package/src/messaging/providers/telegram-bot/adapter.ts +162 -0
  155. package/src/messaging/providers/telegram-bot/client.ts +104 -0
  156. package/src/messaging/providers/telegram-bot/types.ts +15 -0
  157. package/src/messaging/registry.ts +1 -0
  158. package/src/permissions/checker.ts +48 -44
  159. package/src/permissions/prompter.ts +0 -4
  160. package/src/permissions/shell-identity.ts +227 -0
  161. package/src/permissions/trust-store.ts +76 -53
  162. package/src/permissions/types.ts +0 -19
  163. package/src/permissions/workspace-policy.ts +114 -0
  164. package/src/providers/retry.ts +12 -37
  165. package/src/runtime/assistant-event-hub.ts +41 -4
  166. package/src/runtime/channel-approval-parser.ts +60 -0
  167. package/src/runtime/channel-approval-types.ts +71 -0
  168. package/src/runtime/channel-approvals.ts +145 -0
  169. package/src/runtime/gateway-client.ts +16 -0
  170. package/src/runtime/http-server.ts +29 -9
  171. package/src/runtime/routes/call-routes.ts +52 -2
  172. package/src/runtime/routes/channel-routes.ts +296 -16
  173. package/src/runtime/routes/events-routes.ts +97 -28
  174. package/src/runtime/routes/run-routes.ts +2 -7
  175. package/src/runtime/run-orchestrator.ts +0 -3
  176. package/src/schedule/recurrence-engine.ts +26 -2
  177. package/src/schedule/recurrence-types.ts +1 -1
  178. package/src/schedule/schedule-store.ts +12 -3
  179. package/src/security/secret-scanner.ts +7 -0
  180. package/src/tasks/ephemeral-permissions.ts +0 -2
  181. package/src/tasks/task-scheduler.ts +2 -1
  182. package/src/tools/calls/call-start.ts +8 -0
  183. package/src/tools/execution-target.ts +21 -0
  184. package/src/tools/execution-timeout.ts +49 -0
  185. package/src/tools/executor.ts +6 -135
  186. package/src/tools/network/web-search.ts +9 -32
  187. package/src/tools/policy-context.ts +29 -0
  188. package/src/tools/schedule/update.ts +8 -1
  189. package/src/tools/terminal/parser.ts +16 -18
  190. package/src/tools/types.ts +4 -11
  191. package/src/twitter/oauth-client.ts +102 -0
  192. package/src/twitter/router.ts +101 -0
  193. package/src/util/debounce.ts +88 -0
  194. package/src/util/network-info.ts +47 -0
  195. package/src/util/platform.ts +29 -4
  196. package/src/util/promise-guard.ts +37 -0
  197. package/src/util/retry.ts +98 -0
  198. package/src/util/truncate.ts +1 -1
  199. package/src/workspace/git-service.ts +129 -112
  200. package/src/tools/contacts/contact-merge.ts +0 -55
  201. package/src/tools/contacts/contact-search.ts +0 -58
  202. package/src/tools/contacts/contact-upsert.ts +0 -64
  203. package/src/tools/playbooks/index.ts +0 -4
  204. package/src/tools/playbooks/playbook-create.ts +0 -96
  205. package/src/tools/playbooks/playbook-delete.ts +0 -52
  206. package/src/tools/playbooks/playbook-list.ts +0 -74
  207. package/src/tools/playbooks/playbook-update.ts +0 -111
@@ -0,0 +1,267 @@
1
+ import { describe, test, expect, beforeEach, mock, afterEach } from 'bun:test';
2
+ import { existsSync, mkdirSync, writeFileSync, readFileSync, rmSync, chmodSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+
5
+ const testDataDir = '/tmp/qdrant-manager-test-' + process.pid;
6
+
7
+ mock.module('../util/platform.js', () => ({
8
+ getDataDir: () => testDataDir,
9
+ }));
10
+
11
+ mock.module('../util/logger.js', () => ({
12
+ getLogger: () => new Proxy({} as Record<string, unknown>, {
13
+ get: () => () => {},
14
+ }),
15
+ }));
16
+
17
+ import { QdrantManager } from '../memory/qdrant-manager.js';
18
+
19
+ function placeFakeBinary(script: string): string {
20
+ const binaryPath = join(testDataDir, 'qdrant', 'bin', 'qdrant');
21
+ writeFileSync(binaryPath, script);
22
+ chmodSync(binaryPath, 0o755);
23
+ return binaryPath;
24
+ }
25
+
26
+ let nextPort = 16500;
27
+ function getTestPort(): number {
28
+ return nextPort++;
29
+ }
30
+
31
+ beforeEach(() => {
32
+ rmSync(testDataDir, { recursive: true, force: true });
33
+ mkdirSync(join(testDataDir, 'qdrant', 'bin'), { recursive: true });
34
+ delete process.env.QDRANT_URL;
35
+ });
36
+
37
+ afterEach(() => {
38
+ delete process.env.QDRANT_URL;
39
+ rmSync(testDataDir, { recursive: true, force: true });
40
+ });
41
+
42
+ describe('QdrantManager', () => {
43
+
44
+ // ── Constructor ──────────────────────────────────────────────
45
+
46
+ describe('constructor', () => {
47
+ test('parses URL correctly', () => {
48
+ const mgr = new QdrantManager({ url: 'http://127.0.0.1:6333' });
49
+ expect(mgr.getUrl()).toBe('http://127.0.0.1:6333');
50
+ });
51
+
52
+ test('defaults port to 6333 when not in URL', () => {
53
+ const mgr = new QdrantManager({ url: 'http://localhost' });
54
+ expect(mgr.getUrl()).toBe('http://localhost');
55
+ });
56
+
57
+ test('accepts custom storagePath', () => {
58
+ const mgr = new QdrantManager({
59
+ url: 'http://127.0.0.1:6333',
60
+ storagePath: '/custom/storage',
61
+ });
62
+ expect(mgr.getUrl()).toBe('http://127.0.0.1:6333');
63
+ });
64
+ });
65
+
66
+ // ── getUrl ───────────────────────────────────────────────────
67
+
68
+ describe('getUrl', () => {
69
+ test('returns the configured URL', () => {
70
+ const mgr = new QdrantManager({ url: 'http://myhost:7777' });
71
+ expect(mgr.getUrl()).toBe('http://myhost:7777');
72
+ });
73
+ });
74
+
75
+ // ── External Mode ────────────────────────────────────────────
76
+
77
+ describe('external mode', () => {
78
+ test('enters external mode when QDRANT_URL is set', async () => {
79
+ process.env.QDRANT_URL = 'http://external:6333';
80
+ const port = getTestPort();
81
+ const mgr = new QdrantManager({ url: `http://127.0.0.1:${port}` });
82
+
83
+ // External mode goes straight to waitForReady, which will timeout
84
+ await expect(mgr.start()).rejects.toThrow('did not become ready');
85
+ }, 35_000);
86
+
87
+ test('does not enter external mode when QDRANT_URL is empty', () => {
88
+ process.env.QDRANT_URL = ' ';
89
+ const mgr = new QdrantManager({ url: 'http://127.0.0.1:6333' });
90
+ expect(mgr.getUrl()).toBe('http://127.0.0.1:6333');
91
+ });
92
+
93
+ test('does not enter external mode when QDRANT_URL is unset', () => {
94
+ delete process.env.QDRANT_URL;
95
+ const mgr = new QdrantManager({ url: 'http://127.0.0.1:6333' });
96
+ expect(mgr.getUrl()).toBe('http://127.0.0.1:6333');
97
+ });
98
+ });
99
+
100
+ // ── stop() without a running process ─────────────────────────
101
+
102
+ describe('stop() without running process', () => {
103
+ test('removes stale PID file', async () => {
104
+ const pidPath = join(testDataDir, 'qdrant', 'qdrant.pid');
105
+ writeFileSync(pidPath, '99999');
106
+
107
+ const mgr = new QdrantManager({ url: 'http://127.0.0.1:6333' });
108
+ await mgr.stop();
109
+
110
+ expect(existsSync(pidPath)).toBe(false);
111
+ });
112
+
113
+ test('is a no-op when no PID file exists', async () => {
114
+ const mgr = new QdrantManager({ url: 'http://127.0.0.1:6333' });
115
+ await mgr.stop();
116
+ });
117
+ });
118
+
119
+ // ── Stale PID Cleanup ────────────────────────────────────────
120
+
121
+ describe('stale PID cleanup during start()', () => {
122
+ test('removes PID file for non-existent process', async () => {
123
+ const pidPath = join(testDataDir, 'qdrant', 'qdrant.pid');
124
+ writeFileSync(pidPath, '2147483647');
125
+
126
+ placeFakeBinary('#!/bin/sh\nexit 1');
127
+
128
+ const port = getTestPort();
129
+ const mgr = new QdrantManager({ url: `http://127.0.0.1:${port}` });
130
+
131
+ try { await mgr.start(); } catch { /* readyz timeout */ }
132
+
133
+ expect(existsSync(pidPath)).toBe(false);
134
+ }, 40_000);
135
+
136
+ test('handles invalid PID file contents', async () => {
137
+ const pidPath = join(testDataDir, 'qdrant', 'qdrant.pid');
138
+ writeFileSync(pidPath, 'garbage');
139
+
140
+ placeFakeBinary('#!/bin/sh\nexit 1');
141
+
142
+ const port = getTestPort();
143
+ const mgr = new QdrantManager({ url: `http://127.0.0.1:${port}` });
144
+
145
+ try { await mgr.start(); } catch { /* expected */ }
146
+
147
+ expect(existsSync(pidPath)).toBe(false);
148
+ }, 40_000);
149
+
150
+ test('handles empty PID file', async () => {
151
+ const pidPath = join(testDataDir, 'qdrant', 'qdrant.pid');
152
+ writeFileSync(pidPath, '');
153
+
154
+ placeFakeBinary('#!/bin/sh\nexit 1');
155
+
156
+ const port = getTestPort();
157
+ const mgr = new QdrantManager({ url: `http://127.0.0.1:${port}` });
158
+
159
+ try { await mgr.start(); } catch { /* expected */ }
160
+
161
+ expect(existsSync(pidPath)).toBe(false);
162
+ }, 40_000);
163
+ });
164
+
165
+ // ── Process Lifecycle ────────────────────────────────────────
166
+
167
+ describe('process lifecycle', () => {
168
+ test('writes PID file after spawning', async () => {
169
+ const pidPath = join(testDataDir, 'qdrant', 'qdrant.pid');
170
+
171
+ // Binary that stays alive. We'll stop it before readyz times out.
172
+ placeFakeBinary('#!/bin/sh\nexec sleep 300');
173
+
174
+ const port = getTestPort();
175
+ const mgr = new QdrantManager({ url: `http://127.0.0.1:${port}` });
176
+
177
+ // Start polls readyz forever; we race it with our assertions + stop
178
+ const startPromise = mgr.start();
179
+
180
+ // Wait for spawn to happen
181
+ await Bun.sleep(500);
182
+
183
+ // PID file should be written
184
+ expect(existsSync(pidPath)).toBe(true);
185
+ const pid = parseInt(readFileSync(pidPath, 'utf-8').trim(), 10);
186
+ expect(isNaN(pid)).toBe(false);
187
+ expect(pid).toBeGreaterThan(0);
188
+
189
+ // Stop kills the process and cleans up PID
190
+ await mgr.stop();
191
+ expect(existsSync(pidPath)).toBe(false);
192
+
193
+ // start() should now reject because process was killed
194
+ await expect(startPromise).rejects.toThrow('did not become ready');
195
+ }, 40_000);
196
+
197
+ test('stop() escalates to SIGKILL after grace period', async () => {
198
+ const pidPath = join(testDataDir, 'qdrant', 'qdrant.pid');
199
+
200
+ // Binary that ignores SIGTERM
201
+ placeFakeBinary('#!/bin/sh\ntrap "" TERM\nexec sleep 300');
202
+
203
+ const port = getTestPort();
204
+ const mgr = new QdrantManager({ url: `http://127.0.0.1:${port}` });
205
+
206
+ const startPromise = mgr.start();
207
+ await Bun.sleep(500);
208
+
209
+ expect(existsSync(pidPath)).toBe(true);
210
+
211
+ const stopStart = Date.now();
212
+ await mgr.stop();
213
+ const stopElapsed = Date.now() - stopStart;
214
+
215
+ // Grace period is 5s — should wait at least that long
216
+ expect(stopElapsed).toBeGreaterThanOrEqual(4500);
217
+ expect(existsSync(pidPath)).toBe(false);
218
+
219
+ await expect(startPromise).rejects.toThrow('did not become ready');
220
+ }, 45_000);
221
+ });
222
+
223
+ // ── Start Failure Cleanup ────────────────────────────────────
224
+
225
+ describe('start failure cleanup', () => {
226
+ test('cleans up process on readyz timeout', async () => {
227
+ const pidPath = join(testDataDir, 'qdrant', 'qdrant.pid');
228
+
229
+ // Binary that stays alive but never serves readyz
230
+ placeFakeBinary('#!/bin/sh\nexec sleep 300');
231
+
232
+ const port = getTestPort();
233
+ const mgr = new QdrantManager({ url: `http://127.0.0.1:${port}` });
234
+
235
+ await expect(mgr.start()).rejects.toThrow('did not become ready');
236
+ expect(existsSync(pidPath)).toBe(false);
237
+ }, 40_000);
238
+
239
+ test('cleans up when process exits immediately', async () => {
240
+ const pidPath = join(testDataDir, 'qdrant', 'qdrant.pid');
241
+
242
+ placeFakeBinary('#!/bin/sh\nexit 1');
243
+
244
+ const port = getTestPort();
245
+ const mgr = new QdrantManager({ url: `http://127.0.0.1:${port}` });
246
+
247
+ await expect(mgr.start()).rejects.toThrow('did not become ready');
248
+ expect(existsSync(pidPath)).toBe(false);
249
+ }, 40_000);
250
+ });
251
+
252
+ // ── Binary Detection ─────────────────────────────────────────
253
+
254
+ describe('binary detection', () => {
255
+ test('skips download when binary exists', async () => {
256
+ placeFakeBinary('#!/bin/sh\nexit 1');
257
+
258
+ const port = getTestPort();
259
+ const mgr = new QdrantManager({ url: `http://127.0.0.1:${port}` });
260
+
261
+ try { await mgr.start(); } catch { /* readyz timeout */ }
262
+
263
+ const binaryPath = join(testDataDir, 'qdrant', 'bin', 'qdrant');
264
+ expect(existsSync(binaryPath)).toBe(true);
265
+ }, 40_000);
266
+ });
267
+ });
@@ -76,3 +76,100 @@ describe('RRULE set engine support', () => {
76
76
  expect(next).toBeGreaterThan(Date.now() - 1);
77
77
  });
78
78
  });
79
+
80
+ // ── EXRULE behavioral tests ──────────────────────────────────────────
81
+
82
+ describe('EXRULE engine behavior', () => {
83
+ test('EXRULE excludes matching occurrences from daily series', () => {
84
+ // RRULE: every day at 09:00 starting Jan 1 2099
85
+ // EXRULE: every Saturday and Sunday (weekends)
86
+ // Expected: weekday occurrences only
87
+ const expr = [
88
+ 'DTSTART:20990101T090000Z',
89
+ 'RRULE:FREQ=DAILY;BYHOUR=9;BYMINUTE=0;BYSECOND=0',
90
+ 'EXRULE:FREQ=WEEKLY;BYDAY=SA,SU;BYHOUR=9;BYMINUTE=0;BYSECOND=0',
91
+ ].join('\n');
92
+
93
+ expect(isValidScheduleExpression({ syntax: 'rrule', expression: expr })).toBe(true);
94
+
95
+ // Jan 1 2099 is a Thursday. Enumerate the first several occurrences
96
+ // and verify that no Saturday (Jan 3) or Sunday (Jan 4) appears.
97
+ const thu = new Date('2099-01-01T09:00:00Z').getTime(); // Thu
98
+ const fri = new Date('2099-01-02T09:00:00Z').getTime(); // Fri
99
+ const sat = new Date('2099-01-03T09:00:00Z').getTime(); // Sat
100
+ const sun = new Date('2099-01-04T09:00:00Z').getTime(); // Sun
101
+ const mon = new Date('2099-01-05T09:00:00Z').getTime(); // Mon
102
+
103
+ // After Thu -> should be Fri (not Sat/Sun)
104
+ const afterThu = computeNextRunAt({ syntax: 'rrule', expression: expr }, thu + 1);
105
+ expect(afterThu).toBe(fri);
106
+
107
+ // After Fri -> should skip Sat+Sun, land on Mon
108
+ const afterFri = computeNextRunAt({ syntax: 'rrule', expression: expr }, fri + 1);
109
+ expect(afterFri).toBe(mon);
110
+
111
+ // Explicitly confirm Sat and Sun are never returned
112
+ expect(afterFri).not.toBe(sat);
113
+ expect(afterFri).not.toBe(sun);
114
+ });
115
+
116
+ test('EXRULE with FREQ acts as repeating exclusion series', () => {
117
+ // RRULE: every day starting Jan 1 2099
118
+ // EXRULE: every 3rd day starting Jan 1 2099 (excludes Jan 1, Jan 4, Jan 7, ...)
119
+ const expr = [
120
+ 'DTSTART:20990101T090000Z',
121
+ 'RRULE:FREQ=DAILY;BYHOUR=9;BYMINUTE=0;BYSECOND=0',
122
+ 'EXRULE:FREQ=DAILY;INTERVAL=3;BYHOUR=9;BYMINUTE=0;BYSECOND=0',
123
+ ].join('\n');
124
+
125
+ expect(isValidScheduleExpression({ syntax: 'rrule', expression: expr })).toBe(true);
126
+
127
+ const jan1 = new Date('2099-01-01T09:00:00Z').getTime();
128
+ const jan2 = new Date('2099-01-02T09:00:00Z').getTime();
129
+ const jan3 = new Date('2099-01-03T09:00:00Z').getTime();
130
+ const jan4 = new Date('2099-01-04T09:00:00Z').getTime();
131
+ const jan5 = new Date('2099-01-05T09:00:00Z').getTime();
132
+
133
+ // Jan 1 is excluded (EXRULE fires on DTSTART). First occurrence after
134
+ // DTSTART should be Jan 2.
135
+ const first = computeNextRunAt({ syntax: 'rrule', expression: expr }, jan1 - 1);
136
+ expect(first).toBe(jan2);
137
+
138
+ // After Jan 2 -> Jan 3 (Jan 3 not excluded)
139
+ const second = computeNextRunAt({ syntax: 'rrule', expression: expr }, jan2 + 1);
140
+ expect(second).toBe(jan3);
141
+
142
+ // After Jan 3 -> should skip Jan 4 (excluded, 3 days after Jan 1) -> Jan 5
143
+ const third = computeNextRunAt({ syntax: 'rrule', expression: expr }, jan3 + 1);
144
+ expect(third).toBe(jan5);
145
+ expect(third).not.toBe(jan4);
146
+ });
147
+
148
+ test('EXRULE does not affect non-matching occurrences', () => {
149
+ // RRULE: every weekday (Mon-Fri) at 09:00
150
+ // EXRULE: every Saturday at 09:00 (no overlap with weekday rule)
151
+ // Expected: all weekday occurrences remain intact
152
+ const expr = [
153
+ 'DTSTART:20990105T090000Z',
154
+ 'RRULE:FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR;BYHOUR=9;BYMINUTE=0;BYSECOND=0',
155
+ 'EXRULE:FREQ=WEEKLY;BYDAY=SA;BYHOUR=9;BYMINUTE=0;BYSECOND=0',
156
+ ].join('\n');
157
+
158
+ expect(isValidScheduleExpression({ syntax: 'rrule', expression: expr })).toBe(true);
159
+
160
+ // Jan 5 2099 is a Monday. Verify all five weekdays in the first week appear.
161
+ const mon = new Date('2099-01-05T09:00:00Z').getTime();
162
+ const tue = new Date('2099-01-06T09:00:00Z').getTime();
163
+ const wed = new Date('2099-01-07T09:00:00Z').getTime();
164
+ const thu = new Date('2099-01-08T09:00:00Z').getTime();
165
+ const fri = new Date('2099-01-09T09:00:00Z').getTime();
166
+ const nextMon = new Date('2099-01-12T09:00:00Z').getTime();
167
+
168
+ expect(computeNextRunAt({ syntax: 'rrule', expression: expr }, mon + 1)).toBe(tue);
169
+ expect(computeNextRunAt({ syntax: 'rrule', expression: expr }, tue + 1)).toBe(wed);
170
+ expect(computeNextRunAt({ syntax: 'rrule', expression: expr }, wed + 1)).toBe(thu);
171
+ expect(computeNextRunAt({ syntax: 'rrule', expression: expr }, thu + 1)).toBe(fri);
172
+ // After Fri -> skips weekend entirely (Sat excluded + Sun not in RRULE) -> Mon
173
+ expect(computeNextRunAt({ syntax: 'rrule', expression: expr }, fri + 1)).toBe(nextMon);
174
+ });
175
+ });
@@ -59,6 +59,15 @@ describe('recurrence engine — rrule', () => {
59
59
  expect(() => computeNextRunAt({ syntax: 'rrule', expression: 'RRULE:FREQ=DAILY' })).toThrow(/DTSTART/);
60
60
  });
61
61
 
62
+ test('preserves TZID parameter values when normalizing lowercase prefixes', () => {
63
+ // TZID contains case-sensitive timezone names (e.g. America/New_York)
64
+ // that must not be uppercased during prefix normalization.
65
+ const expr = 'dtstart;TZID=America/New_York:20990601T090000\nrrule:FREQ=DAILY';
66
+ expect(isValidScheduleExpression({ syntax: 'rrule', expression: expr })).toBe(true);
67
+ const next = computeNextRunAt({ syntax: 'rrule', expression: expr });
68
+ expect(next).toBeGreaterThan(Date.now());
69
+ });
70
+
62
71
  test('computes next run for RRULE with EXDATE set construct', () => {
63
72
  const expr = 'DTSTART:20990101T090000Z\nRRULE:FREQ=DAILY\nEXDATE:20990101T090000Z';
64
73
  const next = computeNextRunAt({ syntax: 'rrule', expression: expr });
@@ -56,6 +56,14 @@ describe('normalizeScheduleSyntax', () => {
56
56
  expect(result).toEqual({ syntax: 'cron', expression: '0 9 * * *' });
57
57
  });
58
58
 
59
+ test('honors explicit syntax hint in legacyCronExpression fallback', () => {
60
+ const result = normalizeScheduleSyntax({
61
+ syntax: 'rrule',
62
+ legacyCronExpression: '0 9 * * *',
63
+ });
64
+ expect(result).toEqual({ syntax: 'rrule', expression: '0 9 * * *' });
65
+ });
66
+
59
67
  test('returns null when nothing is provided', () => {
60
68
  expect(normalizeScheduleSyntax({})).toBeNull();
61
69
  });
@@ -176,7 +176,7 @@ describe('tool manifest', () => {
176
176
  });
177
177
 
178
178
  test('eager module tool names list contains expected count', () => {
179
- expect(eagerModuleToolNames.length).toBe(15);
179
+ expect(eagerModuleToolNames.length).toBe(16);
180
180
  });
181
181
 
182
182
  test('explicit tools list includes memory, credential, watch, catalog, and discover tools', () => {
@@ -92,7 +92,7 @@ function makeFailingSession(errorMsg: string): Session {
92
92
  }
93
93
 
94
94
  /** Session whose agent loop emits a confirmation_request. */
95
- function makeConfirmationSession(toolName: string, principal?: { kind?: string; id?: string; version?: string }): Session {
95
+ function makeConfirmationSession(toolName: string): Session {
96
96
  let clientHandler: (msg: ServerMessage) => void = () => {};
97
97
  return {
98
98
  isProcessing: () => false,
@@ -110,9 +110,6 @@ function makeConfirmationSession(toolName: string, principal?: { kind?: string;
110
110
  riskLevel: 'medium',
111
111
  allowlistOptions: [],
112
112
  scopeOptions: [],
113
- principalKind: principal?.kind,
114
- principalId: principal?.id,
115
- principalVersion: principal?.version,
116
113
  });
117
114
  // Hang to simulate waiting for decision
118
115
  await new Promise<void>(() => {});
@@ -241,27 +238,6 @@ describe('runtime runs — swarm lifecycle', () => {
241
238
  expect(orchestrator.getRun('nonexistent-id')).toBeNull();
242
239
  });
243
240
 
244
- test('principal context flows through to pending confirmation', async () => {
245
- const conversation = createConversation('principal context test');
246
- const orchestrator = new RunOrchestrator({
247
- getOrCreateSession: async () => makeConfirmationSession('bash', {
248
- kind: 'skill',
249
- id: 'weather-skill',
250
- version: 'sha256:abcdef1234567890',
251
- }),
252
- resolveAttachments: () => [],
253
- });
254
-
255
- const run = await orchestrator.startRun(conversation.id, 'Run with principal');
256
- await new Promise((r) => setTimeout(r, 50));
257
-
258
- const stored = orchestrator.getRun(run.id);
259
- expect(stored?.status).toBe('needs_confirmation');
260
- expect(stored?.pendingConfirmation?.principalKind).toBe('skill');
261
- expect(stored?.pendingConfirmation?.principalId).toBe('weather-skill');
262
- expect(stored?.pendingConfirmation?.principalVersion).toBe('sha256:abcdef1234567890');
263
- });
264
-
265
241
  test('submitDecision returns run_not_found for unknown run', () => {
266
242
  const orchestrator = new RunOrchestrator({
267
243
  getOrCreateSession: async () => makeCompletingSession(),
@@ -435,27 +435,29 @@ describe('claimDueSchedules', () => {
435
435
  });
436
436
 
437
437
  test('claims exhausted RRULE schedule and disables it', () => {
438
- // COUNT=1 means only one occurrence the DTSTART itself
439
- const rrule = 'DTSTART:20250101T000000Z\nRRULE:FREQ=DAILY;COUNT=1';
440
- const job = createSchedule({
441
- name: 'Finite RRULE',
442
- cronExpression: rrule,
443
- message: 'one-shot',
444
- syntax: 'rrule',
445
- expression: rrule,
446
- });
447
-
448
- // Force the schedule to be due (past the only occurrence)
449
- getRawDb().run('UPDATE cron_jobs SET next_run_at = ? WHERE id = ?', [Date.now() - 1000, job.id]);
438
+ // COUNT=1 with a past DTSTART means the single occurrence has already
439
+ // passed, so computeNextRunAt returns null — triggering the exhaustion path.
440
+ // We insert directly via SQL because createSchedule validates that at least
441
+ // one future run exists, which would reject an already-exhausted schedule.
442
+ const yesterday = new Date(Date.now() - 86_400_000);
443
+ const dtstart = yesterday.toISOString().replace(/[-:]/g, '').replace(/\.\d{3}/, '');
444
+ const rrule = `DTSTART:${dtstart}\nRRULE:FREQ=DAILY;COUNT=1`;
445
+ const id = 'exhausted-rrule-test';
446
+ const now = Date.now();
447
+ getRawDb().run(
448
+ `INSERT INTO cron_jobs (id, name, enabled, cron_expression, schedule_syntax, message, next_run_at, retry_count, created_by, created_at, updated_at)
449
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
450
+ [id, 'Finite RRULE', 1, rrule, 'rrule', 'one-shot', now - 1000, 0, 'agent', now, now],
451
+ );
450
452
 
451
453
  const claimed = claimDueSchedules(Date.now());
452
454
  expect(claimed.length).toBe(1);
453
- expect(claimed[0].id).toBe(job.id);
455
+ expect(claimed[0].id).toBe(id);
454
456
  expect(claimed[0].enabled).toBe(false);
455
457
  expect(claimed[0].nextRunAt).toBe(0);
456
458
 
457
459
  // Verify the schedule is disabled in the DB
458
- const persisted = getSchedule(job.id);
460
+ const persisted = getSchedule(id);
459
461
  expect(persisted!.enabled).toBe(false);
460
462
 
461
463
  // A subsequent claim should not pick it up
@@ -420,6 +420,41 @@ describe('schedule_update with RRULE', () => {
420
420
  expect(result.content).toContain('Syntax: rrule');
421
421
  expect(result.content).toContain('RRULE:FREQ=DAILY');
422
422
  });
423
+
424
+ test('auto-detects rrule syntax when updating expression without explicit syntax', async () => {
425
+ await executeScheduleCreate({
426
+ name: 'Auto-detect on update',
427
+ cron_expression: '0 9 * * *',
428
+ message: 'test',
429
+ }, ctx);
430
+
431
+ const row = getRawDb().query('SELECT id FROM cron_jobs LIMIT 1').get() as { id: string };
432
+ const result = await executeScheduleUpdate({
433
+ job_id: row.id,
434
+ expression: 'DTSTART:20250601T120000Z\nRRULE:FREQ=WEEKLY;BYDAY=MO',
435
+ }, ctx);
436
+
437
+ expect(result.isError).toBe(false);
438
+ expect(result.content).toContain('Syntax: rrule');
439
+ expect(result.content).toContain('RRULE:FREQ=WEEKLY');
440
+ });
441
+
442
+ test('auto-detects cron syntax when updating expression without explicit syntax', async () => {
443
+ await executeScheduleCreate({
444
+ name: 'Cron auto-detect',
445
+ cron_expression: '0 9 * * *',
446
+ message: 'test',
447
+ }, ctx);
448
+
449
+ const row = getRawDb().query('SELECT id FROM cron_jobs LIMIT 1').get() as { id: string };
450
+ const result = await executeScheduleUpdate({
451
+ job_id: row.id,
452
+ expression: '30 17 * * 1-5',
453
+ }, ctx);
454
+
455
+ expect(result.isError).toBe(false);
456
+ expect(result.content).toContain('Syntax: cron');
457
+ });
423
458
  });
424
459
 
425
460
  describe('schedule_list with RRULE', () => {
@@ -657,6 +692,54 @@ describe('schedule_list with RRULE set', () => {
657
692
  });
658
693
  });
659
694
 
695
+ // ── EXRULE support in schedule tools ──────────────────────────────────
696
+
697
+ describe('schedule_create with RRULE + EXRULE', () => {
698
+ beforeEach(() => {
699
+ getRawDb().run('DELETE FROM cron_runs');
700
+ getRawDb().run('DELETE FROM cron_jobs');
701
+ });
702
+
703
+ test('creates a schedule with RRULE + EXRULE', async () => {
704
+ const expression = [
705
+ 'DTSTART:20990101T090000Z',
706
+ 'RRULE:FREQ=DAILY;BYHOUR=9;BYMINUTE=0;BYSECOND=0',
707
+ 'EXRULE:FREQ=WEEKLY;BYDAY=SA,SU;BYHOUR=9;BYMINUTE=0;BYSECOND=0',
708
+ ].join('\n');
709
+
710
+ const result = await executeScheduleCreate({
711
+ name: 'Weekday-only via EXRULE',
712
+ syntax: 'rrule',
713
+ expression,
714
+ message: 'EXRULE test',
715
+ }, ctx);
716
+
717
+ expect(result.isError).toBe(false);
718
+ expect(result.content).toContain('Schedule created successfully');
719
+ expect(result.content).toContain('Syntax: rrule');
720
+ });
721
+
722
+ test('list output shows [RRULE set] label for EXRULE expression', async () => {
723
+ const expression = [
724
+ 'DTSTART:20990101T090000Z',
725
+ 'RRULE:FREQ=DAILY;BYHOUR=9;BYMINUTE=0;BYSECOND=0',
726
+ 'EXRULE:FREQ=WEEKLY;BYDAY=SA,SU;BYHOUR=9;BYMINUTE=0;BYSECOND=0',
727
+ ].join('\n');
728
+
729
+ await executeScheduleCreate({
730
+ name: 'EXRULE Set Schedule',
731
+ syntax: 'rrule',
732
+ expression,
733
+ message: 'EXRULE set test',
734
+ }, ctx);
735
+
736
+ const result = await executeScheduleList({}, ctx);
737
+
738
+ expect(result.isError).toBe(false);
739
+ expect(result.content).toContain('[RRULE set]');
740
+ });
741
+ });
742
+
660
743
  // ── schedule_delete ─────────────────────────────────────────────────
661
744
 
662
745
  describe('schedule_delete tool', () => {