Skip to main content

supercode_runtime/
event.rs

1use supercode_interchange::ToolCall;
2
3use crate::Usage;
4
5/// Streaming events emitted by a native runtime agent as a turn unfolds.
6///
7/// Attach an [`EventSink`] to a runtime configuration to observe these live — for
8/// example to render tokens to a terminal as they arrive, or to surface tool
9/// activity in a UI.
10///
11/// `#[non_exhaustive]` because new event kinds will be added over time; match
12/// with a `_` arm so a new variant is not a breaking change.
13#[derive(Debug, Clone)]
14#[non_exhaustive]
15pub enum AgentEvent {
16    /// A chunk of assistant text was produced.
17    TextDelta(String),
18
19    /// The assistant finished a text/tool turn (one round-trip to the model).
20    TurnCompleted,
21
22    /// The model requested a tool call (fired before the tool runs).
23    ToolCallStarted {
24        /// Provider-assigned call id.
25        id: String,
26        /// Tool name.
27        name: String,
28        /// Raw JSON argument string as sent by the model.
29        arguments: String,
30    },
31
32    /// A tool finished running.
33    ToolCallCompleted {
34        /// Provider-assigned call id.
35        id: String,
36        /// Tool name.
37        name: String,
38        /// The tool's textual output (truncated for display upstream if needed).
39        output: String,
40        /// Whether the tool reported an error.
41        is_error: bool,
42    },
43
44    /// UX-26 (B7-warn): the turn that just completed likely paid a
45    /// full-price prompt-cache miss despite reuse being expected under
46    /// an imported-prefix cache plan. Emitted at most once per turn, only when
47    /// cache warnings are enabled (default on) and reuse was genuinely
48    /// expected (never on a
49    /// first/establishing request, a same-turn tool-schema-tier bust, or
50    /// under a disabled cache plan — so this never fires as a false
51    /// positive on a cold-by-design request).
52    CacheWarning {
53        /// Ready-to-print, human-readable warning line (no trailing newline).
54        message: String,
55    },
56
57    /// UX-23: token accounting for the request that just completed (one per
58    /// model round-trip — a multi-tool-call turn emits one of these per
59    /// round-trip, same cadence as [`AgentEvent::TurnCompleted`], which this
60    /// is always emitted immediately before). Reuses the provider
61    /// completion's usage return rather than introducing a
62    /// second accounting path, so `--trace`/`stream-json` consumers see
63    /// exactly the numbers the provider reported — never a derived estimate.
64    Usage(Usage),
65
66    /// BP-7 (catalog §4a "Auto-retry on transient provider errors"): the
67    /// transport hit a transient failure and is retrying after backoff.
68    /// Emitted once per retried attempt, attached to the round-trip that
69    /// produced it — the surfacing half the retry mechanism had always
70    /// lacked.
71    ProviderRetry {
72        /// 0-based index of the attempt that failed.
73        attempt: u32,
74        /// Backoff slept before the next attempt, milliseconds.
75        delay_ms: u64,
76        /// One-line reason (HTTP status or transport error).
77        reason: String,
78    },
79
80    /// BP-7 (catalog §4a "Interrupt/abort with state preserved"): the
81    /// in-flight turn was interrupted. The partial work already appended to
82    /// the transcript stands; this event is the live counterpart of the
83    /// persisted `aborted` turn marker
84    /// (`crate::turn_record::TurnMarker::Aborted` in the harness crate).
85    TurnAborted {
86        /// Where the interruption came from (`"ctrl_c"`, `"cancelled"`, …).
87        source: String,
88    },
89
90    /// BP-13 (catalog Domain 9, "Mid-session model switching" /
91    /// "Failure fallback model chains"): the model this session sends to
92    /// changed. Emitted for BOTH causes — a user-driven switch and a
93    /// fallback hop the loop performed after a failure — because a routing
94    /// change the user cannot see is a routing change they cannot trust.
95    ModelChanged {
96        /// The model in force before the change.
97        from: String,
98        /// The model in force after it.
99        to: String,
100        /// Why, when the change was not user-initiated (the provider
101        /// failure that triggered the fallback hop). `None` for a
102        /// deliberate switch.
103        reason: Option<String>,
104    },
105
106    /// P5-6 (COMPOSABLE-HARNESS-DESIGN.md §2 module 4 `tools.background`,
107    /// D1 "monitor/event feed"): new output a background job (spawned via
108    /// the `background_exec` intrinsic) has produced since the last
109    /// `background_status` poll — the "event feed" `capabilities.
110    /// tools_background` promises. Emitted from
111    /// the runtime agent's background-status operation, at most once per poll,
112    /// only when there IS new output (an idle poll of a still-running job
113    /// with nothing new to report emits nothing).
114    BackgroundOutput {
115        /// The job id `background_exec` returned.
116        job_id: String,
117        /// The newly captured text since the previous poll (never a repeat
118        /// of already-emitted output).
119        chunk: String,
120        /// Whether this job's RETAINED capture has hit
121        /// `capabilities.tools_background.max_output_bytes` — `chunk`
122        /// itself is never truncated mid-character, but once this is
123        /// `true` no further output from this job will ever be retained or
124        /// emitted, even though the process may still be producing it.
125        truncated: bool,
126    },
127}
128
129impl AgentEvent {
130    /// Build the event emitted immediately before a tool call executes.
131    pub fn tool_started(call: &ToolCall) -> Self {
132        AgentEvent::ToolCallStarted {
133            id: call.id.clone(),
134            name: call.function.name.clone(),
135            arguments: call.function.arguments.clone(),
136        }
137    }
138
139    /// P5-8 (§2 module 31 `server`, completing Obligation 9's "partial"
140    /// core commitment): the canonical `{"type": ..., ...}` JSONL
141    /// projection of this event — field names mirror the enum's own
142    /// (`id`/`name`/`arguments`/`output`/`is_error`/`prompt_tokens`/…)
143    /// rather than a hand-maintained parallel vocabulary, so the wire shape
144    /// can never silently drift from the enum it projects.
145    ///
146    /// Shared by the CLI's `--output-format stream-json` sink (UX-23) and
147    /// the `server` module's RPC/SSE event-notification channel, so both
148    /// out-of-process surfaces stay byte-identical for the same event
149    /// instead of maintaining two hand-written projections that could
150    /// silently diverge.
151    ///
152    /// Match is exhaustive with NO wildcard arm on purpose: `#[non_exhaustive]`
153    /// only affects callers OUTSIDE this crate (it forced the CLI's old,
154    /// external copy of this projection to carry a `{"type":"unknown"}`
155    /// fallback arm) — from INSIDE the crate that defines the enum, adding a
156    /// future `AgentEvent` variant makes this fail to COMPILE until it's
157    /// given a real projection here, which is strictly safer than silently
158    /// falling back to an opaque `"unknown"` line for a new event kind.
159    pub fn to_json(&self) -> serde_json::Value {
160        match self {
161            AgentEvent::TextDelta(text) => {
162                serde_json::json!({"type": "text_delta", "text": text})
163            }
164            AgentEvent::TurnCompleted => serde_json::json!({"type": "turn_completed"}),
165            AgentEvent::ToolCallStarted {
166                id,
167                name,
168                arguments,
169            } => {
170                serde_json::json!({
171                    "type": "tool_call_started",
172                    "id": id,
173                    "name": name,
174                    "arguments": arguments,
175                })
176            }
177            AgentEvent::ToolCallCompleted {
178                id,
179                name,
180                output,
181                is_error,
182            } => {
183                serde_json::json!({
184                    "type": "tool_call_completed",
185                    "id": id,
186                    "name": name,
187                    "output": output,
188                    "is_error": is_error,
189                })
190            }
191            AgentEvent::CacheWarning { message } => {
192                serde_json::json!({"type": "cache_warning", "message": message})
193            }
194            AgentEvent::ModelChanged { from, to, reason } => {
195                serde_json::json!({
196                    "type": "model_changed",
197                    "from": from,
198                    "to": to,
199                    "reason": reason,
200                })
201            }
202            AgentEvent::Usage(usage) => {
203                serde_json::json!({
204                    "type": "usage",
205                    "prompt_tokens": usage.prompt_tokens,
206                    "completion_tokens": usage.completion_tokens,
207                    "total_tokens": usage.total_tokens,
208                    "cached_tokens": usage.prompt_tokens_details.as_ref().map(|d| d.cached_tokens),
209                })
210            }
211            AgentEvent::ProviderRetry {
212                attempt,
213                delay_ms,
214                reason,
215            } => {
216                serde_json::json!({
217                    "type": "provider_retry",
218                    "attempt": attempt,
219                    "delay_ms": delay_ms,
220                    "reason": reason,
221                })
222            }
223            AgentEvent::TurnAborted { source } => {
224                serde_json::json!({"type": "turn_aborted", "source": source})
225            }
226            // P5-8: the one event kind added since `stream_json_sink` was
227            // first written (P5-6, module 4 `tools.background`) — it fell
228            // into the generic "unknown" catch-all before this method
229            // existed; giving it a real projection is part of "emit the
230            // full event set" (this unit's ladder rung 1 completion).
231            AgentEvent::BackgroundOutput {
232                job_id,
233                chunk,
234                truncated,
235            } => {
236                serde_json::json!({
237                    "type": "background_output",
238                    "job_id": job_id,
239                    "chunk": chunk,
240                    "truncated": truncated,
241                })
242            }
243        }
244    }
245}
246
247/// A sink for [`AgentEvent`]s.
248///
249/// This is a boxed closure so callers can wire up whatever they like (printing,
250/// channels, metrics) without the crate dictating a concurrency model.
251pub type EventSink = Box<dyn Fn(AgentEvent) + Send + Sync>;
252
253#[cfg(test)]
254mod tests {
255    use super::*;
256    use crate::{PromptTokensDetails, Usage};
257
258    #[test]
259    fn text_delta_projects_type_and_text() {
260        let v = AgentEvent::TextDelta("hi".to_string()).to_json();
261        assert_eq!(v["type"], "text_delta");
262        assert_eq!(v["text"], "hi");
263    }
264
265    #[test]
266    fn turn_completed_projects_bare_type() {
267        assert_eq!(
268            AgentEvent::TurnCompleted.to_json(),
269            serde_json::json!({"type": "turn_completed"})
270        );
271    }
272
273    #[test]
274    fn tool_call_started_projects_all_fields() {
275        let v = AgentEvent::ToolCallStarted {
276            id: "call_1".into(),
277            name: "bash".into(),
278            arguments: "{\"cmd\":\"ls\"}".into(),
279        }
280        .to_json();
281        assert_eq!(v["type"], "tool_call_started");
282        assert_eq!(v["id"], "call_1");
283        assert_eq!(v["name"], "bash");
284        assert_eq!(v["arguments"], "{\"cmd\":\"ls\"}");
285    }
286
287    #[test]
288    fn tool_call_completed_projects_all_fields() {
289        let v = AgentEvent::ToolCallCompleted {
290            id: "call_1".into(),
291            name: "bash".into(),
292            output: "ok".into(),
293            is_error: false,
294        }
295        .to_json();
296        assert_eq!(v["type"], "tool_call_completed");
297        assert_eq!(v["output"], "ok");
298        assert_eq!(v["is_error"], false);
299    }
300
301    #[test]
302    fn usage_projects_cached_tokens_when_present() {
303        let v = AgentEvent::Usage(Usage {
304            prompt_tokens: 10,
305            completion_tokens: 5,
306            total_tokens: 15,
307            prompt_tokens_details: Some(PromptTokensDetails { cached_tokens: 4 }),
308        })
309        .to_json();
310        assert_eq!(v["type"], "usage");
311        assert_eq!(v["prompt_tokens"], 10);
312        assert_eq!(v["cached_tokens"], 4);
313    }
314
315    #[test]
316    fn usage_projects_null_cached_tokens_when_absent() {
317        let v = AgentEvent::Usage(Usage {
318            prompt_tokens: 10,
319            completion_tokens: 5,
320            total_tokens: 15,
321            prompt_tokens_details: None,
322        })
323        .to_json();
324        assert!(v["cached_tokens"].is_null());
325    }
326
327    #[test]
328    fn provider_retry_projects_all_fields() {
329        let v = AgentEvent::ProviderRetry {
330            attempt: 1,
331            delay_ms: 1000,
332            reason: "provider status 503".into(),
333        }
334        .to_json();
335        assert_eq!(v["type"], "provider_retry");
336        assert_eq!(v["attempt"], 1);
337        assert_eq!(v["delay_ms"], 1000);
338        assert_eq!(v["reason"], "provider status 503");
339    }
340
341    #[test]
342    fn turn_aborted_projects_its_source() {
343        let v = AgentEvent::TurnAborted {
344            source: "ctrl_c".into(),
345        }
346        .to_json();
347        assert_eq!(v["type"], "turn_aborted");
348        assert_eq!(v["source"], "ctrl_c");
349    }
350
351    #[test]
352    fn background_output_projects_all_fields_not_unknown() {
353        let v = AgentEvent::BackgroundOutput {
354            job_id: "job_1".into(),
355            chunk: "more output".into(),
356            truncated: true,
357        }
358        .to_json();
359        assert_eq!(v["type"], "background_output");
360        assert_eq!(v["job_id"], "job_1");
361        assert_eq!(v["chunk"], "more output");
362        assert_eq!(v["truncated"], true);
363    }
364}