| 1 | use anyhow::{Context, Result, anyhow, bail}; |
| 2 | use rusqlite::Connection |
| 3 | use serde_json::{Value |
| 4 | use std::time::{ |
| 5 | |
| 6 | use crate::db; |
| 7 | |
| 8 | const CMD_TIMEOUT: Duration |
| 9 | |
| 10 | pub struct ToolDef |
| 11 | pub name: & |
| 12 | pub description: & |
| 13 | pub parameters: Value |
| 14 | } |
| 15 | |
| 16 | pub fn definitions() |
| 17 | vec![ |
| 18 | ToolDef { |
| 19 | name: "read_file", |
| 20 | description: "Read the contents of a text file at the given path. Large files are returned as a bounded window (first 400 and last 100 lines) with a line-count marker; pass 'lines' to read a specific range instead.", |
| 21 | parameters: json! |
| 22 | "type": "object", |
| 23 | "properties": { |
| 24 | "path": {"type" |
| 25 | "lines": {"type" |
| 26 | }, |
| 27 | "required": ["path" |
| 28 | }), |
| 29 | }, |
| 30 | ToolDef { |
| 31 | name: "write_file", |
| 32 | description: "Create a new file or overwrite an existing one with the given content.", |
| 33 | parameters: json! |
| 34 | "type": "object", |
| 35 | "properties": { |
| 36 | "path": {"type" |
| 37 | "content": {"type" |
| 38 | }, |
| 39 | "required": ["path" |
| 40 | }), |
| 41 | }, |
| 42 | ToolDef { |
| 43 | name: "edit_file", |
| 44 | description: "Replace an exact substring in a file (find and replace).", |
| 45 | parameters: json! |
| 46 | "type": "object", |
| 47 | "properties": { |
| 48 | "path": {"type" |
| 49 | "old_string": {"type" |
| 50 | "new_string": {"type" |
| 51 | "replace_all": {"type" |
| 52 | }, |
| 53 | "required": ["path" |
| 54 | }), |
| 55 | }, |
| 56 | ToolDef { |
| 57 | name: "delete_file", |
| 58 | description: "Delete the file at the given path.", |
| 59 | parameters: json! |
| 60 | "type": "object", |
| 61 | "properties": { |
| 62 | "path": {"type" |
| 63 | }, |
| 64 | "required": ["path" |
| 65 | }), |
| 66 | }, |
| 67 | ToolDef { |
| 68 | name: "list_files", |
| 69 | description: "List files matching a glob pattern under a directory.", |
| 70 | parameters: json! |
| 71 | "type": "object", |
| 72 | "properties": { |
| 73 | "path": {"type" |
| 74 | "pattern": {"type" |
| 75 | }, |
| 76 | "required": [] |
| 77 | }), |
| 78 | }, |
| 79 | ToolDef { |
| 80 | name: "search", |
| 81 | description: "Search for a regular expression and return matching lines with file and line numbers. `path` may be a single file or a directory (defaults to the current directory).", |
| 82 | parameters: json! |
| 83 | "type": "object", |
| 84 | "properties": { |
| 85 | "pattern": {"type" |
| 86 | "path": {"type" |
| 87 | "include": {"type" |
| 88 | }, |
| 89 | "required": ["pattern" |
| 90 | }), |
| 91 | }, |
| 92 | ToolDef { |
| 93 | name: "delete_chats", |
| 94 | description: "Delete chat sessions from the local SQLite history after the user confirms in a dialog. Use this when the user asks to delete/clear chats, wipe conversation history, or remove past sessions — do NOT claim you cannot access chat history. scope='current' clears this session's messages; 'others' deletes every other session; 'all' does both." |
| 95 | parameters: json! |
| 96 | "type": "object", |
| 97 | "properties": { |
| 98 | "scope": { |
| 99 | "type": "string", |
| 100 | "enum": ["current" |
| 101 | "description": "What to delete: current session messages, all other sessions, or everything." |
| 102 | } |
| 103 | }, |
| 104 | "required": ["scope" |
| 105 | }), |
| 106 | }, |
| 107 | ToolDef { |
| 108 | name: "run_command", |
| 109 | description: "Run a shell command (sh -c) and return its stdout, stderr and exit code.", |
| 110 | parameters: json! |
| 111 | "type": "object", |
| 112 | "properties": { |
| 113 | "command": {"type" |
| 114 | "workdir": {"type" |
| 115 | }, |
| 116 | "required": ["command" |
| 117 | }), |
| 118 | }, |
| 119 | ToolDef { |
| 120 | name: "task", |
| 121 | description: "Launch a new agent to handle a task. Use this tool when you need to offload a complex or specialized task to a sub-agent. Each agent launched has access to its own tools and can work independently. CRITICAL: the sub-agent only sees the 'prompt' you provide — it cannot see this conversation, your todo list, your goal, or your plan. So always hand the sub-agent your full working context in the prompt: your current plan and todo list (if you created one, read it back with todoread), the goal, what has already been done, the exact files and line numbers involved, and what the sub-agent must deliver plus how to verify it. Available sub-agent types: 'general' (full tool access, makes file changes when needed), 'explore' (fast read-only research). |
| 122 | parameters: json! |
| 123 | "type": "object", |
| 124 | "properties": { |
| 125 | "description": {"type" |
| 126 | "prompt": {"type" |
| 127 | "subagent_type": {"type" |
| 128 | "command": {"type" |
| 129 | }, |
| 130 | "required": ["description" |
| 131 | }), |
| 132 | }, |
| 133 | ToolDef { |
| 134 | name: "web_search", |
| 135 | description: "Search the web using the session's web search provider - performs real-time web searches and can scrape content from specific URLs. Provides up-to-date information for current events and recent data. Searches are performed automatically within a single call. The current year is 2026 - include the year in the query when searching for recent information or current events. No API key is required; the provider (Exa Web Search by default, or Parallel Web Search via the OPENCODE_WEBSEARCH_PROVIDER environment variable) is called keyless." |
| 136 | parameters: json! |
| 137 | "type": "object", |
| 138 | "properties": { |
| 139 | "query": {"type" |
| 140 | "num_results": {"type" |
| 141 | "type": {"type" |
| 142 | "livecrawl": {"type" |
| 143 | "context_max_characters": {"type" |
| 144 | }, |
| 145 | "required": ["query" |
| 146 | }), |
| 147 | }, |
| 148 | ToolDef { |
| 149 | name: "web_fetch", |
| 150 | description: "Fetch a URL (http/https only) and return its text content with HTML stripped. Use this to read the page behind a web_search result. Redirects are followed.", |
| 151 | parameters: json! |
| 152 | "type": "object", |
| 153 | "properties": { |
| 154 | "url": {"type" |
| 155 | }, |
| 156 | "required": ["url" |
| 157 | }), |
| 158 | }, |
| 159 | ToolDef { |
| 160 | name: "todowrite", |
| 161 | description: TODO_WRITE_DESCRIPTION, |
| 162 | parameters: json! |
| 163 | "type": "object", |
| 164 | "properties": { |
| 165 | "todos": { |
| 166 | "type": "array", |
| 167 | "description": "The updated todo list", |
| 168 | "items": { |
| 169 | "type": "object", |
| 170 | "properties": { |
| 171 | "content": {"type" |
| 172 | "status": {"type" |
| 173 | "priority": {"type" |
| 174 | }, |
| 175 | "required": ["content" |
| 176 | } |
| 177 | } |
| 178 | }, |
| 179 | "required": ["todos" |
| 180 | }), |
| 181 | }, |
| 182 | ToolDef { |
| 183 | name: "todoread", |
| 184 | description: "Use this tool to read your todo list", |
| 185 | parameters: json! |
| 186 | "type": "object", |
| 187 | "properties": {} |
| 188 | }), |
| 189 | }, |
| 190 | ToolDef { |
| 191 | name: "create_goal", |
| 192 | description: "Set a goal for the current session. The session will work autonomously across multiple turns to achieve it. Creating again with a new objective replaces the current goal and resets its progress.", |
| 193 | parameters: json! |
| 194 | "type": "object", |
| 195 | "properties": { |
| 196 | "objective": {"type" |
| 197 | }, |
| 198 | "required": ["objective" |
| 199 | }), |
| 200 | }, |
| 201 | ToolDef { |
| 202 | name: "update_goal", |
| 203 | description: "Update the status of the current active goal. Use \" |
| 204 | parameters: json! |
| 205 | "type": "object", |
| 206 | "properties": { |
| 207 | "status": { |
| 208 | "type": "string", |
| 209 | "enum": ["complete" |
| 210 | "description": "New status: complete (goal achieved), blocked (cannot proceed)" |
| 211 | } |
| 212 | }, |
| 213 | "required": ["status" |
| 214 | }), |
| 215 | }, |
| 216 | ToolDef { |
| 217 | name: "get_goal", |
| 218 | description: "Get the current goal for this session, or null if none is set.", |
| 219 | parameters: json! |
| 220 | "type": "object", |
| 221 | "properties": {} |
| 222 | }), |
| 223 | }, |
| 224 | ] |
| 225 | } |
| 226 | |
| 227 | const TODO_WRITE_DESCRIPTION: &str |
| 228 | It also helps the user understand the progress of the task and overall progress of their requests. |
| 229 | |
| 230 | ## When to Use This Tool |
| 231 | Use this tool proactively in these scenarios: |
| 232 | |
| 233 | 1. Complex multistep tasks - When a task requires 3 or more distinct steps or actions |
| 234 | 2. Non-trivial and complex tasks - Tasks that require careful planning or multiple operations |
| 235 | 3. User explicitly requests todo list - When the user directly asks you to use the todo list |
| 236 | 4. User provides multiple tasks - When users provide a list of things to be done (numbered or comma-separated) |
| 237 | 5. After receiving new instructions - Immediately capture user requirements as todos. Feel free to edit the todo list based on new information. |
| 238 | 6. After completing a task - Mark it complete and add any new follow-up tasks |
| 239 | 7. When you start working on a new task, mark the todo as in_progress. Ideally you should only have one todo as in_progress at a time. Complete existing tasks before starting new ones. |
| 240 | |
| 241 | ## When NOT to Use This Tool |
| 242 | |
| 243 | Skip using this tool when: |
| 244 | 1. There is only a single, straightforward task |
| 245 | 2. The task is trivial and tracking it provides no organizational benefit |
| 246 | 3. The task can be completed in less than 3 trivial steps |
| 247 | 4. The task is purely conversational or informational |
| 248 | |
| 249 | NOTE that you should not use this tool if there is only one trivial task to do. In this case you are better off just doing the task directly. |
| 250 | |
| 251 | ## Examples of When to Use the Todo List |
| 252 | |
| 253 | <example> |
| 254 | User: I want to add a dark mode toggle to the application settings. Make sure you run the tests and build when you're done! |
| 255 | Assistant: I'll help add a dark mode toggle to your application settings. Let me create a todo list to track this implementation. |
| 256 | *Creates todo list with the following items:* |
| 257 | 1. Create dark mode toggle component in Settings page |
| 258 | 2. Add dark mode state management (context/store) |
| 259 | 3. Implement CSS-in-JS styles for dark theme |
| 260 | 4. Update existing components to support theme switching |
| 261 | 5. Run tests and build process, addressing any failures or errors that occur |
| 262 | *Begins working on the first task* |
| 263 | </example> |
| 264 | |
| 265 | <example> |
| 266 | User: Help me rename the function getCwd to getCurrentWorkingDirectory across my project |
| 267 | Assistant: Let me first search through your codebase to find all occurrences of 'getCwd'. |
| 268 | *Uses grep or search tools to locate all instances of getCwd in the codebase* |
| 269 | Assistant: I've found 15 instances of 'getCwd' across 8 different files. Let me create a todo list to track these changes. |
| 270 | *Creates todo list with specific items for each file that needs updating* |
| 271 | </example> |
| 272 | |
| 273 | <example> |
| 274 | User: I need to implement these features for my e-commerce site: user registration, product catalog, shopping cart, and checkout flow. |
| 275 | Assistant: I'll help implement these features. First, let's add all the features to the todo list. |
| 276 | *Creates a todo list breaking down each feature into specific tasks based on the project architecture* |
| 277 | Assistant: Let's start with user registration. This will involve creating database models, API endpoints, and frontend forms that integrate with your existing authentication system. |
| 278 | </example> |
| 279 | |
| 280 | ## Examples of When NOT to Use the Todo List |
| 281 | |
| 282 | <example> |
| 283 | User: How do I print 'Hello World' in Python? |
| 284 | Assistant: In Python, you can print "Hello World" with this simple code: |
| 285 | |
| 286 | python |
| 287 | print("Hello World") |
| 288 | |
| 289 | This will output the text "Hello World" to the console when executed. |
| 290 | </example> |
| 291 | |
| 292 | <example> |
| 293 | User: What does the git status command do? |
| 294 | Assistant: The git status command shows the current state of your working directory and staging area. It displays which changes have been staged, which haven't, and which files aren't being tracked by Git. This is a fundamental command for understanding the current state of your repository before making commits. |
| 295 | </example> |
| 296 | |
| 297 | ## Task States and Management |
| 298 | |
| 299 | 1. **Task States**: Use these states to track progress: |
| 300 | - pending: Task not yet started |
| 301 | - in_progress: Currently working on (limit to ONE task at a time) |
| 302 | - completed: Task finished successfully |
| 303 | - cancelled: Task no longer needed |
| 304 | |
| 305 | 2. **Task Management**: |
| 306 | - Update task status in real-time as you work |
| 307 | - Mark tasks complete IMMEDIATELY after finishing (don't batch completions) |
| 308 | - Only have ONE task in_progress at any time |
| 309 | - Complete current tasks before starting new ones |
| 310 | - Cancel tasks that become irrelevant |
| 311 | |
| 312 | 3. **Task Breakdown**: |
| 313 | - Create specific, actionable items |
| 314 | - Break complex tasks into smaller, manageable steps |
| 315 | - Use clear, descriptive task names |
| 316 | |
| 317 | When in doubt, use this tool. Being proactive with task management demonstrates attentiveness and ensures you complete all requirements successfully. |
| 318 | "#; |
| 319 | |
| 320 | /// The `task` tool as seen from a read-only context (plan mode, or any |
| 321 | /// read-only sub-agent that can itself delegate). It does not advertise the |
| 322 | /// writable `general` type the caller could never actually get: every sub-agent |
| 323 | /// is coerced to read-only `explore`, and the description and the |
| 324 | /// `subagent_type` enum both say so so the model never plans a task that needs |
| 325 | /// file edits or commands. |
| 326 | pub fn read_only_task_tool(_def |
| 327 | ToolDef { |
| 328 | name: "task", |
| 329 | description: "Launch a read-only research sub-agent to explore the codebase and report back. This context is read-only: EVERY sub-agent is the read-only 'explore' type — whatever 'subagent_type' you request, it is coerced to 'explore' and cannot modify files or run shell commands. Never dispatch a task that requires writing files, editing files, deleting files, or running commands; only delegate research. The sub-agent only sees the 'prompt' you provide — it cannot see this conversation, your todo list, your goal, or your plan. Always hand it your full working context in the prompt: the goal, your current plan and todo list (if you created one), what has already been done, the exact files and line numbers involved, and what it must deliver plus how to verify it. |
| 330 | parameters: json! |
| 331 | "type": "object", |
| 332 | "properties": { |
| 333 | "description": {"type" |
| 334 | "prompt": {"type" |
| 335 | "subagent_type": {"type" |
| 336 | "command": {"type" |
| 337 | }, |
| 338 | "required": ["description" |
| 339 | }), |
| 340 | } |
| 341 | } |
| 342 | |
| 343 | pub fn subagent_tools(subagent_type |
| 344 | let all = |
| 345 | match subagent_type { |
| 346 | "explore" | "scout" |
| 347 | .into_iter() |
| 348 | .filter(|t| |
| 349 | matches!( |
| 350 | t.name, |
| 351 | "read_file" |
| 352 | | "list_files" |
| 353 | | "search" |
| 354 | | "task" |
| 355 | | "web_search" |
| 356 | | "web_fetch" |
| 357 | | "todoread" |
| 358 | ) |
| 359 | }) |
| 360 | .map(|t| |
| 361 | if t.name == |
| 362 | read_only_task_tool(t) |
| 363 | } else { |
| 364 | t |
| 365 | } |
| 366 | }) |
| 367 | .collect(), |
| 368 | _ => all, |
| 369 | } |
| 370 | } |
| 371 | |
| 372 | /// Optional user-confirmation hook. Signature: (title, message, confirm_label) -> approved. |
| 373 | pub type AskUser |
| 374 | |
| 375 | pub fn execute( |
| 376 | conn: &Connection |
| 377 | session_id: i64 |
| 378 | name: &str |
| 379 | args: &Value |
| 380 | ask: Option |
| 381 | ) -> Result<String |
| 382 | match name { |
| 383 | "read_file" => read_file |
| 384 | "write_file" => write_file |
| 385 | "edit_file" => edit_file |
| 386 | "delete_file" => delete_file |
| 387 | "list_files" => list_files |
| 388 | "search" => search |
| 389 | "run_command" => run_command |
| 390 | "web_search" => web_search |
| 391 | "web_fetch" => web_fetch |
| 392 | "todowrite" => todo_write |
| 393 | "todoread" => todo_read |
| 394 | "create_goal" => goal_create |
| 395 | "update_goal" => goal_update |
| 396 | "get_goal" => goal_get |
| 397 | "delete_chats" => delete_chats |
| 398 | other => bail! |
| 399 | } |
| 400 | } |
| 401 | |
| 402 | fn req_str(args: |
| 403 | args.get(key |
| 404 | .and_then(|v| |
| 405 | .map(|s| |
| 406 | .with_context(|| format! |
| 407 | } |
| 408 | |
| 409 | fn truncate_utf8(s: |
| 410 | if s.len() |
| 411 | return s.to_string(); |
| 412 | } |
| 413 | let mut out |
| 414 | out.push_str(&format! |
| 415 | out |
| 416 | } |
| 417 | |
| 418 | const FILE_HEAD_LINES: usize |
| 419 | const FILE_TAIL_LINES: usize |
| 420 | const FILE_RANGE_MAX_LINES: usize |
| 421 | |
| 422 | fn read_file(args: |
| 423 | let path = |
| 424 | let data = |
| 425 | std::fs::read_to_string |
| 426 | let lines: Vec |
| 427 | let total = |
| 428 | |
| 429 | let mut parts |
| 430 | // Grok Build anchors: line number on the first visible line of each |
| 431 | // section plus every 10th line, content only in between — the model |
| 432 | // counts from the nearest anchor. No header/footer text. |
| 433 | let emit = |
| 434 | if first || num |
| 435 | parts.push(format! |
| 436 | } else { |
| 437 | parts.push(line |
| 438 | } |
| 439 | }; |
| 440 | |
| 441 | if let Some |
| 442 | .get("lines") |
| 443 | .and_then(|v| |
| 444 | .filter(|s| |
| 445 | { |
| 446 | let (start, |
| 447 | let start = |
| 448 | let end = |
| 449 | .min(total) |
| 450 | .max(start) |
| 451 | .min(start + |
| 452 | for (i, n |
| 453 | emit(&mut parts |
| 454 | } |
| 455 | return Ok(parts |
| 456 | } |
| 457 | |
| 458 | if total <= FILE_HEAD_LINES |
| 459 | for (i, line |
| 460 | emit(&mut parts |
| 461 | } |
| 462 | return Ok(parts |
| 463 | } |
| 464 | for (i, line |
| 465 | emit(&mut parts |
| 466 | } |
| 467 | parts.push(format! |
| 468 | "...{} lines omitted...", |
| 469 | total - FILE_HEAD_LINES |
| 470 | )); |
| 471 | for (i, line |
| 472 | // Anchor the first tail line so the TUI's gutter resumes numbering. |
| 473 | emit(&mut parts |
| 474 | } |
| 475 | Ok(parts. |
| 476 | } |
| 477 | |
| 478 | fn parse_range(s: |
| 479 | let err = |
| 480 | if let Some |
| 481 | let start = |
| 482 | 1 |
| 483 | } else { |
| 484 | a.trim().parse |
| 485 | }; |
| 486 | let end = |
| 487 | total |
| 488 | } else { |
| 489 | b.trim().parse |
| 490 | }; |
| 491 | Ok((start. |
| 492 | } else { |
| 493 | let n: usize |
| 494 | Ok((n. |
| 495 | } |
| 496 | } |
| 497 | |
| 498 | fn write_file(conn: |
| 499 | let path = |
| 500 | let content = |
| 501 | let existed = |
| 502 | backup_before( |
| 503 | conn, |
| 504 | session_id, |
| 505 | &path, |
| 506 | if existed { " |
| 507 | ); |
| 508 | if let Some |
| 509 | && !parent.as_os_str |
| 510 | { |
| 511 | std::fs::create_dir_all |
| 512 | } |
| 513 | std::fs::write |
| 514 | Ok(format! |
| 515 | } |
| 516 | |
| 517 | fn edit_file(conn: |
| 518 | let path = |
| 519 | let old_string = |
| 520 | let new_string = |
| 521 | let replace_all = |
| 522 | .get("replace_all") |
| 523 | .and_then(|v| |
| 524 | .unwrap_or(false); |
| 525 | let content = |
| 526 | std::fs::read_to_string |
| 527 | if !content.contains |
| 528 | bail!("old_string not found in {path |
| 529 | } |
| 530 | let new_content = |
| 531 | content.replace(&old_string |
| 532 | } else { |
| 533 | content.replacen(&old_string |
| 534 | }; |
| 535 | backup_before(conn, session_id |
| 536 | std::fs::write |
| 537 | let mut result |
| 538 | let diff = |
| 539 | if !diff.is_empty |
| 540 | result.push_str("\ |
| 541 | result.push_str(&diff |
| 542 | } |
| 543 | Ok(result) |
| 544 | } |
| 545 | |
| 546 | /// Number of unchanged context lines kept around each diff hunk. |
| 547 | const DIFF_CONTEXT: usize |
| 548 | /// LCS cells beyond this cap fall back to a coarse single-hunk diff. |
| 549 | const DIFF_LCS_CAP: usize |
| 550 | |
| 551 | /// Unified-style line diff between two file contents. Each emitted line keeps |
| 552 | /// its marker: `' '` context, `'-'` removed, `'+'` added, grouped into hunks |
| 553 | /// headed by `@@ -a,c +b,c @@`. Empty string when the files are identical. |
| 554 | pub fn line_diff(before |
| 555 | let a: Vec |
| 556 | let b: Vec |
| 557 | if a == b |
| 558 | return String::new |
| 559 | } |
| 560 | let ops = |
| 561 | coarse_diff_ops(&a, |
| 562 | } else { |
| 563 | lcs_diff_ops(&a, |
| 564 | }; |
| 565 | format_hunks(&ops) |
| 566 | } |
| 567 | |
| 568 | /// LCS-based line diff (Myers-style edit script via DP backtracking). |
| 569 | fn lcs_diff_ops<'x |
| 570 | let n = |
| 571 | let m = |
| 572 | let w = |
| 573 | let mut dp |
| 574 | for i in ( |
| 575 | for j in ( |
| 576 | dp[i * |
| 577 | dp[(i + |
| 578 | } else { |
| 579 | dp[(i + |
| 580 | }; |
| 581 | } |
| 582 | } |
| 583 | let mut ops |
| 584 | let (mut i |
| 585 | while i < n |
| 586 | if a[i] |
| 587 | ops.push((b' |
| 588 | i += 1; |
| 589 | j += 1; |
| 590 | } else if dp |
| 591 | ops.push((b' |
| 592 | i += 1; |
| 593 | } else { |
| 594 | ops.push((b' |
| 595 | j += 1; |
| 596 | } |
| 597 | } |
| 598 | while i < n |
| 599 | ops.push((b' |
| 600 | i += 1; |
| 601 | } |
| 602 | while j < m |
| 603 | ops.push((b' |
| 604 | j += 1; |
| 605 | } |
| 606 | ops |
| 607 | } |
| 608 | |
| 609 | /// Fallback for very large inputs: treat the common prefix/suffix as context |
| 610 | /// and everything between as one change region. |
| 611 | fn coarse_diff_ops<'x |
| 612 | let n = |
| 613 | let m = |
| 614 | let mut prefix |
| 615 | while prefix < n |
| 616 | prefix += 1; |
| 617 | } |
| 618 | let mut suffix |
| 619 | while suffix < n |
| 620 | && suffix < m |
| 621 | && a[n |
| 622 | { |
| 623 | suffix += 1; |
| 624 | } |
| 625 | let mut ops |
| 626 | ops.extend(a |
| 627 | ops.extend(a |
| 628 | ops.extend(b |
| 629 | ops.extend(a |
| 630 | ops |
| 631 | } |
| 632 | |
| 633 | /// Group an edit script into hunks (changes separated by more than |
| 634 | /// `DIFF_CONTEXT` context lines split into their own hunk) and render them as |
| 635 | /// unified-diff text with `@@` headers. |
| 636 | fn format_hunks(ops: |
| 637 | let changes: Vec |
| 638 | .iter() |
| 639 | .enumerate() |
| 640 | .filter(|(_ |
| 641 | .map(|(i |
| 642 | .collect(); |
| 643 | if changes.is_empty() |
| 644 | return String::new |
| 645 | } |
| 646 | let mut clusters |
| 647 | for &ci in |
| 648 | match clusters.last_mut() |
| 649 | Some((_, |
| 650 | _ => clusters. |
| 651 | } |
| 652 | } |
| 653 | let mut out |
| 654 | for (first, last |
| 655 | let start = |
| 656 | let end = |
| 657 | let a_start = |
| 658 | let b_start = |
| 659 | let a_count = |
| 660 | let b_count = |
| 661 | out.push_str(&format! |
| 662 | "@@ -{a_start},{a_count} |
| 663 | )); |
| 664 | for (m, line |
| 665 | out.push(*m |
| 666 | out.push_str(line |
| 667 | out.push('\ |
| 668 | } |
| 669 | } |
| 670 | out.pop(); |
| 671 | out |
| 672 | } |
| 673 | |
| 674 | fn delete_file(conn: |
| 675 | let path = |
| 676 | backup_before(conn, session_id |
| 677 | std::fs::remove_file |
| 678 | Ok(format! |
| 679 | } |
| 680 | |
| 681 | /// Delete chat sessions after an interactive confirmation dialog. |
| 682 | fn delete_chats( |
| 683 | conn: &Connection |
| 684 | session_id: i64 |
| 685 | args: &Value |
| 686 | ask: Option |
| 687 | ) -> Result<String |
| 688 | let scope = |
| 689 | .get("scope") |
| 690 | .and_then(|v| |
| 691 | .unwrap_or("current") |
| 692 | .to_lowercase(); |
| 693 | let total = |
| 694 | let (message, |
| 695 | "current" => ( |
| 696 | "Clear all messages in this chat?".to_string(), |
| 697 | "clear".to_string(), |
| 698 | ), |
| 699 | "others" => { |
| 700 | let n = |
| 701 | ( |
| 702 | format!("Delete {n |
| 703 | "delete".to_string(), |
| 704 | ) |
| 705 | } |
| 706 | "all" => ( |
| 707 | format!( |
| 708 | "Delete all {total} chat{}?" |
| 709 | if total == 1 |
| 710 | ), |
| 711 | "delete all".to_string(), |
| 712 | ), |
| 713 | other => bail! |
| 714 | }; |
| 715 | |
| 716 | let approved = |
| 717 | Some(ask) |
| 718 | None => { |
| 719 | bail!( |
| 720 | "delete_chats requires an interactive confirmation dialog (not available in this context)" |
| 721 | ) |
| 722 | } |
| 723 | }; |
| 724 | if !approved { |
| 725 | return Ok("Cancelled — no chats were deleted. |
| 726 | } |
| 727 | |
| 728 | match scope.as_str() |
| 729 | "current" => { |
| 730 | let n = |
| 731 | Ok(format! |
| 732 | } |
| 733 | "others" => { |
| 734 | let n = |
| 735 | Ok(format! |
| 736 | } |
| 737 | "all" => { |
| 738 | let others = |
| 739 | let msgs = |
| 740 | Ok(format! |
| 741 | "Deleted {others} other session(s) and cleared {msgs} |
| 742 | )) |
| 743 | } |
| 744 | _ => unreachable! |
| 745 | } |
| 746 | } |
| 747 | |
| 748 | /// Parse a `todos` argument array into typed items, validating the opencode |
| 749 | /// enums (pending/in_progress/completed/cancelled and high/medium/low). |
| 750 | fn parse_todos(args: |
| 751 | let arr = |
| 752 | .get("todos") |
| 753 | .and_then(|v| |
| 754 | .with_context(|| format! |
| 755 | let mut out |
| 756 | for item in arr |
| 757 | let content = |
| 758 | .get("content") |
| 759 | .and_then(|v| |
| 760 | .map(str::to_string |
| 761 | .with_context(|| format! |
| 762 | let status = |
| 763 | .get("status") |
| 764 | .and_then(|v| |
| 765 | .map(str::to_string |
| 766 | .with_context(|| format! |
| 767 | if !matches!( |
| 768 | status.as_str(), |
| 769 | "pending" | "in_progress" |
| 770 | ) { |
| 771 | bail!( |
| 772 | "invalid todo status '{status}': use pending, in_progress, completed, or cancelled" |
| 773 | ); |
| 774 | } |
| 775 | let priority = |
| 776 | .get("priority") |
| 777 | .and_then(|v| |
| 778 | .map(str::to_string |
| 779 | .with_context(|| format! |
| 780 | if !matches!(priority |
| 781 | bail!("invalid todo priority '{priority |
| 782 | } |
| 783 | out.push(db |
| 784 | content, |
| 785 | status, |
| 786 | priority, |
| 787 | }); |
| 788 | } |
| 789 | Ok(out) |
| 790 | } |
| 791 | |
| 792 | /// opencode's `todowrite`: replace the session's todo list and return the |
| 793 | /// pending count plus the updated list as JSON. |
| 794 | fn todo_write(conn: |
| 795 | let todos = |
| 796 | db::set_todos(conn |
| 797 | Ok(render_todos( |
| 798 | } |
| 799 | |
| 800 | /// opencode's `todoread`: return the session's current todo list. |
| 801 | fn todo_read(conn: |
| 802 | let todos = |
| 803 | Ok(render_todos( |
| 804 | } |
| 805 | |
| 806 | /// Text form of opencode's todo ToolResult: the `title` (`N todos` for the |
| 807 | /// items not yet completed) followed by the JSON list. |
| 808 | fn render_todos(todos: |
| 809 | let pending = |
| 810 | let json = |
| 811 | format!("{pending} |
| 812 | } |
| 813 | |
| 814 | /// opencode's `create_goal`: set (or replace) the session's goal. Re-setting |
| 815 | /// the same session's goal with a new objective resets its progress counters, |
| 816 | /// matching opencode's plugin behavior. |
| 817 | fn goal_create(conn: |
| 818 | let objective = |
| 819 | let now = |
| 820 | let goal = |
| 821 | Some(mut |
| 822 | existing.objective = objective |
| 823 | existing.status = crate:: |
| 824 | existing.time_used_seconds = 0; |
| 825 | existing.iteration_count = 0; |
| 826 | existing.time_updated = now |
| 827 | existing |
| 828 | } |
| 829 | None => db |
| 830 | goal_id: uuid::Uuid |
| 831 | objective, |
| 832 | status: crate::goal |
| 833 | time_used_seconds: 0, |
| 834 | iteration_count: 0, |
| 835 | time_created: now, |
| 836 | time_updated: now, |
| 837 | }, |
| 838 | }; |
| 839 | db::set_goal(conn |
| 840 | Ok(format! |
| 841 | "Goal set: {}\n\n{}" |
| 842 | goal.objective, |
| 843 | serde_json::to_string_pretty(&goal |
| 844 | )) |
| 845 | } |
| 846 | |
| 847 | /// opencode's `update_goal`: transition the goal to `complete` or `blocked`. |
| 848 | fn goal_update(conn: |
| 849 | let status = |
| 850 | if status != crate |
| 851 | bail!( |
| 852 | "invalid goal status '{status}': use 'complete' (goal achieved) or 'blocked' (cannot proceed)" |
| 853 | ); |
| 854 | } |
| 855 | match db::get_goal( |
| 856 | Some(mut |
| 857 | goal.status = status |
| 858 | goal.time_updated = crate:: |
| 859 | db::set_goal(conn |
| 860 | Ok(format! |
| 861 | "Goal status updated to: {}\n\n{}" |
| 862 | goal.status, |
| 863 | serde_json::to_string_pretty(&goal |
| 864 | )) |
| 865 | } |
| 866 | None => Ok |
| 867 | } |
| 868 | } |
| 869 | |
| 870 | /// opencode's `get_goal`: report the session's current goal, or that none is set. |
| 871 | fn goal_get(conn: |
| 872 | match db::get_goal( |
| 873 | Some(goal) |
| 874 | let json = |
| 875 | Ok(format! |
| 876 | } |
| 877 | None => Ok |
| 878 | } |
| 879 | } |
| 880 | |
| 881 | fn backup_before(conn: |
| 882 | let abs = |
| 883 | let abs = |
| 884 | let mut backup_path |
| 885 | if kind != " |
| 886 | && let Ok |
| 887 | { |
| 888 | let dir = |
| 889 | let _ = |
| 890 | let name = |
| 891 | let bp = |
| 892 | if std::fs |
| 893 | backup_path = Some |
| 894 | } |
| 895 | } |
| 896 | let _ = |
| 897 | } |
| 898 | |
| 899 | fn basename(path: |
| 900 | std::path::Path |
| 901 | .file_name() |
| 902 | .map(|s| |
| 903 | .unwrap_or_else(|| "file |
| 904 | } |
| 905 | |
| 906 | fn list_files(args: |
| 907 | let dir = |
| 908 | let pattern = |
| 909 | .get("pattern") |
| 910 | .and_then(|v| |
| 911 | .unwrap_or("**/*"); |
| 912 | let full = |
| 913 | let pat_str = |
| 914 | let mut out |
| 915 | let mut count |
| 916 | for entry in glob |
| 917 | match entry { |
| 918 | Ok(p) |
| 919 | out.push_str(&format! |
| 920 | count += 1; |
| 921 | } |
| 922 | _ => {} |
| 923 | } |
| 924 | if count >= 500 |
| 925 | out.push_str("...(truncated) |
| 926 | break; |
| 927 | } |
| 928 | } |
| 929 | if out.is_empty() |
| 930 | out.push_str("(no files matched) |
| 931 | } |
| 932 | Ok(out) |
| 933 | } |
| 934 | |
| 935 | const IGNORED_DIRS: [& |
| 936 | ".git", |
| 937 | ".cache", |
| 938 | "node_modules", |
| 939 | "target", |
| 940 | ".venv", |
| 941 | "venv", |
| 942 | "dist", |
| 943 | "build", |
| 944 | "__pycache__", |
| 945 | ]; |
| 946 | |
| 947 | fn search(args: |
| 948 | let pattern = |
| 949 | let root = |
| 950 | let include = |
| 951 | let re = |
| 952 | let include_pat = |
| 953 | let root_path = |
| 954 | if !root_path.exists |
| 955 | bail!("path not found: {root |
| 956 | } |
| 957 | let mut out |
| 958 | let mut count |
| 959 | let max = |
| 960 | if root_path.is_file() |
| 961 | // Model often passes a single file (e.g. src/llm.rs) — search just that. |
| 962 | search_file(root_path, & |
| 963 | } else if root_path |
| 964 | search_dir( |
| 965 | root_path, |
| 966 | &re, |
| 967 | include_pat.as_ref(), |
| 968 | 0, |
| 969 | &mut out, |
| 970 | &mut count, |
| 971 | max, |
| 972 | )?; |
| 973 | } else { |
| 974 | bail!("not a searchable path: {root |
| 975 | } |
| 976 | if out.is_empty() |
| 977 | out.push_str("(no matches) |
| 978 | } |
| 979 | Ok(out) |
| 980 | } |
| 981 | |
| 982 | /// Scan one text file for regex matches. Skips binary content. |
| 983 | fn search_file( |
| 984 | path: &std:: |
| 985 | re: ®ex:: |
| 986 | out: &mut |
| 987 | count: &mut |
| 988 | max: usize |
| 989 | ) -> Result<() |
| 990 | if *count >= |
| 991 | return Ok(()); |
| 992 | } |
| 993 | let data = |
| 994 | Ok(d) |
| 995 | Err(_) |
| 996 | }; |
| 997 | if data.contains( |
| 998 | return Ok(()); |
| 999 | } |
| 1000 | let text = |
| 1001 | for (i, line |
| 1002 | if re.is_match( |
| 1003 | let trimmed: String |
| 1004 | out.push_str(&format! |
| 1005 | *count += 1; |
| 1006 | if *count >= |
| 1007 | out.push_str("...(truncated) |
| 1008 | return Ok(()); |
| 1009 | } |
| 1010 | } |
| 1011 | } |
| 1012 | Ok(()) |
| 1013 | } |
| 1014 | |
| 1015 | fn search_dir( |
| 1016 | dir: &std:: |
| 1017 | re: ®ex:: |
| 1018 | include_pat: Option |
| 1019 | depth: usize |
| 1020 | out: &mut |
| 1021 | count: &mut |
| 1022 | max: usize |
| 1023 | ) -> Result<() |
| 1024 | if depth > 14 |
| 1025 | return Ok(()); |
| 1026 | } |
| 1027 | for entry in std |
| 1028 | let entry = |
| 1029 | Ok(e) |
| 1030 | Err(_) |
| 1031 | }; |
| 1032 | let path = |
| 1033 | let name = |
| 1034 | if path.is_dir() |
| 1035 | if name.starts_with( |
| 1036 | continue; |
| 1037 | } |
| 1038 | search_dir(&path, |
| 1039 | continue; |
| 1040 | } |
| 1041 | if let Some |
| 1042 | let ok = |
| 1043 | if !ok { |
| 1044 | continue; |
| 1045 | } |
| 1046 | } |
| 1047 | search_file(&path, |
| 1048 | if *count >= |
| 1049 | return Ok(()); |
| 1050 | } |
| 1051 | } |
| 1052 | Ok(()) |
| 1053 | } |
| 1054 | |
| 1055 | fn run_command(args: |
| 1056 | run_command_with_timeout(args, CMD_TIMEOUT |
| 1057 | } |
| 1058 | |
| 1059 | fn run_command_with_timeout(args: |
| 1060 | let cmd = |
| 1061 | let workdir = |
| 1062 | let mut child |
| 1063 | .arg("-c") |
| 1064 | .arg(&cmd) |
| 1065 | .current_dir(workdir) |
| 1066 | .stdin(std::process |
| 1067 | .stdout(std::process |
| 1068 | .stderr(std::process |
| 1069 | .spawn() |
| 1070 | .with_context(|| format! |
| 1071 | let mut stdout |
| 1072 | let mut stderr |
| 1073 | let stdout_reader = |
| 1074 | let mut buf |
| 1075 | let _ = |
| 1076 | buf |
| 1077 | }); |
| 1078 | let stderr_reader = |
| 1079 | let mut buf |
| 1080 | let _ = |
| 1081 | buf |
| 1082 | }); |
| 1083 | let deadline = |
| 1084 | let status = |
| 1085 | if let Some |
| 1086 | break st; |
| 1087 | } |
| 1088 | if Instant::now |
| 1089 | let _ = |
| 1090 | let _ = |
| 1091 | bail!("command timed out after {}s: |
| 1092 | } |
| 1093 | std::thread::sleep |
| 1094 | }; |
| 1095 | let out = |
| 1096 | .join() |
| 1097 | .map_err(|_| |
| 1098 | let err = |
| 1099 | .join() |
| 1100 | .map_err(|_| |
| 1101 | let mut out_s |
| 1102 | if !out.is_empty |
| 1103 | out_s.push_str(&String |
| 1104 | } |
| 1105 | if !err.is_empty |
| 1106 | out_s.push_str(&String |
| 1107 | } |
| 1108 | out_s.push_str(&format! |
| 1109 | Ok(truncate_utf8( |
| 1110 | } |
| 1111 | |
| 1112 | const WEB_TIMEOUT: Duration |
| 1113 | const WEB_USER_AGENT: &str |
| 1114 | const WEB_FETCH_CAP: usize |
| 1115 | const WEB_MCP_TIMEOUT: Duration |
| 1116 | const EXA_MCP_URL: &str |
| 1117 | const PARALLEL_MCP_URL: &str |
| 1118 | |
| 1119 | fn env_key(name: |
| 1120 | std::env::var |
| 1121 | .ok() |
| 1122 | .map(|v| |
| 1123 | .filter(|v| |
| 1124 | } |
| 1125 | |
| 1126 | fn web_search(args: |
| 1127 | let query = |
| 1128 | let num_results = |
| 1129 | .get("num_results") |
| 1130 | .and_then(|v| |
| 1131 | .map(|v| |
| 1132 | .unwrap_or(8); |
| 1133 | let search_type = |
| 1134 | .get("type") |
| 1135 | .and_then(|v| |
| 1136 | .filter(|t| |
| 1137 | .unwrap_or("auto") |
| 1138 | .to_string(); |
| 1139 | let livecrawl = |
| 1140 | .get("livecrawl") |
| 1141 | .and_then(|v| |
| 1142 | .filter(|t| |
| 1143 | .unwrap_or("fallback") |
| 1144 | .to_string(); |
| 1145 | let context_max = |
| 1146 | .get("context_max_characters") |
| 1147 | .and_then(|v| |
| 1148 | .map(|v| |
| 1149 | |
| 1150 | let primary = |
| 1151 | Ok("parallel") |
| 1152 | _ => "exa" |
| 1153 | }; |
| 1154 | let providers: & |
| 1155 | &["exa", "parallel |
| 1156 | } else { |
| 1157 | &["parallel", "exa |
| 1158 | }; |
| 1159 | let mut last_err |
| 1160 | for provider in providers |
| 1161 | match call_web_search( |
| 1162 | provider, |
| 1163 | &query, |
| 1164 | num_results, |
| 1165 | &search_type, |
| 1166 | &livecrawl, |
| 1167 | context_max, |
| 1168 | ) { |
| 1169 | Ok(text) |
| 1170 | Err(e) |
| 1171 | } |
| 1172 | } |
| 1173 | Err(last_err. |
| 1174 | } |
| 1175 | |
| 1176 | fn call_web_search( |
| 1177 | provider: &str |
| 1178 | query: &str |
| 1179 | num_results: usize |
| 1180 | search_type: &str |
| 1181 | livecrawl: &str |
| 1182 | context_max: Option |
| 1183 | ) -> Result<String |
| 1184 | if provider == " |
| 1185 | let args = |
| 1186 | "objective": query, |
| 1187 | "search_queries": [query |
| 1188 | }); |
| 1189 | let headers: Vec |
| 1190 | .map(|key| |
| 1191 | .into_iter() |
| 1192 | .collect(); |
| 1193 | let refs: Vec |
| 1194 | .iter() |
| 1195 | .map(|(k |
| 1196 | .collect(); |
| 1197 | let text = |
| 1198 | Ok(format! |
| 1199 | "Web search results (Parallel Web Search) for: {query}\n{ |
| 1200 | )) |
| 1201 | } else { |
| 1202 | let url = |
| 1203 | Some(key) |
| 1204 | "{EXA_MCP_URL}?exaApiKey={}", |
| 1205 | url::form_urlencoded::byte_serialize |
| 1206 | ), |
| 1207 | None => EXA_MCP_URL |
| 1208 | }; |
| 1209 | let mut args |
| 1210 | args.insert("query |
| 1211 | args.insert("type |
| 1212 | args.insert("numResults |
| 1213 | args.insert("livecrawl |
| 1214 | if let Some |
| 1215 | args.insert("contextMaxCharacters |
| 1216 | } |
| 1217 | let text = |
| 1218 | Ok(format! |
| 1219 | "Web search results (Exa Web Search) for: {query}\n{text |
| 1220 | )) |
| 1221 | } |
| 1222 | } |
| 1223 | |
| 1224 | fn mcp_call(url: |
| 1225 | let body = |
| 1226 | "jsonrpc": "2.0", |
| 1227 | "id": 1, |
| 1228 | "method": "tools/call", |
| 1229 | "params": { "name |
| 1230 | }); |
| 1231 | let mut req |
| 1232 | .timeout(WEB_MCP_TIMEOUT) |
| 1233 | .set("Accept", |
| 1234 | .set("Content-Type", |
| 1235 | .set("User-Agent", |
| 1236 | for (k, v |
| 1237 | req = req. |
| 1238 | } |
| 1239 | let resp = |
| 1240 | let body = |
| 1241 | .into_string() |
| 1242 | .context("web search: invalid response body") |
| 1243 | match parse_mcp_response(&body |
| 1244 | Ok(Some |
| 1245 | Ok(None |
| 1246 | Err(e) |
| 1247 | } |
| 1248 | } |
| 1249 | |
| 1250 | fn clean_search_text(text: |
| 1251 | text.lines() |
| 1252 | .filter(|l| |
| 1253 | .collect::<Vec |
| 1254 | .join("\n" |
| 1255 | .trim() |
| 1256 | .to_string() |
| 1257 | } |
| 1258 | |
| 1259 | fn parse_mcp_response(body: |
| 1260 | let parse_payload = |
| 1261 | let trimmed = |
| 1262 | if !trimmed.starts_with |
| 1263 | return Ok(None |
| 1264 | } |
| 1265 | let val: Value |
| 1266 | serde_json::from_str(trimmed |
| 1267 | if let Some |
| 1268 | let msg = |
| 1269 | .get("message") |
| 1270 | .and_then(|m| |
| 1271 | .map(str::to_string |
| 1272 | .unwrap_or_else(|| err |
| 1273 | bail!("web search provider error: {msg |
| 1274 | } |
| 1275 | let content = |
| 1276 | .get("result") |
| 1277 | .and_then(|r| |
| 1278 | .and_then(|c| |
| 1279 | { |
| 1280 | Some(c) |
| 1281 | None => return |
| 1282 | }; |
| 1283 | for item in content |
| 1284 | if item.get( |
| 1285 | && let Some |
| 1286 | { |
| 1287 | let text = |
| 1288 | if !text.is_empty |
| 1289 | return Ok(Some |
| 1290 | } |
| 1291 | } |
| 1292 | } |
| 1293 | Ok(None |
| 1294 | }; |
| 1295 | if let Some |
| 1296 | return Ok(Some |
| 1297 | } |
| 1298 | for line in body |
| 1299 | let line = |
| 1300 | if let Some |
| 1301 | && let Some |
| 1302 | { |
| 1303 | return Ok(Some |
| 1304 | } |
| 1305 | } |
| 1306 | Ok(None |
| 1307 | } |
| 1308 | |
| 1309 | fn web_fetch(args: |
| 1310 | let raw_url = |
| 1311 | let parsed = |
| 1312 | match parsed.scheme() |
| 1313 | "http" | "https" |
| 1314 | other => bail! |
| 1315 | } |
| 1316 | let resp = |
| 1317 | .timeout(WEB_TIMEOUT) |
| 1318 | .set("User-Agent", |
| 1319 | .set("Accept", |
| 1320 | .call() |
| 1321 | .map_err(|e| |
| 1322 | let body = |
| 1323 | let text = |
| 1324 | let text = |
| 1325 | truncate_utf8(&body, |
| 1326 | } else { |
| 1327 | truncate_utf8(&text, |
| 1328 | }; |
| 1329 | Ok(format! |
| 1330 | } |
| 1331 | |
| 1332 | fn html_to_text(html: |
| 1333 | let mut s |
| 1334 | for tag in [ |
| 1335 | let re = |
| 1336 | s = re. |
| 1337 | } |
| 1338 | let tag_re = |
| 1339 | let s = |
| 1340 | let mut s |
| 1341 | for (ent, ch |
| 1342 | ("&", "&" |
| 1343 | ("<", "<" |
| 1344 | (">", ">" |
| 1345 | (""", "\" |
| 1346 | ("'", "'" |
| 1347 | ("'", "'" |
| 1348 | (" ", " " |
| 1349 | ] { |
| 1350 | s = s. |
| 1351 | } |
| 1352 | let ws_re = |
| 1353 | ws_re.replace_all(&s |
| 1354 | } |
| 1355 | |
| 1356 | #[cfg(test)] |
| 1357 | mod tests { |
| 1358 | use super::*; |
| 1359 | |
| 1360 | fn names(tools: |
| 1361 | tools.iter().map |
| 1362 | } |
| 1363 | |
| 1364 | #[test] |
| 1365 | fn task_tool_is_defined() { |
| 1366 | assert!(definitions() |
| 1367 | } |
| 1368 | |
| 1369 | #[test] |
| 1370 | fn todo_tools_are_defined() { |
| 1371 | let all = |
| 1372 | assert!(all.iter |
| 1373 | assert!(all.iter |
| 1374 | } |
| 1375 | |
| 1376 | #[test] |
| 1377 | fn goal_tools_are_defined() { |
| 1378 | let all = |
| 1379 | assert!(all.iter |
| 1380 | assert!(all.iter |
| 1381 | assert!(all.iter |
| 1382 | } |
| 1383 | |
| 1384 | #[test] |
| 1385 | fn web_tools_are_defined() { |
| 1386 | let all = |
| 1387 | let names = |
| 1388 | assert!(names.contains |
| 1389 | assert!(names.contains |
| 1390 | } |
| 1391 | |
| 1392 | #[test] |
| 1393 | fn general_subagent_gets_all_tools() { |
| 1394 | let tools = |
| 1395 | assert_eq!(names( |
| 1396 | } |
| 1397 | |
| 1398 | #[test] |
| 1399 | fn explore_subagent_is_read_only() { |
| 1400 | let tools = |
| 1401 | let mut names |
| 1402 | names.sort_unstable(); |
| 1403 | assert_eq!( |
| 1404 | names, |
| 1405 | vec![ |
| 1406 | "list_files", |
| 1407 | "read_file", |
| 1408 | "search", |
| 1409 | "task", |
| 1410 | "todoread", |
| 1411 | "web_fetch", |
| 1412 | "web_search" |
| 1413 | ] |
| 1414 | ); |
| 1415 | let task = |
| 1416 | assert!( |
| 1417 | task.description.contains( |
| 1418 | && task.description.contains |
| 1419 | "explore sub-agent's own task tool is read-only\n{}", |
| 1420 | task.description |
| 1421 | ); |
| 1422 | let types = |
| 1423 | .parameters |
| 1424 | .get("properties") |
| 1425 | .and_then(|p| |
| 1426 | .and_then(|s| |
| 1427 | .and_then(|e| |
| 1428 | .unwrap(); |
| 1429 | assert_eq!( |
| 1430 | *types, |
| 1431 | vec![serde_json:: |
| 1432 | "read-only sub-agents only advertise 'explore'" |
| 1433 | ); |
| 1434 | } |
| 1435 | |
| 1436 | #[test] |
| 1437 | fn general_subagent_task_tool_keeps_general_enum() { |
| 1438 | let tools = |
| 1439 | let task = |
| 1440 | let types = |
| 1441 | .parameters |
| 1442 | .get("properties") |
| 1443 | .and_then(|p| |
| 1444 | .and_then(|s| |
| 1445 | .and_then(|e| |
| 1446 | .unwrap(); |
| 1447 | assert!(types.contains |
| 1448 | } |
| 1449 | |
| 1450 | #[test] |
| 1451 | fn web_search_missing_query_errors() { |
| 1452 | assert!(web_search( |
| 1453 | } |
| 1454 | |
| 1455 | #[test] |
| 1456 | fn web_fetch_rejects_non_http_schemes() { |
| 1457 | assert!(web_fetch( |
| 1458 | assert!(web_fetch( |
| 1459 | } |
| 1460 | |
| 1461 | #[test] |
| 1462 | fn web_fetch_invalid_url_errors() { |
| 1463 | assert!(web_fetch( |
| 1464 | } |
| 1465 | |
| 1466 | #[test] |
| 1467 | fn html_to_text_strips_markup() { |
| 1468 | let html = |
| 1469 | assert_eq!(html_to_text( |
| 1470 | } |
| 1471 | |
| 1472 | #[test] |
| 1473 | fn parse_mcp_response_reads_direct_json() { |
| 1474 | let body = |
| 1475 | assert_eq!(parse_mcp_response( |
| 1476 | } |
| 1477 | |
| 1478 | #[test] |
| 1479 | fn parse_mcp_response_reads_sse_lines() { |
| 1480 | let body = |
| 1481 | assert_eq!(parse_mcp_response( |
| 1482 | } |
| 1483 | |
| 1484 | #[test] |
| 1485 | fn parse_mcp_response_surfaces_provider_errors() { |
| 1486 | let body = |
| 1487 | let err = |
| 1488 | assert!(err.to_string |
| 1489 | } |
| 1490 | |
| 1491 | #[test] |
| 1492 | fn parse_mcp_response_returns_none_for_no_results() { |
| 1493 | let body = |
| 1494 | assert!(parse_mcp_response( |
| 1495 | } |
| 1496 | |
| 1497 | #[test] |
| 1498 | fn clean_search_text_drops_ellipsis_separators() { |
| 1499 | let text = |
| 1500 | "Title: Foo\nURL: https://x.dev\nHighlights:\ |
| 1501 | assert_eq!( |
| 1502 | clean_search_text(text), |
| 1503 | "Title: Foo\nURL: https://x.dev\nHighlights:\ |
| 1504 | ); |
| 1505 | } |
| 1506 | |
| 1507 | #[test] |
| 1508 | fn clean_search_text_keeps_embedded_ellipsis() { |
| 1509 | assert_eq!( |
| 1510 | clean_search_text("wait for it... done"), |
| 1511 | "wait for it... done" |
| 1512 | ); |
| 1513 | } |
| 1514 | |
| 1515 | #[test] |
| 1516 | #[ignore] |
| 1517 | fn web_search_reaches_exa_keyless() { |
| 1518 | let out = |
| 1519 | web_search(&json! |
| 1520 | assert!(out.contains |
| 1521 | } |
| 1522 | |
| 1523 | #[test] |
| 1524 | #[ignore] |
| 1525 | fn web_fetch_reads_a_real_page() { |
| 1526 | let out = |
| 1527 | assert!(out.contains |
| 1528 | } |
| 1529 | |
| 1530 | #[test] |
| 1531 | fn run_command_captures_output_and_exit_code() { |
| 1532 | let out = |
| 1533 | assert!(out.contains |
| 1534 | assert!(out.contains |
| 1535 | } |
| 1536 | |
| 1537 | #[test] |
| 1538 | fn run_command_times_out_on_hanging_command() { |
| 1539 | let start = |
| 1540 | let err = |
| 1541 | run_command_with_timeout(&json! |
| 1542 | .unwrap_err(); |
| 1543 | assert!(err.to_string |
| 1544 | assert!( |
| 1545 | start.elapsed() < |
| 1546 | "timeout took too long: {:?}", |
| 1547 | start.elapsed() |
| 1548 | ); |
| 1549 | } |
| 1550 | |
| 1551 | fn temp_file(name: |
| 1552 | let dir = |
| 1553 | let path = |
| 1554 | std::fs::write |
| 1555 | path |
| 1556 | } |
| 1557 | |
| 1558 | #[test] |
| 1559 | fn search_accepts_a_single_file_path() { |
| 1560 | // Regression: agents pass file paths like src/llm.rs; previously we |
| 1561 | // bailed with "not a directory". |
| 1562 | let path = |
| 1563 | "search_one.rs", |
| 1564 | "fn alpha() {}\npub fn print_providers() {}\n |
| 1565 | ); |
| 1566 | let out = |
| 1567 | "pattern": "print_providers|embedded_models", |
| 1568 | "path": path. |
| 1569 | })) |
| 1570 | .unwrap(); |
| 1571 | assert!(out.contains |
| 1572 | assert!(out.contains |
| 1573 | assert!(!out. |
| 1574 | let none = |
| 1575 | "pattern": "definitely_not_present_zzz", |
| 1576 | "path": path. |
| 1577 | })) |
| 1578 | .unwrap(); |
| 1579 | assert!(none.contains |
| 1580 | let _ = |
| 1581 | } |
| 1582 | |
| 1583 | #[test] |
| 1584 | fn search_missing_path_errors_clearly() { |
| 1585 | let err = |
| 1586 | "pattern": "x", |
| 1587 | "path": "/tmp/coding-agent-no-such-path-xyz", |
| 1588 | })) |
| 1589 | .unwrap_err() |
| 1590 | .to_string(); |
| 1591 | assert!(err.contains |
| 1592 | } |
| 1593 | |
| 1594 | #[test] |
| 1595 | fn read_file_small_file_has_line_numbers() { |
| 1596 | let path = |
| 1597 | let out = |
| 1598 | // Anchors on the first line and every 10th line; bare lines between. |
| 1599 | assert_eq!(out, |
| 1600 | let _ = |
| 1601 | } |
| 1602 | |
| 1603 | #[test] |
| 1604 | fn read_file_range_returns_numbered_window() { |
| 1605 | let content: String |
| 1606 | let path = |
| 1607 | let out = |
| 1608 | assert!(out.starts_with |
| 1609 | assert!(out.contains |
| 1610 | assert!(out.contains |
| 1611 | assert!(!out. |
| 1612 | assert!(!out. |
| 1613 | let _ = |
| 1614 | } |
| 1615 | |
| 1616 | #[test] |
| 1617 | fn read_file_big_file_returns_head_tail_window() { |
| 1618 | let content: String |
| 1619 | let path = |
| 1620 | let out = |
| 1621 | assert!(out.starts_with |
| 1622 | assert!(out.contains |
| 1623 | assert!(out.contains |
| 1624 | assert!(out.contains |
| 1625 | assert!( |
| 1626 | out.contains("600→line 600 |
| 1627 | "multiple of 10 anchored\n{out}" |
| 1628 | ); |
| 1629 | let _ = |
| 1630 | } |
| 1631 | |
| 1632 | #[test] |
| 1633 | fn line_diff_marks_single_line_change() { |
| 1634 | let before = |
| 1635 | let after = |
| 1636 | let d = |
| 1637 | assert_eq!(d, |
| 1638 | } |
| 1639 | |
| 1640 | #[test] |
| 1641 | fn line_diff_reports_empty_when_identical() { |
| 1642 | assert_eq!(line_diff( |
| 1643 | assert_eq!(line_diff( |
| 1644 | } |
| 1645 | |
| 1646 | #[test] |
| 1647 | fn line_diff_handles_pure_insertion_at_end() { |
| 1648 | let d = |
| 1649 | assert!(d.contains |
| 1650 | } |
| 1651 | |
| 1652 | #[test] |
| 1653 | fn line_diff_splits_remote_changes_into_hunks() { |
| 1654 | // Two changes separated by more than DIFF_CONTEXT context lines get |
| 1655 | // their own hunk headers with correct line numbers. |
| 1656 | let before = |
| 1657 | let after = |
| 1658 | let d = |
| 1659 | assert!(d.contains |
| 1660 | assert!(d.contains |
| 1661 | assert_eq!(d.lines |
| 1662 | } |
| 1663 | |
| 1664 | #[test] |
| 1665 | fn coarse_diff_ops_prefix_suffix_fallback() { |
| 1666 | let d = |
| 1667 | assert!(d.contains |
| 1668 | } |
| 1669 | |
| 1670 | fn temp_db(name: |
| 1671 | let dir = |
| 1672 | let path = |
| 1673 | "coding-agent-test-{}-{name}.sqlite" |
| 1674 | std::process::id |
| 1675 | )); |
| 1676 | let _ = |
| 1677 | let conn = |
| 1678 | (path, conn) |
| 1679 | } |
| 1680 | |
| 1681 | fn seed_message(conn: |
| 1682 | crate::db::add_message( |
| 1683 | conn, |
| 1684 | session_id, |
| 1685 | role, |
| 1686 | Some(content), |
| 1687 | None, |
| 1688 | None, |
| 1689 | None, |
| 1690 | None, |
| 1691 | None, |
| 1692 | ) |
| 1693 | .unwrap(); |
| 1694 | } |
| 1695 | |
| 1696 | #[test] |
| 1697 | fn delete_chats_declines_without_confirm() { |
| 1698 | let (path, |
| 1699 | let (sid, |
| 1700 | seed_message(&conn, |
| 1701 | let mut ask |
| 1702 | let out = |
| 1703 | assert!(out.contains |
| 1704 | assert_eq!(crate::db |
| 1705 | let _ = |
| 1706 | } |
| 1707 | |
| 1708 | #[test] |
| 1709 | fn delete_chats_clears_current_session_after_confirm() { |
| 1710 | let (path, |
| 1711 | let (sid, |
| 1712 | seed_message(&conn, |
| 1713 | seed_message(&conn, |
| 1714 | seed_message(&conn, |
| 1715 | let mut ask |
| 1716 | let out = |
| 1717 | assert!(out.contains |
| 1718 | // Older turns are wiped; only the current turn's last user survives. |
| 1719 | let msgs = |
| 1720 | assert_eq!(msgs. |
| 1721 | assert_eq!(msgs[0 |
| 1722 | assert_eq!(msgs[0 |
| 1723 | let _ = |
| 1724 | } |
| 1725 | |
| 1726 | #[test] |
| 1727 | fn delete_chats_keeps_in_flight_turn_so_the_loop_can_finish() { |
| 1728 | let (path, |
| 1729 | let (sid, |
| 1730 | seed_message(&conn, |
| 1731 | seed_message( |
| 1732 | &conn, |
| 1733 | sid, |
| 1734 | "assistant", |
| 1735 | "{\"tool_calls\":[{\ |
| 1736 | ); |
| 1737 | seed_message(&conn, |
| 1738 | let mut ask |
| 1739 | let out = |
| 1740 | assert!(out.contains |
| 1741 | // The last user + last assistant (the tool-call message) survive, so |
| 1742 | // the tool result that follows stays attached to a real conversation. |
| 1743 | let msgs = |
| 1744 | assert_eq!(msgs. |
| 1745 | assert_eq!(msgs[0 |
| 1746 | assert_eq!(msgs[1 |
| 1747 | let _ = |
| 1748 | } |
| 1749 | |
| 1750 | #[test] |
| 1751 | fn delete_chats_removes_only_other_sessions() { |
| 1752 | let (path, |
| 1753 | let (mine, |
| 1754 | let (other, |
| 1755 | seed_message(&conn, |
| 1756 | seed_message(&conn, |
| 1757 | let mut ask |
| 1758 | let out = |
| 1759 | assert!(out.contains |
| 1760 | assert_eq!(crate::db |
| 1761 | let sessions = |
| 1762 | assert_eq!(sessions. |
| 1763 | assert_eq!(sessions[ |
| 1764 | let _ = |
| 1765 | } |
| 1766 | |
| 1767 | #[test] |
| 1768 | fn delete_chats_requires_ask_in_non_interactive_context() { |
| 1769 | let (path, |
| 1770 | let (sid, |
| 1771 | let err = |
| 1772 | .unwrap_err() |
| 1773 | .to_string(); |
| 1774 | assert!(err.contains |
| 1775 | let _ = |
| 1776 | } |
| 1777 | |
| 1778 | #[test] |
| 1779 | fn todo_write_round_trips_and_renders_opencode_shape() { |
| 1780 | let (path, |
| 1781 | let (sid, |
| 1782 | let todos = |
| 1783 | {"content": "Implement slice" |
| 1784 | {"content": "Add dark mode toggle" |
| 1785 | ]); |
| 1786 | let out = |
| 1787 | assert!(out.starts_with |
| 1788 | assert!(out.contains |
| 1789 | let stored = |
| 1790 | assert_eq!(stored. |
| 1791 | assert_eq!(stored[0 |
| 1792 | assert_eq!(stored[0 |
| 1793 | let _ = |
| 1794 | } |
| 1795 | |
| 1796 | #[test] |
| 1797 | fn todo_write_title_counts_only_incomplete() { |
| 1798 | let (path, |
| 1799 | let (sid, |
| 1800 | let out = |
| 1801 | &conn, |
| 1802 | sid, |
| 1803 | &json!({"todos" |
| 1804 | {"content": "done" |
| 1805 | {"content": "todo" |
| 1806 | ]}), |
| 1807 | ) |
| 1808 | .unwrap(); |
| 1809 | assert!(out.starts_with |
| 1810 | let _ = |
| 1811 | } |
| 1812 | |
| 1813 | #[test] |
| 1814 | fn todo_read_returns_persisted_list() { |
| 1815 | let (path, |
| 1816 | let (sid, |
| 1817 | let out = |
| 1818 | assert!(out.starts_with |
| 1819 | todo_write( |
| 1820 | &conn, |
| 1821 | sid, |
| 1822 | &json!({"todos" |
| 1823 | ) |
| 1824 | .unwrap(); |
| 1825 | let out = |
| 1826 | assert!(out.starts_with |
| 1827 | assert!(out.contains |
| 1828 | let _ = |
| 1829 | } |
| 1830 | |
| 1831 | #[test] |
| 1832 | fn todo_write_validates_status_and_priority() { |
| 1833 | let (path, |
| 1834 | let (sid, |
| 1835 | let err = |
| 1836 | &conn, |
| 1837 | sid, |
| 1838 | &json!({"todos" |
| 1839 | ) |
| 1840 | .unwrap_err() |
| 1841 | .to_string(); |
| 1842 | assert!(err.contains |
| 1843 | let err = |
| 1844 | &conn, |
| 1845 | sid, |
| 1846 | &json!({"todos" |
| 1847 | ) |
| 1848 | .unwrap_err() |
| 1849 | .to_string(); |
| 1850 | assert!(err.contains |
| 1851 | assert!(crate::db |
| 1852 | let _ = |
| 1853 | } |
| 1854 | |
| 1855 | #[test] |
| 1856 | fn todo_write_requires_todos_argument() { |
| 1857 | let (path, |
| 1858 | let (sid, |
| 1859 | let err = |
| 1860 | assert!(err.contains |
| 1861 | let _ = |
| 1862 | } |
| 1863 | |
| 1864 | #[test] |
| 1865 | fn goal_create_sets_an_active_goal() { |
| 1866 | let (path, |
| 1867 | let (sid, |
| 1868 | let out = |
| 1869 | assert!(out.contains |
| 1870 | let stored = |
| 1871 | assert_eq!(stored. |
| 1872 | assert_eq!(stored. |
| 1873 | assert_eq!(stored. |
| 1874 | assert!(!stored. |
| 1875 | let _ = |
| 1876 | } |
| 1877 | |
| 1878 | #[test] |
| 1879 | fn goal_create_replaces_and_resets_progress() { |
| 1880 | let (path, |
| 1881 | let (sid, |
| 1882 | goal_create(&conn, |
| 1883 | let mut g |
| 1884 | g.status = "paused |
| 1885 | g.iteration_count = 7; |
| 1886 | g.time_used_seconds = 999; |
| 1887 | crate::db::set_goal( |
| 1888 | let out = |
| 1889 | assert!(out.contains |
| 1890 | let stored = |
| 1891 | assert_eq!(stored. |
| 1892 | assert_eq!(stored. |
| 1893 | assert_eq!(stored. |
| 1894 | assert_eq!(stored. |
| 1895 | let _ = |
| 1896 | } |
| 1897 | |
| 1898 | #[test] |
| 1899 | fn goal_create_requires_objective() { |
| 1900 | let (path, |
| 1901 | let (sid, |
| 1902 | let err = |
| 1903 | assert!(err.contains |
| 1904 | assert!(crate::db |
| 1905 | let _ = |
| 1906 | } |
| 1907 | |
| 1908 | #[test] |
| 1909 | fn goal_update_transitions_status() { |
| 1910 | let (path, |
| 1911 | let (sid, |
| 1912 | goal_create(&conn, |
| 1913 | let out = |
| 1914 | assert!(out.contains |
| 1915 | assert_eq!( |
| 1916 | crate::db::get_goal( |
| 1917 | "complete" |
| 1918 | ); |
| 1919 | let _ = |
| 1920 | } |
| 1921 | |
| 1922 | #[test] |
| 1923 | fn goal_update_rejects_unknown_status() { |
| 1924 | let (path, |
| 1925 | let (sid, |
| 1926 | goal_create(&conn, |
| 1927 | let err = |
| 1928 | .unwrap_err() |
| 1929 | .to_string(); |
| 1930 | assert!(err.contains |
| 1931 | assert_eq!( |
| 1932 | crate::db::get_goal( |
| 1933 | "active" |
| 1934 | ); |
| 1935 | let _ = |
| 1936 | } |
| 1937 | |
| 1938 | #[test] |
| 1939 | fn goal_update_without_goal_is_a_noop() { |
| 1940 | let (path, |
| 1941 | let (sid, |
| 1942 | let out = |
| 1943 | assert_eq!(out, |
| 1944 | let _ = |
| 1945 | } |
| 1946 | |
| 1947 | #[test] |
| 1948 | fn goal_get_reports_state_or_none() { |
| 1949 | let (path, |
| 1950 | let (sid, |
| 1951 | let out = |
| 1952 | assert!(out.contains |
| 1953 | goal_create(&conn, |
| 1954 | let out = |
| 1955 | assert!(out.contains |
| 1956 | assert!(out.contains |
| 1957 | assert!(out.contains |
| 1958 | let _ = |
| 1959 | } |
| 1960 | } |