Skip to main content

agentkit_reporting/
lib.rs

1//! Reporting observers for the agentkit agent loop.
2//!
3//! This crate provides [`LoopObserver`] implementations that turn
4//! [`AgentEvent`]s into logs, usage summaries, transcripts, and
5//! machine-readable JSONL streams. Reporters are designed to be composed
6//! through [`CompositeReporter`] so a single loop can feed multiple
7//! observers at once.
8//!
9//! # Included reporters
10//!
11//! | Reporter | Purpose |
12//! |---|---|
13//! | [`StdoutReporter`] | Human-readable terminal output |
14//! | [`JsonlReporter`] | Machine-readable newline-delimited JSON |
15//! | [`UsageReporter`] | Aggregated token / cost totals |
16//! | [`TranscriptReporter`] | Growing snapshot of conversation items |
17//! | [`CompositeReporter`] | Fan-out to multiple reporters |
18//!
19//! # Adapter reporters
20//!
21//! | Adapter | Purpose |
22//! |---|---|
23//! | [`BufferedReporter`] | Enqueues events for batch flushing |
24//! | [`ChannelReporter`] | Forwards events to another thread or task |
25//! | [`TracingReporter`] | Converts events into `tracing` spans and events (requires `tracing` feature) |
26//!
27//! # Failure policy
28//!
29//! Wrap a [`FallibleObserver`] in a [`PolicyReporter`] to control how
30//! errors are handled — see [`FailurePolicy`].
31//!
32//! # Quick start
33//!
34//! ```rust
35//! use agentkit_reporting::{CompositeReporter, JsonlReporter, UsageReporter, TranscriptReporter};
36//!
37//! let reporter = CompositeReporter::new()
38//!     .with_observer(JsonlReporter::new(Vec::new()))
39//!     .with_observer(UsageReporter::new())
40//!     .with_observer(TranscriptReporter::new());
41//! ```
42
43mod buffered;
44mod channel;
45mod policy;
46
47#[cfg(feature = "tracing")]
48mod tracing_reporter;
49
50pub use buffered::BufferedReporter;
51pub use channel::ChannelReporter;
52pub use policy::{FailurePolicy, FallibleObserver, PolicyReporter};
53
54#[cfg(feature = "tracing")]
55pub use tracing_reporter::TracingReporter;
56
57use std::io::{self, Write};
58use std::time::SystemTime;
59
60use agentkit_core::{Item, ItemKind, Part, SessionId, TokenUsage, Usage};
61use agentkit_loop::{AgentEvent, LoopObserver, ObservedEvent, TurnResult};
62use serde::Serialize;
63use thiserror::Error;
64
65/// Errors that can occur while writing reports.
66///
67/// Reporter implementations (e.g. [`JsonlReporter`], [`StdoutReporter`])
68/// collect errors internally rather than surfacing them through the
69/// [`LoopObserver`] interface. Call the reporter's `take_errors()` method
70/// after the loop finishes to inspect any problems.
71#[derive(Debug, Error)]
72pub enum ReportError {
73    /// An I/O error occurred while writing to the underlying writer.
74    #[error("io error: {0}")]
75    Io(#[from] io::Error),
76    /// A serialization error occurred (JSONL reporters only).
77    #[error("serialization error: {0}")]
78    Serialize(#[from] serde_json::Error),
79    /// The receiving end of a channel was dropped.
80    #[error("channel send failed")]
81    ChannelSend,
82}
83
84/// A timestamped wrapper around an [`AgentEvent`].
85///
86/// [`JsonlReporter`] serializes each incoming event inside an
87/// `EventEnvelope` so that the resulting JSONL stream carries
88/// wall-clock timestamps alongside the event payload.
89#[derive(Clone, Debug, PartialEq, Serialize)]
90pub struct EventEnvelope<'a> {
91    /// When the event was observed.
92    pub timestamp: SystemTime,
93    /// Session routing key for shared reporter sinks.
94    #[serde(skip_serializing_if = "Option::is_none")]
95    pub session_id: Option<&'a SessionId>,
96    /// The underlying agent event.
97    pub event: &'a AgentEvent,
98}
99
100/// Fan-out reporter that forwards every [`AgentEvent`] to multiple child observers.
101///
102/// `CompositeReporter` itself implements [`LoopObserver`], so it can be
103/// handed directly to the agent loop. Each event is cloned once per child
104/// observer.
105///
106/// # Example
107///
108/// ```rust
109/// use agentkit_reporting::{
110///     CompositeReporter, JsonlReporter, StdoutReporter, UsageReporter,
111/// };
112///
113/// // Build a reporter that writes to JSONL, prints to stdout, and tracks usage.
114/// let reporter = CompositeReporter::new()
115///     .with_observer(JsonlReporter::new(Vec::new()))
116///     .with_observer(StdoutReporter::new(std::io::stdout()))
117///     .with_observer(UsageReporter::new());
118/// ```
119#[derive(Default)]
120pub struct CompositeReporter {
121    children: Vec<Box<dyn LoopObserver>>,
122}
123
124impl CompositeReporter {
125    /// Creates an empty `CompositeReporter` with no child observers.
126    pub fn new() -> Self {
127        Self::default()
128    }
129
130    /// Adds an observer and returns `self` (builder pattern).
131    ///
132    /// # Arguments
133    ///
134    /// * `observer` - Any type implementing [`LoopObserver`].
135    pub fn with_observer(mut self, observer: impl LoopObserver + 'static) -> Self {
136        self.children.push(Box::new(observer));
137        self
138    }
139
140    /// Adds an observer by mutable reference.
141    ///
142    /// Use this when you need to add observers after initial construction
143    /// rather than in a builder chain.
144    ///
145    /// # Arguments
146    ///
147    /// * `observer` - Any type implementing [`LoopObserver`].
148    pub fn push(&mut self, observer: impl LoopObserver + 'static) -> &mut Self {
149        self.children.push(Box::new(observer));
150        self
151    }
152}
153
154impl LoopObserver for CompositeReporter {
155    fn handle_event(&self, event: ObservedEvent) {
156        if self.children.is_empty() {
157            return;
158        }
159        let last = self.children.len() - 1;
160        for child in &self.children[..last] {
161            child.handle_event(event.clone());
162        }
163        self.children[last].handle_event(event);
164    }
165}
166
167/// Machine-readable reporter that writes one JSON object per line (JSONL).
168///
169/// Each [`AgentEvent`] is wrapped in an [`EventEnvelope`] with a timestamp
170/// and serialized as a single JSON line. This format is easy to ingest in
171/// log aggregation systems or to replay offline.
172///
173/// I/O and serialization errors are collected internally and can be
174/// retrieved with [`take_errors`](JsonlReporter::take_errors).
175///
176/// # Example
177///
178/// ```rust
179/// use agentkit_reporting::JsonlReporter;
180///
181/// // Write JSONL to an in-memory buffer (useful in tests).
182/// let reporter = JsonlReporter::new(Vec::new());
183///
184/// // Write JSONL to a file, flushing after every event.
185/// # fn example() -> std::io::Result<()> {
186/// let file = std::fs::File::create("events.jsonl")?;
187/// let reporter = JsonlReporter::new(std::io::BufWriter::new(file));
188/// # Ok(())
189/// # }
190/// ```
191pub struct JsonlReporter<W> {
192    writer: std::sync::Mutex<W>,
193    flush_each_event: bool,
194    include_session_id: bool,
195    errors: std::sync::Mutex<Vec<ReportError>>,
196}
197
198impl<W> JsonlReporter<W>
199where
200    W: Write,
201{
202    /// Creates a new `JsonlReporter` writing to the given writer.
203    pub fn new(writer: W) -> Self {
204        Self {
205            writer: std::sync::Mutex::new(writer),
206            flush_each_event: true,
207            include_session_id: false,
208            errors: std::sync::Mutex::new(Vec::new()),
209        }
210    }
211
212    /// Controls whether the writer is flushed after every event (builder pattern).
213    pub fn with_flush_each_event(mut self, flush_each_event: bool) -> Self {
214        self.flush_each_event = flush_each_event;
215        self
216    }
217
218    /// Controls whether each JSONL envelope includes the observed session id.
219    ///
220    /// This is opt-in to preserve the original JSONL envelope shape for
221    /// consumers that validate fields strictly.
222    pub fn with_session_id(mut self, include_session_id: bool) -> Self {
223        self.include_session_id = include_session_id;
224        self
225    }
226
227    /// Drains and returns all errors accumulated during event handling.
228    pub fn take_errors(&self) -> Vec<ReportError> {
229        std::mem::take(&mut *self.errors.lock().unwrap_or_else(|e| e.into_inner()))
230    }
231
232    fn record_result(&self, result: Result<(), ReportError>) {
233        if let Err(error) = result {
234            self.errors
235                .lock()
236                .unwrap_or_else(|e| e.into_inner())
237                .push(error);
238        }
239    }
240
241    /// Consumes the reporter and returns the underlying writer.
242    pub fn into_inner(self) -> W {
243        self.writer.into_inner().unwrap_or_else(|e| e.into_inner())
244    }
245}
246
247impl<W> LoopObserver for JsonlReporter<W>
248where
249    W: Write + Send,
250{
251    fn handle_event(&self, event: ObservedEvent) {
252        let result = (|| -> Result<(), ReportError> {
253            let envelope = EventEnvelope {
254                timestamp: SystemTime::now(),
255                session_id: self.include_session_id.then_some(event.session_id.as_ref()),
256                event: &event.event,
257            };
258            let mut buf = serde_json::to_vec(&envelope)?;
259            buf.push(b'\n');
260            let mut writer = self.writer.lock().unwrap_or_else(|e| e.into_inner());
261            writer.write_all(&buf)?;
262            if self.flush_each_event {
263                writer.flush()?;
264            }
265            Ok(())
266        })();
267        self.record_result(result);
268    }
269}
270
271/// Accumulated token counts across all events seen by a [`UsageReporter`].
272#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
273pub struct UsageTotals {
274    /// Total input (prompt) tokens consumed.
275    pub input_tokens: u64,
276    /// Total output (completion) tokens produced.
277    pub output_tokens: u64,
278    /// Total reasoning tokens used (model-dependent).
279    pub reasoning_tokens: u64,
280    /// Total input tokens served from the provider's cache.
281    pub cached_input_tokens: u64,
282    /// Total input tokens written into the provider's cache.
283    pub cache_write_input_tokens: u64,
284}
285
286/// Accumulated monetary cost across all events seen by a [`UsageReporter`].
287#[derive(Clone, Debug, Default, PartialEq)]
288pub struct CostTotals {
289    /// Running total cost expressed in `currency` units.
290    pub amount: f64,
291    /// ISO 4217 currency code (e.g. `"USD"`), set from the first cost event.
292    pub currency: Option<String>,
293}
294
295/// Snapshot of everything a [`UsageReporter`] has tracked so far.
296///
297/// Retrieve this via [`UsageReporter::summary`].
298#[derive(Clone, Debug, Default, PartialEq)]
299pub struct UsageSummary {
300    /// Total number of [`AgentEvent`]s observed (of any variant).
301    pub events_seen: usize,
302    /// Number of events that carried usage information
303    /// ([`AgentEvent::UsageUpdated`] or [`AgentEvent::TurnFinished`] with usage).
304    pub usage_events_seen: usize,
305    /// Number of [`AgentEvent::TurnFinished`] events observed.
306    pub turn_results_seen: usize,
307    /// Aggregated token counts.
308    pub totals: UsageTotals,
309    /// Aggregated cost, present only if at least one event carried cost data.
310    pub cost: Option<CostTotals>,
311}
312
313/// Reporter that aggregates token usage and cost across the entire run.
314///
315/// `UsageReporter` listens for [`AgentEvent::UsageUpdated`] and
316/// [`AgentEvent::TurnFinished`] events and maintains a running
317/// [`UsageSummary`]. After the loop completes, call [`summary`](UsageReporter::summary)
318/// to read the totals.
319///
320/// # Example
321///
322/// ```rust
323/// use agentkit_reporting::UsageReporter;
324/// use agentkit_loop::LoopObserver;
325///
326/// let mut reporter = UsageReporter::new();
327///
328/// // ...pass `reporter` to the agent loop, then afterwards:
329/// let summary = reporter.summary();
330/// println!(
331///     "tokens: {} in / {} out",
332///     summary.totals.input_tokens,
333///     summary.totals.output_tokens,
334/// );
335/// ```
336#[derive(Default)]
337pub struct UsageReporter {
338    summary: std::sync::Mutex<UsageSummary>,
339}
340
341impl UsageReporter {
342    /// Creates a new `UsageReporter` with zeroed counters.
343    pub fn new() -> Self {
344        Self::default()
345    }
346
347    /// Returns a snapshot of the current [`UsageSummary`].
348    pub fn summary(&self) -> UsageSummary {
349        self.summary
350            .lock()
351            .unwrap_or_else(|e| e.into_inner())
352            .clone()
353    }
354
355    fn absorb(summary: &mut UsageSummary, usage: &Usage) {
356        summary.usage_events_seen += 1;
357        if let Some(tokens) = &usage.tokens {
358            summary.totals.input_tokens += tokens.input_tokens;
359            summary.totals.output_tokens += tokens.output_tokens;
360            summary.totals.reasoning_tokens += tokens.reasoning_tokens.unwrap_or_default();
361            summary.totals.cached_input_tokens += tokens.cached_input_tokens.unwrap_or_default();
362            summary.totals.cache_write_input_tokens +=
363                tokens.cache_write_input_tokens.unwrap_or_default();
364        }
365        if let Some(cost) = &usage.cost {
366            let totals = summary.cost.get_or_insert_with(CostTotals::default);
367            totals.amount += cost.amount;
368            if totals.currency.is_none() {
369                totals.currency = Some(cost.currency.clone());
370            }
371        }
372    }
373}
374
375impl LoopObserver for UsageReporter {
376    fn handle_event(&self, event: ObservedEvent) {
377        let event = event.event;
378        let mut summary = self.summary.lock().unwrap_or_else(|e| e.into_inner());
379        summary.events_seen += 1;
380        match event {
381            AgentEvent::UsageUpdated(usage) => Self::absorb(&mut summary, &usage),
382            AgentEvent::TurnFinished(TurnResult {
383                usage: Some(usage), ..
384            }) => {
385                summary.turn_results_seen += 1;
386                Self::absorb(&mut summary, &usage);
387            }
388            AgentEvent::TurnFinished(_) => {
389                summary.turn_results_seen += 1;
390            }
391            _ => {}
392        }
393    }
394}
395
396/// Growing list of conversation [`Item`]s captured by a [`TranscriptReporter`].
397///
398/// Items are appended in the order they arrive: user inputs first, then
399/// assistant outputs from each finished turn.
400#[derive(Clone, Debug, Default, PartialEq)]
401pub struct TranscriptView {
402    /// The ordered sequence of conversation items.
403    pub items: Vec<Item>,
404}
405
406/// Reporter that captures the evolving conversation transcript.
407///
408/// `TranscriptReporter` listens for [`AgentEvent::InputAccepted`] and
409/// [`AgentEvent::TurnFinished`] events and accumulates their [`Item`]s
410/// into a [`TranscriptView`]. This is useful for post-run analysis or
411/// for displaying a conversation history.
412///
413/// # Example
414///
415/// ```rust
416/// use agentkit_reporting::TranscriptReporter;
417/// use agentkit_loop::LoopObserver;
418///
419/// let mut reporter = TranscriptReporter::new();
420///
421/// // ...pass `reporter` to the agent loop, then afterwards:
422/// for item in &reporter.transcript().items {
423///     println!("{:?}: {} parts", item.kind, item.parts.len());
424/// }
425/// ```
426#[derive(Default)]
427pub struct TranscriptReporter {
428    transcript: std::sync::Mutex<TranscriptView>,
429}
430
431impl TranscriptReporter {
432    /// Creates a new `TranscriptReporter` with an empty transcript.
433    pub fn new() -> Self {
434        Self::default()
435    }
436
437    /// Returns a snapshot of the current [`TranscriptView`].
438    pub fn transcript(&self) -> TranscriptView {
439        self.transcript
440            .lock()
441            .unwrap_or_else(|e| e.into_inner())
442            .clone()
443    }
444}
445
446impl LoopObserver for TranscriptReporter {
447    fn handle_event(&self, event: ObservedEvent) {
448        let event = event.event;
449        let mut transcript = self.transcript.lock().unwrap_or_else(|e| e.into_inner());
450        match event {
451            AgentEvent::InputAccepted { items, .. } => {
452                transcript.items.extend(items);
453            }
454            AgentEvent::TurnFinished(result) => {
455                transcript.items.extend(result.items);
456            }
457            _ => {}
458        }
459    }
460}
461
462/// Human-readable reporter that writes structured log lines to a [`Write`] sink.
463///
464/// Each [`AgentEvent`] is printed as a bracketed tag followed by key fields,
465/// for example `[turn] started session=abc turn=1`. Turn results include
466/// indented item and part summaries so the operator can follow the
467/// conversation at a glance.
468///
469/// I/O errors are collected internally; call
470/// [`take_errors`](StdoutReporter::take_errors) after the loop to inspect them.
471///
472/// # Example
473///
474/// ```rust
475/// use agentkit_reporting::StdoutReporter;
476///
477/// // Print events to stderr, hiding usage lines.
478/// let reporter = StdoutReporter::new(std::io::stderr())
479///     .with_usage(false);
480/// ```
481pub struct StdoutReporter<W> {
482    writer: std::sync::Mutex<W>,
483    show_usage: bool,
484    errors: std::sync::Mutex<Vec<ReportError>>,
485}
486
487impl<W> StdoutReporter<W>
488where
489    W: Write,
490{
491    /// Creates a new `StdoutReporter` that writes to the given writer.
492    pub fn new(writer: W) -> Self {
493        Self {
494            writer: std::sync::Mutex::new(writer),
495            show_usage: true,
496            errors: std::sync::Mutex::new(Vec::new()),
497        }
498    }
499
500    /// Controls whether `[usage]` lines are printed (builder pattern).
501    pub fn with_usage(mut self, show_usage: bool) -> Self {
502        self.show_usage = show_usage;
503        self
504    }
505
506    /// Drains and returns all errors accumulated during event handling.
507    pub fn take_errors(&self) -> Vec<ReportError> {
508        std::mem::take(&mut *self.errors.lock().unwrap_or_else(|e| e.into_inner()))
509    }
510
511    fn record_result(&self, result: Result<(), ReportError>) {
512        if let Err(error) = result {
513            self.errors
514                .lock()
515                .unwrap_or_else(|e| e.into_inner())
516                .push(error);
517        }
518    }
519}
520
521impl<W> LoopObserver for StdoutReporter<W>
522where
523    W: Write + Send,
524{
525    fn handle_event(&self, event: ObservedEvent) {
526        let event = event.event;
527        let result = (|| -> Result<(), ReportError> {
528            let mut buf: Vec<u8> = Vec::new();
529            write_stdout_event(&mut buf, &event, self.show_usage)?;
530            let mut writer = self.writer.lock().unwrap_or_else(|e| e.into_inner());
531            writer.write_all(&buf)?;
532            writer.flush()?;
533            Ok(())
534        })();
535        self.record_result(result);
536    }
537}
538
539fn write_stdout_event<W>(
540    writer: &mut W,
541    event: &AgentEvent,
542    show_usage: bool,
543) -> Result<(), ReportError>
544where
545    W: Write,
546{
547    match event {
548        AgentEvent::RunStarted { session_id } => {
549            writeln!(writer, "[run] started session={session_id}")?;
550        }
551        AgentEvent::TurnStarted {
552            session_id,
553            turn_id,
554        } => {
555            writeln!(writer, "[turn] started session={session_id} turn={turn_id}")?;
556        }
557        AgentEvent::InputAccepted { items, .. } => {
558            writeln!(writer, "[input] accepted items={}", items.len())?;
559        }
560        AgentEvent::ContentDelta(delta) => {
561            writeln!(writer, "[delta] {delta:?}")?;
562        }
563        AgentEvent::ToolCallRequested(call) => {
564            writeln!(writer, "[tool] call {} {}", call.name, call.input)?;
565        }
566        AgentEvent::ToolExecutionStarted(call) => {
567            writeln!(writer, "[tool] started {} {}", call.name, call.id)?;
568        }
569        AgentEvent::ToolExecutionProgress(result) => {
570            writeln!(
571                writer,
572                "[tool] progress call_id={} is_error={}",
573                result.call_id, result.is_error
574            )?;
575        }
576        AgentEvent::ToolResultReceived(result) => {
577            writeln!(
578                writer,
579                "[tool] result call_id={} is_error={}",
580                result.call_id, result.is_error
581            )?;
582        }
583        AgentEvent::ApprovalRequired(request) => {
584            writeln!(
585                writer,
586                "[approval] {} {:?}",
587                request.summary, request.reason
588            )?;
589        }
590        AgentEvent::ApprovalResolved { approved } => {
591            writeln!(writer, "[approval] resolved approved={approved}")?;
592        }
593        AgentEvent::ToolCatalogChanged(event) => {
594            writeln!(
595                writer,
596                "[tools] catalog changed source={} added={} removed={} changed={}",
597                event.source,
598                event.added.len(),
599                event.removed.len(),
600                event.changed.len()
601            )?;
602        }
603        AgentEvent::MutationStarted {
604            turn_id,
605            mutator,
606            point,
607            ..
608        } => {
609            writeln!(
610                writer,
611                "[mutation] started turn={} mutator={mutator} point={point:?}",
612                turn_id
613                    .as_ref()
614                    .map(ToString::to_string)
615                    .unwrap_or_else(|| "none".into()),
616            )?;
617        }
618        AgentEvent::MutationFinished {
619            turn_id,
620            mutator,
621            dirty,
622            ..
623        } => {
624            writeln!(
625                writer,
626                "[mutation] finished turn={} mutator={mutator} dirty={dirty}",
627                turn_id
628                    .as_ref()
629                    .map(ToString::to_string)
630                    .unwrap_or_else(|| "none".into()),
631            )?;
632        }
633        AgentEvent::UsageUpdated(usage) if show_usage => {
634            writeln!(writer, "[usage] {}", format_usage(usage))?;
635        }
636        AgentEvent::UsageUpdated(_) => {}
637        AgentEvent::Warning { message } => {
638            writeln!(writer, "[warning] {message}")?;
639        }
640        AgentEvent::RunFailed { message } => {
641            writeln!(writer, "[error] {message}")?;
642        }
643        AgentEvent::TurnFinished(result) => {
644            writeln!(
645                writer,
646                "[turn] finished reason={:?} items={}",
647                result.finish_reason,
648                result.items.len()
649            )?;
650            for item in &result.items {
651                write_item_summary(writer, item)?;
652            }
653            if show_usage && let Some(usage) = &result.usage {
654                writeln!(writer, "[usage] {}", format_usage(usage))?;
655            }
656        }
657        _ => {}
658    }
659
660    writer.flush()?;
661    Ok(())
662}
663
664fn write_item_summary<W>(writer: &mut W, item: &Item) -> Result<(), ReportError>
665where
666    W: Write,
667{
668    writeln!(writer, "  [{}]", item_kind_name(item.kind))?;
669    for part in &item.parts {
670        match part {
671            Part::Text(text) => writeln!(writer, "    [text] {}", text.text)?,
672            Part::Reasoning(reasoning) => {
673                if let Some(summary) = &reasoning.summary {
674                    writeln!(writer, "    [reasoning] {summary}")?;
675                } else {
676                    writeln!(writer, "    [reasoning]")?;
677                }
678            }
679            Part::ToolCall(call) => {
680                writeln!(writer, "    [tool-call] {} {}", call.name, call.input)?
681            }
682            Part::ToolResult(result) => writeln!(
683                writer,
684                "    [tool-result] call={} error={}",
685                result.call_id, result.is_error
686            )?,
687            Part::Structured(value) => writeln!(writer, "    [structured] {}", value.value)?,
688            Part::Media(media) => writeln!(
689                writer,
690                "    [media] {:?} {}",
691                media.modality, media.mime_type
692            )?,
693            Part::File(file) => writeln!(
694                writer,
695                "    [file] {}",
696                file.name.as_deref().unwrap_or("<unnamed>")
697            )?,
698            Part::Custom(custom) => writeln!(writer, "    [custom] {}", custom.kind)?,
699        }
700    }
701    Ok(())
702}
703
704fn item_kind_name(kind: ItemKind) -> &'static str {
705    match kind {
706        ItemKind::System => "system",
707        ItemKind::Developer => "developer",
708        ItemKind::User => "user",
709        ItemKind::Assistant => "assistant",
710        ItemKind::Tool => "tool",
711        ItemKind::Context => "context",
712        ItemKind::Notification => "notification",
713    }
714}
715
716fn format_usage(usage: &Usage) -> String {
717    match &usage.tokens {
718        Some(TokenUsage {
719            input_tokens,
720            output_tokens,
721            reasoning_tokens,
722            cached_input_tokens,
723            cache_write_input_tokens,
724        }) => format!(
725            "input={} output={} reasoning={} cached_input={} cache_write_input={}",
726            input_tokens,
727            output_tokens,
728            reasoning_tokens.unwrap_or_default(),
729            cached_input_tokens.unwrap_or_default(),
730            cache_write_input_tokens.unwrap_or_default()
731        ),
732        None => "no token usage".into(),
733    }
734}
735
736#[cfg(test)]
737mod tests {
738    use super::*;
739    use agentkit_core::{FinishReason, MetadataMap, SessionId, TextPart};
740    use agentkit_loop::TurnResult;
741
742    #[test]
743    fn usage_reporter_accumulates_usage_events_and_turn_results() {
744        let reporter = UsageReporter::new();
745
746        reporter.handle_event(observed(AgentEvent::UsageUpdated(Usage {
747            tokens: Some(TokenUsage {
748                input_tokens: 10,
749                output_tokens: 5,
750                reasoning_tokens: Some(2),
751                cached_input_tokens: Some(1),
752                cache_write_input_tokens: Some(7),
753            }),
754            cost: None,
755            metadata: MetadataMap::new(),
756        })));
757
758        reporter.handle_event(observed(AgentEvent::TurnFinished(TurnResult {
759            turn_id: "turn-1".into(),
760            finish_reason: FinishReason::Completed,
761            items: Vec::new(),
762            usage: Some(Usage {
763                tokens: Some(TokenUsage {
764                    input_tokens: 3,
765                    output_tokens: 4,
766                    reasoning_tokens: Some(1),
767                    cached_input_tokens: None,
768                    cache_write_input_tokens: None,
769                }),
770                cost: None,
771                metadata: MetadataMap::new(),
772            }),
773            metadata: MetadataMap::new(),
774        })));
775
776        let summary = reporter.summary();
777        assert_eq!(summary.events_seen, 2);
778        assert_eq!(summary.usage_events_seen, 2);
779        assert_eq!(summary.turn_results_seen, 1);
780        assert_eq!(summary.totals.input_tokens, 13);
781        assert_eq!(summary.totals.output_tokens, 9);
782        assert_eq!(summary.totals.reasoning_tokens, 3);
783        assert_eq!(summary.totals.cached_input_tokens, 1);
784        assert_eq!(summary.totals.cache_write_input_tokens, 7);
785    }
786
787    #[test]
788    fn transcript_reporter_tracks_inputs_and_outputs() {
789        let reporter = TranscriptReporter::new();
790
791        reporter.handle_event(observed(AgentEvent::InputAccepted {
792            session_id: SessionId::new("session-1"),
793            items: vec![Item {
794                id: None,
795                kind: ItemKind::User,
796                parts: vec![Part::Text(TextPart {
797                    text: "hello".into(),
798                    metadata: MetadataMap::new(),
799                })],
800                metadata: MetadataMap::new(),
801                usage: None,
802                finish_reason: None,
803                created_at: None,
804            }],
805        }));
806
807        reporter.handle_event(observed(AgentEvent::TurnFinished(TurnResult {
808            turn_id: "turn-1".into(),
809            finish_reason: FinishReason::Completed,
810            items: vec![Item {
811                id: None,
812                kind: ItemKind::Assistant,
813                parts: vec![Part::Text(TextPart {
814                    text: "hi".into(),
815                    metadata: MetadataMap::new(),
816                })],
817                metadata: MetadataMap::new(),
818                usage: None,
819                finish_reason: None,
820                created_at: None,
821            }],
822            usage: None,
823            metadata: MetadataMap::new(),
824        })));
825
826        assert_eq!(reporter.transcript().items.len(), 2);
827        assert_eq!(reporter.transcript().items[0].kind, ItemKind::User);
828        assert_eq!(reporter.transcript().items[1].kind, ItemKind::Assistant);
829    }
830
831    #[test]
832    fn jsonl_reporter_serializes_event_envelopes() {
833        let reporter = JsonlReporter::new(Vec::new());
834        reporter.handle_event(observed(AgentEvent::RunStarted {
835            session_id: SessionId::new("session-1"),
836        }));
837
838        let output = String::from_utf8(reporter.into_inner()).unwrap();
839        assert!(output.contains("\"RunStarted\""));
840        assert!(output.contains("session-1"));
841        assert!(!output.contains("\"session_id\":\"s1\""));
842    }
843
844    #[test]
845    fn jsonl_reporter_can_include_observed_session_id() {
846        let reporter = JsonlReporter::new(Vec::new()).with_session_id(true);
847        reporter.handle_event(observed(AgentEvent::ContentDelta(
848            agentkit_core::Delta::AppendText {
849                part_id: "part-1".into(),
850                chunk: "hello".into(),
851            },
852        )));
853
854        let output = String::from_utf8(reporter.into_inner()).unwrap();
855        assert!(output.contains("\"session_id\":\"s1\""));
856    }
857
858    fn run_started_event() -> AgentEvent {
859        AgentEvent::RunStarted {
860            session_id: SessionId::new("s1"),
861        }
862    }
863
864    fn observed(event: AgentEvent) -> ObservedEvent {
865        ObservedEvent {
866            session_id: std::sync::Arc::new(SessionId::new("s1")),
867            event,
868        }
869    }
870
871    #[test]
872    fn buffered_reporter_flushes_at_capacity() {
873        let reporter = BufferedReporter::new(UsageReporter::new(), 2);
874        reporter.handle_event(observed(run_started_event()));
875        assert_eq!(reporter.pending(), 1);
876        assert_eq!(reporter.inner().summary().events_seen, 0);
877
878        reporter.handle_event(observed(run_started_event()));
879        assert_eq!(reporter.pending(), 0);
880        assert_eq!(reporter.inner().summary().events_seen, 2);
881    }
882
883    #[test]
884    fn buffered_reporter_manual_flush() {
885        let reporter = BufferedReporter::new(UsageReporter::new(), 0);
886        reporter.handle_event(observed(run_started_event()));
887        reporter.handle_event(observed(run_started_event()));
888        assert_eq!(reporter.pending(), 2);
889
890        reporter.flush();
891        assert_eq!(reporter.pending(), 0);
892        assert_eq!(reporter.inner().summary().events_seen, 2);
893    }
894
895    #[test]
896    fn buffered_reporter_flushes_on_drop() {
897        let inner = {
898            let reporter = BufferedReporter::new(UsageReporter::new(), 100);
899            reporter.handle_event(observed(run_started_event()));
900            reporter.handle_event(observed(run_started_event()));
901            assert_eq!(reporter.inner().summary().events_seen, 0);
902            // Drop will flush — but we can't inspect after drop.
903            // Instead, verify flush works by checking pending before drop.
904            assert_eq!(reporter.pending(), 2);
905            reporter
906        };
907        // After the block, `inner` is the dropped BufferedReporter — but we
908        // moved it out, so it's still alive here. Verify flush happened on
909        // the inner reporter by inspecting it.
910        assert_eq!(inner.inner().summary().events_seen, 0);
911        // The actual drop-flush happens when `inner` goes out of scope at
912        // end of test. We at least verify the API is sound.
913    }
914
915    #[test]
916    fn channel_reporter_delivers_events() {
917        let (reporter, rx) = ChannelReporter::pair();
918        reporter.handle_event(observed(run_started_event()));
919        reporter.handle_event(observed(run_started_event()));
920
921        let events: Vec<_> = rx.try_iter().collect();
922        assert_eq!(events.len(), 2);
923        assert_eq!(events[0].session_id.0, "s1");
924    }
925
926    #[test]
927    fn channel_reporter_survives_dropped_receiver() {
928        let (reporter, rx) = ChannelReporter::pair();
929        drop(rx);
930        // Should not panic — errors are silently dropped.
931        reporter.handle_event(observed(run_started_event()));
932    }
933
934    #[test]
935    fn channel_reporter_fallible_returns_error_on_dropped_receiver() {
936        let (reporter, rx) = ChannelReporter::pair();
937        drop(rx);
938
939        let result = reporter.try_handle_event(&observed(run_started_event()));
940        assert!(matches!(result, Err(ReportError::ChannelSend)));
941    }
942
943    #[test]
944    fn policy_reporter_ignore_swallows_errors() {
945        let (reporter, rx) = ChannelReporter::pair();
946        drop(rx);
947        let policy = PolicyReporter::new(reporter, FailurePolicy::Ignore);
948        policy.handle_event(observed(run_started_event()));
949        assert!(policy.take_errors().is_empty());
950    }
951
952    #[test]
953    fn policy_reporter_accumulate_collects_errors() {
954        let (reporter, rx) = ChannelReporter::pair();
955        drop(rx);
956        let policy = PolicyReporter::new(reporter, FailurePolicy::Accumulate);
957        policy.handle_event(observed(run_started_event()));
958        policy.handle_event(observed(run_started_event()));
959
960        let errors = policy.take_errors();
961        assert_eq!(errors.len(), 2);
962        assert!(matches!(errors[0], ReportError::ChannelSend));
963    }
964
965    #[test]
966    #[should_panic(expected = "reporter error: channel send failed")]
967    fn policy_reporter_fail_fast_panics() {
968        let (reporter, rx) = ChannelReporter::pair();
969        drop(rx);
970        let policy = PolicyReporter::new(reporter, FailurePolicy::FailFast);
971        policy.handle_event(observed(run_started_event()));
972    }
973}