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; every run output lands under one git-ignored cache root, never in the skills tree (ADR 0032):skills/<skill>/ SKILL.md evals/ eval_<suite>.py fixtures/<name>/ .xharness_eval_cache/ build/ # per-cell workspaces results/{skill}/{harness}/{model}/{run}/{session}/ # log.jsonl, result.json, history.json report/ # report.json + the aggregated micrositefrom 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, cache = /repo/.xharness_eval_cache 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/.xharness_eval_cache/report/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 (
log.jsonl), a normalisedresult.jsonwith a per-turn ledger, and onehistory.jsonmetrics record in its ownresults/{skill}/{harness}/{model}/{run}/{session}/directory — no two cells share a file, so parallel workers never contend (ADR 0032). At session end the one combine step aggregates everything underresults/— every skill, every run — intoreport/:report.json, the accumulatedhistory.jsonl, and a browsablereport.htmlwith its glossary (XHARNESS-REPORT-GLOSSARY.md) beside it. Serve it withpython3 -m http.server --directory .xharness_eval_cacheand open/report/report.html; 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_cache_dir |
.xharness_eval_cache |
The git-ignored root for build workspaces, results and the report (ADR 0032) |
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 report/report.html (flag: --xharness-report-design-tokens FILE) |
xharness_report_inline |
false |
embed every result, log and the tokens into report/report.html so it opens over file:// (flag: --xharness-report-inline) |
xharness_prices |
(none) | Price rows that add to or override the bundled table: <model>: input=<usd/MTok> output=<usd/MTok> [cache_read=..] [cache_write=..] [cache_write_1h=..] (ADR 0030) |
[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 price row to the same ini block, in USD per million tokens (ADR 0030):
xharness_prices = [
"gpt-5.6-luna: input=1.25 output=10.00 cache_read=0.125 cache_write=1.25",
]
How it works
flowchart LR
CASE["eval_*.py case"]
PLUG["plugin/<br/>collect, expand matrix"]
WS["model/workspace.py<br/>pristine copy"]
RUN["harness/<br/>ClaudeHarness | CodexHarness"]
LOG["session log<br/>this run's own"]
NORM["SessionLog.to_result<br/>RunResult"]
PRICE["runtime/pipeline.derive<br/>price, coverage, case"]
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
report/report.html is built from report-ui/, a bun workspace (Vite, React,
TypeScript, Tamagui, Plotly, Tailwind, Vitest, Playwright) that emits one
self-contained HTML file (ADR 0028, ADR 0031). bun is needed only to change the
page; the plugin ships the built file.
make ui-dev CAPTURED=path/to/.xharness_eval_cache # hot reload against real cached data
make ui-check # tsc + eslint + prettier
make ui-test # vitest component tests
make ui-e2e CAPTURED=path/to/.xharness_eval_cache TIER=small # Playwright permutation sweep
make ui-smoke CAPTURED=path/to/.xharness_eval_cache # build, populate inline, boot over file://
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.
Metadata
Release files for pytest-xharness-eval 0.3.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| pytest_xharness_eval-0.3.0-py3-none-any.whl | Python 3 | none | any | Details |
Release files / pytest_xharness_eval-0.3.0-py3-none-any.whl
| Download URL | pytest_xharness_eval-0.3.0-py3-none-any.whl |
|---|---|
| Size | 690.9 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
df745b45fd1d1cb8d9017068303b1316b395d54c12f48e865d50d4f575d49734
|
|
BLAKE2b-256 checksum How to use checksums |
e7675871c193aee6835e4df611c17a7522f7ffe1de3b0e6e1b388c570e15de21
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
uv/0.12.7 {"installer":{"name":"uv","version":"0.12.7","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}
|