ProductHow it worksPricingBlogDocsLoginFind Your First Bug
In-app AI copilot under test: user intent, proposed action, and executed effect, with the assertion layer for each
TestingAICopilot Testing

How to Test an AI Copilot / In-App Assistant

Tom Piaggio
Tom PiaggioCo-Founder at Autonoma

How to test an AI copilot (an in-app assistant that proposes actions inside your product, not an IDE coding assistant) comes down to verifying three layers: the user's intent, the action the copilot proposes, and the effect the application actually executes. Most testing today stops at the first two, scoring whether the copilot's response sounds reasonable, and never checks whether the app performed the right action with the right side effects. Runnable coverage asserts on the resulting application state, not on the copilot's wording, and re-runs against a frozen scenario suite whenever the underlying model changes.

A support lead typed one sentence into her team's in-app assistant: reassign every open ticket from Jenna to Marcus, she's out sick today. The assistant replied with a clean summary of what it was about to do: five tickets, the right customer names, the right two agents. It read perfectly. The actual result: two of the five tickets landed on a third agent who shared Marcus's first name in a staging dataset seeded with lookalike names. Every review of that release read the copilot's reply and moved on. Nobody opened the ticket queue afterward to check what had actually happened there.

SaaS Product Copilots vs. IDE Coding Copilots

Two very different things get called "AI copilot," and only one of them is what this guide covers. An IDE coding copilot, GitHub Copilot, Cursor, Windsurf, autocompletes and suggests code inside a developer's editor. Testing that kind of tool is a genuinely different problem with a different audience, and it belongs to a different conversation entirely, not this one.

The copilot this guide is about lives inside your product, for your users, not inside an editor for developers. It's the assistant a support lead opens to say "filter this queue to last week's failed payments," the button a finance user clicks to have the copilot draft an invoice, the input box a support agent types into to reassign a batch of tickets. The user isn't writing code. They're stating an intent in plain language, and the product is expected to act on it correctly.

The Three Layers Every Copilot Test Has to Cover

Every in-app copilot interaction has three layers, and a test suite that only touches the first two is testing the model, not the product. Intent is what the user actually said or typed, in whatever words they chose. Proposed action is the structured decision the copilot makes in response to that intent, which action to take and with what parameters, not the sentence it writes describing that decision. Executed effect is what actually changes in the application once that action runs: a database row, a queue position, a ticket's owner field.

Most copilot testing today lives entirely at the boundary between the first two layers: does the model produce a plausible-sounding response to a given prompt. That's coverage of the model's language, not of the product. The layer that actually matters to the business, and the one most checklists skip, is the third: did the thing the copilot said would happen actually happen, correctly, in the running application. Testing AI agent tool calls covers the layer in between, whether the copilot picked the right action and arguments at all. This guide carries that correctness through to the app state it's supposed to produce.

That third layer is the whole reason Autonoma works the way it does. Evaluation tooling for AI features almost universally reads the model's output and scores it, which makes the first two layers cheap and the third one somebody else's problem. Autonoma runs behavioral end-to-end tests against your actual running application instead, so the assertion lands on the ticket queue rather than the sentence describing it. Everything below builds toward that assertion: first against a fake app you fully control, then against the real one.

1. User Intent"reassign Jenna's open tickets to Marcus"prose assertioncovers wording only2. Proposed Actionreassign_tickets(from=jenna, to=marcus)structured-action assertioncovers action + params3. Executed Effect5 tickets now owned by marcus in the storeapp-state assertionwhat this guide testsVerdict: Correct, Wrong, or Unauthorizedchecked against the resulting store, not the reply
A copilot test suite can pass every prose and action check and still miss the third layer entirely, because the third layer only exists once the action actually runs.

Suggestion-to-Action Correctness: Assert on What the App Did

The single most valuable test you can write for a copilot is boring by design: give it an intent, let it propose an action, execute that action against a real (or realistically faked) copy of your application, and assert on the resulting state. Not on the copilot's sentence. On the row that changed.

The harness needs three pieces mirroring the three layers above: an app state store standing in for your product's data, a copilot client that turns intent into a structured proposed action instead of prose, and an executor that applies the action and hands back a diff of what changed. Here's the state store, shaped like a real ticket queue with real users and roles:

"""In-memory application state store standing in for a real SaaS backend.

The copilot harness needs something that behaves like your product's data layer
without the cost of a database: no I/O, no network, no fixtures to tear down, so
every test in this repo is fast and deterministic.

The shape here is a support ticket queue, because that is the worked example in
the blog post: a support lead asks the in-app assistant to "reassign every open
ticket from Jenna to Marcus, she's out sick today", and the only honest way to
know whether that worked is to look at the tickets afterwards.
"""

from __future__ import annotations

import copy
from dataclasses import dataclass, field
from typing import Dict, List, Optional

# Roles used throughout the harness. Keep this list tiny on purpose: permission
# scoping is only meaningful if you can read the whole matrix at a glance.
ROLE_AGENT = "agent"
ROLE_BILLING_ADMIN = "billing_admin"

STATUS_OPEN = "open"
STATUS_CLOSED = "closed"


class TicketNotFoundError(KeyError):
    """Raised when an action references a ticket id that does not exist."""


class UserNotFoundError(KeyError):
    """Raised when an action references a user id that does not exist."""


class RefundAmountError(ValueError):
    """Raised when a refund is non-positive or exceeds what is refundable."""


@dataclass
class Ticket:
    """One row in the support queue."""

    id: str
    subject: str
    customer: str
    owner: str
    status: str = STATUS_OPEN
    category: str = "general"
    amount: float = 0.0
    refunded_amount: float = 0.0

    @property
    def refundable_amount(self) -> float:
        return round(self.amount - self.refunded_amount, 2)


@dataclass
class User:
    """A person driving the product (and therefore the copilot)."""

    id: str
    name: str
    role: str


