返回 CodeWhale
test_cases_06.rs
根目录 / crates / tui / src / core / engine / tests / test_cases_06.rs
1 /// A repeated provider ID is a different host execution each time. The FIFO
2 /// child waiter proves the stale answer was consumed while the second Ask was
3 /// pending, without relying on a sleep or a second execution racing an assert.
4 #[cfg(unix)]
5 #[test]
6 fn repeated_provider_id_ask_rejects_stale_answer_and_keeps_local_artifact_origin() {
7 use crate::approval_log::{ApprovalOutcome, ApprovalReceipt};
8 use crate::llm_client::mock::{MockLlmClient, canned};
9 use crate::tools::subagent::ChildApprovalOutcome;
10
11 with_artifact_home(|home| {
12 tokio::runtime::Builder::new_current_thread()
13 .enable_all()
14 .build()
15 .unwrap()
16 .block_on(async {
17 let command = "printf 'identity:%10000s:done\\n' x";
18 let arguments = json!({"command": command}).to_string();
19 let mock = Arc::new(MockLlmClient::new(vec![
20 canned::tool_call_turn("reused-ask", "bash", &arguments),
21 canned::tool_call_turn("reused-ask", "bash", &arguments),
22 canned::simple_text_turn("done"),
23 ]));
24 let mut config = Config::default();
25 let mut op = external_user_message_op(
26 "Run the same output fixture twice.",
27 AppMode::Agent,
28 &config,
29 );
30 let Op::SendMessage(turn) = &mut op else {
31 unreachable!("fixture creates a model turn");
32 };
33 // Declare both limits on the actual route. A 64K window alone
34 // still reserves the model's 64K output cap, leaving 1K input.
35 config.custom_models = Some(vec![
36 serde_json::from_value(json!({
37 "provider": turn.route.identity.key,
38 "base_url": turn.route.candidate.endpoint().base_url,
39 "id": turn.route.model,
40 "tool_call": true,
41 "limit": {"context": 64_000, "output": 4_096},
42 }))
43 .expect("fixture model limits"),
44 ]);
45 turn.route = resolved_route_for_test(&config, &turn.route.model);
46 let limits = turn.route.candidate.limits();
47 let provider = turn.route.identity.provider;
48 assert_eq!(limits.context_tokens, Some(64_000));
49 assert_eq!(limits.output_tokens, Some(4_096));
50 assert!(
51 context_input_budget_for_route(provider, &turn.route.model, Some(limits), 0)
52 .unwrap()
53 >= 58_880,
54 "the resolved input budget must fit the real instructions and two calls"
55 );
56 assert!(
57 crate::route_budget::route_inline_char_budget_for_route(
58 provider,
59 &turn.route.model,
60 Some(limits),
61 ) < 10_000,
62 "the unchanged output fixture must still require an artifact"
63 );
64 let mut engine_config = deterministic_engine_config(home);
65 engine_config.exec_policy_engine = ask_rule_engine(command);
66 let (engine, handle) =
67 Engine::new_with_model_client(engine_config, &config, mock.clone());
68 let shell_manager = engine.shell_manager.clone();
69 let child_manager = engine.subagent_manager.clone();
70 let session_id = engine.session.id.clone();
71 let receipt_store = engine.approval_receipt_store.clone().unwrap();
72 let task = tokio::spawn(engine.run());
73 handle.send(op).await.unwrap();
74
75 let mut starts = Vec::new();
76 let mut approvals = Vec::new();
77 let mut completions = Vec::new();
78 let mut artifact_paths = Vec::new();
79 let mut rx = handle.rx_event.write().await;
80 loop {
81 match tokio::time::timeout(model_turn_event_timeout(), rx.recv())
82 .await
83 .expect("the fixture turn must settle")
84 .expect("engine event stream")
85 {
86 Event::ToolCallStarted { id, model_call, .. } => {
87 assert_eq!(model_call.unwrap().provider_id, "reused-ask");
88 assert!(uuid::Uuid::parse_str(&id).is_ok());
89 assert!(!starts.contains(&id));
90 starts.push(id);
91 }
92 Event::ApprovalRequired { id, .. } => {
93 assert_eq!(starts.last(), Some(&id));
94 if let Some(first) = approvals.first() {
95 assert_ne!(first, &id);
96 assert_eq!(completions, approvals);
97 let (barrier_id, barrier) = child_manager
98 .write()
99 .await
100 .register_child_approval(
101 "agent_barrier",
102 &format!("agent:agent_barrier:approval:{}", uuid::Uuid::new_v4()),
103 "bash",
104 "approval-channel barrier",
105 )
106 .unwrap();
107 handle.approve_tool_call(first).await.unwrap();
108 handle.approve_tool_call(&barrier_id).await.unwrap();
109 assert_eq!(
110 tokio::time::timeout(model_turn_event_timeout(), barrier)
111 .await
112 .expect("queued decisions are consumed")
113 .unwrap(),
114 ChildApprovalOutcome::Approved
115 );
116 let receipts = receipt_store.load(&session_id).unwrap();
117 assert_eq!(receipts.len(), 3, "stale answer cannot decide the second Ask");
118 assert!(matches!(receipts.last(), Some(ApprovalReceipt::Asked { tool_call_id, .. }) if tool_call_id == &id));
119 let jobs = shell_manager.lock().unwrap().list_jobs_for_session(&session_id);
120 assert_eq!(jobs.len(), 1, "the second command is still unexecuted");
121 assert_eq!(jobs[0].origin_tool_call_id.as_ref(), Some(first));
122 }
123 handle.approve_tool_call(&id).await.unwrap();
124 approvals.push(id);
125 }
126 Event::ToolCallComplete { id, model_call, result, .. } => {
127 assert_eq!(model_call.unwrap().provider_id, "reused-ask");
128 assert_eq!(approvals.last(), Some(&id));
129 let output = result.expect("approved shell fixture succeeds");
130 assert!(output.success, "{output:?}");
131 let metadata = output.metadata.as_ref().expect("over-budget output is archived");
132 assert_eq!(metadata["artifact_id"], crate::artifacts::artifact_id_for_tool_call(&id));
133 let path = metadata["artifact_path"].as_str().unwrap().to_string();
134 assert_eq!(fs::read_to_string(&path).unwrap(), output.content);
135 assert!(!artifact_paths.contains(&path));
136 artifact_paths.push(path);
137 completions.push(id);
138 }
139 Event::TurnComplete { status, error, .. } => {
140 assert_eq!(status, TurnOutcomeStatus::Completed, "{error:?}");
141 break;
142 }
143 _ => {}
144 }
145 }
146 drop(rx);
147 assert_eq!(starts.len(), 2);
148 assert_eq!(starts, approvals);
149 assert_eq!(starts, completions);
150 let receipts = receipt_store.load(&session_id).unwrap();
151 assert_eq!(receipts.len(), 4);
152 for (pair, id) in receipts.as_chunks::<2>().0.iter().zip(&starts) {
153 assert!(matches!(&pair[0], ApprovalReceipt::Asked { approval_id, tool_call_id, .. } if approval_id == id && tool_call_id == id));
154 assert!(matches!(&pair[1], ApprovalReceipt::Decided { approval_id, tool_call_id, outcome: ApprovalOutcome::ApprovedOnce, .. } if approval_id == id && tool_call_id == id));
155 }
156 let origins = shell_manager
157 .lock()
158 .unwrap()
159 .list_jobs_for_session(&session_id)
160 .into_iter()
161 .map(|job| job.origin_tool_call_id.unwrap())
162 .collect::<HashSet<_>>();
163 assert_eq!(origins, starts.iter().cloned().collect());
164 let requests = mock.captured_requests();
165 assert_eq!(requests.len(), 3);
166 for id in &starts {
167 let pairs = requests[2].messages.iter().flat_map(|message| &message.content)
168 .filter(|block| block.tool_call_key() == Some(codewhale_models::ToolCallKey::Execution(id)))
169 .collect::<Vec<_>>();
170 assert_eq!(pairs.len(), 2);
171 assert!(matches!(pairs[0], ContentBlock::ToolUse { id, .. } if id == "reused-ask"));
172 assert!(matches!(pairs[1], ContentBlock::ToolResult { tool_use_id, content, .. } if tool_use_id == "reused-ask" && content.contains(&crate::artifacts::artifact_id_for_tool_call(id))));
173 }
174 handle.send(Op::Shutdown).await.unwrap();
175 task.await.unwrap();
176 });
177 });
178 }
179
180 /// #4415 AC(b): a 4-call parallel batch proposed with 2 calls remaining is
181 /// truncated to the first 2 calls in proposal order; the excess 2 are
182 /// rejected with the same typed reason, and the batch is counted in full.
183 #[tokio::test]
184 async fn tool_call_budget_truncates_an_over_budget_parallel_batch() {
185 use crate::llm_client::mock::{MockLlmClient, canned};
186
187 let workspace = tempdir().expect("tempdir");
188 let mut calls = Vec::new();
189 for index in 1..=4 {
190 let name = format!("fixture-{index}.txt");
191 fs::write(workspace.path().join(&name), format!("fixture-{index}\n"))
192 .expect("write fixture");
193 calls.push((
194 format!("call-{index}"),
195 "read_file".to_string(),
196 format!(r#"{{"path":"{name}"}}"#),
197 ));
198 }
199 let call_refs = calls
200 .iter()
201 .map(|(id, name, args)| (id.as_str(), name.as_str(), args.as_str()))
202 .collect::<Vec<_>>();
203 let mock = std::sync::Arc::new(MockLlmClient::new(vec![
204 tool_batch_turn(&call_refs),
205 canned::simple_text_turn("done"),
206 ]));
207
208 let (status, error, completions) =
209 run_budgeted_read_turn(workspace.path(), Some(2), mock.clone()).await;
210 assert_eq!(status, TurnOutcomeStatus::Completed, "{error:?}");
211 assert_eq!(mock.call_count(), 2, "batch turn then the final text turn");
212 assert_eq!(completions.len(), 4);
213
214 let mut admitted = 0;
215 for (id, result) in &completions {
216 match id.as_str() {
217 "call-1" | "call-2" => {
218 admitted += 1;
219 assert!(result.is_ok(), "{id} must execute: {result:?}");
220 }
221 "call-3" | "call-4" => {
222 let rejection = result.as_ref().expect_err("excess calls are rejected");
223 let reason = rejection.to_string();
224 assert!(reason.contains("budget of 2"), "{reason}");
225 assert!(reason.contains("remaining=0"), "{reason}");
226 }
227 other => panic!("unexpected call id {other}"),
228 }
229 }
230 assert_eq!(admitted, 2, "exactly the remaining 2 calls are admitted");
231 }
232
233 /// #4415: the budget is per-turn, not per-batch — a counter that survives
234 /// every model step of the turn. A full 8-call first batch leaves the next
235 /// step's single call with `remaining=0`.
236 #[tokio::test]
237 async fn tool_call_budget_persists_across_model_steps_within_a_turn() {
238 use crate::llm_client::mock::{MockLlmClient, canned};
239
240 let workspace = tempdir().expect("tempdir");
241 let mut first_batch = Vec::new();
242 for index in 1..=8 {
243 let name = format!("fixture-{index}.txt");
244 fs::write(workspace.path().join(&name), format!("fixture-{index}\n"))
245 .expect("write fixture");
246 first_batch.push((
247 format!("call-{index}"),
248 "read_file".to_string(),
249 format!(r#"{{"path":"{name}"}}"#),
250 ));
251 }
252 let first_refs = first_batch
253 .iter()
254 .map(|(id, name, args)| (id.as_str(), name.as_str(), args.as_str()))
255 .collect::<Vec<_>>();
256 fs::write(workspace.path().join("fixture-9.txt"), "fixture-9\n").expect("write fixture");
257 let mock = std::sync::Arc::new(MockLlmClient::new(vec![
258 tool_batch_turn(&first_refs),
259 canned::tool_call_turn("call-9", "read_file", r#"{"path":"fixture-9.txt"}"#),
260 canned::simple_text_turn("done"),
261 ]));
262
263 let (status, error, completions) =
264 run_budgeted_read_turn(workspace.path(), Some(8), mock.clone()).await;
265 assert_eq!(status, TurnOutcomeStatus::Completed, "{error:?}");
266 assert_eq!(
267 mock.call_count(),
268 3,
269 "two tool steps then the final text turn"
270 );
271 assert_eq!(completions.len(), 9);
272
273 let (id, ninth) = completions
274 .iter()
275 .find(|(id, _)| id == "call-9")
276 .expect("the ninth call still reports a completion");
277 assert_eq!(id, "call-9");
278 let reason = ninth
279 .as_ref()
280 .expect_err("ninth call exceeds the turn budget");
281 let reason = reason.to_string();
282 assert!(reason.contains("remaining=0"), "{reason}");
283 assert!(
284 completions
285 .iter()
286 .filter(|(id, result)| id != "call-9" && result.is_ok())
287 .count()
288 == 8,
289 "the first batch of 8 all executed: {completions:?}"
290 );
291 }
292
293 /// #5986: a provider that cuts the stream at its output limit omits the
294 /// closing `ContentBlockStop` for the tool block in flight. The mid-stream
295 /// mirror had already assigned the truncated buffer's best-effort parse
296 /// (the repair ladder appends the missing `}`), and dispatch reads
297 /// `tool.input` directly — so the cut call used to execute with a partial
298 /// argument. The post-stream finalization pass must send it down the same
299 /// malformed-arguments gate a normal block stop applies.
300 #[tokio::test]
301 async fn truncated_tool_call_without_block_stop_never_dispatches() {
302 use crate::llm_client::mock::{MockLlmClient, canned};
303
304 let workspace = tempdir().expect("tempdir");
305 fs::write(workspace.path().join("fixture.txt"), "fixture\n").expect("write fixture");
306 // Cut mid-argument, right after a complete string value: stage 4 of the
307 // repair ladder appends one `}` and the text parses — synthesized.
308 let cut_turn = vec![
309 canned::message_start("mock_msg_cut"),
310 canned::tool_use_block_start(0, "call-cut", "read_file"),
311 canned::tool_input_delta(0, r#"{"path": "fixture.txt""#),
312 // Deliberately no block_stop(0): the output limit ended the turn.
313 canned::message_delta("max_tokens", None),
314 canned::message_stop(),
315 ];
316 let mock = std::sync::Arc::new(MockLlmClient::new(vec![
317 cut_turn,
318 canned::simple_text_turn("done"),
319 ]));
320
321 let (status, error, completions) =
322 run_budgeted_read_turn(workspace.path(), None, mock.clone()).await;
323 assert_eq!(status, TurnOutcomeStatus::Completed, "{error:?}");
324
325 let (_, result) = completions
326 .iter()
327 .find(|(id, _)| id == "call-cut")
328 .expect("the cut tool call still reports a completion");
329 let reason = result
330 .as_ref()
331 .expect_err("a truncated tool call must never execute")
332 .to_string();
333 assert!(
334 reason.contains("malformed tool arguments"),
335 "expected the malformed-arguments gate, got: {reason}"
336 );
337 }
338
339 /// The control for the cut-stream pass: when the omitted block stop is the
340 /// only irregularity and the buffered arguments were structurally complete,
341 /// the tool still dispatches. Otherwise every provider that skips closing
342 /// events would lose all of its tool calls.
343 #[tokio::test]
344 async fn complete_tool_call_without_block_stop_still_dispatches() {
345 use crate::llm_client::mock::{MockLlmClient, canned};
346
347 let workspace = tempdir().expect("tempdir");
348 fs::write(workspace.path().join("fixture.txt"), "fixture\n").expect("write fixture");
349 let no_stop_turn = vec![
350 canned::message_start("mock_msg_nostop"),
351 canned::tool_use_block_start(0, "call-complete", "read_file"),
352 canned::tool_input_delta(0, r#"{"path": "fixture.txt"}"#),
353 canned::message_delta("tool_use", None),
354 canned::message_stop(),
355 ];
356 let mock = std::sync::Arc::new(MockLlmClient::new(vec![
357 no_stop_turn,
358 canned::simple_text_turn("done"),
359 ]));
360
361 let (status, error, completions) =
362 run_budgeted_read_turn(workspace.path(), None, mock.clone()).await;
363 assert_eq!(status, TurnOutcomeStatus::Completed, "{error:?}");
364
365 let (_, result) = completions
366 .iter()
367 .find(|(id, _)| id == "call-complete")
368 .expect("the tool call reports a completion");
369 let output = result
370 .as_ref()
371 .expect("structurally complete arguments still dispatch")
372 .content
373 .clone();
374 assert!(output.contains("fixture"), "{output}");
375 }
376
377 /// #4415 AC(c): a write-first named-file task carries a scoped-write
378 /// authority envelope naming its exact files. The existing allowed-paths
379 /// machinery (`ToolAuthorityEnvelope`, enforced at the registry boundary)
380 /// permits mutating a named file and denies mutating anything outside it
381 /// with a typed permission error, and the denied write never executes.
382 ///
383 /// Seam: the envelope is a MUTATION boundary only — `read_file` outside the
384 /// named files is NOT denied by policy today (read-only tools pass the
385 /// envelope by design). Denying out-of-scope reads for write-first tasks is
386 /// a #4415 follow-up; this test pins the current contract so the seam is
387 /// explicit rather than assumed.
388 #[tokio::test]
389 async fn named_file_write_scope_denies_mutation_outside_the_named_files() {
390 let workspace = tempdir().expect("tempdir");
391 fs::create_dir_all(workspace.path().join("src")).expect("src dir");
392 fs::create_dir_all(workspace.path().join("docs")).expect("docs dir");
393 fs::write(workspace.path().join("docs/other.md"), "outside\n").expect("write fixture");
394 let envelope = crate::tools::spec::ToolAuthorityEnvelope {
395 schema_version: 1,
396 owner: "test-worker".to_string(),
397 authority: crate::tools::spec::ToolMutationAuthority::ScopedWrite,
398 network_access: None,
399 shell: crate::tools::spec::ToolShellAuthority::None,
400 verification: crate::tools::spec::ToolVerificationAuthority::None,
401 writable_roots: Vec::new(),
402 writable_files: vec!["src/named.rs".to_string()],
403 coordination_contracts: Vec::new(),
404 };
405 let context = crate::tools::ToolContext::new(workspace.path().to_path_buf())
406 .with_tool_authority(envelope)
407 .expect("valid envelope");
408 let mut registry = crate::tools::ToolRegistry::new(context);
409 registry.register(std::sync::Arc::new(crate::tools::file::ReadFileTool));
410 registry.register(std::sync::Arc::new(crate::tools::file::WriteFileTool));
411
412 // The named file is writable under the envelope.
413 let named = registry
414 .execute_full(
415 "write_file",
416 json!({"path": "src/named.rs", "content": "fn named() {}\n"}),
417 )
418 .await
419 .expect("mutation of the named file is permitted");
420 assert!(named.success, "{named:?}");
421 assert!(workspace.path().join("src/named.rs").exists());
422
423 // A mutation outside the named files is denied by policy and never runs.
424 let denied = registry
425 .execute_full(
426 "write_file",
427 json!({"path": "docs/other.md", "content": "rewritten\n"}),
428 )
429 .await
430 .expect_err("mutation outside the named files is denied");
431 assert!(
432 denied.to_string().contains("authority envelope"),
433 "{denied}"
434 );
435 assert_eq!(
436 fs::read_to_string(workspace.path().join("docs/other.md")).expect("read back"),
437 "outside\n",
438 "the denied write must not have executed"
439 );
440
441 // Pin the read-side seam: a read outside the named files is allowed
442 // through the mutation-scoped envelope today.
443 let read = registry
444 .execute_full("read_file", json!({"path": "docs/other.md"}))
445 .await
446 .expect("reads are not path-scoped by the mutation envelope today");
447 assert!(read.content.contains("outside"), "{read:?}");
448 }
449
450 #[test]
451 fn empty_allowed_tools_surface_is_empty_and_sends_no_tools_field() {
452 let surface = policy_for_catalog(vec![catalog_tool("read_file")], Some(Vec::new()), None);
453
454 assert!(surface.catalog.is_empty());
455 assert!(surface.active_names.is_empty());
456 assert!(surface.active.is_none());
457 assert!(!surface.allows_tool("read_file"));
458 }
459
460 /// The turn-start capture carries mode/workspace/working-set state only. Work
461 /// used to be rendered here; it moved to the fork seam (#3983) because this
462 /// block is captured before the turn's first tool call. Fork-seam Work parity is
463 /// covered by `fork_state_block_reuses_the_canonical_work_body`.
464 #[test]
465 fn structured_state_block_carries_stable_state_without_work() {
466 let state = StructuredState {
467 mode_label: "Agent".to_string(),
468 workspace: PathBuf::from("/workspace/codewhale"),
469 cwd: Some(PathBuf::from("/workspace/codewhale")),
470 working_set_summary: None,
471 subagent_snapshots: Vec::new(),
472 };
473
474 let block = state.to_system_block().expect("fork state block");
475
476 assert!(block.contains("- Mode: `Agent`"));
477 assert!(!block.contains(crate::todo_snapshot::FORK_TODO_SECTION_HEADING));
478 assert!(!block.contains("To-do ("));
479 assert!(!block.contains("Strategy"));
480 }
481
482 #[test]
483 fn env_only_auth_error_gets_recovery_hint() {
484 let _guard = lock_test_env();
485 let _env = ScopedDeepSeekApiKey::set("stale-env-key");
486 let (engine, _handle) = Engine::new(EngineConfig::default(), &Config::default());
487
488 let message =
489 engine.decorate_auth_error_message("Authentication failed: invalid API key".to_string());
490
491 assert!(message.contains("DEEPSEEK_API_KEY"));
492 assert!(message.contains("no saved config key is present"));
493 assert!(message.contains("codewhale auth status"));
494 assert!(message.contains("codewhale auth set --provider deepseek"));
495 }
496
497 #[test]
498 fn config_auth_error_does_not_blame_env() {
499 let _guard = lock_test_env();
500 let _env = ScopedDeepSeekApiKey::set("stale-env-key");
501 let cfg = Config {
502 ..Config::default()
503 }
504 .with_legacy_root(Some("fresh-config-key".to_string()), None);
505 let (engine, _handle) = Engine::new(EngineConfig::default(), &cfg);
506
507 let message =
508 engine.decorate_auth_error_message("Authentication failed: invalid API key".to_string());
509
510 assert_eq!(message, "Authentication failed: invalid API key");
511 }
512
513 #[test]
514 fn plugin_tools_dir_honors_missing_custom_directory_without_fallback() {
515 let missing = PathBuf::from("definitely-missing-codewhale-plugin-dir");
516 let tools_config = crate::config::ToolsConfig {
517 plugin_dir: Some(missing.to_string_lossy().to_string()),
518 ..Default::default()
519 };
520
521 assert_eq!(plugin_tools_dir(Some(&tools_config)), missing);
522 }
523
524 #[test]
525 fn configure_plugin_tools_applies_overrides_after_discovered_plugins() {
526 let tmp = tempdir().expect("tempdir");
527 let plugin_dir = tmp.path().join("tools");
528 fs::create_dir(&plugin_dir).expect("plugin dir");
529 fs::write(
530 plugin_dir.join("same-name.sh"),
531 "# name: same_tool\n# description: discovered plugin\n",
532 )
533 .expect("plugin script");
534
535 let mut overrides = HashMap::new();
536 overrides.insert(
537 "same_tool".to_string(),
538 crate::config::ToolOverride::Command {
539 command: "configured-command".to_string(),
540 args: None,
541 },
542 );
543 // Everything registered before configuration is built in: D4 refuses a
544 // replacement for it.
545 overrides.insert(
546 "File".to_string(),
547 crate::config::ToolOverride::Command {
548 command: "configured-file".to_string(),
549 args: None,
550 },
551 );
552 let tools_config = crate::config::ToolsConfig {
553 plugin_dir: Some(plugin_dir.to_string_lossy().to_string()),
554 overrides: Some(overrides),
555 ..Default::default()
556 };
557
558 let ctx = crate::tools::ToolContext::new(tmp.path().to_path_buf());
559 let mut registry = crate::tools::ToolRegistryBuilder::new()
560 .with_file_tools()
561 .build(ctx);
562 let file = registry.get("File").expect("built-in File");
563
564 let (plugin_names, refused) = configure_plugin_tools(&mut registry, Some(&tools_config));
565
566 let tool = registry.get("same_tool").expect("same_tool registered");
567 assert!(tool.description().contains("configured-command"));
568 assert!(plugin_names.contains("same_tool"));
569 assert!(Arc::ptr_eq(
570 &registry.get("File").expect("File kept"),
571 &file
572 ));
573 assert!(!plugin_names.contains("File"));
574 // The refusal reaches the engine so it can name it to the user.
575 assert_eq!(refused, vec!["File".to_string()]);
576 assert!(
577 crate::tools::registry::override_refusal_notice("File").contains("[tools.overrides.File]")
578 );
579 }
580
581 fn make_plan(
582 read_only: bool,
583 supports_parallel: bool,
584 approval_required: bool,
585 interactive: bool,
586 ) -> ToolExecutionPlan {
587 make_plan_at(
588 0,
589 read_only,
590 supports_parallel,
591 approval_required,
592 interactive,
593 )
594 }
595
596 fn make_plan_at(
597 index: usize,
598 read_only: bool,
599 supports_parallel: bool,
600 approval_required: bool,
601 interactive: bool,
602 ) -> ToolExecutionPlan {
603 ToolExecutionPlan {
604 model_call: None,
605 index,
606 id: format!("tool-{index}"),
607 name: "grep_files".to_string(),
608 input: json!({"pattern": "test"}),
609 caller: None,
610 interactive,
611 approval_required,
612 approval_description: "desc".to_string(),
613 approval_force_prompt: false,
614 supports_parallel,
615 read_only,
616 detached_start: false,
617 resources: vec![ResourceClaim::ReadPath(PathBuf::from(format!(
618 "src-{index}.rs"
619 )))],
620 blocked_error: None,
621 guard_result: None,
622 }
623 }
624
625 fn parallel_batch_indices(batch: &ToolExecutionBatch) -> Vec<usize> {
626 match batch {
627 ToolExecutionBatch::Parallel(plans) => plans.iter().map(|plan| plan.index).collect(),
628 ToolExecutionBatch::Serial(_) => panic!("expected parallel batch"),
629 }
630 }
631
632 fn ask_rule_engine(command: &str) -> codewhale_execpolicy::ExecPolicyEngine {
633 codewhale_execpolicy::ExecPolicyEngine::with_rulesets(vec![
634 codewhale_execpolicy::Ruleset::user(vec![], vec![])
635 .with_ask_rules(vec![codewhale_execpolicy::ToolAskRule::exec_shell(command)]),
636 ])
637 }
638
639 fn file_ask_rule_engine(tool: &str, path: &str) -> codewhale_execpolicy::ExecPolicyEngine {
640 codewhale_execpolicy::ExecPolicyEngine::with_rulesets(vec![
641 codewhale_execpolicy::Ruleset::user(vec![], vec![]).with_ask_rules(vec![
642 codewhale_execpolicy::ToolAskRule::file_path(tool, path),
643 ]),
644 ])
645 }
646
647 fn model_turn_event_timeout() -> Duration {
648 if cfg!(windows) {
649 // The Windows CI runner executes the full TUI test binary with thousands of
650 // tests competing for CPU. Keep this high enough that an approval-gated
651 // model turn is not mistaken for a lifecycle failure under runner load.
652 Duration::from_secs(60)
653 } else {
654 Duration::from_secs(10)
655 }
656 }
657
658 fn resolved_route_for_test(
659 config: &Config,
660 model: &str,
661 ) -> Box<crate::route_runtime::ResolvedRuntimeRoute> {
662 Box::new(
663 resolve_runtime_route(
664 config,
665 config.active_provider_identity().unwrap().provider,
666 Some(model),
667 )
668 .expect("resolve test route"),
669 )
670 }
671
672 fn active_goal_message_op(
673 config: &Config,
674 content: &str,
675 objective: &str,
676 token_budget: Option<u32>,
677 ) -> Op {
678 Op::SendMessage(TurnSpec {
679 max_output_tokens: None,
680 content: content.to_string(),
681 images: Vec::new(),
682 mode: AppMode::Agent,
683 route: resolved_route_for_test(config, "local-model"),
684 compaction: Box::new(CompactionConfig::default()),
685 initial_routed_usage: Box::default(),
686 goal_objective: Some(objective.to_string()),
687 goal_token_budget: token_budget,
688 goal_status: crate::tools::goal::GoalStatus::Active,
689 reasoning_effort: None,
690 reasoning_effort_auto: false,
691 auto_model: false,
692 allow_shell: false,
693 trust_mode: false,
694 auto_approve: false,
695 approval_mode: ApprovalMode::Suggest,
696 translation_enabled: false,
697 allowed_tools: None,
698 dynamic_tools: Vec::new(),
699 hook_executor: None,
700 verbosity: None,
701 provenance: UserInputProvenance::ExternalUser,
702 submission_id: None,
703 })
704 }
705
706 fn system_prompt_text(prompt: SystemPrompt) -> String {
707 match prompt {
708 SystemPrompt::Text(text) => text,
709 SystemPrompt::Blocks(blocks) => blocks
710 .into_iter()
711 .map(|block| block.text)
712 .collect::<Vec<_>>()
713 .join("\n"),
714 }
715 }
716
717 fn external_user_message_op(content: &str, mode: AppMode, config: &Config) -> Op {
718 Op::SendMessage(TurnSpec {
719 max_output_tokens: None,
720 content: content.to_string(),
721 images: Vec::new(),
722 mode,
723 route: resolved_route_for_test(config, crate::config::DEFAULT_TEXT_MODEL),
724 compaction: Box::new(CompactionConfig::default()),
725 initial_routed_usage: Box::default(),
726 goal_objective: None,
727 goal_token_budget: None,
728 goal_status: crate::tools::goal::GoalStatus::Active,
729 reasoning_effort: None,
730 reasoning_effort_auto: false,
731 auto_model: false,
732 allow_shell: true,
733 trust_mode: false,
734 auto_approve: false,
735 approval_mode: ApprovalMode::Suggest,
736 translation_enabled: false,
737 allowed_tools: None,
738 dynamic_tools: Vec::new(),
739 hook_executor: None,
740 verbosity: None,
741 provenance: UserInputProvenance::ExternalUser,
742 submission_id: None,
743 })
744 }
745
746 fn auto_review_message_op(content: &str, config: &Config) -> Op {
747 Op::SendMessage(TurnSpec {
748 max_output_tokens: None,
749 content: content.to_string(),
750 images: Vec::new(),
751 mode: AppMode::Agent,
752 route: resolved_route_for_test(config, crate::config::DEFAULT_TEXT_MODEL),
753 compaction: Box::new(CompactionConfig::default()),
754 initial_routed_usage: Box::default(),
755 goal_objective: None,
756 goal_token_budget: None,
757 goal_status: crate::tools::goal::GoalStatus::Active,
758 reasoning_effort: None,
759 reasoning_effort_auto: false,
760 auto_model: false,
761 allow_shell: true,
762 trust_mode: false,
763 auto_approve: false,
764 approval_mode: ApprovalMode::Auto,
765 translation_enabled: false,
766 allowed_tools: None,
767 dynamic_tools: Vec::new(),
768 hook_executor: None,
769 verbosity: None,
770 provenance: UserInputProvenance::ExternalUser,
771 submission_id: None,
772 })
773 }
774
775 struct DropSignal(std::sync::Arc<std::sync::atomic::AtomicBool>);
776
777 impl Drop for DropSignal {
778 fn drop(&mut self) {
779 self.0.store(true, std::sync::atomic::Ordering::SeqCst);
780 }
781 }
782
783 struct BlockingModelClient {
784 entered: std::sync::Arc<tokio::sync::Notify>,
785 request_dropped: std::sync::Arc<std::sync::atomic::AtomicBool>,
786 }
787
788 struct BlockingGuardianModelClient {
789 guardian_entered: std::sync::Arc<tokio::sync::Notify>,
790 guardian_dropped: std::sync::Arc<std::sync::atomic::AtomicBool>,
791 streaming_calls: std::sync::atomic::AtomicUsize,
792 }
793
794 struct FailingGuardianModelClient {
795 inner: crate::llm_client::mock::MockLlmClient,
796 }
797
798 #[async_trait::async_trait]
799 impl crate::core::model_client::ModelClient for FailingGuardianModelClient {
800 fn provider_name(&self) -> &str {
801 self.inner.provider_name()
802 }
803
804 fn model(&self) -> &str {
805 self.inner.model()
806 }
807
808 async fn create_message(
809 &self,
810 _request: codewhale_models::MessageRequest,
811 ) -> anyhow::Result<codewhale_models::MessageResponse> {
812 anyhow::bail!("fixture guardian transport failure")
813 }
814
815 async fn create_message_stream(
816 &self,
817 request: codewhale_models::MessageRequest,
818 ) -> anyhow::Result<crate::llm_client::StreamEventBox> {
819 crate::core::model_client::ModelClient::create_message_stream(&self.inner, request).await
820 }
821
822 async fn health_check(&self) -> anyhow::Result<bool> {
823 Ok(true)
824 }
825 }
826
827 #[async_trait::async_trait]
828 impl crate::core::model_client::ModelClient for BlockingGuardianModelClient {
829 fn provider_name(&self) -> &str {
830 "deterministic-blocking-guardian"
831 }
832
833 fn model(&self) -> &str {
834 "deterministic-blocking-guardian-model"
835 }
836
837 async fn create_message(
838 &self,
839 _request: codewhale_models::MessageRequest,
840 ) -> anyhow::Result<codewhale_models::MessageResponse> {
841 let _drop_signal = DropSignal(std::sync::Arc::clone(&self.guardian_dropped));
842 self.guardian_entered.notify_one();
843 std::future::pending().await
844 }
845
846 async fn create_message_stream(
847 &self,
848 _request: codewhale_models::MessageRequest,
849 ) -> anyhow::Result<crate::llm_client::StreamEventBox> {
850 use crate::llm_client::mock::canned;
851
852 assert_eq!(
853 self.streaming_calls
854 .fetch_add(1, std::sync::atomic::Ordering::SeqCst),
855 0,
856 "cancellation must prevent a follow-up model request"
857 );
858 let events = canned::tool_call_turn(
859 "call-cancelled-guardian",
860 "File",
861 r#"{"action":"write","path":".env","content":"must-not-run\n"}"#,
862 );
863 Ok(Box::pin(futures_util::stream::iter(
864 events.into_iter().map(Ok),
865 )))
866 }
867
868 async fn health_check(&self) -> anyhow::Result<bool> {
869 Ok(true)
870 }
871 }
872
873 #[async_trait::async_trait]
874 impl crate::core::model_client::ModelClient for BlockingModelClient {
875 fn provider_name(&self) -> &str {
876 "deterministic-blocking"
877 }
878
879 fn model(&self) -> &str {
880 "deterministic-blocking-model"
881 }
882
883 async fn create_message(
884 &self,
885 _request: codewhale_models::MessageRequest,
886 ) -> anyhow::Result<codewhale_models::MessageResponse> {
887 std::future::pending().await
888 }
889
890 async fn create_message_stream(
891 &self,
892 _request: codewhale_models::MessageRequest,
893 ) -> anyhow::Result<crate::llm_client::StreamEventBox> {
894 let _drop_signal = DropSignal(std::sync::Arc::clone(&self.request_dropped));
895 self.entered.notify_one();
896 std::future::pending().await
897 }
898
899 async fn health_check(&self) -> anyhow::Result<bool> {
900 Ok(true)
901 }
902 }
903
904 fn test_tool_surface(
905 engine: &Engine,
906 registry: crate::tools::ToolRegistry,
907 tools: Option<Vec<codewhale_models::Tool>>,
908 mode: AppMode,
909 ) -> ToolSurfacePolicy {
910 ToolSurfacePolicy::new(
911 registry,
912 tools,
913 mode,
914 &engine.config.tools_always_load,
915 &[],
916 engine.config.strict_tool_mode,
917 engine.config.allowed_tools.clone(),
918 engine.config.disallowed_tools.clone(),
919 engine.config.max_tool_calls,
920 crate::core::engine::tool_catalog::ToolMode::Direct,
921 )
922 }
923
924 #[tokio::test]
925 async fn tool_request_snapshot_matches_the_exact_mock_request_payload() {
926 use crate::llm_client::mock::{MockLlmClient, canned};
927
928 let workspace = tempdir().expect("tempdir");
929 let mock = std::sync::Arc::new(MockLlmClient::new(vec![canned::simple_text_turn("Done.")]));
930 let client: crate::core::model_client::SharedModelClient = mock.clone();
931 let (mut engine, handle) = Engine::new_with_model_client(
932 deterministic_engine_config(workspace.path()),
933 &Config::default(),
934 client,
935 );
936 let context = crate::tools::ToolContext::new(workspace.path().to_path_buf());
937 let mut registry = crate::tools::ToolRegistry::new(context);
938 registry.register(std::sync::Arc::new(crate::tools::file::ReadFileTool));
939 let tools = Some(registry.to_api_tools_with_cache(true));
940 let surface = test_tool_surface(&engine, registry, tools, AppMode::Agent);
941 let mut turn = crate::core::turn::TurnContext::new(4);
942
943 let (status, error) = engine.run_turn(&mut turn, surface, None, None).await;
944 assert_eq!(status, TurnOutcomeStatus::Completed, "{error:?}");
945
946 let request = mock.last_request().expect("mock request");
947 let mut events = handle.rx_event.write().await;
948 let snapshot = std::iter::from_fn(|| events.try_recv().ok())
949 .find_map(|event| match event {
950 Event::ToolRequestSnapshot { snapshot } => Some(snapshot),
951 _ => None,
952 })
953 .expect("request snapshot event");
954
955 assert_eq!(snapshot.tools_field_present, request.tools.is_some());
956 assert_eq!(
957 snapshot.tool_count,
958 request.tools.as_ref().map_or(0, Vec::len)
959 );
960 if let Some(request_tool) = request.tools.as_ref().and_then(|tools| tools.first()) {
961 assert_eq!(
962 snapshot.tools.first().expect("projected tool").name.value,
963 request_tool.name
964 );
965 }
966 assert_eq!(snapshot.turn_id.value, turn.id);
967 assert_eq!(snapshot.step, 0);
968 assert!(snapshot.delivery_status.starts_with("unknown"));
969 }
970
971 /// #6510: the inline ```repl kernel runs model-written Python, so it answers
972 /// to the `code_execution` gate. A surface that leaves `code_execution` out of
973 /// its allowlist (plain `exec`'s zero-tool surface, or a narrowed
974 /// `--allowed-tools`) or denies it must not execute a fence: the reply is the
975 /// answer, no kernel starts, and no second model call is made.
976 #[tokio::test]
977 async fn repl_fence_does_not_run_when_code_execution_is_not_allowed() {
978 use crate::llm_client::mock::{MockLlmClient, canned};
979 use codewhale_models::{ContentBlock, Message};
980
981 let fence = "```repl\nprint('fence ran')\n```";
982 for (allowed, disallowed) in [
983 (Some(Vec::new()), None),
984 (Some(vec!["read_file".to_string()]), None),
985 (None, Some(vec!["code_execution".to_string()])),
986 ] {
987 let workspace = tempdir().expect("tempdir");
988 let mock = std::sync::Arc::new(MockLlmClient::new(vec![canned::simple_text_turn(fence)]));
989 let client: crate::core::model_client::SharedModelClient = mock.clone();
990 let config = EngineConfig {
991 allowed_tools: allowed.clone(),
992 disallowed_tools: disallowed.clone(),
993 ..deterministic_engine_config(workspace.path())
994 };
995 let (mut engine, _handle) =
996 Engine::new_with_model_client(config, &Config::default(), client);
997 engine.session.add_message(Message {
998 role: Role::User,
999 content: vec![ContentBlock::Text {
1000 text: "Answer.".to_string(),
1001 cache_control: None,
1002 }],
1003 });
1004 let registry = crate::tools::ToolRegistry::new(crate::tools::ToolContext::new(
1005 workspace.path().to_path_buf(),
1006 ));
1007 let policy = test_tool_surface(&engine, registry, None, AppMode::Agent);
1008 let mut turn = crate::core::turn::TurnContext::new(4);
1009 let (status, error) = engine.run_turn(&mut turn, policy, None, None).await;
1010
1011 let case = format!("allowed={allowed:?} disallowed={disallowed:?}");
1012 assert_eq!(status, TurnOutcomeStatus::Completed, "{case}: {error:?}");
1013 assert!(
1014 engine.repl_kernel.is_none(),
1015 "{case}: kernel must not start"
1016 );
1017 assert_eq!(mock.call_count(), 1, "{case}: no follow-up model call");
1018 }
1019 }
1020
1021 #[tokio::test]
1022 async fn normal_repl_kernel_persists_across_user_turns() {
1023 use crate::core::engine::tests::rlm_host::{
1024 admitted_context, fixture_config, install_fixture_route,
1025 };
1026 use crate::llm_client::mock::{MockLlmClient, canned};
1027 use codewhale_models::{ContentBlock, Message, Usage};
1028
1029 let workspace = tempdir().expect("tempdir");
1030 let mock = std::sync::Arc::new(MockLlmClient::new(vec![
1031 canned::simple_text_turn(
1032 "```repl\nchild_verdict = sub_query('Return exactly: child route works')\nproof_from_first_turn = f'kernel state survives; {child_verdict}'\nprint('kernel primed', child_verdict)\n```",
1033 ),
1034 vec![
1035 canned::message_start("kernel-child"),
1036 canned::text_block_start(0),
1037 canned::text_delta(0, "child route works"),
1038 canned::block_stop(0),
1039 canned::message_delta(
1040 "end_turn",
1041 Some(Usage {
1042 input_tokens: 7,
1043 output_tokens: 11,
1044 ..Usage::default()
1045 }),
1046 ),
1047 canned::message_stop(),
1048 ],
1049 canned::simple_text_turn("First turn complete."),
1050 canned::simple_text_turn(
1051 "```repl\nprint(proof_from_first_turn)\nfinalize('persistent kernel verified')\n```",
1052 ),
1053 ]));
1054 let client: crate::core::model_client::SharedModelClient = mock.clone();
1055 let config = fixture_config("captured-working-model");
1056 let mut engine_config = deterministic_engine_config(workspace.path());
1057 engine_config.model = "captured-working-model".into();
1058 let (mut engine, handle) = Engine::new_with_model_client(engine_config, &config, client);
1059 install_fixture_route(&mut engine);
1060 let selected_model = engine.session.model.clone();
1061 // Full Access: the fence takes `code_execution`'s approval, which this
1062 // posture already grants.
1063 engine.session.auto_approve = true;
1064
1065 engine.session.add_message(Message {
1066 role: Role::User,
1067 content: vec![ContentBlock::Text {
1068 text: "Prime the working kernel.".to_string(),
1069 cache_control: None,
1070 }],
1071 });
1072 let first_registry =
1073 crate::tools::ToolRegistry::new(admitted_context(&engine, "first-fixture-turn"));
1074 let first_policy = test_tool_surface(
1075 &engine,
1076 first_registry,
1077 Some(vec![catalog_tool(CODE_EXECUTION_TOOL_NAME)]),
1078 AppMode::Agent,
1079 );
1080 let mut first_turn = crate::core::turn::TurnContext::new(4);
1081 let (status, error) = engine
1082 .run_turn(&mut first_turn, first_policy, None, None)
1083 .await;
1084 assert_eq!(status, TurnOutcomeStatus::Completed, "{error:?}");
1085 assert_eq!(first_turn.usage.input_tokens, 7);
1086 assert_eq!(first_turn.usage.output_tokens, 11);
1087 let child_usage_event = {
1088 let mut events = handle.rx_event.write().await;
1089 // Kernel child calls carry their own routed cost receipt, so their
1090 // per-call telemetry arrives as `RoutedTurnUsage` rather than the
1091 // parent-route `TurnUsage` receipt.
1092 std::iter::from_fn(|| events.try_recv().ok()).find_map(|event| match event {
1093 Event::RoutedTurnUsage { usage, .. }
1094 if usage.input_tokens == 7 && usage.output_tokens == 11 =>
1095 {
1096 Some(usage)
1097 }
1098 _ => None,
1099 })
1100 }
1101 .expect("kernel child usage must be visible to the cost UI");
1102 assert_eq!(child_usage_event.input_tokens, 7);
1103 assert_eq!(child_usage_event.output_tokens, 11);
1104
1105 engine.session.add_message(Message {
1106 role: Role::User,
1107 content: vec![ContentBlock::Text {
1108 text: "Use the state from the prior turn.".to_string(),
1109 cache_control: None,
1110 }],
1111 });
1112 let second_registry =
1113 crate::tools::ToolRegistry::new(admitted_context(&engine, "second-fixture-turn"));
1114 let second_policy = test_tool_surface(
1115 &engine,
1116 second_registry,
1117 Some(vec![catalog_tool(CODE_EXECUTION_TOOL_NAME)]),
1118 AppMode::Agent,
1119 );
1120 let mut second_turn = crate::core::turn::TurnContext::new(4);
1121 let (status, error) = engine
1122 .run_turn(&mut second_turn, second_policy, None, None)
1123 .await;
1124 assert_eq!(status, TurnOutcomeStatus::Completed, "{error:?}");
1125
1126 let kernel = engine.repl_kernel.as_ref().expect("persistent kernel");
1127 assert!(
1128 kernel.round_count() >= 4,
1129 "each REPL call should refresh context and then execute code"
1130 );
1131 let final_text = engine
1132 .session
1133 .messages
1134 .last()
1135 .and_then(|message| {
1136 message.content.iter().find_map(|block| match block {
1137 ContentBlock::Text { text, .. } => Some(text.as_str()),
1138 _ => None,
1139 })
1140 })
1141 .expect("final assistant text");
1142 assert_eq!(final_text, "persistent kernel verified");
1143
1144 let child_request = mock
1145 .captured_requests()
1146 .into_iter()
1147 .find(|request| request.messages.iter().any(|message| message.content.iter().any(|block| matches!(block, ContentBlock::Text { text, .. } if text == "Return exactly: child route works"))))
1148 .expect("the injected model client must service a kernel child query");
1149 assert_eq!(child_request.model, selected_model);
1150 assert_eq!(
1151 child_request.stream,
1152 Some(true),
1153 "the canonical Core stream producer services the nested call"
1154 );
1155 assert!(child_request.tools.as_ref().is_none_or(Vec::is_empty));
1156 assert_eq!(mock.captured_requests().len(), 4);
1157 }
1158
1159 /// A recursive `rlm_query` makes a child model write Python. That round runs
1160 /// only after the turn serving the `rlm` call admits it like `code_execution`:
1161 /// outside Full Access it waits on a card of its own, beyond the approval the
1162 /// direct `rlm` call already took. Denied, nothing runs; approved, it runs.
1163 #[tokio::test]
1164 async fn recursive_rlm_round_waits_on_the_code_execution_gate() {
1165 use crate::core::engine::tests::rlm_host::{
1166 admitted_context, fixture_config, install_fixture_route,
1167 };
1168 use crate::llm_client::mock::{MockLlmClient, canned};
1169 use codewhale_models::{ContentBlock, Message};
1170
1171 for approve in [false, true] {
1172 let workspace = tempdir().expect("tempdir");
1173 let marker = workspace.path().join("nested-round-ran");
1174 let nested = format!(
1175 "```repl\nopen({:?}, 'w').write('x')\nFINAL('nested done')\n```",
1176 marker.display().to_string()
1177 );
1178 let rlm = crate::tools::rlm::RLM_TOOL_NAME;
1179 let mock = std::sync::Arc::new(MockLlmClient::new(vec![
1180 canned::tool_call_turn(
1181 "rlm-open",
1182 rlm,
1183 r#"{"action":"open","name":"ctx","content":"fixture context"}"#,
1184 ),
1185 canned::tool_call_turn(
1186 "rlm-eval",
1187 rlm,
1188 r#"{"action":"eval","name":"ctx","code":"print(rlm_query('nested context'))"}"#,
1189 ),
1190 canned::simple_text_turn(&nested),
1191 canned::simple_text_turn("Done."),
1192 ]));
1193 let client: crate::core::model_client::SharedModelClient = mock.clone();
1194 let config = fixture_config("captured-working-model");
1195 let mut engine_config = deterministic_engine_config(workspace.path());
1196 engine_config.model = "captured-working-model".into();
1197 let (mut engine, handle) = Engine::new_with_model_client(engine_config, &config, client);
1198 install_fixture_route(&mut engine);
1199 engine.session.auto_approve = false;
1200 engine.session.approval_mode = ApprovalMode::Suggest;
1201 engine.session.add_message(Message {
1202 role: Role::User,
1203 content: vec![ContentBlock::Text {
1204 text: "Recurse.".to_string(),
1205 cache_control: None,
1206 }],
1207 });
1208 let registry = crate::tools::ToolRegistryBuilder::new()
1209 .with_rlm_tool()
1210 .build(admitted_context(&engine, "nested-gate-fixture-turn"));
1211 let policy = test_tool_surface(
1212 &engine,
1213 registry,
1214 Some(vec![
1215 catalog_tool(rlm),
1216 catalog_tool(CODE_EXECUTION_TOOL_NAME),
1217 ]),
1218 AppMode::Agent,
1219 );
1220 let task = tokio::spawn(async move {
1221 let mut turn = crate::core::turn::TurnContext::new(6);
1222 engine.run_turn(&mut turn, policy, None, None).await
1223 });
1224
1225 let events = handle.rx_event.clone();
1226 let nested_card = tokio::time::timeout(Duration::from_secs(30), async {
1227 let mut rx = events.write().await;
1228 while let Some(event) = rx.recv().await {
1229 if let Event::ApprovalRequired { id, tool_name, .. } = event {
1230 if tool_name == CODE_EXECUTION_TOOL_NAME {
1231 return id;
1232 }
1233 // The direct `rlm` call's own approval.
1234 handle.approve_tool_call(&id).await.expect("approve rlm");
1235 }
1236 }
1237 panic!("event stream closed before the nested round asked for approval");
1238 })
1239 .await
1240 .expect("the nested round must wait on its own approval card");
1241 assert!(
1242 nested_card.ends_with(".1"),
1243 "the round is the rlm call's first nested request: {nested_card}"
1244 );
1245 assert!(!marker.exists(), "nothing runs before the decision");
1246
1247 if approve {
1248 handle
1249 .approve_tool_call(&nested_card)
1250 .await
1251 .expect("approve");
1252 } else {
1253 handle.deny_tool_call(&nested_card).await.expect("deny");
1254 }
1255 let (status, error) = tokio::time::timeout(Duration::from_secs(60), task)
1256 .await
1257 .expect("turn deadline")
1258 .expect("turn task");
1259 assert_eq!(status, TurnOutcomeStatus::Completed, "{error:?}");
1260 assert_eq!(marker.exists(), approve, "approve={approve}");
1261 }
1262 }
1263
1264 /// A kernel keeps what it loaded under the posture it ran in, so a narrower
1265 /// posture starts later code in a fresh interpreter; an unchanged or broader
1266 /// posture keeps the working kernel.
1267 #[tokio::test]
1268 async fn narrowing_the_posture_discards_python_kernels() {
1269 use crate::llm_client::mock::MockLlmClient;
1270
1271 let workspace = tempdir().expect("tempdir");
1272 let client: crate::core::model_client::SharedModelClient =
1273 std::sync::Arc::new(MockLlmClient::new(Vec::new()));
1274 let (mut engine, _handle) = Engine::new_with_model_client(
1275 deterministic_engine_config(workspace.path()),
1276 &Config::default(),
1277 client,
1278 );
1279 // Without a Python interpreter there is no kernel to discard.
1280 let Ok(kernel) = crate::repl::runtime::PythonRuntime::new().await else {
1281 return;
1282 };
1283 engine.repl_kernel = Some(kernel);
1284 let posture = engine.applied_runtime_authority();
1285
1286 // Re-applying the same posture is not a narrowing.
1287 engine
1288 .apply_change_mode(
1289 posture.mode,
1290 posture.allow_shell,
1291 posture.trust_mode,
1292 posture.auto_approve,
1293 posture.approval_mode,
1294 posture.configured_sandbox_mode.clone(),
1295 )
1296 .await;
1297 assert!(
1298 engine.repl_kernel.is_some(),
1299 "an unchanged posture keeps the kernel"
1300 );
1301
1302 engine
1303 .apply_change_mode(
1304 posture.mode,
1305 posture.allow_shell,
1306 posture.trust_mode,
1307 posture.auto_approve,
1308 posture.approval_mode,
1309 Some("read-only".to_string()),
1310 )
1311 .await;
1312 assert!(
1313 engine.applied_runtime_authority().narrows(&posture),
1314 "fixture must actually narrow"
1315 );
1316 assert!(
1317 engine.repl_kernel.is_none(),
1318 "a narrower posture drops the kernel"
1319 );
1320 }
1321
1322 /// A turn dropped mid-round leaves its kernel broken, and a broken kernel
1323 /// refuses every later round. The next turn starts a fresh kernel instead of
1324 /// failing once on the stale one.
1325 #[tokio::test]
1326 async fn a_broken_repl_kernel_is_replaced_by_the_next_turn() {
1327 use crate::llm_client::mock::{MockLlmClient, canned};
1328 use codewhale_models::{ContentBlock, Message};
1329
1330 let workspace = tempdir().expect("tempdir");
1331 let mock = std::sync::Arc::new(MockLlmClient::new(vec![canned::simple_text_turn(
1332 "```repl\nfinalize('fresh kernel ran')\n```",
1333 )]));
1334 let client: crate::core::model_client::SharedModelClient = mock.clone();
1335 let (mut engine, _handle) = Engine::new_with_model_client(
1336 deterministic_engine_config(workspace.path()),
1337 &Config::default(),
1338 client,
1339 );
1340 engine.session.auto_approve = true;
1341
1342 // What a turn dropped mid-round leaves behind.
1343 let mut stale = crate::repl::PythonRuntime::new().await.expect("spawn");
1344 let dropped = tokio::time::timeout(
1345 Duration::from_millis(50),
1346 stale.execute("import time\ntime.sleep(30)"),
1347 )
1348 .await;
1349 assert!(dropped.is_err(), "the round must still be running");
1350 assert!(stale.is_broken());
1351 engine.repl_kernel = Some(stale);
1352
1353 engine.session.add_message(Message {
1354 role: Role::User,
1355 content: vec![ContentBlock::Text {
1356 text: "Run the kernel again.".to_string(),
1357 cache_control: None,
1358 }],
1359 });
1360 let registry = crate::tools::ToolRegistry::new(crate::tools::ToolContext::new(
1361 workspace.path().to_path_buf(),
1362 ));
1363 let policy = test_tool_surface(
1364 &engine,
1365 registry,
1366 Some(vec![catalog_tool(CODE_EXECUTION_TOOL_NAME)]),
1367 AppMode::Agent,
1368 );
1369 let mut turn = crate::core::turn::TurnContext::new(4);
1370 let (status, error) = engine.run_turn(&mut turn, policy, None, None).await;
1371 assert_eq!(status, TurnOutcomeStatus::Completed, "{error:?}");
1372 assert!(
1373 !engine
1374 .repl_kernel
1375 .as_ref()
1376 .expect("a fresh kernel")
1377 .is_broken()
1378 );
1379 }
1380
1381 /// Plan mode withholds `code_execution`, and a ```repl fence is not a way
1382 /// around that: even under Full Access and with the tool named in the
1383 /// supplied catalog, the fenced Python does not run in Plan mode.
1384 #[tokio::test]
1385 async fn plan_mode_repl_fence_does_not_execute() {
1386 use crate::llm_client::mock::{MockLlmClient, canned};
1387 use codewhale_models::{ContentBlock, Message};
1388
1389 let workspace = tempdir().expect("tempdir");
1390 let marker = workspace.path().join("fence-ran");
1391 let fence = format!(
1392 "```repl\nopen({:?}, 'w').write('x')\n```",
1393 marker.display().to_string()
1394 );
1395 let mock = std::sync::Arc::new(MockLlmClient::new(vec![canned::simple_text_turn(&fence)]));
1396 let client: crate::core::model_client::SharedModelClient = mock.clone();
1397 let (mut engine, _handle) = Engine::new_with_model_client(
1398 deterministic_engine_config(workspace.path()),
1399 &Config::default(),
1400 client,
1401 );
1402 engine.session.auto_approve = true;
1403 engine.session.add_message(Message {
1404 role: Role::User,
1405 content: vec![ContentBlock::Text {
1406 text: "Plan only.".to_string(),
1407 cache_control: None,
1408 }],
1409 });
1410 let registry = crate::tools::ToolRegistry::new(crate::tools::ToolContext::new(
1411 workspace.path().to_path_buf(),
1412 ));
1413 let policy = test_tool_surface(
1414 &engine,
1415 registry,
1416 Some(vec![catalog_tool(CODE_EXECUTION_TOOL_NAME)]),
1417 AppMode::Plan,
1418 );
1419 let mut turn = crate::core::turn::TurnContext::new(4);
1420 let (status, error) = engine.run_turn(&mut turn, policy, None, None).await;
1421
1422 assert_eq!(status, TurnOutcomeStatus::Completed, "{error:?}");
1423 assert!(
1424 engine.repl_kernel.is_none(),
1425 "kernel must not start in Plan"
1426 );
1427 assert!(!marker.exists(), "fenced Python must not run in Plan");
1428 assert_eq!(mock.call_count(), 1, "no follow-up model call");
1429 }
1430
1431 /// A ```repl fence runs model-written Python, so outside Full Access it waits
1432 /// on the same approval card as `code_execution`. Denied, it does not run and
1433 /// the turn says so; approved, it runs.
1434 #[tokio::test]
1435 async fn repl_fence_requires_code_execution_approval() {
1436 use crate::llm_client::mock::{MockLlmClient, canned};
1437 use codewhale_models::{ContentBlock, Message};
1438
1439 for approve in [false, true] {
1440 let workspace = tempdir().expect("tempdir");
1441 let marker = workspace.path().join("fence-ran");
1442 let fence = format!(
1443 "```repl\nopen({:?}, 'w').write('x')\nfinalize('done')\n```",
1444 marker.display().to_string()
1445 );
1446 let mock = std::sync::Arc::new(MockLlmClient::new(vec![
1447 canned::simple_text_turn(&fence),
1448 canned::simple_text_turn("Done."),
1449 ]));
1450 let client: crate::core::model_client::SharedModelClient = mock.clone();
1451 let (mut engine, handle) = Engine::new_with_model_client(
1452 deterministic_engine_config(workspace.path()),
1453 &Config::default(),
1454 client,
1455 );
1456 engine.session.auto_approve = false;
1457 engine.session.approval_mode = ApprovalMode::Suggest;
1458 engine.session.add_message(Message {
1459 role: Role::User,
1460 content: vec![ContentBlock::Text {
1461 text: "Compute.".to_string(),
1462 cache_control: None,
1463 }],
1464 });
1465 let registry = crate::tools::ToolRegistry::new(crate::tools::ToolContext::new(
1466 workspace.path().to_path_buf(),
1467 ));
1468 let policy = test_tool_surface(
1469 &engine,
1470 registry,
1471 Some(vec![catalog_tool(CODE_EXECUTION_TOOL_NAME)]),
1472 AppMode::Agent,
1473 );
1474 let task = tokio::spawn(async move {
1475 let mut turn = crate::core::turn::TurnContext::new(4);
1476 let outcome = engine.run_turn(&mut turn, policy, None, None).await;
1477 (engine, outcome)
1478 });
1479
1480 let events = handle.rx_event.clone();
1481 let approval_id = tokio::time::timeout(Duration::from_secs(10), async {
1482 let mut rx = events.write().await;
1483 while let Some(event) = rx.recv().await {
1484 if let Event::ApprovalRequired { id, tool_name, .. } = event {
1485 assert_eq!(tool_name, CODE_EXECUTION_TOOL_NAME);
1486 return id;
1487 }
1488 }
1489 panic!("event stream closed before the fence asked for approval");
1490 })
1491 .await
1492 .expect("the fence must wait on an approval card");
1493 assert!(!marker.exists(), "nothing runs before the decision");
1494
1495 if approve {
1496 handle
1497 .approve_tool_call(&approval_id)
1498 .await
1499 .expect("approve");
1500 } else {
1501 handle.deny_tool_call(&approval_id).await.expect("deny");
1502 }
1503 let (engine, (status, error)) = tokio::time::timeout(Duration::from_secs(30), task)
1504 .await
1505 .expect("turn deadline")
1506 .expect("turn task");
1507 assert_eq!(status, TurnOutcomeStatus::Completed, "{error:?}");
1508 assert_eq!(marker.exists(), approve, "approve={approve}");
1509 if !approve {
1510 assert!(
1511 engine.repl_kernel.is_none(),
1512 "denied fence starts no kernel"
1513 );
1514 let note = {
1515 let mut rx = events.write().await;
1516 std::iter::from_fn(|| rx.try_recv().ok()).any(|event| {
1517 matches!(event, Event::Status { message } if message.starts_with("REPL block not run: not approved"))
1518 })
1519 };
1520 assert!(note, "a denied fence leaves a visible status note");
1521 }
1522 }
1523 }
1524
1524 lines RUST