1use std::path::PathBuf;
24use std::process::Stdio;
25use std::time::Duration;
26
27use async_trait::async_trait;
28use nyx_agent_types::agent::{
29 classify_tool_use, AgentResult, AgentTask, AiError, Budget, CacheStats, CostEstimate,
30 ExtractedAgentResult, HaltReason, Prompt, Response, TokenUsage,
31};
32use nyx_agent_types::event::{AgentEvent, AiEvent, EventSink};
33use semver::Version;
34use serde::Deserialize;
35use tokio::io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader};
36use tokio::process::{ChildStderr, Command};
37use tokio::task::JoinHandle;
38
39use crate::runtime::{AiRuntime, SharedBudgetTracker};
40
41pub const DEFAULT_CLAUDE_BINARY: &str = "claude";
44const FALLBACK_CLAUDE_BINARY: &str = "claude-code";
45
46pub const MINIMUM_CLAUDE_VERSION: &str = "1.0.0";
53
54const MAX_STDERR_CAPTURE_BYTES: usize = 64 * 1024;
60
61#[derive(Clone, Debug)]
65pub struct ClaudeBinary {
66 pub path: PathBuf,
67 pub version: String,
68}
69
70pub async fn detect_claude_binary() -> Result<ClaudeBinary, AiError> {
74 let path = which::which(DEFAULT_CLAUDE_BINARY)
75 .or_else(|_| which::which(FALLBACK_CLAUDE_BINARY))
76 .map_err(|_| {
77 AiError::AdapterUnavailable(format!(
78 "`{DEFAULT_CLAUDE_BINARY}` (or `{FALLBACK_CLAUDE_BINARY}`) not on PATH"
79 ))
80 })?;
81
82 let output = Command::new(&path)
83 .arg("--version")
84 .stdout(Stdio::piped())
85 .stderr(Stdio::piped())
86 .output()
87 .await
88 .map_err(|e| {
89 AiError::AdapterUnavailable(format!(
90 "failed to invoke {} --version: {e}",
91 path.display()
92 ))
93 })?;
94
95 if !output.status.success() {
96 return Err(AiError::AdapterUnavailable(format!(
97 "{} --version exited {}: {}",
98 path.display(),
99 output.status,
100 String::from_utf8_lossy(&output.stderr).trim()
101 )));
102 }
103
104 let version = String::from_utf8_lossy(&output.stdout).trim().to_string();
105 let parsed = parse_claude_version(&version).ok_or_else(|| {
106 AiError::AdapterUnavailable(format!(
107 "claude-code version output `{version}` could not be parsed as semver"
108 ))
109 })?;
110 let floor = Version::parse(MINIMUM_CLAUDE_VERSION).expect("built-in floor parses");
111 if parsed < floor {
112 return Err(AiError::AdapterUnavailable(format!(
113 "claude-code v{parsed} below required minimum v{floor}"
114 )));
115 }
116 Ok(ClaudeBinary { path, version })
117}
118
119fn parse_claude_version(raw: &str) -> Option<Version> {
124 let candidate = raw.split_whitespace().next()?;
125 Version::parse(candidate).ok()
126}
127
128#[derive(Clone)]
130pub struct ClaudeCodeAdapter {
131 binary: ClaudeBinary,
132 tracker: SharedBudgetTracker,
133 default_model: String,
134 timeout: Duration,
137}
138
139impl ClaudeCodeAdapter {
140 pub fn new(binary: ClaudeBinary, tracker: SharedBudgetTracker) -> Self {
144 Self {
145 binary,
146 tracker,
147 default_model: "claude-opus-4-7".to_string(),
148 timeout: Duration::from_secs(15 * 60),
149 }
150 }
151
152 pub async fn discover(tracker: SharedBudgetTracker) -> Result<Self, AiError> {
154 let binary = detect_claude_binary().await?;
155 Ok(Self::new(binary, tracker))
156 }
157
158 pub fn with_default_model(mut self, model: impl Into<String>) -> Self {
159 self.default_model = model.into();
160 self
161 }
162
163 pub fn with_timeout(mut self, timeout: Duration) -> Self {
164 self.timeout = timeout;
165 self
166 }
167
168 pub fn binary(&self) -> &ClaudeBinary {
169 &self.binary
170 }
171}
172
173#[async_trait]
174impl AiRuntime for ClaudeCodeAdapter {
175 fn name(&self) -> &'static str {
176 "claude-code"
177 }
178
179 fn default_model(&self) -> &str {
180 &self.default_model
181 }
182
183 fn supports_agent_loop(&self) -> bool {
184 true
185 }
186
187 fn supports_prompt_cache(&self) -> bool {
188 true
189 }
190
191 fn supports_deterministic_sampling(&self) -> bool {
192 false
193 }
194
195 async fn one_shot(
196 &self,
197 prompt: Prompt,
198 budget: Budget,
199 sink: EventSink,
200 ) -> Result<Response, AiError> {
201 let model = prompt.model.clone().unwrap_or_else(|| self.default_model.clone());
202
203 let spent_before = self.tracker.current_spend(&budget.run_id, budget.kind).await?;
207 let tracker_cap = self.tracker.cap(&budget.run_id, budget.kind).await?;
208 let cap = effective_cap(tracker_cap, budget.cap_usd_micros);
209 if spent_before > cap {
210 let _ = sink.send(AgentEvent::Ai {
211 data: AiEvent::TaskHalted {
212 task_id: prompt.task_id.clone(),
213 reason: HaltReason::BudgetCapReached,
214 },
215 });
216 return Err(AiError::BudgetExceeded {
217 cap_usd_micros: cap,
218 spent_usd_micros: spent_before,
219 });
220 }
221
222 let prompt_body = render_one_shot_prompt(&prompt);
223 let mut child = Command::new(&self.binary.path)
224 .arg("--print")
225 .arg("--output-format")
226 .arg("stream-json")
227 .arg("--verbose")
228 .arg("--max-turns")
229 .arg("1")
230 .arg("--model")
231 .arg(&model)
232 .arg("--dangerously-skip-permissions")
235 .stdin(Stdio::piped())
236 .stdout(Stdio::piped())
237 .stderr(Stdio::piped())
238 .kill_on_drop(true)
239 .spawn()
240 .map_err(|e| AiError::Transport(format!("spawn claude: {e}")))?;
241
242 if let Some(mut stdin) = child.stdin.take() {
243 stdin
244 .write_all(prompt_body.as_bytes())
245 .await
246 .map_err(|e| AiError::Transport(format!("write stdin: {e}")))?;
247 stdin.shutdown().await.map_err(|e| AiError::Transport(format!("close stdin: {e}")))?;
248 }
249
250 let stdout = child
251 .stdout
252 .take()
253 .ok_or_else(|| AiError::Transport("claude stdout missing".to_string()))?;
254 let stderr = child
255 .stderr
256 .take()
257 .ok_or_else(|| AiError::Transport("claude stderr missing".to_string()))?;
258 let stderr_handle = spawn_stderr_drain(stderr);
259 let mut reader = BufReader::new(stdout).lines();
260
261 let mut content = String::new();
262 let mut usage = TokenUsage { input_tokens: 0, output_tokens: 0 };
263 let mut cache = CacheStats { cache_creation_tokens: 0, cache_read_tokens: 0 };
264 let mut cost_usd_micros: i64 = 0;
265 let mut reported_model: Option<String> = None;
266
267 let read_loop = async {
268 while let Some(line) = reader
269 .next_line()
270 .await
271 .map_err(|e| AiError::Transport(format!("read stdout: {e}")))?
272 {
273 if line.trim().is_empty() {
274 continue;
275 }
276 let Some(event) = parse_stream_json(&line) else {
277 continue;
278 };
279 match event {
280 ClaudeEvent::Assistant(msg) => {
281 for block in msg.content {
282 if let ContentBlock::Text { text } = block {
283 content.push_str(&text);
284 let _ = sink.send(AgentEvent::Ai {
285 data: AiEvent::TokenReceived {
286 task_id: prompt.task_id.clone(),
287 token: text,
288 },
289 });
290 }
291 }
292 }
293 ClaudeEvent::Result(r) => {
294 if let Some(model) = r.model {
295 reported_model = Some(model);
296 }
297 if let Some(u) = r.usage {
298 usage.input_tokens =
299 usage.input_tokens.saturating_add(u.input_tokens.unwrap_or(0));
300 usage.output_tokens =
301 usage.output_tokens.saturating_add(u.output_tokens.unwrap_or(0));
302 cache.cache_creation_tokens = cache
303 .cache_creation_tokens
304 .saturating_add(u.cache_creation_input_tokens.unwrap_or(0));
305 cache.cache_read_tokens = cache
306 .cache_read_tokens
307 .saturating_add(u.cache_read_input_tokens.unwrap_or(0));
308 }
309 if let Some(c) = r.total_cost_usd {
310 cost_usd_micros = (c * 1_000_000.0).round() as i64;
311 }
312 if let Some(text) = r.result {
313 if content.is_empty() {
314 content = text;
315 }
316 }
317 }
318 ClaudeEvent::Other => {}
319 }
320 }
321 Ok::<(), AiError>(())
322 };
323
324 match tokio::time::timeout(self.timeout, read_loop).await {
325 Ok(Ok(())) => {}
326 Ok(Err(e)) => {
327 let _ = child.kill().await;
328 let stderr_text = await_stderr_drain(stderr_handle).await;
329 return Err(annotate_with_stderr(e, &stderr_text));
330 }
331 Err(_) => {
332 let _ = child.kill().await;
333 let _ = sink.send(AgentEvent::Ai {
334 data: AiEvent::TaskHalted {
335 task_id: prompt.task_id.clone(),
336 reason: HaltReason::OperatorCancelled,
337 },
338 });
339 let stderr_text = await_stderr_drain(stderr_handle).await;
340 let base = format!("claude one_shot timed out after {}s", self.timeout.as_secs());
341 return Err(AiError::Transport(append_stderr(&base, &stderr_text)));
342 }
343 }
344
345 let status =
346 child.wait().await.map_err(|e| AiError::Transport(format!("wait claude: {e}")))?;
347 if !status.success() {
348 let stderr_text = await_stderr_drain(stderr_handle).await;
349 let base = format!("claude exited {status}");
350 return Err(AiError::UpstreamRefused(append_stderr(&base, &stderr_text)));
351 }
352 drop(stderr_handle);
353
354 if cache.cache_creation_tokens > 0 {
355 let _ = sink.send(AgentEvent::Ai {
356 data: AiEvent::CacheMiss {
357 task_id: prompt.task_id.clone(),
358 tokens: cache.cache_creation_tokens,
359 },
360 });
361 }
362 if cache.cache_read_tokens > 0 {
363 let _ = sink.send(AgentEvent::Ai {
364 data: AiEvent::CacheHit {
365 task_id: prompt.task_id.clone(),
366 tokens: cache.cache_read_tokens,
367 },
368 });
369 }
370
371 let spent_after =
372 self.tracker.add_spend(&budget.run_id, budget.kind, cost_usd_micros).await?;
373 let _ = sink.send(AgentEvent::Ai {
374 data: AiEvent::BudgetTick {
375 task_id: prompt.task_id.clone(),
376 run_id: budget.run_id.clone(),
377 spent_usd_micros: spent_after,
378 },
379 });
380
381 let tracker_cap = self.tracker.cap(&budget.run_id, budget.kind).await?;
382 let cap = effective_cap(tracker_cap, budget.cap_usd_micros);
383 if spent_after > cap {
384 let _ = sink.send(AgentEvent::Ai {
385 data: AiEvent::TaskHalted {
386 task_id: prompt.task_id.clone(),
387 reason: HaltReason::BudgetCapReached,
388 },
389 });
390 return Err(AiError::BudgetExceeded {
391 cap_usd_micros: cap,
392 spent_usd_micros: spent_after,
393 });
394 }
395
396 Ok(Response {
397 prompt_version: prompt.prompt_version,
398 task_id: prompt.task_id,
399 model: reported_model.unwrap_or(model),
400 content,
401 usage,
402 cache: Some(cache),
403 cost_usd_micros,
404 })
405 }
406
407 async fn agent_loop(
408 &self,
409 task: AgentTask,
410 budget: Budget,
411 sink: EventSink,
412 ) -> Result<AgentResult, AiError> {
413 let spent_before = self.tracker.current_spend(&budget.run_id, budget.kind).await?;
416 if let Some(cap) = self.tracker.cap(&budget.run_id, budget.kind).await? {
417 if spent_before > cap {
418 let _ = sink.send(AgentEvent::Ai {
419 data: AiEvent::TaskHalted {
420 task_id: task.task_id.clone(),
421 reason: HaltReason::BudgetCapReached,
422 },
423 });
424 return Err(AiError::BudgetExceeded {
425 cap_usd_micros: cap,
426 spent_usd_micros: spent_before,
427 });
428 }
429 }
430
431 let scratch = tempfile::Builder::new()
432 .prefix("nyx-claude-task-")
433 .tempdir()
434 .map_err(|e| AiError::Transport(format!("scratch dir: {e}")))?;
435 let task_path = scratch.path().join("agent_task.md");
436 let task_body = render_task_markdown(&task);
437 tokio::fs::write(&task_path, &task_body)
438 .await
439 .map_err(|e| AiError::Transport(format!("write agent_task.md: {e}")))?;
440
441 let mut cmd = Command::new(&self.binary.path);
442 cmd.arg("--print")
443 .arg("--output-format")
444 .arg("stream-json")
445 .arg("--verbose")
446 .arg("--max-turns")
447 .arg(task.max_turns.to_string())
448 .arg("--dangerously-skip-permissions")
451 .stdin(Stdio::piped())
452 .stdout(Stdio::piped())
453 .stderr(Stdio::piped())
458 .kill_on_drop(true);
463 if let Some(dir) = task.working_directory.as_deref() {
464 cmd.current_dir(dir);
465 }
466 let mut child =
467 cmd.spawn().map_err(|e| AiError::Transport(format!("spawn claude: {e}")))?;
468
469 if let Some(mut stdin) = child.stdin.take() {
470 stdin
471 .write_all(task_body.as_bytes())
472 .await
473 .map_err(|e| AiError::Transport(format!("write stdin: {e}")))?;
474 stdin.shutdown().await.map_err(|e| AiError::Transport(format!("close stdin: {e}")))?;
475 }
476
477 let stdout = child
478 .stdout
479 .take()
480 .ok_or_else(|| AiError::Transport("claude stdout missing".to_string()))?;
481 let stderr = child
482 .stderr
483 .take()
484 .ok_or_else(|| AiError::Transport("claude stderr missing".to_string()))?;
485 let stderr_handle = spawn_stderr_drain(stderr);
486 let mut reader = BufReader::new(stdout).lines();
487
488 let mut turns: u32 = 0;
489 let mut final_message = String::new();
490 let mut extracted: Vec<ExtractedAgentResult> = Vec::new();
491 let mut usage = TokenUsage { input_tokens: 0, output_tokens: 0 };
492 let mut cost_usd_micros: i64 = 0;
493
494 let read_loop = async {
495 while let Some(line) = reader
496 .next_line()
497 .await
498 .map_err(|e| AiError::Transport(format!("read stdout: {e}")))?
499 {
500 if line.trim().is_empty() {
501 continue;
502 }
503 let Some(event) = parse_stream_json(&line) else {
504 continue;
505 };
506 match event {
507 ClaudeEvent::Assistant(msg) => {
508 turns = turns.saturating_add(1);
509 for block in msg.content {
510 match block {
511 ContentBlock::Text { text } => {
512 final_message.push_str(&text);
513 let _ = sink.send(AgentEvent::Ai {
514 data: AiEvent::TokenReceived {
515 task_id: task.task_id.clone(),
516 token: text,
517 },
518 });
519 }
520 ContentBlock::ToolUse { name, input, .. } => {
521 let _ = sink.send(AgentEvent::Ai {
522 data: AiEvent::ToolCallStarted {
523 task_id: task.task_id.clone(),
524 name: name.clone(),
525 },
526 });
527 if let Some(result) = classify_tool_use(&name, &input) {
528 extracted.push(result);
529 }
530 let _ = sink.send(AgentEvent::Ai {
531 data: AiEvent::ToolCallFinished {
532 task_id: task.task_id.clone(),
533 name,
534 ok: true,
535 },
536 });
537 }
538 ContentBlock::Other => {}
539 }
540 }
541 }
542 ClaudeEvent::Result(r) => {
543 if let Some(u) = r.usage {
544 usage.input_tokens =
545 usage.input_tokens.saturating_add(u.input_tokens.unwrap_or(0));
546 usage.output_tokens =
547 usage.output_tokens.saturating_add(u.output_tokens.unwrap_or(0));
548 }
549 if let Some(c) = r.total_cost_usd {
550 cost_usd_micros = (c * 1_000_000.0).round() as i64;
551 }
552 if let Some(text) = r.result {
553 if final_message.is_empty() {
554 final_message = text;
555 }
556 }
557 }
558 ClaudeEvent::Other => {}
559 }
560 }
561 Ok::<(), AiError>(())
562 };
563
564 match tokio::time::timeout(self.timeout, read_loop).await {
565 Ok(Ok(())) => {}
566 Ok(Err(e)) => {
567 let _ = child.kill().await;
568 let stderr_text = await_stderr_drain(stderr_handle).await;
569 return Err(annotate_with_stderr(e, &stderr_text));
570 }
571 Err(_) => {
572 let _ = child.kill().await;
573 let _ = sink.send(AgentEvent::Ai {
574 data: AiEvent::TaskHalted {
575 task_id: task.task_id.clone(),
576 reason: HaltReason::OperatorCancelled,
577 },
578 });
579 let stderr_text = await_stderr_drain(stderr_handle).await;
580 let base = format!("claude agent_loop timed out after {}s", self.timeout.as_secs());
581 return Err(AiError::Transport(append_stderr(&base, &stderr_text)));
582 }
583 }
584
585 let status =
586 child.wait().await.map_err(|e| AiError::Transport(format!("wait claude: {e}")))?;
587 if !status.success() {
588 let stderr_text = await_stderr_drain(stderr_handle).await;
589 let base = format!("claude exited {status}");
590 return Err(AiError::UpstreamRefused(append_stderr(&base, &stderr_text)));
591 }
592 drop(stderr_handle);
595
596 let spent_after =
597 self.tracker.add_spend(&budget.run_id, budget.kind, cost_usd_micros).await?;
598 let _ = sink.send(AgentEvent::Ai {
599 data: AiEvent::BudgetTick {
600 task_id: task.task_id.clone(),
601 run_id: budget.run_id.clone(),
602 spent_usd_micros: spent_after,
603 },
604 });
605
606 if let Some(cap) = self.tracker.cap(&budget.run_id, budget.kind).await? {
607 if spent_after > cap {
608 let _ = sink.send(AgentEvent::Ai {
609 data: AiEvent::TaskHalted {
610 task_id: task.task_id.clone(),
611 reason: HaltReason::BudgetCapReached,
612 },
613 });
614 return Err(AiError::BudgetExceeded {
615 cap_usd_micros: cap,
616 spent_usd_micros: spent_after,
617 });
618 }
619 }
620
621 extracted.extend(extract_tool_markers_from_text(&final_message));
622
623 Ok(AgentResult {
624 prompt_version: task.prompt_version,
625 task_id: task.task_id,
626 model: self.default_model.clone(),
627 final_message,
628 turns,
629 usage,
630 cache: None,
631 cost_usd_micros,
632 extracted,
633 })
634 }
635
636 fn cost_estimate(&self, _prompt: &Prompt) -> Option<CostEstimate> {
637 None
638 }
639}
640
641fn spawn_stderr_drain(mut stderr: ChildStderr) -> JoinHandle<Vec<u8>> {
642 tokio::spawn(async move {
643 let mut buf: Vec<u8> = Vec::with_capacity(1024);
644 let mut chunk = [0u8; 4096];
645 loop {
646 match stderr.read(&mut chunk).await {
647 Ok(0) => break,
648 Ok(n) => {
649 buf.extend_from_slice(&chunk[..n]);
650 if buf.len() > MAX_STDERR_CAPTURE_BYTES {
651 let drop_n = buf.len() - MAX_STDERR_CAPTURE_BYTES;
652 buf.drain(..drop_n);
653 }
654 }
655 Err(_) => break,
656 }
657 }
658 buf
659 })
660}
661
662async fn await_stderr_drain(handle: JoinHandle<Vec<u8>>) -> String {
663 let bytes = match tokio::time::timeout(Duration::from_secs(1), handle).await {
664 Ok(Ok(b)) => b,
665 _ => return String::new(),
666 };
667 String::from_utf8_lossy(&bytes).trim().to_string()
668}
669
670fn append_stderr(base: &str, stderr_text: &str) -> String {
671 if stderr_text.is_empty() {
672 base.to_string()
673 } else {
674 format!("{base}: stderr: {stderr_text}")
675 }
676}
677
678fn annotate_with_stderr(err: AiError, stderr_text: &str) -> AiError {
679 if stderr_text.is_empty() {
680 return err;
681 }
682 match err {
683 AiError::Transport(msg) => AiError::Transport(append_stderr(&msg, stderr_text)),
684 AiError::UpstreamRefused(msg) => AiError::UpstreamRefused(append_stderr(&msg, stderr_text)),
685 other => other,
686 }
687}
688
689fn render_task_markdown(task: &AgentTask) -> String {
690 let tools_block = if task.tools.is_empty() {
691 "(none; answer from context)".to_string()
692 } else {
693 task.tools.iter().map(|t| format!("- {t}")).collect::<Vec<_>>().join("\n")
694 };
695 let working_directory =
696 task.working_directory.as_deref().unwrap_or("(not set; adapter process cwd will be used)");
697 format!(
698 "# Agent task\n\
699 \n\
700 **prompt_version**: `{pv}` \n\
701 **task_id**: `{tid}` \n\
702 **working_directory**: `{working_directory}` \n\
703 **max_turns**: {max_turns}\n\
704 \n\
705 ## System\n{system}\n\
706 \n\
707 ## Objective\n{objective}\n\
708 \n\
709 ## Tools available\n{tools}\n\
710 \n\
711 When you need to record a structured Nyx Agent artifact, emit a JSON object on its own line \
712 using this shape: {{\"tool\":\"<listed tool name>\",\"input\":{{...}}}}. Use one of the \
713 listed `record_*` tool names as the `tool` value and place its arguments in `input`.\n",
714 pv = task.prompt_version,
715 tid = task.task_id,
716 working_directory = working_directory,
717 max_turns = task.max_turns,
718 system = task.system,
719 objective = task.objective,
720 tools = tools_block,
721 )
722}
723
724fn extract_tool_markers_from_text(text: &str) -> Vec<ExtractedAgentResult> {
725 let mut out = Vec::new();
726 for line in text.lines() {
727 let trimmed = line.trim();
728 if !trimmed.starts_with('{') {
729 continue;
730 }
731 let Ok(value) = serde_json::from_str::<serde_json::Value>(trimmed) else {
732 continue;
733 };
734 extract_tool_marker_value(&value, &mut out);
735 }
736 out
737}
738
739fn extract_tool_marker_value(value: &serde_json::Value, out: &mut Vec<ExtractedAgentResult>) {
740 if let Some(calls) = value.get("tool_calls").and_then(|v| v.as_array()) {
741 for call in calls {
742 extract_tool_marker_value(call, out);
743 }
744 return;
745 }
746 let Some(name) = value.get("tool").or_else(|| value.get("name")).and_then(|v| v.as_str())
747 else {
748 return;
749 };
750 let input = value.get("input").cloned().unwrap_or_else(|| serde_json::json!({}));
751 if let Some(result) = classify_tool_use(name, &input) {
752 out.push(result);
753 }
754}
755
756fn render_one_shot_prompt(prompt: &Prompt) -> String {
757 format!(
758 "# Nyx Agent one-shot task\n\
759 \n\
760 **prompt_version**: `{pv}` \n\
761 **task_id**: `{tid}` \n\
762 **max_output_tokens**: {max_tokens} \n\
763 **temperature**: {temperature}\n\
764 \n\
765 Return the requested content only. Preserve any JSON contract from the system prompt; \
766 do not add explanation, Markdown fences, or surrounding prose unless the prompt explicitly \
767 asks for them.\n\
768 \n\
769 ## System\n{system}\n\
770 \n\
771 ## User\n{user}\n",
772 pv = prompt.prompt_version,
773 tid = prompt.task_id,
774 max_tokens = prompt.max_output_tokens,
775 temperature = prompt.temperature,
776 system = prompt.system,
777 user = prompt.user,
778 )
779}
780
781fn effective_cap(tracker_cap: Option<i64>, envelope_cap: i64) -> i64 {
782 match tracker_cap {
783 Some(t) => t.min(envelope_cap),
784 None => envelope_cap,
785 }
786}
787
788pub fn parse_stream_json(line: &str) -> Option<ClaudeEvent> {
792 let raw: serde_json::Value = serde_json::from_str(line.trim()).ok()?;
793 let kind = raw.get("type")?.as_str()?;
794 match kind {
795 "assistant" => {
796 let msg = raw.get("message")?;
797 let content_raw = msg.get("content").cloned().unwrap_or(serde_json::json!([]));
798 let content: Vec<ContentBlock> = serde_json::from_value(content_raw).ok()?;
799 Some(ClaudeEvent::Assistant(AssistantMessage { content }))
800 }
801 "result" => {
802 let parsed: ResultPayload = serde_json::from_value(raw).ok()?;
803 Some(ClaudeEvent::Result(parsed))
804 }
805 _ => Some(ClaudeEvent::Other),
806 }
807}
808
809#[derive(Debug, Clone, PartialEq)]
810pub enum ClaudeEvent {
811 Assistant(AssistantMessage),
812 Result(ResultPayload),
813 Other,
814}
815
816#[derive(Debug, Clone, PartialEq)]
817pub struct AssistantMessage {
818 pub content: Vec<ContentBlock>,
819}
820
821#[derive(Debug, Clone, PartialEq, Deserialize)]
822#[serde(tag = "type")]
823pub enum ContentBlock {
824 #[serde(rename = "text")]
825 Text { text: String },
826 #[serde(rename = "tool_use")]
827 ToolUse {
828 #[serde(default)]
829 id: String,
830 name: String,
831 #[serde(default)]
832 input: serde_json::Value,
833 },
834 #[serde(other)]
835 Other,
836}
837
838#[derive(Debug, Clone, PartialEq, Deserialize)]
839pub struct ResultPayload {
840 #[serde(default)]
841 pub result: Option<String>,
842 #[serde(default)]
843 pub model: Option<String>,
844 #[serde(default)]
845 pub total_cost_usd: Option<f64>,
846 #[serde(default)]
847 pub usage: Option<ResultUsage>,
848}
849
850#[derive(Debug, Clone, PartialEq, Deserialize)]
851pub struct ResultUsage {
852 #[serde(default)]
853 pub input_tokens: Option<u32>,
854 #[serde(default)]
855 pub output_tokens: Option<u32>,
856 #[serde(default)]
857 pub cache_creation_input_tokens: Option<u32>,
858 #[serde(default)]
859 pub cache_read_input_tokens: Option<u32>,
860}
861
862#[cfg(test)]
863mod tests {
864 use std::sync::Arc;
865
866 use super::*;
867 use crate::runtime::InMemoryBudgetTracker;
868 use nyx_agent_types::agent::BudgetKind;
869 #[cfg(unix)]
870 use std::os::unix::fs::PermissionsExt;
871 use tokio::sync::broadcast;
872
873 fn sample_task() -> AgentTask {
874 AgentTask {
875 prompt_version: "phase13.test.v1".to_string(),
876 task_id: "task-claude-1".to_string(),
877 system: "you are a static analysis exploration agent".to_string(),
878 objective: "enumerate sinks in fixture.py".to_string(),
879 tools: vec!["fs.read".to_string(), "record_payload".to_string()],
880 working_directory: None,
881 max_turns: 3,
882 }
883 }
884
885 fn budget(cap_usd_micros: i64) -> Budget {
886 Budget { run_id: "run-claude-1".to_string(), kind: BudgetKind::AgentLoop, cap_usd_micros }
887 }
888
889 fn one_shot_budget(cap_usd_micros: i64) -> Budget {
890 Budget { run_id: "run-claude-1".to_string(), kind: BudgetKind::OneShot, cap_usd_micros }
891 }
892
893 fn fake_binary() -> ClaudeBinary {
894 ClaudeBinary { path: PathBuf::from("/usr/bin/false"), version: "0.0.0-test".to_string() }
895 }
896
897 fn fake_cli_script(body: &str) -> (tempfile::TempDir, ClaudeBinary) {
898 let dir = tempfile::tempdir().expect("tempdir");
899 let path = dir.path().join("claude");
900 std::fs::write(&path, body).expect("write fake claude");
901 #[cfg(unix)]
902 {
903 let mut perms = std::fs::metadata(&path).expect("metadata").permissions();
904 perms.set_mode(0o755);
905 std::fs::set_permissions(&path, perms).expect("chmod");
906 }
907 let binary = ClaudeBinary { path, version: "2.1.146-test".to_string() };
908 (dir, binary)
909 }
910
911 #[test]
912 fn capability_flags_match_phase13_contract() {
913 let tracker = Arc::new(InMemoryBudgetTracker::new()) as SharedBudgetTracker;
914 let adapter = ClaudeCodeAdapter::new(fake_binary(), tracker);
915 assert_eq!(adapter.name(), "claude-code");
916 assert!(adapter.supports_agent_loop());
917 assert!(adapter.supports_prompt_cache());
918 assert!(!adapter.supports_deterministic_sampling());
919 }
920
921 #[tokio::test]
922 async fn one_shot_parses_stream_json_and_records_budget() {
923 let script = r#"#!/bin/sh
924cat >/dev/null
925printf '%s\n' '{"type":"assistant","message":{"content":[{"type":"text","text":"{\"ok\":true}"}]}}'
926printf '%s\n' '{"type":"result","result":"{\"ok\":true}","model":"claude-sonnet-4-6","total_cost_usd":0.001234,"usage":{"input_tokens":10,"output_tokens":3,"cache_creation_input_tokens":4,"cache_read_input_tokens":5}}'
927"#;
928 let (_dir, binary) = fake_cli_script(script);
929 let tracker = Arc::new(InMemoryBudgetTracker::new());
930 let adapter = ClaudeCodeAdapter::new(binary, tracker.clone() as SharedBudgetTracker);
931 let (tx, _rx) = broadcast::channel::<AgentEvent>(4);
932 let prompt = Prompt {
933 prompt_version: "v1".to_string(),
934 task_id: "t".to_string(),
935 model: None,
936 system: "s".to_string(),
937 user: "u".to_string(),
938 max_output_tokens: 8,
939 temperature: 0.0,
940 seed: None,
941 };
942 let resp = adapter.one_shot(prompt, one_shot_budget(10_000), tx).await.expect("one_shot");
943 assert_eq!(resp.content, "{\"ok\":true}");
944 assert_eq!(resp.model, "claude-sonnet-4-6");
945 assert_eq!(resp.usage.input_tokens, 10);
946 assert_eq!(resp.usage.output_tokens, 3);
947 assert_eq!(resp.cache.unwrap().cache_creation_tokens, 4);
948 assert_eq!(resp.cost_usd_micros, 1_234);
949 assert_eq!(tracker.spent("run-claude-1", BudgetKind::OneShot), 1_234);
950 }
951
952 #[tokio::test]
953 async fn one_shot_enforces_post_call_budget_cap() {
954 let script = r#"#!/bin/sh
955cat >/dev/null
956printf '%s\n' '{"type":"result","result":"done","total_cost_usd":0.0002,"usage":{"input_tokens":1,"output_tokens":1}}'
957"#;
958 let (_dir, binary) = fake_cli_script(script);
959 let tracker = Arc::new(InMemoryBudgetTracker::new());
960 let adapter = ClaudeCodeAdapter::new(binary, tracker.clone() as SharedBudgetTracker);
961 let (tx, _rx) = broadcast::channel::<AgentEvent>(4);
962 let prompt = Prompt {
963 prompt_version: "v1".to_string(),
964 task_id: "t".to_string(),
965 model: None,
966 system: "s".to_string(),
967 user: "u".to_string(),
968 max_output_tokens: 8,
969 temperature: 0.0,
970 seed: None,
971 };
972 let err = adapter
973 .one_shot(prompt, one_shot_budget(100), tx)
974 .await
975 .expect_err("budget should trip");
976 assert!(matches!(
977 err,
978 AiError::BudgetExceeded { cap_usd_micros: 100, spent_usd_micros: 200 }
979 ));
980 assert_eq!(tracker.spent("run-claude-1", BudgetKind::OneShot), 200);
981 }
982
983 #[test]
984 fn parse_claude_version_accepts_bare_semver() {
985 let v = parse_claude_version("1.2.3").expect("bare semver parses");
986 assert_eq!(v, Version::new(1, 2, 3));
987 }
988
989 #[test]
990 fn parse_claude_version_strips_claude_code_suffix() {
991 let v = parse_claude_version("2.0.5 (Claude Code)").expect("suffixed semver parses");
992 assert_eq!(v, Version::new(2, 0, 5));
993 }
994
995 #[test]
996 fn parse_claude_version_rejects_non_semver_output() {
997 assert!(parse_claude_version("").is_none());
998 assert!(parse_claude_version("not-a-version").is_none());
999 assert!(parse_claude_version("v1.2.3").is_none());
1000 }
1001
1002 #[test]
1003 fn minimum_claude_version_constant_parses() {
1004 Version::parse(MINIMUM_CLAUDE_VERSION).expect("MINIMUM_CLAUDE_VERSION literal parses");
1005 }
1006
1007 #[tokio::test]
1008 async fn detect_returns_adapter_unavailable_when_binary_missing() {
1009 let prior = std::env::var_os("PATH");
1011 unsafe {
1014 std::env::set_var("PATH", "");
1015 }
1016 let result = detect_claude_binary().await;
1017 if let Some(p) = prior {
1018 unsafe {
1019 std::env::set_var("PATH", p);
1020 }
1021 } else {
1022 unsafe {
1023 std::env::remove_var("PATH");
1024 }
1025 }
1026 assert!(matches!(result, Err(AiError::AdapterUnavailable(_))));
1027 }
1028
1029 #[test]
1030 fn parse_stream_json_lifts_text_blocks() {
1031 let line = serde_json::json!({
1032 "type": "assistant",
1033 "message": {
1034 "content": [
1035 { "type": "text", "text": "scanning fixture.py" }
1036 ]
1037 }
1038 })
1039 .to_string();
1040 let ev = parse_stream_json(&line).expect("parsed");
1041 match ev {
1042 ClaudeEvent::Assistant(a) => {
1043 assert_eq!(a.content.len(), 1);
1044 assert!(matches!(
1045 a.content[0],
1046 ContentBlock::Text { ref text } if text == "scanning fixture.py"
1047 ));
1048 }
1049 other => panic!("expected Assistant, got {other:?}"),
1050 }
1051 }
1052
1053 #[test]
1054 fn parse_stream_json_lifts_tool_use_block() {
1055 let line = serde_json::json!({
1056 "type": "assistant",
1057 "message": {
1058 "content": [
1059 {
1060 "type": "tool_use",
1061 "id": "toolu_01",
1062 "name": "record_payload",
1063 "input": { "rule_id": "py.cmdi.os_system", "body": "ls -la; #" }
1064 }
1065 ]
1066 }
1067 })
1068 .to_string();
1069 let ev = parse_stream_json(&line).expect("parsed");
1070 let ClaudeEvent::Assistant(a) = ev else { panic!("expected Assistant") };
1071 let block = a.content.into_iter().next().expect("one block");
1072 let ContentBlock::ToolUse { name, input, .. } = block else { panic!("expected ToolUse") };
1073 assert_eq!(name, "record_payload");
1074 let extracted = classify_tool_use(&name, &input).expect("extract");
1075 assert!(matches!(
1076 extracted,
1077 ExtractedAgentResult::PayloadFound { ref rule_id, ref body }
1078 if rule_id == "py.cmdi.os_system" && body == "ls -la; #"
1079 ));
1080 }
1081
1082 #[test]
1083 fn parse_stream_json_lifts_result_event() {
1084 let line = serde_json::json!({
1085 "type": "result",
1086 "subtype": "success",
1087 "result": "done",
1088 "total_cost_usd": 0.0125,
1089 "usage": { "input_tokens": 100, "output_tokens": 50 },
1090 "model": "claude-opus-4-7"
1091 })
1092 .to_string();
1093 let ev = parse_stream_json(&line).expect("parsed");
1094 let ClaudeEvent::Result(r) = ev else { panic!("expected Result") };
1095 assert_eq!(r.result.as_deref(), Some("done"));
1096 assert_eq!(r.model.as_deref(), Some("claude-opus-4-7"));
1097 assert!((r.total_cost_usd.unwrap() - 0.0125).abs() < f64::EPSILON);
1098 assert_eq!(r.usage.unwrap().input_tokens, Some(100));
1099 }
1100
1101 #[test]
1102 fn classify_tool_use_falls_back_to_exploration_event() {
1103 let input = serde_json::json!({ "path": "src/main.rs" });
1104 let ev = classify_tool_use("fs.read", &input).expect("extracted");
1105 match ev {
1106 ExtractedAgentResult::ExplorationEvent { message } => {
1107 assert!(message.starts_with("tool fs.read"));
1108 }
1109 other => panic!("expected ExplorationEvent, got {other:?}"),
1110 }
1111 }
1112
1113 #[test]
1114 fn classify_tool_use_records_spec_and_chains() {
1115 let spec_input = serde_json::json!({
1116 "capability": "fs.write",
1117 "spec": "writes to /tmp"
1118 });
1119 let ev = classify_tool_use("record_spec", &spec_input).expect("extracted");
1120 assert!(matches!(
1121 ev,
1122 ExtractedAgentResult::SpecFound { ref capability, ref spec }
1123 if capability == "fs.write" && spec == "writes to /tmp"
1124 ));
1125
1126 let chain_input = serde_json::json!({
1127 "chain_ids": ["c1", "c2"],
1128 "rationale": "shorter sink reachability"
1129 });
1130 let ev = classify_tool_use("record_chains", &chain_input).expect("extracted");
1131 assert!(matches!(
1132 ev,
1133 ExtractedAgentResult::ChainsRanked { ref chain_ids, .. }
1134 if chain_ids == &vec!["c1".to_string(), "c2".to_string()]
1135 ));
1136 }
1137
1138 #[test]
1139 fn classify_tool_use_records_exploration_finding() {
1140 let input = serde_json::json!({
1141 "path": "<api:/api/admin/orders>",
1142 "line": 42,
1143 "cap": "AUTH_BYPASS",
1144 "rationale": "GET admin endpoint accepts unauthenticated requests",
1145 "endpoint": "GET /api/admin/orders",
1146 "suggested_payload_hint": "curl -i http://127.0.0.1:3000/api/admin/orders",
1147 });
1148 let ev = classify_tool_use("record_exploration_finding", &input).expect("extracted");
1149 assert!(matches!(
1150 ev,
1151 ExtractedAgentResult::ExplorationFinding {
1152 ref path, line: Some(42), ref cap, ref endpoint, ..
1153 } if path == "<api:/api/admin/orders>"
1154 && cap == "AUTH_BYPASS"
1155 && endpoint.as_deref() == Some("GET /api/admin/orders")
1156 ));
1157 let bad = serde_json::json!({ "path": "", "cap": "X", "rationale": "y" });
1160 assert!(classify_tool_use("record_exploration_finding", &bad).is_none());
1161 }
1162
1163 #[test]
1164 fn render_task_markdown_includes_all_fields() {
1165 let md = render_task_markdown(&sample_task());
1166 assert!(md.contains("phase13.test.v1"));
1167 assert!(md.contains("task-claude-1"));
1168 assert!(md.contains("enumerate sinks"));
1169 assert!(md.contains("- fs.read"));
1170 assert!(md.contains("- record_payload"));
1171 }
1172
1173 #[test]
1174 fn append_stderr_skips_when_empty() {
1175 assert_eq!(append_stderr("base", ""), "base");
1176 assert_eq!(append_stderr("base", "boom"), "base: stderr: boom");
1177 }
1178
1179 #[cfg(unix)]
1180 #[tokio::test]
1181 async fn agent_loop_surfaces_stderr_on_nonzero_exit() {
1182 use std::os::unix::fs::PermissionsExt;
1183
1184 let dir = tempfile::tempdir().expect("tempdir");
1185 let script = dir.path().join("fake_claude");
1186 let body = "#!/bin/sh\n\
1187 cat > /dev/null\n\
1188 printf 'API authentication failed: invalid bearer token\\n' >&2\n\
1189 exit 7\n";
1190 tokio::fs::write(&script, body).await.expect("write script");
1191 let mut perms = tokio::fs::metadata(&script).await.expect("meta").permissions();
1192 perms.set_mode(0o755);
1193 tokio::fs::set_permissions(&script, perms).await.expect("perms");
1194
1195 let tracker = Arc::new(InMemoryBudgetTracker::new()) as SharedBudgetTracker;
1196 let adapter = ClaudeCodeAdapter::new(
1197 ClaudeBinary { path: script, version: "test".to_string() },
1198 tracker,
1199 )
1200 .with_timeout(Duration::from_secs(10));
1201 let (tx, _rx) = broadcast::channel::<AgentEvent>(4);
1202 let err = adapter
1203 .agent_loop(sample_task(), budget(1_000_000), tx)
1204 .await
1205 .expect_err("expected upstream refused");
1206 match err {
1207 AiError::UpstreamRefused(msg) => {
1208 assert!(msg.starts_with("claude exited"), "missing exit prefix: {msg}");
1209 assert!(msg.contains("API authentication failed"), "stderr missing: {msg}");
1210 assert!(msg.contains("invalid bearer token"), "trailing stderr trimmed: {msg}");
1211 }
1212 other => panic!("expected UpstreamRefused, got {other:?}"),
1213 }
1214 }
1215
1216 #[cfg(unix)]
1217 #[tokio::test]
1218 async fn agent_loop_surfaces_stderr_on_timeout() {
1219 use std::os::unix::fs::PermissionsExt;
1220
1221 let dir = tempfile::tempdir().expect("tempdir");
1222 let script = dir.path().join("fake_claude_slow");
1223 let body = "#!/bin/sh\n\
1227 printf 'pre-stall diagnostic line\\n' >&2\n\
1228 exec sleep 30\n";
1229 tokio::fs::write(&script, body).await.expect("write script");
1230 let mut perms = tokio::fs::metadata(&script).await.expect("meta").permissions();
1231 perms.set_mode(0o755);
1232 tokio::fs::set_permissions(&script, perms).await.expect("perms");
1233
1234 let tracker = Arc::new(InMemoryBudgetTracker::new()) as SharedBudgetTracker;
1235 let adapter = ClaudeCodeAdapter::new(
1236 ClaudeBinary { path: script, version: "test".to_string() },
1237 tracker,
1238 )
1239 .with_timeout(Duration::from_millis(2_000));
1240 let (tx, _rx) = broadcast::channel::<AgentEvent>(4);
1241 let err = adapter
1242 .agent_loop(sample_task(), budget(1_000_000), tx)
1243 .await
1244 .expect_err("expected timeout");
1245 match err {
1246 AiError::Transport(msg) => {
1247 assert!(msg.contains("timed out"), "missing timeout prefix: {msg}");
1248 assert!(msg.contains("pre-stall diagnostic line"), "stderr missing: {msg}");
1249 }
1250 other => panic!("expected Transport timeout, got {other:?}"),
1251 }
1252 }
1253}