mirror of
https://github.com/openai/codex.git
synced 2026-09-17 12:23:33 +00:00
Merge 1d1ef6f468 into sapling-pr-archive-bolinfest
This commit is contained in:
30
codex-rs/Cargo.lock
generated
30
codex-rs/Cargo.lock
generated
@@ -298,17 +298,6 @@ dependencies = [
|
||||
"syn 2.0.104",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async-trait"
|
||||
version = "0.1.88"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e539d3fca749fcee5236ab05e93a52867dd549cc157c8cb7f99595f3cedffdb5"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.104",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "atomic-waker"
|
||||
version = "1.1.2"
|
||||
@@ -1279,12 +1268,12 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deadpool"
|
||||
version = "0.10.0"
|
||||
version = "0.12.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fb84100978c1c7b37f09ed3ce3e5f843af02c2a2c431bae5b19230dad2c1b490"
|
||||
checksum = "0be2b1d1d6ec8d846f05e137292d0b89133caf95ef33695424c09568bdd39b1b"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"deadpool-runtime",
|
||||
"lazy_static",
|
||||
"num_cpus",
|
||||
"tokio",
|
||||
]
|
||||
@@ -2131,13 +2120,14 @@ checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
|
||||
|
||||
[[package]]
|
||||
name = "hyper"
|
||||
version = "1.6.0"
|
||||
version = "1.7.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cc2b571658e38e0c01b1fdca3bbbe93c00d3d71693ff2770043f8c29bc7d6f80"
|
||||
checksum = "eb3aa54a13a0dfe7fbe3a59e0c76093041720fdc77b110cc0fc260fafb4dc51e"
|
||||
dependencies = [
|
||||
"atomic-waker",
|
||||
"bytes",
|
||||
"futures-channel",
|
||||
"futures-util",
|
||||
"futures-core",
|
||||
"h2",
|
||||
"http",
|
||||
"http-body",
|
||||
@@ -2145,6 +2135,7 @@ dependencies = [
|
||||
"httpdate",
|
||||
"itoa",
|
||||
"pin-project-lite",
|
||||
"pin-utils",
|
||||
"smallvec",
|
||||
"tokio",
|
||||
"want",
|
||||
@@ -6272,12 +6263,11 @@ checksum = "d135d17ab770252ad95e9a872d365cf3090e3be864a34ab46f48555993efc904"
|
||||
|
||||
[[package]]
|
||||
name = "wiremock"
|
||||
version = "0.6.4"
|
||||
version = "0.6.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a2b8b99d4cdbf36b239a9532e31fe4fb8acc38d1897c1761e161550a7dc78e6a"
|
||||
checksum = "08db1edfb05d9b3c1542e521aea074442088292f00b5f28e435c714a98f85031"
|
||||
dependencies = [
|
||||
"assert-json-diff",
|
||||
"async-trait",
|
||||
"base64 0.22.1",
|
||||
"deadpool",
|
||||
"futures",
|
||||
|
||||
@@ -6,7 +6,6 @@ use codex_apply_patch::APPLY_PATCH_TOOL_INSTRUCTIONS;
|
||||
use codex_protocol::config_types::ReasoningEffort as ReasoningEffortConfig;
|
||||
use codex_protocol::config_types::ReasoningSummary as ReasoningSummaryConfig;
|
||||
use codex_protocol::config_types::Verbosity as VerbosityConfig;
|
||||
use codex_protocol::models::ContentItem;
|
||||
use codex_protocol::models::ResponseItem;
|
||||
use futures::Stream;
|
||||
use serde::Serialize;
|
||||
@@ -20,10 +19,6 @@ use tokio::sync::mpsc;
|
||||
/// with this content.
|
||||
const BASE_INSTRUCTIONS: &str = include_str!("../prompt.md");
|
||||
|
||||
/// wraps user instructions message in a tag for the model to parse more easily.
|
||||
const USER_INSTRUCTIONS_START: &str = "<user_instructions>\n\n";
|
||||
const USER_INSTRUCTIONS_END: &str = "\n\n</user_instructions>";
|
||||
|
||||
/// API request payload for a single model turn
|
||||
#[derive(Default, Debug, Clone)]
|
||||
pub struct Prompt {
|
||||
@@ -68,17 +63,6 @@ impl Prompt {
|
||||
pub(crate) fn get_formatted_input(&self) -> Vec<ResponseItem> {
|
||||
self.input.clone()
|
||||
}
|
||||
|
||||
/// Creates a formatted user instructions message from a string
|
||||
pub(crate) fn format_user_instructions_message(ui: &str) -> ResponseItem {
|
||||
ResponseItem::Message {
|
||||
id: None,
|
||||
role: "user".to_string(),
|
||||
content: vec![ContentItem::InputText {
|
||||
text: format!("{USER_INSTRUCTIONS_START}{ui}{USER_INSTRUCTIONS_END}"),
|
||||
}],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
|
||||
@@ -9,6 +9,7 @@ use std::sync::atomic::AtomicU64;
|
||||
use std::time::Duration;
|
||||
|
||||
use crate::AuthManager;
|
||||
use crate::event_mapping::map_response_item_to_event_messages;
|
||||
use async_channel::Receiver;
|
||||
use async_channel::Sender;
|
||||
use codex_apply_patch::ApplyPatchAction;
|
||||
@@ -75,9 +76,7 @@ use crate::project_doc::get_user_instructions;
|
||||
use crate::protocol::AgentMessageDeltaEvent;
|
||||
use crate::protocol::AgentMessageEvent;
|
||||
use crate::protocol::AgentReasoningDeltaEvent;
|
||||
use crate::protocol::AgentReasoningEvent;
|
||||
use crate::protocol::AgentReasoningRawContentDeltaEvent;
|
||||
use crate::protocol::AgentReasoningRawContentEvent;
|
||||
use crate::protocol::AgentReasoningSectionBreakEvent;
|
||||
use crate::protocol::ApplyPatchApprovalRequestEvent;
|
||||
use crate::protocol::AskForApproval;
|
||||
@@ -102,13 +101,13 @@ use crate::protocol::Submission;
|
||||
use crate::protocol::TaskCompleteEvent;
|
||||
use crate::protocol::TurnDiffEvent;
|
||||
use crate::protocol::WebSearchBeginEvent;
|
||||
use crate::protocol::WebSearchEndEvent;
|
||||
use crate::rollout::RolloutRecorder;
|
||||
use crate::safety::SafetyCheck;
|
||||
use crate::safety::assess_command_safety;
|
||||
use crate::safety::assess_safety_for_untrusted_command;
|
||||
use crate::shell;
|
||||
use crate::turn_diff_tracker::TurnDiffTracker;
|
||||
use crate::user_instructions::UserInstructions;
|
||||
use crate::user_notification::UserNotification;
|
||||
use crate::util::backoff;
|
||||
use codex_protocol::config_types::ReasoningEffort as ReasoningEffortConfig;
|
||||
@@ -117,12 +116,9 @@ use codex_protocol::custom_prompts::CustomPrompt;
|
||||
use codex_protocol::models::ContentItem;
|
||||
use codex_protocol::models::FunctionCallOutputPayload;
|
||||
use codex_protocol::models::LocalShellAction;
|
||||
use codex_protocol::models::ReasoningItemContent;
|
||||
use codex_protocol::models::ReasoningItemReasoningSummary;
|
||||
use codex_protocol::models::ResponseInputItem;
|
||||
use codex_protocol::models::ResponseItem;
|
||||
use codex_protocol::models::ShellToolCallParams;
|
||||
use codex_protocol::models::WebSearchAction;
|
||||
|
||||
// A convenience extension trait for acquiring mutex locks where poisoning is
|
||||
// unrecoverable and should abort the program. This avoids scattered `.unwrap()`
|
||||
@@ -199,6 +195,7 @@ impl Codex {
|
||||
config.clone(),
|
||||
auth_manager.clone(),
|
||||
tx_event.clone(),
|
||||
conversation_history.clone(),
|
||||
)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
@@ -361,6 +358,7 @@ impl Session {
|
||||
config: Arc<Config>,
|
||||
auth_manager: Arc<AuthManager>,
|
||||
tx_event: Sender<Event>,
|
||||
initial_history: InitialHistory,
|
||||
) -> anyhow::Result<(Arc<Self>, TurnContext)> {
|
||||
let session_id = Uuid::new_v4();
|
||||
let ConfigureSession {
|
||||
@@ -480,6 +478,12 @@ impl Session {
|
||||
});
|
||||
|
||||
// Dispatch the SessionConfiguredEvent first and then report any errors.
|
||||
// If resuming, include converted initial messages in the payload so UIs can render them immediately.
|
||||
let initial_messages = match &initial_history {
|
||||
InitialHistory::New => None,
|
||||
InitialHistory::Resumed(items) => Some(sess.build_initial_messages(items)),
|
||||
};
|
||||
|
||||
let events = std::iter::once(Event {
|
||||
id: INITIAL_SUBMIT_ID.to_owned(),
|
||||
msg: EventMsg::SessionConfigured(SessionConfiguredEvent {
|
||||
@@ -487,6 +491,7 @@ impl Session {
|
||||
model,
|
||||
history_log_id,
|
||||
history_entry_count,
|
||||
initial_messages,
|
||||
}),
|
||||
})
|
||||
.chain(post_session_configured_error_events.into_iter());
|
||||
@@ -537,7 +542,7 @@ impl Session {
|
||||
// TODO: Those items shouldn't be "user messages" IMO. Maybe developer messages.
|
||||
let mut conversation_items = Vec::<ResponseItem>::with_capacity(2);
|
||||
if let Some(user_instructions) = turn_context.user_instructions.as_deref() {
|
||||
conversation_items.push(Prompt::format_user_instructions_message(user_instructions));
|
||||
conversation_items.push(UserInstructions::new(user_instructions.to_string()).into());
|
||||
}
|
||||
conversation_items.push(ResponseItem::from(EnvironmentContext::new(
|
||||
Some(turn_context.cwd.clone()),
|
||||
@@ -552,6 +557,17 @@ impl Session {
|
||||
self.record_conversation_items(&items).await;
|
||||
}
|
||||
|
||||
/// build the initial messages vector for SessionConfigured by converting
|
||||
/// ResponseItems into EventMsg.
|
||||
fn build_initial_messages(&self, items: &[ResponseItem]) -> Vec<EventMsg> {
|
||||
items
|
||||
.iter()
|
||||
.flat_map(|item| {
|
||||
map_response_item_to_event_messages(item, self.show_raw_agent_reasoning)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Sends the given event to the client and swallows the send event, if
|
||||
/// any, logging it as an error.
|
||||
pub(crate) async fn send_event(&self, event: Event) {
|
||||
@@ -1915,53 +1931,6 @@ async fn handle_response_item(
|
||||
) -> CodexResult<Option<ResponseInputItem>> {
|
||||
debug!(?item, "Output item");
|
||||
let output = match item {
|
||||
ResponseItem::Message { content, .. } => {
|
||||
for item in content {
|
||||
if let ContentItem::OutputText { text } = item {
|
||||
let event = Event {
|
||||
id: sub_id.to_string(),
|
||||
msg: EventMsg::AgentMessage(AgentMessageEvent { message: text }),
|
||||
};
|
||||
sess.tx_event.send(event).await.ok();
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
ResponseItem::Reasoning {
|
||||
id: _,
|
||||
summary,
|
||||
content,
|
||||
encrypted_content: _,
|
||||
} => {
|
||||
for item in summary {
|
||||
let text = match item {
|
||||
ReasoningItemReasoningSummary::SummaryText { text } => text,
|
||||
};
|
||||
let event = Event {
|
||||
id: sub_id.to_string(),
|
||||
msg: EventMsg::AgentReasoning(AgentReasoningEvent { text }),
|
||||
};
|
||||
sess.tx_event.send(event).await.ok();
|
||||
}
|
||||
if sess.show_raw_agent_reasoning
|
||||
&& let Some(content) = content
|
||||
{
|
||||
for item in content {
|
||||
let text = match item {
|
||||
ReasoningItemContent::ReasoningText { text } => text,
|
||||
ReasoningItemContent::Text { text } => text,
|
||||
};
|
||||
let event = Event {
|
||||
id: sub_id.to_string(),
|
||||
msg: EventMsg::AgentReasoningRawContent(AgentReasoningRawContentEvent {
|
||||
text,
|
||||
}),
|
||||
};
|
||||
sess.tx_event.send(event).await.ok();
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
ResponseItem::FunctionCall {
|
||||
name,
|
||||
arguments,
|
||||
@@ -2051,12 +2020,14 @@ async fn handle_response_item(
|
||||
debug!("unexpected CustomToolCallOutput from stream");
|
||||
None
|
||||
}
|
||||
ResponseItem::WebSearchCall { id, action, .. } => {
|
||||
if let WebSearchAction::Search { query } = action {
|
||||
let call_id = id.unwrap_or_else(|| "".to_string());
|
||||
ResponseItem::Message { .. }
|
||||
| ResponseItem::Reasoning { .. }
|
||||
| ResponseItem::WebSearchCall { .. } => {
|
||||
let msgs = map_response_item_to_event_messages(&item, sess.show_raw_agent_reasoning);
|
||||
for msg in msgs {
|
||||
let event = Event {
|
||||
id: sub_id.to_string(),
|
||||
msg: EventMsg::WebSearchEnd(WebSearchEndEvent { call_id, query }),
|
||||
msg,
|
||||
};
|
||||
sess.tx_event.send(event).await.ok();
|
||||
}
|
||||
|
||||
@@ -8,12 +8,10 @@ use crate::shell::Shell;
|
||||
use codex_protocol::config_types::SandboxMode;
|
||||
use codex_protocol::models::ContentItem;
|
||||
use codex_protocol::models::ResponseItem;
|
||||
use codex_protocol::protocol::ENVIRONMENT_CONTEXT_CLOSE_TAG;
|
||||
use codex_protocol::protocol::ENVIRONMENT_CONTEXT_OPEN_TAG;
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// wraps environment context message in a tag for the model to parse more easily.
|
||||
pub(crate) const ENVIRONMENT_CONTEXT_START: &str = "<environment_context>";
|
||||
pub(crate) const ENVIRONMENT_CONTEXT_END: &str = "</environment_context>";
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, DeriveDisplay)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
#[strum(serialize_all = "kebab-case")]
|
||||
@@ -79,7 +77,7 @@ impl EnvironmentContext {
|
||||
/// </environment_context>
|
||||
/// ```
|
||||
pub fn serialize_to_xml(self) -> String {
|
||||
let mut lines = vec![ENVIRONMENT_CONTEXT_START.to_string()];
|
||||
let mut lines = vec![ENVIRONMENT_CONTEXT_OPEN_TAG.to_string()];
|
||||
if let Some(cwd) = self.cwd {
|
||||
lines.push(format!(" <cwd>{}</cwd>", cwd.to_string_lossy()));
|
||||
}
|
||||
@@ -101,7 +99,7 @@ impl EnvironmentContext {
|
||||
{
|
||||
lines.push(format!(" <shell>{shell_name}</shell>"));
|
||||
}
|
||||
lines.push(ENVIRONMENT_CONTEXT_END.to_string());
|
||||
lines.push(ENVIRONMENT_CONTEXT_CLOSE_TAG.to_string());
|
||||
lines.join("\n")
|
||||
}
|
||||
}
|
||||
|
||||
98
codex-rs/core/src/event_mapping.rs
Normal file
98
codex-rs/core/src/event_mapping.rs
Normal file
@@ -0,0 +1,98 @@
|
||||
use crate::protocol::AgentMessageEvent;
|
||||
use crate::protocol::AgentReasoningEvent;
|
||||
use crate::protocol::AgentReasoningRawContentEvent;
|
||||
use crate::protocol::EventMsg;
|
||||
use crate::protocol::InputMessageKind;
|
||||
use crate::protocol::UserMessageEvent;
|
||||
use crate::protocol::WebSearchEndEvent;
|
||||
use codex_protocol::models::ContentItem;
|
||||
use codex_protocol::models::ReasoningItemContent;
|
||||
use codex_protocol::models::ReasoningItemReasoningSummary;
|
||||
use codex_protocol::models::ResponseItem;
|
||||
use codex_protocol::models::WebSearchAction;
|
||||
|
||||
/// Convert a `ResponseItem` into zero or more `EventMsg` values that the UI can render.
|
||||
///
|
||||
/// When `show_raw_agent_reasoning` is false, raw reasoning content events are omitted.
|
||||
pub(crate) fn map_response_item_to_event_messages(
|
||||
item: &ResponseItem,
|
||||
show_raw_agent_reasoning: bool,
|
||||
) -> Vec<EventMsg> {
|
||||
match item {
|
||||
ResponseItem::Message { role, content, .. } => {
|
||||
// Do not surface system messages as user events.
|
||||
if role == "system" {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
let events: Vec<EventMsg> = content
|
||||
.iter()
|
||||
.filter_map(|content_item| match content_item {
|
||||
ContentItem::OutputText { text } => {
|
||||
Some(EventMsg::AgentMessage(AgentMessageEvent {
|
||||
message: text.clone(),
|
||||
}))
|
||||
}
|
||||
ContentItem::InputText { text } => {
|
||||
let trimmed = text.trim_start();
|
||||
let kind = if trimmed.starts_with("<environment_context>") {
|
||||
Some(InputMessageKind::EnvironmentContext)
|
||||
} else if trimmed.starts_with("<user_instructions>") {
|
||||
Some(InputMessageKind::UserInstructions)
|
||||
} else {
|
||||
Some(InputMessageKind::Plain)
|
||||
};
|
||||
Some(EventMsg::UserMessage(UserMessageEvent {
|
||||
message: text.clone(),
|
||||
kind,
|
||||
}))
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
events
|
||||
}
|
||||
|
||||
ResponseItem::Reasoning {
|
||||
summary, content, ..
|
||||
} => {
|
||||
let mut events = Vec::new();
|
||||
for ReasoningItemReasoningSummary::SummaryText { text } in summary {
|
||||
events.push(EventMsg::AgentReasoning(AgentReasoningEvent {
|
||||
text: text.clone(),
|
||||
}));
|
||||
}
|
||||
if let Some(items) = content.as_ref().filter(|_| show_raw_agent_reasoning) {
|
||||
for c in items {
|
||||
let text = match c {
|
||||
ReasoningItemContent::ReasoningText { text }
|
||||
| ReasoningItemContent::Text { text } => text,
|
||||
};
|
||||
events.push(EventMsg::AgentReasoningRawContent(
|
||||
AgentReasoningRawContentEvent { text: text.clone() },
|
||||
));
|
||||
}
|
||||
}
|
||||
events
|
||||
}
|
||||
|
||||
ResponseItem::WebSearchCall { id, action, .. } => match action {
|
||||
WebSearchAction::Search { query } => {
|
||||
let call_id = id.clone().unwrap_or_else(|| "".to_string());
|
||||
vec![EventMsg::WebSearchEnd(WebSearchEndEvent {
|
||||
call_id,
|
||||
query: query.clone(),
|
||||
})]
|
||||
}
|
||||
WebSearchAction::Other => Vec::new(),
|
||||
},
|
||||
|
||||
// Variants that require side effects are handled by higher layers and do not emit events here.
|
||||
ResponseItem::FunctionCall { .. }
|
||||
| ResponseItem::FunctionCallOutput { .. }
|
||||
| ResponseItem::LocalShellCall { .. }
|
||||
| ResponseItem::CustomToolCall { .. }
|
||||
| ResponseItem::CustomToolCallOutput { .. }
|
||||
| ResponseItem::Other => Vec::new(),
|
||||
}
|
||||
}
|
||||
@@ -34,12 +34,14 @@ mod mcp_tool_call;
|
||||
mod message_history;
|
||||
mod model_provider_info;
|
||||
pub mod parse_command;
|
||||
mod user_instructions;
|
||||
pub use model_provider_info::BUILT_IN_OSS_MODEL_PROVIDER_ID;
|
||||
pub use model_provider_info::ModelProviderInfo;
|
||||
pub use model_provider_info::WireApi;
|
||||
pub use model_provider_info::built_in_model_providers;
|
||||
pub use model_provider_info::create_oss_provider_with_base_url;
|
||||
mod conversation_manager;
|
||||
mod event_mapping;
|
||||
pub use conversation_manager::ConversationManager;
|
||||
pub use conversation_manager::NewConversation;
|
||||
// Re-export common auth types for workspace consumers
|
||||
@@ -59,7 +61,10 @@ pub mod spawn;
|
||||
pub mod terminal;
|
||||
mod tool_apply_patch;
|
||||
pub mod turn_diff_tracker;
|
||||
pub use rollout::RolloutRecorder;
|
||||
pub use rollout::list::ConversationItem;
|
||||
pub use rollout::list::ConversationsPage;
|
||||
pub use rollout::list::Cursor;
|
||||
mod user_notification;
|
||||
pub mod util;
|
||||
pub use apply_patch::CODEX_APPLY_PATCH_ARG1;
|
||||
|
||||
@@ -34,7 +34,8 @@ pub struct ConversationItem {
|
||||
}
|
||||
|
||||
/// Hard cap to bound worst‑case work per request.
|
||||
const MAX_SCAN_FILES: usize = 50_000;
|
||||
const MAX_SCAN_FILES: usize = 10_000;
|
||||
const HEAD_RECORD_LIMIT: usize = 10;
|
||||
|
||||
/// Pagination cursor identifying a file by timestamp and UUID.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
@@ -166,7 +167,9 @@ async fn traverse_directories_for_paths(
|
||||
if items.len() == page_size {
|
||||
break 'outer;
|
||||
}
|
||||
let head = read_first_jsonl_records(&path, 5).await.unwrap_or_default();
|
||||
let head = read_first_jsonl_records(&path, HEAD_RECORD_LIMIT)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
items.push(ConversationItem { path, head });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -107,6 +107,7 @@ impl RolloutRecorder {
|
||||
"[year]-[month]-[day]T[hour]:[minute]:[second].[subsecond digits:3]Z"
|
||||
);
|
||||
let timestamp = timestamp
|
||||
.to_offset(time::UtcOffset::UTC)
|
||||
.format(timestamp_format)
|
||||
.map_err(|e| IoError::other(format!("failed to format timestamp: {e}")))?;
|
||||
|
||||
|
||||
42
codex-rs/core/src/user_instructions.rs
Normal file
42
codex-rs/core/src/user_instructions.rs
Normal file
@@ -0,0 +1,42 @@
|
||||
use serde::Deserialize;
|
||||
use serde::Serialize;
|
||||
|
||||
use codex_protocol::models::ContentItem;
|
||||
use codex_protocol::models::ResponseItem;
|
||||
use codex_protocol::protocol::USER_INSTRUCTIONS_CLOSE_TAG;
|
||||
use codex_protocol::protocol::USER_INSTRUCTIONS_OPEN_TAG;
|
||||
|
||||
/// Wraps user instructions in a tag so the model can classify them easily.
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename = "user_instructions", rename_all = "snake_case")]
|
||||
pub(crate) struct UserInstructions {
|
||||
text: String,
|
||||
}
|
||||
|
||||
impl UserInstructions {
|
||||
pub fn new<T: Into<String>>(text: T) -> Self {
|
||||
Self { text: text.into() }
|
||||
}
|
||||
|
||||
/// Serializes the user instructions to an XML-like tagged block that starts
|
||||
/// with <user_instructions> so clients can classify it.
|
||||
pub fn serialize_to_xml(self) -> String {
|
||||
format!(
|
||||
"{USER_INSTRUCTIONS_OPEN_TAG}\n\n{}\n\n{USER_INSTRUCTIONS_CLOSE_TAG}",
|
||||
self.text
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<UserInstructions> for ResponseItem {
|
||||
fn from(ui: UserInstructions) -> Self {
|
||||
ResponseItem::Message {
|
||||
id: None,
|
||||
role: "user".to_string(),
|
||||
content: vec![ContentItem::InputText {
|
||||
text: ui.serialize_to_xml(),
|
||||
}],
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,7 @@ use core_test_support::load_default_config_for_test;
|
||||
use core_test_support::load_sse_fixture_with_id;
|
||||
use core_test_support::wait_for_event;
|
||||
use serde_json::json;
|
||||
use std::io::Write;
|
||||
use tempfile::TempDir;
|
||||
use wiremock::Mock;
|
||||
use wiremock::MockServer;
|
||||
@@ -108,6 +109,141 @@ fn write_auth_json(
|
||||
fake_jwt
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn resume_includes_initial_messages_and_sends_prior_items() {
|
||||
if std::env::var(CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR).is_ok() {
|
||||
println!(
|
||||
"Skipping test because it cannot execute when network is disabled in a Codex sandbox."
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Create a fake rollout session file with prior user + system + assistant messages.
|
||||
let tmpdir = TempDir::new().unwrap();
|
||||
let session_path = tmpdir.path().join("resume-session.jsonl");
|
||||
let mut f = std::fs::File::create(&session_path).unwrap();
|
||||
// First line: meta (content not used by reader other than non-empty)
|
||||
writeln!(
|
||||
f,
|
||||
"{}",
|
||||
serde_json::json!({"meta":"test","instructions":"be nice"})
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// Prior item: user message (should be delivered)
|
||||
let prior_user = codex_protocol::models::ResponseItem::Message {
|
||||
id: None,
|
||||
role: "user".to_string(),
|
||||
content: vec![codex_protocol::models::ContentItem::InputText {
|
||||
text: "resumed user message".to_string(),
|
||||
}],
|
||||
};
|
||||
writeln!(f, "{}", serde_json::to_string(&prior_user).unwrap()).unwrap();
|
||||
|
||||
// Prior item: system message (excluded from API history)
|
||||
let prior_system = codex_protocol::models::ResponseItem::Message {
|
||||
id: None,
|
||||
role: "system".to_string(),
|
||||
content: vec![codex_protocol::models::ContentItem::OutputText {
|
||||
text: "resumed system instruction".to_string(),
|
||||
}],
|
||||
};
|
||||
writeln!(f, "{}", serde_json::to_string(&prior_system).unwrap()).unwrap();
|
||||
|
||||
// Prior item: assistant message
|
||||
let prior_item = codex_protocol::models::ResponseItem::Message {
|
||||
id: None,
|
||||
role: "assistant".to_string(),
|
||||
content: vec![codex_protocol::models::ContentItem::OutputText {
|
||||
text: "resumed assistant message".to_string(),
|
||||
}],
|
||||
};
|
||||
writeln!(f, "{}", serde_json::to_string(&prior_item).unwrap()).unwrap();
|
||||
drop(f);
|
||||
|
||||
// Mock server that will receive the resumed request
|
||||
let server = MockServer::start().await;
|
||||
let first = ResponseTemplate::new(200)
|
||||
.insert_header("content-type", "text/event-stream")
|
||||
.set_body_raw(sse_completed("resp1"), "text/event-stream");
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/v1/responses"))
|
||||
.respond_with(first)
|
||||
.expect(1)
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
// Configure Codex to resume from our file
|
||||
let model_provider = ModelProviderInfo {
|
||||
base_url: Some(format!("{}/v1", server.uri())),
|
||||
..built_in_model_providers()["openai"].clone()
|
||||
};
|
||||
let codex_home = TempDir::new().unwrap();
|
||||
let mut config = load_default_config_for_test(&codex_home);
|
||||
config.model_provider = model_provider;
|
||||
config.experimental_resume = Some(session_path.clone());
|
||||
// Also configure user instructions to ensure they are NOT delivered on resume.
|
||||
config.user_instructions = Some("be nice".to_string());
|
||||
|
||||
let conversation_manager =
|
||||
ConversationManager::with_auth(CodexAuth::from_api_key("Test API Key"));
|
||||
let NewConversation {
|
||||
conversation: codex,
|
||||
session_configured,
|
||||
..
|
||||
} = conversation_manager
|
||||
.new_conversation(config)
|
||||
.await
|
||||
.expect("create new conversation");
|
||||
|
||||
// 1) Assert initial_messages contains the prior user + assistant messages as EventMsg entries
|
||||
let initial_msgs = session_configured
|
||||
.initial_messages
|
||||
.clone()
|
||||
.expect("expected initial messages for resumed session");
|
||||
let initial_json = serde_json::to_value(&initial_msgs).unwrap();
|
||||
let expected_initial_json = serde_json::json!([
|
||||
{ "type": "user_message", "message": "resumed user message", "kind": "plain" },
|
||||
{ "type": "agent_message", "message": "resumed assistant message" }
|
||||
]);
|
||||
assert_eq!(initial_json, expected_initial_json);
|
||||
|
||||
// 2) Submit new input; the request body must include the prior item followed by the new user input.
|
||||
codex
|
||||
.submit(Op::UserInput {
|
||||
items: vec![InputItem::Text {
|
||||
text: "hello".into(),
|
||||
}],
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
wait_for_event(&codex, |ev| matches!(ev, EventMsg::TaskComplete(_))).await;
|
||||
|
||||
let request = &server.received_requests().await.unwrap()[0];
|
||||
let request_body = request.body_json::<serde_json::Value>().unwrap();
|
||||
let expected_input = serde_json::json!([
|
||||
{
|
||||
"type": "message",
|
||||
"id": null,
|
||||
"role": "user",
|
||||
"content": [{ "type": "input_text", "text": "resumed user message" }]
|
||||
},
|
||||
{
|
||||
"type": "message",
|
||||
"id": null,
|
||||
"role": "assistant",
|
||||
"content": [{ "type": "output_text", "text": "resumed assistant message" }]
|
||||
},
|
||||
{
|
||||
"type": "message",
|
||||
"id": null,
|
||||
"role": "user",
|
||||
"content": [{ "type": "input_text", "text": "hello" }]
|
||||
}
|
||||
]);
|
||||
assert_eq!(request_body["input"], expected_input);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn includes_session_id_and_model_headers_in_request() {
|
||||
if std::env::var(CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR).is_ok() {
|
||||
|
||||
@@ -515,6 +515,7 @@ impl EventProcessor for EventProcessorWithHumanOutput {
|
||||
model,
|
||||
history_log_id: _,
|
||||
history_entry_count: _,
|
||||
initial_messages: _,
|
||||
} = session_configured_event;
|
||||
|
||||
ts_println!(
|
||||
@@ -551,6 +552,7 @@ impl EventProcessor for EventProcessorWithHumanOutput {
|
||||
},
|
||||
EventMsg::ShutdownComplete => return CodexStatus::Shutdown,
|
||||
EventMsg::ConversationHistory(_) => {}
|
||||
EventMsg::UserMessage(_) => {}
|
||||
}
|
||||
CodexStatus::Running
|
||||
}
|
||||
|
||||
@@ -279,6 +279,7 @@ async fn run_codex_tool_session_inner(
|
||||
| EventMsg::PlanUpdate(_)
|
||||
| EventMsg::TurnAborted(_)
|
||||
| EventMsg::ConversationHistory(_)
|
||||
| EventMsg::UserMessage(_)
|
||||
| EventMsg::ShutdownComplete => {
|
||||
// For now, we do not do anything extra for these
|
||||
// events. Note that
|
||||
|
||||
@@ -260,6 +260,7 @@ mod tests {
|
||||
model: "gpt-4o".to_string(),
|
||||
history_log_id: 1,
|
||||
history_entry_count: 1000,
|
||||
initial_messages: None,
|
||||
}),
|
||||
};
|
||||
|
||||
@@ -289,6 +290,7 @@ mod tests {
|
||||
model: "gpt-4o".to_string(),
|
||||
history_log_id: 1,
|
||||
history_entry_count: 1000,
|
||||
initial_messages: None,
|
||||
};
|
||||
let event = Event {
|
||||
id: "1".to_string(),
|
||||
|
||||
@@ -309,6 +309,8 @@ impl std::ops::Deref for FunctionCallOutputPayload {
|
||||
}
|
||||
}
|
||||
|
||||
// (Moved event mapping logic into codex-core to avoid coupling protocol to UI-facing events.)
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -27,6 +27,13 @@ use crate::models::ResponseItem;
|
||||
use crate::parse_command::ParsedCommand;
|
||||
use crate::plan_tool::UpdatePlanArgs;
|
||||
|
||||
/// Open/close tags for special user-input blocks. Used across crates to avoid
|
||||
/// duplicated hardcoded strings.
|
||||
pub const USER_INSTRUCTIONS_OPEN_TAG: &str = "<user_instructions>";
|
||||
pub const USER_INSTRUCTIONS_CLOSE_TAG: &str = "</user_instructions>";
|
||||
pub const ENVIRONMENT_CONTEXT_OPEN_TAG: &str = "<environment_context>";
|
||||
pub const ENVIRONMENT_CONTEXT_CLOSE_TAG: &str = "</environment_context>";
|
||||
|
||||
/// Submission Queue Entry - requests from user
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
pub struct Submission {
|
||||
@@ -417,6 +424,9 @@ pub enum EventMsg {
|
||||
/// Agent text output message
|
||||
AgentMessage(AgentMessageEvent),
|
||||
|
||||
/// User/system input message (what was sent to the model)
|
||||
UserMessage(UserMessageEvent),
|
||||
|
||||
/// Agent text output delta message
|
||||
AgentMessageDelta(AgentMessageDeltaEvent),
|
||||
|
||||
@@ -610,6 +620,47 @@ pub struct AgentMessageEvent {
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum InputMessageKind {
|
||||
/// Plain user text (default)
|
||||
Plain,
|
||||
/// XML-wrapped user instructions (<user_instructions>...)
|
||||
UserInstructions,
|
||||
/// XML-wrapped environment context (<environment_context>...)
|
||||
EnvironmentContext,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
pub struct UserMessageEvent {
|
||||
pub message: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub kind: Option<InputMessageKind>,
|
||||
}
|
||||
|
||||
impl<T, U> From<(T, U)> for InputMessageKind
|
||||
where
|
||||
T: AsRef<str>,
|
||||
U: AsRef<str>,
|
||||
{
|
||||
fn from(value: (T, U)) -> Self {
|
||||
let (_role, message) = value;
|
||||
let message = message.as_ref();
|
||||
let trimmed = message.trim();
|
||||
if trimmed.starts_with(ENVIRONMENT_CONTEXT_OPEN_TAG)
|
||||
&& trimmed.ends_with(ENVIRONMENT_CONTEXT_CLOSE_TAG)
|
||||
{
|
||||
InputMessageKind::EnvironmentContext
|
||||
} else if trimmed.starts_with(USER_INSTRUCTIONS_OPEN_TAG)
|
||||
&& trimmed.ends_with(USER_INSTRUCTIONS_CLOSE_TAG)
|
||||
{
|
||||
InputMessageKind::UserInstructions
|
||||
} else {
|
||||
InputMessageKind::Plain
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
pub struct AgentMessageDeltaEvent {
|
||||
pub delta: String,
|
||||
@@ -839,6 +890,11 @@ pub struct SessionConfiguredEvent {
|
||||
|
||||
/// Current number of entries in the history log.
|
||||
pub history_entry_count: usize,
|
||||
|
||||
/// Optional initial messages (as events) for resumed sessions.
|
||||
/// When present, UIs can use these to seed the history.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub initial_messages: Option<Vec<EventMsg>>,
|
||||
}
|
||||
|
||||
/// User's decision in response to an ExecApprovalRequest.
|
||||
@@ -914,6 +970,7 @@ mod tests {
|
||||
model: "codex-mini-latest".to_string(),
|
||||
history_log_id: 0,
|
||||
history_entry_count: 0,
|
||||
initial_messages: None,
|
||||
}),
|
||||
};
|
||||
let serialized = serde_json::to_string(&event).unwrap();
|
||||
|
||||
@@ -4,6 +4,7 @@ use crate::app_event_sender::AppEventSender;
|
||||
use crate::chatwidget::ChatWidget;
|
||||
use crate::file_search::FileSearchManager;
|
||||
use crate::pager_overlay::Overlay;
|
||||
use crate::resume_picker::ResumeSelection;
|
||||
use crate::tui;
|
||||
use crate::tui::TuiEvent;
|
||||
use codex_ansi_escape::ansi_escape_line;
|
||||
@@ -12,6 +13,7 @@ use codex_core::ConversationManager;
|
||||
use codex_core::config::Config;
|
||||
use codex_core::protocol::TokenUsage;
|
||||
use color_eyre::eyre::Result;
|
||||
use color_eyre::eyre::WrapErr;
|
||||
use crossterm::event::KeyCode;
|
||||
use crossterm::event::KeyEvent;
|
||||
use crossterm::event::KeyEventKind;
|
||||
@@ -61,6 +63,7 @@ impl App {
|
||||
config: Config,
|
||||
initial_prompt: Option<String>,
|
||||
initial_images: Vec<PathBuf>,
|
||||
resume_selection: ResumeSelection,
|
||||
) -> Result<TokenUsage> {
|
||||
use tokio_stream::StreamExt;
|
||||
let (app_event_tx, mut app_event_rx) = unbounded_channel();
|
||||
@@ -70,15 +73,44 @@ impl App {
|
||||
|
||||
let enhanced_keys_supported = supports_keyboard_enhancement().unwrap_or(false);
|
||||
|
||||
let chat_widget = ChatWidget::new(
|
||||
config.clone(),
|
||||
conversation_manager.clone(),
|
||||
tui.frame_requester(),
|
||||
app_event_tx.clone(),
|
||||
initial_prompt,
|
||||
initial_images,
|
||||
enhanced_keys_supported,
|
||||
);
|
||||
let chat_widget = match resume_selection {
|
||||
ResumeSelection::StartFresh | ResumeSelection::Exit => {
|
||||
let init = crate::chatwidget::ChatWidgetInit {
|
||||
config: config.clone(),
|
||||
frame_requester: tui.frame_requester(),
|
||||
app_event_tx: app_event_tx.clone(),
|
||||
initial_prompt: initial_prompt.clone(),
|
||||
initial_images: initial_images.clone(),
|
||||
enhanced_keys_supported,
|
||||
};
|
||||
ChatWidget::new(init, conversation_manager.clone())
|
||||
}
|
||||
ResumeSelection::Resume(path) => {
|
||||
let resumed = conversation_manager
|
||||
.resume_conversation_from_rollout(
|
||||
config.clone(),
|
||||
path.clone(),
|
||||
auth_manager.clone(),
|
||||
)
|
||||
.await
|
||||
.wrap_err_with(|| {
|
||||
format!("Failed to resume session from {}", path.display())
|
||||
})?;
|
||||
let init = crate::chatwidget::ChatWidgetInit {
|
||||
config: config.clone(),
|
||||
frame_requester: tui.frame_requester(),
|
||||
app_event_tx: app_event_tx.clone(),
|
||||
initial_prompt: initial_prompt.clone(),
|
||||
initial_images: initial_images.clone(),
|
||||
enhanced_keys_supported,
|
||||
};
|
||||
ChatWidget::new_from_existing(
|
||||
init,
|
||||
resumed.conversation,
|
||||
resumed.session_configured,
|
||||
)
|
||||
}
|
||||
};
|
||||
|
||||
let file_search = FileSearchManager::new(config.cwd.clone(), app_event_tx.clone());
|
||||
|
||||
@@ -168,15 +200,15 @@ impl App {
|
||||
async fn handle_event(&mut self, tui: &mut tui::Tui, event: AppEvent) -> Result<bool> {
|
||||
match event {
|
||||
AppEvent::NewSession => {
|
||||
self.chat_widget = ChatWidget::new(
|
||||
self.config.clone(),
|
||||
self.server.clone(),
|
||||
tui.frame_requester(),
|
||||
self.app_event_tx.clone(),
|
||||
None,
|
||||
Vec::new(),
|
||||
self.enhanced_keys_supported,
|
||||
);
|
||||
let init = crate::chatwidget::ChatWidgetInit {
|
||||
config: self.config.clone(),
|
||||
frame_requester: tui.frame_requester(),
|
||||
app_event_tx: self.app_event_tx.clone(),
|
||||
initial_prompt: None,
|
||||
initial_images: Vec::new(),
|
||||
enhanced_keys_supported: self.enhanced_keys_supported,
|
||||
};
|
||||
self.chat_widget = ChatWidget::new(init, self.server.clone());
|
||||
tui.frame_requester().schedule_frame();
|
||||
}
|
||||
AppEvent::InsertHistoryCell(cell) => {
|
||||
|
||||
@@ -319,14 +319,16 @@ impl App {
|
||||
) {
|
||||
let conv = new_conv.conversation;
|
||||
let session_configured = new_conv.session_configured;
|
||||
self.chat_widget = crate::chatwidget::ChatWidget::new_from_existing(
|
||||
cfg,
|
||||
conv,
|
||||
session_configured,
|
||||
tui.frame_requester(),
|
||||
self.app_event_tx.clone(),
|
||||
self.enhanced_keys_supported,
|
||||
);
|
||||
let init = crate::chatwidget::ChatWidgetInit {
|
||||
config: cfg,
|
||||
frame_requester: tui.frame_requester(),
|
||||
app_event_tx: self.app_event_tx.clone(),
|
||||
initial_prompt: None,
|
||||
initial_images: Vec::new(),
|
||||
enhanced_keys_supported: self.enhanced_keys_supported,
|
||||
};
|
||||
self.chat_widget =
|
||||
crate::chatwidget::ChatWidget::new_from_existing(init, conv, session_configured);
|
||||
// Trim transcript up to the selected user message and re-render it.
|
||||
self.trim_transcript_for_backtrack(drop_count);
|
||||
self.render_transcript_once(tui);
|
||||
|
||||
@@ -19,6 +19,7 @@ use codex_core::protocol::ExecApprovalRequestEvent;
|
||||
use codex_core::protocol::ExecCommandBeginEvent;
|
||||
use codex_core::protocol::ExecCommandEndEvent;
|
||||
use codex_core::protocol::InputItem;
|
||||
use codex_core::protocol::InputMessageKind;
|
||||
use codex_core::protocol::ListCustomPromptsResponseEvent;
|
||||
use codex_core::protocol::McpListToolsResponseEvent;
|
||||
use codex_core::protocol::McpToolCallBeginEvent;
|
||||
@@ -30,6 +31,7 @@ use codex_core::protocol::TaskCompleteEvent;
|
||||
use codex_core::protocol::TokenUsage;
|
||||
use codex_core::protocol::TurnAbortReason;
|
||||
use codex_core::protocol::TurnDiffEvent;
|
||||
use codex_core::protocol::UserMessageEvent;
|
||||
use codex_core::protocol::WebSearchBeginEvent;
|
||||
use codex_core::protocol::WebSearchEndEvent;
|
||||
use codex_protocol::parse_command::ParsedCommand;
|
||||
@@ -89,6 +91,16 @@ struct RunningCommand {
|
||||
parsed_cmd: Vec<ParsedCommand>,
|
||||
}
|
||||
|
||||
/// Common initialization parameters shared by all `ChatWidget` constructors.
|
||||
pub(crate) struct ChatWidgetInit {
|
||||
pub(crate) config: Config,
|
||||
pub(crate) frame_requester: FrameRequester,
|
||||
pub(crate) app_event_tx: AppEventSender,
|
||||
pub(crate) initial_prompt: Option<String>,
|
||||
pub(crate) initial_images: Vec<PathBuf>,
|
||||
pub(crate) enhanced_keys_supported: bool,
|
||||
}
|
||||
|
||||
pub(crate) struct ChatWidget {
|
||||
app_event_tx: AppEventSender,
|
||||
codex_op_tx: UnboundedSender<Op>,
|
||||
@@ -112,6 +124,9 @@ pub(crate) struct ChatWidget {
|
||||
frame_requester: FrameRequester,
|
||||
// Whether to include the initial welcome banner on session configured
|
||||
show_welcome_banner: bool,
|
||||
// When resuming an existing session (selected via resume picker), avoid an
|
||||
// immediate redraw on SessionConfigured to prevent a gratuitous UI flicker.
|
||||
suppress_session_configured_redraw: bool,
|
||||
// User messages queued while a turn is in progress
|
||||
queued_user_messages: VecDeque<UserMessage>,
|
||||
}
|
||||
@@ -148,6 +163,10 @@ impl ChatWidget {
|
||||
self.bottom_pane
|
||||
.set_history_metadata(event.history_log_id, event.history_entry_count);
|
||||
self.session_id = Some(event.session_id);
|
||||
let initial_messages = event.initial_messages.clone();
|
||||
if let Some(messages) = initial_messages {
|
||||
self.replay_initial_messages(messages);
|
||||
}
|
||||
self.add_to_history(history_cell::new_session_info(
|
||||
&self.config,
|
||||
event,
|
||||
@@ -158,7 +177,9 @@ impl ChatWidget {
|
||||
if let Some(user_message) = self.initial_user_message.take() {
|
||||
self.submit_user_message(user_message);
|
||||
}
|
||||
self.request_redraw();
|
||||
if !self.suppress_session_configured_redraw {
|
||||
self.request_redraw();
|
||||
}
|
||||
}
|
||||
|
||||
fn on_agent_message(&mut self, message: String) {
|
||||
@@ -602,14 +623,17 @@ impl ChatWidget {
|
||||
}
|
||||
|
||||
pub(crate) fn new(
|
||||
config: Config,
|
||||
common: ChatWidgetInit,
|
||||
conversation_manager: Arc<ConversationManager>,
|
||||
frame_requester: FrameRequester,
|
||||
app_event_tx: AppEventSender,
|
||||
initial_prompt: Option<String>,
|
||||
initial_images: Vec<PathBuf>,
|
||||
enhanced_keys_supported: bool,
|
||||
) -> Self {
|
||||
let ChatWidgetInit {
|
||||
config,
|
||||
frame_requester,
|
||||
app_event_tx,
|
||||
initial_prompt,
|
||||
initial_images,
|
||||
enhanced_keys_supported,
|
||||
} = common;
|
||||
let mut rng = rand::rng();
|
||||
let placeholder = EXAMPLE_PROMPTS[rng.random_range(0..EXAMPLE_PROMPTS.len())].to_string();
|
||||
let codex_op_tx = spawn_agent(config.clone(), app_event_tx.clone(), conversation_manager);
|
||||
@@ -643,18 +667,24 @@ impl ChatWidget {
|
||||
session_id: None,
|
||||
queued_user_messages: VecDeque::new(),
|
||||
show_welcome_banner: true,
|
||||
suppress_session_configured_redraw: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a ChatWidget attached to an existing conversation (e.g., a fork).
|
||||
pub(crate) fn new_from_existing(
|
||||
config: Config,
|
||||
common: ChatWidgetInit,
|
||||
conversation: std::sync::Arc<codex_core::CodexConversation>,
|
||||
session_configured: codex_core::protocol::SessionConfiguredEvent,
|
||||
frame_requester: FrameRequester,
|
||||
app_event_tx: AppEventSender,
|
||||
enhanced_keys_supported: bool,
|
||||
) -> Self {
|
||||
let ChatWidgetInit {
|
||||
config,
|
||||
frame_requester,
|
||||
app_event_tx,
|
||||
initial_prompt,
|
||||
initial_images,
|
||||
enhanced_keys_supported,
|
||||
} = common;
|
||||
let mut rng = rand::rng();
|
||||
let placeholder = EXAMPLE_PROMPTS[rng.random_range(0..EXAMPLE_PROMPTS.len())].to_string();
|
||||
|
||||
@@ -675,7 +705,10 @@ impl ChatWidget {
|
||||
}),
|
||||
active_exec_cell: None,
|
||||
config: config.clone(),
|
||||
initial_user_message: None,
|
||||
initial_user_message: create_initial_user_message(
|
||||
initial_prompt.unwrap_or_default(),
|
||||
initial_images,
|
||||
),
|
||||
total_token_usage: TokenUsage::default(),
|
||||
last_token_usage: TokenUsage::default(),
|
||||
stream: StreamController::new(config),
|
||||
@@ -687,6 +720,7 @@ impl ChatWidget {
|
||||
session_id: None,
|
||||
queued_user_messages: VecDeque::new(),
|
||||
show_welcome_banner: false,
|
||||
suppress_session_configured_redraw: true,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -950,9 +984,32 @@ impl ChatWidget {
|
||||
}
|
||||
}
|
||||
|
||||
/// Replay a subset of initial events into the UI to seed the transcript when
|
||||
/// resuming an existing session. This approximates the live event flow and
|
||||
/// is intentionally conservative: only safe-to-replay items are rendered to
|
||||
/// avoid triggering side effects. Event ids are passed as `None` to
|
||||
/// distinguish replayed events from live ones.
|
||||
fn replay_initial_messages(&mut self, events: Vec<EventMsg>) {
|
||||
for msg in events {
|
||||
if matches!(msg, EventMsg::SessionConfigured(_)) {
|
||||
continue;
|
||||
}
|
||||
// `id: None` indicates a synthetic/fake id coming from replay.
|
||||
self.dispatch_event_msg(None, msg, true);
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn handle_codex_event(&mut self, event: Event) {
|
||||
let Event { id, msg } = event;
|
||||
self.dispatch_event_msg(Some(id), msg, false);
|
||||
}
|
||||
|
||||
/// Dispatch a protocol `EventMsg` to the appropriate handler.
|
||||
///
|
||||
/// `id` is `Some` for live events and `None` for replayed events from
|
||||
/// `replay_initial_messages()`. Callers should treat `None` as a "fake" id
|
||||
/// that must not be used to correlate follow-up actions.
|
||||
fn dispatch_event_msg(&mut self, id: Option<String>, msg: EventMsg, from_replay: bool) {
|
||||
match msg {
|
||||
EventMsg::AgentMessageDelta(_)
|
||||
| EventMsg::AgentReasoningDelta(_)
|
||||
@@ -990,8 +1047,13 @@ impl ChatWidget {
|
||||
}
|
||||
},
|
||||
EventMsg::PlanUpdate(update) => self.on_plan_update(update),
|
||||
EventMsg::ExecApprovalRequest(ev) => self.on_exec_approval_request(id, ev),
|
||||
EventMsg::ApplyPatchApprovalRequest(ev) => self.on_apply_patch_approval_request(id, ev),
|
||||
EventMsg::ExecApprovalRequest(ev) => {
|
||||
// For replayed events, synthesize an empty id (these should not occur).
|
||||
self.on_exec_approval_request(id.clone().unwrap_or_default(), ev)
|
||||
}
|
||||
EventMsg::ApplyPatchApprovalRequest(ev) => {
|
||||
self.on_apply_patch_approval_request(id.clone().unwrap_or_default(), ev)
|
||||
}
|
||||
EventMsg::ExecCommandBegin(ev) => self.on_exec_command_begin(ev),
|
||||
EventMsg::ExecCommandOutputDelta(delta) => self.on_exec_command_output_delta(delta),
|
||||
EventMsg::PatchApplyBegin(ev) => self.on_patch_apply_begin(ev),
|
||||
@@ -1010,14 +1072,33 @@ impl ChatWidget {
|
||||
self.on_background_event(message)
|
||||
}
|
||||
EventMsg::StreamError(StreamErrorEvent { message }) => self.on_stream_error(message),
|
||||
EventMsg::UserMessage(ev) => {
|
||||
if from_replay {
|
||||
self.on_user_message_event(ev);
|
||||
}
|
||||
}
|
||||
EventMsg::ConversationHistory(ev) => {
|
||||
// Forward to App so it can process backtrack flows.
|
||||
self.app_event_tx
|
||||
.send(crate::app_event::AppEvent::ConversationHistory(ev));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn on_user_message_event(&mut self, event: UserMessageEvent) {
|
||||
match event.kind {
|
||||
Some(InputMessageKind::EnvironmentContext)
|
||||
| Some(InputMessageKind::UserInstructions) => {
|
||||
// Skip XML‑wrapped context blocks in the transcript.
|
||||
}
|
||||
Some(InputMessageKind::Plain) | None => {
|
||||
let message = event.message.trim();
|
||||
if !message.is_empty() {
|
||||
self.add_to_history(history_cell::new_user_prompt(message.to_string()));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn request_redraw(&mut self) {
|
||||
self.frame_requester.schedule_frame();
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ use codex_core::protocol::ExecApprovalRequestEvent;
|
||||
use codex_core::protocol::ExecCommandBeginEvent;
|
||||
use codex_core::protocol::ExecCommandEndEvent;
|
||||
use codex_core::protocol::FileChange;
|
||||
use codex_core::protocol::InputMessageKind;
|
||||
use codex_core::protocol::PatchApplyBeginEvent;
|
||||
use codex_core::protocol::PatchApplyEndEvent;
|
||||
use codex_core::protocol::StreamErrorEvent;
|
||||
@@ -34,6 +35,7 @@ use std::io::BufRead;
|
||||
use std::io::BufReader;
|
||||
use std::path::PathBuf;
|
||||
use tokio::sync::mpsc::unbounded_channel;
|
||||
use uuid::Uuid;
|
||||
|
||||
fn test_config() -> Config {
|
||||
// Use base defaults to avoid depending on host state.
|
||||
@@ -126,6 +128,53 @@ fn final_answer_without_newline_is_flushed_immediately() {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resumed_initial_messages_render_history() {
|
||||
let (mut chat, mut rx, _ops) = make_chatwidget_manual();
|
||||
|
||||
let configured = codex_core::protocol::SessionConfiguredEvent {
|
||||
session_id: Uuid::nil(),
|
||||
model: "test-model".to_string(),
|
||||
history_log_id: 0,
|
||||
history_entry_count: 0,
|
||||
initial_messages: Some(vec![
|
||||
EventMsg::UserMessage(codex_core::protocol::UserMessageEvent {
|
||||
message: "hello from user".to_string(),
|
||||
kind: Some(InputMessageKind::Plain),
|
||||
}),
|
||||
EventMsg::AgentMessage(AgentMessageEvent {
|
||||
message: "assistant reply".to_string(),
|
||||
}),
|
||||
]),
|
||||
};
|
||||
|
||||
chat.handle_codex_event(Event {
|
||||
id: "initial".into(),
|
||||
msg: EventMsg::SessionConfigured(configured),
|
||||
});
|
||||
|
||||
let cells = drain_insert_history(&mut rx);
|
||||
let mut merged_lines = Vec::new();
|
||||
for lines in cells {
|
||||
let text = lines
|
||||
.iter()
|
||||
.flat_map(|line| line.spans.iter())
|
||||
.map(|span| span.content.clone())
|
||||
.collect::<String>();
|
||||
merged_lines.push(text);
|
||||
}
|
||||
|
||||
let text_blob = merged_lines.join("\n");
|
||||
assert!(
|
||||
text_blob.contains("hello from user"),
|
||||
"expected replayed user message",
|
||||
);
|
||||
assert!(
|
||||
text_blob.contains("assistant reply"),
|
||||
"expected replayed agent message",
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn helpers_are_available_and_do_not_panic() {
|
||||
let (tx_raw, _rx) = unbounded_channel::<AppEvent>();
|
||||
@@ -134,15 +183,15 @@ async fn helpers_are_available_and_do_not_panic() {
|
||||
let conversation_manager = Arc::new(ConversationManager::with_auth(CodexAuth::from_api_key(
|
||||
"test",
|
||||
)));
|
||||
let mut w = ChatWidget::new(
|
||||
cfg,
|
||||
conversation_manager,
|
||||
crate::tui::FrameRequester::test_dummy(),
|
||||
tx,
|
||||
None,
|
||||
Vec::new(),
|
||||
false,
|
||||
);
|
||||
let init = ChatWidgetInit {
|
||||
config: cfg,
|
||||
frame_requester: crate::tui::FrameRequester::test_dummy(),
|
||||
app_event_tx: tx,
|
||||
initial_prompt: None,
|
||||
initial_images: Vec::new(),
|
||||
enhanced_keys_supported: false,
|
||||
};
|
||||
let mut w = ChatWidget::new(init, conversation_manager);
|
||||
// Basic construction sanity.
|
||||
let _ = &mut w;
|
||||
}
|
||||
@@ -184,6 +233,7 @@ fn make_chatwidget_manual() -> (
|
||||
frame_requester: crate::tui::FrameRequester::test_dummy(),
|
||||
show_welcome_banner: true,
|
||||
queued_user_messages: std::collections::VecDeque::new(),
|
||||
suppress_session_configured_redraw: false,
|
||||
};
|
||||
(widget, rx, op_rx)
|
||||
}
|
||||
|
||||
@@ -13,6 +13,30 @@ pub struct Cli {
|
||||
#[arg(long = "image", short = 'i', value_name = "FILE", value_delimiter = ',', num_args = 1..)]
|
||||
pub images: Vec<PathBuf>,
|
||||
|
||||
/// Open an interactive picker to resume a previous session recorded on disk
|
||||
/// instead of starting a new one.
|
||||
///
|
||||
/// Notes:
|
||||
/// - Mutually exclusive with `--continue`.
|
||||
/// - The picker displays recent sessions and a preview of the first real user
|
||||
/// message to help you select the right one.
|
||||
#[arg(long = "resume", default_value_t = false, conflicts_with = "continue")]
|
||||
pub resume: bool,
|
||||
|
||||
/// Continue the most recent conversation without showing the picker.
|
||||
///
|
||||
/// Notes:
|
||||
/// - Mutually exclusive with `--resume`.
|
||||
/// - If no recorded sessions are found, this behaves like starting fresh.
|
||||
/// - Equivalent to picking the newest item in the resume picker.
|
||||
#[arg(
|
||||
id = "continue",
|
||||
long = "continue",
|
||||
default_value_t = false,
|
||||
conflicts_with = "resume"
|
||||
)]
|
||||
pub r#continue: bool,
|
||||
|
||||
/// Model the agent should use.
|
||||
#[arg(long, short = 'm')]
|
||||
pub model: Option<String>,
|
||||
|
||||
@@ -646,6 +646,7 @@ pub(crate) fn new_session_info(
|
||||
session_id: _,
|
||||
history_log_id: _,
|
||||
history_entry_count: _,
|
||||
initial_messages: _,
|
||||
} = event;
|
||||
if is_first_event {
|
||||
let cwd_str = match relativize_to_home(&config.cwd) {
|
||||
|
||||
@@ -7,6 +7,7 @@ use app::App;
|
||||
use codex_core::AuthManager;
|
||||
use codex_core::BUILT_IN_OSS_MODEL_PROVIDER_ID;
|
||||
use codex_core::CodexAuth;
|
||||
use codex_core::RolloutRecorder;
|
||||
use codex_core::config::Config;
|
||||
use codex_core::config::ConfigOverrides;
|
||||
use codex_core::config::ConfigToml;
|
||||
@@ -47,6 +48,7 @@ mod markdown_stream;
|
||||
pub mod onboarding;
|
||||
mod pager_overlay;
|
||||
mod render;
|
||||
mod resume_picker;
|
||||
mod session_log;
|
||||
mod shimmer;
|
||||
mod slash_command;
|
||||
@@ -299,7 +301,13 @@ async fn run_ratatui_app(
|
||||
// Initialize high-fidelity session event logging if enabled.
|
||||
session_log::maybe_init(&config);
|
||||
|
||||
let Cli { prompt, images, .. } = cli;
|
||||
let Cli {
|
||||
prompt,
|
||||
images,
|
||||
resume,
|
||||
r#continue,
|
||||
..
|
||||
} = cli;
|
||||
|
||||
let auth_manager = AuthManager::shared(
|
||||
config.codex_home.clone(),
|
||||
@@ -327,7 +335,37 @@ async fn run_ratatui_app(
|
||||
}
|
||||
}
|
||||
|
||||
let app_result = App::run(&mut tui, auth_manager, config, prompt, images).await;
|
||||
let resume_selection = if r#continue {
|
||||
match RolloutRecorder::list_conversations(&config.codex_home, 1, None).await {
|
||||
Ok(page) => page
|
||||
.items
|
||||
.first()
|
||||
.map(|it| resume_picker::ResumeSelection::Resume(it.path.clone()))
|
||||
.unwrap_or(resume_picker::ResumeSelection::StartFresh),
|
||||
Err(_) => resume_picker::ResumeSelection::StartFresh,
|
||||
}
|
||||
} else if resume {
|
||||
match resume_picker::run_resume_picker(&mut tui, &config.codex_home).await? {
|
||||
resume_picker::ResumeSelection::Exit => {
|
||||
restore();
|
||||
session_log::log_session_end();
|
||||
return Ok(codex_core::protocol::TokenUsage::default());
|
||||
}
|
||||
other => other,
|
||||
}
|
||||
} else {
|
||||
resume_picker::ResumeSelection::StartFresh
|
||||
};
|
||||
|
||||
let app_result = App::run(
|
||||
&mut tui,
|
||||
auth_manager,
|
||||
config,
|
||||
prompt,
|
||||
images,
|
||||
resume_selection,
|
||||
)
|
||||
.await;
|
||||
|
||||
restore();
|
||||
// Mark the end of the recorded session.
|
||||
|
||||
504
codex-rs/tui/src/resume_picker.rs
Normal file
504
codex-rs/tui/src/resume_picker.rs
Normal file
@@ -0,0 +1,504 @@
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use chrono::DateTime;
|
||||
use chrono::TimeZone;
|
||||
use chrono::Utc;
|
||||
use codex_core::ConversationItem;
|
||||
use codex_core::ConversationsPage;
|
||||
use codex_core::Cursor;
|
||||
use codex_core::RolloutRecorder;
|
||||
use codex_core::protocol::InputMessageKind;
|
||||
use color_eyre::eyre::Result;
|
||||
use crossterm::event::KeyCode;
|
||||
use crossterm::event::KeyEvent;
|
||||
use crossterm::event::KeyEventKind;
|
||||
use ratatui::layout::Constraint;
|
||||
use ratatui::layout::Layout;
|
||||
use ratatui::layout::Rect;
|
||||
use ratatui::style::Stylize as _;
|
||||
use ratatui::text::Line;
|
||||
use tokio_stream::StreamExt;
|
||||
|
||||
use crate::text_formatting::truncate_text;
|
||||
use crate::tui::FrameRequester;
|
||||
use crate::tui::Tui;
|
||||
use crate::tui::TuiEvent;
|
||||
|
||||
const PAGE_SIZE: usize = 25;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum ResumeSelection {
|
||||
StartFresh,
|
||||
Resume(PathBuf),
|
||||
Exit,
|
||||
}
|
||||
|
||||
/// Interactive session picker that lists recorded rollout files with simple
|
||||
/// search and pagination. Shows the first user input as the preview, relative
|
||||
/// time (e.g., "5 seconds ago"), and the absolute path.
|
||||
pub async fn run_resume_picker(tui: &mut Tui, codex_home: &Path) -> Result<ResumeSelection> {
|
||||
let alt = AltScreenGuard::enter(tui);
|
||||
let mut state = PickerState::new(codex_home.to_path_buf(), alt.tui.frame_requester());
|
||||
state.load_page(None).await?;
|
||||
state.request_frame();
|
||||
|
||||
let mut events = alt.tui.event_stream();
|
||||
while let Some(ev) = events.next().await {
|
||||
match ev {
|
||||
TuiEvent::Key(key) => {
|
||||
if matches!(key.kind, KeyEventKind::Release) {
|
||||
continue;
|
||||
}
|
||||
if let Some(sel) = state.handle_key(key).await? {
|
||||
return Ok(sel);
|
||||
}
|
||||
}
|
||||
TuiEvent::Draw => {
|
||||
draw_picker(alt.tui, &state)?;
|
||||
}
|
||||
// Ignore paste and attach-image in picker
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback – treat as cancel/new
|
||||
Ok(ResumeSelection::StartFresh)
|
||||
}
|
||||
|
||||
/// RAII guard that ensures we leave the alt-screen on scope exit.
|
||||
struct AltScreenGuard<'a> {
|
||||
tui: &'a mut Tui,
|
||||
}
|
||||
|
||||
impl<'a> AltScreenGuard<'a> {
|
||||
fn enter(tui: &'a mut Tui) -> Self {
|
||||
let _ = tui.enter_alt_screen();
|
||||
Self { tui }
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for AltScreenGuard<'_> {
|
||||
fn drop(&mut self) {
|
||||
let _ = self.tui.leave_alt_screen();
|
||||
}
|
||||
}
|
||||
|
||||
struct PickerState {
|
||||
codex_home: PathBuf,
|
||||
requester: FrameRequester,
|
||||
// pagination
|
||||
pagination: Pagination,
|
||||
// data
|
||||
all_rows: Vec<Row>, // unfiltered rows for current page
|
||||
filtered_rows: Vec<Row>,
|
||||
selected: usize,
|
||||
// search
|
||||
query: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct Pagination {
|
||||
current_anchor: Option<Cursor>,
|
||||
backstack: Vec<Option<Cursor>>, // track previous anchors for ←/a
|
||||
next_cursor: Option<Cursor>,
|
||||
page_index: usize,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct Row {
|
||||
path: PathBuf,
|
||||
preview: String,
|
||||
ts: Option<DateTime<Utc>>,
|
||||
}
|
||||
|
||||
impl PickerState {
|
||||
fn new(codex_home: PathBuf, requester: FrameRequester) -> Self {
|
||||
Self {
|
||||
codex_home,
|
||||
requester,
|
||||
pagination: Pagination {
|
||||
current_anchor: None,
|
||||
backstack: vec![None],
|
||||
next_cursor: None,
|
||||
page_index: 0,
|
||||
},
|
||||
all_rows: Vec::new(),
|
||||
filtered_rows: Vec::new(),
|
||||
selected: 0,
|
||||
query: String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn request_frame(&self) {
|
||||
self.requester.schedule_frame();
|
||||
}
|
||||
|
||||
async fn handle_key(&mut self, key: KeyEvent) -> Result<Option<ResumeSelection>> {
|
||||
match key.code {
|
||||
KeyCode::Esc => return Ok(Some(ResumeSelection::StartFresh)),
|
||||
KeyCode::Char('c')
|
||||
if key
|
||||
.modifiers
|
||||
.contains(crossterm::event::KeyModifiers::CONTROL) =>
|
||||
{
|
||||
return Ok(Some(ResumeSelection::Exit));
|
||||
}
|
||||
KeyCode::Enter => {
|
||||
if let Some(row) = self.filtered_rows.get(self.selected) {
|
||||
return Ok(Some(ResumeSelection::Resume(row.path.clone())));
|
||||
}
|
||||
}
|
||||
KeyCode::Up => {
|
||||
if self.selected > 0 {
|
||||
self.selected -= 1;
|
||||
}
|
||||
self.request_frame();
|
||||
}
|
||||
KeyCode::Down => {
|
||||
if self.selected + 1 < self.filtered_rows.len() {
|
||||
self.selected += 1;
|
||||
}
|
||||
self.request_frame();
|
||||
}
|
||||
KeyCode::Left | KeyCode::Char('a') => {
|
||||
self.prev_page().await?;
|
||||
}
|
||||
KeyCode::Right | KeyCode::Char('d') => {
|
||||
self.next_page().await?;
|
||||
}
|
||||
KeyCode::Backspace => {
|
||||
self.query.pop();
|
||||
self.apply_filter();
|
||||
}
|
||||
KeyCode::Char(c) => {
|
||||
// basic text input for search
|
||||
if !key
|
||||
.modifiers
|
||||
.contains(crossterm::event::KeyModifiers::CONTROL)
|
||||
&& !key.modifiers.contains(crossterm::event::KeyModifiers::ALT)
|
||||
{
|
||||
self.query.push(c);
|
||||
self.apply_filter();
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
async fn prev_page(&mut self) -> Result<()> {
|
||||
if self.pagination.page_index == 0 {
|
||||
return Ok(());
|
||||
}
|
||||
// current_anchor points to the page we just loaded; backstack[page_index-1] is the anchor to reload
|
||||
if self.pagination.page_index > 0 {
|
||||
self.pagination.page_index -= 1;
|
||||
let anchor = self
|
||||
.pagination
|
||||
.backstack
|
||||
.get(self.pagination.page_index)
|
||||
.cloned()
|
||||
.flatten();
|
||||
self.pagination.current_anchor = anchor.clone();
|
||||
self.load_page(anchor.as_ref()).await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn next_page(&mut self) -> Result<()> {
|
||||
if let Some(next) = self.pagination.next_cursor.clone() {
|
||||
// Record the anchor for the page we are moving to at index new_index
|
||||
let new_index = self.pagination.page_index + 1;
|
||||
if self.pagination.backstack.len() <= new_index {
|
||||
self.pagination.backstack.resize(new_index + 1, None);
|
||||
}
|
||||
self.pagination.backstack[new_index] = Some(next.clone());
|
||||
self.pagination.current_anchor = Some(next.clone());
|
||||
self.pagination.page_index = new_index;
|
||||
let anchor = self.pagination.current_anchor.clone();
|
||||
self.load_page(anchor.as_ref()).await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn load_page(&mut self, anchor: Option<&Cursor>) -> Result<()> {
|
||||
let page = RolloutRecorder::list_conversations(&self.codex_home, PAGE_SIZE, anchor).await?;
|
||||
self.pagination.next_cursor = page.next_cursor.clone();
|
||||
self.all_rows = to_rows(page);
|
||||
self.apply_filter();
|
||||
// reset selection on new page
|
||||
self.selected = 0;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn apply_filter(&mut self) {
|
||||
if self.query.is_empty() {
|
||||
self.filtered_rows = self.all_rows.clone();
|
||||
} else {
|
||||
let q = self.query.to_lowercase();
|
||||
self.filtered_rows = self
|
||||
.all_rows
|
||||
.iter()
|
||||
.filter(|r| r.preview.to_lowercase().contains(&q))
|
||||
.cloned()
|
||||
.collect();
|
||||
}
|
||||
if self.selected >= self.filtered_rows.len() {
|
||||
self.selected = self.filtered_rows.len().saturating_sub(1);
|
||||
}
|
||||
self.request_frame();
|
||||
}
|
||||
}
|
||||
|
||||
fn to_rows(page: ConversationsPage) -> Vec<Row> {
|
||||
use std::cmp::Reverse;
|
||||
let mut rows: Vec<Row> = page
|
||||
.items
|
||||
.into_iter()
|
||||
.filter_map(|it| head_to_row(&it))
|
||||
.collect();
|
||||
// Ensure newest-first ordering within the page by timestamp when available.
|
||||
let epoch = Utc.timestamp_opt(0, 0).single().unwrap_or_else(Utc::now);
|
||||
rows.sort_by_key(|r| Reverse(r.ts.unwrap_or(epoch)));
|
||||
rows
|
||||
}
|
||||
|
||||
fn head_to_row(item: &ConversationItem) -> Option<Row> {
|
||||
let mut ts: Option<DateTime<Utc>> = None;
|
||||
if let Some(first) = item.head.first()
|
||||
&& let Some(t) = first.get("timestamp").and_then(|v| v.as_str())
|
||||
&& let Ok(parsed) = chrono::DateTime::parse_from_rfc3339(t)
|
||||
{
|
||||
ts = Some(parsed.with_timezone(&Utc));
|
||||
}
|
||||
|
||||
let preview = find_first_user_text(&item.head)?;
|
||||
let preview = preview.trim().to_string();
|
||||
if preview.is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some(Row {
|
||||
path: item.path.clone(),
|
||||
preview,
|
||||
ts,
|
||||
})
|
||||
}
|
||||
|
||||
/// Return the first plain user text from the JSONL `head` of a rollout.
|
||||
///
|
||||
/// Strategy: scan for the first `{ type: "message", role: "user" }` entry and
|
||||
/// then return the first `content` item where `{ type: "input_text" }` that is
|
||||
/// classified as `InputMessageKind::Plain` (i.e., not wrapped in
|
||||
/// `<user_instructions>` or `<environment_context>` tags).
|
||||
fn find_first_user_text(head: &[serde_json::Value]) -> Option<String> {
|
||||
for v in head.iter() {
|
||||
let t = v.get("type").and_then(|x| x.as_str()).unwrap_or("");
|
||||
if t != "message" {
|
||||
continue;
|
||||
}
|
||||
if v.get("role").and_then(|x| x.as_str()) != Some("user") {
|
||||
continue;
|
||||
}
|
||||
if let Some(arr) = v.get("content").and_then(|c| c.as_array()) {
|
||||
for c in arr.iter() {
|
||||
if let (Some("input_text"), Some(txt)) =
|
||||
(c.get("type").and_then(|t| t.as_str()), c.get("text"))
|
||||
&& let Some(s) = txt.as_str()
|
||||
{
|
||||
// Skip XML-wrapped user_instructions/environment_context blocks and
|
||||
// return the first plain user text we find.
|
||||
if matches!(InputMessageKind::from(("user", s)), InputMessageKind::Plain) {
|
||||
return Some(s.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn draw_picker(tui: &mut Tui, state: &PickerState) -> std::io::Result<()> {
|
||||
// Render full-screen overlay
|
||||
let height = tui.terminal.size()?.height;
|
||||
tui.draw(height, |frame| {
|
||||
let area = frame.area();
|
||||
let [header, search, list, hint] = Layout::vertical([
|
||||
Constraint::Length(1),
|
||||
Constraint::Length(1),
|
||||
Constraint::Min(area.height.saturating_sub(3)),
|
||||
Constraint::Length(1),
|
||||
])
|
||||
.areas(area);
|
||||
|
||||
// Header
|
||||
frame.render_widget_ref(
|
||||
Line::from(vec!["Resume a previous session".bold().cyan()]),
|
||||
header,
|
||||
);
|
||||
|
||||
// Search line
|
||||
let q = if state.query.is_empty() {
|
||||
"Type to search".dim().to_string()
|
||||
} else {
|
||||
format!("Search: {}", state.query)
|
||||
};
|
||||
frame.render_widget_ref(Line::from(q), search);
|
||||
|
||||
// List
|
||||
render_list(frame, list, state);
|
||||
|
||||
// Hint line
|
||||
let hint_line: Line = vec![
|
||||
"Enter".bold(),
|
||||
" to resume ".into(),
|
||||
"Esc".bold(),
|
||||
" to start new ".into(),
|
||||
"Ctrl+C".into(),
|
||||
" to quit ".dim(),
|
||||
"←/a".into(),
|
||||
" prev ".dim(),
|
||||
"→/d".into(),
|
||||
" next".dim(),
|
||||
]
|
||||
.into();
|
||||
frame.render_widget_ref(hint_line, hint);
|
||||
})
|
||||
}
|
||||
|
||||
fn render_list(frame: &mut crate::custom_terminal::Frame, area: Rect, state: &PickerState) {
|
||||
let rows = &state.filtered_rows;
|
||||
if rows.is_empty() {
|
||||
frame.render_widget_ref(Line::from("No sessions found".italic().dim()), area);
|
||||
return;
|
||||
}
|
||||
|
||||
// Compute how many rows fit (1 line per item)
|
||||
let capacity = area.height as usize;
|
||||
let start = state.selected.saturating_sub(capacity.saturating_sub(1));
|
||||
let visible = &rows[start..rows.len().min(start + capacity)];
|
||||
|
||||
let mut y = area.y;
|
||||
for (idx, row) in visible.iter().enumerate() {
|
||||
let is_sel = start + idx == state.selected;
|
||||
let marker = if is_sel { "> ".bold() } else { " ".into() };
|
||||
let ts = row
|
||||
.ts
|
||||
.map(human_time_ago)
|
||||
.unwrap_or_else(|| "".to_string())
|
||||
.dim();
|
||||
let max_cols = area.width.saturating_sub(6) as usize;
|
||||
let preview = truncate_text(&row.preview, max_cols);
|
||||
|
||||
let line: Line = vec![marker, ts, " ".into(), preview.into()].into();
|
||||
let rect = Rect::new(area.x, y, area.width, 1);
|
||||
frame.render_widget_ref(line, rect);
|
||||
y = y.saturating_add(1);
|
||||
}
|
||||
}
|
||||
|
||||
fn human_time_ago(ts: DateTime<Utc>) -> String {
|
||||
let now = Utc::now();
|
||||
let delta = now - ts;
|
||||
let secs = delta.num_seconds();
|
||||
if secs < 60 {
|
||||
let n = secs.max(0);
|
||||
if n == 1 {
|
||||
format!("{n} second ago")
|
||||
} else {
|
||||
format!("{n} seconds ago")
|
||||
}
|
||||
} else if secs < 60 * 60 {
|
||||
let m = secs / 60;
|
||||
if m == 1 {
|
||||
format!("{m} minute ago")
|
||||
} else {
|
||||
format!("{m} minutes ago")
|
||||
}
|
||||
} else if secs < 60 * 60 * 24 {
|
||||
let h = secs / 3600;
|
||||
if h == 1 {
|
||||
format!("{h} hour ago")
|
||||
} else {
|
||||
format!("{h} hours ago")
|
||||
}
|
||||
} else {
|
||||
let d = secs / (60 * 60 * 24);
|
||||
if d == 1 {
|
||||
format!("{d} day ago")
|
||||
} else {
|
||||
format!("{d} days ago")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
fn head_with_ts_and_user_text(ts: &str, texts: &[&str]) -> Vec<serde_json::Value> {
|
||||
vec![
|
||||
json!({ "timestamp": ts }),
|
||||
json!({
|
||||
"type": "message",
|
||||
"role": "user",
|
||||
"content": texts
|
||||
.iter()
|
||||
.map(|t| json!({ "type": "input_text", "text": *t }))
|
||||
.collect::<Vec<_>>()
|
||||
}),
|
||||
]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skips_user_instructions_and_env_context() {
|
||||
let head = vec![
|
||||
json!({ "timestamp": "2025-01-01T00:00:00Z" }),
|
||||
json!({
|
||||
"type": "message",
|
||||
"role": "user",
|
||||
"content": [
|
||||
{ "type": "input_text", "text": "<user_instructions>hi</user_instructions>" }
|
||||
]
|
||||
}),
|
||||
json!({
|
||||
"type": "message",
|
||||
"role": "user",
|
||||
"content": [
|
||||
{ "type": "input_text", "text": "<environment_context>cwd</environment_context>" }
|
||||
]
|
||||
}),
|
||||
json!({
|
||||
"type": "message",
|
||||
"role": "user",
|
||||
"content": [ { "type": "input_text", "text": "real question" } ]
|
||||
}),
|
||||
];
|
||||
let first = find_first_user_text(&head);
|
||||
assert_eq!(first.as_deref(), Some("real question"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn to_rows_sorts_descending_by_timestamp() {
|
||||
// Construct two items with different timestamps and real user text.
|
||||
let a = ConversationItem {
|
||||
path: PathBuf::from("/tmp/a.jsonl"),
|
||||
head: head_with_ts_and_user_text("2025-01-01T00:00:00Z", &["A"]),
|
||||
};
|
||||
let b = ConversationItem {
|
||||
path: PathBuf::from("/tmp/b.jsonl"),
|
||||
head: head_with_ts_and_user_text("2025-01-02T00:00:00Z", &["B"]),
|
||||
};
|
||||
let rows = to_rows(ConversationsPage {
|
||||
items: vec![a, b],
|
||||
next_cursor: None,
|
||||
num_scanned_files: 0,
|
||||
reached_scan_cap: false,
|
||||
});
|
||||
assert_eq!(rows.len(), 2);
|
||||
// Expect the newer timestamp (B) first
|
||||
assert!(rows[0].preview.contains('B'));
|
||||
assert!(rows[1].preview.contains('A'));
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,18 @@
|
||||
|
||||
Key flags: `--model/-m`, `--ask-for-approval/-a`.
|
||||
|
||||
Resume options:
|
||||
|
||||
- `--resume`: open an interactive picker of recent sessions (shows a preview of the first real user message). Conflicts with `--continue`.
|
||||
- `--continue`: resume the most recent session without showing the picker (falls back to starting fresh if none exist). Conflicts with `--resume`.
|
||||
|
||||
Examples:
|
||||
|
||||
```shell
|
||||
codex --resume
|
||||
codex --continue
|
||||
```
|
||||
|
||||
### Running with a prompt as input
|
||||
|
||||
You can also run Codex CLI with a prompt as input:
|
||||
|
||||
Reference in New Issue
Block a user