feat(TASK-061): add rag.sync.* and mcp.server.* hook events plus COYOTE_ROLE

- Register five new events (26 -> 31): rag.sync.started/completed/failed
  and mcp.server.connected/failed, whitelist-gated for agent global hooks
  like every non-escalation event
- Bracket every real RAG document sync with rag.sync.* via pre-resolved
  RagSyncHooks snapshots carried into the build funnels; cache hits, pure
  loads, and attaches fire nothing, and a user abort mid-sync counts as a
  failure
- Fire mcp.server.connected only on real spawns in McpFactory::acquire
  (reusing a live server is silent), marking respawns of a previously
  live key with COYOTE_MCP_RECONNECT; mcp.server.failed carries
  COYOTE_ERROR and COYOTE_MCP_AUTH_REQUIRED on auth failures. Boot-time
  registry connects deliberately fire nothing
- Carry COYOTE_ROLE on session.* and turn.* events, resolved at fire
  time so mid-session role switches and temp roles are reflected
This commit is contained in:
Dark-Alex-17 committed 2026-09-17 13:16:05 -06:00
1 parent fcdcb303a3
commit 04b983459b
9 files changed
+986 -37

No files matched your search

+128 -9
View File
@@ -68,7 +68,7 @@ pub(crate) struct TurnBracket(());
impl TurnBracket {
#[must_use]
pub(crate) fn enter(ctx: &RequestContext) -> Self {
hooks::fire(HookEvent::TurnStarted, ctx, &[], None);
hooks::fire(HookEvent::TurnStarted, ctx, &hooks::role_extras(ctx), None);
Self(())
}
@@ -79,16 +79,25 @@ impl TurnBracket {
result: Result<T>,
) -> Result<T> {
if abort_signal.aborted_ctrlc() {
hooks::fire(HookEvent::TurnInterrupted, ctx, &[], None);
hooks::fire(
HookEvent::TurnInterrupted,
ctx,
&hooks::role_extras(ctx),
None,
);
} else {
match &result {
Ok(_) => hooks::fire(HookEvent::TurnCompleted, ctx, &[], None),
Err(err) => hooks::fire(
HookEvent::TurnFailed,
Ok(_) => hooks::fire(
HookEvent::TurnCompleted,
ctx,
&[("COYOTE_ERROR", format!("{err:#}"))],
&hooks::role_extras(ctx),
None,
),
Err(err) => {
let mut extras = hooks::role_extras(ctx);
extras.push(("COYOTE_ERROR", format!("{err:#}")));
hooks::fire(HookEvent::TurnFailed, ctx, &extras, None);
}
}
}
result
@@ -1143,7 +1152,12 @@ pub async fn run_repl_command(
abort_signal.clone(),
)
.await?;
hooks::fire(HookEvent::SessionCompressed, ctx, &[], None);
hooks::fire(
HookEvent::SessionCompressed,
ctx,
&hooks::role_extras(ctx),
None,
);
println!("✓ Successfully compressed the session.");
}
_ => {
@@ -1697,7 +1711,12 @@ fn print_turn_divider() {
async fn auto_compress_session(ctx: &mut RequestContext) {
match ctx.compress_session().await {
Ok(()) => hooks::fire(HookEvent::SessionCompressed, ctx, &[], None),
Ok(()) => hooks::fire(
HookEvent::SessionCompressed,
ctx,
&hooks::role_extras(ctx),
None,
),
Err(err) => warn!("Failed to compress the session: {err}"),
}
}
@@ -2018,7 +2037,7 @@ pub fn split_args_text(line: &str, is_win: bool) -> (Vec<String>, &str) {
mod tests {
use super::*;
use crate::client::{ClientConfig, Model};
use crate::config::{AppState, RoleLike, Session, WorkingMode};
use crate::config::{AppState, Role, RoleLike, Session, TEMP_ROLE_NAME, WorkingMode};
use crate::hooks::{HookDef, HooksMap, test_sink};
use anyhow::anyhow;
use serial_test::serial;
@@ -2165,6 +2184,64 @@ mod tests {
assert_eq!(turn_counts(marker), (1, 0, 1, 0));
}
#[tokio::test]
#[serial]
async fn turn_events_carry_the_role_a_session_holds() {
let _sink = test_sink::install();
let marker = "tb-role-m3x";
let mut ctx = ctx_with_hooks(&TURN_EVENTS, marker);
// The role lives on the session (as after `.role` inside a session);
// fire-time resolution must find it there.
let mut session = Session::default();
session.set_role(Role::new("dev-role", "Prompt"));
ctx.session = Some(session);
let abort_signal = create_abort_signal();
let bracket = TurnBracket::enter(&ctx);
let result: Result<()> = Ok(());
bracket.finish(&ctx, &abort_signal, result).unwrap();
let captures: Vec<_> = test_sink::snapshot()
.into_iter()
.filter(|capture| capture.hook_name.starts_with(marker))
.collect();
assert_eq!(captures.len(), 2, "{captures:?}");
for capture in &captures {
assert_eq!(
capture.envs.get("COYOTE_ROLE").map(String::as_str),
Some("dev-role"),
"{} must carry the session-held role",
capture.hook_name
);
}
}
#[tokio::test]
#[serial]
async fn turn_events_omit_the_role_var_when_no_role_is_active() {
let _sink = test_sink::install();
let marker = "tb-norole-q8j";
let ctx = ctx_with_hooks(&TURN_EVENTS, marker);
let abort_signal = create_abort_signal();
let bracket = TurnBracket::enter(&ctx);
let result: Result<()> = Ok(());
bracket.finish(&ctx, &abort_signal, result).unwrap();
let captures: Vec<_> = test_sink::snapshot()
.into_iter()
.filter(|capture| capture.hook_name.starts_with(marker))
.collect();
assert_eq!(captures.len(), 2, "{captures:?}");
for capture in &captures {
assert!(
!capture.envs.contains_key("COYOTE_ROLE"),
"{} must omit COYOTE_ROLE without an active role",
capture.hook_name
);
}
}
/// Drives a deep REPL future to completion on a thread with extra stack
/// headroom: nested `run_repl_command`/`compress_session` poll frames
/// are deep in debug builds and overflow the default test-thread stack
@@ -2312,6 +2389,48 @@ mod tests {
);
}
#[test]
#[serial]
fn session_compressed_carries_the_active_role_name() {
let _sink = test_sink::install();
let marker = "compress-role-t6b";
run_async(async {
let mut ctx = compressible_ctx(marker);
// A temp role (`--prompt`) is a real role for COYOTE_ROLE.
ctx.role = Some(Role::new(TEMP_ROLE_NAME, "Session role prompt"));
auto_compress_session(&mut ctx).await;
});
let capture = test_sink::snapshot()
.into_iter()
.find(|capture| capture.hook_name == format!("{marker}-session.compressed"))
.expect("session.compressed capture");
assert_eq!(
capture.envs.get("COYOTE_ROLE").map(String::as_str),
Some(TEMP_ROLE_NAME)
);
}
#[test]
#[serial]
fn session_compressed_omits_the_role_var_when_no_role_is_active() {
let _sink = test_sink::install();
let marker = "compress-norole-p9c";
run_async(async {
let mut ctx = compressible_ctx(marker);
auto_compress_session(&mut ctx).await;
});
let capture = test_sink::snapshot()
.into_iter()
.find(|capture| capture.hook_name == format!("{marker}-session.compressed"))
.expect("session.compressed capture");
assert!(
!capture.envs.contains_key("COYOTE_ROLE"),
"session.compressed must omit COYOTE_ROLE without an active role"
);
}
#[test]
#[serial]
fn auto_compress_failure_fires_no_session_compressed_and_swallows_error() {