@dataclass
class AppStateStore:
    """The only thing in this repo that owns mutable application state.

    ``snapshot()`` is the workhorse for tests: it returns a deep-copied,
    plain-data view of everything that matters, so a test can diff before and
    after an action, or assert that a rejected action changed literally nothing.
    """

    tickets: Dict[str, Ticket] = field(default_factory=dict)
    users: Dict[str, User] = field(default_factory=dict)
    balances: Dict[str, float] = field(default_factory=dict)
    audit_log: List[str] = field(default_factory=list)

    # ---- lookups -------------------------------------------------------

    def get_ticket(self, ticket_id: str) -> Ticket:
        try:
            return self.tickets[ticket_id]
        except KeyError:
            raise TicketNotFoundError(f"no such ticket: {ticket_id!r}") from None

    def get_user(self, user_id: str) -> User:
        try:
            return self.users[user_id]
        except KeyError:
            raise UserNotFoundError(f"no such user: {user_id!r}") from None

    def filter_queue(
        self,
        owner: Optional[str] = None,
        status: Optional[str] = None,
        category: Optional[str] = None,
    ) -> List[Ticket]:
        """Read-only query over the queue. Never mutates anything."""
        matches = [
            ticket
            for ticket in self.tickets.values()
            if (owner is None or ticket.owner == owner)
            and (status is None or ticket.status == status)
            and (category is None or ticket.category == category)
        ]
        return sorted(matches, key=lambda ticket: ticket.id)

    # ---- mutations -----------------------------------------------------

    def reassign_ticket(self, ticket_id: str, new_owner: str) -> Ticket:
        """Move a single ticket to a new owner."""
        ticket = self.get_ticket(ticket_id)
        self.get_user(new_owner)  # reassigning to a ghost is a bug, not a feature
        previous_owner = ticket.owner
        ticket.owner = new_owner
        self.audit_log.append(
            f"reassign_ticket {ticket_id}: {previous_owner} -> {new_owner}"
        )
        return ticket

    def issue_refund(self, ticket_id: str, amount: float, requested_by: str) -> Ticket:
        """Refund money against a ticket.

        This is a second, independent permission check. The executor in
        ``copilot_app/actions.py`` already refuses to dispatch a refund for a
        user without the ``billing_admin`` role, but the store refuses too:
        money-moving operations should not be one missing ``if`` away from
        happening by accident.
        """
        requester = self.get_user(requested_by)
        if requester.role != ROLE_BILLING_ADMIN:
            raise PermissionError(
                f"user {requested_by!r} has role {requester.role!r}; "
                f"refunds require {ROLE_BILLING_ADMIN!r}"
            )

        ticket = self.get_ticket(ticket_id)
        amount = round(float(amount), 2)
        if amount <= 0:
            raise RefundAmountError(f"refund amount must be positive, got {amount}")
        if amount > ticket.refundable_amount:
            raise RefundAmountError(
                f"refund of {amount} exceeds refundable {ticket.refundable_amount} "
                f"on ticket {ticket_id}"
            )

        ticket.refunded_amount = round(ticket.refunded_amount + amount, 2)
        self.balances[ticket.customer] = round(
            self.balances.get(ticket.customer, 0.0) + amount, 2
        )
        self.audit_log.append(
            f"issue_refund {ticket_id}: {amount} to {ticket.customer} by {requested_by}"
        )
        return ticket

    # ---- snapshots -----------------------------------------------------

    def snapshot(self) -> dict:
        """A deep-copied, comparable view of current state.

        The audit log is deliberately excluded. Two snapshots should compare
        equal when the *data* is identical, so that a test can say "this
        rejected action changed nothing" without the log defeating it, and so
        that undo can restore data without rewriting history.
        """
        return {
            "tickets": {
                ticket_id: copy.deepcopy(vars(ticket))
                for ticket_id, ticket in sorted(self.tickets.items())
            },
            "users": {
                user_id: copy.deepcopy(vars(user))
                for user_id, user in sorted(self.users.items())
            },
            "balances": dict(sorted(self.balances.items())),
        }

    def restore(self, snapshot: dict) -> None:
        """Reset data state to a previously captured snapshot.

        Used by ``undo_last_action``. This is a rollback, not an action: the
        executor stays the only place a *proposed action* touches state.
        """
        restored = copy.deepcopy(snapshot)
        self.tickets = {
            ticket_id: Ticket(**payload)
            for ticket_id, payload in restored["tickets"].items()
        }
        self.users = {
            user_id: User(**payload) for user_id, payload in restored["users"].items()
        }
        self.balances = dict(restored["balances"])
        self.audit_log.append("restore: state reverted to snapshot")


def seed_store() -> AppStateStore:
    """Build the fixture used by every test in this repo.

    Five open tickets owned by ``jenna`` (the batch the copilot is asked to
    reassign), plus one closed ticket she owns, which must be left alone. That
    closed ticket is not padding: it is how the correctness test proves the
    copilot's ``status: open`` parameter was actually honored.
    """
    users = [
        User(id="marcus", name="Marcus Hale", role=ROLE_AGENT),
        User(id="jenna", name="Jenna Ruiz", role=ROLE_AGENT),
        User(id="priya", name="Priya Nair", role=ROLE_BILLING_ADMIN),
    ]
    tickets = [
        Ticket(
            id="T-1040",
            subject="Old invoice question",
            customer="acme",
            owner="jenna",
            status=STATUS_CLOSED,
            category="billing",
            amount=0.0,
        ),
        Ticket(
            id="T-1041",
            subject="Card declined on renewal",
            customer="acme",
            owner="jenna",
            status=STATUS_OPEN,
            category="failed_payment",
            amount=49.00,
        ),
        Ticket(
            id="T-1042",
            subject="Duplicate charge last Friday",
            customer="bolt",
            owner="jenna",
            status=STATUS_OPEN,
            category="billing",
            amount=12.50,
        ),
        Ticket(
            id="T-1043",
            subject="Cannot reset password",
            customer="cove",
            owner="jenna",
            status=STATUS_OPEN,
            category="general",
            amount=0.0,
        ),
        Ticket(
            id="T-1044",
            subject="Payment retry keeps failing",
            customer="dune",
            owner="jenna",
            status=STATUS_OPEN,
            category="failed_payment",
            amount=49.00,
        ),
        Ticket(
            id="T-1045",
            subject="Charged twice for seats",
            customer="echo",
            owner="jenna",
            status=STATUS_OPEN,
            category="billing",
            amount=25.00,
        ),
    ]
    return AppStateStore(
        tickets={ticket.id: ticket for ticket in tickets},
        users={user.id: user for user in users},
        balances={ticket.customer: 0.0 for ticket in tickets},
    )

The copilot client wraps whatever model or provider you're using, but the wrapper's entire job is to force a decision out of it: an action name and a set of parameters, validated against a small registry of recognized actions, rather than accepting free text as the contract between the model and your application:

"""The copilot client: turns a plain-language intent into a structured decision.

The wrapper's entire job is to force a decision out of the model. Not prose. An
action name and a set of parameters, validated against a small registry of
actions your application actually supports.

That constraint is what makes the rest of this repo testable. If free text is
the contract between the model and your application, every test you write has to
parse English to find out what the copilot decided, and every such test is one
rephrasing away from a false pass.

The provider call is injected (``llm_call``), so nothing here is tied to a
specific SDK. Pass a fake for tests, pass a real HTTP call for the nightly
regression run, pass your own vendor's client in production.
"""

from __future__ import annotations

import json
from collections.abc import Mapping
from typing import Any, Callable, Dict, Iterable, Union

from pydantic import BaseModel, ConfigDict, Field, ValidationError, field_validator

from copilot_app.store import User

# The only actions this application recognizes. Anything else the model invents
# is rejected at the boundary rather than dispatched hopefully.
ACTIONS: tuple[str, ...] = ("reassign_tickets", "issue_refund", "filter_queue")

# A provider call takes a prompt and returns either a JSON string or an
# already-parsed mapping. Both are accepted so you can plug in a raw HTTP client
# or an SDK that parses for you.
LlmCall = Callable[[str], Union[str, Mapping[str, Any]]]

PROMPT_TEMPLATE = """You are the action-planning layer of an in-app assistant for a
support ticket queue. Choose exactly one action and return JSON only.

Allowed actions and parameters:
- reassign_tickets: {{"from": <owner id or null>, "to": <owner id>, "status": <"open"|"closed", optional>}}
- issue_refund: {{"ticket_id": <ticket id>, "amount": <number>}}
- filter_queue: {{"owner": <owner id, optional>, "status": <"open"|"closed", optional>, "category": <"failed_payment"|"billing"|"general", optional>}}

Known user ids: marcus, jenna, priya. Ticket ids look like T-1042.
Omit optional parameters entirely when the request does not mention them.

Respond with a single JSON object shaped like:
{{"action": "<one of {actions}>", "params": {{...}}, "rationale": "<one short sentence>", "confidence": <0.0-1.0>}}

REQUESTING_USER: {user_id} (role: {user_role})
INTENT: {intent}
"""


