test_client.py 42 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001
  1. from __future__ import annotations
  2. import json
  3. import inspect
  4. import sys
  5. import threading
  6. import time
  7. from pathlib import Path
  8. import pytest
  9. from deepseek_harness import DeepSeekHarness, HarnessClient, HarnessConfig, Notification, SdkProtocolError
  10. def test_high_level_sdk_runs_turn_and_collects_final_response(tmp_path: Path) -> None:
  11. script = tmp_path / "fake_runtime.py"
  12. env_dump = tmp_path / "env.json"
  13. init_dump = tmp_path / "init.json"
  14. script.write_text(
  15. """
  16. import json
  17. import os
  18. import sys
  19. env_dump = os.environ["ENV_DUMP"]
  20. json.dump({
  21. "DEEPSEEK_API_KEY": os.environ.get("DEEPSEEK_API_KEY"),
  22. "DEEPSEEK_BASE_URL": os.environ.get("DEEPSEEK_BASE_URL"),
  23. "DSH_CWD": os.environ.get("DSH_CWD"),
  24. "DSH_SESSION_ROOT": os.environ.get("DSH_SESSION_ROOT"),
  25. "DSH_CORDIS_CONFIG": os.environ.get("DSH_CORDIS_CONFIG"),
  26. }, open(env_dump, "w"))
  27. for line in sys.stdin:
  28. msg = json.loads(line)
  29. method = msg.get("method")
  30. if method == "initialize":
  31. json.dump(msg.get("params"), open(os.environ["INIT_DUMP"], "w"))
  32. print(json.dumps({"jsonrpc": "2.0", "id": msg["id"], "result": {"serverInfo": {"name": "fake-runtime"}}}), flush=True)
  33. elif method == "session/prompt":
  34. params = msg.get("params") or {}
  35. print(json.dumps({"jsonrpc": "2.0", "method": "session.event", "params": {"sessionId": params["sessionId"], "event": {"type": "agent/inbox/spliced", "data": {"target": "next-turn", "start": 0, "inserted": [{"id": "message-1"}]}}}}), flush=True)
  36. print(json.dumps({"jsonrpc": "2.0", "method": "session.status", "params": {"sessionId": params["sessionId"], "status": "running"}}), flush=True)
  37. print(json.dumps({"jsonrpc": "2.0", "id": msg["id"], "result": {"messageId": "message-1"}}), flush=True)
  38. print(json.dumps({
  39. "jsonrpc": "2.0",
  40. "method": "session.event",
  41. "params": {
  42. "sessionId": params["sessionId"],
  43. "event": {
  44. "type": "assistant/message",
  45. "data": {
  46. "message": {
  47. "role": "assistant",
  48. "content": [{"type": "text", "text": "hello from runtime"}],
  49. },
  50. },
  51. },
  52. },
  53. }), flush=True)
  54. print(json.dumps({
  55. "jsonrpc": "2.0",
  56. "method": "session.event",
  57. "params": {
  58. "sessionId": params["sessionId"],
  59. "event": {
  60. "type": "turn/end",
  61. "data": {"turn": 1, "reason": {"kind": "completed"}},
  62. },
  63. },
  64. }), flush=True)
  65. print(json.dumps({
  66. "jsonrpc": "2.0",
  67. "method": "session.event",
  68. "params": {
  69. "sessionId": params["sessionId"],
  70. "event": {
  71. "type": "turn/end",
  72. "data": {"turn": 2, "reason": {"kind": "max-tokens"}},
  73. },
  74. },
  75. }), flush=True)
  76. print(json.dumps({
  77. "jsonrpc": "2.0",
  78. "method": "session.status",
  79. "params": {"sessionId": params["sessionId"], "status": "idle"},
  80. }), flush=True)
  81. elif method == "shutdown":
  82. print(json.dumps({"jsonrpc": "2.0", "id": msg["id"], "result": {}}), flush=True)
  83. break
  84. """.strip()
  85. )
  86. with DeepSeekHarness(
  87. model="deepseek-v4-flash",
  88. max_tokens=4096,
  89. cwd=str(tmp_path),
  90. cordis=str(tmp_path / "cordis.yml"),
  91. session_root=str(tmp_path / "sessions"),
  92. launch_args_override=(sys.executable, str(script)),
  93. env={
  94. "ENV_DUMP": str(env_dump),
  95. "INIT_DUMP": str(init_dump),
  96. "DEEPSEEK_API_KEY": "env-key",
  97. "DEEPSEEK_BASE_URL": "http://127.0.0.1:4321",
  98. },
  99. ) as harness:
  100. result = harness.run("say hello", session_id="main")
  101. assert result.final_response == "hello from runtime"
  102. assert result.finish_reason == "max-tokens"
  103. assert result.events[-1]["type"] == "turn/end"
  104. dumped_env = json.loads(env_dump.read_text())
  105. assert dumped_env["DEEPSEEK_API_KEY"] == "env-key"
  106. assert dumped_env["DEEPSEEK_BASE_URL"] == "http://127.0.0.1:4321"
  107. assert dumped_env["DSH_CWD"] == str(tmp_path)
  108. assert dumped_env["DSH_SESSION_ROOT"] == str(tmp_path / "sessions")
  109. assert dumped_env["DSH_CORDIS_CONFIG"] == str(tmp_path / "cordis.yml")
  110. assert json.loads(init_dump.read_text()) == {
  111. "cwd": str(tmp_path),
  112. "provider": "deepseek-official",
  113. "model": "deepseek-v4-flash",
  114. "maxTokens": 4096,
  115. }
  116. def test_session_run_invokes_notification_callback_before_returning(tmp_path: Path) -> None:
  117. script = tmp_path / "fake_runtime.py"
  118. script.write_text(
  119. """
  120. import json
  121. import sys
  122. for line in sys.stdin:
  123. msg = json.loads(line)
  124. method = msg.get("method")
  125. if method == "initialize":
  126. print(json.dumps({"jsonrpc": "2.0", "id": msg["id"], "result": {"serverInfo": {"name": "fake-runtime"}}}), flush=True)
  127. elif method == "session/prompt":
  128. print(json.dumps({"jsonrpc": "2.0", "method": "session.event", "params": {"sessionId": "main", "event": {"type": "agent/inbox/spliced", "data": {"target": "next-turn", "start": 0, "inserted": [{"id": "message-1"}]}}}}), flush=True)
  129. print(json.dumps({"jsonrpc": "2.0", "method": "session.status", "params": {"sessionId": "main", "status": "running"}}), flush=True)
  130. print(json.dumps({"jsonrpc": "2.0", "id": msg["id"], "result": {"messageId": "message-1"}}), flush=True)
  131. print(json.dumps({"jsonrpc": "2.0", "method": "subagent.started", "params": {"parentSessionId": "main", "childSessionId": "child"}}), flush=True)
  132. print(json.dumps({"jsonrpc": "2.0", "method": "session.status", "params": {"sessionId": "main", "status": "idle"}}), flush=True)
  133. elif method == "shutdown":
  134. print(json.dumps({"jsonrpc": "2.0", "id": msg["id"], "result": {}}), flush=True)
  135. break
  136. """.strip()
  137. )
  138. seen: list[str] = []
  139. with DeepSeekHarness(
  140. launch_args_override=(sys.executable, str(script)),
  141. cwd=str(tmp_path),
  142. ) as harness:
  143. session = harness.start_session("main")
  144. result = session.run(
  145. "spawn a helper",
  146. on_notification=lambda notification: seen.append(notification.method),
  147. )
  148. assert seen == ["session.event", "session.status", "subagent.started", "session.status"]
  149. assert result.finish_reason is None
  150. def test_high_level_sdk_rejects_turn_end_without_reason_kind(tmp_path: Path) -> None:
  151. script = tmp_path / "fake_runtime.py"
  152. script.write_text(
  153. """
  154. import json
  155. import sys
  156. for line in sys.stdin:
  157. msg = json.loads(line)
  158. method = msg.get("method")
  159. if method == "initialize":
  160. print(json.dumps({"jsonrpc": "2.0", "id": msg["id"], "result": {"serverInfo": {"name": "fake-runtime"}}}), flush=True)
  161. elif method == "session/prompt":
  162. params = msg.get("params") or {}
  163. print(json.dumps({"jsonrpc": "2.0", "method": "session.event", "params": {"sessionId": params["sessionId"], "event": {"type": "agent/inbox/spliced", "data": {"target": "next-turn", "start": 0, "inserted": [{"id": "message-1"}]}}}}), flush=True)
  164. print(json.dumps({"jsonrpc": "2.0", "id": msg["id"], "result": {"messageId": "message-1"}}), flush=True)
  165. print(json.dumps({"jsonrpc": "2.0", "method": "session.event", "params": {"sessionId": params["sessionId"], "event": {"type": "turn/end", "data": {"turn": 1, "reason": {}}}}}), flush=True)
  166. print(json.dumps({"jsonrpc": "2.0", "method": "session.status", "params": {"sessionId": params["sessionId"], "status": "idle"}}), flush=True)
  167. elif method == "shutdown":
  168. print(json.dumps({"jsonrpc": "2.0", "id": msg["id"], "result": {}}), flush=True)
  169. break
  170. """.strip()
  171. )
  172. with DeepSeekHarness(
  173. launch_args_override=(sys.executable, str(script)),
  174. cwd=str(tmp_path),
  175. ) as harness:
  176. with pytest.raises(
  177. SdkProtocolError,
  178. match=r"turn/end event requires a string data\.reason\.kind",
  179. ):
  180. harness.run("reject malformed turn ending", session_id="main")
  181. def test_relative_cwd_is_absolute_in_process_environment_and_wire(
  182. tmp_path: Path, monkeypatch: pytest.MonkeyPatch
  183. ) -> None:
  184. script = tmp_path / "capture_cwd.py"
  185. capture = tmp_path / "cwd.json"
  186. script.write_text(
  187. """
  188. import json
  189. import os
  190. import sys
  191. for line in sys.stdin:
  192. msg = json.loads(line)
  193. if msg.get("method") == "initialize":
  194. json.dump({"process": os.getcwd(), "environment": os.environ.get("DSH_CWD"), "wire": msg["params"]["cwd"]}, open(os.environ["CAPTURE"], "w"))
  195. print(json.dumps({"jsonrpc": "2.0", "id": msg["id"], "result": {"serverInfo": {"name": "fake-runtime"}}}), flush=True)
  196. elif msg.get("method") == "shutdown":
  197. print(json.dumps({"jsonrpc": "2.0", "id": msg["id"], "result": {}}), flush=True)
  198. break
  199. """.strip()
  200. )
  201. monkeypatch.chdir(tmp_path)
  202. with DeepSeekHarness(
  203. cwd=".",
  204. runtime_cwd=".",
  205. launch_args_override=(sys.executable, str(script)),
  206. env={"CAPTURE": str(capture)},
  207. ):
  208. pass
  209. expected = str(tmp_path.resolve())
  210. assert json.loads(capture.read_text()) == {
  211. "process": expected,
  212. "environment": expected,
  213. "wire": expected,
  214. }
  215. def test_session_run_includes_subagent_finished_for_parent_session(tmp_path: Path) -> None:
  216. script = tmp_path / "fake_runtime.py"
  217. script.write_text(
  218. """
  219. import json
  220. import sys
  221. for line in sys.stdin:
  222. msg = json.loads(line)
  223. method = msg.get("method")
  224. if method == "initialize":
  225. print(json.dumps({"jsonrpc": "2.0", "id": msg["id"], "result": {"serverInfo": {"name": "fake-runtime"}}}), flush=True)
  226. elif method == "session/prompt":
  227. print(json.dumps({"jsonrpc": "2.0", "method": "session.event", "params": {"sessionId": "main", "event": {"type": "agent/inbox/spliced", "data": {"target": "next-turn", "start": 0, "inserted": [{"id": "message-1"}]}}}}), flush=True)
  228. print(json.dumps({"jsonrpc": "2.0", "method": "session.status", "params": {"sessionId": "main", "status": "running"}}), flush=True)
  229. print(json.dumps({"jsonrpc": "2.0", "id": msg["id"], "result": {"messageId": "message-1"}}), flush=True)
  230. print(json.dumps({"jsonrpc": "2.0", "method": "subagent.started", "params": {"parentSessionId": "main", "childSessionId": "child"}}), flush=True)
  231. print(json.dumps({"jsonrpc": "2.0", "method": "subagent.finished", "params": {"parentSessionId": "main", "childSessionId": "child", "status": "ok", "stopReason": "completed"}}), flush=True)
  232. print(json.dumps({"jsonrpc": "2.0", "method": "session.status", "params": {"sessionId": "main", "status": "idle"}}), flush=True)
  233. elif method == "shutdown":
  234. print(json.dumps({"jsonrpc": "2.0", "id": msg["id"], "result": {}}), flush=True)
  235. break
  236. """.strip()
  237. )
  238. with DeepSeekHarness(
  239. launch_args_override=(sys.executable, str(script)),
  240. cwd=str(tmp_path),
  241. ) as harness:
  242. result = harness.run("spawn a helper", session_id="main")
  243. assert [notification.method for notification in result.notifications] == [
  244. "session.event",
  245. "session.status",
  246. "subagent.started",
  247. "subagent.finished",
  248. "session.status",
  249. ]
  250. def test_session_run_collects_nested_subagent_tree_without_polluting_root_events(
  251. tmp_path: Path,
  252. ) -> None:
  253. script = tmp_path / "fake_runtime.py"
  254. script.write_text(
  255. """
  256. import json
  257. import sys
  258. for line in sys.stdin:
  259. msg = json.loads(line)
  260. method = msg.get("method")
  261. if method == "initialize":
  262. print(json.dumps({"jsonrpc": "2.0", "id": msg["id"], "result": {"serverInfo": {"name": "fake-runtime"}}}), flush=True)
  263. elif method == "session/prompt":
  264. root = (msg.get("params") or {})["sessionId"]
  265. print(json.dumps({"jsonrpc": "2.0", "method": "session.event", "params": {"sessionId": root, "event": {"type": "agent/inbox/spliced", "data": {"target": "next-turn", "start": 0, "inserted": [{"id": "message-1"}]}}}}), flush=True)
  266. print(json.dumps({"jsonrpc": "2.0", "method": "session.status", "params": {"sessionId": root, "status": "running"}}), flush=True)
  267. print(json.dumps({"jsonrpc": "2.0", "id": msg["id"], "result": {"messageId": "message-1"}}), flush=True)
  268. print(json.dumps({"jsonrpc": "2.0", "method": "subagent.started", "params": {"parentSessionId": root, "childSessionId": "child"}}), flush=True)
  269. print(json.dumps({"jsonrpc": "2.0", "method": "session.event", "params": {"sessionId": "child", "event": {"type": "assistant/message", "data": {"content": [{"type": "text", "text": "child response"}]}}}}), flush=True)
  270. print(json.dumps({"jsonrpc": "2.0", "method": "subagent.started", "params": {"parentSessionId": "child", "childSessionId": "grandchild"}}), flush=True)
  271. print(json.dumps({"jsonrpc": "2.0", "method": "session.event", "params": {"sessionId": "grandchild", "event": {"type": "assistant/message", "data": {"content": [{"type": "text", "text": "grandchild response"}]}}}}), flush=True)
  272. print(json.dumps({"jsonrpc": "2.0", "method": "subagent.finished", "params": {"parentSessionId": "child", "childSessionId": "grandchild", "status": "ok"}}), flush=True)
  273. print(json.dumps({"jsonrpc": "2.0", "method": "subagent.finished", "params": {"parentSessionId": root, "childSessionId": "child", "status": "ok"}}), flush=True)
  274. print(json.dumps({"jsonrpc": "2.0", "method": "session.event", "params": {"sessionId": root, "event": {"type": "assistant/message", "data": {"content": [{"type": "text", "text": "root response"}]}}}}), flush=True)
  275. print(json.dumps({"jsonrpc": "2.0", "method": "session.status", "params": {"sessionId": root, "status": "idle"}}), flush=True)
  276. elif method == "shutdown":
  277. print(json.dumps({"jsonrpc": "2.0", "id": msg["id"], "result": {}}), flush=True)
  278. break
  279. """.strip()
  280. )
  281. seen: list[str] = []
  282. with DeepSeekHarness(
  283. launch_args_override=(sys.executable, str(script)),
  284. cwd=str(tmp_path),
  285. ) as harness:
  286. result = harness.run(
  287. "delegate recursively",
  288. session_id="main",
  289. on_notification=lambda notification: seen.append(notification.method),
  290. )
  291. assert harness.client._notifications.qsize() == 0
  292. assert result.final_response == "root response"
  293. assert [event["data"]["content"][0]["text"] for event in result.events if event["type"] == "assistant/message"] == ["root response"]
  294. assert [notification.method for notification in result.notifications] == [
  295. "session.event",
  296. "session.status",
  297. "subagent.started",
  298. "session.event",
  299. "subagent.started",
  300. "session.event",
  301. "subagent.finished",
  302. "subagent.finished",
  303. "session.event",
  304. "session.status",
  305. ]
  306. assert seen == [notification.method for notification in result.notifications]
  307. def test_session_run_ignores_notifications_for_other_sessions(tmp_path: Path) -> None:
  308. script = tmp_path / "fake_runtime.py"
  309. script.write_text(
  310. """
  311. import json
  312. import sys
  313. for line in sys.stdin:
  314. msg = json.loads(line)
  315. method = msg.get("method")
  316. if method == "initialize":
  317. print(json.dumps({"jsonrpc": "2.0", "id": msg["id"], "result": {"serverInfo": {"name": "fake-runtime"}}}), flush=True)
  318. elif method == "session/prompt":
  319. params = msg.get("params") or {}
  320. print(json.dumps({"jsonrpc": "2.0", "method": "session.event", "params": {"sessionId": "other", "event": {"type": "assistant/message", "data": {"content": [{"type": "text", "text": "wrong session"}]}}}}), flush=True)
  321. print(json.dumps({"jsonrpc": "2.0", "method": "session.status", "params": {"sessionId": "other", "status": "idle"}}), flush=True)
  322. print(json.dumps({"jsonrpc": "2.0", "method": "session.event", "params": {"sessionId": params["sessionId"], "event": {"type": "agent/inbox/spliced", "data": {"target": "next-turn", "start": 0, "inserted": [{"id": "message-1"}]}}}}), flush=True)
  323. print(json.dumps({"jsonrpc": "2.0", "method": "session.status", "params": {"sessionId": params["sessionId"], "status": "running"}}), flush=True)
  324. print(json.dumps({"jsonrpc": "2.0", "id": msg["id"], "result": {"messageId": "message-1"}}), flush=True)
  325. print(json.dumps({"jsonrpc": "2.0", "method": "session.event", "params": {"sessionId": params["sessionId"], "event": {"type": "assistant/message", "data": {"content": [{"type": "text", "text": "right session"}]}}}}), flush=True)
  326. print(json.dumps({"jsonrpc": "2.0", "method": "session.status", "params": {"sessionId": params["sessionId"], "status": "idle"}}), flush=True)
  327. elif method == "shutdown":
  328. print(json.dumps({"jsonrpc": "2.0", "id": msg["id"], "result": {}}), flush=True)
  329. break
  330. """.strip()
  331. )
  332. with DeepSeekHarness(
  333. launch_args_override=(sys.executable, str(script)),
  334. cwd=str(tmp_path),
  335. ) as harness:
  336. result = harness.run("stay in your lane", session_id="main")
  337. assert result.final_response == "right session"
  338. assert [notification.payload.get("sessionId") for notification in result.notifications] == ["main"] * 4
  339. def test_high_level_session_run_does_not_accumulate_global_notifications(tmp_path: Path) -> None:
  340. script = tmp_path / "fake_runtime.py"
  341. script.write_text(
  342. """
  343. import json
  344. import sys
  345. for line in sys.stdin:
  346. msg = json.loads(line)
  347. method = msg.get("method")
  348. if method == "initialize":
  349. print(json.dumps({"jsonrpc": "2.0", "id": msg["id"], "result": {"serverInfo": {"name": "fake-runtime"}}}), flush=True)
  350. elif method == "session/prompt":
  351. params = msg.get("params") or {}
  352. print(json.dumps({"jsonrpc": "2.0", "method": "session.event", "params": {"sessionId": params["sessionId"], "event": {"type": "agent/inbox/spliced", "data": {"target": "next-turn", "start": 0, "inserted": [{"id": "message-1"}]}}}}), flush=True)
  353. print(json.dumps({"jsonrpc": "2.0", "method": "session.status", "params": {"sessionId": params["sessionId"], "status": "running"}}), flush=True)
  354. print(json.dumps({"jsonrpc": "2.0", "id": msg["id"], "result": {"messageId": "message-1"}}), flush=True)
  355. print(json.dumps({"jsonrpc": "2.0", "method": "session.event", "params": {"sessionId": params["sessionId"], "event": {"type": "assistant/message", "data": {"content": [{"type": "text", "text": "ok"}]}}}}), flush=True)
  356. print(json.dumps({"jsonrpc": "2.0", "method": "session.status", "params": {"sessionId": params["sessionId"], "status": "idle"}}), flush=True)
  357. elif method == "shutdown":
  358. print(json.dumps({"jsonrpc": "2.0", "id": msg["id"], "result": {}}), flush=True)
  359. break
  360. """.strip()
  361. )
  362. with DeepSeekHarness(launch_args_override=(sys.executable, str(script)), cwd=str(tmp_path)) as harness:
  363. result = harness.run("one turn", session_id="main")
  364. assert harness.client._notifications.qsize() == 0
  365. def test_session_run_waits_for_late_idle_without_replaying_stale_notifications(tmp_path: Path) -> None:
  366. script = tmp_path / "fake_runtime.py"
  367. script.write_text(
  368. """
  369. import json
  370. import sys
  371. import time
  372. turn = 0
  373. for line in sys.stdin:
  374. msg = json.loads(line)
  375. method = msg.get("method")
  376. if method == "initialize":
  377. print(json.dumps({"jsonrpc": "2.0", "id": msg["id"], "result": {"serverInfo": {"name": "fake-runtime"}}}), flush=True)
  378. elif method == "session/prompt":
  379. turn += 1
  380. params = msg.get("params") or {}
  381. session_id = params["sessionId"]
  382. message_id = f"message-{turn}"
  383. print(json.dumps({"jsonrpc": "2.0", "method": "session.event", "params": {"sessionId": session_id, "event": {"type": "agent/inbox/spliced", "data": {"target": "next-turn", "start": 0, "inserted": [{"id": message_id}]}}}}), flush=True)
  384. print(json.dumps({"jsonrpc": "2.0", "method": "session.status", "params": {"sessionId": session_id, "status": "running"}}), flush=True)
  385. print(json.dumps({"jsonrpc": "2.0", "id": msg["id"], "result": {"messageId": message_id}}), flush=True)
  386. if turn == 1:
  387. print(json.dumps({"jsonrpc": "2.0", "method": "session.event", "params": {"sessionId": session_id, "event": {"type": "assistant/message", "data": {"content": [{"type": "text", "text": "first"}]}}}}), flush=True)
  388. print(json.dumps({"jsonrpc": "2.0", "method": "session.status", "params": {"sessionId": session_id, "status": "idle"}}), flush=True)
  389. else:
  390. time.sleep(0.05)
  391. print(json.dumps({"jsonrpc": "2.0", "method": "session.event", "params": {"sessionId": session_id, "event": {"type": "assistant/message", "data": {"content": [{"type": "text", "text": "second"}]}}}}), flush=True)
  392. print(json.dumps({"jsonrpc": "2.0", "method": "session.status", "params": {"sessionId": session_id, "status": "idle"}}), flush=True)
  393. elif method == "shutdown":
  394. print(json.dumps({"jsonrpc": "2.0", "id": msg["id"], "result": {}}), flush=True)
  395. break
  396. """.strip()
  397. )
  398. with DeepSeekHarness(launch_args_override=(sys.executable, str(script)), cwd=str(tmp_path)) as harness:
  399. first = harness.run("first turn", session_id="main")
  400. second = harness.run("second turn", session_id="main")
  401. assert first.final_response == "first"
  402. assert second.final_response == "second"
  403. assert [notification.payload.get("sessionId") for notification in second.notifications] == ["main"] * 4
  404. def test_client_starts_subprocess_sends_requests_and_routes_notifications(tmp_path: Path) -> None:
  405. script = tmp_path / "fake_bridge.py"
  406. script.write_text(
  407. """
  408. import json
  409. import sys
  410. for line in sys.stdin:
  411. msg = json.loads(line)
  412. method = msg.get("method")
  413. if method == "initialize":
  414. print(json.dumps({"jsonrpc": "2.0", "id": msg["id"], "result": {"serverInfo": {"name": "fake-dsh"}}}), flush=True)
  415. elif method == "session/prompt":
  416. params = msg.get("params") or {}
  417. print(json.dumps({"jsonrpc": "2.0", "method": "llm/request", "params": {"requestId": "req-1", "sessionId": params["sessionId"], "model": "dsagent", "messages": []}}), flush=True)
  418. print(json.dumps({"jsonrpc": "2.0", "id": msg["id"], "result": {"messageId": "message-1"}}), flush=True)
  419. elif method == "shutdown":
  420. print(json.dumps({"jsonrpc": "2.0", "id": msg["id"], "result": {}}), flush=True)
  421. break
  422. """.strip()
  423. )
  424. with HarnessClient(
  425. HarnessConfig(launch_args_override=(sys.executable, str(script)))
  426. ) as client:
  427. init = client.initialize(provider="deepseek-official", cwd="/workspace", model="dsagent")
  428. assert init.serverInfo.name == "fake-dsh"
  429. client.session_prompt("main", [{"type": "text", "text": "fix it"}])
  430. notification = client.next_notification()
  431. assert notification.method == "llm/request"
  432. assert notification.payload["requestId"] == "req-1"
  433. assert notification.payload["sessionId"] == "main"
  434. def test_client_keeps_unmatched_notifications_available_globally_while_subscribed() -> None:
  435. client = HarnessClient()
  436. with client.subscribe_session_notifications("main"):
  437. client._handle_message({
  438. "jsonrpc": "2.0",
  439. "method": "session.event",
  440. "params": {"sessionId": "other", "event": {"type": "assistant/message"}},
  441. })
  442. assert client._notifications.qsize() == 1
  443. notification = client._notifications.get_nowait()
  444. assert not isinstance(notification, BaseException)
  445. assert notification.method == "session.event"
  446. assert notification.payload["sessionId"] == "other"
  447. def test_session_subscription_keeps_descendant_relationships_across_subscriptions() -> None:
  448. client = HarnessClient()
  449. with client.subscribe_session_notifications("main") as first:
  450. client._handle_message({
  451. "jsonrpc": "2.0",
  452. "method": "subagent.started",
  453. "params": {"parentSessionId": "main", "childSessionId": "child"},
  454. })
  455. assert first.next().payload["childSessionId"] == "child"
  456. with client.subscribe_session_notifications("main") as second:
  457. client._handle_message({
  458. "jsonrpc": "2.0",
  459. "method": "subagent.started",
  460. "params": {"parentSessionId": "child", "childSessionId": "grandchild"},
  461. })
  462. client._handle_message({
  463. "jsonrpc": "2.0",
  464. "method": "session.event",
  465. "params": {"sessionId": "grandchild", "event": {"type": "assistant/message"}},
  466. })
  467. assert second.next().payload["childSessionId"] == "grandchild"
  468. assert second.next().payload["sessionId"] == "grandchild"
  469. assert client._notifications.qsize() == 0
  470. def test_session_subscription_preserves_reused_child_ancestry_after_late_finish() -> None:
  471. client = HarnessClient()
  472. old_seen: list[Notification] = []
  473. new_seen: list[Notification] = []
  474. with (
  475. client.subscribe_session_notifications("old-parent") as old_subscription,
  476. client.subscribe_session_notifications("new-parent") as new_subscription,
  477. ):
  478. client._handle_message({
  479. "jsonrpc": "2.0",
  480. "method": "subagent.started",
  481. "params": {"parentSessionId": "old-parent", "childSessionId": "reused-child"},
  482. })
  483. old_subscription.drain(old_seen.append)
  484. new_subscription.drain(new_seen.append)
  485. assert [notification.method for notification in old_seen] == ["subagent.started"]
  486. assert new_seen == []
  487. client._handle_message({
  488. "jsonrpc": "2.0",
  489. "method": "subagent.started",
  490. "params": {"parentSessionId": "new-parent", "childSessionId": "reused-child"},
  491. })
  492. old_subscription.drain(old_seen.append)
  493. new_subscription.drain(new_seen.append)
  494. assert [notification.method for notification in new_seen] == ["subagent.started"]
  495. client._handle_message({
  496. "jsonrpc": "2.0",
  497. "method": "subagent.finished",
  498. "params": {"parentSessionId": "old-parent", "childSessionId": "reused-child"},
  499. })
  500. old_subscription.drain(old_seen.append)
  501. new_subscription.drain(new_seen.append)
  502. assert [notification.method for notification in old_seen] == [
  503. "subagent.started",
  504. "subagent.finished",
  505. ]
  506. assert [notification.method for notification in new_seen] == ["subagent.started"]
  507. client._handle_message({
  508. "jsonrpc": "2.0",
  509. "method": "session.event",
  510. "params": {"sessionId": "reused-child", "event": {"type": "assistant/message"}},
  511. })
  512. old_subscription.drain(old_seen.append)
  513. new_subscription.drain(new_seen.append)
  514. assert [notification.method for notification in old_seen] == [
  515. "subagent.started",
  516. "subagent.finished",
  517. ]
  518. assert [notification.method for notification in new_seen] == [
  519. "subagent.started",
  520. "session.event",
  521. ]
  522. assert client._notifications.qsize() == 0
  523. def test_client_contains_notification_filter_failure_to_its_subscription(tmp_path: Path) -> None:
  524. script = tmp_path / "fake_bridge.py"
  525. script.write_text(
  526. """
  527. import json
  528. import sys
  529. for line in sys.stdin:
  530. msg = json.loads(line)
  531. method = msg.get("method")
  532. if method == "initialize":
  533. print(json.dumps({"jsonrpc": "2.0", "id": msg["id"], "result": {"serverInfo": {"name": "fake-dsh"}}}), flush=True)
  534. elif method in {"emit-first", "emit-second"}:
  535. print(json.dumps({"jsonrpc": "2.0", "method": "tick", "params": {"source": method}}), flush=True)
  536. elif method == "session/prompt":
  537. print(json.dumps({"jsonrpc": "2.0", "id": msg["id"], "result": {"messageId": "message-1"}}), flush=True)
  538. elif method == "shutdown":
  539. print(json.dumps({"jsonrpc": "2.0", "id": msg["id"], "result": {}}), flush=True)
  540. break
  541. """.strip()
  542. )
  543. def broken_filter(_notification: object) -> bool:
  544. raise RuntimeError("bad notification filter")
  545. with HarnessClient(HarnessConfig(launch_args_override=(sys.executable, str(script)))) as client:
  546. client.initialize(provider="deepseek-official", cwd="/workspace", model="dsagent")
  547. with (
  548. client.subscribe_notifications(broken_filter) as broken,
  549. client.subscribe_notifications(lambda notification: notification.method == "tick") as healthy,
  550. ):
  551. client.notify("emit-first")
  552. with pytest.raises(RuntimeError, match="bad notification filter"):
  553. broken.next()
  554. assert healthy.next().payload == {"source": "emit-first"}
  555. assert client._notifications.qsize() == 0
  556. client.session_prompt("main", [{"type": "text", "text": "reader still works"}])
  557. client.notify("emit-second")
  558. assert healthy.next().payload == {"source": "emit-second"}
  559. def test_client_rejects_unaccepted_session_prompt_response(tmp_path: Path) -> None:
  560. script = tmp_path / "fake_bridge.py"
  561. script.write_text(
  562. """
  563. import json
  564. import sys
  565. for line in sys.stdin:
  566. msg = json.loads(line)
  567. method = msg.get("method")
  568. if method == "initialize":
  569. print(json.dumps({"jsonrpc": "2.0", "id": msg["id"], "result": {"serverInfo": {"name": "fake-dsh"}}}), flush=True)
  570. elif method == "session/prompt":
  571. print(json.dumps({"jsonrpc": "2.0", "id": msg["id"], "result": {"accepted": False}}), flush=True)
  572. elif method == "shutdown":
  573. print(json.dumps({"jsonrpc": "2.0", "id": msg["id"], "result": {}}), flush=True)
  574. break
  575. """.strip()
  576. )
  577. with HarnessClient(HarnessConfig(launch_args_override=(sys.executable, str(script)))) as client:
  578. client.initialize(provider="deepseek-official", cwd="/workspace", model="dsagent")
  579. with pytest.raises(ValueError):
  580. client.session_prompt("main", [{"type": "text", "text": "fix it"}])
  581. def test_client_routes_bridge_requests_and_sends_responses(tmp_path: Path) -> None:
  582. script = tmp_path / "fake_bridge.py"
  583. script.write_text(
  584. """
  585. import json
  586. import sys
  587. for line in sys.stdin:
  588. msg = json.loads(line)
  589. method = msg.get("method")
  590. if method == "initialize":
  591. print(json.dumps({"jsonrpc": "2.0", "id": msg["id"], "result": {"serverInfo": {"name": "fake-dsh"}}}), flush=True)
  592. print(json.dumps({"jsonrpc": "2.0", "id": "bridge-req-1", "method": "llm.request", "params": {"requestId": "req-1", "sessionId": "main", "model": "dsagent", "messages": []}}), flush=True)
  593. elif "id" in msg and "method" not in msg:
  594. print(json.dumps({"jsonrpc": "2.0", "method": "response/seen", "params": {"result": msg.get("result")}}), flush=True)
  595. elif method == "shutdown":
  596. print(json.dumps({"jsonrpc": "2.0", "id": msg["id"], "result": {}}), flush=True)
  597. break
  598. """.strip()
  599. )
  600. with HarnessClient(
  601. HarnessConfig(launch_args_override=(sys.executable, str(script)))
  602. ) as client:
  603. client.initialize(provider="deepseek-official", cwd="/workspace", model="dsagent")
  604. request = client.next_request()
  605. assert request.id == "bridge-req-1"
  606. assert request.method == "llm.request"
  607. assert request.payload["requestId"] == "req-1"
  608. client.respond(request.id, {"content_blocks": [{"type": "text", "text": "done"}]})
  609. notification = client.next_notification()
  610. assert notification.method == "response/seen"
  611. assert notification.payload["result"]["content_blocks"][0]["text"] == "done"
  612. def test_client_ignores_non_json_stdout_lines(tmp_path: Path) -> None:
  613. script = tmp_path / "fake_bridge.py"
  614. script.write_text(
  615. """
  616. import json
  617. import sys
  618. print("node warning: experimental loader", flush=True)
  619. for line in sys.stdin:
  620. msg = json.loads(line)
  621. if msg.get("method") == "initialize":
  622. print(json.dumps({"jsonrpc": "2.0", "id": msg["id"], "result": {"serverInfo": {"name": "fake-dsh"}}}), flush=True)
  623. elif msg.get("method") == "shutdown":
  624. print(json.dumps({"jsonrpc": "2.0", "id": msg["id"], "result": {}}), flush=True)
  625. break
  626. """.strip()
  627. )
  628. with HarnessClient(
  629. HarnessConfig(launch_args_override=(sys.executable, str(script)))
  630. ) as client:
  631. init = client.initialize(provider="deepseek-official", cwd="/workspace", model="dsagent")
  632. assert init.serverInfo.name == "fake-dsh"
  633. def test_client_request_times_out_when_bridge_does_not_respond(tmp_path: Path) -> None:
  634. script = tmp_path / "fake_bridge.py"
  635. script.write_text(
  636. """
  637. import sys
  638. import time
  639. print("bridge is still starting", file=sys.stderr, flush=True)
  640. time.sleep(60)
  641. """.strip()
  642. )
  643. with HarnessClient(
  644. HarnessConfig(
  645. launch_args_override=(sys.executable, str(script)),
  646. request_timeout_seconds=0.1,
  647. )
  648. ) as client:
  649. start = time.monotonic()
  650. try:
  651. client.initialize(provider="deepseek-official", cwd="/workspace", model="dsagent")
  652. except TimeoutError as exc:
  653. assert time.monotonic() - start < 2
  654. assert "bridge is still starting" in str(exc)
  655. else:
  656. raise AssertionError("initialize should time out")
  657. def test_client_close_times_out_when_shutdown_does_not_respond(tmp_path: Path) -> None:
  658. script = tmp_path / "fake_bridge.py"
  659. script.write_text(
  660. """
  661. import json
  662. import signal
  663. import sys
  664. import time
  665. signal.signal(signal.SIGTERM, signal.SIG_IGN)
  666. for line in sys.stdin:
  667. msg = json.loads(line)
  668. if msg.get("method") == "initialize":
  669. print(json.dumps({"jsonrpc": "2.0", "id": msg["id"], "result": {"serverInfo": {"name": "fake-dsh"}}}), flush=True)
  670. elif msg.get("method") == "shutdown":
  671. time.sleep(60)
  672. """.strip()
  673. )
  674. client = HarnessClient(
  675. HarnessConfig(
  676. launch_args_override=(sys.executable, str(script)),
  677. shutdown_timeout_seconds=0.1,
  678. )
  679. )
  680. client.start()
  681. proc = client._proc
  682. assert proc is not None
  683. client.initialize(provider="deepseek-official", cwd="/workspace", model="dsagent")
  684. start = time.monotonic()
  685. client.close()
  686. assert time.monotonic() - start < 2
  687. assert proc.poll() is not None
  688. assert client._proc is None
  689. def test_initialize_failure_reaps_started_runtime(tmp_path: Path) -> None:
  690. script = tmp_path / "rejecting_runtime.py"
  691. script.write_text(
  692. """
  693. import json
  694. import sys
  695. for line in sys.stdin:
  696. msg = json.loads(line)
  697. if msg.get("method") == "initialize":
  698. print(json.dumps({"jsonrpc": "2.0", "id": msg["id"], "error": {"code": -32000, "message": "bad initialize"}}), flush=True)
  699. elif msg.get("method") == "shutdown":
  700. print(json.dumps({"jsonrpc": "2.0", "id": msg["id"], "result": {}}), flush=True)
  701. break
  702. """.strip()
  703. )
  704. client = HarnessClient(HarnessConfig(launch_args_override=(sys.executable, str(script))))
  705. client.start()
  706. proc = client._proc
  707. assert proc is not None
  708. with pytest.raises(Exception, match="bad initialize"):
  709. client.initialize(provider="deepseek-official", cwd=".", model="dsagent")
  710. assert proc.wait(timeout=1) is not None
  711. assert client._proc is None
  712. def test_public_signatures_omit_unsupported_wire_parameters() -> None:
  713. from deepseek_harness import DeepSeekHarnessConfig, Session
  714. assert "session_root" not in inspect.signature(HarnessClient.initialize).parameters
  715. assert "system_prompt" not in inspect.signature(HarnessClient.initialize).parameters
  716. assert "profile" not in inspect.signature(HarnessClient.session_prompt).parameters
  717. assert "profile" not in inspect.signature(DeepSeekHarness.run).parameters
  718. assert "profile" not in inspect.signature(Session.run).parameters
  719. assert "system_prompt" not in DeepSeekHarnessConfig.__dataclass_fields__
  720. assert "max_tokens" in DeepSeekHarnessConfig.__dataclass_fields__
  721. assert "max_tokens" in inspect.signature(HarnessClient.initialize).parameters
  722. assert "client_name" not in HarnessConfig.__dataclass_fields__
  723. assert "client_version" not in HarnessConfig.__dataclass_fields__
  724. def test_client_close_is_idempotent_before_and_after_start(tmp_path: Path) -> None:
  725. HarnessClient().close()
  726. script = tmp_path / "fake_bridge.py"
  727. script.write_text(
  728. """
  729. import json
  730. import sys
  731. for line in sys.stdin:
  732. msg = json.loads(line)
  733. if msg.get("method") == "initialize":
  734. print(json.dumps({"jsonrpc": "2.0", "id": msg["id"], "result": {"serverInfo": {"name": "fake-dsh"}}}), flush=True)
  735. elif msg.get("method") == "shutdown":
  736. print(json.dumps({"jsonrpc": "2.0", "id": msg["id"], "result": {}}), flush=True)
  737. break
  738. """.strip()
  739. )
  740. client = HarnessClient(HarnessConfig(launch_args_override=(sys.executable, str(script))))
  741. client.start()
  742. client.initialize(provider="deepseek-official", cwd="/workspace", model="dsagent")
  743. client.close()
  744. client.close()
  745. def test_runtime_closed_error_includes_stderr_tail(tmp_path: Path) -> None:
  746. script = tmp_path / "crashing_runtime.py"
  747. script.write_text(
  748. """
  749. import sys
  750. print("fatal bridge exploded", file=sys.stderr, flush=True)
  751. sys.exit(42)
  752. """.strip()
  753. )
  754. with HarnessClient(
  755. HarnessConfig(
  756. launch_args_override=(sys.executable, str(script)),
  757. request_timeout_seconds=2,
  758. )
  759. ) as client:
  760. with pytest.raises(Exception, match="fatal bridge exploded"):
  761. client.initialize(provider="deepseek-official", cwd="/workspace", model="dsagent")
  762. def test_client_serializes_concurrent_writes(tmp_path: Path) -> None:
  763. script = tmp_path / "fake_bridge.py"
  764. output = tmp_path / "seen.jsonl"
  765. script.write_text(
  766. """
  767. import json
  768. import os
  769. import sys
  770. with open(os.environ["SEEN"], "w") as seen:
  771. for line in sys.stdin:
  772. seen.write(line)
  773. seen.flush()
  774. msg = json.loads(line)
  775. if "id" in msg and msg.get("method") == "initialize":
  776. print(json.dumps({"jsonrpc": "2.0", "id": msg["id"], "result": {"serverInfo": {"name": "fake-dsh"}}}), flush=True)
  777. elif "id" in msg and msg.get("method") == "shutdown":
  778. print(json.dumps({"jsonrpc": "2.0", "id": msg["id"], "result": {}}), flush=True)
  779. break
  780. """.strip()
  781. )
  782. with HarnessClient(
  783. HarnessConfig(
  784. launch_args_override=(sys.executable, str(script)),
  785. env={"SEEN": str(output)},
  786. )
  787. ) as client:
  788. client.initialize(provider="deepseek-official", cwd="/workspace", model="dsagent")
  789. threads = [
  790. threading.Thread(target=client.notify, args=(f"notice-{index}", {"index": index}))
  791. for index in range(50)
  792. ]
  793. for thread in threads:
  794. thread.start()
  795. for thread in threads:
  796. thread.join()
  797. for line in output.read_text().splitlines():
  798. json.loads(line)
  799. def _install_fake_bundled_runtime(
  800. tmp_path: Path, monkeypatch: pytest.MonkeyPatch
  801. ) -> Path:
  802. """Install a fake runtime package that records config and serves lifecycle calls.
  803. Returns the fake bundled default config path.
  804. """
  805. runtime = tmp_path / "dsh-jsonrpc-agent"
  806. runtime.write_text(
  807. """#!/usr/bin/env python3
  808. import json
  809. import os
  810. import sys
  811. json.dump({"DSH_CORDIS_CONFIG": os.environ.get("DSH_CORDIS_CONFIG")}, open(os.environ["ENV_DUMP"], "w"))
  812. for line in sys.stdin:
  813. msg = json.loads(line)
  814. if msg.get("method") == "initialize":
  815. print(json.dumps({"jsonrpc": "2.0", "id": msg["id"], "result": {"serverInfo": {"name": "bundled-runtime"}}}), flush=True)
  816. elif msg.get("method") == "shutdown":
  817. print(json.dumps({"jsonrpc": "2.0", "id": msg["id"], "result": {}}), flush=True)
  818. break
  819. """.strip()
  820. )
  821. runtime.chmod(0o755)
  822. default_config = tmp_path / "default-cordis.yml"
  823. module_dir = tmp_path / "deepseek_harness_runtime"
  824. module_dir.mkdir()
  825. (module_dir / "__init__.py").write_text(
  826. f"""
  827. def resolve_bundled_launch_args(mode=None):
  828. return ({str(runtime)!r},)
  829. def bundled_default_config_path():
  830. return {str(default_config)!r}
  831. """.strip()
  832. )
  833. monkeypatch.syspath_prepend(str(tmp_path))
  834. monkeypatch.delitem(sys.modules, "deepseek_harness_runtime", raising=False)
  835. return default_config
  836. @pytest.mark.parametrize("ambient_config", [None, ""], ids=["unset", "empty-counts-as-absent"])
  837. def test_client_default_launch_uses_bundled_runtime_and_injects_default_config(
  838. tmp_path: Path, monkeypatch: pytest.MonkeyPatch, ambient_config: str | None
  839. ) -> None:
  840. env_dump = tmp_path / "env.json"
  841. default_config = _install_fake_bundled_runtime(tmp_path, monkeypatch)
  842. if ambient_config is None:
  843. monkeypatch.delenv("DSH_CORDIS_CONFIG", raising=False)
  844. else:
  845. monkeypatch.setenv("DSH_CORDIS_CONFIG", ambient_config)
  846. with HarnessClient(HarnessConfig(env={"ENV_DUMP": str(env_dump)})) as client:
  847. init = client.initialize(provider="deepseek-official", cwd="/workspace", model="deepseek-v4-pro")
  848. assert init.serverInfo.name == "bundled-runtime"
  849. assert json.loads(env_dump.read_text())["DSH_CORDIS_CONFIG"] == str(default_config)
  850. def test_client_respects_explicit_config_over_bundled_default(
  851. tmp_path: Path, monkeypatch: pytest.MonkeyPatch
  852. ) -> None:
  853. env_dump = tmp_path / "env.json"
  854. _install_fake_bundled_runtime(tmp_path, monkeypatch)
  855. monkeypatch.delenv("DSH_CORDIS_CONFIG", raising=False)
  856. with HarnessClient(
  857. HarnessConfig(env={"ENV_DUMP": str(env_dump), "DSH_CORDIS_CONFIG": "./explicit.yml"})
  858. ) as client:
  859. client.initialize(provider="deepseek-official", cwd="/workspace", model="deepseek-v4-pro")
  860. assert json.loads(env_dump.read_text())["DSH_CORDIS_CONFIG"] == "./explicit.yml"
  861. def test_client_reports_missing_bundled_runtime_dependency(monkeypatch: pytest.MonkeyPatch) -> None:
  862. monkeypatch.delitem(sys.modules, "deepseek_harness_runtime", raising=False)
  863. monkeypatch.setattr(sys, "path", [])
  864. with pytest.raises(FileNotFoundError, match="Install deepseek-harness-runtime-bin"):
  865. HarnessClient().start()