Matteo
Vincenti
Menu

Debugging with an AI agent: challenge the diagnosis before fixing the code

Reproduce a JavaScript date bug in Docker, distinguish two diagnoses and preserve regression tests before asking an AI agent to patch the code.

A monitor and two probes flank a magnifying glass highlighting a break in a circuit.

An invoice is due on 25 October. The application receives 2026-10-25, yet a server configured for New York displays the 24th. In UTC and Rome, the day is correct.

It is a useful problem to give a coding agent: a few files, a wrong result and a clear requirement. Before requesting a fix, one decision matters: which observation separates a plausible diagnosis from the cause we are looking for? Changing the timezone of the entire process would hide the symptom and could change other application behaviour.

The following Docker lab reproduces this problem. The lab ends with a small fix, the same tests run before and after it, and a second patch rejected for a specific reason.

A due date and an event mean different things

A civil date identifies a calendar day, such as a contractual deadline or a birthday. Here, 2026-10-25 means “25 October”, with no time of day and no requested conversion between timezones.

An instant identifies a point on the timeline. 2026-10-25T00:30:00Z is half past midnight UTC; in New York, it still falls on 24 October. That is the correct result for an event.

This distinction determines the patch's job. The due date must keep its calendar day; event displays must continue to use the application's timezone.

Set up the minimal case

You need Docker Engine or Docker Desktop running, a Bash or zsh terminal and an editor. Node runs inside the container: there is no local Node installation or npm package to add. You can follow the entire lab manually. If you already use Claude Code or Codex, open your session in the same directory.

Create an empty directory and enter it:

bash
mkdir date-debug-lab
cd date-debug-lab

You will create three files. Start with the implementation and probe; we will add the test file after the diagnosis.

txt
date-debug-lab/
  date.mjs
  probe.mjs
  date.test.mjs

Put this initial implementation in date.mjs:

javascript
const pad = value => String(value).padStart(2, '0');

function requireCivilDate(value) {
  if (typeof value !== 'string' || !/^\d{4}-\d{2}-\d{2}$/.test(value)) {
    throw new RangeError('Expected a civil date in YYYY-MM-DD format');
  }
  const parsed = new Date(`${value}T00:00:00.000Z`);
  if (!Number.isFinite(parsed.getTime()) || parsed.toISOString().slice(0, 10) !== value) {
    throw new RangeError('Invalid calendar date');
  }
}

export function formatDueDate(value) {
  requireCivilDate(value);
  const date = new Date(value);
  return `${pad(date.getDate())}/${pad(date.getMonth() + 1)}/${date.getFullYear()}`;
}

export function formatInstant(value, timeZone) {
  return new Intl.DateTimeFormat('en-GB', {
    day: '2-digit', month: '2-digit', year: 'numeric', timeZone,
  }).format(new Date(value));
}

Validation rejects formats other than YYYY-MM-DD and dates that do not exist. The round trip through toISOString() also catches dates JavaScript normalises to a different day. The defect to isolate is in due-date formatting, which reads the local components of a Date object.

Now write probe.mjs. A probe is a short program that exposes values useful for a diagnosis:

javascript
import { formatDueDate, formatInstant } from './date.mjs';

const input = '2026-10-25';
const parsed = new Date(input);
console.log(JSON.stringify({
  requestedZone: process.env.TZ,
  resolvedZone: Intl.DateTimeFormat().resolvedOptions().timeZone,
  input,
  epochMs: parsed.getTime(),
  iso: parsed.toISOString(),
  local: { year: parsed.getFullYear(), month: parsed.getMonth() + 1, day: parsed.getDate() },
  dueDate: formatDueDate(input),
  instant: formatInstant('2026-10-25T00:30:00.000Z'),
}, null, 2));

Download the pinned lab image:

bash
LAB_IMAGE='node@sha256:3d27e5c11e5786e309ec3e03f93ae536eb36e6e5eb3714d5eb3300a36157add0'
docker pull "$LAB_IMAGE"

The recorded runs used Node 24.21.0 on Linux arm64, with ICU 78.3 and timezone data 2026c. The digest pins the image; Docker can select a different platform variant on another architecture.

Define this helper in your terminal while remaining in date-debug-lab:

