Persist Daybreak preferences in thread metadata (#42854)

## Why

Clients need a per-thread Daybreak toggle that survives restarts and can be
restored on resume without changing the access program of an active or future
turn.

## What changed

- Add experimental `daybreakEnabled` support to `thread/metadata/update` and
  expose the saved value on thread responses.
- Store the preference in SQLite, including for threads without a first turn,
  and preserve it through reads, resumes, reconciliation, and restarts.
- Copy the preference when forking a thread while keeping subsequent updates
  independent.
- Keep `daybreakEnabled` separate from the per-turn `cyberAccessProgram` field;
  app-server does not derive one from the other.

## Testing

- Cover persistence across restarts, cold reads, reconciliation, and forks.
- Verify metadata updates do not alter the access program of active or later
  turns.

GitOrigin-RevId: bcf987a9fcd7b0ac8a88b1c8f0df06b33ed8ad6e
This commit is contained in:
faizan-oai
2026-09-04 21:17:25 +00:00
committed by copyberry
parent 4636819a35
commit 3921a30d6b
47 changed files with 555 additions and 52 deletions
@@ -254,6 +254,7 @@ fn sample_thread_with_metadata(
section: None,
section_entered_at: None,
project_id: None,
daybreak_enabled: None,
history_mode: Default::default(),
model_provider: "openai".to_string(),
model: None,
+1
View File
@@ -601,6 +601,7 @@ fn sample_thread(thread_id: &str) -> Thread {
section: None,
section_entered_at: None,
project_id: None,
daybreak_enabled: None,
history_mode: Default::default(),
model_provider: "openai".to_string(),
model: None,
@@ -3158,6 +3158,7 @@ mod tests {
agent_role: None,
git_info: None,
name: None,
daybreak_enabled: None,
turns: Vec::new(),
},
model: "gpt-5".to_string(),
@@ -3219,6 +3220,7 @@ mod tests {
"agentRole": null,
"gitInfo": null,
"name": null,
"daybreakEnabled": null,
"turns": []
},
"model": "gpt-5",
@@ -312,6 +312,7 @@ fn thread_resume_response_round_trips_initial_turns_page() {
agent_role: None,
git_info: None,
name: None,
daybreak_enabled: None,
turns: Vec::new(),
},
model: "gpt-5".to_string(),
@@ -988,6 +988,12 @@ pub struct ThreadMetadataUpdateParams {
/// provide a string to replace the stored value.
#[ts(optional = nullable)]
pub git_info: Option<ThreadMetadataGitInfoUpdateParams>,
/// Save the client's Daybreak choice for this persistent thread.
/// Omitted or null leaves it unchanged. This does not select a turn's
/// `cyberAccessProgram` or grant access.
#[experimental("thread/metadata/update.daybreakEnabled")]
#[ts(optional = nullable)]
pub daybreak_enabled: Option<bool>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
@@ -283,6 +283,9 @@ pub struct Thread {
pub git_info: Option<GitInfo>,
/// Optional user-facing thread title.
pub name: Option<String>,
/// Saved Daybreak choice, independent of turn execution. Null if unset.
#[experimental("thread.daybreakEnabled")]
pub daybreak_enabled: Option<bool>,
/// Only populated on `thread/resume`, `thread/rollback`, `thread/fork`, and `thread/read`
/// (when `includeTurns` is true) responses.
/// For all other responses and notifications returning a Thread,
@@ -330,6 +333,7 @@ struct ThreadCompatibility {
agent_role: Option<String>,
git_info: Option<GitInfo>,
name: Option<String>,
daybreak_enabled: Option<bool>,
turns: Vec<Turn>,
}
@@ -370,6 +374,7 @@ impl<'de> Deserialize<'de> for Thread {
agent_role: thread.agent_role,
git_info: thread.git_info,
name: thread.name,
daybreak_enabled: thread.daybreak_enabled,
turns: thread.turns,
})
}
+14 -2
View File
@@ -196,7 +196,7 @@ Example with notification opt-out:
- `thread/turns/list` — page through a stored thread’s turn history without resuming it; supports cursor-based pagination with `sortDirection`, `itemsView`, `nextCursor`, and `backwardsCursor`.
- `thread/items/list` — page through persisted thread items without resuming the thread. Pass `turnId` to restrict results to one turn, or omit it to page items across the thread. The active thread store must support item pagination.
- `thread/searchOccurrences` — experimental; find literal, case-insensitive matches in visible user messages and summary-selected final assistant messages within one paginated thread.
- `thread/metadata/update` — patch stored thread metadata in sqlite; supports updating persisted `gitInfo` fields and experimental `projectId`, then returns the refreshed `thread`. Omit `projectId` to preserve assignment and pass an empty string to clear it.
- `thread/metadata/update` — patch stored thread metadata in sqlite; supports updating persisted `gitInfo` fields, experimental `projectId`, and experimental `daybreakEnabled`, then returns the refreshed `thread`. Omit `projectId` to preserve assignment and pass an empty string to clear it.
- `thread/section/move` — atomically move a thread into the section identified by `sectionId`, before another thread or at the end when `beforeThreadId` is `null`. Reordering within the same section preserves `sectionEnteredAt`; entering a different section resets it. Set `sectionId` to `null` to remove the thread from its section. Returns `{}` on success.
- `thread/settings/update` — experimental; queue a partial update to a loaded thread’s next-turn settings without starting a turn or adding transcript items. Omitted fields leave settings unchanged; `serviceTier: null` clears the tier; deprecated `multiAgentMode` is ignored, while Ultra reasoning effort enables proactive multi-agent behavior; `sandboxPolicy` and `permissions` cannot be combined. Parent-owned Multi-Agent V2 subagents reject direct settings updates. Returns `{}` when the update is accepted and emits `thread/settings/updated` with the full effective settings only if they actually change. `turn/start` settings overrides emit the same notification when they change the stored settings.
- `thread/memoryMode/set` — experimental; set a thread’s persisted memory eligibility to `"enabled"` or `"disabled"` for either a loaded thread or a stored rollout; returns `{}` on success.
@@ -844,6 +844,18 @@ Use `thread/metadata/update` to patch sqlite-backed `gitInfo` without resuming a
{ "id": 29, "result": {} }
```
Experimental `daybreakEnabled` saves the client's on/off preference through `thread/metadata/update`. Omitted or null leaves the preference unchanged. The sqlite-backed `Thread.daybreakEnabled` is the source of truth: thread read/start/resume/fork responses return the saved value, or null when unset. Updates are persisted before the response. Saving a preference works before the first turn and does not require resuming the thread. Ephemeral threads are unsupported.
| Client action | Daybreak behavior |
| --- | --- |
| Create a task | Copy the client's default on/off preference with `thread/metadata/update` before sending its first turn. |
| Change the toggle | Save only that thread's preference through `thread/metadata/update`. An active turn keeps its original program. |
| Start a turn | Compute `cyberAccessProgram` from the thread's preference and selected model. Starting or steering a turn never changes the saved toggle. |
| Resume | Restore the saved preference, without applying the client's current default. |
| Fork | Copy the parent's preference; later changes to either thread are independent. |
The saved preference controls the client's toggle; app-server does not derive a request program from it. `turn/start` has no `daybreakEnabled` field.
Experimental: use `thread/memoryMode/set` to change whether a thread remains eligible for future memory generation.
```json
@@ -1055,7 +1067,7 @@ You can optionally specify config overrides on the new turn. If specified, these
`serviceTierForTurn` overrides the tier only when the request starts a new turn, without changing the thread's saved tier. Use `"default"` for standard speed, or omit it (or pass `null`) to inherit the thread's tier. It is ignored when the request steers an active turn. The existing `serviceTier` field still changes the tier for subsequent turns, including when both fields are supplied.
Experimental `cyberAccessProgram` also applies only to the new turn. It accepts `standard`, `daybreakBlue`, or `daybreakRed`; omission preserves automatic backend behavior. For ChatGPT-authenticated requests through the built-in OpenAI provider, Codex sends the corresponding `standard`, `daybreak_blue`, or `daybreak_red` value in `access_programs.cyber` on Responses and remote-compaction requests. WebSocket `response.create` messages carry the choice per request, so changing it does not require reconnecting. The server still enforces workspace authorization and model restrictions. API-key and custom-provider requests omit this field. This field does not change the saved model or grant access.
Experimental `cyberAccessProgram` applies only to the new turn and does not change the saved Daybreak choice. It accepts `standard`, `daybreakBlue`, or `daybreakRed`; omission preserves automatic backend behavior, regardless of `daybreakEnabled`. For ChatGPT-authenticated requests through the built-in OpenAI provider, Codex sends the corresponding `standard`, `daybreak_blue`, or `daybreak_red` value in `access_programs.cyber` on Responses and remote-compaction requests. WebSocket `response.create` messages carry the choice per request, so changing it does not require reconnecting. The server still enforces workspace authorization and model restrictions. API-key and custom-provider requests omit this field. Neither field changes the saved model or grants access.
Child agents use the invoking turn's choice when spawned or started on a new follow-up, including after a reload. Input delivered into an already-running child turn does not change that turn's choice.
@@ -2316,6 +2316,7 @@ mod tests {
section_position: None,
section_entered_at: None,
project_id: None,
daybreak_enabled: None,
cwd: test_path_buf("/tmp").abs().into(),
cli_version: "0.0.0".to_string(),
source: SessionSource::Cli,
@@ -33,24 +33,18 @@ const THREAD_ROLLBACK_DEPRECATION_SUMMARY: &str =
const PAGINATED_FULL_HISTORY_DEPRECATION_SUMMARY: &str = "Full-history hydration is deprecated for paginated threads; use `excludeTurns: true`, then page with `thread/turns/list` and `thread/items/list`.";
const PAGINATED_THREAD_READ_DEPRECATION_SUMMARY: &str = "Full-history hydration is deprecated for paginated threads; omit `includeTurns` or set it to `false`, then page with `thread/turns/list` and `thread/items/list`.";
async fn stage_pending_project_metadata(
async fn stage_pending_thread_metadata(
thread_manager: &ThreadManager,
thread_store: &dyn ThreadStore,
project_id: Option<&str>,
patch: StoreThreadMetadataPatch,
operation: &'static str,
) -> Result<Option<ThreadId>, JSONRPCErrorError> {
let Some(project_id) = project_id else {
if patch.is_empty() {
return Ok(None);
};
}
let thread_id = thread_manager.reserve_thread_id();
thread_store
.stage_pending_thread_metadata(
thread_id,
StoreThreadMetadataPatch {
project_id: Some(Some(project_id.to_string())),
..Default::default()
},
)
.stage_pending_thread_metadata(thread_id, patch)
.await
.map_err(|error| match error {
ThreadStoreError::Unsupported { .. } => {
@@ -62,7 +56,7 @@ async fn stage_pending_project_metadata(
Ok(Some(thread_id))
}
async fn remove_pending_project_metadata(
async fn remove_pending_thread_metadata(
thread_store: &dyn ThreadStore,
thread_id: Option<ThreadId>,
) {
@@ -70,7 +64,7 @@ async fn remove_pending_project_metadata(
return;
};
if let Err(error) = thread_store.remove_pending_thread_metadata(thread_id).await {
warn!("failed to remove staged project metadata for {thread_id}: {error}");
warn!("failed to remove staged thread metadata for {thread_id}: {error}");
}
}
@@ -1449,10 +1443,13 @@ impl ThreadRequestProcessor {
let reserved_thread_id = if start_options.config.ephemeral {
None
} else {
stage_pending_project_metadata(
stage_pending_thread_metadata(
listener_task_context.thread_manager.as_ref(),
thread_store.as_ref(),
project_id.as_deref(),
StoreThreadMetadataPatch {
project_id: project_id.clone().map(Some),
..Default::default()
},
"thread/start",
)
.await?
@@ -1493,7 +1490,7 @@ impl ThreadRequestProcessor {
} = match new_thread {
Ok(new_thread) => new_thread,
Err(err) => {
remove_pending_project_metadata(thread_store.as_ref(), reserved_thread_id).await;
remove_pending_thread_metadata(thread_store.as_ref(), reserved_thread_id).await;
return Err(match err.details() {
CodexErrorDetails::InvalidRequest(message) => invalid_request(message.clone()),
CodexErrorDetails::UnsupportedOperation(message) => {
@@ -1884,6 +1881,7 @@ impl ThreadRequestProcessor {
thread_id,
project_id,
git_info,
daybreak_enabled,
} = params;
let thread_uuid = ThreadId::from_string(&thread_id)
.map_err(|err| invalid_request(format!("invalid thread id: {err}")))?;
@@ -1906,7 +1904,7 @@ impl ThreadRequestProcessor {
}
}
if git_info.is_none() && project_id.is_none() {
if git_info.is_none() && project_id.is_none() && daybreak_enabled.is_none() {
return Err(invalid_request(
"thread metadata update must include at least one field",
));
@@ -1984,6 +1982,7 @@ impl ThreadRequestProcessor {
let patch = StoreThreadMetadataPatch {
git_info,
project_id: project_update.clone(),
daybreak_enabled,
..Default::default()
};
let updated_thread = self
@@ -5032,10 +5031,14 @@ impl ThreadRequestProcessor {
let reserved_thread_id = if config.ephemeral {
None
} else {
stage_pending_project_metadata(
stage_pending_thread_metadata(
self.thread_manager.as_ref(),
self.thread_store.as_ref(),
inherited_project_id.as_deref(),
StoreThreadMetadataPatch {
project_id: inherited_project_id.clone().map(Some),
daybreak_enabled: source_thread.daybreak_enabled,
..Default::default()
},
"thread/fork",
)
.await?
@@ -5077,7 +5080,7 @@ impl ThreadRequestProcessor {
} = match new_thread {
Ok(new_thread) => new_thread,
Err(err) => {
remove_pending_project_metadata(self.thread_store.as_ref(), reserved_thread_id)
remove_pending_thread_metadata(self.thread_store.as_ref(), reserved_thread_id)
.await;
return Err(match err.details() {
CodexErrorDetails::Io(_) | CodexErrorDetails::Json(_) => {
@@ -6020,6 +6023,7 @@ pub(crate) fn thread_from_stored_thread(
thread_source: thread.thread_source.map(Into::into),
git_info,
name: thread.name,
daybreak_enabled: thread.daybreak_enabled,
turns: Vec::new(),
};
(thread, history)
@@ -6205,6 +6209,7 @@ fn build_thread_from_snapshot(
thread_source: config_snapshot.thread_source.clone().map(Into::into),
git_info: None,
name: None,
daybreak_enabled: None,
turns: Vec::new(),
}
}
@@ -478,6 +478,7 @@ mod thread_processor_behavior_tests {
section_position: None,
section_entered_at: None,
project_id: None,
daybreak_enabled: None,
cwd: PathBuf::from("/tmp"),
cli_version: "0.0.0".to_string(),
source: SessionSource::Cli,
@@ -210,6 +210,7 @@ mod tests {
section: None,
section_entered_at: None,
project_id: None,
daybreak_enabled: None,
history_mode: Default::default(),
model_provider: "mock_provider".to_string(),
model: None,
@@ -289,6 +289,7 @@ pub(crate) fn summary_to_thread(
thread_source: None,
git_info,
name: None,
daybreak_enabled: None,
turns: Vec::new(),
}
}
@@ -2,7 +2,16 @@ use anyhow::Result;
use app_test_support::ChatGptAuthFixture;
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::ThreadForkParams;
use codex_app_server_protocol::ThreadForkResponse;
use codex_app_server_protocol::ThreadMetadataUpdateParams;
use codex_app_server_protocol::ThreadMetadataUpdateResponse;
use codex_app_server_protocol::ThreadReadParams;
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::TurnStartParams;
use codex_app_server_protocol::TurnStatus;
@@ -11,8 +20,10 @@ use codex_login::AuthCredentialsStoreMode;
use core_test_support::responses;
use pretty_assertions::assert_eq;
use serde_json::json;
use std::path::Path;
use tempfile::TempDir;
use wiremock::Mock;
use wiremock::MockServer;
use wiremock::ResponseTemplate;
use wiremock::matchers::header;
use wiremock::matchers::method;
@@ -22,11 +33,6 @@ use wiremock::matchers::path;
async fn turn_start_forwards_explicit_cyber_access_program() -> Result<()> {
core_test_support::skip_if_no_network!(Ok(()));
let server = responses::start_mock_server().await;
Mock::given(method("GET"))
.and(path("/v1/responses"))
.respond_with(ResponseTemplate::new(426))
.mount(&server)
.await;
let programs = [
None,
Some(CyberAccessProgram::DaybreakBlue),
@@ -47,23 +53,7 @@ async fn turn_start_forwards_explicit_cyber_access_program() -> Result<()> {
)
.await;
let home = TempDir::new()?;
std::fs::write(
home.path().join("config.toml"),
format!(
"model = \"gpt-5.5\"\napproval_policy = \"never\"\nopenai_base_url = \"{}/v1\"\ncli_auth_credentials_store = \"file\"\n",
server.uri()
),
)?;
write_chatgpt_auth(
home.path(),
ChatGptAuthFixture::new("chatgpt-test-token").plan_type("pro"),
AuthCredentialsStoreMode::File,
)?;
let mut app = TestAppServer::builder()
.with_codex_home(home.path())
.without_managed_config()
.build_initialized()
.await?;
let mut app = start_chatgpt_app(home.path(), &server).await?;
let thread = app.start_thread(ThreadStartParams::default()).await?.thread;
for program in programs {
let completed = app
@@ -97,6 +87,170 @@ async fn turn_start_forwards_explicit_cyber_access_program() -> Result<()> {
Ok(())
}
#[tokio::test]
async fn daybreak_thread_metadata_persists_independently_across_restart_and_fork() -> Result<()> {
core_test_support::skip_if_no_network!(Ok(()));
let server = responses::start_mock_server().await;
let requests = responses::mount_sse_sequence(
&server,
(0..4)
.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 first = app.start_thread(ThreadStartParams::default()).await?;
let second = app.start_thread(ThreadStartParams::default()).await?;
assert_eq!(
(
first.thread.daybreak_enabled,
second.thread.daybreak_enabled
),
(None, None)
);
for (thread_id, enabled) in [(&first.thread.id, true), (&second.thread.id, false)] {
let updated: ThreadMetadataUpdateResponse = app
.request(|request_id| ClientRequest::ThreadMetadataUpdate {
request_id,
params: ThreadMetadataUpdateParams {
thread_id: thread_id.clone(),
project_id: None,
git_info: None,
daybreak_enabled: Some(enabled),
},
})
.await?;
assert_eq!(
(updated.thread.id, updated.thread.daybreak_enabled),
(thread_id.clone(), Some(enabled))
);
}
// Leave the second thread empty to exercise metadata durability without a rollout.
let completed = app
.start_turn_and_wait_for_completion(TurnStartParams {
thread_id: first.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);
assert_eq!(requests.requests().len(), 1);
app.shutdown_gracefully().await?;
let mut restarted = start_chatgpt_app(home.path(), &server).await?;
for (thread_id, enabled) in [(&first.thread.id, true), (&second.thread.id, false)] {
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.clone(), Some(enabled))
);
}
let resumed: ThreadResumeResponse = restarted
.request(|request_id| ClientRequest::ThreadResume {
request_id,
params: ThreadResumeParams {
thread_id: first.thread.id.clone(),
..Default::default()
},
})
.await?;
assert_eq!(resumed.thread.daybreak_enabled, Some(true));
let forked: ThreadForkResponse = restarted
.request(|request_id| ClientRequest::ThreadFork {
request_id,
params: ThreadForkParams {
thread_id: first.thread.id.clone(),
..Default::default()
},
})
.await?;
assert_eq!(forked.thread.daybreak_enabled, Some(true));
let updated: ThreadMetadataUpdateResponse = restarted
.request(|request_id| ClientRequest::ThreadMetadataUpdate {
request_id,
params: ThreadMetadataUpdateParams {
thread_id: first.thread.id.clone(),
project_id: None,
git_info: None,
daybreak_enabled: Some(false),
},
})
.await?;
assert_eq!(
(updated.thread.id, updated.thread.daybreak_enabled),
(first.thread.id.clone(), Some(false))
);
assert_eq!(requests.requests().len(), 1);
restarted.shutdown_gracefully().await?;
let mut restarted = start_chatgpt_app(home.path(), &server).await?;
for (thread_id, program, enabled) in [
(&forked.thread.id, Some(CyberAccessProgram::Standard), true),
(&forked.thread.id, None, true),
(&first.thread.id, None, false),
] {
let resumed: ThreadResumeResponse = restarted
.request(|request_id| ClientRequest::ThreadResume {
request_id,
params: ThreadResumeParams {
thread_id: thread_id.clone(),
..Default::default()
},
})
.await?;
assert_eq!(resumed.thread.daybreak_enabled, Some(enabled));
let completed = restarted
.start_turn_and_wait_for_completion(TurnStartParams {
thread_id: thread_id.clone(),
cyber_access_program: program,
input: vec![UserInput::Text {
text: "hello".to_owned(),
text_elements: Vec::new(),
}],
..Default::default()
})
.await?;
assert_eq!(completed.turn.status, TurnStatus::Completed);
let resumed: ThreadResumeResponse = restarted
.request(|request_id| ClientRequest::ThreadResume {
request_id,
params: ThreadResumeParams {
thread_id: thread_id.clone(),
..Default::default()
},
})
.await?;
assert_eq!(resumed.thread.daybreak_enabled, Some(enabled));
}
assert_eq!(
requests
.requests()
.iter()
.map(|request| request.body_json()["access_programs"].clone())
.collect::<Vec<_>>(),
[
json!(null),
json!({"cyber": "standard"}),
json!(null),
json!(null),
]
);
Ok(())
}
#[tokio::test]
async fn turn_start_forwards_cyber_access_program_with_personal_access_token() -> Result<()> {
core_test_support::skip_if_no_network!(Ok(()));
@@ -167,3 +321,28 @@ async fn turn_start_forwards_cyber_access_program_with_personal_access_token() -
);
Ok(())
}
async fn start_chatgpt_app(home: &Path, server: &MockServer) -> Result<TestAppServer> {
Mock::given(method("GET"))
.and(path("/v1/responses"))
.respond_with(ResponseTemplate::new(/*s*/ 426))
.mount(server)
.await;
std::fs::write(
home.join("config.toml"),
format!(
"model = \"gpt-5.5\"\napproval_policy = \"never\"\nopenai_base_url = \"{}/v1\"\ncli_auth_credentials_store = \"file\"\n",
server.uri()
),
)?;
write_chatgpt_auth(
home,
ChatGptAuthFixture::new("chatgpt-test-token").plan_type("pro"),
AuthCredentialsStoreMode::File,
)?;
TestAppServer::builder()
.with_codex_home(home)
.without_managed_config()
.build_initialized()
.await
}
@@ -875,6 +875,7 @@ async fn metadata_and_mcp_requests_complete_while_unrelated_resume_loads_config(
params: ThreadMetadataUpdateParams {
thread_id: thread.id.clone(),
project_id: None,
daybreak_enabled: None,
git_info: Some(ThreadMetadataGitInfoUpdateParams {
sha: None,
branch: Some(Some("feature/viewer".to_string())),
@@ -1063,6 +1064,7 @@ async fn resume_revalidates_persisted_thread_after_config_load(
params: ThreadMetadataUpdateParams {
thread_id: thread_id.clone(),
project_id: project_id.clone(),
daybreak_enabled: None,
git_info: None,
},
})
@@ -1087,6 +1089,7 @@ async fn resume_revalidates_persisted_thread_after_config_load(
params: ThreadMetadataUpdateParams {
thread_id: thread_id.clone(),
project_id: project_id.clone(),
daybreak_enabled: None,
git_info: Some(ThreadMetadataGitInfoUpdateParams {
sha: None,
branch: Some(Some("feature/updated-during-resume".to_string())),
@@ -352,6 +352,7 @@ async fn projects_persist_and_assign_threads() -> Result<()> {
params: ThreadMetadataUpdateParams {
thread_id: started.thread.id.clone(),
project_id: Some(String::new()),
daybreak_enabled: None,
git_info: None,
},
})
@@ -398,6 +399,7 @@ async fn projects_persist_and_assign_threads() -> Result<()> {
params: ThreadMetadataUpdateParams {
thread_id: started.thread.id.clone(),
project_id: Some(created.project.id.clone()),
daybreak_enabled: None,
git_info: None,
},
})
@@ -446,6 +448,7 @@ async fn projects_persist_and_assign_threads() -> Result<()> {
params: ThreadMetadataUpdateParams {
thread_id: started.thread.id.clone(),
project_id: Some(created.project.id.clone()),
daybreak_enabled: None,
git_info: None,
},
})
@@ -934,6 +937,7 @@ async fn projects_validate_filters_cursors_and_sqlite_less_assignment() -> Resul
.send_thread_metadata_update_request(ThreadMetadataUpdateParams {
thread_id: started.thread.id.clone(),
project_id: Some(Uuid::now_v7().to_string()),
daybreak_enabled: None,
git_info: Some(ThreadMetadataGitInfoUpdateParams {
sha: Some(Some("abc123".to_string())),
branch: None,
@@ -588,6 +588,7 @@ async fn thread_metadata_update_patches_git_branch_and_returns_updated_thread()
.send_thread_metadata_update_request(ThreadMetadataUpdateParams {
thread_id: thread.id.clone(),
project_id: None,
daybreak_enabled: None,
git_info: Some(ThreadMetadataGitInfoUpdateParams {
sha: None,
branch: Some(Some("feature/sidebar-pr".to_string())),
@@ -690,6 +691,7 @@ async fn thread_metadata_update_sanitizes_git_origin_before_persisting() -> Resu
.send_thread_metadata_update_request(ThreadMetadataUpdateParams {
thread_id: thread.id.clone(),
project_id: None,
daybreak_enabled: None,
git_info: Some(ThreadMetadataGitInfoUpdateParams {
sha: None,
branch: None,
@@ -771,6 +773,7 @@ async fn thread_metadata_update_rejects_empty_git_info_patch() -> Result<()> {
.send_thread_metadata_update_request(ThreadMetadataUpdateParams {
thread_id: thread.id,
project_id: None,
daybreak_enabled: None,
git_info: Some(ThreadMetadataGitInfoUpdateParams {
sha: None,
branch: None,
@@ -822,6 +825,7 @@ async fn thread_metadata_update_rejects_ephemeral_thread() -> Result<()> {
.send_thread_metadata_update_request(ThreadMetadataUpdateParams {
thread_id: thread.id.clone(),
project_id: None,
daybreak_enabled: None,
git_info: Some(ThreadMetadataGitInfoUpdateParams {
sha: None,
branch: Some(Some("feature/ephemeral".to_string())),
@@ -897,6 +901,7 @@ async fn thread_metadata_update_repairs_missing_sqlite_row_for_stored_thread() -
.send_thread_metadata_update_request(ThreadMetadataUpdateParams {
thread_id: thread_id.clone(),
project_id: None,
daybreak_enabled: None,
git_info: Some(ThreadMetadataGitInfoUpdateParams {
sha: None,
branch: Some(Some("feature/stored-thread".to_string())),
@@ -984,6 +989,7 @@ async fn thread_metadata_update_repairs_loaded_thread_without_resetting_summary(
.send_thread_metadata_update_request(ThreadMetadataUpdateParams {
thread_id: thread_id.clone(),
project_id: None,
daybreak_enabled: None,
git_info: Some(ThreadMetadataGitInfoUpdateParams {
sha: None,
branch: Some(Some("feature/loaded-thread".to_string())),
@@ -1056,6 +1062,7 @@ async fn thread_metadata_update_repairs_missing_sqlite_row_for_archived_thread()
.send_thread_metadata_update_request(ThreadMetadataUpdateParams {
thread_id: thread_id.clone(),
project_id: None,
daybreak_enabled: None,
git_info: Some(ThreadMetadataGitInfoUpdateParams {
sha: None,
branch: Some(Some("feature/archived-thread".to_string())),
@@ -1120,6 +1127,7 @@ async fn thread_metadata_update_can_clear_stored_git_fields() -> Result<()> {
.send_thread_metadata_update_request(ThreadMetadataUpdateParams {
thread_id: thread_id.clone(),
project_id: None,
daybreak_enabled: None,
git_info: Some(ThreadMetadataGitInfoUpdateParams {
sha: Some(None),
branch: Some(None),
@@ -4162,6 +4162,7 @@ async fn thread_resume_prefers_persisted_git_metadata_for_local_threads() -> Res
.send_thread_metadata_update_request(ThreadMetadataUpdateParams {
thread_id: thread_id.clone(),
project_id: None,
daybreak_enabled: None,
git_info: Some(ThreadMetadataGitInfoUpdateParams {
sha: None,
branch: Some(Some("feature/pr-branch".to_string())),
@@ -1,5 +1,6 @@
use anyhow::Context;
use anyhow::Result;
use app_test_support::ChatGptAuthFixture;
use app_test_support::MockResponsesConfig;
use app_test_support::TestAppServer;
use app_test_support::create_apply_patch_sse_response;
@@ -12,6 +13,7 @@ use app_test_support::create_mock_responses_server_sequence;
use app_test_support::create_mock_responses_server_sequence_unchecked;
use app_test_support::create_request_user_input_sse_response;
use app_test_support::format_with_current_shell_display;
use app_test_support::write_chatgpt_auth;
use app_test_support::write_mock_responses_config_toml_with_chatgpt_base_url;
use app_test_support::write_models_cache;
use codex_app_server::INPUT_TOO_LARGE_ERROR_CODE;
@@ -27,6 +29,7 @@ use codex_app_server_protocol::CollabAgentToolCallStatus;
use codex_app_server_protocol::CommandExecutionApprovalDecision;
use codex_app_server_protocol::CommandExecutionRequestApprovalResponse;
use codex_app_server_protocol::CommandExecutionStatus;
use codex_app_server_protocol::CyberAccessProgram;
use codex_app_server_protocol::FileChangeApprovalDecision;
use codex_app_server_protocol::FileChangePatchUpdatedNotification;
use codex_app_server_protocol::FileChangeRequestApprovalResponse;
@@ -50,6 +53,8 @@ use codex_app_server_protocol::ThreadInjectItemsParams;
use codex_app_server_protocol::ThreadItem;
use codex_app_server_protocol::ThreadLoadedListParams;
use codex_app_server_protocol::ThreadLoadedListResponse;
use codex_app_server_protocol::ThreadMetadataUpdateParams;
use codex_app_server_protocol::ThreadMetadataUpdateResponse;
use codex_app_server_protocol::ThreadSettingsUpdateParams;
use codex_app_server_protocol::ThreadSettingsUpdatedNotification;
use codex_app_server_protocol::ThreadShellCommandParams;
@@ -70,6 +75,7 @@ use codex_app_server_protocol::WarningNotification;
use codex_core::test_support::all_model_presets;
use codex_exec_server::LOCAL_ENVIRONMENT_ID;
use codex_features::Feature;
use codex_login::AuthCredentialsStoreMode;
use codex_protocol::config_types::CollaborationMode;
use codex_protocol::config_types::ModeKind;
use codex_protocol::config_types::MultiAgentMode;
@@ -440,9 +446,21 @@ async fn turn_start_steers_active_turn_and_returns_active_turn_id() -> Result<()
])
.await;
let codex_home = TempDir::new()?;
MockResponsesConfig::new(server.uri()).write(codex_home.path())?;
std::fs::write(
codex_home.path().join("config.toml"),
format!(
"model = \"gpt-5.5\"\napproval_policy = \"never\"\nopenai_base_url = \"{}/v1\"\ncli_auth_credentials_store = \"file\"\n[features]\nenable_request_compression = false\n",
server.uri()
),
)?;
write_chatgpt_auth(
codex_home.path(),
ChatGptAuthFixture::new("chatgpt-test-token").plan_type("pro"),
AuthCredentialsStoreMode::File,
)?;
let mut mcp = TestAppServer::builder()
.with_codex_home(codex_home.path())
.without_managed_config()
.build_initialized()
.await?;
@@ -463,6 +481,7 @@ async fn turn_start_steers_active_turn_and_returns_active_turn_id() -> Result<()
text_elements: Vec::new(),
}],
turn_trigger: Some("user".to_string()),
cyber_access_program: Some(CyberAccessProgram::DaybreakBlue),
..Default::default()
},
})
@@ -472,6 +491,27 @@ async fn turn_start_steers_active_turn_and_returns_active_turn_id() -> Result<()
mcp.read_stream_until_notification_message("turn/started"),
)
.await??;
timeout(
DEFAULT_READ_TIMEOUT,
server.wait_for_request_count(/*count*/ 1),
)
.await?;
let updated: ThreadMetadataUpdateResponse = mcp
.request(|request_id| ClientRequest::ThreadMetadataUpdate {
request_id,
params: ThreadMetadataUpdateParams {
thread_id: thread.id.clone(),
project_id: None,
git_info: None,
daybreak_enabled: Some(false),
},
})
.await?;
assert_eq!(
(updated.thread.id, updated.thread.daybreak_enabled),
(thread.id.clone(), Some(false))
);
let TurnStartResponse { turn: steered_turn } = mcp
.request(|request_id| ClientRequest::TurnStart {
@@ -484,6 +524,7 @@ async fn turn_start_steers_active_turn_and_returns_active_turn_id() -> Result<()
text_elements: Vec::new(),
}],
turn_trigger: Some("goal".to_string()),
cyber_access_program: Some(CyberAccessProgram::Standard),
..Default::default()
},
})
@@ -503,6 +544,7 @@ async fn turn_start_steers_active_turn_and_returns_active_turn_id() -> Result<()
assert_eq!(requests.len(), 2);
for request in requests {
let body: Value = serde_json::from_slice(&request)?;
assert_eq!(body["access_programs"], json!({"cyber": "daybreak_blue"}));
let turn_metadata: Value = serde_json::from_str(
body["client_metadata"]["x-codex-turn-metadata"]
.as_str()
@@ -54,6 +54,7 @@ fn stored_thread(cwd: &str, title: &str, first_user_message: &str) -> StoredThre
.expect("valid timestamp"),
archived_at: None,
project_id: None,
daybreak_enabled: None,
section: None,
section_position: None,
section_entered_at: None,
@@ -52,6 +52,7 @@ impl StreamingSseServer {
/// Starts a lightweight HTTP server that supports:
/// - GET /v1/models -> empty models response
/// - GET /v1/responses -> 426 to select the HTTP fallback
/// - POST /v1/responses -> SSE stream gated per-chunk, served in order
///
/// Returns the server handle and a list of receivers that fire when each
@@ -116,6 +117,11 @@ pub async fn start_streaming_sse_server(
return;
}
if method == "GET" && path == "/v1/responses" {
let _ = write_http_response(&mut stream, /*status*/ 426, "websockets unsupported", "text/plain").await;
return;
}
if method == "POST" && path == "/v1/responses" {
let body = match read_request_body(&mut stream, &request, body_prefix)
.await
+2
View File
@@ -362,6 +362,7 @@ fn turn_items_for_thread_returns_matching_turn_items() {
section: None,
section_entered_at: None,
project_id: None,
daybreak_enabled: None,
history_mode: Default::default(),
model_provider: "openai".to_string(),
model: None,
@@ -855,6 +856,7 @@ fn sample_thread_start_response() -> ThreadStartResponse {
section: None,
section_entered_at: None,
project_id: None,
daybreak_enabled: None,
history_mode: Default::default(),
model_provider: "openai".to_string(),
model: None,
+3
View File
@@ -64,6 +64,8 @@ pub struct ThreadItem {
pub section: Option<codex_state::ThreadSection>,
/// Canonical project assignment in SQLite-owned metadata.
pub project_id: Option<String>,
/// Saved Daybreak choice in SQLite-owned metadata, when available.
pub daybreak_enabled: Option<bool>,
/// Working directory from session metadata.
pub cwd: Option<PathBuf>,
/// Git branch from session metadata.
@@ -848,6 +850,7 @@ async fn build_thread_item(
preview,
section: None,
project_id: None,
daybreak_enabled: None,
cwd,
git_branch,
git_sha,
+3
View File
@@ -1289,6 +1289,7 @@ fn fill_missing_thread_item_metadata(item: &mut ThreadItem, state_item: ThreadIt
preview,
section,
project_id,
daybreak_enabled,
cwd,
git_branch,
git_sha,
@@ -1319,6 +1320,7 @@ fn fill_missing_thread_item_metadata(item: &mut ThreadItem, state_item: ThreadIt
}
item.section = section;
item.project_id = project_id;
item.daybreak_enabled = daybreak_enabled;
item.model = model;
item.reasoning_effort = reasoning_effort;
if item.cwd.is_none() {
@@ -2036,6 +2038,7 @@ fn thread_item_from_state_metadata(
preview: item.preview,
section: item.section,
project_id: item.project_id,
daybreak_enabled: item.daybreak_enabled,
cwd: Some(item.cwd),
git_branch: item.git_branch,
git_sha: item.git_sha,
+3
View File
@@ -1600,6 +1600,7 @@ fn fill_missing_thread_item_metadata_preserves_identity_and_prefers_state_git_fi
first_user_message: Some("filesystem message".to_string()),
preview: Some("filesystem preview".to_string()),
project_id: None,
daybreak_enabled: None,
section: None,
cwd: None,
git_branch: Some("filesystem-branch".to_string()),
@@ -1628,6 +1629,7 @@ fn fill_missing_thread_item_metadata_preserves_identity_and_prefers_state_git_fi
first_user_message: Some("state message".to_string()),
preview: Some("state preview".to_string()),
project_id: None,
daybreak_enabled: Some(true),
section: Some(codex_state::ThreadSection {
id: codex_state::PINNED_THREAD_SECTION_ID.to_string(),
name: codex_state::PINNED_THREAD_SECTION_NAME.to_string(),
@@ -1658,6 +1660,7 @@ fn fill_missing_thread_item_metadata_preserves_identity_and_prefers_state_git_fi
assert_eq!(item.path, filesystem_path);
assert_eq!(item.thread_id, Some(filesystem_thread_id));
assert_eq!(item.daybreak_enabled, Some(true));
assert_eq!(
item.section,
Some(codex_state::ThreadSection {
+11
View File
@@ -725,6 +725,7 @@ async fn test_list_conversations_latest_first() {
first_user_message: Some("Hello from user".to_string()),
preview: Some("Hello from user".to_string()),
project_id: None,
daybreak_enabled: None,
section: None,
cwd: Some(Path::new(".").to_path_buf()),
git_branch: None,
@@ -750,6 +751,7 @@ async fn test_list_conversations_latest_first() {
first_user_message: Some("Hello from user".to_string()),
preview: Some("Hello from user".to_string()),
project_id: None,
daybreak_enabled: None,
section: None,
cwd: Some(Path::new(".").to_path_buf()),
git_branch: None,
@@ -775,6 +777,7 @@ async fn test_list_conversations_latest_first() {
first_user_message: Some("Hello from user".to_string()),
preview: Some("Hello from user".to_string()),
project_id: None,
daybreak_enabled: None,
section: None,
cwd: Some(Path::new(".").to_path_buf()),
git_branch: None,
@@ -893,6 +896,7 @@ async fn test_pagination_cursor() {
first_user_message: Some("Hello from user".to_string()),
preview: Some("Hello from user".to_string()),
project_id: None,
daybreak_enabled: None,
section: None,
cwd: Some(Path::new(".").to_path_buf()),
git_branch: None,
@@ -918,6 +922,7 @@ async fn test_pagination_cursor() {
first_user_message: Some("Hello from user".to_string()),
preview: Some("Hello from user".to_string()),
project_id: None,
daybreak_enabled: None,
section: None,
cwd: Some(Path::new(".").to_path_buf()),
git_branch: None,
@@ -979,6 +984,7 @@ async fn test_pagination_cursor() {
first_user_message: Some("Hello from user".to_string()),
preview: Some("Hello from user".to_string()),
project_id: None,
daybreak_enabled: None,
section: None,
cwd: Some(Path::new(".").to_path_buf()),
git_branch: None,
@@ -1004,6 +1010,7 @@ async fn test_pagination_cursor() {
first_user_message: Some("Hello from user".to_string()),
preview: Some("Hello from user".to_string()),
project_id: None,
daybreak_enabled: None,
section: None,
cwd: Some(Path::new(".").to_path_buf()),
git_branch: None,
@@ -1057,6 +1064,7 @@ async fn test_pagination_cursor() {
first_user_message: Some("Hello from user".to_string()),
preview: Some("Hello from user".to_string()),
project_id: None,
daybreak_enabled: None,
section: None,
cwd: Some(Path::new(".").to_path_buf()),
git_branch: None,
@@ -1235,6 +1243,7 @@ async fn test_get_thread_contents() {
first_user_message: Some("Hello from user".to_string()),
preview: Some("Hello from user".to_string()),
project_id: None,
daybreak_enabled: None,
section: None,
cwd: Some(Path::new(".").to_path_buf()),
git_branch: None,
@@ -1641,6 +1650,7 @@ async fn test_timestamp_only_cursor_skips_same_second_filesystem_ties() {
first_user_message: Some("Hello from user".to_string()),
preview: Some("Hello from user".to_string()),
project_id: None,
daybreak_enabled: None,
section: None,
cwd: Some(Path::new(".").to_path_buf()),
git_branch: None,
@@ -1666,6 +1676,7 @@ async fn test_timestamp_only_cursor_skips_same_second_filesystem_ties() {
first_user_message: Some("Hello from user".to_string()),
preview: Some("Hello from user".to_string()),
project_id: None,
daybreak_enabled: None,
section: None,
cwd: Some(Path::new(".").to_path_buf()),
git_branch: None,
@@ -0,0 +1 @@
ALTER TABLE threads ADD COLUMN daybreak_enabled BOOLEAN;
+1
View File
@@ -728,6 +728,7 @@ mod tests {
section_position: None,
section_entered_at: None,
project_id: None,
daybreak_enabled: None,
git_sha: None,
git_branch: None,
git_origin_url: None,
@@ -181,6 +181,8 @@ pub struct ThreadMetadata {
pub section_entered_at: Option<DateTime<Utc>>,
/// Canonical project assignment owned by app-server, if any.
pub project_id: Option<String>,
/// User-selected Daybreak preference, absent until explicitly set.
pub daybreak_enabled: Option<bool>,
/// The git commit SHA, if known.
pub git_sha: Option<String>,
/// The git branch name, if known.
@@ -319,6 +321,7 @@ impl ThreadMetadataBuilder {
section_position: None,
section_entered_at: None,
project_id: None,
daybreak_enabled: None,
git_sha: self.git_sha.clone(),
git_branch: self.git_branch.clone(),
git_origin_url: self.git_origin_url.clone(),
@@ -448,6 +451,9 @@ impl ThreadMetadata {
if self.project_id != other.project_id {
diffs.push("project_id");
}
if self.daybreak_enabled != other.daybreak_enabled {
diffs.push("daybreak_enabled");
}
if self.git_sha != other.git_sha {
diffs.push("git_sha");
}
@@ -498,6 +504,7 @@ pub(crate) struct ThreadRow {
section_position: Option<i64>,
section_entered_at_ms: Option<i64>,
project_id: Option<String>,
daybreak_enabled: Option<bool>,
git_sha: Option<String>,
git_branch: Option<String>,
git_origin_url: Option<String>,
@@ -537,6 +544,7 @@ impl ThreadRow {
section_position: row.try_get("section_position")?,
section_entered_at_ms: row.try_get("section_entered_at_ms")?,
project_id: row.try_get("project_id")?,
daybreak_enabled: row.try_get("daybreak_enabled")?,
git_sha: row.try_get("git_sha")?,
git_branch: row.try_get("git_branch")?,
git_origin_url: row.try_get("git_origin_url")?,
@@ -580,6 +588,7 @@ impl TryFrom<ThreadRow> for ThreadMetadata {
section_position,
section_entered_at_ms,
project_id,
daybreak_enabled,
git_sha,
git_branch,
git_origin_url,
@@ -638,6 +647,7 @@ impl TryFrom<ThreadRow> for ThreadMetadata {
.map(epoch_millis_to_datetime)
.transpose()?,
project_id,
daybreak_enabled,
git_sha,
git_branch,
git_origin_url: git_origin_url
@@ -748,6 +758,7 @@ mod tests {
section_position: None,
section_entered_at_ms: None,
project_id: None,
daybreak_enabled: None,
git_sha: None,
git_branch: None,
git_origin_url: None,
@@ -786,6 +797,7 @@ mod tests {
section_position: None,
section_entered_at: None,
project_id: None,
daybreak_enabled: None,
git_sha: None,
git_branch: None,
git_origin_url: None,
+2
View File
@@ -210,6 +210,7 @@ SELECT
threads.section_position,
threads.section_entered_at_ms,
threads.project_id,
threads.daybreak_enabled,
threads.git_sha,
threads.git_branch,
threads.git_origin_url
@@ -601,6 +602,7 @@ SELECT
threads.section_position,
threads.section_entered_at_ms,
threads.project_id,
threads.daybreak_enabled,
threads.git_sha,
threads.git_branch,
threads.git_origin_url
@@ -74,6 +74,7 @@ pub(super) fn test_thread_metadata(
section_position: None,
section_entered_at: None,
project_id: None,
daybreak_enabled: None,
git_sha: None,
git_branch: None,
git_origin_url: None,
+25 -4
View File
@@ -49,6 +49,7 @@ SELECT
threads.section_position,
threads.section_entered_at_ms,
threads.project_id,
threads.daybreak_enabled,
threads.git_sha,
threads.git_branch,
threads.git_origin_url
@@ -646,8 +647,9 @@ INSERT INTO threads (
git_branch,
git_origin_url,
memory_mode,
project_id
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
project_id,
daybreak_enabled
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO NOTHING
"#,
)
@@ -698,6 +700,7 @@ ON CONFLICT(id) DO NOTHING
.bind(metadata.git_origin_url.as_deref())
.bind("enabled")
.bind(metadata.project_id.as_deref())
.bind(metadata.daybreak_enabled)
.execute(self.pool.as_ref())
.await?;
self.insert_thread_spawn_edge_from_source_if_absent(metadata.id, metadata.source.as_str())
@@ -705,6 +708,20 @@ ON CONFLICT(id) DO NOTHING
Ok(result.rows_affected() > 0)
}
/// Set the user preference without changing rollout-derived thread metadata.
pub async fn set_thread_daybreak_enabled(
&self,
thread_id: ThreadId,
daybreak_enabled: bool,
) -> anyhow::Result<bool> {
let result = sqlx::query("UPDATE threads SET daybreak_enabled = ? WHERE id = ?")
.bind(daybreak_enabled)
.bind(thread_id.to_string())
.execute(self.pool.as_ref())
.await?;
Ok(result.rows_affected() > 0)
}
pub async fn set_thread_memory_mode(
&self,
thread_id: ThreadId,
@@ -886,6 +903,7 @@ WHERE id = ?
// Backfill/reconcile callers merge existing git info before upserting, but that
// read/modify/write is not atomic. Preserve non-null SQLite git fields here so
// an explicit metadata update cannot be lost if a stale rollout upsert lands later.
// Daybreak and project choices are insert-only here; explicit changes use their setters.
sqlx::query(
r#"
INSERT INTO threads (
@@ -925,8 +943,9 @@ INSERT INTO threads (
git_branch,
git_origin_url,
memory_mode,
project_id
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
project_id,
daybreak_enabled
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
rollout_path = excluded.rollout_path,
created_at = excluded.created_at,
@@ -1011,6 +1030,7 @@ ON CONFLICT(id) DO UPDATE SET
.bind(metadata.git_origin_url.as_deref())
.bind(creation_memory_mode.unwrap_or("enabled"))
.bind(metadata.project_id.as_deref())
.bind(metadata.daybreak_enabled)
.execute(self.pool.as_ref())
.await?;
self.insert_thread_spawn_edge_from_source_if_absent(metadata.id, metadata.source.as_str())
@@ -1312,6 +1332,7 @@ SELECT
threads.section_position,
threads.section_entered_at_ms,
threads.project_id,
threads.daybreak_enabled,
threads.git_sha,
threads.git_branch,
threads.git_origin_url
+1
View File
@@ -1078,6 +1078,7 @@ fn stored_thread_from_state(
section_position: state.section_positions.get(&thread_id).copied(),
section_entered_at: state.section_entered_at.get(&thread_id).copied(),
project_id: None,
daybreak_enabled: metadata.and_then(|metadata| metadata.daybreak_enabled),
cwd: metadata
.and_then(|metadata| metadata.cwd.clone())
.unwrap_or_default(),
@@ -0,0 +1,105 @@
//! Covers the saved Daybreak preference independently of transcript history.
use codex_protocol::ThreadId;
use pretty_assertions::assert_eq;
use tempfile::TempDir;
use uuid::Uuid;
use super::LocalThreadStore;
use super::test_support::test_config;
use super::test_support::write_session_file;
use crate::ReadThreadParams;
use crate::ThreadMetadataPatch;
use crate::ThreadStore;
use crate::ThreadStoreError;
use crate::UpdateThreadMetadataParams;
#[tokio::test]
async fn daybreak_preference_survives_reconciliation_and_cold_reads()
-> Result<(), Box<dyn std::error::Error>> {
let home = TempDir::new()?;
let config = test_config(home.path());
let uuid = Uuid::new_v4();
let thread_id = ThreadId::from_string(&uuid.to_string())?;
let rollout_path = write_session_file(home.path(), "2025-01-03T14-20-00", uuid)?;
let original_rollout = std::fs::read(&rollout_path)?;
let runtime = codex_state::StateRuntime::init(
config.sqlite.clone(),
config.default_model_provider_id.clone(),
)
.await?;
let store = LocalThreadStore::new(config.clone(), Some(runtime.clone()));
for daybreak_enabled in [true, false] {
let updated = store
.update_thread_metadata(UpdateThreadMetadataParams {
thread_id,
patch: ThreadMetadataPatch {
daybreak_enabled: Some(daybreak_enabled),
..Default::default()
},
include_archived: false,
})
.await?
.expect("updated thread");
assert_eq!(updated.daybreak_enabled, Some(daybreak_enabled));
}
let mut stale_metadata = runtime
.get_thread(thread_id)
.await?
.expect("saved metadata");
stale_metadata.daybreak_enabled = Some(true);
runtime.upsert_thread(&stale_metadata).await?;
codex_rollout::state_db::reconcile_rollout(
Some(runtime.as_ref()),
&rollout_path,
&config.default_model_provider_id,
/*builder*/ None,
&[],
/*archived_only*/ None,
/*new_thread_memory_mode*/ None,
)
.await;
drop(store);
drop(runtime);
let runtime = codex_state::StateRuntime::init(
config.sqlite.clone(),
config.default_model_provider_id.clone(),
)
.await?;
let store = LocalThreadStore::new(config, Some(runtime));
for include_history in [false, true] {
let thread = store
.read_thread(ReadThreadParams {
thread_id,
include_archived: false,
include_history,
})
.await?;
assert_eq!(thread.daybreak_enabled, Some(false));
}
assert_eq!(std::fs::read(rollout_path)?, original_rollout);
Ok(())
}
#[tokio::test]
async fn daybreak_preference_requires_sqlite() -> Result<(), Box<dyn std::error::Error>> {
let home = TempDir::new()?;
let uuid = Uuid::new_v4();
let thread_id = ThreadId::from_string(&uuid.to_string())?;
write_session_file(home.path(), "2025-01-03T14-20-00", uuid)?;
let store = LocalThreadStore::new(test_config(home.path()), /*state_db*/ None);
let result = store
.update_thread_metadata(UpdateThreadMetadataParams {
thread_id,
patch: ThreadMetadataPatch {
daybreak_enabled: Some(false),
..Default::default()
},
include_archived: false,
})
.await;
assert!(matches!(result, Err(ThreadStoreError::Internal { .. })));
Ok(())
}
@@ -174,6 +174,7 @@ pub(super) fn stored_thread_from_rollout_item(
section_position: None,
section_entered_at: None,
project_id: item.project_id,
daybreak_enabled: item.daybreak_enabled,
cwd: item.cwd.unwrap_or_default(),
cli_version: item.cli_version.unwrap_or_default(),
originator: item.originator,
+3
View File
@@ -24,6 +24,9 @@ mod unarchive_thread;
mod update_thread_metadata;
mod writer_lock;
#[cfg(test)]
#[path = "daybreak_metadata_tests.rs"]
mod daybreak_metadata_tests;
#[cfg(test)]
#[path = "pending_thread_metadata_tests.rs"]
mod pending_thread_metadata_tests;
@@ -36,6 +36,9 @@ pub(super) async fn read_thread(
let persisted_model_settings = sqlite_metadata
.as_ref()
.map(|metadata| (metadata.model.clone(), metadata.reasoning_effort.clone()));
let daybreak_enabled = sqlite_metadata
.as_ref()
.and_then(|metadata| metadata.daybreak_enabled);
if let Some(metadata) = sqlite_metadata
&& (params.include_archived
|| (metadata.archived_at.is_none()
@@ -70,6 +73,7 @@ pub(super) async fn read_thread(
rollout_thread.name = thread.name;
}
rollout_thread.project_id = thread.project_id;
rollout_thread.daybreak_enabled = thread.daybreak_enabled;
rollout_thread.model = thread.model;
rollout_thread.reasoning_effort = thread.reasoning_effort;
rollout_thread.git_info = thread.git_info;
@@ -97,6 +101,7 @@ pub(super) async fn read_thread(
})?;
let mut thread = read_thread_from_rollout_path(store, path).await?;
thread.daybreak_enabled = daybreak_enabled;
if let Some((model, reasoning_effort)) = persisted_model_settings {
thread.model = model;
thread.reasoning_effort = reasoning_effort;
@@ -151,6 +156,7 @@ pub(super) async fn read_thread_by_rollout_path(
thread.section_position = metadata.section_position;
thread.section_entered_at = metadata.section_entered_at;
thread.project_id = metadata.project_id;
thread.daybreak_enabled = metadata.daybreak_enabled;
thread.model = metadata.model;
thread.reasoning_effort = metadata.reasoning_effort;
if !metadata.cwd.as_os_str().is_empty()
@@ -400,6 +406,7 @@ pub(super) fn stored_thread_from_state_metadata(
section_position: metadata.section_position,
section_entered_at: metadata.section_entered_at,
project_id: metadata.project_id,
daybreak_enabled: metadata.daybreak_enabled,
cwd: metadata.cwd,
cli_version: metadata.cli_version,
originator: metadata.originator,
@@ -500,6 +507,7 @@ fn stored_thread_from_meta_line(
section_position: None,
section_entered_at: None,
project_id: None,
daybreak_enabled: None,
cwd: meta_line.meta.cwd,
cli_version: meta_line.meta.cli_version,
originator: (!meta_line.meta.originator.is_empty()).then_some(meta_line.meta.originator),
@@ -451,6 +451,9 @@ async fn apply_metadata_update(
if let Some(project_id) = project_id.as_ref() {
metadata.project_id = project_id.clone();
}
if let Some(daybreak_enabled) = patch.daybreak_enabled {
metadata.daybreak_enabled = Some(daybreak_enabled);
}
let upsert_result = state_db.upsert_thread(&metadata).await;
if existing.is_none()
&& metadata.project_id.is_some()
@@ -540,6 +543,22 @@ async fn apply_metadata_update(
message: format!("failed to update memory mode for {thread_id}: {err}"),
})?;
}
if let Some(daybreak_enabled) = patch.daybreak_enabled
&& !state_db
.set_thread_daybreak_enabled(thread_id, daybreak_enabled)
.await
.map_err(|err| ThreadStoreError::Internal {
message: format!(
"failed to update Daybreak preference for {thread_id}: {err}"
),
})?
{
return Err(ThreadStoreError::Internal {
message: format!(
"thread metadata unavailable before Daybreak update: {thread_id}"
),
});
}
Ok(())
}
.await
@@ -655,9 +674,11 @@ fn sqlite_write_failure_should_block(patch: &ThreadMetadataPatch) -> bool {
// transcript-derived metadata, thread names, and memory-mode indexing were log-only. Keep that
// failure isolation so a corrupted optional state DB does not make JSONL transcript durability
// look broken. Explicit git-only updates still require SQLite because partial git patches need
// the existing SQLite value to preserve unspecified fields. Project updates always require
// SQLite because assignment only exists in the state database.
patch.project_id.is_some() || (patch.git_info.is_some() && !has_observed_metadata_facts(patch))
// the existing SQLite value to preserve unspecified fields. Project and Daybreak updates
// require SQLite because those preferences only exist in the state database.
patch.project_id.is_some()
|| patch.daybreak_enabled.is_some()
|| (patch.git_info.is_some() && !has_observed_metadata_facts(patch))
}
fn sqlite_write_error_is_best_effort(err: &ThreadStoreError) -> bool {
+9
View File
@@ -611,6 +611,9 @@ pub struct StoredThread {
/// Canonical project assignment owned by app-server, if any.
#[serde(default)]
pub project_id: Option<String>,
/// User-selected Daybreak preference, absent until explicitly set.
#[serde(default)]
pub daybreak_enabled: Option<bool>,
/// Working directory captured for the thread.
pub cwd: PathBuf,
/// CLI version captured for the thread.
@@ -780,6 +783,8 @@ pub struct ThreadMetadataPatch {
with = "optional_option"
)]
pub project_id: ClearableField<String>,
/// User-selected Daybreak preference; omission leaves it unchanged.
pub daybreak_enabled: Option<bool>,
}
impl ThreadMetadataPatch {
@@ -866,6 +871,9 @@ impl ThreadMetadataPatch {
if next.project_id.is_some() {
self.project_id = next.project_id;
}
if next.daybreak_enabled.is_some() {
self.daybreak_enabled = next.daybreak_enabled;
}
}
pub fn is_empty(&self) -> bool {
@@ -894,6 +902,7 @@ impl ThreadMetadataPatch {
&& self.git_info.is_none()
&& self.memory_mode.is_none()
&& self.project_id.is_none()
&& self.daybreak_enabled.is_none()
}
}
@@ -232,6 +232,7 @@ fn overview_thread(
id: thread_id.to_string(),
extra: None,
project_id: None,
daybreak_enabled: None,
session_id: parent_thread_id.unwrap_or(thread_id).to_string(),
forked_from_id: None,
parent_thread_id: None,
+1
View File
@@ -147,6 +147,7 @@ mod tests {
section: None,
section_entered_at: None,
project_id: None,
daybreak_enabled: None,
history_mode: Default::default(),
model_provider: "openai".to_string(),
model: None,
+3
View File
@@ -4078,6 +4078,7 @@ async fn inactive_thread_started_notification_initializes_replay_session() -> Re
section: None,
section_entered_at: None,
project_id: None,
daybreak_enabled: None,
history_mode: Default::default(),
model_provider: "agent-provider".to_string(),
model: None,
@@ -4181,6 +4182,7 @@ async fn inactive_thread_started_notification_preserves_primary_model_when_path_
section: None,
section_entered_at: None,
project_id: None,
daybreak_enabled: None,
history_mode: Default::default(),
model_provider: "agent-provider".to_string(),
model: None,
@@ -4251,6 +4253,7 @@ async fn thread_read_session_state_does_not_reuse_primary_permission_profile() {
section: None,
section_entered_at: None,
project_id: None,
daybreak_enabled: None,
history_mode: Default::default(),
model_provider: "read-provider".to_string(),
model: None,
@@ -1311,6 +1311,7 @@ async fn dynamic_tool_requests_ignore_other_namespaces_and_dispatch_tui_namespac
params: codex_app_server_protocol::ThreadMetadataUpdateParams {
thread_id: creation_source.to_string(),
project_id: Some(project.project.id.clone()),
daybreak_enabled: None,
git_info: None,
},
})
+1
View File
@@ -945,6 +945,7 @@ async fn known_thread_started_preserves_session_without_reading_unmaterialized_r
section: None,
section_entered_at: None,
project_id: None,
daybreak_enabled: None,
history_mode: Default::default(),
model_provider: "notification-provider".to_string(),
model: None,
@@ -422,6 +422,7 @@ mod tests {
section: None,
section_entered_at: None,
project_id: None,
daybreak_enabled: None,
history_mode: Default::default(),
model_provider: "read-provider".to_string(),
model: None,
+2
View File
@@ -1172,6 +1172,7 @@ impl AppServerSession {
params: ThreadMetadataUpdateParams {
thread_id: thread_id.to_string(),
project_id: None,
daybreak_enabled: None,
git_info: Some(ThreadMetadataGitInfoUpdateParams {
sha: None,
branch: Some(Some(branch)),
@@ -3748,6 +3749,7 @@ mod tests {
section: None,
section_entered_at: None,
project_id: None,
daybreak_enabled: None,
history_mode: Default::default(),
model_provider: "openai".to_string(),
model: None,
+4
View File
@@ -6287,6 +6287,7 @@ session_picker_view = "dense"
section: None,
section_entered_at: None,
project_id: None,
daybreak_enabled: None,
history_mode: Default::default(),
model_provider: String::from("openai"),
model: None,
@@ -6333,6 +6334,7 @@ session_picker_view = "dense"
section: None,
section_entered_at: None,
project_id: None,
daybreak_enabled: None,
history_mode: Default::default(),
model_provider: String::from("openai"),
model: None,
@@ -6419,6 +6421,7 @@ session_picker_view = "dense"
section: None,
section_entered_at: None,
project_id: None,
daybreak_enabled: None,
history_mode: Default::default(),
model_provider: String::from("openai"),
model: None,
@@ -6496,6 +6499,7 @@ session_picker_view = "dense"
section: None,
section_entered_at: None,
project_id: None,
daybreak_enabled: None,
history_mode: Default::default(),
model_provider: String::from("openai"),
model: None,