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