/** * Tests for `assistant/src/memory/jobs/embed-concept-page.ts`. * * Coverage matrix (from PR 13 acceptance criteria): * - Enqueue + dispatch round-trip: writing a page → enqueueing the job → * dispatching it via `embedConceptPageJob` → upserts the embedding. * - Delete propagation: when the page is missing on disk, the handler * removes the embedding instead of upserting. * - Cache hit: a second run with the same content reuses the cached dense * vector and skips the embedding backend. * - Skips when slug is missing from the payload (defensive). * * Mocks: the embedding backend, Qdrant client, and v2 qdrant module are * stubbed so the test runs without network/IO. Pages live on a temp * workspace under `os.tmpdir()` per the cross-cutting safety rule. */ import { mkdirSync, mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, mock, test, } from "bun:test"; // ── Embedding backend stub ───────────────────────────────────────── // `embedConceptPageJob` calls `getMemoryBackendStatus` first to verify a // provider is configured, then `embedWithBackend` for the dense vector and // `generateSparseEmbedding` for the sparse one. Stub all three so tests run // without an embedding backend. const embedWithBackendCalls: Array<{ inputs: unknown; options: unknown; }> = []; mock.module( "../../../../../persistence/embeddings/embedding-backend.js", () => ({ getMemoryBackendStatus: async () => ({ enabled: true, degraded: false, provider: "local", model: "test-model", reason: null, }), embedWithBackend: async ( _config: unknown, inputs: unknown[], options?: unknown, ) => { embedWithBackendCalls.push({ inputs, options }); // Return a dense vector matching the test config's vectorSize (4). return { provider: "local" as const, model: "test-model", vectors: inputs.map(() => [0.1, 0.2, 0.3, 0.4]), }; }, generateSparseEmbedding: (text: string) => ({ indices: [text.length % 100], values: [1], }), // Other exports from the real module — stubbed so adjacent imports // (e.g. via transitive `db.ts` → `indexer.ts`) don't crash on missing // names when the mock replaces the module wholesale. selectedBackendSupportsMultimodal: async () => false, }), ); // ── v2 qdrant stub ───────────────────────────────────────────────── // `embedConceptPageJob` upserts via `upsertConceptPageEmbedding` and deletes // via `deleteConceptPageEmbedding`. Capture both so we can assert on them. const upsertCalls: Array<{ slug: string; dense: number[]; sparse: { indices: number[]; values: number[] }; summary?: { dense: number[]; sparse: { indices: number[]; values: number[] }; }; updatedAt: number; }> = []; const deleteCalls: string[] = []; mock.module("../../substrate/qdrant.js", () => ({ upsertConceptPageEmbedding: async (params: { slug: string; dense: number[]; sparse: { indices: number[]; values: number[] }; summary?: { dense: number[]; sparse: { indices: number[]; values: number[] }; }; updatedAt: number; }) => { upsertCalls.push(params); }, deleteConceptPageEmbedding: async (slug: string) => { deleteCalls.push(slug); }, // Other exports from the real module — stubbed so transitive imports // don't crash on missing names when the mock replaces the module wholesale. hybridQueryConceptPages: async () => [], _resetMemoryV2QdrantForTests: () => {}, ensureConceptPageCollection: async () => {}, MEMORY_V2_COLLECTION: "memory_v2_concept_pages", })); // ── Workspace setup ──────────────────────────────────────────────── let tmpWorkspace: string; let previousWorkspaceEnv: string | undefined; beforeAll(() => { tmpWorkspace = mkdtempSync(join(tmpdir(), "embed-concept-page-test-")); mkdirSync(join(tmpWorkspace, "memory", "concepts"), { recursive: true }); previousWorkspaceEnv = process.env.VELLUM_WORKSPACE_DIR; process.env.VELLUM_WORKSPACE_DIR = tmpWorkspace; }); afterAll(() => { if (previousWorkspaceEnv === undefined) { delete process.env.VELLUM_WORKSPACE_DIR; } else { process.env.VELLUM_WORKSPACE_DIR = previousWorkspaceEnv; } rmSync(tmpWorkspace, { recursive: true, force: true }); }); // Imports are deferred to after the env var is set so any internal use of // `getWorkspaceDir()` resolves to the tmpdir. const { DEFAULT_CONFIG } = await import("../../../../../config/defaults.js"); const { getMemoryDb } = await import("../../../../../persistence/db-connection.js"); const { resetDbForTesting } = await import("../../../../../__tests__/db-test-helpers.js"); const { initializeDb } = await import("../../../../../persistence/db-init.js"); const { memoryEmbeddings, memoryJobs } = await import("../../../../../persistence/schema/index.js"); const { claimMemoryJobs, enqueueMemoryJob } = await import("../../../../../persistence/jobs-store.js"); type MemoryJobMod = typeof import("../../../../../persistence/jobs-store.js"); type MemoryJob = ReturnType[number]; const { embedConceptPageJob, enqueueEmbedConceptPageJob } = await import("../embed-concept-page.js"); const { writePage } = await import("../../substrate/page-store.js"); const { _resetQdrantBreaker, isQdrantBreakerOpen, withQdrantBreaker } = await import("../../../../../persistence/embeddings/qdrant-circuit-breaker.js"); // Use a tiny vectorSize so the cache-dim check matches our stub vector. const TEST_CONFIG = { ...DEFAULT_CONFIG, memory: { ...DEFAULT_CONFIG.memory, qdrant: { ...DEFAULT_CONFIG.memory.qdrant, vectorSize: 4, }, }, }; function makeJob(payload: Record): MemoryJob { return { id: "job-1", type: "embed_concept_page", payload, status: "running", attempts: 0, deferrals: 0, runAfter: 0, lastError: null, startedAt: Date.now(), createdAt: Date.now(), updatedAt: Date.now(), }; } beforeEach(async () => { resetDbForTesting(); // The first run pays the full cold-start migration chain; bump the hook // timeout above bun's 5s default so the leading test doesn't flake in CI. await initializeDb(); // memory_jobs lives in the dedicated memory connection, which the shared // template-DB restore does not clear — truncate it so job rows written by // earlier tests (or earlier suites in the same process) don't leak into // the enqueue/coalesce assertions. getMemoryDb()!.run("DELETE FROM memory_jobs"); embedWithBackendCalls.length = 0; upsertCalls.length = 0; deleteCalls.length = 0; _resetQdrantBreaker(); }, 30_000); afterEach(() => { // Clean up any pages written between tests so each scenario starts fresh. rmSync(join(tmpWorkspace, "memory", "concepts"), { recursive: true, force: true, }); mkdirSync(join(tmpWorkspace, "memory", "concepts"), { recursive: true }); }); // --------------------------------------------------------------------------- describe("embedConceptPageJob — happy path", () => { test("reads the page, embeds it, and upserts to the v2 collection", async () => { await writePage(tmpWorkspace, { slug: "alice-prefers-vs-code", frontmatter: { edges: [], ref_files: [], ref_urls: [] }, body: "Alice prefers VS Code over Vim.\nShe ships at end of day.\n", }); await embedConceptPageJob( makeJob({ slug: "alice-prefers-vs-code" }), TEST_CONFIG, ); // Dense embedding came from the backend stub once (no cache to start). expect(embedWithBackendCalls).toHaveLength(1); // Exactly one upsert with both vectors and the slug payload. expect(upsertCalls).toHaveLength(1); const call = upsertCalls[0]; expect(call.slug).toBe("alice-prefers-vs-code"); expect(call.dense).toEqual([0.1, 0.2, 0.3, 0.4]); expect(call.sparse.indices.length).toBe(1); expect(call.sparse.values).toEqual([1]); expect(typeof call.updatedAt).toBe("number"); // Delete path was never taken. expect(deleteCalls).toEqual([]); }); test("populates the SQLite embedding cache row keyed on (concept_page, slug)", async () => { await writePage(tmpWorkspace, { slug: "bob-uses-zsh", frontmatter: { edges: [], ref_files: [], ref_urls: [] }, body: "Bob uses zsh.\n", }); await embedConceptPageJob(makeJob({ slug: "bob-uses-zsh" }), TEST_CONFIG); const row = getMemoryDb()! .select() .from(memoryEmbeddings) .all() .find((r) => r.targetId === "bob-uses-zsh"); expect(row).toBeDefined(); expect(row!.targetType).toBe("concept_page"); expect(row!.dimensions).toBe(4); expect(row!.contentHash).toBeTruthy(); }); }); describe("embedConceptPageJob — summary embedding", () => { test("embeds the summary when present and forwards summary vectors to upsert", async () => { await writePage(tmpWorkspace, { slug: "summarized-page", frontmatter: { edges: [], ref_files: [], ref_urls: [], summary: "A short prose summary that retrieval indexes separately.", }, body: "Long-form body content.\n", }); await embedConceptPageJob( makeJob({ slug: "summarized-page" }), TEST_CONFIG, ); // Body and summary are batched into one backend call (saves a round-trip). expect(embedWithBackendCalls).toHaveLength(1); expect(embedWithBackendCalls[0].inputs).toHaveLength(2); expect(upsertCalls).toHaveLength(1); const call = upsertCalls[0]; expect(call.slug).toBe("summarized-page"); expect(call.dense).toEqual([0.1, 0.2, 0.3, 0.4]); expect(call.sparse).toBeDefined(); expect(call.summary?.dense).toEqual([0.1, 0.2, 0.3, 0.4]); expect(call.summary?.sparse).toBeDefined(); }); test("skips summary embedding when the page has no summary in frontmatter", async () => { await writePage(tmpWorkspace, { slug: "legacy-page", frontmatter: { edges: [], ref_files: [], ref_urls: [] }, body: "Body only — no summary in frontmatter.\n", }); await embedConceptPageJob(makeJob({ slug: "legacy-page" }), TEST_CONFIG); // Only the body was embedded. expect(embedWithBackendCalls).toHaveLength(1); expect(upsertCalls).toHaveLength(1); const call = upsertCalls[0]; expect(call.summary).toBeUndefined(); }); test("skips summary embedding when the summary is whitespace-only", async () => { // Whitespace-only summaries (` `, `\n`) are equivalent to absent — the // embedding backend would reject the empty input downstream anyway. await writePage(tmpWorkspace, { slug: "whitespace-summary", frontmatter: { edges: [], ref_files: [], ref_urls: [], summary: " ", }, body: "Body content.\n", }); await embedConceptPageJob( makeJob({ slug: "whitespace-summary" }), TEST_CONFIG, ); expect(embedWithBackendCalls).toHaveLength(1); expect(upsertCalls[0].summary).toBeUndefined(); }); test("body and summary cache rows are independent (summary edit doesn't invalidate body)", async () => { // Write a page with a summary, run the job to prime caches. await writePage(tmpWorkspace, { slug: "cached-summary", frontmatter: { edges: [], ref_files: [], ref_urls: [], summary: "First version of the summary.", }, body: "Stable body that never changes.\n", }); await embedConceptPageJob(makeJob({ slug: "cached-summary" }), TEST_CONFIG); // Body + summary batched into a single backend call on first run. expect(embedWithBackendCalls).toHaveLength(1); expect(embedWithBackendCalls[0].inputs).toHaveLength(2); // Edit only the summary — body stays identical, only the summary text // changes. Re-running the job should hit the body cache (no re-embed) // but recompute the summary embedding. await writePage(tmpWorkspace, { slug: "cached-summary", frontmatter: { edges: [], ref_files: [], ref_urls: [], summary: "Second version of the summary, different wording.", }, body: "Stable body that never changes.\n", }); await embedConceptPageJob(makeJob({ slug: "cached-summary" }), TEST_CONFIG); // One additional backend call with only the summary text — body hit the cache. expect(embedWithBackendCalls).toHaveLength(2); expect(embedWithBackendCalls[1].inputs).toHaveLength(1); }); }); describe("embedConceptPageJob — cache hit", () => { test("reuses the cached dense vector when content hash matches", async () => { await writePage(tmpWorkspace, { slug: "alice-prefers-vs-code", frontmatter: { edges: [], ref_files: [], ref_urls: [] }, body: "Stable content.\n", }); // First run — primes the cache. await embedConceptPageJob( makeJob({ slug: "alice-prefers-vs-code" }), TEST_CONFIG, ); expect(embedWithBackendCalls).toHaveLength(1); // Second run with identical body — backend should not be hit again. await embedConceptPageJob( makeJob({ slug: "alice-prefers-vs-code" }), TEST_CONFIG, ); expect(embedWithBackendCalls).toHaveLength(1); // Both runs upserted to Qdrant — caching only saves the embedding step. expect(upsertCalls).toHaveLength(2); }); test("re-embeds when the body changes (content hash mismatch)", async () => { await writePage(tmpWorkspace, { slug: "alice-prefers-vs-code", frontmatter: { edges: [], ref_files: [], ref_urls: [] }, body: "First content.\n", }); await embedConceptPageJob( makeJob({ slug: "alice-prefers-vs-code" }), TEST_CONFIG, ); // Rewrite with different body. await writePage(tmpWorkspace, { slug: "alice-prefers-vs-code", frontmatter: { edges: [], ref_files: [], ref_urls: [] }, body: "Second content (different).\n", }); await embedConceptPageJob( makeJob({ slug: "alice-prefers-vs-code" }), TEST_CONFIG, ); // Both runs hit the backend because the second body produces a new hash. expect(embedWithBackendCalls).toHaveLength(2); expect(upsertCalls).toHaveLength(2); }); }); describe("embedConceptPageJob — delete propagation", () => { test("removes the embedding when the page is missing on disk", async () => { // No `writePage` → page does not exist. Worker should clean up Qdrant. await embedConceptPageJob(makeJob({ slug: "deleted-slug" }), TEST_CONFIG); expect(deleteCalls).toEqual(["deleted-slug"]); expect(upsertCalls).toEqual([]); expect(embedWithBackendCalls).toEqual([]); }); }); describe("embedConceptPageJob — defensive", () => { test("skips when slug is missing from the payload", async () => { await embedConceptPageJob(makeJob({}), TEST_CONFIG); expect(upsertCalls).toEqual([]); expect(deleteCalls).toEqual([]); expect(embedWithBackendCalls).toEqual([]); }); test("skips when slug is the empty string", async () => { await embedConceptPageJob(makeJob({ slug: "" }), TEST_CONFIG); expect(upsertCalls).toEqual([]); expect(deleteCalls).toEqual([]); }); }); describe("embedConceptPageJob — Qdrant breaker integration", () => { test("half-open probe success closes the breaker so embed catch-up unthrottles", async () => { // Trip the breaker by recording 5 consecutive Qdrant failures. Without // this fix, `embed_concept_page` bypassed the breaker entirely — winning // the half-open probe slot did not transition state back to closed and // the embed lane stayed throttled at one job per tick indefinitely. for (let i = 0; i < 5; i++) { try { await withQdrantBreaker(async () => { throw new Error("simulated qdrant failure"); }); } catch { // expected } } expect(isQdrantBreakerOpen()).toBe(true); // Advance time past the 30s cooldown so the next breaker call transitions // open → half-open and allows the probe through. const originalNow = Date.now; Date.now = () => originalNow() + 60_000; try { await writePage(tmpWorkspace, { slug: "probe-success", frontmatter: { edges: [], ref_files: [], ref_urls: [] }, body: "Probe body.\n", }); await embedConceptPageJob( makeJob({ slug: "probe-success" }), TEST_CONFIG, ); } finally { Date.now = originalNow; } expect(upsertCalls).toHaveLength(1); // Probe succeeded → breaker should now be closed (not open, not // half-open), restoring full embed-lane concurrency. expect(isQdrantBreakerOpen()).toBe(false); }); test("half-open probe success on the delete path also closes the breaker", async () => { // Same flow as above but exercising the missing-page branch — both v2 // Qdrant calls (`upsert` and `delete`) must close the breaker on success. for (let i = 0; i < 5; i++) { try { await withQdrantBreaker(async () => { throw new Error("simulated qdrant failure"); }); } catch { // expected } } expect(isQdrantBreakerOpen()).toBe(true); const originalNow = Date.now; Date.now = () => originalNow() + 60_000; try { // No `writePage` — the handler takes the delete branch. await embedConceptPageJob(makeJob({ slug: "missing-slug" }), TEST_CONFIG); } finally { Date.now = originalNow; } expect(deleteCalls).toEqual(["missing-slug"]); expect(isQdrantBreakerOpen()).toBe(false); }); }); describe("enqueueEmbedConceptPageJob", () => { test("enqueues a pending embed_concept_page job with the slug payload", () => { const id = enqueueEmbedConceptPageJob({ slug: "alice-prefers-vs-code" }); expect(id).toBeTruthy(); const claimed = claimMemoryJobs({ slowLlm: 10, fast: 10, embed: 10 }); expect(claimed).toHaveLength(1); const [job] = claimed; expect(job.type).toBe("embed_concept_page"); expect(job.payload).toEqual({ slug: "alice-prefers-vs-code" }); }); test("round-trip: enqueued job dispatches through embedConceptPageJob", async () => { await writePage(tmpWorkspace, { slug: "round-trip-slug", frontmatter: { edges: [], ref_files: [], ref_urls: [] }, body: "Round-trip body.\n", }); enqueueEmbedConceptPageJob({ slug: "round-trip-slug" }); const claimed = claimMemoryJobs({ slowLlm: 10, fast: 10, embed: 10 }); expect(claimed).toHaveLength(1); const [job] = claimed; expect(job.type).toBe("embed_concept_page"); await embedConceptPageJob(job, TEST_CONFIG); expect(upsertCalls).toHaveLength(1); expect(upsertCalls[0].slug).toBe("round-trip-slug"); }); test("inserted job row carries the right type and slug payload", () => { const id = enqueueEmbedConceptPageJob({ slug: "row-check" }); const row = getMemoryDb()! .select() .from(memoryJobs) .all() .find((r) => r.id === id); expect(row).toBeDefined(); expect(row!.type).toBe("embed_concept_page"); expect(JSON.parse(row!.payload)).toEqual({ slug: "row-check" }); }); test("coalesces with an existing pending job for the same slug", () => { const firstId = enqueueEmbedConceptPageJob({ slug: "dup-slug" }); const secondId = enqueueEmbedConceptPageJob({ slug: "dup-slug" }); expect(secondId).toBe(firstId); const rows = getMemoryDb()! .select() .from(memoryJobs) .all() .filter((r) => r.type === "embed_concept_page"); expect(rows).toHaveLength(1); }); test("different slugs do not coalesce", () => { enqueueEmbedConceptPageJob({ slug: "slug-a" }); enqueueEmbedConceptPageJob({ slug: "slug-b" }); const slugs = getMemoryDb()! .select() .from(memoryJobs) .all() .filter((r) => r.type === "embed_concept_page") .map((r) => JSON.parse(r.payload).slug) .sort(); expect(slugs).toEqual(["slug-a", "slug-b"]); }); test("a coalesced enqueue keeps the pending row's runAfter (backoff preserved)", () => { // Seed a pending row deferred into the future — the shape a // breaker-deferred or retry-backed-off row has. A coalescing enqueue // must not pull it earlier. const futureRunAfter = Date.now() + 300_000; const id = enqueueMemoryJob( "embed_concept_page", { slug: "deferred-slug" }, futureRunAfter, ); const coalescedId = enqueueEmbedConceptPageJob({ slug: "deferred-slug" }); expect(coalescedId).toBe(id); const row = getMemoryDb()! .select() .from(memoryJobs) .all() .find((r) => r.id === id); expect(row!.runAfter).toBe(futureRunAfter); }); test("a running job for the slug does not suppress a fresh enqueue", () => { // A running job may have already read the page from disk, so a write // landing after it started must still get its own pending row. const firstId = enqueueEmbedConceptPageJob({ slug: "in-flight" }); const claimed = claimMemoryJobs({ slowLlm: 10, fast: 10, embed: 10 }); expect(claimed.map((j) => j.id)).toEqual([firstId]); const secondId = enqueueEmbedConceptPageJob({ slug: "in-flight" }); expect(secondId).not.toBe(firstId); const rows = getMemoryDb()! .select() .from(memoryJobs) .all() .filter((r) => r.type === "embed_concept_page"); expect(rows.map((r) => r.status).sort()).toEqual(["pending", "running"]); }); });