@vellumai/assistant 0.10.4-dev.202607010146.a37a2fa → 0.10.4-dev.202607010541.092b857
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__/context-search-conversations-source.test.ts +281 -1
- 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 +3 -4
- package/src/daemon/conversation-agent-loop-handlers.ts +9 -0
- package/src/daemon/conversation-history.ts +6 -0
- package/src/daemon/conversation.ts +15 -1
- package/src/daemon/handlers/conversation-history.ts +3 -1
- package/src/persistence/__tests__/conversation-queries-search.test.ts +362 -0
- package/src/persistence/__tests__/slow-sync-log.test.ts +65 -0
- package/src/persistence/conversation-crud.ts +65 -7
- package/src/persistence/conversation-queries.ts +271 -73
- 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/persistence/raw-query.ts +42 -15
- package/src/persistence/slow-sync-log.ts +106 -0
- package/src/plugins/defaults/index.ts +10 -0
- package/src/plugins/defaults/memory/__tests__/conversation-queries.test.ts +15 -15
- package/src/plugins/defaults/memory/config.ts +11 -0
- package/src/plugins/defaults/memory/context-search/agent-runner.ts +1 -1
- package/src/plugins/defaults/memory/context-search/sources/conversations.ts +183 -12
- 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/hooks/init.ts +2 -2
- package/src/plugins/defaults/memory/injectors.ts +2 -2
- 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 +195 -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/memory-retrospective-startup-cleanup.ts +2 -2
- package/src/plugins/defaults/memory/persistence-hooks.ts +53 -1
- package/src/plugins/defaults/memory/pkb/pkb-search.ts +2 -2
- package/src/plugins/defaults/memory/routes/__tests__/memory-v2-simulate-route.test.ts +4 -6
- package/src/plugins/defaults/memory/search/semantic.ts +2 -2
- 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/prune.ts +2 -2
- package/src/plugins/defaults/memory/v3-eval/eval-packets.ts +1 -4
- package/src/runtime/AGENTS.md +5 -2
- package/src/runtime/http-server.ts +12 -4
- package/src/runtime/middleware/__tests__/rate-limiter.test.ts +22 -0
- package/src/runtime/middleware/rate-limiter.ts +30 -0
- package/src/runtime/routes/conversation-management-routes.ts +3 -0
- package/src/runtime/routes/conversation-query-routes.ts +4 -2
- package/src/runtime/routes/conversation-routes.ts +3 -3
- package/src/runtime/routes/conversations-import-routes.ts +5 -0
- package/src/runtime/routes/global-search-routes.ts +1 -1
- package/src/runtime/routes/inbound-stages/edit-intercept.ts +6 -0
- package/src/tools/terminal/safe-env.ts +1 -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
|
+
});
|
|
@@ -1,6 +1,57 @@
|
|
|
1
|
-
import { beforeEach, describe, expect, test } from "bun:test";
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
|
|
2
2
|
|
|
3
3
|
import { writeSlackMetadata } from "../messaging/providers/slack/message-metadata.js";
|
|
4
|
+
import type { MessageLexicalSearchResult } from "../persistence/embeddings/messages-lexical-index.js";
|
|
5
|
+
import { setOverridesForTesting } from "./feature-flag-test-helpers.js";
|
|
6
|
+
|
|
7
|
+
// Mutable stand-in for the Qdrant lexical candidate helper. The default
|
|
8
|
+
// throws so any qdrant-path test that forgets to set an implementation fails
|
|
9
|
+
// loudly rather than silently exercising an empty candidate set. FTS-path
|
|
10
|
+
// tests never reach it (the flag defaults to fts5), so its value is irrelevant
|
|
11
|
+
// there.
|
|
12
|
+
let lexicalMockImpl: (
|
|
13
|
+
query: string,
|
|
14
|
+
limit: number,
|
|
15
|
+
opts?: { conversationId?: string },
|
|
16
|
+
) => Promise<MessageLexicalSearchResult[]> = () => {
|
|
17
|
+
throw new Error("searchMessageIdsLexical mock not configured for this test");
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
// Records the arguments of every mock invocation so tests can assert the
|
|
21
|
+
// candidate over-fetch count the qdrant branch requests.
|
|
22
|
+
let lexicalCalls: Array<{
|
|
23
|
+
query: string;
|
|
24
|
+
limit: number;
|
|
25
|
+
opts?: { conversationId?: string };
|
|
26
|
+
}> = [];
|
|
27
|
+
|
|
28
|
+
mock.module("../persistence/conversation-search-lexical.js", () => ({
|
|
29
|
+
searchMessageIdsLexical: (
|
|
30
|
+
query: string,
|
|
31
|
+
limit: number,
|
|
32
|
+
opts?: { conversationId?: string },
|
|
33
|
+
) => {
|
|
34
|
+
lexicalCalls.push({ query, limit, opts });
|
|
35
|
+
return lexicalMockImpl(query, limit, opts);
|
|
36
|
+
},
|
|
37
|
+
}));
|
|
38
|
+
|
|
39
|
+
// Drives the real recall backend gate: when true the source must fall back to
|
|
40
|
+
// FTS regardless of the flag, because the lexical index write path is
|
|
41
|
+
// suppressed and Qdrant is never populated. Defaults false so every other test
|
|
42
|
+
// exercises its intended backend. Spread the real module so the other 9 exports
|
|
43
|
+
// (job handlers, enqueue helpers) stay intact for transitive importers.
|
|
44
|
+
let suppressIndexing = false;
|
|
45
|
+
const realLexicalModule =
|
|
46
|
+
await import("../plugins/defaults/memory/job-handlers/index-message-lexical.js");
|
|
47
|
+
mock.module(
|
|
48
|
+
"../plugins/defaults/memory/job-handlers/index-message-lexical.js",
|
|
49
|
+
() => ({
|
|
50
|
+
...realLexicalModule,
|
|
51
|
+
isMemoryIndexingSuppressed: () => suppressIndexing,
|
|
52
|
+
}),
|
|
53
|
+
);
|
|
54
|
+
|
|
4
55
|
import { getDb } from "../persistence/db-connection.js";
|
|
5
56
|
import { initializeDb } from "../persistence/db-init.js";
|
|
6
57
|
import { rawRun } from "../persistence/raw-query.js";
|
|
@@ -345,6 +396,235 @@ describe("searchConversationSource", () => {
|
|
|
345
396
|
});
|
|
346
397
|
});
|
|
347
398
|
|
|
399
|
+
describe("searchConversationSource with the qdrant backend", () => {
|
|
400
|
+
beforeEach(() => {
|
|
401
|
+
getDb().run("DELETE FROM messages");
|
|
402
|
+
getDb().run("DELETE FROM conversations");
|
|
403
|
+
lexicalCalls = [];
|
|
404
|
+
suppressIndexing = false;
|
|
405
|
+
setOverridesForTesting({ "messages-search-backend": true });
|
|
406
|
+
});
|
|
407
|
+
|
|
408
|
+
afterEach(() => {
|
|
409
|
+
setOverridesForTesting({});
|
|
410
|
+
suppressIndexing = false;
|
|
411
|
+
lexicalMockImpl = () => {
|
|
412
|
+
throw new Error(
|
|
413
|
+
"searchMessageIdsLexical mock not configured for this test",
|
|
414
|
+
);
|
|
415
|
+
};
|
|
416
|
+
});
|
|
417
|
+
|
|
418
|
+
test("falls back to FTS when memory indexing is suppressed, even with the qdrant flag on", async () => {
|
|
419
|
+
const match = await seedConversation({
|
|
420
|
+
title: "Suppressed indexing notes",
|
|
421
|
+
content: "The suppressedtoken decision is recorded here.",
|
|
422
|
+
});
|
|
423
|
+
|
|
424
|
+
// The lexical index is not populated while indexing is suppressed, so the
|
|
425
|
+
// source must NOT query Qdrant — it must use the FTS path.
|
|
426
|
+
suppressIndexing = true;
|
|
427
|
+
lexicalMockImpl = async () => {
|
|
428
|
+
throw new Error("searchMessageIdsLexical must not run while suppressed");
|
|
429
|
+
};
|
|
430
|
+
|
|
431
|
+
const result = await searchConversationSource(
|
|
432
|
+
"suppressedtoken",
|
|
433
|
+
makeContext(),
|
|
434
|
+
5,
|
|
435
|
+
);
|
|
436
|
+
|
|
437
|
+
// FTS path found the row (proves the backend fell back), and the Qdrant
|
|
438
|
+
// candidate helper was never called.
|
|
439
|
+
expect(lexicalCalls).toHaveLength(0);
|
|
440
|
+
expect(result.evidence.map((item) => item.locator)).toEqual([
|
|
441
|
+
`${match.conversation.id}#${match.message.id}`,
|
|
442
|
+
]);
|
|
443
|
+
});
|
|
444
|
+
|
|
445
|
+
test("routes short/punctuation-only queries to the exact LIKE path, not Qdrant", async () => {
|
|
446
|
+
const match = await seedConversation({
|
|
447
|
+
title: "C++ notes",
|
|
448
|
+
role: "user",
|
|
449
|
+
content: "Use C++ when the example needs deterministic lifetime notes.",
|
|
450
|
+
});
|
|
451
|
+
|
|
452
|
+
// `C++` produces no usable ≥2-char FTS match shape, so the source must use
|
|
453
|
+
// the exact LIKE path rather than the sparse encoder (which would still
|
|
454
|
+
// emit a noisy 1-char `c` token). The mock throws to prove it never runs.
|
|
455
|
+
lexicalMockImpl = async () => {
|
|
456
|
+
throw new Error("searchMessageIdsLexical must not run for a short query");
|
|
457
|
+
};
|
|
458
|
+
|
|
459
|
+
const result = await searchConversationSource("C++", makeContext(), 5);
|
|
460
|
+
|
|
461
|
+
expect(lexicalCalls).toHaveLength(0);
|
|
462
|
+
expect(result.evidence.map((item) => item.locator)).toEqual([
|
|
463
|
+
`${match.conversation.id}#${match.message.id}`,
|
|
464
|
+
]);
|
|
465
|
+
});
|
|
466
|
+
|
|
467
|
+
test("over-fetches a wide candidate pool from Qdrant, not the FTS prefetch window", async () => {
|
|
468
|
+
const match = await seedConversation({
|
|
469
|
+
title: "Launch notes",
|
|
470
|
+
content: "The widecandidatetoken launch checklist is recorded here.",
|
|
471
|
+
});
|
|
472
|
+
|
|
473
|
+
lexicalMockImpl = async () => [{ messageId: match.message.id, score: 0.9 }];
|
|
474
|
+
|
|
475
|
+
// limit = 5 → FTS would prefetch 5 × 5 = 25. The qdrant branch instead
|
|
476
|
+
// over-fetches max(5 × 20, 200) = 200 candidates so post-filter yield stays
|
|
477
|
+
// healthy when top lexical hits are excluded by the SQL predicates.
|
|
478
|
+
const result = await searchConversationSource(
|
|
479
|
+
"widecandidatetoken",
|
|
480
|
+
makeContext(),
|
|
481
|
+
5,
|
|
482
|
+
);
|
|
483
|
+
|
|
484
|
+
expect(lexicalCalls).toHaveLength(1);
|
|
485
|
+
expect(lexicalCalls[0]?.limit).toBe(200);
|
|
486
|
+
// Filtering still yields the correctly-scored surviving row.
|
|
487
|
+
expect(result.evidence.map((item) => item.locator)).toEqual([
|
|
488
|
+
`${match.conversation.id}#${match.message.id}`,
|
|
489
|
+
]);
|
|
490
|
+
expect(result.evidence[0]?.score).toBeGreaterThan(0);
|
|
491
|
+
});
|
|
492
|
+
|
|
493
|
+
test("returns app-scored evidence for Qdrant-supplied candidates", async () => {
|
|
494
|
+
const match = await seedConversation({
|
|
495
|
+
title: "Launch notes",
|
|
496
|
+
content: "The alpha launch checklist includes database backups.",
|
|
497
|
+
});
|
|
498
|
+
// A candidate the lexical index also surfaces but that the query does not
|
|
499
|
+
// actually match — proves the app-side scorer, not Qdrant order, ranks.
|
|
500
|
+
const weak = await seedConversation({
|
|
501
|
+
title: "Unrelated",
|
|
502
|
+
content: "Nothing salient in this conversation at all.",
|
|
503
|
+
});
|
|
504
|
+
|
|
505
|
+
lexicalMockImpl = async () => [
|
|
506
|
+
{ messageId: weak.message.id, score: 0.9 },
|
|
507
|
+
{ messageId: match.message.id, score: 0.1 },
|
|
508
|
+
];
|
|
509
|
+
|
|
510
|
+
const result = await searchConversationSource(
|
|
511
|
+
"alpha launch",
|
|
512
|
+
makeContext(),
|
|
513
|
+
5,
|
|
514
|
+
);
|
|
515
|
+
|
|
516
|
+
expect(result.evidence[0]).toMatchObject({
|
|
517
|
+
id: `conversations:${match.conversation.id}:${match.message.id}`,
|
|
518
|
+
source: "conversations",
|
|
519
|
+
title: "Launch notes",
|
|
520
|
+
locator: `${match.conversation.id}#${match.message.id}`,
|
|
521
|
+
excerpt: "The alpha launch checklist includes database backups.",
|
|
522
|
+
metadata: {
|
|
523
|
+
role: "assistant",
|
|
524
|
+
conversationId: match.conversation.id,
|
|
525
|
+
},
|
|
526
|
+
});
|
|
527
|
+
expect(result.evidence[0]?.score).toBeGreaterThan(
|
|
528
|
+
result.evidence[1]?.score ?? -1,
|
|
529
|
+
);
|
|
530
|
+
});
|
|
531
|
+
|
|
532
|
+
test("applies source, type, and excluded-conversation filters to Qdrant candidates", async () => {
|
|
533
|
+
const visible = await seedConversation({
|
|
534
|
+
title: "User conversation",
|
|
535
|
+
content: "derivedtoken belongs to a user-authored conversation.",
|
|
536
|
+
});
|
|
537
|
+
const subagent = await seedConversation({
|
|
538
|
+
title: "Subagent conversation",
|
|
539
|
+
source: "subagent",
|
|
540
|
+
content: "derivedtoken should not include subagent output.",
|
|
541
|
+
});
|
|
542
|
+
const autoAnalysis = await seedConversation({
|
|
543
|
+
title: "Auto-analysis conversation",
|
|
544
|
+
source: "auto-analysis",
|
|
545
|
+
content: "derivedtoken should not include auto-analysis output.",
|
|
546
|
+
});
|
|
547
|
+
const notification = await seedConversation({
|
|
548
|
+
title: "Notification conversation",
|
|
549
|
+
source: "notification",
|
|
550
|
+
content: "derivedtoken should not include notification output.",
|
|
551
|
+
});
|
|
552
|
+
const current = await seedConversation({
|
|
553
|
+
title: "Current conversation",
|
|
554
|
+
content: "derivedtoken appears in the active conversation.",
|
|
555
|
+
});
|
|
556
|
+
const legacyPrivate = await seedConversation({
|
|
557
|
+
title: "Legacy private conversation",
|
|
558
|
+
content: "derivedtoken belongs to legacy private history.",
|
|
559
|
+
});
|
|
560
|
+
rawRun(
|
|
561
|
+
"UPDATE conversations SET conversation_type = 'private' WHERE id = ?",
|
|
562
|
+
legacyPrivate.conversation.id,
|
|
563
|
+
);
|
|
564
|
+
|
|
565
|
+
// The lexical index does not filter — it hands back every candidate,
|
|
566
|
+
// including the ones SQL must exclude.
|
|
567
|
+
lexicalMockImpl = async () => [
|
|
568
|
+
{ messageId: visible.message.id, score: 0.9 },
|
|
569
|
+
{ messageId: subagent.message.id, score: 0.85 },
|
|
570
|
+
{ messageId: autoAnalysis.message.id, score: 0.8 },
|
|
571
|
+
{ messageId: notification.message.id, score: 0.75 },
|
|
572
|
+
{ messageId: current.message.id, score: 0.7 },
|
|
573
|
+
{ messageId: legacyPrivate.message.id, score: 0.65 },
|
|
574
|
+
];
|
|
575
|
+
|
|
576
|
+
const result = await searchConversationSource(
|
|
577
|
+
"derivedtoken",
|
|
578
|
+
makeContext({ conversationId: current.conversation.id }),
|
|
579
|
+
10,
|
|
580
|
+
);
|
|
581
|
+
|
|
582
|
+
expect(result.evidence.map((item) => item.locator)).toEqual([
|
|
583
|
+
`${visible.conversation.id}#${visible.message.id}`,
|
|
584
|
+
]);
|
|
585
|
+
});
|
|
586
|
+
|
|
587
|
+
test("falls back to LIKE when the Qdrant lookup throws", async () => {
|
|
588
|
+
const match = await seedConversation({
|
|
589
|
+
title: "Fallback notes",
|
|
590
|
+
content: "The likefallbacktoken decision is recorded here.",
|
|
591
|
+
});
|
|
592
|
+
|
|
593
|
+
lexicalMockImpl = async () => {
|
|
594
|
+
throw new Error("qdrant unavailable");
|
|
595
|
+
};
|
|
596
|
+
|
|
597
|
+
const result = await searchConversationSource(
|
|
598
|
+
"likefallbacktoken",
|
|
599
|
+
makeContext(),
|
|
600
|
+
5,
|
|
601
|
+
);
|
|
602
|
+
|
|
603
|
+
expect(result.evidence.map((item) => item.locator)).toEqual([
|
|
604
|
+
`${match.conversation.id}#${match.message.id}`,
|
|
605
|
+
]);
|
|
606
|
+
});
|
|
607
|
+
|
|
608
|
+
test("falls back to LIKE when Qdrant returns no candidates", async () => {
|
|
609
|
+
const match = await seedConversation({
|
|
610
|
+
title: "Empty candidate notes",
|
|
611
|
+
content: "The emptycandidatetoken decision is recorded here.",
|
|
612
|
+
});
|
|
613
|
+
|
|
614
|
+
lexicalMockImpl = async () => [];
|
|
615
|
+
|
|
616
|
+
const result = await searchConversationSource(
|
|
617
|
+
"emptycandidatetoken",
|
|
618
|
+
makeContext(),
|
|
619
|
+
5,
|
|
620
|
+
);
|
|
621
|
+
|
|
622
|
+
expect(result.evidence.map((item) => item.locator)).toEqual([
|
|
623
|
+
`${match.conversation.id}#${match.message.id}`,
|
|
624
|
+
]);
|
|
625
|
+
});
|
|
626
|
+
});
|
|
627
|
+
|
|
348
628
|
function seedConversation(opts: {
|
|
349
629
|
title?: string;
|
|
350
630
|
conversationType?: "standard" | "background" | "scheduled";
|
|
@@ -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,
|