1591 lines
53 KiB
Rust
1591 lines
53 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() {
|
|
return None;
|
|
}
|
|
|
|
String::from_utf8_lossy(&output.stdout)
|
|
.lines()
|
|
.find_map(|line| {
|
|
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 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 { .. })));
|
|
}
|
|
}
|