返回 CodeWhale
limits_tests.rs
根目录 / crates / tui / src / tools / subagent / limits_tests.rs
1 use super::tests::{make_snapshot, make_worker_spec, stub_runtime};
2 use super::*;
3 use tempfile::tempdir;
4
5 #[test]
6 fn depth_one_child_cannot_spawn_a_grandchild_even_with_a_wider_profile() {
7 let root = stub_runtime().with_max_spawn_depth(1);
8 let mut child = root.child_runtime();
9 child.worker_profile = worker_profile_for_spawn(
10 &child,
11 &FleetRole::Worker,
12 &AgentWorkerToolProfile::Inherited,
13 "deepseek-v4-flash",
14 None,
15 false,
16 );
17 assert_eq!(child.spawn_depth, 1);
18 assert_eq!(child.max_spawn_depth, 1);
19 assert_eq!(child.worker_profile.max_spawn_depth, 1);
20 assert_eq!(child.worker_profile.spawn_depth, 1);
21 assert!(child.would_exceed_depth());
22 assert!(!child.worker_profile.can_spawn_child());
23 child.worker_profile.max_spawn_depth = u32::MAX;
24 assert!(
25 child.would_exceed_depth(),
26 "a widened projection cannot bypass runtime ceiling"
27 );
28 assert!(child.background_runtime().would_exceed_depth());
29 }
30
31 #[test]
32 fn depth_overflow_fails_closed() {
33 let mut runtime = stub_runtime();
34 runtime.spawn_depth = u32::MAX;
35 runtime.max_spawn_depth = u32::MAX;
36 assert!(runtime.would_exceed_depth());
37 assert_eq!(runtime.child_runtime().spawn_depth, u32::MAX);
38 }
39
40 #[test]
41 fn old_relative_profile_depth_does_not_gain_authority_on_recovery() {
42 let tmp = tempdir().unwrap();
43 let mut spec = make_worker_spec("legacy", tmp.path().to_path_buf());
44 spec.spawn_depth = 2;
45 spec.max_spawn_depth = 3;
46 spec.runtime_profile.max_spawn_depth = 1; // old remaining allowance
47 let record = AgentWorkerRecord::new(spec, epoch_millis_now());
48 let mut encoded = serde_json::to_value(&record).unwrap();
49 encoded["spec"]["runtime_profile"]
50 .as_object_mut()
51 .unwrap()
52 .remove("spawn_depth");
53 let decoded: AgentWorkerRecord = serde_json::from_value(encoded).unwrap();
54 let recovered = normalize_worker_record(decoded);
55 assert_eq!(recovered.spec.runtime_profile.spawn_depth, 2);
56 assert_eq!(recovered.spec.max_spawn_depth, 1);
57 assert_eq!(recovered.spec.runtime_profile.max_spawn_depth, 1);
58 assert!(!recovered.spec.runtime_profile.can_spawn_child());
59 }
60
61 #[test]
62 fn operator_and_inherited_budgets_only_narrow_including_zero_sentinels() {
63 assert_eq!(resolve_max_steps(FleetRole::Worker, Some(99), Some(7)), 7);
64 assert_eq!(resolve_max_steps(FleetRole::Worker, Some(0), Some(7)), 7);
65 let parent = WorkerRuntimeProfile {
66 max_steps: 8,
67 wall_time_secs: Some(40),
68 wall_deadline_ms: Some(123_000),
69 ..WorkerRuntimeProfile::default()
70 };
71 let requested = WorkerRuntimeProfile {
72 wall_time_secs: Some(4_000),
73 wall_deadline_ms: Some(456_000),
74 ..WorkerRuntimeProfile::default()
75 };
76 let child = parent.derive_child(&requested);
77 assert_eq!(child.max_steps, 8);
78 assert_eq!(child.wall_time_secs, Some(40));
79 assert_eq!(child.wall_deadline_ms, Some(123_000));
80 }
81
82 #[test]
83 fn child_runtime_budget_context_reports_every_resolved_limit() {
84 let mut runtime = stub_runtime();
85 runtime.worker_profile.wall_time_secs = Some(1_800);
86 runtime.worker_profile.wall_deadline_ms = Some(epoch_millis_now() + 1_700_000);
87 let context = child_runtime_budget_context(&runtime, 50, 49);
88 assert!(context.contains("Runtime budget (host-enforced"));
89 assert!(context.contains("wall clock: task work stops about"));
90 assert!(context.contains("total run budget 30m 00s"));
91 assert!(context.contains("49 model turns of task work (limit 50"));
92 assert!(context.contains("reserved hand-back turn"));
93 assert!(context.contains("Commit or checkpoint work-in-progress early"));
94 assert!(context.contains("There is no token budget and no per-step context cap"));
95 }
96
97 #[test]
98 fn child_runtime_budget_context_names_unbounded_limits_honestly() {
99 let runtime = stub_runtime();
100 let context = child_runtime_budget_context(&runtime, 0, 0);
101 assert!(context.contains("wall clock: no wall-clock limit."));
102 assert!(context.contains("model steps: no per-run step cap."));
103 assert!(context.contains("There is no token budget"));
104 assert!(context.contains("reserved hand-back turn"));
105 }
106
107 #[test]
108 fn child_runtime_budget_context_tells_the_child_it_compacts_like_the_parent() {
109 let mut runtime = stub_runtime();
110 let context = child_runtime_budget_context(&runtime, 0, 0);
111 assert!(
112 !context.contains("no automatic compaction"),
113 "children compact like the parent: {context}"
114 );
115 assert!(
116 context.contains("compacts it automatically") && context.contains("the run continues"),
117 "{context}"
118 );
119 assert!(
120 !context.contains("input tokens"),
121 "no per-step input cap: {context}"
122 );
123
124 // The parent's opt-out is the child's opt-out, and the prompt says so.
125 runtime.compaction.enabled = false;
126 let context = child_runtime_budget_context(&runtime, 0, 0);
127 assert!(
128 context.contains("Automatic context compaction is disabled for this session"),
129 "{context}"
130 );
131 assert!(!context.contains("compacts it automatically"), "{context}");
132 }
133
134 #[test]
135 fn child_compaction_config_inherits_the_parent_policy_for_the_child_route() {
136 let mut runtime = stub_runtime();
137 let route = runtime
138 .client
139 .effective_route_envelope(&runtime.model, chrono::Utc::now());
140 let window = crate::route_budget::route_context_window_tokens(
141 route.provider,
142 &route.model,
143 runtime.client.route_limits(),
144 );
145 runtime.compaction = crate::compaction::CompactionConfig {
146 enabled: false,
147 token_threshold: 12_345,
148 effective_context_window: Some(window),
149 summary_instructions: Some("keep file paths".to_string()),
150 retained_user_message_tokens: 777,
151 focus: Some("manual focus never leaks into auto passes".to_string()),
152 ..crate::compaction::CompactionConfig::default()
153 };
154
155 let config = child_compaction_config(
156 &runtime,
157 &route,
158 crate::model_profile::SupportState::Supported,
159 );
160 assert!(!config.enabled, "the parent's opt-out holds for children");
161 assert_eq!(config.token_threshold, 12_345, "same window, same trigger");
162 assert_eq!(config.effective_context_window, Some(window));
163 assert_eq!(config.model, runtime.model);
164 assert_eq!(
165 config.image_input,
166 crate::model_profile::SupportState::Supported
167 );
168 assert_eq!(
169 config.summary_instructions.as_deref(),
170 Some("keep file paths")
171 );
172 assert_eq!(config.retained_user_message_tokens, 777);
173 assert_eq!(config.focus, None);
174 assert_eq!(config.workspace, Some(runtime.context.workspace.clone()));
175
176 // A parent on a different window carries its trigger as the same
177 // fraction of the child's window, through the shared route helper.
178 runtime.compaction.enabled = true;
179 runtime.compaction.effective_context_window = Some(window.saturating_mul(2));
180 runtime.compaction.token_threshold = usize::try_from(window).unwrap();
181 let config = child_compaction_config(
182 &runtime,
183 &route,
184 crate::model_profile::SupportState::Unknown,
185 );
186 assert!(config.enabled);
187 assert_eq!(
188 config.token_threshold,
189 crate::route_budget::compaction_threshold_for_route_at_percent(
190 route.provider,
191 &route.model,
192 runtime.client.route_limits(),
193 50.0,
194 )
195 );
196 }
197
198 #[test]
199 fn child_budget_pacing_notice_fires_at_three_quarters_of_each_bound() {
200 let started_at = Instant::now() - Duration::from_secs(80);
201 let deadline = started_at + Duration::from_secs(100);
202 let notice = child_budget_pacing_notice(started_at, Some(deadline), 38, 50)
203 .expect("all three bounds past 75%");
204 assert!(notice.contains("kind=\"budget_pacing\""));
205 assert!(notice.contains("wall clock:"));
206 assert!(notice.contains("model steps: 38 of 50 used"));
207 }
208
209 #[test]
210 fn child_budget_pacing_notice_stays_silent_with_headroom() {
211 let started_at = Instant::now();
212 let deadline = started_at + Duration::from_secs(100);
213 assert!(child_budget_pacing_notice(started_at, Some(deadline), 10, 50).is_none());
214 // No bound at all means there is nothing to pace against.
215 assert!(child_budget_pacing_notice(started_at, None, 9_999, 0).is_none());
216 }
217
218 #[test]
219 fn per_call_budget_fields_reject_empty_zero_null_negative_and_oversized_values() {
220 for field in ["max_steps", "wall_time_secs"] {
221 for invalid in [
222 json!(0),
223 json!(-1),
224 json!(null),
225 json!(""),
226 json!("7"),
227 json!(false),
228 json!(1.5),
229 ] {
230 let mut input = json!({"prompt": "inspect"});
231 input[field] = invalid;
232 assert!(parse_spawn_request(&input).is_err(), "{input}");
233 }
234 }
235 for (field, value) in [
236 ("max_steps", u64::from(MAX_SUBAGENT_STEPS) + 1),
237 ("wall_time_secs", MAX_CHILD_WALL_TIME.as_secs() + 1),
238 ] {
239 let mut input = json!({"prompt": "inspect"});
240 input[field] = json!(value);
241 assert!(parse_spawn_request(&input).is_err(), "{input}");
242 }
243 }
244
245 #[test]
246 fn budget_partial_handback_is_bounded_and_keeps_unknown_usage_honest() {
247 let mut snapshot = make_snapshot(SubAgentStatus::Running);
248 snapshot.result = Some("partial 🐳 ".repeat(2_000));
249 let result = budget_partial_result(snapshot, "child wall-time budget exhausted", None);
250 assert_eq!(result.status, SubAgentStatus::BudgetExhausted);
251 let summary = result.result.unwrap();
252 assert!(summary.chars().count() < 4_500);
253 assert!(summary.contains("usage has not been reported"));
254 assert!(summary.contains("makes no further model request"));
255 let checkpoint = result.checkpoint.unwrap();
256 assert!(!checkpoint.continuable);
257 assert_eq!(
258 subagent_failure_class(&result.status, &checkpoint.reason),
259 "wall_time_budget"
260 );
261 }
262
263 #[tokio::test]
264 async fn launch_narrows_all_limits_and_continuation_cannot_restart_deadline() {
265 let tmp = tempdir().unwrap();
266 let manager = Arc::new(RwLock::new(
267 SubAgentManager::new(tmp.path().to_path_buf(), 4)
268 .with_default_max_steps(Some(4))
269 .with_default_wall_time(Some(Duration::from_secs(10))),
270 ));
271 let mut runtime = stub_runtime().child_runtime();
272 runtime.context = ToolContext::new(tmp.path().to_path_buf());
273 runtime.manager = Arc::clone(&manager);
274 runtime.cancel_token.cancel(); // inspect admission; no provider request may run
275 let options = SubAgentSpawnOptions {
276 max_steps: Some(999),
277 wall_time: Some(Duration::from_secs(999)),
278 ..Default::default()
279 };
280 let mut guard = manager.write().await;
281 let child = guard
282 .spawn_background_with_assignment_options(
283 Arc::clone(&manager),
284 runtime.clone(),
285 FleetRole::Scout,
286 "inspect".to_string(),
287 SubAgentAssignment::new("inspect".to_string(), None),
288 Some(vec![]),
289 options,
290 None,
291 )
292 .unwrap();
293 let profile = &guard.worker_records[&child.agent_id].spec.runtime_profile;
294 assert_eq!(profile.max_steps, 4);
295 assert!(profile.wall_time_secs.unwrap() <= 10);
296 // Continuation admission requires the same actor to settle before its saved deadline is checked.
297 let requested = guard.cancel_agent(&child.agent_id).unwrap();
298 drop(guard);
299 let settled = settle_requested_child(&manager, requested).await;
300 assert_eq!(settled.status, SubAgentStatus::Cancelled);
301 let mut guard = manager.write().await;
302 guard
303 .worker_records
304 .get_mut(&child.agent_id)
305 .unwrap()
306 .spec
307 .runtime_profile
308 .wall_deadline_ms = Some(1);
309 let refused = guard.spawn_background_with_assignment_options(
310 Arc::clone(&manager),
311 runtime,
312 FleetRole::Scout,
313 "continue".to_string(),
314 SubAgentAssignment::new("continue".to_string(), None),
315 Some(vec![]),
316 SubAgentSpawnOptions {
317 resume_from_agent_id: Some(child.agent_id),
318 ..Default::default()
319 },
320 None,
321 );
322 assert!(
323 refused
324 .unwrap_err()
325 .to_string()
326 .contains("cannot reset its deadline")
327 );
328 }
329
330 #[tokio::test]
331 async fn explicit_wall_time_may_exceed_the_built_in_default() {
332 let tmp = tempdir().unwrap();
333 let manager = Arc::new(RwLock::new(SubAgentManager::new(
334 tmp.path().to_path_buf(),
335 4,
336 )));
337 let mut runtime = stub_runtime().child_runtime();
338 runtime.context = ToolContext::new(tmp.path().to_path_buf());
339 runtime.manager = Arc::clone(&manager);
340 runtime.worker_profile.wall_time_secs = None;
341 runtime.cancel_token.cancel(); // inspect admission; no provider request may run
342 let spawn = |guard: &mut SubAgentManager, wall_time: Option<Duration>| {
343 let child = guard
344 .spawn_background_with_assignment_options(
345 Arc::clone(&manager),
346 runtime.clone(),
347 FleetRole::Scout,
348 "inspect".to_string(),
349 SubAgentAssignment::new("inspect".to_string(), None),
350 Some(vec![]),
351 SubAgentSpawnOptions {
352 wall_time,
353 ..Default::default()
354 },
355 None,
356 )
357 .unwrap();
358 guard.worker_records[&child.agent_id]
359 .spec
360 .runtime_profile
361 .wall_time_secs
362 };
363 let mut guard = manager.write().await;
364 assert_eq!(
365 spawn(&mut guard, None),
366 Some(DEFAULT_CHILD_WALL_TIME.as_secs())
367 );
368 assert_eq!(
369 spawn(&mut guard, Some(Duration::from_secs(4 * 60 * 60))),
370 Some(4 * 60 * 60),
371 "an explicit request may raise the built-in 30-minute default"
372 );
373 }
374
375 #[tokio::test]
376 async fn resume_intersects_saved_write_shell_and_tool_permissions_with_current_caller() {
377 let tmp = tempdir().unwrap();
378 let manager = Arc::new(RwLock::new(SubAgentManager::new(
379 tmp.path().to_path_buf(),
380 4,
381 )));
382 let mut runtime = stub_runtime().child_runtime();
383 runtime.context = ToolContext::new(tmp.path().to_path_buf());
384 runtime.manager = Arc::clone(&manager);
385 runtime.cancel_token.cancel();
386 runtime.worker_profile.permissions.write = false;
387 runtime.worker_profile.permissions.network = false;
388 runtime.worker_profile.shell = ShellPolicy::ReadOnly;
389 runtime.worker_profile.tools = ToolScope::Explicit(vec!["read_file".to_string()]);
390 runtime.worker_profile.denied_tools = vec!["exec_shell".to_string()];
391 let saved = WorkerRuntimeProfile::for_role(FleetRole::Worker);
392 let mut guard = manager.write().await;
393 let child = guard
394 .spawn_background_with_assignment_options(
395 Arc::clone(&manager),
396 runtime,
397 FleetRole::Worker,
398 "resume".to_string(),
399 SubAgentAssignment::new("resume".to_string(), None),
400 None,
401 SubAgentSpawnOptions {
402 preserve_runtime_profile: Some(saved),
403 ..Default::default()
404 },
405 None,
406 )
407 .unwrap();
408 let profile = &guard.worker_records[&child.agent_id].spec.runtime_profile;
409 assert!(!profile.permissions.write);
410 assert!(!profile.permissions.network);
411 assert_eq!(profile.shell, ShellPolicy::ReadOnly);
412 assert_eq!(
413 profile.tools,
414 ToolScope::Explicit(vec!["read_file".to_string()])
415 );
416 assert!(profile.denied_tools.contains(&"exec_shell".to_string()));
417 }
418
419 #[tokio::test]
420 async fn root_fork_of_depth_two_leaf_cannot_regain_a_generation() {
421 let tmp = tempdir().unwrap();
422 let manager = Arc::new(RwLock::new(SubAgentManager::new(
423 tmp.path().to_path_buf(),
424 4,
425 )));
426 let mut runtime = stub_runtime().with_max_spawn_depth(3).child_runtime();
427 runtime.context = ToolContext::new(tmp.path().to_path_buf());
428 runtime.manager = Arc::clone(&manager);
429 runtime.cancel_token.cancel();
430 let mut guard = manager.write().await;
431 let (canonical, directory) = crate::runtime_api::open_workspace_directory(tmp.path()).unwrap();
432 guard
433 .admit_coordination_workspace(tmp.path().to_path_buf(), canonical, Arc::new(directory))
434 .unwrap();
435 let source_id = guard.insert_test_running_direct_child("leaf", tmp.path());
436 guard.cancel_agent(&source_id).unwrap();
437 let mut source = make_worker_spec(&source_id, tmp.path().to_path_buf());
438 source.spawn_depth = 2;
439 source.max_spawn_depth = 2;
440 source.runtime_profile.spawn_depth = 2;
441 source.runtime_profile.max_spawn_depth = 2;
442 // Preserve the existing actor/origin/session receipt while installing the depth-2 profile.
443 guard.worker_records.get_mut(&source_id).unwrap().spec = source;
444 let child = guard
445 .spawn_background_with_assignment_options(
446 Arc::clone(&manager),
447 runtime,
448 FleetRole::Scout,
449 "fork leaf".to_string(),
450 SubAgentAssignment::new("fork leaf".to_string(), None),
451 Some(vec![]),
452 SubAgentSpawnOptions {
453 resume_from_agent_id: Some(source_id),
454 ..Default::default()
455 },
456 None,
457 )
458 .unwrap();
459 let spec = &guard.worker_records[&child.agent_id].spec;
460 assert_eq!(spec.spawn_depth, 2);
461 assert_eq!(spec.max_spawn_depth, 2);
462 assert_eq!(spec.runtime_profile.spawn_depth, 2);
463 assert!(!spec.runtime_profile.can_spawn_child());
464 }
465
466 // ── #6282 tool-result cap tests ───────────────────────────────────────────
467
468 fn cap_tokens(n: u32) -> std::num::NonZeroU32 {
469 std::num::NonZeroU32::new(n).expect("n > 0")
470 }
471
472 #[test]
473 fn hard_cap_passes_through_content_below_both_caps() {
474 let content = "small".to_string();
475 let capped = hard_cap_tool_result(content.clone(), cap_tokens(10_000));
476 assert_eq!(capped, content);
477 assert!(!capped.contains("truncated"));
478 }
479
480 #[test]
481 fn hard_cap_truncates_content_above_byte_cap_and_stamps_truncated_marker() {
482 let content = "X".repeat(1_048_577); // 1 byte over the 1 MiB cap
483 let capped = hard_cap_tool_result(content, cap_tokens(u32::MAX));
484 assert!(capped.len() <= 1_048_576 + "\n[truncated: true]".len());
485 assert!(capped.ends_with("\n[truncated: true]"));
486 assert!(capped.starts_with('X'));
487 }
488
489 #[test]
490 fn hard_cap_truncates_content_above_token_cap() {
491 // 10k tokens × 3 bytes/token = 30k byte cap. 100k chars should trigger it.
492 let content = "A".repeat(100_000);
493 let capped = hard_cap_tool_result(content, cap_tokens(10_000));
494 // The effective cap is min(30k bytes, 1 MiB) = 30k bytes.
495 let token_cap_bytes = 10_000usize.saturating_mul(3);
496 assert!(capped.len() <= token_cap_bytes + "\n[truncated: true]".len());
497 assert!(capped.ends_with("\n[truncated: true]"));
498 }
499
500 #[test]
501 fn hard_cap_truncates_at_valid_utf8_boundary() {
502 // Build content where 1 MiB boundary falls mid-char.
503 // '好' is 3 bytes in UTF-8.
504 let mut content = String::new();
505 let target = 1_048_576; // exactly at boundary
506 while content.len() < target + 2 {
507 content.push('好');
508 }
509 assert!(content.len() > target);
510 let capped = hard_cap_tool_result(content, cap_tokens(u32::MAX));
511 // Must be valid UTF-8 (no panic during slicing or display).
512 assert!(capped.ends_with("\n[truncated: true]"));
513 // The marker is ASCII; verify the rest is still valid UTF-8.
514 let without_marker = &capped[..capped.len() - "\n[truncated: true]".len()];
515 assert!(std::str::from_utf8(without_marker.as_bytes()).is_ok());
516 }
517
518 #[test]
519 fn hard_cap_respects_custom_max_output_tokens_nonzero() {
520 let content = "X".repeat(10_000);
521 // 100 tokens × 3 bytes = 300 byte cap — much tighter than default.
522 let capped = hard_cap_tool_result(content.clone(), cap_tokens(100));
523 assert!(capped.len() <= 300 + "\n[truncated: true]".len());
524 assert!(capped.ends_with("\n[truncated: true]"));
525 }
526
527 #[test]
528 fn hard_cap_min_token_value_produces_three_byte_cap() {
529 // NonZeroU32::MIN = 1 token → cap at 3 bytes.
530 let content = "hello world".to_string();
531 let capped = hard_cap_tool_result(content, std::num::NonZeroU32::MIN);
532 assert!(capped.len() <= 3 + "\n[truncated: true]".len());
533 assert!(capped.ends_with("\n[truncated: true]"));
534 }
535
535 lines RUST