test_run_workspace.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273
  1. """
  2. Tests for PaaS Run Workspace — persistent run directories.
  3. Tests cover:
  4. 1. create_run_workspace() creates correct directory structure
  5. 2. save_run_artifacts() writes output/trace/cost files
  6. 3. Multiple runs preserve all history (no deletion)
  7. 4. Context carries workspace_path through fork/extend
  8. 5. Workspace not created when agent_dir is empty
  9. """
  10. import json
  11. import os
  12. import shutil
  13. import tempfile
  14. import pytest
  15. from agentpaas.engine.sandbox import (
  16. create_run_workspace,
  17. save_run_artifacts,
  18. build_change_manifest,
  19. ExecutionResult,
  20. )
  21. from lambdagent.core import Context, TraceEntry
  22. @pytest.fixture
  23. def agent_dir():
  24. """Create a temporary agent directory for testing."""
  25. d = tempfile.mkdtemp(prefix="test_agent_")
  26. yield d
  27. shutil.rmtree(d, ignore_errors=True)
  28. # ============================================================
  29. # 1. create_run_workspace()
  30. # ============================================================
  31. class TestCreateRunWorkspace:
  32. def test_creates_directory_structure(self, agent_dir):
  33. """Creates workspace/run_YYYYMMDD_HHMMSS/ with subdirs"""
  34. ws = create_run_workspace(agent_dir, "run_abc123", "hello", {"type": "simple"})
  35. assert os.path.isdir(ws)
  36. assert os.path.isdir(os.path.join(ws, "code"))
  37. assert os.path.isdir(os.path.join(ws, "results"))
  38. assert os.path.isdir(os.path.join(ws, "final"))
  39. def test_saves_input_json(self, agent_dir):
  40. """input.json contains run_id and input text"""
  41. ws = create_run_workspace(agent_dir, "run_abc123", "test input", {})
  42. with open(os.path.join(ws, "input.json")) as f:
  43. data = json.load(f)
  44. assert data["run_id"] == "run_abc123"
  45. assert data["input"] == "test input"
  46. def test_saves_config_yml(self, agent_dir):
  47. """config.yml is a snapshot of the agent config"""
  48. config = {"type": "react", "systemPrompt": "You are helpful"}
  49. ws = create_run_workspace(agent_dir, "run_abc123", "input", config)
  50. assert os.path.isfile(os.path.join(ws, "config.yml"))
  51. def test_workspace_under_agent_dir(self, agent_dir):
  52. """Workspace is under {agent_dir}/workspace/"""
  53. ws = create_run_workspace(agent_dir, "run_abc123", "input", {})
  54. assert ws.startswith(os.path.join(agent_dir, "workspace"))
  55. def test_run_dir_has_timestamp(self, agent_dir):
  56. """Run directory name matches run_YYYYMMDD_HHMMSS pattern"""
  57. ws = create_run_workspace(agent_dir, "run_abc123", "input", {})
  58. dirname = os.path.basename(ws)
  59. assert dirname.startswith("run_20")
  60. # ============================================================
  61. # 2. save_run_artifacts()
  62. # ============================================================
  63. class TestSaveRunArtifacts:
  64. def test_saves_output_json(self, agent_dir):
  65. ws = create_run_workspace(agent_dir, "run_123", "input", {})
  66. save_run_artifacts(ws, "final result", [], 1000, status="completed")
  67. with open(os.path.join(ws, "output.json")) as f:
  68. data = json.load(f)
  69. assert data["output"] == "final result"
  70. assert data["status"] == "completed"
  71. def test_saves_trace_json(self, agent_dir):
  72. ws = create_run_workspace(agent_dir, "run_123", "input", {})
  73. trace = [TraceEntry("agent1", "id1", "in", "out", 100.0, "model", 50)]
  74. save_run_artifacts(ws, "result", trace, 500)
  75. with open(os.path.join(ws, "trace.json")) as f:
  76. data = json.load(f)
  77. assert len(data) == 1
  78. assert data[0]["term_name"] == "agent1"
  79. assert data[0]["tokens_used"] == 50
  80. def test_saves_cost_json(self, agent_dir):
  81. ws = create_run_workspace(agent_dir, "run_123", "input", {})
  82. save_run_artifacts(ws, "result", [], 1500, input_tokens=100, steps=5)
  83. with open(os.path.join(ws, "cost.json")) as f:
  84. data = json.load(f)
  85. assert data["duration_ms"] == 1500
  86. assert data["input_tokens"] == 100
  87. assert data["steps"] == 5
  88. def test_saves_on_failure(self, agent_dir):
  89. ws = create_run_workspace(agent_dir, "run_123", "input", {})
  90. save_run_artifacts(ws, "", [], 500, error="boom", status="failed")
  91. with open(os.path.join(ws, "output.json")) as f:
  92. data = json.load(f)
  93. assert data["status"] == "failed"
  94. assert data["error"] == "boom"
  95. def test_noop_without_workspace(self):
  96. """save_run_artifacts does nothing if workspace_path is empty"""
  97. save_run_artifacts("", "result", [], 100) # Should not raise
  98. save_run_artifacts("/nonexistent/path", "result", [], 100) # Should not raise
  99. # ============================================================
  100. # 3. Multiple Runs Preserve History
  101. # ============================================================
  102. class TestMultipleRuns:
  103. def test_multiple_runs_coexist(self, agent_dir):
  104. """Each run creates a separate directory, old ones are not deleted"""
  105. import time
  106. ws1 = create_run_workspace(agent_dir, "run_aaa", "input1", {"v": 1})
  107. time.sleep(1.1) # Ensure different timestamp
  108. ws2 = create_run_workspace(agent_dir, "run_bbb", "input2", {"v": 2})
  109. assert ws1 != ws2
  110. assert os.path.isdir(ws1) # First run still exists
  111. assert os.path.isdir(ws2) # Second run also exists
  112. # Both have their own input files
  113. with open(os.path.join(ws1, "input.json")) as f:
  114. assert json.load(f)["run_id"] == "run_aaa"
  115. with open(os.path.join(ws2, "input.json")) as f:
  116. assert json.load(f)["run_id"] == "run_bbb"
  117. def test_workspace_dir_lists_all_runs(self, agent_dir):
  118. """workspace/ directory contains all run directories"""
  119. import time
  120. create_run_workspace(agent_dir, "run_1", "i1", {})
  121. time.sleep(1.1)
  122. create_run_workspace(agent_dir, "run_2", "i2", {})
  123. ws_dir = os.path.join(agent_dir, "workspace")
  124. runs = os.listdir(ws_dir)
  125. assert len(runs) == 2
  126. assert all(r.startswith("run_") for r in runs)
  127. # ============================================================
  128. # 4. Context Carries workspace_path
  129. # ============================================================
  130. class TestContextWorkspace:
  131. def test_context_workspace_path(self):
  132. ctx = Context(workspace_path="/tmp/test_ws", run_id="run_123")
  133. assert ctx.workspace_path == "/tmp/test_ws"
  134. assert ctx.run_id == "run_123"
  135. def test_fork_preserves_workspace(self):
  136. ctx = Context(workspace_path="/tmp/ws", run_id="run_abc")
  137. forked = ctx.fork()
  138. assert forked.workspace_path == "/tmp/ws"
  139. assert forked.run_id == "run_abc"
  140. def test_extend_preserves_workspace(self):
  141. ctx = Context(workspace_path="/tmp/ws", run_id="run_abc")
  142. child = ctx.extend(x=42)
  143. assert child.workspace_path == "/tmp/ws"
  144. assert child.run_id == "run_abc"
  145. def test_default_workspace_is_none(self):
  146. ctx = Context()
  147. assert ctx.workspace_path is None
  148. assert ctx.run_id is None
  149. # ============================================================
  150. # 5. No Workspace When agent_dir Empty
  151. # ============================================================
  152. class TestNoWorkspaceWithoutAgentDir:
  153. def test_execution_result_default_empty(self):
  154. r = ExecutionResult()
  155. assert r.workspace_path == ""
  156. # ============================================================
  157. # 6. Change Manifest (修改清单)
  158. # ============================================================
  159. class TestChangeManifest:
  160. def test_save_run_artifacts_writes_manifest(self, agent_dir):
  161. """save_run_artifacts auto-generates manifest.json"""
  162. ws = create_run_workspace(agent_dir, "run_m1", "input", {})
  163. save_run_artifacts(ws, "result", [], 100)
  164. assert os.path.isfile(os.path.join(ws, "manifest.json"))
  165. def test_manifest_lists_agent_produced_files(self, agent_dir):
  166. """Files the agent wrote under the run dir appear in the manifest"""
  167. ws = create_run_workspace(agent_dir, "run_m2", "input", {})
  168. with open(os.path.join(ws, "code", "main.py"), "w") as f:
  169. f.write("print('hi')\n")
  170. with open(os.path.join(ws, "results", "data.csv"), "w") as f:
  171. f.write("a,b\n1,2\n")
  172. manifest = build_change_manifest(ws)
  173. paths = {e["path"] for e in manifest["files"]}
  174. assert "code/main.py" in paths
  175. assert "results/data.csv" in paths
  176. assert manifest["file_count"] == 2
  177. assert manifest["total_bytes"] > 0
  178. def test_manifest_excludes_framework_metadata(self, agent_dir):
  179. """input/config/output/trace/cost/manifest themselves are not counted"""
  180. ws = create_run_workspace(agent_dir, "run_m3", "input", {})
  181. save_run_artifacts(ws, "result", [], 100) # writes output/trace/cost + manifest
  182. with open(os.path.join(ws, "manifest.json")) as f:
  183. manifest = json.load(f)
  184. paths = {e["path"] for e in manifest["files"]}
  185. assert paths.isdisjoint({
  186. "input.json", "config.yml", "output.json",
  187. "trace.json", "cost.json", "manifest.json",
  188. })
  189. def test_manifest_records_sha256(self, agent_dir):
  190. """Each produced file carries a sha256 digest for the evidence chain"""
  191. ws = create_run_workspace(agent_dir, "run_m4", "input", {})
  192. with open(os.path.join(ws, "final", "report.txt"), "w") as f:
  193. f.write("hello world")
  194. manifest = build_change_manifest(ws)
  195. entry = next(e for e in manifest["files"] if e["path"] == "final/report.txt")
  196. # sha256("hello world")
  197. assert entry["sha256"] == (
  198. "b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9"
  199. )
  200. def test_manifest_carries_run_id(self, agent_dir):
  201. """run_id from input.json is propagated into the manifest"""
  202. ws = create_run_workspace(agent_dir, "run_m5", "input", {})
  203. manifest = build_change_manifest(ws)
  204. assert manifest["run_id"] == "run_m5"
  205. def test_manifest_git_field_present(self, agent_dir):
  206. """git section is always present (available False outside a repo)"""
  207. ws = create_run_workspace(agent_dir, "run_m6", "input", {})
  208. manifest = build_change_manifest(ws)
  209. assert "git" in manifest
  210. assert "available" in manifest["git"]
  211. def test_manifest_noop_on_invalid_path(self):
  212. """Invalid path returns {} and does not raise"""
  213. assert build_change_manifest("") == {}
  214. assert build_change_manifest("/nonexistent/xyz") == {}
  215. def test_empty_run_yields_empty_manifest(self, agent_dir):
  216. """A run that produced nothing still yields a valid (empty) manifest"""
  217. ws = create_run_workspace(agent_dir, "run_m7", "input", {})
  218. manifest = build_change_manifest(ws) # only framework metadata present
  219. assert manifest["file_count"] == 0
  220. assert manifest["files"] == []