Skip to main content

supercode/
event.rs

1use crate::message::ToolCall;
2use crate::provider::Usage;
3
4/// Streaming events emitted by an [`crate::Agent`] as a turn unfolds.
5///
6/// Attach an [`EventSink`] to a [`crate::Config`] to observe these live — for
7/// example to render tokens to a terminal as they arrive, or to surface tool
8/// activity in a UI.
9///
10/// `#[non_exhaustive]` because new event kinds will be added over time; match
11/// with a `_` arm so a new variant is not a breaking change.
12#[derive(Debug, Clone)]
13#[non_exhaustive]
14pub enum AgentEvent {
15    /// A chunk of assistant text was produced.
16    TextDelta(String),
17
18    /// The assistant finished a text/tool turn (one round-trip to the model).
19    TurnCompleted,
20
21    /// The model requested a tool call (fired before the tool runs).
22    ToolCallStarted {
23        /// Provider-assigned call id.
24        id: String,
25        /// Tool name.
26        name: String,
27        /// Raw JSON argument string as sent by the model.
28        arguments: String,
29    },
30
31    /// A tool finished running.
32    ToolCallCompleted {
33        /// Provider-assigned call id.
34        id: String,
35        /// Tool name.
36        name: String,
37        /// The tool's textual output (truncated for display upstream if needed).
38        output: String,
39        /// Whether the tool reported an error.
40        is_error: bool,
41    },
42
43    /// UX-26 (B7-warn): the turn that just completed likely paid a
44    /// full-price prompt-cache miss despite reuse being expected under
45    /// [`crate::CachePlan::ImportedPrefix`] — see
46    /// `crate::provider::cache_cold_reason` for the exact trigger. Emitted
47    /// at most once per turn, only when [`crate::Config::cache_warnings`] is
48    /// enabled (default on) and reuse was genuinely expected (never on a
49    /// first/establishing request, a same-turn tool-schema-tier bust, or
50    /// under [`crate::CachePlan::Off`] — 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 existing
61    /// [`crate::Provider::complete`] 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    /// P5-6 (COMPOSABLE-HARNESS-DESIGN.md §2 module 4 `tools.background`,
67    /// D1 "monitor/event feed"): new output a background job (spawned via
68    /// the `background_exec` intrinsic) has produced since the last
69    /// `background_status` poll — the "event feed" `capabilities.
70    /// tools_background` promises. Emitted from
71    /// `crate::agent::Agent::run_background_status`, at most once per poll,
72    /// only when there IS new output (an idle poll of a still-running job
73    /// with nothing new to report emits nothing).
74    BackgroundOutput {
75        /// The job id `background_exec` returned.
76        job_id: String,
77        /// The newly captured text since the previous poll (never a repeat
78        /// of already-emitted output).
79        chunk: String,
80        /// Whether this job's RETAINED capture has hit
81        /// `capabilities.tools_background.max_output_bytes` — `chunk`
82        /// itself is never truncated mid-character, but once this is
83        /// `true` no further output from this job will ever be retained or
84        /// emitted, even though the process may still be producing it.
85        truncated: bool,
86    },
87}
88
89impl AgentEvent {
90    pub(crate) fn tool_started(call: &ToolCall) -> Self {
91        AgentEvent::ToolCallStarted {
92            id: call.id.clone(),
93            name: call.function.name.clone(),
94            arguments: call.function.arguments.clone(),
95        }
96    }
97
98    /// P5-8 (§2 module 31 `server`, completing Obligation 9's "partial"
99    /// core commitment): the canonical `{"type": ..., ...}` JSONL
100    /// projection of this event — field names mirror the enum's own
101    /// (`id`/`name`/`arguments`/`output`/`is_error`/`prompt_tokens`/…)
102    /// rather than a hand-maintained parallel vocabulary, so the wire shape
103    /// can never silently drift from the enum it projects.
104    ///
105    /// Shared by the CLI's `--output-format stream-json` sink (UX-23) and
106    /// the `server` module's RPC/SSE event-notification channel, so both
107    /// out-of-process surfaces stay byte-identical for the same event
108    /// instead of maintaining two hand-written projections that could
109    /// silently diverge.
110    ///
111    /// Match is exhaustive with NO wildcard arm on purpose: `#[non_exhaustive]`
112    /// only affects callers OUTSIDE this crate (it forced the CLI's old,
113    /// external copy of this projection to carry a `{"type":"unknown"}`
114    /// fallback arm) — from INSIDE the crate that defines the enum, adding a
115    /// future `AgentEvent` variant makes this fail to COMPILE until it's
116    /// given a real projection here, which is strictly safer than silently
117    /// falling back to an opaque `"unknown"` line for a new event kind.
118    pub fn to_json(&self) -> serde_json::Value {
119        match self {
120            AgentEvent::TextDelta(text) => {
121                serde_json::json!({"type": "text_delta", "text": text})
122            }
123            AgentEvent::TurnCompleted => serde_json::json!({"type": "turn_completed"}),
124            AgentEvent::ToolCallStarted {
125                id,
126                name,
127                arguments,
128            } => {
129                serde_json::json!({
130                    "type": "tool_call_started",
131                    "id": id,
132                    "name": name,
133                    "arguments": arguments,
134                })
135            }
136            AgentEvent::ToolCallCompleted {
137                id,
138                name,
139                output,
140                is_error,
141            } => {
142                serde_json::json!({
143                    "type": "tool_call_completed",
144                    "id": id,
145                    "name": name,
146                    "output": output,
147                    "is_error": is_error,
148                })
149            }
150            AgentEvent::CacheWarning { message } => {
151                serde_json::json!({"type": "cache_warning", "message": message})
152            }
153            AgentEvent::Usage(usage) => {
154                serde_json::json!({
155                    "type": "usage",
156                    "prompt_tokens": usage.prompt_tokens,
157                    "completion_tokens": usage.completion_tokens,
158                    "total_tokens": usage.total_tokens,
159                    "cached_tokens": usage.prompt_tokens_details.as_ref().map(|d| d.cached_tokens),
160                })
161            }
162            // P5-8: the one event kind added since `stream_json_sink` was
163            // first written (P5-6, module 4 `tools.background`) — it fell
164            // into the generic "unknown" catch-all before this method
165            // existed; giving it a real projection is part of "emit the
166            // full event set" (this unit's ladder rung 1 completion).
167            AgentEvent::BackgroundOutput {
168                job_id,
169                chunk,
170                truncated,
171            } => {
172                serde_json::json!({
173                    "type": "background_output",
174                    "job_id": job_id,
175                    "chunk": chunk,
176                    "truncated": truncated,
177                })
178            }
179        }
180    }
181}
182
183/// A sink for [`AgentEvent`]s.
184///
185/// This is a boxed closure so callers can wire up whatever they like (printing,
186/// channels, metrics) without the crate dictating a concurrency model.
187pub type EventSink = Box<dyn Fn(AgentEvent) + Send + Sync>;
188
189#[cfg(test)]
190mod tests {
191    use super::*;
192    use crate::provider::{PromptTokensDetails, Usage};
193
194    #[test]
195    fn text_delta_projects_type_and_text() {
196        let v = AgentEvent::TextDelta("hi".to_string()).to_json();
197        assert_eq!(v["type"], "text_delta");
198        assert_eq!(v["text"], "hi");
199    }
200
201    #[test]
202    fn turn_completed_projects_bare_type() {
203        assert_eq!(
204            AgentEvent::TurnCompleted.to_json(),
205            serde_json::json!({"type": "turn_completed"})
206        );
207    }
208
209    #[test]
210    fn tool_call_started_projects_all_fields() {
211        let v = AgentEvent::ToolCallStarted {
212            id: "call_1".into(),
213            name: "bash".into(),
214            arguments: "{\"cmd\":\"ls\"}".into(),
215        }
216        .to_json();
217        assert_eq!(v["type"], "tool_call_started");
218        assert_eq!(v["id"], "call_1");
219        assert_eq!(v["name"], "bash");
220        assert_eq!(v["arguments"], "{\"cmd\":\"ls\"}");
221    }
222
223    #[test]
224    fn tool_call_completed_projects_all_fields() {
225        let v = AgentEvent::ToolCallCompleted {
226            id: "call_1".into(),
227            name: "bash".into(),
228            output: "ok".into(),
229            is_error: false,
230        }
231        .to_json();
232        assert_eq!(v["type"], "tool_call_completed");
233        assert_eq!(v["output"], "ok");
234        assert_eq!(v["is_error"], false);
235    }
236
237    #[test]
238    fn usage_projects_cached_tokens_when_present() {
239        let v = AgentEvent::Usage(Usage {
240            prompt_tokens: 10,
241            completion_tokens: 5,
242            total_tokens: 15,
243            prompt_tokens_details: Some(PromptTokensDetails { cached_tokens: 4 }),
244        })
245        .to_json();
246        assert_eq!(v["type"], "usage");
247        assert_eq!(v["prompt_tokens"], 10);
248        assert_eq!(v["cached_tokens"], 4);
249    }
250
251    #[test]
252    fn usage_projects_null_cached_tokens_when_absent() {
253        let v = AgentEvent::Usage(Usage {
254            prompt_tokens: 10,
255            completion_tokens: 5,
256            total_tokens: 15,
257            prompt_tokens_details: None,
258        })
259        .to_json();
260        assert!(v["cached_tokens"].is_null());
261    }
262
263    #[test]
264    fn background_output_projects_all_fields_not_unknown() {
265        let v = AgentEvent::BackgroundOutput {
266            job_id: "job_1".into(),
267            chunk: "more output".into(),
268            truncated: true,
269        }
270        .to_json();
271        assert_eq!(v["type"], "background_output");
272        assert_eq!(v["job_id"], "job_1");
273        assert_eq!(v["chunk"], "more output");
274        assert_eq!(v["truncated"], true);
275    }
276}