@vellumai/assistant 0.5.2 → 0.5.3
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 +109 -0
- package/docs/skills.md +100 -0
- package/package.json +1 -1
- package/src/__tests__/conversation-agent-loop-overflow.test.ts +7 -0
- package/src/__tests__/conversation-agent-loop.test.ts +7 -0
- package/src/__tests__/conversation-memory-dirty-tail.test.ts +150 -0
- package/src/__tests__/conversation-provider-retry-repair.test.ts +7 -0
- package/src/__tests__/conversation-wipe.test.ts +226 -0
- package/src/__tests__/db-memory-archive-migration.test.ts +372 -0
- package/src/__tests__/db-memory-brief-state-migration.test.ts +213 -0
- package/src/__tests__/db-memory-reducer-checkpoints.test.ts +273 -0
- package/src/__tests__/inline-command-runner.test.ts +311 -0
- package/src/__tests__/inline-skill-authoring-guard.test.ts +220 -0
- package/src/__tests__/inline-skill-load-permissions.test.ts +435 -0
- package/src/__tests__/list-messages-attachments.test.ts +96 -0
- package/src/__tests__/memory-brief-open-loops.test.ts +530 -0
- package/src/__tests__/memory-brief-time.test.ts +285 -0
- package/src/__tests__/memory-brief-wrapper.test.ts +311 -0
- package/src/__tests__/memory-chunk-archive.test.ts +400 -0
- package/src/__tests__/memory-chunk-dual-write.test.ts +453 -0
- package/src/__tests__/memory-episode-archive.test.ts +370 -0
- package/src/__tests__/memory-episode-dual-write.test.ts +626 -0
- package/src/__tests__/memory-observation-archive.test.ts +375 -0
- package/src/__tests__/memory-observation-dual-write.test.ts +318 -0
- package/src/__tests__/memory-recall-quality.test.ts +2 -2
- package/src/__tests__/memory-reducer-store.test.ts +728 -0
- package/src/__tests__/memory-reducer-types.test.ts +699 -0
- package/src/__tests__/memory-reducer.test.ts +698 -0
- package/src/__tests__/memory-regressions.test.ts +6 -4
- package/src/__tests__/memory-simplified-config.test.ts +281 -0
- package/src/__tests__/parse-identity-fields.test.ts +129 -0
- package/src/__tests__/skill-load-inline-command.test.ts +598 -0
- package/src/__tests__/skill-load-inline-includes.test.ts +644 -0
- package/src/__tests__/skills-inline-command-expansions.test.ts +301 -0
- package/src/__tests__/skills-transitive-hash.test.ts +333 -0
- package/src/__tests__/vellum-self-knowledge-inline-command.test.ts +320 -0
- package/src/__tests__/workspace-migration-backfill-installation-id.test.ts +4 -4
- package/src/config/bundled-skills/app-builder/SKILL.md +8 -8
- package/src/config/bundled-skills/skill-management/SKILL.md +1 -1
- package/src/config/bundled-skills/skill-management/TOOLS.json +2 -2
- package/src/config/feature-flag-registry.json +16 -0
- package/src/config/loader.ts +1 -0
- package/src/config/raw-config-utils.ts +28 -0
- package/src/config/schema.ts +12 -0
- package/src/config/schemas/memory-simplified.ts +101 -0
- package/src/config/schemas/memory.ts +4 -0
- package/src/config/skills.ts +50 -4
- package/src/daemon/conversation-agent-loop-handlers.ts +8 -3
- package/src/daemon/conversation-agent-loop.ts +71 -1
- package/src/daemon/conversation-lifecycle.ts +11 -1
- package/src/daemon/conversation-runtime-assembly.ts +2 -1
- package/src/daemon/conversation-surfaces.ts +31 -8
- package/src/daemon/conversation.ts +40 -23
- package/src/daemon/handlers/config-embeddings.ts +10 -2
- package/src/daemon/handlers/config-model.ts +0 -9
- package/src/daemon/handlers/identity.ts +12 -1
- package/src/daemon/lifecycle.ts +9 -1
- package/src/daemon/message-types/conversations.ts +0 -1
- package/src/daemon/server.ts +1 -1
- package/src/followups/followup-store.ts +47 -1
- package/src/memory/archive-store.ts +400 -0
- package/src/memory/brief-formatting.ts +33 -0
- package/src/memory/brief-open-loops.ts +266 -0
- package/src/memory/brief-time.ts +161 -0
- package/src/memory/brief.ts +75 -0
- package/src/memory/conversation-crud.ts +245 -101
- package/src/memory/db-init.ts +12 -0
- package/src/memory/indexer.ts +106 -15
- package/src/memory/job-handlers/embedding.test.ts +1 -0
- package/src/memory/job-handlers/embedding.ts +83 -0
- package/src/memory/job-utils.ts +1 -1
- package/src/memory/jobs-store.ts +6 -0
- package/src/memory/jobs-worker.ts +12 -0
- package/src/memory/migrations/185-memory-brief-state.ts +52 -0
- package/src/memory/migrations/186-memory-archive.ts +109 -0
- package/src/memory/migrations/187-memory-reducer-checkpoints.ts +19 -0
- package/src/memory/migrations/index.ts +3 -0
- package/src/memory/qdrant-client.ts +23 -4
- package/src/memory/reducer-store.ts +271 -0
- package/src/memory/reducer-types.ts +99 -0
- package/src/memory/reducer.ts +453 -0
- package/src/memory/schema/conversations.ts +3 -0
- package/src/memory/schema/index.ts +2 -0
- package/src/memory/schema/memory-archive.ts +121 -0
- package/src/memory/schema/memory-brief.ts +55 -0
- package/src/memory/search/semantic.ts +17 -4
- package/src/oauth/oauth-store.ts +3 -1
- package/src/permissions/checker.ts +89 -6
- package/src/permissions/defaults.ts +14 -0
- package/src/runtime/routes/conversation-management-routes.ts +6 -0
- package/src/runtime/routes/conversation-query-routes.ts +7 -0
- package/src/runtime/routes/conversation-routes.ts +52 -5
- package/src/runtime/routes/identity-routes.ts +2 -35
- package/src/runtime/routes/llm-context-normalization.ts +14 -1
- package/src/runtime/routes/memory-item-routes.ts +90 -5
- package/src/runtime/routes/secret-routes.ts +2 -0
- package/src/runtime/routes/surface-action-routes.ts +68 -1
- package/src/schedule/schedule-store.ts +21 -0
- package/src/skills/inline-command-expansions.ts +204 -0
- package/src/skills/inline-command-render.ts +127 -0
- package/src/skills/inline-command-runner.ts +242 -0
- package/src/skills/transitive-version-hash.ts +88 -0
- package/src/tasks/task-store.ts +43 -1
- package/src/tools/permission-checker.ts +8 -1
- package/src/tools/skills/load.ts +140 -6
- package/src/util/platform.ts +18 -0
- package/src/workspace/migrations/{002-backfill-installation-id.ts → 011-backfill-installation-id.ts} +1 -1
- package/src/workspace/migrations/registry.ts +1 -1
|
@@ -0,0 +1,375 @@
|
|
|
1
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
2
|
+
import { tmpdir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import {
|
|
5
|
+
afterAll,
|
|
6
|
+
afterEach,
|
|
7
|
+
beforeEach,
|
|
8
|
+
describe,
|
|
9
|
+
expect,
|
|
10
|
+
mock,
|
|
11
|
+
test,
|
|
12
|
+
} from "bun:test";
|
|
13
|
+
|
|
14
|
+
const testDir = mkdtempSync(join(tmpdir(), "memory-observation-archive-test-"));
|
|
15
|
+
const dbPath = join(testDir, "test.db");
|
|
16
|
+
|
|
17
|
+
mock.module("../util/platform.js", () => ({
|
|
18
|
+
getDataDir: () => testDir,
|
|
19
|
+
isMacOS: () => process.platform === "darwin",
|
|
20
|
+
isLinux: () => process.platform === "linux",
|
|
21
|
+
isWindows: () => process.platform === "win32",
|
|
22
|
+
getPidPath: () => join(testDir, "test.pid"),
|
|
23
|
+
getDbPath: () => dbPath,
|
|
24
|
+
getLogPath: () => join(testDir, "test.log"),
|
|
25
|
+
ensureDataDir: () => {},
|
|
26
|
+
}));
|
|
27
|
+
|
|
28
|
+
mock.module("../util/logger.js", () => ({
|
|
29
|
+
getLogger: () =>
|
|
30
|
+
new Proxy({} as Record<string, unknown>, {
|
|
31
|
+
get: () => () => {},
|
|
32
|
+
}),
|
|
33
|
+
}));
|
|
34
|
+
|
|
35
|
+
import { eq } from "drizzle-orm";
|
|
36
|
+
|
|
37
|
+
import {
|
|
38
|
+
computeObservationContentHash,
|
|
39
|
+
getChunkByObservationId,
|
|
40
|
+
getObservation,
|
|
41
|
+
insertObservation,
|
|
42
|
+
type InsertObservationParams,
|
|
43
|
+
insertObservations,
|
|
44
|
+
} from "../memory/archive-store.js";
|
|
45
|
+
import { getDb, initializeDb, rawAll, resetDb } from "../memory/db.js";
|
|
46
|
+
import { claimMemoryJobs } from "../memory/jobs-store.js";
|
|
47
|
+
import { conversations, memoryChunks, messages } from "../memory/schema.js";
|
|
48
|
+
|
|
49
|
+
function removeTestDbFiles(): void {
|
|
50
|
+
rmSync(dbPath, { force: true });
|
|
51
|
+
rmSync(`${dbPath}-shm`, { force: true });
|
|
52
|
+
rmSync(`${dbPath}-wal`, { force: true });
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// ── Helpers ─────────────────────────────────────────────────────────
|
|
56
|
+
|
|
57
|
+
function createConversation(id: string): void {
|
|
58
|
+
const db = getDb();
|
|
59
|
+
const now = Date.now();
|
|
60
|
+
db.insert(conversations)
|
|
61
|
+
.values({
|
|
62
|
+
id,
|
|
63
|
+
createdAt: now,
|
|
64
|
+
updatedAt: now,
|
|
65
|
+
})
|
|
66
|
+
.run();
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function createMessage(id: string, conversationId: string): void {
|
|
70
|
+
const db = getDb();
|
|
71
|
+
db.insert(messages)
|
|
72
|
+
.values({
|
|
73
|
+
id,
|
|
74
|
+
conversationId,
|
|
75
|
+
role: "user",
|
|
76
|
+
content: "test message",
|
|
77
|
+
createdAt: Date.now(),
|
|
78
|
+
})
|
|
79
|
+
.run();
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function getJobsByType(type: string) {
|
|
83
|
+
return rawAll<{
|
|
84
|
+
id: string;
|
|
85
|
+
type: string;
|
|
86
|
+
payload: string;
|
|
87
|
+
status: string;
|
|
88
|
+
}>(`SELECT id, type, payload, status FROM memory_jobs WHERE type = ?`, type);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// ── Setup ───────────────────────────────────────────────────────────
|
|
92
|
+
|
|
93
|
+
describe("memory observation archive store", () => {
|
|
94
|
+
beforeEach(() => {
|
|
95
|
+
resetDb();
|
|
96
|
+
removeTestDbFiles();
|
|
97
|
+
initializeDb();
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
afterEach(() => {
|
|
101
|
+
resetDb();
|
|
102
|
+
removeTestDbFiles();
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
afterAll(() => {
|
|
106
|
+
resetDb();
|
|
107
|
+
rmSync(testDir, { recursive: true, force: true });
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
// ── Row insertion ───────────────────────────────────────────────
|
|
111
|
+
|
|
112
|
+
describe("insertObservation", () => {
|
|
113
|
+
test("inserts observation row into memory_observations table", () => {
|
|
114
|
+
createConversation("conv-1");
|
|
115
|
+
|
|
116
|
+
const result = insertObservation({
|
|
117
|
+
conversationId: "conv-1",
|
|
118
|
+
role: "user",
|
|
119
|
+
content: "The user prefers dark mode",
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
expect(result.observationId).toBeTruthy();
|
|
123
|
+
expect(result.contentHash).toBeTruthy();
|
|
124
|
+
|
|
125
|
+
const obs = getObservation(result.observationId);
|
|
126
|
+
expect(obs).toBeDefined();
|
|
127
|
+
expect(obs!.conversationId).toBe("conv-1");
|
|
128
|
+
expect(obs!.role).toBe("user");
|
|
129
|
+
expect(obs!.content).toBe("The user prefers dark mode");
|
|
130
|
+
expect(obs!.modality).toBe("text");
|
|
131
|
+
expect(obs!.scopeId).toBe("default");
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
test("inserts associated chunk with correct content hash", () => {
|
|
135
|
+
createConversation("conv-1");
|
|
136
|
+
|
|
137
|
+
const result = insertObservation({
|
|
138
|
+
conversationId: "conv-1",
|
|
139
|
+
role: "user",
|
|
140
|
+
content: "The user lives in NYC",
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
expect(result.chunkId).toBeTruthy();
|
|
144
|
+
|
|
145
|
+
const chunk = getChunkByObservationId(result.observationId);
|
|
146
|
+
expect(chunk).toBeDefined();
|
|
147
|
+
expect(chunk!.content).toBe("The user lives in NYC");
|
|
148
|
+
expect(chunk!.contentHash).toBe(result.contentHash);
|
|
149
|
+
expect(chunk!.tokenEstimate).toBeGreaterThan(0);
|
|
150
|
+
expect(chunk!.scopeId).toBe("default");
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
test("respects optional params: scopeId, modality, source, messageId", () => {
|
|
154
|
+
createConversation("conv-1");
|
|
155
|
+
createMessage("msg-1", "conv-1");
|
|
156
|
+
|
|
157
|
+
const result = insertObservation({
|
|
158
|
+
conversationId: "conv-1",
|
|
159
|
+
messageId: "msg-1",
|
|
160
|
+
role: "assistant",
|
|
161
|
+
content: "Voice observation about weather",
|
|
162
|
+
scopeId: "custom-scope",
|
|
163
|
+
modality: "voice",
|
|
164
|
+
source: "phone",
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
const obs = getObservation(result.observationId);
|
|
168
|
+
expect(obs).toBeDefined();
|
|
169
|
+
expect(obs!.messageId).toBe("msg-1");
|
|
170
|
+
expect(obs!.scopeId).toBe("custom-scope");
|
|
171
|
+
expect(obs!.modality).toBe("voice");
|
|
172
|
+
expect(obs!.source).toBe("phone");
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
test("does not touch legacy memory tables", () => {
|
|
176
|
+
createConversation("conv-1");
|
|
177
|
+
|
|
178
|
+
insertObservation({
|
|
179
|
+
conversationId: "conv-1",
|
|
180
|
+
role: "user",
|
|
181
|
+
content: "A fact about the user",
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
// Verify no rows in legacy memory_segments or memory_items
|
|
185
|
+
const segments = rawAll<{ id: string }>(`SELECT id FROM memory_segments`);
|
|
186
|
+
const items = rawAll<{ id: string }>(`SELECT id FROM memory_items`);
|
|
187
|
+
expect(segments).toHaveLength(0);
|
|
188
|
+
expect(items).toHaveLength(0);
|
|
189
|
+
});
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
// ── Content hash idempotency ──────────────────────────────────
|
|
193
|
+
|
|
194
|
+
describe("content hash idempotency", () => {
|
|
195
|
+
test("duplicate content in same scope does not create a second chunk", () => {
|
|
196
|
+
createConversation("conv-1");
|
|
197
|
+
|
|
198
|
+
const result1 = insertObservation({
|
|
199
|
+
conversationId: "conv-1",
|
|
200
|
+
role: "user",
|
|
201
|
+
content: "The user likes cats",
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
const result2 = insertObservation({
|
|
205
|
+
conversationId: "conv-1",
|
|
206
|
+
role: "user",
|
|
207
|
+
content: "The user likes cats",
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
// Both observations should exist
|
|
211
|
+
expect(getObservation(result1.observationId)).toBeDefined();
|
|
212
|
+
expect(getObservation(result2.observationId)).toBeDefined();
|
|
213
|
+
|
|
214
|
+
// First creates a chunk, second is deduplicated
|
|
215
|
+
expect(result1.chunkId).toBeTruthy();
|
|
216
|
+
expect(result2.chunkId).toBeNull();
|
|
217
|
+
|
|
218
|
+
// Only one chunk row should exist
|
|
219
|
+
const db = getDb();
|
|
220
|
+
const chunks = db
|
|
221
|
+
.select()
|
|
222
|
+
.from(memoryChunks)
|
|
223
|
+
.where(eq(memoryChunks.scopeId, "default"))
|
|
224
|
+
.all();
|
|
225
|
+
expect(chunks).toHaveLength(1);
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
test("same content in different scopes creates separate chunks", () => {
|
|
229
|
+
createConversation("conv-1");
|
|
230
|
+
|
|
231
|
+
const result1 = insertObservation({
|
|
232
|
+
conversationId: "conv-1",
|
|
233
|
+
role: "user",
|
|
234
|
+
content: "The user likes dogs",
|
|
235
|
+
scopeId: "scope-a",
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
const result2 = insertObservation({
|
|
239
|
+
conversationId: "conv-1",
|
|
240
|
+
role: "user",
|
|
241
|
+
content: "The user likes dogs",
|
|
242
|
+
scopeId: "scope-b",
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
expect(result1.chunkId).toBeTruthy();
|
|
246
|
+
expect(result2.chunkId).toBeTruthy();
|
|
247
|
+
expect(result1.chunkId).not.toBe(result2.chunkId);
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
test("content hashes are deterministic", () => {
|
|
251
|
+
const hash1 = computeObservationContentHash("default", "Hello world");
|
|
252
|
+
const hash2 = computeObservationContentHash("default", "Hello world");
|
|
253
|
+
expect(hash1).toBe(hash2);
|
|
254
|
+
|
|
255
|
+
// Different scope produces different hash
|
|
256
|
+
const hash3 = computeObservationContentHash("other", "Hello world");
|
|
257
|
+
expect(hash1).not.toBe(hash3);
|
|
258
|
+
});
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
// ── Embedding job dispatch ────────────────────────────────────
|
|
262
|
+
|
|
263
|
+
describe("embedding job dispatch", () => {
|
|
264
|
+
test("enqueues embed_observation job when new chunk is created", () => {
|
|
265
|
+
createConversation("conv-1");
|
|
266
|
+
|
|
267
|
+
const result = insertObservation({
|
|
268
|
+
conversationId: "conv-1",
|
|
269
|
+
role: "user",
|
|
270
|
+
content: "User prefers TypeScript",
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
expect(result.embeddingJobId).toBeTruthy();
|
|
274
|
+
|
|
275
|
+
const jobs = getJobsByType("embed_observation");
|
|
276
|
+
expect(jobs).toHaveLength(1);
|
|
277
|
+
expect(jobs[0].status).toBe("pending");
|
|
278
|
+
|
|
279
|
+
const payload = JSON.parse(jobs[0].payload);
|
|
280
|
+
expect(payload.observationId).toBe(result.observationId);
|
|
281
|
+
expect(payload.chunkId).toBe(result.chunkId);
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
test("does not enqueue embed job when chunk is deduplicated", () => {
|
|
285
|
+
createConversation("conv-1");
|
|
286
|
+
|
|
287
|
+
// First insert creates a chunk and job
|
|
288
|
+
insertObservation({
|
|
289
|
+
conversationId: "conv-1",
|
|
290
|
+
role: "user",
|
|
291
|
+
content: "User prefers Python",
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
// Second insert with same content should not create another job
|
|
295
|
+
const result2 = insertObservation({
|
|
296
|
+
conversationId: "conv-1",
|
|
297
|
+
role: "user",
|
|
298
|
+
content: "User prefers Python",
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
expect(result2.embeddingJobId).toBeNull();
|
|
302
|
+
|
|
303
|
+
const jobs = getJobsByType("embed_observation");
|
|
304
|
+
expect(jobs).toHaveLength(1); // Only from the first insert
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
test("embed_observation jobs are claimable by the worker", () => {
|
|
308
|
+
createConversation("conv-1");
|
|
309
|
+
|
|
310
|
+
insertObservation({
|
|
311
|
+
conversationId: "conv-1",
|
|
312
|
+
role: "user",
|
|
313
|
+
content: "Claimable observation",
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
const claimed = claimMemoryJobs(10);
|
|
317
|
+
const embedJobs = claimed.filter((j) => j.type === "embed_observation");
|
|
318
|
+
expect(embedJobs).toHaveLength(1);
|
|
319
|
+
expect(embedJobs[0].status).toBe("running");
|
|
320
|
+
expect(embedJobs[0].payload.observationId).toBeTruthy();
|
|
321
|
+
expect(embedJobs[0].payload.chunkId).toBeTruthy();
|
|
322
|
+
});
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
// ── Batch insertion ───────────────────────────────────────────
|
|
326
|
+
|
|
327
|
+
describe("insertObservations (batch)", () => {
|
|
328
|
+
test("inserts multiple observations atomically", () => {
|
|
329
|
+
createConversation("conv-1");
|
|
330
|
+
|
|
331
|
+
const params: InsertObservationParams[] = [
|
|
332
|
+
{ conversationId: "conv-1", role: "user", content: "Fact A" },
|
|
333
|
+
{ conversationId: "conv-1", role: "user", content: "Fact B" },
|
|
334
|
+
{ conversationId: "conv-1", role: "assistant", content: "Fact C" },
|
|
335
|
+
];
|
|
336
|
+
|
|
337
|
+
const results = insertObservations(params);
|
|
338
|
+
expect(results).toHaveLength(3);
|
|
339
|
+
|
|
340
|
+
// All observations should exist
|
|
341
|
+
for (const result of results) {
|
|
342
|
+
expect(getObservation(result.observationId)).toBeDefined();
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
// All should have chunks (different content)
|
|
346
|
+
for (const result of results) {
|
|
347
|
+
expect(result.chunkId).toBeTruthy();
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
// All should have embedding jobs
|
|
351
|
+
const jobs = getJobsByType("embed_observation");
|
|
352
|
+
expect(jobs).toHaveLength(3);
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
test("batch handles content hash dedup within the batch", () => {
|
|
356
|
+
createConversation("conv-1");
|
|
357
|
+
|
|
358
|
+
const params: InsertObservationParams[] = [
|
|
359
|
+
{ conversationId: "conv-1", role: "user", content: "Same content" },
|
|
360
|
+
{ conversationId: "conv-1", role: "user", content: "Same content" },
|
|
361
|
+
];
|
|
362
|
+
|
|
363
|
+
const results = insertObservations(params);
|
|
364
|
+
expect(results).toHaveLength(2);
|
|
365
|
+
|
|
366
|
+
// First creates a chunk, second is deduplicated
|
|
367
|
+
expect(results[0].chunkId).toBeTruthy();
|
|
368
|
+
expect(results[1].chunkId).toBeNull();
|
|
369
|
+
|
|
370
|
+
// Only one embedding job
|
|
371
|
+
const jobs = getJobsByType("embed_observation");
|
|
372
|
+
expect(jobs).toHaveLength(1);
|
|
373
|
+
});
|
|
374
|
+
});
|
|
375
|
+
});
|
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
2
|
+
import { tmpdir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import {
|
|
5
|
+
afterAll,
|
|
6
|
+
afterEach,
|
|
7
|
+
beforeEach,
|
|
8
|
+
describe,
|
|
9
|
+
expect,
|
|
10
|
+
mock,
|
|
11
|
+
test,
|
|
12
|
+
} from "bun:test";
|
|
13
|
+
|
|
14
|
+
const testDir = mkdtempSync(
|
|
15
|
+
join(tmpdir(), "memory-observation-dual-write-test-"),
|
|
16
|
+
);
|
|
17
|
+
const dbPath = join(testDir, "test.db");
|
|
18
|
+
|
|
19
|
+
mock.module("../util/platform.js", () => ({
|
|
20
|
+
getDataDir: () => testDir,
|
|
21
|
+
getRootDir: () => join(testDir, ".vellum"),
|
|
22
|
+
getWorkspaceDir: () => join(testDir, ".vellum", "workspace"),
|
|
23
|
+
getConversationsDir: () =>
|
|
24
|
+
join(testDir, ".vellum", "workspace", "conversations"),
|
|
25
|
+
isMacOS: () => process.platform === "darwin",
|
|
26
|
+
isLinux: () => process.platform === "linux",
|
|
27
|
+
isWindows: () => process.platform === "win32",
|
|
28
|
+
getPidPath: () => join(testDir, "test.pid"),
|
|
29
|
+
getDbPath: () => dbPath,
|
|
30
|
+
getLogPath: () => join(testDir, "test.log"),
|
|
31
|
+
ensureDataDir: () => {},
|
|
32
|
+
}));
|
|
33
|
+
|
|
34
|
+
mock.module("../util/logger.js", () => ({
|
|
35
|
+
getLogger: () =>
|
|
36
|
+
new Proxy({} as Record<string, unknown>, {
|
|
37
|
+
get: () => () => {},
|
|
38
|
+
}),
|
|
39
|
+
}));
|
|
40
|
+
|
|
41
|
+
// Stub the local embedding backend so the real ONNX model never loads.
|
|
42
|
+
mock.module("../memory/embedding-local.js", () => ({
|
|
43
|
+
LocalEmbeddingBackend: class {
|
|
44
|
+
readonly provider = "local" as const;
|
|
45
|
+
readonly model: string;
|
|
46
|
+
constructor(model: string) {
|
|
47
|
+
this.model = model;
|
|
48
|
+
}
|
|
49
|
+
async embed(texts: string[]): Promise<number[][]> {
|
|
50
|
+
return texts.map(() => new Array(384).fill(0));
|
|
51
|
+
}
|
|
52
|
+
},
|
|
53
|
+
}));
|
|
54
|
+
|
|
55
|
+
// Mock Qdrant client so semantic search returns empty results.
|
|
56
|
+
mock.module("../memory/qdrant-client.js", () => ({
|
|
57
|
+
getQdrantClient: () => ({
|
|
58
|
+
searchWithFilter: async () => [],
|
|
59
|
+
hybridSearch: async () => [],
|
|
60
|
+
upsertPoints: async () => {},
|
|
61
|
+
deletePoints: async () => {},
|
|
62
|
+
}),
|
|
63
|
+
initQdrantClient: () => {},
|
|
64
|
+
}));
|
|
65
|
+
|
|
66
|
+
import { DEFAULT_CONFIG } from "../config/defaults.js";
|
|
67
|
+
|
|
68
|
+
// Enable memory but disable LLM extraction and summarization.
|
|
69
|
+
const TEST_CONFIG = {
|
|
70
|
+
...DEFAULT_CONFIG,
|
|
71
|
+
memory: {
|
|
72
|
+
...DEFAULT_CONFIG.memory,
|
|
73
|
+
enabled: true,
|
|
74
|
+
extraction: {
|
|
75
|
+
...DEFAULT_CONFIG.memory.extraction,
|
|
76
|
+
useLLM: false,
|
|
77
|
+
},
|
|
78
|
+
summarization: {
|
|
79
|
+
...DEFAULT_CONFIG.memory.summarization,
|
|
80
|
+
useLLM: false,
|
|
81
|
+
},
|
|
82
|
+
},
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
mock.module("../config/loader.js", () => ({
|
|
86
|
+
loadConfig: () => TEST_CONFIG,
|
|
87
|
+
getConfig: () => TEST_CONFIG,
|
|
88
|
+
invalidateConfigCache: () => {},
|
|
89
|
+
}));
|
|
90
|
+
|
|
91
|
+
import { eq } from "drizzle-orm";
|
|
92
|
+
|
|
93
|
+
import { getChunkByObservationId } from "../memory/archive-store.js";
|
|
94
|
+
import { addMessage, createConversation } from "../memory/conversation-crud.js";
|
|
95
|
+
import { getDb, initializeDb, rawAll, resetDb } from "../memory/db.js";
|
|
96
|
+
import { memoryObservations, memorySegments } from "../memory/schema.js";
|
|
97
|
+
|
|
98
|
+
function removeTestDbFiles(): void {
|
|
99
|
+
rmSync(dbPath, { force: true });
|
|
100
|
+
rmSync(`${dbPath}-shm`, { force: true });
|
|
101
|
+
rmSync(`${dbPath}-wal`, { force: true });
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function getObservationsByConversation(conversationId: string) {
|
|
105
|
+
const db = getDb();
|
|
106
|
+
return db
|
|
107
|
+
.select()
|
|
108
|
+
.from(memoryObservations)
|
|
109
|
+
.where(eq(memoryObservations.conversationId, conversationId))
|
|
110
|
+
.all();
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function getJobsByType(type: string) {
|
|
114
|
+
return rawAll<{
|
|
115
|
+
id: string;
|
|
116
|
+
type: string;
|
|
117
|
+
payload: string;
|
|
118
|
+
status: string;
|
|
119
|
+
}>(`SELECT id, type, payload, status FROM memory_jobs WHERE type = ?`, type);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// ── Setup ───────────────────────────────────────────────────────────
|
|
123
|
+
|
|
124
|
+
describe("memory observation dual-write from addMessage", () => {
|
|
125
|
+
beforeEach(() => {
|
|
126
|
+
resetDb();
|
|
127
|
+
removeTestDbFiles();
|
|
128
|
+
initializeDb();
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
afterEach(() => {
|
|
132
|
+
resetDb();
|
|
133
|
+
removeTestDbFiles();
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
afterAll(() => {
|
|
137
|
+
resetDb();
|
|
138
|
+
rmSync(testDir, { recursive: true, force: true });
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
// ── Text-only messages ──────────────────────────────────────────
|
|
142
|
+
|
|
143
|
+
describe("text-only messages", () => {
|
|
144
|
+
test("creates an observation for a plain text user message", async () => {
|
|
145
|
+
const conv = createConversation("test-conv");
|
|
146
|
+
await addMessage(conv.id, "user", "I prefer dark mode for all editors");
|
|
147
|
+
|
|
148
|
+
const observations = getObservationsByConversation(conv.id);
|
|
149
|
+
expect(observations).toHaveLength(1);
|
|
150
|
+
expect(observations[0].role).toBe("user");
|
|
151
|
+
expect(observations[0].content).toBe(
|
|
152
|
+
"I prefer dark mode for all editors",
|
|
153
|
+
);
|
|
154
|
+
expect(observations[0].modality).toBe("text");
|
|
155
|
+
expect(observations[0].scopeId).toBe("default");
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
test("creates an observation for an assistant message", async () => {
|
|
159
|
+
const conv = createConversation("test-conv");
|
|
160
|
+
await addMessage(
|
|
161
|
+
conv.id,
|
|
162
|
+
"assistant",
|
|
163
|
+
"Sure, I will use dark mode from now on.",
|
|
164
|
+
);
|
|
165
|
+
|
|
166
|
+
const observations = getObservationsByConversation(conv.id);
|
|
167
|
+
expect(observations).toHaveLength(1);
|
|
168
|
+
expect(observations[0].role).toBe("assistant");
|
|
169
|
+
expect(observations[0].content).toBe(
|
|
170
|
+
"Sure, I will use dark mode from now on.",
|
|
171
|
+
);
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
test("creates a chunk with embed_chunk job for text message", async () => {
|
|
175
|
+
const conv = createConversation("test-conv");
|
|
176
|
+
await addMessage(conv.id, "user", "My favorite language is TypeScript");
|
|
177
|
+
|
|
178
|
+
const observations = getObservationsByConversation(conv.id);
|
|
179
|
+
expect(observations).toHaveLength(1);
|
|
180
|
+
|
|
181
|
+
const chunk = getChunkByObservationId(observations[0].id);
|
|
182
|
+
expect(chunk).toBeDefined();
|
|
183
|
+
expect(chunk!.content).toBe("My favorite language is TypeScript");
|
|
184
|
+
|
|
185
|
+
const embedJobs = getJobsByType("embed_chunk");
|
|
186
|
+
expect(embedJobs.length).toBeGreaterThanOrEqual(1);
|
|
187
|
+
const matchingJob = embedJobs.find((j) => {
|
|
188
|
+
const payload = JSON.parse(j.payload);
|
|
189
|
+
return payload.chunkId === chunk!.id;
|
|
190
|
+
});
|
|
191
|
+
expect(matchingJob).toBeDefined();
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
test("links observation to the correct messageId", async () => {
|
|
195
|
+
const conv = createConversation("test-conv");
|
|
196
|
+
const msg = await addMessage(conv.id, "user", "Testing message link");
|
|
197
|
+
|
|
198
|
+
const observations = getObservationsByConversation(conv.id);
|
|
199
|
+
expect(observations).toHaveLength(1);
|
|
200
|
+
expect(observations[0].messageId).toBe(msg.id);
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
test("uses conversation memory scope for observation", async () => {
|
|
204
|
+
const conv = createConversation({
|
|
205
|
+
conversationType: "private",
|
|
206
|
+
});
|
|
207
|
+
await addMessage(conv.id, "user", "Private observation");
|
|
208
|
+
|
|
209
|
+
const observations = getObservationsByConversation(conv.id);
|
|
210
|
+
expect(observations).toHaveLength(1);
|
|
211
|
+
expect(observations[0].scopeId).toBe(`private:${conv.id}`);
|
|
212
|
+
});
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
// ── Multimodal messages ─────────────────────────────────────────
|
|
216
|
+
|
|
217
|
+
describe("multimodal messages", () => {
|
|
218
|
+
test("creates observation for message with text + image blocks", async () => {
|
|
219
|
+
const conv = createConversation("test-conv");
|
|
220
|
+
const content = JSON.stringify([
|
|
221
|
+
{ type: "text", text: "Here is my screenshot" },
|
|
222
|
+
{
|
|
223
|
+
type: "image",
|
|
224
|
+
source: {
|
|
225
|
+
type: "base64",
|
|
226
|
+
media_type: "image/png",
|
|
227
|
+
data: "iVBORw0KGgo=",
|
|
228
|
+
},
|
|
229
|
+
},
|
|
230
|
+
]);
|
|
231
|
+
await addMessage(conv.id, "user", content);
|
|
232
|
+
|
|
233
|
+
const observations = getObservationsByConversation(conv.id);
|
|
234
|
+
expect(observations).toHaveLength(1);
|
|
235
|
+
// Text extraction produces the text portion
|
|
236
|
+
expect(observations[0].content).toContain("Here is my screenshot");
|
|
237
|
+
// Text+image = multimodal since media blocks are present
|
|
238
|
+
expect(observations[0].modality).toBe("multimodal");
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
test("creates observation with multimodal modality for image-only message", async () => {
|
|
242
|
+
const conv = createConversation("test-conv");
|
|
243
|
+
const content = JSON.stringify([
|
|
244
|
+
{
|
|
245
|
+
type: "image",
|
|
246
|
+
source: {
|
|
247
|
+
type: "base64",
|
|
248
|
+
media_type: "image/png",
|
|
249
|
+
data: "iVBORw0KGgo=",
|
|
250
|
+
},
|
|
251
|
+
},
|
|
252
|
+
]);
|
|
253
|
+
await addMessage(conv.id, "user", content);
|
|
254
|
+
|
|
255
|
+
const observations = getObservationsByConversation(conv.id);
|
|
256
|
+
expect(observations).toHaveLength(1);
|
|
257
|
+
expect(observations[0].modality).toBe("multimodal");
|
|
258
|
+
});
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
// ── Legacy indexing unchanged ───────────────────────────────────
|
|
262
|
+
|
|
263
|
+
describe("legacy indexing continues alongside dual-write", () => {
|
|
264
|
+
test("legacy memory_segments are still created for text messages", async () => {
|
|
265
|
+
const conv = createConversation("test-conv");
|
|
266
|
+
await addMessage(conv.id, "user", "A fact worth remembering for memory");
|
|
267
|
+
|
|
268
|
+
// Legacy segments should exist
|
|
269
|
+
const db = getDb();
|
|
270
|
+
const segments = db
|
|
271
|
+
.select()
|
|
272
|
+
.from(memorySegments)
|
|
273
|
+
.where(eq(memorySegments.conversationId, conv.id))
|
|
274
|
+
.all();
|
|
275
|
+
expect(segments.length).toBeGreaterThanOrEqual(1);
|
|
276
|
+
|
|
277
|
+
// Observation should also exist
|
|
278
|
+
const observations = getObservationsByConversation(conv.id);
|
|
279
|
+
expect(observations).toHaveLength(1);
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
test("legacy extract_items jobs are still enqueued for user messages", async () => {
|
|
283
|
+
const conv = createConversation("test-conv");
|
|
284
|
+
await addMessage(conv.id, "user", "The user lives in San Francisco");
|
|
285
|
+
|
|
286
|
+
const extractJobs = getJobsByType("extract_items");
|
|
287
|
+
expect(extractJobs.length).toBeGreaterThanOrEqual(1);
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
test("skipping indexing skips both legacy and observation writes", async () => {
|
|
291
|
+
const conv = createConversation("test-conv");
|
|
292
|
+
await addMessage(conv.id, "user", "No indexing please", undefined, {
|
|
293
|
+
skipIndexing: true,
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
// No legacy segments
|
|
297
|
+
const db = getDb();
|
|
298
|
+
const segments = db
|
|
299
|
+
.select()
|
|
300
|
+
.from(memorySegments)
|
|
301
|
+
.where(eq(memorySegments.conversationId, conv.id))
|
|
302
|
+
.all();
|
|
303
|
+
expect(segments).toHaveLength(0);
|
|
304
|
+
|
|
305
|
+
// No observations
|
|
306
|
+
const observations = getObservationsByConversation(conv.id);
|
|
307
|
+
expect(observations).toHaveLength(0);
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
test("does not create observation for empty content messages", async () => {
|
|
311
|
+
const conv = createConversation("test-conv");
|
|
312
|
+
await addMessage(conv.id, "user", "");
|
|
313
|
+
|
|
314
|
+
const observations = getObservationsByConversation(conv.id);
|
|
315
|
+
expect(observations).toHaveLength(0);
|
|
316
|
+
});
|
|
317
|
+
});
|
|
318
|
+
});
|