use std::io::{BufRead, BufReader, Write};
use std::path::{Path, PathBuf};
use std::process::{Child, ChildStdin, Command, Stdio};
use std::sync::{Arc, Mutex};
use serde::Deserialize;
use serde_json::Value;
use crate::settings::Settings;
use crate::vault::VaultError;
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase", default)]
pub struct AgentConfig {
pub command: String,
pub runtime: String,
pub vault: String,
pub provider: String,
pub model: String,
pub thinking: String,
pub api_key: String,
pub base_url: String,
pub trust_project: bool,
pub context_files: bool,
pub session_dir: String,
pub extra_args: Vec<String>,
pub extensions: Vec<String>,
pub append_system_prompt: String,
}
impl Default for AgentConfig {
fn default() -> Self {
Self {
command: String::new(),
runtime: "auto".to_string(),
vault: String::new(),
provider: String::new(),
model: String::new(),
thinking: String::new(),
api_key: String::new(),
base_url: String::new(),
trust_project: true,
context_files: true,
session_dir: String::new(),
extra_args: Vec::new(),
extensions: Vec::new(),
append_system_prompt: String::new(),
}
}
}
impl AgentConfig {
fn trimmed(value: &str) -> Option<String> {
let trimmed = value.trim();
if trimmed.is_empty() {
None
} else {
Some(trimmed.to_string())
}
}
pub fn from_settings(settings: &Settings, vault: &Path) -> Self {
let ai = &settings.ai;
AgentConfig {
command: ai.agent_command.clone(),
runtime: ai.runtime.clone(),
vault: vault.to_string_lossy().to_string(),
provider: ai.provider.clone(),
model: ai.model.clone(),
thinking: ai.thinking.clone(),
api_key: ai.api_key.clone(),
base_url: ai.base_url.clone(),
trust_project: ai.trust_project,
context_files: ai.context_files,
session_dir: ai.session_dir.clone(),
extra_args: ai.extra_args.clone(),
extensions: ai.extensions.clone(),
append_system_prompt: String::new(),
}
}
pub fn resolve(values: &Value, settings: &Settings, vault: Option<&Path>) -> Self {
let defaults = match vault {
Some(root) => AgentConfig::from_settings(settings, root),
None => AgentConfig {
command: settings.ai.agent_command.clone(),
..AgentConfig::default()
},
};
let patch: AgentConfigPatch = serde_json::from_value(values.clone()).unwrap_or_default();
AgentConfig {
command: patch.command.unwrap_or(defaults.command),
runtime: patch.runtime.unwrap_or(defaults.runtime),
vault: patch.vault.unwrap_or(defaults.vault),
provider: patch.provider.unwrap_or(defaults.provider),
model: patch.model.unwrap_or(defaults.model),
thinking: patch.thinking.unwrap_or(defaults.thinking),
api_key: patch.api_key.unwrap_or(defaults.api_key),
base_url: patch.base_url.unwrap_or(defaults.base_url),
trust_project: patch.trust_project.unwrap_or(defaults.trust_project),
context_files: patch.context_files.unwrap_or(defaults.context_files),
session_dir: patch.session_dir.unwrap_or(defaults.session_dir),
extra_args: patch.extra_args.unwrap_or(defaults.extra_args),
extensions: patch.extensions.unwrap_or(defaults.extensions),
append_system_prompt: patch
.append_system_prompt
.unwrap_or(defaults.append_system_prompt),
}
}
pub fn child_env(&self, cli: &Path) -> Vec<(String, String)> {
let mut env = vec![
("TASK_LENS_CLI".to_string(), cli.to_string_lossy().to_string()),
("NO_COLOR".to_string(), "1".to_string()),
];
if !self.vault.trim().is_empty() {
env.push(("TASK_LENS_VAULT".to_string(), self.vault.clone()));
}
if let Some(base) = Self::trimmed(&self.base_url) {
env.push(("OPENAI_BASE_URL".to_string(), base));
}
env
}
pub fn resolved_extensions(&self) -> Vec<String> {
let vault = Path::new(self.vault.trim());
self.extensions
.iter()
.filter_map(|raw| {
let trimmed = raw.trim();
if trimmed.is_empty() {
return None;
}
let candidate = Path::new(trimmed);
let absolute = if candidate.is_absolute() {
candidate.to_path_buf()
} else {
vault.join(candidate)
};
if absolute.is_file() {
Some(absolute.to_string_lossy().to_string())
} else {
None
}
})
.collect()
}
pub fn missing_extensions(&self) -> Vec<String> {
let vault = Path::new(self.vault.trim());
self.extensions
.iter()
.filter(|raw| !raw.trim().is_empty())
.filter(|raw| {
let candidate = Path::new(raw.trim());
let absolute = if candidate.is_absolute() {
candidate.to_path_buf()
} else {
vault.join(candidate)
};
!absolute.is_file()
})
.map(|raw| raw.trim().to_string())
.collect()
}
pub fn host_payload(&self) -> Value {
serde_json::json!({
"vault": self.vault,
"provider": self.provider,
"model": self.model,
"thinking": self.thinking,
"apiKey": self.api_key,
"baseUrl": self.base_url,
"contextFiles": self.context_files,
"sessionDir": self.session_dir,
"extensions": self.resolved_extensions(),
})
}
pub fn build_args(&self) -> Vec<String> {
let mut args = vec!["--mode".to_string(), "rpc".to_string()];
let mut push_flag = |flag: &str, value: Option<String>| {
if let Some(value) = value {
args.push(flag.to_string());
args.push(value);
}
};
push_flag("--provider", Self::trimmed(&self.provider));
push_flag("--model", Self::trimmed(&self.model));
push_flag("--thinking", Self::trimmed(&self.thinking));
push_flag("--api-key", Self::trimmed(&self.api_key));
push_flag("--session-dir", Self::trimmed(&self.session_dir));
push_flag(
"--append-system-prompt",
Self::trimmed(&self.append_system_prompt),
);
args.push(if self.trust_project { "--approve" } else { "--no-approve" }.to_string());
if !self.context_files {
args.push("--no-context-files".to_string());
}
for extension in self.resolved_extensions() {
args.push("-e".to_string());
args.push(extension);
}
for extra in &self.extra_args {
let trimmed = extra.trim();
if !trimmed.is_empty() {
args.push(trimmed.to_string());
}
}
args
}
}
#[derive(Debug, Default, Deserialize)]
#[serde(rename_all = "camelCase", default)]
struct AgentConfigPatch {
command: Option<String>,
runtime: Option<String>,
vault: Option<String>,
provider: Option<String>,
model: Option<String>,
thinking: Option<String>,
api_key: Option<String>,
base_url: Option<String>,
trust_project: Option<bool>,
context_files: Option<bool>,
session_dir: Option<String>,
extra_args: Option<Vec<String>>,
extensions: Option<Vec<String>>,
append_system_prompt: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LaunchPlan {
pub program: PathBuf,
pub args: Vec<String>,
pub needs_shell: bool,
pub embedded: bool,
}
pub fn exe_dir() -> PathBuf {
std::env::current_exe()
.ok()
.and_then(|path| path.parent().map(Path::to_path_buf))
.unwrap_or_else(|| PathBuf::from("."))
}
pub fn cli_binary(exe_dir: &Path) -> PathBuf {
let name = if cfg!(windows) { "tasklens-cli.exe" } else { "tasklens-cli" };
let sibling = exe_dir.join(name);
if sibling.is_file() {
return sibling;
}
let extensions: &[&str] = if cfg!(windows) { &[".exe", ".cmd", ".bat"] } else { &[""] };
let path_env = std::env::var("PATH").unwrap_or_default();
if let Some(installed) = find_in_path("tasklens-cli", &path_env, extensions, &|candidate: &Path| {
candidate.is_file()
}) {
return installed;
}
std::env::current_exe().unwrap_or_else(|_| PathBuf::from(name))
}
const HOST_ENTRIES: [&str; 2] = ["agent-host/host.mjs", "agent-host/index.ts"];
const HOST_ENV: &str = "TASK_LENS_AGENT_HOST";
pub fn host_candidates(exe_dir: &Path) -> Vec<PathBuf> {
let mut candidates = Vec::new();
if let Ok(explicit) = std::env::var(HOST_ENV) {
if !explicit.trim().is_empty() {
candidates.push(PathBuf::from(explicit));
}
}
for entry in HOST_ENTRIES {
candidates.push(exe_dir.join("resources").join(entry));
}
for entry in HOST_ENTRIES {
candidates.push(exe_dir.join(entry));
}
let mut directory = exe_dir.to_path_buf();
for _ in 0..4 {
if !directory.pop() {
break;
}
for entry in HOST_ENTRIES {
candidates.push(directory.join(entry));
}
}
candidates
}
fn pick_runtime(requested: &str) -> Result<(&'static str, PathBuf, bool), VaultError> {
let wanted = requested.trim();
let candidates: &[&str] = match wanted {
"" | "auto" => &["bun", "node"],
"bun" => &["bun"],
"node" => &["node"],
other => {
return Err(VaultError(format!(
"未知的 JS 运行时「{other}」:只能是 auto / bun / node"
)))
}
};
for candidate in candidates {
let (program, needs_shell) = resolve_command(candidate);
if program.is_file() {
return Ok((candidate, program, needs_shell));
}
}
Err(VaultError(format!(
"未找到可用的 JS 运行时(尝试了 {}):内置 Pi 组件需要 bun 或 node",
candidates.join(" / ")
)))
}
pub fn launch_plan(config: &AgentConfig) -> Result<LaunchPlan, VaultError> {
let external = config.command.trim();
if !external.is_empty() {
let (program, needs_shell) = resolve_command(external);
return Ok(LaunchPlan {
program,
args: config.build_args(),
needs_shell,
embedded: false,
});
}
let exe_dir = exe_dir();
let host = host_candidates(&exe_dir)
.into_iter()
.find(|candidate| candidate.is_file())
.ok_or_else(|| {
VaultError(format!(
"找不到内置 agent 宿主({}):请确认 agent-host/ 随应用一起分发",
HOST_ENTRIES[0]
))
})?;
let (runtime, program, needs_shell) = pick_runtime(&config.runtime)?;
let mut args = Vec::new();
let host_is_typescript = host
.extension()
.is_some_and(|extension| extension.eq_ignore_ascii_case("ts"));
if runtime == "node" && host_is_typescript {
args.push("--experimental-strip-types".to_string());
args.push("--no-warnings".to_string());
}
args.push(host.to_string_lossy().to_string());
Ok(LaunchPlan {
program,
args,
needs_shell,
embedded: true,
})
}
fn find_in_path(
name: &str,
path_env: &str,
extensions: &[&str],
is_file: &dyn Fn(&Path) -> bool,
) -> Option<PathBuf> {
for directory in path_env.split(if cfg!(windows) { ';' } else { ':' }) {
let directory = directory.trim();
if directory.is_empty() {
continue;
}
for extension in extensions {
let candidate = Path::new(directory).join(format!("{name}{extension}"));
if is_file(&candidate) {
return Some(candidate);
}
}
}
None
}
pub fn resolve_command(name: &str) -> (PathBuf, bool) {
let trimmed = name.trim();
if trimmed.contains('/') || trimmed.contains('\\') {
return (PathBuf::from(trimmed), false);
}
let path_env = std::env::var("PATH").unwrap_or_default();
let is_file = |candidate: &Path| candidate.is_file();
if !cfg!(windows) {
return match find_in_path(trimmed, &path_env, &[""], &is_file) {
Some(found) => (found, false),
None => (PathBuf::from(trimmed), false),
};
}
if let Some(executable) = find_in_path(trimmed, &path_env, &[".exe"], &is_file) {
return (executable, false);
}
if let Some(shim) = find_in_path(trimmed, &path_env, &[".cmd", ".bat"], &is_file) {
return (shim, true);
}
(PathBuf::from(trimmed), false)
}
/// A live agent process.
pub struct AgentSession {
stdin: Mutex<ChildStdin>,
child: Mutex<Child>,
pub command: String,
/// True when the embedded Pi host is running rather than an external CLI.
pub embedded: bool,
}
/// Wrap a line from the agent so the UI always receives JSON.
fn wrap_line(line: &str, stream: &str) -> String {
let trimmed = line.trim_end();
if stream == "stdout" && serde_json::from_str::<Value>(trimmed).is_ok() {
return trimmed.to_string();
}
serde_json::json!({ "type": "tasklens_log", "stream": stream, "text": trimmed }).to_string()
}
impl AgentSession {
/// Spawn the agent for `config`. `on_line` receives one JSON line per event.
pub fn start(
config: &AgentConfig,
on_line: Arc<dyn Fn(String) + Send + Sync>,
) -> Result<AgentSession, VaultError> {
let plan = launch_plan(config)?;
let cwd = if config.vault.trim().is_empty() {
None
} else {
Some(PathBuf::from(config.vault.trim()))
};
if let Some(directory) = &cwd {
if !directory.is_dir() {
return Err(VaultError(format!(
"知识库目录不存在: {}",
directory.to_string_lossy()
)));
}
}
let mut command = if plan.needs_shell {
let mut shell = Command::new("cmd.exe");
shell.arg("/c").arg(&plan.program);
shell.args(&plan.args);
shell
} else {
let mut direct = Command::new(&plan.program);
direct.args(&plan.args);
direct
};
let cli = cli_binary(&exe_dir());
for (key, value) in config.child_env(&cli) {
command.env(key, value);
}
if let Some(directory) = cwd {
command.current_dir(directory);
}
command.stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::piped());
#[cfg(windows)]
{
use std::os::windows::process::CommandExt;
const CREATE_NO_WINDOW: u32 = 0x0800_0000;
if !plan.embedded {
command.creation_flags(CREATE_NO_WINDOW);
}
}
let mut child = command.spawn().map_err(|problem| {
VaultError(format!(
"无法启动 agent({}): {problem}",
plan.program.to_string_lossy()
))
})?;
let stdin = child
.stdin
.take()
.ok_or_else(|| VaultError("无法连接 agent 输入".to_string()))?;
let stdout = child
.stdout
.take()
.ok_or_else(|| VaultError("无法连接 agent 输出".to_string()))?;
let stderr = child.stderr.take();
// The embedded host takes its configuration on stdin (an external CLI
// gets it from the command line instead).
let mut stdin = stdin;
if plan.embedded {
let request = serde_json::json!({ "type": "start", "config": config.host_payload() });
stdin
.write_all(request.to_string().as_bytes())
.and_then(|_| stdin.write_all(b"\n"))
.and_then(|_| stdin.flush())
.map_err(|problem| VaultError(format!("无法向 agent 发送配置: {problem}")))?;
}
let out_events = Arc::clone(&on_line);
std::thread::spawn(move || {
for line in BufReader::new(stdout).lines().map_while(Result::ok) {
if line.trim().is_empty() {
continue;
}
out_events(wrap_line(&line, "stdout"));
}
// stdout closing means the agent process ended.
out_events(serde_json::json!({ "type": "tasklens_exit" }).to_string());
});
if let Some(stderr) = stderr {
let err_events = Arc::clone(&on_line);
std::thread::spawn(move || {
for line in BufReader::new(stderr).lines().map_while(Result::ok) {
if line.trim().is_empty() {
continue;
}
err_events(wrap_line(&line, "stderr"));
}
});
}
Ok(AgentSession {
stdin: Mutex::new(stdin),
child: Mutex::new(child),
command: plan.program.to_string_lossy().to_string(),
embedded: plan.embedded,
})
}
/// Forward one protocol line (a command such as `prompt` or `abort`).
pub fn send_line(&self, line: &str) -> Result<(), VaultError> {
let mut stdin = self
.stdin
.lock()
.map_err(|_| VaultError("agent 输入不可用".to_string()))?;
stdin
.write_all(line.trim_end().as_bytes())
.and_then(|_| stdin.write_all(b"\n"))
.and_then(|_| stdin.flush())
.map_err(|problem| VaultError(format!("发送给 agent 失败: {problem}")))
}
pub fn is_running(&self) -> bool {
self.child
.lock()
.map(|mut child| matches!(child.try_wait(), Ok(None)))
.unwrap_or(false)
}
/// Terminate the agent process — and its children.
///
/// On Windows the direct child is `cmd.exe` wrapping the real agent, so
/// killing only that would leave an agent running (and spending tokens)
/// after the user pressed stop.
pub fn stop(&self) {
if let Ok(mut child) = self.child.lock() {
let pid = child.id();
let _ = child.kill();
let _ = child.wait();
#[cfg(windows)]
kill_process_tree(pid);
}
}
}
/// `taskkill /T` walks the whole tree; the plain handle kill misses grandchildren.
#[cfg(windows)]
fn kill_process_tree(pid: u32) {
let mut command = Command::new("taskkill");
command
.args(["/PID", &pid.to_string(), "/T", "/F"])
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null());
{
use std::os::windows::process::CommandExt;
const CREATE_NO_WINDOW: u32 = 0x0800_0000;
command.creation_flags(CREATE_NO_WINDOW);
}
let _ = command.status();
}
impl Drop for AgentSession {
fn drop(&mut self) {
self.stop();
}
}
#[cfg(test)]
mod tests {
use super::*;
fn config() -> AgentConfig {
AgentConfig {
command: "pi".to_string(),
vault: "/tmp/vault".to_string(),
provider: "deepseek".to_string(),
model: "deepseek-chat".to_string(),
thinking: "medium".to_string(),
api_key: "sk-test".to_string(),
trust_project: true,
context_files: true,
extensions: Vec::new(),
extra_args: vec!["--verbose".to_string(), "".to_string()],
..AgentConfig::default()
}
}
#[test]
fn builds_the_rpc_command_line() {
let directory = tempfile::tempdir().expect("temp dir");
let extension = directory.path().join("tasklens.ts");
std::fs::write(&extension, "export default () => {}").expect("write");
let mut config = config();
config.extensions = vec![
extension.to_string_lossy().to_string(),
" ".to_string(),
];
let args = config.build_args();
assert_eq!(args[0], "--mode");
assert_eq!(args[1], "rpc");
for expected in [
["--provider", "deepseek"],
["--model", "deepseek-chat"],
["--thinking", "medium"],
["--api-key", "sk-test"],
] {
let position = args
.iter()
.position(|value| value == expected[0])
.unwrap_or_else(|| panic!("missing {}", expected[0]));
assert_eq!(args[position + 1], expected[1]);
}
assert!(args.contains(&"--approve".to_string()));
assert!(!args.contains(&"--no-context-files".to_string()));
// Blank entries are dropped; real ones are passed as absolute paths.
assert_eq!(args.iter().filter(|value| *value == "-e").count(), 1);
assert!(args.contains(&extension.to_string_lossy().to_string()));
assert!(args.contains(&"--verbose".to_string()));
}
#[test]
fn omits_empty_options_and_forwards_trust_choice() {
let plain = AgentConfig {
command: "pi".to_string(),
trust_project: false,
context_files: false,
..AgentConfig::default()
};
let args = plain.build_args();
assert_eq!(args, vec!["--mode", "rpc", "--no-approve", "--no-context-files"]);
}
#[test]
fn exposes_the_vault_core_to_the_extension() {
let env = config().child_env(Path::new("C:/app/tasklens-cli.exe"));
assert!(env
.iter()
.any(|(key, value)| key == "TASK_LENS_CLI" && value.ends_with("tasklens-cli.exe")));
assert!(env
.iter()
.any(|(key, value)| key == "TASK_LENS_VAULT" && value == "/tmp/vault"));
let with_base = AgentConfig {
base_url: "https://api.example.com/v1".to_string(),
..config()
};
assert!(with_base
.child_env(Path::new("cli"))
.iter()
.any(|(key, value)| key == "OPENAI_BASE_URL" && value.starts_with("https://")));
}
#[test]
fn prefers_real_executables_over_windows_shims() {
let both = |candidate: &Path| {
let name = candidate.to_string_lossy();
name.ends_with("pi.cmd") || name.ends_with("pi.exe")
};
let found = find_in_path("pi", "C:\\a;C:\\bin", &[".exe", ".cmd", ".bat"], &both).expect("found");
assert!(found.to_string_lossy().ends_with("pi.exe"), "{found:?}");
// A shim is still usable — it just needs `cmd.exe` to launch it.
let shim_only = |candidate: &Path| candidate.to_string_lossy().ends_with("pi.cmd");
let shim = find_in_path("pi", "C:\\bin", &[".exe", ".cmd", ".bat"], &shim_only).expect("shim");
assert!(shim.to_string_lossy().ends_with("pi.cmd"));
assert!(find_in_path("nope", "C:\\bin", &[".exe"], &|_| false).is_none());
}
#[test]
fn resolves_overrides_over_persisted_settings() {
let mut settings = Settings::default();
settings.ai.provider = "deepseek".to_string();
settings.ai.model = "deepseek-chat".to_string();
settings.ai.api_key = "sk-from-settings".to_string();
settings.ai.extensions = vec!["/opt/vault-tools.ts".to_string()];
settings.ai.trust_project = false;
// Callers may send only what they know; everything else comes from settings.
let resolved = AgentConfig::resolve(
&serde_json::json!({ "model": "deepseek-reasoner" }),
&settings,
Some(Path::new("/tmp/vault")),
);
assert_eq!(resolved.model, "deepseek-reasoner");
assert_eq!(resolved.provider, "deepseek");
assert_eq!(resolved.api_key, "sk-from-settings");
assert_eq!(resolved.extensions, vec!["/opt/vault-tools.ts".to_string()]);
assert!(!resolved.trust_project, "an explicit false must survive");
assert_eq!(resolved.vault, "/tmp/vault");
// Booleans can be flipped on as well.
let flipped = AgentConfig::resolve(
&serde_json::json!({ "trustProject": true, "contextFiles": false }),
&settings,
None,
);
assert!(flipped.trust_project);
assert!(!flipped.context_files);
// No external command configured: the embedded host runs instead.
assert_eq!(flipped.command, "");
assert_eq!(flipped.runtime, "auto");
}
#[test]
fn host_payload_carries_the_configuration_the_host_expects() {
let config = AgentConfig {
vault: "/tmp/vault".to_string(),
provider: "deepseek".to_string(),
model: "deepseek-chat".to_string(),
thinking: "high".to_string(),
api_key: "sk-x".to_string(),
context_files: false,
..AgentConfig::default()
};
let payload = config.host_payload();
assert_eq!(payload["provider"], "deepseek");
assert_eq!(payload["model"], "deepseek-chat");
assert_eq!(payload["thinking"], "high");
assert_eq!(payload["apiKey"], "sk-x");
assert_eq!(payload["contextFiles"], false);
assert_eq!(payload["vault"], "/tmp/vault");
assert!(payload["extensions"].as_array().is_some_and(Vec::is_empty));
}
#[test]
fn an_explicit_command_switches_to_the_external_agent() {
let config = AgentConfig {
command: "definitely-not-installed-tool".to_string(),
vault: "/tmp/vault".to_string(),
..AgentConfig::default()
};
let plan = launch_plan(&config).expect("plan");
assert!(!plan.embedded);
assert!(plan
.program
.to_string_lossy()
.contains("definitely-not-installed-tool"));
// External CLIs are still driven through the RPC protocol.
assert_eq!(plan.args.first().map(String::as_str), Some("--mode"));
}
#[test]
fn prefers_the_sibling_cli_binary() {
let directory = tempfile::tempdir().expect("temp dir");
let exe_dir = directory.path().join("app");
std::fs::create_dir_all(&exe_dir).expect("mkdir");
let name = if cfg!(windows) { "tasklens-cli.exe" } else { "tasklens-cli" };
std::fs::write(exe_dir.join(name), "// cli").expect("write");
assert_eq!(cli_binary(&exe_dir), exe_dir.join(name));
}
#[test]
fn finds_the_embedded_host_next_to_or_above_the_executable() {
let directory = tempfile::tempdir().expect("temp dir");
let executable = directory.path().join("app").join("tasklens.exe");
std::fs::create_dir_all(executable.parent().expect("parent")).expect("mkdir");
// Packaged layout: resources/ beside the executable.
let packaged = directory.path().join("app").join("resources").join("agent-host");
std::fs::create_dir_all(&packaged).expect("mkdir");
std::fs::write(packaged.join("index.ts"), "// host").expect("write");
assert_eq!(
host_candidates(&directory.path().join("app"))
.into_iter()
.find(|candidate| candidate.is_file()),
Some(packaged.join("index.ts"))
);
// The bundled, self-contained entry point wins over the source.
std::fs::write(packaged.join("host.mjs"), "// bundled").expect("write");
assert_eq!(
host_candidates(&directory.path().join("app"))
.into_iter()
.find(|candidate| candidate.is_file()),
Some(packaged.join("host.mjs"))
);
// Development layout: the repository root a few levels up.
let development = tempfile::tempdir().expect("temp dir");
let deep = development.path().join("target").join("debug");
std::fs::create_dir_all(&deep).expect("mkdir");
let repository_host = development.path().join("agent-host");
std::fs::create_dir_all(&repository_host).expect("mkdir");
std::fs::write(repository_host.join("index.ts"), "// host").expect("write");
assert_eq!(
host_candidates(&deep).into_iter().find(|candidate| candidate.is_file()),
Some(repository_host.join("index.ts"))
);
assert!(pick_runtime("python").is_err());
}
#[test]
fn resolves_extension_paths_and_reports_missing_ones() {
let directory = tempfile::tempdir().expect("temp dir");
let vault = directory.path();
let present = vault.join("tools.ts");
std::fs::write(&present, "export default () => {}").expect("write");
let config = AgentConfig {
vault: vault.to_string_lossy().to_string(),
// Relative (resolved against the vault), missing, and blank entries.
extensions: vec!["tools.ts".to_string(), "gone.ts".to_string(), " ".to_string()],
..AgentConfig::default()
};
let resolved = config.resolved_extensions();
assert_eq!(resolved.len(), 1, "{resolved:?}");
assert!(resolved[0].ends_with("tools.ts"));
assert!(Path::new(&resolved[0]).is_absolute());
assert_eq!(config.missing_extensions(), vec!["gone.ts".to_string()]);
// Only real files reach pi; the missing one must not kill the agent.
let args = config.build_args();
assert_eq!(args.iter().filter(|value| *value == "-e").count(), 1);
assert!(!args.iter().any(|value| value.contains("gone.ts")));
}
#[test]
fn wraps_non_json_output_so_the_ui_can_always_parse_it() {
let wrapped = wrap_line("Warning: something", "stderr");
let parsed: Value = serde_json::from_str(&wrapped).expect("json");
assert_eq!(parsed["type"], "tasklens_log");
assert_eq!(parsed["stream"], "stderr");
assert_eq!(parsed["text"], "Warning: something");
// Real protocol lines pass through untouched.
let passthrough = wrap_line("{\"type\":\"message_end\"}", "stdout");
assert_eq!(passthrough, "{\"type\":\"message_end\"}");
}
}