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
FakeModelgets two replies: a call toread_file, then the answer.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.- 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.