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    /// Thinking text arrived.
12    Thinking(String),
13    /// Response text arrived.
14    Text(String),
15    /// Tool use started.
16    ToolStart { tool_id: String, tool_name: String },
17    /// Tool use input delta.
18    ToolDelta { tool_id: String, delta: String },
19    /// Tool use finalized.
20    ///
21    /// `input` is the parsed JSON value, not a stringified version. Renderers
22    /// that need a string preview (chat.rs, server's HistoryEntry::ToolUse)
23    /// can call `serde_json::to_string` themselves; the wire-format ToolUse
24    /// in server mode passes the Value through directly.
25    ToolFinalized {
26        tool_id: String,
27        tool_name: String,
28        input: serde_json::Value,
29    },
30    /// Tool result delta.
31    ToolResultDelta { tool_id: String, delta: String },
32    /// Tool result complete.
33    ToolResult { tool_id: String, result: String },
34    /// Subagent dispatched.
35    SubagentStart { id: u64, name: String, task: String },
36    /// Subagent status update.
37    SubagentUpdate { id: u64, status: String },
38    /// Subagent finished.
39    SubagentDone {
40        id: u64,
41        status: String,
42        duration_secs: f64,
43    },
44    /// Steering message was delivered.
45    SteeringDelivered { message: String },
46    /// Usage stats for this turn.
47    Usage {
48        input_tokens: u64,
49        output_tokens: u64,
50        cache_read: u64,
51        cache_creation: u64,
52        /// Cache-write TTL split — `None` when the API omitted the breakdown.
53        cache_creation_5m: Option<u64>,
54        cache_creation_1h: Option<u64>,
55        model: Option<String>,
56    },
57    /// Internal bookkeeping — no visual output.
58    Noop,
59    /// Display-only status notice (e.g. API retry). Not part of the transcript.
60    Notice(String),
61    /// Stream completed.
62    Done,
63    /// Stream errored.
64    Error(String),
65}
66
67/// Subagent tracking state.
68#[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/// What the caller should do after stream completion.
79#[derive(Debug)]
80pub enum StreamCompletion {
81    /// Stream is still going.
82    Continue,
83    /// Stream done — auto-send this queued message.
84    AutoSendQueued(String),
85    /// Stream done — pending events need a new model turn.
86    AutoTriggerEvents,
87    /// Stream done — nothing special.
88    Done,
89    /// Stream errored — carries the typed spec §5.2 terminal outcome
90    /// (variant + correlation ID) so frontends never re-derive it.
91    Error(agent_core::TurnError),
92}
93
94/// Repair conversation history after a turn failure.
95///
96/// Only messages appended by the ACTIVE turn (index >= `turn_baseline`) may
97/// be removed, and only while the trailing message is invalid history — an
98/// assistant message with unmatched `tool_use` blocks or empty content.
99/// Valid partial output (assistant text, completed `tool_result` messages)
100/// and everything pre-existing (including a trailing user prompt) survive.
101pub 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            // User messages (text prompts or tool_result blocks) are always
108            // a valid trailing state.
109            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
134/// Convert a raw StreamEvent into an EngineStreamEvent.
135/// Also handles message history capture and returns completion signals.
136///
137/// `messages` — the conversation history (updated in place on MessageHistory)
138/// `subagents` — tracked subagent states (updated in place)
139/// `queued_message` — message queued during streaming (taken if stream completes)
140/// `pending_events` — events buffered during streaming (drained on completion)
141/// `turn_baseline` — `messages.len()` when this turn started; failure repair
142///   may only remove messages appended at or after this index
143pub 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
162/// Process one stream event and invoke `terminal_capture` only after a typed,
163/// successful terminal turn. The hook is deliberately infallible: capture
164/// enqueue/build/provider failures cannot replace the completed outcome.
165pub 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            // The canonical history has reached its typed successful terminal
312            // state. Capture is best-effort and cannot alter completion.
313            terminal_capture();
314            subagents.clear();
315
316            // Drain pending events into messages
317            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            // Check for queued message
326            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            // Display-only status (e.g. retry notice) — surface as a system
339            // line, never recorded into message history.
340            (EngineStreamEvent::Notice(text), StreamCompletion::Continue)
341        }
342        StreamEvent::Session(SessionEvent::Error(err)) => {
343            subagents.clear();
344            // Repair history: remove only invalid messages appended by the
345            // ACTIVE turn — never a pre-existing trailing message, never
346            // valid partial output (spec §5.2).
347            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                // The capture boundary absorbs build/enqueue/provider failure.
402                let _: Result<(), &'static str> = Err("capture_failed");
403            },
404        );
405        assert!(matches!(event, EngineStreamEvent::Done));
406        assert!(matches!(completion, StreamCompletion::Done));
407    }
408
409    /// T3 criterion 4: a pre-existing trailing user message (the prompt that
410    /// started the turn) must never be removed by failure repair.
411    #[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    /// T3 criterion 2: partial assistant output and completed tool results
425    /// appended by the active turn must survive an unrecovered failure.
426    #[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    /// A turn-appended trailing assistant message with unmatched tool_use
455    /// blocks is invalid history and must be removed by repair.
456    #[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}