Ctrl K
ComunidadGuías…

test/billion.test.js

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
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);
  });
});