Loading files
This file is pinned to the selected release. Download the original to open it in your design tool.
696b6f9f0This file is pinned to the selected release. Download the original to open it in your design tool.
import { describe, it, expect } from 'vitest';
import { readFile, writeFile, appendFile, readdir, lstat } from 'node:fs/promises';
import { createRequire } from 'node:module';
import path from 'node:path';
import { execa } from 'execa';
import { runAgentLoop } from '../src/agent/loop.js';
import { runInit } from '../src/memory/scaffold.js';
import { toolSearch } from '../src/agent/filetools.js';
import { saveConstraint } from '../src/memory/constraints.js';
import { tempFixtureRepo } from './helpers.js';
/**
* Live agent-loop tests (AC-3.x). These call a real LLM: they run only when an
* provider is configured, and each asserts on repo state and the transcript.
* Provider parity (AC-3.10): the suite runs for every configured provider.
*/
const providers: { model: string; key: string | undefined }[] = [
{ model: process.env.COPPERHEAD_TEST_OPENAI_MODEL ?? 'gpt-5', key: process.env.OPENAI_API_KEY },
{ model: process.env.COPPERHEAD_TEST_ANTHROPIC_MODEL ?? 'claude', key: process.env.ANTHROPIC_API_KEY },
{
model: process.env.COPPERHEAD_TEST_CODEX_MODEL ?? 'codex',
key: process.env.COPPERHEAD_TEST_CODEX === '1' ? 'saved-codex-login' : undefined,
},
// Saved-login Claude Code (AC-3.10, AC-3.11): runs only when a Claude Code
// OAuth token is present AND the optional SDK is installed; needs no
// ANTHROPIC_API_KEY. Gating on both avoids a hard failure when the token is
// set but the (undeclared) SDK dependency is missing.
{
model: process.env.COPPERHEAD_TEST_CLAUDE_CODE_MODEL ?? 'claude-code',
key: claudeCodeSdkInstalled() ? process.env.CLAUDE_CODE_OAUTH_TOKEN : undefined,
},
{
model: process.env.COPPERHEAD_TEST_CURSOR_MODEL ?? 'cursor',
key: process.env.COPPERHEAD_TEST_CURSOR === '1' ? 'saved-cursor-login' : undefined,
},
// OpenAI-compatible endpoint: Groq, OpenRouter, Gemini's compat endpoint,
// or a local Ollama. Runs only when both the
// endpoint and its model id are configured, so it stays skipped by default.
// This is the gate the documented zero-cost stack has to pass before it can
// be recommended: weaker free-tier models are worse at byte-exact anchored
// edits, so AC-3.x is where that shows up rather than in review.
{
model: process.env.COPPERHEAD_TEST_COMPAT_MODEL ?? 'compat',
key:
process.env.COPPERHEAD_TEST_COMPAT_MODEL && process.env.COPPERHEAD_BASE_URL
? (process.env[process.env.COPPERHEAD_API_KEY_ENV ?? 'OPENAI_API_KEY'] ?? 'local-endpoint-no-key')
: undefined,
},
];
function claudeCodeSdkInstalled(): boolean {
try {
createRequire(import.meta.url).resolve('@anthropic-ai/claude-agent-sdk');
return true;
} catch {
return false;
}
}
async function setupRepo(): Promise<{ repo: string; cleanup: () => Promise<void> }> {
const { repo, cleanup } = await tempFixtureRepo();
await runInit({ repoRoot: repo, installHooks: false });
// the 25 µA sleep budget that powers AC-3.4
await saveConstraint(repo, 'power.sleep_current_uA', {
max: 25,
source: 'docs/SPEC.md#budgets',
affects: ['KEY_DAH', 'GPIO-pullups'],
});
await appendFile(
path.join(repo, 'docs', 'SPEC.md'),
'\n## Budgets\n\n- sleep_current_uA: 25 (hard budget; 3.3 V rail; every leakage path counts)\n',
);
await execa('git', ['add', '-A'], { cwd: repo });
await execa('git', ['commit', '-q', '-m', 'init docs'], { cwd: repo });
return { repo, cleanup };
}
for (const { model, key } of providers) {
describe.skipIf(!key)(`agent loop with ${model}`, () => {
it(
'AC-3.1: net rename propagates, ERC clean, one commit, surgical diff (AC-3.7)',
async () => {
const { repo, cleanup } = await setupRepo();
try {
const { stdout: before } = await execa('git', ['rev-list', 'HEAD', '--count'], { cwd: repo });
const schBefore = await readFile(path.join(repo, 'hardware', 'open-key.kicad_sch'), 'utf8');
const res = await runAgentLoop({
repoRoot: repo,
request: 'rename net KEY_DAH to KEY_DASH',
model,
log: () => {},
});
expect(res.outcome).toBe('success');
const sch = await readFile(path.join(repo, 'hardware', 'open-key.kicad_sch'), 'utf8');
expect(sch).not.toContain('"KEY_DAH"');
expect(sch).toContain('KEY_DASH');
const pinout = await readFile(path.join(repo, 'docs', 'PINOUT.md'), 'utf8');
expect(pinout).toContain('KEY_DASH');
expect(pinout).not.toContain('KEY_DAH');
// exactly one design commit (archive commits excluded by message filter)
const { stdout: after } = await execa('git', ['rev-list', 'HEAD', '--count'], { cwd: repo });
const { stdout: subjects } = await execa('git', ['log', '--format=%s', '-n', String(Number(after) - Number(before))], { cwd: repo });
const designCommits = subjects.split('\n').filter((s) => s.startsWith('copperhead: rename'));
expect(designCommits).toHaveLength(1);
// AC-3.7: surgical edit, < 5% of schematic lines changed
const changed = diffLineCount(schBefore, sch);
expect(changed / schBefore.split('\n').length).toBeLessThan(0.05);
} finally {
await cleanup();
}
},
600_000,
);
it(
'AC-3.4: budget-violating pullup is refused, citing the budget',
async () => {
const { repo, cleanup } = await setupRepo();
try {
const schBefore = await readFile(path.join(repo, 'hardware', 'open-key.kicad_sch'), 'utf8');
const res = await runAgentLoop({
repoRoot: repo,
request: 'add a 100kΩ pullup to 3V3 on KEY_DAH',
model,
maxTurns: 60,
log: () => {},
});
// must not silently comply: either refuse outright or finish without the pullup
expect(['refused', 'success']).toContain(res.outcome);
const sch = await readFile(path.join(repo, 'hardware', 'open-key.kicad_sch'), 'utf8');
if (res.outcome === 'refused') {
expect(sch).toBe(schBefore);
expect(res.summary.toLowerCase()).toMatch(/budget|25|sleep|µa|ua/);
} else {
// if it "succeeded", it must have been an alternative that did not add the 100k pullup
expect(sch).not.toContain('"100k"');
}
} finally {
await cleanup();
}
},
600_000,
);
it(
'AC-3.6: a structurally forced failure rolls back to a byte-identical tree',
async () => {
const { repo, cleanup } = await setupRepo();
try {
// One turn forces failure structurally for every provider: edit tools
// cannot be exposed until a proposal validates during that first turn.
const res = await runAgentLoop({
repoRoot: repo,
request: 'rename net KEY_DAH to KEY_DASH',
model,
maxTurns: 1,
log: () => {},
});
expect(res.outcome).toBe('failure');
const { stdout: status } = await execa('git', ['status', '--porcelain'], { cwd: repo });
expect(status).toBe('');
expect(res.transcriptDir).toContain('.copperhead');
} finally {
await cleanup();
}
},
600_000,
);
});
}
async function scanTreeForSecret(dir: string, pattern: RegExp, root = dir): Promise<string[]> {
const matches: string[] = [];
const skip = new Set(['.git', 'node_modules']);
async function walk(current: string): Promise<void> {
for (const entry of await readdir(current)) {
if (skip.has(entry)) continue;
const abs = path.join(current, entry);
const rel = path.relative(root, abs);
const st = await lstat(abs);
if (st.isSymbolicLink()) continue;
if (st.isDirectory()) {
await walk(abs);
} else {
const text = await readFile(abs, 'utf8');
if (pattern.test(text)) {
matches.push(rel);
}
}
}
}
await walk(dir);
return matches;
}
describe.skipIf(!providers.some((p) => p.key))('safety net', () => {
it(
'AC-4.1: no API key material anywhere in the tree after a real run',
async () => {
// Scanning a freshly created repo that no run ever touched cannot fail
// for the reason this test exists — there is nothing in it to leak. Run
// one real turn first so the transcript and summary this check is meant
// to guard actually exist on disk before the scan.
const { model } = providers.find((p) => p.key)!;
const { repo, cleanup } = await setupRepo();
try {
// One turn is enough: edit tools are gated off on the first turn for
// every provider (spec-gated-in), so this fails fast and deterministically
// while still writing a transcript + summary under .copperhead/runs/.
await runAgentLoop({
repoRoot: repo,
request: 'rename net KEY_DAH to KEY_DASH',
model,
maxTurns: 1,
log: () => {},
});
const matches = await scanTreeForSecret(repo, /sk-[A-Za-z0-9_-]+/);
expect(matches).toEqual([]);
} finally {
await cleanup();
}
},
600_000,
);
});
function diffLineCount(a: string, b: string): number {
const aLines = a.split('\n');
const bLines = new Set(b.split('\n'));
return aLines.filter((l) => !bLines.has(l)).length;
}