Hard fork of xai-org/grok-build (Apache-2.0) re-targeted as Kigi, an
unofficial Kimi Code CLI community build.
Rename & identity
- 72 xai-*/xai-grok-* crates -> kigi-* (explicit: xai-grok-pager-bin ->
kigi-bin [binary `kigi`], xai-grok-pager -> kigi-tui; rest mechanical);
ptyctl, ptyctl-cli, third_party/ unchanged; proto package
xai.grok.tools.v1 -> kigi.tools.v1
- Config home ~/.kigi (KIGI_SHARE_DIR override), env prefix GROK_* ->
KIGI_*, `kigi --version` carries the unofficial-community-build notice
- clap identity, help text, startup banner, prompt templates rebranded
(templates re-encrypted)
Deletions (PRD removal list #5/#6/#7/#9/#10)
- voice input (xai-grok-voice) and all TUI wiring
- telemetry: Mixpanel client, external OTel stream, Sentry, OTLP layers,
trace/GCS/S3 upload queues (kigi-file-utils halved), workspace upload
module & dc_log, heap-profile uploader, auth-diagnostics uploader,
session-analytics halves of feedback; local zero-egress observability
preserved in new kigi-log crate (unified log, --debug firehose,
subsystem file logs, opt-in instrumentation)
- announcements (crate, remote-settings fields, TUI surfaces)
- plugin marketplace (crate, sources/browse/CTA/extensions-modal tab);
direct plugin install/uninstall/update via kigi-agent git_install kept
- relay/gateway/assets endpoints and features (agent relay, headless
relay transport, gateway bridge, LeaderEnvUrls); leader IPC socket now
~/.kigi/leader.sock + KIGI_LEADER_SOCKET, no ws-url derivation
- functional types rehomed instead of deleted: PermissionMode ->
kigi-config-types, McpInitStrategy -> kigi-mcp, PrCreationSource ->
session signals, TerminalDiagnostics -> kigi-pager-render, agent_id ->
shell util
Endpoints
- kigi-env rewritten: single production KigiEndpoints {coding_api_base_url
https://api.kimi.com/coding/v1 (KIGI_CODE_BASE_URL), oauth_host
https://auth.kimi.com (KIGI_OAUTH_HOST), update_base_url (GitHub
Releases API), upgrade_page_url}; GrokBuildEnvironment enum deleted
Toolchain & workspace hygiene
- Rust 1.97.0 pinned; edition 2024; full cargo update; git2 hoisted to
workspace at 0.21 (Option->Result API migration), quick-xml 0.41
- Root Cargo.toml hand-maintained (PRD §8.1): version 0.1.0 inherited by
all members, members sorted, unused deps pruned
- cargo-deny advisories gate (deny.toml with documented transitive
exceptions); CI workflow (check/clippy/fmt/deny/test, macOS+Linux)
- cross-crate test seams re-gated behind `test-support` cargo feature;
insta snapshot baselines renamed to the kigi_tui prefix
- clippy --workspace --all-targets: zero warnings; fmt clean
Fixes surfaced by the port
- updater probe/installer divergence (bin/kigi vs bin/grok symlink set)
- idle model-metadata refresh dead under KIGI_CODE_BASE_URL override
(new is_effective_coding_endpoint_url, loopback+override aware)
- macOS symlinked-TMPDIR fixture canonicalization (foreign_sessions,
fast-worktree); RSS measurement tests serialized via serial_test
Docs & legal (Apache §4)
- NOTICE added (upstream attribution + change statement); THIRD-PARTY
notices sustained; kigi-tools ported-code notices extended; README,
CONTRIBUTING, SECURITY, AGENTS.md rewritten
Out of scope for M0 (tracked): Kimi auth/inference (M1), search/fetch,
command parity, config import (M2), Computer Hub excision & final
brand-token sweep (M2), distribution & self-update rewrite (M3).
1031 lines
38 KiB
Rust
1031 lines
38 KiB
Rust
//! Hunk Tracker extension API layer.
|
|
//!
|
|
//! Provides access to the kigi-hunk-tracker functionality for tracking file changes
|
|
//! with agent/external attribution.
|
|
|
|
use std::collections::HashSet;
|
|
use std::path::PathBuf;
|
|
use std::sync::Arc;
|
|
|
|
use agent_client_protocol as acp;
|
|
use serde::{Deserialize, Serialize};
|
|
|
|
use super::{ExtResult, parse_params, to_ext_response};
|
|
use crate::agent::MvpAgent;
|
|
use kigi_hunk_tracker::{
|
|
FileContentEntry, FileContentStatus, FileContentView, Hunk, HunkTrackerHandle,
|
|
};
|
|
use kigi_workspace::workspace_ops::{
|
|
FileContentEntryWire, FileContentStatusWire, FileContentViewWire, HunkActionKind,
|
|
HunkActionReq, HunkAllActionReq, HunkFileActionReq, HunkGetAllFileContentsReq,
|
|
HunkGetSessionSummaryReq, HunkSingleActionReq, HunkTurnActionReq,
|
|
};
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════
|
|
// Request Types
|
|
// ═══════════════════════════════════════════════════════════════════════
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct GetHunksRequest {
|
|
#[serde(default)]
|
|
pub session_id: Option<acp::SessionId>,
|
|
/// Filter by file path (optional)
|
|
pub path: Option<String>,
|
|
/// Filter by source: "agent", "external", or "all" (default: "all")
|
|
#[serde(default)]
|
|
pub source: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct GetFilesRequest {
|
|
#[serde(default)]
|
|
pub session_id: Option<acp::SessionId>,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct HunkActionRequest {
|
|
#[serde(default)]
|
|
pub session_id: Option<acp::SessionId>,
|
|
pub hunk_id: String,
|
|
pub action: String, // "accept" | "reject"
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct FileActionRequest {
|
|
#[serde(default)]
|
|
pub session_id: Option<acp::SessionId>,
|
|
pub path: String,
|
|
pub action: String, // "accept" | "reject"
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct TurnActionRequest {
|
|
#[serde(default)]
|
|
pub session_id: Option<acp::SessionId>,
|
|
pub prompt_index: usize,
|
|
pub action: String, // "accept" | "reject"
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct AllActionRequest {
|
|
#[serde(default)]
|
|
pub session_id: Option<acp::SessionId>,
|
|
pub action: String, // "accept" | "reject"
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct GetSummaryRequest {
|
|
#[serde(default)]
|
|
pub session_id: Option<acp::SessionId>,
|
|
}
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════
|
|
// Response Types
|
|
// ═══════════════════════════════════════════════════════════════════════
|
|
|
|
#[derive(Debug, Serialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct GetHunksResponse {
|
|
pub hunks: Vec<Arc<Hunk>>,
|
|
|
|
// === Explicit content status (new fields) ===
|
|
/// Baseline content with explicit status - only present when requesting a specific path
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub baseline: Option<FileContentView>,
|
|
/// Current content with explicit status - only present when requesting a specific path
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub current: Option<FileContentView>,
|
|
|
|
// === Legacy fields for backward compatibility ===
|
|
/// Baseline content (git HEAD) - legacy, use `baseline.content` instead
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub baseline_content: Option<String>,
|
|
/// Current content (on disk) - legacy, use `current.content` instead
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub current_content: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Serialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct FileSummary {
|
|
pub path: PathBuf,
|
|
pub is_agent_file: bool,
|
|
pub staged: bool,
|
|
pub hunk_count: usize,
|
|
pub additions: usize,
|
|
pub deletions: usize,
|
|
}
|
|
|
|
#[derive(Debug, Serialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct GetFilesResponse {
|
|
pub files: Vec<FileSummary>,
|
|
}
|
|
|
|
#[derive(Debug, Serialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct GetAllFileContentsResponse {
|
|
pub files: Vec<FileContentEntry>,
|
|
}
|
|
|
|
#[derive(Debug, Serialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct ActionResponse {
|
|
pub success: bool,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub error: Option<String>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub affected_count: Option<usize>,
|
|
}
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════
|
|
// Helper Functions
|
|
// ═══════════════════════════════════════════════════════════════════════
|
|
|
|
/// Bridge the workspace RPC's lean wire response type back to the
|
|
/// hunk-tracker's `FileContentEntry`: the RPC returns the wire type while the
|
|
/// shell's ACP DTOs use the hunk-tracker types.
|
|
fn file_content_entry_from_wire(w: FileContentEntryWire) -> FileContentEntry {
|
|
FileContentEntry {
|
|
path: w.path,
|
|
baseline: file_content_view_from_wire(w.baseline),
|
|
current: file_content_view_from_wire(w.current),
|
|
is_agent_file: w.is_agent_file,
|
|
staged: w.staged,
|
|
}
|
|
}
|
|
|
|
fn file_content_view_from_wire(w: FileContentViewWire) -> FileContentView {
|
|
FileContentView {
|
|
status: file_content_status_from_wire(w.status),
|
|
byte_len: w.byte_len,
|
|
content: w.content,
|
|
}
|
|
}
|
|
|
|
fn file_content_status_from_wire(w: FileContentStatusWire) -> FileContentStatus {
|
|
match w {
|
|
FileContentStatusWire::Missing => FileContentStatus::Missing,
|
|
FileContentStatusWire::Binary => FileContentStatus::Binary,
|
|
FileContentStatusWire::TooLarge => FileContentStatus::TooLarge,
|
|
FileContentStatusWire::LfsPointer => FileContentStatus::LfsPointer,
|
|
FileContentStatusWire::Symlink => FileContentStatus::Symlink,
|
|
FileContentStatusWire::Full => FileContentStatus::Full,
|
|
// A status from a newer peer this build does not know: degrade to
|
|
// Missing (content unavailable) rather than failing.
|
|
FileContentStatusWire::Unknown => FileContentStatus::Missing,
|
|
}
|
|
}
|
|
|
|
/// Session context for hunk tracker operations: the tracker handle plus
|
|
/// optional path rewriting info for forked sessions.
|
|
struct HunkTrackerContext {
|
|
handle: HunkTrackerHandle,
|
|
/// When set, rewrite absolute worktree paths (starting with `real_cwd`)
|
|
/// to `display_cwd` in API responses so the client UI shows the original
|
|
/// project path, not the worktree path.
|
|
real_cwd: String,
|
|
display_cwd: Option<String>,
|
|
}
|
|
|
|
impl HunkTrackerContext {
|
|
/// Rewrite a worktree path to the display path for client-facing output.
|
|
fn display_path(&self, path: &std::path::Path) -> PathBuf {
|
|
if let Some(ref display) = self.display_cwd {
|
|
let real = std::path::Path::new(&self.real_cwd);
|
|
if let Ok(suffix) = path.strip_prefix(real) {
|
|
return PathBuf::from(display).join(suffix);
|
|
}
|
|
}
|
|
path.to_path_buf()
|
|
}
|
|
|
|
/// Rewrite paths in a list of hunks for display.
|
|
fn rewrite_hunks(&self, hunks: Vec<Arc<Hunk>>) -> Vec<Arc<Hunk>> {
|
|
if self.display_cwd.is_none() {
|
|
return hunks;
|
|
}
|
|
hunks
|
|
.into_iter()
|
|
.map(|h| {
|
|
let new_path = self.display_path(&h.path);
|
|
if new_path == h.path {
|
|
return h;
|
|
}
|
|
// Clone the hunk with the rewritten path
|
|
Arc::new(Hunk {
|
|
path: new_path,
|
|
id: h.id.clone(),
|
|
line_info: h.line_info.clone(),
|
|
source: h.source,
|
|
old_text: h.old_text.clone(),
|
|
new_text: h.new_text.clone(),
|
|
patch: h.patch.clone(),
|
|
created_at: h.created_at,
|
|
selected: h.selected,
|
|
})
|
|
})
|
|
.collect()
|
|
}
|
|
}
|
|
|
|
/// Get the hunk tracker context for the given session.
|
|
fn get_hunk_tracker(
|
|
agent: &MvpAgent,
|
|
session_id: Option<&acp::SessionId>,
|
|
) -> Result<HunkTrackerContext, acp::Error> {
|
|
let session_id = session_id.ok_or_else(|| {
|
|
acp::Error::invalid_params().data("sessionId is required for hunk tracker operations")
|
|
})?;
|
|
|
|
let handle = agent.get_session_handle(session_id).ok_or_else(|| {
|
|
acp::Error::invalid_params().data(format!("session not found: {}", session_id.0))
|
|
})?;
|
|
|
|
Ok(HunkTrackerContext {
|
|
handle: handle.tool_context.hunk_tracker_handle.clone(),
|
|
real_cwd: handle.info.cwd.clone(),
|
|
display_cwd: handle.display_cwd.clone(),
|
|
})
|
|
}
|
|
|
|
/// Compute file summaries from hunks.
|
|
///
|
|
/// `staged_paths` contains the absolute paths of files staged in the git index.
|
|
fn compute_file_summaries(
|
|
hunks: &[Arc<Hunk>],
|
|
staged_paths: &HashSet<PathBuf>,
|
|
) -> Vec<FileSummary> {
|
|
use std::collections::HashMap;
|
|
|
|
let mut file_map: HashMap<PathBuf, FileSummary> = HashMap::new();
|
|
|
|
for hunk in hunks {
|
|
let entry = file_map
|
|
.entry(hunk.path.clone())
|
|
.or_insert_with(|| FileSummary {
|
|
path: hunk.path.clone(),
|
|
is_agent_file: false,
|
|
staged: staged_paths.contains(&hunk.path),
|
|
hunk_count: 0,
|
|
additions: 0,
|
|
deletions: 0,
|
|
});
|
|
|
|
entry.hunk_count += 1;
|
|
entry.additions += hunk.new_text.lines().count();
|
|
entry.deletions += hunk
|
|
.old_text
|
|
.as_ref()
|
|
.map(|t| t.lines().count())
|
|
.unwrap_or(0);
|
|
|
|
// Mark as agent file if any hunk is from agent
|
|
if hunk.source.is_agent_edit() {
|
|
entry.is_agent_file = true;
|
|
}
|
|
}
|
|
|
|
let mut files: Vec<_> = file_map.into_values().collect();
|
|
files.sort_by(|a, b| a.path.cmp(&b.path));
|
|
files
|
|
}
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════
|
|
// Main Handler
|
|
// ═══════════════════════════════════════════════════════════════════════
|
|
|
|
pub async fn handle(
|
|
agent: &MvpAgent,
|
|
ops: &kigi_workspace::WorkspaceOps,
|
|
args: &acp::ExtRequest,
|
|
) -> ExtResult {
|
|
match args.method.as_ref() {
|
|
// ───────────────────────────────────────────────────────────────
|
|
// Queries
|
|
// ───────────────────────────────────────────────────────────────
|
|
"x.ai/hunk-tracker/get-hunks" => {
|
|
let req = parse_params::<GetHunksRequest>(args)?;
|
|
let ctx = get_hunk_tracker(agent, req.session_id.as_ref())?;
|
|
|
|
// If path is specified, use get_file_hunk_data to get hunks + content together
|
|
let (hunks, baseline, current, baseline_content, current_content) =
|
|
if let Some(path) = req.path {
|
|
let data = ctx.handle.get_file_hunk_data(PathBuf::from(path)).await;
|
|
(
|
|
data.hunks,
|
|
Some(data.baseline),
|
|
Some(data.current),
|
|
data.baseline_content,
|
|
data.current_content,
|
|
)
|
|
} else {
|
|
(ctx.handle.get_all_hunks().await, None, None, None, None)
|
|
};
|
|
|
|
// Filter by source if specified
|
|
let hunks = match req.source.as_deref() {
|
|
Some("agent") => hunks
|
|
.into_iter()
|
|
.filter(|h| h.source.is_agent_edit())
|
|
.collect(),
|
|
Some("external") => hunks
|
|
.into_iter()
|
|
.filter(|h| h.source.is_external())
|
|
.collect(),
|
|
_ => hunks, // "all" or unspecified
|
|
};
|
|
|
|
// Rewrite worktree paths to display paths for client UI
|
|
let hunks = ctx.rewrite_hunks(hunks);
|
|
|
|
to_ext_response(Ok(GetHunksResponse {
|
|
hunks,
|
|
baseline,
|
|
current,
|
|
baseline_content,
|
|
current_content,
|
|
}))
|
|
}
|
|
|
|
"x.ai/hunk-tracker/get-files" => {
|
|
let req = parse_params::<GetFilesRequest>(args)?;
|
|
let ctx = get_hunk_tracker(agent, req.session_id.as_ref())?;
|
|
|
|
let hunks = ctx.handle.get_all_hunks().await;
|
|
let staged_paths = ctx.handle.get_staged_files().await;
|
|
// Rewrite paths before computing summaries so file paths are stable
|
|
let hunks = ctx.rewrite_hunks(hunks);
|
|
// Rewrite staged paths for display (worktree → display path)
|
|
let staged_paths: HashSet<PathBuf> =
|
|
staged_paths.iter().map(|p| ctx.display_path(p)).collect();
|
|
let files = compute_file_summaries(&hunks, &staged_paths);
|
|
|
|
to_ext_response(Ok(GetFilesResponse { files }))
|
|
}
|
|
|
|
"x.ai/hunk-tracker/get-all-file-contents" => {
|
|
let req = parse_params::<GetFilesRequest>(args)?;
|
|
|
|
let sid = req.session_id.as_ref().map(|s| s.0.as_ref());
|
|
// The RPC returns the lean wire type; bridge it back to the
|
|
// hunk-tracker type so the ACP response shape is unchanged.
|
|
let mut files: Vec<FileContentEntry> = ops
|
|
.dispatch(&HunkGetAllFileContentsReq {}, sid)
|
|
.await
|
|
.map_err(|e| acp::Error::internal_error().data(e.to_string()))?
|
|
.into_iter()
|
|
.map(file_content_entry_from_wire)
|
|
.collect();
|
|
// Post-dispatch: rewrite worktree paths to display paths for client UI
|
|
if let Some(ctx) = req
|
|
.session_id
|
|
.as_ref()
|
|
.and_then(|s| get_hunk_tracker(agent, Some(s)).ok())
|
|
&& ctx.display_cwd.is_some()
|
|
{
|
|
for entry in &mut files {
|
|
entry.path = ctx.display_path(&entry.path);
|
|
}
|
|
}
|
|
to_ext_response(Ok(GetAllFileContentsResponse { files }))
|
|
}
|
|
|
|
"x.ai/hunk-tracker/get-summary" => {
|
|
let req = parse_params::<GetSummaryRequest>(args)?;
|
|
|
|
let sid = req.session_id.as_ref().map(|s| s.0.as_ref());
|
|
let result = ops
|
|
.dispatch(&HunkGetSessionSummaryReq {}, sid)
|
|
.await
|
|
.map_err(|e| acp::Error::internal_error().data(e.to_string()))?;
|
|
to_ext_response(Ok(result))
|
|
}
|
|
|
|
// ───────────────────────────────────────────────────────────────
|
|
// Single Hunk Action
|
|
// ───────────────────────────────────────────────────────────────
|
|
"x.ai/hunk-tracker/hunk-action" => {
|
|
let req = parse_params::<HunkActionRequest>(args)?;
|
|
|
|
let action_kind = match req.action.as_str() {
|
|
"accept" => HunkActionKind::Accept,
|
|
"reject" => HunkActionKind::Reject,
|
|
other => {
|
|
return Err(
|
|
acp::Error::invalid_params().data(format!("unknown action: {other}"))
|
|
);
|
|
}
|
|
};
|
|
let op = HunkSingleActionReq {
|
|
action: HunkActionReq {
|
|
hunk_id: req.hunk_id.clone(),
|
|
action: action_kind,
|
|
},
|
|
};
|
|
let sid = req.session_id.as_ref().map(|s| s.0.as_ref());
|
|
match ops.dispatch(&op, sid).await {
|
|
Ok(_) => to_ext_response(Ok(ActionResponse {
|
|
success: true,
|
|
error: None,
|
|
affected_count: Some(1),
|
|
})),
|
|
Err(e) => to_ext_response(Ok(ActionResponse {
|
|
success: false,
|
|
error: Some(e.to_string()),
|
|
affected_count: None,
|
|
})),
|
|
}
|
|
}
|
|
|
|
// ───────────────────────────────────────────────────────────────
|
|
// Bulk Actions
|
|
// ───────────────────────────────────────────────────────────────
|
|
"x.ai/hunk-tracker/file-action" => {
|
|
let req = parse_params::<FileActionRequest>(args)?;
|
|
|
|
let action_kind = match req.action.as_str() {
|
|
"accept" => HunkActionKind::Accept,
|
|
"reject" => HunkActionKind::Reject,
|
|
other => {
|
|
return Err(
|
|
acp::Error::invalid_params().data(format!("unknown action: {other}"))
|
|
);
|
|
}
|
|
};
|
|
let op = HunkFileActionReq {
|
|
path: req.path.clone(),
|
|
action: action_kind,
|
|
};
|
|
let sid = req.session_id.as_ref().map(|s| s.0.as_ref());
|
|
match ops.dispatch(&op, sid).await {
|
|
Ok(resp) => to_ext_response(Ok(ActionResponse {
|
|
success: true,
|
|
error: None,
|
|
affected_count: Some(resp.affected.len()),
|
|
})),
|
|
Err(e) => to_ext_response(Ok(ActionResponse {
|
|
success: false,
|
|
error: Some(e.to_string()),
|
|
affected_count: None,
|
|
})),
|
|
}
|
|
}
|
|
|
|
"x.ai/hunk-tracker/turn-action" => {
|
|
let req = parse_params::<TurnActionRequest>(args)?;
|
|
|
|
let action_kind = match req.action.as_str() {
|
|
"accept" => HunkActionKind::Accept,
|
|
"reject" => HunkActionKind::Reject,
|
|
other => {
|
|
return Err(
|
|
acp::Error::invalid_params().data(format!("unknown action: {other}"))
|
|
);
|
|
}
|
|
};
|
|
let op = HunkTurnActionReq {
|
|
prompt_index: req.prompt_index,
|
|
action: action_kind,
|
|
};
|
|
let sid = req.session_id.as_ref().map(|s| s.0.as_ref());
|
|
match ops.dispatch(&op, sid).await {
|
|
Ok(resp) => to_ext_response(Ok(ActionResponse {
|
|
success: true,
|
|
error: None,
|
|
affected_count: Some(resp.affected.len()),
|
|
})),
|
|
Err(e) => to_ext_response(Ok(ActionResponse {
|
|
success: false,
|
|
error: Some(e.to_string()),
|
|
affected_count: None,
|
|
})),
|
|
}
|
|
}
|
|
|
|
"x.ai/hunk-tracker/all-action" => {
|
|
let req = parse_params::<AllActionRequest>(args)?;
|
|
|
|
let action_kind = match req.action.as_str() {
|
|
"accept" => HunkActionKind::Accept,
|
|
"reject" => HunkActionKind::Reject,
|
|
other => {
|
|
return Err(
|
|
acp::Error::invalid_params().data(format!("unknown action: {other}"))
|
|
);
|
|
}
|
|
};
|
|
let op = HunkAllActionReq {
|
|
action: action_kind,
|
|
};
|
|
let sid = req.session_id.as_ref().map(|s| s.0.as_ref());
|
|
match ops.dispatch(&op, sid).await {
|
|
Ok(resp) => to_ext_response(Ok(ActionResponse {
|
|
success: true,
|
|
error: None,
|
|
affected_count: Some(resp.affected.len()),
|
|
})),
|
|
Err(e) => to_ext_response(Ok(ActionResponse {
|
|
success: false,
|
|
error: Some(e.to_string()),
|
|
affected_count: None,
|
|
})),
|
|
}
|
|
}
|
|
|
|
_ => Err(acp::Error::method_not_found()),
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::{GetAllFileContentsResponse, GetHunksResponse, compute_file_summaries};
|
|
use chrono::Utc;
|
|
use kigi_hunk_tracker::{
|
|
FileContentStatus, FileContentView, Hunk, HunkId, HunkLineInfo, HunkSource,
|
|
};
|
|
use std::collections::HashSet;
|
|
use std::path::PathBuf;
|
|
use std::sync::Arc;
|
|
|
|
fn make_hunk(
|
|
id: &str,
|
|
path: &str,
|
|
source: HunkSource,
|
|
old_text: Option<&str>,
|
|
new_text: &str,
|
|
) -> Arc<Hunk> {
|
|
let old_count = old_text.map(|text| text.lines().count()).unwrap_or(0);
|
|
let new_count = new_text.lines().count();
|
|
Arc::new(Hunk {
|
|
id: HunkId::from_string(id.to_string()),
|
|
path: PathBuf::from(path),
|
|
line_info: HunkLineInfo {
|
|
old_start: 1,
|
|
old_count,
|
|
new_start: 1,
|
|
new_count,
|
|
},
|
|
source,
|
|
old_text: old_text.map(|text| text.to_string()),
|
|
new_text: new_text.to_string(),
|
|
patch: None,
|
|
created_at: Utc::now(),
|
|
selected: false,
|
|
})
|
|
}
|
|
|
|
#[test]
|
|
fn compute_file_summaries_aggregates_counts_and_sort_order() {
|
|
let hunks = vec![
|
|
make_hunk(
|
|
"hunk-a1",
|
|
"/repo/a.txt",
|
|
HunkSource::AgentEdit { prompt_index: 2 },
|
|
Some("old1\nold2"),
|
|
"new1\nnew2\nnew3",
|
|
),
|
|
make_hunk(
|
|
"hunk-a2",
|
|
"/repo/a.txt",
|
|
HunkSource::ExternalEditOnAgentFile,
|
|
None,
|
|
"added",
|
|
),
|
|
make_hunk(
|
|
"hunk-b1",
|
|
"/repo/b.txt",
|
|
HunkSource::External,
|
|
Some("gone"),
|
|
"",
|
|
),
|
|
];
|
|
|
|
let no_staged = HashSet::new();
|
|
let files = compute_file_summaries(&hunks, &no_staged);
|
|
|
|
assert_eq!(files.len(), 2);
|
|
assert_eq!(files[0].path, PathBuf::from("/repo/a.txt"));
|
|
assert_eq!(files[1].path, PathBuf::from("/repo/b.txt"));
|
|
|
|
assert_eq!(files[0].hunk_count, 2);
|
|
assert_eq!(files[0].additions, 4);
|
|
assert_eq!(files[0].deletions, 2);
|
|
assert!(files[0].is_agent_file);
|
|
|
|
assert_eq!(files[1].hunk_count, 1);
|
|
assert_eq!(files[1].additions, 0);
|
|
assert_eq!(files[1].deletions, 1);
|
|
assert!(!files[1].is_agent_file);
|
|
}
|
|
|
|
#[test]
|
|
fn compute_file_summaries_returns_empty_for_no_hunks() {
|
|
let no_staged = HashSet::new();
|
|
let files = compute_file_summaries(&[], &no_staged);
|
|
|
|
assert!(files.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn compute_file_summaries_sets_agent_flag_only_for_agent_edits() {
|
|
let hunks = vec![
|
|
make_hunk(
|
|
"hunk-external",
|
|
"/repo/a.txt",
|
|
HunkSource::ExternalEditOnAgentFile,
|
|
Some("before"),
|
|
"after",
|
|
),
|
|
make_hunk(
|
|
"hunk-external-2",
|
|
"/repo/a.txt",
|
|
HunkSource::External,
|
|
Some("before2"),
|
|
"after2",
|
|
),
|
|
];
|
|
|
|
let no_staged = HashSet::new();
|
|
let files = compute_file_summaries(&hunks, &no_staged);
|
|
|
|
assert_eq!(files.len(), 1);
|
|
assert!(!files[0].is_agent_file);
|
|
}
|
|
|
|
#[test]
|
|
fn compute_file_summaries_counts_deletions_when_new_text_empty() {
|
|
let hunks = vec![make_hunk(
|
|
"hunk-delete",
|
|
"/repo/deleted.txt",
|
|
HunkSource::AgentEdit { prompt_index: 0 },
|
|
Some("line1\nline2\nline3"),
|
|
"",
|
|
)];
|
|
|
|
let no_staged = HashSet::new();
|
|
let files = compute_file_summaries(&hunks, &no_staged);
|
|
|
|
assert_eq!(files.len(), 1);
|
|
assert_eq!(files[0].additions, 0);
|
|
assert_eq!(files[0].deletions, 3);
|
|
}
|
|
|
|
#[test]
|
|
fn compute_file_summaries_sets_staged_flag_from_staged_paths() {
|
|
let hunks = vec![
|
|
make_hunk(
|
|
"hunk-a1",
|
|
"/repo/a.txt",
|
|
HunkSource::AgentEdit { prompt_index: 0 },
|
|
Some("old"),
|
|
"new",
|
|
),
|
|
make_hunk(
|
|
"hunk-b1",
|
|
"/repo/b.txt",
|
|
HunkSource::External,
|
|
Some("old"),
|
|
"new",
|
|
),
|
|
];
|
|
|
|
let staged = HashSet::from([PathBuf::from("/repo/a.txt")]);
|
|
let files = compute_file_summaries(&hunks, &staged);
|
|
|
|
assert_eq!(files.len(), 2);
|
|
// a.txt is staged
|
|
assert!(files[0].staged);
|
|
// b.txt is not staged
|
|
assert!(!files[1].staged);
|
|
}
|
|
|
|
// =========================================================================
|
|
// GetHunksResponse Serialization Tests
|
|
// =========================================================================
|
|
// These tests verify that the ACP get-hunks response correctly serializes
|
|
// the new explicit status fields (baseline, current) alongside legacy fields.
|
|
|
|
/// GetHunksResponse serializes Full status with all fields
|
|
#[test]
|
|
fn get_hunks_response_serializes_full_status() {
|
|
let baseline_text = "baseline content\n";
|
|
let current_text = "current content\n";
|
|
|
|
let response = GetHunksResponse {
|
|
hunks: vec![],
|
|
baseline: Some(FileContentView::full(baseline_text.to_string())),
|
|
current: Some(FileContentView::full(current_text.to_string())),
|
|
baseline_content: Some(baseline_text.to_string()),
|
|
current_content: Some(current_text.to_string()),
|
|
};
|
|
|
|
let json = serde_json::to_value(&response).unwrap();
|
|
|
|
// Verify baseline view fields
|
|
let baseline = json.get("baseline").expect("baseline should be present");
|
|
assert_eq!(baseline.get("status").unwrap().as_str().unwrap(), "full");
|
|
assert_eq!(
|
|
baseline.get("byteLen").unwrap().as_u64().unwrap() as usize,
|
|
baseline_text.len()
|
|
);
|
|
assert_eq!(
|
|
baseline.get("content").unwrap().as_str().unwrap(),
|
|
baseline_text
|
|
);
|
|
|
|
// Verify current view fields
|
|
let current = json.get("current").expect("current should be present");
|
|
assert_eq!(current.get("status").unwrap().as_str().unwrap(), "full");
|
|
assert_eq!(
|
|
current.get("content").unwrap().as_str().unwrap(),
|
|
current_text
|
|
);
|
|
|
|
// Verify legacy fields
|
|
assert_eq!(
|
|
json.get("baselineContent").unwrap().as_str().unwrap(),
|
|
baseline_text
|
|
);
|
|
assert_eq!(
|
|
json.get("currentContent").unwrap().as_str().unwrap(),
|
|
current_text
|
|
);
|
|
}
|
|
|
|
/// GetHunksResponse serializes Missing status
|
|
#[test]
|
|
fn get_hunks_response_serializes_missing_status() {
|
|
let response = GetHunksResponse {
|
|
hunks: vec![],
|
|
baseline: Some(FileContentView::missing()),
|
|
current: Some(FileContentView::full("new file\n".to_string())),
|
|
baseline_content: None,
|
|
current_content: Some("new file\n".to_string()),
|
|
};
|
|
|
|
let json = serde_json::to_value(&response).unwrap();
|
|
|
|
// Verify baseline is Missing (no content or byteLen)
|
|
let baseline = json.get("baseline").expect("baseline should be present");
|
|
assert_eq!(baseline.get("status").unwrap().as_str().unwrap(), "missing");
|
|
assert!(baseline.get("content").is_none());
|
|
assert!(baseline.get("byteLen").is_none());
|
|
|
|
// Legacy baseline_content should be absent (skipped when None)
|
|
assert!(json.get("baselineContent").is_none());
|
|
}
|
|
|
|
/// GetHunksResponse serializes Binary status with byte_len
|
|
#[test]
|
|
fn get_hunks_response_serializes_binary_status() {
|
|
let response = GetHunksResponse {
|
|
hunks: vec![],
|
|
baseline: Some(FileContentView::binary(Some(1024))),
|
|
current: Some(FileContentView::binary(Some(2048))),
|
|
baseline_content: None,
|
|
current_content: None,
|
|
};
|
|
|
|
let json = serde_json::to_value(&response).unwrap();
|
|
|
|
// Verify baseline is Binary
|
|
let baseline = json.get("baseline").expect("baseline should be present");
|
|
assert_eq!(baseline.get("status").unwrap().as_str().unwrap(), "binary");
|
|
assert_eq!(baseline.get("byteLen").unwrap().as_u64().unwrap(), 1024);
|
|
assert!(baseline.get("content").is_none());
|
|
|
|
// Verify current is Binary
|
|
let current = json.get("current").expect("current should be present");
|
|
assert_eq!(current.get("status").unwrap().as_str().unwrap(), "binary");
|
|
assert_eq!(current.get("byteLen").unwrap().as_u64().unwrap(), 2048);
|
|
}
|
|
|
|
/// GetHunksResponse serializes TooLarge status with byte_len
|
|
#[test]
|
|
fn get_hunks_response_serializes_too_large_status() {
|
|
let response = GetHunksResponse {
|
|
hunks: vec![],
|
|
baseline: Some(FileContentView::too_large(5_000_000)),
|
|
current: Some(FileContentView::too_large(10_000_000)),
|
|
baseline_content: None,
|
|
current_content: None,
|
|
};
|
|
|
|
let json = serde_json::to_value(&response).unwrap();
|
|
|
|
// Verify baseline is TooLarge
|
|
let baseline = json.get("baseline").expect("baseline should be present");
|
|
assert_eq!(
|
|
baseline.get("status").unwrap().as_str().unwrap(),
|
|
"tooLarge"
|
|
);
|
|
assert_eq!(
|
|
baseline.get("byteLen").unwrap().as_u64().unwrap(),
|
|
5_000_000
|
|
);
|
|
assert!(baseline.get("content").is_none());
|
|
|
|
// Verify current is TooLarge
|
|
let current = json.get("current").expect("current should be present");
|
|
assert_eq!(current.get("status").unwrap().as_str().unwrap(), "tooLarge");
|
|
assert_eq!(
|
|
current.get("byteLen").unwrap().as_u64().unwrap(),
|
|
10_000_000
|
|
);
|
|
}
|
|
|
|
/// GetHunksResponse omits baseline/current when None (get-all-hunks case)
|
|
#[test]
|
|
fn get_hunks_response_omits_none_fields() {
|
|
let response = GetHunksResponse {
|
|
hunks: vec![make_hunk(
|
|
"hunk-1",
|
|
"/repo/file.txt",
|
|
HunkSource::AgentEdit { prompt_index: 0 },
|
|
Some("old"),
|
|
"new",
|
|
)],
|
|
baseline: None,
|
|
current: None,
|
|
baseline_content: None,
|
|
current_content: None,
|
|
};
|
|
|
|
let json = serde_json::to_value(&response).unwrap();
|
|
|
|
// baseline, current, baselineContent, currentContent should all be absent
|
|
assert!(json.get("baseline").is_none());
|
|
assert!(json.get("current").is_none());
|
|
assert!(json.get("baselineContent").is_none());
|
|
assert!(json.get("currentContent").is_none());
|
|
|
|
// hunks should still be present
|
|
assert!(json.get("hunks").is_some());
|
|
assert_eq!(json.get("hunks").unwrap().as_array().unwrap().len(), 1);
|
|
}
|
|
|
|
/// FileContentView default is Missing status
|
|
#[test]
|
|
fn file_content_view_default_is_missing() {
|
|
let view = FileContentView::default();
|
|
|
|
assert_eq!(view.status, FileContentStatus::Missing);
|
|
assert!(view.byte_len.is_none());
|
|
assert!(view.content.is_none());
|
|
}
|
|
|
|
// =========================================================================
|
|
// GetAllFileContentsResponse Serialization Tests
|
|
// =========================================================================
|
|
|
|
/// GetAllFileContentsResponse serializes with all fields using camelCase
|
|
#[test]
|
|
fn get_all_file_contents_response_serializes_correctly() {
|
|
use kigi_hunk_tracker::FileContentEntry;
|
|
|
|
let response = GetAllFileContentsResponse {
|
|
files: vec![FileContentEntry {
|
|
path: PathBuf::from("/repo/foo.txt"),
|
|
baseline: FileContentView::full("old content\n".to_string()),
|
|
current: FileContentView::full("new content\n".to_string()),
|
|
is_agent_file: true,
|
|
staged: false,
|
|
}],
|
|
};
|
|
|
|
let json = serde_json::to_value(&response).unwrap();
|
|
let files = json.get("files").unwrap().as_array().unwrap();
|
|
assert_eq!(files.len(), 1);
|
|
|
|
let f = &files[0];
|
|
assert_eq!(f.get("path").unwrap().as_str().unwrap(), "/repo/foo.txt");
|
|
assert!(f.get("isAgentFile").unwrap().as_bool().unwrap());
|
|
assert!(!f.get("staged").unwrap().as_bool().unwrap());
|
|
|
|
// Baseline
|
|
let baseline = f.get("baseline").unwrap();
|
|
assert_eq!(baseline.get("status").unwrap().as_str().unwrap(), "full");
|
|
assert_eq!(
|
|
baseline.get("content").unwrap().as_str().unwrap(),
|
|
"old content\n"
|
|
);
|
|
|
|
// Current
|
|
let current = f.get("current").unwrap();
|
|
assert_eq!(current.get("status").unwrap().as_str().unwrap(), "full");
|
|
assert_eq!(
|
|
current.get("content").unwrap().as_str().unwrap(),
|
|
"new content\n"
|
|
);
|
|
}
|
|
|
|
/// GetAllFileContentsResponse handles missing baseline (new file)
|
|
#[test]
|
|
fn get_all_file_contents_response_missing_baseline() {
|
|
use kigi_hunk_tracker::FileContentEntry;
|
|
|
|
let response = GetAllFileContentsResponse {
|
|
files: vec![FileContentEntry {
|
|
path: PathBuf::from("/repo/new.txt"),
|
|
baseline: FileContentView::missing(),
|
|
current: FileContentView::full("brand new\n".to_string()),
|
|
is_agent_file: true,
|
|
staged: true,
|
|
}],
|
|
};
|
|
|
|
let json = serde_json::to_value(&response).unwrap();
|
|
let f = &json.get("files").unwrap().as_array().unwrap()[0];
|
|
|
|
assert!(f.get("staged").unwrap().as_bool().unwrap());
|
|
|
|
let baseline = f.get("baseline").unwrap();
|
|
assert_eq!(baseline.get("status").unwrap().as_str().unwrap(), "missing");
|
|
assert!(baseline.get("content").is_none());
|
|
assert!(baseline.get("byteLen").is_none());
|
|
}
|
|
|
|
/// GetAllFileContentsResponse handles binary files
|
|
#[test]
|
|
fn get_all_file_contents_response_binary_file() {
|
|
use kigi_hunk_tracker::FileContentEntry;
|
|
|
|
let response = GetAllFileContentsResponse {
|
|
files: vec![FileContentEntry {
|
|
path: PathBuf::from("/repo/image.png"),
|
|
baseline: FileContentView::binary(Some(1024)),
|
|
current: FileContentView::binary(Some(2048)),
|
|
is_agent_file: false,
|
|
staged: false,
|
|
}],
|
|
};
|
|
|
|
let json = serde_json::to_value(&response).unwrap();
|
|
let f = &json.get("files").unwrap().as_array().unwrap()[0];
|
|
|
|
let baseline = f.get("baseline").unwrap();
|
|
assert_eq!(baseline.get("status").unwrap().as_str().unwrap(), "binary");
|
|
assert_eq!(baseline.get("byteLen").unwrap().as_u64().unwrap(), 1024);
|
|
assert!(baseline.get("content").is_none());
|
|
|
|
let current = f.get("current").unwrap();
|
|
assert_eq!(current.get("status").unwrap().as_str().unwrap(), "binary");
|
|
assert_eq!(current.get("byteLen").unwrap().as_u64().unwrap(), 2048);
|
|
}
|
|
|
|
/// GetAllFileContentsResponse returns empty files array when no tracked files
|
|
#[test]
|
|
fn get_all_file_contents_response_empty() {
|
|
let response = GetAllFileContentsResponse { files: vec![] };
|
|
|
|
let json = serde_json::to_value(&response).unwrap();
|
|
let files = json.get("files").unwrap().as_array().unwrap();
|
|
assert!(files.is_empty());
|
|
}
|
|
|
|
/// GetAllFileContentsResponse with multiple files preserves all entries
|
|
#[test]
|
|
fn get_all_file_contents_response_multiple_files() {
|
|
use kigi_hunk_tracker::FileContentEntry;
|
|
|
|
let response = GetAllFileContentsResponse {
|
|
files: vec![
|
|
FileContentEntry {
|
|
path: PathBuf::from("/repo/agent.rs"),
|
|
baseline: FileContentView::full("fn main() {}\n".to_string()),
|
|
current: FileContentView::full("fn main() { run(); }\n".to_string()),
|
|
is_agent_file: true,
|
|
staged: true,
|
|
},
|
|
FileContentEntry {
|
|
path: PathBuf::from("/repo/readme.md"),
|
|
baseline: FileContentView::full("# Title\n".to_string()),
|
|
current: FileContentView::full("# New Title\n".to_string()),
|
|
is_agent_file: false,
|
|
staged: false,
|
|
},
|
|
],
|
|
};
|
|
|
|
let json = serde_json::to_value(&response).unwrap();
|
|
let files = json.get("files").unwrap().as_array().unwrap();
|
|
assert_eq!(files.len(), 2);
|
|
|
|
// First file: agent, staged
|
|
assert!(files[0].get("isAgentFile").unwrap().as_bool().unwrap());
|
|
assert!(files[0].get("staged").unwrap().as_bool().unwrap());
|
|
|
|
// Second file: not agent, not staged
|
|
assert!(!files[1].get("isAgentFile").unwrap().as_bool().unwrap());
|
|
assert!(!files[1].get("staged").unwrap().as_bool().unwrap());
|
|
}
|
|
}
|