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))) 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)> { fn llm_request_extras(client: &dyn Client) -> Vec<(&'static str, String)> {
vec![ vec![
("COYOTE_LLM_PROVIDER", client.name().to_string()), ("COYOTE_LLM_PROVIDER", client.name().to_string()),
@@ -567,10 +563,6 @@ fn llm_completed_extras(client: &dyn Client, started_at: Instant) -> Vec<(&'stat
extras 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( fn llm_failed_extras(
client: &dyn Client, client: &dyn Client,
err: Option<&anyhow::Error>, err: Option<&anyhow::Error>,
@@ -592,6 +584,7 @@ fn llm_failed_extras(
err.map(|err| format!("{err:#}")) err.map(|err| format!("{err:#}"))
.unwrap_or_else(|| "Aborted.".to_string()), .unwrap_or_else(|| "Aborted.".to_string()),
)); ));
extras extras
} }
@@ -1136,6 +1129,7 @@ mod tests {
use super::super::access_token::{is_rejected, set_access_token}; use super::super::access_token::{is_rejected, set_access_token};
use crate::config::{AppState, WorkingMode}; use crate::config::{AppState, WorkingMode};
use anyhow::Error;
use std::sync::Arc; use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering}; use std::sync::atomic::{AtomicUsize, Ordering};
use tokio::time::Instant; use tokio::time::Instant;
@@ -1352,8 +1346,8 @@ mod tests {
/// Wrapped in `.context(...)` so every test below proves the downcast /// Wrapped in `.context(...)` so every test below proves the downcast
/// works through an anyhow context chain, as in the trait methods. /// works through an anyhow context chain, as in the trait methods.
fn api_status_error(status: u16) -> anyhow::Error { fn api_status_error(status: u16) -> Error {
anyhow::Error::new(ApiStatusError { Error::new(ApiStatusError {
status, status,
message: format!("error (status: {status})"), message: format!("error (status: {status})"),
}) })
@@ -1709,8 +1703,8 @@ mod tests {
} }
} }
fn error(&self) -> anyhow::Error { fn error(&self) -> Error {
anyhow::Error::new(ApiStatusError { Error::new(ApiStatusError {
status: self.status, status: self.status,
message: format!("error (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.started",
"llm.request.completed", "llm.request.completed",
@@ -1787,8 +1781,8 @@ mod tests {
ctx ctx
} }
fn llm_captures(marker: &str) -> Vec<crate::hooks::test_sink::Capture> { fn llm_captures(marker: &str) -> Vec<hooks::test_sink::Capture> {
crate::hooks::test_sink::snapshot() hooks::test_sink::snapshot()
.into_iter() .into_iter()
.filter(|capture| capture.hook_name == marker) .filter(|capture| capture.hook_name == marker)
.collect() .collect()
@@ -1802,7 +1796,7 @@ mod tests {
let input = Input::from_str(&ctx, "hi", None).unwrap(); let input = Input::from_str(&ctx, "hi", None).unwrap();
let mut client = paced_client(false); let mut client = paced_client(false);
client.nonstream_replies = true; client.nonstream_replies = true;
let _guard = crate::hooks::test_sink::install(); let _guard = hooks::test_sink::install();
call_chat_completions( call_chat_completions(
&input, &input,
@@ -1838,7 +1832,7 @@ mod tests {
let mut ctx = llm_hooked_ctx(marker); let mut ctx = llm_hooked_ctx(marker);
let input = Input::from_str(&ctx, "hi", None).unwrap(); let input = Input::from_str(&ctx, "hi", None).unwrap();
let client = FailingStatusClient::new(429); let client = FailingStatusClient::new(429);
let _guard = crate::hooks::test_sink::install(); let _guard = hooks::test_sink::install();
let err = call_chat_completions( let err = call_chat_completions(
&input, &input,
@@ -1885,7 +1879,7 @@ mod tests {
let mut ctx = llm_hooked_ctx(marker); let mut ctx = llm_hooked_ctx(marker);
let input = Input::from_str(&ctx, "hi", None).unwrap(); let input = Input::from_str(&ctx, "hi", None).unwrap();
let client = paced_client(false); 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()) call_chat_completions_streaming(&input, &client, &mut ctx, create_abort_signal())
.await .await
@@ -1911,7 +1905,7 @@ mod tests {
tokio::time::sleep(PACED_GAP * PACED_DELTAS as u32 + Duration::from_secs(30)).await; tokio::time::sleep(PACED_GAP * PACED_DELTAS as u32 + Duration::from_secs(30)).await;
trigger.set_ctrlc(); 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) let err = call_chat_completions_streaming(&input, &client, &mut ctx, abort)
.await .await
@@ -1936,7 +1930,7 @@ mod tests {
let mut ctx = llm_hooked_ctx(marker); let mut ctx = llm_hooked_ctx(marker);
let input = Input::from_str(&ctx, "hi", None).unwrap(); let input = Input::from_str(&ctx, "hi", None).unwrap();
let client = paced_client(false); 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()) call_chat_completions_streaming_quiet(&input, &client, &mut ctx, create_abort_signal())
.await .await
@@ -1969,7 +1963,7 @@ mod tests {
tokio::time::sleep(PACED_GAP * PACED_DELTAS as u32 + Duration::from_secs(30)).await; tokio::time::sleep(PACED_GAP * PACED_DELTAS as u32 + Duration::from_secs(30)).await;
trigger.set_ctrlc(); 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) let err = call_chat_completions_streaming_quiet(&input, &client, &mut ctx, abort)
.await .await
@@ -1997,7 +1991,7 @@ mod tests {
let mut ctx = llm_hooked_ctx(marker); let mut ctx = llm_hooked_ctx(marker);
let input = Input::from_str(&ctx, "hi", None).unwrap(); let input = Input::from_str(&ctx, "hi", None).unwrap();
let client = FailingStatusClient::new(500); let client = FailingStatusClient::new(500);
let _guard = crate::hooks::test_sink::install(); let _guard = hooks::test_sink::install();
let err = let err =
call_chat_completions_streaming_quiet(&input, &client, &mut ctx, create_abort_signal()) call_chat_completions_streaming_quiet(&input, &client, &mut ctx, create_abort_signal())
+1 -1
View File
@@ -1489,7 +1489,7 @@ nodes:
type: end type: end
output: done 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); let config = AgentConfig::from_graph("g", &graph);
+2 -2
View File
@@ -807,7 +807,7 @@ mod tests {
#[test] #[test]
fn from_config_copies_hooks() { fn from_config_copies_hooks() {
let mut hooks = crate::hooks::HooksMap::new(); let mut hooks = HooksMap::new();
hooks.insert( hooks.insert(
"turn.completed".to_string(), "turn.completed".to_string(),
vec![crate::hooks::HookDef { vec![crate::hooks::HookDef {
@@ -845,7 +845,7 @@ mod tests {
let env_name = get_env_name("hooks"); let env_name = get_env_name("hooks");
let prev = env::var_os(&env_name); let prev = env::var_os(&env_name);
let mut hooks = crate::hooks::HooksMap::new(); let mut hooks = HooksMap::new();
hooks.insert( hooks.insert(
"turn.completed".to_string(), "turn.completed".to_string(),
vec![crate::hooks::HookDef { vec![crate::hooks::HookDef {
+4 -11
View File
@@ -15,16 +15,8 @@ use std::collections::BTreeSet;
use std::fs; use std::fs;
use std::path::Path; 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"; 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 { pub(crate) fn is_builtin_manifest_name(name: &str) -> bool {
name.trim_end_matches(['.', ' ']) name.trim_end_matches(['.', ' '])
.to_uppercase() .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 /// absent from `shipped`. The manifest is then rewritten to the names this
/// installer actually owns: files written this run (`written`), plus shipped /// installer actually owns: files written this run (`written`), plus shipped
/// names it already owned, plus stale files whose removal failed (kept so a /// 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 /// 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. /// pre-existing user file with a colliding name) is never claimed.
/// ///
/// Failure modes degrade toward keeping files: a missing or unreadable /// Failure modes degrade toward keeping files: a missing or unreadable
/// manifest yields no deletions, and entries that are not plain filenames /// 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 { mod tests {
use super::*; use super::*;
use crate::utils; 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, ""); let dir = utils::temp_file(label, "");
fs::create_dir_all(&dir).unwrap(); fs::create_dir_all(&dir).unwrap();
dir dir
+6 -6
View File
@@ -2418,6 +2418,7 @@ fn print_secret_summary(added: &[String], deferred: &[String]) {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use crate::config;
use crate::config::builtin_manifest::BUILTIN_MANIFEST_FILE; use crate::config::builtin_manifest::BUILTIN_MANIFEST_FILE;
use crate::sandbox::SANDBOX_ENV_FLAG; use crate::sandbox::SANDBOX_ENV_FLAG;
use crate::utils::get_env_name; use crate::utils::get_env_name;
@@ -2710,8 +2711,6 @@ mod tests {
assert!(out.functions_tools.is_none() && out.mcp_json.is_none()); 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] #[test]
fn apply_filter_hooks_keeps_only_hooks() { fn apply_filter_hooks_keeps_only_hooks() {
let l = RemoteLayout { let l = RemoteLayout {
@@ -3624,8 +3623,9 @@ mod tests {
fn assert_executable(path: &Path) { fn assert_executable(path: &Path) {
use std::os::unix::fs::PermissionsExt; use std::os::unix::fs::PermissionsExt;
let mode = fs::metadata(path).unwrap().permissions().mode(); let mode = fs::metadata(path).unwrap().permissions().mode();
assert!( assert_ne!(
mode & 0o111 != 0, mode & 0o111,
0,
"{} must carry the executable bit", "{} must carry the executable bit",
path.display() path.display()
); );
@@ -3932,7 +3932,7 @@ mod tests {
} }
let _guard = TestVaultConfigGuard::new("prov-builtin"); 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!(fs::read_dir(paths::macros_dir()).unwrap().next().is_some());
assert!(!paths::installed_bundles_file().exists()); assert!(!paths::installed_bundles_file().exists());
@@ -3957,7 +3957,7 @@ mod tests {
// Capture outcomes before restoring the env var so a failed assertion // Capture outcomes before restoring the env var so a failed assertion
// cannot leave the hooks-dir override pointing at a deleted temp dir. // 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 = ( let installed = (
hooks_dir.join("notify.sh").is_file(), hooks_dir.join("notify.sh").is_file(),
hooks_dir.join("log-events.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; pub use macros::macro_execute;
use crate::config::macros::Macro; use crate::config::macros::Macro;
use crate::hooks;
use crate::vault::{ use crate::vault::{
GlobalVault, Vault, create_vault_password_file, interpolate_secrets, prompt_provider_choice, 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)?; Agent::install_builtin_agents(false)?;
Macro::install_macros(false)?; Macro::install_macros(false)?;
Skill::install_builtin_skills(false)?; Skill::install_builtin_skills(false)?;
crate::hooks::install_builtin_hooks(false)?; hooks::install_builtin_hooks(false)?;
Role::install_builtin_role_hooks(false)?; Role::install_builtin_role_hooks(false)?;
Ok(()) Ok(())
} }
@@ -522,7 +523,7 @@ pub fn install_assets(category: AssetCategory) -> Result<()> {
AssetCategory::Macros => Macro::install_macros(true)?, AssetCategory::Macros => Macro::install_macros(true)?,
AssetCategory::Functions => Functions::install_builtin_global_tools(true)?, AssetCategory::Functions => Functions::install_builtin_global_tools(true)?,
AssetCategory::Skills => Skill::install_builtin_skills(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()?, AssetCategory::McpConfig => Functions::install_mcp_config()?,
} }
+3 -15
View File
@@ -4733,8 +4733,6 @@ impl RequestContext {
} }
self.refresh_mcp_tool_filters(); self.refresh_mcp_tool_filters();
self.init_agent_session_variables(new_session)?; 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 { if created_new_session {
hooks::fire(HookEvent::SessionStarted, self, &[], None); hooks::fire(HookEvent::SessionStarted, self, &[], None);
} }
@@ -4933,24 +4931,18 @@ impl RequestContext {
Ok(()) 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) { pub fn top_level_agent_started(&mut self) {
if self.top_level_agent_id.is_some() { if self.top_level_agent_id.is_some() {
return; return;
} }
let Some(agent) = self.agent.as_ref() else { let Some(agent) = self.agent.as_ref() else {
return; return;
}; };
let nonce = Alphanumeric.sample_string(&mut rand::rng(), 8); let nonce = Alphanumeric.sample_string(&mut rand::rng(), 8);
let id = format!("top-{}-{nonce}", agent.name()); let id = format!("top-{}-{nonce}", agent.name());
self.top_level_agent_id = Some(id.clone()); self.top_level_agent_id = Some(id.clone());
hooks::fire( hooks::fire(
HookEvent::AgentStarted, HookEvent::AgentStarted,
self, 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>) { pub fn top_level_agent_finished(&mut self, error: Option<&Error>) {
let Some(id) = self.top_level_agent_id.take() else { let Some(id) = self.top_level_agent_id.take() else {
return; return;
}; };
match error { match error {
None => hooks::fire( None => hooks::fire(
HookEvent::AgentCompleted, HookEvent::AgentCompleted,
+22 -35
View File
@@ -1039,9 +1039,9 @@ Input 1
.collect() .collect()
} }
fn hooks_fixture_dir(label: &str) -> std::path::PathBuf { fn hooks_fixture_dir(label: &str) -> PathBuf {
let dir = crate::utils::temp_file(label, ""); let dir = temp_file(label, "");
std::fs::create_dir_all(&dir).unwrap(); create_dir_all(&dir).unwrap();
dir.join("hooks") dir.join("hooks")
} }
@@ -1052,55 +1052,42 @@ Input 1
install_and_reconcile_role_hooks(&dir, &fixture(&[("a.sh", "#!/bin/sh\n")]), false) install_and_reconcile_role_hooks(&dir, &fixture(&[("a.sh", "#!/bin/sh\n")]), false)
.unwrap(); .unwrap();
assert_eq!(read_to_string(dir.join("a.sh")).unwrap(), "#!/bin/sh\n");
assert_eq!( assert_eq!(
std::fs::read_to_string(dir.join("a.sh")).unwrap(), read_to_string(dir.join(builtin_manifest::BUILTIN_MANIFEST_FILE)).unwrap(),
"#!/bin/sh\n"
);
assert_eq!(
std::fs::read_to_string(dir.join(builtin_manifest::BUILTIN_MANIFEST_FILE)).unwrap(),
"a.sh\n" "a.sh\n"
); );
#[cfg(unix)] #[cfg(unix)]
{ {
use std::os::unix::fs::PermissionsExt; 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() .unwrap()
.permissions() .permissions()
.mode(); .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!( assert_eq!(
manifest_mode & 0o111, manifest_mode & 0o111,
0, 0,
"the manifest must never be executable" "the manifest must never be executable"
); );
} }
let _ = std::fs::remove_dir_all(dir.parent().unwrap()); let _ = fs::remove_dir_all(dir.parent().unwrap());
} }
#[test] #[test]
fn role_hooks_install_overwrites_only_with_force() { fn role_hooks_install_overwrites_only_with_force() {
let dir = hooks_fixture_dir("role-hooks-force-"); let dir = hooks_fixture_dir("role-hooks-force-");
let shipped = fixture(&[("a.sh", "new content\n")]); let shipped = fixture(&[("a.sh", "new content\n")]);
std::fs::create_dir_all(&dir).unwrap(); create_dir_all(&dir).unwrap();
std::fs::write(dir.join("a.sh"), "SENTINEL").unwrap(); fs::write(dir.join("a.sh"), "SENTINEL").unwrap();
install_and_reconcile_role_hooks(&dir, &shipped, false).unwrap(); install_and_reconcile_role_hooks(&dir, &shipped, false).unwrap();
assert_eq!( assert_eq!(read_to_string(dir.join("a.sh")).unwrap(), "SENTINEL");
std::fs::read_to_string(dir.join("a.sh")).unwrap(),
"SENTINEL"
);
install_and_reconcile_role_hooks(&dir, &shipped, true).unwrap(); install_and_reconcile_role_hooks(&dir, &shipped, true).unwrap();
assert_eq!( assert_eq!(read_to_string(dir.join("a.sh")).unwrap(), "new content\n");
std::fs::read_to_string(dir.join("a.sh")).unwrap(), let _ = fs::remove_dir_all(dir.parent().unwrap());
"new content\n"
);
let _ = std::fs::remove_dir_all(dir.parent().unwrap());
} }
#[test] #[test]
@@ -1113,7 +1100,7 @@ Input 1
false, false,
) )
.unwrap(); .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) install_and_reconcile_role_hooks(&dir, &fixture(&[("keep.sh", "#!/bin/sh\n")]), false)
.unwrap(); .unwrap();
@@ -1125,17 +1112,17 @@ Input 1
assert!(dir.join("keep.sh").exists()); assert!(dir.join("keep.sh").exists());
assert!(dir.join("user.sh").exists(), "user file survives reconcile"); assert!(dir.join("user.sh").exists(), "user file survives reconcile");
assert_eq!( 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" "keep.sh\n"
); );
let _ = std::fs::remove_dir_all(dir.parent().unwrap()); let _ = fs::remove_dir_all(dir.parent().unwrap());
} }
#[test] #[test]
fn role_hooks_never_claim_a_preexisting_user_file() { fn role_hooks_never_claim_a_preexisting_user_file() {
let dir = hooks_fixture_dir("role-hooks-unclaimed-"); let dir = hooks_fixture_dir("role-hooks-unclaimed-");
std::fs::create_dir_all(&dir).unwrap(); create_dir_all(&dir).unwrap();
std::fs::write(dir.join("a.sh"), "user-owned").unwrap(); fs::write(dir.join("a.sh"), "user-owned").unwrap();
install_and_reconcile_role_hooks(&dir, &fixture(&[("a.sh", "#!/bin/sh\n")]), false) install_and_reconcile_role_hooks(&dir, &fixture(&[("a.sh", "#!/bin/sh\n")]), false)
.unwrap(); .unwrap();
@@ -1146,11 +1133,11 @@ Input 1
install_and_reconcile_role_hooks(&dir, &[], false).unwrap(); install_and_reconcile_role_hooks(&dir, &[], false).unwrap();
assert_eq!( assert_eq!(
std::fs::read_to_string(dir.join("a.sh")).unwrap(), read_to_string(dir.join("a.sh")).unwrap(),
"user-owned", "user-owned",
"the user file survives the hook being dropped from the embed" "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] #[test]
@@ -1160,6 +1147,6 @@ Input 1
install_and_reconcile_role_hooks(&dir, &[], false).unwrap(); install_and_reconcile_role_hooks(&dir, &[], false).unwrap();
assert!(!dir.exists(), "an empty shipped set must not create hooks/"); 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() { fn session_deserialization_ignores_injected_role_hooks() {
// A session file is attacker-writable data: a `role_hooks:` key // A session file is attacker-writable data: a `role_hooks:` key
// crafted into it must never deserialize into executable hook // 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 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(); let session: Session = serde_yaml::from_str(yaml).unwrap();
assert!( 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)) || 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 { struct JobHookSnapshot {
completed: Vec<ResolvedHook>, completed: Vec<ResolvedHook>,
failed: Vec<ResolvedHook>, failed: Vec<ResolvedHook>,
@@ -92,9 +88,11 @@ impl JobHookSnapshot {
} else { } else {
(HookEvent::JobFailed, self.failed) (HookEvent::JobFailed, self.failed)
}; };
if resolved.is_empty() { if resolved.is_empty() {
return; return;
} }
let base_envs = hooks::base_envs_parts( let base_envs = hooks::base_envs_parts(
event, event,
self.session_name.as_deref(), self.session_name.as_deref(),
@@ -104,9 +102,11 @@ impl JobHookSnapshot {
("COYOTE_JOB_ID", self.job_id), ("COYOTE_JOB_ID", self.job_id),
("COYOTE_TOOL_NAME", self.tool), ("COYOTE_TOOL_NAME", self.tool),
]; ];
if let Some(error) = error { if let Some(error) = error {
extras.push(("COYOTE_JOB_ERROR", error)); extras.push(("COYOTE_JOB_ERROR", error));
} }
hooks::fire_resolved(event, resolved, base_envs, &extras, None); 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"); 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"] ["job.started", "job.completed", "job.failed"]
.into_iter() .into_iter()
.map(|event| { .map(|event| {
( (
event.to_string(), event.to_string(),
vec![crate::hooks::HookDef { vec![hooks::HookDef {
name: marker.to_string(), name: marker.to_string(),
command: "true".to_string(), command: "true".to_string(),
}], }],
@@ -3230,8 +3230,8 @@ mod tests {
RequestContext::new(app, WorkingMode::Cmd) RequestContext::new(app, WorkingMode::Cmd)
} }
fn job_captures(marker: &str) -> Vec<crate::hooks::test_sink::Capture> { fn job_captures(marker: &str) -> Vec<hooks::test_sink::Capture> {
crate::hooks::test_sink::snapshot() hooks::test_sink::snapshot()
.into_iter() .into_iter()
.filter(|capture| capture.hook_name == marker) .filter(|capture| capture.hook_name == marker)
.collect() .collect()
@@ -3245,7 +3245,7 @@ mod tests {
let marker = "job-hooks-ok-x7x"; let marker = "job-hooks-ok-x7x";
let mut ctx = job_hooked_ctx(marker); let mut ctx = job_hooked_ctx(marker);
ctx.declared_function_names.insert("echo".into()); 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": {}})) let started = handle_start(&mut ctx, &json!({"tool": "echo", "arguments": {}}))
.await .await
@@ -3300,7 +3300,7 @@ mod tests {
let marker = "job-hooks-fail-x7x"; let marker = "job-hooks-fail-x7x";
let mut ctx = job_hooked_ctx(marker); let mut ctx = job_hooked_ctx(marker);
ctx.declared_function_names.insert("false".into()); 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": {}})) let started = handle_start(&mut ctx, &json!({"tool": "false", "arguments": {}}))
.await .await
@@ -3344,7 +3344,7 @@ mod tests {
filter.push_layer(LayerSource::Global, &[]); filter.push_layer(LayerSource::Global, &[]);
runtime.tool_filters.insert("fixture".to_string(), filter); runtime.tool_filters.insert("fixture".to_string(), filter);
ctx.tool_scope.mcp_runtime = runtime; ctx.tool_scope.mcp_runtime = runtime;
let _guard = crate::hooks::test_sink::install(); let _guard = hooks::test_sink::install();
let started = handle_start( let started = handle_start(
&mut ctx, &mut ctx,
@@ -3382,7 +3382,7 @@ mod tests {
fn rejected_job_start_fires_no_hooks() { fn rejected_job_start_fires_no_hooks() {
let marker = "job-hooks-rejected-x7x"; let marker = "job-hooks-rejected-x7x";
let mut ctx = job_hooked_ctx(marker); 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( let result = run_async(handle_start(
&mut ctx, &mut ctx,
+11 -17
View File
@@ -1539,12 +1539,6 @@ impl ToolCall {
Ok(result) 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> { pub async fn eval(&self, ctx: &mut RequestContext) -> Result<Value> {
let result = self.eval_inner(ctx).await; let result = self.eval_inner(ctx).await;
if let Err(err) = &result { 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"] ["tool.started", "tool.completed", "tool.failed"]
.into_iter() .into_iter()
.map(|event| { .map(|event| {
( (
event.to_string(), event.to_string(),
vec![crate::hooks::HookDef { vec![hooks::HookDef {
name: marker.to_string(), name: marker.to_string(),
command: "true".to_string(), command: "true".to_string(),
}], }],
@@ -5334,8 +5328,8 @@ mod tests {
RequestContext::new(Arc::new(app), WorkingMode::Cmd) RequestContext::new(Arc::new(app), WorkingMode::Cmd)
} }
fn tool_captures(marker: &str) -> Vec<crate::hooks::test_sink::Capture> { fn tool_captures(marker: &str) -> Vec<hooks::test_sink::Capture> {
crate::hooks::test_sink::snapshot() hooks::test_sink::snapshot()
.into_iter() .into_iter()
.filter(|capture| capture.hook_name == marker) .filter(|capture| capture.hook_name == marker)
.collect() .collect()
@@ -5351,7 +5345,7 @@ mod tests {
config.auto_continue = true; config.auto_continue = true;
}); });
ctx.tool_scope.functions.append_todo_functions(); ctx.tool_scope.functions.append_todo_functions();
let _guard = crate::hooks::test_sink::install(); let _guard = hooks::test_sink::install();
let output = let output =
run_async(call_with_args("todo__init", json!({"goal": "g"})).eval(&mut ctx)).unwrap(); run_async(call_with_args("todo__init", json!({"goal": "g"})).eval(&mut ctx)).unwrap();
@@ -5389,7 +5383,7 @@ mod tests {
ctx.tool_scope ctx.tool_scope
.functions .functions
.append_mcp_meta_functions(vec![tools_only("fixture")]); .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"})) let output = call_with_args("mcp_invoke_fixture", json!({"tool": "dup"}))
.eval(&mut ctx) .eval(&mut ctx)
@@ -5424,7 +5418,7 @@ mod tests {
parameters: JsonSchema::default(), parameters: JsonSchema::default(),
agent: false, 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(); let output = run_async(call_with_args("echo", json!({"x": 1})).eval(&mut ctx)).unwrap();
@@ -5454,7 +5448,7 @@ mod tests {
parameters: JsonSchema::default(), parameters: JsonSchema::default(),
agent: false, 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(); 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); let mut plain = RequestContext::new(Arc::new(AppState::test_default()), WorkingMode::Cmd);
plain.tool_scope.functions.append_todo_functions(); plain.tool_scope.functions.append_todo_functions();
let call = call_with_args("todo__done", json!({})); 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 hooked_output = run_async(call.eval(&mut hooked)).unwrap();
let plain_output = run_async(call.eval(&mut plain)).unwrap(); let plain_output = run_async(call.eval(&mut plain)).unwrap();
@@ -5517,7 +5511,7 @@ mod tests {
let marker = "tool-hooks-user-x7x"; let marker = "tool-hooks-user-x7x";
let mut ctx = ctx_with_tool_hooks(marker, |_| {}); let mut ctx = ctx_with_tool_hooks(marker, |_| {});
ctx.tool_scope.functions.append_user_interaction_functions(); 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(); 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 hooked = ctx_with_tool_hooks(marker, |_| {});
let mut plain = RequestContext::new(Arc::new(AppState::test_default()), WorkingMode::Cmd); let mut plain = RequestContext::new(Arc::new(AppState::test_default()), WorkingMode::Cmd);
let call = call_with_args("nonexistent_tool", json!({})); 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 hooked_err = run_async(call.eval(&mut hooked)).unwrap_err();
let plain_err = run_async(call.eval(&mut plain)).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, parent_ctx: &mut RequestContext,
retire_peer_on_return: bool, retire_peer_on_return: bool,
) -> Result<AgentExecutionOutcome> { ) -> 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 let agent_hook_id = parent_ctx
.peer_assignment .peer_assignment
.as_ref() .as_ref()
@@ -52,9 +49,6 @@ impl AgentNodeExecutor {
retire_peer_on_return, retire_peer_on_return,
) )
.await; .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 { if let Err(e) = &result {
hooks::fire( hooks::fire(
HookEvent::AgentFailed, HookEvent::AgentFailed,
+10 -16
View File
@@ -514,12 +514,6 @@ fn provision_frontier_peers(
(Some(registry), assignments) (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 { struct NodeHookEmitter {
completed: Vec<ResolvedHook>, completed: Vec<ResolvedHook>,
failed: Vec<ResolvedHook>, failed: Vec<ResolvedHook>,
@@ -1152,8 +1146,8 @@ mod integration_tests {
which::which(name).is_ok() which::which(name).is_ok()
} }
fn hooks_config(entries: &[(&str, &str)]) -> crate::hooks::HooksMap { fn hooks_config(entries: &[(&str, &str)]) -> hooks::HooksMap {
let mut map = crate::hooks::HooksMap::new(); let mut map = hooks::HooksMap::new();
for (event, name) in entries { for (event, name) in entries {
map.entry((*event).to_string()).or_default().push(HookDef { map.entry((*event).to_string()).or_default().push(HookDef {
name: (*name).to_string(), name: (*name).to_string(),
@@ -1163,7 +1157,7 @@ mod integration_tests {
map 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(); let mut app = AppState::test_default();
app.config = Arc::new(AppConfig { app.config = Arc::new(AppConfig {
hooks, hooks,
@@ -1172,8 +1166,8 @@ mod integration_tests {
RequestContext::new(Arc::new(app), WorkingMode::Cmd) RequestContext::new(Arc::new(app), WorkingMode::Cmd)
} }
fn hook_captures_named(name: &str) -> Vec<crate::hooks::test_sink::Capture> { fn hook_captures_named(name: &str) -> Vec<hooks::test_sink::Capture> {
crate::hooks::test_sink::snapshot() hooks::test_sink::snapshot()
.into_iter() .into_iter()
.filter(|capture| capture.hook_name == name) .filter(|capture| capture.hook_name == name)
.collect() .collect()
@@ -1328,7 +1322,7 @@ nodes:
/// node also yields `graph.node.completed`, never `graph.node.failed`. /// node also yields `graph.node.completed`, never `graph.node.failed`.
#[tokio::test] #[tokio::test]
async fn agent_node_failure_with_fallback_fires_agent_failed_and_node_completed() { 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(&[ let mut ctx = ctx_with_global_hooks(hooks_config(&[
("agent.failed", "t037_fb_agent_failed"), ("agent.failed", "t037_fb_agent_failed"),
("graph.node.completed", "t037_fb_node_completed"), ("graph.node.completed", "t037_fb_node_completed"),
@@ -1404,7 +1398,7 @@ nodes:
eprintln!("skipping: bash not available"); eprintln!("skipping: bash not available");
return; return;
} }
let _sink = crate::hooks::test_sink::install(); let _sink = hooks::test_sink::install();
let ws = TestWorkspace::new(); let ws = TestWorkspace::new();
for script in [ for script in [
"dispatcher.sh", "dispatcher.sh",
@@ -1512,7 +1506,7 @@ nodes:
/// event stays gated off because the agent whitelisted nothing. /// event stays gated off because the agent whitelisted nothing.
#[tokio::test] #[tokio::test]
async fn graph_agent_hooks_resolve_from_graph_yaml_top_level() { 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 = let mut ctx =
ctx_with_global_hooks(hooks_config(&[("graph.node.completed", "t037_gy_global")])); ctx_with_global_hooks(hooks_config(&[("graph.node.completed", "t037_gy_global")]));
@@ -1571,7 +1565,7 @@ nodes:
/// failure then propagates out of the executor. /// failure then propagates out of the executor.
#[tokio::test] #[tokio::test]
async fn genuine_node_failure_fires_graph_node_failed_with_payload() { 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(&[( let mut ctx = ctx_with_global_hooks(hooks_config(&[(
"graph.node.failed", "graph.node.failed",
"t037_gf_node_failed", "t037_gf_node_failed",
@@ -1626,7 +1620,7 @@ nodes:
#[tokio::test] #[tokio::test]
async fn malformed_graph_yaml_hook_is_skipped_without_breaking_the_node() { async fn malformed_graph_yaml_hook_is_skipped_without_breaking_the_node() {
crate::testing::install_warn_collector(); crate::testing::install_warn_collector();
let _sink = crate::hooks::test_sink::install(); let _sink = hooks::test_sink::install();
let mut ctx = make_ctx(); let mut ctx = make_ctx();
let yaml = r#" 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)] #[allow(dead_code)]
pub fn fire( pub fn fire(
event: HookEvent, event: HookEvent,
@@ -263,6 +259,7 @@ pub fn fire(
if resolved.is_empty() { if resolved.is_empty() {
return; return;
} }
fire_resolved(event, resolved, base_envs(event, ctx), extras, payload); fire_resolved(event, resolved, base_envs(event, ctx), extras, payload);
} }
@@ -407,9 +404,6 @@ pub(crate) fn base_envs_parts(
envs 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<()> { async fn write_payload_file(path: &Path, json: &str) -> std::io::Result<()> {
let mut options = tokio::fs::OpenOptions::new(); let mut options = tokio::fs::OpenOptions::new();
options.write(true).create_new(true); options.write(true).create_new(true);
@@ -423,9 +417,6 @@ async fn write_payload_file(path: &Path, json: &str) -> std::io::Result<()> {
#[cfg(test)] #[cfg(test)]
static PAYLOAD_DIR_OVERRIDE: std::sync::Mutex<Option<PathBuf>> = std::sync::Mutex::new(None); 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 { fn payload_dir() -> PathBuf {
#[cfg(test)] #[cfg(test)]
if let Some(dir) = PAYLOAD_DIR_OVERRIDE 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 { if code == 0 && app.save_shell_history {
let _ = append_to_shell_history(&shell.name, &eval_str, code); 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); ctx.top_level_agent_finished(None);
hooks::drain_pending(EXIT_HOOK_DRAIN_TIMEOUT).await; hooks::drain_pending(EXIT_HOOK_DRAIN_TIMEOUT).await;
process::exit(code); process::exit(code);
@@ -760,9 +755,6 @@ async fn shell_execute(
Ok(()) 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( async fn shell_execute_turn(
ctx: &mut RequestContext, ctx: &mut RequestContext,
input: &Input, input: &Input,
-5
View File
@@ -1695,11 +1695,6 @@ fn print_turn_divider() {
println!(); 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) { async fn auto_compress_session(ctx: &mut RequestContext) {
match ctx.compress_session().await { match ctx.compress_session().await {
Ok(()) => hooks::fire(HookEvent::SessionCompressed, ctx, &[], None), 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]) { for path in collect_mixins(&paths::roles_dir(), &[ScanMode::Flat]) {
out.push(read_mixin(path)?); 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]) { for path in collect_mixins(&paths::roles_dir().join("hooks"), &[ScanMode::Flat]) {
out.push(read_mixin(path)?); out.push(read_mixin(path)?);
} }