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