1use std::path::Path;
2use std::sync::Arc;
3use std::sync::atomic::{AtomicU64, Ordering};
4
5use claude_code_sdk_rust::mcp::{MCPContent, SdkMcpTool, SimpleMCPServer};
6use claude_code_sdk_rust::{
7 ClaudeAgentOptions, EffortLevel, PermissionMode, PermissionResult, SettingSource,
8};
9use roder_api::inference::{AgentInferenceRequest, ToolCallCompleted, TurnToolExecutor};
10use roder_api::tools::ToolChoice;
11
12use crate::provider::ClaudeCodeConfig;
13
14pub fn build_options(
15 config: &ClaudeCodeConfig,
16 request: &AgentInferenceRequest,
17 tool_executor: Option<Arc<dyn TurnToolExecutor>>,
18 cwd: Option<&Path>,
19 resume_session_id: Option<&str>,
20) -> anyhow::Result<ClaudeAgentOptions> {
21 let mut builder = ClaudeAgentOptions::builder()
22 .model(request.model.model.clone())
23 .include_partial_messages(true);
24 if let Some(session_id) = resume_session_id.filter(|value| !value.trim().is_empty()) {
30 builder = builder.resume(session_id.to_string());
31 }
32 if let Some(cli_path) = config
33 .cli_path
34 .as_deref()
35 .filter(|value| !value.trim().is_empty())
36 {
37 builder = builder.cli_path(cli_path.to_string());
38 }
39 if let Some(cwd) = cwd {
40 builder = builder.cwd(cwd.display().to_string());
41 }
42 if let Some(system) = merged_system_prompt(request) {
43 builder = builder.system_prompt(system);
44 }
45 if let Some(mode) = config.permission_mode.as_deref() {
46 builder = builder.permission_mode(parse_permission_mode(mode)?);
47 }
48 if let Some(setting_sources) = &config.setting_sources {
49 builder = builder.setting_sources(parse_setting_sources(setting_sources)?);
50 }
51 if request.reasoning.enabled
52 && let Some(level) = request.reasoning.level.as_deref()
53 && let Some(effort) = parse_effort_level(level)
54 {
55 builder = builder.effort(effort);
56 }
57 if let Some(max_tokens) = request.output.max_tokens {
58 let budget = i32::try_from(max_tokens).unwrap_or(i32::MAX);
59 builder = builder.max_thinking_tokens(budget);
60 }
61 let chrome_enabled = claude_in_chrome_enabled(config);
67 if chrome_enabled {
68 builder = builder
69 .env_var("CLAUDE_CODE_ENABLE_CFC", "1")
70 .env_var("ANTHROPIC_API_KEY", "")
71 .env_var("ANTHROPIC_AUTH_TOKEN", "");
72 }
73 let allowed_tool_names = allowed_claude_tool_names(request);
74 if !request.tools.is_empty() && !matches!(request.tool_choice, ToolChoice::None) {
75 let executor = tool_executor.ok_or_else(|| {
76 anyhow::anyhow!("Claude Code provider requires a Roder tool executor")
77 })?;
78 let server = roder_sdk_mcp_server(request, executor);
79 builder = builder.sdk_mcp_server("roder", server);
80 builder = builder.tools(Vec::new());
87 builder = builder.allowed_tools(
88 allowed_tool_names
89 .iter()
90 .map(|name| format!("mcp__roder__{name}"))
91 .collect(),
92 );
93 }
94 builder = builder.can_use_tool(move |tool_name, _input, _context| {
95 let allowed_tool_names = allowed_tool_names.clone();
96 async move {
97 if chrome_enabled && is_claude_in_chrome_tool(&tool_name) {
101 return Ok(PermissionResult::allow());
102 }
103 let Some(claude_tool_name) = tool_name.strip_prefix("mcp__roder__") else {
104 return Ok(PermissionResult::deny(format!(
105 "Claude Code tool {tool_name} is not managed by Roder"
106 )));
107 };
108 if allowed_tool_names
109 .iter()
110 .any(|name| name == claude_tool_name)
111 {
112 Ok(PermissionResult::allow())
113 } else {
114 Ok(PermissionResult::deny(format!(
115 "Claude Code tool {tool_name} was not advertised for this Roder turn"
116 )))
117 }
118 }
119 });
120 Ok(builder.build())
121}
122
123const CLAUDE_IN_CHROME_TOOL_PREFIXES: [&str; 2] =
127 ["mcp__claude-in-chrome__", "mcp__Claude_in_Chrome__"];
128
129fn is_claude_in_chrome_tool(name: &str) -> bool {
131 CLAUDE_IN_CHROME_TOOL_PREFIXES
132 .iter()
133 .any(|prefix| name.starts_with(prefix))
134}
135
136fn claude_in_chrome_enabled(config: &ClaudeCodeConfig) -> bool {
141 if let Some(explicit) = config.enable_claude_in_chrome {
142 return explicit;
143 }
144 if let Some(value) = env_flag("RODER_CLAUDE_CODE_ENABLE_CHROME")
145 .or_else(|| env_flag("CLAUDE_CODE_ENABLE_CHROME"))
146 {
147 return value;
148 }
149 detect_claude_in_chrome_setup()
150}
151
152fn env_flag(key: &str) -> Option<bool> {
155 let raw = std::env::var(key).ok()?;
156 match raw.trim().to_ascii_lowercase().as_str() {
157 "1" | "true" | "yes" | "on" => Some(true),
158 "0" | "false" | "no" | "off" | "" => Some(false),
159 _ => None,
160 }
161}
162
163fn detect_claude_in_chrome_setup() -> bool {
167 let Some(path) = claude_config_path() else {
168 return false;
169 };
170 let Ok(text) = std::fs::read_to_string(&path) else {
171 return false;
172 };
173 let Ok(value) = serde_json::from_str::<serde_json::Value>(&text) else {
174 return false;
175 };
176 let default_enabled = value
177 .get("claudeInChromeDefaultEnabled")
178 .and_then(serde_json::Value::as_bool)
179 .unwrap_or(false);
180 let paired = value
181 .get("chromeExtension")
182 .and_then(|ext| ext.get("pairedDeviceId"))
183 .map(|id| !id.is_null())
184 .unwrap_or(false);
185 let installed = value
186 .get("cachedChromeExtensionInstalled")
187 .and_then(serde_json::Value::as_bool)
188 .unwrap_or(false);
189 default_enabled || paired || installed
190}
191
192fn claude_config_path() -> Option<std::path::PathBuf> {
194 if let Some(dir) = std::env::var_os("CLAUDE_CONFIG_DIR") {
195 return Some(std::path::PathBuf::from(dir).join(".claude.json"));
196 }
197 let home = std::env::var_os("HOME")?;
198 Some(std::path::PathBuf::from(home).join(".claude.json"))
199}
200
201fn allowed_claude_tool_names(request: &AgentInferenceRequest) -> Vec<String> {
202 request
203 .tools
204 .iter()
205 .flat_map(|tool| {
206 let mut names = vec![tool.name.clone()];
207 names.extend(claude_aliases_for_roder_tool(&tool.name).map(str::to_string));
208 names
209 })
210 .collect()
211}
212
213fn roder_sdk_mcp_server(
214 request: &AgentInferenceRequest,
215 executor: Arc<dyn TurnToolExecutor>,
216) -> SimpleMCPServer {
217 let tools = request
218 .tools
219 .iter()
220 .flat_map(|spec| {
221 let mut names = vec![spec.name.clone()];
222 names.extend(claude_aliases_for_roder_tool(&spec.name).map(str::to_string));
223 names.into_iter().map(|claude_name| {
224 sdk_tool_for_spec(
225 claude_name,
226 spec.name.clone(),
227 spec.description.clone(),
228 spec.parameters.clone(),
229 Arc::clone(&executor),
230 )
231 })
232 })
233 .collect();
234 claude_code_sdk_rust::mcp::create_sdk_mcp_server("roder", tools)
235}
236
237static NEXT_TOOL_CALL_SEQ: AtomicU64 = AtomicU64::new(0);
240
241fn sdk_tool_for_spec(
242 claude_name: String,
243 roder_name: String,
244 description: String,
245 schema: serde_json::Value,
246 executor: Arc<dyn TurnToolExecutor>,
247) -> SdkMcpTool {
248 let call_schema = schema.clone();
249 SdkMcpTool::new(
250 claude_name.clone(),
251 description,
252 schema,
253 None,
254 move |input| {
255 let input =
256 repair_sdk_mcp_input_for_tool(input, &call_schema, &claude_name, &roder_name);
257 let seq = NEXT_TOOL_CALL_SEQ.fetch_add(1, Ordering::Relaxed);
262 let call = ToolCallCompleted {
263 id: format!("claude-code-{claude_name}-{seq}"),
264 name: roder_name.clone(),
265 arguments: input.to_string(),
266 };
267 let handle = tokio::runtime::Handle::current();
268 let outcome = tokio::task::block_in_place(|| handle.block_on(executor.execute(call)));
269 match outcome {
270 Ok(outcome) => {
271 let text = if outcome.is_error {
272 format!("Tool returned an error:\n{}", outcome.result)
273 } else {
274 outcome.result
275 };
276 Ok(vec![MCPContent::Text { text }])
277 }
278 Err(err) => Err(err.to_string()),
279 }
280 },
281 )
282}
283
284fn claude_aliases_for_roder_tool(name: &str) -> impl Iterator<Item = &'static str> {
285 match name {
286 "shell" => ["Bash"].as_slice().iter().copied(),
287 "read_file" => ["Read"].as_slice().iter().copied(),
288 "list_files" => ["LS"].as_slice().iter().copied(),
289 "grep" => ["Grep"].as_slice().iter().copied(),
290 "glob" => ["Glob"].as_slice().iter().copied(),
291 "write_file" => ["Write"].as_slice().iter().copied(),
292 "edit" => ["Edit"].as_slice().iter().copied(),
293 "multi_edit" => ["MultiEdit"].as_slice().iter().copied(),
294 _ => [].as_slice().iter().copied(),
295 }
296}
297
298fn retain_schema_properties(
299 object: &mut serde_json::Map<String, serde_json::Value>,
300 schema: &serde_json::Value,
301) {
302 let Some(properties) = schema
303 .get("properties")
304 .and_then(serde_json::Value::as_object)
305 else {
306 return;
307 };
308 object.retain(|key, _| properties.contains_key(key));
309}
310
311fn repair_sdk_mcp_input_for_tool(
312 input: serde_json::Value,
313 schema: &serde_json::Value,
314 claude_name: &str,
315 roder_name: &str,
316) -> serde_json::Value {
317 if let Some(mut object) = input.as_object().cloned() {
318 normalize_claude_tool_input_aliases(&mut object, claude_name, roder_name);
319 normalize_sdk_mcp_aliases(&mut object, schema);
320 retain_schema_properties(&mut object, schema);
321 return serde_json::Value::Object(object);
322 }
323 let Some(required) = schema.get("required").and_then(serde_json::Value::as_array) else {
324 return input;
325 };
326 let [only_required] = required.as_slice() else {
327 return input;
328 };
329 let Some(property) = only_required.as_str() else {
330 return input;
331 };
332 let value = if property == "command" {
333 command_value_from_sdk_mcp_input(input)
334 } else {
335 input
336 };
337 serde_json::json!({ property: value })
338}
339
340fn normalize_claude_tool_input_aliases(
341 object: &mut serde_json::Map<String, serde_json::Value>,
342 claude_name: &str,
343 roder_name: &str,
344) {
345 match (claude_name, roder_name) {
346 ("Bash", "shell") => move_key_if_missing(object, "command", "command"),
347 ("Read", "read_file") => move_key_if_missing(object, "file_path", "path"),
348 ("LS", "list_files") => move_key_if_missing(object, "path", "path"),
349 ("Grep", "grep") => {
350 move_key_if_missing(object, "pattern", "query");
351 move_key_if_missing(object, "path", "path");
352 move_key_if_missing(object, "glob", "glob");
353 }
354 ("Glob", "glob") => move_key_if_missing(object, "pattern", "pattern"),
355 ("Write", "write_file") => move_key_if_missing(object, "file_path", "path"),
356 ("Edit", "edit") | ("MultiEdit", "multi_edit") => {
357 move_key_if_missing(object, "file_path", "path")
358 }
359 _ => {}
360 }
361}
362
363fn move_key_if_missing(
364 object: &mut serde_json::Map<String, serde_json::Value>,
365 from: &str,
366 to: &str,
367) {
368 if !object.contains_key(to)
369 && let Some(value) = object.remove(from)
370 {
371 object.insert(to.to_string(), value);
372 }
373}
374
375fn command_value_from_sdk_mcp_input(input: serde_json::Value) -> serde_json::Value {
376 let Some(items) = input.as_array() else {
377 return input;
378 };
379 let command = items
380 .iter()
381 .map(|item| match item {
382 serde_json::Value::String(text) => text.clone(),
383 other => other.to_string(),
384 })
385 .collect::<Vec<_>>()
386 .join(" ");
387 serde_json::Value::String(command)
388}
389
390fn normalize_sdk_mcp_aliases(
391 object: &mut serde_json::Map<String, serde_json::Value>,
392 schema: &serde_json::Value,
393) {
394 if schema
395 .get("properties")
396 .and_then(serde_json::Value::as_object)
397 .is_some_and(|properties| properties.contains_key("path"))
398 && !object.contains_key("path")
399 && let Some(value) = object.remove("file_path")
400 {
401 object.insert("path".to_string(), value);
402 }
403}
404
405fn merged_system_prompt(request: &AgentInferenceRequest) -> Option<String> {
406 match (
407 request.instructions.system.as_deref(),
408 request.instructions.developer.as_deref(),
409 ) {
410 (Some(system), Some(developer)) => Some(format!("{system}\n\n{developer}")),
411 (Some(system), None) => Some(system.to_string()),
412 (None, Some(developer)) => Some(developer.to_string()),
413 (None, None) => None,
414 }
415}
416
417fn parse_effort_level(value: &str) -> Option<EffortLevel> {
420 match value.trim().to_ascii_lowercase().as_str() {
421 "low" | "minimal" => Some(EffortLevel::Low),
422 "medium" => Some(EffortLevel::Medium),
423 "high" => Some(EffortLevel::High),
424 "xhigh" | "very-high" | "veryhigh" => Some(EffortLevel::Xhigh),
425 "max" | "maximum" => Some(EffortLevel::Max),
426 _ => None,
427 }
428}
429
430pub fn parse_permission_mode(value: &str) -> anyhow::Result<PermissionMode> {
431 match value.trim().to_ascii_lowercase().replace('_', "-").as_str() {
432 "default" => Ok(PermissionMode::Default),
433 "accept-edits" | "acceptedits" => Ok(PermissionMode::AcceptEdits),
434 "plan" => Ok(PermissionMode::Plan),
435 "bypass-permissions" | "bypasspermissions" => Ok(PermissionMode::BypassPermissions),
436 "dont-ask" | "dontask" => Ok(PermissionMode::DontAsk),
437 "auto" => Ok(PermissionMode::Auto),
438 other => anyhow::bail!(
439 "unsupported Claude Code permission mode {other:?}; expected default, accept-edits, plan, bypass-permissions, dont-ask, or auto"
440 ),
441 }
442}
443
444pub fn parse_setting_sources(values: &[String]) -> anyhow::Result<Vec<SettingSource>> {
445 values
446 .iter()
447 .map(|value| match value.trim().to_ascii_lowercase().as_str() {
448 "user" => Ok(SettingSource::User),
449 "project" => Ok(SettingSource::Project),
450 "local" => Ok(SettingSource::Local),
451 other => anyhow::bail!(
452 "unsupported Claude Code setting source {other:?}; expected user, project, or local"
453 ),
454 })
455 .collect()
456}
457
458#[cfg(test)]
459mod tests {
460 use roder_api::inference::{
461 AgentInferenceRequest, HostedWebSearchConfig, InstructionBundle, ModelSelection,
462 OutputConfig, ReasoningConfig, RuntimeHints, TurnToolOutcome,
463 };
464 use roder_api::tools::{ToolChoice, ToolSpec};
465
466 use super::*;
467
468 #[test]
469 fn parses_permission_modes() {
470 assert!(matches!(
471 parse_permission_mode("accept-edits").unwrap(),
472 PermissionMode::AcceptEdits
473 ));
474 assert!(parse_permission_mode("root").is_err());
475 }
476
477 #[test]
478 fn builds_options_without_running_claude() {
479 let request = AgentInferenceRequest {
480 model: ModelSelection {
481 provider: "claude-code".to_string(),
482 model: "sonnet".to_string(),
483 },
484 instructions: InstructionBundle {
485 system: Some("system".to_string()),
486 developer: Some("developer".to_string()),
487 developer_context: None,
488 },
489 transcript: Vec::new(),
490 tools: Vec::new(),
491 tool_choice: ToolChoice::Auto,
492 reasoning: ReasoningConfig {
493 enabled: true,
494 level: Some("medium".to_string()),
495 },
496 output: OutputConfig {
497 max_tokens: Some(1024),
498 temperature: None,
499 top_p: None,
500 response_format: None,
501 },
502 runtime: RuntimeHints {
503 hosted_web_search: HostedWebSearchConfig::disabled(),
504 ..RuntimeHints::default()
505 },
506 metadata: serde_json::json!({}),
507 };
508 let options = build_options(
509 &ClaudeCodeConfig {
510 cli_path: Some("/bin/claude".to_string()),
511 permission_mode: Some("default".to_string()),
512 setting_sources: Some(vec!["user".to_string(), "project".to_string()]),
513 workspace: None,
514 reuse_cli_session: None,
515 enable_claude_in_chrome: Some(false),
516 },
517 &request,
518 None,
519 None,
520 None,
521 )
522 .unwrap();
523 assert_eq!(options.model.as_deref(), Some("sonnet"));
524 assert_eq!(options.cli_path.as_deref(), Some("/bin/claude"));
525 assert_eq!(
526 options.system_prompt.as_deref(),
527 Some("system\n\ndeveloper")
528 );
529 assert_eq!(options.effort.map(|effort| effort.as_cli()), Some("medium"));
530 assert!(options.can_use_tool.is_some());
531 }
532
533 struct FakeToolExecutor;
534
535 #[async_trait::async_trait]
536 impl TurnToolExecutor for FakeToolExecutor {
537 async fn execute(&self, _call: ToolCallCompleted) -> anyhow::Result<TurnToolOutcome> {
538 Ok(TurnToolOutcome {
539 data: serde_json::Value::Null,
540 result: "ok".to_string(),
541 is_error: false,
542 })
543 }
544 }
545
546 #[test]
547 fn registers_roder_tools_as_sdk_mcp_server() {
548 let mut request = AgentInferenceRequest {
549 model: ModelSelection {
550 provider: "claude-code".to_string(),
551 model: "sonnet".to_string(),
552 },
553 instructions: InstructionBundle::default(),
554 transcript: Vec::new(),
555 tools: vec![ToolSpec {
556 name: "grep".to_string(),
557 description: "Search text".to_string(),
558 parameters: serde_json::json!({
559 "type": "object",
560 "properties": {"query": {"type": "string"}},
561 "required": ["query"],
562 "additionalProperties": false
563 }),
564 }],
565 tool_choice: ToolChoice::Auto,
566 reasoning: ReasoningConfig::default(),
567 output: OutputConfig::default(),
568 runtime: RuntimeHints {
569 hosted_web_search: HostedWebSearchConfig::disabled(),
570 ..RuntimeHints::default()
571 },
572 metadata: serde_json::json!({}),
573 };
574
575 let options = build_options(
576 &ClaudeCodeConfig::default(),
577 &request,
578 Some(Arc::new(FakeToolExecutor)),
579 None,
580 None,
581 )
582 .unwrap();
583
584 assert!(options.mcp_servers.contains_key("roder"));
585 assert_eq!(
586 options.allowed_tools,
587 vec!["mcp__roder__grep", "mcp__roder__Grep"]
588 );
589 let server = options.sdk_mcp_servers.get("roder").unwrap();
590 let mut names = server
591 .list_tools()
592 .into_iter()
593 .map(|tool| tool.name.clone())
594 .collect::<Vec<_>>();
595 names.sort();
596 assert_eq!(names, vec!["Grep", "grep"]);
597
598 assert!(options.tools_set);
601 assert!(options.tools.is_empty());
602
603 request.tool_choice = ToolChoice::None;
604 let options =
605 build_options(&ClaudeCodeConfig::default(), &request, None, None, None).unwrap();
606 assert!(options.sdk_mcp_servers.is_empty());
607 assert!(options.allowed_tools.is_empty());
608 assert!(!options.tools_set);
610 }
611
612 fn chrome_request() -> AgentInferenceRequest {
613 AgentInferenceRequest {
614 model: ModelSelection {
615 provider: "claude-code".to_string(),
616 model: "opus".to_string(),
617 },
618 instructions: InstructionBundle::default(),
619 transcript: Vec::new(),
620 tools: Vec::new(),
621 tool_choice: ToolChoice::None,
622 reasoning: ReasoningConfig::default(),
623 output: OutputConfig::default(),
624 runtime: RuntimeHints {
625 hosted_web_search: HostedWebSearchConfig::disabled(),
626 ..RuntimeHints::default()
627 },
628 metadata: serde_json::json!({}),
629 }
630 }
631
632 #[test]
633 fn classifies_claude_in_chrome_tools() {
634 assert!(is_claude_in_chrome_tool("mcp__claude-in-chrome__navigate"));
635 assert!(is_claude_in_chrome_tool("mcp__Claude_in_Chrome__read_page"));
636 assert!(!is_claude_in_chrome_tool("mcp__roder__shell"));
637 assert!(!is_claude_in_chrome_tool("Bash"));
638 }
639
640 #[test]
641 fn chrome_disabled_sets_no_chrome_env() {
642 let options = build_options(
643 &ClaudeCodeConfig {
644 enable_claude_in_chrome: Some(false),
645 ..ClaudeCodeConfig::default()
646 },
647 &chrome_request(),
648 None,
649 None,
650 None,
651 )
652 .unwrap();
653 assert!(!options.env.contains_key("CLAUDE_CODE_ENABLE_CFC"));
654 assert!(!options.env.contains_key("ANTHROPIC_API_KEY"));
655 assert!(!options.env.contains_key("ANTHROPIC_AUTH_TOKEN"));
656 }
657
658 #[tokio::test]
659 async fn chrome_enabled_wires_cfc_env_and_authorizes_browser_tools() {
660 let options = build_options(
661 &ClaudeCodeConfig {
662 enable_claude_in_chrome: Some(true),
663 ..ClaudeCodeConfig::default()
664 },
665 &chrome_request(),
666 None,
667 None,
668 None,
669 )
670 .unwrap();
671 assert_eq!(
674 options
675 .env
676 .get("CLAUDE_CODE_ENABLE_CFC")
677 .map(String::as_str),
678 Some("1")
679 );
680 assert_eq!(
681 options.env.get("ANTHROPIC_API_KEY").map(String::as_str),
682 Some("")
683 );
684 assert_eq!(
685 options.env.get("ANTHROPIC_AUTH_TOKEN").map(String::as_str),
686 Some("")
687 );
688
689 let can_use_tool = options.can_use_tool.expect("permission callback set");
690 let allowed = can_use_tool
691 .call(
692 "mcp__claude-in-chrome__navigate".to_string(),
693 serde_json::Map::new(),
694 claude_code_sdk_rust::ToolPermissionContext::default(),
695 )
696 .await
697 .unwrap();
698 assert!(matches!(allowed, PermissionResult::Allow { .. }));
699 }
700
701 #[tokio::test]
702 async fn chrome_disabled_callback_denies_browser_tools() {
703 let options = build_options(
704 &ClaudeCodeConfig {
705 enable_claude_in_chrome: Some(false),
706 ..ClaudeCodeConfig::default()
707 },
708 &chrome_request(),
709 None,
710 None,
711 None,
712 )
713 .unwrap();
714 let can_use_tool = options.can_use_tool.expect("permission callback set");
715 let denied = can_use_tool
716 .call(
717 "mcp__claude-in-chrome__navigate".to_string(),
718 serde_json::Map::new(),
719 claude_code_sdk_rust::ToolPermissionContext::default(),
720 )
721 .await
722 .unwrap();
723 assert!(matches!(denied, PermissionResult::Deny { .. }));
724 }
725
726 #[test]
727 fn resume_session_id_is_passed_through_to_options() {
728 let request = AgentInferenceRequest {
729 model: ModelSelection {
730 provider: "claude-code".to_string(),
731 model: "sonnet".to_string(),
732 },
733 instructions: InstructionBundle::default(),
734 transcript: Vec::new(),
735 tools: Vec::new(),
736 tool_choice: ToolChoice::None,
737 reasoning: ReasoningConfig::default(),
738 output: OutputConfig::default(),
739 runtime: RuntimeHints {
740 hosted_web_search: HostedWebSearchConfig::disabled(),
741 ..RuntimeHints::default()
742 },
743 metadata: serde_json::json!({}),
744 };
745
746 let options = build_options(
747 &ClaudeCodeConfig::default(),
748 &request,
749 None,
750 None,
751 Some("session-123"),
752 )
753 .unwrap();
754 assert_eq!(options.resume.as_deref(), Some("session-123"));
755
756 let options = build_options(
758 &ClaudeCodeConfig::default(),
759 &request,
760 None,
761 None,
762 Some(" "),
763 )
764 .unwrap();
765 assert!(options.resume.is_none());
766 }
767
768 #[test]
769 fn repairs_raw_string_sdk_mcp_input_for_single_required_property() {
770 let schema = serde_json::json!({
771 "type": "object",
772 "properties": {
773 "path": { "type": "string" }
774 },
775 "required": ["path"],
776 "additionalProperties": false
777 });
778
779 assert_eq!(
780 repair_sdk_mcp_input_for_tool(
781 serde_json::json!("crates/roder-ext-claude-code"),
782 &schema,
783 "",
784 "",
785 ),
786 serde_json::json!({"path": "crates/roder-ext-claude-code"})
787 );
788 assert_eq!(
789 repair_sdk_mcp_input_for_tool(
790 serde_json::json!({"path": "Cargo.toml"}),
791 &schema,
792 "",
793 "",
794 ),
795 serde_json::json!({"path": "Cargo.toml"})
796 );
797 assert_eq!(
798 repair_sdk_mcp_input_for_tool(
799 serde_json::json!({"file_path": "README.md"}),
800 &schema,
801 "",
802 "",
803 ),
804 serde_json::json!({"path": "README.md"})
805 );
806 }
807
808 #[test]
809 fn repairs_array_sdk_mcp_input_for_command_property() {
810 let schema = serde_json::json!({
811 "type": "object",
812 "properties": {
813 "command": { "type": "string" }
814 },
815 "required": ["command"],
816 "additionalProperties": false
817 });
818
819 assert_eq!(
820 repair_sdk_mcp_input_for_tool(serde_json::json!(["ls", "-la"]), &schema, "", ""),
821 serde_json::json!({"command": "ls -la"})
822 );
823 }
824
825 #[test]
826 fn maps_claude_native_alias_inputs_to_roder_arguments() {
827 let grep_schema = serde_json::json!({
828 "type": "object",
829 "properties": {
830 "query": { "type": "string" },
831 "path": { "type": "string" }
832 },
833 "required": ["query"],
834 "additionalProperties": false
835 });
836 assert_eq!(
837 repair_sdk_mcp_input_for_tool(
838 serde_json::json!({"pattern": "TokenUsage", "path": "crates", "include": "*.rs"}),
839 &grep_schema,
840 "Grep",
841 "grep",
842 ),
843 serde_json::json!({"query": "TokenUsage", "path": "crates"})
844 );
845
846 let read_schema = serde_json::json!({
847 "type": "object",
848 "properties": {"path": {"type": "string"}},
849 "required": ["path"],
850 "additionalProperties": false
851 });
852 assert_eq!(
853 repair_sdk_mcp_input_for_tool(
854 serde_json::json!({"file_path": "README.md"}),
855 &read_schema,
856 "Read",
857 "read_file",
858 ),
859 serde_json::json!({"path": "README.md"})
860 );
861 }
862}