1- """Tests for hooks/PreToolUse/require_delegation.py (soft adaptive nudges )."""
1+ """Tests for hooks/PreToolUse/require_delegation.py (blocks work tools )."""
22
33import json
44import os
@@ -18,10 +18,8 @@ def _run(
1818 project_dir : Path ,
1919 env_extra : dict [str , str ] | None = None ,
2020) -> tuple [str , str , int ]:
21- """Run the hook in an isolated project dir, return (stdout, stderr, rc)."""
2221 env = os .environ .copy ()
2322 env ["CLAUDE_PROJECT_DIR" ] = str (project_dir )
24- # Strip subagent markers so we test main-agent behavior unless overridden
2523 env .pop ("CLAUDE_PARENT_SESSION_ID" , None )
2624 env .pop ("CLAUDE_AGENT_ID" , None )
2725 if env_extra :
@@ -42,193 +40,61 @@ def _input(tool_name: str) -> str:
4240 return json .dumps ({"tool_name" : tool_name , "tool_input" : {}})
4341
4442
45- # ---------------------------------------------------------------------------
46- # Always returns 0
47- # ---------------------------------------------------------------------------
48-
49-
50- class TestNeverBlocks :
51- def test_work_tool_returns_zero (self , tmp_path : Path ) -> None :
52- _ , _ , rc = _run (_input ("Bash" ), tmp_path )
53- assert rc == 0 # noqa: S101
54-
55- def test_unknown_tool_returns_zero (self , tmp_path : Path ) -> None :
56- _ , _ , rc = _run (_input ("FooBarBaz" ), tmp_path )
57- assert rc == 0 # noqa: S101
58-
59- def test_empty_stdin_returns_zero (self , tmp_path : Path ) -> None :
60- _ , _ , rc = _run ("" , tmp_path )
61- assert rc == 0 # noqa: S101
62-
63- def test_malformed_stdin_returns_zero (self , tmp_path : Path ) -> None :
64- _ , _ , rc = _run ("not json" , tmp_path )
65- assert rc == 0 # noqa: S101
66-
67-
68- # ---------------------------------------------------------------------------
69- # Stable violation set: only WORK_TOOLS trigger nudges
70- # ---------------------------------------------------------------------------
71-
72-
73- class TestViolationSet :
43+ class TestBlocksWorkTools :
7444 @pytest .mark .parametrize (
7545 "tool" ,
7646 ["Bash" , "Edit" , "Write" , "Glob" , "Grep" , "MultiEdit" , "NotebookEdit" ],
7747 )
78- def test_work_tools_count_as_violations (self , tmp_path : Path , tool : str ) -> None :
79- _ , stderr , _ = _run (_input (tool ), tmp_path )
48+ def test_work_tools_blocked (self , tmp_path : Path , tool : str ) -> None :
49+ _ , stderr , rc = _run (_input (tool ), tmp_path )
50+ assert rc == 2 # noqa: S101
8051 assert "delegate" in stderr .lower () # noqa: S101
8152
82- def test_read_is_silent (self , tmp_path : Path ) -> None :
83- """Read is exempt from delegation nudges (not in WORK_TOOLS)."""
53+ def test_read_allowed (self , tmp_path : Path ) -> None :
8454 _ , stderr , rc = _run (_input ("Read" ), tmp_path )
8555 assert rc == 0 # noqa: S101
8656 assert stderr == "" # noqa: S101
8757
8858 @pytest .mark .parametrize (
8959 "tool" ,
90- [
91- "AskUserQuestion" ,
92- "TaskCreate" ,
93- "TaskUpdate" ,
94- "Agent" ,
95- "Task" ,
96- "Skill" ,
97- "SlashCommand" ,
98- "EnterPlanMode" ,
99- "ExitPlanMode" ,
100- "ToolSearch" ,
101- "TeamCreate" ,
102- "SendMessage" ,
103- "FooBar" , # hypothetical new tool
104- "CronCreate" ,
105- ],
60+ ["Agent" , "Skill" , "SlashCommand" , "TaskCreate" , "TaskUpdate" ,
61+ "EnterPlanMode" , "ExitPlanMode" , "ToolSearch" , "TeamCreate" ,
62+ "SendMessage" , "AskUserQuestion" , "FooBar" , "CronCreate" ],
10663 )
107- def test_non_work_tools_silent (self , tmp_path : Path , tool : str ) -> None :
64+ def test_non_work_tools_allowed (self , tmp_path : Path , tool : str ) -> None :
10865 _ , stderr , rc = _run (_input (tool ), tmp_path )
10966 assert rc == 0 # noqa: S101
11067 assert stderr == "" # noqa: S101
11168
69+ def test_empty_stdin_allowed (self , tmp_path : Path ) -> None :
70+ _ , _ , rc = _run ("" , tmp_path )
71+ assert rc == 0 # noqa: S101
11272
113- # ---------------------------------------------------------------------------
114- # Escalation ladder
115- # ---------------------------------------------------------------------------
116-
117-
118- class TestEscalationLadder :
119- def test_first_violation_is_imperative (self , tmp_path : Path ) -> None :
120- _ , stderr , _ = _run (_input ("Bash" ), tmp_path )
121- # New imperative message must include STOP + the delegate command
122- assert "STOP" in stderr # noqa: S101
123- assert "/workflow-orchestrator:delegate" in stderr # noqa: S101
124-
125- def test_second_violation_is_medium (self , tmp_path : Path ) -> None :
126- _run (_input ("Bash" ), tmp_path )
127- _ , stderr , _ = _run (_input ("Edit" ), tmp_path )
128- assert "STOP" in stderr # noqa: S101
129- assert "/workflow-orchestrator:delegate" in stderr # noqa: S101
130- # Distinct 2nd-call phrasing
131- assert "2nd direct tool call" in stderr # noqa: S101
132-
133- def test_third_violation_is_warning (self , tmp_path : Path ) -> None :
134- for tool in ("Bash" , "Edit" , "Write" ):
135- _run (_input (tool ), tmp_path )
136- _ , stderr , _ = _run (_input ("Glob" ), tmp_path )
137- # 4th call -> "STOP. 4 direct tool calls bypassing delegation..."
138- assert "STOP" in stderr # noqa: S101
139- assert "4" in stderr # noqa: S101
140- assert "/workflow-orchestrator:delegate" in stderr # noqa: S101
141-
142- def test_fifth_plus_is_strong (self , tmp_path : Path ) -> None :
143- for _ in range (5 ):
144- _run (_input ("Bash" ), tmp_path )
145- _ , stderr , _ = _run (_input ("Bash" ), tmp_path ) # 6th call
146- assert "STOP" in stderr # noqa: S101
147- # The ≥3 message explains what's being lost
148- assert "losing planning, parallelization, and context isolation" in stderr # noqa: S101
149- # Long message — over ~100 chars
150- assert len (stderr .strip ()) > 100 # noqa: S101
151-
152- def test_message_length_grows (self , tmp_path : Path ) -> None :
153- """Verify escalation adds context (proxied by message length/content)."""
154- lengths = []
155- messages = []
156- for _ in range (6 ):
157- _ , stderr , _ = _run (_input ("Bash" ), tmp_path )
158- lengths .append (len (stderr .strip ()))
159- messages .append (stderr .strip ())
160- # Each level should be at least as long as the previous (monotonic)
161- for i in range (1 , len (lengths )):
162- assert lengths [i ] >= lengths [i - 1 ] # noqa: S101
163- # The final message should be strictly longer than the first
164- assert lengths [- 1 ] > lengths [0 ] # noqa: S101
165- # And must include the "what's being lost" context that proves escalation
166- assert "losing planning, parallelization, and context isolation" in messages [- 1 ] # noqa: S101
167-
168-
169- # ---------------------------------------------------------------------------
170- # Counter persistence
171- # ---------------------------------------------------------------------------
172-
173-
174- class TestCounterPersistence :
175- def test_counter_persists_across_calls (self , tmp_path : Path ) -> None :
176- for _ in range (3 ):
177- _run (_input ("Bash" ), tmp_path )
178- counter = json .loads (
179- (tmp_path / ".claude" / "state" / "delegation_violations.json" ).read_text ()
180- )
181- assert counter ["violations" ] == 3 # noqa: S101
182-
183- def test_fresh_project_starts_at_zero (self , tmp_path : Path ) -> None :
184- _run (_input ("Bash" ), tmp_path )
185- counter = json .loads (
186- (tmp_path / ".claude" / "state" / "delegation_violations.json" ).read_text ()
187- )
188- assert counter ["violations" ] == 1 # noqa: S101
189-
190-
191- # ---------------------------------------------------------------------------
192- # Subagent immunity
193- # ---------------------------------------------------------------------------
73+ def test_malformed_stdin_allowed (self , tmp_path : Path ) -> None :
74+ _ , _ , rc = _run ("not json" , tmp_path )
75+ assert rc == 0 # noqa: S101
19476
19577
19678class TestSubagentImmunity :
197- def test_parent_session_id_skips (self , tmp_path : Path ) -> None :
79+ def test_parent_session_id_allows (self , tmp_path : Path ) -> None :
19880 _ , stderr , rc = _run (
199- _input ("Bash" ),
200- tmp_path ,
81+ _input ("Bash" ), tmp_path ,
20182 env_extra = {"CLAUDE_PARENT_SESSION_ID" : "abc123" },
20283 )
20384 assert rc == 0 # noqa: S101
20485 assert stderr == "" # noqa: S101
20586
206- def test_agent_id_skips (self , tmp_path : Path ) -> None :
87+ def test_agent_id_allows (self , tmp_path : Path ) -> None :
20788 _ , stderr , rc = _run (
208- _input ("Bash" ),
209- tmp_path ,
89+ _input ("Bash" ), tmp_path ,
21090 env_extra = {"CLAUDE_AGENT_ID" : "xyz" },
21191 )
21292 assert rc == 0 # noqa: S101
21393 assert stderr == "" # noqa: S101
21494
215- def test_subagent_does_not_increment_counter (self , tmp_path : Path ) -> None :
216- _run (
217- _input ("Bash" ),
218- tmp_path ,
219- env_extra = {"CLAUDE_PARENT_SESSION_ID" : "x" },
220- )
221- counter_file = tmp_path / ".claude" / "state" / "delegation_violations.json"
222- assert not counter_file .exists () # noqa: S101
223-
224-
225- # ---------------------------------------------------------------------------
226- # delegation_active flag suppresses nudges
227- # ---------------------------------------------------------------------------
228-
22995
23096class TestDelegationActiveFlag :
231- def test_active_flag_suppresses_nudge (self , tmp_path : Path ) -> None :
97+ def test_active_flag_allows (self , tmp_path : Path ) -> None :
23298 state = tmp_path / ".claude" / "state"
23399 state .mkdir (parents = True )
234100 (state / "delegation_active" ).touch ()
0 commit comments