Testing AI agent state transitions in a code editor
← Back to Blog
October 2026·AI Engineering·12 min read

How I test AI agent state transitions, not just model outputs

Learn to test AI agent state transitions with deterministic contracts, failure paths, replay checks and PHP examples for production workflows.

Testing AI agent state transitions means verifying that every command moves a workflow from one valid state to another, rejects illegal moves and records the right effects. I do this separately from judging whether the model wrote a good summary or chose the best label. Model quality is probabilistic. Workflow correctness should be deterministic.

That distinction sounds obvious until a test suite contains twenty model prompts and almost no assertions about the workflow around them. The tests celebrate a convincing answer, while the production bug is a duplicated tool call, an approval that can be bypassed or a failed job that restarts from the wrong place.

I have become much less interested in whether an agent can complete one impressive run. I want to know what happens on the second delivery, after a timeout, with stale state, or when the model proposes a transition the business process does not allow. Those cases tell me whether I am testing a demo or a system.

Model quality and workflow correctness are different test suites

An AI agent usually combines two kinds of behaviour. The model performs judgment: classifying a request, extracting facts, drafting a response or proposing an action. The application owns control: deciding which tools are available, whether approval is required, which transition is legal and whether an external write may happen.

Those responsibilities fail differently, so I do not test them with one score.

QuestionTest styleTypical assertion
Did the model understand the request?Evaluation setClassification or rubric score
Is this transition allowed?Deterministic unit testAccepted or rejected state pair
Was the correct effect scheduled?Integration testOne outbox command with a stable key
Can the run resume safely?Replay testNo duplicate effect; same resulting state
Does the provider still honour its contract?Adapter contract testValidated request and response schema

A model evaluation can tolerate a threshold. A transition rule cannot be correct 92% of the time. If awaiting_approval may move to executing only after an authorised review, every application path must enforce that rule.

This is one reason I prefer an explicit state machine for production agents. It turns control flow into an application contract that ordinary tests can exercise without spending tokens or waiting for an API.

Start with the transition contract

Before writing tests, I describe each transition as four parts:

  1. Command: what is being requested, such as approve or retry.
  2. Guard: what must be true before the command is accepted.
  3. State change: the exact previous and next states.
  4. Effects and evidence: what must be scheduled or recorded after acceptance.

Imagine an illustrative support agent that researches a ticket, drafts a proposed reply, waits for approval and then sends it. Its contract might include:

Current stateCommandGuardNext stateEffect
receivedstart_researchTicket is assignedresearchingQueue research job
researchingsubmit_draftSources and draft validateawaiting_approvalCreate review task
awaiting_approvalapproveReviewer role and artifact version matchapprovedQueue send command
approvedconfirm_sentProvider receipt existscompletedRecord outcome
researchingfailRetry budget exhaustedintervention_requiredNotify operator

The table becomes executable design. It also reveals missing decisions early. Can a rejected draft return to research? Can an approval expire? What happens when the provider accepts the send but the worker times out before storing the receipt? A vague “failed” state hides those questions. A transition contract forces names onto them.

The happy path is the smallest part of the state graph. For every allowed transition, there are usually several moves that must be rejected. I put both sets into data providers so a reviewer can see the policy without reading dozens of almost identical test methods.

use PHPUnit\Framework\Attributes\DataProvider;

final class WorkflowTransitionTest extends TestCase
{
    #[DataProvider('legalTransitions')]
    public function test_it_accepts_legal_transitions(
        State $from,
        Command $command,
        State $expected,
    ): void {
        $workflow = WorkflowFixture::inState($from);

        $result = $workflow->handle($command);

        self::assertSame($expected, $result->state());
        self::assertSame($from, $result->event()->from);
        self::assertSame($expected, $result->event()->to);
    }

    public static function legalTransitions(): iterable
    {
        yield 'research starts' => [
            State::Received,
            Command::StartResearch,
            State::Researching,
        ];

        yield 'approved draft completes' => [
            State::Approved,
            Command::ConfirmSent,
            State::Completed,
        ];
    }
}

The illegal matrix matters just as much. I test that received -> completed fails, a rejected artifact cannot execute, a completed workflow cannot restart and a human-only transition is rejected when the actor is the agent.

A useful rejection assertion checks more than the exception class. The state must remain unchanged. No outbox record may be created. The rejected attempt should leave audit evidence with the workflow ID, requested command and policy reason, but it must not look like a successful transition.

This is also where default-deny pays off. If a developer adds a new state and forgets its policy, the system rejects the transition until a test and rule explicitly allow it. An allow-by-accident design produces the opposite behaviour: a new combination silently reaches production.

Replace the model with recorded decisions

I do not call an LLM in state-machine unit tests. The workflow does not need prose; it needs a typed decision at a boundary. For example:

final readonly class DraftDecision
{
    public function __construct(
        public bool $readyForReview,
        public array $sourceIds,
        public string $artifactHash,
        public array $policyFlags,
    ) {}
}

The test supplies fixtures for the meaningful shapes: ready, missing evidence, policy violation, malformed output and unavailable model. It then asserts how the application reacts to each shape. That makes the suite fast, repeatable and useful during an incident.

The model still needs evaluation. I maintain a separate dataset that checks whether real model responses map to the expected DraftDecision. That suite may run less often, cost money and use tolerance-based scoring. Once a decision crosses the boundary, however, the orchestration tests treat it like any other external input.

This separation prevents a common testing illusion. If a live model happens to return the expected answer, an end-to-end test can pass even though the application would accept an illegal state change from a different response. Recorded decisions let me test the uncomfortable branches deliberately.

Assert effects and evidence, not only the final status

