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 const COMMAND_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(120);
2299 const COMMAND_OUTPUT_LIMIT: usize = 50_000;
2300 /// How long to keep waiting for the output drains once the command itself has
2301 /// exited. Only a process that outlived the shell and still holds a pipe can
2302 /// use this up, so it is short.
2303 const OUTPUT_GRACE: std::time::Duration = std::time::Duration::from_secs(2);
2304
2305 /// Spawn `command_str` through the platform shell with piped stdout/stderr.
2306 /// Shared by the foreground and background paths of `run_command`.
2307 ///
2308 /// stdin is `/dev/null`, never inherited. In ACP mode sigit's stdin is the
2309 /// JSON-RPC pipe from the editor, and a child holding that fd can read the
2310 /// client's next request out of it: the agent never sees the request, the
2311 /// editor waits forever for a reply, and the session is wedged past any
2312 /// timeout this function's callers apply. Null also means a command that wants
2313 /// input gets EOF and fails instead of blocking on input nobody can type.
2314 ///
2315 /// On Unix the child also leads its own process group, so a timeout can kill
2316 /// the whole tree. `sh -c` may fork rather than exec (dash does), and a
2317 /// surviving grandchild keeps the pipe write ends open.
2318 fn spawn_shell(command_str: &str, cwd_path: &Path) -> std::io::Result<std::process::Child> {
2319 #[cfg(unix)]
2320 let (shell, flag) = ("sh", "-c");
2321 #[cfg(windows)]
2322 let (shell, flag) = ("cmd", "/C");
2323
2324 let mut command = Command::new(shell);
2325 command
2326 .arg(flag)
2327 .arg(command_str)
2328 .current_dir(cwd_path)
2329 .stdin(std::process::Stdio::null())
2330 .stdout(std::process::Stdio::piped())
2331 .stderr(std::process::Stdio::piped());
2332 #[cfg(unix)]
2333 std::os::unix::process::CommandExt::process_group(&mut command, 0);
2334 command.spawn()
2335 }
2336
2337 /// SIGKILL a shell spawned by [`spawn_shell`] and everything it started, then
2338 /// reap it so the timeout path doesn't leave a zombie behind.
2339 fn kill_shell_tree(child: &mut std::process::Child) {
2340 #[cfg(unix)]
2341 {
2342 // Negative pid is the process group; the child is its leader. Fall
2343 // back to the child alone if the group is already gone.
2344 if unsafe { libc::kill(-(child.id() as i32), libc::SIGKILL) } != 0 {
2345 let _ = child.kill();
2346 }
2347 }
2348 #[cfg(windows)]
2349 let _ = child.kill();
2350 let _ = child.wait();
2351 }
2352
2353 /// Trailer identifying siGit Code as the co-author of commits it creates.
2354 /// GitHub detects `Co-authored-by:` trailers on the last lines of a commit
2355 /// message (separated from the body by a blank line) and lists the agent
2356 /// alongside the human author; `noreply@sigit.si`
2357 /// is the noreply address for the <https://github.com/sigitc> account
2358 /// ("siGit Code"), so the co-author is rendered with that account's avatar
2359 /// and profile link. GitHub matches on the address, so the name is free to
2360 /// carry the harness version: the model behind a commit changes from one
2361 /// session to the next, but the version says which siGit Code made it. The
2362 /// system prompt asks the model to add this itself; [`ensure_commit_co_author`]
2363 /// is the safety net when it forgets.
2364 ///
2365 /// The version carries a suffix naming the surface the process serves
2366 /// (`v1.5.11-acp`, `v1.5.11-tui`), set once at startup by [`set_surface`], so
2367 /// a commit also says whether it came from the editor or the terminal.
2368 pub fn commit_co_author_trailer() -> String {
2369 co_author_trailer_for(SURFACE.get().copied())
2370 }
2371
2372 fn co_author_trailer_for(surface: Option<Surface>) -> String {
2373 let suffix = surface.map_or(String::new(), |surface| format!("-{}", surface.tag()));
2374 format!(
2375 "Co-Authored-By: siGit Code v{}{suffix} <noreply@sigit.si>",
2376 env!("CARGO_PKG_VERSION")
2377 )
2378 }
2379
2380 /// Which way this process is exposed. One binary serves all of them, chosen in
2381 /// `main` before any prompt is built.
2382 #[derive(Clone, Copy, Debug, PartialEq, Eq)]
2383 pub enum Surface {
2384 /// Agent Client Protocol over stdio (editors).
2385 Acp,
2386 /// The interactive terminal UI.
2387 // Unix-only, like the TUI itself.
2388 #[cfg_attr(not(unix), allow(dead_code))]
2389 Tui,
2390 /// `sigit run` / `sigit -p`.
2391 Headless,
2392 }
2393
2394 impl Surface {
2395 fn tag(self) -> &'static str {
2396 match self {
2397 Surface::Acp => "acp",
2398 Surface::Tui => "tui",
2399 Surface::Headless => "headless",
2400 }
2401 }
2402 }
2403
2404 static SURFACE: OnceLock<Surface> = OnceLock::new();
2405
2406 /// Record the surface this process serves. The first call wins; `main` makes
2407 /// it once, before anything builds a system prompt or runs a tool.
2408 pub fn set_surface(surface: Surface) {
2409 let _ = SURFACE.set(surface);
2410 }
2411
2412 /// Run `git <args>` in `cwd`, returning trimmed stdout on success.
2413 ///
2414 /// stdin is null for the same reason as [`spawn_shell`]: git can ask for input
2415 /// (a credential, a signing passphrase), and inheriting the ACP pipe would let
2416 /// it read the editor's next request instead.
2417 fn git_stdout(cwd: &Path, args: &[&str]) -> Option<String> {
2418 let output = Command::new("git")
2419 .args(args)
2420 .current_dir(cwd)
2421 .stdin(std::process::Stdio::null())
2422 .output()
2423 .ok()?;
2424 output
2425 .status
2426 .success()
2427 .then(|| String::from_utf8_lossy(&output.stdout).trim().to_string())
2428 }
2429
2430 fn git_head(cwd: &Path) -> Option<String> {
2431 git_stdout(cwd, &["rev-parse", "HEAD"])
2432 }
2433
2434 /// Drop the comment block git leaves at the end of a message it wrote itself.
2435 ///
2436 /// `git commit --no-edit` after a conflicted merge takes `MERGE_MSG` as is,
2437 /// and with no editor involved git only cleans up whitespace, so the
2438 /// `# Conflicts:` list stays in the commit. Any trailer after it is no longer
2439 /// in the last paragraph, and GitHub stops seeing it. Only trailing
2440 /// paragraphs made entirely of `#` lines go; a `#` line inside the message
2441 /// (a Markdown heading in a `-m` body, say) is left alone. Paragraphs are
2442 /// split on any blank line, so a run of them or CRLF endings work too.
2443 fn strip_trailing_comment_block(message: &str) -> &str {
2444 let mut rest = message.trim_end();
2445 loop {
2446 // Byte offset just past the last blank line, i.e. where the final
2447 // paragraph starts. `rest` never ends in one, so that paragraph
2448 // is non-empty.
2449 let mut split = None;
2450 let mut offset = 0;
2451 for line in rest.split_inclusive('\n') {
2452 offset += line.len();
2453 if line.trim().is_empty() {
2454 split = Some(offset);
2455 }
2456 }
2457 let Some(split) = split else { return rest };
2458 if rest[split..].lines().all(|line| line.starts_with('#')) {
2459 rest = rest[..split].trim_end();
2460 } else {
2461 return rest;
2462 }
2463 }
2464 }
2465
2466 /// Deterministic co-author attribution: if the commit at HEAD lacks the
2467 /// siGit Code trailer, amend it in (via `git commit --amend --trailer`, which
2468 /// places it after a blank line — the format GitHub detects). A leftover
2469 /// comment block from git (see [`strip_trailing_comment_block`]) is removed in
2470 /// the same amend, since it would otherwise sit below the trailer and hide it.
2471 /// Never rewrites a commit that is already on a remote. Returns a note
2472 /// describing the amend so the model and user can see it happened.
2473 fn ensure_commit_co_author(cwd: &Path) -> Option<String> {
2474 let message = git_stdout(cwd, &["log", "-1", "--format=%B"])?;
2475 let cleaned = strip_trailing_comment_block(&message);
2476 let has_trailer = cleaned
2477 .to_lowercase()
2478 .contains("co-authored-by: sigit code");
2479 if has_trailer && cleaned == message {
2480 return None;
2481 }
2482 // Amending changes the commit id; a commit that any remote ref already
2483 // contains must be left alone or the branch diverges from its upstream.
2484 match git_stdout(cwd, &["branch", "-r", "--contains", "HEAD"]) {
2485 Some(remotes) if remotes.is_empty() => {}
2486 _ => return None,
2487 }
2488 // The message is passed explicitly rather than with --no-edit so the
2489 // comment block is gone before git decides where the trailer goes.
2490 // `whitespace` is what git already applied to a message it didn't edit.
2491 let trailer = commit_co_author_trailer();
2492 let mut amend = Command::new("git");
2493 amend
2494 .args(["commit", "--amend", "--cleanup=whitespace", "-m"])
2495 .arg(cleaned);
2496 if !has_trailer {
2497 amend.arg("--trailer").arg(&trailer);
2498 }
2499 let amend = amend
2500 .stdin(std::process::Stdio::null())
2501 .current_dir(cwd)
2502 .output()
2503 .ok()?;
2504 if amend.status.success() {
2505 log::info!(
2506 "appended co-author trailer to the new commit in {}",
2507 cwd.display()
2508 );
2509 Some(if has_trailer {
2510 "[siGit Code] The new commit was amended to drop git's leftover comment lines, \
2511 which sat below the co-author trailer and hid it (its hash changed)."
2512 .to_string()
2513 } else {
2514 format!(
2515 "[siGit Code] The new commit was amended to append the co-author trailer \
2516 \"{trailer}\" (its hash changed)."
2517 )
2518 })
2519 } else {
2520 log::warn!(
2521 "could not append co-author trailer in {}: {}",
2522 cwd.display(),
2523 String::from_utf8_lossy(&amend.stderr).trim()
2524 );
2525 None
2526 }
2527 }
2528
2529 /// A parsed `run_command` call whose working directory exists.
2530 struct RunCommandCall {
2531 command: String,
2532 cwd: PathBuf,
2533 background: bool,
2534 }
2535
2536 impl RunCommandCall {
2537 fn parse(arguments: &str) -> Result<Self, String> {
2538 let args: Value = serde_json::from_str(arguments)
2539 .map_err(|err| format!("Error: failed to parse arguments: {err}"))?;
2540
2541 let command = args
2542 .get("command")
2543 .and_then(Value::as_str)
2544 .ok_or_else(|| "Error: missing required parameter \"command\"".to_string())?;
2545
2546 let default_cwd = std::env::var("HOME").unwrap_or_else(|_| ".".to_string());
2547 let cwd = args
2548 .get("cwd")
2549 .and_then(Value::as_str)
2550 .unwrap_or(&default_cwd);
2551 let cwd = absolute_path(Path::new(cwd));
2552
2553 if !cwd.exists() {
2554 return Err(format!(
2555 "Error: working directory does not exist: {}",
2556 cwd.display()
2557 ));
2558 }
2559
2560 Ok(Self {
2561 command: command.to_string(),
2562 cwd,
2563 background: args
2564 .get("run_in_background")
2565 .and_then(Value::as_bool)
2566 .unwrap_or(false),
2567 })
2568 }
2569 }
2570
2571 /// Co-author attribution for a foreground command: where HEAD was before a
2572 /// command that looks like it may commit, so a new commit can be detected
2573 /// afterwards. The string check is only a cheap trigger — a false positive
2574 /// costs one `git rev-parse` and nothing else. Background tasks skip it:
2575 /// their commits finish after the tool returns, when there is nothing to
2576 /// amend from.
2577 struct CommitWatch {
2578 cwd: PathBuf,
2579 head_before: Option<String>,
2580 }
2581
2582 impl CommitWatch {
2583 fn start(command_str: &str, cwd: &Path) -> Option<Self> {
2584 (command_str.contains("git") && command_str.contains("commit")).then(|| Self {
2585 cwd: cwd.to_path_buf(),
2586 head_before: git_head(cwd),
2587 })
2588 }
2589
2590 /// A new commit appeared under the command: make sure it carries the
2591 /// siGit co-author trailer (see `ensure_commit_co_author`), and say so at
2592 /// the end of `output` when it had to be amended in.
2593 fn finish(self, output: &mut String) {
2594 let head_after = git_head(&self.cwd);
2595 if head_after.is_some()
2596 && head_after != self.head_before
2597 && let Some(note) = ensure_commit_co_author(&self.cwd)
2598 {
2599 if !output.is_empty() && !output.ends_with('\n') {
2600 output.push('\n');
2601 }
2602 output.push_str(&note);
2603 }
2604 }
2605 }
2606
2607 /// The tool result for a finished foreground command, from its combined
2608 /// output. `exit_code` is `None` when the command did not exit on its own:
2609 /// killed by `signal` when the client says which, otherwise reported as
2610 /// exit code -1.
2611 fn command_result(exit_code: Option<i64>, signal: Option<&str>, combined: String) -> String {
2612 let truncated = if combined.len() > COMMAND_OUTPUT_LIMIT {
2613 let truncated_str = &combined[..COMMAND_OUTPUT_LIMIT];
2614 format!("{truncated_str}\n\n… (output truncated at {COMMAND_OUTPUT_LIMIT} bytes)")
2615 } else {
2616 combined
2617 };
2618
2619 let ended = match (exit_code, signal) {
2620 (Some(code), _) => format!("exit code {code}"),
2621 (None, Some(signal)) => format!("killed by signal {signal}"),
2622 (None, None) => "exit code -1".to_string(),
2623 };
2624 if exit_code == Some(0) {
2625 if truncated.is_empty() {
2626 format!("Command succeeded ({ended}) with no output.")
2627 } else {
2628 format!("Exit code 0:\n{truncated}")
2629 }
2630 } else {
2631 format!("Command failed ({ended}):\n{truncated}")
2632 }
2633 }
2634
2635 /// runs via `sh -c` / `cmd /C`; killed after COMMAND_TIMEOUT unless
2636 /// `run_in_background` is set, in which case the child is registered as a
2637 /// background task and polled with `command_output` / stopped with
2638 /// `kill_command`.
2639 fn exec_run_command(arguments: &str, owner: Option<&str>) -> String {
2640 let call = match RunCommandCall::parse(arguments) {
2641 Ok(call) => call,
2642 Err(error) => return error,
2643 };
2644 let command_str = call.command.as_str();
2645 let cwd_path = call.cwd;
2646 let cwd_str = cwd_path.display().to_string();
2647 let run_in_background = call.background;
2648
2649 log::info!("run_command: `{command_str}` in `{cwd_str}` (background: {run_in_background})");
2650
2651 if run_in_background {
2652 return start_background_task(command_str, &cwd_path, owner.map(str::to_string));
2653 }
2654
2655 let commit_watch = CommitWatch::start(command_str, &cwd_path);
2656
2657 let mut child = match spawn_shell(command_str, &cwd_path) {
2658 Ok(c) => c,
2659 Err(err) => return format!("Error: failed to spawn command: {err}"),
2660 };
2661
2662 // Drain both pipes on their own threads. Waiting first and reading after
2663 // deadlocks the moment a command writes more than the pipe buffer holds
2664 // (64 KiB on Linux, less on macOS): the child blocks on the full pipe, we
2665 // block on the child, and the only way out is the timeout. A build or a
2666 // verbose test run crosses that on its own.
2667 let (chunks_tx, chunks_rx) = std::sync::mpsc::channel::<(u8, Vec<u8>)>();
2668 let drain = |tag: u8, pipe: Option<Box<dyn std::io::Read + Send>>| {
2669 let tx = chunks_tx.clone();
2670 std::thread::spawn(move || {
2671 let mut buf = Vec::new();
2672 if let Some(mut pipe) = pipe {
2673 let _ = pipe.read_to_end(&mut buf);
2674 }
2675 let _ = tx.send((tag, buf));
2676 });
2677 };
2678 drain(0, child.stdout.take().map(|pipe| Box::new(pipe) as _));
2679 drain(1, child.stderr.take().map(|pipe| Box::new(pipe) as _));
2680 drop(chunks_tx);
2681
2682 let start = std::time::Instant::now();
2683 let status = loop {
2684 match child.try_wait() {
2685 Ok(Some(status)) => break status,
2686 Ok(None) => {
2687 if start.elapsed() >= COMMAND_TIMEOUT {
2688 kill_shell_tree(&mut child);
2689 return format!(
2690 "Error: command timed out after {} seconds and was killed.",
2691 COMMAND_TIMEOUT.as_secs()
2692 );
2693 }
2694 std::thread::sleep(std::time::Duration::from_millis(100));
2695 }
2696 Err(err) => return format!("Error: failed to wait on command: {err}"),
2697 }
2698 };
2699
2700 // Collect what the drains read, bounded. The shell has exited, but a
2701 // grandchild that outlived it can still hold a pipe open, and joining
2702 // those threads would wait on it forever.
2703 let mut stdout_bytes = Vec::new();
2704 let mut stderr_bytes = Vec::new();
2705 let deadline = std::time::Instant::now() + OUTPUT_GRACE;
2706 for _ in 0..2 {
2707 let remaining = deadline.saturating_duration_since(std::time::Instant::now());
2708 match chunks_rx.recv_timeout(remaining) {
2709 Ok((0, bytes)) => stdout_bytes = bytes,
2710 Ok((_, bytes)) => stderr_bytes = bytes,
2711 Err(_) => break,
2712 }
2713 }
2714
2715 let mut combined = String::new();
2716 combined.push_str(&String::from_utf8_lossy(&stdout_bytes));
2717 combined.push_str(&String::from_utf8_lossy(&stderr_bytes));
2718
2719 if let Some(watch) = commit_watch {
2720 watch.finish(&mut combined);
2721 }
2722
2723 command_result(status.code().map(i64::from), None, combined)
2724 }
2725
2726 /// The client terminal route for a `run_command` call, when it should take
2727 /// one: a foreground command whose directory is in the session's roots, with
2728 /// a client terminal registered (see [`crate::client_terminal::route_for`]).
2729 fn client_terminal_route(
2730 arguments: &str,
2731 ) -> Option<(RunCommandCall, crate::client_terminal::Route)> {
2732 let call = RunCommandCall::parse(arguments).ok()?;
2733 if call.background {
2734 return None;
2735 }
2736 let route = crate::client_terminal::route_for(active_session().as_deref(), &call.cwd)?;
2737 Some((call, route))
2738 }
2739
2740 /// A foreground `run_command` in the client's terminal, embedded in the tool
2741 /// call `tool_call_id`. The result text has the local path's shape, so the
2742 /// model cannot tell the two apart; a client that cannot start the terminal
2743 /// gets the command run locally instead.
2744 async fn exec_run_command_via_client(
2745 arguments: &str,
2746 call: &RunCommandCall,
2747 route: &crate::client_terminal::Route,
2748 tool_call_id: Option<&str>,
2749 ) -> ToolOutcome {
2750 use crate::client_terminal::Run;
2751
2752 log::info!(
2753 "run_command: `{}` in `{}` (client terminal)",
2754 call.command,
2755 call.cwd.display()
2756 );
2757 let commit_watch = CommitWatch::start(&call.command, &call.cwd);
2758
2759 let run = route
2760 .run(
2761 &call.command,
2762 &call.cwd,
2763 tool_call_id,
2764 COMMAND_TIMEOUT,
2765 COMMAND_OUTPUT_LIMIT as u64,
2766 )
2767 .await;
2768
2769 let (output, terminal_id) = match run {
2770 Run::NotStarted(error) => {
2771 log::warn!("client terminal could not start the command: {error}; running it locally");
2772 let arguments = arguments.to_owned();
2773 let owner = active_session();
2774 return tokio::task::spawn_blocking(move || {
2775 exec_run_command(&arguments, owner.as_deref())
2776 })
2777 .await
2778 .unwrap_or_else(|err| format!("Error: run_command task failed: {err}"))
2779 .into();
2780 }
2781 Run::Exited {
2782 terminal_id,
2783 status,
2784 output,
2785 } => {
2786 let mut combined = client_output_text(output);
2787 if let Some(watch) = commit_watch {
2788 watch.finish(&mut combined);
2789 }
2790 let result = command_result(
2791 status.exit_code.map(i64::from),
2792 status.signal.as_deref(),
2793 combined,
2794 );
2795 (result, terminal_id)
2796 }
2797 Run::TimedOut {
2798 terminal_id,
2799 output,
2800 } => {
2801 let mut result = format!(
2802 "Error: command timed out after {} seconds and was killed.",
2803 COMMAND_TIMEOUT.as_secs()
2804 );
2805 let combined = client_output_text(output);
2806 if !combined.is_empty() {
2807 result.push_str("\nOutput before it was killed:\n");
2808 result.push_str(&combined);
2809 }
2810 (result, terminal_id)
2811 }
2812 Run::Failed { terminal_id, error } => (format!("Error: {error}"), terminal_id),
2813 };
2814
2815 ToolOutcome {
2816 output,
2817 change: None,
2818 terminal: Some(terminal_id),
2819 }
2820 }
2821
2822 /// The text of a client terminal's output, saying so when the client dropped
2823 /// its start to stay under the byte limit.
2824 fn client_output_text(output: crate::client_terminal::Output) -> String {
2825 if output.truncated {
2826 format!(
2827 "… (earlier output dropped to stay under {COMMAND_OUTPUT_LIMIT} bytes)\n{}",
2828 output.output
2829 )
2830 } else {
2831 output.output
2832 }
2833 }
2834
2835 // ── background tasks (command_output / kill_command) ─────────────────────────
2836
2837 /// A command started with `run_in_background`. The child is never detached,
2838 /// so background tasks die with the sigit process.
2839 struct BackgroundTask {
2840 command: String,
2841 child: std::process::Child,
2842 /// Combined stdout+stderr drained by the reader threads, shared with them.
2843 output: Arc<Mutex<TaskOutput>>,
2844 /// Exit code cached once observed; `-1` when there is no code (killed by
2845 /// a signal). `None` while the task is still running.
2846 exit_code: Option<i32>,
2847 /// Set when the task was stopped via `kill_command`.
2848 killed: bool,
2849 /// The session that started the task (see [`set_active_session`]). Only
2850 /// that session can poll or kill it.
2851 owner: Option<String>,
2852 }
2853
2854 /// Output accumulated for a background task since the last poll.
2855 #[derive(Default)]
2856 struct TaskOutput {
2857 buf: String,
2858 /// Whether the oldest output was dropped because `buf` hit the cap.
2859 dropped: bool,
2860 }
2861
2862 /// The ACP session whose turn is running, or `None` outside ACP. One process
2863 /// serves every thread an editor has open, and task ids are small sequential
2864 /// numbers, so without an owner check a thread can poll or kill a task another
2865 /// thread started just by guessing its id.
2866 static ACTIVE_SESSION: RwLock<Option<String>> = RwLock::new(None);
2867
2868 /// Record which session the next tool calls run for.
2869 pub fn set_active_session(session: Option<&str>) {
2870 let mut guard = ACTIVE_SESSION
2871 .write()
2872 .unwrap_or_else(|poisoned| poisoned.into_inner());
2873 *guard = session.map(str::to_string);
2874 }
2875
2876 fn active_session() -> Option<String> {
2877 ACTIVE_SESSION
2878 .read()
2879 .unwrap_or_else(|poisoned| poisoned.into_inner())
2880 .clone()
2881 }
2882
2883 /// Process-global background task table (same pattern as `mcp::MCP`).
2884 fn tasks() -> &'static Mutex<HashMap<u64, BackgroundTask>> {
2885 static TASKS: OnceLock<Mutex<HashMap<u64, BackgroundTask>>> = OnceLock::new();
2886 TASKS.get_or_init(|| Mutex::new(HashMap::new()))
2887 }
2888
2889 fn lock_tasks() -> std::sync::MutexGuard<'static, HashMap<u64, BackgroundTask>> {
2890 tasks()
2891 .lock()
2892 .unwrap_or_else(|poisoned| poisoned.into_inner())
2893 }
2894
2895 fn next_task_id() -> u64 {
2896 static NEXT_ID: AtomicU64 = AtomicU64::new(1);
2897 NEXT_ID.fetch_add(1, Ordering::Relaxed)
2898 }
2899
2900 /// Drain a child's stream into the task's shared buffer on a plain std thread,
2901 /// capping the buffer at COMMAND_OUTPUT_LIMIT by dropping the oldest output.
2902 fn spawn_output_reader<R: std::io::Read + Send + 'static>(
2903 mut stream: R,
2904 output: Arc<Mutex<TaskOutput>>,
2905 ) {
2906 std::thread::spawn(move || {
2907 let mut chunk = [0u8; 8192];
2908 loop {
2909 match stream.read(&mut chunk) {
2910 Ok(0) | Err(_) => break,
2911 Ok(n) => {
2912 let text = String::from_utf8_lossy(&chunk[..n]).into_owned();
2913 let mut out = output
2914 .lock()
2915 .unwrap_or_else(|poisoned| poisoned.into_inner());
2916 out.buf.push_str(&text);
2917 if out.buf.len() > COMMAND_OUTPUT_LIMIT {
2918 let mut cut = out.buf.len() - COMMAND_OUTPUT_LIMIT;
2919 while !out.buf.is_char_boundary(cut) {
2920 cut += 1;
2921 }
2922 out.buf.drain(..cut);
2923 out.dropped = true;
2924 }
2925 }
2926 }
2927 }
2928 });
2929 }
2930
2931 /// Background branch of `run_command`: spawn, register, return immediately.
2932 fn start_background_task(command_str: &str, cwd_path: &Path, owner: Option<String>) -> String {
2933 let mut child = match spawn_shell(command_str, cwd_path) {
2934 Ok(c) => c,
2935 Err(err) => return format!("Error: failed to spawn command: {err}"),
2936 };
2937
2938 let output = Arc::new(Mutex::new(TaskOutput::default()));
2939 if let Some(stdout) = child.stdout.take() {
2940 spawn_output_reader(stdout, Arc::clone(&output));
2941 }
2942 if let Some(stderr) = child.stderr.take() {
2943 spawn_output_reader(stderr, Arc::clone(&output));
2944 }
2945
2946 let task_id = next_task_id();
2947 lock_tasks().insert(
2948 task_id,
2949 BackgroundTask {
2950 command: command_str.to_string(),
2951 child,
2952 output,
2953 exit_code: None,
2954 killed: false,
2955 owner,
2956 },
2957 );
2958
2959 format!(
2960 "Started background task {task_id}: `{command_str}`. Poll its output and status \
2961 with command_output (task_id: {task_id}); stop it with kill_command. The task is \
2962 killed when sigit exits."
2963 )
2964 }
2965
2966 /// Check (and cache) whether a task's child has exited. Returns the exit code,
2967 /// or `None` while it is still running.
2968 fn poll_exit_code(task: &mut BackgroundTask) -> Option<i32> {
2969 if task.exit_code.is_none()
2970 && let Ok(Some(status)) = task.child.try_wait()
2971 {
2972 task.exit_code = Some(status.code().unwrap_or(-1));
2973 }
2974 task.exit_code
2975 }
2976
2977 /// Take everything the task has printed since the last poll, plus whether
2978 /// older output was dropped at the buffer cap.
2979 fn drain_task_output(task: &BackgroundTask) -> (String, bool) {
2980 let mut out = task
2981 .output
2982 .lock()
2983 .unwrap_or_else(|poisoned| poisoned.into_inner());
2984 (
2985 std::mem::take(&mut out.buf),
2986 std::mem::take(&mut out.dropped),
2987 )
2988 }
2989
2990 fn parse_task_id(arguments: &str) -> Result<u64, String> {
2991 let args: Value = serde_json::from_str(arguments)
2992 .map_err(|err| format!("Error: failed to parse arguments: {err}"))?;
2993 args.get("task_id")
2994 .and_then(Value::as_u64)
2995 .ok_or_else(|| "Error: missing required parameter \"task_id\"".to_string())
2996 }
2997
2998 fn unknown_task(task_id: u64) -> String {
2999 format!(
3000 "Error: no background task with id {task_id}. Start one with run_command and \
3001 run_in_background set to true."
3002 )
3003 }
3004
3005 fn dropped_note(dropped: bool) -> &'static str {
3006 if dropped {
3007 "\n(note: earlier output was dropped after exceeding the 50000-byte buffer)"
3008 } else {
3009 ""
3010 }
3011 }
3012
3013 /// `command_output` tool: output since the last poll + running/exited status.
3014 /// Only the tests use this one-shot form; production goes through
3015 /// `exec_command_output_wait`, which long-polls instead of spinning.
3016 #[cfg(test)]
3017 fn exec_command_output(arguments: &str, owner: Option<&str>) -> String {
3018 poll_command_output(arguments, owner, false).text
3019 }
3020
3021 /// One `command_output` check, with the tool text and whether the task is
3022 /// still running, so the long-poll loop knows when to keep waiting.
3023 struct CommandPoll {
3024 text: String,
3025 /// True while the task's child process is still running.
3026 running: bool,
3027 }
3028
3029 impl CommandPoll {
3030 /// A result that ends the wait: an error, or a task that isn't ours.
3031 fn done(text: String) -> Self {
3032 Self {
3033 text,
3034 running: false,
3035 }
3036 }
3037 }
3038
3039 /// `hold_while_running` is the long-poll's "not my last look" flag: a task
3040 /// that is still running keeps its output buffered and the returned text is
3041 /// empty, so the call that finally reports is the one that drains it.
3042 fn poll_command_output(
3043 arguments: &str,
3044 owner: Option<&str>,
3045 hold_while_running: bool,
3046 ) -> CommandPoll {
3047 let task_id = match parse_task_id(arguments) {
3048 Ok(id) => id,
3049 Err(err) => return CommandPoll::done(err),
3050 };
3051
3052 let mut map = lock_tasks();
3053 let Some(task) = map
3054 .get_mut(&task_id)
3055 .filter(|task| task.owner.as_deref() == owner)
3056 else {
3057 return CommandPoll::done(unknown_task(task_id));
3058 };
3059
3060 let was_running = task.exit_code.is_none();
3061 let exit_code = poll_exit_code(task);
3062 if hold_while_running && exit_code.is_none() {
3063 return CommandPoll {
3064 text: String::new(),
3065 running: true,
3066 };
3067 }
3068 if was_running && exit_code.is_some() {
3069 // The child just exited; give the reader threads a moment to flush
3070 // the final output through the pipes before draining the buffer.
3071 std::thread::sleep(std::time::Duration::from_millis(100));
3072 }
3073
3074 let (new_output, dropped) = drain_task_output(task);
3075
3076 let command = &task.command;
3077 let status = match exit_code {
3078 None => format!("Task {task_id} (`{command}`) is still running."),
3079 Some(_) if task.killed => format!("Task {task_id} (`{command}`) was killed."),
3080 Some(code) => format!("Task {task_id} (`{command}`) exited with code {code}."),
3081 };
3082
3083 let running = exit_code.is_none();
3084 let text = if new_output.is_empty() {
3085 format!(
3086 "{status} No new output since the last check.{}",
3087 dropped_note(dropped)
3088 )
3089 } else {
3090 format!(
3091 "{status} New output since the last check:{}\n{new_output}",
3092 dropped_note(dropped)
3093 )
3094 };
3095 CommandPoll { text, running }
3096 }
3097
3098 /// Long-poll a background task without occupying Tokio's blocking pool while
3099 /// nothing changes. This keeps model tool rounds from being consumed by a
3100 /// tight status-check loop.
3101 async fn exec_command_output_wait(arguments: &str, owner: Option<String>) -> String {
3102 const DEFAULT_WAIT_SECONDS: u64 = 10;
3103 const MAX_WAIT_SECONDS: u64 = 30;
3104 const POLL_INTERVAL: std::time::Duration = std::time::Duration::from_millis(200);
3105
3106 let wait_seconds = serde_json::from_str::<Value>(arguments)
3107 .ok()
3108 .and_then(|args| args.get("wait_seconds").and_then(Value::as_u64))
3109 .unwrap_or(DEFAULT_WAIT_SECONDS)
3110 .min(MAX_WAIT_SECONDS);
3111 let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(wait_seconds);
3112
3113 loop {
3114 let arguments = arguments.to_owned();
3115 let owner = owner.clone();
3116 // Every poll drains the task's buffer, so only the last look may take
3117 // the output of a running task; an earlier one would throw it away.
3118 let last_look = wait_seconds == 0 || tokio::time::Instant::now() >= deadline;
3119 let poll = tokio::task::spawn_blocking(move || {
3120 poll_command_output(&arguments, owner.as_deref(), !last_look)
3121 })
3122 .await
3123 .unwrap_or_else(|err| {
3124 CommandPoll::done(format!("Error: command_output task failed: {err}"))
3125 });
3126
3127 if last_look || !poll.running {
3128 return poll.text;
3129 }
3130
3131 tokio::time::sleep(POLL_INTERVAL).await;
3132 }
3133 }
3134
3135 /// Stop and forget every background task `owner` started. Called when its
3136 /// session closes: nobody is left who could poll or kill them. Returns how
3137 /// many were still running.
3138 pub fn kill_session_tasks(owner: &str) -> usize {
3139 let mut map = lock_tasks();
3140 let ids: Vec<u64> = map
3141 .iter()
3142 .filter(|(_, task)| task.owner.as_deref() == Some(owner))
3143 .map(|(id, _)| *id)
3144 .collect();
3145
3146 let mut running = 0;
3147 for id in ids {
3148 let Some(mut task) = map.remove(&id) else {
3149 continue;
3150 };
3151 if poll_exit_code(&mut task).is_none() {
3152 running += 1;
3153 kill_shell_tree(&mut task.child);
3154 }
3155 }
3156 running
3157 }
3158
3159 /// `kill_command` tool: stop a background task and report its output tail.
3160 fn exec_kill_command(arguments: &str, owner: Option<&str>) -> String {
3161 let task_id = match parse_task_id(arguments) {
3162 Ok(id) => id,
3163 Err(err) => return err,
3164 };
3165
3166 let mut map = lock_tasks();
3167 let Some(task) = map
3168 .get_mut(&task_id)
3169 .filter(|task| task.owner.as_deref() == owner)
3170 else {
3171 return unknown_task(task_id);
3172 };
3173
3174 if let Some(code) = poll_exit_code(task) {
3175 let (new_output, dropped) = drain_task_output(task);
3176 let command = &task.command;
3177 let tail = if new_output.is_empty() {
3178 String::new()
3179 } else {
3180 format!(" Unread output:\n{new_output}")
3181 };
3182 return format!(
3183 "Task {task_id} (`{command}`) had already exited with code {code}; nothing to \
3184 kill.{}{tail}",
3185 dropped_note(dropped)
3186 );
3187 }
3188
3189 if let Err(err) = task.child.kill() {
3190 return format!("Error: failed to kill task {task_id}: {err}");
3191 }
3192 match task.child.wait() {
3193 Ok(status) => task.exit_code = Some(status.code().unwrap_or(-1)),
3194 Err(_) => task.exit_code = Some(-1),
3195 }
3196 task.killed = true;
3197
3198 // Let the reader threads flush whatever was in flight before reporting.
3199 std::thread::sleep(std::time::Duration::from_millis(100));
3200 let (new_output, dropped) = drain_task_output(task);
3201
3202 let command = &task.command;
3203 let tail = if new_output.is_empty() {
3204 " It produced no unread output.".to_string()
3205 } else {
3206 format!(" Last output:\n{new_output}")
3207 };
3208 format!(
3209 "Killed task {task_id} (`{command}`).{}{tail}",
3210 dropped_note(dropped)
3211 )
3212 }
3213
3214 #[cfg(test)]
3215 mod tests {
3216 use super::*;
3217 use std::fs;
3218
3219 #[tokio::test]
3220 async fn test_execute_unknown_tool() {
3221 let result = execute_tool("nonexistent", "{}").await;
3222 assert!(result.starts_with("Unknown tool:"));
3223 }
3224
3225 #[test]
3226 fn test_read_file_missing_path_param() {
3227 let result = exec_read_file("{}");
3228 assert!(result.contains("missing required parameter"));
3229 }
3230
3231 #[test]
3232 fn test_read_file_nonexistent() {
3233 let result = exec_read_file(r#"{"path": "/tmp/__sigit_no_such_file_42__"}"#);
3234 assert!(result.contains("does not exist"));
3235 }
3236
3237 #[test]
3238 fn test_read_file_success() {
3239 let dir = std::env::temp_dir().join("sigit_test_read_file");
3240 let _ = fs::create_dir_all(&dir);
3241 let file_path = dir.join("hello.txt");
3242 fs::write(&file_path, "hello world").unwrap();
3243
3244 let args = serde_json::json!({ "path": file_path }).to_string();
3245 let result = exec_read_file(&args);
3246 assert_eq!(result, "hello world");
3247
3248 let _ = fs::remove_dir_all(&dir);
3249 }
3250
3251 #[test]
3252 fn test_list_directory_missing_path_param() {
3253 let result = exec_list_directory("{}");
3254 assert!(result.contains("missing required parameter"));
3255 }
3256
3257 #[test]
3258 fn test_list_directory_success() {
3259 let dir = std::env::temp_dir().join("sigit_test_list_dir");
3260 let _ = fs::remove_dir_all(&dir);
3261 fs::create_dir_all(dir.join("subdir")).unwrap();
3262 fs::write(dir.join("aaa.txt"), "").unwrap();
3263 fs::write(dir.join("bbb.rs"), "").unwrap();
3264
3265 let args = serde_json::json!({ "path": dir }).to_string();
3266 let result = exec_list_directory(&args);
3267
3268 assert!(result.contains("[DIR] subdir"));
3269 assert!(result.contains("[FILE] aaa.txt"));
3270 assert!(result.contains("[FILE] bbb.rs"));
3271
3272 // Directories should appear before files.
3273 let dir_pos = result.find("[DIR]").unwrap();
3274 let file_pos = result.find("[FILE]").unwrap();
3275 assert!(dir_pos < file_pos);
3276
3277 let _ = fs::remove_dir_all(&dir);
3278 }
3279
3280 #[test]
3281 fn test_search_files_invalid_regex() {
3282 let result = exec_search_files(r#"{"pattern": "[invalid", "path": "."}"#);
3283 assert!(result.contains("invalid regex"));
3284 }
3285
3286 #[test]
3287 fn test_search_files_success() {
3288 let dir = std::env::temp_dir().join("sigit_test_search");
3289 let _ = fs::remove_dir_all(&dir);
3290 fs::create_dir_all(&dir).unwrap();
3291 fs::write(
3292 dir.join("code.rs"),
3293 "fn main() {\n println!(\"hello\");\n}\n",
3294 )
3295 .unwrap();
3296 fs::write(dir.join("other.txt"), "no match here\n").unwrap();
3297
3298 let args = serde_json::json!({
3299 "pattern": "println",
3300 "path": dir
3301 })
3302 .to_string();
3303 let result = exec_search_files(&args);
3304
3305 assert!(result.contains("code.rs:2:"));
3306 assert!(result.contains("println"));
3307 assert!(!result.contains("other.txt"));
3308
3309 let _ = fs::remove_dir_all(&dir);
3310 }
3311
3312 #[test]
3313 fn test_search_files_no_matches() {
3314 let dir = std::env::temp_dir().join("sigit_test_search_none");
3315 let _ = fs::remove_dir_all(&dir);
3316 fs::create_dir_all(&dir).unwrap();
3317 fs::write(dir.join("empty.txt"), "nothing special").unwrap();
3318
3319 let args = serde_json::json!({
3320 "pattern": "zzz_will_not_match_42",
3321 "path": dir
3322 })
3323 .to_string();
3324 let result = exec_search_files(&args);
3325 assert!(result.contains("No matches found"));
3326
3327 let _ = fs::remove_dir_all(&dir);
3328 }
3329
3330 #[test]
3331 fn test_all_tools_count() {
3332 let tools = all_tools();
3333 assert_eq!(tools.len(), 15);
3334 assert_eq!(tools[0].name, "read_file");
3335 assert_eq!(tools[1].name, "create_directory");
3336 assert_eq!(tools[2].name, "list_directory");
3337 assert_eq!(tools[3].name, "search_files");
3338 assert_eq!(tools[4].name, "read_website");
3339 assert_eq!(tools[5].name, "create_file");
3340 assert_eq!(tools[6].name, "edit_file");
3341 assert_eq!(tools[7].name, "delete_file");
3342 assert_eq!(tools[8].name, "run_command");
3343 assert_eq!(tools[9].name, "multi_edit");
3344 assert_eq!(tools[10].name, "glob");
3345 assert_eq!(tools[11].name, "write_todos");
3346 assert_eq!(tools[12].name, "remember");
3347 assert_eq!(tools[13].name, "command_output");
3348 assert_eq!(tools[14].name, "kill_command");
3349 }
3350
3351 #[test]
3352 fn test_edit_file_replace_all() {
3353 let dir = std::env::temp_dir().join("sigit_test_edit_replace_all");
3354 let _ = fs::remove_dir_all(&dir);
3355 fs::create_dir_all(&dir).unwrap();
3356 let file = dir.join("f.txt");
3357 fs::write(&file, "foo foo foo").unwrap();
3358
3359 // Without replace_all an ambiguous match is rejected.
3360 let args =
3361 serde_json::json!({ "path": &file, "old_text": "foo", "new_text": "bar" }).to_string();
3362 let result = exec_edit_file(&args);
3363 assert!(result.contains("appears 3 times"), "{result}");
3364
3365 // With replace_all every occurrence is changed.
3366 let args = serde_json::json!({
3367 "path": &file, "old_text": "foo", "new_text": "bar", "replace_all": true
3368 })
3369 .to_string();
3370 let result = exec_edit_file(&args);
3371 assert!(result.starts_with("Edited file:"), "{result}");
3372 assert_eq!(fs::read_to_string(&file).unwrap(), "bar bar bar");
3373
3374 let _ = fs::remove_dir_all(&dir);
3375 }
3376
3377 #[test]
3378 fn test_edit_file_whitespace_hint() {
3379 let dir = std::env::temp_dir().join("sigit_test_edit_hint");
3380 let _ = fs::remove_dir_all(&dir);
3381 fs::create_dir_all(&dir).unwrap();
3382 let file = dir.join("f.txt");
3383 fs::write(&file, "line one\n indented\nline three\n").unwrap();
3384
3385 // old_text has more indentation than the file, so it isn't a substring,
3386 // but its trimmed content still locates the intended line.
3387 let args = serde_json::json!({
3388 "path": &file, "old_text": " indented", "new_text": "x"
3389 })
3390 .to_string();
3391 let result = exec_edit_file(&args);
3392 assert!(result.contains("line 2"), "{result}");
3393 assert!(result.contains("whitespace"), "{result}");
3394
3395 let _ = fs::remove_dir_all(&dir);
3396 }
3397
3398 #[test]
3399 fn test_multi_edit_atomic_on_failure() {
3400 let dir = std::env::temp_dir().join("sigit_test_multi_edit");
3401 let _ = fs::remove_dir_all(&dir);
3402 fs::create_dir_all(&dir).unwrap();
3403 let file = dir.join("f.txt");
3404 fs::write(&file, "alpha beta gamma").unwrap();
3405
3406 // Second edit can't match -> nothing should be written.
3407 let args = serde_json::json!({
3408 "path": &file,
3409 "edits": [
3410 { "old_text": "alpha", "new_text": "ALPHA" },
3411 { "old_text": "nope", "new_text": "x" }
3412 ]
3413 })
3414 .to_string();
3415 let result = exec_multi_edit(&args);
3416 assert!(result.contains("edit #2 failed"), "{result}");
3417 assert_eq!(fs::read_to_string(&file).unwrap(), "alpha beta gamma");
3418
3419 // All-matching batch applies in sequence.
3420 let args = serde_json::json!({
3421 "path": &file,
3422 "edits": [
3423 { "old_text": "alpha", "new_text": "ALPHA" },
3424 { "old_text": "gamma", "new_text": "GAMMA" }
3425 ]
3426 })
3427 .to_string();
3428 let result = exec_multi_edit(&args);
3429 assert!(result.contains("Applied 2 edits"), "{result}");
3430 assert_eq!(fs::read_to_string(&file).unwrap(), "ALPHA beta GAMMA");
3431
3432 let _ = fs::remove_dir_all(&dir);
3433 }
3434
3435 #[test]
3436 fn test_glob_to_regex() {
3437 let re = Regex::new(&glob_to_regex("**/*.rs")).unwrap();
3438 assert!(re.is_match("src/tools.rs"));
3439 assert!(re.is_match("main.rs")); // `**/` matches zero directories too
3440 assert!(!re.is_match("src/tools.txt"));
3441
3442 let re = Regex::new(&glob_to_regex("*.{ts,tsx}")).unwrap();
3443 assert!(re.is_match("app.ts"));
3444 assert!(re.is_match("app.tsx"));
3445 assert!(!re.is_match("app.js"));
3446 }
3447
3448 #[test]
3449 fn test_glob_tool_success() {
3450 let dir = std::env::temp_dir().join("sigit_test_glob");
3451 let _ = fs::remove_dir_all(&dir);
3452 fs::create_dir_all(dir.join("src")).unwrap();
3453 fs::write(dir.join("Cargo.toml"), "").unwrap();
3454 fs::write(dir.join("src/main.rs"), "").unwrap();
3455 fs::write(dir.join("src/lib.rs"), "").unwrap();
3456
3457 let args = serde_json::json!({ "pattern": "**/*.rs", "path": &dir }).to_string();
3458 let result = exec_glob(&args);
3459 assert!(result.contains("main.rs"), "{result}");
3460 assert!(result.contains("lib.rs"), "{result}");
3461 assert!(!result.contains("Cargo.toml"), "{result}");
3462
3463 let _ = fs::remove_dir_all(&dir);
3464 }
3465
3466 #[test]
3467 fn test_search_files_file_glob_filter() {
3468 let dir = std::env::temp_dir().join("sigit_test_search_glob");
3469 let _ = fs::remove_dir_all(&dir);
3470 fs::create_dir_all(&dir).unwrap();
3471 fs::write(dir.join("code.rs"), "needle here\n").unwrap();
3472 fs::write(dir.join("notes.txt"), "needle here\n").unwrap();
3473
3474 let args = serde_json::json!({
3475 "pattern": "needle", "path": &dir, "file_glob": "*.rs"
3476 })
3477 .to_string();
3478 let result = exec_search_files(&args);
3479 assert!(result.contains("code.rs"), "{result}");
3480 assert!(!result.contains("notes.txt"), "{result}");
3481
3482 let _ = fs::remove_dir_all(&dir);
3483 }
3484
3485 #[test]
3486 fn test_write_todos_renders_checklist() {
3487 let args = serde_json::json!({
3488 "todos": [
3489 { "content": "Read code", "status": "completed" },
3490 { "content": "Make change", "status": "in_progress" },
3491 { "content": "Run tests", "status": "pending" }
3492 ]
3493 })
3494 .to_string();
3495 let result = exec_write_todos(&args);
3496 assert!(result.contains("1/3 done"), "{result}");
3497 assert!(result.contains("[x] Read code"), "{result}");
3498 assert!(result.contains("[~] Make change"), "{result}");
3499 assert!(result.contains("[ ] Run tests"), "{result}");
3500 }
3501
3502 #[test]
3503 fn test_write_todos_accepts_an_empty_list_to_clear() {
3504 let result = exec_write_todos(r#"{"todos":[]}"#);
3505 assert_eq!(result, "Task list cleared.");
3506 }
3507
3508 #[test]
3509 fn test_remember_appends_to_instruction_file() {
3510 let dir = std::env::temp_dir().join("sigit_test_remember");
3511 let _ = fs::remove_dir_all(&dir);
3512 fs::create_dir_all(dir.join(".git")).unwrap();
3513 let claude_md = dir.join("CLAUDE.md");
3514 fs::write(&claude_md, "# Project\n").unwrap();
3515
3516 let target = crate::instructions::memory_file(&dir);
3517 // Should pick the existing CLAUDE.md at the repo root.
3518 assert_eq!(
3519 target.canonicalize().unwrap(),
3520 claude_md.canonicalize().unwrap()
3521 );
3522
3523 let result = remember_at(&dir, "remembered text");
3524 assert!(result.contains("remembered"), "{result}");
3525
3526 let updated = fs::read_to_string(&claude_md).unwrap();
3527 assert!(updated.contains("## Remembered notes"), "{updated}");
3528 assert!(updated.contains("- remembered text"), "{updated}");
3529
3530 let _ = fs::remove_dir_all(&dir);
3531 }
3532
3533 #[test]
3534 fn test_all_tools_schemas_are_valid_json_objects() {
3535 for tool in all_tools() {
3536 assert!(
3537 tool.parameters_schema.is_object(),
3538 "schema for {} is not an object",
3539 tool.name
3540 );
3541 let obj = tool.parameters_schema.as_object().unwrap();
3542 assert!(obj.contains_key("type"));
3543 assert!(obj.contains_key("properties"));
3544 assert!(obj.contains_key("required"));
3545 }
3546 }
3547
3548 // ── read_website tests ───────────────────────────────────────────────
3549
3550 #[test]
3551 fn test_read_website_missing_url() {
3552 let result = exec_read_website("{}");
3553 assert!(result.contains("missing required parameter"));
3554 }
3555
3556 #[test]
3557 fn test_read_website_invalid_scheme() {
3558 let result = exec_read_website(r#"{"url": "file:///tmp/test.html"}"#);
3559 assert!(result.contains("url must start with http:// or https://"));
3560 }
3561
3562 #[test]
3563 fn test_read_website_extracts_title_from_html() {
3564 let body = r#"
3565 <html>
3566 <head>
3567 <title>Qwen 3.6 27B</title>
3568 </head>
3569 <body>
3570 <h1>Model card</h1>
3571 <p>Large language model.</p>
3572 </body>
3573 </html>
3574 "#;
3575
3576 let title = Regex::new(r"(?is)<title[^>]*>(.*?)</title>")
3577 .unwrap()
3578 .captures(body)
3579 .and_then(|captures| captures.get(1))
3580 .map(|m| {
3581 Regex::new(r"\s+")
3582 .unwrap()
3583 .replace_all(m.as_str(), " ")
3584 .trim()
3585 .to_string()
3586 })
3587 .filter(|title| !title.is_empty());
3588
3589 assert_eq!(title.as_deref(), Some("Qwen 3.6 27B"));
3590 }
3591
3592 #[test]
3593 fn test_read_website_metadata_includes_final_url_header() {
3594 let final_url = "https://huggingface.co/Qwen/Qwen3.6-27B";
3595 let title = Some("Qwen 3.6 27B".to_string());
3596 let cleaned = "Model card\nLarge language model.".to_string();
3597
3598 let mut metadata = vec![format!("URL: {final_url}")];
3599 if let Some(title) = &title {
3600 metadata.push(format!("Title: {title}"));
3601 }
3602
3603 let body_text = match title {
3604 Some(_) => cleaned,
3605 None => cleaned,
3606 };
3607
3608 let output = format!("{}\n\n{}", metadata.join("\n"), body_text);
3609
3610 assert!(output.starts_with("URL: https://huggingface.co/Qwen/Qwen3.6-27B"));
3611 assert!(output.contains("\nTitle: Qwen 3.6 27B\n\n"));
3612 }
3613
3614 // ── create_directory tests ───────────────────────────────────────────
3615
3616 #[test]
3617 fn test_create_directory_missing_path() {
3618 let result = exec_create_directory("{}");
3619 assert!(result.contains("missing required parameter"));
3620 }
3621
3622 #[test]
3623 fn test_create_directory_success() {
3624 let dir = std::env::temp_dir()
3625 .join("sigit_test_create_directory")
3626 .join("nested")
3627 .join("child");
3628 let _ = fs::remove_dir_all(dir.parent().unwrap());
3629
3630 let args = serde_json::json!({ "path": dir }).to_string();
3631 let result = exec_create_directory(&args);
3632 assert!(result.starts_with("Created directory:"), "got: {result}");
3633 assert!(dir.exists());
3634 assert!(dir.is_dir());
3635
3636 let _ = fs::remove_dir_all(dir.parent().unwrap().parent().unwrap());
3637 }
3638
3639 #[test]
3640 fn test_create_directory_already_exists() {
3641 let dir = std::env::temp_dir().join("sigit_test_create_directory_exists");
3642 let _ = fs::remove_dir_all(&dir);
3643 fs::create_dir_all(&dir).unwrap();
3644
3645 let args = serde_json::json!({ "path": dir }).to_string();
3646 let result = exec_create_directory(&args);
3647 assert!(result.contains("Directory already exists"), "got: {result}");
3648
3649 let _ = fs::remove_dir_all(&dir);
3650 }
3651
3652 // ── create_file tests ────────────────────────────────────────────────
3653
3654 #[test]
3655 fn test_create_file_missing_path() {
3656 let result = exec_create_file(r#"{"content": "hello"}"#);
3657 assert!(result.contains("missing required parameter"));
3658 }
3659
3660 #[test]
3661 fn test_create_file_missing_content() {
3662 let result = exec_create_file(r#"{"path": "/tmp/sigit_test_nope.txt"}"#);
3663 assert!(result.contains("missing required parameter"));
3664 }
3665
3666 #[test]
3667 fn test_create_file_success() {
3668 let dir = std::env::temp_dir().join("sigit_test_create_file");
3669 let _ = fs::remove_dir_all(&dir);
3670
3671 let file_path = dir.join("sub").join("new_file.txt");
3672 let args = serde_json::json!({
3673 "path": file_path,
3674 "content": "hello world"
3675 })
3676 .to_string();
3677
3678 let result = exec_create_file(&args);
3679 assert!(result.starts_with("Created file:"), "got: {result}");
3680 assert!(file_path.exists());
3681 assert_eq!(fs::read_to_string(&file_path).unwrap(), "hello world");
3682
3683 let _ = fs::remove_dir_all(&dir);
3684 }
3685
3686 #[test]
3687 fn command_results_read_the_same_wherever_the_command_ran() {
3688 assert_eq!(
3689 command_result(Some(0), None, "hi\n".to_string()),
3690 "Exit code 0:\nhi\n"
3691 );
3692 assert_eq!(
3693 command_result(Some(0), None, String::new()),
3694 "Command succeeded (exit code 0) with no output."
3695 );
3696 assert_eq!(
3697 command_result(Some(2), None, "boom\n".to_string()),
3698 "Command failed (exit code 2):\nboom\n"
3699 );
3700 // Stopped from the editor: the client names the signal.
3701 assert_eq!(
3702 command_result(None, Some("SIGTERM"), "partial\n".to_string()),
3703 "Command failed (killed by signal SIGTERM):\npartial\n"
3704 );
3705 // A local process killed by a signal has no code to report.
3706 assert_eq!(
3707 command_result(None, None, String::new()),
3708 "Command failed (exit code -1):\n"
3709 );
3710
3711 let dropped = client_output_text(crate::client_terminal::Output {
3712 output: "tail\n".to_string(),
3713 truncated: true,
3714 });
3715 assert!(
3716 dropped.starts_with("… (earlier output dropped"),
3717 "{dropped}"
3718 );
3719 assert!(dropped.ends_with("\ntail\n"), "{dropped}");
3720 }
3721
3722 #[test]
3723 fn written_files_report_their_change() {
3724 let dir = std::env::temp_dir().join("sigit_test_file_change");
3725 let _ = fs::remove_dir_all(&dir);
3726 fs::create_dir_all(&dir).unwrap();
3727 let file_path = dir.join("notes.txt");
3728
3729 let created = create_file(
3730 &serde_json::json!({ "path": file_path, "content": "one\ntwo\n" }).to_string(),
3731 );
3732 assert_eq!(
3733 created.change,
3734 Some(FileChange {
3735 path: file_path.clone(),
3736 old_text: None,
3737 new_text: "one\ntwo\n".to_string(),
3738 })
3739 );
3740
3741 let edited = edit(
3742 "edit_file",
3743 &serde_json::json!({ "path": file_path, "old_text": "two", "new_text": "2" })
3744 .to_string(),
3745 );
3746 assert_eq!(
3747 edited.change,
3748 Some(FileChange {
3749 path: file_path.clone(),
3750 old_text: Some("one\ntwo\n".to_string()),
3751 new_text: "one\n2\n".to_string(),
3752 })
3753 );
3754
3755 let batch = edit(
3756 "multi_edit",
3757 &serde_json::json!({
3758 "path": file_path,
3759 "edits": [
3760 { "old_text": "one", "new_text": "1" },
3761 { "old_text": "2", "new_text": "two" },
3762 ],
3763 })
3764 .to_string(),
3765 );
3766 let change = batch.change.expect("multi_edit reports its change");
3767 assert_eq!(change.old_text.as_deref(), Some("one\n2\n"));
3768 assert_eq!(change.new_text, "1\ntwo\n");
3769
3770 // A call that writes nothing has nothing to show.
3771 let failed = edit(
3772 "edit_file",
3773 &serde_json::json!({ "path": file_path, "old_text": "absent", "new_text": "x" })
3774 .to_string(),
3775 );
3776 assert!(
3777 failed.output.starts_with("Error:"),
3778 "got: {}",
3779 failed.output
3780 );
3781 assert_eq!(failed.change, None);
3782
3783 let _ = fs::remove_dir_all(&dir);
3784 }
3785
3786 #[tokio::test]
3787 async fn previewing_a_change_writes_nothing() {
3788 let dir = std::env::temp_dir().join("sigit_test_preview_change");
3789 let _ = fs::remove_dir_all(&dir);
3790 fs::create_dir_all(&dir).unwrap();
3791 let existing = dir.join("existing.txt");
3792 fs::write(&existing, "alpha\nbeta\n").unwrap();
3793
3794 let edit_args =
3795 serde_json::json!({ "path": existing, "old_text": "beta", "new_text": "gamma" })
3796 .to_string();
3797 let preview = preview_file_change("edit_file", &edit_args).await;
3798 assert_eq!(
3799 preview,
3800 Some(FileChange {
3801 path: existing.clone(),
3802 old_text: Some("alpha\nbeta\n".to_string()),
3803 new_text: "alpha\ngamma\n".to_string(),
3804 })
3805 );
3806 assert_eq!(fs::read_to_string(&existing).unwrap(), "alpha\nbeta\n");
3807
3808 // `create_file` would make the parent directories; the preview must not.
3809 let new_file = dir.join("missing").join("new.txt");
3810 let create_args = serde_json::json!({ "path": new_file, "content": "fresh" }).to_string();
3811 let preview = preview_file_change("create_file", &create_args).await;
3812 assert_eq!(
3813 preview,
3814 Some(FileChange {
3815 path: new_file.clone(),
3816 old_text: None,
3817 new_text: "fresh".to_string(),
3818 })
3819 );
3820 assert!(!dir.join("missing").exists());
3821
3822 // Calls that would fail, and tools that write no file, preview nothing.
3823 let unmatched =
3824 serde_json::json!({ "path": existing, "old_text": "absent", "new_text": "x" })
3825 .to_string();
3826 assert_eq!(preview_file_change("edit_file", &unmatched).await, None);
3827 let clobber = serde_json::json!({ "path": existing, "content": "x" }).to_string();
3828 assert_eq!(preview_file_change("create_file", &clobber).await, None);
3829 assert_eq!(
3830 preview_file_change(
3831 "delete_file",
3832 &serde_json::json!({ "path": existing }).to_string()
3833 )
3834 .await,
3835 None
3836 );
3837
3838 let _ = fs::remove_dir_all(&dir);
3839 }
3840
3841 #[test]
3842 fn test_create_file_already_exists() {
3843 let dir = std::env::temp_dir().join("sigit_test_create_exists");
3844 let _ = fs::remove_dir_all(&dir);
3845 fs::create_dir_all(&dir).unwrap();
3846
3847 let file_path = dir.join("existing.txt");
3848 fs::write(&file_path, "original").unwrap();
3849
3850 let args = serde_json::json!({
3851 "path": file_path,
3852 "content": "overwrite attempt"
3853 })
3854 .to_string();
3855
3856 let result = exec_create_file(&args);
3857 assert!(result.contains("already exists"), "got: {result}");
3858 // Original content untouched.
3859 assert_eq!(fs::read_to_string(&file_path).unwrap(), "original");
3860
3861 let _ = fs::remove_dir_all(&dir);
3862 }
3863
3864 // ── edit_file tests ──────────────────────────────────────────────────
3865
3866 #[test]
3867 fn test_edit_file_missing_params() {
3868 let result = exec_edit_file(r#"{"path": "x"}"#);
3869 assert!(result.contains("missing required parameter"));
3870
3871 let result = exec_edit_file(r#"{"path": "x", "old_text": "a"}"#);
3872 assert!(result.contains("missing required parameter"));
3873 }
3874
3875 #[test]
3876 fn test_edit_file_nonexistent() {
3877 let result = exec_edit_file(
3878 r#"{"path": "/tmp/__sigit_no_such__", "old_text": "a", "new_text": "b"}"#,
3879 );
3880 assert!(result.contains("does not exist"));
3881 }
3882
3883 #[test]
3884 fn test_edit_file_success() {
3885 let dir = std::env::temp_dir().join("sigit_test_edit_file");
3886 let _ = fs::remove_dir_all(&dir);
3887 fs::create_dir_all(&dir).unwrap();
3888
3889 let file_path = dir.join("code.rs");
3890 fs::write(&file_path, "fn main() {\n println!(\"hello\");\n}\n").unwrap();
3891
3892 let args = serde_json::json!({
3893 "path": file_path,
3894 "old_text": "println!(\"hello\")",
3895 "new_text": "println!(\"world\")"
3896 })
3897 .to_string();
3898
3899 let result = exec_edit_file(&args);
3900 assert!(result.starts_with("Edited file:"), "got: {result}");
3901
3902 let updated = fs::read_to_string(&file_path).unwrap();
3903 assert!(updated.contains("println!(\"world\")"));
3904 assert!(!updated.contains("println!(\"hello\")"));
3905
3906 let _ = fs::remove_dir_all(&dir);
3907 }
3908
3909 #[test]
3910 fn test_edit_file_old_text_not_found() {
3911 let dir = std::env::temp_dir().join("sigit_test_edit_notfound");
3912 let _ = fs::remove_dir_all(&dir);
3913 fs::create_dir_all(&dir).unwrap();
3914
3915 let file_path = dir.join("data.txt");
3916 fs::write(&file_path, "aaa bbb ccc").unwrap();
3917
3918 let args = serde_json::json!({
3919 "path": file_path,
3920 "old_text": "zzz",
3921 "new_text": "yyy"
3922 })
3923 .to_string();
3924
3925 let result = exec_edit_file(&args);
3926 assert!(result.contains("old_text not found"), "got: {result}");
3927
3928 let _ = fs::remove_dir_all(&dir);
3929 }
3930
3931 #[test]
3932 fn test_edit_file_ambiguous_match() {
3933 let dir = std::env::temp_dir().join("sigit_test_edit_ambiguous");
3934 let _ = fs::remove_dir_all(&dir);
3935 fs::create_dir_all(&dir).unwrap();
3936
3937 let file_path = dir.join("repeat.txt");
3938 fs::write(&file_path, "foo bar foo bar foo").unwrap();
3939
3940 let args = serde_json::json!({
3941 "path": file_path,
3942 "old_text": "foo",
3943 "new_text": "baz"
3944 })
3945 .to_string();
3946
3947 let result = exec_edit_file(&args);
3948 assert!(result.contains("appears 3 times"), "got: {result}");
3949 // File should be unchanged.
3950 assert_eq!(
3951 fs::read_to_string(&file_path).unwrap(),
3952 "foo bar foo bar foo"
3953 );
3954
3955 let _ = fs::remove_dir_all(&dir);
3956 }
3957
3958 // ── delete_file tests ────────────────────────────────────────────────
3959
3960 #[test]
3961 fn test_delete_file_missing_path() {
3962 let result = exec_delete_file("{}");
3963 assert!(
3964 result.contains("missing required parameter"),
3965 "got: {result}"
3966 );
3967 }
3968
3969 #[test]
3970 fn test_delete_file_nonexistent() {
3971 let result = exec_delete_file(r#"{"path": "/tmp/sigit_test_no_such_file_xyz"}"#);
3972 assert!(result.contains("does not exist"), "got: {result}");
3973 }
3974
3975 #[test]
3976 fn test_delete_file_success() {
3977 let dir = std::env::temp_dir().join("sigit_test_delete_file");
3978 let _ = fs::remove_dir_all(&dir);
3979 fs::create_dir_all(&dir).unwrap();
3980
3981 let file_path = dir.join("to_delete.txt");
3982 fs::write(&file_path, "bye").unwrap();
3983 assert!(file_path.exists());
3984
3985 let args = serde_json::json!({ "path": file_path }).to_string();
3986 let result = exec_delete_file(&args);
3987 assert!(result.contains("Deleted file"), "got: {result}");
3988 assert!(!file_path.exists());
3989
3990 let _ = fs::remove_dir_all(&dir);
3991 }
3992
3993 #[test]
3994 fn test_delete_empty_directory() {
3995 let dir = std::env::temp_dir().join("sigit_test_delete_empty_dir");
3996 let _ = fs::remove_dir_all(&dir);
3997 fs::create_dir_all(&dir).unwrap();
3998
3999 let args = serde_json::json!({ "path": dir }).to_string();
4000 let result = exec_delete_file(&args);
4001 assert!(result.contains("Deleted empty directory"), "got: {result}");
4002 assert!(!dir.exists());
4003 }
4004
4005 #[test]
4006 fn test_delete_nonempty_directory() {
4007 let dir = std::env::temp_dir().join("sigit_test_delete_nonempty_dir");
4008 let _ = fs::remove_dir_all(&dir);
4009 fs::create_dir_all(&dir).unwrap();
4010 fs::write(dir.join("child.txt"), "content").unwrap();
4011
4012 let args = serde_json::json!({ "path": dir }).to_string();
4013 let result = exec_delete_file(&args);
4014 assert!(result.contains("Error"), "got: {result}");
4015 assert!(dir.exists(), "directory should not have been deleted");
4016
4017 let _ = fs::remove_dir_all(&dir);
4018 }
4019
4020 // ── run_command tests ────────────────────────────────────────────────
4021
4022 #[test]
4023 fn test_run_command_missing_command() {
4024 let result = exec_run_command("{}", None);
4025 assert!(
4026 result.contains("missing required parameter"),
4027 "got: {result}"
4028 );
4029 }
4030
4031 #[test]
4032 fn test_run_command_success() {
4033 let result = exec_run_command(r#"{"command": "echo hello"}"#, None);
4034 assert!(result.contains("hello"), "got: {result}");
4035 assert!(result.contains("Exit code 0"), "got: {result}");
4036 }
4037
4038 /// Fresh git repo with one commit, test identity, and signing off (the
4039 /// developer's global gpgsign must not leak into sandbox commits).
4040 fn init_test_repo(name: &str) -> std::path::PathBuf {
4041 let dir = std::env::temp_dir().join(format!("sigit_test_{name}_{}", std::process::id()));
4042 let _ = fs::remove_dir_all(&dir);
4043 fs::create_dir_all(&dir).unwrap();
4044 test_git(&dir, &["init", "-q", "-b", "main"]);
4045 test_git(&dir, &["config", "user.name", "Test User"]);
4046 test_git(&dir, &["config", "user.email", "test@example.com"]);
4047 test_git(&dir, &["config", "commit.gpgsign", "false"]);
4048 fs::write(dir.join("file.txt"), "one\n").unwrap();
4049 test_git(&dir, &["add", "file.txt"]);
4050 test_git(&dir, &["commit", "-q", "-m", "Initial"]);
4051 dir
4052 }
4053
4054 fn test_git(dir: &Path, args: &[&str]) {
4055 let out = Command::new("git")
4056 .args(args)
4057 .current_dir(dir)
4058 .output()
4059 .unwrap();
4060 assert!(
4061 out.status.success(),
4062 "git {args:?} failed: {}",
4063 String::from_utf8_lossy(&out.stderr)
4064 );
4065 }
4066
4067 /// The wait loop used to read the pipes only after the child exited, so a
4068 /// command that outgrew the pipe buffer blocked on the write, we blocked on
4069 /// the child, and the whole thing sat there until the 120-second timeout.
4070 #[test]
4071 fn run_command_survives_more_output_than_a_pipe_buffer_holds() {
4072 let dir = std::env::temp_dir();
4073 // ~400 KB, well past any platform's pipe buffer. It also crosses
4074 // COMMAND_OUTPUT_LIMIT, so the result is truncated; the point of the
4075 // test is that the command finishes at all.
4076 let line = "x".repeat(199);
4077 // ~400 KB, well past any platform's pipe buffer. It also crosses
4078 // COMMAND_OUTPUT_LIMIT, so the result is truncated; the point of the
4079 // test is that the command finishes at all.
4080 #[cfg(unix)]
4081 let command = format!("for i in $(seq 1 2000); do echo {line}; done");
4082 // cmd has no seq; echo is a builtin so 2000 iterations stay fast.
4083 #[cfg(windows)]
4084 let command = format!("for /L %i in (1,1,2000) do @echo {line}");
4085 let args = json!({"command": command, "cwd": dir.to_str().unwrap()}).to_string();
4086
4087 let started = std::time::Instant::now();
4088 let result = exec_run_command(&args, None);
4089
4090 assert!(
4091 started.elapsed() < std::time::Duration::from_secs(30),
4092 "run_command took {:?}; it deadlocked on the pipe",
4093 started.elapsed()
4094 );
4095 assert!(
4096 result.contains("Exit code 0"),
4097 "unexpected result: {result}"
4098 );
4099 assert!(
4100 result.contains("output truncated"),
4101 "expected the 50000-byte cap to bite: {result}"
4102 );
4103 }
4104
4105 /// A command that wants input gets EOF instead of blocking forever on a
4106 /// pipe nobody is going to write to (in ACP mode, the editor's own).
4107 #[test]
4108 fn run_command_gives_a_command_that_reads_stdin_an_empty_one() {
4109 let dir = std::env::temp_dir();
4110 let args = json!({"command": "cat", "cwd": dir.to_str().unwrap()}).to_string();
4111
4112 let started = std::time::Instant::now();
4113 let result = exec_run_command(&args, None);
4114
4115 assert!(
4116 started.elapsed() < std::time::Duration::from_secs(30),
4117 "run_command blocked on stdin for {:?}",
4118 started.elapsed()
4119 );
4120 assert!(result.contains("no output"), "unexpected result: {result}");
4121 }
4122
4123 #[test]
4124 fn strip_trailing_comment_block_only_drops_trailing_comment_paragraphs() {
4125 assert_eq!(
4126 strip_trailing_comment_block("Merge branch 'side'\n\n# Conflicts:\n#\tfile.txt\n"),
4127 "Merge branch 'side'"
4128 );
4129 assert_eq!(
4130 strip_trailing_comment_block(
4131 "Merge\n\nCo-Authored-By: siGit Code <noreply@sigit.si>\n\n# Conflicts:\n#\tf"
4132 ),
4133 "Merge\n\nCo-Authored-By: siGit Code <noreply@sigit.si>"
4134 );
4135 // A heading inside the body, or a paragraph that only starts with
4136 // one, is the author's text.
4137 let body = "Title\n\n## Why\n\nBecause\n\n# Note\nnot a comment";
4138 assert_eq!(strip_trailing_comment_block(body), body);
4139 assert_eq!(strip_trailing_comment_block("# only"), "# only");
4140 // Extra blank lines, whitespace-only lines, and CRLF still separate
4141 // paragraphs.
4142 assert_eq!(
4143 strip_trailing_comment_block("Merge\n\n\n\n# Conflicts:\n#\tf\n"),
4144 "Merge"
4145 );
4146 assert_eq!(
4147 strip_trailing_comment_block("Merge\n \t\n# Conflicts:\n#\tf"),
4148 "Merge"
4149 );
4150 assert_eq!(
4151 strip_trailing_comment_block("Merge\r\n\r\n# Conflicts:\r\n#\tf\r\n"),
4152 "Merge"
4153 );
4154 let crlf = "Title\r\n\r\n# Note\r\nnot a comment\r\n";
4155 assert_eq!(strip_trailing_comment_block(crlf), crlf.trim_end());
4156 }
4157
4158 /// Start a merge of `side` into `main` that conflicts on file.txt and
4159 /// resolve it, leaving the merge ready to commit.
4160 fn stage_resolved_merge_conflict(dir: &Path) {
4161 test_git(dir, &["switch", "-q", "-c", "side"]);
4162 fs::write(dir.join("file.txt"), "side\n").unwrap();
4163 test_git(dir, &["commit", "-q", "-am", "Side"]);
4164 test_git(dir, &["switch", "-q", "main"]);
4165 fs::write(dir.join("file.txt"), "main\n").unwrap();
4166 test_git(dir, &["commit", "-q", "-am", "Main"]);
4167 let merge = Command::new("git")
4168 .args(["merge", "-q", "side"])
4169 .current_dir(dir)
4170 .output()
4171 .unwrap();
4172 assert!(!merge.status.success(), "the merge should conflict");
4173 fs::write(dir.join("file.txt"), "both\n").unwrap();
4174 test_git(dir, &["add", "file.txt"]);
4175 }
4176
4177 /// Issue #74's screenshot: the model finished a conflicted merge with
4178 /// `git commit --no-edit`, git kept its `# Conflicts:` block, and the
4179 /// amended trailer landed above it where GitHub doesn't look.
4180 #[test]
4181 fn run_command_puts_trailer_last_on_a_conflicted_merge() {
4182 let dir = init_test_repo("coauthor_merge_conflict");
4183 stage_resolved_merge_conflict(&dir);
4184 let args = serde_json::json!({
4185 "command": "git commit --no-edit",
4186 "cwd": dir.display().to_string(),
4187 })
4188 .to_string();
4189
4190 let result = exec_run_command(&args, None);
4191 assert!(result.contains("co-author trailer"), "got: {result}");
4192
4193 let message = git_stdout(&dir, &["log", "-1", "--format=%B"]).unwrap();
4194 assert_eq!(
4195 message,
4196 format!("Merge branch 'side'\n\n{}", commit_co_author_trailer())
4197 );
4198 let parents = git_stdout(&dir, &["log", "-1", "--format=%P"]).unwrap();
4199 assert_eq!(parents.split(' ').count(), 2, "still a merge: {parents}");
4200 let _ = fs::remove_dir_all(&dir);
4201 }
4202
4203 #[test]
4204 fn run_command_drops_comment_block_below_an_existing_trailer() {
4205 let dir = init_test_repo("coauthor_merge_trailer_hidden");
4206 stage_resolved_merge_conflict(&dir);
4207 // The layout from the issue, trailer present but not last. Written
4208 // with direct git args because cmd /C can't pass the trailer through.
4209 test_git(
4210 &dir,
4211 &[
4212 "commit",
4213 "-q",
4214 "--cleanup=verbatim",
4215 "-m",
4216 &format!(
4217 "Merge branch 'side'\n\n{}\n\n# Conflicts:\n#\tfile.txt",
4218 commit_co_author_trailer()
4219 ),
4220 ],
4221 );
4222 // Change the tree too, or an amend in the same second can reproduce
4223 // the same commit id and HEAD wouldn't move.
4224 fs::write(dir.join("file.txt"), "both, fixed\n").unwrap();
4225 test_git(&dir, &["add", "file.txt"]);
4226 let args = serde_json::json!({
4227 "command": "git commit --amend --no-edit",
4228 "cwd": dir.display().to_string(),
4229 })
4230 .to_string();
4231
4232 let result = exec_run_command(&args, None);
4233 assert!(result.contains("leftover comment lines"), "got: {result}");
4234
4235 let message = git_stdout(&dir, &["log", "-1", "--format=%B"]).unwrap();
4236 assert_eq!(
4237 message,
4238 format!("Merge branch 'side'\n\n{}", commit_co_author_trailer())
4239 );
4240 let _ = fs::remove_dir_all(&dir);
4241 }
4242
4243 #[test]
4244 fn run_command_appends_co_author_trailer_to_new_commits() {
4245 let dir = init_test_repo("coauthor_append");
4246 fs::write(dir.join("file.txt"), "two\n").unwrap();
4247 // Quote-free command: `cmd /C` does not strip double quotes the way
4248 // `sh -c` does, so quoted arguments would break on Windows.
4249 let args = serde_json::json!({
4250 "command": "git add file.txt && git commit -m Update",
4251 "cwd": dir.display().to_string(),
4252 })
4253 .to_string();
4254
4255 let result = exec_run_command(&args, None);
4256 assert!(result.contains("co-author trailer"), "got: {result}");
4257
4258 let message = git_stdout(&dir, &["log", "-1", "--format=%B"]).unwrap();
4259 let trailer = commit_co_author_trailer();
4260 assert!(
4261 message.ends_with(&trailer),
4262 "trailer must be the last line: {message:?}"
4263 );
4264 assert!(
4265 message.contains(&format!("\n\n{trailer}")),
4266 "trailer needs a blank line before it for GitHub to detect it: {message:?}"
4267 );
4268 let _ = fs::remove_dir_all(&dir);
4269 }
4270
4271 #[test]
4272 fn run_command_keeps_existing_co_author_trailer() {
4273 let dir = init_test_repo("coauthor_present");
4274 fs::write(dir.join("file.txt"), "two\n").unwrap();
4275 // The trailer contains spaces and angle brackets, which `cmd /C`
4276 // mis-tokenizes (`<` is redirection), so create the trailer-carrying
4277 // commit with direct git args and let run_command amend it without
4278 // editing: HEAD changes, the message already has the trailer, and the
4279 // gate must leave it alone.
4280 test_git(&dir, &["add", "file.txt"]);
4281 test_git(
4282 &dir,
4283 &[
4284 "commit",
4285 "-q",
4286 "-m",
4287 &format!("Update file\n\n{}", commit_co_author_trailer()),
4288 ],
4289 );
4290 let args = serde_json::json!({
4291 "command": "git commit --amend --no-edit",
4292 "cwd": dir.display().to_string(),
4293 })
4294 .to_string();
4295
4296 let result = exec_run_command(&args, None);
4297 assert!(
4298 !result.contains("[siGit Code]"),
4299 "no amend expected: {result}"
4300 );
4301
4302 let message = git_stdout(&dir, &["log", "-1", "--format=%B"]).unwrap();
4303 assert_eq!(
4304 message.matches("Co-Authored-By: siGit Code").count(),
4305 1,
4306 "trailer must not be duplicated: {message:?}"
4307 );
4308 let _ = fs::remove_dir_all(&dir);
4309 }
4310
4311 #[test]
4312 fn run_command_never_amends_pushed_commits() {
4313 let dir = init_test_repo("coauthor_pushed");
4314 let remote = std::env::temp_dir().join(format!(
4315 "sigit_test_coauthor_remote_{}.git",
4316 std::process::id()
4317 ));
4318 let _ = fs::remove_dir_all(&remote);
4319 fs::create_dir_all(&remote).unwrap();
4320 test_git(&remote, &["init", "-q", "--bare"]);
4321 test_git(&dir, &["remote", "add", "origin", remote.to_str().unwrap()]);
4322
4323 fs::write(dir.join("file.txt"), "two\n").unwrap();
4324 let args = serde_json::json!({
4325 "command": "git add file.txt && git commit -m Update && git push -q origin main",
4326 "cwd": dir.display().to_string(),
4327 })
4328 .to_string();
4329
4330 let result = exec_run_command(&args, None);
4331 assert!(
4332 !result.contains("[siGit Code]"),
4333 "no amend expected: {result}"
4334 );
4335
4336 // Already on the remote when the gate ran, so it must be untouched.
4337 let message = git_stdout(&dir, &["log", "-1", "--format=%B"]).unwrap();
4338 assert!(
4339 !message.contains("Co-Authored-By"),
4340 "pushed commit must not be rewritten: {message:?}"
4341 );
4342 let _ = fs::remove_dir_all(&dir);
4343 let _ = fs::remove_dir_all(&remote);
4344 }
4345
4346 #[test]
4347 fn co_author_trailer_names_the_harness_version_and_surface() {
4348 let version = env!("CARGO_PKG_VERSION");
4349 assert_eq!(
4350 co_author_trailer_for(Some(Surface::Acp)),
4351 format!("Co-Authored-By: siGit Code v{version}-acp <noreply@sigit.si>")
4352 );
4353 assert_eq!(
4354 co_author_trailer_for(Some(Surface::Tui)),
4355 format!("Co-Authored-By: siGit Code v{version}-tui <noreply@sigit.si>")
4356 );
4357 assert_eq!(
4358 co_author_trailer_for(Some(Surface::Headless)),
4359 format!("Co-Authored-By: siGit Code v{version}-headless <noreply@sigit.si>")
4360 );
4361 // Before `main` picks a surface (tests, or a tool run outside a
4362 // session) the version stands alone rather than guessing.
4363 assert_eq!(
4364 co_author_trailer_for(None),
4365 format!("Co-Authored-By: siGit Code v{version} <noreply@sigit.si>")
4366 );
4367 }
4368
4369 #[test]
4370 fn run_command_keeps_a_trailer_from_an_older_version() {
4371 let dir = init_test_repo("coauthor_older");
4372 fs::write(dir.join("file.txt"), "two\n").unwrap();
4373 // Same cmd /C constraint as above: the trailer goes in with direct git
4374 // args. Commits made before the version was added, or by another
4375 // release, already credit siGit Code and must not get a second line.
4376 test_git(&dir, &["add", "file.txt"]);
4377 test_git(
4378 &dir,
4379 &[
4380 "commit",
4381 "-q",
4382 "-m",
4383 "Update file\n\nCo-Authored-By: siGit Code <noreply@sigit.si>",
4384 ],
4385 );
4386 let args = serde_json::json!({
4387 "command": "git commit --amend --no-edit",
4388 "cwd": dir.display().to_string(),
4389 })
4390 .to_string();
4391
4392 let result = exec_run_command(&args, None);
4393 assert!(
4394 !result.contains("[siGit Code]"),
4395 "no amend expected: {result}"
4396 );
4397 let message = git_stdout(&dir, &["log", "-1", "--format=%B"]).unwrap();
4398 assert_eq!(
4399 message.matches("Co-Authored-By: siGit Code").count(),
4400 1,
4401 "trailer must not be duplicated: {message:?}"
4402 );
4403 let _ = fs::remove_dir_all(&dir);
4404 }
4405
4406 #[test]
4407 fn test_run_command_failure() {
4408 #[cfg(unix)]
4409 let command = "false";
4410 #[cfg(windows)]
4411 let command = "exit /b 1";
4412
4413 let args = serde_json::json!({ "command": command }).to_string();
4414 let result = exec_run_command(&args, None);
4415 assert!(result.contains("failed"), "got: {result}");
4416 }
4417
4418 #[test]
4419 fn test_run_command_with_cwd() {
4420 let dir = std::env::temp_dir().join("sigit_test_run_cmd_cwd");
4421 let _ = fs::remove_dir_all(&dir);
4422 fs::create_dir_all(&dir).unwrap();
4423
4424 #[cfg(unix)]
4425 let command = "pwd";
4426 #[cfg(windows)]
4427 let command = "cd";
4428
4429 let args = serde_json::json!({
4430 "command": command,
4431 "cwd": dir
4432 })
4433 .to_string();
4434 let result = exec_run_command(&args, None);
4435 // The output should contain the temp dir path.
4436 assert!(
4437 result.contains(&dir.to_string_lossy().to_string()),
4438 "got: {result}"
4439 );
4440
4441 let _ = fs::remove_dir_all(&dir);
4442 }
4443
4444 #[test]
4445 fn test_run_command_bad_cwd() {
4446 let missing_dir = std::env::temp_dir().join("sigit_no_such_dir_xyz");
4447 let _ = fs::remove_dir_all(&missing_dir);
4448
4449 let args = serde_json::json!({
4450 "command": "echo hi",
4451 "cwd": missing_dir
4452 })
4453 .to_string();
4454 let result = exec_run_command(&args, None);
4455 assert!(result.contains("does not exist"), "got: {result}");
4456 }
4457
4458 #[test]
4459 fn test_run_command_captures_stderr() {
4460 #[cfg(unix)]
4461 let command = "echo err >&2";
4462 #[cfg(windows)]
4463 let command = "echo err 1>&2";
4464
4465 let args = serde_json::json!({ "command": command }).to_string();
4466 let result = exec_run_command(&args, None);
4467 assert!(result.contains("err"), "got: {result}");
4468 }
4469
4470 // ── background command tests ─────────────────────────────────────────
4471
4472 /// Extract the task id from a "Started background task {id}: ..." result.
4473 fn background_task_id(result: &str) -> u64 {
4474 let digits: String = result
4475 .strip_prefix("Started background task ")
4476 .unwrap_or_else(|| panic!("unexpected spawn result: {result}"))
4477 .chars()
4478 .take_while(char::is_ascii_digit)
4479 .collect();
4480 digits.parse().expect("task id")
4481 }
4482
4483 /// Whether the task's child has exited, without draining its output.
4484 fn background_task_exited(task_id: u64) -> bool {
4485 let mut map = lock_tasks();
4486 match map.get_mut(&task_id) {
4487 Some(task) => poll_exit_code(task).is_some(),
4488 None => true,
4489 }
4490 }
4491
4492 /// The output part of a `command_output` reply. The status line quotes
4493 /// the command, so matching the whole reply finds "echo done" whether or
4494 /// not `done` was ever printed.
4495 fn polled_output(poll: &str) -> &str {
4496 poll.split_once("New output since the last check:")
4497 .map_or("", |(_, output)| output)
4498 }
4499
4500 /// Wait (bounded) for a background task's child to exit.
4501 fn wait_for_background_exit(task_id: u64) {
4502 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30);
4503 while !background_task_exited(task_id) {
4504 assert!(
4505 std::time::Instant::now() < deadline,
4506 "task {task_id} did not exit in time"
4507 );
4508 std::thread::sleep(std::time::Duration::from_millis(50));
4509 }
4510 }
4511
4512 #[test]
4513 fn test_run_command_background_lifecycle() {
4514 #[cfg(unix)]
4515 let command = "echo start; sleep 2; echo done";
4516 #[cfg(windows)]
4517 let command = "echo start&& ping -n 3 127.0.0.1 > nul&& echo done";
4518
4519 let args = serde_json::json!({
4520 "command": command,
4521 "cwd": std::env::temp_dir(),
4522 "run_in_background": true
4523 })
4524 .to_string();
4525 let spawned = std::time::Instant::now();
4526 let result = exec_run_command(&args, None);
4527 // Spawning must return immediately, not wait the ~2s the command takes.
4528 assert!(
4529 spawned.elapsed() < std::time::Duration::from_secs(1),
4530 "background spawn blocked for {:?}",
4531 spawned.elapsed()
4532 );
4533 assert!(result.contains("command_output"), "got: {result}");
4534 let task_id = background_task_id(&result);
4535
4536 // Polling while the command is still sleeping reports it as running.
4537 let poll_args = serde_json::json!({ "task_id": task_id }).to_string();
4538 let poll = exec_command_output(&poll_args, None);
4539 assert!(poll.contains("still running"), "got: {poll}");
4540 let mut combined = poll;
4541
4542 wait_for_background_exit(task_id);
4543 // Grace period so the reader threads finish draining the pipes.
4544 std::thread::sleep(std::time::Duration::from_millis(300));
4545 let final_poll = exec_command_output(&poll_args, None);
4546 assert!(
4547 final_poll.contains("exited with code 0"),
4548 "got: {final_poll}"
4549 );
4550 combined.push_str(&final_poll);
4551
4552 // Across the polls, all output the command printed was delivered.
4553 assert!(combined.contains("start"), "got: {combined}");
4554 assert!(combined.contains("done"), "got: {combined}");
4555 }
4556
4557 #[tokio::test]
4558 async fn test_command_output_waits_for_background_completion() {
4559 #[cfg(unix)]
4560 let command = "sleep 1; echo done";
4561 #[cfg(windows)]
4562 let command = "ping -n 2 127.0.0.1 > nul&& echo done";
4563
4564 let args = serde_json::json!({
4565 "command": command,
4566 "cwd": std::env::temp_dir(),
4567 "run_in_background": true
4568 })
4569 .to_string();
4570 let result = exec_run_command(&args, None);
4571 let task_id = background_task_id(&result);
4572
4573 let poll_args = serde_json::json!({
4574 "task_id": task_id,
4575 "wait_seconds": 5
4576 })
4577 .to_string();
4578 let poll = exec_command_output_wait(&poll_args, None).await;
4579
4580 assert!(poll.contains("exited with code 0"), "got: {poll}");
4581 assert!(polled_output(&poll).contains("done"), "got: {poll}");
4582 }
4583
4584 #[tokio::test]
4585 async fn test_command_output_wait_keeps_output_printed_before_exit() {
4586 #[cfg(unix)]
4587 let command = "echo start; sleep 1; echo done";
4588 #[cfg(windows)]
4589 let command = "echo start&& ping -n 2 127.0.0.1 > nul&& echo done";
4590
4591 let args = serde_json::json!({
4592 "command": command,
4593 "cwd": std::env::temp_dir(),
4594 "run_in_background": true
4595 })
4596 .to_string();
4597 let task_id = background_task_id(&exec_run_command(&args, None));
4598
4599 let poll_args = serde_json::json!({ "task_id": task_id, "wait_seconds": 5 }).to_string();
4600 let poll = exec_command_output_wait(&poll_args, None).await;
4601
4602 // "start" is printed well before the exit the wait returns on; the
4603 // polls in between must not have drained it away.
4604 assert!(poll.contains("exited with code 0"), "got: {poll}");
4605 assert!(polled_output(&poll).contains("start"), "got: {poll}");
4606 assert!(polled_output(&poll).contains("done"), "got: {poll}");
4607 }
4608
4609 #[tokio::test]
4610 async fn test_command_output_wait_reports_output_of_a_running_task() {
4611 #[cfg(unix)]
4612 let command = "echo start; sleep 30";
4613 #[cfg(windows)]
4614 let command = "echo start&& ping -n 31 127.0.0.1 > nul";
4615
4616 let args = serde_json::json!({
4617 "command": command,
4618 "cwd": std::env::temp_dir(),
4619 "run_in_background": true
4620 })
4621 .to_string();
4622 let task_id = background_task_id(&exec_run_command(&args, None));
4623
4624 let poll_args = serde_json::json!({ "task_id": task_id, "wait_seconds": 1 }).to_string();
4625 let poll = exec_command_output_wait(&poll_args, None).await;
4626
4627 assert!(poll.contains("still running"), "got: {poll}");
4628 assert!(polled_output(&poll).contains("start"), "got: {poll}");
4629 exec_kill_command(&serde_json::json!({ "task_id": task_id }).to_string(), None);
4630 }
4631
4632 #[tokio::test]
4633 async fn test_command_output_waits_out_a_quiet_running_task() {
4634 #[cfg(unix)]
4635 let command = "sleep 30";
4636 #[cfg(windows)]
4637 let command = "ping -n 31 127.0.0.1 > nul";
4638
4639 let args = serde_json::json!({
4640 "command": command,
4641 "cwd": std::env::temp_dir(),
4642 "run_in_background": true
4643 })
4644 .to_string();
4645 let task_id = background_task_id(&exec_run_command(&args, None));
4646
4647 let poll_args = serde_json::json!({ "task_id": task_id, "wait_seconds": 1 }).to_string();
4648 let started = std::time::Instant::now();
4649 let poll = exec_command_output_wait(&poll_args, None).await;
4650
4651 assert!(poll.contains("still running"), "got: {poll}");
4652 assert!(
4653 started.elapsed() >= std::time::Duration::from_millis(900),
4654 "an idle task should hold the poll for the whole window, returned after {:?}",
4655 started.elapsed()
4656 );
4657 exec_kill_command(&serde_json::json!({ "task_id": task_id }).to_string(), None);
4658 }
4659
4660 #[test]
4661 fn test_kill_command_stops_background_task() {
4662 #[cfg(unix)]
4663 let command = "sleep 30";
4664 #[cfg(windows)]
4665 let command = "ping -n 31 127.0.0.1 > nul";
4666
4667 let args = serde_json::json!({
4668 "command": command,
4669 "cwd": std::env::temp_dir(),
4670 "run_in_background": true
4671 })
4672 .to_string();
4673 let result = exec_run_command(&args, None);
4674 let task_id = background_task_id(&result);
4675
4676 let kill_args = serde_json::json!({ "task_id": task_id }).to_string();
4677 let killed_at = std::time::Instant::now();
4678 let kill_result = exec_kill_command(&kill_args, None);
4679 assert!(
4680 kill_result.contains(&format!("Killed task {task_id}")),
4681 "got: {kill_result}"
4682 );
4683 // The kill must not wait out the 30s sleep.
4684 assert!(
4685 killed_at.elapsed() < std::time::Duration::from_secs(5),
4686 "kill blocked for {:?}",
4687 killed_at.elapsed()
4688 );
4689
4690 // A later poll reports the task as killed, not still running.
4691 let poll =
4692 exec_command_output(&serde_json::json!({ "task_id": task_id }).to_string(), None);
4693 assert!(poll.contains("was killed"), "got: {poll}");
4694 }
4695
4696 #[test]
4697 fn background_tasks_are_only_visible_to_the_session_that_started_them() {
4698 #[cfg(unix)]
4699 let command = "sleep 30";
4700 #[cfg(windows)]
4701 let command = "ping -n 31 127.0.0.1 > nul";
4702
4703 let result = start_background_task(command, &std::env::temp_dir(), Some("thread-a".into()));
4704 let task_id = background_task_id(&result);
4705 let args = serde_json::json!({ "task_id": task_id }).to_string();
4706
4707 // Another thread (or no thread at all) can't see it, let alone kill it.
4708 for other in [Some("thread-b"), None] {
4709 let poll = exec_command_output(&args, other);
4710 assert!(poll.contains("no background task with id"), "got: {poll}");
4711 let kill = exec_kill_command(&args, other);
4712 assert!(kill.contains("no background task with id"), "got: {kill}");
4713 }
4714
4715 let poll = exec_command_output(&args, Some("thread-a"));
4716 assert!(poll.contains("still running"), "got: {poll}");
4717 let kill = exec_kill_command(&args, Some("thread-a"));
4718 assert!(
4719 kill.contains(&format!("Killed task {task_id}")),
4720 "got: {kill}"
4721 );
4722 }
4723
4724 #[test]
4725 fn closing_a_session_stops_its_background_tasks_and_no_one_elses() {
4726 #[cfg(unix)]
4727 let command = "sleep 30";
4728 #[cfg(windows)]
4729 let command = "ping -n 31 127.0.0.1 > nul";
4730
4731 let cwd = std::env::temp_dir();
4732 let closed = start_background_task(command, &cwd, Some("thread-closing".into()));
4733 let kept = start_background_task(command, &cwd, Some("thread-staying".into()));
4734 let closed_args = serde_json::json!({ "task_id": background_task_id(&closed) }).to_string();
4735 let kept_args = serde_json::json!({ "task_id": background_task_id(&kept) }).to_string();
4736
4737 assert_eq!(kill_session_tasks("thread-closing"), 1);
4738
4739 // The task is gone from the table, not just stopped.
4740 let poll = exec_command_output(&closed_args, Some("thread-closing"));
4741 assert!(poll.contains("no background task with id"), "got: {poll}");
4742 let poll = exec_command_output(&kept_args, Some("thread-staying"));
4743 assert!(poll.contains("still running"), "got: {poll}");
4744
4745 assert_eq!(kill_session_tasks("thread-closing"), 0);
4746 exec_kill_command(&kept_args, Some("thread-staying"));
4747 }
4748
4749 #[test]
4750 fn test_command_output_unknown_task() {
4751 let result = exec_command_output(r#"{"task_id": 9999999}"#, None);
4752 assert!(
4753 result.contains("no background task with id"),
4754 "got: {result}"
4755 );
4756 }
4757
4758 // ── task (subagent) tests ────────────────────────────────────────────
4759
4760 #[test]
4761 fn test_subagent_toolset_is_read_only() {
4762 let specs = subagent_tool_specs(&effective_subagent_tools(None).unwrap());
4763 let mut names: Vec<&str> = specs.iter().map(|spec| spec.name.as_str()).collect();
4764 names.sort_unstable();
4765 assert_eq!(
4766 names,
4767 [
4768 "glob",
4769 "list_directory",
4770 "read_file",
4771 "read_website",
4772 "search_files"
4773 ]
4774 );
4775
4776 // No recursion and no mutating tools, ever.
4777 assert!(!names.contains(&TASK_TOOL_NAME));
4778 for banned in [
4779 "create_file",
4780 "create_directory",
4781 "edit_file",
4782 "multi_edit",
4783 "delete_file",
4784 "run_command",
4785 "write_todos",
4786 "remember",
4787 ] {
4788 assert!(!names.contains(&banned), "{banned} leaked into subagent");
4789 }
4790
4791 // Every spec came from `all_tools()` (schemas intact).
4792 for spec in &specs {
4793 assert!(
4794 serde_json::from_str::<Value>(&spec.parameters_schema)
4795 .unwrap()
4796 .is_object()
4797 );
4798 }
4799 }
4800
4801 #[tokio::test]
4802 async fn test_task_reports_unavailable_without_factory() {
4803 let args = serde_json::json!({
4804 "description": "look around",
4805 "prompt": "What is in the current directory?"
4806 })
4807 .to_string();
4808 // `None` is exactly what `exec_task` passes before any surface has
4809 // registered the process-global factory.
4810 let result = exec_task_with(&args, None).await;
4811 assert!(
4812 result.contains("not available on-device yet"),
4813 "got: {result}"
4814 );
4815 }
4816
4817 #[test]
4818 fn test_kill_command_unknown_task() {
4819 let result = exec_kill_command(r#"{"task_id": 9999999}"#, None);
4820 assert!(
4821 result.contains("no background task with id"),
4822 "got: {result}"
4823 );
4824 }
4825
4826 #[tokio::test]
4827 async fn test_task_missing_prompt() {
4828 let result = exec_task_with(r#"{"description": "x"}"#, None).await;
4829 assert!(
4830 result.contains("missing required parameter \"prompt\""),
4831 "got: {result}"
4832 );
4833 }
4834
4835 // ── web_search tests ─────────────────────────────────────────────────
4836
4837 #[test]
4838 fn test_web_search_tool_spec_matches_the_cloud_mcp_tools_contract() {
4839 let spec = web_search_tool_spec();
4840 assert_eq!(spec.name, "web_search");
4841 let schema: Value = serde_json::from_str(&spec.parameters_schema).unwrap();
4842 assert_eq!(schema["required"], serde_json::json!(["query"]));
4843 assert!(schema["properties"]["query"].is_object());
4844 assert!(schema["properties"]["count"].is_object());
4845 }
4846
4847 #[test]
4848 fn test_is_web_search_delegate() {
4849 assert!(is_web_search_delegate("mcp__sigit__web_search"));
4850 assert!(!is_web_search_delegate("web_search"));
4851 assert!(!is_web_search_delegate("mcp__other__web_search"));
4852 }
4853
4854 #[test]
4855 fn test_web_search_unavailable_without_mcp_init() {
4856 // `mcp::init()` is never called anywhere in this test binary (it does
4857 // real network I/O and is a one-shot `OnceLock`, same constraint as
4858 // `SUBAGENT_FACTORY`), so the official server's tools — including
4859 // web_search — are never discovered here. This proves the
4860 // conditional-advertisement gate fails closed, matching a
4861 // signed-out/MCP-disabled session, rather than defaulting to "always
4862 // offered".
4863 assert!(!web_search_available());
4864 }
4865
4866 #[tokio::test]
4867 async fn test_web_search_delegates_to_mcp_and_degrades_gracefully_when_uninitialized() {
4868 // With MCP uninitialized (see the test above), `mcp::call_tool`
4869 // returns its own in-band error string rather than panicking — proves
4870 // execute_tool's WEB_SEARCH_TOOL_NAME arm actually reaches
4871 // mcp::call_tool(WEB_SEARCH_MCP_DELEGATE, ...) instead of, say, silently
4872 // no-op'ing or hitting the `Unknown tool` fallback.
4873 let args = serde_json::json!({ "query": "siGit Code" }).to_string();
4874 let result = execute_tool(WEB_SEARCH_TOOL_NAME, &args).await;
4875 assert_eq!(result, "Error: MCP is not initialized.");
4876 }
4877
4878 #[test]
4879 fn test_command_output_missing_task_id() {
4880 let result = exec_command_output("{}", None);
4881 assert!(
4882 result.contains("missing required parameter"),
4883 "got: {result}"
4884 );
4885 }
4886
4887 #[test]
4888 fn test_background_output_is_capped() {
4889 // Print well over COMMAND_OUTPUT_LIMIT (50 000) bytes without polling,
4890 // so the buffer must drop the oldest output and note the truncation.
4891 #[cfg(unix)]
4892 let command = r#"i=0; while [ $i -lt 200 ]; do printf 'line-%04d %s\n' "$i" \
4893 "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa \
4894 aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa \
4895 aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa \
4896 aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa \
4897 aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; \
4898 i=$((i+1)); done"#;
4899 // Quote-free on purpose: `cmd /C` does not strip double quotes the
4900 // way `sh -c` does, so a quoted PowerShell one-liner gets mangled.
4901 // A cmd-native for /L loop needs no quoting at all.
4902 #[cfg(windows)]
4903 let command = &format!(
4904 "for /L %i in (1,1,200) do @echo line-%i-end {}",
4905 "a".repeat(330)
4906 );
4907
4908 let args = serde_json::json!({
4909 "command": command,
4910 "cwd": std::env::temp_dir(),
4911 "run_in_background": true
4912 })
4913 .to_string();
4914 let result = exec_run_command(&args, None);
4915 let task_id = background_task_id(&result);
4916
4917 wait_for_background_exit(task_id);
4918 // Grace period so the reader threads finish draining the pipes.
4919 std::thread::sleep(std::time::Duration::from_millis(300));
4920
4921 let poll =
4922 exec_command_output(&serde_json::json!({ "task_id": task_id }).to_string(), None);
4923 assert!(poll.contains("exited with code 0"), "got: {poll}");
4924 assert!(
4925 poll.contains("earlier output was dropped"),
4926 "expected truncation note, got: {poll}"
4927 );
4928 // The oldest lines were dropped; the newest survived. The two
4929 // platforms number lines differently (printf %04d vs cmd's %i).
4930 #[cfg(unix)]
4931 let (oldest, newest) = ("line-0000", "line-0199");
4932 #[cfg(windows)]
4933 let (oldest, newest) = ("line-1-end", "line-200-end");
4934 assert!(!poll.contains(oldest), "oldest output not dropped");
4935 assert!(poll.contains(newest), "newest output missing");
4936 // Buffer stayed within the cap, plus the framing: the status line
4937 // (which quotes the command) and the truncation note.
4938 assert!(
4939 poll.len() <= COMMAND_OUTPUT_LIMIT + command.len() + 500,
4940 "poll result too large: {} bytes",
4941 poll.len()
4942 );
4943 }
4944
4945 // ── task (subagent) end-to-end against a scripted endpoint ──────────
4946
4947 /// A completion whose assistant message requests one tool call.
4948 fn completion_tool_call(id: &str, name: &str, arguments: &str) -> String {
4949 serde_json::json!({
4950 "choices": [{"message": {
4951 "role": "assistant",
4952 "content": serde_json::Value::Null,
4953 "tool_calls": [{
4954 "id": id,
4955 "type": "function",
4956 "function": {"name": name, "arguments": arguments},
4957 }],
4958 }}]
4959 })
4960 .to_string()
4961 }
4962
4963 /// A completion whose assistant message is a plain text answer.
4964 fn completion_text(text: &str) -> String {
4965 serde_json::json!({
4966 "choices": [{"message": {"role": "assistant", "content": text}}]
4967 })
4968 .to_string()
4969 }
4970
4971 /// Minimal scripted OpenAI-compatible endpoint (same pattern as
4972 /// `tests/acp_permissions.rs`): serves one canned chat-completion JSON body
4973 /// per request and records each request body. The subagent loop passes
4974 /// `sink: None`, so the backend takes the non-streaming path and expects
4975 /// plain JSON rather than SSE.
4976 fn start_scripted_endpoint(responses: Vec<String>) -> (u16, Arc<std::sync::Mutex<Vec<Value>>>) {
4977 use std::io::{BufRead, BufReader, Read, Write};
4978
4979 let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind endpoint");
4980 let port = listener.local_addr().unwrap().port();
4981 let requests: Arc<std::sync::Mutex<Vec<Value>>> = Arc::default();
4982 let recorded = Arc::clone(&requests);
4983 let queue = std::sync::Mutex::new(std::collections::VecDeque::from(responses));
4984
4985 std::thread::spawn(move || {
4986 // `connection: close` means one request per connection, matching
4987 // the backend's serial completion requests.
4988 for stream in listener.incoming() {
4989 let Ok(mut stream) = stream else { continue };
4990 let mut reader = BufReader::new(match stream.try_clone() {
4991 Ok(clone) => clone,
4992 Err(_) => continue,
4993 });
4994 let mut content_length = 0usize;
4995 loop {
4996 let mut line = String::new();
4997 if reader.read_line(&mut line).unwrap_or(0) == 0 {
4998 break;
4999 }
5000 let line = line.trim();
Showing first 5,000 of 5,364 lines. View raw