@vellumai/assistant 0.8.9-staging.3 → 0.8.9-staging.5

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 (91) hide show
  1. package/openapi.yaml +16 -115
  2. package/package.json +1 -1
  3. package/src/__tests__/anthropic-provider.test.ts +23 -8
  4. package/src/__tests__/context-window-manager-compact-retry.test.ts +120 -14
  5. package/src/__tests__/conversation-tool-setup-app-refresh.test.ts +73 -4
  6. package/src/__tests__/credential-security-invariants.test.ts +1 -0
  7. package/src/__tests__/gateway-only-guard.test.ts +5 -0
  8. package/src/__tests__/llm-resolver.test.ts +73 -0
  9. package/src/__tests__/plugin-external-api.test.ts +68 -0
  10. package/src/__tests__/published-app-updater.test.ts +138 -0
  11. package/src/api/responses/memory-v3-selection-log.ts +19 -10
  12. package/src/cli/commands/__tests__/memory-v3.test.ts +191 -210
  13. package/src/cli/commands/memory-v3.ts +57 -199
  14. package/src/config/call-site-defaults.ts +0 -1
  15. package/src/config/llm-resolver.ts +39 -7
  16. package/src/config/schemas/__tests__/memory-v3.test.ts +25 -9
  17. package/src/config/schemas/call-site-catalog.ts +0 -7
  18. package/src/config/schemas/llm.ts +17 -1
  19. package/src/config/schemas/memory-v3.ts +58 -8
  20. package/src/config/seed-inference-profiles.ts +18 -0
  21. package/src/daemon/conversation-agent-loop.ts +9 -17
  22. package/src/daemon/external-plugins-bootstrap.ts +8 -3
  23. package/src/daemon/tool-side-effects.ts +15 -0
  24. package/src/memory/v2/__tests__/consolidation-job.test.ts +4 -97
  25. package/src/memory/v2/__tests__/page-store.test.ts +22 -0
  26. package/src/memory/v2/consolidation-job.ts +2 -72
  27. package/src/memory/v2/types.ts +5 -0
  28. package/src/plugins/defaults/compaction/window-manager.ts +56 -0
  29. package/src/plugins/defaults/memory-v3-shadow/__tests__/capabilities.test.ts +19 -66
  30. package/src/plugins/defaults/memory-v3-shadow/__tests__/dense.test.ts +181 -0
  31. package/src/plugins/defaults/memory-v3-shadow/__tests__/edge.test.ts +247 -0
  32. package/src/plugins/defaults/memory-v3-shadow/__tests__/live-integration.test.ts +139 -129
  33. package/src/plugins/defaults/memory-v3-shadow/__tests__/maintain-job.test.ts +332 -164
  34. package/src/plugins/defaults/memory-v3-shadow/__tests__/orchestrate.test.ts +516 -293
  35. package/src/plugins/defaults/memory-v3-shadow/__tests__/pool-select.test.ts +306 -0
  36. package/src/plugins/defaults/memory-v3-shadow/__tests__/render-injection.test.ts +51 -21
  37. package/src/plugins/defaults/memory-v3-shadow/__tests__/section-dense-store.test.ts +402 -0
  38. package/src/plugins/defaults/memory-v3-shadow/__tests__/section-needle.test.ts +135 -0
  39. package/src/plugins/defaults/memory-v3-shadow/__tests__/sections.test.ts +125 -0
  40. package/src/plugins/defaults/memory-v3-shadow/__tests__/selection-log-store.test.ts +66 -11
  41. package/src/plugins/defaults/memory-v3-shadow/__tests__/shadow-integration.test.ts +446 -0
  42. package/src/plugins/defaults/memory-v3-shadow/__tests__/shadow-plugin.test.ts +271 -110
  43. package/src/plugins/defaults/memory-v3-shadow/__tests__/types.test.ts +0 -22
  44. package/src/plugins/defaults/memory-v3-shadow/capabilities.ts +26 -62
  45. package/src/plugins/defaults/memory-v3-shadow/dense.ts +97 -0
  46. package/src/plugins/defaults/memory-v3-shadow/edge.ts +252 -0
  47. package/src/plugins/defaults/memory-v3-shadow/injector.ts +3 -2
  48. package/src/plugins/defaults/memory-v3-shadow/maintain-job.ts +415 -181
  49. package/src/plugins/defaults/memory-v3-shadow/orchestrate.ts +196 -56
  50. package/src/plugins/defaults/memory-v3-shadow/page-content.ts +39 -2
  51. package/src/plugins/defaults/memory-v3-shadow/pool-select.ts +204 -0
  52. package/src/plugins/defaults/memory-v3-shadow/render-injection.ts +15 -7
  53. package/src/plugins/defaults/memory-v3-shadow/section-dense-store.ts +236 -0
  54. package/src/plugins/defaults/memory-v3-shadow/section-needle.ts +200 -0
  55. package/src/plugins/defaults/memory-v3-shadow/sections.ts +115 -0
  56. package/src/plugins/defaults/memory-v3-shadow/selection-log-store.ts +75 -3
  57. package/src/plugins/defaults/memory-v3-shadow/shadow-plugin.ts +111 -78
  58. package/src/plugins/defaults/memory-v3-shadow/types.ts +52 -19
  59. package/src/plugins/defaults/memory-v3-shadow/working-set.ts +4 -1
  60. package/src/plugins/external-api.ts +114 -0
  61. package/src/providers/anthropic/client.ts +9 -10
  62. package/src/providers/inference/kimi-cjk-token-ids.ts +493 -0
  63. package/src/providers/inference/logit-bias.ts +55 -0
  64. package/src/providers/openai/chat-completions-provider.ts +19 -1
  65. package/src/providers/retry.ts +22 -0
  66. package/src/providers/types.ts +6 -0
  67. package/src/runtime/routes/app-management-routes.ts +3 -0
  68. package/src/runtime/routes/memory-v3-routes.ts +64 -314
  69. package/src/services/published-app-updater.ts +30 -8
  70. package/src/tools/skills/load.ts +1 -1
  71. package/src/plugins/defaults/memory-v3-shadow/__tests__/assign.test.ts +0 -242
  72. package/src/plugins/defaults/memory-v3-shadow/__tests__/core.test.ts +0 -39
  73. package/src/plugins/defaults/memory-v3-shadow/__tests__/health.test.ts +0 -219
  74. package/src/plugins/defaults/memory-v3-shadow/__tests__/needle.test.ts +0 -107
  75. package/src/plugins/defaults/memory-v3-shadow/__tests__/provider-blocks.test.ts +0 -13
  76. package/src/plugins/defaults/memory-v3-shadow/__tests__/reconcile.test.ts +0 -274
  77. package/src/plugins/defaults/memory-v3-shadow/__tests__/router.test.ts +0 -337
  78. package/src/plugins/defaults/memory-v3-shadow/__tests__/selector.test.ts +0 -470
  79. package/src/plugins/defaults/memory-v3-shadow/__tests__/snapshot.test.ts +0 -168
  80. package/src/plugins/defaults/memory-v3-shadow/__tests__/tree.test.ts +0 -192
  81. package/src/plugins/defaults/memory-v3-shadow/assign.ts +0 -272
  82. package/src/plugins/defaults/memory-v3-shadow/core.ts +0 -26
  83. package/src/plugins/defaults/memory-v3-shadow/health.ts +0 -0
  84. package/src/plugins/defaults/memory-v3-shadow/needle.ts +0 -115
  85. package/src/plugins/defaults/memory-v3-shadow/provider-blocks.ts +0 -26
  86. package/src/plugins/defaults/memory-v3-shadow/reconcile.ts +0 -527
  87. package/src/plugins/defaults/memory-v3-shadow/router.ts +0 -190
  88. package/src/plugins/defaults/memory-v3-shadow/selector.ts +0 -226
  89. package/src/plugins/defaults/memory-v3-shadow/snapshot.ts +0 -209
  90. package/src/plugins/defaults/memory-v3-shadow/tree.ts +0 -174
  91. package/src/util/map-limit.ts +0 -27
