mirror of
https://github.com/openai/codex.git
synced 2026-11-10 16:44:38 +00:00
[2 of 3] Support long pasted text in TUI goals (#27509)
## Stack 1. [1 of 3] Support long raw TUI goal objectives - #27508 2. **[2 of 3] Support long pasted text in TUI goals** - this PR 3. [3 of 3] Support images in TUI goals - #27510 ## Why Large text pasted into the TUI composer is represented as a paste placeholder plus pending paste metadata. For `/goal`, preserving only the visible placeholder is not enough: the agent would see a short placeholder string instead of the actual pasted text, and the long-text support from the first PR would never see the payload. The TUI also needs to avoid writing stale sidecar files when a user pastes a large block and then deletes its placeholder before submitting the goal. ## What Changed - Introduces a TUI `GoalDraft` for goal submissions so `/goal`, `/goal edit`, and queued goal commands can carry objective text plus text elements and pending paste payloads. - Materializes active pasted-text placeholders to `pasted-text-N.txt` files through the app-server filesystem path introduced in #27508. - Rewrites active paste placeholders in the persisted objective to file references, while leaving literal placeholder-looking text alone. - Filters out deleted paste placeholders so otherwise-small goals do not require `$CODEX_HOME` or remote filesystem writes. - Preserves pending paste metadata when a `/goal` command is queued before a thread exists. ## Verification - Added goal materialization tests for active paste placeholders, deleted paste placeholders, and whitespace-only paste payloads. - Added/updated TUI slash-command tests for large pasted text, queued `/goal` commands before thread start, and queued oversized goal behavior. ## Manual Testing - Used real terminal bracketed-paste sequences through a remote TUI session. A 1,228-byte multiline paste became `pasted-text-1.txt`; its first/last lines and byte count matched exactly, and the persisted objective referenced the server-host path. - Pasted a large block, deleted its placeholder, and submitted a small replacement objective. No new directory or sidecar file was created. - Added two same-length large pastes to one goal. The composer disambiguated their visible placeholders, and materialization preserved order and contents in `pasted-text-1.txt` and `pasted-text-2.txt`. - Submitted a whitespace-only large paste and verified the goal was rejected as empty without writing a file. - Submitted a pasted-text replacement while another goal was active, verified no file was written before confirmation, then canceled and confirmed the original goal remained unchanged. - Combined a large paste with enough raw text to exceed 4,000 characters after placeholder rewriting. The paste sidecar and `goal-objective.md` were created in the same remote attachment directory, and `/goal edit` restored the rewritten objective with its sidecar reference.
This commit is contained in:
1 parent
216dee1189
commit
c0f1ec5afd
17 files changed
+600
-201
No files matched your search
@@ -123,9 +123,9 @@ async fn goal_edit_prompt_submits_preserved_status_and_budget() {
|
||||
chat.handle_key_event(KeyEvent::from(KeyCode::Enter));
|
||||
|
||||
match rx.try_recv() {
|
||||
Ok(AppEvent::SetThreadGoalObjective {
|
||||
Ok(AppEvent::SetThreadGoalDraft {
|
||||
thread_id: event_thread_id,
|
||||
objective,
|
||||
draft,
|
||||
mode:
|
||||
crate::app_event::ThreadGoalSetMode::UpdateExisting {
|
||||
status,
|
||||
@@ -134,13 +134,13 @@ async fn goal_edit_prompt_submits_preserved_status_and_budget() {
|
||||
}) => {
|
||||
assert_eq!(event_thread_id, thread_id);
|
||||
assert_eq!(
|
||||
objective,
|
||||
draft.objective,
|
||||
"Keep improving the bare goal command until it feels calm and useful. with clearer wording"
|
||||
);
|
||||
assert_eq!(status, AppThreadGoalStatus::Paused);
|
||||
assert_eq!(token_budget, Some(80_000));
|
||||
}
|
||||
other => panic!("expected SetThreadGoalObjective event, got {other:?}"),
|
||||
other => panic!("expected SetThreadGoalDraft event, got {other:?}"),
|
||||
}
|
||||
assert!(chat.no_modal_or_popup_active());
|
||||
}
|
||||
@@ -165,7 +165,7 @@ async fn goal_edit_prompt_preserves_resumable_stopped_statuses() {
|
||||
chat.handle_key_event(KeyEvent::from(KeyCode::Enter));
|
||||
|
||||
match rx.try_recv() {
|
||||
Ok(AppEvent::SetThreadGoalObjective {
|
||||
Ok(AppEvent::SetThreadGoalDraft {
|
||||
mode:
|
||||
crate::app_event::ThreadGoalSetMode::UpdateExisting {
|
||||
status,
|
||||
@@ -176,7 +176,7 @@ async fn goal_edit_prompt_preserves_resumable_stopped_statuses() {
|
||||
assert_eq!(status, stopped_status);
|
||||
assert_eq!(token_budget, Some(80_000));
|
||||
}
|
||||
other => panic!("expected SetThreadGoalObjective event, got {other:?}"),
|
||||
other => panic!("expected SetThreadGoalDraft event, got {other:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -203,7 +203,7 @@ async fn goal_edit_prompt_resets_terminal_status_to_active() {
|
||||
chat.handle_key_event(KeyEvent::from(KeyCode::Enter));
|
||||
|
||||
match rx.try_recv() {
|
||||
Ok(AppEvent::SetThreadGoalObjective {
|
||||
Ok(AppEvent::SetThreadGoalDraft {
|
||||
mode:
|
||||
crate::app_event::ThreadGoalSetMode::UpdateExisting {
|
||||
status,
|
||||
@@ -214,7 +214,7 @@ async fn goal_edit_prompt_resets_terminal_status_to_active() {
|
||||
assert_eq!(status, AppThreadGoalStatus::Active);
|
||||
assert_eq!(token_budget, Some(80_000));
|
||||
}
|
||||
other => panic!("expected SetThreadGoalObjective event, got {other:?}"),
|
||||
other => panic!("expected SetThreadGoalDraft event, got {other:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,14 +38,12 @@ fn next_goal_objective(
|
||||
) -> String {
|
||||
loop {
|
||||
let event = rx.try_recv().expect("expected goal objective event");
|
||||
if let AppEvent::SetThreadGoalObjective {
|
||||
thread_id,
|
||||
objective,
|
||||
..
|
||||
if let AppEvent::SetThreadGoalDraft {
|
||||
thread_id, draft, ..
|
||||
} = event
|
||||
{
|
||||
assert_eq!(thread_id, expected_thread_id);
|
||||
return objective;
|
||||
return draft.objective;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -62,16 +60,16 @@ async fn goal_slash_command_accepts_objective_at_limit() {
|
||||
submit_composer_text(&mut chat, &command);
|
||||
|
||||
let event = rx.try_recv().expect("expected goal objective event");
|
||||
let AppEvent::SetThreadGoalObjective {
|
||||
let AppEvent::SetThreadGoalDraft {
|
||||
thread_id: actual_thread_id,
|
||||
objective: actual_objective,
|
||||
draft,
|
||||
..
|
||||
} = event
|
||||
else {
|
||||
panic!("expected SetThreadGoalObjective, got {event:?}");
|
||||
panic!("expected SetThreadGoalDraft, got {event:?}");
|
||||
};
|
||||
assert_eq!(actual_thread_id, thread_id);
|
||||
assert_eq!(actual_objective, objective);
|
||||
assert_eq!(draft.objective, objective);
|
||||
assert_no_submit_op(&mut op_rx);
|
||||
}
|
||||
|
||||
@@ -86,16 +84,16 @@ async fn goal_slash_command_accepts_multiline_objective_after_blank_first_line()
|
||||
submit_composer_text(&mut chat, &format!("/goal \n\n{objective}"));
|
||||
|
||||
let event = rx.try_recv().expect("expected goal objective event");
|
||||
let AppEvent::SetThreadGoalObjective {
|
||||
let AppEvent::SetThreadGoalDraft {
|
||||
thread_id: actual_thread_id,
|
||||
objective: actual_objective,
|
||||
draft,
|
||||
..
|
||||
} = event
|
||||
else {
|
||||
panic!("expected SetThreadGoalObjective, got {event:?}");
|
||||
panic!("expected SetThreadGoalDraft, got {event:?}");
|
||||
};
|
||||
assert_eq!(actual_thread_id, thread_id);
|
||||
assert_eq!(actual_objective, objective);
|
||||
assert_eq!(draft.objective, objective);
|
||||
assert_no_submit_op(&mut op_rx);
|
||||
}
|
||||
|
||||
@@ -114,7 +112,7 @@ async fn goal_slash_command_emits_oversized_objective() {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn goal_slash_command_expands_large_pasted_objective() {
|
||||
async fn goal_slash_command_preserves_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);
|
||||
let thread_id = ThreadId::new();
|
||||
@@ -130,7 +128,8 @@ async fn goal_slash_command_expands_large_pasted_objective() {
|
||||
);
|
||||
submit_current_composer(&mut chat);
|
||||
|
||||
assert_eq!(next_goal_objective(&mut rx, thread_id), objective);
|
||||
let draft = next_goal_draft(&mut rx, thread_id);
|
||||
assert_eq!(draft.pending_pastes[0].1, objective);
|
||||
assert_no_submit_op(&mut op_rx);
|
||||
}
|
||||
|
||||
@@ -153,3 +152,33 @@ async fn queued_goal_slash_command_emits_oversized_objective_and_stops_queue() {
|
||||
assert_eq!(chat.input_queue.queued_user_messages.len(), 1);
|
||||
assert_no_submit_op(&mut op_rx);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn goal_slash_command_emits_only_inserted_paste_text_element() {
|
||||
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 paste = "x".repeat(1_001);
|
||||
let placeholder = format!("[Pasted Content {} chars]", paste.chars().count());
|
||||
chat.bottom_pane.set_composer_text(
|
||||
format!("/goal keep literal {placeholder} and "),
|
||||
Vec::new(),
|
||||
Vec::new(),
|
||||
);
|
||||
chat.handle_paste(paste.clone());
|
||||
|
||||
submit_current_composer(&mut chat);
|
||||
|
||||
let draft = next_goal_draft(&mut rx, thread_id);
|
||||
assert!(
|
||||
draft
|
||||
.objective
|
||||
.contains(&format!("keep literal {placeholder} and {placeholder}")),
|
||||
"expected literal placeholder and inserted paste placeholder, got {:?}",
|
||||
draft.objective
|
||||
);
|
||||
assert_eq!(draft.pending_pastes, vec![(placeholder, paste)]);
|
||||
assert!(chat.bottom_pane.composer_pending_pastes().is_empty());
|
||||
assert_no_submit_op(&mut op_rx);
|
||||
}
|
||||
@@ -65,6 +65,13 @@ fn queue_composer_text_with_tab(chat: &mut ChatWidget, text: &str) {
|
||||
chat.handle_key_event(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE));
|
||||
}
|
||||
|
||||
fn queue_goal_with_large_paste(chat: &mut ChatWidget, paste: String) {
|
||||
chat.bottom_pane
|
||||
.set_composer_text("/goal ".to_string(), Vec::new(), Vec::new());
|
||||
chat.handle_paste(paste);
|
||||
chat.handle_key_event(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE));
|
||||
}
|
||||
|
||||
fn recall_latest_after_clearing(chat: &mut ChatWidget) -> String {
|
||||
chat.bottom_pane
|
||||
.set_composer_text(String::new(), Vec::new(), Vec::new());
|
||||
@@ -608,17 +615,17 @@ async fn goal_slash_command_with_extra_os_emits_set_goal_event() {
|
||||
|
||||
submit_composer_text(&mut chat, command);
|
||||
|
||||
let event = rx.try_recv().expect("expected goal objective event");
|
||||
let AppEvent::SetThreadGoalObjective {
|
||||
let event = rx.try_recv().expect("expected goal draft event");
|
||||
let AppEvent::SetThreadGoalDraft {
|
||||
thread_id: actual_thread_id,
|
||||
objective,
|
||||
draft,
|
||||
mode,
|
||||
} = event
|
||||
else {
|
||||
panic!("expected SetThreadGoalObjective, got {event:?}");
|
||||
panic!("expected SetThreadGoalDraft, got {event:?}");
|
||||
};
|
||||
assert_eq!(actual_thread_id, thread_id);
|
||||
assert_eq!(objective, "--tokens 98.5K improve benchmark coverage");
|
||||
assert_eq!(draft.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);
|
||||
@@ -645,17 +652,8 @@ async fn goal_slash_command_uses_plain_text_for_mentions() {
|
||||
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");
|
||||
let draft = next_goal_draft(&mut rx, thread_id);
|
||||
assert_eq!(draft.objective, "use $figma for the mockup");
|
||||
assert_no_submit_op(&mut op_rx);
|
||||
}
|
||||
|
||||
@@ -682,17 +680,8 @@ async fn goal_slash_command_drops_attached_images() {
|
||||
|
||||
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]");
|
||||
let draft = next_goal_draft(&mut rx, thread_id);
|
||||
assert_eq!(draft.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);
|
||||
@@ -813,20 +802,77 @@ async fn queued_goal_slash_command_emits_set_goal_event_after_thread_starts() {
|
||||
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");
|
||||
let draft = next_goal_draft(&mut rx, thread_id);
|
||||
assert_eq!(draft.objective, "improve benchmark coverage");
|
||||
assert_no_submit_op(&mut op_rx);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn queued_goal_slash_command_preserves_large_paste() {
|
||||
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);
|
||||
handle_turn_started(&mut chat, "turn-1");
|
||||
let paste = "x".repeat(codex_protocol::user_input::MAX_USER_INPUT_TEXT_CHARS + 1);
|
||||
|
||||
queue_goal_with_large_paste(&mut chat, paste.clone());
|
||||
|
||||
assert_eq!(chat.input_queue.queued_user_messages.len(), 1);
|
||||
complete_turn_with_message(&mut chat, "turn-1", Some("done"));
|
||||
|
||||
let draft = next_goal_draft(&mut rx, thread_id);
|
||||
assert_eq!(draft.pending_pastes.len(), 1);
|
||||
assert_eq!(draft.pending_pastes[0].1, paste);
|
||||
assert!(draft.objective.contains(&draft.pending_pastes[0].0));
|
||||
assert_no_submit_op(&mut op_rx);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn queued_goal_slash_command_restores_large_paste_for_edit() {
|
||||
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);
|
||||
handle_turn_started(&mut chat, "turn-1");
|
||||
let paste = "x".repeat(codex_protocol::user_input::MAX_USER_INPUT_TEXT_CHARS + 1);
|
||||
|
||||
queue_goal_with_large_paste(&mut chat, paste.clone());
|
||||
chat.handle_key_event(KeyEvent::new(KeyCode::Up, KeyModifiers::ALT));
|
||||
|
||||
assert_eq!(chat.bottom_pane.composer_pending_pastes()[0].1, paste);
|
||||
complete_turn_with_message(&mut chat, "turn-1", Some("done"));
|
||||
submit_current_composer(&mut chat);
|
||||
|
||||
let draft = next_goal_draft(&mut rx, thread_id);
|
||||
assert_eq!(draft.pending_pastes.len(), 1);
|
||||
assert_eq!(draft.pending_pastes[0].1, paste);
|
||||
assert_no_submit_op(&mut op_rx);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn interrupt_disambiguates_same_sized_goal_pastes() {
|
||||
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
chat.set_feature_enabled(Feature::Goals, /*enabled*/ true);
|
||||
handle_turn_started(&mut chat, "turn-1");
|
||||
let first = "a".repeat(codex_protocol::user_input::MAX_USER_INPUT_TEXT_CHARS + 1);
|
||||
let second = "b".repeat(first.len());
|
||||
|
||||
queue_goal_with_large_paste(&mut chat, first);
|
||||
chat.bottom_pane
|
||||
.set_composer_text("/goal ".to_string(), Vec::new(), Vec::new());
|
||||
chat.handle_paste(second.clone());
|
||||
chat.on_interrupted_turn(TurnAbortReason::Interrupted);
|
||||
|
||||
chat.handle_key_event(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE));
|
||||
chat.handle_key_event(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE));
|
||||
|
||||
let remaining = chat.bottom_pane.composer_pending_pastes();
|
||||
assert_eq!(remaining.len(), 1);
|
||||
assert_eq!(remaining[0].1, second);
|
||||
assert!(chat.bottom_pane.composer_text().contains(&remaining[0].0));
|
||||
}
|
||||
|
||||
#[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;
|
||||
@@ -855,14 +901,7 @@ async fn queued_goal_slash_command_preserves_current_draft_metadata() {
|
||||
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
|
||||
);
|
||||
let _ = next_goal_draft(&mut rx, 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]);
|
||||
@@ -891,16 +930,7 @@ async fn restored_queued_goal_slash_command_emits_set_goal_event() {
|
||||
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
|
||||
);
|
||||
let _ = next_goal_draft(&mut restored_rx, thread_id);
|
||||
assert_no_submit_op(&mut restored_op_rx);
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user