@vellumai/assistant 0.7.3 → 0.8.0

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 (169) hide show
  1. package/ARCHITECTURE.md +29 -28
  2. package/Dockerfile +1 -0
  3. package/__tests__/permissions/gateway-threshold-reader.test.ts +236 -9
  4. package/bun.lock +3 -0
  5. package/knip.json +1 -0
  6. package/node_modules/@vellumai/ipc-server-utils/bun.lock +24 -0
  7. package/node_modules/@vellumai/ipc-server-utils/package.json +18 -0
  8. package/node_modules/@vellumai/ipc-server-utils/src/index.ts +6 -0
  9. package/node_modules/@vellumai/ipc-server-utils/src/socket-watchdog.test.ts +430 -0
  10. package/node_modules/@vellumai/ipc-server-utils/src/socket-watchdog.ts +221 -0
  11. package/node_modules/@vellumai/ipc-server-utils/tsconfig.json +20 -0
  12. package/openapi.yaml +22 -4
  13. package/package.json +3 -1
  14. package/src/__tests__/annotate-risk-options.test.ts +291 -0
  15. package/src/__tests__/approval-cascade.test.ts +8 -16
  16. package/src/__tests__/approval-routes-http.test.ts +6 -0
  17. package/src/__tests__/auto-analysis-end-to-end.test.ts +12 -25
  18. package/src/__tests__/call-constants.test.ts +10 -1
  19. package/src/__tests__/call-controller.test.ts +127 -0
  20. package/src/__tests__/cli-memory-v2-reembed-skills.test.ts +58 -28
  21. package/src/__tests__/config-loader-platform-defaults.test.ts +284 -1
  22. package/src/__tests__/context-search-memory-source.test.ts +3 -26
  23. package/src/__tests__/context-search-pkb-source.test.ts +12 -6
  24. package/src/__tests__/conversation-abort-tool-results.test.ts +1 -6
  25. package/src/__tests__/conversation-agent-loop-inference-profile.test.ts +1 -1
  26. package/src/__tests__/conversation-agent-loop-overflow.test.ts +1 -1
  27. package/src/__tests__/conversation-agent-loop.test.ts +3 -3
  28. package/src/__tests__/conversation-confirmation-signals.test.ts +5 -13
  29. package/src/__tests__/conversation-init.benchmark.test.ts +1 -1
  30. package/src/__tests__/conversation-process-callsite.test.ts +1 -6
  31. package/src/__tests__/conversation-provider-retry-repair.test.ts +1 -6
  32. package/src/__tests__/conversation-runtime-assembly.test.ts +15 -6
  33. package/src/__tests__/conversation-slash-unknown.test.ts +1 -6
  34. package/src/__tests__/conversation-surfaces-action-delivery.test.ts +170 -9
  35. package/src/__tests__/conversation-surfaces-data-persist.test.ts +73 -1
  36. package/src/__tests__/conversation-tool-setup-app-refresh.test.ts +59 -0
  37. package/src/__tests__/conversation-workspace-injection.test.ts +1 -7
  38. package/src/__tests__/conversation-workspace-tool-tracking.test.ts +1 -7
  39. package/src/__tests__/filing-service.test.ts +2 -19
  40. package/src/__tests__/handlers-skills-memory-v2-reseed.test.ts +10 -26
  41. package/src/__tests__/injector-chain.test.ts +24 -16
  42. package/src/__tests__/injector-pkb-v2-silenced.test.ts +10 -7
  43. package/src/__tests__/lifecycle-memory-v2-seed.test.ts +154 -67
  44. package/src/__tests__/notification-decision-fallback.test.ts +91 -0
  45. package/src/__tests__/notification-decision-strategy.test.ts +22 -0
  46. package/src/__tests__/oauth-cli.test.ts +121 -0
  47. package/src/__tests__/relay-server.test.ts +46 -2
  48. package/src/__tests__/secret-prompt-log-hygiene.test.ts +7 -5
  49. package/src/__tests__/secret-prompter-channel-fallback.test.ts +7 -5
  50. package/src/__tests__/secret-response-routing.test.ts +7 -5
  51. package/src/__tests__/server-history-render.test.ts +82 -0
  52. package/src/__tests__/skill-include-graph.test.ts +31 -0
  53. package/src/__tests__/skill-load-tool.test.ts +44 -16
  54. package/src/__tests__/skills.test.ts +39 -0
  55. package/src/__tests__/tool-execution-pipeline.benchmark.test.ts +0 -42
  56. package/src/__tests__/tool-executor.test.ts +155 -0
  57. package/src/__tests__/voice-session-bridge.test.ts +3 -0
  58. package/src/__tests__/workspace-migration-069-seed-onboarding-threads.test.ts +120 -0
  59. package/src/__tests__/workspace-migration-071-remove-safe-storage-release-note.test.ts +206 -0
  60. package/src/__tests__/workspace-migration-safe-storage-limits-release.test.ts +15 -27
  61. package/src/agent/loop.ts +11 -0
  62. package/src/approvals/guardian-decision-primitive.ts +0 -13
  63. package/src/approvals/guardian-request-resolvers.ts +4 -32
  64. package/src/calls/call-constants.ts +5 -8
  65. package/src/calls/call-controller.ts +130 -67
  66. package/src/calls/relay-server.ts +7 -1
  67. package/src/calls/voice-session-bridge.ts +1 -1
  68. package/src/cli/commands/memory-v2.ts +7 -7
  69. package/src/cli/commands/oauth/__tests__/connect.test.ts +0 -254
  70. package/src/cli/commands/oauth/connect.ts +10 -52
  71. package/src/config/bundled-skills/app-builder/SKILL.md +1 -3
  72. package/src/config/feature-flag-registry.json +1 -17
  73. package/src/config/loader.ts +72 -19
  74. package/src/config/schemas/memory-v2.ts +1 -1
  75. package/src/daemon/__tests__/conversation-lifecycle-auto-analyze.test.ts +32 -0
  76. package/src/daemon/conversation-agent-loop-handlers.ts +32 -0
  77. package/src/daemon/conversation-agent-loop.ts +13 -10
  78. package/src/daemon/conversation-lifecycle.ts +22 -8
  79. package/src/daemon/conversation-surfaces.ts +16 -14
  80. package/src/daemon/conversation-tool-setup.ts +9 -5
  81. package/src/daemon/conversation.ts +1 -1
  82. package/src/daemon/handlers/shared.ts +26 -0
  83. package/src/daemon/host-bash-proxy.ts +1 -1
  84. package/src/daemon/host-browser-proxy.ts +1 -1
  85. package/src/daemon/host-cu-proxy.ts +1 -1
  86. package/src/daemon/host-file-proxy.ts +1 -1
  87. package/src/daemon/host-transfer-proxy.ts +2 -2
  88. package/src/daemon/lifecycle.ts +88 -73
  89. package/src/daemon/memory-v2-startup.ts +55 -14
  90. package/src/daemon/message-types/messages.ts +19 -1
  91. package/src/documents/document-store.ts +35 -1
  92. package/src/filing/filing-service.ts +2 -3
  93. package/src/heartbeat/heartbeat-service.ts +1 -1
  94. package/src/ipc/assistant-server.ts +93 -36
  95. package/src/ipc/skill-server.ts +99 -42
  96. package/src/memory/__tests__/jobs-worker-v2-schedule.test.ts +10 -57
  97. package/src/memory/context-search/sources/memory-v2.ts +1 -17
  98. package/src/memory/context-search/sources/memory.ts +2 -2
  99. package/src/memory/context-search/sources/pkb.ts +2 -3
  100. package/src/memory/graph/__tests__/conversation-graph-memory-v2-routing.test.ts +104 -61
  101. package/src/memory/graph/__tests__/handle-remember-v2.test.ts +11 -26
  102. package/src/memory/graph/conversation-graph-memory.ts +32 -9
  103. package/src/memory/graph/graph-search.test.ts +6 -5
  104. package/src/memory/graph/graph-search.ts +3 -4
  105. package/src/memory/graph/retriever.test.ts +12 -7
  106. package/src/memory/graph/retriever.ts +4 -5
  107. package/src/memory/graph/tool-handlers.ts +3 -4
  108. package/src/memory/graph/tools.ts +4 -4
  109. package/src/memory/indexer.ts +1 -2
  110. package/src/memory/jobs/__tests__/embed-concept-page.test.ts +116 -0
  111. package/src/memory/jobs/embed-concept-page.ts +223 -87
  112. package/src/memory/jobs-worker.ts +8 -4
  113. package/src/memory/pkb/pkb-search.test.ts +6 -5
  114. package/src/memory/pkb/pkb-search.ts +4 -5
  115. package/src/memory/qdrant-client.ts +3 -0
  116. package/src/memory/search/semantic.ts +4 -5
  117. package/src/memory/v2/__tests__/activation.test.ts +35 -5
  118. package/src/memory/v2/__tests__/consolidation-job.test.ts +21 -32
  119. package/src/memory/v2/__tests__/injection.test.ts +140 -23
  120. package/src/memory/v2/__tests__/qdrant.test.ts +310 -9
  121. package/src/memory/v2/__tests__/sim.test.ts +118 -7
  122. package/src/memory/v2/__tests__/static-context.test.ts +1 -13
  123. package/src/memory/v2/__tests__/sweep-job.test.ts +19 -33
  124. package/src/memory/v2/consolidation-job.ts +7 -8
  125. package/src/memory/v2/injection.ts +32 -12
  126. package/src/memory/v2/page-store.ts +39 -0
  127. package/src/memory/v2/prompts/consolidation.ts +5 -0
  128. package/src/memory/v2/qdrant.ts +209 -48
  129. package/src/memory/v2/sim.ts +67 -26
  130. package/src/memory/v2/static-context.ts +4 -8
  131. package/src/memory/v2/sweep-job.ts +5 -6
  132. package/src/memory/v2/types.ts +7 -0
  133. package/src/notifications/copy-composer.ts +46 -12
  134. package/src/notifications/decision-engine.ts +46 -0
  135. package/src/permissions/gateway-threshold-reader.ts +116 -8
  136. package/src/permissions/prompter.ts +86 -96
  137. package/src/permissions/secret-prompter.ts +31 -31
  138. package/src/plugins/defaults/injectors.ts +1 -2
  139. package/src/proactive-artifact/job.test.ts +51 -4
  140. package/src/proactive-artifact/job.ts +16 -2
  141. package/src/proactive-artifact/message-copy.ts +18 -1
  142. package/src/prompts/templates/SOUL.md +13 -28
  143. package/src/runtime/auth/route-policy.ts +1 -0
  144. package/src/runtime/channel-approvals.ts +3 -2
  145. package/src/runtime/guardian-reply-router.ts +0 -10
  146. package/src/runtime/pending-interactions.ts +19 -15
  147. package/src/runtime/routes/__tests__/memory-v2-routes.test.ts +147 -0
  148. package/src/runtime/routes/approval-routes.ts +7 -3
  149. package/src/runtime/routes/consolidation-routes.ts +8 -9
  150. package/src/runtime/routes/conversation-query-routes.ts +44 -1
  151. package/src/runtime/routes/debug-bash-routes.ts +2 -0
  152. package/src/runtime/routes/filing-routes.ts +2 -3
  153. package/src/runtime/routes/inbound-stages/guardian-reply-intercept.ts +0 -3
  154. package/src/runtime/routes/memory-item-routes.test.ts +3 -9
  155. package/src/runtime/routes/memory-item-routes.ts +5 -6
  156. package/src/runtime/routes/memory-v2-routes.ts +103 -17
  157. package/src/skills/include-graph.ts +35 -13
  158. package/src/tools/document/document-tool.ts +20 -0
  159. package/src/tools/executor.ts +18 -2
  160. package/src/tools/memory/register.test.ts +7 -5
  161. package/src/tools/permission-checker.ts +15 -0
  162. package/src/tools/skills/load.ts +24 -20
  163. package/src/tools/tool-name-aliases.ts +19 -0
  164. package/src/tools/types.ts +19 -1
  165. package/src/workspace/migrations/067-release-notes-safe-storage-limits.ts +4 -62
  166. package/src/workspace/migrations/069-seed-onboarding-threads.ts +28 -0
  167. package/src/workspace/migrations/070-memory-v2-summary-schema-rebuild.ts +31 -0
  168. package/src/workspace/migrations/071-remove-safe-storage-release-note.ts +111 -0
  169. package/src/workspace/migrations/registry.ts +6 -0
