tandem-server 0.6.9

HTTP server for Tandem engine APIs
use super::*;

#[tokio::test]
async fn orchestration_tool_catalog_is_complete_and_handoff_targets_are_not_agent_selected() {
    let tools = crate::http::orchestration_tools::orchestration_tools(test_state().await);
    let schemas = tools.iter().map(|tool| tool.schema()).collect::<Vec<_>>();
    let names = schemas
        .iter()
        .map(|schema| schema.name.as_str())
        .collect::<Vec<_>>();
    assert_eq!(
        names,
        vec![
            "orchestration_create_draft",
            "orchestration_validate",
            "orchestration_publish",
            "goal_start",
            "goal_get",
            "goal_cancel",
            "handoff_emit",
            "handoff_approve",
            "wait_inspect",
            "wait_resolve",
        ]
    );
    let handoff = schemas
        .iter()
        .find(|schema| schema.name == "handoff_emit")
        .unwrap();
    let properties = handoff.input_schema["properties"].as_object().unwrap();
    assert!(properties.contains_key("transition_key"));
    assert!(!properties.contains_key("target_automation_id"));
    assert!(!properties.contains_key("target_node_id"));
}

#[tokio::test]
async fn approval_and_wait_resolution_tools_fail_closed_without_explicit_authority() {
    let tools = crate::http::orchestration_tools::orchestration_tools(test_state().await);
    for (name, args) in [
        (
            "handoff_approve",
            json!({
                "goal_id": "goal-1",
                "handoff_id": "handoff-1",
                "decision": "approve",
                "idempotency_key": "approve-1"
            }),
        ),
        (
            "wait_resolve",
            json!({
                "goal_id": "goal-1",
                "wait_id": "wait-1",
                "resolution": {"approved": true},
                "idempotency_key": "resolve-1"
            }),
        ),
    ] {
        let tool = tools
            .iter()
            .find(|tool| tool.schema().name == name)
            .unwrap();
        let error = tool
            .execute_for_tenant(args, TenantContext::local_implicit())
            .await
            .expect_err("authority-free mutation must fail closed");
        assert!(error.to_string().contains("lacks orchestration."));
    }
}

#[tokio::test]
async fn orchestration_create_tool_replays_matching_idempotency_key() {
    let directory = tempfile::tempdir().unwrap();
    let mut state = test_state().await;
    let runs_path = directory.path().join("automation_v2_runs.json");
    state.automation_v2_runs_path = runs_path.clone();
    let tool = crate::http::orchestration_tools::orchestration_tools(state)
        .into_iter()
        .find(|tool| tool.schema().name == "orchestration_create_draft")
        .unwrap();
    let tenant = TenantContext::local_implicit();
    let args = json!({
        "orchestration_id": "mcp-loop",
        "name": "MCP loop",
        "root_node_id": "done",
        "nodes": [{
            "node_id": "done",
            "name": "Done",
            "kind": "terminal",
            "outcome": "complete"
        }],
        "edges": [],
        "idempotency_key": "create-loop-1"
    });
    let mut conflicting = args.clone();
    conflicting["orchestration_id"] = json!("different-loop");

    let first = tool
        .execute_for_tenant(args.clone(), tenant.clone())
        .await
        .unwrap();
    let replay = tool
        .execute_for_tenant(args.clone(), tenant.clone())
        .await
        .unwrap();

    assert_eq!(first.metadata, replay.metadata);

    let store_paths =
        crate::stateful_runtime::OrchestrationStorePaths::from_automation_runs_path(&runs_path);
    let store =
        crate::stateful_runtime::OrchestrationStateStore::open(store_paths.clone()).unwrap();
    let tenant = TenantContext::local_implicit();
    let mut stored = store
        .get_orchestration_draft(&tenant, "mcp-loop")
        .unwrap()
        .unwrap();
    let previous_updated_at_ms = stored.updated_at_ms;
    stored.created_at_ms = 123;
    stored.updated_at_ms = 456;
    store
        .put_orchestration_draft(&stored, Some(previous_updated_at_ms))
        .unwrap();
    let connection = rusqlite::Connection::open(&store_paths.database_path).unwrap();
    connection
        .execute(
            "UPDATE orchestration_tool_requests
             SET response_json = NULL, completed_at_ms = NULL, created_at_ms = 0
             WHERE operation = 'orchestration_create_draft'
               AND idempotency_key = 'create-loop-1'",
            [],
        )
        .unwrap();
    let recovered = tool.execute_for_tenant(args.clone(), tenant).await.unwrap();
    assert_eq!(recovered.metadata["updated_at_ms"], 456);
    assert_eq!(recovered.metadata["orchestration"]["created_at_ms"], 123);

    let error = tool
        .execute_for_tenant(conflicting, TenantContext::local_implicit())
        .await
        .expect_err("one operation key must not bind multiple drafts");
    assert!(error.to_string().contains("already bound"));
}

#[tokio::test]
async fn orchestration_publish_tool_rejects_archived_drafts() {
    let directory = tempfile::tempdir().unwrap();
    let mut state = test_state().await;
    let runs_path = directory.path().join("automation_v2_runs.json");
    state.automation_v2_runs_path = runs_path.clone();
    let mut tools = crate::http::orchestration_tools::orchestration_tools(state);
    let create_index = tools
        .iter()
        .position(|tool| tool.schema().name == "orchestration_create_draft")
        .unwrap();
    let create = tools.remove(create_index);
    let publish = tools
        .into_iter()
        .find(|tool| tool.schema().name == "orchestration_publish")
        .unwrap();
    let tenant = TenantContext::local_implicit();

    create
        .execute_for_tenant(
            json!({
                "orchestration_id": "archived-loop",
                "name": "Archived loop",
                "root_node_id": "done",
                "nodes": [{
                    "node_id": "done",
                    "name": "Done",
                    "kind": "terminal",
                    "outcome": "complete"
                }],
                "edges": [],
                "idempotency_key": "create-archived-1"
            }),
            tenant.clone(),
        )
        .await
        .unwrap();
    let store =
        crate::stateful_runtime::OrchestrationStateStore::from_automation_runs_path(&runs_path)
            .unwrap();
    let mut draft = store
        .get_orchestration_draft(&tenant, "archived-loop")
        .unwrap()
        .unwrap();
    let expected_updated_at_ms = draft.updated_at_ms;
    draft.status = tandem_automation::OrchestrationStatus::Archived;
    draft.updated_at_ms += 1;
    store
        .put_orchestration_draft(&draft, Some(expected_updated_at_ms))
        .unwrap();

    let error = publish
        .execute_for_tenant(
            json!({
                "orchestration_id": "archived-loop",
                "idempotency_key": "publish-archived-1"
            }),
            tenant,
        )
        .await
        .expect_err("archived drafts must remain unpublishable through MCP");
    assert!(error
        .to_string()
        .contains("archived drafts cannot be published"));
}