1use crate::{AgentEvent, LlmEvent, SessionEvent, StreamEvent};
7
8#[derive(Debug)]
10pub enum EngineStreamEvent {
11 Thinking(String),
13 Text(String),
15 ToolStart { tool_id: String, tool_name: String },
17 ToolDelta { tool_id: String, delta: String },
19 ToolFinalized {
26 tool_id: String,
27 tool_name: String,
28 input: serde_json::Value,
29 },
30 ToolResultDelta { tool_id: String, delta: String },
32 ToolResult { tool_id: String, result: String },
34 SubagentStart { id: u64, name: String, task: String },
36 SubagentUpdate { id: u64, status: String },
38 SubagentDone {
40 id: u64,
41 status: String,
42 duration_secs: f64,
43 },
44 SteeringDelivered { message: String },
46 Usage {
48 input_tokens: u64,
49 output_tokens: u64,
50 cache_read: u64,
51 cache_creation: u64,
52 cache_creation_5m: Option<u64>,
54 cache_creation_1h: Option<u64>,
55 model: Option<String>,
56 },
57 Noop,
59 Notice(String),
61 Done,
63 Error(String),
65}
66
67#[derive(Debug, Clone)]
69pub struct SubagentTracker {
70 pub id: u64,
71 pub name: String,
72 pub status: String,
73 pub start_time: std::time::Instant,
74 pub done: bool,
75 pub duration_secs: Option<f64>,
76}
77
78#[derive(Debug)]
80pub enum StreamCompletion {
81 Continue,
83 AutoSendQueued(String),
85 AutoTriggerEvents,
87 Done,
89 Error(agent_core::TurnError),
92}
93
94pub fn repair_history_after_failure(
102 messages: &mut Vec<crate::SharedMessage>,
103 turn_baseline: usize,
104) {
105 fn trailing_is_valid(msg: &serde_json::Value) -> bool {
106 match msg["role"].as_str() {
107 Some("user") => true,
110 Some("assistant") => match &msg["content"] {
111 serde_json::Value::String(text) => !text.is_empty(),
112 serde_json::Value::Array(blocks) => {
113 !blocks.is_empty()
114 && !blocks
115 .iter()
116 .any(|b| b["type"].as_str() == Some("tool_use"))
117 }
118 _ => false,
119 },
120 _ => false,
121 }
122 }
123
124 while messages.len() > turn_baseline {
125 match messages.last() {
126 Some(last) if trailing_is_valid(last) => break,
127 _ => {
128 messages.pop();
129 }
130 }
131 }
132}
133
134pub fn process_stream_event(
144 event: StreamEvent,
145 messages: &mut Vec<crate::SharedMessage>,
146 subagents: &mut Vec<SubagentTracker>,
147 queued_message: &mut Option<String>,
148 pending_events: &mut Vec<String>,
149 turn_baseline: usize,
150) -> (EngineStreamEvent, StreamCompletion) {
151 process_stream_event_with_terminal_capture(
152 event,
153 messages,
154 subagents,
155 queued_message,
156 pending_events,
157 turn_baseline,
158 || {},
159 )
160}
161
162pub fn process_stream_event_with_terminal_capture(
166 event: StreamEvent,
167 messages: &mut Vec<crate::SharedMessage>,
168 subagents: &mut Vec<SubagentTracker>,
169 queued_message: &mut Option<String>,
170 pending_events: &mut Vec<String>,
171 turn_baseline: usize,
172 terminal_capture: impl FnOnce(),
173) -> (EngineStreamEvent, StreamCompletion) {
174 match event {
175 StreamEvent::Llm(LlmEvent::Thinking(text)) => (
176 EngineStreamEvent::Thinking(text),
177 StreamCompletion::Continue,
178 ),
179 StreamEvent::Llm(LlmEvent::Text(text)) => {
180 (EngineStreamEvent::Text(text), StreamCompletion::Continue)
181 }
182 StreamEvent::Llm(LlmEvent::ToolUseStart { tool_name, tool_id }) => (
183 EngineStreamEvent::ToolStart { tool_id, tool_name },
184 StreamCompletion::Continue,
185 ),
186 StreamEvent::Llm(LlmEvent::ToolUseDelta { tool_id, delta }) => (
187 EngineStreamEvent::ToolDelta { tool_id, delta },
188 StreamCompletion::Continue,
189 ),
190 StreamEvent::Llm(LlmEvent::ToolUse {
191 tool_name,
192 tool_id,
193 input,
194 }) => (
195 EngineStreamEvent::ToolFinalized {
196 tool_id,
197 tool_name,
198 input,
199 },
200 StreamCompletion::Continue,
201 ),
202 StreamEvent::Llm(LlmEvent::ToolResultDelta { delta, tool_id }) => (
203 EngineStreamEvent::ToolResultDelta { tool_id, delta },
204 StreamCompletion::Continue,
205 ),
206 StreamEvent::Llm(LlmEvent::ToolResult { result, tool_id }) => (
207 EngineStreamEvent::ToolResult { tool_id, result },
208 StreamCompletion::Continue,
209 ),
210 StreamEvent::Session(SessionEvent::MessageHistory(history)) => {
211 *messages = history;
212 (EngineStreamEvent::Noop, StreamCompletion::Continue)
213 }
214 StreamEvent::Agent(AgentEvent::SubagentStart {
215 subagent_id,
216 agent_name,
217 task_preview,
218 }) => {
219 subagents.push(SubagentTracker {
220 id: subagent_id,
221 name: agent_name.clone(),
222 status: format!("starting: {}", task_preview),
223 start_time: std::time::Instant::now(),
224 done: false,
225 duration_secs: None,
226 });
227 (
228 EngineStreamEvent::SubagentStart {
229 id: subagent_id,
230 name: agent_name,
231 task: task_preview,
232 },
233 StreamCompletion::Continue,
234 )
235 }
236 StreamEvent::Agent(AgentEvent::SubagentUpdate {
237 subagent_id,
238 status,
239 ..
240 }) => {
241 if let Some(sa) = subagents.iter_mut().find(|s| s.id == subagent_id) {
242 sa.status = status.clone();
243 }
244 (
245 EngineStreamEvent::SubagentUpdate {
246 id: subagent_id,
247 status,
248 },
249 StreamCompletion::Continue,
250 )
251 }
252 StreamEvent::Agent(AgentEvent::SubagentDone {
253 subagent_id,
254 result_preview,
255 duration_secs,
256 ..
257 }) => {
258 let status = if result_preview.starts_with("[TIMED OUT") {
259 "\u{26a0} timed out".to_string()
260 } else if result_preview.starts_with("ERROR") {
261 let preview: String = result_preview.chars().take(40).collect();
262 format!("\u{2718} {}", preview)
263 } else {
264 let preview: String = result_preview.chars().take(40).collect();
265 format!("\u{2714} {}", preview)
266 };
267 if let Some(sa) = subagents.iter_mut().find(|s| s.id == subagent_id) {
268 sa.done = true;
269 sa.duration_secs = Some(duration_secs);
270 sa.status = status.clone();
271 }
272 (
273 EngineStreamEvent::SubagentDone {
274 id: subagent_id,
275 status,
276 duration_secs,
277 },
278 StreamCompletion::Continue,
279 )
280 }
281 StreamEvent::Agent(AgentEvent::SteeringDelivered { message }) => {
282 if queued_message.as_ref() == Some(&message) {
283 *queued_message = None;
284 }
285 (
286 EngineStreamEvent::SteeringDelivered { message },
287 StreamCompletion::Continue,
288 )
289 }
290 StreamEvent::Session(SessionEvent::Usage {
291 input_tokens,
292 output_tokens,
293 cache_read_input_tokens,
294 cache_creation_input_tokens,
295 cache_creation_5m,
296 cache_creation_1h,
297 model,
298 }) => (
299 EngineStreamEvent::Usage {
300 input_tokens,
301 output_tokens,
302 cache_read: cache_read_input_tokens,
303 cache_creation: cache_creation_input_tokens,
304 cache_creation_5m,
305 cache_creation_1h,
306 model,
307 },
308 StreamCompletion::Continue,
309 ),
310 StreamEvent::Session(SessionEvent::Done) => {
311 terminal_capture();
314 subagents.clear();
315
316 let had_pending = !pending_events.is_empty();
318 for formatted in pending_events.drain(..) {
319 messages.push(std::sync::Arc::new(serde_json::json!({
320 "role": "user",
321 "content": formatted
322 })));
323 }
324
325 if let Some(queued) = queued_message.take() {
327 (
328 EngineStreamEvent::Done,
329 StreamCompletion::AutoSendQueued(queued),
330 )
331 } else if had_pending {
332 (EngineStreamEvent::Done, StreamCompletion::AutoTriggerEvents)
333 } else {
334 (EngineStreamEvent::Done, StreamCompletion::Done)
335 }
336 }
337 StreamEvent::Session(SessionEvent::Notice(text)) => {
338 (EngineStreamEvent::Notice(text), StreamCompletion::Continue)
341 }
342 StreamEvent::Session(SessionEvent::Error(err)) => {
343 subagents.clear();
344 repair_history_after_failure(messages, turn_baseline);
348 (
349 EngineStreamEvent::Error(err.message.clone()),
350 StreamCompletion::Error(err),
351 )
352 }
353 }
354}
355
356#[cfg(test)]
357mod tests {
358 use super::*;
359 use serde_json::json;
360 use std::sync::Arc;
361
362 fn msg(v: serde_json::Value) -> crate::SharedMessage {
363 Arc::new(v)
364 }
365
366 fn run_error_event(
367 messages: &mut Vec<crate::SharedMessage>,
368 turn_baseline: usize,
369 ) -> (EngineStreamEvent, StreamCompletion) {
370 let mut subagents = Vec::new();
371 let mut queued = None;
372 let mut pending = Vec::new();
373 process_stream_event(
374 StreamEvent::Session(SessionEvent::Error(agent_core::TurnError::provider(
375 "provider exploded",
376 "api_status",
377 "turn-test-0",
378 ))),
379 messages,
380 &mut subagents,
381 &mut queued,
382 &mut pending,
383 turn_baseline,
384 )
385 }
386
387 #[test]
388 fn memory_terminal_capture_failure_cannot_change_completed_engine_turn() {
389 let mut messages = vec![msg(json!({"role": "user", "content": "hello"}))];
390 let mut subagents = Vec::new();
391 let mut queued = None;
392 let mut pending = Vec::new();
393 let (event, completion) = process_stream_event_with_terminal_capture(
394 StreamEvent::Session(SessionEvent::Done),
395 &mut messages,
396 &mut subagents,
397 &mut queued,
398 &mut pending,
399 1,
400 || {
401 let _: Result<(), &'static str> = Err("capture_failed");
403 },
404 );
405 assert!(matches!(event, EngineStreamEvent::Done));
406 assert!(matches!(completion, StreamCompletion::Done));
407 }
408
409 #[test]
412 fn error_repair_keeps_preexisting_trailing_user_message() {
413 let mut messages = vec![msg(json!({"role": "user", "content": "hello"}))];
414 let (_, completion) = run_error_event(&mut messages, 1);
415 assert!(matches!(completion, StreamCompletion::Error(_)));
416 assert_eq!(
417 messages.len(),
418 1,
419 "pre-existing trailing user message must survive turn failure"
420 );
421 assert_eq!(messages[0]["content"], "hello");
422 }
423
424 #[test]
427 fn error_repair_keeps_partial_assistant_output_and_tool_results() {
428 let mut messages = vec![
429 msg(json!({"role": "user", "content": "do the thing"})),
430 msg(json!({"role": "assistant", "content": [
431 {"type": "text", "text": "working on it"},
432 {"type": "tool_use", "id": "tu_1", "name": "bash", "input": {}},
433 ]})),
434 msg(json!({"role": "user", "content": [
435 {"type": "tool_result", "tool_use_id": "tu_1", "content": "ok"},
436 ]})),
437 msg(json!({"role": "assistant", "content": [
438 {"type": "text", "text": "partial answer before the failure"},
439 ]})),
440 ];
441 let (_, completion) = run_error_event(&mut messages, 1);
442 assert!(matches!(completion, StreamCompletion::Error(_)));
443 assert_eq!(
444 messages.len(),
445 4,
446 "valid partial assistant output and completed tool results must survive"
447 );
448 assert_eq!(
449 messages[3]["content"][0]["text"],
450 "partial answer before the failure"
451 );
452 }
453
454 #[test]
457 fn error_repair_drops_turn_appended_unmatched_tool_use() {
458 let mut messages = vec![
459 msg(json!({"role": "user", "content": "do the thing"})),
460 msg(json!({"role": "assistant", "content": [
461 {"type": "tool_use", "id": "tu_1", "name": "bash", "input": {}},
462 ]})),
463 ];
464 let (_, completion) = run_error_event(&mut messages, 1);
465 assert!(matches!(completion, StreamCompletion::Error(_)));
466 assert_eq!(
467 messages.len(),
468 1,
469 "unmatched trailing tool_use appended by the turn must be removed"
470 );
471 assert_eq!(messages[0]["content"], "do the thing");
472 }
473}