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::Session(SessionEvent::ContextHeadCheckpoint { receipt, .. }) => {
240 receipt.complete(Err(std::io::Error::other(
241 "this stream consumer has no durable context-head handler",
242 )));
243 (EngineStreamEvent::Noop, StreamCompletion::Continue)
244 }
245 StreamEvent::Session(SessionEvent::MessageHistory(history)) => {
246 *messages = history;
247 (EngineStreamEvent::Noop, StreamCompletion::Continue)
248 }
249 StreamEvent::Agent(AgentEvent::SubagentStart {
250 subagent_id,
251 agent_name,
252 task_preview,
253 }) => {
254 subagents.push(SubagentTracker {
255 id: subagent_id,
256 name: agent_name.clone(),
257 status: format!("starting: {}", task_preview),
258 start_time: std::time::Instant::now(),
259 done: false,
260 duration_secs: None,
261 });
262 (
263 EngineStreamEvent::SubagentStart {
264 id: subagent_id,
265 name: agent_name,
266 task: task_preview,
267 },
268 StreamCompletion::Continue,
269 )
270 }
271 StreamEvent::Agent(AgentEvent::SubagentUpdate {
272 subagent_id,
273 status,
274 ..
275 }) => {
276 if let Some(sa) = subagents.iter_mut().find(|s| s.id == subagent_id) {
277 sa.status = status.clone();
278 }
279 (
280 EngineStreamEvent::SubagentUpdate {
281 id: subagent_id,
282 status,
283 },
284 StreamCompletion::Continue,
285 )
286 }
287 StreamEvent::Agent(AgentEvent::SubagentDone {
288 subagent_id,
289 result_preview,
290 duration_secs,
291 ..
292 }) => {
293 let status = if result_preview.starts_with("[TIMED OUT") {
294 "\u{26a0} timed out".to_string()
295 } else if result_preview.starts_with("ERROR") {
296 let preview: String = result_preview.chars().take(40).collect();
297 format!("\u{2718} {}", preview)
298 } else {
299 let preview: String = result_preview.chars().take(40).collect();
300 format!("\u{2714} {}", preview)
301 };
302 if let Some(sa) = subagents.iter_mut().find(|s| s.id == subagent_id) {
303 sa.done = true;
304 sa.duration_secs = Some(duration_secs);
305 sa.status = status.clone();
306 }
307 (
308 EngineStreamEvent::SubagentDone {
309 id: subagent_id,
310 status,
311 duration_secs,
312 },
313 StreamCompletion::Continue,
314 )
315 }
316 StreamEvent::Agent(AgentEvent::SteeringDelivered { message }) => {
317 if queued_message.as_ref() == Some(&message) {
318 *queued_message = None;
319 }
320 (
321 EngineStreamEvent::SteeringDelivered { message },
322 StreamCompletion::Continue,
323 )
324 }
325 StreamEvent::Session(SessionEvent::Usage {
326 input_tokens,
327 output_tokens,
328 cache_read_input_tokens,
329 cache_creation_input_tokens,
330 cache_creation_5m,
331 cache_creation_1h,
332 model,
333 }) => (
334 EngineStreamEvent::Usage {
335 input_tokens,
336 output_tokens,
337 cache_read: cache_read_input_tokens,
338 cache_creation: cache_creation_input_tokens,
339 cache_creation_5m,
340 cache_creation_1h,
341 model,
342 },
343 StreamCompletion::Continue,
344 ),
345 StreamEvent::Session(SessionEvent::Done) => {
346 terminal_capture();
349 subagents.clear();
350
351 let had_pending = !pending_events.is_empty();
353 for formatted in pending_events.drain(..) {
354 messages.push(std::sync::Arc::new(serde_json::json!({
355 "role": "user",
356 "content": formatted
357 })));
358 }
359
360 if let Some(queued) = queued_message.take() {
362 (
363 EngineStreamEvent::Done,
364 StreamCompletion::AutoSendQueued(queued),
365 )
366 } else if had_pending {
367 (EngineStreamEvent::Done, StreamCompletion::AutoTriggerEvents)
368 } else {
369 (EngineStreamEvent::Done, StreamCompletion::Done)
370 }
371 }
372 StreamEvent::Session(SessionEvent::Notice(text)) => {
373 (EngineStreamEvent::Notice(text), StreamCompletion::Continue)
376 }
377 StreamEvent::Session(SessionEvent::Error(err)) => {
378 subagents.clear();
379 repair_history_after_failure(messages, turn_baseline);
383 (
384 EngineStreamEvent::Error(err.message.clone()),
385 StreamCompletion::Error(err),
386 )
387 }
388 }
389}
390
391#[cfg(test)]
392mod tests {
393 use super::*;
394 use serde_json::json;
395 use std::sync::Arc;
396
397 fn msg(v: serde_json::Value) -> crate::SharedMessage {
398 Arc::new(v)
399 }
400
401 fn run_error_event(
402 messages: &mut Vec<crate::SharedMessage>,
403 turn_baseline: usize,
404 ) -> (EngineStreamEvent, StreamCompletion) {
405 let mut subagents = Vec::new();
406 let mut queued = None;
407 let mut pending = Vec::new();
408 process_stream_event(
409 StreamEvent::Session(SessionEvent::Error(agent_core::TurnError::provider(
410 "provider exploded",
411 "api_status",
412 "turn-test-0",
413 ))),
414 messages,
415 &mut subagents,
416 &mut queued,
417 &mut pending,
418 turn_baseline,
419 )
420 }
421
422 #[test]
423 fn memory_terminal_capture_failure_cannot_change_completed_engine_turn() {
424 let mut messages = vec![msg(json!({"role": "user", "content": "hello"}))];
425 let mut subagents = Vec::new();
426 let mut queued = None;
427 let mut pending = Vec::new();
428 let (event, completion) = process_stream_event_with_terminal_capture(
429 StreamEvent::Session(SessionEvent::Done),
430 &mut messages,
431 &mut subagents,
432 &mut queued,
433 &mut pending,
434 1,
435 || {
436 let _: Result<(), &'static str> = Err("capture_failed");
438 },
439 );
440 assert!(matches!(event, EngineStreamEvent::Done));
441 assert!(matches!(completion, StreamCompletion::Done));
442 }
443
444 #[test]
447 fn error_repair_keeps_preexisting_trailing_user_message() {
448 let mut messages = vec![msg(json!({"role": "user", "content": "hello"}))];
449 let (_, completion) = run_error_event(&mut messages, 1);
450 assert!(matches!(completion, StreamCompletion::Error(_)));
451 assert_eq!(
452 messages.len(),
453 1,
454 "pre-existing trailing user message must survive turn failure"
455 );
456 assert_eq!(messages[0]["content"], "hello");
457 }
458
459 #[test]
462 fn error_repair_keeps_partial_assistant_output_and_tool_results() {
463 let mut messages = vec![
464 msg(json!({"role": "user", "content": "do the thing"})),
465 msg(json!({"role": "assistant", "content": [
466 {"type": "text", "text": "working on it"},
467 {"type": "tool_use", "id": "tu_1", "name": "bash", "input": {}},
468 ]})),
469 msg(json!({"role": "user", "content": [
470 {"type": "tool_result", "tool_use_id": "tu_1", "content": "ok"},
471 ]})),
472 msg(json!({"role": "assistant", "content": [
473 {"type": "text", "text": "partial answer before the failure"},
474 ]})),
475 ];
476 let (_, completion) = run_error_event(&mut messages, 1);
477 assert!(matches!(completion, StreamCompletion::Error(_)));
478 assert_eq!(
479 messages.len(),
480 4,
481 "valid partial assistant output and completed tool results must survive"
482 );
483 assert_eq!(
484 messages[3]["content"][0]["text"],
485 "partial answer before the failure"
486 );
487 }
488
489 #[test]
492 fn error_repair_drops_turn_appended_unmatched_tool_use() {
493 let mut messages = vec![
494 msg(json!({"role": "user", "content": "do the thing"})),
495 msg(json!({"role": "assistant", "content": [
496 {"type": "tool_use", "id": "tu_1", "name": "bash", "input": {}},
497 ]})),
498 ];
499 let (_, completion) = run_error_event(&mut messages, 1);
500 assert!(matches!(completion, StreamCompletion::Error(_)));
501 assert_eq!(
502 messages.len(),
503 1,
504 "unmatched trailing tool_use appended by the turn must be removed"
505 );
506 assert_eq!(messages[0]["content"], "do the thing");
507 }
508}