1use std::collections::HashMap;
22use std::path::PathBuf;
23use std::process::Stdio;
24use std::sync::Arc;
25use std::time::Duration;
26
27use async_trait::async_trait;
28use chrono::{DateTime, Utc};
29use serde::{Deserialize, Serialize};
30use serde_json::{json, Value};
31use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
32use tokio::process::{Child, Command};
33use tokio::sync::{mpsc, Mutex};
34use tokio::task::JoinHandle;
35use tokio_util::sync::CancellationToken;
36
37use bamboo_agent_core::{AgentEvent, TokenUsage, ToolResult};
38use bamboo_subagent::executor::{ChildExecutor, ChildOutcome, EventSink, HostBridge, SteerInbox};
39use bamboo_subagent::proto::RunSpec;
40
41const MAX_STDOUT_LINE_BYTES: usize = 10 * 1024 * 1024;
47
48const STDERR_TAIL_BYTES: usize = 16 * 1024;
51
52const TOOL_RESULT_TRUNCATE_CHARS: usize = 20_000;
56
57const GRACEFUL_EXIT_WAIT: Duration = Duration::from_secs(5);
62const SIGTERM_WAIT: Duration = Duration::from_secs(2);
64
65const APPROVAL_RELAY_TIMEOUT: Duration = Duration::from_secs(300);
71
72const ENV_ALLOWLIST: &[&str] = &[
79 "HOME", "PATH", "SHELL", "TERM", "LANG", "TMPDIR", "USER", "LOGNAME",
80];
81
82pub fn resolve_claude_code_state_dir(storage_dir: &Option<String>, child_id: &str) -> PathBuf {
91 storage_dir
93 .clone()
94 .map(PathBuf::from)
95 .unwrap_or_else(|| bamboo_config::paths::subagents_dir().join(child_id))
96}
97
98const STATE_FILE_NAME: &str = "claude-code-session.json";
102
103const HISTORY_PREAMBLE_MAX_CHARS: usize = 24_000;
106const HISTORY_PREAMBLE_MAX_MESSAGES: usize = 40;
107
108#[derive(Debug, Clone, Serialize, Deserialize)]
114struct ClaudeSessionState {
115 session_id: String,
116 workspace: Option<String>,
117 updated_at: DateTime<Utc>,
118}
119
120pub struct ClaudeCodeExecutor {
123 binary: String,
126 model: Option<String>,
127 permission_mode: Option<String>,
128 workspace: Option<String>,
132 state_dir: Option<PathBuf>,
139 inherit_user_config: bool,
144 forward_env: Vec<String>,
147 relay_timeout: Duration,
152}
153
154impl ClaudeCodeExecutor {
155 #[allow(clippy::too_many_arguments)]
156 pub fn new(
157 binary: Option<String>,
158 model: Option<String>,
159 permission_mode: Option<String>,
160 workspace: Option<String>,
161 state_dir: Option<PathBuf>,
162 inherit_user_config: bool,
163 forward_env: Vec<String>,
164 ) -> Self {
165 Self {
166 binary: binary.unwrap_or_else(|| "claude".to_string()),
167 model,
168 permission_mode,
169 workspace,
170 state_dir,
171 inherit_user_config,
172 forward_env,
173 relay_timeout: APPROVAL_RELAY_TIMEOUT,
174 }
175 }
176
177 #[cfg(test)]
181 fn with_relay_timeout_for_test(mut self, timeout: Duration) -> Self {
182 self.relay_timeout = timeout;
183 self
184 }
185
186 fn state_file_path(&self) -> Option<PathBuf> {
187 self.state_dir.as_ref().map(|dir| dir.join(STATE_FILE_NAME))
188 }
189
190 async fn read_state(&self) -> Option<ClaudeSessionState> {
194 let path = self.state_file_path()?;
195 let bytes = tokio::fs::read(&path).await.ok()?;
196 serde_json::from_slice(&bytes).ok()
197 }
198
199 async fn delete_state_file(&self) {
204 if let Some(path) = self.state_file_path() {
205 let _ = tokio::fs::remove_file(&path).await;
206 }
207 }
208
209 async fn write_state(&self, session_id: &str) {
215 let Some(dir) = &self.state_dir else { return };
216 let path = dir.join(STATE_FILE_NAME);
217 let state = ClaudeSessionState {
218 session_id: session_id.to_string(),
219 workspace: self.workspace.clone(),
220 updated_at: Utc::now(),
221 };
222 let Ok(bytes) = serde_json::to_vec_pretty(&state) else {
223 return;
224 };
225 if tokio::fs::create_dir_all(dir).await.is_err() {
226 return;
227 }
228 let tmp_path = dir.join(format!("{STATE_FILE_NAME}.{}.tmp", std::process::id()));
231 if tokio::fs::write(&tmp_path, &bytes).await.is_err() {
232 return;
233 }
234 let _ = tokio::fs::rename(&tmp_path, &path).await;
235 }
236
237 async fn resolve_resume_id(&self) -> Option<String> {
244 let state = self.read_state().await?;
245 if state.workspace != self.workspace {
246 tracing::warn!(
247 recorded = ?state.workspace,
248 current = ?self.workspace,
249 "claude code: state file workspace mismatch; falling back to history rehydration"
250 );
251 return None;
252 }
253 Some(state.session_id)
254 }
255
256 fn build_command(&self, resume_id: Option<&str>) -> Command {
260 let mut cmd = Command::new(&self.binary);
261 cmd.arg("--output-format")
262 .arg("stream-json")
263 .arg("--input-format")
264 .arg("stream-json")
265 .arg("--permission-prompt-tool")
266 .arg("stdio")
267 .arg("--replay-user-messages")
268 .arg("--verbose");
269 cmd.arg("--permission-mode")
278 .arg(self.permission_mode.as_deref().unwrap_or("default"));
279 if let Some(model) = &self.model {
280 cmd.arg("--model").arg(model);
281 }
282 if let Some(id) = resume_id {
283 cmd.arg("--resume").arg(id);
284 }
285 if !self.inherit_user_config {
292 cmd.arg("--strict-mcp-config");
293 cmd.arg("--setting-sources").arg("project");
294 }
295 cmd.env_clear();
304 for (key, value) in std::env::vars() {
305 if ENV_ALLOWLIST.contains(&key.as_str()) || key.starts_with("LC_") {
306 cmd.env(key, value);
307 }
308 }
309 for name in &self.forward_env {
310 if let Ok(value) = std::env::var(name) {
311 cmd.env(name, value);
312 }
313 }
314 cmd.env_remove("CLAUDECODE");
317 if let Some(ws) = &self.workspace {
318 cmd.current_dir(ws);
319 }
320 cmd.stdin(Stdio::piped());
321 cmd.stdout(Stdio::piped());
322 cmd.stderr(Stdio::piped());
323 cmd.kill_on_drop(true);
326 #[cfg(unix)]
327 {
328 cmd.process_group(0);
331 }
332 cmd
333 }
334
335 async fn handle_frame(
340 &self,
341 value: Value,
342 events: &EventSink,
343 write_tx: &mpsc::UnboundedSender<Value>,
344 pending: &mut HashMap<String, JoinHandle<()>>,
345 last_text: &mut String,
346 ) -> Option<ChildOutcome> {
347 let frame_type = value.get("type").and_then(Value::as_str).unwrap_or("");
348 match frame_type {
349 "system" => {
350 let session_id = value
351 .get("session_id")
352 .and_then(|v| v.as_str())
353 .unwrap_or("");
354 let model = value.get("model").and_then(|v| v.as_str()).unwrap_or("");
355 tracing::debug!(session_id, model, "claude code: session bootstrap");
356 if !session_id.is_empty() {
357 self.write_state(session_id).await;
358 }
359 None
360 }
361 "assistant" => {
362 if let Some(blocks) = value.pointer("/message/content").and_then(Value::as_array) {
363 for block in blocks {
364 emit_assistant_block(block, events, last_text);
365 }
366 }
367 None
368 }
369 "user" => {
370 if let Some(blocks) = value.pointer("/message/content").and_then(Value::as_array) {
371 for block in blocks {
372 emit_tool_result_block(block, events);
373 }
374 }
375 None
376 }
377 "result" => {
378 let subtype = value.get("subtype").and_then(Value::as_str).unwrap_or("");
379 if matches!(subtype, "compact" | "compaction") {
380 tracing::debug!("claude code: mid-turn compaction result, continuing");
383 return None;
384 }
385 if let Some(session_id) = value.get("session_id").and_then(Value::as_str) {
388 if !session_id.is_empty() {
389 self.write_state(session_id).await;
390 }
391 }
392 let final_text = value
393 .get("result")
394 .and_then(Value::as_str)
395 .filter(|s| !s.is_empty())
396 .map(str::to_string)
397 .unwrap_or_else(|| last_text.clone());
398 let usage = value
399 .get("usage")
400 .map(|u| {
401 let prompt = u.get("input_tokens").and_then(Value::as_u64).unwrap_or(0);
402 let completion =
403 u.get("output_tokens").and_then(Value::as_u64).unwrap_or(0);
404 TokenUsage {
405 prompt_tokens: prompt,
406 completion_tokens: completion,
407 total_tokens: prompt.saturating_add(completion),
408 }
409 })
410 .unwrap_or_default();
411 events.emit(event_json(AgentEvent::Complete { usage }));
412 Some(ChildOutcome::completed(final_text))
413 }
414 "control_request" => {
415 self.handle_control_request(value, events, write_tx, pending);
416 None
417 }
418 "control_cancel_request" => {
419 let request_id = value
420 .get("request_id")
421 .and_then(Value::as_str)
422 .unwrap_or("");
423 if let Some(handle) = pending.remove(request_id) {
424 handle.abort();
425 }
426 None
427 }
428 other => {
429 tracing::debug!(frame_type = other, "claude code: unrecognized stdout frame");
430 None
431 }
432 }
433 }
434
435 fn handle_control_request(
441 &self,
442 value: Value,
443 events: &EventSink,
444 write_tx: &mpsc::UnboundedSender<Value>,
445 pending: &mut HashMap<String, JoinHandle<()>>,
446 ) {
447 let request_id = value
448 .get("request_id")
449 .and_then(Value::as_str)
450 .unwrap_or("")
451 .to_string();
452 let request = value.get("request").cloned().unwrap_or_else(|| json!({}));
453 let subtype = request.get("subtype").and_then(Value::as_str).unwrap_or("");
454 if subtype != "can_use_tool" {
455 send_control_response(
459 write_tx,
460 &request_id,
461 false,
462 None,
463 Some(format!("unsupported control_request subtype '{subtype}'")),
464 );
465 return;
466 }
467 let tool_name = request
468 .get("tool_name")
469 .and_then(Value::as_str)
470 .unwrap_or("")
471 .to_string();
472 let input = request.get("input").cloned().unwrap_or_else(|| json!({}));
473 let host = events.host().cloned();
474 let permission_mode = self.permission_mode.clone();
475 let relay_timeout = self.relay_timeout;
476 let write_tx = write_tx.clone();
477 let task_request_id = request_id.clone();
478 let handle = tokio::spawn(async move {
479 decide_and_respond(
480 host,
481 permission_mode,
482 relay_timeout,
483 &task_request_id,
484 &tool_name,
485 input,
486 &write_tx,
487 )
488 .await;
489 });
490 pending.insert(request_id, handle);
491 }
492
493 async fn shutdown_child(child: &mut Child) {
499 if tokio::time::timeout(GRACEFUL_EXIT_WAIT, child.wait())
500 .await
501 .is_ok()
502 {
503 return;
504 }
505 signal_process_group(child, ProcessSignal::Term);
506 if tokio::time::timeout(SIGTERM_WAIT, child.wait())
507 .await
508 .is_ok()
509 {
510 return;
511 }
512 signal_process_group(child, ProcessSignal::Kill);
513 let _ = child.wait().await;
514 }
515}
516
517impl ClaudeCodeExecutor {
518 async fn run_once(
531 &self,
532 body: &str,
533 resume_id: Option<&str>,
534 events: &EventSink,
535 cancel: &CancellationToken,
536 ) -> (ChildOutcome, bool) {
537 let mut child = match spawn_with_etxtbsy_retry(|| self.build_command(resume_id)).await {
538 Ok(c) => c,
539 Err(e) => {
540 return (
541 ChildOutcome::error(format!("spawn '{}': {e}", self.binary)),
542 false,
543 )
544 }
545 };
546 let Some(stdin) = child.stdin.take() else {
547 return (
548 ChildOutcome::error("claude child has no stdin pipe".to_string()),
549 false,
550 );
551 };
552 let Some(stdout) = child.stdout.take() else {
553 return (
554 ChildOutcome::error("claude child has no stdout pipe".to_string()),
555 false,
556 );
557 };
558 let stderr = child.stderr.take();
559
560 let stderr_tail = Arc::new(Mutex::new(String::new()));
561 let stderr_task = stderr.map(|stderr| {
562 let tail = stderr_tail.clone();
563 tokio::spawn(async move { drain_stderr_tail(stderr, tail).await })
564 });
565
566 let (write_tx, writer_handle) = spawn_stdin_writer(stdin);
567 let assignment_frame = json!({
568 "type": "user",
569 "message": { "role": "user", "content": body },
570 });
571 if write_tx.send(assignment_frame).is_err() {
572 let _ = child.start_kill();
573 return (
574 ChildOutcome::error(
575 "claude code executor: failed to queue the assignment on stdin".to_string(),
576 ),
577 false,
578 );
579 }
580
581 let mut reader = tokio::io::BufReader::with_capacity(64 * 1024, stdout);
582 let mut pending: HashMap<String, JoinHandle<()>> = HashMap::new();
583 let mut last_text = String::new();
584
585 let (outcome, exited_without_result) = loop {
586 tokio::select! {
587 _ = cancel.cancelled() => {
588 break (ChildOutcome::cancelled(), false);
589 }
590 line = read_bounded_line(&mut reader, MAX_STDOUT_LINE_BYTES) => {
591 match line {
592 Ok(Some(bytes)) => {
593 if bytes.iter().all(u8::is_ascii_whitespace) {
594 continue;
595 }
596 let value: Value = match serde_json::from_slice(&bytes) {
597 Ok(v) => v,
598 Err(e) => {
599 tracing::debug!("claude code: unparsable stdout line ({e}); skipping");
600 continue;
601 }
602 };
603 if let Some(outcome) = self
604 .handle_frame(value, events, &write_tx, &mut pending, &mut last_text)
605 .await
606 {
607 break (outcome, false);
608 }
609 }
610 Ok(None) => {
611 let code = child.wait().await.ok().and_then(|s| s.code());
614 let tail = stderr_tail.lock().await.clone();
615 break (ChildOutcome::error(format!(
616 "claude exited (code {code:?}) without a result frame; stderr tail: {}",
617 if tail.is_empty() { "<empty>" } else { tail.trim() }
618 )), true);
619 }
620 Err(e) => {
621 break (ChildOutcome::error(format!("claude stdout read error: {e}")), false);
622 }
623 }
624 }
625 }
626 };
627
628 for (_, handle) in pending.drain() {
629 handle.abort();
630 }
631 drop(write_tx);
638 let _ = tokio::time::timeout(Duration::from_millis(500), writer_handle).await;
639 if let Some(stderr_task) = stderr_task {
640 stderr_task.abort();
641 }
642
643 Self::shutdown_child(&mut child).await;
644 (outcome, exited_without_result)
645 }
646}
647
648#[async_trait]
649impl ChildExecutor for ClaudeCodeExecutor {
650 async fn run(
667 &self,
668 spec: RunSpec,
669 events: EventSink,
670 mut steer: SteerInbox,
678 cancel: CancellationToken,
679 ) -> ChildOutcome {
680 let steer_drain = tokio::spawn(async move { while steer.recv().await.is_some() {} });
685
686 if spec.messages.is_empty() {
688 self.delete_state_file().await;
689 }
690
691 let resume_id = if spec.messages.is_empty() {
693 None
694 } else {
695 self.resolve_resume_id().await
696 };
697
698 let body = build_turn_body(&spec, resume_id.as_deref());
700
701 let used_resume = resume_id.is_some();
702 let (outcome, exited_without_result) = self
703 .run_once(&body, resume_id.as_deref(), &events, &cancel)
704 .await;
705
706 let outcome = if used_resume && exited_without_result {
709 tracing::warn!(
710 "claude code: --resume spawn exited without a result frame; \
711 retrying once without --resume"
712 );
713 self.delete_state_file().await;
714 let fallback_body = build_turn_body(&spec, None);
715 self.run_once(&fallback_body, None, &events, &cancel)
716 .await
717 .0
718 } else {
719 outcome
720 };
721
722 steer_drain.abort();
723 outcome
724 }
725}
726
727async fn spawn_with_etxtbsy_retry(mut build: impl FnMut() -> Command) -> std::io::Result<Child> {
736 let mut last_err = None;
737 for _ in 0..5 {
738 match build().spawn() {
739 Ok(child) => return Ok(child),
740 Err(e) if e.raw_os_error() == Some(26) => {
741 last_err = Some(e);
742 tokio::time::sleep(Duration::from_millis(10)).await;
743 }
744 Err(e) => return Err(e),
745 }
746 }
747 Err(last_err.expect("retry loop always records an error before exhausting"))
748}
749
750enum ProcessSignal {
752 Term,
753 Kill,
754}
755
756#[cfg(unix)]
762fn signal_process_group(child: &Child, signal: ProcessSignal) {
763 if let Some(pid) = child.id() {
764 let signo = match signal {
765 ProcessSignal::Term => libc::SIGTERM,
766 ProcessSignal::Kill => libc::SIGKILL,
767 };
768 unsafe {
773 libc::kill(-(pid as libc::pid_t), signo);
774 }
775 }
776}
777
778#[cfg(not(unix))]
779fn signal_process_group(_child: &Child, _signal: ProcessSignal) {}
780
781fn spawn_stdin_writer(
785 mut stdin: tokio::process::ChildStdin,
786) -> (mpsc::UnboundedSender<Value>, JoinHandle<()>) {
787 let (tx, mut rx) = mpsc::unbounded_channel::<Value>();
788 let handle = tokio::spawn(async move {
789 while let Some(value) = rx.recv().await {
790 let Ok(mut line) = serde_json::to_vec(&value) else {
791 continue;
792 };
793 line.push(b'\n');
794 if stdin.write_all(&line).await.is_err() {
795 break;
796 }
797 if stdin.flush().await.is_err() {
798 break;
799 }
800 }
801 });
804 (tx, handle)
805}
806
807async fn read_bounded_line<R>(reader: &mut R, max_bytes: usize) -> std::io::Result<Option<Vec<u8>>>
811where
812 R: tokio::io::AsyncBufRead + Unpin,
813{
814 let mut out = Vec::new();
815 loop {
816 let (found, consumed) = {
817 let available = reader.fill_buf().await?;
818 if available.is_empty() {
819 return Ok(if out.is_empty() { None } else { Some(out) });
820 }
821 match available.iter().position(|&b| b == b'\n') {
822 Some(pos) => {
823 out.extend_from_slice(&available[..pos]);
824 (true, pos + 1)
825 }
826 None => {
827 out.extend_from_slice(available);
828 (false, available.len())
829 }
830 }
831 };
832 reader.consume(consumed);
833 if found {
834 return Ok(Some(out));
835 }
836 if out.len() > max_bytes {
837 return Err(std::io::Error::new(
838 std::io::ErrorKind::InvalidData,
839 format!("stdout line exceeded {max_bytes} bytes"),
840 ));
841 }
842 }
843}
844
845async fn drain_stderr_tail(stderr: tokio::process::ChildStderr, tail: Arc<Mutex<String>>) {
848 let mut reader = BufReader::new(stderr);
849 let mut buf = Vec::new();
850 loop {
851 buf.clear();
852 match reader.read_until(b'\n', &mut buf).await {
853 Ok(0) | Err(_) => return,
854 Ok(_) => {
855 let mut t = tail.lock().await;
856 t.push_str(&String::from_utf8_lossy(&buf));
857 if t.len() > STDERR_TAIL_BYTES {
858 let excess = t.len() - STDERR_TAIL_BYTES;
859 let cut = t
860 .char_indices()
861 .map(|(i, _)| i)
862 .find(|&i| i >= excess)
863 .unwrap_or(t.len());
864 t.drain(..cut);
865 }
866 }
867 }
868 }
869}
870
871fn emit_assistant_block(block: &Value, events: &EventSink, last_text: &mut String) {
874 match block.get("type").and_then(Value::as_str) {
875 Some("text") => {
876 let text = block.get("text").and_then(Value::as_str).unwrap_or("");
877 if !text.is_empty() {
878 last_text.push_str(text);
879 events.emit(event_json(AgentEvent::Token {
880 content: text.to_string(),
881 }));
882 }
883 }
884 Some("thinking") => {
885 let text = block.get("thinking").and_then(Value::as_str).unwrap_or("");
886 if !text.is_empty() {
887 events.emit(event_json(AgentEvent::ReasoningToken {
888 content: text.to_string(),
889 }));
890 }
891 }
892 Some("tool_use") => {
893 let tool_call_id = block
894 .get("id")
895 .and_then(Value::as_str)
896 .unwrap_or("")
897 .to_string();
898 let tool_name = block
899 .get("name")
900 .and_then(Value::as_str)
901 .unwrap_or("")
902 .to_string();
903 let arguments = block.get("input").cloned().unwrap_or_else(|| json!({}));
904 events.emit(event_json(AgentEvent::ToolStart {
905 tool_call_id,
906 tool_name,
907 arguments,
908 }));
909 }
910 _ => {}
911 }
912}
913
914fn emit_tool_result_block(block: &Value, events: &EventSink) {
917 if block.get("type").and_then(Value::as_str) != Some("tool_result") {
918 return;
919 }
920 let tool_call_id = block
921 .get("tool_use_id")
922 .and_then(Value::as_str)
923 .unwrap_or("")
924 .to_string();
925 let is_error = block
926 .get("is_error")
927 .and_then(Value::as_bool)
928 .unwrap_or(false);
929 let text = truncate_chars(
930 &tool_result_text(block.get("content")),
931 TOOL_RESULT_TRUNCATE_CHARS,
932 );
933 let event = if is_error {
934 AgentEvent::ToolError {
935 tool_call_id,
936 error: text,
937 }
938 } else {
939 AgentEvent::ToolComplete {
940 tool_call_id,
941 result: ToolResult::text(true, text),
942 }
943 };
944 events.emit(event_json(event));
945}
946
947fn tool_result_text(content: Option<&Value>) -> String {
950 match content {
951 Some(Value::String(s)) => s.clone(),
952 Some(Value::Array(items)) => items
953 .iter()
954 .filter_map(|b| b.get("text").and_then(Value::as_str))
955 .collect::<Vec<_>>()
956 .join("\n"),
957 Some(other) => other.to_string(),
958 None => String::new(),
959 }
960}
961
962fn build_turn_body(spec: &RunSpec, resume_id: Option<&str>) -> String {
968 if resume_id.is_some() || spec.messages.is_empty() {
969 return spec.assignment.clone();
970 }
971 match render_history_preamble(&spec.messages, &spec.assignment) {
972 Some(preamble) => format!("{preamble}\n\n## Current task\n\n{}", spec.assignment),
973 None => spec.assignment.clone(),
974 }
975}
976
977fn render_history_preamble(messages: &[Value], assignment: &str) -> Option<String> {
985 let mut entries: Vec<(String, String)> = messages
986 .iter()
987 .filter_map(|m| {
988 let role = m.get("role").and_then(Value::as_str)?.to_string();
989 let content = m.get("content").and_then(Value::as_str)?;
990 if content.is_empty() {
991 return None;
992 }
993 Some((role, content.to_string()))
994 })
995 .collect();
996
997 if let Some((role, content)) = entries.last() {
1000 if role == "user" && content == assignment {
1001 entries.pop();
1002 }
1003 }
1004 if entries.is_empty() {
1005 return None;
1006 }
1007
1008 let dropped_by_count = entries.len().saturating_sub(HISTORY_PREAMBLE_MAX_MESSAGES);
1010 if dropped_by_count > 0 {
1011 entries.drain(0..dropped_by_count);
1012 }
1013
1014 let mut rendered: Vec<String> = entries
1015 .iter()
1016 .map(|(role, content)| format!("**{role}**: {content}"))
1017 .collect();
1018
1019 let mut dropped_by_chars = 0usize;
1023 while rendered.len() > 1
1024 && rendered
1025 .iter()
1026 .map(|s| s.chars().count() + 2)
1027 .sum::<usize>()
1028 > HISTORY_PREAMBLE_MAX_CHARS
1029 {
1030 rendered.remove(0);
1031 dropped_by_chars += 1;
1032 }
1033 if let [only] = rendered.as_mut_slice() {
1034 if only.chars().count() > HISTORY_PREAMBLE_MAX_CHARS {
1035 *only = truncate_chars(only, HISTORY_PREAMBLE_MAX_CHARS);
1036 }
1037 }
1038
1039 let mut out = String::from("## Prior conversation (rehydrated)\n\n");
1040 if dropped_by_count > 0 || dropped_by_chars > 0 {
1041 out.push_str(&format!(
1042 "_[truncated: {} earlier message(s) omitted]_\n\n",
1043 dropped_by_count + dropped_by_chars
1044 ));
1045 }
1046 out.push_str(&rendered.join("\n\n"));
1047 Some(out)
1048}
1049
1050fn truncate_chars(s: &str, max_chars: usize) -> String {
1051 if s.chars().count() <= max_chars {
1052 return s.to_string();
1053 }
1054 let head: String = s.chars().take(max_chars).collect();
1055 let dropped = s.chars().count() - max_chars;
1056 format!("{head}\n… [truncated, {dropped} more chars]")
1057}
1058
1059async fn decide_and_respond(
1063 host: Option<HostBridge>,
1064 permission_mode: Option<String>,
1065 relay_timeout: Duration,
1066 request_id: &str,
1067 tool_name: &str,
1068 input: Value,
1069 write_tx: &mpsc::UnboundedSender<Value>,
1070) {
1071 if tool_name == "AskUserQuestion" {
1072 send_control_response(
1076 write_tx,
1077 request_id,
1078 false,
1079 None,
1080 Some(
1081 "interactive questions are not supported by the Claude Code executor yet"
1082 .to_string(),
1083 ),
1084 );
1085 return;
1086 }
1087
1088 let (allow, deny_message) = if let Some(host) = host {
1089 let body = json!({ "tool_name": tool_name, "input": input });
1090 match tokio::time::timeout(relay_timeout, host.approval_call(body)).await {
1097 Ok(Ok(reply)) => {
1098 let approved = reply
1099 .get("approved")
1100 .and_then(Value::as_bool)
1101 .unwrap_or(false);
1102 let msg = (!approved).then(|| "denied by host approver".to_string());
1103 (approved, msg)
1104 }
1105 Ok(Err(e)) => (false, Some(format!("approval relay failed: {e}"))),
1106 Err(_) => (
1107 false,
1108 Some(format!(
1109 "approval relay timed out after {}s; denying",
1110 relay_timeout.as_secs()
1111 )),
1112 ),
1113 }
1114 } else if permission_mode.as_deref() == Some("bypassPermissions") {
1115 (true, None)
1116 } else {
1117 (
1118 false,
1119 Some(
1120 "permission relay unavailable; run with bypassPermissions or attach a host bridge"
1121 .to_string(),
1122 ),
1123 )
1124 };
1125 let updated_input = allow.then_some(input);
1126 send_control_response(write_tx, request_id, allow, updated_input, deny_message);
1127}
1128
1129fn send_control_response(
1131 write_tx: &mpsc::UnboundedSender<Value>,
1132 request_id: &str,
1133 allow: bool,
1134 updated_input: Option<Value>,
1135 deny_message: Option<String>,
1136) {
1137 let response = if allow {
1138 json!({
1139 "behavior": "allow",
1140 "updatedInput": updated_input.unwrap_or_else(|| json!({})),
1141 })
1142 } else {
1143 json!({
1144 "behavior": "deny",
1145 "message": deny_message.unwrap_or_default(),
1146 })
1147 };
1148 let frame = json!({
1149 "type": "control_response",
1150 "response": {
1151 "subtype": "success",
1152 "request_id": request_id,
1153 "response": response,
1154 },
1155 });
1156 let _ = write_tx.send(frame);
1157}
1158
1159fn event_json(event: AgentEvent) -> Value {
1165 serde_json::to_value(event).unwrap_or_else(|_| json!({}))
1166}
1167
1168#[cfg(test)]
1169mod tests {
1170 use super::*;
1171 use std::io::Write as _;
1172 use std::os::unix::fs::PermissionsExt;
1173 use std::path::PathBuf;
1174
1175 use bamboo_subagent::executor::EventSink;
1176 use bamboo_subagent::proto::TerminalStatus;
1177
1178 fn write_stub(dir: &std::path::Path, body: &str) -> PathBuf {
1182 let path = dir.join("claude");
1183 let mut f = std::fs::File::create(&path).unwrap();
1184 writeln!(f, "#!/bin/sh").unwrap();
1185 f.write_all(body.as_bytes()).unwrap();
1186 let mut perms = std::fs::metadata(&path).unwrap().permissions();
1187 perms.set_mode(0o755);
1188 std::fs::set_permissions(&path, perms).unwrap();
1189 path
1190 }
1191
1192 fn executor(binary: PathBuf) -> ClaudeCodeExecutor {
1195 executor_with_state(binary, None, None)
1196 }
1197
1198 fn executor_with_state(
1199 binary: PathBuf,
1200 state_dir: Option<PathBuf>,
1201 workspace: Option<String>,
1202 ) -> ClaudeCodeExecutor {
1203 ClaudeCodeExecutor::new(
1204 Some(binary.to_string_lossy().into_owned()),
1205 None,
1206 None,
1207 workspace,
1208 state_dir,
1209 false,
1210 Vec::new(),
1211 )
1212 }
1213
1214 fn run_spec(assignment: &str) -> RunSpec {
1215 RunSpec {
1216 assignment: assignment.to_string(),
1217 reasoning_effort: None,
1218 messages: Vec::new(),
1219 }
1220 }
1221
1222 fn run_spec_with_messages(assignment: &str, messages: Vec<Value>) -> RunSpec {
1223 RunSpec {
1224 assignment: assignment.to_string(),
1225 reasoning_effort: None,
1226 messages,
1227 }
1228 }
1229
1230 fn msg(role: &str, content: &str) -> Value {
1231 json!({ "role": role, "content": content })
1232 }
1233
1234 fn read_argv(dir: &std::path::Path, name: &str) -> String {
1235 std::fs::read_to_string(dir.join(name)).unwrap_or_default()
1236 }
1237
1238 fn stdin_body(dir: &std::path::Path, name: &str) -> String {
1242 let raw = std::fs::read_to_string(dir.join(name)).unwrap();
1243 let value: Value = serde_json::from_str(raw.trim()).unwrap();
1244 value["message"]["content"].as_str().unwrap().to_string()
1245 }
1246
1247 #[tokio::test]
1248 async fn happy_path_streams_events_then_completes() {
1249 let dir = tempfile::tempdir().unwrap();
1250 let bin = write_stub(
1251 dir.path(),
1252 r#"
1253read -r _assignment
1254echo '{"type":"system","session_id":"s1","model":"stub-model"}'
1255echo '{"type":"assistant","message":{"content":[{"type":"text","text":"Working on it"},{"type":"tool_use","id":"toolu_1","name":"Bash","input":{"command":"echo hi"}}]}}'
1256echo '{"type":"user","message":{"content":[{"type":"tool_result","tool_use_id":"toolu_1","content":"hi","is_error":false}]}}'
1257echo '{"type":"result","subtype":"success","result":"done: hi","usage":{"input_tokens":10,"output_tokens":5}}'
1258"#,
1259 );
1260 let (sink, mut rx) = EventSink::channel();
1261 let outcome = executor(bin)
1262 .run(
1263 run_spec("say hi"),
1264 sink,
1265 SteerInbox::disconnected(),
1266 CancellationToken::new(),
1267 )
1268 .await;
1269
1270 assert_eq!(outcome.status, TerminalStatus::Completed);
1271 assert_eq!(outcome.result.as_deref(), Some("done: hi"));
1272
1273 let mut events = Vec::new();
1274 while let Ok(e) = rx.try_recv() {
1275 events.push(e);
1276 }
1277 let types: Vec<&str> = events
1278 .iter()
1279 .map(|e| e["type"].as_str().unwrap_or(""))
1280 .collect();
1281 assert_eq!(
1282 types,
1283 vec!["token", "tool_start", "tool_complete", "complete"]
1284 );
1285 assert_eq!(events[0]["content"], "Working on it");
1286 assert_eq!(events[1]["tool_name"], "Bash");
1287 assert_eq!(events[2]["result"]["result"], "hi");
1288 assert_eq!(events[3]["usage"]["prompt_tokens"], 10);
1289 assert_eq!(events[3]["usage"]["completion_tokens"], 5);
1290 }
1291
1292 #[tokio::test]
1293 async fn compaction_result_does_not_complete_the_run() {
1294 let dir = tempfile::tempdir().unwrap();
1295 let bin = write_stub(
1296 dir.path(),
1297 r#"
1298read -r _assignment
1299echo '{"type":"result","subtype":"compact","result":"mid-turn compaction, ignore"}'
1300echo '{"type":"assistant","message":{"content":[{"type":"text","text":"back after compaction"}]}}'
1301echo '{"type":"result","subtype":"success","result":"final answer"}'
1302"#,
1303 );
1304 let (sink, _rx) = EventSink::channel();
1305 let outcome = executor(bin)
1306 .run(
1307 run_spec("do the thing"),
1308 sink,
1309 SteerInbox::disconnected(),
1310 CancellationToken::new(),
1311 )
1312 .await;
1313 assert_eq!(outcome.status, TerminalStatus::Completed);
1314 assert_eq!(outcome.result.as_deref(), Some("final answer"));
1315 }
1316
1317 #[tokio::test]
1318 async fn control_request_with_no_host_denies_and_run_continues() {
1319 let dir = tempfile::tempdir().unwrap();
1320 let bin = write_stub(
1321 dir.path(),
1322 r#"
1323DIR="$(cd "$(dirname "$0")" && pwd)"
1324read -r _assignment
1325echo '{"type":"control_request","request_id":"r1","request":{"subtype":"can_use_tool","tool_name":"Bash","input":{"command":"rm -rf /"}}}'
1326read -r control_response_line
1327printf '%s\n' "$control_response_line" > "$DIR/control_response.json"
1328echo '{"type":"result","subtype":"success","result":"continued after deny"}'
1329"#,
1330 );
1331 let (sink, _rx) = EventSink::channel(); let outcome = executor(bin.clone())
1333 .run(
1334 run_spec("do something dangerous"),
1335 sink,
1336 SteerInbox::disconnected(),
1337 CancellationToken::new(),
1338 )
1339 .await;
1340 assert_eq!(outcome.status, TerminalStatus::Completed);
1341 assert_eq!(outcome.result.as_deref(), Some("continued after deny"));
1342
1343 let written = std::fs::read_to_string(dir.path().join("control_response.json")).unwrap();
1344 let value: Value = serde_json::from_str(written.trim()).unwrap();
1345 assert_eq!(value["response"]["request_id"], "r1");
1346 assert_eq!(value["response"]["response"]["behavior"], "deny");
1347 assert!(value["response"]["response"]["message"]
1348 .as_str()
1349 .unwrap()
1350 .contains("permission relay unavailable"));
1351 }
1352
1353 #[tokio::test]
1354 async fn control_request_with_host_bridge_relays_and_allows() {
1355 let dir = tempfile::tempdir().unwrap();
1356 let bin = write_stub(
1357 dir.path(),
1358 r#"
1359DIR="$(cd "$(dirname "$0")" && pwd)"
1360read -r _assignment
1361echo '{"type":"control_request","request_id":"r1","request":{"subtype":"can_use_tool","tool_name":"Write","input":{"file_path":"/tmp/x"}}}'
1362read -r control_response_line
1363printf '%s\n' "$control_response_line" > "$DIR/control_response.json"
1364echo '{"type":"result","subtype":"success","result":"wrote file"}'
1365"#,
1366 );
1367 let (bridge, mut req_rx) = HostBridge::channel();
1368 let approver = tokio::spawn(async move {
1369 let req = req_rx.recv().await.expect("a host approval request");
1370 assert_eq!(req.body["tool_name"], "Write");
1371 let _ = req.reply.send(json!({ "approved": true }));
1372 });
1373 let (sink, _rx) = EventSink::channel();
1374 let sink = sink.with_host_bridge(bridge);
1375 let outcome = executor(bin)
1376 .run(
1377 run_spec("write a file"),
1378 sink,
1379 SteerInbox::disconnected(),
1380 CancellationToken::new(),
1381 )
1382 .await;
1383 approver.await.unwrap();
1384 assert_eq!(outcome.status, TerminalStatus::Completed);
1385
1386 let written = std::fs::read_to_string(dir.path().join("control_response.json")).unwrap();
1387 let value: Value = serde_json::from_str(written.trim()).unwrap();
1388 assert_eq!(value["response"]["response"]["behavior"], "allow");
1389 assert_eq!(
1390 value["response"]["response"]["updatedInput"]["file_path"],
1391 "/tmp/x"
1392 );
1393 }
1394
1395 #[tokio::test]
1396 async fn control_request_approval_relay_times_out_and_denies() {
1397 let dir = tempfile::tempdir().unwrap();
1398 let bin = write_stub(
1399 dir.path(),
1400 r#"
1401DIR="$(cd "$(dirname "$0")" && pwd)"
1402read -r _assignment
1403echo '{"type":"control_request","request_id":"r1","request":{"subtype":"can_use_tool","tool_name":"Bash","input":{"command":"echo hi"}}}'
1404read -r control_response_line
1405printf '%s\n' "$control_response_line" > "$DIR/control_response.json"
1406echo '{"type":"result","subtype":"success","result":"continued after timeout"}'
1407"#,
1408 );
1409 let (bridge, mut req_rx) = HostBridge::channel();
1410 let held = tokio::spawn(async move { req_rx.recv().await });
1417 let (sink, _rx) = EventSink::channel();
1418 let sink = sink.with_host_bridge(bridge);
1419 let outcome = executor(bin)
1420 .with_relay_timeout_for_test(Duration::from_millis(50))
1421 .run(
1422 run_spec("do something"),
1423 sink,
1424 SteerInbox::disconnected(),
1425 CancellationToken::new(),
1426 )
1427 .await;
1428 assert_eq!(outcome.status, TerminalStatus::Completed);
1429 assert_eq!(outcome.result.as_deref(), Some("continued after timeout"));
1430
1431 let written = std::fs::read_to_string(dir.path().join("control_response.json")).unwrap();
1432 let value: Value = serde_json::from_str(written.trim()).unwrap();
1433 assert_eq!(value["response"]["response"]["behavior"], "deny");
1434 let msg = value["response"]["response"]["message"].as_str().unwrap();
1435 assert!(msg.contains("timed out"), "unexpected deny message: {msg}");
1436 assert!(msg.contains("denying"), "unexpected deny message: {msg}");
1437
1438 let _req = held.await.unwrap();
1441 }
1442
1443 #[tokio::test]
1444 async fn env_allowlist_blocks_canary_secret_but_forwards_listed_var() {
1445 let dir = tempfile::tempdir().unwrap();
1446 let bin = write_stub(
1447 dir.path(),
1448 r#"
1449DIR="$(cd "$(dirname "$0")" && pwd)"
1450env > "$DIR/env-dump.txt"
1451read -r _assignment
1452echo '{"type":"result","subtype":"success","result":"ok"}'
1453"#,
1454 );
1455 std::env::set_var("FAKE_SECRET_API_KEY", "leaked-if-broken");
1460 std::env::set_var("BAMBOO_TEST_FORWARD_ME", "forwarded-value");
1461
1462 let exec = ClaudeCodeExecutor::new(
1463 Some(bin.to_string_lossy().into_owned()),
1464 None,
1465 None,
1466 None,
1467 None,
1468 false,
1469 vec!["BAMBOO_TEST_FORWARD_ME".to_string()],
1470 );
1471 let (sink, _rx) = EventSink::channel();
1472 let outcome = exec
1473 .run(
1474 run_spec("hi"),
1475 sink,
1476 SteerInbox::disconnected(),
1477 CancellationToken::new(),
1478 )
1479 .await;
1480
1481 std::env::remove_var("FAKE_SECRET_API_KEY");
1482 std::env::remove_var("BAMBOO_TEST_FORWARD_ME");
1483
1484 assert_eq!(outcome.status, TerminalStatus::Completed);
1485 let dump = std::fs::read_to_string(dir.path().join("env-dump.txt")).unwrap();
1486 assert!(
1487 !dump.contains("FAKE_SECRET_API_KEY"),
1488 "canary secret leaked into the child env:\n{dump}"
1489 );
1490 assert!(
1491 dump.contains("BAMBOO_TEST_FORWARD_ME=forwarded-value"),
1492 "forward_env-listed var missing from the child env:\n{dump}"
1493 );
1494 assert!(dump.contains("PATH="), "PATH missing from the child env");
1498 }
1499
1500 #[tokio::test]
1501 async fn cancel_kills_the_child_process_group() {
1502 let dir = tempfile::tempdir().unwrap();
1503 let bin = write_stub(
1504 dir.path(),
1505 r#"
1506read -r _assignment
1507echo '{"type":"system","session_id":"s1"}'
1508sleep 30
1509echo '{"type":"result","subtype":"success","result":"too late"}'
1510"#,
1511 );
1512 let (sink, _rx) = EventSink::channel();
1513 let cancel = CancellationToken::new();
1514 let cancel_clone = cancel.clone();
1515 let run = tokio::spawn(async move {
1516 executor(bin)
1517 .run(
1518 run_spec("a long task"),
1519 sink,
1520 SteerInbox::disconnected(),
1521 cancel_clone,
1522 )
1523 .await
1524 });
1525 tokio::time::sleep(Duration::from_millis(200)).await;
1526 cancel.cancel();
1527
1528 let outcome = tokio::time::timeout(Duration::from_secs(15), run)
1529 .await
1530 .expect("run finished within the shutdown bound")
1531 .unwrap();
1532 assert_eq!(outcome.status, TerminalStatus::Cancelled);
1533 }
1534
1535 #[tokio::test]
1536 async fn oversized_single_stdout_line_parses() {
1537 let dir = tempfile::tempdir().unwrap();
1538 let big_text = "x".repeat(150_000);
1542 let script = format!(
1543 r#"
1544read -r _assignment
1545echo '{{"type":"assistant","message":{{"content":[{{"type":"text","text":"{big_text}"}}]}}}}'
1546echo '{{"type":"result","subtype":"success","result":"ok"}}'
1547"#
1548 );
1549 let bin = write_stub(dir.path(), &script);
1550 let (sink, mut rx) = EventSink::channel();
1551 let outcome = executor(bin)
1552 .run(
1553 run_spec("emit a huge line"),
1554 sink,
1555 SteerInbox::disconnected(),
1556 CancellationToken::new(),
1557 )
1558 .await;
1559 assert_eq!(outcome.status, TerminalStatus::Completed);
1560 assert_eq!(outcome.result.as_deref(), Some("ok"));
1561
1562 let mut saw_big_token = false;
1563 while let Ok(e) = rx.try_recv() {
1564 if e["type"] == "token" {
1565 assert_eq!(e["content"].as_str().unwrap().len(), 150_000);
1566 saw_big_token = true;
1567 }
1568 }
1569 assert!(saw_big_token, "expected the oversized token event");
1570 }
1571
1572 #[tokio::test]
1573 async fn missing_binary_errors_without_hanging() {
1574 let (sink, _rx) = EventSink::channel();
1575 let outcome = ClaudeCodeExecutor::new(
1576 Some("/nonexistent/definitely-not-claude".into()),
1577 None,
1578 None,
1579 None,
1580 None,
1581 false,
1582 Vec::new(),
1583 )
1584 .run(
1585 run_spec("hi"),
1586 sink,
1587 SteerInbox::disconnected(),
1588 CancellationToken::new(),
1589 )
1590 .await;
1591 assert_eq!(outcome.status, TerminalStatus::Error);
1592 assert!(outcome.error.unwrap().contains("spawn"));
1593 }
1594
1595 #[test]
1596 fn truncate_chars_caps_and_reports_dropped_count() {
1597 let long = "a".repeat(50);
1598 let out = truncate_chars(&long, 10);
1599 assert!(out.starts_with(&"a".repeat(10)));
1600 assert!(out.contains("40 more chars"));
1601 assert_eq!(truncate_chars("short", 10), "short");
1602 }
1603
1604 #[test]
1605 fn tool_result_text_flattens_string_and_block_array() {
1606 assert_eq!(tool_result_text(Some(&json!("plain"))), "plain".to_string());
1607 assert_eq!(
1608 tool_result_text(Some(
1609 &json!([{"type":"text","text":"a"},{"type":"text","text":"b"}])
1610 )),
1611 "a\nb".to_string()
1612 );
1613 assert_eq!(tool_result_text(None), "".to_string());
1614 }
1615
1616 const MULTI_RUN_STUB: &str = r#"
1625DIR="$(cd "$(dirname "$0")" && pwd)"
1626N=$(cat "$DIR/count" 2>/dev/null || echo 0)
1627N=$((N+1))
1628echo "$N" > "$DIR/count"
1629printf '%s\n' "$@" > "$DIR/argv-$N.txt"
1630read -r line
1631printf '%s\n' "$line" > "$DIR/stdin-$N.txt"
1632if [ "$N" = "1" ]; then
1633 echo '{"type":"system","session_id":"s-1"}'
1634 echo '{"type":"result","subtype":"success","result":"first"}'
1635elif [ "$N" = "2" ]; then
1636 echo '{"type":"system","session_id":"s-2"}'
1637 echo '{"type":"result","subtype":"success","result":"second"}'
1638else
1639 echo '{"type":"result","subtype":"success","result":"third"}'
1640fi
1641"#;
1642
1643 #[tokio::test]
1644 async fn resume_state_written_reused_and_cleared_across_activations() {
1645 let bin_dir = tempfile::tempdir().unwrap();
1646 let state_dir = tempfile::tempdir().unwrap();
1647 let bin = write_stub(bin_dir.path(), MULTI_RUN_STUB);
1648 let exec = executor_with_state(bin, Some(state_dir.path().to_path_buf()), None);
1649 let state_path = state_dir.path().join("claude-code-session.json");
1650
1651 let (sink, _rx) = EventSink::channel();
1654 let outcome = exec
1655 .run(
1656 run_spec("task one"),
1657 sink,
1658 SteerInbox::disconnected(),
1659 CancellationToken::new(),
1660 )
1661 .await;
1662 assert_eq!(outcome.status, TerminalStatus::Completed);
1663 assert!(!read_argv(bin_dir.path(), "argv-1.txt").contains("--resume"));
1664 let state: Value =
1665 serde_json::from_str(&std::fs::read_to_string(&state_path).unwrap()).unwrap();
1666 assert_eq!(state["session_id"], "s-1");
1667
1668 let (sink, _rx) = EventSink::channel();
1673 let messages = vec![msg("user", "task one"), msg("assistant", "did it")];
1674 let outcome = exec
1675 .run(
1676 run_spec_with_messages("task two", messages),
1677 sink,
1678 SteerInbox::disconnected(),
1679 CancellationToken::new(),
1680 )
1681 .await;
1682 assert_eq!(outcome.status, TerminalStatus::Completed);
1683 let argv2 = read_argv(bin_dir.path(), "argv-2.txt");
1684 assert!(argv2.contains("--resume"));
1685 assert!(argv2.contains("s-1"));
1686 let state: Value =
1687 serde_json::from_str(&std::fs::read_to_string(&state_path).unwrap()).unwrap();
1688 assert_eq!(state["session_id"], "s-2");
1689 assert_eq!(stdin_body(bin_dir.path(), "stdin-2.txt"), "task two");
1690
1691 let (sink, _rx) = EventSink::channel();
1695 let outcome = exec
1696 .run(
1697 run_spec("task three"),
1698 sink,
1699 SteerInbox::disconnected(),
1700 CancellationToken::new(),
1701 )
1702 .await;
1703 assert_eq!(outcome.status, TerminalStatus::Completed);
1704 assert!(!read_argv(bin_dir.path(), "argv-3.txt").contains("--resume"));
1705 assert!(!state_path.exists());
1706 }
1707
1708 #[tokio::test]
1709 async fn fallback_rehydration_renders_preamble_without_state() {
1710 let bin_dir = tempfile::tempdir().unwrap();
1711 let state_dir = tempfile::tempdir().unwrap();
1712 let bin = write_stub(
1713 bin_dir.path(),
1714 r#"
1715DIR="$(cd "$(dirname "$0")" && pwd)"
1716printf '%s\n' "$@" > "$DIR/argv.txt"
1717read -r line
1718printf '%s\n' "$line" > "$DIR/stdin.txt"
1719echo '{"type":"result","subtype":"success","result":"ok"}'
1720"#,
1721 );
1722 let exec = executor_with_state(bin, Some(state_dir.path().to_path_buf()), None);
1723 let messages = vec![
1724 msg("user", "please do X"),
1725 msg("assistant", "sure, doing X"),
1726 msg("user", "continue"),
1729 ];
1730 let (sink, _rx) = EventSink::channel();
1731 let outcome = exec
1732 .run(
1733 run_spec_with_messages("continue", messages),
1734 sink,
1735 SteerInbox::disconnected(),
1736 CancellationToken::new(),
1737 )
1738 .await;
1739 assert_eq!(outcome.status, TerminalStatus::Completed);
1740 assert!(!read_argv(bin_dir.path(), "argv.txt").contains("--resume"));
1741 let body = stdin_body(bin_dir.path(), "stdin.txt");
1742 assert!(body.contains("## Prior conversation (rehydrated)"));
1743 assert!(body.contains("please do X"));
1744 assert!(body.contains("## Current task"));
1745 assert!(!body.contains("truncated"));
1746 assert_eq!(body.matches("continue").count(), 1);
1749 }
1750
1751 #[tokio::test]
1752 async fn fallback_rehydration_truncates_over_message_cap() {
1753 let bin_dir = tempfile::tempdir().unwrap();
1754 let state_dir = tempfile::tempdir().unwrap();
1755 let bin = write_stub(
1756 bin_dir.path(),
1757 r#"
1758DIR="$(cd "$(dirname "$0")" && pwd)"
1759read -r line
1760printf '%s\n' "$line" > "$DIR/stdin.txt"
1761echo '{"type":"result","subtype":"success","result":"ok"}'
1762"#,
1763 );
1764 let exec = executor_with_state(bin, Some(state_dir.path().to_path_buf()), None);
1765 let mut messages: Vec<Value> = (0..50)
1766 .map(|i| {
1767 let role = if i % 2 == 0 { "user" } else { "assistant" };
1768 msg(role, &format!("message {i}"))
1769 })
1770 .collect();
1771 messages.push(msg("user", "final ask"));
1772 let (sink, _rx) = EventSink::channel();
1773 let outcome = exec
1774 .run(
1775 run_spec_with_messages("final ask", messages),
1776 sink,
1777 SteerInbox::disconnected(),
1778 CancellationToken::new(),
1779 )
1780 .await;
1781 assert_eq!(outcome.status, TerminalStatus::Completed);
1782 let body = stdin_body(bin_dir.path(), "stdin.txt");
1783 assert!(body.contains("truncated"));
1784 assert!(!body.contains("message 0"));
1786 assert!(body.contains("message 49"));
1787 }
1788
1789 #[tokio::test]
1790 async fn fallback_rehydration_truncates_oversized_single_message() {
1791 let bin_dir = tempfile::tempdir().unwrap();
1792 let state_dir = tempfile::tempdir().unwrap();
1793 let bin = write_stub(
1794 bin_dir.path(),
1795 r#"
1796DIR="$(cd "$(dirname "$0")" && pwd)"
1797read -r line
1798printf '%s\n' "$line" > "$DIR/stdin.txt"
1799echo '{"type":"result","subtype":"success","result":"ok"}'
1800"#,
1801 );
1802 let exec = executor_with_state(bin, Some(state_dir.path().to_path_buf()), None);
1803 let huge = "x".repeat(30_000);
1804 let messages = vec![msg("user", &huge), msg("user", "current ask")];
1805 let (sink, _rx) = EventSink::channel();
1806 let outcome = exec
1807 .run(
1808 run_spec_with_messages("current ask", messages),
1809 sink,
1810 SteerInbox::disconnected(),
1811 CancellationToken::new(),
1812 )
1813 .await;
1814 assert_eq!(outcome.status, TerminalStatus::Completed);
1815 let body = stdin_body(bin_dir.path(), "stdin.txt");
1816 assert!(body.contains("truncated"));
1817 assert!(body.len() < huge.len());
1818 }
1819
1820 #[tokio::test]
1821 async fn workspace_mismatch_state_treated_as_unusable_falls_back() {
1822 let bin_dir = tempfile::tempdir().unwrap();
1823 let state_dir = tempfile::tempdir().unwrap();
1824 let workspace_dir = tempfile::tempdir().unwrap();
1827 std::fs::write(
1828 state_dir.path().join("claude-code-session.json"),
1829 serde_json::to_vec(&json!({
1830 "session_id": "stale-id",
1831 "workspace": "/some/other/workspace",
1832 "updated_at": chrono::Utc::now(),
1833 }))
1834 .unwrap(),
1835 )
1836 .unwrap();
1837 let bin = write_stub(
1838 bin_dir.path(),
1839 r#"
1840DIR="$(cd "$(dirname "$0")" && pwd)"
1841printf '%s\n' "$@" > "$DIR/argv.txt"
1842read -r _line
1843echo '{"type":"result","subtype":"success","result":"ok"}'
1844"#,
1845 );
1846 let exec = executor_with_state(
1847 bin,
1848 Some(state_dir.path().to_path_buf()),
1849 Some(workspace_dir.path().to_string_lossy().into_owned()),
1850 );
1851 let messages = vec![msg("user", "hi"), msg("user", "go")];
1852 let (sink, _rx) = EventSink::channel();
1853 let outcome = exec
1854 .run(
1855 run_spec_with_messages("go", messages),
1856 sink,
1857 SteerInbox::disconnected(),
1858 CancellationToken::new(),
1859 )
1860 .await;
1861 assert_eq!(outcome.status, TerminalStatus::Completed);
1862 let argv = read_argv(bin_dir.path(), "argv.txt");
1863 assert!(!argv.contains("--resume"));
1864 assert!(!argv.contains("stale-id"));
1865 }
1866
1867 #[tokio::test]
1868 async fn resume_failure_retries_once_with_fallback_history() {
1869 let bin_dir = tempfile::tempdir().unwrap();
1870 let state_dir = tempfile::tempdir().unwrap();
1871 std::fs::write(
1872 state_dir.path().join("claude-code-session.json"),
1873 serde_json::to_vec(&json!({
1874 "session_id": "dead-id",
1875 "workspace": null,
1876 "updated_at": chrono::Utc::now(),
1877 }))
1878 .unwrap(),
1879 )
1880 .unwrap();
1881 let bin = write_stub(
1882 bin_dir.path(),
1883 r#"
1884DIR="$(cd "$(dirname "$0")" && pwd)"
1885N=$(cat "$DIR/count" 2>/dev/null || echo 0)
1886N=$((N+1))
1887echo "$N" > "$DIR/count"
1888printf '%s\n' "$@" > "$DIR/argv-$N.txt"
1889case "$*" in
1890 *--resume*)
1891 exit 1
1892 ;;
1893 *)
1894 read -r line
1895 printf '%s\n' "$line" > "$DIR/stdin-$N.txt"
1896 echo '{"type":"system","session_id":"s-fresh"}'
1897 echo '{"type":"result","subtype":"success","result":"recovered"}'
1898 ;;
1899esac
1900"#,
1901 );
1902 let exec = executor_with_state(bin, Some(state_dir.path().to_path_buf()), None);
1903 let messages = vec![
1904 msg("user", "earlier context"),
1905 msg("user", "please continue"),
1906 ];
1907 let (sink, _rx) = EventSink::channel();
1908 let outcome = exec
1909 .run(
1910 run_spec_with_messages("please continue", messages),
1911 sink,
1912 SteerInbox::disconnected(),
1913 CancellationToken::new(),
1914 )
1915 .await;
1916 assert_eq!(outcome.status, TerminalStatus::Completed);
1917 assert_eq!(outcome.result.as_deref(), Some("recovered"));
1918 assert_eq!(read_argv(bin_dir.path(), "count").trim(), "2");
1919
1920 let argv1 = read_argv(bin_dir.path(), "argv-1.txt");
1921 assert!(argv1.contains("--resume"));
1922 assert!(argv1.contains("dead-id"));
1923 let argv2 = read_argv(bin_dir.path(), "argv-2.txt");
1924 assert!(!argv2.contains("--resume"));
1925 let body2 = stdin_body(bin_dir.path(), "stdin-2.txt");
1926 assert!(body2.contains("## Prior conversation (rehydrated)"));
1927 assert!(body2.contains("earlier context"));
1928
1929 let state: Value = serde_json::from_str(
1933 &std::fs::read_to_string(state_dir.path().join("claude-code-session.json")).unwrap(),
1934 )
1935 .unwrap();
1936 assert_eq!(state["session_id"], "s-fresh");
1937 let leftover_tmp = std::fs::read_dir(state_dir.path())
1938 .unwrap()
1939 .filter_map(|e| e.ok())
1940 .any(|e| e.file_name().to_string_lossy().contains(".tmp"));
1941 assert!(!leftover_tmp);
1942 }
1943}