smoke-python-runtime.py 43 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057
  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 RunResult
  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. MINIMAL_PROMPT = "Exercise the packaged minimal agent's persistent Bash and string-replacement editor."
  24. MINIMAL_TEXT = "minimal agent smoke ok"
  25. MINIMAL_EDITOR_PATH_PREFIX = "Editor path: "
  26. MINIMAL_CORDIS = (
  27. Path(__file__).resolve().parent.parent / "examples" / "jsonrpc-agent" / "minimal.cordis.yml"
  28. )
  29. MINIMAL_BASH_COMMAND = (
  30. "counter=$(( ${counter:-0} + 1 )); export counter; "
  31. "printf 'COUNT=%s CWD=%s\\n' \"$counter\" \"$PWD\"; "
  32. "if [ \"$counter\" -eq 1 ]; then cd /tmp; fi"
  33. )
  34. SNAPSHOT_PROMPT = "Run the advanced packaged-runtime snapshot scenario."
  35. SNAPSHOT_SESSION_ID = "advanced-executable"
  36. SNAPSHOT_DIRECT_CHILD_PROMPT = "Reply with exactly DIRECT_CHILD_OK and nothing else."
  37. SNAPSHOT_WORKFLOW_CHILD_PROMPT = "Reply with exactly WORKFLOW_CHILD_OK and nothing else."
  38. SNAPSHOT_FINAL_TEXT = "ADVANCED_EXECUTABLE_OK"
  39. SNAPSHOT_PLUGIN_CODE = """\
  40. return (ctx) => {
  41. harness.registerTool(ctx, harness.defineTool({
  42. name: 'snapshot_double',
  43. description: 'Double a number for executable snapshot verification.',
  44. parameters: { value: { type: 'number', required: true } },
  45. output: {
  46. schema: { type: 'number' },
  47. render(_args, value) {
  48. return [{ type: 'text', text: String(value) }]
  49. }
  50. },
  51. async execute(args) {
  52. return args.value * 2
  53. }
  54. }))
  55. }
  56. """
  57. SNAPSHOT_WORKFLOW_SCRIPT = (
  58. "phase('Delegate')\n"
  59. f"const reply = await agent('{SNAPSHOT_WORKFLOW_CHILD_PROMPT}', {{ label: 'workflow-child' }})\n"
  60. "return { reply }"
  61. )
  62. ADVANCED_SNAPSHOT_DIRECTORY = (
  63. Path(__file__).resolve().parent / "snapshots" / "python-sdk-single-exe" / "advanced"
  64. )
  65. ADVANCED_SNAPSHOT_FILENAMES = ("result.json", "session.jsonl", "session.1.jsonl", "session.2.jsonl")
  66. MINIMAL_SNAPSHOT_DIRECTORY = (
  67. Path(__file__).resolve().parent / "snapshots" / "python-sdk-single-exe" / "minimal"
  68. )
  69. MINIMAL_SNAPSHOT_FILENAMES = ("model-visible.json",)
  70. # The agent loop's dynamic runtime-context snapshot is the one model-visible message this
  71. # expected output cannot carry: the same composition emits it on macOS and not on Linux
  72. # (deepseek-harness#2488), and the file must replay on both. Everything else is compared.
  73. RUNTIME_CONTEXT_PREFIX = "Current runtime context"
  74. CUSTOM_CORDIS = """\
  75. - id: sdk-jsonrpc-server
  76. name: '@deepseek-ai/dsh-sdk-jsonrpc-server'
  77. - id: agent-core
  78. name: '@deepseek-ai/dsh-agent-spine-demo'
  79. config:
  80. workspaceContext: false
  81. skills:
  82. enabled: false
  83. toolBash: false
  84. tools:
  85. mode: both
  86. - id: sessions
  87. name: '@deepseek-ai/dsh-session-persistence-jsonl'
  88. config:
  89. root: !!js process.env.DSH_SESSION_ROOT
  90. compression: 'none'
  91. - id: code-runtime
  92. name: '@deepseek-ai/dsh-code-runtime-worker-thread'
  93. - id: subagents
  94. name: '@deepseek-ai/dsh-subagent'
  95. - id: subagent-spawn-in-process
  96. name: '@deepseek-ai/dsh-subagent-spawn-in-process'
  97. config:
  98. providerName: spawn
  99. - id: subagent-tool
  100. name: '@deepseek-ai/dsh-tool-subagent'
  101. config:
  102. provider: spawn
  103. - id: workflow-engine
  104. name: '@deepseek-ai/dsh-workflow-worker-thread'
  105. config:
  106. provider: spawn
  107. - id: workflow-tool
  108. name: '@deepseek-ai/dsh-tool-workflow'
  109. - id: cordis-host-runner
  110. name: '@deepseek-ai/dsh-cordis-host-runner'
  111. - id: cordis-tool
  112. name: '@deepseek-ai/dsh-tool-cordis'
  113. """
  114. class MockModelHandler(BaseHTTPRequestHandler):
  115. """Return deterministic text, worker, and orchestration completions."""
  116. requests: list[dict[str, object]] = []
  117. def do_POST(self) -> None:
  118. content_length = int(self.headers.get("content-length", "0"))
  119. body = json.loads(self.rfile.read(content_length))
  120. self.requests.append(body)
  121. self.send_response(200)
  122. self.send_header("content-type", "text/event-stream")
  123. self.end_headers()
  124. chunks = completion_chunks(body)
  125. for chunk in chunks:
  126. self.wfile.write(f"data: {json.dumps(chunk)}\n\n".encode())
  127. self.wfile.write(b"data: [DONE]\n\n")
  128. self.wfile.flush()
  129. def log_message(self, _format: str, *_args: object) -> None:
  130. return
  131. def completion_chunks(body: dict[str, object]) -> list[dict[str, object]]:
  132. """Choose the next deterministic model response from request history."""
  133. messages = body.get("messages")
  134. if not isinstance(messages, list) or not messages:
  135. raise AssertionError(f"model request has no messages: {body}")
  136. latest = messages[-1]
  137. if not isinstance(latest, dict):
  138. raise AssertionError(f"model request has an invalid latest message: {body}")
  139. if latest.get("role") == "tool":
  140. call_id, tool_name = latest_tool_call(messages)
  141. tool_text = message_text(latest.get("content"))
  142. minimal = minimal_tool_followup(body, call_id, tool_name, tool_text)
  143. if minimal is not None:
  144. return minimal
  145. advanced = advanced_tool_followup(body, call_id, tool_name, tool_text)
  146. if advanced is not None:
  147. return advanced
  148. if "42" not in tool_text:
  149. raise AssertionError(f"{tool_name} worker returned no expected value: {latest}")
  150. if tool_name == "run_code":
  151. return text_chunks(CODE_WORKER_TEXT)
  152. if tool_name == "workflow":
  153. return text_chunks(WORKFLOW_WORKER_TEXT)
  154. raise AssertionError(f"unexpected tool follow-up: {tool_name}")
  155. user_prompts = [
  156. message_text(message.get("content"))
  157. for message in reversed(messages)
  158. if isinstance(message, dict) and message.get("role") == "user"
  159. ]
  160. minimal_prompt = next(
  161. (
  162. prompt
  163. for prompt in user_prompts
  164. if prompt.startswith(f"{MINIMAL_PROMPT}\n{MINIMAL_EDITOR_PATH_PREFIX}")
  165. ),
  166. None,
  167. )
  168. # The minimal composition's assembled system prompt, advertised tool schemas, and
  169. # model-visible messages are pinned by its snapshot, not asserted here.
  170. if minimal_prompt is not None:
  171. return tool_call_chunks(
  172. "minimal-bash-1",
  173. "bash",
  174. {"command": MINIMAL_BASH_COMMAND},
  175. )
  176. scenario_prompts = {
  177. SNAPSHOT_DIRECT_CHILD_PROMPT,
  178. SNAPSHOT_WORKFLOW_CHILD_PROMPT,
  179. SNAPSHOT_PROMPT,
  180. CODE_PROMPT,
  181. WORKFLOW_PROMPT,
  182. }
  183. prompt = next(
  184. (candidate for candidate in user_prompts if candidate in scenario_prompts),
  185. message_text(latest.get("content")),
  186. )
  187. if prompt == SNAPSHOT_DIRECT_CHILD_PROMPT:
  188. return text_chunks("DIRECT_CHILD_OK")
  189. if prompt == SNAPSHOT_WORKFLOW_CHILD_PROMPT:
  190. return text_chunks("WORKFLOW_CHILD_OK")
  191. if prompt == SNAPSHOT_PROMPT:
  192. assert_advertised_tool(body, "cordis_define")
  193. return tool_call_chunks(
  194. "advanced-define",
  195. "cordis_define",
  196. {
  197. "plugin": {"kind": "new", "idPrefix": "snap"},
  198. "name": "Snapshot Double",
  199. "purpose": "Expose a deterministic doubling tool for executable snapshot verification.",
  200. "code": {"host": SNAPSHOT_PLUGIN_CODE},
  201. },
  202. )
  203. if prompt == CODE_PROMPT:
  204. assert_advertised_tool(body, "run_code")
  205. return tool_call_chunks(
  206. "call-code-worker",
  207. "run_code",
  208. {"code": "return 6 * 7", "description": "Compute the smoke value"},
  209. )
  210. if prompt == WORKFLOW_PROMPT:
  211. assert_advertised_tool(body, "workflow")
  212. return tool_call_chunks(
  213. "call-workflow-worker",
  214. "workflow",
  215. {
  216. "script": "return 6 * 7",
  217. "meta": {
  218. "name": "pkg-worker-smoke",
  219. "description": "exercise the packaged workflow worker",
  220. },
  221. },
  222. )
  223. return text_chunks(EXPECTED_TEXT)
  224. def minimal_tool_followup(
  225. body: dict[str, object],
  226. call_id: str,
  227. tool_name: str,
  228. tool_text: str,
  229. ) -> list[dict[str, object]] | None:
  230. """Verify the checked-in minimal composition's PTY and editor."""
  231. if not call_id.startswith("minimal-"):
  232. return None
  233. if call_id == "minimal-bash-1" and tool_name == "bash":
  234. if "COUNT=1" not in tool_text:
  235. raise AssertionError(f"first persistent bash call lost its output: {tool_text}")
  236. return tool_call_chunks(
  237. "minimal-bash-2",
  238. "bash",
  239. {"command": MINIMAL_BASH_COMMAND},
  240. )
  241. if call_id == "minimal-bash-2" and tool_name == "bash":
  242. if "COUNT=2 CWD=/tmp" not in tool_text:
  243. raise AssertionError(f"persistent bash did not retain state: {tool_text}")
  244. messages = body.get("messages")
  245. if not isinstance(messages, list):
  246. raise AssertionError("persistent editor smoke request has no messages")
  247. editor_path = next(
  248. (
  249. text.split(MINIMAL_EDITOR_PATH_PREFIX, 1)[1].strip()
  250. for message in messages
  251. if isinstance(message, dict) and message.get("role") == "user"
  252. for text in [message_text(message.get("content"))]
  253. if MINIMAL_EDITOR_PATH_PREFIX in text
  254. ),
  255. None,
  256. )
  257. if editor_path is None:
  258. raise AssertionError("persistent editor smoke prompt has no editor path")
  259. return tool_call_chunks(
  260. "minimal-editor",
  261. "str_replace_editor",
  262. {
  263. "command": "create",
  264. "path": editor_path,
  265. "file_text": "created by packaged editor\n",
  266. },
  267. )
  268. if call_id == "minimal-editor" and tool_name == "str_replace_editor":
  269. if "New file created successfully" not in tool_text:
  270. raise AssertionError(f"packaged editor did not create its file: {tool_text}")
  271. return text_chunks(MINIMAL_TEXT)
  272. raise AssertionError(f"unexpected minimal-agent follow-up: {call_id} {tool_name}: {tool_text}")
  273. def advanced_tool_followup(
  274. body: dict[str, object],
  275. call_id: str,
  276. tool_name: str,
  277. tool_text: str,
  278. ) -> list[dict[str, object]] | None:
  279. """Advance the executable snapshot's deterministic parent tool chain."""
  280. if not call_id.startswith("advanced-"):
  281. return None
  282. if call_id == "advanced-define" and tool_name == "cordis_define":
  283. if "Defined snap-1/pkg-1 (Snapshot Double)" not in tool_text:
  284. raise AssertionError(f"cordis_define returned no dynamic Package ids: {tool_text}")
  285. if "snapshot_double" in advertised_tool_names(body):
  286. raise AssertionError("snapshot_double was advertised before cordis_run")
  287. assert_advertised_tool(body, "cordis_run")
  288. return tool_call_chunks(
  289. "advanced-run",
  290. "cordis_run",
  291. {"pluginId": "snap-1", "packageId": "pkg-1", "mode": "run"},
  292. )
  293. if call_id == "advanced-run" and tool_name == "cordis_run":
  294. if "snap-1/pkg-1 is running (run-1)" not in tool_text:
  295. raise AssertionError(f"cordis_run returned no running Package ids: {tool_text}")
  296. assert_advertised_tool(body, "run_code")
  297. assert_advertised_tool(body, "snapshot_double")
  298. return tool_call_chunks(
  299. "advanced-code",
  300. "run_code",
  301. {
  302. "code": "return await tools.snapshot_double({ value: 21 })",
  303. "description": "Run the temporary Plugin tool",
  304. },
  305. )
  306. if call_id == "advanced-code" and tool_name == "run_code":
  307. if "42" not in tool_text:
  308. raise AssertionError(f"run_code returned no dynamic-tool value: {tool_text}")
  309. assert_advertised_tool(body, "subagent")
  310. return tool_call_chunks(
  311. "advanced-direct-child",
  312. "subagent",
  313. {
  314. "description": "Check direct child",
  315. "prompt": SNAPSHOT_DIRECT_CHILD_PROMPT,
  316. },
  317. )
  318. if call_id == "advanced-direct-child" and tool_name == "subagent":
  319. if "DIRECT_CHILD_OK" not in tool_text:
  320. raise AssertionError(f"subagent returned no expected child value: {tool_text}")
  321. assert_advertised_tool(body, "workflow")
  322. return tool_call_chunks(
  323. "advanced-workflow",
  324. "workflow",
  325. {
  326. "script": SNAPSHOT_WORKFLOW_SCRIPT,
  327. "meta": {
  328. "name": "advanced-exe-snapshot",
  329. "description": "exercise one packaged workflow child",
  330. },
  331. },
  332. )
  333. if call_id == "advanced-workflow" and tool_name == "workflow":
  334. if "WORKFLOW_CHILD_OK" not in tool_text:
  335. raise AssertionError(f"workflow returned no expected child value: {tool_text}")
  336. assert_advertised_tool(body, "cordis_undefine")
  337. return tool_call_chunks(
  338. "advanced-undefine",
  339. "cordis_undefine",
  340. {"pluginId": "snap-1"},
  341. )
  342. if call_id == "advanced-undefine" and tool_name == "cordis_undefine":
  343. if "Removed dynamic Plugin snap-1 and all of its Packages." not in tool_text:
  344. raise AssertionError(f"cordis_undefine returned no removal result: {tool_text}")
  345. if "snapshot_double" in advertised_tool_names(body):
  346. raise AssertionError("snapshot_double remained advertised after cordis_undefine")
  347. return text_chunks(SNAPSHOT_FINAL_TEXT)
  348. raise AssertionError(f"unexpected advanced tool follow-up: {call_id} {tool_name}: {tool_text}")
  349. def text_chunks(text: str) -> list[dict[str, object]]:
  350. """Build a complete streaming text response."""
  351. return [
  352. {"choices": [{"delta": {"role": "assistant", "content": None, "reasoning_content": ""}}]},
  353. {"choices": [{"delta": {"content": text}}]},
  354. {
  355. "choices": [{"delta": {"content": ""}, "finish_reason": "stop"}],
  356. "usage": {"prompt_tokens": 3, "completion_tokens": 3},
  357. },
  358. ]
  359. def tool_call_chunks(call_id: str, name: str, arguments: dict[str, object]) -> list[dict[str, object]]:
  360. """Build a complete streaming function-call response."""
  361. return [
  362. {"choices": [{"delta": {"role": "assistant", "content": None, "reasoning_content": ""}}]},
  363. {
  364. "choices": [{
  365. "delta": {
  366. "tool_calls": [{
  367. "index": 0,
  368. "id": call_id,
  369. "type": "function",
  370. "function": {"name": name, "arguments": json.dumps(arguments)},
  371. }],
  372. },
  373. }],
  374. },
  375. {
  376. "choices": [{"delta": {"content": ""}, "finish_reason": "tool_calls"}],
  377. "usage": {"prompt_tokens": 3, "completion_tokens": 3},
  378. },
  379. ]
  380. def latest_tool_call(messages: list[object]) -> tuple[str, str]:
  381. """Find the assistant call id and name paired with the latest tool result."""
  382. for message in reversed(messages[:-1]):
  383. if not isinstance(message, dict):
  384. continue
  385. calls = message.get("tool_calls")
  386. if not isinstance(calls, list):
  387. continue
  388. for call in reversed(calls):
  389. if not isinstance(call, dict):
  390. continue
  391. function = call.get("function")
  392. call_id = call.get("id")
  393. if (
  394. isinstance(call_id, str)
  395. and isinstance(function, dict)
  396. and isinstance(function.get("name"), str)
  397. ):
  398. return call_id, function["name"]
  399. raise AssertionError(f"tool result has no preceding assistant tool call: {messages}")
  400. def message_text(content: object) -> str:
  401. """Read OpenAI text content in either string or block-list form."""
  402. if isinstance(content, str):
  403. return content
  404. if isinstance(content, list):
  405. return "".join(
  406. block.get("text", "")
  407. for block in content
  408. if isinstance(block, dict) and isinstance(block.get("text"), str)
  409. )
  410. return ""
  411. def advertised_tool_names(body: dict[str, object]) -> set[str]:
  412. """Return the model-facing tool names advertised on one request."""
  413. tools = body.get("tools")
  414. if not isinstance(tools, list):
  415. raise AssertionError(f"model request advertised no tools: {body}")
  416. names: set[str] = set()
  417. for tool in tools:
  418. if not isinstance(tool, dict):
  419. continue
  420. function = tool.get("function")
  421. if isinstance(function, dict) and isinstance(function.get("name"), str):
  422. names.add(function["name"])
  423. return names
  424. def assert_advertised_tool(body: dict[str, object], expected: str) -> None:
  425. """Require the packaged deployment to expose the requested tool."""
  426. names = advertised_tool_names(body)
  427. if expected not in names:
  428. raise AssertionError(f"model request did not advertise {expected}: {names}")
  429. class MockModel:
  430. def __enter__(self) -> "MockModel":
  431. MockModelHandler.requests.clear()
  432. self.server = ThreadingHTTPServer(("127.0.0.1", 0), MockModelHandler)
  433. self.thread = threading.Thread(target=self.server.serve_forever, daemon=True)
  434. self.thread.start()
  435. host, port = self.server.server_address
  436. self.url = f"http://{host}:{port}"
  437. return self
  438. def __exit__(self, _exc_type: object, _exc: object, _tb: object) -> None:
  439. self.server.shutdown()
  440. self.server.server_close()
  441. self.thread.join(timeout=5)
  442. def main() -> None:
  443. parser = argparse.ArgumentParser(description=__doc__)
  444. parser.add_argument(
  445. "--scenario",
  446. choices=("all", "sdk-default", "sdk-custom", "sdk-minimal", "sdk-snapshot", "direct"),
  447. default="all",
  448. )
  449. parser.add_argument("--exe", type=Path)
  450. parser.add_argument("--update-snapshots", action="store_true")
  451. args = parser.parse_args()
  452. if args.scenario in {"all", "sdk-custom", "sdk-minimal", "sdk-snapshot", "direct"} and args.exe is None:
  453. parser.error("--exe is required for custom, minimal, snapshot, and direct scenarios")
  454. if args.update_snapshots and args.scenario not in {"all", "sdk-minimal", "sdk-snapshot"}:
  455. parser.error("--update-snapshots requires --scenario sdk-minimal, sdk-snapshot, or all")
  456. if args.exe is not None and not args.exe.is_file():
  457. parser.error(f"runtime executable does not exist: {args.exe}")
  458. with MockModel() as model:
  459. if args.scenario in {"all", "sdk-default"}:
  460. smoke_sdk_default(model.url)
  461. if args.scenario in {"all", "sdk-custom"}:
  462. assert args.exe is not None
  463. smoke_sdk_custom(model.url, args.exe.resolve())
  464. if args.scenario in {"all", "sdk-minimal"}:
  465. assert args.exe is not None
  466. smoke_sdk_minimal(model.url, args.exe.resolve(), args.update_snapshots)
  467. if args.scenario in {"all", "sdk-snapshot"}:
  468. assert args.exe is not None
  469. smoke_sdk_snapshot(model.url, args.exe.resolve(), args.update_snapshots)
  470. if args.scenario in {"all", "direct"}:
  471. assert args.exe is not None
  472. smoke_direct(model.url, args.exe.resolve())
  473. if not MockModelHandler.requests:
  474. raise AssertionError("mock model endpoint received no requests")
  475. print(f"smoke-python-runtime: {args.scenario} passed")
  476. def smoke_sdk_default(base_url: str) -> None:
  477. from deepseek_harness import DeepSeekHarness
  478. with tempfile.TemporaryDirectory(prefix="dsh-sdk-default-") as temporary:
  479. root = Path(temporary).resolve()
  480. sessions = root / "sessions"
  481. with DeepSeekHarness(
  482. provider="deepseek-official",
  483. model="smoke-model",
  484. cwd=str(root),
  485. session_root=str(sessions),
  486. api_key="sk-keyless-smoke",
  487. base_url=base_url,
  488. request_timeout_seconds=60,
  489. ) as harness:
  490. result = harness.run("reply with the smoke text", session_id="default-smoke")
  491. assert result.final_response == EXPECTED_TEXT, result.final_response
  492. assert_zstd_session_log(sessions)
  493. def smoke_sdk_custom(base_url: str, executable: Path) -> None:
  494. from deepseek_harness import DeepSeekHarness
  495. with tempfile.TemporaryDirectory(prefix="dsh-sdk-custom-") as temporary:
  496. root = Path(temporary).resolve()
  497. sessions = root / "sessions"
  498. cordis = root / "cordis.yml"
  499. cordis.write_text(CUSTOM_CORDIS)
  500. with DeepSeekHarness(
  501. provider="deepseek-official",
  502. model="smoke-model",
  503. cwd=str(root),
  504. session_root=str(sessions),
  505. cordis=str(cordis),
  506. runtime_bin=str(executable),
  507. api_key="sk-keyless-smoke",
  508. base_url=base_url,
  509. request_timeout_seconds=60,
  510. ) as harness:
  511. text_result = harness.run("reply with the smoke text", session_id="custom-smoke")
  512. code_result = harness.run(CODE_PROMPT, session_id="custom-smoke")
  513. workflow_result = harness.run(WORKFLOW_PROMPT, session_id="custom-smoke")
  514. assert text_result.final_response == EXPECTED_TEXT, text_result.final_response
  515. assert code_result.final_response == CODE_WORKER_TEXT, code_result.final_response
  516. assert workflow_result.final_response == WORKFLOW_WORKER_TEXT, workflow_result.final_response
  517. assert_session_log(sessions, root, EXPECTED_TEXT, CODE_WORKER_TEXT, WORKFLOW_WORKER_TEXT)
  518. def smoke_sdk_minimal(base_url: str, executable: Path, update_snapshots: bool) -> None:
  519. """Exercise the checked-in minimal composition through the packaged executable."""
  520. from deepseek_harness import DeepSeekHarness
  521. # One mock model serves every scenario of a run, so the snapshot takes this turn's slice.
  522. first_request = len(MockModelHandler.requests)
  523. with tempfile.TemporaryDirectory(prefix="dsh-sdk-minimal-") as temporary:
  524. root = Path(temporary).resolve()
  525. editor_path = root / "created.txt"
  526. prompt = f"{MINIMAL_PROMPT}\n{MINIMAL_EDITOR_PATH_PREFIX}{editor_path}"
  527. sessions = root / "sessions"
  528. with DeepSeekHarness(
  529. provider="deepseek-official",
  530. model="smoke-model",
  531. cwd=str(root),
  532. session_root=str(sessions),
  533. cordis=str(MINIMAL_CORDIS),
  534. runtime_bin=str(executable),
  535. api_key="sk-keyless-smoke",
  536. base_url=base_url,
  537. request_timeout_seconds=60,
  538. ) as harness:
  539. result = harness.run(prompt, session_id="minimal-agent-smoke")
  540. event_text = json.dumps(result.events)
  541. if MINIMAL_TEXT not in event_text:
  542. raise AssertionError(f"minimal agent run emitted no final response: {result.events}")
  543. if editor_path.read_text() != "created by packaged editor\n":
  544. raise AssertionError(f"packaged editor wrote unexpected content: {editor_path.read_text()!r}")
  545. assert_session_log(sessions, root, MINIMAL_TEXT, "COUNT=1", "COUNT=2 CWD=/tmp")
  546. files = build_minimal_snapshot_files(MockModelHandler.requests[first_request:], root)
  547. compare_snapshot_files(
  548. files, update_snapshots, MINIMAL_SNAPSHOT_DIRECTORY, MINIMAL_SNAPSHOT_FILENAMES,
  549. )
  550. def smoke_sdk_snapshot(base_url: str, executable: Path, update_snapshots: bool) -> None:
  551. """Drive and compare the advanced SDK/executable behavioral snapshot."""
  552. from deepseek_harness import DeepSeekHarness
  553. with tempfile.TemporaryDirectory(prefix="dsh-sdk-snapshot-") as temporary:
  554. root = Path(temporary).resolve()
  555. sessions = root / "sessions"
  556. cordis = root / "cordis.yml"
  557. cordis.write_text(CUSTOM_CORDIS)
  558. with DeepSeekHarness(
  559. provider="deepseek-official",
  560. model="smoke-model",
  561. cwd=str(root),
  562. session_root=str(sessions),
  563. cordis=str(cordis),
  564. runtime_bin=str(executable),
  565. api_key="sk-keyless-smoke",
  566. base_url=base_url,
  567. request_timeout_seconds=60,
  568. ) as harness:
  569. result = harness.run(SNAPSHOT_PROMPT, session_id=SNAPSHOT_SESSION_ID)
  570. assert result.final_response == SNAPSHOT_FINAL_TEXT, result.final_response
  571. methods = [notification.method for notification in result.notifications]
  572. if methods.count("subagent.started") != 2 or methods.count("subagent.finished") != 2:
  573. raise AssertionError(f"advanced snapshot emitted unexpected subagent lifecycle: {methods}")
  574. if not any(event.get("type") == "tool/code-dispatch" for event in result.events):
  575. raise AssertionError("advanced snapshot emitted no tool/code-dispatch event")
  576. logs = read_session_logs(sessions)
  577. child_ids = snapshot_child_ids(result)
  578. expected_ids = {SNAPSHOT_SESSION_ID, *child_ids}
  579. if set(logs) != expected_ids:
  580. raise AssertionError(f"advanced snapshot expected parent plus two child logs: {sorted(logs)}")
  581. if "DIRECT_CHILD_OK" not in render_jsonl(logs[child_ids[0]]):
  582. raise AssertionError("first advanced child log has no direct-subagent result")
  583. if "WORKFLOW_CHILD_OK" not in render_jsonl(logs[child_ids[1]]):
  584. raise AssertionError("second advanced child log has no workflow-subagent result")
  585. files = build_snapshot_files(result, logs, child_ids, root)
  586. compare_snapshot_files(
  587. files, update_snapshots, ADVANCED_SNAPSHOT_DIRECTORY, ADVANCED_SNAPSHOT_FILENAMES,
  588. )
  589. def smoke_direct(base_url: str, executable: Path) -> None:
  590. with tempfile.TemporaryDirectory(prefix="dsh-direct-") as temporary:
  591. root = Path(temporary).resolve()
  592. sessions = root / "sessions"
  593. cordis = root / "cordis.yml"
  594. cordis.write_text(CUSTOM_CORDIS)
  595. environment = {
  596. **os.environ,
  597. "DSH_CORDIS_CONFIG": str(cordis),
  598. "DSH_SESSION_ROOT": str(sessions),
  599. "DSH_CWD": str(root),
  600. "DEEPSEEK_API_KEY": "sk-keyless-smoke",
  601. "DEEPSEEK_BASE_URL": base_url,
  602. }
  603. peer = RuntimePeer([str(executable)], root, environment)
  604. try:
  605. peer.send({"jsonrpc": "2.0", "id": "initialize", "method": "initialize", "params": {"cwd": str(root), "provider": "deepseek-official", "model": "smoke-model"}})
  606. peer.read_until(lambda message: message.get("id") == "initialize")
  607. peer.send({
  608. "jsonrpc": "2.0",
  609. "id": "prompt",
  610. "method": "session/prompt",
  611. "params": {"sessionId": "direct-smoke", "contentBlocks": [{"type": "text", "text": "reply with the smoke text"}]},
  612. })
  613. messages = peer.read_until(lambda message: message.get("id") == "prompt")
  614. if not any(is_idle_notification(message) for message in messages):
  615. messages.extend(peer.read_until(is_idle_notification))
  616. event_text = json.dumps(messages)
  617. if EXPECTED_TEXT not in event_text:
  618. raise AssertionError(f"direct runtime emitted no final response: {messages}")
  619. peer.send({"jsonrpc": "2.0", "id": "shutdown", "method": "shutdown"})
  620. peer.read_until(lambda message: message.get("id") == "shutdown")
  621. finally:
  622. peer.close()
  623. assert_session_log(sessions, root, EXPECTED_TEXT)
  624. def is_idle_notification(message: dict[str, object]) -> bool:
  625. """Return whether a JSON-RPC notification marks a session idle."""
  626. params = message.get("params")
  627. return (
  628. message.get("method") == "session.status"
  629. and isinstance(params, dict)
  630. and params.get("status") == "idle"
  631. )
  632. class RuntimePeer:
  633. def __init__(self, argv: list[str], cwd: Path, environment: dict[str, str]) -> None:
  634. self.process = subprocess.Popen(
  635. argv,
  636. cwd=cwd,
  637. env=environment,
  638. stdin=subprocess.PIPE,
  639. stdout=subprocess.PIPE,
  640. stderr=subprocess.PIPE,
  641. text=True,
  642. encoding="utf-8",
  643. bufsize=1,
  644. )
  645. self.stdout: queue.Queue[str | None] = queue.Queue()
  646. self.stderr: list[str] = []
  647. threading.Thread(target=self._read_stdout, daemon=True).start()
  648. threading.Thread(target=self._read_stderr, daemon=True).start()
  649. def send(self, message: dict[str, object]) -> None:
  650. if self.process.stdin is None:
  651. raise RuntimeError("runtime stdin is unavailable")
  652. self.process.stdin.write(json.dumps(message) + "\n")
  653. self.process.stdin.flush()
  654. def read_until(self, predicate: Callable[[dict[str, object]], bool]) -> list[dict[str, object]]:
  655. deadline = time.monotonic() + 60
  656. messages: list[dict[str, object]] = []
  657. while time.monotonic() < deadline:
  658. try:
  659. line = self.stdout.get(timeout=min(0.25, deadline - time.monotonic()))
  660. except queue.Empty:
  661. continue
  662. if line is None:
  663. raise RuntimeError(f"runtime exited before expected message; stderr: {''.join(self.stderr)}")
  664. try:
  665. message = json.loads(line)
  666. except json.JSONDecodeError:
  667. continue
  668. messages.append(message)
  669. if predicate(message):
  670. return messages
  671. raise TimeoutError(f"runtime timed out; messages={messages}; stderr={''.join(self.stderr)}")
  672. def close(self) -> None:
  673. if self.process.stdin is not None and not self.process.stdin.closed:
  674. self.process.stdin.close()
  675. try:
  676. self.process.wait(timeout=10)
  677. except subprocess.TimeoutExpired:
  678. self.process.kill()
  679. self.process.wait()
  680. if self.process.returncode not in {0, -15}:
  681. raise RuntimeError(f"runtime exited {self.process.returncode}; stderr: {''.join(self.stderr)}")
  682. def _read_stdout(self) -> None:
  683. assert self.process.stdout is not None
  684. for line in self.process.stdout:
  685. self.stdout.put(line)
  686. self.stdout.put(None)
  687. def _read_stderr(self) -> None:
  688. assert self.process.stderr is not None
  689. self.stderr.extend(self.process.stderr)
  690. def assert_session_log(sessions: Path, cwd: Path, *expected_texts: str) -> None:
  691. logs = list(sessions.rglob("*.jsonl"))
  692. if len(logs) != 1:
  693. raise AssertionError(f"expected one JSONL session log under {sessions}, found {logs}")
  694. lines = logs[0].read_text().splitlines()
  695. header = json.loads(lines[0])
  696. if header.get("cwd") != str(cwd):
  697. raise AssertionError(f"session header cwd is not absolute/canonical: {header}")
  698. rendered = "\n".join(lines)
  699. for expected in expected_texts:
  700. if expected not in rendered:
  701. raise AssertionError(f"session log has no {expected!r} response: {logs[0]}")
  702. def assert_zstd_session_log(sessions: Path) -> None:
  703. logs = list(sessions.rglob("*.jsonl.zstd"))
  704. if len(logs) != 1:
  705. raise AssertionError(f"expected one Zstandard JSONL session log under {sessions}, found {logs}")
  706. if not logs[0].read_bytes().startswith(bytes.fromhex("28b52ffd")):
  707. raise AssertionError(f"session log has no Zstandard magic: {logs[0]}")
  708. def read_session_logs(sessions: Path) -> dict[str, list[dict[str, object]]]:
  709. """Parse every persisted JSONL session into a map keyed by header id."""
  710. logs: dict[str, list[dict[str, object]]] = {}
  711. for path in sorted(sessions.rglob("*.jsonl")):
  712. records = [
  713. json.loads(line)
  714. for line in path.read_text(encoding="utf-8").splitlines()
  715. if line
  716. ]
  717. if not records or records[0].get("type") != "session":
  718. raise AssertionError(f"session log has no header: {path}")
  719. session_id = records[0].get("id")
  720. if not isinstance(session_id, str):
  721. raise AssertionError(f"session log header has no string id: {path}")
  722. if session_id in logs:
  723. raise AssertionError(f"duplicate persisted session id: {session_id}")
  724. logs[session_id] = records
  725. return logs
  726. def snapshot_child_ids(result: "RunResult") -> list[str]:
  727. """Return the two child session ids in their SDK notification order."""
  728. child_ids: list[str] = []
  729. for notification in result.notifications:
  730. if notification.method != "subagent.started":
  731. continue
  732. payload = notification.payload
  733. if payload.get("parentSessionId") != SNAPSHOT_SESSION_ID:
  734. continue
  735. child_id = payload.get("childSessionId")
  736. if isinstance(child_id, str) and child_id not in child_ids:
  737. child_ids.append(child_id)
  738. if len(child_ids) != 2:
  739. raise AssertionError(f"advanced snapshot expected two child session ids: {child_ids}")
  740. return child_ids
  741. def build_minimal_snapshot_files(
  742. requests: list[dict[str, object]],
  743. cwd: Path,
  744. ) -> dict[str, str]:
  745. """Render the minimal composition's model-visible surface as expected output.
  746. Every assembled system prompt, advertised tool schema, and system or user message is
  747. kept verbatim: they carry what the deployment actually shows the model, so a plugin
  748. that contributes an unintended system section or user message cannot pass unnoticed.
  749. Assistant and tool payloads keep only their call identity, and the dynamic
  750. runtime-context snapshot is dropped, because their text differs across the platforms
  751. this expected output must replay on.
  752. """
  753. snapshot = []
  754. for body in requests:
  755. messages = body.get("messages")
  756. if not isinstance(messages, list):
  757. raise AssertionError(f"minimal model request has no messages: {body}")
  758. snapshot.append({
  759. "tools": minimal_snapshot_text(body.get("tools"), cwd),
  760. "messages": [
  761. minimal_snapshot_message(message, cwd)
  762. for message in messages
  763. if not is_runtime_context_message(message)
  764. ],
  765. })
  766. return {"model-visible.json": json.dumps(snapshot, indent=2, ensure_ascii=False) + "\n"}
  767. def is_runtime_context_message(message: object) -> bool:
  768. """Identify the agent loop's dynamic runtime-context snapshot, current or cleared."""
  769. return (
  770. isinstance(message, dict)
  771. and message.get("role") == "user"
  772. and message_text(message.get("content")).startswith(RUNTIME_CONTEXT_PREFIX)
  773. )
  774. def minimal_snapshot_message(message: object, cwd: Path) -> dict[str, object]:
  775. """Reduce one model-visible message to its stable, behavior-carrying parts."""
  776. if not isinstance(message, dict):
  777. raise AssertionError(f"minimal model request has an invalid message: {message}")
  778. role = message.get("role")
  779. if role in ("system", "user"):
  780. return {"role": role, "text": minimal_snapshot_text(message_text(message.get("content")), cwd)}
  781. if role == "assistant":
  782. calls = message.get("tool_calls")
  783. if not isinstance(calls, list):
  784. raise AssertionError(f"minimal assistant message has no tool calls: {message}")
  785. return {
  786. "role": role,
  787. "toolCalls": [
  788. {"id": call.get("id"), "name": (call.get("function") or {}).get("name")}
  789. for call in calls
  790. if isinstance(call, dict)
  791. ],
  792. }
  793. if role == "tool":
  794. return {"role": role, "toolCallId": message.get("tool_call_id"), "text": "{{tool-result}}"}
  795. raise AssertionError(f"minimal model request has an unexpected message role: {message}")
  796. def minimal_snapshot_text(value: object, cwd: Path) -> object:
  797. """Replace the scenario's temporary working directory everywhere it appears."""
  798. if isinstance(value, str):
  799. return value.replace(str(cwd), "{{cwd}}")
  800. if isinstance(value, list):
  801. return [minimal_snapshot_text(item, cwd) for item in value]
  802. if isinstance(value, dict):
  803. return {key: minimal_snapshot_text(item, cwd) for key, item in value.items()}
  804. return value
  805. def build_snapshot_files(
  806. result: "RunResult",
  807. logs: dict[str, list[dict[str, object]]],
  808. child_ids: list[str],
  809. cwd: Path,
  810. ) -> dict[str, str]:
  811. """Render the SDK result and three persisted logs into stable expected outputs."""
  812. replacements = [(str(cwd), "{{cwd}}"), (SNAPSHOT_SESSION_ID, "{{parent}}")]
  813. replacements.append((snapshot_workflow_run_id(result), "{{workflow-run}}"))
  814. for index, child_id in enumerate(child_ids, start=1):
  815. replacements.append((child_id, f"{{{{child-{index}}}}}"))
  816. agent_id = snapshot_agent_id(result, child_id)
  817. replacements.append((agent_id, f"{{{{agent-{index}}}}}"))
  818. replacements.sort(key=lambda pair: len(pair[0]), reverse=True)
  819. result_value = {
  820. "session_id": result.session_id,
  821. "final_response": result.final_response,
  822. "events": result.events,
  823. "notifications": [
  824. {"method": notification.method, "payload": notification.payload}
  825. for notification in result.notifications
  826. ],
  827. "session_root": result.session_root,
  828. }
  829. normalized_result = normalize_snapshot_value(result_value, replacements)
  830. files = {
  831. "result.json": json.dumps(normalized_result, indent=2, ensure_ascii=False) + "\n",
  832. "session.jsonl": render_jsonl(
  833. [normalize_snapshot_value(record, replacements) for record in logs[SNAPSHOT_SESSION_ID]]
  834. ),
  835. }
  836. for index, child_id in enumerate(child_ids, start=1):
  837. files[f"session.{index}.jsonl"] = render_jsonl(
  838. [normalize_snapshot_value(record, replacements) for record in logs[child_id]]
  839. )
  840. return files
  841. def snapshot_workflow_run_id(result: "RunResult") -> str:
  842. """Return the one workflow run id emitted by the advanced scenario."""
  843. run_ids: set[str] = set()
  844. for event in result.events:
  845. event_type = event.get("type")
  846. data = event.get("data")
  847. if not isinstance(event_type, str) or not event_type.startswith("tool-workflow/"):
  848. continue
  849. if isinstance(data, dict) and isinstance(data.get("runId"), str):
  850. run_ids.add(data["runId"])
  851. if len(run_ids) != 1:
  852. raise AssertionError(f"advanced snapshot expected one workflow run id: {sorted(run_ids)}")
  853. return next(iter(run_ids))
  854. def snapshot_agent_id(result: "RunResult", child_id: str) -> str:
  855. """Find the successful subagent id paired with one child session."""
  856. for notification in result.notifications:
  857. if notification.method != "subagent.finished":
  858. continue
  859. payload = notification.payload
  860. if payload.get("childSessionId") != child_id:
  861. continue
  862. if payload.get("provider") != "spawn" or payload.get("status") != "ok":
  863. raise AssertionError(f"advanced child did not finish successfully: {payload}")
  864. agent_id = payload.get("agentId")
  865. if isinstance(agent_id, str):
  866. return agent_id
  867. raise AssertionError(f"advanced snapshot has no finished agent for child {child_id}")
  868. def normalize_snapshot_value(
  869. value: object,
  870. replacements: list[tuple[str, str]],
  871. ) -> object:
  872. """Scrub volatile values and bulky request headers without losing behavior."""
  873. if isinstance(value, str):
  874. normalized = value
  875. for actual, token in replacements:
  876. normalized = normalized.replace(actual, token)
  877. return normalized
  878. if isinstance(value, list):
  879. return [normalize_snapshot_value(item, replacements) for item in value]
  880. if not isinstance(value, dict):
  881. return value
  882. normalized = {
  883. key: normalize_snapshot_value(item, replacements)
  884. for key, item in value.items()
  885. }
  886. if normalized.get("type") == "session" and "createdAt" in normalized:
  887. normalized["createdAt"] = 0
  888. if "seq" in normalized and "time" in normalized:
  889. normalized["time"] = 0
  890. if isinstance(normalized.get("id"), str) and normalized.get("role") in ("assistant", "user"):
  891. normalized["id"] = "{{messageId}}"
  892. scrub_snapshot_header(normalized)
  893. return normalized
  894. def scrub_snapshot_header(value: dict[object, object]) -> None:
  895. """Tokenize full request-header bulk while retaining tool names."""
  896. data = value.get("data")
  897. if not isinstance(data, dict):
  898. return
  899. if value.get("type") == "request/header":
  900. header = data.get("header")
  901. if not isinstance(header, dict):
  902. return
  903. if "system" in header:
  904. header["system"] = "{{system}}"
  905. tools = header.get("tools")
  906. if isinstance(tools, list):
  907. header["tools"] = [
  908. tool.get("name") if isinstance(tool, dict) else "{{tools}}"
  909. for tool in tools
  910. ]
  911. def render_jsonl(records: list[object]) -> str:
  912. """Render parsed JSON values as compact, newline-terminated JSONL."""
  913. return "".join(
  914. json.dumps(record, ensure_ascii=False, separators=(",", ":")) + "\n"
  915. for record in records
  916. )
  917. def compare_snapshot_files(
  918. files: dict[str, str],
  919. update: bool,
  920. directory: Path,
  921. filenames: tuple[str, ...],
  922. ) -> None:
  923. """Write or exactly compare one scenario's expected snapshot files."""
  924. scenario = directory.name
  925. if tuple(files) != filenames:
  926. raise AssertionError(f"{scenario} snapshot builder produced {tuple(files)}, expected {filenames}")
  927. if update:
  928. directory.mkdir(parents=True, exist_ok=True)
  929. for name, content in files.items():
  930. (directory / name).write_text(content, encoding="utf-8")
  931. print(f"smoke-python-runtime: updated snapshots in {directory}")
  932. existing = {
  933. path.name
  934. for path in directory.iterdir()
  935. if path.is_file()
  936. } if directory.is_dir() else set()
  937. expected = set(filenames)
  938. if existing != expected:
  939. raise AssertionError(
  940. f"{scenario} snapshot files differ: "
  941. f"missing={sorted(expected - existing)}, unexpected={sorted(existing - expected)}"
  942. )
  943. for name, actual in files.items():
  944. expected_text = (directory / name).read_text(encoding="utf-8")
  945. if actual == expected_text:
  946. continue
  947. diff = "".join(difflib.unified_diff(
  948. expected_text.splitlines(keepends=True),
  949. actual.splitlines(keepends=True),
  950. fromfile=f"expected/{name}",
  951. tofile=f"actual/{name}",
  952. ))
  953. raise AssertionError(
  954. f"{scenario} executable snapshot mismatch in {name}; "
  955. "rerun with --update-snapshots after reviewing the behavior\n"
  956. f"{diff}"
  957. )
  958. if __name__ == "__main__":
  959. main()