Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions example/agents/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,6 @@ storage
node_modules
/public/build
/public/hot
.ruff_cache
.pytest_cache
.ai
5 changes: 4 additions & 1 deletion example/agents/tests/features/record_no_stream.json
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
{
"1c27b2b038cae60eab7297ec0a808bfc67246c77a56a9e7c5ce1d9e5fdee0ba3": "Hi Alex, it's a pleasure to meet you! How can I help you today?"
"09e2753147eb813860891b4dd62050bceb302309d3f6db70019be2fb32582a47": {
"content": "Hi Alex, thanks for reaching out! How can I help you today?",
"tool_calls": []
}
}
4 changes: 2 additions & 2 deletions example/agents/tests/features/record_stream.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"32e9c85d324f4cadef79b130717a28d0e23a85a2ac349ad2b1b78a233b31dbc4": [
"97a14ff94f83842c3bf7a706f945aa10c9c6799ae651a74191ffc744f933a59f": [
"Hi",
" Bedram, it's a pleasure to assist you today! How may I help you?"
" Bedram, thanks for reaching out! How can I help you today?"
]
}
34 changes: 34 additions & 0 deletions example/agents/tests/units/agents/record_stream.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
{
"034751ef1322a12f3406dad43313f9cdb31f4ea85d9452c22bf7529ad8e92614": {
"content": "Hello! How can I help you today?",
"tool_calls": []
},
"358018dc096ce95b78b7561f562ba049848d03fb8b41953ed75c22c9ad20e228": {
"content": "",
"tool_calls": [
{
"args": {
"query": "python developer"
},
"id": "call_1",
"name": "job_search_tool"
}
]
},
"b6579ba866634a66229ddbed0463c8c990eaaaf349385fbc67c2c2c1886085cd": {
"content": "",
"tool_calls": [
{
"args": {
"query": "python developer"
},
"id": "call_1",
"name": "job_search_tool"
}
]
},
"judge:d33d8a7074bb7d980bf548694cf3b50ba853fa2f01a8df921bd28d7373ef5580": {
"passed": true,
"reasoning": "greets the user"
}
}
5 changes: 3 additions & 2 deletions example/agents/uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion fastapi_startkit/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -129,7 +129,7 @@ exclude = [
"**/__pycache__",
"**/.venv",
]
typeCheckingMode = "basic"
typeCheckingMode = "standard"
pythonVersion = "3.12"

[tool.pytest.ini_options]
Expand Down
7 changes: 3 additions & 4 deletions fastapi_startkit/src/fastapi_startkit/ai/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,21 +13,20 @@
from .judge import JudgeAgent
from .providers.ai_provider import AIProvider
from .response import AgentResponse, AgentSnapshot
from .testing import AgentBinding, AgentModelFake, RecordingAgent, ToolCallView
from .testing import AgentFake, AgentRecordFake, ToolCallView

