@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
@@ -24,22 +24,30 @@
24
24
  * content.
25
25
  */
26
26
 
27
- import { beforeEach, describe, expect, test } from "bun:test";
28
-
29
- import { _setOverridesForTesting } from "../config/assistant-feature-flags.js";
30
- import {
31
- applyRuntimeInjections,
32
- composeInjectorChain,
33
- } from "../daemon/conversation-runtime-assembly.js";
34
- import {
35
- DEFAULT_INJECTOR_ORDER,
36
- defaultInjectorsPlugin,
37
- } from "../plugins/defaults/injectors.js";
38
-
39
- // This test exercises v1 PKB injection. The `memory-v2-enabled` flag
40
- // (registry default `true`) makes the PKB injector go silent — disable it
41
- // here so the v1 injection chain assertions stay meaningful.
42
- _setOverridesForTesting({ "memory-v2-enabled": false });
27
+ import { beforeEach, describe, expect, mock, test } from "bun:test";
28
+
29
+ // This test exercises v1 PKB injection. `config.memory.v2.enabled`
30
+ // (default `true`) makes the PKB injector go silent — force it off here
31
+ // so the v1 injection chain assertions stay meaningful.
32
+ const realLoader = await import("../config/loader.js");
33
+ const realGetConfig = realLoader.getConfig;
34
+ mock.module("../config/loader.js", () => ({
35
+ ...realLoader,
36
+ getConfig: () => {
37
+ const real = realGetConfig();
38
+ return {
39
+ ...real,
40
+ memory: { ...real.memory, v2: { ...real.memory.v2, enabled: false } },
41
+ };
42
+ },
43
+ }));
44
+
45
+ const { applyRuntimeInjections, composeInjectorChain } = await import(
46
+ "../daemon/conversation-runtime-assembly.js"
47
+ );
48
+ const { DEFAULT_INJECTOR_ORDER, defaultInjectorsPlugin } = await import(
49
+ "../plugins/defaults/injectors.js"
50
+ );
43
51
  import {
44
52
  getInjectors,
45
53
  registerPlugin,
@@ -1,23 +1,26 @@
1
1
  /**
2
2
  * v2 read-side cutover behavior for the PKB-derived default injectors.
3
3
  *
4
- * When `isMemoryV2ReadActive(getConfig())` is true:
4
+ * When `getConfig().memory.v2.enabled` is true:
5
5
  * - `pkb-context` silences itself (concept pages own retrieval).
6
6
  * - `pkb-reminder` still fires (its body is generic recall/remember
7
7
  * guidance) but skips the PKB-search hints — those name PKB paths.
8
8
  * - `now-md` fires unchanged (workspace state, independent of PKB).
9
9
  *
10
- * Mocks `isMemoryV2ReadActive` at the module level so each test can flip the
11
- * effective gate state without standing up a full feature-flag + config
12
- * stack. Mocks the PKB hybrid search so the reminder-with-hints branch can
13
- * resolve deterministically when called.
10
+ * Mocks `getConfig` at the module level so each test can flip the effective
11
+ * gate state without standing up a full config stack. Mocks the PKB hybrid
12
+ * search so the reminder-with-hints branch can resolve deterministically
13
+ * when called.
14
14
  */
15
15
  import { beforeEach, describe, expect, mock, test } from "bun:test";
16
16
 
17
17
  let v2Active = false;
18
18
 
19
- mock.module("../memory/context-search/sources/memory-v2.js", () => ({
20
- isMemoryV2ReadActive: () => v2Active,
19
+ const realLoader = await import("../config/loader.js");
20
+
21
+ mock.module("../config/loader.js", () => ({
22
+ ...realLoader,
23
+ getConfig: () => ({ memory: { v2: { enabled: v2Active } } }),
21
24
  }));
22
25
 
23
26
  mock.module("../memory/pkb/pkb-search.js", () => ({
@@ -1,17 +1,17 @@
1
1
  /**
2
- * Tests for the memory-v2 skill seed gate invoked from the daemon startup
3
- * path (`assistant/src/daemon/memory-v2-startup.ts`).
2
+ * Tests for the memory-v2 skill seed gate and the v2 concept-page schema
3
+ * rebuild gate, both invoked from the daemon startup path
4
+ * (`assistant/src/daemon/memory-v2-startup.ts`).
4
5
  *
5
- * The gate is exercised in isolation rather than mounting the full lifecycle
6
- * import graph. Coverage matrix from PR 8 acceptance criteria:
7
- * - Case 1: feature flag on + `config.memory.v2.enabled` on seed runs.
8
- * - Case 2: feature flag off → seed does not run.
9
- * - Case 3: `config.memory.v2.enabled` off (flag on) seed does not run.
10
- * - Case 4: `seedV2SkillEntries` rejects → gate does not throw and the
11
- * warning is logged.
6
+ * The gates are exercised in isolation rather than mounting the full
7
+ * lifecycle import graph. Coverage matrix:
8
+ * - Skill seed (`maybeSeedMemoryV2Skills`): config gating, rejection
9
+ * swallowing.
10
+ * - Schema rebuild (`maybeRebuildMemoryV2Concepts`): config gating,
11
+ * drift-triggered reembed enqueue, empty-after-create reembed enqueue,
12
+ * no enqueue when collection is healthy, error swallowing.
12
13
  *
13
- * The seed call itself is fire-and-forget (`void` + `.catch`); the gate must
14
- * never block startup or surface an exception.
14
+ * Both gates must never block startup or surface an exception.
15
15
  */
16
16
  import { beforeEach, describe, expect, mock, test } from "bun:test";
17
17
 
@@ -22,31 +22,36 @@ import type { AssistantConfig } from "../config/schema.js";
22
22
  // ---------------------------------------------------------------------------
23
23
 
24
24
  interface TestState {
25
- flagOverrides: Record<string, boolean>;
26
25
  seedCallCount: number;
27
26
  seedShouldReject: Error | null;
28
27
  warnCalls: Array<{ obj: unknown; msg: unknown }>;
28
+ infoCalls: Array<{ obj: unknown; msg: unknown }>;
29
+ // Rebuild-gate mocks (drive maybeRebuildMemoryV2Concepts).
30
+ ensureCollectionCallCount: number;
31
+ ensureCollectionResult: { migrated: boolean };
32
+ ensureCollectionThrows: Error | null;
33
+ countResult: number;
34
+ listPagesResult: string[];
35
+ enqueueCalls: Array<{ type: string; payload: Record<string, unknown> }>;
29
36
  }
30
37
 
31
38
  const state: TestState = {
32
- flagOverrides: {},
33
39
  seedCallCount: 0,
34
40
  seedShouldReject: null,
35
41
  warnCalls: [],
42
+ infoCalls: [],
43
+ ensureCollectionCallCount: 0,
44
+ ensureCollectionResult: { migrated: false },
45
+ ensureCollectionThrows: null,
46
+ countResult: 0,
47
+ listPagesResult: [],
48
+ enqueueCalls: [],
36
49
  };
37
50
 
38
51
  // ---------------------------------------------------------------------------
39
52
  // Mocks — installed before the module under test is loaded.
40
53
  // ---------------------------------------------------------------------------
41
54
 
42
- mock.module("../config/assistant-feature-flags.js", () => ({
43
- isAssistantFeatureFlagEnabled: (key: string, _config: unknown): boolean => {
44
- const explicit = state.flagOverrides[key];
45
- if (typeof explicit === "boolean") return explicit;
46
- return true; // undeclared flags default to enabled
47
- },
48
- }));
49
-
50
55
  mock.module("../memory/v2/skill-store.js", () => ({
51
56
  seedV2SkillEntries: async (): Promise<void> => {
52
57
  state.seedCallCount += 1;
@@ -54,18 +59,51 @@ mock.module("../memory/v2/skill-store.js", () => ({
54
59
  },
55
60
  }));
56
61
 
62
+ mock.module("../memory/v2/qdrant.js", () => ({
63
+ ensureConceptPageCollection: async (): Promise<{ migrated: boolean }> => {
64
+ state.ensureCollectionCallCount += 1;
65
+ if (state.ensureCollectionThrows) throw state.ensureCollectionThrows;
66
+ return state.ensureCollectionResult;
67
+ },
68
+ countConceptPagePoints: async (): Promise<number> => state.countResult,
69
+ // The rebuild gate does not call this, but the seed gate's fire-and-forget
70
+ // chain imports it; provide a no-op so the dynamic import resolves.
71
+ dropLegacySkillsCollection: async (): Promise<void> => {},
72
+ }));
73
+
74
+ mock.module("../memory/v2/page-store.js", () => ({
75
+ hasConceptPages: async (): Promise<boolean> =>
76
+ state.listPagesResult.length > 0,
77
+ }));
78
+
79
+ mock.module("../memory/jobs-store.js", () => ({
80
+ enqueueMemoryJob: (
81
+ type: string,
82
+ payload: Record<string, unknown>,
83
+ ): string => {
84
+ state.enqueueCalls.push({ type, payload });
85
+ return "test-job-id";
86
+ },
87
+ }));
88
+
89
+ mock.module("../util/platform.js", () => ({
90
+ getWorkspaceDir: () => "/tmp/test-workspace",
91
+ }));
92
+
57
93
  mock.module("../util/logger.js", () => ({
58
94
  getLogger: () => ({
59
95
  warn: (obj: unknown, msg: unknown) => {
60
96
  state.warnCalls.push({ obj, msg });
61
97
  },
62
- info: () => {},
98
+ info: (obj: unknown, msg: unknown) => {
99
+ state.infoCalls.push({ obj, msg });
100
+ },
63
101
  error: () => {},
64
102
  debug: () => {},
65
103
  }),
66
104
  }));
67
105
 
68
- const { maybeSeedMemoryV2Skills } =
106
+ const { maybeSeedMemoryV2Skills, maybeRebuildMemoryV2Concepts } =
69
107
  await import("../daemon/memory-v2-startup.js");
70
108
 
71
109
  // ---------------------------------------------------------------------------
@@ -99,65 +137,37 @@ async function flushMicrotasks(): Promise<void> {
99
137
  // Tests
100
138
  // ---------------------------------------------------------------------------
101
139
 
140
+ function resetState(): void {
141
+ state.seedCallCount = 0;
142
+ state.seedShouldReject = null;
143
+ state.warnCalls = [];
144
+ state.infoCalls = [];
145
+ state.ensureCollectionCallCount = 0;
146
+ state.ensureCollectionResult = { migrated: false };
147
+ state.ensureCollectionThrows = null;
148
+ state.countResult = 0;
149
+ state.listPagesResult = [];
150
+ state.enqueueCalls = [];
151
+ }
152
+
102
153
  describe("maybeSeedMemoryV2Skills (daemon startup gate)", () => {
103
- beforeEach(() => {
104
- state.flagOverrides = {};
105
- state.seedCallCount = 0;
106
- state.seedShouldReject = null;
107
- state.warnCalls = [];
108
- });
154
+ beforeEach(resetState);
109
155
 
110
- test("invokes seedV2SkillEntries when flag and config are both enabled", async () => {
111
- state.flagOverrides = { "memory-v2-enabled": true };
156
+ test("invokes seedV2SkillEntries when memory.v2.enabled is true", async () => {
112
157
  maybeSeedMemoryV2Skills(makeConfig(true));
113
158
  await flushMicrotasks();
114
159
  expect(state.seedCallCount).toBe(1);
115
160
  expect(state.warnCalls).toHaveLength(0);
116
161
  });
117
162
 
118
- test("does not invoke seedV2SkillEntries when feature flag is off", async () => {
119
- state.flagOverrides = { "memory-v2-enabled": false };
120
- maybeSeedMemoryV2Skills(makeConfig(true));
121
- await flushMicrotasks();
122
- expect(state.seedCallCount).toBe(0);
123
- expect(state.warnCalls).toHaveLength(0);
124
- });
125
-
126
- test("does not invoke seedV2SkillEntries when config.memory.v2.enabled is off", async () => {
127
- state.flagOverrides = { "memory-v2-enabled": true };
128
- maybeSeedMemoryV2Skills(makeConfig(false));
129
- await flushMicrotasks();
130
- expect(state.seedCallCount).toBe(0);
131
- expect(state.warnCalls).toHaveLength(0);
132
- });
133
-
134
- test("does not invoke seedV2SkillEntries when both gates are off", async () => {
135
- state.flagOverrides = { "memory-v2-enabled": false };
163
+ test("does not invoke seedV2SkillEntries when memory.v2.enabled is false", async () => {
136
164
  maybeSeedMemoryV2Skills(makeConfig(false));
137
165
  await flushMicrotasks();
138
166
  expect(state.seedCallCount).toBe(0);
139
167
  expect(state.warnCalls).toHaveLength(0);
140
168
  });
141
169
 
142
- test("re-invocation seeds after flag flips on (deferred-init race recovery)", async () => {
143
- // Models the lifecycle-startup race: the synchronous seed call evaluates
144
- // the flag while the gateway IPC override fetch is still in flight, falls
145
- // through to the registry default (`false`), and skips. Once
146
- // `initFeatureFlagOverrides()` resolves, the chained `.then` re-invokes
147
- // the seed with the now-populated cache and the flag flips to `true`.
148
- state.flagOverrides = { "memory-v2-enabled": false };
149
- maybeSeedMemoryV2Skills(makeConfig(true));
150
- await flushMicrotasks();
151
- expect(state.seedCallCount).toBe(0);
152
-
153
- state.flagOverrides = { "memory-v2-enabled": true };
154
- maybeSeedMemoryV2Skills(makeConfig(true));
155
- await flushMicrotasks();
156
- expect(state.seedCallCount).toBe(1);
157
- });
158
-
159
170
  test("swallows seedV2SkillEntries rejections and logs a warning", async () => {
160
- state.flagOverrides = { "memory-v2-enabled": true };
161
171
  state.seedShouldReject = new Error("seed failed");
162
172
 
163
173
  // The gate must not throw — startup must not block on this.
@@ -172,3 +182,80 @@ describe("maybeSeedMemoryV2Skills (daemon startup gate)", () => {
172
182
  expect(msg).toBe("Failed to seed v2 skill entries");
173
183
  });
174
184
  });
185
+
186
+ describe("maybeRebuildMemoryV2Concepts (daemon startup gate)", () => {
187
+ beforeEach(resetState);
188
+
189
+ test("does nothing when memory.v2.enabled is false", async () => {
190
+ await maybeRebuildMemoryV2Concepts(makeConfig(false));
191
+
192
+ expect(state.ensureCollectionCallCount).toBe(0);
193
+ expect(state.enqueueCalls).toEqual([]);
194
+ });
195
+
196
+ test("enqueues memory_v2_reembed when the collection was migrated", async () => {
197
+ state.ensureCollectionResult = { migrated: true };
198
+
199
+ await maybeRebuildMemoryV2Concepts(makeConfig(true));
200
+
201
+ expect(state.ensureCollectionCallCount).toBe(1);
202
+ expect(state.enqueueCalls).toEqual([
203
+ { type: "memory_v2_reembed", payload: {} },
204
+ ]);
205
+ // Migrated path skips the count probe — drift detection is the trigger.
206
+ // (The mock's countConceptPagePoints would silently return 0 either way,
207
+ // but keeping the path conditional keeps the lifecycle hook predictable.)
208
+ });
209
+
210
+ test("enqueues reembed when the collection is empty but pages exist on disk (crash-mid-rebuild recovery)", async () => {
211
+ state.ensureCollectionResult = { migrated: false };
212
+ state.countResult = 0;
213
+ state.listPagesResult = ["people/alice", "topics/zsh"];
214
+
215
+ await maybeRebuildMemoryV2Concepts(makeConfig(true));
216
+
217
+ expect(state.enqueueCalls).toEqual([
218
+ { type: "memory_v2_reembed", payload: {} },
219
+ ]);
220
+ });
221
+
222
+ test("does not enqueue when the collection is healthy and populated", async () => {
223
+ state.ensureCollectionResult = { migrated: false };
224
+ state.countResult = 1185;
225
+ state.listPagesResult = ["people/alice"];
226
+
227
+ await maybeRebuildMemoryV2Concepts(makeConfig(true));
228
+
229
+ expect(state.enqueueCalls).toEqual([]);
230
+ });
231
+
232
+ test("does not enqueue when the collection is empty AND no pages exist on disk (fresh workspace)", async () => {
233
+ state.ensureCollectionResult = { migrated: false };
234
+ state.countResult = 0;
235
+ state.listPagesResult = [];
236
+
237
+ await maybeRebuildMemoryV2Concepts(makeConfig(true));
238
+
239
+ expect(state.enqueueCalls).toEqual([]);
240
+ });
241
+
242
+ test("swallows ensureConceptPageCollection failures and logs a warning", async () => {
243
+ state.ensureCollectionThrows = new Error("Qdrant unreachable");
244
+
245
+ // Must not throw — startup never blocks on this gate.
246
+ let thrown: unknown = null;
247
+ try {
248
+ await maybeRebuildMemoryV2Concepts(makeConfig(true));
249
+ } catch (err) {
250
+ thrown = err;
251
+ }
252
+ expect(thrown).toBeNull();
253
+
254
+ expect(state.enqueueCalls).toEqual([]);
255
+ expect(state.warnCalls.length).toBeGreaterThan(0);
256
+ const lastWarn = state.warnCalls[state.warnCalls.length - 1];
257
+ expect((lastWarn.obj as { err: Error }).err.message).toBe(
258
+ "Qdrant unreachable",
259
+ );
260
+ });
261
+ });
@@ -95,6 +95,97 @@ describe("notification decision fallback copy", () => {
95
95
  );
96
96
  });
97
97
 
98
+ test("enforces guardian-facing popup copy for heartbeat alerts", async () => {
99
+ configuredProvider = { sendMessage: async () => ({}) };
100
+ extractedToolUse = {
101
+ input: {
102
+ shouldNotify: true,
103
+ selectedChannels: ["vellum"],
104
+ reasoningSummary: "Heartbeat found a useful follow-up.",
105
+ renderedCopy: {
106
+ vellum: {
107
+ title: "Heartbeat Follow-up",
108
+ body: "The daily tracker is ready; consider reminding the guardian to review it before the next check-in.",
109
+ },
110
+ },
111
+ dedupeKey: "heartbeat:test",
112
+ confidence: 0.9,
113
+ },
114
+ };
115
+
116
+ const decision = await evaluateSignal(
117
+ makeSignal({
118
+ sourceEventName: "heartbeat.alert",
119
+ sourceChannel: "watcher",
120
+ contextPayload: {
121
+ summary:
122
+ "The daily tracker is ready; consider reminding the guardian to review it before the next check-in.",
123
+ conversationTitle: "Running Habit Tracking",
124
+ },
125
+ attentionHints: {
126
+ requiresAction: true,
127
+ urgency: "medium",
128
+ isAsyncBackground: true,
129
+ visibleInSourceNow: false,
130
+ },
131
+ }),
132
+ ["vellum"] as NotificationChannel[],
133
+ );
134
+
135
+ expect(decision.fallbackUsed).toBe(false);
136
+ expect(decision.renderedCopy.vellum?.title).toBe("Heartbeat Alert");
137
+ expect(decision.renderedCopy.vellum?.body).toBe(
138
+ "I found something worth your attention in a heartbeat check. Open the conversation for details.",
139
+ );
140
+ expect(decision.renderedCopy.vellum?.body).not.toContain(
141
+ "reminding the guardian",
142
+ );
143
+ });
144
+
145
+ test("keeps direct guardian-facing heartbeat copy", async () => {
146
+ configuredProvider = { sendMessage: async () => ({}) };
147
+ extractedToolUse = {
148
+ input: {
149
+ shouldNotify: true,
150
+ selectedChannels: ["vellum"],
151
+ reasoningSummary: "Heartbeat found a useful follow-up.",
152
+ renderedCopy: {
153
+ vellum: {
154
+ title: "Tracker Ready",
155
+ body: "Your daily tracker is ready. Review it before the next check-in.",
156
+ },
157
+ },
158
+ dedupeKey: "heartbeat:direct-copy-test",
159
+ confidence: 0.9,
160
+ },
161
+ };
162
+
163
+ const decision = await evaluateSignal(
164
+ makeSignal({
165
+ sourceEventName: "heartbeat.alert",
166
+ sourceChannel: "watcher",
167
+ contextPayload: {
168
+ summary:
169
+ "The daily tracker is ready; consider reminding the guardian to review it before the next check-in.",
170
+ conversationTitle: "Running Habit Tracking",
171
+ },
172
+ attentionHints: {
173
+ requiresAction: true,
174
+ urgency: "medium",
175
+ isAsyncBackground: true,
176
+ visibleInSourceNow: false,
177
+ },
178
+ }),
179
+ ["vellum"] as NotificationChannel[],
180
+ );
181
+
182
+ expect(decision.fallbackUsed).toBe(false);
183
+ expect(decision.renderedCopy.vellum?.title).toBe("Tracker Ready");
184
+ expect(decision.renderedCopy.vellum?.body).toBe(
185
+ "Your daily tracker is ready. Review it before the next check-in.",
186
+ );
187
+ });
188
+
98
189
  test("enforces free-text answer instructions for guardian.question when requestCode exists", async () => {
99
190
  const signal = makeSignal({
100
191
  contextPayload: {
@@ -373,6 +373,28 @@ describe("notification decision strategy", () => {
373
373
  "A guardian question needs your attention",
374
374
  );
375
375
  });
376
+
377
+ test("heartbeat.alert fallback avoids intermediary-instruction popup copy", () => {
378
+ const signal = makeSignal({
379
+ sourceEventName: "heartbeat.alert",
380
+ contextPayload: {
381
+ summary:
382
+ "The daily tracker is ready; consider reminding the guardian to review it before the next check-in.",
383
+ conversationTitle: "Running Habit Tracking",
384
+ },
385
+ });
386
+
387
+ const copy = composeFallbackCopy(signal, channels);
388
+ expect(copy.vellum).toBeDefined();
389
+ expect(copy.vellum!.title).toBe("Heartbeat Alert");
390
+ expect(copy.vellum!.body).toBe(
391
+ "I found something worth your attention in a heartbeat check. Open the conversation for details.",
392
+ );
393
+ expect(copy.vellum!.conversationSeedMessage).toContain(
394
+ "consider reminding the guardian",
395
+ );
396
+ expect(copy.telegram!.deliveryText).toBe(copy.vellum!.body);
397
+ });
376
398
  });
377
399
 
378
400
  // -- NotificationChannel type correctness ----------------------------------
@@ -62,6 +62,13 @@ let mockUpsertAppImpl:
62
62
  let mockOrchestrateOAuthConnect: (
63
63
  opts: Record<string, unknown>,
64
64
  ) => Promise<Record<string, unknown>>;
65
+ let mockCliIpcCall: (
66
+ operationId: string,
67
+ params?: Record<string, unknown>,
68
+ ) => Promise<Record<string, unknown>> = async () => ({
69
+ ok: false,
70
+ error: "Could not connect to assistant daemon (test default)",
71
+ });
65
72
  let mockGetAppByProviderAndClientId: (
66
73
  provider: string,
67
74
  clientId: string,
@@ -335,6 +342,15 @@ mock.module("../oauth/connect-orchestrator.js", () => ({
335
342
  mockOrchestrateOAuthConnect(opts),
336
343
  }));
337
344
 
345
+ // ---------------------------------------------------------------------------
346
+ // Mock cli-client (IPC) — used by `oauth connect` for daemon-orchestrated flow
347
+ // ---------------------------------------------------------------------------
348
+
349
+ mock.module("../ipc/cli-client.js", () => ({
350
+ cliIpcCall: (operationId: string, params?: Record<string, unknown>) =>
351
+ mockCliIpcCall(operationId, params),
352
+ }));
353
+
338
354
  mock.module("../oauth/seed-providers.js", () => ({
339
355
  SEEDED_PROVIDER_KEYS: new Set([
340
356
  "google",
@@ -1220,6 +1236,111 @@ describe("assistant oauth connect managed mode — platform 401/403 errors", ()
1220
1236
  });
1221
1237
  });
1222
1238
 
1239
+ // ---------------------------------------------------------------------------
1240
+ // `assistant oauth connect <provider>` BYO mode — daemon-unreachable behavior.
1241
+ //
1242
+ // We deleted the in-process `orchestrateOAuthConnect` fallback (the same
1243
+ // pattern as the MCP CLI consolidation in #29484). When the daemon is
1244
+ // unreachable, the CLI must surface a clear error and exit 1 — never
1245
+ // silently fall back to in-process flow.
1246
+ // ---------------------------------------------------------------------------
1247
+
1248
+ describe("assistant oauth connect <provider> — daemon unreachable (BYO mode)", () => {
1249
+ beforeEach(() => {
1250
+ // BYO provider with a registered app and no managed-mode wiring.
1251
+ mockGetProvider = () => ({
1252
+ provider: "github",
1253
+ authorizeUrl: "https://github.com/login/oauth/authorize",
1254
+ tokenExchangeUrl: "https://github.com/login/oauth/access_token",
1255
+ defaultScopes: "[]",
1256
+ availableScopes: null,
1257
+ authorizeParams: null,
1258
+ managedServiceConfigKey: null,
1259
+ requiresClientSecret: false,
1260
+ createdAt: Date.now(),
1261
+ updatedAt: Date.now(),
1262
+ });
1263
+ mockGetMostRecentAppByProvider = () => ({
1264
+ provider: "github",
1265
+ clientId: "test-client-id",
1266
+ clientSecretCredentialPath: "oauth_app/github/test/client_secret",
1267
+ });
1268
+ mockGetSecureKey = () => "test-secret";
1269
+ mockGetConfig = () => ({ services: {} });
1270
+ });
1271
+
1272
+ test("daemon connect-refused → exit 1 with 'Is the assistant running?'", async () => {
1273
+ mockCliIpcCall = async () => ({
1274
+ ok: false,
1275
+ error: "Could not connect to assistant daemon: ECONNREFUSED",
1276
+ });
1277
+
1278
+ const { exitCode, stdout } = await runCli([
1279
+ "connect",
1280
+ "github",
1281
+ "--no-browser",
1282
+ "--json",
1283
+ ]);
1284
+
1285
+ expect(exitCode).toBe(1);
1286
+ const parsed = JSON.parse(stdout);
1287
+ expect(parsed.ok).toBe(false);
1288
+ expect(parsed.error).toContain("Could not reach the assistant");
1289
+ expect(parsed.error).toContain("Is the assistant running?");
1290
+ });
1291
+
1292
+ test("daemon route missing (Unknown method) → exit 1, never falls through to in-process", async () => {
1293
+ let orchestratorCalls = 0;
1294
+ mockOrchestrateOAuthConnect = async () => {
1295
+ orchestratorCalls++;
1296
+ return { success: true, deferred: false, grantedScopes: [] };
1297
+ };
1298
+ mockCliIpcCall = async () => ({
1299
+ ok: false,
1300
+ error: "Unknown method: internal_oauth_connect_start",
1301
+ });
1302
+
1303
+ const { exitCode } = await runCli([
1304
+ "connect",
1305
+ "github",
1306
+ "--no-browser",
1307
+ "--json",
1308
+ ]);
1309
+
1310
+ expect(exitCode).toBe(1);
1311
+ // Critical regression guard: the in-process `orchestrateOAuthConnect`
1312
+ // must NOT be invoked from the CLI. The daemon-orchestrated path is
1313
+ // the sole code path; this is the same invariant #29484 established
1314
+ // for the MCP CLI.
1315
+ expect(orchestratorCalls).toBe(0);
1316
+ });
1317
+
1318
+ test("daemon HTTP error (statusCode set) → surfaces error verbatim, no fallback", async () => {
1319
+ let orchestratorCalls = 0;
1320
+ mockOrchestrateOAuthConnect = async () => {
1321
+ orchestratorCalls++;
1322
+ return { success: true, deferred: false, grantedScopes: [] };
1323
+ };
1324
+ mockCliIpcCall = async () => ({
1325
+ ok: false,
1326
+ statusCode: 400,
1327
+ error: "service must be registered before connecting",
1328
+ });
1329
+
1330
+ const { exitCode, stdout } = await runCli([
1331
+ "connect",
1332
+ "github",
1333
+ "--no-browser",
1334
+ "--json",
1335
+ ]);
1336
+
1337
+ expect(exitCode).toBe(1);
1338
+ const parsed = JSON.parse(stdout);
1339
+ expect(parsed.error).toContain("service must be registered");
1340
+ expect(orchestratorCalls).toBe(0);
1341
+ });
1342
+ });
1343
+
1223
1344
  // ---------------------------------------------------------------------------
1224
1345
  // requirePlatformClient — improved error messages
1225
1346
  // ---------------------------------------------------------------------------