chabeau 0.7.3

A full-screen terminal chat interface that connects to various AI APIs for real-time conversations
Documentation
use super::{App, AppActionContext, AppCommand};
use crate::core::app::session::ToolFailureKind;
use crate::mcp::permissions::ToolPermissionDecision;
use serde_json::Value;
use tracing::debug;

#[derive(Debug, Clone)]
pub(super) struct ToolResultMeta {
    pub(super) server_label: Option<String>,
    pub(super) server_id: Option<String>,
    pub(super) tool_call_id: Option<String>,
    pub(super) raw_arguments: Option<String>,
    pub(super) failure_kind: Option<ToolFailureKind>,
}

impl ToolResultMeta {
    pub(super) fn new(
        server_label: Option<String>,
        server_id: Option<String>,
        tool_call_id: Option<String>,
        raw_arguments: Option<String>,
    ) -> Self {
        Self {
            server_label,
            server_id,
            tool_call_id,
            raw_arguments,
            failure_kind: None,
        }
    }
}

#[derive(Debug, Clone)]
pub(super) struct PendingToolError {
    pub(super) tool_name: String,
    pub(super) server_id: Option<String>,
    pub(super) tool_call_id: Option<String>,
    pub(super) raw_arguments: Option<String>,
    pub(super) error: String,
}

pub(super) fn handle_tool_permission_decision(
    app: &mut App,
    decision: ToolPermissionDecision,
    ctx: AppActionContext,
) -> Option<AppCommand> {
    let prompt_tool = app.ui.tool_prompt().map(|prompt| prompt.tool_name.clone());
    debug!(
        decision = ?decision,
        prompt_tool = prompt_tool.as_deref().unwrap_or("<none>"),
        "Tool permission decision received"
    );

    if prompt_tool.as_deref() == Some(crate::mcp::MCP_SAMPLING_TOOL) {
        let request = app.session.tool_pipeline.active_sampling_request.take()?;
        return super::sampling::handle_sampling_permission_decision(app, request, decision, ctx);
    }

    if let Some(request) = app.session.tool_pipeline.active_tool_request.take() {
        app.ui.cancel_tool_prompt();
        app.clear_status();

        match decision {
            ToolPermissionDecision::AllowOnce => {}
            ToolPermissionDecision::AllowSession | ToolPermissionDecision::Block => app
                .mcp_permissions
                .record(&request.server_id, &request.tool_name, decision),
            ToolPermissionDecision::DenyOnce => {}
        }

        if matches!(
            decision,
            ToolPermissionDecision::DenyOnce | ToolPermissionDecision::Block
        ) {
            let message = match decision {
                ToolPermissionDecision::Block => "Tool blocked by user.",
                _ => "Tool denied by user.",
            };
            let server_label = super::resolve_server_label(app, &request.server_id);
            let status = match decision {
                ToolPermissionDecision::Block => {
                    crate::core::app::session::ToolResultStatus::Blocked
                }
                _ => crate::core::app::session::ToolResultStatus::Denied,
            };
            let meta = ToolResultMeta::new(
                Some(server_label),
                Some(request.server_id.clone()),
                request.tool_call_id.clone(),
                Some(request.raw_arguments.clone()),
            );
            super::record_tool_result(
                app,
                &request.tool_name,
                meta,
                message.to_string(),
                status,
                ctx,
            );
            return super::advance_tool_queue(app, ctx);
        }

        app.session.tool_pipeline.active_tool_request = Some(request.clone());
        if super::is_instant_recall_tool(&request.tool_name) {
            return super::handle_instant_recall_tool_request(app, request, ctx);
        }
        super::set_status_for_tool_run(app, &request, ctx);
        return Some(AppCommand::RunMcpTool(request));
    }

    let request = app.session.tool_pipeline.active_sampling_request.take()?;
    super::sampling::handle_sampling_permission_decision(app, request, decision, ctx)
}

