forge_workspace/conversation_turns.rs
1//! Where a conversation's turns open, in the shape the fold reads them.
2//!
3//! **One rule, two readers.** A page is cut on the fold's turn boundaries, and
4//! so is the window a session keeps its conversation in: a window that starts
5//! part-way through a turn serves its first turn with the head missing. The
6//! fold lives above this crate (`forge_server::transcript`), so the row
7//! predicates it reads live here and both readers share the one copy.
8//!
9//! **This answers no more than the fold would.** Every frame [`TurnScan`]
10//! names is one the fold opens a turn at; it names none the fold would not.
11//! Three shapes it cannot read the way the fold does are answered the safe way
12//! (no turn), so what they cost is a head the window cannot start on, never a
13//! cut inside a turn.
14//!
15//! A queued prompt opens a turn in the fold, and the scan answers none for it.
16//! That miss is not only the prompt: the fold's turn opens there and resets the
17//! state the next turn opens under, so a frame the fold then draws as a head of
18//! its own - the continuation a compaction leaves - is left alone here too.
19//! `opens_for_debt` answers for it, because the counter debt read wants the
20//! fold's openings rather than the cut's: a missed one carries the previous
21//! turn's counters into the newest.
22//!
23//! And a row opening with a bracket is read as an envelope, because every
24//! envelope this workspace writes opens with one and the parser that tells an
25//! envelope from the reader's own words lives above this crate. That is broader
26//! than the envelopes: a reader's own `[note] ...` row, and a bracket-led
27//! notice the parser rejects, are heads here that this leaves alone. The shapes
28//! read here are exempt from that, so an image note the fold opens a turn at is
29//! named. **A delivery's envelope row opens a turn in the fold unless the CLI
30//! pressed it into a running turn, and this side answers none for it** - the
31//! accepted cost a queued prompt pays above: a head the window cannot start
32//! on, never a cut inside a turn. The debt read answers for it too, on the
33//! row's bracket alone ([`is_envelope_row`]).
34//!
35//! And a forge-minted row arriving at an open call is a delivery the CLI
36//! pressed into the running turn - the fold keeps it there and opens no turn
37//! (#2379). That answer needs the whole list (the fold declines only where the
38//! call's result really comes); a stream cannot read it, so this side answers
39//! no turn for the row, and for a call whose result never arrives it keeps
40//! answering that way - heads the window cannot start on.
41
42use forge_primitives::messages::names_a_dispatch;
43use forge_primitives::{ContentBlock, Message};
44
45/// Where the fold opens a conversation turn, walked frame by frame.
46#[derive(Default)]
47pub struct TurnScan {
48 /// The skills the open turn holds a call for, oldest first: a skill's body
49 /// belongs in the turn whose call loaded it.
50 skills: Vec<String>,
51 /// Whether a compaction boundary sits in the open turn. The continuation
52 /// prompt that follows belongs beside it, so it opens no turn of its own
53 /// while this holds.
54 boundary: bool,
55 /// Whether the open turn has taken a tool call. The harness's line about
56 /// an image it read arrives right behind the call that read it, so it
57 /// joins that turn rather than opening one.
58 calls: bool,
59 /// The open turn's calls still waiting on their results, oldest first. A
60 /// forge-minted row arriving at one is a delivery pressed into the running
61 /// turn: the fold keeps it there, so no turn opens here either.
62 ///
63 /// The fold answers that row too, and it can answer it more sharply - it
64 /// reads the whole list and declines only where the call's result really
65 /// comes. This side reads a stream, so a call whose result never arrives
66 /// (the restart shape, #1836) is declined here and opened there: a head
67 /// the window cannot start on, never a cut inside a turn.
68 open: Vec<String>,
69}
70
71impl TurnScan {
72 /// Whether the fold opens a conversation turn at this frame, under the
73 /// state the frames before it left.
74 pub fn opens(&mut self, message: &Message) -> bool {
75 // A sub-agent's frames are the SUBAGENTS surface's, not the chat's.
76 if is_dispatched(message) {
77 return false;
78 }
79 match message {
80 Message::CompactBoundary { .. } => {
81 self.boundary = true;
82 false
83 }
84 // A call's own row opens no turn, but a `Skill` call is what makes
85 // the body behind it belong to this turn rather than opening one.
86 Message::Assistant { message: envelope, .. } => {
87 self.calls_in(&envelope.content);
88 false
89 }
90 Message::User { message: envelope, uuid, .. } => {
91 let mut opens = false;
92 for block in &envelope.content {
93 match block {
94 ContentBlock::Text { text, .. } => {
95 if claims_skill_call(&mut self.skills, text) {
96 continue;
97 }
98 if self.boundary && is_continuation(text) {
99 self.boundary = false;
100 continue;
101 }
102 if self.calls && is_image_note(text) {
103 continue;
104 }
105 if is_task_notice(text) {
106 continue;
107 }
108 // The bracket fallback below would catch the
109 // interrupt marker only when it opens the string,
110 // and the fold's own read trims first - so a
111 // whitespace-led marker would open a turn here and
112 // none there, and the window could cut inside a
113 // turn. Read by the predicate, in step with the
114 // fold.
115 if is_interrupt_marker(text) {
116 continue;
117 }
118 // Every envelope this workspace writes opens with a
119 // bracket, and the parser that tells one from the
120 // reader's own words lives above this crate - so a
121 // bracket-led row is left alone unless it is a
122 // shape read here.
123 if text.starts_with('[') && !is_image_note(text) {
124 continue;
125 }
126 // **A forge-minted row at an open call is a
127 // delivery pressed into the running turn**: the
128 // fold keeps it there (#2379), and a head named
129 // here would start the window inside that turn.
130 // The fold's own bound - the result really coming
131 // - needs the whole list; a stream cannot read it,
132 // so this side answers no turn and the window
133 // loses only a head.
134 if forge_minted(uuid.as_deref()) && !self.open.is_empty() {
135 continue;
136 }
137 self.skills.clear();
138 self.boundary = false;
139 self.calls = false;
140 self.open.clear();
141 opens = true;
142 }
143 other => self.calls_in(std::slice::from_ref(other)),
144 }
145 }
146 opens
147 }
148 _ => false,
149 }
150 }
151
152 /// The blocks that move the state the next turn opens under rather than
153 /// opening one: a call, the result that answers one, and a `Skill` call's
154 /// own name for what it loads.
155 fn calls_in(&mut self, content: &[ContentBlock]) {
156 for block in content {
157 if let ContentBlock::ToolResult { tool_use_id, .. }
158 | ContentBlock::ServerToolResult { tool_use_id, .. } = block
159 {
160 self.open.retain(|open| open != tool_use_id);
161 continue;
162 }
163 if let ContentBlock::ToolUse { id, name, input, .. }
164 | ContentBlock::ServerToolUse { id, name, input, .. } = block
165 {
166 self.calls = true;
167 self.open.push(id.clone());
168 if let Some(want) = input
169 .get("skill")
170 .and_then(serde_json::Value::as_str)
171 .filter(|_| name.eq_ignore_ascii_case("skill"))
172 {
173 self.skills.push(want.to_owned());
174 }
175 }
176 }
177 }
178}
179
180/// Whether a user row's id says forge minted it: a reader's own send
181/// (`forge_prompt_*`) or the machinery's dispatch (`forge_nudge_*`), the two
182/// namespaces `forge_sdk::request_id` mints.
183///
184/// **The id is the one fact about the sender the CLI carries into the
185/// transcript**, so it is what tells a row the CLI is pressing into a running
186/// turn from a row that opens one - an id minted elsewhere is not this
187/// workspace's row.
188pub fn forge_minted(uuid: Option<&str>) -> bool {
189 uuid.is_some_and(|id| id.starts_with("forge_prompt_") || id.starts_with("forge_nudge_"))
190}
191
192/// Whether a frame opens a turn for a reader that attributes counters to the
193/// newest turn: the scan's own openings plus the two shapes the fold opens at
194/// and the window cut deliberately cannot name - a queued prompt and a
195/// delivery's envelope row.
196///
197/// **The cut's under-approximation is safe; the debt's is not.** A cut the
198/// scan cannot start on leaves a turn's head in place, while a debt
199/// attributed to the wrong turn adds another turn's counters to the figure.
200pub fn opens_for_debt(scan: &mut TurnScan, message: &Message) -> bool {
201 let named = scan.opens(message);
202 named || is_queued_prompt(message) || is_envelope_row(message)
203}
204
205/// Whether a frame is the CLI's queued prompt, which the fold opens a turn at.
206pub fn is_queued_prompt(message: &Message) -> bool {
207 let Message::User { message, .. } = message else {
208 return false;
209 };
210 message.content.iter().any(|block| matches!(block, ContentBlock::QueuedCommand { .. }))
211}
212
213/// Whether a frame is a bracket-led row: the shape every envelope this
214/// workspace injects opens with - a delivery, a peer wrapper, a failure.
215///
216/// **The prefix alone, on purpose.** The parser that tells a real envelope
217/// from the reader's own `[note]` row lives above this crate, and the two
218/// readers want opposite answers about it: the cut must never start on a frame
219/// the fold opens nothing at, while the debt must not carry a previous turn's
220/// counters into the newest. So the cut leaves every bracket-led row alone and
221/// the debt counts every one as an opening - the debt's error is then a figure
222/// short of counters rather than one holding another turn's.
223pub fn is_envelope_row(message: &Message) -> bool {
224 let Message::User { message, .. } = message else {
225 return false;
226 };
227 message
228 .content
229 .iter()
230 .any(|block| matches!(block, ContentBlock::Text { text, .. } if text.starts_with('[')))
231}
232
233/// A frame a sub-agent produced. What it narrates and calls belongs to the
234/// SUBAGENTS surface, never to the session's own conversation.
235pub fn is_dispatched(message: &Message) -> bool {
236 let (Message::Assistant { parent_tool_use_id: parent, .. }
237 | Message::User { parent_tool_use_id: parent, .. }
238 | Message::StopHookSummary { parent_tool_use_id: parent, .. }) = message
239 else {
240 return false;
241 };
242 names_a_dispatch(parent.as_deref())
243}
244
245/// Whether a turn's text is the harness's own task notice rather than anything
246/// a person said: the marker the CLI writes at the head of the XML.
247///
248/// Two carriers hold it - the `attachment` row the scan hoists into a
249/// `queued_command` block, and a `user` row whose content string is the XML -
250/// and the fold reads the marker out of both, so a third shape would arrive
251/// here and not elsewhere.
252pub fn is_task_notice(text: &str) -> bool {
253 text.trim_start().starts_with("<task-notification>")
254}
255
256/// Whether `text` is the harness's own line about an image it just read.
257///
258/// The CLI sends it as the reader's own row right after the result that
259/// carried the image; the client fold hangs it on the call that read the
260/// picture (`imageNoteOf` in its `units.ts`), so it belongs in that call's
261/// turn rather than in one of its own.
262pub fn is_image_note(text: &str) -> bool {
263 text.trim().starts_with("[Image: original ")
264}
265
266/// Whether `text` is the CLI's interrupt marker: the row it writes when the
267/// reader's own interrupt ends a turn.
268///
269/// A `user` row, and not one that opens a turn (#1323): it arrives INSIDE
270/// the turn it ended - the in-flight call, then this, then the failing
271/// `Result` - so a turn opened for it separates the call from the report
272/// and failure line that answer it. Both folds read it as a plain row of
273/// the open turn (the client mirror is `isInterruptMarker` in its
274/// `conversation.ts`).
275pub fn is_interrupt_marker(text: &str) -> bool {
276 let held = text.trim_start();
277 held.starts_with("[Request interrupted by user") && held.contains(']')
278}
279
280/// Whether `text` is the continuation prompt a compaction leaves behind.
281///
282/// The CLI sends it as the reader's own user row right after the boundary
283/// frame, and it is the compaction's own account of what was cut. The client
284/// fold hangs it on the boundary's row (`attachContinuation` in its
285/// `units.ts`), and the two reads must agree.
286pub fn is_continuation(text: &str) -> bool {
287 text.starts_with("This session is being continued from a previous conversation")
288}
289
290/// The skill a body's own frame names, off its first line's directory.
291///
292/// The CLI injects a skill's body as a user row whose first line names the
293/// skill's directory and whose remainder is the skill's markdown. The name is
294/// the path's last segment that is not a version, so a plugin-cached skill
295/// (`.../ui-ux-pro-max/2.13.0`) is named as its directory names it - the same
296/// reading the web client's fold makes (`skillBody` in its `units.ts`).
297pub fn skill_body_name(text: &str) -> Option<&str> {
298 let lead = text.split('\n').next()?;
299 if let Some(path) = lead.strip_prefix("Base directory for this skill:") {
300 let path = path.trim();
301 return path
302 .split('/')
303 .rev()
304 .find(|part| part.chars().next().is_some_and(|c| !c.is_ascii_digit()));
305 }
306 // The carrier a tool-invoked skill uses: the skill's own markdown, opening
307 // on its title heading (`# PR Review Loop` for `pr-review-loop`) with no
308 // plumbing line. The heading is the whole of what names it, so that is the
309 // name - normalized by `names_skill`, the same match the client makes.
310 let trimmed = lead.trim();
311 let title = trimmed.trim_start_matches('#');
312 if title.len() == trimmed.len() || !title.starts_with(' ') {
313 return None;
314 }
315 let title = title.trim();
316 (!title.is_empty()).then_some(title)
317}
318
319/// Whether `text` is the body of a skill the open turn holds a call for,
320/// consuming that claim.
321///
322/// A body belongs in the turn whose call loaded it: it is the same telling the
323/// call's row already carries, and a turn of its own draws it a second time
324/// under the reader's name. The claim is consumed so a later body for the same
325/// skill lands on the call after it, and a body no call holds opens a turn as
326/// it did before - nothing may be dropped.
327pub fn claims_skill_call(turn_skills: &mut Vec<String>, text: &str) -> bool {
328 let Some(name) = skill_body_name(text) else {
329 return false;
330 };
331 let Some(at) = turn_skills.iter().position(|want| names_skill(want, name)) else {
332 return false;
333 };
334 turn_skills.remove(at);
335 true
336}
337
338/// Whether a `Skill` call's own input names the skill a body's path ended in.
339///
340/// The spellings differ two ways: a plugin skill is `ui-ux-pro-max:ui-ux-pro-max`
341/// where the path ends `ui-ux-pro-max`, and a tool-invoked body's heading is
342/// `PR Review Loop` where the call says `pr-review-loop`. The client fold
343/// matches the same way (`namesSkill` in its `units.ts`); the two must agree,
344/// or the same frame lands in one view and not the other.
345pub fn names_skill(want: &str, name: &str) -> bool {
346 if want == name || want.ends_with(&format!(":{name}")) || want.starts_with(&format!("{name}:"))
347 {
348 return true;
349 }
350 let held = normalized_skill(want);
351 let wanted = normalized_skill(name);
352 held == wanted || held.contains(&wanted) || wanted.contains(&held)
353}
354
355/// A skill name as its words, so `pr-review-loop` and `PR Review Loop` agree.
356pub fn normalized_skill(name: &str) -> String {
357 name.to_lowercase()
358 .chars()
359 .map(|c| if c == '-' || c == '_' || c == ':' { ' ' } else { c })
360 .collect::<String>()
361 .split_whitespace()
362 .collect::<Vec<_>>()
363 .join(" ")
364}
365
366#[cfg(test)]
367mod tests {
368 use super::*;
369
370 /// A delivery's envelope row answers the two readers differently: the cut
371 /// leaves it alone, and the debt counts it as an opening.
372 ///
373 /// The parser that tells a real envelope from the reader's own bracket row
374 /// lives above this crate, so the debt reads the prefix - broader than the
375 /// fold opens at, which is the safe side for a counter figure: a figure
376 /// short of counters is a smaller error than one carrying the previous
377 /// turn's.
378 #[test]
379 fn a_delivery_row_opens_for_the_debt_and_not_for_the_cut() {
380 let row: Message = serde_json::from_value(serde_json::json!({
381 "type": "user",
382 "uuid": "u1",
383 "message": {
384 "role": "user",
385 "content": "[Gotify - app 'ci', priority 9]\n\nbuild failed",
386 },
387 "session_id": "s",
388 }))
389 .expect("a user frame");
390
391 assert!(!TurnScan::default().opens(&row), "the cut cannot name a bracket-led row");
392 assert!(
393 opens_for_debt(&mut TurnScan::default(), &row),
394 "and the debt read must count the turn it opens",
395 );
396 }
397}