ai-dispatch 10.52.0

Multi-AI CLI team orchestrator
// Tests for Claude stream-json event parsing.
// Covers rate-limit marking and detail capping with metadata.full.
// Loaded by `claude_events.rs` under `#[cfg(test)]`.
use super::*;
use crate::{paths, rate_limit};

// modelUsage copied verbatim from a real two-model result in ~/.aid/logs/t-73b769ce.jsonl.
const CAPTURED_RESULT: &str = r#"{"type":"result","modelUsage":{"claude-opus-5-5":{"inputTokens":146,"outputTokens":58023,"cacheReadInputTokens":9931249,"cacheCreationInputTokens":188174,"webSearchRequests":0,"costUSD":4.6526858,"contextWindow":1000000,"maxOutputTokens":128000,"thinkingTokens":25274,"canonicalModel":"claude-opus-5-5","provider":"firstParty","costBasis":"list"},"claude-haiku-4-5-20251001":{"inputTokens":65614,"outputTokens":835,"cacheReadInputTokens":0,"cacheCreationInputTokens":0,"webSearchRequests":1,"costUSD":0.079789,"contextWindow":200000,"maxOutputTokens":32000,"thinkingTokens":0,"canonicalModel":"claude-haiku-4-5","provider":"firstParty","costBasis":"list"}}}"#;

#[test]
fn marks_claude_rate_limits_from_error_and_user_events() {
    let temp = tempfile::tempdir().unwrap();
    let _aid_home = paths::AidHomeGuard::set(temp.path());
    rate_limit::clear_rate_limit(&AgentKind::Claude, None);
    let task_id = TaskId("t-claude-rate".to_string());
    let event = parse_event_line(
        &task_id,
        r#"{"type":"error","message":"rate limit exceeded"}"#,
    )
    .unwrap();
    assert_eq!(event.event_kind, EventKind::Error);
    assert!(
        rate_limit::get_rate_limit_info(&AgentKind::Claude, None).is_some(),
        "generic 429 still writes a Transient marker"
    );
    assert!(
        !rate_limit::is_rate_limited(&AgentKind::Claude, None),
        "Transient is not Held"
    );
    rate_limit::clear_rate_limit(&AgentKind::Claude, None);
    // A `tool_result` block flagged `is_error` is the *tool* failing, and its
    // text is whatever the model asked for. It is still an Error event; it is
    // not testimony about the provider. Marking here wrote
    // `~/.aid/rate-limit-claude` on 2026-08-07 off an agent's own message.
    let event = parse_event_line(
        &task_id,
        r#"{"type":"user","message":{"content":[{"content":"HTTP 429 too many requests","is_error":true}]}}"#,
    )
    .unwrap();
    assert_eq!(event.event_kind, EventKind::Error);
    assert!(!rate_limit::is_rate_limited(&AgentKind::Claude, None));
}

#[test]
fn caps_long_assistant_text_and_keeps_full_in_metadata() {
    let task_id = TaskId("t-claude-cap".to_string());
    let long_text = format!("Reasoning {}", "w".repeat(120));
    let line = serde_json::json!({
        "type": "assistant",
        "message": { "content": [{ "type": "text", "text": long_text }] }
    })
    .to_string();
    let event = parse_event_line(&task_id, &line).unwrap();
    assert!(event.detail.len() <= crate::agent::truncate::EVENT_DETAIL_MAX);
    assert!(event.detail.ends_with("..."));
    let metadata = event.metadata.expect("metadata with full text");
    assert_eq!(metadata["full"].as_str(), Some(long_text.as_str()));
}

#[test]
fn short_assistant_text_has_no_full_metadata() {
    let task_id = TaskId("t-claude-short".to_string());
    let line = serde_json::json!({
        "type": "assistant",
        "message": { "content": [{ "type": "text", "text": "brief note" }] }
    })
    .to_string();
    let event = parse_event_line(&task_id, &line).unwrap();
    assert_eq!(event.detail, "brief note");
    assert!(event.metadata.is_none());
}

#[test]
fn picks_claude_model_with_highest_cost_from_captured_result() {
    let event = parse_event_line(&TaskId("t-multi-model".to_string()), CAPTURED_RESULT).unwrap();
    assert_eq!(event.metadata.unwrap()["model"], "claude-opus-5-5");
}

#[test]
fn picks_claude_model_by_tokens_when_cost_missing_or_tied() {
    let mut result: Value = serde_json::from_str(CAPTURED_RESULT).unwrap();
    result["modelUsage"]["claude-haiku-4-5-20251001"]["costUSD"] =
        result["modelUsage"]["claude-opus-5-5"]["costUSD"].clone();
    assert_eq!(extract_result_model(&result).as_deref(), Some("claude-opus-5-5"));
    result["modelUsage"]["claude-haiku-4-5-20251001"].as_object_mut().unwrap().remove("costUSD");
    assert_eq!(extract_result_model(&result).as_deref(), Some("claude-opus-5-5"));
    result["modelUsage"]["claude-opus-5-5"].as_object_mut().unwrap().remove("costUSD");
    assert_eq!(extract_result_model(&result).as_deref(), Some("claude-opus-5-5"));
    for key in ["inputTokens", "outputTokens", "cacheReadInputTokens", "cacheCreationInputTokens"] {
        result["modelUsage"]["claude-haiku-4-5-20251001"][key] =
            result["modelUsage"]["claude-opus-5-5"][key].clone();
    }
    assert_eq!(extract_result_model(&result).as_deref(), Some("claude-haiku-4-5-20251001"));
}

#[test]
fn regression_picks_larger_claude_model_with_huge_token_counts() {
    let result = json!({
        "modelUsage": {
            "a-large": {"inputTokens": i64::MAX, "outputTokens": i64::MAX},
            "z-small": {"inputTokens": i64::MAX, "outputTokens": 1}
        }
    });
    assert_eq!(extract_result_model(&result).as_deref(), Some("a-large"));
}