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}