A state change is rarely the whole result. Moving to approved may need to create an outbox command, bind it to the approved artifact version, assign an idempotency key and append an audit event. A test that checks only state === approved can miss every one of those obligations.

For each accepted transition, I assert a small invariant set:

  • exactly one transition event records the previous and next states;
  • the event identifies the actor, reason, workflow version and timestamp;
  • the effect references the same artifact that passed the guard;
  • the operation key is stable when the same command is delivered again;
  • state and outbox write commit atomically in the local database;
  • no provider call happens before the local commit succeeds.

The outbox test does not prove that an email provider will behave perfectly. It proves that the application records one durable intention before delivery. Adapter tests cover the provider request separately. Production reconciliation covers the ambiguous case where a provider accepts a request but the acknowledgement disappears.

This evidence also supports debugging. AI agent observability starts with events because an event says what the system believed changed. A log line saying “job finished” cannot replace that contract.

Failure paths deserve first-class cases

Retries are transitions, not a loop hidden inside a queue worker. I test the first transient failure, the last permitted retry and the next failure after the budget is exhausted. Each should produce a different, explicit result.

A practical failure matrix includes:

  • Model timeout: remain resumable; do not publish an empty decision.
  • Invalid structured output: record validation evidence and apply the bounded model retry policy.
  • Tool timeout before acceptance: retry with the same operation identity.
  • Ambiguous tool timeout: reconcile before deciding whether another call is safe.
  • Approval expiry: invalidate the old artifact and require a new review.
  • Retry budget exhausted: move to intervention, not an infinite retry cycle.
  • Irreversible effect followed by failure: enter a compensation or operator-decision path.

The tests should control time. Approval deadlines, exponential backoff and leases become painful when a suite sleeps. A fake clock lets the test advance from “not yet retryable” to “retry available” to “deadline missed” in milliseconds.

I also persist the reason for every failure transition. The reason is not merely an exception message; it is a stable category the policy understands. A retry budget needs to distinguish transient provider failure from invalid input, while a compensating workflow needs to know which external commitment already escaped.

Replay and concurrency reveal production bugs

Queue delivery is not the same as exactly-once business execution. Workers restart, acknowledgements get lost and two consumers can read the same version of a workflow. I include replay and concurrency tests before trusting any transition with a side effect.

The basic replay test executes the same command twice with the same operation key. The second delivery must not create another effect or append a second successful transition. It may return the first result or a clear “already applied” outcome. Idempotency turns duplicate delivery into an ordinary condition, but only if the test covers the remote boundary as well as the database row.

For concurrency, I load version 7 of a workflow in two simulated workers. Worker A applies approve and writes version 8. Worker B then tries to apply reject from its stale version 7. The expected result is a concurrency conflict, not whichever write reaches the database last.

I run a similar test around resume behaviour. A checkpoint should resume an agent from the last durable boundary, not rerun every completed tool. The fixture includes completed action IDs and verifies that only the next pending command is scheduled.

Cover the graph, not every possible conversation

It is impossible to enumerate every prompt and response an agent may see. It is practical to enumerate a bounded workflow graph. I want every state to be reachable intentionally, every non-terminal state to have an exit and every terminal state to reject further business commands.

A lightweight graph check can answer:

  • Is any state unreachable from the initial state?
  • Can a normal path reach at least one terminal state?
  • Does a waiting state have timeout, cancellation or escalation exits?
  • Can an approval-required action reach execution without approval?
  • Can a failure cycle continue without consuming a budget?
  • Does every effect-producing transition define replay behaviour?

This is not a formal proof of the entire application. It is a cheap way to catch structural omissions. The graph test found value for me long before a full end-to-end environment could: it moves questions about dead ends and bypasses into CI, where they are easier to fix.

Use a practical CI test pyramid

I split AI agent testing into layers so a failure points to a useful boundary:

  1. Transition unit tests: legal pairs, illegal pairs, guards, invariants and emitted domain events. These run on every commit.
  2. Persistence integration tests: optimistic locks, state plus outbox atomicity, replay and checkpoint loading against the real database engine.
  3. Adapter contract tests: provider schemas, idempotency headers, timeout classification and reconciliation responses.
  4. Model evaluations: recorded domain examples scored for extraction, classification, refusal and decision quality.
  5. Thin end-to-end tests: a few representative workflows through queues and adapters in a controlled environment.

The pyramid keeps the largest layer deterministic. If every case starts a browser, queue worker and live model, the suite becomes slow and flaky precisely when developers need fast feedback. If everything is mocked, integration defects survive. The layers let each test use the smallest environment that can prove its claim.

I do not use end-to-end pass rate as the only release signal. A green happy path says little about approval bypasses, retry exhaustion or duplicate delivery. Release confidence comes from knowing the transition contract is complete and the uncertain model operates inside it.

The checklist I use before shipping a new transition

  • Is the current-state and command pair explicitly allowed?
  • Are business, authorisation and artifact-version guards tested?
  • Does rejection leave state and side effects unchanged?
  • Are state, event and outbox intention stored consistently?
  • Is replay safe with the same operation key?
  • Does a stale worker lose rather than overwrite newer state?
  • Are timeout, retry exhaustion and intervention paths covered?
  • Can the run resume from its last durable checkpoint?

A model can surprise me; the workflow around it should not. By testing state transitions as deterministic contracts, I can improve prompts and models without surrendering the parts of the system that must remain explainable, replay-safe and under application control.

References

References checked on 13 October 2026. The code in this article is illustrative and intentionally omits persistence, authorisation and adapter details that depend on the application.

If you are moving an agent from prototype to production, I can help review the workflow boundaries, failure paths and test strategy. See my AI engineering consulting work or contact me.

Igor Gawrys
Igor Gawrys
AI Engineer & IT Consultant · Katowice, Poland