Skip to content

Commit ebcdddc

Browse files
dreamrecclaude
andcommitted
test: PR-20 mock-DeepSeek fixture for agent evals (Phase 4 step 1, F-24a)
The 12 deselected ``agent_eval``-marker tests at ``tests/agent_evals/`` required a live TouchDesigner instance with the standalone .tox loaded plus real DeepSeek API access. PR-20 adds a fixture-replay harness so the same evals run in regular CI without either dependency. Architecture decision: drive the ``Agent`` class (pure-Python, in ``tdpilot_api_agent.py``) directly with a localhost mock server. The mock binds to a random port and replays captured ``/v1/messages`` exchanges from JSON fixtures under ``tests/fixtures/deepseek/``. This sidesteps the AgentRuntime layer's TD-specific dependencies. The mock enforces the thinking-block echo contract from ``feedback_deepseek_thinking_blocks_must_echo``: when an assistant turn's ``type:thinking`` blocks aren't echoed back in the next request, the mock returns the same HTTP 400 + canonical error message DeepSeek emits. ``test_thinking_echo_regression.py`` proves the detection by sabotaging ``_strip_reasoning`` and asserting the mock catches it. 11 of the 12 live agent_evals are ported as ``tests/agent_evals_mock/test_*_mock.py``. The 12th test (``test_build_no_validation_emits_hint``) exercises the AgentRuntime's validation-hint emission rather than the Agent class — that one stays in the live suite. Real DeepSeek fixtures captured via the new ``scripts/capture_deepseek_fixtures.py`` recorder; fixtures are pretty-printed JSON so future schema drift surfaces as a readable diff. 39 new tests in regular CI (1557 pass / 12 deselected — same 12 live ``agent_eval`` tests, untouched). No version bump — Phase 4 ships as a single bundled v1.9.0 once PR-20..PR-23 all land. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent e1e5821 commit ebcdddc

26 files changed

Lines changed: 10967 additions & 0 deletions

CHANGELOG.md

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,59 @@
11
# Changelog
22

3+
## Unreleased — Phase 4 work-in-progress
4+
5+
Phase 4 of the post-1.7 audit plan ships as a single bundled v1.9.0
6+
release once PR-20..PR-23 all land. This entry records work-in-progress
7+
that's merged onto main but not yet released.
8+
9+
### [PR-20] Mock-DeepSeek fixture for agent evals (F-24a)
10+
11+
The 12 deselected `agent_eval` integration tests required live TD +
12+
real DeepSeek to run. PR-20 adds a fixture-replay harness so the
13+
same evals run in regular CI without either dependency.
14+
15+
**New infrastructure:**
16+
- `tests/_mock_deepseek.py` — single-file `HTTPServer` that replays
17+
captured DeepSeek `/v1/messages` exchanges. Enforces the
18+
thinking-block echo contract by returning HTTP 400 (with the
19+
canonical DeepSeek error message) when an assistant turn's
20+
`type:thinking` blocks aren't echoed back in the next request.
21+
- `tests/_mock_dispatcher.py` — shape-realistic stub TD tool
22+
results, shared between the capture script and the replay tests
23+
so a fixture captured against version N replays cleanly against
24+
N+1.
25+
- `scripts/capture_deepseek_fixtures.py` — recorder/proxy that
26+
forwards real DeepSeek calls to `api.deepseek.com` and writes
27+
the (request, response) pairs to JSON. Run once per scenario;
28+
fixtures live in `tests/fixtures/deepseek/<name>.json`.
29+
- `tests/conftest.py` — new `mock_deepseek` pytest fixture wraps
30+
the lifecycle: `server = mock_deepseek("scenario")` returns a
31+
started server, auto-stopped at test end.
32+
33+
**11 real DeepSeek fixtures captured** (real API, real responses):
34+
inspect_basic_fps, inspect_nodes_list, recipe_save,
35+
recipe_validate_passes, recipe_validate_rejects_bogus_tool,
36+
build_create_node, knowledge_corpus_present,
37+
knowledge_search_trust_tier, memory_save_and_recall,
38+
batch_parallel_calls, failure_recovery_hint_visible.
39+
40+
**11 mock-driven eval tests** under `tests/agent_evals_mock/`
41+
mirror the live suite at `tests/agent_evals/` but run in regular
42+
CI. The 12th eval (`test_build_no_validation_emits_hint`) tests
43+
the `AgentRuntime`'s validation-hint emission, not the `Agent`
44+
class — that one stays in the live suite.
45+
46+
**Regression detector** at
47+
`tests/agent_evals_mock/test_thinking_echo_regression.py`:
48+
sabotages `_strip_reasoning` to drop thinking blocks and asserts
49+
the mock returns 400. Backstop for
50+
`feedback_deepseek_thinking_blocks_must_echo`.
51+
52+
39 new tests in regular CI (1557 pass / 12 deselected). The 12
53+
live `agent_eval`-marker tests stay deselected — they continue to
54+
exercise the standalone webserver against real TD when the user
55+
runs `pytest -m agent_eval` with TD up.
56+
357
## 1.8.3 - 2026-05-08
458

