crabmate 0.5.0

Rust AI agent: OpenAI-compatible chat/completions, function calling, HTTP serve, ops CLI
Documentation
use super::*;
use crate::cm_agent::plan_rewrite::PlanRewriteExhaustedReason;
use crate::cm_agent::plan_semantic::PlanSemanticLlmOutcome;
use crate::cm_types::{FunctionCall, MessageContent, ToolCall};

fn minimal_cfg() -> AgentConfig {
    crate::cm_config::load_config(None).expect("embed default config")
}

fn gate_args<'a>(
    msg: &'a Message,
    messages: &'a [Message],
    cfg: &'a AgentConfig,
    policy: FinalPlanRequirementMode,
    source: PlanRequirementSource,
    attempts: usize,
    max_attempts: usize,
) -> FinalPlanGateArgs<'a> {
    FinalPlanGateArgs {
        msg,
        messages,
        cfg,
        workspace_is_set: false,
        gate_context: build_final_plan_gate_context(policy, source),
        final_plan_require_strict_workflow_node_coverage: false,
        final_plan_semantic_check_enabled: false,
        final_plan_semantic_check_max_non_readonly_tools: 0,
        layer_need: None,
        validate_only_binding_ids: None,
        plan_rewrite_attempts: attempts,
        plan_rewrite_max_attempts: max_attempts,
    }
}

