This commit is contained in:
Scott Werner 2026-11-04 16:31:18 +00:00 • committed by GitHub
commit 2be41a5e55
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 526 additions and 36 deletions

28
Cargo.lock generated
View file

@ -182,6 +182,12 @@ version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7d902e3d592a523def97af8f317b08ce16b7ab854c1985a0c671e6f15cebc236"
[[package]]
name = "arrayvec"
version = "0.7.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56"
[[package]]
name = "assert-json-diff"
version = "2.0.2"
@ -3981,6 +3987,7 @@ dependencies = [
"portable-atomic",
"unicode-width 0.2.2",
"unit-prefix",
"vt100",
"web-time",
]
@ -8240,6 +8247,27 @@ version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c3082ca00d5a5ef149bb8b555a72ae84c9c59f7250f013ac822ac2e49b19c64"
[[package]]
name = "vt100"
version = "0.16.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "054ff75fb8fa83e609e685106df4faeffdf3a735d3c74ebce97ec557d5d36fd9"
dependencies = [
"itoa",
"unicode-width 0.2.2",
"vte",
]
[[package]]
name = "vte"
version = "0.15.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a5924018406ce0063cd67f8e008104968b74b563ee1b85dde3ed1f7cb87d3dbd"
dependencies = [
"arrayvec",
"memchr",
]
[[package]]
name = "wait-timeout"
version = "0.2.1"

View file

@ -103,6 +103,7 @@ fabro-build-support = { path = "../../foundation/build-support" }
chrono = { workspace = true }
[dev-dependencies]
indicatif = { workspace = true, features = ["in_memory"] }
shlex = "1"
ulid.workspace = true
regex.workspace = true

View file

