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        // Scheduler queue hints are a TUI presentation detail; line-mode
240        // renderers (`synaps chat`) print a call once, at `ToolFinalized`.
241        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            // The canonical history has reached its typed successful terminal
352            // state. Capture is best-effort and cannot alter completion.
353            terminal_capture();
354            subagents.clear();
355
356            // Drain pending events into messages
357            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            // Check for queued message
366            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            // Display-only status (e.g. retry notice) — surface as a system
379            // line, never recorded into message history.
380            (EngineStreamEvent::Notice(text), StreamCompletion::Continue)
381        }
382        StreamEvent::Session(SessionEvent::Error(err)) => {
383            subagents.clear();
384            // Repair history: remove only invalid messages appended by the
385            // ACTIVE turn — never a pre-existing trailing message, never
386            // valid partial output (spec §5.2).
387            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                // The capture boundary absorbs build/enqueue/provider failure.
442                let _: Result<(), &'static str> = Err("capture_failed");
443            },
444        );
445        assert!(matches!(event, EngineStreamEvent::Done));
446        assert!(matches!(completion, StreamCompletion::Done));
447    }
448
449    /// T3 criterion 4: a pre-existing trailing user message (the prompt that
450    /// started the turn) must never be removed by failure repair.
451    #[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    /// T3 criterion 2: partial assistant output and completed tool results
465    /// appended by the active turn must survive an unrecovered failure.
466    #[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    /// A turn-appended trailing assistant message with unmatched tool_use
495    /// blocks is invalid history and must be removed by repair.
496    #[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}