smoke-python-runtime.py 31 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784
  1. #!/usr/bin/env python3
  2. """Keyless full-turn and snapshot smoke for the Python SDK runtime."""
  3. from __future__ import annotations
  4. import argparse
  5. import difflib
  6. import json
  7. import os
  8. import queue
  9. import subprocess
  10. import tempfile
  11. import threading
  12. import time
  13. from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
  14. from pathlib import Path
  15. from typing import TYPE_CHECKING, Callable
  16. if TYPE_CHECKING:
  17. from deepseek_harness import TurnResult
  18. EXPECTED_TEXT = "runtime smoke ok"
  19. CODE_PROMPT = "Use run_code to compute the packaged worker smoke value."
  20. CODE_WORKER_TEXT = "code worker smoke ok"
  21. WORKFLOW_PROMPT = "Use workflow to compute the packaged worker smoke value without agents."
  22. WORKFLOW_WORKER_TEXT = "workflow worker smoke ok"
  23. SNAPSHOT_PROMPT = "Run the advanced packaged-runtime snapshot scenario."
  24. SNAPSHOT_SESSION_ID = "advanced-executable"
  25. SNAPSHOT_DIRECT_CHILD_PROMPT = "Reply with exactly DIRECT_CHILD_OK and nothing else."
  26. SNAPSHOT_WORKFLOW_CHILD_PROMPT = "Reply with exactly WORKFLOW_CHILD_OK and nothing else."
  27. SNAPSHOT_FINAL_TEXT = "ADVANCED_EXECUTABLE_OK"
  28. SNAPSHOT_MOUNT_CODE = """\
  29. return (ctx) => {
  30. harness.registerTool(ctx, harness.defineTool({
  31. name: 'snapshot_double',
  32. description: 'Double a number for executable snapshot verification.',
  33. parameters: { value: { type: 'number', required: true } },
  34. async execute(args) {
  35. return [{ type: 'text', text: String(args.value * 2) }]
  36. }
  37. }))
  38. }
  39. """
  40. SNAPSHOT_WORKFLOW_SCRIPT = (
  41. "phase('Delegate')\n"
  42. f"const reply = await agent('{SNAPSHOT_WORKFLOW_CHILD_PROMPT}', {{ label: 'workflow-child' }})\n"
  43. "return { reply }"
  44. )
  45. SNAPSHOT_DIRECTORY = (
  46. Path(__file__).resolve().parent / "snapshots" / "python-sdk-single-exe" / "advanced"
  47. )
  48. SNAPSHOT_FILENAMES = ("result.json", "session.jsonl", "session.1.jsonl", "session.2.jsonl")
  49. CUSTOM_CORDIS = """\
  50. - id: jsonrpc
  51. name: '@deepseek-ai/dsh-jsonrpc'
  52. - id: agent-core
  53. name: '@deepseek-ai/dsh-agent-spine-demo'
  54. config:
  55. workspaceContext: false
  56. tools:
  57. mode: both
  58. - id: sessions
  59. name: '@deepseek-ai/dsh-session-persistence-jsonl'
  60. config:
  61. root: !!js process.env.DSH_SESSION_ROOT
  62. compression: 'none'
  63. - id: bash
  64. name: '@deepseek-ai/dsh-bash-local'
  65. config:
  66. cwd: !!js process.env.DSH_CWD
  67. - id: code-runtime
  68. name: '@deepseek-ai/dsh-code-runtime-worker'
  69. - id: subagents
  70. name: '@deepseek-ai/dsh-subagent'
  71. - id: subagent-spawn
  72. name: '@deepseek-ai/dsh-subagent-spawn'
  73. config:
  74. providerName: spawn
  75. - id: subagent-tool
  76. name: '@deepseek-ai/dsh-tool-subagent'
  77. config:
  78. provider: spawn
  79. - id: workflow-engine
  80. name: '@deepseek-ai/dsh-workflow-workerthread'
  81. config:
  82. provider: spawn
  83. - id: workflow-tool
  84. name: '@deepseek-ai/dsh-tool-workflow'
  85. - id: cordis-tool
  86. name: '@deepseek-ai/dsh-tool-cordis'
  87. """
  88. class MockModelHandler(BaseHTTPRequestHandler):
  89. """Return deterministic text, worker, and orchestration completions."""
  90. requests: list[dict[str, object]] = []
  91. def do_POST(self) -> None:
  92. content_length = int(self.headers.get("content-length", "0"))
  93. body = json.loads(self.rfile.read(content_length))
  94. self.requests.append(body)
  95. self.send_response(200)
  96. self.send_header("content-type", "text/event-stream")
  97. self.end_headers()
  98. chunks = completion_chunks(body)
  99. for chunk in chunks:
  100. self.wfile.write(f"data: {json.dumps(chunk)}\n\n".encode())
  101. self.wfile.write(b"data: [DONE]\n\n")
  102. self.wfile.flush()
  103. def log_message(self, _format: str, *_args: object) -> None:
  104. return
  105. def completion_chunks(body: dict[str, object]) -> list[dict[str, object]]:
  106. """Choose the next deterministic model response from request history."""
  107. messages = body.get("messages")
  108. if not isinstance(messages, list) or not messages:
  109. raise AssertionError(f"model request has no messages: {body}")
  110. latest = messages[-1]
  111. if not isinstance(latest, dict):
  112. raise AssertionError(f"model request has an invalid latest message: {body}")
  113. if latest.get("role") == "tool":
  114. call_id, tool_name = latest_tool_call(messages)
  115. tool_text = message_text(latest.get("content"))
  116. advanced = advanced_tool_followup(body, call_id, tool_name, tool_text)
  117. if advanced is not None:
  118. return advanced
  119. if "42" not in tool_text:
  120. raise AssertionError(f"{tool_name} worker returned no expected value: {latest}")
  121. if tool_name == "run_code":
  122. return text_chunks(CODE_WORKER_TEXT)
  123. if tool_name == "workflow":
  124. return text_chunks(WORKFLOW_WORKER_TEXT)
  125. raise AssertionError(f"unexpected tool follow-up: {tool_name}")
  126. prompt = message_text(latest.get("content"))
  127. if prompt == SNAPSHOT_DIRECT_CHILD_PROMPT:
  128. return text_chunks("DIRECT_CHILD_OK")
  129. if prompt == SNAPSHOT_WORKFLOW_CHILD_PROMPT:
  130. return text_chunks("WORKFLOW_CHILD_OK")
  131. if prompt == SNAPSHOT_PROMPT:
  132. assert_advertised_tool(body, "cordis_mount")
  133. return tool_call_chunks(
  134. "advanced-mount",
  135. "cordis_mount",
  136. {"code": SNAPSHOT_MOUNT_CODE},
  137. )
  138. if prompt == CODE_PROMPT:
  139. assert_advertised_tool(body, "run_code")
  140. return tool_call_chunks("call-code-worker", "run_code", {"code": "return 6 * 7"})
  141. if prompt == WORKFLOW_PROMPT:
  142. assert_advertised_tool(body, "workflow")
  143. return tool_call_chunks(
  144. "call-workflow-worker",
  145. "workflow",
  146. {
  147. "script": "return 6 * 7",
  148. "meta": {
  149. "name": "pkg-worker-smoke",
  150. "description": "exercise the packaged workflow worker",
  151. },
  152. },
  153. )
  154. return text_chunks(EXPECTED_TEXT)
  155. def advanced_tool_followup(
  156. body: dict[str, object],
  157. call_id: str,
  158. tool_name: str,
  159. tool_text: str,
  160. ) -> list[dict[str, object]] | None:
  161. """Advance the executable snapshot's deterministic parent tool chain."""
  162. if not call_id.startswith("advanced-"):
  163. return None
  164. if call_id == "advanced-mount" and tool_name == "cordis_mount":
  165. if "mounted dyn-1" not in tool_text:
  166. raise AssertionError(f"cordis_mount returned no mount id: {tool_text}")
  167. assert_advertised_tool(body, "run_code")
  168. assert_advertised_tool(body, "snapshot_double")
  169. return tool_call_chunks(
  170. "advanced-code",
  171. "run_code",
  172. {"code": "return await tools.snapshot_double({ value: 21 })"},
  173. )
  174. if call_id == "advanced-code" and tool_name == "run_code":
  175. if "42" not in tool_text:
  176. raise AssertionError(f"run_code returned no dynamic-tool value: {tool_text}")
  177. assert_advertised_tool(body, "subagent")
  178. return tool_call_chunks(
  179. "advanced-direct-child",
  180. "subagent",
  181. {
  182. "description": "Check direct child",
  183. "prompt": SNAPSHOT_DIRECT_CHILD_PROMPT,
  184. },
  185. )
  186. if call_id == "advanced-direct-child" and tool_name == "subagent":
  187. if "DIRECT_CHILD_OK" not in tool_text:
  188. raise AssertionError(f"subagent returned no expected child value: {tool_text}")
  189. assert_advertised_tool(body, "workflow")
  190. return tool_call_chunks(
  191. "advanced-workflow",
  192. "workflow",
  193. {
  194. "script": SNAPSHOT_WORKFLOW_SCRIPT,
  195. "meta": {
  196. "name": "advanced-exe-snapshot",
  197. "description": "exercise one packaged workflow child",
  198. },
  199. },
  200. )
  201. if call_id == "advanced-workflow" and tool_name == "workflow":
  202. if "WORKFLOW_CHILD_OK" not in tool_text:
  203. raise AssertionError(f"workflow returned no expected child value: {tool_text}")
  204. assert_advertised_tool(body, "cordis_unmount")
  205. return tool_call_chunks(
  206. "advanced-unmount",
  207. "cordis_unmount",
  208. {"id": "dyn-1"},
  209. )
  210. if call_id == "advanced-unmount" and tool_name == "cordis_unmount":
  211. if "unmounted dyn-1" not in tool_text:
  212. raise AssertionError(f"cordis_unmount returned no disposal result: {tool_text}")
  213. if "snapshot_double" in advertised_tool_names(body):
  214. raise AssertionError("snapshot_double remained advertised after cordis_unmount")
  215. return text_chunks(SNAPSHOT_FINAL_TEXT)
  216. raise AssertionError(f"unexpected advanced tool follow-up: {call_id} {tool_name}: {tool_text}")
  217. def text_chunks(text: str) -> list[dict[str, object]]:
  218. """Build a complete streaming text response."""
  219. return [
  220. {"choices": [{"delta": {"role": "assistant", "content": None, "reasoning_content": ""}}]},
  221. {"choices": [{"delta": {"content": text}}]},
  222. {
  223. "choices": [{"delta": {"content": ""}, "finish_reason": "stop"}],
  224. "usage": {"prompt_tokens": 3, "completion_tokens": 3},
  225. },
  226. ]
  227. def tool_call_chunks(call_id: str, name: str, arguments: dict[str, object]) -> list[dict[str, object]]:
  228. """Build a complete streaming function-call response."""
  229. return [
  230. {"choices": [{"delta": {"role": "assistant", "content": None, "reasoning_content": ""}}]},
  231. {
  232. "choices": [{
  233. "delta": {
  234. "tool_calls": [{
  235. "index": 0,
  236. "id": call_id,
  237. "type": "function",
  238. "function": {"name": name, "arguments": json.dumps(arguments)},
  239. }],
  240. },
  241. }],
  242. },
  243. {
  244. "choices": [{"delta": {"content": ""}, "finish_reason": "tool_calls"}],
  245. "usage": {"prompt_tokens": 3, "completion_tokens": 3},
  246. },
  247. ]
  248. def latest_tool_call(messages: list[object]) -> tuple[str, str]:
  249. """Find the assistant call id and name paired with the latest tool result."""
  250. for message in reversed(messages[:-1]):
  251. if not isinstance(message, dict):
  252. continue
  253. calls = message.get("tool_calls")
  254. if not isinstance(calls, list):
  255. continue
  256. for call in reversed(calls):
  257. if not isinstance(call, dict):
  258. continue
  259. function = call.get("function")
  260. call_id = call.get("id")
  261. if (
  262. isinstance(call_id, str)
  263. and isinstance(function, dict)
  264. and isinstance(function.get("name"), str)
  265. ):
  266. return call_id, function["name"]
  267. raise AssertionError(f"tool result has no preceding assistant tool call: {messages}")
  268. def message_text(content: object) -> str:
  269. """Read OpenAI text content in either string or block-list form."""
  270. if isinstance(content, str):
  271. return content
  272. if isinstance(content, list):
  273. return "".join(
  274. block.get("text", "")
  275. for block in content
  276. if isinstance(block, dict) and isinstance(block.get("text"), str)
  277. )
  278. return ""
  279. def advertised_tool_names(body: dict[str, object]) -> set[str]:
  280. """Return the model-facing tool names advertised on one request."""
  281. tools = body.get("tools")
  282. if not isinstance(tools, list):
  283. raise AssertionError(f"model request advertised no tools: {body}")
  284. names: set[str] = set()
  285. for tool in tools:
  286. if not isinstance(tool, dict):
  287. continue
  288. function = tool.get("function")
  289. if isinstance(function, dict) and isinstance(function.get("name"), str):
  290. names.add(function["name"])
  291. return names
  292. def assert_advertised_tool(body: dict[str, object], expected: str) -> None:
  293. """Require the packaged deployment to expose the requested tool."""
  294. names = advertised_tool_names(body)
  295. if expected not in names:
  296. raise AssertionError(f"model request did not advertise {expected}: {names}")
  297. class MockModel:
  298. def __enter__(self) -> "MockModel":
  299. MockModelHandler.requests.clear()
  300. self.server = ThreadingHTTPServer(("127.0.0.1", 0), MockModelHandler)
  301. self.thread = threading.Thread(target=self.server.serve_forever, daemon=True)
  302. self.thread.start()
  303. host, port = self.server.server_address
  304. self.url = f"http://{host}:{port}"
  305. return self
  306. def __exit__(self, _exc_type: object, _exc: object, _tb: object) -> None:
  307. self.server.shutdown()
  308. self.server.server_close()
  309. self.thread.join(timeout=5)
  310. def main() -> None:
  311. parser = argparse.ArgumentParser(description=__doc__)
  312. parser.add_argument(
  313. "--scenario",
  314. choices=("all", "sdk-default", "sdk-custom", "sdk-snapshot", "direct"),
  315. default="all",
  316. )
  317. parser.add_argument("--exe", type=Path)
  318. parser.add_argument("--update-snapshots", action="store_true")
  319. args = parser.parse_args()
  320. if args.scenario in {"all", "sdk-custom", "sdk-snapshot", "direct"} and args.exe is None:
  321. parser.error("--exe is required for custom, snapshot, and direct scenarios")
  322. if args.update_snapshots and args.scenario not in {"all", "sdk-snapshot"}:
  323. parser.error("--update-snapshots requires --scenario sdk-snapshot or all")
  324. if args.exe is not None and not args.exe.is_file():
  325. parser.error(f"runtime executable does not exist: {args.exe}")
  326. with MockModel() as model:
  327. if args.scenario in {"all", "sdk-default"}:
  328. smoke_sdk_default(model.url)
  329. if args.scenario in {"all", "sdk-custom"}:
  330. assert args.exe is not None
  331. smoke_sdk_custom(model.url, args.exe.resolve())
  332. if args.scenario in {"all", "sdk-snapshot"}:
  333. assert args.exe is not None
  334. smoke_sdk_snapshot(model.url, args.exe.resolve(), args.update_snapshots)
  335. if args.scenario in {"all", "direct"}:
  336. assert args.exe is not None
  337. smoke_direct(model.url, args.exe.resolve())
  338. if not MockModelHandler.requests:
  339. raise AssertionError("mock model endpoint received no requests")
  340. print(f"smoke-python-runtime: {args.scenario} passed")
  341. def smoke_sdk_default(base_url: str) -> None:
  342. from deepseek_harness import DeepSeekHarness
  343. with tempfile.TemporaryDirectory(prefix="dsh-sdk-default-") as temporary:
  344. root = Path(temporary).resolve()
  345. sessions = root / "sessions"
  346. with DeepSeekHarness(
  347. provider="deepseek",
  348. model="smoke-model",
  349. cwd=str(root),
  350. session_root=str(sessions),
  351. api_key="sk-keyless-smoke",
  352. base_url=base_url,
  353. request_timeout_seconds=60,
  354. ) as harness:
  355. result = harness.run("reply with the smoke text", session_id="default-smoke")
  356. assert result.status == "ok", result
  357. assert result.final_response == EXPECTED_TEXT, result.final_response
  358. assert_zstd_session_log(sessions)
  359. def smoke_sdk_custom(base_url: str, executable: Path) -> None:
  360. from deepseek_harness import DeepSeekHarness
  361. with tempfile.TemporaryDirectory(prefix="dsh-sdk-custom-") as temporary:
  362. root = Path(temporary).resolve()
  363. sessions = root / "sessions"
  364. cordis = root / "cordis.yml"
  365. cordis.write_text(CUSTOM_CORDIS)
  366. with DeepSeekHarness(
  367. provider="deepseek",
  368. model="smoke-model",
  369. cwd=str(root),
  370. session_root=str(sessions),
  371. cordis=str(cordis),
  372. runtime_bin=str(executable),
  373. api_key="sk-keyless-smoke",
  374. base_url=base_url,
  375. request_timeout_seconds=60,
  376. ) as harness:
  377. text_result = harness.run("reply with the smoke text", session_id="custom-smoke")
  378. code_result = harness.run(CODE_PROMPT, session_id="custom-smoke")
  379. workflow_result = harness.run(WORKFLOW_PROMPT, session_id="custom-smoke")
  380. assert text_result.status == "ok", text_result
  381. assert text_result.final_response == EXPECTED_TEXT, text_result.final_response
  382. assert code_result.status == "ok", code_result
  383. assert code_result.final_response == CODE_WORKER_TEXT, code_result.final_response
  384. assert workflow_result.status == "ok", workflow_result
  385. assert workflow_result.final_response == WORKFLOW_WORKER_TEXT, workflow_result.final_response
  386. assert_session_log(sessions, root, EXPECTED_TEXT, CODE_WORKER_TEXT, WORKFLOW_WORKER_TEXT)
  387. def smoke_sdk_snapshot(base_url: str, executable: Path, update_snapshots: bool) -> None:
  388. """Drive and compare the advanced SDK/executable behavioral snapshot."""
  389. from deepseek_harness import DeepSeekHarness
  390. with tempfile.TemporaryDirectory(prefix="dsh-sdk-snapshot-") as temporary:
  391. root = Path(temporary).resolve()
  392. sessions = root / "sessions"
  393. cordis = root / "cordis.yml"
  394. cordis.write_text(CUSTOM_CORDIS)
  395. with DeepSeekHarness(
  396. provider="deepseek",
  397. model="smoke-model",
  398. cwd=str(root),
  399. session_root=str(sessions),
  400. cordis=str(cordis),
  401. runtime_bin=str(executable),
  402. api_key="sk-keyless-smoke",
  403. base_url=base_url,
  404. request_timeout_seconds=60,
  405. ) as harness:
  406. result = harness.run(SNAPSHOT_PROMPT, session_id=SNAPSHOT_SESSION_ID)
  407. assert result.status == "ok", result
  408. assert result.final_response == SNAPSHOT_FINAL_TEXT, result.final_response
  409. methods = [notification.method for notification in result.notifications]
  410. if methods.count("subagent.started") != 2 or methods.count("subagent.finished") != 2:
  411. raise AssertionError(f"advanced snapshot emitted unexpected subagent lifecycle: {methods}")
  412. if not any(event.get("type") == "tool/code-dispatch" for event in result.events):
  413. raise AssertionError("advanced snapshot emitted no tool/code-dispatch event")
  414. logs = read_session_logs(sessions)
  415. child_ids = snapshot_child_ids(result)
  416. expected_ids = {SNAPSHOT_SESSION_ID, *child_ids}
  417. if set(logs) != expected_ids:
  418. raise AssertionError(f"advanced snapshot expected parent plus two child logs: {sorted(logs)}")
  419. if "DIRECT_CHILD_OK" not in render_jsonl(logs[child_ids[0]]):
  420. raise AssertionError("first advanced child log has no direct-subagent result")
  421. if "WORKFLOW_CHILD_OK" not in render_jsonl(logs[child_ids[1]]):
  422. raise AssertionError("second advanced child log has no workflow-subagent result")
  423. files = build_snapshot_files(result, logs, child_ids, root)
  424. compare_snapshot_files(files, update_snapshots)
  425. def smoke_direct(base_url: str, executable: Path) -> None:
  426. with tempfile.TemporaryDirectory(prefix="dsh-direct-") as temporary:
  427. root = Path(temporary).resolve()
  428. sessions = root / "sessions"
  429. cordis = root / "cordis.yml"
  430. cordis.write_text(CUSTOM_CORDIS)
  431. environment = {
  432. **os.environ,
  433. "DSH_CORDIS_CONFIG": str(cordis),
  434. "DSH_SESSION_ROOT": str(sessions),
  435. "DSH_CWD": str(root),
  436. "DEEPSEEK_API_KEY": "sk-keyless-smoke",
  437. "DEEPSEEK_BASE_URL": base_url,
  438. }
  439. peer = RuntimePeer([str(executable)], root, environment)
  440. try:
  441. peer.send({"jsonrpc": "2.0", "id": "initialize", "method": "initialize", "params": {"cwd": str(root), "provider": "deepseek", "model": "smoke-model"}})
  442. peer.read_until(lambda message: message.get("id") == "initialize")
  443. peer.send({
  444. "jsonrpc": "2.0",
  445. "id": "prompt",
  446. "method": "session/prompt",
  447. "params": {"sessionId": "direct-smoke", "contentBlocks": [{"type": "text", "text": "reply with the smoke text"}]},
  448. })
  449. messages = peer.read_until(lambda message: message.get("id") == "prompt")
  450. if not any(message.get("method") == "session.finished" and message.get("params", {}).get("status") == "ok" for message in messages):
  451. messages.extend(peer.read_until(lambda message: message.get("method") == "session.finished"))
  452. event_text = json.dumps(messages)
  453. if EXPECTED_TEXT not in event_text:
  454. raise AssertionError(f"direct runtime emitted no final response: {messages}")
  455. peer.send({"jsonrpc": "2.0", "id": "shutdown", "method": "shutdown"})
  456. peer.read_until(lambda message: message.get("id") == "shutdown")
  457. finally:
  458. peer.close()
  459. assert_session_log(sessions, root, EXPECTED_TEXT)
  460. class RuntimePeer:
  461. def __init__(self, argv: list[str], cwd: Path, environment: dict[str, str]) -> None:
  462. self.process = subprocess.Popen(
  463. argv,
  464. cwd=cwd,
  465. env=environment,
  466. stdin=subprocess.PIPE,
  467. stdout=subprocess.PIPE,
  468. stderr=subprocess.PIPE,
  469. text=True,
  470. encoding="utf-8",
  471. bufsize=1,
  472. )
  473. self.stdout: queue.Queue[str | None] = queue.Queue()
  474. self.stderr: list[str] = []
  475. threading.Thread(target=self._read_stdout, daemon=True).start()
  476. threading.Thread(target=self._read_stderr, daemon=True).start()
  477. def send(self, message: dict[str, object]) -> None:
  478. if self.process.stdin is None:
  479. raise RuntimeError("runtime stdin is unavailable")
  480. self.process.stdin.write(json.dumps(message) + "\n")
  481. self.process.stdin.flush()
  482. def read_until(self, predicate: Callable[[dict[str, object]], bool]) -> list[dict[str, object]]:
  483. deadline = time.monotonic() + 60
  484. messages: list[dict[str, object]] = []
  485. while time.monotonic() < deadline:
  486. try:
  487. line = self.stdout.get(timeout=min(0.25, deadline - time.monotonic()))
  488. except queue.Empty:
  489. continue
  490. if line is None:
  491. raise RuntimeError(f"runtime exited before expected message; stderr: {''.join(self.stderr)}")
  492. try:
  493. message = json.loads(line)
  494. except json.JSONDecodeError:
  495. continue
  496. messages.append(message)
  497. if predicate(message):
  498. return messages
  499. raise TimeoutError(f"runtime timed out; messages={messages}; stderr={''.join(self.stderr)}")
  500. def close(self) -> None:
  501. if self.process.stdin is not None and not self.process.stdin.closed:
  502. self.process.stdin.close()
  503. try:
  504. self.process.wait(timeout=10)
  505. except subprocess.TimeoutExpired:
  506. self.process.kill()
  507. self.process.wait()
  508. if self.process.returncode not in {0, -15}:
  509. raise RuntimeError(f"runtime exited {self.process.returncode}; stderr: {''.join(self.stderr)}")
  510. def _read_stdout(self) -> None:
  511. assert self.process.stdout is not None
  512. for line in self.process.stdout:
  513. self.stdout.put(line)
  514. self.stdout.put(None)
  515. def _read_stderr(self) -> None:
  516. assert self.process.stderr is not None
  517. self.stderr.extend(self.process.stderr)
  518. def assert_session_log(sessions: Path, cwd: Path, *expected_texts: str) -> None:
  519. logs = list(sessions.rglob("*.jsonl"))
  520. if len(logs) != 1:
  521. raise AssertionError(f"expected one JSONL session log under {sessions}, found {logs}")
  522. lines = logs[0].read_text().splitlines()
  523. header = json.loads(lines[0])
  524. if header.get("cwd") != str(cwd):
  525. raise AssertionError(f"session header cwd is not absolute/canonical: {header}")
  526. rendered = "\n".join(lines)
  527. for expected in expected_texts:
  528. if expected not in rendered:
  529. raise AssertionError(f"session log has no {expected!r} response: {logs[0]}")
  530. def assert_zstd_session_log(sessions: Path) -> None:
  531. logs = list(sessions.rglob("*.jsonl.zstd"))
  532. if len(logs) != 1:
  533. raise AssertionError(f"expected one Zstandard JSONL session log under {sessions}, found {logs}")
  534. if not logs[0].read_bytes().startswith(bytes.fromhex("28b52ffd")):
  535. raise AssertionError(f"session log has no Zstandard magic: {logs[0]}")
  536. def read_session_logs(sessions: Path) -> dict[str, list[dict[str, object]]]:
  537. """Parse every persisted JSONL session into a map keyed by header id."""
  538. logs: dict[str, list[dict[str, object]]] = {}
  539. for path in sorted(sessions.rglob("*.jsonl")):
  540. records = [
  541. json.loads(line)
  542. for line in path.read_text(encoding="utf-8").splitlines()
  543. if line
  544. ]
  545. if not records or records[0].get("type") != "session":
  546. raise AssertionError(f"session log has no header: {path}")
  547. session_id = records[0].get("id")
  548. if not isinstance(session_id, str):
  549. raise AssertionError(f"session log header has no string id: {path}")
  550. if session_id in logs:
  551. raise AssertionError(f"duplicate persisted session id: {session_id}")
  552. logs[session_id] = records
  553. return logs
  554. def snapshot_child_ids(result: "TurnResult") -> list[str]:
  555. """Return the two child session ids in their SDK notification order."""
  556. child_ids: list[str] = []
  557. for notification in result.notifications:
  558. if notification.method != "subagent.started":
  559. continue
  560. payload = notification.payload
  561. if payload.get("parentSessionId") != SNAPSHOT_SESSION_ID:
  562. continue
  563. child_id = payload.get("childSessionId")
  564. if isinstance(child_id, str) and child_id not in child_ids:
  565. child_ids.append(child_id)
  566. if len(child_ids) != 2:
  567. raise AssertionError(f"advanced snapshot expected two child session ids: {child_ids}")
  568. return child_ids
  569. def build_snapshot_files(
  570. result: "TurnResult",
  571. logs: dict[str, list[dict[str, object]]],
  572. child_ids: list[str],
  573. cwd: Path,
  574. ) -> dict[str, str]:
  575. """Render the SDK result and three persisted logs into stable expected outputs."""
  576. replacements = [(str(cwd), "{{cwd}}"), (SNAPSHOT_SESSION_ID, "{{parent}}")]
  577. for index, child_id in enumerate(child_ids, start=1):
  578. replacements.append((child_id, f"{{{{child-{index}}}}}"))
  579. agent_id = snapshot_agent_id(result, child_id)
  580. replacements.append((agent_id, f"{{{{agent-{index}}}}}"))
  581. replacements.sort(key=lambda pair: len(pair[0]), reverse=True)
  582. result_value = {
  583. "session_id": result.session_id,
  584. "status": result.status,
  585. "final_response": result.final_response,
  586. "events": result.events,
  587. "notifications": [
  588. {"method": notification.method, "payload": notification.payload}
  589. for notification in result.notifications
  590. ],
  591. "session_root": result.session_root,
  592. }
  593. normalized_result = normalize_snapshot_value(result_value, replacements)
  594. files = {
  595. "result.json": json.dumps(normalized_result, indent=2, ensure_ascii=False) + "\n",
  596. "session.jsonl": render_jsonl(
  597. [normalize_snapshot_value(record, replacements) for record in logs[SNAPSHOT_SESSION_ID]]
  598. ),
  599. }
  600. for index, child_id in enumerate(child_ids, start=1):
  601. files[f"session.{index}.jsonl"] = render_jsonl(
  602. [normalize_snapshot_value(record, replacements) for record in logs[child_id]]
  603. )
  604. if tuple(files) != SNAPSHOT_FILENAMES:
  605. raise AssertionError(f"advanced snapshot file set drifted: {tuple(files)}")
  606. return files
  607. def snapshot_agent_id(result: "TurnResult", child_id: str) -> str:
  608. """Find the successful subagent id paired with one child session."""
  609. for notification in result.notifications:
  610. if notification.method != "subagent.finished":
  611. continue
  612. payload = notification.payload
  613. if payload.get("childSessionId") != child_id:
  614. continue
  615. if payload.get("provider") != "spawn" or payload.get("status") != "ok":
  616. raise AssertionError(f"advanced child did not finish successfully: {payload}")
  617. agent_id = payload.get("agentId")
  618. if isinstance(agent_id, str):
  619. return agent_id
  620. raise AssertionError(f"advanced snapshot has no finished agent for child {child_id}")
  621. def normalize_snapshot_value(
  622. value: object,
  623. replacements: list[tuple[str, str]],
  624. ) -> object:
  625. """Scrub volatile values and bulky request headers without losing behavior."""
  626. if isinstance(value, str):
  627. normalized = value
  628. for actual, token in replacements:
  629. normalized = normalized.replace(actual, token)
  630. return normalized
  631. if isinstance(value, list):
  632. return [normalize_snapshot_value(item, replacements) for item in value]
  633. if not isinstance(value, dict):
  634. return value
  635. normalized = {
  636. key: normalize_snapshot_value(item, replacements)
  637. for key, item in value.items()
  638. }
  639. if normalized.get("type") == "session" and "createdAt" in normalized:
  640. normalized["createdAt"] = 0
  641. if "seq" in normalized and "time" in normalized:
  642. normalized["time"] = 0
  643. scrub_snapshot_header(normalized)
  644. return normalized
  645. def scrub_snapshot_header(value: dict[object, object]) -> None:
  646. """Tokenize full request-header bulk while retaining tool names."""
  647. data = value.get("data")
  648. if not isinstance(data, dict):
  649. return
  650. if value.get("type") == "request/header":
  651. header = data.get("header")
  652. if not isinstance(header, dict):
  653. return
  654. if "system" in header:
  655. header["system"] = "{{system}}"
  656. tools = header.get("tools")
  657. if isinstance(tools, list):
  658. header["tools"] = [
  659. tool.get("name") if isinstance(tool, dict) else "{{tools}}"
  660. for tool in tools
  661. ]
  662. if isinstance(header.get("messagePrefix"), list):
  663. header["messagePrefix"] = ["{{messagePrefix}}" for _ in header["messagePrefix"]]
  664. def render_jsonl(records: list[object]) -> str:
  665. """Render parsed JSON values as compact, newline-terminated JSONL."""
  666. return "".join(
  667. json.dumps(record, ensure_ascii=False, separators=(",", ":")) + "\n"
  668. for record in records
  669. )
  670. def compare_snapshot_files(files: dict[str, str], update: bool) -> None:
  671. """Write or exactly compare the advanced executable snapshot files."""
  672. if update:
  673. SNAPSHOT_DIRECTORY.mkdir(parents=True, exist_ok=True)
  674. for name, content in files.items():
  675. (SNAPSHOT_DIRECTORY / name).write_text(content, encoding="utf-8")
  676. print(f"smoke-python-runtime: updated snapshots in {SNAPSHOT_DIRECTORY}")
  677. existing = {
  678. path.name
  679. for path in SNAPSHOT_DIRECTORY.iterdir()
  680. if path.is_file()
  681. } if SNAPSHOT_DIRECTORY.is_dir() else set()
  682. expected = set(SNAPSHOT_FILENAMES)
  683. if existing != expected:
  684. raise AssertionError(
  685. "advanced snapshot files differ: "
  686. f"missing={sorted(expected - existing)}, unexpected={sorted(existing - expected)}"
  687. )
  688. for name, actual in files.items():
  689. expected_text = (SNAPSHOT_DIRECTORY / name).read_text(encoding="utf-8")
  690. if actual == expected_text:
  691. continue
  692. diff = "".join(difflib.unified_diff(
  693. expected_text.splitlines(keepends=True),
  694. actual.splitlines(keepends=True),
  695. fromfile=f"expected/{name}",
  696. tofile=f"actual/{name}",
  697. ))
  698. raise AssertionError(
  699. f"advanced executable snapshot mismatch in {name}; "
  700. "rerun with --update-snapshots after reviewing the behavior\n"
  701. f"{diff}"
  702. )
  703. if __name__ == "__main__":
  704. main()