class CopilotProtocolError(ValueError):
    """The model returned something that is not a valid ProposedAction.

    Raised for malformed JSON, missing fields, an out-of-range confidence, or an
    action name outside the registry. A protocol error is a test failure, not
    something to recover from by guessing what the model meant.
    """


class ProposedAction(BaseModel):
    """Layer 2 of the three layers: the copilot's structured decision.

    ``rationale`` exists so a human can read why, and so a regression run can
    compare it loosely. It is never what a correctness test asserts on.
    """

    model_config = ConfigDict(extra="forbid")

    action: str
    params: Dict[str, Any] = Field(default_factory=dict)
    rationale: str = ""
    confidence: float = Field(default=0.0, ge=0.0, le=1.0)

    @field_validator("action")
    @classmethod
    def _action_must_be_registered(cls, value: str) -> str:
        if value not in ACTIONS:
            raise ValueError(
                f"unrecognized action {value!r}; allowed actions are {list(ACTIONS)}"
            )
        return value


class CopilotClient:
    """Wraps any model provider behind ``propose()``."""

    def __init__(
        self,
        llm_call: LlmCall,
        actions: Iterable[str] = ACTIONS,
        prompt_template: str = PROMPT_TEMPLATE,
    ) -> None:
        self._llm_call = llm_call
        self._actions = tuple(actions)
        self._prompt_template = prompt_template

    @property
    def actions(self) -> tuple[str, ...]:
        return self._actions

    def build_prompt(self, intent: str, user: User) -> str:
        return self._prompt_template.format(
            actions="|".join(self._actions),
            user_id=user.id,
            user_role=user.role,
            intent=intent,
        )

    def propose(self, intent: str, user: User) -> ProposedAction:
        """Ask the copilot what to do. Returns a decision, never prose."""
        raw = self._llm_call(self.build_prompt(intent, user))

        if isinstance(raw, (bytes, bytearray)):
            raw = raw.decode("utf-8")
        if isinstance(raw, str):
            try:
                raw = json.loads(_strip_code_fence(raw))
            except json.JSONDecodeError as exc:
                raise CopilotProtocolError(
                    f"copilot returned text that is not JSON: {raw[:400]!r}"
                ) from exc
        if not isinstance(raw, Mapping):
            raise CopilotProtocolError(
                f"copilot returned {type(raw).__name__}, expected a JSON object"
            )

        try:
            return ProposedAction.model_validate(dict(raw))
        except ValidationError as exc:
            raise CopilotProtocolError(
                f"copilot returned an invalid proposed action: {exc}"
            ) from exc


def _strip_code_fence(text: str) -> str:
    """Tolerate ```json fences, which most providers emit sooner or later."""
    stripped = text.strip()
    if not stripped.startswith("```"):
        return stripped
    lines = [line for line in stripped.splitlines() if not line.startswith("```")]
    return "\n".join(lines).strip()

The executor is where permission scoping belongs: a copilot must never propose or execute an action the current user couldn't perform through the normal UI. That's a different risk class from a merely wrong suggestion, because a permission bypass ships silently even when every other test passes:

"""The executor: the only place a proposed action is allowed to touch app state.

Two things make this file the load-bearing one in the harness.

First, permission scoping happens *before* dispatch. A copilot must never
execute an action the current user could not perform through the normal UI, and
a permission bypass is a different risk class from a merely wrong suggestion:
it ships silently even when every other test passes. So the check runs first and
raises before any handler is reached. Nothing is written, then rolled back.
Nothing is written at all.

Second, every successful execution returns an ``EffectDiff``: the before
snapshot, the after snapshot, and what ran. That diff is layer 3, the executed
effect, and it is the thing tests assert on instead of the copilot's wording.
"""

from __future__ import annotations

from dataclasses import dataclass, field
from typing import Any, Callable, Dict, List, Mapping

from copilot_app.copilot_client import ProposedAction
from copilot_app.store import AppStateStore, User

# The permission matrix, small enough to read in one glance and therefore small
# enough to review. An agent moves tickets around and filters the queue. Only a
# billing admin moves money.
PERMISSIONS: Mapping[str, frozenset] = {
    "agent": frozenset({"reassign_tickets", "filter_queue"}),
    "billing_admin": frozenset({"issue_refund"}),
}


class PermissionDeniedError(PermissionError):
    """The user's role does not include the proposed action.

    Subclasses the builtin ``PermissionError`` so callers that already handle
    permission failures generically keep working.
    """


class ActionDispatchError(ValueError):
    """The action is recognized and permitted but its parameters are unusable."""


@dataclass(frozen=True)
class EffectDiff:
    """What actually changed in the application, as plain comparable data."""

    action: str
    params: Dict[str, Any] = field(default_factory=dict)
    before: Dict[str, Any] = field(default_factory=dict)
    after: Dict[str, Any] = field(default_factory=dict)
    result: Any = None

    @property
    def changed(self) -> bool:
        return self.before != self.after

    def changed_ticket_ids(self) -> List[str]:
        """Ticket ids whose stored fields differ between before and after."""
        before_tickets = self.before.get("tickets", {})
        after_tickets = self.after.get("tickets", {})
        return sorted(
            ticket_id
            for ticket_id in set(before_tickets) | set(after_tickets)
            if before_tickets.get(ticket_id) != after_tickets.get(ticket_id)
        )


def permitted_actions(user: User) -> frozenset:
    return PERMISSIONS.get(user.role, frozenset())


def is_permitted(user: User, action: str) -> bool:
    return action in permitted_actions(user)


def execute_action(
    store: AppStateStore, action: ProposedAction, user: User
) -> EffectDiff:
    """Apply a proposed action to the store on behalf of ``user``.

    Raises ``PermissionDeniedError`` without touching the store when the action
    falls outside the user's own permissions, no matter how confident the
    copilot was when it proposed it.
    """
    before = store.snapshot()

    if not is_permitted(user, action.action):
        raise PermissionDeniedError(
            f"user {user.id!r} (role {user.role!r}) may not perform "
            f"{action.action!r}; permitted: {sorted(permitted_actions(user))}"
        )

    handler = _HANDLERS.get(action.action)
    if handler is None:  # pragma: no cover - registry and ACTIONS stay in sync
        raise ActionDispatchError(f"no handler registered for {action.action!r}")

    result = handler(store, dict(action.params), user)
    after = store.snapshot()

    return EffectDiff(
        action=action.action,
        params=dict(action.params),
        before=before,
        after=after,
        result=result,
    )


def _handle_reassign_tickets(
    store: AppStateStore, params: Dict[str, Any], user: User
) -> List[str]:
    new_owner = params.get("to")
    if not new_owner:
        raise ActionDispatchError("reassign_tickets requires a 'to' parameter")

    targets = store.filter_queue(owner=params.get("from"), status=params.get("status"))
    return [store.reassign_ticket(ticket.id, new_owner).id for ticket in targets]


