agent_ui: Handle legacy agent enum variants during deserialization (#51125) (cherry-pick to preview) (#51126)

zed-zippy[bot] and Ben Brandt created

Cherry-pick of #51125 to preview

----
Add custom `Deserialize` implementations for `AgentType` and
`ExternalAgent` to map old built-in variant names to current custom
agent names, while still accepting current serialized formats.

Release Notes:

- N/A

Co-authored-by: Ben Brandt <benjamin.j.brandt@gmail.com>

Change summary

crates/agent_ui/src/agent_panel.rs | 132 +++++++++++++++++++++++++++++++
crates/agent_ui/src/agent_ui.rs    |  97 +++++++++++++++++++++++
2 files changed, 227 insertions(+), 2 deletions(-)

Detailed changes

crates/agent_ui/src/agent_panel.rs 🔗

@@ -374,7 +374,7 @@ enum WhichFontSize {
 }
 
 // TODO unify this with ExternalAgent
-#[derive(Debug, Default, Clone, PartialEq, Serialize, Deserialize)]
+#[derive(Debug, Default, Clone, PartialEq, Serialize)]
 pub enum AgentType {
     #[default]
     NativeAgent,
@@ -384,6 +384,63 @@ pub enum AgentType {
     },
 }
 
+// Custom impl handles legacy variant names from before the built-in agents were moved to
+// the registry: "ClaudeAgent" -> Custom { name: "claude-acp" }, "Codex" -> Custom { name:
+// "codex-acp" }, "Gemini" -> Custom { name: "gemini" }.
+// Can be removed at some point in the future and go back to #[derive(Deserialize)].
+impl<'de> Deserialize<'de> for AgentType {
+    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
+    where
+        D: serde::Deserializer<'de>,
+    {
+        let value = serde_json::Value::deserialize(deserializer)?;
+
+        if let Some(s) = value.as_str() {
+            return match s {
+                "NativeAgent" => Ok(Self::NativeAgent),
+                "TextThread" => Ok(Self::TextThread),
+                "ClaudeAgent" | "ClaudeCode" => Ok(Self::Custom {
+                    name: CLAUDE_AGENT_NAME.into(),
+                }),
+                "Codex" => Ok(Self::Custom {
+                    name: CODEX_NAME.into(),
+                }),
+                "Gemini" => Ok(Self::Custom {
+                    name: GEMINI_NAME.into(),
+                }),
+                other => Err(serde::de::Error::unknown_variant(
+                    other,
+                    &[
+                        "NativeAgent",
+                        "TextThread",
+                        "Custom",
+                        "ClaudeAgent",
+                        "ClaudeCode",
+                        "Codex",
+                        "Gemini",
+                    ],
+                )),
+            };
+        }
+
+        if let Some(obj) = value.as_object() {
+            if let Some(inner) = obj.get("Custom") {
+                #[derive(Deserialize)]
+                struct CustomFields {
+                    name: SharedString,
+                }
+                let fields: CustomFields =
+                    serde_json::from_value(inner.clone()).map_err(serde::de::Error::custom)?;
+                return Ok(Self::Custom { name: fields.name });
+            }
+        }
+
+        Err(serde::de::Error::custom(
+            "expected a string variant or {\"Custom\": {\"name\": ...}}",
+        ))
+    }
+}
+
 impl AgentType {
     pub fn is_native(&self) -> bool {
         matches!(self, Self::NativeAgent)
@@ -4869,4 +4926,77 @@ mod tests {
             );
         });
     }