@@ -1,245 +1,226 @@
1
1
  /**
2
- * Tests for the `memory v3` CLI command group's daemon-side handlers.
2
+ * Tests for the `assistant memory v3` CLI subgroup (section-lane model).
3
3
  *
4
- * The CLI subcommands are thin IPC shells over the handlers in
5
- * `runtime/routes/memory-v3-routes.ts`; the logic worth testing lives there.
6
- * We exercise the handlers directly against a temp workspace + data dir
7
- * injected via their `deps` parameter. Generic taxonomy only
8
- * (domain-a/topic-x, etc.).
9
- *
10
- * Source-of-truth note: `health` and `set-core` build the leaf tree from each
11
- * concept page's `leaves:` frontmatter (via the page index) and union it over
12
- * `assignments.json`, matching the consolidation-injected health block. The
13
- * fixture below writes concept pages with `leaves:` frontmatter as the
14
- * authoritative membership and keeps a *stale* `assignments.json` to prove the
15
- * handlers read the frontmatter, not the stale assignments. The seeded
16
- * skill/CLI-command catalogs are mocked empty so `allSlugs` (derived from the
17
- * page index) is deterministic.
4
+ * Validates:
5
+ * - Subcommand registration (rebuild-index, backfill-sections) under
6
+ * `memory v3`.
7
+ * - Each subcommand maps to the right `cliIpcCall` method.
8
+ * - `backfill-sections` passes a long IPC timeout (the one-time full-corpus
9
+ * embed easily outlasts the default 60s), while `rebuild-index` does not.
10
+ * - IPC error paths return a non-zero exit code without throwing.
18
11
  */
