mirror of
https://github.com/openai/codex.git
synced 2026-10-22 17:58:46 +00:00
## Description This PR makes new threads with `history_mode = "paginated"` persist `ItemCompleted(item: <turn_item>)` in their rollout JSONL file. Legacy threads keep persisting the existing legacy events. Because the format is selected per thread, a rollout is either legacy or paginated; we do not need to support mixed rollouts containing both representations. This PR depends on [#31473](https://github.com/openai/codex/pull/31473). ## Why Paginated thread history needs stable turn/item IDs and completed item snapshots so the later SQLite projector can materialize appended rollout JSONL without rebuilding the whole thread. Keeping the legacy persistence policy unchanged avoids changing historical rollouts or the readers that still consume them. ## What changed - Made rollout filtering history-mode aware. Paginated threads keep completed canonical `ItemCompleted` events and drop their redundant legacy projections; legacy threads keep the existing event set. - Made forks inherit the source thread history mode, so copied legacy history is never filtered as paginated. - Made paginated threads assign IDs to locally-created response items even when `Feature::ItemIds` is off, and reject streamed output items that arrive without server IDs. - Updated legacy turn replay, rollout list/search, and SQLite metadata extraction to understand completed canonical user-message items.
342 lines
10 KiB
Rust
342 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_response_sandbox_policy(
|
|
permission_profile: &codex_protocol::models::PermissionProfile,
|
|
cwd: &Path,
|
|
) -> codex_app_server_protocol::SandboxPolicy {
|
|
let sandbox_policy = codex_sandboxing::compatibility_sandbox_policy_for_permission_profile(
|
|
permission_profile,
|
|
cwd,
|
|
);
|
|
sandbox_policy.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: thread_response_sandbox_policy(
|
|
&config_snapshot.permission_profile,
|
|
config_snapshot.cwd().as_path(),
|
|
),
|
|
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 = thread_response_sandbox_policy(&permission_profile, cwd.as_path());
|
|
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,
|
|
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(),
|
|
thread_source: None,
|
|
git_info,
|
|
name: None,
|
|
turns: Vec::new(),
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
#[path = "thread_summary_tests.rs"]
|
|
mod thread_summary_tests;
|