@vellumai/assistant 0.5.2 → 0.5.4
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/architecture/memory.md +105 -0
- package/docs/skills.md +100 -0
- package/package.json +1 -1
- package/src/__tests__/archive-recall.test.ts +560 -0
- 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-clear-safety.test.ts +259 -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-switch-memory-reduction.test.ts +474 -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__/db-schedule-syntax-migration.test.ts +3 -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-job.test.ts +538 -0
- package/src/__tests__/memory-reducer-scheduling.test.ts +473 -0
- package/src/__tests__/memory-reducer-store.test.ts +728 -0
- package/src/__tests__/memory-reducer-types.test.ts +707 -0
- package/src/__tests__/memory-reducer.test.ts +704 -0
- package/src/__tests__/memory-regressions.test.ts +30 -8
- package/src/__tests__/memory-simplified-config.test.ts +281 -0
- package/src/__tests__/parse-identity-fields.test.ts +129 -0
- package/src/__tests__/simplified-memory-e2e.test.ts +666 -0
- package/src/__tests__/simplified-memory-runtime.test.ts +616 -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/cli/commands/conversations.ts +18 -0
- package/src/config/bundled-skills/app-builder/SKILL.md +8 -8
- package/src/config/bundled-skills/schedule/TOOLS.json +8 -0
- 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/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-memory.ts +117 -0
- package/src/daemon/conversation-runtime-assembly.ts +3 -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/conversations.ts +11 -0
- package/src/daemon/handlers/identity.ts +12 -1
- package/src/daemon/lifecycle.ts +52 -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-recall.ts +516 -0
- 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 +162 -0
- package/src/memory/brief.ts +75 -0
- package/src/memory/conversation-crud.ts +455 -101
- package/src/memory/conversation-key-store.ts +33 -4
- package/src/memory/db-init.ts +16 -0
- package/src/memory/indexer.ts +106 -15
- package/src/memory/job-handlers/backfill-simplified-memory.ts +462 -0
- package/src/memory/job-handlers/conversation-starters.ts +9 -3
- package/src/memory/job-handlers/embedding.test.ts +1 -0
- package/src/memory/job-handlers/embedding.ts +83 -0
- package/src/memory/job-handlers/reduce-conversation-memory.ts +229 -0
- package/src/memory/job-utils.ts +1 -1
- package/src/memory/jobs-store.ts +8 -0
- package/src/memory/jobs-worker.ts +20 -0
- package/src/memory/migrations/036-normalize-phone-identities.ts +49 -14
- package/src/memory/migrations/135-backfill-contact-interaction-stats.ts +9 -1
- package/src/memory/migrations/141-rename-verification-table.ts +8 -0
- package/src/memory/migrations/142-rename-verification-session-id-column.ts +7 -2
- package/src/memory/migrations/174-rename-thread-starters-table.ts +8 -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/188-schedule-quiet-flag.ts +13 -0
- package/src/memory/migrations/index.ts +4 -0
- package/src/memory/qdrant-client.ts +23 -4
- package/src/memory/reducer-scheduler.ts +242 -0
- package/src/memory/reducer-store.ts +271 -0
- package/src/memory/reducer-types.ts +106 -0
- package/src/memory/reducer.ts +467 -0
- package/src/memory/schema/conversations.ts +3 -0
- package/src/memory/schema/index.ts +2 -0
- package/src/memory/schema/infrastructure.ts +1 -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/auth/route-policy.ts +10 -1
- package/src/runtime/routes/conversation-management-routes.ts +94 -2
- package/src/runtime/routes/conversation-query-routes.ts +7 -0
- package/src/runtime/routes/conversation-routes.ts +52 -5
- package/src/runtime/routes/guardian-bootstrap-routes.ts +19 -7
- 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 +3 -0
- package/src/runtime/routes/surface-action-routes.ts +68 -1
- package/src/schedule/schedule-store.ts +28 -0
- package/src/schedule/scheduler.ts +6 -2
- 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/telemetry/usage-telemetry-reporter.ts +1 -1
- package/src/tools/filesystem/edit.ts +6 -1
- package/src/tools/filesystem/read.ts +6 -1
- package/src/tools/filesystem/write.ts +6 -1
- package/src/tools/memory/handlers.ts +129 -1
- package/src/tools/permission-checker.ts +8 -1
- package/src/tools/schedule/create.ts +3 -0
- package/src/tools/schedule/list.ts +5 -1
- package/src/tools/schedule/update.ts +6 -0
- 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,474 @@
|
|
|
1
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
2
|
+
import { tmpdir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { afterAll, beforeEach, describe, expect, mock, test } from "bun:test";
|
|
5
|
+
|
|
6
|
+
// ── Test directory & platform mocks ───────────────────────────────
|
|
7
|
+
|
|
8
|
+
const testDir = mkdtempSync(
|
|
9
|
+
join(tmpdir(), "conversation-switch-memory-reduction-test-"),
|
|
10
|
+
);
|
|
11
|
+
|
|
12
|
+
mock.module("../util/platform.js", () => ({
|
|
13
|
+
getDataDir: () => testDir,
|
|
14
|
+
getRootDir: () => testDir,
|
|
15
|
+
getWorkspaceDir: () => join(testDir, "workspace"),
|
|
16
|
+
getConversationsDir: () => join(testDir, "workspace", "conversations"),
|
|
17
|
+
isMacOS: () => process.platform === "darwin",
|
|
18
|
+
isLinux: () => process.platform === "linux",
|
|
19
|
+
isWindows: () => process.platform === "win32",
|
|
20
|
+
getPidPath: () => join(testDir, "test.pid"),
|
|
21
|
+
getDbPath: () => join(testDir, "test.db"),
|
|
22
|
+
getLogPath: () => join(testDir, "test.log"),
|
|
23
|
+
ensureDataDir: () => {},
|
|
24
|
+
}));
|
|
25
|
+
|
|
26
|
+
mock.module("../util/logger.js", () => ({
|
|
27
|
+
getLogger: () =>
|
|
28
|
+
new Proxy({} as Record<string, unknown>, {
|
|
29
|
+
get: () => () => {},
|
|
30
|
+
}),
|
|
31
|
+
}));
|
|
32
|
+
|
|
33
|
+
// ── Config mock ───────────────────────────────────────────────────
|
|
34
|
+
|
|
35
|
+
mock.module("../config/loader.js", () => ({
|
|
36
|
+
getConfig: () => ({
|
|
37
|
+
memory: {
|
|
38
|
+
simplified: {
|
|
39
|
+
reducer: {
|
|
40
|
+
idleDelayMs: 30_000,
|
|
41
|
+
switchWaitMs: 5_000,
|
|
42
|
+
},
|
|
43
|
+
},
|
|
44
|
+
},
|
|
45
|
+
}),
|
|
46
|
+
loadConfig: () => ({
|
|
47
|
+
memory: {
|
|
48
|
+
simplified: {
|
|
49
|
+
reducer: {
|
|
50
|
+
idleDelayMs: 30_000,
|
|
51
|
+
switchWaitMs: 5_000,
|
|
52
|
+
},
|
|
53
|
+
},
|
|
54
|
+
},
|
|
55
|
+
}),
|
|
56
|
+
}));
|
|
57
|
+
|
|
58
|
+
// ── Suppress disk-view side effects ──────────────────────────────
|
|
59
|
+
|
|
60
|
+
mock.module("../memory/conversation-disk-view.js", () => ({
|
|
61
|
+
initConversationDir: () => {},
|
|
62
|
+
removeConversationDir: () => {},
|
|
63
|
+
syncMessageToDisk: () => {},
|
|
64
|
+
updateMetaFile: () => {},
|
|
65
|
+
}));
|
|
66
|
+
|
|
67
|
+
// ── Suppress indexer side effects ────────────────────────────────
|
|
68
|
+
|
|
69
|
+
mock.module("../memory/indexer.js", () => ({
|
|
70
|
+
indexMessageNow: async () => {},
|
|
71
|
+
}));
|
|
72
|
+
|
|
73
|
+
// ── Suppress attention side effects ──────────────────────────────
|
|
74
|
+
|
|
75
|
+
mock.module("../memory/conversation-attention-store.js", () => ({
|
|
76
|
+
projectAssistantMessage: () => {},
|
|
77
|
+
seedForkedConversationAttention: () => {},
|
|
78
|
+
}));
|
|
79
|
+
|
|
80
|
+
// ── Mock the reducer ──────────────────────────────────────────────
|
|
81
|
+
|
|
82
|
+
import type { ReducerPromptInput } from "../memory/reducer.js";
|
|
83
|
+
import type { ReducerResult } from "../memory/reducer-types.js";
|
|
84
|
+
import { EMPTY_REDUCER_RESULT } from "../memory/reducer-types.js";
|
|
85
|
+
|
|
86
|
+
let mockReducerResult: ReducerResult = EMPTY_REDUCER_RESULT;
|
|
87
|
+
let lastReducerInput: ReducerPromptInput | null = null;
|
|
88
|
+
let reducerCallCount = 0;
|
|
89
|
+
|
|
90
|
+
mock.module("../memory/reducer.js", () => ({
|
|
91
|
+
runReducer: async (input: ReducerPromptInput) => {
|
|
92
|
+
lastReducerInput = input;
|
|
93
|
+
reducerCallCount++;
|
|
94
|
+
return mockReducerResult;
|
|
95
|
+
},
|
|
96
|
+
}));
|
|
97
|
+
|
|
98
|
+
// ── Imports (after mocks) ─────────────────────────────────────────
|
|
99
|
+
|
|
100
|
+
import { initializeDb, resetDb } from "../memory/db.js";
|
|
101
|
+
import { getSqlite } from "../memory/db-connection.js";
|
|
102
|
+
import { resetTestTables } from "../memory/raw-query.js";
|
|
103
|
+
import {
|
|
104
|
+
findMostRecentDirtyConversation,
|
|
105
|
+
reduceBeforeSwitch,
|
|
106
|
+
} from "../memory/reducer-scheduler.js";
|
|
107
|
+
|
|
108
|
+
initializeDb();
|
|
109
|
+
|
|
110
|
+
// ── Helpers ───────────────────────────────────────────────────────
|
|
111
|
+
|
|
112
|
+
const NOW = 1_700_000_000_000;
|
|
113
|
+
const SCOPE = "default";
|
|
114
|
+
|
|
115
|
+
function insertConversation(
|
|
116
|
+
id: string,
|
|
117
|
+
opts?: {
|
|
118
|
+
dirtyTailMessageId?: string | null;
|
|
119
|
+
updatedAt?: number;
|
|
120
|
+
memoryScopeId?: string;
|
|
121
|
+
contextSummary?: string;
|
|
122
|
+
},
|
|
123
|
+
): void {
|
|
124
|
+
const raw = getSqlite();
|
|
125
|
+
raw.run(
|
|
126
|
+
`INSERT INTO conversations (id, title, created_at, updated_at, conversation_type, source, memory_scope_id, is_auto_title,
|
|
127
|
+
memory_dirty_tail_since_message_id, context_summary)
|
|
128
|
+
VALUES (?, 'Test', ?, ?, 'standard', 'user', ?, 1, ?, ?)`,
|
|
129
|
+
[
|
|
130
|
+
id,
|
|
131
|
+
NOW,
|
|
132
|
+
opts?.updatedAt ?? NOW,
|
|
133
|
+
opts?.memoryScopeId ?? SCOPE,
|
|
134
|
+
opts?.dirtyTailMessageId ?? null,
|
|
135
|
+
opts?.contextSummary ?? null,
|
|
136
|
+
],
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function insertMessage(opts: {
|
|
141
|
+
id: string;
|
|
142
|
+
conversationId: string;
|
|
143
|
+
role?: string;
|
|
144
|
+
content?: string;
|
|
145
|
+
createdAt?: number;
|
|
146
|
+
}): void {
|
|
147
|
+
const raw = getSqlite();
|
|
148
|
+
raw.run(
|
|
149
|
+
`INSERT INTO messages (id, conversation_id, role, content, created_at)
|
|
150
|
+
VALUES (?, ?, ?, ?, ?)`,
|
|
151
|
+
[
|
|
152
|
+
opts.id,
|
|
153
|
+
opts.conversationId,
|
|
154
|
+
opts.role ?? "user",
|
|
155
|
+
opts.content ?? "test message",
|
|
156
|
+
opts.createdAt ?? NOW,
|
|
157
|
+
],
|
|
158
|
+
);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function getRawConversation(conversationId: string): Record<string, unknown> {
|
|
162
|
+
const raw = getSqlite();
|
|
163
|
+
return raw
|
|
164
|
+
.query(`SELECT * FROM conversations WHERE id = ?`)
|
|
165
|
+
.get(conversationId) as Record<string, unknown>;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function makeReducerResult(overrides?: Partial<ReducerResult>): ReducerResult {
|
|
169
|
+
return {
|
|
170
|
+
timeContexts: [],
|
|
171
|
+
openLoops: [],
|
|
172
|
+
archiveObservations: [],
|
|
173
|
+
archiveEpisodes: [],
|
|
174
|
+
...overrides,
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// ── Teardown ──────────────────────────────────────────────────────
|
|
179
|
+
|
|
180
|
+
afterAll(() => {
|
|
181
|
+
resetDb();
|
|
182
|
+
try {
|
|
183
|
+
rmSync(testDir, { recursive: true });
|
|
184
|
+
} catch {
|
|
185
|
+
/* best effort */
|
|
186
|
+
}
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
beforeEach(() => {
|
|
190
|
+
resetTestTables(
|
|
191
|
+
"messages",
|
|
192
|
+
"conversations",
|
|
193
|
+
"memory_jobs",
|
|
194
|
+
"time_contexts",
|
|
195
|
+
"open_loops",
|
|
196
|
+
);
|
|
197
|
+
mockReducerResult = EMPTY_REDUCER_RESULT;
|
|
198
|
+
lastReducerInput = null;
|
|
199
|
+
reducerCallCount = 0;
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
// ── Tests ─────────────────────────────────────────────────────────
|
|
203
|
+
|
|
204
|
+
describe("findMostRecentDirtyConversation", () => {
|
|
205
|
+
test("returns the most recently updated dirty conversation", () => {
|
|
206
|
+
insertConversation("conv-old", {
|
|
207
|
+
dirtyTailMessageId: "msg-old",
|
|
208
|
+
updatedAt: NOW - 5000,
|
|
209
|
+
});
|
|
210
|
+
insertConversation("conv-recent", {
|
|
211
|
+
dirtyTailMessageId: "msg-recent",
|
|
212
|
+
updatedAt: NOW,
|
|
213
|
+
});
|
|
214
|
+
insertConversation("conv-target", { updatedAt: NOW + 1000 });
|
|
215
|
+
|
|
216
|
+
const result = findMostRecentDirtyConversation("conv-target");
|
|
217
|
+
// Should return the most recently updated dirty conversation (ordered by updatedAt DESC)
|
|
218
|
+
expect(result).toBe("conv-recent");
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
test("excludes the target conversation", () => {
|
|
222
|
+
insertConversation("conv-dirty", {
|
|
223
|
+
dirtyTailMessageId: "msg-1",
|
|
224
|
+
updatedAt: NOW,
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
const result = findMostRecentDirtyConversation("conv-dirty");
|
|
228
|
+
expect(result).toBeNull();
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
test("returns null when no dirty conversations exist", () => {
|
|
232
|
+
insertConversation("conv-clean", { updatedAt: NOW });
|
|
233
|
+
|
|
234
|
+
const result = findMostRecentDirtyConversation("conv-target");
|
|
235
|
+
expect(result).toBeNull();
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
test("returns null when only dirty conversation is the target", () => {
|
|
239
|
+
insertConversation("conv-target", {
|
|
240
|
+
dirtyTailMessageId: "msg-1",
|
|
241
|
+
updatedAt: NOW,
|
|
242
|
+
});
|
|
243
|
+
insertConversation("conv-clean", { updatedAt: NOW + 1000 });
|
|
244
|
+
|
|
245
|
+
const result = findMostRecentDirtyConversation("conv-target");
|
|
246
|
+
expect(result).toBeNull();
|
|
247
|
+
});
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
describe("reduceBeforeSwitch — conversation switch", () => {
|
|
251
|
+
test("reduces the dirty conversation before switching", async () => {
|
|
252
|
+
// Previous conversation with dirty messages
|
|
253
|
+
insertConversation("conv-prev", {
|
|
254
|
+
dirtyTailMessageId: "msg-1",
|
|
255
|
+
updatedAt: NOW,
|
|
256
|
+
});
|
|
257
|
+
insertMessage({
|
|
258
|
+
id: "msg-1",
|
|
259
|
+
conversationId: "conv-prev",
|
|
260
|
+
role: "user",
|
|
261
|
+
content: "Hello",
|
|
262
|
+
createdAt: NOW,
|
|
263
|
+
});
|
|
264
|
+
insertMessage({
|
|
265
|
+
id: "msg-2",
|
|
266
|
+
conversationId: "conv-prev",
|
|
267
|
+
role: "assistant",
|
|
268
|
+
content: "Hi there!",
|
|
269
|
+
createdAt: NOW + 1000,
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
// Target conversation
|
|
273
|
+
insertConversation("conv-target", { updatedAt: NOW + 5000 });
|
|
274
|
+
|
|
275
|
+
mockReducerResult = makeReducerResult({
|
|
276
|
+
openLoops: [
|
|
277
|
+
{
|
|
278
|
+
action: "create",
|
|
279
|
+
summary: "User greeted the assistant",
|
|
280
|
+
source: "conversation",
|
|
281
|
+
},
|
|
282
|
+
],
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
const result = await reduceBeforeSwitch("conv-target");
|
|
286
|
+
|
|
287
|
+
// Should have reduced conv-prev
|
|
288
|
+
expect(result).toBe("conv-prev");
|
|
289
|
+
expect(reducerCallCount).toBe(1);
|
|
290
|
+
|
|
291
|
+
// Checkpoint should be advanced
|
|
292
|
+
const conv = getRawConversation("conv-prev");
|
|
293
|
+
expect(conv.memory_reduced_through_message_id).toBe("msg-2");
|
|
294
|
+
expect(conv.memory_dirty_tail_since_message_id).toBeNull();
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
test("skips when no eligible dirty conversation exists", async () => {
|
|
298
|
+
insertConversation("conv-clean", { updatedAt: NOW });
|
|
299
|
+
insertConversation("conv-target", { updatedAt: NOW + 1000 });
|
|
300
|
+
|
|
301
|
+
const result = await reduceBeforeSwitch("conv-target");
|
|
302
|
+
|
|
303
|
+
expect(result).toBeNull();
|
|
304
|
+
expect(reducerCallCount).toBe(0);
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
test("skips when the only dirty conversation is the target", async () => {
|
|
308
|
+
insertConversation("conv-target", {
|
|
309
|
+
dirtyTailMessageId: "msg-1",
|
|
310
|
+
updatedAt: NOW,
|
|
311
|
+
});
|
|
312
|
+
insertMessage({ id: "msg-1", conversationId: "conv-target" });
|
|
313
|
+
|
|
314
|
+
const result = await reduceBeforeSwitch("conv-target");
|
|
315
|
+
|
|
316
|
+
expect(result).toBeNull();
|
|
317
|
+
expect(reducerCallCount).toBe(0);
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
test("does not advance checkpoint when reducer returns empty result", async () => {
|
|
321
|
+
insertConversation("conv-prev", {
|
|
322
|
+
dirtyTailMessageId: "msg-1",
|
|
323
|
+
updatedAt: NOW,
|
|
324
|
+
});
|
|
325
|
+
insertMessage({
|
|
326
|
+
id: "msg-1",
|
|
327
|
+
conversationId: "conv-prev",
|
|
328
|
+
role: "user",
|
|
329
|
+
content: "Hello",
|
|
330
|
+
createdAt: NOW,
|
|
331
|
+
});
|
|
332
|
+
insertConversation("conv-target", { updatedAt: NOW + 5000 });
|
|
333
|
+
|
|
334
|
+
mockReducerResult = EMPTY_REDUCER_RESULT;
|
|
335
|
+
|
|
336
|
+
const result = await reduceBeforeSwitch("conv-target");
|
|
337
|
+
|
|
338
|
+
// Returns null because empty result means nothing was reduced
|
|
339
|
+
expect(result).toBeNull();
|
|
340
|
+
|
|
341
|
+
// Checkpoint should NOT advance
|
|
342
|
+
const conv = getRawConversation("conv-prev");
|
|
343
|
+
expect(conv.memory_reduced_through_message_id).toBeNull();
|
|
344
|
+
expect(conv.memory_dirty_tail_since_message_id).toBe("msg-1");
|
|
345
|
+
});
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
describe("reduceBeforeSwitch — new conversation", () => {
|
|
349
|
+
test("reduces the previous dirty conversation when starting a new one", async () => {
|
|
350
|
+
insertConversation("conv-prev", {
|
|
351
|
+
dirtyTailMessageId: "msg-1",
|
|
352
|
+
updatedAt: NOW,
|
|
353
|
+
});
|
|
354
|
+
insertMessage({
|
|
355
|
+
id: "msg-1",
|
|
356
|
+
conversationId: "conv-prev",
|
|
357
|
+
role: "user",
|
|
358
|
+
content: "Some prior work",
|
|
359
|
+
createdAt: NOW,
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
// The new conversation ID (just created)
|
|
363
|
+
const newConvId = "conv-new";
|
|
364
|
+
insertConversation(newConvId, { updatedAt: NOW + 5000 });
|
|
365
|
+
|
|
366
|
+
mockReducerResult = makeReducerResult({
|
|
367
|
+
timeContexts: [
|
|
368
|
+
{
|
|
369
|
+
action: "create",
|
|
370
|
+
summary: "Prior work in progress",
|
|
371
|
+
source: "conversation",
|
|
372
|
+
activeFrom: NOW,
|
|
373
|
+
activeUntil: NOW + 7 * 24 * 60 * 60 * 1000,
|
|
374
|
+
},
|
|
375
|
+
],
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
const result = await reduceBeforeSwitch(newConvId);
|
|
379
|
+
|
|
380
|
+
expect(result).toBe("conv-prev");
|
|
381
|
+
expect(reducerCallCount).toBe(1);
|
|
382
|
+
|
|
383
|
+
// Verify the previous conversation's checkpoint was advanced
|
|
384
|
+
const conv = getRawConversation("conv-prev");
|
|
385
|
+
expect(conv.memory_reduced_through_message_id).toBe("msg-1");
|
|
386
|
+
expect(conv.memory_dirty_tail_since_message_id).toBeNull();
|
|
387
|
+
});
|
|
388
|
+
});
|
|
389
|
+
|
|
390
|
+
describe("reduceBeforeSwitch — most recent dirty selection", () => {
|
|
391
|
+
test("selects the most recently updated dirty conversation when multiple exist", async () => {
|
|
392
|
+
// Two dirty conversations — conv-newer is more recently updated
|
|
393
|
+
insertConversation("conv-older", {
|
|
394
|
+
dirtyTailMessageId: "msg-older",
|
|
395
|
+
updatedAt: NOW - 10_000,
|
|
396
|
+
});
|
|
397
|
+
insertMessage({
|
|
398
|
+
id: "msg-older",
|
|
399
|
+
conversationId: "conv-older",
|
|
400
|
+
role: "user",
|
|
401
|
+
content: "Older conversation",
|
|
402
|
+
createdAt: NOW - 10_000,
|
|
403
|
+
});
|
|
404
|
+
|
|
405
|
+
insertConversation("conv-newer", {
|
|
406
|
+
dirtyTailMessageId: "msg-newer",
|
|
407
|
+
updatedAt: NOW,
|
|
408
|
+
});
|
|
409
|
+
insertMessage({
|
|
410
|
+
id: "msg-newer",
|
|
411
|
+
conversationId: "conv-newer",
|
|
412
|
+
role: "user",
|
|
413
|
+
content: "Newer conversation",
|
|
414
|
+
createdAt: NOW,
|
|
415
|
+
});
|
|
416
|
+
|
|
417
|
+
insertConversation("conv-target", { updatedAt: NOW + 5000 });
|
|
418
|
+
|
|
419
|
+
mockReducerResult = makeReducerResult();
|
|
420
|
+
|
|
421
|
+
// Even though two are dirty, we only reduce one per switch.
|
|
422
|
+
// The function picks the most recently updated (by updatedAt DESC).
|
|
423
|
+
const result = await reduceBeforeSwitch("conv-target");
|
|
424
|
+
|
|
425
|
+
// Should pick the most recently updated dirty conversation
|
|
426
|
+
expect(result).toBe("conv-newer");
|
|
427
|
+
expect(reducerCallCount).toBe(1);
|
|
428
|
+
expect(lastReducerInput?.conversationId).toBe("conv-newer");
|
|
429
|
+
});
|
|
430
|
+
});
|
|
431
|
+
|
|
432
|
+
describe("reduceBeforeSwitch — error handling", () => {
|
|
433
|
+
test("returns null and continues when reducer throws", async () => {
|
|
434
|
+
insertConversation("conv-prev", {
|
|
435
|
+
dirtyTailMessageId: "msg-1",
|
|
436
|
+
updatedAt: NOW,
|
|
437
|
+
});
|
|
438
|
+
insertMessage({
|
|
439
|
+
id: "msg-1",
|
|
440
|
+
conversationId: "conv-prev",
|
|
441
|
+
role: "user",
|
|
442
|
+
content: "Hello",
|
|
443
|
+
createdAt: NOW,
|
|
444
|
+
});
|
|
445
|
+
insertConversation("conv-target", { updatedAt: NOW + 5000 });
|
|
446
|
+
|
|
447
|
+
// Override mock to throw
|
|
448
|
+
mock.module("../memory/reducer.js", () => ({
|
|
449
|
+
runReducer: async () => {
|
|
450
|
+
reducerCallCount++;
|
|
451
|
+
throw new Error("Provider timeout");
|
|
452
|
+
},
|
|
453
|
+
}));
|
|
454
|
+
|
|
455
|
+
const result = await reduceBeforeSwitch("conv-target");
|
|
456
|
+
|
|
457
|
+
// Should return null (graceful failure, don't block the switch)
|
|
458
|
+
expect(result).toBeNull();
|
|
459
|
+
|
|
460
|
+
// Checkpoint should NOT advance
|
|
461
|
+
const conv = getRawConversation("conv-prev");
|
|
462
|
+
expect(conv.memory_reduced_through_message_id).toBeNull();
|
|
463
|
+
expect(conv.memory_dirty_tail_since_message_id).toBe("msg-1");
|
|
464
|
+
|
|
465
|
+
// Restore normal mock for subsequent tests
|
|
466
|
+
mock.module("../memory/reducer.js", () => ({
|
|
467
|
+
runReducer: async (input: ReducerPromptInput) => {
|
|
468
|
+
lastReducerInput = input;
|
|
469
|
+
reducerCallCount++;
|
|
470
|
+
return mockReducerResult;
|
|
471
|
+
},
|
|
472
|
+
}));
|
|
473
|
+
});
|
|
474
|
+
});
|
|
@@ -26,6 +26,7 @@ mock.module("../util/logger.js", () => ({
|
|
|
26
26
|
import {
|
|
27
27
|
addMessage,
|
|
28
28
|
createConversation,
|
|
29
|
+
deleteConversation,
|
|
29
30
|
getConversation,
|
|
30
31
|
getMessages,
|
|
31
32
|
wipeConversation,
|
|
@@ -436,3 +437,228 @@ describe("wipeConversation", () => {
|
|
|
436
437
|
expect(itemBRow).not.toBeNull();
|
|
437
438
|
});
|
|
438
439
|
});
|
|
440
|
+
|
|
441
|
+
describe("deleteConversation — private scope cleanup", () => {
|
|
442
|
+
beforeEach(() => {
|
|
443
|
+
const db = getDb();
|
|
444
|
+
db.run(`DELETE FROM conversation_starters`);
|
|
445
|
+
db.run(`DELETE FROM memory_item_sources`);
|
|
446
|
+
db.run(`DELETE FROM memory_segments`);
|
|
447
|
+
db.run(`DELETE FROM memory_items`);
|
|
448
|
+
db.run(`DELETE FROM memory_summaries`);
|
|
449
|
+
db.run(`DELETE FROM memory_embeddings`);
|
|
450
|
+
db.run(`DELETE FROM memory_jobs`);
|
|
451
|
+
db.run(`DELETE FROM tool_invocations`);
|
|
452
|
+
db.run(`DELETE FROM llm_request_logs`);
|
|
453
|
+
db.run(`DELETE FROM messages`);
|
|
454
|
+
db.run(`DELETE FROM conversations`);
|
|
455
|
+
});
|
|
456
|
+
|
|
457
|
+
test("sourceless items cleaned up", () => {
|
|
458
|
+
const conv = createConversation({ conversationType: "private" });
|
|
459
|
+
const scopeId = conv.memoryScopeId;
|
|
460
|
+
const now = Date.now();
|
|
461
|
+
|
|
462
|
+
const raw = (
|
|
463
|
+
getDb() as unknown as {
|
|
464
|
+
$client: import("bun:sqlite").Database;
|
|
465
|
+
}
|
|
466
|
+
).$client;
|
|
467
|
+
|
|
468
|
+
// Insert a memory item with matching scopeId but no memory_item_sources
|
|
469
|
+
raw
|
|
470
|
+
.query(
|
|
471
|
+
`INSERT INTO memory_items (id, status, kind, subject, statement, confidence, fingerprint, scope_id, first_seen_at, last_seen_at)
|
|
472
|
+
VALUES ('priv-item-1', 'active', 'fact', 'test', 'test fact', 0.8, 'fp-priv-1', ?, ?, ?)`,
|
|
473
|
+
)
|
|
474
|
+
.run(scopeId, now, now);
|
|
475
|
+
|
|
476
|
+
const result = deleteConversation(conv.id);
|
|
477
|
+
|
|
478
|
+
// Item should be gone
|
|
479
|
+
const itemRow = raw
|
|
480
|
+
.query("SELECT * FROM memory_items WHERE id = 'priv-item-1'")
|
|
481
|
+
.get();
|
|
482
|
+
expect(itemRow).toBeNull();
|
|
483
|
+
|
|
484
|
+
// Its ID should be in orphanedItemIds
|
|
485
|
+
expect(result.orphanedItemIds).toContain("priv-item-1");
|
|
486
|
+
});
|
|
487
|
+
|
|
488
|
+
test("summaries cleaned up", () => {
|
|
489
|
+
const conv = createConversation({ conversationType: "private" });
|
|
490
|
+
const scopeId = conv.memoryScopeId;
|
|
491
|
+
const now = Date.now();
|
|
492
|
+
|
|
493
|
+
const raw = (
|
|
494
|
+
getDb() as unknown as {
|
|
495
|
+
$client: import("bun:sqlite").Database;
|
|
496
|
+
}
|
|
497
|
+
).$client;
|
|
498
|
+
|
|
499
|
+
// Insert a memory summary with matching scopeId
|
|
500
|
+
raw
|
|
501
|
+
.query(
|
|
502
|
+
`INSERT INTO memory_summaries (id, scope, scope_key, summary, token_estimate, version, scope_id, start_at, end_at, created_at, updated_at)
|
|
503
|
+
VALUES ('priv-sum-1', 'global', 'all', 'private summary', 100, 1, ?, ?, ?, ?, ?)`,
|
|
504
|
+
)
|
|
505
|
+
.run(scopeId, now, now, now, now);
|
|
506
|
+
|
|
507
|
+
const result = deleteConversation(conv.id);
|
|
508
|
+
|
|
509
|
+
// Summary should be gone
|
|
510
|
+
const summaryRow = raw
|
|
511
|
+
.query("SELECT * FROM memory_summaries WHERE id = 'priv-sum-1'")
|
|
512
|
+
.get();
|
|
513
|
+
expect(summaryRow).toBeNull();
|
|
514
|
+
|
|
515
|
+
// Its ID should be in deletedSummaryIds
|
|
516
|
+
expect(result.deletedSummaryIds).toContain("priv-sum-1");
|
|
517
|
+
});
|
|
518
|
+
|
|
519
|
+
test("standard conversations unaffected", async () => {
|
|
520
|
+
const conv = createConversation("standard test");
|
|
521
|
+
const now = Date.now();
|
|
522
|
+
|
|
523
|
+
const raw = (
|
|
524
|
+
getDb() as unknown as {
|
|
525
|
+
$client: import("bun:sqlite").Database;
|
|
526
|
+
}
|
|
527
|
+
).$client;
|
|
528
|
+
|
|
529
|
+
// Insert items with scopeId = "default"
|
|
530
|
+
raw
|
|
531
|
+
.query(
|
|
532
|
+
`INSERT INTO memory_items (id, status, kind, subject, statement, confidence, fingerprint, scope_id, first_seen_at, last_seen_at)
|
|
533
|
+
VALUES ('default-item-1', 'active', 'fact', 'test', 'test fact', 0.8, 'fp-default', 'default', ?, ?)`,
|
|
534
|
+
)
|
|
535
|
+
.run(now, now);
|
|
536
|
+
|
|
537
|
+
deleteConversation(conv.id);
|
|
538
|
+
|
|
539
|
+
// Default-scope items should still exist
|
|
540
|
+
const itemRow = raw
|
|
541
|
+
.query("SELECT * FROM memory_items WHERE id = 'default-item-1'")
|
|
542
|
+
.get();
|
|
543
|
+
expect(itemRow).not.toBeNull();
|
|
544
|
+
});
|
|
545
|
+
|
|
546
|
+
test("embeddings cleaned up", () => {
|
|
547
|
+
const conv = createConversation({ conversationType: "private" });
|
|
548
|
+
const scopeId = conv.memoryScopeId;
|
|
549
|
+
const now = Date.now();
|
|
550
|
+
|
|
551
|
+
const raw = (
|
|
552
|
+
getDb() as unknown as {
|
|
553
|
+
$client: import("bun:sqlite").Database;
|
|
554
|
+
}
|
|
555
|
+
).$client;
|
|
556
|
+
|
|
557
|
+
// Insert a memory item with matching scopeId
|
|
558
|
+
raw
|
|
559
|
+
.query(
|
|
560
|
+
`INSERT INTO memory_items (id, status, kind, subject, statement, confidence, fingerprint, scope_id, first_seen_at, last_seen_at)
|
|
561
|
+
VALUES ('priv-item-emb', 'active', 'fact', 'test', 'test fact', 0.8, 'fp-priv-emb', ?, ?, ?)`,
|
|
562
|
+
)
|
|
563
|
+
.run(scopeId, now, now);
|
|
564
|
+
|
|
565
|
+
// Insert a corresponding embedding
|
|
566
|
+
raw
|
|
567
|
+
.query(
|
|
568
|
+
`INSERT INTO memory_embeddings (id, target_type, target_id, provider, model, dimensions, created_at, updated_at)
|
|
569
|
+
VALUES ('emb-priv-item', 'item', 'priv-item-emb', 'test', 'test', 384, ?, ?)`,
|
|
570
|
+
)
|
|
571
|
+
.run(now, now);
|
|
572
|
+
|
|
573
|
+
deleteConversation(conv.id);
|
|
574
|
+
|
|
575
|
+
// Both item and embedding should be deleted
|
|
576
|
+
const itemRow = raw
|
|
577
|
+
.query("SELECT * FROM memory_items WHERE id = 'priv-item-emb'")
|
|
578
|
+
.get();
|
|
579
|
+
expect(itemRow).toBeNull();
|
|
580
|
+
|
|
581
|
+
const embeddingRow = raw
|
|
582
|
+
.query("SELECT * FROM memory_embeddings WHERE id = 'emb-priv-item'")
|
|
583
|
+
.get();
|
|
584
|
+
expect(embeddingRow).toBeNull();
|
|
585
|
+
});
|
|
586
|
+
|
|
587
|
+
test("conversationStarters cleaned up", () => {
|
|
588
|
+
const conv = createConversation({ conversationType: "private" });
|
|
589
|
+
const scopeId = conv.memoryScopeId;
|
|
590
|
+
const now = Date.now();
|
|
591
|
+
|
|
592
|
+
const raw = (
|
|
593
|
+
getDb() as unknown as {
|
|
594
|
+
$client: import("bun:sqlite").Database;
|
|
595
|
+
}
|
|
596
|
+
).$client;
|
|
597
|
+
|
|
598
|
+
// Insert a conversation_starters row with the private scopeId
|
|
599
|
+
raw
|
|
600
|
+
.query(
|
|
601
|
+
`INSERT INTO conversation_starters (id, label, prompt, generation_batch, scope_id, card_type, created_at)
|
|
602
|
+
VALUES ('starter-1', 'Test starter', 'Tell me about tests', 1, ?, 'chip', ?)`,
|
|
603
|
+
)
|
|
604
|
+
.run(scopeId, now);
|
|
605
|
+
|
|
606
|
+
// Also insert a default-scope starter that should NOT be deleted
|
|
607
|
+
raw
|
|
608
|
+
.query(
|
|
609
|
+
`INSERT INTO conversation_starters (id, label, prompt, generation_batch, scope_id, card_type, created_at)
|
|
610
|
+
VALUES ('starter-default', 'Default starter', 'Hello', 1, 'default', 'chip', ?)`,
|
|
611
|
+
)
|
|
612
|
+
.run(now);
|
|
613
|
+
|
|
614
|
+
deleteConversation(conv.id);
|
|
615
|
+
|
|
616
|
+
// Private-scope starter should be gone
|
|
617
|
+
const starterRow = raw
|
|
618
|
+
.query("SELECT * FROM conversation_starters WHERE id = 'starter-1'")
|
|
619
|
+
.get();
|
|
620
|
+
expect(starterRow).toBeNull();
|
|
621
|
+
|
|
622
|
+
// Default-scope starter should still exist
|
|
623
|
+
const defaultStarterRow = raw
|
|
624
|
+
.query("SELECT * FROM conversation_starters WHERE id = 'starter-default'")
|
|
625
|
+
.get();
|
|
626
|
+
expect(defaultStarterRow).not.toBeNull();
|
|
627
|
+
});
|
|
628
|
+
|
|
629
|
+
test("no duplicate IDs", async () => {
|
|
630
|
+
const conv = createConversation({ conversationType: "private" });
|
|
631
|
+
const scopeId = conv.memoryScopeId;
|
|
632
|
+
const msg = await addMessage(conv.id, "user", "hello");
|
|
633
|
+
const now = Date.now();
|
|
634
|
+
|
|
635
|
+
const raw = (
|
|
636
|
+
getDb() as unknown as {
|
|
637
|
+
$client: import("bun:sqlite").Database;
|
|
638
|
+
}
|
|
639
|
+
).$client;
|
|
640
|
+
|
|
641
|
+
// Insert a memory item with the private scopeId AND a source linking to the message
|
|
642
|
+
raw
|
|
643
|
+
.query(
|
|
644
|
+
`INSERT INTO memory_items (id, status, kind, subject, statement, confidence, fingerprint, scope_id, first_seen_at, last_seen_at)
|
|
645
|
+
VALUES ('priv-item-dup', 'active', 'fact', 'test', 'test fact', 0.8, 'fp-priv-dup', ?, ?, ?)`,
|
|
646
|
+
)
|
|
647
|
+
.run(scopeId, now, now);
|
|
648
|
+
|
|
649
|
+
raw
|
|
650
|
+
.query(
|
|
651
|
+
`INSERT INTO memory_item_sources (memory_item_id, message_id, created_at) VALUES ('priv-item-dup', ?, ?)`,
|
|
652
|
+
)
|
|
653
|
+
.run(msg.id, now);
|
|
654
|
+
|
|
655
|
+
const result = deleteConversation(conv.id);
|
|
656
|
+
|
|
657
|
+
// The item ID should appear exactly once in orphanedItemIds (caught by
|
|
658
|
+
// source-based cleanup, not double-counted by scope sweep).
|
|
659
|
+
const count = result.orphanedItemIds.filter(
|
|
660
|
+
(id) => id === "priv-item-dup",
|
|
661
|
+
).length;
|
|
662
|
+
expect(count).toBe(1);
|
|
663
|
+
});
|
|
664
|
+
});
|