Skip to main content

agent_engine/engine/
stream.rs

1//! Engine-level stream processing — TUI-agnostic event handling.
2//!
3//! Processes StreamEvent variants, tracks subagent state and usage,
4//! and returns renderer-agnostic actions.
5
6use crate::{AgentEvent, LlmEvent, SessionEvent, StreamEvent};
7
8/// What happened during a stream event — renderer decides how to display.
9#[derive(Debug)]
10pub enum EngineStreamEvent {
11    ResponseStart,
12    ResponseReset,
13    /// Thinking text arrived.
14    Thinking(String),
15    /// Response text arrived.
16    Text(String),
17    /// Tool use started.
18    ToolStart {
19        tool_id: String,
20        tool_name: String,
21    },
22    /// Tool use input delta.
23    ToolDelta {
24        tool_id: String,
25        delta: String,
26    },
27    /// Tool use finalized.
28    ///
29    /// `input` is the parsed JSON value, not a stringified version. Renderers
30    /// that need a string preview (chat.rs, server's HistoryEntry::ToolUse)
31    /// can call `serde_json::to_string` themselves; the wire-format ToolUse
32    /// in server mode passes the Value through directly.
33    ToolFinalized {
34        tool_id: String,
35        tool_name: String,
36        input: serde_json::Value,
37    },
38    /// Tool result delta.
39    ToolResultDelta {
40        tool_id: String,
41        delta: String,
42    },
43    /// Tool result complete.
44    ToolResult {
45        tool_id: String,
46        result: String,
47    },
48    /// Subagent dispatched.
49    SubagentStart {
50        id: u64,
51        name: String,
52        task: String,
53    },
54    /// Subagent status update.
55    SubagentUpdate {
56        id: u64,
57        status: String,
58    },
59    /// Subagent finished.
60    SubagentDone {
61        id: u64,
62        status: String,
63        duration_secs: f64,
64    },
65    /// Steering message was delivered.
66    SteeringDelivered {
67        message: String,
68    },
69    /// Usage stats for this turn.
70    Usage {
71        input_tokens: u64,
72        output_tokens: u64,
73        cache_read: u64,
74        cache_creation: u64,
75        /// Cache-write TTL split — `None` when the API omitted the breakdown.
76        cache_creation_5m: Option<u64>,
77        cache_creation_1h: Option<u64>,
78        model: Option<String>,
79    },
80    /// Internal bookkeeping — no visual output.
81    Noop,
82    /// Display-only status notice (e.g. API retry). Not part of the transcript.
83    Notice(String),
84    /// Stream completed.
85    Done,
86    /// Stream errored.
87    Error(String),
88}
89
90/// Subagent tracking state.
91#[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/// What the caller should do after stream completion.
102#[derive(Debug)]
103pub enum StreamCompletion {
104    /// Stream is still going.
105    Continue,
106    /// Stream done — auto-send this queued message.
107    AutoSendQueued(String),
108    /// Stream done — pending events need a new model turn.
109    AutoTriggerEvents,
110    /// Stream done — nothing special.
111    Done,
112    /// Stream errored — carries the typed spec §5.2 terminal outcome
113    /// (variant + correlation ID) so frontends never re-derive it.
114    Error(agent_core::TurnError),
115}
116
117/// Repair conversation history after a turn failure.
118///
119/// Only messages appended by the ACTIVE turn (index >= `turn_baseline`) may
120/// be removed, and only while the trailing message is invalid history — an
121/// assistant message with unmatched `tool_use` blocks or empty content.
122/// Valid partial output (assistant text, completed `tool_result` messages)
123/// and everything pre-existing (including a trailing user prompt) survive.
124pub 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            // User messages (text prompts or tool_result blocks) are always
131            // a valid trailing state.
132            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
157/// Convert a raw StreamEvent into an EngineStreamEvent.
158/// Also handles message history capture and returns completion signals.
159///
160/// `messages` — the conversation history (updated in place on MessageHistory)
161/// `subagents` — tracked subagent states (updated in place)
162/// `queued_message` — message queued during streaming (taken if stream completes)
163/// `pending_events` — events buffered during streaming (drained on completion)
164/// `turn_baseline` — `messages.len()` when this turn started; failure repair
165///   may only remove messages appended at or after this index
166pub 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
185/// Process one stream event and invoke `terminal_capture` only after a typed,
186/// successful terminal turn. The hook is deliberately infallible: capture
187/// enqueue/build/provider failures cannot replace the completed outcome.
188pub 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            // The canonical history has reached its typed successful terminal
347            // state. Capture is best-effort and cannot alter completion.
348            terminal_capture();
349            subagents.clear();
350
351            // Drain pending events into messages
352            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            // Check for queued message
361            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            // Display-only status (e.g. retry notice) — surface as a system
374            // line, never recorded into message history.
375            (EngineStreamEvent::Notice(text), StreamCompletion::Continue)
376        }
377        StreamEvent::Session(SessionEvent::Error(err)) => {
378            subagents.clear();
379            // Repair history: remove only invalid messages appended by the
380            // ACTIVE turn — never a pre-existing trailing message, never
381            // valid partial output (spec §5.2).
382            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                // The capture boundary absorbs build/enqueue/provider failure.
437                let _: Result<(), &'static str> = Err("capture_failed");
438            },
439        );
440        assert!(matches!(event, EngineStreamEvent::Done));
441        assert!(matches!(completion, StreamCompletion::Done));
442    }
443
444    /// T3 criterion 4: a pre-existing trailing user message (the prompt that
445    /// started the turn) must never be removed by failure repair.
446    #[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    /// T3 criterion 2: partial assistant output and completed tool results
460    /// appended by the active turn must survive an unrecovered failure.
461    #[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    /// A turn-appended trailing assistant message with unmatched tool_use
490    /// blocks is invalid history and must be removed by repair.
491    #[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}