bash
run_case() {
  LAB_TZ="$1"
  LAB_EXPECTED="$2"
  shift 2
  docker run --rm --pull never --network none --read-only \
    --cap-drop ALL --security-opt no-new-privileges \
    --memory 128m --cpus 1 --pids-limit 64 --user node \
    --env "TZ=$LAB_TZ" --env "EXPECTED_INSTANT_DATE=$LAB_EXPECTED" \
    --mount "type=bind,src=$PWD,dst=/app,readonly" \
    --workdir /app "$LAB_IMAGE" node "$@"
}

run_case takes the timezone, the expected date for the event, and then the Node command. The second argument is for the tests; the probe does not use it. Source files are mounted read-only, networking is disabled, and the container is removed when it exits. --pull never makes the command fail if the image is not already available.

The complete package contains all three versions, the tests, README and results if you prefer to compare separate files:

debug-date-lab-sources.json ↓

Two hypotheses, one variable

“It's a timezone problem” is still too broad to tell us where to intervene. We can formulate two explanations that predict different observations.

Two hypotheses, one variable
HypothesisWhat should change between UTC and New York
Parsing constructs a different instantThe number returned by getTime() and the UTC representation from toISOString().
The instant is unchanged; local component access differsThe day returned by getDate(), while the epoch value and UTC representation stay the same.

The epoch value is the number of milliseconds since the start of 1970 in UTC. It lets us compare instants without depending on their display format. toISOString() makes the same UTC comparison readable.

The agent can propose hypotheses and prepare the experiment. This reusable prompt asks it to stop before changing code:

txt
Investigate this failure before changing the implementation.

Domain contract:
- An invoice due date is a civil date in canonical YYYY-MM-DD form.
- Display it as DD/MM/YYYY without changing the calendar day.
- Event timestamps are instants. Their display must still use the application's timezone.

Observed symptom:
2026-10-25 displays as 25/10/2026 in UTC and as 24/10/2026 in America/New_York.

Read date.mjs and probe.mjs. Do not edit files yet.
1. State two competing explanations and the observation each predicts.
2. Propose a command that distinguishes them while keeping the input, code and runtime fixed.
3. If tool access is available, run the experiment and include the actual output. Otherwise, give me the command and wait for its output.
4. Identify which explanation the observation rules out and what remains unresolved.
5. Propose a regression test that fails before the fix and a test protecting valid instant conversion.

Do not propose a patch until the observation supports the diagnosis.
Keep expected output separate from output actually observed.

If the agent cannot access a terminal, run its proposed command and return the output. If it can, check that it actually performed the requested comparison. Claude Code's debugging documentation already asks for a reproduction command, steps and whether the failure is intermittent or consistent. Here we add a prediction the observation can refute, following the method described in The Debugging Book.

Run the probe three times:

bash
run_case UTC 25/10/2026 probe.mjs
run_case Europe/Rome 25/10/2026 probe.mjs
run_case America/New_York 24/10/2026 probe.mjs

The input, source and runtime stay fixed. We change TZ, the environment variable Node uses to configure its timezone.

All three executions produced:

json
{
  "epochMs": 1792886400000,
  "iso": "2026-10-25T00:00:00.000Z"
}

The local components and displayed due date differ:

Two hypotheses, one variable
TimezoneLocal dayDisplayed due date
UTC2525/10/2026
Europe/Rome2525/10/2026
America/New_York2424/10/2026

This comparison refutes the first hypothesis: the constructed instant is identical. Reading its local calendar components introduces the difference. The ECMAScript specification explains why: a date-only string without an offset is interpreted in UTC. Reading its local day in New York then produces the previous day.

Write the test before the fix

Create date.test.mjs. The first test captures the invoice requirement. The others cover additional dates, invalid input and the event behaviour we want to preserve.

javascript
import test from 'node:test';
import assert from 'node:assert/strict';
import { formatDueDate, formatInstant } from './date.mjs';

