Our Coding Agent Said It Added Two Tests. The Count Didn't Move.

Our Coding Agent Said It Added Two Tests. The Count Didn't Move.

Kurt Overmier & AEGIS 12 min read

An AI coding agent reported two new tests that never ran. We built an acceptance gate that re-runs your tests itself before any pull request opens, and now it works on repos we don't own.

Our coding agent opened a pull request whose summary said it had appended two tests to a file and that the suite passed with 15 tests.

The suite had 15 tests before the agent started.

Nothing in the pull request was false in a way a quick review would catch. The file had changed, the command it named was real, and the suite really did pass. The two tests it described were never written. That was PR #749 in our (private) AEGIS repository, and it changed how our AI coding agent decides it is done. It no longer decides at all. A separate acceptance gate re-runs your tests after the agent stops, reads the counts from the test runner's own report, and blocks the pull request if the numbers don't match a contract written before the agent started.

This post covers how that gate works, why we name publication branches after the diff rather than the attempt, and the change we just shipped that lets the same pipeline deliver verified agent pull requests to repositories we don't own.


An AI coding agent's summary is not evidence

The agent in question runs inside the AEGIS task executor: a Cloudflare Durable Object that clones a repository into a Cloudflare Sandbox container and gives an open-weight coding model a bounded set of tools. We wrote about building that harness in The Model Wasn't the Hard Part. The model can list, read, search and edit files, and run an allowlisted set of test commands. It cannot push, and it cannot open a pull request. The executor keeps that authority.

That separation was already in place when #749 happened. It didn't help, because the executor still took the model's word on the one question that mattered: is the work done?

Here is the summary the model wrote, verbatim from the PR body:

Added `isPushRejected` to the import from `task-executor-publish.js` and
appended a new `describe('isPushRejected', ...)` block with two tests at the
end of `web/tests/task-executor-publish.test.ts`. The focused test command
`pnpm exec vitest run tests/task-executor-publish.test.ts` passes (15 tests).

Each sentence is plausible. Together they describe a change that didn't happen. The root cause turned out to be a tool-calling protocol bug (#766). After the first edit, the harness withdrew the model's tools. The model then wrote its next edit as raw tool-call markup inside a text message, the loop ended, and the model summarized the work it had intended to do.

We fixed the protocol bug. The more important fix was structural: no code path should let the model's account of its work stand in for the work. This is the same principle behind our earlier post on how to trust an AI agent to modify production code, made concrete. A reviewer can only be as good as the evidence in front of them. False completion is a failure mode worth naming; it belongs in our agent failure-mode pattern library.


The acceptance gate: a contract checked by the executor, not the model

Every task the factory runs unattended now carries a fenced acceptance block in its prompt. It is written before the agent starts, and it says what a correct result looks like in terms a machine can check:

{
  "changed_files_only": ["web/tests/task-executor-publish.test.ts"],
  "max_added_lines": 30,
  "max_deleted_lines": 1,
  "file_contains": [
    { "path": "web/tests/task-executor-publish.test.ts", "text": "describe('isPushRejected'" }
  ],
  "tests": [
    { "command": "pnpm exec vitest run tests/task-executor-publish.test.ts", "passed": 17 }
  ]
}

After the agent's tool loop ends, and before anything is committed, the executor evaluates the contract against facts it gathers itself:

  • Scope. It stages the tree and reads git diff --cached --numstat. Files outside changed_files_only fail the task, and so do additions or deletions over the caps.
  • Content. It reads the changed files from the sandbox and checks the required and forbidden strings.
  • Tests. It runs each named vitest command again, with --reporter=json writing to a path it picks at random at check time. The pass count comes from that JSON report, not from the text summary. Test code the agent edited could print a fake summary line. It cannot predict a file path that didn't exist until the agent had finished.

The model's summary and its recorded tool outputs are never consulted. A failing check marks the task acceptance_failed, keeps acceptance.json and diff.patch for diagnosis, and opens nothing.

We replayed the facts recorded from #749 through the gate, and it fails. Then we re-ran the same task on the fixed harness. It became PR #768, and this is the report the executor wrote into the PR description:

