§9 acceptance: grep-zero sweep — every internal x.ai/grok identifier renamed

The PRD's first acceptance gate now holds: grep -RinE '\bx\.ai\b|grok'
crates/ --include='*.rs' → 0 matches (exempt: NOTICE and third-party
license archives, README provenance, and the required 'Based on Grok
Build Open Source' attribution, now sourced from version_attribution.txt).

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

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

Gates: §9 grep 0; fmt clean; workspace check/clippy 0/0 (-D warnings);
FULL cargo test --workspace: 234 suites, 21,961 passed, 0 failed;
deny advisories ok.
This commit is contained in:
2026-07-18 02:48:46 -04:00
parent 86e3724310
commit 6f31415ed6
1056 changed files with 8410 additions and 18307 deletions
@@ -0,0 +1,759 @@
//! `hashline_grep` — anchor-annotated search results.
//!
//! Delegates to the standard `GrepTool` for ripgrep execution, then
//! post-processes content-mode output to inject scheme-aware anchors.
//! Enables grep → edit workflows without an intermediate file read.
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::time::Duration;
use crate::computer::types::AsyncFileSystem;
use crate::implementations::kigi::grep::{GrepSearchInput, GrepTool, OutputMode};
use crate::types::output::GrepSearchOutput;
use crate::types::requirements::{Expr, ToolRequirement};
use crate::types::resources::Params;
use crate::types::tool::{ToolKind, ToolNamespace};
use super::anchor::split_lines;
use super::config::HashlineSchemeParams;
use super::scheme::{Anchor, AnchorScheme};
/// Default timeout for anchor injection (seconds).
const DEFAULT_ANCHOR_TIMEOUT_SECS: u64 = 60;
/// Get cached anchors or generate and cache them for a single invocation.
async fn get_or_generate<'a>(
cache: &'a mut HashMap<PathBuf, Vec<Anchor>>,
path: &Path,
scheme: &dyn AnchorScheme,
fs: &dyn AsyncFileSystem,
) -> Option<&'a [Anchor]> {
if !cache.contains_key(path) {
let bytes = fs.read_file(path).await.ok()?;
let content = String::from_utf8_lossy(&bytes);
let lines = split_lines(&content);
cache.insert(path.to_path_buf(), scheme.generate_anchors(&lines));
}
cache.get(path).map(|v| v.as_slice())
}
/// Inject anchors into ripgrep content-mode output.
///
/// Transforms lines like `123: let x = 1;` or `124- let y = 2;`
/// into `123:abc:rst: let x = 1;` or `124:abc:rst- let y = 2;`.
///
/// Lines that are file headers or separators pass through unchanged.
pub(crate) async fn inject_anchors(
stdout_bytes: &[u8],
cwd: &Path,
fs: &dyn AsyncFileSystem,
scheme: &dyn AnchorScheme,
) -> Vec<u8> {
let stdout = String::from_utf8_lossy(stdout_bytes);
let (prefix, body, suffix) = match (stdout.find(">\n"), stdout.rfind("\n</workspace_result>")) {
(Some(start), Some(end)) => {
let body_start = start + 2;
(
&stdout[..body_start],
&stdout[body_start..end],
&stdout[end..],
)
}
_ => return stdout_bytes.to_vec(),
};
let mut file_anchors: HashMap<PathBuf, Vec<Anchor>> = HashMap::new();
let mut result = String::from(prefix);
let mut current_file: Option<PathBuf> = None;
for line in body.lines() {
if !result.ends_with('\n') && !result.ends_with('>') {
result.push('\n');
}
// Group separators (--) pass through.
if line == "--" {
result.push_str(line);
continue;
}
// Try to parse as a numbered match/context line first.
// This correctly handles file paths that start with digits (e.g.
// "2024_migration.rs") — they won't parse as valid rg lines because
// they lack a ':' or '-' separator after the numeric prefix.
if let Some((line_num, separator, content)) = parse_rg_line(line)
&& let Some(ref file_path) = current_file
&& let Some(anchors) = get_or_generate(&mut file_anchors, file_path, scheme, fs).await
&& line_num.saturating_sub(1) < anchors.len()
{
let a = &anchors[line_num - 1];
let suffix_str = match &a.context {
Some(ctx) => format!("{}:{ctx}", a.local),
None => a.local.clone(),
};
result.push_str(&format!("{line_num}:{suffix_str}{separator}{content}"));
continue;
}
// Not a numbered line — could be a file header, summary, or
// a numbered line that failed anchor lookup (passed through below).
if parse_rg_line(line).is_none()
&& !line.starts_with("Found ")
&& !line.starts_with("... [")
&& !line.is_empty()
{
current_file = Some(cwd.join(line));
}
result.push_str(line);
}
result.push_str(suffix);
result.into_bytes()
}
/// Parse a ripgrep numbered line: `123:content` or `45-context`.
fn parse_rg_line(line: &str) -> Option<(usize, char, &str)> {
let bytes = line.as_bytes();
let mut idx = 0;
while idx < bytes.len() && bytes[idx].is_ascii_digit() {
idx += 1;
}
if idx == 0 || idx >= bytes.len() {
return None;
}
let sep = bytes[idx] as char;
if sep != ':' && sep != '-' {
return None;
}
let num: usize = line[..idx].parse().ok()?;
Some((num, sep, &line[idx + 1..]))
}
const DESCRIPTION: &str = r#"Search file contents with anchor-annotated results for use with ${{ tools.by_kind.edit }}.
Match lines include anchors you can pass directly to ${{ tools.by_kind.edit }} without
needing to ${{ tools.by_kind.read }} the file first. Unlike ${{ tools.by_kind.read }},
this grep format keeps grep-style separators after the anchor: `:` for
match lines and `-` for context lines.
Content output format:
{grep_match} ← match (:)
{grep_context} ← context (-)
Usage:
- Pattern is a regex: `log.*Error`, `function\s+\w+`, `TODO`
- Output modes: "content" (default, with anchors), "files_with_matches", "count"
- Use -A, -B, -C for context lines around matches
- Only use 'type' or 'glob' when certain of the file type
- Results are capped; truncated results show "at least" counts"#;
/// `hashline_grep` — searches with anchor-annotated results.
#[derive(Debug, Default)]
pub struct HashlineGrepTool;
impl crate::types::tool_metadata::ToolMetadata for HashlineGrepTool {
fn kind(&self) -> ToolKind {
ToolKind::Search
}
fn tool_namespace(&self) -> ToolNamespace {
ToolNamespace::KigiHashline
}
fn description_template(&self) -> &str {
DESCRIPTION
}
fn versioned_definition(
&self,
_contract_version: Option<&str>,
client_name: &str,
description_override: Option<&str>,
renderer: &crate::types::template_renderer::TemplateRenderer,
param_map: &std::collections::HashMap<String, String>,
input_schema: &serde_json::Value,
effective_params: &serde_json::Value,
) -> crate::types::definition::ToolDefinition {
let params: HashlineSchemeParams =
serde_json::from_value(effective_params.clone()).unwrap_or_default();
params.build_tool_definition(
DESCRIPTION,
client_name,
description_override,
renderer,
param_map,
input_schema,
)
}
fn requires_expr(&self) -> Expr<ToolRequirement> {
Expr::True
}
}
impl kigi_tool_runtime::Tool for HashlineGrepTool {
type Args = GrepSearchInput;
type Output = GrepSearchOutput;
fn id(&self) -> kigi_tool_protocol::ToolId {
kigi_tool_protocol::ToolId::new("hashline_grep").expect("valid tool id")
}
fn description(
&self,
_ctx: &::kigi_tool_runtime::ListToolsContext,
) -> kigi_tool_types::ToolDescription {
kigi_tool_types::ToolDescription::new(
"hashline_grep",
crate::types::tool_metadata::ToolMetadata::description_template(self),
)
}
fn capabilities(&self) -> kigi_tool_protocol::ToolCapabilities {
kigi_tool_protocol::ToolCapabilities {
is_read_only: true,
tool_scope: Some(kigi_tool_protocol::ToolScope::Read),
..Default::default()
}
}
#[tracing::instrument(
name = "tool.hashline_grep",
skip_all,
fields(timed_out = tracing::field::Empty)
)]
async fn run(
&self,
ctx: kigi_tool_runtime::ToolCallContext,
input: GrepSearchInput,
) -> Result<GrepSearchOutput, kigi_tool_runtime::ToolError> {
use crate::types::tool_metadata::shared_resources;
let resources = shared_resources(&ctx)?;
let output_mode = input.output_mode.clone().unwrap_or(OutputMode::Content);
// Delegate to standard GrepTool for ripgrep execution.
let grep = GrepTool;
let cwd = crate::types::tool_metadata::resolve_cwd(&ctx, &resources).await?;
let call_id = kigi_tool_protocol::ToolCallId::new_v7();
let mut rt_ctx = kigi_tool_runtime::ToolCallContext::new(call_id);
rt_ctx.extensions.insert(resources.clone());
rt_ctx.extensions.insert(kigi_tool_runtime::Cwd(cwd));
let mut result = kigi_tool_runtime::Tool::run(&grep, rt_ctx, input)
.await
.map_err(|e| {
kigi_tool_runtime::ToolError::execution(
kigi_tool_protocol::ToolId::new("grep").expect("valid"),
e.to_string(),
)
})?;
// Inject anchors only for content mode.
if matches!(output_mode, OutputMode::Content) && result.exit_code == 0 {
let cwd = crate::types::tool_metadata::resolve_cwd(&ctx, &resources).await?;
let (fs, scheme) = {
let res = resources.lock().await;
let fs = res
.require::<crate::types::resources::FileSystem>()?
.0
.clone();
let params = res
.get::<Params<HashlineSchemeParams>>()
.cloned()
.unwrap_or_default();
let scheme = params
.0
.build_scheme()
.map_err(kigi_tool_runtime::ToolError::invalid_arguments)?;
(fs, scheme)
};
match tokio::time::timeout(
Duration::from_secs(DEFAULT_ANCHOR_TIMEOUT_SECS),
inject_anchors(&result.stdout, &cwd, &*fs, &*scheme),
)
.await
{
Ok(anchored) => result.stdout = anchored,
Err(_elapsed) => {
tracing::Span::current().record("timed_out", true);
tracing::warn!(
timeout_secs = DEFAULT_ANCHOR_TIMEOUT_SECS,
"anchor injection timed out after {DEFAULT_ANCHOR_TIMEOUT_SECS}s, returning un-anchored results"
);
let warning = format!(
"\nHashline anchoring for tool output failed after \
{DEFAULT_ANCHOR_TIMEOUT_SECS} seconds. Since results lack \
anchoring, please read the file before editing.\n"
);
result.stdout.extend_from_slice(warning.as_bytes());
}
}
}
Ok(result)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn test_scheme() -> Box<dyn AnchorScheme> {
HashlineSchemeParams::default().build_scheme().unwrap()
}
#[test]
fn parse_rg_line_match() {
let (num, sep, content) = parse_rg_line("123: let x = 1;").unwrap();
assert_eq!(num, 123);
assert_eq!(sep, ':');
assert_eq!(content, " let x = 1;");
}
#[test]
fn parse_rg_line_context() {
let (num, sep, content) = parse_rg_line("45- let y = 2;").unwrap();
assert_eq!(num, 45);
assert_eq!(sep, '-');
assert_eq!(content, " let y = 2;");
}
#[test]
fn parse_rg_line_no_digits() {
assert!(parse_rg_line("src/main.rs").is_none());
}
#[test]
fn parse_rg_line_empty() {
assert!(parse_rg_line("").is_none());
}
#[test]
fn tool_metadata() {
use crate::types::tool_metadata::ToolMetadata;
let tool = HashlineGrepTool;
assert_eq!(kigi_tool_runtime::Tool::id(&tool).as_str(), "hashline_grep");
assert_eq!(ToolMetadata::kind(&tool), ToolKind::Search);
assert!(kigi_tool_runtime::Tool::capabilities(&tool).is_read_only);
assert!(matches!(
ToolMetadata::tool_namespace(&tool),
ToolNamespace::KigiHashline
));
}
#[test]
fn description_mentions_anchors_and_edit() {
use crate::types::tool_metadata::ToolMetadata;
let tool = HashlineGrepTool;
assert!(ToolMetadata::description_template(&tool).contains("anchor"));
assert!(ToolMetadata::description_template(&tool).contains("tools.by_kind.edit"));
}
#[tokio::test]
async fn inject_anchors_content_mode() {
use crate::computer::local::LocalFs;
use std::sync::Arc;
use tempfile::TempDir;
let tmp = TempDir::new().unwrap();
std::fs::write(
tmp.path().join("test.rs"),
"fn main() {\n let x = 1;\n let y = 2;\n}\n",
)
.unwrap();
let fs = Arc::new(LocalFs);
// Simulate ripgrep output for "let" search.
let rg_output = format!(
"<workspace_result workspace_path=\"{}\">\n\
Found 2 matching lines\n\
test.rs\n\
2: let x = 1;\n\
3: let y = 2;\n\
</workspace_result>",
tmp.path().display()
);
let scheme = test_scheme();
let result = inject_anchors(rg_output.as_bytes(), tmp.path(), &*fs, &*scheme).await;
let output = String::from_utf8_lossy(&result);
// Anchored lines should have the pattern NUM:LOCAL:CONTEXT:CONTENT
// (3 colons for chunk scheme: line:local:context:content).
for line in output.lines() {
if line.starts_with(|c: char| c.is_ascii_digit()) {
let colon_count = line.matches(':').count();
assert!(
colon_count >= 3,
"anchored line should have ≥3 colons, got {colon_count}: {line}"
);
}
}
}
#[tokio::test]
async fn inject_anchors_preserves_headers() {
use crate::computer::local::LocalFs;
use std::sync::Arc;
use tempfile::TempDir;
let tmp = TempDir::new().unwrap();
std::fs::write(tmp.path().join("a.rs"), "hello\nworld\n").unwrap();
let fs = Arc::new(LocalFs);
let rg_output = format!(
"<workspace_result workspace_path=\"{}\">\n\
Found 1 matching lines\n\
a.rs\n\
1:hello\n\
</workspace_result>",
tmp.path().display()
);
let scheme = test_scheme();
let result = inject_anchors(rg_output.as_bytes(), tmp.path(), &*fs, &*scheme).await;
let output = String::from_utf8_lossy(&result);
assert!(output.contains("Found 1 matching lines"));
assert!(output.contains("a.rs\n"));
assert!(output.contains("</workspace_result>"));
}
#[tokio::test]
async fn inject_anchors_context_lines() {
use crate::computer::local::LocalFs;
use std::sync::Arc;
use tempfile::TempDir;
let tmp = TempDir::new().unwrap();
std::fs::write(tmp.path().join("test.rs"), "a\nb\nc\nd\ne\n").unwrap();
let fs = Arc::new(LocalFs);
let rg_output = format!(
"<workspace_result workspace_path=\"{}\">\n\
Found 1 matching lines\n\
test.rs\n\
2-b\n\
3:c\n\
4-d\n\
</workspace_result>",
tmp.path().display()
);
let scheme = test_scheme();
let result = inject_anchors(rg_output.as_bytes(), tmp.path(), &*fs, &*scheme).await;
let output = String::from_utf8_lossy(&result);
// Context line should use '-' separator after the anchor.
let line_2 = output.lines().find(|l| l.starts_with('2')).unwrap();
assert!(
line_2.contains('-'),
"context line should keep '-': {line_2}"
);
// Match line should use ':' separator after the anchor.
let line_3 = output.lines().find(|l| l.starts_with('3')).unwrap();
// Count colons: line:local:context:content = 3 colons with ':'
let colon_count = line_3.matches(':').count();
assert!(
colon_count >= 3,
"match line should have ≥3 colons: {line_3}"
);
}
#[tokio::test]
async fn no_injection_for_missing_file() {
use crate::computer::local::LocalFs;
use std::sync::Arc;
use tempfile::TempDir;
let tmp = TempDir::new().unwrap();
let fs = Arc::new(LocalFs);
// File doesn't exist — anchors can't be generated.
let rg_output = format!(
"<workspace_result workspace_path=\"{}\">\n\
Found 1 matching lines\n\
nonexistent.rs\n\
5:some content\n\
</workspace_result>",
tmp.path().display()
);
let scheme = test_scheme();
let result = inject_anchors(rg_output.as_bytes(), tmp.path(), &*fs, &*scheme).await;
let output = String::from_utf8_lossy(&result);
// Should fall through without anchors — original line preserved.
assert!(output.contains("5:some content"));
}
#[tokio::test]
async fn digit_leading_file_path_recognized_as_header() {
use crate::computer::local::LocalFs;
use std::sync::Arc;
use tempfile::TempDir;
let tmp = TempDir::new().unwrap();
let subdir = tmp.path().join("2024_data");
std::fs::create_dir_all(&subdir).unwrap();
std::fs::write(subdir.join("config.rs"), "let x = 1;\nlet y = 2;\n").unwrap();
let fs = Arc::new(LocalFs);
let rg_output = format!(
"<workspace_result workspace_path=\"{}\">\n\
Found 1 matching lines\n\
2024_data/config.rs\n\
1:let x = 1;\n\
</workspace_result>",
tmp.path().display()
);
let scheme = test_scheme();
let result = inject_anchors(rg_output.as_bytes(), tmp.path(), &*fs, &*scheme).await;
let output = String::from_utf8_lossy(&result);
// The file header should be recognized despite starting with digits.
// Line 1 should be annotated with anchors from the correct file.
let line_1 = output.lines().find(|l| l.starts_with('1')).unwrap();
let colon_count = line_1.matches(':').count();
assert!(
colon_count >= 3,
"anchors should be from the correct file, got: {line_1}"
);
}
#[tokio::test]
async fn no_injection_for_files_with_matches_output() {
use crate::computer::local::LocalFs;
use std::sync::Arc;
use tempfile::TempDir;
let tmp = TempDir::new().unwrap();
let fs = Arc::new(LocalFs);
// files_with_matches output has no numbered lines — just file paths.
let rg_output = format!(
"<workspace_result workspace_path=\"{}\">\n\
Found 2 files\n\
src/main.rs\n\
src/lib.rs\n\
</workspace_result>",
tmp.path().display()
);
let scheme = test_scheme();
let result = inject_anchors(rg_output.as_bytes(), tmp.path(), &*fs, &*scheme).await;
let output = String::from_utf8_lossy(&result);
// No numbered lines → no anchor injection. Output should be unchanged.
assert!(output.contains("src/main.rs"));
assert!(output.contains("src/lib.rs"));
assert!(
!output.contains('→'),
"files_with_matches should have no anchors"
);
}
#[tokio::test]
async fn no_injection_for_count_output() {
use crate::computer::local::LocalFs;
use std::sync::Arc;
use tempfile::TempDir;
let tmp = TempDir::new().unwrap();
let fs = Arc::new(LocalFs);
// count output: "file:N" format.
let rg_output = format!(
"<workspace_result workspace_path=\"{}\">\n\
src/main.rs:5\n\
src/lib.rs:3\n\
</workspace_result>",
tmp.path().display()
);
let scheme = test_scheme();
let result = inject_anchors(rg_output.as_bytes(), tmp.path(), &*fs, &*scheme).await;
let output = String::from_utf8_lossy(&result);
// Count lines should pass through. They look like "src/main.rs:5"
// which parse_rg_line won't match (path contains '/', not just digits).
assert!(output.contains("src/main.rs:5"));
assert!(output.contains("src/lib.rs:3"));
}
#[tokio::test]
async fn multiple_matches_same_file_use_cached_anchors() {
use crate::computer::local::LocalFs;
use std::sync::Arc;
use tempfile::TempDir;
let tmp = TempDir::new().unwrap();
std::fs::write(
tmp.path().join("multi.rs"),
"fn a() {}\nfn b() {}\nfn c() {}\nfn d() {}\n",
)
.unwrap();
let fs = Arc::new(LocalFs);
let rg_output = format!(
"<workspace_result workspace_path=\"{}\">\n\
Found 4 matching lines\n\
multi.rs\n\
1:fn a() {{}}\n\
2:fn b() {{}}\n\
3:fn c() {{}}\n\
4:fn d() {{}}\n\
</workspace_result>",
tmp.path().display()
);
let scheme = test_scheme();
let result = inject_anchors(rg_output.as_bytes(), tmp.path(), &*fs, &*scheme).await;
let output = String::from_utf8_lossy(&result);
// All 4 lines should be anchored (same file, same cache entry).
let anchored_count = output
.lines()
.filter(|l| l.starts_with(|c: char| c.is_ascii_digit()))
.filter(|l| l.matches(':').count() >= 3)
.count();
assert_eq!(anchored_count, 4, "all 4 lines should be anchored");
}
#[tokio::test]
async fn inject_anchors_timeout_returns_unanchored() {
use crate::computer::local::LocalFs;
use crate::computer::types::{AsyncFileSystem, ComputerError};
use std::sync::Arc;
use tempfile::TempDir;
struct SlowFs;
#[async_trait::async_trait]
impl AsyncFileSystem for SlowFs {
async fn read_file(&self, _path: &Path) -> Result<Vec<u8>, ComputerError> {
tokio::time::sleep(Duration::from_secs(10)).await;
Ok(Vec::new())
}
async fn write_file(&self, _path: &Path, _data: &[u8]) -> Result<(), ComputerError> {
unimplemented!()
}
async fn delete_file(&self, _path: &Path) -> Result<(), ComputerError> {
unimplemented!()
}
}
let tmp = TempDir::new().unwrap();
std::fs::write(
tmp.path().join("test.rs"),
"fn main() {\n let x = 1;\n let y = 2;\n}\n",
)
.unwrap();
let rg_output = format!(
"<workspace_result workspace_path=\"{}\">\n\
Found 2 matching lines\n\
test.rs\n\
2: let x = 1;\n\
3: let y = 2;\n\
</workspace_result>",
tmp.path().display()
);
let rg_bytes = rg_output.as_bytes();
let scheme = test_scheme();
// Happy path: real filesystem produces anchored output.
let fast_fs = Arc::new(LocalFs);
let anchored = inject_anchors(rg_bytes, tmp.path(), &*fast_fs, &*scheme).await;
let anchored_str = String::from_utf8_lossy(&anchored);
for line in anchored_str.lines() {
if line.starts_with(|c: char| c.is_ascii_digit()) {
assert!(
line.matches(':').count() >= 3,
"happy path should produce anchored lines, got: {line}"
);
}
}
// Timeout path: slow filesystem causes cancellation.
let slow_fs = Arc::new(SlowFs);
let timeout_result = tokio::time::timeout(
Duration::from_millis(10),
inject_anchors(rg_bytes, tmp.path(), &*slow_fs, &*scheme),
)
.await;
assert!(
timeout_result.is_err(),
"inject_anchors should have timed out"
);
// On timeout, production keeps the original output unchanged.
let unanchored_str = String::from_utf8_lossy(rg_bytes);
for line in unanchored_str.lines() {
if line.starts_with(|c: char| c.is_ascii_digit()) {
assert_eq!(
line.matches(':').count(),
1,
"un-anchored line should have exactly 1 colon, got: {line}"
);
}
}
assert!(unanchored_str.contains("Found 2 matching lines"));
assert!(unanchored_str.contains("test.rs"));
assert!(unanchored_str.contains("</workspace_result>"));
assert_ne!(anchored, rg_bytes, "anchored output should differ from raw");
}
#[tokio::test]
async fn truncated_output_stays_well_formed() {
use crate::computer::local::LocalFs;
use std::sync::Arc;
use tempfile::TempDir;
let tmp = TempDir::new().unwrap();
std::fs::write(tmp.path().join("big.rs"), "match\n".repeat(100)).unwrap();
let fs = Arc::new(LocalFs);
// Simulate truncated output with "... [N lines truncated]" marker.
let mut rg_lines = String::new();
for i in 1..=10 {
rg_lines.push_str(&format!("{i}:match\n"));
}
let rg_output = format!(
"<workspace_result workspace_path=\"{}\">\n\
Found at least 100 matching lines\n\
big.rs\n\
{rg_lines}\
... [at least 90 lines truncated] ...\n\
</workspace_result>",
tmp.path().display()
);
let scheme = test_scheme();
let result = inject_anchors(rg_output.as_bytes(), tmp.path(), &*fs, &*scheme).await;
let output = String::from_utf8_lossy(&result);
// Truncation marker should be preserved.
assert!(output.contains("... [at least 90 lines truncated] ..."));
// The wrapper should be intact.
assert!(output.contains("</workspace_result>"));
// Visible lines should be anchored.
let anchored = output
.lines()
.filter(|l| l.starts_with(|c: char| c.is_ascii_digit()))
.filter(|l| l.matches(':').count() >= 3)
.count();
assert_eq!(anchored, 10);
}
}