Cursor afterFileEdit test hook recipe
Copy a project hooks.json recipe that records Cursor Agent edits with afterFileEdit, batches paths, and runs tests once at the stop hook.
The reliable Cursor test hook uses two events. afterFileEdit records each file changed by the Agent, then stop runs the tests once after the turn. This avoids launching the same suite after every write while still returning failures to the Agent before the work is considered finished.
I am Pramod, and I work on tailtest’s Cursor integration. This page is the practical recipe. It gives you a project hook configuration and two small Python scripts you can adapt. For the exact payload fields, configuration levels, environment variables, and event boundaries, use the separate Cursor afterFileEdit payload reference.
Updated 15 September 2026. The configuration and event names below were checked against the current Cursor Hooks reference. The example is intentionally dependency-free and assumes one active Agent conversation. A production integration should key its state by conversation_id and add a file lock when concurrent conversations can edit the same workspace.
Why afterFileEdit instead of postToolUse
Cursor exposes both postToolUse and afterFileEdit.
postToolUseis generic. It runs after successful tool calls such as shell, read, write, MCP, and other Agent tools.afterFileEditis the file-edit event. Its input includes the changedfile_pathand the edit records.
If your question is “which file did the Agent just change?”, use afterFileEdit. A generic postToolUse hook needs filtering before it can answer the same question.
Cursor also has a separate afterTabFileEdit event for Tab completions. This recipe targets Agent edits only.
The project layout
Add these three files to the repository:
.cursor/
hooks.json
hooks/
collect_edit.py
run_changed_tests.py
Project hooks execute from the project root. Keeping the scripts under .cursor/hooks/ makes their ownership obvious and keeps relative paths stable.
Review every hook script before trusting a repository. Hooks are executable code and run with the user’s process permissions.
Step 1: configure afterFileEdit and stop
Create .cursor/hooks.json:
{
"version": 1,
"hooks": {
"afterFileEdit": [
{
"command": "python3 .cursor/hooks/collect_edit.py",
"timeout": 5
}
],
"stop": [
{
"command": "python3 .cursor/hooks/run_changed_tests.py",
"timeout": 120,
"loop_limit": 5
}
]
}
}
Cursor watches hook configuration and reloads it when the file changes. A restart is still a useful troubleshooting step if the Hooks output channel does not show the new entries.
The five-second edit timeout is deliberately short. The edit hook only parses JSON, filters the path, and records it. The longer timeout belongs on stop, where the tests actually run.
Step 2: collect changed paths
Create .cursor/hooks/collect_edit.py:
import json
import sys
from pathlib import Path
payload = json.load(sys.stdin)
file_path = payload.get("file_path")
if not file_path:
print("{}")
raise SystemExit(0)
path = Path(file_path)
ignored_parts = {"node_modules", ".venv", "dist", "build", "vendor"}
ignored_names = {"package-lock.json", "pnpm-lock.yaml", "yarn.lock"}
if ignored_parts.intersection(path.parts) or path.name in ignored_names:
print("{}")
raise SystemExit(0)
supported = {".py", ".js", ".jsx", ".ts", ".tsx"}
if path.suffix not in supported:
print("{}")
raise SystemExit(0)
state = Path(".cursor/hooks/.pending_files")
existing = set(state.read_text().splitlines()) if state.exists() else set()
existing.add(str(path))
state.write_text("\n".join(sorted(existing)) + "\n")
print("{}")
The collector does four useful things:
- Reads
file_pathfrom Cursor’s JSON input. - Rejects generated, vendored, and lock files before they create test noise.
- Keeps only languages this sample knows how to test.
- Deduplicates paths so repeated edits still produce one turn-level run.
It does not read the changed file or run a test command. That keeps the per-edit path cheap.
The .pending_files state file should be ignored by version control. Add this line to .gitignore:
.cursor/hooks/.pending_files
Step 3: run once at the turn boundary
Create .cursor/hooks/run_changed_tests.py:
import json
import subprocess
import sys
from pathlib import Path
state = Path(".cursor/hooks/.pending_files")
if not state.exists():
print("{}")
raise SystemExit(0)
paths = [Path(value) for value in state.read_text().splitlines() if value]
state.unlink()
commands = []
if any(path.suffix == ".py" for path in paths):
commands.append([sys.executable, "-m", "pytest"])
if any(path.suffix in {".js", ".jsx", ".ts", ".tsx"} for path in paths):
commands.append(["npm", "test"])
failures = []
for command in commands:
completed = subprocess.run(command, text=True, capture_output=True)
if completed.returncode:
failures.append(completed.stdout[-3000:] + completed.stderr[-3000:])
if failures:
message = "Tests failed after this turn. Review and fix:\n\n" + "\n\n".join(failures)
print(json.dumps({"followup_message": message}))
else:
print("{}")
This sample runs the Python or JavaScript test command once per turn, regardless of how many supported files the Agent changed. When a command fails, the stop hook returns a documented followup_message, so Cursor can continue the conversation with the failure output. loop_limit bounds how many automatic follow-ups this script may trigger.
Deleting the state file before running tests matters. If the Agent responds to a failure by editing more files, the collector creates a fresh state set for the next verification cycle.
Step 4: choose the smallest useful runner
The sample deliberately uses the repository’s normal test commands. Replace them with the smallest deterministic test selection your stack supports.
- Python teams can use pytest selection, testmon, or a repository-specific affected-test map.
- JavaScript and TypeScript teams can use the test runner already declared by the project rather than adding a second runner only for the hook.
- Monorepos should map each changed path to its owning package and run only that package’s test target.
Do not derive a shell command by concatenating file_path. Cursor gives you an absolute path, and paths can contain spaces or unexpected characters. Build subprocess argument arrays or validate against a fixed dispatch table.
Step 5: handle concurrent Agent conversations
The minimal collector uses one state file, which is fine for a single active Agent conversation. Cursor’s common hook input also includes conversation_id. Use that value in the filename when more than one conversation may edit the workspace:
conversation = payload.get("conversation_id", "default")
safe_id = "".join(char for char in conversation if char.isalnum())
state = Path(f".cursor/hooks/.pending_files_{safe_id}")
Add a lock when two hook processes can update the same conversation state at the same time. The exact lock mechanism is platform-dependent, so the portable sample does not pretend one snippet covers every operating system.
The decisions that keep the hook usable
Filter before recording. Lockfiles, generated clients, coverage output, build artifacts, and vendored packages rarely justify a test cycle. Start with obvious exclusions and add only the ones your repository proves noisy.
Batch at stop. An Agent turn commonly edits several related files. Running after each write repeats work and increases latency. The collector and runner split makes the event boundary match the unit of work.
Keep post-edit tests fail-open at the editor boundary. A broken helper should not make Cursor unusable. Let the test result become Agent context. Use earlier permission hooks and failClosed only for controls that truly must prevent an unsafe action.
Cap automatic retries. A stop hook can auto-submit a follow-up message. A finite loop_limit prevents a failing suite from creating an endless loop.
Keep output bounded. Test logs can be large. Return the useful tail of the failure and store the full report in a local artifact if the Agent needs it.
Where tailtest adds value
The recipe above is enough to prove the event flow. Tailtest adds the production parts teams otherwise have to maintain themselves:
- Runner dispatch across supported languages and project layouts.
- Conversation-scoped state and turn-level batching.
- Failure classification that separates a product bug, a test bug, and an environment failure.
- Adversarial test generation for boundary values, format and injection cases, type confusion, concurrent state, time and locale edges, partial failures, resource exhaustion, and off-by-one behavior.
- A structured report that the Agent and the human reviewer can inspect.
The Cursor solution page explains the integration. The per-edit testing platform page covers the runtime, and hook-based testing explained covers why the trigger belongs in the Agent lifecycle instead of the prompt.
FAQ
Which Cursor hook should run tests after an Agent edits a file?
Use afterFileEdit to record the changed path, then use stop to run the test cycle once after the turn. A generic postToolUse hook fires for more than file writes and therefore needs extra filtering.
Where does the Cursor project hook configuration go?
Put it at <project-root>/.cursor/hooks.json. Project hook commands run from the project root, so paths such as .cursor/hooks/collect_edit.py resolve consistently.
Does afterFileEdit run when I save a file manually?
The event is documented as an Agent file-edit hook. Manual editor activity is outside that event. Cursor exposes a separate afterTabFileEdit event for Tab completions.
Should I run tests inside afterFileEdit or stop?
Split the work. Let afterFileEdit record and deduplicate paths. Let stop run tests once over the turn’s changes. This keeps edit handling quick and prevents duplicate suite runs.
How does the Agent see a failed test?
The stop hook can return a followup_message. The sample returns a bounded failure summary through that field so Cursor can continue with the test result as the next instruction.
Is it safe to commit Cursor hooks to a repository?
Only after review. Project hooks are executable code. Keep commands local and inspectable, avoid shell construction from untrusted paths, and require workspace trust before they run.