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;