Skip to main content

agent_engine/extensions/
invoke_output.rs

1//! CP-11 fix-3: bounded, budgeted collection of `command.invoke` output.
2//!
3//! ## Threat model
4//!
5//! An interactive plugin command streams `command.output` / `task.*`
6//! notifications to the host while its `command.invoke` JSON-RPC response
7//! is pending. The pre-fix architecture handed the producer an
8//! `mpsc::UnboundedSender<InvokeCommandEvent>` whose consumer (the TUI)
9//! drained it only AFTER the call resolved — so a hostile or
10//! malfunctioning extension could park an aggregate flood of output
11//! bytes in host memory for the whole invocation window. The 4 MiB
12//! per-frame reader cap bounds individual frames, the 120 s invoke
13//! timeout bounds duration, and the one-in-flight user action bounds
14//! concurrency — none of them bounds aggregate queued bytes.
15//!
16//! ## Architecture
17//!
18//! A naive bounded-channel substitution would deadlock: the invocation
19//! loop awaits sends into the sink while the TUI's post-hoc drain waits
20//! for the call to resolve. Instead, the sink is BOUNDED
21//! ([`INVOKE_EVENT_QUEUE_CAPACITY`]) and paired with an EAGERLY
22//! CONCURRENT collector that runs alongside the invocation (see
23//! `ExtensionManager::invoke_command_collected`, which joins both
24//! futures). The collector consumes unconditionally — it never waits on
25//! anything but the channel — so producers are always paced, never
26//! parked behind an absent consumer, and budgets are enforced at
27//! PRODUCTION time:
28//!
29//! - retained payload bytes are capped by
30//!   [`InvokeOutputBudget::max_retained_payload_bytes`], with the final
31//!   partially-fitting text event truncated UTF-8-safely via
32//!   [`agent_core::BoundedText`];
33//! - retained events are capped by
34//!   [`InvokeOutputBudget::max_retained_events`];
35//! - terminal/control semantics are preserved past budget exhaustion
36//!   through a small dedicated control reserve: the first
37//!   `CommandOutputEvent::Done` marker is always retained, and a
38//!   `task.done` for a task whose `task.start` was retained is retained
39//!   (at most once per task id) so no retained task is left dangling;
40//! - everything else past budget is dropped WHOLE with exact byte/event
41//!   accounting; dropped `Error` events are additionally counted so
42//!   callers can surface error visibility (see
43//!   [`InvokeOutputReport::limit_notice`]).
44//!
45//! Truncation/coalescing policy is deterministic: head retention in
46//! arrival order; only `Text`/`System`/`Error` content is partially
47//! truncated (single-string payloads); `Table` and `task.*` events never
48//! fit partially — they are retained whole or dropped whole.
49//!
50//! Cancellation/timeout/drop safety: the sink is single-ownership (not
51//! `Clone`); when the invocation future is dropped (caller cancellation,
52//! 120 s timeout, transport failure) the sink drops with it, the
53//! collector's channel closes and [`InvokeOutputCollector::collect`]
54//! returns promptly, and any producer blocked on an awaited send is
55//! released by the dropped receiver. No detached tasks are involved.
56//!
57//! Counter identities (asserted by tests):
58//!
59//! - `consumed_payload_bytes == retained + truncated + dropped` bytes;
60//! - `consumed_events == retained_events + dropped_events`
61//!   (truncated events are retained);
62//! - `produced_* >= consumed_*`, equal while every send succeeds;
63//! - `retained_payload_bytes <= max_retained_payload_bytes +
64//!   control_reserve_bytes` at every instant.
65
66use std::sync::atomic::{AtomicU64, Ordering};
67use std::sync::Arc;
68
69use tokio::sync::mpsc;
70
71use crate::extensions::commands::CommandOutputEvent;
72use crate::extensions::runtime::InvokeCommandEvent;
73use crate::extensions::tasks::TaskEvent;
74
75/// Bounded producer→collector handoff capacity, in events. Small on
76/// purpose: with an eagerly-draining collector, channel-parked bytes are
77/// scheduling-transient; capacity only smooths bursts (mirrors
78/// `NOTIFICATION_QUEUE_CAPACITY` in the transport underneath).
79pub const INVOKE_EVENT_QUEUE_CAPACITY: usize = 8;
80
81/// Default invocation-local retained payload byte budget. Command output
82/// is chat-transcript text; 256 KiB comfortably covers legitimate
83/// commands while capping hostile floods.
84pub const INVOKE_RETAINED_BYTE_BUDGET: usize = 256 * 1024;
85
86/// Default invocation-local retained event budget (guards against floods
87/// of many tiny events — e.g. hostile `task.start` storms — that stay
88/// under the byte budget).
89pub const INVOKE_RETAINED_EVENT_BUDGET: usize = 1024;
90
91/// Byte reserve usable ONLY by post-budget control events (`Done`
92/// markers, `task.done` completion for already-retained tasks) so
93/// terminal semantics survive budget exhaustion. Its consumers are
94/// structurally bounded: at most one `task.done` per retained task id
95/// plus one `Done` marker.
96pub const INVOKE_CONTROL_RESERVE_BYTES: usize = 16 * 1024;
97
98/// Invocation-local budgets applied by the collector at production time.
99#[derive(Debug, Clone, Copy, PartialEq, Eq)]
100pub struct InvokeOutputBudget {
101    /// Max payload bytes retained for post-invoke consumption
102    /// (excluding the control reserve).
103    pub max_retained_payload_bytes: usize,
104    /// Max events retained for post-invoke consumption (control-reserve
105    /// retentions may exceed this by their structurally bounded count).
106    pub max_retained_events: usize,
107    /// Byte reserve for post-budget control retention.
108    pub control_reserve_bytes: usize,
109}
110
111impl Default for InvokeOutputBudget {
112    fn default() -> Self {
113        Self {
114            max_retained_payload_bytes: INVOKE_RETAINED_BYTE_BUDGET,
115            max_retained_events: INVOKE_RETAINED_EVENT_BUDGET,
116            control_reserve_bytes: INVOKE_CONTROL_RESERVE_BYTES,
117        }
118    }
119}
120
121impl InvokeOutputBudget {
122    /// Effectively unlimited budgets. MUTATION ORACLE ONLY: tests use
123    /// this to prove the bounded-retention assertions are sensitive to
124    /// the budget (disabling it must retain the whole flood). Production
125    /// call sites must use [`InvokeOutputBudget::default`].
126    pub fn unlimited() -> Self {
127        Self {
128            max_retained_payload_bytes: usize::MAX,
129            max_retained_events: usize::MAX,
130            control_reserve_bytes: 0,
131        }
132    }
133}
134
135/// Exact invocation-local accounting. See module docs for identities.
136#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
137pub struct InvokeOutputCounters {
138    /// Events/payload bytes the producer side handed to the sink
139    /// (counted BEFORE the send, so a closed channel still counts).
140    pub produced_events: u64,
141    pub produced_payload_bytes: u64,
142    /// Events/payload bytes the collector received from the channel.
143    pub consumed_events: u64,
144    pub consumed_payload_bytes: u64,
145    /// Events/payload bytes retained for post-invoke consumption
146    /// (truncated events count here with their KEPT byte length).
147    pub retained_events: u64,
148    pub retained_payload_bytes: u64,
149    /// Events partially truncated to fit the byte budget, and the exact
150    /// bytes trimmed from them.
151    pub truncated_events: u64,
152    pub truncated_payload_bytes: u64,
153    /// Events dropped whole, and their exact payload bytes.
154    pub dropped_events: u64,
155    pub dropped_payload_bytes: u64,
156    /// Subset of `dropped_events` that were `CommandOutputEvent::Error`
157    /// — surfaced so error visibility survives the drop policy.
158    pub dropped_error_events: u64,
159    /// Whether the producer emitted a `CommandOutputEvent::Done`
160    /// terminal marker.
161    pub saw_done: bool,
162}
163
164/// Everything retained from one `command.invoke` output stream, plus
165/// exact accounting.
166#[derive(Debug)]
167pub struct InvokeOutputReport {
168    /// Retained events in arrival order (post truncation policy).
169    pub events: Vec<InvokeCommandEvent>,
170    pub counters: InvokeOutputCounters,
171}
172
173impl InvokeOutputReport {
174    /// True when any budget limit affected the output.
175    pub fn is_limited(&self) -> bool {
176        self.counters.dropped_events > 0 || self.counters.truncated_events > 0
177    }
178
179    /// Human-readable limit notice with exact totals, or `None` when the
180    /// output was fully retained. `severity_error` is set when dropped
181    /// events included `Error` events (callers should surface the notice
182    /// on their error channel so error visibility is preserved).
183    pub fn limit_notice(&self) -> Option<InvokeOutputNotice> {
184        if !self.is_limited() {
185            return None;
186        }
187        let c = &self.counters;
188        let mut message = format!(
189            "command output limited by invocation budget: produced {} bytes in {} events; \
190             retained {} bytes in {} events; truncated {} bytes across {} events; \
191             dropped {} bytes across {} events",
192            c.produced_payload_bytes,
193            c.produced_events,
194            c.retained_payload_bytes,
195            c.retained_events,
196            c.truncated_payload_bytes,
197            c.truncated_events,
198            c.dropped_payload_bytes,
199            c.dropped_events,
200        );
201        if c.dropped_error_events > 0 {
202            message.push_str(&format!(
203                " (including {} error events)",
204                c.dropped_error_events
205            ));
206        }
207        Some(InvokeOutputNotice {
208            severity_error: c.dropped_error_events > 0,
209            message,
210        })
211    }
212}
213
214/// Limit notice for UI surfaces.
215#[derive(Debug, Clone, PartialEq, Eq)]
216pub struct InvokeOutputNotice {
217    pub message: String,
218    /// True when error events were dropped — surface as an error.
219    pub severity_error: bool,
220}
221
222/// Payload byte weight of one event: the UTF-8 byte length of every
223/// producer-controlled string it carries. `Done` is a zero-byte marker.
224pub fn event_payload_bytes(event: &InvokeCommandEvent) -> usize {
225    match event {
226        InvokeCommandEvent::Output(CommandOutputEvent::Text { content })
227        | InvokeCommandEvent::Output(CommandOutputEvent::System { content })
228        | InvokeCommandEvent::Output(CommandOutputEvent::Error { content }) => content.len(),
229        InvokeCommandEvent::Output(CommandOutputEvent::Table { headers, rows }) => {
230            headers.iter().map(String::len).sum::<usize>()
231                + rows
232                    .iter()
233                    .map(|r| r.iter().map(String::len).sum::<usize>())
234                    .sum::<usize>()
235        }
236        InvokeCommandEvent::Output(CommandOutputEvent::Done) => 0,
237        InvokeCommandEvent::Task(TaskEvent::Start { id, label, .. }) => id.len() + label.len(),
238        InvokeCommandEvent::Task(TaskEvent::Update { id, message, .. }) => {
239            id.len() + message.as_deref().map(str::len).unwrap_or(0)
240        }
241        InvokeCommandEvent::Task(TaskEvent::Log { id, line }) => id.len() + line.len(),
242        InvokeCommandEvent::Task(TaskEvent::Done { id, error }) => {
243            id.len() + error.as_deref().map(str::len).unwrap_or(0)
244        }
245    }
246}
247
248/// Shared produced-side meter (producer counts before each send).
249#[derive(Debug, Default)]
250struct InvokeMeter {
251    produced_events: AtomicU64,
252    produced_payload_bytes: AtomicU64,
253}
254
255/// Error returned by [`InvokeEventSink::send`] when the collector (and
256/// its channel) is gone. Producers treat this exactly like the old
257/// closed-unbounded-sink case: stop forwarding, let the call finish.
258#[derive(Debug, Clone, Copy, PartialEq, Eq)]
259pub struct InvokeSinkClosed;
260
261/// Bounded, metered producer handle for `command.invoke` events.
262///
263/// Single-ownership on purpose (no `Clone`): dropping the invocation
264/// future drops the only sender, which closes the collector's channel —
265/// the property cancellation safety relies on.
266#[derive(Debug)]
267pub struct InvokeEventSink {
268    tx: mpsc::Sender<InvokeCommandEvent>,
269    meter: Arc<InvokeMeter>,
270}
271
272impl InvokeEventSink {
273    /// Awaited, backpressured send. Counts the event as produced whether
274    /// or not the collector is still there.
275    pub async fn send(&self, event: InvokeCommandEvent) -> Result<(), InvokeSinkClosed> {
276        self.meter.produced_events.fetch_add(1, Ordering::SeqCst);
277        self.meter
278            .produced_payload_bytes
279            .fetch_add(event_payload_bytes(&event) as u64, Ordering::SeqCst);
280        self.tx.send(event).await.map_err(|_| InvokeSinkClosed)
281    }
282
283    /// Whether the collector side is gone.
284    pub fn is_closed(&self) -> bool {
285        self.tx.is_closed()
286    }
287}
288
289/// Eagerly concurrent consumer for one invocation's event stream.
290#[derive(Debug)]
291pub struct InvokeOutputCollector {
292    rx: mpsc::Receiver<InvokeCommandEvent>,
293    meter: Arc<InvokeMeter>,
294    budget: InvokeOutputBudget,
295}
296
297/// Create the bounded sink/collector pair for one `command.invoke`.
298pub fn invoke_event_channel(
299    budget: InvokeOutputBudget,
300) -> (InvokeEventSink, InvokeOutputCollector) {
301    let (tx, rx) = mpsc::channel(INVOKE_EVENT_QUEUE_CAPACITY);
302    let meter = Arc::new(InvokeMeter::default());
303    (
304        InvokeEventSink {
305            tx,
306            meter: Arc::clone(&meter),
307        },
308        InvokeOutputCollector { rx, meter, budget },
309    )
310}
311
312impl InvokeOutputCollector {
313    /// Drain the channel until the sink drops, applying the budget policy
314    /// to every event as it is produced. Must run CONCURRENTLY with the
315    /// invocation future (e.g. `tokio::join!`) — it is the consumer that
316    /// keeps bounded producers from parking.
317    pub async fn collect(mut self) -> InvokeOutputReport {
318        let mut state = CollectorState::new(self.budget);
319        while let Some(event) = self.rx.recv().await {
320            state.ingest(event);
321        }
322        state.finish(&self.meter)
323    }
324}
325
326/// Budget-enforcing retention state. Separated from the channel so the
327/// policy is unit-testable synchronously.
328struct CollectorState {
329    budget: InvokeOutputBudget,
330    events: Vec<InvokeCommandEvent>,
331    counters: InvokeOutputCounters,
332    /// Ordinary-budget bytes used (excludes control reserve).
333    ordinary_bytes: usize,
334    /// Control-reserve bytes used.
335    control_bytes: usize,
336    /// Task ids whose `task.start` is retained.
337    started_task_ids: std::collections::HashSet<String>,
338    /// Task ids whose `task.done` is already retained.
339    done_task_ids: std::collections::HashSet<String>,
340}
341
342impl CollectorState {
343    fn new(budget: InvokeOutputBudget) -> Self {
344        Self {
345            budget,
346            events: Vec::new(),
347            counters: InvokeOutputCounters::default(),
348            ordinary_bytes: 0,
349            control_bytes: 0,
350            started_task_ids: std::collections::HashSet::new(),
351            done_task_ids: std::collections::HashSet::new(),
352        }
353    }
354
355    fn ingest(&mut self, event: InvokeCommandEvent) {
356        let bytes = event_payload_bytes(&event);
357        self.counters.consumed_events += 1;
358        self.counters.consumed_payload_bytes += bytes as u64;
359
360        match event {
361            InvokeCommandEvent::Output(CommandOutputEvent::Done) => {
362                if self.counters.saw_done {
363                    // Duplicate terminal markers carry no information.
364                    self.drop_whole(0, false);
365                } else {
366                    self.counters.saw_done = true;
367                    // Zero-byte control retention: always fits.
368                    self.retain_control(InvokeCommandEvent::Output(CommandOutputEvent::Done), 0);
369                }
370            }
371            InvokeCommandEvent::Task(TaskEvent::Done { id, error }) => {
372                let within_ordinary = self.fits_ordinary(bytes);
373                let already_done = self.done_task_ids.contains(&id);
374                if within_ordinary && !already_done {
375                    self.done_task_ids.insert(id.clone());
376                    self.retain_ordinary(
377                        InvokeCommandEvent::Task(TaskEvent::Done { id, error }),
378                        bytes,
379                    );
380                } else if !already_done
381                    && self.started_task_ids.contains(&id)
382                    && self.control_bytes + bytes <= self.budget.control_reserve_bytes
383                {
384                    // Past budget, but the task's start is retained:
385                    // preserve completion so no retained task dangles.
386                    self.done_task_ids.insert(id.clone());
387                    self.retain_control(
388                        InvokeCommandEvent::Task(TaskEvent::Done { id, error }),
389                        bytes,
390                    );
391                } else {
392                    self.drop_whole(bytes, false);
393                }
394            }
395            event => self.ingest_ordinary(event, bytes),
396        }
397    }
398
399    fn fits_ordinary(&self, bytes: usize) -> bool {
400        (self.counters.retained_events as usize) < self.budget.max_retained_events
401            && self
402                .ordinary_bytes
403                .checked_add(bytes)
404                .is_some_and(|total| total <= self.budget.max_retained_payload_bytes)
405    }
406
407    fn ingest_ordinary(&mut self, event: InvokeCommandEvent, bytes: usize) {
408        let is_error = is_error_event(&event);
409        if (self.counters.retained_events as usize) >= self.budget.max_retained_events {
410            self.drop_whole(bytes, is_error);
411            return;
412        }
413        let remaining = self
414            .budget
415            .max_retained_payload_bytes
416            .saturating_sub(self.ordinary_bytes);
417        if bytes <= remaining {
418            if let InvokeCommandEvent::Task(TaskEvent::Start { id, .. }) = &event {
419                self.started_task_ids.insert(id.clone());
420            }
421            self.retain_ordinary(event, bytes);
422            return;
423        }
424        // Partially fitting single-string text payloads truncate
425        // UTF-8-safely to exactly the remaining budget; everything else
426        // drops whole (Tables/task events have no deterministic partial
427        // representation).
428        if remaining > 0 {
429            if let Some((rebuild, content)) = truncatable_content(event) {
430                let bounded = agent_core::BoundedText::new(&content, remaining);
431                let kept = bounded.retained_bytes;
432                debug_assert!(bounded.truncated);
433                self.counters.truncated_events += 1;
434                self.counters.truncated_payload_bytes += (bytes - kept) as u64;
435                self.retain_ordinary(rebuild(bounded.text), kept);
436                return;
437            }
438            // Not partially representable — fell through to a whole drop.
439        }
440        self.drop_whole(bytes, is_error);
441    }
442
443    fn retain_ordinary(&mut self, event: InvokeCommandEvent, kept_bytes: usize) {
444        self.ordinary_bytes += kept_bytes;
445        self.counters.retained_events += 1;
446        self.counters.retained_payload_bytes += kept_bytes as u64;
447        self.events.push(event);
448    }
449
450    fn retain_control(&mut self, event: InvokeCommandEvent, kept_bytes: usize) {
451        self.control_bytes += kept_bytes;
452        self.counters.retained_events += 1;
453        self.counters.retained_payload_bytes += kept_bytes as u64;
454        self.events.push(event);
455    }
456
457    fn drop_whole(&mut self, bytes: usize, is_error: bool) {
458        self.counters.dropped_events += 1;
459        self.counters.dropped_payload_bytes += bytes as u64;
460        if is_error {
461            self.counters.dropped_error_events += 1;
462        }
463    }
464
465    fn finish(mut self, meter: &InvokeMeter) -> InvokeOutputReport {
466        self.counters.produced_events = meter.produced_events.load(Ordering::SeqCst);
467        self.counters.produced_payload_bytes = meter.produced_payload_bytes.load(Ordering::SeqCst);
468        debug_assert!(
469            self.counters.retained_payload_bytes
470                <= (self.budget.max_retained_payload_bytes as u64)
471                    .saturating_add(self.budget.control_reserve_bytes as u64),
472            "retained bytes must never exceed budget + control reserve",
473        );
474        InvokeOutputReport {
475            events: self.events,
476            counters: self.counters,
477        }
478    }
479}
480
481fn is_error_event(event: &InvokeCommandEvent) -> bool {
482    matches!(
483        event,
484        InvokeCommandEvent::Output(CommandOutputEvent::Error { .. })
485    )
486}
487
488/// For single-string text payloads, return the content plus a rebuilder
489/// applied after truncation. `None` for events with no deterministic
490/// partial representation.
491#[allow(clippy::type_complexity)]
492fn truncatable_content(
493    event: InvokeCommandEvent,
494) -> Option<(fn(String) -> InvokeCommandEvent, String)> {
495    match event {
496        InvokeCommandEvent::Output(CommandOutputEvent::Text { content }) => Some((
497            |content| InvokeCommandEvent::Output(CommandOutputEvent::Text { content }),
498            content,
499        )),
500        InvokeCommandEvent::Output(CommandOutputEvent::System { content }) => Some((
501            |content| InvokeCommandEvent::Output(CommandOutputEvent::System { content }),
502            content,
503        )),
504        InvokeCommandEvent::Output(CommandOutputEvent::Error { content }) => Some((
505            |content| InvokeCommandEvent::Output(CommandOutputEvent::Error { content }),
506            content,
507        )),
508        _ => None,
509    }
510}
511
512#[cfg(test)]
513mod tests {
514    use super::*;
515
516    fn text(content: &str) -> InvokeCommandEvent {
517        InvokeCommandEvent::Output(CommandOutputEvent::Text {
518            content: content.to_string(),
519        })
520    }
521
522    fn budget(bytes: usize, events: usize, reserve: usize) -> InvokeOutputBudget {
523        InvokeOutputBudget {
524            max_retained_payload_bytes: bytes,
525            max_retained_events: events,
526            control_reserve_bytes: reserve,
527        }
528    }
529
530    fn ingest_all(
531        budget: InvokeOutputBudget,
532        events: Vec<InvokeCommandEvent>,
533    ) -> InvokeOutputReport {
534        let mut state = CollectorState::new(budget);
535        let meter = InvokeMeter::default();
536        for ev in events {
537            meter.produced_events.fetch_add(1, Ordering::SeqCst);
538            meter
539                .produced_payload_bytes
540                .fetch_add(event_payload_bytes(&ev) as u64, Ordering::SeqCst);
541            state.ingest(ev);
542        }
543        state.finish(&meter)
544    }
545
546    fn assert_conservation(report: &InvokeOutputReport) {
547        let c = &report.counters;
548        assert_eq!(
549            c.consumed_payload_bytes,
550            c.retained_payload_bytes + c.truncated_payload_bytes + c.dropped_payload_bytes,
551            "byte conservation"
552        );
553        assert_eq!(
554            c.consumed_events,
555            c.retained_events + c.dropped_events,
556            "event conservation"
557        );
558        assert_eq!(report.events.len() as u64, c.retained_events);
559    }
560
561    #[test]
562    fn under_budget_output_is_retained_verbatim() {
563        let events = vec![
564            text("hello"),
565            text("world"),
566            InvokeCommandEvent::Output(CommandOutputEvent::Done),
567        ];
568        let report = ingest_all(InvokeOutputBudget::default(), events.clone());
569        assert_eq!(report.events, events);
570        assert!(!report.is_limited());
571        assert!(report.limit_notice().is_none());
572        assert!(report.counters.saw_done);
573        assert_eq!(report.counters.retained_payload_bytes, 10);
574        assert_eq!(report.counters.produced_payload_bytes, 10);
575        assert_conservation(&report);
576    }
577
578    #[test]
579    fn head_retention_truncates_boundary_event_then_drops_rest() {
580        // Budget 10: "abcdef" (6) fits, "ghijkl" (6) truncates to 4, rest drops.
581        let report = ingest_all(
582            budget(10, 100, 0),
583            vec![text("abcdef"), text("ghijkl"), text("mnopqr")],
584        );
585        assert_eq!(report.events, vec![text("abcdef"), text("ghij")]);
586        let c = &report.counters;
587        assert_eq!(c.retained_payload_bytes, 10);
588        assert_eq!(c.truncated_events, 1);
589        assert_eq!(c.truncated_payload_bytes, 2);
590        assert_eq!(c.dropped_events, 1);
591        assert_eq!(c.dropped_payload_bytes, 6);
592        assert_conservation(&report);
593    }
594
595    #[test]
596    fn truncation_is_utf8_char_boundary_safe() {
597        // "éé" is 4 bytes; budget 3 keeps only the first 'é' (2 bytes).
598        let report = ingest_all(budget(3, 100, 0), vec![text("éé")]);
599        assert_eq!(report.events, vec![text("é")]);
600        assert_eq!(report.counters.retained_payload_bytes, 2);
601        assert_eq!(report.counters.truncated_payload_bytes, 2);
602        assert_conservation(&report);
603    }
604
605    #[test]
606    fn event_budget_caps_tiny_event_floods() {
607        let flood: Vec<_> = (0..500).map(|i| text(&format!("{i}"))).collect();
608        let report = ingest_all(budget(usize::MAX, 16, 0), flood);
609        assert_eq!(report.counters.retained_events, 16);
610        assert_eq!(report.counters.dropped_events, 484);
611        assert_conservation(&report);
612    }
613
614    #[test]
615    fn first_done_marker_survives_budget_exhaustion() {
616        let report = ingest_all(
617            budget(4, 100, 1024),
618            vec![
619                text("aaaa"),
620                text("bbbb"),
621                InvokeCommandEvent::Output(CommandOutputEvent::Done),
622                InvokeCommandEvent::Output(CommandOutputEvent::Done),
623            ],
624        );
625        assert!(report.counters.saw_done);
626        assert_eq!(
627            report.events,
628            vec![
629                text("aaaa"),
630                InvokeCommandEvent::Output(CommandOutputEvent::Done)
631            ]
632        );
633        // The duplicate Done was dropped (zero bytes).
634        assert_eq!(report.counters.dropped_events, 2);
635        assert_conservation(&report);
636    }
637
638    #[test]
639    fn task_done_for_retained_start_survives_budget_exhaustion() {
640        let start = InvokeCommandEvent::Task(TaskEvent::Start {
641            id: "t1".into(),
642            label: "L".into(),
643            kind: crate::extensions::tasks::TaskKind::Download,
644        });
645        let done = InvokeCommandEvent::Task(TaskEvent::Done {
646            id: "t1".into(),
647            error: None,
648        });
649        let report = ingest_all(
650            budget(3, 100, 1024),
651            vec![start.clone(), text("xxxxxxxx"), done.clone(), done.clone()],
652        );
653        // start (3 bytes) fits; flood text dropped; first done retained
654        // via control reserve; duplicate done dropped.
655        assert_eq!(report.events, vec![start, done]);
656        assert_eq!(report.counters.dropped_events, 2);
657        assert_conservation(&report);
658    }
659
660    #[test]
661    fn task_done_for_unknown_task_does_not_consume_control_reserve() {
662        let done = InvokeCommandEvent::Task(TaskEvent::Done {
663            id: "ghost".into(),
664            error: None,
665        });
666        let report = ingest_all(budget(0, 0, 1024), vec![done]);
667        assert!(report.events.is_empty());
668        assert_eq!(report.counters.dropped_events, 1);
669        assert_conservation(&report);
670    }
671
672    #[test]
673    fn dropped_error_events_are_counted_and_flagged_in_notice() {
674        let report = ingest_all(
675            budget(2, 100, 0),
676            vec![
677                text("xx"),
678                InvokeCommandEvent::Output(CommandOutputEvent::Error {
679                    content: "boom".into(),
680                }),
681            ],
682        );
683        assert_eq!(report.counters.dropped_error_events, 1);
684        let notice = report.limit_notice().expect("limited");
685        assert!(notice.severity_error);
686        assert!(notice.message.contains("including 1 error events"));
687        assert_conservation(&report);
688    }
689
690    /// MUTATION ORACLE: with the budget disabled, the collector retains
691    /// the ENTIRE flood — proving the bounded-retention assertions in the
692    /// adversarial tests are sensitive to the budget (restoring post-hoc
693    /// unbounded behavior or disabling the budget must fail them).
694    #[test]
695    fn disabled_budget_retains_entire_flood() {
696        let flood: Vec<_> = (0..1000).map(|_| text(&"z".repeat(1024))).collect();
697        let report = ingest_all(InvokeOutputBudget::unlimited(), flood);
698        let c = &report.counters;
699        assert_eq!(c.retained_payload_bytes, 1_024_000);
700        assert_eq!(c.retained_events, 1000);
701        assert_eq!(c.dropped_events, 0);
702        assert_eq!(c.truncated_events, 0);
703        assert!(
704            c.retained_payload_bytes > INVOKE_RETAINED_BYTE_BUDGET as u64,
705            "an unlimited budget must visibly violate the production ceiling"
706        );
707        assert_conservation(&report);
708    }
709
710    /// Producer counting is send-attempt based: a closed collector still
711    /// counts produced events, so produced >= consumed holds.
712    #[tokio::test]
713    async fn produced_counts_survive_closed_collector() {
714        let (sink, collector) = invoke_event_channel(InvokeOutputBudget::default());
715        sink.send(text("a")).await.expect("open");
716        // Race-free: collector still holds the channel; drop it now.
717        drop(collector);
718        assert!(matches!(sink.send(text("bb")).await, Err(InvokeSinkClosed)));
719        assert!(sink.is_closed());
720    }
721}