19
12
 
20
- import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
21
- import { tmpdir } from "node:os";
22
- import { join } from "node:path";
23
- import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test";
24
-
25
- import * as cliCommandStore from "../../../memory/v2/cli-command-store.js";
26
- import { invalidatePageIndex } from "../../../memory/v2/page-index.js";
27
- import * as skillStore from "../../../memory/v2/skill-store.js";
28
- import {
29
- handleMemoryV3Health,
30
- handleMemoryV3Reconcile,
31
- handleMemoryV3SetCore,
32
- UnknownLeafError,
33
- } from "../../../runtime/routes/memory-v3-routes.js";
34
-
35
- // ---------------------------------------------------------------------------
36
- // Temp data-dir fixture
37
- // ---------------------------------------------------------------------------
38
-
39
- let workspaceDir: string;
40
- let dataDir: string;
41
-
42
- /** Write a leaf markdown file with the given frontmatter under leaves/. */
43
- async function writeLeaf(
44
- relPath: string,
45
- frontmatter: { path: string; in_core: boolean; id?: string },
46
- body = "description",
47
- ): Promise<void> {
48
- const file = join(dataDir, "leaves", `${relPath}.md`);
49
- await mkdir(join(file, ".."), { recursive: true });
50
- const fm = [
51
- "---",
52
- `path: ${frontmatter.path}`,
53
- `in_core: ${frontmatter.in_core}`,
54
- ...(frontmatter.id ? [`id: ${frontmatter.id}`] : []),
55
- "---",
56
- body,
57
- "",
58
- ].join("\n");
59
- await writeFile(file, fm);
13
+ import { beforeEach, describe, expect, mock, test } from "bun:test";
14
+
15
+ import { Command } from "commander";
16
+
17
+ // ---------------------------------------------------------------------------
18
+ // Mock state
19
+ // ---------------------------------------------------------------------------
20
+
21
+ /** The last `cliIpcCall` invocation captured for assertions. */
22
+ let lastIpcCall: {
23
+ method: string;
24
+
25
+ params?: any;
26
+ options?: { timeoutMs?: number; signal?: AbortSignal };
27
+ } | null = null;
28
+
29
+ /** The result that cliIpcCall will return. */
30
+ let mockIpcResult: {
31
+ ok: boolean;
32
+ result?: unknown;
33
+ error?: string;
34
+ } = { ok: true, result: { articles: 3, sections: 12, failures: 0 } };
35
+
36
+ /** Captured log output for assertion. */
37
+ let logOutput: string[] = [];
38
+
39
+ // ---------------------------------------------------------------------------
40
+ // Mocks
41
+ // ---------------------------------------------------------------------------
42
+
43
+ mock.module("../../../ipc/cli-client.js", () => ({
44
+ cliIpcCall: async (
45
+ method: string,
46
+ params?: any,
47
+ options?: { timeoutMs?: number; signal?: AbortSignal },
48
+ ) => {
49
+ lastIpcCall = { method, params, options };
50
+ return mockIpcResult;
51
+ },
52
+ }));
53
+
54
+ const capture = (...args: unknown[]) => {
55
+ logOutput.push(args.map(String).join(" "));
56
+ };
57
+ const fakeLogger = {
58
+ info: capture,
59
+ warn: capture,
60
+ error: capture,
61
+ debug: () => {},
62
+ };
63
+ mock.module("../../../util/logger.js", () => ({
64
+ getLogger: () => fakeLogger,
65
+ getCliLogger: () => fakeLogger,
66
+ }));
67
+
68
+ // ---------------------------------------------------------------------------
69
+ // Import modules under test (after mocks)
70
+ // ---------------------------------------------------------------------------
71
+
72
+ const { registerMemoryV3Command } = await import("../memory-v3.js");
73
+
74
+ // ---------------------------------------------------------------------------
75
+ // Test helpers
76
+ // ---------------------------------------------------------------------------
77
+
78
+ function buildProgram(): Command {
79
+ const program = new Command();
80
+ program.exitOverride();
81
+ program.configureOutput({
82
+ writeErr: () => {},
83
+ writeOut: () => {},
84
+ });
85
+ registerMemoryV3Command(program);
86
+ return program;
60
87
  }
61
88
 
62
- /** Write a concept page with `leaves:` frontmatter under memory/concepts/. */
63
- async function writePage(slug: string, leaves: string[]): Promise<void> {
64
- const file = join(workspaceDir, "memory", "concepts", `${slug}.md`);
65
- await mkdir(join(file, ".."), { recursive: true });
66
- const fm = [
67
- "---",
68
- `summary: ${slug} summary`,
69
- "leaves:",
70
- ...leaves.map((l) => ` - ${l}`),
71
- "---",
72
- `${slug} body`,
73
- "",
74
- ].join("\n");
75
- await writeFile(file, fm);
89
+ async function runCommand(
90
+ args: string[],
91
+ ): Promise<{ stdout: string; exitCode: number }> {
92
+ const originalStdoutWrite = process.stdout.write.bind(process.stdout);
93
+ const originalStderrWrite = process.stderr.write.bind(process.stderr);
94
+ const stdoutChunks: string[] = [];
95
+
96
+ process.stdout.write = ((chunk: unknown) => {
97
+ stdoutChunks.push(typeof chunk === "string" ? chunk : String(chunk));
98
+ return true;
99
+ }) as typeof process.stdout.write;
100
+
101
+ process.stderr.write = (() => true) as typeof process.stderr.write;
102
+
103
+ process.exitCode = 0;
104
+
105
+ try {
106
+ const program = buildProgram();
107
+ await program.parseAsync(["node", "assistant", ...args]);
108
+ } catch {
109
+ if (process.exitCode === 0) process.exitCode = 1;
110
+ } finally {
111
+ process.stdout.write = originalStdoutWrite;
112
+ process.stderr.write = originalStderrWrite;
113
+ }
114
+
115
+ const exitCode = process.exitCode ?? 0;
116
+ process.exitCode = 0;
117
+
118
+ return { exitCode, stdout: stdoutChunks.join("") };
76
119
  }
77
120
 
78
- beforeEach(async () => {
79
- workspaceDir = await mkdtemp(join(tmpdir(), "mem-v3-cli-ws-"));
80
- dataDir = join(workspaceDir, "memory", "v3", "data");
81
- await mkdir(dataDir, { recursive: true });
82
-
83
- // Keep the page index deterministic regardless of the on-disk seeded
84
- // catalogs, and clear any module-cached index from a prior test.
85
- spyOn(skillStore, "listSkillEntries").mockReturnValue([]);
86
- spyOn(cliCommandStore, "listCliCommandEntries").mockReturnValue([]);
87
- invalidatePageIndex();
88
-
89
- // A small generic tree: two leaves under domain-a, one under domain-b.
90
- await writeLeaf("domain-a/topic-x", {
91
- path: "domain-a/topic-x",
92
- in_core: true,
93
- id: "leaf-x",
94
- });
95
- await writeLeaf("domain-a/topic-y", {
96
- path: "domain-a/topic-y",
97
- in_core: false,
98
- id: "leaf-y",
99
- });
100
- await writeLeaf("domain-b/topic-z", {
101
- path: "domain-b/topic-z",
102
- in_core: false,
103
- id: "leaf-z",
104
- });
121
+ // ---------------------------------------------------------------------------
122
+ // Setup
123
+ // ---------------------------------------------------------------------------
105
124
 
106
- // Authoritative membership lives in each page's `leaves:` frontmatter.
107
- await writePage("page-1", ["domain-a/topic-x"]);
108
- await writePage("page-2", ["domain-a/topic-x", "domain-a/topic-y"]);
109
- await writePage("page-3", ["domain-b/topic-z"]);
110
-
111
- // STALE assignments.json: deliberately divergent from the frontmatter above.
112
- // It re-maps the SAME slugs that already carry `leaves:` frontmatter to the
113
- // WRONG leaves. `loadLeafTree`'s per-page precedence only lets frontmatter win
114
- // when it is non-empty, so for these slugs the frontmatter wins and
115
- // assignments.json contributes no membership — proving the handlers read the
116
- // page index, not these stale assignments (the regression Defect A guards
117
- // against). We deliberately avoid a slug that exists ONLY in assignments.json:
118
- // such a slug has no frontmatter, falls back to its assignments entry, and
119
- // would inflate the cost preview below with phantom membership.
120
- await writeFile(
121
- join(dataDir, "assignments.json"),
122
- JSON.stringify({
123
- "page-1": ["domain-a/bogus-leaf"],
124
- "page-2": ["domain-b/topic-z"],
125
- }),
126
- );
127
- await writeFile(
128
- join(dataDir, "core.json"),
129
- JSON.stringify({ alwaysOn: ["domain-a/topic-x"] }),
130
- );
125
+ beforeEach(() => {
126
+ lastIpcCall = null;
127
+ mockIpcResult = {
128
+ ok: true,
129
+ result: { articles: 3, sections: 12, failures: 0 },
130
+ };
131
+ logOutput = [];
132
+ process.exitCode = 0;
131
133
  });
132
134
 
133
- afterEach(async () => {
134
- invalidatePageIndex();
135
- await rm(workspaceDir, { recursive: true, force: true });
135
+ // ---------------------------------------------------------------------------
136
+ // Subcommand registration
137
+ // ---------------------------------------------------------------------------
138
+
139
+ describe("subcommand registration", () => {
140
+ test("registers v3 under memory with the expected subcommands", () => {
141
+ const program = buildProgram();
142
+ const memory = program.commands.find((c) => c.name() === "memory");
143
+ expect(memory).toBeDefined();
144
+ const v3 = memory!.commands.find((c) => c.name() === "v3");
145
+ expect(v3).toBeDefined();
146
+ const subcommandNames = v3!.commands.map((c) => c.name()).sort();
147
+ expect(subcommandNames).toEqual(["backfill-sections", "rebuild-index"]);
148
+ });
136
149
  });
137
150
 
138
151
  // ---------------------------------------------------------------------------
139
- // set-core
152
+ // rebuild-index
140
153
  // ---------------------------------------------------------------------------
141
154
 
142
- describe("handleMemoryV3SetCore", () => {
143
- test("rejects an unknown leaf and does not write", async () => {
144
- const call = handleMemoryV3SetCore(
145
- { add: ["domain-a/does-not-exist"], write: true },
146
- { dataDir, workspaceDir },
147
- );
148
- await expect(call).rejects.toThrow(/domain-a\/does-not-exist/);
149
- await expect(
150
- handleMemoryV3SetCore(
151
- { add: ["domain-a/does-not-exist"], write: true },
152
- { dataDir, workspaceDir },
153
- ),
154
- ).rejects.toBeInstanceOf(UnknownLeafError);
155
+ describe("memory v3 rebuild-index", () => {
156
+ test("sends memory_v3_rebuild_index with the default (no) timeout", async () => {
157
+ mockIpcResult = { ok: true, result: { invalidated: true } };
155
158
 
156
- // core.json must be untouched (still just the original entry).
157
- const core = JSON.parse(await readFile(join(dataDir, "core.json"), "utf8"));
158
- expect(core.alwaysOn).toEqual(["domain-a/topic-x"]);
159
- });
159
+ const { exitCode } = await runCommand(["memory", "v3", "rebuild-index"]);
160
160
 
161
- test("previews the always-on page count from frontmatter membership", async () => {
162
- // Adding topic-y to core: core = {topic-x, topic-y}. Membership comes from
163
- // page `leaves:` frontmatter — topic-x → {page-1, page-2}, topic-y →
164
- // {page-2} ⇒ unique always-on slugs {page-1, page-2} = 2. If the handler
165
- // read the stale assignments.json instead (page-1 → bogus-leaf, page-2 →
166
- // topic-z), topic-x/topic-y would have no members and this would be 0.
167
- const result = await handleMemoryV3SetCore(
168
- { add: ["domain-a/topic-y"] },
169
- { dataDir, workspaceDir },
170
- );
171
- expect(result.written).toBe(false);
172
- expect(result.nextCore).toEqual(["domain-a/topic-x", "domain-a/topic-y"]);
173
- expect(result.alwaysOnPageCount).toBe(2);
174
-
175
- // No write happened — core.json still holds only the original entry.
176
- const core = JSON.parse(await readFile(join(dataDir, "core.json"), "utf8"));
177
- expect(core.alwaysOn).toEqual(["domain-a/topic-x"]);
161
+ expect(exitCode).toBe(0);
162
+ expect(lastIpcCall!.method).toBe("memory_v3_rebuild_index");
163
+ expect(lastIpcCall!.params!.body).toEqual({});
164
+ // rebuild-index is fast; it relies on the default call timeout.
165
+ expect(lastIpcCall!.options).toBeUndefined();
178
166
  });
179
167
 
180
- test("writes core.json on --yes (write: true)", async () => {
181
- const result = await handleMemoryV3SetCore(
182
- { add: ["domain-b/topic-z"], write: true },
183
- { dataDir, workspaceDir },
184
- );
185
- expect(result.written).toBe(true);
186
- expect(result.nextCore).toEqual(["domain-a/topic-x", "domain-b/topic-z"]);
168
+ test("exits with code 1 on IPC failure", async () => {
169
+ mockIpcResult = { ok: false, error: "Daemon down" };
187
170
 
188
- const core = JSON.parse(await readFile(join(dataDir, "core.json"), "utf8"));
189
- expect(core.alwaysOn).toEqual(["domain-a/topic-x", "domain-b/topic-z"]);
190
- });
171
+ const { exitCode } = await runCommand(["memory", "v3", "rebuild-index"]);
191
172
 
192
- test("remove is idempotent for an absent entry", async () => {
193
- const result = await handleMemoryV3SetCore(
194
- { remove: ["domain-a/topic-y"], write: true },
195
- { dataDir, workspaceDir },
196
- );
197
- // topic-y was never in core; result is the original core unchanged.
198
- expect(result.nextCore).toEqual(["domain-a/topic-x"]);
173
+ expect(exitCode).toBe(1);
199
174
  });
200
175
  });
201
176
 
202
177
  // ---------------------------------------------------------------------------
203
- // health
178
+ // backfill-sections
204
179
  // ---------------------------------------------------------------------------
205
180
 
206
- describe("handleMemoryV3Health", () => {
207
- test("computes the slug universe from page frontmatter, not assignments.json", async () => {
208
- const result = await handleMemoryV3Health({ dataDir, workspaceDir });
209
- // domain-b/topic-z has a single member ⇒ a tiny leaf, so the report is
210
- // non-empty and renders the memory-v3 health header.
211
- expect(result.rendered).toContain("memory-v3 health:");
212
- expect(result.counts.tinyLeaves).toBeGreaterThanOrEqual(1);
213
- // Every page leaf ref (from frontmatter) resolves to a real leaf file ⇒ no
214
- // dangling refs. If assignments.json were the source, page-1's bogus-leaf
215
- // would dangle.
216
- expect(result.counts.danglingRefs).toBe(0);
217
- // The slug universe is the page-index pages (page-1..3), all assigned via
218
- // frontmatter, so nothing is unassigned.
219
- expect(result.counts.unassigned).toBe(0);
181
+ describe("memory v3 backfill-sections", () => {
182
+ test("sends memory_v3_backfill_sections with a long (30-min) IPC timeout", async () => {
183
+ mockIpcResult = {
184
+ ok: true,
185
+ result: { articles: 50, sections: 400, failures: 0 },
186
+ };
187
+
188
+ const { exitCode } = await runCommand([
189
+ "memory",
190
+ "v3",
191
+ "backfill-sections",
192
+ ]);
193
+
194
+ expect(exitCode).toBe(0);
195
+ expect(lastIpcCall!.method).toBe("memory_v3_backfill_sections");
196
+ expect(lastIpcCall!.params!.body).toEqual({});
197
+ // The one-time full-corpus embed is long-running, so the CLI must override
198
+ // the default 60s timeout with a generous ceiling (30 min).
199
+ expect(lastIpcCall!.options!.timeoutMs).toBe(30 * 60 * 1000);
220
200
  });
221
201
 
222
- test("flags a page frontmatter ref with no backing leaf as dangling", async () => {
223
- await writePage("page-4", ["domain-a/missing-leaf"]);
224
- invalidatePageIndex();
225
- const result = await handleMemoryV3Health({ dataDir, workspaceDir });
226
- expect(result.counts.danglingRefs).toBeGreaterThanOrEqual(1);
202
+ test("summarizes embedded sections on success", async () => {
203
+ mockIpcResult = {
204
+ ok: true,
205
+ result: { articles: 7, sections: 42, failures: 0 },
206
+ };
207
+
208
+ await runCommand(["memory", "v3", "backfill-sections"]);
209
+
210
+ expect(
211
+ logOutput.some((line) => line.includes("42") && line.includes("7")),
212
+ ).toBe(true);
227
213
  });
228
- });
229
214
 
230
- // ---------------------------------------------------------------------------
231
- // reconcile
232
- // ---------------------------------------------------------------------------
215
+ test("exits with code 1 on IPC failure", async () => {
216
+ mockIpcResult = { ok: false, error: "Memory v3 not enabled" };
217
+
218
+ const { exitCode } = await runCommand([
219
+ "memory",
220
+ "v3",
221
+ "backfill-sections",
222
+ ]);
233
223
 
234
- describe("handleMemoryV3Reconcile", () => {
235
- test("no-op reconcile against an unchanged tree returns empty diffs", async () => {
236
- // v1: prevLeaves is derived from the current tree, so diffing
237
- // current-vs-current yields no renames/deletes — reconcile runs as a
238
- // convergence/prune pass. This still drives the full reconcileTree path
239
- // (snapshot → apply → validate → invalidate).
240
- const result = await handleMemoryV3Reconcile({ dataDir, workspaceDir });
241
- expect(result.renames).toEqual([]);
242
- expect(result.deleted).toEqual([]);
243
- expect(result.prunedCore).toEqual([]);
224
+ expect(exitCode).toBe(1);
244
225
  });
245
226
  });