test('civil due date keeps its calendar day', () => {
  assert.equal(formatDueDate('2026-10-25'), '25/10/2026');
});
test('civil date on the spring clock change keeps its calendar day', () => {
  assert.equal(formatDueDate('2026-03-29'), '29/03/2026');
});
test('valid leap day is accepted', () => {
  assert.equal(formatDueDate('2024-02-29'), '29/02/2024');
});
test('non-leap February 29 is rejected', () => {
  assert.throws(() => formatDueDate('2026-02-29'), RangeError);
});
test('February 30 is rejected', () => {
  assert.throws(() => formatDueDate('2026-02-30'), RangeError);
});
test('month 13 is rejected', () => {
  assert.throws(() => formatDueDate('2026-13-01'), RangeError);
});
test('timestamp is rejected by the civil-date function', () => {
  assert.throws(() => formatDueDate('2026-10-25T00:30:00Z'), RangeError);
});
test('noncanonical date is rejected', () => {
  assert.throws(() => formatDueDate('2026-1-5'), RangeError);
});
test('a real instant has October 25 in UTC', () => {
  assert.equal(formatInstant('2026-10-25T00:30:00Z', 'UTC'), '25/10/2026');
});
test('the same real instant has October 24 in New York', () => {
  assert.equal(formatInstant('2026-10-25T00:30:00Z', 'America/New_York'), '24/10/2026');
});
test('the default instant display keeps the configured application timezone', () => {
  assert.ok(process.env.EXPECTED_INSTANT_DATE, 'Set EXPECTED_INSTANT_DATE for this run');
  assert.equal(formatInstant('2026-10-25T00:30:00Z'), process.env.EXPECTED_INSTANT_DATE);
});

Run the same suite in the three timezones:

bash
run_case UTC 25/10/2026 --test date.test.mjs
run_case Europe/Rome 25/10/2026 --test date.test.mjs
run_case America/New_York 24/10/2026 --test date.test.mjs

The initial version passes all 11 tests in UTC and Rome. In New York, it passes 8 and fails 3: the 25 October due date, 29 March and the leap day. The final command exits with status 1. That is the failure we wanted to reproduce, not a problem starting the lab.

29 March and 25 October are also clock-change days in Rome in 2026. The defect also appears on 29 February, so attributing it solely to daylight saving time would send the investigation in the wrong direction.

Fix the right value

Replace only formatDueDate in date.mjs:

javascript
export function formatDueDate(value) {
  requireCivilDate(value);
  const [year, month, day] = value.split('-');
  return `${day}/${month}/${year}`;
}

Also remove the unused pad constant on the first line. Leave requireCivilDate, formatInstant and every test unchanged.

Validation still checks whether the day exists. Display now uses the civil date's year, month and day directly. The Date object remains inside calendar validation; displaying the due date no longer reads local calendar components.

Repeat the three suite commands. The corrected version passed 11 out of 11 tests in each timezone, including two different results for the same instant: 25 October in UTC and the 24th in New York.

Setting the process timezone globally to UTC would have hidden the first error. The package includes that counterexample too: the initial version with process.env.TZ = 'UTC' prepended to the module. In New York, it passes 10 tests out of 11. The failing test protects the default event display in the configured timezone. That event now appears on the 25th instead of the 24th.

I would reject that patch even with a green invoice test. It removes the symptom by changing another part of the requirement. An application designed to display every event in UTC would make a different choice; this one keeps its configured application timezone.

Take the diagnosis back to the project

The reduced case leaves a precise next step: find where a civil date becomes an instant and is then read in local time. In the real project, trace that same path from the received value to the component displaying it.

If the reduced program stops reproducing the error, restore the last removed element that made it appear. A parsing library, external input or the order of two operations may be the decisive part. Reduction preserves the failing behaviour while removing the rest, as the chapter on failure-inducing inputs in The Debugging Book explains.

A useful diagnosis record can be brief: the civil-date requirement, the failing command, the observation that ruled out different parsing, the unchanged tests and the preserved instant behaviour. The agent can now prepare the project patch within a much clearer boundary. To decide whether to accept the diff, the next step is a code review of the proposal and its tests.

There is no need for docker prune when you finish: --rm has already removed the containers, with no volumes or packages installed. Keep the three files while you need them, then remove only the directory you created. Leave the image in place if other work uses it.

Share this article

Have a question or a comment about this article?

Message me on LinkedIn
All articles