@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.
- package/ARCHITECTURE.md +29 -28
- package/Dockerfile +1 -0
- package/__tests__/permissions/gateway-threshold-reader.test.ts +236 -9
- package/bun.lock +3 -0
- package/knip.json +1 -0
- package/node_modules/@vellumai/ipc-server-utils/bun.lock +24 -0
- package/node_modules/@vellumai/ipc-server-utils/package.json +18 -0
- package/node_modules/@vellumai/ipc-server-utils/src/index.ts +6 -0
- package/node_modules/@vellumai/ipc-server-utils/src/socket-watchdog.test.ts +430 -0
- package/node_modules/@vellumai/ipc-server-utils/src/socket-watchdog.ts +221 -0
- package/node_modules/@vellumai/ipc-server-utils/tsconfig.json +20 -0
- package/openapi.yaml +22 -4
- package/package.json +3 -1
- package/src/__tests__/annotate-risk-options.test.ts +291 -0
- package/src/__tests__/approval-cascade.test.ts +8 -16
- package/src/__tests__/approval-routes-http.test.ts +6 -0
- package/src/__tests__/auto-analysis-end-to-end.test.ts +12 -25
- package/src/__tests__/call-constants.test.ts +10 -1
- package/src/__tests__/call-controller.test.ts +127 -0
- package/src/__tests__/cli-memory-v2-reembed-skills.test.ts +58 -28
- package/src/__tests__/config-loader-platform-defaults.test.ts +284 -1
- package/src/__tests__/context-search-memory-source.test.ts +3 -26
- package/src/__tests__/context-search-pkb-source.test.ts +12 -6
- package/src/__tests__/conversation-abort-tool-results.test.ts +1 -6
- package/src/__tests__/conversation-agent-loop-inference-profile.test.ts +1 -1
- package/src/__tests__/conversation-agent-loop-overflow.test.ts +1 -1
- package/src/__tests__/conversation-agent-loop.test.ts +3 -3
- package/src/__tests__/conversation-confirmation-signals.test.ts +5 -13
- package/src/__tests__/conversation-init.benchmark.test.ts +1 -1
- package/src/__tests__/conversation-process-callsite.test.ts +1 -6
- package/src/__tests__/conversation-provider-retry-repair.test.ts +1 -6
- package/src/__tests__/conversation-runtime-assembly.test.ts +15 -6
- package/src/__tests__/conversation-slash-unknown.test.ts +1 -6
- package/src/__tests__/conversation-surfaces-action-delivery.test.ts +170 -9
- package/src/__tests__/conversation-surfaces-data-persist.test.ts +73 -1
- package/src/__tests__/conversation-tool-setup-app-refresh.test.ts +59 -0
- package/src/__tests__/conversation-workspace-injection.test.ts +1 -7
- package/src/__tests__/conversation-workspace-tool-tracking.test.ts +1 -7
- package/src/__tests__/filing-service.test.ts +2 -19
- package/src/__tests__/handlers-skills-memory-v2-reseed.test.ts +10 -26
- package/src/__tests__/injector-chain.test.ts +24 -16
- package/src/__tests__/injector-pkb-v2-silenced.test.ts +10 -7
- package/src/__tests__/lifecycle-memory-v2-seed.test.ts +154 -67
- package/src/__tests__/notification-decision-fallback.test.ts +91 -0
- package/src/__tests__/notification-decision-strategy.test.ts +22 -0
- package/src/__tests__/oauth-cli.test.ts +121 -0
- package/src/__tests__/relay-server.test.ts +46 -2
- package/src/__tests__/secret-prompt-log-hygiene.test.ts +7 -5
- package/src/__tests__/secret-prompter-channel-fallback.test.ts +7 -5
- package/src/__tests__/secret-response-routing.test.ts +7 -5
- package/src/__tests__/server-history-render.test.ts +82 -0
- package/src/__tests__/skill-include-graph.test.ts +31 -0
- package/src/__tests__/skill-load-tool.test.ts +44 -16
- package/src/__tests__/skills.test.ts +39 -0
- package/src/__tests__/tool-execution-pipeline.benchmark.test.ts +0 -42
- package/src/__tests__/tool-executor.test.ts +155 -0
- package/src/__tests__/voice-session-bridge.test.ts +3 -0
- package/src/__tests__/workspace-migration-069-seed-onboarding-threads.test.ts +120 -0
- package/src/__tests__/workspace-migration-071-remove-safe-storage-release-note.test.ts +206 -0
- package/src/__tests__/workspace-migration-safe-storage-limits-release.test.ts +15 -27
- package/src/agent/loop.ts +11 -0
- package/src/approvals/guardian-decision-primitive.ts +0 -13
- package/src/approvals/guardian-request-resolvers.ts +4 -32
- package/src/calls/call-constants.ts +5 -8
- package/src/calls/call-controller.ts +130 -67
- package/src/calls/relay-server.ts +7 -1
- package/src/calls/voice-session-bridge.ts +1 -1
- package/src/cli/commands/memory-v2.ts +7 -7
- package/src/cli/commands/oauth/__tests__/connect.test.ts +0 -254
- package/src/cli/commands/oauth/connect.ts +10 -52
- package/src/config/bundled-skills/app-builder/SKILL.md +1 -3
- package/src/config/feature-flag-registry.json +1 -17
- package/src/config/loader.ts +72 -19
- package/src/config/schemas/memory-v2.ts +1 -1
- package/src/daemon/__tests__/conversation-lifecycle-auto-analyze.test.ts +32 -0
- package/src/daemon/conversation-agent-loop-handlers.ts +32 -0
- package/src/daemon/conversation-agent-loop.ts +13 -10
- package/src/daemon/conversation-lifecycle.ts +22 -8
- package/src/daemon/conversation-surfaces.ts +16 -14
- package/src/daemon/conversation-tool-setup.ts +9 -5
- package/src/daemon/conversation.ts +1 -1
- package/src/daemon/handlers/shared.ts +26 -0
- package/src/daemon/host-bash-proxy.ts +1 -1
- package/src/daemon/host-browser-proxy.ts +1 -1
- package/src/daemon/host-cu-proxy.ts +1 -1
- package/src/daemon/host-file-proxy.ts +1 -1
- package/src/daemon/host-transfer-proxy.ts +2 -2
- package/src/daemon/lifecycle.ts +88 -73
- package/src/daemon/memory-v2-startup.ts +55 -14
- package/src/daemon/message-types/messages.ts +19 -1
- package/src/documents/document-store.ts +35 -1
- package/src/filing/filing-service.ts +2 -3
- package/src/heartbeat/heartbeat-service.ts +1 -1
- package/src/ipc/assistant-server.ts +93 -36
- package/src/ipc/skill-server.ts +99 -42
- package/src/memory/__tests__/jobs-worker-v2-schedule.test.ts +10 -57
- package/src/memory/context-search/sources/memory-v2.ts +1 -17
- package/src/memory/context-search/sources/memory.ts +2 -2
- package/src/memory/context-search/sources/pkb.ts +2 -3
- package/src/memory/graph/__tests__/conversation-graph-memory-v2-routing.test.ts +104 -61
- package/src/memory/graph/__tests__/handle-remember-v2.test.ts +11 -26
- package/src/memory/graph/conversation-graph-memory.ts +32 -9
- package/src/memory/graph/graph-search.test.ts +6 -5
- package/src/memory/graph/graph-search.ts +3 -4
- package/src/memory/graph/retriever.test.ts +12 -7
- package/src/memory/graph/retriever.ts +4 -5
- package/src/memory/graph/tool-handlers.ts +3 -4
- package/src/memory/graph/tools.ts +4 -4
- package/src/memory/indexer.ts +1 -2
- package/src/memory/jobs/__tests__/embed-concept-page.test.ts +116 -0
- package/src/memory/jobs/embed-concept-page.ts +223 -87
- package/src/memory/jobs-worker.ts +8 -4
- package/src/memory/pkb/pkb-search.test.ts +6 -5
- package/src/memory/pkb/pkb-search.ts +4 -5
- package/src/memory/qdrant-client.ts +3 -0
- package/src/memory/search/semantic.ts +4 -5
- package/src/memory/v2/__tests__/activation.test.ts +35 -5
- package/src/memory/v2/__tests__/consolidation-job.test.ts +21 -32
- package/src/memory/v2/__tests__/injection.test.ts +140 -23
- package/src/memory/v2/__tests__/qdrant.test.ts +310 -9
- package/src/memory/v2/__tests__/sim.test.ts +118 -7
- package/src/memory/v2/__tests__/static-context.test.ts +1 -13
- package/src/memory/v2/__tests__/sweep-job.test.ts +19 -33
- package/src/memory/v2/consolidation-job.ts +7 -8
- package/src/memory/v2/injection.ts +32 -12
- package/src/memory/v2/page-store.ts +39 -0
- package/src/memory/v2/prompts/consolidation.ts +5 -0
- package/src/memory/v2/qdrant.ts +209 -48
- package/src/memory/v2/sim.ts +67 -26
- package/src/memory/v2/static-context.ts +4 -8
- package/src/memory/v2/sweep-job.ts +5 -6
- package/src/memory/v2/types.ts +7 -0
- package/src/notifications/copy-composer.ts +46 -12
- package/src/notifications/decision-engine.ts +46 -0
- package/src/permissions/gateway-threshold-reader.ts +116 -8
- package/src/permissions/prompter.ts +86 -96
- package/src/permissions/secret-prompter.ts +31 -31
- package/src/plugins/defaults/injectors.ts +1 -2
- package/src/proactive-artifact/job.test.ts +51 -4
- package/src/proactive-artifact/job.ts +16 -2
- package/src/proactive-artifact/message-copy.ts +18 -1
- package/src/prompts/templates/SOUL.md +13 -28
- package/src/runtime/auth/route-policy.ts +1 -0
- package/src/runtime/channel-approvals.ts +3 -2
- package/src/runtime/guardian-reply-router.ts +0 -10
- package/src/runtime/pending-interactions.ts +19 -15
- package/src/runtime/routes/__tests__/memory-v2-routes.test.ts +147 -0
- package/src/runtime/routes/approval-routes.ts +7 -3
- package/src/runtime/routes/consolidation-routes.ts +8 -9
- package/src/runtime/routes/conversation-query-routes.ts +44 -1
- package/src/runtime/routes/debug-bash-routes.ts +2 -0
- package/src/runtime/routes/filing-routes.ts +2 -3
- package/src/runtime/routes/inbound-stages/guardian-reply-intercept.ts +0 -3
- package/src/runtime/routes/memory-item-routes.test.ts +3 -9
- package/src/runtime/routes/memory-item-routes.ts +5 -6
- package/src/runtime/routes/memory-v2-routes.ts +103 -17
- package/src/skills/include-graph.ts +35 -13
- package/src/tools/document/document-tool.ts +20 -0
- package/src/tools/executor.ts +18 -2
- package/src/tools/memory/register.test.ts +7 -5
- package/src/tools/permission-checker.ts +15 -0
- package/src/tools/skills/load.ts +24 -20
- package/src/tools/tool-name-aliases.ts +19 -0
- package/src/tools/types.ts +19 -1
- package/src/workspace/migrations/067-release-notes-safe-storage-limits.ts +4 -62
- package/src/workspace/migrations/069-seed-onboarding-threads.ts +28 -0
- package/src/workspace/migrations/070-memory-v2-summary-schema-rebuild.ts +31 -0
- package/src/workspace/migrations/071-remove-safe-storage-release-note.ts +111 -0
- package/src/workspace/migrations/registry.ts +6 -0
|
@@ -27,10 +27,39 @@ mock.module("../../qdrant-client.js", () => ({
|
|
|
27
27
|
// records every call and lets each test program the next response.
|
|
28
28
|
type MockPoint = {
|
|
29
29
|
id: string;
|
|
30
|
-
vector: {
|
|
30
|
+
vector: {
|
|
31
|
+
dense: number[];
|
|
32
|
+
sparse: { indices: number[]; values: number[] };
|
|
33
|
+
summary_dense?: number[];
|
|
34
|
+
summary_sparse?: { indices: number[]; values: number[] };
|
|
35
|
+
};
|
|
31
36
|
payload: { slug: string; updated_at: number };
|
|
32
37
|
};
|
|
33
38
|
|
|
39
|
+
type MockCollectionInfo = {
|
|
40
|
+
config: {
|
|
41
|
+
params: {
|
|
42
|
+
vectors?: Record<string, { size: number }> | { size: number };
|
|
43
|
+
sparse_vectors?: Record<string, unknown>;
|
|
44
|
+
};
|
|
45
|
+
};
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
const FULL_SCHEMA_INFO: MockCollectionInfo = {
|
|
49
|
+
config: {
|
|
50
|
+
params: {
|
|
51
|
+
vectors: {
|
|
52
|
+
dense: { size: 384 },
|
|
53
|
+
summary_dense: { size: 384 },
|
|
54
|
+
},
|
|
55
|
+
sparse_vectors: {
|
|
56
|
+
sparse: {},
|
|
57
|
+
summary_sparse: {},
|
|
58
|
+
},
|
|
59
|
+
},
|
|
60
|
+
},
|
|
61
|
+
};
|
|
62
|
+
|
|
34
63
|
const state = {
|
|
35
64
|
collectionExistsBeforeCreate: false,
|
|
36
65
|
collectionExistsCalls: 0,
|
|
@@ -39,6 +68,10 @@ const state = {
|
|
|
39
68
|
createIndexCalls: [] as Array<{ field_name: string; field_schema: string }>,
|
|
40
69
|
upsertCalls: [] as Array<{ wait: boolean; points: MockPoint[] }>,
|
|
41
70
|
deleteCalls: [] as Array<{ wait: boolean; points: string[] }>,
|
|
71
|
+
// Tracks `client.deleteCollection(name)` calls (distinct from `delete()`,
|
|
72
|
+
// which targets points). The schema-drift recreate path drops the
|
|
73
|
+
// collection entirely and we want to assert it ran exactly once.
|
|
74
|
+
deleteCollectionCalls: [] as string[],
|
|
42
75
|
queryCalls: [] as Array<{
|
|
43
76
|
using: string;
|
|
44
77
|
query: unknown;
|
|
@@ -55,6 +88,17 @@ const state = {
|
|
|
55
88
|
}>,
|
|
56
89
|
},
|
|
57
90
|
createCollectionThrows: null as Error | null,
|
|
91
|
+
// Schema returned by `client.getCollection`. Tests that exercise the
|
|
92
|
+
// drift path point this at a partial schema; the default mirrors a fully
|
|
93
|
+
// migrated collection so the no-drift path is the silent default.
|
|
94
|
+
getCollectionInfo: FULL_SCHEMA_INFO as MockCollectionInfo,
|
|
95
|
+
getCollectionThrows: null as Error | null,
|
|
96
|
+
getCollectionCalls: 0,
|
|
97
|
+
// Point count returned by `client.count`. Used by `countConceptPagePoints`
|
|
98
|
+
// which the lifecycle hook reads for the empty-after-create recovery path.
|
|
99
|
+
countResult: 0,
|
|
100
|
+
countThrows: null as Error | null,
|
|
101
|
+
countCalls: 0,
|
|
58
102
|
// Throw queue for upsert: first call shifts and throws if non-null;
|
|
59
103
|
// subsequent calls succeed once the queue is exhausted.
|
|
60
104
|
upsertThrowQueue: [] as Array<Error | null>,
|
|
@@ -66,13 +110,29 @@ class MockQdrantClient {
|
|
|
66
110
|
state.collectionExistsCalls++;
|
|
67
111
|
return { exists: state.collectionExistsBeforeCreate };
|
|
68
112
|
}
|
|
113
|
+
async getCollection(_name: string) {
|
|
114
|
+
state.getCollectionCalls++;
|
|
115
|
+
if (state.getCollectionThrows) throw state.getCollectionThrows;
|
|
116
|
+
return state.getCollectionInfo;
|
|
117
|
+
}
|
|
69
118
|
async createCollection(_name: string, params: unknown) {
|
|
70
119
|
state.createCollectionCalls++;
|
|
71
120
|
state.createCollectionParams = params;
|
|
72
121
|
if (state.createCollectionThrows) throw state.createCollectionThrows;
|
|
73
122
|
state.collectionExistsBeforeCreate = true;
|
|
123
|
+
state.getCollectionInfo = FULL_SCHEMA_INFO;
|
|
124
|
+
return {};
|
|
125
|
+
}
|
|
126
|
+
async deleteCollection(name: string) {
|
|
127
|
+
state.deleteCollectionCalls.push(name);
|
|
128
|
+
state.collectionExistsBeforeCreate = false;
|
|
74
129
|
return {};
|
|
75
130
|
}
|
|
131
|
+
async count(_name: string, _opts: { exact: boolean }) {
|
|
132
|
+
state.countCalls++;
|
|
133
|
+
if (state.countThrows) throw state.countThrows;
|
|
134
|
+
return { count: state.countResult };
|
|
135
|
+
}
|
|
76
136
|
async createPayloadIndex(
|
|
77
137
|
_name: string,
|
|
78
138
|
params: { field_name: string; field_schema: string },
|
|
@@ -102,7 +162,14 @@ class MockQdrantClient {
|
|
|
102
162
|
},
|
|
103
163
|
) {
|
|
104
164
|
state.queryCalls.push(params);
|
|
105
|
-
|
|
165
|
+
// Both `dense` and `summary_dense` consume from the dense queue (and
|
|
166
|
+
// similarly for sparse). The four-channel hybrid query fires them in
|
|
167
|
+
// order: body-dense, body-sparse, summary-dense, summary-sparse — so
|
|
168
|
+
// queue order matches call order.
|
|
169
|
+
const queue =
|
|
170
|
+
state.queryResponses[
|
|
171
|
+
params.using.endsWith("sparse") ? "sparse" : "dense"
|
|
172
|
+
];
|
|
106
173
|
return queue.shift() ?? { points: [] };
|
|
107
174
|
}
|
|
108
175
|
}
|
|
@@ -116,6 +183,7 @@ const {
|
|
|
116
183
|
upsertConceptPageEmbedding,
|
|
117
184
|
deleteConceptPageEmbedding,
|
|
118
185
|
hybridQueryConceptPages,
|
|
186
|
+
countConceptPagePoints,
|
|
119
187
|
MEMORY_V2_COLLECTION,
|
|
120
188
|
_resetMemoryV2QdrantForTests,
|
|
121
189
|
} = await import("../qdrant.js");
|
|
@@ -128,10 +196,17 @@ function resetState(): void {
|
|
|
128
196
|
state.createIndexCalls.length = 0;
|
|
129
197
|
state.upsertCalls.length = 0;
|
|
130
198
|
state.deleteCalls.length = 0;
|
|
199
|
+
state.deleteCollectionCalls.length = 0;
|
|
131
200
|
state.queryCalls.length = 0;
|
|
132
201
|
state.queryResponses.dense.length = 0;
|
|
133
202
|
state.queryResponses.sparse.length = 0;
|
|
134
203
|
state.createCollectionThrows = null;
|
|
204
|
+
state.getCollectionInfo = FULL_SCHEMA_INFO;
|
|
205
|
+
state.getCollectionThrows = null;
|
|
206
|
+
state.getCollectionCalls = 0;
|
|
207
|
+
state.countResult = 0;
|
|
208
|
+
state.countThrows = null;
|
|
209
|
+
state.countCalls = 0;
|
|
135
210
|
state.upsertThrowQueue.length = 0;
|
|
136
211
|
_resetMemoryV2QdrantForTests();
|
|
137
212
|
}
|
|
@@ -140,7 +215,7 @@ describe("memory v2 qdrant — collection lifecycle", () => {
|
|
|
140
215
|
beforeEach(resetState);
|
|
141
216
|
afterEach(resetState);
|
|
142
217
|
|
|
143
|
-
test("creates the collection with named dense + sparse vectors", async () => {
|
|
218
|
+
test("creates the collection with named dense + sparse vectors (body and summary)", async () => {
|
|
144
219
|
state.collectionExistsBeforeCreate = false;
|
|
145
220
|
|
|
146
221
|
await ensureConceptPageCollection();
|
|
@@ -149,8 +224,12 @@ describe("memory v2 qdrant — collection lifecycle", () => {
|
|
|
149
224
|
const params = state.createCollectionParams as {
|
|
150
225
|
vectors: {
|
|
151
226
|
dense: { size: number; distance: string; on_disk: boolean };
|
|
227
|
+
summary_dense: { size: number; distance: string; on_disk: boolean };
|
|
228
|
+
};
|
|
229
|
+
sparse_vectors: {
|
|
230
|
+
sparse: Record<string, unknown>;
|
|
231
|
+
summary_sparse: Record<string, unknown>;
|
|
152
232
|
};
|
|
153
|
-
sparse_vectors: { sparse: Record<string, unknown> };
|
|
154
233
|
hnsw_config: { on_disk: boolean; m: number; ef_construct: number };
|
|
155
234
|
on_disk_payload: boolean;
|
|
156
235
|
};
|
|
@@ -159,7 +238,14 @@ describe("memory v2 qdrant — collection lifecycle", () => {
|
|
|
159
238
|
distance: "Cosine",
|
|
160
239
|
on_disk: true,
|
|
161
240
|
});
|
|
241
|
+
// Summary side mirrors body so the activation pipeline can fuse symmetrically.
|
|
242
|
+
expect(params.vectors.summary_dense).toEqual({
|
|
243
|
+
size: 384,
|
|
244
|
+
distance: "Cosine",
|
|
245
|
+
on_disk: true,
|
|
246
|
+
});
|
|
162
247
|
expect(params.sparse_vectors.sparse).toEqual({});
|
|
248
|
+
expect(params.sparse_vectors.summary_sparse).toEqual({});
|
|
163
249
|
expect(params.hnsw_config).toEqual({
|
|
164
250
|
on_disk: true,
|
|
165
251
|
m: 16,
|
|
@@ -219,6 +305,115 @@ describe("memory v2 qdrant — collection lifecycle", () => {
|
|
|
219
305
|
// expected to have created it (it ran the same code).
|
|
220
306
|
expect(state.createIndexCalls).toEqual([]);
|
|
221
307
|
});
|
|
308
|
+
|
|
309
|
+
test("detects missing summary_dense / summary_sparse on an existing collection and recreates", async () => {
|
|
310
|
+
// Pre-#29823 schema: only body channels, no summary_*.
|
|
311
|
+
state.collectionExistsBeforeCreate = true;
|
|
312
|
+
state.getCollectionInfo = {
|
|
313
|
+
config: {
|
|
314
|
+
params: {
|
|
315
|
+
vectors: { dense: { size: 384 } },
|
|
316
|
+
sparse_vectors: { sparse: {} },
|
|
317
|
+
},
|
|
318
|
+
},
|
|
319
|
+
};
|
|
320
|
+
|
|
321
|
+
const result = await ensureConceptPageCollection();
|
|
322
|
+
|
|
323
|
+
// Drift path probed once, dropped the collection once, and recreated
|
|
324
|
+
// with the full four-vector schema (the create-success branch resets
|
|
325
|
+
// `getCollectionInfo` to FULL_SCHEMA_INFO so a follow-up probe agrees).
|
|
326
|
+
expect(state.getCollectionCalls).toBe(1);
|
|
327
|
+
expect(state.deleteCollectionCalls).toEqual([MEMORY_V2_COLLECTION]);
|
|
328
|
+
expect(state.createCollectionCalls).toBe(1);
|
|
329
|
+
expect(result).toEqual({ migrated: true });
|
|
330
|
+
|
|
331
|
+
// Recreated schema carries summary_dense + summary_sparse.
|
|
332
|
+
const params = state.createCollectionParams as {
|
|
333
|
+
vectors: Record<string, unknown>;
|
|
334
|
+
sparse_vectors: Record<string, unknown>;
|
|
335
|
+
};
|
|
336
|
+
expect(params.vectors.summary_dense).toBeDefined();
|
|
337
|
+
expect(params.sparse_vectors.summary_sparse).toBeDefined();
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
test("leaves a fully migrated collection untouched", async () => {
|
|
341
|
+
// Default `getCollectionInfo` is FULL_SCHEMA_INFO — already migrated.
|
|
342
|
+
state.collectionExistsBeforeCreate = true;
|
|
343
|
+
|
|
344
|
+
const result = await ensureConceptPageCollection();
|
|
345
|
+
|
|
346
|
+
expect(state.getCollectionCalls).toBe(1);
|
|
347
|
+
expect(state.deleteCollectionCalls).toEqual([]);
|
|
348
|
+
expect(state.createCollectionCalls).toBe(0);
|
|
349
|
+
expect(result).toEqual({ migrated: false });
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
test("getCollection failure is treated as compatible (no destructive recreate)", async () => {
|
|
353
|
+
state.collectionExistsBeforeCreate = true;
|
|
354
|
+
state.getCollectionThrows = new Error("transient REST error");
|
|
355
|
+
|
|
356
|
+
const result = await ensureConceptPageCollection();
|
|
357
|
+
|
|
358
|
+
expect(state.getCollectionCalls).toBe(1);
|
|
359
|
+
expect(state.deleteCollectionCalls).toEqual([]);
|
|
360
|
+
expect(state.createCollectionCalls).toBe(0);
|
|
361
|
+
expect(result).toEqual({ migrated: false });
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
test("concurrent ensure during a schema rebuild only deletes/creates once", async () => {
|
|
365
|
+
state.collectionExistsBeforeCreate = true;
|
|
366
|
+
state.getCollectionInfo = {
|
|
367
|
+
config: {
|
|
368
|
+
params: {
|
|
369
|
+
vectors: { dense: { size: 384 } },
|
|
370
|
+
sparse_vectors: { sparse: {} },
|
|
371
|
+
},
|
|
372
|
+
},
|
|
373
|
+
};
|
|
374
|
+
|
|
375
|
+
const results = await Promise.all([
|
|
376
|
+
ensureConceptPageCollection(),
|
|
377
|
+
ensureConceptPageCollection(),
|
|
378
|
+
ensureConceptPageCollection(),
|
|
379
|
+
]);
|
|
380
|
+
|
|
381
|
+
expect(state.deleteCollectionCalls).toEqual([MEMORY_V2_COLLECTION]);
|
|
382
|
+
expect(state.createCollectionCalls).toBe(1);
|
|
383
|
+
// All three concurrent callers see the same migrated signal so any one
|
|
384
|
+
// of them is safe to enqueue the reembed (the lifecycle hook is the
|
|
385
|
+
// single producer in practice).
|
|
386
|
+
expect(results).toEqual([
|
|
387
|
+
{ migrated: true },
|
|
388
|
+
{ migrated: true },
|
|
389
|
+
{ migrated: true },
|
|
390
|
+
]);
|
|
391
|
+
});
|
|
392
|
+
});
|
|
393
|
+
|
|
394
|
+
describe("memory v2 qdrant — point count", () => {
|
|
395
|
+
beforeEach(resetState);
|
|
396
|
+
afterEach(resetState);
|
|
397
|
+
|
|
398
|
+
test("returns the approximate Qdrant count for the v2 collection", async () => {
|
|
399
|
+
state.collectionExistsBeforeCreate = true;
|
|
400
|
+
state.countResult = 1185;
|
|
401
|
+
|
|
402
|
+
const count = await countConceptPagePoints();
|
|
403
|
+
|
|
404
|
+
expect(count).toBe(1185);
|
|
405
|
+
expect(state.countCalls).toBe(1);
|
|
406
|
+
});
|
|
407
|
+
|
|
408
|
+
test("returns 0 when the count call fails (treated as needs-reembed)", async () => {
|
|
409
|
+
state.collectionExistsBeforeCreate = true;
|
|
410
|
+
state.countThrows = new Error("Qdrant unreachable");
|
|
411
|
+
|
|
412
|
+
const count = await countConceptPagePoints();
|
|
413
|
+
|
|
414
|
+
expect(count).toBe(0);
|
|
415
|
+
expect(state.countCalls).toBe(1);
|
|
416
|
+
});
|
|
222
417
|
});
|
|
223
418
|
|
|
224
419
|
describe("memory v2 qdrant — upsert", () => {
|
|
@@ -249,12 +444,67 @@ describe("memory v2 qdrant — upsert", () => {
|
|
|
249
444
|
indices: [1, 2],
|
|
250
445
|
values: [0.5, 0.5],
|
|
251
446
|
});
|
|
447
|
+
// No summary vectors when caller didn't pass them — Qdrant accepts a
|
|
448
|
+
// partial named-vector subset, and pages without a frontmatter summary
|
|
449
|
+
// legitimately have nothing to embed on the summary side.
|
|
450
|
+
const vectorRecord = point.vector as unknown as Record<string, unknown>;
|
|
451
|
+
expect(vectorRecord.summary_dense).toBeUndefined();
|
|
452
|
+
expect(vectorRecord.summary_sparse).toBeUndefined();
|
|
252
453
|
// Point ID is a UUID-shaped string derived from the slug.
|
|
253
454
|
expect(point.id).toMatch(
|
|
254
455
|
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/,
|
|
255
456
|
);
|
|
256
457
|
});
|
|
257
458
|
|
|
459
|
+
test("upserts summary vectors alongside body vectors when both are provided", async () => {
|
|
460
|
+
state.collectionExistsBeforeCreate = true;
|
|
461
|
+
|
|
462
|
+
await upsertConceptPageEmbedding({
|
|
463
|
+
slug: "summarized-page",
|
|
464
|
+
dense: [0.1, 0.2, 0.3],
|
|
465
|
+
sparse: { indices: [1, 2], values: [0.5, 0.5] },
|
|
466
|
+
summary: {
|
|
467
|
+
dense: [0.4, 0.5, 0.6],
|
|
468
|
+
sparse: { indices: [3, 4], values: [0.7, 0.7] },
|
|
469
|
+
},
|
|
470
|
+
updatedAt: 1714000000000,
|
|
471
|
+
});
|
|
472
|
+
|
|
473
|
+
expect(state.upsertCalls).toHaveLength(1);
|
|
474
|
+
const [point] = state.upsertCalls[0].points;
|
|
475
|
+
const vectorRecord = point.vector as unknown as Record<string, unknown>;
|
|
476
|
+
expect(vectorRecord.dense).toEqual([0.1, 0.2, 0.3]);
|
|
477
|
+
expect(vectorRecord.sparse).toEqual({
|
|
478
|
+
indices: [1, 2],
|
|
479
|
+
values: [0.5, 0.5],
|
|
480
|
+
});
|
|
481
|
+
expect(vectorRecord.summary_dense).toEqual([0.4, 0.5, 0.6]);
|
|
482
|
+
expect(vectorRecord.summary_sparse).toEqual({
|
|
483
|
+
indices: [3, 4],
|
|
484
|
+
values: [0.7, 0.7],
|
|
485
|
+
});
|
|
486
|
+
});
|
|
487
|
+
|
|
488
|
+
test("omits summary vectors when the summary block is undefined", async () => {
|
|
489
|
+
// The grouped-shape signature enforces summary as a paired { dense, sparse }
|
|
490
|
+
// block; passing `undefined` (or omitting it) leaves the summary vectors off
|
|
491
|
+
// the point entirely so query-time fusion stays symmetric.
|
|
492
|
+
state.collectionExistsBeforeCreate = true;
|
|
493
|
+
|
|
494
|
+
await upsertConceptPageEmbedding({
|
|
495
|
+
slug: "no-summary",
|
|
496
|
+
dense: [0.1],
|
|
497
|
+
sparse: { indices: [1], values: [1] },
|
|
498
|
+
// summary intentionally omitted
|
|
499
|
+
updatedAt: 1,
|
|
500
|
+
});
|
|
501
|
+
|
|
502
|
+
const [point] = state.upsertCalls[0].points;
|
|
503
|
+
const vectorRecord = point.vector as unknown as Record<string, unknown>;
|
|
504
|
+
expect(vectorRecord.summary_dense).toBeUndefined();
|
|
505
|
+
expect(vectorRecord.summary_sparse).toBeUndefined();
|
|
506
|
+
});
|
|
507
|
+
|
|
258
508
|
test("two upserts for the same slug share the same point id (overwrites in place)", async () => {
|
|
259
509
|
state.collectionExistsBeforeCreate = true;
|
|
260
510
|
|
|
@@ -357,8 +607,9 @@ describe("memory v2 qdrant — hybrid query", () => {
|
|
|
357
607
|
beforeEach(resetState);
|
|
358
608
|
afterEach(resetState);
|
|
359
609
|
|
|
360
|
-
test("runs
|
|
610
|
+
test("runs all four channels (body dense/sparse + summary dense/sparse) and returns per-channel scores", async () => {
|
|
361
611
|
state.collectionExistsBeforeCreate = true;
|
|
612
|
+
// Body channel hits.
|
|
362
613
|
state.queryResponses.dense.push({
|
|
363
614
|
points: [
|
|
364
615
|
{ score: 0.91, payload: { slug: "alice-prefers-vs-code" } },
|
|
@@ -371,6 +622,14 @@ describe("memory v2 qdrant — hybrid query", () => {
|
|
|
371
622
|
{ score: 3, payload: { slug: "bob-uses-zsh" } },
|
|
372
623
|
],
|
|
373
624
|
});
|
|
625
|
+
// Summary channel hits — queue order is body-dense, body-sparse,
|
|
626
|
+
// summary-dense, summary-sparse, so push summaries after bodies.
|
|
627
|
+
state.queryResponses.dense.push({
|
|
628
|
+
points: [{ score: 0.81, payload: { slug: "alice-prefers-vs-code" } }],
|
|
629
|
+
});
|
|
630
|
+
state.queryResponses.sparse.push({
|
|
631
|
+
points: [{ score: 9, payload: { slug: "alice-prefers-vs-code" } }],
|
|
632
|
+
});
|
|
374
633
|
|
|
375
634
|
const results = await hybridQueryConceptPages(
|
|
376
635
|
[0.1, 0.2, 0.3],
|
|
@@ -378,14 +637,19 @@ describe("memory v2 qdrant — hybrid query", () => {
|
|
|
378
637
|
5,
|
|
379
638
|
);
|
|
380
639
|
|
|
381
|
-
//
|
|
382
|
-
expect(state.queryCalls).toHaveLength(
|
|
640
|
+
// All four queries fired with the same limit and distinct `using`.
|
|
641
|
+
expect(state.queryCalls).toHaveLength(4);
|
|
383
642
|
const usings = state.queryCalls.map((c) => c.using).sort();
|
|
384
|
-
expect(usings).toEqual([
|
|
643
|
+
expect(usings).toEqual([
|
|
644
|
+
"dense",
|
|
645
|
+
"sparse",
|
|
646
|
+
"summary_dense",
|
|
647
|
+
"summary_sparse",
|
|
648
|
+
]);
|
|
385
649
|
expect(state.queryCalls.every((c) => c.limit === 5)).toBe(true);
|
|
386
650
|
expect(state.queryCalls.every((c) => c.with_payload === true)).toBe(true);
|
|
387
651
|
|
|
388
|
-
//
|
|
652
|
+
// Alice has hits on all four channels; bob is body-only.
|
|
389
653
|
expect(results).toHaveLength(2);
|
|
390
654
|
const alice = results.find((r) => r.slug === "alice-prefers-vs-code");
|
|
391
655
|
const bob = results.find((r) => r.slug === "bob-uses-zsh");
|
|
@@ -393,6 +657,8 @@ describe("memory v2 qdrant — hybrid query", () => {
|
|
|
393
657
|
slug: "alice-prefers-vs-code",
|
|
394
658
|
denseScore: 0.91,
|
|
395
659
|
sparseScore: 12,
|
|
660
|
+
summaryDenseScore: 0.81,
|
|
661
|
+
summarySparseScore: 9,
|
|
396
662
|
});
|
|
397
663
|
expect(bob).toEqual({
|
|
398
664
|
slug: "bob-uses-zsh",
|
|
@@ -403,6 +669,8 @@ describe("memory v2 qdrant — hybrid query", () => {
|
|
|
403
669
|
|
|
404
670
|
test("dense-only hits leave sparseScore undefined (and vice versa)", async () => {
|
|
405
671
|
state.collectionExistsBeforeCreate = true;
|
|
672
|
+
// Body dense + sparse hits. Summary channels stay empty (no push) →
|
|
673
|
+
// they fall through to `{ points: [] }` and produce no summary scores.
|
|
406
674
|
state.queryResponses.dense.push({
|
|
407
675
|
points: [{ score: 0.7, payload: { slug: "dense-only" } }],
|
|
408
676
|
});
|
|
@@ -420,8 +688,41 @@ describe("memory v2 qdrant — hybrid query", () => {
|
|
|
420
688
|
const sparseOnly = results.find((r) => r.slug === "sparse-only");
|
|
421
689
|
expect(denseOnly).toEqual({ slug: "dense-only", denseScore: 0.7 });
|
|
422
690
|
expect(denseOnly?.sparseScore).toBeUndefined();
|
|
691
|
+
expect(denseOnly?.summaryDenseScore).toBeUndefined();
|
|
423
692
|
expect(sparseOnly).toEqual({ slug: "sparse-only", sparseScore: 2 });
|
|
424
693
|
expect(sparseOnly?.denseScore).toBeUndefined();
|
|
694
|
+
expect(sparseOnly?.summarySparseScore).toBeUndefined();
|
|
695
|
+
});
|
|
696
|
+
|
|
697
|
+
test("returns summary-channel scores when only the summary side hits", async () => {
|
|
698
|
+
// Page has no body hits but matches via the summary embedding —
|
|
699
|
+
// exercises the path where `simBatch` falls back to summary-only.
|
|
700
|
+
state.collectionExistsBeforeCreate = true;
|
|
701
|
+
// Body channels empty.
|
|
702
|
+
state.queryResponses.dense.push({ points: [] });
|
|
703
|
+
state.queryResponses.sparse.push({ points: [] });
|
|
704
|
+
// Summary channels hit.
|
|
705
|
+
state.queryResponses.dense.push({
|
|
706
|
+
points: [{ score: 0.6, payload: { slug: "summary-only" } }],
|
|
707
|
+
});
|
|
708
|
+
state.queryResponses.sparse.push({
|
|
709
|
+
points: [{ score: 4, payload: { slug: "summary-only" } }],
|
|
710
|
+
});
|
|
711
|
+
|
|
712
|
+
const results = await hybridQueryConceptPages(
|
|
713
|
+
[0.1],
|
|
714
|
+
{ indices: [1], values: [1] },
|
|
715
|
+
5,
|
|
716
|
+
);
|
|
717
|
+
|
|
718
|
+
const summaryOnly = results.find((r) => r.slug === "summary-only");
|
|
719
|
+
expect(summaryOnly).toEqual({
|
|
720
|
+
slug: "summary-only",
|
|
721
|
+
summaryDenseScore: 0.6,
|
|
722
|
+
summarySparseScore: 4,
|
|
723
|
+
});
|
|
724
|
+
expect(summaryOnly?.denseScore).toBeUndefined();
|
|
725
|
+
expect(summaryOnly?.sparseScore).toBeUndefined();
|
|
425
726
|
});
|
|
426
727
|
|
|
427
728
|
test("does not use Qdrant-side RRF fusion (separate per-channel queries)", async () => {
|
|
@@ -136,7 +136,11 @@ class MockQdrantClient {
|
|
|
136
136
|
limit: params.limit,
|
|
137
137
|
filter: params.filter,
|
|
138
138
|
});
|
|
139
|
-
|
|
139
|
+
// Both `dense` and `summary_dense` consume from the dense queue (and
|
|
140
|
+
// similarly for sparse). The four-channel hybrid query fires them in
|
|
141
|
+
// order: body-dense, body-sparse, summary-dense, summary-sparse — so
|
|
142
|
+
// the queue order matches the call order.
|
|
143
|
+
const channel = params.using.endsWith("sparse") ? "sparse" : "dense";
|
|
140
144
|
return state.queryResponses[channel].shift() ?? { points: [] };
|
|
141
145
|
}
|
|
142
146
|
}
|
|
@@ -185,10 +189,18 @@ function configWithWeights(
|
|
|
185
189
|
/**
|
|
186
190
|
* Stage a single Qdrant response that maps each (slug, denseScore?, sparseScore?)
|
|
187
191
|
* tuple onto the dense or sparse channel, mirroring how `hybridQueryConceptPages`
|
|
188
|
-
* merges per-channel hits.
|
|
192
|
+
* merges per-channel hits. Optional `summaryDenseScore` / `summarySparseScore`
|
|
193
|
+
* stage the summary-side channels — pages without those entries fall through
|
|
194
|
+
* to body-only scoring at fusion time.
|
|
189
195
|
*/
|
|
190
196
|
function stageHybridResponse(
|
|
191
|
-
hits: Array<{
|
|
197
|
+
hits: Array<{
|
|
198
|
+
slug: string;
|
|
199
|
+
denseScore?: number;
|
|
200
|
+
sparseScore?: number;
|
|
201
|
+
summaryDenseScore?: number;
|
|
202
|
+
summarySparseScore?: number;
|
|
203
|
+
}>,
|
|
192
204
|
): void {
|
|
193
205
|
state.queryResponses.dense.push({
|
|
194
206
|
points: hits
|
|
@@ -200,6 +212,20 @@ function stageHybridResponse(
|
|
|
200
212
|
.filter((h) => h.sparseScore !== undefined)
|
|
201
213
|
.map((h) => ({ score: h.sparseScore, payload: { slug: h.slug } })),
|
|
202
214
|
});
|
|
215
|
+
// The four-channel hybrid query also fires `summary_dense` and
|
|
216
|
+
// `summary_sparse` queries against the same collection. Tests that don't
|
|
217
|
+
// care about summary scores leave those channels empty so the fallback
|
|
218
|
+
// (body-only) path runs.
|
|
219
|
+
state.queryResponses.dense.push({
|
|
220
|
+
points: hits
|
|
221
|
+
.filter((h) => h.summaryDenseScore !== undefined)
|
|
222
|
+
.map((h) => ({ score: h.summaryDenseScore, payload: { slug: h.slug } })),
|
|
223
|
+
});
|
|
224
|
+
state.queryResponses.sparse.push({
|
|
225
|
+
points: hits
|
|
226
|
+
.filter((h) => h.summarySparseScore !== undefined)
|
|
227
|
+
.map((h) => ({ score: h.summarySparseScore, payload: { slug: h.slug } })),
|
|
228
|
+
});
|
|
203
229
|
}
|
|
204
230
|
|
|
205
231
|
beforeEach(resetState);
|
|
@@ -468,15 +494,16 @@ describe("simBatch", () => {
|
|
|
468
494
|
expect(out.get("loud-page")).toBe(1);
|
|
469
495
|
});
|
|
470
496
|
|
|
471
|
-
test("forwards the candidate slugs as a Qdrant slug-IN filter", async () => {
|
|
497
|
+
test("forwards the candidate slugs as a Qdrant slug-IN filter on every channel", async () => {
|
|
472
498
|
const config = configWithWeights(0.7, 0.3);
|
|
473
499
|
stageHybridResponse([]);
|
|
474
500
|
|
|
475
501
|
await simBatch("query", ["alice", "bob", "carol"], config);
|
|
476
502
|
|
|
477
|
-
//
|
|
478
|
-
// filter and the same per-channel limit
|
|
479
|
-
|
|
503
|
+
// All four channels (body dense + sparse, summary dense + sparse) ran
|
|
504
|
+
// with the same slug-restriction filter and the same per-channel limit
|
|
505
|
+
// equal to the candidate count.
|
|
506
|
+
expect(state.queryCalls).toHaveLength(4);
|
|
480
507
|
for (const call of state.queryCalls) {
|
|
481
508
|
expect(call.limit).toBe(3);
|
|
482
509
|
expect(call.filter).toEqual({
|
|
@@ -496,6 +523,90 @@ describe("simBatch", () => {
|
|
|
496
523
|
expect(state.sparseCalls).toEqual(["hello world"]);
|
|
497
524
|
});
|
|
498
525
|
|
|
526
|
+
test("takes max(body, summary) per slug — summary higher than body wins", async () => {
|
|
527
|
+
// Body channels return a modest score; summary channels return a much
|
|
528
|
+
// higher score. The max collapses to the summary score.
|
|
529
|
+
const config = configWithWeights(1.0, 0.0);
|
|
530
|
+
stageHybridResponse([
|
|
531
|
+
{
|
|
532
|
+
slug: "alice",
|
|
533
|
+
denseScore: 0.3,
|
|
534
|
+
summaryDenseScore: 0.7,
|
|
535
|
+
},
|
|
536
|
+
]);
|
|
537
|
+
|
|
538
|
+
const out = await simBatch("query", ["alice"], config);
|
|
539
|
+
|
|
540
|
+
expect(out.get("alice")).toBeCloseTo(0.7, 6);
|
|
541
|
+
});
|
|
542
|
+
|
|
543
|
+
test("takes max(body, summary) per slug — body higher than summary wins", async () => {
|
|
544
|
+
// Inverse case: body dominates, max stays at body.
|
|
545
|
+
const config = configWithWeights(1.0, 0.0);
|
|
546
|
+
stageHybridResponse([
|
|
547
|
+
{
|
|
548
|
+
slug: "alice",
|
|
549
|
+
denseScore: 0.9,
|
|
550
|
+
summaryDenseScore: 0.4,
|
|
551
|
+
},
|
|
552
|
+
]);
|
|
553
|
+
|
|
554
|
+
const out = await simBatch("query", ["alice"], config);
|
|
555
|
+
|
|
556
|
+
expect(out.get("alice")).toBeCloseTo(0.9, 6);
|
|
557
|
+
});
|
|
558
|
+
|
|
559
|
+
test("falls back to body-only when the page has no summary embedding", async () => {
|
|
560
|
+
// Pages predating the summary field have no summary_dense/sparse vectors.
|
|
561
|
+
// Their summary channels return no hits — the max collapses to body.
|
|
562
|
+
const config = configWithWeights(1.0, 0.0);
|
|
563
|
+
stageHybridResponse([
|
|
564
|
+
{
|
|
565
|
+
slug: "legacy-page",
|
|
566
|
+
denseScore: 0.6,
|
|
567
|
+
// summaryDenseScore / summarySparseScore omitted
|
|
568
|
+
},
|
|
569
|
+
]);
|
|
570
|
+
|
|
571
|
+
const out = await simBatch("query", ["legacy-page"], config);
|
|
572
|
+
|
|
573
|
+
expect(out.get("legacy-page")).toBeCloseTo(0.6, 6);
|
|
574
|
+
});
|
|
575
|
+
|
|
576
|
+
test("normalizes body and summary sparse channels independently", async () => {
|
|
577
|
+
// Summary sparse scores live on a different scale than body sparse —
|
|
578
|
+
// a small absolute summary-sparse value (1.5) on the only page that
|
|
579
|
+
// has summary signal still normalizes to 1.0 within the summary
|
|
580
|
+
// channel, so the summary-only fused score should win out.
|
|
581
|
+
const config = configWithWeights(0.0, 1.0);
|
|
582
|
+
stageHybridResponse([
|
|
583
|
+
{
|
|
584
|
+
slug: "alice",
|
|
585
|
+
denseScore: 0.0,
|
|
586
|
+
sparseScore: 100, // body sparse max in this batch
|
|
587
|
+
},
|
|
588
|
+
{
|
|
589
|
+
slug: "bob",
|
|
590
|
+
denseScore: 0.0,
|
|
591
|
+
sparseScore: 0.5, // body sparse normalized = 0.005
|
|
592
|
+
summaryDenseScore: 0.0,
|
|
593
|
+
summarySparseScore: 1.5, // summary sparse max in this batch
|
|
594
|
+
},
|
|
595
|
+
]);
|
|
596
|
+
|
|
597
|
+
const out = await simBatch("query", ["alice", "bob"], config);
|
|
598
|
+
|
|
599
|
+
// Alice has only body. Body sparse normalized to 1.0; sparse_weight=1.0 → 1.0.
|
|
600
|
+
expect(out.get("alice")).toBeCloseTo(1.0, 6);
|
|
601
|
+
// Bob's summary side normalizes its 1.5 (only sparse-bearing summary
|
|
602
|
+
// hit) — a single sparse-bearing hit is below the adaptive-spread
|
|
603
|
+
// floor, so the channel collapses to base weights and the lone
|
|
604
|
+
// sparseNormalized=1.0 hit yields a fused summary score of 1.0.
|
|
605
|
+
// Body side has only bob's tiny sparse=0.5 against the body batch max
|
|
606
|
+
// of 100 → ~0.005. The max picks the summary side.
|
|
607
|
+
expect(out.get("bob")).toBeCloseTo(1.0, 6);
|
|
608
|
+
});
|
|
609
|
+
|
|
499
610
|
test("returned scores are always in [0, 1] for arbitrary inputs", async () => {
|
|
500
611
|
const config = configWithWeights(0.7, 0.3);
|
|
501
612
|
stageHybridResponse([
|
|
@@ -1,8 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Tests for `readMemoryV2StaticContent` — the loader that powers the
|
|
3
|
-
* `memory-v2-static` user-message auto-injection.
|
|
4
|
-
* lived in the deprecated `system-prompt-memory-v2.test.ts`:
|
|
5
|
-
* - Returns null when the v2 flag is off.
|
|
3
|
+
* `memory-v2-static` user-message auto-injection.
|
|
6
4
|
* - Returns null when `config.memory.v2.enabled` is off.
|
|
7
5
|
* - Reads the four files in canonical order and joins them under headings.
|
|
8
6
|
* - Skips empty / missing files.
|
|
@@ -47,8 +45,6 @@ mock.module("../../../config/loader.js", () => ({
|
|
|
47
45
|
setNestedValue: () => {},
|
|
48
46
|
}));
|
|
49
47
|
|
|
50
|
-
const { _setOverridesForTesting } =
|
|
51
|
-
await import("../../../config/assistant-feature-flags.js");
|
|
52
48
|
const { readMemoryV2StaticContent, shouldLoadMemoryV2Static } =
|
|
53
49
|
await import("../static-context.js");
|
|
54
50
|
|
|
@@ -75,18 +71,10 @@ describe("readMemoryV2StaticContent", () => {
|
|
|
75
71
|
beforeEach(() => {
|
|
76
72
|
mkdirSync(TEST_DIR, { recursive: true });
|
|
77
73
|
configMemoryV2Enabled = true;
|
|
78
|
-
_setOverridesForTesting({ "memory-v2-enabled": true });
|
|
79
74
|
});
|
|
80
75
|
|
|
81
76
|
afterEach(() => {
|
|
82
77
|
cleanupMemoryDir();
|
|
83
|
-
_setOverridesForTesting({});
|
|
84
|
-
});
|
|
85
|
-
|
|
86
|
-
test("returns null when the feature flag is off", () => {
|
|
87
|
-
_setOverridesForTesting({ "memory-v2-enabled": false });
|
|
88
|
-
for (const file of MEMORY_FILES) writeMemoryFile(file, `Content ${file}`);
|
|
89
|
-
expect(readMemoryV2StaticContent()).toBeNull();
|
|
90
78
|
});
|
|
91
79
|
|
|
92
80
|
test("returns null when config.memory.v2.enabled is off", () => {
|