返回 CodeWhale
test_cases_07.rs
根目录 / crates / tui / src / core / engine / tests / test_cases_07.rs
1 /// A fence is admitted through the same planning as a `code_execution`
2 /// call, so an Auto-Review block rule on `code_execution` also stops the
3 /// fence, even under Full Access where no card would open.
4 #[tokio::test]
5 async fn repl_fence_obeys_auto_review_block_under_full_access() {
6 use crate::llm_client::mock::{MockLlmClient, canned};
7 use codewhale_models::{ContentBlock, Message};
8
9 let workspace = tempdir().expect("tempdir");
10 let marker = workspace.path().join("fence-ran");
11 let fence = format!(
12 "```repl\nopen({:?}, 'w').write('x')\nfinalize('done')\n```",
13 marker.display().to_string()
14 );
15 let mock = std::sync::Arc::new(MockLlmClient::new(vec![
16 canned::simple_text_turn(&fence),
17 canned::simple_text_turn("Done."),
18 ]));
19 let client: crate::core::model_client::SharedModelClient = mock.clone();
20 let mut config = deterministic_engine_config(workspace.path());
21 // Built through the config entry point, as production does.
22 config.auto_review_policy = Config {
23 auto_review: Some(crate::config::AutoReviewConfig {
24 block: vec![crate::config::AutoReviewRuleConfig {
25 id: Some("no-code".to_string()),
26 tool: Some(CODE_EXECUTION_TOOL_NAME.to_string()),
27 reason: Some("code is blocked here".to_string()),
28 ..Default::default()
29 }],
30 ..Default::default()
31 }),
32 ..Config::default()
33 }
34 .auto_review_policy();
35 let (mut engine, handle) = Engine::new_with_model_client(config, &Config::default(), client);
36 engine.session.auto_approve = true;
37 engine.session.add_message(Message {
38 role: Role::User,
39 content: vec![ContentBlock::Text {
40 text: "Compute.".to_string(),
41 cache_control: None,
42 }],
43 });
44 let registry = crate::tools::ToolRegistry::new(crate::tools::ToolContext::new(
45 workspace.path().to_path_buf(),
46 ));
47 let policy = test_tool_surface(
48 &engine,
49 registry,
50 Some(vec![catalog_tool(CODE_EXECUTION_TOOL_NAME)]),
51 AppMode::Agent,
52 );
53 let mut turn = crate::core::turn::TurnContext::new(4);
54 let (status, error) = engine.run_turn(&mut turn, policy, None, None).await;
55
56 assert_eq!(status, TurnOutcomeStatus::Completed, "{error:?}");
57 assert!(
58 engine.repl_kernel.is_none(),
59 "blocked fence starts no kernel"
60 );
61 assert!(!marker.exists(), "blocked fence must not run");
62 let note = {
63 let mut rx = handle.rx_event.write().await;
64 std::iter::from_fn(|| rx.try_recv().ok()).any(|event| {
65 matches!(event, Event::Status { message }
66 if message.starts_with("REPL block not run:") && message.contains("code is blocked here"))
67 })
68 };
69 assert!(note, "the block reason is shown");
70 }
71
72 /// Under Auto-Review a fence goes through the same review as a
73 /// `code_execution` call instead of an approval card that the posture can
74 /// only auto-deny: with the reviewer allowing it, the fence runs.
75 #[tokio::test]
76 async fn repl_fence_runs_through_auto_review_without_a_card() {
77 use crate::llm_client::mock::{MockLlmClient, canned};
78 use codewhale_models::{ContentBlock, Message};
79
80 let workspace = tempdir().expect("tempdir");
81 let marker = workspace.path().join("fence-ran");
82 let fence = format!(
83 "```repl\nopen({:?}, 'w').write('x')\nfinalize('done')\n```",
84 marker.display().to_string()
85 );
86 let mock = std::sync::Arc::new(MockLlmClient::new(vec![
87 canned::simple_text_turn(&fence),
88 canned::simple_text_turn("Done."),
89 ]));
90 mock.push_message_response(guardian_fixture_response(
91 r#"{"risk_level":"low","decision":"allow","reason":"isolated fixture write"}"#,
92 ));
93 let client: crate::core::model_client::SharedModelClient = mock.clone();
94 let (mut engine, handle) = Engine::new_with_model_client(
95 deterministic_engine_config(workspace.path()),
96 &Config::default(),
97 client,
98 );
99 engine.session.auto_approve = false;
100 engine.session.approval_mode = ApprovalMode::Auto;
101 engine.session.add_message(Message {
102 role: Role::User,
103 content: vec![ContentBlock::Text {
104 text: "Compute.".to_string(),
105 cache_control: None,
106 }],
107 });
108 let registry = crate::tools::ToolRegistry::new(crate::tools::ToolContext::new(
109 workspace.path().to_path_buf(),
110 ));
111 let policy = test_tool_surface(
112 &engine,
113 registry,
114 Some(vec![catalog_tool(CODE_EXECUTION_TOOL_NAME)]),
115 AppMode::Agent,
116 );
117 let mut turn = crate::core::turn::TurnContext::new(4);
118 let (status, error) = tokio::time::timeout(
119 Duration::from_secs(30),
120 engine.run_turn(&mut turn, policy, None, None),
121 )
122 .await
123 .expect("an Auto-Review fence must not wait on a card");
124
125 assert_eq!(status, TurnOutcomeStatus::Completed, "{error:?}");
126 let asked = {
127 let mut rx = handle.rx_event.write().await;
128 std::iter::from_fn(|| rx.try_recv().ok())
129 .any(|event| matches!(event, Event::ApprovalRequired { .. }))
130 };
131 assert!(!asked, "Auto-Review opens no card for a reviewed fence");
132 assert!(marker.exists(), "the reviewed fence runs");
133 }
134
135 async fn snapshot_for_catalog(
136 workspace: &Path,
137 catalog: Option<Vec<codewhale_models::Tool>>,
138 ) -> crate::tool_inspection::ToolInspectionSnapshot {
139 use crate::llm_client::mock::{MockLlmClient, canned};
140
141 let mock = std::sync::Arc::new(MockLlmClient::new(vec![canned::simple_text_turn("Done.")]));
142 let client: crate::core::model_client::SharedModelClient = mock;
143 let (mut engine, handle) = Engine::new_with_model_client(
144 deterministic_engine_config(workspace),
145 &Config::default(),
146 client,
147 );
148 let registry =
149 crate::tools::ToolRegistry::new(crate::tools::ToolContext::new(workspace.to_path_buf()));
150 let surface = test_tool_surface(&engine, registry, catalog, AppMode::Agent);
151 let mut turn = crate::core::turn::TurnContext::new(2);
152 let (status, error) = engine.run_turn(&mut turn, surface, None, None).await;
153 assert_eq!(status, TurnOutcomeStatus::Completed, "{error:?}");
154 let mut events = handle.rx_event.write().await;
155 std::iter::from_fn(|| events.try_recv().ok())
156 .find_map(|event| match event {
157 Event::ToolRequestSnapshot { snapshot } => Some(snapshot),
158 _ => None,
159 })
160 .expect("request snapshot")
161 }
162
163 #[tokio::test]
164 async fn request_selector_distinguishes_absent_tools_from_present_empty_tools() {
165 let workspace = tempdir().expect("tempdir");
166 let absent = snapshot_for_catalog(workspace.path(), None).await;
167 let deferred_only = codewhale_models::Tool {
168 tool_type: Some("function".to_string()),
169 name: "deferred_fixture".to_string(),
170 description: "Deferred fixture".to_string(),
171 input_schema: json!({"type": "object"}),
172 allowed_callers: None,
173 defer_loading: Some(true),
174 input_examples: None,
175 strict: None,
176 cache_control: None,
177 };
178 let selected = active_tools_for_request(&[deferred_only], &HashSet::new(), false);
179 let present_empty = crate::tool_inspection::ToolInspectionSnapshot::from_prepared_request(
180 "turn",
181 0,
182 selected.as_deref(),
183 );
184
185 assert!(!absent.tools_field_present);
186 assert_eq!(absent.tool_count, 0);
187 assert!(present_empty.tools_field_present);
188 assert_eq!(present_empty.tool_count, 0);
189 assert_eq!(present_empty.payload_json_bytes, Some(2));
190 }
191
192 #[tokio::test]
193 async fn terminal_diagnostics_distinguish_narration_from_missing_protocol_tool_calls() {
194 use crate::llm_client::mock::{MockLlmClient, canned};
195 use crate::tool_inspection::TurnStopReason;
196
197 for finish in ["end_turn", "tool_calls", "tool_use"] {
198 let workspace = tempdir().expect("tempdir");
199 let response = vec![
200 canned::message_start("stop_diagnostic_fixture"),
201 canned::text_block_start(0),
202 canned::text_delta(0, "I will edit the file. 我现在修改文件。"),
203 canned::block_stop(0),
204 canned::message_delta(
205 finish,
206 Some(Usage {
207 input_tokens: 100,
208 output_tokens: 12,
209 reasoning_tokens: Some(4),
210 prompt_cache_hit_tokens: Some(80),
211 prompt_cache_miss_tokens: Some(20),
212 ..Usage::default()
213 }),
214 ),
215 canned::message_stop(),
216 ];
217 let mock = std::sync::Arc::new(MockLlmClient::new(vec![response]));
218 let client: crate::core::model_client::SharedModelClient = mock.clone();
219 let (mut engine, handle) = Engine::new_with_model_client(
220 deterministic_engine_config(workspace.path()),
221 &Config::default(),
222 client,
223 );
224 let registry = crate::tools::ToolRegistry::new(crate::tools::ToolContext::new(
225 workspace.path().to_path_buf(),
226 ));
227 let surface = test_tool_surface(&engine, registry, None, AppMode::Agent);
228 let mut turn = crate::core::turn::TurnContext::new(4);
229 let (status, error) = engine.run_turn(&mut turn, surface, None, None).await;
230 let expected = if finish == "end_turn" {
231 assert!(
232 error.is_none(),
233 "ordinary narration is not a protocol error"
234 );
235 TurnStopReason::ProviderNoToolCall
236 } else {
237 assert_eq!(status, TurnOutcomeStatus::Failed);
238 assert!(
239 error
240 .as_deref()
241 .is_some_and(|error| error.contains("supplied no tool call"))
242 );
243 TurnStopReason::ProviderToolCallMissing
244 };
245 let snapshot = turn
246 .terminal_request_snapshot(status)
247 .expect("terminal request snapshot");
248 let terminal = snapshot.terminal.as_ref().expect("terminal facts");
249 assert_eq!(terminal.reason, Some(expected));
250 assert_eq!(terminal.model_requests_started, 1);
251 assert_eq!(terminal.last_reported_input_tokens, Some(100));
252 assert_eq!(terminal.last_response_tool_calls, Some(0));
253 assert_eq!(terminal.last_response_tool_calls_suppressed, Some(0));
254 assert_eq!(
255 terminal.last_provider_finish_reason.as_ref().unwrap().value,
256 finish
257 );
258 assert_eq!(
259 mock.captured_requests().len(),
260 1,
261 "narration must not synthesize continuation"
262 );
263 assert_eq!(turn.usage.input_tokens, 100);
264 assert_eq!(
265 turn.usage.output_tokens, 12,
266 "reasoning is an output subset, not additional output"
267 );
268 assert!(snapshot.render_text().contains("Terminal diagnostics"));
269 let json = serde_json::to_value(&snapshot).expect("serialize snapshot");
270 assert_eq!(json["terminal"]["model_requests_started"], 1);
271 let mut events = handle.rx_event.write().await;
272 assert!(
273 !std::iter::from_fn(|| events.try_recv().ok())
274 .any(|event| matches!(event, Event::ToolCallStarted { .. }))
275 );
276 }
277 }
278
279 #[tokio::test]
280 async fn terminal_diagnostics_merge_cumulative_usage_within_one_request() {
281 use crate::llm_client::mock::{MockLlmClient, canned};
282
283 let workspace = tempdir().expect("tempdir");
284 let usage = Usage {
285 input_tokens: 100,
286 output_tokens: 12,
287 reasoning_tokens: Some(4),
288 prompt_cache_hit_tokens: Some(80),
289 prompt_cache_miss_tokens: Some(20),
290 ..Usage::default()
291 };
292 let mut start = canned::message_start("cumulative_usage_fixture");
293 if let StreamEvent::MessageStart { message } = &mut start {
294 message.usage = usage.clone();
295 }
296 let mock = std::sync::Arc::new(MockLlmClient::new(vec![vec![
297 start,
298 canned::text_block_start(0),
299 canned::text_delta(0, "Done."),
300 canned::block_stop(0),
301 canned::message_delta("end_turn", Some(usage.clone())),
302 canned::message_delta("end_turn", Some(usage.clone())),
303 canned::message_stop(),
304 ]]));
305 let client: crate::core::model_client::SharedModelClient = mock.clone();
306 let (mut engine, handle) = Engine::new_with_model_client(
307 deterministic_engine_config(workspace.path()),
308 &Config::default(),
309 client,
310 );
311 let registry = crate::tools::ToolRegistry::new(crate::tools::ToolContext::new(
312 workspace.path().to_path_buf(),
313 ));
314 let surface = test_tool_surface(&engine, registry, None, AppMode::Agent);
315 let mut turn = crate::core::turn::TurnContext::new(4);
316 let (status, error) = engine.run_turn(&mut turn, surface, None, None).await;
317 assert_eq!(status, TurnOutcomeStatus::Completed, "{error:?}");
318 assert_eq!(mock.captured_requests().len(), 1);
319 assert_eq!(
320 turn.usage, usage,
321 "repeated cumulative receipts are counted once"
322 );
323 let mut events = handle.rx_event.write().await;
324 let usages = std::iter::from_fn(|| events.try_recv().ok())
325 .filter_map(|event| match event {
326 Event::TurnUsage { usage, .. } => Some(usage),
327 _ => None,
328 })
329 .collect::<Vec<_>>();
330 assert_eq!(usages, vec![usage]);
331 }
332
333 #[tokio::test]
334 async fn request_snapshots_advance_to_the_latest_tool_step() {
335 use crate::llm_client::mock::{MockLlmClient, canned};
336
337 let workspace = tempdir().expect("tempdir");
338 fs::write(workspace.path().join("README.md"), "fixture\n").expect("write fixture");
339 let mock = std::sync::Arc::new(MockLlmClient::new(vec![
340 canned::tool_call_turn("call-read", "read_file", r#"{"path":"README.md"}"#),
341 canned::simple_text_turn("Done."),
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(4);
355
356 let (status, error) = engine.run_turn(&mut turn, surface, None, None).await;
357 assert_eq!(status, TurnOutcomeStatus::Completed, "{error:?}");
358 let mut events = handle.rx_event.write().await;
359 let snapshots = std::iter::from_fn(|| events.try_recv().ok())
360 .filter_map(|event| match event {
361 Event::ToolRequestSnapshot { snapshot } => Some(snapshot),
362 _ => None,
363 })
364 .collect::<Vec<_>>();
365
366 assert_eq!(snapshots.len(), 2);
367 assert_eq!(snapshots[0].step, 0);
368 assert_eq!(snapshots[1].step, 1);
369 assert_eq!(snapshots[1].turn_id.value, turn.id);
370 }
371
372 #[tokio::test]
373 async fn tool_result_followed_by_terminal_empty_assistant_fails_turn() {
374 use crate::llm_client::mock::{MockLlmClient, canned};
375
376 let workspace = tempdir().expect("tempdir");
377 fs::write(workspace.path().join("README.md"), "fixture\n").expect("write fixture");
378 let empty_terminal_turn = vec![
379 canned::message_start("mock_empty_after_tool"),
380 canned::message_delta("stop", None),
381 canned::message_stop(),
382 ];
383 // #6310: an answerless clean stop is retried (exact prefix, then nudged)
384 // before the turn fails, so the fixture stays empty for every attempt.
385 let mock = std::sync::Arc::new(MockLlmClient::new(vec![
386 canned::tool_call_turn("call-read", "read_file", r#"{"path":"README.md"}"#),
387 empty_terminal_turn.clone(),
388 empty_terminal_turn.clone(),
389 empty_terminal_turn,
390 ]));
391 let client: crate::core::model_client::SharedModelClient = mock.clone();
392 let (mut engine, handle) = Engine::new_with_model_client(
393 deterministic_engine_config(workspace.path()),
394 &Config::default(),
395 client,
396 );
397 let context = crate::tools::ToolContext::new(workspace.path().to_path_buf());
398 let mut registry = crate::tools::ToolRegistry::new(context);
399 registry.register(std::sync::Arc::new(crate::tools::file::ReadFileTool));
400 let tools = Some(registry.to_api_tools_with_cache(true));
401 let surface = test_tool_surface(&engine, registry, tools, AppMode::Agent);
402 let mut turn = crate::core::turn::TurnContext::new(4);
403
404 let (status, error) = engine.run_turn(&mut turn, surface, None, None).await;
405 assert_eq!(status, TurnOutcomeStatus::Failed);
406 assert_eq!(
407 mock.call_count(),
408 4,
409 "tool step, empty provider step, then exactly two bounded retries"
410 );
411 assert_eq!(turn.stop_diagnostics.empty_stop_retries, 2);
412 assert!(
413 error
414 .as_deref()
415 .is_some_and(|message| message.contains("terminal stop reason `stop`")
416 && message.contains("after 2 retries")),
417 "terminal empty response must produce a precise failure: {error:?}"
418 );
419
420 let mut events = handle.rx_event.write().await;
421 let events = std::iter::from_fn(|| events.try_recv().ok()).collect::<Vec<_>>();
422 assert!(
423 events.iter().any(|event| matches!(
424 event,
425 Event::ToolCallComplete { model_call: Some(model_call), result, .. }
426 if model_call.provider_id == "call-read" && result.is_ok()
427 )),
428 "the successful tool result must remain durable: {events:?}"
429 );
430 assert!(
431 events
432 .iter()
433 .any(|event| matches!(event, Event::Error { .. })),
434 "the empty provider response must be visible as an error: {events:?}"
435 );
436 assert!(
437 engine
438 .session
439 .messages
440 .iter()
441 .all(|message| { message.role != Role::Assistant || !message.content.is_empty() }),
442 "the engine must not fabricate an empty assistant message"
443 );
444 }
445
446 fn empty_clean_stop_turn() -> Vec<StreamEvent> {
447 use crate::llm_client::mock::canned;
448 vec![
449 canned::message_start("mock_empty_clean_stop"),
450 canned::message_delta("stop", None),
451 canned::message_stop(),
452 ]
453 }
454
455 async fn run_empty_stop_fixture(
456 turns: Vec<Vec<StreamEvent>>,
457 ) -> (
458 std::sync::Arc<crate::llm_client::mock::MockLlmClient>,
459 Engine,
460 crate::core::turn::TurnContext,
461 TurnOutcomeStatus,
462 Option<String>,
463 Vec<Event>,
464 ) {
465 let workspace = tempdir().expect("tempdir");
466 let mock = std::sync::Arc::new(crate::llm_client::mock::MockLlmClient::new(turns));
467 let client: crate::core::model_client::SharedModelClient = mock.clone();
468 let (mut engine, handle) = Engine::new_with_model_client(
469 deterministic_engine_config(workspace.path()),
470 &Config::default(),
471 client,
472 );
473 let registry = crate::tools::ToolRegistry::new(crate::tools::ToolContext::new(
474 workspace.path().to_path_buf(),
475 ));
476 let surface = test_tool_surface(&engine, registry, None, AppMode::Agent);
477 let mut turn = crate::core::turn::TurnContext::new(4);
478 let (status, error) = engine.run_turn(&mut turn, surface, None, None).await;
479 let mut rx = handle.rx_event.write().await;
480 let events = std::iter::from_fn(|| rx.try_recv().ok()).collect::<Vec<_>>();
481 (mock, engine, turn, status, error, events)
482 }
483
484 /// #6310: one clean `stop` with no text, reasoning or tool call is retried
485 /// with the identical request and the turn completes on the real answer.
486 #[tokio::test]
487 async fn empty_clean_stop_is_retried_once_and_the_turn_completes() {
488 use crate::llm_client::mock::canned;
489
490 let (mock, engine, turn, status, error, events) = run_empty_stop_fixture(vec![
491 empty_clean_stop_turn(),
492 canned::simple_text_turn("the recovered answer"),
493 ])
494 .await;
495
496 assert_eq!(status, TurnOutcomeStatus::Completed, "{error:?}");
497 assert_eq!(mock.call_count(), 2, "exactly one retry");
498 assert_eq!(turn.stop_diagnostics.empty_stop_retries, 1);
499 let requests = mock.captured_requests();
500 assert_eq!(
501 requests[0].messages.len(),
502 requests[1].messages.len(),
503 "the first retry is an exact-prefix re-request"
504 );
505 let transcript =
506 serde_json::to_string(&engine.session.messages.iter().collect::<Vec<_>>()).unwrap();
507 assert_eq!(transcript.matches("the recovered answer").count(), 1);
508 assert!(
509 engine
510 .session
511 .messages
512 .iter()
513 .all(|message| message.role != Role::Assistant || !message.content.is_empty()),
514 "the empty response must not be persisted"
515 );
516 let retry_receipts = events
517 .iter()
518 .filter_map(|event| match event {
519 Event::Status { message } if message.starts_with("Retry attempt: empty-stop ") => {
520 Some(message)
521 }
522 _ => None,
523 })
524 .collect::<Vec<_>>();
525 assert_eq!(retry_receipts.len(), 1);
526 for (index, receipt) in retry_receipts.iter().enumerate() {
527 assert!(receipt.starts_with(&format!("Retry attempt: empty-stop {}/2;", index + 1)));
528 }
529 assert_eq!(events.iter().filter(|event| matches!(event, Event::Status { message } if message == "Retry recovery: empty-stop used 1/2 retries; turn completed")).count(), 1);
530 }
531
532 /// #6310: the second retry carries the request-scoped nudge, which never
533 /// joins the session; the retry after that budget is not attempted.
534 #[tokio::test]
535 async fn empty_clean_stop_second_retry_is_nudged_and_never_persisted() {
536 use crate::llm_client::mock::canned;
537
538 let (mock, engine, turn, status, error, events) = run_empty_stop_fixture(vec![
539 empty_clean_stop_turn(),
540 empty_clean_stop_turn(),
541 canned::simple_text_turn("answer after nudge"),
542 ])
543 .await;
544
545 assert_eq!(status, TurnOutcomeStatus::Completed, "{error:?}");
546 assert_eq!(mock.call_count(), 3);
547 assert_eq!(turn.stop_diagnostics.empty_stop_retries, 2);
548 let requests = mock.captured_requests();
549 let nudge = crate::config::DEFAULT_REASONING_ONLY_REPROMPT_MESSAGE;
550 let carries_nudge = |request: &codewhale_models::MessageRequest| {
551 serde_json::to_string(&request.messages)
552 .unwrap()
553 .contains(nudge)
554 };
555 assert!(!carries_nudge(&requests[0]));
556 assert!(!carries_nudge(&requests[1]), "first retry is exact-prefix");
557 assert!(carries_nudge(&requests[2]), "second retry is nudged");
558 assert_eq!(requests[2].messages.len(), requests[0].messages.len() + 1);
559 assert!(
560 !serde_json::to_string(&engine.session.messages.iter().collect::<Vec<_>>())
561 .unwrap()
562 .contains(nudge),
563 "the nudge is request-scoped and never written to the session"
564 );
565 let retry_receipts = events
566 .iter()
567 .filter_map(|event| match event {
568 Event::Status { message } if message.starts_with("Retry attempt: empty-stop ") => {
569 Some(message)
570 }
571 _ => None,
572 })
573 .collect::<Vec<_>>();
574 assert_eq!(retry_receipts.len(), 2);
575 for (index, receipt) in retry_receipts.iter().enumerate() {
576 assert!(receipt.starts_with(&format!("Retry attempt: empty-stop {}/2;", index + 1)));
577 }
578 assert_eq!(events.iter().filter(|event| matches!(event, Event::Status { message } if message == "Retry recovery: empty-stop used 2/2 retries; turn completed")).count(), 1);
579 }
580
581 /// #6310: an empty response on every attempt fails visibly once the budget
582 /// is spent, with the retries recorded in stop diagnostics.
583 #[tokio::test]
584 async fn empty_clean_stop_every_time_fails_after_the_retry_budget() {
585 let (mock, _engine, turn, status, error, events) = run_empty_stop_fixture(vec![
586 empty_clean_stop_turn(),
587 empty_clean_stop_turn(),
588 empty_clean_stop_turn(),
589 ])
590 .await;
591
592 assert_eq!(status, TurnOutcomeStatus::Failed);
593 assert_eq!(
594 mock.call_count(),
595 1 + crate::core::engine::turn_loop::EMPTY_STOP_MAX_RETRIES as usize
596 );
597 assert_eq!(
598 turn.stop_diagnostics.empty_stop_retries,
599 crate::core::engine::turn_loop::EMPTY_STOP_MAX_RETRIES
600 );
601 assert!(
602 error
603 .as_deref()
604 .is_some_and(|message| message.contains("terminal stop reason `stop`")
605 && message.contains("after 2 retries")),
606 "{error:?}"
607 );
608 let retry_receipts = events
609 .iter()
610 .filter_map(|event| match event {
611 Event::Status { message } if message.starts_with("Retry attempt: empty-stop ") => {
612 Some(message)
613 }
614 _ => None,
615 })
616 .collect::<Vec<_>>();
617 assert_eq!(retry_receipts.len(), 2);
618 for (index, receipt) in retry_receipts.iter().enumerate() {
619 assert!(receipt.starts_with(&format!("Retry attempt: empty-stop {}/2;", index + 1)));
620 }
621 assert_eq!(events.iter().filter(|event| matches!(event, Event::Status { message } if message == "Retry stopped: empty-stop used 2/2 retries; turn failed")).count(), 1);
622 }
623
624 #[tokio::test]
625 async fn request_snapshot_reports_registry_provenance_for_the_transmitted_catalog() {
626 use crate::llm_client::mock::{MockLlmClient, canned};
627
628 let workspace = tempdir().expect("tempdir");
629 let mock = std::sync::Arc::new(MockLlmClient::new(vec![canned::simple_text_turn("Done.")]));
630 let client: crate::core::model_client::SharedModelClient = mock.clone();
631 // Pin `read_file` loaded so this step's request actually carries it; the
632 // point of the test is what a *transmitted* tool is reported as.
633 let mut engine_config = deterministic_engine_config(workspace.path());
634 engine_config.tools_always_load = HashSet::from(["read_file".to_string()]);
635 let (mut engine, handle) =
636 Engine::new_with_model_client(engine_config, &Config::default(), client);
637 let context = crate::tools::ToolContext::new(workspace.path().to_path_buf());
638 let mut registry = crate::tools::ToolRegistry::new(context);
639 registry.register(std::sync::Arc::new(crate::tools::file::ReadFileTool));
640 // `read_file` is a hidden compatibility alias, so `to_api_tools` would hand
641 // the turn an empty catalog and nothing would be transmitted. Hand the
642 // engine an explicit catalog instead: the registry is still the source of
643 // the *facts*, including the fact that this tool is not model-visible.
644 let tools = Some(vec![codewhale_models::Tool {
645 tool_type: None,
646 name: "read_file".to_string(),
647 description: "Read a file".to_string(),
648 input_schema: json!({"type": "object"}),
649 allowed_callers: Some(vec!["direct".to_string()]),
650 defer_loading: Some(false),
651 input_examples: None,
652 strict: None,
653 cache_control: None,
654 }]);
655
656 // The same surface context `handle_send_message` resolves for a real turn:
657 // real registry facts, real (empty) MCP attribution, the engine's own
658 // synthetic-name list, and the resolved model client's receipt.
659 let synthetic_names = super::tool_catalog::default_synthetic_catalog_tool_names();
660 let surface = crate::tool_inspection::ToolSurfaceContext {
661 registry: registry.registry_facts(&HashSet::new()),
662 mcp_servers: std::collections::BTreeMap::new(),
663 synthetic_names: synthetic_names.clone(),
664 provider: engine.tool_surface_provider_receipt(),
665 };
666 let policy = test_tool_surface(&engine, registry, tools, AppMode::Agent);
667
668 let mut turn = crate::core::turn::TurnContext::new(4);
669 let (status, error) = engine
670 .run_turn(&mut turn, policy, None, Some(surface))
671 .await;
672 assert_eq!(status, TurnOutcomeStatus::Completed, "{error:?}");
673
674 let mut events = handle.rx_event.write().await;
675 let snapshot = std::iter::from_fn(|| events.try_recv().ok())
676 .find_map(|event| match event {
677 Event::ToolRequestSnapshot { snapshot } => Some(snapshot),
678 _ => None,
679 })
680 .expect("request snapshot");
681
682 let transmitted = mock
683 .last_request()
684 .expect("captured request")
685 .tools
686 .unwrap_or_default();
687 assert!(!transmitted.is_empty(), "the turn must carry tools");
688
689 // The digest is the request path's, over what was prepared.
690 assert_eq!(
691 snapshot.active_tool_catalog_sha256.as_deref(),
692 Some(crate::core::engine::preview::active_tool_catalog_sha256(&transmitted).as_str())
693 );
694
695 // Provenance is registry-derived truth, not "unavailable".
696 assert!(snapshot.registry_facts_present);
697 assert!(snapshot.provider.is_available());
698 assert_eq!(
699 snapshot.unavailable_for_this_request,
700 vec!["provider_wire_payload"]
701 );
702
703 let read_file = snapshot
704 .tools
705 .iter()
706 .find(|entry| entry.name.value == "read_file")
707 .expect("read_file projected");
708 assert_eq!(
709 read_file.provenance,
710 crate::tool_inspection::Evidence::Known {
711 value: crate::tool_inspection::ToolProvenance::Builtin
712 }
713 );
714 assert!(matches!(
715 read_file.approval,
716 crate::tool_inspection::Evidence::Known { .. }
717 ));
718 // Registry truth, not an inference from the request: this alias is hidden
719 // from the model catalog even though this catalog carried it explicitly.
720 assert_eq!(
721 read_file.model_visible,
722 crate::tool_inspection::Evidence::Known { value: false }
723 );
724 assert!(read_file.visibility.in_request());
725
726 // Anything the engine injected rather than registered is reported as
727 // synthetic, from the engine's own list — never guessed from the name.
728 for entry in &snapshot.tools {
729 if synthetic_names.contains(&entry.name.value) {
730 assert_eq!(
731 entry.provenance,
732 crate::tool_inspection::Evidence::Known {
733 value: crate::tool_inspection::ToolProvenance::Synthetic
734 },
735 "'{}' is engine-injected, not registry-backed",
736 entry.name.value
737 );
738 // Not in the registry, so capabilities are unknown, not "none".
739 assert!(matches!(
740 entry.capabilities,
741 crate::tool_inspection::Evidence::Unknown { .. }
742 ));
743 }
744 }
745 }
746
747 fn deterministic_engine_config(workspace: &Path) -> EngineConfig {
748 EngineConfig {
749 workspace: workspace.to_path_buf(),
750 snapshots_enabled: false,
751 subagents_enabled: false,
752 ..EngineConfig::default()
753 }
754 }
755
756 /// The compaction budget is measured against the real system prompt, and the
757 /// skills block in that prompt is discovered from the developer's home as well
758 /// as from the workspace. Left ambient, this test counts whatever skills the
759 /// machine happens to have installed into its token budget: 39 of them trip a
760 /// seventh compaction pass on a developer box while CI, with an empty home,
761 /// sees six and passes. That is the #5359 leak class, and the isolated home is
762 /// what makes `deterministic_engine_config` actually deterministic here.
763 #[test]
764 fn automatic_compaction_continues_one_task_and_suppresses_failed_passes() {
765 let _env = lock_test_env();
766 let home = tempdir().unwrap();
767 let _codewhale_home = EnvVarGuard::set("CODEWHALE_HOME", home.path());
768 let _user_home = EnvVarGuard::set("HOME", home.path());
769 let _user_profile = EnvVarGuard::set("USERPROFILE", home.path());
770 let runtime = tokio::runtime::Builder::new_current_thread()
771 .enable_all()
772 .build()
773 .unwrap();
774 runtime.block_on(async {
775 use crate::llm_client::mock::{MockLlmClient, canned};
776 for fail_summary in [false, true] {
777 let workspace = tempdir().unwrap();
778 fs::write(
779 workspace.path().join("README.md"),
780 "verified fixture evidence",
781 )
782 .unwrap();
783 let mock = std::sync::Arc::new(MockLlmClient::new(Vec::new()));
784 for step in 0..16 {
785 mock.push_turn(vec![
786 canned::message_start(&format!("response-{step}")),
787 canned::text_block_start(0),
788 canned::text_delta(0, &format!("Step {step}: {}", "x".repeat(32_000))),
789 canned::block_stop(0),
790 canned::tool_use_block_start(1, &format!("read-{step}"), "File"),
791 canned::tool_input_delta(1, r#"{"action":"read","path":"README.md"}"#),
792 canned::block_stop(1),
793 canned::message_delta("tool_use", None),
794 canned::message_stop(),
795 ]);
796 }
797 mock.push_turn(canned::simple_text_turn(
798 "All sixteen reads verified; task complete.",
799 ));
800 for checkpoint in 0..8 {
801 let content = if fail_summary {
802 json!([{"type":"tool_use","id":"unexpected","name":"File","input":{}}])
803 } else {
804 json!([{"type":"text","text":format!("Current objective: complete all sixteen reads. Checkpoint {checkpoint}: earlier reads verified. Preserve the user's no-publication constraint. Continue the remaining File reads, then report the observed evidence.")}])
805 };
806 mock.push_message_response(serde_json::from_value(json!({
807 "id":format!("summary-{checkpoint}"), "type":"message", "role":"assistant",
808 "content":content, "model":"mock-model", "usage":{"input_tokens":0,"output_tokens":0}
809 })).unwrap());
810 }
811 let config = Config::default();
812 let (engine, handle) = Engine::new_with_model_client(
813 deterministic_engine_config(workspace.path()),
814 &config,
815 mock.clone(),
816 );
817 let task = tokio::spawn(engine.run());
818 let mut op = external_user_message_op(
819 "Complete all sixteen reads; do not publish.",
820 AppMode::Agent,
821 &config,
822 );
823 if let Op::SendMessage(TurnSpec {
824 compaction,
825 auto_approve,
826 ..
827 }) = &mut op
828 {
829 compaction.token_threshold = 40_000;
830 *auto_approve = true;
831 }
832 handle.send(op).await.unwrap();
833 let mut completed = 0;
834 let mut failed = 0;
835 {
836 let mut rx = handle.rx_event.write().await;
837 loop {
838 match tokio::time::timeout(Duration::from_secs(30), rx.recv())
839 .await
840 .unwrap()
841 .unwrap()
842 {
843 Event::CompactionCompleted { auto: true, .. } => completed += 1,
844 Event::CompactionFailed { auto: true, .. } => failed += 1,
845 Event::TurnComplete { status, error, .. } => {
846 assert_eq!(status, TurnOutcomeStatus::Completed, "{error:?}");
847 break;
848 }
849 _ => {}
850 }
851 }
852 }
853 let requests = mock.captured_requests();
854 let streaming = requests
855 .iter()
856 .filter(|r| r.stream == Some(true))
857 .collect::<Vec<_>>();
858 assert_eq!(
859 streaming.len(),
860 17,
861 "one user request must continue through all tool steps"
862 );
863 if fail_summary {
864 assert_eq!(
865 (completed, failed),
866 (0, 1),
867 "failed compaction must not loop at every tool boundary"
868 );
869 } else {
870 assert!(
871 (2..=6).contains(&completed),
872 "expected repeated useful compaction: {completed}"
873 );
874 assert_eq!(failed, 0);
875 }
876 for request in &requests {
877 assert_eq!(
878 request.system, streaming[0].system,
879 "the stable system prefix must survive every pass"
880 );
881 assert_eq!(
882 request.tools, streaming[0].tools,
883 "summarizing must reuse the tool prefix"
884 );
885 if request.stream == Some(false) {
886 assert_eq!(request.tool_choice, Some(json!("none")));
887 }
888 let mut calls = HashSet::new();
889 for message in &request.messages {
890 for block in &message.content {
891 match block {
892 ContentBlock::ToolUse { id, .. } => {
893 calls.insert(id);
894 }
895 ContentBlock::ToolResult { tool_use_id, .. } => assert!(
896 calls.contains(tool_use_id),
897 "orphan tool result after compaction"
898 ),
899 _ => {}
900 }
901 }
902 }
903 }
904 let snapshot = handle.get_session_snapshot().await.unwrap();
905 assert!(snapshot.messages.iter().any(|m| m.content.iter().any(|b| matches!(b, ContentBlock::Text {text,..} if text.contains("All sixteen reads verified")))));
906 handle.send(Op::Shutdown).await.unwrap();
907 task.await.unwrap();
908 }
909 });
910 }
911
912 #[tokio::test]
913 async fn initial_routed_usage_is_total_only_emitted_once_and_keeps_parent_route_separate() {
914 use crate::llm_client::mock::{MockLlmClient, canned};
915
916 let _cost_scope = crate::cost_status::test_scope();
917 let workspace = tempdir().expect("tempdir");
918 let parent_usage = Usage {
919 input_tokens: 11,
920 output_tokens: 3,
921 ..Usage::default()
922 };
923 let classifier_usage = Usage {
924 input_tokens: 7,
925 output_tokens: 5,
926 ..Usage::default()
927 };
928 let mock = std::sync::Arc::new(MockLlmClient::new(vec![vec![
929 canned::message_start("parent-response"),
930 canned::text_block_start(0),
931 canned::text_delta(0, "done"),
932 canned::block_stop(0),
933 canned::message_delta("end_turn", Some(parent_usage.clone())),
934 canned::message_stop(),
935 ]]));
936 let client: crate::core::model_client::SharedModelClient = mock;
937 let api_config = Config::default();
938 let (engine, handle) = Engine::new_with_model_client(
939 deterministic_engine_config(workspace.path()),
940 &api_config,
941 client,
942 );
943 let task = tokio::spawn(engine.run());
944
945 let mut op = external_user_message_op("account for classifier", AppMode::Agent, &api_config);
946 let Op::SendMessage(TurnSpec {
947 initial_routed_usage,
948 ..
949 }) = &mut op
950 else {
951 unreachable!("external_user_message_op always builds SendMessage");
952 };
953 let mut missing_usage_route = crate::cost_status::EffectiveRouteEnvelope::capture(
954 None,
955 ProviderKind::Openai,
956 "openai",
957 "classifier-model",
958 Some(ProviderKind::Openai.provider().default_base_url()),
959 chrono::Utc::now(),
960 );
961 missing_usage_route.billing_mode = crate::cost_status::RouteBillingMode::Metered;
962 **initial_routed_usage = crate::cost_status::RuntimeUsageBatch {
963 decisions: Vec::new(),
964 records: vec![crate::cost_status::RuntimeUsageRecord {
965 source_id: "auto-router:engine-fixture".to_string(),
966 usage: crate::cost_status::EffectiveRouteUsage {
967 route: crate::cost_status::EffectiveRouteEnvelope::capture(
968 None,
969 ProviderKind::Openai,
970 "openai",
971 "classifier-model",
972 Some(ProviderKind::Openai.provider().default_base_url()),
973 chrono::Utc::now(),
974 ),
975 usage: classifier_usage.clone(),
976 },
977 }],
978 drop_records: vec![crate::cost_status::RuntimeUsageDropRecord {
979 reason: crate::cost_status::RuntimeUsageMissingReason::default(),
980 source_id: "auto-router:engine-fixture:missing-usage".to_string(),
981 route: missing_usage_route,
982 }],
983 // One exact route-aware missing receipt plus two residual gaps whose
984 // route identity was truncated upstream.
985 dropped_records: 3,
986 };
987 handle.send(op).await.expect("send routed-usage turn");
988
989 let mut routed_events = Vec::new();
990 let mut rx = handle.rx_event.write().await;
991 let (total_usage, terminal_parent_usage, dropped_records) = loop {
992 let event = tokio::time::timeout(model_turn_event_timeout(), rx.recv())
993 .await
994 .expect("timed out waiting for routed-usage turn")
995 .expect("engine event stream closed");
996 match event {
997 Event::RoutedTurnUsage { usage, .. } => routed_events.push(usage),
998 Event::TurnComplete {
999 usage,
1000 parent_route_usage,
1001 routed_usage_dropped_records,
1002 status,
1003 error,
1004 ..
1005 } => {
1006 assert_eq!(status, TurnOutcomeStatus::Completed, "{error:?}");
1007 break (usage, parent_route_usage, routed_usage_dropped_records);
1008 }
1009 _ => {}
1010 }
1011 };
1012 drop(rx);
1013
1014 assert_eq!(routed_events, vec![classifier_usage.clone()]);
1015 assert_eq!(terminal_parent_usage, parent_usage);
1016 assert_eq!(total_usage.input_tokens, 18);
1017 assert_eq!(total_usage.output_tokens, 8);
1018 assert_eq!(dropped_records, 2);
1019 let initial_cost = crate::cost_status::drain();
1020 assert!(
1021 initial_cost.usage_source_fingerprints.contains(
1022 &crate::cost_status::usage_source_fingerprint(
1023 "auto-router:engine-fixture:missing-usage"
1024 )
1025 ),
1026 "exact missing-usage response identity was not settled"
1027 );
1028 assert!(
1029 initial_cost
1030 .unpriced_reasons
1031 .contains("provider_success_missing_usage"),
1032 "metered missing-usage route was not marked incomplete"
1033 );
1034
1035 handle.send(Op::Shutdown).await.expect("shutdown engine");
1036 task.await.expect("engine task");
1037 }
1038
1039 #[tokio::test]
1040 async fn isolated_runtime_chat_provider_request_contains_no_host_context_or_tools() {
1041 use crate::llm_client::mock::{MockLlmClient, canned};
1042
1043 const PROJECT_CANARY: &str = "PRIVATE_PROJECT_AGENTS_CANARY";
1044 const MEMORY_CANARY: &str = "PRIVATE_USER_MEMORY_CANARY";
1045 const SKILL_CANARY: &str = "PRIVATE_SKILL_CANARY";
1046 const MCP_CANARY: &str = "PRIVATE_MCP_CANARY";
1047 const INSTRUCTION_CANARY: &str = "PRIVATE_INSTRUCTION_CANARY";
1048 const ATTACHMENT_CANARY: &str = "PRIVATE_ATTACHMENT_BYTES_CANARY";
1049
1050 let root = tempdir().expect("private Runtime Chat root");
1051 let workspace = root.path().join("private-host-workspace-canary");
1052 let skills = root.path().join("private-skills-canary");
1053 fs::create_dir_all(&workspace).expect("create private workspace");
1054 fs::create_dir_all(&skills).expect("create private skills");
1055 fs::write(workspace.join("AGENTS.md"), PROJECT_CANARY).expect("write AGENTS canary");
1056 let memory = root.path().join("private-memory.md");
1057 fs::write(&memory, MEMORY_CANARY).expect("write memory canary");
1058 fs::write(skills.join("SKILL.md"), SKILL_CANARY).expect("write skill canary");
1059 let mcp = root.path().join("private-mcp.json");
1060 fs::write(&mcp, format!(r#"{{"server":"{MCP_CANARY}"}}"#)).expect("write MCP canary");
1061 let instructions = root.path().join("private-instructions.md");
1062 fs::write(&instructions, INSTRUCTION_CANARY).expect("write instruction canary");
1063 let attachment = root.path().join("private-attachment.png");
1064 fs::write(&attachment, ATTACHMENT_CANARY).expect("write attachment canary");
1065
1066 let api_config = Config {
1067 runtime_chat_isolated: true,
1068 ..Config::default()
1069 };
1070 let mut engine_config = deterministic_engine_config(&workspace);
1071 engine_config.instructions = vec![instructions.into()];
1072 engine_config.project_context_pack_enabled = true;
1073 engine_config.memory_enabled = true;
1074 engine_config.memory_path = memory;
1075 engine_config.skills_dir = skills;
1076 engine_config.mcp_config_path = mcp;
1077 engine_config.subagents_enabled = true;
1078 engine_config.allowed_tools = None;
1079
1080 let mock = std::sync::Arc::new(MockLlmClient::new(vec![canned::simple_text_turn(
1081 "Hello from isolated Chat.",
1082 )]));
1083 let client: crate::core::model_client::SharedModelClient = mock.clone();
1084 let (engine, handle) = Engine::new_with_model_client(engine_config, &api_config, client);
1085 let task = tokio::spawn(engine.run());
1086 handle
1087 .send(external_user_message_op(
1088 &format!("Say hello.\n[Attached image: {}]", attachment.display()),
1089 AppMode::Agent,
1090 &api_config,
1091 ))
1092 .await
1093 .expect("send isolated Chat turn");
1094
1095 let mut rx = handle.rx_event.write().await;
1096 loop {
1097 let event = tokio::time::timeout(model_turn_event_timeout(), rx.recv())
1098 .await
1099 .expect("isolated Chat turn timed out")
1100 .expect("isolated Chat event stream closed");
1101 if let Event::TurnComplete { status, error, .. } = event {
1102 assert_eq!(status, TurnOutcomeStatus::Completed, "{error:?}");
1103 break;
1104 }
1105 }
1106
1107 let request = mock.last_request().expect("captured provider request");
1108 assert!(
1109 request.tools.as_ref().is_none_or(Vec::is_empty),
1110 "isolated Chat must expose no provider tools"
1111 );
1112 // #6517: the engine and the Runtime Chat relay once carried two different
1113 // isolated-chat prompts. The engine sends the one shared constant; the
1114 // relay's own test pins `dedicated_chat_system_prompt(None)` to the same
1115 // constant, so core tests need no edge into the relay (runtime ratchet).
1116 let system = match request.system.as_ref() {
1117 Some(SystemPrompt::Text(text)) => text.clone(),
1118 other => panic!("isolated Chat should send one text system prompt: {other:?}"),
1119 };
1120 assert_eq!(system, ISOLATED_CHAT_SYSTEM_PROMPT);
1121 let serialized = serde_json::to_string(&request).expect("serialize captured request");
1122 assert!(serialized.contains("Say hello."), "{serialized}");
1123 assert!(serialized.contains("Attachment omitted"), "{serialized}");
1124 for forbidden in [
1125 workspace.to_string_lossy().as_ref(),
1126 root.path().to_string_lossy().as_ref(),
1127 PROJECT_CANARY,
1128 MEMORY_CANARY,
1129 SKILL_CANARY,
1130 MCP_CANARY,
1131 INSTRUCTION_CANARY,
1132 ATTACHMENT_CANARY,
1133 "<turn_meta>",
1134 "<user_memory>",
1135 "<available_skills>",
1136 "Git workspace:",
1137 ] {
1138 assert!(
1139 !serialized.contains(forbidden),
1140 "leaked {forbidden}: {serialized}"
1141 );
1142 }
1143
1144 task.abort();
1145 }
1146
1147 #[tokio::test]
1148 async fn injected_model_drives_real_engine_navigation_trajectory() {
1149 use crate::llm_client::mock::{MockLlmClient, canned};
1150
1151 let workspace = tempdir().expect("tempdir");
1152 fs::write(
1153 workspace.path().join("README.md"),
1154 "navigation-seam-proof\n",
1155 )
1156 .expect("write fixture");
1157 let mock = std::sync::Arc::new(MockLlmClient::new(vec![
1158 canned::tool_call_turn(
1159 "call-read",
1160 "File",
1161 r#"{"action":"read","path":"README.md"}"#,
1162 ),
1163 canned::simple_text_turn("Navigation complete."),
1164 ]));
1165 let client: crate::core::model_client::SharedModelClient = mock.clone();
1166 let (engine, handle) = Engine::new_with_model_client(
1167 deterministic_engine_config(workspace.path()),
1168 &Config::default(),
1169 client,
1170 );
1171 let task = tokio::spawn(engine.run());
1172 handle
1173 .send(external_user_message_op(
1174 "Read README.md and report what it contains.",
1175 AppMode::Agent,
1176 &Config::default(),
1177 ))
1178 .await
1179 .expect("send deterministic navigation turn");
1180
1181 let mut saw_read = false;
1182 let mut saw_answer = false;
1183 let mut saw_unreceipted_injected_route = false;
1184 let mut saw_unattributed_injected_completion = false;
1185 let mut rx = handle.rx_event.write().await;
1186 while let Some(event) = tokio::time::timeout(model_turn_event_timeout(), rx.recv())
1187 .await
1188 .expect("timed out waiting for deterministic navigation")
1189 {
1190 match event {
1191 Event::TurnStarted { route, .. } => {
1192 let route = route.expect("injected model turn route");
1193 assert!(
1194 route.receipt.is_none(),
1195 "an auxiliary route client must not receipt injected model I/O"
1196 );
1197 saw_unreceipted_injected_route = true;
1198 }
1199 Event::ToolCallComplete { name, result, .. } if name == "File" => {
1200 let result = result.expect("File.read result");
1201 assert!(result.success, "{result:?}");
1202 assert!(result.content.contains("navigation-seam-proof"));
1203 saw_read = true;
1204 }
1205 Event::MessageDelta { content, .. } => {
1206 saw_answer |= content.contains("Navigation complete");
1207 }
1208 Event::TurnComplete {
1209 status,
1210 error,
1211 base_url,
1212 ..
1213 } => {
1214 assert_eq!(status, TurnOutcomeStatus::Completed, "{error:?}");
1215 assert!(
1216 base_url.is_none(),
1217 "an auxiliary route client must not attribute an injected completion"
1218 );
1219 saw_unattributed_injected_completion = true;
1220 break;
1221 }
1222 _ => {}
1223 }
1224 }
1225 drop(rx);
1226 assert!(
1227 saw_read,
1228 "real registry must execute the mock-requested read"
1229 );
1230 assert!(
1231 saw_answer,
1232 "real stream projection must emit the final answer"
1233 );
1234 assert!(saw_unreceipted_injected_route);
1235 assert!(saw_unattributed_injected_completion);
1236 assert_eq!(mock.call_count(), 2);
1237 handle.send(Op::Shutdown).await.expect("shutdown engine");
1238 task.await.expect("engine task");
1239 }
1240
1241 #[tokio::test]
1242 async fn injected_model_sandbox_escalation_applies_only_after_exact_call_approval() {
1243 use crate::llm_client::mock::{MockLlmClient, canned};
1244
1245 const COMMAND: &str = "echo elevated > escalation.txt";
1246 for (tool, source) in [
1247 ("bash", json!({"command": COMMAND})),
1248 (
1249 CODE_EXECUTION_TOOL_NAME,
1250 json!({"code": "open('escalation.txt', 'w').write('approved')"}),
1251 ),
1252 (
1253 JS_EXECUTION_TOOL_NAME,
1254 json!({"code": "require('fs').writeFileSync('escalation.txt', 'approved')"}),
1255 ),
1256 ] {
1257 let mut input = source;
1258 input["sandbox_permissions"] = json!("workspace-write");
1259 input["justification"] = json!("the exact command writes the requested workspace proof");
1260 let workspace = tempdir().expect("tempdir");
1261 let mock = std::sync::Arc::new(MockLlmClient::new(vec![
1262 canned::tool_call_turn("call-escalated-bash", tool, &input.to_string()),
1263 canned::simple_text_turn("Escalated command complete."),
1264 ]));
1265 let client: crate::core::model_client::SharedModelClient = mock.clone();
1266 let config = Config {
1267 sandbox_mode: Some("read-only".to_string()),
1268 ..Config::default()
1269 };
1270 let mut engine_config = deterministic_engine_config(workspace.path());
1271 engine_config.exec_policy_engine = ask_rule_engine(COMMAND);
1272 let (engine, handle) = Engine::new_with_model_client(engine_config, &config, client);
1273 let task = tokio::spawn(engine.run());
1274 handle
1275 .send(external_user_message_op(
1276 "Create the escalation proof after approval.",
1277 AppMode::Agent,
1278 &config,
1279 ))
1280 .await
1281 .expect("send escalation journey");
1282
1283 let mut approved_result = None;
1284 let mut rx = handle.rx_event.write().await;
1285 loop {
1286 let event = tokio::time::timeout(model_turn_event_timeout(), rx.recv())
1287 .await
1288 .expect("timed out waiting for escalation journey")
1289 .expect("engine event stream closed");
1290 match event {
1291 Event::ApprovalRequired {
1292 id,
1293 input,
1294 description,
1295 ..
1296 } => {
1297 assert_eq!(input["sandbox_permissions"], "workspace-write");
1298 assert!(
1299 description
1300 .contains("the exact command writes the requested workspace proof"),
1301 "{description}"
1302 );
1303 if tool == "bash" {
1304 assert!(
1305 description.contains("Additional approval gate"),
1306 "the sandbox grant must not hide the typed ask rule: {description}"
1307 );
1308 assert!(
1309 description.contains("Typed ask rule"),
1310 "the typed ask rule must not hide the sandbox grant: {description}"
1311 );
1312 }
1313 assert!(
1314 !workspace.path().join("escalation.txt").exists(),
1315 "approval must happen before execution"
1316 );
1317 handle
1318 .approve_tool_call(&id)
1319 .await
1320 .expect("approve exact escalated call");
1321 }
1322 Event::ToolCallComplete {
1323 model_call: Some(model_call),
1324 result,
1325 ..
1326 } if model_call.provider_id == "call-escalated-bash" => {
1327 approved_result = Some(result.expect("approved escalation result"));
1328 }
1329 Event::TurnComplete { status, error, .. } => {
1330 assert_eq!(status, TurnOutcomeStatus::Completed, "{error:?}");
1331 break;
1332 }
1333 _ => {}
1334 }
1335 }
1336 drop(rx);
1337
1338 let result = approved_result.expect("paired escalated tool result");
1339 assert!(result.success, "{result:?}");
1340 assert!(
1341 result
1342 .content
1343 .contains("approved by the user with an adjusted execution policy"),
1344 "{}",
1345 result.content
1346 );
1347 assert!(workspace.path().join("escalation.txt").exists());
1348 assert_eq!(mock.call_count(), 2);
1349 handle.send(Op::Shutdown).await.expect("shutdown engine");
1350 task.await.expect("engine task");
1351 }
1352 }
1353
1354 #[tokio::test]
1355 async fn sandbox_escalation_fails_closed_when_the_posture_cannot_prompt() {
1356 use crate::llm_client::mock::{MockLlmClient, canned};
1357
1358 for (approval_mode, auto_approve, posture, expected_denial) in [
1359 (
1360 ApprovalMode::Auto,
1361 false,
1362 "Auto-Review",
1363 "Sandbox escalation requires a one-shot user approval",
1364 ),
1365 (
1366 ApprovalMode::Suggest,
1367 true,
1368 "Full Access",
1369 "requires a one-shot user approval",
1370 ),
1371 ] {
1372 for tool in ["bash", CODE_EXECUTION_TOOL_NAME, JS_EXECUTION_TOOL_NAME] {
1373 let mut input = if tool == CODE_EXECUTION_TOOL_NAME {
1374 json!({"code": "open('escalation.txt', 'w').write('denied')"})
1375 } else if tool == JS_EXECUTION_TOOL_NAME {
1376 json!({"code": "require('fs').writeFileSync('escalation.txt', 'denied')"})
1377 } else {
1378 json!({"command": "echo denied > escalation.txt"})
1379 };
1380 input["sandbox_permissions"] = json!("workspace-write");
1381 input["justification"] = json!("this execution needs workspace write access");
1382 let workspace = tempdir().expect("tempdir");
1383 let mock = std::sync::Arc::new(MockLlmClient::new(vec![
1384 canned::tool_call_turn("call-unattended-escalation", tool, &input.to_string()),
1385 canned::simple_text_turn("Escalation was unavailable."),
1386 ]));
1387 if matches!(approval_mode, ApprovalMode::Auto) {
1388 // Let Auto-Review's independent guardian approve the bounded
1389 // fixture call so this test reaches the separate rule under test:
1390 // unattended postures still cannot mint a sandbox escalation.
1391 mock.push_message_response(guardian_fixture_response(
1392 r#"{"risk_level":"low","decision":"allow","reason":"isolated fixture write"}"#,
1393 ));
1394 }
1395 let client: crate::core::model_client::SharedModelClient = mock.clone();
1396 let config = Config {
1397 sandbox_mode: Some("read-only".to_string()),
1398 ..Config::default()
1399 };
1400 let (engine, handle) = Engine::new_with_model_client(
1401 deterministic_engine_config(workspace.path()),
1402 &config,
1403 client,
1404 );
1405 let task = tokio::spawn(engine.run());
1406 let mut op = external_user_message_op(
1407 "Do not pause for an unattended escalation.",
1408 AppMode::Agent,
1409 &config,
1410 );
1411 let Op::SendMessage(TurnSpec {
1412 approval_mode: op_approval_mode,
1413 auto_approve: op_auto_approve,
1414 ..
1415 }) = &mut op
1416 else {
1417 panic!("user message op")
1418 };
1419 *op_approval_mode = approval_mode;
1420 *op_auto_approve = auto_approve;
1421 handle.send(op).await.expect("send unattended escalation");
1422
1423 let mut saw_denial = false;
1424 let mut rx = handle.rx_event.write().await;
1425 loop {
1426 let event = tokio::time::timeout(model_turn_event_timeout(), rx.recv())
1427 .await
1428 .expect("timed out waiting for unattended escalation")
1429 .expect("engine event stream closed");
1430 match event {
1431 Event::ApprovalRequired { .. } => {
1432 panic!("{posture} must not open an escalation prompt")
1433 }
1434 Event::ToolCallComplete {
1435 model_call: Some(model_call),
1436 result,
1437 ..
1438 } if model_call.provider_id == "call-unattended-escalation" => {
1439 let error = result.expect_err("unattended escalation must be denied");
1440 assert!(error.to_string().contains(expected_denial), "{error}");
1441 assert!(error.to_string().contains(posture), "{error}");
1442 saw_denial = true;
1443 }
1444 Event::TurnComplete { status, error, .. } => {
1445 assert_eq!(status, TurnOutcomeStatus::Completed, "{error:?}");
1446 break;
1447 }
1448 _ => {}
1449 }
1450 }
1451 drop(rx);
1452
1453 assert!(saw_denial);
1454 assert!(!workspace.path().join("escalation.txt").exists());
1455 handle.send(Op::Shutdown).await.expect("shutdown engine");
1456 task.await.expect("engine task");
1457 }
1458 }
1459 }
1460
1461 #[tokio::test]
1462 async fn productive_tool_results_do_not_hit_no_user_input_backstop() {
1463 use crate::llm_client::mock::{MockLlmClient, canned};
1464
1465 const TOOL_ROUNDS: usize = 201;
1466 const FINAL_ANSWER: &str = "All productive tool rounds completed.";
1467
1468 let workspace = tempdir().expect("tempdir");
1469 let mut turns = Vec::with_capacity(TOOL_ROUNDS + 1);
1470 for index in 1..=TOOL_ROUNDS {
1471 let fixture = format!("fixture-{index}.txt");
1472 fs::write(
1473 workspace.path().join(&fixture),
1474 format!("productive-round-{index}\n"),
1475 )
1476 .expect("write distinct read fixture");
1477 turns.push(canned::tool_call_turn(
1478 &format!("call-read-{index}"),
1479 "File",
1480 &format!(r#"{{"action":"read","path":"{fixture}"}}"#),
1481 ));
1482 }
1483 turns.push(canned::simple_text_turn(FINAL_ANSWER));
1484
1485 let mock = std::sync::Arc::new(MockLlmClient::new(turns));
1486 let client: crate::core::model_client::SharedModelClient = mock.clone();
1487 let (engine, handle) = Engine::new_with_model_client(
1488 deterministic_engine_config(workspace.path()),
1489 &Config::default(),
1490 client,
1491 );
1492 let task = tokio::spawn(engine.run());
1493 handle
1494 .send(external_user_message_op(
1495 "Read every distinct fixture, then report completion.",
1496 AppMode::Agent,
1497 &Config::default(),
1498 ))
1499 .await
1500 .expect("send productive tool trajectory");
1501
1502 let mut successful_tool_ids = HashSet::new();
1503 let mut saw_final_answer = false;
1504 let mut rx = handle.rx_event.write().await;
1505 while let Some(event) = tokio::time::timeout(model_turn_event_timeout(), rx.recv())
1506 .await
1507 .expect("timed out waiting for productive tool trajectory")
1508 {
1509 match event {
1510 Event::ToolCallComplete {
1511 id, name, result, ..
1512 } if name == "File" => {
1513 let result = result.expect("File.read result");
1514 assert!(result.success, "{id}: {result:?}");
1515 assert!(
1516 successful_tool_ids.insert(id.clone()),
1517 "tool id completed twice: {id}"
1518 );
1519 }
1520 Event::MessageDelta { content, .. } => {
1521 saw_final_answer |= content.contains(FINAL_ANSWER);
1522 }
1523 Event::TurnComplete { status, error, .. } => {
1524 assert_eq!(status, TurnOutcomeStatus::Completed, "{error:?}");
1525 assert_eq!(
1526 successful_tool_ids.len(),
1527 TOOL_ROUNDS,
1528 "turn completed before all productive tool rounds"
1529 );
1530 assert_eq!(
1531 mock.call_count(),
1532 TOOL_ROUNDS + 1,
1533 "productive work must finish beyond the former 200-step default"
1534 );
1535 assert!(
1536 saw_final_answer,
1537 "turn completed before the final assistant text arrived"
1538 );
1539 break;
1540 }
1541 _ => {}
1542 }
1543 }
1544 drop(rx);
1545
1546 assert_eq!(mock.remaining_turns(), 0, "the final turn must be consumed");
1547 handle.send(Op::Shutdown).await.expect("shutdown engine");
1548 task.await.expect("engine task");
1549 }
1550
1551 #[tokio::test]
1552 async fn eight_identical_sequential_reads_all_execute_before_final_answer() {
1553 use crate::llm_client::mock::{MockLlmClient, canned};
1554
1555 const READ_ROUNDS: usize = 8;
1556 const FINAL_ANSWER: &str = "Repeated observation complete.";
1557
1558 let workspace = tempdir().expect("tempdir");
1559 fs::write(workspace.path().join("state.txt"), "stable-observation\n")
1560 .expect("write repeated-read fixture");
1561 let mut turns = Vec::with_capacity(READ_ROUNDS + 1);
1562 for index in 1..=READ_ROUNDS {
1563 turns.push(canned::tool_call_turn(
1564 &format!("call-identical-read-{index}"),
1565 "File",
1566 r#"{"action":"read","path":"state.txt"}"#,
1567 ));
1568 }
1569 turns.push(canned::simple_text_turn(FINAL_ANSWER));
1570
1571 let mock = std::sync::Arc::new(MockLlmClient::new(turns));
1572 let client: crate::core::model_client::SharedModelClient = mock.clone();
1573 let (engine, handle) = Engine::new_with_model_client(
1574 deterministic_engine_config(workspace.path()),
1575 &Config::default(),
1576 client,
1577 );
1578 let task = tokio::spawn(engine.run());
1579 handle
1580 .send(external_user_message_op(
1581 "Read state.txt until you have enough evidence, then report completion.",
1582 AppMode::Agent,
1583 &Config::default(),
1584 ))
1585 .await
1586 .expect("send repeated-read trajectory");
1587
1588 let mut completed_ids = HashSet::new();
1589 let mut saw_final_answer = false;
1590 let mut rx = handle.rx_event.write().await;
1591 while let Some(event) = tokio::time::timeout(model_turn_event_timeout(), rx.recv())
1592 .await
1593 .expect("timed out waiting for repeated-read trajectory")
1594 {
1595 match event {
1596 Event::ToolCallComplete {
1597 id, name, result, ..
1598 } if name == "File" => {
1599 let result = result.expect("File.read result");
1600 assert!(result.success, "{id}: {result:?}");
1601 assert!(result.content.contains("stable-observation"));
1602 assert!(completed_ids.insert(id), "tool id completed twice");
1603 }
1604 Event::MessageDelta { content, .. } => {
1605 saw_final_answer |= content.contains(FINAL_ANSWER);
1606 }
1607 Event::TurnComplete { status, error, .. } => {
1608 assert_eq!(status, TurnOutcomeStatus::Completed, "{error:?}");
1609 assert_eq!(completed_ids.len(), READ_ROUNDS);
1610 assert_eq!(mock.call_count(), READ_ROUNDS + 1);
1611 assert!(saw_final_answer);
1612 break;
1613 }
1614 _ => {}
1615 }
1616 }
1617 drop(rx);
1618
1619 assert_eq!(mock.remaining_turns(), 0);
1620 handle.send(Op::Shutdown).await.expect("shutdown engine");
1621 task.await.expect("engine task");
1622 }
1623
1623 lines RUST