tests/candidates.test.ts
Rama main · réplica de lectura del repositorio original.
import assert from 'node:assert/strict';
import { mkdir, mkdtemp } from 'node:fs/promises';
import { resolve } from 'node:path';
import test from 'node:test';
import { FileCandidateStore } from '../src/adapters/storage/candidate-store.js';
import { Store } from '../src/adapters/storage/file-store.js';
import { FileIterationStore } from '../src/adapters/storage/iteration-store.js';
import { approveCandidate, processIssueCandidates, refreshCandidates, reproduceCandidate, runCandidate, type CandidateDependencies } from '../src/application/candidates.js';
import { createGoal } from '../src/application/iteration.js';
import { executeGoals } from '../src/cli/commands/goals.js';
import { candidateFromSignal } from '../src/domain/candidate.js';
import { configSchema } from '../src/domain/config.js';
import type { QueueIssue } from '../src/ports/automation.js';
import { answer, result, testCase } from './helpers.js';
const sha = 'a'.repeat(40);
async function fixture(options: { autoApprove?: boolean; maxFailures?: number;
maxPerRun?: number; priorityLabels?: string[]; publish?: boolean } = {}): Promise<{
d: CandidateDependencies; issue: QueueIssue; published: () => number }> {
await mkdir('.cache/tests', { recursive: true });
const dataDir = await mkdtemp(resolve('.cache/tests/candidates-'));
const { publish = false, ...queueOptions } = options;
const config = configSchema.parse({ repository: 'owner/repo', dataDir, publish,
agent: { enabled: true, repair: true },
iteration: { queue: { labels: ['agent-ready'], trustedAuthors: ['maintainer'],
allowedPaths: ['src', 'test'], ...queueOptions } } });
const issue: QueueIssue = { number: 7, title: 'Reject negative quantities', body: 'Negative quantities must produce a validation error.',
state: 'open', user: { login: 'maintainer' }, labels: [{ name: 'agent-ready' }], created_at: '2026-01-01T00:00:00Z' };
const snapshot = new Map([['src/a.ts', 'BROKEN']]);
let writes = 0;
const d: CandidateDependencies = { config, store: new Store(dataDir), goals: new FileIterationStore(dataDir),
candidates: new FileCandidateStore(dataDir), signal: new AbortController().signal,
github: { issues: async () => [issue], issue: async () => issue, target: async () => ({ branch: 'main', sha }),
listPulls: async () => [], pull: async () => { throw new Error('Unexpected PR read'); },
current: async report => report.goal ? issue : undefined,
publish: async () => { writes++; return 'https://github.com/owner/repo/pull/10'; },
feedback: async () => { throw new Error('Unexpected feedback'); }, updatePull: async () => undefined,
recoverPull: async () => undefined, propose: async () => { writes++; return ''; },
outcome: async () => { throw new Error('Unexpected outcome'); } },
repository: { prepare: async () => {}, fetch: async (_path, _repo, from, to) => {
assert.equal(from, sha); assert.equal(to, sha);
}, snapshot: async () => snapshot, resolveCommit: async () => sha },
runner: { run: async files => {
const cases = [testCase()];
if (files.has('test/generated.test.js')) cases.push(testCase('test/generated.test.js',
files.get('src/a.ts') === 'GOOD' ? 'passed' : 'failed'));
return result(cases.some(item => item.status === 'failed') ? 'failed' : 'passed', cases);
} },
agent: { design: async () => ({ ...answer(), steps: [{ id: 'issue-step', title: 'Fix negative quantity validation',
acceptanceIds: ['issue-request'], dependsOn: [] }] }), review: async () => answer(),
plan: async (_base, _head, description) => {
const quote = description.includes('Acceptance:') ? description.split('Acceptance:\n')[1]!.split('\nAllowed paths:')[0]!
: issue.title + '\n' + issue.body;
return { ...answer([{ path: 'test/generated.test.js', content: 'test("negative", () => {});' }]),
scenarios: [{ name: 'negative', requirement: quote, requirementQuote: quote,
kind: issue.labels.some(label => label.name === 'enhancement') ? 'new_behavior' : 'regression',
testFile: 'test/generated.test.js' }] };
}, repair: async () => answer([{ path: 'src/a.ts', content: 'GOOD' }]) } };
return { d, issue, published: () => writes };
}
test('candidate evidence changes invalidate reproduction and approval', async () => {
const signal = { kind: 'issue' as const, repository: 'owner/repo', identity: [7], commit: sha,
branch: 'main', evidence: ['request'], title: 'Reject negative values', detail: 'A regression exists.',
priority: 70, priorityReasons: ['trusted'] };
const first = candidateFromSignal(signal);
const changed = candidateFromSignal({ ...signal, evidence: ['edited'] }, { ...first, status: 'approved',
reproduction: { status: 'reproduced', at: '2026-01-01T00:00:00Z', reason: 'stable' } });
assert.equal(changed.status, 'stale'); assert.equal(changed.reproduction, undefined);
});
test('trusted Issue must reproduce with frozen structured tests before goal creation', async () => {
const { d, issue, published } = await fixture();
const refreshed = await refreshCandidates(d);
assert.equal(refreshed.created, 1);
const candidate = refreshed.candidates[0]!;
assert.equal(candidate.kind, 'issue');
await assert.rejects(approveCandidate(candidate.id, candidate.evidenceDigest, d), /stable reproduction/);
const reproduced = await reproduceCandidate(candidate.id, d);
assert.equal(reproduced.status, 'reproduced');
const report = await d.store.read(reproduced.reproduction!.reportId!);
assert.equal(report?.reproduction?.reproduced, true);
assert.equal(report?.changes.length, 0);
assert.equal(report?.attempts, 0);
assert.equal(published(), 0);
await assert.rejects(approveCandidate(candidate.id, 'b'.repeat(24), d), /evidence changed/);
await approveCandidate(candidate.id, candidate.evidenceDigest, d);
issue.body = 'Edited acceptance after approval.';
assert.equal((await runCandidate(candidate.id, d)).status, 'stale');
assert.deepEqual(await d.goals.list(), []);
});
test('tampered reproduction report cannot start a goal', async () => {
const { d } = await fixture();
const candidate = (await refreshCandidates(d)).candidates[0]!;
await reproduceCandidate(candidate.id, d); await approveCandidate(candidate.id, candidate.evidenceDigest, d);
const original = (await d.store.read((await d.candidates.read(candidate.id))!.reproduction!.reportId!))!;
original.tests.base = result('failed'); await d.store.save(original);
await assert.rejects(runCandidate(candidate.id, d), /reproduction evidence/);
assert.deepEqual(await d.goals.list(), []);
});
test('Issue label revocation during reproduction leaves no approvable candidate', async () => {
const { d, issue } = await fixture();
const candidate = (await refreshCandidates(d)).candidates[0]!;
const runner = d.runner!;
d.runner = { run: async (files, phase, signal) => {
if (phase === 'planned-head') issue.labels = [];
return runner.run(files, phase, signal);
} };
assert.equal((await reproduceCandidate(candidate.id, d)).status, 'stale');
await assert.rejects(approveCandidate(candidate.id, candidate.evidenceDigest, d), /stable reproduction/);
});
test('changing the feature label during reproduction invalidates its test mode', async () => {
const { d, issue } = await fixture();
const candidate = (await refreshCandidates(d)).candidates[0]!;
const runner = d.runner!;
d.runner = { run: async (files, phase, signal) => {
if (phase === 'planned-head') issue.labels.push({ name: 'enhancement' });
return runner.run(files, phase, signal);
} };
assert.equal((await reproduceCandidate(candidate.id, d)).status, 'stale');
assert.deepEqual(await d.goals.list(), []);
});
test('a reproduced, approved Issue creates one recoverable goal', async () => {
const { d, published } = await fixture();
const candidate = (await refreshCandidates(d)).candidates[0]!;
await reproduceCandidate(candidate.id, d); await approveCandidate(candidate.id, candidate.evidenceDigest, d);
const completed = await runCandidate(candidate.id, d);
assert.equal(completed.status, 'completed', JSON.stringify(await d.goals.list())); assert.equal(published(), 0);
assert.equal((await d.goals.list()).length, 1);
assert.equal((await d.goals.read(completed.goalId!))?.candidateId, candidate.id);
assert.equal((await runCandidate(candidate.id, d)).goalId, completed.goalId);
assert.equal((await d.goals.list()).length, 1);
});
test('candidate execution recovers a goal saved before the candidate link', async () => {
const { d, issue } = await fixture();
const candidate = (await refreshCandidates(d)).candidates[0]!;
await reproduceCandidate(candidate.id, d); await approveCandidate(candidate.id, candidate.evidenceDigest, d);
const request = issue.title + '\n' + issue.body;
const goal = await createGoal({ title: issue.title, objective: request, mode: 'bugfix', issue: issue.number,
branch: 'main', acceptance: [{ id: 'issue-request', text: request }], allowedPaths: ['src', 'test'] }, d,
{ candidateId: candidate.id, expectedSha: sha });
const saved = (await d.candidates.read(candidate.id))!;
saved.status = 'running'; await d.candidates.save(saved);
assert.equal((await runCandidate(candidate.id, d)).goalId, goal.id);
assert.equal((await d.goals.list()).length, 1);
});
test('polling reproduces once and waits for explicit approval by default', async () => {
const { d } = await fixture();
let plans = 0;
const agent = d.agent!, original = agent.plan;
agent.plan = async (...args) => { plans++; return original(...args); };
const first = await processIssueCandidates(d);
assert.equal(first.processed[0]?.status, 'reproduced'); assert.equal(first.pendingApproval, 1);
assert.deepEqual(await d.goals.list(), []);
assert.equal((await processIssueCandidates(d)).processed.length, 0);
assert.equal(plans, 1);
const candidate = (await d.candidates.list())[0]!;
await approveCandidate(candidate.id, candidate.evidenceDigest, d);
const resumed = await processIssueCandidates(d);
assert.equal(resumed.processed[0]?.status, 'completed');
assert.equal((await d.goals.list()).length, 1);
assert.equal((await processIssueCandidates(d)).processed.length, 0);
assert.equal((await d.goals.list()).length, 1);
});
test('opt-in automatic approval reaches a single verified goal after stable reproduction', async () => {
const { d, published } = await fixture({ autoApprove: true });
const first = await processIssueCandidates(d);
assert.equal(first.processed[0]?.status, 'completed'); assert.equal(first.errors.length, 0);
assert.equal((await d.goals.list())[0]?.candidateId, first.processed[0]?.id);
assert.equal((await d.goals.list())[0]?.status, 'verified'); assert.equal(published(), 0);
assert.equal((await processIssueCandidates(d)).processed.length, 0);
assert.equal((await d.goals.list()).length, 1);
});
test('opt-in publication happens once after candidate and goal verification', async () => {
const { d, published } = await fixture({ autoApprove: true, publish: true });
const cycle = await processIssueCandidates(d);
assert.equal(cycle.processed[0]?.status, 'completed');
assert.equal((await d.goals.list())[0]?.status, 'published');
assert.equal(published(), 1);
await processIssueCandidates(d);
assert.equal(published(), 1);
});
test('feature-labeled Issues prove missing behavior before automatic implementation', async () => {
const { d, issue } = await fixture({ autoApprove: true });
issue.labels.push({ name: 'enhancement' });
const result = await processIssueCandidates(d);
const candidate = (await d.candidates.list())[0]!;
const report = candidate.reproduction?.reportId ? await d.store.read(candidate.reproduction.reportId) : undefined;
assert.equal(result.processed[0]?.status, 'completed', JSON.stringify({ result, report }));
assert.equal((await d.goals.list())[0]?.spec.mode, 'feature');
assert.equal((await d.store.read(candidate.reproduction!.reportId!))?.reproduction?.reproduced, true);
});
test('automatic feature intake stops when the generated behavior already passes', async () => {
const { d, issue } = await fixture({ autoApprove: true });
issue.labels.push({ name: 'enhancement' });
d.runner = { run: async () => result('passed', [testCase(), testCase('test/generated.test.js')]) };
const cycle = await processIssueCandidates(d);
assert.equal(cycle.processed[0]?.status, 'blocked');
assert.equal((await d.candidates.list())[0]?.reproduction?.status, 'not_reproduced');
assert.deepEqual(await d.goals.list(), []);
});
test('iterate CLI uses the candidate queue and defaults to manual approval', async () => {
const { d } = await fixture();
const output: string[] = [];
assert.equal(await executeGoals('iterate', undefined, undefined, { once: true }, d,
{ write: line => output.push(line), error: line => assert.fail(line) }), 0);
assert.equal(JSON.parse(output.at(-1)!).queue.pendingApproval, 1);
assert.deepEqual(await d.goals.list(), []);
});
test('changed Issue evidence invalidates approval and prevents duplicate goal budgets', async () => {
const { d, issue } = await fixture();
await processIssueCandidates(d);
const original = (await d.candidates.list())[0]!, reportId = original.reproduction!.reportId;
await approveCandidate(original.id, original.evidenceDigest, d);
issue.body = 'Negative quantities must be rejected before saving the order.';
const updated = await processIssueCandidates(d);
assert.equal(updated.processed[0]?.status, 'reproduced');
assert.notEqual((await d.candidates.read(original.id))?.reproduction?.reportId, reportId);
assert.deepEqual(await d.goals.list(), []);
await approveCandidate(original.id, (await d.candidates.read(original.id))!.evidenceDigest, d);
await processIssueCandidates(d);
assert.equal((await d.goals.list()).length, 1);
issue.body = 'A third changed requirement must not replenish the goal budget.';
const third = await processIssueCandidates(d);
assert.equal(third.processed[0]?.status, 'blocked');
assert.equal((await d.goals.list()).length, 1);
});
test('revoked labels and oversized Issues do not enter automatic execution', async () => {
const { d, issue } = await fixture({ autoApprove: true });
issue.body = 'x'.repeat(2000);
const long = await processIssueCandidates(d);
assert.equal(long.processed[0]?.status, 'blocked');
assert.deepEqual(await d.goals.list(), []);
issue.body = 'Short acceptance request.';
issue.title = 'T'.repeat(201);
const wide = await processIssueCandidates(d);
assert.equal(wide.processed[0]?.status, 'blocked');
assert.equal(wide.errors.length, 0);
assert.deepEqual(await d.goals.list(), []);
issue.labels = [];
const revoked = await processIssueCandidates(d);
assert.equal(revoked.processed.length, 0);
assert.equal((await d.candidates.list())[0]?.status, 'stale');
assert.deepEqual(await d.goals.list(), []);
});
test('an Issue returning to the trusted queue can be revalidated after its source disappeared', async () => {
const { d, issue } = await fixture();
await processIssueCandidates(d);
const original = (await d.candidates.list())[0]!;
d.github.issues = async () => [];
await processIssueCandidates(d);
const absent = (await d.candidates.read(original.id))!;
assert.equal(absent.status, 'stale'); assert.equal(absent.reproduction, undefined);
d.github.issues = async () => [issue];
const returned = await processIssueCandidates(d);
assert.equal(returned.processed[0]?.status, 'reproduced');
assert.equal(returned.pendingApproval, 1);
assert.deepEqual(await d.goals.list(), []);
});
test('polling persists bounded infrastructure backoff and stops after its retry limit', async () => {
const { d } = await fixture({ maxFailures: 2 });
d.repository.fetch = async () => { throw new Error('temporary fetch outage'); };
const first = await processIssueCandidates(d);
assert.equal(first.errors.length, 1);
const candidate = (await d.candidates.list())[0]!;
assert.equal(candidate.failureCount, 1); assert.ok(candidate.retryAfter);
assert.equal((await processIssueCandidates(d)).errors.length, 0, 'retry window suppresses immediate replay');
candidate.retryAfter = new Date(0).toISOString(); await d.candidates.save(candidate);
const second = await processIssueCandidates(d);
assert.equal(second.errors.length, 1);
assert.equal((await d.candidates.read(candidate.id))?.status, 'blocked');
assert.equal((await processIssueCandidates(d)).errors.length, 0);
assert.deepEqual(await d.goals.list(), []);
});
test('polling resumes a candidate whose goal was saved before its candidate link', async () => {
const { d, issue } = await fixture();
await processIssueCandidates(d);
const candidate = (await d.candidates.list())[0]!;
await approveCandidate(candidate.id, candidate.evidenceDigest, d);
const request = issue.title + '\n' + issue.body;
const goal = await createGoal({ title: issue.title, objective: request, mode: 'bugfix', issue: issue.number,
branch: 'main', acceptance: [{ id: 'issue-request', text: request }], allowedPaths: ['src', 'test'] }, d,
{ candidateId: candidate.id, expectedSha: sha });
const pending = (await d.candidates.read(candidate.id))!;
pending.status = 'running'; await d.candidates.save(pending);
await d.goals.pause(goal.id);
assert.equal((await processIssueCandidates(d)).processed.length, 0, 'automatic polling respects an operator pause');
await d.goals.unpause(goal.id);
const resumed = await processIssueCandidates(d);
assert.equal(resumed.processed[0]?.status, 'completed');
assert.equal(resumed.processed[0]?.goalId, goal.id);
assert.equal((await d.goals.list()).length, 1);
});
test('strategy gate blocks new admission but preserves already approved recovery', async () => {
const { d } = await fixture();
await processIssueCandidates(d);
const candidate = (await d.candidates.list())[0]!;
await approveCandidate(candidate.id, candidate.evidenceDigest, d);
const resumed = await processIssueCandidates(d, false);
assert.equal(resumed.deferredByStrategy, true);
assert.equal(resumed.processed[0]?.status, 'completed');
assert.equal((await d.goals.list()).length, 1);
});
test('target movement between discovery and reproduction stops automatic execution', async () => {
const { d } = await fixture({ autoApprove: true });
let reads = 0;
d.github.target = async () => ({ branch: 'main', sha: ++reads === 1 ? sha : 'b'.repeat(40) });
const cycle = await processIssueCandidates(d);
assert.equal(cycle.processed[0]?.status, 'stale');
assert.deepEqual(await d.goals.list(), []);
});
test('Issue edits between candidate validation and goal creation cannot consume a new goal budget', async () => {
for (const change of ['body', 'labels'] as const) {
const { d, issue } = await fixture();
const candidate = (await refreshCandidates(d)).candidates[0]!;
await reproduceCandidate(candidate.id, d); await approveCandidate(candidate.id, candidate.evidenceDigest, d);
let reads = 0;
d.github.issue = async () => {
reads++;
if (reads === 2) {
if (change === 'body') issue.body = 'The Issue changed before goal creation.';
else issue.labels = [];
}
return issue;
};
await assert.rejects(runCandidate(candidate.id, d), /changed before creation|lost queue authorization/);
assert.deepEqual(await d.goals.list(), []);
}
});
test('priority and maxPerRun limit automatic work to one candidate per cycle', async () => {
const { d, issue } = await fixture({ maxPerRun: 1, priorityLabels: ['urgent'] });
const urgent: QueueIssue = { ...issue, number: 8, labels: [...issue.labels, { name: 'urgent' }] };
d.github.issues = async () => [issue, urgent];
d.github.issue = async number => number === 8 ? urgent : issue;
const first = await processIssueCandidates(d);
assert.equal(first.processed.length, 1);
assert.equal((await d.candidates.read(first.processed[0]!.id))?.issue, 8);
const second = await processIssueCandidates(d);
assert.equal(second.processed.length, 1);
assert.equal((await d.candidates.read(second.processed[0]!.id))?.issue, 7);
});