Commit 9655531594bd4c5e2f40b1c4a8f4a3eee070a163

Authored by bboonnee
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>
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()
... ...