ai-dispatch 11.2.0

Multi-AI CLI team orchestrator
// Native OpenCode spec and shared OpenCode-compatible parsing helpers.
// Preserves served-model probing, route identity, and event parsing.

use chrono::Local;
use serde_json::json;

use super::opencode_overlay::OpenCodeOverlaySpec;
use super::truncate::capped_detail_with;
use crate::routing::rate_limit;
use crate::types::*;

pub(crate) fn spec() -> OpenCodeOverlaySpec {
    OpenCodeOverlaySpec {
        id: "opencode".to_string(),
        display_name: "OpenCode".to_string(),
        reported_kind: AgentKind::OpenCode,
        binary: "opencode".to_string(),
        extra_args: Vec::new(),
        default_model: None,
        interactive_input: true,
        rate_limit_kind: AgentKind::OpenCode,
        allow_external_directories: true,
        probe_served_models: true,
    }
}

pub(crate) fn parse_json_event(
    signature_kind: AgentKind,
    marker_kind: AgentKind,
    custom_name: Option<&str>,
    task_id: &TaskId,
    v: &serde_json::Value,
    now: chrono::DateTime<Local>,
) -> Option<TaskEvent> {
    let event_type = v.get("type").and_then(|t| t.as_str())?;
    let session_id = v.get("sessionID").and_then(|s| s.as_str());
    let (detail, metadata) = match event_type {
        "tool_call" | "function_call" => {
            let name = v.get("name").and_then(|n| n.as_str()).unwrap_or("unknown");
            let args = v.get("arguments").and_then(|a| a.as_str()).unwrap_or("");
            (format!("{name}: {args}"), None)
        }
        "message" => {
            let detail = v
                .get("content")
                .or(v.get("text"))
                .and_then(|t| t.as_str())
                .unwrap_or("")
                .to_string();
            (detail, Some(json!({ "assistant_output": true })))
        }
        "text" => {
            (v.pointer("/part/text").and_then(|t| t.as_str()).unwrap_or("").to_string(),
                Some(json!({ "assistant_output": true })))
        }
        "auto_compact" | "git_snapshot" => (milestone_detail(v, event_type), None),
        "step_start" => return None,
        "step_finish" => {
            let total = v.pointer("/part/tokens/total").and_then(|t| t.as_i64())?;
            let input = v.pointer("/part/tokens/input").and_then(|t| t.as_i64())?;
            let output = v.pointer("/part/tokens/output").and_then(|t| t.as_i64())?;
            let cost = v.pointer("/part/cost").and_then(|c| c.as_f64())?;
            (
                format!("tokens: {} in + {} out = {}", input, output, total),
                Some(json!({
                    "tokens": total,
                    "input_tokens": input,
                    "output_tokens": output,
                    "cost_usd": cost,
                    "usage_is_delta": event_type == "step_finish",
                })),
            )
        }
        "completion" | "done" => {
            let tokens = v.get("tokens").and_then(|t| t.as_i64());
            (tokens.map(|t| format!("completed with {t} tokens")).unwrap_or_else(|| "completed".to_string()), tokens.map(|value| json!({ "tokens": value })))
        }
        "error" => {
            let detail = error_detail(v).unwrap_or("unknown error");
            // Watcher owns built-in OpenCode (has the model); overlays write here.
            if marker_kind != AgentKind::OpenCode {
                if let Some(message) = rate_limit::refusal_on_channel(
                    &v.to_string(), signature_kind, crate::routing::quota::channel::Channel::CliStream,
                ) {
                    rate_limit::mark_rate_limited_for_message(&marker_kind, custom_name, &message);
                }
            }
            let auth = crate::routing::auth_marker::auth_failure_line(
                &v.to_string(), marker_kind, crate::routing::quota::channel::Channel::CliStream,
            );
            (detail.to_string(), auth.map(|message| json!({ "opencode_auth_failure": message })))
        }
        _ => return None,
    };

    if detail.is_empty() {
        return None;
    }

    let event_kind = match event_type {
        "tool_call" | "function_call" => classify_tool_detail(&detail),
        "message" | "text" => EventKind::Reasoning,
        "auto_compact" | "git_snapshot" => EventKind::Milestone,
        "error" => EventKind::Error,
        "step_finish" | "completion" | "done" => EventKind::Completion,
        _ => EventKind::Reasoning,
    };

    let metadata = if let Some(sid) = session_id {
        match metadata {
            Some(mut m) => {
                if let Some(obj) = m.as_object_mut() {
                    obj.insert("agent_session_id".to_string(), json!(sid));
                }
                Some(m)
            }
            None => Some(json!({ "agent_session_id": sid })),
        }
    } else {
        metadata
    };

    let (detail, metadata) = capped_detail_with(&detail, metadata);
    Some(TaskEvent {
        task_id: task_id.clone(),
        timestamp: now,
        event_kind,
        detail,
        metadata,
    })
}

