@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.
Files changed (79) hide show
  1. package/package.json +1 -1
  2. package/src/__tests__/clear-all-lexical.test.ts +93 -0
  3. package/src/__tests__/context-search-conversations-source.test.ts +281 -1
  4. package/src/__tests__/conversation-wipe.test.ts +20 -5
  5. package/src/__tests__/edit-propagation.test.ts +56 -2
  6. package/src/__tests__/job-handler-registry-guard.test.ts +1 -0
  7. package/src/__tests__/lexical-index-dual-write.test.ts +418 -0
  8. package/src/__tests__/plugin-disabled-state.test.ts +21 -0
  9. package/src/__tests__/plugin-import-boundary-guard.test.ts +3 -4
  10. package/src/daemon/conversation-agent-loop-handlers.ts +9 -0
  11. package/src/daemon/conversation-history.ts +6 -0
  12. package/src/daemon/conversation.ts +15 -1
  13. package/src/daemon/handlers/conversation-history.ts +3 -1
  14. package/src/persistence/__tests__/conversation-queries-search.test.ts +362 -0
  15. package/src/persistence/__tests__/slow-sync-log.test.ts +65 -0
  16. package/src/persistence/conversation-crud.ts +65 -7
  17. package/src/persistence/conversation-queries.ts +271 -73
  18. package/src/persistence/embeddings/__tests__/messages-lexical-index.test.ts +27 -0
  19. package/src/persistence/embeddings/messages-lexical-index.ts +28 -0
  20. package/src/persistence/jobs-store.ts +1 -0
  21. package/src/persistence/memory-lifecycle-hooks.test.ts +50 -0
  22. package/src/persistence/memory-lifecycle-hooks.ts +41 -0
  23. package/src/persistence/raw-query.ts +42 -15
  24. package/src/persistence/slow-sync-log.ts +106 -0
  25. package/src/plugins/defaults/index.ts +10 -0
  26. package/src/plugins/defaults/memory/__tests__/conversation-queries.test.ts +15 -15
  27. package/src/plugins/defaults/memory/config.ts +11 -0
  28. package/src/plugins/defaults/memory/context-search/agent-runner.ts +1 -1
  29. package/src/plugins/defaults/memory/context-search/sources/conversations.ts +183 -12
  30. package/src/plugins/defaults/memory/frontmatter.ts +50 -0
  31. package/src/plugins/defaults/memory/graph/consolidation.ts +3 -5
  32. package/src/plugins/defaults/memory/graph/extraction.ts +2 -5
  33. package/src/plugins/defaults/memory/graph/narrative.ts +3 -5
  34. package/src/plugins/defaults/memory/graph/pattern-scan.ts +3 -5
  35. package/src/plugins/defaults/memory/graph/retriever.test.ts +1 -6
  36. package/src/plugins/defaults/memory/graph/retriever.ts +2 -5
  37. package/src/plugins/defaults/memory/graph/tools.ts +1 -1
  38. package/src/plugins/defaults/memory/hooks/init.ts +2 -2
  39. package/src/plugins/defaults/memory/injectors.ts +2 -2
  40. package/src/plugins/defaults/memory/job-handlers/__tests__/lexical-cleanup-disabled.test.ts +197 -0
  41. package/src/plugins/defaults/memory/job-handlers/index-message-lexical.ts +195 -3
  42. package/src/plugins/defaults/memory/job-handlers.ts +5 -0
  43. package/src/plugins/defaults/memory/llm-helpers.ts +76 -0
  44. package/src/plugins/defaults/memory/memory-retrospective-startup-cleanup.ts +2 -2
  45. package/src/plugins/defaults/memory/persistence-hooks.ts +53 -1
  46. package/src/plugins/defaults/memory/pkb/pkb-search.ts +2 -2
  47. package/src/plugins/defaults/memory/routes/__tests__/memory-v2-simulate-route.test.ts +4 -6
  48. package/src/plugins/defaults/memory/search/semantic.ts +2 -2
  49. package/src/plugins/defaults/memory/v2/__tests__/migration.test.ts +2 -13
  50. package/src/plugins/defaults/memory/v2/__tests__/router.test.ts +4 -5
  51. package/src/plugins/defaults/memory/v2/__tests__/sweep-job.test.ts +1 -8
  52. package/src/plugins/defaults/memory/v2/frontmatter-sweep.ts +1 -1
  53. package/src/plugins/defaults/memory/v2/migration.ts +2 -5
  54. package/src/plugins/defaults/memory/v2/page-store.ts +1 -1
  55. package/src/plugins/defaults/memory/v2/router.ts +5 -5
  56. package/src/plugins/defaults/memory/v2/sweep-job.ts +6 -6
  57. package/src/plugins/defaults/memory/v3/__tests__/carry-integration.test.ts +5 -9
  58. package/src/plugins/defaults/memory/v3/__tests__/live-integration.test.ts +1 -3
  59. package/src/plugins/defaults/memory/v3/__tests__/orchestrate.test.ts +1 -3
  60. package/src/plugins/defaults/memory/v3/__tests__/pool-select.test.ts +1 -4
  61. package/src/plugins/defaults/memory/v3/__tests__/shadow-integration.test.ts +9 -13
  62. package/src/plugins/defaults/memory/v3/card.ts +1 -4
  63. package/src/plugins/defaults/memory/v3/edge.ts +1 -1
  64. package/src/plugins/defaults/memory/v3/pool-select.test.ts +7 -9
  65. package/src/plugins/defaults/memory/v3/pool-select.ts +6 -6
  66. package/src/plugins/defaults/memory/v3/prune.ts +2 -2
  67. package/src/plugins/defaults/memory/v3-eval/eval-packets.ts +1 -4
  68. package/src/runtime/AGENTS.md +5 -2
  69. package/src/runtime/http-server.ts +12 -4
  70. package/src/runtime/middleware/__tests__/rate-limiter.test.ts +22 -0
  71. package/src/runtime/middleware/rate-limiter.ts +30 -0
  72. package/src/runtime/routes/conversation-management-routes.ts +3 -0
  73. package/src/runtime/routes/conversation-query-routes.ts +4 -2
  74. package/src/runtime/routes/conversation-routes.ts +3 -3
  75. package/src/runtime/routes/conversations-import-routes.ts +5 -0
  76. package/src/runtime/routes/global-search-routes.ts +1 -1
  77. package/src/runtime/routes/inbound-stages/edit-intercept.ts +6 -0
  78. package/src/tools/terminal/safe-env.ts +1 -0
  79. 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.202607010541.092b857",
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
+ });
@@ -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<{ 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