tools-kit 0.0.2

Portable Local and MCP tool execution with narrow extension traits.
Documentation
use serde_json::json;
use tools_kit::{
    ToolError, ToolHandlerBackend, Toolset, ToolsetBackend,
    declaration::{ToolInfo, ToolName},
    execution::{ToolHandlerInput, ToolHandlerOutput, ToolInvocation, ToolResult},
    interaction::{ToolInteractionCapabilities, ToolInteractionKind, ToolInteractionResponse},
    local::ToolRegistration,
    schema::JsonSchema,
};

fn interactive_toolset() -> Toolset {
    let mut info = ToolInfo::new(
        ToolName::new("ask").unwrap(),
        JsonSchema::strict(json!({"type":"object"})),
    );
    info.interactions = ToolInteractionCapabilities {
        form: false,
        form_schema_validation: false,
        text: true,
        multiline_text: false,
    };
    Toolset::local(vec![ToolRegistration::interactive(
        info,
        |_invocation, context| async move {
            let answer = context.request_text("answer?", false).await?;
            Ok(ToolResult::text(answer))
        },
    )])
    .unwrap()
}

#[tokio::test]
async fn local_handler_resumes_exact_pending_interaction() {
    let toolset = interactive_toolset();
    let mut handler = toolset
        .tool_handler(ToolName::new("ask").unwrap())
        .await
        .unwrap();
    let output = handler
        .step(ToolHandlerInput::Invoke(ToolInvocation {
            call_id: Some("call".to_owned()),
            arguments: json!({}),
        }))
        .await
        .unwrap();
    let ToolHandlerOutput::Interaction(request) = output else {
        panic!("handler should request text");
    };
    assert!(matches!(request.kind, ToolInteractionKind::Text { .. }));
    let output = handler
        .step(ToolHandlerInput::Interaction(
            ToolInteractionResponse::submit(request.id, json!("accepted")),
        ))
        .await
        .unwrap();
    let ToolHandlerOutput::Completed(result) = output else {
        panic!("handler should complete");
    };
    assert_eq!(result, ToolResult::text("accepted"));
}

#[tokio::test]
async fn mismatched_interaction_id_is_rejected_without_losing_pending_state() {
    let toolset = interactive_toolset();
    let mut handler = toolset
        .tool_handler(ToolName::new("ask").unwrap())
        .await
        .unwrap();
    let ToolHandlerOutput::Interaction(request) = handler
        .step(ToolHandlerInput::Invoke(ToolInvocation {
            call_id: None,
            arguments: json!({}),
        }))
        .await
        .unwrap()
    else {
        panic!("handler should request text");
    };
    let error = handler
        .step(ToolHandlerInput::Interaction(
            ToolInteractionResponse::submit(Default::default(), json!("wrong")),
        ))
        .await
        .unwrap_err();
    assert!(matches!(error, ToolError::InvalidInteraction { .. }));
    assert!(matches!(
        handler
            .step(ToolHandlerInput::Interaction(
                ToolInteractionResponse::submit(request.id, json!("right")),
            ))
            .await
            .unwrap(),
        ToolHandlerOutput::Completed(_)
    ));
}