From e9d1419c23f05325049c3dfe9742f627914a1e6c Mon Sep 17 00:00:00 2001 From: Bennet Bo Fenner Date: Tue, 24 Mar 2026 15:40:26 +0100 Subject: [PATCH] agent: Allow model to respond with stringified array when it edits (#52303) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Context Sometimes the model likes to respond with `"[...]"` for the edits array, instead of just `[...]`, which causes deserialisation to fail: ``` Failed to receive tool input: invalid type: string "[{"old_text": ..., "new_text": ...}]", expected a sequence ``` ## How to Review ## Self-Review Checklist - [x] I've reviewed my own diff for quality, security, and reliability - [x] Unsafe blocks (if any) have justifying comments - [x] The content is consistent with the [UI/UX checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) - [x] Tests cover the new/changed behavior - [x] Performance impact has been considered and is acceptable Release Notes: - N/A --------- Co-authored-by: Gaauwe Rombouts --- .../src/tools/streaming_edit_file_tool.rs | 69 ++++++++++++++++++- 1 file changed, 66 insertions(+), 3 deletions(-) diff --git a/crates/agent/src/tools/streaming_edit_file_tool.rs b/crates/agent/src/tools/streaming_edit_file_tool.rs index 065ff49f8498025830a6491344a57c7ae6053f5e..c0e4e39753e436511fcdd675cbe8027d41bf1e73 100644 --- a/crates/agent/src/tools/streaming_edit_file_tool.rs +++ b/crates/agent/src/tools/streaming_edit_file_tool.rs @@ -22,7 +22,10 @@ use language_model::LanguageModelToolResultContent; use project::lsp_store::{FormatTrigger, LspFormatTarget}; use project::{AgentLocation, Project, ProjectPath}; use schemars::JsonSchema; -use serde::{Deserialize, Serialize}; +use serde::{ + Deserialize, Deserializer, Serialize, + de::{DeserializeOwned, Error as _}, +}; use std::ops::Range; use std::path::PathBuf; use std::sync::Arc; @@ -89,7 +92,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_optional_vec_or_json_string" + )] pub edits: Option>, } @@ -128,7 +135,7 @@ struct StreamingEditFileToolPartialInput { mode: Option, #[serde(default)] content: Option, - #[serde(default)] + #[serde(default, deserialize_with = "deserialize_optional_vec_or_json_string")] edits: Option>, } @@ -140,6 +147,33 @@ pub struct PartialEdit { pub new_text: Option, } +/// Sometimes the model responds with a stringified JSON array of edits (`"[...]"`) instead of a regular array (`[...]`) +fn deserialize_optional_vec_or_json_string<'de, T, D>( + deserializer: D, +) -> Result>, D::Error> +where + T: DeserializeOwned, + D: Deserializer<'de>, +{ + #[derive(Deserialize)] + #[serde(untagged)] + enum VecOrJsonString { + Vec(Vec), + String(String), + } + + let value = Option::>::deserialize(deserializer)?; + match value { + None => Ok(None), + Some(VecOrJsonString::Vec(items)) => Ok(Some(items)), + Some(VecOrJsonString::String(string)) => serde_json::from_str::>(&string) + .map(Some) + .map_err(|error| { + D::Error::custom(format!("failed to parse stringified edits array: {error}")) + }), + } +} + #[derive(Debug, Serialize, Deserialize)] #[serde(untagged)] pub enum StreamingEditFileToolOutput { @@ -3673,6 +3707,35 @@ mod tests { assert_eq!(new_text, "HELLO\nWORLD\nfoo\n"); } + #[gpui::test] + async fn test_streaming_final_input_stringified_edits_succeeds(cx: &mut TestAppContext) { + let (tool, _project, _action_log, _fs, _thread) = + setup_test(cx, json!({"file.txt": "hello\nworld\n"})).await; + let (sender, input) = ToolInput::::test(); + let (event_stream, _receiver) = ToolCallEventStream::test(); + let task = cx.update(|cx| tool.clone().run(input, event_stream, cx)); + + sender.send_partial(json!({ + "display_description": "Edit", + "path": "root/file.txt", + "mode": "edit" + })); + cx.run_until_parked(); + + sender.send_final(json!({ + "display_description": "Edit", + "path": "root/file.txt", + "mode": "edit", + "edits": "[{\"old_text\": \"hello\\nworld\", \"new_text\": \"HELLO\\nWORLD\"}]" + })); + + let result = task.await; + let StreamingEditFileToolOutput::Success { new_text, .. } = result.unwrap() else { + panic!("expected success"); + }; + assert_eq!(new_text, "HELLO\nWORLD\n"); + } + // Verifies that after streaming_edit_file_tool edits a file, the action log // reports changed buffers so that the Accept All / Reject All review UI appears. #[gpui::test]