zagens_core/engine/
tool_progress.rs1use std::fs::OpenOptions;
4use std::io::Write;
5use std::path::PathBuf;
6
7use serde_json::Value;
8
9const PROGRESS_CMD_PREVIEW_BYTES: usize = 120;
10
11fn truncate_progress_preview(text: &str, max_bytes: usize) -> &str {
13 if text.len() <= max_bytes {
14 return text;
15 }
16 let mut end = max_bytes;
17 while end > 0 && !text.is_char_boundary(end) {
18 end -= 1;
19 }
20 &text[..end]
21}
22
23pub fn emit_tool_audit(event: Value) {
25 let Some(path) = std::env::var_os("DEEPSEEK_TOOL_AUDIT_LOG") else {
26 return;
27 };
28 let line = match serde_json::to_string(&event) {
29 Ok(line) => line,
30 Err(_) => return,
31 };
32 let path = PathBuf::from(path);
33 if let Some(parent) = path.parent() {
34 let _ = std::fs::create_dir_all(parent);
35 }
36 if let Ok(mut file) = OpenOptions::new().create(true).append(true).open(path) {
37 let _ = writeln!(file, "{line}");
38 }
39}
40
41#[must_use]
43pub fn tool_progress_opening_line(tool_name: &str, input: &Value) -> String {
44 match tool_name {
45 "write_file" | "edit_file" | "read_file" => match input.get("path").and_then(Value::as_str)
46 {
47 Some(p) if !p.is_empty() => format!("{tool_name} → {p}"),
48 _ => format!("{tool_name} …"),
49 },
50 "apply_patch" => "apply_patch → unified diff".to_string(),
51 "write_office" => {
52 let fmt = input.get("format").and_then(Value::as_str).unwrap_or("");
53 match input.get("path").and_then(Value::as_str) {
54 Some(p) if !p.is_empty() && !fmt.is_empty() => {
55 format!("write_office ({fmt}) → {p}")
56 }
57 Some(p) if !p.is_empty() => format!("write_office → {p}"),
58 _ => "write_office …".to_string(),
59 }
60 }
61 "exec_shell" => match input.get("command").and_then(Value::as_str) {
62 Some(cmd) if cmd.len() > PROGRESS_CMD_PREVIEW_BYTES => {
63 format!(
64 "exec_shell: {}…",
65 truncate_progress_preview(cmd, PROGRESS_CMD_PREVIEW_BYTES)
66 )
67 }
68 Some(cmd) if !cmd.is_empty() => format!("exec_shell: {cmd}"),
69 _ => "exec_shell …".to_string(),
70 },
71 "task_shell_start" => match input.get("command").and_then(Value::as_str) {
72 Some(cmd) if cmd.len() > PROGRESS_CMD_PREVIEW_BYTES => {
73 format!(
74 "task_shell_start: {}…",
75 truncate_progress_preview(cmd, PROGRESS_CMD_PREVIEW_BYTES)
76 )
77 }
78 Some(cmd) if !cmd.is_empty() => format!("task_shell_start: {cmd}"),
79 _ => "task_shell_start …".to_string(),
80 },
81 "task_shell_wait" => match input.get("task_id").and_then(Value::as_str) {
82 Some(id) if !id.is_empty() => format!("task_shell_wait → {id}"),
83 _ => "task_shell_wait …".to_string(),
84 },
85 other => format!("{other} …"),
86 }
87}
88
89#[must_use]
90pub fn tool_progress_phase_line(tool_name: &str) -> &'static str {
91 match tool_name {
92 "write_file" => "Writing file and building diff preview…",
93 "edit_file" => "Reading target file and applying replacement…",
94 "apply_patch" => "Applying patch hunks to workspace…",
95 "read_file" => "Reading from disk…",
96 "write_office" => "Generating Office document (may take a few seconds)…",
97 "exec_shell" => "Running shell command…",
98 "task_shell_start" => "Starting background shell task…",
99 "task_shell_wait" => "Collecting task output…",
100 _ => "Executing tool…",
101 }
102}
103
104#[cfg(test)]
105mod tests {
106 use super::*;
107 use serde_json::json;
108 use std::sync::Mutex;
109
110 static AUDIT_TEST_GUARD: Mutex<()> = Mutex::new(());
111
112 fn audit_test_guard() -> std::sync::MutexGuard<'static, ()> {
113 AUDIT_TEST_GUARD.lock().unwrap_or_else(|e| e.into_inner())
114 }
115
116 #[test]
117 fn emit_tool_audit_writes_jsonl_line_when_env_var_set() {
118 let _g = audit_test_guard();
119 let tmp = tempfile::tempdir().expect("tempdir");
120 let path = tmp.path().join("audit.log");
121 unsafe {
122 std::env::set_var("DEEPSEEK_TOOL_AUDIT_LOG", &path);
123 }
124
125 emit_tool_audit(json!({
126 "event": "tool.spillover",
127 "tool_id": "call-abc",
128 "tool_name": "exec_shell",
129 "path": "/tmp/foo.txt",
130 }));
131 emit_tool_audit(json!({
132 "event": "tool.result",
133 "tool_id": "call-xyz",
134 "success": true,
135 }));
136
137 let body = std::fs::read_to_string(&path).expect("audit log written");
138 let lines: Vec<&str> = body.lines().collect();
139 assert_eq!(lines.len(), 2);
140
141 let first: Value = serde_json::from_str(lines[0]).expect("first line is JSON");
142 assert_eq!(
143 first.get("event").and_then(|v| v.as_str()),
144 Some("tool.spillover")
145 );
146
147 unsafe {
148 std::env::remove_var("DEEPSEEK_TOOL_AUDIT_LOG");
149 }
150 }
151
152 #[test]
153 fn emit_tool_audit_is_noop_when_env_var_unset() {
154 let _g = audit_test_guard();
155 unsafe {
156 std::env::remove_var("DEEPSEEK_TOOL_AUDIT_LOG");
157 }
158 emit_tool_audit(json!({"event": "noop", "x": 1}));
159 }
160
161 #[test]
162 fn tool_progress_opening_line_exec_shell_truncates_at_char_boundary() {
163 let prefix = "x".repeat(118);
164 let cmd = format!("{prefix}中文");
165 assert!(cmd.len() > PROGRESS_CMD_PREVIEW_BYTES);
166 assert!(!cmd.is_char_boundary(PROGRESS_CMD_PREVIEW_BYTES));
167
168 let line = tool_progress_opening_line("exec_shell", &json!({ "command": cmd }));
169 assert!(line.starts_with("exec_shell: "));
170 assert!(line.ends_with('…'));
171 }
172
173 #[test]
174 fn tool_progress_opening_line_exec_shell_whiteboard_word_count_command() {
175 let cmd = concat!(
176 "node -e \"const s=require('fs').readFileSync('DESIGN.md','utf8');",
177 "const w=s.match(/[\\u4e00-\\u9fff]+/g)||[];",
178 "console.log('中文字符:',w.join('').length,'约',Math.round(w.join('').length/2),'字')\""
179 );
180 assert!(cmd.len() > PROGRESS_CMD_PREVIEW_BYTES);
181
182 let line = tool_progress_opening_line("exec_shell", &json!({ "command": cmd }));
183 assert!(line.starts_with("exec_shell: "));
184 assert!(line.ends_with('…'));
185 }
186
187 #[test]
188 fn tool_progress_opening_line_task_shell_start_truncates_at_char_boundary() {
189 let prefix = "y".repeat(118);
190 let cmd = format!("{prefix}中文");
191 assert!(cmd.len() > PROGRESS_CMD_PREVIEW_BYTES);
192
193 let line = tool_progress_opening_line("task_shell_start", &json!({ "command": cmd }));
194 assert!(line.starts_with("task_shell_start: "));
195 assert!(line.ends_with('…'));
196 }
197}