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("&nbsp;", " ")
1411 .replace("&amp;", "&")
1412 .replace("&lt;", "<")
1413 .replace("&gt;", ">")
1414 .replace("&quot;", "\"")
1415 .replace("&#39;", "'");
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(&note);
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