Skip to main content

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}