Merge remote-tracking branch 'origin/main' into feat/PLAN-responses-api

# Conflicts:
#	src/client/common.rs
This commit is contained in:
Dark-Alex-17 committed 2026-09-16 14:33:29 -06:00
commit 717a48bef2
32 files changed
+6353 -64

No files matched your search

+249 -2
View File
@@ -4,8 +4,9 @@ use crate::client::{Model, ModelType, call_chat_completions};
use crate::config::{
Agent, AgentVariable, AgentVariables, AppState, Input, RequestContext, Role, RoleLike,
default_max_agent_depth, effective_max_concurrent_jobs, jobs_enabled,
list_agents_with_descriptions, load_agent_variables,
list_agents_with_descriptions, load_agent_variables, paths,
};
use crate::hooks::{self, HookEvent, ResolvedHook};
use crate::supervisor::mailbox::{Envelope, EnvelopePayload, Inbox, PeerRegistry, graph_agent_id};
use crate::supervisor::notification::agent_notification;
use crate::supervisor::{AgentExitStatus, AgentHandle, AgentResult, Supervisor, TaskKind};
@@ -14,7 +15,7 @@ use crate::utils::{AbortSignal, create_abort_signal, wait_abort_signal, wait_use
use crate::graph;
use crate::repl::DEFAULT_CONTINUATION_PROMPT;
use anyhow::{Context, Result, anyhow, bail};
use chrono::Utc;
use chrono::{SecondsFormat, Utc};
use indexmap::IndexMap;
use log::{debug, warn};
use parking_lot::RwLock;
@@ -996,6 +997,86 @@ fn sync_agent_functions_to_ctx(ctx: &mut RequestContext) -> Result<()> {
Ok(())
}
/// Mirrors the base-env contract `hooks::fire` assembles from a live context,
/// for detached call sites that dispatch pre-resolved hook snapshots after
/// the context they were resolved from is gone. The names are captured by
/// the caller when it takes the snapshot; the timestamp is taken here, at
/// fire time, so it reflects when the event actually happened.
pub(crate) fn hook_base_envs(
event: HookEvent,
session_name: Option<&str>,
agent_name: Option<&str>,
) -> Vec<(String, String)> {
let mut envs = vec![
("COYOTE_EVENT".to_string(), event.as_str().to_string()),
(
"COYOTE_CONFIG_DIR".to_string(),
paths::config_dir().display().to_string(),
),
(
"COYOTE_EVENT_TIMESTAMP".to_string(),
Utc::now().to_rfc3339_opts(SecondsFormat::Secs, true),
),
];
if let Some(name) = session_name {
envs.push(("COYOTE_SESSION_ID".to_string(), name.to_string()));
}
if let Some(name) = agent_name {
envs.push(("COYOTE_AGENT_NAME".to_string(), name.to_string()));
}
envs
}
/// Pre-resolved `agent.completed`/`agent.failed` snapshots for a spawned
/// agent's result arms, captured from the child context before the spawn
/// task takes ownership of it. Resolving against the child keeps global
/// hooks gated by the child's own whitelist, the same gate every other
/// event fired from the child's context goes through.
struct SpawnResultHooks {
completed: Vec<ResolvedHook>,
failed: Vec<ResolvedHook>,
session_name: Option<String>,
agent_id: String,
agent_name: String,
}
impl SpawnResultHooks {
fn resolve(child_ctx: &RequestContext, agent_id: &str, agent_name: &str) -> Self {
Self {
completed: child_ctx.resolved_hooks(HookEvent::AgentCompleted),
failed: child_ctx.resolved_hooks(HookEvent::AgentFailed),
session_name: child_ctx
.session
.as_ref()
.map(|session| session.name().to_string()),
agent_id: agent_id.to_string(),
agent_name: agent_name.to_string(),
}
}
/// Fires `agent.completed`, or `agent.failed` when `error` is given.
fn fire(self, error: Option<&str>) {
let mut extras = vec![
("COYOTE_AGENT_ID", self.agent_id),
("COYOTE_AGENT_NAME", self.agent_name.clone()),
];
let (event, resolved) = match error {
None => (HookEvent::AgentCompleted, self.completed),
Some(error) => {
extras.push(("COYOTE_AGENT_ERROR", error.to_string()));
(HookEvent::AgentFailed, self.failed)
}
};
hooks::fire_resolved(
event,
resolved,
hook_base_envs(event, self.session_name.as_deref(), Some(&self.agent_name)),
&extras,
None,
);
}
}
async fn handle_spawn(ctx: &mut RequestContext, args: &Value) -> Result<Value> {
let agent_name = args
.get("agent")
@@ -1149,6 +1230,19 @@ async fn handle_spawn(ctx: &mut RequestContext, args: &Value) -> Result<Value> {
debug!("Spawning child agent '{agent_name}' as '{agent_id}'");
hooks::fire(
HookEvent::AgentStarted,
&child_ctx,
&[
("COYOTE_AGENT_ID", agent_id.clone()),
("COYOTE_AGENT_NAME", agent_name.clone()),
],
None,
);
// Resolved before the spawn: the result arms outlive the child context
// the snapshots come from.
let result_hooks = SpawnResultHooks::resolve(&child_ctx, &agent_id, &agent_name);
let spawn_agent_id = agent_id.clone();
let spawn_agent_name = agent_name.clone();
let spawn_abort = child_abort.clone();
@@ -1173,6 +1267,10 @@ async fn handle_spawn(ctx: &mut RequestContext, args: &Value) -> Result<Value> {
},
};
let success = agent_result.exit_status == AgentExitStatus::Completed;
match &agent_result.exit_status {
AgentExitStatus::Completed => result_hooks.fire(None),
AgentExitStatus::Failed(e) => result_hooks.fire(Some(e)),
}
spawn_notifications.push(agent_notification(
&agent_result.id,
&agent_result.agent_name,
@@ -2154,6 +2252,155 @@ mod tests {
.unwrap();
}
/// A graph agent whose graph.yaml wires `agent.*` hooks at its top
/// level. With `failing`, the start node references a script that does
/// not exist, so the child run fails at runtime — after the spawn seam
/// has already fired `agent.started`.
fn write_graph_agent_with_hooks(agent_name: &str, marker: &str, failing: bool) {
let agent_dir = paths::agent_data_dir(agent_name);
create_dir_all(&agent_dir).unwrap();
let (start, nodes) = if failing {
(
"work",
" work:\n type: script\n script: missing.sh\n next: done\n done:\n type: end\n output: done\n",
)
} else {
("done", " done:\n type: end\n output: done\n")
};
write(
agent_dir.join("graph.yaml"),
format!(
"name: {agent_name}\n\
start: {start}\n\
settings:\n\
\x20 validate_before_run: false\n\
hooks:\n\
\x20 agent.started:\n\
\x20 - name: {marker}_started\n\
\x20 command: \"true\"\n\
\x20 agent.completed:\n\
\x20 - name: {marker}_completed\n\
\x20 command: \"true\"\n\
\x20 agent.failed:\n\
\x20 - name: {marker}_failed\n\
\x20 command: \"true\"\n\
nodes:\n\
{nodes}"
),
)
.unwrap();
}
fn hook_captures_named(name: &str) -> Vec<hooks::test_sink::Capture> {
hooks::test_sink::snapshot()
.into_iter()
.filter(|capture| capture.hook_name == name)
.collect()
}
/// Spawns an agent through the supervisor seam and awaits its spawn
/// task, returning the spawn id. The result arms fire their hooks
/// before that task returns, so the sink is complete afterwards.
fn spawn_and_collect(agent_name: &str) -> String {
run_async(async {
let mut ctx = ctx_with_supervisor(4, 3);
let spawned = handle_spawn(&mut ctx, &json!({"agent": agent_name, "prompt": "hi"}))
.await
.unwrap();
assert_eq!(spawned["status"], "ok", "{spawned}");
let id = spawned["id"].as_str().unwrap().to_string();
let handle = ctx
.supervisor
.as_ref()
.unwrap()
.write()
.take(&id)
.expect("spawned agent must be registered");
handle
.join_handle
.await
.expect("spawn task must not panic")
.expect("spawn task must return an AgentResult");
id
})
}
#[test]
#[serial]
fn spawned_agent_success_fires_started_and_completed_with_matching_id() {
let _guard = TestConfigDirGuard::new();
let _sink = hooks::test_sink::install();
let agent_name = unique_agent_name("test_hook_spawn_ok");
write_graph_agent_with_hooks(&agent_name, "t037_spawn_ok", false);
let id = spawn_and_collect(&agent_name);
let started = hook_captures_named("t037_spawn_ok_started");
assert_eq!(started.len(), 1, "{started:?}");
assert_eq!(started[0].envs["COYOTE_EVENT"], "agent.started");
assert_eq!(started[0].envs["COYOTE_AGENT_ID"], id);
assert_eq!(started[0].envs["COYOTE_AGENT_NAME"], agent_name);
let completed = hook_captures_named("t037_spawn_ok_completed");
assert_eq!(completed.len(), 1, "{completed:?}");
assert_eq!(completed[0].envs["COYOTE_EVENT"], "agent.completed");
assert_eq!(completed[0].envs["COYOTE_AGENT_ID"], id);
assert_eq!(completed[0].envs["COYOTE_AGENT_NAME"], agent_name);
assert!(hook_captures_named("t037_spawn_ok_failed").is_empty());
}
#[test]
#[serial]
fn spawned_agent_failure_fires_started_and_failed_with_matching_id() {
let _guard = TestConfigDirGuard::new();
let _sink = hooks::test_sink::install();
let agent_name = unique_agent_name("test_hook_spawn_fail");
write_graph_agent_with_hooks(&agent_name, "t037_spawn_fail", true);
let id = spawn_and_collect(&agent_name);
let started = hook_captures_named("t037_spawn_fail_started");
assert_eq!(started.len(), 1, "{started:?}");
assert_eq!(started[0].envs["COYOTE_AGENT_ID"], id);
let failed = hook_captures_named("t037_spawn_fail_failed");
assert_eq!(failed.len(), 1, "{failed:?}");
assert_eq!(failed[0].envs["COYOTE_EVENT"], "agent.failed");
assert_eq!(failed[0].envs["COYOTE_AGENT_ID"], id);
assert!(
!failed[0].envs["COYOTE_AGENT_ERROR"].is_empty(),
"a failed spawn must carry its error"
);
assert!(hook_captures_named("t037_spawn_fail_completed").is_empty());
}
#[test]
fn hook_base_envs_carries_optional_names_only_when_present() {
let envs = hook_base_envs(HookEvent::AgentCompleted, None, None);
let keys: Vec<&str> = envs.iter().map(|(key, _)| key.as_str()).collect();
assert!(keys.contains(&"COYOTE_EVENT"));
assert!(keys.contains(&"COYOTE_CONFIG_DIR"));
assert!(keys.contains(&"COYOTE_EVENT_TIMESTAMP"));
assert!(!keys.contains(&"COYOTE_SESSION_ID"));
assert!(!keys.contains(&"COYOTE_AGENT_NAME"));
assert!(
envs.iter()
.any(|(key, value)| key == "COYOTE_EVENT" && value == "agent.completed")
);
let envs = hook_base_envs(HookEvent::AgentFailed, Some("sess"), Some("bot"));
assert!(
envs.iter()
.any(|(key, value)| key == "COYOTE_SESSION_ID" && value == "sess")
);
assert!(
envs.iter()
.any(|(key, value)| key == "COYOTE_AGENT_NAME" && value == "bot")
);
}
fn run_agent_for_graph_error(
agent_name: &str,
graph_inputs: Option<HashMap<String, Value>>,
+274
View File
@@ -9,6 +9,7 @@ use crate::config::{
McpRuntime, RequestContext, effective_max_concurrent_jobs, jobs_enabled, paths,
};
use crate::graph;
use crate::hooks::{self, HookEvent, ResolvedHook};
use crate::mcp::{
MCP_DESCRIBE_META_FUNCTION_NAME_PREFIX, MCP_INVOKE_META_FUNCTION_NAME_PREFIX,
MCP_PROMPT_META_FUNCTION_NAME_PREFIX, MCP_READ_META_FUNCTION_NAME_PREFIX,
@@ -53,6 +54,63 @@ pub fn is_agent_task(supervisor: Option<&Arc<RwLock<Supervisor>>>, id: &str) ->
|| supervisor.is_some_and(|sup| sup.read().has_agent(id))
}
struct JobHookSnapshot {
completed: Vec<ResolvedHook>,
failed: Vec<ResolvedHook>,
session_name: Option<String>,
agent_name: Option<String>,
job_id: String,
tool: String,
}
impl JobHookSnapshot {
fn resolve(ctx: &RequestContext, job_id: &str, tool: &str) -> Self {
Self {
completed: ctx.resolved_hooks(HookEvent::JobCompleted),
failed: ctx.resolved_hooks(HookEvent::JobFailed),
session_name: ctx
.session
.as_ref()
.map(|session| session.name().to_string()),
agent_name: ctx.agent.as_ref().map(|agent| agent.name().to_string()),
job_id: job_id.to_string(),
tool: tool.to_string(),
}
}
/// Fires `job.completed` or `job.failed`. `fire_resolved` callers supply
/// their own base envs; the event name and timestamp are computed here so
/// they reflect the actual outcome rather than the moment the snapshot
/// was taken.
fn fire_terminal(self, success: bool, error: Option<String>) {
let (event, resolved) = if success {
(HookEvent::JobCompleted, self.completed)
} else {
(HookEvent::JobFailed, self.failed)
};
if resolved.is_empty() {
return;
}
let base_envs = hooks::base_envs_parts(
event,
self.session_name.as_deref(),
self.agent_name.as_deref(),
);
let mut extras = vec![
("COYOTE_JOB_ID", self.job_id),
("COYOTE_TOOL_NAME", self.tool),
];
if let Some(error) = error {
extras.push(("COYOTE_JOB_ERROR", error));
}
hooks::fire_resolved(event, resolved, base_envs, &extras, None);
}
}
pub struct RingBuf {
buf: Vec<u8>,
capacity: usize,
@@ -470,15 +528,27 @@ async fn handle_start(ctx: &mut RequestContext, args: &Value) -> Result<Value> {
let task_notifications = Arc::clone(&ctx.notification_queue);
let notify_id = job_id.clone();
let notify_tool = tool.clone();
let hook_snapshot = JobHookSnapshot::resolve(ctx, &job_id, &tool);
hooks::fire(
HookEvent::JobStarted,
ctx,
&[
("COYOTE_JOB_ID", job_id.clone()),
("COYOTE_TOOL_NAME", tool.clone()),
],
None,
);
tokio::spawn(async move {
let result = run_mcp_job(job_ctx, server, inner_tool, inner_args).await;
let success = result.is_ok();
let error = result.as_ref().err().map(|err| err.to_string());
task_state.lock().status = if success {
JobStatus::Completed
} else {
JobStatus::Failed
};
task_notifications.push(job_notification(&notify_id, &notify_tool, success));
hook_snapshot.fire_terminal(success, error);
result
})
} else {
@@ -488,9 +558,26 @@ async fn handle_start(ctx: &mut RequestContext, args: &Value) -> Result<Value> {
let task_notifications = Arc::clone(&ctx.notification_queue);
let notify_id = job_id.clone();
let notify_tool = tool.clone();
let hook_snapshot = JobHookSnapshot::resolve(ctx, &job_id, &tool);
hooks::fire(
HookEvent::JobStarted,
ctx,
&[
("COYOTE_JOB_ID", job_id.clone()),
("COYOTE_TOOL_NAME", tool.clone()),
],
None,
);
tokio::spawn(async move {
let result = run_process_job(snapshot, Arc::clone(&task_state), task_buf).await;
let success = matches!(&result, Ok(job_result) if job_result.exit_code == Some(0));
let error = (!success).then(|| match &result {
Err(err) => err.to_string(),
Ok(job_result) => match job_result.exit_code {
Some(code) => format!("exited with code {code}"),
None => "terminated without an exit code".to_string(),
},
});
let mut job_state = task_state.lock();
job_state.pgid = None;
job_state.status = if success {
@@ -502,6 +589,7 @@ async fn handle_start(ctx: &mut RequestContext, args: &Value) -> Result<Value> {
drop(job_state);
task_notifications.push(job_notification(&notify_id, &notify_tool, success));
hook_snapshot.fire_terminal(success, error);
result
})
};
@@ -3119,4 +3207,190 @@ mod tests {
);
assert!(ctx.supervisor.is_none(), "no job may be spawned");
}
fn job_hooks_map(marker: &str) -> hooks::HooksMap {
["job.started", "job.completed", "job.failed"]
.into_iter()
.map(|event| {
(
event.to_string(),
vec![hooks::HookDef {
name: marker.to_string(),
command: "true".to_string(),
}],
)
})
.collect()
}
fn job_hooked_ctx(marker: &str) -> RequestContext {
let app = app_state_with_config(|config| {
config.hooks = job_hooks_map(marker);
});
RequestContext::new(app, WorkingMode::Cmd)
}
fn job_captures(marker: &str) -> Vec<hooks::test_sink::Capture> {
hooks::test_sink::snapshot()
.into_iter()
.filter(|capture| capture.hook_name == marker)
.collect()
}
#[cfg(unix)]
#[test]
#[serial_test::serial]
fn job_hooks_fire_started_and_completed() {
run_async(async {
let marker = "job-hooks-ok-x7x";
let mut ctx = job_hooked_ctx(marker);
ctx.declared_function_names.insert("echo".into());
let _guard = hooks::test_sink::install();
let started = handle_start(&mut ctx, &json!({"tool": "echo", "arguments": {}}))
.await
.unwrap();
assert_eq!(started["status"], "ok");
let job_id = started["job_id"].as_str().unwrap().to_string();
// `job.started` is dispatched before `handle_start` returns.
let captures = job_captures(marker);
assert_eq!(captures.len(), 1, "{captures:?}");
assert_eq!(captures[0].event, HookEvent::JobStarted);
assert_eq!(
captures[0].envs.get("COYOTE_JOB_ID").map(String::as_str),
Some(job_id.as_str())
);
assert_eq!(
captures[0].envs.get("COYOTE_TOOL_NAME").map(String::as_str),
Some("echo")
);
let collected = handle_collect(&ctx, &json!({"id": job_id})).await.unwrap();
assert_eq!(collected["status"], "completed");
let captures = job_captures(marker);
assert_eq!(captures.len(), 2, "{captures:?}");
let completed = &captures[1];
assert_eq!(completed.event, HookEvent::JobCompleted);
assert_eq!(
completed.envs.get("COYOTE_EVENT").map(String::as_str),
Some("job.completed")
);
assert_eq!(
completed.envs.get("COYOTE_JOB_ID").map(String::as_str),
Some(job_id.as_str())
);
assert_eq!(
completed.envs.get("COYOTE_TOOL_NAME").map(String::as_str),
Some("echo")
);
assert!(completed.envs.contains_key("COYOTE_CONFIG_DIR"));
assert!(completed.envs.contains_key("COYOTE_EVENT_TIMESTAMP"));
assert!(!completed.envs.contains_key("COYOTE_SESSION_ID"));
assert!(!completed.envs.contains_key("COYOTE_JOB_ERROR"));
});
}
#[cfg(unix)]
#[test]
#[serial_test::serial]
fn job_hooks_fire_failed_with_job_error() {
run_async(async {
let marker = "job-hooks-fail-x7x";
let mut ctx = job_hooked_ctx(marker);
ctx.declared_function_names.insert("false".into());
let _guard = hooks::test_sink::install();
let started = handle_start(&mut ctx, &json!({"tool": "false", "arguments": {}}))
.await
.unwrap();
let job_id = started["job_id"].as_str().unwrap().to_string();
let collected = handle_collect(&ctx, &json!({"id": job_id})).await.unwrap();
assert_eq!(collected["status"], "failed");
let captures = job_captures(marker);
assert_eq!(captures.len(), 2, "{captures:?}");
assert_eq!(captures[0].event, HookEvent::JobStarted);
let failed = &captures[1];
assert_eq!(failed.event, HookEvent::JobFailed);
assert_eq!(
failed.envs.get("COYOTE_EVENT").map(String::as_str),
Some("job.failed")
);
assert_eq!(
failed.envs.get("COYOTE_JOB_ID").map(String::as_str),
Some(job_id.as_str())
);
assert_eq!(
failed.envs.get("COYOTE_JOB_ERROR").map(String::as_str),
Some("exited with code 1")
);
});
}
#[test]
#[serial_test::serial]
fn job_hooks_fire_for_mcp_jobs() {
run_async(async {
let marker = "job-hooks-mcp-x7x";
let mut ctx = job_hooked_ctx(marker);
ctx.declared_function_names
.insert("mcp_invoke_fixture".into());
let fixture = FixtureServer::default();
let (mut runtime, _server) = fixture_runtime(fixture).await;
let mut filter = ToolFilter::default();
filter.push_layer(LayerSource::Global, &[]);
runtime.tool_filters.insert("fixture".to_string(), filter);
ctx.tool_scope.mcp_runtime = runtime;
let _guard = hooks::test_sink::install();
let started = handle_start(
&mut ctx,
&json!({"tool": "mcp_invoke_fixture", "arguments": {"tool": "dup"}}),
)
.await
.unwrap();
let job_id = started["job_id"].as_str().unwrap().to_string();
let collected = handle_collect(&ctx, &json!({"id": job_id})).await.unwrap();
assert_eq!(collected["status"], "failed");
let captures = job_captures(marker);
assert_eq!(captures.len(), 2, "{captures:?}");
assert_eq!(captures[0].event, HookEvent::JobStarted);
assert_eq!(
captures[0].envs.get("COYOTE_TOOL_NAME").map(String::as_str),
Some("mcp_invoke_fixture")
);
let failed = &captures[1];
assert_eq!(failed.event, HookEvent::JobFailed);
assert!(
failed
.envs
.get("COYOTE_JOB_ERROR")
.unwrap()
.contains("dup not found"),
"{failed:?}"
);
});
}
#[test]
#[serial_test::serial]
fn rejected_job_start_fires_no_hooks() {
let marker = "job-hooks-rejected-x7x";
let mut ctx = job_hooked_ctx(marker);
let _guard = hooks::test_sink::install();
let result = run_async(handle_start(
&mut ctx,
&json!({"tool": "undeclared_tool", "arguments": {}}),
))
.unwrap();
assert_eq!(result["status"], "error");
assert!(job_captures(marker).is_empty());
}
}
+332 -1
View File
@@ -17,6 +17,7 @@ use crate::{
use crate::config::ensure_parent_exists;
use crate::config::paths;
use crate::hooks::{self, HookEvent};
use crate::mcp::{
MCP_DESCRIBE_META_FUNCTION_NAME_PREFIX, MCP_INVOKE_META_FUNCTION_NAME_PREFIX,
MCP_META_FUNCTION_PREFIXES, MCP_PROMPT_META_FUNCTION_NAME_PREFIX,
@@ -1539,6 +1540,22 @@ impl ToolCall {
}
pub async fn eval(&self, ctx: &mut RequestContext) -> Result<Value> {
let result = self.eval_inner(ctx).await;
if let Err(err) = &result {
hooks::fire(
HookEvent::ToolFailed,
ctx,
&[
("COYOTE_TOOL_NAME", self.name.clone()),
("COYOTE_TOOL_ERROR", err.to_string()),
],
None,
);
}
result
}
async fn eval_inner(&self, ctx: &mut RequestContext) -> Result<Value> {
let agent = ctx.agent.clone();
let functions = ctx.tool_scope.functions.clone();
let current_depth = ctx.current_depth;
@@ -1566,7 +1583,8 @@ impl ToolCall {
);
};
cmd_args.push(json_data.to_string());
let args_json = json_data.to_string();
cmd_args.push(args_json.clone());
if (*IS_STDOUT_TERMINAL || *SHOW_TOOL_CALLS)
&& current_depth == 0
@@ -1575,6 +1593,14 @@ impl ToolCall {
println!("{}", format_call_log(&cmd_name, &cmd_args, &json_data));
}
hooks::fire(
HookEvent::ToolStarted,
ctx,
&[("COYOTE_TOOL_NAME", self.name.clone())],
Some(args_json.clone()),
);
let tool_started_at = Instant::now();
let output = match cmd_name.as_str() {
_ if cmd_name.starts_with(MCP_SEARCH_META_FUNCTION_NAME_PREFIX) => {
Self::search_mcp_tools(ctx, &cmd_name, &json_data)
@@ -1699,6 +1725,38 @@ impl ToolCall {
},
};
match output.get("tool_call_error") {
Some(error) => {
let error = error
.as_str()
.map(str::to_string)
.unwrap_or_else(|| error.to_string());
hooks::fire(
HookEvent::ToolFailed,
ctx,
&[
("COYOTE_TOOL_NAME", self.name.clone()),
("COYOTE_TOOL_ERROR", error),
],
Some(args_json),
);
}
None => {
hooks::fire(
HookEvent::ToolCompleted,
ctx,
&[
("COYOTE_TOOL_NAME", self.name.clone()),
(
"COYOTE_TOOL_DURATION_MS",
tool_started_at.elapsed().as_millis().to_string(),
),
],
Some(args_json),
);
}
}
Ok(output)
}
@@ -5271,4 +5329,277 @@ mod tests {
assert!(err.starts_with(expected), "{name}: {err}");
}
}
fn tool_hooks_map(marker: &str) -> hooks::HooksMap {
["tool.started", "tool.completed", "tool.failed"]
.into_iter()
.map(|event| {
(
event.to_string(),
vec![hooks::HookDef {
name: marker.to_string(),
command: "true".to_string(),
}],
)
})
.collect()
}
fn ctx_with_tool_hooks(marker: &str, update: impl FnOnce(&mut AppConfig)) -> RequestContext {
let mut app = AppState::test_default();
let mut config = AppConfig {
hooks: tool_hooks_map(marker),
..Default::default()
};
update(&mut config);
app.config = Arc::new(config);
RequestContext::new(Arc::new(app), WorkingMode::Cmd)
}
fn tool_captures(marker: &str) -> Vec<hooks::test_sink::Capture> {
hooks::test_sink::snapshot()
.into_iter()
.filter(|capture| capture.hook_name == marker)
.collect()
}
#[test]
#[serial]
fn eval_fires_started_and_completed_for_builtin_tools() {
use crate::hooks::HookEvent;
let marker = "tool-hooks-builtin-ok-x7x";
let mut ctx = ctx_with_tool_hooks(marker, |config| {
config.function_calling_support = true;
config.auto_continue = true;
});
ctx.tool_scope.functions.append_todo_functions();
let _guard = hooks::test_sink::install();
let output =
run_async(call_with_args("todo__init", json!({"goal": "g"})).eval(&mut ctx)).unwrap();
assert_eq!(output["status"], "ok");
let captures = tool_captures(marker);
assert_eq!(captures.len(), 2, "{captures:?}");
assert_eq!(captures[0].event, HookEvent::ToolStarted);
assert_eq!(
captures[0].envs.get("COYOTE_TOOL_NAME").map(String::as_str),
Some("todo__init")
);
assert_eq!(captures[0].payload.as_deref(), Some(r#"{"goal":"g"}"#));
assert_eq!(captures[0].cwd, paths::config_dir());
assert_eq!(captures[1].event, HookEvent::ToolCompleted);
assert!(captures[1].envs.contains_key("COYOTE_TOOL_DURATION_MS"));
assert_eq!(captures[1].payload.as_deref(), Some(r#"{"goal":"g"}"#));
}
#[test]
#[serial]
fn eval_fires_started_and_completed_for_mcp_meta_tools() {
use crate::hooks::HookEvent;
let marker = "tool-hooks-mcp-ok-x7x";
run_async(async {
let mut result = CallToolResult::success(vec![ContentBlock::text("hi")]);
result.structured_content = Some(json!({"ok": true}));
let fixture = FixtureServer {
tool_result: Some(result),
..Default::default()
};
let (runtime, _server) = fixture_runtime(fixture).await;
let mut ctx = ctx_with_tool_hooks(marker, |_| {});
ctx.tool_scope.mcp_runtime = runtime;
ctx.tool_scope
.functions
.append_mcp_meta_functions(vec![tools_only("fixture")]);
let _guard = hooks::test_sink::install();
let output = call_with_args("mcp_invoke_fixture", json!({"tool": "dup"}))
.eval(&mut ctx)
.await
.unwrap();
assert_eq!(output["isError"], false);
let captures = tool_captures(marker);
assert_eq!(captures.len(), 2, "{captures:?}");
assert_eq!(captures[0].event, HookEvent::ToolStarted);
assert_eq!(
captures[0].envs.get("COYOTE_TOOL_NAME").map(String::as_str),
Some("mcp_invoke_fixture")
);
assert_eq!(captures[0].payload.as_deref(), Some(r#"{"tool":"dup"}"#));
assert_eq!(captures[1].event, HookEvent::ToolCompleted);
});
}
#[cfg(unix)]
#[test]
#[serial]
fn eval_fires_started_and_completed_for_external_command_tools() {
use crate::hooks::HookEvent;
let marker = "tool-hooks-external-ok-x7x";
let mut ctx = ctx_with_tool_hooks(marker, |_| {});
ctx.tool_scope
.functions
.append_declaration(FunctionDeclaration {
name: "echo".to_string(),
description: String::new(),
parameters: JsonSchema::default(),
agent: false,
});
let _guard = hooks::test_sink::install();
let output = run_async(call_with_args("echo", json!({"x": 1})).eval(&mut ctx)).unwrap();
assert_eq!(output, Value::Null);
let captures = tool_captures(marker);
assert_eq!(captures.len(), 2, "{captures:?}");
assert_eq!(captures[0].event, HookEvent::ToolStarted);
assert_eq!(
captures[0].envs.get("COYOTE_TOOL_NAME").map(String::as_str),
Some("echo")
);
assert_eq!(captures[1].event, HookEvent::ToolCompleted);
}
#[cfg(unix)]
#[test]
#[serial]
fn eval_fires_failed_for_external_command_nonzero_exit() {
use crate::hooks::HookEvent;
let marker = "tool-hooks-external-fail-x7x";
let mut ctx = ctx_with_tool_hooks(marker, |_| {});
ctx.tool_scope
.functions
.append_declaration(FunctionDeclaration {
name: "false".to_string(),
description: String::new(),
parameters: JsonSchema::default(),
agent: false,
});
let _guard = hooks::test_sink::install();
let output = run_async(call_with_args("false", json!({})).eval(&mut ctx)).unwrap();
assert!(output["tool_call_error"].is_string());
let captures = tool_captures(marker);
assert_eq!(captures.len(), 2, "{captures:?}");
assert_eq!(captures[0].event, HookEvent::ToolStarted);
assert_eq!(captures[1].event, HookEvent::ToolFailed);
assert!(
captures[1]
.envs
.get("COYOTE_TOOL_ERROR")
.unwrap()
.contains("exited with code 1")
);
assert!(!captures[1].envs.contains_key("COYOTE_TOOL_DURATION_MS"));
}
#[test]
#[serial]
fn eval_fires_failed_on_error_shaped_output_with_identical_result() {
use crate::hooks::HookEvent;
let marker = "tool-hooks-error-shape-x7x";
// Default config leaves todo tools disabled, so the handler bails
// and eval shapes the failure as a `tool_call_error` output.
let mut hooked = ctx_with_tool_hooks(marker, |_| {});
hooked.tool_scope.functions.append_todo_functions();
let mut plain = RequestContext::new(Arc::new(AppState::test_default()), WorkingMode::Cmd);
plain.tool_scope.functions.append_todo_functions();
let call = call_with_args("todo__done", json!({}));
let _guard = hooks::test_sink::install();
let hooked_output = run_async(call.eval(&mut hooked)).unwrap();
let plain_output = run_async(call.eval(&mut plain)).unwrap();
assert_eq!(
hooked_output.to_string(),
plain_output.to_string(),
"hooks must not change the tool result"
);
let error = hooked_output["tool_call_error"].as_str().unwrap();
assert!(error.starts_with("Todo tool failed:"), "{error}");
let captures = tool_captures(marker);
assert_eq!(captures.len(), 2, "{captures:?}");
assert_eq!(captures[0].event, HookEvent::ToolStarted);
assert_eq!(captures[1].event, HookEvent::ToolFailed);
assert_eq!(
captures[1]
.envs
.get("COYOTE_TOOL_ERROR")
.map(String::as_str),
Some(error)
);
}
#[test]
#[serial]
fn eval_fires_hooks_with_exact_user_prefixed_tool_name() {
use crate::hooks::HookEvent;
let marker = "tool-hooks-user-x7x";
let mut ctx = ctx_with_tool_hooks(marker, |_| {});
ctx.tool_scope.functions.append_user_interaction_functions();
let _guard = hooks::test_sink::install();
let output = run_async(call_with_args("user__confirm", json!({})).eval(&mut ctx)).unwrap();
// Without a question the user handler errors before prompting, so the
// error-shaped output must fire started + failed with the exact name.
let error = output["tool_call_error"].as_str().unwrap();
assert!(error.starts_with("User interaction failed"), "{error}");
let captures = tool_captures(marker);
assert_eq!(captures.len(), 2, "{captures:?}");
assert_eq!(captures[0].event, HookEvent::ToolStarted);
assert_eq!(
captures[0].envs.get("COYOTE_TOOL_NAME").map(String::as_str),
Some("user__confirm")
);
assert_eq!(captures[1].event, HookEvent::ToolFailed);
assert_eq!(
captures[1].envs.get("COYOTE_TOOL_NAME").map(String::as_str),
Some("user__confirm")
);
assert_eq!(
captures[1]
.envs
.get("COYOTE_TOOL_ERROR")
.map(String::as_str),
Some(error)
);
}
#[test]
#[serial]
fn eval_err_fires_failed_without_started_and_identical_error() {
use crate::hooks::HookEvent;
let marker = "tool-hooks-eval-err-x7x";
let mut hooked = ctx_with_tool_hooks(marker, |_| {});
let mut plain = RequestContext::new(Arc::new(AppState::test_default()), WorkingMode::Cmd);
let call = call_with_args("nonexistent_tool", json!({}));
let _guard = hooks::test_sink::install();
let hooked_err = run_async(call.eval(&mut hooked)).unwrap_err();
let plain_err = run_async(call.eval(&mut plain)).unwrap_err();
assert_eq!(
hooked_err.to_string(),
plain_err.to_string(),
"hooks must not change the error"
);
let captures = tool_captures(marker);
assert_eq!(captures.len(), 1, "{captures:?}");
assert_eq!(captures[0].event, HookEvent::ToolFailed);
assert_eq!(
captures[0].envs.get("COYOTE_TOOL_NAME").map(String::as_str),
Some("nonexistent_tool")
);
assert!(
captures[0]
.envs
.get("COYOTE_TOOL_ERROR")
.unwrap()
.contains("Unexpected call")
);
assert!(captures[0].payload.is_none());
}
}