mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08:00
Conversation naming (#8991)
Session renaming: - `/rename my_session` - `/rename` without arg and passing an argument in `customViewPrompt` - AppExitInfo shows resume hint using the session name if set instead of uuid, defaults to uuid if not set - Names are stored in `CODEX_HOME/sessions.jsonl` Session resuming: - codex resume <name> lookup for `CODEX_HOME/sessions.jsonl` first entry matching the name and resumes the session --------- Co-authored-by: jif-oai <jif@openai.com>
This commit is contained in:
committed by
GitHub
Unverified
parent
25ad414680
commit
1ef5455eb6
@@ -132,6 +132,10 @@ client_request_definitions! {
|
||||
params: v2::ThreadArchiveParams,
|
||||
response: v2::ThreadArchiveResponse,
|
||||
},
|
||||
ThreadSetName => "thread/name/set" {
|
||||
params: v2::ThreadSetNameParams,
|
||||
response: v2::ThreadSetNameResponse,
|
||||
},
|
||||
ThreadUnarchive => "thread/unarchive" {
|
||||
params: v2::ThreadUnarchiveParams,
|
||||
response: v2::ThreadUnarchiveResponse,
|
||||
@@ -598,6 +602,7 @@ server_notification_definitions! {
|
||||
/// NEW NOTIFICATIONS
|
||||
Error => "error" (v2::ErrorNotification),
|
||||
ThreadStarted => "thread/started" (v2::ThreadStartedNotification),
|
||||
ThreadNameUpdated => "thread/name/updated" (v2::ThreadNameUpdatedNotification),
|
||||
ThreadTokenUsageUpdated => "thread/tokenUsage/updated" (v2::ThreadTokenUsageUpdatedNotification),
|
||||
TurnStarted => "turn/started" (v2::TurnStartedNotification),
|
||||
TurnCompleted => "turn/completed" (v2::TurnCompletedNotification),
|
||||
|
||||
@@ -1295,6 +1295,14 @@ pub struct ThreadArchiveParams {
|
||||
#[ts(export_to = "v2/")]
|
||||
pub struct ThreadArchiveResponse {}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export_to = "v2/")]
|
||||
pub struct ThreadSetNameParams {
|
||||
pub thread_id: String,
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export_to = "v2/")]
|
||||
@@ -1302,6 +1310,11 @@ pub struct ThreadUnarchiveParams {
|
||||
pub thread_id: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export_to = "v2/")]
|
||||
pub struct ThreadSetNameResponse {}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export_to = "v2/")]
|
||||
@@ -2285,6 +2298,16 @@ pub struct ThreadStartedNotification {
|
||||
pub thread: Thread,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export_to = "v2/")]
|
||||
pub struct ThreadNameUpdatedNotification {
|
||||
pub thread_id: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[ts(optional)]
|
||||
pub thread_name: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export_to = "v2/")]
|
||||
|
||||
@@ -82,6 +82,7 @@ Example (from OpenAI's official VSCode extension):
|
||||
- `thread/loaded/list` — list the thread ids currently loaded in memory.
|
||||
- `thread/read` — read a stored thread by id without resuming it; optionally include turns via `includeTurns`.
|
||||
- `thread/archive` — move a thread’s rollout file into the archived directory; returns `{}` on success.
|
||||
- `thread/name/set` — set or update a thread’s user-facing name; returns `{}` on success. Thread names are not required to be unique; name lookups resolve to the most recently updated thread.
|
||||
- `thread/unarchive` — move an archived rollout file back into the sessions directory; returns the restored `thread` on success.
|
||||
- `thread/rollback` — drop the last N turns from the agent’s in-memory context and persist a rollback marker in the rollout so future resumes see the pruned history; returns the updated `thread` (with `turns` populated) on success.
|
||||
- `turn/start` — add user input to a thread and begin Codex generation; responds with the initial `turn` object and streams `turn/started`, `item/*`, and `turn/completed` notifications.
|
||||
|
||||
@@ -52,6 +52,7 @@ use codex_app_server_protocol::ServerNotification;
|
||||
use codex_app_server_protocol::ServerRequestPayload;
|
||||
use codex_app_server_protocol::TerminalInteractionNotification;
|
||||
use codex_app_server_protocol::ThreadItem;
|
||||
use codex_app_server_protocol::ThreadNameUpdatedNotification;
|
||||
use codex_app_server_protocol::ThreadRollbackResponse;
|
||||
use codex_app_server_protocol::ThreadTokenUsage;
|
||||
use codex_app_server_protocol::ThreadTokenUsageUpdatedNotification;
|
||||
@@ -1097,6 +1098,17 @@ pub(crate) async fn apply_bespoke_event_handling(
|
||||
outgoing.send_response(request_id, response).await;
|
||||
}
|
||||
}
|
||||
EventMsg::ThreadNameUpdated(thread_name_event) => {
|
||||
if let ApiVersion::V2 = api_version {
|
||||
let notification = ThreadNameUpdatedNotification {
|
||||
thread_id: thread_name_event.thread_id.to_string(),
|
||||
thread_name: thread_name_event.thread_name,
|
||||
};
|
||||
outgoing
|
||||
.send_server_notification(ServerNotification::ThreadNameUpdated(notification))
|
||||
.await;
|
||||
}
|
||||
}
|
||||
EventMsg::TurnDiff(turn_diff_event) => {
|
||||
handle_turn_diff(
|
||||
conversation_id,
|
||||
|
||||
@@ -110,6 +110,8 @@ use codex_app_server_protocol::ThreadReadResponse;
|
||||
use codex_app_server_protocol::ThreadResumeParams;
|
||||
use codex_app_server_protocol::ThreadResumeResponse;
|
||||
use codex_app_server_protocol::ThreadRollbackParams;
|
||||
use codex_app_server_protocol::ThreadSetNameParams;
|
||||
use codex_app_server_protocol::ThreadSetNameResponse;
|
||||
use codex_app_server_protocol::ThreadSortKey;
|
||||
use codex_app_server_protocol::ThreadSourceKind;
|
||||
use codex_app_server_protocol::ThreadStartParams;
|
||||
@@ -416,6 +418,9 @@ impl CodexMessageProcessor {
|
||||
ClientRequest::ThreadArchive { request_id, params } => {
|
||||
self.thread_archive(request_id, params).await;
|
||||
}
|
||||
ClientRequest::ThreadSetName { request_id, params } => {
|
||||
self.thread_set_name(request_id, params).await;
|
||||
}
|
||||
ClientRequest::ThreadUnarchive { request_id, params } => {
|
||||
self.thread_unarchive(request_id, params).await;
|
||||
}
|
||||
@@ -1780,6 +1785,36 @@ impl CodexMessageProcessor {
|
||||
}
|
||||
}
|
||||
|
||||
async fn thread_set_name(&self, request_id: RequestId, params: ThreadSetNameParams) {
|
||||
let ThreadSetNameParams { thread_id, name } = params;
|
||||
let Some(name) = codex_core::util::normalize_thread_name(&name) else {
|
||||
self.send_invalid_request_error(
|
||||
request_id,
|
||||
"thread name must not be empty".to_string(),
|
||||
)
|
||||
.await;
|
||||
return;
|
||||
};
|
||||
|
||||
let (_, thread) = match self.load_thread(&thread_id).await {
|
||||
Ok(v) => v,
|
||||
Err(error) => {
|
||||
self.outgoing.send_error(request_id, error).await;
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
if let Err(err) = thread.submit(Op::SetThreadName { name }).await {
|
||||
self.send_internal_error(request_id, format!("failed to set thread name: {err}"))
|
||||
.await;
|
||||
return;
|
||||
}
|
||||
|
||||
self.outgoing
|
||||
.send_response(request_id, ThreadSetNameResponse {})
|
||||
.await;
|
||||
}
|
||||
|
||||
async fn thread_unarchive(&mut self, request_id: RequestId, params: ThreadUnarchiveParams) {
|
||||
// TODO(jif) mostly rewrite this using sqlite after phase 1
|
||||
let thread_id = match ThreadId::from_string(¶ms.thread_id) {
|
||||
|
||||
@@ -144,7 +144,7 @@ struct CompletionCommand {
|
||||
|
||||
#[derive(Debug, Parser)]
|
||||
struct ResumeCommand {
|
||||
/// Conversation/session id (UUID). When provided, resumes this session.
|
||||
/// Conversation/session id (UUID) or thread name. UUIDs take precedence if it parses.
|
||||
/// If omitted, use --last to pick the most recent recorded session.
|
||||
#[arg(value_name = "SESSION_ID")]
|
||||
session_id: Option<String>,
|
||||
@@ -323,6 +323,7 @@ fn format_exit_messages(exit_info: AppExitInfo, color_enabled: bool) -> Vec<Stri
|
||||
let AppExitInfo {
|
||||
token_usage,
|
||||
thread_id: conversation_id,
|
||||
thread_name,
|
||||
..
|
||||
} = exit_info;
|
||||
|
||||
@@ -335,8 +336,9 @@ fn format_exit_messages(exit_info: AppExitInfo, color_enabled: bool) -> Vec<Stri
|
||||
codex_core::protocol::FinalOutput::from(token_usage)
|
||||
)];
|
||||
|
||||
if let Some(session_id) = conversation_id {
|
||||
let resume_cmd = format!("codex resume {session_id}");
|
||||
if let Some(resume_cmd) =
|
||||
codex_core::util::resume_command(thread_name.as_deref(), conversation_id)
|
||||
{
|
||||
let command = if color_enabled {
|
||||
resume_cmd.cyan().to_string()
|
||||
} else {
|
||||
@@ -1028,7 +1030,7 @@ mod tests {
|
||||
app_server
|
||||
}
|
||||
|
||||
fn sample_exit_info(conversation: Option<&str>) -> AppExitInfo {
|
||||
fn sample_exit_info(conversation_id: Option<&str>, thread_name: Option<&str>) -> AppExitInfo {
|
||||
let token_usage = TokenUsage {
|
||||
output_tokens: 2,
|
||||
total_tokens: 2,
|
||||
@@ -1036,7 +1038,10 @@ mod tests {
|
||||
};
|
||||
AppExitInfo {
|
||||
token_usage,
|
||||
thread_id: conversation.map(ThreadId::from_string).map(Result::unwrap),
|
||||
thread_id: conversation_id
|
||||
.map(ThreadId::from_string)
|
||||
.map(Result::unwrap),
|
||||
thread_name: thread_name.map(str::to_string),
|
||||
update_action: None,
|
||||
exit_reason: ExitReason::UserRequested,
|
||||
}
|
||||
@@ -1047,6 +1052,7 @@ mod tests {
|
||||
let exit_info = AppExitInfo {
|
||||
token_usage: TokenUsage::default(),
|
||||
thread_id: None,
|
||||
thread_name: None,
|
||||
update_action: None,
|
||||
exit_reason: ExitReason::UserRequested,
|
||||
};
|
||||
@@ -1056,7 +1062,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn format_exit_messages_includes_resume_hint_without_color() {
|
||||
let exit_info = sample_exit_info(Some("123e4567-e89b-12d3-a456-426614174000"));
|
||||
let exit_info = sample_exit_info(Some("123e4567-e89b-12d3-a456-426614174000"), None);
|
||||
let lines = format_exit_messages(exit_info, false);
|
||||
assert_eq!(
|
||||
lines,
|
||||
@@ -1070,12 +1076,28 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn format_exit_messages_applies_color_when_enabled() {
|
||||
let exit_info = sample_exit_info(Some("123e4567-e89b-12d3-a456-426614174000"));
|
||||
let exit_info = sample_exit_info(Some("123e4567-e89b-12d3-a456-426614174000"), None);
|
||||
let lines = format_exit_messages(exit_info, true);
|
||||
assert_eq!(lines.len(), 2);
|
||||
assert!(lines[1].contains("\u{1b}[36m"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_exit_messages_prefers_thread_name() {
|
||||
let exit_info = sample_exit_info(
|
||||
Some("123e4567-e89b-12d3-a456-426614174000"),
|
||||
Some("my-thread"),
|
||||
);
|
||||
let lines = format_exit_messages(exit_info, false);
|
||||
assert_eq!(
|
||||
lines,
|
||||
vec![
|
||||
"Token usage: total=2 input=0 output=2".to_string(),
|
||||
"To continue this session, run codex resume my-thread".to_string(),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resume_model_flag_applies_when_no_root_flags() {
|
||||
let interactive =
|
||||
|
||||
+108
-3
@@ -26,6 +26,7 @@ use crate::features::maybe_push_unstable_features_warning;
|
||||
use crate::models_manager::manager::ModelsManager;
|
||||
use crate::parse_command::parse_command;
|
||||
use crate::parse_turn_item;
|
||||
use crate::rollout::session_index;
|
||||
use crate::stream_events_utils::HandleOutputCtx;
|
||||
use crate::stream_events_utils::handle_non_tool_response_item;
|
||||
use crate::stream_events_utils::handle_output_item_done;
|
||||
@@ -354,6 +355,8 @@ impl Codex {
|
||||
sandbox_policy: config.sandbox_policy.clone(),
|
||||
windows_sandbox_level: WindowsSandboxLevel::from_config(&config),
|
||||
cwd: config.cwd.clone(),
|
||||
codex_home: config.codex_home.clone(),
|
||||
thread_name: None,
|
||||
original_config_do_not_use: Arc::clone(&config),
|
||||
session_source,
|
||||
dynamic_tools,
|
||||
@@ -544,6 +547,10 @@ pub(crate) struct SessionConfiguration {
|
||||
/// `ConfigureSession` operation so that the business-logic layer can
|
||||
/// operate deterministically.
|
||||
cwd: PathBuf,
|
||||
/// Directory containing all Codex state for this session.
|
||||
codex_home: PathBuf,
|
||||
/// Optional user-facing name for the thread, updated during the session.
|
||||
thread_name: Option<String>,
|
||||
|
||||
// TODO(pakrym): Remove config from here
|
||||
original_config_do_not_use: Arc<Config>,
|
||||
@@ -553,6 +560,10 @@ pub(crate) struct SessionConfiguration {
|
||||
}
|
||||
|
||||
impl SessionConfiguration {
|
||||
pub(crate) fn codex_home(&self) -> &PathBuf {
|
||||
&self.codex_home
|
||||
}
|
||||
|
||||
fn thread_config_snapshot(&self) -> ThreadConfigSnapshot {
|
||||
ThreadConfigSnapshot {
|
||||
model: self.collaboration_mode.model().to_string(),
|
||||
@@ -623,6 +634,11 @@ impl Session {
|
||||
per_turn_config
|
||||
}
|
||||
|
||||
pub(crate) async fn codex_home(&self) -> PathBuf {
|
||||
let state = self.state.lock().await;
|
||||
state.session_configuration.codex_home().clone()
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn make_turn_context(
|
||||
auth_manager: Option<Arc<AuthManager>>,
|
||||
@@ -683,7 +699,7 @@ impl Session {
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
async fn new(
|
||||
session_configuration: SessionConfiguration,
|
||||
mut session_configuration: SessionConfiguration,
|
||||
config: Arc<Config>,
|
||||
auth_manager: Arc<AuthManager>,
|
||||
models_manager: Arc<ModelsManager>,
|
||||
@@ -863,6 +879,16 @@ impl Session {
|
||||
otel_manager.clone(),
|
||||
);
|
||||
}
|
||||
let thread_name =
|
||||
match session_index::find_thread_name_by_id(&config.codex_home, &conversation_id).await
|
||||
{
|
||||
Ok(name) => name,
|
||||
Err(err) => {
|
||||
warn!("Failed to read session index for thread name: {err}");
|
||||
None
|
||||
}
|
||||
};
|
||||
session_configuration.thread_name = thread_name.clone();
|
||||
let state = SessionState::new(session_configuration.clone());
|
||||
|
||||
let services = SessionServices {
|
||||
@@ -904,6 +930,7 @@ impl Session {
|
||||
msg: EventMsg::SessionConfigured(SessionConfiguredEvent {
|
||||
session_id: conversation_id,
|
||||
forked_from_id,
|
||||
thread_name: session_configuration.thread_name.clone(),
|
||||
model: session_configuration.collaboration_mode.model().to_string(),
|
||||
model_provider_id: config.model_provider_id.clone(),
|
||||
approval_policy: session_configuration.approval_policy.value(),
|
||||
@@ -1451,8 +1478,7 @@ impl Session {
|
||||
.lock()
|
||||
.await
|
||||
.session_configuration
|
||||
.original_config_do_not_use
|
||||
.codex_home
|
||||
.codex_home()
|
||||
.clone();
|
||||
|
||||
if !features.enabled(Feature::ExecPolicy) {
|
||||
@@ -2440,6 +2466,9 @@ async fn submission_loop(sess: Arc<Session>, config: Arc<Config>, rx_sub: Receiv
|
||||
Op::ThreadRollback { num_turns } => {
|
||||
handlers::thread_rollback(&sess, sub.id.clone(), num_turns).await;
|
||||
}
|
||||
Op::SetThreadName { name } => {
|
||||
handlers::set_thread_name(&sess, sub.id.clone(), name).await;
|
||||
}
|
||||
Op::RunUserShellCommand { command } => {
|
||||
handlers::run_user_shell_command(
|
||||
&sess,
|
||||
@@ -2483,6 +2512,7 @@ mod handlers {
|
||||
use crate::mcp::collect_mcp_snapshot_from_manager;
|
||||
use crate::mcp::effective_mcp_servers;
|
||||
use crate::review_prompts::resolve_review_request;
|
||||
use crate::rollout::session_index;
|
||||
use crate::tasks::CompactTask;
|
||||
use crate::tasks::RegularTask;
|
||||
use crate::tasks::UndoTask;
|
||||
@@ -2499,6 +2529,7 @@ mod handlers {
|
||||
use codex_protocol::protocol::ReviewDecision;
|
||||
use codex_protocol::protocol::ReviewRequest;
|
||||
use codex_protocol::protocol::SkillsListEntry;
|
||||
use codex_protocol::protocol::ThreadNameUpdatedEvent;
|
||||
use codex_protocol::protocol::ThreadRolledBackEvent;
|
||||
use codex_protocol::protocol::TurnAbortReason;
|
||||
use codex_protocol::protocol::WarningEvent;
|
||||
@@ -2952,6 +2983,72 @@ mod handlers {
|
||||
.await;
|
||||
}
|
||||
|
||||
/// Persists the thread name in the session index, updates in-memory state, and emits
|
||||
/// a `ThreadNameUpdated` event on success.
|
||||
///
|
||||
/// This appends the name to `CODEX_HOME/sessions_index.jsonl` via `session_index::append_thread_name` for the
|
||||
/// current `thread_id`, then updates `SessionConfiguration::thread_name`.
|
||||
///
|
||||
/// Returns an error event if the name is empty or session persistence is disabled.
|
||||
pub async fn set_thread_name(sess: &Arc<Session>, sub_id: String, name: String) {
|
||||
let Some(name) = crate::util::normalize_thread_name(&name) else {
|
||||
let event = Event {
|
||||
id: sub_id,
|
||||
msg: EventMsg::Error(ErrorEvent {
|
||||
message: "Thread name cannot be empty.".to_string(),
|
||||
codex_error_info: Some(CodexErrorInfo::BadRequest),
|
||||
}),
|
||||
};
|
||||
sess.send_event_raw(event).await;
|
||||
return;
|
||||
};
|
||||
|
||||
let persistence_enabled = {
|
||||
let rollout = sess.services.rollout.lock().await;
|
||||
rollout.is_some()
|
||||
};
|
||||
if !persistence_enabled {
|
||||
let event = Event {
|
||||
id: sub_id,
|
||||
msg: EventMsg::Error(ErrorEvent {
|
||||
message: "Session persistence is disabled; cannot rename thread.".to_string(),
|
||||
codex_error_info: Some(CodexErrorInfo::Other),
|
||||
}),
|
||||
};
|
||||
sess.send_event_raw(event).await;
|
||||
return;
|
||||
};
|
||||
|
||||
let codex_home = sess.codex_home().await;
|
||||
if let Err(e) =
|
||||
session_index::append_thread_name(&codex_home, sess.conversation_id, &name).await
|
||||
{
|
||||
let event = Event {
|
||||
id: sub_id,
|
||||
msg: EventMsg::Error(ErrorEvent {
|
||||
message: format!("Failed to set thread name: {e}"),
|
||||
codex_error_info: Some(CodexErrorInfo::Other),
|
||||
}),
|
||||
};
|
||||
sess.send_event_raw(event).await;
|
||||
return;
|
||||
}
|
||||
|
||||
{
|
||||
let mut state = sess.state.lock().await;
|
||||
state.session_configuration.thread_name = Some(name.clone());
|
||||
}
|
||||
|
||||
sess.send_event_raw(Event {
|
||||
id: sub_id,
|
||||
msg: EventMsg::ThreadNameUpdated(ThreadNameUpdatedEvent {
|
||||
thread_id: sess.conversation_id,
|
||||
thread_name: Some(name),
|
||||
}),
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
pub async fn shutdown(sess: &Arc<Session>, sub_id: String) -> bool {
|
||||
sess.abort_all_tasks(TurnAbortReason::Interrupted).await;
|
||||
sess.services
|
||||
@@ -4440,6 +4537,8 @@ mod tests {
|
||||
sandbox_policy: config.sandbox_policy.clone(),
|
||||
windows_sandbox_level: WindowsSandboxLevel::from_config(&config),
|
||||
cwd: config.cwd.clone(),
|
||||
codex_home: config.codex_home.clone(),
|
||||
thread_name: None,
|
||||
original_config_do_not_use: Arc::clone(&config),
|
||||
session_source: SessionSource::Exec,
|
||||
dynamic_tools: Vec::new(),
|
||||
@@ -4521,6 +4620,8 @@ mod tests {
|
||||
sandbox_policy: config.sandbox_policy.clone(),
|
||||
windows_sandbox_level: WindowsSandboxLevel::from_config(&config),
|
||||
cwd: config.cwd.clone(),
|
||||
codex_home: config.codex_home.clone(),
|
||||
thread_name: None,
|
||||
original_config_do_not_use: Arc::clone(&config),
|
||||
session_source: SessionSource::Exec,
|
||||
dynamic_tools: Vec::new(),
|
||||
@@ -4786,6 +4887,8 @@ mod tests {
|
||||
sandbox_policy: config.sandbox_policy.clone(),
|
||||
windows_sandbox_level: WindowsSandboxLevel::from_config(&config),
|
||||
cwd: config.cwd.clone(),
|
||||
codex_home: config.codex_home.clone(),
|
||||
thread_name: None,
|
||||
original_config_do_not_use: Arc::clone(&config),
|
||||
session_source: SessionSource::Exec,
|
||||
dynamic_tools: Vec::new(),
|
||||
@@ -4900,6 +5003,8 @@ mod tests {
|
||||
sandbox_policy: config.sandbox_policy.clone(),
|
||||
windows_sandbox_level: WindowsSandboxLevel::from_config(&config),
|
||||
cwd: config.cwd.clone(),
|
||||
codex_home: config.codex_home.clone(),
|
||||
thread_name: None,
|
||||
original_config_do_not_use: Arc::clone(&config),
|
||||
session_source: SessionSource::Exec,
|
||||
dynamic_tools: Vec::new(),
|
||||
|
||||
@@ -208,6 +208,10 @@ async fn forward_events(
|
||||
id: _,
|
||||
msg: EventMsg::SessionConfigured(_),
|
||||
} => {}
|
||||
Event {
|
||||
id: _,
|
||||
msg: EventMsg::ThreadNameUpdated(_),
|
||||
} => {}
|
||||
Event {
|
||||
id,
|
||||
msg: EventMsg::ExecApprovalRequest(event),
|
||||
|
||||
@@ -100,12 +100,14 @@ pub mod turn_diff_tracker;
|
||||
pub use rollout::ARCHIVED_SESSIONS_SUBDIR;
|
||||
pub use rollout::INTERACTIVE_SESSION_SOURCES;
|
||||
pub use rollout::RolloutRecorder;
|
||||
pub use rollout::RolloutRecorderParams;
|
||||
pub use rollout::SESSIONS_SUBDIR;
|
||||
pub use rollout::SessionMeta;
|
||||
pub use rollout::find_archived_thread_path_by_id_str;
|
||||
#[deprecated(note = "use find_thread_path_by_id_str")]
|
||||
pub use rollout::find_conversation_path_by_id_str;
|
||||
pub use rollout::find_thread_path_by_id_str;
|
||||
pub use rollout::find_thread_path_by_name_str;
|
||||
pub use rollout::list::Cursor;
|
||||
pub use rollout::list::ThreadItem;
|
||||
pub use rollout::list::ThreadSortKey;
|
||||
|
||||
@@ -12,6 +12,7 @@ pub mod list;
|
||||
pub(crate) mod metadata;
|
||||
pub(crate) mod policy;
|
||||
pub mod recorder;
|
||||
pub(crate) mod session_index;
|
||||
pub(crate) mod truncation;
|
||||
|
||||
pub use codex_protocol::protocol::SessionMeta;
|
||||
@@ -23,6 +24,7 @@ pub use list::find_thread_path_by_id_str as find_conversation_path_by_id_str;
|
||||
pub use list::rollout_date_parts;
|
||||
pub use recorder::RolloutRecorder;
|
||||
pub use recorder::RolloutRecorderParams;
|
||||
pub use session_index::find_thread_path_by_name_str;
|
||||
|
||||
#[cfg(test)]
|
||||
pub mod tests;
|
||||
|
||||
@@ -58,6 +58,7 @@ pub(crate) fn should_persist_event_msg(ev: &EventMsg) -> bool {
|
||||
| EventMsg::AgentReasoningSectionBreak(_)
|
||||
| EventMsg::RawResponseItem(_)
|
||||
| EventMsg::SessionConfigured(_)
|
||||
| EventMsg::ThreadNameUpdated(_)
|
||||
| EventMsg::McpToolCallBegin(_)
|
||||
| EventMsg::McpToolCallEnd(_)
|
||||
| EventMsg::WebSearchBegin(_)
|
||||
|
||||
@@ -0,0 +1,325 @@
|
||||
use std::fs::File;
|
||||
use std::io::Read;
|
||||
use std::io::Seek;
|
||||
use std::io::SeekFrom;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use codex_protocol::ThreadId;
|
||||
use serde::Deserialize;
|
||||
use serde::Serialize;
|
||||
use tokio::io::AsyncWriteExt;
|
||||
|
||||
const SESSION_INDEX_FILE: &str = "session_index.jsonl";
|
||||
const READ_CHUNK_SIZE: usize = 8192;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct SessionIndexEntry {
|
||||
pub id: ThreadId,
|
||||
pub thread_name: String,
|
||||
pub updated_at: String,
|
||||
}
|
||||
|
||||
/// Append a thread name update to the session index.
|
||||
/// The index is append-only; the most recent entry wins when resolving names or ids.
|
||||
pub async fn append_thread_name(
|
||||
codex_home: &Path,
|
||||
thread_id: ThreadId,
|
||||
name: &str,
|
||||
) -> std::io::Result<()> {
|
||||
use time::OffsetDateTime;
|
||||
use time::format_description::well_known::Rfc3339;
|
||||
|
||||
let updated_at = OffsetDateTime::now_utc()
|
||||
.format(&Rfc3339)
|
||||
.unwrap_or_else(|_| "unknown".to_string());
|
||||
let entry = SessionIndexEntry {
|
||||
id: thread_id,
|
||||
thread_name: name.to_string(),
|
||||
updated_at,
|
||||
};
|
||||
append_session_index_entry(codex_home, &entry).await
|
||||
}
|
||||
|
||||
/// Append a raw session index entry to `session_index.jsonl`.
|
||||
/// The file is append-only; consumers scan from the end to find the newest match.
|
||||
pub async fn append_session_index_entry(
|
||||
codex_home: &Path,
|
||||
entry: &SessionIndexEntry,
|
||||
) -> std::io::Result<()> {
|
||||
let path = session_index_path(codex_home);
|
||||
let mut file = tokio::fs::OpenOptions::new()
|
||||
.create(true)
|
||||
.append(true)
|
||||
.open(&path)
|
||||
.await?;
|
||||
let mut line = serde_json::to_string(entry).map_err(std::io::Error::other)?;
|
||||
line.push('\n');
|
||||
file.write_all(line.as_bytes()).await?;
|
||||
file.flush().await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Find the latest thread name for a thread id, if any.
|
||||
pub async fn find_thread_name_by_id(
|
||||
codex_home: &Path,
|
||||
thread_id: &ThreadId,
|
||||
) -> std::io::Result<Option<String>> {
|
||||
let path = session_index_path(codex_home);
|
||||
if !path.exists() {
|
||||
return Ok(None);
|
||||
}
|
||||
let id = *thread_id;
|
||||
let entry = tokio::task::spawn_blocking(move || scan_index_from_end_by_id(&path, &id))
|
||||
.await
|
||||
.map_err(std::io::Error::other)??;
|
||||
Ok(entry.map(|entry| entry.thread_name))
|
||||
}
|
||||
|
||||
/// Find the most recently updated thread id for a thread name, if any.
|
||||
pub async fn find_thread_id_by_name(
|
||||
codex_home: &Path,
|
||||
name: &str,
|
||||
) -> std::io::Result<Option<ThreadId>> {
|
||||
if name.trim().is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
let path = session_index_path(codex_home);
|
||||
if !path.exists() {
|
||||
return Ok(None);
|
||||
}
|
||||
let name = name.to_string();
|
||||
let entry = tokio::task::spawn_blocking(move || scan_index_from_end_by_name(&path, &name))
|
||||
.await
|
||||
.map_err(std::io::Error::other)??;
|
||||
Ok(entry.map(|entry| entry.id))
|
||||
}
|
||||
|
||||
/// Locate a recorded thread rollout file by thread name using newest-first ordering.
|
||||
/// Returns `Ok(Some(path))` if found, `Ok(None)` if not present.
|
||||
pub async fn find_thread_path_by_name_str(
|
||||
codex_home: &Path,
|
||||
name: &str,
|
||||
) -> std::io::Result<Option<PathBuf>> {
|
||||
let Some(thread_id) = find_thread_id_by_name(codex_home, name).await? else {
|
||||
return Ok(None);
|
||||
};
|
||||
super::list::find_thread_path_by_id_str(codex_home, &thread_id.to_string()).await
|
||||
}
|
||||
|
||||
fn session_index_path(codex_home: &Path) -> PathBuf {
|
||||
codex_home.join(SESSION_INDEX_FILE)
|
||||
}
|
||||
|
||||
fn scan_index_from_end_by_id(
|
||||
path: &Path,
|
||||
thread_id: &ThreadId,
|
||||
) -> std::io::Result<Option<SessionIndexEntry>> {
|
||||
scan_index_from_end(path, |entry| entry.id == *thread_id)
|
||||
}
|
||||
|
||||
fn scan_index_from_end_by_name(
|
||||
path: &Path,
|
||||
name: &str,
|
||||
) -> std::io::Result<Option<SessionIndexEntry>> {
|
||||
scan_index_from_end(path, |entry| entry.thread_name == name)
|
||||
}
|
||||
|
||||
fn scan_index_from_end<F>(
|
||||
path: &Path,
|
||||
mut predicate: F,
|
||||
) -> std::io::Result<Option<SessionIndexEntry>>
|
||||
where
|
||||
F: FnMut(&SessionIndexEntry) -> bool,
|
||||
{
|
||||
let mut file = File::open(path)?;
|
||||
let mut remaining = file.metadata()?.len();
|
||||
let mut line_rev: Vec<u8> = Vec::new();
|
||||
let mut buf = vec![0u8; READ_CHUNK_SIZE];
|
||||
|
||||
while remaining > 0 {
|
||||
let read_size = usize::try_from(remaining.min(READ_CHUNK_SIZE as u64))
|
||||
.map_err(std::io::Error::other)?;
|
||||
remaining -= read_size as u64;
|
||||
file.seek(SeekFrom::Start(remaining))?;
|
||||
file.read_exact(&mut buf[..read_size])?;
|
||||
|
||||
for &byte in buf[..read_size].iter().rev() {
|
||||
if byte == b'\n' {
|
||||
if let Some(entry) = parse_line_from_rev(&mut line_rev, &mut predicate)? {
|
||||
return Ok(Some(entry));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
line_rev.push(byte);
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(entry) = parse_line_from_rev(&mut line_rev, &mut predicate)? {
|
||||
return Ok(Some(entry));
|
||||
}
|
||||
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
fn parse_line_from_rev<F>(
|
||||
line_rev: &mut Vec<u8>,
|
||||
predicate: &mut F,
|
||||
) -> std::io::Result<Option<SessionIndexEntry>>
|
||||
where
|
||||
F: FnMut(&SessionIndexEntry) -> bool,
|
||||
{
|
||||
if line_rev.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
line_rev.reverse();
|
||||
let line = std::mem::take(line_rev);
|
||||
let Ok(mut line) = String::from_utf8(line) else {
|
||||
return Ok(None);
|
||||
};
|
||||
if line.ends_with('\r') {
|
||||
line.pop();
|
||||
}
|
||||
let trimmed = line.trim();
|
||||
if trimmed.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
let Ok(entry) = serde_json::from_str::<SessionIndexEntry>(trimmed) else {
|
||||
return Ok(None);
|
||||
};
|
||||
if predicate(&entry) {
|
||||
return Ok(Some(entry));
|
||||
}
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use pretty_assertions::assert_eq;
|
||||
use tempfile::TempDir;
|
||||
fn write_index(path: &Path, lines: &[SessionIndexEntry]) -> std::io::Result<()> {
|
||||
let mut out = String::new();
|
||||
for entry in lines {
|
||||
out.push_str(&serde_json::to_string(entry).unwrap());
|
||||
out.push('\n');
|
||||
}
|
||||
std::fs::write(path, out)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_thread_id_by_name_prefers_latest_entry() -> std::io::Result<()> {
|
||||
let temp = TempDir::new()?;
|
||||
let path = session_index_path(temp.path());
|
||||
let id1 = ThreadId::new();
|
||||
let id2 = ThreadId::new();
|
||||
let lines = vec![
|
||||
SessionIndexEntry {
|
||||
id: id1,
|
||||
thread_name: "same".to_string(),
|
||||
updated_at: "2024-01-01T00:00:00Z".to_string(),
|
||||
},
|
||||
SessionIndexEntry {
|
||||
id: id2,
|
||||
thread_name: "same".to_string(),
|
||||
updated_at: "2024-01-02T00:00:00Z".to_string(),
|
||||
},
|
||||
];
|
||||
write_index(&path, &lines)?;
|
||||
|
||||
let found = scan_index_from_end_by_name(&path, "same")?;
|
||||
assert_eq!(found.map(|entry| entry.id), Some(id2));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_thread_name_by_id_prefers_latest_entry() -> std::io::Result<()> {
|
||||
let temp = TempDir::new()?;
|
||||
let path = session_index_path(temp.path());
|
||||
let id = ThreadId::new();
|
||||
let lines = vec![
|
||||
SessionIndexEntry {
|
||||
id,
|
||||
thread_name: "first".to_string(),
|
||||
updated_at: "2024-01-01T00:00:00Z".to_string(),
|
||||
},
|
||||
SessionIndexEntry {
|
||||
id,
|
||||
thread_name: "second".to_string(),
|
||||
updated_at: "2024-01-02T00:00:00Z".to_string(),
|
||||
},
|
||||
];
|
||||
write_index(&path, &lines)?;
|
||||
|
||||
let found = scan_index_from_end_by_id(&path, &id)?;
|
||||
assert_eq!(
|
||||
found.map(|entry| entry.thread_name),
|
||||
Some("second".to_string())
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scan_index_returns_none_when_entry_missing() -> std::io::Result<()> {
|
||||
let temp = TempDir::new()?;
|
||||
let path = session_index_path(temp.path());
|
||||
let id = ThreadId::new();
|
||||
let lines = vec![SessionIndexEntry {
|
||||
id,
|
||||
thread_name: "present".to_string(),
|
||||
updated_at: "2024-01-01T00:00:00Z".to_string(),
|
||||
}];
|
||||
write_index(&path, &lines)?;
|
||||
|
||||
let missing_name = scan_index_from_end_by_name(&path, "missing")?;
|
||||
assert_eq!(missing_name, None);
|
||||
|
||||
let missing_id = scan_index_from_end_by_id(&path, &ThreadId::new())?;
|
||||
assert_eq!(missing_id, None);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scan_index_finds_latest_match_among_mixed_entries() -> std::io::Result<()> {
|
||||
let temp = TempDir::new()?;
|
||||
let path = session_index_path(temp.path());
|
||||
let id_target = ThreadId::new();
|
||||
let id_other = ThreadId::new();
|
||||
let expected = SessionIndexEntry {
|
||||
id: id_target,
|
||||
thread_name: "target".to_string(),
|
||||
updated_at: "2024-01-03T00:00:00Z".to_string(),
|
||||
};
|
||||
let expected_other = SessionIndexEntry {
|
||||
id: id_other,
|
||||
thread_name: "target".to_string(),
|
||||
updated_at: "2024-01-02T00:00:00Z".to_string(),
|
||||
};
|
||||
// Resolution is based on append order (scan from end), not updated_at.
|
||||
let lines = vec![
|
||||
SessionIndexEntry {
|
||||
id: id_target,
|
||||
thread_name: "target".to_string(),
|
||||
updated_at: "2024-01-01T00:00:00Z".to_string(),
|
||||
},
|
||||
expected_other.clone(),
|
||||
expected.clone(),
|
||||
SessionIndexEntry {
|
||||
id: ThreadId::new(),
|
||||
thread_name: "another".to_string(),
|
||||
updated_at: "2024-01-04T00:00:00Z".to_string(),
|
||||
},
|
||||
];
|
||||
write_index(&path, &lines)?;
|
||||
|
||||
let found_by_name = scan_index_from_end_by_name(&path, "target")?;
|
||||
assert_eq!(found_by_name, Some(expected.clone()));
|
||||
|
||||
let found_by_id = scan_index_from_end_by_id(&path, &id_target)?;
|
||||
assert_eq!(found_by_id, Some(expected));
|
||||
|
||||
let found_other_by_id = scan_index_from_end_by_id(&path, &id_other)?;
|
||||
assert_eq!(found_other_by_id, Some(expected_other));
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -2,10 +2,13 @@ use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
use std::time::Duration;
|
||||
|
||||
use codex_protocol::ThreadId;
|
||||
use rand::Rng;
|
||||
use tracing::debug;
|
||||
use tracing::error;
|
||||
|
||||
use crate::parse_command::shlex_join;
|
||||
|
||||
const INITIAL_DELAY_MS: u64 = 200;
|
||||
const BACKOFF_FACTOR: f64 = 2.0;
|
||||
|
||||
@@ -72,6 +75,32 @@ pub fn resolve_path(base: &Path, path: &PathBuf) -> PathBuf {
|
||||
}
|
||||
}
|
||||
|
||||
/// Trim a thread name and return `None` if it is empty after trimming.
|
||||
pub fn normalize_thread_name(name: &str) -> Option<String> {
|
||||
let trimmed = name.trim();
|
||||
if trimmed.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(trimmed.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
pub fn resume_command(thread_name: Option<&str>, thread_id: Option<ThreadId>) -> Option<String> {
|
||||
let resume_target = thread_name
|
||||
.filter(|name| !name.is_empty())
|
||||
.map(str::to_string)
|
||||
.or_else(|| thread_id.map(|thread_id| thread_id.to_string()));
|
||||
resume_target.map(|target| {
|
||||
let needs_double_dash = target.starts_with('-');
|
||||
let escaped = shlex_join(&[target]);
|
||||
if needs_double_dash {
|
||||
format!("codex resume -- {escaped}")
|
||||
} else {
|
||||
format!("codex resume {escaped}")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -107,4 +136,51 @@ mod tests {
|
||||
|
||||
feedback_tags!(model = "gpt-5", cached = true, debug_only = OnlyDebug);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_thread_name_trims_and_rejects_empty() {
|
||||
assert_eq!(normalize_thread_name(" "), None);
|
||||
assert_eq!(
|
||||
normalize_thread_name(" my thread "),
|
||||
Some("my thread".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resume_command_prefers_name_over_id() {
|
||||
let thread_id = ThreadId::from_string("123e4567-e89b-12d3-a456-426614174000").unwrap();
|
||||
let command = resume_command(Some("my-thread"), Some(thread_id));
|
||||
assert_eq!(command, Some("codex resume my-thread".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resume_command_with_only_id() {
|
||||
let thread_id = ThreadId::from_string("123e4567-e89b-12d3-a456-426614174000").unwrap();
|
||||
let command = resume_command(None, Some(thread_id));
|
||||
assert_eq!(
|
||||
command,
|
||||
Some("codex resume 123e4567-e89b-12d3-a456-426614174000".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resume_command_with_no_name_or_id() {
|
||||
let command = resume_command(None, None);
|
||||
assert_eq!(command, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resume_command_quotes_thread_name_when_needed() {
|
||||
let command = resume_command(Some("-starts-with-dash"), None);
|
||||
assert_eq!(
|
||||
command,
|
||||
Some("codex resume -- -starts-with-dash".to_string())
|
||||
);
|
||||
|
||||
let command = resume_command(Some("two words"), None);
|
||||
assert_eq!(command, Some("codex resume 'two words'".to_string()));
|
||||
|
||||
let command = resume_command(Some("quote'case"), None);
|
||||
assert_eq!(command, Some("codex resume \"quote'case\"".to_string()));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,8 +3,16 @@ use std::io::Write;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use codex_core::RolloutRecorder;
|
||||
use codex_core::RolloutRecorderParams;
|
||||
use codex_core::config::ConfigBuilder;
|
||||
use codex_core::find_archived_thread_path_by_id_str;
|
||||
use codex_core::find_thread_path_by_id_str;
|
||||
use codex_core::find_thread_path_by_name_str;
|
||||
use codex_core::protocol::SessionSource;
|
||||
use codex_protocol::ThreadId;
|
||||
use codex_protocol::models::BaseInstructions;
|
||||
use pretty_assertions::assert_eq;
|
||||
use tempfile::TempDir;
|
||||
use uuid::Uuid;
|
||||
|
||||
@@ -87,6 +95,54 @@ async fn find_ignores_granular_gitignore_rules() {
|
||||
assert_eq!(found, Some(expected));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn find_locates_rollout_file_written_by_recorder() -> std::io::Result<()> {
|
||||
// Ensures the name-based finder locates a rollout produced by the real recorder.
|
||||
let home = TempDir::new().unwrap();
|
||||
let config = ConfigBuilder::default()
|
||||
.codex_home(home.path().to_path_buf())
|
||||
.build()
|
||||
.await?;
|
||||
let thread_id = ThreadId::new();
|
||||
let thread_name = "named thread";
|
||||
let recorder = RolloutRecorder::new(
|
||||
&config,
|
||||
RolloutRecorderParams::new(
|
||||
thread_id,
|
||||
None,
|
||||
SessionSource::Exec,
|
||||
BaseInstructions::default(),
|
||||
Vec::new(),
|
||||
),
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
recorder.flush().await?;
|
||||
|
||||
let index_path = home.path().join("session_index.jsonl");
|
||||
std::fs::write(
|
||||
&index_path,
|
||||
format!(
|
||||
"{}\n",
|
||||
serde_json::json!({
|
||||
"id": thread_id,
|
||||
"thread_name": thread_name,
|
||||
"updated_at": "2024-01-01T00:00:00Z"
|
||||
})
|
||||
),
|
||||
)?;
|
||||
|
||||
let found = find_thread_path_by_name_str(home.path(), thread_name).await?;
|
||||
|
||||
let path = found.expect("expected rollout path to be found");
|
||||
assert!(path.exists());
|
||||
let contents = std::fs::read_to_string(&path)?;
|
||||
assert!(contents.contains(&thread_id.to_string()));
|
||||
recorder.shutdown().await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn find_archived_locates_rollout_file_by_id() {
|
||||
let home = TempDir::new().unwrap();
|
||||
|
||||
@@ -47,6 +47,7 @@ ts-rs = { workspace = true, features = [
|
||||
"serde-json-impl",
|
||||
"no-serde-warnings",
|
||||
] }
|
||||
uuid = { workspace = true }
|
||||
|
||||
|
||||
[dev-dependencies]
|
||||
|
||||
@@ -114,7 +114,7 @@ pub enum Command {
|
||||
struct ResumeArgsRaw {
|
||||
// Note: This is the direct clap shape. We reinterpret the positional when --last is set
|
||||
// so "codex resume --last <prompt>" treats the positional as a prompt, not a session id.
|
||||
/// Conversation/session id (UUID). When provided, resumes this session.
|
||||
/// Conversation/session id (UUID) or thread name. UUIDs take precedence if it parses.
|
||||
/// If omitted, use --last to pick the most recent recorded session.
|
||||
#[arg(value_name = "SESSION_ID")]
|
||||
session_id: Option<String>,
|
||||
@@ -144,7 +144,7 @@ struct ResumeArgsRaw {
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct ResumeArgs {
|
||||
/// Conversation/session id (UUID). When provided, resumes this session.
|
||||
/// Conversation/session id (UUID) or thread name. UUIDs take precedence if it parses.
|
||||
/// If omitted, use --last to pick the most recent recorded session.
|
||||
pub session_id: Option<String>,
|
||||
|
||||
|
||||
@@ -750,7 +750,8 @@ impl EventProcessor for EventProcessorWithHumanOutput {
|
||||
);
|
||||
}
|
||||
EventMsg::ShutdownComplete => return CodexStatus::Shutdown,
|
||||
EventMsg::ExecApprovalRequest(_)
|
||||
EventMsg::ThreadNameUpdated(_)
|
||||
| EventMsg::ExecApprovalRequest(_)
|
||||
| EventMsg::ApplyPatchApprovalRequest(_)
|
||||
| EventMsg::TerminalInteraction(_)
|
||||
| EventMsg::ExecCommandOutputDelta(_)
|
||||
|
||||
@@ -117,6 +117,7 @@ impl EventProcessorWithJsonOutput {
|
||||
pub fn collect_thread_events(&mut self, event: &protocol::Event) -> Vec<ThreadEvent> {
|
||||
match &event.msg {
|
||||
protocol::EventMsg::SessionConfigured(ev) => self.handle_session_configured(ev),
|
||||
protocol::EventMsg::ThreadNameUpdated(_) => Vec::new(),
|
||||
protocol::EventMsg::AgentMessage(ev) => self.handle_agent_message(ev),
|
||||
protocol::EventMsg::AgentReasoning(ev) => self.handle_reasoning_event(ev),
|
||||
protocol::EventMsg::ExecCommandBegin(ev) => self.handle_exec_command_begin(ev),
|
||||
|
||||
@@ -59,12 +59,14 @@ use tracing::info;
|
||||
use tracing::warn;
|
||||
use tracing_subscriber::EnvFilter;
|
||||
use tracing_subscriber::prelude::*;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::cli::Command as ExecCommand;
|
||||
use crate::event_processor::CodexStatus;
|
||||
use crate::event_processor::EventProcessor;
|
||||
use codex_core::default_client::set_default_originator;
|
||||
use codex_core::find_thread_path_by_id_str;
|
||||
use codex_core::find_thread_path_by_name_str;
|
||||
|
||||
enum InitialOperation {
|
||||
UserTurn {
|
||||
@@ -619,8 +621,13 @@ async fn resolve_resume_path(
|
||||
}
|
||||
}
|
||||
} else if let Some(id_str) = args.session_id.as_deref() {
|
||||
let path = find_thread_path_by_id_str(&config.codex_home, id_str).await?;
|
||||
Ok(path)
|
||||
if Uuid::parse_str(id_str).is_ok() {
|
||||
let path = find_thread_path_by_id_str(&config.codex_home, id_str).await?;
|
||||
Ok(path)
|
||||
} else {
|
||||
let path = find_thread_path_by_name_str(&config.codex_home, id_str).await?;
|
||||
Ok(path)
|
||||
}
|
||||
} else {
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
@@ -88,6 +88,7 @@ fn session_configured_produces_thread_started_event() {
|
||||
EventMsg::SessionConfigured(SessionConfiguredEvent {
|
||||
session_id,
|
||||
forked_from_id: None,
|
||||
thread_name: None,
|
||||
model: "codex-mini-latest".to_string(),
|
||||
model_provider_id: "test-provider".to_string(),
|
||||
approval_policy: AskForApproval::Never,
|
||||
|
||||
@@ -308,6 +308,9 @@ async fn run_codex_tool_session_inner(
|
||||
EventMsg::SessionConfigured(_) => {
|
||||
tracing::error!("unexpected SessionConfigured event");
|
||||
}
|
||||
EventMsg::ThreadNameUpdated(_) => {
|
||||
// Ignore session metadata updates in MCP tool runner.
|
||||
}
|
||||
EventMsg::AgentMessageDelta(_) => {
|
||||
// TODO: think how we want to support this in the MCP
|
||||
}
|
||||
|
||||
@@ -258,6 +258,7 @@ mod tests {
|
||||
msg: EventMsg::SessionConfigured(SessionConfiguredEvent {
|
||||
session_id: thread_id,
|
||||
forked_from_id: None,
|
||||
thread_name: None,
|
||||
model: "gpt-4o".to_string(),
|
||||
model_provider_id: "test-provider".to_string(),
|
||||
approval_policy: AskForApproval::Never,
|
||||
@@ -298,6 +299,7 @@ mod tests {
|
||||
let session_configured_event = SessionConfiguredEvent {
|
||||
session_id: conversation_id,
|
||||
forked_from_id: None,
|
||||
thread_name: None,
|
||||
model: "gpt-4o".to_string(),
|
||||
model_provider_id: "test-provider".to_string(),
|
||||
approval_policy: AskForApproval::Never,
|
||||
@@ -362,6 +364,7 @@ mod tests {
|
||||
let session_configured_event = SessionConfiguredEvent {
|
||||
session_id: thread_id,
|
||||
forked_from_id: None,
|
||||
thread_name: None,
|
||||
model: "gpt-4o".to_string(),
|
||||
model_provider_id: "test-provider".to_string(),
|
||||
approval_policy: AskForApproval::Never,
|
||||
|
||||
@@ -273,6 +273,11 @@ pub enum Op {
|
||||
/// to generate a summary which will be returned as an AgentMessage event.
|
||||
Compact,
|
||||
|
||||
/// Set a user-facing thread name in the persisted rollout metadata.
|
||||
/// This is a local-only operation handled by codex-core; it does not
|
||||
/// involve the model.
|
||||
SetThreadName { name: String },
|
||||
|
||||
/// Request Codex to undo a turn (turn are stacked so it is the same effect as CMD + Z).
|
||||
Undo,
|
||||
|
||||
@@ -735,6 +740,9 @@ pub enum EventMsg {
|
||||
/// Ack the client's configure message.
|
||||
SessionConfigured(SessionConfiguredEvent),
|
||||
|
||||
/// Updated session metadata (e.g., thread name changes).
|
||||
ThreadNameUpdated(ThreadNameUpdatedEvent),
|
||||
|
||||
/// Incremental MCP startup progress updates.
|
||||
McpStartupUpdate(McpStartupUpdateEvent),
|
||||
|
||||
@@ -2186,11 +2194,15 @@ pub struct SkillsListEntry {
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)]
|
||||
pub struct SessionConfiguredEvent {
|
||||
/// Name left as session_id instead of thread_id for backwards compatibility.
|
||||
pub session_id: ThreadId,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub forked_from_id: Option<ThreadId>,
|
||||
|
||||
/// Optional user-facing thread name (may be unset).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[ts(optional)]
|
||||
pub thread_name: Option<String>,
|
||||
|
||||
/// Tell the client what model is being queried.
|
||||
pub model: String,
|
||||
|
||||
@@ -2226,6 +2238,14 @@ pub struct SessionConfiguredEvent {
|
||||
pub rollout_path: Option<PathBuf>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)]
|
||||
pub struct ThreadNameUpdatedEvent {
|
||||
pub thread_id: ThreadId,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[ts(optional)]
|
||||
pub thread_name: Option<String>,
|
||||
}
|
||||
|
||||
/// User's decision in response to an ExecApprovalRequest.
|
||||
#[derive(Debug, Default, Clone, Deserialize, Serialize, PartialEq, Eq, Display, JsonSchema, TS)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
@@ -2569,6 +2589,7 @@ mod tests {
|
||||
msg: EventMsg::SessionConfigured(SessionConfiguredEvent {
|
||||
session_id: conversation_id,
|
||||
forked_from_id: None,
|
||||
thread_name: None,
|
||||
model: "codex-mini-latest".to_string(),
|
||||
model_provider_id: "openai".to_string(),
|
||||
approval_policy: AskForApproval::Never,
|
||||
|
||||
@@ -94,6 +94,7 @@ tree-sitter-highlight = { workspace = true }
|
||||
unicode-segmentation = { workspace = true }
|
||||
unicode-width = { workspace = true }
|
||||
url = { workspace = true }
|
||||
uuid = { workspace = true }
|
||||
|
||||
codex-windows-sandbox = { workspace = true }
|
||||
tokio-util = { workspace = true, features = ["time"] }
|
||||
|
||||
+45
-8
@@ -108,6 +108,7 @@ const THREAD_EVENT_CHANNEL_CAPACITY: usize = 32768;
|
||||
pub struct AppExitInfo {
|
||||
pub token_usage: TokenUsage,
|
||||
pub thread_id: Option<ThreadId>,
|
||||
pub thread_name: Option<String>,
|
||||
pub update_action: Option<UpdateAction>,
|
||||
pub exit_reason: ExitReason,
|
||||
}
|
||||
@@ -117,6 +118,7 @@ impl AppExitInfo {
|
||||
Self {
|
||||
token_usage: TokenUsage::default(),
|
||||
thread_id: None,
|
||||
thread_name: None,
|
||||
update_action: None,
|
||||
exit_reason: ExitReason::Fatal(message.into()),
|
||||
}
|
||||
@@ -135,13 +137,17 @@ pub enum ExitReason {
|
||||
Fatal(String),
|
||||
}
|
||||
|
||||
fn session_summary(token_usage: TokenUsage, thread_id: Option<ThreadId>) -> Option<SessionSummary> {
|
||||
fn session_summary(
|
||||
token_usage: TokenUsage,
|
||||
thread_id: Option<ThreadId>,
|
||||
thread_name: Option<String>,
|
||||
) -> Option<SessionSummary> {
|
||||
if token_usage.is_zero() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let usage_line = FinalOutput::from(token_usage).to_string();
|
||||
let resume_command = thread_id.map(|thread_id| format!("codex resume {thread_id}"));
|
||||
let resume_command = codex_core::util::resume_command(thread_name.as_deref(), thread_id);
|
||||
Some(SessionSummary {
|
||||
usage_line,
|
||||
resume_command,
|
||||
@@ -496,6 +502,7 @@ async fn handle_model_migration_prompt_if_needed(
|
||||
return Some(AppExitInfo {
|
||||
token_usage: TokenUsage::default(),
|
||||
thread_id: None,
|
||||
thread_name: None,
|
||||
update_action: None,
|
||||
exit_reason: ExitReason::UserRequested,
|
||||
});
|
||||
@@ -1208,6 +1215,7 @@ impl App {
|
||||
Ok(AppExitInfo {
|
||||
token_usage: app.token_usage(),
|
||||
thread_id: app.chat_widget.thread_id(),
|
||||
thread_name: app.chat_widget.thread_name(),
|
||||
update_action: app.pending_update_action,
|
||||
exit_reason,
|
||||
})
|
||||
@@ -1269,8 +1277,11 @@ impl App {
|
||||
match event {
|
||||
AppEvent::NewSession => {
|
||||
let model = self.chat_widget.current_model().to_string();
|
||||
let summary =
|
||||
session_summary(self.chat_widget.token_usage(), self.chat_widget.thread_id());
|
||||
let summary = session_summary(
|
||||
self.chat_widget.token_usage(),
|
||||
self.chat_widget.thread_id(),
|
||||
self.chat_widget.thread_name(),
|
||||
);
|
||||
self.shutdown_current_thread().await;
|
||||
if let Err(err) = self.server.remove_and_close_all_threads().await {
|
||||
tracing::warn!(error = %err, "failed to close all threads");
|
||||
@@ -1343,6 +1354,7 @@ impl App {
|
||||
let summary = session_summary(
|
||||
self.chat_widget.token_usage(),
|
||||
self.chat_widget.thread_id(),
|
||||
self.chat_widget.thread_name(),
|
||||
);
|
||||
match self
|
||||
.server
|
||||
@@ -1401,8 +1413,11 @@ impl App {
|
||||
tui.frame_requester().schedule_frame();
|
||||
}
|
||||
AppEvent::ForkCurrentSession => {
|
||||
let summary =
|
||||
session_summary(self.chat_widget.token_usage(), self.chat_widget.thread_id());
|
||||
let summary = session_summary(
|
||||
self.chat_widget.token_usage(),
|
||||
self.chat_widget.thread_id(),
|
||||
self.chat_widget.thread_name(),
|
||||
);
|
||||
if let Some(path) = self.chat_widget.rollout_path() {
|
||||
match self
|
||||
.server
|
||||
@@ -2241,6 +2256,7 @@ impl App {
|
||||
msg: EventMsg::SessionConfigured(SessionConfiguredEvent {
|
||||
session_id: thread_id,
|
||||
forked_from_id: None,
|
||||
thread_name: None,
|
||||
model: config_snapshot.model,
|
||||
model_provider_id: config_snapshot.model_provider_id,
|
||||
approval_policy: config_snapshot.approval_policy,
|
||||
@@ -2908,6 +2924,7 @@ mod tests {
|
||||
let event = SessionConfiguredEvent {
|
||||
session_id: ThreadId::new(),
|
||||
forked_from_id: None,
|
||||
thread_name: None,
|
||||
model: "gpt-test".to_string(),
|
||||
model_provider_id: "test-provider".to_string(),
|
||||
approval_policy: AskForApproval::Never,
|
||||
@@ -2960,6 +2977,7 @@ mod tests {
|
||||
msg: EventMsg::SessionConfigured(SessionConfiguredEvent {
|
||||
session_id: base_id,
|
||||
forked_from_id: None,
|
||||
thread_name: None,
|
||||
model: "gpt-test".to_string(),
|
||||
model_provider_id: "test-provider".to_string(),
|
||||
approval_policy: AskForApproval::Never,
|
||||
@@ -3005,6 +3023,7 @@ mod tests {
|
||||
let event = SessionConfiguredEvent {
|
||||
session_id: thread_id,
|
||||
forked_from_id: None,
|
||||
thread_name: None,
|
||||
model: "gpt-test".to_string(),
|
||||
model_provider_id: "test-provider".to_string(),
|
||||
approval_policy: AskForApproval::Never,
|
||||
@@ -3036,7 +3055,7 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
async fn session_summary_skip_zero_usage() {
|
||||
assert!(session_summary(TokenUsage::default(), None).is_none());
|
||||
assert!(session_summary(TokenUsage::default(), None, None).is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -3049,7 +3068,7 @@ mod tests {
|
||||
};
|
||||
let conversation = ThreadId::from_string("123e4567-e89b-12d3-a456-426614174000").unwrap();
|
||||
|
||||
let summary = session_summary(usage, Some(conversation)).expect("summary");
|
||||
let summary = session_summary(usage, Some(conversation), None).expect("summary");
|
||||
assert_eq!(
|
||||
summary.usage_line,
|
||||
"Token usage: total=12 input=10 output=2"
|
||||
@@ -3059,4 +3078,22 @@ mod tests {
|
||||
Some("codex resume 123e4567-e89b-12d3-a456-426614174000".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn session_summary_prefers_name_over_id() {
|
||||
let usage = TokenUsage {
|
||||
input_tokens: 10,
|
||||
output_tokens: 2,
|
||||
total_tokens: 12,
|
||||
..Default::default()
|
||||
};
|
||||
let conversation = ThreadId::from_string("123e4567-e89b-12d3-a456-426614174000").unwrap();
|
||||
|
||||
let summary = session_summary(usage, Some(conversation), Some("my-session".to_string()))
|
||||
.expect("summary");
|
||||
assert_eq!(
|
||||
summary.resume_command,
|
||||
Some("codex resume my-session".to_string())
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2054,7 +2054,7 @@ impl ChatComposer {
|
||||
self.personality_command_enabled,
|
||||
self.windows_degraded_sandbox_active,
|
||||
)
|
||||
&& cmd == SlashCommand::Review
|
||||
&& matches!(cmd, SlashCommand::Review | SlashCommand::Rename)
|
||||
{
|
||||
self.textarea.set_text_clearing_elements("");
|
||||
return Some(InputResult::CommandWithArgs(cmd, rest.to_string()));
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
use std::collections::HashMap;
|
||||
use std::collections::HashSet;
|
||||
use std::collections::VecDeque;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
@@ -197,7 +198,6 @@ use self::skills::collect_tool_mentions;
|
||||
use self::skills::find_app_mentions;
|
||||
use self::skills::find_skill_mentions_with_tool_mentions;
|
||||
use crate::streaming::controller::StreamController;
|
||||
use std::path::Path;
|
||||
|
||||
use chrono::Local;
|
||||
use codex_common::approval_presets::ApprovalPreset;
|
||||
@@ -517,6 +517,7 @@ pub(crate) struct ChatWidget {
|
||||
// Previous status header to restore after a transient stream retry.
|
||||
retry_status_header: Option<String>,
|
||||
thread_id: Option<ThreadId>,
|
||||
thread_name: Option<String>,
|
||||
forked_from: Option<ThreadId>,
|
||||
frame_requester: FrameRequester,
|
||||
// Whether to include the initial welcome banner on session configured
|
||||
@@ -788,6 +789,7 @@ impl ChatWidget {
|
||||
self.set_skills(None);
|
||||
self.bottom_pane.set_connectors_snapshot(None);
|
||||
self.thread_id = Some(event.session_id);
|
||||
self.thread_name = event.thread_name.clone();
|
||||
self.forked_from = event.forked_from_id;
|
||||
self.current_rollout_path = event.rollout_path.clone();
|
||||
let initial_messages = event.initial_messages.clone();
|
||||
@@ -828,6 +830,13 @@ impl ChatWidget {
|
||||
}
|
||||
}
|
||||
|
||||
fn on_thread_name_updated(&mut self, event: codex_core::protocol::ThreadNameUpdatedEvent) {
|
||||
if self.thread_id == Some(event.thread_id) {
|
||||
self.thread_name = event.thread_name;
|
||||
self.request_redraw();
|
||||
}
|
||||
}
|
||||
|
||||
fn set_skills(&mut self, skills: Option<Vec<SkillMetadata>>) {
|
||||
self.bottom_pane.set_skills(skills);
|
||||
}
|
||||
@@ -2167,6 +2176,7 @@ impl ChatWidget {
|
||||
current_status_header: String::from("Working"),
|
||||
retry_status_header: None,
|
||||
thread_id: None,
|
||||
thread_name: None,
|
||||
forked_from: None,
|
||||
queued_user_messages: VecDeque::new(),
|
||||
show_welcome_banner: is_first_run,
|
||||
@@ -2307,6 +2317,7 @@ impl ChatWidget {
|
||||
current_status_header: String::from("Working"),
|
||||
retry_status_header: None,
|
||||
thread_id: None,
|
||||
thread_name: None,
|
||||
forked_from: None,
|
||||
saw_plan_update_this_turn: false,
|
||||
queued_user_messages: VecDeque::new(),
|
||||
@@ -2436,6 +2447,7 @@ impl ChatWidget {
|
||||
current_status_header: String::from("Working"),
|
||||
retry_status_header: None,
|
||||
thread_id: None,
|
||||
thread_name: None,
|
||||
forked_from: None,
|
||||
queued_user_messages: VecDeque::new(),
|
||||
show_welcome_banner: false,
|
||||
@@ -2706,6 +2718,9 @@ impl ChatWidget {
|
||||
SlashCommand::Review => {
|
||||
self.open_review_popup();
|
||||
}
|
||||
SlashCommand::Rename => {
|
||||
self.show_rename_prompt();
|
||||
}
|
||||
SlashCommand::Model => {
|
||||
self.open_model_popup();
|
||||
}
|
||||
@@ -2907,6 +2922,17 @@ impl ChatWidget {
|
||||
|
||||
let trimmed = args.trim();
|
||||
match cmd {
|
||||
SlashCommand::Rename if !trimmed.is_empty() => {
|
||||
let Some(name) = codex_core::util::normalize_thread_name(trimmed) else {
|
||||
self.add_error_message("Thread name cannot be empty.".to_string());
|
||||
return;
|
||||
};
|
||||
let cell = Self::rename_confirmation_cell(&name, self.thread_id);
|
||||
self.add_boxed_history(Box::new(cell));
|
||||
self.request_redraw();
|
||||
self.app_event_tx
|
||||
.send(AppEvent::CodexOp(Op::SetThreadName { name }));
|
||||
}
|
||||
SlashCommand::Collab | SlashCommand::Plan => {
|
||||
let _ = trimmed;
|
||||
self.dispatch_command(cmd);
|
||||
@@ -2925,6 +2951,38 @@ impl ChatWidget {
|
||||
}
|
||||
}
|
||||
|
||||
fn show_rename_prompt(&mut self) {
|
||||
let tx = self.app_event_tx.clone();
|
||||
let has_name = self
|
||||
.thread_name
|
||||
.as_ref()
|
||||
.is_some_and(|name| !name.is_empty());
|
||||
let title = if has_name {
|
||||
"Rename thread"
|
||||
} else {
|
||||
"Name thread"
|
||||
};
|
||||
let thread_id = self.thread_id;
|
||||
let view = CustomPromptView::new(
|
||||
title.to_string(),
|
||||
"Type a name and press Enter".to_string(),
|
||||
None,
|
||||
Box::new(move |name: String| {
|
||||
let Some(name) = codex_core::util::normalize_thread_name(&name) else {
|
||||
tx.send(AppEvent::InsertHistoryCell(Box::new(
|
||||
history_cell::new_error_event("Thread name cannot be empty.".to_string()),
|
||||
)));
|
||||
return;
|
||||
};
|
||||
let cell = Self::rename_confirmation_cell(&name, thread_id);
|
||||
tx.send(AppEvent::InsertHistoryCell(Box::new(cell)));
|
||||
tx.send(AppEvent::CodexOp(Op::SetThreadName { name }));
|
||||
}),
|
||||
);
|
||||
|
||||
self.bottom_pane.show_view(Box::new(view));
|
||||
}
|
||||
|
||||
pub(crate) fn handle_paste(&mut self, text: String) {
|
||||
self.bottom_pane.handle_paste(text);
|
||||
}
|
||||
@@ -3125,7 +3183,10 @@ impl ChatWidget {
|
||||
/// distinguish replayed events from live ones.
|
||||
fn replay_initial_messages(&mut self, events: Vec<EventMsg>) {
|
||||
for msg in events {
|
||||
if matches!(msg, EventMsg::SessionConfigured(_)) {
|
||||
if matches!(
|
||||
msg,
|
||||
EventMsg::SessionConfigured(_) | EventMsg::ThreadNameUpdated(_)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
// `id: None` indicates a synthetic/fake id coming from replay.
|
||||
@@ -3169,6 +3230,7 @@ impl ChatWidget {
|
||||
|
||||
match msg {
|
||||
EventMsg::SessionConfigured(e) => self.on_session_configured(e),
|
||||
EventMsg::ThreadNameUpdated(e) => self.on_thread_name_updated(e),
|
||||
EventMsg::AgentMessage(AgentMessageEvent { message }) => self.on_agent_message(message),
|
||||
EventMsg::AgentMessageDelta(AgentMessageDeltaEvent { delta }) => {
|
||||
self.on_agent_message_delta(delta)
|
||||
@@ -3468,6 +3530,7 @@ impl ChatWidget {
|
||||
token_info,
|
||||
total_usage,
|
||||
&self.thread_id,
|
||||
self.thread_name.clone(),
|
||||
self.forked_from,
|
||||
self.rate_limit_snapshot.as_ref(),
|
||||
self.plan_type,
|
||||
@@ -5275,6 +5338,20 @@ impl ChatWidget {
|
||||
self.request_redraw();
|
||||
}
|
||||
|
||||
fn rename_confirmation_cell(name: &str, thread_id: Option<ThreadId>) -> PlainHistoryCell {
|
||||
let resume_cmd = codex_core::util::resume_command(Some(name), thread_id)
|
||||
.unwrap_or_else(|| format!("codex resume {name}"));
|
||||
let name = name.to_string();
|
||||
let line = vec![
|
||||
"• ".into(),
|
||||
"Thread renamed to ".into(),
|
||||
name.cyan(),
|
||||
", to resume this thread run ".into(),
|
||||
resume_cmd.cyan(),
|
||||
];
|
||||
PlainHistoryCell::new(vec![line.into()])
|
||||
}
|
||||
|
||||
pub(crate) fn add_mcp_output(&mut self) {
|
||||
if self.config.mcp_servers.is_empty() {
|
||||
self.add_to_history(history_cell::empty_mcp_output());
|
||||
@@ -5796,6 +5873,9 @@ impl ChatWidget {
|
||||
self.thread_id
|
||||
}
|
||||
|
||||
pub(crate) fn thread_name(&self) -> Option<String> {
|
||||
self.thread_name.clone()
|
||||
}
|
||||
pub(crate) fn rollout_path(&self) -> Option<PathBuf> {
|
||||
self.current_rollout_path.clone()
|
||||
}
|
||||
|
||||
@@ -134,6 +134,7 @@ async fn resumed_initial_messages_render_history() {
|
||||
let configured = codex_core::protocol::SessionConfiguredEvent {
|
||||
session_id: conversation_id,
|
||||
forked_from_id: None,
|
||||
thread_name: None,
|
||||
model: "test-model".to_string(),
|
||||
model_provider_id: "test-provider".to_string(),
|
||||
approval_policy: AskForApproval::Never,
|
||||
@@ -200,6 +201,7 @@ async fn replayed_user_message_preserves_text_elements_and_local_images() {
|
||||
let configured = codex_core::protocol::SessionConfiguredEvent {
|
||||
session_id: conversation_id,
|
||||
forked_from_id: None,
|
||||
thread_name: None,
|
||||
model: "test-model".to_string(),
|
||||
model_provider_id: "test-provider".to_string(),
|
||||
approval_policy: AskForApproval::Never,
|
||||
@@ -252,6 +254,7 @@ async fn submission_preserves_text_elements_and_local_images() {
|
||||
let configured = codex_core::protocol::SessionConfiguredEvent {
|
||||
session_id: conversation_id,
|
||||
forked_from_id: None,
|
||||
thread_name: None,
|
||||
model: "test-model".to_string(),
|
||||
model_provider_id: "test-provider".to_string(),
|
||||
approval_policy: AskForApproval::Never,
|
||||
@@ -823,6 +826,7 @@ async fn make_chatwidget_manual(
|
||||
current_status_header: String::from("Working"),
|
||||
retry_status_header: None,
|
||||
thread_id: None,
|
||||
thread_name: None,
|
||||
forked_from: None,
|
||||
frame_requester: FrameRequester::test_dummy(),
|
||||
show_welcome_banner: true,
|
||||
|
||||
+21
-2
@@ -26,6 +26,7 @@ use codex_core::config::resolve_oss_provider;
|
||||
use codex_core::config_loader::ConfigLoadError;
|
||||
use codex_core::config_loader::format_config_error_with_source;
|
||||
use codex_core::find_thread_path_by_id_str;
|
||||
use codex_core::find_thread_path_by_name_str;
|
||||
use codex_core::path_utils;
|
||||
use codex_core::protocol::AskForApproval;
|
||||
use codex_core::read_session_meta_line;
|
||||
@@ -47,6 +48,7 @@ use tracing::error;
|
||||
use tracing_appender::non_blocking;
|
||||
use tracing_subscriber::EnvFilter;
|
||||
use tracing_subscriber::prelude::*;
|
||||
use uuid::Uuid;
|
||||
|
||||
mod additional_dirs;
|
||||
mod app;
|
||||
@@ -411,6 +413,7 @@ async fn run_ratatui_app(
|
||||
return Ok(AppExitInfo {
|
||||
token_usage: codex_core::protocol::TokenUsage::default(),
|
||||
thread_id: None,
|
||||
thread_name: None,
|
||||
update_action: Some(action),
|
||||
exit_reason: ExitReason::UserRequested,
|
||||
});
|
||||
@@ -451,6 +454,7 @@ async fn run_ratatui_app(
|
||||
return Ok(AppExitInfo {
|
||||
token_usage: codex_core::protocol::TokenUsage::default(),
|
||||
thread_id: None,
|
||||
thread_name: None,
|
||||
update_action: None,
|
||||
exit_reason: ExitReason::UserRequested,
|
||||
});
|
||||
@@ -484,6 +488,7 @@ async fn run_ratatui_app(
|
||||
Ok(AppExitInfo {
|
||||
token_usage: codex_core::protocol::TokenUsage::default(),
|
||||
thread_id: None,
|
||||
thread_name: None,
|
||||
update_action: None,
|
||||
exit_reason: ExitReason::Fatal(format!(
|
||||
"No saved session found with ID {id_str}. Run `codex {action}` without an ID to choose from existing sessions."
|
||||
@@ -494,7 +499,13 @@ async fn run_ratatui_app(
|
||||
let use_fork = cli.fork_picker || cli.fork_last || cli.fork_session_id.is_some();
|
||||
let session_selection = if use_fork {
|
||||
if let Some(id_str) = cli.fork_session_id.as_deref() {
|
||||
match find_thread_path_by_id_str(&config.codex_home, id_str).await? {
|
||||
let is_uuid = Uuid::parse_str(id_str).is_ok();
|
||||
let path = if is_uuid {
|
||||
find_thread_path_by_id_str(&config.codex_home, id_str).await?
|
||||
} else {
|
||||
find_thread_path_by_name_str(&config.codex_home, id_str).await?
|
||||
};
|
||||
match path {
|
||||
Some(path) => resume_picker::SessionSelection::Fork(path),
|
||||
None => return missing_session_exit(id_str, "fork"),
|
||||
}
|
||||
@@ -533,6 +544,7 @@ async fn run_ratatui_app(
|
||||
return Ok(AppExitInfo {
|
||||
token_usage: codex_core::protocol::TokenUsage::default(),
|
||||
thread_id: None,
|
||||
thread_name: None,
|
||||
update_action: None,
|
||||
exit_reason: ExitReason::UserRequested,
|
||||
});
|
||||
@@ -543,7 +555,13 @@ async fn run_ratatui_app(
|
||||
resume_picker::SessionSelection::StartFresh
|
||||
}
|
||||
} else if let Some(id_str) = cli.resume_session_id.as_deref() {
|
||||
match find_thread_path_by_id_str(&config.codex_home, id_str).await? {
|
||||
let is_uuid = Uuid::parse_str(id_str).is_ok();
|
||||
let path = if is_uuid {
|
||||
find_thread_path_by_id_str(&config.codex_home, id_str).await?
|
||||
} else {
|
||||
find_thread_path_by_name_str(&config.codex_home, id_str).await?
|
||||
};
|
||||
match path {
|
||||
Some(path) => resume_picker::SessionSelection::Resume(path),
|
||||
None => return missing_session_exit(id_str, "resume"),
|
||||
}
|
||||
@@ -584,6 +602,7 @@ async fn run_ratatui_app(
|
||||
return Ok(AppExitInfo {
|
||||
token_usage: codex_core::protocol::TokenUsage::default(),
|
||||
thread_id: None,
|
||||
thread_name: None,
|
||||
update_action: None,
|
||||
exit_reason: ExitReason::UserRequested,
|
||||
});
|
||||
|
||||
@@ -20,6 +20,7 @@ pub enum SlashCommand {
|
||||
Experimental,
|
||||
Skills,
|
||||
Review,
|
||||
Rename,
|
||||
New,
|
||||
Resume,
|
||||
Fork,
|
||||
@@ -53,6 +54,7 @@ impl SlashCommand {
|
||||
SlashCommand::Init => "create an AGENTS.md file with instructions for Codex",
|
||||
SlashCommand::Compact => "summarize conversation to prevent hitting the context limit",
|
||||
SlashCommand::Review => "review my current changes and find issues",
|
||||
SlashCommand::Rename => "rename the current thread",
|
||||
SlashCommand::Resume => "resume a saved chat",
|
||||
SlashCommand::Fork => "fork the current chat",
|
||||
// SlashCommand::Undo => "ask Codex to undo a turn",
|
||||
@@ -103,6 +105,7 @@ impl SlashCommand {
|
||||
| SlashCommand::Review
|
||||
| SlashCommand::Logout => false,
|
||||
SlashCommand::Diff
|
||||
| SlashCommand::Rename
|
||||
| SlashCommand::Mention
|
||||
| SlashCommand::Skills
|
||||
| SlashCommand::Status
|
||||
|
||||
@@ -68,6 +68,7 @@ struct StatusHistoryCell {
|
||||
collaboration_mode: Option<String>,
|
||||
model_provider: Option<String>,
|
||||
account: Option<StatusAccountDisplay>,
|
||||
thread_name: Option<String>,
|
||||
session_id: Option<String>,
|
||||
forked_from: Option<String>,
|
||||
token_usage: StatusTokenUsageData,
|
||||
@@ -81,6 +82,7 @@ pub(crate) fn new_status_output(
|
||||
token_info: Option<&TokenUsageInfo>,
|
||||
total_usage: &TokenUsage,
|
||||
session_id: &Option<ThreadId>,
|
||||
thread_name: Option<String>,
|
||||
forked_from: Option<ThreadId>,
|
||||
rate_limits: Option<&RateLimitSnapshotDisplay>,
|
||||
plan_type: Option<PlanType>,
|
||||
@@ -96,6 +98,7 @@ pub(crate) fn new_status_output(
|
||||
token_info,
|
||||
total_usage,
|
||||
session_id,
|
||||
thread_name,
|
||||
forked_from,
|
||||
rate_limits,
|
||||
plan_type,
|
||||
@@ -116,6 +119,7 @@ impl StatusHistoryCell {
|
||||
token_info: Option<&TokenUsageInfo>,
|
||||
total_usage: &TokenUsage,
|
||||
session_id: &Option<ThreadId>,
|
||||
thread_name: Option<String>,
|
||||
forked_from: Option<ThreadId>,
|
||||
rate_limits: Option<&RateLimitSnapshotDisplay>,
|
||||
plan_type: Option<PlanType>,
|
||||
@@ -197,6 +201,7 @@ impl StatusHistoryCell {
|
||||
collaboration_mode: collaboration_mode.map(ToString::to_string),
|
||||
model_provider,
|
||||
account,
|
||||
thread_name,
|
||||
session_id,
|
||||
forked_from,
|
||||
token_usage,
|
||||
@@ -377,6 +382,7 @@ impl HistoryCell for StatusHistoryCell {
|
||||
.map(str::to_string)
|
||||
.collect();
|
||||
let mut seen: BTreeSet<String> = labels.iter().cloned().collect();
|
||||
let thread_name = self.thread_name.as_deref().filter(|name| !name.is_empty());
|
||||
|
||||
if self.model_provider.is_some() {
|
||||
push_label(&mut labels, &mut seen, "Model provider");
|
||||
@@ -384,6 +390,9 @@ impl HistoryCell for StatusHistoryCell {
|
||||
if account_value.is_some() {
|
||||
push_label(&mut labels, &mut seen, "Account");
|
||||
}
|
||||
if thread_name.is_some() {
|
||||
push_label(&mut labels, &mut seen, "Thread name");
|
||||
}
|
||||
if self.session_id.is_some() {
|
||||
push_label(&mut labels, &mut seen, "Session");
|
||||
}
|
||||
@@ -442,10 +451,12 @@ impl HistoryCell for StatusHistoryCell {
|
||||
lines.push(formatter.line("Account", vec![Span::from(account_value)]));
|
||||
}
|
||||
|
||||
if let Some(thread_name) = thread_name {
|
||||
lines.push(formatter.line("Thread name", vec![Span::from(thread_name.to_string())]));
|
||||
}
|
||||
if let Some(collab_mode) = self.collaboration_mode.as_ref() {
|
||||
lines.push(formatter.line("Collaboration mode", vec![Span::from(collab_mode.clone())]));
|
||||
}
|
||||
|
||||
if let Some(session) = self.session_id.as_ref() {
|
||||
lines.push(formatter.line("Session", vec![Span::from(session.clone())]));
|
||||
}
|
||||
|
||||
@@ -148,6 +148,7 @@ async fn status_snapshot_includes_reasoning_details() {
|
||||
&usage,
|
||||
&None,
|
||||
None,
|
||||
None,
|
||||
Some(&rate_display),
|
||||
None,
|
||||
captured_at,
|
||||
@@ -200,6 +201,7 @@ async fn status_snapshot_includes_forked_from() {
|
||||
Some(&token_info),
|
||||
&usage,
|
||||
&Some(session_id),
|
||||
None,
|
||||
Some(forked_from),
|
||||
None,
|
||||
None,
|
||||
@@ -260,6 +262,7 @@ async fn status_snapshot_includes_monthly_limit() {
|
||||
&usage,
|
||||
&None,
|
||||
None,
|
||||
None,
|
||||
Some(&rate_display),
|
||||
None,
|
||||
captured_at,
|
||||
@@ -307,6 +310,7 @@ async fn status_snapshot_shows_unlimited_credits() {
|
||||
&usage,
|
||||
&None,
|
||||
None,
|
||||
None,
|
||||
Some(&rate_display),
|
||||
None,
|
||||
captured_at,
|
||||
@@ -353,6 +357,7 @@ async fn status_snapshot_shows_positive_credits() {
|
||||
&usage,
|
||||
&None,
|
||||
None,
|
||||
None,
|
||||
Some(&rate_display),
|
||||
None,
|
||||
captured_at,
|
||||
@@ -399,6 +404,7 @@ async fn status_snapshot_hides_zero_credits() {
|
||||
&usage,
|
||||
&None,
|
||||
None,
|
||||
None,
|
||||
Some(&rate_display),
|
||||
None,
|
||||
captured_at,
|
||||
@@ -443,6 +449,7 @@ async fn status_snapshot_hides_when_has_no_credits_flag() {
|
||||
&usage,
|
||||
&None,
|
||||
None,
|
||||
None,
|
||||
Some(&rate_display),
|
||||
None,
|
||||
captured_at,
|
||||
@@ -489,6 +496,7 @@ async fn status_card_token_usage_excludes_cached_tokens() {
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
now,
|
||||
&model_slug,
|
||||
None,
|
||||
@@ -546,6 +554,7 @@ async fn status_snapshot_truncates_in_narrow_terminal() {
|
||||
&usage,
|
||||
&None,
|
||||
None,
|
||||
None,
|
||||
Some(&rate_display),
|
||||
None,
|
||||
captured_at,
|
||||
@@ -596,6 +605,7 @@ async fn status_snapshot_shows_missing_limits_message() {
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
now,
|
||||
&model_slug,
|
||||
None,
|
||||
@@ -660,6 +670,7 @@ async fn status_snapshot_includes_credits_and_limits() {
|
||||
&usage,
|
||||
&None,
|
||||
None,
|
||||
None,
|
||||
Some(&rate_display),
|
||||
None,
|
||||
captured_at,
|
||||
@@ -714,6 +725,7 @@ async fn status_snapshot_shows_empty_limits_message() {
|
||||
&usage,
|
||||
&None,
|
||||
None,
|
||||
None,
|
||||
Some(&rate_display),
|
||||
None,
|
||||
captured_at,
|
||||
@@ -777,6 +789,7 @@ async fn status_snapshot_shows_stale_limits_message() {
|
||||
&usage,
|
||||
&None,
|
||||
None,
|
||||
None,
|
||||
Some(&rate_display),
|
||||
None,
|
||||
now,
|
||||
@@ -844,6 +857,7 @@ async fn status_snapshot_cached_limits_hide_credits_without_flag() {
|
||||
&usage,
|
||||
&None,
|
||||
None,
|
||||
None,
|
||||
Some(&rate_display),
|
||||
None,
|
||||
now,
|
||||
@@ -903,6 +917,7 @@ async fn status_context_window_uses_last_usage() {
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
now,
|
||||
&model_slug,
|
||||
None,
|
||||
|
||||
@@ -9,6 +9,7 @@ Use /status to see the current model, approvals, and token usage.
|
||||
Use /fork to branch the current chat into a new thread.
|
||||
Use /init to create an AGENTS.md with project-specific guidance.
|
||||
Use /mcp to list configured MCP tools.
|
||||
Use /rename to rename your threads for easier thread resuming.
|
||||
Use the OpenAI docs MCP for API questions; enable it with `codex mcp add openaiDeveloperDocs --url https://developers.openai.com/mcp`.
|
||||
Join the OpenAI community Discord: http://discord.gg/openai
|
||||
Visit the Codex community forum: https://community.openai.com/c/codex/37
|
||||
|
||||
Reference in New Issue
Block a user