Skip to main content

aft/list_surfaces/
bash.rs

1//! Bash list surface adapter (R17, R20, R22).
2//!
3//! List surface metadata and envelope builder for `bash.output`.
4//! Unit: `lines`. Reason: `cap` (`ReasonKind::Selecting`). Narrow: `[]`.
5//! No narrow clause in rendered text.
6//! `shown` = output line count, `total` = `Exact(input line count)`, both
7//! measured on the text the agent receives.
8//! `dropped_by_class` counts blocks and is NEVER a line-count source.
9
10use crate::list_envelope::{derive_wire_key, ListEnvelope, Reason, Total, Unit};
11use crate::list_surfaces::ReasonKind;
12
13/// Registered list ID for bash text output.
14pub const LIST_ID: &str = "bash.output";
15
16/// Wire serialization key at reply root beside `output` (R14, R21).
17pub const WIRE_KEY: &str = "bash_output_list_envelope";
18
19/// Registered unit for bash output.
20pub const UNIT: Unit = Unit::Lines;
21
22/// Registered reason for bash truncation.
23pub const REASON: Reason = Reason::Cap;
24
25/// Reason kind for bash cap.
26pub const REASON_KIND: ReasonKind = ReasonKind::Selecting;
27
28/// Narrowing parameters: bash accepts no narrowing parameter.
29pub const NARROW: &[&str] = &[];
30
31/// Count lines in agent-facing output text.
32#[inline]
33pub fn count_output_lines(output: &str) -> usize {
34    output.lines().count()
35}
36
37/// Derive the wire key for serializing the bash envelope.
38#[inline]
39pub fn wire_key() -> String {
40    derive_wire_key(LIST_ID, true)
41}
42
43/// Construct a truncation envelope for bash output if lines were dropped.
44///
45/// Returns `Some(ListEnvelope)` when `shown < total_input_lines`.
46/// When output was not compressed or no lines were dropped (`shown >= total_input_lines`),
47/// returns `None` (reason is `None`, nothing rendered, no envelope field serialized).
48pub fn build_bash_output_envelope(shown: usize, total_input_lines: usize) -> Option<ListEnvelope> {
49    if shown >= total_input_lines {
50        None
51    } else {
52        Some(ListEnvelope::new(
53            shown,
54            Total::Exact(total_input_lines),
55            UNIT,
56            vec![REASON],
57            NARROW,
58        ))
59    }
60}
61
62/// Construct a truncation envelope measured directly on the agent-facing output text
63/// and the compressor-reported input line count.
64///
65/// `shown` is the output line count of `agent_received_output`.
66/// `total` is `Exact(input_line_count)`.
67/// Neither count is ever sourced from `dropped_by_class`.
68pub fn build_envelope_from_output(
69    agent_received_output: &str,
70    input_line_count: usize,
71) -> Option<ListEnvelope> {
72    let shown = count_output_lines(agent_received_output);
73    build_bash_output_envelope(shown, input_line_count)
74}
75
76/// Append the text-surface trailer and return its envelope when compression dropped lines.
77///
78/// The trailer is itself part of the agent-facing line count, so `shown` includes the
79/// one line appended here. Uncompressed text remains byte-identical.
80pub fn append_envelope_trailer(
81    agent_received_output: &mut String,
82    input_line_count: usize,
83) -> Option<ListEnvelope> {
84    let body_lines = count_output_lines(agent_received_output);
85    if body_lines >= input_line_count {
86        return None;
87    }
88
89    let envelope = ListEnvelope::new(
90        body_lines.saturating_add(1),
91        Total::Exact(input_line_count),
92        UNIT,
93        vec![REASON],
94        NARROW,
95    );
96    let trailer = envelope_trailer(&envelope);
97    if !agent_received_output.is_empty() && !agent_received_output.ends_with('\n') {
98        agent_received_output.push('\n');
99    }
100    agent_received_output.push_str(&trailer);
101    Some(envelope)
102}
103
104/// Render a bash envelope through the authorized NDJSON trailer seam.
105///
106/// An empty base isolates the trailer, while array mode asks the shared seam to
107/// render it before bash integrates that text into its output payload.
108pub fn envelope_trailer(envelope: &ListEnvelope) -> String {
109    let mut data = serde_json::Map::new();
110    data.insert(
111        derive_wire_key(LIST_ID, false),
112        serde_json::to_value(envelope).expect("ListEnvelope serialization"),
113    );
114    crate::ndjson_text::build_ndjson_text(
115        "",
116        &serde_json::Value::Object(data),
117        Some(LIST_ID),
118        false,
119    )
120}
121
122/// Attach the bash output list envelope to a JSON response object beside `output`.
123pub fn attach_bash_output_envelope(
124    data: &mut serde_json::Map<String, serde_json::Value>,
125    envelope: &Option<ListEnvelope>,
126) {
127    if let Some(env) = envelope {
128        data.insert(
129            WIRE_KEY.to_string(),
130            serde_json::to_value(env).expect("ListEnvelope serialization"),
131        );
132    }
133}
134
135/// Produce a bash reply data map containing `output` and optionally `bash_output_list_envelope`.
136pub fn produce_bash_reply_data(
137    output: String,
138    envelope: Option<ListEnvelope>,
139) -> serde_json::Map<String, serde_json::Value> {
140    let mut map = serde_json::Map::new();
141    map.insert("output".to_string(), serde_json::Value::String(output));
142    attach_bash_output_envelope(&mut map, &envelope);
143    map
144}