use std::path::PathBuf;
use std::time::Duration;
use crate::shim::protocol::{self, Channel, Command, Event, ShimState};
use crate::shim::runtime::ShimArgs;
fn is_timeout_error(e: &anyhow::Error) -> bool {
if let Some(io_err) = e.downcast_ref::<std::io::Error>() {
matches!(
io_err.kind(),
std::io::ErrorKind::WouldBlock | std::io::ErrorKind::TimedOut
)
} else {
false
}
}
fn wait_for_event<F>(ch: &mut Channel, timeout: Duration, predicate: F) -> Option<Event>
where
F: Fn(&Event) -> bool,
{
let deadline = std::time::Instant::now() + timeout;
loop {
if std::time::Instant::now() > deadline {
return None;
}
match ch.recv::<Event>() {
Ok(Some(evt)) if predicate(&evt) => return Some(evt),
Ok(Some(_)) => continue,
Ok(None) => return None,
Err(e) if is_timeout_error(&e) => continue,
Err(_) => return None,
}
}
}
fn spawn_sdk_mock(mock_cmd: &str) -> Channel {
let (parent_sock, child_sock) = protocol::socketpair().unwrap();
let channel = Channel::new(child_sock);
let args = ShimArgs {
id: "sdk-test".into(),
agent_type: crate::shim::classifier::AgentType::Claude,
cmd: mock_cmd.to_string(),
cwd: PathBuf::from("/tmp"),
rows: 24,
cols: 80,
pty_log_path: None,
graceful_shutdown_timeout_secs: 5,
auto_commit_on_restart: true,
};
std::thread::spawn(move || {
crate::shim::runtime_sdk::run_sdk(args, channel).ok();
});
let mut ch = Channel::new(parent_sock);
ch.set_read_timeout(Some(Duration::from_millis(500)))
.unwrap();
ch
}
#[test]
#[cfg_attr(not(feature = "shim-integration"), ignore)]
fn sdk_emits_ready_immediately() {
let mut ch = spawn_sdk_mock("sleep 30");
let evt = wait_for_event(&mut ch, Duration::from_secs(5), |e| {
matches!(e, Event::Ready)
});
assert!(evt.is_some(), "expected Ready event");
ch.send(&Command::Shutdown {
timeout_secs: 2,
reason: crate::shim::protocol::ShutdownReason::Requested,
})
.ok();
}
#[test]
#[cfg_attr(not(feature = "shim-integration"), ignore)]
fn sdk_ping_pong() {
let mut ch = spawn_sdk_mock("sleep 30");
wait_for_event(&mut ch, Duration::from_secs(5), |e| {
matches!(e, Event::Ready)
})
.expect("Ready");
ch.send(&Command::Ping).unwrap();
let evt = wait_for_event(&mut ch, Duration::from_secs(2), |e| {
matches!(e, Event::Pong)
});
assert!(evt.is_some(), "expected Pong");
ch.send(&Command::Shutdown {
timeout_secs: 2,
reason: crate::shim::protocol::ShutdownReason::Requested,
})
.ok();
}
#[test]
#[cfg_attr(not(feature = "shim-integration"), ignore)]
fn sdk_get_state_idle() {
let mut ch = spawn_sdk_mock("sleep 30");
wait_for_event(&mut ch, Duration::from_secs(5), |e| {
matches!(e, Event::Ready)
})
.expect("Ready");
ch.send(&Command::GetState).unwrap();
let evt = wait_for_event(&mut ch, Duration::from_secs(2), |e| {
matches!(e, Event::State { .. })
});
match evt {
Some(Event::State { state, .. }) => assert_eq!(state, ShimState::Idle),
_ => panic!("expected State event"),
}
ch.send(&Command::Shutdown {
timeout_secs: 2,
reason: crate::shim::protocol::ShutdownReason::Requested,
})
.ok();
}
#[test]
#[cfg_attr(not(feature = "shim-integration"), ignore)]
fn sdk_message_produces_completion() {
let mock_script = r#"bash -c '
read line
echo "{\"type\":\"assistant\",\"session_id\":\"sess-1\",\"uuid\":\"u1\",\"message\":{\"role\":\"assistant\",\"content\":[{\"type\":\"text\",\"text\":\"Hello from mock\"}]}}"
sleep 0.1
echo "{\"type\":\"result\",\"subtype\":\"success\",\"session_id\":\"sess-1\",\"uuid\":\"u2\",\"result\":\"done\",\"num_turns\":1,\"is_error\":false,\"duration_ms\":100,\"duration_api_ms\":50,\"total_cost_usd\":0.01,\"stop_reason\":\"end_turn\",\"usage\":{\"input_tokens\":10,\"output_tokens\":5},\"modelUsage\":{},\"permission_denials\":[]}"
sleep 30
'"#;
let mut ch = spawn_sdk_mock(mock_script);
wait_for_event(&mut ch, Duration::from_secs(5), |e| {
matches!(e, Event::Ready)
})
.expect("Ready");
ch.send(&Command::SendMessage {
from: "user".into(),
body: "Hello".into(),
message_id: Some("msg-1".into()),
})
.unwrap();
let evt = wait_for_event(&mut ch, Duration::from_secs(5), |e| {
matches!(
e,
Event::StateChanged {
to: ShimState::Working,
..
}
)
});
assert!(evt.is_some(), "expected Working state change");
let mut got_completion = false;
let mut got_idle = false;
let mut response_text = String::new();
let mut completion_msg_id = None;
let deadline = std::time::Instant::now() + Duration::from_secs(10);
while !(got_completion && got_idle) && std::time::Instant::now() < deadline {
match ch.recv::<Event>() {
Ok(Some(Event::Completion {
response,
message_id,
..
})) => {
response_text = response;
completion_msg_id = message_id;
got_completion = true;
}
Ok(Some(Event::StateChanged {
to: ShimState::Idle,
..
})) => {
got_idle = true;
}
Ok(Some(_)) => continue,
Ok(None) => break,
Err(e) if is_timeout_error(&e) => continue,
Err(_) => break,
}
}
assert!(got_completion, "expected Completion event");
assert!(got_idle, "expected Idle state after completion");
assert!(
response_text.contains("Hello from mock"),
"response should contain mock text, got: {response_text}"
);
assert_eq!(completion_msg_id.as_deref(), Some("msg-1"));
ch.send(&Command::Shutdown {
timeout_secs: 2,
reason: crate::shim::protocol::ShutdownReason::Requested,
})
.ok();
}
#[test]
#[cfg_attr(not(feature = "shim-integration"), ignore)]
fn sdk_subprocess_exit_emits_died() {
let mut ch = spawn_sdk_mock("bash -c 'sleep 0.5; exit 0'");
wait_for_event(&mut ch, Duration::from_secs(5), |e| {
matches!(e, Event::Ready)
})
.expect("Ready");
let evt = wait_for_event(&mut ch, Duration::from_secs(5), |e| {
matches!(e, Event::Died { .. })
});
assert!(evt.is_some(), "expected Died event after subprocess exit");
}
#[test]
#[cfg_attr(not(feature = "shim-integration"), ignore)]
fn sdk_message_queued_while_working() {
let mock_script = r#"bash -c '
while read line; do
echo "{\"type\":\"assistant\",\"session_id\":\"s\",\"uuid\":\"u\",\"message\":{\"role\":\"assistant\",\"content\":[{\"type\":\"text\",\"text\":\"response\"}]}}"
sleep 0.1
echo "{\"type\":\"result\",\"subtype\":\"success\",\"session_id\":\"s\",\"uuid\":\"u2\",\"result\":\"ok\",\"num_turns\":1,\"is_error\":false,\"duration_ms\":1,\"duration_api_ms\":1,\"total_cost_usd\":0,\"stop_reason\":\"end_turn\",\"usage\":{\"input_tokens\":1,\"output_tokens\":1},\"modelUsage\":{},\"permission_denials\":[]}"
done
'"#;
let mut ch = spawn_sdk_mock(mock_script);
wait_for_event(&mut ch, Duration::from_secs(5), |e| {
matches!(e, Event::Ready)
})
.expect("Ready");
ch.send(&Command::SendMessage {
from: "user".into(),
body: "first".into(),
message_id: Some("m1".into()),
})
.unwrap();
wait_for_event(&mut ch, Duration::from_secs(5), |e| {
matches!(
e,
Event::StateChanged {
to: ShimState::Working,
..
}
)
})
.expect("Working after first message");
ch.send(&Command::SendMessage {
from: "user".into(),
body: "second".into(),
message_id: Some("m2".into()),
})
.unwrap();
let evt = wait_for_event(&mut ch, Duration::from_secs(3), |e| {
matches!(e, Event::Warning { .. })
});
assert!(evt.is_some(), "expected Warning about queued message");
wait_for_event(&mut ch, Duration::from_secs(10), |e| {
matches!(e, Event::Completion { .. })
})
.expect("first Completion");
let evt = wait_for_event(&mut ch, Duration::from_secs(10), |e| {
matches!(e, Event::Completion { .. })
});
assert!(
evt.is_some(),
"expected second Completion from queued message"
);
ch.send(&Command::Shutdown {
timeout_secs: 2,
reason: crate::shim::protocol::ShutdownReason::Requested,
})
.ok();
}
#[test]
#[cfg_attr(not(feature = "shim-integration"), ignore)]
fn sdk_context_exhaustion_detected() {
let mock_script = r#"bash -c '
read line
echo "{\"type\":\"result\",\"subtype\":\"error_during_execution\",\"session_id\":\"s\",\"uuid\":\"u\",\"is_error\":true,\"errors\":[\"context window exceeded\"],\"num_turns\":1,\"duration_ms\":1,\"duration_api_ms\":1,\"total_cost_usd\":0,\"stop_reason\":null,\"usage\":{\"input_tokens\":1,\"output_tokens\":1},\"modelUsage\":{},\"permission_denials\":[]}"
sleep 30
'"#;
let mut ch = spawn_sdk_mock(mock_script);
wait_for_event(&mut ch, Duration::from_secs(5), |e| {
matches!(e, Event::Ready)
})
.expect("Ready");
ch.send(&Command::SendMessage {
from: "user".into(),
body: "test".into(),
message_id: None,
})
.unwrap();
let evt = wait_for_event(&mut ch, Duration::from_secs(10), |e| {
matches!(e, Event::ContextExhausted { .. })
});
assert!(evt.is_some(), "expected ContextExhausted event");
ch.send(&Command::Kill).ok();
}
#[test]
#[cfg_attr(not(feature = "shim-integration"), ignore)]
fn sdk_auto_approves_tool_use() {
let mock_script = r#"bash -c '
read user_msg
echo "{\"type\":\"control_request\",\"request_id\":\"req-1\",\"request\":{\"subtype\":\"can_use_tool\",\"tool_name\":\"Bash\",\"tool_use_id\":\"tu-1\",\"input\":{\"command\":\"ls\"}}}"
read approval
if echo "$approval" | grep -q "approved.*true"; then
echo "{\"type\":\"assistant\",\"session_id\":\"s\",\"uuid\":\"u\",\"message\":{\"role\":\"assistant\",\"content\":[{\"type\":\"text\",\"text\":\"tool approved\"}]}}"
sleep 0.1
echo "{\"type\":\"result\",\"subtype\":\"success\",\"session_id\":\"s\",\"uuid\":\"u2\",\"result\":\"done\",\"num_turns\":1,\"is_error\":false,\"duration_ms\":1,\"duration_api_ms\":1,\"total_cost_usd\":0,\"stop_reason\":\"end_turn\",\"usage\":{\"input_tokens\":1,\"output_tokens\":1},\"modelUsage\":{},\"permission_denials\":[]}"
else
echo "{\"type\":\"result\",\"subtype\":\"error_during_execution\",\"session_id\":\"s\",\"uuid\":\"u2\",\"is_error\":true,\"errors\":[\"approval denied\"],\"num_turns\":1,\"duration_ms\":1,\"duration_api_ms\":1,\"total_cost_usd\":0,\"stop_reason\":null,\"usage\":{\"input_tokens\":1,\"output_tokens\":1},\"modelUsage\":{},\"permission_denials\":[]}"
fi
sleep 30
'"#;
let mut ch = spawn_sdk_mock(mock_script);
wait_for_event(&mut ch, Duration::from_secs(5), |e| {
matches!(e, Event::Ready)
})
.expect("Ready");
ch.send(&Command::SendMessage {
from: "user".into(),
body: "run a command".into(),
message_id: None,
})
.unwrap();
let evt = wait_for_event(&mut ch, Duration::from_secs(10), |e| {
matches!(e, Event::Completion { .. })
});
match evt {
Some(Event::Completion { response, .. }) => {
assert!(
response.contains("tool approved"),
"expected auto-approval, got: {response}"
);
}
_ => panic!("expected Completion"),
}
ch.send(&Command::Shutdown {
timeout_secs: 2,
reason: crate::shim::protocol::ShutdownReason::Requested,
})
.ok();
}
#[test]
#[cfg_attr(not(feature = "shim-integration"), ignore)]
fn sdk_capture_screen_returns_accumulated_text() {
let mut ch = spawn_sdk_mock("sleep 30");
wait_for_event(&mut ch, Duration::from_secs(5), |e| {
matches!(e, Event::Ready)
})
.expect("Ready");
ch.send(&Command::CaptureScreen {
last_n_lines: Some(10),
})
.unwrap();
let evt = wait_for_event(&mut ch, Duration::from_secs(2), |e| {
matches!(e, Event::ScreenCapture { .. })
});
match evt {
Some(Event::ScreenCapture { content, .. }) => {
assert!(
content.is_empty(),
"expected empty screen before any messages"
);
}
_ => panic!("expected ScreenCapture"),
}
ch.send(&Command::Shutdown {
timeout_secs: 2,
reason: crate::shim::protocol::ShutdownReason::Requested,
})
.ok();
}
#[test]
#[cfg_attr(not(feature = "shim-integration"), ignore)]
fn sdk_shutdown_terminates_cleanly() {
let mut ch = spawn_sdk_mock("sleep 30");
wait_for_event(&mut ch, Duration::from_secs(5), |e| {
matches!(e, Event::Ready)
})
.expect("Ready");
ch.send(&Command::Shutdown {
timeout_secs: 3,
reason: crate::shim::protocol::ShutdownReason::Requested,
})
.unwrap();
let evt = wait_for_event(&mut ch, Duration::from_secs(10), |e| {
matches!(
e,
Event::Died { .. }
| Event::StateChanged {
to: ShimState::Dead,
..
}
)
});
assert!(evt.is_some(), "expected Died or Dead state after shutdown");
}