Files
codex/codex-rs/app-server/src/request_processors/thread_summary.rs
T
Owen Lin 2342b2c2a6 feat(rollout): persist TurnItems for paginated thread rollouts (#30188)
## 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.
2026-07-08 19:55:03 -07:00

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;