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: [], }; const ACTIVE_BOARD = '# Task Board\n_Updated: 2026-06-08_\n\n## 🔴 [my-task] — A thing\n\n' + '**Status:** active\n**Where I stopped:** mid-flight\n**Next action:** keep going\n' + '**Branch:** master\n**Owner:** host:claude-opus:1\n**Claim token:** tok-123\n\n---\n'; let cacheFile: string; const fixedNow = () => new Date('2026-06-08T10:00:00.000Z'); beforeEach(async () => { const dir = await mkdtemp(join(tmpdir(), 'park-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: 'Should we drop the legacy column? (irreversible)', }; describe('tasks.park_question', () => { it('is registered', () => { const { backend } = recordingBackend(); expect(makeTools(backend).find((t) => t.name === 'tasks.park_question')).toBeTruthy(); }); it('flips status to blocked and writes the pending question (atomic, single commit)', async () => { const { backend, commits } = recordingBackend({ 'alpha:.tasks/STATUS.md': { content: ACTIVE_BOARD, sha: 'old' }, }); const tools = makeTools(backend); const r = await call(tools, 'tasks.park_question', base); const parsed = JSON.parse(r.content[0].text); expect(parsed.parked).toBe(true); expect(commits).toHaveLength(1); expect(commits[0].path).toBe('.tasks/STATUS.md'); expect(commits[0].content).toContain('## 🔵 [my-task]'); expect(commits[0].content).toContain('**Status:** blocked'); expect(commits[0].content).toContain('Should we drop the legacy column?'); }); it('commits straight (no confirm gate — infra mutation)', async () => { const { backend, commits } = recordingBackend({ 'alpha:.tasks/STATUS.md': { content: ACTIVE_BOARD, sha: 'old' }, }); const tools = makeTools(backend); await call(tools, 'tasks.park_question', base); // no confirm flag expect(commits).toHaveLength(1); }); it('with a matching claim_token, parks (token-gated like heartbeat)', async () => { const { backend, commits } = recordingBackend({ 'alpha:.tasks/STATUS.md': { content: ACTIVE_BOARD, sha: 'old' }, }); const tools = makeTools(backend); const r = await call(tools, 'tasks.park_question', { ...base, claim_token: 'tok-123' }); expect(JSON.parse(r.content[0].text).parked).toBe(true); expect(commits).toHaveLength(1); }); it('rejects a claim_token mismatch without committing (someone else holds the claim)', async () => { const { backend, commits } = recordingBackend({ 'alpha:.tasks/STATUS.md': { content: ACTIVE_BOARD, sha: 'old' }, }); const tools = makeTools(backend); const r = await call(tools, 'tasks.park_question', { ...base, claim_token: 'WRONG' }); const parsed = JSON.parse(r.content[0].text); expect(parsed.ok).toBe(false); expect(parsed.reason).toBe('token-mismatch'); expect(commits).toHaveLength(0); }); it('errors when the task is not on the board', async () => { const { backend } = recordingBackend({ 'alpha:.tasks/STATUS.md': { content: ACTIVE_BOARD, sha: 'old' }, }); const tools = makeTools(backend); const r = await call(tools, 'tasks.park_question', { ...base, slug: 'ghost' }); expect(r.isError).toBe(true); }); it('is disabled without a backend', async () => { const tools = makeTasksTools({ cacheFile }); const r = await call(tools, 'tasks.park_question', base); expect(r.isError).toBe(true); }); it('calls inboxWriter with event:blocked when task has Notify field', async () => { const boardWithNotify = '# Task Board\n_Updated: 2026-06-08_\n\n## 🔴 [my-task] — A thing\n\n' + '**Status:** active\n**Where I stopped:** mid-flight\n**Next action:** keep going\n' + '**Branch:** master\n**Owner:** host:claude-opus:1\n**Claim token:** tok-123\n' + '**Notify:** OpeItcLoc03/workshop\n\n---\n'; const { backend } = recordingBackend({ 'alpha:.tasks/STATUS.md': { content: boardWithNotify, sha: 'old' }, }); const inboxCalls: unknown[] = []; const inboxWriter = async (args: unknown) => { inboxCalls.push(args); }; const tools = makeTasksTools({ cacheFile, backend, giteaUser: 'u', agendaTasksRepo: 'pt', now: fixedNow, inboxWriter }); await call(tools, 'tasks.park_question', base); expect(inboxCalls).toHaveLength(1); expect((inboxCalls[0] as Record).event).toBe('blocked'); expect((inboxCalls[0] as Record).notify).toBe('OpeItcLoc03/workshop'); expect((inboxCalls[0] as Record).slug).toBe('my-task'); }); });