use super::{
AgentOutputSink, AgentRunOutput,
session_persistence::SessionPersistence,
tool_continuation::{activity_id_for_call, tool_activity_metadata},
tool_output_compression::provider_visible_tool_output,
turn_state::AgentTurnState,
};
use crate::{
agent::cancellation::{AgentCancellation, AgentRunCanceled},
config::{InjectedContentSettings, InjectedContentStyle},
herdr::HerdrReporter,
hooks::{
HookAction, HookContextInjectionRecord, HookContextInjectionStatus, HookDiagnostic,
HookLifecycleRecord, HookPhase, HookPolicyError, HookRuntime,
},
instructions::subdir::discover_subdir_instructions,
lsp::EditDiagnosticsRequest,
output::{
ActivityEvent, ActivityId, ActivityKind, ActivityMetadata, ActivitySender, ActivityStatus,
HookContextMetadata, OutputEvent, ProviderContextInjectionDisplay, ToolDispatchContext,
pending_activity_status, tool_display_summary,
},
providers::{ChatMessage, ProviderConversationItem, ProviderToolResult, ToolCall},
sessions::SessionEventKind,
tools::{
ToolResult, ToolResultDisplay, ToolRuntime,
contract::tool_name,
dispatch::{ToolDispatchOutcome, TouchedPath, TouchedPathKind},
},
};
use serde_json::json;
use std::{
hash::{Hash, Hasher},
time::Instant,
};
pub(crate) struct StartedToolActivity {
pub(crate) activity_id: ActivityId,
pub(crate) activity_sender: Option<ActivitySender>,
}
pub(crate) fn emit_tool_started(
output_sink: &mut Option<&mut dyn AgentOutputSink>,
iteration: usize,
tool_index: usize,
call: &ToolCall,
) -> anyhow::Result<StartedToolActivity> {
let activity_id = activity_id_for_call(iteration, tool_index, call);
let mut display_call = call.clone();
if display_call.id.trim().is_empty() {
display_call.id = activity_id.as_str().to_string();
}
let parent_activity_id = output_sink
.as_deref()
.and_then(AgentOutputSink::current_parent_activity_id);
let activity_sender = output_sink
.as_deref()
.and_then(AgentOutputSink::activity_sender);
let metadata = tool_activity_metadata(call, None);
if let Some(sink) = output_sink.as_deref_mut() {
sink.activity_event(ActivityEvent::Started {
id: activity_id.clone(),
parent_id: parent_activity_id,
kind: ActivityKind::Tool,
status: pending_activity_status(call),
metadata,
})?;
sink.output_event(OutputEvent::ToolStarted {
call: Box::new(display_call.clone()),
label: crate::output::tool_display_label(call),
})?;
}
Ok(StartedToolActivity {
activity_id,
activity_sender,
})
}
pub(crate) fn emit_tool_finished(
output_sink: &mut Option<&mut dyn AgentOutputSink>,
activity_id: ActivityId,
call: &ToolCall,
result: &ToolResult,
) -> anyhow::Result<()> {
if let Some(sink) = output_sink.as_deref_mut() {
sink.activity_event(ActivityEvent::Finished {
id: activity_id,
status: if result.success {
ActivityStatus::Success
} else {
ActivityStatus::Failed
},
metadata: Some(tool_activity_metadata(call, Some(result))),
})?;
}
Ok(())
}
pub(crate) enum ToolExecutionOutcome {
Dispatched {
provider_result: ProviderToolResult,
tool_result: Box<ToolResult>,
after_hook_failure: Option<HookDiagnostic>,
subdir_context_items: Vec<ProviderConversationItem>,
after_hook_context_items: Vec<ProviderConversationItem>,
},
BeforeHookFailed {
diagnostic: HookDiagnostic,
},
}
fn display_tool_result_call(call: &ToolCall, context: &ToolDispatchContext) -> ToolCall {
let mut display_call = call.clone();
if display_call.id.trim().is_empty()
&& let Some(activity_id) = &context.parent_activity_id
{
display_call.id = activity_id.as_str().to_string();
}
display_call
}
pub(crate) fn before_hook_failure_activity_result(
call: &ToolCall,
diagnostic: &HookDiagnostic,
) -> ToolResult {
ToolResult {
tool_name: call.name.clone(),
success: false,
content: format!(
"tool call failed by local before_tool hook policy: tool={} hook=<redacted> category={}",
call.name,
diagnostic.category.as_str()
),
metadata: json!({"call_id": call.id.clone(), "hook_failed": true}),
display: ToolResultDisplay::default(),
}
}
pub(crate) fn execute_tool_call(
tools: Option<&ToolRuntime>,
hooks: Option<&HookRuntime>,
session_persistence: &mut SessionPersistence<'_>,
output_sink: &mut Option<&mut dyn AgentOutputSink>,
subdir_instruction_state: Option<&mut super::subdir_instructions::SubdirInstructionState>,
call: ToolCall,
context: ToolDispatchContext,
) -> anyhow::Result<ToolExecutionOutcome> {
if let Some(hooks) = hooks {
let outcome = hooks.run_before_with_activity(&call, &context);
emit_hook_lifecycle_records(session_persistence, output_sink, &outcome.lifecycle_records)?;
let mut phase_diagnostics = outcome.diagnostics;
if outcome.canceled {
emit_hook_diagnostics(session_persistence, output_sink, &phase_diagnostics)?;
return Err(AgentRunCanceled.into());
}
match outcome.action {
HookAction::Continue => {
emit_hook_diagnostics(session_persistence, output_sink, &phase_diagnostics)?;
}
HookAction::Block(diagnostic) => {
phase_diagnostics.push(diagnostic.clone());
emit_hook_diagnostics(session_persistence, output_sink, &phase_diagnostics)?;
let result = ToolResult {
tool_name: call.name.clone(),
success: false,
content: format!(
"tool call blocked by local before_tool hook policy: tool={} hook=<redacted> category={}",
call.name,
diagnostic.category.as_str()
),
metadata: json!({"call_id": call.id.clone(), "hook_blocked": true}),
display: ToolResultDisplay::default(),
};
session_persistence.record_required(
SessionEventKind::ToolResult,
json!({"call_id": call.id.clone(), "result": result.clone()}),
)?;
if let Some(sink) = output_sink.as_deref_mut() {
sink.output_event(OutputEvent::ToolResult {
summary: Box::new(tool_display_summary(&call, &result)),
call: Box::new(display_tool_result_call(&call, &context)),
result: Box::new(result.clone()),
})?;
}
let provider_result = ProviderToolResult {
call_id: call.id,
tool_name: result.tool_name.clone(),
success: false,
output: result.content.clone(),
};
return Ok(ToolExecutionOutcome::Dispatched {
provider_result,
tool_result: Box::new(result),
after_hook_failure: None,
subdir_context_items: Vec::new(),
after_hook_context_items: Vec::new(),
});
}
HookAction::Fail(diagnostic) => {
phase_diagnostics.push(diagnostic.clone());
emit_hook_diagnostics(session_persistence, output_sink, &phase_diagnostics)?;
return Ok(ToolExecutionOutcome::BeforeHookFailed { diagnostic });
}
}
}
let dispatch_outcome = tools.map_or_else(
|| ToolDispatchOutcome {
result: ToolResult {
tool_name: call.name.clone(),
success: false,
content: "tool runtime is not configured".to_string(),
metadata: json!({"call_id": call.id.clone()}),
display: ToolResultDisplay::default(),
},
touched_paths: Vec::new(),
},
|tools| {
tools.dispatch_with_context_outcome(&call.name, call.arguments.clone(), context.clone())
},
);
let mut result = dispatch_outcome.result;
let touched_paths = dispatch_outcome.touched_paths;
maybe_append_lsp_diagnostics(
tools,
&call.name,
&mut result,
&touched_paths,
&context.cancellation,
);
session_persistence.record_required(
SessionEventKind::ToolResult,
json!({"call_id": call.id.clone(), "result": result.clone()}),
)?;
if let Some(sink) = output_sink.as_deref_mut() {
sink.output_event(OutputEvent::ToolResult {
summary: Box::new(tool_display_summary(&call, &result)),
call: Box::new(display_tool_result_call(&call, &context)),
result: Box::new(result.clone()),
})?;
}
let provider_output = tools
.map(|tools| {
provider_visible_tool_output(&call, &result, tools.output_compression_settings())
})
.unwrap_or_else(|| result.content.clone());
let provider_result = ProviderToolResult {
call_id: call.id.clone(),
tool_name: result.tool_name.clone(),
success: result.success,
output: provider_output,
};
let subdir_context_items = discover_and_record_subdir_context_items(
tools,
session_persistence,
output_sink,
subdir_instruction_state,
&touched_paths,
context.activity_sender.as_ref(),
context.parent_activity_id.as_ref().map(ActivityId::as_str),
&call.id,
)?;
let mut after_hook_failure = None;
let mut after_hook_context_items = Vec::new();
if let Some(hooks) = hooks {
let outcome = hooks.run_after_with_activity(&call, &result, &context);
emit_hook_lifecycle_records(session_persistence, output_sink, &outcome.lifecycle_records)?;
emit_hook_context_injection_records(
session_persistence,
output_sink,
&outcome.context_injection_records,
)?;
emit_provider_context_injection_display(
output_sink,
&outcome.context_injection_records,
&outcome.context_items,
&hooks.injected_content_settings(),
context.parent_activity_id.as_ref(),
context.activity_sender.as_ref(),
)?;
let mut phase_diagnostics = outcome.diagnostics;
after_hook_context_items = outcome.context_items;
match outcome.action {
HookAction::Continue => {}
HookAction::Block(diagnostic) | HookAction::Fail(diagnostic) => {
phase_diagnostics.push(diagnostic.clone());
after_hook_failure = Some(diagnostic);
}
}
emit_hook_diagnostics(session_persistence, output_sink, &phase_diagnostics)?;
if outcome.canceled {
return Err(AgentRunCanceled.into());
}
}
Ok(ToolExecutionOutcome::Dispatched {
provider_result,
tool_result: Box::new(result),
after_hook_failure,
subdir_context_items,
after_hook_context_items,
})
}
fn maybe_append_lsp_diagnostics(
tools: Option<&ToolRuntime>,
tool_name: &str,
result: &mut ToolResult,
touched_paths: &[TouchedPath],
cancellation: &AgentCancellation,
) {
if !result.success {
return;
}
if tool_name != tool_name::WRITE && tool_name != tool_name::HASH_EDIT {
return;
}
let Some(manager) = tools.and_then(ToolRuntime::lsp_manager) else {
return;
};
if !manager.is_enabled() || !manager.inject_diagnostics_on_edit() {
return;
}
let deadline = Instant::now() + manager.edit_budget();
for touched in touched_paths.iter().filter(|path| {
matches!(
path.kind,
TouchedPathKind::Write | TouchedPathKind::HashEdit
) && path.success
&& path.inside_root
&& !path.is_dir
}) {
let path = &touched.canonical;
let Ok(content) = std::fs::read_to_string(path) else {
continue;
};
if let Some(block) = manager.sync_and_wait_diagnostics(
EditDiagnosticsRequest { path, content },
deadline,
cancellation,
) && !block.trim().is_empty()
{
result.content.push_str("\n\n");
result.content.push_str(&block);
}
}
}
pub(super) fn emit_hook_lifecycle_records(
session_persistence: &mut SessionPersistence<'_>,
output_sink: &mut Option<&mut dyn AgentOutputSink>,
records: &[HookLifecycleRecord],
) -> anyhow::Result<()> {
for record in records {
session_persistence.try_record(
SessionEventKind::HookLifecycle,
record.to_session_payload(),
output_sink,
)?;
}
Ok(())
}
pub(super) fn emit_hook_context_injection_records(
session_persistence: &mut SessionPersistence<'_>,
output_sink: &mut Option<&mut dyn AgentOutputSink>,
records: &[HookContextInjectionRecord],
) -> anyhow::Result<()> {
for record in records {
session_persistence.try_record(
SessionEventKind::HookContextInjection,
record.to_session_payload(),
output_sink,
)?;
}
Ok(())
}
pub(super) fn emit_provider_context_injection_display(
output_sink: &mut Option<&mut dyn AgentOutputSink>,
records: &[HookContextInjectionRecord],
context_items: &[ProviderConversationItem],
settings: &InjectedContentSettings,
parent_activity_id: Option<&ActivityId>,
activity_sender: Option<&ActivitySender>,
) -> anyhow::Result<()> {
if !settings.show_in_transcript && !settings.show_in_activity_tree {
return Ok(());
}
let mut provider_messages = context_items.iter().filter_map(|item| match item {
ProviderConversationItem::Message(message) => Some(message.content.as_str()),
_ => None,
});
for record in records {
let content = if settings.style == InjectedContentStyle::Content
&& record.status == HookContextInjectionStatus::Success
{
let joined = provider_messages
.by_ref()
.take(record.item_count)
.collect::<Vec<_>>()
.join("\n");
(!joined.is_empty()).then(|| bounded_injected_content(&joined))
} else {
None
};
let display = provider_context_injection_display(record, content);
if settings.show_in_transcript
&& let Some(sink) = output_sink.as_deref_mut()
{
sink.output_event(OutputEvent::ProviderContextInjection {
metadata: display.clone(),
})?;
}
if settings.show_in_activity_tree
&& let Some(sender) = activity_sender
{
emit_provider_context_injection_activity(sender, parent_activity_id, record, &display);
}
}
Ok(())
}
fn provider_context_injection_display(
record: &HookContextInjectionRecord,
content: Option<String>,
) -> ProviderContextInjectionDisplay {
ProviderContextInjectionDisplay::new(
record.phase,
record.label.clone(),
record.target_tool.clone(),
record.status.as_str(),
record.item_count,
record.byte_count,
content,
)
}
fn emit_provider_context_injection_activity(
sender: &ActivitySender,
parent_activity_id: Option<&ActivityId>,
record: &HookContextInjectionRecord,
display: &ProviderContextInjectionDisplay,
) {
let id = provider_context_injection_activity_id(parent_activity_id, record);
let metadata = provider_context_injection_activity_metadata(display);
let status = if record.status == HookContextInjectionStatus::Success {
ActivityStatus::Success
} else {
ActivityStatus::Failed
};
sender(ActivityEvent::Started {
id: id.clone(),
parent_id: parent_activity_id.cloned(),
kind: ActivityKind::ProviderContextInjection,
status: ActivityStatus::Running,
metadata: metadata.clone(),
});
sender(ActivityEvent::Finished {
id,
status,
metadata: Some(metadata),
});
}
fn provider_context_injection_activity_id(
parent_activity_id: Option<&ActivityId>,
record: &HookContextInjectionRecord,
) -> ActivityId {
let phase = record.phase.as_str();
if let Some(parent_activity_id) = parent_activity_id {
ActivityId::new(format!(
"{}/{phase}-provider-context-injection-{}",
parent_activity_id.as_str(),
record.hook_index
))
} else {
ActivityId::new(format!(
"provider-context-injection/{}/{}/{}",
record.target_tool, phase, record.hook_index
))
}
}
fn provider_context_injection_activity_metadata(
display: &ProviderContextInjectionDisplay,
) -> ActivityMetadata {
let label = format!("provider context injection {}", display.label);
ActivityMetadata {
label,
detail: Some(format!(
"provider context injection '{}' for {} {}: {} {} {} bytes",
display.label,
display.target,
display.phase.as_str(),
display.status,
item_count_text(display.item_count),
display.byte_count
)),
fields: vec![
("phase".to_string(), display.phase.as_str().to_string()),
("hook_label".to_string(), display.label.clone()),
("target".to_string(), display.target.clone()),
("status".to_string(), display.status.clone()),
("item_count".to_string(), display.item_count.to_string()),
("bytes".to_string(), display.byte_count.to_string()),
],
}
}
fn item_count_text(count: usize) -> String {
if count == 1 {
"1 item".to_string()
} else {
format!("{count} items")
}
}
fn bounded_injected_content(text: &str) -> String {
const MAX_INJECTED_CONTENT_CHARS: usize = 2000;
let redacted = crate::output::redact_sensitive_text(text);
let mut chars = redacted.chars();
let truncated = chars
.by_ref()
.take(MAX_INJECTED_CONTENT_CHARS)
.collect::<String>();
if chars.next().is_some() {
format!("{truncated}…")
} else {
truncated
}
}
pub(super) fn emit_hook_diagnostics(
session_persistence: &mut SessionPersistence<'_>,
output_sink: &mut Option<&mut dyn AgentOutputSink>,
diagnostics: &[HookDiagnostic],
) -> anyhow::Result<()> {
for diagnostic in diagnostics {
session_persistence.try_record(
SessionEventKind::HookDiagnostic,
json!({
"phase": diagnostic.phase.as_str(),
"tool": diagnostic.tool_name,
"label": diagnostic.label,
"category": diagnostic.category.as_str(),
"policy": diagnostic.policy.as_str(),
"target_ran": diagnostic.target_ran,
"message": diagnostic.sanitized_message(),
}),
output_sink,
)?;
if let Some(sink) = output_sink.as_deref_mut() {
sink.output_event(OutputEvent::HookDiagnostic {
diagnostic: Box::new(diagnostic.clone()),
})?;
}
}
Ok(())
}
fn record_subdir_instruction_load(
session_persistence: &mut SessionPersistence<'_>,
output_sink: &mut Option<&mut dyn AgentOutputSink>,
path: &std::path::Path,
bytes: usize,
activity_sender: Option<&ActivitySender>,
parent_activity_id: &str,
tool_call_id: &str,
) -> anyhow::Result<()> {
session_persistence.try_record(
SessionEventKind::SubdirInstructionLoad,
json!({
"source": "subdir_agents",
"path": path,
"status": "success",
"bytes": bytes,
"tool_call_id": tool_call_id,
}),
output_sink,
)?;
if let Some(sink) = output_sink.as_deref_mut() {
sink.output_event(OutputEvent::SubdirInstructionInjection {
path: path.to_path_buf(),
bytes,
parent_activity_id: Some(parent_activity_id.to_string()),
})?;
}
if let Some(sender) = activity_sender {
let mut hasher = std::collections::hash_map::DefaultHasher::new();
path.hash(&mut hasher);
let slug = format!("{:x}", hasher.finish());
let id = ActivityId::new(format!(
"{parent_activity_id}/subdir-instruction-injection-{slug}",
));
let metadata = ActivityMetadata {
label: format!("Loaded subdirectory instructions: {}", path.display()),
detail: Some(format!(
"Loaded subdirectory instructions: {} ({} bytes)",
path.display(),
bytes
)),
fields: vec![
("source".to_string(), "subdir_agents".to_string()),
("path".to_string(), path.display().to_string()),
("status".to_string(), "success".to_string()),
("bytes".to_string(), bytes.to_string()),
],
};
sender(ActivityEvent::Started {
id: id.clone(),
parent_id: Some(ActivityId::new(parent_activity_id.to_string())),
kind: ActivityKind::ProviderContextInjection,
status: ActivityStatus::Running,
metadata: metadata.clone(),
});
sender(ActivityEvent::Finished {
id,
status: ActivityStatus::Success,
metadata: Some(metadata),
});
}
Ok(())
}
pub(super) fn record_provider_context_item(
session_persistence: &mut SessionPersistence<'_>,
output_sink: &mut Option<&mut dyn AgentOutputSink>,
item: &ProviderConversationItem,
) -> anyhow::Result<()> {
let ProviderConversationItem::Message(message) = item else {
return Ok(());
};
session_persistence.try_record(
SessionEventKind::ProviderContextItem,
json!({"role": message.role.as_api_str(), "content": message.content}),
output_sink,
)
}
fn record_subdir_provider_context_item(
session_persistence: &mut SessionPersistence<'_>,
output_sink: &mut Option<&mut dyn AgentOutputSink>,
item: &ProviderConversationItem,
path: &std::path::Path,
) -> anyhow::Result<()> {
let ProviderConversationItem::Message(message) = item else {
return Ok(());
};
session_persistence.try_record(
SessionEventKind::ProviderContextItem,
json!({
"role": message.role.as_api_str(),
"content": message.content,
"source": "subdir_agents",
"path": path,
}),
output_sink,
)
}
#[allow(clippy::too_many_arguments)]
fn discover_and_record_subdir_context_items(
tools: Option<&ToolRuntime>,
session_persistence: &mut SessionPersistence<'_>,
output_sink: &mut Option<&mut dyn AgentOutputSink>,
subdir_instruction_state: Option<&mut super::subdir_instructions::SubdirInstructionState>,
touched_paths: &[TouchedPath],
activity_sender: Option<&ActivitySender>,
parent_activity_id: Option<&str>,
tool_call_id: &str,
) -> anyhow::Result<Vec<ProviderConversationItem>> {
let Some(tools) = tools.filter(|tools| tools.subdir_discovery_enabled()) else {
return Ok(Vec::new());
};
let Some(state) = subdir_instruction_state else {
return Ok(Vec::new());
};
let Some(parent_activity_id) = parent_activity_id else {
return Ok(Vec::new());
};
let root = tools.cwd_canonical();
let claimed = state.claimed_mut();
let mut subdir_context_items = Vec::new();
for touched in touched_paths {
if !touched.inside_root || touched.self_authored_agents_md {
continue;
}
let (discovered, diagnostics) =
discover_subdir_instructions(&touched.canonical, root, claimed);
for message in &diagnostics {
if let Some(sink) = output_sink.as_deref_mut() {
sink.output_event(OutputEvent::Diagnostic {
level: "warning".to_string(),
message: format!("subdir instruction discovery: {message}"),
})?;
}
}
for instruction in discovered {
let path = instruction.path.clone();
let bytes = instruction.bytes;
let rendered = format!(
"<Additional-Context-Files>\n<Context File={}>\n{}\n</Context>\n</Additional-Context-Files>",
path.display(),
instruction.content
);
let provider_item = ProviderConversationItem::Message(ChatMessage::user(rendered));
record_subdir_provider_context_item(
session_persistence,
output_sink,
&provider_item,
&path,
)?;
record_subdir_instruction_load(
session_persistence,
output_sink,
&path,
bytes,
activity_sender,
parent_activity_id,
tool_call_id,
)?;
claimed.insert(path);
subdir_context_items.push(provider_item);
}
}
Ok(subdir_context_items)
}
pub(super) struct MessageHookOutcome {
pub(super) context_items: Vec<ProviderConversationItem>,
pub(super) failure: Option<HookDiagnostic>,
}
#[allow(clippy::too_many_arguments)]
pub(super) fn run_message_phase_hooks(
hooks: Option<&HookRuntime>,
phase: HookPhase,
message_id: &str,
text: &str,
hook_context: &HookContextMetadata,
cancellation: &AgentCancellation,
session_persistence: &mut SessionPersistence<'_>,
output_sink: &mut Option<&mut dyn AgentOutputSink>,
) -> anyhow::Result<MessageHookOutcome> {
let Some(hooks) = hooks else {
return Ok(MessageHookOutcome {
context_items: Vec::new(),
failure: None,
});
};
let dispatch_context = ToolDispatchContext::new_with_hook_context_and_cancellation(
Some(ActivityId::new(message_id)),
output_sink
.as_deref()
.and_then(AgentOutputSink::activity_sender),
hook_context.clone(),
cancellation.clone(),
);
let outcome = match phase {
HookPhase::AfterAssistant => hooks.run_after_assistant(message_id, text, &dispatch_context),
HookPhase::AfterReasoning => hooks.run_after_reasoning(message_id, text, &dispatch_context),
_ => unreachable!("run_message_phase_hooks called with non-message phase"),
};
emit_hook_lifecycle_records(session_persistence, output_sink, &outcome.lifecycle_records)?;
emit_hook_context_injection_records(
session_persistence,
output_sink,
&outcome.context_injection_records,
)?;
emit_provider_context_injection_display(
output_sink,
&outcome.context_injection_records,
&outcome.context_items,
&hooks.injected_content_settings(),
dispatch_context.parent_activity_id.as_ref(),
dispatch_context.activity_sender.as_ref(),
)?;
let mut failure = None;
let mut diagnostics = outcome.diagnostics;
match outcome.action {
HookAction::Continue => {}
HookAction::Block(diagnostic) | HookAction::Fail(diagnostic) => {
diagnostics.push(diagnostic.clone());
failure = Some(diagnostic);
}
}
emit_hook_diagnostics(session_persistence, output_sink, &diagnostics)?;
if outcome.canceled {
return Err(AgentRunCanceled.into());
}
Ok(MessageHookOutcome {
context_items: outcome.context_items,
failure,
})
}
pub(super) struct ToolLifecycleRun<'a, 'sink, 'session, 'run> {
pub(super) tool_calls: Vec<ToolCall>,
pub(super) tools: Option<&'run ToolRuntime>,
pub(super) hooks: Option<&'run HookRuntime>,
pub(super) herdr_reporter: Option<&'run HerdrReporter>,
pub(super) output_sink: &'a mut Option<&'sink mut dyn AgentOutputSink>,
pub(super) cancellation: &'a AgentCancellation,
pub(super) turn_state: &'a mut AgentTurnState,
pub(super) output: &'a mut AgentRunOutput,
pub(super) session_persistence: &'a mut SessionPersistence<'session>,
pub(super) hook_context: HookContextMetadata,
pub(super) subdir_instruction_state:
Option<&'a mut super::subdir_instructions::SubdirInstructionState>,
}
pub(super) fn run_tool_lifecycle(mut run: ToolLifecycleRun<'_, '_, '_, '_>) -> anyhow::Result<()> {
for (tool_index, call) in run.tool_calls.into_iter().enumerate() {
run.cancellation.check()?;
run.turn_state.register_tool_call(&call)?;
run.session_persistence
.record_required(
SessionEventKind::ToolCall,
json!({"id": call.id.clone(), "name": call.name.clone(), "arguments": call.arguments.clone()}),
)
.map_err(|error| anyhow::anyhow!("failed to persist tool call before dispatch: {error}"))?;
run.turn_state.append_function_call_if_missing(&call);
let activity = emit_tool_started(
run.output_sink,
run.turn_state.iteration(),
tool_index,
&call,
)?;
if let Some(reporter) = run.herdr_reporter {
reporter.report_tool(&call.name);
}
let turn_id = format!("turn-{}", run.turn_state.iteration());
let message_id = format!("tool-{}", activity.activity_id.as_str());
let dispatch_context = ToolDispatchContext::new_with_hook_context_and_cancellation(
Some(activity.activity_id.clone()),
activity.activity_sender.clone(),
run.hook_context.for_tool_call(turn_id, message_id),
run.cancellation.clone(),
);
let outcome = execute_tool_call(
run.tools,
run.hooks,
run.session_persistence,
run.output_sink,
run.subdir_instruction_state.as_deref_mut(),
call.clone(),
dispatch_context,
)?;
match outcome {
ToolExecutionOutcome::Dispatched {
provider_result,
tool_result,
after_hook_failure,
subdir_context_items,
after_hook_context_items,
} => {
emit_tool_finished(
run.output_sink,
activity.activity_id.clone(),
&call,
&tool_result,
)?;
run.turn_state.append_tool_result(provider_result);
run.turn_state
.append_provider_context_items(subdir_context_items);
for item in &after_hook_context_items {
record_provider_context_item(run.session_persistence, run.output_sink, item)?;
}
run.turn_state
.append_provider_context_items(after_hook_context_items);
run.output.tool_results.push((*tool_result).clone());
if let Some(diagnostic) = after_hook_failure {
return Err(HookPolicyError::new(diagnostic).into());
}
}
ToolExecutionOutcome::BeforeHookFailed { diagnostic } => {
let local_result = before_hook_failure_activity_result(&call, &diagnostic);
emit_tool_finished(run.output_sink, activity.activity_id, &call, &local_result)?;
return Err(HookPolicyError::new(diagnostic).into());
}
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{
config::{
HookDefinition, HookFailurePolicy, HookSettings, InjectedContentSettings,
InjectedContentStyle, LspServerConfig, LspSettings,
},
hooks::HookRuntime,
output::ActivitySender,
providers::{ChatMessage, ProviderConversationItem},
sessions::SessionManager,
};
use serde_json::Value;
use std::{
fs,
path::Path,
sync::{Arc, Mutex},
};
fn call(name: &str) -> ToolCall {
ToolCall {
id: "call_1".to_string(),
name: name.to_string(),
arguments: json!({}),
}
}
fn fake_lsp_server_script(mode: &str) -> String {
format!(
r#"
import json, sys, time
mode = {mode:?}
def read_msg():
header = b''
while not header.endswith(b'\r\n\r\n'):
chunk = sys.stdin.buffer.readline()
if not chunk:
return None
header += chunk
length = 0
for line in header.decode().splitlines():
if line.lower().startswith('content-length:'):
length = int(line.split(':', 1)[1].strip())
return json.loads(sys.stdin.buffer.read(length).decode())
def send(value):
body = json.dumps(value, separators=(',', ':')).encode()
sys.stdout.buffer.write(b'Content-Length: ' + str(len(body)).encode() + b'\r\n\r\n' + body)
sys.stdout.buffer.flush()
while True:
msg = read_msg()
if msg is None:
break
method = msg.get('method')
if method == 'initialize':
if mode == 'crash':
sys.exit(7)
send({{'jsonrpc':'2.0','id':msg['id'],'result':{{'capabilities':{{'textDocumentSync':1}}}}}})
elif method == 'initialized':
pass
elif method in ('textDocument/didOpen','textDocument/didChange'):
if mode == 'none':
continue
if mode == 'delay':
time.sleep(0.25)
td = msg['params'].get('textDocument', {{}})
uri = td.get('uri')
version = td.get('version')
if mode == 'stale' and version is not None:
version -= 1
send({{'jsonrpc':'2.0','method':'textDocument/publishDiagnostics','params':{{'uri':uri,'version':version,'diagnostics':[{{'range':{{'start':{{'line':0,'character':0}},'end':{{'line':0,'character':1}}}},'severity':1,'source':'fake','message':'boom from lifecycle'}}]}}}})
elif method == 'shutdown':
send({{'jsonrpc':'2.0','id':msg['id'],'result':None}})
elif method == 'exit':
break
"#
)
}
fn lsp_settings(mode: &str, wait_ms: u64) -> LspSettings {
let mut settings = LspSettings {
enabled: true,
diagnostics_wait_ms: wait_ms,
..LspSettings::default()
};
settings.servers.insert(
"rust-analyzer".to_string(),
LspServerConfig {
command: "python3".to_string(),
args: vec![
"-u".to_string(),
"-c".to_string(),
fake_lsp_server_script(mode),
],
enabled: true,
},
);
settings
}
fn tool_runtime_with_lsp(root: &Path, mode: &str, wait_ms: u64) -> ToolRuntime {
let settings = crate::config::Settings {
lsp: lsp_settings(mode, wait_ms),
..crate::config::Settings::default()
};
ToolRuntime::new_with_full_settings_and_mcp(
root,
crate::config::McPaths::from_root(root.join("mc")),
settings,
None,
)
.unwrap()
}
fn execute_write_for_lsp_test(
tools: &ToolRuntime,
root: &Path,
path: impl AsRef<Path>,
) -> ToolExecutionOutcome {
let mut session_persistence = SessionPersistence::new(None, root);
let mut output_sink = None;
execute_tool_call(
Some(tools),
None,
&mut session_persistence,
&mut output_sink,
None,
ToolCall {
id: "call_lsp".to_string(),
name: tool_name::WRITE.to_string(),
arguments: json!({"path": path.as_ref(), "content": "fn main() { missing; }\n"}),
},
ToolDispatchContext::new(Some(ActivityId::new("call_lsp")), None),
)
.unwrap()
}
#[test]
fn lsp_diagnostics_injection_reaches_output_session_and_provider_result() {
let temp = tempfile::TempDir::new().unwrap();
let session = SessionManager::new(temp.path().join("sessions"))
.create()
.unwrap();
let tools = tool_runtime_with_lsp(temp.path(), "normal", 1_000);
let mut session_persistence = SessionPersistence::new(Some(&session), temp.path());
let mut capture = CaptureSink::default();
let mut output_sink: Option<&mut dyn AgentOutputSink> = Some(&mut capture);
let outcome = execute_tool_call(
Some(&tools),
None,
&mut session_persistence,
&mut output_sink,
None,
ToolCall {
id: "call_lsp".to_string(),
name: tool_name::WRITE.to_string(),
arguments: json!({"path": "lib.rs", "content": "fn main() { missing; }\n"}),
},
ToolDispatchContext::new(Some(ActivityId::new("call_lsp")), None),
)
.unwrap();
let ToolExecutionOutcome::Dispatched {
provider_result,
tool_result,
..
} = outcome
else {
panic!("expected dispatched outcome");
};
assert!(tool_result.content.contains("DIAGNOSTICS"));
assert!(tool_result.content.contains("boom from lifecycle"));
assert!(provider_result.output.contains("boom from lifecycle"));
assert!(session.read_events().unwrap().iter().any(|event| {
event.event_type == "tool_result"
&& event.payload["result"]["content"]
.as_str()
.is_some_and(|content| content.contains("boom from lifecycle"))
}));
assert!(capture.outputs.iter().any(|event| matches!(event, OutputEvent::ToolResult { result, .. } if result.content.contains("boom from lifecycle"))));
}
#[test]
fn hash_edit_success_appends_lsp_diagnostics() {
let temp = tempfile::TempDir::new().unwrap();
fs::write(temp.path().join("lib.rs"), "fn main() {}\n").unwrap();
let tools = tool_runtime_with_lsp(temp.path(), "normal", 1_000);
let read = tools.dispatch("read", json!({"path":"lib.rs"}));
let tag = read
.content
.split("#")
.nth(1)
.and_then(|rest| rest.split(']').next())
.expect("hashline tag");
let mut session_persistence = SessionPersistence::new(None, temp.path());
let mut output_sink = None;
let outcome = execute_tool_call(
Some(&tools),
None,
&mut session_persistence,
&mut output_sink,
None,
ToolCall {
id: "call_hash_lsp".to_string(),
name: tool_name::HASH_EDIT.to_string(),
arguments: json!({"input": format!("[lib.rs#{tag}]\nSWAP 1.=1:\n+fn main() {{ missing; }}")}),
},
ToolDispatchContext::new(Some(ActivityId::new("call_hash_lsp")), None),
)
.unwrap();
let ToolExecutionOutcome::Dispatched { tool_result, .. } = outcome else {
panic!("expected dispatched outcome");
};
assert!(tool_result.success, "{}", tool_result.content);
assert!(tool_result.content.contains("DIAGNOSTICS"));
assert!(tool_result.content.contains("boom from lifecycle"));
}
#[test]
fn lsp_injection_leaves_result_unchanged_without_fresh_diagnostics_or_disabled_manager() {
let temp = tempfile::TempDir::new().unwrap();
let no_diag_tools = tool_runtime_with_lsp(temp.path(), "none", 100);
let outcome = execute_write_for_lsp_test(&no_diag_tools, temp.path(), "lib.rs");
let ToolExecutionOutcome::Dispatched { tool_result, .. } = outcome else {
panic!("expected dispatched outcome");
};
assert!(!tool_result.content.contains("DIAGNOSTICS"));
let disabled_tools = ToolRuntime::new_with_full_settings_and_mcp(
temp.path(),
crate::config::McPaths::from_root(temp.path().join("mc-disabled")),
crate::config::Settings::default(),
None,
)
.unwrap();
assert!(disabled_tools.lsp_manager().is_none());
let outcome = execute_write_for_lsp_test(&disabled_tools, temp.path(), "disabled.rs");
let ToolExecutionOutcome::Dispatched { tool_result, .. } = outcome else {
panic!("expected dispatched outcome");
};
assert!(!tool_result.content.contains("DIAGNOSTICS"));
}
#[test]
fn lsp_injection_degrades_without_changing_success_for_slow_stale_or_crashed_servers() {
for mode in ["delay", "stale", "crash"] {
let temp = tempfile::TempDir::new().unwrap();
let tools = tool_runtime_with_lsp(temp.path(), mode, 50);
let outcome = execute_write_for_lsp_test(&tools, temp.path(), "lib.rs");
let ToolExecutionOutcome::Dispatched { tool_result, .. } = outcome else {
panic!("expected dispatched outcome");
};
assert!(tool_result.success, "{mode}: {}", tool_result.content);
assert!(
!tool_result.content.contains("DIAGNOSTICS"),
"{mode}: {}",
tool_result.content
);
}
}
#[test]
fn lsp_injection_skips_absolute_writes_outside_workspace_root() {
let root = tempfile::TempDir::new().unwrap();
let outside = tempfile::TempDir::new().unwrap();
let outside_file = outside.path().join("lib.rs");
let mut settings = crate::config::Settings::default();
settings.tools.write.absolute_paths = true;
settings.lsp = lsp_settings("normal", 1_000);
let tools = ToolRuntime::new_with_full_settings_and_mcp(
root.path(),
crate::config::McPaths::from_root(root.path().join("mc")),
settings,
None,
)
.unwrap();
let outcome = execute_write_for_lsp_test(&tools, root.path(), &outside_file);
let ToolExecutionOutcome::Dispatched { tool_result, .. } = outcome else {
panic!("expected dispatched outcome");
};
assert!(tool_result.success, "{}", tool_result.content);
assert!(!tool_result.content.contains("DIAGNOSTICS"));
assert!(outside_file.exists());
}
fn event_types(session: &crate::sessions::Session) -> Vec<String> {
session
.read_events()
.unwrap()
.into_iter()
.map(|event| event.event_type)
.collect()
}
#[derive(Default)]
struct CaptureSink {
outputs: Vec<OutputEvent>,
}
impl AgentOutputSink for CaptureSink {
fn assistant_delta(&mut self, _text: &str) -> anyhow::Result<()> {
Ok(())
}
fn output_event(&mut self, event: OutputEvent) -> anyhow::Result<()> {
self.outputs.push(event);
Ok(())
}
fn tool_block(&mut self, _block: &str) -> anyhow::Result<()> {
Ok(())
}
}
fn context_record(status: HookContextInjectionStatus) -> HookContextInjectionRecord {
HookContextInjectionRecord {
phase: HookPhase::After,
label: "after-context".to_string(),
target_tool: "read".to_string(),
status,
item_count: usize::from(status == HookContextInjectionStatus::Success),
byte_count: 14,
max_bytes: 4096,
hook_index: 0,
tool_call_id: Some("call_1".to_string()),
injection_id: "after_tool:call_1:0".to_string(),
}
}
fn provider_item(content: &str) -> ProviderConversationItem {
ProviderConversationItem::Message(ChatMessage::user(content))
}
#[test]
fn injection_display_transcript_gate_and_content_style() {
let mut capture = CaptureSink::default();
let mut sink: Option<&mut dyn AgentOutputSink> = Some(&mut capture);
let records = vec![context_record(HookContextInjectionStatus::Success)];
let items = vec![provider_item("visible injected content")];
emit_provider_context_injection_display(
&mut sink,
&records,
&items,
&InjectedContentSettings {
show_in_transcript: true,
show_in_activity_tree: false,
style: InjectedContentStyle::Content,
},
None,
None,
)
.unwrap();
assert_eq!(capture.outputs.len(), 1);
let OutputEvent::ProviderContextInjection { metadata } = &capture.outputs[0] else {
panic!("expected provider context injection output");
};
assert_eq!(
metadata.content.as_deref(),
Some("visible injected content")
);
}
#[test]
fn injection_display_metadata_style_omits_content() {
let mut capture = CaptureSink::default();
let mut sink: Option<&mut dyn AgentOutputSink> = Some(&mut capture);
let records = vec![context_record(HookContextInjectionStatus::Success)];
let items = vec![provider_item("hidden injected content")];
emit_provider_context_injection_display(
&mut sink,
&records,
&items,
&InjectedContentSettings {
show_in_transcript: true,
show_in_activity_tree: false,
style: InjectedContentStyle::Metadata,
},
None,
None,
)
.unwrap();
let OutputEvent::ProviderContextInjection { metadata } = &capture.outputs[0] else {
panic!("expected provider context injection output");
};
assert_eq!(metadata.content, None);
}
#[test]
fn injection_display_activity_gate_ignores_transcript_and_show_in_tui() {
let events = Arc::new(Mutex::new(Vec::new()));
let sender_events = Arc::clone(&events);
let sender: ActivitySender =
Arc::new(move |event| sender_events.lock().unwrap().push(event));
let mut sink = None;
let records = vec![context_record(HookContextInjectionStatus::InvalidJson)];
emit_provider_context_injection_display(
&mut sink,
&records,
&[],
&InjectedContentSettings {
show_in_transcript: false,
show_in_activity_tree: true,
style: InjectedContentStyle::Content,
},
Some(&ActivityId::new("tool-1")),
Some(&sender),
)
.unwrap();
let events = events.lock().unwrap();
assert_eq!(events.len(), 2);
assert!(matches!(events[0], ActivityEvent::Started { .. }));
assert!(matches!(
events[1],
ActivityEvent::Finished {
status: ActivityStatus::Failed,
..
}
));
}
#[test]
fn injection_display_disabled_gates_emit_nothing() {
let mut capture = CaptureSink::default();
let mut sink: Option<&mut dyn AgentOutputSink> = Some(&mut capture);
let events = Arc::new(Mutex::new(Vec::new()));
let sender_events = Arc::clone(&events);
let sender: ActivitySender =
Arc::new(move |event| sender_events.lock().unwrap().push(event));
emit_provider_context_injection_display(
&mut sink,
&[context_record(HookContextInjectionStatus::Success)],
&[provider_item("ignored")],
&InjectedContentSettings::default(),
Some(&ActivityId::new("tool-1")),
Some(&sender),
)
.unwrap();
assert!(capture.outputs.is_empty());
assert!(events.lock().unwrap().is_empty());
}
#[cfg(unix)]
#[test]
fn hook_lifecycle_session_order_surrounds_tool_result() {
let temp = tempfile::TempDir::new().unwrap();
let session = SessionManager::new(temp.path().join("sessions"))
.create()
.unwrap();
let hooks = HookRuntime::new(
temp.path(),
HookSettings {
enabled: true,
show_in_tui: false,
before_tool: vec![HookDefinition {
label: Some("before".into()),
command: "true".into(),
..HookDefinition::default()
}],
after_tool: vec![HookDefinition {
label: Some("after".into()),
command: "true".into(),
..HookDefinition::default()
}],
..HookSettings::default()
},
true,
)
.unwrap();
let mut session_persistence = SessionPersistence::new(Some(&session), temp.path());
let mut output_sink = None;
let call = call("read");
session_persistence
.try_record(
SessionEventKind::ToolCall,
json!({"id": call.id.clone(), "name": call.name.clone(), "arguments": call.arguments.clone()}),
&mut output_sink,
)
.unwrap();
let outcome = execute_tool_call(
None,
Some(&hooks),
&mut session_persistence,
&mut output_sink,
None,
call,
ToolDispatchContext::new(Some(ActivityId::new("call_1")), None),
)
.unwrap();
assert!(matches!(outcome, ToolExecutionOutcome::Dispatched { .. }));
assert_eq!(
event_types(&session),
vec![
"tool_call",
"hook_lifecycle",
"hook_lifecycle",
"tool_result",
"hook_lifecycle",
"hook_lifecycle",
]
);
let events = session.read_events().unwrap();
assert_eq!(events[1].payload["phase"], "before_tool");
assert_eq!(events[1].payload["status"], "started");
assert_eq!(events[2].payload["status"], "success");
assert_eq!(events[4].payload["phase"], "after_tool");
assert_eq!(events[4].payload["status"], "started");
assert_eq!(events[5].payload["status"], "success");
assert!(
!events
.iter()
.any(|event| event.event_type == "hook_diagnostic")
);
}
#[cfg(unix)]
#[test]
fn subdir_discovery_records_before_after_hook_context() {
let temp = tempfile::TempDir::new().unwrap();
fs::create_dir_all(temp.path().join("sub")).unwrap();
fs::write(temp.path().join("sub/AGENTS.md"), "subdir rules").unwrap();
fs::write(temp.path().join("sub/file.txt"), "content").unwrap();
fs::write(
temp.path().join("hook.out"),
r#"{"context_items":[{"role":"user","content":"after memory"}]}"#,
)
.unwrap();
let session = SessionManager::new(temp.path().join("sessions"))
.create()
.unwrap();
let hooks = HookRuntime::new(
temp.path(),
HookSettings {
enabled: true,
provider_context_injection: true,
after_tool: vec![HookDefinition {
label: Some("after-context".into()),
command: "cat hook.out".into(),
..HookDefinition::default()
}],
..HookSettings::default()
},
true,
)
.unwrap();
let mut settings = crate::config::Settings::default();
settings.instructions.subdir_discovery = true;
let tools = ToolRuntime::new_with_full_settings_and_mcp(
temp.path(),
crate::config::McPaths::from_root(temp.path().join("mc")),
settings,
None,
)
.unwrap();
let mut subdir_state = super::super::subdir_instructions::SubdirInstructionState::default();
let mut session_persistence = SessionPersistence::new(Some(&session), temp.path());
let mut output_sink = None;
let call = ToolCall {
id: "call_1".to_string(),
name: "read".to_string(),
arguments: json!({"paths":["sub/file.txt"]}),
};
session_persistence
.try_record(
SessionEventKind::ToolCall,
json!({"id": call.id.clone(), "name": call.name.clone(), "arguments": call.arguments.clone()}),
&mut output_sink,
)
.unwrap();
let outcome = execute_tool_call(
Some(&tools),
Some(&hooks),
&mut session_persistence,
&mut output_sink,
Some(&mut subdir_state),
call,
ToolDispatchContext::new(Some(ActivityId::new("call_1")), None),
)
.unwrap();
let ToolExecutionOutcome::Dispatched {
subdir_context_items,
after_hook_context_items,
..
} = outcome
else {
panic!("expected dispatched outcome");
};
assert_eq!(subdir_context_items.len(), 1);
assert_eq!(after_hook_context_items.len(), 1);
assert_eq!(
event_types(&session),
vec![
"tool_call",
"tool_result",
"provider_context_item",
"subdir_instruction_load",
"hook_lifecycle",
"hook_lifecycle",
"hook_context_injection",
]
);
let events = session.read_events().unwrap();
assert_eq!(events[2].payload["source"], "subdir_agents");
assert!(
events[2].payload["content"]
.as_str()
.unwrap()
.contains("subdir rules")
);
assert_eq!(events[6].payload["status"], "success");
}
#[cfg(unix)]
#[test]
fn after_hook_provider_context_records_audit_and_context_events() {
let temp = tempfile::TempDir::new().unwrap();
fs::write(
temp.path().join("hook.out"),
r#"{"context_items":[{"role":"user","content":"session memory"}]}"#,
)
.unwrap();
let session = SessionManager::new(temp.path().join("sessions"))
.create()
.unwrap();
let hooks = HookRuntime::new(
temp.path(),
HookSettings {
enabled: true,
provider_context_injection: true,
after_tool: vec![HookDefinition {
label: Some("after-context".into()),
command: "cat hook.out".into(),
..HookDefinition::default()
}],
..HookSettings::default()
},
true,
)
.unwrap();
let mut session_persistence = SessionPersistence::new(Some(&session), temp.path());
let mut output_sink = None;
let call = call("read");
session_persistence
.try_record(
SessionEventKind::ToolCall,
json!({"id": call.id.clone(), "name": call.name.clone(), "arguments": call.arguments.clone()}),
&mut output_sink,
)
.unwrap();
let outcome = execute_tool_call(
None,
Some(&hooks),
&mut session_persistence,
&mut output_sink,
None,
call,
ToolDispatchContext::new(Some(ActivityId::new("call_1")), None),
)
.unwrap();
let ToolExecutionOutcome::Dispatched {
after_hook_context_items,
..
} = outcome
else {
panic!("expected dispatched outcome");
};
assert_eq!(after_hook_context_items.len(), 1);
assert_eq!(
event_types(&session),
vec![
"tool_call",
"tool_result",
"hook_lifecycle",
"hook_lifecycle",
"hook_context_injection",
]
);
let events = session.read_events().unwrap();
assert_eq!(events[4].payload["status"], "success");
assert_eq!(events[4].payload["item_count"], 1);
assert!(!events[4].payload.to_string().contains("session memory"));
}
#[cfg(unix)]
#[test]
fn after_hook_fail_policy_records_hook_failed_audit_without_provider_context_event() {
let temp = tempfile::TempDir::new().unwrap();
fs::write(
temp.path().join("hook.out"),
r#"{"context_items":[{"role":"user","content":"do not persist as context"}]}"#,
)
.unwrap();
let session = SessionManager::new(temp.path().join("sessions"))
.create()
.unwrap();
let hooks = HookRuntime::new(
temp.path(),
HookSettings {
enabled: true,
provider_context_injection: true,
after_tool: vec![HookDefinition {
label: Some("after-context".into()),
command: "cat hook.out; exit 9".into(),
failure_policy: Some(HookFailurePolicy::Fail),
..HookDefinition::default()
}],
..HookSettings::default()
},
true,
)
.unwrap();
let mut session_persistence = SessionPersistence::new(Some(&session), temp.path());
let mut output_sink = None;
let call = call("read");
let outcome = execute_tool_call(
None,
Some(&hooks),
&mut session_persistence,
&mut output_sink,
None,
call,
ToolDispatchContext::new(Some(ActivityId::new("call_1")), None),
)
.unwrap();
let ToolExecutionOutcome::Dispatched {
after_hook_context_items,
after_hook_failure,
..
} = outcome
else {
panic!("expected dispatched outcome");
};
assert!(after_hook_context_items.is_empty());
assert!(after_hook_failure.is_some());
let events = session.read_events().unwrap();
assert!(events.iter().any(|event| {
event.event_type == "hook_context_injection" && event.payload["status"] == "hook_failed"
}));
assert!(
!events
.iter()
.any(|event| event.event_type == "provider_context_item")
);
}
#[cfg(unix)]
#[test]
fn before_block_lifecycle_precedes_blocked_tool_result() {
let temp = tempfile::TempDir::new().unwrap();
let session = SessionManager::new(temp.path().join("sessions"))
.create()
.unwrap();
let hooks = HookRuntime::new(
temp.path(),
HookSettings {
enabled: true,
before_tool: vec![HookDefinition {
label: Some("block".into()),
command: "exit 2".into(),
failure_policy: Some(HookFailurePolicy::Block),
..HookDefinition::default()
}],
..HookSettings::default()
},
true,
)
.unwrap();
let mut session_persistence = SessionPersistence::new(Some(&session), temp.path());
let mut output_sink = None;
let call = call("bash");
session_persistence
.try_record(
SessionEventKind::ToolCall,
json!({"id": call.id.clone(), "name": call.name.clone(), "arguments": call.arguments.clone()}),
&mut output_sink,
)
.unwrap();
execute_tool_call(
None,
Some(&hooks),
&mut session_persistence,
&mut output_sink,
None,
call,
ToolDispatchContext::new(Some(ActivityId::new("call_1")), None),
)
.unwrap();
assert_eq!(
event_types(&session),
vec![
"tool_call",
"hook_lifecycle",
"hook_lifecycle",
"hook_diagnostic",
"tool_result",
]
);
let events = session.read_events().unwrap();
assert_eq!(events[2].payload["status"], "failed");
assert_eq!(events[2].payload["policy"], "block");
assert!(
events[4].payload["result"]["metadata"]["hook_blocked"]
.as_bool()
.unwrap()
);
}
#[cfg(unix)]
#[test]
fn before_fail_lifecycle_records_without_tool_result() {
let temp = tempfile::TempDir::new().unwrap();
let session = SessionManager::new(temp.path().join("sessions"))
.create()
.unwrap();
let hooks = HookRuntime::new(
temp.path(),
HookSettings {
enabled: true,
before_tool: vec![HookDefinition {
label: Some("fail".into()),
command: "exit 2".into(),
failure_policy: Some(HookFailurePolicy::Fail),
..HookDefinition::default()
}],
..HookSettings::default()
},
true,
)
.unwrap();
let mut session_persistence = SessionPersistence::new(Some(&session), temp.path());
let mut output_sink = None;
let call = call("bash");
session_persistence
.try_record(
SessionEventKind::ToolCall,
json!({"id": call.id.clone(), "name": call.name.clone(), "arguments": call.arguments.clone()}),
&mut output_sink,
)
.unwrap();
let outcome = execute_tool_call(
None,
Some(&hooks),
&mut session_persistence,
&mut output_sink,
None,
call,
ToolDispatchContext::new(Some(ActivityId::new("call_1")), None),
)
.unwrap();
assert!(matches!(
outcome,
ToolExecutionOutcome::BeforeHookFailed { .. }
));
assert_eq!(
event_types(&session),
vec![
"tool_call",
"hook_lifecycle",
"hook_lifecycle",
"hook_diagnostic",
]
);
}
#[cfg(unix)]
#[test]
fn after_fail_lifecycle_follows_original_tool_result() {
let temp = tempfile::TempDir::new().unwrap();
let session = SessionManager::new(temp.path().join("sessions"))
.create()
.unwrap();
let hooks = HookRuntime::new(
temp.path(),
HookSettings {
enabled: true,
after_tool: vec![HookDefinition {
label: Some("after-fail".into()),
command: "exit 3".into(),
failure_policy: Some(HookFailurePolicy::Fail),
..HookDefinition::default()
}],
..HookSettings::default()
},
true,
)
.unwrap();
let mut session_persistence = SessionPersistence::new(Some(&session), temp.path());
let mut output_sink = None;
let call = call("read");
session_persistence
.try_record(
SessionEventKind::ToolCall,
json!({"id": call.id.clone(), "name": call.name.clone(), "arguments": call.arguments.clone()}),
&mut output_sink,
)
.unwrap();
let outcome = execute_tool_call(
None,
Some(&hooks),
&mut session_persistence,
&mut output_sink,
None,
call,
ToolDispatchContext::new(Some(ActivityId::new("call_1")), None),
)
.unwrap();
assert!(matches!(
outcome,
ToolExecutionOutcome::Dispatched {
after_hook_failure: Some(_),
..
}
));
assert_eq!(
event_types(&session),
vec![
"tool_call",
"tool_result",
"hook_lifecycle",
"hook_lifecycle",
"hook_diagnostic",
]
);
let events = session.read_events().unwrap();
assert_eq!(events[2].payload["phase"], "after_tool");
assert_eq!(events[3].payload["status"], "failed");
}
#[cfg(unix)]
#[test]
fn parent_subagents_tool_can_record_lifecycle_without_provider_output_change() {
let temp = tempfile::TempDir::new().unwrap();
let session = SessionManager::new(temp.path().join("sessions"))
.create()
.unwrap();
let hooks = HookRuntime::new(
temp.path(),
HookSettings {
enabled: true,
before_tool: vec![HookDefinition {
label: Some("parent-only".into()),
command: "true".into(),
include_tools: vec!["subagents".into()],
..HookDefinition::default()
}],
..HookSettings::default()
},
true,
)
.unwrap();
let mut session_persistence = SessionPersistence::new(Some(&session), temp.path());
let mut output_sink = None;
let call = ToolCall {
id: "subagents_1".to_string(),
name: "subagents".to_string(),
arguments: json!({"tasks":[{"intent":"inspect"}],"concurrency":1}),
};
let outcome = execute_tool_call(
None,
Some(&hooks),
&mut session_persistence,
&mut output_sink,
None,
call,
ToolDispatchContext::new(Some(ActivityId::new("subagents_1")), None),
)
.unwrap();
let ToolExecutionOutcome::Dispatched {
provider_result, ..
} = outcome
else {
panic!("expected dispatched outcome");
};
assert_eq!(provider_result.tool_name, "subagents");
assert!(!provider_result.output.contains("hook_lifecycle"));
assert!(
session
.read_events()
.unwrap()
.iter()
.any(|event| event.event_type == "hook_lifecycle"
&& event.payload["target_tool"] == "subagents")
);
}
#[cfg(unix)]
#[test]
fn no_session_records_no_hook_lifecycle_file() {
let temp = tempfile::TempDir::new().unwrap();
let hooks = HookRuntime::new(
temp.path(),
HookSettings {
enabled: true,
before_tool: vec![HookDefinition {
label: Some("no-session".into()),
command: "true".into(),
..HookDefinition::default()
}],
..HookSettings::default()
},
true,
)
.unwrap();
let mut session_persistence = SessionPersistence::new(None, temp.path());
let mut output_sink = None;
let call = call("read");
execute_tool_call(
None,
Some(&hooks),
&mut session_persistence,
&mut output_sink,
None,
call,
ToolDispatchContext::new(Some(ActivityId::new("call_1")), None),
)
.unwrap();
assert!(!temp.path().join("sessions").exists());
}
fn lifecycle_payload_keys(payload: &Value) -> Vec<String> {
let mut keys = payload
.as_object()
.unwrap()
.keys()
.cloned()
.collect::<Vec<_>>();
keys.sort();
keys
}
#[cfg(unix)]
#[test]
fn lifecycle_payload_uses_allowlisted_keys_only() {
let temp = tempfile::TempDir::new().unwrap();
let hooks = HookRuntime::new(
temp.path(),
HookSettings {
enabled: true,
before_tool: vec![HookDefinition {
label: Some("audit".into()),
command: "true".into(),
..HookDefinition::default()
}],
..HookSettings::default()
},
true,
)
.unwrap();
let outcome = hooks.run_before(&call("bash"));
let payload = outcome.lifecycle_records[0].to_session_payload();
assert_eq!(
lifecycle_payload_keys(&payload),
vec![
"activity_id",
"category",
"elapsed_ms",
"hook_index",
"label",
"message",
"phase",
"policy",
"status",
"target_ran",
"target_tool",
"tool_call_id",
]
);
}
}