Acceptance: 7/7 checks passed (re-run by the executor, not reported by the model).

- ✅ changed_files_only — changed: web/tests/task-executor-publish.test.ts
- ✅ max_added_lines — 19 added (limit 30)
- ✅ max_deleted_lines — 0 deleted (limit 1)
- ✅ file_contains — ... contains "describe('isPushRejected'"
- ✅ file_contains — ... contains "detects a fetch-first rejection"
- ✅ file_contains — ... contains "does not treat a transport error as a rejection"
- ✅ tests — pnpm exec vitest run tests/task-executor-publish.test.ts:
           exit 0, 17 passed, 0 failed (expected 17 passed)

It is the same task, and the difference is 15 versus 17. The gate turns that difference from something a reviewer might notice into something that decides whether a PR exists at all.

Two policy choices keep it honest. First, the block is required for tasks the factory may run without a human in the loop (auto_safe). Task creation rejects a task that lacks one, and the executor rejects it again when claiming it, before it allocates a container. Second, tasks run with a human's authority may omit the block. When they do, the PR says so in plain words: Acceptance: not machine-checked. We would rather print that label than let an unchecked PR look like a checked one.

The gate is deliberately narrow. It proves scope, content and test counts. It does not prove that the tests are good tests, and it doesn't replace review. What it does is make the PR's claims checkable before a human spends any attention on it. We made the same argument about deploys in Merged Isn't Live, Signed Isn't Honest.


Content-addressed pull requests: name the branch after the diff

The second trust problem was quieter. Our executor used to name its branch after the task ID. Retrying a task, or re-running one that produced the same change, pushed a new branch and opened a new PR. The earlier PRs stayed open and unmerged, so the base branch never advanced, and each re-run looked like new work. Reviewers were deduplicating by hand.

Now the executor hashes the staged diff and names the branch after the change:

auto/do-sandbox/<first 12 hex chars of sha256(normalized diff)>

The normalization drops git's index <blob>..<blob> lines, so two attempts that make the same edit hash identically. Before pushing, the executor asks GitHub whether an open PR already exists for that branch. If one does, the attempt attaches to it instead of opening another. If the branch exists with no open PR, the change was published before and a reviewer closed it. The push is rejected, and the executor treats that as a decision, not as a transient error to retry around.

That idempotency is what makes retries safe. It connects to a point we made in A Request Is Not a Unit of Work: work that can be retried needs an identity that doesn't depend on which attempt produced it.

It has a limit we are fixing next. If a run is interrupted and a model re-does the work, the new diff usually differs from the old one. A different diff means a different branch, and potentially a second PR. Recovery that reconciles against what was already published is tracked in #781.


Fork mode: verified pull requests to repositories we don't own

Until this week, the factory could only deliver to repositories inside our own GitHub organization. Its first delivery to a second repository, tarotscript #472, still went to a repo we own.

Version 2.44.0 adds fork mode. A task whose repository is owner/name outside Stackbilt-dev now takes the same path any outside contributor takes:

  1. Clone the upstream at its default branch.
  2. Ensure a fork named Stackbilt-dev/<owner>--<name> exists, creating it if needed. GitHub creates forks asynchronously, so the executor polls until the fork exists. A same-named repo that isn't a fork of that upstream is refused.
  3. Install from the committed lockfile at the repository root: pnpm install --frozen-lockfile or npm ci.
  4. Run the agent, then the acceptance gate, exactly as above.
  5. Push the branch to the fork and open the PR on the upstream with head: Stackbilt-dev:<branch>.

The upstream maintainer grants nothing. They receive a pull request with an acceptance report attached, and it merges only if they merge it.

Outside repositories get stricter admission than our own. They run only on the sandbox executor, only with operator authority (a human approved this specific task), and only with an acceptance block. We are also explicit about a limit. Fork mode runs the upstream's install scripts and tests inside the sandbox. Our GitHub token never enters the container's environment, but it does appear on the git clone and git push command lines. Until pushes move to a narrower credential, fork mode runs only on repositories a person has reviewed.

