Write the Test First: TDD Pipeline Mode

📁 Computer

In short: The standard pipeline writes code first, then tests. This post covers a TDD mode that flips the order: QA writes tests before the engineers see the problem, then engineers implement against those tests, then a fix loop runs until the suite is green. It also covers how this forced a proper stage registry — replacing hardcoded stage sequences with a configurable system.


Test-driven development is easy to preach and hard to enforce. In a team of humans, you rely on discipline and code review. In a team of AI agents, you can enforce it at the architecture level.


Why TDD for agents?

The original pipeline wrote code and then tested it. The tests were written by QA after the engineers — which means QA was testing whatever the engineer happened to build, not whether the engineer satisfied the original requirement.

There’s a subtler benefit: when tests exist before implementation, the engineer’s prompt can include the test file. The task becomes concrete. Instead of “implement a REST API for a todo app”, the engineer sees:

def test_create_todo_returns_201():
    response = client.post("/todos", json={"title": "Buy milk"})
    assert response.status_code == 201
    assert response.json()["id"] is not None

def test_list_todos_returns_all():
    # ... etc

The acceptance criteria are no longer abstract — they’re executable. The engineer can’t hallucinate a different API shape without those tests failing.


The stage order problem

Implementing TDD mode exposed a deeper issue: the original run() method was one long function with hardcoded stage calls.

def run(self, requirement: str) -> PipelineResult:
    result = self._stage_pm(result, requirement)
    result = self._stage_pm_reviewer(result)
    result = self._stage_architect(result)
    # ... 80 more lines of explicit stage calls

To swap QA before engineers, I needed to slice this function in the right place. But the hardcoding made this fragile — a stage flip was a surgical edit to a 100-line function.

The fix was a stage registry.


The PipelineStage dataclass

Each stage is now a PipelineStage:

@dataclass
class PipelineStage:
    name: str
    label: str
    description: str
    checkpoint_key: str
    fn: Callable[[PipelineResult], None]
    skip_if: Callable[[PipelineResult], bool] = lambda r: False
    stop_if: Callable[[PipelineResult], bool] = lambda r: False
    stop_message: str = ""

The fn closes over self (the orchestrator), so each stage function has full access to agent instances, config, and the result object:

def _make_stage_registry(self) -> dict[str, PipelineStage]:
    return {
        "junior_engineer": PipelineStage(
            name="junior_engineer",
            label="Engineers",
            fn=lambda r: self._stage_junior_engineer(r),
            ...
        ),
        "qa_planner": PipelineStage(...),
        "qa_write": PipelineStage(
            name="qa_write",
            fn=lambda r: self._stage_qa_write(r),
            ...
        ),
        # ...
    }

_make_stage_registry() is the single source of truth for what stages exist. When a new agent is added, it appears here — and automatically becomes available in every mode, pipeline config, and GUI palette.


Two modes

MODES = {
    "standard": [
        "tier_review", "junior_engineer", "senior_engineer",
        "reviewer", "qa_planner", "qa_engineer",
        "test_fix", "deploy_tester", "deploy_fix",
    ],
    "tdd": [
        "qa_planner", "qa_write",
        "tier_review", "junior_engineer", "senior_engineer",
        "test_fix", "reviewer",
        "deploy_tester", "deploy_fix",
    ],
}

The key difference: in TDD mode, qa_planner and qa_write run before any engineer.

_build_stage_list() resolves a mode name (or a custom list from config) into an ordered list of PipelineStage objects. The run() loop is now just:

for stage in self._build_stage_list():
    if stage.skip_if(result):
        continue
    stage.fn(result)
    result.completed_stages.append(stage.checkpoint_key)
    self._save_checkpoint(result)
    if stage.stop_if(result):
        print(stage.stop_message)
        break

Eighty lines of hardcoded stage calls replaced by eight.


The qa_write stage

The qa_write stage calls QAEngineerAgent.run() with a write_only=True flag:

def _stage_qa_write(self, result: PipelineResult) -> None:
    qa = QAEngineerAgent(...)
    qa_result = qa.run(
        prd=result.prd,
        design=result.design,
        code={},           # no code yet — that's the point
        write_only=True,
    )
    result.test_files = qa_result["test_files"]
    self._write_test_files_locally(result)

With write_only=True, the QA agent generates test files but skips test execution (there’s nothing to execute against). The test files are saved locally so engineers can read them.


Tests as engineer input

When test_files is populated on the result, the engineer prompt includes them:

if test_files:
    test_context = self._truncate_test_files(test_files, max_chars=10000)
    prompt += f"\n\n## Existing Tests\n{test_context}\n\nImplement code that makes these tests pass."

The truncation is deliberate — a large test suite shouldn’t crowd out the design doc. 3000 characters per file, 10000 total, with a truncation notice appended. The engineer sees enough to understand the expected behaviour without losing the rest of the context.


TDD mode in practice

A run in TDD mode shows the reordering clearly:

✅  qa_planner     — QA Planner Henry completed test plan
✅  qa_write       — QA Engineer Edward wrote test suite (write-only, no execution)
✅  tier_review    — Repo indexed
✅  junior_engineer — Engineers Alex ×2 completed module implementation
✅  senior_engineer — Senior Engineer integrated modules
✅  test_fix       — 14/21 tests passed (7 failures, entering fix loop)
✅  test_fix       — 18/21 tests passed (3 failures, entering fix loop)
✅  test_fix       — 21/21 tests passed ✓

The test failures aren’t a problem — they’re expected. The fix loop is the TDD red→green cycle. Engineers iterate until the suite passes, then the code reviewer runs.

Some tests will fail after the first engineering pass. That’s normal. The fix loop (which existed before TDD mode) is exactly the right mechanism to drive convergence.


Config

pipeline:
  mode: tdd   # or "standard" (default)

Or selectively skip stages:

pipeline:
  mode: tdd
  stages:
    reviewer: skip   # skip Code Reviewer in TDD mode if tests are sufficient

What this unlocked

The stage registry was the real win. TDD mode was the motivating use case, but the dataclass + _build_stage_list() refactor made the pipeline composable. Adding a new mode or variant is now a matter of listing stage names in order — no surgery on a 100-line function.

It also made the next feature possible.



Code: github.com/wanleung/ai-dev-team