@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.
- package/package.json +1 -1
- package/src/__tests__/clear-all-lexical.test.ts +93 -0
- package/src/__tests__/conversation-wipe.test.ts +20 -5
- package/src/__tests__/edit-propagation.test.ts +56 -2
- package/src/__tests__/job-handler-registry-guard.test.ts +1 -0
- package/src/__tests__/lexical-index-dual-write.test.ts +418 -0
- package/src/__tests__/plugin-disabled-state.test.ts +21 -0
- package/src/__tests__/plugin-import-boundary-guard.test.ts +1 -4
- package/src/daemon/conversation-agent-loop-handlers.ts +9 -0
- package/src/daemon/conversation-history.ts +6 -0
- package/src/persistence/conversation-crud.ts +49 -0
- package/src/persistence/embeddings/__tests__/messages-lexical-index.test.ts +27 -0
- package/src/persistence/embeddings/messages-lexical-index.ts +28 -0
- package/src/persistence/jobs-store.ts +1 -0
- package/src/persistence/memory-lifecycle-hooks.test.ts +50 -0
- package/src/persistence/memory-lifecycle-hooks.ts +41 -0
- package/src/plugins/defaults/index.ts +10 -0
- package/src/plugins/defaults/memory/context-search/agent-runner.ts +1 -1
- package/src/plugins/defaults/memory/frontmatter.ts +50 -0
- package/src/plugins/defaults/memory/graph/consolidation.ts +3 -5
- package/src/plugins/defaults/memory/graph/extraction.ts +2 -5
- package/src/plugins/defaults/memory/graph/narrative.ts +3 -5
- package/src/plugins/defaults/memory/graph/pattern-scan.ts +3 -5
- package/src/plugins/defaults/memory/graph/retriever.test.ts +1 -6
- package/src/plugins/defaults/memory/graph/retriever.ts +2 -5
- package/src/plugins/defaults/memory/graph/tools.ts +1 -1
- package/src/plugins/defaults/memory/job-handlers/__tests__/lexical-cleanup-disabled.test.ts +197 -0
- package/src/plugins/defaults/memory/job-handlers/index-message-lexical.ts +190 -3
- package/src/plugins/defaults/memory/job-handlers.ts +5 -0
- package/src/plugins/defaults/memory/llm-helpers.ts +76 -0
- package/src/plugins/defaults/memory/persistence-hooks.ts +52 -1
- package/src/plugins/defaults/memory/routes/__tests__/memory-v2-simulate-route.test.ts +4 -6
- package/src/plugins/defaults/memory/v2/__tests__/migration.test.ts +2 -13
- package/src/plugins/defaults/memory/v2/__tests__/router.test.ts +4 -5
- package/src/plugins/defaults/memory/v2/__tests__/sweep-job.test.ts +1 -8
- package/src/plugins/defaults/memory/v2/frontmatter-sweep.ts +1 -1
- package/src/plugins/defaults/memory/v2/migration.ts +2 -5
- package/src/plugins/defaults/memory/v2/page-store.ts +1 -1
- package/src/plugins/defaults/memory/v2/router.ts +5 -5
- package/src/plugins/defaults/memory/v2/sweep-job.ts +6 -6
- package/src/plugins/defaults/memory/v3/__tests__/carry-integration.test.ts +5 -9
- package/src/plugins/defaults/memory/v3/__tests__/live-integration.test.ts +1 -3
- package/src/plugins/defaults/memory/v3/__tests__/orchestrate.test.ts +1 -3
- package/src/plugins/defaults/memory/v3/__tests__/pool-select.test.ts +1 -4
- package/src/plugins/defaults/memory/v3/__tests__/shadow-integration.test.ts +9 -13
- package/src/plugins/defaults/memory/v3/card.ts +1 -4
- package/src/plugins/defaults/memory/v3/edge.ts +1 -1
- package/src/plugins/defaults/memory/v3/pool-select.test.ts +7 -9
- package/src/plugins/defaults/memory/v3/pool-select.ts +6 -6
- package/src/plugins/defaults/memory/v3-eval/eval-packets.ts +1 -4
- package/src/runtime/routes/conversation-management-routes.ts +3 -0
- package/src/runtime/routes/conversations-import-routes.ts +5 -0
- package/src/runtime/routes/inbound-stages/edit-intercept.ts +6 -0
- package/src/providers/cache-control.ts +0 -26
package/package.json
CHANGED
|
@@ -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<{
|
|
125
|
-
|
|
126
|
-
|
|
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,
|
|
@@ -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
|
+
});
|