//! Empty-composer Enter sends the top mid-turn queued follow-up now. //! //! Regression for send-now discoverability: plain Enter with text still //! *queues*; a second bare Enter on the empty prompt is cancel-and-send — the //! running turn is cancelled (silently: no "Turn cancelled by user" marker) //! and the queued row runs as the next turn, arriving on the wire as a //! standard `` prompt with no interjection preamble. use std::time::Duration; use anyhow::{Context, Result, bail}; use super::wait_for_welcome; use crate::{ContentController, PtyHarness, pager_binary}; const DEFAULT_ROWS: u16 = 50; const DEFAULT_COLS: u16 = 120; /// The interjection-merge preamble: send-now must never produce it. const INTERJECTION_WIRE_PREFIX: &str = "The user sent a message while you were working"; fn slow_turn_text(sentinel: &str) -> String { let mut s = String::from(sentinel); for i in 0..30 { s.push_str(&format!(" streaming{i}")); } s } fn all_user_message_blobs(content: &ContentController) -> Vec { content .request_bodies() .iter() .flat_map(|b| { let items = b["messages"].as_array().or_else(|| b["input"].as_array()); items .into_iter() .flatten() .filter(|m| m["role"] == "user") .map(|m| match m["content"].as_str() { Some(s) => s.to_owned(), None => m["content"].to_string(), }) .collect::>() }) .collect() } pub async fn assert_empty_enter_force_sends_top_queued() -> Result<()> { let content = ContentController::start() .await .context("start ContentController")?; // Gate turn 1's terminal event so the queue + empty-Enter provably land // mid-turn — a paced-chunk window races turn end on slow (remote) workers. content.hold_agent_completions(); content.set_turns([ slow_turn_text("TURNONE"), "TURNTWO reply to the promoted follow-up.".to_owned(), ]); let binary = pager_binary().context("resolve pager binary")?; let mut harness = PtyHarness::spawn_with_content(&binary, DEFAULT_ROWS, DEFAULT_COLS, &content, &[]) .context("spawn pager")?; wait_for_welcome(&mut harness).await?; harness.inject_keys(b"go\r").context("submit prompt")?; harness .wait_for_text("TURNONE", Duration::from_secs(30)) .context("turn 1 streaming")?; harness .inject_keys(b"please also check the logs\r") .context("queue follow-up")?; harness .wait_for_text("please also check the logs", Duration::from_secs(10)) .context("queued text visible")?; harness.inject_keys(b"\r").context("empty Enter send-now")?; // Release the gate so a completion racing the abort resolves rather than // hangs. content.release_agent_completions(); // The "❯ " prefix distinguishes the committed prompt block from the // prefix-less queue row. harness .wait_for_text( "\u{276F} please also check the logs", Duration::from_secs(15), ) .context("promoted prompt scrollback chrome")?; harness .wait_for_text("TURNTWO", Duration::from_secs(40)) .context("promoted turn reply")?; if harness.contains_text("Turn cancelled by user") { bail!( "send-now cancel must not render a cancelled marker\n{}", harness.screen_contents() ); } let users = all_user_message_blobs(&content); let Some(promoted) = users .iter() .find(|u| u.contains("please also check the logs")) else { bail!("queued follow-up never reached the wire: {users:#?}"); }; if promoted.contains(INTERJECTION_WIRE_PREFIX) { bail!("send-now must not use the interjection preamble: {promoted}"); } if !promoted.contains("") { bail!("send-now must arrive as a standard user_query prompt: {promoted}"); } if harness.contains_text("panicked") { bail!("pager panicked\n{}", harness.screen_contents()); } harness.quit().context("clean quit")?; Ok(()) }