@@ -4,9 +4,8 @@
4
4
  *
5
5
  * The CLI half mocks `cliIpcCall` and asserts the subcommand dispatches
6
6
  * to `memory_v2_reembed_skills` with an empty body. The route half uses
7
- * the real `loadConfig` + flag resolver flags are toggled via
8
- * `_setOverridesForTesting` and `memory.v2.enabled` is toggled via a
9
- * per-test `config.json` fixture in the temp workspace. We mock only
7
+ * the real `loadConfig` — `memory.v2.enabled` is toggled via a per-test
8
+ * `config.json` fixture in the temp workspace. We mock only
10
9
  * `seedV2SkillEntries` so we can assert it was invoked without actually
11
10
  * embedding skills.
12
11
  */
@@ -17,20 +16,14 @@ import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
17
16
 
18
17
  import { Command } from "commander";
19
18
 
20
- import {
21
- _setOverridesForTesting,
22
- clearFeatureFlagOverridesCache,
23
- } from "../config/assistant-feature-flags.js";
24
19
  import { invalidateConfigCache } from "../config/loader.js";
25
20
  import { getWorkspaceDir } from "../util/platform.js";
26
21
 
27
22
  // ---------------------------------------------------------------------------
28
- // Module-level mocks — kept minimal. `loadConfig`,
29
- // `isAssistantFeatureFlagEnabled`, and `getLogger` use their real
30
- // implementations because we already have first-class test hooks
31
- // (`_setOverridesForTesting` for flags, a per-test workspace `config.json`
32
- // for config) that exercise the same code paths the route handler runs in
33
- // production.
23
+ // Module-level mocks — kept minimal. `loadConfig` and `getLogger` use their
24
+ // real implementations because we have first-class test hooks (a per-test
25
+ // workspace `config.json` for config) that exercise the same code paths
26
+ // the route handler runs in production.
34
27
  // ---------------------------------------------------------------------------