fn milestone_detail(v: &serde_json::Value, event_type: &str) -> String {
    v.get("message")
        .or_else(|| v.get("content"))
        .or_else(|| v.get("text"))
        .or_else(|| v.get("summary"))
        .or_else(|| v.get("title"))
        .or_else(|| v.pointer("/part/text"))
        .and_then(|value| value.as_str())
        .filter(|value| !value.is_empty())
        .map(ToOwned::to_owned)
        .unwrap_or_else(|| event_type.replace('_', " "))
}

/// Classification only. PTY-rendered lines are not a mark site; the captured
/// refusal is a `{"type":"error"}` envelope that `quota_channel` reads.
pub(crate) fn classify_text_line(line: &str) -> (Option<EventKind>, &str) {
    if line.contains("error[") || line.contains("FAILED") || line.starts_with("Error:") {
        (Some(EventKind::Error), line)
    } else if line.contains("test result:") || line.contains("running") && line.contains("test") {
        (Some(EventKind::Test), line)
    } else if line.contains("Compiling") || line.contains("Finished") {
        (Some(EventKind::Build), line)
    } else if line.contains("git commit") || line.starts_with("commit ") {
        (Some(EventKind::Commit), line)
    } else if line.starts_with("Writing") || line.starts_with("Creating") {
        (Some(EventKind::FileWrite), line)
    } else if line.starts_with("Reading") {
        (Some(EventKind::FileRead), line)
    } else {
        // Skip noisy lines, keep substantive ones
        if line.len() > 10 {
            (Some(EventKind::Reasoning), line)
        } else {
            (None, line)
        }
    }
}

pub(crate) fn classify_tool_detail(detail: &str) -> EventKind {
    if detail.contains("cargo test") || detail.contains("npm test") {
        EventKind::Test
    } else if detail.contains("cargo build") || detail.contains("cargo check") {
        EventKind::Build
    } else if detail.contains("git commit") {
        EventKind::Commit
    } else {
        EventKind::ToolCall
    }
}

fn error_detail(v: &serde_json::Value) -> Option<&str> {
    [v.get("message"), v.get("text"), v.pointer("/error/message"), v.pointer("/error/data/message")]
        .into_iter()
        .flatten()
        .find_map(|value| value.as_str().filter(|msg| !msg.is_empty()))
}

pub(crate) fn extract_tokens_from_output(output: &str) -> (Option<i64>, Option<f64>) {
    let mut total_tokens: i64 = 0;
    let mut total_cost: f64 = 0.0;
    let mut found_any = false;

    for line in output.lines() {
        if let Ok(v) = serde_json::from_str::<serde_json::Value>(line)
            && v.get("type").and_then(|t| t.as_str()) == Some("step_finish")
            && let Some(part) = v.get("part")
        {
            if let Some(tokens) = part.get("tokens").and_then(|t| t.get("total"))
                && let Some(n) = tokens.as_i64()
            {
                total_tokens += n;
                found_any = true;
            }
            if let Some(cost) = part.get("cost").and_then(|c| c.as_f64()) {
                total_cost += cost;
            }
        }
    }

    if found_any {
        (Some(total_tokens), Some(total_cost))
    } else {
        (None, None)
    }
}

#[cfg(test)] #[path = "opencode_tests.rs"] mod tests;
#[cfg(test)] #[path = "opencode_event_tests.rs"] mod event_tests;