Files
Kigi-CLI/crates/codegen/kigi-shell/src/session/acp_conversion.rs
T
ZacharyZhang-NY 6f31415ed6 §9 acceptance: grep-zero sweep — every internal x.ai/grok identifier renamed
The PRD's first acceptance gate now holds: grep -RinE '\bx\.ai\b|grok'
crates/ --include='*.rs' → 0 matches (exempt: NOTICE and third-party
license archives, README provenance, and the required 'Based on Grok
Build Open Source' attribution, now sourced from version_attribution.txt).

Wire-visible renames (both sides in this repo, changed in lockstep):
- Auth method id 'grok.com' → 'kimi-code' (AuthMethodKind::KimiCode).
- Every x.ai/* and _x.ai/* ACP ext method and meta key → kigi/* /
  _kigi/* (~200 names; grokShell → kigiShell). Session-file replay keeps
  a read-side alias for the legacy '_x.ai/session/update' method so
  existing updates.jsonl histories load; writes emit only the new name
  (both directions test-pinned).
- Agent types grok-build* → kigi* with a documented legacy-prefix alias
  at resolution time so persisted sessions keep resolving.
- ToolNamespace/BuiltinAgentName GrokBuild* → Kigi* (wire snake_case
  kigi/kigi_concise/kigi_hashline; schema regenerated); grok_build
  implementation dirs renamed to kigi*.
- x-grok-* headers → x-kigi-*, __GROK_* sentinels → __KIGI_*, themes
  grokday/groknight → kigiday/kiginight (old persisted values fall back
  to the default theme), web_fetch allowlist xAI hosts → kimi.com +
  moonshot platforms, changelog CDN → this repo, grok-build changelog
  archives deleted.
- BYOK default endpoint removed: [endpoints] api_base_url is now truly
  optional with NO default — consumers fail fast with the flag name when
  unset (no silent x.ai egress). Mock harnesses inject it explicitly.
- System-prompt identity fixed: 'released by xAI' → 'an unofficial
  community CLI for Kimi' (template + regenerated encrypted form).

Also repaired pre-existing grok-era test debt found by the sweep: the
stale trace_classify default-model pin, the grok-pager UA label test,
pty-harness stale-binary reuse and non-hermetic moonshot routing (a PTY
test could previously reach the real api.moonshot.cn), and the outdated
oauth fixture scope key.

Gates: §9 grep 0; fmt clean; workspace check/clippy 0/0 (-D warnings);
FULL cargo test --workspace: 234 suites, 21,961 passed, 0 failed;
deny advisories ok.
2026-07-18 02:48:46 -04:00

1280 lines
53 KiB
Rust

//! 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<Self> {
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<serde_json::Value> {
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<serde_json::Value>,
) -> Option<acp::ToolCallUpdate> {
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<acp::ToolCallContent> = 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<acp::ToolCallContent> = 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<String>`,
/// `status: Option<String>`) 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<acp::Plan> {
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<acp::PlanEntry> = 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:?}"),
}
}
}
}