Skip to main content

leviath_agent_client/
protocol.rs

1//! Agent Client Protocol wire types.
2//!
3//! JSON-RPC 2.0 messages, newline-delimited, one compact message per line. Field
4//! names are camelCase and discriminator *values* are snake_case, per the spec.
5//!
6//! Every type a client sends us is permissive on deserialize - unknown fields are
7//! ignored and every optional field carries `#[serde(default)]` - because real
8//! hosts differ in how much of the spec they populate. Gas City, for instance,
9//! sends `initialize` with only `protocolVersion` and `clientInfo` and no
10//! `clientCapabilities` at all.
11
12use serde::{Deserialize, Serialize};
13
14/// The protocol MAJOR version this crate speaks.
15pub const PROTOCOL_VERSION: u32 = 1;
16
17/// The largest single JSON frame (one line) we will ever write.
18///
19/// Hosts read the stdio stream line-by-line with a bounded buffer and drop -
20/// or error on - anything longer: Gas City's `bufio.Scanner` is capped at 1 MiB
21/// and abandons its read loop on an oversized frame, which would strand the
22/// session. Callers streaming agent output must therefore split it into chunks
23/// no larger than this, leaving generous headroom for JSON escaping (a
24/// worst-case string of control characters expands ~6×).
25pub const MAX_FRAME_BYTES: usize = 64 * 1024;
26
27/// Compile-time guard on the headroom [`MAX_FRAME_BYTES`] claims: even if every
28/// byte of a chunk needed the longest JSON escape (`\u00XX`, 6×), the frame must
29/// still fit inside a host's 1 MiB line limit.
30const _: () = assert!(MAX_FRAME_BYTES * 6 < 1024 * 1024);
31
32/// Standard JSON-RPC 2.0 error codes.
33pub mod error_codes {
34    /// Invalid JSON was received.
35    pub const PARSE_ERROR: i32 = -32700;
36    /// The JSON sent is not a valid request object.
37    pub const INVALID_REQUEST: i32 = -32600;
38    /// The method does not exist.
39    pub const METHOD_NOT_FOUND: i32 = -32601;
40    /// Invalid method parameters.
41    pub const INVALID_PARAMS: i32 = -32602;
42    /// Internal agent error.
43    pub const INTERNAL_ERROR: i32 = -32603;
44}
45
46// ─── Envelope ────────────────────────────────────────────────────────────────
47
48/// A JSON-RPC 2.0 message: request, response, or notification depending on which
49/// fields are set.
50///
51/// `id` is a [`serde_json::Value`] rather than an integer so any id a host uses
52/// (number or string, both legal) round-trips back verbatim in the response.
53#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
54pub struct JsonRpcMessage {
55    /// Always `"2.0"`.
56    pub jsonrpc: String,
57    /// Request/response correlation id; absent on notifications.
58    #[serde(default, skip_serializing_if = "Option::is_none")]
59    pub id: Option<serde_json::Value>,
60    /// Method name; set on requests and notifications.
61    #[serde(default, skip_serializing_if = "Option::is_none")]
62    pub method: Option<String>,
63    /// Method parameters.
64    #[serde(default, skip_serializing_if = "Option::is_none")]
65    pub params: Option<serde_json::Value>,
66    /// Success payload; set on responses.
67    #[serde(default, skip_serializing_if = "Option::is_none")]
68    pub result: Option<serde_json::Value>,
69    /// Failure payload; set on error responses.
70    #[serde(default, skip_serializing_if = "Option::is_none")]
71    pub error: Option<JsonRpcError>,
72}
73
74impl JsonRpcMessage {
75    /// A successful response to the request identified by `id`.
76    ///
77    /// `result` is always one of this module's protocol types, whose
78    /// serialization is infallible; a failure would be a bug here rather than a
79    /// runtime condition, so it degrades to JSON `null` instead of propagating.
80    pub fn response(id: serde_json::Value, result: &impl Serialize) -> Self {
81        Self {
82            jsonrpc: "2.0".to_string(),
83            id: Some(id),
84            method: None,
85            params: None,
86            result: Some(serde_json::to_value(result).unwrap_or(serde_json::Value::Null)),
87            error: None,
88        }
89    }
90
91    /// An error response to the request identified by `id`.
92    pub fn error_response(id: serde_json::Value, code: i32, message: impl Into<String>) -> Self {
93        Self {
94            jsonrpc: "2.0".to_string(),
95            id: Some(id),
96            method: None,
97            params: None,
98            result: None,
99            error: Some(JsonRpcError {
100                code,
101                message: message.into(),
102            }),
103        }
104    }
105
106    /// An outbound notification (no id, no response expected).
107    pub fn notification(method: impl Into<String>, params: &impl Serialize) -> Self {
108        Self {
109            jsonrpc: "2.0".to_string(),
110            id: None,
111            method: Some(method.into()),
112            params: Some(serde_json::to_value(params).unwrap_or(serde_json::Value::Null)),
113            result: None,
114            error: None,
115        }
116    }
117
118    /// An outbound request (agent → client), e.g. `session/request_permission`.
119    pub fn request(
120        id: serde_json::Value,
121        method: impl Into<String>,
122        params: &impl Serialize,
123    ) -> Self {
124        Self {
125            jsonrpc: "2.0".to_string(),
126            id: Some(id),
127            method: Some(method.into()),
128            params: Some(serde_json::to_value(params).unwrap_or(serde_json::Value::Null)),
129            result: None,
130            error: None,
131        }
132    }
133
134    /// Whether this message is a notification: a method call with no id, which
135    /// must never be answered.
136    pub fn is_notification(&self) -> bool {
137        self.id.is_none() && self.method.is_some()
138    }
139}
140
141/// A JSON-RPC 2.0 error object.
142#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
143pub struct JsonRpcError {
144    /// The error code (see [`error_codes`]).
145    pub code: i32,
146    /// A human-readable description.
147    pub message: String,
148}
149
150// ─── Content ─────────────────────────────────────────────────────────────────
151
152/// One block of prompt or message content.
153///
154/// Modelled as a permissive struct rather than a tagged enum: hosts send block
155/// kinds we do not advertise support for (`image`, `audio`, `resource_link`),
156/// and a strict enum would fail the whole prompt rather than skipping the block
157/// we cannot use. Unknown kinds deserialize with `text`/`resource` both `None`
158/// and are dropped by [`crate::flatten_prompt`].
159#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
160pub struct ContentBlock {
161    /// The block kind: `text`, `resource`, `image`, `audio`, `resource_link`.
162    #[serde(rename = "type")]
163    pub kind: String,
164    /// The text, for `text` blocks.
165    #[serde(default, skip_serializing_if = "Option::is_none")]
166    pub text: Option<String>,
167    /// The inlined resource, for `resource` blocks.
168    #[serde(default, skip_serializing_if = "Option::is_none")]
169    pub resource: Option<EmbeddedResource>,
170}
171
172impl ContentBlock {
173    /// A `text` content block.
174    pub fn text(text: impl Into<String>) -> Self {
175        Self {
176            kind: "text".to_string(),
177            text: Some(text.into()),
178            resource: None,
179        }
180    }
181}
182
183/// A resource inlined into a prompt (the `embeddedContext` capability).
184#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
185#[serde(rename_all = "camelCase")]
186pub struct EmbeddedResource {
187    /// The resource's URI.
188    pub uri: String,
189    /// The resource's MIME type, when known.
190    #[serde(default, skip_serializing_if = "Option::is_none")]
191    pub mime_type: Option<String>,
192    /// The resource's textual content.
193    #[serde(default, skip_serializing_if = "Option::is_none")]
194    pub text: Option<String>,
195}
196
197// ─── initialize ──────────────────────────────────────────────────────────────
198
199/// Params of the `initialize` request.
200#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
201#[serde(rename_all = "camelCase")]
202pub struct InitializeParams {
203    /// The MAJOR protocol version the client speaks.
204    #[serde(default)]
205    pub protocol_version: u32,
206    /// What the client can do on the agent's behalf. Absent for hosts that do
207    /// not implement the client-side methods at all.
208    #[serde(default)]
209    pub client_capabilities: Option<ClientCapabilities>,
210}
211
212/// Client-side capabilities advertised at `initialize`.
213#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
214#[serde(rename_all = "camelCase")]
215pub struct ClientCapabilities {
216    /// Filesystem methods the client offers (`fs/read_text_file` etc.).
217    #[serde(default)]
218    pub fs: Option<serde_json::Value>,
219    /// Whether the client offers the `terminal/*` methods.
220    #[serde(default)]
221    pub terminal: Option<bool>,
222}
223
224/// Result of the `initialize` request.
225#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
226#[serde(rename_all = "camelCase")]
227pub struct InitializeResult {
228    /// The MAJOR protocol version the agent speaks.
229    pub protocol_version: u32,
230    /// What this agent supports.
231    pub agent_capabilities: AgentCapabilities,
232    /// This agent's identity.
233    pub agent_info: AgentInfo,
234    /// Authentication methods; Leviath authenticates against LLM providers
235    /// itself, so this is always empty.
236    pub auth_methods: Vec<serde_json::Value>,
237}
238
239/// Agent-side capabilities advertised at `initialize`.
240#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
241#[serde(rename_all = "camelCase")]
242pub struct AgentCapabilities {
243    /// Whether `session/load` is supported (resuming a session by id).
244    pub load_session: bool,
245    /// Which prompt content kinds the agent accepts.
246    pub prompt_capabilities: PromptCapabilities,
247}
248
249/// Prompt content kinds an agent accepts.
250#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
251#[serde(rename_all = "camelCase")]
252pub struct PromptCapabilities {
253    /// `image` content blocks.
254    pub image: bool,
255    /// `audio` content blocks.
256    pub audio: bool,
257    /// `resource` content blocks with inlined text.
258    pub embedded_context: bool,
259}
260
261/// An agent's identity.
262#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
263pub struct AgentInfo {
264    /// Machine-readable name.
265    pub name: String,
266    /// Version string.
267    pub version: String,
268}
269
270// ─── session/new ─────────────────────────────────────────────────────────────
271
272/// Params of the `session/new` request.
273#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
274#[serde(rename_all = "camelCase")]
275pub struct SessionNewParams {
276    /// Absolute working directory for the session.
277    #[serde(default)]
278    pub cwd: String,
279    /// MCP servers the client wants attached. Captured verbatim - Leviath
280    /// blueprints declare their own MCP servers, so these are logged and not
281    /// injected (see the module docs of `leviath_cli::commands::agent_client`).
282    #[serde(default)]
283    pub mcp_servers: Vec<serde_json::Value>,
284}
285
286/// Result of the `session/new` request.
287#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
288#[serde(rename_all = "camelCase")]
289pub struct SessionNewResult {
290    /// The new session's id.
291    pub session_id: String,
292}
293
294// ─── session/prompt ──────────────────────────────────────────────────────────
295
296/// Params of the `session/prompt` request.
297#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
298#[serde(rename_all = "camelCase")]
299pub struct SessionPromptParams {
300    /// The session to prompt.
301    #[serde(default)]
302    pub session_id: String,
303    /// The prompt's content blocks.
304    #[serde(default)]
305    pub prompt: Vec<ContentBlock>,
306}
307
308/// Result of the `session/prompt` request.
309#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
310#[serde(rename_all = "camelCase")]
311pub struct SessionPromptResult {
312    /// Why the turn ended.
313    pub stop_reason: StopReason,
314}
315
316/// Why a prompt turn ended.
317#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
318#[serde(rename_all = "snake_case")]
319pub enum StopReason {
320    /// The agent finished its turn normally.
321    EndTurn,
322    /// The token limit was reached.
323    MaxTokens,
324    /// The per-turn model-request limit was exceeded.
325    MaxTurnRequests,
326    /// The agent declined to continue.
327    Refusal,
328    /// The client cancelled the turn.
329    Cancelled,
330}
331
332// ─── session/cancel ──────────────────────────────────────────────────────────
333
334/// Params of the `session/cancel` notification.
335#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
336#[serde(rename_all = "camelCase")]
337pub struct SessionCancelParams {
338    /// The session whose in-flight turn should be cancelled.
339    #[serde(default)]
340    pub session_id: String,
341}
342
343// ─── session/update ──────────────────────────────────────────────────────────
344
345/// Params of an outbound `session/update` notification.
346#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
347#[serde(rename_all = "camelCase")]
348pub struct SessionUpdateParams {
349    /// The session the update belongs to.
350    pub session_id: String,
351    /// The update itself.
352    pub update: SessionUpdate,
353}
354
355/// One `session/update` payload, discriminated by `sessionUpdate`.
356///
357/// Only the variants Leviath emits are modelled. The spec also defines
358/// `agent_thought_chunk`, `tool_call`, `tool_call_update`, `plan`,
359/// `available_commands_update` and `current_mode_update`; adding one is a
360/// matter of adding a variant here.
361#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
362#[serde(tag = "sessionUpdate", rename_all = "snake_case")]
363pub enum SessionUpdate {
364    /// A chunk of the agent's user-visible output.
365    AgentMessageChunk {
366        /// The chunk's content.
367        content: ContentBlock,
368    },
369    /// Context-window consumption, for host-side progress display.
370    #[serde(rename_all = "camelCase")]
371    UsageUpdate {
372        /// Context tokens currently in use.
373        used: usize,
374        /// The context window's capacity.
375        size: usize,
376    },
377}
378
379// ─── session/request_permission ──────────────────────────────────────────────
380
381/// Params of an outbound `session/request_permission` request.
382#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
383#[serde(rename_all = "camelCase")]
384pub struct RequestPermissionParams {
385    /// The session the tool call belongs to.
386    pub session_id: String,
387    /// The tool call awaiting approval.
388    pub tool_call: ToolCallRef,
389    /// The choices offered to the user.
390    pub options: Vec<PermissionOption>,
391}
392
393/// The tool call a permission request refers to.
394#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
395#[serde(rename_all = "camelCase")]
396pub struct ToolCallRef {
397    /// Correlation id for this tool call.
398    pub tool_call_id: String,
399    /// Human-readable summary.
400    pub title: String,
401    /// What kind of operation it is.
402    pub kind: ToolKind,
403    /// Its current status.
404    pub status: ToolCallStatus,
405}
406
407/// A tool call's lifecycle status.
408#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
409#[serde(rename_all = "snake_case")]
410pub enum ToolCallStatus {
411    /// Not started (e.g. awaiting permission).
412    Pending,
413    /// Executing.
414    InProgress,
415    /// Finished successfully.
416    Completed,
417    /// Finished with an error.
418    Failed,
419}
420
421/// The category of operation a tool performs.
422#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
423#[serde(rename_all = "snake_case")]
424pub enum ToolKind {
425    /// Reads data.
426    Read,
427    /// Modifies data.
428    Edit,
429    /// Deletes data.
430    Delete,
431    /// Moves or renames data.
432    Move,
433    /// Searches.
434    Search,
435    /// Executes a command.
436    Execute,
437    /// Reasons without side effects.
438    Think,
439    /// Fetches remote data.
440    Fetch,
441    /// Anything else.
442    Other,
443}
444
445/// One choice offered in a permission request.
446#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
447#[serde(rename_all = "camelCase")]
448pub struct PermissionOption {
449    /// The id echoed back in the outcome.
450    pub option_id: String,
451    /// The label shown to the user.
452    pub name: String,
453    /// What selecting it means.
454    pub kind: PermissionOptionKind,
455}
456
457/// What selecting a permission option means.
458#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
459#[serde(rename_all = "snake_case")]
460pub enum PermissionOptionKind {
461    /// Approve this call only.
462    AllowOnce,
463    /// Approve this and future equivalent calls.
464    AllowAlways,
465    /// Deny this call only.
466    RejectOnce,
467    /// Deny this and future equivalent calls.
468    RejectAlways,
469}
470
471/// Result of a `session/request_permission` request.
472#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
473pub struct RequestPermissionResult {
474    /// What the user chose.
475    pub outcome: PermissionOutcome,
476}
477
478/// The user's decision on a permission request.
479#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
480#[serde(tag = "outcome", rename_all = "snake_case")]
481pub enum PermissionOutcome {
482    /// An option was chosen.
483    #[serde(rename_all = "camelCase")]
484    Selected {
485        /// The chosen option's id.
486        option_id: String,
487    },
488    /// The turn was cancelled before the user chose.
489    Cancelled,
490}
491
492#[cfg(test)]
493mod tests {
494    use super::*;
495
496    /// Serialize `value` compactly, as the wire framing does.
497    fn json(value: &impl Serialize) -> String {
498        serde_json::to_string(value).unwrap()
499    }
500
501    /// Serialize `value` and re-parse it, for comparing against an expected
502    /// shape without pinning field *order*.
503    ///
504    /// Whole-[`JsonRpcMessage`] assertions must go through this: `params` and
505    /// `result` are stored as [`serde_json::Value`], whose object map is sorted,
506    /// so the emitted key order is not the declaration order. JSON objects are
507    /// unordered by definition and every host parses by name, so this is a
508    /// property of the encoding rather than something to assert on.
509    fn shape(value: &impl Serialize) -> serde_json::Value {
510        serde_json::from_str(&json(value)).unwrap()
511    }
512
513    #[test]
514    fn response_carries_id_and_result_and_omits_everything_else() {
515        let msg = JsonRpcMessage::response(
516            serde_json::json!(7),
517            &SessionNewResult {
518                session_id: "s1".to_string(),
519            },
520        );
521        assert_eq!(
522            json(&msg),
523            r#"{"jsonrpc":"2.0","id":7,"result":{"sessionId":"s1"}}"#
524        );
525        assert!(!msg.is_notification());
526    }
527
528    #[test]
529    fn error_response_carries_code_and_message() {
530        let msg = JsonRpcMessage::error_response(
531            serde_json::json!("abc"),
532            error_codes::METHOD_NOT_FOUND,
533            "no such method",
534        );
535        assert_eq!(
536            json(&msg),
537            r#"{"jsonrpc":"2.0","id":"abc","error":{"code":-32601,"message":"no such method"}}"#
538        );
539        assert!(!msg.is_notification());
540    }
541
542    #[test]
543    fn notification_has_no_id() {
544        let msg = JsonRpcMessage::notification(
545            "session/update",
546            &SessionUpdateParams {
547                session_id: "s1".to_string(),
548                update: SessionUpdate::AgentMessageChunk {
549                    content: ContentBlock::text("hi"),
550                },
551            },
552        );
553        assert_eq!(
554            shape(&msg),
555            serde_json::json!({
556                "jsonrpc": "2.0",
557                "method": "session/update",
558                "params": {
559                    "sessionId": "s1",
560                    "update": {
561                        "sessionUpdate": "agent_message_chunk",
562                        "content": {"type": "text", "text": "hi"},
563                    },
564                },
565            })
566        );
567        // No `id` key at all - a notification must never invite a response.
568        assert!(!json(&msg).contains("\"id\""));
569        assert!(msg.is_notification());
570    }
571
572    #[test]
573    fn request_has_both_id_and_method() {
574        let msg = JsonRpcMessage::request(
575            serde_json::json!(1),
576            "session/request_permission",
577            &serde_json::json!({"sessionId": "s1"}),
578        );
579        assert_eq!(
580            json(&msg),
581            r#"{"jsonrpc":"2.0","id":1,"method":"session/request_permission","params":{"sessionId":"s1"}}"#
582        );
583        assert!(!msg.is_notification());
584    }
585
586    #[test]
587    fn a_response_with_neither_id_nor_method_is_not_a_notification() {
588        let msg: JsonRpcMessage = serde_json::from_str(r#"{"jsonrpc":"2.0"}"#).unwrap();
589        assert!(!msg.is_notification());
590    }
591
592    #[test]
593    fn usage_update_uses_camel_case_fields() {
594        let msg = JsonRpcMessage::notification(
595            "session/update",
596            &SessionUpdateParams {
597                session_id: "s1".to_string(),
598                update: SessionUpdate::UsageUpdate {
599                    used: 10,
600                    size: 200,
601                },
602            },
603        );
604        assert_eq!(
605            shape(&msg)["params"]["update"],
606            serde_json::json!({"sessionUpdate": "usage_update", "used": 10, "size": 200})
607        );
608    }
609
610    #[test]
611    fn session_update_round_trips() {
612        let update = SessionUpdate::AgentMessageChunk {
613            content: ContentBlock::text("out"),
614        };
615        assert_eq!(
616            serde_json::from_str::<SessionUpdate>(&json(&update)).unwrap(),
617            update
618        );
619        let usage = SessionUpdate::UsageUpdate { used: 1, size: 2 };
620        assert_eq!(
621            serde_json::from_str::<SessionUpdate>(&json(&usage)).unwrap(),
622            usage
623        );
624    }
625
626    #[test]
627    fn initialize_params_tolerate_a_bare_protocol_version() {
628        // Gas City sends `protocolVersion` + `clientInfo` and no capabilities.
629        let params: InitializeParams = serde_json::from_str(
630            r#"{"protocolVersion":1,"clientInfo":{"name":"gc","version":"1.0"}}"#,
631        )
632        .unwrap();
633        assert_eq!(params.protocol_version, 1);
634        assert!(params.client_capabilities.is_none());
635
636        // A fully-populated client round-trips too.
637        let full: InitializeParams = serde_json::from_str(
638            r#"{"protocolVersion":1,"clientCapabilities":{"fs":{"readTextFile":true},"terminal":true}}"#,
639        )
640        .unwrap();
641        let caps = full.client_capabilities.unwrap();
642        assert!(caps.terminal.unwrap());
643        assert!(caps.fs.is_some());
644
645        // Entirely absent params still deserialize.
646        assert_eq!(
647            serde_json::from_str::<InitializeParams>("{}").unwrap(),
648            InitializeParams::default()
649        );
650    }
651
652    #[test]
653    fn initialize_result_serializes_the_spec_shape() {
654        let result = InitializeResult {
655            protocol_version: PROTOCOL_VERSION,
656            agent_capabilities: AgentCapabilities {
657                load_session: false,
658                prompt_capabilities: PromptCapabilities {
659                    image: false,
660                    audio: false,
661                    embedded_context: true,
662                },
663            },
664            agent_info: AgentInfo {
665                name: "leviath".to_string(),
666                version: "0.1.0".to_string(),
667            },
668            auth_methods: vec![],
669        };
670        assert_eq!(
671            json(&result),
672            r#"{"protocolVersion":1,"agentCapabilities":{"loadSession":false,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":true}},"agentInfo":{"name":"leviath","version":"0.1.0"},"authMethods":[]}"#
673        );
674        assert_eq!(
675            serde_json::from_str::<InitializeResult>(&json(&result)).unwrap(),
676            result
677        );
678    }
679
680    #[test]
681    fn session_new_params_default_every_field() {
682        let params: SessionNewParams = serde_json::from_str("{}").unwrap();
683        assert_eq!(params, SessionNewParams::default());
684        assert_eq!(params.cwd, "");
685        assert!(params.mcp_servers.is_empty());
686
687        let populated: SessionNewParams =
688            serde_json::from_str(r#"{"cwd":"/w","mcpServers":[{"name":"x"}]}"#).unwrap();
689        assert_eq!(populated.cwd, "/w");
690        assert_eq!(populated.mcp_servers.len(), 1);
691        // Round-trips, so the captured servers can be logged verbatim.
692        assert_eq!(
693            serde_json::from_str::<SessionNewParams>(&json(&populated)).unwrap(),
694            populated
695        );
696    }
697
698    #[test]
699    fn prompt_params_accept_unknown_block_kinds() {
700        let params: SessionPromptParams = serde_json::from_str(
701            r#"{"sessionId":"s","prompt":[{"type":"text","text":"hi"},{"type":"image","data":"..."}]}"#,
702        )
703        .unwrap();
704        assert_eq!(params.session_id, "s");
705        assert_eq!(params.prompt.len(), 2);
706        assert_eq!(params.prompt[1].kind, "image");
707        assert!(params.prompt[1].text.is_none());
708        assert!(params.prompt[1].resource.is_none());
709
710        assert_eq!(
711            serde_json::from_str::<SessionPromptParams>("{}").unwrap(),
712            SessionPromptParams::default()
713        );
714    }
715
716    #[test]
717    fn embedded_resource_round_trips_with_and_without_optionals() {
718        let full = EmbeddedResource {
719            uri: "file:///a.rs".to_string(),
720            mime_type: Some("text/rust".to_string()),
721            text: Some("fn main() {}".to_string()),
722        };
723        assert_eq!(
724            json(&full),
725            r#"{"uri":"file:///a.rs","mimeType":"text/rust","text":"fn main() {}"}"#
726        );
727        assert_eq!(
728            serde_json::from_str::<EmbeddedResource>(&json(&full)).unwrap(),
729            full
730        );
731
732        let bare = EmbeddedResource {
733            uri: "u".to_string(),
734            mime_type: None,
735            text: None,
736        };
737        assert_eq!(json(&bare), r#"{"uri":"u"}"#);
738    }
739
740    #[test]
741    fn content_block_text_constructor_and_round_trip() {
742        let block = ContentBlock::text("hello");
743        assert_eq!(json(&block), r#"{"type":"text","text":"hello"}"#);
744        assert_eq!(
745            serde_json::from_str::<ContentBlock>(&json(&block)).unwrap(),
746            block
747        );
748
749        let resource = ContentBlock {
750            kind: "resource".to_string(),
751            text: None,
752            resource: Some(EmbeddedResource {
753                uri: "u".to_string(),
754                mime_type: None,
755                text: Some("body".to_string()),
756            }),
757        };
758        assert_eq!(
759            serde_json::from_str::<ContentBlock>(&json(&resource)).unwrap(),
760            resource
761        );
762    }
763
764    #[test]
765    fn stop_reasons_use_snake_case() {
766        for (reason, wire) in [
767            (StopReason::EndTurn, r#""end_turn""#),
768            (StopReason::MaxTokens, r#""max_tokens""#),
769            (StopReason::MaxTurnRequests, r#""max_turn_requests""#),
770            (StopReason::Refusal, r#""refusal""#),
771            (StopReason::Cancelled, r#""cancelled""#),
772        ] {
773            assert_eq!(json(&reason), wire);
774            assert_eq!(serde_json::from_str::<StopReason>(wire).unwrap(), reason);
775        }
776        assert_eq!(
777            json(&SessionPromptResult {
778                stop_reason: StopReason::EndTurn
779            }),
780            r#"{"stopReason":"end_turn"}"#
781        );
782        assert_eq!(
783            serde_json::from_str::<SessionPromptResult>(r#"{"stopReason":"refusal"}"#)
784                .unwrap()
785                .stop_reason,
786            StopReason::Refusal
787        );
788    }
789
790    #[test]
791    fn session_cancel_params_default_the_session_id() {
792        assert_eq!(
793            serde_json::from_str::<SessionCancelParams>("{}").unwrap(),
794            SessionCancelParams::default()
795        );
796        let params: SessionCancelParams = serde_json::from_str(r#"{"sessionId":"s"}"#).unwrap();
797        assert_eq!(params.session_id, "s");
798        assert_eq!(json(&params), r#"{"sessionId":"s"}"#);
799    }
800
801    #[test]
802    fn permission_request_serializes_the_spec_shape() {
803        let params = RequestPermissionParams {
804            session_id: "s1".to_string(),
805            tool_call: ToolCallRef {
806                tool_call_id: "t1".to_string(),
807                title: "run tests".to_string(),
808                kind: ToolKind::Execute,
809                status: ToolCallStatus::Pending,
810            },
811            options: vec![PermissionOption {
812                option_id: "allow-once".to_string(),
813                name: "Allow".to_string(),
814                kind: PermissionOptionKind::AllowOnce,
815            }],
816        };
817        assert_eq!(
818            json(&params),
819            r#"{"sessionId":"s1","toolCall":{"toolCallId":"t1","title":"run tests","kind":"execute","status":"pending"},"options":[{"optionId":"allow-once","name":"Allow","kind":"allow_once"}]}"#
820        );
821        assert_eq!(
822            serde_json::from_str::<RequestPermissionParams>(&json(&params)).unwrap(),
823            params
824        );
825    }
826
827    #[test]
828    fn every_tool_and_permission_enum_value_round_trips() {
829        for (kind, wire) in [
830            (ToolKind::Read, r#""read""#),
831            (ToolKind::Edit, r#""edit""#),
832            (ToolKind::Delete, r#""delete""#),
833            (ToolKind::Move, r#""move""#),
834            (ToolKind::Search, r#""search""#),
835            (ToolKind::Execute, r#""execute""#),
836            (ToolKind::Think, r#""think""#),
837            (ToolKind::Fetch, r#""fetch""#),
838            (ToolKind::Other, r#""other""#),
839        ] {
840            assert_eq!(json(&kind), wire);
841            assert_eq!(serde_json::from_str::<ToolKind>(wire).unwrap(), kind);
842        }
843        for (status, wire) in [
844            (ToolCallStatus::Pending, r#""pending""#),
845            (ToolCallStatus::InProgress, r#""in_progress""#),
846            (ToolCallStatus::Completed, r#""completed""#),
847            (ToolCallStatus::Failed, r#""failed""#),
848        ] {
849            assert_eq!(json(&status), wire);
850            assert_eq!(
851                serde_json::from_str::<ToolCallStatus>(wire).unwrap(),
852                status
853            );
854        }
855        for (kind, wire) in [
856            (PermissionOptionKind::AllowOnce, r#""allow_once""#),
857            (PermissionOptionKind::AllowAlways, r#""allow_always""#),
858            (PermissionOptionKind::RejectOnce, r#""reject_once""#),
859            (PermissionOptionKind::RejectAlways, r#""reject_always""#),
860        ] {
861            assert_eq!(json(&kind), wire);
862            assert_eq!(
863                serde_json::from_str::<PermissionOptionKind>(wire).unwrap(),
864                kind
865            );
866        }
867    }
868
869    #[test]
870    fn permission_outcomes_round_trip() {
871        let selected = RequestPermissionResult {
872            outcome: PermissionOutcome::Selected {
873                option_id: "allow-once".to_string(),
874            },
875        };
876        assert_eq!(
877            json(&selected),
878            r#"{"outcome":{"outcome":"selected","optionId":"allow-once"}}"#
879        );
880        assert_eq!(
881            serde_json::from_str::<RequestPermissionResult>(&json(&selected)).unwrap(),
882            selected
883        );
884
885        let cancelled = RequestPermissionResult {
886            outcome: PermissionOutcome::Cancelled,
887        };
888        assert_eq!(json(&cancelled), r#"{"outcome":{"outcome":"cancelled"}}"#);
889        assert_eq!(
890            serde_json::from_str::<RequestPermissionResult>(&json(&cancelled)).unwrap(),
891            cancelled
892        );
893    }
894
895    #[test]
896    fn agent_capability_defaults_are_all_false() {
897        assert_eq!(
898            json(&AgentCapabilities::default()),
899            r#"{"loadSession":false,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}}"#
900        );
901    }
902}