Write the Test First: TDD Pipeline Mode
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():
# ... etcThe 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 callsTo 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)
breakEighty 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 sufficientWhat 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.