Skip to main content

claude_codes/io/
control.rs

1use serde::{Deserialize, Deserializer, Serialize, Serializer};
2use serde_json::Value;
3use std::collections::HashMap;
4use std::fmt;
5
6use crate::tool_inputs::AskUserQuestionInput;
7
8// ============================================================================
9// Permission Enums
10// ============================================================================
11
12/// The type of a permission grant.
13///
14/// Determines whether the permission adds rules for specific tools
15/// or sets a broad mode.
16#[derive(Debug, Clone, PartialEq, Eq, Hash)]
17pub enum PermissionType {
18    /// Add fine-grained rules for specific tools.
19    AddRules,
20    /// Set a broad permission mode (e.g., accept all edits).
21    SetMode,
22    /// A type not yet known to this version of the crate.
23    Unknown(String),
24}
25
26impl PermissionType {
27    pub fn as_str(&self) -> &str {
28        match self {
29            Self::AddRules => "addRules",
30            Self::SetMode => "setMode",
31            Self::Unknown(s) => s.as_str(),
32        }
33    }
34}
35
36impl fmt::Display for PermissionType {
37    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
38        f.write_str(self.as_str())
39    }
40}
41
42impl From<&str> for PermissionType {
43    fn from(s: &str) -> Self {
44        match s {
45            "addRules" => Self::AddRules,
46            "setMode" => Self::SetMode,
47            other => Self::Unknown(other.to_string()),
48        }
49    }
50}
51
52impl Serialize for PermissionType {
53    fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
54        serializer.serialize_str(self.as_str())
55    }
56}
57
58impl<'de> Deserialize<'de> for PermissionType {
59    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
60        let s = String::deserialize(deserializer)?;
61        Ok(Self::from(s.as_str()))
62    }
63}
64
65/// Where a permission applies.
66#[derive(Debug, Clone, PartialEq, Eq, Hash)]
67pub enum PermissionDestination {
68    /// Applies only to the current session.
69    Session,
70    /// Persists across sessions for the project.
71    Project,
72    /// A destination not yet known to this version of the crate.
73    Unknown(String),
74}
75
76impl PermissionDestination {
77    pub fn as_str(&self) -> &str {
78        match self {
79            Self::Session => "session",
80            Self::Project => "project",
81            Self::Unknown(s) => s.as_str(),
82        }
83    }
84}
85
86impl fmt::Display for PermissionDestination {
87    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
88        f.write_str(self.as_str())
89    }
90}
91
92impl From<&str> for PermissionDestination {
93    fn from(s: &str) -> Self {
94        match s {
95            "session" => Self::Session,
96            "project" => Self::Project,
97            other => Self::Unknown(other.to_string()),
98        }
99    }
100}
101
102impl Serialize for PermissionDestination {
103    fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
104        serializer.serialize_str(self.as_str())
105    }
106}
107
108impl<'de> Deserialize<'de> for PermissionDestination {
109    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
110        let s = String::deserialize(deserializer)?;
111        Ok(Self::from(s.as_str()))
112    }
113}
114
115/// The behavior of a permission rule.
116#[derive(Debug, Clone, PartialEq, Eq, Hash)]
117pub enum PermissionBehavior {
118    /// Allow the tool action.
119    Allow,
120    /// Deny the tool action.
121    Deny,
122    /// A behavior not yet known to this version of the crate.
123    Unknown(String),
124}
125
126impl PermissionBehavior {
127    pub fn as_str(&self) -> &str {
128        match self {
129            Self::Allow => "allow",
130            Self::Deny => "deny",
131            Self::Unknown(s) => s.as_str(),
132        }
133    }
134}
135
136impl fmt::Display for PermissionBehavior {
137    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
138        f.write_str(self.as_str())
139    }
140}
141
142impl From<&str> for PermissionBehavior {
143    fn from(s: &str) -> Self {
144        match s {
145            "allow" => Self::Allow,
146            "deny" => Self::Deny,
147            other => Self::Unknown(other.to_string()),
148        }
149    }
150}
151
152impl Serialize for PermissionBehavior {
153    fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
154        serializer.serialize_str(self.as_str())
155    }
156}
157
158impl<'de> Deserialize<'de> for PermissionBehavior {
159    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
160        let s = String::deserialize(deserializer)?;
161        Ok(Self::from(s.as_str()))
162    }
163}
164
165/// Named permission modes that can be set via `setMode`.
166#[derive(Debug, Clone, PartialEq, Eq, Hash)]
167pub enum PermissionModeName {
168    /// Accept all file edits without prompting.
169    AcceptEdits,
170    /// Bypass all permission checks.
171    BypassPermissions,
172    /// A mode not yet known to this version of the crate.
173    Unknown(String),
174}
175
176impl PermissionModeName {
177    pub fn as_str(&self) -> &str {
178        match self {
179            Self::AcceptEdits => "acceptEdits",
180            Self::BypassPermissions => "bypassPermissions",
181            Self::Unknown(s) => s.as_str(),
182        }
183    }
184}
185
186impl fmt::Display for PermissionModeName {
187    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
188        f.write_str(self.as_str())
189    }
190}
191
192impl From<&str> for PermissionModeName {
193    fn from(s: &str) -> Self {
194        match s {
195            "acceptEdits" => Self::AcceptEdits,
196            "bypassPermissions" => Self::BypassPermissions,
197            other => Self::Unknown(other.to_string()),
198        }
199    }
200}
201
202impl Serialize for PermissionModeName {
203    fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
204        serializer.serialize_str(self.as_str())
205    }
206}
207
208impl<'de> Deserialize<'de> for PermissionModeName {
209    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
210        let s = String::deserialize(deserializer)?;
211        Ok(Self::from(s.as_str()))
212    }
213}
214
215// ============================================================================
216// Control Protocol Types (for bidirectional tool approval)
217// ============================================================================
218
219/// Control request from CLI (tool permission requests, hooks, etc.)
220///
221/// When using `--permission-prompt-tool stdio`, the CLI sends these requests
222/// asking for approval before executing tools. The SDK must respond with a
223/// [`ControlResponse`].
224#[derive(Debug, Clone, Serialize, Deserialize)]
225pub struct ControlRequest {
226    /// Unique identifier for this request (used to correlate responses)
227    pub request_id: String,
228    /// The request payload
229    pub request: ControlRequestPayload,
230}
231
232/// Withdrawal of an in-flight [`ControlRequest`] (`control_cancel_request`).
233///
234/// Either side may send this for a request it originated. The CLI sends it
235/// when it no longer needs the answer, for example a pending `can_use_tool`
236/// prompt after the turn was interrupted, or one another client already
237/// answered. The receiver should drop any UI or pending state for
238/// `request_id`; a [`ControlResponse`] sent afterwards is ignored. There is
239/// no reply to the cancel itself.
240#[derive(Debug, Clone, Serialize, Deserialize)]
241pub struct ControlCancelRequest {
242    /// The `request_id` of the [`ControlRequest`] being withdrawn.
243    pub request_id: String,
244    /// Session id, stamped only by the remote bridge.
245    #[serde(default, skip_serializing_if = "Option::is_none")]
246    pub session_id: Option<String>,
247}
248
249/// Control request payload variants
250#[derive(Debug, Clone, Serialize, Deserialize)]
251#[serde(tag = "subtype", rename_all = "snake_case")]
252pub enum ControlRequestPayload {
253    /// Tool permission request - Claude wants to use a tool
254    CanUseTool(ToolPermissionRequest),
255    /// Hook callback request
256    HookCallback(HookCallbackRequest),
257    /// MCP message request
258    McpMessage(McpMessageRequest),
259    /// Initialize request (sent by SDK to CLI)
260    Initialize(InitializeRequest),
261    /// Interrupt request (sent by SDK to CLI to stop the in-flight turn)
262    Interrupt,
263    /// Merge settings into the live session's flag-settings layer (sent by
264    /// SDK to CLI), e.g. to change effort mid-session.
265    ApplyFlagSettings(ApplyFlagSettingsRequest),
266}
267
268/// Body of an `apply_flag_settings` control request.
269///
270/// The CLI merges `settings` into its flag-settings layer (the layer
271/// `--settings` writes) and updates the active configuration; a key set to
272/// `null` is removed from that layer. The CLI acknowledges with a
273/// `control_response` for the same `request_id`, or an error response if a
274/// value is rejected.
275///
276/// `settings` is an open map, so keys this crate has no setter for (and keys
277/// added by newer CLIs) can still be sent with [`set`](Self::set) and survive
278/// a round trip. Typed setters cover the per-session effort controls:
279///
280/// ```
281/// use claude_codes::{ApplyFlagSettingsRequest, EffortLevel};
282///
283/// let req = ApplyFlagSettingsRequest::new()
284///     .effort_level(EffortLevel::Xhigh)
285///     .ultracode(false);
286/// assert_eq!(req.get_effort_level(), Some(EffortLevel::Xhigh));
287/// assert_eq!(
288///     serde_json::to_value(&req).unwrap(),
289///     serde_json::json!({"settings": {"effortLevel": "xhigh", "ultracode": false}})
290/// );
291/// ```
292///
293/// A remote-control bridge applies only `effortLevel` and `ultracode` and
294/// errors when neither is present; a local CLI accepts other settings keys
295/// too.
296#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
297pub struct ApplyFlagSettingsRequest {
298    /// Settings keys (camelCase, as in `settings.json`) to merge.
299    #[serde(default)]
300    pub settings: serde_json::Map<String, Value>,
301}
302
303impl ApplyFlagSettingsRequest {
304    /// An empty request; add keys with the setters.
305    pub fn new() -> Self {
306        Self::default()
307    }
308
309    /// Set `effortLevel`, the session's effort level.
310    pub fn effort_level(self, level: crate::EffortLevel) -> Self {
311        self.set("effortLevel", Value::String(level.as_str().to_string()))
312    }
313
314    /// Clear `effortLevel` from the flag layer (sends `null`), so the
315    /// effort falls back to the lower settings layers / model default.
316    pub fn clear_effort_level(self) -> Self {
317        self.set("effortLevel", Value::Null)
318    }
319
320    /// Set `ultracode`: standing dynamic-workflow orchestration for the
321    /// session, independent of the effort level.
322    pub fn ultracode(self, on: bool) -> Self {
323        self.set("ultracode", Value::Bool(on))
324    }
325
326    /// Set any settings key to a raw JSON value (`Value::Null` clears it).
327    pub fn set(mut self, key: impl Into<String>, value: Value) -> Self {
328        self.settings.insert(key.into(), value);
329        self
330    }
331
332    /// The `effortLevel` being set, if any (`None` when absent or cleared).
333    pub fn get_effort_level(&self) -> Option<crate::EffortLevel> {
334        self.settings
335            .get("effortLevel")?
336            .as_str()
337            .map(crate::EffortLevel::from)
338    }
339
340    /// The `ultracode` value being set, if any.
341    pub fn get_ultracode(&self) -> Option<bool> {
342        self.settings.get("ultracode")?.as_bool()
343    }
344}
345
346/// A permission to grant for "remember this decision" functionality.
347///
348/// When responding to a tool permission request, you can include permissions
349/// that should be granted to avoid repeated prompts for similar actions.
350///
351/// # Example
352///
353/// ```
354/// use claude_codes::{Permission, PermissionModeName, PermissionDestination};
355///
356/// // Grant permission for a specific bash command
357/// let perm = Permission::allow_tool("Bash", "npm test");
358///
359/// // Grant permission to set a mode for the session
360/// let mode_perm = Permission::set_mode(PermissionModeName::AcceptEdits, PermissionDestination::Session);
361/// ```
362#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
363pub struct Permission {
364    /// The type of permission (e.g., addRules, setMode)
365    #[serde(rename = "type")]
366    pub permission_type: PermissionType,
367    /// Where to apply this permission (e.g., session, project)
368    pub destination: PermissionDestination,
369    /// The permission mode (for setMode type)
370    #[serde(skip_serializing_if = "Option::is_none")]
371    pub mode: Option<PermissionModeName>,
372    /// The behavior (for addRules type, e.g., allow, deny)
373    #[serde(skip_serializing_if = "Option::is_none")]
374    pub behavior: Option<PermissionBehavior>,
375    /// The rules to add (for addRules type)
376    #[serde(skip_serializing_if = "Option::is_none")]
377    pub rules: Option<Vec<PermissionRule>>,
378}
379
380/// A rule within a permission grant.
381#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
382pub struct PermissionRule {
383    /// The name of the tool this rule applies to
384    #[serde(rename = "toolName")]
385    pub tool_name: String,
386    /// The rule content (glob pattern or command pattern)
387    #[serde(rename = "ruleContent")]
388    pub rule_content: String,
389}
390
391impl Permission {
392    /// Create a permission to allow a specific tool with a rule pattern.
393    ///
394    /// # Example
395    /// ```
396    /// use claude_codes::Permission;
397    ///
398    /// // Allow "npm test" bash command for this session
399    /// let perm = Permission::allow_tool("Bash", "npm test");
400    ///
401    /// // Allow reading from /tmp directory
402    /// let read_perm = Permission::allow_tool("Read", "/tmp/**");
403    /// ```
404    pub fn allow_tool(tool_name: impl Into<String>, rule_content: impl Into<String>) -> Self {
405        Permission {
406            permission_type: PermissionType::AddRules,
407            destination: PermissionDestination::Session,
408            mode: None,
409            behavior: Some(PermissionBehavior::Allow),
410            rules: Some(vec![PermissionRule {
411                tool_name: tool_name.into(),
412                rule_content: rule_content.into(),
413            }]),
414        }
415    }
416
417    /// Create a permission to allow a tool with a specific destination.
418    ///
419    /// # Example
420    /// ```
421    /// use claude_codes::{Permission, PermissionDestination};
422    ///
423    /// // Allow for the entire project, not just session
424    /// let perm = Permission::allow_tool_with_destination("Bash", "npm test", PermissionDestination::Project);
425    /// ```
426    pub fn allow_tool_with_destination(
427        tool_name: impl Into<String>,
428        rule_content: impl Into<String>,
429        destination: PermissionDestination,
430    ) -> Self {
431        Permission {
432            permission_type: PermissionType::AddRules,
433            destination,
434            mode: None,
435            behavior: Some(PermissionBehavior::Allow),
436            rules: Some(vec![PermissionRule {
437                tool_name: tool_name.into(),
438                rule_content: rule_content.into(),
439            }]),
440        }
441    }
442
443    /// Create a permission to set a mode (like acceptEdits or bypassPermissions).
444    ///
445    /// # Example
446    /// ```
447    /// use claude_codes::{Permission, PermissionModeName, PermissionDestination};
448    ///
449    /// // Accept all edits for this session
450    /// let perm = Permission::set_mode(PermissionModeName::AcceptEdits, PermissionDestination::Session);
451    /// ```
452    pub fn set_mode(mode: PermissionModeName, destination: PermissionDestination) -> Self {
453        Permission {
454            permission_type: PermissionType::SetMode,
455            destination,
456            mode: Some(mode),
457            behavior: None,
458            rules: None,
459        }
460    }
461
462    /// Create a permission from a PermissionSuggestion.
463    ///
464    /// This is useful when you want to grant a permission that Claude suggested.
465    ///
466    /// # Example
467    /// ```
468    /// use claude_codes::{Permission, PermissionSuggestion, PermissionType, PermissionDestination, PermissionModeName};
469    ///
470    /// // Convert a suggestion to a permission for the response
471    /// let suggestion = PermissionSuggestion {
472    ///     suggestion_type: PermissionType::SetMode,
473    ///     destination: PermissionDestination::Session,
474    ///     mode: Some(PermissionModeName::AcceptEdits),
475    ///     behavior: None,
476    ///     rules: None,
477    /// };
478    /// let perm = Permission::from_suggestion(&suggestion);
479    /// ```
480    pub fn from_suggestion(suggestion: &PermissionSuggestion) -> Self {
481        Permission {
482            permission_type: suggestion.suggestion_type.clone(),
483            destination: suggestion.destination.clone(),
484            mode: suggestion.mode.clone(),
485            behavior: suggestion.behavior.clone(),
486            rules: suggestion.rules.as_ref().map(|rules| {
487                rules
488                    .iter()
489                    .filter_map(|v| {
490                        Some(PermissionRule {
491                            tool_name: v.get("toolName")?.as_str()?.to_string(),
492                            rule_content: v.get("ruleContent")?.as_str()?.to_string(),
493                        })
494                    })
495                    .collect()
496            }),
497        }
498    }
499}
500
501/// A suggested permission for tool approval.
502///
503/// When Claude requests tool permission, it may include suggestions for
504/// permissions that could be granted to avoid repeated prompts for similar
505/// actions. The format varies based on the suggestion type:
506///
507/// - `setMode`: `{"type": "setMode", "mode": "acceptEdits", "destination": "session"}`
508/// - `addRules`: `{"type": "addRules", "rules": [...], "behavior": "allow", "destination": "session"}`
509///
510/// Use the helper methods to access common fields.
511#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
512pub struct PermissionSuggestion {
513    /// The type of suggestion (e.g., setMode, addRules)
514    #[serde(rename = "type")]
515    pub suggestion_type: PermissionType,
516    /// Where to apply this permission (e.g., session, project)
517    pub destination: PermissionDestination,
518    /// The permission mode (for setMode type)
519    #[serde(skip_serializing_if = "Option::is_none")]
520    pub mode: Option<PermissionModeName>,
521    /// The behavior (for addRules type, e.g., allow)
522    #[serde(skip_serializing_if = "Option::is_none")]
523    pub behavior: Option<PermissionBehavior>,
524    /// The rules to add (for addRules type)
525    #[serde(skip_serializing_if = "Option::is_none")]
526    pub rules: Option<Vec<Value>>,
527}
528
529/// Tool permission request details
530///
531/// This is sent when Claude wants to use a tool. The SDK should evaluate
532/// the request and respond with allow/deny using the ergonomic builder methods.
533///
534/// # Example
535///
536/// ```
537/// use claude_codes::{ToolPermissionRequest, ControlResponse};
538/// use serde_json::json;
539///
540/// fn handle_permission(req: &ToolPermissionRequest, request_id: &str) -> ControlResponse {
541///     // Block dangerous bash commands
542///     if req.tool_name == "Bash" {
543///         if let Some(cmd) = req.input.get("command").and_then(|v| v.as_str()) {
544///             if cmd.contains("rm -rf") {
545///                 return req.deny("Dangerous command blocked", request_id);
546///             }
547///         }
548///     }
549///
550///     // Allow everything else
551///     req.allow(request_id)
552/// }
553/// ```
554#[derive(Debug, Clone, Serialize, Deserialize)]
555pub struct ToolPermissionRequest {
556    /// Name of the tool Claude wants to use (e.g., "Bash", "Write", "Read")
557    pub tool_name: String,
558    /// Input parameters for the tool
559    pub input: Value,
560    /// Suggested permissions that could be granted to avoid repeated prompts
561    #[serde(default)]
562    pub permission_suggestions: Vec<PermissionSuggestion>,
563    /// Path that was blocked (if this is a retry after path-based denial)
564    #[serde(skip_serializing_if = "Option::is_none")]
565    pub blocked_path: Option<String>,
566    /// Reason why this tool use requires approval
567    #[serde(skip_serializing_if = "Option::is_none")]
568    pub decision_reason: Option<String>,
569    /// The tool use ID for this request
570    #[serde(skip_serializing_if = "Option::is_none")]
571    pub tool_use_id: Option<String>,
572}
573
574impl ToolPermissionRequest {
575    /// Allow the tool to execute with its original input.
576    ///
577    /// # Example
578    /// ```
579    /// # use claude_codes::ToolPermissionRequest;
580    /// # use serde_json::json;
581    /// let req = ToolPermissionRequest {
582    ///     tool_name: "Read".to_string(),
583    ///     input: json!({"file_path": "/tmp/test.txt"}),
584    ///     permission_suggestions: vec![],
585    ///     blocked_path: None,
586    ///     decision_reason: None,
587    ///     tool_use_id: None,
588    /// };
589    /// let response = req.allow("req-123");
590    /// ```
591    pub fn allow(&self, request_id: &str) -> ControlResponse {
592        ControlResponse::from_result(request_id, PermissionResult::allow(self.input.clone()))
593    }
594
595    /// Allow the tool to execute with modified input.
596    ///
597    /// Use this to sanitize or redirect tool inputs. For example, redirecting
598    /// file writes to a safe directory.
599    ///
600    /// # Example
601    /// ```
602    /// # use claude_codes::ToolPermissionRequest;
603    /// # use serde_json::json;
604    /// let req = ToolPermissionRequest {
605    ///     tool_name: "Write".to_string(),
606    ///     input: json!({"file_path": "/etc/passwd", "content": "test"}),
607    ///     permission_suggestions: vec![],
608    ///     blocked_path: None,
609    ///     decision_reason: None,
610    ///     tool_use_id: None,
611    /// };
612    /// // Redirect to safe location
613    /// let safe_input = json!({"file_path": "/tmp/safe/passwd", "content": "test"});
614    /// let response = req.allow_with(safe_input, "req-123");
615    /// ```
616    pub fn allow_with(&self, modified_input: Value, request_id: &str) -> ControlResponse {
617        ControlResponse::from_result(request_id, PermissionResult::allow(modified_input))
618    }
619
620    /// Allow with updated permissions list (raw JSON Values).
621    ///
622    /// Prefer using `allow_and_remember` for type safety.
623    pub fn allow_with_permissions(
624        &self,
625        modified_input: Value,
626        permissions: Vec<Value>,
627        request_id: &str,
628    ) -> ControlResponse {
629        ControlResponse::from_result(
630            request_id,
631            PermissionResult::allow_with_permissions(modified_input, permissions),
632        )
633    }
634
635    /// Allow the tool and grant permissions for "remember this decision".
636    ///
637    /// This is the ergonomic way to allow a tool while also granting permissions
638    /// so similar actions won't require approval in the future.
639    ///
640    /// # Example
641    /// ```
642    /// use claude_codes::{ToolPermissionRequest, Permission};
643    /// use serde_json::json;
644    ///
645    /// let req = ToolPermissionRequest {
646    ///     tool_name: "Bash".to_string(),
647    ///     input: json!({"command": "npm test"}),
648    ///     permission_suggestions: vec![],
649    ///     blocked_path: None,
650    ///     decision_reason: None,
651    ///     tool_use_id: None,
652    /// };
653    ///
654    /// // Allow and remember this decision for the session
655    /// let response = req.allow_and_remember(
656    ///     vec![Permission::allow_tool("Bash", "npm test")],
657    ///     "req-123",
658    /// );
659    /// ```
660    pub fn allow_and_remember(
661        &self,
662        permissions: Vec<Permission>,
663        request_id: &str,
664    ) -> ControlResponse {
665        ControlResponse::from_result(
666            request_id,
667            PermissionResult::allow_with_typed_permissions(self.input.clone(), permissions),
668        )
669    }
670
671    /// Allow the tool with modified input and grant permissions.
672    ///
673    /// Combines input modification with "remember this decision" functionality.
674    pub fn allow_with_and_remember(
675        &self,
676        modified_input: Value,
677        permissions: Vec<Permission>,
678        request_id: &str,
679    ) -> ControlResponse {
680        ControlResponse::from_result(
681            request_id,
682            PermissionResult::allow_with_typed_permissions(modified_input, permissions),
683        )
684    }
685
686    /// Allow the tool and remember using the first permission suggestion.
687    ///
688    /// This is a convenience method for the common case of accepting Claude's
689    /// first suggested permission (usually the most relevant one).
690    ///
691    /// Returns `None` if there are no permission suggestions.
692    ///
693    /// # Example
694    /// ```
695    /// use claude_codes::ToolPermissionRequest;
696    /// use serde_json::json;
697    ///
698    /// let req = ToolPermissionRequest {
699    ///     tool_name: "Bash".to_string(),
700    ///     input: json!({"command": "npm test"}),
701    ///     permission_suggestions: vec![],  // Would have suggestions in real use
702    ///     blocked_path: None,
703    ///     decision_reason: None,
704    ///     tool_use_id: None,
705    /// };
706    ///
707    /// // Try to allow with first suggestion, or just allow without remembering
708    /// let response = req.allow_and_remember_suggestion("req-123")
709    ///     .unwrap_or_else(|| req.allow("req-123"));
710    /// ```
711    pub fn allow_and_remember_suggestion(&self, request_id: &str) -> Option<ControlResponse> {
712        self.permission_suggestions.first().map(|suggestion| {
713            let perm = Permission::from_suggestion(suggestion);
714            self.allow_and_remember(vec![perm], request_id)
715        })
716    }
717
718    /// Deny the tool execution.
719    ///
720    /// The message will be shown to Claude, who may try a different approach.
721    ///
722    /// # Example
723    /// ```
724    /// # use claude_codes::ToolPermissionRequest;
725    /// # use serde_json::json;
726    /// let req = ToolPermissionRequest {
727    ///     tool_name: "Bash".to_string(),
728    ///     input: json!({"command": "sudo rm -rf /"}),
729    ///     permission_suggestions: vec![],
730    ///     blocked_path: None,
731    ///     decision_reason: None,
732    ///     tool_use_id: None,
733    /// };
734    /// let response = req.deny("Dangerous command blocked by policy", "req-123");
735    /// ```
736    pub fn deny(&self, message: impl Into<String>, request_id: &str) -> ControlResponse {
737        ControlResponse::from_result(request_id, PermissionResult::deny(message))
738    }
739
740    /// Deny the tool execution and stop the entire session.
741    ///
742    /// Use this for severe policy violations that should halt all processing.
743    pub fn deny_and_stop(&self, message: impl Into<String>, request_id: &str) -> ControlResponse {
744        ControlResponse::from_result(request_id, PermissionResult::deny_and_interrupt(message))
745    }
746
747    /// Build an `allow` response for an `AskUserQuestion` permission request
748    /// by supplying the user's chosen answers.
749    ///
750    /// The CLI's `AskUserQuestion` tool has a finicky wire contract that
751    /// is easy to get wrong by hand:
752    ///
753    /// - The response's `input` must preserve the original `questions`
754    ///   array. Stripping it (sending just `{"answers": ...}`) makes the
755    ///   downstream viewer crash with
756    ///   `undefined is not an object (evaluating 'q.map')` (the CLI echoes
757    ///   the `updatedInput` we return into `tool_use_result`, and viewers
758    ///   read `tool_use_result.questions`).
759    /// - The `answers` map must be keyed by the full **question text**
760    ///   (the `question` field). Keying by `header` or by question index
761    ///   makes the CLI's tool render `"Your questions have been answered: ."`
762    ///   with an empty answer body, leaving Claude unable to see which
763    ///   option the user picked.
764    ///
765    /// This helper does both correctly: it parses `self.input` as
766    /// [`AskUserQuestionInput`](crate::AskUserQuestionInput), looks each
767    /// answer up by question index, and inserts the answer keyed by
768    /// `q.question`. The original `questions` (and any `metadata`) pass
769    /// through unchanged.
770    ///
771    /// `answers_by_index` maps 0-based question index into the original
772    /// `questions` array to the user's chosen answer label. For
773    /// multi-select questions, join the selected labels with `", "`.
774    /// Questions not in the map are treated as unanswered.
775    ///
776    /// Non-`AskUserQuestion` callers should keep using
777    /// [`allow`](Self::allow) / [`allow_with`](Self::allow_with).
778    ///
779    /// # Errors
780    ///
781    /// Returns [`AskUserQuestionResponseError`] if the request isn't an
782    /// `AskUserQuestion` request, the input doesn't parse, or an answer
783    /// references a question index that doesn't exist.
784    ///
785    /// # Example
786    ///
787    /// ```no_run
788    /// use claude_codes::ToolPermissionRequest;
789    /// use std::collections::HashMap;
790    ///
791    /// fn answer(req: &ToolPermissionRequest, request_id: &str) {
792    ///     let mut answers = HashMap::new();
793    ///     answers.insert(0_usize, "Blue".to_string());
794    ///     let response = req
795    ///         .answer_questions(&answers, request_id)
796    ///         .expect("request is AskUserQuestion-shaped");
797    ///     // send `response` back to the CLI
798    /// }
799    /// ```
800    pub fn answer_questions(
801        &self,
802        answers_by_index: &HashMap<usize, String>,
803        request_id: &str,
804    ) -> Result<ControlResponse, AskUserQuestionResponseError> {
805        if self.tool_name != "AskUserQuestion" {
806            return Err(AskUserQuestionResponseError::WrongTool(
807                self.tool_name.clone(),
808            ));
809        }
810        let parsed: AskUserQuestionInput = serde_json::from_value(self.input.clone())
811            .map_err(AskUserQuestionResponseError::ParseInput)?;
812        let total = parsed.questions.len();
813
814        let mut answers_map = serde_json::Map::new();
815        for (idx, answer) in answers_by_index {
816            let q = parsed.questions.get(*idx).ok_or(
817                AskUserQuestionResponseError::QuestionIndexOutOfRange { index: *idx, total },
818            )?;
819            answers_map.insert(q.question.clone(), Value::String(answer.clone()));
820        }
821
822        let mut updated_input = self.input.clone();
823        // Input is an object — we just deserialized it as `AskUserQuestionInput`.
824        updated_input
825            .as_object_mut()
826            .expect("AskUserQuestion input is a JSON object")
827            .insert("answers".to_string(), Value::Object(answers_map));
828
829        Ok(ControlResponse::from_result(
830            request_id,
831            PermissionResult::allow(updated_input),
832        ))
833    }
834}
835
836/// Errors that can occur when building an `AskUserQuestion` response via
837/// [`ToolPermissionRequest::answer_questions`].
838#[derive(Debug, thiserror::Error)]
839pub enum AskUserQuestionResponseError {
840    /// The request was for a different tool, not `AskUserQuestion`.
841    #[error("expected tool_name=AskUserQuestion, got `{0}`")]
842    WrongTool(String),
843    /// The `input` field didn't deserialize into `AskUserQuestionInput`.
844    #[error("failed to parse AskUserQuestion input: {0}")]
845    ParseInput(#[source] serde_json::Error),
846    /// An answer entry referenced a question index outside the questions array.
847    #[error("answer references question index {index}, but only {total} question(s) were asked")]
848    QuestionIndexOutOfRange {
849        /// The out-of-range index the caller supplied.
850        index: usize,
851        /// The number of questions actually in the request.
852        total: usize,
853    },
854}
855
856/// Result of a permission decision
857///
858/// This type represents the decision made by the permission callback.
859/// It can be serialized directly into the control response format.
860#[derive(Debug, Clone, Serialize, Deserialize)]
861#[serde(tag = "behavior", rename_all = "snake_case")]
862pub enum PermissionResult {
863    /// Allow the tool to execute
864    Allow {
865        /// The (possibly modified) input to pass to the tool
866        #[serde(rename = "updatedInput")]
867        updated_input: Value,
868        /// Optional updated permissions list
869        #[serde(rename = "updatedPermissions", skip_serializing_if = "Option::is_none")]
870        updated_permissions: Option<Vec<Value>>,
871    },
872    /// Deny the tool execution
873    Deny {
874        /// Message explaining why the tool was denied
875        message: String,
876        /// If true, stop the entire session
877        #[serde(default, skip_serializing_if = "std::ops::Not::not")]
878        interrupt: bool,
879    },
880}
881
882impl PermissionResult {
883    /// Create an allow result with the given input
884    pub fn allow(input: Value) -> Self {
885        PermissionResult::Allow {
886            updated_input: input,
887            updated_permissions: None,
888        }
889    }
890
891    /// Create an allow result with raw permissions (as JSON Values).
892    ///
893    /// Prefer using `allow_with_typed_permissions` for type safety.
894    pub fn allow_with_permissions(input: Value, permissions: Vec<Value>) -> Self {
895        PermissionResult::Allow {
896            updated_input: input,
897            updated_permissions: Some(permissions),
898        }
899    }
900
901    /// Create an allow result with typed permissions.
902    ///
903    /// This is the preferred way to grant permissions for "remember this decision"
904    /// functionality.
905    ///
906    /// # Example
907    /// ```
908    /// use claude_codes::{Permission, PermissionResult};
909    /// use serde_json::json;
910    ///
911    /// let result = PermissionResult::allow_with_typed_permissions(
912    ///     json!({"command": "npm test"}),
913    ///     vec![Permission::allow_tool("Bash", "npm test")],
914    /// );
915    /// ```
916    pub fn allow_with_typed_permissions(input: Value, permissions: Vec<Permission>) -> Self {
917        let permission_values: Vec<Value> = permissions
918            .into_iter()
919            .filter_map(|p| serde_json::to_value(p).ok())
920            .collect();
921        PermissionResult::Allow {
922            updated_input: input,
923            updated_permissions: Some(permission_values),
924        }
925    }
926
927    /// Create a deny result
928    pub fn deny(message: impl Into<String>) -> Self {
929        PermissionResult::Deny {
930            message: message.into(),
931            interrupt: false,
932        }
933    }
934
935    /// Create a deny result that also interrupts the session
936    pub fn deny_and_interrupt(message: impl Into<String>) -> Self {
937        PermissionResult::Deny {
938            message: message.into(),
939            interrupt: true,
940        }
941    }
942}
943
944/// Hook callback request
945#[derive(Debug, Clone, Serialize, Deserialize)]
946pub struct HookCallbackRequest {
947    pub callback_id: String,
948    pub input: Value,
949    #[serde(skip_serializing_if = "Option::is_none")]
950    pub tool_use_id: Option<String>,
951}
952
953/// MCP message request
954#[derive(Debug, Clone, Serialize, Deserialize)]
955pub struct McpMessageRequest {
956    pub server_name: String,
957    pub message: Value,
958}
959
960/// Initialize request (SDK -> CLI)
961#[derive(Debug, Clone, Serialize, Deserialize)]
962pub struct InitializeRequest {
963    #[serde(skip_serializing_if = "Option::is_none")]
964    pub hooks: Option<Value>,
965}
966
967/// Control response to CLI
968///
969/// Built using the ergonomic methods on [`ToolPermissionRequest`] or
970/// constructed directly for other control request types.
971#[derive(Debug, Clone, Serialize, Deserialize)]
972pub struct ControlResponse {
973    /// The request ID this response corresponds to
974    pub response: ControlResponsePayload,
975}
976
977impl ControlResponse {
978    /// Create a success response from a PermissionResult
979    ///
980    /// This is the preferred way to construct permission responses.
981    pub fn from_result(request_id: &str, result: PermissionResult) -> Self {
982        // Serialize the PermissionResult to Value for the response
983        let response_value = serde_json::to_value(&result)
984            .expect("PermissionResult serialization should never fail");
985        ControlResponse {
986            response: ControlResponsePayload::Success {
987                request_id: request_id.to_string(),
988                response: Some(response_value),
989            },
990        }
991    }
992
993    /// Create a success response with the given payload (raw Value)
994    pub fn success(request_id: &str, response_data: Value) -> Self {
995        ControlResponse {
996            response: ControlResponsePayload::Success {
997                request_id: request_id.to_string(),
998                response: Some(response_data),
999            },
1000        }
1001    }
1002
1003    /// Create an empty success response (for acks)
1004    pub fn success_empty(request_id: &str) -> Self {
1005        ControlResponse {
1006            response: ControlResponsePayload::Success {
1007                request_id: request_id.to_string(),
1008                response: None,
1009            },
1010        }
1011    }
1012
1013    /// Create an error response
1014    pub fn error(request_id: &str, error_message: impl Into<String>) -> Self {
1015        ControlResponse {
1016            response: ControlResponsePayload::Error {
1017                request_id: request_id.to_string(),
1018                error: error_message.into(),
1019            },
1020        }
1021    }
1022
1023    /// Parse a successful response payload as the CLI `get_usage` response.
1024    pub fn get_usage_response(&self) -> Option<Result<GetUsageResponse, serde_json::Error>> {
1025        match &self.response {
1026            ControlResponsePayload::Success {
1027                response: Some(value),
1028                ..
1029            } => Some(serde_json::from_value(value.clone())),
1030            _ => None,
1031        }
1032    }
1033}
1034
1035/// Control response payload
1036#[derive(Debug, Clone, Serialize, Deserialize)]
1037#[serde(tag = "subtype", rename_all = "snake_case")]
1038pub enum ControlResponsePayload {
1039    Success {
1040        request_id: String,
1041        #[serde(skip_serializing_if = "Option::is_none")]
1042        response: Option<Value>,
1043    },
1044    Error {
1045        request_id: String,
1046        error: String,
1047    },
1048}
1049
1050/// Typed payload returned by the CLI `get_usage` control request.
1051#[derive(Debug, Clone, Serialize, Deserialize)]
1052pub struct GetUsageResponse {
1053    pub session: UsageSession,
1054    pub subscription_type: Option<String>,
1055    pub rate_limits_available: bool,
1056    pub rate_limits: Option<UsageRateLimits>,
1057    pub behaviors: UsageBehaviors,
1058    #[serde(flatten)]
1059    pub extra: serde_json::Map<String, Value>,
1060}
1061
1062#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1063pub struct UsageSession {
1064    pub total_cost_usd: f64,
1065    pub total_api_duration_ms: u64,
1066    pub total_duration_ms: u64,
1067    pub total_lines_added: u64,
1068    pub total_lines_removed: u64,
1069    pub model_usage: HashMap<String, UsageModelUsage>,
1070    #[serde(flatten)]
1071    pub extra: serde_json::Map<String, Value>,
1072}
1073
1074#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1075#[serde(rename_all = "camelCase")]
1076pub struct UsageModelUsage {
1077    #[serde(default)]
1078    pub input_tokens: u64,
1079    #[serde(default)]
1080    pub output_tokens: u64,
1081    #[serde(default)]
1082    pub cache_read_input_tokens: u64,
1083    #[serde(default)]
1084    pub cache_creation_input_tokens: u64,
1085    #[serde(default)]
1086    pub web_search_requests: u64,
1087    #[serde(default, rename = "costUSD")]
1088    pub cost_usd: f64,
1089    #[serde(default)]
1090    pub context_window: u64,
1091    #[serde(default)]
1092    pub max_output_tokens: u64,
1093    #[serde(flatten)]
1094    pub extra: serde_json::Map<String, Value>,
1095}
1096
1097#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1098pub struct UsageRateLimits {
1099    #[serde(default, skip_serializing_if = "Option::is_none")]
1100    pub five_hour: Option<UsageRateLimitWindow>,
1101    #[serde(default, skip_serializing_if = "Option::is_none")]
1102    pub seven_day: Option<UsageRateLimitWindow>,
1103    #[serde(default, skip_serializing_if = "Option::is_none")]
1104    pub seven_day_oauth_apps: Option<UsageRateLimitWindow>,
1105    #[serde(default, skip_serializing_if = "Option::is_none")]
1106    pub seven_day_opus: Option<UsageRateLimitWindow>,
1107    #[serde(default, skip_serializing_if = "Option::is_none")]
1108    pub seven_day_sonnet: Option<UsageRateLimitWindow>,
1109    #[serde(default, skip_serializing_if = "Option::is_none")]
1110    pub model_scoped: Option<Vec<ModelScopedRateLimit>>,
1111    #[serde(flatten)]
1112    pub extra: serde_json::Map<String, Value>,
1113}
1114
1115#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1116pub struct UsageRateLimitWindow {
1117    pub utilization: Option<f64>,
1118    pub resets_at: Option<String>,
1119    #[serde(flatten)]
1120    pub extra: serde_json::Map<String, Value>,
1121}
1122
1123#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1124pub struct ModelScopedRateLimit {
1125    pub display_name: String,
1126    pub utilization: Option<f64>,
1127    pub resets_at: Option<String>,
1128    #[serde(flatten)]
1129    pub extra: serde_json::Map<String, Value>,
1130}
1131
1132#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1133pub struct UsageBehaviors {
1134    pub request_count: u64,
1135    pub session_count: u64,
1136    pub behaviors: Vec<UsageBehavior>,
1137    pub agents: Value,
1138    pub skills: Value,
1139    pub plugins: Value,
1140    pub mcp_servers: Value,
1141    #[serde(flatten)]
1142    pub extra: serde_json::Map<String, Value>,
1143}
1144
1145#[derive(Debug, Clone, Serialize, Deserialize)]
1146pub struct UsageBehavior {
1147    pub key: String,
1148    pub pct: f64,
1149    pub count: u64,
1150    #[serde(flatten)]
1151    pub extra: serde_json::Map<String, Value>,
1152}
1153
1154/// Wrapper for outgoing control responses (includes type tag)
1155#[derive(Debug, Clone, Serialize, Deserialize)]
1156pub struct ControlResponseMessage {
1157    #[serde(rename = "type")]
1158    pub message_type: String,
1159    pub response: ControlResponsePayload,
1160}
1161
1162impl From<ControlResponse> for ControlResponseMessage {
1163    fn from(resp: ControlResponse) -> Self {
1164        ControlResponseMessage {
1165            message_type: "control_response".to_string(),
1166            response: resp.response,
1167        }
1168    }
1169}
1170
1171/// Wrapper for outgoing control requests (includes type tag)
1172#[derive(Debug, Clone, Serialize, Deserialize)]
1173pub struct ControlRequestMessage {
1174    #[serde(rename = "type")]
1175    pub message_type: String,
1176    pub request_id: String,
1177    pub request: ControlRequestPayload,
1178}
1179
1180impl ControlRequestMessage {
1181    /// Create an initialization request to send to CLI
1182    pub fn initialize(request_id: impl Into<String>) -> Self {
1183        ControlRequestMessage {
1184            message_type: "control_request".to_string(),
1185            request_id: request_id.into(),
1186            request: ControlRequestPayload::Initialize(InitializeRequest { hooks: None }),
1187        }
1188    }
1189
1190    /// Create an initialization request with hooks configuration
1191    pub fn initialize_with_hooks(request_id: impl Into<String>, hooks: Value) -> Self {
1192        ControlRequestMessage {
1193            message_type: "control_request".to_string(),
1194            request_id: request_id.into(),
1195            request: ControlRequestPayload::Initialize(InitializeRequest { hooks: Some(hooks) }),
1196        }
1197    }
1198
1199    /// Create an interrupt request to stop the in-flight turn.
1200    ///
1201    /// Serializes to the `control_request` envelope the CLI requires:
1202    /// `{"type":"control_request","request_id":...,"request":{"subtype":"interrupt"}}`.
1203    /// The CLI acknowledges with a `control_response` carrying the same
1204    /// `request_id` and ends the turn with a `result` message.
1205    pub fn interrupt(request_id: impl Into<String>) -> Self {
1206        ControlRequestMessage {
1207            message_type: "control_request".to_string(),
1208            request_id: request_id.into(),
1209            request: ControlRequestPayload::Interrupt,
1210        }
1211    }
1212}
1213
1214#[cfg(test)]
1215mod tests {
1216    use super::*;
1217    use crate::io::ClaudeOutput;
1218
1219    #[test]
1220    fn test_deserialize_control_request_can_use_tool() {
1221        let json = r#"{
1222            "type": "control_request",
1223            "request_id": "perm-abc123",
1224            "request": {
1225                "subtype": "can_use_tool",
1226                "tool_name": "Write",
1227                "input": {
1228                    "file_path": "/home/user/hello.py",
1229                    "content": "print('hello')"
1230                },
1231                "permission_suggestions": [],
1232                "blocked_path": null
1233            }
1234        }"#;
1235
1236        let output: ClaudeOutput = serde_json::from_str(json).unwrap();
1237        assert!(output.is_control_request());
1238
1239        if let ClaudeOutput::ControlRequest(req) = output {
1240            assert_eq!(req.request_id, "perm-abc123");
1241            if let ControlRequestPayload::CanUseTool(perm_req) = req.request {
1242                assert_eq!(perm_req.tool_name, "Write");
1243                assert_eq!(
1244                    perm_req.input.get("file_path").unwrap().as_str().unwrap(),
1245                    "/home/user/hello.py"
1246                );
1247            } else {
1248                panic!("Expected CanUseTool payload");
1249            }
1250        } else {
1251            panic!("Expected ControlRequest");
1252        }
1253    }
1254
1255    #[test]
1256    fn test_deserialize_control_request_edit_tool_real() {
1257        // Real production message from Claude CLI
1258        let json = r#"{"type":"control_request","request_id":"f3cf357c-17d6-4eca-b498-dd17c7ac43dd","request":{"subtype":"can_use_tool","tool_name":"Edit","input":{"file_path":"/home/meawoppl/repos/cc-proxy/proxy/src/ui.rs","old_string":"/// Print hint to re-authenticate\npub fn print_reauth_hint() {\n    println!(\n        \"  {} Run: {} to re-authenticate\",\n        \"→\".bright_blue(),\n        \"claude-portal logout && claude-portal login\".bright_cyan()\n    );\n}","new_string":"/// Print hint to re-authenticate\npub fn print_reauth_hint() {\n    println!(\n        \"  {} Run: {} to re-authenticate\",\n        \"→\".bright_blue(),\n        \"claude-portal --reauth\".bright_cyan()\n    );\n}","replace_all":false},"permission_suggestions":[{"type":"setMode","mode":"acceptEdits","destination":"session"}],"tool_use_id":"toolu_015BDGtNiqNrRSJSDrWXNckW"}}"#;
1259
1260        let output: ClaudeOutput = serde_json::from_str(json).unwrap();
1261        assert!(output.is_control_request());
1262        assert_eq!(output.message_type(), "control_request");
1263
1264        if let ClaudeOutput::ControlRequest(req) = output {
1265            assert_eq!(req.request_id, "f3cf357c-17d6-4eca-b498-dd17c7ac43dd");
1266            if let ControlRequestPayload::CanUseTool(perm_req) = req.request {
1267                assert_eq!(perm_req.tool_name, "Edit");
1268                assert_eq!(
1269                    perm_req.input.get("file_path").unwrap().as_str().unwrap(),
1270                    "/home/meawoppl/repos/cc-proxy/proxy/src/ui.rs"
1271                );
1272                assert!(perm_req.input.get("old_string").is_some());
1273                assert!(perm_req.input.get("new_string").is_some());
1274                assert!(!perm_req
1275                    .input
1276                    .get("replace_all")
1277                    .unwrap()
1278                    .as_bool()
1279                    .unwrap());
1280            } else {
1281                panic!("Expected CanUseTool payload");
1282            }
1283        } else {
1284            panic!("Expected ControlRequest");
1285        }
1286    }
1287
1288    #[test]
1289    fn test_tool_permission_request_allow() {
1290        let req = ToolPermissionRequest {
1291            tool_name: "Read".to_string(),
1292            input: serde_json::json!({"file_path": "/tmp/test.txt"}),
1293            permission_suggestions: vec![],
1294            blocked_path: None,
1295            decision_reason: None,
1296            tool_use_id: None,
1297        };
1298
1299        let response = req.allow("req-123");
1300        let message: ControlResponseMessage = response.into();
1301
1302        let json = serde_json::to_string(&message).unwrap();
1303        assert!(json.contains("\"type\":\"control_response\""));
1304        assert!(json.contains("\"subtype\":\"success\""));
1305        assert!(json.contains("\"request_id\":\"req-123\""));
1306        assert!(json.contains("\"behavior\":\"allow\""));
1307        assert!(json.contains("\"updatedInput\""));
1308    }
1309
1310    #[test]
1311    fn test_tool_permission_request_allow_with_modified_input() {
1312        let req = ToolPermissionRequest {
1313            tool_name: "Write".to_string(),
1314            input: serde_json::json!({"file_path": "/etc/passwd", "content": "test"}),
1315            permission_suggestions: vec![],
1316            blocked_path: None,
1317            decision_reason: None,
1318            tool_use_id: None,
1319        };
1320
1321        let modified_input = serde_json::json!({
1322            "file_path": "/tmp/safe/passwd",
1323            "content": "test"
1324        });
1325        let response = req.allow_with(modified_input, "req-456");
1326        let message: ControlResponseMessage = response.into();
1327
1328        let json = serde_json::to_string(&message).unwrap();
1329        assert!(json.contains("/tmp/safe/passwd"));
1330        assert!(!json.contains("/etc/passwd"));
1331    }
1332
1333    #[test]
1334    fn test_tool_permission_request_deny() {
1335        let req = ToolPermissionRequest {
1336            tool_name: "Bash".to_string(),
1337            input: serde_json::json!({"command": "sudo rm -rf /"}),
1338            permission_suggestions: vec![],
1339            blocked_path: None,
1340            decision_reason: None,
1341            tool_use_id: None,
1342        };
1343
1344        let response = req.deny("Dangerous command blocked", "req-789");
1345        let message: ControlResponseMessage = response.into();
1346
1347        let json = serde_json::to_string(&message).unwrap();
1348        assert!(json.contains("\"behavior\":\"deny\""));
1349        assert!(json.contains("Dangerous command blocked"));
1350        assert!(!json.contains("\"interrupt\":true"));
1351    }
1352
1353    #[test]
1354    fn test_tool_permission_request_deny_and_stop() {
1355        let req = ToolPermissionRequest {
1356            tool_name: "Bash".to_string(),
1357            input: serde_json::json!({"command": "rm -rf /"}),
1358            permission_suggestions: vec![],
1359            blocked_path: None,
1360            decision_reason: None,
1361            tool_use_id: None,
1362        };
1363
1364        let response = req.deny_and_stop("Security violation", "req-000");
1365        let message: ControlResponseMessage = response.into();
1366
1367        let json = serde_json::to_string(&message).unwrap();
1368        assert!(json.contains("\"behavior\":\"deny\""));
1369        assert!(json.contains("\"interrupt\":true"));
1370    }
1371
1372    #[test]
1373    fn test_permission_result_serialization() {
1374        // Test allow
1375        let allow = PermissionResult::allow(serde_json::json!({"test": "value"}));
1376        let json = serde_json::to_string(&allow).unwrap();
1377        assert!(json.contains("\"behavior\":\"allow\""));
1378        assert!(json.contains("\"updatedInput\""));
1379
1380        // Test deny
1381        let deny = PermissionResult::deny("Not allowed");
1382        let json = serde_json::to_string(&deny).unwrap();
1383        assert!(json.contains("\"behavior\":\"deny\""));
1384        assert!(json.contains("\"message\":\"Not allowed\""));
1385        assert!(!json.contains("\"interrupt\""));
1386
1387        // Test deny with interrupt
1388        let deny_stop = PermissionResult::deny_and_interrupt("Stop!");
1389        let json = serde_json::to_string(&deny_stop).unwrap();
1390        assert!(json.contains("\"interrupt\":true"));
1391    }
1392
1393    #[test]
1394    fn test_control_request_message_initialize() {
1395        let init = ControlRequestMessage::initialize("init-1");
1396
1397        let json = serde_json::to_string(&init).unwrap();
1398        assert!(json.contains("\"type\":\"control_request\""));
1399        assert!(json.contains("\"request_id\":\"init-1\""));
1400        assert!(json.contains("\"subtype\":\"initialize\""));
1401    }
1402
1403    #[test]
1404    fn test_control_request_message_interrupt_wire_shape() {
1405        // The CLI silently ignores a bare {"subtype":"interrupt"}; it only
1406        // acts on the full control_request envelope (verified against 2.1.211).
1407        let msg = ControlRequestMessage::interrupt("interrupt-1");
1408        assert_eq!(
1409            serde_json::to_value(&msg).unwrap(),
1410            serde_json::json!({
1411                "type": "control_request",
1412                "request_id": "interrupt-1",
1413                "request": {"subtype": "interrupt"}
1414            })
1415        );
1416    }
1417
1418    #[test]
1419    fn test_interrupt_payload_roundtrip() {
1420        let json = r#"{"subtype":"interrupt"}"#;
1421        let payload: ControlRequestPayload = serde_json::from_str(json).unwrap();
1422        assert!(matches!(payload, ControlRequestPayload::Interrupt));
1423        assert_eq!(serde_json::to_string(&payload).unwrap(), json);
1424    }
1425
1426    #[test]
1427    fn test_apply_flag_settings_wire_shape() {
1428        let input = crate::io::ClaudeInput::apply_flag_settings(
1429            "apply-flag-settings-1",
1430            ApplyFlagSettingsRequest::new()
1431                .effort_level(crate::EffortLevel::Max)
1432                .ultracode(true),
1433        );
1434        assert_eq!(
1435            serde_json::to_value(&input).unwrap(),
1436            serde_json::json!({
1437                "type": "control_request",
1438                "request_id": "apply-flag-settings-1",
1439                "request": {
1440                    "subtype": "apply_flag_settings",
1441                    "settings": {"effortLevel": "max", "ultracode": true}
1442                }
1443            })
1444        );
1445    }
1446
1447    #[test]
1448    fn test_apply_flag_settings_clear_and_unknown_keys_roundtrip() {
1449        // A null clears the key in the CLI's flag layer; keys this crate has
1450        // no setter for (and future ones) must survive a round trip.
1451        let json = r#"{"subtype":"apply_flag_settings","settings":{"effortLevel":null,"fastMode":true,"someFutureKey":{"a":1}}}"#;
1452        let payload: ControlRequestPayload = serde_json::from_str(json).unwrap();
1453        let ControlRequestPayload::ApplyFlagSettings(req) = &payload else {
1454            panic!("expected ApplyFlagSettings, got {payload:?}");
1455        };
1456        assert_eq!(req.get_effort_level(), None);
1457        assert_eq!(req.settings.get("effortLevel"), Some(&Value::Null));
1458        assert_eq!(req.settings["someFutureKey"], serde_json::json!({"a": 1}));
1459        assert_eq!(
1460            serde_json::to_value(&payload).unwrap(),
1461            serde_json::from_str::<Value>(json).unwrap()
1462        );
1463
1464        let cleared = ApplyFlagSettingsRequest::new().clear_effort_level();
1465        assert_eq!(
1466            serde_json::to_value(&cleared).unwrap(),
1467            serde_json::json!({"settings": {"effortLevel": null}})
1468        );
1469
1470        let future = ApplyFlagSettingsRequest::new().set("effortLevel", Value::from("ultra"));
1471        assert_eq!(
1472            future.get_effort_level(),
1473            Some(crate::EffortLevel::Unknown("ultra".to_string()))
1474        );
1475        assert_eq!(future.get_ultracode(), None);
1476    }
1477
1478    #[test]
1479    fn test_control_response_error() {
1480        let response = ControlResponse::error("req-err", "Something went wrong");
1481        let message: ControlResponseMessage = response.into();
1482
1483        let json = serde_json::to_string(&message).unwrap();
1484        assert!(json.contains("\"subtype\":\"error\""));
1485        assert!(json.contains("\"error\":\"Something went wrong\""));
1486    }
1487
1488    #[test]
1489    fn test_get_usage_response_parses_model_scoped_limits() {
1490        let response = ControlResponse::success(
1491            "usage-1",
1492            serde_json::json!({
1493                "session": {
1494                    "total_cost_usd": 0.5,
1495                    "total_api_duration_ms": 100,
1496                    "total_duration_ms": 200,
1497                    "total_lines_added": 3,
1498                    "total_lines_removed": 1,
1499                    "model_usage": {
1500                        "claude-sonnet-4-6": {
1501                            "inputTokens": 10,
1502                            "outputTokens": 2,
1503                            "cacheReadInputTokens": 1,
1504                            "cacheCreationInputTokens": 0,
1505                            "webSearchRequests": 0,
1506                            "costUSD": 0.01,
1507                            "contextWindow": 200000,
1508                            "maxOutputTokens": 64000
1509                        }
1510                    }
1511                },
1512                "subscription_type": "pro",
1513                "rate_limits_available": true,
1514                "rate_limits": {
1515                    "five_hour": {"utilization": 0.2, "resets_at": "2026-07-09T22:00:00Z"},
1516                    "model_scoped": [
1517                        {"display_name": "Sonnet", "utilization": 0.4, "resets_at": "2026-07-16T00:00:00Z"}
1518                    ]
1519                },
1520                "behaviors": {
1521                    "request_count": 1,
1522                    "session_count": 1,
1523                    "behaviors": [{"key": "cron", "pct": 100.0, "count": 1}],
1524                    "agents": [],
1525                    "skills": [],
1526                    "plugins": [],
1527                    "mcp_servers": []
1528                }
1529            }),
1530        );
1531
1532        let usage = response.get_usage_response().unwrap().unwrap();
1533        assert_eq!(usage.subscription_type.as_deref(), Some("pro"));
1534        let model = usage.session.model_usage.get("claude-sonnet-4-6").unwrap();
1535        assert_eq!(model.context_window, 200000);
1536        assert_eq!(model.max_output_tokens, 64000);
1537        let limits = usage.rate_limits.unwrap();
1538        assert_eq!(limits.five_hour.unwrap().utilization, Some(0.2));
1539        assert_eq!(limits.model_scoped.unwrap()[0].display_name, "Sonnet");
1540    }
1541
1542    #[test]
1543    fn test_roundtrip_control_request() {
1544        let original_json = r#"{
1545            "type": "control_request",
1546            "request_id": "test-123",
1547            "request": {
1548                "subtype": "can_use_tool",
1549                "tool_name": "Bash",
1550                "input": {"command": "ls -la"},
1551                "permission_suggestions": []
1552            }
1553        }"#;
1554
1555        let output: ClaudeOutput = serde_json::from_str(original_json).unwrap();
1556
1557        let reserialized = serde_json::to_string(&output).unwrap();
1558        assert!(reserialized.contains("control_request"));
1559        assert!(reserialized.contains("test-123"));
1560        assert!(reserialized.contains("Bash"));
1561    }
1562
1563    #[test]
1564    fn test_permission_suggestions_parsing() {
1565        let json = r#"{
1566            "type": "control_request",
1567            "request_id": "perm-456",
1568            "request": {
1569                "subtype": "can_use_tool",
1570                "tool_name": "Bash",
1571                "input": {"command": "npm test"},
1572                "permission_suggestions": [
1573                    {"type": "setMode", "mode": "acceptEdits", "destination": "session"},
1574                    {"type": "setMode", "mode": "bypassPermissions", "destination": "project"}
1575                ]
1576            }
1577        }"#;
1578
1579        let output: ClaudeOutput = serde_json::from_str(json).unwrap();
1580        if let ClaudeOutput::ControlRequest(req) = output {
1581            if let ControlRequestPayload::CanUseTool(perm_req) = req.request {
1582                assert_eq!(perm_req.permission_suggestions.len(), 2);
1583                assert_eq!(
1584                    perm_req.permission_suggestions[0].suggestion_type,
1585                    PermissionType::SetMode
1586                );
1587                assert_eq!(
1588                    perm_req.permission_suggestions[0].mode,
1589                    Some(PermissionModeName::AcceptEdits)
1590                );
1591                assert_eq!(
1592                    perm_req.permission_suggestions[0].destination,
1593                    PermissionDestination::Session
1594                );
1595                assert_eq!(
1596                    perm_req.permission_suggestions[1].suggestion_type,
1597                    PermissionType::SetMode
1598                );
1599                assert_eq!(
1600                    perm_req.permission_suggestions[1].mode,
1601                    Some(PermissionModeName::BypassPermissions)
1602                );
1603                assert_eq!(
1604                    perm_req.permission_suggestions[1].destination,
1605                    PermissionDestination::Project
1606                );
1607            } else {
1608                panic!("Expected CanUseTool payload");
1609            }
1610        } else {
1611            panic!("Expected ControlRequest");
1612        }
1613    }
1614
1615    #[test]
1616    fn test_permission_suggestion_set_mode_roundtrip() {
1617        let suggestion = PermissionSuggestion {
1618            suggestion_type: PermissionType::SetMode,
1619            destination: PermissionDestination::Session,
1620            mode: Some(PermissionModeName::AcceptEdits),
1621            behavior: None,
1622            rules: None,
1623        };
1624
1625        let json = serde_json::to_string(&suggestion).unwrap();
1626        assert!(json.contains("\"type\":\"setMode\""));
1627        assert!(json.contains("\"mode\":\"acceptEdits\""));
1628        assert!(json.contains("\"destination\":\"session\""));
1629        assert!(!json.contains("\"behavior\""));
1630        assert!(!json.contains("\"rules\""));
1631
1632        let parsed: PermissionSuggestion = serde_json::from_str(&json).unwrap();
1633        assert_eq!(parsed, suggestion);
1634    }
1635
1636    #[test]
1637    fn test_permission_suggestion_add_rules_roundtrip() {
1638        let suggestion = PermissionSuggestion {
1639            suggestion_type: PermissionType::AddRules,
1640            destination: PermissionDestination::Session,
1641            mode: None,
1642            behavior: Some(PermissionBehavior::Allow),
1643            rules: Some(vec![serde_json::json!({
1644                "toolName": "Read",
1645                "ruleContent": "//tmp/**"
1646            })]),
1647        };
1648
1649        let json = serde_json::to_string(&suggestion).unwrap();
1650        assert!(json.contains("\"type\":\"addRules\""));
1651        assert!(json.contains("\"behavior\":\"allow\""));
1652        assert!(json.contains("\"destination\":\"session\""));
1653        assert!(json.contains("\"rules\""));
1654        assert!(json.contains("\"toolName\":\"Read\""));
1655        assert!(!json.contains("\"mode\""));
1656
1657        let parsed: PermissionSuggestion = serde_json::from_str(&json).unwrap();
1658        assert_eq!(parsed, suggestion);
1659    }
1660
1661    #[test]
1662    fn test_permission_suggestion_add_rules_from_real_json() {
1663        let json = r#"{"type":"addRules","rules":[{"toolName":"Read","ruleContent":"//tmp/**"}],"behavior":"allow","destination":"session"}"#;
1664
1665        let parsed: PermissionSuggestion = serde_json::from_str(json).unwrap();
1666        assert_eq!(parsed.suggestion_type, PermissionType::AddRules);
1667        assert_eq!(parsed.destination, PermissionDestination::Session);
1668        assert_eq!(parsed.behavior, Some(PermissionBehavior::Allow));
1669        assert!(parsed.rules.is_some());
1670        assert!(parsed.mode.is_none());
1671    }
1672
1673    #[test]
1674    fn test_permission_allow_tool() {
1675        let perm = Permission::allow_tool("Bash", "npm test");
1676
1677        assert_eq!(perm.permission_type, PermissionType::AddRules);
1678        assert_eq!(perm.destination, PermissionDestination::Session);
1679        assert_eq!(perm.behavior, Some(PermissionBehavior::Allow));
1680        assert!(perm.mode.is_none());
1681
1682        let rules = perm.rules.unwrap();
1683        assert_eq!(rules.len(), 1);
1684        assert_eq!(rules[0].tool_name, "Bash");
1685        assert_eq!(rules[0].rule_content, "npm test");
1686    }
1687
1688    #[test]
1689    fn test_permission_allow_tool_with_destination() {
1690        let perm = Permission::allow_tool_with_destination(
1691            "Read",
1692            "/tmp/**",
1693            PermissionDestination::Project,
1694        );
1695
1696        assert_eq!(perm.permission_type, PermissionType::AddRules);
1697        assert_eq!(perm.destination, PermissionDestination::Project);
1698        assert_eq!(perm.behavior, Some(PermissionBehavior::Allow));
1699
1700        let rules = perm.rules.unwrap();
1701        assert_eq!(rules[0].tool_name, "Read");
1702        assert_eq!(rules[0].rule_content, "/tmp/**");
1703    }
1704
1705    #[test]
1706    fn test_permission_set_mode() {
1707        let perm = Permission::set_mode(
1708            PermissionModeName::AcceptEdits,
1709            PermissionDestination::Session,
1710        );
1711
1712        assert_eq!(perm.permission_type, PermissionType::SetMode);
1713        assert_eq!(perm.destination, PermissionDestination::Session);
1714        assert_eq!(perm.mode, Some(PermissionModeName::AcceptEdits));
1715        assert!(perm.behavior.is_none());
1716        assert!(perm.rules.is_none());
1717    }
1718
1719    #[test]
1720    fn test_permission_serialization() {
1721        let perm = Permission::allow_tool("Bash", "npm test");
1722        let json = serde_json::to_string(&perm).unwrap();
1723
1724        assert!(json.contains("\"type\":\"addRules\""));
1725        assert!(json.contains("\"destination\":\"session\""));
1726        assert!(json.contains("\"behavior\":\"allow\""));
1727        assert!(json.contains("\"toolName\":\"Bash\""));
1728        assert!(json.contains("\"ruleContent\":\"npm test\""));
1729    }
1730
1731    #[test]
1732    fn test_permission_from_suggestion_set_mode() {
1733        let suggestion = PermissionSuggestion {
1734            suggestion_type: PermissionType::SetMode,
1735            destination: PermissionDestination::Session,
1736            mode: Some(PermissionModeName::AcceptEdits),
1737            behavior: None,
1738            rules: None,
1739        };
1740
1741        let perm = Permission::from_suggestion(&suggestion);
1742
1743        assert_eq!(perm.permission_type, PermissionType::SetMode);
1744        assert_eq!(perm.destination, PermissionDestination::Session);
1745        assert_eq!(perm.mode, Some(PermissionModeName::AcceptEdits));
1746    }
1747
1748    #[test]
1749    fn test_permission_from_suggestion_add_rules() {
1750        let suggestion = PermissionSuggestion {
1751            suggestion_type: PermissionType::AddRules,
1752            destination: PermissionDestination::Session,
1753            mode: None,
1754            behavior: Some(PermissionBehavior::Allow),
1755            rules: Some(vec![serde_json::json!({
1756                "toolName": "Read",
1757                "ruleContent": "/tmp/**"
1758            })]),
1759        };
1760
1761        let perm = Permission::from_suggestion(&suggestion);
1762
1763        assert_eq!(perm.permission_type, PermissionType::AddRules);
1764        assert_eq!(perm.behavior, Some(PermissionBehavior::Allow));
1765
1766        let rules = perm.rules.unwrap();
1767        assert_eq!(rules.len(), 1);
1768        assert_eq!(rules[0].tool_name, "Read");
1769        assert_eq!(rules[0].rule_content, "/tmp/**");
1770    }
1771
1772    #[test]
1773    fn test_permission_result_allow_with_typed_permissions() {
1774        let result = PermissionResult::allow_with_typed_permissions(
1775            serde_json::json!({"command": "npm test"}),
1776            vec![Permission::allow_tool("Bash", "npm test")],
1777        );
1778
1779        let json = serde_json::to_string(&result).unwrap();
1780        assert!(json.contains("\"behavior\":\"allow\""));
1781        assert!(json.contains("\"updatedPermissions\""));
1782        assert!(json.contains("\"toolName\":\"Bash\""));
1783    }
1784
1785    #[test]
1786    fn test_tool_permission_request_allow_and_remember() {
1787        let req = ToolPermissionRequest {
1788            tool_name: "Bash".to_string(),
1789            input: serde_json::json!({"command": "npm test"}),
1790            permission_suggestions: vec![],
1791            blocked_path: None,
1792            decision_reason: None,
1793            tool_use_id: None,
1794        };
1795
1796        let response =
1797            req.allow_and_remember(vec![Permission::allow_tool("Bash", "npm test")], "req-123");
1798        let message: ControlResponseMessage = response.into();
1799        let json = serde_json::to_string(&message).unwrap();
1800
1801        assert!(json.contains("\"type\":\"control_response\""));
1802        assert!(json.contains("\"behavior\":\"allow\""));
1803        assert!(json.contains("\"updatedPermissions\""));
1804        assert!(json.contains("\"toolName\":\"Bash\""));
1805    }
1806
1807    #[test]
1808    fn test_tool_permission_request_allow_and_remember_suggestion() {
1809        let req = ToolPermissionRequest {
1810            tool_name: "Bash".to_string(),
1811            input: serde_json::json!({"command": "npm test"}),
1812            permission_suggestions: vec![PermissionSuggestion {
1813                suggestion_type: PermissionType::SetMode,
1814                destination: PermissionDestination::Session,
1815                mode: Some(PermissionModeName::AcceptEdits),
1816                behavior: None,
1817                rules: None,
1818            }],
1819            blocked_path: None,
1820            decision_reason: None,
1821            tool_use_id: None,
1822        };
1823
1824        let response = req.allow_and_remember_suggestion("req-123");
1825        assert!(response.is_some());
1826
1827        let message: ControlResponseMessage = response.unwrap().into();
1828        let json = serde_json::to_string(&message).unwrap();
1829
1830        assert!(json.contains("\"type\":\"setMode\""));
1831        assert!(json.contains("\"mode\":\"acceptEdits\""));
1832    }
1833
1834    #[test]
1835    fn test_tool_permission_request_allow_and_remember_suggestion_none() {
1836        let req = ToolPermissionRequest {
1837            tool_name: "Bash".to_string(),
1838            input: serde_json::json!({"command": "npm test"}),
1839            permission_suggestions: vec![], // No suggestions
1840            blocked_path: None,
1841            decision_reason: None,
1842            tool_use_id: None,
1843        };
1844
1845        let response = req.allow_and_remember_suggestion("req-123");
1846        assert!(response.is_none());
1847    }
1848
1849    fn ask_user_question_request() -> ToolPermissionRequest {
1850        ToolPermissionRequest {
1851            tool_name: "AskUserQuestion".to_string(),
1852            input: serde_json::json!({
1853                "questions": [
1854                    {
1855                        "question": "Which color do you prefer?",
1856                        "header": "Color",
1857                        "options": [
1858                            {"label": "Red", "description": "warm"},
1859                            {"label": "Blue", "description": "cool"}
1860                        ],
1861                        "multiSelect": false
1862                    },
1863                    {
1864                        "question": "Pick a size",
1865                        "header": "Size",
1866                        "options": [
1867                            {"label": "Small"},
1868                            {"label": "Large"}
1869                        ],
1870                        "multiSelect": false
1871                    }
1872                ]
1873            }),
1874            permission_suggestions: vec![],
1875            blocked_path: None,
1876            decision_reason: None,
1877            tool_use_id: None,
1878        }
1879    }
1880
1881    fn extract_updated_input(resp: &ControlResponse) -> Value {
1882        let ControlResponsePayload::Success { response, .. } = &resp.response else {
1883            panic!("expected Success payload");
1884        };
1885        let inner = response.as_ref().expect("response body present");
1886        inner
1887            .get("updatedInput")
1888            .cloned()
1889            .expect("updatedInput field present")
1890    }
1891
1892    #[test]
1893    fn answer_questions_keys_by_question_text_and_preserves_questions() {
1894        let req = ask_user_question_request();
1895        let mut answers = HashMap::new();
1896        answers.insert(0, "Blue".to_string());
1897        answers.insert(1, "Large".to_string());
1898
1899        let resp = req.answer_questions(&answers, "rid-1").unwrap();
1900        let updated = extract_updated_input(&resp);
1901
1902        // questions array is preserved verbatim
1903        assert_eq!(
1904            updated["questions"], req.input["questions"],
1905            "questions array must round-trip unchanged"
1906        );
1907        // answers keyed by question text (not header, not index)
1908        assert_eq!(
1909            updated["answers"]["Which color do you prefer?"],
1910            Value::String("Blue".into())
1911        );
1912        assert_eq!(
1913            updated["answers"]["Pick a size"],
1914            Value::String("Large".into())
1915        );
1916        // header is NOT used as the key
1917        assert!(updated["answers"].get("Color").is_none());
1918        assert!(updated["answers"].get("Size").is_none());
1919    }
1920
1921    #[test]
1922    fn answer_questions_partial_answers_omits_unanswered() {
1923        let req = ask_user_question_request();
1924        let mut answers = HashMap::new();
1925        answers.insert(1, "Small".to_string());
1926
1927        let resp = req.answer_questions(&answers, "rid-2").unwrap();
1928        let updated = extract_updated_input(&resp);
1929
1930        assert_eq!(
1931            updated["answers"]["Pick a size"],
1932            Value::String("Small".into())
1933        );
1934        assert!(updated["answers"]
1935            .get("Which color do you prefer?")
1936            .is_none());
1937    }
1938
1939    #[test]
1940    fn answer_questions_rejects_wrong_tool() {
1941        let mut req = ask_user_question_request();
1942        req.tool_name = "Bash".to_string();
1943
1944        let mut answers = HashMap::new();
1945        answers.insert(0, "Blue".to_string());
1946        match req.answer_questions(&answers, "rid-3").unwrap_err() {
1947            AskUserQuestionResponseError::WrongTool(name) => assert_eq!(name, "Bash"),
1948            other => panic!("expected WrongTool, got {other:?}"),
1949        }
1950    }
1951
1952    #[test]
1953    fn answer_questions_rejects_unparseable_input() {
1954        let req = ToolPermissionRequest {
1955            tool_name: "AskUserQuestion".to_string(),
1956            input: serde_json::json!({"not_questions": "garbage"}),
1957            permission_suggestions: vec![],
1958            blocked_path: None,
1959            decision_reason: None,
1960            tool_use_id: None,
1961        };
1962
1963        let answers = HashMap::new();
1964        match req.answer_questions(&answers, "rid-4").unwrap_err() {
1965            AskUserQuestionResponseError::ParseInput(_) => {}
1966            other => panic!("expected ParseInput, got {other:?}"),
1967        }
1968    }
1969
1970    #[test]
1971    fn answer_questions_rejects_out_of_range_index() {
1972        let req = ask_user_question_request();
1973        let mut answers = HashMap::new();
1974        answers.insert(7, "ghost".to_string());
1975
1976        match req.answer_questions(&answers, "rid-5").unwrap_err() {
1977            AskUserQuestionResponseError::QuestionIndexOutOfRange { index, total } => {
1978                assert_eq!(index, 7);
1979                assert_eq!(total, 2);
1980            }
1981            other => panic!("expected QuestionIndexOutOfRange, got {other:?}"),
1982        }
1983    }
1984}