use super::traits::{CliEvent, NdjsonParser};
use crate::utils::truncate_str;
use crate::transport::SpawnOptions;
pub struct OpenCodeNdjsonParser {
session_id: Option<String>,
}
impl OpenCodeNdjsonParser {
pub fn new() -> Self {
Self { session_id: None }
}
}
impl Default for OpenCodeNdjsonParser {
fn default() -> Self {
Self::new()
}
}
impl NdjsonParser for OpenCodeNdjsonParser {
fn parse_line(&mut self, line: &str) -> Vec<CliEvent> {
let line = line.trim();
if line.is_empty() {
return vec![];
}
let v: serde_json::Value = match serde_json::from_str(line) {
Ok(v) => v,
Err(_) => {
return vec![CliEvent::Error {
message: format!("invalid JSON: {}", truncate_str(line, 100)),
}]
}
};
let mut events = Vec::new();
if self.session_id.is_none() {
let sid = v
.get("sessionID")
.or_else(|| v.get("session_id"))
.and_then(|s| s.as_str());
if let Some(sid) = sid {
self.session_id = Some(sid.to_string());
events.push(CliEvent::SessionStart {
session_id: sid.to_string(),
model: String::new(),
tools: vec![],
});
}
}
match v.get("type").and_then(|t| t.as_str()) {
Some("tool_use") => {
let call_id = v
.pointer("/part/callID")
.and_then(|s| s.as_str())
.unwrap_or("")
.to_string();
let name = v
.pointer("/part/tool")
.and_then(|s| s.as_str())
.unwrap_or("")
.to_string();
let input = v
.pointer("/part/state/input")
.cloned()
.unwrap_or(serde_json::Value::Null);
events.push(CliEvent::ToolCallStart {
id: call_id.clone(),
name,
input,
});
let status = v
.pointer("/part/state/status")
.and_then(|s| s.as_str())
.unwrap_or("pending");
if status == "completed" {
let output = v
.pointer("/part/state/output")
.and_then(|s| s.as_str())
.unwrap_or("")
.to_string();
events.push(CliEvent::ToolCallResult {
id: call_id,
output,
is_error: false,
duration_ms: None,
});
}
}
Some("step_start") => {
}
Some("text") => {
let text = v
.pointer("/part/text")
.and_then(|s| s.as_str())
.or_else(|| v.get("text").and_then(|s| s.as_str()))
.or_else(|| v.get("content").and_then(|s| s.as_str()))
.unwrap_or("")
.to_string();
if !text.is_empty() {
events.push(CliEvent::AssistantText {
text,
is_delta: false,
});
}
}
Some("step_finish") => {
let input_tokens = v
.pointer("/part/tokens/input")
.and_then(|v| v.as_u64())
.unwrap_or(0);
let output_tokens = v
.pointer("/part/tokens/output")
.and_then(|v| v.as_u64())
.unwrap_or(0);
if input_tokens > 0 || output_tokens > 0 {
events.push(CliEvent::TurnComplete {
input_tokens,
output_tokens,
});
}
}
Some("reasoning") => {
let text = v
.pointer("/part/text")
.and_then(|s| s.as_str())
.or_else(|| v.get("text").and_then(|s| s.as_str()))
.or_else(|| v.get("content").and_then(|s| s.as_str()))
.unwrap_or("")
.to_string();
if !text.is_empty() {
events.push(CliEvent::Thinking { text });
}
}
Some("error") => {
let message = v
.pointer("/error/data/message")
.and_then(|s| s.as_str())
.or_else(|| v.pointer("/error/message").and_then(|s| s.as_str()))
.or_else(|| v.get("message").and_then(|s| s.as_str()))
.unwrap_or("unknown error")
.to_string();
events.push(CliEvent::Error { message });
}
_ => {}
}
events
}
fn session_id(&self) -> Option<&str> {
self.session_id.as_deref()
}
}
pub struct OpenCodePipeBuilder;
impl super::traits::CliCommandBuilder for OpenCodePipeBuilder {
fn build_command(&self, opts: &SpawnOptions) -> std::process::Command {
let mut cmd = std::process::Command::new("opencode");
cmd.arg("run");
cmd.arg("--format");
cmd.arg("json");
if let Some(ref session_id) = opts.resume_session_id {
cmd.arg("--session");
cmd.arg(session_id);
} else if opts.continue_last {
cmd.arg("--continue");
}
let model = opts.model.as_deref().unwrap_or("opencode/gpt-5-nano");
cmd.arg("-m");
cmd.arg(model);
for arg in &opts.extra_args {
cmd.arg(arg);
}
cmd.arg(&opts.prompt);
cmd
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn opencode_parses_real_text_event() {
let mut parser = OpenCodeNdjsonParser::new();
let line = r#"{"type":"text","timestamp":1775737274160,"sessionID":"ses_28dcfca7effeG4iMaRZrD8C8x6","part":{"id":"prt_d72307f22001","sessionID":"ses_28dcfca7effeG4iMaRZrD8C8x6","messageID":"msg_d7230374c001","type":"text","text":"Hello! 👋","time":{"start":1775737274149,"end":1775737274149}}}"#;
let events = parser.parse_line(line);
assert_eq!(events.len(), 2);
match &events[0] {
CliEvent::SessionStart { session_id, .. } => {
assert_eq!(session_id, "ses_28dcfca7effeG4iMaRZrD8C8x6");
}
other => panic!("expected SessionStart, got {:?}", other),
}
match &events[1] {
CliEvent::AssistantText { text, is_delta } => {
assert_eq!(text, "Hello! 👋");
assert!(!is_delta);
}
other => panic!("expected AssistantText, got {:?}", other),
}
assert_eq!(parser.session_id(), Some("ses_28dcfca7effeG4iMaRZrD8C8x6"));
}
#[test]
fn opencode_parses_real_tool_use() {
let mut parser = OpenCodeNdjsonParser::new();
let line = r#"{"type":"tool_use","timestamp":1775737305647,"sessionID":"ses_abc","part":{"id":"prt_xyz","type":"tool","callID":"chatcmpl-tool-bdc397ac90703079","tool":"read","state":{"status":"completed","input":{"filePath":"C:\\README.md"},"output":"<file>contents here</file>","title":"README.md","time":{"start":1775737305648,"end":1775737305700}}}}"#;
let events = parser.parse_line(line);
assert_eq!(events.len(), 3, "completed tool_use must emit SessionStart + ToolCallStart + ToolCallResult");
match &events[0] {
CliEvent::SessionStart { session_id, .. } => {
assert_eq!(session_id, "ses_abc");
}
other => panic!("expected SessionStart, got {:?}", other),
}
match &events[1] {
CliEvent::ToolCallStart { id, name, input } => {
assert_eq!(id, "chatcmpl-tool-bdc397ac90703079");
assert_eq!(name, "read");
assert_eq!(
input.pointer("/filePath").and_then(|v| v.as_str()),
Some("C:\\README.md")
);
}
other => panic!("expected ToolCallStart, got {:?}", other),
}
match &events[2] {
CliEvent::ToolCallResult { id, output, is_error, .. } => {
assert_eq!(id, "chatcmpl-tool-bdc397ac90703079");
assert_eq!(output, "<file>contents here</file>");
assert!(!is_error);
}
other => panic!("expected ToolCallResult, got {:?}", other),
}
}
#[test]
fn opencode_parses_real_tool_use_pending_no_result() {
let mut parser = OpenCodeNdjsonParser::new();
let line = r#"{"type":"tool_use","sessionID":"ses_abc","part":{"type":"tool","callID":"call-123","tool":"bash","state":{"status":"pending","input":{"command":"ls"}}}}"#;
let events = parser.parse_line(line);
assert_eq!(events.len(), 2, "pending tool_use emits SessionStart + ToolCallStart");
match &events[0] {
CliEvent::SessionStart { session_id, .. } => {
assert_eq!(session_id, "ses_abc");
}
other => panic!("expected SessionStart, got {:?}", other),
}
match &events[1] {
CliEvent::ToolCallStart { id, name, .. } => {
assert_eq!(id, "call-123");
assert_eq!(name, "bash");
}
other => panic!("expected ToolCallStart, got {:?}", other),
}
}
#[test]
fn opencode_parses_real_step_finish_with_tokens() {
let mut parser = OpenCodeNdjsonParser::new();
let line = r#"{"type":"step_finish","timestamp":1775737275618,"sessionID":"ses_abc","part":{"type":"step-finish","reason":"stop","snapshot":"abc","cost":0,"tokens":{"input":14290,"output":6,"reasoning":4,"cache":{"read":0,"write":0}}}}"#;
let events = parser.parse_line(line);
assert_eq!(events.len(), 2);
match &events[0] {
CliEvent::SessionStart { session_id, .. } => {
assert_eq!(session_id, "ses_abc");
}
other => panic!("expected SessionStart, got {:?}", other),
}
match &events[1] {
CliEvent::TurnComplete { input_tokens, output_tokens } => {
assert_eq!(*input_tokens, 14290);
assert_eq!(*output_tokens, 6);
}
other => panic!("expected TurnComplete, got {:?}", other),
}
}
#[test]
fn opencode_step_finish_zero_tokens_emits_nothing() {
let mut parser = OpenCodeNdjsonParser::new();
let line = r#"{"type":"step_finish","sessionID":"ses_abc","part":{"type":"step-finish","reason":"tool-calls","cost":0,"tokens":{"input":0,"output":0}}}"#;
let events = parser.parse_line(line);
assert_eq!(events.len(), 1, "zero-token step_finish should emit only SessionStart");
match &events[0] {
CliEvent::SessionStart { .. } => {}
other => panic!("expected SessionStart, got {:?}", other),
}
}
#[test]
fn opencode_parses_real_error() {
let mut parser = OpenCodeNdjsonParser::new();
let line = r#"{"type":"error","timestamp":1775737195012,"sessionID":"ses_28dd0bb31ffe","error":{"name":"APIError","data":{"message":"Incorrect API key provided: sk-or-v1...d6a3.","statusCode":401,"isRetryable":false}}}"#;
let events = parser.parse_line(line);
assert_eq!(events.len(), 2);
match &events[0] {
CliEvent::SessionStart { session_id, .. } => {
assert_eq!(session_id, "ses_28dd0bb31ffe");
}
other => panic!("expected SessionStart, got {:?}", other),
}
match &events[1] {
CliEvent::Error { message } => {
assert_eq!(message, "Incorrect API key provided: sk-or-v1...d6a3.");
}
other => panic!("expected CliEvent::Error, got {:?}", other),
}
}
#[test]
fn opencode_error_fallback_to_error_message() {
let mut parser = OpenCodeNdjsonParser::new();
let line = r#"{"type":"error","error":{"message":"network timeout"}}"#;
let events = parser.parse_line(line);
assert_eq!(events.len(), 1);
match &events[0] {
CliEvent::Error { message } => assert_eq!(message, "network timeout"),
other => panic!("expected CliEvent::Error, got {:?}", other),
}
}
#[test]
fn opencode_error_fallback_to_toplevel_message() {
let mut parser = OpenCodeNdjsonParser::new();
let line = r#"{"type":"error","message":"model not available"}"#;
let events = parser.parse_line(line);
assert_eq!(events.len(), 1);
match &events[0] {
CliEvent::Error { message } => assert_eq!(message, "model not available"),
other => panic!("expected CliEvent::Error, got {:?}", other),
}
}
#[test]
fn opencode_step_start_emits_only_session_start() {
let mut parser = OpenCodeNdjsonParser::new();
let line = r#"{"type":"step_start","timestamp":1775737274135,"sessionID":"ses_28dcfca7effeG4iMaRZrD8C8x6","part":{"id":"prt_d72307f0a001","sessionID":"ses_28dcfca7effeG4iMaRZrD8C8x6","messageID":"msg_d7230374c001","type":"step-start","snapshot":"11f897c48dde50396cfdadda13159d56b138e9af"}}"#;
let events = parser.parse_line(line);
assert_eq!(events.len(), 1, "step_start should emit only SessionStart, got {:?}", events);
match &events[0] {
CliEvent::SessionStart { session_id, .. } => {
assert_eq!(session_id, "ses_28dcfca7effeG4iMaRZrD8C8x6");
}
other => panic!("expected SessionStart, got {:?}", other),
}
}
#[test]
fn opencode_session_id_from_any_line() {
let mut parser = OpenCodeNdjsonParser::new();
assert!(parser.session_id().is_none());
let step_start = r#"{"type":"step_start","timestamp":123,"sessionID":"ses_realworld123","part":{"type":"step-start","snapshot":"abc"}}"#;
let events = parser.parse_line(step_start);
assert_eq!(events.len(), 1, "first line with sessionID emits SessionStart");
match &events[0] {
CliEvent::SessionStart { session_id, .. } => {
assert_eq!(session_id, "ses_realworld123");
}
other => panic!("expected SessionStart, got {:?}", other),
}
assert_eq!(parser.session_id(), Some("ses_realworld123"));
}
#[test]
fn opencode_session_id_tracked_camel_case() {
let mut parser = OpenCodeNdjsonParser::new();
assert!(parser.session_id().is_none());
let line = r#"{"sessionID":"ses_test123","type":"text","part":{"type":"text","text":"hi"}}"#;
parser.parse_line(line);
assert_eq!(parser.session_id(), Some("ses_test123"));
}
#[test]
fn opencode_session_id_tracked_snake_case_fallback() {
let mut parser = OpenCodeNdjsonParser::new();
assert!(parser.session_id().is_none());
let line = r#"{"session_id":"ses_test456","type":"text","part":{"type":"text","text":"hi"}}"#;
parser.parse_line(line);
assert_eq!(parser.session_id(), Some("ses_test456"));
}
#[test]
fn opencode_reasoning_emits_thinking_from_part() {
let mut parser = OpenCodeNdjsonParser::new();
let line = r#"{"type":"reasoning","sessionID":"ses_abc","part":{"type":"reasoning","text":"Let me think about this carefully..."}}"#;
let events = parser.parse_line(line);
assert_eq!(events.len(), 2);
match &events[0] {
CliEvent::SessionStart { session_id, .. } => {
assert_eq!(session_id, "ses_abc");
}
other => panic!("expected SessionStart, got {:?}", other),
}
match &events[1] {
CliEvent::Thinking { text } => {
assert_eq!(text, "Let me think about this carefully...");
}
other => panic!("expected CliEvent::Thinking, got {:?}", other),
}
}
#[test]
fn opencode_reasoning_fallback_to_toplevel_text() {
let mut parser = OpenCodeNdjsonParser::new();
let line = r#"{"type":"reasoning","text":"top-level reasoning text"}"#;
let events = parser.parse_line(line);
assert_eq!(events.len(), 1);
match &events[0] {
CliEvent::Thinking { text } => {
assert_eq!(text, "top-level reasoning text");
}
other => panic!("expected CliEvent::Thinking, got {:?}", other),
}
}
#[test]
fn opencode_reasoning_via_content_field() {
let mut parser = OpenCodeNdjsonParser::new();
let line = r#"{"type":"reasoning","content":"Alternative content field for reasoning"}"#;
let events = parser.parse_line(line);
assert_eq!(events.len(), 1);
match &events[0] {
CliEvent::Thinking { text } => {
assert_eq!(text, "Alternative content field for reasoning");
}
other => panic!("expected CliEvent::Thinking, got {:?}", other),
}
}
#[test]
fn opencode_reasoning_empty_text_ignored() {
let mut parser = OpenCodeNdjsonParser::new();
let line = r#"{"type":"reasoning","part":{"type":"reasoning","text":""}}"#;
let events = parser.parse_line(line);
assert!(events.is_empty(), "empty reasoning text should produce no events");
}
#[test]
fn opencode_text_empty_part_text_ignored() {
let mut parser = OpenCodeNdjsonParser::new();
let line = r#"{"type":"text","sessionID":"ses_abc","part":{"type":"text","text":""}}"#;
let events = parser.parse_line(line);
assert_eq!(events.len(), 1, "empty text on first line emits only SessionStart");
match &events[0] {
CliEvent::SessionStart { .. } => {}
other => panic!("expected SessionStart, got {:?}", other),
}
}
#[test]
fn opencode_invalid_json_emits_error() {
let mut parser = OpenCodeNdjsonParser::new();
let events = parser.parse_line("not json at all {{{");
assert_eq!(events.len(), 1);
match &events[0] {
CliEvent::Error { message } => assert!(message.starts_with("invalid JSON")),
other => panic!("expected CliEvent::Error, got {:?}", other),
}
}
#[test]
fn opencode_empty_line_produces_no_events() {
let mut parser = OpenCodeNdjsonParser::new();
assert!(parser.parse_line("").is_empty());
assert!(parser.parse_line(" ").is_empty());
}
#[test]
fn opencode_session_start_emitted_exactly_once() {
let mut parser = OpenCodeNdjsonParser::new();
let first_line = r#"{"type":"step_start","sessionID":"ses_once","part":{"type":"step-start","snapshot":"abc"}}"#;
let second_line = r#"{"type":"text","sessionID":"ses_once","part":{"type":"text","text":"hello"}}"#;
let third_line = r#"{"type":"step_finish","sessionID":"ses_once","part":{"type":"step-finish","reason":"stop","cost":0,"tokens":{"input":100,"output":10}}}"#;
let events1 = parser.parse_line(first_line);
assert_eq!(events1.len(), 1);
assert!(matches!(&events1[0], CliEvent::SessionStart { session_id, .. } if session_id == "ses_once"));
let events2 = parser.parse_line(second_line);
assert_eq!(events2.len(), 1);
assert!(matches!(&events2[0], CliEvent::AssistantText { .. }), "got {:?}", events2);
let events3 = parser.parse_line(third_line);
assert_eq!(events3.len(), 1);
assert!(matches!(&events3[0], CliEvent::TurnComplete { .. }), "got {:?}", events3);
}
}