1use serde::{Deserialize, Serialize};
32use serde_json::Value;
33
34use crate::types::{ContentBlock, SessionEvent, StopReason};
35
36#[derive(Debug, Clone, Serialize, Deserialize)]
50#[non_exhaustive]
51pub enum RunnerOutput {
52 TextContent {
54 text: String,
56 },
57
58 BuiltinToolCall {
60 tool_use_id: String,
62 name: String,
64 input: Value,
66 },
67
68 CustomToolCall {
71 custom_tool_use_id: String,
73 name: String,
75 input: Value,
77 },
78
79 McpToolCall {
81 tool_use_id: String,
83 name: String,
85 input: Value,
87 },
88
89 TurnComplete {
91 stop_reason: StopReason,
93 },
94}
95
96#[derive(Debug, Clone, Copy, PartialEq, Eq)]
101pub enum ToolKind {
102 Builtin,
104 Custom,
106 Mcp,
108}
109
110pub fn map_runner_output(output: RunnerOutput, seq: u64) -> SessionEvent {
138 match output {
139 RunnerOutput::TextContent { text } => {
140 SessionEvent::Message { content: vec![ContentBlock::Text { text }], seq }
141 }
142 RunnerOutput::BuiltinToolCall { tool_use_id, name, input } => {
143 SessionEvent::ToolUse { tool_use_id, name, input, seq }
144 }
145 RunnerOutput::CustomToolCall { custom_tool_use_id, name, input } => {
146 SessionEvent::CustomToolUse { custom_tool_use_id, name, input, seq }
147 }
148 RunnerOutput::McpToolCall { tool_use_id, name, input } => {
149 SessionEvent::McpToolUse { tool_use_id, name, input, seq }
150 }
151 RunnerOutput::TurnComplete { stop_reason } => {
152 SessionEvent::StatusIdle { seq, stop_reason: Some(stop_reason), usage: None }
153 }
154 }
155}
156
157pub fn requires_parking(output: &RunnerOutput) -> bool {
163 matches!(output, RunnerOutput::CustomToolCall { .. })
164}
165
166pub fn custom_tool_use_id(output: &RunnerOutput) -> Option<&str> {
170 match output {
171 RunnerOutput::CustomToolCall { custom_tool_use_id, .. } => Some(custom_tool_use_id),
172 _ => None,
173 }
174}
175
176#[cfg(test)]
177mod tests {
178 use super::*;
179 use serde_json::json;
180
181 #[test]
182 fn test_text_content_maps_to_agent_message() {
183 let output = RunnerOutput::TextContent { text: "Hello from the model".to_string() };
184 let event = map_runner_output(output, 5);
185
186 match event {
187 SessionEvent::Message { content, seq } => {
188 assert_eq!(seq, 5);
189 assert_eq!(content.len(), 1);
190 match &content[0] {
191 ContentBlock::Text { text } => {
192 assert_eq!(text, "Hello from the model");
193 }
194 _ => panic!("expected Text content block"),
195 }
196 }
197 _ => panic!("expected Message event"),
198 }
199 }
200
201 #[test]
202 fn test_builtin_tool_call_maps_to_tool_use() {
203 let output = RunnerOutput::BuiltinToolCall {
204 tool_use_id: "tu_001".to_string(),
205 name: "web_search".to_string(),
206 input: json!({"query": "rust async"}),
207 };
208 let event = map_runner_output(output, 10);
209
210 match event {
211 SessionEvent::ToolUse { tool_use_id, name, input, seq } => {
212 assert_eq!(seq, 10);
213 assert_eq!(tool_use_id, "tu_001");
214 assert_eq!(name, "web_search");
215 assert_eq!(input["query"], "rust async");
216 }
217 _ => panic!("expected ToolUse event"),
218 }
219 }
220
221 #[test]
222 fn test_custom_tool_call_maps_to_custom_tool_use() {
223 let output = RunnerOutput::CustomToolCall {
224 custom_tool_use_id: "ctu_002".to_string(),
225 name: "deploy".to_string(),
226 input: json!({"target": "production"}),
227 };
228 let event = map_runner_output(output, 20);
229
230 match event {
231 SessionEvent::CustomToolUse { custom_tool_use_id, name, input, seq } => {
232 assert_eq!(seq, 20);
233 assert_eq!(custom_tool_use_id, "ctu_002");
234 assert_eq!(name, "deploy");
235 assert_eq!(input["target"], "production");
236 }
237 _ => panic!("expected CustomToolUse event"),
238 }
239 }
240
241 #[test]
242 fn test_mcp_tool_call_maps_to_mcp_tool_use() {
243 let output = RunnerOutput::McpToolCall {
244 tool_use_id: "mcp_003".to_string(),
245 name: "file_read".to_string(),
246 input: json!({"path": "/tmp/data.txt"}),
247 };
248 let event = map_runner_output(output, 30);
249
250 match event {
251 SessionEvent::McpToolUse { tool_use_id, name, input, seq } => {
252 assert_eq!(seq, 30);
253 assert_eq!(tool_use_id, "mcp_003");
254 assert_eq!(name, "file_read");
255 assert_eq!(input["path"], "/tmp/data.txt");
256 }
257 _ => panic!("expected McpToolUse event"),
258 }
259 }
260
261 #[test]
262 fn test_turn_complete_maps_to_status_idle() {
263 let output = RunnerOutput::TurnComplete { stop_reason: StopReason::EndTurn };
264 let event = map_runner_output(output, 40);
265
266 match event {
267 SessionEvent::StatusIdle { seq, stop_reason, .. } => {
268 assert_eq!(seq, 40);
269 assert!(matches!(stop_reason, Some(StopReason::EndTurn)));
270 }
271 _ => panic!("expected StatusIdle event"),
272 }
273 }
274
275 #[test]
276 fn test_turn_complete_requires_action() {
277 let output = RunnerOutput::TurnComplete {
278 stop_reason: StopReason::RequiresAction {
279 event_ids: vec!["evt_1".to_string(), "evt_2".to_string()],
280 },
281 };
282 let event = map_runner_output(output, 50);
283
284 match event {
285 SessionEvent::StatusIdle { seq, stop_reason, .. } => {
286 assert_eq!(seq, 50);
287 match stop_reason {
288 Some(StopReason::RequiresAction { event_ids }) => {
289 assert_eq!(event_ids, vec!["evt_1", "evt_2"]);
290 }
291 _ => panic!("expected RequiresAction stop reason"),
292 }
293 }
294 _ => panic!("expected StatusIdle event"),
295 }
296 }
297
298 #[test]
299 fn test_turn_complete_max_tokens() {
300 let output = RunnerOutput::TurnComplete { stop_reason: StopReason::MaxTokens };
301 let event = map_runner_output(output, 60);
302
303 match event {
304 SessionEvent::StatusIdle { seq, stop_reason, .. } => {
305 assert_eq!(seq, 60);
306 assert!(matches!(stop_reason, Some(StopReason::MaxTokens)));
307 }
308 _ => panic!("expected StatusIdle event"),
309 }
310 }
311
312 #[test]
313 fn test_requires_parking_custom_tool() {
314 let output = RunnerOutput::CustomToolCall {
315 custom_tool_use_id: "ctu_park".to_string(),
316 name: "deploy".to_string(),
317 input: json!({}),
318 };
319 assert!(requires_parking(&output));
320 }
321
322 #[test]
323 fn test_requires_parking_other_variants() {
324 let text = RunnerOutput::TextContent { text: "hi".to_string() };
325 let builtin = RunnerOutput::BuiltinToolCall {
326 tool_use_id: "tu".to_string(),
327 name: "search".to_string(),
328 input: json!({}),
329 };
330 let mcp = RunnerOutput::McpToolCall {
331 tool_use_id: "mcp".to_string(),
332 name: "read".to_string(),
333 input: json!({}),
334 };
335 let complete = RunnerOutput::TurnComplete { stop_reason: StopReason::EndTurn };
336
337 assert!(!requires_parking(&text));
338 assert!(!requires_parking(&builtin));
339 assert!(!requires_parking(&mcp));
340 assert!(!requires_parking(&complete));
341 }
342
343 #[test]
344 fn test_custom_tool_use_id_extraction() {
345 let output = RunnerOutput::CustomToolCall {
346 custom_tool_use_id: "ctu_extract".to_string(),
347 name: "deploy".to_string(),
348 input: json!({}),
349 };
350 assert_eq!(custom_tool_use_id(&output), Some("ctu_extract"));
351
352 let text = RunnerOutput::TextContent { text: "hi".to_string() };
353 assert_eq!(custom_tool_use_id(&text), None);
354 }
355
356 #[test]
357 fn test_provider_parity_identical_inputs_produce_identical_outputs() {
358 let from_gemini = RunnerOutput::BuiltinToolCall {
361 tool_use_id: "tu_parity".to_string(),
362 name: "web_search".to_string(),
363 input: json!({"query": "weather"}),
364 };
365 let from_openai = RunnerOutput::BuiltinToolCall {
366 tool_use_id: "tu_parity".to_string(),
367 name: "web_search".to_string(),
368 input: json!({"query": "weather"}),
369 };
370 let from_anthropic = RunnerOutput::BuiltinToolCall {
371 tool_use_id: "tu_parity".to_string(),
372 name: "web_search".to_string(),
373 input: json!({"query": "weather"}),
374 };
375
376 let ev1 = map_runner_output(from_gemini, 0);
377 let ev2 = map_runner_output(from_openai, 0);
378 let ev3 = map_runner_output(from_anthropic, 0);
379
380 let json1 = serde_json::to_string(&ev1).unwrap();
382 let json2 = serde_json::to_string(&ev2).unwrap();
383 let json3 = serde_json::to_string(&ev3).unwrap();
384
385 assert_eq!(json1, json2);
386 assert_eq!(json2, json3);
387 }
388
389 #[test]
390 fn test_mapping_preserves_seq_exactly() {
391 let outputs = vec![
393 RunnerOutput::TextContent { text: "a".to_string() },
394 RunnerOutput::BuiltinToolCall {
395 tool_use_id: "t".to_string(),
396 name: "n".to_string(),
397 input: json!({}),
398 },
399 RunnerOutput::CustomToolCall {
400 custom_tool_use_id: "c".to_string(),
401 name: "n".to_string(),
402 input: json!({}),
403 },
404 RunnerOutput::McpToolCall {
405 tool_use_id: "m".to_string(),
406 name: "n".to_string(),
407 input: json!({}),
408 },
409 RunnerOutput::TurnComplete { stop_reason: StopReason::EndTurn },
410 ];
411
412 for (i, output) in outputs.into_iter().enumerate() {
413 let seq = (i as u64) * 100 + 7;
414 let event = map_runner_output(output, seq);
415
416 let event_seq = match &event {
417 SessionEvent::Message { seq, .. } => *seq,
418 SessionEvent::ToolUse { seq, .. } => *seq,
419 SessionEvent::CustomToolUse { seq, .. } => *seq,
420 SessionEvent::McpToolUse { seq, .. } => *seq,
421 SessionEvent::StatusIdle { seq, .. } => *seq,
422 SessionEvent::StatusRunning { seq } => *seq,
423 SessionEvent::Error { seq, .. } => *seq,
424 };
425 assert_eq!(event_seq, seq);
426 }
427 }
428}