Skip to main content

agent_client_protocol_schema/v2/
plan.rs

1//! Execution plans for complex tasks that require multiple steps.
2//!
3//! Plans are strategies that agents share with clients through session updates,
4//! providing real-time visibility into their thinking and progress.
5//!
6//! See: [Agent Plan](https://agentclientprotocol.com/protocol/v2/agent-plan)
7
8use std::{collections::BTreeMap, sync::Arc};
9
10use derive_more::{Display, From};
11#[cfg(feature = "schemars")]
12use schemars::Schema;
13use serde::{Deserialize, Serialize};
14use serde_with::{DefaultOnError, VecSkipError, serde_as, skip_serializing_none};
15
16use super::Meta;
17use crate::IntoOption;
18
19/// Unique identifier for a plan within a session.
20#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
21#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash, Display, From)]
22#[serde(transparent)]
23#[from(forward)]
24#[non_exhaustive]
25pub struct PlanId(pub Arc<str>);
26
27impl PlanId {
28    /// Wraps a protocol string as a typed [`PlanId`].
29    #[must_use]
30    pub fn new(id: impl Into<Self>) -> Self {
31        id.into()
32    }
33}
34
35/// A content update for a plan identified by ID.
36#[serde_as]
37#[skip_serializing_none]
38#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
39#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
40#[serde(rename_all = "camelCase")]
41#[non_exhaustive]
42pub struct PlanUpdate {
43    /// The updated plan content.
44    pub plan: PlanUpdateContent,
45    /// The _meta property is reserved by ACP to allow clients and agents to attach additional
46    /// metadata to their interactions. Omitted and `null` are equivalent and mean no metadata.
47    /// Implementations MUST NOT assign meaning to keys they do not recognize.
48    ///
49    /// See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/v2/extensibility)
50    #[serde_as(deserialize_as = "DefaultOnError")]
51    #[cfg_attr(feature = "schemars", schemars(extend("x-deserialize-default-on-error" = true)))]
52    #[serde(default)]
53    #[serde(rename = "_meta")]
54    pub meta: Option<Meta>,
55}
56
57impl PlanUpdate {
58    /// Builds [`PlanUpdate`] with the required fields set; optional fields start unset or empty.
59    #[must_use]
60    pub fn new(plan: PlanUpdateContent) -> Self {
61        Self { plan, meta: None }
62    }
63
64    /// The _meta property is reserved by ACP to allow clients and agents to attach additional
65    /// metadata to their interactions. Implementations MUST NOT assign meaning to keys they
66    /// do not recognize.
67    ///
68    /// See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/v2/extensibility)
69    #[must_use]
70    pub fn meta(mut self, meta: impl IntoOption<Meta>) -> Self {
71        self.meta = meta.into_option();
72        self
73    }
74}
75
76/// Updated content for a plan.
77#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
78#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
79#[serde(tag = "type", rename_all = "snake_case")]
80#[non_exhaustive]
81pub enum PlanUpdateContent {
82    /// Structured plan entries.
83    Items(PlanItems),
84    /// **UNSTABLE**
85    ///
86    /// This capability is not part of the spec yet, and may be removed or changed at any point.
87    ///
88    /// A URI pointing to a file containing the plan.
89    #[cfg(feature = "unstable_plan_operations")]
90    File(PlanFile),
91    /// **UNSTABLE**
92    ///
93    /// This capability is not part of the spec yet, and may be removed or changed at any point.
94    ///
95    /// Raw markdown content for the plan.
96    #[cfg(feature = "unstable_plan_operations")]
97    Markdown(PlanMarkdown),
98    /// Custom or future plan update content.
99    ///
100    /// Values beginning with `_` are reserved for implementation-specific
101    /// extensions. Unknown values that do not begin with `_` are reserved for
102    /// future ACP variants.
103    ///
104    /// Receivers that do not understand this content type should preserve the
105    /// raw payload when storing, replaying, proxying, or forwarding plans, and
106    /// otherwise ignore it or display it generically. It still replaces any earlier
107    /// content for the same `planId`.
108    #[serde(untagged)]
109    Other(OtherPlanUpdateContent),
110}
111
112/// Custom or future plan update content payload.
113#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
114#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
115#[cfg_attr(feature = "schemars", schemars(inline))]
116#[cfg_attr(feature = "schemars", schemars(transform = other_plan_update_content_schema))]
117#[serde(rename_all = "camelCase")]
118#[non_exhaustive]
119pub struct OtherPlanUpdateContent {
120    /// Custom or future plan update content type.
121    ///
122    /// Values beginning with `_` are reserved for implementation-specific
123    /// extensions. Unknown values that do not begin with `_` are reserved for
124    /// future ACP variants.
125    #[serde(rename = "type")]
126    pub type_: String,
127    /// The plan ID to update.
128    pub plan_id: PlanId,
129    /// Additional fields from the unknown plan update content payload.
130    #[serde(flatten)]
131    pub fields: BTreeMap<String, serde_json::Value>,
132}
133
134impl OtherPlanUpdateContent {
135    /// Builds [`OtherPlanUpdateContent`] from an unknown discriminator and preserves the remaining extension fields.
136    #[must_use]
137    pub fn new(
138        type_: impl Into<String>,
139        plan_id: impl Into<PlanId>,
140        mut fields: BTreeMap<String, serde_json::Value>,
141    ) -> Self {
142        fields.remove("type");
143        fields.remove("planId");
144        Self {
145            type_: type_.into(),
146            plan_id: plan_id.into(),
147            fields,
148        }
149    }
150}
151
152impl<'de> Deserialize<'de> for OtherPlanUpdateContent {
153    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
154    where
155        D: serde::Deserializer<'de>,
156    {
157        let mut fields = BTreeMap::<String, serde_json::Value>::deserialize(deserializer)?;
158        let type_ = fields
159            .remove("type")
160            .ok_or_else(|| serde::de::Error::missing_field("type"))?;
161        let serde_json::Value::String(type_) = type_ else {
162            return Err(serde::de::Error::custom("`type` must be a string"));
163        };
164        let plan_id = fields
165            .remove("planId")
166            .ok_or_else(|| serde::de::Error::missing_field("planId"))?;
167        let serde_json::Value::String(plan_id) = plan_id else {
168            return Err(serde::de::Error::custom("`planId` must be a string"));
169        };
170
171        if is_known_plan_update_content_type(&type_) {
172            return Err(serde::de::Error::custom(format!(
173                "known plan update content `{type_}` did not match its schema"
174            )));
175        }
176
177        Ok(Self {
178            type_,
179            plan_id: PlanId::new(plan_id),
180            fields,
181        })
182    }
183}
184
185fn is_known_plan_update_content_type(type_: &str) -> bool {
186    KNOWN_PLAN_UPDATE_CONTENT_TYPES.contains(&type_)
187}
188
189#[cfg(feature = "schemars")]
190fn other_plan_update_content_schema(schema: &mut Schema) {
191    super::schema_util::reject_known_string_discriminators(
192        schema,
193        "type",
194        KNOWN_PLAN_UPDATE_CONTENT_TYPES,
195    );
196}
197
198const KNOWN_PLAN_UPDATE_CONTENT_TYPES: &[&str] = &[
199    "items",
200    #[cfg(feature = "unstable_plan_operations")]
201    "file",
202    #[cfg(feature = "unstable_plan_operations")]
203    "markdown",
204];
205
206impl PlanUpdateContent {
207    /// Builds a plan update that replaces the itemized entries for a plan.
208    #[must_use]
209    pub fn items(plan_id: impl Into<PlanId>, entries: Vec<PlanEntry>) -> Self {
210        Self::Items(PlanItems::new(plan_id, entries))
211    }
212
213    /// Builds a plan update that points clients at an external plan file URI.
214    #[cfg(feature = "unstable_plan_operations")]
215    #[must_use]
216    pub fn file(plan_id: impl Into<PlanId>, uri: impl Into<String>) -> Self {
217        Self::File(PlanFile::new(plan_id, uri))
218    }
219
220    /// Builds a plan update whose plan content is inline Markdown.
221    #[cfg(feature = "unstable_plan_operations")]
222    #[must_use]
223    pub fn markdown(plan_id: impl Into<PlanId>, content: impl Into<String>) -> Self {
224        Self::Markdown(PlanMarkdown::new(plan_id, content))
225    }
226}
227
228/// A plan represented as structured entries.
229#[serde_as]
230#[skip_serializing_none]
231#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
232#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
233#[serde(rename_all = "camelCase")]
234#[non_exhaustive]
235pub struct PlanItems {
236    /// The plan ID to update.
237    pub plan_id: PlanId,
238    /// The list of tasks to be accomplished.
239    ///
240    /// When updating an item-based plan, the agent must send a complete list of all entries
241    /// with their current status. The client replaces that plan with each update.
242    #[serde_as(deserialize_as = "VecSkipError<_>")]
243    #[cfg_attr(feature = "schemars", schemars(extend("x-deserialize-skip-invalid-items" = true)))]
244    pub entries: Vec<PlanEntry>,
245    /// The _meta property is reserved by ACP to allow clients and agents to attach additional
246    /// metadata to their interactions. Omitted and `null` are equivalent and mean no metadata.
247    /// Implementations MUST NOT assign meaning to keys they do not recognize.
248    ///
249    /// See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/v2/extensibility)
250    #[serde_as(deserialize_as = "DefaultOnError")]
251    #[cfg_attr(feature = "schemars", schemars(extend("x-deserialize-default-on-error" = true)))]
252    #[serde(default)]
253    #[serde(rename = "_meta")]
254    pub meta: Option<Meta>,
255}
256
257impl PlanItems {
258    /// Builds [`PlanItems`] with the required fields set; optional fields start unset or empty.
259    #[must_use]
260    pub fn new(plan_id: impl Into<PlanId>, entries: Vec<PlanEntry>) -> Self {
261        Self {
262            plan_id: plan_id.into(),
263            entries,
264            meta: None,
265        }
266    }
267
268    /// The _meta property is reserved by ACP to allow clients and agents to attach additional
269    /// metadata to their interactions. Implementations MUST NOT assign meaning to keys they
270    /// do not recognize.
271    ///
272    /// See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/v2/extensibility)
273    #[must_use]
274    pub fn meta(mut self, meta: impl IntoOption<Meta>) -> Self {
275        self.meta = meta.into_option();
276        self
277    }
278}
279
280/// **UNSTABLE**
281///
282/// This capability is not part of the spec yet, and may be removed or changed at any point.
283///
284/// A plan represented by a file URI.
285#[cfg(feature = "unstable_plan_operations")]
286#[serde_as]
287#[skip_serializing_none]
288#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
289#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
290#[serde(rename_all = "camelCase")]
291#[non_exhaustive]
292pub struct PlanFile {
293    /// The plan ID to update.
294    pub plan_id: PlanId,
295    /// The URI of the file containing the plan.
296    #[cfg_attr(feature = "schemars", schemars(url))]
297    pub uri: String,
298    /// The _meta property is reserved by ACP to allow clients and agents to attach additional
299    /// metadata to their interactions. Omitted and `null` are equivalent and mean no metadata.
300    /// Implementations MUST NOT assign meaning to keys they do not recognize.
301    ///
302    /// See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/v2/extensibility)
303    #[serde_as(deserialize_as = "DefaultOnError")]
304    #[cfg_attr(feature = "schemars", schemars(extend("x-deserialize-default-on-error" = true)))]
305    #[serde(default)]
306    #[serde(rename = "_meta")]
307    pub meta: Option<Meta>,
308}
309
310#[cfg(feature = "unstable_plan_operations")]
311impl PlanFile {
312    /// Builds [`PlanFile`] with the required fields set; optional fields start unset or empty.
313    #[must_use]
314    pub fn new(plan_id: impl Into<PlanId>, uri: impl Into<String>) -> Self {
315        Self {
316            plan_id: plan_id.into(),
317            uri: uri.into(),
318            meta: None,
319        }
320    }
321
322    /// The _meta property is reserved by ACP to allow clients and agents to attach additional
323    /// metadata to their interactions. Implementations MUST NOT assign meaning to keys they
324    /// do not recognize.
325    ///
326    /// See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/v2/extensibility)
327    #[must_use]
328    pub fn meta(mut self, meta: impl IntoOption<Meta>) -> Self {
329        self.meta = meta.into_option();
330        self
331    }
332}
333
334/// **UNSTABLE**
335///
336/// This capability is not part of the spec yet, and may be removed or changed at any point.
337///
338/// A plan represented as raw markdown content.
339#[cfg(feature = "unstable_plan_operations")]
340#[serde_as]
341#[skip_serializing_none]
342#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
343#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
344#[serde(rename_all = "camelCase")]
345#[non_exhaustive]
346pub struct PlanMarkdown {
347    /// The plan ID to update.
348    pub plan_id: PlanId,
349    /// Markdown content for the plan.
350    pub content: String,
351    /// The _meta property is reserved by ACP to allow clients and agents to attach additional
352    /// metadata to their interactions. Omitted and `null` are equivalent and mean no metadata.
353    /// Implementations MUST NOT assign meaning to keys they do not recognize.
354    ///
355    /// See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/v2/extensibility)
356    #[serde_as(deserialize_as = "DefaultOnError")]
357    #[cfg_attr(feature = "schemars", schemars(extend("x-deserialize-default-on-error" = true)))]
358    #[serde(default)]
359    #[serde(rename = "_meta")]
360    pub meta: Option<Meta>,
361}
362
363#[cfg(feature = "unstable_plan_operations")]
364impl PlanMarkdown {
365    /// Builds [`PlanMarkdown`] with the required fields set; optional fields start unset or empty.
366    #[must_use]
367    pub fn new(plan_id: impl Into<PlanId>, content: impl Into<String>) -> Self {
368        Self {
369            plan_id: plan_id.into(),
370            content: content.into(),
371            meta: None,
372        }
373    }
374
375    /// The _meta property is reserved by ACP to allow clients and agents to attach additional
376    /// metadata to their interactions. Implementations MUST NOT assign meaning to keys they
377    /// do not recognize.
378    ///
379    /// See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/v2/extensibility)
380    #[must_use]
381    pub fn meta(mut self, meta: impl IntoOption<Meta>) -> Self {
382        self.meta = meta.into_option();
383        self
384    }
385}
386
387/// **UNSTABLE**
388///
389/// This capability is not part of the spec yet, and may be removed or changed at any point.
390///
391/// Removal notice for a plan identified by ID.
392#[cfg(feature = "unstable_plan_operations")]
393#[serde_as]
394#[skip_serializing_none]
395#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
396#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
397#[serde(rename_all = "camelCase")]
398#[non_exhaustive]
399pub struct PlanRemoved {
400    /// The plan ID to remove.
401    pub plan_id: PlanId,
402    /// The _meta property is reserved by ACP to allow clients and agents to attach additional
403    /// metadata to their interactions. Omitted and `null` are equivalent and mean no metadata.
404    /// Implementations MUST NOT assign meaning to keys they do not recognize.
405    ///
406    /// See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/v2/extensibility)
407    #[serde_as(deserialize_as = "DefaultOnError")]
408    #[cfg_attr(feature = "schemars", schemars(extend("x-deserialize-default-on-error" = true)))]
409    #[serde(default)]
410    #[serde(rename = "_meta")]
411    pub meta: Option<Meta>,
412}
413
414#[cfg(feature = "unstable_plan_operations")]
415impl PlanRemoved {
416    /// Builds [`PlanRemoved`] with the required fields set; optional fields start unset or empty.
417    #[must_use]
418    pub fn new(plan_id: impl Into<PlanId>) -> Self {
419        Self {
420            plan_id: plan_id.into(),
421            meta: None,
422        }
423    }
424
425    /// The _meta property is reserved by ACP to allow clients and agents to attach additional
426    /// metadata to their interactions. Implementations MUST NOT assign meaning to keys they
427    /// do not recognize.
428    ///
429    /// See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/v2/extensibility)
430    #[must_use]
431    pub fn meta(mut self, meta: impl IntoOption<Meta>) -> Self {
432        self.meta = meta.into_option();
433        self
434    }
435}
436
437/// A single entry in the execution plan.
438///
439/// Represents a task or goal that the assistant intends to accomplish
440/// as part of fulfilling the user's request.
441/// See protocol docs: [Plan Entries](https://agentclientprotocol.com/protocol/v2/agent-plan#plan-entries)
442#[serde_as]
443#[skip_serializing_none]
444#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
445#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
446#[serde(rename_all = "camelCase")]
447#[non_exhaustive]
448pub struct PlanEntry {
449    /// Human-readable description of what this task aims to accomplish.
450    pub content: String,
451    /// The relative importance of this task.
452    /// Used to indicate which tasks are most critical to the overall goal.
453    pub priority: PlanEntryPriority,
454    /// Current execution status of this task.
455    pub status: PlanEntryStatus,
456    /// The _meta property is reserved by ACP to allow clients and agents to attach additional
457    /// metadata to their interactions. Omitted and `null` are equivalent and mean no metadata.
458    /// Implementations MUST NOT assign meaning to keys they do not recognize.
459    ///
460    /// See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/v2/extensibility)
461    #[serde_as(deserialize_as = "DefaultOnError")]
462    #[cfg_attr(feature = "schemars", schemars(extend("x-deserialize-default-on-error" = true)))]
463    #[serde(default)]
464    #[serde(rename = "_meta")]
465    pub meta: Option<Meta>,
466}
467
468impl PlanEntry {
469    /// Builds [`PlanEntry`] with the required fields set; optional fields start unset or empty.
470    #[must_use]
471    pub fn new(
472        content: impl Into<String>,
473        priority: PlanEntryPriority,
474        status: PlanEntryStatus,
475    ) -> Self {
476        Self {
477            content: content.into(),
478            priority,
479            status,
480            meta: None,
481        }
482    }
483
484    /// The _meta property is reserved by ACP to allow clients and agents to attach additional
485    /// metadata to their interactions. Implementations MUST NOT assign meaning to keys they
486    /// do not recognize.
487    ///
488    /// See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/v2/extensibility)
489    #[must_use]
490    pub fn meta(mut self, meta: impl IntoOption<Meta>) -> Self {
491        self.meta = meta.into_option();
492        self
493    }
494}
495
496/// Priority levels for plan entries.
497///
498/// Used to indicate the relative importance or urgency of different
499/// tasks in the execution plan.
500/// See protocol docs: [Plan Entries](https://agentclientprotocol.com/protocol/v2/agent-plan#plan-entries)
501#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
502#[derive(Deserialize, Serialize, Debug, Clone, PartialEq, Eq)]
503#[serde(rename_all = "snake_case")]
504#[non_exhaustive]
505pub enum PlanEntryPriority {
506    /// High priority task - critical to the overall goal.
507    High,
508    /// Medium priority task - important but not critical.
509    Medium,
510    /// Low priority task - nice to have but not essential.
511    Low,
512    /// Custom or future plan entry priority.
513    ///
514    /// Values beginning with `_` are reserved for implementation-specific
515    /// extensions. Unknown values that do not begin with `_` are reserved for
516    /// future ACP variants.
517    #[serde(untagged)]
518    Other(String),
519}
520
521/// Status of a plan entry in the execution flow.
522///
523/// Tracks the lifecycle of each task from planning through completion.
524/// See protocol docs: [Plan Entries](https://agentclientprotocol.com/protocol/v2/agent-plan#plan-entries)
525#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
526#[derive(Deserialize, Serialize, Debug, Clone, PartialEq, Eq)]
527#[serde(rename_all = "snake_case")]
528#[non_exhaustive]
529pub enum PlanEntryStatus {
530    /// The task has not started yet.
531    Pending,
532    /// The task is currently being worked on.
533    InProgress,
534    /// The task has been successfully completed.
535    Completed,
536    /// The task was cancelled before it completed.
537    Cancelled,
538    /// Custom or future plan entry status.
539    ///
540    /// Values beginning with `_` are reserved for implementation-specific
541    /// extensions. Unknown values that do not begin with `_` are reserved for
542    /// future ACP variants.
543    #[serde(untagged)]
544    Other(String),
545}
546
547#[cfg(test)]
548mod tests {
549    use super::*;
550
551    #[test]
552    fn plan_entry_priority_preserves_unknown_variant() {
553        let priority: PlanEntryPriority = serde_json::from_str("\"urgent\"").unwrap();
554        assert_eq!(priority, PlanEntryPriority::Other("urgent".to_string()));
555        assert_eq!(serde_json::to_value(&priority).unwrap(), "urgent");
556    }
557
558    #[test]
559    fn plan_entry_status_preserves_unknown_variant() {
560        let status: PlanEntryStatus = serde_json::from_str("\"blocked\"").unwrap();
561        assert_eq!(status, PlanEntryStatus::Other("blocked".to_string()));
562        assert_eq!(serde_json::to_value(&status).unwrap(), "blocked");
563    }
564
565    #[test]
566    fn plan_entry_status_recognizes_cancelled_variant() {
567        let status: PlanEntryStatus = serde_json::from_str("\"cancelled\"").unwrap();
568        assert_eq!(status, PlanEntryStatus::Cancelled);
569        assert_eq!(serde_json::to_value(&status).unwrap(), "cancelled");
570    }
571
572    #[test]
573    fn plan_update_content_preserves_unknown_variant() {
574        let content: PlanUpdateContent = serde_json::from_value(serde_json::json!({
575            "type": "_timeline",
576            "planId": "plan-1",
577            "events": []
578        }))
579        .unwrap();
580
581        let PlanUpdateContent::Other(unknown) = content else {
582            panic!("expected unknown plan update content");
583        };
584
585        assert_eq!(unknown.type_, "_timeline");
586        assert_eq!(unknown.plan_id.to_string(), "plan-1");
587        assert!(!unknown.fields.contains_key("planId"));
588        assert_eq!(
589            serde_json::to_value(PlanUpdateContent::Other(unknown)).unwrap(),
590            serde_json::json!({
591                "type": "_timeline",
592                "planId": "plan-1",
593                "events": []
594            })
595        );
596    }
597
598    #[test]
599    fn plan_update_content_does_not_hide_malformed_known_variant() {
600        assert!(
601            serde_json::from_value::<PlanUpdateContent>(serde_json::json!({
602                "type": "items"
603            }))
604            .is_err()
605        );
606    }
607
608    #[cfg(feature = "unstable_plan_operations")]
609    #[test]
610    fn plan_update_content_does_not_hide_malformed_known_experimental_variant() {
611        assert!(
612            serde_json::from_value::<PlanUpdateContent>(serde_json::json!({
613                "type": "file",
614                "planId": "plan-1"
615            }))
616            .is_err()
617        );
618        assert!(
619            serde_json::from_value::<PlanUpdateContent>(serde_json::json!({
620                "type": "markdown",
621                "planId": "plan-1"
622            }))
623            .is_err()
624        );
625    }
626
627    #[test]
628    fn plan_update_content_requires_id_for_unknown_variant() {
629        assert!(
630            serde_json::from_value::<PlanUpdateContent>(serde_json::json!({
631                "type": "_timeline"
632            }))
633            .is_err()
634        );
635    }
636}