Skip to content

Commit cb93e0f

Browse files
authored
feat(fork): append_file 输出 inline unified diff
append_file 输出 inline unified diff,保留字节摘要,并为超大文件和非 UTF-8 内容提供安全回退。 同时补齐 forkguard 行为测试与格式修复。 Signed-off-by: hexin <372726039@qq.com>
1 parent 070f441 commit cb93e0f

1 file changed

Lines changed: 169 additions & 2 deletions

File tree

crates/tui/src/tools/file.rs

Lines changed: 169 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,16 @@ const WRITE_FILE_INLINE_DIFF_LIMIT_BYTES: usize = 32 * 1024;
4141
const WRITE_FILE_MAX_CONTENT_BYTES: usize = 64 * 1024;
4242
const APPEND_FILE_MAX_CONTENT_BYTES: usize = 64 * 1024;
4343

44+
/// Cap on the *existing* file size for `append_file`'s inline diff.
45+
///
46+
/// Much larger than `WRITE_FILE_INLINE_DIFF_LIMIT_BYTES` on purpose: append is
47+
/// the chunked-artifact path, so the prior contents routinely pass 32KB after a
48+
/// couple of ≤16KB chunks. The appended chunk itself is already hard-capped at
49+
/// 64KB, and `similar` trims the shared prefix before diffing, so the emitted
50+
/// diff stays tiny (tail context + added lines) even for a few hundred KB of
51+
/// prior content. Beyond this cap we fall back to the byte-count summary.
52+
const APPEND_FILE_INLINE_DIFF_LIMIT_BYTES: usize = 512 * 1024;
53+
4454
// === ReadFileTool ===
4555

4656
fn canonical_path_for_credential_guard(path: &Path) -> PathBuf {
@@ -821,6 +831,51 @@ fn write_file_result_body(
821831
}
822832
}
823833

834+
/// Build the `append_file` result body: unified diff of the appended chunk on
835+
/// top (same renderer path as `write_file`, #505), byte-count summary last.
836+
/// `prior_contents` is `None` when the existing file was skipped at the call
837+
/// site: either it exceeds the inline-diff size cap (gated on metadata, so a
838+
/// huge file is never read into memory — those get the `[diff omitted]` note)
839+
/// or it isn't readable UTF-8 (nothing trustworthy to diff against, so keep
840+
/// the plain summary).
841+
fn append_file_result_body(
842+
path: &str,
843+
prior_contents: Option<&str>,
844+
old_len: u64,
845+
append_content: &str,
846+
summary: &str,
847+
) -> String {
848+
let Some(prior) = prior_contents else {
849+
if old_len > APPEND_FILE_INLINE_DIFF_LIMIT_BYTES as u64 {
850+
return format!(
851+
"{summary}\n\
852+
[diff omitted] {path} is too large for an inline append_file diff \
853+
(old={old_len} bytes, limit={APPEND_FILE_INLINE_DIFF_LIMIT_BYTES} bytes). \
854+
Use read_file with line ranges to inspect it."
855+
);
856+
}
857+
return summary.to_string();
858+
};
859+
// Belt and braces for the file growing between the metadata check and the
860+
// snapshot read.
861+
if prior.len() > APPEND_FILE_INLINE_DIFF_LIMIT_BYTES {
862+
return format!(
863+
"{summary}\n\
864+
[diff omitted] {path} is too large for an inline append_file diff \
865+
(old={} bytes, limit={} bytes). Use read_file with line ranges to inspect it.",
866+
prior.len(),
867+
APPEND_FILE_INLINE_DIFF_LIMIT_BYTES
868+
);
869+
}
870+
let new_contents = format!("{prior}{append_content}");
871+
let diff = make_unified_diff(path, prior, &new_contents);
872+
if diff.is_empty() {
873+
summary.to_string()
874+
} else {
875+
format!("{diff}\n{summary}")
876+
}
877+
}
878+
824879
// === AppendFileTool ===
825880

826881
/// Tool for appending UTF-8 content to files in the workspace.
@@ -833,7 +888,7 @@ impl ToolSpec for AppendFileTool {
833888
}
834889

835890
fn description(&self) -> &'static str {
836-
"Append UTF-8 content to a file in the workspace. Use this for large generated artifacts after creating a small skeleton with write_file, instead of trying to send one huge write_file content argument. **Recommended chunk size ≤ 16KB, hard limit 64KB per call** to keep tool-arg size small and avoid SSE-timeout-on-slow-decoders failures. Creates parent directories and the target file if needed. Returns a compact byte-count summary, not a full diff."
891+
"Append UTF-8 content to a file in the workspace. Use this for large generated artifacts after creating a small skeleton with write_file, instead of trying to send one huge write_file content argument. **Recommended chunk size ≤ 16KB, hard limit 64KB per call** to keep tool-arg size small and avoid SSE-timeout-on-slow-decoders failures. Creates parent directories and the target file if needed. Returns a compact unified diff of the appended chunk (omitted when the existing file is very large) plus a byte-count summary."
837892
}
838893

