Dekita

The agent ended its turn. That is not the same as done.

ai agents devtools testing

The run reported success. The final message said: "I've written the v2 ledger layer and the numbers module. The sync path, migration, and resolvers are still to write, and I'm at about 18% of the context window. I'll get to them next."

That message arrived 16 tool calls into a 300-call budget. The build scored a median of 0.0621. The same model scored 0.9661 on the previous version of the same benchmark, one release earlier. The build did not fail. It ended. And the harness, like most harnesses, treated the end of a turn as the end of the task.

The fix is not a better prompt. It is a stop condition that reads the repository instead of the final message. One script, wired as a Stop hook, blocks the run from reporting done until there is evidence.

The run that ended early

The numbers come from an October 20, 2026 writeup of two agentic coding benchmarks, in which models build a real application through a coding agent with nobody in the loop. Claude Haiku 5.5 ended both of its builds on a text turn. Build 1 stopped after 16 of 300 calls with the quote above. Build 2 lasted 37 calls: "Given how much remains, I'll stop expanding the design here and finish the work in order, testing each part as I go."

Read the evidence, not the tone. Build 1's manifest pointed at an admin page file it never wrote, so the benchmark's own lint failed. The only time it had run lint was around call 4, before it had written a single file. It never ran its own code. On the benchmark board, Haiku 5.5 took 29th of 31 entries with a median of 0.0621; on the previous version of the same board it had scored 0.9661. Same model, one benchmark release apart.

The opposite failure burns the whole budget

The mirror image shows up in the same runs. In the same benchmark, Grok 4.7 used all 300 calls of its worse build. It tried rg 44 times on a machine that did not have it and got "command not found" 42 times. Four of its replies hit the output limit repeating one line, one of them 292,726 characters of "The field values are present, so I'll run the full suite once more." Another model read the same starter file 45 times.

Quitting early and never quitting are the same bug wearing two costumes: nothing in the loop knows what finished means. The loop only knows that the model stopped talking.

Effort is a dial, not a gate

The usual answer is prompt harder. The vendor's own documentation, as quoted in the same writeup, says how far that gets you. Anthropic's Haiku 5.5 prompting guide states that "with a long coding-agent system prompt at low effort, it sometimes stops early and hands the task back to the user", and that moving from low to medium "roughly halved early stopping". The benchmark ran at medium. The same guide adds that at medium the model "sometimes reports a code change as done without running a check". The Opus 5.5 guide warns that "an unattended agent loop that treats such a turn as the end of the task stops running there".

Halved is not zero. The writeup's own verdict: "A Stop hook, a nudge or Anthropic's own prompt text might have changed Haiku's score." A nudge is a suggestion. A hook is a check that runs.

A final message is a claim, and a claim needs a second source