@ -163,15 +163,23 @@ impl<'a> PetriItem<'a> {
self.value().pointer(pointer)?.as_str()
}
/// The condition the applied route matched, as written: the edge a
/// `route.applied` record names is a key into the subject node's
/// `meta.edges`, whose entry carries the edge's `condition` when it
/// has one.
pub(crate) fn matched_condition(self) -> Option<&'a str> {
/// Metadata for the exact edge named by `route.applied`, not another
/// edge to the same target or the stage's preferred-label signal.
fn selected_edge(self) -> Option<&'a Value> {
let edge = self.body()?.get("edge")?.as_u64()?;
self.node()?
.pointer(&format!("/meta/edges/{edge}/condition"))?
self.node()?.pointer(&format!("/meta/edges/{edge}"))
}
pub(crate) fn selected_edge_label(self) -> Option<&'a str> {
self.selected_edge()?
.get("label")?
.as_str()
.filter(|label| !label.is_empty())
}
/// The condition the applied route matched, as written.
pub(crate) fn matched_condition(self) -> Option<&'a str> {
self.selected_edge()?.get("condition")?.as_str()
}
}
@ -418,6 +426,22 @@ pub(crate) fn format_pretty(
.and_then(Value::as_bool)
.unwrap_or(false);
let detail = if back { " (loop)" } else { "" };
let edge_label = view.selected_edge_label();
let arrow = if edge_label.is_some() && back {
"↺"
} else {
"→"
};
let route_detail = if let Some(label) = edge_label {
let transition = if transition == "continue" || transition.is_empty() {
String::new()
} else {
format!(" {transition}")
};
format!(" [{}]{transition}", label.escape_debug())
} else {
format!(" {transition}{detail}")
};
// The condition the edge matched, when it has one.
let condition = view
.matched_condition()
@ -425,12 +449,11 @@ pub(crate) fn format_pretty(
// Petri records the route after the next visit started, so the
// line names both ends of the edge.
Some(format!(
"{ts} {} {} {} {}{}{}",
"{ts} {} {} {}{}{}",
styles.dim.apply_to(view.node_name().unwrap_or("?")),
styles.dim.apply_to("\u{2192}"),
styles.dim.apply_to(arrow),
target,
styles.dim.apply_to(&transition),
styles.dim.apply_to(detail),
styles.dim.apply_to(&route_detail),
styles.dim.apply_to(&condition),
))
}

View file

@ -752,7 +752,10 @@ mod tests {
#[tokio::test]
async fn remote_workflow_dropped_waiter_cancels_child_before_checkout_cleanup() {
let (fake, git) = fake_git("printf '%s' $$ > pid; exec /bin/sleep 60");
// Publish the PID only after writing it: file existence is the readiness
// signal.
let (fake, git) =
fake_git("printf '%s' $$ > pid.tmp; /bin/mv pid.tmp pid; exec /bin/sleep 60");
let checkout = tempfile::tempdir().unwrap();
let path = checkout.path().to_path_buf();
let worker = tokio::spawn(async move {
@ -1154,7 +1157,8 @@ mod tests {
#[tokio::test]
async fn remote_workflow_timeout_and_cancel_reap_owned_children() {
for timeout in [true, false] {
let (root, mut git) = fake_git("printf '%s' $$ > pid; exec /bin/sleep 60");
let (root, mut git) =
fake_git("printf '%s' $$ > pid.tmp; /bin/mv pid.tmp pid; exec /bin/sleep 60");
git.timeout = Duration::from_secs(2);
let cancel = CancellationToken::new();
let trigger = async {

View file

@ -30,9 +30,10 @@ pub(super) enum ProgressEvent {
base_sha: Option<String>,
},
StageStarted {
node_id: String,
name: String,
script: Option<String>,
node_id: String,
name: String,
script: Option<String>,
route_key: Option<String>,
},
StageCompleted {
node_id: String,
@ -132,14 +133,17 @@ pub(super) enum ProgressEvent {
turns_used: u64,
},
EdgeSelected {
route_key: Option<String>,
from_node: String,
to_node: String,
label: Option<String>,
condition: Option<String>,
},
LoopRestart {
route_key: Option<String>,
from_node: String,
to_node: String,
label: Option<String>,
},
RunNotice {
level: RunNoticeLevel,

View file

@ -72,16 +72,11 @@ impl InfoDisplay {
return;
}
let detail = if let Some(condition) = condition {
format!(" [{condition}]")
} else if let Some(label) = label {
format!(" \"{label}\"")
} else {
String::new()
};
let detail = condition.map_or_else(String::new, |condition| format!(" [{condition}]"));
let label = label.map_or_else(String::new, |label| format!(" [{}]", label.escape_debug()));
Self::insert_info_line(
renderer,
&format!("\u{2192} {from_node} \u{2192} {to_node}{detail}"),
&format!("\u{2192} {from_node} → {to_node}{label}{detail}"),
);
}
@ -90,14 +85,16 @@ impl InfoDisplay {
renderer: &ProgressRenderer,
from_node: &str,
to_node: &str,
label: Option<&str>,
) {
if !self.verbose {
return;
}
let label = label.map_or_else(String::new, |label| format!(" [{}]", label.escape_debug()));
Self::insert_info_line(
renderer,
&format!("\u{21ba} {from_node} \u{2192} {to_node} (loop restart)"),
&format!("\u{21ba} {from_node} → {to_node}{label} (loop restart)"),
);
}

View file

@ -112,9 +112,15 @@ impl ProgressUI {
node_id,
name,
script,
route_key,
} => {
self.stage
.on_stage_started(renderer, &node_id, &name, script.as_deref());
self.stage.on_stage_started(
renderer,
&node_id,
&name,
script.as_deref(),
route_key,
);
}
ProgressEvent::StageCompleted {
node_id,
@ -305,11 +311,24 @@ impl ProgressUI {
);
}
ProgressEvent::EdgeSelected {
route_key,
from_node,
to_node,
label,
condition,
} => {
let message = label.as_ref().map(|label| {
let detail = condition
.as_ref()
.map_or_else(String::new, |value| format!(" [{value}]"));
format!("└─ {}{detail}", label.escape_debug())
});
if self
.stage
.on_route(renderer, route_key.as_deref(), message.as_deref())
{
return;
}
self.info.on_edge_selected(
renderer,
&from_node,
@ -318,8 +337,23 @@ impl ProgressUI {
condition.as_deref(),
);
}
ProgressEvent::LoopRestart { from_node, to_node } => {
self.info.on_loop_restart(renderer, &from_node, &to_node);
ProgressEvent::LoopRestart {
route_key,
from_node,
to_node,
label,
} => {
let message = label
.as_ref()
.map(|label| format!("↺ {}", label.escape_debug()));
if self
.stage
.on_route(renderer, route_key.as_deref(), message.as_deref())
{
return;
}
self.info
.on_loop_restart(renderer, &from_node, &to_node, label.as_deref());
}
ProgressEvent::RunNotice {
level,

View file

@ -41,6 +41,18 @@ pub(super) fn progress_events(
node_id,
name: label,
script: None,
route_key: view
.node()
.and_then(|node| node.pointer("/meta/edges"))
.and_then(Value::as_object)
.filter(|edges| {
edges.values().any(|edge| {
edge.get("label")
.and_then(Value::as_str)
.is_some_and(|label| !label.is_empty())
})
})
.and_then(|_| view.stage_key()),
}]
} else if view.node_kind() == Some("parallel.branch") {
vec![ProgressEvent::ParallelBranchStarted { branch: node_id }]
@ -154,14 +166,17 @@ pub(super) fn progress_events(
.unwrap_or(false);
if back {
vec![ProgressEvent::LoopRestart {
route_key: view.stage_key(),
from_node: node_id,
to_node: to_node.to_string(),
label: view.selected_edge_label().map(str::to_owned),
}]
} else {
vec![ProgressEvent::EdgeSelected {
route_key: view.stage_key(),
from_node: node_id,
to_node: to_node.to_string(),
label: None,
label: view.selected_edge_label().map(str::to_owned),
condition: derived
.get("transition")
.and_then(Value::as_str)

View file

@ -4,6 +4,7 @@
terminal progress lines"
)]
use std::cell::Cell;
use std::io::Write;
use std::sync::Mutex;
@ -18,26 +19,29 @@ enum RendererInner {
}
pub(super) struct ProgressRenderer {
inner: RendererInner,
styles: Styles,
inner: RendererInner,
styles: Styles,
printed_lines: Cell<usize>,
}
impl ProgressRenderer {
pub(super) fn new_tty() -> Self {
Self {
inner: RendererInner::Tty {
inner: RendererInner::Tty {
multi: MultiProgress::new(),
},
styles: Styles::new(console::colors_enabled_stderr()),
styles: Styles::new(console::colors_enabled_stderr()),
printed_lines: Cell::new(0),
}
}
pub(super) fn new_plain(out: Box<dyn Write + Send>, colors: bool) -> Self {
Self {
inner: RendererInner::Plain {
inner: RendererInner::Plain {
out: Mutex::new(out),
},
styles: Styles::new(colors),
styles: Styles::new(colors),
printed_lines: Cell::new(0),
}
}
@ -59,6 +63,22 @@ impl ProgressRenderer {
if let RendererInner::Plain { out } = &self.inner {
let mut out = out.lock().expect("plain renderer lock poisoned");
let _ = writeln!(out, "{}{message}", " ".repeat(indent));
self.printed_lines.set(self.printed_lines.get() + 1);
}
}
pub(super) fn printed_lines(&self) -> usize {
self.printed_lines.get()
}
#[cfg(test)]
pub(super) fn with_draw_target(target: ProgressDrawTarget) -> Self {
Self {
inner: RendererInner::Tty {
multi: MultiProgress::with_draw_target(target),
},
styles: Styles::new(false),
printed_lines: Cell::new(0),
}
}

View file

@ -33,6 +33,7 @@ pub(super) struct ToolCallEntry {
#[derive(Debug)]
pub(super) struct ActiveStage {
route_key: Option<String>,
pub(super) display_name: String,
pub(super) has_model: bool,
pub(super) spinner: ProgressBar,
@ -52,6 +53,9 @@ impl ActiveStage {
}
pub(super) struct StageDisplay {
/// Keep the completed visit's last visible line until its route arrives.
/// Keys include the execution and firing, so revisits cannot steal a route.
route_anchors: HashMap<String, (ProgressBar, usize)>,
verbose: bool,
pub(super) active_stages: HashMap<String, ActiveStage>,
pub(super) stage_counts: HashMap<String, (u64, u64)>,
@ -63,6 +67,7 @@ pub(super) struct StageDisplay {
impl StageDisplay {
pub(super) fn new(verbose: bool) -> Self {
Self {
route_anchors: HashMap::new(),
verbose,
active_stages: HashMap::new(),
stage_counts: HashMap::new(),
@ -73,6 +78,7 @@ impl StageDisplay {
}
pub(super) fn finish(&mut self) {
self.route_anchors.clear();
for (_node_id, stage) in self.active_stages.drain() {
if let Some(bar) = stage.compaction_bar {
bar.finish_and_clear();
@ -97,6 +103,7 @@ impl StageDisplay {
node_id: &str,
name: &str,
script: Option<&str>,
route_key: Option<String>,
) {
self.stage_counts.insert(node_id.to_string(), (0, 0));
let display_name = match script {
@ -121,6 +128,7 @@ impl StageDisplay {
bar.enable_steady_tick(Duration::from_millis(100));
}
self.active_stages.insert(node_id.to_string(), ActiveStage {
route_key,
display_name,
has_model: false,
spinner: bar,
@ -675,6 +683,12 @@ impl StageDisplay {
return;
};
let anchor = if self.verbose && stage.route_key.is_some() {
Some(stage.last_bar().clone())
} else {
None
};
if let Some(bar) = stage.compaction_bar {
bar.finish_and_clear();
}
@ -698,6 +712,37 @@ impl StageDisplay {
} else {
Self::print_plain_stage_completion(renderer, name, glyph, prefix);
}
if let (Some(key), Some(anchor)) = (stage.route_key, anchor) {
self.route_anchors
.insert(key, (anchor, renderer.printed_lines()));
}
}
/// A TTY can insert beneath an earlier visit even after other stages start.
/// A plain transcript can only abbreviate when that visit is still the
/// preceding line; otherwise the caller prints a self-contained route.
pub(super) fn on_route(
&mut self,
renderer: &ProgressRenderer,
key: Option<&str>,
message: Option<&str>,
) -> bool {
let Some((anchor, position)) = key.and_then(|key| self.route_anchors.remove(key)) else {
return false;
};
let Some(message) = message else {
return false;
};
if renderer.is_tty() {
let bar = renderer.insert_after(&anchor);
bar.set_style(styles::style_route());
bar.finish_with_message(message.to_string());
} else if renderer.printed_lines() == position {
renderer.print_line(8, message);
} else {
return false;
}
true
}
fn print_plain_stage_completion(

View file

@ -32,6 +32,7 @@ cached_style!(style_tool_done, " {wide_msg} {prefix:.dim}");
cached_style!(style_subagent_info, " {wide_msg}");
cached_style!(style_branch_done, " {wide_msg} {prefix:.dim}");
cached_style!(style_static_dim, " {wide_msg:.dim}");
cached_style!(style_route, " {wide_msg:.dim}");
cached_style!(style_empty, " ");
pub(super) fn green_check(styles: &Styles) -> String {

View file

@ -10,6 +10,7 @@ use fabro_types::{
DirtyStatus, ForkSourceRef, GitContext, GitRunTarget, RunStreamItemKind, fixtures, test_support,
};
use fabro_util::terminal::Styles;
use indicatif::{InMemoryTerm, ProgressDrawTarget};
use serde_json::{Value, json};
use super::*;
@ -284,3 +285,320 @@ fn base_header_uses_only_the_commit_when_source_branch_is_unknown_or_ambiguous()
}
}
}
fn applied_route(from: &str, to: &str, label: &Value, back: bool) -> RunStreamItem {
RunStreamItem {
run_id: fixtures::RUN_1,
stream_seq: 1,
kind: RunStreamItemKind::Petri,
id: "execution 0/19/0".to_string(),
recorded_at: 0,
item: json!({
"origin": "core",
"context": {"invocation": 0, "execution": 0},
"subject": {"node": {"name": from, "meta": {"edges": {
"0": {"to": to, "label": "Not selected"},
"7": {"to": to, "label": label}
}}}, "firing": 2},
"record": {"seq": 19, "body": {"event": "route.applied",
"kind": "edge", "firing": 2, "group": 0, "edge": 7}},
"derived": {"target": {"name": to}, "transition": "Continue", "back": back}
}),
}
}
fn route_progress(item: &RunStreamItem, verbose: bool) -> String {
let output = Output::default();
let renderer = ProgressRenderer::new_plain(Box::new(output.clone()), false);
let mut ui = ProgressUI::with_renderer(renderer, verbose);
ui.handle_stream_item(&route_visit(item, "visit.started", None));
ui.handle_stream_item(&route_visit(item, "visit.completed", None));
output.0.lock().unwrap().clear();
ui.handle_stream_item(item);
output.text()
}
fn route_visit(route: &RunStreamItem, event: &str, label: Option<&str>) -> RunStreamItem {
let mut item = visit(
0,
event,
json!({"outcome": {"status": "success"}, "executed": true}),
);
item.item["subject"] = route.item["subject"].clone();
item.item["subject"]["node"]["meta"]["kind"] = json!("agent");
item.item["subject"]["visit"] = json!(1);
if let Some(label) = label {
item.item["subject"]["node"]["meta"]["label"] = json!(label);
}
item
}
#[test]
fn selected_route_labels_render_beneath_stages_and_in_pretty_events() {
let mut transcript = String::new();
for (from, to, label, back) in [
(
"Run tests",
"Open pull request",
json!("Tests passed"),
false,
),
(
"Review changes",
"Revise implementation",
json!("Changes requested"),
false,
),
("Approval", "Deploy", json!("Approved"), false),
("Verify fix", "Investigate", json!("Still failing"), true),
("work", "a", json!("yes"), false),
("start", "work", Value::Null, false),
("verify", "work", Value::Null, true),
] {
let item = applied_route(from, to, &label, back);
let before = petri_stream::raw_line(&item).unwrap();
transcript.push_str(&route_progress(&item, true));
transcript.push_str(
&petri_stream::format_pretty(&item, &Styles::new(false), &mut PrettyState::default())
.unwrap(),
);
transcript.push('\n');
assert_eq!(route_progress(&item, false), "");
assert_eq!(petri_stream::raw_line(&item).unwrap(), before);
}
insta::assert_snapshot!(transcript, @r#"
└─ Tests passed
00:00:00 Run tests → Open pull request [Tests passed]
└─ Changes requested
00:00:00 Review changes → Revise implementation [Changes requested]
└─ Approved
00:00:00 Approval → Deploy [Approved]
↺ Still failing
00:00:00 Verify fix ↺ Investigate [Still failing]
└─ yes
00:00:00 work → a [yes]
→ start → work
00:00:00 start → work continue
↺ verify → work (loop restart)
00:00:00 verify → work continue (loop)
"#);
}
#[test]
fn selected_route_labels_coexist_with_transition_and_condition_details() {
let mut item = applied_route("verify", "finish", &json!("Tests passed"), false);
item.item["subject"]["node"]["meta"]["edges"]["7"]["condition"] = json!("outcome=succeeded");
item.item["derived"]["transition"] = json!("Restart");
let pretty =
petri_stream::format_pretty(&item, &Styles::new(false), &mut PrettyState::default())
.unwrap();
insta::assert_snapshot!(format!("{}{pretty}", route_progress(&item, true)), @r#"
└─ Tests passed [restart]
00:00:00 verify → finish [Tests passed] restart when outcome=succeeded
"#);
}
#[test]
fn unavailable_selected_route_labels_preserve_unlabeled_output() {
let baseline = applied_route("work", "next", &Value::Null, false);
let mut cases = vec![
applied_route("work", "next", &json!(""), false),
applied_route("work", "next", &json!(42), false),
];
let mut missing_edge = baseline.clone();
missing_edge.item["record"]["body"]["edge"] = json!(99);
cases.push(missing_edge);
let mut missing_metadata = baseline.clone();
missing_metadata.item["subject"]["node"]["meta"] = json!({});
cases.push(missing_metadata);
let mut no_edge = baseline.clone();
no_edge.item["record"]["body"] = json!({"event": "route.applied", "kind": "jump"});
cases.push(no_edge);
for item in cases {
assert_eq!(route_progress(&item, true), route_progress(&baseline, true));
assert_eq!(
petri_stream::format_pretty(&item, &Styles::new(false), &mut PrettyState::default()),
petri_stream::format_pretty(
&baseline,
&Styles::new(false),
&mut PrettyState::default()
),
);
}
}
#[test]
fn selected_route_labels_escape_controls_and_preserve_unicode() {
let item = applied_route(
"review",
"revise",
&json!("Réessayer \"tests\"\n\u{1b}[31m"),
false,
);
insta::assert_snapshot!(route_progress(&item, true), @r#"
└─ Réessayer \"tests\"\n\u{1b}[31m
"#);
}
fn review_route(back: bool) -> RunStreamItem {
applied_route(
"review_images",
if back { "render_images" } else { "render_html" },
&json!(if back {
"Regenerate images"
} else {
"Keep images"
}),
back,
)
}
fn target_route(source: &RunStreamItem) -> RunStreamItem {
let target = source.item["derived"]["target"]["name"].as_str().unwrap();
let mut route = applied_route(target, "exit", &Value::Null, false);
route.item["subject"]["firing"] = json!(3);
route.item["subject"]["node"]["meta"]["edges"] = json!({});
route
}
#[test]
fn decision_lines_follow_display_labels_or_node_names_in_plain_progress() {
let mut transcript = String::new();
for back in [false, true] {
for labels in [true, false] {
let output = Output::default();
let renderer = ProgressRenderer::new_plain(Box::new(output.clone()), false);
let mut ui = ProgressUI::with_renderer(renderer, true);
let source = review_route(back);
let target = target_route(&source);
let source_label = labels.then_some("Review images");
let target_label = labels.then_some(if back {
"Render 12 images"
} else {
"Render HTML reading"
});
ui.handle_stream_item(&route_visit(&source, "visit.started", source_label));
ui.handle_stream_item(&route_visit(&source, "visit.completed", source_label));
// The route follows the target's start in Petri's stream.
ui.handle_stream_item(&route_visit(&target, "visit.started", target_label));
ui.handle_stream_item(&source);
ui.handle_stream_item(&route_visit(&target, "visit.completed", target_label));
ui.finish();
transcript.push_str(&output.text());
}
}
insta::assert_snapshot!(transcript, @r#"
✓ Review images 0ms
└─ Keep images
✓ Render HTML reading 0ms
✓ review_images 0ms
└─ Keep images
✓ render_html 0ms
✓ Review images 0ms
↺ Regenerate images
✓ Render 12 images 0ms
✓ review_images 0ms
↺ Regenerate images
✓ render_images 0ms
"#);
}
#[test]
fn tty_decision_stays_with_source_when_another_stage_has_already_finished() {
for back in [false, true] {
let term = InMemoryTerm::new(24, 100);
let renderer = ProgressRenderer::with_draw_target(ProgressDrawTarget::term_like(Box::new(
term.clone(),
)));
let mut ui = ProgressUI::with_renderer(renderer, true);
let source = review_route(back);
let target = target_route(&source);
ui.handle_stream_item(&route_visit(
&source,
"visit.started",
Some("Review images"),
));
ui.handle_stream_item(&route_visit(
&source,
"visit.completed",
Some("Review images"),
));
ui.handle_stream_item(&route_visit(&target, "visit.started", Some("Next stage")));
ui.handle_stream_item(&route_visit(&target, "visit.completed", Some("Next stage")));
ui.handle_stream_item(&source);
ui.finish();
let screen = term.contents();
let decision = if back {
"↺ Regenerate images"
} else {
"└─ Keep images"
};
let lines: Vec<_> = screen
.lines()
.filter(|line| !line.trim().is_empty())
.collect();
assert_eq!(lines.len(), 3, "{screen}");
assert!(lines[0].contains("✓ Review images"), "{screen}");
assert_eq!(lines[1], format!(" {decision}"));
assert!(lines[2].contains("✓ Next stage"), "{screen}");
assert!(!screen.contains("review_images"));
}
}
#[test]
fn detached_or_interleaved_plain_routes_keep_context() {
for with_source in [false, true] {
let output = Output::default();
let renderer = ProgressRenderer::new_plain(Box::new(output.clone()), false);
let mut ui = ProgressUI::with_renderer(renderer, true);
let source = review_route(false);
if with_source {
ui.handle_stream_item(&route_visit(
&source,
"visit.started",
Some("Review images"),
));
ui.handle_stream_item(&route_visit(
&source,
"visit.completed",
Some("Review images"),
));
// An append-only transcript cannot move the decision above this line.
ui.renderer.print_line(4, "Other activity");
}
output.0.lock().unwrap().clear();
ui.handle_stream_item(&source);
insta::allow_duplicates! {
insta::assert_snapshot!(output.text(), @" → review_images → render_html [Keep images]");
}
}
}
#[test]
fn tty_self_loop_decisions_attach_to_the_correct_visit() {
let term = InMemoryTerm::new(24, 100);
let renderer =
ProgressRenderer::with_draw_target(ProgressDrawTarget::term_like(Box::new(term.clone())));
let mut ui = ProgressUI::with_renderer(renderer, true);
let first = applied_route("review", "review", &json!("Regenerate images"), true);
let mut second = applied_route("review", "exit", &json!("Keep images"), false);
second.item["subject"]["firing"] = json!(3);
for (route, name) in [(&first, "First review"), (&second, "Second review")] {
ui.handle_stream_item(&route_visit(route, "visit.started", Some(name)));
ui.handle_stream_item(&route_visit(route, "visit.completed", Some(name)));
}
// Both completed visits share a node name; routes arrive in reverse order.
ui.handle_stream_item(&second);
ui.handle_stream_item(&first);
ui.finish();
let screen = term.contents();
let lines: Vec<_> = screen
.lines()
.filter(|line| !line.trim().is_empty())
.collect();
assert_eq!(lines.len(), 4, "{screen}");
assert!(lines[0].contains("✓ First review"), "{screen}");
assert_eq!(lines[1], " ↺ Regenerate images");
assert!(lines[2].contains("✓ Second review"), "{screen}");
assert_eq!(lines[3], " └─ Keep images");
}