1pub mod actions;
5pub mod activity;
6pub mod classify;
7pub mod readonly;
8pub mod types;
9
10pub use actions::{
11 action_qualified_policy_name, command_session_action, command_session_action_in, command_session_action_is,
12 file_operation_action, file_operation_action_in, file_operation_action_is, mcp_action, mcp_action_is,
13};
14pub use activity::{
15 GENERIC_VERIFIER_DESCRIPTION, ShellActivity, VERIFIER_SHELL_FORM_NOTE, classify_shell_activity,
16 default_verifier_for_workspace, rewrite_truncation_only_verifier, shell_args_as_executed,
17 shell_command_contains_verifier, shell_command_is_admitted_verification_attempt, shell_command_is_output_search,
18 shell_command_is_standalone_grep_search, verification_recovery_directive, verifier_reference,
19};
20pub use classify::{
21 builtin_tool_behavior, canonical_command_session_tool_name, classify_tool_intent, is_command_run_tool,
22 is_command_run_tool_call, is_command_session_tool, is_command_tool, is_edited_file_conflict_guarded_call,
23 is_exec_session_cleanup_call, is_parallel_safe_call, is_parallel_safe_call_with_intent, is_turn_budget_exempt_call,
24 planning_allowed_actions, remap_file_operation_command_args_to_command_session, should_use_spool_reference_only,
25};
26pub use readonly::{is_readonly_command_session_command, is_spool_file_read_command};
27pub use types::{ToolBehavior, ToolIntent, ToolIntentClassifier, ToolMutationModel, ToolSurfaceKind};
28
29#[cfg(test)]
30mod tests {
31 use super::{
32 canonical_command_session_tool_name, classify_tool_intent, file_operation_action, is_command_run_tool_call,
33 is_command_session_tool, is_edited_file_conflict_guarded_call, is_parallel_safe_call,
34 is_parallel_safe_call_with_intent, remap_file_operation_command_args_to_command_session,
35 should_use_spool_reference_only,
36 };
37 use crate::config::constants::tools;
38 use serde_json::json;
39
40 #[test]
41 fn file_operation_read_is_retry_safe() {
42 let intent = classify_tool_intent(tools::UNIFIED_FILE, &json!({"action": "read", "path": "README.md"}));
43 assert!(!intent.mutating);
44 assert!(intent.readonly_unified_action);
45 assert!(intent.retry_safe);
46 }
47
48 #[test]
49 fn command_session_poll_is_retry_safe() {
50 let intent = classify_tool_intent(tools::UNIFIED_EXEC, &json!({"action": "poll", "session_id": 1}));
51 assert!(!intent.mutating);
52 assert!(intent.readonly_unified_action);
53 assert!(intent.retry_safe);
54 }
55
56 #[test]
57 fn turn_budget_exempt_covers_wait_and_inspect_only() {
58 use super::is_turn_budget_exempt_call;
59
60 assert!(is_turn_budget_exempt_call(tools::UNIFIED_EXEC, &json!({"action": "wait", "session_id": "run-1"})));
62 assert!(is_turn_budget_exempt_call(
63 tools::UNIFIED_EXEC,
64 &json!({"action": "inspect", "spool_path": ".vtcode/context/tool_outputs/run-1.txt"})
65 ));
66 assert!(is_turn_budget_exempt_call(tools::WRITE_STDIN, &json!({"action": "wait", "session_id": "run-1"})));
67 assert!(is_turn_budget_exempt_call(tools::EXEC_COMMAND, &json!({"action": "wait", "session_id": "run-1"})));
68
69 assert!(!is_turn_budget_exempt_call(
71 tools::UNIFIED_EXEC,
72 &json!({"action": "run", "command": "./scripts/check-dev.sh"})
73 ));
74 assert!(!is_turn_budget_exempt_call(tools::UNIFIED_EXEC, &json!({"action": "poll", "session_id": "run-1"})));
75 assert!(!is_turn_budget_exempt_call(
76 tools::UNIFIED_EXEC,
77 &json!({"action": "continue", "session_id": "run-1", "input": "y"})
78 ));
79 assert!(!is_turn_budget_exempt_call(tools::UNIFIED_EXEC, &json!({"action": "list"})));
80
81 assert!(!is_turn_budget_exempt_call(tools::READ_FILE, &json!({"action": "wait", "path": "x.txt"})));
83 assert!(!is_turn_budget_exempt_call(tools::APPLY_PATCH, &json!({"action": "inspect", "input": "..."})));
84 assert!(!is_turn_budget_exempt_call(tools::UNIFIED_FILE, &json!({"action": "read", "path": "x.txt"})));
85
86 assert!(is_turn_budget_exempt_call(tools::RUN_PTY_CMD, &json!({"action": "wait", "session_id": "run-1"})));
88 }
89
90 #[test]
91 fn skill_loading_is_mutating_and_requires_confirmation() {
92 let intent = classify_tool_intent(tools::LOAD_SKILL, &json!({"name": "example"}));
93 assert!(intent.mutating);
94 assert!(intent.destructive);
95 assert!(!intent.retry_safe);
96 }
97
98 #[test]
99 fn command_session_inspect_is_retry_safe() {
100 let intent = classify_tool_intent(
101 tools::UNIFIED_EXEC,
102 &json!({"action": "inspect", "spool_path": ".vtcode/context/tool_outputs/run-1.txt"}),
103 );
104 assert!(!intent.mutating);
105 assert!(intent.readonly_unified_action);
106 assert!(intent.retry_safe);
107 }
108
109 #[test]
110 fn command_session_continue_without_input_is_retry_safe() {
111 let intent = classify_tool_intent(tools::UNIFIED_EXEC, &json!({"action": "continue", "session_id": "run-1"}));
112 assert!(!intent.mutating);
113 assert!(intent.readonly_unified_action);
114 assert!(intent.retry_safe);
115 }
116
117 #[test]
118 fn command_session_continue_with_input_is_mutating_and_destructive() {
119 let intent = classify_tool_intent(
120 tools::UNIFIED_EXEC,
121 &json!({"action": "continue", "session_id": "run-1", "input": "q"}),
122 );
123 assert!(intent.mutating);
124 assert!(intent.destructive);
125 assert!(!intent.readonly_unified_action);
126 assert!(!intent.retry_safe);
127 }
128
129 #[test]
130 fn command_session_continue_with_empty_input_stays_retry_safe() {
131 let intent = classify_tool_intent(
132 tools::UNIFIED_EXEC,
133 &json!({"action": "continue", "session_id": "run-1", "input": ""}),
134 );
135 assert!(!intent.mutating);
136 assert!(intent.readonly_unified_action);
137 assert!(intent.retry_safe);
138 }
139
140 #[test]
141 fn command_session_run_is_mutating_and_destructive() {
142 let intent =
143 classify_tool_intent(tools::UNIFIED_EXEC, &json!({"action": "run", "command": "rm -rf /tmp/test"}));
144 assert!(intent.mutating);
145 assert!(intent.destructive);
146 assert!(!intent.retry_safe);
147 }
148
149 #[test]
150 fn command_session_run_echo_is_read_only() {
151 let intent = classify_tool_intent(tools::UNIFIED_EXEC, &json!({"action": "run", "command": "echo hi"}));
152 assert!(!intent.mutating);
153 assert!(intent.readonly_unified_action);
154 assert!(intent.retry_safe);
155 }
156
157 #[test]
158 fn command_session_run_diff_is_read_only() {
159 let intent = classify_tool_intent(tools::UNIFIED_EXEC, &json!({"action": "run", "command": "diff a.rs b.rs"}));
160 assert!(!intent.mutating);
161 assert!(intent.readonly_unified_action);
162 assert!(intent.retry_safe);
163 }
164
165 #[test]
166 fn command_session_run_find_is_read_only() {
167 let intent = classify_tool_intent(
168 tools::UNIFIED_EXEC,
169 &json!({"action": "run", "command": "find . -type f -name '*.rs' -not -path '*/target/*'"}),
170 );
171 assert!(!intent.mutating);
172 assert!(intent.readonly_unified_action);
173 assert!(intent.retry_safe);
174 }
175
176 #[test]
177 fn command_session_run_grep_wc_head_are_read_only() {
178 for cmd in [
179 "grep -rn 'todo' src",
180 "wc -l src/main.rs",
181 "head -50 src/lib.rs",
182 "tail -20 src/lib.rs",
183 "sort src/words.txt | uniq",
184 "ast-grep -p 'foo($A)' -l rs",
185 ] {
186 let intent = classify_tool_intent(tools::UNIFIED_EXEC, &json!({"action": "run", "command": cmd}));
187 assert!(!intent.mutating, "expected '{cmd}' to be read-only");
188 assert!(intent.readonly_unified_action, "expected '{cmd}' to be readonly_unified_action");
189 }
190 }
191
192 #[test]
193 fn command_session_run_with_redirection_is_mutating() {
194 for cmd in [
195 "cat a.txt > b.txt",
196 "grep x src > out.txt",
197 "diff a b | wc -l > count.txt",
198 "echo $(date) > log.txt",
199 ] {
200 let intent = classify_tool_intent(tools::UNIFIED_EXEC, &json!({"action": "run", "command": cmd}));
201 assert!(intent.mutating, "expected '{cmd}' to be mutating because it contains redirection/substitution");
202 }
203 }
204
205 #[test]
206 fn command_session_run_find_with_destructive_flags_is_mutating() {
207 for cmd in [
208 "find . -type f -delete",
209 "find . -name '*.tmp' -exec rm {} \\;",
210 "find . -name '*.tmp' -exec chmod 600 {} \\;",
211 ] {
212 let intent = classify_tool_intent(tools::UNIFIED_EXEC, &json!({"action": "run", "command": cmd}));
213 assert!(intent.mutating, "expected '{cmd}' to be mutating because it has destructive find flags");
214 }
215 }
216
217 #[test]
218 fn command_session_run_pipelines_with_unsafe_segments_are_mutating() {
219 for cmd in ["cat a.txt | tee b.txt", "python script.py | cat", "grep x src | rm -rf"] {
220 let intent = classify_tool_intent(tools::UNIFIED_EXEC, &json!({"action": "run", "command": cmd}));
221 assert!(intent.mutating, "expected '{cmd}' to be mutating because a pipeline segment is unsafe");
222 }
223 }
224
225 #[test]
226 fn command_session_run_allowlisted_is_read_only() {
227 let intent =
228 classify_tool_intent(tools::UNIFIED_EXEC, &json!({"action": "run", "command": "rg planning_active src"}));
229 assert!(!intent.mutating);
230 assert!(intent.readonly_unified_action);
231 assert!(intent.retry_safe);
232 }
233
234 #[test]
235 fn command_session_run_dry_run_is_read_only() {
236 let intent =
237 classify_tool_intent(tools::UNIFIED_EXEC, &json!({"action": "run", "command": "npm install --dry-run"}));
238 assert!(!intent.mutating);
239 assert!(intent.readonly_unified_action);
240 assert!(intent.retry_safe);
241 }
242
243 #[test]
244 fn parallel_safe_calls_reject_control_and_exec_paths() {
245 assert!(is_parallel_safe_call(tools::READ_FILE, &json!({"path": "README.md"})));
246 assert!(!is_parallel_safe_call(tools::LIST_PTY_SESSIONS, &json!({})));
247 assert!(!is_parallel_safe_call(tools::REQUEST_USER_INPUT, &json!({"questions": []})));
248 assert!(!is_parallel_safe_call(tools::UNIFIED_EXEC, &json!({"action": "inspect", "session_id": "run-1"})));
249 assert!(is_parallel_safe_call(tools::EXEC_COMMAND, &json!({"cmd": "sed -n '1,40p' README.md"})));
250 assert!(!is_parallel_safe_call(tools::EXEC_COMMAND, &json!({"cmd": "cat README.md && cat Cargo.toml"})));
251 assert!(!is_parallel_safe_call(
252 tools::EXEC_COMMAND,
253 &json!({"cmd": "sed -n '1,40p' README.md", "tty": true})
254 ));
255 }
256
257 #[test]
258 fn parallel_safe_with_intent_matches_classifying_variant() {
259 for (tool_name, args) in [
263 (tools::UNIFIED_EXEC, json!({"action": "run", "command": "rg -n 'todo' src"})),
264 (tools::UNIFIED_EXEC, json!({"action": "run", "command": "rm -rf /tmp/test"})),
265 (tools::UNIFIED_EXEC, json!({"action": "poll", "session_id": "run-1"})),
266 (tools::EXEC_COMMAND, json!({"cmd": "sed -n '1,40p' README.md"})),
267 (tools::READ_FILE, json!({"path": "README.md"})),
268 (tools::WRITE_FILE, json!({"path": "README.md", "content": "x"})),
269 (tools::APPLY_PATCH, json!({"input": "*** Begin Patch\n*** End Patch\n"})),
270 ("totally_unknown_tool", json!({"path": "README.md"})),
271 ] {
272 let with_intent =
273 is_parallel_safe_call_with_intent(tool_name, &args, &classify_tool_intent(tool_name, &args));
274 assert_eq!(
275 with_intent,
276 is_parallel_safe_call(tool_name, &args),
277 "variants disagree for {tool_name} with {args}"
278 );
279 }
280 }
281
282 #[test]
283 fn command_session_cmd_alias_infers_run() {
284 let intent = classify_tool_intent(tools::UNIFIED_EXEC, &json!({"cmd": "rm -rf /tmp/test"}));
285 assert!(intent.mutating);
286 assert!(intent.destructive);
287 assert!(!intent.readonly_unified_action);
288 }
289
290 #[test]
291 fn command_session_chars_alias_infers_write() {
292 let intent = classify_tool_intent(tools::UNIFIED_EXEC, &json!({"session_id": "abc123", "chars": "status\n"}));
293 assert!(intent.mutating);
294 assert!(intent.destructive);
295 assert!(!intent.readonly_unified_action);
296 }
297
298 #[test]
299 fn write_stdin_empty_chars_is_read_only_and_retry_safe() {
300 let intent = classify_tool_intent(tools::WRITE_STDIN, &json!({"session_id": "abc123", "chars": ""}));
301
302 assert!(!intent.mutating);
303 assert!(!intent.destructive);
304 assert!(!intent.readonly_unified_action);
305 assert!(intent.retry_safe);
306 }
307
308 #[test]
309 fn write_stdin_non_empty_chars_is_mutating() {
310 let intent = classify_tool_intent(tools::WRITE_STDIN, &json!({"session_id": "abc123", "chars": " status\n"}));
311
312 assert!(intent.mutating);
313 assert!(intent.destructive);
314 assert!(!intent.readonly_unified_action);
315 assert!(!intent.retry_safe);
316 }
317
318 #[test]
319 fn command_session_text_alias_infers_write() {
320 let intent = classify_tool_intent(tools::UNIFIED_EXEC, &json!({"session_id": "abc123", "text": "status\n"}));
321 assert!(intent.mutating);
322 assert!(intent.destructive);
323 assert!(!intent.readonly_unified_action);
324 }
325
326 #[test]
327 fn command_session_spool_path_alias_infers_inspect() {
328 let intent =
329 classify_tool_intent(tools::UNIFIED_EXEC, &json!({"spool_path": ".vtcode/context/tool_outputs/run-1.txt"}));
330 assert!(!intent.mutating);
331 assert!(!intent.destructive);
332 assert!(intent.readonly_unified_action);
333 }
334
335 #[test]
336 fn command_session_compact_session_alias_infers_poll() {
337 let intent = classify_tool_intent(tools::UNIFIED_EXEC, &json!({"s": "run-1"}));
338 assert!(!intent.mutating);
339 assert!(!intent.destructive);
340 assert!(intent.readonly_unified_action);
341 }
342
343 #[test]
344 fn file_operation_unknown_args_require_action() {
345 let args = json!({
346 "unexpected": true
347 });
348 let action = file_operation_action(&args);
349 assert_eq!(action, None);
350 }
351
352 #[test]
353 fn file_operation_compact_path_alias_infers_read() {
354 let args = json!({
355 "p": "README.md"
356 });
357 let action = file_operation_action(&args);
358 assert_eq!(action, Some("read"));
359 }
360
361 #[test]
362 fn remap_file_operation_command_args_maps_command_payload_to_command_session() {
363 let remapped = remap_file_operation_command_args_to_command_session(&json!({
364 "command": "cargo check",
365 "cwd": ".",
366 "timeout_ms": 1000
367 }))
368 .expect("command payload should remap");
369
370 assert_eq!(remapped["action"], "run");
371 assert_eq!(remapped["command"], "cargo check");
372 assert_eq!(remapped["cwd"], ".");
373 assert_eq!(remapped["timeout_ms"], 1000);
374 }
375
376 #[test]
377 fn remap_file_operation_command_args_accepts_exec_action_aliases() {
378 let remapped = remap_file_operation_command_args_to_command_session(&json!({
379 "action": "shell",
380 "cmd": "echo ok"
381 }))
382 .expect("shell action alias should remap");
383
384 assert_eq!(remapped["action"], "run");
385 assert_eq!(remapped["command"], "echo ok");
386 }
387
388 #[test]
389 fn remap_file_operation_command_args_rejects_non_command_actions() {
390 let remapped = remap_file_operation_command_args_to_command_session(&json!({
391 "action": "read",
392 "command": "echo ok"
393 }));
394
395 assert_eq!(remapped, None);
396 }
397
398 #[test]
399 fn edited_file_conflict_guard_accepts_supported_mutations() {
400 assert!(is_edited_file_conflict_guarded_call(
401 tools::WRITE_FILE,
402 &json!({"path": "README.md", "content": "agent"})
403 ));
404 assert!(is_edited_file_conflict_guarded_call(
405 tools::CREATE_FILE,
406 &json!({"path": "README.md", "content": "agent"})
407 ));
408 assert!(is_edited_file_conflict_guarded_call(
409 tools::EDIT_FILE,
410 &json!({"path": "README.md", "old_str": "a", "new_str": "b"})
411 ));
412 assert!(is_edited_file_conflict_guarded_call(
413 tools::APPLY_PATCH,
414 &json!({"patch": "*** Begin Patch\n*** End Patch\n"})
415 ));
416 assert!(is_edited_file_conflict_guarded_call(
417 tools::UNIFIED_FILE,
418 &json!({"action": "write", "path": "README.md", "content": "agent"})
419 ));
420 assert!(is_edited_file_conflict_guarded_call(
421 tools::UNIFIED_FILE,
422 &json!({"action": "create", "path": "README.md", "content": "agent"})
423 ));
424 }
425
426 #[test]
427 fn edited_file_conflict_guard_rejects_non_guarded_calls() {
428 assert!(!is_edited_file_conflict_guarded_call(tools::READ_FILE, &json!({"path": "README.md"})));
429 assert!(!is_edited_file_conflict_guarded_call(tools::GREP_FILE, &json!({"pattern": "needle", "path": "."})));
430 assert!(!is_edited_file_conflict_guarded_call(tools::LIST_FILES, &json!({"path": "."})));
431 assert!(!is_edited_file_conflict_guarded_call(
432 tools::UNIFIED_FILE,
433 &json!({"action": "read", "path": "README.md"})
434 ));
435 assert!(!is_edited_file_conflict_guarded_call(
436 tools::UNIFIED_FILE,
437 &json!({"action": "delete", "path": "README.md"})
438 ));
439 assert!(!is_edited_file_conflict_guarded_call(
440 tools::UNIFIED_EXEC,
441 &json!({"action": "run", "command": "git status"})
442 ));
443 }
444
445 #[test]
446 fn canonical_command_session_tool_name_normalizes_exec_aliases() {
447 for alias in [
448 tools::UNIFIED_EXEC,
449 tools::RUN_PTY_CMD,
450 tools::SEND_PTY_INPUT,
451 tools::READ_PTY_SESSION,
452 tools::LIST_PTY_SESSIONS,
453 tools::CLOSE_PTY_SESSION,
454 tools::EXECUTE_CODE,
455 tools::EXEC_PTY_CMD,
456 tools::EXEC_COMMAND,
457 tools::WRITE_STDIN,
458 tools::SHELL,
459 "bash",
460 "exec",
461 "container.exec",
462 ] {
463 assert_eq!(canonical_command_session_tool_name(alias), Some(tools::UNIFIED_EXEC));
464 assert!(is_command_session_tool(alias));
465 }
466 assert!(!is_command_session_tool(tools::READ_FILE));
467 assert!(!is_command_session_tool(""));
468 }
469
470 #[test]
471 fn spool_reference_only_detects_exec_aliases() {
472 assert!(should_use_spool_reference_only(
473 Some(tools::RUN_PTY_CMD),
474 &json!({"spool_path": ".vtcode/context/tool_outputs/run-1.txt"})
475 ));
476 }
477
478 #[test]
479 fn spool_reference_only_detects_exec_payload_without_tool_name() {
480 assert!(should_use_spool_reference_only(
481 None,
482 &json!({
483 "command": "cargo check",
484 "spool_path": ".vtcode/context/tool_outputs/run-1.txt",
485 "exit_code": 0
486 })
487 ));
488 }
489
490 #[test]
491 fn spool_reference_only_skips_loop_recovery_payloads() {
492 assert!(!should_use_spool_reference_only(
493 Some(tools::UNIFIED_EXEC),
494 &json!({
495 "spool_path": ".vtcode/context/tool_outputs/run-1.txt",
496 "exit_code": 0,
497 "loop_detected": true
498 })
499 ));
500 }
501
502 #[test]
503 fn is_command_run_tool_call_only_accepts_run_actions() {
504 assert!(is_command_run_tool_call(tools::RUN_PTY_CMD, &json!({"command": "cargo check"})));
505 assert!(is_command_run_tool_call(tools::UNIFIED_EXEC, &json!({"action": "run", "command": "cargo check"})));
506 assert!(is_command_run_tool_call(tools::EXEC_COMMAND, &json!({"cmd": "cargo check"})));
507 assert!(!is_command_run_tool_call(tools::UNIFIED_EXEC, &json!({"action": "poll", "session_id": "run-1"})));
508 assert!(!is_command_run_tool_call(tools::WRITE_STDIN, &json!({"session_id": "run-1", "chars": "q"})));
509 }
510}