Harden ACP tool-call content rendering

Preserve whitespace-only output, avoid misleading JSON fences for malformed arguments, sanitize fence language identifiers, and add regression coverage. Co-Authored-By: siGit Code <noreply@sigit.si>

Seto Elkahfi committed Sep 10, 2026 at 14:57 UTC 124c80cb2c18a289416a36f3a3f5a0cd519890ef
1 file changed +60 -5
src/main.rs
+60 -5
@@ -370,15 +370,35 @@ fn longest_backtick_run(text: &str) -> usize {
370 /// backtick run already in `text` so it can't break out of the block.
371 fn fenced_code_block(language: &str, text: &str) -> String {
372 let fence = "`".repeat((longest_backtick_run(text) + 1).max(3));
373 + let language = if language
374 + .chars()
375 + .all(|character| character.is_ascii_alphanumeric() || matches!(character, '-' | '_' | '+'))
376 + {
377 + language
378 + } else {
379 + ""
380 + };
381 format!("{fence}{language}\n{text}\n{fence}")
382 }
383
384 +/// Displays tool arguments as JSON when they parse as JSON, otherwise as an
385 +/// unlabelled code block so malformed arguments do not get misleading syntax
386 +/// highlighting.
387 +fn tool_arguments_content(arguments_json: &str) -> ToolCallContent {
388 + let language = if serde_json::from_str::<serde_json::Value>(arguments_json).is_ok() {
389 + "json"
390 + } else {
391 + ""
392 + };
393 + fenced_code_block(language, &chat::pretty_tool_arguments(arguments_json)).into()
394 +}
395 +
396 /// Wraps a tool's output as the display content of its ACP tool-call card
397 /// (issue #78). Capped via [`chat::cap_output_preview`] — display only, the
398 /// model still gets the full output — and fenced so plain-text output (or a
399 /// stray tag, per issue #73) renders literally instead of as markdown.
400 fn tool_output_content(output: &str) -> ToolCallContent {
381 - if output.trim().is_empty() {
401 + if output.is_empty() {
402 return "(no output)".to_string().into();
403 }
404 fenced_code_block("", &chat::cap_output_preview(output)).into()
@@ -1882,8 +1902,7 @@ impl SiGitAgent {
1902 log::warn!("malformed JSON in tool arguments for '{}': {e}", tc.name);
1903 serde_json::Value::String(tc.arguments.clone())
1904 });
1885 - let invocation_content =
1886 - fenced_code_block("json", &chat::pretty_tool_arguments(&tc.arguments));
1905 + let invocation_content = tool_arguments_content(&tc.arguments);
1906 self.send_tool_call_update(
1907 cx,
1908 session_id.clone(),
@@ -1891,7 +1910,7 @@ impl SiGitAgent {
1910 ToolCall::new(tc.id.clone(), chat::tool_title(&tc.name, &tc.arguments))
1911 .kind(tool_kind_for(&tc.name))
1912 .status(ToolCallStatus::InProgress)
1894 - .content(vec![invocation_content.into()])
1913 + .content(vec![invocation_content])
1914 .locations(tool_call_locations(&tc.name, &tc.arguments))
1915 .raw_input(raw_input),
1916 ),
@@ -4371,10 +4390,33 @@ mod tests {
4390
4391 #[test]
4392 fn tool_output_content_shows_a_placeholder_for_empty_output() {
4374 - let content = tool_output_content(" \n\t ");
4393 + let content = tool_output_content("");
4394 assert!(format!("{content:?}").contains("(no output)"));
4395 }
4396
4397 + #[test]
4398 + fn tool_output_content_preserves_whitespace_only_output() {
4399 + let content = tool_output_content(" \n\t ");
4400 + let rendered = format!("{content:?}");
4401 + assert!(!rendered.contains("(no output)"));
4402 + assert!(rendered.contains(" \\n\\t "));
4403 + }
4404 +
4405 + #[test]
4406 + fn tool_arguments_content_only_labels_valid_json() {
4407 + let json = format!("{:?}", tool_arguments_content(r#"{"path":"src/main.rs"}"#));
4408 + let malformed = format!("{:?}", tool_arguments_content("{not json"));
4409 + assert!(json.contains("```json"));
4410 + assert!(!malformed.contains("```json"));
4411 + assert!(malformed.contains("{not json"));
4412 + }
4413 +
4414 + #[test]
4415 + fn fenced_code_block_drops_an_unsafe_language() {
4416 + let block = fenced_code_block("json\nnot fenced", "{}");
4417 + assert_eq!(block, "```\n{}\n```");
4418 + }
4419 +
4420 #[test]
4421 fn tool_output_content_widens_the_fence_around_embedded_backticks() {
4422 let output = "here is a fence:\n```\ncode\n```";
@@ -4400,4 +4442,17 @@ mod tests {
4442 assert_eq!(longest_backtick_run("no backticks here"), 0);
4443 assert_eq!(longest_backtick_run("one ` two `` three ``` four"), 3);
4444 }
4445 +
4446 + #[test]
4447 + fn tool_call_locations_require_a_known_tool_and_string_path() {
4448 + assert!(tool_call_locations("read_file", "{not json").is_empty());
4449 + assert!(tool_call_locations("read_file", r#"{"path":42}"#).is_empty());
4450 + assert!(
4451 + tool_call_locations("future_multi_file_tool", r#"{"path":"src/main.rs"}"#).is_empty()
4452 + );
4453 +
4454 + let locations = tool_call_locations("multi_edit", r#"{"path":"src/main.rs"}"#);
4455 + assert_eq!(locations.len(), 1);
4456 + assert!(locations[0].path.ends_with("src/main.rs"));
4457 + }
4458 }