Allow setting daybreakEnabled when starting a thread (#45513)

## What changed

Add experimental `thread/start.daybreakEnabled` so clients can set the initial preference for persistent threads. Omitted or null values leave it unset; explicit values are rejected for ephemeral threads.

Return the choice in the start response, `thread/started`, and reads before persistence. Stage it with the initial thread metadata and save it when the thread is persisted. Later changes still use `thread/metadata/update`. The preference does not select `turn/start.cyberAccessProgram` or grant access.

## Testing

Add coverage for true, false, and unset values in responses, notifications, reads, and reads after persistence and restart, plus rejection for ephemeral threads. Update existing metadata and access-program tests to exercise threads with an initial preference.

GitOrigin-RevId: 3bff3dc55a18436067bc2a3f156f5abf52d7321b
This commit is contained in:
faizan-oai
2026-09-14 20:45:04 +00:00
committed by copyberry
parent ea3c4848d8
commit ef8b356c22
8 changed files with 181 additions and 4 deletions

View File

@@ -126,6 +126,12 @@ pub struct ThreadStartParams {
#[experimental("thread/start.projectId")]
#[ts(optional = nullable)]
pub project_id: Option<String>,
/// 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<bool>,
/// Optional sticky environments for this thread.
///
/// Omitted selects the default environment when environment access is

View File

@@ -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

View File

@@ -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<codex_app_server_protocol::ThreadStartSource>,
thread_source: Option<codex_protocol::protocol::ThreadSource>,
project_id: Option<String>,
daybreak_enabled: Option<bool>,
environment_selections: Option<Vec<TurnEnvironmentSelection>>,
service_name: Option<String>,
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<Thread, ThreadReadViewError> {
// 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 {

View File

@@ -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)] {

View File

@@ -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,

View File

@@ -481,6 +481,19 @@ impl ThreadStore for LocalThreadStore {
})
}
fn read_pending_thread_metadata(
&self,
thread_id: ThreadId,
) -> ThreadStoreFuture<'_, Option<ThreadMetadataPatch>> {
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;

View File

@@ -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<ThreadMetadataPatch>> {
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 {