bamboo-engine 2026.4.30

Execution engine and orchestration for the Bamboo agent framework
Documentation
use tokio::sync::mpsc;

use super::maybe_handle_user_question_tool;
use crate::runtime::config::AgentLoopConfig;
use bamboo_agent_core::tools::{FunctionCall, ToolCall, ToolResult};
use bamboo_agent_core::{AgentEvent, Role, Session};

#[tokio::test]
async fn maybe_handle_user_question_tool_sets_pending_question_and_emits_events() {
    let tool_call = ToolCall {
        id: "ask-1".to_string(),
        tool_type: "function".to_string(),
        function: FunctionCall {
            name: "conclusion_with_options".to_string(),
            arguments: "{}".to_string(),
        },
    };
    let result = ToolResult {
        success: true,
        result: serde_json::json!({
            "question": "Continue?",
            "options": ["Yes", "No"],
            "allow_custom": false
        })
        .to_string(),
        display_preference: Some("conclusion_with_options".to_string()),
    };

    let (tx, mut rx) = mpsc::channel(8);
    let mut session = Session::new("session-1", "model");

    let handled = maybe_handle_user_question_tool(
        &tool_call,
        &result,
        &mut session,
        &tx,
        None,
        "session-1",
        "round-1",
        &AgentLoopConfig::default(),
    )
    .await;

    assert!(handled);
    assert_eq!(session.messages.len(), 1);
    assert!(matches!(session.messages[0].role, Role::Tool));
    let saved_payload: serde_json::Value =
        serde_json::from_str(&session.messages[0].content).expect("saved tool result payload");
    assert_eq!(saved_payload["question"], "Continue?");
    assert_eq!(saved_payload["allow_custom"], false);

    let pending = session
        .pending_question
        .as_ref()
        .expect("pending question should be set");
    assert_eq!(pending.tool_call_id, "ask-1");
    assert_eq!(pending.question, "Continue?");
    assert_eq!(pending.options, vec!["Yes".to_string(), "No".to_string()]);
    assert!(!pending.allow_custom);

    let first_event = rx.recv().await.expect("first event");
    match first_event {
        AgentEvent::ToolComplete {
            tool_call_id,
            result: event_result,
        } => {
            assert_eq!(tool_call_id, "ask-1");
            assert!(event_result.success);
        }
        other => panic!("unexpected first event: {other:?}"),
    }

    let second_event = rx.recv().await.expect("second event");
    match second_event {
        AgentEvent::NeedClarification {
            question,
            options,
            tool_call_id,
            allow_custom,
        } => {
            assert_eq!(question, "Continue?");
            assert_eq!(options, Some(vec!["Yes".to_string(), "No".to_string()]));
            assert_eq!(tool_call_id, Some("ask-1".to_string()));
            assert!(!allow_custom);
        }
        other => panic!("unexpected second event: {other:?}"),
    }
}

#[tokio::test]
async fn maybe_handle_user_question_tool_handles_request_permissions() {
    let tool_call = ToolCall {
        id: "perm-1".to_string(),
        tool_type: "function".to_string(),
        function: FunctionCall {
            name: "request_permissions".to_string(),
            arguments: "{}".to_string(),
        },
    };
    let result = ToolResult {
        success: true,
        result: serde_json::json!({
            "status": "awaiting_permission_approval",
            "question": "**Permission Request**\n\nNeed write access\n\n**Requested permissions:**\n- write_file `/tmp/deploy`\n",
            "reason": "Need write access",
            "permissions": [{"type": "write_file", "resource": "/tmp/deploy", "risk_level": "Medium Risk"}],
            "options": ["Approve", "Deny"],
            "allow_custom": false
        })
        .to_string(),
        display_preference: Some("request_permissions".to_string()),
    };

    let (tx, mut rx) = mpsc::channel(8);
    let mut session = Session::new("session-perm", "model");

    let handled = maybe_handle_user_question_tool(
        &tool_call,
        &result,
        &mut session,
        &tx,
        None,
        "session-perm",
        "round-1",
        &AgentLoopConfig::default(),
    )
    .await;

    assert!(
        handled,
        "request_permissions should be handled as a pause-tool"
    );
    assert_eq!(session.messages.len(), 1);
    assert!(matches!(session.messages[0].role, Role::Tool));

    let pending = session
        .pending_question
        .as_ref()
        .expect("pending question should be set for request_permissions");
    assert_eq!(pending.tool_call_id, "perm-1");
    assert!(pending.question.contains("Permission Request"));
    assert_eq!(
        pending.options,
        vec!["Approve".to_string(), "Deny".to_string()]
    );
    assert!(!pending.allow_custom);

    let first_event = rx.recv().await.expect("first event");
    assert!(matches!(first_event, AgentEvent::ToolComplete { .. }));

    let second_event = rx.recv().await.expect("second event");
    match second_event {
        AgentEvent::NeedClarification {
            question,
            options,
            tool_call_id,
            allow_custom,
        } => {
            assert!(question.contains("Permission Request"));
            assert_eq!(
                options,
                Some(vec!["Approve".to_string(), "Deny".to_string()])
            );
            assert_eq!(tool_call_id, Some("perm-1".to_string()));
            assert!(!allow_custom);
        }
        other => panic!("unexpected second event: {other:?}"),
    }
}

#[tokio::test]
async fn maybe_handle_user_question_tool_ignores_unrelated_tool_calls() {
    let tool_call = ToolCall {
        id: "read-1".to_string(),
        tool_type: "function".to_string(),
        function: FunctionCall {
            name: "Read".to_string(),
            arguments: "{}".to_string(),
        },
    };
    let result = ToolResult {
        success: true,
        result: "{}".to_string(),
        display_preference: None,
    };

    let (tx, mut rx) = mpsc::channel(4);
    let mut session = Session::new("session-1", "model");

    let handled = maybe_handle_user_question_tool(
        &tool_call,
        &result,
        &mut session,
        &tx,
        None,
        "session-1",
        "round-1",
        &AgentLoopConfig::default(),
    )
    .await;

    assert!(!handled);
    assert!(session.pending_question.is_none());
    assert!(session.messages.is_empty());
    assert!(rx.try_recv().is_err());
}