Skip to main content

codeswarm_adapters/
claude.rs

1//! Native adapter for Claude Code's print-mode JSONL stream.
2
3use std::{
4    collections::{BTreeMap, BTreeSet},
5    path::PathBuf,
6    sync::{
7        Arc, Mutex,
8        atomic::{AtomicBool, Ordering},
9    },
10};
11
12use async_trait::async_trait;
13use serde_json::Value;
14use tokio::{
15    io::{AsyncBufReadExt, BufReader},
16    process::{Child, Command},
17    sync::mpsc,
18};
19
20use crate::{
21    AgentCapabilities, AgentEvent, Mode, PermissionAnswer, RosterSlot, ToolStatus, ToolUpdate,
22};
23
24use super::native::{NativeTurn, spawn_native_turn};
25use super::{
26    AdapterError, AdapterResult, AgentAdapter, CANCEL_SETTLE_TIMEOUT, drain_bounded,
27    isolate_process_group, parse_command_line, terminate_child,
28};
29
30#[derive(Debug, Default)]
31struct ParserState {
32    tools: BTreeMap<String, ToolUpdate>,
33    finished_tools: BTreeSet<String>,
34}
35
36fn content_text(value: &Value) -> Option<String> {
37    match value {
38        Value::String(text) => (!text.is_empty()).then(|| text.to_owned()),
39        Value::Array(content) => {
40            let text = content
41                .iter()
42                .filter_map(content_text)
43                .collect::<Vec<_>>()
44                .join("\n");
45            (!text.is_empty()).then_some(text)
46        }
47        Value::Object(content) => {
48            let direct = [
49                "text",
50                "stdout",
51                "stderr",
52                "error",
53                "error_code",
54                "error_message",
55                "message",
56            ]
57            .into_iter()
58            .filter_map(|key| content.get(key).and_then(Value::as_str))
59            .filter(|text| !text.is_empty())
60            .collect::<Vec<_>>()
61            .join("\n");
62            (!direct.is_empty())
63                .then_some(direct)
64                .or_else(|| content.get("content").and_then(content_text))
65        }
66        _ => None,
67    }
68}
69
70fn is_tool_use_type(kind: &str) -> bool {
71    matches!(kind, "tool_use" | "server_tool_use" | "mcp_tool_use")
72}
73
74fn is_tool_result_type(kind: &str) -> bool {
75    matches!(
76        kind,
77        "tool_result"
78            | "tool_search_tool_result"
79            | "web_fetch_tool_result"
80            | "web_search_tool_result"
81            | "code_execution_tool_result"
82            | "bash_code_execution_tool_result"
83            | "text_editor_code_execution_tool_result"
84            | "mcp_tool_result"
85    )
86}
87
88fn tool_title(block: &Value) -> String {
89    let name = block
90        .get("name")
91        .and_then(Value::as_str)
92        .filter(|name| !name.is_empty())
93        .unwrap_or("Tool call")
94        .replace('_', " ");
95    let description = block
96        .get("input")
97        .and_then(|input| input.get("description"))
98        .and_then(Value::as_str)
99        .filter(|description| !description.is_empty());
100    description.map_or(name.clone(), |description| format!("{name}: {description}"))
101}
102
103fn parse_tool_uses(slot: RosterSlot, value: &Value, state: &mut ParserState) -> Vec<AgentEvent> {
104    if value.get("type").and_then(Value::as_str) != Some("assistant") {
105        return Vec::new();
106    }
107    let Some(content) = value
108        .get("message")
109        .and_then(|message| message.get("content"))
110        .and_then(Value::as_array)
111    else {
112        return Vec::new();
113    };
114    content
115        .iter()
116        .filter(|block| {
117            block
118                .get("type")
119                .and_then(Value::as_str)
120                .is_some_and(is_tool_use_type)
121        })
122        .filter_map(|block| {
123            let id = block
124                .get("id")
125                .and_then(Value::as_str)
126                .filter(|id| !id.is_empty())?;
127            let title = tool_title(block);
128            if state.finished_tools.contains(id) {
129                return None;
130            }
131            let update = state
132                .tools
133                .entry(id.to_owned())
134                .or_insert_with(|| ToolUpdate {
135                    id: id.to_owned(),
136                    title: title.clone(),
137                    status: ToolStatus::Running,
138                    detail: None,
139                });
140            update.title = title;
141            Some(AgentEvent::Tool {
142                slot,
143                update: update.clone(),
144            })
145        })
146        .collect()
147}
148
149fn parse_stream_event(
150    slot: RosterSlot,
151    value: &Value,
152    state: &mut ParserState,
153) -> Option<AgentEvent> {
154    if value.get("type").and_then(Value::as_str) != Some("stream_event") {
155        return None;
156    }
157    let event = value.get("event")?;
158    let index = event.get("index").and_then(Value::as_u64);
159    match event.get("type").and_then(Value::as_str)? {
160        "content_block_delta" => {
161            let delta = event.get("delta")?;
162            match delta.get("type").and_then(Value::as_str)? {
163                "text_delta" => delta
164                    .get("text")
165                    .and_then(Value::as_str)
166                    .filter(|text| !text.is_empty())
167                    .map(|text| AgentEvent::Text {
168                        slot,
169                        text: text.to_owned(),
170                    }),
171                "thinking_delta" => delta
172                    .get("thinking")
173                    .and_then(Value::as_str)
174                    .filter(|text| !text.is_empty())
175                    .map(|text| AgentEvent::Thought {
176                        slot,
177                        text: text.to_owned(),
178                    }),
179                _ => None,
180            }
181        }
182        "content_block_start" => {
183            let index = index?;
184            let block = event.get("content_block")?;
185            if !block
186                .get("type")
187                .and_then(Value::as_str)
188                .is_some_and(is_tool_use_type)
189            {
190                return None;
191            }
192            let id = block
193                .get("id")
194                .and_then(Value::as_str)
195                .filter(|id| !id.is_empty())
196                .map_or_else(|| format!("claude-tool-{index}"), str::to_owned);
197            let title = tool_title(block);
198            state.finished_tools.remove(&id);
199            state.tools.insert(
200                id.clone(),
201                ToolUpdate {
202                    id: id.clone(),
203                    title: title.clone(),
204                    status: ToolStatus::Running,
205                    detail: None,
206                },
207            );
208            Some(AgentEvent::Tool {
209                slot,
210                update: ToolUpdate {
211                    id,
212                    title,
213                    status: ToolStatus::Running,
214                    detail: None,
215                },
216            })
217        }
218        "content_block_stop" => {
219            // This only closes Claude's streamed `tool_use` input block. The
220            // tool is still executing; its later `tool_result` user message
221            // carries the actual completion status and output.
222            None
223        }
224        _ => None,
225    }
226}
227
228fn parse_tool_results(slot: RosterSlot, value: &Value, state: &mut ParserState) -> Vec<AgentEvent> {
229    if value.get("type").and_then(Value::as_str) != Some("user") {
230        return Vec::new();
231    }
232    let Some(content) = value
233        .get("message")
234        .and_then(|message| message.get("content"))
235        .and_then(Value::as_array)
236    else {
237        return Vec::new();
238    };
239    content
240        .iter()
241        .filter(|block| {
242            block
243                .get("type")
244                .and_then(Value::as_str)
245                .is_some_and(is_tool_result_type)
246        })
247        .filter_map(|block| {
248            let id = block
249                .get("tool_use_id")
250                .and_then(Value::as_str)
251                .filter(|id| !id.is_empty())?;
252            let tool = state
253                .tools
254                .entry(id.to_owned())
255                .or_insert_with(|| ToolUpdate {
256                    id: id.to_owned(),
257                    title: "Tool call".into(),
258                    status: ToolStatus::Running,
259                    detail: None,
260                });
261            let result_type = block
262                .get("type")
263                .and_then(Value::as_str)
264                .unwrap_or_default();
265            let result = block.get("content");
266            let structured_result_type = result
267                .and_then(|result| result.get("type"))
268                .and_then(Value::as_str)
269                .unwrap_or_default();
270            let nonzero_exit = result.is_some_and(|result| {
271                ["return_code", "exit_code"]
272                    .into_iter()
273                    .filter_map(|key| result.get(key).and_then(Value::as_i64))
274                    .any(|code| code != 0)
275            });
276            tool.status = if block
277                .get("is_error")
278                .and_then(Value::as_bool)
279                .unwrap_or(false)
280                || result_type.ends_with("_error")
281                || structured_result_type.ends_with("_error")
282                || nonzero_exit
283            {
284                ToolStatus::Failed
285            } else {
286                ToolStatus::Completed
287            };
288            tool.detail = result.and_then(content_text);
289            state.finished_tools.insert(id.to_owned());
290            Some(AgentEvent::Tool {
291                slot,
292                update: tool.clone(),
293            })
294        })
295        .collect()
296}
297
298fn parse_tool_progress(
299    slot: RosterSlot,
300    value: &Value,
301    state: &mut ParserState,
302) -> Option<AgentEvent> {
303    if value.get("type").and_then(Value::as_str) != Some("tool_progress") {
304        return None;
305    }
306    let reported = value.get("tool_use_id").and_then(Value::as_str);
307    let parent = value.get("parent_tool_use_id").and_then(Value::as_str);
308    let id = reported
309        .filter(|id| state.tools.contains_key(*id) && !state.finished_tools.contains(*id))
310        .or_else(|| {
311            parent.filter(|id| state.tools.contains_key(*id) && !state.finished_tools.contains(*id))
312        })?;
313    let update = state.tools.get_mut(id)?;
314    update.status = ToolStatus::Running;
315    if let Some(seconds) = value.get("elapsed_time_seconds").and_then(Value::as_u64) {
316        update.detail = Some(format!("running for {seconds}s"));
317    }
318    Some(AgentEvent::Tool {
319        slot,
320        update: update.clone(),
321    })
322}
323
324fn result_text(value: &Value) -> Option<String> {
325    value
326        .get("result")
327        .and_then(Value::as_str)
328        .filter(|text| !text.is_empty())
329        .map(str::to_owned)
330}
331
332fn session_id(value: &Value) -> Option<String> {
333    value
334        .get("session_id")
335        .or_else(|| value.get("sessionId"))
336        .and_then(Value::as_str)
337        .filter(|id| !id.is_empty())
338        .map(str::to_owned)
339}
340
341#[derive(Debug)]
342pub struct ClaudeAdapter {
343    slot: RosterSlot,
344    cwd: PathBuf,
345    command: String,
346    mode: String,
347    model: Option<String>,
348    session_id: Option<String>,
349    child: Option<Child>,
350    sender: mpsc::Sender<AdapterResult<AgentEvent>>,
351    receiver: mpsc::Receiver<AdapterResult<AgentEvent>>,
352    announced_session: Arc<Mutex<Option<String>>>,
353    cancel_requested: Arc<AtomicBool>,
354}
355
356impl ClaudeAdapter {
357    pub fn new(slot: RosterSlot, cwd: PathBuf, command: impl Into<String>) -> Self {
358        let (sender, receiver) = mpsc::channel(256);
359        Self {
360            slot,
361            cwd,
362            command: command.into(),
363            mode: "bypassPermissions".into(),
364            model: None,
365            session_id: None,
366            child: None,
367            sender,
368            receiver,
369            announced_session: Arc::new(Mutex::new(None)),
370            cancel_requested: Arc::new(AtomicBool::new(false)),
371        }
372    }
373
374    pub fn with_session_id(
375        slot: RosterSlot,
376        cwd: PathBuf,
377        command: impl Into<String>,
378        session_id: impl Into<String>,
379    ) -> Self {
380        let mut adapter = Self::new(slot, cwd, command);
381        adapter.session_id = Some(session_id.into());
382        adapter
383    }
384
385    fn modes() -> Vec<Mode> {
386        [("plan", "Plan"), ("bypassPermissions", "Full Access")]
387            .into_iter()
388            .map(|(id, label)| Mode {
389                id: id.into(),
390                label: label.into(),
391            })
392            .collect()
393    }
394
395    fn models(&self) -> Vec<Mode> {
396        let mut models = [
397            ("fable", "Fable"),
398            ("opus", "Opus"),
399            ("sonnet", "Sonnet"),
400            ("haiku", "Haiku"),
401        ]
402        .into_iter()
403        .map(|(id, label)| Mode {
404            id: id.into(),
405            label: label.into(),
406        })
407        .collect::<Vec<_>>();
408        if let Some(model) = &self.model
409            && !models.iter().any(|candidate| candidate.id == *model)
410        {
411            // Claude Code has no model-discovery command. Keep its documented
412            // aliases static, but retain an explicitly configured full model
413            // ID instead of pretending the alias list is exhaustive.
414            models.push(Mode {
415                id: model.clone(),
416                label: model.clone(),
417            });
418        }
419        models
420    }
421
422    async fn emit(&self, event: AdapterResult<AgentEvent>) {
423        let _ = self.sender.send(event).await;
424    }
425}
426
427#[async_trait]
428impl AgentAdapter for ClaudeAdapter {
429    fn slot(&self) -> RosterSlot {
430        self.slot
431    }
432
433    fn display_name(&self) -> String {
434        "Claude".into()
435    }
436
437    fn session_id(&self) -> Option<String> {
438        self.session_id.clone()
439    }
440
441    fn protocol(&self) -> &'static str {
442        "native"
443    }
444
445    fn capabilities(&self) -> AgentCapabilities {
446        AgentCapabilities {
447            supports_cancel: true,
448            supports_modes: true,
449            supports_permissions: false,
450            supports_terminals: false,
451            supports_session_load: true,
452            supports_models: true,
453        }
454    }
455
456    async fn start(&mut self) -> AdapterResult<()> {
457        self.cancel_requested.store(false, Ordering::Release);
458        self.emit(Ok(AgentEvent::ModesReplaced {
459            slot: self.slot,
460            modes: Self::modes(),
461            current_mode: Some(self.mode.clone()),
462        }))
463        .await;
464        self.emit(Ok(AgentEvent::ModelsReplaced {
465            slot: self.slot,
466            config_id: "claude:model".into(),
467            models: self.models(),
468            current_model: self.model.clone(),
469        }))
470        .await;
471        self.emit(Ok(AgentEvent::Ready {
472            slot: self.slot,
473            capabilities: self.capabilities(),
474        }))
475        .await;
476        Ok(())
477    }
478
479    async fn send_prompt(&mut self, prompt: String) -> AdapterResult<()> {
480        if self.child.is_some() {
481            return Err(AdapterError::Transport(
482                "agent is already handling a turn".into(),
483            ));
484        }
485        self.cancel_requested.store(false, Ordering::Release);
486        let (program, args) = parse_command_line(&self.command)
487            .map_err(|error| AdapterError::Spawn(format!("invalid agent command: {error}")))?;
488        let mut command = Command::new(program);
489        isolate_process_group(&mut command);
490        command
491            .args(args)
492            .arg("--print")
493            .arg("--output-format")
494            .arg("stream-json")
495            .arg("--verbose")
496            .arg("--include-partial-messages")
497            .arg("--permission-prompts")
498            .arg("none")
499            .arg("--permission-mode")
500            .arg(&self.mode)
501            .current_dir(&self.cwd)
502            .env("CODESWARM_CWD", &self.cwd);
503        if self.mode == "bypassPermissions" {
504            command.arg("--allow-dangerously-skip-permissions");
505        }
506        if let Some(model) = &self.model {
507            command.arg("--model").arg(model);
508        }
509        if let Some(session_id) = &self.session_id {
510            command.arg("--resume").arg(session_id);
511        }
512        let NativeTurn {
513            child,
514            stdout,
515            stderr,
516        } = spawn_native_turn(command, prompt).await?;
517        let sender = self.sender.clone();
518        let slot = self.slot;
519        let announced = Arc::clone(&self.announced_session);
520        let cancelled = Arc::clone(&self.cancel_requested);
521        tokio::spawn(async move {
522            let stderr_task = tokio::spawn(drain_bounded(stderr, 32 * 1024));
523            let mut lines = BufReader::new(stdout).lines();
524            let mut state = ParserState::default();
525            let mut result = None;
526            let mut streamed = false;
527            while let Ok(Some(line)) = lines.next_line().await {
528                let Ok(value) = serde_json::from_str::<Value>(&line) else {
529                    continue;
530                };
531                if let Some(id) = session_id(&value)
532                    && let Ok(mut current) = announced.lock()
533                {
534                    *current = Some(id);
535                }
536                if value.get("type").and_then(Value::as_str) == Some("result") {
537                    result = Some(value.clone());
538                }
539                if let Some(event) = parse_stream_event(slot, &value, &mut state) {
540                    streamed |= matches!(event, AgentEvent::Text { .. });
541                    if sender.send(Ok(event)).await.is_err() {
542                        break;
543                    }
544                }
545                for event in parse_tool_uses(slot, &value, &mut state) {
546                    if sender.send(Ok(event)).await.is_err() {
547                        break;
548                    }
549                }
550                if let Some(event) = parse_tool_progress(slot, &value, &mut state)
551                    && sender.send(Ok(event)).await.is_err()
552                {
553                    break;
554                }
555                for event in parse_tool_results(slot, &value, &mut state) {
556                    if sender.send(Ok(event)).await.is_err() {
557                        break;
558                    }
559                }
560            }
561            let stderr = stderr_task.await.ok().unwrap_or_default();
562            let succeeded = cancelled.load(Ordering::Acquire)
563                || result.as_ref().is_some_and(|value| {
564                    value.get("subtype").and_then(Value::as_str) == Some("success")
565                        && !value
566                            .get("is_error")
567                            .and_then(Value::as_bool)
568                            .unwrap_or(false)
569                });
570            if succeeded {
571                if !streamed && let Some(text) = result.as_ref().and_then(result_text) {
572                    let _ = sender.send(Ok(AgentEvent::Text { slot, text })).await;
573                }
574                let _ = sender.send(Ok(AgentEvent::TurnComplete { slot })).await;
575            } else {
576                let detail = result
577                    .as_ref()
578                    .and_then(result_text)
579                    .or_else(|| {
580                        result
581                            .as_ref()
582                            .and_then(|value| value.get("subtype"))
583                            .and_then(Value::as_str)
584                            .map(str::to_owned)
585                    })
586                    .or_else(|| (!stderr.is_empty()).then_some(stderr))
587                    .unwrap_or_else(|| "Claude stream ended before a successful result".into());
588                let _ = sender
589                    .send(Ok(AgentEvent::Failed {
590                        slot,
591                        started: true,
592                        detail,
593                    }))
594                    .await;
595            }
596        });
597        self.child = Some(child);
598        Ok(())
599    }
600
601    async fn cancel(&mut self) -> AdapterResult<bool> {
602        self.cancel_requested.store(true, Ordering::Release);
603        let Some(mut child) = self.child.take() else {
604            return Ok(false);
605        };
606        terminate_child(&mut child).await?;
607        let _ = tokio::time::timeout(CANCEL_SETTLE_TIMEOUT, async {
608            while let Some(event) = self.receiver.recv().await {
609                if matches!(
610                    event,
611                    Ok(AgentEvent::TurnComplete { .. } | AgentEvent::Failed { .. })
612                ) {
613                    break;
614                }
615            }
616        })
617        .await;
618        Ok(true)
619    }
620
621    async fn answer_permission(
622        &mut self,
623        _request_id: String,
624        _answer: PermissionAnswer,
625    ) -> AdapterResult<()> {
626        Err(AdapterError::Unsupported("permission answer"))
627    }
628
629    async fn set_mode(&mut self, mode: String) -> AdapterResult<()> {
630        self.mode = match mode.as_str() {
631            "codeswarm:mode:full-access"
632            | "full-access"
633            | "auto"
634            | "autopilot"
635            | "bypassPermissions" => "bypassPermissions",
636            "codeswarm:mode:plan" | "readonly" | "plan" => "plan",
637            _ => return Err(AdapterError::Unsupported("requested Claude mode")),
638        }
639        .into();
640        self.emit(Ok(AgentEvent::ModesReplaced {
641            slot: self.slot,
642            modes: Self::modes(),
643            current_mode: Some(self.mode.clone()),
644        }))
645        .await;
646        Ok(())
647    }
648
649    async fn set_model(&mut self, model: String) -> AdapterResult<()> {
650        let documented_alias = self.models().iter().any(|candidate| candidate.id == model);
651        let full_model_id = model.strip_prefix("claude-").is_some_and(|suffix| {
652            !suffix.is_empty()
653                && suffix
654                    .bytes()
655                    .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-')
656        });
657        if !documented_alias && !full_model_id {
658            return Err(AdapterError::Protocol(
659                "model must be a documented Claude Code alias or a full claude-* model ID".into(),
660            ));
661        }
662        self.model = Some(model.clone());
663        self.emit(Ok(AgentEvent::ModelUpdated {
664            slot: self.slot,
665            current_model: model,
666        }))
667        .await;
668        Ok(())
669    }
670
671    async fn reload(&mut self) -> AdapterResult<()> {
672        self.stop().await?;
673        self.start().await
674    }
675
676    async fn stop(&mut self) -> AdapterResult<()> {
677        let _ = self.cancel().await?;
678        Ok(())
679    }
680
681    async fn next_event(&mut self) -> Option<AdapterResult<AgentEvent>> {
682        let event = self.receiver.recv().await;
683        if matches!(
684            event.as_ref(),
685            Some(Ok(
686                AgentEvent::TurnComplete { .. } | AgentEvent::Failed { .. }
687            ))
688        ) {
689            if self.session_id.is_none()
690                && let Ok(session) = self.announced_session.lock()
691            {
692                self.session_id = session.clone();
693            }
694            if let Some(mut child) = self.child.take() {
695                let _ = child.wait().await;
696            }
697        }
698        event
699    }
700}
701
702#[cfg(test)]
703mod tests {
704    use super::{
705        ClaudeAdapter, ParserState, parse_stream_event, parse_tool_progress, parse_tool_results,
706        parse_tool_uses,
707    };
708    use crate::{AgentAdapter, AgentEvent, ToolStatus};
709    use serde_json::json;
710
711    #[test]
712    fn parses_claude_text_thought_and_tool_events() {
713        let mut state = ParserState::default();
714        assert!(matches!(
715            parse_stream_event(2, &json!({"type":"stream_event","event":{"type":"content_block_delta","delta":{"type":"text_delta","text":"hello"}}}), &mut state),
716            Some(AgentEvent::Text { slot: 2, text }) if text == "hello"
717        ));
718        assert!(matches!(
719            parse_stream_event(2, &json!({"type":"stream_event","event":{"type":"content_block_delta","delta":{"type":"thinking_delta","thinking":"check"}}}), &mut state),
720            Some(AgentEvent::Thought { text, .. }) if text == "check"
721        ));
722        assert!(matches!(
723            parse_stream_event(2, &json!({"type":"stream_event","event":{"type":"content_block_start","index":1,"content_block":{"type":"tool_use","id":"tool-1","name":"Read"}}}), &mut state),
724            Some(AgentEvent::Tool { update, .. }) if update.status == ToolStatus::Running && update.title == "Read"
725        ));
726        assert_eq!(
727            parse_stream_event(
728                2,
729                &json!({"type":"stream_event","event":{"type":"content_block_stop","index":1}}),
730                &mut state
731            ),
732            None
733        );
734
735        let completed = parse_tool_results(
736            2,
737            &json!({
738                "type": "user",
739                "message": {
740                    "content": [{
741                        "type": "tool_result",
742                        "tool_use_id": "tool-1",
743                        "content": [{"type": "text", "text": "file contents"}]
744                    }]
745                }
746            }),
747            &mut state,
748        );
749        assert!(matches!(
750            completed.as_slice(),
751            [AgentEvent::Tool { update, .. }]
752                if update.status == ToolStatus::Completed
753                    && update.title == "Read"
754                    && update.detail.as_deref() == Some("file contents")
755        ));
756    }
757
758    #[test]
759    fn failed_claude_tool_result_preserves_error_detail() {
760        let mut state = ParserState::default();
761        let failed = parse_tool_results(
762            4,
763            &json!({
764                "type": "user",
765                "message": {
766                    "content": [{
767                        "type": "tool_result",
768                        "tool_use_id": "tool-without-partial-start",
769                        "is_error": true,
770                        "content": "permission denied"
771                    }]
772                }
773            }),
774            &mut state,
775        );
776        assert!(matches!(
777            failed.as_slice(),
778            [AgentEvent::Tool { slot: 4, update }]
779                if update.status == ToolStatus::Failed
780                    && update.id == "tool-without-partial-start"
781                    && update.detail.as_deref() == Some("permission denied")
782        ));
783    }
784
785    #[test]
786    fn consolidated_tool_use_and_sdk_result_variants_match_claude_acp() {
787        let mut state = ParserState::default();
788        let refined = parse_tool_uses(
789            1,
790            &json!({
791                "type": "assistant",
792                "message": {"content": [{
793                    "type": "server_tool_use",
794                    "id": "server-tool",
795                    "name": "bash_code_execution",
796                    "input": {"description": "Compile the project"}
797                }]}
798            }),
799            &mut state,
800        );
801        assert!(matches!(
802            refined.as_slice(),
803            [AgentEvent::Tool { update, .. }]
804                if update.status == ToolStatus::Running
805                    && update.title == "bash code execution: Compile the project"
806        ));
807
808        let completed = parse_tool_results(
809            1,
810            &json!({
811                "type": "user",
812                "message": {"content": [{
813                    "type": "bash_code_execution_tool_result",
814                    "tool_use_id": "server-tool",
815                    "content": {
816                        "type": "bash_code_execution_result",
817                        "stdout": "partial output",
818                        "stderr": "compiler error",
819                        "return_code": 2
820                    }
821                }]}
822            }),
823            &mut state,
824        );
825        assert!(matches!(
826            completed.as_slice(),
827            [AgentEvent::Tool { update, .. }]
828                if update.status == ToolStatus::Failed
829                    && update.title == "bash code execution: Compile the project"
830                    && update.detail.as_deref() == Some("partial output\ncompiler error")
831        ));
832        assert!(
833            parse_tool_progress(
834                1,
835                &json!({
836                    "type": "tool_progress",
837                    "tool_use_id": "server-tool-heartbeat-1",
838                    "parent_tool_use_id": "server-tool",
839                    "tool_name": "bash_code_execution",
840                    "elapsed_time_seconds": 30
841                }),
842                &mut state,
843            )
844            .is_none(),
845            "a late heartbeat must not reopen a completed tool"
846        );
847    }
848
849    #[test]
850    fn tool_progress_uses_the_real_parent_id_without_creating_phantom_tools() {
851        let mut state = ParserState::default();
852        let _ = parse_stream_event(
853            3,
854            &json!({
855                "type": "stream_event",
856                "event": {
857                    "type": "content_block_start",
858                    "index": 0,
859                    "content_block": {"type": "tool_use", "id": "tool-3", "name": "Bash"}
860                }
861            }),
862            &mut state,
863        );
864        assert!(matches!(
865            parse_tool_progress(
866                3,
867                &json!({
868                    "type": "tool_progress",
869                    "tool_use_id": "tool-3-heartbeat-1",
870                    "parent_tool_use_id": "tool-3",
871                    "tool_name": "Bash",
872                    "elapsed_time_seconds": 30
873                }),
874                &mut state,
875            ),
876            Some(AgentEvent::Tool { update, .. })
877                if update.id == "tool-3"
878                    && update.status == ToolStatus::Running
879                    && update.detail.as_deref() == Some("running for 30s")
880        ));
881        assert_eq!(state.tools.len(), 1);
882    }
883
884    #[test]
885    fn structured_sdk_error_results_are_failed_with_their_details() {
886        for (outer, inner) in [
887            ("tool_search_tool_result", "tool_search_tool_result_error"),
888            ("web_fetch_tool_result", "web_fetch_tool_result_error"),
889            ("web_search_tool_result", "web_search_tool_result_error"),
890            (
891                "code_execution_tool_result",
892                "code_execution_tool_result_error",
893            ),
894            (
895                "bash_code_execution_tool_result",
896                "bash_code_execution_tool_result_error",
897            ),
898            (
899                "text_editor_code_execution_tool_result",
900                "text_editor_code_execution_tool_result_error",
901            ),
902        ] {
903            let mut state = ParserState::default();
904            let events = parse_tool_results(
905                5,
906                &json!({
907                    "type": "user",
908                    "message": {"content": [{
909                        "type": outer,
910                        "tool_use_id": "failed-tool",
911                        "content": {
912                            "type": inner,
913                            "error_code": "execution_failed",
914                            "error_message": "provider rejected the tool"
915                        }
916                    }]}
917                }),
918                &mut state,
919            );
920            assert!(matches!(
921                events.as_slice(),
922                [AgentEvent::Tool { update, .. }]
923                    if update.status == ToolStatus::Failed
924                        && update.detail.as_deref()
925                            == Some("execution_failed\nprovider rejected the tool")
926            ));
927        }
928    }
929
930    #[tokio::test]
931    async fn advertises_only_noninteractive_modes_and_accepts_full_model_ids() {
932        let mut adapter = ClaudeAdapter::new(0, std::env::current_dir().unwrap(), "claude");
933        assert_eq!(
934            ClaudeAdapter::modes()
935                .into_iter()
936                .map(|mode| mode.id)
937                .collect::<Vec<_>>(),
938            ["plan", "bypassPermissions"]
939        );
940        assert_eq!(
941            adapter
942                .models()
943                .into_iter()
944                .map(|model| model.id)
945                .collect::<Vec<_>>(),
946            ["fable", "opus", "sonnet", "haiku"]
947        );
948        assert!(adapter.set_mode("manual".into()).await.is_err());
949        adapter
950            .set_model("claude-sonnet-4-5-20250929".into())
951            .await
952            .unwrap();
953        assert!(
954            adapter
955                .models()
956                .iter()
957                .any(|model| model.id == "claude-sonnet-4-5-20250929")
958        );
959        assert!(adapter.set_model("made-up-alias".into()).await.is_err());
960    }
961
962    #[tokio::test]
963    async fn native_claude_process_captures_session_and_resumes() {
964        let args_path =
965            std::env::temp_dir().join(format!("codeswarm-claude-args-{}", std::process::id()));
966        let stdin_path =
967            std::env::temp_dir().join(format!("codeswarm-claude-stdin-{}", std::process::id()));
968        let script_path =
969            std::env::temp_dir().join(format!("codeswarm-claude-script-{}", std::process::id()));
970        std::fs::write(
971            &script_path,
972            format!(
973                "printf '%s\\n' \"$*\" >> '{}'\nsed -n 'p' >> '{}'\nprintf '\\n' >> '{}'\nprintf '%s\\n' '{{\"type\":\"system\",\"session_id\":\"session-native\"}}' '{{\"type\":\"result\",\"subtype\":\"success\",\"result\":\"hello\",\"session_id\":\"session-native\"}}'\n",
974                args_path.display(),
975                stdin_path.display(),
976                stdin_path.display()
977            ),
978        )
979        .unwrap();
980        let mut adapter = ClaudeAdapter::new(
981            0,
982            std::env::current_dir().unwrap(),
983            format!("sh {}", script_path.display()),
984        );
985        adapter.start().await.unwrap();
986        assert!(matches!(
987            adapter.next_event().await,
988            Some(Ok(AgentEvent::ModesReplaced { .. }))
989        ));
990        assert!(matches!(
991            adapter.next_event().await,
992            Some(Ok(AgentEvent::ModelsReplaced { .. }))
993        ));
994        assert!(matches!(
995            adapter.next_event().await,
996            Some(Ok(AgentEvent::Ready { .. }))
997        ));
998        adapter.send_prompt("-first prompt".into()).await.unwrap();
999        assert!(
1000            matches!(adapter.next_event().await, Some(Ok(AgentEvent::Text { text, .. })) if text == "hello")
1001        );
1002        assert!(matches!(
1003            adapter.next_event().await,
1004            Some(Ok(AgentEvent::TurnComplete { .. }))
1005        ));
1006        assert_eq!(adapter.session_id(), Some("session-native".into()));
1007        adapter.send_prompt("second prompt".into()).await.unwrap();
1008        assert!(matches!(
1009            adapter.next_event().await,
1010            Some(Ok(AgentEvent::Text { .. }))
1011        ));
1012        assert!(matches!(
1013            adapter.next_event().await,
1014            Some(Ok(AgentEvent::TurnComplete { .. }))
1015        ));
1016        let args = std::fs::read_to_string(&args_path).unwrap();
1017        assert!(args.contains("--resume session-native"), "{args}");
1018        assert!(!args.contains("first prompt"), "{args}");
1019        assert!(!args.contains("second prompt"), "{args}");
1020        assert_eq!(
1021            std::fs::read_to_string(&stdin_path).unwrap(),
1022            "-first prompt\nsecond prompt\n"
1023        );
1024        adapter.stop().await.unwrap();
1025        let _ = std::fs::remove_file(args_path);
1026        let _ = std::fs::remove_file(stdin_path);
1027        let _ = std::fs::remove_file(script_path);
1028    }
1029
1030    #[tokio::test]
1031    async fn native_claude_reload_reaps_a_silent_turn() {
1032        let mut adapter =
1033            ClaudeAdapter::new(0, std::env::current_dir().unwrap(), "sh -c 'sleep 10'");
1034        adapter.start().await.unwrap();
1035        for _ in 0..3 {
1036            assert!(adapter.next_event().await.is_some());
1037        }
1038        adapter.send_prompt("stuck".into()).await.unwrap();
1039        tokio::time::timeout(std::time::Duration::from_secs(5), adapter.reload())
1040            .await
1041            .expect("reload should not hang")
1042            .expect("reload should succeed");
1043        assert!(adapter.child.is_none());
1044        adapter.stop().await.unwrap();
1045    }
1046}