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 | 5 | constraint) -- costs real API usage, skipped by default in normal |
| 4 | 6 | `python3 -m unittest` runs, matching graphloop's RUN_REAL_AGENT_E2E precedent. |
| 5 | 7 | |
| 6 | 8 | Run: RUN_REAL_AGENT_E2E=1 python3 -m unittest src.test_agent_e2e -v |
| 7 | 9 | """ |
| 8 | 10 | |
| 9 | -import json | |
| 10 | 11 | import subprocess |
| 11 | 12 | import tempfile |
| 12 | 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 | 16 | from src.test_support import skip_unless_env |
| 16 | 17 | |
| 17 | 18 | |
| ... | ... | @@ -25,24 +26,14 @@ class RealPlannerInvocationTest(unittest.TestCase): |
| 25 | 26 | project_scaffold.create_project(conn, tmp, init_git_if_missing=False) |
| 26 | 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 | 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 | 37 | self.assertTrue(answer, "planner returned an empty result") |
| 47 | 38 | # A-2's actual question: did the identity come through, not the default |
| 48 | 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() | ... | ... |