@vellumai/assistant 0.10.4-dev.202607010146.a37a2fa → 0.10.4-dev.202607010319.f2b69c7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/package.json +1 -1
  2. package/src/__tests__/clear-all-lexical.test.ts +93 -0
  3. package/src/__tests__/conversation-wipe.test.ts +20 -5
  4. package/src/__tests__/edit-propagation.test.ts +56 -2
  5. package/src/__tests__/job-handler-registry-guard.test.ts +1 -0
  6. package/src/__tests__/lexical-index-dual-write.test.ts +418 -0
  7. package/src/__tests__/plugin-disabled-state.test.ts +21 -0
  8. package/src/__tests__/plugin-import-boundary-guard.test.ts +1 -4
  9. package/src/daemon/conversation-agent-loop-handlers.ts +9 -0
  10. package/src/daemon/conversation-history.ts +6 -0
  11. package/src/persistence/conversation-crud.ts +49 -0
  12. package/src/persistence/embeddings/__tests__/messages-lexical-index.test.ts +27 -0
  13. package/src/persistence/embeddings/messages-lexical-index.ts +28 -0
  14. package/src/persistence/jobs-store.ts +1 -0
  15. package/src/persistence/memory-lifecycle-hooks.test.ts +50 -0
  16. package/src/persistence/memory-lifecycle-hooks.ts +41 -0
  17. package/src/plugins/defaults/index.ts +10 -0
  18. package/src/plugins/defaults/memory/context-search/agent-runner.ts +1 -1
  19. package/src/plugins/defaults/memory/frontmatter.ts +50 -0
  20. package/src/plugins/defaults/memory/graph/consolidation.ts +3 -5
  21. package/src/plugins/defaults/memory/graph/extraction.ts +2 -5
  22. package/src/plugins/defaults/memory/graph/narrative.ts +3 -5
  23. package/src/plugins/defaults/memory/graph/pattern-scan.ts +3 -5
  24. package/src/plugins/defaults/memory/graph/retriever.test.ts +1 -6
  25. package/src/plugins/defaults/memory/graph/retriever.ts +2 -5
  26. package/src/plugins/defaults/memory/graph/tools.ts +1 -1
  27. package/src/plugins/defaults/memory/job-handlers/__tests__/lexical-cleanup-disabled.test.ts +197 -0
  28. package/src/plugins/defaults/memory/job-handlers/index-message-lexical.ts +190 -3
  29. package/src/plugins/defaults/memory/job-handlers.ts +5 -0
  30. package/src/plugins/defaults/memory/llm-helpers.ts +76 -0
  31. package/src/plugins/defaults/memory/persistence-hooks.ts +52 -1
  32. package/src/plugins/defaults/memory/routes/__tests__/memory-v2-simulate-route.test.ts +4 -6
  33. package/src/plugins/defaults/memory/v2/__tests__/migration.test.ts +2 -13
  34. package/src/plugins/defaults/memory/v2/__tests__/router.test.ts +4 -5
  35. package/src/plugins/defaults/memory/v2/__tests__/sweep-job.test.ts +1 -8
  36. package/src/plugins/defaults/memory/v2/frontmatter-sweep.ts +1 -1
  37. package/src/plugins/defaults/memory/v2/migration.ts +2 -5
  38. package/src/plugins/defaults/memory/v2/page-store.ts +1 -1
  39. package/src/plugins/defaults/memory/v2/router.ts +5 -5
  40. package/src/plugins/defaults/memory/v2/sweep-job.ts +6 -6
  41. package/src/plugins/defaults/memory/v3/__tests__/carry-integration.test.ts +5 -9
  42. package/src/plugins/defaults/memory/v3/__tests__/live-integration.test.ts +1 -3
  43. package/src/plugins/defaults/memory/v3/__tests__/orchestrate.test.ts +1 -3
  44. package/src/plugins/defaults/memory/v3/__tests__/pool-select.test.ts +1 -4
  45. package/src/plugins/defaults/memory/v3/__tests__/shadow-integration.test.ts +9 -13
  46. package/src/plugins/defaults/memory/v3/card.ts +1 -4
  47. package/src/plugins/defaults/memory/v3/edge.ts +1 -1
  48. package/src/plugins/defaults/memory/v3/pool-select.test.ts +7 -9
  49. package/src/plugins/defaults/memory/v3/pool-select.ts +6 -6
  50. package/src/plugins/defaults/memory/v3-eval/eval-packets.ts +1 -4
  51. package/src/runtime/routes/conversation-management-routes.ts +3 -0
  52. package/src/runtime/routes/conversations-import-routes.ts +5 -0
  53. package/src/runtime/routes/inbound-stages/edit-intercept.ts +6 -0
  54. package/src/providers/cache-control.ts +0 -26
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vellumai/assistant",
3
- "version": "0.10.4-dev.202607010146.a37a2fa",
3
+ "version": "0.10.4-dev.202607010319.f2b69c7",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "exports": {
@@ -0,0 +1,93 @@
1
+ // clearAll() must drop the memory feature's bulk per-message index (the whole
2
+ // lexical Qdrant collection) — a "delete all" leaves no conversation ids to key
3
+ // per-conversation purges on, so the points would otherwise survive a
4
+ // "permanently delete everything" action. clearAll routes this through the
5
+ // `MemoryPersistenceHooks.onAllConversationsCleared` seam (persistence stays
6
+ // decoupled from the plugin); the memory plugin's impl calls
7
+ // `clearMessagesLexicalIndex`. This asserts the full chain fires.
8
+
9
+ import { beforeEach, describe, expect, mock, test } from "bun:test";
10
+
11
+ import { makeMockLogger } from "./helpers/mock-logger.js";
12
+
13
+ mock.module("../util/logger.js", () => ({
14
+ getLogger: () => makeMockLogger(),
15
+ }));
16
+
17
+ // Spy on `clearMessagesLexicalIndex` (the plugin's `onAllConversationsCleared`
18
+ // impl calls it) while keeping the rest of the module REAL. `mock.module` is
19
+ // process-global and not undone by `mock.restore()`, so a partial mock that
20
+ // drops the other exports would leak into any later test file in the same
21
+ // process that imports them (e.g. the handler tests importing
22
+ // `indexMessageLexicalJob`). Spread the real module so only this one export is
23
+ // replaced. Importing the real module here is safe: its deps
24
+ // (`@qdrant/js-client-rest`, `uuid`, the local TF-IDF encoder) resolve in the
25
+ // worktree.
26
+ const actualLexical =
27
+ await import("../plugins/defaults/memory/job-handlers/index-message-lexical.js");
28
+ let clearCalls = 0;
29
+ mock.module(
30
+ "../plugins/defaults/memory/job-handlers/index-message-lexical.js",
31
+ () => ({
32
+ ...actualLexical,
33
+ clearMessagesLexicalIndex: async () => {
34
+ clearCalls += 1;
35
+ },
36
+ }),
37
+ );
38
+
39
+ import { clearAll } from "../persistence/conversation-crud.js";
40
+ import { initializeDb } from "../persistence/db-init.js";
41
+ import {
42
+ getMemoryPersistenceHooks,
43
+ registerMemoryPersistenceHooks,
44
+ resetMemoryPersistenceHooksForTests,
45
+ } from "../persistence/memory-lifecycle-hooks.js";
46
+ import { registerDefaultPluginPersistenceHooks } from "../plugins/defaults/index.js";
47
+
48
+ await initializeDb();
49
+
50
+ describe("clearAll bulk lexical index cleanup", () => {
51
+ beforeEach(() => {
52
+ clearCalls = 0;
53
+ // Register the real memory persistence hooks so `onAllConversationsCleared`
54
+ // routes to the plugin impl (which calls the spied clear helper).
55
+ registerDefaultPluginPersistenceHooks();
56
+ });
57
+
58
+ test("clearAll fires onAllConversationsCleared, which clears the lexical index", async () => {
59
+ await clearAll();
60
+ expect(clearCalls).toBe(1);
61
+ });
62
+
63
+ test("clearAll AWAITS the collection drop before returning", async () => {
64
+ // The drop must complete before clearAll resolves — otherwise a write right
65
+ // after clear-all could land in a collection that is about to be dropped.
66
+ // Register a hook whose drop yields to the microtask queue before finishing;
67
+ // if clearAll did not await, `dropCompleted` would still be false here.
68
+ let dropCompleted = false;
69
+ registerMemoryPersistenceHooks({
70
+ ...getMemoryPersistenceHooks(),
71
+ async onAllConversationsCleared() {
72
+ await new Promise((r) => setTimeout(r, 10));
73
+ dropCompleted = true;
74
+ },
75
+ });
76
+
77
+ await clearAll();
78
+ expect(dropCompleted).toBe(true);
79
+
80
+ registerDefaultPluginPersistenceHooks();
81
+ });
82
+
83
+ test("clearAll is a safe no-op when the memory hooks are not registered", async () => {
84
+ // Persistence must work with no memory present — the seam falls through to
85
+ // its no-op and clearAll does not touch the lexical index.
86
+ resetMemoryPersistenceHooksForTests();
87
+ clearCalls = 0;
88
+ await clearAll();
89
+ expect(clearCalls).toBe(0);
90
+ // Restore for any later test in the same process.
91
+ registerDefaultPluginPersistenceHooks();
92
+ });
93
+ });
@@ -118,16 +118,31 @@ describe("wipeConversation", () => {
118
118
  }
119
119
  ).$client;
120
120
 
121
- // Both jobs should be failed with conversation_wiped error
122
121
  const jobs = raw
123
- .query("SELECT status, last_error FROM memory_jobs")
124
- .all() as Array<{ status: string; last_error: string | null }>;
125
-
126
- for (const job of jobs) {
122
+ .query("SELECT type, status, last_error FROM memory_jobs")
123
+ .all() as Array<{
124
+ type: string;
125
+ status: string;
126
+ last_error: string | null;
127
+ }>;
128
+
129
+ // The pre-existing jobs should be failed with the conversation_wiped error.
130
+ for (const job of jobs.filter(
131
+ (j) => j.type !== "purge_conversation_lexical",
132
+ )) {
127
133
  expect(job.status).toBe("failed");
128
134
  expect(job.last_error).toContain("conversation_wiped");
129
135
  }
130
136
 
137
+ // Wiping also enqueues a lexical-index purge, which must SURVIVE the
138
+ // job-cancellation pass (it is enqueued after cancellation) so the worker
139
+ // can delete the conversation's Qdrant points.
140
+ const purgeJobs = jobs.filter(
141
+ (j) => j.type === "purge_conversation_lexical",
142
+ );
143
+ expect(purgeJobs).toHaveLength(1);
144
+ expect(purgeJobs[0].status).toBe("pending");
145
+
131
146
  expect(result.cancelledJobCount).toBeGreaterThanOrEqual(2);
132
147
  });
