feat: ephemeral threads (#9765)

Add ephemeral threads capabilities. Only exposed through the
`app-server` v2

The idea is to disable the rollout recorder for those threads.
This commit is contained in:
jif-oai
2026-01-24 15:57:40 +01:00
committed by GitHub
Unverified
parent 515ac2cd19
commit 83775f4df1
30 changed files with 343 additions and 166 deletions
+1
View File
@@ -1096,6 +1096,7 @@ dependencies = [
"serial_test",
"shlex",
"tempfile",
"time",
"tokio",
"toml 0.9.5",
"tracing",
@@ -1086,6 +1086,7 @@ pub struct ThreadStartParams {
pub base_instructions: Option<String>,
pub developer_instructions: Option<String>,
pub personality: Option<Personality>,
pub ephemeral: Option<bool>,
/// If true, opt into emitting raw response items on the event stream.
///
/// This is for internal use only (e.g. Codex Cloud).
@@ -1469,7 +1470,7 @@ pub struct Thread {
#[ts(type = "number")]
pub updated_at: i64,
/// [UNSTABLE] Path to the thread on disk.
pub path: PathBuf,
pub path: Option<PathBuf>,
/// Working directory captured for the thread.
pub cwd: PathBuf,
/// Version of the CLI that created the thread.
+1
View File
@@ -35,6 +35,7 @@ serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true }
mcp-types = { workspace = true }
tempfile = { workspace = true }
time = { workspace = true }
toml = { workspace = true }
tokio = { workspace = true, features = [
"io-std",
@@ -1006,7 +1006,15 @@ pub(crate) async fn apply_bespoke_event_handling(
};
if let Some(request_id) = pending {
let rollout_path = conversation.rollout_path();
let Some(rollout_path) = conversation.rollout_path() else {
let error = JSONRPCErrorError {
code: INVALID_REQUEST_ERROR_CODE,
message: "thread has no persisted rollout".to_string(),
data: None,
};
outgoing.send_error(request_id, error).await;
return;
};
let response = match read_summary_from_rollout(
rollout_path.as_path(),
fallback_model_provider.as_str(),
+177 -114
View File
@@ -134,6 +134,7 @@ use codex_core::InitialHistory;
use codex_core::NewThread;
use codex_core::RolloutRecorder;
use codex_core::SessionMeta;
use codex_core::ThreadConfigSnapshot;
use codex_core::ThreadManager;
use codex_core::ThreadSortKey as CoreThreadSortKey;
use codex_core::auth::CLIENT_ID;
@@ -172,6 +173,7 @@ use codex_protocol::config_types::ForcedLoginMethod;
use codex_protocol::config_types::Personality;
use codex_protocol::items::TurnItem;
use codex_protocol::models::ResponseItem;
use codex_protocol::protocol::AgentStatus;
use codex_protocol::protocol::GitInfo as CoreGitInfo;
use codex_protocol::protocol::McpAuthStatus as CoreMcpAuthStatus;
use codex_protocol::protocol::McpServerRefreshConfig;
@@ -192,7 +194,6 @@ use std::sync::Arc;
use std::sync::atomic::AtomicBool;
use std::sync::atomic::Ordering;
use std::time::Duration;
use tokio::select;
use tokio::sync::Mutex;
use tokio::sync::broadcast;
use tokio::sync::oneshot;
@@ -1378,11 +1379,23 @@ impl CodexMessageProcessor {
session_configured,
..
} = new_thread;
let rollout_path = match session_configured.rollout_path {
Some(path) => path,
None => {
let error = JSONRPCErrorError {
code: INTERNAL_ERROR_CODE,
message: "rollout path missing for v1 conversation".to_string(),
data: None,
};
self.outgoing.send_error(request_id, error).await;
return;
}
};
let response = NewConversationResponse {
conversation_id: thread_id,
model: session_configured.model,
reasoning_effort: session_configured.reasoning_effort,
rollout_path: session_configured.rollout_path,
rollout_path,
};
self.outgoing.send_response(request_id, response).await;
}
@@ -1398,7 +1411,7 @@ impl CodexMessageProcessor {
}
async fn thread_start(&mut self, request_id: RequestId, params: ThreadStartParams) {
let typesafe_overrides = self.build_thread_config_overrides(
let mut typesafe_overrides = self.build_thread_config_overrides(
params.model,
params.model_provider,
params.cwd,
@@ -1408,6 +1421,7 @@ impl CodexMessageProcessor {
params.developer_instructions,
params.personality,
);
typesafe_overrides.ephemeral = Some(params.ephemeral.unwrap_or_default());
let config =
match derive_config_from_params(&self.cli_overrides, params.config, typesafe_overrides)
@@ -1429,50 +1443,45 @@ impl CodexMessageProcessor {
Ok(new_conv) => {
let NewThread {
thread_id,
thread,
session_configured,
..
} = new_conv;
let rollout_path = session_configured.rollout_path.clone();
let config_snapshot = thread.config_snapshot().await;
let fallback_provider = self.config.model_provider_id.as_str();
// A bit hacky, but the summary contains a lot of useful information for the thread
// that unfortunately does not get returned from thread_manager.start_thread().
let thread = match read_summary_from_rollout(
rollout_path.as_path(),
fallback_provider,
)
.await
{
Ok(summary) => summary_to_thread(summary),
Err(err) => {
self.send_internal_error(
request_id,
format!(
"failed to load rollout `{}` for thread {thread_id}: {err}",
rollout_path.display()
),
)
.await;
return;
let thread = match session_configured.rollout_path.as_ref() {
Some(rollout_path) => {
match read_summary_from_rollout(rollout_path.as_path(), fallback_provider)
.await
{
Ok(summary) => summary_to_thread(summary),
Err(err) => {
self.send_internal_error(
request_id,
format!(
"failed to load rollout `{}` for thread {thread_id}: {err}",
rollout_path.display()
),
)
.await;
return;
}
}
}
None => build_ephemeral_thread(thread_id, &config_snapshot),
};
let SessionConfiguredEvent {
model,
model_provider_id,
cwd,
approval_policy,
sandbox_policy,
..
} = session_configured;
let response = ThreadStartResponse {
thread: thread.clone(),
model,
model_provider: model_provider_id,
cwd,
approval_policy: approval_policy.into(),
sandbox: sandbox_policy.into(),
reasoning_effort: session_configured.reasoning_effort,
model: config_snapshot.model,
model_provider: config_snapshot.model_provider_id,
cwd: config_snapshot.cwd,
approval_policy: config_snapshot.approval_policy.into(),
sandbox: config_snapshot.sandbox_policy.into(),
reasoning_effort: config_snapshot.reasoning_effort,
};
// Auto-attach a thread listener when starting a thread.
@@ -1725,7 +1734,7 @@ impl CodexMessageProcessor {
self.outgoing.send_response(request_id, response).await;
}
async fn thread_read(&self, request_id: RequestId, params: ThreadReadParams) {
async fn thread_read(&mut self, request_id: RequestId, params: ThreadReadParams) {
let ThreadReadParams {
thread_id,
include_turns,
@@ -1744,15 +1753,8 @@ impl CodexMessageProcessor {
match find_thread_path_by_id_str(&self.config.codex_home, &thread_uuid.to_string())
.await
{
Ok(Some(path)) => path,
Ok(None) => {
self.send_invalid_request_error(
request_id,
format!("no rollout found for thread id {thread_uuid}"),
)
.await;
return;
}
Ok(Some(path)) => Some(path),
Ok(None) => None,
Err(err) => {
self.send_invalid_request_error(
request_id,
@@ -1763,24 +1765,45 @@ impl CodexMessageProcessor {
}
};
let fallback_provider = self.config.model_provider_id.as_str();
let mut thread = match read_summary_from_rollout(&rollout_path, fallback_provider).await {
Ok(summary) => summary_to_thread(summary),
Err(err) => {
self.send_internal_error(
let mut thread = if let Some(rollout_path) = rollout_path.as_ref() {
let fallback_provider = self.config.model_provider_id.as_str();
match read_summary_from_rollout(rollout_path, fallback_provider).await {
Ok(summary) => summary_to_thread(summary),
Err(err) => {
self.send_internal_error(
request_id,
format!(
"failed to load rollout `{}` for thread {thread_uuid}: {err}",
rollout_path.display()
),
)
.await;
return;
}
}
} else {
let Ok(thread) = self.thread_manager.get_thread(thread_uuid).await else {
self.send_invalid_request_error(
request_id,
format!(
"failed to load rollout `{}` for thread {thread_uuid}: {err}",
rollout_path.display()
),
format!("thread not loaded: {thread_uuid}"),
)
.await;
return;
};
let config_snapshot = thread.config_snapshot().await;
if include_turns {
self.send_invalid_request_error(
request_id,
"ephemeral threads do not support includeTurns".to_string(),
)
.await;
return;
}
build_ephemeral_thread(thread_uuid, &config_snapshot)
};
if include_turns {
match read_event_msgs_from_rollout(&rollout_path).await {
if include_turns && let Some(rollout_path) = rollout_path.as_ref() {
match read_event_msgs_from_rollout(rollout_path).await {
Ok(events) => {
thread.turns = build_turns_from_event_msgs(&events);
}
@@ -1967,6 +1990,14 @@ impl CodexMessageProcessor {
initial_messages,
..
} = session_configured;
let Some(rollout_path) = rollout_path else {
self.send_internal_error(
request_id,
format!("rollout path missing for thread {thread_id}"),
)
.await;
return;
};
// Auto-attach a thread listener when resuming a thread.
if let Err(err) = self
.attach_conversation_listener(thread_id, false, ApiVersion::V2)
@@ -2170,6 +2201,14 @@ impl CodexMessageProcessor {
initial_messages,
..
} = session_configured;
let Some(rollout_path) = rollout_path else {
self.send_internal_error(
request_id,
format!("rollout path missing for thread {thread_id}"),
)
.await;
return;
};
// Auto-attach a conversation listener when forking a thread.
if let Err(err) = self
.attach_conversation_listener(thread_id, false, ApiVersion::V2)
@@ -2919,6 +2958,18 @@ impl CodexMessageProcessor {
session_configured,
..
}) => {
let rollout_path = match session_configured.rollout_path.clone() {
Some(path) => path,
None => {
let error = JSONRPCErrorError {
code: INTERNAL_ERROR_CODE,
message: "rollout path missing for resumed conversation".to_string(),
data: None,
};
self.outgoing.send_error(request_id, error).await;
return;
}
};
self.outgoing
.send_server_notification(ServerNotification::SessionConfigured(
SessionConfiguredNotification {
@@ -2928,7 +2979,7 @@ impl CodexMessageProcessor {
history_log_id: session_configured.history_log_id,
history_entry_count: session_configured.history_entry_count,
initial_messages: session_configured.initial_messages.clone(),
rollout_path: session_configured.rollout_path.clone(),
rollout_path: rollout_path.clone(),
},
))
.await;
@@ -2941,7 +2992,7 @@ impl CodexMessageProcessor {
conversation_id: thread_id,
model: session_configured.model.clone(),
initial_messages,
rollout_path: session_configured.rollout_path.clone(),
rollout_path,
};
self.outgoing.send_response(request_id, response).await;
}
@@ -3117,6 +3168,19 @@ impl CodexMessageProcessor {
}
};
let rollout_path = match session_configured.rollout_path.clone() {
Some(path) => path,
None => {
let error = JSONRPCErrorError {
code: INTERNAL_ERROR_CODE,
message: "rollout path missing for forked conversation".to_string(),
data: None,
};
self.outgoing.send_error(request_id, error).await;
return;
}
};
self.outgoing
.send_server_notification(ServerNotification::SessionConfigured(
SessionConfiguredNotification {
@@ -3126,7 +3190,7 @@ impl CodexMessageProcessor {
history_log_id: session_configured.history_log_id,
history_entry_count: session_configured.history_entry_count,
initial_messages: session_configured.initial_messages.clone(),
rollout_path: session_configured.rollout_path.clone(),
rollout_path: rollout_path.clone(),
},
))
.await;
@@ -3139,7 +3203,7 @@ impl CodexMessageProcessor {
conversation_id: thread_id,
model: session_configured.model.clone(),
initial_messages,
rollout_path: session_configured.rollout_path.clone(),
rollout_path,
};
self.outgoing.send_response(request_id, response).await;
}
@@ -3250,51 +3314,27 @@ impl CodexMessageProcessor {
// If the thread is active, request shutdown and wait briefly.
if let Some(conversation) = self.thread_manager.remove_thread(&thread_id).await {
info!("thread {thread_id} was active; shutting down");
let conversation_clone = conversation.clone();
let notify = Arc::new(tokio::sync::Notify::new());
let notify_clone = notify.clone();
// Establish the listener for ShutdownComplete before submitting
// Shutdown so it is not missed.
let is_shutdown = tokio::spawn(async move {
// Create the notified future outside the loop to avoid losing notifications.
let notified = notify_clone.notified();
tokio::pin!(notified);
loop {
select! {
_ = &mut notified => { break; }
event = conversation_clone.next_event() => {
match event {
Ok(event) => {
if matches!(event.msg, EventMsg::ShutdownComplete) { break; }
}
// Break on errors to avoid tight loops when the agent loop has exited.
Err(_) => { break; }
}
}
}
}
});
// Request shutdown.
match conversation.submit(Op::Shutdown).await {
Ok(_) => {
// Successfully submitted Shutdown; wait before proceeding.
select! {
_ = is_shutdown => {
// Normal shutdown: proceed with archive.
}
_ = tokio::time::sleep(Duration::from_secs(10)) => {
warn!("thread {thread_id} shutdown timed out; proceeding with archive");
// Wake any waiter; use notify_waiters to avoid missing the signal.
notify.notify_waiters();
// Perhaps we lost a shutdown race, so let's continue to
// clean up the .jsonl file.
// Poll agent status rather than consuming events so attached listeners do not block shutdown.
let wait_for_shutdown = async {
loop {
if matches!(conversation.agent_status().await, AgentStatus::Shutdown) {
break;
}
tokio::time::sleep(Duration::from_millis(50)).await;
}
};
if tokio::time::timeout(Duration::from_secs(10), wait_for_shutdown)
.await
.is_err()
{
warn!("thread {thread_id} shutdown timed out; proceeding with archive");
}
}
Err(err) => {
error!("failed to submit Shutdown to thread {thread_id}: {err}");
notify.notify_waiters();
}
}
}
@@ -3809,23 +3849,29 @@ impl CodexMessageProcessor {
);
}
let rollout_path = review_thread.rollout_path();
let fallback_provider = self.config.model_provider_id.as_str();
match read_summary_from_rollout(rollout_path.as_path(), fallback_provider).await {
Ok(summary) => {
let thread = summary_to_thread(summary);
let notif = ThreadStartedNotification { thread };
self.outgoing
.send_server_notification(ServerNotification::ThreadStarted(notif))
.await;
}
Err(err) => {
tracing::warn!(
"failed to load summary for review thread {}: {}",
session_configured.session_id,
err
);
if let Some(rollout_path) = review_thread.rollout_path() {
match read_summary_from_rollout(rollout_path.as_path(), fallback_provider).await {
Ok(summary) => {
let thread = summary_to_thread(summary);
let notif = ThreadStartedNotification { thread };
self.outgoing
.send_server_notification(ServerNotification::ThreadStarted(notif))
.await;
}
Err(err) => {
tracing::warn!(
"failed to load summary for review thread {}: {}",
session_configured.session_id,
err
);
}
}
} else {
tracing::warn!(
"review thread {} has no rollout path",
session_configured.session_id
);
}
let turn_id = review_thread
@@ -4228,7 +4274,7 @@ impl CodexMessageProcessor {
async fn resolve_rollout_path(&self, conversation_id: ThreadId) -> Option<PathBuf> {
match self.thread_manager.get_thread(conversation_id).await {
Ok(conv) => Some(conv.rollout_path()),
Ok(conv) => conv.rollout_path(),
Err(_) => None,
}
}
@@ -4493,6 +4539,23 @@ async fn read_updated_at(path: &Path, created_at: Option<&str>) -> Option<String
updated_at.or_else(|| created_at.map(str::to_string))
}
fn build_ephemeral_thread(thread_id: ThreadId, config_snapshot: &ThreadConfigSnapshot) -> Thread {
let now = time::OffsetDateTime::now_utc().unix_timestamp();
Thread {
id: thread_id.to_string(),
preview: String::new(),
model_provider: config_snapshot.model_provider_id.clone(),
created_at: now,
updated_at: now,
path: None,
cwd: config_snapshot.cwd.clone(),
cli_version: env!("CARGO_PKG_VERSION").to_string(),
source: config_snapshot.session_source.clone().into(),
git_info: None,
turns: Vec::new(),
}
}
pub(crate) fn summary_to_thread(summary: ConversationSummary) -> Thread {
let ConversationSummary {
conversation_id,
@@ -4521,7 +4584,7 @@ pub(crate) fn summary_to_thread(summary: ConversationSummary) -> Thread {
model_provider,
created_at: created_at.map(|dt| dt.timestamp()).unwrap_or(0),
updated_at: updated_at.map(|dt| dt.timestamp()).unwrap_or(0),
path,
path: Some(path),
cwd,
cli_version,
source: source.into(),
@@ -77,8 +77,9 @@ async fn thread_fork_creates_new_thread_and_emits_started() -> Result<()> {
assert_ne!(thread.id, conversation_id);
assert_eq!(thread.preview, preview);
assert_eq!(thread.model_provider, "mock_provider");
assert!(thread.path.is_absolute());
assert_ne!(thread.path, original_path);
let thread_path = thread.path.clone().expect("thread path");
assert!(thread_path.is_absolute());
assert_ne!(thread_path, original_path);
assert!(thread.cwd.is_absolute());
assert_eq!(thread.source, SessionSource::VsCode);
@@ -64,7 +64,7 @@ async fn thread_read_returns_summary_without_turns() -> Result<()> {
assert_eq!(thread.id, conversation_id);
assert_eq!(thread.preview, preview);
assert_eq!(thread.model_provider, "mock_provider");
assert!(thread.path.is_absolute());
assert!(thread.path.as_ref().expect("thread path").is_absolute());
assert_eq!(thread.cwd, PathBuf::from("/"));
assert_eq!(thread.cli_version, "0.0.0");
assert_eq!(thread.source, SessionSource::Cli);
@@ -117,7 +117,7 @@ async fn thread_resume_returns_rollout_history() -> Result<()> {
assert_eq!(thread.id, conversation_id);
assert_eq!(thread.preview, preview);
assert_eq!(thread.model_provider, "mock_provider");
assert!(thread.path.is_absolute());
assert!(thread.path.as_ref().expect("thread path").is_absolute());
assert_eq!(thread.cwd, PathBuf::from("/"));
assert_eq!(thread.cli_version, "0.0.0");
assert_eq!(thread.source, SessionSource::Cli);
@@ -169,7 +169,7 @@ async fn thread_resume_prefers_path_over_thread_id() -> Result<()> {
.await??;
let ThreadStartResponse { thread, .. } = to_response::<ThreadStartResponse>(start_resp)?;
let thread_path = thread.path.clone();
let thread_path = thread.path.clone().expect("thread path");
let resume_id = mcp
.send_thread_resume_request(ThreadResumeParams {
thread_id: "not-a-valid-thread-id".to_string(),
+35 -4
View File
@@ -89,6 +89,7 @@ use crate::client::ModelClient;
use crate::client::ModelClientSession;
use crate::client_common::Prompt;
use crate::client_common::ResponseEvent;
use crate::codex_thread::ThreadConfigSnapshot;
use crate::compact::collect_user_messages;
use crate::config::Config;
use crate::config::Constrained;
@@ -192,6 +193,7 @@ pub struct Codex {
pub(crate) rx_event: Receiver<Event>,
// Last known status of the agent.
pub(crate) agent_status: watch::Receiver<AgentStatus>,
pub(crate) session: Arc<Session>,
}
/// Wrapper returned by [`Codex::spawn`] containing the spawned [`Codex`],
@@ -345,12 +347,13 @@ impl Codex {
let thread_id = session.conversation_id;
// This task will run until Op::Shutdown is received.
tokio::spawn(submission_loop(session, config, rx_sub));
tokio::spawn(submission_loop(Arc::clone(&session), config, rx_sub));
let codex = Codex {
next_id: AtomicU64::new(0),
tx_sub,
rx_event,
agent_status: agent_status_rx,
session,
};
#[allow(deprecated)]
@@ -394,6 +397,11 @@ impl Codex {
pub(crate) async fn agent_status(&self) -> AgentStatus {
self.agent_status.borrow().clone()
}
pub(crate) async fn thread_config_snapshot(&self) -> ThreadConfigSnapshot {
let state = self.session.state.lock().await;
state.session_configuration.thread_config_snapshot()
}
}
/// Context for an initialized model agent
@@ -495,6 +503,19 @@ pub(crate) struct SessionConfiguration {
}
impl SessionConfiguration {
fn thread_config_snapshot(&self) -> ThreadConfigSnapshot {
ThreadConfigSnapshot {
model: self.collaboration_mode.model().to_string(),
model_provider_id: self.original_config_do_not_use.model_provider_id.clone(),
approval_policy: self.approval_policy.value(),
sandbox_policy: self.sandbox_policy.get().clone(),
cwd: self.cwd.clone(),
reasoning_effort: self.collaboration_mode.reasoning_effort(),
personality: self.personality,
session_source: self.session_source.clone(),
}
}
pub(crate) fn apply(&self, updates: &SessionSettingsUpdate) -> ConstraintResult<Self> {
let mut next_configuration = self.clone();
if let Some(collaboration_mode) = updates.collaboration_mode.clone() {
@@ -652,7 +673,15 @@ impl Session {
// - initialize RolloutRecorder with new or resumed session info
// - perform default shell discovery
// - load history metadata
let rollout_fut = RolloutRecorder::new(&config, rollout_params);
let rollout_fut = async {
if config.ephemeral {
Ok(None)
} else {
RolloutRecorder::new(&config, rollout_params)
.await
.map(Some)
}
};
let history_meta_fut = crate::message_history::history_metadata(&config);
let auth_manager_clone = Arc::clone(&auth_manager);
@@ -679,7 +708,9 @@ impl Session {
error!("failed to initialize rollout recorder: {e:#}");
anyhow::Error::from(e)
})?;
let rollout_path = rollout_recorder.rollout_path.clone();
let rollout_path = rollout_recorder
.as_ref()
.map(|rec| rec.rollout_path.clone());
let mut post_session_configured_events = Vec::<Event>::new();
@@ -768,7 +799,7 @@ impl Session {
mcp_startup_cancellation_token: Mutex::new(CancellationToken::new()),
unified_exec_manager: UnifiedExecProcessManager::default(),
notifier: UserNotifier::new(config.notify.clone()),
rollout: Mutex::new(Some(rollout_recorder)),
rollout: Mutex::new(rollout_recorder),
user_shell: Arc::new(default_shell),
show_raw_agent_reasoning: config.show_raw_agent_reasoning,
exec_policy,
+5 -2
View File
@@ -92,6 +92,7 @@ pub(crate) async fn run_codex_thread_interactive(
tx_sub: tx_ops,
rx_event: rx_sub,
agent_status: codex.agent_status.clone(),
session: Arc::clone(&codex.session),
})
}
@@ -134,6 +135,7 @@ pub(crate) async fn run_codex_thread_one_shot(
let (tx_bridge, rx_bridge) = async_channel::bounded(SUBMISSION_CHANNEL_CAPACITY);
let ops_tx = io.tx_sub.clone();
let agent_status = io.agent_status.clone();
let session = Arc::clone(&io.session);
let io_for_bridge = io;
tokio::spawn(async move {
while let Ok(event) = io_for_bridge.next_event().await {
@@ -166,6 +168,7 @@ pub(crate) async fn run_codex_thread_one_shot(
rx_event: rx_bridge,
tx_sub: tx_closed,
agent_status,
session,
})
}
@@ -442,15 +445,15 @@ mod tests {
let (tx_events, rx_events) = bounded(1);
let (tx_sub, rx_sub) = bounded(SUBMISSION_CHANNEL_CAPACITY);
let (_agent_status_tx, agent_status) = watch::channel(AgentStatus::PendingInit);
let (session, ctx, _rx_evt) = crate::codex::make_session_and_context_with_rx().await;
let codex = Arc::new(Codex {
next_id: AtomicU64::new(0),
tx_sub,
rx_event: rx_events,
agent_status,
session: Arc::clone(&session),
});
let (session, ctx, _rx_evt) = crate::codex::make_session_and_context_with_rx().await;
let (tx_out, rx_out) = bounded(1);
tx_out
.send(Event {
+24 -3
View File
@@ -4,18 +4,35 @@ use crate::error::Result as CodexResult;
use crate::protocol::Event;
use crate::protocol::Op;
use crate::protocol::Submission;
use codex_protocol::config_types::Personality;
use codex_protocol::openai_models::ReasoningEffort;
use codex_protocol::protocol::AskForApproval;
use codex_protocol::protocol::SandboxPolicy;
use codex_protocol::protocol::SessionSource;
use std::path::PathBuf;
use tokio::sync::watch;
#[derive(Clone, Debug)]
pub struct ThreadConfigSnapshot {
pub model: String,
pub model_provider_id: String,
pub approval_policy: AskForApproval,
pub sandbox_policy: SandboxPolicy,
pub cwd: PathBuf,
pub reasoning_effort: Option<ReasoningEffort>,
pub personality: Option<Personality>,
pub session_source: SessionSource,
}
pub struct CodexThread {
codex: Codex,
rollout_path: PathBuf,
rollout_path: Option<PathBuf>,
}
/// Conduit for the bidirectional stream of messages that compose a thread
/// (formerly called a conversation) in Codex.
impl CodexThread {
pub(crate) fn new(codex: Codex, rollout_path: PathBuf) -> Self {
pub(crate) fn new(codex: Codex, rollout_path: Option<PathBuf>) -> Self {
Self {
codex,
rollout_path,
@@ -43,7 +60,11 @@ impl CodexThread {
self.codex.agent_status.clone()
}
pub fn rollout_path(&self) -> PathBuf {
pub fn rollout_path(&self) -> Option<PathBuf> {
self.rollout_path.clone()
}
pub async fn config_snapshot(&self) -> ThreadConfigSnapshot {
self.codex.thread_config_snapshot().await
}
}
+10
View File
@@ -261,6 +261,9 @@ pub struct Config {
/// Settings that govern if and what will be written to `~/.codex/history.jsonl`.
pub history: History,
/// When true, session is not persisted on disk. Default to `false`
pub ephemeral: bool,
/// Optional URI-based file opener. If set, citations to files in the model
/// output will be hyperlinked using the specified URI scheme.
pub file_opener: UriBasedFileOpener,
@@ -1158,6 +1161,7 @@ pub struct ConfigOverrides {
pub include_apply_patch_tool: Option<bool>,
pub show_raw_agent_reasoning: Option<bool>,
pub tools_web_search_request: Option<bool>,
pub ephemeral: Option<bool>,
/// Additional directories that should be treated as writable roots for this session.
pub additional_writable_roots: Vec<PathBuf>,
}
@@ -1246,6 +1250,7 @@ impl Config {
include_apply_patch_tool: include_apply_patch_tool_override,
show_raw_agent_reasoning,
tools_web_search_request: override_tools_web_search_request,
ephemeral,
additional_writable_roots,
} = overrides;
@@ -1530,6 +1535,7 @@ impl Config {
codex_home,
config_layer_stack,
history,
ephemeral: ephemeral.unwrap_or_default(),
file_opener: cfg.file_opener.unwrap_or(UriBasedFileOpener::VsCode),
codex_linux_sandbox_exe,
@@ -3691,6 +3697,7 @@ model_verbosity = "high"
codex_home: fixture.codex_home(),
config_layer_stack: Default::default(),
history: History::default(),
ephemeral: false,
file_opener: UriBasedFileOpener::VsCode,
codex_linux_sandbox_exe: None,
hide_agent_reasoning: false,
@@ -3772,6 +3779,7 @@ model_verbosity = "high"
codex_home: fixture.codex_home(),
config_layer_stack: Default::default(),
history: History::default(),
ephemeral: false,
file_opener: UriBasedFileOpener::VsCode,
codex_linux_sandbox_exe: None,
hide_agent_reasoning: false,
@@ -3868,6 +3876,7 @@ model_verbosity = "high"
codex_home: fixture.codex_home(),
config_layer_stack: Default::default(),
history: History::default(),
ephemeral: false,
file_opener: UriBasedFileOpener::VsCode,
codex_linux_sandbox_exe: None,
hide_agent_reasoning: false,
@@ -3950,6 +3959,7 @@ model_verbosity = "high"
codex_home: fixture.codex_home(),
config_layer_stack: Default::default(),
history: History::default(),
ephemeral: false,
file_opener: UriBasedFileOpener::VsCode,
codex_linux_sandbox_exe: None,
hide_agent_reasoning: false,
+1
View File
@@ -15,6 +15,7 @@ pub mod codex;
mod codex_thread;
mod compact_remote;
pub use codex_thread::CodexThread;
pub use codex_thread::ThreadConfigSnapshot;
mod agent;
mod codex_delegate;
mod command_safety;
@@ -422,7 +422,11 @@ async fn resume_replays_collaboration_instructions() -> Result<()> {
let mut builder = test_codex();
let initial = builder.build(&server).await?;
let rollout_path = initial.session_configured.rollout_path.clone();
let rollout_path = initial
.session_configured
.rollout_path
.clone()
.expect("rollout path");
let home = initial.home.clone();
let collab_text = "resume instructions";
+7 -3
View File
@@ -154,7 +154,7 @@ async fn summarize_context_three_requests_and_instructions() {
session_configured,
..
} = thread_manager.start_thread(config).await.unwrap();
let rollout_path = session_configured.rollout_path;
let rollout_path = session_configured.rollout_path.expect("rollout path");
// 1) Normal user input should hit server once.
codex
@@ -1237,7 +1237,11 @@ async fn auto_compact_runs_after_resume_when_token_usage_is_over_limit() {
});
let initial = builder.build(&server).await.unwrap();
let home = initial.home.clone();
let rollout_path = initial.session_configured.rollout_path.clone();
let rollout_path = initial
.session_configured
.rollout_path
.clone()
.expect("rollout path");
// A single over-limit completion should not auto-compact until the next user message.
mount_sse_once(
@@ -1429,7 +1433,7 @@ async fn auto_compact_persists_rollout_entries() {
codex.submit(Op::Shutdown).await.unwrap();
wait_for_event(&codex, |ev| matches!(ev, EventMsg::ShutdownComplete)).await;
let rollout_path = session_configured.rollout_path;
let rollout_path = session_configured.rollout_path.expect("rollout path");
let text = std::fs::read_to_string(&rollout_path).unwrap_or_else(|e| {
panic!(
"failed to read rollout file {}: {e}",
+6 -1
View File
@@ -230,7 +230,12 @@ async fn remote_compact_persists_replacement_history_in_rollout() -> Result<()>
)
.await?;
let codex = harness.test().codex.clone();
let rollout_path = harness.test().session_configured.rollout_path.clone();
let rollout_path = harness
.test()
.session_configured
.rollout_path
.clone()
.expect("rollout path");
let responses_mock = responses::mount_sse_once(
harness.server(),
@@ -1013,7 +1013,7 @@ async fn compact_conversation(conversation: &Arc<CodexThread>) {
}
async fn fetch_conversation_path(conversation: &Arc<CodexThread>) -> std::path::PathBuf {
conversation.rollout_path()
conversation.rollout_path().expect("rollout path")
}
async fn resume_conversation(
+3 -3
View File
@@ -80,7 +80,7 @@ async fn fork_thread_twice_drops_to_first_message() {
}
// Request history from the base conversation to obtain rollout path.
let base_path = codex.rollout_path();
let base_path = codex.rollout_path().expect("rollout path");
// GetHistory flushes before returning the path; no wait needed.
@@ -135,7 +135,7 @@ async fn fork_thread_twice_drops_to_first_message() {
.await
.expect("fork 1");
let fork1_path = codex_fork1.rollout_path();
let fork1_path = codex_fork1.rollout_path().expect("rollout path");
// GetHistory on fork1 flushed; the file is ready.
let fork1_items = read_items(&fork1_path);
@@ -154,7 +154,7 @@ async fn fork_thread_twice_drops_to_first_message() {
.await
.expect("fork 2");
let fork2_path = codex_fork2.rollout_path();
let fork2_path = codex_fork2.rollout_path().expect("rollout path");
// GetHistory on fork2 flushed; the file is ready.
let fork1_items = read_items(&fork1_path);
let fork1_user_inputs = find_user_input_positions(&fork1_items);
+2 -2
View File
@@ -136,7 +136,7 @@ async fn copy_paste_local_image_persists_rollout_request_shape() -> anyhow::Resu
codex.submit(Op::Shutdown).await?;
wait_for_event(&codex, |event| matches!(event, EventMsg::ShutdownComplete)).await;
let rollout_path = codex.rollout_path();
let rollout_path = codex.rollout_path().expect("rollout path");
let rollout_text = read_rollout_text(&rollout_path).await?;
let actual = find_user_message_with_image(&rollout_text)
.expect("expected user message with input image in rollout");
@@ -217,7 +217,7 @@ async fn drag_drop_image_persists_rollout_request_shape() -> anyhow::Result<()>
codex.submit(Op::Shutdown).await?;
wait_for_event(&codex, |event| matches!(event, EventMsg::ShutdownComplete)).await;
let rollout_path = codex.rollout_path();
let rollout_path = codex.rollout_path().expect("rollout path");
let rollout_text = read_rollout_text(&rollout_path).await?;
let actual = find_user_message_with_image(&rollout_text)
.expect("expected user message with input image in rollout");
@@ -129,7 +129,7 @@ async fn override_turn_context_records_permissions_update() -> Result<()> {
test.codex.submit(Op::Shutdown).await?;
wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::ShutdownComplete)).await;
let rollout_path = test.codex.rollout_path();
let rollout_path = test.codex.rollout_path().expect("rollout path");
let rollout_text = read_rollout_text(&rollout_path).await?;
let developer_texts = rollout_developer_texts(&rollout_text);
let approval_texts: Vec<&String> = developer_texts
@@ -172,7 +172,7 @@ async fn override_turn_context_records_environment_update() -> Result<()> {
test.codex.submit(Op::Shutdown).await?;
wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::ShutdownComplete)).await;
let rollout_path = test.codex.rollout_path();
let rollout_path = test.codex.rollout_path().expect("rollout path");
let rollout_text = read_rollout_text(&rollout_path).await?;
let env_texts = rollout_environment_texts(&rollout_text);
let new_cwd_text = new_cwd.path().display().to_string();
@@ -209,7 +209,7 @@ async fn override_turn_context_records_collaboration_update() -> Result<()> {
test.codex.submit(Op::Shutdown).await?;
wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::ShutdownComplete)).await;
let rollout_path = test.codex.rollout_path();
let rollout_path = test.codex.rollout_path().expect("rollout path");
let rollout_text = read_rollout_text(&rollout_path).await?;
let developer_texts = rollout_developer_texts(&rollout_text);
let collab_text = collab_xml(collab_text);
@@ -202,7 +202,11 @@ async fn resume_replays_permissions_messages() -> Result<()> {
config.approval_policy = Constrained::allow_any(AskForApproval::OnRequest);
});
let initial = builder.build(&server).await?;
let rollout_path = initial.session_configured.rollout_path.clone();
let rollout_path = initial
.session_configured
.rollout_path
.clone()
.expect("rollout path");
let home = initial.home.clone();
initial
@@ -280,7 +284,11 @@ async fn resume_and_fork_append_permissions_messages() -> Result<()> {
config.approval_policy = Constrained::allow_any(AskForApproval::OnRequest);
});
let initial = builder.build(&server).await?;
let rollout_path = initial.session_configured.rollout_path.clone();
let rollout_path = initial
.session_configured
.rollout_path
.clone()
.expect("rollout path");
let home = initial.home.clone();
initial
+15 -3
View File
@@ -26,7 +26,11 @@ async fn resume_includes_initial_messages_from_rollout_events() -> Result<()> {
let initial = builder.build(&server).await?;
let codex = Arc::clone(&initial.codex);
let home = initial.home.clone();
let rollout_path = initial.session_configured.rollout_path.clone();
let rollout_path = initial
.session_configured
.rollout_path
.clone()
.expect("rollout path");
let initial_sse = sse(vec![
ev_response_created("resp-initial"),
@@ -85,7 +89,11 @@ async fn resume_includes_initial_messages_from_reasoning_events() -> Result<()>
let initial = builder.build(&server).await?;
let codex = Arc::clone(&initial.codex);
let home = initial.home.clone();
let rollout_path = initial.session_configured.rollout_path.clone();
let rollout_path = initial
.session_configured
.rollout_path
.clone()
.expect("rollout path");
let initial_sse = sse(vec![
ev_response_created("resp-initial"),
@@ -143,7 +151,11 @@ async fn resume_switches_models_preserves_base_instructions() -> Result<()> {
let initial = builder.build(&server).await?;
let codex = Arc::clone(&initial.codex);
let home = initial.home.clone();
let rollout_path = initial.session_configured.rollout_path.clone();
let rollout_path = initial
.session_configured
.rollout_path
.clone()
.expect("rollout path");
let initial_sse = sse(vec![
ev_response_created("resp-initial"),
+2 -2
View File
@@ -120,7 +120,7 @@ async fn review_op_emits_lifecycle_and_review_output() {
// Also verify that a user message with the header and a formatted finding
// was recorded back in the parent session's rollout.
let path = codex.rollout_path();
let path = codex.rollout_path().expect("rollout path");
let text = std::fs::read_to_string(&path).expect("read rollout file");
let mut saw_header = false;
@@ -627,7 +627,7 @@ async fn review_input_isolated_from_parent_history() {
assert_eq!(instructions, REVIEW_PROMPT);
// Also verify that a user interruption note was recorded in the rollout.
let path = codex.rollout_path();
let path = codex.rollout_path().expect("rollout path");
let text = std::fs::read_to_string(&path).expect("read rollout file");
let mut saw_interruption_message = false;
for line in text.lines() {
+1
View File
@@ -229,6 +229,7 @@ pub async fn run_main(cli: Cli, codex_linux_sandbox_exe: Option<PathBuf>) -> any
include_apply_patch_tool: None,
show_raw_agent_reasoning: oss.then_some(true),
tools_web_search_request: None,
ephemeral: None,
additional_writable_roots: add_dir,
};
@@ -85,7 +85,7 @@ fn session_configured_produces_thread_started_event() {
history_log_id: 0,
history_entry_count: 0,
initial_messages: None,
rollout_path,
rollout_path: Some(rollout_path),
}),
);
let out = ep.collect_thread_events(&ev);
+3 -3
View File
@@ -267,7 +267,7 @@ mod tests {
history_log_id: 1,
history_entry_count: 1000,
initial_messages: None,
rollout_path: rollout_file.path().to_path_buf(),
rollout_path: Some(rollout_file.path().to_path_buf()),
}),
};
@@ -307,7 +307,7 @@ mod tests {
history_log_id: 1,
history_entry_count: 1000,
initial_messages: None,
rollout_path: rollout_file.path().to_path_buf(),
rollout_path: Some(rollout_file.path().to_path_buf()),
};
let event = Event {
id: "1".to_string(),
@@ -371,7 +371,7 @@ mod tests {
history_log_id: 1,
history_entry_count: 1000,
initial_messages: None,
rollout_path: rollout_file.path().to_path_buf(),
rollout_path: Some(rollout_file.path().to_path_buf()),
};
let event = Event {
id: "1".to_string(),
+4 -2
View File
@@ -2143,7 +2143,9 @@ pub struct SessionConfiguredEvent {
#[serde(skip_serializing_if = "Option::is_none")]
pub initial_messages: Option<Vec<EventMsg>>,
pub rollout_path: PathBuf,
/// Path in which the rollout is stored. Can be `None` for ephemeral threads
#[serde(skip_serializing_if = "Option::is_none")]
pub rollout_path: Option<PathBuf>,
}
/// User's decision in response to an ExecApprovalRequest.
@@ -2495,7 +2497,7 @@ mod tests {
history_log_id: 0,
history_entry_count: 0,
initial_messages: None,
rollout_path: rollout_file.path().to_path_buf(),
rollout_path: Some(rollout_file.path().to_path_buf()),
}),
};
+5 -5
View File
@@ -2014,14 +2014,14 @@ impl App {
history_log_id: 0,
history_entry_count: 0,
initial_messages: None,
rollout_path: PathBuf::new(),
rollout_path: Some(PathBuf::new()),
});
session_configured.session_id = thread_id;
session_configured.forked_from_id = None;
session_configured.history_log_id = 0;
session_configured.history_entry_count = 0;
session_configured.initial_messages = None;
session_configured.rollout_path = PathBuf::new();
session_configured.rollout_path = Some(PathBuf::new());
session_configured
}
@@ -2576,7 +2576,7 @@ mod tests {
history_log_id: 0,
history_entry_count: 0,
initial_messages: None,
rollout_path: PathBuf::new(),
rollout_path: Some(PathBuf::new()),
};
Arc::new(new_session_info(
app.chat_widget.config_ref(),
@@ -2628,7 +2628,7 @@ mod tests {
history_log_id: 0,
history_entry_count: 0,
initial_messages: None,
rollout_path: PathBuf::new(),
rollout_path: Some(PathBuf::new()),
}),
});
@@ -2673,7 +2673,7 @@ mod tests {
history_log_id: 0,
history_entry_count: 0,
initial_messages: None,
rollout_path: PathBuf::new(),
rollout_path: Some(PathBuf::new()),
};
app.chat_widget.handle_codex_event(Event {
+1 -1
View File
@@ -727,7 +727,7 @@ impl ChatWidget {
self.set_skills(None);
self.thread_id = Some(event.session_id);
self.forked_from = event.forked_from_id;
self.current_rollout_path = Some(event.rollout_path.clone());
self.current_rollout_path = event.rollout_path.clone();
let initial_messages = event.initial_messages.clone();
let model_for_header = event.model.clone();
self.session_header.set_model(&model_for_header);
+3 -3
View File
@@ -160,7 +160,7 @@ async fn resumed_initial_messages_render_history() {
message: "assistant reply".to_string(),
}),
]),
rollout_path: rollout_file.path().to_path_buf(),
rollout_path: Some(rollout_file.path().to_path_buf()),
};
chat.handle_codex_event(Event {
@@ -221,7 +221,7 @@ async fn replayed_user_message_preserves_text_elements_and_local_images() {
text_elements: text_elements.clone(),
local_images: local_images.clone(),
})]),
rollout_path: rollout_file.path().to_path_buf(),
rollout_path: Some(rollout_file.path().to_path_buf()),
};
chat.handle_codex_event(Event {
@@ -268,7 +268,7 @@ async fn submission_preserves_text_elements_and_local_images() {
history_log_id: 0,
history_entry_count: 0,
initial_messages: None,
rollout_path: rollout_file.path().to_path_buf(),
rollout_path: Some(rollout_file.path().to_path_buf()),
};
chat.handle_codex_event(Event {
id: "initial".into(),