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 /// 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 /// the runtime agent's background-status operation, 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 /// Build the event emitted immediately before a tool call executes.
91 pub fn tool_started(call: &ToolCall) -> Self {
92 AgentEvent::ToolCallStarted {
93 id: call.id.clone(),
94 name: call.function.name.clone(),
95 arguments: call.function.arguments.clone(),
96 }
97 }
98
99 /// P5-8 (§2 module 31 `server`, completing Obligation 9's "partial"
100 /// core commitment): the canonical `{"type": ..., ...}` JSONL
101 /// projection of this event — field names mirror the enum's own
102 /// (`id`/`name`/`arguments`/`output`/`is_error`/`prompt_tokens`/…)
103 /// rather than a hand-maintained parallel vocabulary, so the wire shape
104 /// can never silently drift from the enum it projects.
105 ///
106 /// Shared by the CLI's `--output-format stream-json` sink (UX-23) and
107 /// the `server` module's RPC/SSE event-notification channel, so both
108 /// out-of-process surfaces stay byte-identical for the same event
109 /// instead of maintaining two hand-written projections that could
110 /// silently diverge.
111 ///
112 /// Match is exhaustive with NO wildcard arm on purpose: `#[non_exhaustive]`
113 /// only affects callers OUTSIDE this crate (it forced the CLI's old,
114 /// external copy of this projection to carry a `{"type":"unknown"}`
115 /// fallback arm) — from INSIDE the crate that defines the enum, adding a
116 /// future `AgentEvent` variant makes this fail to COMPILE until it's
117 /// given a real projection here, which is strictly safer than silently
118 /// falling back to an opaque `"unknown"` line for a new event kind.
119 pub fn to_json(&self) -> serde_json::Value {
120 match self {
121 AgentEvent::TextDelta(text) => {
122 serde_json::json!({"type": "text_delta", "text": text})
123 }
124 AgentEvent::TurnCompleted => serde_json::json!({"type": "turn_completed"}),
125 AgentEvent::ToolCallStarted {
126 id,
127 name,
128 arguments,
129 } => {
130 serde_json::json!({
131 "type": "tool_call_started",
132 "id": id,
133 "name": name,
134 "arguments": arguments,
135 })
136 }
137 AgentEvent::ToolCallCompleted {
138 id,
139 name,
140 output,
141 is_error,
142 } => {
143 serde_json::json!({
144 "type": "tool_call_completed",
145 "id": id,
146 "name": name,
147 "output": output,
148 "is_error": is_error,
149 })
150 }
151 AgentEvent::CacheWarning { message } => {
152 serde_json::json!({"type": "cache_warning", "message": message})
153 }
154 AgentEvent::Usage(usage) => {
155 serde_json::json!({
156 "type": "usage",
157 "prompt_tokens": usage.prompt_tokens,
158 "completion_tokens": usage.completion_tokens,
159 "total_tokens": usage.total_tokens,
160 "cached_tokens": usage.prompt_tokens_details.as_ref().map(|d| d.cached_tokens),
161 })
162 }
163 // P5-8: the one event kind added since `stream_json_sink` was
164 // first written (P5-6, module 4 `tools.background`) — it fell
165 // into the generic "unknown" catch-all before this method
166 // existed; giving it a real projection is part of "emit the
167 // full event set" (this unit's ladder rung 1 completion).
168 AgentEvent::BackgroundOutput {
169 job_id,
170 chunk,
171 truncated,
172 } => {
173 serde_json::json!({
174 "type": "background_output",
175 "job_id": job_id,
176 "chunk": chunk,
177 "truncated": truncated,
178 })
179 }
180 }
181 }
182}
183
184/// A sink for [`AgentEvent`]s.
185///
186/// This is a boxed closure so callers can wire up whatever they like (printing,
187/// channels, metrics) without the crate dictating a concurrency model.
188pub type EventSink = Box<dyn Fn(AgentEvent) + Send + Sync>;
189
190#[cfg(test)]
191mod tests {
192 use super::*;
193 use crate::{PromptTokensDetails, Usage};
194
195 #[test]
196 fn text_delta_projects_type_and_text() {
197 let v = AgentEvent::TextDelta("hi".to_string()).to_json();
198 assert_eq!(v["type"], "text_delta");
199 assert_eq!(v["text"], "hi");
200 }
201
202 #[test]
203 fn turn_completed_projects_bare_type() {
204 assert_eq!(
205 AgentEvent::TurnCompleted.to_json(),
206 serde_json::json!({"type": "turn_completed"})
207 );
208 }
209
210 #[test]
211 fn tool_call_started_projects_all_fields() {
212 let v = AgentEvent::ToolCallStarted {
213 id: "call_1".into(),
214 name: "bash".into(),
215 arguments: "{\"cmd\":\"ls\"}".into(),
216 }
217 .to_json();
218 assert_eq!(v["type"], "tool_call_started");
219 assert_eq!(v["id"], "call_1");
220 assert_eq!(v["name"], "bash");
221 assert_eq!(v["arguments"], "{\"cmd\":\"ls\"}");
222 }
223
224 #[test]
225 fn tool_call_completed_projects_all_fields() {
226 let v = AgentEvent::ToolCallCompleted {
227 id: "call_1".into(),
228 name: "bash".into(),
229 output: "ok".into(),
230 is_error: false,
231 }
232 .to_json();
233 assert_eq!(v["type"], "tool_call_completed");
234 assert_eq!(v["output"], "ok");
235 assert_eq!(v["is_error"], false);
236 }
237
238 #[test]
239 fn usage_projects_cached_tokens_when_present() {
240 let v = AgentEvent::Usage(Usage {
241 prompt_tokens: 10,
242 completion_tokens: 5,
243 total_tokens: 15,
244 prompt_tokens_details: Some(PromptTokensDetails { cached_tokens: 4 }),
245 })
246 .to_json();
247 assert_eq!(v["type"], "usage");
248 assert_eq!(v["prompt_tokens"], 10);
249 assert_eq!(v["cached_tokens"], 4);
250 }
251
252 #[test]
253 fn usage_projects_null_cached_tokens_when_absent() {
254 let v = AgentEvent::Usage(Usage {
255 prompt_tokens: 10,
256 completion_tokens: 5,
257 total_tokens: 15,
258 prompt_tokens_details: None,
259 })
260 .to_json();
261 assert!(v["cached_tokens"].is_null());
262 }
263
264 #[test]
265 fn background_output_projects_all_fields_not_unknown() {
266 let v = AgentEvent::BackgroundOutput {
267 job_id: "job_1".into(),
268 chunk: "more output".into(),
269 truncated: true,
270 }
271 .to_json();
272 assert_eq!(v["type"], "background_output");
273 assert_eq!(v["job_id"], "job_1");
274 assert_eq!(v["chunk"], "more output");
275 assert_eq!(v["truncated"], true);
276 }
277}