style: applied improved styling to the whole of the hooks integration

This commit is contained in:
Dark-Alex-17 committed 2026-09-16 13:44:35 -06:00
1 parent 0defc61bec
commit 4087f3b3d0
17 files changed
+92 -176

No files matched your search

+16 -22
View File
@@ -547,10 +547,6 @@ pub async fn create_openai_compatible_client_config(
Ok(Some((model, clients)))
}
/// Base extras every `llm.request.*` event carries. The three transport
/// functions below are the single seam family for these events: they cover
/// every provider and every caller, so the `Client` trait and per-provider
/// code stay uninstrumented.
fn llm_request_extras(client: &dyn Client) -> Vec<(&'static str, String)> {
vec![
("COYOTE_LLM_PROVIDER", client.name().to_string()),
@@ -567,10 +563,6 @@ fn llm_completed_extras(client: &dyn Client, started_at: Instant) -> Vec<(&'stat
extras
}
/// `aborted` marks a user interrupt; otherwise the error is `api_error` when
/// it bottoms out in an [`ApiStatusError`] (which also yields
/// `COYOTE_LLM_STATUS`), else `other`. Abort paths that produce no error
/// object report `Aborted.`.
fn llm_failed_extras(
client: &dyn Client,
err: Option<&anyhow::Error>,
@@ -592,6 +584,7 @@ fn llm_failed_extras(
err.map(|err| format!("{err:#}"))
.unwrap_or_else(|| "Aborted.".to_string()),
));
extras
}
@@ -1136,6 +1129,7 @@ mod tests {
use super::super::access_token::{is_rejected, set_access_token};
use crate::config::{AppState, WorkingMode};
use anyhow::Error;
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
use tokio::time::Instant;
@@ -1352,8 +1346,8 @@ mod tests {
/// Wrapped in `.context(...)` so every test below proves the downcast
/// works through an anyhow context chain, as in the trait methods.
fn api_status_error(status: u16) -> anyhow::Error {
anyhow::Error::new(ApiStatusError {
fn api_status_error(status: u16) -> Error {
Error::new(ApiStatusError {
status,
message: format!("error (status: {status})"),
})
@@ -1709,8 +1703,8 @@ mod tests {
}
}
fn error(&self) -> anyhow::Error {
anyhow::Error::new(ApiStatusError {
fn error(&self) -> Error {
Error::new(ApiStatusError {
status: self.status,
message: format!("error (status: {})", self.status),
})
@@ -1757,7 +1751,7 @@ mod tests {
}
}
fn llm_hooks_map(marker: &str) -> crate::hooks::HooksMap {
fn llm_hooks_map(marker: &str) -> hooks::HooksMap {
[
"llm.request.started",
"llm.request.completed",
@@ -1787,8 +1781,8 @@ mod tests {
ctx
}
fn llm_captures(marker: &str) -> Vec<crate::hooks::test_sink::Capture> {
crate::hooks::test_sink::snapshot()
fn llm_captures(marker: &str) -> Vec<hooks::test_sink::Capture> {
hooks::test_sink::snapshot()
.into_iter()
.filter(|capture| capture.hook_name == marker)
.collect()
@@ -1802,7 +1796,7 @@ mod tests {
let input = Input::from_str(&ctx, "hi", None).unwrap();
let mut client = paced_client(false);
client.nonstream_replies = true;
let _guard = crate::hooks::test_sink::install();
let _guard = hooks::test_sink::install();
call_chat_completions(
&input,
@@ -1838,7 +1832,7 @@ mod tests {
let mut ctx = llm_hooked_ctx(marker);
let input = Input::from_str(&ctx, "hi", None).unwrap();
let client = FailingStatusClient::new(429);
let _guard = crate::hooks::test_sink::install();
let _guard = hooks::test_sink::install();
let err = call_chat_completions(
&input,
@@ -1885,7 +1879,7 @@ mod tests {
let mut ctx = llm_hooked_ctx(marker);
let input = Input::from_str(&ctx, "hi", None).unwrap();
let client = paced_client(false);
let _guard = crate::hooks::test_sink::install();
let _guard = hooks::test_sink::install();
call_chat_completions_streaming(&input, &client, &mut ctx, create_abort_signal())
.await
@@ -1911,7 +1905,7 @@ mod tests {
tokio::time::sleep(PACED_GAP * PACED_DELTAS as u32 + Duration::from_secs(30)).await;
trigger.set_ctrlc();
});
let _guard = crate::hooks::test_sink::install();
let _guard = hooks::test_sink::install();
let err = call_chat_completions_streaming(&input, &client, &mut ctx, abort)
.await
@@ -1936,7 +1930,7 @@ mod tests {
let mut ctx = llm_hooked_ctx(marker);
let input = Input::from_str(&ctx, "hi", None).unwrap();
let client = paced_client(false);
let _guard = crate::hooks::test_sink::install();
let _guard = hooks::test_sink::install();
call_chat_completions_streaming_quiet(&input, &client, &mut ctx, create_abort_signal())
.await
@@ -1969,7 +1963,7 @@ mod tests {
tokio::time::sleep(PACED_GAP * PACED_DELTAS as u32 + Duration::from_secs(30)).await;
trigger.set_ctrlc();
});
let _guard = crate::hooks::test_sink::install();
let _guard = hooks::test_sink::install();
let err = call_chat_completions_streaming_quiet(&input, &client, &mut ctx, abort)
.await
@@ -1997,7 +1991,7 @@ mod tests {
let mut ctx = llm_hooked_ctx(marker);
let input = Input::from_str(&ctx, "hi", None).unwrap();
let client = FailingStatusClient::new(500);
let _guard = crate::hooks::test_sink::install();
let _guard = hooks::test_sink::install();
let err =
call_chat_completions_streaming_quiet(&input, &client, &mut ctx, create_abort_signal())
+1 -1
View File
@@ -1489,7 +1489,7 @@ nodes:
type: end
output: done
";
let graph: graph::Graph = serde_yaml::from_str(yaml).unwrap();
let graph: Graph = serde_yaml::from_str(yaml).unwrap();
let config = AgentConfig::from_graph("g", &graph);
+2 -2
View File
@@ -807,7 +807,7 @@ mod tests {
#[test]
fn from_config_copies_hooks() {
let mut hooks = crate::hooks::HooksMap::new();
let mut hooks = HooksMap::new();
hooks.insert(
"turn.completed".to_string(),
vec![crate::hooks::HookDef {
@@ -845,7 +845,7 @@ mod tests {
let env_name = get_env_name("hooks");
let prev = env::var_os(&env_name);
let mut hooks = crate::hooks::HooksMap::new();
let mut hooks = HooksMap::new();
hooks.insert(
"turn.completed".to_string(),
vec![crate::hooks::HookDef {
+4 -11
View File
@@ -15,16 +15,8 @@ use std::collections::BTreeSet;
use std::fs;
use std::path::Path;
/// Hidden per-directory record of the files the builtin installer shipped
/// there. Format: one plain filename per line (LF-separated, sorted); no
/// path separators, and never the manifest itself. Written non-executable
/// through the same atomic-write path as other installed files.
pub(crate) const BUILTIN_MANIFEST_FILE: &str = ".builtin-manifest";
/// True when `name` aliases the manifest file on some real-world filesystem:
/// the comparison ignores trailing dots/spaces (Windows strips them at
/// creation) and folds Unicode case (case-insensitive APFS/NTFS volumes fold
/// beyond ASCII, e.g. U+017F ſ → s).
pub(crate) fn is_builtin_manifest_name(name: &str) -> bool {
name.trim_end_matches(['.', ' '])
.to_uppercase()
@@ -36,8 +28,8 @@ pub(crate) fn is_builtin_manifest_name(name: &str) -> bool {
/// absent from `shipped`. The manifest is then rewritten to the names this
/// installer actually owns: files written this run (`written`), plus shipped
/// names it already owned, plus stale files whose removal failed (kept so a
/// later run retries). A shipped name the installer declined to write — a
/// pre-existing user file with a colliding name — is never claimed.
/// later run retries). A shipped name the installer declined to write (e.g., a
/// pre-existing user file with a colliding name) is never claimed.
///
/// Failure modes degrade toward keeping files: a missing or unreadable
/// manifest yields no deletions, and entries that are not plain filenames
@@ -137,8 +129,9 @@ fn write_manifest(dir: &Path, names: &BTreeSet<String>) -> Result<()> {
mod tests {
use super::*;
use crate::utils;
use std::path::PathBuf;
fn fresh_dir(label: &str) -> std::path::PathBuf {
fn fresh_dir(label: &str) -> PathBuf {
let dir = utils::temp_file(label, "");
fs::create_dir_all(&dir).unwrap();
dir
+6 -6
View File
@@ -2418,6 +2418,7 @@ fn print_secret_summary(added: &[String], deferred: &[String]) {
#[cfg(test)]
mod tests {
use super::*;
use crate::config;
use crate::config::builtin_manifest::BUILTIN_MANIFEST_FILE;
use crate::sandbox::SANDBOX_ENV_FLAG;
use crate::utils::get_env_name;
@@ -2710,8 +2711,6 @@ mod tests {
assert!(out.functions_tools.is_none() && out.mcp_json.is_none());
}
/// `--filter hooks` used to be an inert arm that always produced an empty
/// layout; it now selects the bundle's top-level hooks directory.
#[test]
fn apply_filter_hooks_keeps_only_hooks() {
let l = RemoteLayout {
@@ -3624,8 +3623,9 @@ mod tests {
fn assert_executable(path: &Path) {
use std::os::unix::fs::PermissionsExt;
let mode = fs::metadata(path).unwrap().permissions().mode();
assert!(
mode & 0o111 != 0,
assert_ne!(
mode & 0o111,
0,
"{} must carry the executable bit",
path.display()
);
@@ -3932,7 +3932,7 @@ mod tests {
}
let _guard = TestVaultConfigGuard::new("prov-builtin");
crate::config::install_assets(crate::config::AssetCategory::Macros).unwrap();
config::install_assets(config::AssetCategory::Macros).unwrap();
assert!(fs::read_dir(paths::macros_dir()).unwrap().next().is_some());
assert!(!paths::installed_bundles_file().exists());
@@ -3957,7 +3957,7 @@ mod tests {
// Capture outcomes before restoring the env var so a failed assertion
// cannot leave the hooks-dir override pointing at a deleted temp dir.
let result = crate::config::install_assets(crate::config::AssetCategory::Hooks);
let result = config::install_assets(AssetCategory::Hooks);
let installed = (
hooks_dir.join("notify.sh").is_file(),
hooks_dir.join("log-events.sh").is_file(),
+3 -2
View File
@@ -83,6 +83,7 @@ use crate::utils::*;
pub use macros::macro_execute;
use crate::config::macros::Macro;
use crate::hooks;
use crate::vault::{
GlobalVault, Vault, create_vault_password_file, interpolate_secrets, prompt_provider_choice,
};
@@ -420,7 +421,7 @@ pub fn install_builtins() -> Result<()> {
Agent::install_builtin_agents(false)?;
Macro::install_macros(false)?;
Skill::install_builtin_skills(false)?;
crate::hooks::install_builtin_hooks(false)?;
hooks::install_builtin_hooks(false)?;
Role::install_builtin_role_hooks(false)?;
Ok(())
}
@@ -522,7 +523,7 @@ pub fn install_assets(category: AssetCategory) -> Result<()> {
AssetCategory::Macros => Macro::install_macros(true)?,
AssetCategory::Functions => Functions::install_builtin_global_tools(true)?,
AssetCategory::Skills => Skill::install_builtin_skills(true)?,
AssetCategory::Hooks => crate::hooks::install_builtin_hooks(true)?,
AssetCategory::Hooks => hooks::install_builtin_hooks(true)?,
AssetCategory::McpConfig => Functions::install_mcp_config()?,
}
+3 -15
View File
@@ -4733,8 +4733,6 @@ impl RequestContext {
}
self.refresh_mcp_tool_filters();
self.init_agent_session_variables(new_session)?;
// NEW sessions only, and only once the session is fully engaged on
// the context; resuming an existing session fires nothing.
if created_new_session {
hooks::fire(HookEvent::SessionStarted, self, &[], None);
}
@@ -4933,24 +4931,18 @@ impl RequestContext {
Ok(())
}
/// Opens the top-level `agent.*` bracket: fires `agent.started` once per
/// agent-context lifetime when the ROOT context runs with a loaded agent
/// (headless `--agent` dispatch, or entering an agent interactively).
/// Spawned sub-agents are bracketed at their spawn seam instead, never
/// here. Synthesizes and stores `COYOTE_AGENT_ID` under the
/// `top-<agent-name>-<8-char alphanumeric nonce>` scheme; calling this
/// again while the bracket is open, or without an agent, is a no-op —
/// per-message granularity belongs to `turn.*`, not `agent.*`.
pub fn top_level_agent_started(&mut self) {
if self.top_level_agent_id.is_some() {
return;
}
let Some(agent) = self.agent.as_ref() else {
return;
};
let nonce = Alphanumeric.sample_string(&mut rand::rng(), 8);
let id = format!("top-{}-{nonce}", agent.name());
self.top_level_agent_id = Some(id.clone());
hooks::fire(
HookEvent::AgentStarted,
self,
@@ -4959,15 +4951,11 @@ impl RequestContext {
);
}
/// Closes the top-level `agent.*` bracket: fires `agent.completed`, or
/// `agent.failed` carrying `COYOTE_AGENT_ERROR` when the run ended in an
/// error. No-op unless [`Self::top_level_agent_started`] opened the
/// bracket; the stored id is consumed so exactly one terminal event
/// fires per lifetime.
pub fn top_level_agent_finished(&mut self, error: Option<&Error>) {
let Some(id) = self.top_level_agent_id.take() else {
return;
};
match error {
None => hooks::fire(
HookEvent::AgentCompleted,
+22 -35
View File
@@ -1039,9 +1039,9 @@ Input 1
.collect()
}
fn hooks_fixture_dir(label: &str) -> std::path::PathBuf {
let dir = crate::utils::temp_file(label, "");
std::fs::create_dir_all(&dir).unwrap();
fn hooks_fixture_dir(label: &str) -> PathBuf {
let dir = temp_file(label, "");
create_dir_all(&dir).unwrap();
dir.join("hooks")
}
@@ -1052,55 +1052,42 @@ Input 1
install_and_reconcile_role_hooks(&dir, &fixture(&[("a.sh", "#!/bin/sh\n")]), false)
.unwrap();
assert_eq!(read_to_string(dir.join("a.sh")).unwrap(), "#!/bin/sh\n");
assert_eq!(
std::fs::read_to_string(dir.join("a.sh")).unwrap(),
"#!/bin/sh\n"
);
assert_eq!(
std::fs::read_to_string(dir.join(builtin_manifest::BUILTIN_MANIFEST_FILE)).unwrap(),
read_to_string(dir.join(builtin_manifest::BUILTIN_MANIFEST_FILE)).unwrap(),
"a.sh\n"
);
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mode = std::fs::metadata(dir.join("a.sh"))
let mode = fs::metadata(dir.join("a.sh")).unwrap().permissions().mode();
assert_eq!(mode & 0o777, 0o755, "role hook scripts install executable");
let manifest_mode = fs::metadata(dir.join(builtin_manifest::BUILTIN_MANIFEST_FILE))
.unwrap()
.permissions()
.mode();
assert_eq!(mode & 0o777, 0o755, "role hook scripts install executable");
let manifest_mode =
std::fs::metadata(dir.join(builtin_manifest::BUILTIN_MANIFEST_FILE))
.unwrap()
.permissions()
.mode();
assert_eq!(
manifest_mode & 0o111,
0,
"the manifest must never be executable"
);
}
let _ = std::fs::remove_dir_all(dir.parent().unwrap());
let _ = fs::remove_dir_all(dir.parent().unwrap());
}
#[test]
fn role_hooks_install_overwrites_only_with_force() {
let dir = hooks_fixture_dir("role-hooks-force-");
let shipped = fixture(&[("a.sh", "new content\n")]);
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("a.sh"), "SENTINEL").unwrap();
create_dir_all(&dir).unwrap();
fs::write(dir.join("a.sh"), "SENTINEL").unwrap();
install_and_reconcile_role_hooks(&dir, &shipped, false).unwrap();
assert_eq!(
std::fs::read_to_string(dir.join("a.sh")).unwrap(),
"SENTINEL"
);
assert_eq!(read_to_string(dir.join("a.sh")).unwrap(), "SENTINEL");
install_and_reconcile_role_hooks(&dir, &shipped, true).unwrap();
assert_eq!(
std::fs::read_to_string(dir.join("a.sh")).unwrap(),
"new content\n"
);
let _ = std::fs::remove_dir_all(dir.parent().unwrap());
assert_eq!(read_to_string(dir.join("a.sh")).unwrap(), "new content\n");
let _ = fs::remove_dir_all(dir.parent().unwrap());
}
#[test]
@@ -1113,7 +1100,7 @@ Input 1
false,
)
.unwrap();
std::fs::write(dir.join("user.sh"), "user-owned").unwrap();
fs::write(dir.join("user.sh"), "user-owned").unwrap();
install_and_reconcile_role_hooks(&dir, &fixture(&[("keep.sh", "#!/bin/sh\n")]), false)
.unwrap();
@@ -1125,17 +1112,17 @@ Input 1
assert!(dir.join("keep.sh").exists());
assert!(dir.join("user.sh").exists(), "user file survives reconcile");
assert_eq!(
std::fs::read_to_string(dir.join(builtin_manifest::BUILTIN_MANIFEST_FILE)).unwrap(),
read_to_string(dir.join(builtin_manifest::BUILTIN_MANIFEST_FILE)).unwrap(),
"keep.sh\n"
);
let _ = std::fs::remove_dir_all(dir.parent().unwrap());
let _ = fs::remove_dir_all(dir.parent().unwrap());
}
#[test]
fn role_hooks_never_claim_a_preexisting_user_file() {
let dir = hooks_fixture_dir("role-hooks-unclaimed-");
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("a.sh"), "user-owned").unwrap();
create_dir_all(&dir).unwrap();
fs::write(dir.join("a.sh"), "user-owned").unwrap();
install_and_reconcile_role_hooks(&dir, &fixture(&[("a.sh", "#!/bin/sh\n")]), false)
.unwrap();
@@ -1146,11 +1133,11 @@ Input 1
install_and_reconcile_role_hooks(&dir, &[], false).unwrap();
assert_eq!(
std::fs::read_to_string(dir.join("a.sh")).unwrap(),
read_to_string(dir.join("a.sh")).unwrap(),
"user-owned",
"the user file survives the hook being dropped from the embed"
);
let _ = std::fs::remove_dir_all(dir.parent().unwrap());
let _ = fs::remove_dir_all(dir.parent().unwrap());
}
#[test]
@@ -1160,6 +1147,6 @@ Input 1
install_and_reconcile_role_hooks(&dir, &[], false).unwrap();
assert!(!dir.exists(), "an empty shipped set must not create hooks/");
let _ = std::fs::remove_dir_all(dir.parent().unwrap());
let _ = fs::remove_dir_all(dir.parent().unwrap());
}
}
+1 -1
View File
@@ -1288,7 +1288,7 @@ mod tests {
fn session_deserialization_ignores_injected_role_hooks() {
// A session file is attacker-writable data: a `role_hooks:` key
// crafted into it must never deserialize into executable hook
// config — hooks are only ever re-read from the role file itself.
// config. Hooks are only ever re-read from the role file itself.
let yaml = "model: ''\nmessages: []\nrole_hooks:\n session.started:\n - name: injected\n command: touch /tmp/pwned\n";
let session: Session = serde_yaml::from_str(yaml).unwrap();
assert!(
+12 -12
View File
@@ -54,10 +54,6 @@ pub fn is_agent_task(supervisor: Option<&Arc<RwLock<Supervisor>>>, id: &str) ->
|| supervisor.is_some_and(|sup| sup.read().has_agent(id))
}
/// Terminal-event hooks for one job, resolved on the caller's context before
/// the task is spawned (`build_env_snapshot` precedent) so the detached
/// closure owns everything it fires with. Session and agent names are
/// snapshotted because the context is gone by fire time.
struct JobHookSnapshot {
completed: Vec<ResolvedHook>,
failed: Vec<ResolvedHook>,
@@ -92,9 +88,11 @@ impl JobHookSnapshot {
} else {
(HookEvent::JobFailed, self.failed)
};
if resolved.is_empty() {
return;
}
let base_envs = hooks::base_envs_parts(
event,
self.session_name.as_deref(),
@@ -104,9 +102,11 @@ impl JobHookSnapshot {
("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);
}
}
@@ -3208,13 +3208,13 @@ mod tests {
assert!(ctx.supervisor.is_none(), "no job may be spawned");
}
fn job_hooks_map(marker: &str) -> crate::hooks::HooksMap {
fn job_hooks_map(marker: &str) -> hooks::HooksMap {
["job.started", "job.completed", "job.failed"]
.into_iter()
.map(|event| {
(
event.to_string(),
vec![crate::hooks::HookDef {
vec![hooks::HookDef {
name: marker.to_string(),
command: "true".to_string(),
}],
@@ -3230,8 +3230,8 @@ mod tests {
RequestContext::new(app, WorkingMode::Cmd)
}
fn job_captures(marker: &str) -> Vec<crate::hooks::test_sink::Capture> {
crate::hooks::test_sink::snapshot()
fn job_captures(marker: &str) -> Vec<hooks::test_sink::Capture> {
hooks::test_sink::snapshot()
.into_iter()
.filter(|capture| capture.hook_name == marker)
.collect()
@@ -3245,7 +3245,7 @@ mod tests {
let marker = "job-hooks-ok-x7x";
let mut ctx = job_hooked_ctx(marker);
ctx.declared_function_names.insert("echo".into());
let _guard = crate::hooks::test_sink::install();
let _guard = hooks::test_sink::install();
let started = handle_start(&mut ctx, &json!({"tool": "echo", "arguments": {}}))
.await
@@ -3300,7 +3300,7 @@ mod tests {
let marker = "job-hooks-fail-x7x";
let mut ctx = job_hooked_ctx(marker);
ctx.declared_function_names.insert("false".into());
let _guard = crate::hooks::test_sink::install();
let _guard = hooks::test_sink::install();
let started = handle_start(&mut ctx, &json!({"tool": "false", "arguments": {}}))
.await
@@ -3344,7 +3344,7 @@ mod tests {
filter.push_layer(LayerSource::Global, &[]);
runtime.tool_filters.insert("fixture".to_string(), filter);
ctx.tool_scope.mcp_runtime = runtime;
let _guard = crate::hooks::test_sink::install();
let _guard = hooks::test_sink::install();
let started = handle_start(
&mut ctx,
@@ -3382,7 +3382,7 @@ mod tests {
fn rejected_job_start_fires_no_hooks() {
let marker = "job-hooks-rejected-x7x";
let mut ctx = job_hooked_ctx(marker);
let _guard = crate::hooks::test_sink::install();
let _guard = hooks::test_sink::install();
let result = run_async(handle_start(
&mut ctx,
+11 -17
View File
@@ -1539,12 +1539,6 @@ impl ToolCall {
Ok(result)
}
/// Evaluates the call, firing `tool.*` hooks around it: `tool.started`
/// once the name and arguments are resolved, then exactly one of
/// `tool.completed` / `tool.failed`. A failure before resolution
/// (unknown tool, malformed arguments) fires `tool.failed` alone. Hooks
/// are pure observers — the returned value is exactly what the tool
/// produced.
pub async fn eval(&self, ctx: &mut RequestContext) -> Result<Value> {
let result = self.eval_inner(ctx).await;
if let Err(err) = &result {
@@ -5308,13 +5302,13 @@ mod tests {
}
}
fn tool_hooks_map(marker: &str) -> crate::hooks::HooksMap {
fn tool_hooks_map(marker: &str) -> hooks::HooksMap {
["tool.started", "tool.completed", "tool.failed"]
.into_iter()
.map(|event| {
(
event.to_string(),
vec![crate::hooks::HookDef {
vec![hooks::HookDef {
name: marker.to_string(),
command: "true".to_string(),
}],
@@ -5334,8 +5328,8 @@ mod tests {
RequestContext::new(Arc::new(app), WorkingMode::Cmd)
}
fn tool_captures(marker: &str) -> Vec<crate::hooks::test_sink::Capture> {
crate::hooks::test_sink::snapshot()
fn tool_captures(marker: &str) -> Vec<hooks::test_sink::Capture> {
hooks::test_sink::snapshot()
.into_iter()
.filter(|capture| capture.hook_name == marker)
.collect()
@@ -5351,7 +5345,7 @@ mod tests {
config.auto_continue = true;
});
ctx.tool_scope.functions.append_todo_functions();
let _guard = crate::hooks::test_sink::install();
let _guard = hooks::test_sink::install();
let output =
run_async(call_with_args("todo__init", json!({"goal": "g"})).eval(&mut ctx)).unwrap();
@@ -5389,7 +5383,7 @@ mod tests {
ctx.tool_scope
.functions
.append_mcp_meta_functions(vec![tools_only("fixture")]);
let _guard = crate::hooks::test_sink::install();
let _guard = hooks::test_sink::install();
let output = call_with_args("mcp_invoke_fixture", json!({"tool": "dup"}))
.eval(&mut ctx)
@@ -5424,7 +5418,7 @@ mod tests {
parameters: JsonSchema::default(),
agent: false,
});
let _guard = crate::hooks::test_sink::install();
let _guard = hooks::test_sink::install();
let output = run_async(call_with_args("echo", json!({"x": 1})).eval(&mut ctx)).unwrap();
@@ -5454,7 +5448,7 @@ mod tests {
parameters: JsonSchema::default(),
agent: false,
});
let _guard = crate::hooks::test_sink::install();
let _guard = hooks::test_sink::install();
let output = run_async(call_with_args("false", json!({})).eval(&mut ctx)).unwrap();
@@ -5485,7 +5479,7 @@ mod tests {
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 = crate::hooks::test_sink::install();
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();
@@ -5517,7 +5511,7 @@ mod tests {
let marker = "tool-hooks-user-x7x";
let mut ctx = ctx_with_tool_hooks(marker, |_| {});
ctx.tool_scope.functions.append_user_interaction_functions();
let _guard = crate::hooks::test_sink::install();
let _guard = hooks::test_sink::install();
let output = run_async(call_with_args("user__confirm", json!({})).eval(&mut ctx)).unwrap();
@@ -5554,7 +5548,7 @@ mod tests {
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 = crate::hooks::test_sink::install();
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();
-6
View File
@@ -36,9 +36,6 @@ impl AgentNodeExecutor {
parent_ctx: &mut RequestContext,
retire_peer_on_return: bool,
) -> Result<AgentExecutionOutcome> {
// The teammate identity doubles as the agent's id for observers; the
// run consumes it off the ctx, so it is captured up front. Nodes
// without one are identified by their node id.
let agent_hook_id = parent_ctx
.peer_assignment
.as_ref()
@@ -52,9 +49,6 @@ impl AgentNodeExecutor {
retire_peer_on_return,
)
.await;
// Fired at the point of failure, before a declared fallback converts
// it into a successful route: observers must see the agent's failure
// even when the node itself continues.
if let Err(e) = &result {
hooks::fire(
HookEvent::AgentFailed,
+10 -16
View File
@@ -514,12 +514,6 @@ fn provision_frontier_peers(
(Some(registry), assignments)
}
/// Pre-resolved `graph.node.completed`/`graph.node.failed` snapshots plus
/// the metadata one branch task needs to fire them. Captured per branch
/// before the branch is spawned: every parallel super-step branch owns its
/// emitter, so node metadata never crosses between concurrent tasks and no
/// task borrows the context it was forked from. Base envs are rebuilt at
/// fire time so the timestamp reflects when the node actually finished.
struct NodeHookEmitter {
completed: Vec<ResolvedHook>,
failed: Vec<ResolvedHook>,
@@ -1152,8 +1146,8 @@ mod integration_tests {
which::which(name).is_ok()
}
fn hooks_config(entries: &[(&str, &str)]) -> crate::hooks::HooksMap {
let mut map = crate::hooks::HooksMap::new();
fn hooks_config(entries: &[(&str, &str)]) -> hooks::HooksMap {
let mut map = hooks::HooksMap::new();
for (event, name) in entries {
map.entry((*event).to_string()).or_default().push(HookDef {
name: (*name).to_string(),
@@ -1163,7 +1157,7 @@ mod integration_tests {
map
}
fn ctx_with_global_hooks(hooks: crate::hooks::HooksMap) -> RequestContext {
fn ctx_with_global_hooks(hooks: hooks::HooksMap) -> RequestContext {
let mut app = AppState::test_default();
app.config = Arc::new(AppConfig {
hooks,
@@ -1172,8 +1166,8 @@ mod integration_tests {
RequestContext::new(Arc::new(app), WorkingMode::Cmd)
}
fn hook_captures_named(name: &str) -> Vec<crate::hooks::test_sink::Capture> {
crate::hooks::test_sink::snapshot()
fn hook_captures_named(name: &str) -> Vec<hooks::test_sink::Capture> {
hooks::test_sink::snapshot()
.into_iter()
.filter(|capture| capture.hook_name == name)
.collect()
@@ -1328,7 +1322,7 @@ nodes:
/// node also yields `graph.node.completed`, never `graph.node.failed`.
#[tokio::test]
async fn agent_node_failure_with_fallback_fires_agent_failed_and_node_completed() {
let _sink = crate::hooks::test_sink::install();
let _sink = hooks::test_sink::install();
let mut ctx = ctx_with_global_hooks(hooks_config(&[
("agent.failed", "t037_fb_agent_failed"),
("graph.node.completed", "t037_fb_node_completed"),
@@ -1404,7 +1398,7 @@ nodes:
eprintln!("skipping: bash not available");
return;
}
let _sink = crate::hooks::test_sink::install();
let _sink = hooks::test_sink::install();
let ws = TestWorkspace::new();
for script in [
"dispatcher.sh",
@@ -1512,7 +1506,7 @@ nodes:
/// event stays gated off because the agent whitelisted nothing.
#[tokio::test]
async fn graph_agent_hooks_resolve_from_graph_yaml_top_level() {
let _sink = crate::hooks::test_sink::install();
let _sink = hooks::test_sink::install();
let mut ctx =
ctx_with_global_hooks(hooks_config(&[("graph.node.completed", "t037_gy_global")]));
@@ -1571,7 +1565,7 @@ nodes:
/// failure then propagates out of the executor.
#[tokio::test]
async fn genuine_node_failure_fires_graph_node_failed_with_payload() {
let _sink = crate::hooks::test_sink::install();
let _sink = hooks::test_sink::install();
let mut ctx = ctx_with_global_hooks(hooks_config(&[(
"graph.node.failed",
"t037_gf_node_failed",
@@ -1626,7 +1620,7 @@ nodes:
#[tokio::test]
async fn malformed_graph_yaml_hook_is_skipped_without_breaking_the_node() {
crate::testing::install_warn_collector();
let _sink = crate::hooks::test_sink::install();
let _sink = hooks::test_sink::install();
let mut ctx = make_ctx();
let yaml = r#"
+1 -10
View File
@@ -248,10 +248,6 @@ fn push_defs(event_name: &str, defs: &[HookDef], cwd: &Path, out: &mut Vec<Resol
}
}
/// Fires every hook configured for `event` in `ctx`. Fire-and-forget: each
/// hook runs in its own detached task and nothing about its outcome — exit
/// status, stdout, stderr — is awaited or read. `payload` carries the tool
/// arguments JSON for `tool.*` events; pass `None` otherwise.
#[allow(dead_code)]
pub fn fire(
event: HookEvent,
@@ -263,6 +259,7 @@ pub fn fire(
if resolved.is_empty() {
return;
}
fire_resolved(event, resolved, base_envs(event, ctx), extras, payload);
}
@@ -407,9 +404,6 @@ pub(crate) fn base_envs_parts(
envs
}
/// Creates the payload file fresh (`create_new`) with owner-only permissions,
/// so a pre-existing path — symlink or otherwise — is an error. On failure
/// the hook still runs, just without `COYOTE_HOOK_PAYLOAD_FILE`.
async fn write_payload_file(path: &Path, json: &str) -> std::io::Result<()> {
let mut options = tokio::fs::OpenOptions::new();
options.write(true).create_new(true);
@@ -423,9 +417,6 @@ async fn write_payload_file(path: &Path, json: &str) -> std::io::Result<()> {
#[cfg(test)]
static PAYLOAD_DIR_OVERRIDE: std::sync::Mutex<Option<PathBuf>> = std::sync::Mutex::new(None);
/// Directory payload files are written to: `std::env::temp_dir()`, except in
/// tests, where `PAYLOAD_DIR_OVERRIDE` redirects it without touching
/// process-global state like `TMPDIR`.
fn payload_dir() -> PathBuf {
#[cfg(test)]
if let Some(dir) = PAYLOAD_DIR_OVERRIDE
-8
View File
@@ -706,11 +706,6 @@ async fn shell_execute(
if code == 0 && app.save_shell_history {
let _ = append_to_shell_history(&shell.name, &eval_str, code);
}
// `process::exit` never returns, so the dispatch
// wrapper's bracket close in `run()` is unreachable
// from here: close the top-level agent bracket now.
// The agent run itself succeeded — the exit code
// belongs to the user's shell command, not to us.
ctx.top_level_agent_finished(None);
hooks::drain_pending(EXIT_HOOK_DRAIN_TIMEOUT).await;
process::exit(code);
@@ -760,9 +755,6 @@ async fn shell_execute(
Ok(())
}
/// The LLM portion of one shell-execute turn — everything the turn bracket
/// measures. The interactive follow-up menu (execute/revise/describe/copy)
/// runs after the turn has already completed.
async fn shell_execute_turn(
ctx: &mut RequestContext,
input: &Input,
-5
View File
@@ -1695,11 +1695,6 @@ fn print_turn_divider() {
println!();
}
/// Auto-compression seam for the ask loop: `session.compressed` fires only
/// when compression actually succeeded. A failed auto-compression is
/// survivable mid-session, so the error is logged and swallowed rather
/// than aborting the turn — unlike `.compress session`, which surfaces
/// the failure to the user.
async fn auto_compress_session(ctx: &mut RequestContext) {
match ctx.compress_session().await {
Ok(()) => hooks::fire(HookEvent::SessionCompressed, ctx, &[], None),
-7
View File
@@ -203,13 +203,6 @@ pub fn discover() -> Result<Vec<DiscoveredMixin>> {
for path in collect_mixins(&paths::roles_dir(), &[ScanMode::Flat]) {
out.push(read_mixin(path)?);
}
// Mirrors the role-hook working directory (role hook commands run with
// cwd = roles_dir), so `./hooks/*.sbx-mixin.yaml` beside a role resolves
// the same way the role's hook commands do — intentionally NOT
// paths::hooks_dir(). The agent-hook working directory
// (`<agents>/<agent>/hooks/` — agent hooks run with cwd = the agent's
// data dir) is deliberately not scanned here either; agent-dir scanning
// above covers `<agents>/<agent>/*.sbx-mixin.yaml` only.
for path in collect_mixins(&paths::roles_dir().join("hooks"), &[ScanMode::Flat]) {
out.push(read_mixin(path)?);
}