Skip to content

Commit 29db91a

Browse files
authored
fix(attachment): hash full file content instead of range content and consolidate FileInfo (#2578)
1 parent 7c9e325 commit 29db91a

16 files changed

Lines changed: 200 additions & 182 deletions

File tree

Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/forge_app/src/changed_files.rs

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -111,10 +111,7 @@ mod tests {
111111
let hash = compute_hash(content);
112112
ReadOutput {
113113
content: Content::file(content.clone()),
114-
start_line: 1,
115-
end_line: 1,
116-
total_lines: 1,
117-
content_hash: hash,
114+
info: forge_domain::FileInfo::new(1, 1, 1, hash),
118115
}
119116
})
120117
.ok_or_else(|| anyhow::anyhow!(std::io::Error::from(std::io::ErrorKind::NotFound)))

crates/forge_app/src/file_tracking.rs

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -62,14 +62,14 @@ impl<F: FsReadService> FileChangeDetector<F> {
6262
async move {
6363
// Get current hash from the full raw file content (not the
6464
// truncated/formatted content returned to the LLM).
65-
// ReadOutput.content_hash is always computed from the
65+
// ReadOutput.info.content_hash is always computed from the
6666
// unprocessed file, so it is directly comparable with the
6767
// stored hash.
6868
let current_hash = fs
6969
.read(file_path.to_string_lossy().to_string(), None, None)
7070
.await
7171
.ok()
72-
.map(|o| o.content_hash);
72+
.map(|o| o.info.content_hash);
7373

7474
// Check if hash has changed
7575
if current_hash != last_hash {
@@ -182,10 +182,7 @@ mod tests {
182182
if let Some(file) = self.files.get(&path) {
183183
Ok(crate::ReadOutput {
184184
content: Content::File(file.displayed_content.clone()),
185-
start_line: 1,
186-
end_line: 1,
187-
total_lines: 1,
188-
content_hash: compute_hash(&file.raw_content),
185+
info: forge_domain::FileInfo::new(1, 1, 1, compute_hash(&file.raw_content)),
189186
})
190187
} else {
191188
Err(anyhow::anyhow!(std::io::Error::from(

crates/forge_app/src/fmt/fmt_output.rs

Lines changed: 3 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,7 @@ mod tests {
5656

5757
use console::strip_ansi_codes;
5858
use forge_display::DiffFormat;
59-
use forge_domain::{ChatResponseContent, Environment};
59+
use forge_domain::{ChatResponseContent, Environment, FileInfo};
6060
use insta::assert_snapshot;
6161
use pretty_assertions::assert_eq;
6262

@@ -97,10 +97,7 @@ mod tests {
9797
},
9898
output: ReadOutput {
9999
content: Content::file(content),
100-
start_line: 1,
101-
end_line: 1,
102-
total_lines: 5,
103-
content_hash: crate::compute_hash(content),
100+
info: FileInfo::new(1, 1, 5, crate::compute_hash(content)),
104101
},
105102
};
106103
let env = fixture_environment();
@@ -123,10 +120,7 @@ mod tests {
123120
},
124121
output: ReadOutput {
125122
content: Content::file(content),
126-
start_line: 2,
127-
end_line: 4,
128-
total_lines: 10,
129-
content_hash: crate::compute_hash(content),
123+
info: FileInfo::new(2, 4, 10, crate::compute_hash(content)),
130124
},
131125
};
132126
let env = fixture_environment();

crates/forge_app/src/infra.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,9 @@ pub trait FileReaderInfra: Send + Sync {
7878
/// - FileInfo.start_line: starting line position
7979
/// - FileInfo.end_line: ending line position
8080
/// - FileInfo.total_lines: total line count in file
81+
/// - FileInfo.content_hash: SHA-256 hash of the **full** file content,
82+
/// allowing callers to store a stable hash that matches what a whole-file
83+
/// read produces (used by the external-change detector)
8184
async fn range_read_utf8(
8285
&self,
8386
path: &Path,

crates/forge_app/src/operation.rs

Lines changed: 11 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -238,7 +238,7 @@ impl ToolOperation {
238238
*metrics = metrics.clone().insert(
239239
input.file_path.clone(),
240240
FileOperation::new(tool_kind)
241-
.content_hash(Some(output.content_hash.clone())),
241+
.content_hash(Some(output.info.content_hash.clone())),
242242
);
243243

244244
return forge_domain::ToolOutput::image(image.clone());
@@ -248,7 +248,7 @@ impl ToolOperation {
248248
let content = output.content.file_content();
249249
let content = if input.show_line_numbers {
250250
content
251-
.to_numbered_from(output.start_line as usize)
251+
.to_numbered_from(output.info.start_line as usize)
252252
.to_string()
253253
} else {
254254
content.to_string()
@@ -257,7 +257,7 @@ impl ToolOperation {
257257
.attr("path", &input.file_path)
258258
.attr(
259259
"display_lines",
260-
format!("{}-{}", output.start_line, output.end_line),
260+
format!("{}-{}", output.info.start_line, output.info.end_line),
261261
)
262262
.attr("total_lines", content.lines().count())
263263
.cdata(content);
@@ -270,7 +270,8 @@ impl ToolOperation {
270270
);
271271
*metrics = metrics.clone().insert(
272272
input.file_path.clone(),
273-
FileOperation::new(tool_kind).content_hash(Some(output.content_hash.clone())),
273+
FileOperation::new(tool_kind)
274+
.content_hash(Some(output.info.content_hash.clone())),
274275
);
275276

276277
forge_domain::ToolOutput::text(elm)
@@ -708,7 +709,7 @@ mod tests {
708709
use std::fmt::Write;
709710
use std::path::PathBuf;
710711

711-
use forge_domain::{FSRead, ToolValue};
712+
use forge_domain::{FSRead, FileInfo, ToolValue};
712713

713714
use super::*;
714715
use crate::{Content, Match, MatchResult};
@@ -828,10 +829,7 @@ mod tests {
828829
},
829830
output: ReadOutput {
830831
content: Content::file(content),
831-
start_line: 1,
832-
end_line: 2,
833-
total_lines: 2,
834-
content_hash: hash,
832+
info: FileInfo::new(1, 2, 2, hash),
835833
},
836834
};
837835

@@ -860,10 +858,7 @@ mod tests {
860858
},
861859
output: ReadOutput {
862860
content: Content::file(content),
863-
start_line: 1,
864-
end_line: 1,
865-
total_lines: 1,
866-
content_hash: hash,
861+
info: FileInfo::new(1, 1, 1, hash),
867862
},
868863
};
869864

@@ -891,10 +886,7 @@ mod tests {
891886
},
892887
output: ReadOutput {
893888
content: Content::file(content),
894-
start_line: 2,
895-
end_line: 3,
896-
total_lines: 5,
897-
content_hash: hash,
889+
info: FileInfo::new(2, 3, 5, hash),
898890
},
899891
};
900892

@@ -923,10 +915,7 @@ mod tests {
923915
},
924916
output: ReadOutput {
925917
content: Content::file(content),
926-
start_line: 1,
927-
end_line: 100,
928-
total_lines: 200,
929-
content_hash: hash,
918+
info: FileInfo::new(1, 100, 200, hash),
930919
},
931920
};
932921

@@ -2499,10 +2488,7 @@ mod tests {
24992488
"base64_image_data".to_string(),
25002489
"image/png",
25012490
)),
2502-
start_line: 1,
2503-
end_line: 1,
2504-
total_lines: 1,
2505-
content_hash: "hash123".to_string(),
2491+
info: FileInfo::new(1, 1, 1, "hash123".to_string()),
25062492
},
25072493
};
25082494

crates/forge_app/src/services.rs

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,8 @@ use derive_setters::Setters;
66
use forge_domain::{
77
AgentId, AnyProvider, Attachment, AuthContextRequest, AuthContextResponse, AuthMethod,
88
ChatCompletionMessage, CommandOutput, Context, Conversation, ConversationId, Environment, File,
9-
FileStatus, Image, InitAuth, LoginInfo, McpConfig, McpServers, Model, ModelId, Node, Provider,
10-
ProviderId, ResultStream, Scope, SearchParams, SyncProgress, SyntaxError, Template,
9+
FileInfo, FileStatus, Image, InitAuth, LoginInfo, McpConfig, McpServers, Model, ModelId, Node,
10+
Provider, ProviderId, ResultStream, Scope, SearchParams, SyncProgress, SyntaxError, Template,
1111
ToolCallFull, ToolOutput, Workflow, WorkspaceAuth, WorkspaceId, WorkspaceInfo,
1212
};
1313
use merge::Merge;
@@ -38,10 +38,7 @@ pub struct PatchOutput {
3838
#[setters(into)]
3939
pub struct ReadOutput {
4040
pub content: Content,
41-
pub start_line: u64,
42-
pub end_line: u64,
43-
pub total_lines: u64,
44-
pub content_hash: String,
41+
pub info: FileInfo,
4542
}
4643

4744
#[derive(Debug)]

crates/forge_app/src/user_prompt.rs

Lines changed: 7 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -222,10 +222,11 @@ impl<S: AttachmentService> UserPromptGenerator<S> {
222222
// Use the raw content_hash (computed before line-numbering) so that the
223223
// external-change detector, which hashes the raw file on disk, sees a
224224
// matching hash and does not raise a false "modified externally" warning.
225-
if let AttachmentContent::FileContent { content_hash, .. } = &attachment.content {
225+
if let AttachmentContent::FileContent { info, .. } = &attachment.content {
226226
metrics = metrics.insert(
227227
attachment.path.clone(),
228-
FileOperation::new(ToolKind::Read).content_hash(Some(content_hash.clone())),
228+
FileOperation::new(ToolKind::Read)
229+
.content_hash(Some(info.content_hash.clone())),
229230
);
230231
}
231232
}
@@ -240,8 +241,8 @@ impl<S: AttachmentService> UserPromptGenerator<S> {
240241
#[cfg(test)]
241242
mod tests {
242243
use forge_domain::{
243-
AgentId, AttachmentContent, Context, ContextMessage, ConversationId, ModelId, ProviderId,
244-
ToolKind,
244+
AgentId, AttachmentContent, Context, ContextMessage, ConversationId, FileInfo, ModelId,
245+
ProviderId, ToolKind,
245246
};
246247
use pretty_assertions::assert_eq;
247248

@@ -390,20 +391,14 @@ mod tests {
390391
path: "/test/file1.rs".to_string(),
391392
content: AttachmentContent::FileContent {
392393
content: "fn main() {}".to_string(),
393-
start_line: 1,
394-
end_line: 1,
395-
total_lines: 1,
396-
content_hash: "hash1".to_string(),
394+
info: FileInfo::new(1, 1, 1, "hash1".to_string()),
397395
},
398396
},
399397
Attachment {
400398
path: "/test/file2.rs".to_string(),
401399
content: AttachmentContent::FileContent {
402400
content: "fn test() {}".to_string(),
403-
start_line: 1,
404-
end_line: 1,
405-
total_lines: 1,
406-
content_hash: "hash2".to_string(),
401+
info: FileInfo::new(1, 1, 1, "hash2".to_string()),
407402
},
408403
},
409404
])

crates/forge_domain/src/attachment.rs

Lines changed: 6 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
use nom::Parser;
22
use nom::bytes::complete::tag;
33

4-
use crate::Image;
4+
use crate::{FileInfo, Image};
55

66
/// A file or directory attachment included in a chat message.
77
#[derive(Debug, serde::Deserialize, serde::Serialize, Clone, PartialEq, Eq)]
@@ -24,16 +24,9 @@ pub enum AttachmentContent {
2424
/// Line-numbered display text shown to the model. May represent only a
2525
/// slice of the full file when a range was requested.
2626
content: String,
27-
/// First line of the displayed range (1-based, inclusive).
28-
start_line: u64,
29-
/// Last line of the displayed range (1-based, inclusive).
30-
end_line: u64,
31-
/// Total number of lines in the full file on disk.
32-
total_lines: u64,
33-
/// SHA-256 hash of the raw (unformatted, untruncated) file content.
34-
/// Used for external-change detection so the stored hash matches what
35-
/// the detector reads back from disk, avoiding false-positive warnings.
36-
content_hash: String,
27+
/// Metadata about the file read: line positions and full-file content
28+
/// hash for external-change detection.
29+
info: FileInfo,
3730
},
3831
/// A directory listing showing the immediate children of a directory.
3932
DirectoryListing {
@@ -76,8 +69,8 @@ impl AttachmentContent {
7669

7770
pub fn range_info(&self) -> Option<(u64, u64, u64)> {
7871
match self {
79-
AttachmentContent::FileContent { start_line, end_line, total_lines, .. } => {
80-
Some((*start_line, *end_line, *total_lines))
72+
AttachmentContent::FileContent { info, .. } => {
73+
Some((info.start_line, info.end_line, info.total_lines))
8174
}
8275
_ => None,
8376
}

crates/forge_domain/src/context.rs

Lines changed: 8 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -461,18 +461,12 @@ impl Context {
461461
attachments.into_iter().fold(self, |ctx, attachment| {
462462
ctx.add_message(match attachment.content {
463463
AttachmentContent::Image(image) => ContextMessage::Image(image),
464-
AttachmentContent::FileContent {
465-
content,
466-
start_line,
467-
end_line,
468-
total_lines,
469-
..
470-
} => {
464+
AttachmentContent::FileContent { content, info } => {
471465
let elm = Element::new("file_content")
472466
.attr("path", attachment.path)
473-
.attr("start_line", start_line)
474-
.attr("end_line", end_line)
475-
.attr("total_lines", total_lines)
467+
.attr("start_line", info.start_line)
468+
.attr("end_line", info.end_line)
469+
.attr("total_lines", info.total_lines)
476470
.cdata(content);
477471

478472
let mut message = TextMessage::new(Role::User, elm.to_string()).droppable(true);
@@ -734,7 +728,7 @@ mod tests {
734728

735729
use super::*;
736730
use crate::transformer::Transformer;
737-
use crate::{DirectoryEntry, estimate_token_count};
731+
use crate::{DirectoryEntry, FileInfo, estimate_token_count};
738732

739733
#[test]
740734
fn test_override_system_message() {
@@ -1134,10 +1128,7 @@ mod tests {
11341128
path: "/path/to/file.rs".to_string(),
11351129
content: AttachmentContent::FileContent {
11361130
content: "fn main() {}\n".to_string(),
1137-
start_line: 1,
1138-
end_line: 1,
1139-
total_lines: 1,
1140-
content_hash: "hash".to_string(),
1131+
info: FileInfo::new(1, 1, 1, "hash".to_string()),
11411132
},
11421133
}];
11431134

@@ -1187,20 +1178,14 @@ mod tests {
11871178
path: "/path/to/file1.rs".to_string(),
11881179
content: AttachmentContent::FileContent {
11891180
content: "fn foo() {}\n".to_string(),
1190-
start_line: 1,
1191-
end_line: 1,
1192-
total_lines: 1,
1193-
content_hash: "hash1".to_string(),
1181+
info: FileInfo::new(1, 1, 1, "hash1".to_string()),
11941182
},
11951183
},
11961184
Attachment {
11971185
path: "/path/to/file2.rs".to_string(),
11981186
content: AttachmentContent::FileContent {
11991187
content: "fn bar() {}\n".to_string(),
1200-
start_line: 1,
1201-
end_line: 1,
1202-
total_lines: 1,
1203-
content_hash: "hash2".to_string(),
1188+
info: FileInfo::new(1, 1, 1, "hash2".to_string()),
12041189
},
12051190
},
12061191
];

0 commit comments

Comments
 (0)