feat(TASK-036): fire tool, LLM, and job hook events at their seams

Wire the hook engine into the three runtime seams:

- tool.*: ToolCall::eval fires tool.started once the name and arguments
  are resolved, then exactly one of tool.completed/tool.failed,
  discriminated on both the Err path and tool_call_error-shaped outputs.
  The arguments JSON is handed to the engine as the payload; eval itself
  performs no payload-file I/O.
- llm.request.*: the three transport free functions
  (call_chat_completions, call_chat_completions_streaming, and the quiet
  graph-node variant) fire started/completed/failed for every provider
  and caller, classifying failures as aborted, api_error (with
  COYOTE_LLM_STATUS), or other.
- job.*: handle_job_tool fires job.started before either spawn and the
  terminal event inside the detached closures via a pre-resolved
  fire_resolved snapshot, so job__start's return is never delayed.

The test sink now captures the payload and cwd of each dispatch
(sink-only change), and seam tests pin event cadence, env contracts, and
byte-identical tool results and errors.
This commit is contained in:
Dark-Alex-17 committed 2026-09-16 13:44:35 -06:00
1 parent 0c2a68ecc4
commit 2731e98ed1
4 files changed
+1105 -11

No files matched your search

+15 -3
View File
@@ -248,7 +248,7 @@ pub fn fire_resolved(
.collect();
#[cfg(test)]
if test_sink::record(event, &hook, &envs) {
if test_sink::record(event, &hook, &envs, payload.as_deref()) {
continue;
}
@@ -496,6 +496,11 @@ pub(crate) mod test_sink {
pub event: HookEvent,
pub hook_name: String,
pub envs: HashMap<String, String>,
/// The tool-arguments JSON as handed to dispatch; `None` for events
/// that carry no payload.
pub payload: Option<String>,
/// The working directory the hook would have spawned in.
pub cwd: std::path::PathBuf,
}
/// Suppresses process spawning and records every dispatch synchronously
@@ -518,7 +523,12 @@ pub(crate) mod test_sink {
}
}
pub(super) fn record(event: HookEvent, hook: &ResolvedHook, envs: &[(String, String)]) -> bool {
pub(super) fn record(
event: HookEvent,
hook: &ResolvedHook,
envs: &[(String, String)],
payload: Option<&str>,
) -> bool {
if INSTALLED.load(Ordering::SeqCst) == 0 {
return false;
}
@@ -529,6 +539,8 @@ pub(crate) mod test_sink {
event,
hook_name: hook.name.clone(),
envs: envs.iter().cloned().collect(),
payload: payload.map(str::to_string),
cwd: hook.cwd.clone(),
});
true
}
@@ -901,7 +913,7 @@ mod tests {
command: "true".to_string(),
cwd: env::temp_dir(),
};
assert!(test_sink::record(HookEvent::TurnFailed, &hook, &[]));
assert!(test_sink::record(HookEvent::TurnFailed, &hook, &[], None));
let captures = test_sink::drain();
assert!(