返回 CodeWhale
test_cases_08.rs
根目录 / crates / tui / src / core / engine / tests / test_cases_08.rs
1 #[tokio::test]
2 async fn max_steps_exhaustion_fails_as_budget_never_completed() {
3 use crate::llm_client::mock::{MockLlmClient, canned};
4
5 let workspace = tempdir().expect("tempdir");
6 fs::write(workspace.path().join("state.txt"), "still-working\n").expect("write fixture");
7 // The model keeps tool-calling past the 1-step budget; it never gets to
8 // produce a final answer.
9 let turns = vec![
10 canned::tool_call_turn(
11 "call-step-1",
12 "File",
13 r#"{"action":"read","path":"state.txt"}"#,
14 ),
15 canned::tool_call_turn(
16 "call-step-2",
17 "File",
18 r#"{"action":"read","path":"state.txt"}"#,
19 ),
20 ];
21 let mock = std::sync::Arc::new(MockLlmClient::new(turns));
22 let client: crate::core::model_client::SharedModelClient = mock.clone();
23 let engine_config = EngineConfig {
24 max_steps: 1,
25 ..deterministic_engine_config(workspace.path())
26 };
27 let (engine, handle) = Engine::new_with_model_client(engine_config, &Config::default(), client);
28 let task = tokio::spawn(engine.run());
29 handle
30 .send(external_user_message_op(
31 "Keep reading until done.",
32 AppMode::Agent,
33 &Config::default(),
34 ))
35 .await
36 .expect("send step-budget trajectory");
37
38 let mut rx = handle.rx_event.write().await;
39 let (status, error) = loop {
40 let event = tokio::time::timeout(model_turn_event_timeout(), rx.recv())
41 .await
42 .expect("timed out waiting for step-budget trajectory")
43 .expect("engine event");
44 if let Event::TurnComplete { status, error, .. } = event {
45 break (status, error);
46 }
47 };
48 drop(rx);
49
50 assert_eq!(
51 status,
52 TurnOutcomeStatus::Failed,
53 "step-budget exhaustion must never report Completed"
54 );
55 let error = error.expect("step-budget exhaustion must carry a terminal error");
56 assert!(error.contains("Maximum model steps"), "{error}");
57
58 // The terminal error reduces to BudgetExhausted for machine consumers —
59 // this is the same reduction the headless exec receipt applies, and it is
60 // what gates persistent-service release on Completed-only turns.
61 let category = crate::error_taxonomy::classify_error_message(&error);
62 assert_eq!(category, ErrorCategory::Budget);
63 assert_eq!(
64 crate::core::termination::classify_turn_termination(status, Some(category), false, false),
65 crate::core::termination::RunTerminationReason::BudgetExhausted
66 );
67
68 handle.send(Op::Shutdown).await.expect("shutdown engine");
69 task.await.expect("engine task");
70 }
71
72 #[tokio::test]
73 async fn goal_turn_uses_goal_step_allowance_and_pauses_budget_limit_after_final_report() {
74 use crate::llm_client::mock::{MockLlmClient, canned};
75
76 let config = goal_custom_route_config();
77 // The model makes one tool step (consuming the 1-step goal allowance),
78 // then writes its bounded final report when granted it.
79 let turns = vec![
80 canned::tool_call_turn(
81 "call-step-1",
82 "File",
83 r#"{"action":"read","path":"state.txt"}"#,
84 ),
85 canned::simple_text_turn("final report: one step of progress made"),
86 ];
87 let mock = std::sync::Arc::new(MockLlmClient::new(turns));
88 let client: crate::core::model_client::SharedModelClient = mock.clone();
89 let workspace = tempdir().expect("tempdir");
90 fs::write(workspace.path().join("state.txt"), "still-working\n").expect("write fixture");
91 let (engine, handle) = Engine::new_with_model_client(
92 EngineConfig {
93 model: "local-model".to_string(),
94 workspace: workspace.path().to_path_buf(),
95 max_steps: 200,
96 goal_max_steps: Some(1),
97 goal_objective: Some("finish the migration".to_string()),
98 snapshots_enabled: false,
99 terminal_chrome_enabled: false,
100 ..EngineConfig::default()
101 },
102 &config,
103 client,
104 );
105 let goal_state = engine.config.goal_state.clone();
106 let task = tokio::spawn(engine.run());
107 handle
108 .send(active_goal_message_op(
109 &config,
110 "Work on the goal.",
111 "finish the migration",
112 None,
113 ))
114 .await
115 .expect("send goal-budget trajectory");
116
117 let mut rx = handle.rx_event.write().await;
118 let (status, _) = loop {
119 let event = tokio::time::timeout(model_turn_event_timeout(), rx.recv())
120 .await
121 .expect("timed out waiting for goal-budget trajectory")
122 .expect("engine event");
123 if let Event::TurnComplete { status, error, .. } = event {
124 break (status, error);
125 }
126 };
127 drop(rx);
128
129 // The final report closes the turn cleanly; the unfinished goal then
130 // pauses BudgetLimit instead of re-arming another full goal turn (#5994).
131 assert_eq!(status, TurnOutcomeStatus::Completed);
132 let snapshot = goal_state.lock().expect("goal lock").snapshot();
133 assert_eq!(snapshot.status, "paused");
134 assert_eq!(
135 snapshot.pause_reason,
136 Some(crate::tools::goal::GoalPauseReason::BudgetLimit)
137 );
138 // The mock served exactly the tool step plus the final report; any
139 // re-armed continuation would have needed a third provider turn.
140 assert_eq!(mock.call_count(), 2);
141
142 handle.send(Op::Shutdown).await.expect("shutdown engine");
143 task.await.expect("engine task");
144 }
145
146 #[tokio::test]
147 async fn interactive_turn_keeps_ordinary_ceiling_when_goal_allowance_is_configured() {
148 use crate::llm_client::mock::{MockLlmClient, canned};
149
150 let workspace = tempdir().expect("tempdir");
151 fs::write(workspace.path().join("state.txt"), "still-working\n").expect("write fixture");
152 // No active goal: the [goal] allowance must never raise the ordinary
153 // interactive ceiling.
154 let turns = vec![
155 canned::tool_call_turn(
156 "call-step-1",
157 "File",
158 r#"{"action":"read","path":"state.txt"}"#,
159 ),
160 canned::tool_call_turn(
161 "call-step-2",
162 "File",
163 r#"{"action":"read","path":"state.txt"}"#,
164 ),
165 ];
166 let mock = std::sync::Arc::new(MockLlmClient::new(turns));
167 let client: crate::core::model_client::SharedModelClient = mock.clone();
168 let engine_config = EngineConfig {
169 max_steps: 1,
170 goal_max_steps: Some(1_000),
171 ..deterministic_engine_config(workspace.path())
172 };
173 let (engine, handle) = Engine::new_with_model_client(engine_config, &Config::default(), client);
174 let task = tokio::spawn(engine.run());
175 handle
176 .send(external_user_message_op(
177 "Keep reading until done.",
178 AppMode::Agent,
179 &Config::default(),
180 ))
181 .await
182 .expect("send interactive trajectory");
183
184 let mut rx = handle.rx_event.write().await;
185 let (status, error) = loop {
186 let event = tokio::time::timeout(model_turn_event_timeout(), rx.recv())
187 .await
188 .expect("timed out waiting for interactive trajectory")
189 .expect("engine event");
190 if let Event::TurnComplete { status, error, .. } = event {
191 break (status, error);
192 }
193 };
194 drop(rx);
195
196 assert_eq!(status, TurnOutcomeStatus::Failed);
197 let error = error.expect("budget exhaustion carries a terminal error");
198 assert!(error.contains("limit: 1"), "{error}");
199 assert!(error.contains("max_steps"), "{error}");
200
201 handle.send(Op::Shutdown).await.expect("shutdown engine");
202 task.await.expect("engine task");
203 }
204
205 #[test]
206 fn synthetic_resume_paths_have_no_hidden_default_ceiling() {
207 let turn_loop = include_str!("../turn_loop.rs");
208
209 for legacy_marker in ["no_user_input_continues", "no-user-input resume backstop"] {
210 assert!(
211 !turn_loop.contains(legacy_marker),
212 "turn_loop.rs reintroduced the hidden synthetic-resume ceiling marker {legacy_marker:?}"
213 );
214 }
215 }
216
217 #[tokio::test]
218 async fn injected_model_duplicate_reads_both_execute_and_close_both_tool_ids() {
219 use crate::llm_client::mock::{MockLlmClient, canned};
220
221 let workspace = tempdir().expect("tempdir");
222 fs::write(workspace.path().join("README.md"), "duplicate-read-proof\n").expect("write fixture");
223 let duplicate_read_turn = vec![
224 canned::message_start("mock_msg_duplicate_read"),
225 canned::tool_use_block_start(0, "call-read-1", "File"),
226 canned::tool_input_delta(0, r#"{"action":"read","path":"README.md"}"#),
227 canned::block_stop(0),
228 canned::tool_use_block_start(1, "call-read-2", "File"),
229 canned::tool_input_delta(1, r#"{"action":"read","path":"README.md"}"#),
230 canned::block_stop(1),
231 canned::message_delta("tool_use", None),
232 canned::message_stop(),
233 ];
234 let mock = std::sync::Arc::new(MockLlmClient::new(vec![
235 duplicate_read_turn,
236 canned::simple_text_turn("Duplicate read complete."),
237 ]));
238 let client: crate::core::model_client::SharedModelClient = mock.clone();
239 let (engine, handle) = Engine::new_with_model_client(
240 deterministic_engine_config(workspace.path()),
241 &Config::default(),
242 client,
243 );
244 let task = tokio::spawn(engine.run());
245 handle
246 .send(external_user_message_op(
247 "Issue the duplicate read batch.",
248 AppMode::Agent,
249 &Config::default(),
250 ))
251 .await
252 .expect("send duplicate-read trajectory");
253
254 let mut results = HashMap::new();
255 let mut rx = handle.rx_event.write().await;
256 while let Some(event) = tokio::time::timeout(model_turn_event_timeout(), rx.recv())
257 .await
258 .expect("timed out waiting for duplicate-read trajectory")
259 {
260 match event {
261 Event::ToolCallComplete {
262 model_call: Some(model_call),
263 name,
264 result,
265 ..
266 } if name == "File" => {
267 results.insert(model_call.provider_id, result.expect("read result"));
268 }
269 Event::TurnComplete { status, error, .. } => {
270 assert_eq!(status, TurnOutcomeStatus::Completed, "{error:?}");
271 break;
272 }
273 _ => {}
274 }
275 }
276 drop(rx);
277
278 assert_eq!(results.len(), 2, "every tool ID needs one terminal result");
279 assert!(
280 results["call-read-1"]
281 .content
282 .contains("duplicate-read-proof")
283 );
284 assert!(
285 results["call-read-2"]
286 .content
287 .contains("duplicate-read-proof")
288 );
289 assert!(
290 results.values().all(|result| result
291 .metadata
292 .as_ref()
293 .is_none_or(|metadata| { metadata.get("executed").is_none() })),
294 "neither model-requested read may be replaced with a synthetic receipt"
295 );
296
297 let requests = mock.captured_requests();
298 assert_eq!(requests.len(), 2);
299 let result_ids = requests[1]
300 .messages
301 .iter()
302 .flat_map(|message| message.content.iter())
303 .filter_map(|block| match block {
304 ContentBlock::ToolResult { tool_use_id, .. } => Some(tool_use_id.as_str()),
305 _ => None,
306 })
307 .collect::<Vec<_>>();
308 assert!(result_ids.contains(&"call-read-1"));
309 assert!(result_ids.contains(&"call-read-2"));
310
311 handle.send(Op::Shutdown).await.expect("shutdown engine");
312 task.await.expect("engine task");
313 }
314
315 #[tokio::test]
316 async fn duplicate_raw_read_errors_each_touch_the_working_set() {
317 use crate::llm_client::mock::{MockLlmClient, canned};
318
319 async fn missing_read_touches(read_count: usize) -> u32 {
320 let workspace = tempdir().expect("tempdir");
321 let mut read_turn = vec![canned::message_start("mock_msg_missing_read")];
322 for index in 0..read_count {
323 let block_index = u32::try_from(index).expect("test read count fits u32");
324 let tool_id = format!("call-missing-{}", index + 1);
325 read_turn.push(canned::tool_use_block_start(
326 block_index,
327 &tool_id,
328 "read_file",
329 ));
330 read_turn.push(canned::tool_input_delta(
331 block_index,
332 r#"{"path":"missing.rs"}"#,
333 ));
334 read_turn.push(canned::block_stop(block_index));
335 }
336 read_turn.push(canned::message_delta("tool_use", None));
337 read_turn.push(canned::message_stop());
338
339 let mock = std::sync::Arc::new(MockLlmClient::new(vec![
340 read_turn,
341 canned::simple_text_turn("Missing read handled."),
342 ]));
343 let client: crate::core::model_client::SharedModelClient = mock;
344 let (mut engine, _handle) = Engine::new_with_model_client(
345 deterministic_engine_config(workspace.path()),
346 &Config::default(),
347 client,
348 );
349 let context = crate::tools::ToolContext::new(workspace.path().to_path_buf());
350 let mut registry = crate::tools::ToolRegistry::new(context);
351 registry.register(std::sync::Arc::new(crate::tools::file::ReadFileTool));
352 let tools = Some(registry.to_api_tools_with_cache(true));
353 let surface = test_tool_surface(&engine, registry, tools, AppMode::Agent);
354 let mut turn = crate::core::turn::TurnContext::new(8);
355
356 let (status, error) = engine.run_turn(&mut turn, surface, None, None).await;
357
358 assert_eq!(status, TurnOutcomeStatus::Completed, "{error:?}");
359 engine
360 .session
361 .working_set
362 .entries
363 .get("missing.rs")
364 .expect("leader error should record the attempted path")
365 .touches
366 }
367
368 let baseline_touches = missing_read_touches(1).await;
369 let duplicate_touches = missing_read_touches(2).await;
370 assert_eq!(
371 duplicate_touches,
372 baseline_touches.saturating_mul(2),
373 "each model-requested read must execute and record its own observation"
374 );
375 }
376
377 #[tokio::test]
378 async fn truncated_response_continues_turn() {
379 use crate::llm_client::mock::{MockLlmClient, canned};
380
381 let workspace = tempdir().expect("tempdir");
382 let truncated_turn = vec![
383 canned::message_start("mock_msg_truncated_continue"),
384 canned::text_block_start(0),
385 canned::text_delta(0, "Partial answer before the output budget ran out"),
386 canned::block_stop(0),
387 canned::message_delta(
388 "max_output_tokens",
389 Some(Usage {
390 input_tokens: 41,
391 output_tokens: 7,
392 reasoning_tokens: Some(3),
393 ..Default::default()
394 }),
395 ),
396 canned::message_stop(),
397 ];
398 let followup_turn = canned::simple_text_turn("Continued after the truncation.");
399 let mock = std::sync::Arc::new(MockLlmClient::new(vec![truncated_turn, followup_turn]));
400 let client: crate::core::model_client::SharedModelClient = mock.clone();
401 let (engine, handle) = Engine::new_with_model_client(
402 deterministic_engine_config(workspace.path()),
403 &Config::default(),
404 client,
405 );
406 let task = tokio::spawn(engine.run());
407 handle
408 .send(external_user_message_op(
409 "Answer the question.",
410 AppMode::Agent,
411 &Config::default(),
412 ))
413 .await
414 .expect("send truncated-then-continue trajectory");
415
416 let mut saw_turn_usage = false;
417 let mut saw_truncation_observation = false;
418 let mut last_session_messages = None;
419 let mut rx = handle.rx_event.write().await;
420 while let Some(event) = tokio::time::timeout(model_turn_event_timeout(), rx.recv())
421 .await
422 .expect("timed out waiting for truncated-then-continue trajectory")
423 {
424 match event {
425 Event::TurnUsage {
426 usage: reported, ..
427 } => {
428 assert_eq!(reported.input_tokens, 41);
429 assert_eq!(reported.output_tokens, 7);
430 assert_eq!(reported.reasoning_tokens, Some(3));
431 saw_turn_usage = true;
432 }
433 Event::SessionUpdated { messages, .. } => {
434 saw_truncation_observation |= messages.iter().any(|message| {
435 message.content.iter().any(|block| {
436 matches!(
437 block,
438 ContentBlock::Text { text, .. }
439 if text.contains("output limit")
440 && text.contains("Continue from where you left off")
441 )
442 })
443 });
444 last_session_messages = Some(messages);
445 }
446 Event::TurnComplete { status, error, .. } => {
447 assert_eq!(status, TurnOutcomeStatus::Completed, "{error:?}");
448 assert!(error.is_none(), "no terminal error expected: {error:?}");
449 break;
450 }
451 _ => {}
452 }
453 }
454 drop(rx);
455
456 assert!(
457 saw_turn_usage,
458 "reported usage must be accounted before the turn continues"
459 );
460 assert!(
461 saw_truncation_observation,
462 "the truncation must be surfaced to the model as a bounded observation"
463 );
464 let messages = last_session_messages.expect("session updated after truncation");
465 assert!(
466 messages.iter().any(|message| {
467 message.role == "assistant"
468 && message.content.iter().any(|block| {
469 matches!(
470 block,
471 ContentBlock::Text { text, .. }
472 if text.contains("Partial answer before the output budget ran out")
473 )
474 })
475 }),
476 "the partial text must be accepted as a completed assistant message"
477 );
478
479 let requests = mock.captured_requests();
480 assert_eq!(
481 requests.len(),
482 2,
483 "the loop must continue with a follow-up request"
484 );
485 handle.send(Op::Shutdown).await.expect("shutdown engine");
486 task.await.expect("engine task");
487 }
488
489 #[tokio::test]
490 async fn injected_chat_content_filter_never_becomes_a_completed_answer() {
491 use crate::llm_client::mock::{MockLlmClient, canned};
492
493 let workspace = tempdir().expect("tempdir");
494 let filtered_turn = vec![
495 canned::message_start("mock_msg_content_filter"),
496 canned::text_block_start(0),
497 canned::text_delta(0, "Provider returned only a partial fragment"),
498 canned::block_stop(0),
499 canned::message_delta(
500 "content_filter",
501 Some(Usage {
502 input_tokens: 17,
503 output_tokens: 4,
504 ..Default::default()
505 }),
506 ),
507 canned::message_stop(),
508 ];
509 let mock = std::sync::Arc::new(MockLlmClient::new(vec![filtered_turn]));
510 let client: crate::core::model_client::SharedModelClient = mock.clone();
511 let (engine, handle) = Engine::new_with_model_client(
512 deterministic_engine_config(workspace.path()),
513 &Config::default(),
514 client,
515 );
516 let task = tokio::spawn(engine.run());
517 handle
518 .send(external_user_message_op(
519 "Answer the question.",
520 AppMode::Agent,
521 &Config::default(),
522 ))
523 .await
524 .expect("send content-filter trajectory");
525
526 let mut rx = handle.rx_event.write().await;
527 while let Some(event) = tokio::time::timeout(model_turn_event_timeout(), rx.recv())
528 .await
529 .expect("timed out waiting for content-filter trajectory")
530 {
531 match event {
532 Event::MessageComplete { .. } => {
533 panic!("content-filtered text must not be marked completed")
534 }
535 Event::TurnComplete { status, error, .. } => {
536 assert_eq!(status, TurnOutcomeStatus::Failed);
537 let error = error.expect("content-filtered turn needs a terminal error");
538 assert!(error.contains("Model response incomplete"), "{error}");
539 assert!(error.contains("content_filter"), "{error}");
540 break;
541 }
542 _ => {}
543 }
544 }
545 drop(rx);
546
547 assert_eq!(mock.captured_requests().len(), 1);
548 handle.send(Op::Shutdown).await.expect("shutdown engine");
549 task.await.expect("engine task");
550 }
551
552 #[tokio::test]
553 async fn injected_model_complete_tool_block_at_max_output_tokens_executes() {
554 use crate::llm_client::mock::{MockLlmClient, canned};
555
556 let workspace = tempdir().expect("tempdir");
557 fs::write(
558 workspace.path().join("truncated-tool-ran.txt"),
559 "truncated-tool-executed",
560 )
561 .expect("write fixture");
562 let usage = Usage {
563 input_tokens: 52,
564 output_tokens: 23,
565 ..Default::default()
566 };
567 let truncated_tool_turn = vec![
568 canned::message_start("mock_msg_truncated_tool"),
569 canned::tool_use_block_start(0, "call-truncated", "File"),
570 canned::tool_input_delta(0, r#"{"action":"read","path":"truncated-tool-ran.txt"}"#),
571 canned::block_stop(0),
572 canned::message_delta("max_output_tokens", Some(usage.clone())),
573 canned::message_stop(),
574 ];
575 let followup_turn = canned::simple_text_turn("Done.");
576 let mock = std::sync::Arc::new(MockLlmClient::new(vec![truncated_tool_turn, followup_turn]));
577 let client: crate::core::model_client::SharedModelClient = mock.clone();
578 let (engine, handle) = Engine::new_with_model_client(
579 deterministic_engine_config(workspace.path()),
580 &Config::default(),
581 client,
582 );
583 let task = tokio::spawn(engine.run());
584 handle
585 .send(external_user_message_op(
586 "Read the fixture file.",
587 AppMode::Agent,
588 &Config::default(),
589 ))
590 .await
591 .expect("send truncated-tool trajectory");
592
593 let mut saw_turn_usage = false;
594 let mut saw_tool_start = false;
595 let mut saw_tool_success = false;
596 let mut rx = handle.rx_event.write().await;
597 while let Some(event) = tokio::time::timeout(model_turn_event_timeout(), rx.recv())
598 .await
599 .expect("timed out waiting for truncated-tool trajectory")
600 {
601 match event {
602 Event::TurnUsage { .. } => saw_turn_usage = true,
603 Event::ToolCallStarted {
604 id,
605 name,
606 model_call,
607 ..
608 } => {
609 assert_ne!(id, "call-truncated");
610 assert_eq!(model_call.unwrap().provider_id, "call-truncated");
611 assert_eq!(name, "File");
612 saw_tool_start = true;
613 }
614 Event::ToolCallComplete {
615 id,
616 name,
617 result,
618 model_call,
619 } => {
620 assert_ne!(id, "call-truncated");
621 assert_eq!(model_call.unwrap().provider_id, "call-truncated");
622 assert_eq!(name, "File");
623 let result = result.expect("complete tool call closes with a tool result");
624 assert!(
625 result.success,
626 "complete tool call must be accepted and executed: {result:?}"
627 );
628 saw_tool_success = true;
629 }
630 Event::TurnComplete { status, error, .. } => {
631 assert_eq!(status, TurnOutcomeStatus::Completed, "{error:?}");
632 assert!(error.is_none(), "no terminal error expected: {error:?}");
633 break;
634 }
635 _ => {}
636 }
637 }
638 drop(rx);
639
640 assert!(saw_turn_usage, "reported usage must be emitted");
641 assert!(saw_tool_start, "the streamed tool lifecycle must open");
642 assert!(
643 saw_tool_success,
644 "the complete tool call must be accepted and executed"
645 );
646 let requests = mock.captured_requests();
647 assert_eq!(
648 requests.len(),
649 2,
650 "the loop must continue with a follow-up request after the tool result"
651 );
652 handle.send(Op::Shutdown).await.expect("shutdown engine");
653 task.await.expect("engine task");
654 }
655
656 #[tokio::test]
657 async fn injected_model_receives_malformed_tool_feedback_and_recovers() {
658 use crate::llm_client::mock::{MockLlmClient, canned};
659
660 let workspace = tempdir().expect("tempdir");
661 let mock = std::sync::Arc::new(MockLlmClient::new(vec![
662 canned::tool_call_turn("call-bad-read", "File", r#"{"action":"read"}"#),
663 canned::simple_text_turn("Recovered after validation feedback."),
664 ]));
665 let client: crate::core::model_client::SharedModelClient = mock.clone();
666 let (engine, handle) = Engine::new_with_model_client(
667 deterministic_engine_config(workspace.path()),
668 &Config::default(),
669 client,
670 );
671 let task = tokio::spawn(engine.run());
672 handle
673 .send(external_user_message_op(
674 "Exercise malformed tool feedback.",
675 AppMode::Agent,
676 &Config::default(),
677 ))
678 .await
679 .expect("send malformed trajectory");
680
681 let mut validation_feedback = None;
682 let mut recovered = false;
683 let mut rx = handle.rx_event.write().await;
684 while let Some(event) = tokio::time::timeout(model_turn_event_timeout(), rx.recv())
685 .await
686 .expect("timed out waiting for malformed trajectory")
687 {
688 match event {
689 Event::ToolCallComplete { name, result, .. } if name == "File" => {
690 validation_feedback = Some(match result {
691 Ok(result) => result.content,
692 Err(error) => error.to_string(),
693 });
694 }
695 Event::MessageDelta { content, .. } => {
696 recovered |= content.contains("Recovered after validation feedback");
697 }
698 Event::TurnComplete { status, error, .. } => {
699 assert_eq!(status, TurnOutcomeStatus::Completed, "{error:?}");
700 break;
701 }
702 _ => {}
703 }
704 }
705 drop(rx);
706 let feedback = validation_feedback.expect("validation feedback event");
707 assert!(feedback.to_ascii_lowercase().contains("path"), "{feedback}");
708 assert!(
709 recovered,
710 "model must get a follow-up turn after tool failure"
711 );
712 assert_eq!(mock.call_count(), 2);
713 handle.send(Op::Shutdown).await.expect("shutdown engine");
714 task.await.expect("engine task");
715 }
716
717 #[tokio::test]
718 async fn engine_cancellation_drops_active_injected_model_request() {
719 let workspace = tempdir().expect("tempdir");
720 let entered = std::sync::Arc::new(tokio::sync::Notify::new());
721 let request_dropped = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
722 let client: crate::core::model_client::SharedModelClient =
723 std::sync::Arc::new(BlockingModelClient {
724 entered: std::sync::Arc::clone(&entered),
725 request_dropped: std::sync::Arc::clone(&request_dropped),
726 });
727 let (engine, handle) = Engine::new_with_model_client(
728 deterministic_engine_config(workspace.path()),
729 &Config::default(),
730 client,
731 );
732 let task = tokio::spawn(engine.run());
733 handle
734 .send(external_user_message_op(
735 "Block until explicitly cancelled.",
736 AppMode::Agent,
737 &Config::default(),
738 ))
739 .await
740 .expect("send cancellation trajectory");
741 tokio::time::timeout(model_turn_event_timeout(), entered.notified())
742 .await
743 .expect("model request was never entered");
744
745 let mut rx = handle.rx_event.write().await;
746 let pending_snapshot = loop {
747 let event = tokio::time::timeout(model_turn_event_timeout(), rx.recv())
748 .await
749 .expect("timed out waiting for pending request snapshot")
750 .expect("engine event");
751 if let Event::ToolRequestSnapshot { snapshot } = event {
752 break snapshot;
753 }
754 };
755 assert!(pending_snapshot.delivery_status.starts_with("unknown"));
756 assert!(pending_snapshot.tools_field_present);
757 drop(rx);
758 handle.cancel();
759
760 let mut rx = handle.rx_event.write().await;
761 while let Some(event) = tokio::time::timeout(model_turn_event_timeout(), rx.recv())
762 .await
763 .expect("timed out waiting for cancellation")
764 {
765 if let Event::TurnComplete { status, error, .. } = event {
766 assert_eq!(status, TurnOutcomeStatus::Interrupted, "{error:?}");
767 break;
768 }
769 }
770 drop(rx);
771 assert!(
772 request_dropped.load(std::sync::atomic::Ordering::SeqCst),
773 "cancellation must drop the active provider future"
774 );
775 handle.send(Op::Shutdown).await.expect("shutdown engine");
776 task.await.expect("engine task");
777 }
778
779 fn guardian_fixture_response(text: &str) -> codewhale_models::MessageResponse {
780 codewhale_models::MessageResponse {
781 id: "guardian-fixture".to_string(),
782 r#type: "message".to_string(),
783 role: "assistant".to_string(),
784 content: vec![ContentBlock::Text {
785 text: text.to_string(),
786 cache_control: None,
787 }],
788 model: "mock-model".to_string(),
789 stop_reason: Some("end_turn".to_string()),
790 stop_sequence: None,
791 container: None,
792 usage: Usage {
793 input_tokens: 17,
794 output_tokens: 3,
795 ..Usage::default()
796 },
797 }
798 }
799
800 fn guardian_tool_results<'a>(
801 request: &'a codewhale_models::MessageRequest,
802 call_id: &str,
803 ) -> Vec<(&'a str, Option<bool>)> {
804 request
805 .messages
806 .iter()
807 .flat_map(|message| &message.content)
808 .filter_map(|block| match block {
809 ContentBlock::ToolResult {
810 tool_use_id,
811 content,
812 is_error,
813 ..
814 } if tool_use_id == call_id => Some((content.as_str(), *is_error)),
815 _ => None,
816 })
817 .collect()
818 }
819
820 /// One transcript-visible gate receipt observed on the event stream.
821 #[derive(Debug, Clone, PartialEq, Eq)]
822 struct GateReceipt {
823 gate: crate::core::events::ToolGate,
824 decision: crate::core::events::ToolGateVerdict,
825 risk: Option<String>,
826 reason: String,
827 }
828
829 async fn collect_guardian_journey_with_receipts(
830 handle: &EngineHandle,
831 call_id: &str,
832 ) -> (
833 Result<crate::tools::spec::ToolResult, crate::tools::spec::ToolError>,
834 Vec<Usage>,
835 Usage,
836 Vec<GateReceipt>,
837 ) {
838 let mut completion = None;
839 let mut execution_id = None;
840 let mut usage_events = Vec::new();
841 let mut receipts = Vec::new();
842 let mut rx = handle.rx_event.write().await;
843 let terminal_usage = loop {
844 let event = tokio::time::timeout(model_turn_event_timeout(), rx.recv())
845 .await
846 .expect("timed out waiting for Auto-Review journey")
847 .expect("engine event stream closed");
848 match event {
849 Event::ToolCallStarted {
850 id,
851 model_call: Some(model_call),
852 ..
853 } if model_call.provider_id == call_id => {
854 execution_id = Some(id);
855 }
856 Event::ToolCallComplete {
857 model_call: Some(model_call),
858 result,
859 ..
860 } if model_call.provider_id == call_id => {
861 assert!(
862 completion.replace(result).is_none(),
863 "duplicate tool result"
864 );
865 }
866 // Guardian consults carry their own routed receipt; both the
867 // parent-route and routed per-call telemetry count as reaching
868 // the cost UI.
869 Event::TurnUsage { usage, .. } | Event::RoutedTurnUsage { usage, .. } => {
870 usage_events.push(usage);
871 }
872 Event::ToolGateDecision {
873 tool_id,
874 gate,
875 decision,
876 risk,
877 reason,
878 ..
879 } if execution_id.as_deref() == Some(tool_id.as_str()) => receipts.push(GateReceipt {
880 gate,
881 decision,
882 risk,
883 reason,
884 }),
885 Event::TurnComplete {
886 status,
887 error,
888 usage,
889 ..
890 } => {
891 assert_eq!(status, TurnOutcomeStatus::Completed, "{error:?}");
892 break usage;
893 }
894 _ => {}
895 }
896 };
897 drop(rx);
898 (
899 completion.expect("held call must have one paired result"),
900 usage_events,
901 terminal_usage,
902 receipts,
903 )
904 }
905
906 #[cfg(windows)]
907 #[test]
908 fn windows_full_access_node_image_kill_is_denied_before_any_shell_effect() {
909 use crate::llm_client::mock::{MockLlmClient, canned};
910
911 with_artifact_home(|home| {
912 tokio::runtime::Builder::new_current_thread()
913 .enable_all()
914 .build()
915 .unwrap()
916 .block_on(async {
917 let workspace = home.join("windows-node-floor");
918 fs::create_dir(&workspace).expect("fixture workspace");
919 let sentinel = workspace.join("must-not-run.txt");
920 // The scanner deliberately holds commands inside conditionals.
921 // Keep the kill unreachable even if this regression breaks:
922 // failing the guard may write the sentinel, but must never kill
923 // the developer's other Node or Codewhale processes.
924 let command = "powershell -NoProfile -Command \"Set-Content -LiteralPath 'must-not-run.txt' -Value 'ran'; if ($false) { taskkill /F /IM node.exe }\"";
925 let arguments = json!({"command": command}).to_string();
926 let mock = std::sync::Arc::new(MockLlmClient::new(vec![canned::tool_call_turn(
927 "windows-node-kill",
928 "bash",
929 &arguments,
930 )]));
931 mock.push_factory(|request| {
932 let results = guardian_tool_results(request, "windows-node-kill");
933 assert_eq!(results.len(), 1, "one provider ID gets one result");
934 assert_eq!(results[0].1, Some(true));
935 assert!(results[0].0.contains("Node launcher"), "{results:?}");
936 assert!(results[0].0.contains("do not work around"), "{results:?}");
937 canned::simple_text_turn("Use an owned server PID instead.")
938 });
939 let config = Config { allow_shell: Some(true), ..Config::default() };
940 let (engine, handle) = Engine::new_with_model_client(
941 deterministic_engine_config(&workspace), &config, mock.clone(),
942 );
943 let mut op = external_user_message_op("Attempt the supplied cleanup.", AppMode::Agent, &config);
944 let Op::SendMessage(turn) = &mut op else { unreachable!("model turn fixture") };
945 turn.auto_approve = true;
946 turn.approval_mode = ApprovalMode::Bypass;
947 let task = tokio::spawn(engine.run());
948 handle.send(op).await.expect("send Full Access trajectory");
949 let (completion, _, _, receipts) = collect_guardian_journey_with_receipts(&handle, "windows-node-kill").await;
950 let error = completion.expect_err("runtime safety floor must deny before shell execution");
951 assert!(matches!(error, crate::tools::spec::ToolError::PermissionDenied { .. }));
952 assert!(error.to_string().contains("Node launcher"), "{error}");
953 assert!(!sentinel.exists(), "no part of the composed shell call may run");
954 assert_eq!(receipts.len(), 1, "{receipts:?}");
955 assert_eq!(receipts[0].gate, crate::core::events::ToolGate::AutoReviewDeterministic);
956 assert_eq!(receipts[0].decision, crate::core::events::ToolGateVerdict::Denied);
957 assert!(receipts[0].reason.contains("Node launcher"));
958 assert_eq!(mock.captured_requests().len(), 2, "main and paired-result follow-up only; no guardian");
959 handle.send(Op::Shutdown).await.expect("shutdown engine");
960 tokio::time::timeout(model_turn_event_timeout(), task).await.expect("bounded shutdown").expect("engine task");
961 });
962 });
963 }
964
965 #[tokio::test]
966 async fn auto_review_guardian_allow_executes_once_and_accounts_usage_without_prompt_leak() {
967 use crate::llm_client::mock::{MockLlmClient, canned};
968
969 const REVIEW_REASON: &str = "bounded fixture write is reversible";
970 let workspace = tempdir().expect("tempdir");
971 fs::create_dir(workspace.path().join(".git")).expect("git marker");
972 let mock = std::sync::Arc::new(MockLlmClient::new(vec![canned::tool_call_turn(
973 "call-guardian-allow",
974 "File",
975 r#"{"action":"write","path":".env","content":"assembled=true\n"}"#,
976 )]));
977 mock.push_factory(|request| {
978 let tool_results = guardian_tool_results(request, "call-guardian-allow");
979 assert_eq!(tool_results.len(), 1, "one call must produce one result");
980 assert_ne!(tool_results[0].1, Some(true));
981 let request_json = serde_json::to_string(request).expect("serialize follow-up request");
982 assert!(!request_json.contains(REVIEW_REASON), "{request_json}");
983 assert!(
984 !request_json.contains("deterministic_observations"),
985 "{request_json}"
986 );
987 assert!(!request_json.contains("hold_reason"), "{request_json}");
988 canned::simple_text_turn("Guardian-approved write complete.")
989 });
990 mock.push_message_response(guardian_fixture_response(&format!(
991 r#"{{"risk_level":"low","decision":"allow","reason":"{REVIEW_REASON}"}}"#
992 )));
993 let client: crate::core::model_client::SharedModelClient = mock.clone();
994 let config = Config::default();
995 let (engine, handle) = Engine::new_with_model_client(
996 deterministic_engine_config(workspace.path()),
997 &config,
998 client,
999 );
1000 let task = tokio::spawn(engine.run());
1001 handle
1002 .send(auto_review_message_op(
1003 "Write the isolated fixture.",
1004 &config,
1005 ))
1006 .await
1007 .expect("send Auto-Review allow journey");
1008
1009 let (completion, usage_events, terminal_usage, receipts) =
1010 collect_guardian_journey_with_receipts(&handle, "call-guardian-allow").await;
1011 assert!(completion.expect("guardian-approved tool result").success);
1012 // The person never saw a prompt, so the transcript gets exactly one
1013 // receipt naming the guardian's verdict and risk tier.
1014 assert_eq!(receipts.len(), 1, "{receipts:?}");
1015 assert_eq!(
1016 receipts[0].gate,
1017 crate::core::events::ToolGate::AutoReviewGuardian
1018 );
1019 assert_eq!(
1020 receipts[0].decision,
1021 crate::core::events::ToolGateVerdict::Allowed
1022 );
1023 assert!(receipts[0].risk.is_some(), "{receipts:?}");
1024 assert!(!receipts[0].reason.contains('\n'));
1025 assert!(
1026 usage_events
1027 .iter()
1028 .any(|usage| usage.input_tokens == 17 && usage.output_tokens == 3),
1029 "guardian usage must reach the cost UI"
1030 );
1031 assert_eq!(terminal_usage.input_tokens, 17);
1032 assert_eq!(terminal_usage.output_tokens, 3);
1033 assert_eq!(
1034 fs::read_to_string(workspace.path().join(".env")).expect("written fixture"),
1035 "assembled=true\n"
1036 );
1037 let requests = mock.captured_requests();
1038 assert_eq!(requests.len(), 3, "main, guardian, follow-up");
1039 assert_eq!(requests[1].stream, Some(false));
1040 assert!(requests[1].tools.is_none());
1041 let guardian_json = serde_json::to_string(&requests[1]).expect("guardian request JSON");
1042 assert!(guardian_json.contains("call") || guardian_json.contains("proposed_tool_call"));
1043 assert!(guardian_json.contains(".env"));
1044
1045 handle.send(Op::Shutdown).await.expect("shutdown engine");
1046 task.await.expect("engine task");
1047 }
1048
1049 #[tokio::test]
1050 async fn auto_review_guardian_deny_returns_one_paired_failed_result() {
1051 use crate::llm_client::mock::{MockLlmClient, canned};
1052
1053 const DENIAL: &str = "sensitive configuration must remain untouched";
1054 let workspace = tempdir().expect("tempdir");
1055 fs::create_dir(workspace.path().join(".git")).expect("git marker");
1056 let mock = std::sync::Arc::new(MockLlmClient::new(vec![canned::tool_call_turn(
1057 "call-guardian-deny",
1058 "File",
1059 r#"{"action":"write","path":".env","content":"must-not-run\n"}"#,
1060 )]));
1061 mock.push_factory(|request| {
1062 let tool_results = guardian_tool_results(request, "call-guardian-deny");
1063 assert_eq!(tool_results.len(), 1, "denied call must not be orphaned");
1064 assert_eq!(tool_results[0].1, Some(true));
1065 assert!(tool_results[0].0.contains(DENIAL), "{tool_results:?}");
1066 assert!(
1067 tool_results[0].0.contains("Do not work around this denial"),
1068 "{tool_results:?}"
1069 );
1070 let request_json = serde_json::to_string(request).expect("serialize follow-up request");
1071 assert!(
1072 !request_json.contains("deterministic_observations"),
1073 "{request_json}"
1074 );
1075 assert!(!request_json.contains("hold_reason"), "{request_json}");
1076 canned::simple_text_turn("Stopped after the guardian denial.")
1077 });
1078 mock.push_message_response(guardian_fixture_response(&format!(
1079 r#"{{"risk_level":"medium","decision":"deny","reason":"{DENIAL}"}}"#
1080 )));
1081 let client: crate::core::model_client::SharedModelClient = mock.clone();
1082 let config = Config::default();
1083 let (engine, handle) = Engine::new_with_model_client(
1084 deterministic_engine_config(workspace.path()),
1085 &config,
1086 client,
1087 );
1088 let task = tokio::spawn(engine.run());
1089 handle
1090 .send(auto_review_message_op("Attempt the held write.", &config))
1091 .await
1092 .expect("send Auto-Review deny journey");
1093
1094 let (completion, _, _, receipts) =
1095 collect_guardian_journey_with_receipts(&handle, "call-guardian-deny").await;
1096 let error = completion.expect_err("guardian denial must fail the tool call");
1097 assert!(error.to_string().contains(DENIAL), "{error}");
1098 assert_eq!(receipts.len(), 1, "{receipts:?}");
1099 assert_eq!(
1100 receipts[0].decision,
1101 crate::core::events::ToolGateVerdict::Denied
1102 );
1103 assert!(receipts[0].reason.contains(DENIAL), "{receipts:?}");
1104 assert!(!workspace.path().join(".env").exists());
1105 assert_eq!(mock.captured_requests().len(), 3);
1106 handle.send(Op::Shutdown).await.expect("shutdown engine");
1107 task.await.expect("engine task");
1108 }
1109
1110 #[tokio::test]
1111 async fn auto_review_guardian_parse_and_transport_failures_deny_closed() {
1112 use crate::llm_client::mock::{MockLlmClient, canned};
1113
1114 for failure in ["parse", "transport"] {
1115 let workspace = tempdir().expect("tempdir");
1116 fs::create_dir(workspace.path().join(".git")).expect("git marker");
1117 let call_id = format!("call-guardian-{failure}");
1118 let initial = canned::tool_call_turn(
1119 &call_id,
1120 "File",
1121 r#"{"action":"write","path":".env","content":"must-not-run\n"}"#,
1122 );
1123 let follow_up_id = call_id.clone();
1124 let follow_up = move |request: &codewhale_models::MessageRequest| {
1125 let results = guardian_tool_results(request, &follow_up_id);
1126 assert_eq!(results.len(), 1, "reviewer failure must pair one result");
1127 let result = results[0];
1128 assert_eq!(result.1, Some(true));
1129 assert!(result.0.contains("denied (fail closed)"), "{result:?}");
1130 assert!(!result.0.contains("fixture guardian transport failure"));
1131 canned::simple_text_turn("Stopped after reviewer failure.")
1132 };
1133
1134 let config = Config::default();
1135 let client: crate::core::model_client::SharedModelClient = if failure == "parse" {
1136 let mock = MockLlmClient::new(vec![initial]);
1137 mock.push_factory(follow_up);
1138 mock.push_message_response(guardian_fixture_response("not valid guardian JSON"));
1139 std::sync::Arc::new(mock)
1140 } else {
1141 let mock = MockLlmClient::new(vec![initial]);
1142 mock.push_factory(follow_up);
1143 std::sync::Arc::new(FailingGuardianModelClient { inner: mock })
1144 };
1145 let (engine, handle) = Engine::new_with_model_client(
1146 deterministic_engine_config(workspace.path()),
1147 &config,
1148 client,
1149 );
1150 let task = tokio::spawn(engine.run());
1151 handle
1152 .send(auto_review_message_op("Attempt the held write.", &config))
1153 .await
1154 .expect("send reviewer failure journey");
1155
1156 let (completion, _, _, receipts) =
1157 collect_guardian_journey_with_receipts(&handle, &call_id).await;
1158 let error = completion.expect_err("reviewer failure must deny");
1159 assert!(error.to_string().contains("fail closed"), "{error}");
1160 assert_eq!(receipts.len(), 1, "{receipts:?}");
1161 assert_eq!(
1162 receipts[0].decision,
1163 crate::core::events::ToolGateVerdict::Unavailable,
1164 "{receipts:?}"
1165 );
1166 assert!(receipts[0].risk.is_none());
1167 assert!(!workspace.path().join(".env").exists());
1168 handle.send(Op::Shutdown).await.expect("shutdown engine");
1169 task.await.expect("engine task");
1170 }
1171 }
1172
1173 #[tokio::test]
1174 async fn auto_review_cancellation_promptly_drops_the_guardian_request() {
1175 let workspace = tempdir().expect("tempdir");
1176 fs::create_dir(workspace.path().join(".git")).expect("git marker");
1177 let guardian_entered = std::sync::Arc::new(tokio::sync::Notify::new());
1178 let guardian_dropped = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
1179 let model = std::sync::Arc::new(BlockingGuardianModelClient {
1180 guardian_entered: std::sync::Arc::clone(&guardian_entered),
1181 guardian_dropped: std::sync::Arc::clone(&guardian_dropped),
1182 streaming_calls: std::sync::atomic::AtomicUsize::new(0),
1183 });
1184 let client: crate::core::model_client::SharedModelClient = model.clone();
1185 let config = Config::default();
1186 let (engine, handle) = Engine::new_with_model_client(
1187 deterministic_engine_config(workspace.path()),
1188 &config,
1189 client,
1190 );
1191 let task = tokio::spawn(engine.run());
1192 handle
1193 .send(auto_review_message_op("Attempt the held write.", &config))
1194 .await
1195 .expect("send blocking guardian journey");
1196 tokio::time::timeout(model_turn_event_timeout(), guardian_entered.notified())
1197 .await
1198 .expect("guardian request was never entered");
1199
1200 handle.cancel();
1201 let mut rx = handle.rx_event.write().await;
1202 loop {
1203 let event = tokio::time::timeout(Duration::from_secs(1), rx.recv())
1204 .await
1205 .expect("guardian cancellation did not settle promptly")
1206 .expect("engine event stream closed");
1207 if let Event::TurnComplete { status, error, .. } = event {
1208 assert_eq!(status, TurnOutcomeStatus::Interrupted, "{error:?}");
1209 break;
1210 }
1211 }
1212 drop(rx);
1213
1214 assert!(
1215 guardian_dropped.load(std::sync::atomic::Ordering::SeqCst),
1216 "cancellation must drop the guardian provider future"
1217 );
1218 assert_eq!(
1219 model
1220 .streaming_calls
1221 .load(std::sync::atomic::Ordering::SeqCst),
1222 1
1223 );
1224 assert!(!workspace.path().join(".env").exists());
1225 handle.send(Op::Shutdown).await.expect("shutdown engine");
1226 task.await.expect("engine task");
1227 }
1228
1229 #[tokio::test]
1230 #[allow(clippy::await_holding_lock)]
1231 async fn operate_conversation_reaches_provider_when_workers_are_disabled() {
1232 use wiremock::matchers::{method, path};
1233 use wiremock::{Mock, MockServer, ResponseTemplate};
1234
1235 let _lock = lock_test_env();
1236 let workspace = tempdir().expect("tempdir");
1237 let server = MockServer::start().await;
1238 let done_sse = concat!(
1239 "data: {\"id\":\"chatcmpl-operate\",\"choices\":[{\"index\":0,",
1240 "\"delta\":{\"content\":\"I can still answer normally.\"},\"finish_reason\":null}]}\n\n",
1241 "data: {\"id\":\"chatcmpl-operate\",\"choices\":[{\"index\":0,",
1242 "\"delta\":{},\"finish_reason\":\"stop\"}]}\n\n",
1243 "data: [DONE]\n\n",
1244 );
1245 Mock::given(method("POST"))
1246 .and(path("/v1/chat/completions"))
1247 .respond_with(
1248 ResponseTemplate::new(200)
1249 .insert_header("content-type", "text/event-stream")
1250 .set_body_string(done_sse),
1251 )
1252 .expect(1)
1253 .mount(&server)
1254 .await;
1255
1256 let api_config = Config {
1257 ..Config::default()
1258 }
1259 .with_legacy_root(Some("test-key".to_string()), Some(server.uri()));
1260 let engine_config = EngineConfig {
1261 workspace: workspace.path().to_path_buf(),
1262 snapshots_enabled: false,
1263 subagents_enabled: false,
1264 ..EngineConfig::default()
1265 };
1266 let (operate_engine, operate_handle) = Engine::new(engine_config, &api_config);
1267 let operate_task = tokio::spawn(operate_engine.run());
1268 operate_handle
1269 .send(external_user_message_op(
1270 "what is a Rust worktree?",
1271 AppMode::Operate,
1272 &api_config,
1273 ))
1274 .await
1275 .expect("send Operate turn");
1276
1277 let mut saw_operate_complete = false;
1278 let mut saw_operate_route = false;
1279 let mut operate_rx = operate_handle.rx_event.write().await;
1280 while let Some(event) = tokio::time::timeout(model_turn_event_timeout(), operate_rx.recv())
1281 .await
1282 .expect("timed out waiting for Operate completion")
1283 {
1284 match event {
1285 Event::RouteDispatched { route, .. } => {
1286 assert_eq!(route.provider, ProviderKind::Deepseek);
1287 assert_eq!(route.model, crate::config::DEFAULT_TEXT_MODEL);
1288 assert!(!route.auto_model);
1289 saw_operate_route = true;
1290 }
1291 Event::Error { envelope, .. } => {
1292 panic!("ordinary Operate conversation emitted an error: {envelope:?}");
1293 }
1294 Event::TurnComplete { status, error, .. } => {
1295 assert_eq!(status, TurnOutcomeStatus::Completed, "{error:?}");
1296 saw_operate_complete = true;
1297 break;
1298 }
1299 _ => {}
1300 }
1301 }
1302 drop(operate_rx);
1303
1304 assert!(
1305 saw_operate_route,
1306 "model turns must publish route provenance"
1307 );
1308 assert!(
1309 saw_operate_complete,
1310 "Operate conversation must complete without worker readiness"
1311 );
1312 let requests = server
1313 .received_requests()
1314 .await
1315 .expect("recorded requests after Operate");
1316 assert_eq!(requests.len(), 1, "Operate must reach the provider");
1317 operate_handle
1318 .send(Op::Shutdown)
1319 .await
1320 .expect("shutdown Operate engine");
1321 operate_task.await.expect("Operate engine task");
1322 }
1323
1324 fn auto_review_plan_decision(
1325 policy: &crate::tui::auto_review::AutoReviewPolicy,
1326 tool_name: &str,
1327 tool_input: &Value,
1328 run_origin: crate::tui::auto_review::RunOrigin,
1329 approval_mode: ApprovalMode,
1330 workspace_trusted: bool,
1331 workspace: Option<&Path>,
1332 ) -> (AutoReviewPlanDecision, Value) {
1333 let context = crate::tui::auto_review::AutoReviewContext::from_tool_call(
1334 tool_name,
1335 tool_input,
1336 run_origin,
1337 approval_mode,
1338 workspace_trusted,
1339 workspace,
1340 );
1341 auto_review_plan_decision_for_context(policy, &context)
1342 }
1343
1344 #[test]
1345 fn auto_review_scenario() {
1346 // Scenario consolidation of: auto_review_classifies_publish_and_holds_without_prompting, auto_review_classifier_allow_executes_without_prompting, auto_review_allows_ordinary_shell_probe_without_prompting, auto_review_routes_unknown_tool_to_reviewer_in_auto, auto_review_policy_blocks_publish_when_approval_is_never, auto_review_allows_ordinary_test_command_without_prompting, auto_review_allows_ordinary_workspace_write_without_prompting, auto_review_routes_unbounded_or_sensitive_workspace_writes_to_reviewer
1347 // from auto_review_classifies_publish_and_holds_without_prompting
1348 {
1349 let (decision, audit) = auto_review_plan_decision(
1350 &crate::tui::auto_review::AutoReviewPolicy::default(),
1351 "exec_shell",
1352 &json!({"command": "git push origin main"}),
1353 crate::tui::auto_review::RunOrigin::Interactive,
1354 ApprovalMode::Auto,
1355 true,
1356 None,
1357 );
1358
1359 assert_eq!(
1360 decision,
1361 AutoReviewPlanDecision::Block(
1362 "Built-in safety gate requires approval: publish-like action requires durable review"
1363 .to_string()
1364 )
1365 );
1366 assert_eq!(audit["action_kind"], "publish");
1367 assert_eq!(audit["decision"], "hold_for_review");
1368 }
1369 // from auto_review_classifier_allow_executes_without_prompting
1370 {
1371 let (decision, audit) = auto_review_plan_decision(
1372 &crate::tui::auto_review::AutoReviewPolicy::default(),
1373 "read_file",
1374 &json!({"path": "Cargo.toml"}),
1375 crate::tui::auto_review::RunOrigin::Interactive,
1376 ApprovalMode::Auto,
1377 true,
1378 None,
1379 );
1380
1381 assert_eq!(decision, AutoReviewPlanDecision::Allow);
1382 assert_eq!(audit["decision"], "allow");
1383 }
1384 // from auto_review_allows_ordinary_shell_probe_without_prompting
1385 {
1386 let (decision, audit) = auto_review_plan_decision(
1387 &crate::tui::auto_review::AutoReviewPolicy::default(),
1388 "exec_shell",
1389 &json!({"command": "git remote -v && git rev-parse --show-toplevel && git branch --show-current && git rev-parse HEAD && git tag --list 'v0.8.65'"}),
1390 crate::tui::auto_review::RunOrigin::Interactive,
1391 ApprovalMode::Auto,
1392 true,
1393 None,
1394 );
1395
1396 assert_eq!(decision, AutoReviewPlanDecision::Allow);
1397 assert_eq!(audit["decision"], "allow");
1398 assert_eq!(audit["action_kind"], "shell");
1399 }
1400 // from auto_review_routes_unknown_tool_to_reviewer_in_auto
1401 {
1402 let (decision, audit) = auto_review_plan_decision(
1403 &crate::tui::auto_review::AutoReviewPolicy::default(),
1404 "mystery_tool",
1405 &json!({"value": true}),
1406 crate::tui::auto_review::RunOrigin::Interactive,
1407 ApprovalMode::Auto,
1408 true,
1409 None,
1410 );
1411
1412 assert_eq!(
1413 decision,
1414 AutoReviewPlanDecision::ConsultReviewer(
1415 "unknown tool category requires explicit review".to_string()
1416 )
1417 );
1418 assert_eq!(audit["decision"], "ask_user");
1419 }
1420 // from auto_review_policy_blocks_publish_when_approval_is_never
1421 {
1422 let (decision, audit) = auto_review_plan_decision(
1423 &crate::tui::auto_review::AutoReviewPolicy::default(),
1424 "github_publish_release",
1425 &json!({"tag": "v0.8.64"}),
1426 crate::tui::auto_review::RunOrigin::Interactive,
1427 ApprovalMode::Never,
1428 true,
1429 None,
1430 );
1431
1432 assert_eq!(
1433 decision,
1434 AutoReviewPlanDecision::Block(
1435 "Built-in safety gate requires approval: publish-like action requires durable review"
1436 .to_string()
1437 )
1438 );
1439 assert_eq!(audit["approval_mode"], "NEVER");
1440 assert_eq!(audit["decision"], "hold_for_review");
1441 }
1442 // from auto_review_allows_ordinary_test_command_without_prompting
1443 {
1444 let (decision, audit) = auto_review_plan_decision(
1445 &crate::tui::auto_review::AutoReviewPolicy::default(),
1446 "exec_shell",
1447 &json!({"command": "cargo test"}),
1448 crate::tui::auto_review::RunOrigin::Interactive,
1449 ApprovalMode::Auto,
1450 true,
1451 None,
1452 );
1453
1454 assert_eq!(decision, AutoReviewPlanDecision::Allow);
1455 assert_eq!(audit["decision"], "allow");
1456 assert_eq!(audit["risk"], "destructive");
1457 }
1458 // from auto_review_allows_ordinary_workspace_write_without_prompting
1459 {
1460 let tmp = tempdir().expect("tempdir");
1461 std::fs::create_dir(tmp.path().join(".git")).expect("git marker");
1462 std::fs::create_dir(tmp.path().join("src")).expect("source directory");
1463 let (decision, audit) = auto_review_plan_decision(
1464 &crate::tui::auto_review::AutoReviewPolicy::default(),
1465 "write_file",
1466 &json!({"path": "src/lib.rs", "content": "pub fn ready() {}\n"}),
1467 crate::tui::auto_review::RunOrigin::Interactive,
1468 ApprovalMode::Auto,
1469 true,
1470 Some(tmp.path()),
1471 );
1472
1473 assert_eq!(decision, AutoReviewPlanDecision::Allow);
1474 assert_eq!(audit["decision"], "allow");
1475 assert_eq!(audit["action_kind"], "write");
1476 }
1477 // from auto_review_routes_unbounded_or_sensitive_workspace_writes_to_reviewer
1478 {
1479 let tmp = tempdir().expect("tempdir");
1480 std::fs::create_dir(tmp.path().join(".git")).expect("git marker");
1481 for path in ["../outside.rs", "/etc/hostname", ".env", ".git/config"] {
1482 let (decision, audit) = auto_review_plan_decision(
1483 &crate::tui::auto_review::AutoReviewPolicy::default(),
1484 "write_file",
1485 &json!({"path": path, "content": "blocked"}),
1486 crate::tui::auto_review::RunOrigin::Interactive,
1487 ApprovalMode::Auto,
1488 true,
1489 Some(tmp.path()),
1490 );
1491 assert!(
1492 matches!(decision, AutoReviewPlanDecision::ConsultReviewer(_)),
1493 "Auto-Review must not auto-approve {path} without reviewer judgment"
1494 );
1495 assert_eq!(audit["decision"], "ask_user", "unexpected audit for {path}");
1496 }
1497 }
1498 }
1499
1500 #[test]
1501 fn repo_law_asks_only_in_ask_posture() {
1502 use ApprovalMode;
1503
1504 assert!(!repo_law_must_block_without_prompt(
1505 ApprovalMode::Suggest,
1506 false
1507 ));
1508 for mode in [
1509 ApprovalMode::Auto,
1510 ApprovalMode::Never,
1511 ApprovalMode::Bypass,
1512 ] {
1513 assert!(
1514 repo_law_must_block_without_prompt(mode, false),
1515 "{} must not open a human repo-law approval",
1516 mode.permission_chip_label()
1517 );
1518 }
1519 assert!(repo_law_must_block_without_prompt(
1520 ApprovalMode::Suggest,
1521 true
1522 ));
1523 }
1524
1525 #[test]
1526 fn rlm_eval_required_approval_is_auto_approved_in_full_access() {
1527 assert!(!registered_tool_approval_required(
1528 "rlm_eval",
1529 ApprovalRequirement::Required,
1530 true
1531 ));
1532 }
1533
1534 /// A remembered grant for a Computer Use consent must not answer a later,
1535 /// identical call: the prompt is forced, so only a card decides it.
1536 #[test]
1537 fn computer_use_decisions_always_force_the_card() {
1538 let consent = serde_json::json!({"app": "Safari", "bundle_id": "com.apple.Safari"});
1539 for name in [
1540 "mcp_codewhale-cu_consent_allow",
1541 "mcp_codewhale-cu_consent_revoke",
1542 ] {
1543 assert!(
1544 call_forces_prompt(name, &consent, ApprovalRequirement::Required),
1545 "{name}"
1546 );
1547 }
1548 assert!(call_forces_prompt(
1549 "mcp_codewhale-cu_app_script",
1550 &serde_json::json!({"script": "tell application \"Finder\" to activate"}),
1551 ApprovalRequirement::Required,
1552 ));
1553 assert!(!call_forces_prompt(
1554 "mcp_codewhale-cu_consent_status",
1555 &serde_json::json!({}),
1556 ApprovalRequirement::Required,
1557 ));
1558 let ask = crate::core::authority::TurnAuthority::from_effective_fields(
1559 AppMode::Agent,
1560 true,
1561 false,
1562 false,
1563 ApprovalMode::Suggest,
1564 );
1565 assert_eq!(
1566 crate::core::authority::resolve_approval_request_disposition(
1567 &ask, true, false, true, false
1568 ),
1569 crate::core::authority::ApprovalRequestDisposition::Prompt,
1570 "a session grant must not pre-answer a forced prompt"
1571 );
1572 }
1573
1574 #[test]
1575 fn non_bypassable_registered_tools_auto_approve_in_full_access() {
1576 // #3866 reversed (owner decision, 2026-08-10): Full Access already grants
1577 // everything these calls can do — shell included — so a hold that cannot
1578 // open its own approval modal auto-approves instead of stranding the
1579 // call. Ask, which can open the modal, still gates every one of these.
1580 // Registry launcher is host-constructed and cache-bound (no free-form
1581 // command), so Full Access auto-approves it: `--auto` automation must
1582 // be able to complete the discovery flow end to end. Ask still gates it.
1583 assert!(!registered_tool_approval_required(
1584 "start_registry_mcp_server",
1585 ApprovalRequirement::Required,
1586 true
1587 ));
1588 assert!(registered_tool_approval_required(
1589 "start_registry_mcp_server",
1590 ApprovalRequirement::Required,
1591 false
1592 ));
1593 assert!(!registered_tool_approval_required(
1594 "start_mcp_server",
1595 ApprovalRequirement::Required,
1596 true
1597 ));
1598 assert!(!registered_tool_approval_required(
1599 "rlm_eval",
1600 ApprovalRequirement::Required,
1601 true
1602 ));
1603 assert!(registered_tool_forces_prompt(
1604 "start_mcp_server",
1605 ApprovalRequirement::Required,
1606 ));
1607 assert!(!registered_tool_forces_prompt(
1608 "start_registry_mcp_server",
1609 ApprovalRequirement::Required,
1610 ));
1611 assert!(registered_tool_forces_prompt(
1612 "rlm_eval",
1613 ApprovalRequirement::Required,
1614 ));
1615 assert!(!registered_tool_approval_required(
1616 "exec_shell",
1617 ApprovalRequirement::Required,
1618 true
1619 ));
1620 assert!(
1621 registered_tool_approval_required("start_mcp_server", ApprovalRequirement::Required, false),
1622 "start_mcp_server must require approval when auto_approve is disabled"
1623 );
1624 // Sanity contrast: an ordinary Required tool is bypassable under auto-approve.
1625 assert!(!registered_tool_approval_required(
1626 "exec_shell",
1627 ApprovalRequirement::Required,
1628 true
1629 ));
1630 }
1631
1631 lines RUST