diff --git a/codex-rs/state/src/delegated_preview.rs b/codex-rs/state/src/delegated_preview.rs new file mode 100644 index 0000000000..12c728eaf9 --- /dev/null +++ b/codex-rs/state/src/delegated_preview.rs @@ -0,0 +1,32 @@ +//! Display-only previews for recognized delegated inputs; model content is never rewritten. + +use codex_protocol::items::FunctionCallOutputItem; + +/// Extract the task text from a delegated input, ignoring unrelated tool outputs. +pub fn delegated_output_preview(output: &FunctionCallOutputItem) -> Option { + if !matches!(output.namespace.as_deref(), Some("codex_app" | "codex_tui")) + || !matches!( + output.name.as_str(), + "create_thread" | "send_message_to_thread" + ) + { + return None; + } + let text = output.output.to_text()?; + let preview = text + .strip_prefix("\n ") + .and_then(|delegation| delegation.split_once("\n ")) + .and_then(|(_, input)| input.strip_suffix("\n")) + .map(|input| { + input + .replace("<", "<") + .replace(">", ">") + .replace("&", "&") + }) + .unwrap_or(text); + (!preview.trim().is_empty()).then_some(preview) +} + +#[cfg(test)] +#[path = "delegated_preview_tests.rs"] +mod tests; diff --git a/codex-rs/state/src/delegated_preview_tests.rs b/codex-rs/state/src/delegated_preview_tests.rs new file mode 100644 index 0000000000..f62bfa834f --- /dev/null +++ b/codex-rs/state/src/delegated_preview_tests.rs @@ -0,0 +1,49 @@ +//! Verify delegated preview decoding and tool identity filtering. + +use super::delegated_output_preview; +use codex_protocol::items::FunctionCallOutputItem; +use codex_protocol::models::FunctionCallOutputBody; +use pretty_assertions::assert_eq; + +#[test] +fn delegated_previews_unwrap_only_recognized_inputs() { + let wrapped = "\n source\n Check <main> & &lt;literal&gt;\n"; + for (namespace, name, text, expected) in [ + ( + Some("codex_app"), + "create_thread", + wrapped, + Some("Check
& <literal>"), + ), + ( + Some("codex_tui"), + "send_message_to_thread", + wrapped, + Some("Check
& <literal>"), + ), + ( + Some("codex_app"), + "create_thread", + "plain <text>", + Some("plain <text>"), + ), + (Some("codex_tui"), "create_thread", " ", None), + (Some("other"), "create_thread", wrapped, None), + (None, "create_thread", wrapped, None), + (Some("codex_app"), "shell", wrapped, None), + ( + Some("codex_app"), + "create_thread", + "incomplete", + Some("incomplete"), + ), + ] { + let output = FunctionCallOutputItem { + id: "output".to_string(), + name: name.to_string(), + namespace: namespace.map(str::to_string), + output: FunctionCallOutputBody::Text(text.to_string()), + }; + assert_eq!(delegated_output_preview(&output).as_deref(), expected); + } +} diff --git a/codex-rs/state/src/extract.rs b/codex-rs/state/src/extract.rs index b761940061..8adeb295bc 100644 --- a/codex-rs/state/src/extract.rs +++ b/codex-rs/state/src/extract.rs @@ -59,7 +59,10 @@ pub fn rollout_item_affects_thread_metadata(item: &RolloutItem) -> bool { | EventMsg::ThreadSettingsApplied(_), ) => true, RolloutItem::EventMsg(EventMsg::ItemCompleted(event)) - if matches!(event.item, TurnItem::UserMessage(_)) => + if matches!( + event.item, + TurnItem::UserMessage(_) | TurnItem::FunctionCallOutput(_) + ) => { true } @@ -142,6 +145,12 @@ fn apply_event_msg(metadata: &mut ThreadMetadata, event: &EventMsg) { && !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) => { @@ -289,6 +298,40 @@ mod tests { 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(); diff --git a/codex-rs/state/src/lib.rs b/codex-rs/state/src/lib.rs index 5f716eab5f..5ae36f8b11 100644 --- a/codex-rs/state/src/lib.rs +++ b/codex-rs/state/src/lib.rs @@ -10,6 +10,7 @@ const _: () = assert!( ); mod audit; +mod delegated_preview; mod extract; pub mod log_db; mod migrations; @@ -39,6 +40,7 @@ pub use sqlite::SqliteConfig; pub use audit::ThreadStateAuditRow; pub use audit::read_thread_state_audit_rows; +pub use delegated_preview::delegated_output_preview; pub use extract::GUARDIAN_THREAD_PREVIEW; pub use extract::GUARDIAN_THREAD_TITLE; /// Low-level storage engine: useful for focused tests. diff --git a/codex-rs/thread-store/src/local/mod.rs b/codex-rs/thread-store/src/local/mod.rs index 3718464ddf..d7aa740270 100644 --- a/codex-rs/thread-store/src/local/mod.rs +++ b/codex-rs/thread-store/src/local/mod.rs @@ -813,6 +813,8 @@ impl ThreadStore for LocalThreadStore { mod tests { #[path = "acquisition_tests.rs"] mod acquisition_tests; + #[path = "delegated_preview_tests.rs"] + mod delegated_preview_tests; use std::sync::Arc; use codex_protocol::ThreadId; diff --git a/codex-rs/thread-store/src/local/tests/delegated_preview_tests.rs b/codex-rs/thread-store/src/local/tests/delegated_preview_tests.rs new file mode 100644 index 0000000000..ec21f7b43d --- /dev/null +++ b/codex-rs/thread-store/src/local/tests/delegated_preview_tests.rs @@ -0,0 +1,83 @@ +//! Delegated tasks are discoverable through database listing before any user message. + +use super::*; +use crate::SortDirection; +use crate::ThreadSortKey; +use codex_protocol::items::FunctionCallOutputItem; +use codex_protocol::models::FunctionCallOutputBody; +use pretty_assertions::assert_eq; + +#[tokio::test] +async fn delegated_thread_is_listed_before_user_followup() { + for history_mode in [ThreadHistoryMode::Legacy, ThreadHistoryMode::Paginated] { + let home = TempDir::new().expect("temp dir"); + let config = test_config(home.path()); + let runtime = codex_state::StateRuntime::init( + config.sqlite.clone(), + config.default_model_provider_id.clone(), + ) + .await + .expect("initialize state database"); + let store = Arc::new(LocalThreadStore::new(config, Some(runtime))); + let thread_id = ThreadId::new(); + let mut params = create_thread_params(thread_id); + params.history_mode = history_mode; + let live_thread = LiveThread::create(store.clone(), params) + .await + .expect("create live thread"); + let preview = "Inspect
& report findings."; + live_thread + .append_items(&[RolloutItem::EventMsg(EventMsg::ItemCompleted( + ItemCompletedEvent { + thread_id, + turn_id: "delegated-turn".to_string(), + item: TurnItem::FunctionCallOutput(FunctionCallOutputItem { + id: "delegated-output".to_string(), + name: "create_thread".to_string(), + namespace: Some("codex_app".to_string()), + output: FunctionCallOutputBody::Text( + "\n source\n Inspect <main> & report findings.\n".to_string(), + ), + }), + started_at_ms: Some(0), + completed_at_ms: 0, + }, + ))]) + .await + .expect("append delegated input"); + live_thread.flush().await.expect("persist delegated input"); + + let listed = store + .list_threads(ListThreadsParams { + page_size: 10, + cursor: None, + sort_key: ThreadSortKey::CreatedAt, + sort_direction: SortDirection::Desc, + allowed_sources: Vec::new(), + model_providers: None, + cwd_filters: None, + section: None, + project_id: None, + archived: false, + search_term: None, + relation_filter: None, + use_state_db_only: true, + }) + .await + .expect("list database threads"); + assert_eq!( + listed + .items + .iter() + .map(|thread| ( + thread.thread_id, + thread.preview.as_str(), + thread.first_user_message.as_deref(), + )) + .collect::>(), + vec![(thread_id, preview, None)], + "history mode: {history_mode:?}", + ); + live_thread.shutdown().await.expect("shutdown live thread"); + } +} diff --git a/codex-rs/thread-store/src/thread_metadata_sync.rs b/codex-rs/thread-store/src/thread_metadata_sync.rs index 55edd7472e..cb3559c137 100644 --- a/codex-rs/thread-store/src/thread_metadata_sync.rs +++ b/codex-rs/thread-store/src/thread_metadata_sync.rs @@ -27,6 +27,10 @@ use crate::types::canonical_history_mode_from_rollout_items; const THREAD_UPDATED_AT_TOUCH_INTERVAL: Duration = Duration::from_secs(5); +#[cfg(test)] +#[path = "thread_metadata_sync_preview_tests.rs"] +mod preview_tests; + /// Live-thread helper that derives metadata updates from appended rollout items. /// /// Stores receive raw rollout items plus explicit metadata patches. This helper @@ -302,6 +306,12 @@ impl ThreadMetadataSync { &user.as_legacy_user_message_event(), &mut update, ); + } else if let TurnItem::FunctionCallOutput(output) = &event.item + && !self.preview_seen + && let Some(preview) = codex_state::delegated_output_preview(output) + { + self.preview_seen = true; + update.preview = Some(preview); } } RolloutItem::EventMsg(EventMsg::TokenCount(token_count)) => { diff --git a/codex-rs/thread-store/src/thread_metadata_sync_preview_tests.rs b/codex-rs/thread-store/src/thread_metadata_sync_preview_tests.rs new file mode 100644 index 0000000000..50b96cb41e --- /dev/null +++ b/codex-rs/thread-store/src/thread_metadata_sync_preview_tests.rs @@ -0,0 +1,60 @@ +//! Verify delegated inputs emit a preview-only live metadata patch exactly once. + +use super::*; +use crate::ThreadPersistenceMetadata; +use codex_protocol::items::FunctionCallOutputItem; +use codex_protocol::models::FunctionCallOutputBody; +use codex_protocol::protocol::ItemCompletedEvent; +use pretty_assertions::assert_eq; + +#[test] +fn delegated_output_emits_only_first_preview_in_live_patch() { + let thread_id = ThreadId::new(); + let mut sync = ThreadMetadataSync::for_resume( + &ResumeThreadParams { + thread_id, + rollout_path: None, + history: None, + history_revision: None, + include_archived: false, + metadata: ThreadPersistenceMetadata { + cwd: None, + model_provider: "test-provider".to_string(), + memory_mode: ThreadMemoryMode::Enabled, + }, + }, + /*metadata*/ None, + ); + for (index, text) in ["first task", "later task"].into_iter().enumerate() { + let item = RolloutItem::EventMsg(EventMsg::ItemCompleted(ItemCompletedEvent { + thread_id, + turn_id: "turn".to_string(), + item: TurnItem::FunctionCallOutput(FunctionCallOutputItem { + id: format!("output-{index}"), + name: "create_thread".to_string(), + namespace: Some("codex_tui".to_string()), + output: FunctionCallOutputBody::Text(format!( + "\n source\n {text}\n" + )), + }), + started_at_ms: Some(0), + completed_at_ms: 0, + })); + let update = sync.observe_appended_items(&[item]); + if index == 0 { + let update = update.expect("first delegated preview patch"); + let expected = ThreadMetadataPatch { + preview: Some(text.to_string()), + updated_at: update.patch.updated_at, + ..Default::default() + }; + assert_eq!( + serde_json::to_value(&update.patch).unwrap(), + serde_json::to_value(expected).unwrap() + ); + sync.mark_pending_update_applied(&update); + } else { + assert_eq!(update.and_then(|update| update.patch.preview), None); + } + } +}