mirror of
https://github.com/openai/codex.git
synced 2026-10-25 18:28:38 +00:00
## Description This PR adds a new `historyMode = "legacy" | "paginated"` to `Thread`. This will be stored in `SessionMeta` in the JSONL rollout file and as a new column in the SQLite thread_metadata table, and exposed on `thread/start` and on the `Thread` object in app-server. ## What changed - Added canonical `ThreadHistoryMode` with `legacy` and `paginated`, defaulting old and new SessionMeta to `legacy`. - Carried `history_mode` through core session config, ThreadStore stored metadata, local/in-memory stores, rollout metadata extraction, and the existing SQLite `threads` table. - Added experimental `historyMode` to app-server v2 `Thread` and `thread/start`. - Made paginated stored threads metadata-discoverable but unsupported for legacy full-history reads, `load_history`, live resume, and create paths. - Regenerated app-server schema fixtures and added protocol/state/thread-store/app-server coverage for persistence and fail-closed behavior. ## Compatibility floor Because users may be running various versions of Codex binaries on the same machine (TUI, Codex App, etc.), we will need to establish a compatibility floor for upcoming paginated threads, which will change how thread storage reads and writes work. The overall plan here: ``` Release N: - Add historyMode to SessionMeta / Thread / SQLite metadata. - Teach binaries to understand paginated threads. - If a binary sees `historyMode="paginated"` but does not support the paginated contract, it refuses to resume/mutate the thread. - Default remains `"legacy"`. Release N+1: - First-party clients start opting into paginated threads where appropriate. - Internal dogfood / staged rollout. - Measure old-client usage and paginated-thread unsupported errors. Release N+2: - Only after Release N+ is overwhelmingly deployed, make paginated the default. - Accept that a small tail of N-1-or-older binaries may not understand paginated threads. ``` The important behavior change is fail-closed handling for a binary that encounters a persisted `paginated` thread before it knows how to fully support paginated history. In app-server, if a thread is `paginated`, we will: - allow metadata-only discovery paths like `thread/list` and `thread/read(includeTurns=false)`, so clients can still see the thread and inspect its `historyMode` - reject legacy full-history/live-thread paths like `thread/read(includeTurns=true)` and `thread/resume` with an unsupported JSON-RPC error - avoid silently treating an unknown or future `historyMode` as `legacy` Under the hood, the ThreadStore layer also rejects legacy operations that would need to load or replay the full thread history for a paginated thread. That gives us the behavior we want for Release N: future paginated threads are visible, but this binary fails closed instead of trying to operate on them as if they were legacy threads.
458 lines
18 KiB
Rust
458 lines
18 KiB
Rust
use super::App;
|
|
use crate::session_resume::read_session_model;
|
|
use crate::session_state::ThreadSessionState;
|
|
use codex_app_server_protocol::AskForApproval;
|
|
use codex_app_server_protocol::Thread;
|
|
use codex_protocol::ThreadId;
|
|
use codex_protocol::models::ActivePermissionProfile;
|
|
use codex_protocol::models::PermissionProfile;
|
|
|
|
impl App {
|
|
pub(super) async fn sync_active_thread_service_tier_to_cached_session(&mut self) {
|
|
let Some(active_thread_id) = self.active_thread_id else {
|
|
return;
|
|
};
|
|
|
|
let service_tier = self.chat_widget.current_service_tier().map(str::to_string);
|
|
let update_session = |session: &mut ThreadSessionState| {
|
|
session.service_tier = service_tier.clone();
|
|
};
|
|
|
|
if self.primary_thread_id == Some(active_thread_id)
|
|
&& let Some(session) = self.primary_session_configured.as_mut()
|
|
{
|
|
update_session(session);
|
|
}
|
|
|
|
if let Some(channel) = self.thread_event_channels.get(&active_thread_id) {
|
|
let mut store = channel.store.lock().await;
|
|
if let Some(session) = store.session.as_mut() {
|
|
update_session(session);
|
|
}
|
|
}
|
|
}
|
|
|
|
pub(super) async fn sync_active_thread_permission_settings_to_cached_session(&mut self) {
|
|
let Some(active_thread_id) = self.active_thread_id else {
|
|
return;
|
|
};
|
|
|
|
let approval_policy = AskForApproval::from(self.config.permissions.approval_policy.value());
|
|
let approvals_reviewer = self.config.approvals_reviewer;
|
|
let permission_profile = self
|
|
.chat_widget
|
|
.config_ref()
|
|
.permissions
|
|
.permission_profile()
|
|
.clone();
|
|
let active_permission_profile = self
|
|
.chat_widget
|
|
.config_ref()
|
|
.permissions
|
|
.active_permission_profile();
|
|
let update_session = |session: &mut ThreadSessionState| {
|
|
session.approval_policy = approval_policy;
|
|
session.approvals_reviewer = approvals_reviewer;
|
|
session.permission_profile = permission_profile.clone();
|
|
session.active_permission_profile = active_permission_profile.clone();
|
|
};
|
|
|
|
if self.primary_thread_id == Some(active_thread_id)
|
|
&& let Some(session) = self.primary_session_configured.as_mut()
|
|
{
|
|
update_session(session);
|
|
}
|
|
|
|
if let Some(channel) = self.thread_event_channels.get(&active_thread_id) {
|
|
let mut store = channel.store.lock().await;
|
|
if let Some(session) = store.session.as_mut() {
|
|
update_session(session);
|
|
}
|
|
}
|
|
}
|
|
|
|
pub(super) async fn session_state_for_thread_read(
|
|
&self,
|
|
thread_id: ThreadId,
|
|
thread: &Thread,
|
|
) -> ThreadSessionState {
|
|
let permission_profile = self.current_permission_profile();
|
|
let active_permission_profile = self.current_active_permission_profile();
|
|
let mut session = if let Some(mut session) = self.primary_session_configured.clone() {
|
|
if session.thread_id != thread_id {
|
|
// `thread/read` does not include thread settings, so do not carry
|
|
// thread-scoped state from the currently active session.
|
|
session.collaboration_mode = None;
|
|
session.personality = None;
|
|
}
|
|
session
|
|
} else {
|
|
ThreadSessionState {
|
|
thread_id,
|
|
forked_from_id: None,
|
|
fork_parent_title: None,
|
|
thread_name: None,
|
|
model: self.chat_widget.current_model().to_string(),
|
|
model_provider_id: self.config.model_provider_id.clone(),
|
|
service_tier: self.chat_widget.current_service_tier().map(str::to_string),
|
|
approval_policy: AskForApproval::from(
|
|
self.config.permissions.approval_policy.value(),
|
|
),
|
|
approvals_reviewer: self.config.approvals_reviewer,
|
|
permission_profile: permission_profile.clone(),
|
|
active_permission_profile: active_permission_profile.clone(),
|
|
cwd: thread.cwd.clone(),
|
|
runtime_workspace_roots: self.config.workspace_roots.clone(),
|
|
instruction_source_paths: Vec::new(),
|
|
reasoning_effort: self.chat_widget.current_reasoning_effort(),
|
|
collaboration_mode: None,
|
|
personality: None,
|
|
message_history: None,
|
|
network_proxy: None,
|
|
rollout_path: thread.path.clone(),
|
|
}
|
|
};
|
|
session.thread_id = thread_id;
|
|
session.thread_name = thread.name.clone();
|
|
session.model_provider_id = thread.model_provider.clone();
|
|
session.set_cwd_retargeting_implicit_runtime_workspace_root(thread.cwd.clone());
|
|
session.permission_profile = permission_profile;
|
|
session.active_permission_profile = active_permission_profile;
|
|
session.instruction_source_paths = Vec::new();
|
|
session.rollout_path = thread.path.clone();
|
|
if let Some(model) =
|
|
read_session_model(self.state_db.as_deref(), thread_id, thread.path.as_deref()).await
|
|
{
|
|
session.model = model;
|
|
} else if thread.path.is_some() {
|
|
session.model.clear();
|
|
}
|
|
session.message_history = None;
|
|
session
|
|
}
|
|
|
|
fn current_permission_profile(&self) -> PermissionProfile {
|
|
self.chat_widget
|
|
.config_ref()
|
|
.permissions
|
|
.permission_profile()
|
|
.clone()
|
|
}
|
|
|
|
fn current_active_permission_profile(&self) -> Option<ActivePermissionProfile> {
|
|
self.chat_widget
|
|
.config_ref()
|
|
.permissions
|
|
.active_permission_profile()
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::app::side::SideThreadState;
|
|
use crate::app::test_support::make_test_app;
|
|
use crate::app::thread_events::ThreadEventChannel;
|
|
use crate::legacy_core::config::PermissionProfileSnapshot;
|
|
use crate::test_support::PathBufExt;
|
|
use crate::test_support::test_path_buf;
|
|
use codex_app_server_protocol::AskForApproval;
|
|
use codex_config::types::ApprovalsReviewer;
|
|
use codex_protocol::config_types::ServiceTier;
|
|
use codex_protocol::models::BUILT_IN_PERMISSION_PROFILE_WORKSPACE;
|
|
use codex_protocol::models::ManagedFileSystemPermissions;
|
|
use codex_protocol::models::PermissionProfile;
|
|
use codex_protocol::permissions::FileSystemAccessMode;
|
|
use codex_protocol::permissions::FileSystemPath;
|
|
use codex_protocol::permissions::FileSystemSandboxEntry;
|
|
use codex_protocol::permissions::FileSystemSpecialPath;
|
|
use codex_protocol::permissions::NetworkSandboxPolicy;
|
|
use pretty_assertions::assert_eq;
|
|
use std::path::PathBuf;
|
|
|
|
fn test_thread_session(thread_id: ThreadId, cwd: PathBuf) -> ThreadSessionState {
|
|
ThreadSessionState {
|
|
thread_id,
|
|
forked_from_id: None,
|
|
fork_parent_title: None,
|
|
thread_name: None,
|
|
model: "gpt-test".to_string(),
|
|
model_provider_id: "test-provider".to_string(),
|
|
service_tier: None,
|
|
approval_policy: AskForApproval::Never,
|
|
approvals_reviewer: ApprovalsReviewer::User,
|
|
permission_profile: PermissionProfile::read_only(),
|
|
active_permission_profile: None,
|
|
cwd: cwd.abs(),
|
|
runtime_workspace_roots: vec![cwd.abs()],
|
|
instruction_source_paths: Vec::new(),
|
|
reasoning_effort: None,
|
|
collaboration_mode: None,
|
|
personality: None,
|
|
message_history: None,
|
|
network_proxy: None,
|
|
rollout_path: Some(PathBuf::new()),
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn permission_settings_sync_updates_active_snapshot_without_rewriting_side_thread() {
|
|
let mut app = make_test_app().await;
|
|
let main_thread_id =
|
|
ThreadId::from_string("00000000-0000-0000-0000-000000000401").expect("valid thread");
|
|
let side_thread_id =
|
|
ThreadId::from_string("00000000-0000-0000-0000-000000000402").expect("valid thread");
|
|
let main_session = test_thread_session(main_thread_id, test_path_buf("/tmp/main"));
|
|
let side_session = ThreadSessionState {
|
|
approval_policy: AskForApproval::OnRequest,
|
|
permission_profile: PermissionProfile::workspace_write(),
|
|
..test_thread_session(side_thread_id, test_path_buf("/tmp/side"))
|
|
};
|
|
|
|
app.primary_thread_id = Some(main_thread_id);
|
|
app.active_thread_id = Some(main_thread_id);
|
|
app.primary_session_configured = Some(main_session.clone());
|
|
app.thread_event_channels.insert(
|
|
main_thread_id,
|
|
ThreadEventChannel::new_with_session(
|
|
/*capacity*/ 4,
|
|
main_session.clone(),
|
|
Vec::new(),
|
|
),
|
|
);
|
|
app.thread_event_channels.insert(
|
|
side_thread_id,
|
|
ThreadEventChannel::new_with_session(
|
|
/*capacity*/ 4,
|
|
side_session.clone(),
|
|
Vec::new(),
|
|
),
|
|
);
|
|
app.side_threads
|
|
.insert(side_thread_id, SideThreadState::new(main_thread_id));
|
|
app.config.permissions.approval_policy =
|
|
codex_config::Constrained::allow_any(AskForApproval::OnRequest.to_core());
|
|
app.config.approvals_reviewer = ApprovalsReviewer::AutoReview;
|
|
let expected_permission_profile = PermissionProfile::workspace_write();
|
|
let expected_active_permission_profile =
|
|
ActivePermissionProfile::new(BUILT_IN_PERMISSION_PROFILE_WORKSPACE);
|
|
app.chat_widget.handle_thread_session(main_session.clone());
|
|
app.chat_widget
|
|
.set_permission_profile_from_session_snapshot(PermissionProfileSnapshot::active(
|
|
expected_permission_profile.clone(),
|
|
expected_active_permission_profile.clone(),
|
|
))
|
|
.expect("set widget permission profile");
|
|
app.config
|
|
.permissions
|
|
.set_permission_profile(expected_permission_profile.clone())
|
|
.expect("set permission profile");
|
|
|
|
app.sync_active_thread_permission_settings_to_cached_session()
|
|
.await;
|
|
|
|
let expected_main_session = ThreadSessionState {
|
|
approval_policy: AskForApproval::OnRequest,
|
|
approvals_reviewer: ApprovalsReviewer::AutoReview,
|
|
permission_profile: expected_permission_profile,
|
|
active_permission_profile: Some(expected_active_permission_profile),
|
|
..main_session
|
|
};
|
|
assert_eq!(
|
|
app.primary_session_configured,
|
|
Some(expected_main_session.clone())
|
|
);
|
|
|
|
let main_store_session = app
|
|
.thread_event_channels
|
|
.get(&main_thread_id)
|
|
.expect("main thread channel")
|
|
.store
|
|
.lock()
|
|
.await
|
|
.session
|
|
.clone();
|
|
assert_eq!(main_store_session, Some(expected_main_session));
|
|
|
|
let side_store_session = app
|
|
.thread_event_channels
|
|
.get(&side_thread_id)
|
|
.expect("side thread channel")
|
|
.store
|
|
.lock()
|
|
.await
|
|
.session
|
|
.clone();
|
|
assert_eq!(side_store_session, Some(side_session));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn permission_settings_sync_preserves_active_profile_only_rules() {
|
|
let mut app = make_test_app().await;
|
|
let thread_id =
|
|
ThreadId::from_string("00000000-0000-0000-0000-000000000403").expect("valid thread");
|
|
let profile: PermissionProfile = PermissionProfile::Managed {
|
|
network: NetworkSandboxPolicy::Restricted,
|
|
file_system: ManagedFileSystemPermissions::Restricted {
|
|
entries: vec![
|
|
FileSystemSandboxEntry {
|
|
path: FileSystemPath::Special {
|
|
value: FileSystemSpecialPath::Root,
|
|
},
|
|
access: FileSystemAccessMode::Read,
|
|
},
|
|
FileSystemSandboxEntry {
|
|
path: FileSystemPath::GlobPattern {
|
|
pattern: "**/.env".to_string(),
|
|
},
|
|
access: FileSystemAccessMode::Deny,
|
|
},
|
|
],
|
|
glob_scan_max_depth: None,
|
|
},
|
|
};
|
|
let session = ThreadSessionState {
|
|
permission_profile: profile.clone(),
|
|
..test_thread_session(thread_id, test_path_buf("/tmp/main"))
|
|
};
|
|
|
|
app.primary_thread_id = Some(thread_id);
|
|
app.active_thread_id = Some(thread_id);
|
|
app.primary_session_configured = Some(session.clone());
|
|
app.thread_event_channels.insert(
|
|
thread_id,
|
|
ThreadEventChannel::new_with_session(/*capacity*/ 4, session.clone(), Vec::new()),
|
|
);
|
|
app.chat_widget.handle_thread_session(session.clone());
|
|
app.config.permissions.approval_policy =
|
|
codex_config::Constrained::allow_any(AskForApproval::OnRequest.to_core());
|
|
|
|
app.sync_active_thread_permission_settings_to_cached_session()
|
|
.await;
|
|
|
|
let expected_session = ThreadSessionState {
|
|
approval_policy: AskForApproval::OnRequest,
|
|
permission_profile: profile,
|
|
..session
|
|
};
|
|
assert_eq!(
|
|
app.primary_session_configured,
|
|
Some(expected_session.clone())
|
|
);
|
|
|
|
let store_session = app
|
|
.thread_event_channels
|
|
.get(&thread_id)
|
|
.expect("thread channel")
|
|
.store
|
|
.lock()
|
|
.await
|
|
.session
|
|
.clone();
|
|
assert_eq!(store_session, Some(expected_session));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn service_tier_sync_updates_active_cached_session() {
|
|
let mut app = make_test_app().await;
|
|
let thread_id =
|
|
ThreadId::from_string("00000000-0000-0000-0000-000000000406").expect("valid thread");
|
|
let session = ThreadSessionState {
|
|
service_tier: Some(ServiceTier::Fast.request_value().to_string()),
|
|
..test_thread_session(thread_id, test_path_buf("/tmp/main"))
|
|
};
|
|
|
|
app.primary_thread_id = Some(thread_id);
|
|
app.active_thread_id = Some(thread_id);
|
|
app.primary_session_configured = Some(session.clone());
|
|
app.thread_event_channels.insert(
|
|
thread_id,
|
|
ThreadEventChannel::new_with_session(/*capacity*/ 4, session.clone(), Vec::new()),
|
|
);
|
|
app.chat_widget.handle_thread_session(session);
|
|
app.chat_widget.set_service_tier(/*service_tier*/ None);
|
|
|
|
app.sync_active_thread_service_tier_to_cached_session()
|
|
.await;
|
|
|
|
let expected_session = ThreadSessionState {
|
|
service_tier: None,
|
|
..test_thread_session(thread_id, test_path_buf("/tmp/main"))
|
|
};
|
|
assert_eq!(
|
|
app.primary_session_configured,
|
|
Some(expected_session.clone())
|
|
);
|
|
|
|
let store_session = app
|
|
.thread_event_channels
|
|
.get(&thread_id)
|
|
.expect("thread channel")
|
|
.store
|
|
.lock()
|
|
.await
|
|
.session
|
|
.clone();
|
|
assert_eq!(store_session, Some(expected_session));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn thread_read_fallback_uses_active_permission_settings() {
|
|
let mut app = make_test_app().await;
|
|
let primary_thread_id =
|
|
ThreadId::from_string("00000000-0000-0000-0000-000000000404").expect("valid thread");
|
|
let read_thread_id =
|
|
ThreadId::from_string("00000000-0000-0000-0000-000000000405").expect("valid thread");
|
|
let primary_session = ThreadSessionState {
|
|
permission_profile: PermissionProfile::workspace_write(),
|
|
..test_thread_session(primary_thread_id, test_path_buf("/tmp/primary"))
|
|
};
|
|
let read_thread = Thread {
|
|
id: read_thread_id.to_string(),
|
|
extra: None,
|
|
session_id: read_thread_id.to_string(),
|
|
forked_from_id: None,
|
|
parent_thread_id: None,
|
|
preview: "read thread".to_string(),
|
|
ephemeral: false,
|
|
history_mode: Default::default(),
|
|
model_provider: "read-provider".to_string(),
|
|
created_at: 1,
|
|
updated_at: 2,
|
|
recency_at: Some(2),
|
|
status: codex_app_server_protocol::ThreadStatus::Idle,
|
|
path: None,
|
|
cwd: test_path_buf("/tmp/read").abs(),
|
|
cli_version: "0.0.0".to_string(),
|
|
source: codex_app_server_protocol::SessionSource::Unknown,
|
|
thread_source: None,
|
|
agent_nickname: None,
|
|
agent_role: None,
|
|
git_info: None,
|
|
name: Some("read thread".to_string()),
|
|
turns: Vec::new(),
|
|
};
|
|
|
|
app.primary_session_configured = Some(primary_session.clone());
|
|
app.chat_widget.handle_thread_session(primary_session);
|
|
|
|
let session = app
|
|
.session_state_for_thread_read(read_thread_id, &read_thread)
|
|
.await;
|
|
|
|
let expected_permission_profile = app
|
|
.chat_widget
|
|
.config_ref()
|
|
.permissions
|
|
.permission_profile()
|
|
.clone();
|
|
assert_eq!(session.permission_profile, expected_permission_profile);
|
|
assert_ne!(
|
|
session.permission_profile,
|
|
app.config.permissions.permission_profile().clone(),
|
|
"thread/read fallback must use the active widget permissions rather than stale app \
|
|
config defaults"
|
|
);
|
|
}
|
|
}
|