The claim problem has its own October thread. On October 15, 2026, a developer published claimcheck, a git platform layer in which every agent pushing a change must file a claim: which files it changed and what each change does. The verifier compares the claim against the real diff before anything merges. Its deterministic rules: the claim must contain all of the files that changed, only files that changed, no protected paths (by default src/auth/**), no oversized diffs, and each description must match the diff. The last check runs through an LLM, and only if the deterministic checks pass.

The demo numbers, self-reported by the author: five full runs, 28 agents pushing at once in each; the system rejected all 11 agents that misreported, in every run, and wrongly rejected 0 honest claims, with a median verdict in 6 to 7.5 seconds. The author is explicit about the limits: the walkthrough agents are scripts rather than AI, so the results repeat and the verifier is what gets tested; the real agent never lied, so every misreport caught came from a script; and the LLM check was evaluated on a small set of short one-file diffs.

The sharpest critique came in the comments on October 17. The claim and the diff both originate from the same agent. An agent that lies consistently, describes file A accurately, reformats file B, and never mentions B, passes cleanly, because from the verifier's side the description is true of everything it names. The conclusion: "The fix was not a better comparison, it was a second source that cannot fail the way the first one does."

That is what a stop-check is. The checks come from outside the agent's own report, and the agent never grades itself. One implementation, countercheck, reads its checks from the base revision before the work started, restores any protected file the agent changed, and writes a sealed receipt per run.

The stop-check

Three rules, in order of how often they catch something:

  1. There is a diff. A run that changed nothing is not done, whatever the summary says.
  2. Claimed files match changed files, in both directions, when a claim file is present.
  3. The checker itself re-runs the test command. The agent's console output is not evidence.
#!/usr/bin/env python3
"""stop_check.py - turn-end is not completion.

Exit 0 only when the repo itself shows evidence the work is done.
Exit 1 = not done (reasons on stderr). Exit 2 = the checker could not
run (missing git, bad claim file) - also treat as not done, but louder.
"""
import json
import subprocess
import sys
from pathlib import Path


def run(cmd):
    proc = subprocess.run(cmd, capture_output=True, text=True)
    return proc.returncode, proc.stdout + proc.stderr


def git_changed():
    code, out = run(["git", "status", "--porcelain"])
    if code != 0:
        print("checker: git status failed", file=sys.stderr)
        sys.exit(2)
    return [line[3:] for line in out.splitlines() if line.strip()]


def parse_args(argv):
    test_cmd, claim_path = None, None
    i = 0
    while i < len(argv):
        if argv[i] == "--test" and i + 1 < len(argv):
            test_cmd = argv[i + 1]
            i += 2
        elif argv[i] == "--claim" and i + 1 < len(argv):
            claim_path = argv[i + 1]
            i += 2
        else:
            i += 1
    return test_cmd, claim_path


def main():
    test_cmd, claim_path = parse_args(sys.argv[1:])
    if test_cmd is None:
        print("checker: --test is required", file=sys.stderr)
        sys.exit(2)

    reasons = []

    changed = git_changed()
    if not changed:
        reasons.append("worktree has no changes")

    if claim_path:
        claim_file = Path(claim_path)
        if not claim_file.exists():
            reasons.append("claim file %s is missing" % claim_path)
        else:
            try:
                claim = json.loads(claim_file.read_text(encoding="utf-8"))
            except ValueError:
                reasons.append("claim file is not valid JSON")
                claim = None
            if claim is not None:
                claimed = set(claim.get("scope", {}).get("paths", []))
                real = set(changed)
                for path in sorted(real - claimed):
                    reasons.append("changed but not claimed: %s" % path)
                for path in sorted(claimed - real):
                    reasons.append("claimed but not changed: %s" % path)

    code, out = run(test_cmd.split())
    if code != 0:
        reasons.append("test command failed (exit %d)" % code)
        print(out[-2000:], file=sys.stderr)

    if reasons:
        for r in reasons:
            print("not done: %s" % r, file=sys.stderr)
        sys.exit(1)
    print("done: %d changed files, tests green" % len(changed))
    sys.exit(0)


if __name__ == "__main__":
    main()

Wire it as a Stop hook, the event that fires "when Claude finishes responding":

#!/usr/bin/env bash
# .claude/hooks/stop_check.sh
input=$(cat)
# stop_hook_active is true when we already blocked once; let the turn end
# so a broken environment cannot loop forever.
if printf '%s' "$input" | grep -q '"stop_hook_active": *true'; then
  exit 0
fi

python3 "$CLAUDE_PROJECT_DIR/scripts/stop_check.py" --test "python3 -m pytest -q" || {
  echo "Not done: the repo does not show finished work. Run the checks yourself." >&2
  exit 2
}

The exit code is the whole mechanism. Per the Claude Code hooks reference, exit 2 is a blocking error, and on the Stop event it "prevents Claude from stopping, continues the conversation". Exit 1, the conventional Unix failure code, is explicitly non-blocking: "If your hook is meant to enforce a policy, use exit 2." The same doc supports a JSON form, {"decision": "block", "reason": "..."}, for the same effect. One pattern catalog adds two operational details: read the stop_hook_active flag before blocking, and expect a cap on consecutive stop-hook continuations, which it puts at eight for Claude Code.

Three hardening rules, each learned the hard way by someone:

Console output is not evidence. A test run in the background, piped through tail, redirected to /dev/null, or followed by || true prints nothing that looks like a failure, and all of them look green. Either have the checker run the command itself, as the script above does, or require the runner's own report files: the report exists, it is newer than every changed file, it counts at least one test, and it records zero failures and errors. A skipped run writes no report. A failed run says so in the file.

The agent must not own the referee. Restore protected paths from the base revision before running the checks, so editing the tests is not an available move. That is the countercheck design: checks read from the revision before the work started, protected files the worker changed are put back, and every run writes a receipt.

Keep the blocking message short and acceptance-shaped. One practitioner's note: a noisy stderr payload makes the next turn burn on the wrong line. List the failing check, not every warning in the tree.

Other harnesses expose the same seam. A Chinese-language survey of verification layers, compiling platform behavior in October 2026, describes Cursor's stop returning a follow-up message and Codex's stop continuing with the block reason as the new prompt. The shape is identical: the loop's exit condition is a check you own, not a sentence the model wrote.

What the stop-check does not prove

It proves work happened and checks ran. It does not prove the code is right. An honest but incomplete claim walks straight through a mismatch check: "fixed typos" can be true and also quietly reformat a function two files over. A consistently lying agent passes claim-versus-diff for the same reason. Both need the second source, which in practice means a fresh CI run on a schedule the agent does not control.

It also does not remove variance. Two builds of the same model on the same board scored 0.7926 and 0.2943; another pair scored 0.6618 and 0.2422. Run the build twice before you trust a single green result.

And it is not complete. One deployment with Stop-hook guardrails, analyzed on October 16, 2026, still let roughly 5% of false completion claims through, across 319 automated publishing runs in three months. A stop-check is a layer that catches the loud failures, not a proof.

Sources

This post was written with AI assistance. The author is responsible for its content.