#[allow(unused_imports)]
use crate::sync_util::LockExt;
use std::future::Future;
use std::pin::Pin;
use std::sync::Arc;
use std::sync::Mutex;
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use serde_json::Value;
use crate::agent::agent_loop::result::LoopToolResult;
use crate::agent::agent_loop::tool::{AbortSignal, LoopTool, LoopToolUpdate};
use crate::agent::agent_loop::types::ToolExecutionMode;
use crate::agent::tools::{Scope, enforce};
use crate::permission::ask::AskSender;
use crate::permission::checker::PermCheck;
use super::{PluginManager, PluginShortcutMeta, PluginToolMeta};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ResolvedCustomMessage {
pub label: String,
pub body: String,
}
pub fn resolve_custom_message_render(
payload: &Value,
pm: Option<&Arc<Mutex<PluginManager>>>,
) -> Option<ResolvedCustomMessage> {
let display = payload
.get("display")
.and_then(|v| v.as_bool())
.unwrap_or(true);
if !display {
return None;
}
let custom_type = payload
.get("customType")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let rendered: Option<String> = pm.and_then(|pm_arc| {
let mut mgr = pm_arc.lock_ignore_poison();
let handler = mgr
.list_message_renderers()
.into_iter()
.find(|(t, _)| t == &custom_type)
.map(|(_, h)| h)?;
let payload_str = payload.to_string();
mgr.invoke_message_renderer(&handler, &payload_str)
.ok()
.flatten()
});
let body = rendered.unwrap_or_else(|| {
payload
.get("content")
.and_then(|v| v.as_str())
.map(String::from)
.unwrap_or_else(|| payload.to_string())
});
let label = if custom_type.is_empty() {
"plugin".to_string()
} else {
format!("plugin:{custom_type}")
};
Some(ResolvedCustomMessage { label, body })
}
pub fn parse_key_spec(spec: &str) -> Option<(KeyCode, KeyModifiers)> {
let lower = spec.trim().to_ascii_lowercase();
if lower.is_empty() {
return None;
}
let parts: Vec<&str> = lower.split('-').collect();
let (key_part, mod_parts) = parts.split_last()?;
let mut mods = KeyModifiers::NONE;
for p in mod_parts {
match *p {
"ctrl" | "control" => mods |= KeyModifiers::CONTROL,
"alt" | "meta" => mods |= KeyModifiers::ALT,
"shift" => mods |= KeyModifiers::SHIFT,
_ => return None,
}
}
let code = match *key_part {
"enter" | "return" => KeyCode::Enter,
"esc" | "escape" => KeyCode::Esc,
"tab" => KeyCode::Tab,
"backspace" | "bs" => KeyCode::Backspace,
"space" => KeyCode::Char(' '),
"up" => KeyCode::Up,
"down" => KeyCode::Down,
"left" => KeyCode::Left,
"right" => KeyCode::Right,
"home" => KeyCode::Home,
"end" => KeyCode::End,
"pageup" | "pgup" => KeyCode::PageUp,
"pagedown" | "pgdn" => KeyCode::PageDown,
"delete" | "del" => KeyCode::Delete,
"insert" | "ins" => KeyCode::Insert,
s if s.starts_with('f') && s.len() > 1 => {
let suffix = &s[1..];
if !suffix.chars().all(|c| c.is_ascii_digit()) {
return None;
}
if suffix.len() > 1 && suffix.starts_with('0') {
return None;
}
let n: u8 = suffix.parse().ok()?;
if (1..=12).contains(&n) {
KeyCode::F(n)
} else {
return None;
}
}
s if s.chars().count() == 1 => KeyCode::Char(s.chars().next()?),
_ => return None,
};
Some((code, mods))
}
#[derive(Debug, Clone)]
pub struct ParsedShortcut {
pub code: KeyCode,
pub modifiers: KeyModifiers,
pub spec: String,
pub handler: String,
}
pub fn parse_shortcuts(metas: Vec<PluginShortcutMeta>) -> Vec<ParsedShortcut> {
metas
.into_iter()
.filter_map(|m| {
let (code, modifiers) = match parse_key_spec(&m.keys) {
Some(pair) => pair,
None => {
tracing::warn!(
target: "dirge::plugin",
spec = %m.keys,
handler = %m.handler,
"plugin shortcut key spec did not parse — binding dropped",
);
return None;
}
};
Some(ParsedShortcut {
code,
modifiers,
spec: m.keys,
handler: m.handler,
})
})
.collect()
}
pub fn match_shortcut<'a>(
key: &KeyEvent,
shortcuts: &'a [ParsedShortcut],
) -> Option<&'a ParsedShortcut> {
shortcuts
.iter()
.find(|s| s.code == key.code && s.modifiers == key.modifiers)
}
pub struct JanetLoopTool {
name: String,
description: String,
label: String,
parameters: Value,
handler: String,
execution_mode: Option<ToolExecutionMode>,
prepare_handler: Option<String>,
pm: Arc<Mutex<PluginManager>>,
permission: Option<PermCheck>,
ask_tx: Option<AskSender>,
}
impl std::fmt::Debug for JanetLoopTool {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("JanetLoopTool")
.field("name", &self.name)
.field("label", &self.label)
.field("handler", &self.handler)
.field("execution_mode", &self.execution_mode)
.finish()
}
}
impl JanetLoopTool {
pub fn from_meta(
meta: PluginToolMeta,
pm: Arc<Mutex<PluginManager>>,
permission: Option<PermCheck>,
ask_tx: Option<AskSender>,
) -> Option<Self> {
let parameters: Value = serde_json::from_str(&meta.parameters)
.ok()
.unwrap_or_else(|| {
tracing::warn!(
target: "dirge::plugin",
tool = %meta.name,
raw = %meta.parameters,
"plugin tool parameters were not valid JSON — falling back to empty object schema",
);
Value::Object(serde_json::Map::new())
});
let execution_mode = match meta.execution_mode.as_deref() {
Some("sequential") => Some(ToolExecutionMode::Sequential),
Some("parallel") => Some(ToolExecutionMode::Parallel),
_ => None,
};
Some(Self {
name: meta.name,
description: meta.description,
label: meta.label,
parameters,
handler: meta.handler,
execution_mode,
prepare_handler: meta.prepare_handler,
pm,
permission,
ask_tx,
})
}
}
impl LoopTool for JanetLoopTool {
fn name(&self) -> &str {
&self.name
}
fn description(&self) -> &str {
&self.description
}
fn label(&self) -> &str {
if self.label.is_empty() {
&self.name
} else {
&self.label
}
}
fn parameters(&self) -> &Value {
&self.parameters
}
fn execution_mode(&self) -> Option<ToolExecutionMode> {
self.execution_mode
}
fn prepare_arguments(&self, args: Value) -> Value {
let Some(handler) = self.prepare_handler.as_deref() else {
return args;
};
let args_json = args.to_string();
let mutated = {
let mut guard = match self.pm.lock() {
Ok(g) => g,
Err(_) => return args,
};
guard
.invoke_prepare_arguments(handler, &args_json)
.ok()
.flatten()
};
match mutated {
Some(json) => match serde_json::from_str::<Value>(&json) {
Ok(v) => v,
Err(e) => {
tracing::warn!(
target: "dirge::plugin",
tool = %self.name,
handler = %handler,
error = %e,
"plugin prepare-arguments returned invalid JSON — ignoring",
);
args
}
},
None => args,
}
}
fn execute<'a>(
&'a self,
tool_call_id: &'a str,
args: Value,
signal: AbortSignal,
on_update: LoopToolUpdate,
) -> Pin<Box<dyn Future<Output = Result<LoopToolResult, String>> + Send + 'a>> {
let args_json = args.to_string();
let pm = self.pm.clone();
let handler = self.handler.clone();
let tool_call_id_owned = tool_call_id.to_string();
let name = self.name.clone();
let permission = self.permission.clone();
let ask_tx = self.ask_tx.clone();
Box::pin(async move {
if signal.is_cancelled() {
return Err("plugin tool aborted before execution".to_string());
}
if let Some(perm) = permission.as_ref() {
let denied = {
let guard = perm.lock_ignore_poison();
guard.any_prompt_denied(&[name.as_str(), "plugin_tool"])
};
if denied {
return Err(format!(
"Plugin tool `{name}` is denied by the active prompt's `deny_tools` \
frontmatter. Switch with `/prompt <other>` to use it."
));
}
}
enforce(&permission, &ask_tx, "plugin_tool", Scope::Raw(&name))
.await
.map_err(|e| e.to_string())?;
let signal_in = signal.clone();
let pm_for_blocking = pm.clone();
let tcid_for_blocking = tool_call_id_owned.clone();
let (result, progress_events) = tokio::task::spawn_blocking(
move || -> Result<(String, Vec<(String, String)>), String> {
if signal_in.is_cancelled() {
return Err("plugin tool aborted before mutex acquire".to_string());
}
let mut guard = pm_for_blocking
.lock()
.map_err(|_| "plugin manager mutex poisoned".to_string())?;
if signal_in.is_cancelled() {
return Err(
"plugin tool aborted while waiting for plugin manager".to_string()
);
}
let r = guard.invoke_plugin_tool(&handler, &args_json, &tcid_for_blocking)?;
let prog = guard.drain_tool_progress();
Ok((r, prog))
},
)
.await
.map_err(|e| format!("plugin tool task join error: {e}"))??;
for (_id, text) in progress_events {
on_update(&LoopToolResult {
content: vec![serde_json::json!({"type": "text", "text": text})],
details: Value::Null,
terminate: None,
});
}
Ok(LoopToolResult {
content: vec![serde_json::json!({"type": "text", "text": result})],
details: Value::Null,
terminate: None,
})
})
}
}
#[cfg(all(test, feature = "plugin"))]
mod tests {
use super::*;
use crate::agent::agent_loop::tool::AbortSignal;
fn noop_update() -> LoopToolUpdate {
Arc::new(|_| {})
}
#[tokio::test]
async fn janet_loop_tool_execute_round_trips_handler_output() {
let pm = {
let mut mgr = PluginManager::try_new().unwrap();
mgr.eval(
r#"(defn my-handler [args] (string "echo:" args))
(harness/register-tool "my-tool" "Echo" "MyTool" "{}" "my-handler")"#,
)
.unwrap();
Arc::new(Mutex::new(mgr))
};
let metas: Vec<PluginToolMeta> = pm.lock().unwrap().list_plugin_tools();
assert_eq!(metas.len(), 1);
let tool =
JanetLoopTool::from_meta(metas.into_iter().next().unwrap(), pm.clone(), None, None)
.expect("from_meta must succeed for valid schema");
assert_eq!(tool.name(), "my-tool");
assert_eq!(tool.label(), "MyTool");
assert_eq!(tool.description(), "Echo");
assert_eq!(tool.parameters(), &Value::Object(serde_json::Map::new()));
let args = serde_json::json!({"x": 1});
let result = tool
.execute("call-1", args, AbortSignal::new(), noop_update())
.await
.expect("execute should succeed");
let text = result
.content
.iter()
.filter_map(|b| b.get("text").and_then(|v| v.as_str()))
.collect::<Vec<_>>()
.join("");
assert_eq!(text, r#"echo:{"x":1}"#);
}
#[tokio::test]
async fn janet_loop_tool_sequential_mode_surfaces_to_loop() {
let pm = {
let mut mgr = PluginManager::try_new().unwrap();
mgr.eval(
r#"(harness/register-tool "mutate" "side effects" "Mutate"
"{}" "noop" :sequential)
(defn noop [args] "ok")"#,
)
.unwrap();
Arc::new(Mutex::new(mgr))
};
let metas: Vec<PluginToolMeta> = pm.lock().unwrap().list_plugin_tools();
let tool =
JanetLoopTool::from_meta(metas.into_iter().next().unwrap(), pm.clone(), None, None)
.unwrap();
assert_eq!(tool.execution_mode(), Some(ToolExecutionMode::Sequential));
}
use crate::permission::checker::PermissionChecker;
use crate::permission::{PermissionConfig, SecurityMode};
fn tool_with_perm(
mode: SecurityMode,
deny: &[&str],
) -> (JanetLoopTool, Arc<Mutex<PluginManager>>) {
let pm = {
let mut mgr = PluginManager::try_new().unwrap();
mgr.eval(
r#"(defn my-handler [args] (string "ran:" args))
(harness/register-tool "my-tool" "Echo" "MyTool" "{}" "my-handler")"#,
)
.unwrap();
Arc::new(Mutex::new(mgr))
};
let mut checker = PermissionChecker::new(&PermissionConfig::default(), mode, None);
if !deny.is_empty() {
checker.set_prompt_deny_tools(deny.iter().map(|s| s.to_string()).collect());
}
let perm: PermCheck = Arc::new(Mutex::new(checker));
let metas: Vec<PluginToolMeta> = pm.lock().unwrap().list_plugin_tools();
let tool = JanetLoopTool::from_meta(
metas.into_iter().next().unwrap(),
pm.clone(),
Some(perm),
None, )
.unwrap();
(tool, pm)
}
#[tokio::test]
async fn plugin_tool_denied_by_prompt_deny_tools() {
let (tool, _pm) = tool_with_perm(SecurityMode::Standard, &["my-tool"]);
let err = tool
.execute(
"c1",
serde_json::json!({}),
AbortSignal::new(),
noop_update(),
)
.await
.expect_err("deny_tools must refuse the plugin tool");
assert!(err.contains("denied"), "message names the denial: {err}");
assert!(err.contains("my-tool"), "names the tool: {err}");
}
#[tokio::test]
async fn plugin_tool_gated_ask_denies_noninteractive() {
let (tool, _pm) = tool_with_perm(SecurityMode::Standard, &[]);
let res = tool
.execute(
"c1",
serde_json::json!({}),
AbortSignal::new(),
noop_update(),
)
.await;
assert!(
res.is_err(),
"unauthorized plugin tool must not run in non-interactive mode; got {res:?}"
);
}
#[tokio::test]
async fn plugin_tool_runs_when_authorized() {
let (tool, _pm) = tool_with_perm(SecurityMode::Yolo, &[]);
let result = tool
.execute(
"c1",
serde_json::json!({"x": 1}),
AbortSignal::new(),
noop_update(),
)
.await
.expect("yolo mode authorizes the plugin tool");
let text = result
.content
.iter()
.filter_map(|b| b.get("text").and_then(|v| v.as_str()))
.collect::<Vec<_>>()
.join("");
assert_eq!(text, r#"ran:{"x":1}"#);
}
#[test]
fn resolve_custom_message_render_respects_display_false() {
let payload = serde_json::json!({
"role": "custom",
"customType": "telemetry",
"content": "x",
"display": false,
});
assert!(resolve_custom_message_render(&payload, None).is_none());
}
#[test]
fn resolve_custom_message_render_bare_falls_back_to_content() {
let payload = serde_json::json!({
"role": "custom",
"customType": "",
"content": "hello",
"display": true,
});
let r = resolve_custom_message_render(&payload, None).unwrap();
assert_eq!(r.label, "plugin");
assert_eq!(r.body, "hello");
}
#[test]
fn resolve_custom_message_render_invokes_registered_handler() {
let pm = {
let mut mgr = PluginManager::try_new().unwrap();
mgr.eval(
r#"(defn render-status [p] (string ">>" p))
(harness/register-message-renderer "status" "render-status")"#,
)
.unwrap();
Arc::new(Mutex::new(mgr))
};
let payload = serde_json::json!({
"role": "custom",
"customType": "status",
"content": "build started",
"display": true,
});
let r = resolve_custom_message_render(&payload, Some(&pm)).unwrap();
assert_eq!(r.label, "plugin:status");
assert!(r.body.starts_with(">>"), "got: {}", r.body);
assert!(
r.body.contains("\"customType\":\"status\""),
"got: {}",
r.body
);
assert!(
r.body.contains("\"content\":\"build started\""),
"got: {}",
r.body
);
}
#[test]
fn parse_key_spec_plain_char() {
let (code, mods) = parse_key_spec("x").unwrap();
assert_eq!(code, KeyCode::Char('x'));
assert!(mods.is_empty());
}
#[test]
fn parse_key_spec_ctrl_char_case_insensitive() {
let a = parse_key_spec("ctrl-x").unwrap();
let b = parse_key_spec("CTRL-X").unwrap();
assert_eq!(a, b);
assert_eq!(a.0, KeyCode::Char('x'));
assert_eq!(a.1, KeyModifiers::CONTROL);
}
#[test]
fn parse_key_spec_multi_modifier() {
let (code, mods) = parse_key_spec("ctrl-alt-shift-f").unwrap();
assert_eq!(code, KeyCode::Char('f'));
assert_eq!(
mods,
KeyModifiers::CONTROL | KeyModifiers::ALT | KeyModifiers::SHIFT
);
}
#[test]
fn parse_key_spec_named_keys() {
assert_eq!(parse_key_spec("enter").unwrap().0, KeyCode::Enter);
assert_eq!(parse_key_spec("esc").unwrap().0, KeyCode::Esc);
assert_eq!(parse_key_spec("space").unwrap().0, KeyCode::Char(' '));
assert_eq!(parse_key_spec("backspace").unwrap().0, KeyCode::Backspace);
assert_eq!(parse_key_spec("pgdn").unwrap().0, KeyCode::PageDown);
}
#[test]
fn parse_key_spec_function_keys() {
assert_eq!(parse_key_spec("f1").unwrap().0, KeyCode::F(1));
assert_eq!(parse_key_spec("F12").unwrap().0, KeyCode::F(12));
assert!(parse_key_spec("f0").is_none());
assert!(parse_key_spec("f13").is_none());
}
#[test]
fn parse_key_spec_rejects_loose_function_key_digits() {
assert!(parse_key_spec("f01").is_none(), "f01 should not parse");
assert!(parse_key_spec("f00").is_none(), "f00 should not parse");
assert!(parse_key_spec("fx").is_none());
}
#[test]
fn parse_key_spec_rejects_unknown_modifier_or_key() {
assert!(parse_key_spec("hyper-x").is_none());
assert!(parse_key_spec("ctrl-mumble").is_none());
assert!(parse_key_spec("").is_none());
}
#[test]
fn match_shortcut_returns_first_load_order_match() {
let shortcuts = parse_shortcuts(vec![
PluginShortcutMeta {
keys: "ctrl-x".into(),
handler: "first".into(),
description: String::new(),
},
PluginShortcutMeta {
keys: "ctrl-x".into(),
handler: "second".into(),
description: String::new(),
},
]);
let ev = KeyEvent::new(KeyCode::Char('x'), KeyModifiers::CONTROL);
let hit = match_shortcut(&ev, &shortcuts).unwrap();
assert_eq!(hit.handler, "first");
let ev2 = KeyEvent::new(KeyCode::Char('y'), KeyModifiers::CONTROL);
assert!(match_shortcut(&ev2, &shortcuts).is_none());
}
#[test]
fn parse_shortcuts_drops_bad_specs_but_keeps_good_ones() {
let parsed = parse_shortcuts(vec![
PluginShortcutMeta {
keys: "bogus-key".into(),
handler: "drop-me".into(),
description: String::new(),
},
PluginShortcutMeta {
keys: "ctrl-x".into(),
handler: "keep-me".into(),
description: String::new(),
},
]);
assert_eq!(parsed.len(), 1);
assert_eq!(parsed[0].handler, "keep-me");
}
#[tokio::test]
async fn janet_loop_tool_execute_forwards_emit_tool_progress_to_on_update() {
let pm = {
let mut mgr = PluginManager::try_new().unwrap();
mgr.eval(
r#"(defn streamer [args]
(harness/emit-tool-progress "halfway")
(harness/emit-tool-progress "almost done")
"complete")
(harness/register-tool "streamer" "" "" "{}" "streamer")"#,
)
.unwrap();
Arc::new(Mutex::new(mgr))
};
let metas: Vec<PluginToolMeta> = pm.lock().unwrap().list_plugin_tools();
let tool =
JanetLoopTool::from_meta(metas.into_iter().next().unwrap(), pm.clone(), None, None)
.unwrap();
let captured: Arc<Mutex<Vec<String>>> = Arc::new(Mutex::new(Vec::new()));
let captured_for_cb = captured.clone();
let on_update: LoopToolUpdate = Arc::new(move |r: &LoopToolResult| {
for c in &r.content {
if let Some(t) = c.get("text").and_then(|v| v.as_str()) {
captured_for_cb.lock().unwrap().push(t.to_string());
}
}
});
let result = tool
.execute(
"stream-1",
Value::Object(Default::default()),
AbortSignal::new(),
on_update,
)
.await
.expect("execute should succeed");
let progress = captured.lock().unwrap().clone();
assert_eq!(progress, vec!["halfway", "almost done"]);
let final_text = result
.content
.iter()
.filter_map(|b| b.get("text").and_then(|v| v.as_str()))
.collect::<Vec<_>>()
.join("");
assert_eq!(final_text, "complete");
}
#[tokio::test]
async fn janet_loop_tool_prepare_arguments_normalizes_via_handler() {
let pm = {
let mut mgr = PluginManager::try_new().unwrap();
mgr.eval(
r#"(defn echo [args] args)
(defn prep [args]
# Wrap the input so we can confirm prepare actually ran.
(string "{\"wrapped\":" args "}"))
(harness/register-tool "wrap" "" "Wrap" "{}" "echo" :parallel "prep")"#,
)
.unwrap();
Arc::new(Mutex::new(mgr))
};
let metas: Vec<PluginToolMeta> = pm.lock().unwrap().list_plugin_tools();
let tool =
JanetLoopTool::from_meta(metas.into_iter().next().unwrap(), pm.clone(), None, None)
.unwrap();
let original = serde_json::json!({"x": 1});
let mutated = tool.prepare_arguments(original);
assert_eq!(mutated.get("wrapped"), Some(&serde_json::json!({"x": 1})));
}
#[tokio::test]
async fn janet_loop_tool_prepare_arguments_passthrough_when_unset() {
let pm = {
let mut mgr = PluginManager::try_new().unwrap();
mgr.eval(
r#"(defn h [args] "ok")
(harness/register-tool "no-prep" "" "" "{}" "h")"#,
)
.unwrap();
Arc::new(Mutex::new(mgr))
};
let metas: Vec<PluginToolMeta> = pm.lock().unwrap().list_plugin_tools();
assert_eq!(metas[0].prepare_handler, None);
let tool =
JanetLoopTool::from_meta(metas.into_iter().next().unwrap(), pm.clone(), None, None)
.unwrap();
let original = serde_json::json!({"a": 1, "b": "two"});
let out = tool.prepare_arguments(original.clone());
assert_eq!(out, original);
}
#[tokio::test]
async fn janet_loop_tool_prepare_arguments_error_falls_back_to_original() {
let pm = {
let mut mgr = PluginManager::try_new().unwrap();
mgr.eval(
r#"(defn h [args] "ok")
(defn bad-prep [args] (error "boom"))
(harness/register-tool "bad" "" "" "{}" "h" :parallel "bad-prep")"#,
)
.unwrap();
Arc::new(Mutex::new(mgr))
};
let metas: Vec<PluginToolMeta> = pm.lock().unwrap().list_plugin_tools();
let tool =
JanetLoopTool::from_meta(metas.into_iter().next().unwrap(), pm.clone(), None, None)
.unwrap();
let original = serde_json::json!({"x": 1});
let out = tool.prepare_arguments(original.clone());
assert_eq!(out, original, "throw must fall back to original args");
}
#[tokio::test]
async fn janet_loop_tool_prepare_arguments_invalid_json_falls_back() {
let pm = {
let mut mgr = PluginManager::try_new().unwrap();
mgr.eval(
r#"(defn h [args] "ok")
(defn weird [args] "not valid json {{{")
(harness/register-tool "w" "" "" "{}" "h" :parallel "weird")"#,
)
.unwrap();
Arc::new(Mutex::new(mgr))
};
let metas: Vec<PluginToolMeta> = pm.lock().unwrap().list_plugin_tools();
let tool =
JanetLoopTool::from_meta(metas.into_iter().next().unwrap(), pm.clone(), None, None)
.unwrap();
let original = serde_json::json!({"x": 1});
let out = tool.prepare_arguments(original.clone());
assert_eq!(out, original);
}
#[tokio::test]
async fn janet_loop_tool_execute_short_circuits_on_pre_cancelled_signal() {
let pm = {
let mut mgr = PluginManager::try_new().unwrap();
mgr.eval(
r#"(var --h1-ran nil)
(defn slow [args]
(set --h1-ran true)
"ok")
(harness/register-tool "slow" "test" "Slow" "{}" "slow")"#,
)
.unwrap();
Arc::new(Mutex::new(mgr))
};
let metas: Vec<PluginToolMeta> = pm.lock().unwrap().list_plugin_tools();
let tool =
JanetLoopTool::from_meta(metas.into_iter().next().unwrap(), pm.clone(), None, None)
.unwrap();
let signal = AbortSignal::new();
signal.cancel();
let err = tool
.execute(
"c",
Value::Object(Default::default()),
signal,
noop_update(),
)
.await
.expect_err("pre-cancelled signal must short-circuit to Err");
assert!(
err.contains("aborted"),
"error should mention abort; got: {err}"
);
let ran = pm.lock().unwrap().eval("--h1-ran").unwrap();
assert_eq!(ran, "nil", "handler must not execute when pre-cancelled");
}
#[tokio::test]
async fn janet_loop_tool_execute_happy_path_with_live_signal() {
let pm = {
let mut mgr = PluginManager::try_new().unwrap();
mgr.eval(
r#"(defn ok-handler [args] "ran")
(harness/register-tool "ok" "test" "OK" "{}" "ok-handler")"#,
)
.unwrap();
Arc::new(Mutex::new(mgr))
};
let metas: Vec<PluginToolMeta> = pm.lock().unwrap().list_plugin_tools();
let tool =
JanetLoopTool::from_meta(metas.into_iter().next().unwrap(), pm.clone(), None, None)
.unwrap();
let signal = AbortSignal::new(); let result = tool
.execute(
"c",
Value::Object(Default::default()),
signal,
noop_update(),
)
.await
.expect("happy path");
let text = result
.content
.iter()
.filter_map(|b| b.get("text").and_then(|v| v.as_str()))
.collect::<Vec<_>>()
.join("");
assert_eq!(text, "ran");
}
#[tokio::test]
async fn janet_loop_tool_handler_error_surfaces_as_err() {
let pm = {
let mut mgr = PluginManager::try_new().unwrap();
mgr.eval(
r#"(defn bad [args] (error "intentional"))
(harness/register-tool "bad" "fails" "Bad" "{}" "bad")"#,
)
.unwrap();
Arc::new(Mutex::new(mgr))
};
let metas: Vec<PluginToolMeta> = pm.lock().unwrap().list_plugin_tools();
let tool =
JanetLoopTool::from_meta(metas.into_iter().next().unwrap(), pm.clone(), None, None)
.unwrap();
let err = tool
.execute(
"c",
Value::Object(Default::default()),
AbortSignal::new(),
noop_update(),
)
.await
.expect_err("handler error should bubble up as Err");
assert!(err.contains("intentional"), "got: {err}");
}
}