ralph_workflow/json_parser/delta_display/renderer_trait.rs
1// Delta renderer trait definition.
2//
3// Contains the DeltaRenderer trait and compute_append_only_suffix helper.
4//
5// # CCS Spam Prevention Architecture
6//
7// This module implements a three-layer approach to prevent repeated prefixed lines
8// for streaming deltas in non-TTY modes (logs, CI output):
9//
10// ## Layer 1: Suppression at Renderer Level
11//
12// Delta renderers (`TextDeltaRenderer`, `ThinkingDeltaRenderer`) return empty strings
13// in `TerminalMode::Basic` and `TerminalMode::None` for both `render_first_delta` and
14// `render_subsequent_delta`. This prevents per-delta spam at the source.
15//
16// ## Layer 2: Accumulation in StreamingSession
17//
18// `StreamingSession` (in `streaming_state/session`) accumulates all content by
19// (ContentType, index) across deltas. This state is preserved across all delta
20// events for a single message.
21//
22// ## Layer 3: Flush at Completion Boundaries
23//
24// Parser layer (ClaudeParser, CodexParser) flushes accumulated content ONCE at
25// completion boundaries:
26// - ClaudeParser: `handle_message_stop` (in `claude/delta_handling.rs`)
27// - CodexParser: `item.completed` handlers (in `codex/event_handlers/*.rs`)
28//
29// This ensures:
30// - **Full mode (TTY)**: Real-time append-only streaming (no cursor movement)
31// - **Basic/None modes**: One prefixed line per content block, regardless of delta count
32//
33// ## Validation
34//
35// Regression tests validate this architecture:
36// - `ccs_delta_spam_systematic_reproduction.rs`: systematic reproduction (all delta types, both parsers, both modes)
37// - `ccs_all_delta_types_spam_reproduction.rs`: 1000+ deltas per block
38// - `ccs_streaming_spam_all_deltas.rs`: all delta types (text/thinking/tool)
39// - `ccs_nuclear_full_log_regression.rs`: large captured logs (thousands of deltas)
40// - `ccs_streaming_edge_cases.rs`: edge cases (empty deltas, rapid transitions)
41// - `ccs_wrapping_waterfall_reproduction.rs`: wrapping/cursor-up failure reproduction
42// - `ccs_ansi_stripping_console.rs`: ANSI-stripping console behavior
43// - `codex_reasoning_spam_regression.rs`: Codex reasoning regression
44
45/// Renderer for streaming delta content.
46///
47/// This trait defines the contract for rendering streaming deltas consistently
48/// across all parsers using the append-only pattern.
49///
50/// # Append-Only Pattern (Full Mode)
51///
52/// The renderer supports true append-only streaming that works correctly under
53/// terminal line wrapping and in ANSI-stripping environments:
54///
55/// 1. **First delta**: Shows prefix with accumulated content, NO newline
56/// - Example: `[ccs/glm] Hello`
57/// - No cursor movement, content stays on current line
58///
59/// 2. **Subsequent deltas**: Parser computes and emits ONLY new suffix
60/// - Parser responsibility: track last rendered content and emit only delta
61/// - Example: parser emits ` World` (just the new text with color codes)
62/// - NO prefix rewrite, NO `\r` (carriage return), NO cursor movement
63/// - Renderers provide `render_subsequent_delta` for backward compatibility
64/// but parsers implementing append-only should bypass it
65///
66/// 3. **Completion**: Single newline to finalize the line
67/// - Example: `\n`
68/// - Moves cursor to next line after streaming completes
69///
70/// This pattern works correctly even when content wraps to multiple terminal rows
71/// because there is NO cursor movement. The terminal naturally handles wrapping,
72/// and content appears to grow incrementally on the same logical line.
73///
74/// # Why Append-Only?
75///
76/// Previous patterns using `\r` (carriage return) or `\n\x1b[1A` (newline + cursor up)
77/// fail in two scenarios:
78///
79/// 1. **Line wrapping**: When content exceeds terminal width and wraps to multiple rows,
80/// `\r` only returns to column 0 of current row (not start of logical line), and
81/// `\x1b[1A` (cursor up 1 row) + `\x1b[2K` (clear 1 row) cannot erase all wrapped rows
82/// 2. **ANSI-stripping consoles**: Many CI/log environments strip or ignore ANSI sequences,
83/// so `\n` becomes a literal newline causing waterfall spam
84///
85/// Append-only streaming eliminates both issues by never using cursor movement.
86///
87/// # Non-TTY Modes (Basic/None)
88///
89/// Per-delta output is suppressed. Content is flushed ONCE at completion boundaries
90/// by the parser layer to prevent spam in logs and CI output.
91///
92/// # Rendering Rules
93///
94/// - `render_first_delta()`: Called for the first delta of a content block
95/// - Must include prefix
96/// - Must NOT include newline (stays on current line for append-only)
97/// - Shows the accumulated content so far
98///
99/// - `render_subsequent_delta()`: Called for subsequent deltas
100/// - **Parsers implementing append-only MUST compute the suffix and bypass this method**
101/// - Renderer implementations in this repo intentionally return empty strings in all modes
102/// to avoid reintroducing cursor/CR patterns.
103///
104/// - `render_completion()`: Called when streaming completes
105/// - Returns single newline (`\n`) in Full mode to finalize the line
106/// - Returns empty string in Basic/None mode (parser already flushed with newline)
107///
108/// # Terminal Mode Awareness
109///
110/// The renderer automatically adapts output based on terminal capability:
111/// - **Full mode**: Append-only streaming (no cursor movement during deltas)
112/// - **Basic mode**: Per-delta output suppressed; parser flushes once at completion
113/// - **None mode**: Per-delta output suppressed; parser flushes once at completion, plain text
114///
115/// # Example
116///
117/// ```ignore
118/// use crate::json_parser::delta_display::DeltaRenderer;
119/// use crate::logger::Colors;
120/// use crate::json_parser::TerminalMode;
121///
122/// let colors = Colors { enabled: true };
123/// let terminal_mode = TerminalMode::detect();
124///
125/// // First chunk
126/// let output = DeltaRenderer::render_first_delta(
127/// "Hello",
128/// "ccs-glm",
129/// colors,
130/// terminal_mode
131/// );
132///
133/// // Second chunk
134/// let output = DeltaRenderer::render_subsequent_delta(
135/// "Hello World",
136/// "ccs-glm",
137/// colors,
138/// terminal_mode
139/// );
140///
141/// // Complete
142/// let output = DeltaRenderer::render_completion(terminal_mode);
143/// ```
144pub trait DeltaRenderer {
145 /// Render the first delta of a content block.
146 ///
147 /// This is called when streaming begins for a new content block.
148 /// The output should include the prefix and the accumulated content.
149 ///
150 /// # Arguments
151 /// * `accumulated` - The full accumulated content so far
152 /// * `prefix` - The agent prefix (e.g., "ccs-glm")
153 /// * `colors` - Terminal colors
154 /// * `terminal_mode` - The detected terminal capability mode
155 ///
156 /// # Returns
157 /// A formatted string with prefix and content.
158 ///
159 /// In Full mode, this MUST NOT include a trailing newline or any cursor movement.
160 /// (Append-only streaming keeps the cursor on the current line until completion.)
161 ///
162 /// In Basic/None modes, returns an empty string (per-delta output is suppressed; the parser
163 /// flushes the final newline-terminated content at completion boundaries).
164 fn render_first_delta(
165 accumulated: &str,
166 prefix: &str,
167 colors: Colors,
168 terminal_mode: TerminalMode,
169 ) -> String;
170
171 /// Render a subsequent delta (in-place update).
172 ///
173 /// This is called for all deltas after the first. The output should
174 /// clear the entire line and rewrite with the prefix and accumulated content
175 /// in Full mode, or append content in Basic/None mode.
176 ///
177 /// # Arguments
178 /// * `accumulated` - The full accumulated content so far
179 /// * `prefix` - The agent prefix (e.g., "ccs-glm")
180 /// * `colors` - Terminal colors
181 /// * `terminal_mode` - The detected terminal capability mode
182 ///
183 /// # Returns
184 /// A formatted string representing the delta.
185 ///
186 /// In the append-only contract, parsers should NOT call this method in Full mode; they should
187 /// compute the new suffix and emit it directly. The default renderer implementations return
188 /// empty strings to make incorrect usage obvious.
189 ///
190 /// In Basic/None modes, this returns an empty string (per-delta output is suppressed).
191 fn render_subsequent_delta(
192 accumulated: &str,
193 prefix: &str,
194 colors: Colors,
195 terminal_mode: TerminalMode,
196 ) -> String;
197
198 /// Render the completion of streaming.
199 ///
200 /// This is called when streaming completes to finalize the line.
201 /// In Full mode with append-only pattern, this emits a single newline to complete the line.
202 ///
203 /// The streamed content is already visible on the terminal from previous deltas.
204 /// This method simply adds the final newline for proper line termination.
205 ///
206 /// # Arguments
207 /// * `terminal_mode` - The detected terminal capability mode
208 ///
209 /// # Returns
210 /// A string with appropriate completion sequence for the terminal mode.
211 #[must_use]
212 fn render_completion(terminal_mode: TerminalMode) -> String {
213 match terminal_mode {
214 TerminalMode::Full => "\n".to_string(), // Single newline at end for append-only pattern
215 // In non-TTY modes, streamed output is suppressed and the parser flushes
216 // newline-terminated content at completion boundaries. Returning a newline here
217 // would add an extra blank line if a caller invokes `render_completion`.
218 TerminalMode::Basic | TerminalMode::None => String::new(),
219 }
220 }
221}
222
223/// Compute the append-only suffix to emit for a snapshot-style accumulated string.
224///
225/// Providers differ in what they send as a "delta": some stream true incremental suffixes,
226/// others send the full accumulated content repeatedly (snapshot-style). Our append-only
227/// rendering contract treats the parser's sanitized accumulated content as the source of truth.
228///
229/// Given the last rendered sanitized content and the current sanitized content, return
230/// the string that should be appended to the terminal to advance the visible output.
231///
232/// Rules:
233/// - If `last_rendered` is empty, emit `current` (first delta).
234/// - If `current` starts with `last_rendered`, emit the new suffix only.
235/// - Otherwise, treat as a discontinuity/reset and emit an empty suffix.
236///
237/// ## Why discontinuities emit nothing
238///
239/// In an append-only renderer, emitting `current` on a discontinuity would append an entire
240/// replacement snapshot onto already-rendered output, producing duplicated/corrupted display.
241/// Callers that need to surface a reset must do so explicitly (e.g., finalize the current line
242/// and start a new one).
243///
244/// ## Discontinuity Detection
245///
246/// A discontinuity occurs when `current` does not start with `last_rendered` (i.e.,
247/// `current.strip_prefix(last_rendered)` returns `None`). This indicates:
248/// - Non-monotonic deltas from the provider (e.g., "Hello World" followed by "Hello Universe")
249/// - Protocol violations where content changes unexpectedly
250/// - Content resets that should be handled explicitly by the caller
251///
252/// When a discontinuity is detected, this function returns an empty string. Callers should
253/// detect this condition (when both `last_rendered` and `current` are non-empty but the
254/// result is empty) and emit appropriate warnings or metrics to track provider behavior.
255#[must_use]
256pub fn compute_append_only_suffix<'a>(last_rendered: &str, current: &'a str) -> &'a str {
257 if last_rendered.is_empty() {
258 return current;
259 }
260
261 let suffix = current.strip_prefix(last_rendered).unwrap_or_default();
262
263 // Debug assertion to help detect unexpected discontinuities during development
264 #[cfg(debug_assertions)]
265 if suffix.is_empty() && !current.is_empty() && !last_rendered.is_empty() {
266 eprintln!(
267 "Debug: Delta discontinuity detected in compute_append_only_suffix. \
268 Last rendered: {:?} (len={}), Current: {:?} (len={}). \
269 This may indicate non-monotonic deltas from the provider.",
270 &last_rendered[..last_rendered.len().min(50)],
271 last_rendered.len(),
272 ¤t[..current.len().min(50)],
273 current.len()
274 );
275 }
276
277 suffix
278}