mirror of
https://github.com/openai/codex.git
synced 2026-11-10 21:00:47 +00:00
## Why Clients starting delegated work need to attribute descendant turns to the original turn and retain that attribution through completion and history reads. ## What changed - Accept optional `parentTurnId` and `rootTurnId` in `turn/start`, and expose `rootTurnId` on returned turns and lifecycle notifications. - Default new turns to their own root when none is supplied. Preserve the active turn's root when steering, ignoring incoming lineage fields. - Carry root IDs through completion, interruption, recovery, and fork boundaries, and persist them for paginated history reads. Leave unknown roots null in older history. - Update protocol schemas and generated TypeScript and Python bindings. ## Testing Extend coverage for default and explicit roots, steering without changing lineage, interrupted and recovered turns, history reads after restart in both history modes, nested fork pagination, and compatibility with older database writers. GitOrigin-RevId: f1a570a12bd11742929fd2e8b017e5d35aa07dcb
248 lines
10 KiB
Rust
248 lines
10 KiB
Rust
//! Usage warnings preserve composer geometry and yield to active interactions.
|
|
|
|
use super::*;
|
|
use codex_app_server_protocol::RateLimitSnapshot;
|
|
use codex_app_server_protocol::ServerNotification;
|
|
use codex_app_server_protocol::Turn;
|
|
use codex_app_server_protocol::TurnItemsView;
|
|
use codex_app_server_protocol::TurnStartedNotification;
|
|
use codex_app_server_protocol::TurnStatus;
|
|
use pretty_assertions::assert_eq;
|
|
|
|
fn quota(used_percent: i32) -> RateLimitSnapshot {
|
|
serde_json::from_value(serde_json::json!({
|
|
"primary": {"usedPercent": used_percent, "windowDurationMins": 300},
|
|
}))
|
|
.unwrap()
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn usage_notice_preserves_composer_geometry_on_recovery() -> Result<()> {
|
|
let mut snapshots = Vec::new();
|
|
for (width, height, running, queued, used_percent) in [
|
|
(80, 10, false, false, 92),
|
|
(80, 12, true, false, 98),
|
|
(32, 10, true, false, 101),
|
|
(18, 10, true, false, 95),
|
|
(80, 18, true, true, 98),
|
|
(80, 11, true, true, 98),
|
|
(80, 6, true, false, 98),
|
|
] {
|
|
let (mut app, mut events, _ops) = crate::app::tests::make_test_app_with_channels().await;
|
|
app.local_settings.tui.show_tooltips = !running;
|
|
app.local_settings.tui.animations = false;
|
|
app.transcript_cells
|
|
.push(Arc::new(crate::history_cell::PlainHistoryCell::new(
|
|
if running {
|
|
(0..20)
|
|
.map(|row| format!("Conversation row {row}").into())
|
|
.collect()
|
|
} else {
|
|
vec!["Conversation".into()]
|
|
},
|
|
)));
|
|
if running {
|
|
app.chat_widget.handle_server_notification(
|
|
ServerNotification::TurnStarted(TurnStartedNotification {
|
|
thread_id: ThreadId::new().to_string(),
|
|
turn: Turn {
|
|
id: "turn".into(),
|
|
root_turn_id: None,
|
|
items_view: TurnItemsView::Full,
|
|
items: Vec::new(),
|
|
status: TurnStatus::InProgress,
|
|
error: None,
|
|
started_at: None,
|
|
completed_at: None,
|
|
duration_ms: None,
|
|
},
|
|
}),
|
|
/*replay_kind*/ None,
|
|
);
|
|
assert!(app.chat_widget.is_user_turn_pending_or_running());
|
|
if queued {
|
|
app.keymap.chat.edit_queued_message = vec![crate::key_hint::shift(KeyCode::Left)];
|
|
app.chat_widget
|
|
.apply_keymap_update(Default::default(), &app.keymap);
|
|
app.chat_widget
|
|
.apply_external_edit("queued follow-up".into());
|
|
app.chat_widget
|
|
.handle_key_event(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE));
|
|
assert_eq!(
|
|
app.chat_widget.queued_user_message_texts(),
|
|
vec!["queued follow-up"],
|
|
);
|
|
}
|
|
app.chat_widget.apply_external_edit(if queued {
|
|
"draft stays\nsecond line".into()
|
|
} else {
|
|
"draft stays".into()
|
|
});
|
|
}
|
|
let mut tui = crate::tui::test_support::make_test_tui()?;
|
|
tui.set_owned_screen(/*owned*/ true)?;
|
|
let size = Size::new(width, height);
|
|
tui.terminal.resize(size)?;
|
|
let before = app.render_owned_transcript(&mut tui, size)?;
|
|
let cursor = tui.terminal.last_known_cursor_pos;
|
|
let hint = app.composer_hint(width);
|
|
app.chat_widget
|
|
.on_rate_limit_snapshot(Some(quota(used_percent)));
|
|
while let Ok(event) = events.try_recv() {
|
|
if let AppEvent::InsertHistoryCell(cell) = event {
|
|
app.insert_history_cell(&mut tui, cell);
|
|
}
|
|
}
|
|
app.render_owned_transcript(&mut tui, size)?;
|
|
assert_eq!(tui.terminal.last_known_cursor_pos, cursor);
|
|
let buffer = crate::custom_terminal::test_support::last_rendered_buffer(&tui.terminal);
|
|
let screen = buffer
|
|
.content()
|
|
.chunks(usize::from(buffer.area.width))
|
|
.map(|row| {
|
|
row.iter()
|
|
.map(ratatui::buffer::Cell::symbol)
|
|
.collect::<String>()
|
|
.trim_end()
|
|
.to_owned()
|
|
})
|
|
.collect::<Vec<_>>()
|
|
.join("\n");
|
|
snapshots.push(format!(
|
|
"{width} columns, {height} rows, running={running}, queued={queued}\n{screen}"
|
|
));
|
|
app.chat_widget
|
|
.on_rate_limit_snapshot(Some(quota(/*used_percent*/ 10)));
|
|
assert_eq!(app.composer_hint(width), hint);
|
|
assert_eq!(app.render_owned_transcript(&mut tui, size)?, before);
|
|
assert_eq!(tui.terminal.last_known_cursor_pos, cursor);
|
|
tui.set_owned_screen(/*owned*/ false)?;
|
|
}
|
|
insta::assert_snapshot!(
|
|
"composer_usage_notice",
|
|
crate::chatwidget::tests::helpers::normalize_snapshot_paths(snapshots.join("\n\n")),
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn usage_notice_yields_to_interactions_and_blocking_banners() {
|
|
let mut app = crate::app::test_support::make_test_app().await;
|
|
app.local_settings.tui.show_tooltips = true;
|
|
let hint = app.composer_hint(/*width*/ 80);
|
|
app.chat_widget
|
|
.on_rate_limit_snapshot(Some(quota(/*used_percent*/ 92)));
|
|
let notice = app.composer_hint(/*width*/ 80);
|
|
assert!(notice.is_some());
|
|
assert_ne!(notice, hint);
|
|
let notice_line = notice.as_ref().map(|notice| notice.line.clone());
|
|
app.transcript_view.begin_search();
|
|
assert_eq!(app.composer_hint(/*width*/ 80), None);
|
|
app.transcript_view = Default::default();
|
|
assert_eq!(app.composer_hint(/*width*/ 80), notice);
|
|
app.chat_widget.open_warnings(&app.transcript_cells);
|
|
assert_eq!(app.composer_hint(/*width*/ 80), None);
|
|
app.chat_widget.handle_key_event(KeyCode::Esc.into());
|
|
assert_eq!(app.composer_hint(/*width*/ 80), notice);
|
|
|
|
for (spend_control, reached_type) in [
|
|
(Some(true), None),
|
|
(
|
|
None,
|
|
Some(codex_app_server_protocol::RateLimitReachedType::RateLimitReached),
|
|
),
|
|
] {
|
|
let mut update = quota(/*used_percent*/ 92);
|
|
update.spend_control_reached = spend_control;
|
|
update.rate_limit_reached_type = reached_type;
|
|
app.chat_widget.on_rate_limit_snapshot(Some(update));
|
|
assert_eq!(app.chat_widget.usage_notice(/*width*/ 80), None);
|
|
}
|
|
app.chat_widget
|
|
.on_rate_limit_snapshot(Some(quota(/*used_percent*/ 92)));
|
|
assert_eq!(app.chat_widget.usage_notice(/*width*/ 78), notice_line);
|
|
app.chat_widget.update_backend_banner(
|
|
&serde_json::from_value(serde_json::json!({
|
|
"rateLimits": quota(/*used_percent*/ 92),
|
|
"rateLimitUpsell": {
|
|
"banner_type": "selected_model_limit", "title": "Usage limit reached",
|
|
"description": "Choose another model.", "ctas": [],
|
|
},
|
|
}))
|
|
.unwrap(),
|
|
);
|
|
assert_eq!(app.chat_widget.usage_notice(/*width*/ 80), None);
|
|
app.chat_widget.clear_backend_banner();
|
|
assert_eq!(app.chat_widget.usage_notice(/*width*/ 78), notice_line);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn escape_closes_shortcut_help_before_transcript_backtracking() -> Result<()> {
|
|
let mut app = crate::app::test_support::make_test_app().await;
|
|
let mut server = Box::pin(crate::start_embedded_app_server_for_picker(&app.config)).await?;
|
|
let mut tui = crate::tui::test_support::make_test_tui()?;
|
|
tui.set_owned_screen(/*owned*/ true)?;
|
|
app.transcript_cells = vec![Arc::new(crate::history_cell::PlainHistoryCell::new(
|
|
(0..60)
|
|
.map(|row| format!("response row {row}").into())
|
|
.collect(),
|
|
))];
|
|
app.render_owned_transcript(&mut tui, Size::new(/*width*/ 80, /*height*/ 24))?;
|
|
app.transcript_view
|
|
.scroll(&app.transcript_cells, /*rows*/ -20);
|
|
let area = Rect::new(
|
|
/*x*/ 0, /*y*/ 0, /*width*/ 80, /*height*/ 8,
|
|
);
|
|
let mut before = ratatui::buffer::Buffer::empty(area);
|
|
app.transcript_view
|
|
.render(area, &mut before, &app.transcript_cells);
|
|
let escape = KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE);
|
|
let toggle = KeyEvent::new(KeyCode::Char('?'), KeyModifiers::NONE);
|
|
assert!(app.should_handle_backtrack_esc(escape));
|
|
app.handle_tui_event(&mut tui, &mut server, TuiEvent::Key(toggle))
|
|
.await?;
|
|
assert!(app.chat_widget.shortcut_overlay_visible());
|
|
assert!(!app.should_handle_backtrack_esc(escape));
|
|
assert!(!app.should_reject_side_backtrack_esc(escape));
|
|
|
|
app.handle_tui_event(&mut tui, &mut server, TuiEvent::Key(escape))
|
|
.await?;
|
|
assert!(!app.chat_widget.shortcut_overlay_visible());
|
|
assert!(!app.backtrack.primed);
|
|
assert!(!app.transcript_view.is_following());
|
|
assert!(app.should_handle_backtrack_esc(escape));
|
|
let mut after = ratatui::buffer::Buffer::empty(area);
|
|
app.transcript_view
|
|
.render(area, &mut after, &app.transcript_cells);
|
|
assert_eq!(after, before);
|
|
|
|
// Search and selection replace the help footer and keep their own first Escape.
|
|
for search in [true, false] {
|
|
app.handle_tui_event(&mut tui, &mut server, TuiEvent::Key(toggle))
|
|
.await?;
|
|
if search {
|
|
app.transcript_view.begin_search();
|
|
} else {
|
|
tui.screen_size_for_event(&TuiEvent::Resize(Size::new(
|
|
/*width*/ 80, /*height*/ 40,
|
|
)))?;
|
|
app.handle_owned_transcript_event(
|
|
&mut tui,
|
|
&mut server,
|
|
&TuiEvent::Key(KeyEvent::new(KeyCode::Char(' '), KeyModifiers::CONTROL)),
|
|
)?;
|
|
}
|
|
assert!(app.transcript_view.has_active_interaction());
|
|
app.handle_tui_event(&mut tui, &mut server, TuiEvent::Key(escape))
|
|
.await?;
|
|
assert!(!app.transcript_view.has_active_interaction());
|
|
assert!(app.chat_widget.shortcut_overlay_visible());
|
|
app.handle_tui_event(&mut tui, &mut server, TuiEvent::Key(escape))
|
|
.await?;
|
|
assert!(!app.chat_widget.shortcut_overlay_visible());
|
|
}
|
|
server.shutdown().await?;
|
|
tui.set_owned_screen(/*owned*/ false)?;
|
|
Ok(())
|
|
}
|