use anyhow::Result;
use chrono::Local;
use serde_json::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("--auto").arg("low");
} else {
cmd.arg("--auto").arg("high");
}
if let Some(ref model) = opts.model {
cmd.args(["-m", model]);
}
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: serde_json::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_use" | "tool_result" | "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,
})
}
"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" => {
let msg = v
.get("message")
.or_else(|| v.get("error"))
.and_then(|e| e.as_str())
.unwrap_or("unknown error");
if rate_limit::is_rate_limit_error(msg) {
rate_limit::mark_rate_limited(&AgentKind::Droid, msg);
}
Some(TaskEvent {
task_id: task_id.clone(),
timestamp: now,
event_kind: EventKind::Error,
detail: truncate_text(msg, 80),
metadata: None,
})
}
_ => None,
}
}
fn parse_completion(&self, _output: &str) -> CompletionInfo {
CompletionInfo {
tokens: None,
status: TaskStatus::Done,
model: None,
cost_usd: None,
exit_code: None,
}
}
}
#[cfg(test)]
mod tests {
use super::DroidAgent;
use crate::agent::{Agent, RunOpts};
#[test]
fn build_command_uses_droid_exec() {
let opts = RunOpts {
dir: None,
output: None,
model: None,
budget: false,
read_only: false,
context_files: vec![],
session_id: None,
env: None,
env_forward: None,
};
let cmd = DroidAgent.build_command("test prompt", &opts).unwrap();
assert_eq!(cmd.get_program().to_str().unwrap(), "droid");
let args: Vec<String> = cmd.get_args().map(|a| a.to_string_lossy().to_string()).collect();
assert!(args.contains(&"exec".to_string()));
assert!(args.contains(&"stream-json".to_string()));
assert!(args.contains(&"high".to_string()));
}
#[test]
fn build_command_read_only_uses_auto_low() {
let opts = RunOpts {
dir: None,
output: None,
model: None,
budget: false,
read_only: true,
context_files: vec![],
session_id: None,
env: None,
env_forward: None,
};
let cmd = DroidAgent.build_command("test", &opts).unwrap();
let args: Vec<String> = cmd.get_args().map(|a| a.to_string_lossy().to_string()).collect();
assert!(args.contains(&"low".to_string()));
}
#[test]
fn parses_tool_call_events_with_tool_name() {
use crate::types::{EventKind, TaskId};
let agent = DroidAgent;
let line = r#"{"type":"tool_call","id":"toolu_01","toolId":"Read","toolName":"Read","parameters":{"file_path":"src/main.rs"}}"#;
let event = agent.parse_event(&TaskId("t-droid".to_string()), line).unwrap();
assert_eq!(event.event_kind, EventKind::ToolCall);
assert_eq!(event.detail, "Read");
}
#[test]
fn build_command_with_dir_sets_cwd() {
let opts = RunOpts {
dir: Some("/tmp/test".to_string()),
output: None,
model: None,
budget: false,
read_only: false,
context_files: vec![],
session_id: None,
env: None,
env_forward: None,
};
let cmd = DroidAgent.build_command("test", &opts).unwrap();
let args: Vec<String> = cmd.get_args().map(|a| a.to_string_lossy().to_string()).collect();
assert!(args.contains(&"--cwd".to_string()));
assert!(args.contains(&"/tmp/test".to_string()));
}
}