def _handle_issue_refund(
    store: AppStateStore, params: Dict[str, Any], user: User
) -> str:
    ticket_id = params.get("ticket_id")
    amount = params.get("amount")
    if not ticket_id or amount is None:
        raise ActionDispatchError(
            "issue_refund requires 'ticket_id' and 'amount' parameters"
        )
    ticket = store.issue_refund(ticket_id, float(amount), requested_by=user.id)
    return ticket.id


def _handle_filter_queue(
    store: AppStateStore, params: Dict[str, Any], user: User
) -> List[str]:
    matches = store.filter_queue(
        owner=params.get("owner"),
        status=params.get("status"),
        category=params.get("category"),
    )
    return [ticket.id for ticket in matches]


_HANDLERS: Mapping[str, Callable[[AppStateStore, Dict[str, Any], User], Any]] = {
    "reassign_tickets": _handle_reassign_tickets,
    "issue_refund": _handle_issue_refund,
    "filter_queue": _handle_filter_queue,
}

With those three pieces in place, the correctness test reads almost like a spec: state the intent, run it through the copilot and the executor, then assert against the store instead of the copilot's reply. assert ticket.owner == "marcus" catches the exact bug the copilot's cheerful summary never would have:

"""Suggestion-to-action correctness: assert on what the app did.

The most valuable copilot test is boring by design. Give it an intent, let it
propose an action, execute that action against a realistic copy of the
application, and assert on the resulting state. Not on the copilot's sentence.
On the row that changed.

No network. The provider is the deterministic fake in
``copilot_app/fake_provider.py``, so these tests run in milliseconds and fail for
one reason only: the harness stopped doing the right thing to the store.
"""

from __future__ import annotations

import pytest

from copilot_app.actions import execute_action
from copilot_app.copilot_client import CopilotClient
from copilot_app.fake_provider import fake_llm_call
from copilot_app.store import seed_store

INTENT = "reassign every open ticket from Jenna to Marcus, she's out sick today"


@pytest.fixture
def store():
    return seed_store()


@pytest.fixture
def copilot():
    # The provider call is injected, so the same client class runs against a fake
    # here and against a real model in the nightly regression suite.
    return CopilotClient(llm_call=fake_llm_call)


def test_copilot_returns_a_structured_action_not_prose(store, copilot):
    proposed = copilot.propose(INTENT, store.users["marcus"])

    assert proposed.action == "reassign_tickets"
    assert proposed.params == {"from": "jenna", "to": "marcus", "status": "open"}


def test_executing_the_proposal_reassigns_every_open_jenna_ticket(store, copilot):
    marcus = store.users["marcus"]
    open_jenna_ticket_ids = [
        ticket.id for ticket in store.filter_queue(owner="jenna", status="open")
    ]
    assert len(open_jenna_ticket_ids) == 5, "fixture drift: expected 5 open tickets"

    proposed = copilot.propose(INTENT, marcus)
    diff = execute_action(store, proposed, marcus)

    # This is the assertion the copilot's cheerful summary can never make for
    # you. It reads the store, after the action ran, one ticket at a time.
    for ticket_id in open_jenna_ticket_ids:
        ticket = store.tickets[ticket_id]
        assert ticket.owner == "marcus"

    assert diff.changed
    assert diff.changed_ticket_ids() == sorted(open_jenna_ticket_ids)
    assert sorted(diff.result) == sorted(open_jenna_ticket_ids)


def test_the_closed_ticket_is_left_alone(store, copilot):
    marcus = store.users["marcus"]
    assert store.tickets["T-1040"].status == "closed"

    execute_action(store, copilot.propose(INTENT, marcus), marcus)

    # The copilot said "every open ticket". A run that also grabs the closed one
    # produces a plausible summary and the wrong queue.
    assert store.tickets["T-1040"].owner == "jenna"
    assert len(store.filter_queue(owner="marcus")) == 5
    assert len(store.filter_queue(owner="jenna")) == 1


def test_correctness_does_not_depend_on_the_rationale_text(store, copilot):
    marcus = store.users["marcus"]
    proposed = copilot.propose(INTENT, marcus)

    # Replace the copilot's explanation with something useless. The executed
    # effect, and therefore this suite, is unaffected. A prose assertion would
    # have broken here while the application behaved identically.
    rewritten = proposed.model_copy(update={"rationale": "Sure! All done. 🎉"})
    execute_action(store, rewritten, marcus)

    assert all(
        ticket.owner == "marcus"
        for ticket in store.filter_queue(status="open")
    )


def test_no_money_moved_and_no_ticket_was_refunded(store, copilot):
    marcus = store.users["marcus"]
    execute_action(store, copilot.propose(INTENT, marcus), marcus)

    assert all(balance == 0.0 for balance in store.balances.values())
    assert all(ticket.refunded_amount == 0.0 for ticket in store.tickets.values())

The permission boundary gets its own test: an agent-role user asking the copilot to issue a refund should produce a rejected proposal, and the assertion needs to prove the store never moved, not just that an error was raised somewhere:

"""Permission scoping: a copilot must not become a privilege escalation path.

A wrong suggestion is a correctness bug. An action the requesting user could not
have performed through the normal UI is a different risk class, because it ships
silently even when every other test passes.

The assertion that matters here is not "an error was raised". It is "the store
never moved". A guard that raises after writing the row has not protected
anything, and only a before/after snapshot comparison can tell the difference.
"""

from __future__ import annotations

import pytest

from copilot_app.actions import PermissionDeniedError, execute_action, permitted_actions
from copilot_app.copilot_client import CopilotClient
from copilot_app.fake_provider import fake_llm_call
from copilot_app.store import seed_store

REFUND_INTENT = "issue a 49.00 refund on ticket T-1044, the customer was double charged"


@pytest.fixture
def store():
    return seed_store()


@pytest.fixture
def copilot():
    return CopilotClient(llm_call=fake_llm_call)


def test_the_copilot_happily_proposes_a_refund_to_an_agent(store, copilot):
    # The model is not the guardrail. It has no idea what this user may do, and
    # designing the test as though it did would hide the real bug.
    proposed = copilot.propose(REFUND_INTENT, store.users["marcus"])

    assert proposed.action == "issue_refund"
    assert proposed.params == {"ticket_id": "T-1044", "amount": 49.0}


def test_agent_cannot_execute_a_refund_and_the_store_never_moves(store, copilot):
    marcus = store.users["marcus"]
    assert "issue_refund" not in permitted_actions(marcus)

    proposed = copilot.propose(REFUND_INTENT, marcus)
    before = store.snapshot()

    with pytest.raises(PermissionDeniedError):
        execute_action(store, proposed, marcus)

    # The whole point. Not "an exception happened somewhere" but "no ticket, no
    # balance, and no owner field is different from before the attempt".
    assert store.snapshot() == before
    assert store.tickets["T-1044"].refunded_amount == 0.0
    assert store.balances["dune"] == 0.0


def test_billing_admin_executes_the_same_refund_successfully(store, copilot):
    priya = store.users["priya"]
    proposed = copilot.propose(REFUND_INTENT, priya)

    diff = execute_action(store, proposed, priya)

    assert diff.changed
    assert store.tickets["T-1044"].refunded_amount == 49.0
    assert store.tickets["T-1044"].refundable_amount == 0.0
    assert store.balances["dune"] == 49.0


