//! ACP conversion functions for `kigi-tools`'s `ToolOutput`. //! //! These standalone functions convert `kigi_tools::types::output::ToolOutput` //! into ACP protocol types (`acp::ToolCallUpdate`, `acp::Plan`). //! //! `raw_output` is serialized directly from ToolOutput via serde — no manual JSON //! construction. The TUI deserializes it back into the same ToolOutput type, so //! field names must match exactly. Path relativization for display happens on the //! TUI side (which already has `base_path` for this purpose). use std::path::{Path, PathBuf}; use std::sync::Arc; use agent_client_protocol as acp; use kigi_tool_types::{KillTaskOutput, TaskOutputOutput}; use kigi_tools::types::output::{ ApplyPatchOutput, CodexGrepFilesOutput, ListDirOutput, MCPOutputDetails, ReadFileOutput, SearchReplaceEditContextInformation, SearchReplaceEditDetail, SearchReplaceOutput, ToolOutput, }; /// Rewrites real worktree paths to display paths in serialized output. /// /// In forked sessions, tools produce output containing the worktree /// directory (e.g., `/root/.kigi/worktrees/project/fork-019cb252-...`). The /// client UI should instead see the original project path (the `display_cwd`). #[derive(Clone, Debug)] pub struct PathRewriter { /// The real worktree path (what tools actually see). real_cwd: String, /// The display path (what the client UI should see). display_cwd: String, } impl PathRewriter { /// Create a new `PathRewriter` if `display_cwd` differs from `real_cwd`. /// /// Returns `None` if the paths are the same (no rewriting needed) or if /// `display_cwd` is not set. pub fn new(real_cwd: &str, display_cwd: Option<&str>) -> Option { let display_cwd = display_cwd?; if real_cwd == display_cwd { return None; } Some(Self { real_cwd: real_cwd.to_string(), display_cwd: display_cwd.to_string(), }) } /// Rewrite all occurrences of the real worktree path with the display path. /// /// Handles both plain paths (e.g., `/root/.kigi/worktrees/project/fork-...`) /// and URL-encoded paths (e.g., `%2Froot%2F.kigi%2Fworktrees%2F...`) that /// appear in session directory structures and `output_file` references. pub fn rewrite(&self, text: &str) -> String { let plain = text.replace(&self.real_cwd, &self.display_cwd); // Also replace URL-encoded form — session directory paths use // urlencoding::encode(&cwd) as a path component, so background task // output_file paths and similar references contain encoded overlay cwd. let encoded_real = urlencoding::encode(&self.real_cwd); if plain.contains(encoded_real.as_ref()) { let encoded_display = urlencoding::encode(&self.display_cwd); plain.replace(encoded_real.as_ref(), encoded_display.as_ref()) } else { plain } } /// Rewrite a `PathBuf` if it starts with the real worktree path. pub fn rewrite_path(&self, path: &Path) -> PathBuf { match path.strip_prefix(&self.real_cwd) { Ok(relative) => PathBuf::from(&self.display_cwd).join(relative), Err(_) => path.to_path_buf(), } } /// Rewrite a `serde_json::Value` by replacing paths in the serialized JSON string. /// /// Serialize to string, replace (plain + URL-encoded), re-parse. Catches /// paths embedded anywhere in the JSON tree without needing to walk the /// structure. Reuses `rewrite()` so both plain and encoded replacements /// are applied consistently. pub fn rewrite_json(&self, value: serde_json::Value) -> serde_json::Value { let serialized = value.to_string(); let rewritten = self.rewrite(&serialized); if rewritten == serialized { return value; } serde_json::from_str(&rewritten).unwrap_or(value) } } /// Rewrite a string if a rewriter is present, otherwise return it unchanged. pub(crate) fn maybe_rewrite(rewriter: Option<&PathRewriter>, text: String) -> String { match rewriter { Some(rw) => rw.rewrite(&text), None => text, } } /// Rewrite a path if a rewriter is present, otherwise return it unchanged. fn maybe_rewrite_path(rewriter: Option<&PathRewriter>, path: PathBuf) -> PathBuf { match rewriter { Some(rw) => rw.rewrite_path(&path), None => path, } } /// Serialize ToolOutput to JSON for the `raw_output` field in ACP updates. /// /// Uses serde directly — ToolOutput derives Serialize with `#[serde(tag = "type")]`, /// so the JSON round-trips cleanly with the TUI's deserialization. pub fn raw_output_json( output: &ToolOutput, rewriter: Option<&PathRewriter>, ) -> Option { let value = serde_json::to_value(output).ok()?; Some(match rewriter { Some(rw) => rw.rewrite_json(value), None => value, }) } /// Convert tool output to an ACP `ToolCallUpdate` for rich TUI rendering. /// /// `Todo` output returns a minimal `Completed` update (the richer rendering /// goes through `acp_plan_update` as a `Plan` notification). /// /// `tool_meta` is attached as `_meta` on the update for MCP tools that have /// MCP Apps UI metadata (e.g., `_meta.ui.resourceUri`). This allows clients /// to render interactive UIs without maintaining a separate metadata store. pub fn acp_tool_update( output: &ToolOutput, tool_call_id: &str, rewriter: Option<&PathRewriter>, tool_meta: Option, ) -> Option { match output { ToolOutput::ReadFile(read_file_output) => { let (content, status) = match read_file_output { ReadFileOutput::FileContent(file_content) => { let content = Some(vec![acp::ToolCallContent::from(acp::ContentBlock::Text( acp::TextContent::new(file_content.content.clone()), ))]); (content, acp::ToolCallStatus::Completed) } ReadFileOutput::FileNotFound(error_msg) | ReadFileOutput::IsADirectory(error_msg) | ReadFileOutput::PermissionDenied(error_msg) | ReadFileOutput::FileTooLarge(error_msg) | ReadFileOutput::FileReadError(error_msg) => { let content = Some(vec![acp::ToolCallContent::from(acp::ContentBlock::Text( acp::TextContent::new(error_msg.clone()), ))]); (content, acp::ToolCallStatus::Failed) } ReadFileOutput::ImageContent(image_content) => { // Construct the ACP `ImageContent` directly from the // tool's local image type rather than going through a // `From` impl on the tools crate -- that lets // `kigi-tools` stay free of an // `agent-client-protocol` dependency. let content = Some(vec![acp::ToolCallContent::from(acp::ContentBlock::Image( acp::ImageContent::new( image_content.data.clone(), image_content.mime_type.clone(), ) .uri(image_content.uri.clone()) .meta( image_content .meta .clone() .and_then(|v| v.as_object().cloned()), ), ))]); (content, acp::ToolCallStatus::Completed) } ReadFileOutput::ImageSizeError(error_msg) => { let content = Some(vec![acp::ToolCallContent::from(acp::ContentBlock::Text( acp::TextContent::new(error_msg.clone()), ))]); (content, acp::ToolCallStatus::Failed) } ReadFileOutput::PdfPageImages(pdf) => { let blocks: Vec = pdf .pages .iter() .map(|page| { acp::ToolCallContent::from(acp::ContentBlock::Image( acp::ImageContent::new(page.data.clone(), page.mime_type.clone()), )) }) .collect(); (Some(blocks), acp::ToolCallStatus::Completed) } }; Some(acp::ToolCallUpdate::new( acp::ToolCallId::new(Arc::from(tool_call_id)), acp::ToolCallUpdateFields::new() .status(Some(status)) .content(content) .raw_output(raw_output_json(output, rewriter)), )) } ToolOutput::ListDir(list_dir_output) => { let status = if matches!(list_dir_output, ListDirOutput::Content(_)) { acp::ToolCallStatus::Completed } else { acp::ToolCallStatus::Failed }; Some(acp::ToolCallUpdate::new( acp::ToolCallId::new(Arc::from(tool_call_id)), acp::ToolCallUpdateFields::new() .status(Some(status)) .raw_output(raw_output_json(output, rewriter)), )) } ToolOutput::SearchReplace(search_replace_output) => { let (content, status) = match search_replace_output { SearchReplaceOutput::EditsApplied(edits_applied) => { // Use absolute path for Diff content (TUI file opening). let diff_path = maybe_rewrite_path(rewriter, edits_applied.absolute_path.clone()); let content = Some(vec![acp::ToolCallContent::from( acp::Diff::new(diff_path, edits_applied.new_string.clone()) .old_text(Some(edits_applied.old_string.clone())) .meta( serde_json::to_value(&edits_applied.edits) .ok() .and_then(|v| v.as_object().cloned()), ), )]); (content, acp::ToolCallStatus::Completed) } SearchReplaceOutput::NoMatchesFound(e) => { let content = Some(vec![acp::ToolCallContent::from(acp::ContentBlock::Text( acp::TextContent::new(e.message.clone()), ))]); (content, acp::ToolCallStatus::Failed) } SearchReplaceOutput::FileAlreadyExists(msg) | SearchReplaceOutput::MultipleMatchesFound(msg) | SearchReplaceOutput::InvalidInput(msg) | SearchReplaceOutput::FileNotFound(msg) | SearchReplaceOutput::FilenameTooLong(msg) => { let content = Some(vec![acp::ToolCallContent::from(acp::ContentBlock::Text( acp::TextContent::new(msg.clone()), ))]); (content, acp::ToolCallStatus::Failed) } }; Some(acp::ToolCallUpdate::new( acp::ToolCallId::new(Arc::from(tool_call_id)), acp::ToolCallUpdateFields::new() .status(Some(status)) .content(content) .raw_output(raw_output_json(output, rewriter)), )) } ToolOutput::Bash(bash_output) => { let is_backgrounded = bash_output.signal.as_deref() == Some("backgrounded"); let is_failure = bash_output.timed_out || (bash_output.signal.is_some() && !is_backgrounded); let status = if is_backgrounded { None } else if is_failure { Some(acp::ToolCallStatus::Failed) } else { Some(acp::ToolCallStatus::Completed) }; let text = String::from_utf8_lossy(&bash_output.output).to_string(); let text = maybe_rewrite(rewriter, text); Some(acp::ToolCallUpdate::new( acp::ToolCallId::new(Arc::from(tool_call_id)), acp::ToolCallUpdateFields::new() .status(status) .content(Some(vec![acp::ToolCallContent::from( acp::ContentBlock::Text(acp::TextContent::new(text)), )])) .raw_output(raw_output_json(output, rewriter)), )) } ToolOutput::GrepSearch(grep_search_output) => Some(acp::ToolCallUpdate::new( acp::ToolCallId::new(Arc::from(tool_call_id)), acp::ToolCallUpdateFields::new() .status(Some(acp::ToolCallStatus::Completed)) .content(Some(vec![acp::ToolCallContent::from( acp::ContentBlock::Text(acp::TextContent::new(format!( "found {} matches", grep_search_output.match_count ))), )])) .raw_output(raw_output_json(output, rewriter)), )), ToolOutput::WebSearch(_) => Some(acp::ToolCallUpdate::new( acp::ToolCallId::new(Arc::from(tool_call_id)), acp::ToolCallUpdateFields::new() .status(Some(acp::ToolCallStatus::Completed)) .raw_output(raw_output_json(output, rewriter)), )), // Web fetch output is converted to text content for the model. // Success (Content) → Completed; errors (DomainNotAllowed, CrossHostRedirect) → Failed. // This matches the pattern used by ReadFile, ListDir, and SearchReplace. ToolOutput::WebFetch(web_fetch_output) => { use kigi_tools::types::output::WebFetchOutput; let status = match web_fetch_output { WebFetchOutput::Content(_) => acp::ToolCallStatus::Completed, WebFetchOutput::DomainNotAllowed(_) | WebFetchOutput::CrossHostRedirect { .. } | WebFetchOutput::Error { .. } => acp::ToolCallStatus::Failed, }; let text = web_fetch_output.to_prompt_format(); Some(acp::ToolCallUpdate::new( acp::ToolCallId::new(Arc::from(tool_call_id)), acp::ToolCallUpdateFields::new() .status(Some(status)) .content(Some(vec![acp::ToolCallContent::from( acp::ContentBlock::Text(acp::TextContent::new(text)), )])) .raw_output(raw_output_json(output, rewriter)), )) } // Todo also sends a Plan notification (see acp_plan_update), but we still // need to complete the tool call so the TUI flushes pending agent messages // and avoids concatenating text across tool-call boundaries. // // Error variants (e.g., DuplicateId) get `Failed` status so the Python // side can distinguish tool-logic errors from infra errors via raw_output. ToolOutput::Todo(todo_output) => { use kigi_tools::types::output::TodoWriteOutput; let (status, content) = match todo_output { TodoWriteOutput::TodosUpdated(_) => (acp::ToolCallStatus::Completed, None), TodoWriteOutput::DuplicateId(msg) | TodoWriteOutput::InvalidArgument(msg) => ( acp::ToolCallStatus::Failed, Some(vec![acp::ToolCallContent::from(acp::ContentBlock::Text( acp::TextContent::new(msg.clone()), ))]), ), }; Some(acp::ToolCallUpdate::new( acp::ToolCallId::new(Arc::from(tool_call_id)), acp::ToolCallUpdateFields::new() .status(Some(status)) .content(content) .raw_output(raw_output_json(output, rewriter)), )) } ToolOutput::MCP(mcp_output) => Some( acp::ToolCallUpdate::new( acp::ToolCallId::new(Arc::from(tool_call_id)), acp::ToolCallUpdateFields::new() .status(Some( if matches!(mcp_output.output(), MCPOutputDetails::Error(_)) { acp::ToolCallStatus::Failed } else { acp::ToolCallStatus::Completed }, )) .raw_output(raw_output_json(output, rewriter)), ) .meta(tool_meta.and_then(|v| serde_json::from_value(v).ok())), ), ToolOutput::BackgroundTaskStarted(bg) => { let short_id = if bg.task_id.len() > 8 { &bg.task_id[..8] } else { &bg.task_id }; let title = maybe_rewrite(rewriter, format!("[bg] {} ({})", bg.command, short_id)); Some(acp::ToolCallUpdate::new( acp::ToolCallId::new(Arc::from(tool_call_id)), acp::ToolCallUpdateFields::new() .status(Some(acp::ToolCallStatus::Completed)) .content(Some(vec![acp::ToolCallContent::from( acp::ContentBlock::Text(acp::TextContent::new(bg.summary.clone())), )])) .raw_output(raw_output_json(output, rewriter)) .title(Some(title)), )) } ToolOutput::TaskOutput(task_output) => { let (status, title) = match task_output { TaskOutputOutput::Result(r) => { let short_id = if r.task_id.len() > 8 { &r.task_id[..8] } else { &r.task_id }; ( acp::ToolCallStatus::Completed, maybe_rewrite(rewriter, format!("{} ({})", r.command, short_id)), ) } TaskOutputOutput::TaskNotFound(_) => { (acp::ToolCallStatus::Failed, "task not found".to_string()) } TaskOutputOutput::MultiResult(mr) => ( acp::ToolCallStatus::Completed, format!("multi-wait ({})", mr.mode), ), }; Some(acp::ToolCallUpdate::new( acp::ToolCallId::new(Arc::from(tool_call_id)), acp::ToolCallUpdateFields::new() .status(Some(status)) .raw_output(raw_output_json(output, rewriter)) .title(Some(title)), )) } ToolOutput::KillTask(kill_output) => { let (status, title) = match kill_output { KillTaskOutput::Result(r) => { let short_id = if r.task_id.len() > 8 { &r.task_id[..8] } else { &r.task_id }; ( acp::ToolCallStatus::Completed, format!("kill {} ({})", short_id, r.outcome), ) } KillTaskOutput::TaskNotFound(_) => { (acp::ToolCallStatus::Failed, "task not found".to_string()) } }; Some(acp::ToolCallUpdate::new( acp::ToolCallId::new(Arc::from(tool_call_id)), acp::ToolCallUpdateFields::new() .status(Some(status)) .raw_output(raw_output_json(output, rewriter)) .title(Some(title)), )) } ToolOutput::Skill(skill_output) => { let status = if skill_output.success { acp::ToolCallStatus::Completed } else { acp::ToolCallStatus::Failed }; let title = if let Some(ref error) = skill_output.error { format!("Skill: {} - {}", skill_output.skill_name, error) } else { format!("Skill: {}", skill_output.skill_name) }; Some(acp::ToolCallUpdate::new( acp::ToolCallId::new(Arc::from(tool_call_id)), acp::ToolCallUpdateFields::new() .status(Some(status)) .content(Some(vec![acp::ToolCallContent::from( acp::ContentBlock::Text(acp::TextContent::new( skill_output.tool_result.clone(), )), )])) .raw_output(raw_output_json(output, rewriter)) .title(Some(title)), )) } ToolOutput::ApplyPatch(apply_patch_output) => { let (content, status) = match apply_patch_output { ApplyPatchOutput::Success { files, .. } => { // Send one acp::Diff per affected file — mirrors the // SearchReplace pattern so the TUI can render inline diffs. let content: Vec = files .iter() .map(|f| { let old = f.old_text.as_deref().unwrap_or(""); let new = f.new_text.as_str(); let edits = build_apply_patch_edit_details(old, new); let diff_path = maybe_rewrite_path( rewriter, f.move_to.clone().unwrap_or_else(|| f.path.clone()), ); acp::ToolCallContent::from( acp::Diff::new(diff_path, f.new_text.clone()) .old_text(f.old_text.clone()) .meta( serde_json::to_value(&edits) .ok() .and_then(|v| v.as_object().cloned()), ), ) }) .collect(); (Some(content), acp::ToolCallStatus::Completed) } ApplyPatchOutput::ParseError(msg) | ApplyPatchOutput::ApplicationError(msg) | ApplyPatchOutput::EmptyPatch(msg) => { let content = Some(vec![acp::ToolCallContent::from(acp::ContentBlock::Text( acp::TextContent::new(msg.clone()), ))]); (content, acp::ToolCallStatus::Failed) } }; Some(acp::ToolCallUpdate::new( acp::ToolCallId::new(Arc::from(tool_call_id)), acp::ToolCallUpdateFields::new() .status(Some(status)) .content(content) .raw_output(raw_output_json(output, rewriter)), )) } ToolOutput::CodexGrepFiles(grep_files_output) => { let status = match grep_files_output { CodexGrepFilesOutput::Matches { .. } | CodexGrepFilesOutput::NoMatches(_) => { acp::ToolCallStatus::Completed } CodexGrepFilesOutput::Error(_) => acp::ToolCallStatus::Failed, }; Some(acp::ToolCallUpdate::new( acp::ToolCallId::new(Arc::from(tool_call_id)), acp::ToolCallUpdateFields::new() .status(Some(status)) .raw_output(raw_output_json(output, rewriter)), )) } ToolOutput::SearchTool(out) => Some(acp::ToolCallUpdate::new( acp::ToolCallId::new(Arc::from(tool_call_id)), acp::ToolCallUpdateFields::new() .status(Some(acp::ToolCallStatus::Completed)) .content(Some(vec![acp::ToolCallContent::from( acp::ContentBlock::Text(acp::TextContent::new(out.content.clone())), )])) .raw_output(raw_output_json(output, rewriter)), )), ToolOutput::Text(text) => Some(acp::ToolCallUpdate::new( acp::ToolCallId::new(Arc::from(tool_call_id)), acp::ToolCallUpdateFields::new() .status(Some(acp::ToolCallStatus::Completed)) .content(Some(vec![acp::ToolCallContent::from( acp::ContentBlock::Text(acp::TextContent::new(text.text.clone())), )])) .raw_output(raw_output_json(output, rewriter)), )), ToolOutput::SubagentCompleted(sub) => { // Text includes resume handle for discoverability + meta for TUI. // Shared with the chat-bidi server via `to_model_text` so both // surfaces present a completed subagent identically. let content = Some(vec![acp::ToolCallContent::from(acp::ContentBlock::Text( acp::TextContent::new(sub.to_model_text()), ))]); Some(acp::ToolCallUpdate::new( acp::ToolCallId::new(Arc::from(tool_call_id)), acp::ToolCallUpdateFields::new() .status(Some(acp::ToolCallStatus::Completed)) .content(content) .raw_output(raw_output_json(output, rewriter)), )) } ToolOutput::AskUserQuestion(ask) => { let message = match ask { kigi_tools::types::output::AskUserQuestionOutput::UserAnswered { message } => { message.clone() } kigi_tools::types::output::AskUserQuestionOutput::QuestionsSent { message, .. } => message.clone(), }; Some(acp::ToolCallUpdate::new( acp::ToolCallId::new(Arc::from(tool_call_id)), acp::ToolCallUpdateFields::new() .status(Some(acp::ToolCallStatus::Completed)) .content(Some(vec![acp::ToolCallContent::from( acp::ContentBlock::Text(acp::TextContent::new(message)), )])) .raw_output(raw_output_json(output, rewriter)), )) } ToolOutput::EnterPlanMode(enter) => { let message = match enter { kigi_tools::types::output::EnterPlanModeOutput::Entered { message, .. } => { message.clone() } }; Some(acp::ToolCallUpdate::new( acp::ToolCallId::new(Arc::from(tool_call_id)), acp::ToolCallUpdateFields::new() .status(Some(acp::ToolCallStatus::Completed)) .title(Some("Plan mode entered".to_string())) .content(Some(vec![acp::ToolCallContent::from( acp::ContentBlock::Text(acp::TextContent::new(message)), )])) .raw_output(raw_output_json(output, rewriter)), )) } ToolOutput::ExitPlanMode(exit) => { let message = match exit { kigi_tools::types::output::ExitPlanModeOutput::PlanReady { message, .. } => { message.clone() } kigi_tools::types::output::ExitPlanModeOutput::EmptyPlan { message, .. } => { message.clone() } }; Some(acp::ToolCallUpdate::new( acp::ToolCallId::new(Arc::from(tool_call_id)), acp::ToolCallUpdateFields::new() .status(Some(acp::ToolCallStatus::Completed)) .title(Some("Plan mode exited".to_string())) .content(Some(vec![acp::ToolCallContent::from( acp::ContentBlock::Text(acp::TextContent::new(message)), )])) .raw_output(raw_output_json(output, rewriter)), )) } ToolOutput::UpdateGoal(_) | ToolOutput::Monitor(_) | ToolOutput::SchedulerCreate(_) | ToolOutput::SchedulerDelete(_) | ToolOutput::SchedulerList(_) => Some(acp::ToolCallUpdate::new( acp::ToolCallId::new(Arc::from(tool_call_id)), acp::ToolCallUpdateFields::new() .status(Some(acp::ToolCallStatus::Completed)) .raw_output(raw_output_json(output, rewriter)), )), // Internal tools (open_page, browse_page, etc.) are not used in the // shell — they are server-only. This arm covers variants that appear // when Cargo unifies the optional web-tools feature across the workspace. #[allow(unreachable_patterns)] _ => None, } } /// Convert a `Todo` tool output to an ACP `Plan` notification. /// /// Returns `None` for non-Todo outputs. /// /// This converts `kigi-tools`' TodoItem (which has `id`, `content: Option`, /// `status: Option`) to `acp::PlanEntry` (which has `content`, `priority`, `status`). /// The `id` is not directly represented in `PlanEntry` but the ordering is preserved. pub fn acp_plan_update(output: &ToolOutput) -> Option { use crate::tools::todo::plan_entry_from_todo_item; use kigi_tools::types::output::TodoWriteOutput; match output { ToolOutput::Todo(TodoWriteOutput::TodosUpdated(success)) => { let entries = success .todos .iter() .cloned() .map(plan_entry_from_todo_item) .collect(); Some(acp::Plan::new(entries)) } // Error variants (DuplicateId, etc.) don't produce Plan updates. _ => None, } } /// Build `SearchReplaceEditContextInformation` from full old/new file content /// for an apply_patch file result. Extracts contiguous changed regions so the /// renderer gets accurate line numbers, old/new strings, and surrounding context. fn build_apply_patch_edit_details( old_content: &str, new_content: &str, ) -> SearchReplaceEditContextInformation { const CONTEXT_LINES: usize = 3; let old_lines: Vec<&str> = old_content.lines().collect(); let new_lines: Vec<&str> = new_content.lines().collect(); // Walk both line arrays to find contiguous changed regions. let mut details = Vec::new(); let max_len = old_lines.len().max(new_lines.len()); let mut i = 0; while i < max_len { let old_line = old_lines.get(i).copied(); let new_line = new_lines.get(i).copied(); if old_line != new_line { // Found start of a changed region. Scan forward to find the end. let region_start = i; let mut old_end = i; let mut new_end = i; while old_end < old_lines.len() || new_end < new_lines.len() { let ol = old_lines.get(old_end).copied(); let nl = new_lines.get(new_end).copied(); if ol == nl { break; } if old_end < old_lines.len() { old_end += 1; } if new_end < new_lines.len() { new_end += 1; } } let old_string = old_lines[region_start..old_end].join("\n"); let new_string = new_lines[region_start..new_end].join("\n"); // Context before: up to CONTEXT_LINES lines before the change. let ctx_before_start = region_start.saturating_sub(CONTEXT_LINES); let context_before = old_lines[ctx_before_start..region_start].join("\n"); // Context after: up to CONTEXT_LINES lines after the change. let ctx_after_end = (old_end + CONTEXT_LINES).min(old_lines.len()); let context_after = old_lines[old_end..ctx_after_end].join("\n"); details.push(SearchReplaceEditDetail { old_string, old_line: region_start + 1, // 1-based new_string, new_line: region_start + 1, // 1-based context_before, context_after, line_prefix: String::new(), }); i = old_end.max(new_end); } else { i += 1; } } // If no differences found (e.g., add or delete), create a single entry // covering the whole content. if details.is_empty() { details.push(SearchReplaceEditDetail { old_string: old_content.to_string(), old_line: 1, new_string: new_content.to_string(), new_line: 1, context_before: String::new(), context_after: String::new(), line_prefix: String::new(), }); } SearchReplaceEditContextInformation { details } } #[cfg(test)] mod tests { use super::*; use kigi_tools::types::output::*; use std::path::PathBuf; #[test] fn test_acp_tool_update_read_file_success() { let output = ToolOutput::ReadFile(ReadFileOutput::FileContent(FileContent { content: "file content".to_string(), content_concise: None, absolute_path: PathBuf::from("/project/src/main.rs"), offset: None, limit: None, raw_output: "file content".to_string(), total_lines: 100, extracted_images: Vec::new(), })); let update = acp_tool_update(&output, "call-1", None, None).unwrap(); assert_eq!(update.fields.status, Some(acp::ToolCallStatus::Completed)); assert!(update.fields.content.is_some()); } #[test] fn test_acp_tool_update_todo_returns_completed() { let output = ToolOutput::Todo(TodoWriteOutput::TodosUpdated(TodoWriteSuccess { summary_for_prompt: "tasks".to_string(), todos: vec![], state: kigi_tools::implementations::kigi::todo::TodoState::default(), })); let update = acp_tool_update(&output, "call-1", None, None).unwrap(); assert_eq!(update.fields.status, Some(acp::ToolCallStatus::Completed)); } #[test] fn test_turn_end_plan_cleanup_preserves_semantics_and_priority() { use crate::tools::todo::plan_entry_from_todo_item; use kigi_tools::implementations::kigi::todo::{TodoItem, TodoPriority, TodoStatus}; // Simulate a mixed todo list at turn end. let items = [ TodoItem { content: "Done".to_string(), priority: TodoPriority::Medium, status: TodoStatus::Completed, meta: None, }, TodoItem { content: "Dropped".to_string(), priority: TodoPriority::Low, status: TodoStatus::Cancelled, meta: None, }, TodoItem { content: "Stale spinner".to_string(), priority: TodoPriority::High, status: TodoStatus::InProgress, meta: None, }, ]; // Build plan entries using the canonical helper, then override // in_progress → completed (same logic as emit_turn_end_plan_cleanup). let entries: Vec = items .iter() .map(|item| { let mut entry = plan_entry_from_todo_item(item.clone()); if item.status == TodoStatus::InProgress { entry.status = acp::PlanEntryStatus::Completed; } entry }) .collect(); // Completed item: unchanged, medium priority preserved assert_eq!(entries[0].status, acp::PlanEntryStatus::Completed); assert_eq!(entries[0].priority, acp::PlanEntryPriority::Medium); assert!(entries[0].meta.is_none()); // Cancelled item: Completed with cancelled marker, LOW priority preserved assert_eq!(entries[1].status, acp::PlanEntryStatus::Completed); assert_eq!(entries[1].priority, acp::PlanEntryPriority::Low); assert_eq!(entries[1].meta.as_ref().unwrap()["cancelled"], true); // In-progress item: overridden to Completed, HIGH priority preserved assert_eq!(entries[2].status, acp::PlanEntryStatus::Completed); assert_eq!(entries[2].priority, acp::PlanEntryPriority::High); // No cancelled marker (it was in_progress, not cancelled) assert!(entries[2].meta.is_none()); } #[test] fn test_acp_tool_update_todo_duplicate_id_returns_failed() { let output = ToolOutput::Todo(TodoWriteOutput::DuplicateId( "Duplicate todo ID: \"dup\"".to_string(), )); let update = acp_tool_update(&output, "call-1", None, None).unwrap(); assert_eq!(update.fields.status, Some(acp::ToolCallStatus::Failed)); assert!(update.fields.content.is_some()); } #[test] fn test_acp_plan_update_todo() { let output = ToolOutput::Todo(TodoWriteOutput::TodosUpdated(TodoWriteSuccess { summary_for_prompt: "tasks".to_string(), todos: vec![kigi_tools::implementations::kigi::todo::TodoItem { content: "Task 1".to_string(), priority: kigi_tools::implementations::kigi::todo::TodoPriority::Medium, status: kigi_tools::implementations::kigi::todo::TodoStatus::Completed, meta: None, }], state: kigi_tools::implementations::kigi::todo::TodoState::default(), })); let plan = acp_plan_update(&output).unwrap(); assert_eq!(plan.entries.len(), 1); assert_eq!(plan.entries[0].content, "Task 1"); assert_eq!(plan.entries[0].status, acp::PlanEntryStatus::Completed); } #[test] fn test_acp_plan_update_todo_duplicate_id_returns_none() { let output = ToolOutput::Todo(TodoWriteOutput::DuplicateId( "Duplicate todo ID: \"dup\"".to_string(), )); assert!(acp_plan_update(&output).is_none()); } #[test] fn test_acp_plan_update_non_todo_returns_none() { let output = ToolOutput::GrepSearch(GrepSearchOutput { stdout: vec![], stderr: vec![], exit_code: 0, match_count: 0, file_matches: vec![], }); assert!(acp_plan_update(&output).is_none()); } #[test] fn test_raw_output_json_round_trips() { // raw_output_json uses serde directly, so it must round-trip with ToolOutput deserialization let output = ToolOutput::ReadFile(ReadFileOutput::FileContent(FileContent { content: "content".to_string(), content_concise: None, absolute_path: PathBuf::from("/project/src/main.rs"), offset: None, limit: None, raw_output: "content".to_string(), total_lines: 100, extracted_images: Vec::new(), })); let json = raw_output_json(&output, None).unwrap(); // Verify it deserializes back into the same type let round_tripped: ToolOutput = serde_json::from_value(json).unwrap(); match round_tripped { ToolOutput::ReadFile(ReadFileOutput::FileContent(fc)) => { assert_eq!(fc.absolute_path, PathBuf::from("/project/src/main.rs")); assert_eq!(fc.content, "content"); } other => panic!("Expected ReadFile, got {:?}", other), } } #[test] fn test_acp_tool_update_text_completes_with_content() { let output = ToolOutput::Text( "Found 3 memory result(s):\n\n### Result 1" .to_string() .into(), ); let update = acp_tool_update(&output, "call-mem", None, None).unwrap(); assert_eq!(update.fields.status, Some(acp::ToolCallStatus::Completed)); let content = update.fields.content.as_ref().expect("should have content"); assert_eq!(content.len(), 1); match &content[0] { acp::ToolCallContent::Content(acp::Content { content: acp::ContentBlock::Text(tc), .. }) => assert!(tc.text.contains("Found 3 memory result(s)")), other => panic!("expected Text content, got {other:?}"), } // ToolOutput::Text wraps TextOutput { text: String }, which serde can // serialize with internal tagging. raw_output carries the JSON. assert!(update.fields.raw_output.is_some()); } #[test] fn test_raw_output_json_list_dir_round_trips() { let output = ToolOutput::ListDir(ListDirOutput::Content(ListDirContent { content: "file1.rs\nfile2.rs".to_string(), absolute_root_path: PathBuf::from("/project/src"), })); let json = raw_output_json(&output, None).unwrap(); let round_tripped: ToolOutput = serde_json::from_value(json).unwrap(); match round_tripped { ToolOutput::ListDir(ListDirOutput::Content(ldc)) => { assert_eq!(ldc.absolute_root_path, PathBuf::from("/project/src")); } other => panic!("Expected ListDir, got {:?}", other), } } // --- PathRewriter tests --- #[test] fn test_path_rewriter_new_returns_none_when_same() { assert!(PathRewriter::new("/project", Some("/project")).is_none()); } #[test] fn test_path_rewriter_new_returns_none_when_no_display() { assert!(PathRewriter::new("/project", None).is_none()); } #[test] fn test_path_rewriter_new_returns_some_when_different() { let rw = PathRewriter::new( "/root/.kigi/worktrees/project/ab-123", Some("/home/user/project"), ); assert!(rw.is_some()); } #[test] fn test_path_rewriter_rewrite_text() { let rw = PathRewriter::new( "/root/.kigi/worktrees/myproject/ab-123", Some("/testbed/myproject"), ) .unwrap(); let input = "File at /root/.kigi/worktrees/myproject/ab-123/src/main.rs"; let output = rw.rewrite(input); assert_eq!(output, "File at /testbed/myproject/src/main.rs"); } #[test] fn test_path_rewriter_rewrite_path() { let rw = PathRewriter::new( "/root/.kigi/worktrees/myproject/ab-123", Some("/testbed/myproject"), ) .unwrap(); let path = Path::new("/root/.kigi/worktrees/myproject/ab-123/src/lib.rs"); let rewritten = rw.rewrite_path(path); assert_eq!(rewritten, PathBuf::from("/testbed/myproject/src/lib.rs")); } #[test] fn test_path_rewriter_rewrite_path_no_match() { let rw = PathRewriter::new( "/root/.kigi/worktrees/myproject/ab-123", Some("/testbed/myproject"), ) .unwrap(); let path = Path::new("/other/path/file.rs"); let rewritten = rw.rewrite_path(path); assert_eq!(rewritten, PathBuf::from("/other/path/file.rs")); } #[test] fn test_path_rewriter_rewrites_raw_output_json() { let rw = PathRewriter::new( "/root/.kigi/worktrees/myproject/ab-123", Some("/testbed/myproject"), ) .unwrap(); let output = ToolOutput::ReadFile(ReadFileOutput::FileContent(FileContent { content: "content".to_string(), content_concise: None, absolute_path: PathBuf::from("/root/.kigi/worktrees/myproject/ab-123/src/main.rs"), offset: None, limit: None, raw_output: "content".to_string(), total_lines: 100, extracted_images: Vec::new(), })); let json = raw_output_json(&output, Some(&rw)).unwrap(); let round_tripped: ToolOutput = serde_json::from_value(json).unwrap(); match round_tripped { ToolOutput::ReadFile(ReadFileOutput::FileContent(fc)) => { assert_eq!( fc.absolute_path, PathBuf::from("/testbed/myproject/src/main.rs") ); } other => panic!("Expected ReadFile, got {:?}", other), } } #[test] fn test_path_rewriter_rewrites_list_dir_raw_output() { let rw = PathRewriter::new( "/root/.kigi/worktrees/myproject/ab-123", Some("/testbed/myproject"), ) .unwrap(); let output = ToolOutput::ListDir(ListDirOutput::Content(ListDirContent { content: "file1.rs\nfile2.rs".to_string(), absolute_root_path: PathBuf::from("/root/.kigi/worktrees/myproject/ab-123/src"), })); let json = raw_output_json(&output, Some(&rw)).unwrap(); let round_tripped: ToolOutput = serde_json::from_value(json).unwrap(); match round_tripped { ToolOutput::ListDir(ListDirOutput::Content(ldc)) => { assert_eq!( ldc.absolute_root_path, PathBuf::from("/testbed/myproject/src") ); } other => panic!("Expected ListDir, got {:?}", other), } } #[test] fn test_path_rewriter_rewrites_bash_command_and_output() { let rw = PathRewriter::new( "/root/.kigi/worktrees/myproject/ab-123", Some("/testbed/myproject"), ) .unwrap(); let output = ToolOutput::Bash(BashOutput { output: b"listing /root/.kigi/worktrees/myproject/ab-123/src".to_vec(), output_for_prompt: String::new(), exit_code: 0, command: "ls /root/.kigi/worktrees/myproject/ab-123/src".to_string(), truncated: false, signal: None, timed_out: false, description: None, current_dir: "/root/.kigi/worktrees/myproject/ab-123".to_string(), output_file: "/tmp/output.txt".to_string(), output_delta: None, total_bytes: 0, was_bare_echo: false, }); let json = raw_output_json(&output, Some(&rw)).unwrap(); let round_tripped: ToolOutput = serde_json::from_value(json).unwrap(); match round_tripped { ToolOutput::Bash(bash) => { assert_eq!(bash.command, "ls /testbed/myproject/src"); assert_eq!(bash.current_dir, "/testbed/myproject"); } other => panic!("Expected Bash, got {:?}", other), } } #[test] fn test_path_rewriter_rewrites_search_replace_diff_path() { let rw = PathRewriter::new( "/root/.kigi/worktrees/myproject/ab-123", Some("/testbed/myproject"), ) .unwrap(); let output = ToolOutput::SearchReplace(SearchReplaceOutput::EditsApplied( SearchReplaceEditsApplied { old_string: "old".to_string(), new_string: "new".to_string(), tool_output_for_prompt: String::new(), tool_output_for_prompt_concise: None, absolute_path: PathBuf::from("/root/.kigi/worktrees/myproject/ab-123/src/lib.rs"), edits: SearchReplaceEditContextInformation::default(), patch: None, unicode_normalized: false, }, )); let update = acp_tool_update(&output, "call-1", Some(&rw), None).unwrap(); let content = update.fields.content.unwrap(); assert_eq!(content.len(), 1); match &content[0] { acp::ToolCallContent::Diff(diff) => { assert_eq!(diff.path, PathBuf::from("/testbed/myproject/src/lib.rs")); } other => panic!("Expected Diff, got {:?}", other), } let raw = update.fields.raw_output.unwrap(); let raw_str = raw.to_string(); assert!( !raw_str.contains("/root/.kigi/worktrees/myproject/ab-123"), "raw_output should not contain worktree path, got: {}", raw_str ); assert!(raw_str.contains("/testbed/myproject/src/lib.rs")); } // ── URL-encoded path rewriting ───────────────────────────────── #[test] fn test_rewrite_handles_url_encoded_paths() { let rw = PathRewriter::new( "/root/.kigi/worktrees/project/ab-123-a-overlay", Some("/home/user/project"), ) .unwrap(); // Session directory paths use urlencoding::encode(&cwd) let encoded_overlay = urlencoding::encode("/root/.kigi/worktrees/project/ab-123-a-overlay"); let input = format!( "output-file: /root/.kigi/sessions/{}/session-id/terminal/call.log", encoded_overlay ); let result = rw.rewrite(&input); assert!( !result.contains("ab-123-a-overlay"), "URL-encoded overlay path must be rewritten: {result}" ); let encoded_display = urlencoding::encode("/home/user/project"); assert!( result.contains(encoded_display.as_ref()), "rewritten output must contain the encoded display path: {result}" ); } #[test] fn test_rewrite_handles_plain_paths() { let rw = PathRewriter::new( "/root/.kigi/worktrees/project/ab-123-a-overlay", Some("/home/user/project"), ) .unwrap(); let input = "file: /root/.kigi/worktrees/project/ab-123-a-overlay/src/main.rs"; let result = rw.rewrite(input); assert_eq!(result, "file: /home/user/project/src/main.rs"); } #[test] fn test_rewrite_json_handles_url_encoded_paths() { let rw = PathRewriter::new( "/root/.kigi/worktrees/project/ab-123", Some("/testbed/project"), ) .unwrap(); let encoded = urlencoding::encode("/root/.kigi/worktrees/project/ab-123"); let value = serde_json::json!({ "output_file": format!("/sessions/{}/task.log", encoded), "status": "running", }); let rewritten = rw.rewrite_json(value); let output_file = rewritten["output_file"].as_str().unwrap(); assert!( !output_file.contains("ab-123"), "rewrite_json must handle URL-encoded paths: {output_file}" ); } #[test] fn test_rewrite_noop_when_no_overlay_path_present() { let rw = PathRewriter::new( "/root/.kigi/worktrees/project/ab-123", Some("/testbed/project"), ) .unwrap(); let input = "exit: 0\nhello world\n"; assert_eq!( rw.rewrite(input), input, "text without overlay paths must pass through unchanged" ); } #[test] fn test_maybe_rewrite_with_none_returns_original() { let original = "Tool `read_file` failed: /some/path".to_string(); let result = maybe_rewrite(None, original.clone()); assert_eq!(result, original); } #[test] fn test_maybe_rewrite_with_rewriter_sanitizes_error_text() { let rw = PathRewriter::new( "/root/.kigi/worktrees/project/ab-999", Some("/home/user/project"), ) .unwrap(); let error_text = "Tool `read_file` failed: IO error reading /root/.kigi/worktrees/project/ab-999/src/lib.rs".to_string(); let result = maybe_rewrite(Some(&rw), error_text); assert!( !result.contains("ab-999"), "error text must not contain the overlay path: {result}" ); assert!( result.contains("/home/user/project/src/lib.rs"), "error text must show the display path: {result}" ); } #[test] fn test_no_rewriter_preserves_original_paths() { let output = ToolOutput::ReadFile(ReadFileOutput::FileContent(FileContent { content: "content".to_string(), content_concise: None, absolute_path: PathBuf::from("/root/.kigi/worktrees/myproject/ab-123/src/main.rs"), offset: None, limit: None, raw_output: "content".to_string(), total_lines: 100, extracted_images: Vec::new(), })); let json = raw_output_json(&output, None).unwrap(); let round_tripped: ToolOutput = serde_json::from_value(json).unwrap(); match round_tripped { ToolOutput::ReadFile(ReadFileOutput::FileContent(fc)) => { assert_eq!( fc.absolute_path, PathBuf::from("/root/.kigi/worktrees/myproject/ab-123/src/main.rs") ); } other => panic!("Expected ReadFile, got {:?}", other), } } #[test] fn test_acp_tool_update_pdf_page_images() { let output = ToolOutput::ReadFile(ReadFileOutput::PdfPageImages(PdfPageImages { pages: vec![ PdfPageImage { data: "base64_page1".to_string(), mime_type: "image/jpeg".to_string(), page_number: 1, }, PdfPageImage { data: "base64_page2".to_string(), mime_type: "image/jpeg".to_string(), page_number: 2, }, ], total_pages: 10, file_size: 4096, })); let update = acp_tool_update(&output, "call-pdf", None, None).unwrap(); assert_eq!(update.fields.status, Some(acp::ToolCallStatus::Completed)); let content = update.fields.content.as_ref().expect("should have content"); assert_eq!(content.len(), 2, "should have 2 image blocks"); for block in content { match block { acp::ToolCallContent::Content(acp::Content { content: acp::ContentBlock::Image(img), .. }) => { assert_eq!(img.mime_type, "image/jpeg"); } other => panic!("expected Image block, got {other:?}"), } } } }