@@ -74,6 +74,262 @@ fn test_disabled_planning_never_runs_pre_analysis() {
7474 assert ! ( !agent. should_run_pre_analysis( ) ) ;
7575}
7676
77+ #[ derive( Debug ) ]
78+ struct PlanningHookRecorder {
79+ events : Arc < std:: sync:: Mutex < Vec < crate :: hooks:: HookEvent > > > ,
80+ result : crate :: hooks:: HookResult ,
81+ }
82+
83+ #[ async_trait:: async_trait]
84+ impl crate :: hooks:: HookExecutor for PlanningHookRecorder {
85+ async fn fire ( & self , event : & crate :: hooks:: HookEvent ) -> crate :: hooks:: HookResult {
86+ self . events . lock ( ) . unwrap ( ) . push ( event. clone ( ) ) ;
87+ self . result . clone ( )
88+ }
89+ }
90+
91+ fn planning_hook_recorder (
92+ result : crate :: hooks:: HookResult ,
93+ ) -> (
94+ Arc < PlanningHookRecorder > ,
95+ Arc < std:: sync:: Mutex < Vec < crate :: hooks:: HookEvent > > > ,
96+ ) {
97+ let events = Arc :: new ( std:: sync:: Mutex :: new ( Vec :: new ( ) ) ) ;
98+ (
99+ Arc :: new ( PlanningHookRecorder {
100+ events : Arc :: clone ( & events) ,
101+ result,
102+ } ) ,
103+ events,
104+ )
105+ }
106+
107+ // ========================================================================
108+ // Planning hooks
109+ // ========================================================================
110+
111+ #[ tokio:: test]
112+ async fn test_execute_with_planning_fires_pre_and_post_planning_hooks ( ) {
113+ let mock_client = Arc :: new ( MockLlmClient :: new ( vec ! [
114+ MockLlmClient :: text_response(
115+ r#"{
116+ "goal": "Build planning hooks",
117+ "complexity": "Simple",
118+ "steps": [
119+ {
120+ "id": "step-1",
121+ "description": "Wire planning hooks into the runtime",
122+ "dependencies": [],
123+ "success_criteria": "Pre and post hooks are emitted"
124+ }
125+ ],
126+ "required_tools": []
127+ }"# ,
128+ ) ,
129+ MockLlmClient :: text_response( "Planning hooks wired." ) ,
130+ ] ) ) ;
131+ let tool_executor = Arc :: new ( ToolExecutor :: new ( "/tmp" . to_string ( ) ) ) ;
132+ let ( hook, events) = planning_hook_recorder ( crate :: hooks:: HookResult :: Continue ( None ) ) ;
133+ let config = AgentConfig {
134+ hook_engine : Some ( hook) ,
135+ ..AgentConfig :: default ( )
136+ } ;
137+ let agent = AgentLoop :: new ( mock_client, tool_executor, test_tool_context ( ) , config) ;
138+
139+ let result = agent
140+ . execute_with_planning (
141+ & [ ] ,
142+ "Build planning hooks" ,
143+ Some ( "planning-hooks-session" ) ,
144+ None ,
145+ None ,
146+ )
147+ . await
148+ . unwrap ( ) ;
149+
150+ assert_eq ! ( result. text, "Planning hooks wired." ) ;
151+
152+ let planning_events = events
153+ . lock ( )
154+ . unwrap ( )
155+ . iter ( )
156+ . filter_map ( |event| match event {
157+ crate :: hooks:: HookEvent :: PrePlanning ( event) => Some ( (
158+ "pre" ,
159+ event. session_id . clone ( ) ,
160+ event. task_description . clone ( ) ,
161+ None ,
162+ ) ) ,
163+ crate :: hooks:: HookEvent :: PostPlanning ( event) => Some ( (
164+ "post" ,
165+ event. session_id . clone ( ) ,
166+ event. task_description . clone ( ) ,
167+ Some ( ( event. success , event. subtasks . clone ( ) ) ) ,
168+ ) ) ,
169+ _ => None ,
170+ } )
171+ . collect :: < Vec < _ > > ( ) ;
172+
173+ assert_eq ! ( planning_events. len( ) , 2 ) ;
174+ assert_eq ! ( planning_events[ 0 ] . 0 , "pre" ) ;
175+ assert_eq ! ( planning_events[ 0 ] . 1 , "planning-hooks-session" ) ;
176+ assert_eq ! ( planning_events[ 0 ] . 2 , "Build planning hooks" ) ;
177+ assert_eq ! ( planning_events[ 1 ] . 0 , "post" ) ;
178+ assert_eq ! ( planning_events[ 1 ] . 1 , "planning-hooks-session" ) ;
179+ assert_eq ! ( planning_events[ 1 ] . 2 , "Build planning hooks" ) ;
180+ assert_eq ! (
181+ planning_events[ 1 ] . 3 . as_ref( ) . unwrap( ) . 1 ,
182+ vec![ "Wire planning hooks into the runtime" . to_string( ) ]
183+ ) ;
184+ assert ! ( planning_events[ 1 ] . 3 . as_ref( ) . unwrap( ) . 0 ) ;
185+ }
186+
187+ #[ tokio:: test]
188+ async fn test_pre_planning_hook_can_block_planning_before_start_event ( ) {
189+ let mock_client = Arc :: new ( MockLlmClient :: new ( vec ! [ ] ) ) ;
190+ let tool_executor = Arc :: new ( ToolExecutor :: new ( "/tmp" . to_string ( ) ) ) ;
191+ let ( hook, events) =
192+ planning_hook_recorder ( crate :: hooks:: HookResult :: block ( "policy denied planning" ) ) ;
193+ let config = AgentConfig {
194+ hook_engine : Some ( hook) ,
195+ ..AgentConfig :: default ( )
196+ } ;
197+ let agent = AgentLoop :: new ( mock_client, tool_executor, test_tool_context ( ) , config) ;
198+
199+ let ( tx, mut rx) = mpsc:: channel ( 100 ) ;
200+ let error = agent
201+ . execute_with_planning (
202+ & [ ] ,
203+ "Plan a blocked change" ,
204+ Some ( "blocked-planning-session" ) ,
205+ Some ( tx) ,
206+ None ,
207+ )
208+ . await
209+ . unwrap_err ( ) ;
210+
211+ assert ! ( error
212+ . to_string( )
213+ . contains( "Planning blocked by hook: policy denied planning" ) ) ;
214+
215+ {
216+ let recorded = events. lock ( ) . unwrap ( ) ;
217+ assert_eq ! ( recorded. len( ) , 1 ) ;
218+ assert ! ( matches!(
219+ recorded. first( ) . unwrap( ) ,
220+ crate :: hooks:: HookEvent :: PrePlanning ( event)
221+ if event. session_id == "blocked-planning-session"
222+ && event. task_description == "Plan a blocked change"
223+ ) ) ;
224+ }
225+
226+ rx. close ( ) ;
227+ while let Some ( event) = rx. recv ( ) . await {
228+ assert ! (
229+ !matches!( event, AgentEvent :: PlanningStart { .. } ) ,
230+ "PlanningStart must not be emitted after a blocking PrePlanning hook"
231+ ) ;
232+ }
233+ }
234+
235+ #[ tokio:: test]
236+ async fn test_pre_planning_hook_modification_updates_planner_input ( ) {
237+ let mock_client = Arc :: new ( MockLlmClient :: new ( vec ! [
238+ MockLlmClient :: text_response(
239+ r#"{
240+ "goal": "Implement the policy-refined planning task",
241+ "complexity": "Simple",
242+ "steps": [
243+ {
244+ "id": "step-1",
245+ "description": "Follow the hook-modified task",
246+ "dependencies": [],
247+ "success_criteria": "The modified planning task is used"
248+ }
249+ ],
250+ "required_tools": []
251+ }"# ,
252+ ) ,
253+ MockLlmClient :: text_response( "Modified planning complete." ) ,
254+ ] ) ) ;
255+ let tool_executor = Arc :: new ( ToolExecutor :: new ( "/tmp" . to_string ( ) ) ) ;
256+ let ( hook, events) =
257+ planning_hook_recorder ( crate :: hooks:: HookResult :: continue_with ( serde_json:: json!( {
258+ "modified_task" : "Use the hook-modified planning task" ,
259+ "selected_strategy" : "step_by_step" ,
260+ "planning_template" : "Break the work into observable, testable steps." ,
261+ "hints" : [ "Preserve the original user request" , "Keep changes scoped" ]
262+ } ) ) ) ;
263+ let config = AgentConfig {
264+ hook_engine : Some ( hook) ,
265+ ..AgentConfig :: default ( )
266+ } ;
267+ let agent = AgentLoop :: new (
268+ mock_client. clone ( ) ,
269+ tool_executor,
270+ test_tool_context ( ) ,
271+ config,
272+ ) ;
273+
274+ let ( tx, mut rx) = mpsc:: channel ( 100 ) ;
275+ let result = agent
276+ . execute_with_planning (
277+ & [ ] ,
278+ "Original planning request" ,
279+ Some ( "modified-planning-session" ) ,
280+ Some ( tx) ,
281+ None ,
282+ )
283+ . await
284+ . unwrap ( ) ;
285+
286+ assert_eq ! ( result. text, "Modified planning complete." ) ;
287+
288+ let request_texts = mock_client. request_texts . lock ( ) . unwrap ( ) . clone ( ) ;
289+ assert ! (
290+ request_texts[ 0 ] . contains( "Original user request:\n Original planning request" ) ,
291+ "planner input should preserve original request: {}" ,
292+ request_texts[ 0 ]
293+ ) ;
294+ assert ! (
295+ request_texts[ 0 ]
296+ . contains( "Hook-modified planning task:\n Use the hook-modified planning task" ) ,
297+ "planner input should include hook-modified task: {}" ,
298+ request_texts[ 0 ]
299+ ) ;
300+ assert ! (
301+ request_texts[ 0 ] . contains( "Break the work into observable, testable steps." ) ,
302+ "planner input should include hook planning template: {}" ,
303+ request_texts[ 0 ]
304+ ) ;
305+ assert ! (
306+ request_texts[ 0 ] . contains( "- Preserve the original user request" ) ,
307+ "planner input should include hook hints: {}" ,
308+ request_texts[ 0 ]
309+ ) ;
310+
311+ let mut planning_start_prompt = None ;
312+ while let Ok ( event) = rx. try_recv ( ) {
313+ if let AgentEvent :: PlanningStart { prompt } = event {
314+ planning_start_prompt = Some ( prompt) ;
315+ break ;
316+ }
317+ }
318+ let planning_start_prompt = planning_start_prompt. expect ( "PlanningStart should be emitted" ) ;
319+ assert ! ( planning_start_prompt. contains( "Hook-modified planning task" ) ) ;
320+
321+ let post_planning_task = events
322+ . lock ( )
323+ . unwrap ( )
324+ . iter ( )
325+ . find_map ( |event| match event {
326+ crate :: hooks:: HookEvent :: PostPlanning ( event) => Some ( event. task_description . clone ( ) ) ,
327+ _ => None ,
328+ } )
329+ . expect ( "PostPlanning should be emitted" ) ;
330+ assert ! ( post_planning_task. contains( "Hook-modified planning task" ) ) ;
331+ }
332+
77333// ========================================================================
78334// AgentEvent serialization
79335// ========================================================================
0 commit comments