133
148
 
@@ -21,10 +21,10 @@ mock.module("../util/logger.js", () => ({
21
21
  import { readSlackMetadata } from "../messaging/providers/slack/message-metadata.js";
22
22
  import { addMessage } from "../persistence/conversation-crud.js";
23
23
  import { getConversationByKey } from "../persistence/conversation-key-store.js";
24
- import { getDb } from "../persistence/db-connection.js";
24
+ import { getDb, getMemoryDb } from "../persistence/db-connection.js";
25
25
  import { initializeDb } from "../persistence/db-init.js";
26
26
  import { linkMessage, recordInbound } from "../persistence/delivery-crud.js";
27
- import { messages } from "../persistence/schema/index.js";
27
+ import { memoryJobs, messages } from "../persistence/schema/index.js";
28
28
  import { handleEditIntercept } from "../runtime/routes/inbound-stages/edit-intercept.js";
29
29
 
30
30
  await initializeDb();
@@ -35,6 +35,25 @@ function resetTables(): void {
35
35
  db.run("DELETE FROM messages");
36
36
  db.run("DELETE FROM conversation_keys");
37
37
  db.run("DELETE FROM conversations");
38
+ // memory_jobs lives in the dedicated memory connection.
39
+ getMemoryDb()!.run("DELETE FROM memory_jobs");
40
+ }
41
+
42
+ function lexicalIndexJobMessageIds(): string[] {
43
+ return getMemoryDb()!
44
+ .select({ payload: memoryJobs.payload })
45
+ .from(memoryJobs)
46
+ .where(eq(memoryJobs.type, "index_message_lexical"))
47
+ .all()
48
+ .map((r) => {
49
+ try {
50
+ return (
51
+ (JSON.parse(r.payload) as { messageId?: string }).messageId ?? ""
52
+ );
53
+ } catch {
54
+ return "";
55
+ }
56
+ });
38
57
  }
39
58
 
40
59
  interface SeededFixture {
@@ -276,6 +295,41 @@ describe("Slack edit propagation", () => {
276
295
  expect(after.content).toBe(before.content);
277
296
  // No metadata mutation either -- the write is fully skipped.
278
297
  expect(after.metadata).toBe(before.metadata);
298
+
299
+ // A no-op edit changes no searchable text, so it must NOT enqueue a lexical
300
+ // reindex.
301
+ expect(lexicalIndexJobMessageIds()).not.toContain(seeded.messageId);
302
+ });
303
+
304
+ test("a content-changing edit enqueues a lexical reindex for the message", async () => {
305
+ // Regression: channel edits update the row in-place via
306
+ // updateMessageContentAndMetadata / updateMessageContent, bypassing
307
+ // onMessagePersisted. The lexical index must be refreshed so the old Qdrant
308
+ // point does not go stale against the edited (FTS-updated) content.
309
+ const seeded = await seedSlackMessage({
310
+ conversationExternalId: "C0123CHANNEL",
311
+ channelTs: "1234.5678",
312
+ initialContent: "original text",
313
+ });
314
+
315
+ // The seed used skipIndexing, so no lexical job exists yet.
316
+ expect(lexicalIndexJobMessageIds()).not.toContain(seeded.messageId);
317
+
318
+ await handleEditIntercept({
319
+ sourceChannel: "slack",
320
+ conversationExternalId: seeded.conversationExternalId,
321
+ externalMessageId: nextEditEventId(),
322
+ sourceMessageId: seeded.channelTs,
323
+ canonicalAssistantId: "self",
324
+ assistantId: "self",
325
+ content: "edited searchable text",
326
+ });
327
+
328
+ expect(readMessageRow(seeded.messageId).content).toBe(
329
+ "edited searchable text",
330
+ );
331
+ // The edit reindexed the message into the lexical index.
332
+ expect(lexicalIndexJobMessageIds()).toContain(seeded.messageId);
279
333
  });
280
334
 
281
335
  // The lookup retries 5 times with 2s backoff (~10s total) before giving up,
@@ -45,6 +45,7 @@ const MEMORY_JOB_TYPES = [
45
45
  "memory_retrospective",
46
46
  "index_message_lexical",
47
47
  "purge_conversation_lexical",
48
+ "delete_message_lexical",
48
49
  "backfill_lexical_index",
49
50
  ].sort();
50
51
 
@@ -0,0 +1,418 @@
1
+ // Integration coverage for the messages lexical-index dual-write wiring:
2
+ // - a real persist (`addMessage` → `onMessagePersisted`) enqueues one
3
+ // `index_message_lexical` job for the message when memory is enabled, and
4
+ // none when memory is disabled;
5
+ // - forking a conversation copies message rows WITHOUT routing through
6
+ // `onMessagePersisted`, so a fork enqueues ZERO `index_message_lexical`
7
+ // jobs (the fork-exclusion regression);
8
+ // - wiping a conversation enqueues one `purge_conversation_lexical` job.
9
+ //
10
+ // The heavy segment indexer (`indexMessageNow`) is stubbed to a no-op so these
11
+ // tests exercise only the lexical enqueue seam, not the embedding/extraction
12
+ // machinery. Memory config is real (default-enabled), flipped on disk for the
13
+ // disabled case.
14
+
15
+ import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
16
+
17
+ import { eq } from "drizzle-orm";
18
+
19
+ import { makeMockLogger } from "./helpers/mock-logger.js";
20
+
21
+ mock.module("../util/logger.js", () => ({
22
+ getLogger: () => makeMockLogger(),
23
+ }));
24
+
25
+ // Stub the segment indexer so `onMessagePersisted` runs cheaply. The lexical
26
+ // enqueue in the hook is a separate call and still fires. Other exports are
27
+ // provided so any transitive importer of this module resolves. `throwFromIndex`
28
+ // lets a test simulate a transient segment-indexing failure to prove the
29
+ // lexical enqueue survives it.
30
+ let throwFromIndex = false;
31
+ mock.module("../plugins/defaults/memory/indexer.js", () => ({
32
+ MIN_SEGMENT_CHARS: 50,
33
+ indexMessageNow: async () => {
34
+ if (throwFromIndex) throw new Error("simulated segment-indexing failure");
35
+ return { indexedSegments: 0, enqueuedJobs: 0 };
36
+ },
37
+ enqueueBackfillJob: () => "",
38
+ enqueueRebuildIndexJob: () => "",
39
+ }));
40
+
41
+ import {
42
+ invalidateConfigCache,
43
+ loadRawConfig,
44
+ saveRawConfig,
45
+ } from "../config/loader.js";
46
+ import { consolidateAssistantMessages } from "../daemon/conversation-history.js";
47
+ import {
48
+ addMessage,
49
+ createConversation,
50
+ deleteConversation,
51
+ deleteConversationGently,
52
+ deleteLastExchange,
53
+ deleteMessageById,
54
+ forkConversation,
55
+ getMessages,
56
+ updateMessageContent,
57
+ wipeConversation,
58
+ } from "../persistence/conversation-crud.js";
59
+ import { getMemoryDb } from "../persistence/db-connection.js";
60
+ import { initializeDb } from "../persistence/db-init.js";
61
+ import type { MemoryJobType } from "../persistence/jobs-store.js";
62
+ import {
63
+ getMemoryPersistenceHooks,
64
+ type MemoryPersistenceHooks,
65
+ registerMemoryPersistenceHooks,
66
+ } from "../persistence/memory-lifecycle-hooks.js";
67
+ import { memoryJobs } from "../persistence/schema/index.js";
68
+ import { registerDefaultPluginPersistenceHooks } from "../plugins/defaults/index.js";
69
+ import { enqueueLexicalIndexForMessage } from "../plugins/defaults/memory/job-handlers/index-message-lexical.js";
70
+
71
+ await initializeDb();
72
+
73
+ function countJobs(type: MemoryJobType, conversationId?: string): number {
74
+ const rows = getMemoryDb()!
75
+ .select({ payload: memoryJobs.payload })
76
+ .from(memoryJobs)
77
+ .where(eq(memoryJobs.type, type))
78
+ .all();
79
+ if (conversationId == null) return rows.length;
80
+ return rows.filter((r) => {
81
+ try {
82
+ return (
83
+ (JSON.parse(r.payload) as { conversationId?: string })
84
+ .conversationId === conversationId
85
+ );
86
+ } catch {
87
+ return false;
88
+ }
89
+ }).length;
90
+ }
91
+
92
+ function lexicalJobMessageIds(): string[] {
93
+ return getMemoryDb()!
94
+ .select({ payload: memoryJobs.payload })
95
+ .from(memoryJobs)
96
+ .where(eq(memoryJobs.type, "index_message_lexical"))
97
+ .all()
98
+ .map((r) => {
99
+ try {
100
+ return (
101
+ (JSON.parse(r.payload) as { messageId?: string }).messageId ?? ""
102
+ );
103
+ } catch {
104
+ return "";
105
+ }
106
+ });
107
+ }
108
+
109
+ function resetMemoryJobs(): void {
110
+ getMemoryDb()!.delete(memoryJobs).run();
111
+ }
112
+
113
+ function setMemoryEnabled(enabled: boolean): void {
114
+ const raw = loadRawConfig();
115
+ const memory =
116
+ raw.memory && typeof raw.memory === "object"
117
+ ? (raw.memory as Record<string, unknown>)
118
+ : {};
119
+ saveRawConfig({ ...raw, memory: { ...memory, enabled } });
120
+ invalidateConfigCache();
121
+ }
122
+
123
+ describe("messages lexical-index dual-write", () => {
124
+ beforeEach(() => {
125
+ resetMemoryJobs();
126
+ registerDefaultPluginPersistenceHooks();
127
+ });
128
+
129
+ afterEach(() => {
130
+ // Restore the default-enabled config so a disabled-case test cannot leak
131
+ // into later tests sharing this process.
132
+ setMemoryEnabled(true);
133
+ throwFromIndex = false;
134
+ });
135
+
136
+ test("addMessage enqueues one index_message_lexical job for the persisted message", async () => {
137
+ const conv = createConversation("Lexical persist thread");
138
+ const message = await addMessage(conv.id, "user", "hello lexical world");
139
+
140
+ const ids = lexicalJobMessageIds();
141
+ expect(ids).toContain(message.id);
142
+ expect(ids.filter((id) => id === message.id)).toHaveLength(1);
143
+ });
144
+
145
+ test("lexical job is still enqueued when segment indexing (indexMessageNow) throws", async () => {
146
+ // The sparse lexical job is independent of the dense embedding path, so a
147
+ // transient segment-indexing failure must not leave the message missing
148
+ // from the lexical index. `addMessage` catches the hook throw as non-fatal.
149
+ throwFromIndex = true;
150
+ const conv = createConversation("Segment failure thread");
151
+ const message = await addMessage(
152
+ conv.id,
153
+ "user",
154
+ "index me despite failure",
155
+ );
156
+
157
+ expect(lexicalJobMessageIds()).toContain(message.id);
158
+ });
159
+
160
+ test("addMessage enqueues NO index_message_lexical job when memory is disabled", async () => {
161
+ setMemoryEnabled(false);
162
+ const conv = createConversation("Disabled memory thread");
163
+ await addMessage(conv.id, "user", "should not be lexically indexed");
164
+
165
+ expect(countJobs("index_message_lexical")).toBe(0);
166
+ });
167
+
168
+ test("enqueueLexicalIndexForMessage no-ops on an empty message id", () => {
169
+ enqueueLexicalIndexForMessage("");
170
+ expect(countJobs("index_message_lexical")).toBe(0);
171
+ });
172
+
173
+ test("forking a conversation enqueues ZERO index_message_lexical jobs", async () => {
174
+ const source = createConversation("Fork exclusion thread");
175
+ // Persist source messages through the real path so each enqueues a lexical
176
+ // job — this makes the "fork adds none" assertion meaningful.
177
+ await addMessage(source.id, "user", "Draft a launch plan");
178
+ await addMessage(source.id, "assistant", "Here is a first pass.");
179
+ await addMessage(source.id, "user", "Fork from here");
180
+
181
+ const beforeFork = countJobs("index_message_lexical");
182
+ expect(beforeFork).toBe(3);
183
+
184
+ // The fork copies every source row directly, bypassing onMessagePersisted.
185
+ const fork = forkConversation({ conversationId: source.id });
186
+ expect(fork.id).not.toBe(source.id);
187
+
188
+ // No new lexical index jobs were enqueued for the copied fork rows.
189
+ expect(countJobs("index_message_lexical")).toBe(beforeFork);
190
+ // ...and specifically none of the fork's own message ids were enqueued.
191
+ const enqueuedMessageIds = new Set(lexicalJobMessageIds());
192
+ for (const forkMessage of getMessages(fork.id)) {
193
+ expect(enqueuedMessageIds.has(forkMessage.id)).toBe(false);
194
+ }
195
+ });
196
+
197
+ test("wipeConversation enqueues a purge_conversation_lexical job for the conversation", async () => {
198
+ const conv = createConversation("Lexical wipe thread");
199
+ await addMessage(conv.id, "user", "index me then wipe me");
200
+
201
+ resetMemoryJobs();
202
+ wipeConversation(conv.id);
203
+
204
+ expect(countJobs("purge_conversation_lexical", conv.id)).toBe(1);
205
+ });
206
+
207
+ test("deleteConversation (direct, no route) enqueues a purge for the conversation", async () => {
208
+ // Retrospective startup cleanup calls deleteConversation directly, bypassing
209
+ // the HTTP route — the purge must still fire from the shared primitive.
210
+ const conv = createConversation("Direct delete thread");
211
+ await addMessage(conv.id, "user", "index me then delete me");
212
+
213
+ resetMemoryJobs();
214
+ deleteConversation(conv.id);
215
+
216
+ expect(countJobs("purge_conversation_lexical", conv.id)).toBe(1);
217
+ });
218
+
219
+ test("deleteConversationGently (retrospective GC) enqueues a purge for the conversation", async () => {
220
+ const conv = createConversation("Gentle delete thread");
221
+ await addMessage(conv.id, "user", "index me then gently delete me");
222
+
223
+ resetMemoryJobs();
224
+ await deleteConversationGently(conv.id);
225
+
226
+ expect(countJobs("purge_conversation_lexical", conv.id)).toBe(1);
227
+ });
228
+
229
+ test("deleteMessageById enqueues a delete_message_lexical job for the removed message", async () => {
230
+ const conv = createConversation("Single delete thread");
231
+ const message = await addMessage(conv.id, "user", "delete just me");
232
+
233
+ resetMemoryJobs();
234
+ deleteMessageById(message.id);
235
+
236
+ const ids = getMemoryDb()!
237
+ .select({ payload: memoryJobs.payload })
238
+ .from(memoryJobs)
239
+ .where(eq(memoryJobs.type, "delete_message_lexical"))
240
+ .all()
241
+ .map((r) => (JSON.parse(r.payload) as { messageId?: string }).messageId);
242
+ expect(ids).toEqual([message.id]);
243
+ });
244
+
245
+ test("deleteMessageById for a nonexistent message enqueues nothing", () => {
246
+ resetMemoryJobs();
247
+ deleteMessageById("does-not-exist");
248
+ expect(countJobs("delete_message_lexical")).toBe(0);
249
+ });
250
+
251
+ test("deleteLastExchange enqueues delete_message_lexical for every removed message", async () => {
252
+ // Undo bulk-deletes the last user turn + everything after it via a single
253
+ // `tx.delete(messages)`, bypassing `deleteMessageById`. It must still purge
254
+ // each removed message's lexical point.
255
+ const conv = createConversation("Undo thread");
256
+ await addMessage(conv.id, "user", "first user turn");
257
+ await addMessage(conv.id, "assistant", "first assistant reply");
258
+ const lastUser = await addMessage(conv.id, "user", "undo this turn");
259
+ const lastAssistant = await addMessage(
260
+ conv.id,
261
+ "assistant",
262
+ "and this reply",
263
+ );
264
+
265
+ resetMemoryJobs();
266
+ const removed = deleteLastExchange(conv.id);
267
+ expect(removed).toBe(2);
268
+
269
+ const deletedIds = getMemoryDb()!
270
+ .select({ payload: memoryJobs.payload })
271
+ .from(memoryJobs)
272
+ .where(eq(memoryJobs.type, "delete_message_lexical"))
273
+ .all()
274
+ .map((r) => (JSON.parse(r.payload) as { messageId?: string }).messageId);
275
+ expect(new Set(deletedIds)).toEqual(
276
+ new Set([lastUser.id, lastAssistant.id]),
277
+ );
278
+ });
279
+
280
+ test("updateMessageContent (CRUD primitive) does not enqueue a reindex on its own", async () => {
281
+ // The reindex is owned by the semantic seams (streaming finalize, edits,
282
+ // consolidation), NOT the low-level primitive — this keeps mid-stream
283
+ // partial flushes and tool-timing stamps from spamming reindex jobs.
284
+ const conv = createConversation("Raw update thread");
285
+ const message = await addMessage(conv.id, "user", "original text");
286
+
287
+ resetMemoryJobs();
288
+ updateMessageContent(
289
+ message.id,
290
+ JSON.stringify([{ type: "text", text: "edited text" }]),
291
+ );
292
+
293
+ expect(countJobs("index_message_lexical")).toBe(0);
294
+ });
295
+
296
+ test("consolidation reindexes the retained message and purges the merged-away rows", async () => {
297
+ const conv = createConversation("Consolidation thread");
298
+ const userMsg = await addMessage(conv.id, "user", "do a multi-step task");
299
+ const retained = await addMessage(
300
+ conv.id,
301
+ "assistant",
302
+ JSON.stringify([{ type: "text", text: "first assistant segment" }]),
303
+ );
304
+ const mergedAway = await addMessage(
305
+ conv.id,
306
+ "assistant",
307
+ JSON.stringify([{ type: "text", text: "second assistant segment" }]),
308
+ );
309
+
310
+ resetMemoryJobs();
311
+ const didConsolidate = consolidateAssistantMessages(conv.id, userMsg.id);
312
+ expect(didConsolidate).toBe(true);
313
+
314
+ // The retained (first) assistant row is reindexed with the merged content.
315
+ expect(lexicalJobMessageIds()).toContain(retained.id);
316
+ // The merged-away row's point is removed via delete_message_lexical.
317
+ const deletedIds = getMemoryDb()!
318
+ .select({ payload: memoryJobs.payload })
319
+ .from(memoryJobs)
320
+ .where(eq(memoryJobs.type, "delete_message_lexical"))
321
+ .all()
322
+ .map((r) => (JSON.parse(r.payload) as { messageId?: string }).messageId);
323
+ expect(deletedIds).toContain(mergedAway.id);
324
+ // The retained row is not itself scheduled for deletion.
325
+ expect(deletedIds).not.toContain(retained.id);
326
+ });
327
+ });
328
+
329
+ // The delete paths must route their cleanup through the persistence-hook seam
330
+ // rather than importing memory internals directly, so persistence stays
331
+ // decoupled from the plugin: single-message deletes fire `onMessagesDeleted`,
332
+ // and whole-conversation deletes fire `onConversationDeleted` from the shared
333
+ // `deleteConversation`/`deleteConversationGently` primitive (covering every
334
+ // caller, not just the HTTP route).
335
+ describe("delete paths route through the persistence-hook seam", () => {
336
+ const deletedBatches: string[][] = [];
337
+ const deletedConversations: string[] = [];
338
+ const spyHooks: MemoryPersistenceHooks = {
339
+ onMessagePersisted() {},
340
+ onConversationForked() {},
341
+ onConversationWiped() {
342
+ return 0;
343
+ },
344
+ onConversationDeleted(id) {
345
+ deletedConversations.push(id);
346
+ },
347
+ onMessagesDeleted(ids) {
348
+ deletedBatches.push(ids);
349
+ },
350
+ async onAllConversationsCleared() {},
351
+ onWorkerStartup() {},
352
+ countMemoryBufferLines() {
353
+ return 0;
354
+ },
355
+ };
356
+
357
+ beforeEach(() => {
358
+ resetMemoryJobs();
359
+ deletedBatches.length = 0;
360
+ deletedConversations.length = 0;
361
+ registerMemoryPersistenceHooks(spyHooks);
362
+ });
363
+
364
+ afterEach(() => {
365
+ // Restore the real memory hooks so later tests are unaffected.
366
+ registerDefaultPluginPersistenceHooks();
367
+ });
368
+
369
+ test("the spy hook is installed", () => {
370
+ expect(getMemoryPersistenceHooks()).toBe(spyHooks);
371
+ });
372
+
373
+ test("deleteConversation fires onConversationDeleted (covers all callers, not just the route)", async () => {
374
+ const conv = createConversation("Seam conversation delete");
375
+ await addMessage(conv.id, "user", "hello");
376
+
377
+ deletedConversations.length = 0;
378
+ deleteConversation(conv.id);
379
+
380
+ expect(deletedConversations).toEqual([conv.id]);
381
+ });
382
+
383
+ test("deleteConversationGently fires onConversationDeleted (the retrospective-GC path)", async () => {
384
+ const conv = createConversation("Seam gentle delete");
385
+ await addMessage(conv.id, "user", "hello");
386
+
387
+ deletedConversations.length = 0;
388
+ await deleteConversationGently(conv.id);
389
+
390
+ expect(deletedConversations).toEqual([conv.id]);
391
+ });
392
+
393
+ test("deleteMessageById fires onMessagesDeleted with the single id", async () => {
394
+ const conv = createConversation("Seam single delete");
395
+ const message = await addMessage(conv.id, "user", "delete me via seam");
396
+
397
+ deletedBatches.length = 0;
398
+ deleteMessageById(message.id);
399
+
400
+ expect(deletedBatches).toEqual([[message.id]]);
401
+ });
402
+
403
+ test("deleteLastExchange fires onMessagesDeleted with every removed id", async () => {
404
+ const conv = createConversation("Seam undo");
405
+ await addMessage(conv.id, "user", "first turn");
406
+ await addMessage(conv.id, "assistant", "first reply");
407
+ const lastUser = await addMessage(conv.id, "user", "undo this");
408
+ const lastAssistant = await addMessage(conv.id, "assistant", "and this");
409
+
410
+ deletedBatches.length = 0;
411
+ deleteLastExchange(conv.id);
412
+
413
+ expect(deletedBatches).toHaveLength(1);
414
+ expect(new Set(deletedBatches[0])).toEqual(
415
+ new Set([lastUser.id, lastAssistant.id]),
416
+ );
417
+ });
418
+ });