Skip to main content

zagens_core/engine/
tool_progress.rs

1//! Tool progress copy and optional JSONL audit logging (P2 PR4 → `zagens-core`).
2
3use 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
11/// Truncate shell command previews without splitting multibyte UTF-8 sequences.
12fn 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
23/// Append one JSON line to `DEEPSEEK_TOOL_AUDIT_LOG` when the env var is set.
24pub 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/// First progress line shown when a long-running tool starts.
42#[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}