def test_billing_admin_cannot_reassign_tickets(store, copilot):
    # Scoping cuts both ways. A billing admin holds the refund permission and
    # not the queue-management one.
    priya = store.users["priya"]
    proposed = copilot.propose(
        "reassign every open ticket from jenna to marcus", priya
    )
    before = store.snapshot()

    with pytest.raises(PermissionDeniedError):
        execute_action(store, proposed, priya)

    assert store.snapshot() == before


def test_agent_keeps_the_permissions_it_does_have(store, copilot):
    marcus = store.users["marcus"]
    proposed = copilot.propose(
        "reassign every open ticket from jenna to marcus", marcus
    )

    execute_action(store, proposed, marcus)

    assert len(store.filter_queue(owner="marcus", status="open")) == 5


def test_the_store_refuses_a_direct_refund_by_an_agent_too(store):
    # Defense in depth: even if a future caller bypasses the executor, the
    # money-moving method checks the requester's role itself.
    with pytest.raises(PermissionError):
        store.issue_refund("T-1044", 49.0, requested_by="marcus")

    assert store.tickets["T-1044"].refunded_amount == 0.0

Copilot Regression Testing: When the Model Changes and Nobody Deployed Anything

The failure mode that makes copilot testing genuinely different from testing a conventional feature is this: the model provider ships an update on their schedule, not yours. No pull request, no deploy, no changelog entry in your own repo, and the copilot that used to propose "reassign to Marcus" for a given intent now proposes "escalate to manager" for the exact same words, because the model's decision boundary quietly moved.

The fix is a frozen scenario suite: a fixed list of intents, each paired with the exact action and parameters it's supposed to produce, checked in as reviewable data instead of regenerated on the fly. It's the same discipline behind testing an AI agent more broadly: freeze the scenario, assert on the decision, not on how it's phrased. A regression runner replays every scenario against the live copilot on a schedule, nightly is usually enough, and diffs the selected action against the frozen expectation.

Because a single run can be noisy, the runner repeats each scenario N times and gates on a pass threshold. Semantic similarity is the right tolerance for the copilot's explanatory prose. The action name and its parameters get an exact match, because "close enough" on which button got pressed isn't close enough when the button reassigns five tickets or issues a refund.

A frozen suite has one predictable failure mode of its own: it freezes. Every product change that adds an action, renames a parameter, or narrows a permission leaves the file a little further behind the application, and a scenario that no longer matches reality reads as coverage while asserting last quarter's behavior. This is the maintenance problem Autonoma's Diffs Agent is pointed at. It reads each pull request's diff and updates the test suite alongside the code, so the suite tracks the product instead of drifting away from it between the quarterly cleanups nobody schedules. Whether you automate that or keep it manual, budget for it: an unmaintained copilot suite goes stale faster than almost anything else in a codebase, because the thing it describes changes on two schedules, yours and the model provider's.

Here's the frozen suite itself, plain data so a reviewer can read every expected outcome without touching test code:

"""The frozen scenario suite: checked-in data, not generated at runtime.

This file is the contract between your product and whatever model is behind the
copilot today. Each record pairs an intent with the exact action and parameters
it must produce. It is deliberately plain Python data so a support lead or a PM
can read every expected outcome without opening a test file.

Two rules for editing it:

1. Changing an expectation is a product decision, so it belongs in a pull
   request with a reason. Never edit a scenario to make a failing nightly run go
   green.
2. A scenario that no longer matches how the product should behave is worse than
   no test at all, because it looks like coverage while checking last quarter's
   assumptions. Delete it or fix it.

``expected_rationale`` is compared loosely (semantic similarity). Only
``expected_action`` and ``expected_params`` are matched exactly.
"""

from __future__ import annotations

from dataclasses import dataclass, field
from typing import Any, Dict, Tuple


@dataclass(frozen=True)
class Scenario:
    """One frozen intent-to-action expectation."""

    id: str
    intent: str
    user_id: str
    role: str
    expected_action: str
    expected_params: Dict[str, Any] = field(default_factory=dict)
    expected_rationale: str = ""
    # True when the copilot is expected to propose this action and the executor
    # is expected to refuse it. Proposing a refund is not a bug; executing one
    # without the billing_admin role is.
    expect_permission_denied: bool = False
    # Part of the fast pull-request smoke set.
    smoke: bool = False
    notes: str = ""


FROZEN_SCENARIOS: Tuple[Scenario, ...] = (
    Scenario(
        id="reassign-open-tickets-from-jenna",
        intent="reassign every open ticket from Jenna to Marcus, she's out sick today",
        user_id="marcus",
        role="agent",
        expected_action="reassign_tickets",
        expected_params={"from": "jenna", "to": "marcus", "status": "open"},
        expected_rationale="Reassigning every open ticket owned by jenna to marcus.",
        smoke=True,
        notes="The worked example from the blog post. Must not touch closed tickets.",
    ),
    Scenario(
        id="reassign-same-request-different-words",
        intent="hand jenna's open tickets to marcus",
        user_id="marcus",
        role="agent",
        expected_action="reassign_tickets",
        expected_params={"from": "jenna", "to": "marcus", "status": "open"},
        expected_rationale="Reassigning every open ticket owned by jenna to marcus.",
        smoke=True,
        notes="Paraphrase of the scenario above. Same decision, different wording.",
    ),
    Scenario(
        id="refund-as-billing-admin",
        intent="issue a 49.00 refund on ticket T-1044, the customer was double charged",
        user_id="priya",
        role="billing_admin",
        expected_action="issue_refund",
        expected_params={"ticket_id": "T-1044", "amount": 49.0},
        expected_rationale="Refunding 49.00 against ticket T-1044 as requested.",
        smoke=True,
        notes="Full refund of the ticket amount by the one role allowed to move money.",
    ),
    Scenario(
        id="refund-as-agent-is-denied",
        intent="issue a 49.00 refund on ticket T-1044, the customer was double charged",
        user_id="marcus",
        role="agent",
        expected_action="issue_refund",
        expected_params={"ticket_id": "T-1044", "amount": 49.0},
        expected_rationale="Refunding 49.00 against ticket T-1044 as requested.",
        expect_permission_denied=True,
        smoke=True,
        notes=(
            "Identical intent to the scenario above, different role. The copilot is "
            "expected to propose the refund; the executor is expected to refuse it "
            "without touching the store."
        ),
    ),
    Scenario(
        id="refund-partial-amount",
        intent="refund 12.50 on ticket T-1045 for the duplicate charge",
        user_id="priya",
        role="billing_admin",
        expected_action="issue_refund",
        expected_params={"ticket_id": "T-1045", "amount": 12.5},
        expected_rationale="Refunding 12.50 against ticket T-1045 as requested.",
        notes="Partial refund: T-1045 is a 25.00 ticket.",
    ),
    Scenario(
        id="filter-open-failed-payments",
        intent="filter this queue to open failed payments",
        user_id="marcus",
        role="agent",
        expected_action="filter_queue",
        expected_params={"status": "open", "category": "failed_payment"},
        expected_rationale=(
            "Filtering the ticket queue to category=failed_payment, status=open."
        ),
        smoke=True,
        notes="Read-only action. Must not mutate the store.",
    ),
    Scenario(
        id="filter-jennas-open-queue",
        intent="show me everything still open in jenna's queue",
        user_id="marcus",
        role="agent",
        expected_action="filter_queue",
        expected_params={"owner": "jenna", "status": "open"},
        expected_rationale="Filtering the ticket queue to owner=jenna, status=open.",
        notes="Owner scoping comes from a possessive, not from an explicit field.",
    ),
)


