Codex Handbook
state/src/extract.rs 562 lines
use crate::model::ThreadMetadata;use codex_protocol::models::ResponseItem;use codex_protocol::protocol::EventMsg;use codex_protocol::protocol::RolloutItem;use codex_protocol::protocol::SessionMetaLine;use codex_protocol::protocol::TurnContextItem;use codex_protocol::protocol::USER_MESSAGE_BEGIN;use codex_protocol::protocol::UserMessageEvent;use serde::Serialize;use serde_json::Value;const IMAGE_ONLY_USER_MESSAGE_PLACEHOLDER: &str = "[Image]";/// 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),        RolloutItem::InterAgentCommunication(_) => {}        RolloutItem::Compacted(_) => {}    }    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(_),        ) => true,        RolloutItem::EventMsg(_)        | RolloutItem::ResponseItem(_)        | RolloutItem::InterAgentCommunication(_)        | RolloutItem::Compacted(_) => 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.id = meta_line.meta.id;    metadata.source = enum_to_string(&meta_line.meta.source);    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();    }    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) => {            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();                }            }        }        EventMsg::ThreadGoalUpdated(event) => {            let objective = event.goal.objective.trim();            if !objective.is_empty() {                set_preview_if_empty(metadata, Some(objective.to_string()));            }        }        _ => {}    }}fn apply_response_item(_metadata: &mut ThreadMetadata, _item: &ResponseItem) {}fn set_preview_if_empty(metadata: &mut ThreadMetadata, preview: Option<String>) {    if metadata.preview.is_none() {        metadata.preview = preview;    }}fn strip_user_message_prefix(text: &str) -> &str {    match text.find(USER_MESSAGE_BEGIN) {        Some(idx) => text[idx + USER_MESSAGE_BEGIN.len()..].trim(),        None => text.trim(),    }}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_ONLY_USER_MESSAGE_PLACEHOLDER.to_string());    }    None}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 crate::model::ThreadMetadata;    use chrono::DateTime;    use chrono::Utc;    use codex_protocol::ThreadId;    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::RolloutItem;    use codex_protocol::protocol::SandboxPolicy;    use codex_protocol::protocol::SessionMeta;    use codex_protocol::protocol::SessionMetaLine;    use codex_protocol::protocol::SessionSource;    use codex_protocol::protocol::ThreadGoal;    use codex_protocol::protocol::ThreadGoalStatus;    use codex_protocol::protocol::ThreadGoalUpdatedEvent;    use codex_protocol::protocol::TurnContextItem;    use codex_protocol::protocol::USER_MESSAGE_BEGIN;    use codex_protocol::protocol::UserMessageEvent;    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 {            id: None,            role: "user".to_string(),            content: vec![ContentItem::InputText {                text: "hello from response item".to_string(),            }],            phase: None,            metadata: None,        });        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 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(super::IMAGE_ONLY_USER_MESSAGE_PLACEHOLDER)        );        assert_eq!(            metadata.preview.as_deref(),            Some(super::IMAGE_ONLY_USER_MESSAGE_PLACEHOLDER)        );        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(SessionMetaLine {                meta: SessionMeta {                    id: thread_id,                    forked_from_id: Some(                        ThreadId::from_string(&Uuid::now_v7().to_string()).expect("thread id"),                    ),                    parent_thread_id: None,                    timestamp: "2026-02-26T00:00:00.000Z".to_string(),                    cwd: PathBuf::from("/child/worktree"),                    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,                    memory_mode: None,                    multi_agent_version: None,                },                git: None,            }),            "test-provider",        );        apply_rollout_item(            &mut metadata,            &RolloutItem::TurnContext(TurnContextItem {                turn_id: Some("turn-1".to_string()),                cwd: PathBuf::from("/parent/workspace"),                workspace_roots: None,                current_date: None,                timezone: None,                approval_policy: AskForApproval::Never,                sandbox_policy: SandboxPolicy::DangerFullAccess,                permission_profile: None,                network: None,                file_system_sandbox_policy: None,                model: "gpt-5".to_string(),                comp_hash: None,                personality: None,                collaboration_mode: None,                multi_agent_version: None,                realtime_active: None,                effort: None,                summary: 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()),                cwd: PathBuf::from("/workspace"),                workspace_roots: None,                current_date: None,                timezone: None,                approval_policy: AskForApproval::OnRequest,                sandbox_policy: SandboxPolicy::DangerFullAccess,                permission_profile: Some(permission_profile.clone()),                network: None,                file_system_sandbox_policy: None,                model: "gpt-5".to_string(),                comp_hash: None,                personality: None,                collaboration_mode: None,                multi_agent_version: None,                realtime_active: None,                effort: None,                summary: 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();        apply_rollout_item(            &mut metadata,            &RolloutItem::TurnContext(TurnContextItem {                turn_id: Some("turn-1".to_string()),                cwd: PathBuf::from("/fallback/workspace"),                workspace_roots: None,                current_date: None,                timezone: None,                approval_policy: AskForApproval::OnRequest,                sandbox_policy: SandboxPolicy::new_read_only_policy(),                permission_profile: None,                network: None,                file_system_sandbox_policy: None,                model: "gpt-5".to_string(),                comp_hash: None,                personality: None,                collaboration_mode: None,                multi_agent_version: None,                realtime_active: None,                effort: Some(ReasoningEffort::High),                summary: codex_protocol::config_types::ReasoningSummary::Auto,            }),            "test-provider",        );        assert_eq!(metadata.cwd, PathBuf::from("/fallback/workspace"));    }    #[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()),                cwd: PathBuf::from("/fallback/workspace"),                workspace_roots: None,                current_date: None,                timezone: None,                approval_policy: AskForApproval::OnRequest,                sandbox_policy: SandboxPolicy::new_read_only_policy(),                permission_profile: None,                network: None,                file_system_sandbox_policy: None,                model: "gpt-5".to_string(),                comp_hash: None,                personality: None,                collaboration_mode: None,                multi_agent_version: None,                realtime_active: None,                effort: Some(ReasoningEffort::High),                summary: 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 session_meta_does_not_set_model_or_reasoning_effort() {        let mut metadata = metadata_for_test();        let thread_id = metadata.id;        apply_rollout_item(            &mut metadata,            &RolloutItem::SessionMeta(SessionMetaLine {                meta: SessionMeta {                    id: thread_id,                    forked_from_id: None,                    parent_thread_id: None,                    timestamp: "2026-02-26T00:00:00.000Z".to_string(),                    cwd: PathBuf::from("/workspace"),                    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,                    memory_mode: None,                    multi_agent_version: None,                },                git: None,            }),            "test-provider",        );        assert_eq!(metadata.model, None);        assert_eq!(metadata.reasoning_effort, None);    }    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 {            id,            rollout_path: PathBuf::from("/tmp/a.jsonl"),            created_at,            updated_at: created_at,            source: "cli".to_string(),            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(),            preview: None,            sandbox_policy: "read-only".to_string(),            approval_mode: "on-request".to_string(),            tokens_used: 1,            first_user_message: None,            archived_at: 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"]);    }}