bamboo-server 2026.5.1

HTTP server and API layer for the Bamboo agent framework
Documentation
use crate::{app_state::AppState, error::AppError};
use actix_web::{body::to_bytes, http::StatusCode, web};
use bamboo_tools::BuiltinToolExecutor;

use super::tools::{resolve_session_identifier, select_tools_by_allowlist, to_openai_tools};
use super::types::FilteredToolsQuery;

#[test]
fn resolve_session_identifier_prefers_session_id() {
    let query = FilteredToolsQuery {
        session_id: Some("session-123".to_string()),
        chat_id: Some("chat-123".to_string()),
    };

    assert_eq!(resolve_session_identifier(&query), Some("session-123"));
}

#[test]
fn resolve_session_identifier_falls_back_to_chat_id() {
    let query = FilteredToolsQuery {
        session_id: None,
        chat_id: Some("chat-456".to_string()),
    };

    assert_eq!(resolve_session_identifier(&query), Some("chat-456"));
}

#[test]
fn select_tools_by_allowlist_returns_all_when_allowlist_is_empty() {
    let all_tools = BuiltinToolExecutor::tool_schemas();
    let expected_len = all_tools.len();

    let selected = select_tools_by_allowlist(all_tools, &[]);
    assert_eq!(selected.len(), expected_len);
}

#[test]
fn select_tools_by_allowlist_filters_to_exact_tool_names() {
    let all_tools = BuiltinToolExecutor::tool_schemas();
    let allowed_tool = all_tools
        .first()
        .expect("Builtin tools should not be empty")
        .function
        .name
        .clone();
    let allowlist = vec![allowed_tool.clone()];

    let selected = select_tools_by_allowlist(all_tools, &allowlist);
    assert!(!selected.is_empty());
    assert!(selected
        .iter()
        .all(|tool| tool.function.name == allowed_tool));
}

#[test]
fn to_openai_tools_maps_function_fields() {
    let mut all_tools = BuiltinToolExecutor::tool_schemas();
    let first = all_tools
        .drain(..1)
        .next()
        .expect("Builtin tools should not be empty");
    let expected_name = first.function.name.clone();
    let expected_description = first.function.description.clone();

    let mapped = to_openai_tools(vec![first]);
    assert_eq!(mapped.len(), 1);
    assert_eq!(mapped[0].tool_type, "function");
    assert_eq!(mapped[0].function.name, expected_name);
    assert_eq!(mapped[0].function.description, expected_description);
}

#[actix_web::test]
async fn skill_config_registers_expected_routes() {
    let app = actix_web::test::init_service(actix_web::App::new().configure(super::config)).await;

    for uri in [
        "/skills",
        "/skills/test-id",
        "/skills/available-tools",
        "/skills/filtered-tools",
        "/skills/available-workflows",
    ] {
        let req = actix_web::test::TestRequest::get().uri(uri).to_request();
        let resp = actix_web::test::call_service(&app, req).await;
        assert_ne!(
            resp.status(),
            StatusCode::NOT_FOUND,
            "expected skill route to be registered: {uri}"
        );
    }
}

#[tokio::test]
async fn get_available_workflows_returns_sorted_workflow_names() {
    let temp_dir = tempfile::tempdir().expect("create temp dir");
    let app_state = web::Data::new(
        AppState::new(temp_dir.path().to_path_buf())
            .await
            .expect("app state should initialize"),
    );
    let workflows_dir = app_state.app_data_dir.join("workflows");

    tokio::fs::create_dir_all(&workflows_dir)
        .await
        .expect("create workflows dir");
    tokio::fs::write(workflows_dir.join("zeta.yaml"), "# zeta")
        .await
        .expect("write zeta workflow");
    tokio::fs::write(workflows_dir.join("alpha.md"), "# alpha")
        .await
        .expect("write alpha workflow");

    let response = super::get_available_workflows(app_state)
        .await
        .expect("handler should return response");
    assert_eq!(response.status(), StatusCode::OK);

    let body = to_bytes(response.into_body())
        .await
        .expect("serialize response body");
    let payload: serde_json::Value =
        serde_json::from_slice(&body).expect("deserialize workflow response");

    assert_eq!(payload["workflows"], serde_json::json!(["alpha", "zeta"]));
}

#[tokio::test]
async fn get_available_workflows_maps_listing_failures_to_internal_error() {
    let temp_dir = tempfile::tempdir().expect("create temp dir");
    let app_state = web::Data::new(
        AppState::new(temp_dir.path().to_path_buf())
            .await
            .expect("app state should initialize"),
    );
    let workflows_path = app_state.app_data_dir.join("workflows");

    if workflows_path.exists() {
        tokio::fs::remove_dir_all(&workflows_path)
            .await
            .expect("remove workflows dir");
    }
    tokio::fs::write(&workflows_path, "not a directory")
        .await
        .expect("write workflows file");

    let error = super::get_available_workflows(app_state)
        .await
        .expect_err("handler should return error when workflows path is invalid");

    match error {
        AppError::InternalError(inner) => assert!(
            inner.to_string().contains("Failed to list workflows"),
            "unexpected error message: {inner}"
        ),
        other => panic!("expected internal error, got {other:?}"),
    }
}