#[test]
fn gate_route_accept_ok_when_plan_valid() {
    let cfg = minimal_cfg();
    let ok = Message {
        role: "assistant".to_string(),
        content: Some(
            r#"```json
{"type":"agent_reply_plan","version":1,"steps":[{"id":"s1","description":"x"}]}
```"#
                .into(),
        ),
        reasoning_content: None,
        reasoning_details: None,
        tool_calls: None,
        name: None,
        tool_call_id: None,
    };
    let hist: Vec<Message> = vec![];
    let o = step_check_structured_plan(gate_args(
        &ok,
        &hist,
        &cfg,
        FinalPlanRequirementMode::WorkflowReflection,
        PlanRequirementSource::WorkflowReflection,
        0,
        2,
    ));
    assert_eq!(o.route, FinalPlanGateRoute::AcceptStructuredPlanOk);
    assert_eq!(
        o.decision_reason,
        FinalPlanGateDecisionReason::StructuredPlanAccepted
    );
    assert!(matches!(o.after, AfterFinalAssistant::StopTurn));
}

#[test]
fn gate_route_rewrite_when_parse_fails() {
    let cfg = minimal_cfg();
    let bad = Message {
        role: "assistant".to_string(),
        content: Some(MessageContent::Text("no json plan".to_string())),
        reasoning_content: None,
        reasoning_details: None,
        tool_calls: None,
        name: None,
        tool_call_id: None,
    };
    let hist: Vec<Message> = vec![];
    let o = step_check_structured_plan(gate_args(
        &bad,
        &hist,
        &cfg,
        FinalPlanRequirementMode::WorkflowReflection,
        PlanRequirementSource::WorkflowReflection,
        0,
        2,
    ));
    assert_eq!(o.route, FinalPlanGateRoute::SemanticsFailedRequestRewrite);
    assert_eq!(
        o.decision_reason,
        FinalPlanGateDecisionReason::PlanParseFailed
    );
    match &o.after {
        AfterFinalAssistant::RequestPlanRewrite(m) => {
            let body = crate::cm_types::message_content_as_str(&m.content).unwrap_or("");
            assert!(
                body.contains("校验反馈") && body.contains("not_found"),
                "parse-fail rewrite should echo error code; got:\n{body}"
            );
            assert!(
                !body.contains("expect_json_path_equals"),
                "rewrite must stay brief"
            );
        }
        other => panic!("expected RequestPlanRewrite, got {other:?}"),
    }
    assert_eq!(o.next_plan_rewrite_count, Some(1));
}

#[test]
fn gate_route_rewrite_when_layer_count_mismatches_includes_feedback() {
    let cfg = minimal_cfg();
    let one_step = Message {
        role: "assistant".to_string(),
        content: Some(
            r#"```json
{"type":"agent_reply_plan","version":1,"steps":[{"id":"s1","description":"only one step"}]}
```"#
                .into(),
        ),
        reasoning_content: None,
        reasoning_details: None,
        tool_calls: None,
        name: None,
        tool_call_id: None,
    };
    let hist: Vec<Message> = vec![];
    let mut args = gate_args(
        &one_step,
        &hist,
        &cfg,
        FinalPlanRequirementMode::WorkflowReflection,
        PlanRequirementSource::WorkflowReflection,
        0,
        2,
    );
    args.layer_need = Some(2);
    let o = step_check_structured_plan(args);
    assert_eq!(o.route, FinalPlanGateRoute::SemanticsFailedRequestRewrite);
    assert_eq!(
        o.decision_reason,
        FinalPlanGateDecisionReason::StaticSemanticsFailed
    );
    match &o.after {
        AfterFinalAssistant::RequestPlanRewrite(m) => {
            let body = crate::cm_types::message_content_as_str(&m.content).unwrap_or("");
            assert!(
                body.contains("校验反馈")
                    && body.contains("plan_layer_count_mismatch")
                    && body.contains("need=2")
                    && body.contains("got=1"),
                "layer-mismatch rewrite should echo feedback codes; got:\n{body}"
            );
            assert!(
                !body.contains("expect_json_path_equals"),
                "rewrite must stay brief"
            );
        }
        other => panic!("expected RequestPlanRewrite, got {other:?}"),
    }
    assert_eq!(o.next_plan_rewrite_count, Some(1));
}

#[test]
fn gate_route_exhausted_when_attempts_maxed() {
    let cfg = minimal_cfg();
    let bad = Message {
        role: "assistant".to_string(),
        content: Some(MessageContent::Text("no json plan".to_string())),
        reasoning_content: None,
        reasoning_details: None,
        tool_calls: None,
        name: None,
        tool_call_id: None,
    };
    let hist: Vec<Message> = vec![];
    let o = step_check_structured_plan(gate_args(
        &bad,
        &hist,
        &cfg,
        FinalPlanRequirementMode::WorkflowReflection,
        PlanRequirementSource::WorkflowReflection,
        2,
        2,
    ));
    assert_eq!(o.route, FinalPlanGateRoute::SemanticsFailedRewriteExhausted);
    assert!(matches!(
        o.after,
        AfterFinalAssistant::StopTurnPlanRewriteExhausted {
            reason: PlanRewriteExhaustedReason::PlanMissing
        }
    ));
}

#[test]
fn resolve_phase_never_is_no_requirement() {
    let ctx = build_final_plan_gate_context(
        FinalPlanRequirementMode::Never,
        PlanRequirementSource::WorkflowReflection,
    );
    assert_eq!(ctx.phase, FinalPlanGatePhase::NoRequirement);
}

#[test]
fn run_gate_no_requirement_returns_stop_turn() {
    let cfg = minimal_cfg();
    let msg = Message {
        role: "assistant".to_string(),
        content: Some("x".into()),
        reasoning_content: None,
        reasoning_details: None,
        tool_calls: None,
        name: None,
        tool_call_id: None,
    };
    let o = run_final_plan_gate(
        FinalPlanGatePhase::NoRequirement,
        FinalPlanGateEvent::FinalAssistantArrived,
        gate_args(
            &msg,
            &[],
            &cfg,
            FinalPlanRequirementMode::Never,
            PlanRequirementSource::None,
            0,
            2,
        ),
    );
    assert_eq!(o.route, FinalPlanGateRoute::StopNoRequirement);
    assert_eq!(
        o.decision_reason,
        FinalPlanGateDecisionReason::PolicyNoRequirement
    );
    assert!(matches!(o.after, AfterFinalAssistant::StopTurn));
}

#[test]
fn gate_route_pending_semantic_when_digest_present() {
    let cfg = minimal_cfg();
    let ok = Message {
        role: "assistant".to_string(),
        content: Some(
            r#"```json
{"type":"agent_reply_plan","version":1,"steps":[{"id":"s1","description":"x"}]}
```"#
                .into(),
        ),
        reasoning_content: None,
        reasoning_details: None,
        tool_calls: None,
        name: None,
        tool_call_id: None,
    };
    let hist = vec![
        Message {
            role: "assistant".to_string(),
            content: None,
            reasoning_content: None,
            reasoning_details: None,
            tool_calls: Some(vec![ToolCall {
                id: "tc0".to_string(),
                typ: "function".to_string(),
                function: FunctionCall {
                    name: "read_file".to_string(),
                    arguments: r#"{"path":"a.rs"}"#.to_string(),
                },
            }]),
            name: None,
            tool_call_id: None,
        },
        Message {
            role: "tool".to_string(),
            content: Some("file contents".into()),
            reasoning_content: None,
            reasoning_details: None,
            tool_calls: None,
            name: None,
            tool_call_id: Some("tc0".to_string()),
        },
    ];
    let o = step_check_structured_plan(FinalPlanGateArgs {
        msg: &ok,
        messages: &hist,
        cfg: &cfg,
        workspace_is_set: false,
        gate_context: build_final_plan_gate_context(
            FinalPlanRequirementMode::WorkflowReflection,
            PlanRequirementSource::WorkflowReflection,
        ),
        final_plan_require_strict_workflow_node_coverage: false,
        final_plan_semantic_check_enabled: true,
        final_plan_semantic_check_max_non_readonly_tools: 4,
        layer_need: None,
        validate_only_binding_ids: None,
        plan_rewrite_attempts: 0,
        plan_rewrite_max_attempts: 2,
    });
    assert_eq!(o.route, FinalPlanGateRoute::PendingSemanticConsistencyLlm);
    assert_eq!(
        o.decision_reason,
        FinalPlanGateDecisionReason::PendingSemanticConsistencyLlm
    );
    assert!(matches!(
        o.after,
        AfterFinalAssistant::StopTurnPendingPlanConsistencyLlm { .. }
    ));
}

#[test]
fn semantic_completed_consistent_stops() {
    let o = run_final_plan_gate_semantic_completed(
        &PlanSemanticLlmOutcome {
            consistent: true,
            violation_codes: vec![],
            rationale: None,
            user_cancelled: false,
        },
        0,
        3,
    );
    assert_eq!(o.route, FinalPlanGateRoute::SemanticConsistencyAcceptedStop);
    assert!(matches!(o.after, AfterFinalAssistant::StopTurn));
    assert_eq!(o.next_plan_rewrite_count, None);
}

#[test]
fn semantic_completed_inconsistent_rewrites() {
    let o = run_final_plan_gate_semantic_completed(
        &PlanSemanticLlmOutcome {
            consistent: false,
            violation_codes: vec!["x".into()],
            rationale: Some("r".into()),
            user_cancelled: false,
        },
        1,
        3,
    );
    assert_eq!(o.route, FinalPlanGateRoute::SemanticMismatchRequestRewrite);
    assert!(matches!(
        o.after,
        AfterFinalAssistant::RequestPlanRewrite(_)
    ));
    assert_eq!(o.next_plan_rewrite_count, Some(2));
}

#[test]
fn semantic_completed_inconsistent_exhausted() {
    let o = run_final_plan_gate_semantic_completed(
        &PlanSemanticLlmOutcome {
            consistent: false,
            violation_codes: vec!["x".into()],
            rationale: None,
            user_cancelled: false,
        },
        3,
        3,
    );
    assert_eq!(
        o.route,
        FinalPlanGateRoute::SemanticMismatchRewriteExhausted
    );
    assert!(matches!(
        o.after,
        AfterFinalAssistant::StopTurnPlanRewriteExhausted {
            reason: PlanRewriteExhaustedReason::PlanSemanticInconsistent
        }
    ));
}