__all__ = [
"Agent",
"Ai",
"Middleware",
"AgentBinding",
"AgentModelFake",
"AgentFake",
"AgentResponse",
"AgentSnapshot",
"AIConfig",
"AIProvider",
"AnthropicConfig",
"JudgeAgent",
"RecordingAgent",
"AgentRecordFake",
"ToolCallView",
"Audio",
"AudioResponse",
Expand Down
15 changes: 7 additions & 8 deletions fastapi_startkit/src/fastapi_startkit/ai/agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,11 @@

from .document import Document
from .response import AgentResponse
from .testing import AgentBinding

if TYPE_CHECKING:
from langchain_core.tools import BaseTool

from .testing import AgentModelFake
from .testing import AgentFake, AgentRecordFake


class Agent:
Expand Down Expand Up @@ -81,16 +80,16 @@ async def stream(
yield chunk

@classmethod
def fake(cls, responses: list) -> "AgentModelFake":
from .testing import AgentModelFake
def fake(cls, responses: list) -> "AgentFake":
from .testing import AgentFake

return AgentModelFake(cls, responses)
return AgentFake(cls, responses)

@classmethod
def record(cls, cassette: str | None = None, messages: list | None = None) -> "AgentBinding":
from .testing import AgentBinding, RecordingAgent
def record(cls, cassette: str | None = None, messages: list | None = None) -> "AgentRecordFake":
from .testing import AgentRecordFake

return AgentBinding(cls, RecordingAgent(cls(), cassette, messages))
return AgentRecordFake(cls(), cassette, messages)

@classmethod
def _binding(cls) -> Any:
Expand Down
37 changes: 16 additions & 21 deletions fastapi_startkit/src/fastapi_startkit/ai/testing.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,11 +55,10 @@ def _joined(value: Any) -> str:
return "".join(value) if isinstance(value, list) else value


class AgentModelFake:
class AgentFake:
"""Registers a fixed, ordered list of replies as ``agent_cls``'s chat
model for the duration of a ``with`` block (or a decorated function).

Unlike the old pattern-matching stand-in, this swaps only the model —
``prompt()``/``stream()`` still run the real message-building, pipeline,
and tool-execution path; see ``Ai.fake()``.
"""
Expand Down Expand Up @@ -111,7 +110,7 @@ def __repr__(self) -> str:
return f"ToolCallView(name={self.name!r}, args={self.args!r})"


class RecordingAgent(_Recorder):
class AgentRecordFake(_Recorder):
"""Bound as ``agent`` by ``with Agent.record(cassette) as agent:``.

Fluent testing handle around a record-and-replay session: ``prompt()``
Expand All @@ -124,6 +123,11 @@ class RecordingAgent(_Recorder):
cached to disk (keyed by the conversation history so far, plus the new
message, so two sessions with different histories but the same latest
message text don't collide). On a hit, it's replayed with no live call.

Entering the ``with`` block also binds this handle into the container
under the agent class's name, so any other instance of that class
created during the block (e.g. by application code under test) is
routed through the same recording session.
"""

def __init__(self, real: Agent, cassette: str | None = None, messages: list | None = None) -> None:
Expand Down Expand Up @@ -159,7 +163,7 @@ def _key(self, message: str, attachments: list[Document] | None) -> str:

def _load(self) -> tuple[Path, dict]:
cassette = self.cassette
assert cassette is not None, "RecordingAgent has no cassette resolved"
assert cassette is not None, "AgentRecordFake has no cassette resolved"
return cassette, (json.loads(cassette.read_text()) if cassette.exists() else {})

def _save(self, cassette: Path, store: dict, key: str, value: Any) -> None:
Expand Down Expand Up @@ -276,34 +280,25 @@ async def _judge_live(self, model: str, expectation: str, content: str, provider
judge.provider = provider
return await judge.judge(expectation, content)


class AgentBinding:
def __init__(self, agent_cls: type[Agent], stand_in: Any) -> None:
self._agent_cls = agent_cls
self._stand_in = stand_in

def _resolve_cassette(self, filename: str, qualname: str) -> None:
stand_in = self._stand_in
if not isinstance(stand_in, RecordingAgent):
return
here = Path(filename).parent
if stand_in.cassette is None:
stand_in.cassette = here / "cassettes" / f"{qualname.replace('.', '_')}.json"
elif not stand_in.cassette.is_absolute():
stand_in.cassette = here / stand_in.cassette
if self.cassette is None:
self.cassette = here / "cassettes" / f"{qualname.replace('.', '_')}.json"
elif not self.cassette.is_absolute():
self.cassette = here / self.cassette

def __enter__(self) -> Any:
def __enter__(self) -> "AgentRecordFake":
from fastapi_startkit.application import app

caller = sys._getframe(1).f_code
self._resolve_cassette(caller.co_filename, caller.co_qualname)
app().bind(self._agent_cls.__name__, self._stand_in)
return self._stand_in
app().bind(type(self._real).__name__, self)
return self

def __exit__(self, *_exc: Any) -> bool:
from fastapi_startkit.application import app

app().unbind(self._agent_cls.__name__)
app().unbind(type(self._real).__name__)
return False

def __call__(self, func: Callable) -> Callable:
Expand Down
16 changes: 8 additions & 8 deletions fastapi_startkit/tests/ai/test_agent_record_fluent.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
"""Tests for the fluent Agent.record() testing DSL.

``with Agent.record(cassette) as agent:`` binds a ``RecordingAgent`` handle
``with Agent.record(cassette) as agent:`` binds an ``AgentRecordFake`` handle
whose async ``prompt()`` and assertion methods judge the most recent turn —
mirroring how a browser-testing ``page`` object exposes assertions against
current page state:
Expand All @@ -24,7 +24,7 @@

from fastapi_startkit.ai.agent import Agent
from fastapi_startkit.ai.response import AgentResponse
from fastapi_startkit.ai.testing import RecordingAgent
from fastapi_startkit.ai.testing import AgentRecordFake


class SimpleAgent(Agent):
Expand Down Expand Up @@ -254,7 +254,7 @@ async def test_passes_when_judge_approves(self):
self.setup_agent("Hello there, welcome!")
with tempfile.TemporaryDirectory() as tmp:
with mock.patch.object(
RecordingAgent,
AgentRecordFake,
"_judge_live",
mock.AsyncMock(return_value={"passed": True, "reasoning": "greets the user"}),
):
Expand All @@ -268,7 +268,7 @@ async def test_fails_when_judge_rejects(self):
self.setup_agent("Completely unrelated content")
with tempfile.TemporaryDirectory() as tmp:
with mock.patch.object(
RecordingAgent,
AgentRecordFake,
"_judge_live",
mock.AsyncMock(return_value={"passed": False, "reasoning": "not a greeting"}),
):
Expand All @@ -284,7 +284,7 @@ async def test_verdict_is_cached_in_the_cassette_and_not_re_judged(self):
judge = mock.AsyncMock(return_value={"passed": True, "reasoning": "ok"})
with tempfile.TemporaryDirectory() as tmp:
cassette = os.path.join(tmp, "c.json")
with mock.patch.object(RecordingAgent, "_judge_live", judge):
with mock.patch.object(AgentRecordFake, "_judge_live", judge):
with SimpleAgent.record(cassette) as agent:
await agent.prompt("hello")
await agent.assert_response_judged(model="gpt-3.5-turbo", expectation="greet")
Expand All @@ -297,14 +297,14 @@ async def test_verdict_persists_to_disk_for_a_later_replay(self):
with tempfile.TemporaryDirectory() as tmp:
cassette = os.path.join(tmp, "c.json")
with mock.patch.object(
RecordingAgent, "_judge_live", mock.AsyncMock(return_value={"passed": True, "reasoning": "ok"})
AgentRecordFake, "_judge_live", mock.AsyncMock(return_value={"passed": True, "reasoning": "ok"})
):
with SimpleAgent.record(cassette) as agent:
await agent.prompt("hello")
await agent.assert_response_judged(model="gpt-3.5-turbo", expectation="greet")

judge = mock.AsyncMock(side_effect=AssertionError("must not be called on replay"))
with mock.patch.object(RecordingAgent, "_judge_live", judge):
with mock.patch.object(AgentRecordFake, "_judge_live", judge):
with SimpleAgent.record(cassette) as agent:
await agent.prompt("hello")
await agent.assert_response_judged(model="gpt-3.5-turbo", expectation="greet")
Expand All @@ -321,7 +321,7 @@ async def test_provider_is_forwarded_to_the_judge(self):
self.setup_agent("Hello there!")
judge = mock.AsyncMock(return_value={"passed": True, "reasoning": "ok"})
with tempfile.TemporaryDirectory() as tmp:
with mock.patch.object(RecordingAgent, "_judge_live", judge):
with mock.patch.object(AgentRecordFake, "_judge_live", judge):
with SimpleAgent.record(os.path.join(tmp, "c.json")) as agent:
await agent.prompt("hello")
await agent.assert_response_judged(model="gpt-3.5-turbo", provider="openai", expectation="greet")
Expand Down
Loading