1use 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 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 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}