import { describe, it, expect } from 'vitest'; import { runTasksCli } from '../../src/tasks-cli.js'; import type { ToolDef, ToolResult } from '../../src/tools/types.js'; function fakeTool( name: string, impl: (args: Record) => ToolResult, ): ToolDef { return { name, description: '', inputSchema: { type: 'object' }, handler: async (args) => impl(args), }; } function jsonResult(value: unknown): ToolResult { return { content: [{ type: 'text', text: JSON.stringify(value) }] }; } describe('runTasksCli', () => { it('routes claim-next to tasks.claim_next with parsed flags and prints the handler JSON', async () => { let received: Record | undefined; const tools = [ fakeTool('tasks.claim_next', (args) => { received = args; return jsonResult({ ok: true, claimed: true, slug: 'foo' }); }), ]; const out: string[] = []; const code = await runTasksCli( [ 'claim-next', '--claimer-identity', 'host:claude-opus:123', '--runtime', 'claude-opus', '--requirements', 'needs-db,needs-internet', '--confirm', ], { tools, log: (s) => out.push(s) }, ); expect(code).toBe(0); expect(received).toEqual({ claimer_identity: 'host:claude-opus:123', confirm: true, filter: { runtime: 'claude-opus', requirements: ['needs-db', 'needs-internet'] }, }); expect(JSON.parse(out.join('\n'))).toEqual({ ok: true, claimed: true, slug: 'foo' }); }); it('routes append-decision-trail to tasks.append_decision_trail, splitting the chain', async () => { let received: Record | undefined; const tools = [ fakeTool('tasks.append_decision_trail', (args) => { received = args; return jsonResult({ committed: true, trail_ref: 'x' }); }), ]; const code = await runTasksCli( [ 'append-decision-trail', '--target-project', 'OpeItcLoc03/alpha', '--slug', 'my-task', '--question', 'cache key includes locale?', '--decided-by', 'arbiter', '--blast-radius', 'reversible', '--ruling', 'include locale', '--rationale', 'avoids bleed', '--escalation-chain', 'brief,read-repo', ], { tools, log: () => {} }, ); expect(code).toBe(0); expect(received).toEqual({ target_project: 'OpeItcLoc03/alpha', slug: 'my-task', question: 'cache key includes locale?', decided_by: 'arbiter', blast_radius: 'reversible', ruling: 'include locale', rationale: 'avoids bleed', escalation_chain: ['brief', 'read-repo'], }); }); it('omits ruling on a halt trail entry', async () => { let received: Record | undefined; const tools = [ fakeTool('tasks.append_decision_trail', (args) => { received = args; return jsonResult({ committed: true }); }), ]; await runTasksCli( [ 'append-decision-trail', '--target-project', 'OpeItcLoc03/alpha', '--slug', 'my-task', '--question', 'priorities?', '--decided-by', 'human-required', '--escalation-chain', 'brief,arbiter-fork', ], { tools, log: () => {} }, ); expect(received).not.toHaveProperty('ruling'); expect(received!.decided_by).toBe('human-required'); }); it('routes park-question to tasks.park_question with question + optional claim_token', async () => { let received: Record | undefined; const tools = [ fakeTool('tasks.park_question', (args) => { received = args; return jsonResult({ parked: true }); }), ]; const code = await runTasksCli( [ 'park-question', '--target-project', 'OpeItcLoc03/alpha', '--slug', 'my-task', '--question', 'drop the legacy column?', '--claim-token', 'tok-123', ], { tools, log: () => {} }, ); expect(code).toBe(0); expect(received).toEqual({ target_project: 'OpeItcLoc03/alpha', slug: 'my-task', question: 'drop the legacy column?', claim_token: 'tok-123', }); }); it('routes get-status to tasks.get_status', async () => { let received: Record | undefined; const tools = [ fakeTool('tasks.get_status', (args) => { received = args; return jsonResult({ found: true, status: 'blocked' }); }), ]; const code = await runTasksCli( ['get-status', '--target-project', 'OpeItcLoc03/alpha', '--slug', 'my-task'], { tools, log: () => {} }, ); expect(code).toBe(0); expect(received).toEqual({ target_project: 'OpeItcLoc03/alpha', slug: 'my-task' }); }); it('routes heartbeat to tasks.heartbeat with slug + claim_token (+ optional target_project)', async () => { let received: Record | undefined; const tools = [ fakeTool('tasks.heartbeat', (args) => { received = args; return jsonResult({ ok: true }); }), ]; const code = await runTasksCli( ['heartbeat', '--slug', 'foo', '--claim-token', 'tok-1', '--target-project', 'victor/books'], { tools, log: () => {} }, ); expect(code).toBe(0); expect(received).toEqual({ slug: 'foo', claim_token: 'tok-1', target_project: 'victor/books' }); }); it('routes close to tasks.close with target_project + slug + note + confirm', async () => { let received: Record | undefined; const tools = [ fakeTool('tasks.close', (args) => { received = args; return jsonResult({ committed: true }); }), ]; const code = await runTasksCli( ['close', '--target-project', 'victor/books', '--slug', 'foo', '--note', 'done via poller', '--confirm'], { tools, log: () => {} }, ); expect(code).toBe(0); expect(received).toEqual({ target_project: 'victor/books', slug: 'foo', note: 'done via poller', confirm: true, }); }); it('routes update to tasks.update with target_project + slug + status + where_stopped', async () => { let received: Record | undefined; const tools = [ fakeTool('tasks.update', (args) => { received = args; return jsonResult({ committed: true }); }), ]; const code = await runTasksCli( [ 'update', '--target-project', 'victor/books', '--slug', 'foo', '--status', 'blocked', '--where-stopped', 'spawn exited 1: boom', '--confirm', ], { tools, log: () => {} }, ); expect(code).toBe(0); expect(received).toEqual({ target_project: 'victor/books', slug: 'foo', status: 'blocked', where_stopped: 'spawn exited 1: boom', confirm: true, }); }); it('passes an explicit empty --blocker= through as blocker:"" (clear the blocker)', async () => { // The ops watchdog resets a transient-blocked task with `--status=ready // --blocker=`; the empty value must reach the handler so updateTaskFields // strips the stale **Blocker:** line, not be dropped as a falsy flag. let received: Record | undefined; const tools = [ fakeTool('tasks.update', (args) => { received = args; return jsonResult({ committed: true }); }), ]; const code = await runTasksCli( ['update', '--target-project=victor/books', '--slug=foo', '--status=ready', '--blocker=', '--confirm'], { tools, log: () => {} }, ); expect(code).toBe(0); expect(received).toEqual({ target_project: 'victor/books', slug: 'foo', status: 'ready', blocker: '', confirm: true, }); }); it('omits blocker entirely when --blocker is absent', async () => { let received: Record | undefined; const tools = [ fakeTool('tasks.update', (args) => { received = args; return jsonResult({ committed: true }); }), ]; const code = await runTasksCli( ['update', '--target-project=victor/books', '--slug=foo', '--status=ready', '--confirm'], { tools, log: () => {} }, ); expect(code).toBe(0); expect(received).not.toHaveProperty('blocker'); }); it('returns exit code 1 when the handler result isError', async () => { const tools = [ fakeTool('tasks.claim_next', () => ({ content: [{ type: 'text', text: 'cache missing — run sync first' }], isError: true, })), ]; const out: string[] = []; const code = await runTasksCli(['claim-next', '--claimer-identity', 'h:r:1'], { tools, log: (s) => out.push(s), }); expect(code).toBe(1); expect(out.join('\n')).toContain('cache missing'); }); it('returns exit code 2 and usage on an unknown command', async () => { const err: string[] = []; const code = await runTasksCli(['frobnicate'], { tools: [], errLog: (s) => err.push(s) }); expect(code).toBe(2); expect(err.join('\n')).toContain('unknown command: frobnicate'); }); it('a throwing handler (e.g. zod parse on a missing required flag) → JSON + non-zero, not an uncaught throw', async () => { const tools = [ fakeTool('tasks.claim_next', () => { throw new Error('claimer_identity: Required'); }), ]; const out: string[] = []; const code = await runTasksCli(['claim-next'], { tools, log: (s) => out.push(s) }); expect(code).not.toBe(0); const parsed = JSON.parse(out.join('\n')); expect(parsed.ok).toBe(false); expect(parsed.error).toContain('claimer_identity'); }); it('--flag=value form: a value beginning with -- survives (blocked-path where_stopped)', async () => { let received: Record | undefined; const tools = [ fakeTool('tasks.update', (args) => { received = args; return jsonResult({ committed: true }); }), ]; const code = await runTasksCli( ['update', '--target-project=victor/books', '--slug=foo', '--status=blocked', '--where-stopped=--boom: bad flag', '--confirm'], { tools, log: () => {} }, ); expect(code).toBe(0); expect(received).toEqual({ target_project: 'victor/books', slug: 'foo', status: 'blocked', where_stopped: '--boom: bad flag', confirm: true, }); }); it('routes list-blocked to tasks.list_blocked with optional project_allowlist', async () => { let received: Record | undefined; const tools = [ fakeTool('tasks.list_blocked', (args) => { received = args; return jsonResult({ ok: true, tasks: [] }); }), ]; const out: string[] = []; const code = await runTasksCli( ['list-blocked', '--projects', 'OpeItcLoc03/alpha,OpeItcLoc03/beta'], { tools, log: (s) => out.push(s) }, ); expect(code).toBe(0); expect(received).toEqual({ project_allowlist: ['OpeItcLoc03/alpha', 'OpeItcLoc03/beta'] }); expect(JSON.parse(out.join('\n'))).toEqual({ ok: true, tasks: [] }); }); it('routes list-blocked without --projects (no allowlist)', async () => { let received: Record | undefined; const tools = [ fakeTool('tasks.list_blocked', (args) => { received = args; return jsonResult({ ok: true, tasks: [] }); }), ]; const code = await runTasksCli(['list-blocked'], { tools, log: () => {} }); expect(code).toBe(0); expect(received).toEqual({}); }); });