import { describe, it, expect, beforeEach } from 'vitest'; import { mkdtemp, mkdir } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { makeKnowledgeTools } from '../../src/tools/knowledge.js'; import { writeCache, type CacheFile } from '../../src/lib/cache.js'; import type { Backend, CommitFileArgs, FileWithSha } from '../../src/lib/backend.js'; let wikiRoot: string; let projectCwd: string; let cacheFile: string; beforeEach(async () => { wikiRoot = await mkdtemp(join(tmpdir(), 'kw-shared-')); await mkdir(wikiRoot, { recursive: true }); projectCwd = await mkdtemp(join(tmpdir(), 'kw-cwd-')); cacheFile = join(await mkdtemp(join(tmpdir(), 'kw-cache-')), 'cache.json'); }); interface FetchedPage { body: string; } /** * Pages are keyed by qualified `/` so the mock backend can verify * that handlers route fetches to the correct owner — the whole point of the * cross-owner regression in [multi-owner-ask-projects-owner-fix]. */ function makeBackend(pages: Record>): Backend { return { async listUserRepos() { return []; }, async getRawFile(owner, repo, path) { const repoPages = pages[`${owner}/${repo}`]; if (!repoPages) return null; const v = repoPages[path]; if (v === undefined) return null; if (v instanceof Error) throw v; return v.body; }, async getFileWithSha(): Promise { return null; }, async commitFile(_a: CommitFileArgs) { throw new Error('not used'); }, }; } async function seedCache(projects: CacheFile['projects']) { const file: CacheFile = { schemaVersion: 3, synced_at: '2026-04-30T00:00:00Z', synced_from: 'https://g', machine: 'm', projects, errors: [], }; await writeCache(cacheFile, file); } function call( tools: ReturnType, name: string, args: Record, ) { const t = tools.find((x) => x.name === name); if (!t) throw new Error(`tool ${name} missing`); return t.handler(args); } const sampleIndex = `# Wiki Index ## Concepts - [yarn-on-windows](concepts/yarn-on-windows.md) — Yarn on Windows - [other](concepts/other.md) — Other Concept ## Raw - [pdf-dump](raw/pdf-dump.md) — Heavy PDF `; describe('knowledge.ask_projects', () => { it('returns matches across listed projects with snippets', async () => { await seedCache([ { name: 'u/foo', default_branch: 'main', fetched_at: '2026-04-30T00:00:00Z', active_tasks: [], all_tasks_count: 0, raw: '', wiki_index: sampleIndex, }, ]); const backend = makeBackend({ 'u/foo': { '.wiki/concepts/yarn-on-windows.md': { body: '---\ntitle: Yarn on Windows\n---\n\nyarn install on windows is fiddly', }, }, }); const tools = makeKnowledgeTools({ wikiRoot, projectCwd, cacheFile, backend, giteaUser: 'u', }); const r = await call(tools, 'knowledge.ask_projects', { query: 'yarn', projects: ['u/foo'], }); const parsed = JSON.parse(r.content[0].text); expect(parsed.asked_projects).toEqual(['u/foo']); expect(parsed.results).toHaveLength(1); expect(parsed.results[0].project).toBe('u/foo'); expect(parsed.results[0].matches).toHaveLength(1); expect(parsed.results[0].matches[0].slug).toBe('concepts/yarn-on-windows'); expect(parsed.results[0].matches[0].type).toBe('concepts'); expect(parsed.results[0].matches[0].title).toBe('Yarn on Windows'); expect(parsed.results[0].matches[0].snippet).toContain('yarn'); }); it('routes fetches to the correct owner across multiple owners (cross-owner)', async () => { await seedCache([ { name: 'victor/books', default_branch: 'main', fetched_at: '2026-04-30T00:00:00Z', active_tasks: [], all_tasks_count: 0, raw: '', wiki_index: sampleIndex, }, { name: 'OpeItcLoc03/common', default_branch: 'master', fetched_at: '2026-04-30T00:00:00Z', active_tasks: [], all_tasks_count: 0, raw: '', wiki_index: sampleIndex, }, ]); const calls: Array<{ owner: string; repo: string }> = []; const backend: Backend = { async listUserRepos() { return []; }, async getRawFile(owner, repo, path) { calls.push({ owner, repo }); if (owner === 'victor' && repo === 'books') { return path === '.wiki/concepts/yarn-on-windows.md' ? 'victor body about yarn' : null; } if (owner === 'OpeItcLoc03' && repo === 'common') { return path === '.wiki/concepts/yarn-on-windows.md' ? 'common body about yarn' : null; } // Reject acting-identity routing — proves the bug is gone. throw new Error(`unexpected fetch ${owner}/${repo}`); }, async getFileWithSha(): Promise { return null; }, async commitFile() { throw new Error('not used'); }, }; const tools = makeKnowledgeTools({ wikiRoot, projectCwd, cacheFile, backend, giteaUser: 'OpeItcLoc03', }); const r = await call(tools, 'knowledge.ask_projects', { query: 'yarn', projects: ['victor/books', 'OpeItcLoc03/common'], }); const parsed = JSON.parse(r.content[0].text); expect(parsed.results).toHaveLength(2); const victorResult = parsed.results.find((x: { project: string }) => x.project === 'victor/books'); const commonResult = parsed.results.find((x: { project: string }) => x.project === 'OpeItcLoc03/common'); expect(victorResult.matches[0].snippet).toContain('victor body'); expect(commonResult.matches[0].snippet).toContain('common body'); // Owner extracted from projectName, not from acting giteaUser. expect(calls.some((c) => c.owner === 'victor' && c.repo === 'books')).toBe(true); expect(calls.some((c) => c.owner === 'OpeItcLoc03' && c.repo === 'common')).toBe(true); }); it('rejects bare project name with parseTargetProject hint', async () => { await seedCache([]); const backend = makeBackend({}); const tools = makeKnowledgeTools({ wikiRoot, projectCwd, cacheFile, backend, giteaUser: 'u', }); const r = await call(tools, 'knowledge.ask_projects', { query: 'x', projects: ['books'], }); const parsed = JSON.parse(r.content[0].text); expect(parsed.results).toHaveLength(1); expect(parsed.results[0].project).toBe('books'); expect(parsed.results[0].error).toContain('must be qualified'); }); it('continues with valid projects when one is bare', async () => { await seedCache([ { name: 'u/foo', default_branch: 'main', fetched_at: '2026-04-30T00:00:00Z', active_tasks: [], all_tasks_count: 0, raw: '', wiki_index: sampleIndex, }, ]); const backend = makeBackend({ 'u/foo': { '.wiki/concepts/yarn-on-windows.md': { body: 'yarn body' }, }, }); const tools = makeKnowledgeTools({ wikiRoot, projectCwd, cacheFile, backend, giteaUser: 'u', }); const r = await call(tools, 'knowledge.ask_projects', { query: 'yarn', projects: ['books', 'u/foo'], }); const parsed = JSON.parse(r.content[0].text); expect(parsed.results).toHaveLength(2); expect(parsed.results[0].error).toContain('must be qualified'); expect(parsed.results[1].matches).toHaveLength(1); }); it('rejects agenda literal with explanatory error', async () => { await seedCache([]); const backend = makeBackend({}); const tools = makeKnowledgeTools({ wikiRoot, projectCwd, cacheFile, backend, giteaUser: 'u', }); const r = await call(tools, 'knowledge.ask_projects', { query: 'x', projects: ['agenda'], }); const parsed = JSON.parse(r.content[0].text); expect(parsed.results[0].error).toContain('agenda not supported'); }); it('reports "no wiki" for projects without wiki_index', async () => { await seedCache([ { name: 'u/no-wiki', default_branch: 'main', fetched_at: '2026-04-30T00:00:00Z', active_tasks: [], all_tasks_count: 0, raw: '', }, ]); const backend = makeBackend({}); const tools = makeKnowledgeTools({ wikiRoot, projectCwd, cacheFile, backend, giteaUser: 'u', }); const r = await call(tools, 'knowledge.ask_projects', { query: 'anything', projects: ['u/no-wiki'], }); const parsed = JSON.parse(r.content[0].text); expect(parsed.results).toEqual([ { project: 'u/no-wiki', error: 'no wiki — repo has no .wiki/index.md' }, ]); }); it('reports "unknown project" when project not in cache', async () => { await seedCache([]); const backend = makeBackend({}); const tools = makeKnowledgeTools({ wikiRoot, projectCwd, cacheFile, backend, giteaUser: 'u', }); const r = await call(tools, 'knowledge.ask_projects', { query: 'x', projects: ['u/ghost'], }); const parsed = JSON.parse(r.content[0].text); expect(parsed.results).toEqual([ { project: 'u/ghost', error: 'unknown project — run sync first' }, ]); }); it('excludes raw type by default', async () => { await seedCache([ { name: 'u/foo', default_branch: 'main', fetched_at: '2026-04-30T00:00:00Z', active_tasks: [], all_tasks_count: 0, raw: '', wiki_index: sampleIndex, }, ]); const backend = makeBackend({ 'u/foo': { '.wiki/raw/pdf-dump.md': { body: 'pdf about pdf' }, }, }); const tools = makeKnowledgeTools({ wikiRoot, projectCwd, cacheFile, backend, giteaUser: 'u', }); const r = await call(tools, 'knowledge.ask_projects', { query: 'pdf', projects: ['u/foo'], }); const parsed = JSON.parse(r.content[0].text); expect(parsed.results[0].matches).toEqual([]); }); it('includes raw when explicitly asked', async () => { await seedCache([ { name: 'u/foo', default_branch: 'main', fetched_at: '2026-04-30T00:00:00Z', active_tasks: [], all_tasks_count: 0, raw: '', wiki_index: sampleIndex, }, ]); const backend = makeBackend({ 'u/foo': { '.wiki/raw/pdf-dump.md': { body: 'pdf body' } }, }); const tools = makeKnowledgeTools({ wikiRoot, projectCwd, cacheFile, backend, giteaUser: 'u', }); const r = await call(tools, 'knowledge.ask_projects', { query: 'pdf', projects: ['u/foo'], types: ['raw'], }); const parsed = JSON.parse(r.content[0].text); expect(parsed.results[0].matches).toHaveLength(1); expect(parsed.results[0].matches[0].slug).toBe('raw/pdf-dump'); }); it('respects limit per project', async () => { const wideIndex = `# Wiki Index\n\n## Concepts\n\n- [a](concepts/a.md) — A\n- [b](concepts/b.md) — B\n- [c](concepts/c.md) — C\n`; await seedCache([ { name: 'u/foo', default_branch: 'main', fetched_at: '2026-04-30T00:00:00Z', active_tasks: [], all_tasks_count: 0, raw: '', wiki_index: wideIndex, }, ]); const backend = makeBackend({ 'u/foo': { '.wiki/concepts/a.md': { body: '' }, '.wiki/concepts/b.md': { body: '' }, '.wiki/concepts/c.md': { body: '' }, }, }); const tools = makeKnowledgeTools({ wikiRoot, projectCwd, cacheFile, backend, giteaUser: 'u', }); const r = await call(tools, 'knowledge.ask_projects', { query: '', projects: ['u/foo'], limit: 2, }); const parsed = JSON.parse(r.content[0].text); expect(parsed.results[0].matches).toHaveLength(2); }); it('marks snippet as "(fetch failed)" when live fetch throws', async () => { await seedCache([ { name: 'u/foo', default_branch: 'main', fetched_at: '2026-04-30T00:00:00Z', active_tasks: [], all_tasks_count: 0, raw: '', wiki_index: sampleIndex, }, ]); const backend = makeBackend({ 'u/foo': { '.wiki/concepts/yarn-on-windows.md': new Error('boom'), }, }); const tools = makeKnowledgeTools({ wikiRoot, projectCwd, cacheFile, backend, giteaUser: 'u', }); const r = await call(tools, 'knowledge.ask_projects', { query: 'yarn', projects: ['u/foo'], }); const parsed = JSON.parse(r.content[0].text); expect(parsed.results[0].matches[0].snippet).toBe('(fetch failed)'); expect(parsed.results[0].matches[0].slug).toBe('concepts/yarn-on-windows'); }); it('returns error when backend not configured', async () => { await seedCache([]); const tools = makeKnowledgeTools({ wikiRoot, projectCwd, cacheFile }); const r = await call(tools, 'knowledge.ask_projects', { query: 'x', projects: ['u/foo'], }); expect(r.isError).toBe(true); expect(r.content[0].text).toContain('disabled'); }); it('next_action_hint mentions both knowledge.get_from and tasks.create', async () => { await seedCache([]); const tools = makeKnowledgeTools({ wikiRoot, projectCwd, cacheFile, backend: makeBackend({}), giteaUser: 'u', }); const r = await call(tools, 'knowledge.ask_projects', { query: 'x', projects: ['u/foo'], }); const parsed = JSON.parse(r.content[0].text); expect(parsed.next_action_hint).toContain('knowledge.get_from'); expect(parsed.next_action_hint).toContain('tasks.create'); }); });