+
+    #[test]
+    fn test_deserialize_legacy_agent_type_variants() {
+        assert_eq!(
+            serde_json::from_str::<AgentType>(r#""ClaudeAgent""#).unwrap(),
+            AgentType::Custom {
+                name: CLAUDE_AGENT_NAME.into(),
+            },
+        );
+        assert_eq!(
+            serde_json::from_str::<AgentType>(r#""ClaudeCode""#).unwrap(),
+            AgentType::Custom {
+                name: CLAUDE_AGENT_NAME.into(),
+            },
+        );
+        assert_eq!(
+            serde_json::from_str::<AgentType>(r#""Codex""#).unwrap(),
+            AgentType::Custom {
+                name: CODEX_NAME.into(),
+            },
+        );
+        assert_eq!(
+            serde_json::from_str::<AgentType>(r#""Gemini""#).unwrap(),
+            AgentType::Custom {
+                name: GEMINI_NAME.into(),
+            },
+        );
+    }
+
+    #[test]
+    fn test_deserialize_current_agent_type_variants() {
+        assert_eq!(
+            serde_json::from_str::<AgentType>(r#""NativeAgent""#).unwrap(),
+            AgentType::NativeAgent,
+        );
+        assert_eq!(
+            serde_json::from_str::<AgentType>(r#""TextThread""#).unwrap(),
+            AgentType::TextThread,
+        );
+        assert_eq!(
+            serde_json::from_str::<AgentType>(r#"{"Custom":{"name":"my-agent"}}"#).unwrap(),
+            AgentType::Custom {
+                name: "my-agent".into(),
+            },
+        );
+    }
+
+    #[test]
+    fn test_deserialize_legacy_serialized_panel() {
+        let json = serde_json::json!({
+            "width": 300.0,
+            "selected_agent": "ClaudeAgent",
+            "last_active_thread": {
+                "session_id": "test-session",
+                "agent_type": "Codex",
+            },
+        });
+
+        let panel: SerializedAgentPanel = serde_json::from_value(json).unwrap();
+        assert_eq!(
+            panel.selected_agent,
+            Some(AgentType::Custom {
+                name: CLAUDE_AGENT_NAME.into(),
+            }),
+        );
+        let thread = panel.last_active_thread.unwrap();
+        assert_eq!(
+            thread.agent_type,
+            AgentType::Custom {
+                name: CODEX_NAME.into(),
+            },
+        );
+    }
 }

crates/agent_ui/src/agent_ui.rs 🔗

@@ -204,13 +204,70 @@ pub struct NewNativeAgentThreadFromSummary {
 }
 
 // TODO unify this with AgentType
-#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
+#[derive(Debug, Clone, PartialEq, Serialize, JsonSchema)]
 #[serde(rename_all = "snake_case")]
 pub enum ExternalAgent {
     NativeAgent,
     Custom { name: SharedString },
 }
 
+// Custom impl handles legacy variant names from before the built-in agents were moved to
+// the registry: "claude_code" -> Custom { name: "claude-acp" }, "codex" -> Custom { name:
+// "codex-acp" }, "gemini" -> Custom { name: "gemini" }.
+// Can be removed at some point in the future and go back to #[derive(Deserialize)].
+impl<'de> serde::Deserialize<'de> for ExternalAgent {
+    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
+    where
+        D: serde::Deserializer<'de>,
+    {
+        use project::agent_server_store::{CLAUDE_AGENT_NAME, CODEX_NAME, GEMINI_NAME};
+
+        let value = serde_json::Value::deserialize(deserializer)?;
+
+        if let Some(s) = value.as_str() {
+            return match s {
+                "native_agent" => Ok(Self::NativeAgent),
+                "claude_code" | "claude_agent" => Ok(Self::Custom {
+                    name: CLAUDE_AGENT_NAME.into(),
+                }),
+                "codex" => Ok(Self::Custom {
+                    name: CODEX_NAME.into(),
+                }),
+                "gemini" => Ok(Self::Custom {
+                    name: GEMINI_NAME.into(),
+                }),
+                other => Err(serde::de::Error::unknown_variant(
+                    other,
+                    &[
+                        "native_agent",
+                        "custom",
+                        "claude_agent",
+                        "claude_code",
+                        "codex",
+                        "gemini",
+                    ],
+                )),
+            };
+        }
+
+        if let Some(obj) = value.as_object() {
+            if let Some(inner) = obj.get("custom") {
+                #[derive(serde::Deserialize)]
+                struct CustomFields {
+                    name: SharedString,
+                }
+                let fields: CustomFields =
+                    serde_json::from_value(inner.clone()).map_err(serde::de::Error::custom)?;
+                return Ok(Self::Custom { name: fields.name });
+            }
+        }
+
+        Err(serde::de::Error::custom(
+            "expected a string variant or {\"custom\": {\"name\": ...}}",
+        ))
+    }
+}
+
 impl ExternalAgent {
     pub fn server(
         &self,
@@ -667,4 +724,42 @@ mod tests {
             );
         });
     }
+
+    #[test]
+    fn test_deserialize_legacy_external_agent_variants() {
+        use project::agent_server_store::{CLAUDE_AGENT_NAME, CODEX_NAME, GEMINI_NAME};
+
+        assert_eq!(
+            serde_json::from_str::<ExternalAgent>(r#""claude_code""#).unwrap(),
+            ExternalAgent::Custom {
+                name: CLAUDE_AGENT_NAME.into(),
+            },
+        );
+        assert_eq!(
+            serde_json::from_str::<ExternalAgent>(r#""codex""#).unwrap(),
+            ExternalAgent::Custom {
+                name: CODEX_NAME.into(),
+            },
+        );
+        assert_eq!(
+            serde_json::from_str::<ExternalAgent>(r#""gemini""#).unwrap(),
+            ExternalAgent::Custom {
+                name: GEMINI_NAME.into(),
+            },
+        );
+    }
+
+    #[test]
+    fn test_deserialize_current_external_agent_variants() {
+        assert_eq!(
+            serde_json::from_str::<ExternalAgent>(r#""native_agent""#).unwrap(),
+            ExternalAgent::NativeAgent,
+        );
+        assert_eq!(
+            serde_json::from_str::<ExternalAgent>(r#"{"custom":{"name":"my-agent"}}"#).unwrap(),
+            ExternalAgent::Custom {
+                name: "my-agent".into(),
+            },
+        );
+    }
 }