Skip to main content

yog/git_tree/
streaming.rs

1//! Live-streaming accumulation for in-flight steps.
2//!
3//! Per ARCH §2.3 / §3.5 / §4.4: the harness writes
4//! `<conv-repo>/steps/<conv-id>/<NNN>/response.json` as a JSONL stream
5//! of §4.4 events as the model's adapter emits them, then closes the fd
6//! at completion. The frontend tails this file from disk on every tick
7//! (§3.5: re-read, no in-memory accumulator that could drift).
8//!
9//! **One read, one value** (§5.1 #10, #28b). The fold yields a [`Stream`]:
10//! the accumulated answer text, the accumulated reasoning text, and the
11//! **kind of the last content delta** that landed. The last is what splits a
12//! live model call into the three things it can be doing — nothing back yet,
13//! thinking, answering — for the per-agent `Doing` derivation the §11 live
14//! mark paints. Reading the file twice would cost a second syscall per agent
15//! per tick and could catch two different mid-write states of one file, so the
16//! facts come off one pass or they are not one file's answer at all — which is
17//! why they travel as one value ([`Agent::stream`](super::Agent::stream))
18//! rather than as fields that could be filled from two reads.
19//!
20//! The fold is **resumable** ([`Stream::absorb`], §7.2 live tail): folding the
21//! bytes appended since the last read and absorbing the result is the same
22//! `Stream` as folding the file whole, so the frame-cadence follower
23//! (`app::live`) reuses this parser on a growing suffix instead of growing a
24//! second one.
25//!
26//! Functions here are pure over an on-disk `<conv-repo>/steps/` tree.
27//! Events outside the `content_delta` seam (`message_start`, tool-argument
28//! deltas, etc.) are ignored at this layer — pulsing tool indicators (bl-23d9)
29//! and branch-state badges (bl-de6b) read their own signals.
30//!
31//! Deltas are read from brazen's `v=1` `content_delta` (§4.4). The v0.6 legacy
32//! vocabulary is retired (bl-56ee).
33
34use std::path::Path;
35
36use super::STEPS_DIR;
37
38/// The fold's own JSON spelling (REMOTE §3, bl-73e7) — the follow lane's frame
39/// body, beside the type rather than at the boundary that carries it.
40pub mod wire;
41
42/// Width of the zero-padded step sequence in on-disk paths
43/// (`steps/<conv-id>/001`, `…/002`, ...). Mirrors
44/// `src/prompt/step::STEP_SEQ_WIDTH` — duplicated here to keep the UI
45/// crate free of a dep on the harness binary.
46const STEP_SEQ_WIDTH: usize = 3;
47
48/// The live tail the harness appends stream events to and closes at
49/// completion. `pub(super)` so the §11 recency derivation
50/// (`enumerate::last_action_from_disk`) names the same file this folds.
51pub(super) const RESPONSE_FILE: &str = "response.json";
52
53/// The kind of one `content_delta` — the brazen `v=1` `Delta` arms yog reads
54/// (§4.4). `json_delta` (tool arguments) is not one of them: it is the model
55/// composing a tool call, which the §5.1 #10 tool records already say better.
56#[derive(Debug, Clone, Copy, PartialEq, Eq)]
57pub enum Delta {
58    /// `text_delta` — answer text, the part the live tail displays.
59    Text,
60    /// `thinking_delta` — reasoning. It is *also* display text (§7.2 the
61    /// thinking ruling): a badge that never grows cannot tell a model thinking
62    /// hard from a driver that has hung, and "nothing is happening on screen"
63    /// is the complaint this whole seam exists to answer.
64    Thinking,
65}
66
67/// What one read of the latest step's `response.json` says (§5.1 #10, #28b) —
68/// every fact off one pass, so they cannot describe two different mid-write
69/// states of the same file.
70#[derive(Debug, Clone, PartialEq, Eq, Default)]
71pub struct Stream {
72    /// The accumulated answer text: `Some` once at least one `text_delta`
73    /// has landed, `None` while the file is absent, empty, or carries only
74    /// deltas that are not answer text.
75    pub text: Option<String>,
76    /// The accumulated reasoning text, on the same terms — `Some` once a
77    /// `thinking_delta` has landed. Held apart from [`text`](Self::text)
78    /// because they are two different things being said and the transcript
79    /// paints them as two rows, exactly as a *committed* model entry's
80    /// `Block::Thinking` and `Block::Text` already are.
81    pub thinking: Option<String>,
82    /// The kind of the **last** content delta seen. `None` means the stream
83    /// has produced nothing yet — which, under an open `response.json` fd
84    /// (§5.1 #9), is exactly "waiting for the API".
85    pub last_delta: Option<Delta>,
86}
87
88impl Stream {
89    /// Absorb the fold of the bytes that landed **after** this one's — the
90    /// resumability the §7.2 live-tail follower rides. Text accretes in stream
91    /// order and the newer delta kind wins when the suffix had one at all, so
92    /// `fold(a).absorb(fold(b)) == fold(a ++ b)` for any split on a line
93    /// boundary. That equality is the whole contract: the follower never has a
94    /// second parser, only a second *place* to start reading.
95    pub(crate) fn absorb(&mut self, later: Self) {
96        append(&mut self.text, later.text);
97        append(&mut self.thinking, later.thinking);
98        self.last_delta = later.last_delta.or(self.last_delta);
99    }
100}
101
102/// Accrete `more` onto an accumulator that may not exist yet. Absent stays
103/// absent — a stream that has said nothing has said nothing, and an empty
104/// `Some("")` would read as "it spoke" to every seat downstream.
105fn append(slot: &mut Option<String>, more: Option<String>) {
106    if let Some(more) = more {
107        slot.get_or_insert_default().push_str(&more);
108    }
109}
110
111/// Read the latest step's live stream from disk.
112///
113/// `pub` because it completes the view-model boundary: [`Transcript::with_live`]
114/// (crate::transcript::Transcript::with_live) takes a [`Stream`], and a public
115/// surface that consumes one owes a public way to obtain one off a workspace.
116///
117/// "Latest step" is the highest `<NNN>` directory under
118/// `<conv-repo>/steps/<conv-id>/`. Re-derived on every call from the
119/// directory listing so the view-model has no in-memory state to drift
120/// out of sync with disk (§3.5). An absent conversation, an absent file and
121/// an unreadable one all read as the default [`Stream`] — nothing has come
122/// back — which is the general path with empty input, not a case.
123pub fn stream_from_disk(workspace: &Path, agent_id: &str) -> Stream {
124    let agent_steps = workspace.join(STEPS_DIR).join(agent_id);
125    let Some(latest) = latest_step_dir(&agent_steps) else {
126        return Stream::default();
127    };
128    match std::fs::read(latest.join(RESPONSE_FILE)) {
129        Ok(bytes) => fold_stream(&bytes),
130        Err(_) => Stream::default(),
131    }
132}
133
134/// The latest step's open response file, or `None` when no step has opened
135/// one — the path the §7.2 live-tail follower holds its offset into.
136/// `pub(crate)` because the follower is the only caller that wants the *file*
137/// rather than the fold.
138pub(crate) fn latest_response_path(workspace: &Path, agent_id: &str) -> Option<std::path::PathBuf> {
139    let agent_steps = workspace.join(STEPS_DIR).join(agent_id);
140    Some(latest_step_dir(&agent_steps)?.join(RESPONSE_FILE))
141}
142
143/// Find the highest-numbered `<NNN>/` directory under `conv_steps`.
144/// Entries that don't match the zero-padded step shape are ignored —
145/// `tools/` lives one level deeper, so it's structurally not at risk
146/// here, but staying strict keeps us robust to stray files.
147pub(super) fn latest_step_dir(conv_steps: &Path) -> Option<std::path::PathBuf> {
148    let entries = std::fs::read_dir(conv_steps).ok()?;
149    let mut best: Option<(u32, std::path::PathBuf)> = None;
150    for entry in entries.flatten() {
151        let name = entry.file_name();
152        let Some(name_str) = name.to_str() else {
153            continue;
154        };
155        if name_str.len() != STEP_SEQ_WIDTH {
156            continue;
157        }
158        let Ok(seq) = name_str.parse::<u32>() else {
159            continue;
160        };
161        let path = entry.path();
162        if !path.is_dir() {
163            continue;
164        }
165        if best.as_ref().is_none_or(|(s, _)| seq > *s) {
166            best = Some((seq, path));
167        }
168    }
169    best.map(|(_, p)| p)
170}
171
172/// Fold a JSONL `response.json` payload into its [`Stream`]. Each line is a
173/// §4.4 stream event; text fragments accumulate in stream order across all
174/// block indices, and every recognised delta moves `last_delta` — so the field
175/// ends on the newest one, which is what the operator is watching happen.
176/// Lines that fail to parse, and events outside the delta seam, are skipped —
177/// partial-write tolerance is structural (the harness may be mid-line on disk).
178/// `pub(crate)` so the §7.2 follower folds its appended suffix through this one
179/// parser and absorbs the result ([`Stream::absorb`]).
180pub(crate) fn fold_stream(bytes: &[u8]) -> Stream {
181    let (mut text, mut thinking) = (String::new(), String::new());
182    let mut last_delta = None;
183    for line in bytes.split(|&b| b == b'\n') {
184        if line.is_empty() {
185            continue;
186        }
187        let Ok(value): Result<serde_json::Value, _> = serde_json::from_slice(line) else {
188            continue;
189        };
190        if let Some((kind, fragment)) = delta_of(&value) {
191            match kind {
192                Delta::Text => text.push_str(fragment),
193                Delta::Thinking => thinking.push_str(fragment),
194            }
195            last_delta = Some(kind);
196        }
197    }
198    Stream {
199        text: said(text),
200        thinking: said(thinking),
201        last_delta,
202    }
203}
204
205/// An accumulator as the fact it is: nothing said reads as absent, never as an
206/// empty utterance — every seat downstream branches on `Some`.
207fn said(accumulated: String) -> Option<String> {
208    (!accumulated.is_empty()).then_some(accumulated)
209}
210
211/// The brazen `v=1` delta seam:
212/// `{"type":"content_delta","delta":{"<arm>":"…"}}` — the externally-tagged
213/// `Delta`'s two arms yog reads, with their payloads. A `json_delta` (tool
214/// arguments) or any other event yields `None`.
215fn delta_of(value: &serde_json::Value) -> Option<(Delta, &str)> {
216    if value.get("type").and_then(|v| v.as_str())? != "content_delta" {
217        return None;
218    }
219    let delta = value.get("delta")?;
220    let arm = |key: &str| delta.get(key).and_then(|v| v.as_str());
221    arm("text_delta")
222        .map(|t| (Delta::Text, t))
223        .or_else(|| arm("thinking_delta").map(|t| (Delta::Thinking, t)))
224}
225
226#[cfg(test)]
227mod tests;