pub(super) fn handle_tool_call_completed(
    app: &mut App,
    tool_name: String,
    tool_call_id: Option<String>,
    result: Result<String, String>,
    ctx: AppActionContext,
) -> Option<AppCommand> {
    let Some(active_request) = app.session.tool_pipeline.active_tool_request.as_ref() else {
        app.end_mcp_operation_if_active();
        return None;
    };
    if let (Some(active_id), Some(completed_id)) = (
        active_request.tool_call_id.as_deref(),
        tool_call_id.as_deref(),
    ) {
        if active_id != completed_id {
            return None;
        }
    }
    let request = app
        .session
        .tool_pipeline
        .active_tool_request
        .take()
        .expect("active tool request should still be present");
    let server_label = Some(super::resolve_server_label(app, &request.server_id));
    app.end_mcp_operation_if_active();

    match result {
        Ok(payload) => {
            let is_tool_error = is_tool_error_payload(&payload);
            let mut meta = ToolResultMeta::new(
                server_label,
                Some(request.server_id.clone()),
                tool_call_id.clone(),
                Some(request.raw_arguments.clone()),
            );
            if is_tool_error {
                meta.failure_kind = Some(ToolFailureKind::ToolError);
            }
            super::record_tool_result(
                app,
                &tool_name,
                meta,
                payload,
                if is_tool_error {
                    crate::core::app::session::ToolResultStatus::Error
                } else {
                    crate::core::app::session::ToolResultStatus::Success
                },
                ctx,
            );
        }
        Err(err) => {
            let mut meta = ToolResultMeta::new(
                server_label,
                Some(request.server_id.clone()),
                tool_call_id,
                Some(request.raw_arguments.clone()),
            );
            meta.failure_kind = Some(ToolFailureKind::ToolCallFailure);
            super::record_tool_result(
                app,
                &tool_name,
                meta,
                format!("Tool call failure: {err}"),
                crate::core::app::session::ToolResultStatus::Error,
                ctx,
            );
        }
    }

    super::advance_tool_queue(app, ctx)
}

fn is_tool_error_payload(payload: &str) -> bool {
    serde_json::from_str::<Value>(payload)
        .ok()
        .and_then(|value| value.get("isError").and_then(Value::as_bool))
        .unwrap_or(false)
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::core::app::session::{ToolCallRequest, ToolResultStatus};
    use crate::utils::test_utils::create_test_app;

    fn default_ctx() -> AppActionContext {
        AppActionContext {
            term_width: 80,
            term_height: 24,
        }
    }

    #[test]
    fn tool_call_completed_flags_tool_error_payloads() {
        let mut app = create_test_app();
        let ctx = default_ctx();
        app.session.tool_pipeline.active_tool_request = Some(ToolCallRequest {
            server_id: "alpha".to_string(),
            tool_name: "lookup".to_string(),
            arguments: None,
            raw_arguments: "{}".to_string(),
            tool_call_id: Some("call-1".to_string()),
        });

        let payload = serde_json::json!({"content": [], "isError": true}).to_string();
        let result = handle_tool_call_completed(
            &mut app,
            "lookup".to_string(),
            Some("call-1".to_string()),
            Ok(payload),
            ctx,
        );
        assert!(result.is_none());

        let record = app
            .session
            .tool_pipeline
            .tool_result_history
            .last()
            .expect("record");
        assert_eq!(record.status, ToolResultStatus::Error);
        assert_eq!(record.failure_kind, Some(ToolFailureKind::ToolError));
    }

    #[test]
    fn tool_call_completed_ignores_stale_completion_without_active_request() {
        let mut app = create_test_app();
        app.session.active_assistant_message_index = Some(2);
        app.begin_mcp_operation();
        let ctx = default_ctx();

        let result = handle_tool_call_completed(
            &mut app,
            "lookup".to_string(),
            Some("call-old".to_string()),
            Ok("{\"ok\":true}".to_string()),
            ctx,
        );

        assert!(result.is_none());
        assert!(app.session.tool_pipeline.tool_result_history.is_empty());
        assert!(!app.ui.messages.iter().any(|message| matches!(
            message.role,
            crate::core::message::TranscriptRole::ToolResult
        )));
    }

    #[test]
    fn tool_call_completed_ignores_stale_completion_with_mismatched_call_id() {
        let mut app = create_test_app();
        let ctx = default_ctx();
        app.session.tool_pipeline.active_tool_request = Some(ToolCallRequest {
            server_id: "alpha".to_string(),
            tool_name: "lookup".to_string(),
            arguments: None,
            raw_arguments: "{}".to_string(),
            tool_call_id: Some("call-current".to_string()),
        });

        let result = handle_tool_call_completed(
            &mut app,
            "lookup".to_string(),
            Some("call-stale".to_string()),
            Ok("{\"ok\":true}".to_string()),
            ctx,
        );

        assert!(result.is_none());
        assert!(app.session.tool_pipeline.tool_result_history.is_empty());
        assert_eq!(
            app.session
                .tool_pipeline
                .active_tool_request
                .as_ref()
                .and_then(|request| request.tool_call_id.as_deref()),
            Some("call-current")
        );
    }
}