Skip to main content

forge_server/
transcript.rs

1//! The conversation fold: wire messages in, where its turns sit out.
2//!
3//! The fold walks the whole conversation in one pass and reports the message
4//! each turn opens at, which is what a page is cut on, plus the task endings
5//! it holds. The rules it folds by are not restated here: the row predicates
6//! it opens a turn by ([`forge_workspace::conversation_turns`]) are the ones
7//! the conversation window reads too, and the envelope parser
8//! ([`crate::envelope`]) is the one the read path uses.
9
10use std::collections::HashMap;
11
12use forge_primitives::{ContentBlock, Message, StopReason, ToolCallStatus};
13// The row predicates the fold opens a turn by, which the conversation window
14// reads too: they live in the window's crate so the two share one copy.
15use forge_workspace::conversation_turns::{
16    claims_skill_call, forge_minted, is_continuation, is_dispatched, is_image_note,
17    is_interrupt_marker, is_task_notice,
18};
19
20use crate::envelope::detect_inbound;
21
22/// Where one turn sits in the conversation it was folded from, and what names
23/// it.
24///
25/// **In MESSAGE terms**, because a page crosses as whole turns: a client is
26/// handed each turn's messages and folds them itself, so the boundary a page
27/// is cut on has to name a message.
28pub struct TurnSpan {
29    /// The message the turn opens at.
30    pub opens_at: usize,
31    /// The key the fold named the turn by, `None` when nothing did - a
32    /// conversation read from a transcript carries no `Result` frame, and the
33    /// key comes from one.
34    pub key: Option<String>,
35}
36
37/// A folded conversation: where its turns sit, and the endings it holds.
38pub struct Rendered {
39    pub turns: Vec<TurnSpan>,
40    /// Every task ending the conversation holds, by the call each names.
41    ///
42    /// Read here because the fold is where it is read from at all - the
43    /// preamble's pass over the messages - and carried out so the turns built
44    /// from this answer do not walk the conversation again for it. A page is
45    /// the case that pays: it is built while a reader scrolls.
46    pub endings: HashMap<String, TaskEnding>,
47}
48
49/// Whether any of `open` is answered later than `from`.
50///
51/// The settled half of the delivery rule's bound: a transcript keeps a call
52/// whose result never arrived - the restart shape (#1836) - and a row after it
53/// belongs to the next turn. Asked on demand, at the rare row that needs it,
54/// so a fold over a settled conversation pays a scan only where it joins; the
55/// live walk never asks, because the caller's flag answers first.
56fn answered_later(messages: &[Message], from: usize, open: &[String]) -> bool {
57    messages[from + 1..].iter().any(|message| {
58        let Message::User { message: envelope, .. } = message else {
59            return false;
60        };
61        envelope.content.iter().any(|block| match block {
62            ContentBlock::ToolResult { tool_use_id, .. }
63            | ContentBlock::ServerToolResult { tool_use_id, .. } => {
64                open.iter().any(|id| id == tool_use_id)
65            }
66            _ => false,
67        })
68    })
69}
70
71/// Open a turn, answering the span it opened: the one a page is cut on and the
72/// one a later report names.
73///
74/// **One place opens a turn.** The fold has two ways in - a block that is the
75/// user's own turn, and a queued prompt hoisted into one - and a span pushed
76/// at only one of them leaves every turn of the other kind outside the page's
77/// boundary list.
78fn open_turn(turns: &mut Vec<TurnSpan>, index: usize) -> usize {
79    turns.push(TurnSpan { opens_at: index, key: None });
80    turns.len() - 1
81}
82
83/// Name the turn the fold has open, if it has one: the report that closed a
84/// turn is what names it.
85fn name_open(turns: &mut [TurnSpan], open: Option<usize>, key: Option<String>) {
86    if let Some(span) = open.and_then(|at| turns.get_mut(at)) {
87        span.key = key;
88    }
89}
90
91/// Fold a conversation into where its turns sit in the message list, and the
92/// task endings it holds.
93///
94/// One walk serves both: a second boundary rule over the messages is how the
95/// two would come to disagree about where a turn begins, and the page is cut
96/// on the answer.
97///
98/// `live` is the session's own turn state at this read, carried by the
99/// CALLER rather than read off a frame: the frame that would say it
100/// (`system/session_state_changed`) is emitted only behind an environment
101/// variable forge never sets and appears in no capture, so the transport that
102/// holds the seat's frames derives the delivered rule instead (a turn is open
103/// until its `Result`, or the error the CLI gives up with) and hands the
104/// answer in. The settled reads - a transcript on disk, a span page - pass
105/// `false` or the seat's own flag where that is at hand.
106pub fn render(messages: &[Message], live: bool) -> Rendered {
107    let endings = task_endings(messages);
108    let mut traced = TurnTrace::default();
109    // The names this fold has already given out, so a second turn opening on
110    // one clock takes its own rather than the first turn's.
111    let mut keys: HashMap<String, usize> = HashMap::new();
112    // Where each turn opens, taken as the fold opens it: one push per turn, in
113    // order.
114    let mut turns: Vec<TurnSpan> = Vec::new();
115    // The turn the fold has open, which is the span a report names.
116    let mut open: Option<usize> = None;
117    // The skills whose `Skill` call the open turn holds and whose body has not
118    // arrived yet, oldest first: a body belongs in the call's turn, and the
119    // claim is what keeps it there. Cleared wherever a turn opens, so a body
120    // cannot join a turn whose calls are not the ones it belongs to.
121    let mut turn_skills: Vec<String> = Vec::new();
122    // Whether a compaction boundary has landed in the open turn. The
123    // continuation prompt that follows belongs beside it - it is the row's own
124    // account - so it opens no turn of its own while this holds.
125    let mut turn_boundary = false;
126    // Whether the open turn has taken any tool call. The harness's line about
127    // an image it read arrives right behind the call that read it, so it joins
128    // that turn rather than opening one; the note claims the call by content
129    // in the views, and this only keeps the two in one turn.
130    let mut turn_calls = false;
131    // The open turn's calls still waiting on their results, oldest first. A
132    // running turn's row that would otherwise open one - the chase's board
133    // notice, a mid-turn prompt - is DELIVERED into this turn, and opening a
134    // turn for it cuts the calls above it off from the answers below: every
135    // reader of the fold then draws the call failed with no result (#2379).
136    //
137    // **The rule's bound is the turn's liveness, which the caller carries.**
138    // While the seat's turn is live, an open call is mid-flight whatever the
139    // list holds - on the live walk the result is not written yet, so a bound
140    // read off the list alone would stop protecting exactly the window #2379
141    // is about. The flag is the whole seat's, so an older crash-shaped turn
142    // sitting in the window keeps its early row too while a later turn runs -
143    // the same "whatever the list holds" policy, read on the window rather
144    // than the turn. A settled shape with no result is the crash one (#1836):
145    // there the open call is never coming back and the row after it opens its
146    // own turn, which is what `answered_later` decides.
147    let mut open_calls: Vec<String> = Vec::new();
148    for (index, message) in messages.iter().enumerate() {
149        // A sub-agent's frames are the SUBAGENTS surface's, not the chat's.
150        if is_dispatched(message) {
151            continue;
152        }
153        // The boundary a compaction left. It opens nothing here - the web
154        // client folds the frame itself - but which turn it lands in is what
155        // the continuation prompt after it must join, so it is watched.
156        if matches!(message, Message::CompactBoundary { .. }) {
157            turn_boundary = true;
158        }
159        // A settled turn's report. It arrives as a message of its own rather
160        // than as a block, so it ends the turn the calls before it built and
161        // names it.
162        if let Message::Result { uuid, .. } = message {
163            let key = take_key(&mut keys, traced.opened.clone(), uuid.as_deref());
164            name_open(&mut turns, open, key);
165            traced = TurnTrace::default();
166            open_calls.clear();
167            continue;
168        }
169        let (assistant, row_uuid, content) = match message {
170            Message::Assistant { message: envelope, .. } => {
171                (true, None, envelope.content.as_slice())
172            }
173            Message::User { message: envelope, uuid, .. } => {
174                (false, uuid.as_deref(), envelope.content.as_slice())
175            }
176            _ => continue,
177        };
178        for block in content {
179            match block {
180                // A skill's body, which the CLI injects as the reader's own
181                // row right after the call that loaded it. It stays in that
182                // call's turn - the second telling of what the call's row
183                // already carries - and a body no call holds falls through to
184                // the arms below and opens a turn as it always did.
185                ContentBlock::Text { text, .. }
186                    if !assistant && claims_skill_call(&mut turn_skills, text) => {}
187                // The continuation prompt a compaction leaves behind, which
188                // belongs in the boundary's own turn: the client hangs it on
189                // that row, and a turn of its own draws the compaction twice.
190                // With no boundary in this turn it opens one as it always did.
191                ContentBlock::Text { text, .. }
192                    if !assistant && turn_boundary && is_continuation(text) =>
193                {
194                    turn_boundary = false;
195                }
196                // The harness's own line about an image it just read: it
197                // belongs in the open turn, right behind the call whose result
198                // carried the picture, where the view hangs it on that call's
199                // row as the caption. It opens no turn of its own either way.
200                ContentBlock::Text { text, .. }
201                    if !assistant && turn_calls && is_image_note(text) => {}
202                // The harness's task ending in its other carrier: the same XML
203                // written into a user row, which the scan hands on as this
204                // plain text. A turn opened for it draws a task id and an
205                // output path attributed to the reader, so the row opens none -
206                // and the ending it carries was read in the pre-pass above,
207                // which is the only place that can see across turns.
208                ContentBlock::Text { text, .. } if !assistant && is_task_notice(text) => {}
209                // The CLI's interrupt marker, written as the reader's own row
210                // INSIDE the turn it ended: a turn opened for it separates the
211                // failing `Result` from the call it ended, so the report and
212                // the failure line land in a turn holding no call (#1323).
213                ContentBlock::Text { text, .. } if !assistant && is_interrupt_marker(text) => {}
214                // **A delivery's envelope row is the turn boundary.** The CLI
215                // runs a delivered prompt as a turn of its own, and the row it
216                // writes is the only marker of where that turn opens; a fold
217                // that opened none here joined a delivered turn's exchange to
218                // the turn above, and a reload drew the two as one turn. The
219                // row still DRAWS as its envelope - the cut is the server's,
220                // the header the client's.
221                ContentBlock::Text { text, .. } if !assistant && detect_inbound(text).is_some() => {
222                    // **A delivery with a call out stays in the running turn**,
223                    // the rule a forge-minted row takes: a turn opened here
224                    // cuts the call above off from the result below (#2379).
225                    if open.is_some()
226                        && !open_calls.is_empty()
227                        && (live || answered_later(messages, index, &open_calls))
228                    {
229                        continue;
230                    }
231                    close_traced(&mut traced, true, &mut keys, &mut turns, open);
232                    open = Some(open_turn(&mut turns, index));
233                    turn_skills.clear();
234                    turn_boundary = false;
235                    turn_calls = false;
236                    open_calls.clear();
237                }
238                ContentBlock::Text { .. } => {
239                    // **A forge-minted row arriving while the open turn waits
240                    // on a call was delivered INTO it**: the CLI presses a
241                    // mid-turn write into the running turn, and the chase's
242                    // board notice while an `agents__despawn` is out is the
243                    // everyday one. A turn opened for it cuts the call off
244                    // from the result below, and every reader of the fold
245                    // draws the despawn failed with no result while it came
246                    // back cleanly (#2379). A row the CLI wrote itself is not
247                    // this - it opens a turn as it always did.
248                    if !assistant
249                        && forge_minted(row_uuid)
250                        && open.is_some()
251                        && !open_calls.is_empty()
252                        && (live || answered_later(messages, index, &open_calls))
253                    {
254                        continue;
255                    }
256                    // The user's own row opens a turn, so the work above it is
257                    // named first; the assistant's prose stays inside the turn
258                    // already open.
259                    if !assistant {
260                        close_traced(&mut traced, true, &mut keys, &mut turns, open);
261                        open = Some(open_turn(&mut turns, index));
262                        turn_skills.clear();
263                        turn_boundary = false;
264                        turn_calls = false;
265                        open_calls.clear();
266                    }
267                }
268                ContentBlock::QueuedCommand { prompt, command_mode, source_uuid, .. } => {
269                    // The harness's own background-completion notice rides the
270                    // same block as a prompt, and the scan hoists it into a
271                    // user envelope without reading the mode - so a turn opened
272                    // for it is a row nothing fills, which the page draws as a
273                    // blank line. A view never draws one: it filters the
274                    // row rather than the turn.
275                    if is_completion_notice(command_mode.as_deref(), prompt) {
276                        continue;
277                    }
278                    // The same rule as the plain row above: a forge-minted
279                    // queued write pressed into a running turn - a call still
280                    // waiting on its result - stays in that turn (#2379). The
281                    // marker rides the block's `source_uuid` when the CLI keeps
282                    // the attachment shape, and the enclosing row's id when
283                    // the scan hoisted it.
284                    if (forge_minted(source_uuid.as_deref()) || forge_minted(row_uuid))
285                        && open.is_some()
286                        && !open_calls.is_empty()
287                        && (live || answered_later(messages, index, &open_calls))
288                    {
289                        continue;
290                    }
291                    close_traced(&mut traced, true, &mut keys, &mut turns, open);
292                    open = Some(open_turn(&mut turns, index));
293                    turn_skills.clear();
294                    turn_boundary = false;
295                    turn_calls = false;
296                    open_calls.clear();
297                }
298                ContentBlock::ToolUse { id, name, input, .. }
299                | ContentBlock::ServerToolUse { id, name, input, .. } => {
300                    turn_calls = true;
301                    open_calls.push(id.clone());
302                    // A `Skill` call's own name for the skill it loads, held
303                    // until that skill's body arrives: the body is what this
304                    // claim decides the turn of.
305                    if let Some(want) = input
306                        .get("skill")
307                        .and_then(serde_json::Value::as_str)
308                        .filter(|_| name.eq_ignore_ascii_case("skill"))
309                    {
310                        turn_skills.push(want.to_owned());
311                    }
312                }
313                // A result answers a call of the open turn: the turn is no
314                // longer waiting on it, which is what tells a row arriving
315                // later it was pressed into a running turn - or not.
316                ContentBlock::ToolResult { tool_use_id, .. }
317                | ContentBlock::ServerToolResult { tool_use_id, .. } => {
318                    open_calls.retain(|open| open != tool_use_id);
319                }
320                // A result is not a row of its own: it is what the call
321                // it answers already carries.
322                _ => {}
323            }
324        }
325        // Watched after the turn it may have started has been written out:
326        // a prompt's own clock belongs to the turn it opens, not to the one
327        // it ended.
328        traced.watch(message);
329    }
330    // The trailing turn is the one a running session is still writing, and
331    // the view draws that turn's row itself. Every other turn is closed by
332    // the turn after it, or by the result that reported it.
333    close_traced(&mut traced, false, &mut keys, &mut turns, open);
334    Rendered { turns, endings }
335}
336
337/// The fold over a settled conversation: every fixture but the live window's,
338/// which call [`render`] with the caller's own flag.
339#[cfg(test)]
340fn settled(messages: &[Message]) -> Rendered {
341    render(messages, false)
342}
343
344/// The facts a turn's own rows carry, gathered while the fold walks them.
345///
346/// A transcript holds no result frame, so a turn read from one has no name of
347/// its own unless one is derived here: the CLI writes that frame to the wire
348/// and not to the file.
349#[derive(Default)]
350struct TurnTrace {
351    /// The first and last clocks the turn's rows carried.
352    opened: Option<String>,
353    ended: Option<String>,
354    /// The stop the turn's last assistant frame reported. A frame handing
355    /// back for a tool call has not ended a turn.
356    stopped: Option<StopReason>,
357    /// Whether the turn wrote an assistant frame at all: a prompt nothing
358    /// answered draws no row, the way a view draws none.
359    worked: bool,
360}
361
362impl TurnTrace {
363    fn watch(&mut self, message: &Message) {
364        match message {
365            Message::Assistant { message: envelope, timestamp, .. } => {
366                self.worked = true;
367                self.stopped = envelope.stop_reason;
368                self.clock(timestamp.as_deref());
369            }
370            Message::User { timestamp, .. } => self.clock(timestamp.as_deref()),
371            _ => {}
372        }
373    }
374
375    fn clock(&mut self, at: Option<&str>) {
376        let Some(at) = at else {
377            return;
378        };
379        self.opened.get_or_insert_with(|| at.to_owned());
380        self.ended = Some(at.to_owned());
381    }
382
383    /// The span the turn's own rows support: `None` when either end does not
384    /// parse or the pair runs backwards, which a transcript does hand back -
385    /// a replayed row carries the stamp it was written with, so it can sit
386    /// behind the row before it. The header prints a span and nothing else
387    /// where one belongs, so a turn with no usable span draws no row: a zero
388    /// there would be a measurement this row does not have.
389    fn span_ms(&self) -> Option<u64> {
390        let from = self.opened.as_deref()?;
391        let to = self.ended.as_deref()?;
392        forge_workspace::env::timezone::millis_between(from, to)
393    }
394
395    /// Whether the turn ran anything worth a row at all.
396    fn drew_work(&self) -> bool {
397        self.worked && self.span_ms().is_some()
398    }
399
400    /// Whether the turn's own last frame ended it rather than handing back
401    /// for a tool call. Only a turn closing at the end of a conversation
402    /// needs this: a turn the next prompt closed is over whatever its last
403    /// frame stopped for.
404    fn stopped_of_its_own_accord(&self) -> bool {
405        self.stopped.is_some_and(|stop| stop != StopReason::ToolUse)
406    }
407}
408
409/// Name the turn a transcript's own rows support, then start the next turn's
410/// trace. A turn the wire already settled is named by its own result frame and
411/// this one nothing, so the two never name one turn twice.
412///
413/// `known_over` is whether whatever closed the turn says so - the next prompt.
414/// A turn closing at the end of a conversation has nothing saying it, and
415/// needs its own last frame to have stopped rather than handed back for a
416/// tool call.
417fn close_traced(
418    trace: &mut TurnTrace,
419    known_over: bool,
420    keys: &mut HashMap<String, usize>,
421    turns: &mut [TurnSpan],
422    open: Option<usize>,
423) {
424    if trace.drew_work() && (known_over || trace.stopped_of_its_own_accord()) {
425        let key = take_key(keys, trace.opened.clone(), None);
426        name_open(turns, open, key);
427    }
428    *trace = TurnTrace::default();
429}
430
431/// What names a settled row: the instant the turn's own first row carried,
432/// which survives an insertion above it where a count of the rows before it
433/// does not.
434///
435/// Two turns can open on one clock - the CLI writes a command and its answer
436/// in the same millisecond - and the page keeps ONE map of the rows it has
437/// been told to open, so two rows sharing a name would open and close
438/// together. No transcript measured emits such a pair, and what stops one
439/// today is `drew_work`, a guard about a turn drawing rather than about its
440/// being named, so a name already taken in this fold takes an ordinal instead
441/// of resting on that. Deterministic for a fixed transcript, unique within
442/// one. The frame's own id stands in for a turn the fold placed no clock on,
443/// and a turn with neither is named by nothing rather than by someone else's
444/// name.
445fn take_key(
446    keys: &mut HashMap<String, usize>,
447    opened: Option<String>,
448    frame: Option<&str>,
449) -> Option<String> {
450    let base = opened.or_else(|| frame.map(str::to_owned))?;
451    let nth = keys.entry(base.clone()).or_insert(0);
452    *nth += 1;
453    Some(if *nth == 1 { base } else { format!("{base}#{nth}") })
454}
455
456/// Whether a queued block is the harness's background-completion notice
457/// rather than anything a person said.
458///
459/// The mode is the field that says which of the three kinds arrived, and
460/// the text's own prefix is the other signal. Both are read here, because a
461/// fold keyed on one of them drifts from the other the first time either moves.
462fn is_completion_notice(command_mode: Option<&str>, prompt: &serde_json::Value) -> bool {
463    command_mode == Some("task-notification") || is_task_notice(&queued_command_text(prompt))
464}
465
466/// What a persisted task ending says, which is what the live wire's
467/// `task_notification` frame says: the CLI writes the frame's fields as XML
468/// into the transcript instead.
469///
470/// Public because [`Rendered`] carries them: a caller building turns needs the
471/// endings the fold already read, rather than a second walk to find them.
472pub struct TaskEnding {
473    /// The call it ends, from the notice's `<tool-use-id>`.
474    pub call: String,
475    /// How the task ended. `None` for a word this does not know.
476    pub status: Option<ToolCallStatus>,
477    /// The harness's own sentence about the outcome.
478    pub summary: String,
479    /// The notice as the CLI wrote it, which a carried copy repeats verbatim.
480    pub text: String,
481    /// Where the notice sits in the conversation it was read from, which is
482    /// what says whether a turn already holds it.
483    pub at: usize,
484}
485
486/// The notice as the block a view reads an ending from: the shape the scan
487/// hoists an `attachment` row into, so both carriers reach a client as one
488/// thing rather than as two the client has to know about.
489pub(crate) fn notice_block(text: &str) -> ContentBlock {
490    ContentBlock::QueuedCommand {
491        prompt: serde_json::Value::String(text.to_owned()),
492        command_mode: Some("task-notification".to_owned()),
493        source_uuid: None,
494        extras: serde_json::Map::new(),
495    }
496}
497
498/// Every task notice the conversation holds as that one block, whichever
499/// carrier the CLI persisted it in.
500///
501/// The row itself is kept - it is the notice, and dropping it would be the
502/// silence rule 25 forbids - and only its shape changes, into the one a view
503/// already reads an ending from. What a view draws for it is nothing: the
504/// ending is drawn on the call's own row.
505pub(crate) fn notices_as_blocks(mut messages: Vec<Message>) -> Vec<Message> {
506    for message in &mut messages {
507        let Message::User { message: envelope, .. } = message else {
508            continue;
509        };
510        for block in &mut envelope.content {
511            if let ContentBlock::Text { text, .. } = block
512                && is_task_notice(text)
513            {
514                *block = notice_block(text);
515            }
516        }
517    }
518    messages
519}
520
521/// The ending a persisted notice carries, or `None` when the text is not one
522/// or names no call to end.
523fn task_ending(text: &str) -> Option<TaskEnding> {
524    if !is_task_notice(text) {
525        return None;
526    }
527    Some(TaskEnding {
528        call: tag(text, "tool-use-id")?.to_owned(),
529        status: tag(text, "status").and_then(task_status),
530        summary: tag(text, "summary").unwrap_or_default().to_owned(),
531        text: text.to_owned(),
532        at: 0,
533    })
534}
535
536/// The text between `<name>` and `</name>`, trimmed.
537fn tag<'a>(text: &'a str, name: &str) -> Option<&'a str> {
538    let (_, rest) = text.split_once(&format!("<{name}>"))?;
539    let (inside, _) = rest.split_once(&format!("</{name}>"))?;
540    Some(inside.trim())
541}
542
543/// A task's own status word, as the frame carrying it draws the call.
544///
545/// The same map a view applies to `task_updated.patch.status`: the wire
546/// says `running` where the row says in progress, and `stopped` is its word
547/// for a graceful cancel, which draws as the kill it is.
548///
549/// **An unrecognised word is `None`.** The word arrives here after the call
550/// is finished, so an unreadable word means the task ended without saying
551/// how, and drawing the call as pending again would walk it back down.
552fn task_status(word: &str) -> Option<ToolCallStatus> {
553    match word {
554        "running" => Some(ToolCallStatus::InProgress),
555        "completed" => Some(ToolCallStatus::Completed),
556        "failed" => Some(ToolCallStatus::Failed),
557        "killed" | "stopped" => Some(ToolCallStatus::Killed),
558        _ => None,
559    }
560}
561
562/// The text a `queued_command` block carries: a plain string for a typed
563/// prompt, or a content-block array for a multi-modal one, where only the
564/// text blocks are the words the user typed. An image or document is not
565/// text: a view draws it as an attachment row, from the block itself. Every
566/// other block renders as a `[type]` placeholder so the reader sees
567/// something rather than a blank.
568pub fn queued_command_text(prompt: &serde_json::Value) -> String {
569    if let Some(text) = prompt.as_str() {
570        return text.to_owned();
571    }
572    let Some(blocks) = prompt.as_array() else {
573        return serde_json::to_string(prompt).unwrap_or_else(|_| String::from("[unrenderable]"));
574    };
575    blocks
576        .iter()
577        .filter_map(|block| match block.get("type").and_then(serde_json::Value::as_str) {
578            Some("text") => {
579                block.get("text").and_then(serde_json::Value::as_str).map(str::to_owned)
580            }
581            // `image` and `document` draw in a view's attachment row, not as
582            // words.
583            Some("image" | "document") | None => None,
584            Some(other) => Some(format!("[{other}]")),
585        })
586        .collect::<Vec<String>>()
587        .join("\n")
588}
589
590/// Every task ending the conversation holds, by the call each names.
591///
592/// Both carriers, because the CLI writes the same notice two ways: an
593/// `attachment` row the scan hoists into a `queued_command` block, and a
594/// `user` row whose content string is the XML.
595///
596/// **A pre-pass, and that is the point of it.** The ending is frequently not
597/// in its call's turn: the notice row opens a turn of its own, and a delivery,
598/// a peer message or a person's next prompt can sit between the two, so a fold
599/// that read them in one pass could only see the endings that happened to
600/// follow their call closely. A pre-pass reads the whole conversation once,
601/// keyed by call, so the walk reads the answer wherever it needs it.
602///
603/// **The last ending naming a call wins**, the only rule that reads a repeated
604/// ending as the newer word rather than the older. It is load-bearing rather than
605/// hypothetical: ten of this machine's calls carry two notices. Nine of those
606/// pairs are the restart case, completed first and stopped second - written
607/// once a restart found no completion record - and one runs the other way, so
608/// the rule is what decides between them rather than the order the CLI
609/// happens to write.
610pub(crate) fn task_endings(messages: &[Message]) -> HashMap<String, TaskEnding> {
611    let mut out: HashMap<String, TaskEnding> = HashMap::new();
612    for (at, message) in messages.iter().enumerate() {
613        let Message::User { message: envelope, .. } = message else {
614            continue;
615        };
616        for block in &envelope.content {
617            let text = match block {
618                ContentBlock::Text { text, .. } => text.clone(),
619                ContentBlock::QueuedCommand { prompt, .. } => queued_command_text(prompt),
620                _ => continue,
621            };
622            if let Some(mut ending) = task_ending(&text) {
623                ending.at = at;
624                out.insert(ending.call.clone(), ending);
625            }
626        }
627    }
628    out
629}
630
631#[cfg(test)]
632mod tests {
633    use forge_primitives::{
634        AssistantEnvelope, ContentBlock, Message, ToolCallStatus, UserEnvelope,
635    };
636
637    use super::{Rendered, TaskEnding, render, settled, task_ending};
638
639    /// An assistant message carrying `content`.
640    fn assistant(content: Vec<ContentBlock>) -> Message {
641        Message::Assistant {
642            message: AssistantEnvelope {
643                id: "msg_01".to_owned(),
644                role: "assistant".to_owned(),
645                model: "claude-opus-4-5".to_owned(),
646                content,
647                stop_reason: None,
648                stop_sequence: None,
649                usage: None,
650                extras: serde_json::Map::new(),
651            },
652            session_id: "session".to_owned(),
653            parent_tool_use_id: None,
654            error: None,
655            uuid: None,
656            timestamp: None,
657            extras: serde_json::Map::new(),
658        }
659    }
660
661    /// One assistant message carrying a single tool call. `family` is the
662    /// word these tests speak in, mapped to the SDK name it stands for.
663    fn tool_call(family: &str) -> Message {
664        let sdk_name = match family {
665            "read" => "Read",
666            "search" => "Grep",
667            "bash" => "Bash",
668            "edit" => "Edit",
669            other => other,
670        };
671        assistant(vec![ContentBlock::ToolUse {
672            id: format!("toolu_{family}"),
673            name: sdk_name.to_owned(),
674            input: serde_json::json!({"file_path": "src/lib.rs", "command": "just check"}),
675            extras: serde_json::Map::new(),
676        }])
677    }
678
679    /// The same frame as it arrives from a sub-agent: the dispatch's own
680    /// tool-use id in `parent_tool_use_id`, which is how the wire names the
681    /// agent a frame belongs to.
682    fn dispatched(mut message: Message) -> Message {
683        match &mut message {
684            Message::Assistant { parent_tool_use_id, .. }
685            | Message::User { parent_tool_use_id, .. }
686            | Message::StopHookSummary { parent_tool_use_id, .. } => {
687                *parent_tool_use_id = Some("toolu_dispatch".to_owned());
688            }
689            other => panic!("no sub-agent produces a frame of this shape: {other:?}"),
690        }
691        message
692    }
693
694    /// A delivery is its own turn: the kinds the parser recognises - Gotify,
695    /// cron, Slack, a peer message and a delivery failure - each arrive as an
696    /// envelope row, and that row is where the delivery's turn opens. The row
697    /// still draws as its envelope - the cut is the fold's, the header the
698    /// client's - and a fold that opened no turn here joined a delivered
699    /// turn's exchange to the turn above it.
700    #[test]
701    fn an_external_envelope_opens_its_turn() {
702        let quiet = user(vec![ContentBlock::Text {
703            text: "[Gotify - app 'watcher', priority 3]\n\ndeploy finished".to_owned(),
704            extras: serde_json::Map::new(),
705        }]);
706        let loud = user(vec![ContentBlock::Text {
707            text: "[Gotify - app 'ci', priority 9]\n\nbuild failed".to_owned(),
708            extras: serde_json::Map::new(),
709        }]);
710        let slack = user(vec![ContentBlock::Text {
711            text: "[Slack - workspace 'Busytools', #forge] id ts\n\nsteward: the gate is green\n"
712                .to_owned(),
713            extras: serde_json::Map::new(),
714        }]);
715        let failed = user(vec![ContentBlock::Text {
716            text: "[Ask id=q-1 to agent 'companies' (org 'Busytools') failed to deliver: channel closed]"
717                .to_owned(),
718            extras: serde_json::Map::new(),
719        }]);
720
721        let rendered = settled(&[quiet, loud, slack, failed]);
722
723        assert_eq!(
724            rendered.turns.iter().map(|turn| turn.opens_at).collect::<Vec<_>>(),
725            vec![0, 1, 2, 3],
726            "a delivery, a fire and a failure each open a turn on their own row",
727        );
728    }
729
730    /// **A delivery pressed into a running turn stays in that turn.** The CLI
731    /// fuses a mid-turn write into the turn it interrupted, and the row lands
732    /// between the call and the result the call waits on. A turn opened there
733    /// cuts the call off from its answer, and every reader of the fold draws
734    /// it failed with no result (#2379).
735    #[test]
736    fn a_mid_call_envelope_stays_in_the_turn_it_was_pressed_into() {
737        let rendered = settled(&[
738            reader_row("despawn probe"),
739            despawn_call(),
740            user(vec![ContentBlock::Text {
741                text:
742                    "[Slack - workspace 'Busytools', #forge] id ts\n\nsteward: the gate is green\n"
743                        .to_owned(),
744                extras: serde_json::Map::new(),
745            }]),
746            tool_result("call_1", false),
747        ]);
748
749        assert_eq!(
750            rendered.turns.len(),
751            1,
752            "the delivery opens no turn of its own while the call is out",
753        );
754        assert_eq!(rendered.turns[0].opens_at, 0, "the turn still opens on the prompt row");
755    }
756
757    /// **A crash-shaped call does not latch the delivery rule.** The restart
758    /// shape (#1836) leaves a `tool_use` with no result and no `Result` frame,
759    /// so a settled fold cannot read that turn as running - the delivery after
760    /// it opens its own turn, as it does after any settled turn.
761    #[test]
762    fn a_delivery_after_a_crash_shaped_call_opens_its_turn() {
763        let rendered = settled(&[
764            reader_row("despawn probe"),
765            despawn_call(),
766            user(vec![ContentBlock::Text {
767                text:
768                    "[Slack - workspace 'Busytools', #forge] id ts\n\nsteward: the gate is green\n"
769                        .to_owned(),
770                extras: serde_json::Map::new(),
771            }]),
772        ]);
773
774        assert_eq!(
775            rendered.turns.len(),
776            2,
777            "the delivery opens a turn after the crash-shaped call"
778        );
779        assert_eq!(rendered.turns[1].opens_at, 2, "on the row it arrived in");
780    }
781
782    /// The live half of the same bound: until the call's result lands the list
783    /// cannot say the turn is still waiting, and the caller's flag does.
784    #[test]
785    fn a_live_turns_open_call_protects_the_envelope_too() {
786        let rendered = render(
787            &[
788                reader_row("despawn probe"),
789                despawn_call(),
790                user(vec![ContentBlock::Text {
791                    text: "[Slack - workspace 'Busytools', #forge] id ts\n\nsteward: the gate is green\n"
792                        .to_owned(),
793                    extras: serde_json::Map::new(),
794                }]),
795            ],
796            true,
797        );
798
799        assert_eq!(
800            rendered.turns.len(),
801            1,
802            "the delivery stays in the turn the caller reports live",
803        );
804    }
805
806    /// The CLI's interrupt marker opens no turn (#1323): it is a `user` row,
807    /// and it arrives INSIDE the turn it ended - the in-flight call, then the
808    /// marker, then the failing `Result` - so a turn opened for it lands the
809    /// report and the failure line in a turn holding no call.
810    #[test]
811    fn the_interrupt_marker_opens_no_turn() {
812        let prompt = user(vec![ContentBlock::Text {
813            text: "run the checks".to_owned(),
814            extras: serde_json::Map::new(),
815        }]);
816        let call = tool_call("bash");
817        let marker = user(vec![ContentBlock::Text {
818            text: "[Request interrupted by user]".to_owned(),
819            extras: serde_json::Map::new(),
820        }]);
821
822        let rendered = settled(&[prompt, call, marker]);
823
824        assert_eq!(
825            rendered.turns.len(),
826            1,
827            "the marker draws inside the turn it ended, not as one of its own",
828        );
829
830        // The prefix alone is not the marker: a truncated row is some other
831        // text and opens a turn as one.
832        let truncated = user(vec![ContentBlock::Text {
833            text: "[Request interrupted by user".to_owned(),
834            extras: serde_json::Map::new(),
835        }]);
836        let rendered = settled(&[
837            user(vec![ContentBlock::Text {
838                text: "run the checks".to_owned(),
839                extras: serde_json::Map::new(),
840            }]),
841            tool_call("bash"),
842            truncated,
843        ]);
844        assert_eq!(rendered.turns.len(), 2, "an unterminated prefix is not the marker");
845    }
846
847    /// A user turn carrying `content`.
848    fn user(content: Vec<ContentBlock>) -> Message {
849        Message::User {
850            message: UserEnvelope {
851                role: "user".to_owned(),
852                content,
853                extras: serde_json::Map::new(),
854            },
855            session_id: "session".to_owned(),
856            parent_tool_use_id: None,
857            uuid: None,
858            tool_use_result: None,
859            timestamp: None,
860            synthetic: false,
861            extras: serde_json::Map::new(),
862        }
863    }
864
865    /// The user's mid-turn queued prompt, as the scan hoists it out of the
866    /// transcript's attachment row.
867    fn queued_prompt(text: &str) -> Message {
868        user(vec![ContentBlock::QueuedCommand {
869            prompt: serde_json::Value::String(text.to_owned()),
870            command_mode: Some("prompt".to_owned()),
871            source_uuid: None,
872            extras: serde_json::Map::new(),
873        }])
874    }
875
876    /// The result the tool at `tool_use_id` came back with.
877    fn tool_result(tool_use_id: &str, is_error: bool) -> Message {
878        user(vec![ContentBlock::ToolResult {
879            tool_use_id: tool_use_id.to_owned(),
880            content: serde_json::Value::String("output".to_owned()),
881            is_error,
882            extras: serde_json::Map::new(),
883        }])
884    }
885
886    /// A user's mid-turn prompt reached the page. On the read path it has
887    /// no other shape: the scan hoists the queued row into a user envelope
888    /// so a reader can rebuild the bubble, and a fold that ignores the
889    /// block drops the question the assistant went on to answer.
890    #[test]
891    fn a_queued_prompt_opens_a_turn_of_its_own() {
892        let rendered = settled(&[queued_prompt("and keep the multiSelect case too")]);
893
894        assert_eq!(rendered.turns.len(), 1, "the queued prompt opens one turn");
895        assert_eq!(rendered.turns[0].opens_at, 0, "at the row the prompt arrived in");
896    }
897
898    /// The reader's own row, which opens the turn.
899    fn reader_row(text: &str) -> Message {
900        user(vec![ContentBlock::Text { text: text.to_owned(), extras: serde_json::Map::new() }])
901    }
902
903    /// The same row under an id forge minted for a send or a dispatch.
904    fn forge_row(uuid: &str, text: &str) -> Message {
905        let mut row = reader_row(text);
906        if let Message::User { uuid: id, .. } = &mut row {
907            *id = Some(uuid.to_owned());
908        }
909        row
910    }
911
912    /// The call a running turn is waiting on.
913    fn despawn_call() -> Message {
914        assistant(vec![ContentBlock::ToolUse {
915            id: "call_1".to_owned(),
916            name: "mcp__forge__agents__despawn".to_owned(),
917            input: serde_json::json!({ "label": "probe" }),
918            extras: serde_json::Map::new(),
919        }])
920    }
921
922    /// **A forge-minted row pressed into a running turn stays in that turn.**
923    ///
924    /// The CLI delivers a mid-turn write into the running turn, and the board
925    /// chase's notice while an `agents__despawn` is out is the everyday one:
926    /// the transcript shows it between the call and the result the call waits
927    /// on. A turn opened for it cuts the call off from its answer, and every
928    /// reader of the fold - a page, a re-attach, a transcript read - draws the
929    /// despawn failed with no result while it came back cleanly (#2379).
930    #[test]
931    fn a_mid_call_forge_row_stays_in_the_turn_it_was_pressed_into() {
932        let rendered = settled(&[
933            reader_row("despawn probe"),
934            despawn_call(),
935            forge_row(
936                "forge_nudge_38_d56ff2d2",
937                "task board: \"probe holds no row\" has no live owner",
938            ),
939            tool_result("call_1", false),
940        ]);
941
942        assert_eq!(
943            rendered.turns.len(),
944            1,
945            "the notice opens no turn of its own while the call is out",
946        );
947        assert_eq!(rendered.turns[0].opens_at, 0, "the turn still opens on the prompt row");
948    }
949
950    /// The same marker on the queued carrier, which rides `source_uuid`.
951    #[test]
952    fn a_mid_call_queued_write_stays_too() {
953        let mut notice = queued_prompt("task board: \"probe holds no row\" has no live owner");
954        if let Message::User { message, .. } = &mut notice
955            && let Some(ContentBlock::QueuedCommand { source_uuid, .. }) =
956                message.content.first_mut()
957        {
958            *source_uuid = Some("forge_nudge_38_d56ff2d2".to_owned());
959        }
960        let rendered = settled(&[
961            reader_row("despawn probe"),
962            despawn_call(),
963            notice,
964            tool_result("call_1", false),
965        ]);
966
967        assert_eq!(rendered.turns.len(), 1, "the queued write opens no turn either");
968    }
969
970    /// The same row at a settled turn opens one, as the reader's own rows do:
971    /// the call's result has landed, so nothing is being waited on.
972    #[test]
973    fn a_forge_row_at_a_settled_turn_opens_one() {
974        let rendered = settled(&[
975            reader_row("despawn probe"),
976            despawn_call(),
977            tool_result("call_1", false),
978            forge_row(
979                "forge_nudge_38_d56ff2d2",
980                "task board: \"probe holds no row\" has no live owner",
981            ),
982        ]);
983
984        assert_eq!(rendered.turns.len(), 2, "a settled turn's notice opens its own");
985        assert_eq!(rendered.turns[1].opens_at, 3, "on the row it arrived in");
986    }
987
988    /// **A call whose result has not streamed yet is still a live turn.**
989    ///
990    /// The live fold runs over the incrementally-grown window, and during
991    /// every in-flight call the result is not in the list yet - so a bound
992    /// read off the list alone stops protecting exactly the window #2379 is
993    /// about, and the notice opens its own turn until a later fold heals it.
994    /// The caller's flag is what tells that apart from the crash shape, and it
995    /// is the delivered signal: the init/result ladder the transport derives
996    /// from the seat's own frames.
997    #[test]
998    fn a_live_turns_open_call_is_protected_before_its_result_lands() {
999        let rendered = render(
1000            &[
1001                reader_row("despawn probe"),
1002                despawn_call(),
1003                forge_row(
1004                    "forge_nudge_38_d56ff2d2",
1005                    "task board: \"probe holds no row\" has no live owner",
1006                ),
1007            ],
1008            true,
1009        );
1010
1011        assert_eq!(
1012            rendered.turns.len(),
1013            1,
1014            "the notice stays in the turn the caller reports live, with no result on the list yet",
1015        );
1016    }
1017
1018    /// **A call whose result never comes does not latch the delivery rule.**
1019    ///
1020    /// The restart shape (#1836): the transcript keeps a `tool_use` with no
1021    /// `tool_result`, and no `Result` frame either - a transcript holds none -
1022    /// so a guard keyed on "a call is out" alone would read that turn as
1023    /// running for the rest of the conversation and swallow every forge row
1024    /// into one crash turn: the auto-continue a restarted seat gets, every
1025    /// nudge, every queued write.
1026    #[test]
1027    fn a_call_whose_result_never_comes_does_not_hold_the_next_turn_shut() {
1028        let rendered = settled(&[
1029            reader_row("run the checks"),
1030            despawn_call(),
1031            forge_row("forge_prompt_9_aaaaaaaa", "continue where you left off"),
1032        ]);
1033
1034        assert_eq!(rendered.turns.len(), 2, "the later forge row opens its own turn");
1035        assert_eq!(rendered.turns[1].opens_at, 2, "on its own row, after the crash turn");
1036    }
1037
1038    /// The harness's background-completion notice is not a turn.
1039    ///
1040    /// It reaches the fold as the same `queued_command` block a prompt does -
1041    /// the scan hoists the attachment row without reading its mode - and a
1042    /// turn opened for it is a row nothing fills. The page draws that as a
1043    /// blank line between two turns, which is the defect #1324 names; the
1044    /// assertion is about the CUT, because a fold that dropped the text alone
1045    /// would still leave the empty turn standing.
1046    ///
1047    /// **Two signals, and each is pinned alone.** Across 10,148 queued blocks
1048    /// in this machine's transcripts, 3,113 carry the mode, 3,113 open with
1049    /// the tag, and none disagrees - so the pair looks redundant, and a row
1050    /// carrying both could not tell a two-signal guard from a one-signal one.
1051    /// A skill's body arrives as the reader's own user row, right after the
1052    /// call that loaded it - and a page read can cut the two apart. The body
1053    /// belongs in the call's turn, where the call's row is what tells the
1054    /// story: a turn of its own is the same thing said twice under the
1055    /// reader's name, and the web client pairs the two by name whatever their
1056    /// turns say.
1057    #[test]
1058    fn a_skill_body_stays_in_the_turn_whose_call_loaded_it() {
1059        let call = |want: &str| {
1060            assistant(vec![ContentBlock::ToolUse {
1061                id: format!("toolu_{want}"),
1062                name: "Skill".to_owned(),
1063                input: serde_json::json!({ "skill": want }),
1064                extras: serde_json::Map::new(),
1065            }])
1066        };
1067        let body = |path: &str| {
1068            user(vec![ContentBlock::Text {
1069                text: format!("Base directory for this skill: {path}\n\n# The skill\n\nDo it."),
1070                extras: serde_json::Map::new(),
1071            }])
1072        };
1073        let prompt = || {
1074            user(vec![ContentBlock::Text { text: "go".to_owned(), extras: serde_json::Map::new() }])
1075        };
1076
1077        let rendered =
1078            settled(&[prompt(), call("unslop"), body("/Users/ved/.claude/skills/unslop")]);
1079        assert_eq!(rendered.turns.len(), 1, "the body opens no turn of its own");
1080
1081        // The plugin spelling: the call says `a:b` where the path ends `b`.
1082        let cached = settled(&[
1083            prompt(),
1084            call("ui-ux-pro-max:ui-ux-pro-max"),
1085            body("/Users/ved/.claude/plugins/cache/x/ui-ux-pro-max/2.13.0"),
1086        ]);
1087        assert_eq!(cached.turns.len(), 1, "a versioned plugin path still matches its call");
1088
1089        // The control: a body no call claimed opens a turn, so nothing is lost.
1090        let orphan = settled(&[prompt(), body("/Users/ved/.claude/skills/other")]);
1091        assert_eq!(orphan.turns.len(), 2, "an unclaimed body opens a turn as it always did");
1092
1093        // The carrier a tool-invoked skill uses: the skill's own markdown,
1094        // named only by its title heading.
1095        let titled = settled(&[
1096            prompt(),
1097            call("pr-review-loop"),
1098            user(vec![ContentBlock::Text {
1099                text: "# PR Review Loop\n\nReview a change with parallel reviewers.".to_owned(),
1100                extras: serde_json::Map::new(),
1101            }]),
1102        ]);
1103        assert_eq!(titled.turns.len(), 1, "a titled body stays in its call's turn");
1104
1105        // The harness's line about an image it read: it belongs behind the
1106        // call that read it, where the view hangs it on that call's row.
1107        let note = user(vec![ContentBlock::Text {
1108            text: "[Image: original 2782x1034, displayed at 2000x743. Multiply coordinates by 1.39 to map to original image.]".to_owned(),
1109            extras: serde_json::Map::new(),
1110        }]);
1111        let pictured = settled(&[prompt(), tool_call("read"), note.clone()]);
1112        assert_eq!(pictured.turns.len(), 1, "the image line opens no turn of its own");
1113
1114        // The control: with no call in the turn it opens one, so nothing is lost.
1115        let loose = settled(&[prompt(), note]);
1116        assert_eq!(loose.turns.len(), 2, "with no call to join it opens a turn");
1117    }
1118
1119    /// The continuation prompt a compaction leaves behind arrives as the
1120    /// reader's own row, right after the boundary frame. It belongs in the
1121    /// boundary's turn - the web client hangs it on that row - and a prompt
1122    /// with no boundary opens a turn as it always did, so nothing is lost.
1123    #[test]
1124    fn a_continuation_prompt_stays_in_the_boundarys_turn() {
1125        let boundary = || Message::CompactBoundary {
1126            trigger: "auto".to_owned(),
1127            pre_tokens: 68_031,
1128            post_tokens: 9_149,
1129            uuid: "cb-1".to_owned(),
1130            session_id: "session".to_owned(),
1131            metadata_extras: serde_json::Map::new(),
1132            extras: serde_json::Map::new(),
1133        };
1134        let summary = user(vec![ContentBlock::Text {
1135            text: "This session is being continued from a previous conversation that ran out of context. And so on.".to_owned(),
1136            extras: serde_json::Map::new(),
1137        }]);
1138        let prompt = || {
1139            user(vec![ContentBlock::Text { text: "go".to_owned(), extras: serde_json::Map::new() }])
1140        };
1141
1142        let rendered = settled(&[prompt(), boundary(), summary.clone()]);
1143        assert_eq!(rendered.turns.len(), 1, "the prompt opens no turn of its own");
1144
1145        // The control: no boundary in the turn, so it opens one as it did.
1146        let orphan = settled(&[prompt(), summary]);
1147        assert_eq!(orphan.turns.len(), 2, "a prompt with no boundary still opens a turn");
1148    }
1149
1150    #[test]
1151    fn a_notification_row_opens_no_turn() {
1152        let by_mode = user(vec![ContentBlock::QueuedCommand {
1153            prompt: serde_json::Value::String("Task bj5g0t2kq done".to_owned()),
1154            command_mode: Some("task-notification".to_owned()),
1155            source_uuid: None,
1156            extras: serde_json::Map::new(),
1157        }]);
1158        let by_tag = user(vec![ContentBlock::QueuedCommand {
1159            prompt: serde_json::Value::String(
1160                "<task-notification>Task bj5g0t2kq completed</task-notification>".to_owned(),
1161            ),
1162            command_mode: Some("prompt".to_owned()),
1163            source_uuid: None,
1164            extras: serde_json::Map::new(),
1165        }]);
1166
1167        for (notice, signal) in [(by_mode, "the mode"), (by_tag, "the tag")] {
1168            let rendered = settled(&[notice]);
1169            assert!(rendered.turns.is_empty(), "a completion notice opens no turn: {signal}");
1170        }
1171    }
1172
1173    /// **The window's turn scan names no frame the fold opens no turn at.**
1174    ///
1175    /// The window cuts its oldest edge on a turn's first frame, and it reads
1176    /// the fold's own row predicates to find one - so the two walks have to
1177    /// agree on the direction that matters: a frame the scan named and the fold
1178    /// drew as something else would start the window inside a turn, which is
1179    /// the whole thing the window's front is for.
1180    ///
1181    /// The other direction is where they are meant to differ: a queued prompt
1182    /// and a delivery's envelope row open a turn in the fold, and the scan
1183    /// leaves both alone - the queued prompt is the one this fixture pins.
1184    #[test]
1185    fn the_windows_turn_scan_names_no_frame_the_fold_opens_no_turn_at() {
1186        let text = |text: &str| {
1187            user(vec![ContentBlock::Text { text: text.to_owned(), extras: serde_json::Map::new() }])
1188        };
1189        let call = |name: &str, input: serde_json::Value, id: &str| {
1190            assistant(vec![ContentBlock::ToolUse {
1191                id: id.to_owned(),
1192                name: name.to_owned(),
1193                input,
1194                extras: serde_json::Map::new(),
1195            }])
1196        };
1197        let boundary = Message::CompactBoundary {
1198            trigger: "auto".to_owned(),
1199            pre_tokens: 68_031,
1200            post_tokens: 9_149,
1201            uuid: "cb-1".to_owned(),
1202            session_id: "session".to_owned(),
1203            metadata_extras: serde_json::Map::new(),
1204            extras: serde_json::Map::new(),
1205        };
1206        let continued = "This session is being continued from a previous conversation";
1207        let messages = vec![
1208            text("hello"),
1209            call("Skill", serde_json::json!({ "skill": "pr-review-loop" }), "toolu_1"),
1210            text("Base directory for this skill: /x/pr-review-loop\n\n# T\n\nDo it."),
1211            text(continued),
1212            text("<task-notification><tool-use-id>tu1</tool-use-id></task-notification>"),
1213            text("[Cron]\n\ndo the thing"),
1214            call("Bash", serde_json::json!({ "command": "ls" }), "toolu_2"),
1215            text("[Image: original 2782x1034, displayed at 2000x743.]"),
1216            queued_prompt("queued mid-turn"),
1217            boundary.clone(),
1218            text(continued),
1219            text("second prompt"),
1220            // The state a turn opens under does not straddle the open: a claim
1221            // an earlier turn made, a boundary it took and a call it made are
1222            // all gone by the next turn's own first frame. Each shape below is
1223            // one a stale flag would swallow - a body the claim no longer
1224            // holds, an image note behind no call, a continuation behind no
1225            // boundary - so dropping any one of the three resets loses a head.
1226            call("Skill", serde_json::json!({ "skill": "pr-review-loop" }), "toolu_3"),
1227            text("third prompt"),
1228            text("Base directory for this skill: /x/pr-review-loop\n\n# T\n\nDo it."),
1229            call("Bash", serde_json::json!({ "command": "ls" }), "toolu_4"),
1230            text("fourth prompt"),
1231            text("[Image: original 2782x1034, displayed at 2000x743.]"),
1232            boundary,
1233            text("fifth prompt"),
1234            text(continued),
1235            // The carriers and non-heads the two walks must agree about: a
1236            // titled skill body behind its own call, a body no call holds, a
1237            // result row, and a sub-agent's frame.
1238            call("Skill", serde_json::json!({ "skill": "pr-review-loop" }), "toolu_5"),
1239            text("# PR Review Loop\n\nReview a change with parallel reviewers."),
1240            text("Base directory for this skill: /x/other\n\n# T\n\nDo it."),
1241            tool_result("tu1", false),
1242            dispatched(text("a sub-agent's row")),
1243            text("sixth prompt"),
1244            // The interrupt marker's own carriers (#1323): the plain row the
1245            // CLI writes, and one with leading whitespace, which the fold's
1246            // trimmed read accepts - the scan must read both as no-heads, or
1247            // the window could cut inside the turn the marker stays in.
1248            text("[Request interrupted by user]"),
1249            text("\n[Request interrupted by user]"),
1250            // **A forge-minted notice delivered mid-call** (#2379): the CLI
1251            // presses it into the running turn, and the fold keeps it there -
1252            // so the scan must name it too, or the window could start inside
1253            // the turn whose call is still out.
1254            call("Bash", serde_json::json!({ "command": "ls" }), "toolu_9"),
1255            forge_row("forge_nudge_9_deadbeef", "task board: a row lost its owner"),
1256            tool_result("toolu_9", false),
1257        ];
1258
1259        let opened: Vec<usize> =
1260            settled(&messages).turns.iter().map(|span| span.opens_at).collect();
1261        let mut scan = forge_workspace::conversation_turns::TurnScan::default();
1262        let named: Vec<usize> = messages
1263            .iter()
1264            .enumerate()
1265            .filter(|(_, message)| scan.opens(message))
1266            .map(|(at, _)| at)
1267            .collect();
1268
1269        assert_eq!(
1270            named,
1271            vec![0, 3, 11, 13, 14, 16, 17, 19, 20, 23, 26],
1272            "precondition: the scan names the fixture's own prompts - each state-reset frame \
1273             included - and the suppressions hold",
1274        );
1275        assert!(
1276            named.iter().all(|at| opened.contains(at)),
1277            "every frame the window would cut at is one the fold opens a turn at: \
1278             scan {named:?}, fold {opened:?}",
1279        );
1280        assert!(
1281            opened.contains(&8) && !named.contains(&8),
1282            "and the queued prompt is where they are meant to differ, the scan leaving it alone: \
1283             scan {named:?}, fold {opened:?}",
1284        );
1285        assert!(
1286            !named.contains(&27) && !named.contains(&28),
1287            "the interrupt marker opens no turn in either walk, leading whitespace included: \
1288             scan {named:?}",
1289        );
1290        assert!(
1291            !opened.contains(&27) && !opened.contains(&28),
1292            "and the fold keeps both inside the turn it ended: fold {opened:?}",
1293        );
1294        // A forge-minted notice delivered at an open call (#2379) is the
1295        // forbidden direction this fixture exists for: the fold keeps it in
1296        // the running turn, so a scan naming it would cut the window inside
1297        // that turn.
1298        assert!(
1299            !opened.contains(&30),
1300            "the fold keeps the mid-call notice inside the turn whose call is out: fold {opened:?}",
1301        );
1302        assert!(!named.contains(&30), "and the scan names no head there either: scan {named:?}");
1303    }
1304
1305    /// A persisted notice IS an ending, and it says the same three things the
1306    /// live wire's `task_notification` frame says: which call it ends, how it
1307    /// ended, and the harness's own sentence.
1308    ///
1309    /// Read off a real row rather than a typed one, because the parser is the
1310    /// thing under test and a notice written to match a parser proves only
1311    /// that it matches itself.
1312    #[test]
1313    fn a_persisted_notice_is_read_as_the_ending_it_carries() {
1314        let row: serde_json::Value =
1315            serde_json::from_str(crate::fixtures::SAME_TURN_USER_ROW[2]).expect("a real row");
1316        let text = row["message"]["content"].as_str().expect("the notice's own text");
1317
1318        let ending = task_ending(text).expect("a task notice");
1319        assert_eq!(ending.call, "call_da4c7ee117d14d48ba99e036", "the call it ends");
1320        assert_eq!(ending.status, Some(ToolCallStatus::Failed), "how it ended");
1321        assert_eq!(
1322            ending.summary,
1323            "Background command \"Watch the account-lifecycle CI run\" failed with exit code 1",
1324            "and what the harness said about it",
1325        );
1326    }
1327
1328    /// A transcript seeded from `rows`, walked the way a view walks it: the
1329    /// scan, the replay synthesiser and the fold. The rows are the ones a
1330    /// test names, and they are real ones - a fold driven from hand-built
1331    /// frames would agree with itself about a shape the CLI does not write.
1332    fn folded(rows: &[&str]) -> Rendered {
1333        let fleet =
1334            crate::testing::Fleet::new(&[("TestOrg", &["proj"])]).expect("the fleet builds");
1335        fleet.seed_transcript("TestOrg", "proj", "lead", rows).expect("the transcript seeds");
1336        fleet.install_agent("TestOrg", "proj", "lead");
1337        let seat = forge_primitives::SessionSlot::lead("TestOrg", "proj");
1338        let surface = fleet.surface();
1339        let cwd = surface.roster().cwd_for(&seat).expect("the seat has a directory");
1340        settled(&surface.conversation(&seat, &cwd).messages)
1341    }
1342
1343    /// The ending the fold read for `call`.
1344    fn ending_for<'a>(rendered: &'a Rendered, call: &str) -> &'a TaskEnding {
1345        rendered.endings.get(call).unwrap_or_else(|| panic!("an ending naming {call}"))
1346    }
1347
1348    /// A transcript's own messages, as the read hands them to the fold BEFORE
1349    /// a view's normalisation: `session_history` is the read the spawn and the
1350    /// surface perform, and the surface's rewrite of the carriers is one
1351    /// caller's step rather than part of the read itself.
1352    fn read_raw(rows: &[&str]) -> Vec<Message> {
1353        let dir = tempfile::tempdir().expect("tempdir");
1354        let projects = dir.path().join("projects").join("any-project-key");
1355        std::fs::create_dir_all(&projects).expect("the project dir");
1356        // A uuid, because the read refuses a name that is not one; it walks
1357        // every project dir for the file, so no key has to be derived.
1358        let session = "b095cf6c-1be5-4337-9965-0dc6e46f6b57";
1359        std::fs::write(projects.join(format!("{session}.jsonl")), rows.join("\n"))
1360            .expect("the transcript writes");
1361        forge_workspace::session_history(dir.path(), session, "").messages
1362    }
1363
1364    /// The notice a task ending arrives as, carrying `status` and `summary`
1365    /// for `call`.
1366    ///
1367    /// Written rather than read off disk, because the rules these pin are
1368    /// about inputs the corpus does not hold: an ending whose status word
1369    /// nothing knows, and one that disagrees with the result beside it.
1370    fn notice(call: &str, status: &str, summary: &str) -> Message {
1371        user(vec![ContentBlock::Text {
1372            text: format!(
1373                "<task-notification>\n<tool-use-id>{call}</tool-use-id>\n\
1374                 <status>{status}</status>\n<summary>{summary}</summary>\n</task-notification>"
1375            ),
1376            extras: serde_json::Map::new(),
1377        }])
1378    }
1379
1380    /// The second carrier opens no turn either.
1381    ///
1382    /// It is the same notice the attachment holds, and a fold that reads one
1383    /// and not the other leaves the raw XML drawn as the reader's own words:
1384    /// 1,200 rows of it across this machine's 2.1.280 transcripts, with the
1385    /// task id and the output path in the text.
1386    #[test]
1387    fn the_user_rows_notice_opens_no_turn() {
1388        let rendered = folded(crate::fixtures::SAME_TURN_USER_ROW);
1389
1390        assert!(
1391            rendered.turns.is_empty(),
1392            "a notice the CLI persisted as a user row opens no turn of its own",
1393        );
1394    }
1395
1396    /// An ending ends the call it names, carrying the harness's own sentence.
1397    ///
1398    /// Three slices, one property each time: the text carrier and the
1399    /// attachment carrier, one where a turn of its own sits between the notice
1400    /// and its call, and one where the call is a dispatched agent - 699 of
1401    /// this machine's 2,194 paired endings name an `Agent` rather than a
1402    /// backgrounded command.
1403    ///
1404    /// The launch's own result says the task is running and is not an error,
1405    /// so a fold that settled the call on it would read a failed or killed
1406    /// task as completed - which is why the notice's own word is what the
1407    /// ending carries.
1408    #[test]
1409    fn an_ending_ends_the_call_it_names() {
1410        for (rows, call, status, said) in [
1411            (
1412                crate::fixtures::SAME_TURN_USER_ROW,
1413                "call_da4c7ee117d14d48ba99e036",
1414                ToolCallStatus::Failed,
1415                "Background command \"Watch the account-lifecycle CI run\" failed with exit code 1",
1416            ),
1417            (
1418                crate::fixtures::CROSS_TURN_ATTACHMENT,
1419                "call_17c05f64497746b0ac450728",
1420                ToolCallStatus::Failed,
1421                "Background command \"Restart the harness with a long terminate window\" failed with exit code 100",
1422            ),
1423            (
1424                crate::fixtures::KILLED_AGENT,
1425                "toolu_013WM27Lt98mvnUq573pXxYD",
1426                ToolCallStatus::Killed,
1427                "Agent \"Run slow counting loop\" was stopped by user",
1428            ),
1429        ] {
1430            let rendered = folded(rows);
1431            let ending = ending_for(&rendered, call);
1432
1433            assert_eq!(ending.status, Some(status), "the notice's own word ends the call");
1434            assert_eq!(ending.summary, said, "and what the harness said rides it");
1435        }
1436    }
1437
1438    /// Two endings naming one call: the later word is the one that stands.
1439    ///
1440    /// The pair is a real one, twelve minutes apart: the first notice says the
1441    /// command completed, and the second - written after a restart found no
1442    /// completion record - says it stopped. Ten of this machine's calls carry
1443    /// two notices, so the rule decides drawings rather than a hypothetical,
1444    /// and a first-wins fold draws this call completed when it stopped. The
1445    /// rule is the one the results pre-pass already keeps, where a second
1446    /// result for a call replaces the first.
1447    #[test]
1448    fn the_later_ending_naming_one_call_wins() {
1449        let rendered = folded(crate::fixtures::DUPLICATE_ENDINGS);
1450        let ending = ending_for(&rendered, "call_370cb30e2834491daf5bb4e2");
1451
1452        assert_eq!(ending.status, Some(ToolCallStatus::Killed), "the second notice's word");
1453        assert_eq!(
1454            ending.summary,
1455            "Background shell command didn't finish before the previous session ended",
1456            "and the sentence it carried",
1457        );
1458        assert!(
1459            !ending.text.contains("completed (exit code 0)"),
1460            "not the first notice's word: {}",
1461            ending.text,
1462        );
1463    }
1464
1465    /// The fold reads the raw carrier too - a message list taken straight off
1466    /// a transcript, before any view's normalisation.
1467    ///
1468    /// **A guard for the fold's own contract, not dead weight.** `render` is
1469    /// public and takes the CLI's own messages; the surface's rewrite of the
1470    /// carriers into one block is one caller's step, not part of the read. A
1471    /// fold that read only the normalised shape would leave the second,
1472    /// quieter contract - a caller handing it a transcript - drawing the raw
1473    /// XML as the reader's own turn.
1474    #[test]
1475    fn a_raw_transcripts_notice_is_read() {
1476        let rendered = settled(&read_raw(crate::fixtures::SAME_TURN_USER_ROW));
1477
1478        assert!(rendered.turns.is_empty(), "the row the CLI wrote opens no turn of its own");
1479        assert_eq!(
1480            ending_for(&rendered, "call_da4c7ee117d14d48ba99e036").status,
1481            Some(ToolCallStatus::Failed),
1482            "and the ending it carries still names the call it ends",
1483        );
1484    }
1485
1486    /// How an ending's own word reads as a status.
1487    ///
1488    /// **The rule worth reading before this map moves.** An unrecognised word
1489    /// is no status at all rather than `Pending`: a task whose ending forge
1490    /// cannot read is one it does not know has ended, and pending is a claim
1491    /// that it did not stop. How the word meets the status a call's own result
1492    /// already gave it is the client's fold, over the same wire word.
1493    #[test]
1494    fn an_endings_word_reads_as_a_status() {
1495        let launch = assistant(vec![ContentBlock::ToolUse {
1496            id: "toolu_ending".to_owned(),
1497            name: "Bash".to_owned(),
1498            input: serde_json::json!({"command": "just check"}),
1499            extras: serde_json::Map::new(),
1500        }]);
1501        let failed = tool_result("toolu_ending", true);
1502
1503        for (word, expected) in
1504            [("reticulating", None), ("completed", Some(ToolCallStatus::Completed))]
1505        {
1506            let rendered = settled(&[
1507                launch.clone(),
1508                failed.clone(),
1509                notice("toolu_ending", word, "what the harness said"),
1510            ]);
1511            assert_eq!(
1512                ending_for(&rendered, "toolu_ending").status,
1513                expected,
1514                "the ending's word is {word}",
1515            );
1516        }
1517    }
1518
1519    /// A sub-agent's frames are not the chat's. A view suppresses
1520    /// them - dropping a parented frame's text and thinking and
1521    /// scoping its calls to the SUBAGENTS inspector - and the fold says the
1522    /// same in one line. The control rides in the same test: the same frame
1523    /// without a parent id IS the conversation, so a fold that dropped every
1524    /// frame could not pass this.
1525    #[test]
1526    fn a_sub_agents_frames_are_not_the_chat() {
1527        let prompt = || {
1528            user(vec![ContentBlock::Text {
1529                text: "run the tests".to_owned(),
1530                extras: serde_json::Map::new(),
1531            }])
1532        };
1533
1534        assert!(
1535            settled(&[dispatched(prompt())]).turns.is_empty(),
1536            "a sub-agent's prompt opens no turn in the parent's chat",
1537        );
1538        assert_eq!(
1539            settled(&[prompt()]).turns.len(),
1540            1,
1541            "and the same frame is a turn when the agent is the session's own",
1542        );
1543
1544        // An empty id names no dispatch, and a view reads the field
1545        // the same way: a frame carrying one is the session's own.
1546        let mut blank = prompt();
1547        if let Message::User { parent_tool_use_id, .. } = &mut blank {
1548            *parent_tool_use_id = Some(String::new());
1549        }
1550        assert_eq!(settled(&[blank]).turns.len(), 1, "an empty id is not a dispatch");
1551
1552        // A parent that names nothing the session dispatched: the guard
1553        // reads the field rather than looking the id up, and the wire has
1554        // frames parented to a uuid with no dispatch behind it at all.
1555        let mut stray = prompt();
1556        if let Message::User { parent_tool_use_id, .. } = &mut stray {
1557            *parent_tool_use_id = Some("8f3c1d20-0a4b-4c6e-9d21-77e5a0b3c914".to_owned());
1558        }
1559        assert!(
1560            settled(&[stray]).turns.is_empty(),
1561            "a parent that names no dispatch still suppresses the frame",
1562        );
1563    }
1564
1565    /// Peer traffic on the wire: an envelope that arrived is where the turn
1566    /// the CLI ran for it opens, and a call the session made opens none. The
1567    /// row still draws as the envelope - the header is the view's, not the
1568    /// reader's own words.
1569    #[test]
1570    fn a_peer_envelope_opens_its_turn() {
1571        let arrived = user(vec![ContentBlock::Text {
1572            text: "[Message id=m-1 from agent 'companies' (org 'Busytools')]\n\nis the cron issue filed?"
1573                .to_owned(),
1574            extras: serde_json::Map::new(),
1575        }]);
1576        let sent = assistant(vec![ContentBlock::ToolUse {
1577            id: "toolu_ask".to_owned(),
1578            name: "mcp__forge__agents__send_message".to_owned(),
1579            input: serde_json::json!({"project": "forge", "message": "did it land?"}),
1580            extras: serde_json::Map::new(),
1581        }]);
1582
1583        assert_eq!(
1584            settled(&[arrived]).turns.len(),
1585            1,
1586            "the envelope that arrived opens the turn it ran",
1587        );
1588        assert!(
1589            settled(&[sent]).turns.is_empty(),
1590            "and the call the session made opens none either",
1591        );
1592    }
1593}
1594
1595/// One policy for the text a `queued_command` block carries: the fold
1596/// above reads it, and so does a view's own message walker.
1597#[cfg(test)]
1598mod queued_command_tests {
1599    use super::queued_command_text;
1600    use serde_json::json;
1601
1602    #[test]
1603    fn plain_string_prompt_round_trips() {
1604        let prompt = json!("Q1, let's give.");
1605        assert_eq!(queued_command_text(&prompt), "Q1, let's give.");
1606    }
1607
1608    #[test]
1609    fn multi_block_prompt_concatenates_text_blocks() {
1610        // An image or document is drawn by a view's own attachment row from
1611        // the block it carries, so it is not words here as well.
1612        let prompt = json!([
1613            {"type": "text", "text": "look at this"},
1614            {"type": "image", "source": {"type": "base64", "data": "..."}},
1615            {"type": "document", "source": {"type": "base64", "data": "..."}},
1616        ]);
1617        assert_eq!(queued_command_text(&prompt), "look at this");
1618    }
1619
1620    #[test]
1621    fn unknown_inner_block_type_renders_as_placeholder() {
1622        let prompt = json!([
1623            {"type": "text", "text": "hi"},
1624            {"type": "future_block_type", "payload": "..."},
1625        ]);
1626        assert_eq!(queued_command_text(&prompt), "hi\n[future_block_type]");
1627    }
1628
1629    #[test]
1630    fn empty_array_returns_empty_string() {
1631        let prompt = json!([]);
1632        assert_eq!(queued_command_text(&prompt), "");
1633    }
1634
1635    #[test]
1636    fn non_array_non_string_falls_back_to_json_literal() {
1637        let prompt = json!({"weird": "shape"});
1638        let out = queued_command_text(&prompt);
1639        assert!(out.contains("weird"), "an unrenderable shape still shows something: {out}");
1640    }
1641}
1642
1643#[cfg(test)]
1644mod turns {
1645    use forge_primitives::{
1646        AssistantEnvelope, ContentBlock, Message, StopReason, Usage, UserEnvelope,
1647    };
1648
1649    use super::{Rendered, settled};
1650
1651    /// An assistant frame the CLI wrote at `at`, having stopped for `stop`.
1652    fn assistant_at(text: &str, at: &str, stop: StopReason, usage: Option<Usage>) -> Message {
1653        Message::Assistant {
1654            message: AssistantEnvelope {
1655                id: "msg_01".to_owned(),
1656                role: "assistant".to_owned(),
1657                model: "claude-opus-5".to_owned(),
1658                content: vec![ContentBlock::Text {
1659                    text: text.to_owned(),
1660                    extras: serde_json::Map::new(),
1661                }],
1662                stop_reason: Some(stop),
1663                stop_sequence: None,
1664                usage,
1665                extras: serde_json::Map::new(),
1666            },
1667            session_id: "session".to_owned(),
1668            parent_tool_use_id: None,
1669            error: None,
1670            uuid: None,
1671            timestamp: Some(at.to_owned()),
1672            extras: serde_json::Map::new(),
1673        }
1674    }
1675
1676    fn user_at(text: &str, at: &str) -> Message {
1677        Message::User {
1678            message: UserEnvelope {
1679                role: "user".to_owned(),
1680                content: vec![ContentBlock::Text {
1681                    text: text.to_owned(),
1682                    extras: serde_json::Map::new(),
1683                }],
1684                extras: serde_json::Map::new(),
1685            },
1686            session_id: "session".to_owned(),
1687            parent_tool_use_id: None,
1688            uuid: None,
1689            tool_use_result: None,
1690            timestamp: Some(at.to_owned()),
1691            synthetic: false,
1692            extras: serde_json::Map::new(),
1693        }
1694    }
1695
1696    fn use_at(id: &str, at: &str) -> Message {
1697        let Message::Assistant { message, session_id, parent_tool_use_id, error, uuid, .. } =
1698            assistant_at("", at, StopReason::ToolUse, None)
1699        else {
1700            panic!("assistant_at builds one");
1701        };
1702        let mut message = message;
1703        message.id = format!("msg_{id}");
1704        message.content = vec![ContentBlock::ToolUse {
1705            id: id.to_owned(),
1706            name: "Read".to_owned(),
1707            input: serde_json::json!({"file_path": "/tmp/a.rs"}),
1708            extras: serde_json::Map::new(),
1709        }];
1710        Message::Assistant {
1711            message,
1712            session_id,
1713            parent_tool_use_id,
1714            error,
1715            uuid,
1716            timestamp: Some(at.to_owned()),
1717            extras: serde_json::Map::new(),
1718        }
1719    }
1720
1721    /// What each turn is named, in the order the fold opens them.
1722    fn keys(rendered: &Rendered) -> Vec<Option<String>> {
1723        rendered.turns.iter().map(|span| span.key.clone()).collect()
1724    }
1725
1726    /// The same frame with its clock stripped: a transcript row the CLI wrote
1727    /// before it stamped one, which the fold has to place no time on.
1728    fn clockless(mut message: Message) -> Message {
1729        match &mut message {
1730            Message::User { timestamp, .. } | Message::Assistant { timestamp, .. } => {
1731                *timestamp = None;
1732            }
1733            other => panic!("no clock to strip: {other:?}"),
1734        }
1735        message
1736    }
1737
1738    /// One result frame, as the wire sends it: the turn it closes, and the
1739    /// id that frame carries.
1740    fn result_frame(uuid: Option<&str>) -> Message {
1741        Message::Result {
1742            subtype: "success".to_owned(),
1743            session_id: "session".to_owned(),
1744            is_error: false,
1745            num_turns: 1,
1746            duration_ms: 41_059,
1747            duration_api_ms: 40_742,
1748            stop_reason: None,
1749            total_cost_usd: Some(0.16),
1750            usage: None,
1751            result: None,
1752            structured_output: None,
1753            extras: serde_json::Map::new(),
1754            model_usage: None,
1755            permission_denials: None,
1756            errors: None,
1757            uuid: uuid.map(str::to_owned),
1758            terminal_reason: None,
1759        }
1760    }
1761
1762    /// A turn is named for the turn it is rather than for its place: the name
1763    /// is the instant the turn's own first row carried, so a turn inserted
1764    /// above another leaves that one named the same. A count of the turns
1765    /// before it renames every one after the insertion.
1766    #[test]
1767    fn a_turn_is_named_for_its_own_opening_instant_and_not_for_its_place() {
1768        let first = [
1769            user_at("first", "2026-04-22T04:15:27.000Z"),
1770            assistant_at("done", "2026-04-22T04:18:08.000Z", StopReason::EndTurn, None),
1771            user_at("second", "2026-04-22T05:00:00.000Z"),
1772            assistant_at("done", "2026-04-22T05:01:00.000Z", StopReason::EndTurn, None),
1773        ];
1774        // The same conversation with a turn inserted above the last one.
1775        let inserted = [
1776            user_at("first", "2026-04-22T04:15:27.000Z"),
1777            assistant_at("done", "2026-04-22T04:18:08.000Z", StopReason::EndTurn, None),
1778            user_at("inserted", "2026-04-22T04:30:00.000Z"),
1779            assistant_at("done", "2026-04-22T04:31:00.000Z", StopReason::EndTurn, None),
1780            user_at("second", "2026-04-22T05:00:00.000Z"),
1781            assistant_at("done", "2026-04-22T05:01:00.000Z", StopReason::EndTurn, None),
1782        ];
1783
1784        assert_eq!(
1785            keys(&settled(&first)),
1786            [
1787                Some("2026-04-22T04:15:27.000Z".to_owned()),
1788                Some("2026-04-22T05:00:00.000Z".to_owned()),
1789            ],
1790            "each turn is named for its own opening instant",
1791        );
1792        assert_eq!(
1793            keys(&settled(&inserted)).last(),
1794            Some(&Some("2026-04-22T05:00:00.000Z".to_owned())),
1795            "and an inserted turn leaves the turn after it named the same",
1796        );
1797    }
1798
1799    /// A turn the wire settled is named the way a turn read back is: the
1800    /// instant its own first row carried. The frame's id is the fallback for
1801    /// a turn the fold placed no clock on, and naming the turn for the frame
1802    /// instead would rename every one of them across a reload.
1803    #[test]
1804    fn a_wire_settled_turn_is_named_for_its_opening_instant() {
1805        let messages = [
1806            user_at("ship it", "2026-04-22T04:15:27.000Z"),
1807            assistant_at("Shipped.", "2026-04-22T04:18:08.000Z", StopReason::EndTurn, None),
1808            result_frame(Some("result-1")),
1809        ];
1810
1811        assert_eq!(
1812            keys(&settled(&messages)),
1813            [Some("2026-04-22T04:15:27.000Z".to_owned())],
1814            "the turn's own instant, not the id of the frame that reported it",
1815        );
1816    }
1817
1818    /// A turn the fold can place no clock on and whose frame carried no id
1819    /// is named by nothing at all. A turn a view does not remember open is
1820    /// the safe answer; a name it shares with a neighbour is the one that
1821    /// opens the wrong body.
1822    #[test]
1823    fn a_turn_nothing_can_name_carries_no_name() {
1824        let messages = [
1825            clockless(user_at("ship it", "2026-04-22T04:15:27.000Z")),
1826            clockless(assistant_at(
1827                "Shipped.",
1828                "2026-04-22T04:18:08.000Z",
1829                StopReason::EndTurn,
1830                None,
1831            )),
1832            result_frame(None),
1833        ];
1834
1835        assert_eq!(
1836            keys(&settled(&messages)),
1837            [None],
1838            "no clock and no frame id, so no name rather than another turn's",
1839        );
1840    }
1841
1842    /// A turn the fold placed no clock on takes the id of the frame that
1843    /// reported it. The arm is reachable rather than belt-and-braces: the
1844    /// baselines carry result frames with no timestamp, so a fold can meet
1845    /// one with nothing in the trace to name it by.
1846    #[test]
1847    fn a_turn_with_no_clock_takes_the_frame_id() {
1848        let messages = [
1849            clockless(user_at("ship it", "2026-04-22T04:15:27.000Z")),
1850            clockless(assistant_at(
1851                "Shipped.",
1852                "2026-04-22T04:18:08.000Z",
1853                StopReason::EndTurn,
1854                None,
1855            )),
1856            result_frame(Some("result-1")),
1857        ];
1858
1859        assert_eq!(
1860            keys(&settled(&messages)),
1861            [Some("result-1".to_owned())],
1862            "nothing placed a clock on the turn, so the frame's own id names it",
1863        );
1864    }
1865
1866    /// Two turns can open on one clock - the CLI writes a command and its
1867    /// answer in the same millisecond - and the page keeps ONE map of the
1868    /// rows it has been told to open, so two rows sharing a name open and
1869    /// close together. The second turn takes a name of its own.
1870    #[test]
1871    fn two_turns_opening_on_one_clock_do_not_share_a_name() {
1872        let messages = [
1873            user_at("first", "2026-04-22T04:15:27.000Z"),
1874            assistant_at("done", "2026-04-22T04:15:27.000Z", StopReason::EndTurn, None),
1875            user_at("second", "2026-04-22T04:15:27.000Z"),
1876            assistant_at("done", "2026-04-22T04:15:27.000Z", StopReason::EndTurn, None),
1877        ];
1878
1879        assert_eq!(
1880            keys(&settled(&messages)),
1881            [
1882                Some("2026-04-22T04:15:27.000Z".to_owned()),
1883                Some("2026-04-22T04:15:27.000Z#2".to_owned()),
1884            ],
1885            "the second turn is named its own rather than the first's",
1886        );
1887    }
1888
1889    /// A transcript holds no result frame, so a turn read from one is named
1890    /// from its own rows or not at all: the opening instant of the first row
1891    /// the turn wrote.
1892    #[test]
1893    fn a_turn_read_from_a_transcript_carries_its_own_name() {
1894        let messages = [
1895            user_at("make the call tree the default", "2026-04-22T04:15:27.000Z"),
1896            assistant_at("Done.", "2026-04-22T04:18:08.000Z", StopReason::EndTurn, None),
1897        ];
1898
1899        assert_eq!(
1900            keys(&settled(&messages)),
1901            [Some("2026-04-22T04:15:27.000Z".to_owned())],
1902            "with no result frame to name it, the turn's own opening instant does",
1903        );
1904    }
1905
1906    /// The wire's own result closes a turn, and the fold must not open a
1907    /// second one for it: the turn it reported is the turn it names.
1908    #[test]
1909    fn a_turn_the_wire_settled_is_named_once() {
1910        let messages = [
1911            user_at("ship it", "2026-04-22T04:15:27.000Z"),
1912            assistant_at("Shipped.", "2026-04-22T04:18:08.000Z", StopReason::EndTurn, None),
1913            result_frame(None),
1914        ];
1915        let rendered = settled(&messages);
1916
1917        assert_eq!(rendered.turns.len(), 1, "the result closes the turn rather than opening one");
1918        assert_eq!(
1919            keys(&rendered),
1920            [Some("2026-04-22T04:15:27.000Z".to_owned())],
1921            "and the one turn is named from its own rows",
1922        );
1923    }
1924
1925    /// A turn that handed back for a tool call has not ended, so the fold
1926    /// names it by nothing while it is the last thing in the conversation:
1927    /// the view draws the live one while it runs.
1928    #[test]
1929    fn a_turn_still_calling_tools_carries_no_name() {
1930        let messages = [
1931            user_at("read the file", "2026-04-22T04:15:27.000Z"),
1932            use_at("toolu_1", "2026-04-22T04:15:29.000Z"),
1933        ];
1934
1935        assert_eq!(
1936            keys(&settled(&messages)),
1937            [None],
1938            "nothing ended this turn, so nothing named it"
1939        );
1940    }
1941
1942    /// A turn the transcript has already closed is named whatever its last
1943    /// frame stopped for: the rows after it are the CLI's own write that the
1944    /// turn is over, which is what a turn ending on a tool call and reopened
1945    /// by a row nobody typed looks like.
1946    #[test]
1947    fn a_turn_the_transcript_closed_carries_its_name() {
1948        let messages = [
1949            user_at("run the skill", "2026-04-22T04:15:27.000Z"),
1950            use_at("toolu_1", "2026-04-22T04:15:29.000Z"),
1951            user_at("Base directory for this skill: /tmp/skill", "2026-04-22T04:15:31.000Z"),
1952        ];
1953
1954        assert_eq!(
1955            keys(&settled(&messages)),
1956            [Some("2026-04-22T04:15:27.000Z".to_owned()), None],
1957            "the closed turn is named from its own rows, and the row that closed it opens a turn \
1958             with nothing to name it",
1959        );
1960    }
1961
1962    /// A pair of row clocks that runs backwards or does not parse gives no
1963    /// span, and a turn with no usable pair is named by nothing: a replayed
1964    /// row carries the stamp it was written with, so the backwards pair is
1965    /// real.
1966    #[test]
1967    fn a_turn_whose_clocks_give_no_span_carries_no_name() {
1968        let backwards = [
1969            user_at("count it", "2026-04-22T04:18:08.000Z"),
1970            assistant_at("done", "2026-04-22T04:15:27.000Z", StopReason::EndTurn, None),
1971        ];
1972        assert_eq!(keys(&settled(&backwards)), [None], "a span that runs backwards is not one");
1973
1974        let unreadable = [
1975            user_at("count it", "2026-04-22T04:15:27.000Z"),
1976            assistant_at("done", "nope", StopReason::EndTurn, None),
1977        ];
1978        assert_eq!(
1979            keys(&settled(&unreadable)),
1980            [None],
1981            "and neither is a clock that does not parse",
1982        );
1983    }
1984
1985    /// A turn whose frames carried no clock is named by nothing: a name is
1986    /// not a place to invent a time from, and the fallback frame id is only
1987    /// the frame's own.
1988    #[test]
1989    fn a_turn_with_no_row_clocks_carries_no_name() {
1990        let messages = [
1991            clockless(user_at("no clocks here", "2026-04-22T04:15:27.000Z")),
1992            clockless(assistant_at(
1993                "nor here",
1994                "2026-04-22T04:15:31.000Z",
1995                StopReason::EndTurn,
1996                None,
1997            )),
1998        ];
1999
2000        assert_eq!(
2001            keys(&settled(&messages)),
2002            [None],
2003            "a name invented here would be one the turn does not have",
2004        );
2005    }
2006
2007    /// A turn the next prompt closed is named while a later one runs: the
2008    /// trailing turn carries no name until its own report arrives.
2009    #[test]
2010    fn a_closed_turn_is_named_while_a_later_one_runs() {
2011        let messages = [
2012            user_at("first", "2026-04-22T04:15:27.000Z"),
2013            assistant_at("one", "2026-04-22T04:16:00.000Z", StopReason::EndTurn, None),
2014            user_at("second", "2026-04-22T04:20:00.000Z"),
2015        ];
2016        assert_eq!(
2017            keys(&settled(&messages)),
2018            [Some("2026-04-22T04:15:27.000Z".to_owned()), None],
2019            "the first turn's name survives a running second",
2020        );
2021    }
2022
2023    /// Two turns each carry their own name, in the order they ran.
2024    #[test]
2025    fn each_turn_carries_its_own_name() {
2026        let messages = [
2027            user_at("first", "2026-04-22T04:15:27.000Z"),
2028            assistant_at("one", "2026-04-22T04:16:00.000Z", StopReason::EndTurn, None),
2029            user_at("second", "2026-04-22T04:20:00.000Z"),
2030            assistant_at("two", "2026-04-22T04:21:30.000Z", StopReason::EndTurn, None),
2031        ];
2032
2033        assert_eq!(
2034            keys(&settled(&messages)),
2035            [
2036                Some("2026-04-22T04:15:27.000Z".to_owned()),
2037                Some("2026-04-22T04:20:00.000Z".to_owned()),
2038            ],
2039            "each turn's own opening instant, not the other's",
2040        );
2041    }
2042}