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