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