From 978ef0203f4c12c5f60a9ca67ef5fb16cbc08119 Mon Sep 17 00:00:00 2001 From: Edwin Date: Sun, 31 May 2026 10:06:48 -0700 Subject: [PATCH] Add transcript parser tests --- crates/adapter-antigravity/src/main.rs | 103 ++++++++------ crates/adapter-claude/src/main.rs | 181 +++++++++++++++++++++---- crates/adapter-codex/src/main.rs | 96 ++++++++++++- 3 files changed, 303 insertions(+), 77 deletions(-) diff --git a/crates/adapter-antigravity/src/main.rs b/crates/adapter-antigravity/src/main.rs index e068a6ca..5037213e 100644 --- a/crates/adapter-antigravity/src/main.rs +++ b/crates/adapter-antigravity/src/main.rs @@ -576,13 +576,20 @@ fn emit_new_transcript_steps(path: &Path, after: i64, emit: &EventEmitter) -> i6 /// Map one transcript step to a `SessionEvent`. fn emit_step(v: &Value, emit: &EventEmitter) { + for event in antigravity_events_from_step(v) { + emit.emit(event); + } +} + +fn antigravity_events_from_step(v: &Value) -> Vec { let ty = v.get("type").and_then(|s| s.as_str()).unwrap_or(""); match ty { // Structural / already-known-to-daemon — skip. - "USER_INPUT" | "CONVERSATION_HISTORY" => {} + "USER_INPUT" | "CONVERSATION_HISTORY" => Vec::new(), "PLANNER_RESPONSE" => { // Either a tool-call decision or assistant prose. if let Some(calls) = v.get("tool_calls").and_then(|c| c.as_array()) { + let mut out = Vec::new(); for c in calls { let name = c .get("name") @@ -590,15 +597,20 @@ fn emit_step(v: &Value, emit: &EventEmitter) { .unwrap_or("?") .to_string(); let args = c.get("args").cloned().unwrap_or(Value::Null); - emit.emit(SessionEvent::ToolUse { tool: name, args }); + out.push(SessionEvent::ToolUse { tool: name, args }); } + out } else if let Some(content) = v.get("content").and_then(|s| s.as_str()) { if !content.is_empty() { - emit.emit(SessionEvent::Message { + vec![SessionEvent::Message { role: MessageRole::Assistant, text: content.to_string(), - }); + }] + } else { + Vec::new() } + } else { + Vec::new() } } // Any other step type is a tool-result step named after the tool @@ -612,11 +624,11 @@ fn emit_step(v: &Value, emit: &EventEmitter) { .and_then(|s| s.as_str()) .unwrap_or("") .to_string(); - emit.emit(SessionEvent::ToolResult { + vec![SessionEvent::ToolResult { tool: ty.to_string(), ok, output, - }); + }] } } } @@ -671,43 +683,52 @@ mod tests { let _ = std::fs::remove_dir_all(&dir); } - // Verifies the step→event classification without a live emitter, by - // re-implementing the branch decision the way `emit_step` does. Keeps - // the schema assumptions pinned: tool_calls→ToolUse, content→Message, - // unknown type→ToolResult. #[test] - fn step_classification_matches_schema() { - fn classify(line: &str) -> &'static str { - let v: Value = serde_json::from_str(line).unwrap(); - let ty = v.get("type").and_then(|s| s.as_str()).unwrap_or(""); - match ty { - "USER_INPUT" | "CONVERSATION_HISTORY" => "skip", - "PLANNER_RESPONSE" => { - if v.get("tool_calls").and_then(|c| c.as_array()).is_some() { - "tool_use" - } else if v.get("content").is_some() { - "message" - } else { - "skip" - } - } - _ => "tool_result", + fn structural_steps_do_not_emit_chat_events() { + let v: Value = serde_json::from_str(r#"{"type":"USER_INPUT","content":"x"}"#).unwrap(); + assert!(antigravity_events_from_step(&v).is_empty()); + } + + #[test] + fn planner_response_content_emits_assistant_message() { + let v: Value = + serde_json::from_str(r#"{"type":"PLANNER_RESPONSE","content":"final"}"#).unwrap(); + match antigravity_events_from_step(&v).as_slice() { + [SessionEvent::Message { role, text }] => { + assert!(matches!(role, MessageRole::Assistant)); + assert_eq!(text, "final"); } + other => panic!("unexpected message events: {other:?}"), + } + } + + #[test] + fn planner_response_tool_calls_emit_tool_uses() { + let v: Value = serde_json::from_str( + r#"{"type":"PLANNER_RESPONSE","tool_calls":[{"name":"run_command","args":{"cmd":"ls"}}]}"#, + ) + .unwrap(); + match antigravity_events_from_step(&v).as_slice() { + [SessionEvent::ToolUse { tool, args }] => { + assert_eq!(tool, "run_command"); + assert_eq!(args["cmd"], "ls"); + } + other => panic!("unexpected tool-use events: {other:?}"), + } + } + + #[test] + fn tool_step_emits_tool_result() { + let v: Value = + serde_json::from_str(r#"{"type":"RUN_COMMAND","status":"DONE","content":"out"}"#) + .unwrap(); + match antigravity_events_from_step(&v).as_slice() { + [SessionEvent::ToolResult { tool, ok, output }] => { + assert_eq!(tool, "RUN_COMMAND"); + assert!(*ok); + assert_eq!(output, "out"); + } + other => panic!("unexpected tool-result events: {other:?}"), } - assert_eq!(classify(r#"{"type":"USER_INPUT","content":"x"}"#), "skip"); - assert_eq!( - classify( - r#"{"type":"PLANNER_RESPONSE","tool_calls":[{"name":"run_command","args":{}}]}"# - ), - "tool_use" - ); - assert_eq!( - classify(r#"{"type":"PLANNER_RESPONSE","content":"final"}"#), - "message" - ); - assert_eq!( - classify(r#"{"type":"RUN_COMMAND","status":"DONE","content":"out"}"#), - "tool_result" - ); } } diff --git a/crates/adapter-claude/src/main.rs b/crates/adapter-claude/src/main.rs index e878c572..31e1c5ab 100644 --- a/crates/adapter-claude/src/main.rs +++ b/crates/adapter-claude/src/main.rs @@ -519,20 +519,50 @@ where fn emit_event_from_json(emit: &EventEmitter, v: Value) { let ty = v.get("type").and_then(|t| t.as_str()).unwrap_or(""); match ty { + "assistant" | "user" | "result" => { + for event in claude_events_from_json(&v) { + emit.emit(event); + } + } + "system" => { + emit.log(format!( + "system: {}", + serde_json::to_string(&v).unwrap_or_default() + )); + } + "rate_limit_event" => { + emit.log(format!( + "rate_limit: {}", + serde_json::to_string(&v).unwrap_or_default() + )); + } + other => { + emit.log(format!( + "claude event[{other}]: {}", + serde_json::to_string(&v).unwrap_or_default() + )); + } + } +} + +fn claude_events_from_json(v: &Value) -> Vec { + match v.get("type").and_then(|t| t.as_str()).unwrap_or("") { "assistant" => { + let mut out = Vec::new(); let text = extract_message_text(v.get("message")); if !text.is_empty() { - emit.emit(SessionEvent::Message { + out.push(SessionEvent::Message { role: MessageRole::Assistant, text, }); } - forward_tool_uses(emit, v.get("message")); + out.extend(tool_uses_from_message(v.get("message"))); + out } "user" => { // The CLI echoes tool_result blocks here. The actual user text is // already in the transcript (daemon emits it when sending input). - forward_tool_results(emit, v.get("message")); + tool_results_from_message(v.get("message")) } "result" => { let usd = v @@ -550,32 +580,17 @@ fn emit_event_from_json(emit: &EventEmitter, v: Value) { .and_then(|n| n.as_u64()) .unwrap_or(0); if usd > 0.0 || tin > 0 || tout > 0 { - emit.emit(SessionEvent::Cost { + vec![SessionEvent::Cost { usd, tokens_in: tin, tokens_out: tout, - }); + }] + } else { + Vec::new() } // The `result` text duplicates the assistant's final message; skip it. } - "system" => { - emit.log(format!( - "system: {}", - serde_json::to_string(&v).unwrap_or_default() - )); - } - "rate_limit_event" => { - emit.log(format!( - "rate_limit: {}", - serde_json::to_string(&v).unwrap_or_default() - )); - } - other => { - emit.log(format!( - "claude event[{other}]: {}", - serde_json::to_string(&v).unwrap_or_default() - )); - } + _ => Vec::new(), } } @@ -603,13 +618,14 @@ fn extract_message_text(msg: Option<&Value>) -> String { String::new() } -fn forward_tool_uses(emit: &EventEmitter, msg: Option<&Value>) { +fn tool_uses_from_message(msg: Option<&Value>) -> Vec { let Some(arr) = msg .and_then(|m| m.get("content")) .and_then(|c| c.as_array()) else { - return; + return Vec::new(); }; + let mut out = Vec::new(); for block in arr { if block.get("type").and_then(|t| t.as_str()) == Some("tool_use") { let name = block @@ -618,21 +634,23 @@ fn forward_tool_uses(emit: &EventEmitter, msg: Option<&Value>) { .unwrap_or("?") .to_string(); let input = block.get("input").cloned().unwrap_or(Value::Null); - emit.emit(SessionEvent::ToolUse { + out.push(SessionEvent::ToolUse { tool: name, args: input, }); } } + out } -fn forward_tool_results(emit: &EventEmitter, msg: Option<&Value>) { +fn tool_results_from_message(msg: Option<&Value>) -> Vec { let Some(arr) = msg .and_then(|m| m.get("content")) .and_then(|c| c.as_array()) else { - return; + return Vec::new(); }; + let mut out = Vec::new(); for block in arr { if block.get("type").and_then(|t| t.as_str()) == Some("tool_result") { let tool = block @@ -649,9 +667,10 @@ fn forward_tool_results(emit: &EventEmitter, msg: Option<&Value>) { Some(v) => serde_json::to_string(v).unwrap_or_default(), None => String::new(), }; - emit.emit(SessionEvent::ToolResult { tool, ok, output }); + out.push(SessionEvent::ToolResult { tool, ok, output }); } } + out } fn short(s: &str, max: usize) -> String { @@ -661,3 +680,107 @@ fn short(s: &str, max: usize) -> String { s.chars().take(max).collect::() + "..." } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn claude_project_slug_matches_project_dir_encoding() { + assert_eq!( + claude_project_slug(Path::new("/Users/moon/agentd/.claude/worktrees/test")), + "-Users-moon-agentd--claude-worktrees-test" + ); + } + + #[test] + fn assistant_transcript_record_emits_message_and_tool_use() { + let v = serde_json::json!({ + "type": "assistant", + "message": { + "role": "assistant", + "content": [ + { "type": "text", "text": "I will inspect it." }, + { + "type": "tool_use", + "id": "toolu_1", + "name": "Bash", + "input": { "command": "cargo test" } + } + ] + } + }); + + let events = claude_events_from_json(&v); + assert_eq!(events.len(), 2); + match &events[0] { + SessionEvent::Message { role, text } => { + assert!(matches!(role, MessageRole::Assistant)); + assert_eq!(text, "I will inspect it."); + } + other => panic!("unexpected message event: {other:?}"), + } + match &events[1] { + SessionEvent::ToolUse { tool, args } => { + assert_eq!(tool, "Bash"); + assert_eq!(args["command"], "cargo test"); + } + other => panic!("unexpected tool-use event: {other:?}"), + } + } + + #[test] + fn user_tool_result_record_emits_tool_result() { + let v = serde_json::json!({ + "type": "user", + "message": { + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_use_id": "toolu_1", + "content": "finished", + "is_error": false + } + ] + } + }); + + match claude_events_from_json(&v).as_slice() { + [SessionEvent::ToolResult { tool, ok, output }] => { + assert_eq!(tool, "toolu_1"); + assert!(*ok); + assert_eq!(output, "finished"); + } + other => panic!("unexpected tool-result events: {other:?}"), + } + } + + #[test] + fn result_record_emits_cost_without_duplicate_message() { + let v = serde_json::json!({ + "type": "result", + "result": "final text that should not become another message", + "total_cost_usd": 0.25, + "usage": { + "input_tokens": 10, + "output_tokens": 20 + } + }); + + match claude_events_from_json(&v).as_slice() { + [ + SessionEvent::Cost { + usd, + tokens_in, + tokens_out, + }, + ] => { + assert_eq!(*usd, 0.25); + assert_eq!(*tokens_in, 10); + assert_eq!(*tokens_out, 20); + } + other => panic!("unexpected cost events: {other:?}"), + } + } +} diff --git a/crates/adapter-codex/src/main.rs b/crates/adapter-codex/src/main.rs index 1844dd11..27bc6600 100644 --- a/crates/adapter-codex/src/main.rs +++ b/crates/adapter-codex/src/main.rs @@ -325,11 +325,17 @@ fn emit_new_codex_rollout_lines(path: &Path, next_line: &mut usize, emit: &Event } fn emit_codex_rollout_event(emit: &EventEmitter, v: &Value) { + for event in codex_rollout_events(v) { + emit.emit(event); + } +} + +fn codex_rollout_events(v: &Value) -> Vec { if v.get("type").and_then(|t| t.as_str()) != Some("response_item") { - return; + return Vec::new(); } let Some(payload) = v.get("payload") else { - return; + return Vec::new(); }; match payload.get("type").and_then(|t| t.as_str()).unwrap_or("") { "message" => { @@ -339,9 +345,10 @@ fn emit_codex_rollout_event(emit: &EventEmitter, v: &Value) { }; if let Some(text) = extract_text_from_blocks(payload.get("content")) { if !text.trim().is_empty() { - emit.emit(SessionEvent::Message { role, text }); + return vec![SessionEvent::Message { role, text }]; } } + Vec::new() } "function_call" => { let tool = payload @@ -355,7 +362,7 @@ fn emit_codex_rollout_event(emit: &EventEmitter, v: &Value) { .and_then(|s| serde_json::from_str::(s).ok()) .or_else(|| payload.get("arguments").cloned()) .unwrap_or(Value::Null); - emit.emit(SessionEvent::ToolUse { tool, args }); + vec![SessionEvent::ToolUse { tool, args }] } "function_call_output" => { let tool = payload @@ -368,13 +375,13 @@ fn emit_codex_rollout_event(emit: &EventEmitter, v: &Value) { Some(v) => serde_json::to_string(v).unwrap_or_default(), None => String::new(), }; - emit.emit(SessionEvent::ToolResult { + vec![SessionEvent::ToolResult { tool, ok: true, output, - }); + }] } - _ => {} + _ => Vec::new(), } } @@ -864,4 +871,79 @@ mod tests { assert_ne!(p, PathBuf::from("/sessions")); } } + + #[test] + fn rollout_message_records_become_chat_messages() { + let user = serde_json::json!({ + "type": "response_item", + "payload": { + "type": "message", + "role": "user", + "content": [{ "type": "input_text", "text": "hello" }] + } + }); + let assistant = serde_json::json!({ + "type": "response_item", + "payload": { + "type": "message", + "role": "assistant", + "content": [ + { "type": "output_text", "text": "one" }, + { "type": "output_text", "text": "two" } + ] + } + }); + + match codex_rollout_events(&user).as_slice() { + [SessionEvent::Message { role, text }] => { + assert!(matches!(role, MessageRole::User)); + assert_eq!(text, "hello"); + } + other => panic!("unexpected user events: {other:?}"), + } + match codex_rollout_events(&assistant).as_slice() { + [SessionEvent::Message { role, text }] => { + assert!(matches!(role, MessageRole::Assistant)); + assert_eq!(text, "one\ntwo"); + } + other => panic!("unexpected assistant events: {other:?}"), + } + } + + #[test] + fn rollout_function_records_become_tool_events() { + let call = serde_json::json!({ + "type": "response_item", + "payload": { + "type": "function_call", + "name": "exec_command", + "arguments": "{\"cmd\":\"cargo test\"}", + "call_id": "call_1" + } + }); + let output = serde_json::json!({ + "type": "response_item", + "payload": { + "type": "function_call_output", + "call_id": "call_1", + "output": "ok" + } + }); + + match codex_rollout_events(&call).as_slice() { + [SessionEvent::ToolUse { tool, args }] => { + assert_eq!(tool, "exec_command"); + assert_eq!(args["cmd"], "cargo test"); + } + other => panic!("unexpected tool-use events: {other:?}"), + } + match codex_rollout_events(&output).as_slice() { + [SessionEvent::ToolResult { tool, ok, output }] => { + assert_eq!(tool, "call_1"); + assert!(*ok); + assert_eq!(output, "ok"); + } + other => panic!("unexpected tool-result events: {other:?}"), + } + } }