mirror of
https://github.com/openai/codex.git
synced 2026-09-15 12:08:01 +00:00
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:
Binary file not shown.
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)] {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user