Skip to main content

protocol_droid/domain/acp/
session.rs

1//! `domain::acp::session` — the session lifecycle request /
2//! response types.
3//!
4//! Source of truth:
5//! `crates/protocol-droid-schema/spec/acp/v1.25.0/schema.json`
6//! under `$defs.NewSessionRequest`, `$defs.LoadSessionRequest`,
7//! `$defs.ListSessionsRequest`, `$defs.DeleteSessionRequest`,
8//! `$defs.ResumeSessionRequest`, `$defs.CloseSessionRequest`,
9//! `$defs.PromptRequest`, `$defs.CancelNotification`,
10//! `$defs.LogoutRequest`, and the matching `*Response`
11//! types.
12//!
13//! # What this module ships
14//!
15//! The 9 agent-side methods the v1.25.0 `meta.json` lists
16//! (13 agent methods total — the 4 `session/set_*` and
17//! `authenticate` methods are intentionally deferred to
18//! keep this commit's surface focused) plus the
19//! `CancelNotification` and the auxiliary value objects
20//! the request / response bodies share: `SessionId`,
21//! `StopReason`, `McpServer` (and its three variants),
22//! `EnvVariable`, `HttpHeader`, and `SessionInfo`.
23//!
24//! # Why request / response types in the same module
25//!
26//! The schema pairs each request with its response; a
27//! module-per-type layout would scatter the round-trip
28//! across files. The `application::acp` dispatcher
29//! will route by method name and pair the request with
30//! its response in one place, so the consumer sees a
31//! single import.
32//!
33//! # Why a single `SessionId` newtype
34//!
35//! Every session-scoped request carries `sessionId`. A
36//! `String` would compile, but a `SessionId` makes the
37//! API self-documenting (`fn load(req: LoadSessionRequest)`
38//! vs `fn load(req: LoadSessionRequest, id: &str)`) and
39//! lets the future per-revision codec narrow the type
40//! (a hypothetical `SessionId::Uuid` variant) without
41//! rewriting every call site.
42
43use serde::{Deserialize, Serialize};
44
45use super::content::ContentBlock;
46
47/// A unique identifier for a conversation session.
48///
49/// The schema's `SessionId` is `type: "string"` — opaque
50/// to the protocol. The newtype makes the API
51/// self-documenting and gives the future per-revision
52/// codec a place to narrow the type without rewriting
53/// every call site.
54#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
55#[serde(transparent)]
56pub struct SessionId(pub String);
57
58impl SessionId {
59    /// A `SessionId` from any string the schema accepts.
60    /// The schema does not constrain the value beyond
61    /// "opaque string"; the consumer is the source of
62    /// truth for what makes a valid id.
63    pub fn new(value: impl Into<String>) -> Self {
64        Self(value.into())
65    }
66
67    /// The inner string, borrowed.
68    pub fn as_str(&self) -> &str {
69        &self.0
70    }
71}
72
73impl core::fmt::Display for SessionId {
74    fn fmt(&self, formatter: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
75        core::fmt::Display::fmt(&self.0, formatter)
76    }
77}
78
79/// Why an agent stopped processing a prompt turn.
80///
81/// The schema names five values; the typed form is
82/// the same five-value space. `Copy` because the
83/// shape is a `Copy` enum — the wire form is a
84/// single string, the consumer's storage is a
85/// single word.
86#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
87#[serde(rename_all = "snake_case")]
88pub enum StopReason {
89    /// The turn ended successfully.
90    EndTurn,
91    /// The turn ended because the agent reached the
92    /// maximum number of tokens.
93    MaxTokens,
94    /// The turn ended because the agent reached the
95    /// maximum number of allowed agent requests
96    /// between user turns.
97    MaxTurnRequests,
98    /// The turn ended because the agent refused to
99    /// continue. The user prompt and everything that
100    /// comes after it are not included in the next
101    /// prompt.
102    Refusal,
103    /// The turn was cancelled by the client via
104    /// `session/cancel`.
105    Cancelled,
106}
107
108// -- session/new -----------------------------------------------------------
109
110/// `session/new` request. Creates a new session.
111#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
112#[serde(rename_all = "camelCase")]
113pub struct NewSessionRequest {
114    /// The working directory for this session.
115    /// Must be an absolute path; the schema rejects
116    /// relative paths on the wire.
117    pub cwd: String,
118    /// Additional workspace roots the session should
119    /// activate. Each path is absolute. When absent
120    /// or empty, no additional roots are activated.
121    #[serde(default, skip_serializing_if = "Option::is_none")]
122    pub additional_directories: Option<Vec<String>>,
123    /// MCP servers the agent should connect to.
124    /// `Vec<McpServer>` is the schema's `mcpServers`
125    /// field; required on the wire.
126    pub mcp_servers: Vec<McpServer>,
127}
128
129/// `session/new` response.
130#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
131#[serde(rename_all = "camelCase")]
132pub struct NewSessionResponse {
133    /// The id of the session the agent created. Every
134    /// subsequent request for this conversation uses
135    /// this id.
136    pub session_id: SessionId,
137    /// Initial mode state the agent advertises. Absent
138    /// when the agent does not support session modes.
139    #[serde(default, skip_serializing_if = "Option::is_none")]
140    pub modes: Option<SessionModeState>,
141    /// Initial session configuration options the
142    /// agent advertises. Absent when the agent has
143    /// no configuration to surface.
144    #[serde(default, skip_serializing_if = "Option::is_none")]
145    pub config_options: Option<Vec<SessionConfigOption>>,
146}
147
148// -- session/load ----------------------------------------------------------
149
150/// `session/load` request. Loads an existing session.
151#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
152#[serde(rename_all = "camelCase")]
153pub struct LoadSessionRequest {
154    /// The id of the session to load.
155    pub session_id: SessionId,
156    /// The working directory the session will run in.
157    /// Must match the session's recorded `cwd`.
158    pub cwd: String,
159    /// Additional workspace roots to activate. When
160    /// non-empty, this is the complete list — the
161    /// request replaces the previous list.
162    #[serde(default, skip_serializing_if = "Option::is_none")]
163    pub additional_directories: Option<Vec<String>>,
164    /// MCP servers the agent should connect to for
165    /// this session.
166    pub mcp_servers: Vec<McpServer>,
167}
168
169/// `session/load` response.
170#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
171#[serde(rename_all = "camelCase")]
172pub struct LoadSessionResponse {
173    /// Initial mode state the agent advertises. Absent
174    /// when the agent does not support session modes.
175    #[serde(default, skip_serializing_if = "Option::is_none")]
176    pub modes: Option<SessionModeState>,
177    /// Initial session configuration options.
178    #[serde(default, skip_serializing_if = "Option::is_none")]
179    pub config_options: Option<Vec<SessionConfigOption>>,
180}
181
182// -- session/list ----------------------------------------------------------
183
184/// `session/list` request. Enumerates sessions.
185#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
186#[serde(rename_all = "camelCase")]
187pub struct ListSessionsRequest {
188    /// Filter sessions by working directory. Absent
189    /// means "all sessions".
190    #[serde(default, skip_serializing_if = "Option::is_none")]
191    pub cwd: Option<String>,
192    /// Opaque cursor token from a previous response's
193    /// `nextCursor`. Absent means "first page".
194    #[serde(default, skip_serializing_if = "Option::is_none")]
195    pub cursor: Option<String>,
196}
197
198/// `session/list` response. A page of sessions plus
199/// the cursor for the next page.
200#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
201#[serde(rename_all = "camelCase")]
202pub struct ListSessionsResponse {
203    /// The session information objects for this page.
204    /// The schema's `x-deserialize-skip-invalid-items`
205    /// annotation means a malformed entry is silently
206    /// dropped during deserialisation.
207    pub sessions: Vec<SessionInfo>,
208    /// Opaque cursor for the next page. Absent means
209    /// "no more pages".
210    #[serde(default, skip_serializing_if = "Option::is_none")]
211    pub next_cursor: Option<String>,
212}
213
214/// Information about a single session, returned by
215/// `session/list`.
216#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
217#[serde(rename_all = "camelCase")]
218pub struct SessionInfo {
219    /// The session's unique identifier.
220    pub session_id: SessionId,
221    /// The working directory the session was created
222    /// with. Must be an absolute path.
223    pub cwd: String,
224    /// Additional workspace roots reported for this
225    /// session.
226    #[serde(default, skip_serializing_if = "Option::is_none")]
227    pub additional_directories: Option<Vec<String>>,
228    /// A human-readable title for the session.
229    #[serde(default, skip_serializing_if = "Option::is_none")]
230    pub title: Option<String>,
231    /// An ISO-8601 timestamp of the last activity.
232    #[serde(default, skip_serializing_if = "Option::is_none")]
233    pub updated_at: Option<String>,
234}
235
236// -- session/delete --------------------------------------------------------
237
238/// `session/delete` request. Removes a session from
239/// the agent's store.
240#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
241#[serde(rename_all = "camelCase")]
242pub struct DeleteSessionRequest {
243    /// The id of the session to delete.
244    pub session_id: SessionId,
245}
246
247/// `session/delete` response. Currently empty; the
248/// struct exists so a future field the schema adds
249/// does not break the consumer.
250#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
251#[serde(rename_all = "camelCase")]
252pub struct DeleteSessionResponse {}
253
254// -- session/resume --------------------------------------------------------
255
256/// `session/resume` request. Resumes a previously
257/// closed session.
258#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
259#[serde(rename_all = "camelCase")]
260pub struct ResumeSessionRequest {
261    /// The id of the session to resume.
262    pub session_id: SessionId,
263    /// The working directory the session will run in.
264    pub cwd: String,
265    /// Additional workspace roots to activate.
266    #[serde(default, skip_serializing_if = "Option::is_none")]
267    pub additional_directories: Option<Vec<String>>,
268    /// MCP servers the agent should connect to.
269    pub mcp_servers: Vec<McpServer>,
270}
271
272/// `session/resume` response.
273#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
274#[serde(rename_all = "camelCase")]
275pub struct ResumeSessionResponse {
276    /// The mode state the agent advertises after
277    /// resuming. Absent when the agent does not
278    /// support session modes.
279    #[serde(default, skip_serializing_if = "Option::is_none")]
280    pub modes: Option<SessionModeState>,
281    /// Initial session configuration options.
282    #[serde(default, skip_serializing_if = "Option::is_none")]
283    pub config_options: Option<Vec<SessionConfigOption>>,
284}
285
286// -- session/close ---------------------------------------------------------
287
288/// `session/close` request. Closes an active session
289/// without deleting it.
290#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
291#[serde(rename_all = "camelCase")]
292pub struct CloseSessionRequest {
293    /// The id of the session to close.
294    pub session_id: SessionId,
295}
296
297/// `session/close` response. Currently empty; the
298/// struct exists so a future field the schema adds
299/// does not break the consumer.
300#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
301#[serde(rename_all = "camelCase")]
302pub struct CloseSessionResponse {}
303
304// -- session/prompt --------------------------------------------------------
305
306/// `session/prompt` request. Sends a user turn to the
307/// agent.
308#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
309#[serde(rename_all = "camelCase")]
310pub struct PromptRequest {
311    /// The id of the session to send the prompt to.
312    pub session_id: SessionId,
313    /// The blocks of content that compose the user's
314    /// message. The schema says every agent MUST
315    /// support `Text` and `ResourceLink`; the other
316    /// variants are gated on the agent's
317    /// `PromptCapabilities`.
318    pub prompt: Vec<ContentBlock>,
319}
320
321/// `session/prompt` response. Carries the reason the
322/// turn ended.
323#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
324#[serde(rename_all = "camelCase")]
325pub struct PromptResponse {
326    /// Why the agent stopped processing the turn.
327    pub stop_reason: StopReason,
328}
329
330// -- session/cancel --------------------------------------------------------
331
332/// `session/cancel` notification. Cancels the
333/// in-flight prompt. A notification has no response;
334/// the consumer does not await a reply.
335#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
336#[serde(rename_all = "camelCase")]
337pub struct CancelNotification {
338    /// The id of the session whose prompt should be
339    /// cancelled.
340    pub session_id: SessionId,
341}
342
343// -- logout ----------------------------------------------------------------
344
345/// `logout` request. Terminates the auth session.
346#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
347#[serde(rename_all = "camelCase")]
348pub struct LogoutRequest {
349    /// The id of the session the auth is associated
350    /// with, when the auth is session-scoped.
351    #[serde(default, skip_serializing_if = "Option::is_none")]
352    pub session_id: Option<SessionId>,
353}
354
355/// `logout` response. Currently empty; the struct
356/// exists so a future field the schema adds does not
357/// break the consumer.
358#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
359#[serde(rename_all = "camelCase")]
360pub struct LogoutResponse {}
361
362// -- aux value objects -----------------------------------------------------
363
364/// Configuration for connecting to an MCP server.
365///
366/// A discriminated union over the three transports the
367/// schema names: `stdio` (the baseline every agent
368/// supports), `http`, and `sse`.
369#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
370#[serde(tag = "type", rename_all = "lowercase")]
371pub enum McpServer {
372    /// Stdio transport. All agents MUST support this.
373    Stdio(McpServerStdio),
374    /// HTTP transport. The agent advertises support
375    /// via `McpCapabilities::http`.
376    Http(McpServerHttp),
377    /// SSE transport. The agent advertises support
378    /// via `McpCapabilities::sse`.
379    Sse(McpServerSse),
380}
381
382/// Stdio transport configuration for MCP. All agents
383/// MUST support this transport.
384#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
385#[serde(rename_all = "camelCase")]
386pub struct McpServerStdio {
387    /// Human-readable name identifying this server.
388    pub name: String,
389    /// Absolute path to the MCP server executable.
390    pub command: String,
391    /// Command-line arguments to pass to the server.
392    pub args: Vec<String>,
393    /// Environment variables to set when launching
394    /// the server.
395    pub env: Vec<EnvVariable>,
396}
397
398/// HTTP transport configuration for MCP.
399#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
400#[serde(rename_all = "camelCase")]
401pub struct McpServerHttp {
402    /// Human-readable name identifying this server.
403    pub name: String,
404    /// URL to the MCP server.
405    pub url: String,
406    /// HTTP headers to set on every request to the
407    /// server.
408    pub headers: Vec<HttpHeader>,
409}
410
411/// SSE transport configuration for MCP.
412#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
413#[serde(rename_all = "camelCase")]
414pub struct McpServerSse {
415    /// Human-readable name identifying this server.
416    pub name: String,
417    /// URL to the MCP server's SSE endpoint.
418    pub url: String,
419    /// HTTP headers to set on every request to the
420    /// server.
421    pub headers: Vec<HttpHeader>,
422}
423
424/// An environment variable to set on a launched
425/// process. The schema's `EnvVariable` is just a
426/// `(name, value)` pair.
427#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
428#[serde(rename_all = "camelCase")]
429pub struct EnvVariable {
430    /// The variable name.
431    pub name: String,
432    /// The variable value.
433    pub value: String,
434}
435
436/// An HTTP header to set when making requests to a
437/// remote MCP server. The schema's `HttpHeader` is
438/// just a `(name, value)` pair.
439#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
440#[serde(rename_all = "camelCase")]
441pub struct HttpHeader {
442    /// The header name.
443    pub name: String,
444    /// The header value.
445    pub value: String,
446}
447
448/// The set of session modes the agent supports, plus
449/// the mode that is currently active. Returned by
450/// `session/new` and `session/load` when the agent
451/// supports session modes.
452#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
453#[serde(rename_all = "camelCase")]
454pub struct SessionModeState {
455    /// The set of available modes.
456    pub available_modes: Vec<SessionMode>,
457    /// The id of the currently active mode. Must be
458    /// a member of `available_modes`.
459    pub current_mode_id: SessionModeId,
460}
461
462/// A single session mode. The agent advertises the
463/// set of modes the session can switch between via
464/// `SessionModeState::available_modes`.
465#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
466#[serde(rename_all = "camelCase")]
467pub struct SessionMode {
468    /// The unique id of the mode. The consumer
469    /// references this id in
470    /// `SetSessionModeRequest` and
471    /// [`CurrentModeUpdate`][super::notification::CurrentModeUpdate].
472    pub id: SessionModeId,
473    /// Human-readable name.
474    pub name: String,
475    /// Human-readable description.
476    #[serde(default, skip_serializing_if = "Option::is_none")]
477    pub description: Option<String>,
478}
479
480/// A unique identifier for a session mode.
481///
482/// The schema's `SessionModeId` is `type: "string"`
483/// — opaque to the protocol. The newtype gives the
484/// API a self-documenting handle and a place for the
485/// future per-revision codec to narrow the type.
486#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
487#[serde(transparent)]
488pub struct SessionModeId(pub String);
489
490impl SessionModeId {
491    /// A `SessionModeId` from any string the schema
492    /// accepts.
493    pub fn new(value: impl Into<String>) -> Self {
494        Self(value.into())
495    }
496}
497
498impl core::fmt::Display for SessionModeId {
499    fn fmt(&self, formatter: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
500        core::fmt::Display::fmt(&self.0, formatter)
501    }
502}
503
504/// A session configuration option. The agent
505/// advertises the set of options the consumer can
506/// set via `SetSessionConfigOptionRequest`.
507#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
508#[serde(rename_all = "camelCase")]
509pub struct SessionConfigOption {
510    /// The unique id of the option. The consumer
511    /// references this id in
512    /// `SetSessionConfigOptionRequest`.
513    pub id: String,
514    /// Human-readable name.
515    pub name: String,
516    /// Human-readable description.
517    #[serde(default, skip_serializing_if = "Option::is_none")]
518    pub description: Option<String>,
519    /// The option's current value.
520    pub current_value: String,
521    /// The set of values the consumer can choose
522    /// from. Absent for free-form input.
523    #[serde(default, skip_serializing_if = "Option::is_none")]
524    pub options: Option<Vec<String>>,
525}
526
527#[cfg(test)]
528mod tests {
529    use super::*;
530    use serde_json::json;
531    use std::collections::BTreeMap;
532
533    /// The `text` session/prompt request carries a
534    /// `sessionId` and a `prompt` array of content
535    /// blocks. The wire form round-trips through
536    /// `Serialize` → `Deserialize`.
537    #[test]
538    fn a_prompt_request_round_trips() -> Result<(), serde_json::Error> {
539        let request = PromptRequest {
540            session_id: SessionId::new("session-1"),
541            prompt: vec![ContentBlock::Text(super::super::content::TextContent {
542                text: "hi".to_owned(),
543                annotations: None,
544                meta: BTreeMap::new(),
545            })],
546        };
547        let value = serde_json::to_value(&request)?;
548        assert_eq!(value.get("sessionId"), Some(&json!("session-1")));
549        let prompt = value
550            .get("prompt")
551            .and_then(serde_json::Value::as_array)
552            .ok_or_else(|| {
553                serde_json::Error::io(std::io::Error::other("prompt is not an array"))
554            })?;
555        assert_eq!(
556            prompt.first().and_then(|b| b.get("type")),
557            Some(&json!("text"))
558        );
559        let back: PromptRequest = serde_json::from_value(value)?;
560        assert_eq!(back, request);
561        Ok(())
562    }
563
564    /// The `sessionId` newtype round-trips as a
565    /// transparent string wrapper. The wire form is
566    /// just a string; the typed form is a `SessionId`.
567    #[test]
568    fn a_session_id_newtype_round_trips() -> Result<(), serde_json::Error> {
569        let id = SessionId::new("abc-123");
570        let value = serde_json::to_value(&id)?;
571        assert_eq!(value, json!("abc-123"));
572        let back: SessionId = serde_json::from_value(value)?;
573        assert_eq!(back, id);
574        Ok(())
575    }
576
577    /// The `McpServer` union serialises the
578    /// `type` discriminator on the wire. Each
579    /// variant round-trips independently.
580    #[test]
581    fn mcp_server_stdio_round_trips() -> Result<(), serde_json::Error> {
582        let server = McpServer::Stdio(McpServerStdio {
583            name: "stdio-server".to_owned(),
584            command: "/usr/local/bin/mcp-server".to_owned(),
585            args: vec!["--quiet".to_owned()],
586            env: vec![EnvVariable {
587                name: "LOG_LEVEL".to_owned(),
588                value: "info".to_owned(),
589            }],
590        });
591        let value = serde_json::to_value(&server)?;
592        assert_eq!(value.get("type"), Some(&json!("stdio")));
593        assert_eq!(value.get("name"), Some(&json!("stdio-server")));
594        let back: McpServer = serde_json::from_value(value)?;
595        assert_eq!(back, server);
596        Ok(())
597    }
598
599    /// The `session/new` request round-trips the
600    /// required fields (`cwd`, `mcpServers`) and
601    /// the optional `additionalDirectories`.
602    #[test]
603    fn a_new_session_request_round_trips() -> Result<(), serde_json::Error> {
604        let request = NewSessionRequest {
605            cwd: "/home/user/project".to_owned(),
606            additional_directories: Some(vec!["/home/user/shared".to_owned()]),
607            mcp_servers: vec![],
608        };
609        let value = serde_json::to_value(&request)?;
610        assert_eq!(value.get("cwd"), Some(&json!("/home/user/project")));
611        assert_eq!(
612            value.get("additionalDirectories"),
613            Some(&json!(["/home/user/shared"]))
614        );
615        assert_eq!(value.get("mcpServers"), Some(&json!([])));
616        let back: NewSessionRequest = serde_json::from_value(value)?;
617        assert_eq!(back, request);
618        Ok(())
619    }
620
621    /// The `session/list` request is `Default`
622    /// because every field is optional. The wire
623    /// form is the empty object `{}`.
624    #[test]
625    fn a_list_sessions_request_default_round_trips() -> Result<(), serde_json::Error> {
626        let request = ListSessionsRequest::default();
627        let value = serde_json::to_value(&request)?;
628        assert_eq!(value, json!({}));
629        let back: ListSessionsRequest = serde_json::from_value(value)?;
630        assert_eq!(back, request);
631        Ok(())
632    }
633
634    /// The `session/cancel` notification is a JSON
635    /// message with no `id` and no `result` —
636    /// round-trips as a struct with the right field
637    /// names.
638    #[test]
639    fn a_cancel_notification_round_trips() -> Result<(), serde_json::Error> {
640        let notification = CancelNotification {
641            session_id: SessionId::new("session-1"),
642        };
643        let value = serde_json::to_value(&notification)?;
644        assert_eq!(value.get("sessionId"), Some(&json!("session-1")));
645        let back: CancelNotification = serde_json::from_value(value)?;
646        assert_eq!(back, notification);
647        Ok(())
648    }
649
650    /// The `StopReason` enum deserialises the string
651    /// the schema names; the wire form is a string,
652    /// the typed form is a `Copy` enum.
653    #[test]
654    fn stop_reason_string_round_trips() -> Result<(), serde_json::Error> {
655        for (variant, wire) in [
656            (StopReason::EndTurn, "end_turn"),
657            (StopReason::MaxTokens, "max_tokens"),
658            (StopReason::MaxTurnRequests, "max_turn_requests"),
659            (StopReason::Refusal, "refusal"),
660            (StopReason::Cancelled, "cancelled"),
661        ] {
662            let value = serde_json::to_value(variant)?;
663            assert_eq!(value, json!(wire));
664            let back: StopReason = serde_json::from_value(value)?;
665            assert_eq!(back, variant);
666        }
667        Ok(())
668    }
669
670    /// The `NewSessionResponse` carries a
671    /// `sessionId` plus optional `modes` and
672    /// `configOptions`. The wire form is
673    /// `camelCase`.
674    #[test]
675    fn a_new_session_response_round_trips() -> Result<(), serde_json::Error> {
676        let response = NewSessionResponse {
677            session_id: SessionId::new("session-1"),
678            modes: Some(SessionModeState {
679                available_modes: vec![SessionMode {
680                    id: SessionModeId::new("default"),
681                    name: "Default".to_owned(),
682                    description: None,
683                }],
684                current_mode_id: SessionModeId::new("default"),
685            }),
686            config_options: None,
687        };
688        let value = serde_json::to_value(&response)?;
689        assert_eq!(value.get("sessionId"), Some(&json!("session-1")));
690        let modes = value
691            .get("modes")
692            .ok_or_else(|| serde_json::Error::io(std::io::Error::other("missing `modes` field")))?;
693        assert_eq!(modes.get("currentModeId"), Some(&json!("default")));
694        let back: NewSessionResponse = serde_json::from_value(value)?;
695        assert_eq!(back, response);
696        Ok(())
697    }
698
699    /// The `LoadSessionRequest` requires `sessionId`,
700    /// `cwd`, and `mcpServers`. The wire form uses
701    /// `camelCase`.
702    #[test]
703    fn a_load_session_request_round_trips() -> Result<(), serde_json::Error> {
704        let request = LoadSessionRequest {
705            session_id: SessionId::new("session-1"),
706            cwd: "/home/user/project".to_owned(),
707            additional_directories: None,
708            mcp_servers: vec![],
709        };
710        let value = serde_json::to_value(&request)?;
711        assert_eq!(value.get("sessionId"), Some(&json!("session-1")));
712        assert_eq!(value.get("cwd"), Some(&json!("/home/user/project")));
713        let back: LoadSessionRequest = serde_json::from_value(value)?;
714        assert_eq!(back, request);
715        Ok(())
716    }
717
718    /// The `LoadSessionResponse` carries optional
719    /// `modes` and `configOptions`. The wire form
720    /// is the empty object when both are absent.
721    #[test]
722    fn a_load_session_response_round_trips() -> Result<(), serde_json::Error> {
723        let response = LoadSessionResponse {
724            modes: None,
725            config_options: None,
726        };
727        let value = serde_json::to_value(&response)?;
728        assert_eq!(value, json!({}));
729        let back: LoadSessionResponse = serde_json::from_value(value)?;
730        assert_eq!(back, response);
731        Ok(())
732    }
733
734    /// The `ListSessionsResponse` carries a
735    /// `sessions` array and an optional
736    /// `nextCursor`. The cursor is omitted on
737    /// the last page.
738    #[test]
739    fn a_list_sessions_response_round_trips() -> Result<(), serde_json::Error> {
740        let response = ListSessionsResponse {
741            sessions: vec![SessionInfo {
742                session_id: SessionId::new("session-1"),
743                cwd: "/home/user/project".to_owned(),
744                additional_directories: None,
745                title: Some("My project".to_owned()),
746                updated_at: Some("2026-10-15T00:00:00Z".to_owned()),
747            }],
748            next_cursor: None,
749        };
750        let value = serde_json::to_value(&response)?;
751        let sessions = value
752            .get("sessions")
753            .and_then(serde_json::Value::as_array)
754            .ok_or_else(|| serde_json::Error::io(std::io::Error::other("missing `sessions`")))?;
755        assert_eq!(sessions.len(), 1);
756        assert_eq!(
757            sessions
758                .first()
759                .and_then(|s| s.get("sessionId"))
760                .and_then(serde_json::Value::as_str),
761            Some("session-1"),
762        );
763        let back: ListSessionsResponse = serde_json::from_value(value)?;
764        assert_eq!(back, response);
765        Ok(())
766    }
767
768    /// The `DeleteSessionRequest` is a
769    /// `sessionId` only. The wire form is
770    /// `{"sessionId": "..."}`.
771    #[test]
772    fn a_delete_session_request_round_trips() -> Result<(), serde_json::Error> {
773        let request = DeleteSessionRequest {
774            session_id: SessionId::new("session-1"),
775        };
776        let value = serde_json::to_value(&request)?;
777        assert_eq!(value, json!({"sessionId": "session-1"}));
778        let back: DeleteSessionRequest = serde_json::from_value(value)?;
779        assert_eq!(back, request);
780        Ok(())
781    }
782
783    /// The `DeleteSessionResponse` is the empty
784    /// object `{}` — the schema has no fields
785    /// the response carries.
786    #[test]
787    fn a_delete_session_response_round_trips() -> Result<(), serde_json::Error> {
788        let response = DeleteSessionResponse::default();
789        let value = serde_json::to_value(&response)?;
790        assert_eq!(value, json!({}));
791        let back: DeleteSessionResponse = serde_json::from_value(value)?;
792        assert_eq!(back, response);
793        Ok(())
794    }
795
796    /// The `ResumeSessionRequest` requires
797    /// `sessionId` and `cwd`. The optional
798    /// `additionalDirectories` and required
799    /// `mcpServers` round-trip.
800    #[test]
801    fn a_resume_session_request_round_trips() -> Result<(), serde_json::Error> {
802        let request = ResumeSessionRequest {
803            session_id: SessionId::new("session-1"),
804            cwd: "/home/user/project".to_owned(),
805            additional_directories: None,
806            mcp_servers: vec![],
807        };
808        let value = serde_json::to_value(&request)?;
809        assert_eq!(value.get("sessionId"), Some(&json!("session-1")));
810        assert_eq!(value.get("cwd"), Some(&json!("/home/user/project")));
811        let back: ResumeSessionRequest = serde_json::from_value(value)?;
812        assert_eq!(back, request);
813        Ok(())
814    }
815
816    /// The `ResumeSessionResponse` is shaped
817    /// like `NewSessionResponse` minus the
818    /// `sessionId` (the consumer already has it).
819    #[test]
820    fn a_resume_session_response_round_trips() -> Result<(), serde_json::Error> {
821        let response = ResumeSessionResponse {
822            modes: None,
823            config_options: None,
824        };
825        let value = serde_json::to_value(&response)?;
826        assert_eq!(value, json!({}));
827        let back: ResumeSessionResponse = serde_json::from_value(value)?;
828        assert_eq!(back, response);
829        Ok(())
830    }
831
832    /// The `CloseSessionRequest` is a
833    /// `sessionId` only. The wire form is
834    /// `{"sessionId": "..."}`.
835    #[test]
836    fn a_close_session_request_round_trips() -> Result<(), serde_json::Error> {
837        let request = CloseSessionRequest {
838            session_id: SessionId::new("session-1"),
839        };
840        let value = serde_json::to_value(&request)?;
841        assert_eq!(value, json!({"sessionId": "session-1"}));
842        let back: CloseSessionRequest = serde_json::from_value(value)?;
843        assert_eq!(back, request);
844        Ok(())
845    }
846
847    /// The `CloseSessionResponse` is the empty
848    /// object `{}` — the schema has no fields
849    /// the response carries.
850    #[test]
851    fn a_close_session_response_round_trips() -> Result<(), serde_json::Error> {
852        let response = CloseSessionResponse::default();
853        let value = serde_json::to_value(&response)?;
854        assert_eq!(value, json!({}));
855        let back: CloseSessionResponse = serde_json::from_value(value)?;
856        assert_eq!(back, response);
857        Ok(())
858    }
859
860    /// The `PromptResponse` carries a
861    /// `stopReason`. The wire form is
862    /// `{"stopReason": "end_turn"}`.
863    #[test]
864    fn a_prompt_response_round_trips() -> Result<(), serde_json::Error> {
865        let response = PromptResponse {
866            stop_reason: StopReason::EndTurn,
867        };
868        let value = serde_json::to_value(&response)?;
869        assert_eq!(value, json!({"stopReason": "end_turn"}));
870        let back: PromptResponse = serde_json::from_value(value)?;
871        assert_eq!(back, response);
872        Ok(())
873    }
874
875    /// The `LogoutRequest` is `Default` because
876    /// every field is optional. The wire form
877    /// is the empty object `{}`.
878    #[test]
879    fn a_logout_request_default_round_trips() -> Result<(), serde_json::Error> {
880        let request = LogoutRequest::default();
881        let value = serde_json::to_value(&request)?;
882        assert_eq!(value, json!({}));
883        let back: LogoutRequest = serde_json::from_value(value)?;
884        assert_eq!(back, request);
885        Ok(())
886    }
887
888    /// The `LogoutRequest` with a `sessionId`
889    /// carries the id on the wire.
890    #[test]
891    fn a_logout_request_with_session_id_round_trips() -> Result<(), serde_json::Error> {
892        let request = LogoutRequest {
893            session_id: Some(SessionId::new("session-1")),
894        };
895        let value = serde_json::to_value(&request)?;
896        assert_eq!(value, json!({"sessionId": "session-1"}));
897        let back: LogoutRequest = serde_json::from_value(value)?;
898        assert_eq!(back, request);
899        Ok(())
900    }
901
902    /// The `LogoutResponse` is the empty
903    /// object `{}` — the schema has no fields
904    /// the response carries.
905    #[test]
906    fn a_logout_response_round_trips() -> Result<(), serde_json::Error> {
907        let response = LogoutResponse::default();
908        let value = serde_json::to_value(&response)?;
909        assert_eq!(value, json!({}));
910        let back: LogoutResponse = serde_json::from_value(value)?;
911        assert_eq!(back, response);
912        Ok(())
913    }
914
915    /// The `McpServer::Http` variant carries
916    /// `name`, `url`, and `headers`. The
917    /// `type` discriminator is `http`.
918    #[test]
919    fn mcp_server_http_round_trips() -> Result<(), serde_json::Error> {
920        let server = McpServer::Http(McpServerHttp {
921            name: "http-server".to_owned(),
922            url: "https://mcp.example.com/sse".to_owned(),
923            headers: vec![HttpHeader {
924                name: "Authorization".to_owned(),
925                value: "Bearer token".to_owned(),
926            }],
927        });
928        let value = serde_json::to_value(&server)?;
929        assert_eq!(value.get("type"), Some(&json!("http")));
930        assert_eq!(
931            value.get("url"),
932            Some(&json!("https://mcp.example.com/sse"))
933        );
934        let back: McpServer = serde_json::from_value(value)?;
935        assert_eq!(back, server);
936        Ok(())
937    }
938
939    /// The `McpServer::Sse` variant carries
940    /// `name`, `url`, and `headers`. The
941    /// `type` discriminator is `sse`.
942    #[test]
943    fn mcp_server_sse_round_trips() -> Result<(), serde_json::Error> {
944        let server = McpServer::Sse(McpServerSse {
945            name: "sse-server".to_owned(),
946            url: "https://mcp.example.com/sse".to_owned(),
947            headers: vec![],
948        });
949        let value = serde_json::to_value(&server)?;
950        assert_eq!(value.get("type"), Some(&json!("sse")));
951        let back: McpServer = serde_json::from_value(value)?;
952        assert_eq!(back, server);
953        Ok(())
954    }
955
956    /// A `SessionConfigOption` with options
957    /// carries the list of values the consumer
958    /// can choose from.
959    #[test]
960    fn a_session_config_option_round_trips() -> Result<(), serde_json::Error> {
961        let option = SessionConfigOption {
962            id: "model".to_owned(),
963            name: "Model".to_owned(),
964            description: Some("The LLM to use".to_owned()),
965            current_value: "claude-sonnet-4-5".to_owned(),
966            options: Some(vec![
967                "claude-sonnet-4-5".to_owned(),
968                "claude-opus-4-7".to_owned(),
969            ]),
970        };
971        let value = serde_json::to_value(&option)?;
972        assert_eq!(value.get("id"), Some(&json!("model")));
973        assert_eq!(value.get("currentValue"), Some(&json!("claude-sonnet-4-5")));
974        let back: SessionConfigOption = serde_json::from_value(value)?;
975        assert_eq!(back, option);
976        Ok(())
977    }
978}