Merge branch 'code-branch-20251006-020831' into main

This commit is contained in:
James Peter
2025-10-06 14:14:08 +10:00
6 changed files with 778 additions and 37 deletions
+1
View File
@@ -18,6 +18,7 @@ debug-logs = []
dev-faults = []
# Fork-specific extensions layered on top of upstream history rendering.
code-fork = []
test-helpers = []
[lints]
workspace = true
+508 -5
View File
@@ -74,7 +74,7 @@ mod terminal_handlers;
mod terminal;
mod tools;
mod running_tools;
#[cfg(test)]
#[cfg(any(test, feature = "test-helpers"))]
pub mod smoke_helpers;
use self::agent_install::{
start_agent_install_session,
@@ -5366,6 +5366,18 @@ impl ChatWidget<'_> {
if !is_reasoning_cell {
self.clear_reasoning_in_progress();
}
let is_background_cell = matches!(cell.kind(), HistoryCellType::BackgroundEvent);
let mut key = key;
let mut key_bumped = false;
if !is_background_cell {
if let Some(last) = self.last_assigned_order {
if key <= last {
key = Self::order_key_successor(last);
key_bumped = true;
}
}
}
// Determine insertion position across the entire history
let mut pos = self.history_cells.len();
for i in 0..self.history_cells.len() {
@@ -5521,6 +5533,15 @@ impl ChatWidget<'_> {
);
}
self.cell_order_seq.insert(pos, key);
if key_bumped {
if let Some(stream) = self.history_cells[pos]
.as_any()
.downcast_ref::<crate::history_cell::StreamingContentCell>()
{
self.stream_order_seq
.insert((StreamKind::Answer, stream.state().stream_id.clone()), key);
}
}
self.last_assigned_order = Some(match self.last_assigned_order {
Some(prev) => prev.max(key),
None => key,
@@ -5571,7 +5592,7 @@ impl ChatWidget<'_> {
fn history_insert_existing_record(
&mut self,
mut cell: Box<dyn HistoryCell>,
key: OrderKey,
mut key: OrderKey,
tag: &'static str,
id: HistoryId,
) -> usize {
@@ -5592,7 +5613,18 @@ impl ChatWidget<'_> {
.downcast_ref::<crate::history_cell::CollapsibleReasoningCell>()
.is_some();
if !is_reasoning_cell {
self.clear_reasoning_in_progress();
self.clear_reasoning_in_progress();
}
let is_background_cell = matches!(cell.kind(), HistoryCellType::BackgroundEvent);
let mut key_bumped = false;
if !is_background_cell {
if let Some(last) = self.last_assigned_order {
if key <= last {
key = Self::order_key_successor(last);
key_bumped = true;
}
}
}
let mut pos = self.history_cells.len();
@@ -5683,6 +5715,15 @@ impl ChatWidget<'_> {
);
}
self.cell_order_seq.insert(pos, key);
if key_bumped {
if let Some(stream) = self.history_cells[pos]
.as_any()
.downcast_ref::<crate::history_cell::StreamingContentCell>()
{
self.stream_order_seq
.insert((StreamKind::Answer, stream.state().stream_id.clone()), key);
}
}
self.last_assigned_order = Some(match self.last_assigned_order {
Some(prev) => prev.max(key),
None => key,
@@ -15704,7 +15745,7 @@ fi\n\
"final-answer: ordered insert new AssistantMarkdownCell id={:?}",
id
);
let key = match id.as_deref() {
let mut key = match id.as_deref() {
Some(rid) => self
.try_stream_order_key(StreamKind::Answer, rid)
.unwrap_or_else(|| {
@@ -15719,6 +15760,18 @@ fi\n\
self.next_internal_key()
}
};
if let Some(last) = self.last_assigned_order {
if key <= last {
// Background notices anchor themselves at out = i32::MAX. If a final answer arrives
// after those notices we still want it to appear at the bottom, so bump the key
// just past the most-recently assigned slot.
key = Self::order_key_successor(last);
if let Some(ref want) = id {
self.stream_order_seq
.insert((StreamKind::Answer, want.clone()), key);
}
}
}
tracing::info!(
"[order] final Answer ordered insert id={:?} {}",
id,
@@ -18854,6 +18907,445 @@ fi\n\
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::chatwidget::smoke_helpers::ChatWidgetHarness;
use crate::history_cell::HistoryCellType;
use code_core::history::state::{
AssistantStreamDelta,
AssistantStreamState,
InlineSpan,
MessageLine,
MessageLineKind,
PlainMessageKind,
PlainMessageRole,
PlainMessageState,
TextEmphasis,
TextTone,
};
use code_core::protocol::OrderMeta;
use ratatui::backend::TestBackend;
use ratatui::text::Line;
use ratatui::Terminal;
use std::time::SystemTime;
fn reset_history(chat: &mut ChatWidget<'_>) {
chat.history_cells.clear();
chat.history_cell_ids.clear();
chat.history_state = HistoryState::new();
chat.history_render.invalidate_all();
chat.cell_order_seq.clear();
chat.cell_order_dbg.clear();
chat.ui_background_seq_counters.clear();
chat.last_assigned_order = None;
chat.last_seen_request_index = 0;
chat.current_request_index = 0;
chat.internal_seq = 0;
chat.synthetic_system_req = None;
chat.layout.scroll_offset = 0;
chat.layout.last_max_scroll.set(0);
chat.layout.last_history_viewport_height.set(0);
}
fn insert_plain_cell(chat: &mut ChatWidget<'_>, lines: &[&str]) {
use code_core::history::state::{
InlineSpan,
MessageLine,
MessageLineKind,
PlainMessageKind,
PlainMessageRole,
PlainMessageState,
TextEmphasis,
TextTone,
};
let state = PlainMessageState {
id: HistoryId::ZERO,
role: PlainMessageRole::System,
kind: PlainMessageKind::Plain,
header: None,
lines: lines
.iter()
.map(|text| MessageLine {
kind: MessageLineKind::Paragraph,
spans: vec![InlineSpan {
text: (*text).to_string(),
tone: TextTone::Default,
emphasis: TextEmphasis::default(),
entity: None,
}],
})
.collect(),
metadata: None,
};
let key = chat.next_internal_key();
let _ = chat.history_insert_plain_state_with_key(state, key, "test");
}
#[test]
fn ordering_keeps_new_answers_after_prior_backgrounds() {
let mut harness = ChatWidgetHarness::new();
let chat = harness.chat();
reset_history(chat);
chat.last_seen_request_index = 1;
chat.current_request_index = 1;
chat.internal_seq = 0;
chat.push_background_tail("background-one".to_string());
chat.push_background_tail("background-two".to_string());
assert_eq!(chat.history_cells.len(), 2, "expected two background cells");
let answer_id = "answer-turn-1";
let seeded_key = OrderKey {
req: 1,
out: 1,
seq: 0,
};
chat.seed_stream_order_key(StreamKind::Answer, answer_id, seeded_key);
let response_text = "assistant-response";
chat.insert_final_answer_with_id(
Some(answer_id.to_string()),
vec![Line::from(response_text)],
response_text.to_string(),
);
assert_eq!(chat.history_cells.len(), 3, "expected assistant cell to be added");
let tail_kinds: Vec<HistoryCellType> = chat
.history_cells
.iter()
.map(|cell| cell.kind())
.collect();
let len = tail_kinds.len();
assert_eq!(
&tail_kinds[len - 3..],
&[
HistoryCellType::BackgroundEvent,
HistoryCellType::BackgroundEvent,
HistoryCellType::Assistant,
],
"assistant output should appear after existing background cells",
);
}
#[test]
fn scrollback_spacer_preserves_top_cell_bottom_line() {
let mut harness = ChatWidgetHarness::new();
let chat = harness.chat();
reset_history(chat);
insert_plain_cell(chat, &["old-1", "old-2"]);
insert_plain_cell(chat, &["mid-1", "mid-2"]);
insert_plain_cell(chat, &["new-1", "new-2"]);
let viewport_height = 6;
chat.layout.scroll_offset = 2;
let mut terminal = Terminal::new(TestBackend::new(40, viewport_height)).expect("terminal");
terminal
.draw(|frame| frame.render_widget_ref(&*chat, frame.area()))
.expect("draw history");
let adjusted = chat.history_render.adjust_scroll_to_content(2);
assert_eq!(adjusted, 1, "scroll origin should step back from spacer row");
let prefix = chat.history_render.prefix_sums.borrow();
assert!(!prefix.is_empty(), "prefix sums populated after draw");
let start_idx = match prefix.binary_search(&adjusted) {
Ok(i) => i,
Err(i) => i.saturating_sub(1),
};
assert_eq!(start_idx, 0, "expected first cell to be visible after adjustment");
let content_y = prefix[start_idx];
drop(prefix);
let skip_top = adjusted.saturating_sub(content_y);
assert_eq!(skip_top, 1, "should display the second line of the oldest cell");
let cell = &chat.history_cells[start_idx];
let lines = cell.display_lines_trimmed();
let line = lines
.get(skip_top as usize)
.expect("line available after scroll adjustment");
let text: String = line.spans.iter().map(|span| span.content.as_ref()).collect();
assert_eq!(text.trim(), "old-2");
}
#[test]
fn scrollback_spacer_exact_offset_adjusts_to_content() {
let mut harness = ChatWidgetHarness::new();
let chat = harness.chat();
reset_history(chat);
insert_plain_cell(chat, &["old-1", "old-2"]);
insert_plain_cell(chat, &["mid-1", "mid-2"]);
insert_plain_cell(chat, &["new-1", "new-2"]);
let viewport_height = 6;
chat.layout.scroll_offset = 2;
{
let mut terminal =
Terminal::new(TestBackend::new(40, viewport_height)).expect("terminal");
terminal
.draw(|frame| frame.render_widget_ref(&*chat, frame.area()))
.expect("draw history");
}
let ranges = chat.history_render.spacing_ranges_for_test();
let (pos, _) = ranges
.first()
.copied()
.expect("expected a spacer-induced adjustment");
let adjusted = chat.history_render.adjust_scroll_to_content(pos);
assert!(
adjusted < pos,
"scroll adjustment should reduce the origin when landing on a spacer"
);
}
#[test]
fn scrollback_top_boundary_retains_oldest_content() {
let mut harness = ChatWidgetHarness::new();
let chat = harness.chat();
reset_history(chat);
insert_plain_cell(chat, &["old-1", "old-2"]);
insert_plain_cell(chat, &["mid-1", "mid-2"]);
insert_plain_cell(chat, &["new-1", "new-2"]);
{
let mut terminal = Terminal::new(TestBackend::new(40, 6)).expect("terminal");
terminal
.draw(|frame| frame.render_widget_ref(&*chat, frame.area()))
.expect("draw history");
}
let max_scroll = chat.layout.last_max_scroll.get();
assert!(max_scroll > 0, "expected overflow to produce a positive max scroll");
chat.layout.scroll_offset = max_scroll;
let mut terminal = Terminal::new(TestBackend::new(40, 6)).expect("terminal");
terminal
.draw(|frame| frame.render_widget_ref(&*chat, frame.area()))
.expect("draw history at top boundary");
let max_scroll = chat.layout.last_max_scroll.get();
let scroll_from_top = max_scroll.saturating_sub(chat.layout.scroll_offset);
let effective = chat.history_render.adjust_scroll_to_content(scroll_from_top);
let prefix = chat.history_render.prefix_sums.borrow();
let mut start_idx = match prefix.binary_search(&effective) {
Ok(i) => i,
Err(i) => i.saturating_sub(1),
};
start_idx = start_idx.min(prefix.len().saturating_sub(1));
start_idx = start_idx.min(chat.history_cells.len().saturating_sub(1));
let content_y = prefix[start_idx];
drop(prefix);
let skip = effective.saturating_sub(content_y) as usize;
let cell = &chat.history_cells[start_idx];
let lines = cell.display_lines_trimmed();
let target_index = skip.min(lines.len().saturating_sub(1));
let visible = lines
.get(target_index)
.map(|line| {
line.spans
.iter()
.map(|span| span.content.as_ref())
.collect::<String>()
})
.unwrap_or_default();
assert!(
visible.contains("old-1"),
"scrolling to the top should keep the oldest content visible"
);
}
#[test]
fn ordering_stream_delta_should_follow_existing_background_tail() {
let mut harness = ChatWidgetHarness::new();
let chat = harness.chat();
reset_history(chat);
chat.last_seen_request_index = 1;
chat.push_background_tail("background".to_string());
let stream_state = AssistantStreamState {
id: HistoryId::ZERO,
stream_id: "stream-1".into(),
preview_markdown: "partial".into(),
deltas: vec![AssistantStreamDelta {
delta: "partial".into(),
sequence: Some(0),
received_at: SystemTime::now(),
}],
citations: vec![],
metadata: None,
in_progress: true,
last_updated_at: SystemTime::now(),
};
let stream_cell = history_cell::new_streaming_content(stream_state, &chat.config);
chat.history_insert_with_key_global_tagged(
Box::new(stream_cell),
OrderKey {
req: 1,
out: 0,
seq: 0,
},
"stream",
None,
);
let kinds: Vec<HistoryCellType> = chat
.history_cells
.iter()
.map(|cell| cell.kind())
.collect();
assert_eq!(
kinds,
vec![HistoryCellType::BackgroundEvent, HistoryCellType::Assistant],
"streaming assistant output should append after the existing background tail cell",
);
}
#[test]
fn ordering_tool_reasoning_explore_should_preserve_arrival_sequence() {
let mut harness = ChatWidgetHarness::new();
let chat = harness.chat();
reset_history(chat);
chat.last_seen_request_index = 1;
let make_plain = |text: &str| PlainMessageState {
id: HistoryId::ZERO,
role: PlainMessageRole::System,
kind: PlainMessageKind::Plain,
header: None,
lines: vec![MessageLine {
kind: MessageLineKind::Paragraph,
spans: vec![InlineSpan {
text: text.to_string(),
tone: TextTone::Default,
emphasis: TextEmphasis::default(),
entity: None,
}],
}],
metadata: None,
};
// Reasoning arrives first with later output index.
let reasoning_key = ChatWidget::order_key_from_order_meta(&OrderMeta {
request_ordinal: 1,
output_index: Some(2),
sequence_number: Some(0),
});
chat.history_insert_plain_state_with_key(make_plain("reasoning"), reasoning_key, "reasoning");
// Explore summary follows immediately afterwards.
let explore_key = ChatWidget::order_key_from_order_meta(&OrderMeta {
request_ordinal: 1,
output_index: Some(3),
sequence_number: Some(0),
});
chat.history_insert_plain_state_with_key(make_plain("explore"), explore_key, "explore");
// Tool run summary arrives last but references an earlier output index.
let tool_key = ChatWidget::order_key_from_order_meta(&OrderMeta {
request_ordinal: 1,
output_index: Some(1),
sequence_number: Some(0),
});
chat.history_insert_plain_state_with_key(make_plain("tool"), tool_key, "tool");
let labels: Vec<String> = chat
.history_cells
.iter()
.map(|cell| {
cell.display_lines_trimmed()
.first()
.map(|line| line.spans.iter().map(|span| span.content.as_ref()).collect())
.unwrap_or_default()
})
.collect();
assert_eq!(
labels,
vec!["reasoning".to_string(), "explore".to_string(), "tool".to_string()],
"later inserts with smaller output_index should not leapfrog visible reasoning/explore summaries",
);
}
#[test]
fn ordering_cross_request_pre_prompt_should_not_prepend_previous_turn() {
let mut harness = ChatWidgetHarness::new();
let chat = harness.chat();
reset_history(chat);
let make_plain = |text: &str| PlainMessageState {
id: HistoryId::ZERO,
role: PlainMessageRole::System,
kind: PlainMessageKind::Plain,
header: None,
lines: vec![MessageLine {
kind: MessageLineKind::Paragraph,
spans: vec![InlineSpan {
text: text.to_string(),
tone: TextTone::Default,
emphasis: TextEmphasis::default(),
entity: None,
}],
}],
metadata: None,
};
chat.history_insert_plain_state_with_key(
make_plain("req1"),
OrderKey {
req: 1,
out: 0,
seq: 0,
},
"req1",
);
chat.last_seen_request_index = 1;
chat.pending_user_prompts_for_next_turn = 0;
let key = chat.system_order_key(SystemPlacement::PrePromptInCurrent, None);
chat.history_insert_plain_state_with_key(make_plain("system"), key, "system");
let labels: Vec<String> = chat
.history_cells
.iter()
.map(|cell| {
cell.display_lines_trimmed()
.first()
.map(|line| line.spans.iter().map(|span| span.content.as_ref()).collect())
.unwrap_or_default()
})
.collect();
assert_eq!(
labels,
vec!["req1".to_string(), "system".to_string()],
"pre-prompt system notices for a new request should append after the prior turn rather than prepending it",
);
}
}
fn append_thought_ellipsis(text: &str) -> String {
let trimmed = text.trim_end();
if trimmed.ends_with('…') {
@@ -23260,6 +23752,7 @@ impl WidgetRef for &ChatWidget<'_> {
prefix.push(0);
let mut acc = 0u16;
let content_width = content_area.width.saturating_sub(GUTTER_WIDTH);
let mut spacing_ranges: Vec<(u16, u16)> = Vec::new();
for (idx, vis) in cells.iter().enumerate() {
let cell = vis.cell.expect("rendered cell missing source");
@@ -23303,7 +23796,10 @@ impl WidgetRef for &ChatWidget<'_> {
}
}
if should_add_spacing {
let spacing_start = acc;
acc = acc.saturating_add(spacing);
// Track the spacer interval so scroll adjustments can skip over it later.
spacing_ranges.push((spacing_start, acc));
}
prefix.push(acc);
}
@@ -23316,6 +23812,7 @@ impl WidgetRef for &ChatWidget<'_> {
}
self.history_render
.update_prefix_cache(content_area.width, prefix, total_height, cells.len());
self.history_render.update_spacing_ranges(spacing_ranges);
rendered_cells_full = Some(cells);
}
@@ -23362,7 +23859,13 @@ impl WidgetRef for &ChatWidget<'_> {
}
}
(content_area.y, scroll_from_top)
// If our scroll origin landed on a spacer row between cells, nudge it up so
// the viewport starts with real content instead of an empty separator.
let adjusted_scroll_from_top = self
.history_render
.adjust_scroll_to_content(scroll_from_top);
(content_area.y, adjusted_scroll_from_top)
};
// Record current viewport height for the next frame
@@ -37,6 +37,9 @@ pub(crate) struct HistoryRenderState {
pub(crate) last_prefix_count: Cell<usize>,
pub(crate) last_total_height: Cell<u16>,
pub(crate) prefix_valid: Cell<bool>,
// Row intervals that correspond to inter-cell spacing so we can avoid
// landing the viewport on empty gaps when scrolling.
spacing_ranges: RefCell<Vec<(u16, u16)>>,
}
impl HistoryRenderState {
@@ -50,6 +53,7 @@ impl HistoryRenderState {
last_prefix_count: Cell::new(0),
last_total_height: Cell::new(0),
prefix_valid: Cell::new(false),
spacing_ranges: RefCell::new(Vec::new()),
}
}
@@ -59,6 +63,7 @@ impl HistoryRenderState {
self.prefix_sums.borrow_mut().clear();
self.last_total_height.set(0);
self.prefix_valid.set(false);
self.spacing_ranges.borrow_mut().clear();
}
pub(crate) fn handle_width_change(&self, width: u16) {
@@ -73,6 +78,7 @@ impl HistoryRenderState {
self.last_total_height.set(0);
self.prefix_valid.set(false);
self.height_cache_last_width.set(width);
self.spacing_ranges.borrow_mut().clear();
}
}
@@ -89,6 +95,7 @@ impl HistoryRenderState {
self.prefix_sums.borrow_mut().clear();
self.last_total_height.set(0);
self.prefix_valid.set(false);
self.spacing_ranges.borrow_mut().clear();
}
pub(crate) fn invalidate_all(&self) {
@@ -97,6 +104,7 @@ impl HistoryRenderState {
self.prefix_sums.borrow_mut().clear();
self.last_total_height.set(0);
self.prefix_valid.set(false);
self.spacing_ranges.borrow_mut().clear();
}
pub(crate) fn should_rebuild_prefix(&self, width: u16, count: usize) -> bool {
@@ -129,6 +137,43 @@ impl HistoryRenderState {
self.prefix_valid.set(true);
}
pub(crate) fn update_spacing_ranges(&self, ranges: Vec<(u16, u16)>) {
*self.spacing_ranges.borrow_mut() = ranges;
}
pub(crate) fn adjust_scroll_to_content(&self, mut scroll_pos: u16) -> u16 {
if scroll_pos == 0 {
return scroll_pos;
}
let ranges = self.spacing_ranges.borrow();
if ranges.is_empty() {
return scroll_pos;
}
// Walk backwards until we hit a true cell row or run out of history.
loop {
let mut adjusted = false;
for &(start, end) in ranges.iter() {
if start == 0 {
continue;
}
if scroll_pos >= start && scroll_pos < end {
scroll_pos = start.saturating_sub(1);
adjusted = true;
break;
}
}
if !adjusted || scroll_pos == 0 {
break;
}
}
scroll_pos
}
#[cfg(test)]
pub(crate) fn spacing_ranges_for_test(&self) -> Vec<(u16, u16)> {
self.spacing_ranges.borrow().clone()
}
pub(crate) fn last_total_height(&self) -> u16 {
self.last_total_height.get()
}
+36 -2
View File
@@ -4,10 +4,12 @@ use super::ChatWidget;
use crate::app_event::AppEvent;
use crate::app_event_sender::AppEventSender;
use crate::tui::TerminalInfo;
use code_core::history::state::HistoryRecord;
use code_core::config::{Config, ConfigOverrides, ConfigToml};
use code_core::protocol::Event;
use once_cell::sync::Lazy;
use std::sync::mpsc::{self, Receiver};
use std::time::{Duration, Instant};
use tokio::runtime::Runtime;
static TEST_RUNTIME: Lazy<Runtime> = Lazy::new(|| {
@@ -60,7 +62,7 @@ impl ChatWidgetHarness {
self.chat.handle_code_event(event);
}
pub fn drain_events(&self) -> Vec<AppEvent> {
pub(crate) fn drain_events(&self) -> Vec<AppEvent> {
let mut out = Vec::new();
while let Ok(ev) = self.events.try_recv() {
out.push(ev);
@@ -68,9 +70,41 @@ impl ChatWidgetHarness {
out
}
pub fn chat(&mut self) -> &mut ChatWidget<'static> {
pub(crate) fn chat(&mut self) -> &mut ChatWidget<'static> {
&mut self.chat
}
pub(crate) fn poll_until<F>(&mut self, mut predicate: F, timeout: Duration) -> Vec<AppEvent>
where
F: FnMut(&[AppEvent]) -> bool,
{
let deadline = Instant::now() + timeout;
let mut collected = Vec::new();
loop {
while let Ok(event) = self.events.try_recv() {
if !matches!(event, AppEvent::RequestRedraw) {
collected.push(event);
}
}
if predicate(&collected) {
break;
}
if Instant::now() >= deadline {
break;
}
std::thread::sleep(Duration::from_millis(5));
}
collected
}
pub(crate) fn history_records(&self) -> Vec<HistoryRecord> {
self.chat.history_state.records.clone()
}
}
pub fn assert_has_insert_history(events: &[AppEvent]) {
+88
View File
@@ -101,6 +101,94 @@ pub use cli::Cli;
pub use self::markdown_render::render_markdown_text;
pub use public_widgets::composer_input::{ComposerAction, ComposerInput};
#[cfg(feature = "test-helpers")]
pub mod test_helpers {
pub use crate::chatwidget::smoke_helpers::ChatWidgetHarness;
use crate::app_event::AppEvent;
use code_core::history::state::HistoryRecord;
use std::time::Duration;
pub fn assert_has_terminal_chunk_containing(
harness: &mut ChatWidgetHarness,
needle: &str,
) {
let events = harness.poll_until(
|events| {
events.iter().any(|event| {
match event {
AppEvent::TerminalChunk { chunk, .. } => {
String::from_utf8_lossy(chunk).contains(needle)
}
_ => false,
}
})
},
Duration::from_millis(200),
);
crate::chatwidget::smoke_helpers::assert_has_terminal_chunk_containing(&events, needle);
}
pub fn assert_has_background_event_containing(
harness: &mut ChatWidgetHarness,
needle: &str,
) {
let events = harness.poll_until(
|events| {
events.iter().any(|event| {
match event {
AppEvent::InsertBackgroundEvent { message, .. } => {
message.contains(needle)
}
_ => false,
}
})
},
Duration::from_millis(200),
);
crate::chatwidget::smoke_helpers::assert_has_background_event_containing(&events, needle);
}
pub fn assert_has_codex_event(harness: &mut ChatWidgetHarness) {
let events = harness.poll_until(
|events| events
.iter()
.any(|event| matches!(event, AppEvent::CodexEvent(_))),
Duration::from_millis(200),
);
crate::chatwidget::smoke_helpers::assert_has_codex_event(&events);
}
pub fn assert_has_insert_history(harness: &mut ChatWidgetHarness) {
let events = harness.poll_until(
|events| {
events.iter().any(|event| {
matches!(
event,
AppEvent::InsertHistory(_)
| AppEvent::InsertHistoryWithKind { .. }
| AppEvent::InsertFinalAnswer { .. }
)
})
},
Duration::from_millis(200),
);
crate::chatwidget::smoke_helpers::assert_has_insert_history(&events);
}
pub fn assert_no_events(harness: &mut ChatWidgetHarness) {
let events = harness.poll_until(
|events| !events.is_empty(),
Duration::from_millis(100),
);
crate::chatwidget::smoke_helpers::assert_no_events(&events);
}
pub fn history_records(harness: &ChatWidgetHarness) -> Vec<HistoryRecord> {
harness.history_records()
}
}
fn theme_configured_in_config_file(code_home: &std::path::Path) -> bool {
let config_path = code_home.join("config.toml");
let Ok(contents) = std::fs::read_to_string(&config_path) else {
+100 -30
View File
@@ -13,6 +13,7 @@ use code_core::history::state::{
HistoryRecord,
HistoryState,
MessageMetadata,
PatchEventType,
};
use code_core::protocol::{
AgentMessageDeltaEvent,
@@ -34,14 +35,7 @@ use code_core::protocol::{
OrderMeta,
TokenUsage,
};
use code_tui::app_event::AppEvent;
use code_tui::chatwidget::smoke_helpers::{
assert_has_background_event_containing,
assert_has_codex_event,
assert_has_insert_history,
assert_has_terminal_chunk_containing,
ChatWidgetHarness,
};
use code_tui::test_helpers::ChatWidgetHarness;
use code_tui::{Cli, ComposerAction, ComposerInput};
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use mcp_types::CallToolResult;
@@ -470,7 +464,11 @@ fn smoke_exec_command_stream() {
cwd: PathBuf::from("/tmp"),
parsed_cmd: Vec::new(),
}),
order: None,
order: Some(OrderMeta {
request_ordinal: 1,
output_index: Some(0),
sequence_number: Some(0),
}),
});
harness.handle_event(Event {
@@ -481,7 +479,11 @@ fn smoke_exec_command_stream() {
stream: ExecOutputStream::Stdout,
chunk: ByteBuf::from(vec![b'h', b'i', b'\n']),
}),
order: None,
order: Some(OrderMeta {
request_ordinal: 1,
output_index: Some(0),
sequence_number: Some(1),
}),
});
harness.handle_event(Event {
@@ -494,11 +496,29 @@ fn smoke_exec_command_stream() {
exit_code: 0,
duration: Duration::from_millis(20),
}),
order: None,
order: Some(OrderMeta {
request_ordinal: 1,
output_index: Some(0),
sequence_number: Some(2),
}),
});
let events = harness.drain_events();
assert_has_terminal_chunk_containing(&events, "hi");
let records = code_tui::test_helpers::history_records(&harness);
let exec_record = records
.into_iter()
.find_map(|record| match record {
HistoryRecord::Exec(record) if record.call_id.as_deref() == Some(&call_id) => Some(record),
_ => None,
})
.expect("exec record present");
assert_eq!(exec_record.exit_code, Some(0), "exec exit code should be success");
let stdout = exec_record
.stdout_chunks
.iter()
.map(|chunk| chunk.content.as_str())
.collect::<String>();
assert_eq!(stdout, "hi\n", "stdout should include streamed content");
}
#[test]
@@ -521,11 +541,36 @@ fn smoke_approval_flow() {
reason: Some("Apply change".into()),
grant_root: None,
}),
order: None,
order: Some(OrderMeta {
request_ordinal: 1,
output_index: Some(1),
sequence_number: Some(0),
}),
});
let events = harness.drain_events();
assert_has_background_event_containing(&events, "sample.txt");
let records = code_tui::test_helpers::history_records(&harness);
let patch_record = records
.into_iter()
.find_map(|record| match record {
HistoryRecord::Patch(record) => Some(record),
_ => None,
})
.expect("patch approval record present");
assert!(
matches!(patch_record.patch_type, PatchEventType::ApprovalRequest),
"expected approval request patch cell"
);
let change = patch_record
.changes
.get(&PathBuf::from("sample.txt"))
.expect("change for sample.txt present");
match change {
FileChange::Add { content } => {
assert_eq!(content, "Hello world", "patch change should include file contents");
}
other => panic!("expected FileChange::Add, got {other:?}"),
}
}
#[test]
@@ -539,7 +584,11 @@ fn smoke_custom_tool_call() {
tool_name: "browser_navigate".into(),
parameters: Some(json!({ "url": "https://example.com" })),
}),
order: None,
order: Some(OrderMeta {
request_ordinal: 1,
output_index: Some(1),
sequence_number: Some(0),
}),
});
harness.handle_event(Event {
@@ -552,11 +601,14 @@ fn smoke_custom_tool_call() {
duration: Duration::from_millis(40),
result: Ok("ok".into()),
}),
order: None,
order: Some(OrderMeta {
request_ordinal: 1,
output_index: Some(1),
sequence_number: Some(1),
}),
});
let events = harness.drain_events();
assert_has_codex_event(&events);
code_tui::test_helpers::assert_has_codex_event(&mut harness);
}
#[test]
@@ -575,7 +627,11 @@ fn smoke_mcp_tool_invocation() {
call_id: "mcp-1".into(),
invocation: invocation.clone(),
}),
order: None,
order: Some(OrderMeta {
request_ordinal: 1,
output_index: Some(1),
sequence_number: Some(0),
}),
});
harness.handle_event(Event {
@@ -588,14 +644,17 @@ fn smoke_mcp_tool_invocation() {
result: Ok(CallToolResult {
content: Vec::new(),
is_error: Some(false),
_meta: None,
structured_content: None,
}),
}),
order: None,
order: Some(OrderMeta {
request_ordinal: 1,
output_index: Some(1),
sequence_number: Some(1),
}),
});
let events = harness.drain_events();
assert_has_codex_event(&events);
code_tui::test_helpers::assert_has_codex_event(&mut harness);
}
#[test]
@@ -607,7 +666,11 @@ fn smoke_streaming_assistant_message() {
msg: EventMsg::AgentMessageDelta(AgentMessageDeltaEvent {
delta: "Hello".into(),
}),
order: None,
order: Some(OrderMeta {
request_ordinal: 1,
output_index: Some(0),
sequence_number: Some(0),
}),
});
harness.handle_event(Event {
@@ -616,7 +679,11 @@ fn smoke_streaming_assistant_message() {
msg: EventMsg::AgentMessageDelta(AgentMessageDeltaEvent {
delta: " world".into(),
}),
order: None,
order: Some(OrderMeta {
request_ordinal: 1,
output_index: Some(0),
sequence_number: Some(1),
}),
});
harness.handle_event(Event {
@@ -625,9 +692,12 @@ fn smoke_streaming_assistant_message() {
msg: EventMsg::AgentMessage(AgentMessageEvent {
message: "Hello world".into(),
}),
order: None,
order: Some(OrderMeta {
request_ordinal: 1,
output_index: Some(0),
sequence_number: Some(2),
}),
});
let events = harness.drain_events();
assert_has_insert_history(&events);
code_tui::test_helpers::assert_has_insert_history(&mut harness);
}