def smoke_scenarios() -> Tuple[Scenario, ...]:
    """The subset the pull-request job runs, at a single repetition."""
    return tuple(scenario for scenario in FROZEN_SCENARIOS if scenario.smoke)


def scenario_by_id(scenario_id: str) -> Scenario:
    for scenario in FROZEN_SCENARIOS:
        if scenario.id == scenario_id:
            return scenario
    raise KeyError(f"no frozen scenario with id {scenario_id!r}")

And the runner that replays it against the live copilot, repeats each scenario for the non-determinism gate, and fails loud with a labeled diff the moment a provider-side model update quietly changes which action gets chosen:

"""Model-change regression: replay the frozen suite against the live copilot.

The failure mode that makes copilot testing different from testing a
conventional feature: the model provider ships an update on their schedule, not
yours. No pull request, no deploy, no changelog entry in your repo. The copilot
that used to propose "reassign to marcus" for a given intent now proposes
something else for the exact same words, because the decision boundary moved.

So this suite makes real provider calls, and it is built for a nightly cron
rather than for every commit:

* Each scenario runs ``REPETITIONS`` times (default 5) and needs
  ``PASS_THRESHOLD`` matches (default 4) to pass. One sample of a
  non-deterministic system is a coin flip, not a result.
* ``expected_action`` and ``expected_params`` are matched exactly. "Close
  enough" on which button got pressed is not close enough when the button
  reassigns five tickets or issues a refund.
* ``expected_rationale`` is compared with a loose similarity floor, because it is
  prose written for a human and rewording it is not a regression.
* A failure prints the frozen expectation next to what the copilot actually
  proposed, per attempt, so the diff is readable without a rerun.

With no provider credentials configured, the whole module skips instead of
failing, so a contributor can still run everything else in this repo.
"""

from __future__ import annotations

import json
import os
from dataclasses import dataclass, field
from typing import Any, Dict, List, Optional

import pytest

from copilot_app.copilot_client import CopilotClient, CopilotProtocolError
from copilot_app.providers import build_live_llm_call, live_provider_configured
from copilot_app.similarity import DEFAULT_SIMILARITY_FLOOR, semantic_similarity
from copilot_app.store import seed_store
from scenarios.frozen_scenarios import FROZEN_SCENARIOS, Scenario

REPETITIONS = int(os.environ.get("COPILOT_REGRESSION_REPETITIONS", "5"))
PASS_THRESHOLD = int(os.environ.get("COPILOT_REGRESSION_PASS_THRESHOLD", "4"))
SIMILARITY_FLOOR = float(
    os.environ.get("COPILOT_RATIONALE_SIMILARITY_FLOOR", DEFAULT_SIMILARITY_FLOOR)
)

pytestmark = [
    pytest.mark.nightly,
    pytest.mark.skipif(
        not live_provider_configured(),
        reason=(
            "live provider not configured; set COPILOT_LLM_ENDPOINT and "
            "COPILOT_LLM_API_KEY to run the model-change regression suite"
        ),
    ),
]


@dataclass
class Attempt:
    """One replay of one scenario."""

    number: int
    action: Optional[str] = None
    params: Optional[Dict[str, Any]] = None
    rationale: str = ""
    rationale_similarity: float = 0.0
    error: Optional[str] = None
    mismatch_reasons: List[str] = field(default_factory=list)

    @property
    def matched(self) -> bool:
        return self.error is None and not self.mismatch_reasons


@pytest.fixture(scope="module")
def live_client() -> CopilotClient:
    return CopilotClient(llm_call=build_live_llm_call())


@pytest.mark.parametrize(
    "scenario", FROZEN_SCENARIOS, ids=[scenario.id for scenario in FROZEN_SCENARIOS]
)
def test_frozen_scenario_still_produces_the_same_action(
    scenario: Scenario, live_client: CopilotClient
) -> None:
    attempts = [
        _replay(scenario, live_client, attempt_number)
        for attempt_number in range(1, REPETITIONS + 1)
    ]
    matches = sum(1 for attempt in attempts if attempt.matched)

    if matches < PASS_THRESHOLD:
        pytest.fail(_report(scenario, attempts, matches), pytrace=False)


def _replay(scenario: Scenario, client: CopilotClient, attempt_number: int) -> Attempt:
    store = seed_store()  # a fresh store per attempt: no cross-attempt bleed
    user = store.get_user(scenario.user_id)
    assert user.role == scenario.role, (
        f"scenario {scenario.id!r} claims role {scenario.role!r} but user "
        f"{scenario.user_id!r} has role {user.role!r}"
    )

    attempt = Attempt(number=attempt_number)
    try:
        proposed = client.propose(scenario.intent, user)
    except CopilotProtocolError as exc:
        attempt.error = f"protocol error: {exc}"
        return attempt
    except Exception as exc:  # provider transport failures count as attempts
        attempt.error = f"{type(exc).__name__}: {exc}"
        return attempt

    attempt.action = proposed.action
    attempt.params = dict(proposed.params)
    attempt.rationale = proposed.rationale
    attempt.rationale_similarity = semantic_similarity(
        proposed.rationale, scenario.expected_rationale
    )

    # Exact on the decision.
    if proposed.action != scenario.expected_action:
        attempt.mismatch_reasons.append("action")
    if dict(proposed.params) != dict(scenario.expected_params):
        attempt.mismatch_reasons.append("params")
    # Loose on the prose.
    if attempt.rationale_similarity < SIMILARITY_FLOOR:
        attempt.mismatch_reasons.append("rationale")

    return attempt


def _report(scenario: Scenario, attempts: List[Attempt], matches: int) -> str:
    lines = [
        f"frozen scenario {scenario.id!r} regressed: "
        f"{matches}/{len(attempts)} matching runs, threshold {PASS_THRESHOLD}",
        "",
        f"intent          : {scenario.intent}",
        f"requesting user : {scenario.user_id} (role {scenario.role})",
        "",
        "FROZEN EXPECTATION",
        f"  action    : {scenario.expected_action}",
        f"  params    : {json.dumps(scenario.expected_params, sort_keys=True)}",
        f"  rationale : {scenario.expected_rationale}",
        f"              (compared loosely, similarity floor {SIMILARITY_FLOOR})",
        "",
        "WHAT THE COPILOT ACTUALLY PROPOSED",
    ]
    for attempt in attempts:
        verdict = "match" if attempt.matched else "MISMATCH"
        lines.append(f"  attempt {attempt.number} [{verdict}]")
        if attempt.error:
            lines.append(f"    error     : {attempt.error}")
            continue
        lines.append(f"    action    : {attempt.action}")
        lines.append(f"    params    : {json.dumps(attempt.params, sort_keys=True)}")
        lines.append(
            f"    rationale : {attempt.rationale!r} "
            f"(similarity {attempt.rationale_similarity})"
        )
        if attempt.mismatch_reasons:
            lines.append(f"    differs on: {', '.join(attempt.mismatch_reasons)}")
    if scenario.notes:
        lines += ["", f"note: {scenario.notes}"]
    return "\n".join(lines)

The Full Assistant Flow, Behaviorally

