M0: compilable skeleton — Kigi 0.1.0 fork surgery
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).
This commit is contained in:
@@ -0,0 +1,782 @@
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
|
||||
use chrono::Utc;
|
||||
use tokio::sync::mpsc;
|
||||
|
||||
use crate::events::HunkEvent;
|
||||
use crate::types::{Hunk, HunkId, HunkLineInfo, HunkSource};
|
||||
|
||||
use super::*;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn sample_agent_hunk() -> Hunk {
|
||||
Hunk {
|
||||
id: HunkId::from_string("test-hunk-001".into()),
|
||||
path: PathBuf::from("/tmp/foo.rs"),
|
||||
line_info: HunkLineInfo {
|
||||
old_start: 10,
|
||||
old_count: 3,
|
||||
new_start: 10,
|
||||
new_count: 5,
|
||||
},
|
||||
source: HunkSource::AgentEdit { prompt_index: 2 },
|
||||
old_text: Some("old\nlines\nhere".into()),
|
||||
new_text: "new\nlines\nhere\nplus\nmore".into(),
|
||||
patch: None,
|
||||
created_at: Utc::now(),
|
||||
selected: false,
|
||||
}
|
||||
}
|
||||
|
||||
fn sample_external_hunk() -> Hunk {
|
||||
Hunk {
|
||||
id: HunkId::from_string("test-hunk-002".into()),
|
||||
path: PathBuf::from("/tmp/bar.rs"),
|
||||
line_info: HunkLineInfo {
|
||||
old_start: 1,
|
||||
old_count: 0,
|
||||
new_start: 1,
|
||||
new_count: 4,
|
||||
},
|
||||
source: HunkSource::External,
|
||||
old_text: None,
|
||||
new_text: "line1\nline2\nline3\nline4".into(),
|
||||
patch: None,
|
||||
created_at: Utc::now(),
|
||||
selected: false,
|
||||
}
|
||||
}
|
||||
|
||||
fn sample_deletion_hunk() -> Hunk {
|
||||
Hunk {
|
||||
id: HunkId::from_string("test-hunk-003".into()),
|
||||
path: PathBuf::from("/tmp/del.rs"),
|
||||
line_info: HunkLineInfo {
|
||||
old_start: 5,
|
||||
old_count: 3,
|
||||
new_start: 0,
|
||||
new_count: 0,
|
||||
},
|
||||
source: HunkSource::AgentEdit { prompt_index: 1 },
|
||||
old_text: Some("deleted\nlines\nhere".into()),
|
||||
new_text: String::new(),
|
||||
patch: None,
|
||||
created_at: Utc::now(),
|
||||
selected: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// In-memory writer for testing.
|
||||
struct VecWriter {
|
||||
records: Vec<HunkRecord>,
|
||||
flush_count: usize,
|
||||
}
|
||||
|
||||
impl VecWriter {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
records: Vec::new(),
|
||||
flush_count: 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl HunkRecordWriter for VecWriter {
|
||||
async fn write(&mut self, record: &HunkRecord) -> std::io::Result<()> {
|
||||
self.records.push(record.clone());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn flush(&mut self) -> std::io::Result<()> {
|
||||
self.flush_count += 1;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Thread-safe wrapper around `VecWriter` for use with `run_loc_sink`
|
||||
/// (which takes ownership of the writer).
|
||||
struct SharedWriter(std::sync::Arc<std::sync::Mutex<VecWriter>>);
|
||||
|
||||
impl SharedWriter {
|
||||
fn new() -> (Self, std::sync::Arc<std::sync::Mutex<VecWriter>>) {
|
||||
let inner = std::sync::Arc::new(std::sync::Mutex::new(VecWriter::new()));
|
||||
(Self(inner.clone()), inner)
|
||||
}
|
||||
}
|
||||
|
||||
impl HunkRecordWriter for SharedWriter {
|
||||
async fn write(&mut self, record: &HunkRecord) -> std::io::Result<()> {
|
||||
// Do the work inside the lock synchronously — don't hold MutexGuard across .await
|
||||
self.0.lock().unwrap().records.push(record.clone());
|
||||
Ok(())
|
||||
}
|
||||
async fn flush(&mut self) -> std::io::Result<()> {
|
||||
self.0.lock().unwrap().flush_count += 1;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn make_ctx() -> LocSinkContext {
|
||||
LocSinkContext {
|
||||
session_id: "sess-001".into(),
|
||||
agent_id: "agent-abc".into(),
|
||||
user_id: Some("user-xyz".into()),
|
||||
aggregate_tx: None,
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Unit tests: HunkRecord::from_hunk
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn from_hunk_agent_edit() {
|
||||
let hunk = sample_agent_hunk();
|
||||
let record = HunkRecord::from_hunk(
|
||||
&hunk,
|
||||
"sess-1",
|
||||
"agent-1",
|
||||
Some("user-1"),
|
||||
EventType::Added,
|
||||
&hunk.source,
|
||||
);
|
||||
|
||||
assert_eq!(record.hunk_id, HunkId::from_string("test-hunk-001".into()));
|
||||
assert_eq!(record.file_path, PathBuf::from("/tmp/foo.rs"));
|
||||
assert_eq!(record.hunk_start, 10);
|
||||
assert_eq!(record.hunk_end, 14); // 10 + 5 - 1
|
||||
assert_eq!(record.lines_added, 5);
|
||||
assert_eq!(record.lines_removed, 3);
|
||||
assert_eq!(record.author_type, Some(AuthorType::Agent));
|
||||
assert_eq!(record.author_id, Some("agent-1".into()));
|
||||
assert_eq!(record.agent_id, "agent-1");
|
||||
assert_eq!(record.session_id, "sess-1");
|
||||
assert_eq!(record.prompt_index, Some(2));
|
||||
assert_eq!(record.source_type, Some(SourceType::AgentEdit));
|
||||
assert_eq!(record.event_type, EventType::Added);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn from_hunk_event_type_updated() {
|
||||
let hunk = sample_agent_hunk();
|
||||
let record = HunkRecord::from_hunk(
|
||||
&hunk,
|
||||
"sess-1",
|
||||
"agent-1",
|
||||
None,
|
||||
EventType::Updated,
|
||||
&hunk.source,
|
||||
);
|
||||
assert_eq!(record.event_type, EventType::Updated);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn from_hunk_external() {
|
||||
let hunk = sample_external_hunk();
|
||||
let record = HunkRecord::from_hunk(
|
||||
&hunk,
|
||||
"sess-1",
|
||||
"agent-1",
|
||||
Some("user-1"),
|
||||
EventType::Added,
|
||||
&hunk.source,
|
||||
);
|
||||
|
||||
assert_eq!(record.author_type, Some(AuthorType::Human));
|
||||
assert_eq!(record.author_id, Some("user-1".into()));
|
||||
assert_eq!(record.prompt_index, None);
|
||||
assert_eq!(record.source_type, Some(SourceType::External));
|
||||
assert_eq!(record.hunk_start, 1);
|
||||
assert_eq!(record.hunk_end, 4); // 1 + 4 - 1
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn from_hunk_external_no_user_id() {
|
||||
let hunk = sample_external_hunk();
|
||||
let record = HunkRecord::from_hunk(
|
||||
&hunk,
|
||||
"sess-1",
|
||||
"agent-1",
|
||||
None,
|
||||
EventType::Added,
|
||||
&hunk.source,
|
||||
);
|
||||
|
||||
assert_eq!(record.author_type, Some(AuthorType::Human));
|
||||
assert_eq!(record.author_id, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn from_hunk_external_edit_on_agent_file() {
|
||||
let mut hunk = sample_external_hunk();
|
||||
hunk.source = HunkSource::ExternalEditOnAgentFile;
|
||||
let record = HunkRecord::from_hunk(
|
||||
&hunk,
|
||||
"sess-1",
|
||||
"agent-1",
|
||||
Some("user-1"),
|
||||
EventType::Added,
|
||||
&hunk.source,
|
||||
);
|
||||
|
||||
assert_eq!(record.author_type, Some(AuthorType::Human));
|
||||
assert_eq!(
|
||||
record.source_type,
|
||||
Some(SourceType::ExternalEditOnAgentFile)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn from_hunk_pure_deletion() {
|
||||
let hunk = sample_deletion_hunk();
|
||||
let record = HunkRecord::from_hunk(
|
||||
&hunk,
|
||||
"sess-1",
|
||||
"agent-1",
|
||||
None,
|
||||
EventType::Added,
|
||||
&hunk.source,
|
||||
);
|
||||
|
||||
// Pure deletion: new_count == 0, so uses old_start/old_count
|
||||
assert_eq!(record.hunk_start, 5);
|
||||
assert_eq!(record.hunk_end, 7); // 5 + 3 - 1
|
||||
assert_eq!(record.lines_added, 0i64);
|
||||
assert_eq!(record.lines_removed, 3i64);
|
||||
}
|
||||
|
||||
/// Verify that attribution_source overrides the hunk's preserved source.
|
||||
#[test]
|
||||
fn from_hunk_trigger_source_overrides_preserved_source() {
|
||||
let hunk = sample_agent_hunk(); // hunk.source = AgentEdit
|
||||
let trigger = HunkSource::ExternalEditOnAgentFile;
|
||||
let record = HunkRecord::from_hunk(
|
||||
&hunk,
|
||||
"sess-1",
|
||||
"agent-1",
|
||||
Some("user-1"),
|
||||
EventType::Updated,
|
||||
&trigger,
|
||||
);
|
||||
|
||||
assert_eq!(record.author_type, Some(AuthorType::Human));
|
||||
assert_eq!(
|
||||
record.source_type,
|
||||
Some(SourceType::ExternalEditOnAgentFile)
|
||||
);
|
||||
assert_eq!(record.author_id, Some("user-1".into()));
|
||||
assert_eq!(record.prompt_index, None);
|
||||
assert_eq!(record.event_type, EventType::Updated);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sink tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn sink_processes_added_and_content_changed() {
|
||||
let (tx, rx) = mpsc::unbounded_channel();
|
||||
let ctx = make_ctx();
|
||||
let cancel = tokio_util::sync::CancellationToken::new();
|
||||
|
||||
let hunk = sample_agent_hunk();
|
||||
let mut updated_hunk = sample_agent_hunk();
|
||||
updated_hunk.line_info.new_count = 8; // grew from 5 to 8 lines
|
||||
|
||||
// Send a mix of events — only HunkAdded and HunkContentChanged should produce records
|
||||
tx.send(HunkEvent::FileAdded {
|
||||
path: PathBuf::from("/tmp/foo.rs"),
|
||||
is_agent_file: true,
|
||||
})
|
||||
.unwrap();
|
||||
tx.send(HunkEvent::HunkAdded {
|
||||
path: PathBuf::from("/tmp/foo.rs"),
|
||||
hunk: Arc::new(hunk),
|
||||
})
|
||||
.unwrap();
|
||||
tx.send(HunkEvent::HunkContentChanged {
|
||||
path: PathBuf::from("/tmp/foo.rs"),
|
||||
hunk: Arc::new(updated_hunk),
|
||||
trigger_source: HunkSource::AgentEdit { prompt_index: 2 },
|
||||
prev_lines_added: 5, // original hunk had 5 lines added
|
||||
prev_lines_removed: 3, // original hunk had 3 lines removed
|
||||
})
|
||||
.unwrap();
|
||||
tx.send(HunkEvent::HunkMoved {
|
||||
path: PathBuf::from("/tmp/foo.rs"),
|
||||
hunk_id: HunkId::from_string("test-hunk-001".into()),
|
||||
new_line_info: HunkLineInfo {
|
||||
old_start: 10,
|
||||
old_count: 3,
|
||||
new_start: 12,
|
||||
new_count: 5,
|
||||
},
|
||||
})
|
||||
.unwrap();
|
||||
tx.send(HunkEvent::HunkRemoved {
|
||||
path: PathBuf::from("/tmp/foo.rs"),
|
||||
hunk_id: HunkId::from_string("test-hunk-001".into()),
|
||||
reason: crate::events::HunkRemovalReason::Superseded,
|
||||
})
|
||||
.unwrap();
|
||||
tx.send(HunkEvent::FileRemoved {
|
||||
path: PathBuf::from("/tmp/foo.rs"),
|
||||
})
|
||||
.unwrap();
|
||||
tx.send(HunkEvent::BaselineUpdated {
|
||||
path: PathBuf::from("/tmp/foo.rs"),
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
// Drop sender to close the channel
|
||||
drop(tx);
|
||||
|
||||
let (shared_writer, shared) = SharedWriter::new();
|
||||
run_loc_sink(rx, shared_writer, ctx, cancel).await;
|
||||
|
||||
let w = shared.lock().unwrap();
|
||||
assert_eq!(
|
||||
w.records.len(),
|
||||
3,
|
||||
"HunkAdded + HunkContentChanged + HunkRemoved should produce 3 records"
|
||||
);
|
||||
|
||||
// First record: added (full counts)
|
||||
assert_eq!(
|
||||
w.records[0].hunk_id,
|
||||
HunkId::from_string("test-hunk-001".into())
|
||||
);
|
||||
assert_eq!(w.records[0].event_type, EventType::Added);
|
||||
assert_eq!(w.records[0].lines_added, 5);
|
||||
assert_eq!(w.records[0].lines_removed, 3);
|
||||
|
||||
// Second record: updated (delta: 8-5=3 added, 3-3=0 removed)
|
||||
assert_eq!(w.records[1].event_type, EventType::Updated);
|
||||
assert_eq!(w.records[1].lines_added, 3i64);
|
||||
assert_eq!(w.records[1].lines_removed, 0i64);
|
||||
|
||||
// Third record: removed (negates accumulated: -(5+3)=-8, -(3+0)=-3)
|
||||
assert_eq!(w.records[2].event_type, EventType::Removed);
|
||||
assert_eq!(w.records[2].lines_added, -8i64);
|
||||
assert_eq!(w.records[2].lines_removed, -3i64);
|
||||
|
||||
// SUM should be zero
|
||||
let total: i64 = w.records.iter().map(|r| r.lines_added).sum();
|
||||
assert_eq!(total, 0);
|
||||
}
|
||||
|
||||
/// When a hunk is removed, the sink must emit a negating record so that
|
||||
/// SUM-based totals zero out the hunk's contribution.
|
||||
#[tokio::test]
|
||||
async fn sink_removed_hunk_zeroes_out_accumulated_total() {
|
||||
let (tx, rx) = mpsc::unbounded_channel();
|
||||
let ctx = make_ctx();
|
||||
let cancel = tokio_util::sync::CancellationToken::new();
|
||||
|
||||
let hunk = sample_agent_hunk(); // lines_added=5, lines_removed=3
|
||||
let hunk_id = hunk.id.clone();
|
||||
let path = hunk.path.clone();
|
||||
|
||||
// Add, then remove
|
||||
tx.send(HunkEvent::HunkAdded {
|
||||
path: path.clone(),
|
||||
hunk: Arc::new(hunk),
|
||||
})
|
||||
.unwrap();
|
||||
tx.send(HunkEvent::HunkRemoved {
|
||||
path: path.clone(),
|
||||
hunk_id: hunk_id.clone(),
|
||||
reason: crate::events::HunkRemovalReason::Rejected,
|
||||
})
|
||||
.unwrap();
|
||||
drop(tx);
|
||||
|
||||
let (shared_writer, shared) = SharedWriter::new();
|
||||
run_loc_sink(rx, shared_writer, ctx, cancel).await;
|
||||
|
||||
let w = shared.lock().unwrap();
|
||||
assert_eq!(w.records.len(), 2, "Should have added + removed records");
|
||||
|
||||
// First: added
|
||||
assert_eq!(w.records[0].event_type, EventType::Added);
|
||||
assert_eq!(w.records[0].lines_added, 5);
|
||||
assert_eq!(w.records[0].lines_removed, 3);
|
||||
|
||||
// Second: removed (negated)
|
||||
assert_eq!(w.records[1].event_type, EventType::Removed);
|
||||
assert_eq!(w.records[1].lines_added, -5);
|
||||
assert_eq!(w.records[1].lines_removed, -3);
|
||||
|
||||
// SUM should be zero
|
||||
let total_added: i64 = w.records.iter().map(|r| r.lines_added).sum();
|
||||
let total_removed: i64 = w.records.iter().map(|r| r.lines_removed).sum();
|
||||
assert_eq!(total_added, 0, "Removed hunk should zero out lines_added");
|
||||
assert_eq!(
|
||||
total_removed, 0,
|
||||
"Removed hunk should zero out lines_removed"
|
||||
);
|
||||
}
|
||||
|
||||
/// Full scenario: agent adds, human expands, then hunk is removed.
|
||||
/// The negating record must cancel the entire accumulated total.
|
||||
#[tokio::test]
|
||||
async fn sink_removed_hunk_after_updates_zeroes_correctly() {
|
||||
let (tx, rx) = mpsc::unbounded_channel();
|
||||
let ctx = make_ctx();
|
||||
let cancel = tokio_util::sync::CancellationToken::new();
|
||||
|
||||
let hunk = sample_agent_hunk(); // lines_added=5, lines_removed=3
|
||||
let hunk_id = hunk.id.clone();
|
||||
let path = hunk.path.clone();
|
||||
|
||||
let mut updated = sample_agent_hunk();
|
||||
updated.line_info.new_count = 8; // grew from 5 → 8
|
||||
|
||||
// Add → update → remove
|
||||
tx.send(HunkEvent::HunkAdded {
|
||||
path: path.clone(),
|
||||
hunk: Arc::new(hunk),
|
||||
})
|
||||
.unwrap();
|
||||
tx.send(HunkEvent::HunkContentChanged {
|
||||
path: path.clone(),
|
||||
hunk: Arc::new(updated),
|
||||
trigger_source: HunkSource::ExternalEditOnAgentFile,
|
||||
prev_lines_added: 5,
|
||||
prev_lines_removed: 3,
|
||||
})
|
||||
.unwrap();
|
||||
tx.send(HunkEvent::HunkRemoved {
|
||||
path: path.clone(),
|
||||
hunk_id: hunk_id.clone(),
|
||||
reason: crate::events::HunkRemovalReason::Superseded,
|
||||
})
|
||||
.unwrap();
|
||||
drop(tx);
|
||||
|
||||
let (shared_writer, shared) = SharedWriter::new();
|
||||
run_loc_sink(rx, shared_writer, ctx, cancel).await;
|
||||
|
||||
let w = shared.lock().unwrap();
|
||||
assert_eq!(w.records.len(), 3, "added + updated + removed");
|
||||
|
||||
// SUM should be zero: the hunk was fully removed
|
||||
let total_added: i64 = w.records.iter().map(|r| r.lines_added).sum();
|
||||
let total_removed: i64 = w.records.iter().map(|r| r.lines_removed).sum();
|
||||
assert_eq!(total_added, 0);
|
||||
assert_eq!(total_removed, 0);
|
||||
}
|
||||
|
||||
/// Accepted hunks keep their LOC contribution — no negating record is written.
|
||||
#[tokio::test]
|
||||
async fn sink_accepted_hunk_preserves_loc() {
|
||||
let (tx, rx) = mpsc::unbounded_channel();
|
||||
let ctx = make_ctx();
|
||||
let cancel = tokio_util::sync::CancellationToken::new();
|
||||
|
||||
let hunk = sample_agent_hunk(); // lines_added=5, lines_removed=3
|
||||
let hunk_id = hunk.id.clone();
|
||||
let path = hunk.path.clone();
|
||||
|
||||
tx.send(HunkEvent::HunkAdded {
|
||||
path: path.clone(),
|
||||
hunk: Arc::new(hunk),
|
||||
})
|
||||
.unwrap();
|
||||
tx.send(HunkEvent::HunkRemoved {
|
||||
path: path.clone(),
|
||||
hunk_id: hunk_id.clone(),
|
||||
reason: crate::events::HunkRemovalReason::Accepted,
|
||||
})
|
||||
.unwrap();
|
||||
drop(tx);
|
||||
|
||||
let (shared_writer, shared) = SharedWriter::new();
|
||||
run_loc_sink(rx, shared_writer, ctx, cancel).await;
|
||||
|
||||
let w = shared.lock().unwrap();
|
||||
// Only the Added record — no Removed record for accepted hunks
|
||||
assert_eq!(
|
||||
w.records.len(),
|
||||
1,
|
||||
"Accepted hunk should NOT produce a Removed record"
|
||||
);
|
||||
assert_eq!(w.records[0].event_type, EventType::Added);
|
||||
|
||||
// LOC is preserved
|
||||
let total_added: i64 = w.records.iter().map(|r| r.lines_added).sum();
|
||||
assert_eq!(total_added, 5, "Accepted hunk's LOC should be preserved");
|
||||
}
|
||||
|
||||
/// When a hunk *shrinks* (e.g., human deletes 3 of 10 agent lines), the
|
||||
/// delta must be negative so SUM-based LOC totals stay accurate.
|
||||
#[tokio::test]
|
||||
async fn sink_shrinking_hunk_produces_negative_delta() {
|
||||
let (tx, rx) = mpsc::unbounded_channel();
|
||||
let ctx = make_ctx();
|
||||
let cancel = tokio_util::sync::CancellationToken::new();
|
||||
|
||||
// Agent adds 10 lines
|
||||
let mut hunk = sample_agent_hunk();
|
||||
hunk.line_info.new_count = 10;
|
||||
hunk.line_info.old_count = 0;
|
||||
|
||||
// Human deletes 3 → hunk shrinks to 7
|
||||
let mut shrunk = sample_agent_hunk();
|
||||
shrunk.line_info.new_count = 7;
|
||||
shrunk.line_info.old_count = 0;
|
||||
|
||||
tx.send(HunkEvent::HunkAdded {
|
||||
path: hunk.path.clone(),
|
||||
hunk: Arc::new(hunk),
|
||||
})
|
||||
.unwrap();
|
||||
tx.send(HunkEvent::HunkContentChanged {
|
||||
path: shrunk.path.clone(),
|
||||
hunk: Arc::new(shrunk),
|
||||
trigger_source: HunkSource::ExternalEditOnAgentFile,
|
||||
prev_lines_added: 10,
|
||||
prev_lines_removed: 0,
|
||||
})
|
||||
.unwrap();
|
||||
drop(tx);
|
||||
|
||||
let (shared_writer, shared) = SharedWriter::new();
|
||||
run_loc_sink(rx, shared_writer, ctx, cancel).await;
|
||||
|
||||
let w = shared.lock().unwrap();
|
||||
assert_eq!(w.records.len(), 2);
|
||||
|
||||
// First: agent added 10 lines
|
||||
assert_eq!(w.records[0].author_type, Some(AuthorType::Agent));
|
||||
assert_eq!(w.records[0].lines_added, 10i64);
|
||||
|
||||
// Second: human shrunk the hunk by 3 → negative delta
|
||||
assert_eq!(w.records[1].author_type, Some(AuthorType::Human));
|
||||
assert_eq!(w.records[1].event_type, EventType::Updated);
|
||||
assert_eq!(w.records[1].lines_added, -3i64);
|
||||
assert_eq!(w.records[1].lines_removed, 0i64);
|
||||
|
||||
// SUM(lines_added) by author: agent=10, human=-3, net=7 ✅
|
||||
let agent_total: i64 = w
|
||||
.records
|
||||
.iter()
|
||||
.filter(|r| r.author_type == Some(AuthorType::Agent))
|
||||
.map(|r| r.lines_added)
|
||||
.sum();
|
||||
let human_total: i64 = w
|
||||
.records
|
||||
.iter()
|
||||
.filter(|r| r.author_type == Some(AuthorType::Human))
|
||||
.map(|r| r.lines_added)
|
||||
.sum();
|
||||
assert_eq!(agent_total, 10);
|
||||
assert_eq!(human_total, -3);
|
||||
assert_eq!(agent_total + human_total, 7); // net lines in file
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn sink_drains_on_cancellation() {
|
||||
let (tx, rx) = mpsc::unbounded_channel();
|
||||
let ctx = make_ctx();
|
||||
let cancel = tokio_util::sync::CancellationToken::new();
|
||||
|
||||
let hunk1 = sample_agent_hunk();
|
||||
let hunk2 = sample_external_hunk();
|
||||
|
||||
// Send events before cancellation
|
||||
tx.send(HunkEvent::HunkAdded {
|
||||
path: hunk1.path.clone(),
|
||||
hunk: Arc::new(hunk1),
|
||||
})
|
||||
.unwrap();
|
||||
tx.send(HunkEvent::HunkAdded {
|
||||
path: hunk2.path.clone(),
|
||||
hunk: Arc::new(hunk2),
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
// Cancel immediately
|
||||
cancel.cancel();
|
||||
|
||||
let (shared_writer, shared) = SharedWriter::new();
|
||||
run_loc_sink(rx, shared_writer, ctx, cancel).await;
|
||||
|
||||
let w = shared.lock().unwrap();
|
||||
assert_eq!(
|
||||
w.records.len(),
|
||||
2,
|
||||
"Both events should be drained on cancellation"
|
||||
);
|
||||
assert!(w.flush_count > 0, "Writer should be flushed on shutdown");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// JSONL round-trip test
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn jsonl_round_trip() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("hunk_records.jsonl");
|
||||
let mut writer = JsonlHunkRecordWriter::new(path.clone());
|
||||
|
||||
let hunk = sample_agent_hunk();
|
||||
let record = HunkRecord::from_hunk(
|
||||
&hunk,
|
||||
"sess-rt",
|
||||
"agent-rt",
|
||||
Some("user-rt"),
|
||||
EventType::Added,
|
||||
&hunk.source,
|
||||
);
|
||||
|
||||
writer.write(&record).await.unwrap();
|
||||
writer.flush().await.unwrap();
|
||||
|
||||
// Read back and deserialize
|
||||
let contents = tokio::fs::read_to_string(&path).await.unwrap();
|
||||
let lines: Vec<&str> = contents.trim().lines().collect();
|
||||
assert_eq!(lines.len(), 1);
|
||||
|
||||
let deserialized: HunkRecord = serde_json::from_str(lines[0]).unwrap();
|
||||
assert_eq!(deserialized, record);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn jsonl_writer_creates_parent_dirs() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("nested").join("deep").join("records.jsonl");
|
||||
let mut writer = JsonlHunkRecordWriter::new(path.clone());
|
||||
|
||||
let hunk = sample_agent_hunk();
|
||||
let record = HunkRecord::from_hunk(
|
||||
&hunk,
|
||||
"sess-1",
|
||||
"agent-1",
|
||||
None,
|
||||
EventType::Added,
|
||||
&hunk.source,
|
||||
);
|
||||
|
||||
writer.write(&record).await.unwrap();
|
||||
assert!(path.exists());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn jsonl_writer_appends() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("records.jsonl");
|
||||
let mut writer = JsonlHunkRecordWriter::new(path.clone());
|
||||
|
||||
let hunk1 = sample_agent_hunk();
|
||||
let hunk2 = sample_external_hunk();
|
||||
let r1 = HunkRecord::from_hunk(&hunk1, "s", "a", None, EventType::Added, &hunk1.source);
|
||||
let r2 = HunkRecord::from_hunk(&hunk2, "s", "a", None, EventType::Added, &hunk2.source);
|
||||
|
||||
writer.write(&r1).await.unwrap();
|
||||
writer.write(&r2).await.unwrap();
|
||||
writer.flush().await.unwrap();
|
||||
|
||||
let contents = tokio::fs::read_to_string(&path).await.unwrap();
|
||||
let lines: Vec<&str> = contents.trim().lines().collect();
|
||||
assert_eq!(lines.len(), 2);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Deserialization validation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Invalid enum values must be rejected during deserialization.
|
||||
/// This validates that the serde enum gate works — a typo like "foo"
|
||||
/// in the JSONL can't silently sneak past.
|
||||
#[test]
|
||||
fn deserialize_rejects_invalid_author_type() {
|
||||
let hunk = sample_agent_hunk();
|
||||
let record = HunkRecord::from_hunk(&hunk, "s", "a", None, EventType::Added, &hunk.source);
|
||||
let mut json = serde_json::to_string(&record).unwrap();
|
||||
|
||||
// Replace valid "agent" with invalid "foo"
|
||||
json = json.replacen("\"agent\"", "\"foo\"", 1);
|
||||
let result = serde_json::from_str::<HunkRecord>(&json);
|
||||
assert!(result.is_err(), "Should reject invalid author_type");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deserialize_rejects_invalid_event_type() {
|
||||
let hunk = sample_agent_hunk();
|
||||
let record = HunkRecord::from_hunk(&hunk, "s", "a", None, EventType::Added, &hunk.source);
|
||||
let mut json = serde_json::to_string(&record).unwrap();
|
||||
|
||||
json = json.replacen("\"added\"", "\"foo\"", 1);
|
||||
let result = serde_json::from_str::<HunkRecord>(&json);
|
||||
assert!(result.is_err(), "Should reject invalid event_type");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deserialize_rejects_invalid_source_type() {
|
||||
let hunk = sample_agent_hunk();
|
||||
let record = HunkRecord::from_hunk(&hunk, "s", "a", None, EventType::Added, &hunk.source);
|
||||
let mut json = serde_json::to_string(&record).unwrap();
|
||||
|
||||
json = json.replacen("\"agentEdit\"", "\"foo\"", 1);
|
||||
let result = serde_json::from_str::<HunkRecord>(&json);
|
||||
assert!(result.is_err(), "Should reject invalid source_type");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Writer failure resilience
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// The sink must continue processing events even when the writer fails.
|
||||
/// This validates the "log warning and drop the record" error policy.
|
||||
#[tokio::test]
|
||||
async fn sink_continues_after_writer_failure() {
|
||||
let (tx, rx) = mpsc::unbounded_channel();
|
||||
let ctx = make_ctx();
|
||||
let cancel = tokio_util::sync::CancellationToken::new();
|
||||
|
||||
let hunk1 = sample_agent_hunk();
|
||||
let hunk2 = sample_external_hunk();
|
||||
|
||||
tx.send(HunkEvent::HunkAdded {
|
||||
path: hunk1.path.clone(),
|
||||
hunk: Arc::new(hunk1),
|
||||
})
|
||||
.unwrap();
|
||||
tx.send(HunkEvent::HunkAdded {
|
||||
path: hunk2.path.clone(),
|
||||
hunk: Arc::new(hunk2),
|
||||
})
|
||||
.unwrap();
|
||||
drop(tx);
|
||||
|
||||
/// Writer that always fails on write but tracks flush calls.
|
||||
struct FailingWriter(std::sync::Arc<std::sync::Mutex<bool>>);
|
||||
|
||||
impl HunkRecordWriter for FailingWriter {
|
||||
async fn write(&mut self, _record: &HunkRecord) -> std::io::Result<()> {
|
||||
Err(std::io::Error::other("disk full"))
|
||||
}
|
||||
async fn flush(&mut self) -> std::io::Result<()> {
|
||||
*self.0.lock().unwrap() = true;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
let flush_called = std::sync::Arc::new(std::sync::Mutex::new(false));
|
||||
let writer = FailingWriter(flush_called.clone());
|
||||
|
||||
// This must not panic — the sink should log warnings and continue.
|
||||
run_loc_sink(rx, writer, ctx, cancel).await;
|
||||
|
||||
// Flush must still be called on shutdown (sink didn't abort early).
|
||||
assert!(
|
||||
*flush_called.lock().unwrap(),
|
||||
"Sink should flush on shutdown even after write failures"
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user