@vellumai/assistant 0.6.0 → 0.6.1

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 (285) hide show
  1. package/AGENTS.md +4 -0
  2. package/ARCHITECTURE.md +68 -15
  3. package/Dockerfile +2 -2
  4. package/bun.lock +6 -2
  5. package/docker-entrypoint.sh +32 -1
  6. package/docs/architecture/integrations.md +1 -1
  7. package/docs/architecture/memory.md +21 -24
  8. package/openapi.yaml +538 -3
  9. package/package.json +5 -1
  10. package/src/__tests__/anthropic-provider.test.ts +160 -95
  11. package/src/__tests__/app-dir-path-guard.test.ts +1 -0
  12. package/src/__tests__/app-executors.test.ts +47 -1
  13. package/src/__tests__/app-source-watcher.test.ts +159 -0
  14. package/src/__tests__/checker.test.ts +38 -6
  15. package/src/__tests__/config-schema.test.ts +5 -0
  16. package/src/__tests__/conversation-agent-loop-overflow.test.ts +4 -6
  17. package/src/__tests__/conversation-agent-loop.test.ts +4 -51
  18. package/src/__tests__/conversation-history-web-search.test.ts +1 -1
  19. package/src/__tests__/conversation-runtime-assembly.test.ts +653 -832
  20. package/src/__tests__/conversation-runtime-workspace.test.ts +1 -93
  21. package/src/__tests__/conversation-tool-setup-app-refresh.test.ts +17 -4
  22. package/src/__tests__/conversation-wipe.test.ts +2 -6
  23. package/src/__tests__/conversation-workspace-cache-state.test.ts +6 -12
  24. package/src/__tests__/conversation-workspace-injection.test.ts +25 -26
  25. package/src/__tests__/conversation-workspace-tool-tracking.test.ts +1 -1
  26. package/src/__tests__/copy-composer-tc-templates.test.ts +335 -0
  27. package/src/__tests__/date-context.test.ts +76 -210
  28. package/src/__tests__/db-schedule-syntax-migration.test.ts +16 -1
  29. package/src/__tests__/file-list-tool.test.ts +219 -0
  30. package/src/__tests__/first-greeting.test.ts +1 -1
  31. package/src/__tests__/heartbeat-service.test.ts +180 -3
  32. package/src/__tests__/identity-routes.test.ts +328 -0
  33. package/src/__tests__/injection-block.test.ts +24 -0
  34. package/src/__tests__/install-skill-routing.test.ts +7 -6
  35. package/src/__tests__/jobs-store-qdrant-breaker.test.ts +15 -14
  36. package/src/__tests__/list-messages-tool-merge.test.ts +300 -0
  37. package/src/__tests__/llm-context-normalization.test.ts +18 -18
  38. package/src/__tests__/llm-context-route-provider.test.ts +101 -0
  39. package/src/__tests__/llm-request-log-turn-query.test.ts +162 -0
  40. package/src/__tests__/log-export-workspace.test.ts +72 -105
  41. package/src/__tests__/mcp-abort-signal.test.ts +5 -0
  42. package/src/__tests__/mcp-client-auth.test.ts +5 -0
  43. package/src/__tests__/memory-recall-log-store.test.ts +132 -0
  44. package/src/__tests__/migration-export-streaming.test.ts +304 -0
  45. package/src/__tests__/migration-import-commit-http.test.ts +11 -10
  46. package/src/__tests__/mock-fetch.ts +87 -0
  47. package/src/__tests__/notification-decision-recipient-context.test.ts +282 -0
  48. package/src/__tests__/onboarding-template-contract.test.ts +62 -14
  49. package/src/__tests__/parser.test.ts +32 -0
  50. package/src/__tests__/permission-checker-host-gate.test.ts +452 -0
  51. package/src/__tests__/permission-controls-v2-flag.test.ts +55 -0
  52. package/src/__tests__/permission-mode-sse.test.ts +418 -0
  53. package/src/__tests__/permission-mode-store.test.ts +277 -0
  54. package/src/__tests__/permission-mode.test.ts +101 -0
  55. package/src/__tests__/platform-bash-auto-approve.test.ts +359 -0
  56. package/src/__tests__/profiler-routes.test.ts +502 -0
  57. package/src/__tests__/profiler-run-store.test.ts +441 -0
  58. package/src/__tests__/proxy-approval-callback.test.ts +4 -75
  59. package/src/__tests__/registry.test.ts +1 -1
  60. package/src/__tests__/sandbox-host-parity.test.ts +5 -4
  61. package/src/__tests__/scheduler-reuse-conversation.test.ts +368 -0
  62. package/src/__tests__/scrub-corrupted-image-attachments.test.ts +278 -0
  63. package/src/__tests__/search-skills-unified.test.ts +4 -3
  64. package/src/__tests__/send-endpoint-busy.test.ts +42 -3
  65. package/src/__tests__/set-permission-mode.test.ts +274 -0
  66. package/src/__tests__/skill-load-feature-flag.test.ts +12 -0
  67. package/src/__tests__/skill-memory.test.ts +2 -783
  68. package/src/__tests__/strip-memory-injections.test.ts +187 -0
  69. package/src/__tests__/subagent-detail.test.ts +84 -0
  70. package/src/__tests__/subagent-disposal.test.ts +308 -0
  71. package/src/__tests__/subagent-manager-notify.test.ts +19 -10
  72. package/src/__tests__/subagent-notify-parent.test.ts +390 -0
  73. package/src/__tests__/subagent-role-registry.test.ts +108 -0
  74. package/src/__tests__/subagent-tool-filtering.test.ts +71 -0
  75. package/src/__tests__/subagent-tools.test.ts +464 -4
  76. package/src/__tests__/system-prompt-ask-mode.test.ts +139 -0
  77. package/src/__tests__/task-memory-cleanup.test.ts +12 -12
  78. package/src/__tests__/terminal-tools.test.ts +17 -27
  79. package/src/__tests__/test-preload.ts +4 -0
  80. package/src/__tests__/tool-executor.test.ts +4 -26
  81. package/src/__tests__/tool-side-effects-slack-dm.test.ts +1 -0
  82. package/src/__tests__/top-level-renderer.test.ts +10 -13
  83. package/src/__tests__/trusted-contact-lifecycle-notifications.test.ts +116 -2
  84. package/src/__tests__/workspace-migration-028-recover-conversations-from-disk-view.test.ts +387 -0
  85. package/src/agent/loop.ts +6 -0
  86. package/src/approvals/guardian-request-resolvers.ts +24 -0
  87. package/src/avatar/traits-png-sync.ts +3 -3
  88. package/src/cli/__tests__/run-assistant-command.ts +29 -0
  89. package/src/cli/commands/__tests__/email-download.test.ts +245 -0
  90. package/src/cli/commands/__tests__/email-list.test.ts +192 -0
  91. package/src/cli/commands/__tests__/email-register.test.ts +186 -0
  92. package/src/cli/commands/__tests__/email-send.test.ts +291 -0
  93. package/src/cli/commands/__tests__/email-status.test.ts +181 -0
  94. package/src/cli/commands/__tests__/email-unregister.test.ts +139 -0
  95. package/src/cli/commands/__tests__/routes.test.ts +562 -0
  96. package/src/cli/commands/conversations.ts +1 -8
  97. package/src/cli/commands/email.ts +584 -835
  98. package/src/cli/commands/memory.ts +1 -34
  99. package/src/cli/commands/notifications.ts +7 -2
  100. package/src/cli/commands/oauth/connect.ts +14 -5
  101. package/src/cli/commands/routes.ts +396 -0
  102. package/src/cli/commands/skills.ts +130 -20
  103. package/src/cli/program.ts +2 -0
  104. package/src/cli.ts +1 -120
  105. package/src/config/bundled-skills/app-builder/SKILL.md +4 -1
  106. package/src/config/bundled-skills/gmail/SKILL.md +2 -2
  107. package/src/config/bundled-skills/messaging/SKILL.md +7 -0
  108. package/src/config/bundled-skills/schedule/SKILL.md +22 -2
  109. package/src/config/bundled-skills/schedule/TOOLS.json +8 -0
  110. package/src/config/bundled-skills/settings/tools/avatar-get.ts +3 -13
  111. package/src/config/bundled-skills/settings/tools/avatar-remove.ts +2 -4
  112. package/src/config/bundled-skills/settings/tools/avatar-update.ts +5 -2
  113. package/src/config/bundled-skills/slack/SKILL.md +2 -0
  114. package/src/config/bundled-skills/subagent/SKILL.md +43 -3
  115. package/src/config/bundled-skills/subagent/TOOLS.json +29 -4
  116. package/src/config/env-registry.ts +63 -0
  117. package/src/config/feature-flag-registry.json +17 -1
  118. package/src/config/schema.ts +8 -0
  119. package/src/config/schemas/filing.ts +51 -0
  120. package/src/config/schemas/heartbeat.ts +15 -12
  121. package/src/config/schemas/memory-lifecycle.ts +12 -0
  122. package/src/config/schemas/security.ts +14 -0
  123. package/src/daemon/app-source-watcher.ts +93 -0
  124. package/src/daemon/config-watcher.ts +79 -1
  125. package/src/daemon/conversation-agent-loop-handlers.ts +20 -0
  126. package/src/daemon/conversation-agent-loop.ts +158 -65
  127. package/src/daemon/conversation-history.ts +4 -19
  128. package/src/daemon/conversation-lifecycle.ts +8 -14
  129. package/src/daemon/conversation-process.ts +13 -7
  130. package/src/daemon/conversation-runtime-assembly.ts +300 -306
  131. package/src/daemon/conversation-tool-setup.ts +44 -14
  132. package/src/daemon/conversation-workspace.ts +1 -2
  133. package/src/daemon/conversation.ts +18 -0
  134. package/src/daemon/date-context.ts +26 -53
  135. package/src/daemon/first-greeting.ts +1 -1
  136. package/src/daemon/handlers/conversations.ts +4 -7
  137. package/src/daemon/handlers/shared.test.ts +143 -0
  138. package/src/daemon/handlers/shared.ts +63 -5
  139. package/src/daemon/handlers/skills.ts +11 -18
  140. package/src/daemon/lifecycle.ts +199 -157
  141. package/src/daemon/message-types/conversations.ts +25 -6
  142. package/src/daemon/message-types/messages.ts +9 -1
  143. package/src/daemon/message-types/schedules.ts +1 -0
  144. package/src/daemon/message-types/settings.ts +6 -0
  145. package/src/daemon/profiler-run-store.ts +557 -0
  146. package/src/daemon/server.ts +89 -9
  147. package/src/daemon/shutdown-handlers.ts +5 -0
  148. package/src/daemon/tool-side-effects.ts +23 -3
  149. package/src/export/transcript-formatter.ts +148 -0
  150. package/src/filing/filing-service.ts +228 -0
  151. package/src/heartbeat/heartbeat-service.ts +96 -7
  152. package/src/mcp/client.ts +6 -0
  153. package/src/mcp/mcp-oauth-provider.ts +149 -27
  154. package/src/memory/admin.ts +33 -32
  155. package/src/memory/app-store.ts +69 -0
  156. package/src/memory/conversation-bootstrap.ts +1 -1
  157. package/src/memory/conversation-crud.ts +136 -107
  158. package/src/memory/conversation-group-migration.ts +1 -1
  159. package/src/memory/conversation-queries.ts +58 -12
  160. package/src/memory/conversation-title-service.ts +1 -0
  161. package/src/memory/db-init.ts +182 -376
  162. package/src/memory/graph/bootstrap.ts +75 -66
  163. package/src/memory/graph/capability-seed.ts +167 -15
  164. package/src/memory/graph/consolidation.ts +38 -4
  165. package/src/memory/graph/conversation-graph-memory.ts +133 -104
  166. package/src/memory/graph/extraction-job.ts +9 -4
  167. package/src/memory/graph/extraction.ts +66 -23
  168. package/src/memory/graph/graph-memory-state-store.ts +37 -0
  169. package/src/memory/graph/graph-search.ts +29 -15
  170. package/src/memory/graph/injection.ts +38 -8
  171. package/src/memory/graph/inspect.ts +12 -3
  172. package/src/memory/graph/retriever.ts +365 -262
  173. package/src/memory/graph/store.test.ts +48 -0
  174. package/src/memory/graph/store.ts +150 -11
  175. package/src/memory/graph/tool-handlers.ts +84 -209
  176. package/src/memory/graph/tools.ts +8 -52
  177. package/src/memory/graph/types.ts +24 -0
  178. package/src/memory/job-handlers/cleanup.ts +44 -1
  179. package/src/memory/jobs-store.ts +70 -60
  180. package/src/memory/jobs-worker.ts +44 -28
  181. package/src/memory/llm-request-log-store.ts +96 -12
  182. package/src/memory/memory-recall-log-store.ts +49 -5
  183. package/src/memory/migrations/203-drop-memory-items-tables.ts +33 -1
  184. package/src/memory/migrations/206-memory-graph-node-edits.ts +19 -0
  185. package/src/memory/migrations/206-scrub-corrupted-image-attachments.ts +131 -0
  186. package/src/memory/migrations/207-conversation-graph-memory-state.ts +20 -0
  187. package/src/memory/migrations/208-conversations-last-message-at.ts +35 -0
  188. package/src/memory/migrations/209-strip-thinking-from-consolidated.ts +85 -0
  189. package/src/memory/migrations/210-schedule-reuse-conversation.ts +13 -0
  190. package/src/memory/migrations/211-memory-recall-logs-query-context.ts +21 -0
  191. package/src/memory/migrations/212-llm-request-logs-created-at-index.ts +19 -0
  192. package/src/memory/migrations/index.ts +8 -0
  193. package/src/memory/migrations/registry.ts +8 -0
  194. package/src/memory/schema/conversations.ts +14 -0
  195. package/src/memory/schema/infrastructure.ts +8 -1
  196. package/src/memory/schema/memory-core.ts +0 -51
  197. package/src/memory/schema/memory-graph.ts +15 -0
  198. package/src/memory/task-memory-cleanup.ts +30 -11
  199. package/src/notifications/copy-composer.ts +86 -0
  200. package/src/notifications/decision-engine.ts +35 -0
  201. package/src/permissions/checker.ts +12 -1
  202. package/src/permissions/permission-mode-store.ts +180 -0
  203. package/src/permissions/permission-mode.ts +31 -0
  204. package/src/permissions/workspace-policy.ts +9 -0
  205. package/src/prompts/system-prompt.ts +59 -7
  206. package/src/prompts/templates/BOOTSTRAP-REFERENCE.md +100 -0
  207. package/src/prompts/templates/BOOTSTRAP.md +70 -165
  208. package/src/prompts/templates/HEARTBEAT.md +3 -1
  209. package/src/prompts/templates/SOUL.md +25 -4
  210. package/src/prompts/templates/UPDATES.md +8 -0
  211. package/src/providers/anthropic/client.ts +107 -219
  212. package/src/runtime/auth/route-policy.ts +23 -0
  213. package/src/runtime/http-server.ts +32 -2
  214. package/src/runtime/http-types.ts +12 -1
  215. package/src/runtime/migrations/vbundle-builder.ts +389 -3
  216. package/src/runtime/migrations/vbundle-importer.ts +8 -6
  217. package/src/runtime/routes/__tests__/user-route-dispatcher.test.ts +378 -0
  218. package/src/runtime/routes/app-management-routes.ts +1 -11
  219. package/src/runtime/routes/approval-strategies/guardian-callback-strategy.ts +26 -0
  220. package/src/runtime/routes/archive-utils.ts +29 -0
  221. package/src/runtime/routes/avatar-routes.ts +2 -9
  222. package/src/runtime/routes/btw-routes.ts +14 -1
  223. package/src/runtime/routes/conversation-analysis-routes.ts +173 -0
  224. package/src/runtime/routes/conversation-management-routes.ts +1 -14
  225. package/src/runtime/routes/conversation-query-routes.ts +49 -3
  226. package/src/runtime/routes/conversation-routes.ts +264 -44
  227. package/src/runtime/routes/heartbeat-routes.ts +4 -10
  228. package/src/runtime/routes/identity-routes.ts +53 -18
  229. package/src/runtime/routes/llm-context-normalization.ts +14 -10
  230. package/src/runtime/routes/log-export-routes.ts +23 -275
  231. package/src/runtime/routes/memory-item-routes.test.ts +168 -233
  232. package/src/runtime/routes/migration-routes.ts +18 -7
  233. package/src/runtime/routes/profiler-routes.ts +350 -0
  234. package/src/runtime/routes/schedule-routes.ts +27 -12
  235. package/src/runtime/routes/settings-routes.ts +95 -8
  236. package/src/runtime/routes/subagents-routes.ts +28 -7
  237. package/src/runtime/routes/user-route-dispatcher.ts +223 -0
  238. package/src/runtime/routes/user-routes.ts +41 -0
  239. package/src/runtime/routes/workspace-routes.ts +0 -1
  240. package/src/schedule/schedule-store.ts +30 -0
  241. package/src/schedule/scheduler.ts +45 -18
  242. package/src/skills/catalog-install.ts +10 -2
  243. package/src/skills/managed-store.ts +2 -2
  244. package/src/skills/skill-memory.ts +1 -293
  245. package/src/subagent/index.ts +13 -3
  246. package/src/subagent/manager.ts +308 -29
  247. package/src/subagent/types.ts +68 -0
  248. package/src/tasks/task-runner.ts +4 -4
  249. package/src/tools/apps/executors.ts +29 -4
  250. package/src/tools/filesystem/list.ts +93 -0
  251. package/src/tools/permission-checker.ts +78 -0
  252. package/src/tools/registry.ts +4 -0
  253. package/src/tools/schedule/create.ts +3 -0
  254. package/src/tools/schedule/list.ts +1 -0
  255. package/src/tools/schedule/update.ts +6 -0
  256. package/src/tools/shared/filesystem/errors.ts +5 -0
  257. package/src/tools/shared/filesystem/file-ops-service.ts +90 -2
  258. package/src/tools/shared/filesystem/types.ts +17 -0
  259. package/src/tools/shared/shell-output.ts +31 -2
  260. package/src/tools/subagent/abort.ts +12 -2
  261. package/src/tools/subagent/message.ts +9 -2
  262. package/src/tools/subagent/notify-parent.ts +79 -0
  263. package/src/tools/subagent/read.ts +29 -8
  264. package/src/tools/subagent/resolve.ts +21 -0
  265. package/src/tools/subagent/spawn.ts +2 -0
  266. package/src/tools/subagent/status.ts +11 -1
  267. package/src/tools/system/avatar-generator.ts +3 -3
  268. package/src/tools/system/register.ts +23 -0
  269. package/src/tools/system/set-permission-mode.ts +103 -0
  270. package/src/tools/terminal/parser.ts +30 -5
  271. package/src/tools/terminal/safe-env.ts +16 -1
  272. package/src/tools/tool-manifest.ts +6 -0
  273. package/src/tools/types.ts +2 -0
  274. package/src/util/logger.ts +1 -1
  275. package/src/util/platform.ts +50 -17
  276. package/src/workspace/migrations/023-move-config-files-to-workspace.ts +2 -2
  277. package/src/workspace/migrations/024-move-runtime-files-to-workspace.ts +2 -2
  278. package/src/workspace/migrations/028-recover-conversations-from-disk-view.ts +270 -0
  279. package/src/workspace/migrations/029-seed-pkb.ts +84 -0
  280. package/src/workspace/migrations/registry.ts +4 -0
  281. package/src/workspace/top-level-renderer.ts +5 -9
  282. package/src/__tests__/cli-memory.test.ts +0 -377
  283. package/src/__tests__/clipboard.test.ts +0 -88
  284. package/src/cli/cli-memory.ts +0 -179
  285. package/src/util/clipboard.ts +0 -34
