Skip to main content

zeph_subagent/
forward.rs

1// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4//! Live subagent transcript forwarding (issue #6359, spec `068-subagent-transcript-forward`;
5//! token-level intra-turn streaming, issue #6456, FR-002b).
6//!
7//! Opt-in forwarding of a running subagent's text/thinking output to the TUI runtime detail
8//! view and/or a `--bare` stdout sink, under the single `forward_transcript` config flag.
9//! Granularity depends on provider support: when the provider's native streaming-with-tools
10//! path is available (`agent_loop.rs` drives it), text/thinking chunks are forwarded as
11//! partial deltas *within* a turn; otherwise (or when streaming fails) the full, untruncated
12//! text/thinking output of one completed LLM turn is forwarded once the turn completes
13//! (FR-002a, unchanged). Pipeline shape:
14//!
15//! ```text
16//! agent_loop.rs (sync, non-blocking) --try_send(RawChunk)--> per-task mpsc (cap 128)
17//!     -> manager-owned per-task drain: sanitize (the ONE sanitize point) -> dispatch to sinks
18//! ```
19//!
20//! `RawChunk` only ever travels on the ingress channel; `SanitizedChunk` is constructed
21//! exclusively by the drain's sanitize step and is the only type any sink can receive
22//! (NFR-005 enforced structurally, not by convention).
23//!
24//! # Design contract: deltas are ephemeral, display-only (FR-002b)
25//!
26//! Every chunk sent through `ForwardSender::send_text` / `ForwardSender::send_thinking` —
27//! whether it carries a whole turn's text or one streamed delta — travels on the same
28//! tail-drop `mpsc` and MUST be treated as **display-only**. A dropped chunk is a display
29//! gap, never a correctness error: the loop's own accumulated response text (returned from
30//! `run_agent_loop`'s LLM call and pushed into `messages`) is assembled independently of
31//! whether any given delta was actually forwarded, and the guaranteed terminal chunk (see
32//! `ForwardSender::send_terminal`) marks the one point a consumer may treat as authoritative
33//! for "this run reached a terminal state". No consumer (TUI ring buffer, `--bare` sink, a
34//! future sink) may reconstruct the subagent's conversational state — let alone feed it back
35//! into the parent's LLM context — by concatenating forwarded chunks; deltas never enter any
36//! LLM context, they exist purely for live human-facing display.
37
38use std::collections::{HashMap, VecDeque};
39use std::sync::Arc;
40use std::sync::atomic::{AtomicU64, Ordering};
41use std::time::Duration;
42
43use tokio::sync::mpsc;
44use zeph_sanitizer::pii::PiiFilter;
45use zeph_sanitizer::secret_mask::SecretMaskRegistry;
46use zeph_sanitizer::secret_shape::scrub_secret_shapes;
47use zeph_sanitizer::{ContentSanitizer, ContentSource, ContentSourceKind};
48
49use crate::state::SubAgentState;
50
51/// Bound on the per-task ingress channel (mpsc). `try_send` drops the newest chunk on
52/// full (tail-drop) rather than blocking the subagent's own turn loop (NFR-001).
53const FORWARD_CHANNEL_CAPACITY: usize = 128;
54
55/// Maximum number of sanitized display lines retained per task in the TUI ring buffer.
56const FORWARD_RING_CAPACITY: usize = 200;
57
58/// How long a finished task's ring buffer entry survives after its terminal chunk, so a
59/// TUI detail view opened just after completion still shows the final transcript.
60const FORWARD_BUFFER_GRACE: Duration = Duration::from_secs(5);
61
62/// Which consumer surfaces are active for this session, fixed at session start (session
63/// scope, not hot-swappable — a headless run does not gain a TUI mid-session).
64///
65/// Set once via [`crate::SubAgentManager::set_forward_surfaces`] during bootstrap. When both
66/// fields are `false`, no forwarding sender or drain is ever constructed for any subagent,
67/// regardless of `forward_transcript` config (FR-007).
68#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
69pub struct ForwardSurfaces {
70    /// A TUI session is active — sanitized chunks are appended to the per-task ring buffer.
71    pub tui: bool,
72    /// `--bare` mode is active — sanitized chunks are written as JSON lines to stdout.
73    pub bare: bool,
74}
75
76impl ForwardSurfaces {
77    /// Returns `true` when at least one consumer surface is active.
78    #[must_use]
79    pub fn any(self) -> bool {
80        self.tui || self.bare
81    }
82}
83
84/// One incremental piece of a subagent's forwarded output, pre-sanitize.
85///
86/// Only ever travels on the per-task ingress `mpsc` — never exposed outside this module.
87#[derive(Debug, Clone)]
88pub(crate) struct RawChunk {
89    kind: ForwardChunkKind,
90}
91
92/// The content carried by a forwarded chunk. `pub(crate)`: only ever constructed by
93/// `ForwardSender`'s `send_*` methods, never named outside this crate.
94#[derive(Debug, Clone)]
95#[non_exhaustive]
96pub(crate) enum ForwardChunkKind {
97    /// Full, untruncated text produced by one completed LLM turn (FR-002a).
98    Text(String),
99    /// Full, untruncated visible reasoning text from one thinking block.
100    Thinking(String),
101    /// End-of-transcript signal (FR-008): either the loop's own terminal status, or a
102    /// synthesized backstop when the ingress channel closed without one (hard abort).
103    Terminal(SubAgentState),
104}
105
106/// A forwarded chunk after passing through the drain's single sanitize stage.
107///
108/// Constructed only by the drain's internal sanitize step — the sole type any sink (TUI
109/// ring, `--bare` stdout, a future network sink) can receive, so a sink author cannot
110/// physically emit unsanitized content (NFR-005). `pub(crate)` (not `pub`, security review
111/// Finding 2): nothing outside this crate needs this type — `SubAgentManager::forwarded_tail`
112/// exposes already-rendered `String` lines instead — so it is not part of the public API
113/// surface a future sink integration could hand-construct from.
114#[derive(Debug, Clone)]
115pub(crate) struct SanitizedChunk {
116    /// Task ID of the originating subagent.
117    pub(crate) task_id: Arc<str>,
118    /// Subagent definition name.
119    pub(crate) def_name: Arc<str>,
120    /// Monotonic per-task sequence number (FR-003).
121    pub(crate) seq: u64,
122    /// The sanitized content.
123    pub(crate) kind: SanitizedChunkKind,
124}
125
126/// Sanitized variant of [`ForwardChunkKind`].
127#[derive(Debug, Clone)]
128#[non_exhaustive]
129pub(crate) enum SanitizedChunkKind {
130    /// Sanitized text output.
131    Text(String),
132    /// Sanitized thinking output.
133    Thinking(String),
134    /// End-of-transcript signal, carried through unchanged (no text to sanitize).
135    Terminal(SubAgentState),
136}
137
138/// The full sanitization pipeline applied at the drain's single sanitize point (NFR-005).
139///
140/// Bundles the baseline injection/truncation pass (`ContentSanitizer`, always present), an
141/// always-on generic secret-*shape* scrub (`scrub_secret_shapes`, #6571 — catches API-key-
142/// shaped strings a subagent fabricates or echoes, not just registered vault values), and two
143/// optional hardening layers that mirror the ones already guarding the analogous sub-agent-
144/// output *egress* path (debug dumps, see `PiiScrubbingDumpSink` / #6407 and
145/// `apply_secret_masking` / #5437): a [`SecretMaskRegistry`] that replaces known vault
146/// secrets with opaque placeholders, and a [`PiiFilter`] that scrubs emails/phones/SSNs/etc.
147/// The latter two are `None` unless explicitly wired via `SubAgentManager::set_secret_registry`
148/// / `set_pii_filter` — forwarding remains fully functional (baseline + shape sanitization
149/// only) when neither is configured, matching this crate's existing opt-in-hardening
150/// conventions.
151pub(crate) struct SanitizeLayers {
152    pub(crate) sanitizer: ContentSanitizer,
153    pub(crate) secret_registry: Option<Arc<SecretMaskRegistry>>,
154    pub(crate) pii_filter: Option<PiiFilter>,
155}
156
157fn sanitize_text(raw_text: &str, def_name: &str, layers: &SanitizeLayers) -> String {
158    let source = ContentSource::new(ContentSourceKind::ToolResult).with_identifier(def_name);
159    let mut body = layers.sanitizer.sanitize(raw_text, source).body;
160    // Exact-value registry masking runs first so a registered vault secret gets its typed
161    // `<SECRET:category:...>` placeholder; the shape-based scrub below then only catches
162    // whatever the registry didn't know about (e.g. a key a subagent fabricates or echoes).
163    if let Some(registry) = &layers.secret_registry {
164        body = registry.mask(&body);
165    }
166    body = scrub_secret_shapes(&body).into_owned();
167    if let Some(filter) = &layers.pii_filter {
168        body = filter.scrub(&body).into_owned();
169    }
170    body
171}
172
173/// Bounded lookback window (bytes) held back from the tail of a pending `Text`/`Thinking`
174/// buffer before sanitizing and emitting its safe prefix (review Critical Issue #2, #6456
175/// follow-up).
176///
177/// Without this, each streamed delta (FR-002b) was sanitized in complete isolation — a
178/// secret or PII pattern split across two `ToolSseEvent` chunk boundaries matched neither
179/// fragment individually and reached `--bare` stdout / the TUI ring buffer unmasked. Holding
180/// back this many trailing bytes on every partial flush guarantees any pattern whose two
181/// halves arrive within this window of each other is always sanitized as one contiguous
182/// string before being released.
183///
184/// Chosen generously above [`crate::grants::GrantedSecret`]-delivered or vault-registered
185/// secret lengths seen in practice and every PII pattern in `zeph_sanitizer::pii` (email/
186/// phone/SSN/credit-card are all well under 80 bytes). A secret whose split fragments are
187/// separated by *more* than this many bytes of other already-flushed content is a residual
188/// limitation inherent to any bounded-window approach — not eliminated, only made
189/// practically unreachable for realistic secret/PII lengths.
190const SANITIZE_HOLDBACK_BYTES: usize = 256;
191
192/// Per-task raw text accumulated but not yet sanitized/emitted (review Critical Issue #2).
193///
194/// Kept separate for the `Text` and `Thinking` streams since they are independent logical
195/// channels that must never be concatenated with each other.
196#[derive(Default)]
197struct PendingSanitizeBuffers {
198    text: String,
199    thinking: String,
200}
201
202/// Split off `buf`'s sanitizable prefix, leaving the last `holdback` bytes (rounded down to
203/// the nearest UTF-8 char boundary, same class of problem as UTF-8 chunk-boundary handling)
204/// in place for a future call to potentially combine with. Pass `holdback = 0` to flush the
205/// entire remaining buffer — used once no more data for this task is coming (an explicit
206/// `Terminal` chunk or the hard-abort backstop), so buffered content is only ever delayed,
207/// never silently dropped. Returns `None` when there is nothing new to emit yet.
208fn split_off_safe_prefix(buf: &mut String, holdback: usize) -> Option<String> {
209    if buf.is_empty() {
210        return None;
211    }
212    let target = buf.len().saturating_sub(holdback);
213    let boundary = buf.floor_char_boundary(target);
214    if boundary == 0 {
215        return None;
216    }
217    let prefix = buf[..boundary].to_owned();
218    buf.drain(..boundary);
219    Some(prefix)
220}
221
222/// Attempt to flush a pending buffer's safe prefix, sanitize it, and wrap the result via
223/// `wrap_kind` (`SanitizedChunkKind::Text` or `::Thinking`, both valid as a
224/// `fn(String) -> SanitizedChunkKind` since each is a single-field tuple variant). Returns
225/// `None` when [`split_off_safe_prefix`] found nothing new to emit yet.
226fn try_flush_kind(
227    buf: &mut String,
228    holdback: usize,
229    def_name: &str,
230    layers: &SanitizeLayers,
231    wrap_kind: fn(String) -> SanitizedChunkKind,
232) -> Option<SanitizedChunkKind> {
233    let safe = split_off_safe_prefix(buf, holdback)?;
234    Some(wrap_kind(sanitize_text(&safe, def_name, layers)))
235}
236
237fn make_sanitized_chunk(
238    task_id: &Arc<str>,
239    def_name: &Arc<str>,
240    seq: u64,
241    kind: SanitizedChunkKind,
242) -> SanitizedChunk {
243    SanitizedChunk {
244        task_id: Arc::clone(task_id),
245        def_name: Arc::clone(def_name),
246        seq,
247        kind,
248    }
249}
250
251/// Flush both pending buffers in full (no holdback — nothing more is coming for this task)
252/// and dispatch any resulting chunk(s). Called immediately before an explicit `Terminal`
253/// chunk or the hard-abort backstop, so buffered content is only ever delayed until the
254/// run's very end, never silently dropped.
255#[allow(clippy::too_many_arguments)]
256fn flush_all_pending(
257    pending: &mut PendingSanitizeBuffers,
258    task_id: &Arc<str>,
259    def_name: &Arc<str>,
260    layers: &SanitizeLayers,
261    surfaces: ForwardSurfaces,
262    buffer: &ForwardBuffer,
263    dispatch: &mut impl FnMut(&SanitizedChunk, ForwardSurfaces, &ForwardBuffer),
264    emit_seq: &mut u64,
265) {
266    if let Some(kind) = try_flush_kind(
267        &mut pending.text,
268        0,
269        def_name.as_ref(),
270        layers,
271        SanitizedChunkKind::Text,
272    ) {
273        dispatch(
274            &make_sanitized_chunk(task_id, def_name, *emit_seq, kind),
275            surfaces,
276            buffer,
277        );
278        *emit_seq += 1;
279    }
280    if let Some(kind) = try_flush_kind(
281        &mut pending.thinking,
282        0,
283        def_name.as_ref(),
284        layers,
285        SanitizedChunkKind::Thinking,
286    ) {
287        dispatch(
288            &make_sanitized_chunk(task_id, def_name, *emit_seq, kind),
289            surfaces,
290            buffer,
291        );
292        *emit_seq += 1;
293    }
294}
295
296/// Sender-side handle held by a single subagent's own turn loop for the lifetime of its
297/// run only.
298///
299/// Deliberately **not** `Clone`: the drain's hard-abort backstop (see [`run_forward_drain`])
300/// relies on this being the sole `mpsc::Sender` for its task — dropping the loop's future
301/// must be the only way the channel closes. Do not store this (or its inner `Sender`) in
302/// any struct that outlives a single subagent run (`SpawnContext`, a resume/retry retainer,
303/// etc.) — see P-new-3 in the implementation handoff.
304pub(crate) struct ForwardSender {
305    tx: mpsc::Sender<RawChunk>,
306    task_id: Arc<str>,
307    def_name: Arc<str>,
308    seq: AtomicU64,
309    dropped: AtomicU64,
310}
311
312impl ForwardSender {
313    pub(crate) fn new(tx: mpsc::Sender<RawChunk>, task_id: Arc<str>, def_name: Arc<str>) -> Self {
314        Self {
315            tx,
316            task_id,
317            def_name,
318            seq: AtomicU64::new(0),
319            dropped: AtomicU64::new(0),
320        }
321    }
322
323    fn try_send(&self, kind: ForwardChunkKind) {
324        let seq = self.seq.fetch_add(1, Ordering::Relaxed);
325        let chunk = RawChunk { kind };
326        if self.tx.try_send(chunk).is_ok() {
327            tracing::debug!(
328                task_id = %self.task_id,
329                def_name = %self.def_name,
330                seq,
331                "subagent.forward.emit"
332            );
333        } else {
334            let dropped = self.dropped.fetch_add(1, Ordering::Relaxed) + 1;
335            tracing::warn!(
336                task_id = %self.task_id,
337                def_name = %self.def_name,
338                seq,
339                dropped,
340                "subagent.forward.drop: ingress channel full, chunk dropped"
341            );
342        }
343    }
344
345    /// Forward a piece of assistant text output. Call only from behind an
346    /// `if let Some(f) = forward` guard — the caller (`agent_loop.rs`) must never construct
347    /// or clone the text ahead of that guard (FR-007).
348    ///
349    /// `text` may be a whole turn's full, untruncated text (FR-002a, the non-streaming or
350    /// stream-fallback path) or one incremental delta from a native streaming response
351    /// (FR-002b) — both are display-only chunks tail-dropped under backpressure identically;
352    /// see the module-level "Design contract" section. Callers must not send both the
353    /// streamed deltas and the final whole-turn text for the same turn — that would double-
354    /// forward the same content (see `agent_loop.rs::call_provider_with_status`'s `streamed`
355    /// flag).
356    pub(crate) fn send_text(&self, text: &str) {
357        if text.is_empty() {
358            return;
359        }
360        self.try_send(ForwardChunkKind::Text(text.to_owned()));
361    }
362
363    /// Forward a piece of visible thinking output — a whole completed thinking block
364    /// (FR-002a) or one incremental thinking delta (FR-002b). Same no-op-behind-`Some`
365    /// contract and no-double-forward caller responsibility as [`send_text`][Self::send_text].
366    pub(crate) fn send_thinking(&self, text: &str) {
367        if text.is_empty() {
368            return;
369        }
370        self.try_send(ForwardChunkKind::Thinking(text.to_owned()));
371    }
372
373    /// Emit the terminal (end-of-transcript) chunk. Co-located with every site that
374    /// publishes a terminal `SubAgentStatus` on the status channel (FR-008).
375    pub(crate) fn send_terminal(&self, state: SubAgentState) {
376        tracing::debug!(task_id = %self.task_id, ?state, "subagent.forward.terminal");
377        self.try_send(ForwardChunkKind::Terminal(state));
378    }
379}
380
381pub(crate) type ForwardBuffer = std::sync::Mutex<HashMap<String, VecDeque<String>>>;
382
383/// Render a sanitized chunk as a single display line for the TUI ring buffer, or `None`
384/// for chunks that carry no display text (terminal events).
385fn display_line(kind: &SanitizedChunkKind) -> Option<String> {
386    match kind {
387        SanitizedChunkKind::Text(t) => Some(t.clone()),
388        SanitizedChunkKind::Thinking(t) => Some(format!("[thinking] {t}")),
389        SanitizedChunkKind::Terminal(_) => None,
390    }
391}
392
393fn state_str(state: SubAgentState) -> &'static str {
394    match state {
395        SubAgentState::Submitted => "submitted",
396        SubAgentState::Working => "working",
397        SubAgentState::Completed => "completed",
398        SubAgentState::Failed => "failed",
399        SubAgentState::Canceled => "canceled",
400    }
401}
402
403/// Write one `--bare` stdout event as a single JSON line (M6: one `println!` per chunk,
404/// never multi-write — `println!` takes Rust's internal stdout lock per call, so this is
405/// line-atomic even when interleaved with the main output path).
406fn emit_bare_line(chunk: &SanitizedChunk) {
407    #[derive(serde::Serialize)]
408    struct BareForwardEvent<'a> {
409        task_id: &'a str,
410        def_name: &'a str,
411        seq: u64,
412        kind: &'static str,
413        #[serde(skip_serializing_if = "Option::is_none")]
414        content: Option<&'a str>,
415        #[serde(skip_serializing_if = "Option::is_none")]
416        state: Option<&'static str>,
417    }
418
419    let (kind, content, state) = match &chunk.kind {
420        SanitizedChunkKind::Text(t) => ("text", Some(t.as_str()), None),
421        SanitizedChunkKind::Thinking(t) => ("thinking", Some(t.as_str()), None),
422        SanitizedChunkKind::Terminal(s) => ("terminal", None, Some(state_str(*s))),
423    };
424    let event = BareForwardEvent {
425        task_id: &chunk.task_id,
426        def_name: &chunk.def_name,
427        seq: chunk.seq,
428        kind,
429        content,
430        state,
431    };
432    if let Ok(line) = serde_json::to_string(&event) {
433        println!("{line}");
434    }
435}
436
437/// Dispatch one sanitized chunk to every active surface.
438fn dispatch_chunk(chunk: &SanitizedChunk, surfaces: ForwardSurfaces, buffer: &ForwardBuffer) {
439    if surfaces.tui
440        && let Some(line) = display_line(&chunk.kind)
441    {
442        let mut guard = buffer
443            .lock()
444            .unwrap_or_else(std::sync::PoisonError::into_inner);
445        let ring = guard.entry(chunk.task_id.to_string()).or_default();
446        ring.push_back(line);
447        while ring.len() > FORWARD_RING_CAPACITY {
448            ring.pop_front();
449        }
450    }
451    if surfaces.bare {
452        emit_bare_line(chunk);
453    }
454}
455
456/// Build a fresh `mpsc` ingress pair and its sender-side handle for one subagent run.
457pub(crate) fn new_channel(
458    task_id: Arc<str>,
459    def_name: Arc<str>,
460) -> (ForwardSender, mpsc::Receiver<RawChunk>) {
461    let (tx, rx) = mpsc::channel(FORWARD_CHANNEL_CAPACITY);
462    (ForwardSender::new(tx, task_id, def_name), rx)
463}
464
465/// Manager-owned per-task drain: the single sanitize stage plus sink dispatch, running for
466/// the lifetime of one subagent's forwarding channel.
467///
468/// # Terminal detection (critic C-new-1, must-fix)
469///
470/// The loop breaks immediately after dispatching **any** explicit terminal chunk (sent by
471/// `agent_loop.rs` at each of its three terminal-status sites). This is the only way to
472/// avoid double-emitting a terminal on the happy path: on normal completion the loop sends
473/// an explicit `Terminal` and then drops its `Sender`; because the `Some(raw)` arm below
474/// breaks unconditionally on a terminal chunk, `recv()` is never called again afterward, so
475/// the `None` arm can never fire once an explicit terminal has already been handled.
476/// Consequently, reaching the `None` arm at all — the channel closed with no message
477/// pending — is *only* possible when no explicit terminal was ever sent, i.e. the genuine
478/// hard-abort backstop (`JoinHandle::abort()` / cancel-token firing mid-`.await` drops the
479/// loop's future, and with it its sole `Sender`, before any terminal-status site runs): it
480/// unconditionally synthesizes `Terminal(Canceled)`.
481///
482/// After the loop ends, the task's ring buffer entry is evicted following a short grace
483/// window so a TUI detail view opened just after completion still shows the final
484/// transcript (S3: bounds `forward_buffer` growth across a long multi-subagent session).
485pub(crate) async fn run_forward_drain(
486    task_id: Arc<str>,
487    def_name: Arc<str>,
488    rx: mpsc::Receiver<RawChunk>,
489    layers: SanitizeLayers,
490    surfaces: ForwardSurfaces,
491    buffer: Arc<ForwardBuffer>,
492) {
493    run_forward_drain_with(
494        task_id,
495        def_name,
496        rx,
497        layers,
498        surfaces,
499        buffer,
500        dispatch_chunk,
501    )
502    .await;
503}
504
505/// Same as [`run_forward_drain`], parameterized over the dispatch step so tests can observe
506/// exactly how many (and which) [`SanitizedChunk`]s the drain hands to the sinks — including
507/// `Terminal` chunks, which [`dispatch_chunk`] itself never writes to the TUI ring buffer
508/// (`display_line` returns `None` for them) and which the eviction sweep runs unconditionally
509/// after either loop exit, so buffer *contents* alone cannot distinguish "exactly one terminal
510/// dispatched" from "two". Production always calls this via [`run_forward_drain`] with
511/// [`dispatch_chunk`] itself as the dispatch step — behavior is unchanged.
512async fn run_forward_drain_with(
513    task_id: Arc<str>,
514    def_name: Arc<str>,
515    mut rx: mpsc::Receiver<RawChunk>,
516    layers: SanitizeLayers,
517    surfaces: ForwardSurfaces,
518    buffer: Arc<ForwardBuffer>,
519    mut dispatch: impl FnMut(&SanitizedChunk, ForwardSurfaces, &ForwardBuffer),
520) {
521    let mut pending = PendingSanitizeBuffers::default();
522    let mut emit_seq: u64 = 0;
523
524    loop {
525        if let Some(raw) = rx.recv().await {
526            match raw.kind {
527                ForwardChunkKind::Text(delta) => {
528                    pending.text.push_str(&delta);
529                    if let Some(kind) = try_flush_kind(
530                        &mut pending.text,
531                        SANITIZE_HOLDBACK_BYTES,
532                        def_name.as_ref(),
533                        &layers,
534                        SanitizedChunkKind::Text,
535                    ) {
536                        dispatch(
537                            &make_sanitized_chunk(&task_id, &def_name, emit_seq, kind),
538                            surfaces,
539                            &buffer,
540                        );
541                        emit_seq += 1;
542                    }
543                }
544                ForwardChunkKind::Thinking(delta) => {
545                    pending.thinking.push_str(&delta);
546                    if let Some(kind) = try_flush_kind(
547                        &mut pending.thinking,
548                        SANITIZE_HOLDBACK_BYTES,
549                        def_name.as_ref(),
550                        &layers,
551                        SanitizedChunkKind::Thinking,
552                    ) {
553                        dispatch(
554                            &make_sanitized_chunk(&task_id, &def_name, emit_seq, kind),
555                            surfaces,
556                            &buffer,
557                        );
558                        emit_seq += 1;
559                    }
560                }
561                ForwardChunkKind::Terminal(state) => {
562                    flush_all_pending(
563                        &mut pending,
564                        &task_id,
565                        &def_name,
566                        &layers,
567                        surfaces,
568                        &buffer,
569                        &mut dispatch,
570                        &mut emit_seq,
571                    );
572                    let chunk = make_sanitized_chunk(
573                        &task_id,
574                        &def_name,
575                        emit_seq,
576                        SanitizedChunkKind::Terminal(state),
577                    );
578                    dispatch(&chunk, surfaces, &buffer);
579                    break;
580                }
581            }
582        } else {
583            tracing::warn!(
584                task_id = %task_id,
585                "subagent.forward.terminal: ingress channel closed without an explicit \
586                 terminal chunk — synthesizing hard-abort backstop"
587            );
588            flush_all_pending(
589                &mut pending,
590                &task_id,
591                &def_name,
592                &layers,
593                surfaces,
594                &buffer,
595                &mut dispatch,
596                &mut emit_seq,
597            );
598            let synthesized = make_sanitized_chunk(
599                &task_id,
600                &def_name,
601                emit_seq,
602                SanitizedChunkKind::Terminal(SubAgentState::Canceled),
603            );
604            dispatch(&synthesized, surfaces, &buffer);
605            break;
606        }
607    }
608
609    tokio::time::sleep(FORWARD_BUFFER_GRACE).await;
610    buffer
611        .lock()
612        .unwrap_or_else(std::sync::PoisonError::into_inner)
613        .remove(task_id.as_ref());
614}
615
616/// Read the current ring-buffer tail for `task_id` (up to the last `n` lines).
617///
618/// Returns an empty vector for a task with no forwarded lines yet (or forwarding inactive).
619pub(crate) fn forwarded_tail(buffer: &ForwardBuffer, task_id: &str, n: usize) -> Vec<String> {
620    let guard = buffer
621        .lock()
622        .unwrap_or_else(std::sync::PoisonError::into_inner);
623    guard.get(task_id).map_or_else(Vec::new, |ring| {
624        ring.iter().rev().take(n).rev().cloned().collect()
625    })
626}
627
628/// Construct a fresh, empty forwarding ring buffer.
629pub(crate) fn new_buffer() -> Arc<ForwardBuffer> {
630    Arc::new(std::sync::Mutex::new(HashMap::new()))
631}
632
633#[cfg(test)]
634mod tests {
635    use std::sync::atomic::AtomicUsize;
636
637    use zeph_config::sanitizer::PiiFilterConfig;
638
639    use super::*;
640
641    fn layers() -> SanitizeLayers {
642        SanitizeLayers {
643            sanitizer: ContentSanitizer::new(&zeph_config::ContentIsolationConfig::default()),
644            secret_registry: None,
645            pii_filter: None,
646        }
647    }
648
649    /// Runs the drain via [`run_forward_drain_with`], counting how many `Terminal` chunks
650    /// were actually handed to the dispatch step — the direct, discriminating observable for
651    /// critic C-new-1 (a regression that re-introduces the double-terminal bug increments this
652    /// to 2; buffer state and hang/panic-absence cannot tell the two implementations apart,
653    /// since `dispatch_chunk` never writes `Terminal` chunks to the ring buffer and the
654    /// post-loop eviction runs exactly once regardless of how many terminals were dispatched
655    /// beforehand).
656    async fn run_and_count_terminals(
657        task_id: Arc<str>,
658        def_name: Arc<str>,
659        rx: mpsc::Receiver<RawChunk>,
660        surfaces: ForwardSurfaces,
661        buffer: Arc<ForwardBuffer>,
662    ) -> usize {
663        let terminal_dispatches = Arc::new(AtomicUsize::new(0));
664        let counter = Arc::clone(&terminal_dispatches);
665        run_forward_drain_with(
666            task_id,
667            def_name,
668            rx,
669            layers(),
670            surfaces,
671            buffer,
672            move |chunk, surfaces, buffer| {
673                if matches!(chunk.kind, SanitizedChunkKind::Terminal(_)) {
674                    counter.fetch_add(1, Ordering::SeqCst);
675                }
676                dispatch_chunk(chunk, surfaces, buffer);
677            },
678        )
679        .await;
680        terminal_dispatches.load(Ordering::SeqCst)
681    }
682
683    #[tokio::test(start_paused = true)]
684    async fn happy_path_emits_no_spurious_second_terminal() {
685        // Regression guard for critic C-new-1: an explicit Terminal followed by Sender drop
686        // must produce exactly one terminal dispatch, not two. Asserts on the actual dispatch
687        // count (see `run_and_count_terminals`), not on buffer state — a Terminal chunk is
688        // never written to the ring buffer, so buffer-only assertions cannot detect this
689        // regression (confirmed by the testing validator).
690        let task_id: Arc<str> = Arc::from("task-1");
691        let def_name: Arc<str> = Arc::from("agent-1");
692        let (sender, rx) = new_channel(Arc::clone(&task_id), Arc::clone(&def_name));
693        let buffer = new_buffer();
694
695        sender.send_text("hello");
696        sender.send_terminal(SubAgentState::Completed);
697        drop(sender);
698
699        let terminal_count = run_and_count_terminals(
700            Arc::clone(&task_id),
701            def_name,
702            rx,
703            ForwardSurfaces {
704                tui: true,
705                bare: false,
706            },
707            Arc::clone(&buffer),
708        )
709        .await;
710
711        assert_eq!(
712            terminal_count, 1,
713            "exactly one terminal chunk must be dispatched — a second would mean the drain \
714             looped back to recv() after the explicit terminal (C-new-1 regression)"
715        );
716        let tail = forwarded_tail(&buffer, &task_id, 10);
717        assert!(
718            tail.is_empty(),
719            "buffer entry must be evicted after grace window"
720        );
721    }
722
723    #[tokio::test(start_paused = true)]
724    async fn hard_abort_without_explicit_terminal_synthesizes_backstop() {
725        let task_id: Arc<str> = Arc::from("task-2");
726        let def_name: Arc<str> = Arc::from("agent-2");
727        let (sender, rx) = new_channel(Arc::clone(&task_id), Arc::clone(&def_name));
728        let buffer = new_buffer();
729
730        sender.send_text("partial output");
731        drop(sender); // simulate abort: no explicit terminal was ever sent
732
733        let terminal_count = run_and_count_terminals(
734            Arc::clone(&task_id),
735            def_name,
736            rx,
737            ForwardSurfaces {
738                tui: true,
739                bare: false,
740            },
741            buffer,
742        )
743        .await;
744
745        assert_eq!(
746            terminal_count, 1,
747            "exactly one synthesized backstop terminal must be dispatched on hard abort"
748        );
749    }
750
751    #[tokio::test(start_paused = true)]
752    async fn zero_consumer_surfaces_still_drains_without_panicking() {
753        let task_id: Arc<str> = Arc::from("task-3");
754        let def_name: Arc<str> = Arc::from("agent-3");
755        let (sender, rx) = new_channel(Arc::clone(&task_id), Arc::clone(&def_name));
756        let buffer = new_buffer();
757
758        sender.send_text("no one is listening");
759        sender.send_terminal(SubAgentState::Completed);
760        drop(sender);
761
762        run_forward_drain(
763            task_id,
764            def_name,
765            rx,
766            layers(),
767            ForwardSurfaces::default(),
768            buffer,
769        )
770        .await;
771    }
772
773    #[tokio::test(start_paused = true)]
774    async fn secret_registry_masks_forwarded_text_and_thinking() {
775        // NFR-005 / security Finding 1: forwarded content containing a registered vault
776        // secret must come out masked, not verbatim.
777        use zeph_sanitizer::secret_mask::{SecretCategory, SecretMaskRegistry};
778
779        let registry = Arc::new(SecretMaskRegistry::new());
780        registry.register(
781            "MY_KEY",
782            "sk-live-topsecretvalue123",
783            SecretCategory::ApiKey,
784        );
785
786        let task_id: Arc<str> = Arc::from("task-secret");
787        let def_name: Arc<str> = Arc::from("agent-secret");
788        let (sender, rx) = new_channel(Arc::clone(&task_id), Arc::clone(&def_name));
789        let buffer = new_buffer();
790
791        sender.send_text("the key is sk-live-topsecretvalue123, use it wisely");
792        sender.send_thinking("I will use sk-live-topsecretvalue123 to authenticate");
793        sender.send_terminal(SubAgentState::Completed);
794        drop(sender);
795
796        let seen = Arc::new(std::sync::Mutex::new(Vec::<SanitizedChunk>::new()));
797        let collected = Arc::clone(&seen);
798        let layers = SanitizeLayers {
799            sanitizer: ContentSanitizer::new(&zeph_config::ContentIsolationConfig::default()),
800            secret_registry: Some(registry),
801            pii_filter: None,
802        };
803        run_forward_drain_with(
804            task_id,
805            def_name,
806            rx,
807            layers,
808            ForwardSurfaces {
809                tui: true,
810                bare: false,
811            },
812            buffer,
813            move |chunk, surfaces, buffer| {
814                collected.lock().unwrap().push(chunk.clone());
815                dispatch_chunk(chunk, surfaces, buffer);
816            },
817        )
818        .await;
819
820        let chunks = seen.lock().unwrap();
821        for chunk in chunks.iter() {
822            match &chunk.kind {
823                SanitizedChunkKind::Text(t) | SanitizedChunkKind::Thinking(t) => {
824                    assert!(
825                        !t.contains("sk-live-topsecretvalue123"),
826                        "forwarded content must not contain the raw secret: {t}"
827                    );
828                }
829                SanitizedChunkKind::Terminal(_) => {}
830            }
831        }
832    }
833
834    #[tokio::test(start_paused = true)]
835    async fn registered_secret_that_also_matches_a_shape_gets_typed_placeholder_not_generic() {
836        // A value that is BOTH registered with the SecretMaskRegistry AND shape-matched by
837        // `scrub_secret_shapes` (e.g. any `sk-...` value, since `SecretMaskRegistry::register`
838        // is commonly used for real API keys) must come out through the pipeline with the
839        // registry's typed `<SECRET:category:...>` placeholder, not the shape scrub's generic
840        // `[REDACTED]` marker — proving registry masking really does run before the shape scrub
841        // (see the ordering comment on `sanitize_text`) and the shape scrub does not re-process
842        // (double-mask) the registry's own placeholder output.
843        use zeph_sanitizer::secret_mask::{SecretCategory, SecretMaskRegistry};
844
845        let registry = Arc::new(SecretMaskRegistry::new());
846        registry.register(
847            "MY_KEY",
848            "sk-live-topsecretvalue123",
849            SecretCategory::ApiKey,
850        );
851
852        let task_id: Arc<str> = Arc::from("task-secret-typed");
853        let def_name: Arc<str> = Arc::from("agent-secret-typed");
854        let (sender, rx) = new_channel(Arc::clone(&task_id), Arc::clone(&def_name));
855        let buffer = new_buffer();
856
857        sender.send_text("the key is sk-live-topsecretvalue123, use it wisely");
858        sender.send_terminal(SubAgentState::Completed);
859        drop(sender);
860
861        let seen = Arc::new(std::sync::Mutex::new(Vec::<SanitizedChunk>::new()));
862        let collected = Arc::clone(&seen);
863        let layers = SanitizeLayers {
864            sanitizer: ContentSanitizer::new(&zeph_config::ContentIsolationConfig::default()),
865            secret_registry: Some(registry),
866            pii_filter: None,
867        };
868        run_forward_drain_with(
869            task_id,
870            def_name,
871            rx,
872            layers,
873            ForwardSurfaces {
874                tui: true,
875                bare: false,
876            },
877            buffer,
878            move |chunk, surfaces, buffer| {
879                collected.lock().unwrap().push(chunk.clone());
880                dispatch_chunk(chunk, surfaces, buffer);
881            },
882        )
883        .await;
884
885        let combined = collect_forwarded_text(&seen.lock().unwrap());
886        assert!(
887            !combined.contains("sk-live-topsecretvalue123"),
888            "raw secret must not survive the pipeline: {combined}"
889        );
890        assert!(
891            combined.contains("<SECRET:api_key:"),
892            "registry masking must run first and produce its typed placeholder: {combined}"
893        );
894        assert!(
895            !combined.contains("[REDACTED]"),
896            "shape scrub must not double-mask the registry's own placeholder output: {combined}"
897        );
898    }
899
900    #[tokio::test(start_paused = true)]
901    async fn generic_secret_shape_masked_without_registration() {
902        // #6571: a subagent that fabricates or echoes an API-key-shaped string in its own
903        // response text must have it masked even though it was never registered with a
904        // SecretMaskRegistry (no vault-loaded secret ever equals this value) — the always-on
905        // shape-based scrub (`scrub_secret_shapes`) is the only layer that can catch this.
906        let task_id: Arc<str> = Arc::from("task-shape");
907        let def_name: Arc<str> = Arc::from("agent-shape");
908        let (sender, rx) = new_channel(Arc::clone(&task_id), Arc::clone(&def_name));
909        let buffer = new_buffer();
910
911        sender.send_text("here is a key: sk-test-abc123def456, use it wisely");
912        sender.send_terminal(SubAgentState::Completed);
913        drop(sender);
914
915        let seen = Arc::new(std::sync::Mutex::new(Vec::<SanitizedChunk>::new()));
916        let collected = Arc::clone(&seen);
917        run_forward_drain_with(
918            task_id,
919            def_name,
920            rx,
921            layers(),
922            ForwardSurfaces {
923                tui: true,
924                bare: false,
925            },
926            buffer,
927            move |chunk, surfaces, buffer| {
928                collected.lock().unwrap().push(chunk.clone());
929                dispatch_chunk(chunk, surfaces, buffer);
930            },
931        )
932        .await;
933
934        let combined = collect_forwarded_text(&seen.lock().unwrap());
935        assert!(
936            !combined.contains("sk-test-abc123def456"),
937            "generic secret-shaped string must be masked without prior registration: {combined}"
938        );
939        assert!(
940            combined.contains("[REDACTED]"),
941            "masked placeholder must be present in the combined forwarded text: {combined}"
942        );
943    }
944
945    #[tokio::test(start_paused = true)]
946    async fn pii_filter_scrubs_forwarded_email() {
947        // NFR-005 / security Finding 1: forwarded content containing PII-shaped text must be
948        // scrubbed when a PiiFilter layer is configured.
949        let task_id: Arc<str> = Arc::from("task-pii");
950        let def_name: Arc<str> = Arc::from("agent-pii");
951        let (sender, rx) = new_channel(Arc::clone(&task_id), Arc::clone(&def_name));
952        let buffer = new_buffer();
953
954        sender.send_text("contact me at victim@example.com for details");
955        sender.send_terminal(SubAgentState::Completed);
956        drop(sender);
957
958        let seen = Arc::new(std::sync::Mutex::new(Vec::<SanitizedChunk>::new()));
959        let collected = Arc::clone(&seen);
960        let layers = SanitizeLayers {
961            sanitizer: ContentSanitizer::new(&zeph_config::ContentIsolationConfig::default()),
962            secret_registry: None,
963            pii_filter: Some(PiiFilter::new(PiiFilterConfig::default())),
964        };
965        run_forward_drain_with(
966            task_id,
967            def_name,
968            rx,
969            layers,
970            ForwardSurfaces {
971                tui: true,
972                bare: false,
973            },
974            buffer,
975            move |chunk, surfaces, buffer| {
976                collected.lock().unwrap().push(chunk.clone());
977                dispatch_chunk(chunk, surfaces, buffer);
978            },
979        )
980        .await;
981
982        let chunks = seen.lock().unwrap();
983        let text_chunk = chunks
984            .iter()
985            .find(|c| matches!(c.kind, SanitizedChunkKind::Text(_)))
986            .expect("one text chunk must have been dispatched");
987        let SanitizedChunkKind::Text(ref t) = text_chunk.kind else {
988            unreachable!()
989        };
990        assert!(
991            !t.contains("victim@example.com"),
992            "forwarded content must not contain the raw email address: {t}"
993        );
994    }
995
996    // --- Review Critical Issue #2: cross-delta secret/PII masking gap ---
997
998    fn collect_forwarded_text(chunks: &[SanitizedChunk]) -> String {
999        chunks
1000            .iter()
1001            .filter_map(|c| match &c.kind {
1002                SanitizedChunkKind::Text(t) => Some(t.as_str()),
1003                _ => None,
1004            })
1005            .collect()
1006    }
1007
1008    #[tokio::test(start_paused = true)]
1009    async fn secret_split_across_two_deltas_is_still_masked() {
1010        // A secret whose bytes are split across two separate `send_text` calls — simulating
1011        // two ToolSseEvent::ContentChunk deltas arriving back-to-back during FR-002b
1012        // streaming — must still be masked once both fragments have been buffered. Neither
1013        // fragment alone contains the full registered secret value, so per-delta-isolated
1014        // sanitization (the pre-fix behavior) would have let it straight through.
1015        use zeph_sanitizer::secret_mask::{SecretCategory, SecretMaskRegistry};
1016
1017        let secret_value = "sk-live-topsecretvalue123456789";
1018        let registry = Arc::new(SecretMaskRegistry::new());
1019        registry.register("MY_KEY", secret_value, SecretCategory::ApiKey);
1020        let (first_half, second_half) = secret_value.split_at(secret_value.len() / 2);
1021
1022        let task_id: Arc<str> = Arc::from("task-split");
1023        let def_name: Arc<str> = Arc::from("agent-split");
1024        let (sender, rx) = new_channel(Arc::clone(&task_id), Arc::clone(&def_name));
1025        let buffer = new_buffer();
1026
1027        sender.send_text(&format!("the key is {first_half}"));
1028        sender.send_text(&format!("{second_half}, use it wisely"));
1029        sender.send_terminal(SubAgentState::Completed);
1030        drop(sender);
1031
1032        let seen = Arc::new(std::sync::Mutex::new(Vec::<SanitizedChunk>::new()));
1033        let collected = Arc::clone(&seen);
1034        let layers = SanitizeLayers {
1035            sanitizer: ContentSanitizer::new(&zeph_config::ContentIsolationConfig::default()),
1036            secret_registry: Some(registry),
1037            pii_filter: None,
1038        };
1039        run_forward_drain_with(
1040            task_id,
1041            def_name,
1042            rx,
1043            layers,
1044            ForwardSurfaces {
1045                tui: true,
1046                bare: false,
1047            },
1048            buffer,
1049            move |chunk, surfaces, buffer| {
1050                collected.lock().unwrap().push(chunk.clone());
1051                dispatch_chunk(chunk, surfaces, buffer);
1052            },
1053        )
1054        .await;
1055
1056        let combined = collect_forwarded_text(&seen.lock().unwrap());
1057        assert!(
1058            !combined.contains(secret_value),
1059            "secret split across two forwarded deltas must still be masked: {combined}"
1060        );
1061        assert!(
1062            combined.contains("<SECRET:api_key:"),
1063            "masked placeholder must be present in the combined forwarded text: {combined}"
1064        );
1065    }
1066
1067    #[tokio::test(start_paused = true)]
1068    async fn email_split_across_two_deltas_is_still_scrubbed() {
1069        // Same cross-delta gap, PII side: an email address split across two `send_text`
1070        // calls must still be scrubbed once both fragments are buffered together.
1071        let email = "victim@example.com";
1072        let (first_half, second_half) = email.split_at(email.len() / 2);
1073
1074        let task_id: Arc<str> = Arc::from("task-split-pii");
1075        let def_name: Arc<str> = Arc::from("agent-split-pii");
1076        let (sender, rx) = new_channel(Arc::clone(&task_id), Arc::clone(&def_name));
1077        let buffer = new_buffer();
1078
1079        sender.send_text(&format!("contact me at {first_half}"));
1080        sender.send_text(&format!("{second_half} for details"));
1081        sender.send_terminal(SubAgentState::Completed);
1082        drop(sender);
1083
1084        let seen = Arc::new(std::sync::Mutex::new(Vec::<SanitizedChunk>::new()));
1085        let collected = Arc::clone(&seen);
1086        let layers = SanitizeLayers {
1087            sanitizer: ContentSanitizer::new(&zeph_config::ContentIsolationConfig::default()),
1088            secret_registry: None,
1089            pii_filter: Some(PiiFilter::new(PiiFilterConfig::default())),
1090        };
1091        run_forward_drain_with(
1092            task_id,
1093            def_name,
1094            rx,
1095            layers,
1096            ForwardSurfaces {
1097                tui: true,
1098                bare: false,
1099            },
1100            buffer,
1101            move |chunk, surfaces, buffer| {
1102                collected.lock().unwrap().push(chunk.clone());
1103                dispatch_chunk(chunk, surfaces, buffer);
1104            },
1105        )
1106        .await;
1107
1108        let combined = collect_forwarded_text(&seen.lock().unwrap());
1109        assert!(
1110            !combined.contains(email),
1111            "email split across two forwarded deltas must still be scrubbed: {combined}"
1112        );
1113    }
1114
1115    #[tokio::test(start_paused = true)]
1116    async fn secret_split_across_progressive_flush_boundary_is_still_masked() {
1117        // Stronger test of the holdback *window* itself (not just "buffer until terminal"):
1118        // enough filler precedes the secret's two fragments to force at least one
1119        // progressive flush mid-stream (SANITIZE_HOLDBACK_BYTES is well under the total
1120        // filler size), proving flushing genuinely happens before the terminal event, yet
1121        // the secret's fragments — arriving back-to-back right after the filler — must still
1122        // land inside the held-back tail and be masked as one contiguous string once
1123        // fully buffered.
1124        use zeph_sanitizer::secret_mask::{SecretCategory, SecretMaskRegistry};
1125
1126        let secret_value = "sk-live-anothersecretvalue987654321";
1127        let registry = Arc::new(SecretMaskRegistry::new());
1128        registry.register("MY_KEY", secret_value, SecretCategory::ApiKey);
1129        let (first_half, second_half) = secret_value.split_at(secret_value.len() / 2);
1130
1131        let task_id: Arc<str> = Arc::from("task-window");
1132        let def_name: Arc<str> = Arc::from("agent-window");
1133        let (sender, rx) = new_channel(Arc::clone(&task_id), Arc::clone(&def_name));
1134        let buffer = new_buffer();
1135
1136        for i in 0..40 {
1137            sender.send_text(&format!("filler-chunk-{i:03} "));
1138        }
1139        sender.send_text(first_half);
1140        sender.send_text(second_half);
1141        sender.send_terminal(SubAgentState::Completed);
1142        drop(sender);
1143
1144        let seen = Arc::new(std::sync::Mutex::new(Vec::<SanitizedChunk>::new()));
1145        let collected = Arc::clone(&seen);
1146        let layers = SanitizeLayers {
1147            sanitizer: ContentSanitizer::new(&zeph_config::ContentIsolationConfig::default()),
1148            secret_registry: Some(registry),
1149            pii_filter: None,
1150        };
1151        run_forward_drain_with(
1152            task_id,
1153            def_name,
1154            rx,
1155            layers,
1156            ForwardSurfaces {
1157                tui: true,
1158                bare: false,
1159            },
1160            buffer,
1161            move |chunk, surfaces, buffer| {
1162                collected.lock().unwrap().push(chunk.clone());
1163                dispatch_chunk(chunk, surfaces, buffer);
1164            },
1165        )
1166        .await;
1167
1168        let seen = seen.lock().unwrap();
1169        let text_chunk_count = seen
1170            .iter()
1171            .filter(|c| matches!(c.kind, SanitizedChunkKind::Text(_)))
1172            .count();
1173        assert!(
1174            text_chunk_count > 1,
1175            "filler well over the holdback window must have produced at least one \
1176             progressive flush before the terminal-triggered final flush, got \
1177             {text_chunk_count} text chunk(s)"
1178        );
1179        let combined = collect_forwarded_text(&seen);
1180        assert!(
1181            !combined.contains(secret_value),
1182            "secret split across the streaming boundary must still be masked: {combined}"
1183        );
1184    }
1185
1186    #[tokio::test(start_paused = true)]
1187    async fn buffer_entry_survives_during_grace_window_then_evicted() {
1188        // S3: the grace window's entire purpose is that a TUI view opened just after
1189        // completion still sees the transcript — verify the mid-window state directly with
1190        // controlled virtual-time stepping, not just the post-eviction end state.
1191        let task_id: Arc<str> = Arc::from("task-grace");
1192        let def_name: Arc<str> = Arc::from("agent-grace");
1193        let (sender, rx) = new_channel(Arc::clone(&task_id), Arc::clone(&def_name));
1194        let buffer = new_buffer();
1195
1196        sender.send_text("visible during the grace window");
1197        sender.send_terminal(SubAgentState::Completed);
1198        drop(sender);
1199
1200        let drain_buffer = Arc::clone(&buffer);
1201        let drain_task_id = Arc::clone(&task_id);
1202        let handle = tokio::spawn(run_forward_drain(
1203            drain_task_id,
1204            def_name,
1205            rx,
1206            layers(),
1207            ForwardSurfaces {
1208                tui: true,
1209                bare: false,
1210            },
1211            drain_buffer,
1212        ));
1213
1214        // Let the drain process both chunks and enter its grace-window sleep.
1215        tokio::time::advance(Duration::from_millis(1)).await;
1216        tokio::task::yield_now().await;
1217
1218        let mid_window_tail = forwarded_tail(&buffer, &task_id, 10);
1219        assert_eq!(
1220            mid_window_tail.len(),
1221            1,
1222            "exactly one forwarded line expected"
1223        );
1224        assert!(
1225            mid_window_tail[0].contains("visible during the grace window"),
1226            "the transcript must still be visible during the grace window, got: {:?}",
1227            mid_window_tail[0]
1228        );
1229
1230        tokio::time::advance(FORWARD_BUFFER_GRACE + Duration::from_millis(1)).await;
1231        handle.await.expect("drain task must not panic");
1232
1233        let post_eviction_tail = forwarded_tail(&buffer, &task_id, 10);
1234        assert!(
1235            post_eviction_tail.is_empty(),
1236            "buffer entry must be evicted once the grace window elapses"
1237        );
1238    }
1239
1240    #[test]
1241    fn empty_text_is_not_sent() {
1242        let task_id: Arc<str> = Arc::from("task-4");
1243        let def_name: Arc<str> = Arc::from("agent-4");
1244        let (sender, mut rx) = new_channel(task_id, def_name);
1245        sender.send_text("");
1246        sender.send_thinking("");
1247        drop(sender);
1248        assert!(
1249            rx.try_recv().is_err(),
1250            "empty text/thinking must not be sent onto the ingress channel"
1251        );
1252    }
1253
1254    #[test]
1255    fn channel_full_increments_drop_counter_and_does_not_panic() {
1256        let task_id: Arc<str> = Arc::from("task-5");
1257        let def_name: Arc<str> = Arc::from("agent-5");
1258        let (sender, mut rx) = new_channel(task_id, def_name);
1259        for i in 0..FORWARD_CHANNEL_CAPACITY + 10 {
1260            sender.send_text(&format!("chunk {i}"));
1261        }
1262        // Drain a few to prove the channel still functions after overflow.
1263        let mut received = 0;
1264        while rx.try_recv().is_ok() {
1265            received += 1;
1266        }
1267        assert!(
1268            received > 0,
1269            "at least some chunks must have been delivered"
1270        );
1271        assert!(
1272            received <= FORWARD_CHANNEL_CAPACITY,
1273            "received must never exceed channel capacity"
1274        );
1275    }
1276
1277    #[test]
1278    fn forward_surfaces_any() {
1279        assert!(!ForwardSurfaces::default().any());
1280        assert!(
1281            ForwardSurfaces {
1282                tui: true,
1283                bare: false
1284            }
1285            .any()
1286        );
1287        assert!(
1288            ForwardSurfaces {
1289                tui: false,
1290                bare: true
1291            }
1292            .any()
1293        );
1294    }
1295}