Loading files
This file is pinned to the selected release. Download the original to open it in your design tool.
import { describe, it, expect, vi } from 'vitest';
import path from 'node:path';
import { readFile, writeFile } from 'node:fs/promises';
import { execa } from 'execa';
import { runAgentLoop, type BudgetExhaustedStats } from '../src/agent/loop.js';
import type { Msg, Provider, ToolSchema, Turn } from '../src/agent/types.js';
import { dispatchTool, type RunContext } from '../src/agent/tools.js';
import { ObligationsLedger } from '../src/agent/ledger.js';
import { Transcript } from '../src/agent/transcript.js';
import { buildSystemPrompt } from '../src/agent/prompts.js';
import { preserveFailedRun, snapshot, restore, isDirty } from '../src/util/git.js';
import { loadConfig } from '../src/config.js';
import { loadConstraints } from '../src/memory/constraints.js';
import { runInit } from '../src/memory/scaffold.js';
import { tempFixtureRepo } from './helpers.js';
/**
* Offline tests for issue #15: continue-on-exhaustion, failed-work
* preservation, obligation deferral, batch resolution, prompt guidance,
* Anthropic prompt caching, per-stage budgets.
*/
/** Scripted provider: plays each turn in order, repeats the last one. */
function scriptedProvider(turns: Partial<Turn>[]): Provider & { seen: Msg[][] } {
let i = 0;
const seen: Msg[][] = [];
return {
name: 'scripted',
seen,
async chat(messages: Msg[]): Promise<Turn> {
seen.push([...messages]);
const t = turns[Math.min(i, turns.length - 1)]!;
i++;
return {
text: t.text ?? null,
toolCalls: (t.toolCalls ?? []).map((c, j) => ({ ...c, id: `call-${i}-${j}` })),
usage: t.usage ?? { inputTokens: 100, outputTokens: 10 },
};
},
};
}
const readCall = { name: 'read_file', args: { path: 'hardware/open-key.kicad_sch', start_line: 1, end_line: 2 } };
async function makeCtx(repo: string): Promise<RunContext> {
const transcript = new Transcript(repo);
await transcript.init();
return {
repoRoot: repo,
config: await loadConfig(repo),
transcript,
ledger: new ObligationsLedger(),
runId: 'test-run',
interactive: false,
confirm: async () => true,
editsUnlocked: true,
changeId: null,
proposalValidated: false,
filesTouched: new Set(),
decisions: [],
lastErc: null,
lastDrc: null,
repairCycles: 0,
finishRequest: null,
};
}
describe('turn-budget exhaustion (AC-15.1..15.4)', () => {
it('granting extra turns continues the run to success, with a budget-extended event', async () => {
const { repo, cleanup } = await tempFixtureRepo();
try {
await runInit({ repoRoot: repo, installHooks: false });
await execa('git', ['add', '-A'], { cwd: repo });
await execa('git', ['commit', '-q', '-m', 'docs'], { cwd: repo });
const provider = scriptedProvider([
{ toolCalls: [readCall] },
{ toolCalls: [readCall] },
{ toolCalls: [{ name: 'finish', args: { outcome: 'done', summary: 'nothing to do' } }] },
]);
const calls: BudgetExhaustedStats[] = [];
const res = await runAgentLoop({
repoRoot: repo,
request: 'noop',
model: 'gpt-5',
provider,
maxTurns: 2,
onBudgetExhausted: async (stats) => {
calls.push(stats);
return 3;
},
log: () => {},
});
expect(res.outcome).toBe('success');
expect(calls).toHaveLength(1);
expect(calls[0]!.turnsUsed).toBe(2);
// the ORIGINAL budget, so the CLI can offer a constant increment
expect(calls[0]!.maxTurns).toBe(2);
// AC-15.4: token usage visible at the decision point (2 turns x 100/10)
expect(calls[0]!.tokensIn).toBe(200);
expect(calls[0]!.tokensOut).toBe(20);
const jsonl = await readFile(path.join(res.transcriptDir, 'transcript.jsonl'), 'utf8');
expect(jsonl).toContain('"budget-extended"');
expect(jsonl).toContain('"extraTurns":3');
} finally {
await cleanup();
}
}, 60_000);
it('declining keeps fail-and-restore, but preserves the touched work in a stash (AC-15.16)', async () => {
const { repo, cleanup } = await tempFixtureRepo();
try {
const provider = scriptedProvider([
{
toolCalls: [
{ name: 'propose_change', args: { id: 'c1', why: 'w', what_changes: '- x', tasks: '- [ ] t' } },
{ name: 'validate_change', args: {} },
],
},
{ toolCalls: [{ name: 'write_file', args: { path: 'NOTES.md', content: 'important work\n' } }] },
{ toolCalls: [readCall] },
]);
const res = await runAgentLoop({
repoRoot: repo,
request: 'noop',
model: 'gpt-5',
provider,
maxTurns: 3,
onBudgetExhausted: async () => 0,
log: () => {},
});
expect(res.outcome).toBe('failure');
expect(res.summary).toContain('turn budget exhausted');
// tree restored byte-identical...
const { stdout: status } = await execa('git', ['status', '--porcelain'], { cwd: repo });
expect(status).toBe('');
// ...but the work survives in a named stash entry
const { stdout: stashes } = await execa('git', ['stash', 'list'], { cwd: repo });
expect(stashes).toContain('copperhead failed run');
const { stdout: stashFiles } = await execa('git', ['stash', 'show', '--include-untracked', '--name-only', 'stash@{0}'], {
cwd: repo,
});
expect(stashFiles).toContain('NOTES.md');
} finally {
await cleanup();
}
}, 60_000);
it('no callback (non-interactive) fails exactly as before, no stash for an untouched tree (AC-15.3, AC-15.17)', async () => {
const { repo, cleanup } = await tempFixtureRepo();
try {
const provider = scriptedProvider([{ toolCalls: [readCall] }]);
const res = await runAgentLoop({
repoRoot: repo,
request: 'noop',
model: 'gpt-5',
provider,
maxTurns: 2,
log: () => {},
});
expect(res.outcome).toBe('failure');
const { stdout: stashes } = await execa('git', ['stash', 'list'], { cwd: repo });
expect(stashes).toBe('');
} finally {
await cleanup();
}
}, 60_000);
it('a throwing callback reads as declined: the run still restores instead of crashing past rollback', async () => {
const { repo, cleanup } = await tempFixtureRepo();
try {
const provider = scriptedProvider([
{
toolCalls: [
{ name: 'propose_change', args: { id: 'c1', why: 'w', what_changes: '- x', tasks: '- [ ] t' } },
{ name: 'validate_change', args: {} },
],
},
{ toolCalls: [{ name: 'write_file', args: { path: 'NOTES.md', content: 'work\n' } }] },
]);
const res = await runAgentLoop({
repoRoot: repo,
request: 'noop',
model: 'gpt-5',
provider,
maxTurns: 2,
onBudgetExhausted: async () => {
throw new Error('stdin closed');
},
log: () => {},
});
expect(res.outcome).toBe('failure');
expect(res.summary).toContain('turn budget exhausted');
const { stdout: status } = await execa('git', ['status', '--porcelain'], { cwd: repo });
expect(status).toBe('');
const { stdout: stashes } = await execa('git', ['stash', 'list'], { cwd: repo });
expect(stashes).toContain('copperhead failed run');
} finally {
await cleanup();
}
}, 60_000);
it('the 5-turns-remaining nudge tells the model to batch tool calls (AC-15.6)', async () => {
const { repo, cleanup } = await tempFixtureRepo();
try {
const provider = scriptedProvider([
{ toolCalls: [readCall] },
{ toolCalls: [{ name: 'finish', args: { outcome: 'refuse', summary: 'stop here' } }] },
]);
await runAgentLoop({ repoRoot: repo, request: 'noop', model: 'gpt-5', provider, maxTurns: 6, log: () => {} });
const secondTurnMessages = provider.seen[1]!;
const nudge = secondTurnMessages.filter((m) => m.role === 'user').map((m) => (m as { content: string }).content);
expect(nudge.some((c) => c.includes('Only 5 turns remain') && c.includes('Batch independent tool calls'))).toBe(true);
} finally {
await cleanup();
}
}, 60_000);
});
describe('preserveFailedRun (safety-rails)', () => {
it('stashes tracked and untracked changes under the run id; work is recoverable after restore', async () => {
const { repo, cleanup } = await tempFixtureRepo();
try {
const snap = await snapshot(repo);
const sch = path.join(repo, 'hardware', 'open-key.kicad_sch');
const before = await readFile(sch, 'utf8');
await writeFile(sch, before.replace('KEY_DAH', 'KEY_EDITED'), 'utf8');
await writeFile(path.join(repo, 'new-doc.md'), 'untracked work\n', 'utf8');
const sha = await preserveFailedRun(repo, 'run-42');
expect(sha).toMatch(/^[0-9a-f]{40}$/);
const { stdout: stashes } = await execa('git', ['stash', 'list'], { cwd: repo });
expect(stashes).toContain('copperhead failed run run-42');
await restore(repo, snap);
expect(await isDirty(repo)).toBe(false);
await execa('git', ['stash', 'apply'], { cwd: repo });
expect(await readFile(sch, 'utf8')).toContain('KEY_EDITED');
expect((await readFile(path.join(repo, 'new-doc.md'), 'utf8')).replace(/\r\n/g, '\n')).toBe('untracked work\n');
} finally {
await cleanup();
}
});
it('returns null on a clean tree', async () => {
const { repo, cleanup } = await tempFixtureRepo();
try {
expect(await preserveFailedRun(repo, 'run-43')).toBeNull();
} finally {
await cleanup();
}
});
});
// Obligation deferral for not-yet-built artifacts is provided by the persisted
// constraint-registry mechanism on main (classifyAffectsTarget +
// reopenDeferredAffects); this PR keeps the resolve_affected batch form and the
// budget/continue machinery on top of it. These two tests pin the reconciled
// contract: a not-yet-built artifact defers (persisted, no in-run obligation),
// an existing artifact and a bare refdes open obligations now.
describe('deferred revisit obligations (reconciled with main)', () => {
it('does not open an obligation for an artifact that does not exist yet; persists it as deferred', async () => {
const { repo, cleanup } = await tempFixtureRepo();
try {
const ctx = await makeCtx(repo);
expect(ctx.config.schematic).toBeNull();
const res = await dispatchTool(ctx, 'record_constraint', {
key: 'power.sleep_current_uA',
max: 25,
source: 'brief',
affects: ['schematic', 'layout'],
});
expect(ctx.ledger.openOfKind('affects-revisit')).toHaveLength(0);
expect(res).toContain('deferred until the target artifact exists');
const registry = await loadConstraints(repo);
expect(registry['power.sleep_current_uA']!.deferred).toEqual(['schematic', 'layout']);
// deferral never blocks finish
const done = await dispatchTool(ctx, 'finish', { outcome: 'done', summary: 'seeded' });
expect(done).toContain('all gates satisfied');
} finally {
await cleanup();
}
});
it('opens obligations for an existing artifact and for a bare refdes', async () => {
const { repo, cleanup } = await tempFixtureRepo();
try {
await runInit({ repoRoot: repo, installHooks: false });
const ctx = await makeCtx(repo);
expect(ctx.config.schematic).toBeTruthy();
await dispatchTool(ctx, 'record_constraint', {
key: 'power.sleep_current_uA',
max: 25,
source: 'docs/SPEC.md',
affects: ['schematic', 'R1'],
});
expect(ctx.ledger.openOfKind('affects-revisit')).toHaveLength(2);
const registry = await loadConstraints(repo);
expect(registry['power.sleep_current_uA']!.deferred).toBeUndefined();
} finally {
await cleanup();
}
});
});
describe('resolve_affected batch form (AC-15.9, AC-15.10)', () => {
it('one call clears several obligations', async () => {
const { repo, cleanup } = await tempFixtureRepo();
try {
await runInit({ repoRoot: repo, installHooks: false });
const ctx = await makeCtx(repo);
await dispatchTool(ctx, 'record_constraint', {
key: 'power.sleep_current_uA',
max: 25,
source: 'docs/SPEC.md',
affects: ['R1', 'R2', 'U1'],
});
expect(ctx.ledger.openOfKind('affects-revisit')).toHaveLength(3);
const res = await dispatchTool(ctx, 'resolve_affected', {
resolutions: [
{ constraint_key: 'power.sleep_current_uA', item: 'R1', resolution: 'no change needed: EN pullup' },
{ constraint_key: 'power.sleep_current_uA', item: 'R2', resolution: 'no change needed: not in sleep path' },
{ constraint_key: 'power.sleep_current_uA', item: 'U1', resolution: 'changed: picked low-Iq LDO' },
],
});
expect(ctx.ledger.openOfKind('affects-revisit')).toHaveLength(0);
expect(res.split('\n').filter((l) => l.startsWith('resolved:'))).toHaveLength(3);
expect(ctx.decisions).toHaveLength(3);
} finally {
await cleanup();
}
});
it('a bad entry does not waste the call: per-entry results', async () => {
const { repo, cleanup } = await tempFixtureRepo();
try {
await runInit({ repoRoot: repo, installHooks: false });
const ctx = await makeCtx(repo);
await dispatchTool(ctx, 'record_constraint', {
key: 'power.cc_rd_ohms',
max: 5100,
source: 'docs/SPEC.md',
affects: ['CC1', 'CC2'],
});
const res = await dispatchTool(ctx, 'resolve_affected', {
resolutions: [
{ constraint_key: 'power.cc_rd_ohms', item: 'CC1', resolution: 'changed: 5.1k' },
{ constraint_key: 'power.cc_rd_ohms', item: 'CC-nope', resolution: 'changed: 5.1k' },
],
});
expect(res).toContain('resolved: power.cc_rd_ohms affects CC1');
expect(res).toContain('error: no open affects-revisit obligation matches "power.cc_rd_ohms affects CC-nope"');
expect(ctx.ledger.openOfKind('affects-revisit')).toHaveLength(1);
} finally {
await cleanup();
}
});
it('neither form given is a corrective error', async () => {
const { repo, cleanup } = await tempFixtureRepo();
try {
const ctx = await makeCtx(repo);
const res = await dispatchTool(ctx, 'resolve_affected', {});
expect(res).toContain('error:');
expect(res).toContain('resolutions');
} finally {
await cleanup();
}
});
});
describe('convergence feedback (AC-15.12, AC-15.13)', () => {
it('run_erc / run_drc without artifacts read as not-applicable, not retryable', async () => {
const { repo, cleanup } = await tempFixtureRepo();
try {
const ctx = await makeCtx(repo);
expect(await dispatchTool(ctx, 'run_erc', {})).toContain('does not apply yet');
expect(await dispatchTool(ctx, 'run_drc', {})).toContain('does not apply yet');
} finally {
await cleanup();
}
});
it('empty search pattern gets a corrective hint', async () => {
const { repo, cleanup } = await tempFixtureRepo();
try {
const ctx = await makeCtx(repo);
const res = await dispatchTool(ctx, 'search', { pattern: '' });
expect(res).toContain('non-empty regex');
expect(res).toContain('e.g.');
} finally {
await cleanup();
}
});
});
describe('batching guidance in the system prompt (AC-15.5)', () => {
it('WORKFLOW instructs multiple tool calls per response', async () => {
const { repo, cleanup } = await tempFixtureRepo();
try {
const prompt = await buildSystemPrompt(repo, await loadConfig(repo), {});
expect(prompt).toContain('issue them together in a single reply');
expect(prompt).toContain('resolutions: [...]');
} finally {
await cleanup();
}
});
});
describe('Anthropic prompt caching (AC-15.14, AC-15.15)', () => {
it('marks three cache_control breakpoints and counts cached tokens', async () => {
const create = vi.fn().mockResolvedValue({
content: [{ type: 'text', text: 'ok' }],
usage: { input_tokens: 10, output_tokens: 2, cache_read_input_tokens: 90, cache_creation_input_tokens: 5 },
});
vi.doMock('@anthropic-ai/sdk', () => ({
default: class {
messages = { create };
},
}));
try {
const { AnthropicProvider } = await import('../src/agent/providers/anthropic.js');
const provider = new AnthropicProvider('claude-sonnet-5', 'test-key');
const tools: ToolSchema[] = [
{ name: 'a', description: 'a', parameters: { type: 'object', properties: {}, required: [] } },
{ name: 'b', description: 'b', parameters: { type: 'object', properties: {}, required: [] } },
];
const messages: Msg[] = [
{ role: 'system', content: 'sys' },
{ role: 'user', content: 'hi' },
{ role: 'assistant', content: null, toolCalls: [{ id: 't1', name: 'a', args: {} }] },
{ role: 'tool', toolCallId: 't1', content: 'result' },
];
const turn = await provider.chat(messages, tools);
const req = create.mock.calls[0]![0] as {
system: { cache_control?: unknown }[];
tools: { name: string; cache_control?: unknown }[];
messages: { content: { cache_control?: unknown }[] }[];
};
expect(req.system[0]!.cache_control).toEqual({ type: 'ephemeral' });
expect(req.tools[0]!.cache_control).toBeUndefined();
expect(req.tools[1]!.cache_control).toEqual({ type: 'ephemeral' });
const lastMsg = req.messages[req.messages.length - 1]!;
const lastBlock = lastMsg.content[lastMsg.content.length - 1]!;
expect(lastBlock.cache_control).toEqual({ type: 'ephemeral' });
const breakpoints = JSON.stringify(req).match(/"cache_control"/g) ?? [];
expect(breakpoints).toHaveLength(3);
expect(turn.usage.inputTokens).toBe(105);
expect(turn.usage.outputTokens).toBe(2);
} finally {
vi.doUnmock('@anthropic-ai/sdk');
}
});
});
describe('per-stage turn budgets (AC-15.18, AC-15.19)', () => {
it('loadConfig parses stageMaxTurns and omits it when absent', async () => {
const { repo, cleanup } = await tempFixtureRepo();
try {
expect((await loadConfig(repo)).stageMaxTurns).toBeUndefined();
const { mkdir } = await import('node:fs/promises');
await mkdir(path.join(repo, '.copperhead'), { recursive: true });
await writeFile(
path.join(repo, '.copperhead', 'config.json'),
// zero/negative/non-integer entries are config typos: dropped on load
// so a stage cannot start with an already-exhausted budget
JSON.stringify({ maxTurns: 40, stageMaxTurns: { 'spec-seed': 60, architecture: 0, layout: -5, docs: 1.5 } }),
'utf8',
);
const config = await loadConfig(repo);
expect(config.maxTurns).toBe(40);
expect(config.stageMaxTurns).toEqual({ 'spec-seed': 60 });
} finally {
await cleanup();
}
});
});
describe('empty-completion tolerance (agent loop)', () => {
it('sporadic tool-less turns between productive ones do not fail the run', async () => {
const { repo, cleanup } = await tempFixtureRepo();
try {
await runInit({ repoRoot: repo, installHooks: false });
await execa('git', ['add', '-A'], { cwd: repo });
await execa('git', ['commit', '-q', '-m', 'docs'], { cwd: repo });
// Three empty completions spread across productive turns — observed
// live from a provider mid-run. Only consecutive stalls may fail.
const provider = scriptedProvider([
{},
{ toolCalls: [readCall] },
{},
{ toolCalls: [readCall] },
{},
{ toolCalls: [{ name: 'finish', args: { outcome: 'done', summary: 'done' } }] },
]);
const res = await runAgentLoop({
repoRoot: repo,
request: 'noop',
model: 'gpt-5',
provider,
maxTurns: 10,
log: () => {},
});
expect(res.outcome).toBe('success');
// each empty turn still got the continue-or-finish nudge
const nudged = provider.seen
.flat()
.filter((m) => m.role === 'user' && (m as { content: string }).content.includes('Continue using tools'));
expect(nudged.length).toBeGreaterThanOrEqual(3);
} finally {
await cleanup();
}
}, 60_000);
it('three consecutive tool-less turns still fail the run', async () => {
const { repo, cleanup } = await tempFixtureRepo();
try {
await runInit({ repoRoot: repo, installHooks: false });
await execa('git', ['add', '-A'], { cwd: repo });
await execa('git', ['commit', '-q', '-m', 'docs'], { cwd: repo });
const provider = scriptedProvider([{ toolCalls: [readCall] }, {}]);
const res = await runAgentLoop({
repoRoot: repo,
request: 'noop',
model: 'gpt-5',
provider,
maxTurns: 10,
log: () => {},
});
expect(res.outcome).toBe('failure');
expect(res.summary).toContain('stopped calling tools');
} finally {
await cleanup();
}
}, 60_000);
});
54e85dd7fThis file is pinned to the selected release. Download the original to open it in your design tool.