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