Everything above tests the copilot's decision in isolation. The flow a real user experiences is longer: open the assistant, type an intent, watch the copilot propose an action, confirm it, watch the application mutate its own state, watch the UI reflect what changed, and, if something's wrong, undo it. A suite that only exercises the copilot in isolation never touches the confirm-and-undo boundary, and that boundary is where a genuinely different risk class lives. A copilot that executes an action silently, with no confirmation step, is riskier than one that proposes and waits, because a wrong proposal caught at confirmation costs a click, and a wrong action executed unconfirmed costs a support ticket, or worse, a customer's money.

scope: one behavioral end-to-end test spans every stage below1. Open the Assistant2. Type Intent (plain language)3. Copilot Proposes an Actionriskboundary4. User Confirms5. App Mutates State6. UI Reflects the Change7. Undo Reverts the Change
Confirm is a risk boundary, not just a UI step. Everything after it is a mutation the test needs to verify, and undo needs its own assertion, not an assumption.

Here's a behavioral test that drives the whole flow: opens the assistant, submits the intent, receives the proposed action, confirms it explicitly rather than letting it execute automatically, checks the resulting store state and a simulated UI projection, and then exercises undo to confirm the app can actually reverse what it just did:

"""The full assistant flow, behaviorally: propose, confirm, execute, render, undo.

Everything else in this repo tests the copilot's decision in isolation. This
drives the sequence a real user actually walks through, and asserts on the two
boundaries that isolated tests never reach.

Confirm: a copilot that executes silently is riskier than one that proposes and
waits, because a wrong proposal caught at confirmation costs a click and a wrong
action executed unconfirmed costs a support ticket, or a customer's money. So
this test proves nothing runs before ``confirmed=True``.

Undo: the reversal gets its own assertion, against both the store and the queue
view, because "we call restore" and "the user sees the old queue again" are two
different claims.
"""

from __future__ import annotations

import pytest

from copilot_app.copilot_client import CopilotClient
from copilot_app.fake_provider import fake_llm_call
from copilot_app.session import (
    AssistantSession,
    ConfirmationRequiredError,
    NothingToUndoError,
)
from copilot_app.store import seed_store
from copilot_app.ui import render_ticket_queue

INTENT = "reassign every open ticket from Jenna to Marcus, she's out sick today"


@pytest.fixture
def store():
    return seed_store()


@pytest.fixture
def session(store):
    return AssistantSession(
        store=store,
        client=CopilotClient(llm_call=fake_llm_call),
        user=store.users["marcus"],
    )


def test_full_flow_from_intent_through_confirmation_to_undo(store, session):
    baseline = store.snapshot()
    opening_view = render_ticket_queue(store)
    assert opening_view.owner_counts == {"jenna": 6}

    # 1-2. The user opens the assistant and types an intent.
    proposed = session.submit_intent(INTENT)
    assert proposed.action == "reassign_tickets"
    assert proposed.params == {"from": "jenna", "to": "marcus", "status": "open"}

    # 3. A proposal is not an execution. Submitting the intent must not have
    #    changed a single field.
    assert store.snapshot() == baseline

    # 4. Confirm is the risk boundary. Without it, nothing runs.
    with pytest.raises(ConfirmationRequiredError):
        session.execute()
    assert store.snapshot() == baseline
    assert session.pending_action is proposed

    # 5. Confirmed. Now the app mutates state.
    diff = session.execute(confirmed=True)
    assert diff.changed
    assert len(diff.changed_ticket_ids()) == 5
    for ticket in store.filter_queue(status="open"):
        assert ticket.owner == "marcus"

    # 6. The UI reflects the change. Same data, projected the way a user reads it.
    view_after = render_ticket_queue(store)
    assert view_after.owner_counts == {"marcus": 5, "jenna": 1}
    assert view_after.row("T-1041")["assigned_to"] == "marcus"
    assert view_after.row("T-1041")["assigned_to_name"] == "Marcus Hale"
    assert view_after.row("T-1040")["assigned_to"] == "jenna"

    # 7. Undo reverts the change, in the store and in what the queue renders.
    assert session.can_undo
    undone = session.undo_last_action()
    assert undone.action == "reassign_tickets"
    assert store.snapshot() == baseline

    view_restored = render_ticket_queue(store)
    assert view_restored.owner_counts == {"jenna": 6}
    assert view_restored.owner_counts.get("marcus", 0) == 0
    assert view_restored.rows == opening_view.rows


def test_the_pending_proposal_is_cleared_once_executed(store, session):
    session.submit_intent(INTENT)
    session.execute(confirmed=True)

    assert session.pending_action is None
    assert len(session.executed) == 1


def test_a_truthy_value_is_not_a_confirmation(store, session):
    # ``confirmed`` is checked with ``is True``. A stray "yes" from a form field
    # is not a confirmation, and a coerced truthy value is exactly how an
    # unconfirmed action reaches production.
    baseline = store.snapshot()
    session.submit_intent(INTENT)

    with pytest.raises(ConfirmationRequiredError):
        session.execute(confirmed="yes")

    assert store.snapshot() == baseline


def test_undo_with_nothing_executed_raises(store, session):
    session.submit_intent(INTENT)

    with pytest.raises(NothingToUndoError):
        session.undo_last_action()


def test_undo_is_recorded_in_the_audit_log_rather_than_erasing_it(store, session):
    session.submit_intent(INTENT)
    session.execute(confirmed=True)
    session.undo_last_action()

    # Data state is reverted; the trail of what happened is not.
    assert any("reassign_ticket" in entry for entry in store.audit_log)
    assert store.audit_log[-1].startswith("restore:")

This is the test that would have caught the tickets-reassigned-to-the-wrong-Marcus failure from the opening example, because it checks the store and the UI projection the user actually sees, not the copilot's summary of what it intended to do.

Note what the harness is standing in for, though. The store is a fake, the UI projection is a dictionary, and undo is a method call. That's the right trade for a test that has to run in under a second on every commit, and it's also why this test would still pass if your real confirm dialog swallowed the click, or your real undo endpoint returned 200 without reverting anything. Running the same flow against the deployed application is what closes that gap, and it's the layer Autonoma covers: real browser, real backend, real database state, with the assertion still landing on the ticket's owner field rather than on the copilot's reply.

What Each Assertion Layer Catches (and Misses)

Assertion LayerCatchesMisses
Prose / semantic assertionBad tone, hallucinated factsCorrect-sounding wrong action
Structured-action assertionWrong action or parametersWhether execution ever applied
App-state assertionWrong data change, missed side effectConfirm or undo UI behavior
Full-flow behavioral E2EBroken confirm, broken undo, UI driftLittle; it spans the whole surface

None of these replaces the others. A prose check still catches a hallucinated fact or the wrong tone with a customer. It just can't be the only layer, because it never looks at what the application did.

Gating CI on the Frozen Suite

The frozen scenario suite earns its keep in CI, not just in a nightly cron job. A workflow that runs a smoke set, a handful of the highest-value scenarios plus the permission-scoping cases, on every pull request catches an obvious regression before merge without making every commit wait on a full sweep of real provider calls. The full sweep, every frozen scenario, full repetition count for the non-determinism gate included, runs on the same nightly schedule as the regression runner above:

name: copilot-tests

