mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-05 00:03:15 +01:00
* Attribute every paid trial's cost: bind label-named records by testName, price aborted SDK sessions from streamed usage, model-free cases cost $0
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* fix: case cost adds streamed sessions to record sums and keeps record cost_known
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* fix: the paid report headline counts ledger-only session cost once
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* fix: a cost-only record never sets the trial model
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* test: recordE2E writes cost_case_id only for a registered label-named case
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* docs: CHANGELOG for paid-report cost attribution (Unreleased)
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* fix: the headline adds only streamed session cost; cache writes without a 5m key
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* docs: how the paid report prices a trial and what stays cost-unknown
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* Price PTY sessions from their transcripts: pin a session id, sum the session's and its subagents' usage into the ledger's streamed_cost_usd
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* fix: transcript pricing never throws from a PTY ledger writer
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* chore: pin transcript-cost.ts as non-behavioral at HARNESS_VERSION 6
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* fix: recognise --session-id= and --resume= forms
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* docs: CHANGELOG for PTY transcript pricing (Unreleased)
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* docs: PTY sessions priced from their transcripts
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* fix: price PTY transcripts without adding a launch argument; native AUQ capture opts out
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* docs: harness notes and CHANGELOG match the transcript claim
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* feat(flaky): gstack-flaky run reruns a failed JUnit test once and ledgers the flake (GSTA-158)
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* fix(flaky): stream output, keep unreported runner failures red, fail-closed duplicates and nonzero reruns
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* fix: pre-landing review fixes
gstack-flaky never greens an incomplete run: a targeted rerun needs the
runner's summary proving the first run complete, otherwise (and always for
generic runners) the rerun repeats the whole command. A rerun clears a test
only when it exits 0 and the test passes once on its first attempt. Killed
runners exit 3 (FLAKY_COMMAND_KILLED) and signals are forwarded. The first
report is restored at --junit with reruns kept beside it; ids are redacted at
every output; the ledger gets its own GSTACK_FLAKE_LEDGER_JUNIT; fixtures are
registered in FREE_FIXTURES.
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* fix: latch cancellation and check whole-command rerun reports
A cancel forwarded to a runner that traps the signal now counts as killed for
every later attempt, so a cancelled job is never rerun or greened. A
whole-command rerun clears nothing when its report has a failed test, misses a
first-run test, or its runner counted failures outside the report.
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* fix: whole-command rerun when coverage or runner-flaky tests can fail the run
A coverage threshold or a fail-on-flaky policy fails a run for a reason a
narrowed rerun cannot reproduce, so either forces a whole-command rerun.
--junit refuses an existing file that is not a JUnit report instead of
deleting it.
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* feat(flaky): rerun the whole command by default; targeted rerun is opt-in
A rerun counts only on the runner's own full green verdict: exit 0, no failed
or missing test, nothing counted outside the report. That covers every way a
run can fail, which a narrowed rerun cannot (a coverage threshold with an lcov
reporter, a fail-on-flaky policy, a test never reached). --rerun-scope test
reruns the failed test alone, with its preconditions documented and the
complete-run checks kept as a safety net. An unreadable existing --junit file
is a usage error, not a crash.
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* docs: gstack-flaky CHANGELOG entry and standalone-binaries row (GSTA-158)
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* feat(flaky): quarantine registry for repeat flakes (GSTA-158 P2)
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* fix(flaky): bound renewal dates; match non-ASCII paths in check --base
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* fix(flaky): reword the FLAKY_QUARANTINE_BASE hint so the egress scanner does not read it as a network op
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* test(agent-sdk): every SDK caller passes the claude_code preset or names why not
The SDK's default system prompt is a bare stub that drops the line telling the
model to run /skill through the Skill tool. runAgentSdkTest now refuses a
missing, empty or bare-string prompt; non-preset callers name a reason; a free
tripwire scans test/ and scripts/ for the same mistake.
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* test: record the SDK prompt guard as a non-behavioral harness change and baseline its tripwire
harness-version: runAgentSdkTest validates systemPrompt before starting; every
live caller already passed the claude_code preset, so no verdict can change.
test-of-test ratchet: the tripwire's subject is the eval harness by design.
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* docs: name the preset literal callers pass and the unnamed-prompt refusal
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* feat(eval-plan): frontier models under test (R54)
Haiku and Sonnet are not models under test. dry-run refuses a plan whose every
arm is Haiku or Sonnet unless --tier-question names what it asks about that
tier, warns on a mix, and re-costs the plan on Opus 5.5 and GPT-6.1 Sol.
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* fix(eval-plan): trim R54 skill prose and raise the eval-plan context ceiling
The R54 paragraph and CHECK: frontier bullet were cut to the minimum; the
exemptions and verdict rule stay in docs/TESTING_INTERNALS.md. The remaining
growth is the feature, so only the eval-plan eager ceiling moves (4887 -> 5458,
the capture helper's value); other skills' ceilings are left as they were.
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* fix(eval-plan): pilot start keeps the R54 refusal; --tier-question rejects a flag
Review pass 1 (full depth) found the frontier refusal could be skipped by
starting a pilot on Haiku or Sonnet arms behind a dry run made on other arms,
or behind a dry-run.json written before the check existed. startPilot now
refuses a pilot whose every arm is Haiku or Sonnet unless its dry run recorded
a tier question. dry-run --tier-question no longer takes the next flag as its
answer. The testing doc now says the frontier re-cost is printed for
comparison with the brief cap, not enforced.
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* docs(support-tiers): Windows CI in the present tense now that windows-smoke is required (#3806)
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* chore: empty commit for a clean check set at the same tree (cancelled orphan free-suite check at 97487df)
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* docs(skills): /eval-plan entry names the R54 frontier check
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
---------
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
437 lines
18 KiB
TypeScript
437 lines
18 KiB
TypeScript
/**
|
|
* Shared helpers for E2E test files.
|
|
*
|
|
* Extracted from the (since-deleted) pre-split monolith to support
|
|
* splitting tests across multiple skill-e2e-*.test.ts files by category.
|
|
*/
|
|
|
|
import '../../lib/conductor-env-shim';
|
|
import { describe, test, afterAll, expect } from 'bun:test';
|
|
import type { SkillTestResult } from './session-runner';
|
|
import { EvalCollector, judgePassed } from './eval-store';
|
|
import type { EvalTestEntry } from './eval-store';
|
|
import { judgeRecommendation, type RecommendationScore } from './llm-judge';
|
|
import { selectTests, detectBaseBranch, getChangedFiles, E2E_TOUCHFILES, E2E_TIERS, GLOBAL_TOUCHFILES } from './touchfiles';
|
|
import { WorktreeManager } from '../../lib/worktree';
|
|
import type { HarvestResult } from '../../lib/worktree';
|
|
import { preflightAnthropicApi } from './anthropic-preflight';
|
|
import { isHermeticEnabled } from './hermetic-env';
|
|
import { hostSkipReason, resolveEvalHost } from './e2e-hosts';
|
|
import * as fs from 'fs';
|
|
import * as path from 'path';
|
|
import * as os from 'os';
|
|
|
|
export const ROOT = path.resolve(import.meta.dir, '..', '..');
|
|
|
|
// Skip unless EVALS=1. Session runner strips CLAUDE* env vars to avoid nested session issues.
|
|
//
|
|
// BLAME PROTOCOL: When an eval fails, do NOT claim "pre-existing" or "not related
|
|
// to our changes" without proof. Run the same eval on main to verify. These tests
|
|
// have invisible couplings — preamble text, SKILL.md content, and timing all affect
|
|
// agent behavior. See CLAUDE.md "E2E eval failure blame protocol" for details.
|
|
export const evalsEnabled = !!process.env.EVALS;
|
|
|
|
// --- Diff-based test selection ---
|
|
// When EVALS_ALL is not set, only run tests whose touchfiles were modified.
|
|
// Set EVALS_ALL=1 to force all tests. Set EVALS_BASE to override base branch.
|
|
|
|
/**
|
|
* Compute the diff-based selection for a touchfiles table. Returns null for
|
|
* "run all" (EVALS off, EVALS_ALL=1, or no diff vs the base branch — e.g. on
|
|
* main). Shared by this module (E2E_TOUCHFILES) and skill-llm-eval.test.ts
|
|
* (LLM_JUDGE_TOUCHFILES) so the selection logic exists exactly once.
|
|
*/
|
|
export function computeDiffSelection(
|
|
touchfiles: Record<string, string[]>,
|
|
label: string,
|
|
): string[] | null {
|
|
if (!evalsEnabled || process.env.EVALS_ALL) return null;
|
|
const baseBranch = process.env.EVALS_BASE
|
|
|| detectBaseBranch(ROOT)
|
|
|| 'main';
|
|
const changedFiles = getChangedFiles(baseBranch, ROOT);
|
|
// If changedFiles is empty (e.g., on main branch), run all
|
|
if (changedFiles.length === 0) return null;
|
|
|
|
const selection = selectTests(changedFiles, touchfiles, GLOBAL_TOUCHFILES);
|
|
process.stderr.write(`\n${label} selection (${selection.reason}): ${selection.selected.length}/${Object.keys(touchfiles).length} tests\n`);
|
|
if (selection.skipped.length > 0) {
|
|
process.stderr.write(` Skipped: ${selection.skipped.join(', ')}\n`);
|
|
}
|
|
process.stderr.write('\n');
|
|
return selection.selected;
|
|
}
|
|
|
|
/**
|
|
* Parse the sharded paid runner's precomputed selection (EVALS_SELECTION_JSON,
|
|
* written by serializePaidDiffSelection in scripts/test-paid-shards.ts).
|
|
* Returns { selected: null } for run-all. THROWS on any parse/shape failure —
|
|
* resolveModuleSelection turns that into a fail-open local recompute.
|
|
*/
|
|
export function parseEvalsSelectionJson(raw: string): { selected: string[] | null; reason: string } {
|
|
const parsed: unknown = JSON.parse(raw);
|
|
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) throw new Error('not an object');
|
|
const { selected, reason } = parsed as { selected?: unknown; reason?: unknown };
|
|
if (selected !== null
|
|
&& !(Array.isArray(selected) && selected.every((s) => typeof s === 'string'))) {
|
|
throw new Error('selected must be null or string[]');
|
|
}
|
|
return {
|
|
selected: selected as string[] | null,
|
|
reason: typeof reason === 'string' ? reason : 'parent selection',
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Resolve the module-load E2E selection: prefer the parent shard runner's
|
|
* EVALS_SELECTION_JSON — skipping this module's own git walk and, when
|
|
* touchfiles-data.ts is in the diff, the per-child bun subprocess that
|
|
* evaluates the old data file (test-selection.ts map-diff path, one per
|
|
* shard). On ANY parse/shape failure, fall back to computing locally
|
|
* (fail-open preserved) with one stderr warning.
|
|
*/
|
|
export function resolveModuleSelection(
|
|
raw: string | undefined,
|
|
compute: () => string[] | null,
|
|
stderrWrite: (text: string) => void = (text) => process.stderr.write(text),
|
|
): string[] | null {
|
|
const strictProfile = process.env.EVALS_PROFILE === 'pr';
|
|
if (raw) {
|
|
try {
|
|
const { selected, reason } = parseEvalsSelectionJson(raw);
|
|
stderrWrite(`\nE2E selection (parent-propagated: ${reason}): ${selected === null ? 'all' : selected.length} tests\n`);
|
|
return selected;
|
|
} catch (err) {
|
|
if (strictProfile) throw new Error(`PR profile requires a valid persisted selection: ${err instanceof Error ? err.message : String(err)}`);
|
|
stderrWrite(`WARNING: malformed EVALS_SELECTION_JSON (${err instanceof Error ? err.message : String(err)}) — falling back to local selection\n`);
|
|
}
|
|
}
|
|
if (strictProfile) throw new Error('PR profile requires persisted case selection from scripts/test-paid-shards.ts');
|
|
return compute();
|
|
}
|
|
|
|
export let selectedTests: string[] | null = resolveModuleSelection(
|
|
evalsEnabled ? process.env.EVALS_SELECTION_JSON : undefined,
|
|
() => computeDiffSelection(E2E_TOUCHFILES, 'E2E'),
|
|
); // null = run all
|
|
|
|
// EVALS_TIER: filter tests by tier after diff-based selection.
|
|
// 'gate' = gate tests only (CI default — blocks merge)
|
|
// 'periodic' = periodic tests only (weekly cron / manual)
|
|
// 'marathon' = full end-to-end flows only (non-blocking marathon lane)
|
|
// not set = run all selected tests (local dev default, backward compat)
|
|
if (evalsEnabled && process.env.EVALS_TIER) {
|
|
const tier = process.env.EVALS_TIER as 'gate' | 'periodic' | 'marathon';
|
|
const tierTests = Object.entries(E2E_TIERS)
|
|
.filter(([, t]) => t === tier)
|
|
.map(([name]) => name);
|
|
|
|
if (selectedTests === null) {
|
|
selectedTests = tierTests;
|
|
} else {
|
|
selectedTests = selectedTests.filter(t => tierTests.includes(t));
|
|
}
|
|
process.stderr.write(`EVALS_TIER=${tier}: ${selectedTests.length} tests\n\n`);
|
|
}
|
|
|
|
// EVALS_HOST=codex: Claude-only cases are skipped with their reason, never run
|
|
// (and never failed); the report deselects them (test/helpers/e2e-hosts.ts).
|
|
if (evalsEnabled && resolveEvalHost() !== 'claude') {
|
|
const host = resolveEvalHost();
|
|
const candidates = selectedTests ?? Object.keys(E2E_TIERS);
|
|
const skipped = candidates.filter(id => hostSkipReason(id, host));
|
|
selectedTests = candidates.filter(id => !hostSkipReason(id, host));
|
|
process.stderr.write(`EVALS_HOST=${host}: ${selectedTests.length} tests; ${skipped.length} Claude-only skipped (${skipped.join(', ') || 'none'})\n\n`);
|
|
(await import('./codex-host-runner')).prepareCodexHostRuntime();
|
|
}
|
|
|
|
export const describeE2E = evalsEnabled ? describe : describe.skip;
|
|
|
|
/**
|
|
* Wrap a describe block to skip entirely if none of its tests are selected.
|
|
* `selected` defaults to this module's E2E selection (diff + EVALS_TIER);
|
|
* pass an explicit selection (e.g. computeDiffSelection over
|
|
* LLM_JUDGE_TOUCHFILES) to reuse the gating against a different table.
|
|
*/
|
|
export function describeIfSelected(name: string, testNames: string[], fn: () => void, selected: string[] | null = selectedTests) {
|
|
const anySelected = selected === null || testNames.some(t => selected.includes(t));
|
|
(anySelected ? describeE2E : describe.skip)(name, fn);
|
|
}
|
|
|
|
// Unique run ID for this E2E session — used for heartbeat + per-run log directory
|
|
export const runId = new Date().toISOString().replace(/[:.]/g, '').replace('T', '-').slice(0, 15);
|
|
|
|
export const browseBin = path.resolve(ROOT, 'browse', 'dist', 'browse');
|
|
|
|
// Check if Anthropic API key is available (needed for outcome evals)
|
|
export const hasApiKey = !!process.env.ANTHROPIC_API_KEY;
|
|
|
|
/**
|
|
* Copy a directory tree recursively (files only, follows structure).
|
|
*/
|
|
export function copyDirSync(src: string, dest: string) {
|
|
fs.mkdirSync(dest, { recursive: true });
|
|
for (const entry of fs.readdirSync(src, { withFileTypes: true })) {
|
|
const srcPath = path.join(src, entry.name);
|
|
const destPath = path.join(dest, entry.name);
|
|
if (entry.isDirectory()) {
|
|
copyDirSync(srcPath, destPath);
|
|
} else {
|
|
fs.copyFileSync(srcPath, destPath);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Set up browse shims (binary symlink, find-browse, remote-slug) in a tmpDir.
|
|
*/
|
|
export function setupBrowseShims(dir: string) {
|
|
// Symlink browse binary
|
|
const binDir = path.join(dir, 'browse', 'dist');
|
|
fs.mkdirSync(binDir, { recursive: true });
|
|
if (fs.existsSync(browseBin)) {
|
|
fs.symlinkSync(browseBin, path.join(binDir, 'browse'));
|
|
}
|
|
|
|
// find-browse shim
|
|
const findBrowseDir = path.join(dir, 'browse', 'bin');
|
|
fs.mkdirSync(findBrowseDir, { recursive: true });
|
|
fs.writeFileSync(
|
|
path.join(findBrowseDir, 'find-browse'),
|
|
`#!/bin/bash\necho "${browseBin}"\n`,
|
|
{ mode: 0o755 },
|
|
);
|
|
|
|
// remote-slug shim (returns test-project)
|
|
fs.writeFileSync(
|
|
path.join(findBrowseDir, 'remote-slug'),
|
|
`#!/bin/bash\necho "test-project"\n`,
|
|
{ mode: 0o755 },
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Print cost summary after an E2E test.
|
|
*/
|
|
export function logCost(label: string, result: { costEstimate: { turnsUsed: number; estimatedTokens: number; estimatedCost: number }; duration: number }) {
|
|
const { turnsUsed, estimatedTokens, estimatedCost } = result.costEstimate;
|
|
const durationSec = Math.round(result.duration / 1000);
|
|
console.log(`${label}: $${estimatedCost.toFixed(2)} (${turnsUsed} turns, ${(estimatedTokens / 1000).toFixed(1)}k tokens, ${durationSec}s)`);
|
|
}
|
|
|
|
/**
|
|
* Dump diagnostic info on planted-bug outcome failure (decision 1C).
|
|
*/
|
|
export function dumpOutcomeDiagnostic(dir: string, label: string, report: string, judgeResult: any) {
|
|
try {
|
|
const transcriptDir = path.join(dir, '.gstack', 'test-transcripts');
|
|
fs.mkdirSync(transcriptDir, { recursive: true });
|
|
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
|
|
fs.writeFileSync(
|
|
path.join(transcriptDir, `${label}-outcome-${timestamp}.json`),
|
|
JSON.stringify({ label, report, judgeResult }, null, 2),
|
|
);
|
|
} catch { /* non-fatal */ }
|
|
}
|
|
|
|
/**
|
|
* Create an EvalCollector for a specific suite. Returns null if evals are not enabled.
|
|
*/
|
|
export function createEvalCollector(suite: string): EvalCollector | null {
|
|
return evalsEnabled ? new EvalCollector(suite) : null;
|
|
}
|
|
|
|
/** DRY helper to record an E2E test result into the eval collector. */
|
|
/** Exit reasons for an API or transport failure (session-runner.ts). */
|
|
const INFRA_EXIT_REASONS = new Set(['error_api', 'timeout_startup', 'error_output_stream']);
|
|
|
|
/** API/transport error or CLI crash before the first model turn: INFRA, never a
|
|
* verdict on the product. Any assistant event or counted turn means the model
|
|
* ran, so its refusal, timeout or wrong answer stays an ordinary failure. */
|
|
export function isPreTurnInfraFailure(result: Pick<SkillTestResult, 'exitReason' | 'transcript' | 'costEstimate'>): boolean {
|
|
return result.costEstimate.turnsUsed === 0
|
|
&& (INFRA_EXIT_REASONS.has(result.exitReason) || /^exit_code_\d+$/.test(result.exitReason))
|
|
&& !result.transcript.some(event => event?.type === 'assistant');
|
|
}
|
|
|
|
export function recordE2E(
|
|
evalCollector: EvalCollector | null,
|
|
name: string,
|
|
suite: string,
|
|
result: SkillTestResult,
|
|
extra?: Partial<EvalTestEntry>,
|
|
) {
|
|
// Derive last tool call from transcript for machine-readable diagnostics
|
|
const lastTool = result.toolCalls.length > 0
|
|
? `${result.toolCalls[result.toolCalls.length - 1].tool}(${JSON.stringify(result.toolCalls[result.toolCalls.length - 1].input).slice(0, 60)})`
|
|
: undefined;
|
|
|
|
const passed = extra?.passed ?? (result.exitReason === 'success' && result.browseErrors.length === 0);
|
|
evalCollector?.addTest({
|
|
name, suite, tier: 'e2e',
|
|
passed,
|
|
...(!passed && isPreTurnInfraFailure(result) ? { failure_class: 'infra' as const } : {}),
|
|
duration_ms: result.duration,
|
|
cost_usd: result.costEstimate.estimatedCost,
|
|
...(result.costKnown === false ? { cost_known: false } : {}),
|
|
...(result.testName && result.testName !== name && result.testName in E2E_TIERS ? { cost_case_id: result.testName } : {}),
|
|
...(result.tokenUsage ? { token_usage: result.tokenUsage } : {}),
|
|
transcript: result.transcript,
|
|
output: result.output?.slice(0, 2000),
|
|
turns_used: result.costEstimate.turnsUsed,
|
|
tokens_used: result.costEstimate.estimatedTokens,
|
|
browse_errors: result.browseErrors,
|
|
exit_reason: result.exitReason,
|
|
timeout_at_turn: result.exitReason === 'timeout' ? result.costEstimate.turnsUsed : undefined,
|
|
last_tool_call: lastTool,
|
|
model: result.model,
|
|
first_response_ms: result.firstResponseMs,
|
|
max_inter_turn_ms: result.maxInterTurnMs,
|
|
...extra,
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Threshold for `reason_substance` (1-5 rubric) above which a recommendation
|
|
* is considered substantive enough to ship. 4 = "concrete and option-specific";
|
|
* 3 = generic ("because it's faster"). We want to catch generic. If Haiku
|
|
* flakes at this bar in practice, lower the threshold rather than weakening
|
|
* the gate (per design plan).
|
|
*/
|
|
export const RECOMMENDATION_SUBSTANCE_THRESHOLD = 4;
|
|
|
|
/**
|
|
* Run judgeRecommendation on a captured AskUserQuestion text, record the score
|
|
* into the eval collector, and assert all four quality dimensions. Replaces a
|
|
* 22-line block previously duplicated across every E2E test that captures an
|
|
* AskUserQuestion. Returns the score for tests that want to inspect it
|
|
* further.
|
|
*/
|
|
export async function assertRecommendationQuality(opts: {
|
|
captured: string;
|
|
evalCollector: EvalCollector | null;
|
|
evalId: string;
|
|
evalTitle: string;
|
|
result: SkillTestResult;
|
|
passed: boolean;
|
|
signal?: AbortSignal;
|
|
/** Let a bounded attempt defer its one terminal record until all assertions settle. */
|
|
record?: (extra: Pick<EvalTestEntry, 'judge_scores' | 'judge_reasoning'>) => void;
|
|
}): Promise<RecommendationScore> {
|
|
opts.signal?.throwIfAborted();
|
|
const recScore = await judgeRecommendation(opts.captured, opts.signal);
|
|
opts.signal?.throwIfAborted();
|
|
const metadata = {
|
|
judge_scores: {
|
|
rec_present: recScore.present ? 1 : 0,
|
|
rec_commits: recScore.commits ? 1 : 0,
|
|
rec_has_because: recScore.has_because ? 1 : 0,
|
|
rec_substance: recScore.reason_substance,
|
|
},
|
|
judge_reasoning: `${recScore.reasoning} | reason: "${recScore.reason_text}"`,
|
|
};
|
|
if (opts.record) opts.record(metadata);
|
|
else recordE2E(opts.evalCollector, opts.evalId, opts.evalTitle, opts.result, { passed: opts.passed, ...metadata });
|
|
expect(recScore.present, recScore.reasoning).toBe(true);
|
|
expect(recScore.commits, recScore.reasoning).toBe(true);
|
|
expect(recScore.has_because, recScore.reasoning).toBe(true);
|
|
expect(
|
|
recScore.reason_substance,
|
|
`${recScore.reasoning}\n reason: "${recScore.reason_text}"`,
|
|
).toBeGreaterThanOrEqual(RECOMMENDATION_SUBSTANCE_THRESHOLD);
|
|
return recScore;
|
|
}
|
|
|
|
/** Finalize an eval collector (write results). */
|
|
export async function finalizeEvalCollector(evalCollector: EvalCollector | null) {
|
|
if (evalCollector) {
|
|
try {
|
|
await evalCollector.finalize();
|
|
} catch (err) {
|
|
console.error('Failed to save eval results:', err);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Pre-seed preamble state files so E2E tests don't waste turns on lake intro + telemetry prompts.
|
|
// These are one-time interactive prompts that burn 3-7 turns per test if not pre-seeded.
|
|
// NOTE: since gstack-skill-start honors GSTACK_HOME (EOV7), hermetic children read the
|
|
// temp GSTACK_HOME that hermetic-env.ts seeds (the canonical marker list lives there);
|
|
// this operator-HOME seeding only serves EVALS_HERMETIC=0 debug runs.
|
|
if (evalsEnabled && !isHermeticEnabled()) {
|
|
const gstackDir = path.join(os.homedir(), '.gstack');
|
|
fs.mkdirSync(gstackDir, { recursive: true });
|
|
// Marker list kept at parity with hermetic-env.ts's child-GSTACK_HOME seed
|
|
// (the canonical set for the emission layer's gates).
|
|
for (const f of [
|
|
'.activated',
|
|
'.completeness-intro-seen',
|
|
'.telemetry-prompted',
|
|
'.proactive-prompted',
|
|
'.first-loop-tip-shown',
|
|
'.feature-prompted-model-overlay',
|
|
]) {
|
|
const p = path.join(gstackDir, f);
|
|
if (!fs.existsSync(p)) fs.writeFileSync(p, '');
|
|
}
|
|
}
|
|
|
|
// Fail fast if Anthropic API is unreachable — don't burn through tests getting
|
|
// ConnectionRefused. The sharded paid runner pings once in the parent and sets
|
|
// EVALS_PREFLIGHT_OK=1 for its children, so per-file module loads skip this
|
|
// (was: ~30 paid pings per full sharded run, one per importing file).
|
|
if (evalsEnabled) {
|
|
preflightAnthropicApi();
|
|
}
|
|
|
|
/** Skip an individual test if not selected (for multi-test describe blocks). */
|
|
export function testIfSelected(testName: string, fn: () => Promise<void>, timeout: number, selected: string[] | null = selectedTests) {
|
|
const shouldRun = selected === null || selected.includes(testName);
|
|
(shouldRun ? test : test.skip)(testName, fn, timeout);
|
|
}
|
|
|
|
/** Concurrent version — runs in parallel with other concurrent tests within the same describe block. */
|
|
export function testConcurrentIfSelected(testName: string, fn: () => Promise<void>, timeout: number, selected: string[] | null = selectedTests) {
|
|
const shouldRun = selected === null || selected.includes(testName);
|
|
(shouldRun ? test.concurrent : test.skip)(testName, fn, timeout);
|
|
}
|
|
|
|
// --- Worktree isolation ---
|
|
|
|
let worktreeManager: WorktreeManager | null = null;
|
|
|
|
export function getWorktreeManager(): WorktreeManager {
|
|
if (!worktreeManager) {
|
|
worktreeManager = new WorktreeManager();
|
|
worktreeManager.pruneStale();
|
|
}
|
|
return worktreeManager;
|
|
}
|
|
|
|
/** Create an isolated worktree for a test. Returns the worktree path. */
|
|
export function createTestWorktree(testName: string): string {
|
|
return getWorktreeManager().create(testName);
|
|
}
|
|
|
|
/** Harvest changes and clean up. Call in afterAll(). Returns HarvestResult for eval integration. */
|
|
export function harvestAndCleanup(testName: string): HarvestResult | null {
|
|
const mgr = getWorktreeManager();
|
|
const result = mgr.harvest(testName);
|
|
if (result) {
|
|
if (result.isDuplicate) {
|
|
process.stderr.write(`\n HARVEST [${testName}]: duplicate patch (skipped)\n`);
|
|
} else {
|
|
process.stderr.write(`\n HARVEST [${testName}]: ${result.changedFiles.length} files changed\n`);
|
|
process.stderr.write(` Patch: ${result.patchPath}\n`);
|
|
process.stderr.write(` ${result.diffStat}\n\n`);
|
|
}
|
|
}
|
|
mgr.cleanup(testName);
|
|
return result;
|
|
}
|
|
|
|
export { judgePassed } from './eval-store';
|
|
export { EvalCollector } from './eval-store';
|
|
export type { EvalTestEntry } from './eval-store';
|
|
export type { HarvestResult } from '../../lib/worktree';
|