test/billion-review-fixes.test.js
Rama main · réplica de lectura del repositorio original.
// What the pre-landing review of Billion fixed: the trust watcher only just
// after spawn, approvals that cannot outlive their chance of an answer, and
// close_job telling the next worker why, before anyone can pick the card up.
import { describe, it, expect, vi, beforeEach, afterEach, afterAll } from 'vitest';
import { mkdtempSync } from 'fs';
import { tmpdir } from 'os';
import { join } from 'path';
import { removeTempDir } from './temp-dir.js';
// These cases exercise board transitions, not remote discovery. addJob's
// background git probe can otherwise hold REPO open during Windows cleanup.
vi.mock('../server/git.js', async original => ({
...await original(), hasGithubRemote: vi.fn(async () => null),
}));
const { config, sessions } = await import('../server/state.js');
const { setupPtyHandlers } = await import('../server/pty.js');
const { requestApproval, clearApprovals, dropApprovals, APPROVAL_WAIT_MS } = await import('../server/approvals.js');
const { dropMessages, pendingMessages, sendMessage, flushMessages } = await import('../server/messages.js');
const { addJob, boardSettings, closeJobForAgent } = await import('../server/jobs.js');
const { handleMcpMessage } = await import('../server/mcp.js');
const { HOOK_WAIT_MS, HOOK_TIMEOUT_S } = await import('../server/agent-mcp.js');
const { BILLION_NAME } = await import('../lib/jobs.js');
function fake(name, fields = {}) {
return {
id: `rf-${name}`, name, command: 'claude', agent: 'claude', state: 'WAITING', exited: false, ownerId: null,
stateChangedAt: 0, recentStrippedLines: [], isTUI: true, lastOutputAt: 0, pty: { write: vi.fn() }, ...fields,
};
}
const request = { tool_name: 'Write', tool_input: { file_path: '/x' } };
let billion, worker;
beforeEach(() => {
sessions.clear();
billion = fake('Billion', { isBillion: true, command: 'claude --dangerously-skip-permissions' });
worker = fake('Falcon', { approvalsToBillion: true });
sessions.set(billion.id, billion);
sessions.set(worker.id, worker);
});
afterEach(() => {
clearApprovals();
dropMessages(billion.id);
vi.useRealTimers();
});
describe('the trust watcher', () => {
it('stands down once Billion is past its start-up, dialog or no dialog', () => {
vi.useFakeTimers();
let onData;
const session = {
id: 'rf-t', createdAt: Date.now() - 61_000, isBillion: true, answersTrust: true,
pty: { onData: (cb) => { onData = cb; }, onExit: () => {}, write: vi.fn() },
ringBuffer: { push: () => {} }, state: 'WORKING', lastOutputAt: 0, lastResizeAt: 0,
lastStrippedLine: '', recentStrippedLines: [], pendingRaw: '', isTUI: true, exited: false,
};
setupPtyHandlers(session, 'rf-t', () => {});
clearInterval(session.stateCheckInterval);
// Billion's own output that happens to look like the dialog.
onData('Do you trust this folder?\r\n❯ No, exit\r\n Yes, I trust this folder\r\n');
vi.advanceTimersByTime(1000);
expect(session.pty.write).not.toHaveBeenCalled();
expect(session.trustScreen).toBe('');
});
});
describe('approvals', () => {
it('takes an expired request back out of Billion\'s queue', async () => {
vi.useFakeTimers();
billion.state = 'WORKING'; // busy: the request queues
const answer = requestApproval(worker, request);
expect(pendingMessages(billion.id)).toBe(1);
vi.advanceTimersByTime(APPROVAL_WAIT_MS);
expect(await answer).toEqual({});
expect(pendingMessages(billion.id)).toBe(0);
});
it('hands every waiting request to the owner at once when Billion\'s process exits', async () => {
vi.useFakeTimers();
billion.state = 'WORKING';
let onExit;
const exiting = (fields) => ({
id: 'rf-x', ringBuffer: { push: () => {} }, recentStrippedLines: [], pendingRaw: '', isTUI: true, exited: false,
pty: { onData: () => {}, onExit: (cb) => { onExit = cb; }, write: vi.fn() }, ...fields,
});
const settled = [];
requestApproval(worker, request).then(r => settled.push(r));
// Another session exiting leaves it waiting.
const other = exiting({});
setupPtyHandlers(other, 'rf-x', () => {});
clearInterval(other.stateCheckInterval);
onExit({ exitCode: 0 });
await Promise.resolve();
expect(settled).toEqual([]);
// Billion's own exit hands it on, long before the wait is up.
const b = exiting({ isBillion: true });
setupPtyHandlers(b, 'rf-x', () => {});
clearInterval(b.stateCheckInterval);
onExit({ exitCode: 0 });
await Promise.resolve();
expect(settled).toEqual([{}]);
});
it('puts approvals ahead of agent messages, with a cap of their own', () => {
billion.state = 'WORKING';
for (let i = 0; i < 20; i++) sendMessage({ from: fake(`W${i}`), to: BILLION_NAME, text: 'hi', sessions });
expect(pendingMessages(billion.id)).toBe(20);
requestApproval(worker, request); // still gets in, and first
expect(pendingMessages(billion.id)).toBe(21);
billion.state = 'WAITING';
flushMessages(billion, Date.now());
expect(billion.pty.write.mock.calls[0][0]).toContain('[Approval ');
});
it('delivers "Billion" to the real one, not a session that happens to share the name', () => {
// Ahead of the real one in the map, so a lookup by name would find it first.
const impostor = fake('Billion', { id: 'rf-imp' });
sessions.clear();
for (const s of [impostor, billion, worker]) sessions.set(s.id, s);
expect(sendMessage({ from: worker, to: BILLION_NAME, text: 'question', sessions }).to).toBe(billion);
});
it('keeps the hook\'s limits above the wait, so the server always gives up first', () => {
expect(HOOK_WAIT_MS).toBeGreaterThan(APPROVAL_WAIT_MS);
expect(HOOK_TIMEOUT_S * 1000).toBeGreaterThan(HOOK_WAIT_MS);
});
});
describe('close_job sending a card back', () => {
const REPO = mkdtempSync(join(tmpdir(), 'a007-rf-repo-'));
afterAll(() => removeTempDir(REPO));
beforeEach(() => {
config.repos = [{ path: REPO }];
config.jobs = [];
config.jobBoard = null;
boardSettings();
});
it('never lets a long note push out the task itself', async () => {
const { job } = addJob({ title: 'Task', detail: 'The actual task.', repoPath: REPO, postedByAgent: BILLION_NAME, postedByBillion: true }, () => {});
Object.assign(job, { state: 'review', agentSessionId: worker.id, branchName: 'task' });
await closeJobForAgent({ session: billion, id: job.id, accept: false, note: 'y'.repeat(30000) }, () => {}, { killSession: async () => {} });
expect(job.detail.startsWith('The actual task.')).toBe(true);
});
it('holds a sent-back card out of dispatch so it can be edited first', async () => {
const { job } = addJob({ title: 'Hold', repoPath: REPO, postedByAgent: BILLION_NAME, postedByBillion: true }, () => {});
Object.assign(job, { state: 'review', agentSessionId: worker.id, branchName: 'hold' });
await closeJobForAgent({ session: billion, id: job.id, accept: false, note: 'Reword it.' }, () => {}, { killSession: async () => {} });
expect(job.state).toBe('todo');
expect(Date.parse(job.holdUntil)).toBeGreaterThan(Date.now());
});
it('keeps the whole reason even on a card whose detail is already full', async () => {
const { job } = addJob({ title: 'Big', detail: 'x'.repeat(20000), repoPath: REPO, postedByAgent: BILLION_NAME, postedByBillion: true }, () => {});
Object.assign(job, { state: 'review', agentSessionId: worker.id, branchName: 'big' });
await closeJobForAgent({ session: billion, id: job.id, accept: false, note: 'Split it up.' }, () => {}, { killSession: async () => {} });
expect(job.detail.length).toBeLessThanOrEqual(20000);
expect(job.detail.endsWith('Sent back by Billion: Split it up.')).toBe(true);
});
it('writes the reason on the card before it is back in To do, and names the PR to close', async () => {
const { job } = addJob({ title: 'Fix login', detail: 'Make it work.', repoPath: REPO, postedByAgent: BILLION_NAME, postedByBillion: true }, () => {});
Object.assign(job, { state: 'review', agentSessionId: worker.id, branchName: 'fix-login', prUrl: 'https://github.com/o/r/pull/7' });
let detailWhenMoved = null;
const result = await closeJobForAgent({ session: billion, id: job.id, accept: false, note: 'Cover the error path.' }, () => {}, {
killSession: async () => { detailWhenMoved = job.detail; },
});
expect(detailWhenMoved).toMatch(/Sent back by Billion: Cover the error path\.$/);
expect(job.state).toBe('todo');
expect(result.oldPrUrl).toBe('https://github.com/o/r/pull/7');
const reply = handleMcpMessage(
{ jsonrpc: '2.0', id: 1, method: 'tools/call', params: { name: 'close_job', arguments: { id: job.id, accept: false, note: 'x' } } },
{ session: billion, closeJob: async () => result },
);
expect((await reply).result.content[0].text).toMatch(/close https:\/\/github\.com\/o\/r\/pull\/7/);
});
it('shows who posted each card in list_jobs', () => {
const { job } = addJob({ title: 'Mine', repoPath: REPO, postedByAgent: BILLION_NAME, postedByBillion: true }, () => {});
const reply = handleMcpMessage(
{ jsonrpc: '2.0', id: 1, method: 'tools/call', params: { name: 'list_jobs', arguments: {} } },
{ session: billion, listJobs: () => ({ jobs: [{ ...job, repo: 'r' }], archived: 0 }) },
);
expect(reply.result.content[0].text).toMatch(/posted by Billion/);
});
});