mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08:00
Add goal TUI UX (5 / 5) (#18077)
Adds the TUI user experience for goals on top of the core runtime from PR 4. ## Why Users need a direct TUI control surface for long-running goals. The UI should make the current goal visible, support common goal actions without waiting for a model turn, and avoid confusing end-of-turn notifications while an active goal is immediately continuing. ## What changed - Added `/goal` summary rendering for the current goal, including active, paused, budget-limited, and complete states. - Added `/goal <objective>` creation/replacement through the app-server goal API rather than a model prompt. - Added `/goal clear`, `/goal pause`, and `/goal unpause` command variants. - Added a confirmation menu when the user enters a new goal while another goal already exists. - Updated `/goal` help and summary tip text so it reflects the supported command variants without advertising slash-command token budgets. - Added footer/statusline goal indicators, including elapsed time and token budget display when a budget exists from API/tool-created goals. - Consumes goal updated/cleared notifications so the TUI stays in sync with external app-server changes. - Suppresses end-of-turn desktop notifications only when a goal is still active and follow-up work is expected. - Preserves slash-command history behavior and avoids leaking queued `/goal` state into unrelated submissions. ## Verification - Added TUI unit and snapshot coverage for goal command availability, summary rendering, control commands, replacement menu behavior, status/footer display, notification handling, and command history.
This commit is contained in:
@@ -872,8 +872,11 @@ async fn restore_thread_input_state_syncs_sleep_inhibitor_state() {
|
||||
chat.restore_thread_input_state(Some(ThreadInputState {
|
||||
composer: None,
|
||||
pending_steers: VecDeque::new(),
|
||||
pending_steer_history_records: VecDeque::new(),
|
||||
rejected_steers_queue: VecDeque::new(),
|
||||
rejected_steer_history_records: VecDeque::new(),
|
||||
queued_user_messages: VecDeque::new(),
|
||||
queued_user_message_history_records: VecDeque::new(),
|
||||
user_turn_pending_start: false,
|
||||
current_collaboration_mode: chat.current_collaboration_mode.clone(),
|
||||
active_collaboration_mask: chat.active_collaboration_mask.clone(),
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn goal_menu_active_snapshot() {
|
||||
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
let thread_id = ThreadId::new();
|
||||
|
||||
chat.show_goal_summary(test_goal(
|
||||
thread_id,
|
||||
AppThreadGoalStatus::Active,
|
||||
/*token_budget*/ Some(80_000),
|
||||
));
|
||||
|
||||
assert_chatwidget_snapshot!("goal_menu_active", rendered_goal_summary(&mut rx));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn goal_menu_paused_snapshot() {
|
||||
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
let thread_id = ThreadId::new();
|
||||
|
||||
chat.show_goal_summary(test_goal(
|
||||
thread_id,
|
||||
AppThreadGoalStatus::Paused,
|
||||
/*token_budget*/ None,
|
||||
));
|
||||
|
||||
assert_chatwidget_snapshot!("goal_menu_paused", rendered_goal_summary(&mut rx));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn goal_menu_budget_limited_snapshot() {
|
||||
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
let thread_id = ThreadId::new();
|
||||
|
||||
chat.show_goal_summary(test_goal(
|
||||
thread_id,
|
||||
AppThreadGoalStatus::BudgetLimited,
|
||||
/*token_budget*/ Some(80_000),
|
||||
));
|
||||
|
||||
assert_chatwidget_snapshot!("goal_menu_budget_limited", rendered_goal_summary(&mut rx));
|
||||
}
|
||||
|
||||
fn test_goal(
|
||||
thread_id: ThreadId,
|
||||
status: AppThreadGoalStatus,
|
||||
token_budget: Option<i64>,
|
||||
) -> AppThreadGoal {
|
||||
AppThreadGoal {
|
||||
thread_id: thread_id.to_string(),
|
||||
objective: "Keep improving the bare goal command until it feels calm and useful."
|
||||
.to_string(),
|
||||
status,
|
||||
token_budget,
|
||||
tokens_used: 12_500,
|
||||
time_used_seconds: 90,
|
||||
created_at: 1_776_272_400,
|
||||
updated_at: 1_776_272_460,
|
||||
}
|
||||
}
|
||||
|
||||
fn rendered_goal_summary(
|
||||
rx: &mut tokio::sync::mpsc::UnboundedReceiver<crate::app_event::AppEvent>,
|
||||
) -> String {
|
||||
drain_insert_history(rx)
|
||||
.iter()
|
||||
.map(|lines| lines_to_single_string(lines))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n")
|
||||
}
|
||||
@@ -256,6 +256,7 @@ pub(super) async fn make_chatwidget_manual(
|
||||
suppress_queue_autosend: false,
|
||||
thread_id: None,
|
||||
last_turn_id: None,
|
||||
budget_limited_turn_ids: HashSet::new(),
|
||||
thread_name: None,
|
||||
thread_rename_block_message: None,
|
||||
active_side_conversation: false,
|
||||
@@ -267,8 +268,10 @@ pub(super) async fn make_chatwidget_manual(
|
||||
show_welcome_banner: true,
|
||||
startup_tooltip_override: None,
|
||||
queued_user_messages: VecDeque::new(),
|
||||
queued_user_message_history_records: VecDeque::new(),
|
||||
user_turn_pending_start: false,
|
||||
rejected_steers_queue: VecDeque::new(),
|
||||
rejected_steer_history_records: VecDeque::new(),
|
||||
pending_steers: VecDeque::new(),
|
||||
submit_pending_steers_after_interrupt: false,
|
||||
queued_message_edit_binding: crate::key_hint::alt(KeyCode::Up),
|
||||
@@ -304,6 +307,9 @@ pub(super) async fn make_chatwidget_manual(
|
||||
status_line_branch_cwd: None,
|
||||
status_line_branch_pending: false,
|
||||
status_line_branch_lookup_complete: false,
|
||||
current_goal_status_indicator: None,
|
||||
current_goal_status: None,
|
||||
goal_status_active_turn_started_at: None,
|
||||
external_editor_state: ExternalEditorState::Closed,
|
||||
realtime_conversation: RealtimeConversationUiState::default(),
|
||||
last_rendered_user_message_event: None,
|
||||
@@ -584,6 +590,7 @@ pub(super) fn complete_assistant_message(
|
||||
pub(super) fn pending_steer(text: &str) -> PendingSteer {
|
||||
PendingSteer {
|
||||
user_message: UserMessage::from(text),
|
||||
history_record: UserMessageHistoryRecord::UserMessageText,
|
||||
compare_key: PendingSteerCompareKey {
|
||||
message: text.to_string(),
|
||||
image_count: 0,
|
||||
|
||||
@@ -441,8 +441,11 @@ async fn restore_thread_input_state_restores_pending_steers_without_downgrading_
|
||||
chat.restore_thread_input_state(Some(ThreadInputState {
|
||||
composer: None,
|
||||
pending_steers,
|
||||
pending_steer_history_records: VecDeque::new(),
|
||||
rejected_steers_queue,
|
||||
rejected_steer_history_records: VecDeque::new(),
|
||||
queued_user_messages,
|
||||
queued_user_message_history_records: VecDeque::new(),
|
||||
user_turn_pending_start: false,
|
||||
current_collaboration_mode: chat.current_collaboration_mode.clone(),
|
||||
active_collaboration_mask: chat.active_collaboration_mask.clone(),
|
||||
@@ -1051,6 +1054,24 @@ async fn ctrl_c_shutdown_works_with_caps_lock() {
|
||||
assert_matches!(rx.try_recv(), Ok(AppEvent::Exit(ExitMode::ShutdownFirst)));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn ctrl_c_interrupts_without_arming_quit_when_double_press_disabled() {
|
||||
let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
chat.bottom_pane.set_task_running(/*running*/ true);
|
||||
|
||||
chat.handle_key_event(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL));
|
||||
|
||||
next_interrupt_op(&mut op_rx);
|
||||
assert_matches!(rx.try_recv(), Err(TryRecvError::Empty));
|
||||
assert!(!chat.bottom_pane.quit_shortcut_hint_visible());
|
||||
|
||||
chat.handle_key_event(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL));
|
||||
|
||||
next_interrupt_op(&mut op_rx);
|
||||
assert_matches!(rx.try_recv(), Err(TryRecvError::Empty));
|
||||
assert!(!chat.bottom_pane.quit_shortcut_hint_visible());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn ctrl_c_closes_realtime_conversation_before_interrupt_or_quit() {
|
||||
let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
@@ -1300,6 +1321,132 @@ async fn interrupted_turn_error_message_snapshot() {
|
||||
assert_chatwidget_snapshot!("interrupted_turn_error_message", last);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn interrupted_turn_after_goal_budget_limited_uses_budget_message_snapshot() {
|
||||
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
chat.set_feature_enabled(Feature::Goals, /*enabled*/ true);
|
||||
|
||||
chat.handle_server_notification(
|
||||
codex_app_server_protocol::ServerNotification::TurnStarted(
|
||||
codex_app_server_protocol::TurnStartedNotification {
|
||||
thread_id: "thread-1".to_string(),
|
||||
turn: codex_app_server_protocol::Turn {
|
||||
id: "turn-1".to_string(),
|
||||
items: Vec::new(),
|
||||
status: codex_app_server_protocol::TurnStatus::InProgress,
|
||||
error: None,
|
||||
started_at: None,
|
||||
completed_at: None,
|
||||
duration_ms: None,
|
||||
},
|
||||
},
|
||||
),
|
||||
/*replay_kind*/ None,
|
||||
);
|
||||
chat.handle_server_notification(
|
||||
codex_app_server_protocol::ServerNotification::ThreadGoalUpdated(
|
||||
codex_app_server_protocol::ThreadGoalUpdatedNotification {
|
||||
thread_id: "thread-1".to_string(),
|
||||
turn_id: Some("turn-1".to_string()),
|
||||
goal: codex_app_server_protocol::ThreadGoal {
|
||||
thread_id: "thread-1".to_string(),
|
||||
objective: "Run until the token budget is limited".to_string(),
|
||||
status: codex_app_server_protocol::ThreadGoalStatus::BudgetLimited,
|
||||
token_budget: Some(10_000),
|
||||
tokens_used: 10_500,
|
||||
time_used_seconds: 0,
|
||||
created_at: 0,
|
||||
updated_at: 1,
|
||||
},
|
||||
},
|
||||
),
|
||||
/*replay_kind*/ None,
|
||||
);
|
||||
chat.handle_server_notification(
|
||||
codex_app_server_protocol::ServerNotification::TurnCompleted(
|
||||
codex_app_server_protocol::TurnCompletedNotification {
|
||||
thread_id: "thread-1".to_string(),
|
||||
turn: codex_app_server_protocol::Turn {
|
||||
id: "turn-1".to_string(),
|
||||
items: Vec::new(),
|
||||
status: codex_app_server_protocol::TurnStatus::Interrupted,
|
||||
error: None,
|
||||
started_at: None,
|
||||
completed_at: None,
|
||||
duration_ms: None,
|
||||
},
|
||||
},
|
||||
),
|
||||
/*replay_kind*/ None,
|
||||
);
|
||||
|
||||
let cells = drain_insert_history(&mut rx);
|
||||
let last = lines_to_single_string(cells.last().unwrap());
|
||||
assert_chatwidget_snapshot!("interrupted_turn_goal_budget_limited_message", last);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn direct_budget_limited_turn_uses_budget_message_snapshot() {
|
||||
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
|
||||
chat.handle_codex_event(Event {
|
||||
id: "task-1".into(),
|
||||
msg: EventMsg::TurnStarted(TurnStartedEvent {
|
||||
turn_id: "turn-1".to_string(),
|
||||
started_at: None,
|
||||
model_context_window: None,
|
||||
collaboration_mode_kind: ModeKind::Default,
|
||||
}),
|
||||
});
|
||||
chat.handle_codex_event(Event {
|
||||
id: "task-1".into(),
|
||||
msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent {
|
||||
turn_id: Some("turn-1".to_string()),
|
||||
reason: TurnAbortReason::BudgetLimited,
|
||||
completed_at: None,
|
||||
duration_ms: None,
|
||||
}),
|
||||
});
|
||||
|
||||
let cells = drain_insert_history(&mut rx);
|
||||
let last = lines_to_single_string(cells.last().unwrap());
|
||||
assert_chatwidget_snapshot!("direct_budget_limited_turn_message", last);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn budget_limited_turn_restores_queued_input_without_submitting() {
|
||||
let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
chat.queued_user_messages
|
||||
.push_back(UserMessage::from("follow-up after budget stop").into());
|
||||
chat.refresh_pending_input_preview();
|
||||
|
||||
chat.handle_codex_event(Event {
|
||||
id: "task-1".into(),
|
||||
msg: EventMsg::TurnStarted(TurnStartedEvent {
|
||||
turn_id: "turn-1".to_string(),
|
||||
started_at: None,
|
||||
model_context_window: None,
|
||||
collaboration_mode_kind: ModeKind::Default,
|
||||
}),
|
||||
});
|
||||
chat.handle_codex_event(Event {
|
||||
id: "task-1".into(),
|
||||
msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent {
|
||||
turn_id: Some("turn-1".to_string()),
|
||||
reason: TurnAbortReason::BudgetLimited,
|
||||
completed_at: None,
|
||||
duration_ms: None,
|
||||
}),
|
||||
});
|
||||
|
||||
assert!(chat.queued_user_messages.is_empty());
|
||||
assert_eq!(
|
||||
chat.bottom_pane.composer_text(),
|
||||
"follow-up after budget stop"
|
||||
);
|
||||
assert_no_submit_op(&mut op_rx);
|
||||
}
|
||||
|
||||
// Snapshot test: interrupting specifically to submit pending steers shows an
|
||||
// informational banner instead of the generic "tell the model what to do
|
||||
// differently" error prompt.
|
||||
|
||||
@@ -30,6 +30,19 @@ fn recall_latest_after_clearing(chat: &mut ChatWidget) -> String {
|
||||
chat.bottom_pane.composer_text()
|
||||
}
|
||||
|
||||
fn next_add_to_history_op(op_rx: &mut tokio::sync::mpsc::UnboundedReceiver<Op>) -> String {
|
||||
loop {
|
||||
match op_rx.try_recv() {
|
||||
Ok(Op::AddToHistory { text }) => return text,
|
||||
Ok(_) => continue,
|
||||
Err(TryRecvError::Empty) => panic!("expected AddToHistory op but queue was empty"),
|
||||
Err(TryRecvError::Disconnected) => {
|
||||
panic!("expected AddToHistory op but channel closed")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn slash_compact_eagerly_queues_follow_up_before_turn_start() {
|
||||
let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
@@ -664,6 +677,445 @@ async fn inline_slash_command_is_available_from_local_recall_after_dispatch() {
|
||||
assert_eq!(chat.bottom_pane.composer_text(), "/rename Better title");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn goal_slash_command_emits_set_goal_event() {
|
||||
let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
chat.set_feature_enabled(Feature::Goals, /*enabled*/ true);
|
||||
let thread_id = ThreadId::new();
|
||||
chat.thread_id = Some(thread_id);
|
||||
let command = "/goal --tokens 98.5K improve benchmark coverage";
|
||||
|
||||
submit_composer_text(&mut chat, command);
|
||||
|
||||
let event = rx.try_recv().expect("expected goal objective event");
|
||||
let AppEvent::SetThreadGoalObjective {
|
||||
thread_id: actual_thread_id,
|
||||
objective,
|
||||
mode,
|
||||
} = event
|
||||
else {
|
||||
panic!("expected SetThreadGoalObjective, got {event:?}");
|
||||
};
|
||||
assert_eq!(actual_thread_id, thread_id);
|
||||
assert_eq!(objective, "--tokens 98.5K improve benchmark coverage");
|
||||
assert_eq!(mode, crate::app_event::ThreadGoalSetMode::ConfirmIfExists);
|
||||
assert_no_submit_op(&mut op_rx);
|
||||
assert_eq!(recall_latest_after_clearing(&mut chat), command);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn goal_slash_command_uses_plain_text_for_mentions() {
|
||||
let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
chat.set_feature_enabled(Feature::Goals, /*enabled*/ true);
|
||||
let thread_id = ThreadId::new();
|
||||
chat.thread_id = Some(thread_id);
|
||||
chat.bottom_pane.set_composer_text_with_mention_bindings(
|
||||
"/goal use $figma for the mockup".to_string(),
|
||||
Vec::new(),
|
||||
Vec::new(),
|
||||
vec![MentionBinding {
|
||||
mention: "figma".to_string(),
|
||||
path: "app://figma".to_string(),
|
||||
}],
|
||||
);
|
||||
|
||||
chat.handle_key_event(KeyEvent::new(KeyCode::End, KeyModifiers::NONE));
|
||||
chat.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
|
||||
chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
||||
|
||||
let event = rx.try_recv().expect("expected goal objective event");
|
||||
let AppEvent::SetThreadGoalObjective {
|
||||
thread_id: actual_thread_id,
|
||||
objective,
|
||||
..
|
||||
} = event
|
||||
else {
|
||||
panic!("expected SetThreadGoalObjective, got {event:?}");
|
||||
};
|
||||
assert_eq!(actual_thread_id, thread_id);
|
||||
assert_eq!(objective, "use $figma for the mockup");
|
||||
assert_no_submit_op(&mut op_rx);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn goal_slash_command_drops_attached_images() {
|
||||
let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
chat.set_feature_enabled(Feature::Goals, /*enabled*/ true);
|
||||
let thread_id = ThreadId::new();
|
||||
chat.thread_id = Some(thread_id);
|
||||
let remote_url = "https://example.com/goal.png".to_string();
|
||||
let local_image = PathBuf::from("/tmp/goal-local.png");
|
||||
let placeholder = "[Image #2]";
|
||||
let command = format!("/goal describe {placeholder}");
|
||||
let placeholder_start = command.find(placeholder).expect("placeholder in command");
|
||||
chat.set_remote_image_urls(vec![remote_url]);
|
||||
chat.bottom_pane.set_composer_text(
|
||||
command,
|
||||
vec![TextElement::new(
|
||||
(placeholder_start..placeholder_start + placeholder.len()).into(),
|
||||
Some(placeholder.to_string()),
|
||||
)],
|
||||
vec![local_image],
|
||||
);
|
||||
|
||||
chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
||||
|
||||
let event = rx.try_recv().expect("expected goal objective event");
|
||||
let AppEvent::SetThreadGoalObjective {
|
||||
thread_id: actual_thread_id,
|
||||
objective,
|
||||
..
|
||||
} = event
|
||||
else {
|
||||
panic!("expected SetThreadGoalObjective, got {event:?}");
|
||||
};
|
||||
assert_eq!(actual_thread_id, thread_id);
|
||||
assert_eq!(objective, "describe [Image #2]");
|
||||
assert!(chat.remote_image_urls().is_empty());
|
||||
assert!(chat.bottom_pane.composer_local_image_paths().is_empty());
|
||||
assert_no_submit_op(&mut op_rx);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn bare_goal_slash_command_drains_pending_submission_state() {
|
||||
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
chat.set_feature_enabled(Feature::Goals, /*enabled*/ true);
|
||||
let thread_id = ThreadId::new();
|
||||
chat.thread_id = Some(thread_id);
|
||||
let remote_url = "https://example.com/goal-menu.png".to_string();
|
||||
let local_image = PathBuf::from("/tmp/goal-menu-local.png");
|
||||
chat.set_remote_image_urls(vec![remote_url]);
|
||||
chat.bottom_pane
|
||||
.set_composer_text("/goal".to_string(), Vec::new(), vec![local_image]);
|
||||
|
||||
chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
||||
|
||||
assert_matches!(
|
||||
rx.try_recv(),
|
||||
Ok(AppEvent::OpenThreadGoalMenu { thread_id: opened }) if opened == thread_id
|
||||
);
|
||||
assert!(chat.remote_image_urls().is_empty());
|
||||
assert!(chat.bottom_pane.composer_local_image_paths().is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn goal_control_slash_commands_emit_goal_events() {
|
||||
let cases = [
|
||||
("/goal clear", None),
|
||||
("/goal pause", Some(AppThreadGoalStatus::Paused)),
|
||||
("/goal unpause", Some(AppThreadGoalStatus::Active)),
|
||||
];
|
||||
|
||||
for (command, status) in cases {
|
||||
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
chat.set_feature_enabled(Feature::Goals, /*enabled*/ true);
|
||||
let thread_id = ThreadId::new();
|
||||
chat.thread_id = Some(thread_id);
|
||||
|
||||
submit_composer_text(&mut chat, command);
|
||||
|
||||
match status {
|
||||
Some(status) => {
|
||||
let event = rx.try_recv().expect("expected goal status event");
|
||||
let AppEvent::SetThreadGoalStatus {
|
||||
thread_id: actual_thread_id,
|
||||
status: actual_status,
|
||||
} = event
|
||||
else {
|
||||
panic!("expected SetThreadGoalStatus, got {event:?}");
|
||||
};
|
||||
assert_eq!(actual_thread_id, thread_id);
|
||||
assert_eq!(actual_status, status);
|
||||
}
|
||||
None => {
|
||||
let event = rx.try_recv().expect("expected clear goal event");
|
||||
let AppEvent::ClearThreadGoal {
|
||||
thread_id: actual_thread_id,
|
||||
} = event
|
||||
else {
|
||||
panic!("expected ClearThreadGoal, got {event:?}");
|
||||
};
|
||||
assert_eq!(actual_thread_id, thread_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn queued_goal_slash_command_emits_set_goal_event_after_thread_starts() {
|
||||
let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
chat.set_feature_enabled(Feature::Goals, /*enabled*/ true);
|
||||
let command = "/goal improve benchmark coverage";
|
||||
|
||||
submit_composer_text(&mut chat, command);
|
||||
assert_eq!(chat.queued_user_messages.len(), 1);
|
||||
assert_matches!(op_rx.try_recv(), Err(TryRecvError::Empty));
|
||||
|
||||
let thread_id = ThreadId::new();
|
||||
chat.thread_id = Some(thread_id);
|
||||
chat.maybe_send_next_queued_input();
|
||||
|
||||
let event = rx.try_recv().expect("expected goal objective event");
|
||||
let AppEvent::SetThreadGoalObjective {
|
||||
thread_id: actual_thread_id,
|
||||
objective,
|
||||
..
|
||||
} = event
|
||||
else {
|
||||
panic!("expected SetThreadGoalObjective, got {event:?}");
|
||||
};
|
||||
assert_eq!(actual_thread_id, thread_id);
|
||||
assert_eq!(objective, "improve benchmark coverage");
|
||||
assert_no_submit_op(&mut op_rx);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn queued_goal_slash_command_preserves_current_draft_metadata() {
|
||||
let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
chat.set_feature_enabled(Feature::Goals, /*enabled*/ true);
|
||||
let command = "/goal improve benchmark coverage";
|
||||
|
||||
submit_composer_text(&mut chat, command);
|
||||
assert_matches!(op_rx.try_recv(), Err(TryRecvError::Empty));
|
||||
|
||||
let remote_url = "https://example.com/current-draft.png".to_string();
|
||||
let local_image = PathBuf::from("/tmp/current-draft-local.png");
|
||||
let placeholder = "[Image #3]";
|
||||
let draft = format!("draft with {placeholder}");
|
||||
let placeholder_start = draft.find(placeholder).expect("placeholder in draft");
|
||||
chat.set_remote_image_urls(vec![remote_url.clone()]);
|
||||
chat.bottom_pane.set_composer_text(
|
||||
draft.clone(),
|
||||
vec![TextElement::new(
|
||||
(placeholder_start..placeholder_start + placeholder.len()).into(),
|
||||
Some(placeholder.to_string()),
|
||||
)],
|
||||
vec![local_image.clone()],
|
||||
);
|
||||
|
||||
let thread_id = ThreadId::new();
|
||||
chat.thread_id = Some(thread_id);
|
||||
chat.maybe_send_next_queued_input();
|
||||
|
||||
let event = rx.try_recv().expect("expected goal objective event");
|
||||
assert_matches!(
|
||||
event,
|
||||
AppEvent::SetThreadGoalObjective {
|
||||
thread_id: actual_thread_id,
|
||||
..
|
||||
} if actual_thread_id == thread_id
|
||||
);
|
||||
assert_no_submit_op(&mut op_rx);
|
||||
assert_eq!(chat.bottom_pane.composer_text(), draft);
|
||||
assert_eq!(chat.remote_image_urls(), vec![remote_url]);
|
||||
assert_eq!(
|
||||
chat.bottom_pane.composer_local_image_paths(),
|
||||
vec![local_image]
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn restored_queued_goal_slash_command_emits_set_goal_event() {
|
||||
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
chat.set_feature_enabled(Feature::Goals, /*enabled*/ true);
|
||||
let command = "/goal improve benchmark coverage";
|
||||
|
||||
submit_composer_text(&mut chat, command);
|
||||
let input_state = chat
|
||||
.capture_thread_input_state()
|
||||
.expect("expected queued input state");
|
||||
|
||||
let (mut restored_chat, mut restored_rx, mut restored_op_rx) =
|
||||
make_chatwidget_manual(/*model_override*/ None).await;
|
||||
restored_chat.set_feature_enabled(Feature::Goals, /*enabled*/ true);
|
||||
restored_chat.restore_thread_input_state(Some(input_state));
|
||||
let thread_id = ThreadId::new();
|
||||
restored_chat.thread_id = Some(thread_id);
|
||||
restored_chat.maybe_send_next_queued_input();
|
||||
|
||||
let event = restored_rx
|
||||
.try_recv()
|
||||
.expect("expected goal objective event");
|
||||
assert_matches!(
|
||||
event,
|
||||
AppEvent::SetThreadGoalObjective {
|
||||
thread_id: actual_thread_id,
|
||||
..
|
||||
} if actual_thread_id == thread_id
|
||||
);
|
||||
assert_no_submit_op(&mut restored_op_rx);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merged_history_record_preserves_raw_text_and_rebased_elements() {
|
||||
let first = UserMessage {
|
||||
text: "Ask $figma".to_string(),
|
||||
local_images: Vec::new(),
|
||||
remote_image_urls: Vec::new(),
|
||||
text_elements: vec![TextElement::new((4..10).into(), Some("$figma".to_string()))],
|
||||
mention_bindings: vec![MentionBinding {
|
||||
mention: "figma".to_string(),
|
||||
path: "app://figma".to_string(),
|
||||
}],
|
||||
};
|
||||
let second = UserMessage::from("internal prompt");
|
||||
|
||||
let (_message, history_record) = merge_user_messages_with_history_record(vec![
|
||||
(first, UserMessageHistoryRecord::UserMessageText),
|
||||
(
|
||||
second,
|
||||
UserMessageHistoryRecord::Override(UserMessageHistoryOverride {
|
||||
text: "/goal inspect [Image #1]".to_string(),
|
||||
text_elements: vec![TextElement::new(
|
||||
(14..24).into(),
|
||||
Some("[Image #1]".to_string()),
|
||||
)],
|
||||
}),
|
||||
),
|
||||
]);
|
||||
|
||||
assert_eq!(
|
||||
history_record,
|
||||
UserMessageHistoryRecord::Override(UserMessageHistoryOverride {
|
||||
text: "Ask $figma\n/goal inspect [Image #1]".to_string(),
|
||||
text_elements: vec![
|
||||
TextElement::new((4..10).into(), Some("$figma".to_string())),
|
||||
TextElement::new((25..35).into(), Some("[Image #1]".to_string())),
|
||||
],
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merged_history_record_remaps_override_image_placeholders() {
|
||||
let first_placeholder = "[Image #1]";
|
||||
let second_placeholder = "[Image #1]";
|
||||
let first = UserMessage {
|
||||
text: format!("first {first_placeholder}"),
|
||||
local_images: vec![LocalImageAttachment {
|
||||
placeholder: first_placeholder.to_string(),
|
||||
path: PathBuf::from("/tmp/first.png"),
|
||||
}],
|
||||
remote_image_urls: Vec::new(),
|
||||
text_elements: vec![TextElement::new(
|
||||
(6..16).into(),
|
||||
Some(first_placeholder.to_string()),
|
||||
)],
|
||||
mention_bindings: Vec::new(),
|
||||
};
|
||||
let second = UserMessage {
|
||||
text: format!("internal {second_placeholder}"),
|
||||
local_images: vec![LocalImageAttachment {
|
||||
placeholder: second_placeholder.to_string(),
|
||||
path: PathBuf::from("/tmp/second.png"),
|
||||
}],
|
||||
remote_image_urls: Vec::new(),
|
||||
text_elements: vec![TextElement::new(
|
||||
(9..19).into(),
|
||||
Some(second_placeholder.to_string()),
|
||||
)],
|
||||
mention_bindings: Vec::new(),
|
||||
};
|
||||
|
||||
let (message, history_record) = merge_user_messages_with_history_record(vec![
|
||||
(first, UserMessageHistoryRecord::UserMessageText),
|
||||
(
|
||||
second,
|
||||
UserMessageHistoryRecord::Override(UserMessageHistoryOverride {
|
||||
text: format!("goal {second_placeholder}"),
|
||||
text_elements: vec![TextElement::new(
|
||||
(5..15).into(),
|
||||
Some(second_placeholder.to_string()),
|
||||
)],
|
||||
}),
|
||||
),
|
||||
]);
|
||||
|
||||
assert_eq!(message.text, "first [Image #1]\ninternal [Image #2]");
|
||||
assert_eq!(
|
||||
message.text_elements,
|
||||
vec![
|
||||
TextElement::new((6..16).into(), Some("[Image #1]".to_string())),
|
||||
TextElement::new((26..36).into(), Some("[Image #2]".to_string())),
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
message
|
||||
.local_images
|
||||
.iter()
|
||||
.map(|image| image.placeholder.as_str())
|
||||
.collect::<Vec<_>>(),
|
||||
vec!["[Image #1]", "[Image #2]"]
|
||||
);
|
||||
assert_eq!(
|
||||
history_record,
|
||||
UserMessageHistoryRecord::Override(UserMessageHistoryOverride {
|
||||
text: "first [Image #1]\ngoal [Image #2]".to_string(),
|
||||
text_elements: vec![
|
||||
TextElement::new((6..16).into(), Some("[Image #1]".to_string())),
|
||||
TextElement::new((22..32).into(), Some("[Image #2]".to_string())),
|
||||
],
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn interrupted_merged_message_history_encodes_mentions_once() {
|
||||
let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
chat.thread_id = Some(ThreadId::new());
|
||||
chat.on_task_started();
|
||||
chat.on_agent_message_delta("Final answer line\n".to_string());
|
||||
let text = "use $figma now";
|
||||
chat.bottom_pane.set_composer_text_with_mention_bindings(
|
||||
text.to_string(),
|
||||
Vec::new(),
|
||||
Vec::new(),
|
||||
vec![MentionBinding {
|
||||
mention: "figma".to_string(),
|
||||
path: "app://figma".to_string(),
|
||||
}],
|
||||
);
|
||||
|
||||
chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
||||
|
||||
match next_submit_op(&mut op_rx) {
|
||||
Op::UserTurn { items, .. } => {
|
||||
let [
|
||||
UserInput::Text {
|
||||
text: submitted, ..
|
||||
},
|
||||
] = items.as_slice()
|
||||
else {
|
||||
panic!("expected text item, got {items:?}");
|
||||
};
|
||||
assert_eq!(submitted, text);
|
||||
}
|
||||
other => panic!("expected user turn, got {other:?}"),
|
||||
}
|
||||
let encoded = "use [$figma](app://figma) now";
|
||||
assert_eq!(next_add_to_history_op(&mut op_rx), encoded);
|
||||
|
||||
chat.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
|
||||
next_interrupt_op(&mut op_rx);
|
||||
chat.on_interrupted_turn(TurnAbortReason::Interrupted);
|
||||
|
||||
match next_submit_op(&mut op_rx) {
|
||||
Op::UserTurn { items, .. } => {
|
||||
let [
|
||||
UserInput::Text {
|
||||
text: submitted, ..
|
||||
},
|
||||
] = items.as_slice()
|
||||
else {
|
||||
panic!("expected resubmitted text item, got {items:?}");
|
||||
};
|
||||
assert_eq!(submitted, text);
|
||||
}
|
||||
other => panic!("expected resubmitted user turn, got {other:?}"),
|
||||
}
|
||||
assert_eq!(next_add_to_history_op(&mut op_rx), encoded);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn slash_rename_prefills_existing_thread_name() {
|
||||
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
@@ -1034,6 +1486,91 @@ async fn agent_turn_complete_notification_does_not_reuse_stale_copy_source() {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn active_goal_without_follow_up_suppresses_agent_turn_complete_notification() {
|
||||
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
chat.set_feature_enabled(Feature::Goals, /*enabled*/ true);
|
||||
chat.handle_server_notification(
|
||||
ServerNotification::ThreadGoalUpdated(
|
||||
codex_app_server_protocol::ThreadGoalUpdatedNotification {
|
||||
thread_id: "thread-1".to_string(),
|
||||
turn_id: None,
|
||||
goal: codex_app_server_protocol::ThreadGoal {
|
||||
thread_id: "thread-1".to_string(),
|
||||
objective: "finish the benchmark".to_string(),
|
||||
status: codex_app_server_protocol::ThreadGoalStatus::Active,
|
||||
token_budget: None,
|
||||
tokens_used: 0,
|
||||
time_used_seconds: 0,
|
||||
created_at: 1,
|
||||
updated_at: 1,
|
||||
},
|
||||
},
|
||||
),
|
||||
/*replay_kind*/ None,
|
||||
);
|
||||
|
||||
chat.handle_codex_event(Event {
|
||||
id: "turn-1".into(),
|
||||
msg: EventMsg::TurnComplete(turn_complete_event("turn-1", Some("Still working"))),
|
||||
});
|
||||
|
||||
assert_matches!(chat.pending_notification, None);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn queued_follow_up_suppresses_agent_turn_complete_notification() {
|
||||
let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
chat.thread_id = Some(ThreadId::new());
|
||||
chat.handle_codex_event(Event {
|
||||
id: "turn-1".into(),
|
||||
msg: EventMsg::TurnStarted(TurnStartedEvent {
|
||||
turn_id: "turn-1".to_string(),
|
||||
started_at: None,
|
||||
model_context_window: None,
|
||||
collaboration_mode_kind: ModeKind::Default,
|
||||
}),
|
||||
});
|
||||
chat.queue_user_message("Continue".into());
|
||||
|
||||
chat.handle_codex_event(Event {
|
||||
id: "turn-1".into(),
|
||||
msg: EventMsg::TurnComplete(turn_complete_event("turn-1", Some("Still working"))),
|
||||
});
|
||||
|
||||
assert_matches!(chat.pending_notification, None);
|
||||
assert!(chat.queued_user_messages.is_empty());
|
||||
assert_matches!(next_submit_op(&mut op_rx), Op::UserTurn { .. });
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn queued_menu_slash_keeps_agent_turn_complete_notification() {
|
||||
let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(Some("gpt-5.2")).await;
|
||||
chat.thread_id = Some(ThreadId::new());
|
||||
chat.handle_codex_event(Event {
|
||||
id: "turn-1".into(),
|
||||
msg: EventMsg::TurnStarted(TurnStartedEvent {
|
||||
turn_id: "turn-1".to_string(),
|
||||
started_at: None,
|
||||
model_context_window: None,
|
||||
collaboration_mode_kind: ModeKind::Default,
|
||||
}),
|
||||
});
|
||||
queue_composer_text_with_tab(&mut chat, "/model");
|
||||
|
||||
chat.handle_codex_event(Event {
|
||||
id: "turn-1".into(),
|
||||
msg: EventMsg::TurnComplete(turn_complete_event("turn-1", Some("Done"))),
|
||||
});
|
||||
|
||||
assert_matches!(
|
||||
chat.pending_notification,
|
||||
Some(Notification::AgentTurnComplete { ref response }) if response == "Done"
|
||||
);
|
||||
assert!(render_bottom_popup(&chat, /*width*/ 80).contains("Select Model"));
|
||||
assert_matches!(op_rx.try_recv(), Err(TryRecvError::Empty));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn slash_copy_uses_latest_surviving_response_after_rollback() {
|
||||
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
use super::*;
|
||||
use crate::bottom_pane::goal_status_indicator_line;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
/// Receiving a TokenCount event without usage clears the context indicator.
|
||||
@@ -1628,6 +1629,279 @@ async fn status_line_model_with_reasoning_context_remaining_footer_snapshot() {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn status_line_goal_active_token_budget_footer_snapshot() {
|
||||
use ratatui::Terminal;
|
||||
use ratatui::backend::TestBackend;
|
||||
|
||||
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.4")).await;
|
||||
chat.set_feature_enabled(Feature::Goals, /*enabled*/ true);
|
||||
chat.show_welcome_banner = false;
|
||||
chat.config.tui_status_line = Some(vec!["model-name".to_string()]);
|
||||
chat.refresh_status_line();
|
||||
chat.handle_server_notification(
|
||||
ServerNotification::ThreadGoalUpdated(
|
||||
codex_app_server_protocol::ThreadGoalUpdatedNotification {
|
||||
thread_id: "thread-1".to_string(),
|
||||
turn_id: None,
|
||||
goal: test_thread_goal(
|
||||
codex_app_server_protocol::ThreadGoalStatus::Active,
|
||||
/*token_budget*/ Some(50_000),
|
||||
/*tokens_used*/ 40_000,
|
||||
),
|
||||
},
|
||||
),
|
||||
/*replay_kind*/ None,
|
||||
);
|
||||
|
||||
let width = 80;
|
||||
let height = chat.desired_height(width);
|
||||
let mut terminal = Terminal::new(TestBackend::new(width, height)).expect("create terminal");
|
||||
terminal
|
||||
.draw(|f| chat.render(f.area(), f.buffer_mut()))
|
||||
.expect("draw goal status footer");
|
||||
assert_chatwidget_snapshot!(
|
||||
"status_line_goal_active_token_budget_footer",
|
||||
normalized_backend_snapshot(terminal.backend())
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn status_line_goal_complete_elapsed_footer_snapshot() {
|
||||
use ratatui::Terminal;
|
||||
use ratatui::backend::TestBackend;
|
||||
|
||||
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.4")).await;
|
||||
chat.set_feature_enabled(Feature::Goals, /*enabled*/ true);
|
||||
chat.show_welcome_banner = false;
|
||||
chat.config.tui_status_line = Some(vec!["model-name".to_string()]);
|
||||
chat.refresh_status_line();
|
||||
chat.handle_server_notification(
|
||||
ServerNotification::ThreadGoalUpdated(
|
||||
codex_app_server_protocol::ThreadGoalUpdatedNotification {
|
||||
thread_id: "thread-1".to_string(),
|
||||
turn_id: None,
|
||||
goal: test_thread_goal(
|
||||
codex_app_server_protocol::ThreadGoalStatus::Complete,
|
||||
/*token_budget*/ None,
|
||||
/*tokens_used*/ 40_000,
|
||||
),
|
||||
},
|
||||
),
|
||||
/*replay_kind*/ None,
|
||||
);
|
||||
|
||||
let width = 80;
|
||||
let height = chat.desired_height(width);
|
||||
let mut terminal = Terminal::new(TestBackend::new(width, height)).expect("create terminal");
|
||||
terminal
|
||||
.draw(|f| chat.render(f.area(), f.buffer_mut()))
|
||||
.expect("draw goal status footer");
|
||||
assert_chatwidget_snapshot!(
|
||||
"status_line_goal_complete_elapsed_footer",
|
||||
normalized_backend_snapshot(terminal.backend())
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn session_configured_clears_goal_status_footer() {
|
||||
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.4")).await;
|
||||
chat.set_feature_enabled(Feature::Goals, /*enabled*/ true);
|
||||
chat.handle_server_notification(
|
||||
ServerNotification::ThreadGoalUpdated(
|
||||
codex_app_server_protocol::ThreadGoalUpdatedNotification {
|
||||
thread_id: "thread-1".to_string(),
|
||||
turn_id: None,
|
||||
goal: test_thread_goal(
|
||||
codex_app_server_protocol::ThreadGoalStatus::Active,
|
||||
/*token_budget*/ Some(50_000),
|
||||
/*tokens_used*/ 40_000,
|
||||
),
|
||||
},
|
||||
),
|
||||
/*replay_kind*/ None,
|
||||
);
|
||||
assert_eq!(
|
||||
chat.current_goal_status_indicator,
|
||||
Some(GoalStatusIndicator::Active {
|
||||
usage: Some("40K / 50K".to_string())
|
||||
})
|
||||
);
|
||||
chat.budget_limited_turn_ids.insert("turn-1".to_string());
|
||||
|
||||
let rollout_file = NamedTempFile::new().unwrap();
|
||||
chat.handle_codex_event(Event {
|
||||
id: "session-2".into(),
|
||||
msg: EventMsg::SessionConfigured(SessionConfiguredEvent {
|
||||
session_id: ThreadId::new(),
|
||||
forked_from_id: None,
|
||||
thread_name: None,
|
||||
model: "gpt-5.4".to_string(),
|
||||
model_provider_id: "test-provider".to_string(),
|
||||
service_tier: None,
|
||||
approval_policy: AskForApproval::Never,
|
||||
approvals_reviewer: ApprovalsReviewer::User,
|
||||
sandbox_policy: SandboxPolicy::new_read_only_policy(),
|
||||
permission_profile: None,
|
||||
cwd: test_path_buf("/home/user/project").abs(),
|
||||
reasoning_effort: Some(ReasoningEffortConfig::default()),
|
||||
history_log_id: 0,
|
||||
history_entry_count: 0,
|
||||
initial_messages: None,
|
||||
network_proxy: None,
|
||||
rollout_path: Some(rollout_file.path().to_path_buf()),
|
||||
}),
|
||||
});
|
||||
|
||||
assert_eq!(chat.current_goal_status_indicator, None);
|
||||
assert!(chat.budget_limited_turn_ids.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn thread_goal_update_for_other_thread_is_ignored() {
|
||||
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.4")).await;
|
||||
chat.set_feature_enabled(Feature::Goals, /*enabled*/ true);
|
||||
chat.thread_id = Some(ThreadId::new());
|
||||
let other_thread_id = ThreadId::new().to_string();
|
||||
let mut goal = test_thread_goal(
|
||||
codex_app_server_protocol::ThreadGoalStatus::BudgetLimited,
|
||||
/*token_budget*/ Some(50_000),
|
||||
/*tokens_used*/ 50_000,
|
||||
);
|
||||
goal.thread_id = other_thread_id.clone();
|
||||
|
||||
chat.handle_server_notification(
|
||||
ServerNotification::ThreadGoalUpdated(
|
||||
codex_app_server_protocol::ThreadGoalUpdatedNotification {
|
||||
thread_id: other_thread_id,
|
||||
turn_id: Some("turn-other".to_string()),
|
||||
goal,
|
||||
},
|
||||
),
|
||||
/*replay_kind*/ None,
|
||||
);
|
||||
|
||||
assert_eq!(chat.current_goal_status_indicator, None);
|
||||
assert!(chat.current_goal_status.is_none());
|
||||
assert!(chat.budget_limited_turn_ids.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn goal_status_indicator_formats_statuses_and_budgets() {
|
||||
assert_eq!(
|
||||
goal_status_indicator_from_app_goal(&test_thread_goal(
|
||||
codex_app_server_protocol::ThreadGoalStatus::Active,
|
||||
/*token_budget*/ Some(50_000),
|
||||
/*tokens_used*/ 40_000,
|
||||
)),
|
||||
Some(GoalStatusIndicator::Active {
|
||||
usage: Some("40K / 50K".to_string()),
|
||||
})
|
||||
);
|
||||
assert_eq!(
|
||||
goal_status_indicator_from_app_goal(&test_thread_goal(
|
||||
codex_app_server_protocol::ThreadGoalStatus::Active,
|
||||
/*token_budget*/ None,
|
||||
/*tokens_used*/ 0,
|
||||
)),
|
||||
Some(GoalStatusIndicator::Active {
|
||||
usage: Some("30m".to_string()),
|
||||
})
|
||||
);
|
||||
assert_eq!(
|
||||
goal_status_indicator_from_app_goal(&test_thread_goal(
|
||||
codex_app_server_protocol::ThreadGoalStatus::BudgetLimited,
|
||||
/*token_budget*/ Some(50_000),
|
||||
/*tokens_used*/ 51_000,
|
||||
)),
|
||||
Some(GoalStatusIndicator::BudgetLimited {
|
||||
usage: Some("51K / 50K tokens".to_string()),
|
||||
})
|
||||
);
|
||||
assert_eq!(
|
||||
goal_status_indicator_from_app_goal(&test_thread_goal(
|
||||
codex_app_server_protocol::ThreadGoalStatus::BudgetLimited,
|
||||
/*token_budget*/ None,
|
||||
/*tokens_used*/ 0,
|
||||
)),
|
||||
Some(GoalStatusIndicator::BudgetLimited { usage: None })
|
||||
);
|
||||
assert_eq!(
|
||||
goal_status_indicator_from_app_goal(&test_thread_goal(
|
||||
codex_app_server_protocol::ThreadGoalStatus::Complete,
|
||||
/*token_budget*/ Some(50_000),
|
||||
/*tokens_used*/ 40_000,
|
||||
)),
|
||||
Some(GoalStatusIndicator::Complete {
|
||||
usage: Some("40K tokens".to_string()),
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn goal_status_indicator_line_formats_goal_text() {
|
||||
let cases = [
|
||||
(
|
||||
GoalStatusIndicator::Active {
|
||||
usage: Some("4K / 5K".to_string()),
|
||||
},
|
||||
"Pursuing goal (4K / 5K)",
|
||||
),
|
||||
(
|
||||
GoalStatusIndicator::BudgetLimited {
|
||||
usage: Some("4K / 5K tokens".to_string()),
|
||||
},
|
||||
"Goal unmet (4K / 5K tokens)",
|
||||
),
|
||||
(
|
||||
GoalStatusIndicator::Paused,
|
||||
"Goal paused (/goal to unpause)",
|
||||
),
|
||||
(
|
||||
GoalStatusIndicator::BudgetLimited { usage: None },
|
||||
"Goal abandoned",
|
||||
),
|
||||
(
|
||||
GoalStatusIndicator::Complete {
|
||||
usage: Some("10h 12m".to_string()),
|
||||
},
|
||||
"Goal achieved (10h 12m)",
|
||||
),
|
||||
(
|
||||
GoalStatusIndicator::Complete { usage: None },
|
||||
"Goal achieved",
|
||||
),
|
||||
];
|
||||
|
||||
for (indicator, expected) in cases {
|
||||
let line =
|
||||
goal_status_indicator_line(Some(&indicator)).expect("goal indicator should render");
|
||||
let actual = line
|
||||
.spans
|
||||
.iter()
|
||||
.map(|span| span.content.as_ref())
|
||||
.collect::<String>();
|
||||
assert_eq!(expected, actual);
|
||||
}
|
||||
}
|
||||
|
||||
fn test_thread_goal(
|
||||
status: codex_app_server_protocol::ThreadGoalStatus,
|
||||
token_budget: Option<i64>,
|
||||
tokens_used: i64,
|
||||
) -> codex_app_server_protocol::ThreadGoal {
|
||||
codex_app_server_protocol::ThreadGoal {
|
||||
thread_id: "thread-1".to_string(),
|
||||
objective: "Keep improving the benchmark".to_string(),
|
||||
status,
|
||||
token_budget,
|
||||
tokens_used,
|
||||
time_used_seconds: 30 * 60,
|
||||
created_at: 0,
|
||||
updated_at: 0,
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn runtime_metrics_websocket_timing_logs_and_final_separator_sums_totals() {
|
||||
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
|
||||
Reference in New Issue
Block a user