feat(TASK-049): add escalation.raised/answered, session.resumed, and agent.interrupted hook events

- escalation.raised fires in the child ctx after queue submit; escalation.answered
  fires in the parent ctx from the taken request, so both agree on origin envs
- escalation.* resolves global hooks ungated: the per-agent global_hooks whitelist
  never applies to these root-level events
- session.resumed fires on the use_session resume leg, inverse of session.started
- agent.interrupted replaces agent.completed/failed when the abort signal was
  ctrl-c'd: top-level bracket (now abort-aware), spawned-subagent result arm
  (agent__cancel / REPL ctrl-c), and graph agent nodes via the session signal;
  exit_agent and the shell-exit path stay non-interrupted by passing no signal
- document the deliberate non-fire paths (direct prompts, escalation timeout and
  parent-drop, graph approval nodes)
This commit is contained in:
Dark-Alex-17 committed 2026-09-16 19:30:59 -06:00
1 parent 1744d2ba52
commit 739c1be8bb
8 files changed
+468 -41

No files matched your search

+95 -15
View File
@@ -4741,6 +4741,8 @@ impl RequestContext {
self.init_agent_session_variables(new_session)?;
if created_new_session {
hooks::fire(HookEvent::SessionStarted, self, &[], None);
} else {
hooks::fire(HookEvent::SessionResumed, self, &[], None);
}
Ok(())
}
@@ -4909,7 +4911,10 @@ impl RequestContext {
// Interactive switch-away closes the top-level agent bracket while
// the agent is still attached, so agent-scoped hooks resolve.
self.top_level_agent_finished(None);
// No abort signal on purpose: switching away is a deliberate exit,
// never an interruption, even though cancel_recursive tears the
// agent's children down just below.
self.top_level_agent_finished(None, None);
if self.agent.take().is_some() {
if let Some(supervisor) = self.supervisor.clone() {
supervisor.read().cancel_recursive();
@@ -4957,11 +4962,27 @@ impl RequestContext {
);
}
pub fn top_level_agent_finished(&mut self, error: Option<&Error>) {
pub fn top_level_agent_finished(
&mut self,
error: Option<&Error>,
abort_signal: Option<&AbortSignal>,
) {
let Some(id) = self.top_level_agent_id.take() else {
return;
};
// The abort signal, never the error text, decides interrupted: a
// ctrl-c'd run is not a failure, so no error env rides along.
if abort_signal.is_some_and(|signal| signal.aborted_ctrlc()) {
hooks::fire(
HookEvent::AgentInterrupted,
self,
&[("COYOTE_AGENT_ID", id)],
None,
);
return;
}
match error {
None => hooks::fire(
HookEvent::AgentCompleted,
@@ -8340,7 +8361,12 @@ mod tests {
let agent_dir = paths::agent_data_dir(agent_name);
create_dir_all(&agent_dir).unwrap();
let mut config = format!("name: {agent_name}\ninstructions: hi\nhooks:\n");
for event in ["agent.started", "agent.completed", "agent.failed"] {
for event in [
"agent.started",
"agent.completed",
"agent.interrupted",
"agent.failed",
] {
config.push_str(&format!(
" {event}:\n - name: {marker}-{event}\n command: 'true'\n"
));
@@ -8360,7 +8386,7 @@ mod tests {
#[test]
#[serial]
fn use_session_fires_session_started_for_new_sessions_only() {
fn use_session_discriminates_session_started_from_session_resumed() {
let _guard = TestConfigDirGuard::new();
let sessions_dir = paths::local_dir("sessions");
create_dir_all(&sessions_dir).unwrap();
@@ -8368,13 +8394,16 @@ mod tests {
let marker = "sess-started-q8n";
let mut ctx = create_test_ctx();
ctx.update_app_config(|app| app.hooks = hooks_map_of(&["session.started"], marker));
ctx.update_app_config(|app| {
app.hooks = hooks_map_of(&["session.started", "session.resumed"], marker)
});
let app = ctx.app.config.clone();
let abort = utils::create_abort_signal();
run_async(ctx.use_session(&app, Some("hook-fresh"), abort.clone())).unwrap();
let captures = marker_captures(marker);
assert_eq!(captures.len(), 1, "a new session fires exactly one event");
assert_eq!(captures[0].hook_name, format!("{marker}-session.started"));
assert_eq!(
captures[0]
.envs
@@ -8383,16 +8412,29 @@ mod tests {
Some("hook-fresh")
);
// Resuming a persisted session fires nothing.
// Resuming a persisted session fires session.resumed, never
// session.started.
write_paused_todo_session(&ctx, "hook-resume");
let mut resumed_ctx = create_test_ctx();
resumed_ctx.update_app_config(|app| app.hooks = hooks_map_of(&["session.started"], marker));
resumed_ctx.update_app_config(|app| {
app.hooks = hooks_map_of(&["session.started", "session.resumed"], marker)
});
let resumed_app = resumed_ctx.app.config.clone();
run_async(resumed_ctx.use_session(&resumed_app, Some("hook-resume"), abort)).unwrap();
let captures = marker_captures(marker);
let started: Vec<_> = captures
.iter()
.filter(|capture| capture.hook_name == format!("{marker}-session.started"))
.collect();
let resumed: Vec<_> = captures
.iter()
.filter(|capture| capture.hook_name == format!("{marker}-session.resumed"))
.collect();
assert_eq!(started.len(), 1, "resume must not fire session.started");
assert_eq!(resumed.len(), 1, "resume fires exactly one session.resumed");
assert_eq!(
marker_captures(marker).len(),
1,
"resume must not fire session.started"
resumed[0].envs.get("COYOTE_SESSION_ID").map(String::as_str),
Some("hook-resume")
);
}
@@ -8469,8 +8511,8 @@ mod tests {
ctx.top_level_agent_started();
// Per agent RUN, never per message: re-entering while open is a no-op.
ctx.top_level_agent_started();
ctx.top_level_agent_finished(None);
ctx.top_level_agent_finished(None);
ctx.top_level_agent_finished(None, None);
ctx.top_level_agent_finished(None, None);
let captures = marker_captures(marker);
let started: Vec<_> = captures
@@ -8519,7 +8561,7 @@ mod tests {
ctx.top_level_agent_started();
let err = anyhow!("dispatch exploded");
ctx.top_level_agent_finished(Some(&err));
ctx.top_level_agent_finished(Some(&err), None);
let captures = marker_captures(marker);
let failed: Vec<_> = captures
@@ -8539,6 +8581,44 @@ mod tests {
assert!(failed[0].envs.contains_key("COYOTE_AGENT_ID"));
}
#[test]
#[serial]
fn top_level_agent_ctrlc_fires_agent_interrupted_not_failed() {
let _guard = TestConfigDirGuard::new();
let _sink = test_sink::install();
let marker = "agent-top-int-r6t";
let agent_name = unique_name("hook_agent");
seed_agent_with_hooks(&agent_name, marker);
let mut ctx = create_test_ctx();
let app = ctx.app.config.clone();
let abort = utils::create_abort_signal();
run_async(ctx.use_agent(&app, &agent_name, None, abort.clone())).unwrap();
ctx.top_level_agent_started();
abort.set_ctrlc();
// The fired signal, not the error text, decides: even with an error
// in hand the bracket reports an interruption.
let err = anyhow!("Aborted.");
ctx.top_level_agent_finished(Some(&err), Some(&abort));
let captures = marker_captures(marker);
let interrupted: Vec<_> = captures
.iter()
.filter(|capture| capture.hook_name == format!("{marker}-agent.interrupted"))
.collect();
assert_eq!(interrupted.len(), 1);
assert!(interrupted[0].envs.contains_key("COYOTE_AGENT_ID"));
assert!(
!interrupted[0].envs.contains_key("COYOTE_AGENT_ERROR"),
"an interruption is not a failure and carries no error env"
);
assert!(captures.iter().all(|capture| {
capture.hook_name != format!("{marker}-agent.failed")
&& capture.hook_name != format!("{marker}-agent.completed")
}));
}
#[test]
#[serial]
fn top_level_agent_helpers_noop_without_agent() {
@@ -8556,7 +8636,7 @@ mod tests {
ctx.role = Some(Role::new("plain", "prompt"));
ctx.top_level_agent_started();
ctx.top_level_agent_finished(None);
ctx.top_level_agent_finished(None, None);
assert!(
marker_captures(marker).is_empty(),
@@ -8582,7 +8662,7 @@ mod tests {
ctx.exit_agent(&app).unwrap();
// A later exit seam (REPL teardown) must not double-fire.
ctx.top_level_agent_finished(None);
ctx.top_level_agent_finished(None, None);
let captures = marker_captures(marker);
assert_eq!(