mirror of
https://github.com/openai/codex.git
synced 2026-11-04 08:25:29 +00:00
[1 of 3] Support long raw TUI goal objectives (#27508)
## Stack 1. **[1 of 3] Support long raw TUI goal objectives** - this PR 2. [2 of 3] Support long pasted text in TUI goals - #27509 3. [3 of 3] Support images in TUI goals - #27510 ## Why `thread/goal/set` limits persisted objective text to 4000 characters. The TUI used to reject raw `/goal` objectives above that limit, even though the client can make them usable by writing the long text to a file and storing a short objective that points at that file. This also needs to work for remote app-server sessions: filesystem API calls must create files on the app-server host, and the stored path must be meaningful to the agent on that host. ## What Changed - Adds an app-server-host path helper so TUI code can build paths that are resolved on the app-server host rather than the TUI host. - Adds TUI app-server session helpers for `fs/createDirectory`, `fs/writeFile`, `fs/readFile`, and `fs/remove` that work for embedded and remote app-server sessions without changing the app-server protocol. - Materializes oversized raw `/goal` objectives into `$CODEX_HOME/attachments/<uuid>/goal-objective.md` through the app-server filesystem APIs, then stores a short, readable objective that directs the agent to that file. - Reads managed objective files back for `/goal edit`. Other goal UI renders the readable stored objective normally, without managed-file-specific presentation logic. - Recognizes managed references only when they name the expected generated file under the app server's reported `$CODEX_HOME`, and cleans up newly materialized files when goal replacement or setting does not complete. ## Verification - Added/updated TUI tests for raw oversized `/goal` submission, large inline-paste expansion, queued oversized goals, app-facing materialization before `thread/goal/set`, managed-path validation, editing, and cleanup. - Added/updated app-server-client remote coverage for initialized remote Codex home handling. ## Manual Testing - Ran the real TUI against a Unix-socket app server with different local and server `$CODEX_HOME` directories. Oversized goals wrote only under the server home, and persisted references used the server-canonical path rather than the TUI path. - Exercised 3,999-, 4,000-, and 4,001-character raw objectives. The first two stayed inline without new files; the 4,001-character objective became a managed objective file. - Submitted a larger 8,275-character objective, verified its full contents on the app-server host, and observed the goal continuation open the referenced server-side file. - Opened `/goal edit` for a managed objective and verified the full text was restored through remote `fs/readFile`. - Submitted an oversized replacement while a goal was active, verified no file was written before confirmation, then canceled and confirmed that the existing goal and attachment count were unchanged.
This commit is contained in:
1 parent
d61dfeb23a
commit
78bab04116
17 files changed
+561
-249
No files matched your search
@@ -1,6 +1,5 @@
|
||||
use super::*;
|
||||
use codex_protocol::protocol::MAX_THREAD_GOAL_OBJECTIVE_CHARS;
|
||||
use codex_protocol::user_input::MAX_USER_INPUT_TEXT_CHARS;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
fn complete_turn_with_message(chat: &mut ChatWidget, turn_id: &str, message: Option<&str>) {
|
||||
@@ -33,25 +32,22 @@ fn queue_composer_text_with_tab(chat: &mut ChatWidget, text: &str) {
|
||||
chat.handle_key_event(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE));
|
||||
}
|
||||
|
||||
fn drain_app_events(rx: &mut tokio::sync::mpsc::UnboundedReceiver<AppEvent>) -> Vec<AppEvent> {
|
||||
std::iter::from_fn(|| rx.try_recv().ok()).collect()
|
||||
}
|
||||
|
||||
fn rendered_insert_history(events: &[AppEvent]) -> String {
|
||||
events
|
||||
.iter()
|
||||
.filter_map(|event| match event {
|
||||
AppEvent::InsertHistoryCell(cell) => Some(
|
||||
cell.display_lines(/*width*/ 80)
|
||||
.into_iter()
|
||||
.map(|line| line.to_string())
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n"),
|
||||
),
|
||||
_ => None,
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n")
|
||||
fn next_goal_objective(
|
||||
rx: &mut tokio::sync::mpsc::UnboundedReceiver<AppEvent>,
|
||||
expected_thread_id: ThreadId,
|
||||
) -> String {
|
||||
loop {
|
||||
let event = rx.try_recv().expect("expected goal objective event");
|
||||
if let AppEvent::SetThreadGoalObjective {
|
||||
thread_id,
|
||||
objective,
|
||||
..
|
||||
} = event
|
||||
{
|
||||
assert_eq!(thread_id, expected_thread_id);
|
||||
return objective;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -104,40 +100,29 @@ async fn goal_slash_command_accepts_multiline_objective_after_blank_first_line()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn goal_slash_command_rejects_oversized_objective() {
|
||||
async fn goal_slash_command_emits_oversized_objective() {
|
||||
let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
chat.set_feature_enabled(Feature::Goals, /*enabled*/ true);
|
||||
chat.thread_id = Some(ThreadId::new());
|
||||
let thread_id = ThreadId::new();
|
||||
chat.thread_id = Some(thread_id);
|
||||
let objective = "x".repeat(MAX_THREAD_GOAL_OBJECTIVE_CHARS + 1);
|
||||
|
||||
submit_composer_text(&mut chat, &format!("/goal {objective}"));
|
||||
|
||||
let events = drain_app_events(&mut rx);
|
||||
assert!(
|
||||
!events
|
||||
.iter()
|
||||
.any(|event| matches!(event, AppEvent::SetThreadGoalObjective { .. })),
|
||||
"oversized goal should not emit a SetThreadGoalObjective event: {events:?}"
|
||||
);
|
||||
let rendered = rendered_insert_history(&events);
|
||||
assert!(rendered.contains("Goal objective is too long"));
|
||||
assert!(rendered.contains("Put longer instructions in a file"));
|
||||
assert!(
|
||||
!rendered.contains("Message exceeds the maximum length"),
|
||||
"expected goal-specific length error, got {rendered:?}"
|
||||
);
|
||||
assert_eq!(next_goal_objective(&mut rx, thread_id), objective);
|
||||
assert_no_submit_op(&mut op_rx);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn goal_slash_command_rejects_large_paste_using_expanded_length() {
|
||||
async fn goal_slash_command_expands_large_pasted_objective() {
|
||||
let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
chat.set_feature_enabled(Feature::Goals, /*enabled*/ true);
|
||||
chat.thread_id = Some(ThreadId::new());
|
||||
let thread_id = ThreadId::new();
|
||||
chat.thread_id = Some(thread_id);
|
||||
let objective = "x".repeat(MAX_THREAD_GOAL_OBJECTIVE_CHARS + 1);
|
||||
chat.bottom_pane
|
||||
.set_composer_text("/goal ".to_string(), Vec::new(), Vec::new());
|
||||
let objective = "x".repeat(MAX_THREAD_GOAL_OBJECTIVE_CHARS + 1);
|
||||
chat.handle_paste(objective);
|
||||
chat.handle_paste(objective.clone());
|
||||
|
||||
assert!(
|
||||
chat.bottom_pane.composer_text().contains("[Pasted Content"),
|
||||
@@ -145,56 +130,16 @@ async fn goal_slash_command_rejects_large_paste_using_expanded_length() {
|
||||
);
|
||||
submit_current_composer(&mut chat);
|
||||
|
||||
let events = drain_app_events(&mut rx);
|
||||
assert!(
|
||||
!events
|
||||
.iter()
|
||||
.any(|event| matches!(event, AppEvent::SetThreadGoalObjective { .. })),
|
||||
"oversized pasted goal should not emit a SetThreadGoalObjective event: {events:?}"
|
||||
);
|
||||
let rendered = rendered_insert_history(&events);
|
||||
assert!(rendered.contains("Goal objective is too long"));
|
||||
assert!(rendered.contains("Put longer instructions in a file"));
|
||||
assert!(
|
||||
!rendered.contains("Message exceeds the maximum length"),
|
||||
"expected goal-specific length error, got {rendered:?}"
|
||||
);
|
||||
assert_eq!(next_goal_objective(&mut rx, thread_id), objective);
|
||||
assert_no_submit_op(&mut op_rx);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn goal_slash_command_giant_paste_uses_goal_specific_error() {
|
||||
async fn queued_goal_slash_command_emits_oversized_objective_and_stops_queue() {
|
||||
let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
chat.set_feature_enabled(Feature::Goals, /*enabled*/ true);
|
||||
chat.thread_id = Some(ThreadId::new());
|
||||
chat.bottom_pane
|
||||
.set_composer_text("/goal ".to_string(), Vec::new(), Vec::new());
|
||||
chat.handle_paste("x".repeat(MAX_USER_INPUT_TEXT_CHARS + 1));
|
||||
|
||||
submit_current_composer(&mut chat);
|
||||
|
||||
let events = drain_app_events(&mut rx);
|
||||
assert!(
|
||||
!events
|
||||
.iter()
|
||||
.any(|event| matches!(event, AppEvent::SetThreadGoalObjective { .. })),
|
||||
"giant pasted goal should not emit a SetThreadGoalObjective event: {events:?}"
|
||||
);
|
||||
let rendered = rendered_insert_history(&events);
|
||||
assert!(rendered.contains("Goal objective is too long"));
|
||||
assert!(rendered.contains("Put longer instructions in a file"));
|
||||
assert!(
|
||||
!rendered.contains("Message exceeds the maximum length"),
|
||||
"expected goal-specific length error, got {rendered:?}"
|
||||
);
|
||||
assert_no_submit_op(&mut op_rx);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn queued_goal_slash_command_rejects_oversized_objective_and_drains_next_input() {
|
||||
let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
chat.set_feature_enabled(Feature::Goals, /*enabled*/ true);
|
||||
chat.thread_id = Some(ThreadId::new());
|
||||
let thread_id = ThreadId::new();
|
||||
chat.thread_id = Some(thread_id);
|
||||
handle_turn_started(&mut chat, "turn-1");
|
||||
let objective = "x".repeat(MAX_THREAD_GOAL_OBJECTIVE_CHARS + 1);
|
||||
|
||||
@@ -204,26 +149,7 @@ async fn queued_goal_slash_command_rejects_oversized_objective_and_drains_next_i
|
||||
|
||||
complete_turn_with_message(&mut chat, "turn-1", Some("done"));
|
||||
|
||||
let events = drain_app_events(&mut rx);
|
||||
assert!(
|
||||
!events
|
||||
.iter()
|
||||
.any(|event| matches!(event, AppEvent::SetThreadGoalObjective { .. })),
|
||||
"oversized queued goal should not emit a SetThreadGoalObjective event: {events:?}"
|
||||
);
|
||||
let rendered = rendered_insert_history(&events);
|
||||
assert!(rendered.contains("Goal objective is too long"));
|
||||
assert!(rendered.contains("Put longer instructions in a file"));
|
||||
match next_submit_op(&mut op_rx) {
|
||||
Op::UserTurn { items, .. } => assert_eq!(
|
||||
items,
|
||||
vec![UserInput::Text {
|
||||
text: "continue".to_string(),
|
||||
text_elements: Vec::new(),
|
||||
}]
|
||||
),
|
||||
other => panic!("expected queued follow-up after oversized goal, got {other:?}"),
|
||||
}
|
||||
assert!(chat.input_queue.queued_user_messages.is_empty());
|
||||
assert_eq!(next_goal_objective(&mut rx, thread_id), objective);
|
||||
assert_eq!(chat.input_queue.queued_user_messages.len(), 1);
|
||||
assert_no_submit_op(&mut op_rx);
|
||||
}
|
||||
@@ -1207,7 +1207,7 @@ pub(super) fn render_bottom_first_row(chat: &ChatWidget, width: u16) -> String {
|
||||
String::new()
|
||||
}
|
||||
|
||||
pub(super) fn render_bottom_popup(chat: &ChatWidget, width: u16) -> String {
|
||||
pub(crate) fn render_bottom_popup(chat: &ChatWidget, width: u16) -> String {
|
||||
let height = chat.desired_height(width);
|
||||
let area = Rect::new(0, 0, width, height);
|
||||
let mut buf = Buffer::empty(area);
|
||||
|
||||
Reference in new issue
Block a user