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("