1use crate::model::message::{ContentBlock, Message};
2use crate::model::provider::StopReason;
3use serde::Serialize;
4
5#[derive(Debug, Clone, Serialize)]
6pub enum TurnEvent {
7 TurnStarted {
8 session_id: String,
9 turn_id: u64,
10 user_input: String,
11 },
12 IterationStarted {
13 iteration: usize,
14 message_count: usize,
15 },
16 ProviderRequest {
17 iteration: usize,
18 message_count: usize,
19 tool_count: usize,
20 },
21 ProviderUsage {
22 input_tokens: usize,
23 output_tokens: usize,
24 total_tokens: Option<usize>,
25 prompt_cache_hit_tokens: Option<usize>,
26 prompt_cache_miss_tokens: Option<usize>,
27 reasoning_tokens: Option<usize>,
28 model: Option<String>,
29 },
30 AssistantDelta {
31 text: String,
32 },
33 ThinkingDelta {
34 text: String,
35 },
36 User(Message),
37 Assistant(Message),
38 ToolCall {
39 tool_call_id: String,
40 name: String,
41 detail: String,
42 },
43 ToolProgress {
44 tool_call_id: Option<String>,
45 name: String,
46 message: String,
47 data: Option<serde_json::Value>,
48 },
49 ToolCompleted {
50 tool_call_id: String,
51 name: String,
52 is_error: bool,
53 detail: Option<String>,
54 },
55 ToolResult(Message),
56 CompactionStarted {
57 reason: String,
58 },
59 CompactionCompleted {
60 reason: String,
61 },
62 TokenBudgetExceeded {
63 used_tokens: usize,
64 max_tokens: usize,
65 },
66 PolicyStarted {
67 point: String,
68 name: String,
69 },
70 PolicyCompleted {
71 point: String,
72 name: String,
73 feedback_count: usize,
74 },
75 ApprovalRequested {
76 tool_call_id: String,
77 name: String,
78 reason: String,
79 },
80 ApprovalResolved {
81 tool_call_id: String,
82 name: String,
83 decision: String,
84 },
85 ProviderRetry {
86 attempt: usize,
87 max_retries: usize,
88 delay_ms: u64,
89 },
90 TurnFailed {
91 error: String,
92 },
93 TurnFinished {
94 stop_reason: StopReason,
95 final_text: String,
96 },
97}
98
99#[derive(Debug, Clone, Serialize)]
100pub struct TurnResult {
101 pub events: Vec<TurnEvent>,
102 pub final_message: Message,
103 pub stop_reason: StopReason,
104}
105
106impl TurnEvent {
107 pub fn message(&self) -> Option<&Message> {
108 match self {
109 TurnEvent::User(message)
110 | TurnEvent::Assistant(message)
111 | TurnEvent::ToolResult(message) => Some(message),
112 _ => None,
113 }
114 }
115
116 pub fn text(&self) -> String {
117 match self {
118 TurnEvent::TurnStarted { session_id, turn_id, user_input } => {
119 format!("turn_started:{}#{}:{}", session_id, turn_id, user_input)
120 }
121 TurnEvent::IterationStarted { iteration, message_count } => {
122 format!("iteration_started:{} messages={}", iteration, message_count)
123 }
124 TurnEvent::ProviderRequest { iteration, message_count, tool_count } => format!(
125 "provider_request:{} messages={} tools={}",
126 iteration, message_count, tool_count
127 ),
128 TurnEvent::ProviderUsage {
129 input_tokens,
130 output_tokens,
131 total_tokens,
132 prompt_cache_hit_tokens,
133 prompt_cache_miss_tokens,
134 reasoning_tokens,
135 model,
136 } => {
137 let mut parts =
138 vec![format!("input={input_tokens}"), format!("output={output_tokens}")];
139 if let Some(tokens) = total_tokens {
140 parts.push(format!("total={tokens}"));
141 }
142 if let Some(tokens) = prompt_cache_hit_tokens {
143 parts.push(format!("cache_hit={tokens}"));
144 }
145 if let Some(tokens) = prompt_cache_miss_tokens {
146 parts.push(format!("cache_miss={tokens}"));
147 }
148 if let Some(tokens) = reasoning_tokens {
149 parts.push(format!("reasoning={tokens}"));
150 }
151 if let Some(model) = model {
152 parts.push(format!("model={model}"));
153 }
154 format!("provider_usage:{}", parts.join(" "))
155 }
156 TurnEvent::AssistantDelta { text } => format!("assistant_delta:{text}"),
157 TurnEvent::ThinkingDelta { text } => format!("thinking_delta:{text}"),
158 TurnEvent::ToolCall { tool_call_id, name, detail } => {
159 if detail.is_empty() {
160 format!("tool_call:{}#{}", name, tool_call_id)
161 } else {
162 format!("tool_call:{}#{} {}", name, tool_call_id, detail)
163 }
164 }
165 TurnEvent::ToolProgress { tool_call_id, name, message, .. } => format!(
166 "tool_progress:{}#{}:{}",
167 name,
168 tool_call_id.as_deref().unwrap_or("unknown"),
169 message
170 ),
171 TurnEvent::ToolCompleted { tool_call_id, name, is_error, detail } => {
172 if let Some(detail) = detail {
173 format!(
174 "tool_completed:{}#{} error={} {}",
175 name, tool_call_id, is_error, detail
176 )
177 } else {
178 format!("tool_completed:{}#{} error={}", name, tool_call_id, is_error)
179 }
180 }
181 TurnEvent::CompactionStarted { reason } => format!("compaction_started:{reason}"),
182 TurnEvent::CompactionCompleted { reason } => format!("compaction_completed:{reason}"),
183 TurnEvent::TokenBudgetExceeded { used_tokens, max_tokens } => {
184 format!("token_budget_exceeded:{used_tokens}/{max_tokens}")
185 }
186 TurnEvent::PolicyStarted { point, name } => {
187 format!("policy_started:{point}:{name}")
188 }
189 TurnEvent::PolicyCompleted { point, name, feedback_count } => {
190 format!("policy_completed:{point}:{name}:{feedback_count}")
191 }
192 TurnEvent::ApprovalRequested { tool_call_id, name, reason } => {
193 format!("approval_requested:{name}#{tool_call_id}:{reason}")
194 }
195 TurnEvent::ApprovalResolved { tool_call_id, name, decision } => {
196 format!("approval_resolved:{name}#{tool_call_id}:{decision}")
197 }
198 TurnEvent::ProviderRetry { attempt, max_retries, delay_ms } => {
199 format!("provider_retry:{attempt}/{max_retries} delay={delay_ms}ms")
200 }
201 TurnEvent::TurnFailed { error } => format!("turn_failed:{error}"),
202 TurnEvent::TurnFinished { stop_reason, final_text } => {
203 format!("turn_finished:{stop_reason:?}:{final_text}")
204 }
205 _ => self
206 .message()
207 .map(|message| {
208 message
209 .blocks
210 .iter()
211 .map(|block| match block {
212 ContentBlock::Text(text) => text.text.clone(),
213 ContentBlock::Thinking(thinking) => {
214 format!("thinking:{}", thinking.text)
215 }
216 ContentBlock::ToolCall(call) => {
217 format!("tool_call:{}({})", call.name, call.arguments)
218 }
219 ContentBlock::ToolResult(result) => format!(
220 "tool_result:{}#{} error={}",
221 result.name, result.tool_call_id, result.is_error
222 ),
223 })
224 .collect::<Vec<_>>()
225 .join("\n")
226 })
227 .unwrap_or_default(),
228 }
229 }
230}
231
232#[cfg(test)]
233mod tests {
234 use super::*;
235
236 #[test]
237 fn turn_event_message_returns_some_for_message_variants() {
238 let message = Message::user("hi");
239 assert!(matches!(TurnEvent::User(message.clone()).message(), Some(m) if m == &message));
240 assert!(
241 TurnEvent::TurnStarted { session_id: "s".into(), turn_id: 1, user_input: "hi".into() }
242 .message()
243 .is_none()
244 );
245 }
246}