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//!
6//! Opt-in, per-turn forwarding of a running subagent's full text/thinking output to the
7//! TUI runtime detail view and/or a `--bare` stdout sink. Pipeline shape:
8//!
9//! ```text
10//! agent_loop.rs (sync, non-blocking) --try_send(RawChunk)--> per-task mpsc (cap 128)
11//!     -> manager-owned per-task drain: sanitize (the ONE sanitize point) -> dispatch to sinks
12//! ```
13//!
14//! `RawChunk` only ever travels on the ingress channel; `SanitizedChunk` is constructed
15//! exclusively by the drain's sanitize step and is the only type any sink can receive
16//! (NFR-005 enforced structurally, not by convention).
17
18use std::collections::{HashMap, VecDeque};
19use std::sync::Arc;
20use std::sync::atomic::{AtomicU64, Ordering};
21use std::time::Duration;
22
23use tokio::sync::mpsc;
24use zeph_sanitizer::pii::PiiFilter;
25use zeph_sanitizer::secret_mask::SecretMaskRegistry;
26use zeph_sanitizer::{ContentSanitizer, ContentSource, ContentSourceKind};
27
28use crate::state::SubAgentState;
29
30/// Bound on the per-task ingress channel (mpsc). `try_send` drops the newest chunk on
31/// full (tail-drop) rather than blocking the subagent's own turn loop (NFR-001).
32const FORWARD_CHANNEL_CAPACITY: usize = 128;
33
34/// Maximum number of sanitized display lines retained per task in the TUI ring buffer.
35const FORWARD_RING_CAPACITY: usize = 200;
36
37/// How long a finished task's ring buffer entry survives after its terminal chunk, so a
38/// TUI detail view opened just after completion still shows the final transcript.
39const FORWARD_BUFFER_GRACE: Duration = Duration::from_secs(5);
40
41/// Which consumer surfaces are active for this session, fixed at session start (session
42/// scope, not hot-swappable — a headless run does not gain a TUI mid-session).
43///
44/// Set once via [`crate::SubAgentManager::set_forward_surfaces`] during bootstrap. When both
45/// fields are `false`, no forwarding sender or drain is ever constructed for any subagent,
46/// regardless of `forward_transcript` config (FR-007).
47#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
48pub struct ForwardSurfaces {
49    /// A TUI session is active — sanitized chunks are appended to the per-task ring buffer.
50    pub tui: bool,
51    /// `--bare` mode is active — sanitized chunks are written as JSON lines to stdout.
52    pub bare: bool,
53}
54
55impl ForwardSurfaces {
56    /// Returns `true` when at least one consumer surface is active.
57    #[must_use]
58    pub fn any(self) -> bool {
59        self.tui || self.bare
60    }
61}
62
63/// One incremental piece of a subagent's forwarded output, pre-sanitize.
64///
65/// Only ever travels on the per-task ingress `mpsc` — never exposed outside this module.
66#[derive(Debug, Clone)]
67pub(crate) struct RawChunk {
68    task_id: Arc<str>,
69    def_name: Arc<str>,
70    seq: u64,
71    kind: ForwardChunkKind,
72}
73
74/// The content carried by a forwarded chunk. `pub(crate)`: only ever constructed by
75/// `ForwardSender`'s `send_*` methods, never named outside this crate.
76#[derive(Debug, Clone)]
77#[non_exhaustive]
78pub(crate) enum ForwardChunkKind {
79    /// Full, untruncated text produced by one completed LLM turn (FR-002a).
80    Text(String),
81    /// Full, untruncated visible reasoning text from one thinking block.
82    Thinking(String),
83    /// End-of-transcript signal (FR-008): either the loop's own terminal status, or a
84    /// synthesized backstop when the ingress channel closed without one (hard abort).
85    Terminal(SubAgentState),
86}
87
88/// A forwarded chunk after passing through the drain's single sanitize stage.
89///
90/// Constructed only by the drain's internal sanitize step — the sole type any sink (TUI
91/// ring, `--bare` stdout, a future network sink) can receive, so a sink author cannot
92/// physically emit unsanitized content (NFR-005). `pub(crate)` (not `pub`, security review
93/// Finding 2): nothing outside this crate needs this type — `SubAgentManager::forwarded_tail`
94/// exposes already-rendered `String` lines instead — so it is not part of the public API
95/// surface a future sink integration could hand-construct from.
96#[derive(Debug, Clone)]
97pub(crate) struct SanitizedChunk {
98    /// Task ID of the originating subagent.
99    pub(crate) task_id: Arc<str>,
100    /// Subagent definition name.
101    pub(crate) def_name: Arc<str>,
102    /// Monotonic per-task sequence number (FR-003).
103    pub(crate) seq: u64,
104    /// The sanitized content.
105    pub(crate) kind: SanitizedChunkKind,
106}
107
108/// Sanitized variant of [`ForwardChunkKind`].
109#[derive(Debug, Clone)]
110#[non_exhaustive]
111pub(crate) enum SanitizedChunkKind {
112    /// Sanitized text output.
113    Text(String),
114    /// Sanitized thinking output.
115    Thinking(String),
116    /// End-of-transcript signal, carried through unchanged (no text to sanitize).
117    Terminal(SubAgentState),
118}
119
120/// The full sanitization pipeline applied at the drain's single sanitize point (NFR-005).
121///
122/// Bundles the baseline injection/truncation pass (`ContentSanitizer`, always present) with
123/// two optional hardening layers that mirror the ones already guarding the analogous
124/// sub-agent-output *egress* path (debug dumps, see `PiiScrubbingDumpSink` / #6407 and
125/// `apply_secret_masking` / #5437): a [`SecretMaskRegistry`] that replaces known vault
126/// secrets with opaque placeholders, and a [`PiiFilter`] that scrubs emails/phones/SSNs/etc.
127/// Both are `None` unless explicitly wired via `SubAgentManager::set_secret_registry` /
128/// `set_pii_filter` — forwarding remains fully functional (baseline sanitization only) when
129/// neither is configured, matching this crate's existing opt-in-hardening conventions.
130pub(crate) struct SanitizeLayers {
131    pub(crate) sanitizer: ContentSanitizer,
132    pub(crate) secret_registry: Option<Arc<SecretMaskRegistry>>,
133    pub(crate) pii_filter: Option<PiiFilter>,
134}
135
136fn sanitize_text(raw_text: &str, def_name: &str, layers: &SanitizeLayers) -> String {
137    let source = ContentSource::new(ContentSourceKind::ToolResult).with_identifier(def_name);
138    let mut body = layers.sanitizer.sanitize(raw_text, source).body;
139    if let Some(registry) = &layers.secret_registry {
140        body = registry.mask(&body);
141    }
142    if let Some(filter) = &layers.pii_filter {
143        body = filter.scrub(&body).into_owned();
144    }
145    body
146}
147
148fn sanitize_chunk(raw: RawChunk, layers: &SanitizeLayers) -> SanitizedChunk {
149    let kind = match raw.kind {
150        ForwardChunkKind::Text(text) => {
151            SanitizedChunkKind::Text(sanitize_text(&text, raw.def_name.as_ref(), layers))
152        }
153        ForwardChunkKind::Thinking(text) => {
154            SanitizedChunkKind::Thinking(sanitize_text(&text, raw.def_name.as_ref(), layers))
155        }
156        ForwardChunkKind::Terminal(state) => SanitizedChunkKind::Terminal(state),
157    };
158    SanitizedChunk {
159        task_id: raw.task_id,
160        def_name: raw.def_name,
161        seq: raw.seq,
162        kind,
163    }
164}
165
166/// Sender-side handle held by a single subagent's own turn loop for the lifetime of its
167/// run only.
168///
169/// Deliberately **not** `Clone`: the drain's hard-abort backstop (see [`run_forward_drain`])
170/// relies on this being the sole `mpsc::Sender` for its task — dropping the loop's future
171/// must be the only way the channel closes. Do not store this (or its inner `Sender`) in
172/// any struct that outlives a single subagent run (`SpawnContext`, a resume/retry retainer,
173/// etc.) — see P-new-3 in the implementation handoff.
174pub(crate) struct ForwardSender {
175    tx: mpsc::Sender<RawChunk>,
176    task_id: Arc<str>,
177    def_name: Arc<str>,
178    seq: AtomicU64,
179    dropped: AtomicU64,
180}
181
182impl ForwardSender {
183    pub(crate) fn new(tx: mpsc::Sender<RawChunk>, task_id: Arc<str>, def_name: Arc<str>) -> Self {
184        Self {
185            tx,
186            task_id,
187            def_name,
188            seq: AtomicU64::new(0),
189            dropped: AtomicU64::new(0),
190        }
191    }
192
193    fn try_send(&self, kind: ForwardChunkKind) {
194        let seq = self.seq.fetch_add(1, Ordering::Relaxed);
195        let chunk = RawChunk {
196            task_id: Arc::clone(&self.task_id),
197            def_name: Arc::clone(&self.def_name),
198            seq,
199            kind,
200        };
201        if self.tx.try_send(chunk).is_ok() {
202            tracing::debug!(
203                task_id = %self.task_id,
204                seq,
205                "subagent.forward.emit"
206            );
207        } else {
208            let dropped = self.dropped.fetch_add(1, Ordering::Relaxed) + 1;
209            tracing::warn!(
210                task_id = %self.task_id,
211                seq,
212                dropped,
213                "subagent.forward.drop: ingress channel full, chunk dropped"
214            );
215        }
216    }
217
218    /// Forward one turn's full, untruncated text output. Call only from behind an
219    /// `if let Some(f) = forward` guard — the caller (`agent_loop.rs`) must never construct
220    /// or clone the text ahead of that guard (FR-007).
221    pub(crate) fn send_text(&self, text: &str) {
222        if text.is_empty() {
223            return;
224        }
225        self.try_send(ForwardChunkKind::Text(text.to_owned()));
226    }
227
228    /// Forward one visible thinking block's text. Same no-op-behind-`Some` contract as
229    /// [`send_text`][Self::send_text].
230    pub(crate) fn send_thinking(&self, text: &str) {
231        if text.is_empty() {
232            return;
233        }
234        self.try_send(ForwardChunkKind::Thinking(text.to_owned()));
235    }
236
237    /// Emit the terminal (end-of-transcript) chunk. Co-located with every site that
238    /// publishes a terminal `SubAgentStatus` on the status channel (FR-008).
239    pub(crate) fn send_terminal(&self, state: SubAgentState) {
240        tracing::debug!(task_id = %self.task_id, ?state, "subagent.forward.terminal");
241        self.try_send(ForwardChunkKind::Terminal(state));
242    }
243}
244
245pub(crate) type ForwardBuffer = std::sync::Mutex<HashMap<String, VecDeque<String>>>;
246
247/// Render a sanitized chunk as a single display line for the TUI ring buffer, or `None`
248/// for chunks that carry no display text (terminal events).
249fn display_line(kind: &SanitizedChunkKind) -> Option<String> {
250    match kind {
251        SanitizedChunkKind::Text(t) => Some(t.clone()),
252        SanitizedChunkKind::Thinking(t) => Some(format!("[thinking] {t}")),
253        SanitizedChunkKind::Terminal(_) => None,
254    }
255}
256
257fn state_str(state: SubAgentState) -> &'static str {
258    match state {
259        SubAgentState::Submitted => "submitted",
260        SubAgentState::Working => "working",
261        SubAgentState::Completed => "completed",
262        SubAgentState::Failed => "failed",
263        SubAgentState::Canceled => "canceled",
264    }
265}
266
267/// Write one `--bare` stdout event as a single JSON line (M6: one `println!` per chunk,
268/// never multi-write — `println!` takes Rust's internal stdout lock per call, so this is
269/// line-atomic even when interleaved with the main output path).
270fn emit_bare_line(chunk: &SanitizedChunk) {
271    #[derive(serde::Serialize)]
272    struct BareForwardEvent<'a> {
273        task_id: &'a str,
274        def_name: &'a str,
275        seq: u64,
276        kind: &'static str,
277        #[serde(skip_serializing_if = "Option::is_none")]
278        content: Option<&'a str>,
279        #[serde(skip_serializing_if = "Option::is_none")]
280        state: Option<&'static str>,
281    }
282
283    let (kind, content, state) = match &chunk.kind {
284        SanitizedChunkKind::Text(t) => ("text", Some(t.as_str()), None),
285        SanitizedChunkKind::Thinking(t) => ("thinking", Some(t.as_str()), None),
286        SanitizedChunkKind::Terminal(s) => ("terminal", None, Some(state_str(*s))),
287    };
288    let event = BareForwardEvent {
289        task_id: &chunk.task_id,
290        def_name: &chunk.def_name,
291        seq: chunk.seq,
292        kind,
293        content,
294        state,
295    };
296    if let Ok(line) = serde_json::to_string(&event) {
297        println!("{line}");
298    }
299}
300
301/// Dispatch one sanitized chunk to every active surface.
302fn dispatch_chunk(chunk: &SanitizedChunk, surfaces: ForwardSurfaces, buffer: &ForwardBuffer) {
303    if surfaces.tui
304        && let Some(line) = display_line(&chunk.kind)
305    {
306        let mut guard = buffer
307            .lock()
308            .unwrap_or_else(std::sync::PoisonError::into_inner);
309        let ring = guard.entry(chunk.task_id.to_string()).or_default();
310        ring.push_back(line);
311        while ring.len() > FORWARD_RING_CAPACITY {
312            ring.pop_front();
313        }
314    }
315    if surfaces.bare {
316        emit_bare_line(chunk);
317    }
318}
319
320/// Build a fresh `mpsc` ingress pair and its sender-side handle for one subagent run.
321pub(crate) fn new_channel(
322    task_id: Arc<str>,
323    def_name: Arc<str>,
324) -> (ForwardSender, mpsc::Receiver<RawChunk>) {
325    let (tx, rx) = mpsc::channel(FORWARD_CHANNEL_CAPACITY);
326    (ForwardSender::new(tx, task_id, def_name), rx)
327}
328
329/// Manager-owned per-task drain: the single sanitize stage plus sink dispatch, running for
330/// the lifetime of one subagent's forwarding channel.
331///
332/// # Terminal detection (critic C-new-1, must-fix)
333///
334/// The loop breaks immediately after dispatching **any** explicit terminal chunk (sent by
335/// `agent_loop.rs` at each of its three terminal-status sites). This is the only way to
336/// avoid double-emitting a terminal on the happy path: on normal completion the loop sends
337/// an explicit `Terminal` and then drops its `Sender`; because the `Some(raw)` arm below
338/// breaks unconditionally on a terminal chunk, `recv()` is never called again afterward, so
339/// the `None` arm can never fire once an explicit terminal has already been handled.
340/// Consequently, reaching the `None` arm at all — the channel closed with no message
341/// pending — is *only* possible when no explicit terminal was ever sent, i.e. the genuine
342/// hard-abort backstop (`JoinHandle::abort()` / cancel-token firing mid-`.await` drops the
343/// loop's future, and with it its sole `Sender`, before any terminal-status site runs): it
344/// unconditionally synthesizes `Terminal(Canceled)`.
345///
346/// After the loop ends, the task's ring buffer entry is evicted following a short grace
347/// window so a TUI detail view opened just after completion still shows the final
348/// transcript (S3: bounds `forward_buffer` growth across a long multi-subagent session).
349pub(crate) async fn run_forward_drain(
350    task_id: Arc<str>,
351    def_name: Arc<str>,
352    rx: mpsc::Receiver<RawChunk>,
353    layers: SanitizeLayers,
354    surfaces: ForwardSurfaces,
355    buffer: Arc<ForwardBuffer>,
356) {
357    run_forward_drain_with(
358        task_id,
359        def_name,
360        rx,
361        layers,
362        surfaces,
363        buffer,
364        dispatch_chunk,
365    )
366    .await;
367}
368
369/// Same as [`run_forward_drain`], parameterized over the dispatch step so tests can observe
370/// exactly how many (and which) [`SanitizedChunk`]s the drain hands to the sinks — including
371/// `Terminal` chunks, which [`dispatch_chunk`] itself never writes to the TUI ring buffer
372/// (`display_line` returns `None` for them) and which the eviction sweep runs unconditionally
373/// after either loop exit, so buffer *contents* alone cannot distinguish "exactly one terminal
374/// dispatched" from "two". Production always calls this via [`run_forward_drain`] with
375/// [`dispatch_chunk`] itself as the dispatch step — behavior is unchanged.
376async fn run_forward_drain_with(
377    task_id: Arc<str>,
378    def_name: Arc<str>,
379    mut rx: mpsc::Receiver<RawChunk>,
380    layers: SanitizeLayers,
381    surfaces: ForwardSurfaces,
382    buffer: Arc<ForwardBuffer>,
383    mut dispatch: impl FnMut(&SanitizedChunk, ForwardSurfaces, &ForwardBuffer),
384) {
385    let mut next_seq: u64 = 0;
386
387    loop {
388        if let Some(raw) = rx.recv().await {
389            next_seq = raw.seq + 1;
390            let is_terminal = matches!(raw.kind, ForwardChunkKind::Terminal(_));
391            let chunk = sanitize_chunk(raw, &layers);
392            dispatch(&chunk, surfaces, &buffer);
393            if is_terminal {
394                break;
395            }
396        } else {
397            tracing::warn!(
398                task_id = %task_id,
399                "subagent.forward.terminal: ingress channel closed without an explicit \
400                 terminal chunk — synthesizing hard-abort backstop"
401            );
402            let synthesized = SanitizedChunk {
403                task_id: Arc::clone(&task_id),
404                def_name: Arc::clone(&def_name),
405                seq: next_seq,
406                kind: SanitizedChunkKind::Terminal(SubAgentState::Canceled),
407            };
408            dispatch(&synthesized, surfaces, &buffer);
409            break;
410        }
411    }
412
413    tokio::time::sleep(FORWARD_BUFFER_GRACE).await;
414    buffer
415        .lock()
416        .unwrap_or_else(std::sync::PoisonError::into_inner)
417        .remove(task_id.as_ref());
418}
419
420/// Read the current ring-buffer tail for `task_id` (up to the last `n` lines).
421///
422/// Returns an empty vector for a task with no forwarded lines yet (or forwarding inactive).
423pub(crate) fn forwarded_tail(buffer: &ForwardBuffer, task_id: &str, n: usize) -> Vec<String> {
424    let guard = buffer
425        .lock()
426        .unwrap_or_else(std::sync::PoisonError::into_inner);
427    guard.get(task_id).map_or_else(Vec::new, |ring| {
428        ring.iter().rev().take(n).rev().cloned().collect()
429    })
430}
431
432/// Construct a fresh, empty forwarding ring buffer.
433pub(crate) fn new_buffer() -> Arc<ForwardBuffer> {
434    Arc::new(std::sync::Mutex::new(HashMap::new()))
435}
436
437#[cfg(test)]
438mod tests {
439    use std::sync::atomic::AtomicUsize;
440
441    use zeph_config::sanitizer::PiiFilterConfig;
442
443    use super::*;
444
445    fn layers() -> SanitizeLayers {
446        SanitizeLayers {
447            sanitizer: ContentSanitizer::new(&zeph_config::ContentIsolationConfig::default()),
448            secret_registry: None,
449            pii_filter: None,
450        }
451    }
452
453    /// Runs the drain via [`run_forward_drain_with`], counting how many `Terminal` chunks
454    /// were actually handed to the dispatch step — the direct, discriminating observable for
455    /// critic C-new-1 (a regression that re-introduces the double-terminal bug increments this
456    /// to 2; buffer state and hang/panic-absence cannot tell the two implementations apart,
457    /// since `dispatch_chunk` never writes `Terminal` chunks to the ring buffer and the
458    /// post-loop eviction runs exactly once regardless of how many terminals were dispatched
459    /// beforehand).
460    async fn run_and_count_terminals(
461        task_id: Arc<str>,
462        def_name: Arc<str>,
463        rx: mpsc::Receiver<RawChunk>,
464        surfaces: ForwardSurfaces,
465        buffer: Arc<ForwardBuffer>,
466    ) -> usize {
467        let terminal_dispatches = Arc::new(AtomicUsize::new(0));
468        let counter = Arc::clone(&terminal_dispatches);
469        run_forward_drain_with(
470            task_id,
471            def_name,
472            rx,
473            layers(),
474            surfaces,
475            buffer,
476            move |chunk, surfaces, buffer| {
477                if matches!(chunk.kind, SanitizedChunkKind::Terminal(_)) {
478                    counter.fetch_add(1, Ordering::SeqCst);
479                }
480                dispatch_chunk(chunk, surfaces, buffer);
481            },
482        )
483        .await;
484        terminal_dispatches.load(Ordering::SeqCst)
485    }
486
487    #[tokio::test(start_paused = true)]
488    async fn happy_path_emits_no_spurious_second_terminal() {
489        // Regression guard for critic C-new-1: an explicit Terminal followed by Sender drop
490        // must produce exactly one terminal dispatch, not two. Asserts on the actual dispatch
491        // count (see `run_and_count_terminals`), not on buffer state — a Terminal chunk is
492        // never written to the ring buffer, so buffer-only assertions cannot detect this
493        // regression (confirmed by the testing validator).
494        let task_id: Arc<str> = Arc::from("task-1");
495        let def_name: Arc<str> = Arc::from("agent-1");
496        let (sender, rx) = new_channel(Arc::clone(&task_id), Arc::clone(&def_name));
497        let buffer = new_buffer();
498
499        sender.send_text("hello");
500        sender.send_terminal(SubAgentState::Completed);
501        drop(sender);
502
503        let terminal_count = run_and_count_terminals(
504            Arc::clone(&task_id),
505            def_name,
506            rx,
507            ForwardSurfaces {
508                tui: true,
509                bare: false,
510            },
511            Arc::clone(&buffer),
512        )
513        .await;
514
515        assert_eq!(
516            terminal_count, 1,
517            "exactly one terminal chunk must be dispatched — a second would mean the drain \
518             looped back to recv() after the explicit terminal (C-new-1 regression)"
519        );
520        let tail = forwarded_tail(&buffer, &task_id, 10);
521        assert!(
522            tail.is_empty(),
523            "buffer entry must be evicted after grace window"
524        );
525    }
526
527    #[tokio::test(start_paused = true)]
528    async fn hard_abort_without_explicit_terminal_synthesizes_backstop() {
529        let task_id: Arc<str> = Arc::from("task-2");
530        let def_name: Arc<str> = Arc::from("agent-2");
531        let (sender, rx) = new_channel(Arc::clone(&task_id), Arc::clone(&def_name));
532        let buffer = new_buffer();
533
534        sender.send_text("partial output");
535        drop(sender); // simulate abort: no explicit terminal was ever sent
536
537        let terminal_count = run_and_count_terminals(
538            Arc::clone(&task_id),
539            def_name,
540            rx,
541            ForwardSurfaces {
542                tui: true,
543                bare: false,
544            },
545            buffer,
546        )
547        .await;
548
549        assert_eq!(
550            terminal_count, 1,
551            "exactly one synthesized backstop terminal must be dispatched on hard abort"
552        );
553    }
554
555    #[tokio::test(start_paused = true)]
556    async fn zero_consumer_surfaces_still_drains_without_panicking() {
557        let task_id: Arc<str> = Arc::from("task-3");
558        let def_name: Arc<str> = Arc::from("agent-3");
559        let (sender, rx) = new_channel(Arc::clone(&task_id), Arc::clone(&def_name));
560        let buffer = new_buffer();
561
562        sender.send_text("no one is listening");
563        sender.send_terminal(SubAgentState::Completed);
564        drop(sender);
565
566        run_forward_drain(
567            task_id,
568            def_name,
569            rx,
570            layers(),
571            ForwardSurfaces::default(),
572            buffer,
573        )
574        .await;
575    }
576
577    #[tokio::test(start_paused = true)]
578    async fn secret_registry_masks_forwarded_text_and_thinking() {
579        // NFR-005 / security Finding 1: forwarded content containing a registered vault
580        // secret must come out masked, not verbatim.
581        use zeph_sanitizer::secret_mask::{SecretCategory, SecretMaskRegistry};
582
583        let registry = Arc::new(SecretMaskRegistry::new());
584        registry.register(
585            "MY_KEY",
586            "sk-live-topsecretvalue123",
587            SecretCategory::ApiKey,
588        );
589
590        let task_id: Arc<str> = Arc::from("task-secret");
591        let def_name: Arc<str> = Arc::from("agent-secret");
592        let (sender, rx) = new_channel(Arc::clone(&task_id), Arc::clone(&def_name));
593        let buffer = new_buffer();
594
595        sender.send_text("the key is sk-live-topsecretvalue123, use it wisely");
596        sender.send_thinking("I will use sk-live-topsecretvalue123 to authenticate");
597        sender.send_terminal(SubAgentState::Completed);
598        drop(sender);
599
600        let seen = Arc::new(std::sync::Mutex::new(Vec::<SanitizedChunk>::new()));
601        let collected = Arc::clone(&seen);
602        let layers = SanitizeLayers {
603            sanitizer: ContentSanitizer::new(&zeph_config::ContentIsolationConfig::default()),
604            secret_registry: Some(registry),
605            pii_filter: None,
606        };
607        run_forward_drain_with(
608            task_id,
609            def_name,
610            rx,
611            layers,
612            ForwardSurfaces {
613                tui: true,
614                bare: false,
615            },
616            buffer,
617            move |chunk, surfaces, buffer| {
618                collected.lock().unwrap().push(chunk.clone());
619                dispatch_chunk(chunk, surfaces, buffer);
620            },
621        )
622        .await;
623
624        let chunks = seen.lock().unwrap();
625        for chunk in chunks.iter() {
626            match &chunk.kind {
627                SanitizedChunkKind::Text(t) | SanitizedChunkKind::Thinking(t) => {
628                    assert!(
629                        !t.contains("sk-live-topsecretvalue123"),
630                        "forwarded content must not contain the raw secret: {t}"
631                    );
632                }
633                SanitizedChunkKind::Terminal(_) => {}
634            }
635        }
636    }
637
638    #[tokio::test(start_paused = true)]
639    async fn pii_filter_scrubs_forwarded_email() {
640        // NFR-005 / security Finding 1: forwarded content containing PII-shaped text must be
641        // scrubbed when a PiiFilter layer is configured.
642        let task_id: Arc<str> = Arc::from("task-pii");
643        let def_name: Arc<str> = Arc::from("agent-pii");
644        let (sender, rx) = new_channel(Arc::clone(&task_id), Arc::clone(&def_name));
645        let buffer = new_buffer();
646
647        sender.send_text("contact me at victim@example.com for details");
648        sender.send_terminal(SubAgentState::Completed);
649        drop(sender);
650
651        let seen = Arc::new(std::sync::Mutex::new(Vec::<SanitizedChunk>::new()));
652        let collected = Arc::clone(&seen);
653        let layers = SanitizeLayers {
654            sanitizer: ContentSanitizer::new(&zeph_config::ContentIsolationConfig::default()),
655            secret_registry: None,
656            pii_filter: Some(PiiFilter::new(PiiFilterConfig::default())),
657        };
658        run_forward_drain_with(
659            task_id,
660            def_name,
661            rx,
662            layers,
663            ForwardSurfaces {
664                tui: true,
665                bare: false,
666            },
667            buffer,
668            move |chunk, surfaces, buffer| {
669                collected.lock().unwrap().push(chunk.clone());
670                dispatch_chunk(chunk, surfaces, buffer);
671            },
672        )
673        .await;
674
675        let chunks = seen.lock().unwrap();
676        let text_chunk = chunks
677            .iter()
678            .find(|c| matches!(c.kind, SanitizedChunkKind::Text(_)))
679            .expect("one text chunk must have been dispatched");
680        let SanitizedChunkKind::Text(ref t) = text_chunk.kind else {
681            unreachable!()
682        };
683        assert!(
684            !t.contains("victim@example.com"),
685            "forwarded content must not contain the raw email address: {t}"
686        );
687    }
688
689    #[tokio::test(start_paused = true)]
690    async fn buffer_entry_survives_during_grace_window_then_evicted() {
691        // S3: the grace window's entire purpose is that a TUI view opened just after
692        // completion still sees the transcript — verify the mid-window state directly with
693        // controlled virtual-time stepping, not just the post-eviction end state.
694        let task_id: Arc<str> = Arc::from("task-grace");
695        let def_name: Arc<str> = Arc::from("agent-grace");
696        let (sender, rx) = new_channel(Arc::clone(&task_id), Arc::clone(&def_name));
697        let buffer = new_buffer();
698
699        sender.send_text("visible during the grace window");
700        sender.send_terminal(SubAgentState::Completed);
701        drop(sender);
702
703        let drain_buffer = Arc::clone(&buffer);
704        let drain_task_id = Arc::clone(&task_id);
705        let handle = tokio::spawn(run_forward_drain(
706            drain_task_id,
707            def_name,
708            rx,
709            layers(),
710            ForwardSurfaces {
711                tui: true,
712                bare: false,
713            },
714            drain_buffer,
715        ));
716
717        // Let the drain process both chunks and enter its grace-window sleep.
718        tokio::time::advance(Duration::from_millis(1)).await;
719        tokio::task::yield_now().await;
720
721        let mid_window_tail = forwarded_tail(&buffer, &task_id, 10);
722        assert_eq!(
723            mid_window_tail.len(),
724            1,
725            "exactly one forwarded line expected"
726        );
727        assert!(
728            mid_window_tail[0].contains("visible during the grace window"),
729            "the transcript must still be visible during the grace window, got: {:?}",
730            mid_window_tail[0]
731        );
732
733        tokio::time::advance(FORWARD_BUFFER_GRACE + Duration::from_millis(1)).await;
734        handle.await.expect("drain task must not panic");
735
736        let post_eviction_tail = forwarded_tail(&buffer, &task_id, 10);
737        assert!(
738            post_eviction_tail.is_empty(),
739            "buffer entry must be evicted once the grace window elapses"
740        );
741    }
742
743    #[test]
744    fn empty_text_is_not_sent() {
745        let task_id: Arc<str> = Arc::from("task-4");
746        let def_name: Arc<str> = Arc::from("agent-4");
747        let (sender, mut rx) = new_channel(task_id, def_name);
748        sender.send_text("");
749        sender.send_thinking("");
750        drop(sender);
751        assert!(
752            rx.try_recv().is_err(),
753            "empty text/thinking must not be sent onto the ingress channel"
754        );
755    }
756
757    #[test]
758    fn channel_full_increments_drop_counter_and_does_not_panic() {
759        let task_id: Arc<str> = Arc::from("task-5");
760        let def_name: Arc<str> = Arc::from("agent-5");
761        let (sender, mut rx) = new_channel(task_id, def_name);
762        for i in 0..FORWARD_CHANNEL_CAPACITY + 10 {
763            sender.send_text(&format!("chunk {i}"));
764        }
765        // Drain a few to prove the channel still functions after overflow.
766        let mut received = 0;
767        while rx.try_recv().is_ok() {
768            received += 1;
769        }
770        assert!(
771            received > 0,
772            "at least some chunks must have been delivered"
773        );
774        assert!(
775            received <= FORWARD_CHANNEL_CAPACITY,
776            "received must never exceed channel capacity"
777        );
778    }
779
780    #[test]
781    fn forward_surfaces_any() {
782        assert!(!ForwardSurfaces::default().any());
783        assert!(
784            ForwardSurfaces {
785                tui: true,
786                bare: false
787            }
788            .any()
789        );
790        assert!(
791            ForwardSurfaces {
792                tui: false,
793                bare: true
794            }
795            .any()
796        );
797    }
798}