35
28
 
36
29
  let lastIpcCall: { method: string; params?: Record<string, unknown> } | null =
@@ -60,7 +53,7 @@ mock.module("../memory/v2/skill-store.js", () => ({
60
53
 
61
54
  const { registerMemoryV2Command } =
62
55
  await import("../cli/commands/memory-v2.js");
63
- const { ROUTES: memoryV2Routes } =
56
+ const { ROUTES: memoryV2Routes, MEMORY_V2_DISABLED_CODE } =
64
57
  await import("../runtime/routes/memory-v2-routes.js");
65
58
  const { RouteError } = await import("../runtime/routes/errors.js");
66
59
 
@@ -128,17 +121,15 @@ beforeEach(() => {
128
121
  seedCallCount = 0;
129
122
  process.exitCode = 0;
130
123
 
131
- // Real flag + config defaults: enable both so happy-path tests pass.
132
- _setOverridesForTesting({ "memory-v2-enabled": true });
124
+ // Default: v2 enabled so happy-path tests pass.
133
125
  writeWorkspaceConfig({ memory: { v2: { enabled: true } } });
134
126
  });
135
127
 
136
128
  afterEach(() => {
137
- // Roll back the workspace config + flag overrides between cases so a
138
- // gate-off test does not leak into the next case's setup.
129
+ // Roll back the workspace config between cases so a gate-off test does
130
+ // not leak into the next case's setup.
139
131
  rmSync(join(getWorkspaceDir(), "config.json"), { force: true });
140
132
  invalidateConfigCache();
141
- clearFeatureFlagOverridesCache();
142
133
  });
143
134
 
144
135
  // ---------------------------------------------------------------------------
@@ -188,15 +179,6 @@ describe("memory_v2_reembed_skills route", () => {
188
179
  expect(seedCallCount).toBe(0);
189
180
  });
190
181
 
191
- test("throws RouteError when feature flag is off", async () => {
192
- _setOverridesForTesting({ "memory-v2-enabled": false });
193
-
194
- await expect(
195
- reembedSkillsRoute!.handler({ body: {} }),
196
- ).rejects.toBeInstanceOf(RouteError);
197
- expect(seedCallCount).toBe(0);
198
- });
199
-
200
182
  test("throws RouteError when config.memory.v2.enabled is off", async () => {
201
183
  writeWorkspaceConfig({ memory: { v2: { enabled: false } } });
202
184
 
@@ -206,3 +188,51 @@ describe("memory_v2_reembed_skills route", () => {
206
188
  expect(seedCallCount).toBe(0);
207
189
  });
208
190
  });
191
+
192
+ // ---------------------------------------------------------------------------
193
+ // All v2 routes share the same gate
194
+ // ---------------------------------------------------------------------------
195
+
196
+ describe("all memory v2 routes — MEMORY_V2_DISABLED gate", () => {
197
+ // Minimal bodies that satisfy each route's schema. The gate runs before
198
+ // schema validation so any body would surface the gate error, but using
199
+ // valid shapes keeps the assertion precise: we're confirming the gate
200
+ // (not zod) is what blocks the call.
201
+ const MINIMAL_BODIES: Record<string, Record<string, unknown>> = {
202
+ memory_v2_backfill: { op: "migrate" },
203
+ memory_v2_validate: {},
204
+ memory_v2_get_concept_page: { slug: "any" },
205
+ memory_v2_list_concept_pages: {},
206
+ memory_v2_rebuild_corpus_stats: {},
207
+ memory_v2_explain_similarity: { userText: "hello" },
208
+ memory_v2_concept_frequency: {},
209
+ memory_v2_fit_anisotropy: {},
210
+ };
211
+
212
+ const GATE_OFF_CASES = [
213
+ {
214
+ label: "config is off",
215
+ apply: () => writeWorkspaceConfig({ memory: { v2: { enabled: false } } }),
216
+ },
217
+ ];
218
+
219
+ for (const [operationId, body] of Object.entries(MINIMAL_BODIES)) {
220
+ for (const { label, apply } of GATE_OFF_CASES) {
221
+ test(`${operationId} throws MEMORY_V2_DISABLED when ${label}`, async () => {
222
+ apply();
223
+ const route = memoryV2Routes.find((r) => r.operationId === operationId);
224
+ expect(route).toBeDefined();
225
+
226
+ try {
227
+ await route!.handler({ body });
228
+ throw new Error("expected handler to throw");
229
+ } catch (err) {
230
+ expect(err).toBeInstanceOf(RouteError);
231
+ expect((err as InstanceType<typeof RouteError>).code).toBe(
232
+ MEMORY_V2_DISABLED_CODE,
233
+ );
234
+ }
235
+ });
236
+ }
237
+ }
238
+ });
@@ -58,6 +58,7 @@ afterAll(() => {
58
58
  });
59
59
 
60
60
  import { invalidateConfigCache, loadConfig } from "../config/loader.js";
61
+ import { applyContextDefaultsToRawConfig } from "../runtime/routes/conversation-query-routes.js";
61
62
  import { _setStorePath } from "../security/encrypted-store.js";
62
63
 
63
64
  // ---------------------------------------------------------------------------
@@ -184,7 +185,7 @@ describe("platform-managed config defaults", () => {
184
185
  ) + "\n",
185
186
  );
186
187
 
187
- loadConfig();
188
+ const config = loadConfig();
188
189
 
189
190
  const written = readConfig() as { services?: Record<string, unknown> };
190
191
  expect(written.services).toBeDefined();
@@ -192,5 +193,287 @@ describe("platform-managed config defaults", () => {
192
193
  expect(
193
194
  (written.services!["inference"] as { mode?: string })?.mode,
194
195
  ).toBe("your-own");
196
+ // ...and the in-memory config must mirror the explicit user choice (the
197
+ // fill-defaults pass must not override an explicit "your-own").
198
+ expect(
199
+ (config.services.inference as { mode: string }).mode,
200
+ ).toBe("your-own");
201
+ });
202
+
203
+ test("IS_PLATFORM=true, config file exists without a services key → in-memory config has all managed modes", () => {
204
+ // Regression guard for the platform-managed boot order: by the time
205
+ // `loadConfig()` runs, lifecycle steps such as `seedInferenceProfiles`
206
+ // have already written `config.json` (with `llm.profiles` etc.), so
207
+ // `configFileExisted` is true even on a brand-new platform-managed
208
+ // assistant. Deployment-context defaults must still be applied to the
209
+ // in-memory config for any leaf keys that are absent from disk.
210
+ process.env.IS_PLATFORM = "true";
211
+
212
+ writeFileSync(
213
+ CONFIG_PATH,
214
+ JSON.stringify(
215
+ {
216
+ llm: {
217
+ profiles: {
218
+ balanced: { provider: "anthropic", model: "claude-sonnet-4.5" },
219
+ },
220
+ activeProfile: "balanced",
221
+ },
222
+ },
223
+ null,
224
+ 2,
225
+ ) + "\n",
226
+ );
227
+
228
+ const config = loadConfig();
229
+
230
+ // In-memory config has the deployment-context defaults applied for the
231
+ // missing service-mode fields.
232
+ for (const svc of MANAGED_SERVICES) {
233
+ expect(
234
+ (
235
+ config.services as unknown as Record<
236
+ string,
237
+ { mode: string }
238
+ >
239
+ )[svc]!.mode,
240
+ ).toBe("managed");
241
+ }
242
+
243
+ // The on-disk file is NOT modified by the fill pass — disk reflects only
244
+ // what was already there. Existing-file branch never re-writes config.json.
245
+ const onDisk = readConfig() as Record<string, unknown>;
246
+ expect(onDisk["services"]).toBeUndefined();
247
+ });
248
+
249
+ test("IS_PLATFORM=true, config file exists with a partial service subtree → preserves user fields, fills missing mode", () => {
250
+ process.env.IS_PLATFORM = "true";
251
+
252
+ // User has an image-generation provider configured but never explicitly
253
+ // chose a mode for that service. The fill pass must apply
254
+ // `mode: "managed"` without clobbering the user-supplied provider.
255
+ // (The inference schema dropped per-service model/provider in
256
+ // migration 039 — image-generation still carries them, so it's the
257
+ // right schema to exercise the partial-subtree case.)
258
+ writeFileSync(
259
+ CONFIG_PATH,
260
+ JSON.stringify(
261
+ {
262
+ services: {
263
+ "image-generation": { provider: "openai" },
264
+ },
265
+ },
266
+ null,
267
+ 2,
268
+ ) + "\n",
269
+ );
270
+
271
+ const config = loadConfig();
272
+
273
+ const imageGen = (
274
+ config.services as unknown as Record<
275
+ string,
276
+ { mode: string; provider?: string }
277
+ >
278
+ )["image-generation"]!;
279
+ expect(imageGen.mode).toBe("managed");
280
+ expect(imageGen.provider).toBe("openai");
281
+ });
282
+
283
+ test("IS_PLATFORM=false, config file exists without services key → in-memory config keeps schema your-own defaults", () => {
284
+ // Sanity guard: deployment-context defaults are a no-op when IS_PLATFORM
285
+ // is not enabled, regardless of whether config.json existed.
286
+ process.env.IS_PLATFORM = "false";
287
+
288
+ writeFileSync(
289
+ CONFIG_PATH,
290
+ JSON.stringify(
291
+ {
292
+ llm: {
293
+ profiles: {
294
+ balanced: { provider: "anthropic", model: "claude-sonnet-4.5" },
295
+ },
296
+ activeProfile: "balanced",
297
+ },
298
+ },
299
+ null,
300
+ 2,
301
+ ) + "\n",
302
+ );
303
+
304
+ const config = loadConfig();
305
+
306
+ for (const svc of MANAGED_SERVICES) {
307
+ expect(
308
+ (
309
+ config.services as unknown as Record<
310
+ string,
311
+ { mode: string }
312
+ >
313
+ )[svc]!.mode,
314
+ ).toBe("your-own");
315
+ }
316
+ });
317
+ });
318
+
319
+ /**
320
+ * Regression guard for the `handleGetConfig` route handler in
321
+ * `assistant/src/runtime/routes/conversation-query-routes.ts`. That handler
322
+ * returns the raw on-disk JSON to clients (macOS, web, CLI) via
323
+ * `GET /v1/config`, but first layers deployment-context defaults on top
324
+ * via the `applyContextDefaultsToRawConfig` helper.
325
+ *
326
+ * macOS's `loadServiceModes(config:)` only updates `inferenceMode` when
327
+ * `services.inference.mode` is present in the response — without the fill
328
+ * pass, freshly-hatched platform-managed assistants would have no `services`
329
+ * key on disk (only `llm.profiles` from `seedInferenceProfiles`) and macOS
330
+ * would fall back to its `@Published` default of "your-own". The helper is
331
+ * also responsible for guarding against `loadRawConfig()` returning a
332
+ * non-object payload from a malformed-but-parseable `config.json`.
333
+ */
334
+ describe("GET /v1/config handler — context-default fill on raw response", () => {
335
+ const originalIsPlatform = process.env.IS_PLATFORM;
336
+
337
+ afterEach(() => {
338
+ if (originalIsPlatform === undefined) {
339
+ delete process.env.IS_PLATFORM;
340
+ } else {
341
+ process.env.IS_PLATFORM = originalIsPlatform;
342
+ }
343
+ });
344
+
345
+ test("IS_PLATFORM=true, raw config has no services key → response includes managed defaults", () => {
346
+ process.env.IS_PLATFORM = "true";
347
+
348
+ // Mirrors the real-world fresh-hatch state: lifecycle wrote
349
+ // `llm.profiles` to disk, but never persisted any service modes.
350
+ const raw: Record<string, unknown> = {
351
+ llm: {
352
+ profiles: {
353
+ balanced: { provider: "anthropic", model: "claude-sonnet-4.5" },
354
+ },
355
+ activeProfile: "balanced",
356
+ },
357
+ };
358
+
359
+ const result = applyContextDefaultsToRawConfig(raw) as Record<
360
+ string,
361
+ unknown
362
+ >;
363
+ const services = result["services"] as Record<string, { mode: string }>;
364
+ expect(services).toBeDefined();
365
+ for (const svc of MANAGED_SERVICES) {
366
+ expect(services[svc]!.mode).toBe("managed");
367
+ }
368
+ });
369
+
370
+ test("IS_PLATFORM=true, raw config has explicit services.inference.mode='your-own' → preserved", () => {
371
+ process.env.IS_PLATFORM = "true";
372
+
373
+ // User has explicitly chosen "your-own" via the macOS Save flow.
374
+ // The patch handler persisted that to disk; the fill pass must not
375
+ // override an explicit user choice.
376
+ const raw: Record<string, unknown> = {
377
+ services: {
378
+ inference: { mode: "your-own" },
379
+ },
380
+ };
381
+
382
+ const result = applyContextDefaultsToRawConfig(raw) as Record<
383
+ string,
384
+ unknown
385
+ >;
386
+ const services = result["services"] as Record<string, { mode: string }>;
387
+ expect(services["inference"]!.mode).toBe("your-own");
388
+ // Other services were missing entirely → context defaults fill them in.
389
+ expect(services["image-generation"]!.mode).toBe("managed");
390
+ expect(services["web-search"]!.mode).toBe("managed");
391
+ });
392
+
393
+ test("IS_PLATFORM=false, raw config has no services key → response is unchanged", () => {
394
+ process.env.IS_PLATFORM = "false";
395
+
396
+ const raw: Record<string, unknown> = {
397
+ llm: {
398
+ profiles: {
399
+ balanced: { provider: "anthropic", model: "claude-sonnet-4.5" },
400
+ },
401
+ },
402
+ };
403
+
404
+ const result = applyContextDefaultsToRawConfig(raw) as Record<
405
+ string,
406
+ unknown
407
+ >;
408
+ expect(result["services"]).toBeUndefined();
409
+ });
410
+
411
+ test("IS_PLATFORM=true, raw config has partial services.inference subtree → preserves user fields, fills missing mode", () => {
412
+ process.env.IS_PLATFORM = "true";
413
+
414
+ // User set image-generation.provider but never chose a mode for any
415
+ // service. The fill pass adds the missing modes without clobbering
416
+ // the user-supplied provider.
417
+ const raw: Record<string, unknown> = {
418
+ services: {
419
+ "image-generation": { provider: "openai" },
420
+ },
421
+ };
422
+
423
+ const result = applyContextDefaultsToRawConfig(raw) as Record<
424
+ string,
425
+ unknown
426
+ >;
427
+ const services = result["services"] as Record<
428
+ string,
429
+ { mode: string; provider?: string }
430
+ >;
431
+ expect(services["image-generation"]!.mode).toBe("managed");
432
+ expect(services["image-generation"]!.provider).toBe("openai");
433
+ // Inference, which was missing entirely, picks up the context default.
434
+ expect(services["inference"]!.mode).toBe("managed");
435
+ });
436
+
437
+ // -------------------------------------------------------------------------
438
+ // Malformed-but-parseable config.json — must not 500 the GET endpoint.
439
+ //
440
+ // `loadRawConfig()` is typed `Record<string, unknown>` but `JSON.parse`
441
+ // will happily return `null`, primitives, or arrays for a syntactically
442
+ // valid file like `null` / `42` / `[]`. The helper must return those
443
+ // payloads unchanged rather than throwing inside
444
+ // `fillContextDefaultsForMissingKeys`.
445
+ // -------------------------------------------------------------------------
446
+
447
+ test("IS_PLATFORM=true, raw config is null → returned unchanged (no throw)", () => {
448
+ process.env.IS_PLATFORM = "true";
449
+ expect(applyContextDefaultsToRawConfig(null)).toBe(null);
450
+ });
451
+
452
+ test("IS_PLATFORM=true, raw config is a primitive number → returned unchanged (no throw)", () => {
453
+ process.env.IS_PLATFORM = "true";
454
+ expect(applyContextDefaultsToRawConfig(42)).toBe(42);
455
+ });
456
+
457
+ test("IS_PLATFORM=true, raw config is an array → returned unchanged (no throw)", () => {
458
+ process.env.IS_PLATFORM = "true";
459
+ const raw: unknown[] = [{ foo: "bar" }];
460
+ const result = applyContextDefaultsToRawConfig(raw);
461
+ expect(result).toBe(raw);
462
+ // No `services` key was synthesized onto the array.
463
+ expect((result as { services?: unknown }).services).toBeUndefined();
464
+ });
465
+
466
+ test("IS_PLATFORM=true, raw config is a string → returned unchanged (no throw)", () => {
467
+ process.env.IS_PLATFORM = "true";
468
+ expect(applyContextDefaultsToRawConfig("not-an-object")).toBe(
469
+ "not-an-object",
470
+ );
471
+ });
472
+
473
+ test("IS_PLATFORM=false, raw config is null → returned unchanged (no throw)", () => {
474
+ // Sanity check: when there are no context defaults to apply, the helper
475
+ // also short-circuits cleanly on non-object payloads.
476
+ process.env.IS_PLATFORM = "false";
477
+ expect(applyContextDefaultsToRawConfig(null)).toBe(null);
195
478
  });
196
479
  });
@@ -1,6 +1,5 @@
1
1
  import { beforeEach, describe, expect, mock, test } from "bun:test";
2
2
 
3
- import { _setOverridesForTesting } from "../config/assistant-feature-flags.js";
4
3
  import type { AssistantConfig } from "../config/schema.js";
5
4
  import type {
6
5
  RecallEvidence,
@@ -107,11 +106,7 @@ const v2Calls: Array<{
107
106
  }> = [];
108
107
  let v2EvidenceReturn: RecallEvidence[] = [];
109
108
 
110
- const realMemoryV2 =
111
- await import("../memory/context-search/sources/memory-v2.js");
112
-
113
109
  mock.module(memoryV2SourceModule, () => ({
114
- isMemoryV2ReadActive: realMemoryV2.isMemoryV2ReadActive,
115
110
  searchMemoryV2Source: async (
116
111
  query: string,
117
112
  context: RecallSearchContext,
@@ -138,7 +133,6 @@ describe("searchMemorySource", () => {
138
133
  getNodesByIdsCalls.length = 0;
139
134
  v2Calls.length = 0;
140
135
  v2EvidenceReturn = [];
141
- _setOverridesForTesting({ "memory-v2-enabled": false });
142
136
  });
143
137
 
144
138
  test("hydrates graph hits into memory recall evidence", async () => {
@@ -298,8 +292,7 @@ describe("searchMemorySource", () => {
298
292
  );
299
293
  });
300
294
 
301
- test("routes to v2 source when both v2 gates are on", async () => {
302
- _setOverridesForTesting({ "memory-v2-enabled": true });
295
+ test("routes to v2 source when memory.v2.enabled is on", async () => {
303
296
  v2EvidenceReturn = [
304
297
  {
305
298
  id: "memory:v2:alice",
@@ -334,8 +327,7 @@ describe("searchMemorySource", () => {
334
327
  ]);
335
328
  });
336
329
 
337
- test("stays on legacy path when feature flag is on but config.memory.v2.enabled is off", async () => {
338
- _setOverridesForTesting({ "memory-v2-enabled": true });
330
+ test("stays on legacy path when memory.v2.enabled is off", async () => {
339
331
  searchHits = [{ nodeId: "node-a", score: 0.7, text: "" }];
340
332
  hydratedNodes = [makeNode({ id: "node-a", content: "Legacy hit" })];
341
333
 
@@ -348,21 +340,6 @@ describe("searchMemorySource", () => {
348
340
  expect(v2Calls).toHaveLength(0);
349
341
  expect(searchCalls).toHaveLength(1);
350
342
  });
351
-
352
- test("stays on legacy path when feature flag is off", async () => {
353
- _setOverridesForTesting({ "memory-v2-enabled": false });
354
- searchHits = [{ nodeId: "node-a", score: 0.7, text: "" }];
355
- hydratedNodes = [makeNode({ id: "node-a", content: "Legacy hit" })];
356
-
357
- await searchMemorySource(
358
- "alice",
359
- makeContext({ config: makeV2EnabledConfig() }),
360
- 5,
361
- );
362
-
363
- expect(v2Calls).toHaveLength(0);
364
- expect(searchCalls).toHaveLength(1);
365
- });
366
343
  });
367
344
 
368
345
  function makeV2EnabledConfig(): AssistantConfig {
@@ -387,7 +364,7 @@ function makeContext(
387
364
  return {
388
365
  workingDir: "/tmp/example-workspace",
389
366
  conversationId: "conv-123",
390
- config: {} as AssistantConfig,
367
+ config: { memory: { v2: { enabled: false } } } as AssistantConfig,
391
368
  ...overrides,
392
369
  };
393
370
  }
@@ -9,7 +9,6 @@ import { tmpdir } from "node:os";
9
9
  import { dirname, join } from "node:path";
10
10
  import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
11
11
 
12
- import { _setOverridesForTesting } from "../config/assistant-feature-flags.js";
13
12
  import type { AssistantConfig } from "../config/schema.js";
14
13
  import type { RecallSearchContext } from "../memory/context-search/types.js";
15
14
  import { PKB_WORKSPACE_SCOPE } from "../memory/pkb/types.js";
@@ -19,6 +18,16 @@ mock.module("../util/logger.js", () => ({
19
18
  getLogger: () => makeMockLogger(),
20
19
  }));
21
20
 
21
+ // Override `getConfig` so `searchPkbFiles`'s v2 short-circuit (which checks
22
+ // `getConfig().memory.v2.enabled`) stays inactive — these tests exercise
23
+ // the v1 path. Spread the real loader so other exports (loadConfig,
24
+ // applyNestedDefaults, etc.) keep working.
25
+ const realPkbLoader = await import("../config/loader.js");
26
+ mock.module("../config/loader.js", () => ({
27
+ ...realPkbLoader,
28
+ getConfig: () => ({ memory: { v2: { enabled: false } } }),
29
+ }));
30
+
22
31
  const embedCalls: Array<{
23
32
  config: AssistantConfig;
24
33
  texts: unknown[];
@@ -174,7 +183,7 @@ function makeContext(
174
183
  return {
175
184
  workingDir: "/workspace",
176
185
  conversationId: "conv-xyz",
177
- config: {} as AssistantConfig,
186
+ config: { memory: { v2: { enabled: false } } } as AssistantConfig,
178
187
  ...overrides,
179
188
  };
180
189
  }
@@ -193,7 +202,6 @@ describe("PKB context-search source", () => {
193
202
  denseThrows = null;
194
203
  pkbContext = null;
195
204
  nowScratchpad = null;
196
- _setOverridesForTesting({ "memory-v2-enabled": false });
197
205
  });
198
206
 
199
207
  test("converts PKB hits to recall evidence with snippets and scores", async () => {
@@ -443,8 +451,7 @@ describe("PKB context-search source", () => {
443
451
  ]);
444
452
  });
445
453
 
446
- test("short-circuits to empty when both v2 gates are on", async () => {
447
- _setOverridesForTesting({ "memory-v2-enabled": true });
454
+ test("short-circuits to empty when memory.v2.enabled is on", async () => {
448
455
  denseResults = [
449
456
  {
450
457
  id: "dense-a",
@@ -471,7 +478,6 @@ describe("PKB context-search source", () => {
471
478
  });
472
479
 
473
480
  test("readPkbContextEvidence short-circuits when v2 read is active", () => {
474
- _setOverridesForTesting({ "memory-v2-enabled": true });
475
481
  pkbContext = "should not surface under v2";
476
482
  nowScratchpad = "should not surface under v2";
477
483
 
@@ -1,18 +1,12 @@
1
1
  import { describe, expect, mock, test } from "bun:test";
2
2
 
3
3
  import type { AgentEvent } from "../agent/loop.js";
4
- import { _setOverridesForTesting } from "../config/assistant-feature-flags.js";
5
4
  import type {
6
5
  ContentBlock,
7
6
  Message,
8
7
  ProviderResponse,
9
8
  } from "../providers/types.js";
10
9
 
11
- // This test exercises v1 conversation routing. The `memory-v2-enabled` flag
12
- // (registry default `true`) flips memory routing to v2 — disable it here so
13
- // the v1 paths under test stay active.
14
- _setOverridesForTesting({ "memory-v2-enabled": false });
15
-
16
10
  mock.module("../util/logger.js", () => ({
17
11
  getLogger: () =>
18
12
  new Proxy({} as Record<string, unknown>, { get: () => () => {} }),
@@ -61,6 +55,7 @@ mock.module("../config/loader.js", () => ({
61
55
  pricingOverrides: [],
62
56
  },
63
57
  rateLimit: { maxRequestsPerMinute: 0 },
58
+ memory: { v2: { enabled: false } },
64
59
  daemon: {
65
60
  startupSocketWaitMs: 5000,
66
61
  stopTimeoutMs: 5000,
@@ -476,7 +476,7 @@ function makeCtx(
476
476
  }),
477
477
 
478
478
  graphMemory: {
479
- onCompacted: () => {},
479
+ onCompacted: async () => {},
480
480
  prepareMemory: async () => ({
481
481
  runMessages: [],
482
482
  injectedTokens: 0,
@@ -531,7 +531,7 @@ function makeCtx(
531
531
  }),
532
532
 
533
533
  graphMemory: {
534
- onCompacted: () => {},
534
+ onCompacted: async () => {},
535
535
  prepareMemory: async () => ({
536
536
  runMessages: [],
537
537
  injectedTokens: 0,
@@ -626,7 +626,7 @@ function makeCtx(
626
626
  }),
627
627
 
628
628
  graphMemory: {
629
- onCompacted: () => {},
629
+ onCompacted: async () => {},
630
630
  prepareMemory: async () => ({
631
631
  runMessages: [],
632
632
  injectedTokens: 0,
@@ -3652,11 +3652,11 @@ describe("session-agent-loop", () => {
3652
3652
  expect(rendered).not.toContain("original root");
3653
3653
  });
3654
3654
 
3655
- test("applyCompactionResult records Slack timestamp watermark when provided", () => {
3655
+ test("applyCompactionResult records Slack timestamp watermark when provided", async () => {
3656
3656
  const ctx = makeCtx();
3657
3657
  const events: ServerMessage[] = [];
3658
3658
 
3659
- applyCompactionResult(
3659
+ await applyCompactionResult(
3660
3660
  ctx,
3661
3661
  {
3662
3662
  messages: [
@@ -280,21 +280,13 @@ function seedPendingConfirmation(
280
280
  conversation: Conversation,
281
281
  requestId: string,
282
282
  ): void {
283
+ // Access private ownedIds so denyAllPending/dispose can find this request.
284
+ // promptResolve/promptReject callbacks are stored in pendingInteractions via
285
+ // registerPendingInteraction, which is called separately in each test.
283
286
  const prompter = conversation["prompter"] as unknown as {
284
- pending: Map<
285
- string,
286
- {
287
- resolve: (...args: unknown[]) => void;
288
- reject: (...args: unknown[]) => void;
289
- timer: ReturnType<typeof setTimeout>;
290
- }
291
- >;
287
+ ownedIds: Set<string>;
292
288
  };
293
- prompter.pending.set(requestId, {
294
- resolve: () => {},
295
- reject: () => {},
296
- timer: setTimeout(() => {}, 60_000),
297
- });
289
+ prompter.ownedIds.add(requestId);
298
290
  }
299
291
 
300
292
  // ---------------------------------------------------------------------------
@@ -465,7 +465,7 @@ describe("End-to-end session creation benchmark", () => {
465
465
  timings.push(performance.now() - start);
466
466
 
467
467
  if (i === 0) {
468
- expect(session.eventBus.listenerCount()).toBeGreaterThan(0);
468
+ expect(session.eventBus.anyListenerCount()).toBeGreaterThan(0);
469
469
  }
470
470
  session.dispose();
471
471
  }