Thread命名与摘要
给一个对话改名之后,为什么数据库里还有另一段 title?为什么 preview 仍是首条消息,分组位置却不会随新消息改变?这些字段最终都出现在列表附近,但它们的生产者、权威来源和更新时间不同。把它们都叫“会话标题”,就无法解释改名失败、旧标题回流和排序漂移。
本文从 thread/name/set 追踪到存储与客户端通知,再看追加历史如何派生展示摘要,最后展开 section 的整数位置分配。读者需要理解 Rust 的 Option、异步调用和基本 SQL。可先读ThreadList与分页了解列表查询,再读ThreadRead历史加载区分存储摘要和完整轮次。这里的“摘要”是列表元数据或兼容接口 ConversationSummary,不涉及模型压缩长对话的算法。
Thread 是逻辑对话,CodexThread 是加载到 Core 的运行对象,LiveThread 是其持久化入口。一个 Thread 没有运行对象时,仍可从存储修改名称。name 是显式展示名,preview 是预览文字,Legacy 的 title 兼有历史展示职责;Paginated 则将显式 name 与派生 title 分开。下面按各自生产链阅读。
1. 显式名称的写入
1.1 空白与身份
公开请求只接受 ID 和字符串,不接受 null 来删除名称。
源码文件:codex-rs/app-server-protocol/src/protocol/common.rs
相关函数/类型:ClientRequest::ThreadSetName;行号:566-570。
ThreadSetName => "thread/name/set" {
params: v2::ThreadSetNameParams,
serialization: thread_id(params.thread_id),
response: v2::ThreadSetNameResponse,
},源码文件:codex-rs/app-server-protocol/src/protocol/v2/thread.rs
相关函数/类型:ThreadSetNameParams / ThreadSetNameResponse;行号:753-756,768-768。
pub struct ThreadSetNameParams {
pub thread_id: String,
pub name: String,
}
// ...
pub struct ThreadSetNameResponse {}同一 Thread ID 的请求按注册的队列范围串行;ThreadSetNameResponse {} 不返回完整摘要。输入名如何处理,必须继续看业务函数,不能从 String 推断空串也有效。
源码文件:codex-rs/core/src/util.rs
相关函数/类型:normalize_thread_name;行号:102-109。
pub fn normalize_thread_name(name: &str) -> Option<String> {
let trimmed = name.trim();
if trimmed.is_empty() {
None
} else {
Some(trimmed.to_string())
}
}这个函数只去除首尾空白并拒绝空结果,没有截断、自动摘要或唯一名称约束。重名不会改变逻辑 Thread ID,调用方仍应使用 ID 定位对象。
源码文件:codex-rs/app-server/src/request_processors/thread_processor.rs
相关函数/类型:thread_set_name_response_inner;行号:1751-1783。
async fn thread_set_name_response_inner(
&self,
params: ThreadSetNameParams,
) -> Result<(ThreadSetNameResponse, Option<ThreadNameUpdatedNotification>), JSONRPCErrorError>
{
let ThreadSetNameParams { thread_id, name } = params;
let thread_id = ThreadId::from_string(&thread_id)
.map_err(|err| invalid_request(format!("invalid thread id: {err}")))?;
let Some(name) = codex_core::util::normalize_thread_name(&name) else {
return Err(invalid_request("thread name must not be empty"));
};
let _thread_list_state_permit = self.acquire_thread_list_state_permit().await?;
self.thread_manager
.update_thread_metadata(
thread_id,
StoreThreadMetadataPatch {
name: Some(Some(name.clone())),
..Default::default()
},
/*include_archived*/ false,
)
.await
.map_err(|err| core_thread_write_error("set thread name", err))?;
Ok((
ThreadSetNameResponse {},
Some(ThreadNameUpdatedNotification {
thread_id: thread_id.to_string(),
thread_name: Some(name),
}),
))
}输入校验在取得列表状态 permit 前完成,之后经 ThreadManager 写入 name: Some(Some(...))。双层 Option 是内部 patch 的三态表示:外层决定是否更新,内层决定值或清空;公开改名 API 这里只产生“写入非空值”。include_archived=false 传给持久入口,具体来源有效性仍由 Store 读取与更新路径检查。
1.2 冷热两条路
Core 将已加载和未加载对象汇合到存储契约。
源码文件:codex-rs/core/src/thread_manager.rs
相关函数/类型:update_thread_metadata;行号:793-838。
pub async fn update_thread_metadata(
&self,
thread_id: ThreadId,
patch: ThreadMetadataPatch,
include_archived: bool,
) -> CodexResult<StoredThread> {
if let Ok(thread) = self.get_thread(thread_id).await {
if thread.config_snapshot().await.ephemeral {
return Err(CodexErr::InvalidRequest(format!(
"ephemeral thread does not support metadata updates: {thread_id}"
)));
}
return thread
.update_thread_metadata(patch, include_archived)
.await
.map_err(|err| thread_store_metadata_update_error(thread_id, err));
}
let updated = self
.state
.thread_store
.update_thread_metadata(UpdateThreadMetadataParams {
thread_id,
patch,
include_archived,
})
.await
.map_err(|err| match err {
ThreadStoreError::ThreadNotFound { thread_id } => {
CodexErr::ThreadNotFound(thread_id)
}
err => thread_store_metadata_update_error(thread_id, err),
})?;
match updated {
Some(thread) => Ok(thread),
None => self
.state
.thread_store
.read_thread(ReadThreadParams {
thread_id,
include_archived,
include_history: false,
})
.await
.map_err(|err| thread_store_metadata_update_error(thread_id, err)),
}
}已加载 ephemeral 对象被明确拒绝,因为它没有这项持久元数据承诺。普通加载对象经过 CodexThread::update_thread_metadata,取得 Session 的 LiveThread;未加载对象直接调用 Store。Store 若把空更新返回为 None,manager 用一次读取补足“返回实际 StoredThread”的约定,不能把 None 当成 Thread 不存在。
LiveThread 写显式 patch 前先处理待落盘的派生元数据。
源码文件:codex-rs/thread-store/src/live_thread.rs
相关函数/类型:update_metadata;行号:343-364。
pub async fn update_metadata(
&self,
patch: ThreadMetadataPatch,
include_archived: bool,
) -> ThreadStoreResult<StoredThread> {
self.flush_pending_metadata_update().await?;
let updated = self
.thread_store
.update_thread_metadata(UpdateThreadMetadataParams {
thread_id: self.thread_id,
patch,
include_archived,
})
.await?;
match updated {
Some(thread) => Ok(thread),
None => {
self.read_thread(include_archived, /*include_history*/ false)
.await
}
}
}顺序是 pending metadata flush,再显式 patch,最后必要时 fallback read。假如先改名、再写此前积压的首条消息派生值,Legacy 的 title 更容易被旧观察覆盖;这里的顺序让显式修改发生在当前积压更新之后。它不是跨所有未来追加的永久锁,后文仍要区分两种历史模式的字段。
1.3 模式选择
LocalThreadStore 先判断此次 patch 是否需要识别历史模式,以及 SQLite 写入是否必须成功。
源码文件:codex-rs/thread-store/src/local/update_thread_metadata.rs
相关函数/类型:update_thread_metadata 的模式与写入要求;行号:66-103。
let staged_requires_rollout_compat = pending_patch
.as_ref()
.is_some_and(|patch| patch.memory_mode.is_some() || patch.git_info.is_some());
let requires_rollout_compat =
staged_requires_rollout_compat || requires_rollout_compatibility_update(&patch);
let has_explicit_metadata = patch.name.is_some() || requires_rollout_compat;
let history_mode = if has_explicit_metadata {
match live_writer::live_writer_parts(store, thread_id).await {
Ok((_recorder, _rollout_id, history_mode)) => Some(history_mode),
Err(ThreadStoreError::ThreadNotFound { .. }) => Some(
read_thread::read_thread(
store,
ReadThreadParams {
thread_id,
include_archived: params.include_archived,
include_history: false,
},
)
.await?
.history_mode,
),
Err(err) => return Err(err),
}
} else {
None
};
let paginated = matches!(history_mode, Some(ThreadHistoryMode::Paginated));
let require_sqlite_write =
pending_patch.is_some() || sqlite_write_failure_should_block(&patch) || paginated;
let mut updated = apply_metadata_update(
store,
thread_id,
patch.clone(),
params.include_archived,
require_sqlite_write,
history_mode,
)
.await?;名称修改会触发模式判定,优先从 live writer 取得模式,否则读取摘要。paginated 会强制 SQLite 成功,pending patch 也会提高写入要求。这个结果随后控制故障是否可被降级,不能一概说“SQLite 只是缓存,坏了仍可改名”。
源码文件:codex-rs/thread-store/src/local/update_thread_metadata.rs
相关函数/类型:apply_metadata_update 的显式名称写入;行号:492-520。
if let Some(name) = patch.name.as_ref() {
let history_mode = history_mode.ok_or_else(|| ThreadStoreError::Internal {
message: format!(
"thread history mode unavailable before name update: {thread_id}"
),
})?;
let updated = match history_mode {
ThreadHistoryMode::Legacy => {
state_db
.update_thread_title(thread_id, name.as_deref().unwrap_or_default())
.await
}
ThreadHistoryMode::Paginated => {
state_db
.update_thread_name(thread_id, name.as_deref())
.await
}
}
.map_err(|err| ThreadStoreError::Internal {
message: format!("failed to set thread name: {err}"),
})?;
if !updated {
return Err(ThreadStoreError::Internal {
message: format!(
"thread metadata unavailable before name update: {thread_id}"
),
});
}
}同样的内部 patch.name,Legacy 调用 update_thread_title,Paginated 调用 update_thread_name。数据库的两个 helper 分别执行 UPDATE threads SET title = ? 和 UPDATE threads SET name = ?。这两个字段的并存来自真实兼容边界,不是重复冗余数据。
2. 权威来源与降级
2.1 两种索引承诺
Paginated 更新 SQLite 后也尝试追加名称索引,但失败只记告警。
源码文件:codex-rs/thread-store/src/local/update_thread_metadata.rs
相关函数/类型:update_thread_metadata 的 Paginated 索引;行号:126-143。
if paginated {
// Paginated metadata lives in SQLite. Keep the name index update, then stop before the
// legacy SessionMeta compatibility path below.
if let Some(name) = patch.name.as_ref()
&& let Err(err) = append_thread_name(
store.config.codex_home.as_path(),
thread_id,
name.as_deref().unwrap_or_default(),
)
.await
{
warn!("failed to index paginated thread name for {thread_id}: {err}");
}
if pending_patch.is_some() {
remove_pending_thread_metadata(store, thread_id, &mut pending_metadata).await;
}
return Ok(updated);
}此处保留索引是为了仍需要名称检索的消费者;失败并不撤销已成功的 SQLite 名称。这个分支随后返回,不再进入 Legacy SessionMeta 兼容路径。改名无须给 Paginated 的历史日志追加一条伪对话消息。
Legacy 则在必要的物化与兼容处理后追加索引,并传播索引失败。
源码文件:codex-rs/thread-store/src/local/update_thread_metadata.rs
相关函数/类型:update_thread_metadata 的 Legacy 索引;行号:183-193。
if let Some(name) = name {
append_thread_name(
store.config.codex_home.as_path(),
thread_id,
&name.unwrap_or_default(),
)
.await
.map_err(|err| ThreadStoreError::Internal {
message: format!("failed to index thread name: {err}"),
})?;
}因此相同的 session_index.jsonl 不可写故障,会得到不同 RPC 结果。Legacy 在索引失败前已经可能改过 SQLite title,报错不保证“什么都没改”;Paginated 则能在 SQLite 已更新、索引失败时继续成功。
2.2 SQLite 失败
决定写入强度的辅助函数还区分显式 patch 与历史观察。
源码文件:codex-rs/thread-store/src/local/update_thread_metadata.rs
相关函数/类型:sqlite_write_failure_should_block;行号:651-659。
fn sqlite_write_failure_should_block(patch: &ThreadMetadataPatch) -> bool {
// Before live metadata sync moved above the rollout writer, SQLite sync failures for
// transcript-derived metadata, thread names, and memory-mode indexing were log-only. Keep that
// failure isolation so a corrupted optional state DB does not make JSONL transcript durability
// look broken. Explicit git-only updates still require SQLite because partial git patches need
// the existing SQLite value to preserve unspecified fields. Project updates always require
// SQLite because assignment only exists in the state database.
patch.project_id.is_some() || (patch.git_info.is_some() && !has_observed_metadata_facts(patch))
}显式 project 必须依赖 SQLite,显式 Git 部分更新也需要已有状态保留未指定字段;历史派生信息和 Legacy 名称则保留旧的故障隔离。名称仍会被外层 paginated 或 pending 条件提升为强制写入,不能只读这个 helper 的返回值就下结论。
SQLite 结果按以下分支处理,摘录从缺少 state DB 的分支开始。
源码文件:codex-rs/thread-store/src/local/update_thread_metadata.rs
相关函数/类型:apply_metadata_update 的错误分流;行号:544-560。
} else if require_sqlite_write {
Err(ThreadStoreError::Internal {
message: format!("sqlite state db unavailable for thread {thread_id}"),
})
} else {
Ok(())
};
match sqlite_write_result {
Ok(()) => {}
Err(err) if require_sqlite_write || !sqlite_write_error_is_best_effort(&err) => {
return Err(err);
}
Err(err) => {
warn!("state db update_thread_metadata failed for {thread_id}: {err}");
}
}只有允许尽力写入的 Internal 错误会记录 warning 后继续,非法来源等错误不在这一降级承诺内。文件日志、名称索引与 SQLite 并不存在包住所有步骤的共同事务;定位时要确认最后完成了哪一个写入面。
测试直接设置 Paginated 名称,然后写入不同的派生 title/preview,再把索引路径变成目录以制造追加失败。
源码文件:codex-rs/thread-store/src/local/update_thread_metadata.rs
相关函数/类型:paginated_name_updates_use_sqlite_without_rollout_writes;行号:1093-1139。
assert_eq!(thread.name.as_deref(), Some("Canonical paginated name"));
let metadata = runtime
.get_thread(thread_id)
.await
.expect("read metadata")
.expect("thread metadata");
assert_eq!(metadata.name.as_deref(), Some("Canonical paginated name"));
assert!(metadata.title.is_empty());
assert_eq!(
codex_rollout::find_thread_name_by_id(home.path(), &thread_id)
.await
.expect("find thread name")
.as_deref(),
Some("Canonical paginated name")
);
let thread = store
.update_thread_metadata(UpdateThreadMetadataParams {
thread_id,
patch: ThreadMetadataPatch {
title: Some("Derived first message".to_string()),
preview: Some("Derived first message".to_string()),
..Default::default()
},
include_archived: false,
})
.await
.expect("apply derived paginated metadata")
.expect("local store returns updated thread");
assert_eq!(thread.name.as_deref(), Some("Canonical paginated name"));
let session_index_path = home.path().join("session_index.jsonl");
std::fs::remove_file(&session_index_path).expect("remove session index");
std::fs::create_dir(&session_index_path).expect("block session index writes");
let thread = store
.update_thread_metadata(UpdateThreadMetadataParams {
thread_id,
patch: ThreadMetadataPatch {
name: Some(Some("Updated SQLite name".to_string())),
..Default::default()
},
include_archived: false,
})
.await
.expect("set paginated thread name with unavailable index")
.expect("local store returns updated thread");
assert_eq!(thread.name.as_deref(), Some("Updated SQLite name"));第一次检查数据库 name 与索引一致,第二次要求派生 title 不覆盖显式名称,第三次要求索引不可用时 SQLite 改名仍成功。测试还检查原 rollout 字节不变。它验证的是该模式下的权威分离,不能外推到 Legacy 的索引故障策略。
2.3 读取优先级
Store 单对象名称读取明确按模式分流。
源码文件:codex-rs/thread-store/src/local/read_thread.rs
相关函数/类型:thread_name_from_metadata;行号:407-426。
async fn thread_name_from_metadata(
store: &LocalThreadStore,
metadata: &ThreadMetadata,
history_mode: ThreadHistoryMode,
) -> Option<String> {
match history_mode {
ThreadHistoryMode::Paginated => sqlite_thread_name(metadata),
ThreadHistoryMode::Legacy => {
if let Some(title) = distinct_thread_metadata_title(metadata) {
Some(title)
} else {
find_thread_name_by_id(store.config.codex_home.as_path(), &metadata.id)
.await
.ok()
.flatten()
.filter(|name| !name.trim().is_empty())
}
}
}
}Paginated 只接受 SQLite name 的非空值;Legacy 先找有效的 distinct title,再查名称索引。索引查询失败在这里通过 .ok().flatten() 降级为空,与写入路径的错误处理不同。
源码文件:codex-rs/thread-store/src/local/helpers.rs
相关函数/类型:distinct_thread_metadata_title;行号:274-281。
pub(super) fn distinct_thread_metadata_title(metadata: &ThreadMetadata) -> Option<String> {
let title = metadata.title.trim();
if title.is_empty() || metadata.first_user_message.as_deref().map(str::trim) == Some(title) {
None
} else {
Some(title.to_string())
}
}Legacy 的 title 若等于首条用户消息,就被看作派生标题而非独立名称。注意比较对象是 first_user_message,不是任意一条最新消息。后续向摘要安装名字时还有 preview 相等的过滤。
源码文件:codex-rs/thread-store/src/local/helpers.rs
相关函数/类型:set_thread_name;行号:283-287。
pub(super) fn set_thread_name(thread: &mut StoredThread, name: String) {
if thread.history_mode == ThreadHistoryMode::Paginated || thread.preview.trim() != name.trim() {
thread.name = Some(name);
}
}Paginated 允许显式名称正好等于 preview;Legacy 会过滤这个重复展示。于是“同样输入一个名称”在两种模式下可能得到不同的 name 展示结果,而 preview 仍有可读文字。
批量读取还要避免从旧索引覆盖已有权威名称。
源码文件:codex-rs/thread-store/src/local/helpers.rs
相关函数/类型:resolve_thread_names;行号:237-272。
pub(super) async fn resolve_thread_names(
store: &LocalThreadStore,
thread_history_modes: &HashMap<ThreadId, ThreadHistoryMode>,
) -> HashMap<ThreadId, String> {
let mut names = HashMap::<ThreadId, String>::with_capacity(thread_history_modes.len());
let legacy_thread_ids = thread_history_modes
.iter()
.filter_map(|(&thread_id, &history_mode)| {
(history_mode == ThreadHistoryMode::Legacy).then_some(thread_id)
})
.collect::<HashSet<_>>();
if let Some(state_db_ctx) = store.state_db().await {
for (&thread_id, &history_mode) in thread_history_modes {
let Ok(Some(metadata)) = state_db_ctx.get_thread(thread_id).await else {
continue;
};
let name = match history_mode {
ThreadHistoryMode::Legacy => distinct_thread_metadata_title(&metadata),
ThreadHistoryMode::Paginated => sqlite_thread_name(&metadata),
};
if let Some(name) = name {
names.insert(thread_id, name);
}
}
}
if let Ok(legacy_names) =
find_thread_names_by_ids(store.config.codex_home.as_path(), &legacy_thread_ids).await
{
// Legacy titles remain authoritative when present; the index only fills
// names for threads whose SQLite title is still derived from the preview.
for (thread_id, name) in legacy_names {
names.entry(thread_id).or_insert(name);
}
}
names
}只有 Legacy ID 进入 index 补齐集合;SQLite 的 distinct title 先写入 names,索引用 entry(...).or_insert(...) 填缺项。这与单对象读取共同约束 read/list 的一致性,不能将索引当成所有模式下的最高优先级来源。
下面把读取选择画成决策图。
图中的分流不是客户端自行猜测;它发生在 Store 及摘要装配处。调查一个旧名称时,应先查 history_mode,再决定读取 SQLite 的哪个字段,最后才决定索引是否参与。
3. 名称索引与广播
3.1 追加日志
名称索引是 JSONL 更新日志,并非名称到唯一 Thread 的字典文件。
源码文件:codex-rs/rollout/src/session_index.rs
相关函数/类型:SessionIndexEntry 与索引锁;行号:21-29。
const SESSION_INDEX_FILE: &str = "session_index.jsonl";
static SESSION_INDEX_LOCK: LazyLock<Mutex<()>> = LazyLock::new(|| Mutex::new(()));
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct SessionIndexEntry {
pub id: ThreadId,
pub thread_name: String,
pub updated_at: String,
}id 定位逻辑 Thread,thread_name 保存此次名称,updated_at 是索引记录自己的时间。进程内静态 mutex 保护本进程的写入操作,不能把它描述为跨进程排他文件锁。
源码文件:codex-rs/rollout/src/session_index.rs
相关函数/类型:append_thread_name;行号:33-50。
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
}时间记录采用 RFC3339,时间格式化失败有字符串回退;读取当前名称的先后依据仍是日志位置,不是重新按这个字符串排序。
源码文件:codex-rs/rollout/src/session_index.rs
相关函数/类型:append_session_index_entry;行号:54-71。
pub async fn append_session_index_entry(
codex_home: &Path,
entry: &SessionIndexEntry,
) -> std::io::Result<()> {
let _guard = SESSION_INDEX_LOCK
.lock()
.map_err(|err| std::io::Error::other(err.to_string()))?;
let path = session_index_path(codex_home);
let mut file = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(&path)?;
let mut line = serde_json::to_string(entry).map_err(std::io::Error::other)?;
line.push('\n');
file.write_all(line.as_bytes())?;
file.flush()?;
Ok(())
}一次序列化结果加换行后写入 append 文件并 flush。这个 async 函数内部使用同步文件调用和同步 mutex;不能因为签名带 async 就推断 I/O 已自动放入独立线程。
3.2 单读与批读
按一个 ID 查询使用逆向扫描,并把扫描放进 blocking task。
源码文件:codex-rs/rollout/src/session_index.rs
相关函数/类型:find_thread_name_by_id;行号:108-121。
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))
}scan_index_from_end_by_id 委托通用逆向 JSONL 扫描器,命中该 ID 的最新记录即停止。这里取的是最后记录的 thread_name,是否为空由更上层消费者处理。
批量读取采用另一种成本模型。
源码文件:codex-rs/rollout/src/session_index.rs
相关函数/类型:find_thread_names_by_ids;行号:124-153。
pub async fn find_thread_names_by_ids(
codex_home: &Path,
thread_ids: &HashSet<ThreadId>,
) -> std::io::Result<HashMap<ThreadId, String>> {
let path = session_index_path(codex_home);
if thread_ids.is_empty() || !path.exists() {
return Ok(HashMap::new());
}
let file = tokio::fs::File::open(&path).await?;
let reader = tokio::io::BufReader::new(file);
let mut lines = reader.lines();
let mut names = HashMap::with_capacity(thread_ids.len());
while let Some(line) = lines.next_line().await? {
let trimmed = line.trim();
if trimmed.is_empty() {
continue;
}
let Ok(entry) = serde_json::from_str::<SessionIndexEntry>(trimmed) else {
continue;
};
let name = entry.thread_name.trim();
if !name.is_empty() && thread_ids.contains(&entry.id) {
names.insert(entry.id, name.to_string());
}
}
Ok(names)
}正向遍历时同 ID 的后续非空名称覆盖前值,一次扫描服务整批请求;坏 JSON 与空行跳过,空名称也不会插入。不能把这个实现误写成“对每个 ID 分别逆向查一次”。同时,底层空名称记录在单读和批读中的处理并不相同,不能假定它是全体消费者一致的 tombstone;公开改名入口已经拒绝空白名。
3.3 跨连接传播
显式改名外层在存储成功后排队响应,再广播名称通知。
源码文件:codex-rs/app-server/src/request_processors/thread_processor.rs
相关函数/类型:thread_set_name;行号:635-656。
pub(crate) async fn thread_set_name(
&self,
request_id: ConnectionRequestId,
params: ThreadSetNameParams,
) -> Result<Option<ClientResponsePayload>, JSONRPCErrorError> {
match self.thread_set_name_response_inner(params).await {
Ok((response, notification)) => {
self.outgoing
.send_response(request_id.clone(), response)
.await;
if let Some(notification) = notification {
self.outgoing
.send_server_notification(ServerNotification::ThreadNameUpdated(
notification,
))
.await;
}
Ok(None)
}
Err(error) => Err(error),
}
}无论对象是否加载,这个通知都由 App Server 产生,而非必须等某个 Core Turn 再转发。返回 None 表示分支已接管响应,通用请求层不应再次返回一个空结果。
下面的时序只画加载对象的成功路径。
广播意味着另一连接即使未提交这次请求,也能更新列表名称。出站顺序是服务器排队顺序,不代表 UI 已完成渲染。WebSocket 测试用两条连接验证真实消费者。
源码文件:codex-rs/app-server/tests/suite/v2/thread_name_websocket.rs
相关函数/类型:thread_name_updated_broadcasts_for_loaded_threads;行号:71-86。
let (rename_resp, ws1_notification) = read_response_and_notification_for_method(
&mut ws1,
/*id*/ 11,
"thread/name/updated",
)
.await?;
let _: ThreadSetNameResponse = to_response::<ThreadSetNameResponse>(rename_resp)?;
assert_thread_name_updated(ws1_notification, &conversation_id, renamed)?;
let ws2_notification =
read_notification_for_method(&mut ws2, "thread/name/updated").await?;
assert_thread_name_updated(ws2_notification, &conversation_id, renamed)?;
assert_legacy_thread_name(codex_home.path(), &conversation_id, renamed).await?;
assert_no_message(&mut ws1, Duration::from_millis(250)).await?;
assert_no_message(&mut ws2, Duration::from_millis(250)).await?;测试要求两条连接都收到匹配 ID 与新名称的通知,并检查 Legacy 持久名称;随后短时间内两者都不能再收到重复消息。另一个测试覆盖未加载 Thread。两者证明广播与冷热路径,不证明断线客户端可以离线补收每一次名称变更;重连后应读取当前摘要。
4. 摘要的生产者
4.1 首条内容
preview 不必是纯文本,空文字的图像或音频输入也能形成可见摘要。
源码文件:codex-rs/protocol/src/protocol.rs
相关函数/类型:user_message_preview;行号:2392-2409。
pub fn user_message_preview(user: &UserMessageEvent) -> Option<String> {
let message = strip_user_message_prefix(user.message.as_str());
if !message.is_empty() {
return Some(message.to_string());
}
if user
.images
.as_ref()
.is_some_and(|images| !images.is_empty())
|| !user.local_images.is_empty()
{
return Some("[Image]".to_string());
}
if user.audio.as_ref().is_some_and(|audio| !audio.is_empty()) || !user.local_audio.is_empty() {
return Some("[Audio]".to_string());
}
None
}先剥离用户消息前缀,有文字就返回文字;否则图像优先得到 [Image],再看音频得到 [Audio]。这是一段确定性的内容选择,没有向模型请求自动命名。
MetadataSync 分别记录是否已经看见 preview、首条用户内容和 title。
源码文件:codex-rs/thread-store/src/thread_metadata_sync.rs
相关函数/类型:observe_user_message;行号:315-333。
fn observe_user_message(&mut self, user: &UserMessageEvent, update: &mut ThreadMetadataPatch) {
if let Some(preview) = user_message_preview(user) {
if !self.first_user_message_seen {
self.first_user_message_seen = true;
update.first_user_message = Some(preview.clone());
}
if !self.preview_seen {
self.preview_seen = true;
update.preview = Some(preview);
}
}
if !self.title_seen {
let title = strip_user_message_prefix(user.message.as_str());
if !title.is_empty() {
self.title_seen = true;
update.title = Some(title.to_string());
}
}
}图片输入可以先填 preview 与 first_user_message,但由于正文为空,没有设置 title_seen。之后真正的文字消息仍可能填入 title。因此不能依赖 title == preview == first_user_message 作为不变量,也不能把“第一次设置”简化成“第一个事件都写满所有字段”。
goal 还有独立的 preview 来源。
源码文件:codex-rs/thread-store/src/thread_metadata_sync.rs
相关函数/类型:observe_items_with_update 的 goal 分支;行号:282-290。
RolloutItem::EventMsg(EventMsg::ThreadGoalUpdated(event)) => {
if !self.preview_seen {
let objective = event.goal.objective.trim();
if !objective.is_empty() {
self.preview_seen = true;
update.preview = Some(objective.to_string());
}
}
}只有尚无 preview 时才采用非空 objective;它不会同时把该 objective 写成用户消息。若 goal 比第一条用户输入先到,预览可以是目标,而 first_user_message 后来才出现。
4.2 观察与提交
派生元数据的状态所有者如下。
源码文件:codex-rs/thread-store/src/thread_metadata_sync.rs
相关函数/类型:ThreadMetadataSync / PendingThreadMetadataPatch;行号:34-50。
pub(crate) struct ThreadMetadataSync {
thread_id: ThreadId,
cwd_seen: bool,
preview_seen: bool,
first_user_message_seen: bool,
title_seen: bool,
pending_update: Option<ThreadMetadataPatch>,
pending_update_generation: u64,
last_touch_persisted_at: Option<Instant>,
defer_create_update_until_history_exists: bool,
defer_resume_update_until_append: bool,
}
pub(crate) struct PendingThreadMetadataPatch {
pub(crate) patch: ThreadMetadataPatch,
generation: u64,
}*_seen 控制首次观察,pending_update 保存待写 patch,generation 区分补丁批次,两个 defer 标记分别避免新对象过早持久化、以及 resume 只读过程中重新提交历史观察。时间节流只控制更新频度,不改变名称读取权威。
图中 pending patch 是提交快照,不能把一次成功写入理解成“清空其后所有观察”。generation 正是区分这些时间点的依据。
源码文件:codex-rs/thread-store/src/thread_metadata_sync.rs
相关函数/类型:mark_pending_update_applied;行号:150-157。
pub(crate) fn mark_pending_update_applied(&mut self, update: &PendingThreadMetadataPatch) {
if self.pending_update_generation == update.generation {
self.pending_update = None;
}
if update.patch.updated_at.is_some() {
self.last_touch_persisted_at = Some(Instant::now());
}
}LiveThread 在 Store 写入成功后才确认这一代 patch。
源码文件:codex-rs/thread-store/src/live_thread.rs
相关函数/类型:apply_pending_metadata_update;行号:397-416。
async fn apply_pending_metadata_update(
&self,
update: Option<crate::thread_metadata_sync::PendingThreadMetadataPatch>,
) -> ThreadStoreResult<()> {
let Some(update) = update else {
return Ok(());
};
self.thread_store
.update_thread_metadata(UpdateThreadMetadataParams {
thread_id: self.thread_id,
patch: update.patch.clone(),
include_archived: true,
})
.await?;
self.metadata_sync
.lock()
.await
.mark_pending_update_applied(&update);
Ok(())
}Store 返回错误时 ? 提前结束,没有调用 mark-applied;metadata_sync 锁不跨 Store I/O 持有,因此后续观察可以产生新 generation。只有当前 generation 与已写快照相等,才清除 pending。若等待持久化期间已有更新合并,旧快照成功不能擦掉新内容。updated_at 被实际提交时才更新节流时钟。
4.3 两种时间
追加路径同时处理 updated_at 与 recency_at,但触发条件不同。
源码文件:codex-rs/thread-store/src/thread_metadata_sync.rs
相关函数/类型:observe_appended_items;行号:159-192。
pub(crate) fn observe_appended_items(
&mut self,
items: &[RolloutItem],
) -> Option<PendingThreadMetadataPatch> {
self.defer_create_update_until_history_exists = false;
self.defer_resume_update_until_append = false;
let affects_metadata = items
.iter()
.any(codex_state::rollout_item_affects_thread_metadata);
let advances_recency = items
.iter()
.any(|item| matches!(item, RolloutItem::EventMsg(EventMsg::TurnStarted(_))));
let mut update = if affects_metadata {
self.observe_items(items)?
} else {
thread_updated_at_touch()
};
if advances_recency {
update.advance_recency_at = Some(Utc::now());
}
self.merge_pending_update(Some(update));
if !affects_metadata
&& !self
.pending_update
.as_ref()
.is_some_and(update_has_metadata_facts)
&& self.last_touch_persisted_at.is_some_and(|last_touch| {
Instant::now().duration_since(last_touch) < THREAD_UPDATED_AT_TOUCH_INTERVAL
})
{
return None;
}
self.take_pending_update()
}TurnStarted 推进 recency,普通不影响元数据的追加只提出 updated_at touch,且五秒内没有新事实时可合并等待。这个条件不能读成“所有元数据五秒才更新一次”:有元数据事实、首次写入和应提交的 pending 都有自己的路径。
section 手工位置不会被这里的时间更新重算。排序故障必须先判断当前列表按 recency、updated_at 还是 SectionPosition 查询;比较不同排序键下的列表结果没有意义。
5. 兼容摘要的边界
ConversationSummary 是兼容入口返回的元数据对象,不是一个名为 summary 的新持久字段。
源码文件:codex-rs/app-server/src/request_processors/thread_processor.rs
相关函数/类型:get_thread_summary_response_inner;行号:5123-5163。
async fn get_thread_summary_response_inner(
&self,
params: GetConversationSummaryParams,
) -> Result<GetConversationSummaryResponse, JSONRPCErrorError> {
let fallback_provider = self.config.model_provider_id.as_str();
let read_result = match params {
GetConversationSummaryParams::ThreadId { conversation_id } => self
.thread_store
.read_thread(StoreReadThreadParams {
thread_id: conversation_id,
include_archived: true,
include_history: false,
})
.await
.map_err(|err| conversation_summary_thread_id_read_error(conversation_id, err)),
GetConversationSummaryParams::RolloutPath { rollout_path } => {
let Some(local_thread_store) = self
.thread_store
.as_any()
.downcast_ref::<LocalThreadStore>()
else {
return Err(invalid_request(
"rollout path queries are only supported with the local thread store",
));
};
local_thread_store
.read_thread_by_rollout_path(
rollout_path.clone(),
/*include_archived*/ true,
/*include_history*/ false,
)
.await
.map_err(|err| conversation_summary_rollout_path_read_error(&rollout_path, err))
}
};
let stored_thread = read_result?;
let summary = summary_from_stored_thread(stored_thread, fallback_provider);
Ok(GetConversationSummaryResponse { summary })
}调用按 ID 或 rollout path 读取 Store 摘要,路径分支要求实际后端为 LocalThreadStore,并读取可用来源。它没有触发模型压缩、重新生成标题或请求完整 turns。出错映射取决于真实来源分支,不能按 UI 上同一个“摘要”词统一处理。
源码文件:codex-rs/app-server/src/request_processors/thread_processor.rs
相关函数/类型:summary_from_stored_thread;行号:5862-5903。
fn summary_from_stored_thread(
thread: StoredThread,
fallback_provider: &str,
) -> ConversationSummary {
let path = thread.rollout_path.unwrap_or_default();
let source = with_thread_spawn_agent_metadata(
thread.source,
thread.agent_nickname.clone(),
thread.agent_role.clone(),
);
let git_info = thread.git_info.map(|git| ConversationGitInfo {
sha: git.commit_hash.map(|sha| sha.0),
branch: git.branch,
origin_url: git.repository_url,
});
ConversationSummary {
conversation_id: thread.thread_id,
path,
preview: thread.preview,
// Preserve millisecond precision from the thread store so thread/list cursors
// round-trip the same ordering key used by pagination queries.
timestamp: Some(
thread
.created_at
.to_rfc3339_opts(SecondsFormat::Millis, true),
),
updated_at: Some(
thread
.updated_at
.to_rfc3339_opts(SecondsFormat::Millis, true),
),
model_provider: if thread.model_provider.is_empty() {
fallback_provider.to_string()
} else {
thread.model_provider
},
cwd: thread.cwd,
cli_version: thread.cli_version,
source,
git_info,
}
}返回字段包括 preview、提供方、cwd、Git 等,但没有显式 name 或 section。两个时间保留毫秒级 RFC3339 精度;如果在兼容序列化里截成秒,分页消费者就可能失去原排序键精度。pathless Store 在这条旧契约中使用默认空路径,这不等于新的 Thread API 也必须伪造本地文件。
| 视图 | 显示文字来源 | 身份与时间 | 需要完整历史吗 |
|---|---|---|---|
| V2 Thread 摘要 | Store 的 name、preview | Thread ID,秒级公开时间字段 | 不必 |
| 兼容 ConversationSummary | preview | conversation ID,毫秒 RFC3339 | 不必 |
| 模型历史 | ResponseItem 等上下文 | Turn/条目边界 | 取决于模型重建路径 |
这三类消费者共享同一个对话身份,但不能相互代替。改名后兼容 summary 的 preview 未变,不足以证明写入丢失。
6. 分组身份与位置
6.1 位置请求
section 是服务端保存的分组实体,移动请求不携带客户端自行计算的浮点 rank。
源码文件:codex-rs/app-server-protocol/src/protocol/v2/thread.rs
相关函数/类型:ThreadSectionMoveParams;行号:1040-1054。
pub struct ThreadSectionMoveParams {
/// Thread to move into, within, or out of a section.
pub thread_id: String,
/// Destination section, or `null` to remove the thread from its section.
#[serde(deserialize_with = "Option::deserialize")]
#[schemars(
required,
schema_with = "crate::protocol::serde_helpers::nullable_string_schema"
)]
#[ts(type = "string | null")]
pub section_id: Option<String>,
/// Existing thread to insert before; omission or null appends to the section.
#[ts(optional = nullable)]
pub before_thread_id: Option<String>,
}sectionId 是必填的可空字段:null 表示移出,缺省不是同一种输入。beforeThreadId 缺省或 null 表示追加到目标组末尾。这种约束来自反序列化定义,而非文档约定。
源码文件:codex-rs/app-server/src/request_processors/thread_processor.rs
相关函数/类型:thread_section_move;行号:667-707。
pub(crate) async fn thread_section_move(
&self,
params: ThreadSectionMoveParams,
) -> Result<Option<ClientResponsePayload>, JSONRPCErrorError> {
let ThreadSectionMoveParams {
thread_id,
section_id,
before_thread_id,
} = params;
let thread_uuid = ThreadId::from_string(&thread_id)
.map_err(|err| invalid_request(format!("invalid thread id: {err}")))?;
if section_id
.as_deref()
.is_some_and(|section| section.trim().is_empty())
{
return Err(invalid_request("sectionId must not be empty"));
}
if section_id.is_none() && before_thread_id.is_some() {
return Err(invalid_request(
"beforeThreadId requires a non-null sectionId",
));
}
let before_thread_uuid = before_thread_id
.map(|thread_id| {
ThreadId::from_string(&thread_id)
.map_err(|err| invalid_request(format!("invalid before thread id: {err}")))
})
.transpose()?;
{
let _thread_list_state_permit = self.acquire_thread_list_state_permit().await?;
self.thread_manager
.move_thread_to_section(thread_uuid, section_id.as_deref(), before_thread_uuid)
.await
.map_err(|err| core_thread_write_error("move thread in section", err))?;
}
Ok(Some(ClientResponsePayload::ThreadSectionMove(
ThreadSectionMoveResponse {},
)))
}空白 section ID 被拒绝,清空 section 时不能指定锚点;ID 都需解析为 ThreadId。真正的变更仍走 manager,并在列表状态 permit 内完成。持久层还会检查目标 section 是否存在、锚点是否在同组,不能把上层字符串校验当成全部合法性检查。
6.2 事务内移动
核心算法先拿写事务,读取当前位置后决定移出、同组重排或跨组移动。
源码文件:codex-rs/state/src/runtime/thread_section_order.rs
相关函数/类型:move_thread_to_section;行号:104-190。
pub async fn move_thread_to_section(
&self,
thread_id: ThreadId,
section: Option<&str>,
before_thread_id: Option<ThreadId>,
) -> anyhow::Result<bool> {
if section.is_none() && before_thread_id.is_some() {
return Err(anyhow::anyhow!(
"before thread cannot be specified without a section"
));
}
let mut tx = self.pool.begin_with("BEGIN IMMEDIATE").await?;
let thread_id = thread_id.to_string();
let current_section = sqlx::query_scalar::<_, Option<String>>(
"SELECT thread_section_id FROM threads WHERE id = ?",
)
.bind(&thread_id)
.fetch_optional(&mut *tx)
.await?;
let Some(current_section) = current_section else {
return Ok(false);
};
let Some(section) = section else {
sqlx::query(
"UPDATE threads SET thread_section_id = NULL, section_position = NULL, section_entered_at_ms = NULL WHERE id = ?",
)
.bind(&thread_id)
.execute(&mut *tx)
.await?;
tx.commit().await?;
return Ok(true);
};
if sqlx::query_scalar::<_, i64>("SELECT 1 FROM thread_sections WHERE id = ?")
.bind(section)
.fetch_optional(&mut *tx)
.await?
.is_none()
{
return Err(anyhow::anyhow!("section {section} does not exist"));
}
let before_thread_id = before_thread_id.map(|id| id.to_string());
if before_thread_id.as_deref() == Some(thread_id.as_str()) {
return Err(anyhow::anyhow!(
"thread {thread_id} cannot be moved before itself"
));
}
if let Some(before_thread_id) = before_thread_id.as_deref() {
let before_section = sqlx::query_scalar::<_, Option<String>>(
"SELECT thread_section_id FROM threads WHERE id = ?",
)
.bind(before_thread_id)
.fetch_optional(&mut *tx)
.await?;
if before_section.flatten().as_deref() != Some(section) {
return Err(anyhow::anyhow!(
"before thread {before_thread_id} is not in section {section}"
));
}
}
let position =
section_move_position(&mut tx, section, &thread_id, before_thread_id.as_deref())
.await?;
if current_section.as_deref() == Some(section) {
sqlx::query("UPDATE threads SET section_position = ? WHERE id = ?")
.bind(position)
.bind(&thread_id)
.execute(&mut *tx)
.await?;
} else {
sqlx::query(
"UPDATE threads SET thread_section_id = ?, section_position = ?, section_entered_at_ms = ? WHERE id = ?",
)
.bind(section)
.bind(position)
.bind(Utc::now().timestamp_millis())
.bind(&thread_id)
.execute(&mut *tx)
.await?;
}
tx.commit().await?;
Ok(true)
}BEGIN IMMEDIATE 在计算新位置前建立写事务,避免两个移动同时根据同一空隙作出决定。未知 Thread 返回 false;锚点等于自己或在其他组则失败。同组仅更新 section_position,跨组才同时写组 ID 与新的进入时间;移出则清空三个字段。
这些更新没有把 Thread 的 recency 当作手工顺序。用户将一条旧对话拖到组首,之后另一条对话产生新 Turn,不应自动改变这个显式位置关系。
列表消费者必须显式选用位置排序。
源码文件:codex-rs/app-server/src/request_processors/thread_processor.rs
相关函数/类型:thread_list_response_inner 排序键与默认方向;行号:2509-2520。
let store_sort_key = match sort_key.unwrap_or(ThreadSortKey::CreatedAt) {
ThreadSortKey::CreatedAt => StoreThreadSortKey::CreatedAt,
ThreadSortKey::UpdatedAt => StoreThreadSortKey::UpdatedAt,
ThreadSortKey::RecencyAt => StoreThreadSortKey::RecencyAt,
ThreadSortKey::SectionPosition => StoreThreadSortKey::SectionPosition,
};
let sort_direction = sort_direction.unwrap_or(match store_sort_key {
StoreThreadSortKey::SectionPosition => SortDirection::Asc,
StoreThreadSortKey::CreatedAt
| StoreThreadSortKey::UpdatedAt
| StoreThreadSortKey::RecencyAt => SortDirection::Desc,
});sortKey=SectionPosition 默认升序,其余键默认降序;完全不传 sortKey 仍是 CreatedAt。把 Thread 移进 section,并不意味着所有列表请求自动改为手工次序。SQL 层据此选择 threads.section_position,返回的列表顺序才消费到前面保存的 rank。
6.3 空隙分配
位置使用 i64,初始间隔常量 SECTION_POSITION_GAP 为 1,000,000。
源码文件:codex-rs/state/src/runtime/thread_section_order.rs
相关函数/类型:section_move_position;行号:193-253。
async fn section_move_position(
tx: &mut sqlx::Transaction<'_, Sqlite>,
section: &str,
thread_id: &str,
before_thread_id: Option<&str>,
) -> anyhow::Result<i64> {
let mut renumbered = false;
loop {
let position = if let Some(before_thread_id) = before_thread_id {
let upper = sqlx::query_scalar::<_, Option<i64>>(
"SELECT section_position FROM threads WHERE id = ? AND thread_section_id = ?",
)
.bind(before_thread_id)
.bind(section)
.fetch_optional(&mut **tx)
.await?
.flatten()
.ok_or_else(|| {
anyhow::anyhow!("before thread {before_thread_id} is not in section {section}")
})?;
let lower = sqlx::query_scalar::<_, Option<i64>>(
"SELECT MAX(section_position) FROM threads WHERE thread_section_id = ? AND section_position < ? AND id <> ?",
)
.bind(section)
.bind(upper)
.bind(thread_id)
.fetch_one(&mut **tx)
.await?;
match lower {
Some(lower) if i128::from(upper) - i128::from(lower) > 1 => Some(i64::try_from(
i128::from(lower) + (i128::from(upper) - i128::from(lower)) / 2,
)?),
Some(_) => None,
None if upper > 1 => Some(upper / 2),
None => None,
}
} else {
let max_position = sqlx::query_scalar::<_, Option<i64>>(
"SELECT MAX(section_position) FROM threads WHERE thread_section_id = ? AND id <> ?",
)
.bind(section)
.bind(thread_id)
.fetch_one(&mut **tx)
.await?;
max_position
.unwrap_or_default()
.checked_add(SECTION_POSITION_GAP)
};
if let Some(position) = position {
return Ok(position);
}
if renumbered {
return Err(anyhow::anyhow!(
"section {section} has no remaining thread positions"
));
}
renumber_section_positions(tx, section, Some(thread_id)).await?;
renumbered = true;
}
}插入锚点前,先找严格小于 upper 且排除当前 Thread 的最大 lower。有足够空隙就取整数中点;在最前面可取 upper 的一半;追加末尾则使用 checked_add(GAP)。差值计算先提升到 i128,避免 upper−lower 在 i64 中溢出。没有空间时只允许重排一次,再无空间则明确报错。
下面用真实算法中的位置说明重排触发点。
图中的循环只在当前事务内重算,不是发生冲突后重新发一次 API。被移动 Thread 不参与邻居 rank 重排,避免拿自身旧位置堵住插入空隙。
源码文件:codex-rs/state/src/runtime/thread_section_order.rs
相关函数/类型:renumber_section_positions;行号:255-280。
async fn renumber_section_positions(
tx: &mut sqlx::Transaction<'_, Sqlite>,
section: &str,
excluded_thread_id: Option<&str>,
) -> anyhow::Result<()> {
sqlx::query(
r#"
UPDATE threads
SET section_position = ranked.position
FROM (
SELECT id,
ROW_NUMBER() OVER (ORDER BY section_position ASC, id ASC) * ? AS position
FROM threads
WHERE thread_section_id = ? AND (? IS NULL OR id <> ?)
) AS ranked
WHERE threads.id = ranked.id
"#,
)
.bind(SECTION_POSITION_GAP)
.bind(section)
.bind(excluded_thread_id)
.bind(excluded_thread_id)
.execute(&mut **tx)
.await?;
Ok(())
}ROW_NUMBER() 按原 position、ID 排序,再乘 GAP 恢复稀疏空间。ID 是并列位置的稳定次序,避免同样数据每次重排出现不同结果。正常移动只分配一个位置;空间耗尽时需要更新该 section 的多个成员,不能承诺所有拖动成本都与分组大小无关。
测试先得到一百万、两百万、三百万位置;移动第三项到第二项前得到一百五十万,进入时间保持不变。随后人工把三个位置压成 1、2、3,强制走重排。
源码文件:codex-rs/state/src/runtime/thread_section_order_tests.rs
相关函数/类型:section_moves_preserve_entry_order_and_renumber_exhausted_ranks;行号:483-495,504-510,530-561。
assert_eq!(
initial
.iter()
.map(|thread| thread.section_position)
.collect::<Vec<_>>(),
vec![Some(1_000_000), Some(2_000_000), Some(3_000_000)]
);
assert!(
initial
.iter()
.all(|thread| thread.section_entered_at.is_some())
);
let original_entered_at = initial[2].section_entered_at;
// ...
runtime
.move_thread_to_section(third, Some(CUSTOM_THREAD_SECTION_ID), Some(second))
.await
.unwrap();
let moved = runtime.get_thread(third).await.unwrap().unwrap();
assert_eq!(moved.section_position, Some(1_500_000));
assert_eq!(moved.section_entered_at, original_entered_at);
// ...
for (thread_id, position) in [(first, 1_i64), (second, 2), (third, 3)] {
sqlx::query("UPDATE threads SET section_position = ? WHERE id = ?")
.bind(position)
.bind(thread_id.to_string())
.execute(runtime.pool.as_ref())
.await
.unwrap();
}
runtime
.move_thread_to_section(third, Some(CUSTOM_THREAD_SECTION_ID), Some(second))
.await
.unwrap();
let reordered = sqlx::query_scalar::<_, String>(
"SELECT id FROM threads WHERE thread_section_id = ? ORDER BY section_position, id",
)
.bind(CUSTOM_THREAD_SECTION_ID)
.fetch_all(runtime.pool.as_ref())
.await
.unwrap();
assert_eq!(
reordered,
vec![first.to_string(), third.to_string(), second.to_string()]
);
assert_eq!(
runtime
.get_thread(third)
.await
.unwrap()
.unwrap()
.section_position,
Some(1_500_000)
);最后 SQL 查询必须得到 first、third、second,third 重新位于 1,500,000。这个反例既检验最终次序,又确保算法确实经历没有空隙的分支。另有并发移动测试约束位置唯一性,跨重启集成测试约束服务端持久顺序;单测中的整数结果不应由客户端自行复制为另一个排序实现。
7. 分组更新与移除
7.1 稳定 ID
重命名 section 不替换其 ID;内建 Pinned 有额外限制。
源码文件:codex-rs/app-server/src/request_processors/thread_sections.rs
相关函数/类型:thread_section_update;行号:89-131。
pub(crate) async fn thread_section_update(
&self,
params: ThreadSectionUpdateParams,
) -> Result<Option<ClientResponsePayload>, JSONRPCErrorError> {
const OPERATION: &str = "threadSection/update";
self.ensure_thread_sections_supported(OPERATION)?;
let name = params.name.trim();
if name.is_empty() {
return Err(invalid_params("section name must not be empty"));
}
if params.section_id.trim().is_empty() {
return Err(invalid_params("sectionId must not be empty"));
}
if params.section_id == PINNED_THREAD_SECTION_ID {
return Err(invalid_params(
"the built-in pinned section cannot be renamed",
));
}
if let Some(Some(appearance)) = params.appearance.as_ref() {
validate_thread_section_appearance(appearance)?;
}
let section = self
.thread_store
.rename_thread_section(StoreRenameThreadSectionParams {
section_id: params.section_id.clone(),
name: name.to_string(),
appearance: params
.appearance
.map(|appearance| appearance.map(state_thread_section_appearance)),
})
.await
.map_err(|err| thread_section_store_error(OPERATION, err))?
.ok_or_else(|| {
invalid_params(format!("thread section not found: {}", params.section_id))
})?;
Ok(Some(
ThreadSectionUpdateResponse {
section: api_thread_section(section),
}
.into(),
))
}请求需要非空名称和 ID,Pinned 不能被重命名。appearance 使用双层 Option:省略保留、null 清空、对象替换。只有提供的非空对象进入值校验。
源码文件:codex-rs/app-server/src/request_processors/thread_sections.rs
相关函数/类型:validate_thread_section_appearance;行号:176-190。
fn validate_thread_section_appearance(
appearance: &ThreadSectionAppearance,
) -> Result<(), JSONRPCErrorError> {
for (field, value) in [
("icon", appearance.icon.as_ref()),
("color", appearance.color.as_ref()),
] {
if value.is_some_and(|value| value.len() > MAX_THREAD_SECTION_APPEARANCE_FIELD_BYTES) {
return Err(invalid_params(format!(
"section appearance {field} must not exceed {MAX_THREAD_SECTION_APPEARANCE_FIELD_BYTES} bytes"
)));
}
}
Ok(())
}限制是每个 appearance 字段最多 64 字节,不是 64 个 Unicode 字符,也没有在此枚举允许的图标或颜色名称。supports_thread_sections 为 false 时,处理器返回方法不可用;这项功能依赖后端能力,不能用前端隐藏分组代替服务端校验。
7.2 删除分组
删除 section 与Thread归档与删除中的删除 Thread 完全不同。
源码文件:codex-rs/state/migrations/0045_threads_section.sql
相关函数/类型:thread_sections 外键;行号:1-10。
CREATE TABLE thread_sections (
id TEXT PRIMARY KEY,
name TEXT NOT NULL
);
INSERT INTO thread_sections (id, name)
VALUES ('01984de2-8f74-7c91-a3b2-5c5e937cf318', 'Pinned');
ALTER TABLE threads ADD COLUMN thread_section_id TEXT
REFERENCES thread_sections(id) ON DELETE SET NULL;外键使用 ON DELETE SET NULL,删除组不级联删除成员对话。Pinned 在初始化迁移中以固定 ID 存在,应用层和 StateRuntime 均拒绝删除它。
源码文件:codex-rs/state/src/runtime/thread_sections.rs
相关函数/类型:delete_thread_section;行号:66-88。
pub async fn delete_thread_section(&self, id: &str) -> anyhow::Result<bool> {
if id == PINNED_THREAD_SECTION_ID {
anyhow::bail!("built-in pinned thread section cannot be deleted");
}
let mut tx = self.pool.begin_with("BEGIN IMMEDIATE").await?;
sqlx::query(
"UPDATE threads SET section_position = NULL, section_entered_at_ms = NULL WHERE thread_section_id = ?",
)
.bind(id)
.execute(&mut *tx)
.await?;
let deleted = sqlx::query("DELETE FROM thread_sections WHERE id = ?")
.bind(id)
.execute(&mut *tx)
.await?
.rows_affected()
> 0;
tx.commit().await?;
Ok(deleted)
}事务先清理成员 position 与 entered_at,再删除组行,外键负责置空 section ID。源码没有附加 archived=false 条件,所以归档成员也会解除分组;测试 deleting_custom_sections_unassigns_active_and_archived_members 特意覆盖两类成员。
下面按字段观察两个操作的不同影响。
section 的消失只改变列表组织方式,没有关闭 Core 运行对象或清除对话历史。调查“分组删除后找不到对话”时,应先取消列表的 section 过滤器,再确认 Thread 是否真的不存在。
8. 展示故障的定位
把以下现象分别落到生产者、权威存储和消费者,不要先通过重复改名修复表面症状。
| 现象 | 应追踪的条件 | 关键位置 |
|---|---|---|
| 改名后 preview 不变 | preview 与 name 是不同生产链 | observe_user_message、名称 patch |
| 名称与首条文字相同后不显示 name | Legacy 的 distinct 与重复预览过滤 | thread_name_from_metadata、set_thread_name |
| 索引追加失败但 RPC 成功 | 是否 Paginated、SQLite 是否已写成功 | update_thread_metadata 分流 |
| 一个查询看到旧名称,另一个没有 | 单读/批读、SQLite 权威及空索引项差异 | resolve_thread_names、find_thread_names_by_ids |
| 新 Turn 没把分组条目顶到最前 | 当前使用手工位置而非 recency | section_position 与列表排序键 |
| 拖动失败 | 锚点自己、跨组、未知 section 或无剩余位置 | move_thread_to_section |
以上逻辑不按操作系统改变字段优先级;本地索引与 SQLite 的可用性、路径权限和 I/O 失败仍由运行环境决定。跨进程索引写入、损坏文件恢复和后端更换不能仅凭本机集成测试作出全局承诺。
可在 Codex 仓库根目录运行以下测试,所有写入都在测试临时目录中发生。
just test --locked -p codex-app-server --test all \
-E 'test(suite::v2::thread_name_websocket::) or test(suite::v2::thread_sections::) or test(suite::v2::thread_metadata_update::) or test(paginated_thread_name_set_is_reflected_in_read_list_and_metadata_resume)'
just test --locked -p codex-thread-store --lib \
-E 'test(local::update_thread_metadata::tests)'
just test --locked -p codex-state --lib \
-E 'test(thread_section_order::tests) or test(thread_sections::tests)'
rg -n 'thread_set_name_response_inner|summary_from_stored_thread' \
codex-rs/app-server/src/request_processors/thread_processor.rs
rg -n 'section_move_position|renumber_section_positions' \
codex-rs/state/src/runtime/thread_section_order.rs尝试解释两个场景:先用纯图片建立对话,随后写入文字并设置显式名称,判断三个文字字段各自来源;再将三个 section 位置压成 1、2、3,把第三项移到第二项前,说明事务内何时重排、哪些时间字段保持不变。能够从输入走到 Store 再走到查询结果,才算区分了“标题写错”“摘要未变”和“排序键不同”。
