test_functional.py 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449
  1. """Functional tests for toyclaw components (no API key needed)."""
  2. import asyncio
  3. import json
  4. import os
  5. import shutil
  6. import sys
  7. import tempfile
  8. from pathlib import Path
  9. loop = asyncio.new_event_loop()
  10. asyncio.set_event_loop(loop)
  11. # ── 1. Config ──
  12. from toyclaw.config import Config, load_config
  13. def test_config():
  14. # Default config
  15. cfg = Config()
  16. assert cfg.model == "gpt-4o"
  17. assert cfg.max_iterations == 30
  18. assert cfg.heartbeat_interval == 1800
  19. # Load from a temp JSON file
  20. tmpdir = tempfile.mkdtemp(prefix="oc_cfg_")
  21. try:
  22. p = Path(tmpdir) / "config.json"
  23. p.write_text(json.dumps({"api_key": "sk-test", "model": "gpt-3.5-turbo"}))
  24. cfg = load_config(p)
  25. assert cfg.api_key == "sk-test"
  26. assert cfg.model == "gpt-3.5-turbo"
  27. # Defaults still applied for missing keys
  28. assert cfg.max_iterations == 30
  29. print(" config: Config + load_config OK")
  30. finally:
  31. shutil.rmtree(tmpdir)
  32. # ── 2. Provider data classes ──
  33. from toyclaw.provider import LLMResponse, ToolCallRequest
  34. def test_provider_dataclasses():
  35. tc = ToolCallRequest(id="t1", name="read_file", arguments={"path": "x"})
  36. assert tc.id == "t1" and tc.name == "read_file"
  37. assert tc.arguments == {"path": "x"}
  38. resp = LLMResponse(content="hi", tool_calls=[], finish_reason="stop")
  39. assert resp.content == "hi"
  40. assert not resp.has_tool_calls
  41. resp2 = LLMResponse(tool_calls=[tc])
  42. assert resp2.has_tool_calls
  43. print(" provider: dataclasses OK")
  44. # ── 3. Tool registry ──
  45. from toyclaw.tools.base import ToolRegistry
  46. def test_tool_registry():
  47. reg = ToolRegistry()
  48. assert reg.get_definitions() == []
  49. assert reg.names == []
  50. # Execute on missing tool
  51. result = loop.run_until_complete(reg.execute("nope", {}))
  52. assert "not found" in result.lower()
  53. print(" tools.base: ToolRegistry OK")
  54. # ── 4. Builtin tools ──
  55. from toyclaw.tools.builtin import (
  56. EditFileTool,
  57. ListDirTool,
  58. ReadFileTool,
  59. ShellTool,
  60. WriteFileTool,
  61. )
  62. def test_builtin_tools():
  63. tmpdir = Path(tempfile.mkdtemp(prefix="oc_test_"))
  64. ws = tmpdir # workspace
  65. try:
  66. # ReadFile — missing file
  67. rt = ReadFileTool(workspace=ws)
  68. result = loop.run_until_complete(rt.execute(path=str(tmpdir / "nope.txt")))
  69. assert "not found" in result.lower()
  70. # WriteFile
  71. wt = WriteFileTool(workspace=ws)
  72. wf = tmpdir / "out.txt"
  73. result = loop.run_until_complete(wt.execute(path=str(wf), content="wrote this"))
  74. assert wf.exists()
  75. assert wf.read_text() == "wrote this"
  76. print(" builtin: WriteFileTool OK")
  77. # ReadFile — existing file
  78. result = loop.run_until_complete(rt.execute(path=str(wf)))
  79. assert "wrote this" in result
  80. print(" builtin: ReadFileTool OK")
  81. # EditFile
  82. et = EditFileTool(workspace=ws)
  83. result = loop.run_until_complete(et.execute(path=str(wf), old_text="wrote", new_text="edited"))
  84. assert "edited this" in wf.read_text()
  85. print(" builtin: EditFileTool OK")
  86. # EditFile — old_text not found
  87. result = loop.run_until_complete(et.execute(path=str(wf), old_text="NOPE", new_text="x"))
  88. assert "not found" in result.lower()
  89. print(" builtin: EditFileTool error case OK")
  90. # ListDir
  91. ld = ListDirTool(workspace=ws)
  92. result = loop.run_until_complete(ld.execute(path=str(tmpdir)))
  93. assert "out.txt" in result
  94. print(" builtin: ListDirTool OK")
  95. # ShellTool
  96. st = ShellTool(workspace=ws)
  97. result = loop.run_until_complete(st.execute(command="echo test123"))
  98. assert "test123" in result
  99. print(" builtin: ShellTool OK")
  100. # ShellTool — deny pattern
  101. result = loop.run_until_complete(st.execute(command="rm -rf /"))
  102. assert "blocked" in result.lower()
  103. print(" builtin: ShellTool safety guard OK")
  104. finally:
  105. shutil.rmtree(tmpdir)
  106. # ── 5. Tool registry integration ──
  107. def test_tool_registry_with_tools():
  108. tmpdir = Path(tempfile.mkdtemp(prefix="oc_reg_"))
  109. try:
  110. reg = ToolRegistry()
  111. reg.register(ReadFileTool(workspace=tmpdir))
  112. reg.register(WriteFileTool(workspace=tmpdir))
  113. reg.register(ListDirTool(workspace=tmpdir))
  114. assert len(reg.names) == 3
  115. assert "read_file" in reg.names
  116. # Schema structure
  117. schemas = reg.get_definitions()
  118. assert len(schemas) == 3
  119. assert all(s["type"] == "function" for s in schemas)
  120. assert schemas[0]["function"]["name"] in ("read_file", "write_file", "list_dir")
  121. print(" registry: register + schema OK")
  122. # Execute via registry
  123. loop.run_until_complete(reg.execute("write_file", {"path": str(tmpdir / "hi.txt"), "content": "hi"}))
  124. result = loop.run_until_complete(reg.execute("read_file", {"path": str(tmpdir / "hi.txt")}))
  125. assert "hi" in result
  126. print(" registry: execute integration OK")
  127. finally:
  128. shutil.rmtree(tmpdir)
  129. # ── 6. Session ──
  130. from toyclaw.session import Session, SessionManager
  131. def test_session():
  132. tmpdir = Path(tempfile.mkdtemp(prefix="oc_sess_"))
  133. try:
  134. mgr = SessionManager(workspace=tmpdir)
  135. # New session
  136. sess = mgr.get_or_create("test_conv")
  137. assert isinstance(sess, Session)
  138. assert sess.key == "test_conv"
  139. assert len(sess.messages) == 0
  140. # Add messages
  141. sess.messages.append({"role": "user", "content": "hi"})
  142. sess.messages.append({"role": "assistant", "content": "hello"})
  143. sess.messages.append({"role": "user", "content": "q2"})
  144. mgr.save(sess)
  145. # get_history trims to user turn
  146. hist = sess.get_history()
  147. assert hist[0]["role"] == "user"
  148. assert len(hist) == 3
  149. print(" session: message append + get_history OK")
  150. # Persistence — reload from disk
  151. mgr2 = SessionManager(workspace=tmpdir)
  152. sess2 = mgr2.get_or_create("test_conv")
  153. assert len(sess2.messages) == 3
  154. assert sess2.messages[0]["content"] == "hi"
  155. print(" session: persistence OK")
  156. finally:
  157. shutil.rmtree(tmpdir)
  158. # ── 7. Skills ──
  159. from toyclaw.skills import SkillsLoader
  160. def test_skills():
  161. tmpdir = Path(tempfile.mkdtemp(prefix="oc_skills_"))
  162. try:
  163. # No skills dir
  164. loader = SkillsLoader(tmpdir)
  165. assert loader.list_skills() == []
  166. # Create a fake skill
  167. sk = tmpdir / "skills" / "demo"
  168. sk.mkdir(parents=True)
  169. (sk / "SKILL.md").write_text("# Demo Skill\nDoes demo things.\n\nMore detail here.")
  170. found = loader.list_skills()
  171. assert len(found) == 1
  172. assert found[0]["name"] == "demo"
  173. assert "Does demo things" in found[0]["description"]
  174. # build_summary
  175. summary = loader.build_summary()
  176. assert "demo" in summary
  177. assert "Does demo things" in summary
  178. print(" skills: SkillsLoader OK")
  179. finally:
  180. shutil.rmtree(tmpdir)
  181. # ── 8. Context ──
  182. from toyclaw.context import ContextBuilder
  183. def test_context():
  184. tmpdir = Path(tempfile.mkdtemp(prefix="oc_ctx_"))
  185. try:
  186. # Bootstrap file
  187. (tmpdir / "AGENTS.md").write_text("# Test Agent Instructions")
  188. builder = ContextBuilder(tmpdir)
  189. messages = builder.build_messages(
  190. history=[],
  191. user_message="hello",
  192. skills_summary="- **test_skill**: does stuff",
  193. )
  194. # Should have system + user
  195. assert len(messages) == 2
  196. assert messages[0]["role"] == "system"
  197. assert messages[1]["role"] == "user"
  198. system = messages[0]["content"]
  199. assert "ToyClaw" in system
  200. assert "Test Agent Instructions" in system
  201. assert "test_skill" in system
  202. print(" context: ContextBuilder OK")
  203. # add_assistant_message / add_tool_result
  204. ContextBuilder.add_assistant_message(messages, "thinking...")
  205. assert messages[-1]["role"] == "assistant"
  206. ContextBuilder.add_tool_result(messages, "tc1", "read_file", "file content")
  207. assert messages[-1]["role"] == "tool"
  208. assert messages[-1]["tool_call_id"] == "tc1"
  209. print(" context: message helpers OK")
  210. # strip_runtime_tag
  211. raw = messages[1]["content"]
  212. assert "[Runtime Context]" in raw
  213. stripped = ContextBuilder.strip_runtime_tag(raw)
  214. assert stripped == "hello"
  215. print(" context: strip_runtime_tag OK")
  216. finally:
  217. shutil.rmtree(tmpdir)
  218. # ── 9. Cron ──
  219. from toyclaw.cron import CronJob, CronSchedule, CronService
  220. def test_cron():
  221. tmpdir = Path(tempfile.mkdtemp(prefix="oc_cron_"))
  222. try:
  223. store = tmpdir / "cron.json"
  224. svc = CronService(store_path=store)
  225. # Add a recurring job
  226. sched = CronSchedule(kind="every", every_ms=30_000)
  227. job = svc.add_job(name="test-job", schedule=sched, message="say hi")
  228. assert isinstance(job, CronJob)
  229. assert job.id
  230. assert job.name == "test-job"
  231. # List
  232. jobs = svc.list_jobs()
  233. assert len(jobs) == 1
  234. assert jobs[0].id == job.id
  235. print(" cron: add_job + list_jobs OK")
  236. # Persistence
  237. assert store.exists()
  238. data = json.loads(store.read_text())
  239. assert len(data) == 1
  240. print(" cron: JSON persistence OK")
  241. # Remove
  242. ok = svc.remove_job(job.id)
  243. assert ok
  244. assert len(svc.list_jobs()) == 0
  245. # Remove non-existent
  246. ok = svc.remove_job("nope")
  247. assert not ok
  248. print(" cron: remove_job OK")
  249. # One-shot schedule
  250. import time
  251. future_ms = int(time.time() * 1000) + 60_000
  252. sched2 = CronSchedule(kind="at", at_ms=future_ms)
  253. job2 = svc.add_job(name="once", schedule=sched2, message="one time", delete_after_run=True)
  254. assert job2.delete_after_run
  255. svc.remove_job(job2.id)
  256. # Cron expression
  257. sched3 = CronSchedule(kind="cron", expr="0 9 * * *")
  258. job3 = svc.add_job(name="daily", schedule=sched3, message="good morning")
  259. assert job3.next_run_ms is not None # croniter computed it
  260. svc.remove_job(job3.id)
  261. print(" cron: all schedule types OK")
  262. finally:
  263. shutil.rmtree(tmpdir)
  264. # ── 10. CronTool ──
  265. from toyclaw.tools.builtin import CronTool
  266. def test_cron_tool():
  267. tmpdir = Path(tempfile.mkdtemp(prefix="oc_ct_"))
  268. try:
  269. svc = CronService(store_path=tmpdir / "cron.json")
  270. ct = CronTool(service=svc)
  271. ct.set_context("cli", "test")
  272. # Add via tool
  273. result = loop.run_until_complete(ct.execute(action="add", message="remind me", every_seconds=60))
  274. assert "created" in result.lower()
  275. print(" cron_tool: add OK")
  276. # List via tool
  277. result = loop.run_until_complete(ct.execute(action="list"))
  278. assert "remind me" in result
  279. print(" cron_tool: list OK")
  280. # Remove via tool
  281. job_id = svc.list_jobs()[0].id
  282. result = loop.run_until_complete(ct.execute(action="remove", job_id=job_id))
  283. assert "removed" in result.lower()
  284. print(" cron_tool: remove OK")
  285. # Error: missing message
  286. result = loop.run_until_complete(ct.execute(action="add"))
  287. assert "error" in result.lower()
  288. print(" cron_tool: error handling OK")
  289. finally:
  290. shutil.rmtree(tmpdir)
  291. # ── 11. Heartbeat ──
  292. from toyclaw.heartbeat import HeartbeatService
  293. def test_heartbeat():
  294. tmpdir = Path(tempfile.mkdtemp(prefix="oc_hb_"))
  295. try:
  296. # HeartbeatService requires a provider; use a mock
  297. class FakeProvider:
  298. pass
  299. hb = HeartbeatService(
  300. workspace=tmpdir,
  301. provider=FakeProvider(),
  302. interval_s=1800,
  303. enabled=False,
  304. )
  305. assert hb.interval_s == 1800
  306. assert not hb.enabled
  307. assert hb._file == tmpdir / "HEARTBEAT.md"
  308. print(" heartbeat: init OK")
  309. # With HEARTBEAT.md
  310. (tmpdir / "HEARTBEAT.md").write_text("- Check email\n- Review tasks")
  311. assert hb._file.exists()
  312. print(" heartbeat: HEARTBEAT.md detected OK")
  313. finally:
  314. shutil.rmtree(tmpdir)
  315. # ── 12. WebFetchTool (strip HTML) ──
  316. from toyclaw.tools.builtin import WebFetchTool
  317. def test_web_fetch_strip():
  318. wf = WebFetchTool()
  319. html = "<html><body><h1>Title</h1><p>Hello <b>world</b></p></body></html>"
  320. text = wf._strip(html)
  321. assert "Title" in text
  322. assert "Hello" in text
  323. assert "world" in text
  324. assert "<" not in text # all tags stripped
  325. print(" web_fetch: HTML strip OK")
  326. # ── Run all ──
  327. if __name__ == "__main__":
  328. tests = [
  329. test_config,
  330. test_provider_dataclasses,
  331. test_tool_registry,
  332. test_builtin_tools,
  333. test_tool_registry_with_tools,
  334. test_session,
  335. test_skills,
  336. test_context,
  337. test_cron,
  338. test_cron_tool,
  339. test_heartbeat,
  340. test_web_fetch_strip,
  341. ]
  342. passed = 0
  343. failed = 0
  344. for t in tests:
  345. name = t.__name__
  346. try:
  347. t()
  348. passed += 1
  349. except Exception as e:
  350. print(f" FAIL {name}: {e}")
  351. import traceback
  352. traceback.print_exc()
  353. failed += 1
  354. print(f"\n{'=' * 50}")
  355. print(f"Results: {passed} passed, {failed} failed out of {len(tests)}")
  356. if failed:
  357. sys.exit(1)
  358. else:
  359. print("All tests passed!")