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 result: "ok".to_string(),
540 is_error: false,
541 })
542 }
543 }
544
545 #[test]
546 fn registers_roder_tools_as_sdk_mcp_server() {
547 let mut request = AgentInferenceRequest {
548 model: ModelSelection {
549 provider: "claude-code".to_string(),
550 model: "sonnet".to_string(),
551 },
552 instructions: InstructionBundle::default(),
553 transcript: Vec::new(),
554 tools: vec![ToolSpec {
555 name: "grep".to_string(),
556 description: "Search text".to_string(),
557 parameters: serde_json::json!({
558 "type": "object",
559 "properties": {"query": {"type": "string"}},
560 "required": ["query"],
561 "additionalProperties": false
562 }),
563 }],
564 tool_choice: ToolChoice::Auto,
565 reasoning: ReasoningConfig::default(),
566 output: OutputConfig::default(),
567 runtime: RuntimeHints {
568 hosted_web_search: HostedWebSearchConfig::disabled(),
569 ..RuntimeHints::default()
570 },
571 metadata: serde_json::json!({}),
572 };
573
574 let options = build_options(
575 &ClaudeCodeConfig::default(),
576 &request,
577 Some(Arc::new(FakeToolExecutor)),
578 None,
579 None,
580 )
581 .unwrap();
582
583 assert!(options.mcp_servers.contains_key("roder"));
584 assert_eq!(
585 options.allowed_tools,
586 vec!["mcp__roder__grep", "mcp__roder__Grep"]
587 );
588 let server = options.sdk_mcp_servers.get("roder").unwrap();
589 let mut names = server
590 .list_tools()
591 .into_iter()
592 .map(|tool| tool.name.clone())
593 .collect::<Vec<_>>();
594 names.sort();
595 assert_eq!(names, vec!["Grep", "grep"]);
596
597 assert!(options.tools_set);
600 assert!(options.tools.is_empty());
601
602 request.tool_choice = ToolChoice::None;
603 let options =
604 build_options(&ClaudeCodeConfig::default(), &request, None, None, None).unwrap();
605 assert!(options.sdk_mcp_servers.is_empty());
606 assert!(options.allowed_tools.is_empty());
607 assert!(!options.tools_set);
609 }
610
611 fn chrome_request() -> AgentInferenceRequest {
612 AgentInferenceRequest {
613 model: ModelSelection {
614 provider: "claude-code".to_string(),
615 model: "opus".to_string(),
616 },
617 instructions: InstructionBundle::default(),
618 transcript: Vec::new(),
619 tools: Vec::new(),
620 tool_choice: ToolChoice::None,
621 reasoning: ReasoningConfig::default(),
622 output: OutputConfig::default(),
623 runtime: RuntimeHints {
624 hosted_web_search: HostedWebSearchConfig::disabled(),
625 ..RuntimeHints::default()
626 },
627 metadata: serde_json::json!({}),
628 }
629 }
630
631 #[test]
632 fn classifies_claude_in_chrome_tools() {
633 assert!(is_claude_in_chrome_tool("mcp__claude-in-chrome__navigate"));
634 assert!(is_claude_in_chrome_tool("mcp__Claude_in_Chrome__read_page"));
635 assert!(!is_claude_in_chrome_tool("mcp__roder__shell"));
636 assert!(!is_claude_in_chrome_tool("Bash"));
637 }
638
639 #[test]
640 fn chrome_disabled_sets_no_chrome_env() {
641 let options = build_options(
642 &ClaudeCodeConfig {
643 enable_claude_in_chrome: Some(false),
644 ..ClaudeCodeConfig::default()
645 },
646 &chrome_request(),
647 None,
648 None,
649 None,
650 )
651 .unwrap();
652 assert!(!options.env.contains_key("CLAUDE_CODE_ENABLE_CFC"));
653 assert!(!options.env.contains_key("ANTHROPIC_API_KEY"));
654 assert!(!options.env.contains_key("ANTHROPIC_AUTH_TOKEN"));
655 }
656
657 #[tokio::test]
658 async fn chrome_enabled_wires_cfc_env_and_authorizes_browser_tools() {
659 let options = build_options(
660 &ClaudeCodeConfig {
661 enable_claude_in_chrome: Some(true),
662 ..ClaudeCodeConfig::default()
663 },
664 &chrome_request(),
665 None,
666 None,
667 None,
668 )
669 .unwrap();
670 assert_eq!(
673 options
674 .env
675 .get("CLAUDE_CODE_ENABLE_CFC")
676 .map(String::as_str),
677 Some("1")
678 );
679 assert_eq!(
680 options.env.get("ANTHROPIC_API_KEY").map(String::as_str),
681 Some("")
682 );
683 assert_eq!(
684 options.env.get("ANTHROPIC_AUTH_TOKEN").map(String::as_str),
685 Some("")
686 );
687
688 let can_use_tool = options.can_use_tool.expect("permission callback set");
689 let allowed = can_use_tool
690 .call(
691 "mcp__claude-in-chrome__navigate".to_string(),
692 serde_json::Map::new(),
693 claude_code_sdk_rust::ToolPermissionContext::default(),
694 )
695 .await
696 .unwrap();
697 assert!(matches!(allowed, PermissionResult::Allow { .. }));
698 }
699
700 #[tokio::test]
701 async fn chrome_disabled_callback_denies_browser_tools() {
702 let options = build_options(
703 &ClaudeCodeConfig {
704 enable_claude_in_chrome: Some(false),
705 ..ClaudeCodeConfig::default()
706 },
707 &chrome_request(),
708 None,
709 None,
710 None,
711 )
712 .unwrap();
713 let can_use_tool = options.can_use_tool.expect("permission callback set");
714 let denied = can_use_tool
715 .call(
716 "mcp__claude-in-chrome__navigate".to_string(),
717 serde_json::Map::new(),
718 claude_code_sdk_rust::ToolPermissionContext::default(),
719 )
720 .await
721 .unwrap();
722 assert!(matches!(denied, PermissionResult::Deny { .. }));
723 }
724
725 #[test]
726 fn resume_session_id_is_passed_through_to_options() {
727 let request = AgentInferenceRequest {
728 model: ModelSelection {
729 provider: "claude-code".to_string(),
730 model: "sonnet".to_string(),
731 },
732 instructions: InstructionBundle::default(),
733 transcript: Vec::new(),
734 tools: Vec::new(),
735 tool_choice: ToolChoice::None,
736 reasoning: ReasoningConfig::default(),
737 output: OutputConfig::default(),
738 runtime: RuntimeHints {
739 hosted_web_search: HostedWebSearchConfig::disabled(),
740 ..RuntimeHints::default()
741 },
742 metadata: serde_json::json!({}),
743 };
744
745 let options = build_options(
746 &ClaudeCodeConfig::default(),
747 &request,
748 None,
749 None,
750 Some("session-123"),
751 )
752 .unwrap();
753 assert_eq!(options.resume.as_deref(), Some("session-123"));
754
755 let options = build_options(
757 &ClaudeCodeConfig::default(),
758 &request,
759 None,
760 None,
761 Some(" "),
762 )
763 .unwrap();
764 assert!(options.resume.is_none());
765 }
766
767 #[test]
768 fn repairs_raw_string_sdk_mcp_input_for_single_required_property() {
769 let schema = serde_json::json!({
770 "type": "object",
771 "properties": {
772 "path": { "type": "string" }
773 },
774 "required": ["path"],
775 "additionalProperties": false
776 });
777
778 assert_eq!(
779 repair_sdk_mcp_input_for_tool(
780 serde_json::json!("crates/roder-ext-claude-code"),
781 &schema,
782 "",
783 "",
784 ),
785 serde_json::json!({"path": "crates/roder-ext-claude-code"})
786 );
787 assert_eq!(
788 repair_sdk_mcp_input_for_tool(
789 serde_json::json!({"path": "Cargo.toml"}),
790 &schema,
791 "",
792 "",
793 ),
794 serde_json::json!({"path": "Cargo.toml"})
795 );
796 assert_eq!(
797 repair_sdk_mcp_input_for_tool(
798 serde_json::json!({"file_path": "README.md"}),
799 &schema,
800 "",
801 "",
802 ),
803 serde_json::json!({"path": "README.md"})
804 );
805 }
806
807 #[test]
808 fn repairs_array_sdk_mcp_input_for_command_property() {
809 let schema = serde_json::json!({
810 "type": "object",
811 "properties": {
812 "command": { "type": "string" }
813 },
814 "required": ["command"],
815 "additionalProperties": false
816 });
817
818 assert_eq!(
819 repair_sdk_mcp_input_for_tool(serde_json::json!(["ls", "-la"]), &schema, "", ""),
820 serde_json::json!({"command": "ls -la"})
821 );
822 }
823
824 #[test]
825 fn maps_claude_native_alias_inputs_to_roder_arguments() {
826 let grep_schema = serde_json::json!({
827 "type": "object",
828 "properties": {
829 "query": { "type": "string" },
830 "path": { "type": "string" }
831 },
832 "required": ["query"],
833 "additionalProperties": false
834 });
835 assert_eq!(
836 repair_sdk_mcp_input_for_tool(
837 serde_json::json!({"pattern": "TokenUsage", "path": "crates", "include": "*.rs"}),
838 &grep_schema,
839 "Grep",
840 "grep",
841 ),
842 serde_json::json!({"query": "TokenUsage", "path": "crates"})
843 );
844
845 let read_schema = serde_json::json!({
846 "type": "object",
847 "properties": {"path": {"type": "string"}},
848 "required": ["path"],
849 "additionalProperties": false
850 });
851 assert_eq!(
852 repair_sdk_mcp_input_for_tool(
853 serde_json::json!({"file_path": "README.md"}),
854 &read_schema,
855 "Read",
856 "read_file",
857 ),
858 serde_json::json!({"path": "README.md"})
859 );
860 }
861}