magi-code 0.96.1

Repository-aware CLI coding agent for terminal work
Documentation
use super::*;

pub(in crate::agent) 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(in crate::agent) 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(in crate::agent) 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 {
        parent_activity_id.child(format!(
            "{phase}-provider-context-injection-{}",
            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(in crate::agent) 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(())
}

/// Outcome of running `after_assistant` / `after_reasoning` hooks.
pub(in crate::agent) struct MessageHookOutcome {
    pub(in crate::agent) context_items: Vec<ProviderConversationItem>,
    pub(in crate::agent) failure: Option<HookDiagnostic>,
}

/// Run message-phase hooks (`after_assistant` or `after_reasoning`).
///
/// Mirrors the tool after-hook flow: emit lifecycle/injection audit records,
/// collect provider-visible context items, and surface policy failures.
#[expect(clippy::too_many_arguments)]
pub(in crate::agent) 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,
    })
}