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(¬ification)?;
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}