mirror of
https://github.com/openai/codex.git
synced 2026-09-10 20:26:47 +00:00
## What changed - Add `thread/section/move` to atomically move a thread into, within, or out of a section. Threads can be inserted before an existing member or appended, and moves within a section preserve `sectionEnteredAt`. - Add `section_position` sorting to `thread/list`, with ascending order as its default, and expose `sectionEnteredAt` in thread responses. - Persist section positions and entry times in SQLite, including migration of existing section members into recency order. Section membership is no longer updated through `thread/metadata/update`. ## Testing - Cover section moves, reordering, pagination, persistence across restarts and rollout reconciliation, concurrent updates, and rank renumbering. GitOrigin-RevId: aec6d7ddedca5277029b5caf5c074975397e956c
335 lines
10 KiB
Rust
335 lines
10 KiB
Rust
use super::*;
|
|
#[cfg(test)]
|
|
use chrono::DateTime;
|
|
#[cfg(test)]
|
|
use chrono::Utc;
|
|
use codex_protocol::config_types::MultiAgentMode;
|
|
|
|
#[cfg(test)]
|
|
pub(crate) async fn read_summary_from_rollout(
|
|
path: &Path,
|
|
fallback_provider: &str,
|
|
) -> std::io::Result<ConversationSummary> {
|
|
let head = read_head_for_summary(path).await?;
|
|
|
|
let Some(first) = head.first() else {
|
|
return Err(IoError::other(format!(
|
|
"rollout at {} is empty",
|
|
path.display()
|
|
)));
|
|
};
|
|
|
|
let session_meta_line =
|
|
serde_json::from_value::<SessionMetaLine>(first.clone()).map_err(|_| {
|
|
IoError::other(format!(
|
|
"rollout at {} does not start with session metadata",
|
|
path.display()
|
|
))
|
|
})?;
|
|
let SessionMetaLine {
|
|
meta: session_meta,
|
|
git,
|
|
} = session_meta_line;
|
|
let mut session_meta = session_meta;
|
|
session_meta.source = with_thread_spawn_agent_metadata(
|
|
session_meta.source.clone(),
|
|
session_meta.agent_nickname.clone(),
|
|
session_meta.agent_role.clone(),
|
|
);
|
|
|
|
let created_at = if session_meta.timestamp.is_empty() {
|
|
None
|
|
} else {
|
|
Some(session_meta.timestamp.as_str())
|
|
};
|
|
let updated_at = read_updated_at(path, created_at).await;
|
|
if let Some(summary) = extract_conversation_summary(
|
|
path.to_path_buf(),
|
|
&head,
|
|
&session_meta,
|
|
git.as_ref(),
|
|
fallback_provider,
|
|
updated_at.clone(),
|
|
) {
|
|
return Ok(summary);
|
|
}
|
|
|
|
let timestamp = if session_meta.timestamp.is_empty() {
|
|
None
|
|
} else {
|
|
Some(session_meta.timestamp.clone())
|
|
};
|
|
let model_provider = session_meta
|
|
.model_provider
|
|
.clone()
|
|
.unwrap_or_else(|| fallback_provider.to_string());
|
|
let git_info = git.as_ref().map(map_git_info);
|
|
let updated_at = updated_at.or_else(|| timestamp.clone());
|
|
|
|
Ok(ConversationSummary {
|
|
conversation_id: session_meta.id,
|
|
timestamp,
|
|
updated_at,
|
|
path: path.to_path_buf(),
|
|
preview: String::new(),
|
|
model_provider,
|
|
cwd: session_meta.cwd,
|
|
cli_version: session_meta.cli_version,
|
|
source: session_meta.source,
|
|
git_info,
|
|
})
|
|
}
|
|
|
|
#[cfg(test)]
|
|
fn extract_conversation_summary(
|
|
path: PathBuf,
|
|
head: &[serde_json::Value],
|
|
session_meta: &SessionMeta,
|
|
git: Option<&CoreGitInfo>,
|
|
fallback_provider: &str,
|
|
updated_at: Option<String>,
|
|
) -> Option<ConversationSummary> {
|
|
let preview = head
|
|
.iter()
|
|
.filter_map(|value| serde_json::from_value::<ResponseItem>(value.clone()).ok())
|
|
.find_map(|item| match codex_core::parse_turn_item(&item) {
|
|
Some(TurnItem::UserMessage(user)) => Some(user.message()),
|
|
_ => None,
|
|
})?;
|
|
|
|
let preview = strip_user_message_prefix(preview.as_str());
|
|
|
|
let timestamp = if session_meta.timestamp.is_empty() {
|
|
None
|
|
} else {
|
|
Some(session_meta.timestamp.clone())
|
|
};
|
|
let conversation_id = session_meta.id;
|
|
let model_provider = session_meta
|
|
.model_provider
|
|
.clone()
|
|
.unwrap_or_else(|| fallback_provider.to_string());
|
|
let git_info = git.map(map_git_info);
|
|
let updated_at = updated_at.or_else(|| timestamp.clone());
|
|
|
|
Some(ConversationSummary {
|
|
conversation_id,
|
|
timestamp,
|
|
updated_at,
|
|
path,
|
|
preview: preview.to_string(),
|
|
model_provider,
|
|
cwd: session_meta.cwd.clone(),
|
|
cli_version: session_meta.cli_version.clone(),
|
|
source: session_meta.source.clone(),
|
|
git_info,
|
|
})
|
|
}
|
|
|
|
#[cfg(test)]
|
|
fn map_git_info(git_info: &CoreGitInfo) -> ConversationGitInfo {
|
|
ConversationGitInfo {
|
|
sha: git_info.commit_hash.as_ref().map(|sha| sha.0.clone()),
|
|
branch: git_info.branch.clone(),
|
|
origin_url: git_info.repository_url.clone(),
|
|
}
|
|
}
|
|
|
|
pub(super) fn with_thread_spawn_agent_metadata(
|
|
source: codex_protocol::protocol::SessionSource,
|
|
agent_nickname: Option<String>,
|
|
agent_role: Option<String>,
|
|
) -> codex_protocol::protocol::SessionSource {
|
|
if agent_nickname.is_none() && agent_role.is_none() {
|
|
return source;
|
|
}
|
|
|
|
match source {
|
|
codex_protocol::protocol::SessionSource::SubAgent(
|
|
codex_protocol::protocol::SubAgentSource::ThreadSpawn {
|
|
parent_thread_id,
|
|
depth,
|
|
agent_path,
|
|
agent_nickname: existing_agent_nickname,
|
|
agent_role: existing_agent_role,
|
|
},
|
|
) => codex_protocol::protocol::SessionSource::SubAgent(
|
|
codex_protocol::protocol::SubAgentSource::ThreadSpawn {
|
|
parent_thread_id,
|
|
depth,
|
|
agent_path,
|
|
agent_nickname: agent_nickname.or(existing_agent_nickname),
|
|
agent_role: agent_role.or(existing_agent_role),
|
|
},
|
|
),
|
|
_ => source,
|
|
}
|
|
}
|
|
|
|
pub(crate) fn thread_response_active_permission_profile(
|
|
active_permission_profile: Option<codex_protocol::models::ActivePermissionProfile>,
|
|
) -> Option<codex_app_server_protocol::ActivePermissionProfile> {
|
|
active_permission_profile.map(Into::into)
|
|
}
|
|
|
|
pub(crate) fn thread_settings_from_config_snapshot(
|
|
config_snapshot: &ThreadConfigSnapshot,
|
|
) -> ThreadSettings {
|
|
ThreadSettings {
|
|
cwd: config_snapshot.cwd().clone(),
|
|
approval_policy: config_snapshot.approval_policy.into(),
|
|
approvals_reviewer: config_snapshot.approvals_reviewer.into(),
|
|
sandbox_policy: config_snapshot.sandbox_policy().into(),
|
|
active_permission_profile: thread_response_active_permission_profile(
|
|
config_snapshot.active_permission_profile.clone(),
|
|
),
|
|
model: config_snapshot.model.clone(),
|
|
model_provider: config_snapshot.model_provider_id.clone(),
|
|
service_tier: config_snapshot.service_tier.clone(),
|
|
effort: config_snapshot.reasoning_effort.clone(),
|
|
summary: config_snapshot.reasoning_summary,
|
|
collaboration_mode: config_snapshot.collaboration_mode.clone(),
|
|
multi_agent_mode: MultiAgentMode::ExplicitRequestOnly,
|
|
personality: config_snapshot.personality,
|
|
}
|
|
}
|
|
|
|
pub(crate) fn thread_settings_from_core_snapshot(
|
|
snapshot: codex_protocol::protocol::ThreadSettingsSnapshot,
|
|
) -> ThreadSettings {
|
|
let codex_protocol::protocol::ThreadSettingsSnapshot {
|
|
model,
|
|
model_provider_id,
|
|
service_tier,
|
|
approval_policy,
|
|
approvals_reviewer,
|
|
permission_profile,
|
|
active_permission_profile,
|
|
cwd,
|
|
reasoning_effort,
|
|
reasoning_summary,
|
|
personality,
|
|
collaboration_mode,
|
|
} = snapshot;
|
|
let sandbox_policy = codex_sandboxing::compatibility_sandbox_policy_for_permission_profile(
|
|
&permission_profile,
|
|
cwd.as_path(),
|
|
)
|
|
.into();
|
|
ThreadSettings {
|
|
sandbox_policy,
|
|
cwd,
|
|
approval_policy: approval_policy.into(),
|
|
approvals_reviewer: approvals_reviewer.into(),
|
|
active_permission_profile: thread_response_active_permission_profile(
|
|
active_permission_profile,
|
|
),
|
|
model,
|
|
model_provider: model_provider_id,
|
|
service_tier,
|
|
effort: reasoning_effort,
|
|
summary: reasoning_summary,
|
|
collaboration_mode,
|
|
multi_agent_mode: MultiAgentMode::ExplicitRequestOnly,
|
|
personality,
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
fn parse_datetime(timestamp: Option<&str>) -> Option<DateTime<Utc>> {
|
|
timestamp.and_then(|ts| {
|
|
chrono::DateTime::parse_from_rfc3339(ts)
|
|
.ok()
|
|
.map(|dt| dt.with_timezone(&chrono::Utc))
|
|
})
|
|
}
|
|
|
|
#[cfg(test)]
|
|
async fn read_updated_at(path: &Path, created_at: Option<&str>) -> Option<String> {
|
|
let updated_at = tokio::fs::metadata(path)
|
|
.await
|
|
.ok()
|
|
.and_then(|meta| meta.modified().ok())
|
|
.map(|modified| {
|
|
let updated_at: DateTime<Utc> = modified.into();
|
|
updated_at.to_rfc3339_opts(SecondsFormat::Millis, true)
|
|
});
|
|
updated_at.or_else(|| created_at.map(str::to_string))
|
|
}
|
|
|
|
pub(super) fn thread_started_notification(mut thread: Thread) -> ThreadStartedNotification {
|
|
thread.turns.clear();
|
|
ThreadStartedNotification { thread }
|
|
}
|
|
|
|
#[cfg(test)]
|
|
pub(crate) fn summary_to_thread(
|
|
summary: ConversationSummary,
|
|
fallback_cwd: &AbsolutePathBuf,
|
|
) -> Thread {
|
|
let ConversationSummary {
|
|
conversation_id,
|
|
path,
|
|
preview,
|
|
timestamp,
|
|
updated_at,
|
|
model_provider,
|
|
cwd,
|
|
cli_version,
|
|
source,
|
|
git_info,
|
|
} = summary;
|
|
|
|
let created_at = parse_datetime(timestamp.as_deref());
|
|
let updated_at = parse_datetime(updated_at.as_deref()).or(created_at);
|
|
let git_info = git_info.map(|info| ApiGitInfo {
|
|
sha: info.sha,
|
|
branch: info.branch,
|
|
origin_url: info.origin_url,
|
|
});
|
|
let cwd =
|
|
AbsolutePathBuf::relative_to_current_dir(path_utils::normalize_for_native_workdir(cwd))
|
|
.unwrap_or_else(|err| {
|
|
warn!(
|
|
conversation_id = %conversation_id,
|
|
path = %path.display(),
|
|
"failed to normalize thread cwd while summarizing thread: {err}"
|
|
);
|
|
fallback_cwd.clone()
|
|
});
|
|
|
|
let thread_id = conversation_id.to_string();
|
|
Thread {
|
|
id: thread_id.clone(),
|
|
extra: None,
|
|
session_id: thread_id,
|
|
forked_from_id: None,
|
|
parent_thread_id: None,
|
|
preview,
|
|
ephemeral: false,
|
|
section: None,
|
|
section_entered_at: None,
|
|
history_mode: ThreadHistoryMode::Legacy,
|
|
model_provider,
|
|
created_at: created_at.map(|dt| dt.timestamp()).unwrap_or(0),
|
|
updated_at: updated_at.map(|dt| dt.timestamp()).unwrap_or(0),
|
|
recency_at: updated_at.map(|dt| dt.timestamp()),
|
|
status: ThreadStatus::NotLoaded,
|
|
path: (!path.as_os_str().is_empty()).then_some(path),
|
|
cwd,
|
|
cli_version,
|
|
agent_nickname: source.get_nickname(),
|
|
agent_role: source.get_agent_role(),
|
|
source: source.into(),
|
|
can_accept_direct_input: None,
|
|
thread_source: None,
|
|
git_info,
|
|
name: None,
|
|
turns: Vec::new(),
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
#[path = "thread_summary_tests.rs"]
|
|
mod thread_summary_tests;
|