返回 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, 100_000);
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("single step billing over 100000 input tokens"));
95 assert!(context.contains("There is no cumulative token cap"));
96 }
97
98 #[test]
99 fn child_runtime_budget_context_names_unbounded_limits_honestly() {
100 let runtime = stub_runtime();
101 let context = child_runtime_budget_context(&runtime, 0, 0, 32_000);
102 assert!(context.contains("wall clock: no wall-clock limit."));
103 assert!(context.contains("model steps: no per-run step cap."));
104 assert!(context.contains("There is no cumulative token cap"));
105 assert!(context.contains("single step billing over 32000 input tokens"));
106 assert!(context.contains("reserved hand-back turn"));
107 }
108
109 #[test]
110 fn child_budget_pacing_notice_fires_at_three_quarters_of_each_bound() {
111 let started_at = Instant::now() - Duration::from_secs(80);
112 let deadline = started_at + Duration::from_secs(100);
113 let notice = child_budget_pacing_notice(started_at, Some(deadline), 38, 50)
114 .expect("all three bounds past 75%");
115 assert!(notice.contains("kind=\"budget_pacing\""));
116 assert!(notice.contains("wall clock:"));
117 assert!(notice.contains("model steps: 38 of 50 used"));
118 }
119
120 #[test]
121 fn child_budget_pacing_notice_stays_silent_with_headroom() {
122 let started_at = Instant::now();
123 let deadline = started_at + Duration::from_secs(100);
124 assert!(child_budget_pacing_notice(started_at, Some(deadline), 10, 50).is_none());
125 // No bound at all means there is nothing to pace against.
126 assert!(child_budget_pacing_notice(started_at, None, 9_999, 0).is_none());
127 }
128
129 #[test]
130 fn child_step_input_bound_prefers_half_window_capped_at_the_guardrail() {
131 assert_eq!(child_step_input_bound(None), 100_000);
132 assert_eq!(child_step_input_bound(Some(64_000)), 32_000);
133 assert_eq!(child_step_input_bound(Some(1_000_000)), 100_000);
134 // Degenerate windows fall back to the flat guardrail, never to zero.
135 assert_eq!(child_step_input_bound(Some(0)), 100_000);
136 assert_eq!(child_step_input_bound(Some(1)), 100_000);
137 }
138
139 #[test]
140 fn child_context_trip_fires_only_past_the_bound() {
141 let reason = child_context_trip(100_001, 100_000).expect("over bound trips");
142 assert!(reason.contains("context budget exhausted"), "{reason}");
143 assert!(reason.contains("100001"), "{reason}");
144 assert!(child_context_trip(100_000, 100_000).is_none());
145 assert!(child_context_trip(71_000, 100_000).is_none());
146 assert!(child_context_trip(0, 100_000).is_none());
147 }
148
149 #[test]
150 fn context_budget_death_classifies_distinctly_from_other_budgets() {
151 assert_eq!(
152 subagent_failure_class(
153 &SubAgentStatus::BudgetExhausted,
154 "child context budget exhausted: step billed 150000 input tokens"
155 ),
156 "context_budget"
157 );
158 // The generic budget-exhausted status still classifies when the cause is
159 // something else entirely.
160 assert_eq!(
161 subagent_failure_class(&SubAgentStatus::BudgetExhausted, "some other reason"),
162 "budget_exhausted"
163 );
164 }
165
166 #[test]
167 fn per_call_budget_fields_reject_empty_zero_null_negative_and_oversized_values() {
168 for field in ["max_steps", "wall_time_secs"] {
169 for invalid in [
170 json!(0),
171 json!(-1),
172 json!(null),
173 json!(""),
174 json!("7"),
175 json!(false),
176 json!(1.5),
177 ] {
178 let mut input = json!({"prompt": "inspect"});
179 input[field] = invalid;
180 assert!(parse_spawn_request(&input).is_err(), "{input}");
181 }
182 }
183 for (field, value) in [
184 ("max_steps", u64::from(MAX_SUBAGENT_STEPS) + 1),
185 ("wall_time_secs", MAX_CHILD_WALL_TIME.as_secs() + 1),
186 ] {
187 let mut input = json!({"prompt": "inspect"});
188 input[field] = json!(value);
189 assert!(parse_spawn_request(&input).is_err(), "{input}");
190 }
191 }
192
193 #[test]
194 fn budget_partial_handback_is_bounded_and_keeps_unknown_usage_honest() {
195 let mut snapshot = make_snapshot(SubAgentStatus::Running);
196 snapshot.result = Some("partial 🐳 ".repeat(2_000));
197 let result = budget_partial_result(snapshot, "child wall-time budget exhausted", None);
198 assert_eq!(result.status, SubAgentStatus::BudgetExhausted);
199 let summary = result.result.unwrap();
200 assert!(summary.chars().count() < 4_500);
201 assert!(summary.contains("usage has not been reported"));
202 assert!(summary.contains("makes no further model request"));
203 let checkpoint = result.checkpoint.unwrap();
204 assert!(!checkpoint.continuable);
205 assert_eq!(
206 subagent_failure_class(&result.status, &checkpoint.reason),
207 "wall_time_budget"
208 );
209 }
210
211 #[tokio::test]
212 async fn launch_narrows_all_limits_and_continuation_cannot_restart_deadline() {
213 let tmp = tempdir().unwrap();
214 let manager = Arc::new(RwLock::new(
215 SubAgentManager::new(tmp.path().to_path_buf(), 4)
216 .with_default_max_steps(Some(4))
217 .with_default_wall_time(Some(Duration::from_secs(10))),
218 ));
219 let mut runtime = stub_runtime().child_runtime();
220 runtime.context = ToolContext::new(tmp.path().to_path_buf());
221 runtime.manager = Arc::clone(&manager);
222 runtime.cancel_token.cancel(); // inspect admission; no provider request may run
223 let options = SubAgentSpawnOptions {
224 max_steps: Some(999),
225 wall_time: Some(Duration::from_secs(999)),
226 ..Default::default()
227 };
228 let mut guard = manager.write().await;
229 let child = guard
230 .spawn_background_with_assignment_options(
231 Arc::clone(&manager),
232 runtime.clone(),
233 FleetRole::Scout,
234 "inspect".to_string(),
235 SubAgentAssignment::new("inspect".to_string(), None),
236 Some(vec![]),
237 options,
238 None,
239 )
240 .unwrap();
241 let profile = &guard.worker_records[&child.agent_id].spec.runtime_profile;
242 assert_eq!(profile.max_steps, 4);
243 assert!(profile.wall_time_secs.unwrap() <= 10);
244 guard
245 .worker_records
246 .get_mut(&child.agent_id)
247 .unwrap()
248 .spec
249 .runtime_profile
250 .wall_deadline_ms = Some(1);
251 let refused = guard.spawn_background_with_assignment_options(
252 Arc::clone(&manager),
253 runtime,
254 FleetRole::Scout,
255 "continue".to_string(),
256 SubAgentAssignment::new("continue".to_string(), None),
257 Some(vec![]),
258 SubAgentSpawnOptions {
259 resume_from_agent_id: Some(child.agent_id),
260 ..Default::default()
261 },
262 None,
263 );
264 assert!(
265 refused
266 .unwrap_err()
267 .to_string()
268 .contains("cannot reset its deadline")
269 );
270 }
271
272 #[tokio::test]
273 async fn resume_intersects_saved_write_shell_and_tool_permissions_with_current_caller() {
274 let tmp = tempdir().unwrap();
275 let manager = Arc::new(RwLock::new(SubAgentManager::new(
276 tmp.path().to_path_buf(),
277 4,
278 )));
279 let mut runtime = stub_runtime().child_runtime();
280 runtime.context = ToolContext::new(tmp.path().to_path_buf());
281 runtime.manager = Arc::clone(&manager);
282 runtime.cancel_token.cancel();
283 runtime.worker_profile.permissions.write = false;
284 runtime.worker_profile.permissions.network = false;
285 runtime.worker_profile.shell = ShellPolicy::ReadOnly;
286 runtime.worker_profile.tools = ToolScope::Explicit(vec!["read_file".to_string()]);
287 runtime.worker_profile.denied_tools = vec!["exec_shell".to_string()];
288 let saved = WorkerRuntimeProfile::for_role(FleetRole::Worker);
289 let mut guard = manager.write().await;
290 let child = guard
291 .spawn_background_with_assignment_options(
292 Arc::clone(&manager),
293 runtime,
294 FleetRole::Worker,
295 "resume".to_string(),
296 SubAgentAssignment::new("resume".to_string(), None),
297 None,
298 SubAgentSpawnOptions {
299 preserve_runtime_profile: Some(saved),
300 ..Default::default()
301 },
302 None,
303 )
304 .unwrap();
305 let profile = &guard.worker_records[&child.agent_id].spec.runtime_profile;
306 assert!(!profile.permissions.write);
307 assert!(!profile.permissions.network);
308 assert_eq!(profile.shell, ShellPolicy::ReadOnly);
309 assert_eq!(
310 profile.tools,
311 ToolScope::Explicit(vec!["read_file".to_string()])
312 );
313 assert!(profile.denied_tools.contains(&"exec_shell".to_string()));
314 }
315
316 #[tokio::test]
317 async fn root_fork_of_depth_two_leaf_cannot_regain_a_generation() {
318 let tmp = tempdir().unwrap();
319 let manager = Arc::new(RwLock::new(SubAgentManager::new(
320 tmp.path().to_path_buf(),
321 4,
322 )));
323 let mut runtime = stub_runtime().with_max_spawn_depth(3).child_runtime();
324 runtime.context = ToolContext::new(tmp.path().to_path_buf());
325 runtime.manager = Arc::clone(&manager);
326 runtime.cancel_token.cancel();
327 let mut guard = manager.write().await;
328 let mut source = make_worker_spec("leaf", tmp.path().to_path_buf());
329 source.spawn_depth = 2;
330 source.max_spawn_depth = 2;
331 source.runtime_profile.spawn_depth = 2;
332 source.runtime_profile.max_spawn_depth = 2;
333 guard.register_worker(source);
334 let child = guard
335 .spawn_background_with_assignment_options(
336 Arc::clone(&manager),
337 runtime,
338 FleetRole::Scout,
339 "fork leaf".to_string(),
340 SubAgentAssignment::new("fork leaf".to_string(), None),
341 Some(vec![]),
342 SubAgentSpawnOptions {
343 resume_from_agent_id: Some("leaf".to_string()),
344 ..Default::default()
345 },
346 None,
347 )
348 .unwrap();
349 let spec = &guard.worker_records[&child.agent_id].spec;
350 assert_eq!(spec.spawn_depth, 2);
351 assert_eq!(spec.max_spawn_depth, 2);
352 assert_eq!(spec.runtime_profile.spawn_depth, 2);
353 assert!(!spec.runtime_profile.can_spawn_child());
354 }
355
356 // ── #6282 tool-result cap tests ───────────────────────────────────────────
357
358 fn cap_tokens(n: u32) -> std::num::NonZeroU32 {
359 std::num::NonZeroU32::new(n).expect("n > 0")
360 }
361
362 #[test]
363 fn hard_cap_passes_through_content_below_both_caps() {
364 let content = "small".to_string();
365 let capped = hard_cap_tool_result(content.clone(), cap_tokens(10_000));
366 assert_eq!(capped, content);
367 assert!(!capped.contains("truncated"));
368 }
369
370 #[test]
371 fn hard_cap_truncates_content_above_byte_cap_and_stamps_truncated_marker() {
372 let content = "X".repeat(1_048_577); // 1 byte over the 1 MiB cap
373 let capped = hard_cap_tool_result(content, cap_tokens(u32::MAX));
374 assert!(capped.len() <= 1_048_576 + "\n[truncated: true]".len());
375 assert!(capped.ends_with("\n[truncated: true]"));
376 assert!(capped.starts_with('X'));
377 }
378
379 #[test]
380 fn hard_cap_truncates_content_above_token_cap() {
381 // 10k tokens × 3 bytes/token = 30k byte cap. 100k chars should trigger it.
382 let content = "A".repeat(100_000);
383 let capped = hard_cap_tool_result(content, cap_tokens(10_000));
384 // The effective cap is min(30k bytes, 1 MiB) = 30k bytes.
385 let token_cap_bytes = 10_000usize.saturating_mul(3);
386 assert!(capped.len() <= token_cap_bytes + "\n[truncated: true]".len());
387 assert!(capped.ends_with("\n[truncated: true]"));
388 }
389
390 #[test]
391 fn hard_cap_truncates_at_valid_utf8_boundary() {
392 // Build content where 1 MiB boundary falls mid-char.
393 // '好' is 3 bytes in UTF-8.
394 let mut content = String::new();
395 let target = 1_048_576; // exactly at boundary
396 while content.len() < target + 2 {
397 content.push('好');
398 }
399 assert!(content.len() > target);
400 let capped = hard_cap_tool_result(content, cap_tokens(u32::MAX));
401 // Must be valid UTF-8 (no panic during slicing or display).
402 assert!(capped.ends_with("\n[truncated: true]"));
403 // The marker is ASCII; verify the rest is still valid UTF-8.
404 let without_marker = &capped[..capped.len() - "\n[truncated: true]".len()];
405 assert!(std::str::from_utf8(without_marker.as_bytes()).is_ok());
406 }
407
408 #[test]
409 fn hard_cap_respects_custom_max_output_tokens_nonzero() {
410 let content = "X".repeat(10_000);
411 // 100 tokens × 3 bytes = 300 byte cap — much tighter than default.
412 let capped = hard_cap_tool_result(content.clone(), cap_tokens(100));
413 assert!(capped.len() <= 300 + "\n[truncated: true]".len());
414 assert!(capped.ends_with("\n[truncated: true]"));
415 }
416
417 #[test]
418 fn hard_cap_min_token_value_produces_three_byte_cap() {
419 // NonZeroU32::MIN = 1 token → cap at 3 bytes.
420 let content = "hello world".to_string();
421 let capped = hard_cap_tool_result(content, std::num::NonZeroU32::MIN);
422 assert!(capped.len() <= 3 + "\n[truncated: true]".len());
423 assert!(capped.ends_with("\n[truncated: true]"));
424 }
425
425 lines RUST