From 4eda1fa029fbd156d43fcb36ef9d1e0096e33ff2 Mon Sep 17 00:00:00 2001 From: Lukas Wirth Date: Tue, 24 Mar 2026 11:23:00 +0100 Subject: [PATCH] agent: Guard against agent producing stringified arrays in edit tool --- .../src/tools/streaming_edit_file_tool.rs | 74 ++++++++++++++++++- crates/gpui_platform/Cargo.toml | 1 + 2 files changed, 74 insertions(+), 1 deletion(-) diff --git a/crates/agent/src/tools/streaming_edit_file_tool.rs b/crates/agent/src/tools/streaming_edit_file_tool.rs index 065ff49f8498025830a6491344a57c7ae6053f5e..595f09f2dc7b9122b8966f4b4e1495d2c6b539fd 100644 --- a/crates/agent/src/tools/streaming_edit_file_tool.rs +++ b/crates/agent/src/tools/streaming_edit_file_tool.rs @@ -89,7 +89,11 @@ pub struct StreamingEditFileToolInput { /// List of edit operations to apply sequentially (required for 'edit' mode). /// Each edit finds `old_text` in the file and replaces it with `new_text`. - #[serde(default, skip_serializing_if = "Option::is_none")] + #[serde( + default, + skip_serializing_if = "Option::is_none", + deserialize_with = "deserialize_edits_maybe_stringified" + )] pub edits: Option>, } @@ -118,6 +122,29 @@ pub struct Edit { pub new_text: String, } +fn deserialize_edits_maybe_stringified<'de, D>( + deserializer: D, +) -> Result>, D::Error> +where + D: serde::Deserializer<'de>, +{ + #[derive(Deserialize)] + #[serde(untagged)] + enum StringOrArray { + Array(Vec), + Stringified(String), + } + + match Option::::deserialize(deserializer)? { + None => Ok(None), + Some(StringOrArray::Array(edits)) => Ok(Some(edits)), + Some(StringOrArray::Stringified(s)) => { + let edits: Vec = serde_json::from_str(&s).map_err(serde::de::Error::custom)?; + Ok(Some(edits)) + } + } +} + #[derive(Clone, Default, Debug, Deserialize)] struct StreamingEditFileToolPartialInput { #[serde(default)] @@ -3908,6 +3935,51 @@ mod tests { ); } + #[test] + fn test_deserialize_edits_from_array() { + let input: StreamingEditFileToolInput = serde_json::from_value(json!({ + "display_description": "Fix bug", + "path": "src/main.rs", + "mode": "edit", + "edits": [ + {"old_text": "foo", "new_text": "bar"} + ] + })) + .unwrap(); + let edits = input.edits.unwrap(); + assert_eq!(edits.len(), 1); + assert_eq!(edits[0].old_text, "foo"); + assert_eq!(edits[0].new_text, "bar"); + } + + // Regression test for the tool creating stringified arrays despite it not being asked to + #[test] + fn test_deserialize_edits_from_stringified_array() { + let input: StreamingEditFileToolInput = serde_json::from_value(json!({ + "display_description": "Fix bug", + "path": "src/main.rs", + "mode": "edit", + "edits": r#"[{"old_text": "foo", "new_text": "bar"}]"# + })) + .unwrap(); + let edits = input.edits.unwrap(); + assert_eq!(edits.len(), 1); + assert_eq!(edits[0].old_text, "foo"); + assert_eq!(edits[0].new_text, "bar"); + } + + #[test] + fn test_deserialize_edits_null() { + let input: StreamingEditFileToolInput = serde_json::from_value(json!({ + "display_description": "Create file", + "path": "src/main.rs", + "mode": "write", + "content": "hello" + })) + .unwrap(); + assert!(input.edits.is_none()); + } + async fn setup_test_with_fs( cx: &mut TestAppContext, fs: Arc, diff --git a/crates/gpui_platform/Cargo.toml b/crates/gpui_platform/Cargo.toml index cfb47b1851b9e792c31fad9aca79b3671095b603..22d44a96b21112336f3bee669c218c2291f78b65 100644 --- a/crates/gpui_platform/Cargo.toml +++ b/crates/gpui_platform/Cargo.toml @@ -28,6 +28,7 @@ gpui_macos.workspace = true [target.'cfg(target_os = "windows")'.dependencies] gpui_windows.workspace = true +gpui = { workspace = true, features = ["windows-manifest"] } [target.'cfg(any(target_os = "linux", target_os = "freebsd"))'.dependencies] gpui_linux.workspace = true