Commit 9655531594bd4c5e2f40b1c4a8f4a3eee070a163
1 parent
2a71bd56
U-005: c_org_node_trigger subprocess wrapper (skeleton)
trigger_org_node(org_node, prompt, target_folder_path): shells out
`claude --agent <org_node> -p ... --permission-mode auto` with cwd=target
project, parses the JSON result, raises OrgNodeTriggerError on nonzero exit
or is_error. Skeleton only, per development-plan.md ("不含完整状态机") --
TASK_NOT_READY/CONCURRENCY_LIMIT_REACHED/USAGE_LIMIT_REACHED handling is
U-010's job.
Also refactors test_agent_e2e.py (U-004) to call this real function instead
of its own ad-hoc subprocess invocation, so the gated real-agent test now
exercises U-005's actual production code path -- confirmed still passing
against a real `claude --agent planner` call.
5 new mocked stdlib unittest cases (CLAUDE.md Testing constraint: mock the
subprocess boundary in normal tests). 46 total across U-001-005, all
passing; the 1 gated real-agent test re-verified manually.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Showing
3 changed files
with
130 additions
and
19 deletions
src/org_node_trigger.py
0 → 100644
| 1 | +"""c_org_node_trigger (03-C §3.3): subprocess wrapper skeleton. | ||
| 2 | + | ||
| 3 | +U-005 scope: shell out `claude --agent <org_node> -p ...` and return the text | ||
| 4 | +response -- "骨架,不含完整状态机" per development-plan.md. No task-lifecycle | ||
| 5 | +state machine yet: TASK_NOT_READY/CONCURRENCY_LIMIT_REACHED/USAGE_LIMIT_REACHED | ||
| 6 | +handling is U-010's job, not this one. | ||
| 7 | + | ||
| 8 | +Identity comes entirely from `.claude/agents/<org_node>.md` in the target | ||
| 9 | +project (see src/agent_profile.py) -- this module only assembles the prompt | ||
| 10 | +and parses the response, no role-isolation logic of its own (03-C §3.3). | ||
| 11 | +""" | ||
| 12 | + | ||
| 13 | +import json | ||
| 14 | +import subprocess | ||
| 15 | + | ||
| 16 | +from src.agent_profile import ORG_NODES | ||
| 17 | + | ||
| 18 | + | ||
| 19 | +class OrgNodeTriggerError(Exception): | ||
| 20 | + """Raised when the claude subprocess exits non-zero or reports is_error.""" | ||
| 21 | + | ||
| 22 | + | ||
| 23 | +def trigger_org_node(org_node, prompt, target_folder_path, timeout=300): | ||
| 24 | + """Runs `claude --agent <org_node> -p <prompt>` with cwd=target_folder_path. | ||
| 25 | + | ||
| 26 | + `.claude/agents/<org_node>.md` must already be deployed in target_folder_path | ||
| 27 | + (agent_profile.deploy_agent_definitions) -- this function does not deploy it. | ||
| 28 | + Returns the response text (the JSON `result` field). | ||
| 29 | + """ | ||
| 30 | + if org_node not in ORG_NODES: | ||
| 31 | + raise ValueError(org_node) | ||
| 32 | + | ||
| 33 | + result = subprocess.run( | ||
| 34 | + [ | ||
| 35 | + "claude", "--agent", org_node, | ||
| 36 | + "-p", prompt, | ||
| 37 | + "--output-format", "json", | ||
| 38 | + "--permission-mode", "auto", | ||
| 39 | + ], | ||
| 40 | + cwd=target_folder_path, | ||
| 41 | + stdin=subprocess.DEVNULL, | ||
| 42 | + capture_output=True, | ||
| 43 | + text=True, | ||
| 44 | + timeout=timeout, | ||
| 45 | + ) | ||
| 46 | + if result.returncode != 0: | ||
| 47 | + raise OrgNodeTriggerError( | ||
| 48 | + "claude --agent {} exited {}: {}".format(org_node, result.returncode, result.stderr) | ||
| 49 | + ) | ||
| 50 | + | ||
| 51 | + payload = json.loads(result.stdout) | ||
| 52 | + if payload.get("is_error"): | ||
| 53 | + raise OrgNodeTriggerError("claude --agent {} reported is_error: {}".format(org_node, payload)) | ||
| 54 | + return payload["result"] |
src/test_agent_e2e.py
| 1 | -"""U-004's actual A-2 validation: a REAL `claude --agent planner -p` call against | ||
| 2 | -a deployed target project. Gated behind RUN_REAL_AGENT_E2E=1 (CLAUDE.md Testing | 1 | +"""A-2's actual validation (U-004) + U-005's real-call coverage: a REAL |
| 2 | +`trigger_org_node("planner", ...)` call against a deployed target project, | ||
| 3 | +using the same production code path U-005 built rather than a one-off | ||
| 4 | +subprocess call. Gated behind RUN_REAL_AGENT_E2E=1 (CLAUDE.md Testing | ||
| 3 | constraint) -- costs real API usage, skipped by default in normal | 5 | constraint) -- costs real API usage, skipped by default in normal |
| 4 | `python3 -m unittest` runs, matching graphloop's RUN_REAL_AGENT_E2E precedent. | 6 | `python3 -m unittest` runs, matching graphloop's RUN_REAL_AGENT_E2E precedent. |
| 5 | 7 | ||
| 6 | Run: RUN_REAL_AGENT_E2E=1 python3 -m unittest src.test_agent_e2e -v | 8 | Run: RUN_REAL_AGENT_E2E=1 python3 -m unittest src.test_agent_e2e -v |
| 7 | """ | 9 | """ |
| 8 | 10 | ||
| 9 | -import json | ||
| 10 | import subprocess | 11 | import subprocess |
| 11 | import tempfile | 12 | import tempfile |
| 12 | import unittest | 13 | import unittest |
| 13 | 14 | ||
| 14 | -from src import agent_profile, project_scaffold, schema | 15 | +from src import agent_profile, org_node_trigger, project_scaffold, schema |
| 15 | from src.test_support import skip_unless_env | 16 | from src.test_support import skip_unless_env |
| 16 | 17 | ||
| 17 | 18 | ||
| @@ -25,24 +26,14 @@ class RealPlannerInvocationTest(unittest.TestCase): | @@ -25,24 +26,14 @@ class RealPlannerInvocationTest(unittest.TestCase): | ||
| 25 | project_scaffold.create_project(conn, tmp, init_git_if_missing=False) | 26 | project_scaffold.create_project(conn, tmp, init_git_if_missing=False) |
| 26 | agent_profile.deploy_agent_definitions(tmp) | 27 | agent_profile.deploy_agent_definitions(tmp) |
| 27 | 28 | ||
| 28 | - result = subprocess.run( | ||
| 29 | - [ | ||
| 30 | - "claude", "--agent", "planner", | ||
| 31 | - "-p", "In one short sentence: what is your job, and what do you never do? Answer only, take no other action.", | ||
| 32 | - "--output-format", "json", | ||
| 33 | - "--permission-mode", "auto", | ||
| 34 | - ], | ||
| 35 | - cwd=tmp, | ||
| 36 | - stdin=subprocess.DEVNULL, | ||
| 37 | - capture_output=True, | ||
| 38 | - text=True, | 29 | + answer = org_node_trigger.trigger_org_node( |
| 30 | + "planner", | ||
| 31 | + "In one short sentence: what is your job, and what do you never do? " | ||
| 32 | + "Answer only, take no other action.", | ||
| 33 | + tmp, | ||
| 39 | timeout=120, | 34 | timeout=120, |
| 40 | ) | 35 | ) |
| 41 | 36 | ||
| 42 | - self.assertEqual(result.returncode, 0, msg=result.stderr) | ||
| 43 | - payload = json.loads(result.stdout) | ||
| 44 | - self.assertFalse(payload.get("is_error"), msg=payload) | ||
| 45 | - answer = payload["result"] | ||
| 46 | self.assertTrue(answer, "planner returned an empty result") | 37 | self.assertTrue(answer, "planner returned an empty result") |
| 47 | # A-2's actual question: did the identity come through, not the default | 38 | # A-2's actual question: did the identity come through, not the default |
| 48 | # assistant persona? "不写业务代码" / "不实现" is the Planner's own red line. | 39 | # assistant persona? "不写业务代码" / "不实现" is the Planner's own red line. |
src/test_org_node_trigger.py
0 → 100644
| 1 | +"""U-005 tests: c_org_node_trigger's subprocess wrapper, mocked at the | ||
| 2 | +subprocess boundary (CLAUDE.md Testing constraint). Real end-to-end coverage | ||
| 3 | +is in src/test_agent_e2e.py (RUN_REAL_AGENT_E2E=1). | ||
| 4 | + | ||
| 5 | +Run: python3 -m unittest src.test_org_node_trigger -v | ||
| 6 | +""" | ||
| 7 | + | ||
| 8 | +import json | ||
| 9 | +import subprocess | ||
| 10 | +import unittest | ||
| 11 | +from unittest.mock import patch | ||
| 12 | + | ||
| 13 | +from src import org_node_trigger | ||
| 14 | + | ||
| 15 | + | ||
| 16 | +def _completed_process(returncode=0, stdout_obj=None, stderr=""): | ||
| 17 | + stdout = json.dumps(stdout_obj) if stdout_obj is not None else "" | ||
| 18 | + return subprocess.CompletedProcess(args=["claude"], returncode=returncode, stdout=stdout, stderr=stderr) | ||
| 19 | + | ||
| 20 | + | ||
| 21 | +class TriggerOrgNodeTest(unittest.TestCase): | ||
| 22 | + def test_rejects_unknown_org_node_without_shelling_out(self): | ||
| 23 | + with patch("src.org_node_trigger.subprocess.run") as run: | ||
| 24 | + with self.assertRaises(ValueError): | ||
| 25 | + org_node_trigger.trigger_org_node("not-a-real-node", "hi", "/tmp/some-project") | ||
| 26 | + run.assert_not_called() | ||
| 27 | + | ||
| 28 | + def test_happy_path_returns_result_text(self): | ||
| 29 | + with patch("src.org_node_trigger.subprocess.run") as run: | ||
| 30 | + run.return_value = _completed_process(stdout_obj={"is_error": False, "result": "hello from planner"}) | ||
| 31 | + answer = org_node_trigger.trigger_org_node("planner", "say hi", "/tmp/some-project") | ||
| 32 | + self.assertEqual(answer, "hello from planner") | ||
| 33 | + | ||
| 34 | + def test_invokes_claude_with_expected_args_and_cwd(self): | ||
| 35 | + with patch("src.org_node_trigger.subprocess.run") as run: | ||
| 36 | + run.return_value = _completed_process(stdout_obj={"is_error": False, "result": "ok"}) | ||
| 37 | + org_node_trigger.trigger_org_node("coder", "do the thing", "/tmp/some-project") | ||
| 38 | + | ||
| 39 | + args, kwargs = run.call_args | ||
| 40 | + argv = args[0] | ||
| 41 | + self.assertEqual(argv[:3], ["claude", "--agent", "coder"]) | ||
| 42 | + self.assertIn("-p", argv) | ||
| 43 | + self.assertIn("do the thing", argv) | ||
| 44 | + self.assertIn("--permission-mode", argv) | ||
| 45 | + self.assertIn("auto", argv) | ||
| 46 | + self.assertNotIn("--bare", argv) | ||
| 47 | + self.assertNotIn("--dangerously-skip-permissions", argv) | ||
| 48 | + self.assertNotIn("bypassPermissions", argv) | ||
| 49 | + self.assertEqual(kwargs["cwd"], "/tmp/some-project") | ||
| 50 | + self.assertEqual(kwargs["stdin"], subprocess.DEVNULL) | ||
| 51 | + | ||
| 52 | + def test_nonzero_exit_raises(self): | ||
| 53 | + with patch("src.org_node_trigger.subprocess.run") as run: | ||
| 54 | + run.return_value = _completed_process(returncode=1, stdout_obj=None, stderr="boom") | ||
| 55 | + with self.assertRaises(org_node_trigger.OrgNodeTriggerError): | ||
| 56 | + org_node_trigger.trigger_org_node("planner", "hi", "/tmp/some-project") | ||
| 57 | + | ||
| 58 | + def test_is_error_true_raises(self): | ||
| 59 | + with patch("src.org_node_trigger.subprocess.run") as run: | ||
| 60 | + run.return_value = _completed_process(stdout_obj={"is_error": True, "result": ""}) | ||
| 61 | + with self.assertRaises(org_node_trigger.OrgNodeTriggerError): | ||
| 62 | + org_node_trigger.trigger_org_node("validator", "hi", "/tmp/some-project") | ||
| 63 | + | ||
| 64 | + | ||
| 65 | +if __name__ == "__main__": | ||
| 66 | + unittest.main() |