1use crate::{AgentEvent, LlmEvent, SessionEvent, StreamEvent};
7
8#[derive(Debug)]
10pub enum EngineStreamEvent {
11 ResponseStart,
12 ResponseReset,
13 Thinking(String),
15 Text(String),
17 ToolStart {
19 tool_id: String,
20 tool_name: String,
21 },
22 ToolDelta {
24 tool_id: String,
25 delta: String,
26 },
27 ToolFinalized {
34 tool_id: String,
35 tool_name: String,
36 input: serde_json::Value,
37 },
38 ToolResultDelta {
40 tool_id: String,
41 delta: String,
42 },
43 ToolResult {
45 tool_id: String,
46 result: String,
47 },
48 SubagentStart {
50 id: u64,
51 name: String,
52 task: String,
53 },
54 SubagentUpdate {
56 id: u64,
57 status: String,
58 },
59 SubagentDone {
61 id: u64,
62 status: String,
63 duration_secs: f64,
64 },
65 SteeringDelivered {
67 message: String,
68 },
69 Usage {
71 input_tokens: u64,
72 output_tokens: u64,
73 cache_read: u64,
74 cache_creation: u64,
75 cache_creation_5m: Option<u64>,
77 cache_creation_1h: Option<u64>,
78 model: Option<String>,
79 },
80 Noop,
82 Notice(String),
84 Done,
86 Error(String),
88}
89
90#[derive(Debug, Clone)]
92pub struct SubagentTracker {
93 pub id: u64,
94 pub name: String,
95 pub status: String,
96 pub start_time: std::time::Instant,
97 pub done: bool,
98 pub duration_secs: Option<f64>,
99}
100
101#[derive(Debug)]
103pub enum StreamCompletion {
104 Continue,
106 AutoSendQueued(String),
108 AutoTriggerEvents,
110 Done,
112 Error(agent_core::TurnError),
115}
116
117pub fn repair_history_after_failure(
125 messages: &mut Vec<crate::SharedMessage>,
126 turn_baseline: usize,
127) {
128 fn trailing_is_valid(msg: &serde_json::Value) -> bool {
129 match msg["role"].as_str() {
130 Some("user") => true,
133 Some("assistant") => match &msg["content"] {
134 serde_json::Value::String(text) => !text.is_empty(),
135 serde_json::Value::Array(blocks) => {
136 !blocks.is_empty()
137 && !blocks
138 .iter()
139 .any(|b| b["type"].as_str() == Some("tool_use"))
140 }
141 _ => false,
142 },
143 _ => false,
144 }
145 }
146
147 while messages.len() > turn_baseline {
148 match messages.last() {
149 Some(last) if trailing_is_valid(last) => break,
150 _ => {
151 messages.pop();
152 }
153 }
154 }
155}
156
157pub fn process_stream_event(
167 event: StreamEvent,
168 messages: &mut Vec<crate::SharedMessage>,
169 subagents: &mut Vec<SubagentTracker>,
170 queued_message: &mut Option<String>,
171 pending_events: &mut Vec<String>,
172 turn_baseline: usize,
173) -> (EngineStreamEvent, StreamCompletion) {
174 process_stream_event_with_terminal_capture(
175 event,
176 messages,
177 subagents,
178 queued_message,
179 pending_events,
180 turn_baseline,
181 || {},
182 )
183}
184
185pub fn process_stream_event_with_terminal_capture(
189 event: StreamEvent,
190 messages: &mut Vec<crate::SharedMessage>,
191 subagents: &mut Vec<SubagentTracker>,
192 queued_message: &mut Option<String>,
193 pending_events: &mut Vec<String>,
194 turn_baseline: usize,
195 terminal_capture: impl FnOnce(),
196) -> (EngineStreamEvent, StreamCompletion) {
197 match event {
198 StreamEvent::Llm(LlmEvent::ResponseStart) => {
199 (EngineStreamEvent::ResponseStart, StreamCompletion::Continue)
200 }
201 StreamEvent::Llm(LlmEvent::ResponseReset) => {
202 (EngineStreamEvent::ResponseReset, StreamCompletion::Continue)
203 }
204 StreamEvent::Llm(LlmEvent::Thinking(text)) => (
205 EngineStreamEvent::Thinking(text),
206 StreamCompletion::Continue,
207 ),
208 StreamEvent::Llm(LlmEvent::Text(text)) => {
209 (EngineStreamEvent::Text(text), StreamCompletion::Continue)
210 }
211 StreamEvent::Llm(LlmEvent::ToolUseStart { tool_name, tool_id }) => (
212 EngineStreamEvent::ToolStart { tool_id, tool_name },
213 StreamCompletion::Continue,
214 ),
215 StreamEvent::Llm(LlmEvent::ToolUseDelta { tool_id, delta }) => (
216 EngineStreamEvent::ToolDelta { tool_id, delta },
217 StreamCompletion::Continue,
218 ),
219 StreamEvent::Llm(LlmEvent::ToolUse {
220 tool_name,
221 tool_id,
222 input,
223 }) => (
224 EngineStreamEvent::ToolFinalized {
225 tool_id,
226 tool_name,
227 input,
228 },
229 StreamCompletion::Continue,
230 ),
231 StreamEvent::Llm(LlmEvent::ToolResultDelta { delta, tool_id }) => (
232 EngineStreamEvent::ToolResultDelta { tool_id, delta },
233 StreamCompletion::Continue,
234 ),
235 StreamEvent::Llm(LlmEvent::ToolResult { result, tool_id }) => (
236 EngineStreamEvent::ToolResult { tool_id, result },
237 StreamCompletion::Continue,
238 ),
239 StreamEvent::Llm(LlmEvent::ToolQueued { .. } | LlmEvent::ToolStarted { .. }) => {
242 (EngineStreamEvent::Noop, StreamCompletion::Continue)
243 }
244 StreamEvent::Session(SessionEvent::ContextHeadCheckpoint { receipt, .. }) => {
245 receipt.complete(Err(std::io::Error::other(
246 "this stream consumer has no durable context-head handler",
247 )));
248 (EngineStreamEvent::Noop, StreamCompletion::Continue)
249 }
250 StreamEvent::Session(SessionEvent::MessageHistory(history)) => {
251 *messages = history;
252 (EngineStreamEvent::Noop, StreamCompletion::Continue)
253 }
254 StreamEvent::Agent(AgentEvent::SubagentStart {
255 subagent_id,
256 agent_name,
257 task_preview,
258 }) => {
259 subagents.push(SubagentTracker {
260 id: subagent_id,
261 name: agent_name.clone(),
262 status: format!("starting: {}", task_preview),
263 start_time: std::time::Instant::now(),
264 done: false,
265 duration_secs: None,
266 });
267 (
268 EngineStreamEvent::SubagentStart {
269 id: subagent_id,
270 name: agent_name,
271 task: task_preview,
272 },
273 StreamCompletion::Continue,
274 )
275 }
276 StreamEvent::Agent(AgentEvent::SubagentUpdate {
277 subagent_id,
278 status,
279 ..
280 }) => {
281 if let Some(sa) = subagents.iter_mut().find(|s| s.id == subagent_id) {
282 sa.status = status.clone();
283 }
284 (
285 EngineStreamEvent::SubagentUpdate {
286 id: subagent_id,
287 status,
288 },
289 StreamCompletion::Continue,
290 )
291 }
292 StreamEvent::Agent(AgentEvent::SubagentDone {
293 subagent_id,
294 result_preview,
295 duration_secs,
296 ..
297 }) => {
298 let status = if result_preview.starts_with("[TIMED OUT") {
299 "\u{26a0} timed out".to_string()
300 } else if result_preview.starts_with("ERROR") {
301 let preview: String = result_preview.chars().take(40).collect();
302 format!("\u{2718} {}", preview)
303 } else {
304 let preview: String = result_preview.chars().take(40).collect();
305 format!("\u{2714} {}", preview)
306 };
307 if let Some(sa) = subagents.iter_mut().find(|s| s.id == subagent_id) {
308 sa.done = true;
309 sa.duration_secs = Some(duration_secs);
310 sa.status = status.clone();
311 }
312 (
313 EngineStreamEvent::SubagentDone {
314 id: subagent_id,
315 status,
316 duration_secs,
317 },
318 StreamCompletion::Continue,
319 )
320 }
321 StreamEvent::Agent(AgentEvent::SteeringDelivered { message }) => {
322 if queued_message.as_ref() == Some(&message) {
323 *queued_message = None;
324 }
325 (
326 EngineStreamEvent::SteeringDelivered { message },
327 StreamCompletion::Continue,
328 )
329 }
330 StreamEvent::Session(SessionEvent::Usage {
331 input_tokens,
332 output_tokens,
333 cache_read_input_tokens,
334 cache_creation_input_tokens,
335 cache_creation_5m,
336 cache_creation_1h,
337 model,
338 }) => (
339 EngineStreamEvent::Usage {
340 input_tokens,
341 output_tokens,
342 cache_read: cache_read_input_tokens,
343 cache_creation: cache_creation_input_tokens,
344 cache_creation_5m,
345 cache_creation_1h,
346 model,
347 },
348 StreamCompletion::Continue,
349 ),
350 StreamEvent::Session(SessionEvent::Done) => {
351 terminal_capture();
354 subagents.clear();
355
356 let had_pending = !pending_events.is_empty();
358 for formatted in pending_events.drain(..) {
359 messages.push(std::sync::Arc::new(serde_json::json!({
360 "role": "user",
361 "content": formatted
362 })));
363 }
364
365 if let Some(queued) = queued_message.take() {
367 (
368 EngineStreamEvent::Done,
369 StreamCompletion::AutoSendQueued(queued),
370 )
371 } else if had_pending {
372 (EngineStreamEvent::Done, StreamCompletion::AutoTriggerEvents)
373 } else {
374 (EngineStreamEvent::Done, StreamCompletion::Done)
375 }
376 }
377 StreamEvent::Session(SessionEvent::Notice(text) | SessionEvent::Retry(text)) => {
378 (EngineStreamEvent::Notice(text), StreamCompletion::Continue)
381 }
382 StreamEvent::Session(SessionEvent::Error(err)) => {
383 subagents.clear();
384 repair_history_after_failure(messages, turn_baseline);
388 (
389 EngineStreamEvent::Error(err.message.clone()),
390 StreamCompletion::Error(err),
391 )
392 }
393 }
394}
395
396#[cfg(test)]
397mod tests {
398 use super::*;
399 use serde_json::json;
400 use std::sync::Arc;
401
402 fn msg(v: serde_json::Value) -> crate::SharedMessage {
403 Arc::new(v)
404 }
405
406 fn run_error_event(
407 messages: &mut Vec<crate::SharedMessage>,
408 turn_baseline: usize,
409 ) -> (EngineStreamEvent, StreamCompletion) {
410 let mut subagents = Vec::new();
411 let mut queued = None;
412 let mut pending = Vec::new();
413 process_stream_event(
414 StreamEvent::Session(SessionEvent::Error(agent_core::TurnError::provider(
415 "provider exploded",
416 "api_status",
417 "turn-test-0",
418 ))),
419 messages,
420 &mut subagents,
421 &mut queued,
422 &mut pending,
423 turn_baseline,
424 )
425 }
426
427 #[test]
428 fn memory_terminal_capture_failure_cannot_change_completed_engine_turn() {
429 let mut messages = vec![msg(json!({"role": "user", "content": "hello"}))];
430 let mut subagents = Vec::new();
431 let mut queued = None;
432 let mut pending = Vec::new();
433 let (event, completion) = process_stream_event_with_terminal_capture(
434 StreamEvent::Session(SessionEvent::Done),
435 &mut messages,
436 &mut subagents,
437 &mut queued,
438 &mut pending,
439 1,
440 || {
441 let _: Result<(), &'static str> = Err("capture_failed");
443 },
444 );
445 assert!(matches!(event, EngineStreamEvent::Done));
446 assert!(matches!(completion, StreamCompletion::Done));
447 }
448
449 #[test]
452 fn error_repair_keeps_preexisting_trailing_user_message() {
453 let mut messages = vec![msg(json!({"role": "user", "content": "hello"}))];
454 let (_, completion) = run_error_event(&mut messages, 1);
455 assert!(matches!(completion, StreamCompletion::Error(_)));
456 assert_eq!(
457 messages.len(),
458 1,
459 "pre-existing trailing user message must survive turn failure"
460 );
461 assert_eq!(messages[0]["content"], "hello");
462 }
463
464 #[test]
467 fn error_repair_keeps_partial_assistant_output_and_tool_results() {
468 let mut messages = vec![
469 msg(json!({"role": "user", "content": "do the thing"})),
470 msg(json!({"role": "assistant", "content": [
471 {"type": "text", "text": "working on it"},
472 {"type": "tool_use", "id": "tu_1", "name": "bash", "input": {}},
473 ]})),
474 msg(json!({"role": "user", "content": [
475 {"type": "tool_result", "tool_use_id": "tu_1", "content": "ok"},
476 ]})),
477 msg(json!({"role": "assistant", "content": [
478 {"type": "text", "text": "partial answer before the failure"},
479 ]})),
480 ];
481 let (_, completion) = run_error_event(&mut messages, 1);
482 assert!(matches!(completion, StreamCompletion::Error(_)));
483 assert_eq!(
484 messages.len(),
485 4,
486 "valid partial assistant output and completed tool results must survive"
487 );
488 assert_eq!(
489 messages[3]["content"][0]["text"],
490 "partial answer before the failure"
491 );
492 }
493
494 #[test]
497 fn error_repair_drops_turn_appended_unmatched_tool_use() {
498 let mut messages = vec![
499 msg(json!({"role": "user", "content": "do the thing"})),
500 msg(json!({"role": "assistant", "content": [
501 {"type": "tool_use", "id": "tu_1", "name": "bash", "input": {}},
502 ]})),
503 ];
504 let (_, completion) = run_error_event(&mut messages, 1);
505 assert!(matches!(completion, StreamCompletion::Error(_)));
506 assert_eq!(
507 messages.len(),
508 1,
509 "unmatched trailing tool_use appended by the turn must be removed"
510 );
511 assert_eq!(messages[0]["content"], "do the thing");
512 }
513}