import { describe, it, expect, beforeEach } from 'vitest'; import { mkdtemp } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { writeCache, type CacheFile } from '../../src/lib/cache.js'; import { makeTasksTools } from '../../src/tools/tasks.js'; import type { Backend, CommitFileArgs, CommitResult, FileWithSha } from '../../src/lib/backend.js'; function recordingBackend(initial: Record = {}) { const files = { ...initial }; const commits: CommitFileArgs[] = []; const backend: Backend = { async listUserRepos() { return []; }, async getRawFile(_u, repo, path) { return files[`${repo}:${path}`]?.content ?? null; }, async getFileWithSha(_u, repo, path) { return files[`${repo}:${path}`] ?? null; }, async commitFile(args: CommitFileArgs): Promise { commits.push({ ...args }); const fileSha = `sha-${commits.length}`; files[`${args.repo}:${args.path}`] = { content: args.content, sha: fileSha }; return { fileSha, commitSha: `commit-${commits.length}` }; }, }; return { backend, commits, files }; } const fixture: CacheFile = { schemaVersion: 3, synced_at: '2026-06-08T12:00:00.000Z', synced_from: 'https://g', machine: 'm', projects: [ { name: 'OpeItcLoc03/alpha', default_branch: 'main', fetched_at: '2026-06-08T12:00:01.000Z', active_tasks: [], all_tasks_count: 0, raw: '', }, ], errors: [], }; let cacheFile: string; const fixedNow = () => new Date('2026-06-08T10:00:00.000Z'); beforeEach(async () => { const dir = await mkdtemp(join(tmpdir(), 'trail-tool-')); cacheFile = join(dir, 'tasks.json'); await writeCache(cacheFile, fixture); }); function makeTools(backend: Backend) { return makeTasksTools({ cacheFile, backend, giteaUser: 'u', agendaTasksRepo: 'projects-tasks', machine: 'host-x', now: fixedNow, }); } function call(tools: ReturnType, name: string, args: Record) { const t = tools.find((x) => x.name === name); if (!t) throw new Error(`tool ${name} not registered`); return t.handler(args); } const base = { target_project: 'OpeItcLoc03/alpha', slug: 'my-task', question: 'cache key includes locale?', decided_by: 'arbiter', ruling: 'include locale', rationale: 'avoids cross-locale bleed', blast_radius: 'reversible', escalation_chain: ['brief'], }; describe('tasks.append_decision_trail', () => { it('is registered', () => { const { backend } = recordingBackend(); const tools = makeTools(backend); expect(tools.find((t) => t.name === 'tasks.append_decision_trail')).toBeTruthy(); }); it('commits straight (no confirm gate — infra append) and targets .tasks/.md', async () => { const { backend, commits } = recordingBackend({ 'alpha:.tasks/my-task.md': { content: '# my-task\n\n## Goal\nx\n', sha: 'old' }, }); const tools = makeTools(backend); const r = await call(tools, 'tasks.append_decision_trail', base); const parsed = JSON.parse(r.content[0].text); expect(parsed.committed).toBe(true); expect(commits).toHaveLength(1); expect(commits[0].path).toBe('.tasks/my-task.md'); expect(commits[0].sha).toBe('old'); // updated existing file via sha lock expect(commits[0].content).toContain('## Decision trail'); expect(commits[0].content).toContain('### consult 1 —'); }); it('returns a trail_ref pointer the worker can be handed', async () => { const { backend } = recordingBackend({ 'alpha:.tasks/my-task.md': { content: '# my-task\n', sha: 'old' }, }); const tools = makeTools(backend); const r = await call(tools, 'tasks.append_decision_trail', base); const parsed = JSON.parse(r.content[0].text); expect(typeof parsed.trail_ref).toBe('string'); expect(parsed.trail_ref).toContain('my-task'); }); it('creates the task file when it does not exist yet (no sha)', async () => { const { backend, commits } = recordingBackend({}); const tools = makeTools(backend); const r = await call(tools, 'tasks.append_decision_trail', base); const parsed = JSON.parse(r.content[0].text); expect(parsed.committed).toBe(true); expect(commits[0].sha).toBeUndefined(); // create, not update expect(commits[0].content).toContain('# my-task'); expect(commits[0].content).toContain('### consult 1 —'); }); it('writes every contract field', async () => { const { backend, commits } = recordingBackend({}); const tools = makeTools(backend); await call(tools, 'tasks.append_decision_trail', base); const c = commits[0].content; expect(c).toContain('- question: cache key includes locale?'); expect(c).toContain('- blast_radius: reversible'); expect(c).toContain('- decided_by: arbiter'); expect(c).toContain('- ruling: include locale'); expect(c).toContain('- rationale: avoids cross-locale bleed'); expect(c).toContain('- escalation_chain: brief'); }); it('numbers a second consult as 2', async () => { const { backend, commits } = recordingBackend({}); const tools = makeTools(backend); await call(tools, 'tasks.append_decision_trail', base); await call(tools, 'tasks.append_decision_trail', { ...base, question: 'second?' }); expect(commits[1].content).toContain('### consult 2 —'); }); it('logs a halt outcome (decided_by human-required, no ruling)', async () => { const { backend, commits } = recordingBackend({}); const tools = makeTools(backend); await call(tools, 'tasks.append_decision_trail', { target_project: 'OpeItcLoc03/alpha', slug: 'my-task', question: 'priorities?', decided_by: 'human-required', rationale: 'escalated: arbiter declared a fork', escalation_chain: ['brief', 'arbiter-fork'], }); expect(commits[0].content).toContain('- decided_by: human-required'); expect(commits[0].content).toContain('- ruling: —'); }); it('is disabled without a backend', async () => { const tools = makeTasksTools({ cacheFile }); const r = await call(tools, 'tasks.append_decision_trail', base); expect(r.isError).toBe(true); }); });