839894
fn input_schema(&self) -> Value {
@@ -894,6 +949,18 @@ impl ToolSpec for AppendFileTool {
894949

895950
let existed_before = file_path.exists();
896951
let before_len = fs::metadata(&file_path).map(|m| m.len()).unwrap_or(0);
952+
// Snapshot the existing contents before appending — used to render an
953+
// inline diff in the tool result (same renderer path as write_file).
954+
// Gate on the metadata size first: appending to a huge file must not
955+
// pull the whole file into memory just to decide the diff is omitted.
956+
// A non-UTF-8 existing file can't be diffed as text → skip the diff.
957+
let prior_contents = if !existed_before {
958+
Some(String::new())
959+
} else if before_len <= APPEND_FILE_INLINE_DIFF_LIMIT_BYTES as u64 {
960+
fs::read_to_string(&file_path).ok()
961+
} else {
962+
None
963+
};
897964

898965
let mut file = fs::OpenOptions::new()
899966
.create(true)
@@ -917,13 +984,20 @@ impl ToolSpec for AppendFileTool {
917984
} else {
918985
"Created and appended"
919986
};
920-
let body = format!(
987+
let summary = format!(
921988
"{action} {} bytes to {} ({} -> {} bytes)",
922989
append_content.len(),
923990
file_path.display(),
924991
before_len,
925992
after_len
926993
);
994+
let body = append_file_result_body(
995+
&file_path.display().to_string(),
996+
prior_contents.as_deref(),
997+
before_len,
998+
append_content,
999+
&summary,
1000+
);
9271001

9281002
let diag_block = lsp_diagnostics_for_paths(context, &[file_path]).await;
9291003
let full_body = if diag_block.is_empty() {
@@ -2167,6 +2241,99 @@ mod tests {
21672241
assert_eq!(written, "<html>\n</html>\n");
21682242
}
21692243

2244+
#[tokio::test]
2245+
async fn forkguard_append_file_emits_inline_diff() {
2246+
// pinvou3's DiffView routes on the unified-diff shape (`--- ` / `+++ `
2247+
// headers + `@@` hunks). append_file must emit the same inline diff +
2248+
// trailing byte summary as write_file so appended chunks render as a
2249+
// diff preview instead of a bare byte count.
2250+
let tmp = tempdir().expect("tempdir");
2251+
let ctx = ToolContext::new(tmp.path().to_path_buf());
2252+
let tool = AppendFileTool;
2253+
2254+
let first = tool
2255+
.execute(json!({"path": "deck.html", "content": "<html>\n"}), &ctx)
2256+
.await
2257+
.expect("first append");
2258+
assert!(first.content.contains("--- a/"), "{}", first.content);
2259+
assert!(first.content.contains("+++ b/"), "{}", first.content);
2260+
assert!(first.content.contains("+<html>"), "{}", first.content);
2261+
assert!(
2262+
first.content.contains("Created and appended"),
2263+
"{}",
2264+
first.content
2265+
);
2266+
2267+
let second = tool
2268+
.execute(json!({"path": "deck.html", "content": "</html>\n"}), &ctx)
2269+
.await
2270+
.expect("second append");
2271+
assert!(second.content.contains("@@"), "{}", second.content);
2272+
assert!(second.content.contains("+</html>"), "{}", second.content);
2273+
// The byte-count summary stays as the trailing line (old sessions and
2274+
// non-diff fallbacks rely on it).
2275+
assert!(
2276+
second.content.contains("Appended 8 bytes"),
2277+
"{}",
2278+
second.content
2279+
);
2280+
}
2281+
2282+
#[tokio::test]
2283+
async fn forkguard_append_file_omits_inline_diff_for_huge_prior_contents() {
2284+
// Beyond APPEND_FILE_INLINE_DIFF_LIMIT_BYTES the append falls back to
2285+
// the summary + [diff omitted] note instead of diffing a huge file.
2286+
let tmp = tempdir().expect("tempdir");
2287+
let ctx = ToolContext::new(tmp.path().to_path_buf());
2288+
let huge = "x".repeat(APPEND_FILE_INLINE_DIFF_LIMIT_BYTES + 1);
2289+
fs::write(tmp.path().join("big.txt"), huge).expect("seed big file");
2290+
2291+
let tool = AppendFileTool;
2292+
let result = tool
2293+
.execute(json!({"path": "big.txt", "content": "tail\n"}), &ctx)
2294+
.await
2295+
.expect("append");
2296+
assert!(result.success);
2297+
assert!(
2298+
result.content.contains("[diff omitted]"),
2299+
"{}",
2300+
result.content
2301+
);
2302+
assert!(!result.content.contains("--- a/"), "{}", result.content);
2303+
assert!(
2304+
result.content.contains("Appended 5 bytes"),
2305+
"{}",
2306+
result.content
2307+
);
2308+
}
2309+
2310+
#[tokio::test]
2311+
async fn forkguard_append_file_falls_back_to_summary_for_non_utf8_prior() {
2312+
// A non-UTF-8 existing file can't be diffed as text → plain byte-count
2313+
// summary, no diff headers, and the append itself still succeeds.
2314+
let tmp = tempdir().expect("tempdir");
2315+
let ctx = ToolContext::new(tmp.path().to_path_buf());
2316+
fs::write(tmp.path().join("bin.dat"), [0xff, 0xfe, 0x00, 0x01]).expect("seed binary");
2317+
2318+
let tool = AppendFileTool;
2319+
let result = tool
2320+
.execute(json!({"path": "bin.dat", "content": "tail\n"}), &ctx)
2321+
.await
2322+
.expect("append");
2323+
assert!(result.success);
2324+
assert!(!result.content.contains("--- a/"), "{}", result.content);
2325+
assert!(
2326+
!result.content.contains("[diff omitted]"),
2327+
"{}",
2328+
result.content
2329+
);
2330+
assert!(
2331+
result.content.contains("Appended 5 bytes"),
2332+
"{}",
2333+
result.content
2334+
);
2335+
}
2336+
21702337
#[tokio::test]
21712338
async fn test_write_file_rejects_oversized_content() {
21722339
let tmp = tempdir().expect("tempdir");

0 commit comments

Comments
 (0)