1use serde::{Deserialize, Serialize};
2use strum::{Display, EnumString};
3
4#[derive(Debug, Clone, Serialize, Deserialize)]
5#[serde(tag = "kind", rename_all = "snake_case")]
6#[non_exhaustive]
7pub enum Action {
8 Command {
9 command: String,
10 #[serde(default)]
11 output: String,
12 exit_code: Option<i32>,
13 status: ActionStatus,
14 },
15 FileChange {
16 changes: Vec<FileChange>,
17 status: ActionStatus,
18 },
19 McpToolCall {
20 server: String,
21 tool: String,
22 #[serde(default)]
23 arguments: serde_json::Value,
24 result: Option<serde_json::Value>,
25 error: Option<String>,
26 status: ActionStatus,
27 },
28 WebSearch {
29 query: String,
30 },
31 ToolUse {
32 name: String,
33 #[serde(default)]
34 input: serde_json::Value,
35 result: Option<serde_json::Value>,
36 #[serde(default)]
37 is_error: bool,
38 },
39 Collaboration {
40 tool: String,
41 #[serde(default)]
42 agents: serde_json::Value,
43 status: ActionStatus,
44 },
45 Other {
46 name: String,
47 #[serde(default)]
48 data: serde_json::Value,
49 },
50}
51
52#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Display, EnumString)]
53#[serde(rename_all = "snake_case")]
54#[strum(serialize_all = "snake_case")]
55#[non_exhaustive]
56pub enum ActionStatus {
57 InProgress,
58 Completed,
59 Failed,
60 Declined,
61}
62
63#[derive(Debug, Clone, Serialize, Deserialize)]
64pub struct FileChange {
65 pub path: String,
66 pub kind: FileChangeKind,
67}
68
69#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Display, EnumString)]
70#[serde(rename_all = "snake_case")]
71#[strum(serialize_all = "snake_case")]
72#[non_exhaustive]
73pub enum FileChangeKind {
74 Add,
75 Delete,
76 Update,
77}
78
79#[derive(Debug, Clone, Default, Serialize, Deserialize)]
80pub struct Usage {
81 #[serde(default)]
82 pub input_tokens: Option<u64>,
83 #[serde(default)]
84 pub output_tokens: Option<u64>,
85 #[serde(default)]
86 pub cache_read_tokens: Option<u64>,
87 #[serde(default)]
88 pub cache_creation_tokens: Option<u64>,
89 #[serde(default)]
90 pub total_cost_usd: Option<f64>,
91}
92
93#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Display, EnumString)]
94#[serde(rename_all = "snake_case")]
95#[strum(serialize_all = "snake_case")]
96pub enum TurnStatus {
97 Completed,
98 Interrupted,
99 Failed,
100}
101
102#[derive(Debug, Clone, Serialize, Deserialize)]
103#[serde(tag = "kind", rename_all = "snake_case")]
104pub enum TurnProviderMeta {
105 #[serde(rename = "claude_code")]
106 ClaudeCode {
107 #[serde(default)]
108 stop_reason: Option<String>,
109 },
110 #[serde(rename = "opencode")]
111 OpenCode {
112 #[serde(default)]
113 finish_reason: Option<String>,
114 },
115}
116
117#[derive(Debug, Clone, Default, Serialize, Deserialize)]
118pub struct TurnResult {
119 pub text: Option<String>,
120 pub duration_ms: Option<u64>,
121 pub session_id: Option<String>,
122 pub num_turns: Option<u32>,
123 #[serde(default)]
124 pub turn_status: Option<TurnStatus>,
125 #[serde(default)]
126 pub provider_meta: Option<TurnProviderMeta>,
127}
128
129#[derive(Debug, Clone, Serialize, Deserialize)]
130pub struct PlanItem {
131 pub text: String,
132 pub completed: bool,
133}
134
135#[cfg(test)]
136mod tests {
137 use super::*;
138 use std::str::FromStr;
139
140 #[test]
141 fn action_status_display() {
142 assert_eq!(ActionStatus::InProgress.to_string(), "in_progress");
143 assert_eq!(ActionStatus::Completed.to_string(), "completed");
144 assert_eq!(ActionStatus::Failed.to_string(), "failed");
145 assert_eq!(ActionStatus::Declined.to_string(), "declined");
146 }
147
148 #[test]
149 fn action_status_from_str() {
150 assert_eq!(
151 ActionStatus::from_str("in_progress").unwrap(),
152 ActionStatus::InProgress
153 );
154 assert_eq!(
155 ActionStatus::from_str("completed").unwrap(),
156 ActionStatus::Completed
157 );
158 }
159
160 #[test]
161 fn file_change_kind_roundtrip() {
162 assert_eq!(
163 FileChangeKind::from_str("add").unwrap(),
164 FileChangeKind::Add
165 );
166 assert_eq!(FileChangeKind::Delete.to_string(), "delete");
167 }
168
169 #[test]
170 fn action_serde_roundtrip() {
171 let action = Action::Command {
172 command: "ls -la".into(),
173 output: "total 0".into(),
174 exit_code: Some(0),
175 status: ActionStatus::Completed,
176 };
177
178 let json = serde_json::to_string(&action).unwrap();
179 let parsed: Action = serde_json::from_str(&json).unwrap();
180
181 match parsed {
182 Action::Command {
183 command,
184 exit_code,
185 status,
186 ..
187 } => {
188 assert_eq!(command, "ls -la");
189 assert_eq!(exit_code, Some(0));
190 assert_eq!(status, ActionStatus::Completed);
191 }
192 _ => panic!("expected Command variant"),
193 }
194 }
195
196 #[test]
197 fn usage_defaults() {
198 let usage: Usage = serde_json::from_str("{}").unwrap();
199 assert!(usage.input_tokens.is_none());
200 assert!(usage.output_tokens.is_none());
201 assert!(usage.total_cost_usd.is_none());
202 }
203
204 #[test]
205 fn turn_status_roundtrip() {
206 assert_eq!(
207 TurnStatus::from_str("completed").unwrap(),
208 TurnStatus::Completed
209 );
210 assert_eq!(TurnStatus::Interrupted.to_string(), "interrupted");
211 }
212
213 #[test]
214 fn turn_result_defaults() {
215 let result: TurnResult = serde_json::from_str("{}").unwrap();
216 assert!(result.text.is_none());
217 assert!(result.turn_status.is_none());
218 assert!(result.provider_meta.is_none());
219 }
220
221 #[test]
222 fn turn_provider_meta_roundtrip() {
223 let meta = TurnProviderMeta::ClaudeCode {
224 stop_reason: Some("end_turn".into()),
225 };
226 let json = serde_json::to_string(&meta).unwrap();
227 let parsed: TurnProviderMeta = serde_json::from_str(&json).unwrap();
228 match parsed {
229 TurnProviderMeta::ClaudeCode { stop_reason } => {
230 assert_eq!(stop_reason.as_deref(), Some("end_turn"));
231 }
232 _ => panic!("expected ClaudeCode metadata"),
233 }
234 }
235}