diff --git a/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-experimental.json.zst b/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-experimental.json.zst index 875b499f8d..7c4f0b7f9c 100644 Binary files a/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-experimental.json.zst and b/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-experimental.json.zst differ diff --git a/codex-rs/app-server-protocol/src/protocol/v2/thread.rs b/codex-rs/app-server-protocol/src/protocol/v2/thread.rs index 0d5a7b5a99..e369775ab3 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/thread.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/thread.rs @@ -126,6 +126,12 @@ pub struct ThreadStartParams { #[experimental("thread/start.projectId")] #[ts(optional = nullable)] pub project_id: Option, + /// Initial Daybreak choice for this persistent thread. Omitted or null + /// leaves it unset. This does not select a turn's `cyberAccessProgram` + /// or grant access. Not supported for ephemeral threads. + #[experimental("thread/start.daybreakEnabled")] + #[ts(optional = nullable)] + pub daybreak_enabled: Option, /// Optional sticky environments for this thread. /// /// Omitted selects the default environment when environment access is diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index 507c7ec0d1..6a15fa2dd8 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -1,3 +1,14 @@ +# Initial Daybreak choice (experimental) + +Persistent threads accept `daybreakEnabled` on `thread/start` with the +`experimentalApi` opt-in. The response and `thread/started` notification both +include the initial choice in `thread.daybreakEnabled`. The choice is staged +with the thread's other initial metadata and saved when the thread is persisted. +An unused thread is not guaranteed to survive restart. Omitted or null leaves +the choice unset. Ephemeral threads cannot save it. +Use `thread/metadata/update` for later changes. This preference does not select +`turn/start.cyberAccessProgram` or grant access to an access program. + # User verification cancellation (experimental) Local UI clients can cancel a native user-verification RPC by sending diff --git a/codex-rs/app-server/src/request_processors/thread_processor.rs b/codex-rs/app-server/src/request_processors/thread_processor.rs index de98fc7cde..4cd83dd37c 100644 --- a/codex-rs/app-server/src/request_processors/thread_processor.rs +++ b/codex-rs/app-server/src/request_processors/thread_processor.rs @@ -1153,6 +1153,7 @@ impl ThreadRequestProcessor { session_start_source, thread_source, project_id, + daybreak_enabled, environments, } = params; if matches!( @@ -1239,6 +1240,7 @@ impl ThreadRequestProcessor { session_start_source, thread_source.map(Into::into), project_id, + daybreak_enabled, environments, service_name, allow_provider_model_fallback, @@ -1320,6 +1322,7 @@ impl ThreadRequestProcessor { session_start_source: Option, thread_source: Option, project_id: Option, + daybreak_enabled: Option, environment_selections: Option>, service_name: Option, allow_provider_model_fallback: bool, @@ -1333,6 +1336,11 @@ impl ThreadRequestProcessor { .load_with_overrides(config_overrides.clone(), typesafe_overrides.clone()) .await .map_err(|err| config_load_error(&err))?; + if config.ephemeral && daybreak_enabled.is_some() { + return Err(invalid_request( + "daybreakEnabled is not supported for ephemeral threads", + )); + } // Project-local config can launch host processes, so only the effective // permissions after managed constraints can imply project trust. let effective_permission_profile = config.permissions.effective_permission_profile(); @@ -1461,6 +1469,7 @@ impl ThreadRequestProcessor { thread_store.as_ref(), StoreThreadMetadataPatch { project_id: project_id.clone().map(Some), + daybreak_enabled, ..Default::default() }, "thread/start", @@ -1543,6 +1552,7 @@ impl ThreadRequestProcessor { session_configured.rollout_path.clone(), ); thread.project_id = project_id.clone(); + thread.daybreak_enabled = daybreak_enabled; // Auto-attach a thread listener when starting a thread. log_listener_attach_result( @@ -2801,6 +2811,17 @@ impl ThreadRequestProcessor { thread_id: ThreadId, include_turns: bool, ) -> Result { + // Read staging first: persistence can consume it while the stored thread is read. + let pending_daybreak_enabled = self + .thread_store + .read_pending_thread_metadata(thread_id) + .await + .map_err(|err| { + ThreadReadViewError::Internal(format!( + "failed to read pending thread metadata: {err}" + )) + })? + .and_then(|metadata| metadata.daybreak_enabled); let loaded_thread = self.thread_manager.get_thread(thread_id).await.ok(); let mut thread = if include_turns { if let Some(loaded_thread) = loaded_thread.as_ref() { @@ -2854,6 +2875,8 @@ impl ThreadRequestProcessor { ))); }; + thread.daybreak_enabled = thread.daybreak_enabled.or(pending_daybreak_enabled); + let has_live_in_progress_turn = if let Some(loaded_thread) = loaded_thread.as_ref() { matches!(loaded_thread.agent_status().await, AgentStatus::Running) } else { diff --git a/codex-rs/app-server/tests/suite/v2/cyber_access_program.rs b/codex-rs/app-server/tests/suite/v2/cyber_access_program.rs index b4ebe28600..fea72f3f56 100644 --- a/codex-rs/app-server/tests/suite/v2/cyber_access_program.rs +++ b/codex-rs/app-server/tests/suite/v2/cyber_access_program.rs @@ -4,6 +4,8 @@ use app_test_support::TestAppServer; use app_test_support::write_chatgpt_auth; use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::CyberAccessProgram; +use codex_app_server_protocol::JSONRPCErrorError; +use codex_app_server_protocol::RequestId; use codex_app_server_protocol::ThreadForkParams; use codex_app_server_protocol::ThreadForkResponse; use codex_app_server_protocol::ThreadMetadataUpdateParams; @@ -13,6 +15,7 @@ use codex_app_server_protocol::ThreadReadResponse; use codex_app_server_protocol::ThreadResumeParams; use codex_app_server_protocol::ThreadResumeResponse; use codex_app_server_protocol::ThreadStartParams; +use codex_app_server_protocol::ThreadStartedNotification; use codex_app_server_protocol::TurnStartParams; use codex_app_server_protocol::TurnStatus; use codex_app_server_protocol::UserInput; @@ -29,6 +32,102 @@ use wiremock::matchers::header; use wiremock::matchers::method; use wiremock::matchers::path; +#[tokio::test] +async fn thread_start_stages_daybreak_until_persistence() -> Result<()> { + core_test_support::skip_if_no_network!(Ok(())); + let server = responses::start_mock_server().await; + responses::mount_sse_sequence( + &server, + (0..3) + .map(|index| responses::sse(vec![responses::ev_completed(&format!("resp-{index}"))])) + .collect(), + ) + .await; + let home = TempDir::new()?; + let mut app = start_chatgpt_app(home.path(), &server).await?; + let mut threads = Vec::new(); + for daybreak_enabled in [Some(true), Some(false), None] { + let started = app + .start_thread(ThreadStartParams { + daybreak_enabled, + ..Default::default() + }) + .await?; + assert_eq!(started.thread.daybreak_enabled, daybreak_enabled); + let notification: ThreadStartedNotification = + app.read_notification("thread/started").await?; + assert_eq!(notification.thread, started.thread); + let read: ThreadReadResponse = app + .request(|request_id| ClientRequest::ThreadRead { + request_id, + params: ThreadReadParams { + thread_id: started.thread.id.clone(), + include_turns: false, + }, + }) + .await?; + assert_eq!(read.thread.daybreak_enabled, daybreak_enabled); + let completed = app + .start_turn_and_wait_for_completion(TurnStartParams { + thread_id: started.thread.id.clone(), + input: vec![UserInput::Text { + text: "start this task".to_owned(), + text_elements: Vec::new(), + }], + ..Default::default() + }) + .await?; + assert_eq!(completed.turn.status, TurnStatus::Completed); + threads.push((started.thread.id, daybreak_enabled)); + } + app.shutdown_gracefully().await?; + + let mut restarted = start_chatgpt_app(home.path(), &server).await?; + for (thread_id, daybreak_enabled) in threads { + let read: ThreadReadResponse = restarted + .request(|request_id| ClientRequest::ThreadRead { + request_id, + params: ThreadReadParams { + thread_id: thread_id.clone(), + include_turns: false, + }, + }) + .await?; + assert_eq!( + (read.thread.id, read.thread.daybreak_enabled), + (thread_id, daybreak_enabled) + ); + } + Ok(()) +} + +#[tokio::test] +async fn thread_start_rejects_daybreak_for_ephemeral_threads() -> Result<()> { + core_test_support::skip_if_no_network!(Ok(())); + let server = responses::start_mock_server().await; + let home = TempDir::new()?; + let mut app = start_chatgpt_app(home.path(), &server).await?; + let request_id = app + .send_thread_start_request_with_auto_env(ThreadStartParams { + daybreak_enabled: Some(true), + ephemeral: Some(true), + ..Default::default() + }) + .await?; + let error = app + .read_stream_until_error_message(RequestId::Integer(request_id)) + .await?; + assert_eq!( + error.error, + JSONRPCErrorError { + code: -32600, + message: "daybreakEnabled is not supported for ephemeral threads".to_owned(), + data: None, + } + ); + Ok(()) +} + #[tokio::test] async fn turn_start_forwards_explicit_cyber_access_program() -> Result<()> { core_test_support::skip_if_no_network!(Ok(())); @@ -54,7 +153,13 @@ async fn turn_start_forwards_explicit_cyber_access_program() -> Result<()> { .await; let home = TempDir::new()?; let mut app = start_chatgpt_app(home.path(), &server).await?; - let thread = app.start_thread(ThreadStartParams::default()).await?.thread; + let thread = app + .start_thread(ThreadStartParams { + daybreak_enabled: Some(true), + ..Default::default() + }) + .await? + .thread; for program in programs { let completed = app .start_turn_and_wait_for_completion(TurnStartParams { @@ -100,14 +205,24 @@ async fn daybreak_thread_metadata_persists_independently_across_restart_and_fork .await; let home = TempDir::new()?; let mut app = start_chatgpt_app(home.path(), &server).await?; - let first = app.start_thread(ThreadStartParams::default()).await?; - let second = app.start_thread(ThreadStartParams::default()).await?; + let first = app + .start_thread(ThreadStartParams { + daybreak_enabled: Some(false), + ..Default::default() + }) + .await?; + let second = app + .start_thread(ThreadStartParams { + daybreak_enabled: Some(true), + ..Default::default() + }) + .await?; assert_eq!( ( first.thread.daybreak_enabled, second.thread.daybreak_enabled ), - (None, None) + (Some(false), Some(true)) ); for (thread_id, enabled) in [(&first.thread.id, true), (&second.thread.id, false)] { diff --git a/codex-rs/app-server/tests/suite/v2/skills_list.rs b/codex-rs/app-server/tests/suite/v2/skills_list.rs index 245f10dd4d..1b3d2948e0 100644 --- a/codex-rs/app-server/tests/suite/v2/skills_list.rs +++ b/codex-rs/app-server/tests/suite/v2/skills_list.rs @@ -1393,6 +1393,7 @@ async fn skills_changed_notification_is_emitted_after_skill_change() -> Result<( session_start_source: None, thread_source: None, project_id: None, + daybreak_enabled: None, dynamic_tools: None, environments: None, selected_capability_roots: None, diff --git a/codex-rs/thread-store/src/local/mod.rs b/codex-rs/thread-store/src/local/mod.rs index 4d8dfb24c1..de8f12a041 100644 --- a/codex-rs/thread-store/src/local/mod.rs +++ b/codex-rs/thread-store/src/local/mod.rs @@ -481,6 +481,19 @@ impl ThreadStore for LocalThreadStore { }) } + fn read_pending_thread_metadata( + &self, + thread_id: ThreadId, + ) -> ThreadStoreFuture<'_, Option> { + Box::pin(async move { + Ok(self + .pending_thread_metadata + .lock(thread_id) + .await + .and_then(|metadata| metadata.clone())) + }) + } + fn remove_pending_thread_metadata(&self, thread_id: ThreadId) -> ThreadStoreFuture<'_, ()> { Box::pin(async move { self.pending_thread_metadata.remove(thread_id).await; diff --git a/codex-rs/thread-store/src/store.rs b/codex-rs/thread-store/src/store.rs index bf2ffb3169..d43b69447c 100644 --- a/codex-rs/thread-store/src/store.rs +++ b/codex-rs/thread-store/src/store.rs @@ -116,6 +116,14 @@ pub trait ThreadStore: Any + Send + Sync { }) } + /// Reads metadata staged for a reserved thread without persisting it. + fn read_pending_thread_metadata( + &self, + _thread_id: ThreadId, + ) -> ThreadStoreFuture<'_, Option> { + Box::pin(async { Ok(None) }) + } + /// Removes host-owned metadata staged for a reserved thread ID. fn remove_pending_thread_metadata(&self, _thread_id: ThreadId) -> ThreadStoreFuture<'_, ()> { Box::pin(async {