Files
tolaria/src-tauri/src/claude_cli.rs
Matt Van Horn 663a7d9a3f fix: prefer .cmd shim over POSIX wrapper for claude/codex on Windows
Fixes #695

`where claude` on Windows returns both the npm-generated POSIX shell
wrapper (extensionless) and the Windows batch shim (`claude.cmd`)
side-by-side. The existing `path_from_successful_output` helpers in
`claude_cli.rs` and `codex_cli.rs` picked the first existing line,
which gave the POSIX wrapper - Windows tried to load it as a PE32,
`CreateProcessW` returned ERROR_BAD_EXE_FORMAT (193), and the AI chat
panel surfaced "%1 is not a valid Win32 application."

Mirror the fix that landed for Gemini in `gemini_discovery.rs`: when
`cfg!(windows)`, filter candidates through the existing shared utility
`cli_agent_runtime::has_windows_cli_extension` so .bat/.cmd/.com/.exe
win over extensionless POSIX scripts. Non-Windows behavior is unchanged.

Added a regression test in each file matching the
`windows_path_lookup_prefers_cmd_shim_over_extensionless_npm_script`
pattern from gemini_discovery.rs.
2026-05-19 23:28:16 -07:00

1620 lines
54 KiB
Rust

pub use crate::cli_agent_runtime::AgentStreamRequest;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::process::{ExitStatus, Stdio};
/// Status returned by `check_claude_cli`.
#[derive(Debug, Serialize, Clone)]
pub struct ClaudeCliStatus {
pub installed: bool,
pub version: Option<String>,
}
/// Event emitted to the frontend during a streaming claude session.
#[derive(Debug, Serialize, Clone)]
#[serde(tag = "kind")]
pub enum ClaudeStreamEvent {
/// Session initialised — carries the session ID for future `--resume`.
Init { session_id: String },
/// Incremental text chunk.
TextDelta { text: String },
/// Incremental thinking/reasoning chunk.
ThinkingDelta { text: String },
/// A tool call started (agent mode only).
ToolStart {
tool_name: String,
tool_id: String,
#[serde(skip_serializing_if = "Option::is_none")]
input: Option<String>,
},
/// A tool call finished (agent mode only).
ToolDone {
tool_id: String,
#[serde(skip_serializing_if = "Option::is_none")]
output: Option<String>,
},
/// Final result text + session ID.
Result { text: String, session_id: String },
/// Something went wrong.
Error { message: String },
/// Stream finished.
Done,
}
/// Parameters accepted by `stream_claude_chat`.
#[derive(Debug, Deserialize)]
pub struct ChatStreamRequest {
pub message: String,
pub system_prompt: Option<String>,
pub session_id: Option<String>,
}
// ---------------------------------------------------------------------------
// Finding the `claude` binary
// ---------------------------------------------------------------------------
pub(crate) fn find_claude_binary() -> Result<PathBuf, String> {
if let Some(binary) = find_claude_binary_on_path() {
return Ok(binary);
}
if let Some(binary) = find_claude_binary_in_user_shell() {
return Ok(binary);
}
if let Some(binary) = crate::cli_agent_runtime::find_executable_binary_candidate(
claude_binary_candidates(),
"Claude CLI",
)? {
return Ok(binary);
}
Err("Claude CLI not found. Install it: https://docs.anthropic.com/en/docs/claude-code".into())
}
fn find_claude_binary_on_path() -> Option<PathBuf> {
crate::hidden_command(claude_path_lookup_command())
.arg("claude")
.output()
.ok()
.and_then(|output| path_from_successful_output(&output))
}
fn claude_path_lookup_command() -> &'static str {
if cfg!(windows) {
"where"
} else {
"which"
}
}
fn find_claude_binary_in_user_shell() -> Option<PathBuf> {
user_shell_candidates()
.into_iter()
.filter(|shell| shell.exists())
.find_map(|shell| claude_path_from_shell(&shell))
}
fn user_shell_candidates() -> Vec<PathBuf> {
let mut shells = Vec::new();
if let Some(shell) = std::env::var_os("SHELL") {
if !shell.is_empty() {
shells.push(PathBuf::from(shell));
}
}
shells.push(PathBuf::from("/bin/zsh"));
shells.push(PathBuf::from("/bin/bash"));
shells
}
fn claude_path_from_shell(shell: &Path) -> Option<PathBuf> {
crate::hidden_command(shell)
.arg("-lc")
.arg("command -v claude")
.output()
.ok()
.and_then(|output| path_from_successful_output(&output))
}
fn path_from_successful_output(output: &std::process::Output) -> Option<PathBuf> {
if output.status.success() {
first_existing_path(&String::from_utf8_lossy(&output.stdout))
} else {
None
}
}
fn first_existing_path(stdout: &str) -> Option<PathBuf> {
first_existing_path_for_platform(stdout, cfg!(windows))
}
fn first_existing_path_for_platform(stdout: &str, windows: bool) -> Option<PathBuf> {
let mut paths = stdout.lines().filter_map(existing_path);
if windows {
return paths.find(|path| crate::cli_agent_runtime::has_windows_cli_extension(path));
}
paths.next()
}
fn existing_path(line: &str) -> Option<PathBuf> {
let trimmed = line.trim();
if trimmed.is_empty() {
return None;
}
let candidate = PathBuf::from(trimmed);
candidate.exists().then_some(candidate)
}
fn claude_binary_candidates() -> Vec<PathBuf> {
dirs::home_dir()
.map(|home| claude_binary_candidates_for_home(&home))
.unwrap_or_default()
}
fn claude_binary_candidates_for_home(home: &Path) -> Vec<PathBuf> {
let mut candidates = vec![
home.join(".local/bin/claude"),
home.join(".local/bin/claude.exe"),
home.join(".claude/local/claude"),
home.join(".claude/local/claude.exe"),
home.join(".local/share/mise/shims/claude"),
home.join(".local/share/mise/shims/claude.exe"),
home.join(".asdf/shims/claude"),
home.join(".asdf/shims/claude.exe"),
home.join(".npm-global/bin/claude"),
home.join(".npm-global/bin/claude.cmd"),
home.join(".npm-global/bin/claude.exe"),
home.join(".npm/bin/claude"),
home.join(".npm/bin/claude.cmd"),
home.join(".npm/bin/claude.exe"),
home.join(".linuxbrew/bin/claude"),
home.join("AppData/Roaming/npm/claude.cmd"),
home.join("AppData/Roaming/npm/claude.exe"),
home.join("AppData/Local/pnpm/claude.cmd"),
home.join("AppData/Local/pnpm/claude.exe"),
home.join("scoop/shims/claude.exe"),
PathBuf::from("/home/linuxbrew/.linuxbrew/bin/claude"),
PathBuf::from("/opt/homebrew/bin/claude"),
PathBuf::from("/usr/local/bin/claude"),
];
candidates.extend(nvm_claude_binary_candidates_for_home(home));
candidates
}
fn nvm_claude_binary_candidates_for_home(home: &Path) -> Vec<PathBuf> {
let Ok(entries) = std::fs::read_dir(home.join(".nvm/versions/node")) else {
return Vec::new();
};
let mut candidates = entries
.filter_map(Result::ok)
.map(|entry| entry.path())
.filter(|path| path.is_dir())
.map(|path| path.join("bin").join("claude"))
.collect::<Vec<_>>();
candidates.sort();
candidates
}
// ---------------------------------------------------------------------------
// Public Tauri commands
// ---------------------------------------------------------------------------
/// Check whether the `claude` CLI is installed and return its version.
pub fn check_cli() -> ClaudeCliStatus {
let bin = match find_claude_binary() {
Ok(b) => b,
Err(_) => {
return ClaudeCliStatus {
installed: false,
version: None,
}
}
};
ClaudeCliStatus {
installed: true,
version: crate::cli_agent_runtime::version_for_binary(&bin),
}
}
/// Spawn `claude -p` for a simple chat (no tools) and stream events via the
/// provided callback. Returns the session ID for future `--resume` calls.
pub fn run_chat_stream<F>(req: ChatStreamRequest, mut emit: F) -> Result<String, String>
where
F: FnMut(ClaudeStreamEvent),
{
let bin = find_claude_binary()?;
let invocation = crate::claude_invocation::chat(&req);
run_claude_subprocess(
ClaudeSubprocessRequest {
bin: &bin,
args: &invocation.args,
fallback_args: &invocation.fallback_args,
stdin_text: invocation.stdin_text.as_deref(),
cwd: None,
},
&mut emit,
)
}
/// Spawn `claude -p` with full tool access and MCP vault tools for an agent task.
pub fn run_agent_stream<F>(req: AgentStreamRequest, mut emit: F) -> Result<String, String>
where
F: FnMut(ClaudeStreamEvent),
{
let bin = find_claude_binary()?;
let invocation = crate::claude_invocation::agent(&req)?;
run_claude_subprocess(
ClaudeSubprocessRequest {
bin: &bin,
args: &invocation.args,
fallback_args: &invocation.fallback_args,
stdin_text: invocation.stdin_text.as_deref(),
cwd: Some(&req.vault_path),
},
&mut emit,
)
}
/// Mutable state accumulated across the JSON stream for a single subprocess.
struct StreamState {
session_id: String,
/// Accumulates `input_json_delta` chunks keyed by tool_use id.
tool_inputs: HashMap<String, String>,
/// The tool_use id of the block currently being streamed.
current_tool_id: Option<String>,
/// Tracks whether response text has already been emitted for this run.
emitted_text: bool,
}
struct ClaudeSubprocessRequest<'a> {
bin: &'a Path,
args: &'a [String],
fallback_args: &'a [Vec<String>],
stdin_text: Option<&'a str>,
cwd: Option<&'a str>,
}
struct ClaudeCommandRequest<'a> {
bin: &'a Path,
args: &'a [String],
cwd: Option<&'a str>,
}
#[derive(Clone, Copy)]
struct ClaudeStderr<'a>(&'a str);
impl<'a> ClaudeStderr<'a> {
fn is_empty(self) -> bool {
self.0.is_empty()
}
fn lines(self) -> std::str::Lines<'a> {
self.0.lines()
}
fn to_ascii_lowercase(self) -> String {
self.0.to_ascii_lowercase()
}
}
struct ClaudeFailure<'a> {
stderr: ClaudeStderr<'a>,
status: ExitStatus,
}
#[derive(Clone, Copy)]
struct TextDeltaChunk<'a>(&'a str);
impl<'a> TextDeltaChunk<'a> {
fn is_empty(self) -> bool {
self.0.is_empty()
}
fn as_str(self) -> &'a str {
self.0
}
}
#[derive(Clone, Copy)]
struct ToolUseId<'a>(&'a str);
impl<'a> ToolUseId<'a> {
fn as_str(self) -> &'a str {
self.0
}
}
/// Core subprocess runner shared by chat and agent modes.
/// When `cwd` is `Some`, the subprocess starts with that working directory.
fn run_claude_subprocess<F>(
request: ClaudeSubprocessRequest<'_>,
emit: &mut F,
) -> Result<String, String>
where
F: FnMut(ClaudeStreamEvent),
{
let attempts = std::iter::once(request.args)
.chain(request.fallback_args.iter().map(Vec::as_slice))
.enumerate();
for (index, attempt_args) in attempts {
let mut state = StreamState {
session_id: String::new(),
tool_inputs: HashMap::new(),
current_tool_id: None,
emitted_text: false,
};
let cmd = build_claude_command(ClaudeCommandRequest {
bin: request.bin,
args: attempt_args,
cwd: request.cwd,
})?;
let process = crate::cli_agent_runtime::JsonLineProcess::new(cmd, "claude")
.with_stdin(request.stdin_text);
let run = crate::cli_agent_runtime::run_json_line_process_with_stdin(
process,
emit,
|message| ClaudeStreamEvent::Error { message },
|json, emit, session_id| {
dispatch_event(json, &mut state, emit);
*session_id = state.session_id.clone();
},
)?;
if !run.status.success() && state.session_id.is_empty() {
let has_fallback = index < request.fallback_args.len();
let stderr = ClaudeStderr(&run.stderr_output);
if has_fallback && is_unsupported_claude_flag_error(stderr) {
continue;
}
emit(ClaudeStreamEvent::Error {
message: format_failed_claude_exit(ClaudeFailure {
stderr,
status: run.status,
}),
});
}
emit(ClaudeStreamEvent::Done);
return Ok(state.session_id);
}
Ok(String::new())
}
fn build_claude_command(
request: ClaudeCommandRequest<'_>,
) -> Result<std::process::Command, String> {
let target = crate::cli_agent_runtime::command_target_avoiding_windows_cmd_shim(request.bin)?;
let mut cmd = crate::hidden_command(&target.program);
crate::cli_agent_runtime::configure_agent_command_environment(&mut cmd, request.bin);
if let Some(first_arg) = target.first_arg {
cmd.arg(first_arg);
}
cmd.args(request.args)
.env_remove("CLAUDECODE") // prevent "nested session" guard
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped());
if let Some(dir) = request.cwd {
cmd.current_dir(dir);
}
Ok(cmd)
}
fn format_failed_claude_exit(failure: ClaudeFailure<'_>) -> String {
if is_claude_auth_error(failure.stderr) {
return "Claude CLI is not authenticated. Run `claude auth login` in your terminal.".into();
}
if failure.stderr.is_empty() {
format!("claude exited with status {}", failure.status)
} else {
failure
.stderr
.lines()
.take(3)
.collect::<Vec<_>>()
.join("\n")
}
}
fn is_claude_auth_error(stderr: ClaudeStderr<'_>) -> bool {
let lower = stderr.to_ascii_lowercase();
["not logged in", "authentication", "auth"]
.iter()
.any(|pattern| lower.contains(pattern))
}
fn is_unsupported_claude_flag_error(stderr: ClaudeStderr<'_>) -> bool {
let lower = stderr.to_ascii_lowercase();
let mentions_compat_flag = ["--tools", "--no-session-persistence"]
.iter()
.any(|flag| lower.contains(flag));
let looks_unsupported = [
"unknown option",
"unknown argument",
"unrecognized option",
"unexpected argument",
"unknown command-line option",
]
.iter()
.any(|pattern| lower.contains(pattern));
mentions_compat_flag && looks_unsupported
}
/// Parse a single JSON line from the stream and emit the appropriate event.
fn dispatch_event<F>(json: &serde_json::Value, state: &mut StreamState, emit: &mut F)
where
F: FnMut(ClaudeStreamEvent),
{
let msg_type = json["type"].as_str().unwrap_or("");
match msg_type {
// --- System init → capture session_id ---
"system" if json["subtype"].as_str() == Some("init") => {
if let Some(sid) = json["session_id"].as_str() {
state.session_id = sid.to_string();
emit(ClaudeStreamEvent::Init {
session_id: sid.to_string(),
});
}
}
// --- Streaming partial events (text deltas, tool_use starts) ---
"stream_event" => {
dispatch_stream_event(json, state, emit);
}
// --- Tool progress (agent mode) ---
"tool_progress" => {
if let (Some(name), Some(id)) =
(json["tool_name"].as_str(), json["tool_use_id"].as_str())
{
emit(ClaudeStreamEvent::ToolStart {
tool_name: name.to_string(),
tool_id: id.to_string(),
input: None,
});
}
}
// --- Tool result (agent mode) ---
"tool_result" => {
if let Some(id) = json["tool_use_id"].as_str() {
let output = extract_tool_result_text(json);
emit(ClaudeStreamEvent::ToolDone {
tool_id: id.to_string(),
output,
});
}
}
// --- Final result ---
"result" => {
let sid = json["session_id"].as_str().unwrap_or("").to_string();
if !sid.is_empty() {
state.session_id = sid.clone();
}
let text = if state.emitted_text {
String::new()
} else {
let text = json["result"].as_str().unwrap_or("").to_string();
if !text.is_empty() {
state.emitted_text = true;
}
text
};
emit(ClaudeStreamEvent::Result {
text,
session_id: sid,
});
}
// --- Complete assistant message (fallback for text when no partials) ---
"assistant" => {
if let Some(content) = json["message"]["content"].as_array() {
let emit_text = !state.emitted_text;
for block in content {
dispatch_assistant_content_block(block, emit_text, state, emit);
}
}
}
_ => {} // ignore other event types
}
}
/// Handle a `stream_event` (partial assistant message).
fn dispatch_stream_event<F>(json: &serde_json::Value, state: &mut StreamState, emit: &mut F)
where
F: FnMut(ClaudeStreamEvent),
{
let event = &json["event"];
let event_type = event["type"].as_str().unwrap_or("");
match event_type {
"content_block_delta" => {
let delta = &event["delta"];
match delta["type"].as_str() {
Some("text_delta") => {
if let Some(text) = delta["text"].as_str() {
emit_text_delta(TextDeltaChunk(text), state, emit);
}
}
Some("thinking_delta") => {
if let Some(text) = delta["thinking"].as_str() {
emit(ClaudeStreamEvent::ThinkingDelta {
text: text.to_string(),
});
}
}
Some("input_json_delta") => {
if let (Some(partial), Some(ref tid)) =
(delta["partial_json"].as_str(), &state.current_tool_id)
{
state
.tool_inputs
.entry(tid.clone())
.or_default()
.push_str(partial);
}
}
_ => {}
}
}
"content_block_start" => {
let block = &event["content_block"];
if block["type"].as_str() == Some("tool_use") {
if let (Some(id), Some(name)) = (block["id"].as_str(), block["name"].as_str()) {
state.current_tool_id = Some(id.to_string());
state.tool_inputs.entry(id.to_string()).or_default();
emit(ClaudeStreamEvent::ToolStart {
tool_name: name.to_string(),
tool_id: id.to_string(),
input: None,
});
}
}
}
"content_block_stop" => {
state.current_tool_id = None;
}
_ => {}
}
}
fn dispatch_assistant_content_block<F>(
block: &serde_json::Value,
emit_text: bool,
state: &mut StreamState,
emit: &mut F,
) where
F: FnMut(ClaudeStreamEvent),
{
match block["type"].as_str() {
Some("text") if emit_text => {
if let Some(text) = block["text"].as_str() {
emit_text_delta(TextDeltaChunk(text), state, emit);
}
}
Some("tool_use") => {
if let (Some(id), Some(name)) = (block["id"].as_str(), block["name"].as_str()) {
let input = format_tool_input(&block["input"], state, ToolUseId(id));
emit(ClaudeStreamEvent::ToolStart {
tool_name: name.to_string(),
tool_id: id.to_string(),
input,
});
}
}
_ => {}
}
}
fn emit_text_delta<F>(text: TextDeltaChunk<'_>, state: &mut StreamState, emit: &mut F)
where
F: FnMut(ClaudeStreamEvent),
{
if !text.is_empty() {
state.emitted_text = true;
}
emit(ClaudeStreamEvent::TextDelta {
text: text.as_str().to_string(),
});
}
/// Build the tool input string, preferring accumulated delta chunks over the
/// block's `input` field (which may be empty at stream start).
fn format_tool_input(
block_input: &serde_json::Value,
state: &StreamState,
tool_id: ToolUseId<'_>,
) -> Option<String> {
if let Some(accumulated) = state.tool_inputs.get(tool_id.as_str()) {
if !accumulated.is_empty() {
return Some(accumulated.clone());
}
}
if !block_input.is_null() && block_input.as_object().is_some_and(|o| !o.is_empty()) {
return Some(block_input.to_string());
}
None
}
/// Extract displayable text from a `tool_result` event.
fn extract_tool_result_text(json: &serde_json::Value) -> Option<String> {
// String content field
if let Some(s) = json["content"].as_str() {
return Some(s.to_string());
}
// Array of content blocks (Claude format)
if let Some(arr) = json["content"].as_array() {
let texts: Vec<&str> = arr.iter().filter_map(|b| b["text"].as_str()).collect();
if !texts.is_empty() {
return Some(texts.join("\n"));
}
}
// Fallback: "output" field
json["output"].as_str().map(|s| s.to_string())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::ai_agents::AiAgentPermissionMode;
use std::ffi::OsStr;
use std::ffi::OsString;
use std::process::Command;
#[cfg(target_os = "linux")]
fn current_test_binary() -> PathBuf {
std::fs::read_link("/proc/self/exe").unwrap()
}
#[cfg(target_os = "macos")]
fn current_test_binary() -> PathBuf {
let pid = std::process::id().to_string();
let output = Command::new("/bin/ps")
.args(["-p", pid.as_str(), "-o", "comm="])
.output()
.unwrap();
let path = String::from_utf8(output.stdout).unwrap();
PathBuf::from(path.trim())
}
fn assert_binary_candidates_include(home: &Path, expected: &[PathBuf]) {
let candidates = claude_binary_candidates_for_home(home);
for candidate in expected {
assert!(
candidates.contains(candidate),
"missing {}",
candidate.display()
);
}
}
#[test]
fn check_cli_returns_status() {
let status = check_cli();
if status.installed {
assert!(status.version.is_some());
} else {
assert!(status.version.is_none());
}
}
#[test]
fn claude_binary_candidates_include_nvm_managed_node_installs() {
let home = tempfile::tempdir().unwrap();
let claude = home.path().join(".nvm/versions/node/v22.12.0/bin/claude");
std::fs::create_dir_all(claude.parent().unwrap()).unwrap();
std::fs::write(&claude, "#!/bin/sh\n").unwrap();
let candidates = claude_binary_candidates_for_home(home.path());
assert!(candidates.contains(&claude), "missing {}", claude.display());
}
#[test]
fn windows_path_lookup_prefers_cmd_shim_over_extensionless_npm_script() {
let dir = tempfile::tempdir().unwrap();
let shell_script = dir.path().join("claude");
let cmd_shim = dir.path().join("claude.cmd");
std::fs::write(&shell_script, "#!/bin/sh\n").unwrap();
std::fs::write(&cmd_shim, "@ECHO off\n").unwrap();
let stdout = format!("{}\n{}\n", shell_script.display(), cmd_shim.display());
assert_eq!(
first_existing_path_for_platform(&stdout, true),
Some(cmd_shim)
);
}
#[test]
fn unsupported_claude_flag_errors_are_detected() {
assert!(is_unsupported_claude_flag_error(ClaudeStderr(
"error: unknown option '--tools'"
)));
assert!(is_unsupported_claude_flag_error(ClaudeStderr(
"Unknown argument: --no-session-persistence"
)));
assert!(!is_unsupported_claude_flag_error(ClaudeStderr(
"Claude CLI is not authenticated"
)));
}
// --- dispatch_event / dispatch_stream_event ---
fn new_state() -> StreamState {
StreamState {
session_id: String::new(),
tool_inputs: HashMap::new(),
current_tool_id: None,
emitted_text: false,
}
}
/// Run dispatch_event on the given JSON and return (session_id, events).
fn run_dispatch(json: serde_json::Value) -> (String, Vec<ClaudeStreamEvent>) {
let mut state = new_state();
let mut events = vec![];
dispatch_event(&json, &mut state, &mut |e| events.push(e));
(state.session_id, events)
}
#[derive(Clone, Copy)]
struct InitialSessionId<'a>(&'a str);
/// Run dispatch_event with a pre-set session_id.
fn run_dispatch_with_sid(
json: serde_json::Value,
initial_sid: InitialSessionId<'_>,
) -> (String, Vec<ClaudeStreamEvent>) {
let mut state = new_state();
state.session_id = initial_sid.0.to_string();
let mut events = vec![];
dispatch_event(&json, &mut state, &mut |e| events.push(e));
(state.session_id, events)
}
/// Run multiple dispatch_event calls sharing state (for multi-event sequences).
fn run_dispatch_sequence(
events_json: Vec<serde_json::Value>,
) -> (StreamState, Vec<ClaudeStreamEvent>) {
let mut state = new_state();
let mut events = vec![];
for json in &events_json {
dispatch_event(json, &mut state, &mut |e| events.push(e));
}
(state, events)
}
#[test]
fn dispatch_event_handles_init() {
let (sid, events) = run_dispatch(serde_json::json!({
"type": "system", "subtype": "init", "session_id": "test-session-123"
}));
assert_eq!(sid, "test-session-123");
assert!(
matches!(&events[0], ClaudeStreamEvent::Init { session_id } if session_id == "test-session-123")
);
}
#[test]
fn dispatch_event_system_without_init_subtype_is_ignored() {
let (_, events) = run_dispatch(serde_json::json!({ "type": "system", "subtype": "other" }));
assert!(events.is_empty());
}
#[test]
fn dispatch_event_system_init_without_session_id_is_ignored() {
let (sid, events) =
run_dispatch(serde_json::json!({ "type": "system", "subtype": "init" }));
assert!(events.is_empty());
assert!(sid.is_empty());
}
#[test]
fn dispatch_event_handles_text_delta() {
let (_, events) = run_dispatch(serde_json::json!({
"type": "stream_event",
"event": { "type": "content_block_delta", "index": 0, "delta": { "type": "text_delta", "text": "Hello" } }
}));
assert!(matches!(&events[0], ClaudeStreamEvent::TextDelta { text } if text == "Hello"));
}
#[test]
fn dispatch_event_handles_tool_start() {
let (_, events) = run_dispatch(serde_json::json!({
"type": "stream_event",
"event": { "type": "content_block_start", "index": 1, "content_block": { "type": "tool_use", "id": "tool_abc", "name": "read_note", "input": {} } }
}));
assert!(
matches!(&events[0], ClaudeStreamEvent::ToolStart { tool_name, tool_id, .. } if tool_name == "read_note" && tool_id == "tool_abc")
);
}
#[test]
fn dispatch_event_handles_result() {
let (sid, events) = run_dispatch(serde_json::json!({
"type": "result", "subtype": "success", "result": "All done!", "session_id": "sess-456"
}));
assert_eq!(sid, "sess-456");
assert!(
matches!(&events[0], ClaudeStreamEvent::Result { text, session_id } if text == "All done!" && session_id == "sess-456")
);
}
#[test]
fn dispatch_event_result_with_empty_session_id() {
let (sid, events) = run_dispatch_with_sid(
serde_json::json!({ "type": "result", "result": "text here" }),
InitialSessionId("prev-session"),
);
assert_eq!(sid, "prev-session");
assert!(
matches!(&events[0], ClaudeStreamEvent::Result { text, .. } if text == "text here")
);
}
#[test]
fn dispatch_event_handles_tool_progress() {
let (_, events) = run_dispatch(serde_json::json!({
"type": "tool_progress", "tool_name": "search_notes", "tool_use_id": "tool_xyz"
}));
assert!(
matches!(&events[0], ClaudeStreamEvent::ToolStart { tool_name, tool_id, .. } if tool_name == "search_notes" && tool_id == "tool_xyz")
);
}
#[test]
fn dispatch_event_tool_progress_missing_fields_is_ignored() {
let (_, events) =
run_dispatch(serde_json::json!({ "type": "tool_progress", "tool_name": "x" }));
assert!(events.is_empty());
}
#[test]
fn dispatch_event_handles_assistant_with_tool_use() {
let (_, events) = run_dispatch(serde_json::json!({
"type": "assistant",
"message": { "content": [
{ "type": "text", "text": "Let me search." },
{ "type": "tool_use", "id": "tu_1", "name": "search_notes", "input": {} }
] }
}));
assert_eq!(events.len(), 2);
assert!(
matches!(&events[0], ClaudeStreamEvent::TextDelta { text } if text == "Let me search.")
);
assert!(
matches!(&events[1], ClaudeStreamEvent::ToolStart { tool_name, tool_id, .. } if tool_name == "search_notes" && tool_id == "tu_1")
);
}
#[test]
fn dispatch_event_result_after_text_delta_does_not_duplicate_response_text() {
let (state, events) = run_dispatch_sequence(vec![
serde_json::json!({
"type": "stream_event",
"event": { "type": "content_block_delta", "delta": { "type": "text_delta", "text": "Visible reply" } }
}),
serde_json::json!({
"type": "result",
"session_id": "session-1",
"result": "Visible reply"
}),
]);
assert_eq!(state.session_id, "session-1");
assert!(matches!(&events[..],
[
ClaudeStreamEvent::TextDelta { text },
ClaudeStreamEvent::Result { text: result_text, session_id },
] if text == "Visible reply" && result_text.is_empty() && session_id == "session-1"));
}
#[test]
fn dispatch_event_assistant_without_content_is_noop() {
let (_, events) = run_dispatch(serde_json::json!({ "type": "assistant", "message": {} }));
assert!(events.is_empty());
}
#[test]
fn dispatch_event_ignores_unknown() {
let (_, events) =
run_dispatch(serde_json::json!({ "type": "some_future_type", "data": 42 }));
assert!(events.is_empty());
}
#[test]
fn dispatch_stream_event_input_json_delta_accumulates_silently() {
// input_json_delta doesn't emit events directly — it accumulates in state
let (_, events) = run_dispatch(serde_json::json!({
"type": "stream_event",
"event": { "type": "content_block_delta", "index": 0, "delta": { "type": "input_json_delta", "partial_json": "{}" } }
}));
assert!(events.is_empty());
}
#[test]
fn dispatch_stream_event_non_tool_block_start_is_ignored() {
let (_, events) = run_dispatch(serde_json::json!({
"type": "stream_event",
"event": { "type": "content_block_start", "index": 0, "content_block": { "type": "text", "text": "" } }
}));
assert!(events.is_empty());
}
#[test]
fn dispatch_stream_event_unknown_type_is_ignored() {
let (_, events) = run_dispatch(serde_json::json!({
"type": "stream_event", "event": { "type": "message_stop" }
}));
assert!(events.is_empty());
}
#[test]
fn dispatch_event_handles_tool_result_string_content() {
let (_, events) = run_dispatch(serde_json::json!({
"type": "tool_result",
"tool_use_id": "tool_abc",
"content": "Found 3 notes matching query"
}));
assert_eq!(events.len(), 1);
assert!(
matches!(&events[0], ClaudeStreamEvent::ToolDone { tool_id, output }
if tool_id == "tool_abc" && output.as_deref() == Some("Found 3 notes matching query"))
);
}
#[test]
fn dispatch_event_handles_tool_result_array_content() {
let (_, events) = run_dispatch(serde_json::json!({
"type": "tool_result",
"tool_use_id": "tool_def",
"content": [{ "type": "text", "text": "Line 1" }, { "type": "text", "text": "Line 2" }]
}));
assert!(
matches!(&events[0], ClaudeStreamEvent::ToolDone { output, .. }
if output.as_deref() == Some("Line 1\nLine 2"))
);
}
#[test]
fn dispatch_event_tool_result_missing_tool_id_is_ignored() {
let (_, events) = run_dispatch(serde_json::json!({
"type": "tool_result", "content": "result text"
}));
assert!(events.is_empty());
}
#[test]
fn dispatch_accumulates_input_json_deltas() {
let (_, events) = run_dispatch_sequence(vec![
// Start tool_use block
serde_json::json!({
"type": "stream_event",
"event": { "type": "content_block_start", "content_block": { "type": "tool_use", "id": "t1", "name": "search_notes", "input": {} } }
}),
// Input delta chunks
serde_json::json!({
"type": "stream_event",
"event": { "type": "content_block_delta", "delta": { "type": "input_json_delta", "partial_json": "{\"query\":" } }
}),
serde_json::json!({
"type": "stream_event",
"event": { "type": "content_block_delta", "delta": { "type": "input_json_delta", "partial_json": "\"test\"}" } }
}),
// Stop block
serde_json::json!({
"type": "stream_event",
"event": { "type": "content_block_stop" }
}),
// Assistant message triggers ToolStart with accumulated input
serde_json::json!({
"type": "assistant",
"message": { "content": [
{ "type": "tool_use", "id": "t1", "name": "search_notes", "input": { "query": "test" } }
] }
}),
]);
// First event: ToolStart with no input (from content_block_start)
assert!(matches!(
&events[0],
ClaudeStreamEvent::ToolStart { input: None, .. }
));
// Second event: ToolStart with accumulated input (from assistant)
assert!(
matches!(&events[1], ClaudeStreamEvent::ToolStart { input: Some(inp), .. }
if inp == "{\"query\":\"test\"}")
);
}
#[test]
fn dispatch_assistant_uses_block_input_when_no_deltas() {
let (_, events) = run_dispatch(serde_json::json!({
"type": "assistant",
"message": { "content": [
{ "type": "tool_use", "id": "tu_x", "name": "create_note", "input": { "title": "Hello", "content": "world" } }
] }
}));
assert!(
matches!(&events[0], ClaudeStreamEvent::ToolStart { input: Some(inp), .. }
if inp.contains("title") && inp.contains("Hello"))
);
}
#[test]
fn content_block_stop_clears_current_tool() {
let (state, _) = run_dispatch_sequence(vec![
serde_json::json!({
"type": "stream_event",
"event": { "type": "content_block_start", "content_block": { "type": "tool_use", "id": "t1", "name": "x", "input": {} } }
}),
serde_json::json!({
"type": "stream_event",
"event": { "type": "content_block_stop" }
}),
]);
assert!(state.current_tool_id.is_none());
}
// --- run_claude_subprocess with mock scripts ---
#[test]
fn build_claude_command_keeps_streaming_process_contract() {
let bin = PathBuf::from("claude");
let args = vec!["-p".to_string(), "hello".to_string()];
let command = build_claude_command(ClaudeCommandRequest {
bin: &bin,
args: &args,
cwd: Some("/tmp/vault"),
})
.unwrap();
let actual_args: Vec<OsString> = command.get_args().map(OsStr::to_os_string).collect();
let claude_code_env = command
.get_envs()
.find(|(key, _)| *key == OsStr::new("CLAUDECODE"))
.map(|(_, value)| value.map(OsStr::to_os_string));
assert_eq!(
(
command.get_program().to_os_string(),
actual_args,
command.get_current_dir().map(Path::to_path_buf),
claude_code_env,
),
(
OsString::from("claude"),
vec![OsString::from("-p"), OsString::from("hello")],
Some(PathBuf::from("/tmp/vault")),
Some(None),
),
);
}
#[test]
fn build_claude_command_avoids_windows_cmd_shim_for_prompt_args() {
let dir = tempfile::tempdir().unwrap();
let shim = dir.path().join("claude.cmd");
let script = dir
.path()
.join("node_modules")
.join("@anthropic-ai")
.join("claude-code")
.join("cli.js");
std::fs::create_dir_all(script.parent().unwrap()).unwrap();
std::fs::write(&script, "console.log('claude')\n").unwrap();
std::fs::write(
&shim,
r#"@ECHO off
"%_prog%" "%~dp0\node_modules\@anthropic-ai\claude-code\cli.js" %*
"#,
)
.unwrap();
let args = vec![
"-p".to_string(),
"Rename the note after reading the active vault".to_string(),
];
let command = build_claude_command(ClaudeCommandRequest {
bin: &shim,
args: &args,
cwd: Some("/tmp/vault"),
})
.unwrap();
let actual_args = command.get_args().collect::<Vec<_>>();
assert_ne!(
command.get_program(),
shim.as_os_str(),
"Claude npm .cmd shims cannot safely receive prompt args directly"
);
assert_eq!(actual_args.first().copied(), Some(script.as_os_str()));
assert!(actual_args.iter().any(|arg| *arg == OsStr::new("-p")));
assert!(actual_args
.iter()
.any(|arg| *arg == OsStr::new("Rename the note after reading the active vault")));
assert_eq!(command.get_current_dir(), Some(Path::new("/tmp/vault")));
}
#[cfg(unix)]
#[derive(Clone, Copy)]
struct MockClaudeScript<'a>(&'a str);
#[cfg(unix)]
#[derive(Clone, Copy)]
struct MockClaudeArgs<'a>(&'a [String]);
#[cfg(unix)]
#[derive(Clone, Copy)]
struct MockClaudeStdin<'a>(Option<&'a str>);
#[cfg(unix)]
fn run_mock_script(
script: MockClaudeScript<'_>,
) -> (Result<String, String>, Vec<ClaudeStreamEvent>) {
run_mock_script_with_args(script, MockClaudeArgs(&[]))
}
#[cfg(unix)]
fn run_mock_script_with_args(
script: MockClaudeScript<'_>,
args: MockClaudeArgs<'_>,
) -> (Result<String, String>, Vec<ClaudeStreamEvent>) {
run_mock_script_with_args_and_stdin(script, args, MockClaudeStdin(None))
}
#[cfg(unix)]
fn run_mock_script_with_args_and_stdin(
script: MockClaudeScript<'_>,
args: MockClaudeArgs<'_>,
stdin_text: MockClaudeStdin<'_>,
) -> (Result<String, String>, Vec<ClaudeStreamEvent>) {
use std::os::unix::fs::PermissionsExt;
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("mock-claude");
std::fs::write(&path, script.0).unwrap();
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).unwrap();
let mut events = vec![];
let result = run_claude_subprocess(
ClaudeSubprocessRequest {
bin: &path,
args: args.0,
fallback_args: &[],
stdin_text: stdin_text.0,
cwd: None,
},
&mut |e| events.push(e),
);
(result, events)
}
#[cfg(unix)]
#[test]
fn run_subprocess_parses_ndjson_stream() {
let (result, events) = run_mock_script(MockClaudeScript(concat!(
"#!/bin/sh\n",
"echo '{\"type\":\"system\",\"subtype\":\"init\",\"session_id\":\"s1\"}'\n",
"echo '{\"type\":\"stream_event\",\"event\":{\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"Hi\"}}}'\n",
"echo '{\"type\":\"result\",\"result\":\"Done\",\"session_id\":\"s1\"}'\n",
)));
assert_eq!(result.unwrap(), "s1");
assert!(matches!(&events[0], ClaudeStreamEvent::Init { session_id } if session_id == "s1"));
assert!(matches!(&events[1], ClaudeStreamEvent::TextDelta { text } if text == "Hi"));
assert!(matches!(&events[2], ClaudeStreamEvent::Result { .. }));
assert!(matches!(&events[3], ClaudeStreamEvent::Done));
}
#[cfg(unix)]
#[test]
fn run_subprocess_retries_with_fallback_args_for_removed_flags() {
use std::os::unix::fs::PermissionsExt;
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("mock-claude");
std::fs::write(
&path,
concat!(
"#!/bin/sh\n",
"for arg in \"$@\"; do\n",
" if [ \"$arg\" = \"--tools\" ]; then\n",
" echo \"error: unknown option '--tools'\" >&2\n",
" exit 1\n",
" fi\n",
"done\n",
"echo '{\"type\":\"system\",\"subtype\":\"init\",\"session_id\":\"fallback\"}'\n",
"echo '{\"type\":\"result\",\"result\":\"Done\",\"session_id\":\"fallback\"}'\n",
),
)
.unwrap();
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).unwrap();
let args = vec!["--tools".to_string(), "Read".to_string()];
let fallback_args = vec![vec!["--allowedTools".to_string(), "Read".to_string()]];
let mut events = vec![];
let result = run_claude_subprocess(
ClaudeSubprocessRequest {
bin: &path,
args: &args,
fallback_args: &fallback_args,
stdin_text: None,
cwd: None,
},
&mut |event| events.push(event),
);
assert_eq!(result.unwrap(), "fallback");
assert!(matches!(
events.first(),
Some(ClaudeStreamEvent::Init { session_id }) if session_id == "fallback"
));
assert!(!events
.iter()
.any(|event| matches!(event, ClaudeStreamEvent::Error { .. })));
assert!(matches!(events.last(), Some(ClaudeStreamEvent::Done)));
}
#[test]
fn run_subprocess_closes_stdin_even_when_parent_stdin_pipe_is_open() {
use std::io::Read;
use std::time::{Duration, Instant};
let mut child = Command::new(current_test_binary())
.arg("stdin_probe_parent_child")
.arg("--ignored")
.arg("--nocapture")
.env("TOLARIA_STDIN_PROBE_CHILD", "1")
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.unwrap();
let child_stdin = child.stdin.take().unwrap();
let mut stdout = child.stdout.take().unwrap();
let mut stderr = child.stderr.take().unwrap();
let deadline = Instant::now() + Duration::from_secs(5);
let status = loop {
if let Some(status) = child.try_wait().unwrap() {
break status;
}
if Instant::now() >= deadline {
child.kill().unwrap();
drop(child_stdin);
panic!("stdin probe child timed out");
}
std::thread::sleep(Duration::from_millis(10));
};
drop(child_stdin);
let mut stdout_text = String::new();
let mut stderr_text = String::new();
stdout.read_to_string(&mut stdout_text).unwrap();
stderr.read_to_string(&mut stderr_text).unwrap();
assert!(
status.success(),
"stdin probe child failed with {status}\nstdout:\n{stdout_text}\nstderr:\n{stderr_text}"
);
}
#[ignore = "spawned by run_subprocess_closes_stdin_even_when_parent_stdin_pipe_is_open"]
#[test]
fn stdin_probe_parent_child() {
if std::env::var_os("TOLARIA_STDIN_PROBE_CHILD").is_none() {
return;
}
let fake_bin = current_test_binary();
let args = vec![
"stdin_probe_mock_claude_child".to_string(),
"--ignored".to_string(),
"--nocapture".to_string(),
];
std::env::set_var("TOLARIA_STDIN_PROBE_MOCK_CLAUDE_CHILD", "1");
let mut events = vec![];
let result = run_claude_subprocess(
ClaudeSubprocessRequest {
bin: &fake_bin,
args: &args,
fallback_args: &[],
stdin_text: None,
cwd: None,
},
&mut |event| events.push(event),
);
std::env::remove_var("TOLARIA_STDIN_PROBE_MOCK_CLAUDE_CHILD");
assert_eq!(result.unwrap(), "stdin-ok");
assert!(matches!(
events.first(),
Some(ClaudeStreamEvent::Result { text, session_id })
if text == "stdin closed" && session_id == "stdin-ok"
));
assert!(matches!(events.last(), Some(ClaudeStreamEvent::Done)));
}
#[ignore = "spawned by stdin_probe_parent_child"]
#[test]
fn stdin_probe_mock_claude_child() {
if std::env::var_os("TOLARIA_STDIN_PROBE_MOCK_CLAUDE_CHILD").is_none() {
return;
}
use std::io::Read;
let mut stdin = String::new();
std::io::stdin().read_to_string(&mut stdin).unwrap();
assert!(stdin.is_empty(), "stdin was not EOF");
println!(
"{}",
serde_json::json!({
"type": "result",
"result": "stdin closed",
"session_id": "stdin-ok"
})
);
}
#[cfg(unix)]
#[test]
fn run_subprocess_skips_blank_and_non_json_lines() {
let (result, events) = run_mock_script(MockClaudeScript(concat!(
"#!/bin/sh\n",
"echo ''\n",
"echo 'not json at all'\n",
"echo '{\"type\":\"result\",\"result\":\"ok\",\"session_id\":\"s2\"}'\n",
)));
assert_eq!(result.unwrap(), "s2");
assert!(matches!(&events[0], ClaudeStreamEvent::Result { text, .. } if text == "ok"));
assert!(matches!(&events[1], ClaudeStreamEvent::Done));
}
#[cfg(unix)]
#[test]
fn run_subprocess_emits_error_on_nonzero_exit() {
let (_, events) = run_mock_script(MockClaudeScript(
"#!/bin/sh\necho 'auth problem' >&2\nexit 1\n",
));
assert!(events
.iter()
.any(|e| matches!(e, ClaudeStreamEvent::Error { .. })));
assert!(matches!(events.last().unwrap(), ClaudeStreamEvent::Done));
}
#[cfg(unix)]
#[test]
fn run_subprocess_detects_auth_error_in_stderr() {
let (_, events) = run_mock_script(MockClaudeScript(
"#!/bin/sh\necho 'not logged in' >&2\nexit 1\n",
));
assert!(events.iter().any(|e| matches!(e, ClaudeStreamEvent::Error { message } if message.contains("not authenticated"))));
}
#[cfg(unix)]
#[test]
fn run_subprocess_reports_exit_code_on_empty_stderr() {
let (_, events) = run_mock_script(MockClaudeScript("#!/bin/sh\nexit 2\n"));
assert!(events.iter().any(
|e| matches!(e, ClaudeStreamEvent::Error { message } if message.contains("exited with"))
));
}
#[cfg(unix)]
#[test]
fn run_subprocess_success_with_no_events() {
let (result, events) = run_mock_script(MockClaudeScript("#!/bin/sh\nexit 0\n"));
assert!(result.is_ok());
assert!(matches!(events.last().unwrap(), ClaudeStreamEvent::Done));
}
#[cfg(unix)]
#[test]
fn run_subprocess_passes_args_through() {
let args: Vec<String> = vec!["--foo".into(), "bar".into()];
let (_, events) = run_mock_script_with_args(
MockClaudeScript(concat!(
"#!/bin/sh\n",
"echo \"{\\\"type\\\":\\\"result\\\",\\\"result\\\":\\\"$*\\\",\\\"session_id\\\":\\\"sx\\\"}\"\n",
)),
MockClaudeArgs(&args),
);
let text = events.iter().find_map(|e| match e {
ClaudeStreamEvent::Result { text, .. } => Some(text.as_str()),
_ => None,
});
assert!(text.unwrap().contains("--foo"));
}
#[cfg(unix)]
#[test]
fn run_subprocess_writes_prompt_to_stdin() {
let args: Vec<String> = vec!["-p".into()];
let (result, events) = run_mock_script_with_args_and_stdin(
MockClaudeScript(concat!(
"#!/bin/sh\n",
"stdin=$(cat)\n",
"if [ \"$stdin\" != \"hello from stdin\" ]; then\n",
" echo \"unexpected stdin: $stdin\" >&2\n",
" exit 3\n",
"fi\n",
"echo '{\"type\":\"result\",\"result\":\"stdin ok\",\"session_id\":\"sx\"}'\n",
)),
MockClaudeArgs(&args),
MockClaudeStdin(Some("hello from stdin")),
);
assert_eq!(result.unwrap(), "sx");
assert!(events.iter().any(
|event| matches!(event, ClaudeStreamEvent::Result { text, .. } if text == "stdin ok")
));
}
// --- find_claude_binary ---
#[test]
fn claude_binary_candidates_include_supported_local_and_toolchain_installs() {
let home = PathBuf::from("/Users/alex");
let expected = [
home.join(".local/bin/claude"),
home.join(".claude/local/claude"),
home.join(".local/share/mise/shims/claude"),
home.join(".npm-global/bin/claude"),
];
assert_binary_candidates_include(&home, &expected);
}
#[test]
fn claude_binary_candidates_include_linuxbrew_installs() {
let home = PathBuf::from("/home/alex");
let expected = [
home.join(".linuxbrew/bin/claude"),
PathBuf::from("/home/linuxbrew/.linuxbrew/bin/claude"),
];
assert_binary_candidates_include(&home, &expected);
}
#[test]
fn claude_binary_candidates_include_windows_exe_installs() {
let home = PathBuf::from(r"C:\Users\alex");
let expected = [
home.join(".local/bin/claude.exe"),
home.join(".claude/local/claude.exe"),
home.join("AppData/Roaming/npm/claude.cmd"),
];
assert_binary_candidates_include(&home, &expected);
}
#[test]
fn claude_path_lookup_command_matches_current_platform() {
let expected = if cfg!(windows) { "where" } else { "which" };
assert_eq!(claude_path_lookup_command(), expected);
}
#[test]
fn find_existing_binary_finds_windows_exe_candidate() {
let dir = tempfile::tempdir().unwrap();
let claude = dir.path().join(".local/bin/claude.exe");
std::fs::create_dir_all(claude.parent().unwrap()).unwrap();
std::fs::write(&claude, "").unwrap();
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&claude, std::fs::Permissions::from_mode(0o755)).unwrap();
}
assert_eq!(
crate::cli_agent_runtime::find_executable_binary_candidate(
claude_binary_candidates_for_home(dir.path()),
"Claude CLI",
)
.unwrap(),
Some(claude)
);
}
#[test]
fn find_claude_binary_returns_result() {
let result = find_claude_binary();
// On dev machines claude may be installed; on CI it may not.
// Either way, the function should return Ok(path) or Err(message).
match &result {
Ok(path) => assert!(path.exists()),
Err(msg) => assert!(msg.contains("not found")),
}
}
// --- run_chat_stream / run_agent_stream error paths ---
#[test]
fn run_chat_stream_returns_result() {
let req = ChatStreamRequest {
message: "test".into(),
system_prompt: None,
session_id: None,
};
let mut events = vec![];
// This will either succeed (if claude is installed) or fail (if not).
let result = run_chat_stream(req, |e| events.push(e));
// Either way the function should have returned without panicking.
assert!(result.is_ok() || result.is_err());
}
#[test]
fn run_agent_stream_returns_result() {
let req = AgentStreamRequest {
message: "test".into(),
system_prompt: Some("sys".into()),
vault_path: "/tmp/nonexistent".into(),
vault_paths: Vec::new(),
permission_mode: AiAgentPermissionMode::Safe,
};
let mut events = vec![];
let result = run_agent_stream(req, |e| events.push(e));
assert!(result.is_ok() || result.is_err());
}
#[test]
fn run_subprocess_spawn_failure() {
let fake_bin = PathBuf::from("/nonexistent/binary/path");
let mut events = vec![];
let result = run_claude_subprocess(
ClaudeSubprocessRequest {
bin: &fake_bin,
args: &[],
fallback_args: &[],
stdin_text: None,
cwd: None,
},
&mut |e| events.push(e),
);
assert!(result.is_err());
assert!(result.unwrap_err().contains("Failed to start claude"));
}
#[cfg(unix)]
#[test]
fn run_subprocess_with_tool_progress_and_assistant() {
let (result, events) = run_mock_script(MockClaudeScript(concat!(
"#!/bin/sh\n",
"echo '{\"type\":\"system\",\"subtype\":\"init\",\"session_id\":\"s3\"}'\n",
"echo '{\"type\":\"tool_progress\",\"tool_name\":\"search\",\"tool_use_id\":\"t1\"}'\n",
"echo '{\"type\":\"assistant\",\"message\":{\"content\":[{\"type\":\"tool_use\",\"id\":\"t2\",\"name\":\"read\",\"input\":{}}]}}'\n",
"echo '{\"type\":\"result\",\"result\":\"fin\",\"session_id\":\"s3\"}'\n",
)));
assert_eq!(result.unwrap(), "s3");
assert!(events.len() >= 4);
}
#[cfg(unix)]
#[test]
fn run_subprocess_success_exit_with_session_id_skips_error() {
let (_, events) = run_mock_script(MockClaudeScript(concat!(
"#!/bin/sh\n",
"echo '{\"type\":\"system\",\"subtype\":\"init\",\"session_id\":\"s4\"}'\n",
"echo 'some warning' >&2\n",
"exit 1\n",
)));
// Should NOT have an error event because session_id is non-empty
assert!(!events
.iter()
.any(|e| matches!(e, ClaudeStreamEvent::Error { .. })));
}
}