import { describe, it, expect } from 'vitest'; import { mkdtempSync, mkdirSync, existsSync, readFileSync, writeFileSync, readdirSync } from 'fs'; import { execFileSync } from 'child_process'; import { join, resolve } from 'path'; import { tmpdir } from 'os'; import { billionEnabled, billionDir, ensureBillionRepo, refreshCharter, suggestProjectsDir, billionCommand, trustDialogKey, changedBoardTools, charterChanges, } from '../server/billion.js'; import { CONFIG_DIR } from '../server/state.js'; import { parseCommand } from '../lib/helpers.js'; import { hasClaudeTranscript } from '../server/agent-transcripts.js'; const fresh = () => join(mkdtempSync(join(tmpdir(), 'a007-billion-')), 'billion'); const log = (dir) => execFileSync('git', ['log', '--format=%s'], { cwd: dir }).toString().trim().split('\n'); describe('billionEnabled', () => { it('is on unless turned off', () => { expect(billionEnabled({})).toBe(true); expect(billionEnabled({ BILLION: '1' })).toBe(true); expect(billionEnabled({ BILLION: 'yes' })).toBe(true); for (const off of ['0', 'false', 'OFF', ' no ']) expect(billionEnabled({ BILLION: off })).toBe(false); }); }); describe('billionDir', () => { it('lives under the config dir unless BILLION_DIR says otherwise', () => { expect(billionDir({})).toBe(join(CONFIG_DIR, 'billion')); expect(billionDir({ BILLION_DIR: '/tmp/somewhere/b' })).toMatch(/somewhere[\\/]b$/); }); }); describe('ensureBillionRepo', () => { it('creates a committed repo from the templates on the first run', () => { const dir = fresh(); expect(ensureBillionRepo(dir)).toEqual({ created: true }); // Exactly these names: listed rather than probed, since macOS matches // charter.md to CHARTER.md. The template names must not come along. expect(readdirSync(dir).filter(f => f !== '.git').sort()).toEqual(['.billion', '.gitignore', 'CHARTER.md', 'CLAUDE.md', 'COMPANY.md', 'STATE.md']); expect(readFileSync(join(dir, 'CHARTER.md'), 'utf8')).toMatch(/You are \*\*Billion\*\*/); // CLAUDE.md is the owner's, and pulls the charter in. expect(readFileSync(join(dir, 'CLAUDE.md'), 'utf8')).toMatch(/^@CHARTER\.md$/m); expect(readFileSync(join(dir, 'CLAUDE.md'), 'utf8')).toMatch(/## Owner's rules/); expect(readFileSync(join(dir, 'STATE.md'), 'utf8')).toMatch(/^Status: not started$/m); expect(log(dir)).toEqual(['Billion: first run']); expect(execFileSync('git', ['status', '--porcelain'], { cwd: dir }).toString()).toBe(''); }); it('leaves an existing repo exactly as it is', () => { const dir = fresh(); ensureBillionRepo(dir); writeFileSync(join(dir, 'STATE.md'), 'Status: running\n'); expect(ensureBillionRepo(dir)).toEqual({ created: false }); expect(readFileSync(join(dir, 'STATE.md'), 'utf8')).toBe('Status: running\n'); expect(log(dir)).toEqual(['Billion: first run']); }); it('sets up a folder holding only what an OS leaves behind', () => { const dir = fresh(); mkdirSync(dir, { recursive: true }); writeFileSync(join(dir, '.DS_Store'), ''); expect(ensureBillionRepo(dir)).toEqual({ created: true }); // Ignored, so not in Billion's history. expect(execFileSync('git', ['ls-files'], { cwd: dir }).toString()).not.toMatch(/DS_Store/); }); it('keeps files already in a folder that is not a repo yet', () => { const dir = fresh(); mkdirSync(dir, { recursive: true }); writeFileSync(join(dir, 'COMPANY.md'), '# Mine\n'); expect(ensureBillionRepo(dir)).toEqual({ created: true }); expect(readFileSync(join(dir, 'COMPANY.md'), 'utf8')).toBe('# Mine\n'); expect(existsSync(join(dir, 'CLAUDE.md'))).toBe(true); }); }); describe('refreshCharter', () => { it('brings an older charter up to date in a commit of its own, leaving the owner\'s files alone', () => { const dir = fresh(); ensureBillionRepo(dir); writeFileSync(join(dir, 'CHARTER.md'), 'an older charter\n'); writeFileSync(join(dir, 'CLAUDE.md'), '@CHARTER.md\n\n## Owner\'s rules\n- New repos go in ~/Code\n'); execFileSync('git', ['-c', 'user.name=t', '-c', 'user.email=t@t', 'commit', '-qam', 'older'], { cwd: dir }); writeFileSync(join(dir, 'STATE.md'), 'Status: mid-cycle, not committed\n'); expect(refreshCharter(dir)).toBe(true); expect(readFileSync(join(dir, 'CHARTER.md'), 'utf8')).toMatch(/You are \*\*Billion\*\*/); expect(readFileSync(join(dir, 'CLAUDE.md'), 'utf8')).toMatch(/~\/Code/); expect(log(dir)[0]).toBe('Agent 007: update the charter'); // Billion's own uncommitted work is not swept into that commit. expect(execFileSync('git', ['status', '--porcelain'], { cwd: dir }).toString()).toBe(' M STATE.md\n'); }); it('does nothing when the charter is current', () => { const dir = fresh(); ensureBillionRepo(dir); expect(refreshCharter(dir)).toBe(false); expect(log(dir)).toEqual(['Billion: first run']); }); }); describe('suggestProjectsDir', () => { it('picks the folder holding most repos, not their common prefix', () => { // resolve(), like the code: on Windows it puts a drive in front. expect(suggestProjectsDir(['/u/Projects/a', '/u/Projects/b', '/u/elsewhere/c'])).toBe(resolve('/u/Projects')); }); it('ignores repos inside Agent 007\'s own folder', () => { const own = join(CONFIG_DIR, 'worktrees', 'x'); expect(suggestProjectsDir([own, join(own, '..', 'y')])).toBeNull(); expect(suggestProjectsDir([own, '/u/Projects/a'])).toBe(resolve('/u/Projects')); }); it('has nothing to suggest with no repos', () => { expect(suggestProjectsDir([])).toBeNull(); }); }); describe('billionCommand', () => { const dir = '/home/me/.agent-007/billion'; const promptOf = (cmd) => parseCommand(cmd).args.at(-1); it('introduces itself on a fresh repo, never continuing', () => { const cmd = billionCommand({ created: true, hasConversation: true, dir, projectsHint: '/home/me/Projects' }); const { file, args } = parseCommand(cmd); expect(file).toBe('claude'); expect(args).toContain('--dangerously-skip-permissions'); expect(args).not.toContain('--continue'); expect(promptOf(cmd)).toMatch(/first run/i); expect(promptOf(cmd)).toContain('Suggest /home/me/Projects'); expect(promptOf(cmd)).toContain(dir); }); it('continues the last conversation after a restart', () => { const cmd = billionCommand({ created: false, hasConversation: true, dir, projectsHint: null }); expect(parseCommand(cmd).args).toContain('--continue'); // A restart can land mid-introduction, so the prompt covers both. expect(promptOf(cmd)).toMatch(/not started/); expect(promptOf(cmd)).toMatch(/operating cycle/); expect(promptOf(cmd)).toMatch(/without suggesting/); }); it('names changed board tools and where the current ones are, only when resuming', () => { const opts = { dir, projectsHint: null, changedTools: ['notify_owner', 'close_job'], toolsFile: '/cfg/billion-tools.json' }; const resumed = promptOf(billionCommand({ ...opts, created: false, hasConversation: true })); expect(resumed).toContain('notify_owner, close_job'); expect(resumed).toContain('/cfg/billion-tools.json'); expect(promptOf(billionCommand({ ...opts, created: false, hasConversation: false }))).not.toContain('notify_owner'); expect(promptOf(billionCommand({ ...opts, created: true, hasConversation: true }))).not.toContain('notify_owner'); expect(promptOf(billionCommand({ ...opts, changedTools: [], created: false, hasConversation: true }))).not.toContain('changed these board tools'); }); it('starts cleanly when there is no conversation to continue', () => { const cmd = billionCommand({ created: false, hasConversation: false, dir, projectsHint: null }); expect(parseCommand(cmd).args).not.toContain('--continue'); }); it('keeps a folder with quotes and spaces intact through parseCommand', () => { const odd = '/Users/a "b"\\c/billion'; expect(promptOf(billionCommand({ created: true, hasConversation: false, dir: odd, projectsHint: null }))).toContain(odd); }); }); describe('changedBoardTools', () => { const tool = (name, description) => ({ name, description, inputSchema: { type: 'object', properties: {} } }); it('counts every tool as changed with no saved copy, then only the ones that differ', () => { const file = join(mkdtempSync(join(tmpdir(), 'a007-tools-')), 'billion-tools.json'); const v1 = [tool('notify_owner', 'old'), tool('post_job', 'same')]; expect(changedBoardTools(file, v1)).toEqual(['notify_owner', 'post_job']); expect(changedBoardTools(file, v1)).toEqual([]); const v2 = [tool('notify_owner', 'new'), tool('post_job', 'same'), tool('tell_owner', 'added')]; expect(changedBoardTools(file, v2)).toEqual(['notify_owner', 'tell_owner']); expect(JSON.parse(readFileSync(file, 'utf8'))).toEqual(v2); }); it('treats an unreadable saved copy as none', () => { const file = join(mkdtempSync(join(tmpdir(), 'a007-tools-')), 'billion-tools.json'); writeFileSync(file, '{not json'); expect(changedBoardTools(file, [tool('a', 'x')])).toEqual(['a']); }); }); it('names post_job when only its model list changed since the last start', async () => { const { toolsFor } = await import('../server/mcp.js'); const file = join(mkdtempSync(join(tmpdir(), 'a007-tools-')), 'billion-tools.json'); changedBoardTools(file, toolsFor({ isBillion: true, agent: 'codex' }, { claude: ['opus'], codex: [] })); expect(changedBoardTools(file, toolsFor({ isBillion: true, agent: 'codex' }, { claude: ['opus'], codex: ['gpt-5.5'] }))).toEqual(['post_job', 'edit_job']); }); describe('charterChanges', () => { const file = () => join(mkdtempSync(join(tmpdir(), 'a007-charter-')), 'billion-charter.md'); const charter = readFileSync(new URL('../templates/billion/charter.md', import.meta.url), 'utf8'); it('has Billion name an agent and a model on every card, never leaving the model to chance', () => { const choosing = charter.slice(charter.indexOf('**Choosing a model.**'), charter.indexOf('## Merging')); expect(choosing).toMatch(/Name both `agent` and `model` on every card/); expect(choosing).toMatch(/doesn't belong to the card's agent is refused/); expect(choosing).not.toMatch(/Leave it empty/); expect(choosing).toMatch(/most appropriate for the job/); expect(choosing).not.toMatch(/strongest|fast one/); }); it('quotes the paragraphs that changed, under their sections, then nothing once seen', () => { const f = file(); writeFileSync(f, charter.replace('How to ask: one short message', 'How to ask: one long message')); const notice = charterChanges(f, charter); expect(notice).toMatch(/^Your charter changed; these rules replace what you did before\./); expect(notice).toContain('From "Escalate":\nHow to ask: one short message with the question'); expect(notice).not.toContain('Every question to the owner'); expect(readFileSync(f, 'utf8')).toBe(charter); expect(charterChanges(f, charter)).toBe(''); }); it('names the changed sections instead of quoting a long change', () => { const f = file(); writeFileSync(f, '# Old\n\nNothing alike.\n'); const notice = charterChanges(f, charter); expect(notice).toMatch(/^Your charter changed/); expect(notice).toContain('Read CHARTER.md sections "Billion\'s charter", "Your files", "First run"'); expect(notice).toContain('"Escalate"'); expect(notice).not.toContain('Every question to the owner'); expect(notice.split('\n')).toHaveLength(1); }); it('on the first start with no saved copy, quotes how to answer the owner outside the terminal', () => { const notice = charterChanges(file(), charter); expect(notice).toMatch(/^Your charter changed/); expect(notice).toContain('the *Billion* tab'); expect(notice).toMatch(/Answer an `\[Owner via app\]` or `\[Owner via Telegram\]` message[\s\S]*with `tell_owner`, not only in your terminal/); expect(notice.split('\n').length).toBeLessThanOrEqual(45); }); it('reaches a resumed conversation only', () => { const dir = '/b'; const promptOf = (cmd) => parseCommand(cmd).args.at(-1); const charterNotice = 'Your charter changed; these rules replace what you did before.\n\nFrom "Escalate":\nUse tell_owner.'; expect(promptOf(billionCommand({ dir, created: false, hasConversation: true, charterNotice }))).toContain('\n\nFrom "Escalate":\nUse tell_owner.'); expect(promptOf(billionCommand({ dir, created: false, hasConversation: false, charterNotice }))).not.toContain('charter changed'); expect(promptOf(billionCommand({ dir, created: true, hasConversation: true, charterNotice }))).not.toContain('charter changed'); expect(promptOf(billionCommand({ dir, agent: 'codex', created: false, codexSessionId: '0199a1b2-c3d4-7e5f-8a9b-0c1d2e3f4a5b', charterNotice }))).toContain('Use tell_owner.'); expect(promptOf(billionCommand({ dir, created: false, hasConversation: true }))).not.toContain('charter changed'); }); }); describe('trustDialogKey', () => { // As Claude Code 2.1 draws it, stripped: cursor moves stand in for spaces. const dialog = (selected) => [ 'Quicksafetycheck:Isthisaprojectyoucreatedoroneyoutrust?', selected === 'no' ? '❯No,exit' : ' No, exit', selected === 'yes' ? '❯ Yes, I trust this folder' : 'Yes,Itrustthisfolder', 'Entertoconfirm·Esctocancel', ].join('\n'); it('ignores text that only mentions the dialog, like a job card quoting it', () => { expect(trustDialogKey('Fix the trust this folder prompt\n❯ Yes, I trust this folder')).toBe(null); expect(trustDialogKey('why does trust this folder say\n❯ No, exit')).toBe(null); }); it('moves off "No, exit", then confirms "Yes"', () => { expect(trustDialogKey(dialog('no'))).toBe('\x0e'); expect(trustDialogKey(dialog('yes'))).toBe('\r'); }); it('follows the last cursor drawn when an older drawing is still in the text', () => { expect(trustDialogKey(dialog('no') + '\n No, exit❯Yes, I trust this folder')).toBe('\r'); expect(trustDialogKey(dialog('yes') + '\n❯No, exit Yes, I trust this folder')).toBe('\x0e'); }); it('leaves anything else alone', () => { expect(trustDialogKey('❯ No, exit')).toBeNull(); // some other "No, exit" menu expect(trustDialogKey('> Try "fix the tests"')).toBeNull(); expect(trustDialogKey('')).toBeNull(); }); }); describe('hasClaudeTranscript', () => { it('finds a conversation Claude Code left in exactly that folder', () => { const home = mkdtempSync(join(tmpdir(), 'a007-claude-home-')); const dir = '/tmp/some folder/billion'; expect(hasClaudeTranscript(dir, { claude: home })).toBe(false); const project = join(home, 'projects', dir.replace(/[^A-Za-z0-9]/g, '-')); mkdirSync(project, { recursive: true }); writeFileSync(join(project, 'x.jsonl'), '{}\n'); expect(hasClaudeTranscript(dir, { claude: home })).toBe(true); expect(hasClaudeTranscript('/tmp/elsewhere', { claude: home })).toBe(false); expect(hasClaudeTranscript('', { claude: home })).toBe(false); }); });