559
**God-module decompose — Phase 3 PR-16 of the post-1.7 audit plan (F-14).**
Lines changed: 313 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,313 @@
1+
"""Capture real DeepSeek responses to JSON fixtures for PR-20.
2+
3+
Usage::
4+
5+
# One scenario, single user prompt:
6+
uv run python scripts/capture_deepseek_fixtures.py \\
7+
--scenario inspect_basic_fps \\
8+
--prompt "What's the current FPS of the project?"
9+
10+
# Multi-turn — feed extra prompts after the first response:
11+
uv run python scripts/capture_deepseek_fixtures.py \\
12+
--scenario memory_save_and_recall \\
13+
--prompt "Save a memory called X with content 'hello'" \\
14+
--prompt "Now recall X and quote the content"
15+
16+
The recorder:
17+
1. Spawns a localhost HTTP server that forwards every POST to
18+
https://api.deepseek.com/anthropic/v1/messages.
19+
2. Constructs the standalone ``Agent`` (from
20+
``td_component/tdpilot_api_agent.py``) pointing at the proxy.
21+
3. Uses a stub dispatcher that returns realistic-shaped TD tool
22+
results — matches the shape real TD's MCP handlers return so
23+
the model's behavior tracks production.
24+
4. Every (request, response) pair is appended to
25+
``tests/fixtures/deepseek/<scenario>.json`` (pretty-printed,
26+
sorted keys, ASCII-safe).
27+
28+
Note: this script costs DeepSeek API credits. Each fixture is a
29+
small handful of API calls (typically 2-5 turns × $0.01-0.03).
30+
The captured fixtures live in the repo so subsequent CI runs are
31+
free.
32+
33+
API key resolution mirrors ``tdpilot_api_config.fetch_api_key``:
34+
TDPILOT_API_KEY env > ~/.tdpilot-api/config.json > .env file.
35+
"""
36+
37+
from __future__ import annotations
38+
39+
import argparse
40+
import json
41+
import sys
42+
import threading
43+
import urllib.error
44+
import urllib.request
45+
from datetime import datetime, timezone
46+
from http.server import BaseHTTPRequestHandler, HTTPServer
47+
from pathlib import Path
48+
from typing import Any
49+
50+
REPO_ROOT = Path(__file__).resolve().parents[1]
51+
sys.path.insert(0, str(REPO_ROOT / "td_component"))
52+
sys.path.insert(0, str(REPO_ROOT / "tests"))
53+
54+
# Capture and replay use the SAME stub dispatcher so a fixture
55+
# captured against version N replays cleanly against version N+1
56+
# unless the agent's tool-use logic itself changes.
57+
from _mock_dispatcher import default_tools_for_capture, stub_dispatcher # type: ignore[import-not-found]
58+
from tdpilot_api_agent import Agent, AgentError # type: ignore[import-not-found]
59+
from tdpilot_api_config import fetch_api_key # type: ignore[import-not-found]
60+
61+
# ---------------------------------------------------------------------------
62+
# Recording proxy server
63+
# ---------------------------------------------------------------------------
64+
65+
66+
class RecordingProxy:
67+
"""HTTP proxy that forwards POSTs to real DeepSeek and records both
68+
sides for fixture writing. Single-threaded — captures are
69+
sequential and we don't want concurrent overlap.
70+
"""
71+
72+
def __init__(self, real_base_url: str, api_key: str) -> None:
73+
self.real_base_url = real_base_url.rstrip("/")
74+
self.api_key = api_key
75+
self.exchanges: list[dict] = []
76+
self._httpd: HTTPServer | None = None
77+
self._thread: threading.Thread | None = None
78+
79+
@property
80+
def base_url(self) -> str:
81+
if self._httpd is None:
82+
raise RuntimeError("not started")
83+
host, port = self._httpd.server_address[:2]
84+
if isinstance(host, bytes):
85+
host = host.decode("ascii")
86+
return f"http://{host}:{port}/anthropic"
87+
88+
def start(self) -> None:
89+
owner = self
90+
91+
class _Handler(BaseHTTPRequestHandler):
92+
def log_message(self, fmt, *args):
93+
return
94+
95+
def do_POST(self): # noqa: N802
96+
owner._handle_post(self)
97+
98+
self._httpd = HTTPServer(("127.0.0.1", 0), _Handler)
99+
self._thread = threading.Thread(
100+
target=self._httpd.serve_forever,
101+
name="RecordingProxy",
102+
daemon=True,
103+
)
104+
self._thread.start()
105+
106+
def stop(self) -> None:
107+
if self._httpd is None:
108+
return
109+
self._httpd.shutdown()
110+
self._httpd.server_close()
111+
if self._thread is not None:
112+
self._thread.join(timeout=3.0)
113+
self._httpd = None
114+
self._thread = None
115+
116+
def __enter__(self) -> RecordingProxy:
117+
self.start()
118+
return self
119+
120+
def __exit__(self, *exc_info) -> None:
121+
self.stop()
122+
123+
def _handle_post(self, h: BaseHTTPRequestHandler) -> None:
124+
length = int(h.headers.get("Content-Length", "0") or "0")
125+
body = h.rfile.read(length) if length else b""
126+
try:
127+
body_dict = json.loads(body.decode("utf-8")) if body else {}
128+
except (ValueError, UnicodeDecodeError):
129+
body_dict = {}
130+
131+
if not h.path.endswith("/v1/messages"):
132+
h.send_response(404)
133+
h.end_headers()
134+
return
135+
136+
# Forward to real DeepSeek using the agent's actual headers.
137+
# We set x-api-key from our stored key; the agent's outbound
138+
# header (sk-mock) is intentionally replaced — the real
139+
# endpoint needs the real key.
140+
target_url = f"{self.real_base_url}/v1/messages"
141+
req = urllib.request.Request(
142+
url=target_url,
143+
method="POST",
144+
headers={
145+
"Content-Type": "application/json",
146+
"x-api-key": self.api_key,
147+
"anthropic-version": "2023-06-01",
148+
},
149+
data=body,
150+
)
151+
try:
152+
with urllib.request.urlopen(req, timeout=120.0) as resp:
153+
resp_body = resp.read()
154+
resp_status = resp.status
155+
except urllib.error.HTTPError as exc:
156+
resp_body = exc.read()
157+
resp_status = exc.code
158+
except Exception as exc:
159+
err_body = json.dumps({"error": {"message": f"proxy upstream: {exc}"}}).encode()
160+
h.send_response(502)
161+
h.send_header("Content-Type", "application/json")
162+
h.send_header("Content-Length", str(len(err_body)))
163+
h.end_headers()
164+
h.wfile.write(err_body)
165+
return
166+
167+
try:
168+
resp_dict = json.loads(resp_body.decode("utf-8"))
169+
except (ValueError, UnicodeDecodeError):
170+
resp_dict = {}
171+
172+
# Record (request, response) for the fixture.
173+
self.exchanges.append({"request": body_dict, "response": resp_dict})
174+
175+
# Pass through to the agent.
176+
h.send_response(resp_status)
177+
h.send_header("Content-Type", "application/json")
178+
h.send_header("Content-Length", str(len(resp_body)))
179+
h.end_headers()
180+
h.wfile.write(resp_body)
181+
182+
183+
# ---------------------------------------------------------------------------
184+
# Capture orchestrator
185+
# ---------------------------------------------------------------------------
186+
187+
188+
def capture(
189+
scenario: str,
190+
prompts: list[str],
191+
*,
192+
real_base_url: str = "https://api.deepseek.com/anthropic",
193+
model: str = "deepseek-v4-pro",
194+
model_tier: str = "auto",
195+
out_dir: Path | None = None,
196+
system_prompt: str = "",
197+
tools: list[dict] | None = None,
198+
) -> Path:
199+
"""Run the agent against the recording proxy and write the fixture
200+
file. Returns the on-disk path of the written fixture.
201+
"""
202+
api_key = fetch_api_key()
203+
if not api_key:
204+
raise SystemExit(
205+
"No DeepSeek API key found. Set TDPILOT_API_KEY or "
206+
"configure ~/.tdpilot-api/config.json before running."
207+
)
208+
209+
out_dir = out_dir or REPO_ROOT / "tests" / "fixtures" / "deepseek"
210+
out_dir.mkdir(parents=True, exist_ok=True)
211+
out_path = out_dir / f"{scenario}.json"
212+
213+
text_chunks: list[str] = []
214+
tool_calls: list[tuple[str, dict]] = []
215+
tool_results: list[tuple[str, Any, bool]] = []
216+
217+
proxy = RecordingProxy(real_base_url=real_base_url, api_key=api_key)
218+
proxy.start()
219+
try:
220+
agent = Agent(
221+
api_key="sk-recorder-bypass", # not actually used; proxy injects real key
222+
dispatcher=stub_dispatcher,
223+
tools=tools or [],
224+
system_prompt=system_prompt,
225+
base_url=proxy.base_url,
226+
model=model,
227+
model_tier=model_tier,
228+
on_text=text_chunks.append,
229+
on_tool_call=lambda n, a: tool_calls.append((n, a)),
230+
on_tool_result=lambda n, r, e: tool_results.append((n, r, e)),
231+
)
232+
for prompt in prompts:
233+
agent.add_user_message(prompt)
234+
try:
235+
agent.run_turn()
236+
except AgentError as exc:
237+
print(f"[capture] AgentError on prompt {prompt!r}: {exc}", file=sys.stderr)
238+
break
239+
finally:
240+
proxy.stop()
241+
242+
fixture = {
243+
"scenario": scenario,
244+
"captured_at": datetime.now(timezone.utc).isoformat(timespec="seconds"),
245+
"model": model,
246+
"model_tier": model_tier,
247+
"prompts": list(prompts),
248+
"tool_calls_observed": [{"name": n, "args": a} for n, a in tool_calls],
249+
"final_text_concatenated": "\n".join(text_chunks),
250+
"exchanges": proxy.exchanges,
251+
}
252+
out_path.write_text(
253+
json.dumps(fixture, indent=2, ensure_ascii=False, sort_keys=False),
254+
encoding="utf-8",
255+
)
256+
print(
257+
f"[capture] wrote {out_path}{len(proxy.exchanges)} exchange(s), "
258+
f"{len(tool_calls)} tool call(s), {len(text_chunks)} text chunk(s)"
259+
)
260+
return out_path
261+
262+
263+
# ---------------------------------------------------------------------------
264+
# CLI
265+
# ---------------------------------------------------------------------------
266+
267+
268+
def main() -> None:
269+
p = argparse.ArgumentParser(description=__doc__)
270+
p.add_argument("--scenario", required=True, help="fixture name (file stem)")
271+
p.add_argument(
272+
"--prompt",
273+
action="append",
274+
required=True,
275+
help="user prompt (repeat for multi-turn)",
276+
)
277+
p.add_argument(
278+
"--model-tier",
279+
default="auto",
280+
choices=("auto", "flash", "pro"),
281+
help="model routing tier",
282+
)
283+
p.add_argument(
284+
"--model",
285+
default="deepseek-v4-pro",
286+
help="model name when tier=pro",
287+
)
288+
p.add_argument(
289+
"--system-prompt",
290+
default="You are TDPilot, an assistant inside TouchDesigner. Use tools when needed.",
291+
help="system prompt for the capture session",
292+
)
293+
p.add_argument(
294+
"--out-dir",
295+
type=Path,
296+
default=None,
297+
help="override the fixture output directory (default: tests/fixtures/deepseek)",
298+
)
299+
args = p.parse_args()
300+
301+
capture(
302+
scenario=args.scenario,
303+
prompts=list(args.prompt),
304+
model=args.model,
305+
model_tier=args.model_tier,
306+
system_prompt=args.system_prompt,
307+
out_dir=args.out_dir,
308+
tools=default_tools_for_capture(),
309+
)
310+
311+
312+
if __name__ == "__main__":
313+
main()

0 commit comments

Comments
 (0)