use super::traits::{CliEvent, NdjsonParser};
use crate::utils::truncate_str;
use crate::transport::SpawnOptions;
pub struct CursorNdjsonParser {
session_id: Option<String>,
}
impl CursorNdjsonParser {
pub fn new() -> Self {
Self { session_id: None }
}
}
impl Default for CursorNdjsonParser {
fn default() -> Self {
Self::new()
}
}
impl NdjsonParser for CursorNdjsonParser {
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();
match v.get("type").and_then(|t| t.as_str()) {
Some("system") => {
let sid = v
.get("session_id")
.and_then(|s| s.as_str())
.unwrap_or("")
.to_string();
let model = v
.get("model")
.and_then(|s| s.as_str())
.unwrap_or("unknown")
.to_string();
self.session_id = Some(sid.clone());
events.push(CliEvent::SessionStart {
session_id: sid,
model,
tools: vec![],
});
}
Some("assistant") => {
if let Some(content) =
v.pointer("/message/content").and_then(|c| c.as_array())
{
for block in content {
if block.get("type").and_then(|t| t.as_str()) == Some("text") {
if let Some(text) = block.get("text").and_then(|t| t.as_str()) {
events.push(CliEvent::AssistantText {
text: text.to_string(),
is_delta: false,
});
}
}
}
}
}
Some("tool_call") => {
let subtype = v
.get("subtype")
.and_then(|s| s.as_str())
.unwrap_or("");
let tool_name = v
.get("tool")
.and_then(|s| s.as_str())
.unwrap_or("")
.to_string();
match subtype {
"started" => {
events.push(CliEvent::ToolCallStart {
id: tool_name.clone(),
name: tool_name,
input: serde_json::Value::Null, });
}
"completed" => {
let duration_ms = v
.get("duration_ms") .and_then(|d| d.as_u64());
events.push(CliEvent::ToolCallResult {
id: tool_name,
output: String::new(), is_error: false, duration_ms,
});
}
_ => {}
}
}
Some("user") => {}
Some("result") => {
let subtype = v
.get("subtype")
.and_then(|s| s.as_str())
.unwrap_or("success");
let is_error = subtype == "error";
events.push(CliEvent::SessionEnd {
result: subtype.to_string(),
cost_usd: None, is_error,
});
}
_ => {}
}
events
}
fn session_id(&self) -> Option<&str> {
self.session_id.as_deref()
}
}
pub struct CursorPipeBuilder;
impl super::traits::CliCommandBuilder for CursorPipeBuilder {
fn build_command(&self, opts: &SpawnOptions) -> std::process::Command {
let mut cmd = std::process::Command::new("cursor-agent");
cmd.arg("-p");
cmd.arg("--output-format");
cmd.arg("stream-json");
if let Some(ref model) = opts.model {
cmd.arg("--model");
cmd.arg(model);
}
if let Some(ref session_id) = opts.resume_session_id {
cmd.arg("--resume");
cmd.arg(session_id);
}
for arg in &opts.extra_args {
cmd.arg(arg);
}
cmd.arg(&opts.prompt);
cmd
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn cursor_parses_system_init() {
let mut parser = CursorNdjsonParser::new();
let line = r#"{"type":"system","subtype":"init","session_id":"cursor_ses_abc","cwd":"/home/user/project","model":"claude-3-5-sonnet"}"#;
let events = parser.parse_line(line);
assert_eq!(events.len(), 1);
match &events[0] {
CliEvent::SessionStart { session_id, model, tools } => {
assert_eq!(session_id, "cursor_ses_abc");
assert_eq!(model, "claude-3-5-sonnet");
assert!(tools.is_empty());
}
other => panic!("expected SessionStart, got {:?}", other),
}
assert_eq!(parser.session_id(), Some("cursor_ses_abc"));
}
#[test]
fn cursor_parses_assistant_text() {
let mut parser = CursorNdjsonParser::new();
let line = r#"{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"Hello from Cursor!"}]},"session_id":"cursor_ses_abc"}"#;
let events = parser.parse_line(line);
assert_eq!(events.len(), 1);
match &events[0] {
CliEvent::AssistantText { text, is_delta } => {
assert_eq!(text, "Hello from Cursor!");
assert!(!is_delta);
}
other => panic!("expected AssistantText, got {:?}", other),
}
}
#[test]
fn cursor_parses_tool_call_started() {
let mut parser = CursorNdjsonParser::new();
let line = r#"{"type":"tool_call","subtype":"started","tool":"shellToolCall","session_id":"cursor_ses_abc"}"#;
let events = parser.parse_line(line);
assert_eq!(events.len(), 1);
match &events[0] {
CliEvent::ToolCallStart { id, name, .. } => {
assert_eq!(id, "shellToolCall");
assert_eq!(name, "shellToolCall");
}
other => panic!("expected ToolCallStart, got {:?}", other),
}
}
#[test]
fn cursor_parses_tool_call_completed() {
let mut parser = CursorNdjsonParser::new();
let line = r#"{"type":"tool_call","subtype":"completed","tool":"shellToolCall","duration_ms":1234,"session_id":"cursor_ses_abc"}"#;
let events = parser.parse_line(line);
assert_eq!(events.len(), 1);
match &events[0] {
CliEvent::ToolCallResult { id, duration_ms, is_error, .. } => {
assert_eq!(id, "shellToolCall");
assert_eq!(*duration_ms, Some(1234));
assert!(!is_error);
}
other => panic!("expected ToolCallResult, got {:?}", other),
}
}
#[test]
fn cursor_parses_user_event_as_no_events() {
let mut parser = CursorNdjsonParser::new();
let line = r#"{"type":"user","message":{"role":"user","content":[{"type":"text","text":"find and fix the memory leak"}]},"session_id":"cursor_ses_abc"}"#;
let events = parser.parse_line(line);
assert_eq!(events.len(), 0);
}
#[test]
fn cursor_parses_result_success() {
let mut parser = CursorNdjsonParser::new();
let line = r#"{"type":"result","subtype":"success","duration_ms":12453,"session_id":"cursor_ses_abc"}"#;
let events = parser.parse_line(line);
assert_eq!(events.len(), 1);
match &events[0] {
CliEvent::SessionEnd { is_error, cost_usd, result } => {
assert!(!is_error);
assert!(cost_usd.is_none());
assert_eq!(result, "success");
}
other => panic!("expected SessionEnd, got {:?}", other),
}
}
#[test]
fn cursor_parses_result_error() {
let mut parser = CursorNdjsonParser::new();
let line = r#"{"type":"result","subtype":"error","duration_ms":500,"session_id":"cursor_ses_abc"}"#;
let events = parser.parse_line(line);
assert_eq!(events.len(), 1);
match &events[0] {
CliEvent::SessionEnd { is_error, .. } => {
assert!(is_error);
}
other => panic!("expected SessionEnd, got {:?}", other),
}
}
#[test]
fn cursor_parses_full_session() {
let mut parser = CursorNdjsonParser::new();
let lines = [
r#"{"type":"system","subtype":"init","session_id":"ses_xyz","cwd":"/repo","model":"gpt-4o"}"#,
r#"{"type":"user","message":{"role":"user","content":[{"type":"text","text":"analyze this CI failure"}]},"session_id":"ses_xyz"}"#,
r#"{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"I found the issue in..."}]},"session_id":"ses_xyz"}"#,
r#"{"type":"tool_call","subtype":"started","tool":"shellToolCall","session_id":"ses_xyz"}"#,
r#"{"type":"tool_call","subtype":"completed","tool":"shellToolCall","duration_ms":300,"session_id":"ses_xyz"}"#,
r#"{"type":"result","subtype":"success","duration_ms":5000,"session_id":"ses_xyz"}"#,
];
let all_events: Vec<_> = lines.iter().flat_map(|l| parser.parse_line(l)).collect();
assert_eq!(all_events.len(), 5);
assert!(matches!(all_events[0], CliEvent::SessionStart { .. }));
assert!(matches!(all_events[1], CliEvent::AssistantText { .. }));
assert!(matches!(all_events[2], CliEvent::ToolCallStart { .. }));
assert!(matches!(all_events[3], CliEvent::ToolCallResult { .. }));
assert!(matches!(all_events[4], CliEvent::SessionEnd { .. }));
assert_eq!(parser.session_id(), Some("ses_xyz"));
}
}