use std::{
collections::HashMap,
path::Path,
sync::{Arc, Mutex},
time::{Duration, Instant},
};
use murmur_artifact::InferenceConfig;
use serde_json::{json, Value};
use tokio::{
io::{AsyncBufReadExt, AsyncWriteExt, BufReader},
process::Command,
};
use crate::{
agent::{AgentLoopExit, PLAN_TOOL_NOTICE, UNTRUSTED_CONTENT_NOTICE},
errors::RuntimeError,
hooks::{HookEvent, HookRuntime},
murmur_md::MURMUR_MD_TRUST_NOTICE,
otel::OtelEmitter,
runtime::CapsuleStoreState,
streaming::{SseBroadcast, SseEventBuffer},
trace::TraceWriter,
};
use super::{claude_bridge, inventory::build_tool_inventory};
const BRIDGE_BIND_ADDR: &str = "127.0.0.1";
const CODEX_DISABLED_FEATURES: &[&str] = &[
"shell_tool",
"unified_exec",
"shell_snapshot",
"code_mode_host",
];
const CODEX_TOOL_GUIDANCE: &str = "\
You have NO shell, file, or built-in tools. Your ONLY capabilities are the MCP tools provided to \
you. For any file operation (read, write, edit, search) or other action, you MUST call the \
matching MCP tool — do not attempt native file access or report that the workspace is read-only. \
Inspect the available tools and use them to complete the task.";
#[derive(Clone, Copy, PartialEq, Eq)]
enum ProcessDialect {
Claude,
Codex,
}
impl ProcessDialect {
fn from_command(command: &str) -> Self {
let base = Path::new(command)
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or(command);
if base.eq_ignore_ascii_case("codex") {
Self::Codex
} else {
Self::Claude
}
}
}
const PROCESS_TIMEOUT_SECS: u64 = 600;
const STDERR_TAIL_CAP: usize = 4096;
fn format_stderr_tail(buf: &Arc<Mutex<String>>) -> String {
let captured = buf.lock().map(|g| g.clone()).unwrap_or_default();
let trimmed = captured.trim();
if trimmed.is_empty() {
String::new()
} else {
format!("\n--- subprocess stderr (tail) ---\n{trimmed}")
}
}
fn read_task_from_workdir(workdir: &Path) -> String {
std::fs::read_to_string(workdir.join("task.md")).unwrap_or_default()
}
pub(super) fn build_process_system_prompt(
system_prompt: Option<&str>,
plan_tool_present: bool,
) -> String {
let plan = if plan_tool_present {
format!("\n{PLAN_TOOL_NOTICE}")
} else {
String::new()
};
let notices = format!("{MURMUR_MD_TRUST_NOTICE}\n{UNTRUSTED_CONTENT_NOTICE}{plan}");
match system_prompt.filter(|sp| !sp.is_empty()) {
Some(sp) => format!("{notices}\n\n{sp}"),
None => notices,
}
}
fn build_process_args(
dialect: ProcessDialect,
inference: &InferenceConfig,
bridge: &Option<claude_bridge::BridgeHandle>,
system_prompt: Option<&str>,
plan_tool_present: bool,
task: &str,
) -> Vec<String> {
let system = build_process_system_prompt(system_prompt, plan_tool_present);
match dialect {
ProcessDialect::Claude => {
let mut args: Vec<String> = vec![
"--print".into(),
"--output-format".into(),
"stream-json".into(),
"--verbose".into(),
"--input-format".into(),
"stream-json".into(),
];
if !inference.model.trim().is_empty() {
args.push("--model".into());
args.push(inference.model.clone());
}
match bridge {
Some(b) => {
args.push("--tools".into());
args.push(b.allowed_tool_names.join(","));
args.push("--mcp-config".into());
args.push(b.mcp_config_json());
args.push("--strict-mcp-config".into());
args.push("--permission-mode".into());
args.push("bypassPermissions".into());
}
None => {
args.push("--tools".into());
args.push(String::new()); }
}
args.push("--system-prompt".into());
args.push(system);
args
}
ProcessDialect::Codex => {
let mut args: Vec<String> = vec![
"exec".into(),
"--json".into(),
"--skip-git-repo-check".into(),
"-s".into(),
"read-only".into(),
"-c".into(),
"include_apply_patch_tool=false".into(),
];
for feature in CODEX_DISABLED_FEATURES {
args.push("--disable".into());
args.push((*feature).into());
}
if !inference.model.trim().is_empty() {
args.push("-m".into());
args.push(inference.model.clone());
}
let guidance = if let Some(b) = bridge {
args.extend(b.codex_config_args());
format!("{CODEX_TOOL_GUIDANCE}\n\n")
} else {
String::new()
};
args.push(format!("{system}\n\n{guidance}{task}"));
args
}
}
}
#[allow(clippy::too_many_arguments)]
pub(crate) async fn run_process_inference_loop(
store_state: &CapsuleStoreState,
workdir: &Path,
inference: &InferenceConfig,
system_prompt: Option<String>,
hooks: &mut HookRuntime,
trace: &mut TraceWriter,
otel: &mut OtelEmitter,
task_id: Option<String>,
_sse: Option<(SseBroadcast, Arc<Mutex<SseEventBuffer>>)>,
accessible_workdir: &Path,
_name: &str,
_version: &str,
) -> Result<AgentLoopExit, RuntimeError> {
let command_name = inference
.command
.as_deref()
.filter(|s| !s.is_empty())
.unwrap_or("claude");
let max_turns = inference.max_turns;
let inventory = build_tool_inventory(workdir, inference.system_prompt_artifact.as_deref());
let bridge = claude_bridge::bind_bridge(BRIDGE_BIND_ADDR, &inventory).await;
otel.begin_session(None);
let task = super::fence_task_payload(
store_state.current_task_provenance,
read_task_from_workdir(accessible_workdir),
);
let dialect = ProcessDialect::from_command(command_name);
let args = build_process_args(
dialect,
inference,
&bridge,
system_prompt.as_deref(),
store_state.capability_policy.plan_submit,
&task,
);
let child_stdin = match dialect {
ProcessDialect::Claude => std::process::Stdio::piped(),
ProcessDialect::Codex => std::process::Stdio::null(),
};
let install_hint = match dialect {
ProcessDialect::Claude => "install the claude CLI from https://claude.ai/download",
ProcessDialect::Codex => {
"install the codex CLI from https://developers.openai.com/codex/cli"
}
};
let mut child = Command::new(command_name)
.args(&args)
.stdin(child_stdin)
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.spawn()
.map_err(|e| {
if e.kind() == std::io::ErrorKind::NotFound {
RuntimeError::AgentLoopFailed(format!(
"'{command_name}': binary not found on PATH\n\
hint: {install_hint}"
))
} else {
RuntimeError::AgentLoopFailed(format!("failed to spawn '{command_name}': {e}"))
}
})?;
let stderr_buf: Arc<Mutex<String>> = Arc::new(Mutex::new(String::new()));
if let Some(stderr) = child.stderr.take() {
let buf = Arc::clone(&stderr_buf);
tokio::spawn(async move {
let mut lines = BufReader::new(stderr).lines();
while let Ok(Some(line)) = lines.next_line().await {
if let Ok(mut b) = buf.lock() {
b.push_str(&line);
b.push('\n');
while b.len() > STDERR_TAIL_CAP {
match b.find('\n') {
Some(nl) => *b = b[nl + 1..].to_string(),
None => break,
}
}
}
}
});
}
if dialect == ProcessDialect::Claude {
let user_msg = serde_json::json!({
"type": "user",
"message": {
"role": "user",
"content": [{"type": "text", "text": task}]
}
});
let mut stdin = child.stdin.take().expect("stdin should be piped");
let line = format!("{}\n", serde_json::to_string(&user_msg).unwrap_or_default());
stdin.write_all(line.as_bytes()).await.map_err(|e| {
RuntimeError::AgentLoopFailed(format!("failed to write to subprocess stdin: {e}"))
})?;
}
let result = tokio::time::timeout(Duration::from_secs(PROCESS_TIMEOUT_SECS), async {
let read = async {
match dialect {
ProcessDialect::Claude => {
read_process_output(
&mut child,
workdir,
inference,
max_turns,
task_id.as_deref(),
hooks,
trace,
otel,
&stderr_buf,
)
.await
}
ProcessDialect::Codex => {
read_codex_output(
&mut child,
workdir,
max_turns,
hooks,
trace,
otel,
&stderr_buf,
)
.await
}
}
};
match &bridge {
Some(b) => {
tokio::select! {
r = read => r,
() = b.serve(store_state) => Err(RuntimeError::AgentLoopFailed(
"Claude Bridge server exited unexpectedly".into(),
)),
}
}
None => read.await,
}
})
.await;
let _ = child.kill().await;
match result {
Err(_elapsed) => {
otel.emit_session_end("failed").await;
Err(RuntimeError::AgentLoopFailed(format!(
"inference subprocess timed out after {PROCESS_TIMEOUT_SECS}s{}",
format_stderr_tail(&stderr_buf)
)))
}
Ok(inner) => inner,
}
}
struct PendingToolCall {
name: String,
input: Value,
input_bytes: u64,
turn: u32,
started: Instant,
}
fn strip_bridge_prefix(name: &str) -> &str {
name.strip_prefix(&format!("mcp__{}__", claude_bridge::BRIDGE_SERVER_KEY))
.unwrap_or(name)
}
fn tool_result_text(block: &Value) -> String {
match block.get("content") {
Some(Value::String(s)) => s.clone(),
Some(Value::Array(arr)) => arr
.iter()
.filter_map(|b| b.get("text").and_then(Value::as_str))
.collect::<Vec<_>>()
.join(""),
_ => String::new(),
}
}
fn is_thinking_only(content: Option<&Value>) -> bool {
match content.and_then(Value::as_array) {
Some(arr) if !arr.is_empty() => arr.iter().all(|b| {
matches!(
b.get("type").and_then(Value::as_str),
Some("thinking") | Some("redacted_thinking")
)
}),
_ => true,
}
}
#[allow(clippy::too_many_arguments)]
async fn read_process_output(
child: &mut tokio::process::Child,
workdir: &Path,
_inference: &InferenceConfig,
max_turns: u32,
task_id: Option<&str>,
hooks: &mut HookRuntime,
trace: &mut TraceWriter,
otel: &mut OtelEmitter,
stderr_buf: &Arc<Mutex<String>>,
) -> Result<AgentLoopExit, RuntimeError> {
let stdout = child.stdout.take().expect("stdout should be piped");
let mut lines = BufReader::new(stdout).lines();
let mut turns: u32 = 0;
let mut result_text = String::new();
let mut found_result = false;
let mut pending_tool_calls: HashMap<String, PendingToolCall> = HashMap::new();
while let Some(line) = lines.next_line().await.map_err(|e| {
RuntimeError::AgentLoopFailed(format!("failed to read subprocess stdout: {e}"))
})? {
let line = line.trim().to_string();
if line.is_empty() {
continue;
}
let event: Value = match serde_json::from_str(&line) {
Ok(v) => v,
Err(e) => {
return Err(RuntimeError::AgentLoopFailed(format!(
"malformed JSON on subprocess stdout: {e}: {line}{}",
format_stderr_tail(stderr_buf)
)));
}
};
let event_type = event.get("type").and_then(Value::as_str).unwrap_or("");
match event_type {
"assistant" => {
let content = event.get("message").and_then(|m| m.get("content"));
if is_thinking_only(content) {
continue;
}
turns += 1;
if turns > max_turns {
otel.emit_session_end("failed").await;
return Err(RuntimeError::AgentLoopFailed(format!(
"max_turns ({max_turns}) exceeded"
)));
}
let turn_idx = turns - 1;
let decision = if content
.and_then(Value::as_array)
.map(|arr| {
arr.iter()
.any(|b| b.get("type").and_then(Value::as_str) == Some("tool_use"))
})
.unwrap_or(false)
{
"tool_call"
} else {
"end_turn"
};
let _ = trace
.write_inference(
turn_idx,
0,
0,
decision.to_string(),
None,
None,
None,
None,
Vec::new(),
None,
)
.await;
otel.emit_inference(turn_idx, 0, 0, decision, None, None, 0, None, None)
.await;
hooks
.emit(
workdir,
HookEvent::Inference {
turn: turn_idx,
input_tokens: 0,
output_tokens: 0,
decision: decision.to_string(),
tool_name: None,
prompt: None,
output: content.map(|c| c.to_string()),
tools: None,
},
)
.await;
if let Some(blocks) = content.and_then(Value::as_array) {
for block in blocks {
if block.get("type").and_then(Value::as_str) != Some("tool_use") {
continue;
}
let Some(id) = block.get("id").and_then(Value::as_str) else {
continue;
};
let name = strip_bridge_prefix(
block
.get("name")
.and_then(Value::as_str)
.unwrap_or("<unknown>"),
)
.to_string();
let input = block.get("input").cloned().unwrap_or_else(|| json!({}));
let input_bytes = serde_json::to_string(&input)
.map(|s| s.len() as u64)
.unwrap_or(0);
pending_tool_calls.insert(
id.to_string(),
PendingToolCall {
name,
input,
input_bytes,
turn: turn_idx,
started: Instant::now(),
},
);
}
}
if let Some(tid) = task_id {
let _ = tid; }
}
"user" => {
let content = event
.get("message")
.and_then(|m| m.get("content"))
.and_then(Value::as_array);
if let Some(blocks) = content {
for block in blocks {
if block.get("type").and_then(Value::as_str) != Some("tool_result") {
continue;
}
let Some(id) = block.get("tool_use_id").and_then(Value::as_str) else {
continue;
};
let Some(pending) = pending_tool_calls.remove(id) else {
continue; };
let is_error = block
.get("is_error")
.and_then(Value::as_bool)
.unwrap_or(false);
let status = if is_error { "error" } else { "ok" };
let output = tool_result_text(block);
let output_bytes = output.len() as u64;
let duration_ms = pending
.started
.elapsed()
.as_millis()
.try_into()
.unwrap_or(u64::MAX);
let _ = trace
.write_tool_call(
pending.turn,
pending.name.clone(),
Some(id.to_string()),
pending.input.clone(),
pending.input_bytes,
&output,
output_bytes,
duration_ms,
status.to_string(),
None,
None,
)
.await;
hooks
.emit(
workdir,
HookEvent::ToolCall {
turn: pending.turn,
tool_name: pending.name,
input_bytes: pending.input_bytes,
input: pending.input.to_string(),
output_bytes,
duration_ms,
status: status.to_string(),
},
)
.await;
}
}
}
"result" => {
let subtype = event.get("subtype").and_then(Value::as_str).unwrap_or("");
if subtype == "success" {
result_text = event
.get("result")
.and_then(Value::as_str)
.unwrap_or("")
.to_string();
found_result = true;
} else {
let err_msg = event
.get("result")
.and_then(Value::as_str)
.unwrap_or(subtype);
otel.emit_session_end("failed").await;
return Err(RuntimeError::AgentLoopFailed(format!(
"claude returned error: {err_msg}"
)));
}
break;
}
_ => {}
}
}
if !found_result {
otel.emit_session_end("failed").await;
return Err(RuntimeError::AgentLoopFailed(format!(
"inference subprocess closed stdout without producing a result{}",
format_stderr_tail(stderr_buf)
)));
}
super::record_result(hooks, workdir, &result_text).map_err(RuntimeError::AgentLoopFailed)?;
otel.emit_session_end("ok").await;
Ok(AgentLoopExit::Ok)
}
async fn read_codex_output(
child: &mut tokio::process::Child,
workdir: &Path,
max_turns: u32,
hooks: &mut HookRuntime,
trace: &mut TraceWriter,
otel: &mut OtelEmitter,
stderr_buf: &Arc<Mutex<String>>,
) -> Result<AgentLoopExit, RuntimeError> {
let stdout = child.stdout.take().expect("stdout should be piped");
let mut lines = BufReader::new(stdout).lines();
let mut turns: u32 = 0;
let mut result_text = String::new();
let mut found_result = false;
while let Some(line) = lines.next_line().await.map_err(|e| {
RuntimeError::AgentLoopFailed(format!("failed to read subprocess stdout: {e}"))
})? {
let line = line.trim().to_string();
if line.is_empty() {
continue;
}
let Ok(event) = serde_json::from_str::<Value>(&line) else {
continue;
};
let event_type = event.get("type").and_then(Value::as_str).unwrap_or("");
match event_type {
"item.completed" => {
let Some(item) = event.get("item") else {
continue;
};
match item.get("type").and_then(Value::as_str).unwrap_or("") {
"reasoning" => {}
"agent_message" => {
turns += 1;
if turns > max_turns {
return codex_max_turns_error(max_turns, otel).await;
}
result_text = item
.get("text")
.and_then(Value::as_str)
.unwrap_or("")
.to_string();
let _ = trace
.write_inference(
turns - 1,
0,
0,
"end_turn".into(),
None,
None,
None,
None,
Vec::new(),
None,
)
.await;
otel.emit_inference(turns - 1, 0, 0, "end_turn", None, None, 0, None, None)
.await;
}
"mcp_tool_call" => {
turns += 1;
if turns > max_turns {
return codex_max_turns_error(max_turns, otel).await;
}
let tool_name = item
.get("tool")
.and_then(Value::as_str)
.unwrap_or("<unknown>")
.to_string();
let input = item.get("arguments").cloned().unwrap_or_else(|| json!({}));
let input_json = input.to_string();
let input_bytes = input_json.len() as u64;
let is_error = item.get("error").map(|e| !e.is_null()).unwrap_or(false);
let status = if is_error { "error" } else { "ok" };
let output = tool_result_text(item.get("result").unwrap_or(&Value::Null));
let output_bytes = output.len() as u64;
let _ = trace
.write_inference(
turns - 1,
0,
0,
"tool_call".into(),
None,
Some(tool_name.clone()),
None,
None,
Vec::new(),
None,
)
.await;
otel.emit_inference(
turns - 1,
0,
0,
"tool_call",
None,
Some(tool_name.as_str()),
0,
None,
None,
)
.await;
let _ = trace
.write_tool_call(
turns - 1,
tool_name.clone(),
None,
input,
input_bytes,
&output,
output_bytes,
0,
status.to_string(),
None,
None,
)
.await;
hooks
.emit(
workdir,
HookEvent::ToolCall {
turn: turns - 1,
tool_name,
input_bytes,
input: input_json,
output_bytes,
duration_ms: 0,
status: status.to_string(),
},
)
.await;
}
"command_execution" => {
let cmd = item.get("command").and_then(Value::as_str).unwrap_or("");
let status = if item.get("exit_code").and_then(Value::as_i64) == Some(0) {
"ok"
} else {
"error"
};
let _ = trace
.write_tool_call(
turns.saturating_sub(1),
format!("codex-shell: {cmd}"),
None,
json!({}),
0,
"",
0,
0,
status.to_string(),
None,
None,
)
.await;
}
_ => {}
}
}
"turn.completed" => {
found_result = true;
break;
}
"turn.failed" | "error" => {
let msg = event
.get("error")
.and_then(|e| e.get("message"))
.and_then(Value::as_str)
.or_else(|| event.get("message").and_then(Value::as_str))
.unwrap_or("codex turn failed");
otel.emit_session_end("failed").await;
return Err(RuntimeError::AgentLoopFailed(format!(
"codex returned error: {msg}{}",
format_stderr_tail(stderr_buf)
)));
}
_ => {}
}
}
if !found_result {
otel.emit_session_end("failed").await;
return Err(RuntimeError::AgentLoopFailed(format!(
"inference subprocess closed stdout without producing a result{}",
format_stderr_tail(stderr_buf)
)));
}
super::record_result(hooks, workdir, &result_text).map_err(RuntimeError::AgentLoopFailed)?;
otel.emit_session_end("ok").await;
Ok(AgentLoopExit::Ok)
}
async fn codex_max_turns_error(
max_turns: u32,
otel: &mut OtelEmitter,
) -> Result<AgentLoopExit, RuntimeError> {
otel.emit_session_end("failed").await;
Err(RuntimeError::AgentLoopFailed(format!(
"max_turns ({max_turns}) exceeded"
)))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn process_system_prompt_is_launch_invariant() {
for arg in [None, Some("You are a helpful assistant.")] {
let first = build_process_system_prompt(arg, false);
let second = build_process_system_prompt(arg, false);
assert_eq!(first, second, "same input must yield the same string");
assert!(first.contains(MURMUR_MD_TRUST_NOTICE));
assert!(first.contains(UNTRUSTED_CONTENT_NOTICE));
assert!(
!first.contains("[Capsule]"),
"the process transport injects no [Capsule] block; got:\n{first}"
);
assert!(
!first.split_whitespace().any(|t| t.starts_with('/')),
"no host path may appear in the prompt; got:\n{first}"
);
assert!(
!first.contains("ses_"),
"no session id may appear in the prompt; got:\n{first}"
);
}
}
#[test]
fn process_system_prompt_carries_trust_notice_with_no_custom_prompt() {
let prompt = build_process_system_prompt(None, false);
assert_eq!(
prompt,
format!("{MURMUR_MD_TRUST_NOTICE}\n{UNTRUSTED_CONTENT_NOTICE}")
);
}
#[test]
fn process_system_prompt_carries_trust_notice_alongside_custom_prompt() {
let prompt = build_process_system_prompt(Some("You are a helpful assistant."), false);
assert!(prompt.contains(MURMUR_MD_TRUST_NOTICE));
assert!(prompt.contains(UNTRUSTED_CONTENT_NOTICE));
assert!(prompt.contains("You are a helpful assistant."));
let notice_pos = prompt.find(MURMUR_MD_TRUST_NOTICE).unwrap();
let untrusted_pos = prompt.find(UNTRUSTED_CONTENT_NOTICE).unwrap();
let custom_pos = prompt.find("You are a helpful assistant.").unwrap();
assert!(notice_pos < custom_pos);
assert!(untrusted_pos < custom_pos);
}
fn test_inference(command: &str, model: &str) -> InferenceConfig {
InferenceConfig {
transport: "process".into(),
endpoint: None,
model: model.into(),
api_key: None,
driver: None,
command: Some(command.into()),
compaction: None,
system_prompt: None,
system_prompt_file: None,
system_prompt_artifact: None,
max_turns: 10,
max_tokens: None,
max_session_tokens: None,
}
}
#[test]
fn dialect_selected_from_command_basename() {
assert!(ProcessDialect::from_command("codex") == ProcessDialect::Codex);
assert!(ProcessDialect::from_command("/usr/local/bin/codex") == ProcessDialect::Codex);
assert!(ProcessDialect::from_command("claude") == ProcessDialect::Claude);
assert!(ProcessDialect::from_command("anything-else") == ProcessDialect::Claude);
}
#[test]
fn codex_args_disable_host_tools_and_carry_task_as_positional() {
let inf = test_inference("codex", "gpt-5.5");
let args = build_process_args(
ProcessDialect::Codex,
&inf,
&None,
None,
false,
"do the thing",
);
assert_eq!(args[0], "exec");
assert!(args.iter().any(|a| a == "--json"));
for f in CODEX_DISABLED_FEATURES {
assert!(
args.windows(2).any(|w| w[0] == "--disable" && w[1] == *f),
"missing --disable {f}"
);
}
assert!(args.iter().any(|a| a == "include_apply_patch_tool=false"));
assert!(args.windows(2).any(|w| w[0] == "-m" && w[1] == "gpt-5.5"));
assert!(args.last().unwrap().ends_with("do the thing"));
}
#[test]
fn codex_args_omit_model_when_empty() {
let inf = test_inference("codex", "");
let args = build_process_args(ProcessDialect::Codex, &inf, &None, None, false, "t");
assert!(
!args.iter().any(|a| a == "-m"),
"empty model must not pass -m (uses account default)"
);
}
#[test]
fn claude_args_unchanged_shape() {
let inf = test_inference("claude", "claude-opus-4-8");
let args = build_process_args(ProcessDialect::Claude, &inf, &None, None, false, "t");
assert_eq!(args[0], "--print");
assert!(args
.windows(2)
.any(|w| w[0] == "--model" && w[1] == "claude-opus-4-8"));
assert!(args
.windows(2)
.any(|w| w[0] == "--tools" && w[1].is_empty()));
assert!(args.iter().any(|a| a == "--system-prompt"));
}
#[test]
fn claude_args_omit_model_when_empty() {
let inf = test_inference("claude", "");
let args = build_process_args(ProcessDialect::Claude, &inf, &None, None, false, "t");
assert!(
!args.iter().any(|a| a == "--model"),
"empty model must not pass --model"
);
}
#[test]
fn strips_bridge_prefix_to_bare_tool_name() {
assert_eq!(
strip_bridge_prefix("mcp__claude_bridge__murmur-tool-editor"),
"murmur-tool-editor"
);
assert_eq!(strip_bridge_prefix("some-other-tool"), "some-other-tool");
assert_eq!(strip_bridge_prefix("mcp__other__t"), "mcp__other__t");
}
#[test]
fn tool_result_text_handles_string_and_array_content() {
use serde_json::json;
assert_eq!(tool_result_text(&json!({"content": "hi"})), "hi");
assert_eq!(
tool_result_text(
&json!({"content": [{"type": "text", "text": "a"}, {"type": "text", "text": "b"}]})
),
"ab"
);
assert_eq!(tool_result_text(&json!({})), "");
}
#[test]
fn thinking_only_events_are_skipped_but_actions_count() {
use serde_json::json;
assert!(is_thinking_only(Some(
&json!([{"type": "thinking", "thinking": "…"}])
)));
assert!(is_thinking_only(Some(
&json!([{"type": "redacted_thinking"}])
)));
assert!(is_thinking_only(Some(&json!([]))));
assert!(is_thinking_only(None));
assert!(!is_thinking_only(Some(
&json!([{"type": "text", "text": "hi"}])
)));
assert!(!is_thinking_only(Some(
&json!([{"type": "tool_use", "name": "editor"}])
)));
assert!(!is_thinking_only(Some(&json!([
{"type": "thinking"}, {"type": "tool_use", "name": "editor"}
]))));
}
#[test]
fn process_system_prompt_treats_empty_string_as_absent() {
let prompt = build_process_system_prompt(Some(""), false);
assert_eq!(
prompt,
format!("{MURMUR_MD_TRUST_NOTICE}\n{UNTRUSTED_CONTENT_NOTICE}")
);
}
}