mirror of
https://github.com/openai/codex.git
synced 2026-10-31 19:28:49 +00:00
## What changed Add `CodexThread::replace_dynamic_tools` to replace the tool catalog without starting a turn. Updates apply to subsequent model steps and future turns while preserving in-flight calls; an empty list clears the catalog. Require host opt-in through `allow_persistent_dynamic_tool_updates` for persistent threads. Persist replacements before publishing them, restore the latest catalog on resume, and preserve replacements and explicit clears through compaction. Keep explicit resume overrides separate from the persisted catalog. ## Testing Add coverage for pending calls during replacement, replacement and clearing across restart, compaction recovery, resume overrides, submission ordering after caller cancellation, host opt-in, and persistence failures. GitOrigin-RevId: fd2634906895a9b72ae7892ddd9435dced45eccb
882 lines
35 KiB
Rust
882 lines
35 KiB
Rust
use crate::model::ThreadMetadata;
|
|
use codex_history::RolloutItem;
|
|
use codex_protocol::items::TurnItem;
|
|
use codex_protocol::models::ResponseItem;
|
|
use codex_protocol::protocol::EventMsg;
|
|
use codex_protocol::protocol::SessionMetaLine;
|
|
use codex_protocol::protocol::SessionSource;
|
|
use codex_protocol::protocol::SubAgentSource;
|
|
use codex_protocol::protocol::TurnContextItem;
|
|
use codex_protocol::protocol::UserMessageEvent;
|
|
use codex_protocol::protocol::strip_user_message_prefix;
|
|
use codex_protocol::protocol::user_message_preview;
|
|
use serde::Serialize;
|
|
use serde_json::Value;
|
|
|
|
pub const GUARDIAN_THREAD_TITLE: &str = "Guardian review";
|
|
pub const GUARDIAN_THREAD_PREVIEW: &str = "Approval review";
|
|
|
|
/// Identifies internal review threads whose user messages are synthetic approval prompts.
|
|
pub fn is_guardian_review_source(source: &SessionSource) -> bool {
|
|
matches!(
|
|
source,
|
|
SessionSource::SubAgent(SubAgentSource::Other(name)) if name == "guardian"
|
|
)
|
|
}
|
|
|
|
/// Apply a rollout item to the metadata structure.
|
|
pub fn apply_rollout_item(
|
|
metadata: &mut ThreadMetadata,
|
|
item: &RolloutItem,
|
|
default_provider: &str,
|
|
) {
|
|
match item {
|
|
RolloutItem::SessionMeta(meta_line) => apply_session_meta_from_item(metadata, meta_line),
|
|
RolloutItem::TurnContext(turn_ctx) => apply_turn_context(metadata, turn_ctx),
|
|
RolloutItem::EventMsg(event) => apply_event_msg(metadata, event),
|
|
RolloutItem::ResponseItem(item) => apply_response_item(metadata, &item.item),
|
|
RolloutItem::InterAgentCommunication(_)
|
|
| RolloutItem::InterAgentCommunicationMetadata { .. } => {}
|
|
RolloutItem::Compacted(_) => {}
|
|
RolloutItem::WorldState(_) => {}
|
|
RolloutItem::DynamicToolsUpdated { .. }
|
|
| RolloutItem::RetainedContext(_)
|
|
| RolloutItem::SecurityRiskScore(_) => {}
|
|
RolloutItem::RealtimeItem(_) => {}
|
|
RolloutItem::TokenUsageRecord(_) => {}
|
|
}
|
|
if metadata.model_provider.is_empty() {
|
|
metadata.model_provider = default_provider.to_string();
|
|
}
|
|
}
|
|
|
|
/// Return whether this rollout item can mutate thread metadata stored in SQLite.
|
|
pub fn rollout_item_affects_thread_metadata(item: &RolloutItem) -> bool {
|
|
match item {
|
|
RolloutItem::SessionMeta(_) | RolloutItem::TurnContext(_) => true,
|
|
RolloutItem::EventMsg(
|
|
EventMsg::TokenCount(_)
|
|
| EventMsg::UserMessage(_)
|
|
| EventMsg::ThreadGoalUpdated(_)
|
|
| EventMsg::ThreadSettingsApplied(_),
|
|
) => true,
|
|
RolloutItem::EventMsg(EventMsg::ItemCompleted(event))
|
|
if matches!(
|
|
event.item,
|
|
TurnItem::UserMessage(_) | TurnItem::FunctionCallOutput(_)
|
|
) =>
|
|
{
|
|
true
|
|
}
|
|
RolloutItem::EventMsg(_)
|
|
| RolloutItem::ResponseItem(_)
|
|
| RolloutItem::InterAgentCommunication(_)
|
|
| RolloutItem::InterAgentCommunicationMetadata { .. }
|
|
| RolloutItem::Compacted(_)
|
|
| RolloutItem::RealtimeItem(_)
|
|
| RolloutItem::RetainedContext(_)
|
|
| RolloutItem::DynamicToolsUpdated { .. }
|
|
| RolloutItem::SecurityRiskScore(_)
|
|
| RolloutItem::TokenUsageRecord(_)
|
|
| RolloutItem::WorldState(_) => false,
|
|
}
|
|
}
|
|
|
|
fn apply_session_meta_from_item(metadata: &mut ThreadMetadata, meta_line: &SessionMetaLine) {
|
|
if metadata.id != meta_line.meta.id {
|
|
// Ignore session_meta lines that don't match the canonical thread ID,
|
|
// e.g., forked rollouts that embed the source session metadata.
|
|
return;
|
|
}
|
|
metadata.creator_user_id = metadata
|
|
.creator_user_id
|
|
.take()
|
|
.or_else(|| meta_line.meta.creator_user_id.clone());
|
|
metadata.creator_account_id = metadata
|
|
.creator_account_id
|
|
.take()
|
|
.or_else(|| meta_line.meta.creator_account_id.clone());
|
|
metadata.id = meta_line.meta.id;
|
|
metadata.attached_thread_parent_id = meta_line
|
|
.meta
|
|
.forked_from_id
|
|
.filter(|_| meta_line.meta.attached_thread);
|
|
metadata.source = enum_to_string(&meta_line.meta.source);
|
|
if metadata.originator.is_none() && !meta_line.meta.originator.is_empty() {
|
|
metadata.originator = Some(meta_line.meta.originator.clone());
|
|
}
|
|
// Later SessionMeta lines do not redefine the canonical history_mode.
|
|
metadata.thread_source = meta_line.meta.thread_source.clone();
|
|
metadata.agent_nickname = meta_line.meta.agent_nickname.clone();
|
|
metadata.agent_role = meta_line.meta.agent_role.clone();
|
|
metadata.agent_path = meta_line.meta.agent_path.clone();
|
|
if let Some(provider) = meta_line.meta.model_provider.as_deref() {
|
|
metadata.model_provider = provider.to_string();
|
|
}
|
|
if !meta_line.meta.cli_version.is_empty() {
|
|
metadata.cli_version = meta_line.meta.cli_version.clone();
|
|
}
|
|
if !meta_line.meta.cwd.as_os_str().is_empty() {
|
|
metadata.cwd = meta_line.meta.cwd.clone();
|
|
}
|
|
if let Some(git) = meta_line.git.as_ref() {
|
|
metadata.git_sha = git.commit_hash.as_ref().map(|sha| sha.0.clone());
|
|
metadata.git_branch = git.branch.clone();
|
|
metadata.git_origin_url = git.repository_url.clone();
|
|
}
|
|
}
|
|
|
|
fn apply_turn_context(metadata: &mut ThreadMetadata, turn_ctx: &TurnContextItem) {
|
|
if metadata.cwd.as_os_str().is_empty() {
|
|
metadata.cwd = turn_ctx.cwd.clone().into_path_buf();
|
|
}
|
|
metadata.model = Some(turn_ctx.model.clone());
|
|
metadata.reasoning_effort = turn_ctx.effort.clone();
|
|
metadata.sandbox_policy =
|
|
serde_json::to_string(&turn_ctx.permission_profile()).unwrap_or_default();
|
|
metadata.approval_mode = enum_to_string(&turn_ctx.approval_policy);
|
|
}
|
|
|
|
fn apply_event_msg(metadata: &mut ThreadMetadata, event: &EventMsg) {
|
|
match event {
|
|
EventMsg::TokenCount(token_count) => {
|
|
if let Some(info) = token_count.info.as_ref() {
|
|
metadata.tokens_used = info.total_token_usage.total_tokens.max(0);
|
|
}
|
|
}
|
|
EventMsg::UserMessage(user) if !metadata_is_guardian_review(metadata) => {
|
|
apply_user_message(metadata, user);
|
|
}
|
|
EventMsg::ItemCompleted(event) => {
|
|
if let TurnItem::UserMessage(user) = &event.item
|
|
&& !metadata_is_guardian_review(metadata)
|
|
{
|
|
apply_user_message(metadata, &user.as_legacy_user_message_event());
|
|
} else if let TurnItem::FunctionCallOutput(output) = &event.item
|
|
&& !metadata_is_guardian_review(metadata)
|
|
{
|
|
// Delegated turns have no user message, but must still be discoverable.
|
|
let preview = crate::delegated_output_preview(output);
|
|
set_preview_if_empty(metadata, preview);
|
|
}
|
|
}
|
|
EventMsg::ThreadGoalUpdated(event) if !metadata_is_guardian_review(metadata) => {
|
|
let objective = event.goal.objective.trim();
|
|
if !objective.is_empty() {
|
|
set_preview_if_empty(metadata, Some(objective.to_string()));
|
|
}
|
|
}
|
|
EventMsg::ThreadSettingsApplied(event) => {
|
|
let settings = &event.thread_settings;
|
|
metadata.model = Some(settings.model.clone());
|
|
metadata.model_provider = settings.model_provider_id.clone();
|
|
metadata.reasoning_effort = settings.reasoning_effort.clone();
|
|
metadata.cwd = settings.cwd.clone().into_path_buf();
|
|
metadata.sandbox_policy =
|
|
serde_json::to_string(&settings.permission_profile).unwrap_or_default();
|
|
metadata.approval_mode = enum_to_string(&settings.approval_policy);
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
|
|
fn apply_response_item(_metadata: &mut ThreadMetadata, _item: &ResponseItem) {}
|
|
|
|
pub(crate) fn metadata_is_guardian_review(metadata: &ThreadMetadata) -> bool {
|
|
serde_json::from_str::<SessionSource>(metadata.source.as_str())
|
|
.as_ref()
|
|
.is_ok_and(is_guardian_review_source)
|
|
}
|
|
|
|
fn apply_user_message(metadata: &mut ThreadMetadata, user: &UserMessageEvent) {
|
|
let preview = user_message_preview(user);
|
|
if metadata.first_user_message.is_none() {
|
|
metadata.first_user_message = preview.clone();
|
|
}
|
|
set_preview_if_empty(metadata, preview);
|
|
if metadata.title.is_empty() {
|
|
let title = strip_user_message_prefix(user.message.as_str());
|
|
if !title.is_empty() {
|
|
metadata.title = title.to_string();
|
|
}
|
|
}
|
|
}
|
|
|
|
fn set_preview_if_empty(metadata: &mut ThreadMetadata, preview: Option<String>) {
|
|
if metadata.preview.is_none() {
|
|
metadata.preview = preview;
|
|
}
|
|
}
|
|
|
|
pub(crate) fn enum_to_string<T: Serialize>(value: &T) -> String {
|
|
match serde_json::to_value(value) {
|
|
Ok(Value::String(s)) => s,
|
|
Ok(other) => other.to_string(),
|
|
Err(_) => String::new(),
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::apply_rollout_item;
|
|
use super::rollout_item_affects_thread_metadata;
|
|
use crate::ThreadMetadataBuilder;
|
|
use crate::model::ThreadMetadata;
|
|
use chrono::DateTime;
|
|
use chrono::Utc;
|
|
use codex_history::RolloutItem;
|
|
use codex_protocol::ThreadId;
|
|
use codex_protocol::config_types::ApprovalsReviewer;
|
|
use codex_protocol::config_types::CollaborationMode;
|
|
use codex_protocol::config_types::ModeKind;
|
|
use codex_protocol::config_types::ReasoningSummary;
|
|
use codex_protocol::config_types::Settings;
|
|
use codex_protocol::items::TurnItem;
|
|
use codex_protocol::items::UserMessageItem;
|
|
use codex_protocol::models::ContentItem;
|
|
use codex_protocol::models::PermissionProfile;
|
|
use codex_protocol::models::ResponseItem;
|
|
use codex_protocol::openai_models::ReasoningEffort;
|
|
use codex_protocol::protocol::AskForApproval;
|
|
use codex_protocol::protocol::EventMsg;
|
|
use codex_protocol::protocol::ItemCompletedEvent;
|
|
use codex_protocol::protocol::SandboxPolicy;
|
|
use codex_protocol::protocol::SessionMeta;
|
|
use codex_protocol::protocol::SessionMetaLine;
|
|
use codex_protocol::protocol::SessionSource;
|
|
use codex_protocol::protocol::SubAgentSource;
|
|
use codex_protocol::protocol::ThreadGoal;
|
|
use codex_protocol::protocol::ThreadGoalStatus;
|
|
use codex_protocol::protocol::ThreadGoalUpdatedEvent;
|
|
use codex_protocol::protocol::ThreadHistoryMode;
|
|
use codex_protocol::protocol::ThreadSettingsAppliedEvent;
|
|
use codex_protocol::protocol::ThreadSettingsSnapshot;
|
|
use codex_protocol::protocol::TurnContextItem;
|
|
use codex_protocol::protocol::USER_MESSAGE_BEGIN;
|
|
use codex_protocol::protocol::UserMessageEvent;
|
|
use codex_protocol::user_input::UserInput;
|
|
|
|
use pretty_assertions::assert_eq;
|
|
use std::path::PathBuf;
|
|
use uuid::Uuid;
|
|
|
|
#[test]
|
|
fn response_item_user_messages_do_not_set_title_or_first_user_message() {
|
|
let mut metadata = metadata_for_test();
|
|
let item = RolloutItem::ResponseItem(
|
|
ResponseItem::Message {
|
|
status: None,
|
|
encrypted_content: None,
|
|
id: None,
|
|
role: "user".to_string(),
|
|
content: vec![ContentItem::InputText {
|
|
text: "hello from response item".to_string(),
|
|
}],
|
|
phase: None,
|
|
internal_chat_message_metadata_passthrough: None,
|
|
}
|
|
.into(),
|
|
);
|
|
|
|
apply_rollout_item(&mut metadata, &item, "test-provider");
|
|
|
|
assert_eq!(metadata.first_user_message, None);
|
|
assert_eq!(metadata.preview, None);
|
|
assert_eq!(metadata.title, "");
|
|
}
|
|
|
|
#[test]
|
|
fn event_msg_user_messages_set_title_and_first_user_message() {
|
|
let mut metadata = metadata_for_test();
|
|
let item = RolloutItem::EventMsg(EventMsg::UserMessage(UserMessageEvent {
|
|
client_id: None,
|
|
message: format!("{USER_MESSAGE_BEGIN} actual user request"),
|
|
images: Some(vec![]),
|
|
local_images: vec![],
|
|
text_elements: vec![],
|
|
..Default::default()
|
|
}));
|
|
|
|
apply_rollout_item(&mut metadata, &item, "test-provider");
|
|
|
|
assert_eq!(
|
|
metadata.first_user_message.as_deref(),
|
|
Some("actual user request")
|
|
);
|
|
assert_eq!(metadata.preview.as_deref(), Some("actual user request"));
|
|
assert_eq!(metadata.title, "actual user request");
|
|
}
|
|
|
|
#[test]
|
|
fn delegated_turn_preview_preserves_user_message_metadata() {
|
|
let mut metadata = metadata_for_test();
|
|
let mut expected = metadata.clone();
|
|
for text in [" ", "delegated task", "later output"] {
|
|
let item = RolloutItem::EventMsg(EventMsg::ItemCompleted(ItemCompletedEvent {
|
|
thread_id: metadata.id,
|
|
turn_id: "turn-1".to_string(),
|
|
item: TurnItem::FunctionCallOutput(codex_protocol::items::FunctionCallOutputItem {
|
|
id: "output-1".to_string(),
|
|
name: "create_thread".to_string(),
|
|
namespace: Some("codex_app".to_string()),
|
|
output: codex_protocol::models::FunctionCallOutputBody::Text(text.to_string()),
|
|
}),
|
|
started_at_ms: Some(0),
|
|
completed_at_ms: 0,
|
|
}));
|
|
assert!(rollout_item_affects_thread_metadata(&item));
|
|
apply_rollout_item(&mut metadata, &item, "test-provider");
|
|
if text == "delegated task" {
|
|
expected.preview = Some(text.to_string());
|
|
}
|
|
assert_eq!(metadata, expected);
|
|
}
|
|
let user_item = RolloutItem::EventMsg(EventMsg::UserMessage(UserMessageEvent {
|
|
message: "actual user follow-up".to_string(),
|
|
..Default::default()
|
|
}));
|
|
apply_rollout_item(&mut metadata, &user_item, "test-provider");
|
|
expected.first_user_message = Some("actual user follow-up".to_string());
|
|
expected.title = "actual user follow-up".to_string();
|
|
assert_eq!(metadata, expected);
|
|
}
|
|
|
|
#[test]
|
|
fn completed_user_message_items_set_title_and_first_user_message() {
|
|
let mut metadata = metadata_for_test();
|
|
let item = RolloutItem::EventMsg(EventMsg::ItemCompleted(ItemCompletedEvent {
|
|
thread_id: ThreadId::default(),
|
|
turn_id: "turn-1".to_string(),
|
|
item: TurnItem::UserMessage(UserMessageItem::new(&[UserInput::Text {
|
|
text: format!("{USER_MESSAGE_BEGIN} actual user request"),
|
|
text_elements: Vec::new(),
|
|
}])),
|
|
started_at_ms: Some(0),
|
|
completed_at_ms: 0,
|
|
}));
|
|
|
|
apply_rollout_item(&mut metadata, &item, "test-provider");
|
|
|
|
assert_eq!(
|
|
metadata.first_user_message.as_deref(),
|
|
Some("actual user request")
|
|
);
|
|
assert_eq!(metadata.preview.as_deref(), Some("actual user request"));
|
|
assert_eq!(metadata.title, "actual user request");
|
|
}
|
|
|
|
#[test]
|
|
fn guardian_projection_skips_prompts_and_preserves_custom_names() {
|
|
let thread_id = ThreadId::new();
|
|
let source = SessionSource::SubAgent(SubAgentSource::Other("guardian".to_string()));
|
|
let mut metadata =
|
|
ThreadMetadataBuilder::new(thread_id, PathBuf::new(), Utc::now(), source.clone())
|
|
.build("test-provider");
|
|
let session_meta = RolloutItem::SessionMeta(Box::new(SessionMetaLine {
|
|
meta: SessionMeta {
|
|
session_id: thread_id.into(),
|
|
id: thread_id,
|
|
source,
|
|
..Default::default()
|
|
},
|
|
git: None,
|
|
}));
|
|
apply_rollout_item(&mut metadata, &session_meta, "test-provider");
|
|
let mut expected = metadata.clone();
|
|
expected.title = super::GUARDIAN_THREAD_TITLE.to_string();
|
|
expected.name = None;
|
|
expected.preview = Some(super::GUARDIAN_THREAD_PREVIEW.to_string());
|
|
expected.first_user_message = None;
|
|
|
|
let user = UserMessageItem::new(&[UserInput::Text {
|
|
text: "large synthetic guardian prompt".to_string(),
|
|
text_elements: Vec::new(),
|
|
}]);
|
|
for event in [
|
|
EventMsg::UserMessage(user.as_legacy_user_message_event()),
|
|
EventMsg::ItemCompleted(ItemCompletedEvent {
|
|
thread_id,
|
|
turn_id: "turn-1".to_string(),
|
|
item: TurnItem::UserMessage(user),
|
|
started_at_ms: Some(0),
|
|
completed_at_ms: 0,
|
|
}),
|
|
] {
|
|
apply_rollout_item(
|
|
&mut metadata,
|
|
&RolloutItem::EventMsg(event),
|
|
"test-provider",
|
|
);
|
|
assert_eq!(metadata, expected);
|
|
}
|
|
|
|
let mut existing = metadata.clone();
|
|
existing.title = "Named Guardian review".to_string();
|
|
metadata.prefer_existing_explicit_title(&existing);
|
|
assert_eq!(metadata, existing);
|
|
|
|
metadata.name = Some("Named Guardian review".to_string());
|
|
let expected = metadata.clone();
|
|
apply_rollout_item(&mut metadata, &session_meta, "test-provider");
|
|
assert_eq!(metadata, expected);
|
|
}
|
|
|
|
#[test]
|
|
fn event_msg_image_only_user_message_sets_image_placeholder_preview() {
|
|
let mut metadata = metadata_for_test();
|
|
let item = RolloutItem::EventMsg(EventMsg::UserMessage(UserMessageEvent {
|
|
client_id: None,
|
|
message: String::new(),
|
|
images: Some(vec!["https://example.com/image.png".to_string()]),
|
|
local_images: vec![],
|
|
text_elements: vec![],
|
|
..Default::default()
|
|
}));
|
|
|
|
apply_rollout_item(&mut metadata, &item, "test-provider");
|
|
|
|
assert_eq!(metadata.first_user_message.as_deref(), Some("[Image]"));
|
|
assert_eq!(metadata.preview.as_deref(), Some("[Image]"));
|
|
assert_eq!(metadata.title, "");
|
|
}
|
|
|
|
#[test]
|
|
fn event_msg_audio_only_user_message_sets_audio_placeholder_preview() {
|
|
let mut metadata = metadata_for_test();
|
|
let item = RolloutItem::EventMsg(EventMsg::UserMessage(UserMessageEvent {
|
|
client_id: None,
|
|
message: String::new(),
|
|
images: None,
|
|
local_images: vec![],
|
|
audio: Some(vec!["https://example.com/audio.mp3".to_string()]),
|
|
local_audio: vec![],
|
|
text_elements: vec![],
|
|
..Default::default()
|
|
}));
|
|
|
|
apply_rollout_item(&mut metadata, &item, "test-provider");
|
|
|
|
assert_eq!(metadata.first_user_message.as_deref(), Some("[Audio]"));
|
|
assert_eq!(metadata.preview.as_deref(), Some("[Audio]"));
|
|
assert_eq!(metadata.title, "");
|
|
}
|
|
|
|
#[test]
|
|
fn event_msg_blank_user_message_without_images_keeps_first_user_message_empty() {
|
|
let mut metadata = metadata_for_test();
|
|
let item = RolloutItem::EventMsg(EventMsg::UserMessage(UserMessageEvent {
|
|
client_id: None,
|
|
message: " ".to_string(),
|
|
images: Some(vec![]),
|
|
local_images: vec![],
|
|
text_elements: vec![],
|
|
..Default::default()
|
|
}));
|
|
|
|
apply_rollout_item(&mut metadata, &item, "test-provider");
|
|
|
|
assert_eq!(metadata.first_user_message, None);
|
|
assert_eq!(metadata.preview, None);
|
|
assert_eq!(metadata.title, "");
|
|
}
|
|
|
|
#[test]
|
|
fn event_msg_thread_goal_sets_preview_only_and_later_user_sets_message_title() {
|
|
let mut metadata = metadata_for_test();
|
|
let goal_item =
|
|
RolloutItem::EventMsg(EventMsg::ThreadGoalUpdated(ThreadGoalUpdatedEvent {
|
|
thread_id: metadata.id,
|
|
turn_id: None,
|
|
goal: ThreadGoal {
|
|
thread_id: metadata.id,
|
|
objective: "optimize the benchmark".to_string(),
|
|
status: ThreadGoalStatus::Active,
|
|
token_budget: None,
|
|
tokens_used: 0,
|
|
time_used_seconds: 0,
|
|
created_at: 1,
|
|
updated_at: 1,
|
|
},
|
|
}));
|
|
|
|
apply_rollout_item(&mut metadata, &goal_item, "test-provider");
|
|
|
|
assert_eq!(metadata.preview.as_deref(), Some("optimize the benchmark"));
|
|
assert_eq!(metadata.first_user_message, None);
|
|
assert_eq!(metadata.title, "");
|
|
|
|
let user_item = RolloutItem::EventMsg(EventMsg::UserMessage(UserMessageEvent {
|
|
client_id: None,
|
|
message: format!("{USER_MESSAGE_BEGIN} next normal prompt"),
|
|
images: Some(vec![]),
|
|
local_images: vec![],
|
|
text_elements: vec![],
|
|
..Default::default()
|
|
}));
|
|
|
|
apply_rollout_item(&mut metadata, &user_item, "test-provider");
|
|
|
|
assert_eq!(metadata.preview.as_deref(), Some("optimize the benchmark"));
|
|
assert_eq!(
|
|
metadata.first_user_message.as_deref(),
|
|
Some("next normal prompt")
|
|
);
|
|
assert_eq!(metadata.title, "next normal prompt");
|
|
}
|
|
|
|
#[test]
|
|
fn turn_context_does_not_override_session_cwd() {
|
|
let mut metadata = metadata_for_test();
|
|
metadata.cwd = PathBuf::new();
|
|
let thread_id = metadata.id;
|
|
|
|
apply_rollout_item(
|
|
&mut metadata,
|
|
&RolloutItem::SessionMeta(Box::new(SessionMetaLine {
|
|
meta: SessionMeta {
|
|
creator_user_id: None,
|
|
attached_thread: false,
|
|
creator_account_id: None,
|
|
session_id: thread_id.into(),
|
|
id: thread_id,
|
|
forked_from_id: Some(
|
|
ThreadId::from_string(&Uuid::now_v7().to_string()).expect("thread id"),
|
|
),
|
|
forked_from_ordinal_exclusive: None,
|
|
parent_thread_id: None,
|
|
timestamp: "2026-02-26T00:00:00.000Z".to_string(),
|
|
cwd: PathBuf::from("/child/worktree"),
|
|
runtime_workspace_roots: None,
|
|
originator: "codex_cli_rs".to_string(),
|
|
cli_version: "0.0.0".to_string(),
|
|
source: SessionSource::Cli,
|
|
thread_source: None,
|
|
agent_path: None,
|
|
agent_nickname: None,
|
|
agent_role: None,
|
|
model_provider: Some("openai".to_string()),
|
|
base_instructions: None,
|
|
dynamic_tools: None,
|
|
selected_capability_roots: Vec::new(),
|
|
memory_mode: None,
|
|
history_mode: Default::default(),
|
|
history_base: None,
|
|
subagent_history_start_ordinal: None,
|
|
multi_agent_version: None,
|
|
context_window: None,
|
|
},
|
|
git: None,
|
|
})),
|
|
"test-provider",
|
|
);
|
|
apply_rollout_item(
|
|
&mut metadata,
|
|
&RolloutItem::TurnContext(TurnContextItem {
|
|
turn_id: Some("turn-1".to_string()),
|
|
root_turn_id: None,
|
|
disabled_plugin_ids: None,
|
|
cwd: serde_json::from_value(serde_json::json!(
|
|
std::env::current_dir()
|
|
.expect("current directory")
|
|
.join("parent/workspace")
|
|
))
|
|
.expect("absolute parent cwd"),
|
|
approval_policy: AskForApproval::Never,
|
|
approvals_reviewer: None,
|
|
sandbox_policy: SandboxPolicy::DangerFullAccess,
|
|
permission_profile: None,
|
|
active_permission_profile: None,
|
|
file_system_sandbox_policy: None,
|
|
model: "gpt-5".to_string(),
|
|
comp_hash: None,
|
|
collaboration_mode: None,
|
|
multi_agent_version: None,
|
|
realtime_active: None,
|
|
cyber_access_program: None,
|
|
effort: None,
|
|
summary: Some(codex_protocol::config_types::ReasoningSummary::Auto),
|
|
}),
|
|
"test-provider",
|
|
);
|
|
|
|
assert_eq!(metadata.cwd, PathBuf::from("/child/worktree"));
|
|
let permission_profile: PermissionProfile = PermissionProfile::Disabled;
|
|
assert_eq!(
|
|
metadata.sandbox_policy,
|
|
serde_json::to_string(&permission_profile).expect("serialize permission profile")
|
|
);
|
|
assert_eq!(metadata.approval_mode, "never");
|
|
}
|
|
|
|
#[test]
|
|
fn turn_context_sets_permission_profile_metadata() {
|
|
let mut metadata = metadata_for_test();
|
|
let permission_profile = PermissionProfile::workspace_write();
|
|
|
|
apply_rollout_item(
|
|
&mut metadata,
|
|
&RolloutItem::TurnContext(TurnContextItem {
|
|
turn_id: Some("turn-1".to_string()),
|
|
root_turn_id: None,
|
|
disabled_plugin_ids: None,
|
|
cwd: serde_json::from_value(serde_json::json!(
|
|
std::env::current_dir()
|
|
.expect("current directory")
|
|
.join("workspace")
|
|
))
|
|
.expect("absolute workspace cwd"),
|
|
approval_policy: AskForApproval::OnRequest,
|
|
approvals_reviewer: None,
|
|
sandbox_policy: SandboxPolicy::DangerFullAccess,
|
|
permission_profile: Some(permission_profile.clone()),
|
|
active_permission_profile: None,
|
|
file_system_sandbox_policy: None,
|
|
model: "gpt-5".to_string(),
|
|
comp_hash: None,
|
|
collaboration_mode: None,
|
|
multi_agent_version: None,
|
|
realtime_active: None,
|
|
cyber_access_program: None,
|
|
effort: None,
|
|
summary: Some(codex_protocol::config_types::ReasoningSummary::Auto),
|
|
}),
|
|
"test-provider",
|
|
);
|
|
|
|
assert_eq!(
|
|
metadata.sandbox_policy,
|
|
serde_json::to_string(&permission_profile).expect("serialize permission profile")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn turn_context_sets_cwd_when_session_cwd_missing() {
|
|
let mut metadata = metadata_for_test();
|
|
metadata.cwd = PathBuf::new();
|
|
let fallback_cwd = std::env::current_dir()
|
|
.expect("current directory")
|
|
.join("fallback/workspace");
|
|
|
|
apply_rollout_item(
|
|
&mut metadata,
|
|
&RolloutItem::TurnContext(TurnContextItem {
|
|
turn_id: Some("turn-1".to_string()),
|
|
root_turn_id: None,
|
|
disabled_plugin_ids: None,
|
|
cwd: serde_json::from_value(serde_json::json!(&fallback_cwd))
|
|
.expect("absolute fallback cwd"),
|
|
approval_policy: AskForApproval::OnRequest,
|
|
approvals_reviewer: None,
|
|
sandbox_policy: SandboxPolicy::new_read_only_policy(),
|
|
permission_profile: None,
|
|
active_permission_profile: None,
|
|
file_system_sandbox_policy: None,
|
|
model: "gpt-5".to_string(),
|
|
comp_hash: None,
|
|
collaboration_mode: None,
|
|
multi_agent_version: None,
|
|
realtime_active: None,
|
|
cyber_access_program: None,
|
|
effort: Some(ReasoningEffort::High),
|
|
summary: Some(codex_protocol::config_types::ReasoningSummary::Auto),
|
|
}),
|
|
"test-provider",
|
|
);
|
|
|
|
assert_eq!(metadata.cwd, fallback_cwd);
|
|
}
|
|
|
|
#[test]
|
|
fn turn_context_sets_model_and_reasoning_effort() {
|
|
let mut metadata = metadata_for_test();
|
|
|
|
apply_rollout_item(
|
|
&mut metadata,
|
|
&RolloutItem::TurnContext(TurnContextItem {
|
|
turn_id: Some("turn-1".to_string()),
|
|
root_turn_id: None,
|
|
disabled_plugin_ids: None,
|
|
cwd: serde_json::from_value(serde_json::json!(
|
|
std::env::current_dir()
|
|
.expect("current directory")
|
|
.join("fallback/workspace")
|
|
))
|
|
.expect("absolute fallback cwd"),
|
|
approval_policy: AskForApproval::OnRequest,
|
|
approvals_reviewer: None,
|
|
sandbox_policy: SandboxPolicy::new_read_only_policy(),
|
|
permission_profile: None,
|
|
active_permission_profile: None,
|
|
file_system_sandbox_policy: None,
|
|
model: "gpt-5".to_string(),
|
|
comp_hash: None,
|
|
collaboration_mode: None,
|
|
multi_agent_version: None,
|
|
realtime_active: None,
|
|
cyber_access_program: None,
|
|
effort: Some(ReasoningEffort::High),
|
|
summary: Some(codex_protocol::config_types::ReasoningSummary::Auto),
|
|
}),
|
|
"test-provider",
|
|
);
|
|
|
|
assert_eq!(metadata.model.as_deref(), Some("gpt-5"));
|
|
assert_eq!(metadata.reasoning_effort, Some(ReasoningEffort::High));
|
|
}
|
|
|
|
#[test]
|
|
fn thread_settings_applied_updates_resume_metadata() {
|
|
let mut metadata = metadata_for_test();
|
|
let permission_profile = PermissionProfile::workspace_write();
|
|
let cwd = std::env::current_dir()
|
|
.expect("current directory")
|
|
.join("updated/workspace");
|
|
let item = RolloutItem::EventMsg(EventMsg::ThreadSettingsApplied(
|
|
ThreadSettingsAppliedEvent {
|
|
thread_id: None,
|
|
thread_settings: ThreadSettingsSnapshot {
|
|
disabled_plugin_ids: Vec::new(),
|
|
model: "gpt-5.2-codex".to_string(),
|
|
model_provider_id: "updated-provider".to_string(),
|
|
service_tier: None,
|
|
approval_policy: AskForApproval::Never,
|
|
approvals_reviewer: ApprovalsReviewer::User,
|
|
permission_profile: permission_profile.clone(),
|
|
active_permission_profile: None,
|
|
cwd: cwd.clone().try_into().expect("absolute settings cwd"),
|
|
runtime_workspace_roots: None,
|
|
reasoning_effort: Some(ReasoningEffort::Ultra),
|
|
reasoning_summary: Some(ReasoningSummary::Auto),
|
|
personality: None,
|
|
collaboration_mode: CollaborationMode {
|
|
mode: ModeKind::Default,
|
|
settings: Settings {
|
|
model: "gpt-5.2-codex".to_string(),
|
|
reasoning_effort: Some(ReasoningEffort::Ultra),
|
|
developer_instructions: None,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
));
|
|
|
|
assert!(rollout_item_affects_thread_metadata(&item));
|
|
apply_rollout_item(&mut metadata, &item, "test-provider");
|
|
|
|
assert_eq!(metadata.model.as_deref(), Some("gpt-5.2-codex"));
|
|
assert_eq!(metadata.model_provider, "updated-provider");
|
|
assert_eq!(metadata.reasoning_effort, Some(ReasoningEffort::Ultra));
|
|
assert_eq!(metadata.cwd, cwd);
|
|
assert_eq!(metadata.approval_mode, "never");
|
|
assert_eq!(
|
|
metadata.sandbox_policy,
|
|
serde_json::to_string(&permission_profile)
|
|
.expect("permission profile should serialize")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn session_meta_does_not_set_model_or_reasoning_effort() {
|
|
let mut metadata = metadata_for_test();
|
|
metadata.history_mode = ThreadHistoryMode::Paginated;
|
|
let thread_id = metadata.id;
|
|
|
|
apply_rollout_item(
|
|
&mut metadata,
|
|
&RolloutItem::SessionMeta(Box::new(SessionMetaLine {
|
|
meta: SessionMeta {
|
|
creator_user_id: None,
|
|
attached_thread: false,
|
|
creator_account_id: None,
|
|
session_id: thread_id.into(),
|
|
id: thread_id,
|
|
forked_from_id: None,
|
|
forked_from_ordinal_exclusive: None,
|
|
parent_thread_id: None,
|
|
timestamp: "2026-02-26T00:00:00.000Z".to_string(),
|
|
cwd: PathBuf::from("/workspace"),
|
|
runtime_workspace_roots: None,
|
|
originator: "codex_cli_rs".to_string(),
|
|
cli_version: "0.0.0".to_string(),
|
|
source: SessionSource::Cli,
|
|
thread_source: None,
|
|
agent_path: None,
|
|
agent_nickname: None,
|
|
agent_role: None,
|
|
model_provider: Some("openai".to_string()),
|
|
base_instructions: None,
|
|
dynamic_tools: None,
|
|
selected_capability_roots: Vec::new(),
|
|
memory_mode: None,
|
|
history_mode: ThreadHistoryMode::Legacy,
|
|
history_base: None,
|
|
subagent_history_start_ordinal: None,
|
|
multi_agent_version: None,
|
|
context_window: None,
|
|
},
|
|
git: None,
|
|
})),
|
|
"test-provider",
|
|
);
|
|
|
|
assert_eq!(metadata.model, None);
|
|
assert_eq!(metadata.reasoning_effort, None);
|
|
assert_eq!(metadata.history_mode, ThreadHistoryMode::Paginated);
|
|
}
|
|
|
|
fn metadata_for_test() -> ThreadMetadata {
|
|
let id = ThreadId::from_string(&Uuid::from_u128(42).to_string()).expect("thread id");
|
|
let created_at = DateTime::<Utc>::from_timestamp(1_735_689_600, 0).expect("timestamp");
|
|
ThreadMetadata {
|
|
creator_user_id: None,
|
|
attached_thread_parent_id: None,
|
|
creator_account_id: None,
|
|
originator: None,
|
|
id,
|
|
rollout_path: PathBuf::from("/tmp/a.jsonl"),
|
|
created_at,
|
|
updated_at: created_at,
|
|
recency_at: created_at,
|
|
source: "cli".to_string(),
|
|
history_mode: Default::default(),
|
|
thread_source: None,
|
|
agent_path: None,
|
|
agent_nickname: None,
|
|
agent_role: None,
|
|
model_provider: "openai".to_string(),
|
|
model: None,
|
|
reasoning_effort: None,
|
|
cwd: PathBuf::from("/tmp"),
|
|
cli_version: "0.0.0".to_string(),
|
|
title: String::new(),
|
|
name: None,
|
|
preview: None,
|
|
sandbox_policy: "read-only".to_string(),
|
|
approval_mode: "on-request".to_string(),
|
|
tokens_used: 1,
|
|
first_user_message: None,
|
|
archived_at: None,
|
|
section: None,
|
|
section_position: None,
|
|
section_entered_at: None,
|
|
project_id: None,
|
|
daybreak_enabled: None,
|
|
git_sha: None,
|
|
git_branch: None,
|
|
git_origin_url: None,
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn diff_fields_detects_changes() {
|
|
let mut base = metadata_for_test();
|
|
base.id = ThreadId::from_string(&Uuid::now_v7().to_string()).expect("thread id");
|
|
base.title = "hello".to_string();
|
|
let mut other = base.clone();
|
|
other.tokens_used = 2;
|
|
other.title = "world".to_string();
|
|
let diffs = base.diff_fields(&other);
|
|
assert_eq!(diffs, vec!["title", "tokens_used"]);
|
|
}
|
|
}
|