use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::time::{Duration, Instant};
use crate::acp::client::AcpClient;
use crate::models::agent::{Agent, AgentEnvVar};
pub(crate) const SPAWN_TIMEOUT: Duration = Duration::from_secs(10);
pub(crate) const UNWIND_TIMEOUT: Duration = Duration::from_secs(5);
pub(crate) const KILL_TIMEOUT: Duration = Duration::from_millis(750);
pub(crate) const REAP_TIMEOUT: Duration = Duration::from_secs(5);
const FAKE_AGENT_LIFETIME: &str = "25";
pub(crate) const FAKE_AGENT_SCRIPT: &str = r#"#!/bin/sh
# test-only fake ACP agent(计划 D5)。响应 initialize(声明 loadSession,restore
# 路径也要能建连)与 session/new,之后按 FAKE_MODE 行动。
log_event() { [ -n "$FAKE_EVENTS_FILE" ] && printf '%s\n' "$1" >> "$FAKE_EVENTS_FILE"; }
if [ -n "$FAKE_DUP_FILE" ] && [ -n "$FAKE_DUP" ]; then
printf '%s' "$FAKE_DUP" > "$FAKE_DUP_FILE"
fi
if [ "$FAKE_MODE" = "hang" ]; then
exec sleep @LIFETIME@
fi
while IFS= read -r line; do
case "$line" in
*'"method":"initialize"'*)
id=$(printf '%s' "$line" | sed -n 's/.*"id":"\([0-9a-f-][0-9a-f-]*\)".*/\1/p')
printf '{"jsonrpc":"2.0","id":"%s","result":{"protocolVersion":1,"agentCapabilities":{"loadSession":true,"promptCapabilities":{}}}}\n' "$id"
# handshake 模式:响应 initialize 后立刻崩溃(session/new 永远等不到
# 响应)——建模「agent 死于握手期」,spawn 必须限时失败而不是挂死。
if [ "$FAKE_MODE" = "handshake" ]; then
exit 3
fi
;;
*'"method":"session/new"'*)
id=$(printf '%s' "$line" | sed -n 's/.*"id":"\([0-9a-f-][0-9a-f-]*\)".*/\1/p')
printf '{"jsonrpc":"2.0","id":"%s","result":{"sessionId":"fake-session"}}\n' "$id"
if [ "$FAKE_MODE" = "group" ]; then
sleep @LIFETIME@ &
echo $! > "$FAKE_GRANDCHILD_PID_FILE"
fi
if [ "$FAKE_MODE" = "exit" ]; then
while [ ! -f "$FAKE_EXIT_FILE" ]; do sleep 1; done
exit 3
fi
;;
*'"method":"session/prompt"'*)
prompt_id=$(printf '%s' "$line" | sed -n 's/.*"id":"\([0-9a-f-][0-9a-f-]*\)".*/\1/p')
log_event "prompt"
case "$FAKE_MODE" in
crash)
# agent 在 turn 进行中崩溃——不回响应,直接退出。
exit 3
;;
prompt_ok)
printf '{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"fake-session","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"hello-from-agent"}}}}\n'
printf '{"jsonrpc":"2.0","id":"%s","result":{"stopReason":"end_turn"}}\n' "$prompt_id"
;;
perm)
printf '{"jsonrpc":"2.0","id":"perm-1","method":"session/request_permission","params":{"sessionId":"fake-session","toolCall":{"toolCallId":"t1","title":"Bash","kind":"execute","content":"echo hi"},"options":[{"optionId":"allow","name":"Allow","kind":"allow_once"},{"optionId":"deny","name":"Deny","kind":"reject_once"}]}}\n'
;;
term)
printf '{"jsonrpc":"2.0","id":"term-create","method":"terminal/create","params":{"sessionId":"fake-session","command":"/bin/sh","args":["-c","printf term-hello; exit 7"]}}\n'
;;
termkill)
printf '{"jsonrpc":"2.0","id":"term-create","method":"terminal/create","params":{"sessionId":"fake-session","command":"/bin/sh","args":["-c","sleep 30"]}}\n'
;;
esac
;;
*'"method":"session/cancel"'*)
log_event "cancel"
# cancel 模式的正常收尾;perm 模式下 cancel 与审批应答可能竞速,
# prompt 应答重复发送无害(客户端只认第一次响应,后者报 id 未知)。
printf '{"jsonrpc":"2.0","id":"%s","result":{"stopReason":"cancelled"}}\n' "$prompt_id"
;;
*'"id":"perm-1"'*)
log_event "perm-response $line"
printf '{"jsonrpc":"2.0","id":"%s","result":{"stopReason":"end_turn"}}\n' "$prompt_id"
;;
*'"id":"term-create"'*)
term_id=$(printf '%s' "$line" | sed -n 's/.*"terminalId":"\([^"]*\)".*/\1/p')
log_event "term-create $term_id"
if [ "$FAKE_MODE" = "termkill" ]; then
printf '{"jsonrpc":"2.0","id":"term-kill","method":"terminal/kill","params":{"sessionId":"fake-session","terminalId":"%s"}}\n' "$term_id"
else
printf '{"jsonrpc":"2.0","id":"term-wait","method":"terminal/wait_for_exit","params":{"sessionId":"fake-session","terminalId":"%s"}}\n' "$term_id"
fi
;;
*'"id":"term-wait"'*)
log_event "term-wait $line"
printf '{"jsonrpc":"2.0","id":"term-output","method":"terminal/output","params":{"sessionId":"fake-session","terminalId":"%s"}}\n' "$term_id"
;;
*'"id":"term-output"'*)
log_event "term-output $line"
printf '{"jsonrpc":"2.0","id":"term-release","method":"terminal/release","params":{"sessionId":"fake-session","terminalId":"%s"}}\n' "$term_id"
;;
*'"id":"term-release"'*)
log_event "term-release $line"
printf '{"jsonrpc":"2.0","id":"%s","result":{"stopReason":"end_turn"}}\n' "$prompt_id"
;;
*'"id":"term-kill"'*)
log_event "term-kill $line"
printf '{"jsonrpc":"2.0","id":"term-killwait","method":"terminal/wait_for_exit","params":{"sessionId":"fake-session","terminalId":"%s"}}\n' "$term_id"
;;
*'"id":"term-killwait"'*)
log_event "term-killwait $line"
printf '{"jsonrpc":"2.0","id":"%s","result":{"stopReason":"end_turn"}}\n' "$prompt_id"
;;
esac
done
exit 0
"#;
pub(crate) fn unique_dir(tag: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!(
"omniterm-fake-agent-{tag}-{}-{}",
std::process::id(),
chrono::Utc::now().timestamp_nanos_opt().unwrap_or(0)
));
std::fs::create_dir_all(&dir).expect("create fake agent test dir");
dir
}
pub(crate) fn write_fake_agent(dir: &Path) -> PathBuf {
let script = dir.join("fake-agent.sh");
let content = FAKE_AGENT_SCRIPT.replace("@LIFETIME@", FAKE_AGENT_LIFETIME);
std::fs::write(&script, content).expect("write fake agent script");
script
}
pub(crate) fn agent_for(script: &Path, mode: &str, dir: &Path) -> Agent {
agent_for_with_env(script, mode, dir, Vec::new())
}
pub(crate) fn agent_for_with_env(
script: &Path,
mode: &str,
dir: &Path,
extra: Vec<AgentEnvVar>,
) -> Agent {
let mut env = vec![
AgentEnvVar { key: "FAKE_MODE".into(), value: mode.into() },
AgentEnvVar {
key: "FAKE_GRANDCHILD_PID_FILE".into(),
value: dir.join("grandchild.pid").to_string_lossy().to_string(),
},
AgentEnvVar {
key: "FAKE_EXIT_FILE".into(),
value: dir.join("exit.gate").to_string_lossy().to_string(),
},
AgentEnvVar {
key: "FAKE_EVENTS_FILE".into(),
value: events_file(dir).to_string_lossy().to_string(),
},
AgentEnvVar {
key: "FAKE_DUP_FILE".into(),
value: dir.join("dup.txt").to_string_lossy().to_string(),
},
];
env.extend(extra);
Agent {
id: "fake-agent".into(),
display_name: "Fake ACP Agent".into(),
command: "/bin/sh".into(),
args: vec![script.to_string_lossy().to_string()],
env,
npm_package: None,
created_at: String::new(),
updated_at: String::new(),
}
}
pub(crate) fn events_file(dir: &Path) -> PathBuf {
dir.join("events.log")
}
pub(crate) fn read_events(dir: &Path) -> String {
std::fs::read_to_string(events_file(dir)).unwrap_or_default()
}
pub(crate) async fn spawn_connect(agent: Agent, cwd: PathBuf) -> AcpClient {
tokio::time::timeout(SPAWN_TIMEOUT, AcpClient::spawn_and_connect(agent, cwd, &HashMap::new()))
.await
.expect("spawn 超时:fake agent 未在限时内完成 initialize/session/new")
.expect("spawn_and_connect 失败")
}
pub(crate) fn proc_state(pid: u32) -> Option<char> {
let stat = std::fs::read_to_string(format!("/proc/{pid}/stat")).ok()?;
let after_comm = stat.rsplit_once(')')?.1;
after_comm.split_whitespace().next().and_then(|s| s.chars().next())
}
pub(crate) fn proc_alive(pid: u32) -> bool {
matches!(proc_state(pid), Some(state) if state != 'Z')
}
pub(crate) fn proc_dead(pid: u32) -> bool {
!proc_alive(pid)
}
pub(crate) fn proc_reaped(pid: u32) -> bool {
proc_state(pid).is_none()
}
pub(crate) async fn wait_until(mut cond: impl FnMut() -> bool, timeout: Duration) -> bool {
let deadline = Instant::now() + timeout;
loop {
if cond() {
return true;
}
let now = Instant::now();
if now >= deadline {
return cond();
}
tokio::time::sleep(Duration::from_millis(10).min(deadline - now)).await;
}
}
pub(crate) async fn wait_until_some<T>(
mut f: impl FnMut() -> Option<T>,
timeout: Duration,
) -> Option<T> {
let deadline = Instant::now() + timeout;
loop {
if let Some(v) = f() {
return Some(v);
}
let now = Instant::now();
if now >= deadline {
return f();
}
tokio::time::sleep(Duration::from_millis(10).min(deadline - now)).await;
}
}
pub(crate) async fn wait_for_event(dir: &Path, needle: &str, timeout: Duration) -> bool {
wait_until(|| read_events(dir).contains(needle), timeout).await
}