mirror of
https://github.com/openai/codex.git
synced 2026-05-01 18:06:47 +00:00
Compare commits
4 Commits
iceweasel/
...
fcoury/aut
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dc99d07c38 | ||
|
|
eb367e3030 | ||
|
|
d35a533e0f | ||
|
|
673827448d |
@@ -19,6 +19,7 @@ use crate::types::OAuthCredentialsStoreMode;
|
||||
use crate::types::OtelConfigToml;
|
||||
use crate::types::PluginConfig;
|
||||
use crate::types::SandboxWorkspaceWrite;
|
||||
use crate::types::SessionTitlesToml;
|
||||
use crate::types::ShellEnvironmentPolicyToml;
|
||||
use crate::types::SkillsConfig;
|
||||
use crate::types::ToolSuggestConfig;
|
||||
@@ -318,6 +319,9 @@ pub struct ConfigToml {
|
||||
/// Memories subsystem settings.
|
||||
pub memories: Option<MemoriesToml>,
|
||||
|
||||
/// Default generated names for interactive sessions.
|
||||
pub session_titles: Option<SessionTitlesToml>,
|
||||
|
||||
/// User-level skill config entries keyed by SKILL.md path.
|
||||
pub skills: Option<SkillsConfig>,
|
||||
|
||||
|
||||
@@ -200,6 +200,44 @@ pub struct MemoriesToml {
|
||||
pub consolidation_model: Option<String>,
|
||||
}
|
||||
|
||||
/// Session title settings loaded from config.toml.
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default, JsonSchema)]
|
||||
#[schemars(deny_unknown_fields)]
|
||||
pub struct SessionTitlesToml {
|
||||
/// When `false`, skip generating a default `/rename` title for new interactive sessions.
|
||||
pub enabled: Option<bool>,
|
||||
/// Model used for default session title generation.
|
||||
pub model: Option<String>,
|
||||
}
|
||||
|
||||
/// Effective session title settings after defaults are applied.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct SessionTitlesConfig {
|
||||
/// Whether interactive sessions should ask an auxiliary model for a default title.
|
||||
pub enabled: bool,
|
||||
/// Optional model override for the title-only background request.
|
||||
pub model: Option<String>,
|
||||
}
|
||||
|
||||
impl Default for SessionTitlesConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enabled: true,
|
||||
model: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<SessionTitlesToml> for SessionTitlesConfig {
|
||||
fn from(toml: SessionTitlesToml) -> Self {
|
||||
let defaults = Self::default();
|
||||
Self {
|
||||
enabled: toml.enabled.unwrap_or(defaults.enabled),
|
||||
model: toml.model,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Effective memories settings after defaults are applied.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct MemoriesConfig {
|
||||
|
||||
@@ -1640,6 +1640,21 @@
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"SessionTitlesToml": {
|
||||
"additionalProperties": false,
|
||||
"description": "Session title settings loaded from config.toml.",
|
||||
"properties": {
|
||||
"enabled": {
|
||||
"description": "When `false`, skip generating a default `/rename` title for new interactive sessions.",
|
||||
"type": "boolean"
|
||||
},
|
||||
"model": {
|
||||
"description": "Model used for default session title generation.",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"ShellEnvironmentPolicyInherit": {
|
||||
"oneOf": [
|
||||
{
|
||||
@@ -2645,6 +2660,14 @@
|
||||
],
|
||||
"description": "Optional explicit service tier preference for new turns (`fast` or `flex`)."
|
||||
},
|
||||
"session_titles": {
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/SessionTitlesToml"
|
||||
}
|
||||
],
|
||||
"description": "Default generated names for interactive sessions."
|
||||
},
|
||||
"shell_environment_policy": {
|
||||
"allOf": [
|
||||
{
|
||||
|
||||
@@ -4,7 +4,9 @@ use std::fmt::Debug;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::AtomicBool;
|
||||
use std::sync::atomic::AtomicU64;
|
||||
use std::sync::atomic::Ordering;
|
||||
use std::time::SystemTime;
|
||||
use std::time::UNIX_EPOCH;
|
||||
|
||||
@@ -76,6 +78,7 @@ use codex_login::default_client::originator;
|
||||
use codex_mcp::McpConnectionManager;
|
||||
use codex_mcp::SandboxState;
|
||||
use codex_mcp::codex_apps_tools_cache_key;
|
||||
use codex_models_manager::ModelsManagerConfig;
|
||||
#[cfg(test)]
|
||||
use codex_models_manager::collaboration_mode_presets::CollaborationModesConfig;
|
||||
use codex_models_manager::manager::ModelsManager;
|
||||
@@ -150,6 +153,7 @@ use rmcp::model::PaginatedRequestParams;
|
||||
use rmcp::model::ReadResourceRequestParams;
|
||||
use rmcp::model::ReadResourceResult;
|
||||
use rmcp::model::RequestId;
|
||||
use serde::Deserialize;
|
||||
use serde_json::Value;
|
||||
use tokio::sync::Mutex;
|
||||
use tokio::sync::RwLock;
|
||||
@@ -373,6 +377,7 @@ use codex_protocol::protocol::SkillMetadata as ProtocolSkillMetadata;
|
||||
use codex_protocol::protocol::SkillToolDependency as ProtocolSkillToolDependency;
|
||||
use codex_protocol::protocol::StreamErrorEvent;
|
||||
use codex_protocol::protocol::Submission;
|
||||
use codex_protocol::protocol::ThreadNameUpdatedEvent;
|
||||
use codex_protocol::protocol::TokenCountEvent;
|
||||
use codex_protocol::protocol::TokenUsage;
|
||||
use codex_protocol::protocol::TokenUsageInfo;
|
||||
@@ -440,6 +445,44 @@ pub(crate) const INITIAL_SUBMIT_ID: &str = "";
|
||||
pub(crate) const SUBMISSION_CHANNEL_CAPACITY: usize = 512;
|
||||
const CYBER_VERIFY_URL: &str = "https://chatgpt.com/cyber";
|
||||
const CYBER_SAFETY_URL: &str = "https://developers.openai.com/codex/concepts/cyber-safety";
|
||||
const DEFAULT_THREAD_TITLE_MODEL: &str = "gpt-5.4-mini";
|
||||
const THREAD_TITLE_INPUT_CHAR_LIMIT: usize = 2_000;
|
||||
const THREAD_TITLE_MIN_CHARS: usize = 18;
|
||||
const THREAD_TITLE_MAX_CHARS: usize = 36;
|
||||
const THREAD_TITLE_PROMPT: &str = r#"You are a helpful assistant. You will be presented with a user prompt, and your job is to provide a short title for a task that will be created from that prompt.
|
||||
The tasks typically have to do with coding-related tasks, for example requests for bug fixes or questions about a codebase. The title you generate will be shown in the UI to represent the prompt.
|
||||
Generate a concise UI title (18-36 characters) for this task.
|
||||
Return JSON with exactly one field: {"title": "..."}.
|
||||
The title value must be plain text. No quotes or trailing punctuation.
|
||||
Do not use markdown or formatting characters.
|
||||
If the task includes a ticket reference (e.g. ABC-123), include it verbatim.
|
||||
|
||||
Generate a clear, informative task title based solely on the prompt provided. Follow the rules below to ensure consistency, readability, and usefulness.
|
||||
|
||||
How to write a good title:
|
||||
Generate a single-line title that captures the question or core change requested. The title should be easy to scan and useful in changelogs or review queues.
|
||||
- Use an imperative verb first: "Add", "Fix", "Update", "Refactor", "Remove", "Locate", "Find", etc.
|
||||
- Aim for 18-36 characters; keep under 5 words where possible.
|
||||
- Capitalize only the first word unless locale requires otherwise.
|
||||
- Write the title in the user's locale.
|
||||
- Do not use punctuation at the end.
|
||||
- Output the title as plain text with no surrounding quotes or backticks.
|
||||
- Use precise, non-redundant language.
|
||||
- Translate fixed phrases into the user's locale (e.g., "Fix bug" -> "Corrige el error" in Spanish-ES), but leave code terms in English unless a widely adopted translation exists.
|
||||
- If the user provides a title explicitly, reuse it (translated if needed) and skip generation logic.
|
||||
- Make it clear when the user is requesting changes (use verbs like "Fix", "Add", etc) vs asking a question (use verbs like "Find", "Locate", "Count").
|
||||
- Do NOT respond to the user, answer questions, or attempt to solve the problem; just write a title that can represent the user's query.
|
||||
|
||||
Examples:
|
||||
- User: "Can we add dark-mode support to the settings page?" -> Add dark-mode support
|
||||
- User: "Fehlerbehebung: Beim Anmelden erscheint 500." (de-DE) -> Login-Fehler 500 beheben
|
||||
- User: "Refactoriser le composant sidebar pour reduire le code duplique." (fr-FR) -> Refactoriser composant sidebar
|
||||
- User: "How do I fix our login bug?" -> Troubleshoot login bug
|
||||
- User: "Where in the codebase is foo_bar created" -> Locate foo_bar
|
||||
- User: "what's 2+2" -> Calculate 2+2
|
||||
|
||||
By following these conventions, your titles will be readable, changelog-friendly, and helpful to both users and downstream tools."#;
|
||||
|
||||
impl Codex {
|
||||
/// Spawn a new [`Codex`] and initialize the session.
|
||||
pub(crate) async fn spawn(args: CodexSpawnArgs) -> CodexResult<CodexSpawnOk> {
|
||||
@@ -838,6 +881,10 @@ pub(crate) struct Session {
|
||||
pub(crate) services: SessionServices,
|
||||
js_repl: Arc<JsReplHandle>,
|
||||
next_internal_sub_id: AtomicU64,
|
||||
/// One-shot guard that prevents later user turns from spawning duplicate title requests.
|
||||
thread_name_generation_started: AtomicBool,
|
||||
/// Serializes manual and generated thread-name writes through the shared session-index path.
|
||||
thread_name_write_lock: Mutex<()>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
@@ -2071,6 +2118,8 @@ impl Session {
|
||||
services,
|
||||
js_repl,
|
||||
next_internal_sub_id: AtomicU64::new(0),
|
||||
thread_name_generation_started: AtomicBool::new(false),
|
||||
thread_name_write_lock: Mutex::new(()),
|
||||
});
|
||||
if let Some(network_policy_decider_session) = network_policy_decider_session {
|
||||
let mut guard = network_policy_decider_session.write().await;
|
||||
@@ -4074,6 +4123,73 @@ impl Session {
|
||||
self.ensure_rollout_materialized().await;
|
||||
}
|
||||
|
||||
async fn maybe_start_thread_name_generation(
|
||||
self: &Arc<Self>,
|
||||
turn_context: Arc<TurnContext>,
|
||||
input: &[UserInput],
|
||||
) {
|
||||
let initial_user_message = UserMessageItem::new(input).message();
|
||||
if initial_user_message.trim().is_empty() {
|
||||
return;
|
||||
}
|
||||
if !self.should_start_thread_name_generation().await {
|
||||
return;
|
||||
}
|
||||
|
||||
let sess = Arc::clone(self);
|
||||
let title_request = ThreadNameGenerationRequest {
|
||||
sub_id: turn_context.sub_id.clone(),
|
||||
user_message: truncate_string_chars(
|
||||
&initial_user_message,
|
||||
THREAD_TITLE_INPUT_CHAR_LIMIT,
|
||||
),
|
||||
model_name: turn_context
|
||||
.config
|
||||
.session_titles
|
||||
.model
|
||||
.clone()
|
||||
.unwrap_or_else(|| DEFAULT_THREAD_TITLE_MODEL.to_string()),
|
||||
model_reasoning_summary: turn_context.reasoning_summary,
|
||||
service_tier: turn_context.config.service_tier,
|
||||
session_telemetry: turn_context.session_telemetry.clone(),
|
||||
turn_metadata_header: turn_context.turn_metadata_state.current_header_value(),
|
||||
models_manager_config: turn_context.config.to_models_manager_config(),
|
||||
};
|
||||
tokio::spawn(async move {
|
||||
if let Err(err) = generate_and_set_thread_name(sess, title_request).await {
|
||||
debug!("auto thread name generation skipped or failed: {err:#}");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Claims the one allowed background-title attempt for this session.
|
||||
///
|
||||
/// A generated name is only useful for persisted, interactive sessions that still
|
||||
/// do not have a name. The name is checked again while committing it, so callers
|
||||
/// may rely on this method only as the spawn gate; it is not the authority that
|
||||
/// decides whether the generated value ultimately wins over `/rename`.
|
||||
async fn should_start_thread_name_generation(&self) -> bool {
|
||||
let (enabled, source_allows_generation, has_thread_name) = {
|
||||
let state = self.state.lock().await;
|
||||
let config = &state.session_configuration.original_config_do_not_use;
|
||||
(
|
||||
config.session_titles.enabled,
|
||||
session_source_allows_thread_name_generation(
|
||||
&state.session_configuration.session_source,
|
||||
),
|
||||
state.session_configuration.thread_name.is_some(),
|
||||
)
|
||||
};
|
||||
let persistence_enabled = self.services.rollout.lock().await.is_some();
|
||||
if !enabled || !source_allows_generation || has_thread_name || !persistence_enabled {
|
||||
return false;
|
||||
}
|
||||
|
||||
!self
|
||||
.thread_name_generation_started
|
||||
.swap(true, Ordering::AcqRel)
|
||||
}
|
||||
|
||||
pub(crate) async fn notify_background_event(
|
||||
&self,
|
||||
turn_context: &TurnContext,
|
||||
@@ -4832,6 +4948,316 @@ async fn submission_loop(sess: Arc<Session>, config: Arc<Config>, rx_sub: Receiv
|
||||
debug!("Agent loop exited");
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
enum SetThreadNameError {
|
||||
Empty,
|
||||
PersistenceDisabled,
|
||||
Persist(anyhow::Error),
|
||||
}
|
||||
|
||||
impl SetThreadNameError {
|
||||
fn to_error_event(&self) -> ErrorEvent {
|
||||
match self {
|
||||
Self::Empty => ErrorEvent {
|
||||
message: "Thread name cannot be empty.".to_string(),
|
||||
codex_error_info: Some(CodexErrorInfo::BadRequest),
|
||||
},
|
||||
Self::PersistenceDisabled => ErrorEvent {
|
||||
message: "Session persistence is disabled; cannot rename thread.".to_string(),
|
||||
codex_error_info: Some(CodexErrorInfo::Other),
|
||||
},
|
||||
Self::Persist(err) => ErrorEvent {
|
||||
message: format!("Failed to set thread name: {err}"),
|
||||
codex_error_info: Some(CodexErrorInfo::Other),
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
enum SetThreadNameMode {
|
||||
Always,
|
||||
IfUnset,
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
enum SetThreadNameOutcome {
|
||||
Updated,
|
||||
SkippedExisting,
|
||||
}
|
||||
|
||||
async fn persist_thread_name_update(
|
||||
sess: &Arc<Session>,
|
||||
event: ThreadNameUpdatedEvent,
|
||||
) -> std::result::Result<EventMsg, SetThreadNameError> {
|
||||
let msg = EventMsg::ThreadNameUpdated(event);
|
||||
let item = RolloutItem::EventMsg(msg.clone());
|
||||
let recorder = {
|
||||
let guard = sess.services.rollout.lock().await;
|
||||
guard.clone()
|
||||
}
|
||||
.ok_or(SetThreadNameError::PersistenceDisabled)?;
|
||||
recorder
|
||||
.persist()
|
||||
.await
|
||||
.map_err(|err| SetThreadNameError::Persist(err.into()))?;
|
||||
recorder
|
||||
.record_items(std::slice::from_ref(&item))
|
||||
.await
|
||||
.map_err(|err| SetThreadNameError::Persist(err.into()))?;
|
||||
recorder
|
||||
.flush()
|
||||
.await
|
||||
.map_err(|err| SetThreadNameError::Persist(err.into()))?;
|
||||
Ok(msg)
|
||||
}
|
||||
|
||||
/// Persists a thread name, updates session state, and notifies listeners as one ordered write.
|
||||
///
|
||||
/// Manual `/rename` passes [`SetThreadNameMode::Always`]. Generated titles pass
|
||||
/// [`SetThreadNameMode::IfUnset`] so the state under the write lock can veto a
|
||||
/// stale model result after the user has already named the thread.
|
||||
async fn commit_thread_name(
|
||||
sess: &Arc<Session>,
|
||||
sub_id: String,
|
||||
name: String,
|
||||
mode: SetThreadNameMode,
|
||||
) -> std::result::Result<SetThreadNameOutcome, SetThreadNameError> {
|
||||
let Some(name) = crate::util::normalize_thread_name(&name) else {
|
||||
return Err(SetThreadNameError::Empty);
|
||||
};
|
||||
|
||||
let _guard = sess.thread_name_write_lock.lock().await;
|
||||
|
||||
if matches!(mode, SetThreadNameMode::IfUnset) {
|
||||
let state = sess.state.lock().await;
|
||||
if state.session_configuration.thread_name.is_some() {
|
||||
return Ok(SetThreadNameOutcome::SkippedExisting);
|
||||
}
|
||||
}
|
||||
|
||||
let msg = persist_thread_name_update(
|
||||
sess,
|
||||
ThreadNameUpdatedEvent {
|
||||
thread_id: sess.conversation_id,
|
||||
thread_name: Some(name.clone()),
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
|
||||
if let Some(state_db) = sess.services.state_db.as_deref()
|
||||
&& let Err(err) = state_db
|
||||
.update_thread_title(sess.conversation_id, &name)
|
||||
.await
|
||||
{
|
||||
warn!("Failed to update thread title in state db: {err}");
|
||||
}
|
||||
|
||||
{
|
||||
let mut state = sess.state.lock().await;
|
||||
state.session_configuration.thread_name = Some(name.clone());
|
||||
}
|
||||
|
||||
let codex_home = sess.codex_home().await;
|
||||
if let Err(err) =
|
||||
crate::rollout::append_thread_name(&codex_home, sess.conversation_id, &name).await
|
||||
{
|
||||
warn!("Failed to update legacy thread name index: {err}");
|
||||
}
|
||||
|
||||
sess.deliver_event_raw(Event { id: sub_id, msg }).await;
|
||||
|
||||
Ok(SetThreadNameOutcome::Updated)
|
||||
}
|
||||
|
||||
/// Returns whether sessions from this entry point should be auto-titled from the first prompt.
|
||||
///
|
||||
/// Exec sessions and sub-agents usually have an enclosing task that names their
|
||||
/// purpose, and generating extra names for them would spend a model request
|
||||
/// without improving the interactive session list.
|
||||
fn session_source_allows_thread_name_generation(session_source: &SessionSource) -> bool {
|
||||
!matches!(
|
||||
session_source,
|
||||
SessionSource::Exec | SessionSource::SubAgent(_)
|
||||
)
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct ThreadNameOutput {
|
||||
title: String,
|
||||
}
|
||||
|
||||
struct ThreadNameGenerationRequest {
|
||||
sub_id: String,
|
||||
user_message: String,
|
||||
model_name: String,
|
||||
model_reasoning_summary: ReasoningSummaryConfig,
|
||||
service_tier: Option<ServiceTier>,
|
||||
session_telemetry: SessionTelemetry,
|
||||
turn_metadata_header: Option<String>,
|
||||
models_manager_config: ModelsManagerConfig,
|
||||
}
|
||||
|
||||
async fn generate_and_set_thread_name(
|
||||
sess: Arc<Session>,
|
||||
request: ThreadNameGenerationRequest,
|
||||
) -> anyhow::Result<SetThreadNameOutcome> {
|
||||
let model_info = sess
|
||||
.services
|
||||
.models_manager
|
||||
.get_model_info(&request.model_name, &request.models_manager_config)
|
||||
.await;
|
||||
let generated_name = generate_thread_name(sess.as_ref(), &request, &model_info).await?;
|
||||
let Some(generated_name) = sanitize_generated_thread_name(&generated_name) else {
|
||||
anyhow::bail!("generated thread name was empty after normalization");
|
||||
};
|
||||
|
||||
commit_thread_name(
|
||||
&sess,
|
||||
request.sub_id,
|
||||
generated_name,
|
||||
SetThreadNameMode::IfUnset,
|
||||
)
|
||||
.await
|
||||
.map_err(|err| anyhow::anyhow!("{err:?}"))
|
||||
}
|
||||
|
||||
/// Runs the title-only model request and returns the raw title candidate.
|
||||
///
|
||||
/// The response is requested as JSON, but older or non-conforming providers can
|
||||
/// still return text. Callers must sanitize the returned string before storing
|
||||
/// it; persisting this raw value could leak quotes, markdown, or an overlong
|
||||
/// summary into terminal titles and session pickers.
|
||||
async fn generate_thread_name(
|
||||
sess: &Session,
|
||||
request: &ThreadNameGenerationRequest,
|
||||
model_info: &ModelInfo,
|
||||
) -> anyhow::Result<String> {
|
||||
let prompt = Prompt {
|
||||
input: vec![ResponseItem::Message {
|
||||
id: None,
|
||||
role: "user".to_string(),
|
||||
content: vec![ContentItem::InputText {
|
||||
text: format!("User prompt:\n{}\n", request.user_message.trim()),
|
||||
}],
|
||||
end_turn: None,
|
||||
phase: None,
|
||||
}],
|
||||
tools: Vec::new(),
|
||||
parallel_tool_calls: false,
|
||||
base_instructions: BaseInstructions {
|
||||
text: THREAD_TITLE_PROMPT.to_string(),
|
||||
},
|
||||
personality: None,
|
||||
output_schema: Some(thread_name_output_schema()),
|
||||
};
|
||||
|
||||
let mut client_session = sess.services.model_client.new_session();
|
||||
let mut stream = client_session
|
||||
.stream(
|
||||
&prompt,
|
||||
model_info,
|
||||
&request.session_telemetry,
|
||||
Some(ReasoningEffortConfig::Low),
|
||||
request.model_reasoning_summary,
|
||||
request.service_tier,
|
||||
request.turn_metadata_header.as_deref(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
let mut result = String::new();
|
||||
while let Some(event) = stream.next().await.transpose()? {
|
||||
match event {
|
||||
ResponseEvent::OutputTextDelta(delta) => result.push_str(&delta),
|
||||
ResponseEvent::OutputItemDone(ResponseItem::Message { content, .. }) => {
|
||||
if result.is_empty()
|
||||
&& let Some(text) = compact::content_items_to_text(&content)
|
||||
{
|
||||
result.push_str(&text);
|
||||
}
|
||||
}
|
||||
ResponseEvent::Completed { .. } => break,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
let output = serde_json::from_str::<ThreadNameOutput>(&result)
|
||||
.map(|output| output.title)
|
||||
.unwrap_or(result);
|
||||
Ok(output)
|
||||
}
|
||||
|
||||
fn thread_name_output_schema() -> Value {
|
||||
serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"title": {
|
||||
"type": "string",
|
||||
"minLength": THREAD_TITLE_MIN_CHARS,
|
||||
"maxLength": THREAD_TITLE_MAX_CHARS,
|
||||
}
|
||||
},
|
||||
"required": ["title"],
|
||||
"additionalProperties": false
|
||||
})
|
||||
}
|
||||
|
||||
fn sanitize_generated_thread_name(name: &str) -> Option<String> {
|
||||
let first_line = name
|
||||
.replace("\r\n", "\n")
|
||||
.lines()
|
||||
.find(|line| !line.trim().is_empty())?
|
||||
.trim()
|
||||
.to_string();
|
||||
let prefixed = first_line.trim();
|
||||
let stripped_title = if prefixed
|
||||
.get(.."title".len())
|
||||
.is_some_and(|prefix| prefix.eq_ignore_ascii_case("title"))
|
||||
&& prefixed["title".len()..]
|
||||
.chars()
|
||||
.next()
|
||||
.is_some_and(|separator| separator == ':' || separator.is_whitespace())
|
||||
{
|
||||
prefixed["title".len()..].trim_start_matches(|ch: char| ch == ':' || ch.is_whitespace())
|
||||
} else {
|
||||
prefixed
|
||||
};
|
||||
let stripped = stripped_title
|
||||
.trim_matches(['"', '\'', '`', '“', '”', '‘', '’'])
|
||||
.split_whitespace()
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ")
|
||||
.trim_end_matches(['.', '?', '!'])
|
||||
.trim()
|
||||
.to_string();
|
||||
let stripped = stripped.as_str();
|
||||
let char_count = stripped.chars().count();
|
||||
if char_count < THREAD_TITLE_MIN_CHARS {
|
||||
return None;
|
||||
}
|
||||
if char_count > THREAD_TITLE_MAX_CHARS {
|
||||
let truncated = stripped
|
||||
.chars()
|
||||
.take(THREAD_TITLE_MAX_CHARS - 1)
|
||||
.collect::<String>()
|
||||
.trim_end()
|
||||
.to_string();
|
||||
return Some(format!("{truncated}…"));
|
||||
}
|
||||
Some(stripped.to_string())
|
||||
}
|
||||
|
||||
fn truncate_string_chars(value: &str, max_chars: usize) -> String {
|
||||
let mut chars = value.chars();
|
||||
let truncated = chars.by_ref().take(max_chars).collect::<String>();
|
||||
if chars.next().is_some() {
|
||||
truncated.trim_end().to_string()
|
||||
} else {
|
||||
value.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
fn submission_dispatch_span(sub: &Submission) -> tracing::Span {
|
||||
let op_name = sub.op.kind();
|
||||
let span_name = format!("op.dispatch.{op_name}");
|
||||
@@ -4902,7 +5328,6 @@ mod handlers {
|
||||
use codex_protocol::protocol::ReviewRequest;
|
||||
use codex_protocol::protocol::RolloutItem;
|
||||
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;
|
||||
@@ -5625,80 +6050,19 @@ mod handlers {
|
||||
.await;
|
||||
}
|
||||
|
||||
async fn persist_thread_name_update(
|
||||
sess: &Arc<Session>,
|
||||
event: ThreadNameUpdatedEvent,
|
||||
) -> anyhow::Result<EventMsg> {
|
||||
let msg = EventMsg::ThreadNameUpdated(event);
|
||||
let item = RolloutItem::EventMsg(msg.clone());
|
||||
let recorder = {
|
||||
let guard = sess.services.rollout.lock().await;
|
||||
guard.clone()
|
||||
}
|
||||
.ok_or_else(|| anyhow::anyhow!("Session persistence is disabled; cannot rename thread."))?;
|
||||
recorder.persist().await?;
|
||||
recorder.record_items(std::slice::from_ref(&item)).await?;
|
||||
recorder.flush().await?;
|
||||
Ok(msg)
|
||||
}
|
||||
|
||||
/// Persists the thread name in the rollout and state database, updates in-memory state, and
|
||||
/// emits a `ThreadNameUpdated` event on success.
|
||||
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 result =
|
||||
super::commit_thread_name(sess, sub_id.clone(), name, super::SetThreadNameMode::Always)
|
||||
.await;
|
||||
if let Err(err) = result {
|
||||
let event = Event {
|
||||
id: sub_id,
|
||||
msg: EventMsg::Error(ErrorEvent {
|
||||
message: "Thread name cannot be empty.".to_string(),
|
||||
codex_error_info: Some(CodexErrorInfo::BadRequest),
|
||||
}),
|
||||
msg: EventMsg::Error(err.to_error_event()),
|
||||
};
|
||||
sess.send_event_raw(event).await;
|
||||
return;
|
||||
};
|
||||
|
||||
let updated = ThreadNameUpdatedEvent {
|
||||
thread_id: sess.conversation_id,
|
||||
thread_name: Some(name.clone()),
|
||||
};
|
||||
|
||||
let msg = match persist_thread_name_update(sess, updated).await {
|
||||
Ok(msg) => msg,
|
||||
Err(err) => {
|
||||
warn!("Failed to persist thread name update to rollout: {err}");
|
||||
let event = Event {
|
||||
id: sub_id,
|
||||
msg: EventMsg::Error(ErrorEvent {
|
||||
message: err.to_string(),
|
||||
codex_error_info: Some(CodexErrorInfo::Other),
|
||||
}),
|
||||
};
|
||||
sess.send_event_raw(event).await;
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
if let Some(state_db) = sess.services.state_db.as_deref()
|
||||
&& let Err(err) = state_db
|
||||
.update_thread_title(sess.conversation_id, &name)
|
||||
.await
|
||||
{
|
||||
warn!("Failed to update thread title in state db: {err}");
|
||||
}
|
||||
|
||||
{
|
||||
let mut state = sess.state.lock().await;
|
||||
state.session_configuration.thread_name = Some(name.clone());
|
||||
}
|
||||
|
||||
let codex_home = sess.codex_home().await;
|
||||
if let Err(err) =
|
||||
crate::rollout::append_thread_name(&codex_home, sess.conversation_id, &name).await
|
||||
{
|
||||
warn!("Failed to update legacy thread name index: {err}");
|
||||
}
|
||||
|
||||
sess.deliver_event_raw(Event { id: sub_id, msg }).await;
|
||||
}
|
||||
|
||||
pub async fn shutdown(sess: &Arc<Session>, sub_id: String) -> bool {
|
||||
@@ -6192,6 +6556,8 @@ pub(crate) async fn run_turn(
|
||||
}
|
||||
sess.record_user_prompt_and_emit_turn_item(turn_context.as_ref(), &input, response_item)
|
||||
.await;
|
||||
sess.maybe_start_thread_name_generation(Arc::clone(&turn_context), &input)
|
||||
.await;
|
||||
user_prompt_submit_outcome.additional_contexts
|
||||
};
|
||||
sess.services
|
||||
|
||||
@@ -154,6 +154,48 @@ fn assistant_message(text: &str) -> ResponseItem {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generated_thread_names_are_sanitized_for_display() {
|
||||
assert_eq!(
|
||||
sanitize_generated_thread_name(" \"Investigate flaky test.\" ").as_deref(),
|
||||
Some("Investigate flaky test")
|
||||
);
|
||||
assert_eq!(
|
||||
sanitize_generated_thread_name("Fix TypeScript\nschema\tgeneration").as_deref(),
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
sanitize_generated_thread_name("TITLE Locate foo_bar creation?").as_deref(),
|
||||
Some("Locate foo_bar creation")
|
||||
);
|
||||
assert_eq!(
|
||||
sanitize_generated_thread_name("Title: Locate foo_bar creation?").as_deref(),
|
||||
Some("Locate foo_bar creation")
|
||||
);
|
||||
assert_eq!(
|
||||
sanitize_generated_thread_name("Refactor an especially noisy sidebar component").as_deref(),
|
||||
Some("Refactor an especially noisy sideba…")
|
||||
);
|
||||
assert_eq!(sanitize_generated_thread_name("Fix bug"), None);
|
||||
assert_eq!(sanitize_generated_thread_name(" \n\t "), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn thread_name_generation_skips_exec_and_subagents() {
|
||||
assert!(!session_source_allows_thread_name_generation(
|
||||
&SessionSource::Exec
|
||||
));
|
||||
assert!(!session_source_allows_thread_name_generation(
|
||||
&SessionSource::SubAgent(SubAgentSource::Review)
|
||||
));
|
||||
assert!(session_source_allows_thread_name_generation(
|
||||
&SessionSource::Cli
|
||||
));
|
||||
assert!(session_source_allows_thread_name_generation(
|
||||
&SessionSource::Mcp
|
||||
));
|
||||
}
|
||||
|
||||
fn skill_message(text: &str) -> ResponseItem {
|
||||
ResponseItem::Message {
|
||||
id: None,
|
||||
@@ -2949,6 +2991,8 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) {
|
||||
mailbox,
|
||||
mailbox_rx: Mutex::new(mailbox_rx),
|
||||
idle_pending_input: Mutex::new(Vec::new()),
|
||||
thread_name_generation_started: AtomicBool::new(false),
|
||||
thread_name_write_lock: Mutex::new(()),
|
||||
guardian_review_session: crate::guardian::GuardianReviewSessionManager::default(),
|
||||
services,
|
||||
js_repl,
|
||||
@@ -3794,6 +3838,8 @@ pub(crate) async fn make_session_and_context_with_dynamic_tools_and_rx(
|
||||
mailbox,
|
||||
mailbox_rx: Mutex::new(mailbox_rx),
|
||||
idle_pending_input: Mutex::new(Vec::new()),
|
||||
thread_name_generation_started: AtomicBool::new(false),
|
||||
thread_name_write_lock: Mutex::new(()),
|
||||
guardian_review_session: crate::guardian::GuardianReviewSessionManager::default(),
|
||||
services,
|
||||
js_repl,
|
||||
|
||||
@@ -38,6 +38,8 @@ use codex_config::types::NotificationCondition;
|
||||
use codex_config::types::NotificationMethod;
|
||||
use codex_config::types::Notifications;
|
||||
use codex_config::types::SandboxWorkspaceWrite;
|
||||
use codex_config::types::SessionTitlesConfig;
|
||||
use codex_config::types::SessionTitlesToml;
|
||||
use codex_config::types::SkillsConfig;
|
||||
use codex_config::types::ToolSuggestDiscoverableType;
|
||||
use codex_config::types::Tui;
|
||||
@@ -213,6 +215,35 @@ persistence = "none"
|
||||
history_no_persistence_cfg.history
|
||||
);
|
||||
|
||||
let session_titles = r#"
|
||||
[session_titles]
|
||||
enabled = false
|
||||
model = "gpt-5.4-mini"
|
||||
"#;
|
||||
let session_titles_cfg =
|
||||
toml::from_str::<ConfigToml>(session_titles).expect("TOML deserialization should succeed");
|
||||
assert_eq!(
|
||||
Some(SessionTitlesToml {
|
||||
enabled: Some(false),
|
||||
model: Some("gpt-5.4-mini".to_string()),
|
||||
}),
|
||||
session_titles_cfg.session_titles
|
||||
);
|
||||
|
||||
let config = Config::load_from_base_config_with_overrides(
|
||||
session_titles_cfg,
|
||||
ConfigOverrides::default(),
|
||||
tempdir().expect("tempdir").path().to_path_buf(),
|
||||
)
|
||||
.expect("load config from session title settings");
|
||||
assert_eq!(
|
||||
config.session_titles,
|
||||
SessionTitlesConfig {
|
||||
enabled: false,
|
||||
model: Some("gpt-5.4-mini".to_string()),
|
||||
}
|
||||
);
|
||||
|
||||
let memories = r#"
|
||||
[memories]
|
||||
no_memories_if_mcp_or_web_search = true
|
||||
@@ -4525,6 +4556,7 @@ fn test_precedence_fixture_with_o3_profile() -> std::io::Result<()> {
|
||||
agent_max_depth: DEFAULT_AGENT_MAX_DEPTH,
|
||||
agent_roles: BTreeMap::new(),
|
||||
memories: MemoriesConfig::default(),
|
||||
session_titles: SessionTitlesConfig::default(),
|
||||
agent_job_max_runtime_seconds: DEFAULT_AGENT_JOB_MAX_RUNTIME_SECONDS,
|
||||
codex_home: fixture.codex_home(),
|
||||
sqlite_home: fixture.codex_home(),
|
||||
@@ -4671,6 +4703,7 @@ fn test_precedence_fixture_with_gpt3_profile() -> std::io::Result<()> {
|
||||
agent_max_depth: DEFAULT_AGENT_MAX_DEPTH,
|
||||
agent_roles: BTreeMap::new(),
|
||||
memories: MemoriesConfig::default(),
|
||||
session_titles: SessionTitlesConfig::default(),
|
||||
agent_job_max_runtime_seconds: DEFAULT_AGENT_JOB_MAX_RUNTIME_SECONDS,
|
||||
codex_home: fixture.codex_home(),
|
||||
sqlite_home: fixture.codex_home(),
|
||||
@@ -4815,6 +4848,7 @@ fn test_precedence_fixture_with_zdr_profile() -> std::io::Result<()> {
|
||||
agent_max_depth: DEFAULT_AGENT_MAX_DEPTH,
|
||||
agent_roles: BTreeMap::new(),
|
||||
memories: MemoriesConfig::default(),
|
||||
session_titles: SessionTitlesConfig::default(),
|
||||
agent_job_max_runtime_seconds: DEFAULT_AGENT_JOB_MAX_RUNTIME_SECONDS,
|
||||
codex_home: fixture.codex_home(),
|
||||
sqlite_home: fixture.codex_home(),
|
||||
@@ -4945,6 +4979,7 @@ fn test_precedence_fixture_with_gpt5_profile() -> std::io::Result<()> {
|
||||
agent_max_depth: DEFAULT_AGENT_MAX_DEPTH,
|
||||
agent_roles: BTreeMap::new(),
|
||||
memories: MemoriesConfig::default(),
|
||||
session_titles: SessionTitlesConfig::default(),
|
||||
agent_job_max_runtime_seconds: DEFAULT_AGENT_JOB_MAX_RUNTIME_SECONDS,
|
||||
codex_home: fixture.codex_home(),
|
||||
sqlite_home: fixture.codex_home(),
|
||||
|
||||
@@ -42,6 +42,7 @@ use codex_config::types::OAuthCredentialsStoreMode;
|
||||
use codex_config::types::OtelConfig;
|
||||
use codex_config::types::OtelConfigToml;
|
||||
use codex_config::types::OtelExporterKind;
|
||||
use codex_config::types::SessionTitlesConfig;
|
||||
use codex_config::types::ShellEnvironmentPolicy;
|
||||
use codex_config::types::ToolSuggestConfig;
|
||||
use codex_config::types::ToolSuggestDiscoverable;
|
||||
@@ -396,6 +397,8 @@ pub struct Config {
|
||||
|
||||
/// Memories subsystem settings.
|
||||
pub memories: MemoriesConfig,
|
||||
/// Settings for default generated thread names.
|
||||
pub session_titles: SessionTitlesConfig,
|
||||
|
||||
/// Directory containing all Codex state (defaults to `~/.codex` but can be
|
||||
/// overridden by the `CODEX_HOME` environment variable).
|
||||
@@ -2042,6 +2045,7 @@ impl Config {
|
||||
agent_max_depth,
|
||||
agent_roles,
|
||||
memories: cfg.memories.unwrap_or_default().into(),
|
||||
session_titles: cfg.session_titles.unwrap_or_default().into(),
|
||||
agent_job_max_runtime_seconds,
|
||||
codex_home,
|
||||
sqlite_home,
|
||||
|
||||
@@ -6,8 +6,8 @@
|
||||
use super::*;
|
||||
|
||||
/// Items shown in the terminal title when the user has not configured a
|
||||
/// custom selection. Intentionally minimal: spinner + project name.
|
||||
pub(super) const DEFAULT_TERMINAL_TITLE_ITEMS: [&str; 2] = ["spinner", "project"];
|
||||
/// custom selection. Intentionally minimal: spinner + thread name + project name.
|
||||
pub(super) const DEFAULT_TERMINAL_TITLE_ITEMS: [&str; 3] = ["spinner", "thread", "project"];
|
||||
|
||||
/// Braille-pattern dot-spinner frames for the terminal title animation.
|
||||
pub(super) const TERMINAL_TITLE_SPINNER_FRAMES: [&str; 10] =
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
use super::*;
|
||||
use codex_protocol::protocol::ThreadNameUpdatedEvent;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
/// Receiving a TokenCount event without usage clears the context indicator.
|
||||
@@ -1140,6 +1141,56 @@ async fn terminal_title_model_updates_on_model_change_without_manual_refresh() {
|
||||
assert_eq!(chat.last_terminal_title, Some("gpt-5.3-codex".to_string()));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn default_terminal_title_omits_thread_until_available() {
|
||||
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
chat.config.cwd = test_project_path().abs();
|
||||
chat.refresh_terminal_title();
|
||||
|
||||
assert_eq!(chat.last_terminal_title, Some("project".to_string()));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn default_terminal_title_includes_thread_name_when_available() {
|
||||
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
chat.config.cwd = test_project_path().abs();
|
||||
let thread_id = ThreadId::new();
|
||||
chat.thread_id = Some(thread_id);
|
||||
chat.handle_codex_event(Event {
|
||||
id: "name-update".into(),
|
||||
msg: EventMsg::ThreadNameUpdated(ThreadNameUpdatedEvent {
|
||||
thread_id,
|
||||
thread_name: Some("Investigate generated terminal title".to_string()),
|
||||
}),
|
||||
});
|
||||
|
||||
assert_eq!(
|
||||
chat.last_terminal_title,
|
||||
Some("Investigate generated terminal title | project".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn default_terminal_title_truncates_long_thread_name() {
|
||||
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
chat.config.cwd = test_project_path().abs();
|
||||
let long_name = "Investigate generated terminal title that is definitely too long";
|
||||
let thread_id = ThreadId::new();
|
||||
chat.thread_id = Some(thread_id);
|
||||
chat.handle_codex_event(Event {
|
||||
id: "name-update".into(),
|
||||
msg: EventMsg::ThreadNameUpdated(ThreadNameUpdatedEvent {
|
||||
thread_id,
|
||||
thread_name: Some(long_name.to_string()),
|
||||
}),
|
||||
});
|
||||
|
||||
assert_eq!(
|
||||
chat.last_terminal_title,
|
||||
Some("Investigate generated terminal title that is ... | project".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn status_line_model_with_reasoning_updates_on_mode_switch_without_manual_refresh() {
|
||||
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.3-codex")).await;
|
||||
|
||||
@@ -72,6 +72,24 @@ back to these environment variables.
|
||||
|
||||
Codex stores "do not show again" flags for some UI prompts under the `[notice]` table.
|
||||
|
||||
## Session titles
|
||||
|
||||
Interactive sessions generate a short default thread name after the first user prompt. The generated name uses the same storage and update event as `/rename`, and `/rename` still wins if the user names the thread before the background title request commits.
|
||||
|
||||
Disable default title generation with:
|
||||
|
||||
```toml
|
||||
[session_titles]
|
||||
enabled = false
|
||||
```
|
||||
|
||||
Select the auxiliary model used for generated titles with:
|
||||
|
||||
```toml
|
||||
[session_titles]
|
||||
model = "gpt-5.4-mini"
|
||||
```
|
||||
|
||||
## Plan mode defaults
|
||||
|
||||
`plan_mode_reasoning_effort` lets you set a Plan-mode-specific default reasoning
|
||||
|
||||
Reference in New Issue
Block a user