use anyhow::Result;
use chrono::Local;
use serde_json::{Value, json};
use std::process::Command;
use super::truncate::truncate_text;
use super::RunOpts;
use crate::rate_limit;
use crate::types::*;
pub struct DroidAgent;
impl super::Agent for DroidAgent {
fn kind(&self) -> AgentKind {
AgentKind::Droid
}
fn streaming(&self) -> bool {
true
}
fn build_command(&self, prompt: &str, opts: &RunOpts) -> Result<Command> {
let mut cmd = Command::new("droid");
cmd.args(["exec", "--output-format", "stream-json"]);
if opts.read_only {
cmd.arg("--use-spec");
} else {
cmd.arg("--skip-permissions-unsafe");
}
if let Some(ref model) = opts.model {
let mapped = map_model_name(model);
cmd.args(["-m", mapped.as_str()]);
}
if let Some(ref session_id) = opts.session_id {
cmd.args(["-s", session_id]);
}
for file in &opts.context_files {
cmd.args(["--append-system-prompt-file", file]);
}
if let Some(ref dir) = opts.dir {
cmd.args(["--cwd", dir]);
cmd.current_dir(dir);
}
cmd.arg(prompt);
Ok(cmd)
}
fn parse_event(&self, task_id: &TaskId, line: &str) -> Option<TaskEvent> {
let v: Value = serde_json::from_str(line).ok()?;
let now = Local::now();
let event_type = v.get("type")?.as_str()?;
match event_type {
"assistant_message" | "text" => {
let text = v
.get("content")
.or_else(|| v.get("text"))
.and_then(|t| t.as_str())
.unwrap_or("");
if text.is_empty() {
return None;
}
Some(TaskEvent {
task_id: task_id.clone(),
timestamp: now,
event_kind: EventKind::Reasoning,
detail: truncate_text(text, 80),
metadata: None,
})
}
"tool_call" => {
let name = v
.get("toolName")
.or_else(|| v.get("toolId"))
.or_else(|| v.get("name"))
.and_then(|n| n.as_str())
.unwrap_or("tool");
let detail = truncate_text(name, 80);
Some(TaskEvent {
task_id: task_id.clone(),
timestamp: now,
event_kind: EventKind::ToolCall,
detail,
metadata: None,
})
}
"tool_use" | "tool_result" => None,
"mission_step" => parse_mission_step(task_id, &v, now),
"session_forked" => parse_session_forked(task_id, &v, now),
"usage" | "turn_complete" => {
let input = v.get("input_tokens").and_then(|t| t.as_i64()).unwrap_or(0);
let output = v.get("output_tokens").and_then(|t| t.as_i64()).unwrap_or(0);
let total = input + output;
let cost = v.get("cost_usd").and_then(|c| c.as_f64());
let model = v.get("model").and_then(|m| m.as_str()).map(ToOwned::to_owned);
Some(TaskEvent {
task_id: task_id.clone(),
timestamp: now,
event_kind: EventKind::Completion,
detail: format!("tokens: {input} in + {output} out = {total}"),
metadata: Some(json!({
"tokens": total, "input_tokens": input, "output_tokens": output,
"model": model, "cost_usd": cost,
})),
})
}
"error" => parse_error_event(task_id, &v, now),
_ => None,
}
}
fn parse_completion(&self, _output: &str) -> CompletionInfo {
CompletionInfo {
tokens: None,
status: TaskStatus::Done,
model: None,
cost_usd: None,
exit_code: None,
}
}
}
fn map_model_name(model: &str) -> String {
match model {
"haiku" => "claude-haiku-4-5-20251001".to_string(),
"sonnet" => "claude-sonnet-4-6".to_string(),
"opus" => "claude-opus-4-7".to_string(),
"gpt-4.1-nano" => "gpt-5.4-mini".to_string(),
"gpt-4.1-mini" => "gpt-5.4-fast".to_string(),
other => other.to_string(),
}
}
fn parse_mission_step(
task_id: &TaskId,
v: &Value,
now: chrono::DateTime<Local>,
) -> Option<TaskEvent> {
let description = v.get("description")?.as_str()?.trim();
if description.is_empty() {
return None;
}
let detail = match v.get("step").and_then(|value| value.as_str()) {
Some(step) if !step.is_empty() => truncate_text(&format!("{step} {description}"), 80),
_ => truncate_text(description, 80),
};
Some(TaskEvent {
task_id: task_id.clone(),
timestamp: now,
event_kind: EventKind::Milestone,
detail,
metadata: None,
})
}
fn parse_session_forked(
task_id: &TaskId,
v: &Value,
now: chrono::DateTime<Local>,
) -> Option<TaskEvent> {
let new_id = v.get("new_id")?.as_str()?;
let detail = match v.get("parent_id").and_then(|value| value.as_str()) {
Some(parent_id) if !parent_id.is_empty() => format!("forked {new_id} from {parent_id}"),
_ => format!("forked {new_id}"),
};
Some(TaskEvent {
task_id: task_id.clone(),
timestamp: now,
event_kind: EventKind::Milestone,
detail,
metadata: Some(json!({ "agent_session_id": new_id })),
})
}
fn parse_error_event(
task_id: &TaskId,
v: &Value,
now: chrono::DateTime<Local>,
) -> Option<TaskEvent> {
let detail = droid_error_detail(v);
if is_droid_rate_limit(v, detail.as_deref()) {
let rate_limit_message = detail.clone().unwrap_or_else(|| "status 429".to_string());
rate_limit::mark_rate_limited(&AgentKind::Droid, &rate_limit_message);
}
Some(TaskEvent {
task_id: task_id.clone(),
timestamp: now,
event_kind: EventKind::Error,
detail: truncate_text(detail.as_deref().unwrap_or("unknown error"), 80),
metadata: None,
})
}
fn droid_error_detail(v: &Value) -> Option<String> {
let message = v
.get("message")
.and_then(|value| value.as_str())
.or_else(|| v.get("error").and_then(|value| value.as_str()))
.or_else(|| v.pointer("/error/message").and_then(|value| value.as_str()));
if let Some(message) = message
&& !message.is_empty()
{
return Some(message.to_string());
}
v.get("error_type")
.and_then(|value| value.as_str())
.or_else(|| v.pointer("/error/type").and_then(|value| value.as_str()))
.map(ToOwned::to_owned)
}
fn is_droid_rate_limit(v: &Value, detail: Option<&str>) -> bool {
if detail.is_some_and(rate_limit::is_rate_limit_error) {
return true;
}
let status = v
.get("status")
.and_then(|value| value.as_i64())
.or_else(|| v.pointer("/error/status").and_then(|value| value.as_i64()));
if status == Some(429) {
return true;
}
v.get("error_type")
.and_then(|value| value.as_str())
.or_else(|| v.pointer("/error/type").and_then(|value| value.as_str()))
.is_some_and(|value| value.eq_ignore_ascii_case("rate_limit_exceeded"))
}
#[cfg(test)]
mod model_name_tests {
use super::map_model_name;
#[test]
fn maps_common_shorthand_models() {
assert_eq!(map_model_name("haiku"), "claude-haiku-4-5-20251001");
assert_eq!(map_model_name("sonnet"), "claude-sonnet-4-6");
assert_eq!(map_model_name("opus"), "claude-opus-4-7");
assert_eq!(map_model_name("gpt-4.1-nano"), "gpt-5.4-mini");
assert_eq!(map_model_name("gpt-4.1-mini"), "gpt-5.4-fast");
}
#[test]
fn preserves_full_model_ids() {
assert_eq!(
map_model_name("claude-haiku-4-5-20251001"),
"claude-haiku-4-5-20251001"
);
}
}
#[cfg(test)]
mod tests;