# Two jobs, two very different costs.
#
# pr-smoke gates every pull request. It is deterministic, offline, and finishes
# in seconds, because nobody's pull request should sit behind a dozen real model
# calls.
#
# nightly-full makes real provider calls and replays the whole frozen suite at
# full repetition. It exists to catch the thing a pull request cannot: a
# provider-side model update that changed which action the copilot picks, with no
# commit anywhere in this repository.

on:
  pull_request:
  schedule:
    # 03:17 UTC daily. An odd minute so it is not queued alongside every other
    # cron job in the world.
    - cron: "17 3 * * *"

permissions:
  contents: read

jobs:
  pr-smoke:
    name: PR smoke (offline, deterministic)
    if: github.event_name == 'pull_request'
    runs-on: ubuntu-latest
    timeout-minutes: 10
    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-python@v5
        with:
          python-version: "3.11"
          cache: pip

      - name: Install dependencies
        run: |
          python -m pip install --upgrade pip
          pip install -r requirements.txt

      - name: Suggestion-to-action correctness, permissions, and the full flow
        run: |
          pytest -v \
            tests/test_suggestion_to_action.py \
            tests/test_permission_scoping.py \
            tests/test_e2e_assistant_flow.py

      - name: Frozen scenario smoke set (single repetition, fake provider)
        run: pytest -v -m smoke tests/test_frozen_scenarios_smoke.py

  nightly-full:
    name: Nightly full regression (real provider)
    if: github.event_name == 'schedule'
    runs-on: ubuntu-latest
    timeout-minutes: 30
    env:
      # Vendor-neutral: point these at any OpenAI-compatible endpoint, or swap
      # copilot_app/providers.py for your own provider call.
      COPILOT_LLM_ENDPOINT: ${{ secrets.COPILOT_LLM_ENDPOINT }}
      COPILOT_LLM_API_KEY: ${{ secrets.COPILOT_LLM_API_KEY }}
      COPILOT_LLM_MODEL: ${{ vars.COPILOT_LLM_MODEL }}
      # The non-determinism gate: repeat each scenario and require most runs to
      # match the frozen expectation.
      COPILOT_REGRESSION_REPETITIONS: "5"
      COPILOT_REGRESSION_PASS_THRESHOLD: "4"
    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-python@v5
        with:
          python-version: "3.11"
          cache: pip

      - name: Install dependencies
        run: |
          python -m pip install --upgrade pip
          pip install -r requirements.txt

      - name: Fail fast if the provider is not configured
        # Without this, the suite would skip and the job would go green, which is
        # the worst possible outcome for a nightly regression gate.
        run: |
          if [ -z "$COPILOT_LLM_API_KEY" ] || [ -z "$COPILOT_LLM_ENDPOINT" ]; then
            echo "::error::COPILOT_LLM_ENDPOINT and COPILOT_LLM_API_KEY must be set for the nightly run"
            exit 1
          fi

      - name: Replay every frozen scenario against the live copilot
        run: pytest -v -m nightly tests/test_model_change_regression.py

A genuine regression, whether it came from your own code or from a provider's silent model update, surfaces within a day. Nobody's pull request sits behind a five-minute wait for a dozen real model calls to finish first.

The Layer Nothing Above Tests

Every layer above verifies a slice of the copilot in isolation, against a fake or partial copy of the application. None of it proves the real, deployed app behaves correctly when a real user drives it through the real production UI. That's the honest boundary of a harness built like this one, and it's also exactly the gap Autonoma exists to close: running behavioral end-to-end tests against your actual running application, so the question this guide has been circling, did the app actually do what the copilot said it would, gets answered against the real thing instead of a fixture.

Build the harness above first. It's cheap, it's fast, and it catches most of what breaks. Then treat the frozen suite as a living contract: a scenario that stops matching the product's actual behavior is worse than no test at all, because it looks like coverage while checking last quarter's assumptions. And put Autonoma underneath both, so the layer that decides whether the tickets actually moved runs against your real application on every pull request instead of against a store you wrote yourself. The support lead from the opening of this piece didn't need a better-worded reply. She needed someone to open the queue afterward, and that is the entire job.

Frequently Asked Questions

Don't evaluate the suggestion by reading it. Evaluate it by executing the proposed action against a real or realistically faked copy of your application and asserting on the resulting state: the ticket owner, the queue position, the balance. A copilot's response can read perfectly and still trigger the wrong action, so the wording is a weak signal and the resulting app state is the strong one.

Copilot regression testing is replaying a frozen suite of intent-to-action scenarios against the live copilot on a schedule, usually nightly, and diffing the action it selects against a checked-in expected outcome. It exists because model providers ship updates on their own schedule, with no deploy and no changelog entry in your repo, and a copilot can silently start choosing a different action for the same words the day after a provider-side model update ships.

Run a small, deterministic smoke set, the highest-value scenarios plus permission-scoping cases, on every pull request, since it's fast and makes real model calls sparingly. Run the full frozen suite, every scenario, full repetition count for the non-determinism gate, on a nightly schedule instead, since it makes real provider calls and is meaningfully slower and more expensive to run on every commit.

Drive the entire flow behaviorally: open the assistant, submit a plain-language intent, receive the proposed action, confirm it explicitly rather than letting it execute automatically, assert on the resulting application state and what the UI actually renders, then exercise undo and confirm the app can reverse what it just did. Testing only the copilot's proposed action in isolation skips the confirm and undo boundary, which is a distinct risk class from a merely wrong suggestion.

It catches most of what breaks and belongs in every suite, because a fake store runs in milliseconds on every commit and lets you force cases a real backend won't reproduce on demand. What it cannot prove is that the deployed product behaves the same way. A confirm dialog that swallows the click, or an undo endpoint that returns 200 without actually reverting anything, passes every test in a harness like the one in this article, because the harness never touches either. Closing that gap means driving the same intent through the real running application, which is what Autonoma does: behavioral end-to-end tests against your actual app, with the assertion landing on the ticket's owner field in the real database rather than on a fixture you wrote yourself.

Related articles

A horizontal agent trajectory diagram showing a tool call passing a right-tool checkpoint but failing an argument-accuracy checkpoint

How to Test AI Agents That Take Actions (Tool Calls)

A runnable guide to testing tool-calling agents: right tool, right order, right arguments, mocked vs live calls, failure handling, and non-determinism.

A chatbot test pipeline moving from manual QA through scripted and semantic assertions into an automated CI gate that samples the model N times before allowing a merge

Chatbot Automation Testing: Why Assertions Fail

Chatbot automation testing that survives non-deterministic replies: the migration to a CI gate, n-run sampling, threshold gating, and real GitHub Actions YAML.

Ghost Inspector alternative concept: Quara the frog beside a cracked recorded-test snapshot next to a regenerating test path

Ghost Inspector Alternative: Recorder, Framework, or AI?

Looking for a Ghost Inspector alternative? Compare record-and-playback SaaS, code frameworks, and AI-agent-generated testing by approach, not just by tool.

Diagram showing AI-generated auth code without a baseline: an agent writes login code on one side, while expected auth behavior (valid login, rejected password, protected route redirect) must be defined explicitly on the other

How to Test the Auth Code an AI Agent Wrote

When an AI agent writes your authentication, there is no baseline for correct behavior. Here is how to test AI-generated code for the auth bugs that compile, pass review, and lock users out.