From a5501e608aae1bbf1d5583b387ebc18518971444 Mon Sep 17 00:00:00 2001 From: Zhongyue Lin <101193087+LeoLin990405@users.noreply.github.com> Date: Thu, 21 May 2026 16:21:42 +0800 Subject: [PATCH] fix(client): keep mutation-tool result confirmations off wire dedup (#1695) Co-Authored-By: LeoLin990405 <126708262+LeoLin990405@users.noreply.github.com> --- crates/tui/src/client/chat.rs | 182 +++++++++++++++++++++++++++++-- crates/tui/src/commands/debug.rs | 7 ++ 2 files changed, 181 insertions(+), 8 deletions(-) diff --git a/crates/tui/src/client/chat.rs b/crates/tui/src/client/chat.rs index e3a10d6c..ab935268 100644 --- a/crates/tui/src/client/chat.rs +++ b/crates/tui/src/client/chat.rs @@ -922,6 +922,18 @@ fn turn_meta_budget_json(turn_meta: &TurnMetaBudget) -> Value { }) } +/// Mutating/write tools whose result body is a *confirmation* (it embeds +/// the unified diff + summary of what was just written), not retrievable +/// reference data. Two identical large `write_file` calls must each keep +/// their full confirmation inline: collapsing the later one to a +/// `` makes the model lose the write-success +/// context and behave as if the file is missing (issue #1695). Read-style +/// tools (`read_file`, `grep_files`, `exec_shell`, …) are unaffected and +/// still dedup normally. +fn is_mutation_tool(tool_name: &str) -> bool { + matches!(tool_name, "write_file" | "edit_file" | "apply_patch") +} + fn compact_tool_result_for_wire( tool_name: &str, input: &Value, @@ -932,10 +944,26 @@ fn compact_tool_result_for_wire( let original_chars = content.chars().count(); let sha = sha256_hex(content.as_bytes()); + // Two independent size-and-kind predicates, deliberately decoupled: + // + // * `persist_eligible` — size only. Any large result (including a + // mutation tool's big diff) is written to the SHA-addressed store + // so that, if it gets truncated below, the elided middle stays + // retrievable via `retrieve_tool_result`. Mutation tools must NOT + // be excluded here: a >12k-char `write_file` diff that we truncate + // without persisting would leave the model unable to recover it. + // * `dedup_eligible` — size AND non-mutation. Only this predicate + // gates collapsing a later identical result to a + // ``. Mutation-tool results are write + // *confirmations*, never dedup-eligible (#1695): two identical + // large `write_file` calls must each keep their full confirmation + // inline. + // // Below the threshold, repeating the content is safer than asking - // the model to chase a reference. Above it, persist a SHA-addressed - // copy before any later message can point at that SHA. - let dedup_eligible = original_chars >= TOOL_RESULT_DEDUP_MIN_CHARS; + // the model to chase a reference, and there's no retrieval burden to + // satisfy, so both predicates are false. + let persist_eligible = original_chars >= TOOL_RESULT_DEDUP_MIN_CHARS; + let dedup_eligible = persist_eligible && !is_mutation_tool(tool_name); if dedup_eligible && let Some(previous) = seen_tool_results.get(&sha) { // Re-check persistence before emitting a ref. If the file is @@ -966,11 +994,17 @@ fn compact_tool_result_for_wire( }; } - if dedup_eligible { - // Persist before registering the content as dedupable. If the - // write fails, later occurrences stay inline instead of pointing - // at a file that was never created. - if persist_tool_result_for_sha(&sha, content) { + if persist_eligible { + // Persist any large result so a later truncation below stays + // retrievable by SHA — this includes mutation tools, whose big + // diffs are NOT dedup-eligible but still must be recoverable + // when elided. Only register the SHA as dedup-able (eligible to + // be replaced by a back-reference later) when `dedup_eligible`: + // if the write fails, skip registration so later occurrences + // stay inline instead of pointing at a file that was never + // created. + let persisted = persist_tool_result_for_sha(&sha, content); + if persisted && dedup_eligible { seen_tool_results.insert( sha.clone(), SeenToolResult { @@ -2768,6 +2802,138 @@ mod stream_decoder_tests { ); } + #[test] + fn request_builder_never_dedups_large_identical_write_file_confirmations() { + // A `write_file` result embeds the unified diff + summary; it is a + // confirmation, not retrievable data. Two identical >1024-char + // write_file results must BOTH stay inline — collapsing the second + // to a SHA ref makes the model lose write-success context and + // report the file as missing (#1695). + let output = "A".repeat(2_000); + let messages = vec![ + tool_use_message("tool-1", "write_file", json!({"path": "big.txt"})), + tool_result_message("tool-1", &output), + tool_use_message("tool-2", "write_file", json!({"path": "big.txt"})), + tool_result_message("tool-2", &output), + ]; + + let built = build_chat_messages(None, &messages, "deepseek-v4-flash"); + let first = tool_message_content(&built, 0); + let second = tool_message_content(&built, 1); + + assert_eq!(first, output); + assert_eq!(second, output); + assert!(!second.contains("` (mutation confirmations stay inline) yet + // (b) still be persisted to the SHA store so the content elided + // by truncation remains retrievable via `retrieve_tool_result`. + // Before the fix, folding `!is_mutation_tool` into the single + // `dedup_eligible` gate also disabled persistence, so a >12k + // mutation diff was truncated AND unrecoverable. + let _guard = crate::tools::truncate::TEST_SPILLOVER_GUARD + .lock() + .unwrap_or_else(|err| err.into_inner()); + let tmp = tempfile::tempdir().expect("tempdir"); + let prior = crate::tools::truncate::set_test_spillover_root(Some( + tmp.path().join(".deepseek").join("tool_outputs"), + )); + struct Restore(Option); + impl Drop for Restore { + fn drop(&mut self) { + crate::tools::truncate::set_test_spillover_root(self.0.take()); + } + } + let _restore = Restore(prior); + + // > TOOL_RESULT_SENT_CHAR_BUDGET (12_000) so the wire path + // truncates and would need a SHA to recover the middle. + let big_diff = "D".repeat(20_000); + let sha = sha256_hex(big_diff.as_bytes()); + + let messages = vec![ + tool_use_message("w-1", "write_file", json!({"path": "huge.rs"})), + tool_result_message("w-1", &big_diff), + tool_use_message("w-2", "write_file", json!({"path": "huge.rs"})), + tool_result_message("w-2", &big_diff), + ]; + let built = build_chat_messages(None, &messages, "deepseek-v4-flash"); + let first = tool_message_content(&built, 0); + let second = tool_message_content(&built, 1); + + // (a) Both confirmations stay inline — truncated, never a ref. + assert!( + first.contains("[TOOL_RESULT_TRUNCATED]"), + "first should be truncated, got: {first}" + ); + assert!( + !first.contains("