Test-First With an Agent
Test-first with an agent divides the work along the line of authority. You write or approve the failing test, and the agent writes the code that makes it pass. Done carefully, it is the tightest form of the supervised loop from Chapter 2. Done carelessly, it hands the agent the question and the answer key together.
The refund change is the case. Run test-first, it would have caught the boundary flip before anyone read a line of the diff, because the test that would have failed is three lines long and was written from the rule, not from the code.
The Loop
Write the test from the rule, using the examples Chapter 5 turned into acceptance criteria. Run it and watch it fail. Commit it, so it becomes a fixed point the agent's work is measured against. Ask the agent to make it pass without changing it. Read the diff. Run the whole suite, not only the new tests, before you accept anything.
The request itself is short, and every clause in it does work. It names the tests, forbids editing them, and tells the agent what to do when it disagrees, so a disagreement arrives as a question rather than as a changed assertion. The reply is a claim like any other, and the runner's own output, not the sentence "all tests pass", is what you check.
That reply is the loop working. Both tests encoded the old rule, and the agent stopped instead of changing them. The answer is yes, a refund of $24.00 and a deadline of 21:00, and you make those changes yourself, in their own commit, with a message that says the rule changed. Chapter 7 showed what happens when that decision is left to the agent: the second-zone line moved to 20:00.
The Refund Tests
The rule says a cancellation up to 12 hours before the class is refunded in full, and Ines ruled that "up to" includes the twelfth hour. Three cases come straight from that wording: 12 hours and 1 minute ahead is refunded, exactly 12 hours ahead is refunded, and 11 hours and 59 minutes ahead is not. Two older tests already state the old rule: the 23-hour case expects no refund, and the second-zone line expects 09:00. They stay as they are for now; what happens to them is the point of the request further down.
"""The 12-hour rule, straight from its wording. Written before the code."""
from datetime import datetime, timedelta
from cragmoor import refunds, schedule
from cragmoor.booking import Booking
from cragmoor.gyms import Gym
GYM = Gym("north", "America/Chicago")
CLASS = Booking(gym=GYM, starts_at=datetime(2026, 11, 14, 9, 0), paid_cents=2400)
def cancelled(hours: int, minutes: int = 0) -> datetime:
"""A fixed moment before the class: the test sets the clock, not the machine."""
start = schedule.localize(CLASS.starts_at, GYM.time_zone)
return start - timedelta(hours=hours, minutes=minutes)
def test_12_hours_1_minute_ahead_is_refunded():
assert refunds.refund_cents(CLASS, cancelled(12, 1)) == 2400
def test_exactly_12_hours_ahead_is_refunded():
assert refunds.refund_cents(CLASS, cancelled(12)) == 2400
def test_11_hours_59_minutes_ahead_is_not_refunded():
assert refunds.refund_cents(CLASS, cancelled(11, 59)) == 0// The 12-hour rule, straight from its wording. Written before the code.
import { expect, test } from "vitest";
import type { Booking } from "../src/booking.ts";
import * as refunds from "../src/refunds.ts";
import * as schedule from "../src/schedule.ts";
const CLASS: Booking = {
gym: { code: "north", timeZone: "America/Chicago" },
startsAt: "2026-11-14T09:00", paidCents: 2400, classes: 1,
};
/** A fixed moment before the class: the test sets the clock, not the machine. */
function cancelled(hours: number, minutes = 0): Date {
const start = schedule.localize(CLASS.startsAt, CLASS.gym.timeZone);
return new Date(start.getTime() - (hours * 60 + minutes) * 60_000);
}
test("12 hours 1 minute ahead is refunded", () => {
expect(refunds.refundCents(CLASS, cancelled(12, 1))).toBe(2400);
});
test("exactly 12 hours ahead is refunded", () => {
expect(refunds.refundCents(CLASS, cancelled(12))).toBe(2400);
});
test("11 hours 59 minutes ahead is not refunded", () => {
expect(refunds.refundCents(CLASS, cancelled(11, 59))).toBe(0);
});import static org.junit.jupiter.api.Assertions.assertEquals;
import java.time.LocalDateTime;
import java.time.ZonedDateTime;
import org.junit.jupiter.api.Test;
/** The 12-hour rule, straight from its wording. Written before the code. */
class RefundWindowTest {
static final Gym GYM = new Gym("north", "America/Chicago");
static final Booking CLASS = new Booking(GYM, LocalDateTime.of(2026, 11, 14, 9, 0), 2400, 1);
/** A fixed moment before the class: the test sets the clock, not the machine. */
static ZonedDateTime cancelled(int hours, int minutes) {
return Schedule.localize(CLASS.startsAt(), GYM.timeZone()).minusHours(hours).minusMinutes(minutes);
}
@Test
void twelveHoursOneMinuteAheadIsRefunded() {
assertEquals(2400, Refunds.refundCents(CLASS, cancelled(12, 1)));
}
@Test
void exactlyTwelveHoursAheadIsRefunded() {
assertEquals(2400, Refunds.refundCents(CLASS, cancelled(12, 0)));
}
@Test
void elevenHoursFiftyNineMinutesAheadIsNotRefunded() {
assertEquals(0, Refunds.refundCents(CLASS, cancelled(11, 59)));
}
}Run against the code as it stood before the change, with the 24-hour window, two of the three fail, and they fail on their assertions: the code refunded nothing where the new rule refunds $24.00. That is red for the rule's reason. Run against the refund diff from Chapter 6, the exactly-12-hours test fails, which is the boundary flip caught in seconds. Run against the narrowed change, all three pass.
tests/test_refund_window.py:24: AssertionError: assert 0 == 2400 FAILED tests/test_refund_window.py::test_12_hours_1_minute_ahead_is_refunded FAILED tests/test_refund_window.py::test_exactly_12_hours_ahead_is_refunded 2 failed, 1 passed in 0.02s
FAILED tests/test_refund_window.py::test_exactly_12_hours_ahead_is_refunded 1 failed, 2 passed in 0.02s
3 passed in 0.02s
× 12 hours 1 minute ahead is refunded 17ms
× exactly 12 hours ahead is refunded 1ms
AssertionError: expected +0 to be 2400 // Object.is equality
Tests 2 failed | 1 passed (3)
× exactly 12 hours ahead is refunded 3ms
AssertionError: expected +0 to be 2400 // Object.is equality
Tests 1 failed | 2 passed (3)
Tests 3 passed (3)
│ └─ RefundWindowTest ✔ │ ├─ elevenHoursFiftyNineMinutesAheadIsNotRefunded() ✔ │ ├─ twelveHoursOneMinuteAheadIsRefunded() ✘ expected: <2400> but was: <0> │ └─ exactlyTwelveHoursAheadIsRefunded() ✘ expected: <2400> but was: <0>
│ ├─ elevenHoursFiftyNineMinutesAheadIsNotRefunded() ✔ │ ├─ twelveHoursOneMinuteAheadIsRefunded() ✔ │ └─ exactlyTwelveHoursAheadIsRefunded() ✘ expected: <2400> but was: <0>
[ 3 tests successful ] [ 0 tests failed ]
Failing for the Right Reason
A new test must fail on its assertion, not on an import error, a misspelt name or a missing fixture. A test that fails for the wrong reason can pass for the wrong reason too: fix the import, and the test may pass at once because the assertion never checked what you meant. Read the failure message before handing the test over, and confirm it says what the rule says, in this case "expected 2400, got 0".
Fixed Clocks and Fixed Inputs
Rules about time need a clock the test controls. An agent asked to test "12 hours before" against the real clock writes a test whose result depends on when it runs, which passes on most days and fails on the night before a class. The tests above never ask the machine for the time; they pass the moment of cancellation in as an argument.
That has a consequence for the code. If the test supplies the time, the code the agent writes must accept it rather than reading the clock itself. Say so in the task. Code that reads the clock deep inside cannot be tested at a boundary, and an agent will happily write it that way unless the test makes the other shape the only one that passes.
The refund code already has the right shape: the function that decides a refund takes the moment of cancellation as an argument. The places that call it, the cancellation screen and the cancellation request, read the clock once and pass the time in. That one read is the only line that touches the real clock, and it is too simple to be wrong in an interesting way.
Where It Goes Wrong
The agent edits the test instead of the code, the bend Chapter 7 described; the commit you made before asking makes that visible. The agent special-cases the three inputs because the tests were narrow, a branch on exactly 12 hours that makes all three pass; add a value it has not seen, or check the rule across a range, as the topic on edges does. A new test passes at once because the code already did that, so it was never red and proves nothing. Or the agent writes code and tests together, and the tests mirror the code, which the next topic takes apart.
When the Agent Drafts the Test
Letting the agent draft the test is acceptable, on one condition: you review it as a specification before any code exists. Does it state the rule, in literal values a member would recognise? Would it fail if the rule were broken? The mutation check in the next topic answers the second question in a minute. Commit the reviewed test, then ask for the code in a separate request, so the test is fixed while the code is written.
A drafted test fails that review in predictable ways. It computes the expected deadline by calling the deadline function. It checks that a refund is "greater than zero" instead of $24.00. It tests 24 hours and 6 hours, far from the boundary, because those are the cases the agent's code obviously handles. Each of those is a sign the test was written from the code the agent was about to write, not from the rule.
Tests-after: the agent writes the code, then tests for it. The tests are derived from the code and agree with it, mistakes included. Use it only to pin behaviour that already exists, the method of Chapter 11.
Test-first: the test is derived from the rule and committed before the code. The code has to meet it. Use it for every change to a rule.
- "Asking for code and tests together is test-driven." Both come from one generation, so the tests agree with the code by construction, boundary flip included.
- "A new test that passes at once is a good sign." A test that has never failed has never been shown able to fail, and it may be testing nothing.
- "Three examples pin the rule." Three exact inputs invite three special cases. Add a value between them, or check a range.
- "The agent may fix a test it believes is wrong." Sometimes your test is wrong. Then the agent says so and stops, and you change the test yourself, in its own commit.
- "Test-first is too slow for a small change." The refund tests are about 20 lines, and they catch the boundary flip that cost a review round in Chapter 6.
- Commit the failing test before the agent starts. Any edit to it then shows as a diff against your commit.
- Run every new test against the old code and read the failure. It must fail on the assertion, for the rule's reason.
- Inject the clock, and any randomness, in the tests you write. A rule about time cannot be tested against the real clock.
- Tell the agent explicitly: make these tests pass, do not edit them, and stop and report if one looks wrong.
Knowledge Check
You ask an agent for the 12-hour change and its tests in one request. Why are those tests weak evidence that the change is right?
- Agents cannot write tests that actually run under the test runner
- They come from one generation and agree with the code by construction
- One request produces too few tests to cover the 12-hour rule properly
- The test runner skips tests that arrive in the same commit as the code
Your new boundary test fails, and the message reports that a name could not be imported. What should you do before giving it to the agent?
- Hand it over as it is, since the agent will fix whatever makes it fail
- Delete it and rely on the existing suite to cover the boundary instead
- Fix the import, rerun it, and check that it now fails on its assertion
- Mark it as skipped until the agent has finished writing the new code
Your test-first tests check 12 hours 1 minute, exactly 12 hours and 11 hours 59 minutes before a class. What can the agent do that passes all three without implementing the rule?
- Change the machine's clock so the tests run at a convenient time
- Mark the three tests as skipped, so that the run reports nothing
- Refund every cancellation, since two of the three expect a refund
- Special-case the three exact inputs so each takes its own branch
Partway through, the agent reports that your exactly-12-hours test looks wrong to it. What should happen next?
- The agent corrects the test itself and carries on with the code
- It stops; you decide, and change the test yourself if it is wrong
- The agent skips that test and finishes the other two of the three
- The agent writes a longer explanation of why the test is wrong
You got correct