Minimal Repro Cases skill for Codex
Shrink any bug to one file a maintainer will run. Codex fixes an exact failure signature before deleting anything, records versions, checks whether the latest release already fixed it, then removes services, framework and data layer by layer. Large inputs are shrunk with a bundled dependency-free ddmin script or cvise and shrinkray, and the result is rebuilt in a clean uv, npm or Docker environment and written up as an upstream-ready issue.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- minimal-repro-cases Installs to ~/.agents/skills/minimal-repro-cases/ for every project on this machine.
curl -fsSL codexskills.dev/cli.mjs -o codex-skills.mjsnode codex-skills.mjs install minimal-repro-cases The CLI also searches, updates and removes skills. Run it with no arguments to pick from a list. Needs Node 20.12+.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- --project minimal-repro-cases Installs to .agents/skills/minimal-repro-cases/ in the current repository. Commit it and everyone who runs Codex there gets it.
curl -fsSLO codexskills.dev/downloads/minimal-repro-cases.zipunzip -o minimal-repro-cases.zip -d ~/.agents/skills/ The archive holds the skill folder with its generated agents/openai.yaml. Unzip into .agents/skills/ to scope it to a repo.
& ([scriptblock]::Create((irm codexskills.dev/install.ps1))) minimal-repro-cases PowerShell 5.1 or 7. Installs to %USERPROFILE%\.agents\skills\. Add -Project for the current repo.
104-line POSIX script. No sudo, no telemetry. Read install.sh
Use it in Codex
Start a prompt with $minimal-repro-cases, or pick it from /skills.
You can also just describe the task: Codex reads the description below and loads the playbook when your request matches.
What Codex reads before loading it
Shrinks a bug to the smallest self-contained reproduction - one file or one command, pinned versions, expected versus actual output - by fixing an exact failure oracle, stripping services and dependencies outside-in, and delta-debugging inputs with a bundled ddmin script, C-Reduce, cvise or shrinkray. Produces a ready-to-file upstream issue. Use when filing a bug against a library or framework, when a failure only happens inside a large app, when a maintainer asks for a minimal reproduction, or when a crashing input file is too big to reason about.
Use it when
- You are about to file a bug against a library, framework or tool
- A maintainer or teammate asked for a minimal reproduction
- A failure needs a huge input file or the whole app to show up
- You need to prove whether a bug is yours or upstream
Use something else when
- You need to find which commit introduced the bug: use git-bisect
- You are still diagnosing the cause in your own code: use systematic-debugging
- The failure is intermittent across a test suite: use flaky-test-triage
How to check the result
Run the repro in a fresh directory or container and confirm the original error signature appears, then confirm that deleting any remaining line makes it pass.
The mistake it prevents
Shrinking with a loose oracle such as 'exits non-zero', which lets the reducer wander into a different, smaller bug than the one you started with.
A repro is done when a stranger can run one command on a clean machine, see the same failure signature, and nothing left in it can be removed without the failure going away. It names exact versions, states expected versus actual, and contains no private code, credentials or customer data.
Workflow
- [ ] 1. Capture the failure and define the oracle (exact signature)
- [ ] 2. Record the environment and pin versions
- [ ] 3. Check it still fails on the latest release / main
- [ ] 4. Remove the app around it, outside-in
- [ ] 5. Shrink the input (ddmin, cvise, shrinkray)
- [ ] 6. Shrink the code by delete-and-retest
- [ ] 7. Rebuild from scratch in a clean environment and rerun
- [ ] 8. Write the report; get approval before posting upstream
1. Define the oracle first
Shrinking without a precise oracle drifts into a different bug. The oracle is a command that exits 0 when the bug is present (“still interesting”) and non-zero otherwise. Match the most specific signal you have:
| Failure kind | Oracle |
|---|---|
| Exception | Exception type plus a stable fragment of the message, not the line number |
| Wrong output | Compare against the expected value; interesting when they differ in the known way |
| Compiler or tool crash | Exit code plus the internal error text (internal compiler error, panicked at) |
| Hang | timeout 10 <cmd>; interesting when the exit code is 124 |
| Flaky | Loop N runs; interesting when the failure count is above zero. Measure the base rate first |
#!/usr/bin/env bash
# interesting.sh: exit 0 only while the original bug reproduces
out=$(python3 repro.py 2>&1)
grep -q "KeyError: 'tenant_id'" <<<"$out"
2. Record the environment
Capture versions before changing anything; repros fail to transfer mostly because of versions nobody wrote down.
python3 -VV && python3 -m pip freeze | grep -iE "^(sqlalchemy|psycopg)"
node -v && npm ls <package> # or: npx envinfo --system --binaries --npmPackages <pkg>
go version && go env GOOS GOARCH
rustc -vV && cargo tree -i <crate>
uname -a # OS and architecture
Note anything environmental the failure might depend on: locale, timezone, CPU arch, filesystem case sensitivity, container vs host.
3. Test the latest version
Before investing in shrinking, try the newest release (and main if cheap). If it is
already fixed, the deliverable is “upgrade to X.Y.Z” plus the changelog entry, not a
repro. If a version range is known good and bad, hand the range to $git-bisect on the
upstream repo. Installing new versions needs network; in a Codex sandbox without it, ask
for approval or report which versions you could test.
Search upstream issues with the exact error text before writing a new one:
gh search issues --repo owner/project "KeyError tenant_id" --limit 20 # open and closed
4. Strip the app, outside-in
Remove whole layers first; they are the biggest wins. After each removal, rerun the oracle. If the failure disappears, the removed thing is involved: put it back and remove something else.
- Network services: replace the HTTP API, queue or cache with a hardcoded value or an
in-memory fake. Databases: try SQLite or a single
docker run postgres:17with one table, created in the repro. - Framework: call the library function directly instead of through the web request, CLI or job runner.
- Your own code: inline the few lines that matter; delete the rest.
- Configuration: start from defaults and add back only the options needed.
- Data: replace real records with two or three synthetic ones that keep the shape that triggers the bug (the null field, the 256-byte string, the emoji).
5. Shrink inputs mechanically
When the trigger is a large input file (query, source file, JSON document, CSV), let a tool do the deleting. The bundled script runs ddmin over lines with no dependencies:
python3 scripts/ddmin_lines.py big_input.sql \
--cmd "python3 run_query.py {file}" --expect-output "IndexError" --timeout 20
# -> lines: 3120 -> 7 runs: 412 written: big_input.min.sql
{file} is a temporary copy with the original basename. Use --expect-code N for an
exact exit code; with neither flag, any non-zero exit counts. The script refuses to run
if the unmodified input does not reproduce, which catches a broken oracle early.
| Input | Tool |
|---|---|
| Any line-oriented text | scripts/ddmin_lines.py |
| C, C++ and other source code | cvise ./interesting.sh file.c (or creduce); both expect the test script to exit 0 while interesting and run it in a temp copy |
| Arbitrary text or binary, structure-aware | shrinkray ./interesting.sh file (pip package) |
| Generated test input from property tests | Let Hypothesis, fast-check or proptest shrink it; copy the shrunk example |
| JSON | Delete keys and array items by hand, or flatten to lines and use ddmin |
Then hand-polish: rename identifiers to neutral names, replace magic values with obvious ones, but rerun the oracle after each edit.
6. Shrink the code
Repeat until no single deletion keeps the failure: delete a statement, import, option or
argument, rerun, keep the deletion if still interesting. Also try replacing values with
simpler ones ([], 0, "", None). Stop when every remaining line is load-bearing;
say so in the report.
7. Rebuild clean
A repro that only works in your checkout is not a repro. Recreate it from nothing:
# /// script
# requires-python = ">=3.12"
# dependencies = ["sqlalchemy==2.0.36"]
# ///
# Run with: uv run repro.py
from sqlalchemy import create_engine, text
engine = create_engine("sqlite://")
with engine.connect() as c:
print(c.execute(text("select :x"), {"x": 1}).scalar())
| Ecosystem | Clean template |
|---|---|
| Python | One file with PEP 723 inline metadata, run with uv run repro.py |
| Node | package.json with exact versions plus repro.mjs; npm ci && node repro.mjs |
| Browser or framework | A minimal starter (Vite, framework CLI) with one changed file, pushed to a public repo or StackBlitz |
| Go | go mod init repro with one main.go or _test.go, versions in go.mod |
| Rust | cargo new repro, exact versions in Cargo.toml |
| System-dependent | A Dockerfile with a pinned base image digest or tag |
Run it in a fresh directory (or docker run --rm) and confirm the oracle passes there.
Gotchas
- Oracle drift: ddmin happily finds a smaller input that fails with a different error. Match the specific message, and compare the final stack trace to the original.
- Removing the trigger: timing and concurrency bugs vanish when the surrounding load is
removed. Keep a loop or a deliberate
sleepand say the repro is probabilistic, with the observed failure rate. - Test-order bugs: a test that fails only in the full suite is pollution. Narrow the
polluter by bisecting the preceding tests (
detect-test-pollutionfor pytest), then repro with exactly two tests. - Private data: real customer rows, internal hostnames, tokens and proprietary code must never reach a public issue. Replace with synthetic values and check the final files.
- Caches: stale
__pycache__,node_modules/.cache, build dirs and Docker layers can keep a fixed bug alive or hide a real one. Clean them before the final run. - Debug builds and release builds can differ; state which one reproduces.
Report template
Posting an issue or comment upstream is outward-facing: show the draft and get a clear
yes before running gh issue create.
### Summary
`Session.merge()` raises KeyError when the primary key column is named `tenant_id`.
### Reproduction
```bash
uv run repro.py
```
<contents of repro.py, under ~40 lines>
### Expected
Prints `1`.
### Actual
```
KeyError: 'tenant_id'
File ".../orm/session.py", line 3871, in merge
```
### Environment
- package 2.0.36 (also fails on 2.1.0b2 and main @ 4c1d2e9); 2.0.30 works
- Python 3.12.7, macOS 15.1 arm64 and Debian 12 x86_64
### Notes
- Every line in repro.py is required; removing any one makes it pass.
- Fails 100% of runs. What the zip contains
Codex reads bundled files only when a step points to them. Every one is shown here, so you can review it before installing.
agents/openai.yaml
Gives the Codex app a display name, a one-line description and a starter prompt for this skill.
interface:
display_name: "Minimal Repro Cases"
short_description: "Shrink any bug to one file a maintainer will run"
default_prompt: "$minimal-repro-cases This crash only happens in our monolith. Get me a minimal repro I can file against the library" scripts/ddmin_lines.py
#!/usr/bin/env python3
"""Shrink a text file line by line while a command keeps reproducing a failure.
Implements Zeller's ddmin over lines. A candidate is "interesting" when the
command's exit code and/or output still match the original failure. The input
file is never modified; the result is written to --out.
Usage:
python3 ddmin_lines.py input.py --cmd "python3 {file}" --expect-output "KeyError"
python3 ddmin_lines.py query.sql --cmd "psql -f {file} db" --expect-code 3
{file} is replaced by the path of a candidate copy that keeps the original
basename (in a temporary directory), so extension-sensitive tools still work.
Standard library only; Python 3.10+.
"""
from __future__ import annotations
import argparse
import shlex
import subprocess
import sys
import tempfile
from pathlib import Path
def make_test(cmd: str, workdir: Path, name: str, expect_code: int | None,
expect_output: str | None, timeout: float, cwd: str | None):
calls = {"n": 0}
cache: dict[tuple[str, ...], bool] = {}
target = workdir / name
def interesting(lines: list[str]) -> bool:
key = tuple(lines)
if key in cache:
return cache[key]
calls["n"] += 1
target.write_text("".join(lines))
argv = shlex.split(cmd.replace("{file}", shlex.quote(str(target))))
try:
proc = subprocess.run(argv, capture_output=True, text=True,
timeout=timeout, cwd=cwd)
except subprocess.TimeoutExpired:
cache[key] = False # hangs are a different bug; do not drift into them
return False
ok = True
if expect_code is not None:
ok = ok and proc.returncode == expect_code
else:
ok = ok and proc.returncode != 0
if expect_output is not None:
ok = ok and expect_output in (proc.stdout + proc.stderr)
cache[key] = ok
return ok
return interesting, calls
def ddmin(lines: list[str], test) -> list[str]:
n = 2
while len(lines) >= 2:
size = len(lines) // n
chunks = [lines[i:i + size] for i in range(0, len(lines), size)] if size else [[l] for l in lines]
reduced = False
for i, chunk in enumerate(chunks):
if test(chunk): # a single chunk reproduces on its own
lines, n, reduced = chunk, 2, True
break
if not reduced:
for i in range(len(chunks)):
complement = [l for j, c in enumerate(chunks) if j != i for l in c]
if test(complement): # removing chunk i keeps the failure
lines, n, reduced = complement, max(n - 1, 2), True
break
if not reduced:
if n >= len(lines):
break
n = min(len(lines), n * 2)
return lines
def main() -> int:
ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
ap.add_argument("input", type=Path)
ap.add_argument("--cmd", required=True, help='command with {file}, e.g. "python3 {file}"')
ap.add_argument("--expect-code", type=int, default=None,
help="exact exit code that means 'still fails' (default: any non-zero)")
ap.add_argument("--expect-output", default=None,
help="substring that must appear in stdout+stderr")
ap.add_argument("--timeout", type=float, default=30.0, help="seconds per run")
ap.add_argument("--cwd", default=None, help="directory to run the command in")
ap.add_argument("--out", type=Path, default=None, help="output path (default: <stem>.min<suffix>)")
args = ap.parse_args()
if "{file}" not in args.cmd:
print("error: --cmd must contain {file}", file=sys.stderr)
return 2
original = args.input.read_text().splitlines(keepends=True)
out = args.out or args.input.with_name(f"{args.input.stem}.min{args.input.suffix}")
with tempfile.TemporaryDirectory() as tmp:
test, calls = make_test(args.cmd, Path(tmp), args.input.name, args.expect_code,
args.expect_output, args.timeout, args.cwd)
if not test(original):
print("error: the unmodified input does not reproduce the failure with this "
"--cmd/--expect-* combination; fix the oracle first", file=sys.stderr)
return 1
result = ddmin(original, test)
out.write_text("".join(result))
print(f"lines: {len(original)} -> {len(result)} runs: {calls['n']} written: {out}")
return 0
if __name__ == "__main__":
sys.exit(main())
About this skill
Written and maintained by the Codex Skills editors to the same editorial standard as every skill here: a selective description, a real workflow, known pitfalls and a way to verify the work. First published , last updated . MIT licensed. Something wrong or out of date? Tell us.