Skip to main content

protocol_droid/domain/acp/
notification.rs

1//! `domain::acp::notification` — the `session/update`
2//! notification the agent streams to the client while
3//! processing a `session/prompt` turn.
4//!
5//! Source of truth:
6//! `crates/protocol-droid-schema/spec/acp/v1.25.0/schema.json`
7//! under `$defs.SessionNotification`, `$defs.SessionUpdate`,
8//! and the twelve variant types the union names.
9//!
10//! # What this module ships
11//!
12//! The top-level `SessionNotification` and the
13//! `SessionUpdate` discriminated union over the
14//! twelve variants the v1.25.0 schema names:
15//!
16//!   - `user_message_chunk`, `agent_message_chunk`,
17//!     `agent_thought_chunk` — `ContentChunk` shapes
18//!     for streaming text
19//!   - `tool_call`, `tool_call_update` —
20//!     [`ToolCall`] and
21//!     [`ToolCallUpdate`]
22//!   - `plan` — the agent's execution plan
23//!   - `available_commands_update` — slash commands
24//!   - `current_mode_update` — mode change
25//!   - `config_option_update` — config change
26//!   - `session_info_update` — title / timestamp
27//!   - `usage_update` — context window and cost
28//!   - `notice` — a one-off user notice
29//!
30//! # Why a 13-variant enum
31//!
32//! The schema's `SessionUpdate` is a 13-arm `oneOf`
33//! where the discriminator is the `sessionUpdate` field
34//! (a string constant per variant). The typed form is
35//! a `Copy`-shaped enum that names the variant on the
36//! type, not on the wire; the `#[serde(tag = "sessionUpdate")]`
37//! attribute maps the variant to the wire form.
38//!
39//! # Why a `ContentChunk` wrapper
40//!
41//! The schema's `ContentChunk` is `{ content, messageId, _meta }`.
42//! The `messageId` is a `MessageId` — a string that
43//! groups consecutive chunks of the same message. A
44//! change in `messageId` means a new message has
45//! started. The typed form mirrors that shape; the
46//! consumer's update handler threads the `messageId`
47//! to whatever surface the consumer renders on.
48
49use std::collections::BTreeMap;
50
51use serde::{Deserialize, Serialize};
52
53use super::content::ContentBlock;
54use super::session::{SessionConfigOption, SessionId, SessionModeId};
55use super::tool_call::{ToolCall, ToolCallUpdate};
56
57/// Notification containing a session update from the
58/// agent. The agent streams these during
59/// `session/prompt` processing.
60///
61/// `PartialEq` only — see [`SessionUpdate`] for the
62/// reason.
63#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
64#[serde(rename_all = "camelCase")]
65pub struct SessionNotification {
66    /// The id of the session this update pertains to.
67    pub session_id: SessionId,
68    /// The actual update content.
69    pub update: SessionUpdate,
70}
71
72/// A streamed item of content. The three message-
73/// chunk variants of `SessionUpdate` all use this
74/// shape; the `messageId` groups consecutive
75/// chunks of the same message.
76#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
77#[serde(rename_all = "camelCase")]
78pub struct ContentChunk {
79    /// A single item of content.
80    pub content: ContentBlock,
81    /// A unique identifier for the message this
82    /// chunk belongs to. All chunks of the same
83    /// message share the same `messageId`; a change
84    /// in `messageId` indicates a new message has
85    /// started.
86    #[serde(default, skip_serializing_if = "Option::is_none")]
87    pub message_id: Option<MessageId>,
88    /// Per-implementation metadata.
89    #[serde(default, rename = "_meta", skip_serializing_if = "BTreeMap::is_empty")]
90    pub meta: BTreeMap<String, serde_json::Value>,
91}
92
93/// A unique identifier for a message within a
94/// session. The schema's `MessageId` is `type: "string"`
95/// — opaque to the protocol. The newtype makes the
96/// API self-documenting.
97#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
98#[serde(transparent)]
99pub struct MessageId(pub String);
100
101impl MessageId {
102    /// A `MessageId` from any string the schema accepts.
103    pub fn new(value: impl Into<String>) -> Self {
104        Self(value.into())
105    }
106}
107
108impl core::fmt::Display for MessageId {
109    fn fmt(&self, formatter: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
110        core::fmt::Display::fmt(&self.0, formatter)
111    }
112}
113
114/// The agent's execution plan for the turn. A
115/// `Vec<PlanEntry>` of tasks the agent intends to
116/// run.
117#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
118#[serde(rename_all = "camelCase")]
119pub struct Plan {
120    /// The ordered list of plan entries. The agent
121    /// can re-send the plan to add or remove
122    /// entries; the consumer treats the array as
123    /// the agent's current intent.
124    pub entries: Vec<PlanEntry>,
125}
126
127/// A single entry in the agent's [`Plan`]. Each
128/// entry is a task the agent intends to run, with
129/// a priority and a status.
130#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
131#[serde(rename_all = "camelCase")]
132pub struct PlanEntry {
133    /// The content of the task. The schema does
134    /// not constrain the format; the consumer
135    /// renders it as-is.
136    pub content: String,
137    /// The relative importance of this task. Used
138    /// to indicate which tasks are most critical
139    /// to the overall goal.
140    pub priority: PlanEntryPriority,
141    /// The current execution status.
142    pub status: PlanEntryStatus,
143}
144
145/// Priority levels for plan entries. The schema
146/// names three values; the typed form is the same
147/// three-value space.
148#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
149#[serde(rename_all = "lowercase")]
150pub enum PlanEntryPriority {
151    /// High priority task — critical to the
152    /// overall goal.
153    High,
154    /// Medium priority task — important but not
155    /// critical.
156    Medium,
157    /// Low priority task — nice to have but not
158    /// essential.
159    Low,
160}
161
162/// Status of a plan entry in the execution flow.
163/// The schema names four values; the typed form is
164/// the same four-value space.
165#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
166#[serde(rename_all = "snake_case")]
167pub enum PlanEntryStatus {
168    /// The task has not started.
169    Pending,
170    /// The task is currently running.
171    InProgress,
172    /// The task completed successfully.
173    Completed,
174    /// The task failed.
175    Failed,
176}
177
178/// `available_commands_update` payload. The set of
179/// slash-commands the agent makes available.
180#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
181#[serde(rename_all = "camelCase")]
182pub struct AvailableCommandsUpdate {
183    /// The list of available commands.
184    pub available_commands: Vec<AvailableCommand>,
185}
186
187/// A slash-command the agent makes available. The
188/// consumer surfaces the command in a UI; when the
189/// user invokes the command, the consumer sends
190/// the command's input as a `session/prompt`.
191#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
192#[serde(rename_all = "camelCase")]
193pub struct AvailableCommand {
194    /// Command name (e.g., `create_plan`,
195    /// `research_codebase`).
196    pub name: String,
197    /// Human-readable description of what the
198    /// command does.
199    pub description: String,
200    /// Input for the command if required.
201    #[serde(default, skip_serializing_if = "Option::is_none")]
202    pub input: Option<AvailableCommandInput>,
203}
204
205/// The input specification for an
206/// [`AvailableCommand`].
207///
208/// The schema names one variant: unstructured
209/// text. A future revision may add a
210/// structured-input variant; the typed form's
211/// `Other(Value)` passthrough preserves unknown
212/// variants.
213#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
214#[serde(tag = "type", rename_all = "snake_case")]
215pub enum AvailableCommandInput {
216    /// All text typed after the command name is
217    /// the input. The most common form.
218    Unstructured(UnstructuredCommandInput),
219    /// A future or unknown input shape. The wire
220    /// payload is preserved verbatim for the
221    /// bridge to forward.
222    #[serde(untagged)]
223    Other(serde_json::Value),
224}
225
226/// An unstructured input. The user types text
227/// after the command name; the entire string is
228/// the input.
229#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
230#[serde(rename_all = "camelCase")]
231pub struct UnstructuredCommandInput {
232    /// A hint to display when the input hasn't
233    /// been provided yet (e.g. "Search for…" on a
234    /// `/search` command).
235    #[serde(default, skip_serializing_if = "Option::is_none")]
236    pub hint: Option<String>,
237}
238
239/// `current_mode_update` payload. The current mode
240/// of the session has changed.
241#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
242#[serde(rename_all = "camelCase")]
243pub struct CurrentModeUpdate {
244    /// The id of the current mode. The id is one
245    /// of the modes the agent advertised in the
246    /// `SessionModeState::available_modes` from
247    /// `session/new` or `session/load`.
248    pub current_mode_id: SessionModeId,
249}
250
251/// `config_option_update` payload. The set of
252/// session configuration options has been updated.
253#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
254#[serde(rename_all = "camelCase")]
255pub struct ConfigOptionUpdate {
256    /// The full set of configuration options and
257    /// their current values.
258    pub config_options: Vec<SessionConfigOption>,
259}
260
261/// `session_info_update` payload. Session metadata
262/// has been updated (title, timestamps). All
263/// fields are optional to support partial updates.
264#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
265#[serde(rename_all = "camelCase")]
266pub struct SessionInfoUpdate {
267    /// Updated human-readable title. Absent means
268    /// the title is unchanged.
269    #[serde(default, skip_serializing_if = "Option::is_none")]
270    pub title: Option<String>,
271    /// Updated ISO 8601 timestamp of last
272    /// activity. Absent means the timestamp is
273    /// unchanged.
274    #[serde(default, skip_serializing_if = "Option::is_none")]
275    pub updated_at: Option<String>,
276}
277
278/// `usage_update` payload. Context window and
279/// cumulative cost update for a session.
280#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
281#[serde(rename_all = "camelCase")]
282pub struct UsageUpdate {
283    /// Tokens currently in context.
284    pub used: u64,
285    /// Total context window size in tokens.
286    pub size: u64,
287    /// Cumulative session cost. Absent when the
288    /// agent does not track cost.
289    #[serde(default, skip_serializing_if = "Option::is_none")]
290    pub cost: Option<Cost>,
291}
292
293/// Cost information for a session. The schema's
294/// `Cost` is `{ amount, currency }`; the typed
295/// form is the same shape.
296///
297/// `PartialEq` only — `f64` does not implement
298/// `Eq` because of `NaN`, so the type opts out of
299/// the stronger equality.
300#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
301#[serde(rename_all = "camelCase")]
302pub struct Cost {
303    /// Total cumulative cost for the session.
304    /// The schema's `format: "double"`; the typed
305    /// form is `f64`.
306    pub amount: f64,
307    /// ISO 4217 currency code (e.g., "USD",
308    /// "EUR").
309    pub currency: String,
310}
311
312/// `notice` payload. Information for the user
313/// that is not part of session history. The
314/// schema says agents MUST only send this when
315/// the client advertised
316/// `ClientSessionCapabilities::notices`.
317#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
318#[serde(rename_all = "camelCase")]
319pub struct Notice {
320    /// Severity hint for the notice.
321    pub level: NoticeSeverity,
322    /// Human-readable text.
323    pub text: String,
324}
325
326/// Severity hint for a [`Notice`]. The schema
327/// names three concrete values plus an `other`
328/// escape hatch for future severities.
329///
330/// The wire form is always a string. The
331/// `known / unknown` split is encoded by hand:
332/// a custom `Deserialize` maps the three named
333/// values to their variants and preserves any
334/// other string as [`NoticeSeverity::Other`].
335/// A future revision that adds a fourth named
336/// value gets a new variant here and a
337/// `severity_string_to_variant` match arm.
338#[derive(Debug, Clone, PartialEq, Eq, Hash)]
339pub enum NoticeSeverity {
340    /// Informational notice.
341    Info,
342    /// Warning notice.
343    Warning,
344    /// Error notice.
345    Error,
346    /// A custom or future severity. The wire
347    /// form is a string; the typed form
348    /// preserves it for the bridge to forward.
349    Other(String),
350}
351
352impl Serialize for NoticeSeverity {
353    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
354        let value = match self {
355            Self::Info => "info",
356            Self::Warning => "warning",
357            Self::Error => "error",
358            Self::Other(name) => name,
359        };
360        serializer.serialize_str(value)
361    }
362}
363
364impl<'de> Deserialize<'de> for NoticeSeverity {
365    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
366        let raw = String::deserialize(deserializer)?;
367        Ok(match raw.as_str() {
368            "info" => Self::Info,
369            "warning" => Self::Warning,
370            "error" => Self::Error,
371            _ => Self::Other(raw),
372        })
373    }
374}
375
376/// Different types of updates the agent can send
377/// during session processing. A 13-arm
378/// discriminated union keyed on the wire
379/// `sessionUpdate` field.
380///
381/// `PartialEq` only — the `UsageUpdate` variant
382/// carries an `f64` (the cost amount), and `f64`
383/// does not implement `Eq` because of `NaN`. The
384/// `usage_update` payload is the only variant that
385/// pays the price, but dropping `Eq` from the
386/// whole enum is the simpler choice.
387#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
388#[serde(tag = "sessionUpdate", rename_all = "snake_case")]
389pub enum SessionUpdate {
390    /// A chunk of the user's message being
391    /// streamed.
392    UserMessageChunk(ContentChunk),
393    /// A chunk of the agent's response being
394    /// streamed.
395    AgentMessageChunk(ContentChunk),
396    /// A chunk of the agent's internal
397    /// reasoning being streamed.
398    AgentThoughtChunk(ContentChunk),
399    /// Notification that a new tool call has
400    /// been initiated.
401    ToolCall(ToolCall),
402    /// Update on the status or results of a
403    /// tool call.
404    ToolCallUpdate(ToolCallUpdate),
405    /// The agent's execution plan.
406    Plan(Plan),
407    /// Available commands are ready or have
408    /// changed.
409    AvailableCommandsUpdate(AvailableCommandsUpdate),
410    /// The current mode of the session has
411    /// changed.
412    CurrentModeUpdate(CurrentModeUpdate),
413    /// Session configuration options have
414    /// been updated.
415    ConfigOptionUpdate(ConfigOptionUpdate),
416    /// Session metadata has been updated
417    /// (title, timestamps).
418    SessionInfoUpdate(SessionInfoUpdate),
419    /// Context window and cost update.
420    UsageUpdate(UsageUpdate),
421    /// Information for the user that is not
422    /// part of session history.
423    Notice(Notice),
424    /// A session update the typed form does
425    /// not yet model. The wire payload is
426    /// preserved verbatim for the bridge to
427    /// forward.
428    #[serde(untagged)]
429    Other(serde_json::Value),
430}
431
432#[cfg(test)]
433mod tests {
434    use super::*;
435    use crate::domain::acp::content::TextContent;
436    use crate::domain::acp::tool_call::{ToolCallId, ToolCallStatus};
437    use serde_json::json;
438
439    /// The `ContentChunk` wire form is `{ content,
440    /// messageId? }`. The `messageId` is omitted
441    /// from the wire when absent.
442    #[test]
443    fn a_content_chunk_round_trips() -> Result<(), serde_json::Error> {
444        let chunk = ContentChunk {
445            content: ContentBlock::Text(TextContent {
446                text: "hello".to_owned(),
447                annotations: None,
448                meta: BTreeMap::new(),
449            }),
450            message_id: Some(MessageId::new("msg-1")),
451            meta: BTreeMap::new(),
452        };
453        let value = serde_json::to_value(&chunk)?;
454        assert_eq!(value.get("messageId"), Some(&json!("msg-1")));
455        let back: ContentChunk = serde_json::from_value(value)?;
456        assert_eq!(back, chunk);
457        Ok(())
458    }
459
460    /// The `PlanEntryPriority` enum deserialises
461    /// every wire value the schema names.
462    #[test]
463    fn plan_entry_priority_string_round_trips() -> Result<(), serde_json::Error> {
464        for (variant, wire) in [
465            (PlanEntryPriority::High, "high"),
466            (PlanEntryPriority::Medium, "medium"),
467            (PlanEntryPriority::Low, "low"),
468        ] {
469            let value = serde_json::to_value(variant)?;
470            assert_eq!(value, json!(wire));
471            let back: PlanEntryPriority = serde_json::from_value(value)?;
472            assert_eq!(back, variant);
473        }
474        Ok(())
475    }
476
477    /// The `PlanEntryStatus` enum deserialises
478    /// every wire value the schema names.
479    #[test]
480    fn plan_entry_status_string_round_trips() -> Result<(), serde_json::Error> {
481        for (variant, wire) in [
482            (PlanEntryStatus::Pending, "pending"),
483            (PlanEntryStatus::InProgress, "in_progress"),
484            (PlanEntryStatus::Completed, "completed"),
485            (PlanEntryStatus::Failed, "failed"),
486        ] {
487            let value = serde_json::to_value(variant)?;
488            assert_eq!(value, json!(wire));
489            let back: PlanEntryStatus = serde_json::from_value(value)?;
490            assert_eq!(back, variant);
491        }
492        Ok(())
493    }
494
495    /// A `Plan` with one entry round-trips. The
496    /// wire form is `{"entries": [...]}`. A plan
497    /// is `Default` because every field is
498    /// optional.
499    #[test]
500    fn a_plan_round_trips() -> Result<(), serde_json::Error> {
501        let plan = Plan {
502            entries: vec![PlanEntry {
503                content: "Read the README".to_owned(),
504                priority: PlanEntryPriority::High,
505                status: PlanEntryStatus::InProgress,
506            }],
507        };
508        let value = serde_json::to_value(&plan)?;
509        let entries = value
510            .get("entries")
511            .and_then(serde_json::Value::as_array)
512            .ok_or_else(|| serde_json::Error::io(std::io::Error::other("missing `entries`")))?;
513        assert_eq!(entries.len(), 1);
514        assert_eq!(
515            entries
516                .first()
517                .and_then(|e| e.get("content"))
518                .and_then(serde_json::Value::as_str),
519            Some("Read the README"),
520        );
521        let back: Plan = serde_json::from_value(value)?;
522        assert_eq!(back, plan);
523        Ok(())
524    }
525
526    /// The `NoticeSeverity::Info/Warning/Error`
527    /// variants serialise as the schema's
528    /// lowercase strings. The `Other(String)`
529    /// passthrough preserves an unknown wire
530    /// value for the bridge to forward.
531    #[test]
532    fn notice_severity_round_trips() -> Result<(), serde_json::Error> {
533        for (variant, wire) in [
534            (NoticeSeverity::Info, "info"),
535            (NoticeSeverity::Warning, "warning"),
536            (NoticeSeverity::Error, "error"),
537        ] {
538            let value = serde_json::to_value(&variant)?;
539            assert_eq!(value, json!(wire));
540            let back: NoticeSeverity = serde_json::from_value(value)?;
541            assert_eq!(back, variant);
542        }
543        let other_value = json!("future_severity");
544        let back: NoticeSeverity = serde_json::from_value(other_value)?;
545        assert_eq!(back, NoticeSeverity::Other("future_severity".to_owned()));
546        Ok(())
547    }
548
549    /// The `SessionUpdate::AgentMessageChunk`
550    /// variant carries a `ContentChunk` payload
551    /// and the wire `sessionUpdate` discriminator
552    /// is `agent_message_chunk`.
553    #[test]
554    fn an_agent_message_chunk_session_update_round_trips() -> Result<(), serde_json::Error> {
555        let update = SessionUpdate::AgentMessageChunk(ContentChunk {
556            content: ContentBlock::Text(TextContent {
557                text: "hi".to_owned(),
558                annotations: None,
559                meta: BTreeMap::new(),
560            }),
561            message_id: Some(MessageId::new("msg-1")),
562            meta: BTreeMap::new(),
563        });
564        let value = serde_json::to_value(&update)?;
565        assert_eq!(
566            value.get("sessionUpdate"),
567            Some(&json!("agent_message_chunk"))
568        );
569        let back: SessionUpdate = serde_json::from_value(value)?;
570        assert_eq!(back, update);
571        Ok(())
572    }
573
574    /// The `SessionUpdate::ToolCall` variant
575    /// carries a `ToolCall` payload and the wire
576    /// `sessionUpdate` discriminator is
577    /// `tool_call`.
578    #[test]
579    fn a_tool_call_session_update_round_trips() -> Result<(), serde_json::Error> {
580        let update = SessionUpdate::ToolCall(ToolCall {
581            tool_call_id: ToolCallId::new("call-1"),
582            title: "Reading".to_owned(),
583            name: Some("read_file".to_owned()),
584            kind: None,
585            status: Some(ToolCallStatus::InProgress),
586            content: None,
587            locations: None,
588            raw_input: None,
589            raw_output: None,
590        });
591        let value = serde_json::to_value(&update)?;
592        assert_eq!(value.get("sessionUpdate"), Some(&json!("tool_call")));
593        assert_eq!(value.get("title"), Some(&json!("Reading")));
594        let back: SessionUpdate = serde_json::from_value(value)?;
595        assert_eq!(back, update);
596        Ok(())
597    }
598
599    /// The `SessionUpdate::UsageUpdate` variant
600    /// carries a `UsageUpdate` payload and the
601    /// wire `sessionUpdate` discriminator is
602    /// `usage_update`.
603    #[test]
604    fn a_usage_update_session_update_round_trips() -> Result<(), serde_json::Error> {
605        let update = SessionUpdate::UsageUpdate(UsageUpdate {
606            used: 1024,
607            size: 200_000,
608            cost: Some(Cost {
609                amount: 0.42,
610                currency: "USD".to_owned(),
611            }),
612        });
613        let value = serde_json::to_value(&update)?;
614        assert_eq!(value.get("sessionUpdate"), Some(&json!("usage_update")));
615        let back: SessionUpdate = serde_json::from_value(value)?;
616        assert_eq!(back, update);
617        Ok(())
618    }
619
620    /// The `SessionUpdate::Notice` variant
621    /// carries a `Notice` payload. The wire
622    /// `sessionUpdate` discriminator is `notice`.
623    #[test]
624    fn a_notice_session_update_round_trips() -> Result<(), serde_json::Error> {
625        let update = SessionUpdate::Notice(Notice {
626            level: NoticeSeverity::Warning,
627            text: "Rate limit approaching".to_owned(),
628        });
629        let value = serde_json::to_value(&update)?;
630        assert_eq!(value.get("sessionUpdate"), Some(&json!("notice")));
631        assert_eq!(value.get("level"), Some(&json!("warning")));
632        let back: SessionUpdate = serde_json::from_value(value)?;
633        assert_eq!(back, update);
634        Ok(())
635    }
636
637    /// An unknown `sessionUpdate` value
638    /// deserialises into the `Other` passthrough
639    /// variant. The wire payload is preserved
640    /// verbatim for the bridge to forward.
641    #[test]
642    fn an_unknown_session_update_lands_in_the_other_variant() -> Result<(), serde_json::Error> {
643        let raw = json!({
644            "sessionUpdate": "future_update",
645            "payload": "anything"
646        });
647        let update: SessionUpdate = serde_json::from_value(raw)?;
648        match update {
649            SessionUpdate::Other(value) => {
650                assert_eq!(value.get("sessionUpdate"), Some(&json!("future_update")));
651            }
652            other => {
653                return Err(serde_json::Error::io(std::io::Error::other(format!(
654                    "expected Other, got {other:?}"
655                ))));
656            }
657        }
658        Ok(())
659    }
660
661    /// The `SessionNotification` envelope carries
662    /// a `sessionId` and an `update`. The wire
663    /// form is `{"sessionId": "...", "update": ...}`.
664    #[test]
665    fn a_session_notification_round_trips() -> Result<(), serde_json::Error> {
666        let notification = SessionNotification {
667            session_id: SessionId::new("session-1"),
668            update: SessionUpdate::Plan(Plan { entries: vec![] }),
669        };
670        let value = serde_json::to_value(&notification)?;
671        assert_eq!(value.get("sessionId"), Some(&json!("session-1")));
672        let back: SessionNotification = serde_json::from_value(value)?;
673        assert_eq!(back, notification);
674        Ok(())
675    }
676}