pytest-xharness-eval 🧪🤖
pytest plugin for cross AI agent harness evaluation.
Write the eval once. Run it against every harness and every model.
What it does
A pytest plugin that runs the claude and codex CLIs headlessly against a fixture
workspace, captures each run's own session log, prices it, and grades what the agent
left behind. A skill opts in by adding an evals/ directory; pytest does the rest.
Every eval cell is live and costs money. There is no replay mode. Preview the spend
with --dry-run before a sweep. The design rationale lives in
ARCHITECTURE.md and the decision log in
docs/adrs/; agents start at AGENTS.md.
Quickstart
-
Install the plugin into the repository that holds your skills. The
pytest11entry point registers it; noconftest.pywiring is needed:uv add --dev pytest-xharness-eval
-
Pin pytest's rootdir to the repository root, so the plugin finds
skills/from any argument path. An empty[tool.pytest.ini_options]table is enough:[tool.pytest.ini_options] -
Add an eval beside the skill. Files and functions both carry the
eval_prefix, astest_does for pytest. Fixtures are seed workspaces copied fresh for every cell;captured/is written by each run and git-ignored; itshistory.jsonlgains one metrics line per live cell:skills/<skill>/ SKILL.md evals/ eval_<suite>.py fixtures/<name>/ captured/<case>/ captured/history.jsonlfrom pytest_xharness_eval import evalcase @evalcase(prompt="...", skill="<skill>", fixture="<name>") def eval_<case>(run, workspace): assert run.exit_code == 0 assert (workspace / "OUTPUT.md").exists()
-
Preview the matrix. Nothing is invoked:
uv run pytest skills/<skill>/evals --dry-run
xharness-eval: skills root = /repo/skills, workdir = /repo/tmp/evals xharness-eval: matrix = plugin default (2 entries); a case's models= overrides it collected 2 items skills/<skill>/evals/eval_<case>.py ss ============================ agent eval report ============================ dry-run - skills/<skill>/evals/eval_<case>.py::eval_<case>[claude/claude-opus-5] dry-run - skills/<skill>/evals/eval_<case>.py::eval_<case>[codex/gpt-5.6-sol] total spend: $0.0000 across 2 cell(s) report: /repo/tmp/evals/report.json
-
Run it live, with
-vso every cell reports its verdict, USD, context, wall clock, turns, and tool calls as it lands. Add-n 2to run cells in parallel. This spends money:uv run pytest skills/<skill>/evals -v
skills/<skill>/evals/eval_<case>.py::eval_<case>[claude/claude-opus-5] PASSED est $0.5762 (harness $0.5773) 352,451 accumulative_billed_tokens 23,898 baseline_tokens 76.0s 9 turns 8 tools
Read the status word as: this plugin's estimate from its price table (and the harness CLI's own figure, where it reports one), every billed token summed over all turns (the cached prefix is re-read each turn), the harness's own prompt on turn 1, wall clock, model calls, tool calls. Every estimate records the rates it used and where they came from (
rates_applied).Each cell leaves its verbatim session log and a normalised
.result.jsonwith a per-turn ledger undercaptured/<case>/, appends one metrics line tocaptured/history.jsonl, and the sweep writestmp/evals/report.jsonwith USD per cell plus a browsablecaptured/report.htmlwith its glossary (XHARNESS-REPORT-GLOSSARY.md) beside it. Serve it withpython3 -m http.server --directory <captured dir>; it fetches the JSON beside it.
run is a RunResult: session id, log path, token usage by tier, tool calls, files
written, and USD cost. The reference case, with its assertions written as a tutorial,
is
eval_palette_mandate.py, the reference case kept beside the skill it grades in the consuming repository.
Narrow a run
The matrix is the spend dial. These options are the plugin's own; everything else is
stock pytest (-k, -x, -m eval, node ids).
| Option | Effect | Example |
|---|---|---|
| path | One skill or all of them | pytest skills/x/evals, pytest skills/*/evals |
--harness <name> |
Only cells for that harness (claude or codex), repeatable |
pytest skills/x/evals --harness codex |
--model <substring> |
Only cells whose model id contains the string, or one exact harness/model, repeatable |
pytest skills/x/evals --model opus |
-k <expr> |
Boolean slices over cell ids and case names (stock pytest) | -k "opus or sol", -k "codex and not sol" |
--dry-run |
Enumerate cells and validate pricing, invoke nothing | pytest skills/x/evals --dry-run |
--collect-only -q |
List cell node ids (stock pytest) | pytest --collect-only -q skills/x/evals |
Do not run pytest skills from the root: it walks into every skill's scripts/
directory and collects their unit tests too. skills/*/evals is the full matrix.
A case overrides the matrix with @evalcase(..., models=["codex/gpt-5.6-sol"]).
Configuration
The matrix has three scopes, highest precedence first: a case's models=, the
project's xharness_matrix ini key, and the plugin's bundled default
(claude/claude-opus-5, codex/gpt-5.6-sol). The report header names which one
applied.
Four ini keys, paths relative to pytest's rootdir:
| Key | Default | Purpose |
|---|---|---|
xharness_matrix |
(plugin default) | Project matrix: harness/model entries every case sweeps unless it sets models= |
xharness_skills_dir |
skills |
Directory holding <skill>/evals/ trees |
xharness_workdir |
tmp/evals |
Per-cell workspaces and report.json |
xharness_skill_ignore |
(none) | gitignore-style patterns for skill files that are not decision surface; a bare pattern applies to every skill, <skill>: <pattern> to the skills matching the selector (ADR 0026) |
xharness_report_design_tokens |
bundled | design tokens JSON that themes captured/report.html (flag: --xharness-report-design-tokens FILE) |
xharness_report_inline |
false |
embed every result, log and the tokens into captured/report.html so it opens over file:// (flag: --xharness-report-inline) |
xharness_prices |
prices.toml |
Optional file whose rows add to or override the bundled price table |
[tool.pytest.ini_options]
xharness_matrix = [
"claude/claude-opus-5",
"claude/claude-sonnet-5",
"claude/claude-haiku-4-5-20251001",
"codex/gpt-5.6-luna",
"codex/gpt-5.6-terra",
"codex/gpt-5.6-sol",
]
An unpriced model stops the sweep at collection, before any spend. Add a row to a
prices.toml at the rootdir:
["gpt-5.6-luna"]
input = 1.25e-6
output = 1.0e-5
cache_read = 1.25e-7
cache_write = 1.25e-6
How it works
flowchart LR
CASE["eval_*.py case"]
PLUG["plugin.py<br/>collect, expand matrix"]
WS["workspace.py<br/>pristine copy"]
RUN["runner.py<br/>claude | codex"]
LOG["session log<br/>this run's own"]
NORM["normalise.py<br/>RunResult"]
PRICE["pricing.py<br/>USD"]
GRADE["case assertions"]
REP["report.json"]
CASE --> PLUG --> WS --> RUN --> LOG --> NORM --> PRICE --> GRADE --> REP
classDef new fill:#7c3aed,color:#fff
classDef data fill:#0f766e,color:#fff
classDef good fill:#047857,color:#fff
class CASE,PLUG,WS,RUN new
class LOG,NORM data
class PRICE,GRADE,REP good
One cell flows left to right: a case is expanded into cells, each cell gets a fresh workspace, the CLI runs, its own log is located and normalised, priced, graded, and reported. The hard part is the middle: the two CLIs need different contracts to tie a verdict to the right log. ARCHITECTURE.md explains both.
What it does not do
- It does not replay recorded runs. Every cell invokes the real CLI (ADR 0002).
- It does not give the agent a git repository. The workspace is a plain copy of the fixture, so skills that read git history are out of scope (ADR 0004).
- It does not mock either CLI, in tests or in evals.
- It does not price an unknown model as zero. It refuses to run (ADR 0007).
- It does not throttle providers.
-n Nruns N cells at once; each cell is isolated (own workspace, ownCODEX_HOME, own Claude session). If a provider rate-limits you,-n 2 --dist loadgroupkeeps each harness's cells on one worker (parallel across harnesses, serial within one).
Development
make format # ruff format + isort
make check # ruff check + isort --check-only + mypy --strict
make test # pytester-based suite, no mocks, coverage badge refresh
make build # wheel into dist/
The functions that spawn a CLI (runner.run_claude, runner.run_codex and their
helpers, plugin.EvalItem._run_live) are excluded from coverage with a stated reason
rather than faked. They are exercised by the paid evals in a consuming repository.
Publishing happens from GitHub Releases via .github/workflows/publish.yml (PyPI
trusted publishing).
The report page
captured/report.html is built from report-ui/, a bun workspace (Vite, React,
TypeScript, Tailwind, shadcn/ui, Vitest) that emits one self-contained HTML file
(ADR 0028). bun is needed only to change the page; the plugin ships the built file.
make ui-dev CAPTURED=path/to/<skill>/evals/captured # hot reload against real captured data
make ui-check # tsc + eslint + prettier
make ui-test # vitest component tests
make ui-smoke CAPTURED=path/to/<skill>/evals/captured # build, populate inline, render headlessly
make ui-promote # ship the build as assets/report.html (CI checks it is current)
Read next
- docs/token-accounting.md: how
accumulative_billed_tokens(billed across turns) andpeak_context_tokens(the largest prompt) are derived from what each provider reports, with a worked session - ARCHITECTURE.md: why the two CLIs need different capture contracts, how pricing works, and the vocabulary the code uses.
- AGENTS.md: operating instructions and hard boundaries for agents.
- docs/adrs/README.md: the decision index.
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distributions
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file pytest_xharness_eval-0.2.0-py3-none-any.whl.
File metadata
- Download URL: pytest_xharness_eval-0.2.0-py3-none-any.whl
- Upload date:
- Size: 320.3 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via:
uv/0.12.5 {"installer":{"name":"uv","version":"0.12.5","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
908f0d3289bf4fcbe6f34ae109bfab1f0d62819a323a5af2fa0e84ce50786cbe
|
|
| MD5 |
819a04f84fea2edc8db820a403d86de4
|
|
| BLAKE2b-256 |
8704ba2a9a10bbed9b7803c14e5019e2c75ca5c8b58aa4f615538024e9c11a2c
|