diff --git a/packages/agents/scripts/testing/smoke-test-utils.ts b/packages/agents/scripts/testing/smoke-test-utils.ts index c5d38c24..13d9c349 100644 --- a/packages/agents/scripts/testing/smoke-test-utils.ts +++ b/packages/agents/scripts/testing/smoke-test-utils.ts @@ -183,35 +183,16 @@ export function makeKbEditSmokeTest(): SmokeTestInvocation { } /** - * Stands up an event store carrying a single seed event plus an isolated home registering it as `default_kb`, then - * returns a `SmokeTestInvocation` that recalls the event by a body term scoped to that store. Exercises the full scope → - * ripgrep recall → note-set scoping → event projection pipeline, the only path that wires the bundled search primitive - * and the event projection together. `ripgrep` (`rg`) must be on PATH for recall to find the seed event. + * Stands up an event store plus an isolated home registering it, then returns a `SmokeTestInvocation` that scopes to a + * store name the registry does not carry. Exercises the bundled resolver from home discovery through registry parse to + * the scope verdict and the JSON result shape, stopping short of recall. + * + * Keep the invocation off the recall path: recalling here would put ripgrep on the critical path of every build. */ export function makeKbRetrieveEventsSmokeTest(): SmokeTestInvocation { const storePath = mkdtempSync(path.join(tmpdir(), 'kb-retrieve-events-store-')); mkdirSync(resolveKbDir(storePath), { recursive: true }); - mkdirSync(resolveEventsDir(storePath), { recursive: true }); - writeFileSync( - resolveEventPath({ storePath, id: 'smoke-event' }), - [ - '---', - 'recordType: event', - 'id: smoke-event', - 'captured-at: 2026-06-18T09:41:02Z', - 'session: smoke', - 'cwd: /tmp/smoke', - 'summary: Smoke retrieve event', - 'repo: owner/repo-smoke', - '---', - '', - 'A smoke note mentioning retrievesmokequux.', - '', - ].join('\n'), - 'utf8', - ); - const home = mkdtempSync(path.join(tmpdir(), 'kb-retrieve-events-home-')); mkdirSync(path.join(home, '.agents'), { recursive: true }); writeFileSync( @@ -221,7 +202,7 @@ export function makeKbRetrieveEventsSmokeTest(): SmokeTestInvocation { ); return { - args: ['retrievesmokequux', '--store', 'codeassembly'], + args: ['retrievesmokequux', '--store', 'no-such-store'], env: { ...process.env, HOME: home }, assertResult: assertKbRetrieveEventsSmokeResult, }; @@ -466,23 +447,19 @@ function assertKbEditSmokeResult(result: unknown): void { } } -/** Assert the kb-retrieve-events smoke recalled the seed event and projected it with its summary and capture timestamp. */ +/** Assert the kb-retrieve-events smoke resolved the registry and reported the requested store as unregistered. */ function assertKbRetrieveEventsSmokeResult(result: unknown): void { if (!isRecord(result)) { throw new TypeError('expected object result from kb-retrieve-events'); } - if (!Array.isArray(result.candidates) || result.candidates.length === 0) { - throw new Error(`expected at least one event candidate, got ${JSON.stringify(result)}`); - } - const candidate: unknown = result.candidates[0]; - if (!isRecord(candidate)) { - throw new TypeError('expected a candidate object'); + if (!Array.isArray(result.candidates) || result.candidates.length > 0) { + throw new Error(`expected an empty candidate table, got ${JSON.stringify(result)}`); } - if (candidate.summary !== 'Smoke retrieve event') { - throw new Error(`expected summary 'Smoke retrieve event', got ${JSON.stringify(candidate.summary)}`); + if (!Array.isArray(result.scopedKbs) || result.scopedKbs.length > 0) { + throw new Error(`expected an empty scope, got ${JSON.stringify(result.scopedKbs)}`); } - if (typeof candidate.capturedAt !== 'string' || !candidate.capturedAt.includes('2026-06-18')) { - throw new Error(`expected an ISO capturedAt, got ${JSON.stringify(candidate.capturedAt)}`); + if (typeof result.diagnostic !== 'string' || !result.diagnostic.includes('is not registered in kb.yaml')) { + throw new Error(`expected an unregistered-store diagnostic, got ${JSON.stringify(result.diagnostic)}`); } } diff --git a/packages/agents/src/kb-retrieve-events/__tests__/cli.test.ts b/packages/agents/src/kb-retrieve-events/__tests__/cli.test.ts index 7bed1c80..95c915e3 100644 --- a/packages/agents/src/kb-retrieve-events/__tests__/cli.test.ts +++ b/packages/agents/src/kb-retrieve-events/__tests__/cli.test.ts @@ -2,6 +2,7 @@ import { join } from 'node:path'; import { describe, expect, it } from 'vitest'; +import { buildRecallStub } from '../../kb-search/test-utils/build-recall-stub.ts'; import { parseArgs, runRetrieveEvents } from '../cli.ts'; // The vault and registry fixtures live with the shared search primitive (kb-search), which owns scope and recall. @@ -58,7 +59,12 @@ describe(parseArgs, () => { describe(runRetrieveEvents, () => { it('returns an event candidate carrying its recurrence signals', async () => { - const result = await runRetrieveEvents({ argv: ['phantomwidget'], startDir: NOTES_VAULT, home: FIXTURES }); + const result = await runRetrieveEvents({ + argv: ['phantomwidget'], + startDir: NOTES_VAULT, + home: FIXTURES, + recall: buildRecallStub({ hits: [eventNote('01HZCEVENTAAAAAAAAAAAAAAAA.md')] }), + }); expect(result.candidates).toHaveLength(1); expect(result.candidates[0]).toMatchObject({ @@ -70,15 +76,25 @@ describe(runRetrieveEvents, () => { }); it('excludes assertion records, pointing the reader at assertion recall', async () => { - const result = await runRetrieveEvents({ argv: ['backpressure'], startDir: NOTES_VAULT, home: FIXTURES }); + const result = await runRetrieveEvents({ + argv: ['backpressure'], + startDir: NOTES_VAULT, + home: FIXTURES, + recall: buildRecallStub({ hits: [join(NOTES_VAULT, 'streams.md')] }), + }); - // backpressure matches only an assertion, so the event table is empty and the diagnostic routes to assertion recall. + // The only recalled note is an assertion, so the event table is empty and the diagnostic routes to assertion recall. expect(result.candidates).toEqual([]); expect(result.diagnostic).toMatch(/kb-retrieve/); }); it('reports a no-match diagnostic when nothing matches', async () => { - const result = await runRetrieveEvents({ argv: ['zzzznomatch'], startDir: NOTES_VAULT, home: FIXTURES }); + const result = await runRetrieveEvents({ + argv: ['zzzznomatch'], + startDir: NOTES_VAULT, + home: FIXTURES, + recall: buildRecallStub(), + }); expect(result.candidates).toEqual([]); expect(result.diagnostic).toBe('no notes matched the query'); @@ -89,6 +105,7 @@ describe(runRetrieveEvents, () => { argv: ['phantomwidget', '--store', 'no-such-store'], startDir: NOTES_VAULT, home: FIXTURES, + recall: buildRecallStub(), }); expect(result.candidates).toEqual([]); @@ -97,14 +114,24 @@ describe(runRetrieveEvents, () => { }); it('reports a diagnostic when the query is blank', async () => { - const result = await runRetrieveEvents({ argv: [], startDir: NOTES_VAULT, home: FIXTURES }); + const result = await runRetrieveEvents({ + argv: [], + startDir: NOTES_VAULT, + home: FIXTURES, + recall: buildRecallStub(), + }); expect(result.candidates).toEqual([]); expect(result.diagnostic).toBe('no query provided'); }); it('surfaces a declared impact on a candidate and omits it on an unrated one', async () => { - const result = await runRetrieveEvents({ argv: ['snorkleweft'], startDir: NOTES_VAULT, home: FIXTURES }); + const result = await runRetrieveEvents({ + argv: ['snorkleweft'], + startDir: NOTES_VAULT, + home: FIXTURES, + recall: buildRecallStub({ hits: snorkleweftEvents() }), + }); const byImpact = new Map(result.candidates.map((candidate) => [candidate.summary, candidate.impact])); expect(byImpact.get('A snorkleweft outage rated high')).toBe('high'); @@ -116,6 +143,7 @@ describe(runRetrieveEvents, () => { argv: ['snorkleweft', '--min-impact', 'high'], startDir: NOTES_VAULT, home: FIXTURES, + recall: buildRecallStub({ hits: snorkleweftEvents() }), }); expect(result.candidates).toHaveLength(1); @@ -127,9 +155,24 @@ describe(runRetrieveEvents, () => { argv: ['snorkleweft', '--min-impact', 'critical'], startDir: NOTES_VAULT, home: FIXTURES, + recall: buildRecallStub({ hits: snorkleweftEvents() }), }); expect(result.candidates).toEqual([]); expect(result.diagnostic).toBe('all matches were below the --min-impact threshold of critical'); }); }); + +/** Resolves an event record's filename to its absolute path in the shared fixture vault. */ +function eventNote(name: string): string { + return join(NOTES_VAULT, 'content', 'events', name); +} + +/** The fixture events mentioning `snorkleweft`: one rated high, one rated lower, one left unrated. */ +function snorkleweftEvents(): string[] { + return [ + eventNote('01HZCEVENTHGHAAAAAAAAAAAAA.md'), + eventNote('01HZCEVENTMNRAAAAAAAAAAAAA.md'), + eventNote('01HZCEVENTNRTAAAAAAAAAAAAA.md'), + ]; +} diff --git a/packages/agents/src/kb-retrieve-events/cli.ts b/packages/agents/src/kb-retrieve-events/cli.ts index a8f6abe8..6e699298 100644 --- a/packages/agents/src/kb-retrieve-events/cli.ts +++ b/packages/agents/src/kb-retrieve-events/cli.ts @@ -6,6 +6,7 @@ import { fileURLToPath } from 'node:url'; import { EVENT_IMPACT_LEVELS, type EventImpact, isEventImpact } from '@codeassembly/kb/records'; +import type { RecallFn } from '../kb-search/recall.ts'; import { recordTypeOf, searchNotes } from '../kb-search/search.ts'; import type { RecallFilters } from '../kb-search/types.ts'; import { type FlagSpec, scanFlags, valueFlagMap } from '../lib/parse-flags.ts'; @@ -117,6 +118,7 @@ export async function runRetrieveEvents(input: { argv: readonly string[]; startDir: string; home?: string; + recall?: RecallFn; }): Promise { const { query, allKbs, storeName, filters, minImpact } = parseArgs(input.argv); @@ -131,6 +133,7 @@ export async function runRetrieveEvents(input: { startDir: input.startDir, ...(storeName !== null && { storeName }), ...(input.home !== undefined && { home: input.home }), + ...(input.recall !== undefined && { recall: input.recall }), }); if (search.emptyScopeDiagnostic !== undefined) { diff --git a/packages/agents/src/kb-retrieve/__tests__/cli.test.ts b/packages/agents/src/kb-retrieve/__tests__/cli.test.ts index 6a3d38ea..74d2ca7f 100644 --- a/packages/agents/src/kb-retrieve/__tests__/cli.test.ts +++ b/packages/agents/src/kb-retrieve/__tests__/cli.test.ts @@ -2,6 +2,7 @@ import { join } from 'node:path'; import { describe, expect, it } from 'vitest'; +import { buildRecallStub } from '../../kb-search/test-utils/build-recall-stub.ts'; import { parseArgs, runRetrieve } from '../cli.ts'; // The vault and registry fixtures live with the shared search primitive (kb-search), which owns scope and recall; the @@ -96,6 +97,7 @@ describe(runRetrieve, () => { startDir: NOTES_VAULT, now: NOW, home: FIXTURES, + recall: buildRecallStub({ hits: [join(NOTES_VAULT, 'streams.md')] }), }); expect(result.candidates.map((candidate) => candidate.title)).toContain('Working with Node.js streams'); @@ -108,9 +110,10 @@ describe(runRetrieve, () => { startDir: NOTES_VAULT, now: NOW, home: FIXTURES, + recall: buildRecallStub({ hits: [eventNote('01HZCEVENTAAAAAAAAAAAAAAAA.md')] }), }); - // phantomwidget matches only an event, so the assertion table is empty and the diagnostic routes to event recall. + // The only recalled note is an event, so the assertion table is empty and the diagnostic routes to event recall. expect(result.candidates).toEqual([]); expect(result.diagnostic).toMatch(/kb-retrieve-events/); }); @@ -121,6 +124,7 @@ describe(runRetrieve, () => { startDir: NOTES_VAULT, now: NOW, home: FIXTURES, + recall: buildRecallStub({ hits: [join(NOTES_VAULT, 'legacy-untyped.md')] }), }); expect(result.candidates).toHaveLength(1); @@ -134,10 +138,18 @@ describe(runRetrieve, () => { startDir: NOTES_VAULT, now: NOW, home: FIXTURES, + // hooks.md is diataxis: reference, so the filter has something to exclude. + recall: buildRecallStub({ + hits: [ + join(NOTES_VAULT, 'new-guide.md'), + join(NOTES_VAULT, 'mid-guide.md'), + join(NOTES_VAULT, 'sub', 'hooks.md'), + ], + }), }); expect(result.candidates.every((candidate) => candidate.diataxis === 'howto')).toBe(true); - expect(result.candidates.length).toBeGreaterThan(0); + expect(result.candidates).toHaveLength(2); }); it('reports a diagnostic and no candidates when nothing matches', async () => { @@ -146,6 +158,7 @@ describe(runRetrieve, () => { startDir: NOTES_VAULT, now: NOW, home: FIXTURES, + recall: buildRecallStub(), }); expect(result.candidates).toEqual([]); @@ -159,6 +172,7 @@ describe(runRetrieve, () => { startDir: NOTES_VAULT, now: NOW, home: FIXTURES, + recall: buildRecallStub({ hits: [join(NOTES_VAULT, 'streams.md')] }), }); expect(result.candidates).toEqual([]); @@ -167,7 +181,13 @@ describe(runRetrieve, () => { }); it('reports a diagnostic when no knowledge base is configured or discovered', async () => { - const result = await runRetrieve({ argv: ['anything'], startDir: '/', now: NOW, home: FIXTURES }); + const result = await runRetrieve({ + argv: ['anything'], + startDir: '/', + now: NOW, + home: FIXTURES, + recall: buildRecallStub(), + }); expect(result.scopedKbs).toEqual([]); expect(result.warnings).toEqual([]); @@ -180,6 +200,7 @@ describe(runRetrieve, () => { startDir: NOTES_VAULT, now: NOW, home: FIXTURES, + recall: buildRecallStub(), }); expect(result.scopedKbs).toEqual([]); @@ -187,14 +208,25 @@ describe(runRetrieve, () => { }); it('reports a diagnostic when the query is blank', async () => { - const result = await runRetrieve({ argv: ['--all-kbs'], startDir: NOTES_VAULT, now: NOW }); + const result = await runRetrieve({ + argv: ['--all-kbs'], + startDir: NOTES_VAULT, + now: NOW, + recall: buildRecallStub(), + }); expect(result.candidates).toEqual([]); expect(result.diagnostic).toBe('no query provided'); }); it('names the registry defect in warnings and diagnostic when a malformed registry is the only KB', async () => { - const result = await runRetrieve({ argv: ['anything'], startDir: MALFORMED_NO_KB, now: NOW, home: FIXTURES }); + const result = await runRetrieve({ + argv: ['anything'], + startDir: MALFORMED_NO_KB, + now: NOW, + home: FIXTURES, + recall: buildRecallStub(), + }); expect(result.candidates).toEqual([]); expect(result.scopedKbs).toEqual([]); @@ -204,44 +236,61 @@ describe(runRetrieve, () => { }); it('returns candidates while still warning about a malformed registry alongside a discovered KB', async () => { - const result = await runRetrieve({ argv: ['zarquon'], startDir: MALFORMED_REGISTRY, now: NOW, home: FIXTURES }); + const result = await runRetrieve({ + argv: ['zarquon'], + startDir: MALFORMED_REGISTRY, + now: NOW, + home: FIXTURES, + recall: buildRecallStub({ hits: [join(MALFORMED_REGISTRY, 'searchable-note.md')] }), + }); - expect(result.candidates.length).toBeGreaterThan(0); + expect(result.candidates).toHaveLength(1); expect(result.warnings).toHaveLength(1); expect(result.warnings[0]).toMatch(/^registry invalid: /); expect(result.diagnostic).toBeUndefined(); }); it('warns about a dead-path entry that is the only KB and excludes it from scopedKbs', async () => { - const result = await runRetrieve({ argv: ['anything'], startDir: DEAD_PATH_REGISTRY, now: NOW, home: FIXTURES }); + const ghostPath = join(DEAD_PATH_REGISTRY, '.agents', 'no-such-kb-directory'); + const result = await runRetrieve({ + argv: ['anything'], + startDir: DEAD_PATH_REGISTRY, + now: NOW, + home: FIXTURES, + recall: buildRecallStub({ missing: [ghostPath] }), + }); expect(result.candidates).toEqual([]); expect(result.scopedKbs).toEqual([]); - expect(result.warnings).toEqual([ - `registry KB "ghost-vault" path does not exist: ${join(DEAD_PATH_REGISTRY, '.agents', 'no-such-kb-directory')}`, - ]); + expect(result.warnings).toEqual([`registry KB "ghost-vault" path does not exist: ${ghostPath}`]); expect(result.diagnostic).toBe('no notes matched the query'); }); it('returns candidates while warning about a dead-path entry alongside a live KB', async () => { + const ghostPath = join(MIXED_REGISTRY, '.agents', 'no-such-kb-directory'); const result = await runRetrieve({ argv: ['backpressure', '--all-kbs'], startDir: MIXED_REGISTRY, now: NOW, home: FIXTURES, + recall: buildRecallStub({ hits: [join(NOTES_VAULT, 'streams.md')], missing: [ghostPath] }), }); - expect(result.candidates.length).toBeGreaterThan(0); - expect(result.warnings).toEqual([ - `registry KB "ghost-vault" path does not exist: ${join(MIXED_REGISTRY, '.agents', 'no-such-kb-directory')}`, - ]); + expect(result.candidates).toHaveLength(1); + expect(result.warnings).toEqual([`registry KB "ghost-vault" path does not exist: ${ghostPath}`]); expect(result.diagnostic).toBeUndefined(); }); it('keeps warnings empty when no registry is configured and a discovered KB is searched', async () => { - const result = await runRetrieve({ argv: ['backpressure'], startDir: NOTES_VAULT, now: NOW, home: FIXTURES }); + const result = await runRetrieve({ + argv: ['backpressure'], + startDir: NOTES_VAULT, + now: NOW, + home: FIXTURES, + recall: buildRecallStub({ hits: [join(NOTES_VAULT, 'streams.md')] }), + }); - expect(result.candidates.length).toBeGreaterThan(0); + expect(result.candidates).toHaveLength(1); expect(result.warnings).toEqual([]); }); @@ -251,6 +300,7 @@ describe(runRetrieve, () => { startDir: CUSTOM_SCHEMA_VAULT, now: NOW, home: FIXTURES, + recall: buildRecallStub({ hits: [join(CUSTOM_SCHEMA_VAULT, 'insight-note.md')] }), }); // insight-note declares recordType: insight — neither an assertion nor an event — so no retrieve command claims it. @@ -265,9 +315,10 @@ describe(runRetrieve, () => { startDir: MALFORMED_SCHEMA_VAULT, now: NOW, home: FIXTURES, + recall: buildRecallStub({ hits: [join(MALFORMED_SCHEMA_VAULT, 'plain-note.md')] }), }); - expect(result.candidates.length).toBeGreaterThan(0); + expect(result.candidates).toHaveLength(1); expect(result.warnings).toEqual([]); // Ranking is unaffected: the assertion note still ranks by freshness (2026-04-20 to 2026-05-01). expect(result.candidates[0]?.lastVerifiedAgeDays).toBe(11); @@ -280,6 +331,9 @@ describe(runRetrieve, () => { startDir: MULTI_SCHEMA_REGISTRY, now: NOW, home: FIXTURES, + recall: buildRecallStub({ + hits: [join(CUSTOM_SCHEMA_VAULT, 'insight-note.md'), join(MALFORMED_SCHEMA_VAULT, 'plain-note.md')], + }), }); const insight = result.candidates.find((candidate) => candidate.path.includes('insight-note.md')); @@ -291,9 +345,21 @@ describe(runRetrieve, () => { }); it('recalls only notes inside the configured targets, skipping root and excluded markdown', async () => { - // scoped-vault stores `zephyrquux` in README.md (root), content/in-scope.md, and content/drafts/excluded.md; - // its config targets `content/**/*.md` and excludes `content/drafts/**`, so only in-scope.md is a note. - const result = await runRetrieve({ argv: ['zephyrquux'], startDir: SCOPED_VAULT, now: NOW, home: FIXTURES }); + // scoped-vault's config targets `content/**/*.md` and excludes `content/drafts/**`, so of the three recalled + // notes only in-scope.md is inside the note set. + const result = await runRetrieve({ + argv: ['zephyrquux'], + startDir: SCOPED_VAULT, + now: NOW, + home: FIXTURES, + recall: buildRecallStub({ + hits: [ + join(SCOPED_VAULT, 'README.md'), + join(SCOPED_VAULT, 'content', 'drafts', 'excluded.md'), + join(SCOPED_VAULT, 'content', 'in-scope.md'), + ], + }), + }); expect(result.candidates.map((candidate) => candidate.path.split('/').at(-1))).toEqual(['in-scope.md']); expect(result.warnings).toEqual([]); @@ -306,6 +372,9 @@ describe(runRetrieve, () => { startDir: DEFAULT_SCOPE_VAULT, now: NOW, home: FIXTURES, + recall: buildRecallStub({ + hits: [join(DEFAULT_SCOPE_VAULT, 'README.md'), join(DEFAULT_SCOPE_VAULT, 'content', 'note.md')], + }), }); expect(result.candidates.map((candidate) => candidate.path.split('/').at(-1))).toEqual(['note.md']); @@ -317,12 +386,18 @@ describe(runRetrieve, () => { startDir: MALFORMED_CONFIG_VAULT, now: NOW, home: FIXTURES, + recall: buildRecallStub({ hits: [join(MALFORMED_CONFIG_VAULT, 'content', 'note.md')] }), }); - // The content/ note still recalls under the degraded default config, and the defect surfaces as one warning. + // The content/ note still survives under the degraded default config, and the defect surfaces as one warning. expect(result.candidates.map((candidate) => candidate.path.split('/').at(-1))).toEqual(['note.md']); expect(result.warnings).toHaveLength(1); expect(result.warnings[0]).toMatch(/config invalid/); expect(result.diagnostic).toBeUndefined(); }); }); + +/** Resolves an event record's filename to its absolute path in the shared fixture vault. */ +function eventNote(name: string): string { + return join(NOTES_VAULT, 'content', 'events', name); +} diff --git a/packages/agents/src/kb-retrieve/cli.ts b/packages/agents/src/kb-retrieve/cli.ts index 4e7a25fe..d438e401 100644 --- a/packages/agents/src/kb-retrieve/cli.ts +++ b/packages/agents/src/kb-retrieve/cli.ts @@ -4,6 +4,7 @@ import { realpathSync } from 'node:fs'; import process from 'node:process'; import { fileURLToPath } from 'node:url'; +import type { RecallFn } from '../kb-search/recall.ts'; import { recordTypeOf, searchNotes } from '../kb-search/search.ts'; import type { RecallFilters } from '../kb-search/types.ts'; import { type FlagSpec, scanFlags, valueFlagMap } from '../lib/parse-flags.ts'; @@ -124,6 +125,7 @@ export async function runRetrieve(input: { startDir: string; now: Date; home?: string; + recall?: RecallFn; }): Promise { const { query, allKbs, storeName, filters } = parseArgs(input.argv); @@ -138,6 +140,7 @@ export async function runRetrieve(input: { startDir: input.startDir, ...(storeName !== null && { storeName }), ...(input.home !== undefined && { home: input.home }), + ...(input.recall !== undefined && { recall: input.recall }), }); if (search.emptyScopeDiagnostic !== undefined) { diff --git a/packages/agents/src/kb-search/__tests__/fixtures/notes-vault/2024-archive/legacy-runbook.md b/packages/agents/src/kb-search/__tests__/fixtures/notes-vault/2024-archive/legacy-runbook.md deleted file mode 100644 index d5760ab8..00000000 --- a/packages/agents/src/kb-search/__tests__/fixtures/notes-vault/2024-archive/legacy-runbook.md +++ /dev/null @@ -1,10 +0,0 @@ ---- -title: Legacy archival runbook -recordType: assertion -diataxis: howto -created: 2024-03-01T09:17:05Z -updated: 2024-03-01T15:58:33Z -tags: [archive] ---- - -A note about quobble retention stored under a digit-prefixed directory. diff --git a/packages/agents/src/kb-search/__tests__/fixtures/notes-vault/2026-05-01-meeting-notes.md b/packages/agents/src/kb-search/__tests__/fixtures/notes-vault/2026-05-01-meeting-notes.md deleted file mode 100644 index 4b04fc4f..00000000 --- a/packages/agents/src/kb-search/__tests__/fixtures/notes-vault/2026-05-01-meeting-notes.md +++ /dev/null @@ -1,10 +0,0 @@ ---- -title: Meeting notes -recordType: assertion -diataxis: note -created: 2026-05-01T07:46:29Z -updated: 2026-05-01T13:41:50Z -tags: [meeting] ---- - -A note about flummox stored in a file with a date-patterned name. diff --git a/packages/agents/src/kb-search/__tests__/fixtures/notes-vault/2026-06-01/daily-log.md b/packages/agents/src/kb-search/__tests__/fixtures/notes-vault/2026-06-01/daily-log.md deleted file mode 100644 index c181bb2a..00000000 --- a/packages/agents/src/kb-search/__tests__/fixtures/notes-vault/2026-06-01/daily-log.md +++ /dev/null @@ -1,10 +0,0 @@ ---- -title: Daily log -recordType: assertion -diataxis: note -created: 2026-06-01T10:34:12Z -updated: 2026-06-01T17:22:08Z -tags: [journal] ---- - -A note about grumbletwist stored under a date-patterned directory. diff --git a/packages/agents/src/kb-search/__tests__/fixtures/notes-vault/multi-match.md b/packages/agents/src/kb-search/__tests__/fixtures/notes-vault/multi-match.md deleted file mode 100644 index b0c0c25a..00000000 --- a/packages/agents/src/kb-search/__tests__/fixtures/notes-vault/multi-match.md +++ /dev/null @@ -1,14 +0,0 @@ ---- -title: Multi-match snippet fixture -recordType: assertion -diataxis: howto -created: 2026-03-01T09:05:51Z -updated: 2026-03-01T16:13:47Z -tags: [test] ---- - -The first thunderfish appears near the top of the note. - -Some intervening content that does not match the query term. - -A second thunderfish appears further down so ripgrep emits more than three events for this file. diff --git a/packages/agents/src/kb-search/__tests__/recall.test.ts b/packages/agents/src/kb-search/__tests__/recall.test.ts index 9fcc036c..0f4c8606 100644 --- a/packages/agents/src/kb-search/__tests__/recall.test.ts +++ b/packages/agents/src/kb-search/__tests__/recall.test.ts @@ -1,7 +1,9 @@ import { join } from 'node:path'; -import { describe, expect, it } from 'vitest'; +import type { Mock } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; +import type { ProcessRunner } from '../recall.ts'; import { parseRipgrepOutput, recallNotes } from '../recall.ts'; import type { ScopedKb } from '../types.ts'; @@ -10,101 +12,148 @@ const NOTES_VAULT = join(import.meta.dirname, 'fixtures', 'notes-vault'); const notesVaultScope: ScopedKb[] = [{ name: 'notes', path: NOTES_VAULT, via: 'discovery' }]; describe(recallNotes, () => { - it('returns notes whose body contains a query term', async () => { - const { hits } = await recallNotes({ query: 'backpressure', scopedKbs: notesVaultScope }); - - expect(matchedBasenames(hits)).toEqual(['streams.md']); + it('invokes ripgrep with the markdown glob, the .kb exclusion, the context window, and the JSON format', async () => { + const runner = vi.fn().mockResolvedValue({ stdout: '' }); + + await recallNotes({ query: 'backpressure', scopedKbs: notesVaultScope, runner }); + + expect(runner).toHaveBeenCalledWith('rg', [ + '--ignore-case', + '--glob', + '*.md', + '--glob', + '!.kb/**', + '--context', + '1', + '--json', + 'backpressure', + NOTES_VAULT, + ]); }); - it('returns a note stored under a digit-prefixed directory', async () => { - // A path segment such as `2024-archive` must not be misread as a ripgrep content line. - const { hits } = await recallNotes({ query: 'quobble', scopedKbs: notesVaultScope }); + it('escapes regex metacharacters so a query term matches literally', async () => { + const runner = vi.fn().mockResolvedValue({ stdout: '' }); + + await recallNotes({ query: 'c++', scopedKbs: notesVaultScope, runner }); - expect(matchedBasenames(hits)).toEqual(['legacy-runbook.md']); + expect(readPattern(runner)).toBe(String.raw`c\+\+`); }); - it('returns a note whose filename is date-patterned', async () => { - // A filename such as 2026-05-01-meeting-notes.md must not have its `-05-` run misread as ripgrep line number. - const { hits } = await recallNotes({ query: 'flummox', scopedKbs: notesVaultScope }); + it('combines query terms disjunctively in a single pattern', async () => { + const runner = vi.fn().mockResolvedValue({ stdout: '' }); - expect(matchedBasenames(hits)).toEqual(['2026-05-01-meeting-notes.md']); + await recallNotes({ query: 'backpressure dependency', scopedKbs: notesVaultScope, runner }); + + expect(readPattern(runner)).toBe('backpressure|dependency'); }); - it('returns a note stored under a date-patterned directory', async () => { - // A directory such as `2026-06-01` must not have its `-06-` run misread as the ripgrep line-number field. - const { hits } = await recallNotes({ query: 'grumbletwist', scopedKbs: notesVaultScope }); + it('expands an alias query term to its canonical tag', async () => { + // "node" is an alias for the canonical tag "nodejs"; notes carry canonical tags only, so the alias alone + // would never match one. + const runner = vi.fn().mockResolvedValue({ stdout: '' }); - expect(matchedBasenames(hits)).toEqual(['daily-log.md']); + await recallNotes({ query: 'node', scopedKbs: notesVaultScope, runner }); + + expect(readPattern(runner)).toBe('node|nodejs'); }); it('attributes each hit to its source KB name and path', async () => { - const { hits } = await recallNotes({ query: 'backpressure', scopedKbs: notesVaultScope }); - - expect(hits[0]?.kbName).toBe('notes'); - expect(hits[0]?.kbPath).toBe(NOTES_VAULT); + const runner = vi + .fn() + .mockResolvedValue({ stdout: buildRipgrepOutput([[join(NOTES_VAULT, 'streams.md'), 'notes on backpressure']]) }); + + const { hits } = await recallNotes({ query: 'backpressure', scopedKbs: notesVaultScope, runner }); + + expect(hits).toEqual([ + { + path: join(NOTES_VAULT, 'streams.md'), + kbName: 'notes', + kbPath: NOTES_VAULT, + snippet: 'notes on backpressure', + }, + ]); }); it('propagates a null kbName for a discovered KB with no registry entry', async () => { const scope: ScopedKb[] = [{ name: null, path: NOTES_VAULT, via: 'discovery' }]; - const { hits } = await recallNotes({ query: 'backpressure', scopedKbs: scope }); + const runner = vi + .fn() + .mockResolvedValue({ stdout: buildRipgrepOutput([[join(NOTES_VAULT, 'streams.md'), 'backpressure']]) }); + + const { hits } = await recallNotes({ query: 'backpressure', scopedKbs: scope, runner }); expect(hits[0]?.kbName).toBeNull(); }); - it('treats each query term disjunctively', async () => { - const { hits } = await recallNotes({ query: 'backpressure dependency', scopedKbs: notesVaultScope }); + it('returns no hits when ripgrep exits 1 to report that nothing matched', async () => { + const runner = vi.fn().mockRejectedValue(buildProcessError(1)); - expect(matchedBasenames(hits)).toEqual(['hooks.md', 'streams.md']); - }); - - it('expands an alias query term to its canonical tag', async () => { - // "node" is an alias for the canonical tag "nodejs"; the streams note is tagged "nodejs" only. - const { hits } = await recallNotes({ query: 'node', scopedKbs: notesVaultScope }); + const { hits } = await recallNotes({ query: 'zzzznomatch', scopedKbs: notesVaultScope, runner }); - expect(matchedBasenames(hits)).toContain('streams.md'); + expect(hits).toEqual([]); }); - it('returns an empty array when no note matches', async () => { - const { hits } = await recallNotes({ query: 'zzzznomatch', scopedKbs: notesVaultScope }); + it('throws a remediation hint when the ripgrep binary cannot be spawned', async () => { + const runner = vi.fn().mockRejectedValue(buildProcessError('ENOENT')); - expect(hits).toEqual([]); + await expect(recallNotes({ query: 'backpressure', scopedKbs: notesVaultScope, runner })).rejects.toThrow( + /requires ripgrep/, + ); }); - it('returns an empty array for a blank query', async () => { - const { hits } = await recallNotes({ query: ' '.repeat(3), scopedKbs: notesVaultScope }); + it('rethrows a ripgrep failure that is neither a no-match exit nor an absent binary', async () => { + const runner = vi.fn().mockRejectedValue(buildProcessError(2)); - expect(hits).toEqual([]); + await expect(recallNotes({ query: 'backpressure', scopedKbs: notesVaultScope, runner })).rejects.toThrow( + /mock process failure: 2/, + ); }); - it('captures a context snippet for each matched note', async () => { - const { hits } = await recallNotes({ query: 'backpressure', scopedKbs: notesVaultScope }); + it('throws when ripgrep reports matches but none of its output can be parsed', async () => { + const runner = vi.fn().mockResolvedValue({ stdout: '{"type":"match","data":{"unexpected":true}}' }); - expect(hits[0]?.snippet).toContain('backpressure'); + await expect(recallNotes({ query: 'backpressure', scopedKbs: notesVaultScope, runner })).rejects.toThrow( + /--json event format/, + ); }); - it('returns a single hit and a capped snippet for a note with multiple matches', async () => { - // multi-match.md carries the unique term on two non-adjacent lines, so ripgrep emits more than three line events - // for the file. - // The note must surface as one hit (not duplicated per match), and its snippet must include only the 1st match's - // window; the 2nd match falls beyond the `SNIPPET_CONTEXT_LINES * 2 + 1` = 3 line cap in `parseRipgrepOutput`. - const { hits } = await recallNotes({ query: 'thunderfish', scopedKbs: notesVaultScope }); + it('runs no search at all for a blank query', async () => { + const runner = vi.fn(); + + const { hits } = await recallNotes({ query: ' '.repeat(3), scopedKbs: notesVaultScope, runner }); - expect(matchedBasenames(hits)).toEqual(['multi-match.md']); - expect(hits[0]?.snippet).toContain('first thunderfish'); - expect(hits[0]?.snippet).not.toContain('second thunderfish'); + expect(hits).toEqual([]); + expect(runner).not.toHaveBeenCalled(); }); it('skips a scoped KB whose path does not exist and reports it in missingKbs', async () => { const missing: ScopedKb = { name: 'missing', path: join(NOTES_VAULT, 'no-such-dir'), via: 'registry-all' }; - const scope: ScopedKb[] = [missing, ...notesVaultScope]; - const { hits, missingKbs } = await recallNotes({ query: 'backpressure', scopedKbs: scope }); + const runner = vi + .fn() + .mockResolvedValue({ stdout: buildRipgrepOutput([[join(NOTES_VAULT, 'streams.md'), 'backpressure']]) }); + + const { hits, missingKbs } = await recallNotes({ + query: 'backpressure', + scopedKbs: [missing, ...notesVaultScope], + runner, + }); - expect(matchedBasenames(hits)).toEqual(['streams.md']); + expect(hits.map((hit) => hit.kbName)).toEqual(['notes']); expect(missingKbs).toEqual([missing]); + expect(runner).toHaveBeenCalledTimes(1); }); }); describe(parseRipgrepOutput, () => { + it('reads a note path from the structured field rather than from the line text', () => { + // The date-patterned segments carry digit runs that resemble ripgrep's line-number field. + const stream = buildRipgrepOutput([['/vault/2026-06-01/2026-05-01-meeting-notes.md', 'grumbletwist']]); + + expect(parseRipgrepOutput(stream)).toEqual([ + { path: '/vault/2026-06-01/2026-05-01-meeting-notes.md', snippet: 'grumbletwist' }, + ]); + }); + it('skips a malformed JSON line and still returns valid matches', () => { // A line that is not valid JSON must be dropped silently, so that a single corrupted event does not lose the // surrounding valid matches in the same stream. @@ -119,9 +168,39 @@ describe(parseRipgrepOutput, () => { expect(entries).toEqual([{ path: './a.md', snippet: 'hello world' }]); }); + + it('returns one entry per note and caps its snippet at the first match window', () => { + // A note matching on several non-adjacent lines emits more than three line events. It must surface once, with a + // snippet drawn from the first match and its neighbors only. + const stream = buildRipgrepOutput([ + ['./multi.md', 'first thunderfish'], + ['./multi.md', 'neighbor one'], + ['./multi.md', 'neighbor two'], + ['./multi.md', 'second thunderfish'], + ]); + + const entries = parseRipgrepOutput(stream); + + expect(entries).toHaveLength(1); + expect(entries[0]?.snippet).toBe('first thunderfish neighbor one neighbor two'); + }); }); -/** Collects the basenames of the matched note paths for order-independent assertions. */ -function matchedBasenames(hits: ReadonlyArray<{ path: string }>): string[] { - return hits.map((hit) => hit.path.split('/').at(-1) ?? '').toSorted(); +/** Builds a child-process error carrying `code`, the shape `execFile` rejects with on a bad exit or a failed spawn. */ +function buildProcessError(code: number | string): Error & { code: number | string } { + return Object.assign(new Error(`mock process failure: ${code}`), { code }); +} + +/** Builds ripgrep `--json` stdout carrying one match event per `[notePath, lineText]` pair. */ +function buildRipgrepOutput(matches: ReadonlyArray): string { + return matches + .map(([path, text]) => + JSON.stringify({ type: 'match', data: { path: { text: path }, lines: { text: `${text}\n` } } }), + ) + .join('\n'); +} + +/** Reads the search pattern out of the recorded ripgrep invocation: the argument preceding the search directory. */ +function readPattern(runner: Mock): string | undefined { + return runner.mock.calls[0]?.[1].at(-2); } diff --git a/packages/agents/src/kb-search/__tests__/search.test.ts b/packages/agents/src/kb-search/__tests__/search.test.ts index 6845b336..516e6c78 100644 --- a/packages/agents/src/kb-search/__tests__/search.test.ts +++ b/packages/agents/src/kb-search/__tests__/search.test.ts @@ -2,7 +2,9 @@ import { join } from 'node:path'; import { describe, expect, it, vi } from 'vitest'; +import type { RecallFn } from '../recall.ts'; import { searchNotes } from '../search.ts'; +import { buildRecallStub } from '../test-utils/build-recall-stub.ts'; const FIXTURES = join(import.meta.dirname, 'fixtures'); const NOTES_VAULT = join(FIXTURES, 'notes-vault'); @@ -15,6 +17,7 @@ describe(searchNotes, () => { filters: {}, startDir: NOTES_VAULT, home: FIXTURES, + recall: buildRecallStub({ hits: [notePath('streams.md')] }), }); const streams = result.hits.find((hit) => hit.hit.path.endsWith('streams.md')); @@ -30,11 +33,14 @@ describe(searchNotes, () => { filters: { diataxis: 'howto' }, startDir: NOTES_VAULT, home: FIXTURES, + recall: buildRecallStub({ + hits: [notePath('new-guide.md'), notePath('mid-guide.md'), notePath('sub/hooks.md')], + }), }); - expect(result.hits.length).toBeGreaterThan(0); expect(result.hits.every((hit) => hit.note.frontmatter?.extra.diataxis === 'howto')).toBe(true); - expect(result.recalledCount).toBeGreaterThanOrEqual(result.hits.length); + expect(result.hits).toHaveLength(2); + expect(result.recalledCount).toBe(3); }); it('returns no hits but a positive recalledCount when a filter excludes every match', async () => { @@ -44,13 +50,16 @@ describe(searchNotes, () => { filters: { folder: 'zzz-nonexistent' }, startDir: NOTES_VAULT, home: FIXTURES, + recall: buildRecallStub({ hits: [notePath('streams.md')] }), }); expect(result.hits).toEqual([]); - expect(result.recalledCount).toBeGreaterThan(0); + expect(result.recalledCount).toBe(1); }); - it('sets an empty-scope diagnostic and searches nothing for an unregistered store', async () => { + it('sets an empty-scope diagnostic and recalls nothing for an unregistered store', async () => { + const recall = vi.fn(buildRecallStub({ hits: [notePath('streams.md')] })); + const result = await searchNotes({ query: 'anything', allKbs: false, @@ -58,11 +67,13 @@ describe(searchNotes, () => { filters: {}, startDir: NOTES_VAULT, home: FIXTURES, + recall, }); expect(result.hits).toEqual([]); expect(result.scopedKbs).toEqual([]); expect(result.emptyScopeDiagnostic).toMatch(/not registered/); + expect(recall).not.toHaveBeenCalled(); }); it('skips an unreadable note and surfaces a warning rather than dropping it silently', async () => { @@ -85,6 +96,7 @@ describe(searchNotes, () => { filters: {}, startDir: NOTES_VAULT, home: FIXTURES, + recall: buildRecallStub({ hits: [notePath('streams.md'), notePath('new-guide.md')] }), }); expect(result.hits.some((hit) => hit.hit.path.endsWith('streams.md'))).toBe(false); @@ -96,3 +108,8 @@ describe(searchNotes, () => { vi.resetModules(); }); }); + +/** Resolves a note's vault-relative name to its absolute path in the shared fixture vault. */ +function notePath(name: string): string { + return join(NOTES_VAULT, name); +} diff --git a/packages/agents/src/kb-search/recall.ts b/packages/agents/src/kb-search/recall.ts index 7c417112..55f55364 100644 --- a/packages/agents/src/kb-search/recall.ts +++ b/packages/agents/src/kb-search/recall.ts @@ -11,6 +11,18 @@ import type { RawHit, ScopedKb } from './types.ts'; const execFileAsync = promisify(execFile); +/** + * Runs a process and resolves its captured stdout, rejecting with an error carrying `code` on a non-zero exit or a + * failed spawn. The only path by which recall reaches ripgrep, so a caller can substitute one and spawn nothing. + */ +export type ProcessRunner = (command: string, args: readonly string[]) => Promise<{ stdout: string }>; + +/** Recalls notes for a query across the in-scope KBs. The seam `searchNotes` injects, so a caller can substitute one. */ +export type RecallFn = (input: { query: string; scopedKbs: ScopedKb[] }) => Promise; + +/** Output cap for one ripgrep invocation, sized well past the match set of a large vault. */ +const RIPGREP_MAX_BUFFER = 32 * 1024 * 1024; + /** Number of context lines captured on each side of a ripgrep match for the snippet. */ const SNIPPET_CONTEXT_LINES = 1; @@ -33,14 +45,20 @@ export interface RecallResult { * An in-scope KB whose path is absent (`ENOENT` / `ENOTDIR`) is skipped and reported in `missingKbs` so that callers * can surface the dead path; a permission error (`EACCES` / `EPERM`) on a path that does exist still throws. * - * ripgrep is required on `PATH`; an absent binary throws with a remediation hint. + * ripgrep is required on `PATH`; an absent binary throws with a remediation hint. `runner` overrides how the process is + * reached, defaulting to a real `rg` invocation. */ -export async function recallNotes(input: { query: string; scopedKbs: ScopedKb[] }): Promise { +export async function recallNotes(input: { + query: string; + scopedKbs: ScopedKb[]; + runner?: ProcessRunner; +}): Promise { const baseTerms = tokenizeQuery(input.query); if (baseTerms.length === 0) { return { hits: [], missingKbs: [] }; } + const runner = input.runner ?? runRipgrepProcess; const hits: RawHit[] = []; const missingKbs: ScopedKb[] = []; for (const kb of input.scopedKbs) { @@ -50,7 +68,7 @@ export async function recallNotes(input: { query: string; scopedKbs: ScopedKb[] } const aliases = await loadAliasesForKb(kb.path); const terms = expandTerms(baseTerms, aliases); - const kbHits = await searchKb({ kb, terms }); + const kbHits = await searchKb({ kb, terms, runner }); hits.push(...kbHits); } return { hits, missingKbs }; @@ -185,24 +203,20 @@ export function parseRipgrepOutput(stdout: string): Array<{ path: string; snippe } /** Invokes ripgrep over `*.md` files and return its stdout; an empty match set yields an empty string. */ -async function runRipgrep(input: { pattern: string; searchDir: string }): Promise { +async function runRipgrep(input: { pattern: string; searchDir: string; runner: ProcessRunner }): Promise { try { - const { stdout } = await execFileAsync( - 'rg', - [ - '--ignore-case', - '--glob', - '*.md', - '--glob', - `!${KB_DIR}/**`, - '--context', - String(SNIPPET_CONTEXT_LINES), - '--json', - input.pattern, - input.searchDir, - ], - { maxBuffer: 32 * 1024 * 1024 }, - ); + const { stdout } = await input.runner('rg', [ + '--ignore-case', + '--glob', + '*.md', + '--glob', + `!${KB_DIR}/**`, + '--context', + String(SNIPPET_CONTEXT_LINES), + '--json', + input.pattern, + input.searchDir, + ]); return stdout; } catch (error) { // ripgrep exits 1 when no matches are found — that is an empty result, not a failure. @@ -216,25 +230,31 @@ async function runRipgrep(input: { pattern: string; searchDir: string }): Promis } } -/** Runs a single ripgrep invocation across one KB and collect its hits, de-duplicated by note path. */ -async function searchKb(input: { kb: ScopedKb; terms: string[] }): Promise { +/** The default {@link ProcessRunner}: spawns the real binary, capping its output at {@link RIPGREP_MAX_BUFFER}. */ +async function runRipgrepProcess(command: string, args: readonly string[]): Promise<{ stdout: string }> { + return execFileAsync(command, [...args], { maxBuffer: RIPGREP_MAX_BUFFER }); +} + +/** Runs a single ripgrep invocation across one KB and attributes each parsed entry to it. */ +async function searchKb(input: { kb: ScopedKb; terms: string[]; runner: ProcessRunner }): Promise { const pattern = input.terms.map(escapeRegExp).join('|'); - const stdout = await runRipgrep({ pattern, searchDir: input.kb.path }); + const stdout = await runRipgrep({ pattern, searchDir: input.kb.path, runner: input.runner }); const matches = parseRipgrepOutput(stdout); - const byPath = new Map(); - for (const match of matches) { - if (byPath.has(match.path)) { - continue; - } - byPath.set(match.path, { - path: match.path, - kbName: input.kb.name, - kbPath: input.kb.path, - snippet: match.snippet, - }); + // ripgrep exits 1 when nothing matched, which `runRipgrep` maps to an empty string, so output here means it found + // something. Parsing none of it therefore means the `--json` event shape no longer matches what this module reads. + if (stdout.trim() !== '' && matches.length === 0) { + throw new Error( + `ripgrep reported matches in ${input.kb.path} but none of its output could be parsed; its --json event format may have changed`, + ); } - return [...byPath.values()]; + + return matches.map((match) => ({ + path: match.path, + kbName: input.kb.name, + kbPath: input.kb.path, + snippet: match.snippet, + })); } /** Splits a query string into lowercase search terms, dropping empties. */ diff --git a/packages/agents/src/kb-search/search.ts b/packages/agents/src/kb-search/search.ts index 448ec415..4ba9d513 100644 --- a/packages/agents/src/kb-search/search.ts +++ b/packages/agents/src/kb-search/search.ts @@ -6,6 +6,7 @@ import type { ParsedNote } from '@codeassembly/kb/frontmatter'; import { resolveKbDir } from '@codeassembly/kb/layout'; import { extractString, parseNoteSafely } from '../kb-shared/note-helpers.ts'; +import type { RecallFn } from './recall.ts'; import { recallNotes } from './recall.ts'; import { resolveScope } from './scope.ts'; import type { RawHit, RecallFilters, ScopedKb, SearchHit, SearchResult } from './types.ts'; @@ -24,7 +25,8 @@ import type { RawHit, RecallFilters, ScopedKb, SearchHit, SearchResult } from '. * frontmatter still becomes a hit (a degraded one), so a broken note is not hidden from the projecting command. * * `home` overrides the directory the user-global `kb.yaml` is read from; it exists so tests can isolate registry - * resolution from the developer's environment. + * resolution from the developer's environment. `recall` overrides how candidate notes are recalled, defaulting to + * ripgrep; it exists so a test of scoping, filtering, or projection never spawns a process. */ export async function searchNotes(input: { query: string; @@ -33,6 +35,7 @@ export async function searchNotes(input: { filters: RecallFilters; startDir: string; home?: string; + recall?: RecallFn; }): Promise { const { kbs: inScopeKbs, @@ -54,7 +57,8 @@ export async function searchNotes(input: { }; } - const { hits: rawHits, missingKbs } = await recallNotes({ query: input.query, scopedKbs: inScopeKbs }); + const recall = input.recall ?? recallNotes; + const { hits: rawHits, missingKbs } = await recall({ query: input.query, scopedKbs: inScopeKbs }); // Scope ripgrep's raw hits to each KB's configured note set — the same `targets`/`exclude` definition `kb check` // enforces — so non-note markdown under the root and excluded paths never reach the candidate table. diff --git a/packages/agents/src/kb-search/test-utils/build-recall-stub.ts b/packages/agents/src/kb-search/test-utils/build-recall-stub.ts new file mode 100644 index 00000000..e58b4a02 --- /dev/null +++ b/packages/agents/src/kb-search/test-utils/build-recall-stub.ts @@ -0,0 +1,36 @@ +import { sep } from 'node:path'; + +import type { RecallFn, RecallResult } from '../recall.ts'; +import type { RawHit, ScopedKb } from '../types.ts'; + +/** + * Builds a recall stub reporting `hits` as the notes found, attributing each to whichever in-scope KB contains it, and + * reporting the KB roots in `missing` as absent. The query is ignored: every call recalls the same notes. + * + * A hit path under no in-scope KB throws, since it can only mean the test named a note the scope never covered. + */ +export function buildRecallStub(input: { hits?: readonly string[]; missing?: readonly string[] } = {}): RecallFn { + const hitPaths = input.hits ?? []; + const missingPaths = new Set(input.missing); + + return function recallStub({ scopedKbs }): Promise { + const searched = scopedKbs.filter((kb) => !missingPaths.has(kb.path)); + return Promise.resolve({ + hits: hitPaths.map((path) => buildHit(path, searched)), + missingKbs: scopedKbs.filter((kb) => missingPaths.has(kb.path)), + }); + }; +} + +// region | Helpers + +/** Attributes one note path to the in-scope KB that contains it, matching the shape real recall reports. */ +function buildHit(path: string, searchedKbs: readonly ScopedKb[]): RawHit { + const kb = searchedKbs.find((candidate) => path === candidate.path || path.startsWith(`${candidate.path}${sep}`)); + if (kb === undefined) { + throw new Error(`recall stub was given the note "${path}", which lies under none of the in-scope KBs`); + } + return { path, kbName: kb.name, kbPath: kb.path, snippet: `snippet for ${path}` }; +} + +// endregion | Helpers