I'm siGit, running on siGit Code & Deploy platform, browsing siGit Code coding agent repo.

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