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 OpenCodeAgent;
impl super::Agent for OpenCodeAgent {
fn kind(&self) -> AgentKind {
AgentKind::OpenCode
}
fn streaming(&self) -> bool {
true
}
fn build_command(&self, prompt: &str, opts: &RunOpts) -> Result<Command> {
if opts.read_only {
aid_warn!("[aid] ⚠OpenCode read-only is prompt-level only, not enforced. Use --worktree for isolation.");
}
let effective_prompt = if opts.read_only {
if opts.result_file.is_some() {
format!(
"IMPORTANT: READ-ONLY MODE. Do NOT modify, create, or delete any files, EXCEPT the result file specified in this prompt. Only read, analyze, and write your findings to the designated result file.\n\n{}",
prompt
)
} else {
format!(
"IMPORTANT: READ-ONLY MODE. Do NOT modify, create, or delete any files. Only read and analyze.\n\n{}",
prompt
)
}
} else {
prompt.to_string()
};
let mut cmd = Command::new("opencode");
cmd.arg("run");
cmd.args(["--format", "json"]);
cmd.arg("--thinking");
cmd.env(
"OPENCODE_CONFIG_CONTENT",
r#"{"agent":{"build":{"permission":{"external_directory":"allow"}}}}"#,
);
if let Some(ref session_id) = opts.session_id {
cmd.args(["--session", session_id]);
cmd.arg("--continue");
cmd.arg("--fork");
}
if opts.budget {
cmd.args(["--variant", "minimal"]);
}
if let Some(ref model) = opts.model {
cmd.args(["-m", model]);
}
if let Some(ref dir) = opts.dir {
cmd.args(["--dir", dir]);
cmd.current_dir(dir);
}
for file in &opts.context_files {
cmd.args(["-f", file]);
}
cmd.arg(&effective_prompt);
Ok(cmd)
}
fn parse_event(&self, task_id: &TaskId, line: &str) -> Option<TaskEvent> {
let now = Local::now();
let trimmed = line.trim();
if trimmed.is_empty() {
return None;
}
if let Ok(v) = serde_json::from_str::<serde_json::Value>(trimmed) {
return parse_json_event(task_id, &v, now);
}
let (kind, detail) = classify_text_line(trimmed);
kind.map(|k| TaskEvent {
task_id: task_id.clone(),
timestamp: now,
event_kind: k,
detail: truncate_text(detail, 80),
metadata: None,
})
}
fn needs_pty(&self) -> bool {
true
}
fn parse_completion(&self, output: &str) -> CompletionInfo {
let (tokens, cost_usd) = extract_tokens_from_output(output);
CompletionInfo {
tokens,
status: TaskStatus::Done,
model: None,
cost_usd,
exit_code: None,
}
}
}
pub(crate) fn parse_json_event(
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}: {}", truncate_text(args, 60)), None)
}
"message" => {
let detail = v
.get("content")
.or(v.get("text"))
.and_then(|t| t.as_str())
.unwrap_or("")
.to_string();
(detail, None)
}
"text" => {
(v.pointer("/part/text").and_then(|t| t.as_str()).unwrap_or("").to_string(), None)
}
"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,
})),
)
}
"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 = v.get("message").or(v.get("text")).and_then(|t| t.as_str()).unwrap_or("unknown error");
if rate_limit::is_rate_limit_error(detail) { rate_limit::mark_rate_limited(&AgentKind::OpenCode, detail); }
(detail.to_string(), None)
}
_ => 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
};
Some(TaskEvent {
task_id: task_id.clone(),
timestamp: now,
event_kind,
detail: truncate_text(&detail, 80),
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('_', " "))
}
pub(crate) fn classify_text_line(line: &str) -> (Option<EventKind>, &str) {
if line.contains("error[") || line.contains("FAILED") || line.starts_with("Error:") {
if rate_limit::is_rate_limit_error(line) { rate_limit::mark_rate_limited(&AgentKind::OpenCode, line); }
(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 {
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
}
}
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)]
mod rate_limit_tests {
use super::*; use crate::{agent::Agent, paths, rate_limit};
#[test]
fn marks_opencode_rate_limits_from_text_and_json_errors() {
let temp = tempfile::tempdir().unwrap(); let _aid_home = paths::AidHomeGuard::set(temp.path()); rate_limit::clear_rate_limit(&AgentKind::OpenCode); let agent = OpenCodeAgent;
assert_eq!(agent.parse_event(&TaskId("t-opencode".to_string()), "Error: rate limit exceeded").unwrap().event_kind, EventKind::Error); assert!(rate_limit::is_rate_limited(&AgentKind::OpenCode)); rate_limit::clear_rate_limit(&AgentKind::OpenCode);
assert_eq!(parse_json_event(&TaskId("t-opencode".to_string()), &serde_json::json!({"type":"error","message":"HTTP 429 too many requests"}), Local::now()).unwrap().event_kind, EventKind::Error); assert!(rate_limit::is_rate_limited(&AgentKind::OpenCode)); rate_limit::clear_rate_limit(&AgentKind::OpenCode);
}
}