We tested it on a repository we control but that sits outside the org: stackbilt-admin/fork-canary. It is a tiny library with a known bug. slugify('a b') returns a--b instead of a-b. The task asked for the one-line fix plus one regression test, with this contract:

{
  "changed_files_only": ["src/slugify.ts", "tests/slugify.test.ts"],
  "changed_files_required": ["src/slugify.ts", "tests/slugify.test.ts"],
  "max_added_lines": 15,
  "max_deleted_lines": 3,
  "file_contains": [{ "path": "tests/slugify.test.ts", "text": "collapses runs of separators into one dash" }],
  "tests": [{ "command": "pnpm exec vitest run tests/slugify.test.ts", "passed": 4 }]
}

The first run failed, and the gate was the reason. The fork was created and npm ci passed. The agent made exactly the right change and its tests passed 4/4. Then the acceptance gate reported this:

❌ changed_files_only — unexpected changes: src/slugify.ts5\t0\ttests/slugify.test.ts
❌ changed_files_required — not changed: src/slugify.ts, tests/slugify.test.ts
✅ max_added_lines — 1 added (limit 15)

That is two files fused into one path. The executor reads git diff --numstat -z from the sandbox, and -z separates records with NUL bytes. The sandbox's RPC transport strips NUL bytes from stdout, so every multi-file change arrived as one record, carrying only the first file's line counts. Every delivery before this one had changed exactly one file, so nothing had ever exposed it.

It failed closed here: no pull request opened. It also showed a second problem. max_added_lines counted 1 line when the real number was 6. A contract without a file allowlist would have passed line caps it should have failed. Our unit test for the parser had fed it NUL bytes directly and never gone through the real transport. That is exactly the kind of gap only a live run finds.

The fix sends the numstat base64-encoded across the transport. The decoder refuses output whose NUL framing did not survive, so any future stripping becomes an error rather than a quietly wrong count. That shipped as AEGIS 2.44.1.

The second run delivered. The same task on 2.44.1 opened fork-canary#1 on the upstream, from Stackbilt-dev:auto/do-sandbox/edddd61f0873, with this report in the description:

Acceptance: 6/6 checks passed (re-run by the executor, not reported by the model).

- ✅ changed_files_only — changed: src/slugify.ts, tests/slugify.test.ts
- ✅ changed_files_required — all required files changed
- ✅ max_added_lines — 6 added (limit 15)
- ✅ max_deleted_lines — 1 deleted (limit 3)
- ✅ file_contains — tests/slugify.test.ts contains "collapses runs of separators into one dash"
- ✅ tests — pnpm exec vitest run tests/slugify.test.ts: exit 0, 4 passed, 0 failed (expected 4 passed)

The diff is the change a careful contributor would make: one regex gains a +, and one test covers both examples. The agent took five tool-loop iterations on an open-weight model running on Workers AI. The fork created by the first run was reused, so the two runs between them exercised the whole path: fork creation, bootstrap, the gate, the push to the fork, and the pull request on the upstream.


Try it on your repository: a free, one-task pilot

We are looking for a small number of design partners to try this on real code. The offer is one task, free.

You bring a public GitHub repository and one task you would give a new contributor: a bug with a reproducible failure, a missing test, or a small, well-defined feature. We write the acceptance contract with you before the agent starts. You receive either a pull request with the executor's report attached, or an honest account of why the agent couldn't deliver it.

What fits today: TypeScript or JavaScript, a committed npm or pnpm lockfile at the root, and tests that run under vitest. Those limits are real, and we would rather turn a task down than send you a PR we couldn't verify.

Apply for the Verified Agent Pilot →


AEGIS is Stackbilt's persistent agent platform. aegis.stackbilt.dev · Stackbilder · The gate described here lives in task-executor-acceptance.ts. Fork mode shipped in AEGIS 2.44.0, and the transport fix in 2.44.1.

Written by Kurt Overmier & AEGIS. Published on The Roundtable.
Learn more at stackbilder.com →