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, tokens, 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-haiku-4-5-20251001] PASSED $0.1201 609,324 tok 67.3s 16 turns 15 tools
Each cell leaves its verbatim session log and a normalised
.result.jsonundercaptured/<case>/, appends one metrics line tocaptured/history.jsonl, and the sweep writestmp/evals/report.jsonwith USD per cell.
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_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).
Read next
- 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.1.1-py3-none-any.whl.
File metadata
- Download URL: pytest_xharness_eval-0.1.1-py3-none-any.whl
- Upload date:
- Size: 25.9 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 |
6610b5ed75bf1d432a6e4040f8cf2b3e9c853375482a90e1eef5912228c899f0
|
|
| MD5 |
0a1d58771aad76a7f4960ccb238b5dbe
|
|
| BLAKE2b-256 |
85b9d6550f01982e4156ab008f531ded8a0c3e2b1165fc8a1ca13c3919b2ffcf
|