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.
1280 lines
53 KiB
Rust
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:?}"),
|
|
}
|
|
}
|
|
}
|
|
}
|