1use serde::{Deserialize, Serialize};
12use serde_json::Value;
13
14#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
16pub struct Usage {
17 #[serde(default)]
18 pub input: f64,
19 #[serde(default)]
20 pub output: f64,
21 #[serde(rename = "cacheRead", default)]
22 pub cache_read: f64,
23 #[serde(rename = "cacheWrite", default)]
24 pub cache_write: f64,
25 #[serde(
26 rename = "totalTokens",
27 default,
28 skip_serializing_if = "Option::is_none"
29 )]
30 pub total_tokens: Option<f64>,
31 #[serde(default, skip_serializing_if = "Option::is_none")]
32 pub cost: Option<UsageCost>,
33 #[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
34 pub extra: serde_json::Map<String, Value>,
35}
36
37#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
39pub struct UsageCost {
40 #[serde(default)]
41 pub input: f64,
42 #[serde(default)]
43 pub output: f64,
44 #[serde(rename = "cacheRead", default)]
45 pub cache_read: f64,
46 #[serde(rename = "cacheWrite", default)]
47 pub cache_write: f64,
48 #[serde(default, skip_serializing_if = "Option::is_none")]
49 pub total: Option<f64>,
50}
51
52#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
57pub struct Model {
58 #[serde(default)]
59 pub id: String,
60 #[serde(default)]
61 pub name: String,
62 #[serde(default)]
63 pub api: String,
64 #[serde(default)]
65 pub provider: String,
66 #[serde(rename = "baseUrl", default)]
67 pub base_url: String,
68 #[serde(default)]
69 pub reasoning: bool,
70 #[serde(default)]
71 pub input: Vec<String>,
72 #[serde(rename = "contextWindow", default)]
73 pub context_window: i64,
74 #[serde(rename = "maxTokens", default)]
75 pub max_tokens: i64,
76 #[serde(default, skip_serializing_if = "Option::is_none")]
77 pub cost: Option<UsageCost>,
78 #[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
79 pub extra: serde_json::Map<String, Value>,
80}
81
82#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
84#[serde(tag = "type", rename_all = "camelCase")]
85pub enum ContentBlock {
86 Text {
87 text: String,
88 },
89 Thinking {
90 thinking: String,
91 },
92 ToolCall {
93 id: String,
94 name: String,
95 arguments: Value,
96 #[serde(
101 rename = "argumentsError",
102 default,
103 skip_serializing_if = "Option::is_none"
104 )]
105 arguments_error: Option<String>,
106 },
107 Image {
108 data: String,
109 #[serde(rename = "mimeType")]
110 mime_type: String,
111 },
112}
113
114#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
117#[serde(tag = "role", rename_all = "camelCase")]
118pub enum PiMessage {
119 System {
128 content: Value,
131 #[serde(default, skip_serializing_if = "serde_json::Map::is_empty")]
135 sections: serde_json::Map<String, Value>,
136 #[serde(rename = "toolsAdded", default, skip_serializing_if = "Vec::is_empty")]
139 tools_added: Vec<Value>,
140 #[serde(
142 rename = "toolsRemoved",
143 default,
144 skip_serializing_if = "Vec::is_empty"
145 )]
146 tools_removed: Vec<Value>,
147 #[serde(default)]
148 timestamp: f64,
149 #[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
150 extra: serde_json::Map<String, Value>,
151 },
152 User {
153 content: Value,
155 #[serde(default)]
156 timestamp: f64,
157 #[serde(default, skip_serializing_if = "Vec::is_empty")]
158 attachments: Vec<Value>,
159 #[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
160 extra: serde_json::Map<String, Value>,
161 },
162 Assistant {
163 content: Vec<ContentBlock>,
164 #[serde(default)]
165 api: String,
166 #[serde(default)]
167 provider: String,
168 #[serde(default)]
169 model: String,
170 #[serde(default, skip_serializing_if = "Option::is_none")]
171 usage: Option<Usage>,
172 #[serde(rename = "stopReason", default)]
175 stop_reason: String,
176 #[serde(
180 rename = "thinkingLevel",
181 default,
182 skip_serializing_if = "Option::is_none"
183 )]
184 thinking_level: Option<String>,
185 #[serde(default)]
186 timestamp: f64,
187 #[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
188 extra: serde_json::Map<String, Value>,
189 },
190 ToolResult {
191 #[serde(rename = "toolCallId", default)]
192 tool_call_id: String,
193 #[serde(rename = "toolName", default)]
194 tool_name: String,
195 content: Value,
196 #[serde(default, skip_serializing_if = "Option::is_none")]
199 usage: Option<Usage>,
200 #[serde(rename = "isError", default)]
201 is_error: bool,
202 #[serde(default)]
203 timestamp: f64,
204 #[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
205 extra: serde_json::Map<String, Value>,
206 },
207 BashExecution {
210 #[serde(default)]
211 command: String,
212 #[serde(default)]
213 output: String,
214 #[serde(rename = "exitCode", default, skip_serializing_if = "Option::is_none")]
215 exit_code: Option<i64>,
216 #[serde(default)]
217 cancelled: bool,
218 #[serde(default)]
219 truncated: bool,
220 #[serde(
221 rename = "fullOutputPath",
222 default,
223 skip_serializing_if = "Option::is_none"
224 )]
225 full_output_path: Option<String>,
226 #[serde(default)]
227 timestamp: f64,
228 #[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
229 extra: serde_json::Map<String, Value>,
230 },
231}
232
233#[derive(Debug, Clone, PartialEq)]
239pub enum PiEvent {
240 AgentStart,
242 AgentEnd { messages: Vec<PiMessage> },
244 TurnStart,
246 TurnEnd {
248 message: Box<PiMessage>,
249 tool_results: Vec<PiMessage>,
250 },
251 MessageStart { message: Box<PiMessage> },
253 MessageUpdate {
257 message: Option<Box<PiMessage>>,
258 usage: Option<Usage>,
259 assistant_message_event: Value,
260 },
261 MessageEnd { message: Box<PiMessage> },
263 ToolExecutionStart {
271 tool_call_id: String,
272 tool_name: String,
273 args: Value,
274 parent_tool_call_id: Option<String>,
275 },
276 ToolExecutionUpdate {
278 tool_call_id: String,
279 tool_name: String,
280 args: Value,
281 partial_result: Value,
282 parent_tool_call_id: Option<String>,
283 },
284 ToolExecutionEnd {
286 tool_call_id: String,
287 tool_name: String,
288 result: Value,
289 is_error: bool,
290 parent_tool_call_id: Option<String>,
291 },
292 Unknown { event_type: String, payload: Value },
297}
298
299impl PiEvent {
300 pub fn event_type(&self) -> &str {
302 match self {
303 PiEvent::AgentStart => "agent_start",
304 PiEvent::AgentEnd { .. } => "agent_end",
305 PiEvent::TurnStart => "turn_start",
306 PiEvent::TurnEnd { .. } => "turn_end",
307 PiEvent::MessageStart { .. } => "message_start",
308 PiEvent::MessageUpdate { .. } => "message_update",
309 PiEvent::MessageEnd { .. } => "message_end",
310 PiEvent::ToolExecutionStart { .. } => "tool_execution_start",
311 PiEvent::ToolExecutionUpdate { .. } => "tool_execution_update",
312 PiEvent::ToolExecutionEnd { .. } => "tool_execution_end",
313 PiEvent::Unknown { event_type, .. } => event_type,
314 }
315 }
316
317 pub fn from_value(v: Value) -> Result<Self, serde_json::Error> {
319 let t = v
320 .get("type")
321 .and_then(Value::as_str)
322 .unwrap_or_default()
323 .to_string();
324 Ok(match t.as_str() {
325 "agent_start" => PiEvent::AgentStart,
326 "agent_end" => PiEvent::AgentEnd {
327 messages: serde_json::from_value(
328 v.get("messages").cloned().unwrap_or(Value::Array(vec![])),
329 )?,
330 },
331 "turn_start" => PiEvent::TurnStart,
332 "turn_end" => PiEvent::TurnEnd {
333 message: serde_json::from_value(v.get("message").cloned().unwrap_or(Value::Null))?,
334 tool_results: serde_json::from_value(
335 v.get("toolResults")
336 .cloned()
337 .unwrap_or(Value::Array(vec![])),
338 )?,
339 },
340 "message_start" => PiEvent::MessageStart {
341 message: serde_json::from_value(v.get("message").cloned().unwrap_or(Value::Null))?,
342 },
343 "message_update" => PiEvent::MessageUpdate {
344 message: match v.get("message") {
345 Some(m) => Some(serde_json::from_value(m.clone())?),
346 None => None,
347 },
348 usage: match v.get("usage") {
349 Some(u) => Some(serde_json::from_value(u.clone())?),
350 None => None,
351 },
352 assistant_message_event: v
353 .get("assistantMessageEvent")
354 .cloned()
355 .unwrap_or(Value::Null),
356 },
357 "message_end" => PiEvent::MessageEnd {
358 message: serde_json::from_value(v.get("message").cloned().unwrap_or(Value::Null))?,
359 },
360 "tool_execution_start" => PiEvent::ToolExecutionStart {
361 tool_call_id: str_field(&v, "toolCallId"),
362 tool_name: str_field(&v, "toolName"),
363 args: v.get("args").cloned().unwrap_or(Value::Null),
364 parent_tool_call_id: opt_str_field(&v, "parentToolCallId"),
365 },
366 "tool_execution_update" => PiEvent::ToolExecutionUpdate {
367 tool_call_id: str_field(&v, "toolCallId"),
368 tool_name: str_field(&v, "toolName"),
369 args: v.get("args").cloned().unwrap_or(Value::Null),
370 partial_result: v.get("partialResult").cloned().unwrap_or(Value::Null),
371 parent_tool_call_id: opt_str_field(&v, "parentToolCallId"),
372 },
373 "tool_execution_end" => PiEvent::ToolExecutionEnd {
374 tool_call_id: str_field(&v, "toolCallId"),
375 tool_name: str_field(&v, "toolName"),
376 result: v.get("result").cloned().unwrap_or(Value::Null),
377 is_error: v
378 .get("isError")
379 .and_then(Value::as_bool)
380 .unwrap_or_default(),
381 parent_tool_call_id: opt_str_field(&v, "parentToolCallId"),
382 },
383 _ => PiEvent::Unknown {
384 event_type: t,
385 payload: v,
386 },
387 })
388 }
389}
390
391fn str_field(v: &Value, k: &str) -> String {
392 v.get(k)
393 .and_then(Value::as_str)
394 .unwrap_or_default()
395 .to_string()
396}
397
398fn opt_str_field(v: &Value, k: &str) -> Option<String> {
399 v.get(k).and_then(Value::as_str).map(str::to_string)
400}
401
402#[cfg(test)]
403mod tests {
404 use super::*;
405
406 #[test]
407 fn assistant_message_from_docs_parses() {
408 let m: PiMessage = serde_json::from_value(serde_json::json!({
409 "role": "assistant",
410 "content": [
411 {"type": "text", "text": "Hello! How can I help?"},
412 {"type": "thinking", "thinking": "User is greeting me..."},
413 {"type": "toolCall", "id": "call_123", "name": "bash", "arguments": {"command": "ls"}}
414 ],
415 "api": "anthropic-messages",
416 "provider": "anthropic",
417 "model": "claude-sonnet-4-20250514",
418 "usage": {
419 "input": 100, "output": 50, "cacheRead": 0, "cacheWrite": 0,
420 "cost": {"input": 0.0003, "output": 0.00075, "cacheRead": 0, "cacheWrite": 0, "total": 0.00105}
421 },
422 "stopReason": "stop",
423 "timestamp": 1733234567890i64
424 }))
425 .unwrap();
426 let PiMessage::Assistant {
427 content,
428 stop_reason,
429 ..
430 } = m
431 else {
432 panic!("wrong role")
433 };
434 assert_eq!(content.len(), 3);
435 assert_eq!(stop_reason, "stop");
436 assert!(matches!(
437 content[2],
438 ContentBlock::ToolCall {
439 arguments_error: None,
440 ..
441 }
442 ));
443 }
444
445 #[test]
446 fn tool_call_arguments_error_round_trips() {
447 let raw = serde_json::json!({
448 "type": "toolCall",
449 "id": "call_7",
450 "name": "read",
451 "arguments": {},
452 "argumentsError": "Tool arguments are not valid JSON: {\"path\": \"a"
453 });
454 let block: ContentBlock = serde_json::from_value(raw.clone()).unwrap();
455 let ContentBlock::ToolCall {
456 ref arguments_error,
457 ..
458 } = block
459 else {
460 panic!("wrong block")
461 };
462 assert!(arguments_error
463 .as_deref()
464 .is_some_and(|e| e.contains("not valid JSON")));
465 assert_eq!(serde_json::to_value(&block).unwrap(), raw);
466 }
467
468 #[test]
469 fn system_message_from_docs_parses() {
470 let m: PiMessage = serde_json::from_value(serde_json::json!({
471 "role": "system",
472 "content": "",
473 "sections": {
474 "preamble": "You are an expert coding assistant...",
475 "tools": "<tools>\n- read: ...\n</tools>",
476 "cwd": "/project"
477 },
478 "toolsAdded": [{"name": "read", "description": "...", "parameters": {}}],
479 "timestamp": 1733234400000i64
480 }))
481 .unwrap();
482 let PiMessage::System {
483 sections,
484 tools_added,
485 tools_removed,
486 ..
487 } = m
488 else {
489 panic!("wrong role")
490 };
491 assert_eq!(sections.len(), 3);
492 assert_eq!(tools_added[0]["name"], "read");
493 assert!(tools_removed.is_empty());
494
495 let patch: PiMessage = serde_json::from_value(serde_json::json!({
496 "role": "system",
497 "content": "",
498 "sections": {"skills": null},
499 "toolsRemoved": [{"name": "write"}],
500 "timestamp": 1733234640000i64
501 }))
502 .unwrap();
503 let PiMessage::System {
504 sections,
505 tools_removed,
506 ..
507 } = patch
508 else {
509 panic!("wrong role")
510 };
511 assert!(sections["skills"].is_null());
512 assert_eq!(tools_removed[0]["name"], "write");
513 }
514
515 #[test]
516 fn bash_execution_message_round_trips() {
517 let v = serde_json::json!({
518 "role": "bashExecution",
519 "command": "ls -la",
520 "output": "total 48",
521 "exitCode": 0,
522 "cancelled": false,
523 "truncated": false,
524 "timestamp": 1733234567890i64
525 });
526 let m: PiMessage = serde_json::from_value(v).unwrap();
527 assert!(matches!(m, PiMessage::BashExecution { .. }));
528 }
529
530 #[test]
531 fn unknown_event_type_is_preserved_not_error() {
532 let e = PiEvent::from_value(serde_json::json!({
533 "type": "queue_update", "steering": [], "followUp": []
534 }))
535 .unwrap();
536 let PiEvent::Unknown {
537 event_type,
538 payload,
539 } = e
540 else {
541 panic!("expected Unknown")
542 };
543 assert_eq!(event_type, "queue_update");
544 assert!(payload.get("steering").is_some());
545 }
546
547 #[test]
548 fn tool_execution_events_lift_fields() {
549 let e = PiEvent::from_value(serde_json::json!({
550 "type": "tool_execution_end",
551 "toolCallId": "call_9", "toolName": "bash",
552 "result": {"ok": true}, "isError": false
553 }))
554 .unwrap();
555 let PiEvent::ToolExecutionEnd {
556 tool_call_id,
557 tool_name,
558 is_error,
559 parent_tool_call_id,
560 ..
561 } = e
562 else {
563 panic!("wrong variant")
564 };
565 assert_eq!(
566 (tool_call_id.as_str(), tool_name.as_str()),
567 ("call_9", "bash")
568 );
569 assert!(!is_error);
570 assert_eq!(parent_tool_call_id, None);
571 }
572
573 #[test]
574 fn nested_tool_execution_events_carry_parent_id() {
575 for (event_type, extra_key) in [
576 ("tool_execution_start", "args"),
577 ("tool_execution_update", "partialResult"),
578 ("tool_execution_end", "result"),
579 ] {
580 let e = PiEvent::from_value(serde_json::json!({
581 "type": event_type,
582 "toolCallId": "call_9/1", "toolName": "read",
583 extra_key: {}, "parentToolCallId": "call_9"
584 }))
585 .unwrap();
586 let parent = match e {
587 PiEvent::ToolExecutionStart {
588 parent_tool_call_id,
589 ..
590 }
591 | PiEvent::ToolExecutionUpdate {
592 parent_tool_call_id,
593 ..
594 }
595 | PiEvent::ToolExecutionEnd {
596 parent_tool_call_id,
597 ..
598 } => parent_tool_call_id,
599 other => panic!("wrong variant: {other:?}"),
600 };
601 assert_eq!(parent.as_deref(), Some("call_9"), "{event_type}");
602 }
603 }
604
605 #[test]
606 fn assistant_thinking_level_is_typed_and_optional() {
607 let with: PiMessage = serde_json::from_value(serde_json::json!({
608 "role": "assistant", "content": [], "stopReason": "stop",
609 "thinkingLevel": "off", "timestamp": 1790756884219i64
610 }))
611 .unwrap();
612 let PiMessage::Assistant {
613 thinking_level,
614 extra,
615 ..
616 } = &with
617 else {
618 panic!("wrong role")
619 };
620 assert_eq!(thinking_level.as_deref(), Some("off"));
621 assert!(!extra.contains_key("thinkingLevel"));
622 assert_eq!(serde_json::to_value(&with).unwrap()["thinkingLevel"], "off");
623
624 let without: PiMessage = serde_json::from_value(serde_json::json!({
625 "role": "assistant", "content": [], "stopReason": "stop"
626 }))
627 .unwrap();
628 let PiMessage::Assistant { thinking_level, .. } = &without else {
629 panic!("wrong role")
630 };
631 assert_eq!(*thinking_level, None);
632 assert!(serde_json::to_value(&without)
633 .unwrap()
634 .get("thinkingLevel")
635 .is_none());
636 }
637}