release/v1.5.10
claude/github-issue-40-0au5ua
claude/linked-issue-acp-tui-u4vvpa
claude/minor-release-cut-vkugow
claude/sigit-acp-local-chat-cx6380
claude/sigit-cloud-agent-expansion-reox0a
claude/tool-permission-system
claude/zen-feynman-0u78dk
development
feature/acp-cancel-request
feature/acp-session-list-cursor
feature/acp-v1-conformance
feature/agent-tools-multiedit-glob-todos-remember
feature/cargo-workspace
feature/hooks
feature/load-local-model-explicitly
feature/sigit-agent-profile
feature/sigit-code-cloud
feature/xcode-mcp-server
fix/acp-replay-loaded-session-history
fix/acp-replay-reasoning-and-attachments
fix/dsml-doubled-bar-markers
main
release/v1.5.10
release/v1.5.6
release/v1.6.1
| 1 | //! Agent tools: schema definitions + execution for siGit Code. |
| 2 | |
| 3 | use regex::Regex; |
| 4 | use serde_json::{Value, json}; |
| 5 | use std::collections::HashMap; |
| 6 | use std::fs; |
| 7 | use std::path::{Path, PathBuf}; |
| 8 | use std::process::Command; |
| 9 | use std::sync::atomic::{AtomicU64, Ordering}; |
| 10 | use std::sync::{Arc, Mutex, OnceLock}; |
| 11 | |
| 12 | use crate::backend::{InferenceBackend, ToolResult, ToolSpec}; |
| 13 | use crate::subagents; |
| 14 | |
| 15 | const WEBSITE_READ_CHAR_LIMIT: usize = 20_000; |
| 16 | const WEBSITE_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(20); |
| 17 | const WEBSITE_USER_AGENT: &str = |
| 18 | "siGit/0.1 (+https://github.com/getsigit/sigit; website-reading tool)"; |
| 19 | |
| 20 | const READ_FILE_CHAR_LIMIT: usize = 10_000; |
| 21 | const SEARCH_FILES_MATCH_LIMIT: usize = 50; |
| 22 | /// Upper bound on `max_results` for `search_files` and the number of paths |
| 23 | /// returned by `glob`, so a broad pattern can't flood the context window. |
| 24 | const SEARCH_RESULTS_HARD_CAP: usize = 1_000; |
| 25 | |
| 26 | // ── Tool schemas ───────────────────────────────────────────────────────────── |
| 27 | |
| 28 | pub struct AgentTool { |
| 29 | pub name: &'static str, |
| 30 | pub description: &'static str, |
| 31 | pub parameters_schema: Value, |
| 32 | } |
| 33 | |
| 34 | pub fn all_tools() -> Vec<AgentTool> { |
| 35 | vec![ |
| 36 | AgentTool { |
| 37 | name: "read_file", |
| 38 | description: "Read the contents of a file at the given path. \ |
| 39 | Prefer an absolute path when possible. Use start_line and \ |
| 40 | end_line to read a specific range instead of the whole file — \ |
| 41 | strongly prefer this when you already know which lines matter. \ |
| 42 | Output is truncated to 10 000 characters.", |
| 43 | parameters_schema: json!({ |
| 44 | "type": "object", |
| 45 | "properties": { |
| 46 | "path": { |
| 47 | "type": "string", |
| 48 | "description": "Absolute or relative path to the file to read." |
| 49 | }, |
| 50 | "start_line": { |
| 51 | "type": "integer", |
| 52 | "description": "First line to read (1-based, inclusive). Omit to start from the beginning." |
| 53 | }, |
| 54 | "end_line": { |
| 55 | "type": "integer", |
| 56 | "description": "Last line to read (1-based, inclusive). Omit to read to the end." |
| 57 | } |
| 58 | }, |
| 59 | "required": ["path"], |
| 60 | "additionalProperties": false |
| 61 | }), |
| 62 | }, |
| 63 | AgentTool { |
| 64 | name: "create_directory", |
| 65 | description: "Create a directory at the given path. \ |
| 66 | Prefer an absolute path when possible. Missing parent \ |
| 67 | directories are created automatically. Use this before \ |
| 68 | create_file when the parent path does not exist. Succeeds \ |
| 69 | if the directory already exists.", |
| 70 | parameters_schema: json!({ |
| 71 | "type": "object", |
| 72 | "properties": { |
| 73 | "path": { |
| 74 | "type": "string", |
| 75 | "description": "Absolute or relative path to the directory to create." |
| 76 | } |
| 77 | }, |
| 78 | "required": ["path"], |
| 79 | "additionalProperties": false |
| 80 | }), |
| 81 | }, |
| 82 | AgentTool { |
| 83 | name: "list_directory", |
| 84 | description: "List files and directories at the given path. \ |
| 85 | Prefer an absolute path when possible. Each entry is \ |
| 86 | prefixed with [DIR] or [FILE]. Directories are listed \ |
| 87 | first, sorted alphabetically.", |
| 88 | parameters_schema: json!({ |
| 89 | "type": "object", |
| 90 | "properties": { |
| 91 | "path": { |
| 92 | "type": "string", |
| 93 | "description": "Absolute or relative path to the directory to list." |
| 94 | } |
| 95 | }, |
| 96 | "required": ["path"], |
| 97 | "additionalProperties": false |
| 98 | }), |
| 99 | }, |
| 100 | AgentTool { |
| 101 | name: "search_files", |
| 102 | description: "Search for a regex pattern across files in a directory tree. \ |
| 103 | Prefer an absolute root path when possible. Returns matching \ |
| 104 | lines in `file:line_number: content` format. Skips binary \ |
| 105 | files and hidden directories. Pass `file_glob` to restrict the \ |
| 106 | search to files whose name matches a glob (e.g. \"*.rs\"), and \ |
| 107 | `max_results` to raise or lower the default cap of 50 matches.", |
| 108 | parameters_schema: json!({ |
| 109 | "type": "object", |
| 110 | "properties": { |
| 111 | "pattern": { |
| 112 | "type": "string", |
| 113 | "description": "Regular expression pattern to search for." |
| 114 | }, |
| 115 | "path": { |
| 116 | "type": "string", |
| 117 | "description": "Root directory to search in. Defaults to \".\" (current directory)." |
| 118 | }, |
| 119 | "file_glob": { |
| 120 | "type": "string", |
| 121 | "description": "Optional glob on the file name (not the full path), e.g. \"*.rs\" or \"*.{ts,tsx}\". Only matching files are searched." |
| 122 | }, |
| 123 | "max_results": { |
| 124 | "type": "integer", |
| 125 | "description": "Maximum number of matching lines to return (default 50, capped at 1000)." |
| 126 | } |
| 127 | }, |
| 128 | "required": ["pattern"], |
| 129 | "additionalProperties": false |
| 130 | }), |
| 131 | }, |
| 132 | AgentTool { |
| 133 | name: "read_website", |
| 134 | description: "Fetch a web page and return readable text content. \ |
| 135 | Use this when the user gives you a URL and asks you to read, \ |
| 136 | summarize, inspect, or extract information from the page. \ |
| 137 | Supports normal http and https URLs. Output is truncated if the \ |
| 138 | page is very large.", |
| 139 | parameters_schema: json!({ |
| 140 | "type": "object", |
| 141 | "properties": { |
| 142 | "url": { |
| 143 | "type": "string", |
| 144 | "description": "Absolute http or https URL to fetch." |
| 145 | } |
| 146 | }, |
| 147 | "required": ["url"], |
| 148 | "additionalProperties": false |
| 149 | }), |
| 150 | }, |
| 151 | AgentTool { |
| 152 | name: "create_file", |
| 153 | description: "Create a new file at the given path with the provided content. \ |
| 154 | Prefer an absolute path when possible. Parent directories are \ |
| 155 | created automatically if they do not exist. Fails if the file \ |
| 156 | already exists — use edit_file to modify existing files.", |
| 157 | parameters_schema: json!({ |
| 158 | "type": "object", |
| 159 | "properties": { |
| 160 | "path": { |
| 161 | "type": "string", |
| 162 | "description": "Absolute or relative path for the new file." |
| 163 | }, |
| 164 | "content": { |
| 165 | "type": "string", |
| 166 | "description": "The full text content to write into the new file." |
| 167 | } |
| 168 | }, |
| 169 | "required": ["path", "content"], |
| 170 | "additionalProperties": false |
| 171 | }), |
| 172 | }, |
| 173 | AgentTool { |
| 174 | name: "edit_file", |
| 175 | description: "Edit an existing file by replacing an exact substring (old_text) with \ |
| 176 | new text (new_text). Prefer an absolute path when possible. By \ |
| 177 | default old_text must appear exactly once; set replace_all to true \ |
| 178 | to replace every occurrence (useful for renaming a symbol). Use \ |
| 179 | read_file first to see the current content and identify the exact \ |
| 180 | text to replace. To append to a file, match the last few lines as \ |
| 181 | old_text and include them plus the new content as new_text.", |
| 182 | parameters_schema: json!({ |
| 183 | "type": "object", |
| 184 | "properties": { |
| 185 | "path": { |
| 186 | "type": "string", |
| 187 | "description": "Path to the existing file to edit." |
| 188 | }, |
| 189 | "old_text": { |
| 190 | "type": "string", |
| 191 | "description": "The exact text span to find and replace. Must match exactly once unless replace_all is true." |
| 192 | }, |
| 193 | "new_text": { |
| 194 | "type": "string", |
| 195 | "description": "The replacement text that will take the place of old_text." |
| 196 | }, |
| 197 | "replace_all": { |
| 198 | "type": "boolean", |
| 199 | "description": "Replace every occurrence of old_text instead of requiring a unique match. Defaults to false." |
| 200 | } |
| 201 | }, |
| 202 | "required": ["path", "old_text", "new_text"], |
| 203 | "additionalProperties": false |
| 204 | }), |
| 205 | }, |
| 206 | AgentTool { |
| 207 | name: "delete_file", |
| 208 | description: "Delete a file or empty directory at the given path. \ |
| 209 | Prefer an absolute path when possible. Refuses to delete \ |
| 210 | non-empty directories to prevent accidental data loss. \ |
| 211 | Use read_file or list_directory first to confirm the target.", |
| 212 | parameters_schema: json!({ |
| 213 | "type": "object", |
| 214 | "properties": { |
| 215 | "path": { |
| 216 | "type": "string", |
| 217 | "description": "Absolute or relative path to the file or empty directory to delete." |
| 218 | } |
| 219 | }, |
| 220 | "required": ["path"], |
| 221 | "additionalProperties": false |
| 222 | }), |
| 223 | }, |
| 224 | AgentTool { |
| 225 | name: "run_command", |
| 226 | description: "Run a shell command and return its combined stdout and stderr output. \ |
| 227 | The command runs in the given working directory (defaults to the \ |
| 228 | user's home directory). Always use an absolute working directory \ |
| 229 | path. Use this for build tools (cargo, npm, make), package managers, \ |
| 230 | linters, test runners, and git commands, including git init, \ |
| 231 | porcelain commands like status/add/commit/checkout, and plumbing \ |
| 232 | commands like rev-parse, hash-object, update-ref, and cat-file. \ |
| 233 | For `git clone`, always specify the full absolute destination path \ |
| 234 | as the last argument (e.g. `git clone <url> /absolute/path/to/dir`) \ |
| 235 | and set cwd to the parent directory. Never run `git clone` without \ |
| 236 | an explicit destination. If the user asks for a new repo or scaffold, \ |
| 237 | use this for `git clone`, `git init`, and normal repo setup steps. \ |
| 238 | In smbCloud repos, prefer existing workspace commands, Rails \ |
| 239 | conventions, and deploy flows over inventing new command sequences. \ |
| 240 | Foreground commands are killed after 120 seconds — run servers, \ |
| 241 | watchers, builds, test suites, and anything that may exceed two \ |
| 242 | minutes with run_in_background set to true, then poll with \ |
| 243 | command_output.", |
| 244 | parameters_schema: json!({ |
| 245 | "type": "object", |
| 246 | "properties": { |
| 247 | "command": { |
| 248 | "type": "string", |
| 249 | "description": "The shell command to execute (e.g. \"cargo update\", \"git status\", \"git rev-parse HEAD\")." |
| 250 | }, |
| 251 | "cwd": { |
| 252 | "type": "string", |
| 253 | "description": "Working directory for the command. Defaults to \".\" (current directory)." |
| 254 | }, |
| 255 | "run_in_background": { |
| 256 | "type": "boolean", |
| 257 | "description": "Start the command as a background task and return a task id immediately instead of waiting for it to finish. Set this to true for servers, watchers, builds, test suites, and anything that may run longer than two minutes (foreground commands are killed after 120 seconds). Poll the task's output and status with command_output, and stop it with kill_command. Background tasks are killed when sigit exits. Defaults to false." |
| 258 | } |
| 259 | }, |
| 260 | "required": ["command"], |
| 261 | "additionalProperties": false |
| 262 | }), |
| 263 | }, |
| 264 | AgentTool { |
| 265 | name: "multi_edit", |
| 266 | description: "Apply several exact-substring edits to a single file in one call. \ |
| 267 | Edits are applied in order, each to the result of the previous one, \ |
| 268 | and the whole batch is atomic — if any edit fails to match, the file \ |
| 269 | is left untouched and an error explains which edit failed. Prefer \ |
| 270 | this over multiple edit_file calls when changing several spots in the \ |
| 271 | same file. Each edit has old_text (must match exactly once, or every \ |
| 272 | time when replace_all is true) and new_text.", |
| 273 | parameters_schema: json!({ |
| 274 | "type": "object", |
| 275 | "properties": { |
| 276 | "path": { |
| 277 | "type": "string", |
| 278 | "description": "Path to the existing file to edit." |
| 279 | }, |
| 280 | "edits": { |
| 281 | "type": "array", |
| 282 | "description": "Ordered list of edits to apply to the file.", |
| 283 | "items": { |
| 284 | "type": "object", |
| 285 | "properties": { |
| 286 | "old_text": { |
| 287 | "type": "string", |
| 288 | "description": "The exact text span to find and replace." |
| 289 | }, |
| 290 | "new_text": { |
| 291 | "type": "string", |
| 292 | "description": "The replacement text." |
| 293 | }, |
| 294 | "replace_all": { |
| 295 | "type": "boolean", |
| 296 | "description": "Replace every occurrence instead of requiring a unique match. Defaults to false." |
| 297 | } |
| 298 | }, |
| 299 | "required": ["old_text", "new_text"], |
| 300 | "additionalProperties": false |
| 301 | } |
| 302 | } |
| 303 | }, |
| 304 | "required": ["path", "edits"], |
| 305 | "additionalProperties": false |
| 306 | }), |
| 307 | }, |
| 308 | AgentTool { |
| 309 | name: "glob", |
| 310 | description: "Find files by name using a glob pattern (e.g. \"**/*.rs\", \ |
| 311 | \"src/**/*.{ts,tsx}\", \"Cargo.toml\"). Returns matching file paths, \ |
| 312 | most-recently-modified first. Supports `*` (any run of non-separator \ |
| 313 | characters), `**` (any number of directories), `?` (one character), \ |
| 314 | and `{a,b}` alternation. Use this to locate files by name; use \ |
| 315 | search_files to search file contents.", |
| 316 | parameters_schema: json!({ |
| 317 | "type": "object", |
| 318 | "properties": { |
| 319 | "pattern": { |
| 320 | "type": "string", |
| 321 | "description": "Glob pattern matched against paths relative to the search root." |
| 322 | }, |
| 323 | "path": { |
| 324 | "type": "string", |
| 325 | "description": "Root directory to search in. Defaults to \".\" (current directory)." |
| 326 | } |
| 327 | }, |
| 328 | "required": ["pattern"], |
| 329 | "additionalProperties": false |
| 330 | }), |
| 331 | }, |
| 332 | AgentTool { |
| 333 | name: "write_todos", |
| 334 | description: "Record or update a checklist of the steps for the current task. \ |
| 335 | Use this for any multi-step task to plan the work and show progress: \ |
| 336 | call it once up front with all the steps as `pending`, then call it \ |
| 337 | again whenever a step's status changes. Mark exactly one step \ |
| 338 | `in_progress` at a time and `completed` as soon as it is done. \ |
| 339 | Keep the list short and outcome-focused.", |
| 340 | parameters_schema: json!({ |
| 341 | "type": "object", |
| 342 | "properties": { |
| 343 | "todos": { |
| 344 | "type": "array", |
| 345 | "description": "The full, current checklist (replaces any previous list).", |
| 346 | "items": { |
| 347 | "type": "object", |
| 348 | "properties": { |
| 349 | "content": { |
| 350 | "type": "string", |
| 351 | "description": "Short imperative description of the step." |
| 352 | }, |
| 353 | "status": { |
| 354 | "type": "string", |
| 355 | "enum": ["pending", "in_progress", "completed"], |
| 356 | "description": "Current status of the step." |
| 357 | } |
| 358 | }, |
| 359 | "required": ["content", "status"], |
| 360 | "additionalProperties": false |
| 361 | } |
| 362 | } |
| 363 | }, |
| 364 | "required": ["todos"], |
| 365 | "additionalProperties": false |
| 366 | }), |
| 367 | }, |
| 368 | AgentTool { |
| 369 | name: "remember", |
| 370 | description: "Persist a durable note, preference, or convention by appending it to \ |
| 371 | this project's instruction file (AGENTS.md / CLAUDE.md). Use this \ |
| 372 | when the user asks you to remember something for next time, or states \ |
| 373 | a lasting preference about how to work in this project. The note is \ |
| 374 | written to the nearest existing instruction file, or a new CLAUDE.md \ |
| 375 | at the repository root if none exists yet.", |
| 376 | parameters_schema: json!({ |
| 377 | "type": "object", |
| 378 | "properties": { |
| 379 | "note": { |
| 380 | "type": "string", |
| 381 | "description": "The fact or preference to remember, phrased as a standalone instruction." |
| 382 | } |
| 383 | }, |
| 384 | "required": ["note"], |
| 385 | "additionalProperties": false |
| 386 | }), |
| 387 | }, |
| 388 | AgentTool { |
| 389 | name: "command_output", |
| 390 | description: "Get the output a background task (started with run_command's \ |
| 391 | run_in_background) has produced since your last check, plus its \ |
| 392 | status: still running, or exited with an exit code. Poll this \ |
| 393 | periodically to follow builds, test suites, and servers. Between \ |
| 394 | polls output is buffered up to 50 000 bytes per task; older \ |
| 395 | output beyond that is dropped and the truncation is noted.", |
| 396 | parameters_schema: json!({ |
| 397 | "type": "object", |
| 398 | "properties": { |
| 399 | "task_id": { |
| 400 | "type": "integer", |
| 401 | "description": "Id of the background task, as returned by run_command with run_in_background." |
| 402 | } |
| 403 | }, |
| 404 | "required": ["task_id"], |
| 405 | "additionalProperties": false |
| 406 | }), |
| 407 | }, |
| 408 | AgentTool { |
| 409 | name: "kill_command", |
| 410 | description: "Kill a background task started with run_command's run_in_background. \ |
| 411 | Reports the tail of the task's unread output and confirms it was \ |
| 412 | killed. Use this to stop servers or watchers you no longer need and \ |
| 413 | runaway commands. Background tasks are also killed automatically \ |
| 414 | when sigit exits.", |
| 415 | parameters_schema: json!({ |
| 416 | "type": "object", |
| 417 | "properties": { |
| 418 | "task_id": { |
| 419 | "type": "integer", |
| 420 | "description": "Id of the background task, as returned by run_command with run_in_background." |
| 421 | } |
| 422 | }, |
| 423 | "required": ["task_id"], |
| 424 | "additionalProperties": false |
| 425 | }), |
| 426 | }, |
| 427 | ] |
| 428 | } |
| 429 | |
| 430 | // ── Tool execution ─────────────────────────────────────────────────────────── |
| 431 | |
| 432 | pub async fn execute_tool(name: &str, arguments: &str) -> String { |
| 433 | // Settings are read once per call (not once per hook phase) so a |
| 434 | // deployment without any [hooks] configured pays one cheap TOML parse |
| 435 | // per tool call rather than two. |
| 436 | let hook_settings = crate::settings::load().hooks; |
| 437 | let cwd = std::env::current_dir().ok(); |
| 438 | |
| 439 | if let Some(cwd) = &cwd |
| 440 | && !hook_settings.pre_tool_use.is_empty() |
| 441 | { |
| 442 | crate::hooks::run_pre_tool_use_hooks(&hook_settings, name, arguments, cwd); |
| 443 | } |
| 444 | |
| 445 | let result = execute_tool_impl(name, arguments).await; |
| 446 | |
| 447 | if let Some(cwd) = &cwd |
| 448 | && !hook_settings.post_tool_use.is_empty() |
| 449 | { |
| 450 | crate::hooks::run_post_tool_use_hooks(&hook_settings, name, &result, cwd); |
| 451 | } |
| 452 | |
| 453 | result |
| 454 | } |
| 455 | |
| 456 | async fn execute_tool_impl(name: &str, arguments: &str) -> String { |
| 457 | match name { |
| 458 | TASK_TOOL_NAME => exec_task(arguments).await, |
| 459 | WEB_SEARCH_TOOL_NAME => exec_web_search(arguments).await, |
| 460 | // Tools discovered from MCP servers are namespaced `mcp__<server>__<tool>` |
| 461 | // and forwarded to the owning server. |
| 462 | _ if crate::mcp::is_mcp_tool(name) => crate::mcp::call_tool(name, arguments).await, |
| 463 | // Everything else is synchronous and may run for a long time: a shell |
| 464 | // command up to COMMAND_TIMEOUT, a glob over a big tree, an HTTP fetch |
| 465 | // on reqwest::blocking (which panics if polled on a runtime thread). |
| 466 | // The ACP connection multiplexes reading the client, writing |
| 467 | // notifications, and running the turn onto a single task, so blocking |
| 468 | // here freezes the editor's view of the session: the tool call's |
| 469 | // `in_progress` update — the editor's spinner — only reaches the client |
| 470 | // once the tool has already finished. |
| 471 | _ => { |
| 472 | let tool = name.to_owned(); |
| 473 | let arguments = arguments.to_owned(); |
| 474 | let running = tool.clone(); |
| 475 | tokio::task::spawn_blocking(move || execute_sync_tool(&running, &arguments)) |
| 476 | .await |
| 477 | // A `JoinError` means the tool panicked (or the runtime is |
| 478 | // shutting down). Report it as the tool's result rather than |
| 479 | // propagating: a panicking tool should cost the model one bad |
| 480 | // tool result, not the whole turn. Note this is strictly safer |
| 481 | // than running the tool inline, where the panic would unwind |
| 482 | // through the turn and take the ACP connection with it. |
| 483 | .unwrap_or_else(|err| format!("Error: {tool} task failed: {err}")) |
| 484 | } |
| 485 | } |
| 486 | } |
| 487 | |
| 488 | /// The synchronous tools, run on the blocking pool by [`execute_tool_impl`]. |
| 489 | /// |
| 490 | /// Everything here runs on a blocking thread with no reactor, so nothing in |
| 491 | /// this match may await or drive a tokio resource. A new tool that needs the |
| 492 | /// async context belongs in [`execute_tool_impl`] alongside `task` and |
| 493 | /// `web_search` instead — and then owes its own answer to the question this |
| 494 | /// dispatch exists for: not blocking the caller's task for its whole run. |
| 495 | fn execute_sync_tool(name: &str, arguments: &str) -> String { |
| 496 | match name { |
| 497 | "read_file" => exec_read_file(arguments), |
| 498 | "list_directory" => exec_list_directory(arguments), |
| 499 | "search_files" => exec_search_files(arguments), |
| 500 | "read_website" => exec_read_website(arguments), |
| 501 | "create_directory" => exec_create_directory(arguments), |
| 502 | "create_file" => exec_create_file(arguments), |
| 503 | "edit_file" => exec_edit_file(arguments), |
| 504 | "multi_edit" => exec_multi_edit(arguments), |
| 505 | "glob" => exec_glob(arguments), |
| 506 | "write_todos" => exec_write_todos(arguments), |
| 507 | "remember" => exec_remember(arguments), |
| 508 | "delete_file" => exec_delete_file(arguments), |
| 509 | "run_command" => exec_run_command(arguments), |
| 510 | "command_output" => exec_command_output(arguments), |
| 511 | "kill_command" => exec_kill_command(arguments), |
| 512 | "skill" => crate::skills::activate_skill(arguments), |
| 513 | _ => format!("Unknown tool: {name}"), |
| 514 | } |
| 515 | } |
| 516 | |
| 517 | // ── web_search ─────────────────────────────────────────────────────────────── |
| 518 | // |
| 519 | // siGit Code Cloud runs a Brave-Search-backed `web_search` tool on its official |
| 520 | // MCP server (`mcp.rs`'s baked-in "sigit" server, discovered as |
| 521 | // `mcp__sigit__web_search`) — the API key and billing live server-side, so |
| 522 | // there is no on-device/local equivalent. This is a thin native wrapper |
| 523 | // around that MCP tool rather than a second implementation of it, for two |
| 524 | // reasons: (1) `mcp.rs` already owns the auth, transport, and error handling, |
| 525 | // so re-doing that here would just be a second copy of the same HTTP/JSON-RPC |
| 526 | // code; (2) every `mcp__*` tool is classified `Mutating` by |
| 527 | // `permissions::classify` (an unknown external tool's side effects can't be |
| 528 | // assumed safe), which would put an "ask" prompt in front of a harmless |
| 529 | // read-only search on every call. Presenting it under the clean `web_search` |
| 530 | // name instead lets `permissions::classify` treat it as read-only, matching |
| 531 | // `read_website`'s zero-friction behavior, while `execute_tool_impl` still |
| 532 | // delegates the actual call to the exact same `mcp::call_tool` codepath as |
| 533 | // any other MCP tool. |
| 534 | // |
| 535 | // Advertised only when the official server has actually discovered a |
| 536 | // `web_search` tool (see `web_search_available`) — the same conditional |
| 537 | // pattern as `skill` and `task` — so a signed-out or MCP-disabled session |
| 538 | // never sees a capability it can't use. |
| 539 | |
| 540 | pub const WEB_SEARCH_TOOL_NAME: &str = "web_search"; |
| 541 | |
| 542 | /// The discovered MCP tool this delegates to: siGit Code Cloud's official |
| 543 | /// server is always registered under the name "sigit" (see `mcp.rs`), so its |
| 544 | /// tools are namespaced `mcp__sigit__<tool>`. |
| 545 | const WEB_SEARCH_MCP_DELEGATE: &str = "mcp__sigit__web_search"; |
| 546 | |
| 547 | /// Whether `web_search` should be offered this turn: the official MCP server |
| 548 | /// has to have actually discovered a `web_search` tool, which in turn only |
| 549 | /// happens when the user is signed in to siGit Code Cloud (an unauthenticated |
| 550 | /// request to the official server fails during `mcp::init`'s discovery and |
| 551 | /// contributes no tools — see `mcp.rs`). No separate sign-in check is needed |
| 552 | /// here: this is exactly the same conditional-advertisement pattern as |
| 553 | /// `subagent_available`, just checking a tool name instead of a factory. |
| 554 | pub fn web_search_available() -> bool { |
| 555 | crate::mcp::tool_specs() |
| 556 | .iter() |
| 557 | .any(|spec| spec.name == WEB_SEARCH_MCP_DELEGATE) |
| 558 | } |
| 559 | |
| 560 | /// Whether `name` is the raw MCP tool [`web_search_tool_spec`] delegates to. |
| 561 | /// Callers assembling the full tool list should exclude this name from |
| 562 | /// whatever they append from `mcp::tool_specs()`, so the model sees the one |
| 563 | /// clean `web_search` option rather than that *and* the raw `mcp__*` name for |
| 564 | /// the same underlying tool. |
| 565 | pub fn is_web_search_delegate(name: &str) -> bool { |
| 566 | name == WEB_SEARCH_MCP_DELEGATE |
| 567 | } |
| 568 | |
| 569 | /// Spec for the `web_search` tool. Lives in the `*_as_specs`/`build_tool_specs` |
| 570 | /// layer (like `skill` and `task`), not in [`all_tools`], because it is only |
| 571 | /// offered when [`web_search_available`] is true. The schema mirrors |
| 572 | /// siGit Code Cloud's actual `web_search` MCP tool exactly (`query` + `count`) |
| 573 | /// so arguments pass through to [`exec_web_search`] unchanged. |
| 574 | pub fn web_search_tool_spec() -> ToolSpec { |
| 575 | ToolSpec { |
| 576 | name: WEB_SEARCH_TOOL_NAME.to_string(), |
| 577 | description: "Search the public web and return matching pages as title, URL, and \ |
| 578 | snippet. Use this to find pages when you don't already know the URL — for \ |
| 579 | current events, library/API documentation, error messages, or anything else \ |
| 580 | outside this repository. Once you have a promising URL, use read_website to \ |
| 581 | fetch its full content. Requires a signed-in siGit Code Cloud account (the \ |
| 582 | search runs server-side); rate-limited per account." |
| 583 | .to_string(), |
| 584 | parameters_schema: json!({ |
| 585 | "type": "object", |
| 586 | "properties": { |
| 587 | "query": { |
| 588 | "type": "string", |
| 589 | "description": "The search query." |
| 590 | }, |
| 591 | "count": { |
| 592 | "type": "integer", |
| 593 | "description": "Number of results to return (default 5, max 10)." |
| 594 | } |
| 595 | }, |
| 596 | "required": ["query"], |
| 597 | "additionalProperties": false |
| 598 | }) |
| 599 | .to_string(), |
| 600 | } |
| 601 | } |
| 602 | |
| 603 | /// The `web_search` tool entry point used by [`execute_tool`]: forward |
| 604 | /// verbatim to the discovered MCP tool. `mcp::call_tool` already handles a |
| 605 | /// missing/uninitialized server gracefully (an in-band error string, never a |
| 606 | /// panic), so there's nothing extra to do here — see the module doc above for |
| 607 | /// why this delegates instead of reimplementing the call. |
| 608 | async fn exec_web_search(arguments: &str) -> String { |
| 609 | crate::mcp::call_tool(WEB_SEARCH_MCP_DELEGATE, arguments).await |
| 610 | } |
| 611 | |
| 612 | // ── task (subagent) ────────────────────────────────────────────────────────── |
| 613 | // |
| 614 | // The `task` tool delegates a self-contained research question to a *nested* |
| 615 | // agent loop running in a fresh conversation, so the main thread receives only |
| 616 | // the final answer instead of every intermediate file read. The subagent's |
| 617 | // toolset is strictly read-only and never includes `task` itself (no |
| 618 | // recursion), so a delegated agent can research but not mutate state. |
| 619 | // |
| 620 | // A caller may optionally pick a *configurable subagent type* (see |
| 621 | // `crate::subagents`, `.sigit/agents/*.md`) via the `subagent_type` argument. |
| 622 | // A type can only ever replace the system prompt and *narrow* the toolset — |
| 623 | // never grant tools outside `SUBAGENT_TOOL_NAMES` — so this stays exactly as |
| 624 | // safe as the default research subagent: a `.sigit/agents/*.md` file cannot |
| 625 | // grant itself `edit_file` or `run_command` and bypass the permission system. |
| 626 | // |
| 627 | // This module is backend-agnostic and cannot construct a backend, so the |
| 628 | // surface that knows the active provider (`run_acp_server` / `run_interactive` |
| 629 | // in `main.rs`) registers a factory at startup. The factory returns `None` |
| 630 | // when inference runs on-device: onde has a single shared conversation |
| 631 | // history, so a second concurrent context is not possible yet. |
| 632 | |
| 633 | pub const TASK_TOOL_NAME: &str = "task"; |
| 634 | |
| 635 | /// Hard cap on how many configurable subagent types are embedded in the |
| 636 | /// `task` tool's description, to bound the tool description size — same |
| 637 | /// rationale as `skills::MAX_ADVERTISED_SKILLS`. |
| 638 | const MAX_ADVERTISED_AGENT_TYPES: usize = 100; |
| 639 | |
| 640 | /// The tool names a subagent may call, filtered from [`all_tools`]. This is |
| 641 | /// the hard ceiling for every subagent, default or custom-typed — see the |
| 642 | /// module doc above. |
| 643 | const SUBAGENT_TOOL_NAMES: &[&str] = &[ |
| 644 | "read_file", |
| 645 | "list_directory", |
| 646 | "search_files", |
| 647 | "glob", |
| 648 | "read_website", |
| 649 | ]; |
| 650 | |
| 651 | /// System prompt seeding a subagent conversation when no `subagent_type` is |
| 652 | /// given (or it doesn't resolve to a configured type). |
| 653 | pub const SUBAGENT_SYSTEM_PROMPT: &str = "You are a focused research subagent. \ |
| 654 | You are given one self-contained task by a calling agent. Investigate it using \ |
| 655 | the read-only tools available to you (read_file, list_directory, search_files, \ |
| 656 | glob, read_website) and answer it thoroughly but concisely. You cannot modify \ |
| 657 | files or run commands. Your final message is returned verbatim to the caller, \ |
| 658 | so make it a complete, self-contained answer — include the concrete facts, \ |
| 659 | paths, and code excerpts the caller needs, and nothing else."; |
| 660 | |
| 661 | /// Rounds of tool calls a subagent may use before it is forced to answer. |
| 662 | const SUBAGENT_MAX_TOOL_ROUNDS: usize = 8; |
| 663 | /// Cap on the answer text returned to the caller. |
| 664 | const SUBAGENT_RESULT_CHAR_LIMIT: usize = 8_000; |
| 665 | |
| 666 | /// Returned when `task` is called but no subagent backend can be built. |
| 667 | const SUBAGENT_UNAVAILABLE: &str = "The task tool is not available on-device \ |
| 668 | yet: on-device inference has a single conversation context. Do the research \ |
| 669 | yourself with the read-only tools."; |
| 670 | |
| 671 | /// Builds a fresh backend for one subagent run seeded with the given system |
| 672 | /// prompt, or `None` when the active inference cannot host a second |
| 673 | /// conversation (on-device). Takes the prompt per call (rather than baking |
| 674 | /// one in at registration) so a configurable subagent type's prompt can |
| 675 | /// replace [`SUBAGENT_SYSTEM_PROMPT`] on a call-by-call basis. |
| 676 | pub type SubagentFactory = Box<dyn Fn(&str) -> Option<Arc<dyn InferenceBackend>> + Send + Sync>; |
| 677 | |
| 678 | static SUBAGENT_FACTORY: OnceLock<SubagentFactory> = OnceLock::new(); |
| 679 | |
| 680 | /// Register the process-wide subagent factory. Called once at startup by the |
| 681 | /// surface that resolved the inference provider; later calls are ignored. |
| 682 | pub fn set_subagent_factory(factory: SubagentFactory) { |
| 683 | let _ = SUBAGENT_FACTORY.set(factory); |
| 684 | } |
| 685 | |
| 686 | /// Whether a `task` call could run right now: a factory is registered and it |
| 687 | /// can build a backend. The spec builders (`agent_tools_as_specs` / |
| 688 | /// `build_tool_specs`) use this to offer the tool only when it works, the same |
| 689 | /// conditional pattern as the `skill` tool. Building a backend just to probe |
| 690 | /// is cheap (no I/O happens until the first message), so this passes a |
| 691 | /// placeholder prompt and discards the result. |
| 692 | pub fn subagent_available() -> bool { |
| 693 | SUBAGENT_FACTORY |
| 694 | .get() |
| 695 | .is_some_and(|factory| factory(SUBAGENT_SYSTEM_PROMPT).is_some()) |
| 696 | } |
| 697 | |
| 698 | /// The read-only toolset offered to a subagent, filtered from [`all_tools`] by |
| 699 | /// name. `allowed` must already be a subset of [`SUBAGENT_TOOL_NAMES`] — |
| 700 | /// see [`effective_subagent_tools`], the only intended way to build one. |
| 701 | fn subagent_tool_specs(allowed: &[String]) -> Vec<ToolSpec> { |
| 702 | all_tools() |
| 703 | .into_iter() |
| 704 | .filter(|tool| allowed.iter().any(|name| name == tool.name)) |
| 705 | .map(|tool| ToolSpec { |
| 706 | name: tool.name.to_string(), |
| 707 | description: tool.description.to_string(), |
| 708 | parameters_schema: tool.parameters_schema.to_string(), |
| 709 | }) |
| 710 | .collect() |
| 711 | } |
| 712 | |
| 713 | /// The toolset a subagent call should actually get: [`SUBAGENT_TOOL_NAMES`] |
| 714 | /// by default, or — when `agent_type` sets a `tools:` allow-list — its |
| 715 | /// *intersection* with that ceiling. A type can never add a tool outside the |
| 716 | /// base read-only set; anything else it lists is dropped with a warning |
| 717 | /// rather than silently granted, since that's the whole security property |
| 718 | /// this function exists to enforce (see the module doc above). |
| 719 | fn effective_subagent_tools( |
| 720 | agent_type: Option<&subagents::AgentType>, |
| 721 | ) -> Result<Vec<String>, String> { |
| 722 | let Some(agent_type) = agent_type else { |
| 723 | return Ok(SUBAGENT_TOOL_NAMES.iter().map(|s| s.to_string()).collect()); |
| 724 | }; |
| 725 | let Some(requested) = &agent_type.tools else { |
| 726 | // No `tools:` field: inherit the full default read-only set. |
| 727 | return Ok(SUBAGENT_TOOL_NAMES.iter().map(|s| s.to_string()).collect()); |
| 728 | }; |
| 729 | |
| 730 | let mut effective = Vec::new(); |
| 731 | for name in requested { |
| 732 | if SUBAGENT_TOOL_NAMES.contains(&name.as_str()) { |
| 733 | effective.push(name.clone()); |
| 734 | } else { |
| 735 | log::warn!( |
| 736 | "agent type \"{}\" at {} lists tool \"{name}\", which is not in the \ |
| 737 | subagent read-only allow-list ({}) — ignoring it", |
| 738 | agent_type.name, |
| 739 | agent_type.path.display(), |
| 740 | SUBAGENT_TOOL_NAMES.join(", ") |
| 741 | ); |
| 742 | } |
| 743 | } |
| 744 | |
| 745 | // A `tools:` field that intersects the ceiling to nothing is a |
| 746 | // misconfiguration, not a request for a toolless subagent: running it |
| 747 | // anyway would answer from the model's priors with no file access and no |
| 748 | // signal beyond a log line. Surface it as an error instead. |
| 749 | if effective.is_empty() { |
| 750 | return Err(format!( |
| 751 | "Error: subagent type \"{}\" at {} sets `tools: {}`, none of which \ |
| 752 | is in the subagent read-only set ({}). Fix the type's `tools:` \ |
| 753 | field, or omit it to inherit the default read-only tools.", |
| 754 | agent_type.name, |
| 755 | agent_type.path.display(), |
| 756 | requested.join(", "), |
| 757 | SUBAGENT_TOOL_NAMES.join(", "), |
| 758 | )); |
| 759 | } |
| 760 | Ok(effective) |
| 761 | } |
| 762 | |
| 763 | /// Spec for the `task` tool. Lives in the `*_as_specs`/`build_tool_specs` |
| 764 | /// layer (like `skill` and MCP tools), not in [`all_tools`], because it is |
| 765 | /// only offered when [`subagent_available`] is true. |
| 766 | pub fn task_tool_spec() -> ToolSpec { |
| 767 | let types = subagents::discover_agent_types(); |
| 768 | let mut description = "Delegate a self-contained task to a subagent that \ |
| 769 | runs in a fresh conversation and returns only its final answer. The \ |
| 770 | subagent's tools are read-only by default (read files, list \ |
| 771 | directories, search, glob, read websites) — it cannot modify \ |
| 772 | anything. Use this for exploratory questions whose intermediate \ |
| 773 | file reads would otherwise clutter this conversation (e.g. \"where \ |
| 774 | is X implemented and how does it work?\"). The subagent cannot see \ |
| 775 | this conversation, so the prompt must be fully self-contained: \ |
| 776 | include absolute paths, symbol names, and exactly what the answer \ |
| 777 | should contain." |
| 778 | .to_string(); |
| 779 | if !types.is_empty() { |
| 780 | description.push_str( |
| 781 | "\n\nOptionally pass `subagent_type` to use one of these specialized \ |
| 782 | subagent types instead of the general-purpose default:\n", |
| 783 | ); |
| 784 | // Same cap as `skills::MAX_ADVERTISED_SKILLS`: this list is baked into |
| 785 | // every turn's tool spec, so bound its context cost even though a |
| 786 | // pathological number of type files is unlikely. `/agents` (see |
| 787 | // `subagents::format_agent_types_list`) shows the full list on request. |
| 788 | for agent_type in types.iter().take(MAX_ADVERTISED_AGENT_TYPES) { |
| 789 | description.push_str("- "); |
| 790 | description.push_str(&agent_type.name); |
| 791 | description.push_str(": "); |
| 792 | description.push_str(&agent_type.description); |
| 793 | description.push('\n'); |
| 794 | } |
| 795 | } |
| 796 | |
| 797 | ToolSpec { |
| 798 | name: TASK_TOOL_NAME.to_string(), |
| 799 | description, |
| 800 | parameters_schema: json!({ |
| 801 | "type": "object", |
| 802 | "properties": { |
| 803 | "description": { |
| 804 | "type": "string", |
| 805 | "description": "A short (3-5 word) summary of the task, for progress display." |
| 806 | }, |
| 807 | "prompt": { |
| 808 | "type": "string", |
| 809 | "description": "The full task for the subagent. Must be self-contained: the subagent sees nothing of this conversation." |
| 810 | }, |
| 811 | "subagent_type": { |
| 812 | "type": "string", |
| 813 | "description": "Optional: the name of a configured subagent type (listed in this tool's description) to use instead of the general-purpose default." |
| 814 | } |
| 815 | }, |
| 816 | "required": ["description", "prompt"], |
| 817 | "additionalProperties": false |
| 818 | }) |
| 819 | .to_string(), |
| 820 | } |
| 821 | } |
| 822 | |
| 823 | /// The `task` tool entry point used by [`execute_tool`]. |
| 824 | async fn exec_task(arguments: &str) -> String { |
| 825 | exec_task_with(arguments, SUBAGENT_FACTORY.get()).await |
| 826 | } |
| 827 | |
| 828 | /// Core of the `task` tool, parameterized on the factory so tests can exercise |
| 829 | /// the unavailable path without touching the process-global `OnceLock`. |
| 830 | async fn exec_task_with(arguments: &str, factory: Option<&SubagentFactory>) -> String { |
| 831 | let args: Value = match serde_json::from_str(arguments) { |
| 832 | Ok(v) => v, |
| 833 | Err(err) => return format!("Error: failed to parse arguments: {err}"), |
| 834 | }; |
| 835 | |
| 836 | let prompt = match args.get("prompt").and_then(Value::as_str) { |
| 837 | Some(p) if !p.trim().is_empty() => p, |
| 838 | _ => return "Error: missing required parameter \"prompt\"".to_string(), |
| 839 | }; |
| 840 | let description = args |
| 841 | .get("description") |
| 842 | .and_then(Value::as_str) |
| 843 | .unwrap_or("(no description)"); |
| 844 | let subagent_type_name = args |
| 845 | .get("subagent_type") |
| 846 | .and_then(Value::as_str) |
| 847 | .filter(|s| !s.trim().is_empty()); |
| 848 | |
| 849 | let agent_type = match subagent_type_name { |
| 850 | Some(name) => match subagents::resolve_agent_type(name) { |
| 851 | Some(agent_type) => Some(agent_type), |
| 852 | None => { |
| 853 | let available = subagents::discover_agent_types() |
| 854 | .into_iter() |
| 855 | .map(|t| t.name) |
| 856 | .collect::<Vec<_>>(); |
| 857 | return if available.is_empty() { |
| 858 | format!( |
| 859 | "Error: no subagent type named \"{name}\" (none are configured). \ |
| 860 | Omit subagent_type to use the default research subagent." |
| 861 | ) |
| 862 | } else { |
| 863 | format!( |
| 864 | "Error: no subagent type named \"{name}\". Available: {}. Omit \ |
| 865 | subagent_type to use the default research subagent.", |
| 866 | available.join(", ") |
| 867 | ) |
| 868 | }; |
| 869 | } |
| 870 | }, |
| 871 | None => None, |
| 872 | }; |
| 873 | |
| 874 | let system_prompt = agent_type |
| 875 | .as_ref() |
| 876 | .map(|t| t.system_prompt.as_str()) |
| 877 | .unwrap_or(SUBAGENT_SYSTEM_PROMPT); |
| 878 | let allowed_tools = match effective_subagent_tools(agent_type.as_ref()) { |
| 879 | Ok(tools) => tools, |
| 880 | Err(err) => return err, |
| 881 | }; |
| 882 | |
| 883 | let Some(backend) = factory.and_then(|build| build(system_prompt)) else { |
| 884 | return SUBAGENT_UNAVAILABLE.to_string(); |
| 885 | }; |
| 886 | |
| 887 | log::info!( |
| 888 | "task: running subagent — {description} (type: {})", |
| 889 | agent_type |
| 890 | .as_ref() |
| 891 | .map(|t| t.name.as_str()) |
| 892 | .unwrap_or("default") |
| 893 | ); |
| 894 | run_subagent(backend.as_ref(), prompt, &allowed_tools).await |
| 895 | } |
| 896 | |
| 897 | /// The nested agent loop: a fresh conversation, a round cap, and only the |
| 898 | /// final text returned. Mirrors the main loops in `main.rs` / `chat.rs`: |
| 899 | /// offer tools each round, and pass `tools = None` on the last round to |
| 900 | /// force a text answer. `allowed` is the toolset for this run — the default |
| 901 | /// read-only set, or a configured type's narrowed subset of it (see |
| 902 | /// [`effective_subagent_tools`]). |
| 903 | async fn run_subagent(backend: &dyn InferenceBackend, prompt: &str, allowed: &[String]) -> String { |
| 904 | let specs = subagent_tool_specs(allowed); |
| 905 | |
| 906 | let mut result = match backend.send_message_with_tools(prompt, &specs, None).await { |
| 907 | Ok(r) => r, |
| 908 | Err(err) => return format!("Error: subagent inference failed: {err}"), |
| 909 | }; |
| 910 | |
| 911 | let mut round = 0; |
| 912 | while !result.tool_calls.is_empty() && round < SUBAGENT_MAX_TOOL_ROUNDS { |
| 913 | round += 1; |
| 914 | log::info!( |
| 915 | "task: subagent tool round {round} — {} call(s)", |
| 916 | result.tool_calls.len() |
| 917 | ); |
| 918 | |
| 919 | let mut tool_results = Vec::with_capacity(result.tool_calls.len()); |
| 920 | for call in &result.tool_calls { |
| 921 | // Hard gate, not just advertisement: even if the model asks for a |
| 922 | // tool outside the offered set, only the allowed tools execute here. |
| 923 | let content = if allowed.iter().any(|name| name == &call.name) { |
| 924 | // Boxed to break the async cycle: execute_tool → task → |
| 925 | // run_subagent → execute_tool. |
| 926 | Box::pin(execute_tool(&call.name, &call.arguments)).await |
| 927 | } else { |
| 928 | format!( |
| 929 | "Error: `{}` is not available to this subagent. Only these \ |
| 930 | tools are: {}.", |
| 931 | call.name, |
| 932 | allowed.join(", ") |
| 933 | ) |
| 934 | }; |
| 935 | tool_results.push(ToolResult { |
| 936 | tool_call_id: call.id.clone(), |
| 937 | content, |
| 938 | }); |
| 939 | } |
| 940 | |
| 941 | // On the last round, offer no tools so the model must produce text. |
| 942 | let allow_tool_calls = round < SUBAGENT_MAX_TOOL_ROUNDS; |
| 943 | result = match backend |
| 944 | .send_tool_results(tool_results, &specs, allow_tool_calls, None) |
| 945 | .await |
| 946 | { |
| 947 | Ok(r) => r, |
| 948 | Err(err) => return format!("Error: subagent inference failed: {err}"), |
| 949 | }; |
| 950 | } |
| 951 | |
| 952 | let text = result.text.trim(); |
| 953 | if text.is_empty() { |
| 954 | return "The subagent finished without a text answer.".to_string(); |
| 955 | } |
| 956 | |
| 957 | let total = text.chars().count(); |
| 958 | if total > SUBAGENT_RESULT_CHAR_LIMIT { |
| 959 | let truncated: String = text.chars().take(SUBAGENT_RESULT_CHAR_LIMIT).collect(); |
| 960 | return format!( |
| 961 | "{truncated}\n\n--- truncated (showing {SUBAGENT_RESULT_CHAR_LIMIT} of {total} \ |
| 962 | characters of the subagent's answer) ---" |
| 963 | ); |
| 964 | } |
| 965 | text.to_string() |
| 966 | } |
| 967 | |
| 968 | fn absolute_path(path: &Path) -> PathBuf { |
| 969 | if path.is_absolute() { |
| 970 | path.to_path_buf() |
| 971 | } else { |
| 972 | std::env::current_dir() |
| 973 | .unwrap_or_else(|_| PathBuf::from(".")) |
| 974 | .join(path) |
| 975 | } |
| 976 | } |
| 977 | |
| 978 | fn absolute_path_string(path: &Path) -> String { |
| 979 | absolute_path(path).display().to_string() |
| 980 | } |
| 981 | |
| 982 | // ── read_file ──────────────────────────────────────────────────────────────── |
| 983 | |
| 984 | fn exec_read_file(arguments: &str) -> String { |
| 985 | let args: Value = match serde_json::from_str(arguments) { |
| 986 | Ok(v) => v, |
| 987 | Err(err) => return format!("Error: failed to parse arguments: {err}"), |
| 988 | }; |
| 989 | |
| 990 | let path_str = match args.get("path").and_then(Value::as_str) { |
| 991 | Some(p) => p, |
| 992 | None => return "Error: missing required parameter \"path\"".to_string(), |
| 993 | }; |
| 994 | |
| 995 | let start_line = args |
| 996 | .get("start_line") |
| 997 | .and_then(Value::as_u64) |
| 998 | .map(|n| n as usize); |
| 999 | let end_line = args |
| 1000 | .get("end_line") |
| 1001 | .and_then(Value::as_u64) |
| 1002 | .map(|n| n as usize); |
| 1003 | |
| 1004 | let path = Path::new(path_str); |
| 1005 | let absolute_path = absolute_path(path); |
| 1006 | let absolute_path_str = absolute_path.display().to_string(); |
| 1007 | |
| 1008 | if !absolute_path.exists() { |
| 1009 | return format!("Error: path does not exist: {absolute_path_str}"); |
| 1010 | } |
| 1011 | |
| 1012 | if !absolute_path.is_file() { |
| 1013 | return format!("Error: path is not a file: {absolute_path_str}"); |
| 1014 | } |
| 1015 | |
| 1016 | match fs::read_to_string(&absolute_path) { |
| 1017 | Ok(contents) => { |
| 1018 | if start_line.is_some() || end_line.is_some() { |
| 1019 | let lines: Vec<&str> = contents.lines().collect(); |
| 1020 | let total = lines.len(); |
| 1021 | let start = start_line.unwrap_or(1).max(1); |
| 1022 | let end = end_line.unwrap_or(total).min(total); |
| 1023 | |
| 1024 | if start > total { |
| 1025 | return format!( |
| 1026 | "Error: start_line {start} is beyond end of file ({total} lines)" |
| 1027 | ); |
| 1028 | } |
| 1029 | |
| 1030 | let selected: Vec<&str> = lines[(start - 1)..end].to_vec(); |
| 1031 | let range_text = selected.join("\n"); |
| 1032 | format!("Lines {start}-{end} of {total} in {absolute_path_str}:\n{range_text}") |
| 1033 | } else if contents.len() > READ_FILE_CHAR_LIMIT { |
| 1034 | let truncated: String = contents.chars().take(READ_FILE_CHAR_LIMIT).collect(); |
| 1035 | format!( |
| 1036 | "{truncated}\n\n--- truncated (showing {READ_FILE_CHAR_LIMIT} of {} characters) ---", |
| 1037 | contents.len() |
| 1038 | ) |
| 1039 | } else { |
| 1040 | contents |
| 1041 | } |
| 1042 | } |
| 1043 | Err(err) => format!("Error: could not read file: {err}"), |
| 1044 | } |
| 1045 | } |
| 1046 | |
| 1047 | // ── list_directory ─────────────────────────────────────────────────────────── |
| 1048 | |
| 1049 | fn exec_list_directory(arguments: &str) -> String { |
| 1050 | let args: Value = match serde_json::from_str(arguments) { |
| 1051 | Ok(v) => v, |
| 1052 | Err(err) => return format!("Error: failed to parse arguments: {err}"), |
| 1053 | }; |
| 1054 | |
| 1055 | let path_str = match args.get("path").and_then(Value::as_str) { |
| 1056 | Some(p) => p, |
| 1057 | None => return "Error: missing required parameter \"path\"".to_string(), |
| 1058 | }; |
| 1059 | |
| 1060 | let path = Path::new(path_str); |
| 1061 | let absolute_path = absolute_path(path); |
| 1062 | let absolute_path_str = absolute_path.display().to_string(); |
| 1063 | |
| 1064 | if !absolute_path.exists() { |
| 1065 | return format!("Error: path does not exist: {absolute_path_str}"); |
| 1066 | } |
| 1067 | |
| 1068 | if !absolute_path.is_dir() { |
| 1069 | return format!("Error: path is not a directory: {absolute_path_str}"); |
| 1070 | } |
| 1071 | |
| 1072 | let entries = match fs::read_dir(&absolute_path) { |
| 1073 | Ok(rd) => rd, |
| 1074 | Err(err) => return format!("Error: could not read directory: {err}"), |
| 1075 | }; |
| 1076 | |
| 1077 | let mut dirs: Vec<String> = Vec::new(); |
| 1078 | let mut files: Vec<String> = Vec::new(); |
| 1079 | |
| 1080 | for entry in entries { |
| 1081 | let entry = match entry { |
| 1082 | Ok(e) => e, |
| 1083 | Err(err) => { |
| 1084 | files.push(format!("[ERR] {err}")); |
| 1085 | continue; |
| 1086 | } |
| 1087 | }; |
| 1088 | |
| 1089 | let name = entry.file_name().to_string_lossy().to_string(); |
| 1090 | |
| 1091 | let is_dir = match entry.file_type() { |
| 1092 | Ok(ft) => ft.is_dir(), |
| 1093 | Err(_) => false, |
| 1094 | }; |
| 1095 | |
| 1096 | if is_dir { |
| 1097 | dirs.push(format!("[DIR] {name}")); |
| 1098 | } else { |
| 1099 | files.push(format!("[FILE] {name}")); |
| 1100 | } |
| 1101 | } |
| 1102 | |
| 1103 | dirs.sort(); |
| 1104 | files.sort(); |
| 1105 | |
| 1106 | dirs.extend(files); |
| 1107 | |
| 1108 | if dirs.is_empty() { |
| 1109 | return format!("(empty directory: {absolute_path_str})"); |
| 1110 | } |
| 1111 | |
| 1112 | dirs.join("\n") |
| 1113 | } |
| 1114 | |
| 1115 | // ── search_files ───────────────────────────────────────────────────────────── |
| 1116 | |
| 1117 | fn exec_search_files(arguments: &str) -> String { |
| 1118 | let args: Value = match serde_json::from_str(arguments) { |
| 1119 | Ok(v) => v, |
| 1120 | Err(err) => return format!("Error: failed to parse arguments: {err}"), |
| 1121 | }; |
| 1122 | |
| 1123 | let pattern_str = match args.get("pattern").and_then(Value::as_str) { |
| 1124 | Some(p) => p, |
| 1125 | None => return "Error: missing required parameter \"pattern\"".to_string(), |
| 1126 | }; |
| 1127 | |
| 1128 | let root_str = args.get("path").and_then(Value::as_str).unwrap_or("."); |
| 1129 | |
| 1130 | let re = match Regex::new(pattern_str) { |
| 1131 | Ok(r) => r, |
| 1132 | Err(err) => return format!("Error: invalid regex pattern: {err}"), |
| 1133 | }; |
| 1134 | |
| 1135 | // Optional file-name filter compiled from a glob (e.g. "*.rs"). |
| 1136 | let name_filter = match args.get("file_glob").and_then(Value::as_str) { |
| 1137 | Some(glob) => match Regex::new(&glob_to_regex(glob)) { |
| 1138 | Ok(r) => Some(r), |
| 1139 | Err(err) => return format!("Error: invalid file_glob: {err}"), |
| 1140 | }, |
| 1141 | None => None, |
| 1142 | }; |
| 1143 | |
| 1144 | let limit = args |
| 1145 | .get("max_results") |
| 1146 | .and_then(Value::as_u64) |
| 1147 | .map(|n| (n as usize).clamp(1, SEARCH_RESULTS_HARD_CAP)) |
| 1148 | .unwrap_or(SEARCH_FILES_MATCH_LIMIT); |
| 1149 | |
| 1150 | let root = Path::new(root_str); |
| 1151 | let absolute_root = absolute_path(root); |
| 1152 | let absolute_root_str = absolute_root.display().to_string(); |
| 1153 | |
| 1154 | if !absolute_root.exists() { |
| 1155 | return format!("Error: path does not exist: {absolute_root_str}"); |
| 1156 | } |
| 1157 | |
| 1158 | if !absolute_root.is_dir() { |
| 1159 | return format!("Error: path is not a directory: {absolute_root_str}"); |
| 1160 | } |
| 1161 | |
| 1162 | let mut matches: Vec<String> = Vec::new(); |
| 1163 | walk_and_search( |
| 1164 | &absolute_root, |
| 1165 | &re, |
| 1166 | name_filter.as_ref(), |
| 1167 | limit, |
| 1168 | &mut matches, |
| 1169 | ); |
| 1170 | |
| 1171 | if matches.is_empty() { |
| 1172 | return format!("No matches found for pattern: {pattern_str}"); |
| 1173 | } |
| 1174 | |
| 1175 | let total = matches.len(); |
| 1176 | if total > limit { |
| 1177 | matches.truncate(limit); |
| 1178 | matches.push(format!( |
| 1179 | "\n--- truncated (showing {limit} of {total}+ matches; raise max_results to see more) ---" |
| 1180 | )); |
| 1181 | } |
| 1182 | |
| 1183 | matches.join("\n") |
| 1184 | } |
| 1185 | |
| 1186 | /// Collects up to `limit + 1` matches (the extra signals truncation) so a broad |
| 1187 | /// pattern can't walk an entire tree once enough hits are found. |
| 1188 | fn walk_and_search( |
| 1189 | dir: &Path, |
| 1190 | re: &Regex, |
| 1191 | name_filter: Option<&Regex>, |
| 1192 | limit: usize, |
| 1193 | matches: &mut Vec<String>, |
| 1194 | ) { |
| 1195 | let entries = match fs::read_dir(dir) { |
| 1196 | Ok(rd) => rd, |
| 1197 | Err(_) => return, |
| 1198 | }; |
| 1199 | |
| 1200 | let mut sorted: Vec<fs::DirEntry> = entries.filter_map(Result::ok).collect(); |
| 1201 | sorted.sort_by_key(|e| e.file_name()); |
| 1202 | |
| 1203 | for entry in sorted { |
| 1204 | if matches.len() > limit { |
| 1205 | return; |
| 1206 | } |
| 1207 | |
| 1208 | let path = entry.path(); |
| 1209 | let name = entry.file_name(); |
| 1210 | let name_str = name.to_string_lossy(); |
| 1211 | |
| 1212 | if name_str.starts_with('.') { |
| 1213 | continue; |
| 1214 | } |
| 1215 | |
| 1216 | if path.is_dir() { |
| 1217 | walk_and_search(&path, re, name_filter, limit, matches); |
| 1218 | } else if path.is_file() { |
| 1219 | if let Some(filter) = name_filter |
| 1220 | && !filter.is_match(&name_str) |
| 1221 | { |
| 1222 | continue; |
| 1223 | } |
| 1224 | search_file(&path, re, matches); |
| 1225 | } |
| 1226 | } |
| 1227 | } |
| 1228 | |
| 1229 | /// skips non-UTF-8 files (probably binary). |
| 1230 | fn search_file(path: &Path, re: &Regex, matches: &mut Vec<String>) { |
| 1231 | let contents = match fs::read_to_string(path) { |
| 1232 | Ok(c) => c, |
| 1233 | Err(_) => return, |
| 1234 | }; |
| 1235 | |
| 1236 | let display_path = absolute_path_string(path); |
| 1237 | |
| 1238 | for (line_idx, line) in contents.lines().enumerate() { |
| 1239 | if re.is_match(line) { |
| 1240 | let line_number = line_idx + 1; |
| 1241 | matches.push(format!("{display_path}:{line_number}: {line}")); |
| 1242 | } |
| 1243 | } |
| 1244 | } |
| 1245 | |
| 1246 | // ── read_website ───────────────────────────────────────────────────────────── |
| 1247 | |
| 1248 | fn exec_read_website(arguments: &str) -> String { |
| 1249 | let args: Value = match serde_json::from_str(arguments) { |
| 1250 | Ok(v) => v, |
| 1251 | Err(err) => return format!("Error: failed to parse arguments: {err}"), |
| 1252 | }; |
| 1253 | |
| 1254 | let url = match args.get("url").and_then(Value::as_str) { |
| 1255 | Some(u) => u, |
| 1256 | None => return "Error: missing required parameter \"url\"".to_string(), |
| 1257 | }; |
| 1258 | |
| 1259 | if !(url.starts_with("http://") || url.starts_with("https://")) { |
| 1260 | return format!("Error: url must start with http:// or https://: {url}"); |
| 1261 | } |
| 1262 | |
| 1263 | let client = match reqwest::blocking::Client::builder() |
| 1264 | .timeout(WEBSITE_READ_TIMEOUT) |
| 1265 | .user_agent(WEBSITE_USER_AGENT) |
| 1266 | .build() |
| 1267 | { |
| 1268 | Ok(client) => client, |
| 1269 | Err(err) => return format!("Error: failed to build website client: {err}"), |
| 1270 | }; |
| 1271 | |
| 1272 | let response = match client.get(url).send() { |
| 1273 | Ok(r) => r, |
| 1274 | Err(err) => return format!("Error: failed to fetch website: {err}"), |
| 1275 | }; |
| 1276 | |
| 1277 | let final_url = response.url().to_string(); |
| 1278 | let status = response.status(); |
| 1279 | if !status.is_success() { |
| 1280 | return format!("Error: website returned HTTP {status} for {final_url}"); |
| 1281 | } |
| 1282 | |
| 1283 | let body = match response.text() { |
| 1284 | Ok(text) => text, |
| 1285 | Err(err) => return format!("Error: failed to read website body: {err}"), |
| 1286 | }; |
| 1287 | |
| 1288 | let title = Regex::new(r"(?is)<title[^>]*>(.*?)</title>") |
| 1289 | .unwrap() |
| 1290 | .captures(&body) |
| 1291 | .and_then(|captures| captures.get(1)) |
| 1292 | .map(|m| { |
| 1293 | Regex::new(r"\s+") |
| 1294 | .unwrap() |
| 1295 | .replace_all(m.as_str(), " ") |
| 1296 | .trim() |
| 1297 | .to_string() |
| 1298 | }) |
| 1299 | .filter(|title| !title.is_empty()); |
| 1300 | |
| 1301 | let with_block_breaks = Regex::new( |
| 1302 | r"(?is)</?(?:p|div|section|article|main|aside|header|footer|nav|li|ul|ol|h1|h2|h3|h4|h5|h6|br|tr|td|th)[^>]*>", |
| 1303 | ) |
| 1304 | .unwrap() |
| 1305 | .replace_all(&body, "\n"); |
| 1306 | let without_scripts = Regex::new(r"(?is)<script[^>]*>.*?</script>") |
| 1307 | .unwrap() |
| 1308 | .replace_all(&with_block_breaks, " "); |
| 1309 | let without_styles = Regex::new(r"(?is)<style[^>]*>.*?</style>") |
| 1310 | .unwrap() |
| 1311 | .replace_all(&without_scripts, " "); |
| 1312 | let without_tags = Regex::new(r"(?is)<[^>]+>") |
| 1313 | .unwrap() |
| 1314 | .replace_all(&without_styles, " "); |
| 1315 | let normalized_newlines = without_tags |
| 1316 | .replace(" ", " ") |
| 1317 | .replace("&", "&") |
| 1318 | .replace("<", "<") |
| 1319 | .replace(">", ">") |
| 1320 | .replace(""", "\"") |
| 1321 | .replace("'", "'"); |
| 1322 | let collapsed_lines = Regex::new(r"[ \t]+") |
| 1323 | .unwrap() |
| 1324 | .replace_all(&normalized_newlines, " "); |
| 1325 | let collapsed_breaks = Regex::new(r"\n\s*\n+") |
| 1326 | .unwrap() |
| 1327 | .replace_all(&collapsed_lines, "\n\n"); |
| 1328 | let cleaned = collapsed_breaks |
| 1329 | .lines() |
| 1330 | .map(str::trim) |
| 1331 | .filter(|line| !line.is_empty()) |
| 1332 | .collect::<Vec<_>>() |
| 1333 | .join("\n"); |
| 1334 | |
| 1335 | if cleaned.is_empty() { |
| 1336 | return format!("Fetched {url}, but no readable text content was found."); |
| 1337 | } |
| 1338 | |
| 1339 | let mut metadata = vec![format!("URL: {final_url}")]; |
| 1340 | if let Some(title) = &title { |
| 1341 | metadata.push(format!("Title: {title}")); |
| 1342 | } |
| 1343 | |
| 1344 | let body_text = match title { |
| 1345 | Some(title) if !cleaned.starts_with(&title) => cleaned, |
| 1346 | _ => cleaned, |
| 1347 | }; |
| 1348 | |
| 1349 | let output = format!("{}\n\n{}", metadata.join("\n"), body_text); |
| 1350 | |
| 1351 | if output.len() > WEBSITE_READ_CHAR_LIMIT { |
| 1352 | let truncated: String = output.chars().take(WEBSITE_READ_CHAR_LIMIT).collect(); |
| 1353 | return format!( |
| 1354 | "{truncated}\n\n--- truncated (showing {WEBSITE_READ_CHAR_LIMIT} of {} characters) ---", |
| 1355 | output.len() |
| 1356 | ); |
| 1357 | } |
| 1358 | |
| 1359 | output |
| 1360 | } |
| 1361 | |
| 1362 | // ── create_directory ───────────────────────────────────────────────────────── |
| 1363 | |
| 1364 | fn exec_create_directory(arguments: &str) -> String { |
| 1365 | let args: Value = match serde_json::from_str(arguments) { |
| 1366 | Ok(v) => v, |
| 1367 | Err(err) => return format!("Error: failed to parse arguments: {err}"), |
| 1368 | }; |
| 1369 | |
| 1370 | let path_str = match args.get("path").and_then(Value::as_str) { |
| 1371 | Some(p) => p, |
| 1372 | None => return "Error: missing required parameter \"path\"".to_string(), |
| 1373 | }; |
| 1374 | |
| 1375 | let path = Path::new(path_str); |
| 1376 | let absolute_path = absolute_path(path); |
| 1377 | let absolute_path_str = absolute_path.display().to_string(); |
| 1378 | |
| 1379 | if absolute_path.exists() { |
| 1380 | if absolute_path.is_dir() { |
| 1381 | return format!("Directory already exists: {absolute_path_str}"); |
| 1382 | } |
| 1383 | return format!("Error: path exists and is not a directory: {absolute_path_str}"); |
| 1384 | } |
| 1385 | |
| 1386 | match fs::create_dir_all(&absolute_path) { |
| 1387 | Ok(()) => format!("Created directory: {absolute_path_str}"), |
| 1388 | Err(err) => format!("Error: could not create directory: {err}"), |
| 1389 | } |
| 1390 | } |
| 1391 | |
| 1392 | /// fails if file exists so the LLM is forced to use `edit_file` for modifications. |
| 1393 | fn exec_create_file(arguments: &str) -> String { |
| 1394 | let args: Value = match serde_json::from_str(arguments) { |
| 1395 | Ok(v) => v, |
| 1396 | Err(err) => return format!("Error: failed to parse arguments: {err}"), |
| 1397 | }; |
| 1398 | |
| 1399 | let path_str = match args.get("path").and_then(Value::as_str) { |
| 1400 | Some(p) => p, |
| 1401 | None => return "Error: missing required parameter \"path\"".to_string(), |
| 1402 | }; |
| 1403 | |
| 1404 | let content = match args.get("content").and_then(Value::as_str) { |
| 1405 | Some(c) => c, |
| 1406 | None => return "Error: missing required parameter \"content\"".to_string(), |
| 1407 | }; |
| 1408 | |
| 1409 | let path = Path::new(path_str); |
| 1410 | let absolute_path = absolute_path(path); |
| 1411 | let absolute_path_str = absolute_path.display().to_string(); |
| 1412 | |
| 1413 | if absolute_path.exists() { |
| 1414 | return format!( |
| 1415 | "Error: file already exists: {absolute_path_str} — use edit_file to modify existing files" |
| 1416 | ); |
| 1417 | } |
| 1418 | |
| 1419 | if let Some(parent) = absolute_path.parent() |
| 1420 | && !parent.as_os_str().is_empty() |
| 1421 | && !parent.exists() |
| 1422 | && let Err(err) = fs::create_dir_all(parent) |
| 1423 | { |
| 1424 | return format!("Error: could not create parent directories: {err}"); |
| 1425 | } |
| 1426 | |
| 1427 | match fs::write(&absolute_path, content) { |
| 1428 | Ok(()) => format!( |
| 1429 | "Created file: {absolute_path_str} ({} bytes)", |
| 1430 | content.len() |
| 1431 | ), |
| 1432 | Err(err) => format!("Error: could not write file: {err}"), |
| 1433 | } |
| 1434 | } |
| 1435 | |
| 1436 | // ── edit_file / multi_edit ───────────────────────────────────────────────── |
| 1437 | |
| 1438 | /// Apply one exact-substring replacement to `contents`. Returns the updated |
| 1439 | /// string, or a human-readable explanation of why the match failed so the model |
| 1440 | /// can correct itself in a single follow-up instead of guessing blindly. |
| 1441 | fn apply_edit( |
| 1442 | contents: &str, |
| 1443 | old_text: &str, |
| 1444 | new_text: &str, |
| 1445 | replace_all: bool, |
| 1446 | ) -> Result<String, String> { |
| 1447 | if old_text.is_empty() { |
| 1448 | return Err("old_text is empty; nothing to match".to_string()); |
| 1449 | } |
| 1450 | if old_text == new_text { |
| 1451 | return Err("old_text and new_text are identical; no change to make".to_string()); |
| 1452 | } |
| 1453 | |
| 1454 | let occurrences = contents.matches(old_text).count(); |
| 1455 | |
| 1456 | if occurrences == 0 { |
| 1457 | return Err(format!( |
| 1458 | "old_text not found. Use read_file to copy the exact text \ |
| 1459 | (including whitespace and indentation) to replace.{}", |
| 1460 | nearest_line_hint(contents, old_text) |
| 1461 | )); |
| 1462 | } |
| 1463 | |
| 1464 | if occurrences > 1 && !replace_all { |
| 1465 | return Err(format!( |
| 1466 | "old_text appears {occurrences} times; include more surrounding context so it \ |
| 1467 | matches exactly once, or set replace_all to true to change every occurrence." |
| 1468 | )); |
| 1469 | } |
| 1470 | |
| 1471 | if replace_all { |
| 1472 | Ok(contents.replace(old_text, new_text)) |
| 1473 | } else { |
| 1474 | Ok(contents.replacen(old_text, new_text, 1)) |
| 1475 | } |
| 1476 | } |
| 1477 | |
| 1478 | /// When `old_text` doesn't match verbatim, point at the line whose trimmed text |
| 1479 | /// equals the first trimmed line of `old_text` — the usual culprit is a |
| 1480 | /// whitespace/indentation mismatch, and naming the line lets the model fix it. |
| 1481 | fn nearest_line_hint(contents: &str, old_text: &str) -> String { |
| 1482 | let first = old_text.lines().find(|l| !l.trim().is_empty()); |
| 1483 | let Some(first) = first.map(str::trim) else { |
| 1484 | return String::new(); |
| 1485 | }; |
| 1486 | for (idx, line) in contents.lines().enumerate() { |
| 1487 | if line.trim() == first { |
| 1488 | return format!( |
| 1489 | " (the first line of old_text appears at line {}, so the difference is likely \ |
| 1490 | whitespace or indentation)", |
| 1491 | idx + 1 |
| 1492 | ); |
| 1493 | } |
| 1494 | } |
| 1495 | String::new() |
| 1496 | } |
| 1497 | |
| 1498 | fn exec_edit_file(arguments: &str) -> String { |
| 1499 | let args: Value = match serde_json::from_str(arguments) { |
| 1500 | Ok(v) => v, |
| 1501 | Err(err) => return format!("Error: failed to parse arguments: {err}"), |
| 1502 | }; |
| 1503 | |
| 1504 | let path_str = match args.get("path").and_then(Value::as_str) { |
| 1505 | Some(p) => p, |
| 1506 | None => return "Error: missing required parameter \"path\"".to_string(), |
| 1507 | }; |
| 1508 | |
| 1509 | let old_text = match args.get("old_text").and_then(Value::as_str) { |
| 1510 | Some(t) => t, |
| 1511 | None => return "Error: missing required parameter \"old_text\"".to_string(), |
| 1512 | }; |
| 1513 | |
| 1514 | let new_text = match args.get("new_text").and_then(Value::as_str) { |
| 1515 | Some(t) => t, |
| 1516 | None => return "Error: missing required parameter \"new_text\"".to_string(), |
| 1517 | }; |
| 1518 | |
| 1519 | let replace_all = args |
| 1520 | .get("replace_all") |
| 1521 | .and_then(Value::as_bool) |
| 1522 | .unwrap_or(false); |
| 1523 | |
| 1524 | let path = Path::new(path_str); |
| 1525 | let absolute_path = absolute_path(path); |
| 1526 | let absolute_path_str = absolute_path.display().to_string(); |
| 1527 | |
| 1528 | if !absolute_path.exists() { |
| 1529 | return format!( |
| 1530 | "Error: file does not exist: {absolute_path_str} — use create_file for new files" |
| 1531 | ); |
| 1532 | } |
| 1533 | |
| 1534 | if !absolute_path.is_file() { |
| 1535 | return format!("Error: path is not a file: {absolute_path_str}"); |
| 1536 | } |
| 1537 | |
| 1538 | let contents = match fs::read_to_string(&absolute_path) { |
| 1539 | Ok(c) => c, |
| 1540 | Err(err) => return format!("Error: could not read file: {err}"), |
| 1541 | }; |
| 1542 | |
| 1543 | let updated = match apply_edit(&contents, old_text, new_text, replace_all) { |
| 1544 | Ok(updated) => updated, |
| 1545 | Err(why) => return format!("Error: {why} (in {absolute_path_str})"), |
| 1546 | }; |
| 1547 | |
| 1548 | match fs::write(&absolute_path, &updated) { |
| 1549 | Ok(()) => format!( |
| 1550 | "Edited file: {absolute_path_str} ({} bytes written)", |
| 1551 | updated.len() |
| 1552 | ), |
| 1553 | Err(err) => format!("Error: could not write file: {err}"), |
| 1554 | } |
| 1555 | } |
| 1556 | |
| 1557 | /// Apply a batch of edits to one file atomically: each edit is applied to the |
| 1558 | /// result of the previous one, and the file is only written if *every* edit |
| 1559 | /// matches. A failure leaves the file untouched. |
| 1560 | fn exec_multi_edit(arguments: &str) -> String { |
| 1561 | let args: Value = match serde_json::from_str(arguments) { |
| 1562 | Ok(v) => v, |
| 1563 | Err(err) => return format!("Error: failed to parse arguments: {err}"), |
| 1564 | }; |
| 1565 | |
| 1566 | let path_str = match args.get("path").and_then(Value::as_str) { |
| 1567 | Some(p) => p, |
| 1568 | None => return "Error: missing required parameter \"path\"".to_string(), |
| 1569 | }; |
| 1570 | |
| 1571 | let edits = match args.get("edits").and_then(Value::as_array) { |
| 1572 | Some(e) if !e.is_empty() => e, |
| 1573 | Some(_) => return "Error: \"edits\" must contain at least one edit".to_string(), |
| 1574 | None => return "Error: missing required parameter \"edits\"".to_string(), |
| 1575 | }; |
| 1576 | |
| 1577 | let path = Path::new(path_str); |
| 1578 | let absolute_path = absolute_path(path); |
| 1579 | let absolute_path_str = absolute_path.display().to_string(); |
| 1580 | |
| 1581 | if !absolute_path.exists() { |
| 1582 | return format!( |
| 1583 | "Error: file does not exist: {absolute_path_str} — use create_file for new files" |
| 1584 | ); |
| 1585 | } |
| 1586 | |
| 1587 | if !absolute_path.is_file() { |
| 1588 | return format!("Error: path is not a file: {absolute_path_str}"); |
| 1589 | } |
| 1590 | |
| 1591 | let mut working = match fs::read_to_string(&absolute_path) { |
| 1592 | Ok(c) => c, |
| 1593 | Err(err) => return format!("Error: could not read file: {err}"), |
| 1594 | }; |
| 1595 | |
| 1596 | for (idx, edit) in edits.iter().enumerate() { |
| 1597 | let old_text = match edit.get("old_text").and_then(Value::as_str) { |
| 1598 | Some(t) => t, |
| 1599 | None => return format!("Error: edit #{} is missing \"old_text\"", idx + 1), |
| 1600 | }; |
| 1601 | let new_text = match edit.get("new_text").and_then(Value::as_str) { |
| 1602 | Some(t) => t, |
| 1603 | None => return format!("Error: edit #{} is missing \"new_text\"", idx + 1), |
| 1604 | }; |
| 1605 | let replace_all = edit |
| 1606 | .get("replace_all") |
| 1607 | .and_then(Value::as_bool) |
| 1608 | .unwrap_or(false); |
| 1609 | |
| 1610 | match apply_edit(&working, old_text, new_text, replace_all) { |
| 1611 | Ok(updated) => working = updated, |
| 1612 | Err(why) => { |
| 1613 | return format!( |
| 1614 | "Error: edit #{} failed: {why}. No changes were written to {absolute_path_str}.", |
| 1615 | idx + 1 |
| 1616 | ); |
| 1617 | } |
| 1618 | } |
| 1619 | } |
| 1620 | |
| 1621 | match fs::write(&absolute_path, &working) { |
| 1622 | Ok(()) => format!( |
| 1623 | "Applied {} edits to {absolute_path_str} ({} bytes written)", |
| 1624 | edits.len(), |
| 1625 | working.len() |
| 1626 | ), |
| 1627 | Err(err) => format!("Error: could not write file: {err}"), |
| 1628 | } |
| 1629 | } |
| 1630 | |
| 1631 | // ── glob ───────────────────────────────────────────────────────────────────── |
| 1632 | |
| 1633 | /// Translate a shell-style glob into an anchored regex. Supports `*` |
| 1634 | /// (non-separator run), `**` (any number of directories), `?` (one |
| 1635 | /// non-separator), and `{a,b}` alternation; everything else is matched |
| 1636 | /// literally. Used by the `glob` tool (against relative paths), by |
| 1637 | /// `search_files`' `file_glob` filter (against bare file names), and by |
| 1638 | /// `crate::permissions` rule patterns (which re-anchor the result). |
| 1639 | pub(crate) fn glob_to_regex(glob: &str) -> String { |
| 1640 | let chars: Vec<char> = glob.chars().collect(); |
| 1641 | let mut re = String::from("^"); |
| 1642 | let mut brace_depth = 0usize; |
| 1643 | let mut i = 0; |
| 1644 | |
| 1645 | while i < chars.len() { |
| 1646 | let c = chars[i]; |
| 1647 | match c { |
| 1648 | '*' => { |
| 1649 | if i + 1 < chars.len() && chars[i + 1] == '*' { |
| 1650 | i += 1; // consume the second '*' |
| 1651 | if i + 1 < chars.len() && chars[i + 1] == '/' { |
| 1652 | // `**/` matches zero or more leading directories. |
| 1653 | re.push_str("(?:.*/)?"); |
| 1654 | i += 1; // consume the '/' |
| 1655 | } else { |
| 1656 | re.push_str(".*"); |
| 1657 | } |
| 1658 | } else { |
| 1659 | re.push_str("[^/]*"); |
| 1660 | } |
| 1661 | } |
| 1662 | '?' => re.push_str("[^/]"), |
| 1663 | '{' => { |
| 1664 | brace_depth += 1; |
| 1665 | re.push_str("(?:"); |
| 1666 | } |
| 1667 | '}' if brace_depth > 0 => { |
| 1668 | brace_depth -= 1; |
| 1669 | re.push(')'); |
| 1670 | } |
| 1671 | ',' if brace_depth > 0 => re.push('|'), |
| 1672 | // Escape regex metacharacters so they match literally. (`{` is always |
| 1673 | // consumed by the brace arm above; an unmatched `}` lands here.) |
| 1674 | '.' | '+' | '(' | ')' | '|' | '^' | '$' | '\\' | '[' | ']' | '}' => { |
| 1675 | re.push('\\'); |
| 1676 | re.push(c); |
| 1677 | } |
| 1678 | other => re.push(other), |
| 1679 | } |
| 1680 | i += 1; |
| 1681 | } |
| 1682 | |
| 1683 | re.push('$'); |
| 1684 | re |
| 1685 | } |
| 1686 | |
| 1687 | fn exec_glob(arguments: &str) -> String { |
| 1688 | let args: Value = match serde_json::from_str(arguments) { |
| 1689 | Ok(v) => v, |
| 1690 | Err(err) => return format!("Error: failed to parse arguments: {err}"), |
| 1691 | }; |
| 1692 | |
| 1693 | let pattern = match args.get("pattern").and_then(Value::as_str) { |
| 1694 | Some(p) => p, |
| 1695 | None => return "Error: missing required parameter \"pattern\"".to_string(), |
| 1696 | }; |
| 1697 | |
| 1698 | let re = match Regex::new(&glob_to_regex(pattern)) { |
| 1699 | Ok(r) => r, |
| 1700 | Err(err) => return format!("Error: invalid glob pattern: {err}"), |
| 1701 | }; |
| 1702 | |
| 1703 | let root_str = args.get("path").and_then(Value::as_str).unwrap_or("."); |
| 1704 | let absolute_root = absolute_path(Path::new(root_str)); |
| 1705 | let absolute_root_str = absolute_root.display().to_string(); |
| 1706 | |
| 1707 | if !absolute_root.exists() { |
| 1708 | return format!("Error: path does not exist: {absolute_root_str}"); |
| 1709 | } |
| 1710 | if !absolute_root.is_dir() { |
| 1711 | return format!("Error: path is not a directory: {absolute_root_str}"); |
| 1712 | } |
| 1713 | |
| 1714 | let mut found: Vec<(std::time::SystemTime, String)> = Vec::new(); |
| 1715 | glob_walk(&absolute_root, &absolute_root, &re, &mut found); |
| 1716 | |
| 1717 | if found.is_empty() { |
| 1718 | return format!("No files match glob: {pattern}"); |
| 1719 | } |
| 1720 | |
| 1721 | // Most-recently-modified first. |
| 1722 | found.sort_by_key(|(mtime, _)| std::cmp::Reverse(*mtime)); |
| 1723 | |
| 1724 | let total = found.len(); |
| 1725 | let mut paths: Vec<String> = found.into_iter().map(|(_, p)| p).collect(); |
| 1726 | if total > SEARCH_RESULTS_HARD_CAP { |
| 1727 | paths.truncate(SEARCH_RESULTS_HARD_CAP); |
| 1728 | paths.push(format!( |
| 1729 | "\n--- truncated (showing {SEARCH_RESULTS_HARD_CAP} of {total} files) ---" |
| 1730 | )); |
| 1731 | } |
| 1732 | |
| 1733 | paths.join("\n") |
| 1734 | } |
| 1735 | |
| 1736 | fn glob_walk(root: &Path, dir: &Path, re: &Regex, out: &mut Vec<(std::time::SystemTime, String)>) { |
| 1737 | if out.len() > SEARCH_RESULTS_HARD_CAP { |
| 1738 | return; |
| 1739 | } |
| 1740 | |
| 1741 | let entries = match fs::read_dir(dir) { |
| 1742 | Ok(rd) => rd, |
| 1743 | Err(_) => return, |
| 1744 | }; |
| 1745 | |
| 1746 | let mut sorted: Vec<fs::DirEntry> = entries.filter_map(Result::ok).collect(); |
| 1747 | sorted.sort_by_key(|e| e.file_name()); |
| 1748 | |
| 1749 | for entry in sorted { |
| 1750 | if out.len() > SEARCH_RESULTS_HARD_CAP { |
| 1751 | return; |
| 1752 | } |
| 1753 | |
| 1754 | let path = entry.path(); |
| 1755 | let name = entry.file_name(); |
| 1756 | if name.to_string_lossy().starts_with('.') { |
| 1757 | continue; |
| 1758 | } |
| 1759 | |
| 1760 | if path.is_dir() { |
| 1761 | glob_walk(root, &path, re, out); |
| 1762 | } else if path.is_file() { |
| 1763 | let relative = path |
| 1764 | .strip_prefix(root) |
| 1765 | .unwrap_or(&path) |
| 1766 | .to_string_lossy() |
| 1767 | .replace('\\', "/"); |
| 1768 | if re.is_match(&relative) { |
| 1769 | let mtime = entry |
| 1770 | .metadata() |
| 1771 | .and_then(|m| m.modified()) |
| 1772 | .unwrap_or(std::time::SystemTime::UNIX_EPOCH); |
| 1773 | out.push((mtime, absolute_path_string(&path))); |
| 1774 | } |
| 1775 | } |
| 1776 | } |
| 1777 | } |
| 1778 | |
| 1779 | // ── write_todos ────────────────────────────────────────────────────────────── |
| 1780 | |
| 1781 | /// Renders the model's task checklist back as the tool result so the surface |
| 1782 | /// (TUI / ACP client) can show live progress. Pure presentation — the list is |
| 1783 | /// owned by the model, not persisted here. |
| 1784 | fn exec_write_todos(arguments: &str) -> String { |
| 1785 | let args: Value = match serde_json::from_str(arguments) { |
| 1786 | Ok(v) => v, |
| 1787 | Err(err) => return format!("Error: failed to parse arguments: {err}"), |
| 1788 | }; |
| 1789 | |
| 1790 | let todos = match args.get("todos").and_then(Value::as_array) { |
| 1791 | Some(t) if !t.is_empty() => t, |
| 1792 | Some(_) => return "Error: \"todos\" must contain at least one item".to_string(), |
| 1793 | None => return "Error: missing required parameter \"todos\"".to_string(), |
| 1794 | }; |
| 1795 | |
| 1796 | let mut lines = Vec::with_capacity(todos.len()); |
| 1797 | let mut completed = 0usize; |
| 1798 | |
| 1799 | for (idx, todo) in todos.iter().enumerate() { |
| 1800 | let content = match todo.get("content").and_then(Value::as_str) { |
| 1801 | Some(c) => c.trim(), |
| 1802 | None => return format!("Error: todo #{} is missing \"content\"", idx + 1), |
| 1803 | }; |
| 1804 | let status = todo |
| 1805 | .get("status") |
| 1806 | .and_then(Value::as_str) |
| 1807 | .unwrap_or("pending"); |
| 1808 | |
| 1809 | let marker = match status { |
| 1810 | "completed" => { |
| 1811 | completed += 1; |
| 1812 | "[x]" |
| 1813 | } |
| 1814 | "in_progress" => "[~]", |
| 1815 | _ => "[ ]", |
| 1816 | }; |
| 1817 | lines.push(format!("{marker} {content}")); |
| 1818 | } |
| 1819 | |
| 1820 | format!( |
| 1821 | "Task list updated ({completed}/{} done):\n{}", |
| 1822 | todos.len(), |
| 1823 | lines.join("\n") |
| 1824 | ) |
| 1825 | } |
| 1826 | |
| 1827 | // ── remember ───────────────────────────────────────────────────────────────── |
| 1828 | |
| 1829 | /// Appends a durable note to the project's instruction file so it persists |
| 1830 | /// across sessions (the always-on counterpart to a one-off chat message). |
| 1831 | fn exec_remember(arguments: &str) -> String { |
| 1832 | let args: Value = match serde_json::from_str(arguments) { |
| 1833 | Ok(v) => v, |
| 1834 | Err(err) => return format!("Error: failed to parse arguments: {err}"), |
| 1835 | }; |
| 1836 | |
| 1837 | let note = match args.get("note").and_then(Value::as_str) { |
| 1838 | Some(n) if !n.trim().is_empty() => n.trim(), |
| 1839 | Some(_) => return "Error: \"note\" must not be empty".to_string(), |
| 1840 | None => return "Error: missing required parameter \"note\"".to_string(), |
| 1841 | }; |
| 1842 | |
| 1843 | let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")); |
| 1844 | remember_at(&cwd, note) |
| 1845 | } |
| 1846 | |
| 1847 | /// Core of `remember`, parameterized on the working directory so it can be |
| 1848 | /// tested without mutating the process-global current directory. |
| 1849 | fn remember_at(cwd: &Path, note: &str) -> String { |
| 1850 | let target = crate::instructions::memory_file(cwd); |
| 1851 | let target_str = target.display().to_string(); |
| 1852 | |
| 1853 | let existed = target.exists(); |
| 1854 | let mut body = if existed { |
| 1855 | match fs::read_to_string(&target) { |
| 1856 | Ok(c) => c, |
| 1857 | Err(err) => return format!("Error: could not read {target_str}: {err}"), |
| 1858 | } |
| 1859 | } else { |
| 1860 | if let Some(parent) = target.parent() |
| 1861 | && !parent.as_os_str().is_empty() |
| 1862 | && !parent.exists() |
| 1863 | && let Err(err) = fs::create_dir_all(parent) |
| 1864 | { |
| 1865 | return format!("Error: could not create parent directories: {err}"); |
| 1866 | } |
| 1867 | String::new() |
| 1868 | }; |
| 1869 | |
| 1870 | // Keep remembered notes grouped under one heading so the file stays tidy. |
| 1871 | const HEADING: &str = "## Remembered notes"; |
| 1872 | if !body.contains(HEADING) { |
| 1873 | if !body.is_empty() && !body.ends_with('\n') { |
| 1874 | body.push('\n'); |
| 1875 | } |
| 1876 | if !body.is_empty() { |
| 1877 | body.push('\n'); |
| 1878 | } |
| 1879 | body.push_str(HEADING); |
| 1880 | body.push('\n'); |
| 1881 | } |
| 1882 | if !body.ends_with('\n') { |
| 1883 | body.push('\n'); |
| 1884 | } |
| 1885 | body.push_str("- "); |
| 1886 | body.push_str(note); |
| 1887 | body.push('\n'); |
| 1888 | |
| 1889 | match fs::write(&target, &body) { |
| 1890 | Ok(()) => { |
| 1891 | let verb = if existed { "Appended to" } else { "Created" }; |
| 1892 | format!("{verb} {target_str}: remembered \"{note}\"") |
| 1893 | } |
| 1894 | Err(err) => format!("Error: could not write {target_str}: {err}"), |
| 1895 | } |
| 1896 | } |
| 1897 | |
| 1898 | // ── delete_file ────────────────────────────────────────────────────────────── |
| 1899 | |
| 1900 | /// only removes files or *empty* directories — no recursive deletes. |
| 1901 | fn exec_delete_file(arguments: &str) -> String { |
| 1902 | let args: Value = match serde_json::from_str(arguments) { |
| 1903 | Ok(v) => v, |
| 1904 | Err(err) => return format!("Error: failed to parse arguments: {err}"), |
| 1905 | }; |
| 1906 | |
| 1907 | let path_str = match args.get("path").and_then(Value::as_str) { |
| 1908 | Some(p) => p, |
| 1909 | None => return "Error: missing required parameter \"path\"".to_string(), |
| 1910 | }; |
| 1911 | |
| 1912 | let path = Path::new(path_str); |
| 1913 | let absolute_path = absolute_path(path); |
| 1914 | let absolute_path_str = absolute_path.display().to_string(); |
| 1915 | |
| 1916 | if !absolute_path.exists() { |
| 1917 | return format!("Error: path does not exist: {absolute_path_str}"); |
| 1918 | } |
| 1919 | |
| 1920 | if absolute_path.is_dir() { |
| 1921 | match fs::remove_dir(&absolute_path) { |
| 1922 | Ok(()) => format!("Deleted empty directory: {absolute_path_str}"), |
| 1923 | Err(err) => format!( |
| 1924 | "Error: could not delete directory: {err}. \ |
| 1925 | Only empty directories can be deleted." |
| 1926 | ), |
| 1927 | } |
| 1928 | } else { |
| 1929 | match fs::remove_file(&absolute_path) { |
| 1930 | Ok(()) => format!("Deleted file: {absolute_path_str}"), |
| 1931 | Err(err) => format!("Error: could not delete file: {err}"), |
| 1932 | } |
| 1933 | } |
| 1934 | } |
| 1935 | |
| 1936 | // ── run_command ────────────────────────────────────────────────────────────── |
| 1937 | |
| 1938 | const COMMAND_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(120); |
| 1939 | const COMMAND_OUTPUT_LIMIT: usize = 50_000; |
| 1940 | /// How long to keep waiting for the output drains once the command itself has |
| 1941 | /// exited. Only a process that outlived the shell and still holds a pipe can |
| 1942 | /// use this up, so it is short. |
| 1943 | const OUTPUT_GRACE: std::time::Duration = std::time::Duration::from_secs(2); |
| 1944 | |
| 1945 | /// Spawn `command_str` through the platform shell with piped stdout/stderr. |
| 1946 | /// Shared by the foreground and background paths of `run_command`. |
| 1947 | /// |
| 1948 | /// stdin is `/dev/null`, never inherited. In ACP mode sigit's stdin is the |
| 1949 | /// JSON-RPC pipe from the editor, and a child holding that fd can read the |
| 1950 | /// client's next request out of it: the agent never sees the request, the |
| 1951 | /// editor waits forever for a reply, and the session is wedged past any |
| 1952 | /// timeout this function's callers apply. Null also means a command that wants |
| 1953 | /// input gets EOF and fails instead of blocking on input nobody can type. |
| 1954 | /// |
| 1955 | /// On Unix the child also leads its own process group, so a timeout can kill |
| 1956 | /// the whole tree. `sh -c` may fork rather than exec (dash does), and a |
| 1957 | /// surviving grandchild keeps the pipe write ends open. |
| 1958 | fn spawn_shell(command_str: &str, cwd_path: &Path) -> std::io::Result<std::process::Child> { |
| 1959 | #[cfg(unix)] |
| 1960 | let (shell, flag) = ("sh", "-c"); |
| 1961 | #[cfg(windows)] |
| 1962 | let (shell, flag) = ("cmd", "/C"); |
| 1963 | |
| 1964 | let mut command = Command::new(shell); |
| 1965 | command |
| 1966 | .arg(flag) |
| 1967 | .arg(command_str) |
| 1968 | .current_dir(cwd_path) |
| 1969 | .stdin(std::process::Stdio::null()) |
| 1970 | .stdout(std::process::Stdio::piped()) |
| 1971 | .stderr(std::process::Stdio::piped()); |
| 1972 | #[cfg(unix)] |
| 1973 | std::os::unix::process::CommandExt::process_group(&mut command, 0); |
| 1974 | command.spawn() |
| 1975 | } |
| 1976 | |
| 1977 | /// SIGKILL a shell spawned by [`spawn_shell`] and everything it started, then |
| 1978 | /// reap it so the timeout path doesn't leave a zombie behind. |
| 1979 | fn kill_shell_tree(child: &mut std::process::Child) { |
| 1980 | #[cfg(unix)] |
| 1981 | { |
| 1982 | // Negative pid is the process group; the child is its leader. Fall |
| 1983 | // back to the child alone if the group is already gone. |
| 1984 | if unsafe { libc::kill(-(child.id() as i32), libc::SIGKILL) } != 0 { |
| 1985 | let _ = child.kill(); |
| 1986 | } |
| 1987 | } |
| 1988 | #[cfg(windows)] |
| 1989 | let _ = child.kill(); |
| 1990 | let _ = child.wait(); |
| 1991 | } |
| 1992 | |
| 1993 | /// Trailer identifying siGit Code as the co-author of commits it creates. |
| 1994 | /// GitHub detects `Co-authored-by:` trailers on the last lines of a commit |
| 1995 | /// message (separated from the body by a blank line) and lists the agent |
| 1996 | /// alongside the human author; `noreply@sigit.si` |
| 1997 | /// is the noreply address for the <https://github.com/sigitc> account |
| 1998 | /// ("siGit Code"), so the co-author is rendered with that account's avatar |
| 1999 | /// and profile link. The system prompt asks the model to add this itself; |
| 2000 | /// [`ensure_commit_co_author`] is the safety net when it forgets. |
| 2001 | pub const COMMIT_CO_AUTHOR_TRAILER: &str = "Co-Authored-By: siGit Code <noreply@sigit.si>"; |
| 2002 | |
| 2003 | /// Run `git <args>` in `cwd`, returning trimmed stdout on success. |
| 2004 | /// |
| 2005 | /// stdin is null for the same reason as [`spawn_shell`]: git can ask for input |
| 2006 | /// (a credential, a signing passphrase), and inheriting the ACP pipe would let |
| 2007 | /// it read the editor's next request instead. |
| 2008 | fn git_stdout(cwd: &Path, args: &[&str]) -> Option<String> { |
| 2009 | let output = Command::new("git") |
| 2010 | .args(args) |
| 2011 | .current_dir(cwd) |
| 2012 | .stdin(std::process::Stdio::null()) |
| 2013 | .output() |
| 2014 | .ok()?; |
| 2015 | output |
| 2016 | .status |
| 2017 | .success() |
| 2018 | .then(|| String::from_utf8_lossy(&output.stdout).trim().to_string()) |
| 2019 | } |
| 2020 | |
| 2021 | fn git_head(cwd: &Path) -> Option<String> { |
| 2022 | git_stdout(cwd, &["rev-parse", "HEAD"]) |
| 2023 | } |
| 2024 | |
| 2025 | /// Deterministic co-author attribution: if the commit at HEAD lacks the |
| 2026 | /// siGit Code trailer, amend it in (via `git commit --amend --trailer`, which |
| 2027 | /// places it after a blank line — the format GitHub detects). Never rewrites |
| 2028 | /// a commit that is already on a remote. Returns a note describing the amend |
| 2029 | /// so the model and user can see it happened. |
| 2030 | fn ensure_commit_co_author(cwd: &Path) -> Option<String> { |
| 2031 | let message = git_stdout(cwd, &["log", "-1", "--format=%B"])?; |
| 2032 | if message |
| 2033 | .to_lowercase() |
| 2034 | .contains("co-authored-by: sigit code") |
| 2035 | { |
| 2036 | return None; |
| 2037 | } |
| 2038 | // Amending changes the commit id; a commit that any remote ref already |
| 2039 | // contains must be left alone or the branch diverges from its upstream. |
| 2040 | match git_stdout(cwd, &["branch", "-r", "--contains", "HEAD"]) { |
| 2041 | Some(remotes) if remotes.is_empty() => {} |
| 2042 | _ => return None, |
| 2043 | } |
| 2044 | let amend = Command::new("git") |
| 2045 | .args(["commit", "--amend", "--no-edit", "--trailer"]) |
| 2046 | .arg(COMMIT_CO_AUTHOR_TRAILER) |
| 2047 | .stdin(std::process::Stdio::null()) |
| 2048 | .current_dir(cwd) |
| 2049 | .output() |
| 2050 | .ok()?; |
| 2051 | if amend.status.success() { |
| 2052 | log::info!( |
| 2053 | "appended co-author trailer to the new commit in {}", |
| 2054 | cwd.display() |
| 2055 | ); |
| 2056 | Some(format!( |
| 2057 | "[siGit Code] The new commit was amended to append the co-author trailer \ |
| 2058 | \"{COMMIT_CO_AUTHOR_TRAILER}\" (its hash changed)." |
| 2059 | )) |
| 2060 | } else { |
| 2061 | log::warn!( |
| 2062 | "could not append co-author trailer in {}: {}", |
| 2063 | cwd.display(), |
| 2064 | String::from_utf8_lossy(&amend.stderr).trim() |
| 2065 | ); |
| 2066 | None |
| 2067 | } |
| 2068 | } |
| 2069 | |
| 2070 | /// runs via `sh -c` / `cmd /C`; killed after COMMAND_TIMEOUT unless |
| 2071 | /// `run_in_background` is set, in which case the child is registered as a |
| 2072 | /// background task and polled with `command_output` / stopped with |
| 2073 | /// `kill_command`. |
| 2074 | fn exec_run_command(arguments: &str) -> String { |
| 2075 | let args: Value = match serde_json::from_str(arguments) { |
| 2076 | Ok(v) => v, |
| 2077 | Err(err) => return format!("Error: failed to parse arguments: {err}"), |
| 2078 | }; |
| 2079 | |
| 2080 | let command_str = match args.get("command").and_then(Value::as_str) { |
| 2081 | Some(c) => c, |
| 2082 | None => return "Error: missing required parameter \"command\"".to_string(), |
| 2083 | }; |
| 2084 | |
| 2085 | let default_cwd = std::env::var("HOME").unwrap_or_else(|_| ".".to_string()); |
| 2086 | let cwd = args |
| 2087 | .get("cwd") |
| 2088 | .and_then(Value::as_str) |
| 2089 | .unwrap_or(&default_cwd); |
| 2090 | let cwd_path = absolute_path(Path::new(cwd)); |
| 2091 | let cwd_str = cwd_path.display().to_string(); |
| 2092 | |
| 2093 | if !cwd_path.exists() { |
| 2094 | return format!("Error: working directory does not exist: {cwd_str}"); |
| 2095 | } |
| 2096 | |
| 2097 | let run_in_background = args |
| 2098 | .get("run_in_background") |
| 2099 | .and_then(Value::as_bool) |
| 2100 | .unwrap_or(false); |
| 2101 | |
| 2102 | log::info!("run_command: `{command_str}` in `{cwd_str}` (background: {run_in_background})"); |
| 2103 | |
| 2104 | if run_in_background { |
| 2105 | return start_background_task(command_str, &cwd_path); |
| 2106 | } |
| 2107 | |
| 2108 | // Co-author attribution: note where HEAD is before a command that looks |
| 2109 | // like it may commit, so a new commit can be detected afterwards. The |
| 2110 | // string check is only a cheap trigger — a false positive costs one |
| 2111 | // `git rev-parse` and nothing else. Background tasks skip the gate: their |
| 2112 | // commits finish after the tool returns, when there is nothing to amend |
| 2113 | // from. |
| 2114 | let may_commit = command_str.contains("git") && command_str.contains("commit"); |
| 2115 | let head_before = if may_commit { |
| 2116 | git_head(&cwd_path) |
| 2117 | } else { |
| 2118 | None |
| 2119 | }; |
| 2120 | |
| 2121 | let mut child = match spawn_shell(command_str, &cwd_path) { |
| 2122 | Ok(c) => c, |
| 2123 | Err(err) => return format!("Error: failed to spawn command: {err}"), |
| 2124 | }; |
| 2125 | |
| 2126 | // Drain both pipes on their own threads. Waiting first and reading after |
| 2127 | // deadlocks the moment a command writes more than the pipe buffer holds |
| 2128 | // (64 KiB on Linux, less on macOS): the child blocks on the full pipe, we |
| 2129 | // block on the child, and the only way out is the timeout. A build or a |
| 2130 | // verbose test run crosses that on its own. |
| 2131 | let (chunks_tx, chunks_rx) = std::sync::mpsc::channel::<(u8, Vec<u8>)>(); |
| 2132 | let drain = |tag: u8, pipe: Option<Box<dyn std::io::Read + Send>>| { |
| 2133 | let tx = chunks_tx.clone(); |
| 2134 | std::thread::spawn(move || { |
| 2135 | let mut buf = Vec::new(); |
| 2136 | if let Some(mut pipe) = pipe { |
| 2137 | let _ = pipe.read_to_end(&mut buf); |
| 2138 | } |
| 2139 | let _ = tx.send((tag, buf)); |
| 2140 | }); |
| 2141 | }; |
| 2142 | drain(0, child.stdout.take().map(|pipe| Box::new(pipe) as _)); |
| 2143 | drain(1, child.stderr.take().map(|pipe| Box::new(pipe) as _)); |
| 2144 | drop(chunks_tx); |
| 2145 | |
| 2146 | let start = std::time::Instant::now(); |
| 2147 | let status = loop { |
| 2148 | match child.try_wait() { |
| 2149 | Ok(Some(status)) => break status, |
| 2150 | Ok(None) => { |
| 2151 | if start.elapsed() >= COMMAND_TIMEOUT { |
| 2152 | kill_shell_tree(&mut child); |
| 2153 | return format!( |
| 2154 | "Error: command timed out after {} seconds and was killed.", |
| 2155 | COMMAND_TIMEOUT.as_secs() |
| 2156 | ); |
| 2157 | } |
| 2158 | std::thread::sleep(std::time::Duration::from_millis(100)); |
| 2159 | } |
| 2160 | Err(err) => return format!("Error: failed to wait on command: {err}"), |
| 2161 | } |
| 2162 | }; |
| 2163 | |
| 2164 | // Collect what the drains read, bounded. The shell has exited, but a |
| 2165 | // grandchild that outlived it can still hold a pipe open, and joining |
| 2166 | // those threads would wait on it forever. |
| 2167 | let mut stdout_bytes = Vec::new(); |
| 2168 | let mut stderr_bytes = Vec::new(); |
| 2169 | let deadline = std::time::Instant::now() + OUTPUT_GRACE; |
| 2170 | for _ in 0..2 { |
| 2171 | let remaining = deadline.saturating_duration_since(std::time::Instant::now()); |
| 2172 | match chunks_rx.recv_timeout(remaining) { |
| 2173 | Ok((0, bytes)) => stdout_bytes = bytes, |
| 2174 | Ok((_, bytes)) => stderr_bytes = bytes, |
| 2175 | Err(_) => break, |
| 2176 | } |
| 2177 | } |
| 2178 | |
| 2179 | let exit_code = status.code().unwrap_or(-1); |
| 2180 | let mut combined = String::new(); |
| 2181 | combined.push_str(&String::from_utf8_lossy(&stdout_bytes)); |
| 2182 | combined.push_str(&String::from_utf8_lossy(&stderr_bytes)); |
| 2183 | |
| 2184 | // A new commit appeared under this command: make sure it carries the |
| 2185 | // siGit co-author trailer (see `ensure_commit_co_author`). |
| 2186 | if may_commit { |
| 2187 | let head_after = git_head(&cwd_path); |
| 2188 | if head_after.is_some() |
| 2189 | && head_after != head_before |
| 2190 | && let Some(note) = ensure_commit_co_author(&cwd_path) |
| 2191 | { |
| 2192 | if !combined.is_empty() && !combined.ends_with('\n') { |
| 2193 | combined.push('\n'); |
| 2194 | } |
| 2195 | combined.push_str(¬e); |
| 2196 | } |
| 2197 | } |
| 2198 | |
| 2199 | let truncated = if combined.len() > COMMAND_OUTPUT_LIMIT { |
| 2200 | let truncated_str = &combined[..COMMAND_OUTPUT_LIMIT]; |
| 2201 | format!("{truncated_str}\n\n… (output truncated at {COMMAND_OUTPUT_LIMIT} bytes)") |
| 2202 | } else { |
| 2203 | combined |
| 2204 | }; |
| 2205 | |
| 2206 | if status.success() { |
| 2207 | if truncated.is_empty() { |
| 2208 | format!("Command succeeded (exit code {exit_code}) with no output.") |
| 2209 | } else { |
| 2210 | format!("Exit code {exit_code}:\n{truncated}") |
| 2211 | } |
| 2212 | } else { |
| 2213 | format!("Command failed (exit code {exit_code}):\n{truncated}") |
| 2214 | } |
| 2215 | } |
| 2216 | |
| 2217 | // ── background tasks (command_output / kill_command) ───────────────────────── |
| 2218 | |
| 2219 | /// A command started with `run_in_background`. The child is never detached, |
| 2220 | /// so background tasks die with the sigit process. |
| 2221 | struct BackgroundTask { |
| 2222 | command: String, |
| 2223 | child: std::process::Child, |
| 2224 | /// Combined stdout+stderr drained by the reader threads, shared with them. |
| 2225 | output: Arc<Mutex<TaskOutput>>, |
| 2226 | /// Exit code cached once observed; `-1` when there is no code (killed by |
| 2227 | /// a signal). `None` while the task is still running. |
| 2228 | exit_code: Option<i32>, |
| 2229 | /// Set when the task was stopped via `kill_command`. |
| 2230 | killed: bool, |
| 2231 | } |
| 2232 | |
| 2233 | /// Output accumulated for a background task since the last poll. |
| 2234 | #[derive(Default)] |
| 2235 | struct TaskOutput { |
| 2236 | buf: String, |
| 2237 | /// Whether the oldest output was dropped because `buf` hit the cap. |
| 2238 | dropped: bool, |
| 2239 | } |
| 2240 | |
| 2241 | /// Process-global background task table (same pattern as `mcp::MCP`). |
| 2242 | fn tasks() -> &'static Mutex<HashMap<u64, BackgroundTask>> { |
| 2243 | static TASKS: OnceLock<Mutex<HashMap<u64, BackgroundTask>>> = OnceLock::new(); |
| 2244 | TASKS.get_or_init(|| Mutex::new(HashMap::new())) |
| 2245 | } |
| 2246 | |
| 2247 | fn lock_tasks() -> std::sync::MutexGuard<'static, HashMap<u64, BackgroundTask>> { |
| 2248 | tasks() |
| 2249 | .lock() |
| 2250 | .unwrap_or_else(|poisoned| poisoned.into_inner()) |
| 2251 | } |
| 2252 | |
| 2253 | fn next_task_id() -> u64 { |
| 2254 | static NEXT_ID: AtomicU64 = AtomicU64::new(1); |
| 2255 | NEXT_ID.fetch_add(1, Ordering::Relaxed) |
| 2256 | } |
| 2257 | |
| 2258 | /// Drain a child's stream into the task's shared buffer on a plain std thread, |
| 2259 | /// capping the buffer at COMMAND_OUTPUT_LIMIT by dropping the oldest output. |
| 2260 | fn spawn_output_reader<R: std::io::Read + Send + 'static>( |
| 2261 | mut stream: R, |
| 2262 | output: Arc<Mutex<TaskOutput>>, |
| 2263 | ) { |
| 2264 | std::thread::spawn(move || { |
| 2265 | let mut chunk = [0u8; 8192]; |
| 2266 | loop { |
| 2267 | match stream.read(&mut chunk) { |
| 2268 | Ok(0) | Err(_) => break, |
| 2269 | Ok(n) => { |
| 2270 | let text = String::from_utf8_lossy(&chunk[..n]).into_owned(); |
| 2271 | let mut out = output |
| 2272 | .lock() |
| 2273 | .unwrap_or_else(|poisoned| poisoned.into_inner()); |
| 2274 | out.buf.push_str(&text); |
| 2275 | if out.buf.len() > COMMAND_OUTPUT_LIMIT { |
| 2276 | let mut cut = out.buf.len() - COMMAND_OUTPUT_LIMIT; |
| 2277 | while !out.buf.is_char_boundary(cut) { |
| 2278 | cut += 1; |
| 2279 | } |
| 2280 | out.buf.drain(..cut); |
| 2281 | out.dropped = true; |
| 2282 | } |
| 2283 | } |
| 2284 | } |
| 2285 | } |
| 2286 | }); |
| 2287 | } |
| 2288 | |
| 2289 | /// Background branch of `run_command`: spawn, register, return immediately. |
| 2290 | fn start_background_task(command_str: &str, cwd_path: &Path) -> String { |
| 2291 | let mut child = match spawn_shell(command_str, cwd_path) { |
| 2292 | Ok(c) => c, |
| 2293 | Err(err) => return format!("Error: failed to spawn command: {err}"), |
| 2294 | }; |
| 2295 | |
| 2296 | let output = Arc::new(Mutex::new(TaskOutput::default())); |
| 2297 | if let Some(stdout) = child.stdout.take() { |
| 2298 | spawn_output_reader(stdout, Arc::clone(&output)); |
| 2299 | } |
| 2300 | if let Some(stderr) = child.stderr.take() { |
| 2301 | spawn_output_reader(stderr, Arc::clone(&output)); |
| 2302 | } |
| 2303 | |
| 2304 | let task_id = next_task_id(); |
| 2305 | lock_tasks().insert( |
| 2306 | task_id, |
| 2307 | BackgroundTask { |
| 2308 | command: command_str.to_string(), |
| 2309 | child, |
| 2310 | output, |
| 2311 | exit_code: None, |
| 2312 | killed: false, |
| 2313 | }, |
| 2314 | ); |
| 2315 | |
| 2316 | format!( |
| 2317 | "Started background task {task_id}: `{command_str}`. Poll its output and status \ |
| 2318 | with command_output (task_id: {task_id}); stop it with kill_command. The task is \ |
| 2319 | killed when sigit exits." |
| 2320 | ) |
| 2321 | } |
| 2322 | |
| 2323 | /// Check (and cache) whether a task's child has exited. Returns the exit code, |
| 2324 | /// or `None` while it is still running. |
| 2325 | fn poll_exit_code(task: &mut BackgroundTask) -> Option<i32> { |
| 2326 | if task.exit_code.is_none() |
| 2327 | && let Ok(Some(status)) = task.child.try_wait() |
| 2328 | { |
| 2329 | task.exit_code = Some(status.code().unwrap_or(-1)); |
| 2330 | } |
| 2331 | task.exit_code |
| 2332 | } |
| 2333 | |
| 2334 | /// Take everything the task has printed since the last poll, plus whether |
| 2335 | /// older output was dropped at the buffer cap. |
| 2336 | fn drain_task_output(task: &BackgroundTask) -> (String, bool) { |
| 2337 | let mut out = task |
| 2338 | .output |
| 2339 | .lock() |
| 2340 | .unwrap_or_else(|poisoned| poisoned.into_inner()); |
| 2341 | ( |
| 2342 | std::mem::take(&mut out.buf), |
| 2343 | std::mem::take(&mut out.dropped), |
| 2344 | ) |
| 2345 | } |
| 2346 | |
| 2347 | fn parse_task_id(arguments: &str) -> Result<u64, String> { |
| 2348 | let args: Value = serde_json::from_str(arguments) |
| 2349 | .map_err(|err| format!("Error: failed to parse arguments: {err}"))?; |
| 2350 | args.get("task_id") |
| 2351 | .and_then(Value::as_u64) |
| 2352 | .ok_or_else(|| "Error: missing required parameter \"task_id\"".to_string()) |
| 2353 | } |
| 2354 | |
| 2355 | fn unknown_task(task_id: u64) -> String { |
| 2356 | format!( |
| 2357 | "Error: no background task with id {task_id}. Start one with run_command and \ |
| 2358 | run_in_background set to true." |
| 2359 | ) |
| 2360 | } |
| 2361 | |
| 2362 | fn dropped_note(dropped: bool) -> &'static str { |
| 2363 | if dropped { |
| 2364 | "\n(note: earlier output was dropped after exceeding the 50000-byte buffer)" |
| 2365 | } else { |
| 2366 | "" |
| 2367 | } |
| 2368 | } |
| 2369 | |
| 2370 | /// `command_output` tool: output since the last poll + running/exited status. |
| 2371 | fn exec_command_output(arguments: &str) -> String { |
| 2372 | let task_id = match parse_task_id(arguments) { |
| 2373 | Ok(id) => id, |
| 2374 | Err(err) => return err, |
| 2375 | }; |
| 2376 | |
| 2377 | let mut map = lock_tasks(); |
| 2378 | let Some(task) = map.get_mut(&task_id) else { |
| 2379 | return unknown_task(task_id); |
| 2380 | }; |
| 2381 | |
| 2382 | let was_running = task.exit_code.is_none(); |
| 2383 | let exit_code = poll_exit_code(task); |
| 2384 | if was_running && exit_code.is_some() { |
| 2385 | // The child just exited; give the reader threads a moment to flush |
| 2386 | // the final output through the pipes before draining the buffer. |
| 2387 | std::thread::sleep(std::time::Duration::from_millis(100)); |
| 2388 | } |
| 2389 | let (new_output, dropped) = drain_task_output(task); |
| 2390 | |
| 2391 | let command = &task.command; |
| 2392 | let status = match exit_code { |
| 2393 | None => format!("Task {task_id} (`{command}`) is still running."), |
| 2394 | Some(_) if task.killed => format!("Task {task_id} (`{command}`) was killed."), |
| 2395 | Some(code) => format!("Task {task_id} (`{command}`) exited with code {code}."), |
| 2396 | }; |
| 2397 | |
| 2398 | if new_output.is_empty() { |
| 2399 | format!( |
| 2400 | "{status} No new output since the last check.{}", |
| 2401 | dropped_note(dropped) |
| 2402 | ) |
| 2403 | } else { |
| 2404 | format!( |
| 2405 | "{status} New output since the last check:{}\n{new_output}", |
| 2406 | dropped_note(dropped) |
| 2407 | ) |
| 2408 | } |
| 2409 | } |
| 2410 | |
| 2411 | /// `kill_command` tool: stop a background task and report its output tail. |
| 2412 | fn exec_kill_command(arguments: &str) -> String { |
| 2413 | let task_id = match parse_task_id(arguments) { |
| 2414 | Ok(id) => id, |
| 2415 | Err(err) => return err, |
| 2416 | }; |
| 2417 | |
| 2418 | let mut map = lock_tasks(); |
| 2419 | let Some(task) = map.get_mut(&task_id) else { |
| 2420 | return unknown_task(task_id); |
| 2421 | }; |
| 2422 | |
| 2423 | if let Some(code) = poll_exit_code(task) { |
| 2424 | let (new_output, dropped) = drain_task_output(task); |
| 2425 | let command = &task.command; |
| 2426 | let tail = if new_output.is_empty() { |
| 2427 | String::new() |
| 2428 | } else { |
| 2429 | format!(" Unread output:\n{new_output}") |
| 2430 | }; |
| 2431 | return format!( |
| 2432 | "Task {task_id} (`{command}`) had already exited with code {code}; nothing to \ |
| 2433 | kill.{}{tail}", |
| 2434 | dropped_note(dropped) |
| 2435 | ); |
| 2436 | } |
| 2437 | |
| 2438 | if let Err(err) = task.child.kill() { |
| 2439 | return format!("Error: failed to kill task {task_id}: {err}"); |
| 2440 | } |
| 2441 | match task.child.wait() { |
| 2442 | Ok(status) => task.exit_code = Some(status.code().unwrap_or(-1)), |
| 2443 | Err(_) => task.exit_code = Some(-1), |
| 2444 | } |
| 2445 | task.killed = true; |
| 2446 | |
| 2447 | // Let the reader threads flush whatever was in flight before reporting. |
| 2448 | std::thread::sleep(std::time::Duration::from_millis(100)); |
| 2449 | let (new_output, dropped) = drain_task_output(task); |
| 2450 | |
| 2451 | let command = &task.command; |
| 2452 | let tail = if new_output.is_empty() { |
| 2453 | " It produced no unread output.".to_string() |
| 2454 | } else { |
| 2455 | format!(" Last output:\n{new_output}") |
| 2456 | }; |
| 2457 | format!( |
| 2458 | "Killed task {task_id} (`{command}`).{}{tail}", |
| 2459 | dropped_note(dropped) |
| 2460 | ) |
| 2461 | } |
| 2462 | |
| 2463 | #[cfg(test)] |
| 2464 | mod tests { |
| 2465 | use super::*; |
| 2466 | use std::fs; |
| 2467 | |
| 2468 | #[tokio::test] |
| 2469 | async fn test_execute_unknown_tool() { |
| 2470 | let result = execute_tool("nonexistent", "{}").await; |
| 2471 | assert!(result.starts_with("Unknown tool:")); |
| 2472 | } |
| 2473 | |
| 2474 | #[test] |
| 2475 | fn test_read_file_missing_path_param() { |
| 2476 | let result = exec_read_file("{}"); |
| 2477 | assert!(result.contains("missing required parameter")); |
| 2478 | } |
| 2479 | |
| 2480 | #[test] |
| 2481 | fn test_read_file_nonexistent() { |
| 2482 | let result = exec_read_file(r#"{"path": "/tmp/__sigit_no_such_file_42__"}"#); |
| 2483 | assert!(result.contains("does not exist")); |
| 2484 | } |
| 2485 | |
| 2486 | #[test] |
| 2487 | fn test_read_file_success() { |
| 2488 | let dir = std::env::temp_dir().join("sigit_test_read_file"); |
| 2489 | let _ = fs::create_dir_all(&dir); |
| 2490 | let file_path = dir.join("hello.txt"); |
| 2491 | fs::write(&file_path, "hello world").unwrap(); |
| 2492 | |
| 2493 | let args = serde_json::json!({ "path": file_path }).to_string(); |
| 2494 | let result = exec_read_file(&args); |
| 2495 | assert_eq!(result, "hello world"); |
| 2496 | |
| 2497 | let _ = fs::remove_dir_all(&dir); |
| 2498 | } |
| 2499 | |
| 2500 | #[test] |
| 2501 | fn test_list_directory_missing_path_param() { |
| 2502 | let result = exec_list_directory("{}"); |
| 2503 | assert!(result.contains("missing required parameter")); |
| 2504 | } |
| 2505 | |
| 2506 | #[test] |
| 2507 | fn test_list_directory_success() { |
| 2508 | let dir = std::env::temp_dir().join("sigit_test_list_dir"); |
| 2509 | let _ = fs::remove_dir_all(&dir); |
| 2510 | fs::create_dir_all(dir.join("subdir")).unwrap(); |
| 2511 | fs::write(dir.join("aaa.txt"), "").unwrap(); |
| 2512 | fs::write(dir.join("bbb.rs"), "").unwrap(); |
| 2513 | |
| 2514 | let args = serde_json::json!({ "path": dir }).to_string(); |
| 2515 | let result = exec_list_directory(&args); |
| 2516 | |
| 2517 | assert!(result.contains("[DIR] subdir")); |
| 2518 | assert!(result.contains("[FILE] aaa.txt")); |
| 2519 | assert!(result.contains("[FILE] bbb.rs")); |
| 2520 | |
| 2521 | // Directories should appear before files. |
| 2522 | let dir_pos = result.find("[DIR]").unwrap(); |
| 2523 | let file_pos = result.find("[FILE]").unwrap(); |
| 2524 | assert!(dir_pos < file_pos); |
| 2525 | |
| 2526 | let _ = fs::remove_dir_all(&dir); |
| 2527 | } |
| 2528 | |
| 2529 | #[test] |
| 2530 | fn test_search_files_invalid_regex() { |
| 2531 | let result = exec_search_files(r#"{"pattern": "[invalid", "path": "."}"#); |
| 2532 | assert!(result.contains("invalid regex")); |
| 2533 | } |
| 2534 | |
| 2535 | #[test] |
| 2536 | fn test_search_files_success() { |
| 2537 | let dir = std::env::temp_dir().join("sigit_test_search"); |
| 2538 | let _ = fs::remove_dir_all(&dir); |
| 2539 | fs::create_dir_all(&dir).unwrap(); |
| 2540 | fs::write( |
| 2541 | dir.join("code.rs"), |
| 2542 | "fn main() {\n println!(\"hello\");\n}\n", |
| 2543 | ) |
| 2544 | .unwrap(); |
| 2545 | fs::write(dir.join("other.txt"), "no match here\n").unwrap(); |
| 2546 | |
| 2547 | let args = serde_json::json!({ |
| 2548 | "pattern": "println", |
| 2549 | "path": dir |
| 2550 | }) |
| 2551 | .to_string(); |
| 2552 | let result = exec_search_files(&args); |
| 2553 | |
| 2554 | assert!(result.contains("code.rs:2:")); |
| 2555 | assert!(result.contains("println")); |
| 2556 | assert!(!result.contains("other.txt")); |
| 2557 | |
| 2558 | let _ = fs::remove_dir_all(&dir); |
| 2559 | } |
| 2560 | |
| 2561 | #[test] |
| 2562 | fn test_search_files_no_matches() { |
| 2563 | let dir = std::env::temp_dir().join("sigit_test_search_none"); |
| 2564 | let _ = fs::remove_dir_all(&dir); |
| 2565 | fs::create_dir_all(&dir).unwrap(); |
| 2566 | fs::write(dir.join("empty.txt"), "nothing special").unwrap(); |
| 2567 | |
| 2568 | let args = serde_json::json!({ |
| 2569 | "pattern": "zzz_will_not_match_42", |
| 2570 | "path": dir |
| 2571 | }) |
| 2572 | .to_string(); |
| 2573 | let result = exec_search_files(&args); |
| 2574 | assert!(result.contains("No matches found")); |
| 2575 | |
| 2576 | let _ = fs::remove_dir_all(&dir); |
| 2577 | } |
| 2578 | |
| 2579 | #[test] |
| 2580 | fn test_all_tools_count() { |
| 2581 | let tools = all_tools(); |
| 2582 | assert_eq!(tools.len(), 15); |
| 2583 | assert_eq!(tools[0].name, "read_file"); |
| 2584 | assert_eq!(tools[1].name, "create_directory"); |
| 2585 | assert_eq!(tools[2].name, "list_directory"); |
| 2586 | assert_eq!(tools[3].name, "search_files"); |
| 2587 | assert_eq!(tools[4].name, "read_website"); |
| 2588 | assert_eq!(tools[5].name, "create_file"); |
| 2589 | assert_eq!(tools[6].name, "edit_file"); |
| 2590 | assert_eq!(tools[7].name, "delete_file"); |
| 2591 | assert_eq!(tools[8].name, "run_command"); |
| 2592 | assert_eq!(tools[9].name, "multi_edit"); |
| 2593 | assert_eq!(tools[10].name, "glob"); |
| 2594 | assert_eq!(tools[11].name, "write_todos"); |
| 2595 | assert_eq!(tools[12].name, "remember"); |
| 2596 | assert_eq!(tools[13].name, "command_output"); |
| 2597 | assert_eq!(tools[14].name, "kill_command"); |
| 2598 | } |
| 2599 | |
| 2600 | #[test] |
| 2601 | fn test_edit_file_replace_all() { |
| 2602 | let dir = std::env::temp_dir().join("sigit_test_edit_replace_all"); |
| 2603 | let _ = fs::remove_dir_all(&dir); |
| 2604 | fs::create_dir_all(&dir).unwrap(); |
| 2605 | let file = dir.join("f.txt"); |
| 2606 | fs::write(&file, "foo foo foo").unwrap(); |
| 2607 | |
| 2608 | // Without replace_all an ambiguous match is rejected. |
| 2609 | let args = |
| 2610 | serde_json::json!({ "path": &file, "old_text": "foo", "new_text": "bar" }).to_string(); |
| 2611 | let result = exec_edit_file(&args); |
| 2612 | assert!(result.contains("appears 3 times"), "{result}"); |
| 2613 | |
| 2614 | // With replace_all every occurrence is changed. |
| 2615 | let args = serde_json::json!({ |
| 2616 | "path": &file, "old_text": "foo", "new_text": "bar", "replace_all": true |
| 2617 | }) |
| 2618 | .to_string(); |
| 2619 | let result = exec_edit_file(&args); |
| 2620 | assert!(result.starts_with("Edited file:"), "{result}"); |
| 2621 | assert_eq!(fs::read_to_string(&file).unwrap(), "bar bar bar"); |
| 2622 | |
| 2623 | let _ = fs::remove_dir_all(&dir); |
| 2624 | } |
| 2625 | |
| 2626 | #[test] |
| 2627 | fn test_edit_file_whitespace_hint() { |
| 2628 | let dir = std::env::temp_dir().join("sigit_test_edit_hint"); |
| 2629 | let _ = fs::remove_dir_all(&dir); |
| 2630 | fs::create_dir_all(&dir).unwrap(); |
| 2631 | let file = dir.join("f.txt"); |
| 2632 | fs::write(&file, "line one\n indented\nline three\n").unwrap(); |
| 2633 | |
| 2634 | // old_text has more indentation than the file, so it isn't a substring, |
| 2635 | // but its trimmed content still locates the intended line. |
| 2636 | let args = serde_json::json!({ |
| 2637 | "path": &file, "old_text": " indented", "new_text": "x" |
| 2638 | }) |
| 2639 | .to_string(); |
| 2640 | let result = exec_edit_file(&args); |
| 2641 | assert!(result.contains("line 2"), "{result}"); |
| 2642 | assert!(result.contains("whitespace"), "{result}"); |
| 2643 | |
| 2644 | let _ = fs::remove_dir_all(&dir); |
| 2645 | } |
| 2646 | |
| 2647 | #[test] |
| 2648 | fn test_multi_edit_atomic_on_failure() { |
| 2649 | let dir = std::env::temp_dir().join("sigit_test_multi_edit"); |
| 2650 | let _ = fs::remove_dir_all(&dir); |
| 2651 | fs::create_dir_all(&dir).unwrap(); |
| 2652 | let file = dir.join("f.txt"); |
| 2653 | fs::write(&file, "alpha beta gamma").unwrap(); |
| 2654 | |
| 2655 | // Second edit can't match -> nothing should be written. |
| 2656 | let args = serde_json::json!({ |
| 2657 | "path": &file, |
| 2658 | "edits": [ |
| 2659 | { "old_text": "alpha", "new_text": "ALPHA" }, |
| 2660 | { "old_text": "nope", "new_text": "x" } |
| 2661 | ] |
| 2662 | }) |
| 2663 | .to_string(); |
| 2664 | let result = exec_multi_edit(&args); |
| 2665 | assert!(result.contains("edit #2 failed"), "{result}"); |
| 2666 | assert_eq!(fs::read_to_string(&file).unwrap(), "alpha beta gamma"); |
| 2667 | |
| 2668 | // All-matching batch applies in sequence. |
| 2669 | let args = serde_json::json!({ |
| 2670 | "path": &file, |
| 2671 | "edits": [ |
| 2672 | { "old_text": "alpha", "new_text": "ALPHA" }, |
| 2673 | { "old_text": "gamma", "new_text": "GAMMA" } |
| 2674 | ] |
| 2675 | }) |
| 2676 | .to_string(); |
| 2677 | let result = exec_multi_edit(&args); |
| 2678 | assert!(result.contains("Applied 2 edits"), "{result}"); |
| 2679 | assert_eq!(fs::read_to_string(&file).unwrap(), "ALPHA beta GAMMA"); |
| 2680 | |
| 2681 | let _ = fs::remove_dir_all(&dir); |
| 2682 | } |
| 2683 | |
| 2684 | #[test] |
| 2685 | fn test_glob_to_regex() { |
| 2686 | let re = Regex::new(&glob_to_regex("**/*.rs")).unwrap(); |
| 2687 | assert!(re.is_match("src/tools.rs")); |
| 2688 | assert!(re.is_match("main.rs")); // `**/` matches zero directories too |
| 2689 | assert!(!re.is_match("src/tools.txt")); |
| 2690 | |
| 2691 | let re = Regex::new(&glob_to_regex("*.{ts,tsx}")).unwrap(); |
| 2692 | assert!(re.is_match("app.ts")); |
| 2693 | assert!(re.is_match("app.tsx")); |
| 2694 | assert!(!re.is_match("app.js")); |
| 2695 | } |
| 2696 | |
| 2697 | #[test] |
| 2698 | fn test_glob_tool_success() { |
| 2699 | let dir = std::env::temp_dir().join("sigit_test_glob"); |
| 2700 | let _ = fs::remove_dir_all(&dir); |
| 2701 | fs::create_dir_all(dir.join("src")).unwrap(); |
| 2702 | fs::write(dir.join("Cargo.toml"), "").unwrap(); |
| 2703 | fs::write(dir.join("src/main.rs"), "").unwrap(); |
| 2704 | fs::write(dir.join("src/lib.rs"), "").unwrap(); |
| 2705 | |
| 2706 | let args = serde_json::json!({ "pattern": "**/*.rs", "path": &dir }).to_string(); |
| 2707 | let result = exec_glob(&args); |
| 2708 | assert!(result.contains("main.rs"), "{result}"); |
| 2709 | assert!(result.contains("lib.rs"), "{result}"); |
| 2710 | assert!(!result.contains("Cargo.toml"), "{result}"); |
| 2711 | |
| 2712 | let _ = fs::remove_dir_all(&dir); |
| 2713 | } |
| 2714 | |
| 2715 | #[test] |
| 2716 | fn test_search_files_file_glob_filter() { |
| 2717 | let dir = std::env::temp_dir().join("sigit_test_search_glob"); |
| 2718 | let _ = fs::remove_dir_all(&dir); |
| 2719 | fs::create_dir_all(&dir).unwrap(); |
| 2720 | fs::write(dir.join("code.rs"), "needle here\n").unwrap(); |
| 2721 | fs::write(dir.join("notes.txt"), "needle here\n").unwrap(); |
| 2722 | |
| 2723 | let args = serde_json::json!({ |
| 2724 | "pattern": "needle", "path": &dir, "file_glob": "*.rs" |
| 2725 | }) |
| 2726 | .to_string(); |
| 2727 | let result = exec_search_files(&args); |
| 2728 | assert!(result.contains("code.rs"), "{result}"); |
| 2729 | assert!(!result.contains("notes.txt"), "{result}"); |
| 2730 | |
| 2731 | let _ = fs::remove_dir_all(&dir); |
| 2732 | } |
| 2733 | |
| 2734 | #[test] |
| 2735 | fn test_write_todos_renders_checklist() { |
| 2736 | let args = serde_json::json!({ |
| 2737 | "todos": [ |
| 2738 | { "content": "Read code", "status": "completed" }, |
| 2739 | { "content": "Make change", "status": "in_progress" }, |
| 2740 | { "content": "Run tests", "status": "pending" } |
| 2741 | ] |
| 2742 | }) |
| 2743 | .to_string(); |
| 2744 | let result = exec_write_todos(&args); |
| 2745 | assert!(result.contains("1/3 done"), "{result}"); |
| 2746 | assert!(result.contains("[x] Read code"), "{result}"); |
| 2747 | assert!(result.contains("[~] Make change"), "{result}"); |
| 2748 | assert!(result.contains("[ ] Run tests"), "{result}"); |
| 2749 | } |
| 2750 | |
| 2751 | #[test] |
| 2752 | fn test_remember_appends_to_instruction_file() { |
| 2753 | let dir = std::env::temp_dir().join("sigit_test_remember"); |
| 2754 | let _ = fs::remove_dir_all(&dir); |
| 2755 | fs::create_dir_all(dir.join(".git")).unwrap(); |
| 2756 | let claude_md = dir.join("CLAUDE.md"); |
| 2757 | fs::write(&claude_md, "# Project\n").unwrap(); |
| 2758 | |
| 2759 | let target = crate::instructions::memory_file(&dir); |
| 2760 | // Should pick the existing CLAUDE.md at the repo root. |
| 2761 | assert_eq!( |
| 2762 | target.canonicalize().unwrap(), |
| 2763 | claude_md.canonicalize().unwrap() |
| 2764 | ); |
| 2765 | |
| 2766 | let result = remember_at(&dir, "remembered text"); |
| 2767 | assert!(result.contains("remembered"), "{result}"); |
| 2768 | |
| 2769 | let updated = fs::read_to_string(&claude_md).unwrap(); |
| 2770 | assert!(updated.contains("## Remembered notes"), "{updated}"); |
| 2771 | assert!(updated.contains("- remembered text"), "{updated}"); |
| 2772 | |
| 2773 | let _ = fs::remove_dir_all(&dir); |
| 2774 | } |
| 2775 | |
| 2776 | #[test] |
| 2777 | fn test_all_tools_schemas_are_valid_json_objects() { |
| 2778 | for tool in all_tools() { |
| 2779 | assert!( |
| 2780 | tool.parameters_schema.is_object(), |
| 2781 | "schema for {} is not an object", |
| 2782 | tool.name |
| 2783 | ); |
| 2784 | let obj = tool.parameters_schema.as_object().unwrap(); |
| 2785 | assert!(obj.contains_key("type")); |
| 2786 | assert!(obj.contains_key("properties")); |
| 2787 | assert!(obj.contains_key("required")); |
| 2788 | } |
| 2789 | } |
| 2790 | |
| 2791 | // ── read_website tests ─────────────────────────────────────────────── |
| 2792 | |
| 2793 | #[test] |
| 2794 | fn test_read_website_missing_url() { |
| 2795 | let result = exec_read_website("{}"); |
| 2796 | assert!(result.contains("missing required parameter")); |
| 2797 | } |
| 2798 | |
| 2799 | #[test] |
| 2800 | fn test_read_website_invalid_scheme() { |
| 2801 | let result = exec_read_website(r#"{"url": "file:///tmp/test.html"}"#); |
| 2802 | assert!(result.contains("url must start with http:// or https://")); |
| 2803 | } |
| 2804 | |
| 2805 | #[test] |
| 2806 | fn test_read_website_extracts_title_from_html() { |
| 2807 | let body = r#" |
| 2808 | <html> |
| 2809 | <head> |
| 2810 | <title>Qwen 3.6 27B</title> |
| 2811 | </head> |
| 2812 | <body> |
| 2813 | <h1>Model card</h1> |
| 2814 | <p>Large language model.</p> |
| 2815 | </body> |
| 2816 | </html> |
| 2817 | "#; |
| 2818 | |
| 2819 | let title = Regex::new(r"(?is)<title[^>]*>(.*?)</title>") |
| 2820 | .unwrap() |
| 2821 | .captures(body) |
| 2822 | .and_then(|captures| captures.get(1)) |
| 2823 | .map(|m| { |
| 2824 | Regex::new(r"\s+") |
| 2825 | .unwrap() |
| 2826 | .replace_all(m.as_str(), " ") |
| 2827 | .trim() |
| 2828 | .to_string() |
| 2829 | }) |
| 2830 | .filter(|title| !title.is_empty()); |
| 2831 | |
| 2832 | assert_eq!(title.as_deref(), Some("Qwen 3.6 27B")); |
| 2833 | } |
| 2834 | |
| 2835 | #[test] |
| 2836 | fn test_read_website_metadata_includes_final_url_header() { |
| 2837 | let final_url = "https://huggingface.co/Qwen/Qwen3.6-27B"; |
| 2838 | let title = Some("Qwen 3.6 27B".to_string()); |
| 2839 | let cleaned = "Model card\nLarge language model.".to_string(); |
| 2840 | |
| 2841 | let mut metadata = vec![format!("URL: {final_url}")]; |
| 2842 | if let Some(title) = &title { |
| 2843 | metadata.push(format!("Title: {title}")); |
| 2844 | } |
| 2845 | |
| 2846 | let body_text = match title { |
| 2847 | Some(_) => cleaned, |
| 2848 | None => cleaned, |
| 2849 | }; |
| 2850 | |
| 2851 | let output = format!("{}\n\n{}", metadata.join("\n"), body_text); |
| 2852 | |
| 2853 | assert!(output.starts_with("URL: https://huggingface.co/Qwen/Qwen3.6-27B")); |
| 2854 | assert!(output.contains("\nTitle: Qwen 3.6 27B\n\n")); |
| 2855 | } |
| 2856 | |
| 2857 | // ── create_directory tests ─────────────────────────────────────────── |
| 2858 | |
| 2859 | #[test] |
| 2860 | fn test_create_directory_missing_path() { |
| 2861 | let result = exec_create_directory("{}"); |
| 2862 | assert!(result.contains("missing required parameter")); |
| 2863 | } |
| 2864 | |
| 2865 | #[test] |
| 2866 | fn test_create_directory_success() { |
| 2867 | let dir = std::env::temp_dir() |
| 2868 | .join("sigit_test_create_directory") |
| 2869 | .join("nested") |
| 2870 | .join("child"); |
| 2871 | let _ = fs::remove_dir_all(dir.parent().unwrap()); |
| 2872 | |
| 2873 | let args = serde_json::json!({ "path": dir }).to_string(); |
| 2874 | let result = exec_create_directory(&args); |
| 2875 | assert!(result.starts_with("Created directory:"), "got: {result}"); |
| 2876 | assert!(dir.exists()); |
| 2877 | assert!(dir.is_dir()); |
| 2878 | |
| 2879 | let _ = fs::remove_dir_all(dir.parent().unwrap().parent().unwrap()); |
| 2880 | } |
| 2881 | |
| 2882 | #[test] |
| 2883 | fn test_create_directory_already_exists() { |
| 2884 | let dir = std::env::temp_dir().join("sigit_test_create_directory_exists"); |
| 2885 | let _ = fs::remove_dir_all(&dir); |
| 2886 | fs::create_dir_all(&dir).unwrap(); |
| 2887 | |
| 2888 | let args = serde_json::json!({ "path": dir }).to_string(); |
| 2889 | let result = exec_create_directory(&args); |
| 2890 | assert!(result.contains("Directory already exists"), "got: {result}"); |
| 2891 | |
| 2892 | let _ = fs::remove_dir_all(&dir); |
| 2893 | } |
| 2894 | |
| 2895 | // ── create_file tests ──────────────────────────────────────────────── |
| 2896 | |
| 2897 | #[test] |
| 2898 | fn test_create_file_missing_path() { |
| 2899 | let result = exec_create_file(r#"{"content": "hello"}"#); |
| 2900 | assert!(result.contains("missing required parameter")); |
| 2901 | } |
| 2902 | |
| 2903 | #[test] |
| 2904 | fn test_create_file_missing_content() { |
| 2905 | let result = exec_create_file(r#"{"path": "/tmp/sigit_test_nope.txt"}"#); |
| 2906 | assert!(result.contains("missing required parameter")); |
| 2907 | } |
| 2908 | |
| 2909 | #[test] |
| 2910 | fn test_create_file_success() { |
| 2911 | let dir = std::env::temp_dir().join("sigit_test_create_file"); |
| 2912 | let _ = fs::remove_dir_all(&dir); |
| 2913 | |
| 2914 | let file_path = dir.join("sub").join("new_file.txt"); |
| 2915 | let args = serde_json::json!({ |
| 2916 | "path": file_path, |
| 2917 | "content": "hello world" |
| 2918 | }) |
| 2919 | .to_string(); |
| 2920 | |
| 2921 | let result = exec_create_file(&args); |
| 2922 | assert!(result.starts_with("Created file:"), "got: {result}"); |
| 2923 | assert!(file_path.exists()); |
| 2924 | assert_eq!(fs::read_to_string(&file_path).unwrap(), "hello world"); |
| 2925 | |
| 2926 | let _ = fs::remove_dir_all(&dir); |
| 2927 | } |
| 2928 | |
| 2929 | #[test] |
| 2930 | fn test_create_file_already_exists() { |
| 2931 | let dir = std::env::temp_dir().join("sigit_test_create_exists"); |
| 2932 | let _ = fs::remove_dir_all(&dir); |
| 2933 | fs::create_dir_all(&dir).unwrap(); |
| 2934 | |
| 2935 | let file_path = dir.join("existing.txt"); |
| 2936 | fs::write(&file_path, "original").unwrap(); |
| 2937 | |
| 2938 | let args = serde_json::json!({ |
| 2939 | "path": file_path, |
| 2940 | "content": "overwrite attempt" |
| 2941 | }) |
| 2942 | .to_string(); |
| 2943 | |
| 2944 | let result = exec_create_file(&args); |
| 2945 | assert!(result.contains("already exists"), "got: {result}"); |
| 2946 | // Original content untouched. |
| 2947 | assert_eq!(fs::read_to_string(&file_path).unwrap(), "original"); |
| 2948 | |
| 2949 | let _ = fs::remove_dir_all(&dir); |
| 2950 | } |
| 2951 | |
| 2952 | // ── edit_file tests ────────────────────────────────────────────────── |
| 2953 | |
| 2954 | #[test] |
| 2955 | fn test_edit_file_missing_params() { |
| 2956 | let result = exec_edit_file(r#"{"path": "x"}"#); |
| 2957 | assert!(result.contains("missing required parameter")); |
| 2958 | |
| 2959 | let result = exec_edit_file(r#"{"path": "x", "old_text": "a"}"#); |
| 2960 | assert!(result.contains("missing required parameter")); |
| 2961 | } |
| 2962 | |
| 2963 | #[test] |
| 2964 | fn test_edit_file_nonexistent() { |
| 2965 | let result = exec_edit_file( |
| 2966 | r#"{"path": "/tmp/__sigit_no_such__", "old_text": "a", "new_text": "b"}"#, |
| 2967 | ); |
| 2968 | assert!(result.contains("does not exist")); |
| 2969 | } |
| 2970 | |
| 2971 | #[test] |
| 2972 | fn test_edit_file_success() { |
| 2973 | let dir = std::env::temp_dir().join("sigit_test_edit_file"); |
| 2974 | let _ = fs::remove_dir_all(&dir); |
| 2975 | fs::create_dir_all(&dir).unwrap(); |
| 2976 | |
| 2977 | let file_path = dir.join("code.rs"); |
| 2978 | fs::write(&file_path, "fn main() {\n println!(\"hello\");\n}\n").unwrap(); |
| 2979 | |
| 2980 | let args = serde_json::json!({ |
| 2981 | "path": file_path, |
| 2982 | "old_text": "println!(\"hello\")", |
| 2983 | "new_text": "println!(\"world\")" |
| 2984 | }) |
| 2985 | .to_string(); |
| 2986 | |
| 2987 | let result = exec_edit_file(&args); |
| 2988 | assert!(result.starts_with("Edited file:"), "got: {result}"); |
| 2989 | |
| 2990 | let updated = fs::read_to_string(&file_path).unwrap(); |
| 2991 | assert!(updated.contains("println!(\"world\")")); |
| 2992 | assert!(!updated.contains("println!(\"hello\")")); |
| 2993 | |
| 2994 | let _ = fs::remove_dir_all(&dir); |
| 2995 | } |
| 2996 | |
| 2997 | #[test] |
| 2998 | fn test_edit_file_old_text_not_found() { |
| 2999 | let dir = std::env::temp_dir().join("sigit_test_edit_notfound"); |
| 3000 | let _ = fs::remove_dir_all(&dir); |
| 3001 | fs::create_dir_all(&dir).unwrap(); |
| 3002 | |
| 3003 | let file_path = dir.join("data.txt"); |
| 3004 | fs::write(&file_path, "aaa bbb ccc").unwrap(); |
| 3005 | |
| 3006 | let args = serde_json::json!({ |
| 3007 | "path": file_path, |
| 3008 | "old_text": "zzz", |
| 3009 | "new_text": "yyy" |
| 3010 | }) |
| 3011 | .to_string(); |
| 3012 | |
| 3013 | let result = exec_edit_file(&args); |
| 3014 | assert!(result.contains("old_text not found"), "got: {result}"); |
| 3015 | |
| 3016 | let _ = fs::remove_dir_all(&dir); |
| 3017 | } |
| 3018 | |
| 3019 | #[test] |
| 3020 | fn test_edit_file_ambiguous_match() { |
| 3021 | let dir = std::env::temp_dir().join("sigit_test_edit_ambiguous"); |
| 3022 | let _ = fs::remove_dir_all(&dir); |
| 3023 | fs::create_dir_all(&dir).unwrap(); |
| 3024 | |
| 3025 | let file_path = dir.join("repeat.txt"); |
| 3026 | fs::write(&file_path, "foo bar foo bar foo").unwrap(); |
| 3027 | |
| 3028 | let args = serde_json::json!({ |
| 3029 | "path": file_path, |
| 3030 | "old_text": "foo", |
| 3031 | "new_text": "baz" |
| 3032 | }) |
| 3033 | .to_string(); |
| 3034 | |
| 3035 | let result = exec_edit_file(&args); |
| 3036 | assert!(result.contains("appears 3 times"), "got: {result}"); |
| 3037 | // File should be unchanged. |
| 3038 | assert_eq!( |
| 3039 | fs::read_to_string(&file_path).unwrap(), |
| 3040 | "foo bar foo bar foo" |
| 3041 | ); |
| 3042 | |
| 3043 | let _ = fs::remove_dir_all(&dir); |
| 3044 | } |
| 3045 | |
| 3046 | // ── delete_file tests ──────────────────────────────────────────────── |
| 3047 | |
| 3048 | #[test] |
| 3049 | fn test_delete_file_missing_path() { |
| 3050 | let result = exec_delete_file("{}"); |
| 3051 | assert!( |
| 3052 | result.contains("missing required parameter"), |
| 3053 | "got: {result}" |
| 3054 | ); |
| 3055 | } |
| 3056 | |
| 3057 | #[test] |
| 3058 | fn test_delete_file_nonexistent() { |
| 3059 | let result = exec_delete_file(r#"{"path": "/tmp/sigit_test_no_such_file_xyz"}"#); |
| 3060 | assert!(result.contains("does not exist"), "got: {result}"); |
| 3061 | } |
| 3062 | |
| 3063 | #[test] |
| 3064 | fn test_delete_file_success() { |
| 3065 | let dir = std::env::temp_dir().join("sigit_test_delete_file"); |
| 3066 | let _ = fs::remove_dir_all(&dir); |
| 3067 | fs::create_dir_all(&dir).unwrap(); |
| 3068 | |
| 3069 | let file_path = dir.join("to_delete.txt"); |
| 3070 | fs::write(&file_path, "bye").unwrap(); |
| 3071 | assert!(file_path.exists()); |
| 3072 | |
| 3073 | let args = serde_json::json!({ "path": file_path }).to_string(); |
| 3074 | let result = exec_delete_file(&args); |
| 3075 | assert!(result.contains("Deleted file"), "got: {result}"); |
| 3076 | assert!(!file_path.exists()); |
| 3077 | |
| 3078 | let _ = fs::remove_dir_all(&dir); |
| 3079 | } |
| 3080 | |
| 3081 | #[test] |
| 3082 | fn test_delete_empty_directory() { |
| 3083 | let dir = std::env::temp_dir().join("sigit_test_delete_empty_dir"); |
| 3084 | let _ = fs::remove_dir_all(&dir); |
| 3085 | fs::create_dir_all(&dir).unwrap(); |
| 3086 | |
| 3087 | let args = serde_json::json!({ "path": dir }).to_string(); |
| 3088 | let result = exec_delete_file(&args); |
| 3089 | assert!(result.contains("Deleted empty directory"), "got: {result}"); |
| 3090 | assert!(!dir.exists()); |
| 3091 | } |
| 3092 | |
| 3093 | #[test] |
| 3094 | fn test_delete_nonempty_directory() { |
| 3095 | let dir = std::env::temp_dir().join("sigit_test_delete_nonempty_dir"); |
| 3096 | let _ = fs::remove_dir_all(&dir); |
| 3097 | fs::create_dir_all(&dir).unwrap(); |
| 3098 | fs::write(dir.join("child.txt"), "content").unwrap(); |
| 3099 | |
| 3100 | let args = serde_json::json!({ "path": dir }).to_string(); |
| 3101 | let result = exec_delete_file(&args); |
| 3102 | assert!(result.contains("Error"), "got: {result}"); |
| 3103 | assert!(dir.exists(), "directory should not have been deleted"); |
| 3104 | |
| 3105 | let _ = fs::remove_dir_all(&dir); |
| 3106 | } |
| 3107 | |
| 3108 | // ── run_command tests ──────────────────────────────────────────────── |
| 3109 | |
| 3110 | #[test] |
| 3111 | fn test_run_command_missing_command() { |
| 3112 | let result = exec_run_command("{}"); |
| 3113 | assert!( |
| 3114 | result.contains("missing required parameter"), |
| 3115 | "got: {result}" |
| 3116 | ); |
| 3117 | } |
| 3118 | |
| 3119 | #[test] |
| 3120 | fn test_run_command_success() { |
| 3121 | let result = exec_run_command(r#"{"command": "echo hello"}"#); |
| 3122 | assert!(result.contains("hello"), "got: {result}"); |
| 3123 | assert!(result.contains("Exit code 0"), "got: {result}"); |
| 3124 | } |
| 3125 | |
| 3126 | /// Fresh git repo with one commit, test identity, and signing off (the |
| 3127 | /// developer's global gpgsign must not leak into sandbox commits). |
| 3128 | fn init_test_repo(name: &str) -> std::path::PathBuf { |
| 3129 | let dir = std::env::temp_dir().join(format!("sigit_test_{name}_{}", std::process::id())); |
| 3130 | let _ = fs::remove_dir_all(&dir); |
| 3131 | fs::create_dir_all(&dir).unwrap(); |
| 3132 | test_git(&dir, &["init", "-q", "-b", "main"]); |
| 3133 | test_git(&dir, &["config", "user.name", "Test User"]); |
| 3134 | test_git(&dir, &["config", "user.email", "test@example.com"]); |
| 3135 | test_git(&dir, &["config", "commit.gpgsign", "false"]); |
| 3136 | fs::write(dir.join("file.txt"), "one\n").unwrap(); |
| 3137 | test_git(&dir, &["add", "file.txt"]); |
| 3138 | test_git(&dir, &["commit", "-q", "-m", "Initial"]); |
| 3139 | dir |
| 3140 | } |
| 3141 | |
| 3142 | fn test_git(dir: &Path, args: &[&str]) { |
| 3143 | let out = Command::new("git") |
| 3144 | .args(args) |
| 3145 | .current_dir(dir) |
| 3146 | .output() |
| 3147 | .unwrap(); |
| 3148 | assert!( |
| 3149 | out.status.success(), |
| 3150 | "git {args:?} failed: {}", |
| 3151 | String::from_utf8_lossy(&out.stderr) |
| 3152 | ); |
| 3153 | } |
| 3154 | |
| 3155 | /// The wait loop used to read the pipes only after the child exited, so a |
| 3156 | /// command that outgrew the pipe buffer blocked on the write, we blocked on |
| 3157 | /// the child, and the whole thing sat there until the 120-second timeout. |
| 3158 | #[test] |
| 3159 | fn run_command_survives_more_output_than_a_pipe_buffer_holds() { |
| 3160 | let dir = std::env::temp_dir(); |
| 3161 | // ~400 KB, well past any platform's pipe buffer. It also crosses |
| 3162 | // COMMAND_OUTPUT_LIMIT, so the result is truncated; the point of the |
| 3163 | // test is that the command finishes at all. |
| 3164 | let line = "x".repeat(199); |
| 3165 | // ~400 KB, well past any platform's pipe buffer. It also crosses |
| 3166 | // COMMAND_OUTPUT_LIMIT, so the result is truncated; the point of the |
| 3167 | // test is that the command finishes at all. |
| 3168 | #[cfg(unix)] |
| 3169 | let command = format!("for i in $(seq 1 2000); do echo {line}; done"); |
| 3170 | // cmd has no seq; echo is a builtin so 2000 iterations stay fast. |
| 3171 | #[cfg(windows)] |
| 3172 | let command = format!("for /L %i in (1,1,2000) do @echo {line}"); |
| 3173 | let args = json!({"command": command, "cwd": dir.to_str().unwrap()}).to_string(); |
| 3174 | |
| 3175 | let started = std::time::Instant::now(); |
| 3176 | let result = exec_run_command(&args); |
| 3177 | |
| 3178 | assert!( |
| 3179 | started.elapsed() < std::time::Duration::from_secs(30), |
| 3180 | "run_command took {:?}; it deadlocked on the pipe", |
| 3181 | started.elapsed() |
| 3182 | ); |
| 3183 | assert!( |
| 3184 | result.contains("Exit code 0"), |
| 3185 | "unexpected result: {result}" |
| 3186 | ); |
| 3187 | assert!( |
| 3188 | result.contains("output truncated"), |
| 3189 | "expected the 50000-byte cap to bite: {result}" |
| 3190 | ); |
| 3191 | } |
| 3192 | |
| 3193 | /// A command that wants input gets EOF instead of blocking forever on a |
| 3194 | /// pipe nobody is going to write to (in ACP mode, the editor's own). |
| 3195 | #[test] |
| 3196 | fn run_command_gives_a_command_that_reads_stdin_an_empty_one() { |
| 3197 | let dir = std::env::temp_dir(); |
| 3198 | let args = json!({"command": "cat", "cwd": dir.to_str().unwrap()}).to_string(); |
| 3199 | |
| 3200 | let started = std::time::Instant::now(); |
| 3201 | let result = exec_run_command(&args); |
| 3202 | |
| 3203 | assert!( |
| 3204 | started.elapsed() < std::time::Duration::from_secs(30), |
| 3205 | "run_command blocked on stdin for {:?}", |
| 3206 | started.elapsed() |
| 3207 | ); |
| 3208 | assert!(result.contains("no output"), "unexpected result: {result}"); |
| 3209 | } |
| 3210 | |
| 3211 | #[test] |
| 3212 | fn run_command_appends_co_author_trailer_to_new_commits() { |
| 3213 | let dir = init_test_repo("coauthor_append"); |
| 3214 | fs::write(dir.join("file.txt"), "two\n").unwrap(); |
| 3215 | // Quote-free command: `cmd /C` does not strip double quotes the way |
| 3216 | // `sh -c` does, so quoted arguments would break on Windows. |
| 3217 | let args = serde_json::json!({ |
| 3218 | "command": "git add file.txt && git commit -m Update", |
| 3219 | "cwd": dir.display().to_string(), |
| 3220 | }) |
| 3221 | .to_string(); |
| 3222 | |
| 3223 | let result = exec_run_command(&args); |
| 3224 | assert!(result.contains("co-author trailer"), "got: {result}"); |
| 3225 | |
| 3226 | let message = git_stdout(&dir, &["log", "-1", "--format=%B"]).unwrap(); |
| 3227 | assert!( |
| 3228 | message.ends_with(COMMIT_CO_AUTHOR_TRAILER), |
| 3229 | "trailer must be the last line: {message:?}" |
| 3230 | ); |
| 3231 | assert!( |
| 3232 | message.contains(&format!("\n\n{COMMIT_CO_AUTHOR_TRAILER}")), |
| 3233 | "trailer needs a blank line before it for GitHub to detect it: {message:?}" |
| 3234 | ); |
| 3235 | let _ = fs::remove_dir_all(&dir); |
| 3236 | } |
| 3237 | |
| 3238 | #[test] |
| 3239 | fn run_command_keeps_existing_co_author_trailer() { |
| 3240 | let dir = init_test_repo("coauthor_present"); |
| 3241 | fs::write(dir.join("file.txt"), "two\n").unwrap(); |
| 3242 | // The trailer contains spaces and angle brackets, which `cmd /C` |
| 3243 | // mis-tokenizes (`<` is redirection), so create the trailer-carrying |
| 3244 | // commit with direct git args and let run_command amend it without |
| 3245 | // editing: HEAD changes, the message already has the trailer, and the |
| 3246 | // gate must leave it alone. |
| 3247 | test_git(&dir, &["add", "file.txt"]); |
| 3248 | test_git( |
| 3249 | &dir, |
| 3250 | &[ |
| 3251 | "commit", |
| 3252 | "-q", |
| 3253 | "-m", |
| 3254 | &format!("Update file\n\n{COMMIT_CO_AUTHOR_TRAILER}"), |
| 3255 | ], |
| 3256 | ); |
| 3257 | let args = serde_json::json!({ |
| 3258 | "command": "git commit --amend --no-edit", |
| 3259 | "cwd": dir.display().to_string(), |
| 3260 | }) |
| 3261 | .to_string(); |
| 3262 | |
| 3263 | let result = exec_run_command(&args); |
| 3264 | assert!( |
| 3265 | !result.contains("[siGit Code]"), |
| 3266 | "no amend expected: {result}" |
| 3267 | ); |
| 3268 | |
| 3269 | let message = git_stdout(&dir, &["log", "-1", "--format=%B"]).unwrap(); |
| 3270 | assert_eq!( |
| 3271 | message.matches("Co-Authored-By: siGit Code").count(), |
| 3272 | 1, |
| 3273 | "trailer must not be duplicated: {message:?}" |
| 3274 | ); |
| 3275 | let _ = fs::remove_dir_all(&dir); |
| 3276 | } |
| 3277 | |
| 3278 | #[test] |
| 3279 | fn run_command_never_amends_pushed_commits() { |
| 3280 | let dir = init_test_repo("coauthor_pushed"); |
| 3281 | let remote = std::env::temp_dir().join(format!( |
| 3282 | "sigit_test_coauthor_remote_{}.git", |
| 3283 | std::process::id() |
| 3284 | )); |
| 3285 | let _ = fs::remove_dir_all(&remote); |
| 3286 | fs::create_dir_all(&remote).unwrap(); |
| 3287 | test_git(&remote, &["init", "-q", "--bare"]); |
| 3288 | test_git(&dir, &["remote", "add", "origin", remote.to_str().unwrap()]); |
| 3289 | |
| 3290 | fs::write(dir.join("file.txt"), "two\n").unwrap(); |
| 3291 | let args = serde_json::json!({ |
| 3292 | "command": "git add file.txt && git commit -m Update && git push -q origin main", |
| 3293 | "cwd": dir.display().to_string(), |
| 3294 | }) |
| 3295 | .to_string(); |
| 3296 | |
| 3297 | let result = exec_run_command(&args); |
| 3298 | assert!( |
| 3299 | !result.contains("[siGit Code]"), |
| 3300 | "no amend expected: {result}" |
| 3301 | ); |
| 3302 | |
| 3303 | // Already on the remote when the gate ran, so it must be untouched. |
| 3304 | let message = git_stdout(&dir, &["log", "-1", "--format=%B"]).unwrap(); |
| 3305 | assert!( |
| 3306 | !message.contains("Co-Authored-By"), |
| 3307 | "pushed commit must not be rewritten: {message:?}" |
| 3308 | ); |
| 3309 | let _ = fs::remove_dir_all(&dir); |
| 3310 | let _ = fs::remove_dir_all(&remote); |
| 3311 | } |
| 3312 | |
| 3313 | #[test] |
| 3314 | fn test_run_command_failure() { |
| 3315 | #[cfg(unix)] |
| 3316 | let command = "false"; |
| 3317 | #[cfg(windows)] |
| 3318 | let command = "exit /b 1"; |
| 3319 | |
| 3320 | let args = serde_json::json!({ "command": command }).to_string(); |
| 3321 | let result = exec_run_command(&args); |
| 3322 | assert!(result.contains("failed"), "got: {result}"); |
| 3323 | } |
| 3324 | |
| 3325 | #[test] |
| 3326 | fn test_run_command_with_cwd() { |
| 3327 | let dir = std::env::temp_dir().join("sigit_test_run_cmd_cwd"); |
| 3328 | let _ = fs::remove_dir_all(&dir); |
| 3329 | fs::create_dir_all(&dir).unwrap(); |
| 3330 | |
| 3331 | #[cfg(unix)] |
| 3332 | let command = "pwd"; |
| 3333 | #[cfg(windows)] |
| 3334 | let command = "cd"; |
| 3335 | |
| 3336 | let args = serde_json::json!({ |
| 3337 | "command": command, |
| 3338 | "cwd": dir |
| 3339 | }) |
| 3340 | .to_string(); |
| 3341 | let result = exec_run_command(&args); |
| 3342 | // The output should contain the temp dir path. |
| 3343 | assert!( |
| 3344 | result.contains(&dir.to_string_lossy().to_string()), |
| 3345 | "got: {result}" |
| 3346 | ); |
| 3347 | |
| 3348 | let _ = fs::remove_dir_all(&dir); |
| 3349 | } |
| 3350 | |
| 3351 | #[test] |
| 3352 | fn test_run_command_bad_cwd() { |
| 3353 | let missing_dir = std::env::temp_dir().join("sigit_no_such_dir_xyz"); |
| 3354 | let _ = fs::remove_dir_all(&missing_dir); |
| 3355 | |
| 3356 | let args = serde_json::json!({ |
| 3357 | "command": "echo hi", |
| 3358 | "cwd": missing_dir |
| 3359 | }) |
| 3360 | .to_string(); |
| 3361 | let result = exec_run_command(&args); |
| 3362 | assert!(result.contains("does not exist"), "got: {result}"); |
| 3363 | } |
| 3364 | |
| 3365 | #[test] |
| 3366 | fn test_run_command_captures_stderr() { |
| 3367 | #[cfg(unix)] |
| 3368 | let command = "echo err >&2"; |
| 3369 | #[cfg(windows)] |
| 3370 | let command = "echo err 1>&2"; |
| 3371 | |
| 3372 | let args = serde_json::json!({ "command": command }).to_string(); |
| 3373 | let result = exec_run_command(&args); |
| 3374 | assert!(result.contains("err"), "got: {result}"); |
| 3375 | } |
| 3376 | |
| 3377 | // ── background command tests ───────────────────────────────────────── |
| 3378 | |
| 3379 | /// Extract the task id from a "Started background task {id}: ..." result. |
| 3380 | fn background_task_id(result: &str) -> u64 { |
| 3381 | let digits: String = result |
| 3382 | .strip_prefix("Started background task ") |
| 3383 | .unwrap_or_else(|| panic!("unexpected spawn result: {result}")) |
| 3384 | .chars() |
| 3385 | .take_while(char::is_ascii_digit) |
| 3386 | .collect(); |
| 3387 | digits.parse().expect("task id") |
| 3388 | } |
| 3389 | |
| 3390 | /// Whether the task's child has exited, without draining its output. |
| 3391 | fn background_task_exited(task_id: u64) -> bool { |
| 3392 | let mut map = lock_tasks(); |
| 3393 | match map.get_mut(&task_id) { |
| 3394 | Some(task) => poll_exit_code(task).is_some(), |
| 3395 | None => true, |
| 3396 | } |
| 3397 | } |
| 3398 | |
| 3399 | /// Wait (bounded) for a background task's child to exit. |
| 3400 | fn wait_for_background_exit(task_id: u64) { |
| 3401 | let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30); |
| 3402 | while !background_task_exited(task_id) { |
| 3403 | assert!( |
| 3404 | std::time::Instant::now() < deadline, |
| 3405 | "task {task_id} did not exit in time" |
| 3406 | ); |
| 3407 | std::thread::sleep(std::time::Duration::from_millis(50)); |
| 3408 | } |
| 3409 | } |
| 3410 | |
| 3411 | #[test] |
| 3412 | fn test_run_command_background_lifecycle() { |
| 3413 | #[cfg(unix)] |
| 3414 | let command = "echo start; sleep 2; echo done"; |
| 3415 | #[cfg(windows)] |
| 3416 | let command = "echo start&& ping -n 3 127.0.0.1 > nul&& echo done"; |
| 3417 | |
| 3418 | let args = serde_json::json!({ |
| 3419 | "command": command, |
| 3420 | "cwd": std::env::temp_dir(), |
| 3421 | "run_in_background": true |
| 3422 | }) |
| 3423 | .to_string(); |
| 3424 | let spawned = std::time::Instant::now(); |
| 3425 | let result = exec_run_command(&args); |
| 3426 | // Spawning must return immediately, not wait the ~2s the command takes. |
| 3427 | assert!( |
| 3428 | spawned.elapsed() < std::time::Duration::from_secs(1), |
| 3429 | "background spawn blocked for {:?}", |
| 3430 | spawned.elapsed() |
| 3431 | ); |
| 3432 | assert!(result.contains("command_output"), "got: {result}"); |
| 3433 | let task_id = background_task_id(&result); |
| 3434 | |
| 3435 | // Polling while the command is still sleeping reports it as running. |
| 3436 | let poll_args = serde_json::json!({ "task_id": task_id }).to_string(); |
| 3437 | let poll = exec_command_output(&poll_args); |
| 3438 | assert!(poll.contains("still running"), "got: {poll}"); |
| 3439 | let mut combined = poll; |
| 3440 | |
| 3441 | wait_for_background_exit(task_id); |
| 3442 | // Grace period so the reader threads finish draining the pipes. |
| 3443 | std::thread::sleep(std::time::Duration::from_millis(300)); |
| 3444 | let final_poll = exec_command_output(&poll_args); |
| 3445 | assert!( |
| 3446 | final_poll.contains("exited with code 0"), |
| 3447 | "got: {final_poll}" |
| 3448 | ); |
| 3449 | combined.push_str(&final_poll); |
| 3450 | |
| 3451 | // Across the polls, all output the command printed was delivered. |
| 3452 | assert!(combined.contains("start"), "got: {combined}"); |
| 3453 | assert!(combined.contains("done"), "got: {combined}"); |
| 3454 | } |
| 3455 | |
| 3456 | #[test] |
| 3457 | fn test_kill_command_stops_background_task() { |
| 3458 | #[cfg(unix)] |
| 3459 | let command = "sleep 30"; |
| 3460 | #[cfg(windows)] |
| 3461 | let command = "ping -n 31 127.0.0.1 > nul"; |
| 3462 | |
| 3463 | let args = serde_json::json!({ |
| 3464 | "command": command, |
| 3465 | "cwd": std::env::temp_dir(), |
| 3466 | "run_in_background": true |
| 3467 | }) |
| 3468 | .to_string(); |
| 3469 | let result = exec_run_command(&args); |
| 3470 | let task_id = background_task_id(&result); |
| 3471 | |
| 3472 | let kill_args = serde_json::json!({ "task_id": task_id }).to_string(); |
| 3473 | let killed_at = std::time::Instant::now(); |
| 3474 | let kill_result = exec_kill_command(&kill_args); |
| 3475 | assert!( |
| 3476 | kill_result.contains(&format!("Killed task {task_id}")), |
| 3477 | "got: {kill_result}" |
| 3478 | ); |
| 3479 | // The kill must not wait out the 30s sleep. |
| 3480 | assert!( |
| 3481 | killed_at.elapsed() < std::time::Duration::from_secs(5), |
| 3482 | "kill blocked for {:?}", |
| 3483 | killed_at.elapsed() |
| 3484 | ); |
| 3485 | |
| 3486 | // A later poll reports the task as killed, not still running. |
| 3487 | let poll = exec_command_output(&serde_json::json!({ "task_id": task_id }).to_string()); |
| 3488 | assert!(poll.contains("was killed"), "got: {poll}"); |
| 3489 | } |
| 3490 | |
| 3491 | #[test] |
| 3492 | fn test_command_output_unknown_task() { |
| 3493 | let result = exec_command_output(r#"{"task_id": 9999999}"#); |
| 3494 | assert!( |
| 3495 | result.contains("no background task with id"), |
| 3496 | "got: {result}" |
| 3497 | ); |
| 3498 | } |
| 3499 | |
| 3500 | // ── task (subagent) tests ──────────────────────────────────────────── |
| 3501 | |
| 3502 | #[test] |
| 3503 | fn test_subagent_toolset_is_read_only() { |
| 3504 | let specs = subagent_tool_specs(&effective_subagent_tools(None).unwrap()); |
| 3505 | let mut names: Vec<&str> = specs.iter().map(|spec| spec.name.as_str()).collect(); |
| 3506 | names.sort_unstable(); |
| 3507 | assert_eq!( |
| 3508 | names, |
| 3509 | [ |
| 3510 | "glob", |
| 3511 | "list_directory", |
| 3512 | "read_file", |
| 3513 | "read_website", |
| 3514 | "search_files" |
| 3515 | ] |
| 3516 | ); |
| 3517 | |
| 3518 | // No recursion and no mutating tools, ever. |
| 3519 | assert!(!names.contains(&TASK_TOOL_NAME)); |
| 3520 | for banned in [ |
| 3521 | "create_file", |
| 3522 | "create_directory", |
| 3523 | "edit_file", |
| 3524 | "multi_edit", |
| 3525 | "delete_file", |
| 3526 | "run_command", |
| 3527 | "write_todos", |
| 3528 | "remember", |
| 3529 | ] { |
| 3530 | assert!(!names.contains(&banned), "{banned} leaked into subagent"); |
| 3531 | } |
| 3532 | |
| 3533 | // Every spec came from `all_tools()` (schemas intact). |
| 3534 | for spec in &specs { |
| 3535 | assert!( |
| 3536 | serde_json::from_str::<Value>(&spec.parameters_schema) |
| 3537 | .unwrap() |
| 3538 | .is_object() |
| 3539 | ); |
| 3540 | } |
| 3541 | } |
| 3542 | |
| 3543 | #[tokio::test] |
| 3544 | async fn test_task_reports_unavailable_without_factory() { |
| 3545 | let args = serde_json::json!({ |
| 3546 | "description": "look around", |
| 3547 | "prompt": "What is in the current directory?" |
| 3548 | }) |
| 3549 | .to_string(); |
| 3550 | // `None` is exactly what `exec_task` passes before any surface has |
| 3551 | // registered the process-global factory. |
| 3552 | let result = exec_task_with(&args, None).await; |
| 3553 | assert!( |
| 3554 | result.contains("not available on-device yet"), |
| 3555 | "got: {result}" |
| 3556 | ); |
| 3557 | } |
| 3558 | |
| 3559 | #[test] |
| 3560 | fn test_kill_command_unknown_task() { |
| 3561 | let result = exec_kill_command(r#"{"task_id": 9999999}"#); |
| 3562 | assert!( |
| 3563 | result.contains("no background task with id"), |
| 3564 | "got: {result}" |
| 3565 | ); |
| 3566 | } |
| 3567 | |
| 3568 | #[tokio::test] |
| 3569 | async fn test_task_missing_prompt() { |
| 3570 | let result = exec_task_with(r#"{"description": "x"}"#, None).await; |
| 3571 | assert!( |
| 3572 | result.contains("missing required parameter \"prompt\""), |
| 3573 | "got: {result}" |
| 3574 | ); |
| 3575 | } |
| 3576 | |
| 3577 | // ── web_search tests ───────────────────────────────────────────────── |
| 3578 | |
| 3579 | #[test] |
| 3580 | fn test_web_search_tool_spec_matches_the_cloud_mcp_tools_contract() { |
| 3581 | let spec = web_search_tool_spec(); |
| 3582 | assert_eq!(spec.name, "web_search"); |
| 3583 | let schema: Value = serde_json::from_str(&spec.parameters_schema).unwrap(); |
| 3584 | assert_eq!(schema["required"], serde_json::json!(["query"])); |
| 3585 | assert!(schema["properties"]["query"].is_object()); |
| 3586 | assert!(schema["properties"]["count"].is_object()); |
| 3587 | } |
| 3588 | |
| 3589 | #[test] |
| 3590 | fn test_is_web_search_delegate() { |
| 3591 | assert!(is_web_search_delegate("mcp__sigit__web_search")); |
| 3592 | assert!(!is_web_search_delegate("web_search")); |
| 3593 | assert!(!is_web_search_delegate("mcp__other__web_search")); |
| 3594 | } |
| 3595 | |
| 3596 | #[test] |
| 3597 | fn test_web_search_unavailable_without_mcp_init() { |
| 3598 | // `mcp::init()` is never called anywhere in this test binary (it does |
| 3599 | // real network I/O and is a one-shot `OnceLock`, same constraint as |
| 3600 | // `SUBAGENT_FACTORY`), so the official server's tools — including |
| 3601 | // web_search — are never discovered here. This proves the |
| 3602 | // conditional-advertisement gate fails closed, matching a |
| 3603 | // signed-out/MCP-disabled session, rather than defaulting to "always |
| 3604 | // offered". |
| 3605 | assert!(!web_search_available()); |
| 3606 | } |
| 3607 | |
| 3608 | #[tokio::test] |
| 3609 | async fn test_web_search_delegates_to_mcp_and_degrades_gracefully_when_uninitialized() { |
| 3610 | // With MCP uninitialized (see the test above), `mcp::call_tool` |
| 3611 | // returns its own in-band error string rather than panicking — proves |
| 3612 | // execute_tool's WEB_SEARCH_TOOL_NAME arm actually reaches |
| 3613 | // mcp::call_tool(WEB_SEARCH_MCP_DELEGATE, ...) instead of, say, silently |
| 3614 | // no-op'ing or hitting the `Unknown tool` fallback. |
| 3615 | let args = serde_json::json!({ "query": "siGit Code" }).to_string(); |
| 3616 | let result = execute_tool(WEB_SEARCH_TOOL_NAME, &args).await; |
| 3617 | assert_eq!(result, "Error: MCP is not initialized."); |
| 3618 | } |
| 3619 | |
| 3620 | #[test] |
| 3621 | fn test_command_output_missing_task_id() { |
| 3622 | let result = exec_command_output("{}"); |
| 3623 | assert!( |
| 3624 | result.contains("missing required parameter"), |
| 3625 | "got: {result}" |
| 3626 | ); |
| 3627 | } |
| 3628 | |
| 3629 | #[test] |
| 3630 | fn test_background_output_is_capped() { |
| 3631 | // Print well over COMMAND_OUTPUT_LIMIT (50 000) bytes without polling, |
| 3632 | // so the buffer must drop the oldest output and note the truncation. |
| 3633 | #[cfg(unix)] |
| 3634 | let command = r#"i=0; while [ $i -lt 200 ]; do printf 'line-%04d %s\n' "$i" \ |
| 3635 | "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa \ |
| 3636 | aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa \ |
| 3637 | aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa \ |
| 3638 | aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa \ |
| 3639 | aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; \ |
| 3640 | i=$((i+1)); done"#; |
| 3641 | // Quote-free on purpose: `cmd /C` does not strip double quotes the |
| 3642 | // way `sh -c` does, so a quoted PowerShell one-liner gets mangled. |
| 3643 | // A cmd-native for /L loop needs no quoting at all. |
| 3644 | #[cfg(windows)] |
| 3645 | let command = &format!( |
| 3646 | "for /L %i in (1,1,200) do @echo line-%i-end {}", |
| 3647 | "a".repeat(330) |
| 3648 | ); |
| 3649 | |
| 3650 | let args = serde_json::json!({ |
| 3651 | "command": command, |
| 3652 | "cwd": std::env::temp_dir(), |
| 3653 | "run_in_background": true |
| 3654 | }) |
| 3655 | .to_string(); |
| 3656 | let result = exec_run_command(&args); |
| 3657 | let task_id = background_task_id(&result); |
| 3658 | |
| 3659 | wait_for_background_exit(task_id); |
| 3660 | // Grace period so the reader threads finish draining the pipes. |
| 3661 | std::thread::sleep(std::time::Duration::from_millis(300)); |
| 3662 | |
| 3663 | let poll = exec_command_output(&serde_json::json!({ "task_id": task_id }).to_string()); |
| 3664 | assert!(poll.contains("exited with code 0"), "got: {poll}"); |
| 3665 | assert!( |
| 3666 | poll.contains("earlier output was dropped"), |
| 3667 | "expected truncation note, got: {poll}" |
| 3668 | ); |
| 3669 | // The oldest lines were dropped; the newest survived. The two |
| 3670 | // platforms number lines differently (printf %04d vs cmd's %i). |
| 3671 | #[cfg(unix)] |
| 3672 | let (oldest, newest) = ("line-0000", "line-0199"); |
| 3673 | #[cfg(windows)] |
| 3674 | let (oldest, newest) = ("line-1-end", "line-200-end"); |
| 3675 | assert!(!poll.contains(oldest), "oldest output not dropped"); |
| 3676 | assert!(poll.contains(newest), "newest output missing"); |
| 3677 | // Buffer stayed within the cap, plus the framing: the status line |
| 3678 | // (which quotes the command) and the truncation note. |
| 3679 | assert!( |
| 3680 | poll.len() <= COMMAND_OUTPUT_LIMIT + command.len() + 500, |
| 3681 | "poll result too large: {} bytes", |
| 3682 | poll.len() |
| 3683 | ); |
| 3684 | } |
| 3685 | |
| 3686 | // ── task (subagent) end-to-end against a scripted endpoint ────────── |
| 3687 | |
| 3688 | /// A completion whose assistant message requests one tool call. |
| 3689 | fn completion_tool_call(id: &str, name: &str, arguments: &str) -> String { |
| 3690 | serde_json::json!({ |
| 3691 | "choices": [{"message": { |
| 3692 | "role": "assistant", |
| 3693 | "content": serde_json::Value::Null, |
| 3694 | "tool_calls": [{ |
| 3695 | "id": id, |
| 3696 | "type": "function", |
| 3697 | "function": {"name": name, "arguments": arguments}, |
| 3698 | }], |
| 3699 | }}] |
| 3700 | }) |
| 3701 | .to_string() |
| 3702 | } |
| 3703 | |
| 3704 | /// A completion whose assistant message is a plain text answer. |
| 3705 | fn completion_text(text: &str) -> String { |
| 3706 | serde_json::json!({ |
| 3707 | "choices": [{"message": {"role": "assistant", "content": text}}] |
| 3708 | }) |
| 3709 | .to_string() |
| 3710 | } |
| 3711 | |
| 3712 | /// Minimal scripted OpenAI-compatible endpoint (same pattern as |
| 3713 | /// `tests/acp_permissions.rs`): serves one canned chat-completion JSON body |
| 3714 | /// per request and records each request body. The subagent loop passes |
| 3715 | /// `sink: None`, so the backend takes the non-streaming path and expects |
| 3716 | /// plain JSON rather than SSE. |
| 3717 | fn start_scripted_endpoint(responses: Vec<String>) -> (u16, Arc<std::sync::Mutex<Vec<Value>>>) { |
| 3718 | use std::io::{BufRead, BufReader, Read, Write}; |
| 3719 | |
| 3720 | let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind endpoint"); |
| 3721 | let port = listener.local_addr().unwrap().port(); |
| 3722 | let requests: Arc<std::sync::Mutex<Vec<Value>>> = Arc::default(); |
| 3723 | let recorded = Arc::clone(&requests); |
| 3724 | let queue = std::sync::Mutex::new(std::collections::VecDeque::from(responses)); |
| 3725 | |
| 3726 | std::thread::spawn(move || { |
| 3727 | // `connection: close` means one request per connection, matching |
| 3728 | // the backend's serial completion requests. |
| 3729 | for stream in listener.incoming() { |
| 3730 | let Ok(mut stream) = stream else { continue }; |
| 3731 | let mut reader = BufReader::new(match stream.try_clone() { |
| 3732 | Ok(clone) => clone, |
| 3733 | Err(_) => continue, |
| 3734 | }); |
| 3735 | let mut content_length = 0usize; |
| 3736 | loop { |
| 3737 | let mut line = String::new(); |
| 3738 | if reader.read_line(&mut line).unwrap_or(0) == 0 { |
| 3739 | break; |
| 3740 | } |
| 3741 | let line = line.trim(); |
| 3742 | if line.is_empty() { |
| 3743 | break; |
| 3744 | } |
| 3745 | if let Some(length) = line.to_ascii_lowercase().strip_prefix("content-length:") |
| 3746 | { |
| 3747 | content_length = length.trim().parse().unwrap_or(0); |
| 3748 | } |
| 3749 | } |
| 3750 | let mut body = vec![0u8; content_length]; |
| 3751 | if reader.read_exact(&mut body).is_err() { |
| 3752 | continue; |
| 3753 | } |
| 3754 | if let Ok(request) = serde_json::from_slice::<Value>(&body) { |
| 3755 | recorded.lock().unwrap().push(request); |
| 3756 | } |
| 3757 | let payload = queue |
| 3758 | .lock() |
| 3759 | .unwrap() |
| 3760 | .pop_front() |
| 3761 | .unwrap_or_else(|| completion_text("out of scripted responses")); |
| 3762 | let response = format!( |
| 3763 | "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\n\ |
| 3764 | content-length: {}\r\nconnection: close\r\n\r\n{}", |
| 3765 | payload.len(), |
| 3766 | payload |
| 3767 | ); |
| 3768 | let _ = stream.write_all(response.as_bytes()); |
| 3769 | } |
| 3770 | }); |
| 3771 | |
| 3772 | (port, requests) |
| 3773 | } |
| 3774 | |
| 3775 | #[tokio::test] |
| 3776 | #[allow(clippy::await_holding_lock)] // see the ENV_TEST_LOCK comment inside |
| 3777 | async fn test_task_runs_subagent_end_to_end() { |
| 3778 | // A file only the subagent's read_file call can surface. |
| 3779 | let dir = std::env::temp_dir().join("sigit_test_subagent_e2e"); |
| 3780 | let _ = fs::remove_dir_all(&dir); |
| 3781 | fs::create_dir_all(&dir).unwrap(); |
| 3782 | let file = dir.join("notes.txt"); |
| 3783 | fs::write(&file, "subagent secret: 4217").unwrap(); |
| 3784 | |
| 3785 | // Script: one read_file tool call, a final text answer, then a third |
| 3786 | // response for the custom-agent-type phase further down. |
| 3787 | let (port, requests) = start_scripted_endpoint(vec![ |
| 3788 | completion_tool_call( |
| 3789 | "call_1", |
| 3790 | "read_file", |
| 3791 | &serde_json::json!({ "path": file }).to_string(), |
| 3792 | ), |
| 3793 | completion_text("The file contains the number 4217."), |
| 3794 | completion_text("Custom type answer."), |
| 3795 | ]); |
| 3796 | |
| 3797 | // Register the real process-global factory, pointing a fresh |
| 3798 | // OpenAiBackend at the scripted endpoint — exactly what the surfaces |
| 3799 | // do at startup. This is the only test that touches the OnceLock |
| 3800 | // (it's a `OnceLock`: later `set_subagent_factory` calls from other |
| 3801 | // tests are silently ignored, so every test needing a real factory |
| 3802 | // has to share this one registration — see the custom-agent-type |
| 3803 | // phase later in this same test). |
| 3804 | set_subagent_factory(Box::new(move |system_prompt: &str| { |
| 3805 | Some(Arc::new(crate::backend::OpenAiBackend::new( |
| 3806 | format!("http://127.0.0.1:{port}"), |
| 3807 | "test-key", |
| 3808 | "scripted-model", |
| 3809 | Some(system_prompt.to_string()), |
| 3810 | )) as Arc<dyn InferenceBackend>) |
| 3811 | })); |
| 3812 | assert!(subagent_available()); |
| 3813 | |
| 3814 | let args = serde_json::json!({ |
| 3815 | "description": "read the notes file", |
| 3816 | "prompt": format!("What number is recorded in {}?", file.display()), |
| 3817 | }) |
| 3818 | .to_string(); |
| 3819 | let result = execute_tool(TASK_TOOL_NAME, &args).await; |
| 3820 | |
| 3821 | // Only the subagent's final text comes back. |
| 3822 | assert_eq!(result, "The file contains the number 4217."); |
| 3823 | |
| 3824 | // Scoped so the `MutexGuard` is dropped before phase 2's `.await` — |
| 3825 | // holding a `std::sync::MutexGuard` across an await point is a bug |
| 3826 | // (it can deadlock or block the executor), and clippy's |
| 3827 | // `await_holding_lock` lint doesn't reliably see through a manual |
| 3828 | // `drop()` call, so use an explicit block instead. |
| 3829 | { |
| 3830 | let recorded = requests.lock().unwrap(); |
| 3831 | assert_eq!(recorded.len(), 2, "expected exactly two completions"); |
| 3832 | |
| 3833 | // The subagent conversation is fresh (subagent system prompt) and |
| 3834 | // was offered only the read-only toolset. |
| 3835 | let first = &recorded[0]; |
| 3836 | assert_eq!(first["messages"][0]["role"], "system"); |
| 3837 | assert!( |
| 3838 | first["messages"][0]["content"] |
| 3839 | .as_str() |
| 3840 | .unwrap() |
| 3841 | .contains("research subagent") |
| 3842 | ); |
| 3843 | let offered: Vec<&str> = first["tools"] |
| 3844 | .as_array() |
| 3845 | .unwrap() |
| 3846 | .iter() |
| 3847 | .map(|tool| tool["function"]["name"].as_str().unwrap()) |
| 3848 | .collect(); |
| 3849 | for name in &offered { |
| 3850 | assert!( |
| 3851 | SUBAGENT_TOOL_NAMES.contains(name), |
| 3852 | "non-read-only tool offered: {name}" |
| 3853 | ); |
| 3854 | } |
| 3855 | assert!(!offered.contains(&TASK_TOOL_NAME)); |
| 3856 | |
| 3857 | // The read-only tool actually executed: its output travelled back |
| 3858 | // to the endpoint as a tool result on the second request. |
| 3859 | let messages = recorded[1]["messages"].as_array().unwrap(); |
| 3860 | let tool_message = messages |
| 3861 | .iter() |
| 3862 | .find(|message| message["role"] == "tool") |
| 3863 | .expect("no tool result in second request"); |
| 3864 | assert_eq!(tool_message["tool_call_id"], "call_1"); |
| 3865 | assert!( |
| 3866 | tool_message["content"] |
| 3867 | .as_str() |
| 3868 | .unwrap() |
| 3869 | .contains("subagent secret: 4217") |
| 3870 | ); |
| 3871 | } |
| 3872 | |
| 3873 | // ── Phase 2: a configured subagent type, on the same registered |
| 3874 | // factory and endpoint (the third scripted response above). Proves |
| 3875 | // `subagent_type` actually swaps the system prompt and narrows the |
| 3876 | // offered toolset end to end, not just at the pure-function level |
| 3877 | // (see `effective_subagent_tools_*` below for that). |
| 3878 | let agents_root = dir.join(".sigit").join("agents"); |
| 3879 | fs::create_dir_all(&agents_root).unwrap(); |
| 3880 | fs::write( |
| 3881 | agents_root.join("notes-reader.md"), |
| 3882 | "---\nname: notes-reader\ndescription: Reads notes files only\ntools: read_file\n---\n\nYou only ever read notes files and report exactly what they say.\n", |
| 3883 | ) |
| 3884 | .unwrap(); |
| 3885 | |
| 3886 | // Serialize the cwd change against the other discovery tests |
| 3887 | // (skills/commands/subagents) — the cwd is process-global and cargo |
| 3888 | // runs tests in parallel. The guard is held across the `.await` |
| 3889 | // below on purpose: the cwd must stay pinned while `execute_tool` |
| 3890 | // runs agent-type discovery. That's safe here — each `#[tokio::test]` |
| 3891 | // gets its own single-threaded runtime on its own test thread, so no |
| 3892 | // other task on this executor can contend for the lock — hence the |
| 3893 | // `await_holding_lock` allow on this test. |
| 3894 | let _env_guard = crate::ENV_TEST_LOCK |
| 3895 | .lock() |
| 3896 | .unwrap_or_else(|poisoned| poisoned.into_inner()); |
| 3897 | let prev_cwd = std::env::current_dir().unwrap(); |
| 3898 | std::env::set_current_dir(&dir).unwrap(); |
| 3899 | |
| 3900 | let args = serde_json::json!({ |
| 3901 | "description": "read a note", |
| 3902 | "prompt": "What does the notes file say?", |
| 3903 | "subagent_type": "notes-reader", |
| 3904 | }) |
| 3905 | .to_string(); |
| 3906 | let result = execute_tool(TASK_TOOL_NAME, &args).await; |
| 3907 | assert_eq!(result, "Custom type answer."); |
| 3908 | |
| 3909 | let recorded = requests.lock().unwrap(); |
| 3910 | assert_eq!(recorded.len(), 3, "expected a third recorded request"); |
| 3911 | let last = &recorded[2]; |
| 3912 | assert_eq!(last["messages"][0]["role"], "system"); |
| 3913 | assert_eq!( |
| 3914 | last["messages"][0]["content"].as_str().unwrap(), |
| 3915 | "You only ever read notes files and report exactly what they say." |
| 3916 | ); |
| 3917 | let offered: Vec<&str> = last["tools"] |
| 3918 | .as_array() |
| 3919 | .unwrap() |
| 3920 | .iter() |
| 3921 | .map(|tool| tool["function"]["name"].as_str().unwrap()) |
| 3922 | .collect(); |
| 3923 | assert_eq!( |
| 3924 | offered, |
| 3925 | vec!["read_file"], |
| 3926 | "notes-reader's tools: read_file should narrow the offered toolset to just that" |
| 3927 | ); |
| 3928 | drop(recorded); |
| 3929 | |
| 3930 | std::env::set_current_dir(prev_cwd).unwrap(); |
| 3931 | let _ = fs::remove_dir_all(&dir); |
| 3932 | } |
| 3933 | |
| 3934 | #[test] |
| 3935 | fn effective_subagent_tools_defaults_to_full_read_only_set_without_a_type() { |
| 3936 | let mut tools = effective_subagent_tools(None).unwrap(); |
| 3937 | tools.sort(); |
| 3938 | let mut expected: Vec<String> = SUBAGENT_TOOL_NAMES.iter().map(|s| s.to_string()).collect(); |
| 3939 | expected.sort(); |
| 3940 | assert_eq!(tools, expected); |
| 3941 | } |
| 3942 | |
| 3943 | #[test] |
| 3944 | fn effective_subagent_tools_inherits_full_set_when_type_omits_tools_field() { |
| 3945 | let agent_type = subagents::AgentType { |
| 3946 | name: "generalist".to_string(), |
| 3947 | description: "does anything".to_string(), |
| 3948 | tools: None, |
| 3949 | system_prompt: "Be helpful.".to_string(), |
| 3950 | path: PathBuf::from("/x/generalist.md"), |
| 3951 | }; |
| 3952 | let mut tools = effective_subagent_tools(Some(&agent_type)).unwrap(); |
| 3953 | tools.sort(); |
| 3954 | let mut expected: Vec<String> = SUBAGENT_TOOL_NAMES.iter().map(|s| s.to_string()).collect(); |
| 3955 | expected.sort(); |
| 3956 | assert_eq!(tools, expected); |
| 3957 | } |
| 3958 | |
| 3959 | #[test] |
| 3960 | fn effective_subagent_tools_narrows_to_the_types_allow_list() { |
| 3961 | let agent_type = subagents::AgentType { |
| 3962 | name: "notes-reader".to_string(), |
| 3963 | description: "reads notes".to_string(), |
| 3964 | tools: Some(vec!["read_file".to_string(), "glob".to_string()]), |
| 3965 | system_prompt: "Read notes.".to_string(), |
| 3966 | path: PathBuf::from("/x/notes-reader.md"), |
| 3967 | }; |
| 3968 | let tools = effective_subagent_tools(Some(&agent_type)).unwrap(); |
| 3969 | assert_eq!(tools, vec!["read_file".to_string(), "glob".to_string()]); |
| 3970 | } |
| 3971 | |
| 3972 | #[test] |
| 3973 | fn effective_subagent_tools_drops_anything_outside_the_read_only_ceiling() { |
| 3974 | // The security-critical case: a type file cannot grant itself a |
| 3975 | // mutating tool by listing it in `tools:` — it's silently dropped, |
| 3976 | // never passed through to the offered/gated toolset. |
| 3977 | let agent_type = subagents::AgentType { |
| 3978 | name: "sneaky".to_string(), |
| 3979 | description: "tries to grant itself write access".to_string(), |
| 3980 | tools: Some(vec![ |
| 3981 | "read_file".to_string(), |
| 3982 | "edit_file".to_string(), |
| 3983 | "run_command".to_string(), |
| 3984 | "task".to_string(), |
| 3985 | ]), |
| 3986 | system_prompt: "...".to_string(), |
| 3987 | path: PathBuf::from("/x/sneaky.md"), |
| 3988 | }; |
| 3989 | let tools = effective_subagent_tools(Some(&agent_type)).unwrap(); |
| 3990 | assert_eq!(tools, vec!["read_file".to_string()]); |
| 3991 | assert!(!tools.contains(&"edit_file".to_string())); |
| 3992 | assert!(!tools.contains(&"run_command".to_string())); |
| 3993 | assert!(!tools.contains(&"task".to_string())); |
| 3994 | } |
| 3995 | |
| 3996 | #[test] |
| 3997 | fn effective_subagent_tools_errors_when_allow_list_intersects_to_nothing() { |
| 3998 | // A type whose `tools:` names only out-of-ceiling tools (e.g. Claude |
| 3999 | // Code names carried over) must error, not silently run toolless. |
| 4000 | let agent_type = subagents::AgentType { |
| 4001 | name: "misconfigured".to_string(), |
| 4002 | description: "names only invalid tools".to_string(), |
| 4003 | tools: Some(vec!["grep".to_string(), "bash".to_string()]), |
| 4004 | system_prompt: "...".to_string(), |
| 4005 | path: PathBuf::from("/x/misconfigured.md"), |
| 4006 | }; |
| 4007 | let err = effective_subagent_tools(Some(&agent_type)).unwrap_err(); |
| 4008 | assert!( |
| 4009 | err.contains("misconfigured"), |
| 4010 | "message names the type: {err}" |
| 4011 | ); |
| 4012 | assert!(err.contains("read-only set"), "message explains why: {err}"); |
| 4013 | } |
| 4014 | } |