@@ -0,0 +1,368 @@
1
+ import {
2
+ afterAll,
3
+ beforeAll,
4
+ beforeEach,
5
+ describe,
6
+ expect,
7
+ mock,
8
+ test,
9
+ } from "bun:test";
10
+
11
+ mock.module("../util/logger.js", () => ({
12
+ getLogger: () =>
13
+ new Proxy({} as Record<string, unknown>, {
14
+ get: () => () => {},
15
+ }),
16
+ truncateForLog: (value: string) => value,
17
+ }));
18
+
19
+ import { deleteConversation } from "../memory/conversation-crud.js";
20
+ import { getDb, initializeDb } from "../memory/db.js";
21
+ import { createSchedule, getScheduleRuns } from "../schedule/schedule-store.js";
22
+ import { startScheduler } from "../schedule/scheduler.js";
23
+
24
+ initializeDb();
25
+
26
+ /** Access the underlying bun:sqlite Database for raw parameterized queries. */
27
+ function getRawDb(): import("bun:sqlite").Database {
28
+ return (getDb() as unknown as { $client: import("bun:sqlite").Database })
29
+ .$client;
30
+ }
31
+
32
+ /** Force a schedule to be due by setting next_run_at in the past. */
33
+ function forceScheduleDue(scheduleId: string): void {
34
+ getRawDb().run("UPDATE cron_jobs SET next_run_at = ? WHERE id = ?", [
35
+ Date.now() - 1000,
36
+ scheduleId,
37
+ ]);
38
+ }
39
+
40
+ // Build an RRULE expression anchored at the given start date, recurring every minute.
41
+ function buildEveryMinuteRrule(dtstart: Date = new Date()): string {
42
+ const pad = (n: number) => String(n).padStart(2, "0");
43
+ const ds = `${dtstart.getUTCFullYear()}${pad(dtstart.getUTCMonth() + 1)}${pad(
44
+ dtstart.getUTCDate(),
45
+ )}T${pad(dtstart.getUTCHours())}${pad(dtstart.getUTCMinutes())}${pad(
46
+ dtstart.getUTCSeconds(),
47
+ )}Z`;
48
+ return `DTSTART:${ds}\nRRULE:FREQ=MINUTELY;INTERVAL=1`;
49
+ }
50
+
51
+ // Replace setTimeout with a zero-delay version so the 500ms scheduler
52
+ // wait calls fire instantly instead of waiting real time.
53
+ let origSetTimeout: typeof globalThis.setTimeout;
54
+
55
+ describe("scheduler conversation reuse", () => {
56
+ beforeAll(() => {
57
+ origSetTimeout = globalThis.setTimeout;
58
+ globalThis.setTimeout = ((
59
+ fn: TimerHandler,
60
+ _ms?: number,
61
+ ...args: unknown[]
62
+ ) => {
63
+ return origSetTimeout(fn, 200, ...args);
64
+ }) as typeof setTimeout;
65
+ });
66
+
67
+ afterAll(() => {
68
+ globalThis.setTimeout = origSetTimeout;
69
+ });
70
+
71
+ beforeEach(() => {
72
+ const db = getDb();
73
+ db.run("DELETE FROM cron_runs");
74
+ db.run("DELETE FROM cron_jobs");
75
+ db.run("DELETE FROM task_runs");
76
+ db.run("DELETE FROM tasks");
77
+ db.run("DELETE FROM messages");
78
+ db.run("DELETE FROM conversations");
79
+ });
80
+
81
+ test("recurring schedule with reuseConversation=true reuses conversation across runs", async () => {
82
+ /**
83
+ * When a recurring schedule has reuseConversation enabled, the second run
84
+ * should reuse the conversation created by the first run.
85
+ */
86
+
87
+ // GIVEN a recurring schedule with reuseConversation enabled
88
+ const rruleExpr = buildEveryMinuteRrule();
89
+ const schedule = createSchedule({
90
+ name: "Reuse Test",
91
+ cronExpression: rruleExpr,
92
+ message: "Reuse conversation message",
93
+ syntax: "rrule",
94
+ expression: rruleExpr,
95
+ reuseConversation: true,
96
+ });
97
+
98
+ // WHEN the schedule fires for the first time
99
+ forceScheduleDue(schedule.id);
100
+
101
+ const processedMessages: { conversationId: string; message: string }[] = [];
102
+ const processMessage = async (conversationId: string, message: string) => {
103
+ processedMessages.push({ conversationId, message });
104
+ };
105
+
106
+ const scheduler1 = startScheduler(
107
+ processMessage,
108
+ () => {},
109
+ () => {},
110
+ );
111
+ await new Promise((resolve) => setTimeout(resolve, 500));
112
+ scheduler1.stop();
113
+
114
+ // THEN a conversation is created and recorded
115
+ expect(processedMessages).toHaveLength(1);
116
+ const firstConversationId = processedMessages[0].conversationId;
117
+ expect(firstConversationId).toBeTruthy();
118
+
119
+ // AND a successful run is recorded
120
+ const runs1 = getScheduleRuns(schedule.id);
121
+ expect(runs1.length).toBe(1);
122
+ expect(runs1[0].status).toBe("ok");
123
+ expect(runs1[0].conversationId).toBe(firstConversationId);
124
+
125
+ // WHEN the schedule fires for the second time
126
+ forceScheduleDue(schedule.id);
127
+ processedMessages.length = 0;
128
+
129
+ const scheduler2 = startScheduler(
130
+ processMessage,
131
+ () => {},
132
+ () => {},
133
+ );
134
+ await new Promise((resolve) => setTimeout(resolve, 500));
135
+ scheduler2.stop();
136
+
137
+ // THEN the same conversation is reused
138
+ expect(processedMessages).toHaveLength(1);
139
+ expect(processedMessages[0].conversationId).toBe(firstConversationId);
140
+
141
+ // AND the run references the reused conversation
142
+ const runs2 = getScheduleRuns(schedule.id);
143
+ expect(runs2.length).toBe(2);
144
+ expect(runs2[0].conversationId).toBe(firstConversationId);
145
+ });
146
+
147
+ test("recurring schedule with reuseConversation=false creates new conversation each run", async () => {
148
+ /**
149
+ * Default behavior: each run creates a brand-new conversation.
150
+ */
151
+
152
+ // GIVEN a recurring schedule with reuseConversation disabled (default)
153
+ const rruleExpr = buildEveryMinuteRrule();
154
+ const schedule = createSchedule({
155
+ name: "No Reuse Test",
156
+ cronExpression: rruleExpr,
157
+ message: "New conv each run",
158
+ syntax: "rrule",
159
+ expression: rruleExpr,
160
+ // reuseConversation defaults to false
161
+ });
162
+
163
+ // WHEN the schedule fires for the first time
164
+ forceScheduleDue(schedule.id);
165
+
166
+ const processedMessages: { conversationId: string; message: string }[] = [];
167
+ const processMessage = async (conversationId: string, message: string) => {
168
+ processedMessages.push({ conversationId, message });
169
+ };
170
+
171
+ const scheduler1 = startScheduler(
172
+ processMessage,
173
+ () => {},
174
+ () => {},
175
+ );
176
+ await new Promise((resolve) => setTimeout(resolve, 500));
177
+ scheduler1.stop();
178
+
179
+ expect(processedMessages).toHaveLength(1);
180
+ const firstConversationId = processedMessages[0].conversationId;
181
+
182
+ // WHEN the schedule fires for the second time
183
+ forceScheduleDue(schedule.id);
184
+ processedMessages.length = 0;
185
+
186
+ const scheduler2 = startScheduler(
187
+ processMessage,
188
+ () => {},
189
+ () => {},
190
+ );
191
+ await new Promise((resolve) => setTimeout(resolve, 500));
192
+ scheduler2.stop();
193
+
194
+ // THEN a different conversation is created
195
+ expect(processedMessages).toHaveLength(1);
196
+ expect(processedMessages[0].conversationId).not.toBe(firstConversationId);
197
+ });
198
+
199
+ test("reuseConversation creates a new conversation when prior one is deleted", async () => {
200
+ /**
201
+ * If the conversation from the last successful run has been deleted,
202
+ * a fresh conversation should be bootstrapped.
203
+ */
204
+
205
+ // GIVEN a recurring schedule with reuseConversation enabled that has already run once
206
+ const rruleExpr = buildEveryMinuteRrule();
207
+ const schedule = createSchedule({
208
+ name: "Deleted Conv Test",
209
+ cronExpression: rruleExpr,
210
+ message: "Handle deleted conv",
211
+ syntax: "rrule",
212
+ expression: rruleExpr,
213
+ reuseConversation: true,
214
+ });
215
+
216
+ forceScheduleDue(schedule.id);
217
+
218
+ const processedMessages: { conversationId: string; message: string }[] = [];
219
+ const processMessage = async (conversationId: string, message: string) => {
220
+ processedMessages.push({ conversationId, message });
221
+ };
222
+
223
+ const scheduler1 = startScheduler(
224
+ processMessage,
225
+ () => {},
226
+ () => {},
227
+ );
228
+ await new Promise((resolve) => setTimeout(resolve, 500));
229
+ scheduler1.stop();
230
+
231
+ expect(processedMessages).toHaveLength(1);
232
+ const firstConversationId = processedMessages[0].conversationId;
233
+
234
+ // AND the conversation is deleted
235
+ deleteConversation(firstConversationId);
236
+
237
+ // WHEN the schedule fires again
238
+ forceScheduleDue(schedule.id);
239
+ processedMessages.length = 0;
240
+
241
+ const scheduler2 = startScheduler(
242
+ processMessage,
243
+ () => {},
244
+ () => {},
245
+ );
246
+ await new Promise((resolve) => setTimeout(resolve, 500));
247
+ scheduler2.stop();
248
+
249
+ // THEN a new conversation is created (not the deleted one)
250
+ expect(processedMessages).toHaveLength(1);
251
+ expect(processedMessages[0].conversationId).not.toBe(firstConversationId);
252
+ });
253
+
254
+ test("one-shot schedule ignores reuseConversation flag", async () => {
255
+ /**
256
+ * One-shot schedules always create a new conversation regardless of the
257
+ * reuseConversation flag since they only fire once.
258
+ */
259
+
260
+ // GIVEN a one-shot schedule with reuseConversation enabled
261
+ const schedule = createSchedule({
262
+ name: "One-shot Reuse Ignored",
263
+ message: "One-shot with reuse flag",
264
+ mode: "execute",
265
+ nextRunAt: Date.now() - 1000,
266
+ reuseConversation: true,
267
+ // No expression = one-shot
268
+ });
269
+
270
+ // WHEN the schedule fires
271
+ const processedMessages: { conversationId: string; message: string }[] = [];
272
+ const processMessage = async (conversationId: string, message: string) => {
273
+ processedMessages.push({ conversationId, message });
274
+ };
275
+
276
+ const scheduler = startScheduler(
277
+ processMessage,
278
+ () => {},
279
+ () => {},
280
+ );
281
+ await new Promise((resolve) => setTimeout(resolve, 500));
282
+ scheduler.stop();
283
+
284
+ // THEN the message is processed with a new conversation
285
+ expect(processedMessages).toHaveLength(1);
286
+ expect(processedMessages[0].conversationId).toBeTruthy();
287
+
288
+ // AND the schedule is marked as fired
289
+ const runs = getScheduleRuns(schedule.id);
290
+ expect(runs.length).toBeGreaterThanOrEqual(1);
291
+ expect(runs[0].status).toBe("ok");
292
+ });
293
+
294
+ test("reuseConversation uses the conversation from the most recent successful run", async () => {
295
+ /**
296
+ * When multiple runs exist, reuseConversation should pick the conversation
297
+ * from the most recent successful run (not a failed one).
298
+ */
299
+
300
+ // GIVEN a recurring schedule with reuseConversation enabled
301
+ const rruleExpr = buildEveryMinuteRrule();
302
+ const schedule = createSchedule({
303
+ name: "Most Recent Success Test",
304
+ cronExpression: rruleExpr,
305
+ message: "Pick latest success",
306
+ syntax: "rrule",
307
+ expression: rruleExpr,
308
+ reuseConversation: true,
309
+ });
310
+
311
+ // AND a first successful run
312
+ forceScheduleDue(schedule.id);
313
+
314
+ let shouldFail = false;
315
+ const processedMessages: { conversationId: string; message: string }[] = [];
316
+ const processMessage = async (conversationId: string, message: string) => {
317
+ processedMessages.push({ conversationId, message });
318
+ if (shouldFail) throw new Error("Simulated failure");
319
+ };
320
+
321
+ const scheduler1 = startScheduler(
322
+ processMessage,
323
+ () => {},
324
+ () => {},
325
+ );
326
+ await new Promise((resolve) => setTimeout(resolve, 500));
327
+ scheduler1.stop();
328
+
329
+ expect(processedMessages).toHaveLength(1);
330
+ const successConversationId = processedMessages[0].conversationId;
331
+
332
+ // AND a second run that fails
333
+ forceScheduleDue(schedule.id);
334
+ processedMessages.length = 0;
335
+ shouldFail = true;
336
+
337
+ const scheduler2 = startScheduler(
338
+ processMessage,
339
+ () => {},
340
+ () => {},
341
+ );
342
+ await new Promise((resolve) => setTimeout(resolve, 500));
343
+ scheduler2.stop();
344
+
345
+ // The failed run created a different conversation (since it failed
346
+ // before the run could reuse — actually it does reuse the same one
347
+ // because the lookup happens before the error). Let's verify the next
348
+ // successful run still uses the original successful conversation.
349
+
350
+ // AND a third run that succeeds
351
+ forceScheduleDue(schedule.id);
352
+ processedMessages.length = 0;
353
+ shouldFail = false;
354
+
355
+ const scheduler3 = startScheduler(
356
+ processMessage,
357
+ () => {},
358
+ () => {},
359
+ );
360
+ await new Promise((resolve) => setTimeout(resolve, 500));
361
+ scheduler3.stop();
362
+
363
+ // THEN the third run reuses the conversation from the first successful run
364
+ // (the lookup queries for status="ok", so it picks the first run's conversation)
365
+ expect(processedMessages).toHaveLength(1);
366
+ expect(processedMessages[0].conversationId).toBe(successConversationId);
367
+ });
368
+ });
@@ -0,0 +1,278 @@
1
+ import { Database } from "bun:sqlite";
2
+ import { describe, expect, test } from "bun:test";
3
+
4
+ import { drizzle } from "drizzle-orm/bun-sqlite";
5
+
6
+ import { migrateScrubCorruptedImageAttachments } from "../memory/migrations/206-scrub-corrupted-image-attachments.js";
7
+ import * as schema from "../memory/schema.js";
8
+
9
+ function createTestDb() {
10
+ const sqlite = new Database(":memory:");
11
+ sqlite.exec("PRAGMA journal_mode=WAL");
12
+ sqlite.exec("PRAGMA foreign_keys = OFF");
13
+ return drizzle(sqlite, { schema });
14
+ }
15
+
16
+ type TestDb = ReturnType<typeof createTestDb>;
17
+
18
+ function getRawSqlite(db: TestDb): Database {
19
+ return (db as unknown as { $client: Database }).$client;
20
+ }
21
+
22
+ function createRequiredTables(raw: Database) {
23
+ raw.exec(/*sql*/ `
24
+ CREATE TABLE memory_checkpoints (
25
+ key TEXT PRIMARY KEY,
26
+ value TEXT NOT NULL,
27
+ updated_at INTEGER NOT NULL
28
+ )
29
+ `);
30
+
31
+ raw.exec(/*sql*/ `
32
+ CREATE TABLE attachments (
33
+ id TEXT PRIMARY KEY,
34
+ original_filename TEXT NOT NULL,
35
+ mime_type TEXT NOT NULL,
36
+ size_bytes INTEGER NOT NULL,
37
+ kind TEXT NOT NULL,
38
+ data_base64 TEXT NOT NULL DEFAULT '',
39
+ content_hash TEXT,
40
+ thumbnail_base64 TEXT,
41
+ file_path TEXT,
42
+ created_at INTEGER NOT NULL
43
+ )
44
+ `);
45
+
46
+ raw.exec(/*sql*/ `
47
+ CREATE TABLE message_attachments (
48
+ id TEXT PRIMARY KEY,
49
+ message_id TEXT NOT NULL,
50
+ attachment_id TEXT NOT NULL,
51
+ position INTEGER NOT NULL DEFAULT 0,
52
+ created_at INTEGER NOT NULL
53
+ )
54
+ `);
55
+ }
56
+
57
+ // A minimal valid PNG header (8 bytes)
58
+ const VALID_PNG_BASE64 = Buffer.from(
59
+ Uint8Array.from([137, 80, 78, 71, 13, 10, 26, 10, 0, 0, 0, 0]),
60
+ ).toString("base64");
61
+
62
+ // HTML error page encoded as base64 (simulating Slack CDN auth failure)
63
+ const HTML_ERROR_BASE64 = Buffer.from(
64
+ "<!DOCTYPE html><html><head><title>Sign in</title></head><body>Please sign in</body></html>",
65
+ ).toString("base64");
66
+
67
+ // HTML with leading whitespace/BOM
68
+ const HTML_WITH_BOM_BASE64 = Buffer.from(
69
+ "\uFEFF <!DOCTYPE html><html><body>Error</body></html>",
70
+ ).toString("base64");
71
+
72
+ const HTML_UPPERCASE_BASE64 = Buffer.from(
73
+ "<HTML><BODY>Error page</BODY></HTML>",
74
+ ).toString("base64");
75
+
76
+ describe("migrateScrubCorruptedImageAttachments", () => {
77
+ test("removes corrupted image attachment with HTML data_base64", () => {
78
+ const db = createTestDb();
79
+ const raw = getRawSqlite(db);
80
+ const now = Date.now();
81
+
82
+ createRequiredTables(raw);
83
+
84
+ // Insert corrupted attachment (HTML stored as image/png)
85
+ raw.exec(/*sql*/ `
86
+ INSERT INTO attachments (id, original_filename, mime_type, size_bytes, kind, data_base64, created_at)
87
+ VALUES ('corrupt-1', 'image.png', 'image/png', 100, 'image', '${HTML_ERROR_BASE64}', ${now})
88
+ `);
89
+
90
+ // Insert message_attachments link
91
+ raw.exec(/*sql*/ `
92
+ INSERT INTO message_attachments (id, message_id, attachment_id, position, created_at)
93
+ VALUES ('ma-1', 'msg-1', 'corrupt-1', 0, ${now})
94
+ `);
95
+
96
+ migrateScrubCorruptedImageAttachments(db);
97
+
98
+ const attachmentCount = raw
99
+ .query(`SELECT COUNT(*) AS count FROM attachments`)
100
+ .get() as { count: number };
101
+ expect(attachmentCount.count).toBe(0);
102
+
103
+ const linkCount = raw
104
+ .query(`SELECT COUNT(*) AS count FROM message_attachments`)
105
+ .get() as { count: number };
106
+ expect(linkCount.count).toBe(0);
107
+ });
108
+
109
+ test("does NOT remove valid PNG attachment", () => {
110
+ const db = createTestDb();
111
+ const raw = getRawSqlite(db);
112
+ const now = Date.now();
113
+
114
+ createRequiredTables(raw);
115
+
116
+ // Insert valid PNG attachment
117
+ raw.exec(/*sql*/ `
118
+ INSERT INTO attachments (id, original_filename, mime_type, size_bytes, kind, data_base64, created_at)
119
+ VALUES ('valid-1', 'photo.png', 'image/png', 200, 'image', '${VALID_PNG_BASE64}', ${now})
120
+ `);
121
+
122
+ raw.exec(/*sql*/ `
123
+ INSERT INTO message_attachments (id, message_id, attachment_id, position, created_at)
124
+ VALUES ('ma-valid', 'msg-2', 'valid-1', 0, ${now})
125
+ `);
126
+
127
+ migrateScrubCorruptedImageAttachments(db);
128
+
129
+ const attachmentCount = raw
130
+ .query(`SELECT COUNT(*) AS count FROM attachments`)
131
+ .get() as { count: number };
132
+ expect(attachmentCount.count).toBe(1);
133
+
134
+ const linkCount = raw
135
+ .query(`SELECT COUNT(*) AS count FROM message_attachments`)
136
+ .get() as { count: number };
137
+ expect(linkCount.count).toBe(1);
138
+ });
139
+
140
+ test("removes corrupted and preserves valid attachments together", () => {
141
+ const db = createTestDb();
142
+ const raw = getRawSqlite(db);
143
+ const now = Date.now();
144
+
145
+ createRequiredTables(raw);
146
+
147
+ // Corrupted attachment
148
+ raw.exec(/*sql*/ `
149
+ INSERT INTO attachments (id, original_filename, mime_type, size_bytes, kind, data_base64, created_at)
150
+ VALUES ('corrupt-1', 'slack-img.png', 'image/png', 100, 'image', '${HTML_ERROR_BASE64}', ${now})
151
+ `);
152
+ raw.exec(/*sql*/ `
153
+ INSERT INTO message_attachments (id, message_id, attachment_id, position, created_at)
154
+ VALUES ('ma-corrupt', 'msg-1', 'corrupt-1', 0, ${now})
155
+ `);
156
+
157
+ // Valid attachment
158
+ raw.exec(/*sql*/ `
159
+ INSERT INTO attachments (id, original_filename, mime_type, size_bytes, kind, data_base64, created_at)
160
+ VALUES ('valid-1', 'photo.png', 'image/png', 200, 'image', '${VALID_PNG_BASE64}', ${now})
161
+ `);
162
+ raw.exec(/*sql*/ `
163
+ INSERT INTO message_attachments (id, message_id, attachment_id, position, created_at)
164
+ VALUES ('ma-valid', 'msg-2', 'valid-1', 0, ${now})
165
+ `);
166
+
167
+ migrateScrubCorruptedImageAttachments(db);
168
+
169
+ const remaining = raw.query(`SELECT id FROM attachments`).all() as Array<{
170
+ id: string;
171
+ }>;
172
+ expect(remaining).toEqual([{ id: "valid-1" }]);
173
+
174
+ const links = raw
175
+ .query(`SELECT attachment_id FROM message_attachments`)
176
+ .all() as Array<{ attachment_id: string }>;
177
+ expect(links).toEqual([{ attachment_id: "valid-1" }]);
178
+ });
179
+
180
+ test("detects HTML with leading BOM and whitespace", () => {
181
+ const db = createTestDb();
182
+ const raw = getRawSqlite(db);
183
+ const now = Date.now();
184
+
185
+ createRequiredTables(raw);
186
+
187
+ raw.exec(/*sql*/ `
188
+ INSERT INTO attachments (id, original_filename, mime_type, size_bytes, kind, data_base64, created_at)
189
+ VALUES ('bom-1', 'img.jpg', 'image/jpeg', 50, 'image', '${HTML_WITH_BOM_BASE64}', ${now})
190
+ `);
191
+
192
+ migrateScrubCorruptedImageAttachments(db);
193
+
194
+ const count = raw
195
+ .query(`SELECT COUNT(*) AS count FROM attachments`)
196
+ .get() as { count: number };
197
+ expect(count.count).toBe(0);
198
+ });
199
+
200
+ test("detects uppercase <HTML> tag", () => {
201
+ const db = createTestDb();
202
+ const raw = getRawSqlite(db);
203
+ const now = Date.now();
204
+
205
+ createRequiredTables(raw);
206
+
207
+ raw.exec(/*sql*/ `
208
+ INSERT INTO attachments (id, original_filename, mime_type, size_bytes, kind, data_base64, created_at)
209
+ VALUES ('upper-1', 'img.gif', 'image/gif', 50, 'image', '${HTML_UPPERCASE_BASE64}', ${now})
210
+ `);
211
+
212
+ migrateScrubCorruptedImageAttachments(db);
213
+
214
+ const count = raw
215
+ .query(`SELECT COUNT(*) AS count FROM attachments`)
216
+ .get() as { count: number };
217
+ expect(count.count).toBe(0);
218
+ });
219
+
220
+ test("is idempotent — running twice does not error", () => {
221
+ const db = createTestDb();
222
+ const raw = getRawSqlite(db);
223
+ const now = Date.now();
224
+
225
+ createRequiredTables(raw);
226
+
227
+ raw.exec(/*sql*/ `
228
+ INSERT INTO attachments (id, original_filename, mime_type, size_bytes, kind, data_base64, created_at)
229
+ VALUES ('corrupt-1', 'image.png', 'image/png', 100, 'image', '${HTML_ERROR_BASE64}', ${now})
230
+ `);
231
+ raw.exec(/*sql*/ `
232
+ INSERT INTO message_attachments (id, message_id, attachment_id, position, created_at)
233
+ VALUES ('ma-1', 'msg-1', 'corrupt-1', 0, ${now})
234
+ `);
235
+
236
+ migrateScrubCorruptedImageAttachments(db);
237
+ migrateScrubCorruptedImageAttachments(db);
238
+
239
+ const attachmentCount = raw
240
+ .query(`SELECT COUNT(*) AS count FROM attachments`)
241
+ .get() as { count: number };
242
+ expect(attachmentCount.count).toBe(0);
243
+
244
+ const linkCount = raw
245
+ .query(`SELECT COUNT(*) AS count FROM message_attachments`)
246
+ .get() as { count: number };
247
+ expect(linkCount.count).toBe(0);
248
+
249
+ // The checkpoint should be set to '1' (completed)
250
+ const checkpoint = raw
251
+ .query(
252
+ `SELECT value FROM memory_checkpoints WHERE key = 'migration_scrub_corrupted_image_attachments_v1'`,
253
+ )
254
+ .get() as { value: string } | null;
255
+ expect(checkpoint?.value).toBe("1");
256
+ });
257
+
258
+ test("skips non-image MIME types", () => {
259
+ const db = createTestDb();
260
+ const raw = getRawSqlite(db);
261
+ const now = Date.now();
262
+
263
+ createRequiredTables(raw);
264
+
265
+ // HTML content with text/html MIME type — should NOT be touched
266
+ raw.exec(/*sql*/ `
267
+ INSERT INTO attachments (id, original_filename, mime_type, size_bytes, kind, data_base64, created_at)
268
+ VALUES ('html-1', 'page.html', 'text/html', 100, 'document', '${HTML_ERROR_BASE64}', ${now})
269
+ `);
270
+
271
+ migrateScrubCorruptedImageAttachments(db);
272
+
273
+ const count = raw
274
+ .query(`SELECT COUNT(*) AS count FROM attachments`)
275
+ .get() as { count: number };
276
+ expect(count.count).toBe(1);
277
+ });
278
+ });
@@ -124,9 +124,10 @@ mock.module("../skills/managed-store.js", () => ({
124
124
  removeSkillsIndexEntry: () => {},
125
125
  validateManagedSkillId: () => null,
126
126
  }));
127
- mock.module("../skills/skill-memory.js", () => ({
128
- deleteSkillCapabilityMemory: () => {},
129
- seedCatalogSkillMemories: () => {},
127
+ mock.module("../memory/graph/capability-seed.js", () => ({
128
+ deleteSkillCapabilityNode: () => {},
129
+ seedSkillGraphNodes: () => {},
130
+ seedUninstalledCatalogSkillMemories: async () => {},
130
131
  }));
131
132
  mock.module("../util/platform.js", () => ({
132
133
  getWorkspaceSkillsDir: () => "/tmp/test-skills",