test/billion.test.js
Rama main · réplica de lectura del repositorio original.
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);
});
});