Skip to content

Test your agent

A test needs no API key and no network. FakeModel replaces the model and returns prepared replies in order.

A test

from pathlib import Path

from alpineagents import Agent, Message, State, StoppedByUntil, tool
from alpineagents.testing import FakeModel, tool_call


@tool
def read_file(path: str) -> str:
    """Read a file"""
    return Path(path).read_text()


agent = Agent(model="claude-sonnet-5", tools=[read_file])


def test_reads_the_file_then_answers(tmp_path, monkeypatch):
    monkeypatch.chdir(tmp_path)
    Path("main.py").write_text("x = 1 +\n")
    fake = FakeModel([
        tool_call("read_file", path="main.py"),
        "Line 1 has a syntax error",
    ])
    state = State(messages=[Message.user("Find the bug in main.py")])

    agent.copy(model=fake, reporter=None).run(state)

    assert state.answer == "Line 1 has a syntax error"
    assert state.stopped == StoppedByUntil("waiting_for_user")
    assert fake.remaining == 0
  1. FakeModel gets two replies: a call to read_file, then the answer.
  2. agent.copy(model=fake, reporter=None) is a new Agent with the same settings, a fake model and no terminal output. The Agent in your code stays as it is.
  3. The assertions check the answer, why the run stopped (see waiting_for_user), and that every reply was used.

Replies FakeModel accepts

Every model request takes the next item. When the items run out, the request raises RuntimeError.

Item The reply
"text" A text reply without tool calls
tool_call("name", arg=value) A reply with one tool call
A list of text and tool calls A reply with those parts in order, for example ["Let me check.", tool_call(...)]
An exception instance, such as RateLimitError("slow down") The request raises it
A Reply object Used as is
A function that takes the Request Its return value, read by the rules above

fake.requests holds every request the model received. Use it to check what the model saw. In the test above, after run:

assert [t.name for t in fake.requests[0].tools] == ["read_file"]

What to assert

To check Assert on
The result state.answer
Why the run stopped state.stopped, for example StoppedByUntil("waiting_for_user")
How many turns state.turn
What happened, in order state.history, for example [h.kind for h in state.history]
What the model saw fake.requests

A tool that fails

A ToolError becomes an error result, and the run goes on. The test shows both:

from pathlib import Path

from alpineagents import Agent, Message, State, StoppedByUntil, ToolError, tool
from alpineagents.testing import FakeModel, tool_call


@tool
def read_file(path: str) -> str:
    """Read a file"""
    file = Path(path)
    if not file.exists():
        raise ToolError(f"No such file: {path}")
    return file.read_text()


agent = Agent(model="claude-sonnet-5", tools=[read_file])


def test_a_missing_file_is_an_error_result(tmp_path, monkeypatch):
    monkeypatch.chdir(tmp_path)
    fake = FakeModel([
        tool_call("read_file", path="missing.py"),
        "There is no missing.py",
    ])
    state = State(messages=[Message.user("Read missing.py")])

    agent.copy(model=fake, reporter=None).run(state)

    result = next(h for h in state.history if h.kind == "tool_result")
    assert (result.content, result.is_error) == ("No such file: missing.py", True)
    assert state.stopped == StoppedByUntil("waiting_for_user")

The tool_result entry in state.history has the message the model got and is_error=True. The stop name shows that the run went on after the failure. To test only the tool, call it as a plain function.

The person's answers

FakeHuman answers the questions of DecideByHuman with prepared answers, in order:

from pathlib import Path

from alpineagents import Agent, Message, State, StoppedByPermission, tool
from alpineagents.permissions import DecideByHuman
from alpineagents.testing import FakeHuman, FakeModel, tool_call


@tool(open_world=False)
def write_file(path: str, content: str) -> None:
    """Create a file, or replace its content"""
    Path(path).write_text(content)


agent = Agent(model="claude-sonnet-5", tools=[write_file], permissions=[DecideByHuman()])


def test_no_stops_the_run_and_writes_nothing(tmp_path, monkeypatch):
    monkeypatch.chdir(tmp_path)
    fake = FakeModel([tool_call("write_file", path="a.md", content="x")])
    human = FakeHuman(["no"])
    state = State(messages=[Message.user("Write a.md")])

    agent.copy(model=fake, human=human, reporter=None).run(state)

    assert human.remaining == 0
    assert isinstance(state.stopped, StoppedByPermission)
    assert not Path("a.md").exists()

human.remaining == 0 checks that every answer was asked for. An answer that does not fit the question is skipped, and the next one is used.

Save and resume

Use a FileStore in pytest's tmp_path, then load the State as another process would:

from alpineagents import Agent, FileStore, Message, State
from alpineagents.testing import FakeModel


def test_a_saved_conversation_loads_as_it_was(tmp_path):
    store = FileStore(tmp_path)
    agent = Agent(model=FakeModel(["Done"]), reporter=None, store=store)
    state = State(id="t1", messages=[Message.user("Fix it")])

    agent.run(state)

    loaded = store.load("t1")
    assert loaded.answer == "Done"
    assert loaded.snapshot() == state.snapshot()  # a loaded State equals the saved one

Next

You have the whole path: tools, loops, the State, permissions, stores and tests. Pick a task in the Guides, for example Use other models or Handle unreliable models. How it all fits together is in How a run works.