use serde::{Deserialize, Serialize};
use serde_json::Value;
use crate::types::{ContentBlock, SessionEvent, StopReason};
#[derive(Debug, Clone, Serialize, Deserialize)]
#[non_exhaustive]
pub enum RunnerOutput {
TextContent {
text: String,
},
BuiltinToolCall {
tool_use_id: String,
name: String,
input: Value,
},
CustomToolCall {
custom_tool_use_id: String,
name: String,
input: Value,
},
McpToolCall {
tool_use_id: String,
name: String,
input: Value,
},
TurnComplete {
stop_reason: StopReason,
},
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ToolKind {
Builtin,
Custom,
Mcp,
}
pub fn map_runner_output(output: RunnerOutput, seq: u64) -> SessionEvent {
match output {
RunnerOutput::TextContent { text } => {
SessionEvent::Message { content: vec![ContentBlock::Text { text }], seq }
}
RunnerOutput::BuiltinToolCall { tool_use_id, name, input } => {
SessionEvent::ToolUse { tool_use_id, name, input, seq }
}
RunnerOutput::CustomToolCall { custom_tool_use_id, name, input } => {
SessionEvent::CustomToolUse { custom_tool_use_id, name, input, seq }
}
RunnerOutput::McpToolCall { tool_use_id, name, input } => {
SessionEvent::McpToolUse { tool_use_id, name, input, seq }
}
RunnerOutput::TurnComplete { stop_reason } => {
SessionEvent::StatusIdle { seq, stop_reason: Some(stop_reason), usage: None }
}
}
}
pub fn requires_parking(output: &RunnerOutput) -> bool {
matches!(output, RunnerOutput::CustomToolCall { .. })
}
pub fn custom_tool_use_id(output: &RunnerOutput) -> Option<&str> {
match output {
RunnerOutput::CustomToolCall { custom_tool_use_id, .. } => Some(custom_tool_use_id),
_ => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn test_text_content_maps_to_agent_message() {
let output = RunnerOutput::TextContent { text: "Hello from the model".to_string() };
let event = map_runner_output(output, 5);
match event {
SessionEvent::Message { content, seq } => {
assert_eq!(seq, 5);
assert_eq!(content.len(), 1);
match &content[0] {
ContentBlock::Text { text } => {
assert_eq!(text, "Hello from the model");
}
_ => panic!("expected Text content block"),
}
}
_ => panic!("expected Message event"),
}
}
#[test]
fn test_builtin_tool_call_maps_to_tool_use() {
let output = RunnerOutput::BuiltinToolCall {
tool_use_id: "tu_001".to_string(),
name: "web_search".to_string(),
input: json!({"query": "rust async"}),
};
let event = map_runner_output(output, 10);
match event {
SessionEvent::ToolUse { tool_use_id, name, input, seq } => {
assert_eq!(seq, 10);
assert_eq!(tool_use_id, "tu_001");
assert_eq!(name, "web_search");
assert_eq!(input["query"], "rust async");
}
_ => panic!("expected ToolUse event"),
}
}
#[test]
fn test_custom_tool_call_maps_to_custom_tool_use() {
let output = RunnerOutput::CustomToolCall {
custom_tool_use_id: "ctu_002".to_string(),
name: "deploy".to_string(),
input: json!({"target": "production"}),
};
let event = map_runner_output(output, 20);
match event {
SessionEvent::CustomToolUse { custom_tool_use_id, name, input, seq } => {
assert_eq!(seq, 20);
assert_eq!(custom_tool_use_id, "ctu_002");
assert_eq!(name, "deploy");
assert_eq!(input["target"], "production");
}
_ => panic!("expected CustomToolUse event"),
}
}
#[test]
fn test_mcp_tool_call_maps_to_mcp_tool_use() {
let output = RunnerOutput::McpToolCall {
tool_use_id: "mcp_003".to_string(),
name: "file_read".to_string(),
input: json!({"path": "/tmp/data.txt"}),
};
let event = map_runner_output(output, 30);
match event {
SessionEvent::McpToolUse { tool_use_id, name, input, seq } => {
assert_eq!(seq, 30);
assert_eq!(tool_use_id, "mcp_003");
assert_eq!(name, "file_read");
assert_eq!(input["path"], "/tmp/data.txt");
}
_ => panic!("expected McpToolUse event"),
}
}
#[test]
fn test_turn_complete_maps_to_status_idle() {
let output = RunnerOutput::TurnComplete { stop_reason: StopReason::EndTurn };
let event = map_runner_output(output, 40);
match event {
SessionEvent::StatusIdle { seq, stop_reason, .. } => {
assert_eq!(seq, 40);
assert!(matches!(stop_reason, Some(StopReason::EndTurn)));
}
_ => panic!("expected StatusIdle event"),
}
}
#[test]
fn test_turn_complete_requires_action() {
let output = RunnerOutput::TurnComplete {
stop_reason: StopReason::RequiresAction {
event_ids: vec!["evt_1".to_string(), "evt_2".to_string()],
},
};
let event = map_runner_output(output, 50);
match event {
SessionEvent::StatusIdle { seq, stop_reason, .. } => {
assert_eq!(seq, 50);
match stop_reason {
Some(StopReason::RequiresAction { event_ids }) => {
assert_eq!(event_ids, vec!["evt_1", "evt_2"]);
}
_ => panic!("expected RequiresAction stop reason"),
}
}
_ => panic!("expected StatusIdle event"),
}
}
#[test]
fn test_turn_complete_max_tokens() {
let output = RunnerOutput::TurnComplete { stop_reason: StopReason::MaxTokens };
let event = map_runner_output(output, 60);
match event {
SessionEvent::StatusIdle { seq, stop_reason, .. } => {
assert_eq!(seq, 60);
assert!(matches!(stop_reason, Some(StopReason::MaxTokens)));
}
_ => panic!("expected StatusIdle event"),
}
}
#[test]
fn test_requires_parking_custom_tool() {
let output = RunnerOutput::CustomToolCall {
custom_tool_use_id: "ctu_park".to_string(),
name: "deploy".to_string(),
input: json!({}),
};
assert!(requires_parking(&output));
}
#[test]
fn test_requires_parking_other_variants() {
let text = RunnerOutput::TextContent { text: "hi".to_string() };
let builtin = RunnerOutput::BuiltinToolCall {
tool_use_id: "tu".to_string(),
name: "search".to_string(),
input: json!({}),
};
let mcp = RunnerOutput::McpToolCall {
tool_use_id: "mcp".to_string(),
name: "read".to_string(),
input: json!({}),
};
let complete = RunnerOutput::TurnComplete { stop_reason: StopReason::EndTurn };
assert!(!requires_parking(&text));
assert!(!requires_parking(&builtin));
assert!(!requires_parking(&mcp));
assert!(!requires_parking(&complete));
}
#[test]
fn test_custom_tool_use_id_extraction() {
let output = RunnerOutput::CustomToolCall {
custom_tool_use_id: "ctu_extract".to_string(),
name: "deploy".to_string(),
input: json!({}),
};
assert_eq!(custom_tool_use_id(&output), Some("ctu_extract"));
let text = RunnerOutput::TextContent { text: "hi".to_string() };
assert_eq!(custom_tool_use_id(&text), None);
}
#[test]
fn test_provider_parity_identical_inputs_produce_identical_outputs() {
let from_gemini = RunnerOutput::BuiltinToolCall {
tool_use_id: "tu_parity".to_string(),
name: "web_search".to_string(),
input: json!({"query": "weather"}),
};
let from_openai = RunnerOutput::BuiltinToolCall {
tool_use_id: "tu_parity".to_string(),
name: "web_search".to_string(),
input: json!({"query": "weather"}),
};
let from_anthropic = RunnerOutput::BuiltinToolCall {
tool_use_id: "tu_parity".to_string(),
name: "web_search".to_string(),
input: json!({"query": "weather"}),
};
let ev1 = map_runner_output(from_gemini, 0);
let ev2 = map_runner_output(from_openai, 0);
let ev3 = map_runner_output(from_anthropic, 0);
let json1 = serde_json::to_string(&ev1).unwrap();
let json2 = serde_json::to_string(&ev2).unwrap();
let json3 = serde_json::to_string(&ev3).unwrap();
assert_eq!(json1, json2);
assert_eq!(json2, json3);
}
#[test]
fn test_mapping_preserves_seq_exactly() {
let outputs = vec![
RunnerOutput::TextContent { text: "a".to_string() },
RunnerOutput::BuiltinToolCall {
tool_use_id: "t".to_string(),
name: "n".to_string(),
input: json!({}),
},
RunnerOutput::CustomToolCall {
custom_tool_use_id: "c".to_string(),
name: "n".to_string(),
input: json!({}),
},
RunnerOutput::McpToolCall {
tool_use_id: "m".to_string(),
name: "n".to_string(),
input: json!({}),
},
RunnerOutput::TurnComplete { stop_reason: StopReason::EndTurn },
];
for (i, output) in outputs.into_iter().enumerate() {
let seq = (i as u64) * 100 + 7;
let event = map_runner_output(output, seq);
let event_seq = match &event {
SessionEvent::Message { seq, .. } => *seq,
SessionEvent::ToolUse { seq, .. } => *seq,
SessionEvent::CustomToolUse { seq, .. } => *seq,
SessionEvent::McpToolUse { seq, .. } => *seq,
SessionEvent::StatusIdle { seq, .. } => *seq,
SessionEvent::StatusRunning { seq } => *seq,
SessionEvent::Error { seq, .. } => *seq,
};
assert_eq!(event_seq, seq);
}
}
}