pytest-agent-check 0.0.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,390 @@
1
+ Metadata-Version: 2.4
2
+ Name: pytest-agent-check
3
+ Version: 0.0.1
4
+ Summary: A pytest plugin for evaluating and testing AI agents — record, replay, and assert agent behavior
5
+ Author-email: Xianpeng Shen <shenxianpeng@example.com>
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/shenxianpeng/pytest-agent-check
8
+ Project-URL: Repository, https://github.com/shenxianpeng/pytest-agent-check
9
+ Project-URL: Issues, https://github.com/shenxianpeng/pytest-agent-check/issues
10
+ Keywords: pytest,plugin,agent,AI,evaluation,testing,LLM
11
+ Classifier: Development Status :: 3 - Alpha
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: Framework :: Pytest
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.10
16
+ Classifier: Programming Language :: Python :: 3.11
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Classifier: Programming Language :: Python :: 3.13
19
+ Classifier: Programming Language :: Python :: 3.14
20
+ Classifier: Topic :: Software Development :: Testing
21
+ Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
22
+ Requires-Python: >=3.10
23
+ Description-Content-Type: text/markdown
24
+ License-File: LICENSE
25
+ Requires-Dist: pytest>=8.0
26
+ Requires-Dist: pyyaml>=6.0
27
+ Requires-Dist: deepdiff>=7.0
28
+ Requires-Dist: rich>=13.0
29
+ Provides-Extra: semantic
30
+ Requires-Dist: sentence-transformers>=2.2; extra == "semantic"
31
+ Provides-Extra: html
32
+ Requires-Dist: jinja2>=3.0; extra == "html"
33
+ Provides-Extra: cli
34
+ Requires-Dist: typer>=0.9; extra == "cli"
35
+ Provides-Extra: all
36
+ Requires-Dist: pytest-agent-check[cli,html,semantic]; extra == "all"
37
+ Dynamic: license-file
38
+
39
+ <h1 align="center">pytest-agent-check</h1>
40
+
41
+ <p align="center">
42
+ <em>A pytest plugin for evaluating and testing AI agents — record, replay, and assert agent behaviour with confidence.</em>
43
+ </p>
44
+
45
+ <p align="center">
46
+ <a href="https://pypi.org/project/pytest-agent-check/">
47
+ <img src="https://img.shields.io/pypi/v/pytest-agent-check" alt="PyPI">
48
+ </a>
49
+ <a href="https://pypi.org/project/pytest-agent-check/">
50
+ <img src="https://img.shields.io/pypi/pyversions/pytest-agent-check" alt="Python versions">
51
+ </a>
52
+ <a href="https://github.com/shenxianpeng/pytest-agent-check/actions/workflows/ci.yml">
53
+ <img src="https://github.com/shenxianpeng/pytest-agent-check/actions/workflows/ci.yml/badge.svg" alt="CI">
54
+ </a>
55
+ <a href="https://opensource.org/licenses/MIT">
56
+ <img src="https://img.shields.io/badge/License-MIT-yellow.svg" alt="License: MIT">
57
+ </a>
58
+ </p>
59
+
60
+ ---
61
+
62
+ ## Overview
63
+
64
+ **pytest-agent-check** brings the familiar **snapshot testing** and **VCR-like cassette** patterns to AI agent evaluation.
65
+
66
+ If you've ever wished you could test your agent as confidently as you test your REST API — with deterministic assertions, offline replay, and a clear diff when something changes — this plugin is for you.
67
+
68
+ ### Why?
69
+
70
+ AI agents are **non-deterministic** and **expensive to run**. You can't just ``assert result == expected`` when the output is natural language. And every test run that calls a real LLM costs money, time, and patience.
71
+
72
+ This plugin solves both problems:
73
+
74
+ 1. **Cassette record/replay** — Run tests once in *record mode* to capture all tool calls and outputs as a "cassette" (YAML file). Subsequent runs replay from the cassette: fast, offline, deterministic, free.
75
+ 2. **Fluent assertion API** — Assert on the *structure* of tool-call sequences (exact matching) and the *intent* of natural-language output (semantic matching, coming soon).
76
+ 3. **Change detection** — When agent behaviour drifts, get a structured diff showing exactly which tool calls changed, were added, or were removed.
77
+
78
+ ---
79
+
80
+ ## Installation
81
+
82
+ ```bash
83
+ pip install pytest-agent-check
84
+ ```
85
+
86
+ Optional extras:
87
+
88
+ ```bash
89
+ pip install pytest-agent-check[semantic] # sentence-transformers for semantic similarity
90
+ pip install pytest-agent-check[cli] # typer-based `agent-eval` CLI
91
+ pip install pytest-agent-check[html] # jinja2 for HTML reports
92
+ pip install pytest-agent-check[all] # everything above
93
+ ```
94
+
95
+ ### Requirements
96
+
97
+ - Python 3.10+
98
+ - pytest 8.0+
99
+
100
+ ---
101
+
102
+ ## Quick Start
103
+
104
+ ### 1. Write an agent test
105
+
106
+ ```python
107
+ # test_support_agent.py
108
+ import pytest
109
+ from pytest_agent_eval import agent_test, expect_tools, expect_output
110
+
111
+
112
+ @pytest.fixture
113
+ def agent():
114
+ """Provide your agent under test."""
115
+ from my_agent import create_agent
116
+ return create_agent()
117
+
118
+
119
+ @agent_test(agent="support-agent", cassette="refund_flow")
120
+ def test_refund_request(agent, cassette):
121
+ """Agent should process refunds: lookup → eligibility → issue."""
122
+ result = cassette.run(agent.run, "I want a refund for order ORD-001")
123
+
124
+ expect_tools(result).called("lookup_order") \
125
+ .then("check_refund_eligibility") \
126
+ .then("issue_refund")
127
+
128
+ expect_output(result).matches_intent(
129
+ "confirms refund is approved and provides arrival time"
130
+ )
131
+ ```
132
+
133
+ ### 2. Record cassettes (first run)
134
+
135
+ ```bash
136
+ agent-eval record test_support_agent.py
137
+ # ─ or ─
138
+ pytest test_support_agent.py --cassette-mode=record -v
139
+ ```
140
+
141
+ This runs your **real agent**, captures all tool calls and outputs, and saves them to ``.cassettes/refund_flow.yaml``.
142
+
143
+ ### 3. Run tests in replay mode
144
+
145
+ ```bash
146
+ # Requires the CLI extra: pip install pytest-agent-check[cli]
147
+ agent-eval run test_support_agent.py
148
+ # ─ or ─
149
+ pytest test_support_agent.py --cassette-mode=replay -v
150
+ ```
151
+
152
+ Now tests run **offline** — no API calls, no LLM latency, no cost. The recorded data is returned instantly.
153
+
154
+ ### 4. Update baselines when behaviour changes
155
+
156
+ ```bash
157
+ agent-eval update test_support_agent.py
158
+ ```
159
+
160
+ Re-records all cassettes. Commit the updated YAML files alongside your code changes.
161
+
162
+ ---
163
+
164
+ ## Core Concepts
165
+
166
+ ### Cassette (`.cassettes/`)
167
+
168
+ A cassette is a YAML file that records everything your agent did during a test:
169
+
170
+ ```yaml
171
+ agent_name: support-agent
172
+ test_name: refund_flow
173
+ interactions:
174
+ - input: "I want a refund for order ORD-001"
175
+ tool_calls:
176
+ - name: lookup_order
177
+ arguments:
178
+ order_id: ORD-001
179
+ result:
180
+ status: delivered
181
+ amount: 49.99
182
+ - name: check_refund_eligibility
183
+ arguments:
184
+ order_id: ORD-001
185
+ result:
186
+ eligible: true
187
+ refund_amount: 49.99
188
+ - name: issue_refund
189
+ arguments:
190
+ order_id: ORD-001
191
+ amount: 49.99
192
+ result:
193
+ refund_id: RF-001
194
+ status: approved
195
+ output: "Your refund of $49.99 has been approved... 3-5 business days."
196
+ ```
197
+
198
+ Cassettes are the **baseline** you compare against. Treat them like snapshot files — commit them to version control.
199
+
200
+ ### Operation modes
201
+
202
+ | Mode | Description |
203
+ |------------|-----------------------------------------------------------------------------|
204
+ | `record` | Run the real agent, save all interactions to a cassette. |
205
+ | `replay` | Return recorded data from the cassette. Fail if cassette doesn't exist. |
206
+ | `auto` | Replay if cassette exists, otherwise record. **Default.** |
207
+
208
+ ### How the `cassette.run()` method works
209
+
210
+ ```python
211
+ result = cassette.run(agent_func, user_input, agent_name="my-agent")
212
+ ```
213
+
214
+ - **Record mode**: calls `agent_func(user_input)`, records the returned `tool_calls` and `output`, stores them internally.
215
+ - **Replay mode**: loads the cassette for this test, returns the recorded `tool_calls` and `output` without calling `agent_func`.
216
+ - Both modes return a dict with `tool_calls`, `output`, `input`, and `_replayed` (bool).
217
+
218
+ The result dict is what you pass to `expect_tools()` and `expect_output()`.
219
+
220
+ ---
221
+
222
+ ## Assertion API
223
+
224
+ ### `expect_tools(result).called(name).then(name)...`
225
+
226
+ Fluent assertions on the **exact sequence** of tool calls.
227
+
228
+ ```python
229
+ expect_tools(result).called("lookup_order") \
230
+ .then("check_refund_eligibility") \
231
+ .then("issue_refund")
232
+ ```
233
+
234
+ Properties:
235
+ - `.total` — total number of tool calls
236
+ - `.remaining` — how many haven't been asserted yet
237
+
238
+ Raises `AssertionError` on mismatch.
239
+
240
+ ### `expect_output(result).matches_intent(description)`
241
+
242
+ Validates the natural-language output is non-empty and documents the expected intent.
243
+
244
+ ```python
245
+ expect_output(result).matches_intent("confirms refund is approved and provides arrival time")
246
+ ```
247
+
248
+ In the MVP this is a non-emptiness check. Future releases will add:
249
+ - `.semantic_similarity(threshold=0.85)` — embedding-based similarity
250
+ - `.judged_by("gpt-4o-mini", rubric=...)` — LLM-as-judge evaluation
251
+
252
+ ---
253
+
254
+ ## CLI Reference
255
+
256
+ When ``typer`` is installed (``pip install pytest-agent-check[cli]``):
257
+
258
+ ```bash
259
+ agent-eval record [<test-path>] # Record cassettes (real agent runs)
260
+ agent-eval run [<test-path>] # Replay from cassettes (offline)
261
+ agent-eval update [<test-path>] # Re-record (update) baselines
262
+ ```
263
+
264
+ Options:
265
+ - ``--cassette-dir`` / ``-d`` — Cassette directory (default: ``.cassettes``)
266
+ - ``--verbose`` / ``--quiet`` — Pytest verbosity
267
+ - ``--pytest-args`` — Extra pytest arguments (repeatable, e.g. ``-pytest -x -pytest -k test_refund``)
268
+
269
+ ---
270
+
271
+ ## Configuration
272
+
273
+ All configuration is via pytest command-line flags:
274
+
275
+ | Flag | Default | Description |
276
+ |--------------------|---------------|--------------------------------------------------|
277
+ | `--cassette-dir` | `.cassettes` | Directory for cassette YAML files |
278
+ | `--cassette-mode` | `auto` | `record`, `replay`, or `auto` |
279
+ | `--agent-eval-report` | off | Print structured eval report after test run |
280
+
281
+ ---
282
+
283
+ ## CI / GitHub Actions Integration
284
+
285
+ **Record mode** is for local development. In CI, always use **replay mode** for deterministic, fast, and cost-free testing.
286
+
287
+ ```yaml
288
+ # .github/workflows/ci.yml
289
+ name: CI
290
+ on: [pull_request]
291
+ jobs:
292
+ agent-tests:
293
+ runs-on: ubuntu-latest
294
+ strategy:
295
+ matrix:
296
+ python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"]
297
+ steps:
298
+ - uses: actions/checkout@v7.0.1
299
+ with:
300
+ fetch-depth: 0 # needed for setuptools-scm
301
+ - uses: actions/setup-python@v7.0.0
302
+ with:
303
+ python-version: ${{ matrix.python-version }}
304
+ - run: pip install pytest-agent-check[cli]
305
+ - run: pytest tests/ --cassette-mode=replay -v
306
+ ```
307
+
308
+ To update baselines (e.g., after intentional agent changes):
309
+
310
+ ```bash
311
+ pip install pytest-agent-check[cli]
312
+ agent-eval update tests/
313
+ git add .cassettes/
314
+ git commit -m "chore: update agent evaluation baselines"
315
+ ```
316
+
317
+ ---
318
+
319
+ ## Project Architecture
320
+
321
+ ```
322
+ pytest-agent-check/
323
+ ├── pytest_agent_eval/
324
+ │ ├── __init__.py # Public API exports
325
+ │ ├── api.py # @agent_test, expect_tools, expect_output
326
+ │ ├── cassette.py # CassetteManager, CassetteContext (record/replay)
327
+ │ ├── comparator.py # Tool-call sequence comparison (deepdiff)
328
+ │ ├── plugin.py # Pytest plugin (markers, options, fixtures)
329
+ │ ├── reporter.py # Terminal output with rich formatting
330
+ │ ├── cli.py # agent-eval CLI (record/run/update)
331
+ │ └── models.py # Data models (ToolCall, Interaction, Cassette, etc.)
332
+ ├── tests/ # Unit and integration tests
333
+ ├── examples/ # Runnable example tests
334
+ └── pyproject.toml # Project metadata & dependencies
335
+ ```
336
+
337
+ ### Roadmap
338
+
339
+ | Phase | Feature |
340
+ |-------|--------------------------------------------------------------|
341
+ | MVP | ✅ pytest plugin skeleton |
342
+ | | ✅ `@agent_test` decorator & marker |
343
+ | | ✅ `expect_tools` fluent assertion |
344
+ | | ✅ `expect_output` basic intent check |
345
+ | | ✅ Cassette record/replay (YAML-based) |
346
+ | | ✅ Tool-call diff comparison (deepdiff) |
347
+ | | ✅ Terminal reporter (rich) |
348
+ | | ✅ CLI (agent-eval record/run/update) |
349
+ | Next | 🔲 Semantic similarity (`expect_output().semantic_similarity()`) |
350
+ | | 🔲 LLM-as-judge evaluation (`expect_output().judged_by()`) |
351
+ | | 🔲 Multi-turn interaction support |
352
+ | | 🔲 HTML / PR comment report format |
353
+ | | 🔲 GitHub Action for automated PR commenting |
354
+ | | 🔲 LangChain / OpenAI SDK adapters (auto-interception) |
355
+ | | 🔲 MCP / A2A protocol support |
356
+
357
+ ---
358
+
359
+ ## Development
360
+
361
+ ```bash
362
+ git clone https://github.com/shenxianpeng/pytest-agent-check.git
363
+ cd pytest-agent-check
364
+ python -m venv venv && source venv/bin/activate
365
+ pip install -e ".[dev]"
366
+ pytest
367
+ ```
368
+
369
+ ### Running tests
370
+
371
+ ```bash
372
+ pytest tests/ -v # unit tests only
373
+ pytest examples/ --cassette-mode=record # integration tests (record)
374
+ pytest examples/ --cassette-mode=replay # integration tests (replay)
375
+ agent-eval run tests/ # via CLI
376
+ ```
377
+
378
+ ---
379
+
380
+ ## License
381
+
382
+ MIT — see [LICENSE](LICENSE).
383
+
384
+ ---
385
+
386
+ ## Contributing
387
+
388
+ Contributions are welcome! Please open an issue or pull request on [GitHub](https://github.com/shenxianpeng/pytest-agent-check).
389
+
390
+ When adding features, include tests and update the documentation. Every PR should pass `pytest tests/ --cassette-mode=replay`.
@@ -0,0 +1,14 @@
1
+ pytest_agent_check-0.0.1.dist-info/licenses/LICENSE,sha256=naE4MjnMNhwEWhlsEWmTREcCAiaiPVqDRxapVJYn4rM,1070
2
+ pytest_agent_eval/__init__.py,sha256=A0ZykDsaeKMn3kKHMa4iGdhL9hMROxvobSjEodk6c3U,867
3
+ pytest_agent_eval/api.py,sha256=0KNfiukSls0-d50VPaQ0W42t2qp5czYURjeLTjY0h-k,6246
4
+ pytest_agent_eval/cassette.py,sha256=YobwYPTHge-8Vofqs0eXPwnemN1MeXtgPn5z-EiWzEY,8181
5
+ pytest_agent_eval/cli.py,sha256=2fevB7OHmJZWL4YIRI_rliV6Wr67F5oRtimJx0UYK68,4998
6
+ pytest_agent_eval/comparator.py,sha256=d-ND6i3UVK_5N0sHD9bD22lw505PERySLAwr1YP7fkY,4032
7
+ pytest_agent_eval/models.py,sha256=-MY8VMwPKheANAsMaRgI_SjGI8rhL5JkYUjQCJn21Oo,6241
8
+ pytest_agent_eval/plugin.py,sha256=kyZVENitIMx9k-RGaIfp4fjsgGRX1eIgLy8pYGHZ5gc,4037
9
+ pytest_agent_eval/reporter.py,sha256=BWMZEZ149EhpHq20XV_2VhXW0KgMYZGEoje81Re5cFQ,4787
10
+ pytest_agent_check-0.0.1.dist-info/METADATA,sha256=ogGMscoNWfwLfZXwUE17H2FNzarXCzSuN2eRW0S7kbQ,13522
11
+ pytest_agent_check-0.0.1.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
12
+ pytest_agent_check-0.0.1.dist-info/entry_points.txt,sha256=LyaPJ3_3SdUTVyQelEuEo8Azg4B1KcnuS27PPXvXYm4,115
13
+ pytest_agent_check-0.0.1.dist-info/top_level.txt,sha256=-pjdrPHR20R5QdJV1Ja-_u-xqogh0Tss5ZFJEin8Ql4,18
14
+ pytest_agent_check-0.0.1.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (83.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,5 @@
1
+ [console_scripts]
2
+ agent-eval = pytest_agent_eval.cli:app
3
+
4
+ [pytest11]
5
+ pytest-agent-check = pytest_agent_eval.plugin
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Xianpeng Shen
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ pytest_agent_eval
@@ -0,0 +1,30 @@
1
+ """
2
+ pytest-agent-check — A pytest plugin for evaluating and testing AI agents.
3
+
4
+ This plugin provides:
5
+ - ``@agent_test`` decorator for marking agent evaluation tests
6
+ - ``expect_tools`` / ``expect_output`` fluent assertion API
7
+ - Cassette-based record/replay for deterministic, offline testing
8
+ - Structured comparison of tool-call sequences
9
+ - Terminal and HTML diff reporting
10
+ - CLI tool for managing test lifecycles
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from .api import agent_test, expect_output, expect_tools
16
+ from .cassette import CassetteContext, CassetteManager
17
+ from .models import Cassette, ComparisonResult, Interaction, ToolCall, ToolCallDiff
18
+
19
+ __all__ = [
20
+ "agent_test",
21
+ "expect_tools",
22
+ "expect_output",
23
+ "ToolCall",
24
+ "Interaction",
25
+ "Cassette",
26
+ "ComparisonResult",
27
+ "ToolCallDiff",
28
+ "CassetteManager",
29
+ "CassetteContext",
30
+ ]
@@ -0,0 +1,188 @@
1
+ """
2
+ Public API — the user-facing decorators and assertion helpers.
3
+
4
+ This is what users ``from pytest_agent_eval import`` in their test files.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from typing import Any, Callable
10
+
11
+ import pytest
12
+
13
+ # ──────────────────────────────────────────────────────────────
14
+ # agent_test decorator
15
+ # ──────────────────────────────────────────────────────────────
16
+
17
+
18
+ def agent_test(
19
+ *,
20
+ agent: str,
21
+ cassette: str | None = None,
22
+ ) -> Callable[[Callable], Callable]:
23
+ """
24
+ Mark a function as an agent evaluation test.
25
+
26
+ This is a thin wrapper around ``@pytest.mark.agent_test`` that
27
+ provides a cleaner user-facing syntax.
28
+
29
+ Args:
30
+ agent: Logical name of the agent under test.
31
+ cassette: (optional) Explicit cassette name. If omitted,
32
+ the test function name is used.
33
+
34
+ Example::
35
+
36
+ from pytest_agent_eval import agent_test, expect_tools
37
+
38
+ @agent_test(agent="my-support-agent")
39
+ def test_refund_request(agent, cassette):
40
+ result = cassette.run(agent, "I want a refund")
41
+ expect_tools(result).called("lookup_order").then("issue_refund")
42
+
43
+ """
44
+
45
+ def decorator(func: Callable) -> Callable:
46
+ kwargs: dict[str, Any] = {"agent": agent}
47
+ if cassette is not None:
48
+ kwargs["cassette"] = cassette
49
+ return pytest.mark.agent_test(**kwargs)(func)
50
+
51
+ return decorator
52
+
53
+
54
+ # ──────────────────────────────────────────────────────────────
55
+ # Fluent assertion helpers
56
+ # ──────────────────────────────────────────────────────────────
57
+
58
+
59
+ class ExpectToolsResult:
60
+ """
61
+ Fluent assertion on a sequence of tool calls.
62
+
63
+ Constructed via :func:`expect_tools` — users should not instantiate
64
+ this directly.
65
+ """
66
+
67
+ def __init__(self, result: dict[str, Any]) -> None:
68
+ self._tool_calls: list[dict[str, Any]] = result.get("tool_calls", [])
69
+ self._index: int = 0
70
+
71
+ def called(self, tool_name: str) -> ExpectToolsResult:
72
+ """
73
+ Assert that the **next** tool call has *tool_name*.
74
+
75
+ Returns ``self`` so calls can be chained with :meth:`then`.
76
+ """
77
+ if self._index >= len(self._tool_calls):
78
+ raise AssertionError(
79
+ f"Expected tool call '{tool_name}' at position "
80
+ f"{self._index + 1}, but only "
81
+ f"{len(self._tool_calls)} tool call(s) were made."
82
+ )
83
+ actual = self._tool_calls[self._index].get("name", "")
84
+ if actual != tool_name:
85
+ raise AssertionError(
86
+ f"Tool call mismatch at position {self._index + 1}:\n"
87
+ f" Expected: '{tool_name}'\n"
88
+ f" Actual: '{actual}'"
89
+ )
90
+ self._index += 1
91
+ return self
92
+
93
+ def then(self, tool_name: str) -> ExpectToolsResult:
94
+ """Alias for :meth:`called` — enables fluent chaining."""
95
+ return self.called(tool_name)
96
+
97
+ @property
98
+ def total(self) -> int:
99
+ """Return the total number of tool calls made."""
100
+ return len(self._tool_calls)
101
+
102
+ @property
103
+ def remaining(self) -> int:
104
+ """Return how many tool calls have not been asserted yet."""
105
+ return len(self._tool_calls) - self._index
106
+
107
+
108
+ class ExpectOutputResult:
109
+ """
110
+ Fluent assertion on the natural-language output of an agent.
111
+
112
+ Constructed via :func:`expect_output`.
113
+ """
114
+
115
+ def __init__(self, result: dict[str, Any]) -> None:
116
+ self._output: str = result.get("output", "")
117
+
118
+ @property
119
+ def text(self) -> str:
120
+ """The raw output text."""
121
+ return self._output
122
+
123
+ def matches_intent(self, description: str) -> ExpectOutputResult:
124
+ """
125
+ Assert the output is non-empty and plausibly matches an intent.
126
+
127
+ In the MVP this is a basic non-emptiness check. Future versions
128
+ will use embedding similarity or an LLM-as-judge for semantic
129
+ equivalence.
130
+
131
+ Args:
132
+ description: Human-readable description of the expected intent.
133
+
134
+ """
135
+ if not self._output:
136
+ raise AssertionError(
137
+ "Expected non-empty agent output, but got an empty string."
138
+ )
139
+ # For MVP we accept any non-empty output as "passing".
140
+ # The description parameter documents intent for future semantic checks.
141
+ return self
142
+
143
+ def semantic_similarity(
144
+ self,
145
+ threshold: float = 0.85,
146
+ reference: str | None = None,
147
+ ) -> ExpectOutputResult:
148
+ """
149
+ Assert that the output exceeds a semantic similarity threshold.
150
+
151
+ Requires ``sentence-transformers`` (install with
152
+ ``pip install pytest-agent-check[semantic]``).
153
+
154
+ Args:
155
+ threshold: Minimum cosine similarity (0-1).
156
+ reference: Reference text to compare against. If ``None``,
157
+ the cassette's recorded output is used.
158
+
159
+ """
160
+ # Stub — full implementation requires the optional dependency.
161
+ # For now we just accept any non-empty output.
162
+ if not self._output:
163
+ raise AssertionError(
164
+ "Expected non-empty output for semantic similarity check."
165
+ )
166
+ return self
167
+
168
+
169
+ def expect_tools(result: dict[str, Any]) -> ExpectToolsResult:
170
+ """
171
+ Start fluent assertion on the tool-call sequence of *result*.
172
+
173
+ Usage::
174
+
175
+ expect_tools(result).called("lookup_order").then("issue_refund")
176
+ """
177
+ return ExpectToolsResult(result)
178
+
179
+
180
+ def expect_output(result: dict[str, Any]) -> ExpectOutputResult:
181
+ """
182
+ Start fluent assertion on the natural-language output of *result*.
183
+
184
+ Usage::
185
+
186
+ expect_output(result).matches_intent("confirms refund and arrival time")
187
+ """
188
+ return ExpectOutputResult(result)