mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08:00
5267e805fb
## 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.
450 lines
16 KiB
Rust
450 lines
16 KiB
Rust
use super::AnalyticsEventsClient;
|
|
use super::AnalyticsEventsDestination;
|
|
use super::AnalyticsEventsQueue;
|
|
#[cfg(debug_assertions)]
|
|
use super::capture_track_events_request;
|
|
#[cfg(debug_assertions)]
|
|
use super::send_track_events_request;
|
|
use super::track_event_request_batches;
|
|
use crate::events::CodexAcceptedLineFingerprintsEventParams;
|
|
use crate::events::CodexAcceptedLineFingerprintsEventRequest;
|
|
use crate::events::SkillInvocationEventParams;
|
|
use crate::events::SkillInvocationEventRequest;
|
|
use crate::events::TrackEventRequest;
|
|
use crate::facts::AnalyticsFact;
|
|
use crate::facts::InvocationType;
|
|
use codex_app_server_protocol::ApprovalsReviewer as AppServerApprovalsReviewer;
|
|
use codex_app_server_protocol::AskForApproval as AppServerAskForApproval;
|
|
use codex_app_server_protocol::ClientRequest;
|
|
use codex_app_server_protocol::ClientResponsePayload;
|
|
use codex_app_server_protocol::RequestId;
|
|
use codex_app_server_protocol::SandboxPolicy as AppServerSandboxPolicy;
|
|
use codex_app_server_protocol::SessionSource as AppServerSessionSource;
|
|
use codex_app_server_protocol::Thread;
|
|
use codex_app_server_protocol::ThreadArchiveParams;
|
|
use codex_app_server_protocol::ThreadArchiveResponse;
|
|
use codex_app_server_protocol::ThreadForkResponse;
|
|
use codex_app_server_protocol::ThreadResumeResponse;
|
|
use codex_app_server_protocol::ThreadStartResponse;
|
|
use codex_app_server_protocol::ThreadStatus as AppServerThreadStatus;
|
|
use codex_app_server_protocol::Turn;
|
|
use codex_app_server_protocol::TurnStartParams;
|
|
use codex_app_server_protocol::TurnStartResponse;
|
|
use codex_app_server_protocol::TurnStatus as AppServerTurnStatus;
|
|
use codex_app_server_protocol::TurnSteerParams;
|
|
use codex_app_server_protocol::TurnSteerResponse;
|
|
use codex_utils_absolute_path::test_support::PathBufExt;
|
|
use codex_utils_absolute_path::test_support::test_path_buf;
|
|
use std::collections::HashSet;
|
|
#[cfg(debug_assertions)]
|
|
use std::fs;
|
|
#[cfg(debug_assertions)]
|
|
use std::path::PathBuf;
|
|
use std::sync::Arc;
|
|
use std::sync::Mutex;
|
|
#[cfg(debug_assertions)]
|
|
use std::time::SystemTime;
|
|
use tokio::sync::mpsc;
|
|
use tokio::sync::mpsc::error::TryRecvError;
|
|
|
|
fn sample_accepted_line_fingerprint_event(thread_id: &str) -> TrackEventRequest {
|
|
TrackEventRequest::AcceptedLineFingerprints(Box::new(
|
|
CodexAcceptedLineFingerprintsEventRequest {
|
|
event_type: "codex_accepted_line_fingerprints",
|
|
event_params: CodexAcceptedLineFingerprintsEventParams {
|
|
event_type: "codex.accepted_line_fingerprints",
|
|
turn_id: "turn-1".to_string(),
|
|
thread_id: thread_id.to_string(),
|
|
product_surface: Some("codex".to_string()),
|
|
model_slug: Some("gpt-5.1-codex".to_string()),
|
|
completed_at: 1,
|
|
repo_hash: None,
|
|
accepted_added_lines: 1,
|
|
accepted_deleted_lines: 0,
|
|
line_fingerprints: Vec::new(),
|
|
},
|
|
},
|
|
))
|
|
}
|
|
|
|
fn sample_regular_track_event(thread_id: &str) -> TrackEventRequest {
|
|
TrackEventRequest::SkillInvocation(SkillInvocationEventRequest {
|
|
event_type: "skill_invocation",
|
|
skill_id: format!("skill-{thread_id}"),
|
|
skill_name: "doc".to_string(),
|
|
event_params: SkillInvocationEventParams {
|
|
product_client_id: None,
|
|
skill_scope: None,
|
|
plugin_id: None,
|
|
repo_url: None,
|
|
thread_id: Some(thread_id.to_string()),
|
|
turn_id: Some("turn-1".to_string()),
|
|
invoke_type: Some(InvocationType::Explicit),
|
|
model_slug: Some("gpt-5.1-codex".to_string()),
|
|
},
|
|
})
|
|
}
|
|
|
|
#[cfg(debug_assertions)]
|
|
fn unique_capture_path(name: &str) -> PathBuf {
|
|
let nonce = SystemTime::now()
|
|
.duration_since(SystemTime::UNIX_EPOCH)
|
|
.expect("system clock should be after Unix epoch")
|
|
.as_nanos();
|
|
std::env::temp_dir().join(format!(
|
|
"codex-analytics-{name}-{}-{nonce}.jsonl",
|
|
std::process::id()
|
|
))
|
|
}
|
|
|
|
fn client_with_receiver() -> (AnalyticsEventsClient, mpsc::Receiver<AnalyticsFact>) {
|
|
let (sender, receiver) = mpsc::channel(8);
|
|
let queue = AnalyticsEventsQueue {
|
|
sender,
|
|
app_used_emitted_keys: Arc::new(Mutex::new(HashSet::new())),
|
|
plugin_used_emitted_keys: Arc::new(Mutex::new(HashSet::new())),
|
|
};
|
|
(AnalyticsEventsClient { queue: Some(queue) }, receiver)
|
|
}
|
|
|
|
#[test]
|
|
#[cfg(debug_assertions)]
|
|
fn analytics_destination_uses_explicit_capture_file() {
|
|
let capture_path = unique_capture_path("destination");
|
|
let destination = AnalyticsEventsDestination::from_base_url_and_capture_file(
|
|
"https://chatgpt.com/backend-api/".to_string(),
|
|
Some(capture_path.clone()),
|
|
);
|
|
|
|
assert_eq!(
|
|
destination,
|
|
AnalyticsEventsDestination::CaptureFile {
|
|
path: capture_path.clone()
|
|
}
|
|
);
|
|
assert_eq!(
|
|
fs::read_to_string(&capture_path).expect("read capture file"),
|
|
""
|
|
);
|
|
#[cfg(unix)]
|
|
{
|
|
use std::os::unix::fs::PermissionsExt;
|
|
|
|
let mode = fs::metadata(&capture_path)
|
|
.expect("read capture file metadata")
|
|
.permissions()
|
|
.mode();
|
|
assert_eq!(mode & 0o777, 0o600);
|
|
}
|
|
fs::remove_file(capture_path).expect("remove capture file");
|
|
}
|
|
|
|
#[test]
|
|
fn analytics_destination_uses_http_without_capture_file() {
|
|
let destination = AnalyticsEventsDestination::from_base_url_and_capture_file(
|
|
"https://chatgpt.com/backend-api/".to_string(),
|
|
/*capture_file*/ None,
|
|
);
|
|
|
|
assert_eq!(
|
|
destination,
|
|
AnalyticsEventsDestination::Http {
|
|
url: "https://chatgpt.com/backend-api/codex/analytics-events/events".to_string()
|
|
}
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
#[cfg(not(debug_assertions))]
|
|
fn analytics_destination_ignores_capture_file_in_release() {
|
|
let destination = AnalyticsEventsDestination::from_base_url_and_capture_file(
|
|
"https://chatgpt.com/backend-api/".to_string(),
|
|
Some(std::path::PathBuf::from("ignored.jsonl")),
|
|
);
|
|
|
|
assert_eq!(
|
|
destination,
|
|
AnalyticsEventsDestination::Http {
|
|
url: "https://chatgpt.com/backend-api/codex/analytics-events/events".to_string()
|
|
}
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[cfg(debug_assertions)]
|
|
async fn capture_file_writes_exact_serialized_request() {
|
|
let capture_path = unique_capture_path("single");
|
|
let destination = AnalyticsEventsDestination::CaptureFile {
|
|
path: capture_path.clone(),
|
|
};
|
|
let event = sample_regular_track_event("thread-1");
|
|
let expected_event = serde_json::to_value(&event).expect("serialize expected event");
|
|
let auth = codex_login::CodexAuth::create_dummy_chatgpt_auth_for_testing();
|
|
|
|
send_track_events_request(&auth, &destination, vec![event]).await;
|
|
|
|
let contents = fs::read_to_string(&capture_path).expect("read capture file");
|
|
let lines = contents.lines().collect::<Vec<_>>();
|
|
assert_eq!(lines.len(), 1);
|
|
let payload: serde_json::Value =
|
|
serde_json::from_str(lines[0]).expect("parse captured payload");
|
|
assert_eq!(payload, serde_json::json!({"events": [expected_event]}));
|
|
|
|
fs::remove_file(capture_path).expect("remove capture file");
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[cfg(debug_assertions)]
|
|
async fn capture_file_writes_final_batches_as_separate_lines() {
|
|
let capture_path = unique_capture_path("batches");
|
|
let destination = AnalyticsEventsDestination::CaptureFile {
|
|
path: capture_path.clone(),
|
|
};
|
|
let auth = codex_login::CodexAuth::create_dummy_chatgpt_auth_for_testing();
|
|
let events = vec![
|
|
sample_regular_track_event("thread-1"),
|
|
sample_accepted_line_fingerprint_event("thread-2"),
|
|
sample_regular_track_event("thread-3"),
|
|
];
|
|
|
|
for batch in track_event_request_batches(events) {
|
|
send_track_events_request(&auth, &destination, batch).await;
|
|
}
|
|
|
|
let contents = fs::read_to_string(&capture_path).expect("read capture file");
|
|
let payloads = contents
|
|
.lines()
|
|
.map(|line| serde_json::from_str::<serde_json::Value>(line).expect("parse capture line"))
|
|
.collect::<Vec<_>>();
|
|
assert_eq!(payloads.len(), 3);
|
|
assert_eq!(payloads[0]["events"][0]["skill_id"], "skill-thread-1");
|
|
assert_eq!(
|
|
payloads[1]["events"][0]["event_type"],
|
|
"codex_accepted_line_fingerprints"
|
|
);
|
|
assert_eq!(payloads[2]["events"][0]["skill_id"], "skill-thread-3");
|
|
|
|
fs::remove_file(capture_path).expect("remove capture file");
|
|
}
|
|
|
|
#[test]
|
|
#[cfg(debug_assertions)]
|
|
fn capture_write_failure_still_consumes_delivery() {
|
|
let capture_path = unique_capture_path("missing-parent").join("events.jsonl");
|
|
let destination = AnalyticsEventsDestination::CaptureFile { path: capture_path };
|
|
let payload = crate::events::TrackEventsRequest {
|
|
events: vec![sample_regular_track_event("thread-1")],
|
|
};
|
|
|
|
assert!(capture_track_events_request(&destination, &payload));
|
|
}
|
|
|
|
fn sample_turn_start_request() -> ClientRequest {
|
|
ClientRequest::TurnStart {
|
|
request_id: RequestId::Integer(1),
|
|
params: TurnStartParams {
|
|
thread_id: "thread-1".to_string(),
|
|
client_user_message_id: None,
|
|
input: Vec::new(),
|
|
..Default::default()
|
|
},
|
|
}
|
|
}
|
|
|
|
fn sample_turn_steer_request() -> ClientRequest {
|
|
ClientRequest::TurnSteer {
|
|
request_id: RequestId::Integer(2),
|
|
params: TurnSteerParams {
|
|
thread_id: "thread-1".to_string(),
|
|
expected_turn_id: "turn-1".to_string(),
|
|
client_user_message_id: None,
|
|
input: Vec::new(),
|
|
responsesapi_client_metadata: None,
|
|
additional_context: None,
|
|
},
|
|
}
|
|
}
|
|
|
|
fn sample_thread_archive_request() -> ClientRequest {
|
|
ClientRequest::ThreadArchive {
|
|
request_id: RequestId::Integer(3),
|
|
params: ThreadArchiveParams {
|
|
thread_id: "thread-1".to_string(),
|
|
},
|
|
}
|
|
}
|
|
|
|
fn sample_thread(thread_id: &str) -> Thread {
|
|
Thread {
|
|
id: thread_id.to_string(),
|
|
extra: None,
|
|
session_id: format!("session-{thread_id}"),
|
|
forked_from_id: None,
|
|
parent_thread_id: None,
|
|
preview: "first prompt".to_string(),
|
|
ephemeral: false,
|
|
history_mode: Default::default(),
|
|
model_provider: "openai".to_string(),
|
|
created_at: 1,
|
|
updated_at: 2,
|
|
recency_at: Some(2),
|
|
status: AppServerThreadStatus::Idle,
|
|
path: None,
|
|
cwd: test_path_buf("/tmp").abs(),
|
|
cli_version: "0.0.0".to_string(),
|
|
source: AppServerSessionSource::Exec,
|
|
thread_source: None,
|
|
agent_nickname: None,
|
|
agent_role: None,
|
|
git_info: None,
|
|
name: None,
|
|
turns: Vec::new(),
|
|
}
|
|
}
|
|
|
|
fn sample_thread_start_response() -> ClientResponsePayload {
|
|
ClientResponsePayload::ThreadStart(ThreadStartResponse {
|
|
thread: sample_thread("thread-1"),
|
|
model: "gpt-5".to_string(),
|
|
model_provider: "openai".to_string(),
|
|
service_tier: None,
|
|
cwd: test_path_buf("/tmp").abs(),
|
|
runtime_workspace_roots: Vec::new(),
|
|
instruction_sources: Vec::new(),
|
|
approval_policy: AppServerAskForApproval::OnRequest,
|
|
approvals_reviewer: AppServerApprovalsReviewer::User,
|
|
sandbox: AppServerSandboxPolicy::DangerFullAccess,
|
|
active_permission_profile: None,
|
|
reasoning_effort: None,
|
|
multi_agent_mode: Default::default(),
|
|
})
|
|
}
|
|
|
|
fn sample_thread_resume_response() -> ClientResponsePayload {
|
|
ClientResponsePayload::ThreadResume(ThreadResumeResponse {
|
|
thread: sample_thread("thread-2"),
|
|
model: "gpt-5".to_string(),
|
|
model_provider: "openai".to_string(),
|
|
service_tier: None,
|
|
cwd: test_path_buf("/tmp").abs(),
|
|
runtime_workspace_roots: Vec::new(),
|
|
instruction_sources: Vec::new(),
|
|
approval_policy: AppServerAskForApproval::OnRequest,
|
|
approvals_reviewer: AppServerApprovalsReviewer::User,
|
|
sandbox: AppServerSandboxPolicy::DangerFullAccess,
|
|
active_permission_profile: None,
|
|
reasoning_effort: None,
|
|
multi_agent_mode: Default::default(),
|
|
initial_turns_page: None,
|
|
})
|
|
}
|
|
|
|
fn sample_thread_fork_response() -> ClientResponsePayload {
|
|
ClientResponsePayload::ThreadFork(ThreadForkResponse {
|
|
thread: sample_thread("thread-3"),
|
|
model: "gpt-5".to_string(),
|
|
model_provider: "openai".to_string(),
|
|
service_tier: None,
|
|
cwd: test_path_buf("/tmp").abs(),
|
|
runtime_workspace_roots: Vec::new(),
|
|
instruction_sources: Vec::new(),
|
|
approval_policy: AppServerAskForApproval::OnRequest,
|
|
approvals_reviewer: AppServerApprovalsReviewer::User,
|
|
sandbox: AppServerSandboxPolicy::DangerFullAccess,
|
|
active_permission_profile: None,
|
|
reasoning_effort: None,
|
|
multi_agent_mode: Default::default(),
|
|
})
|
|
}
|
|
|
|
fn sample_turn_start_response() -> ClientResponsePayload {
|
|
ClientResponsePayload::TurnStart(TurnStartResponse {
|
|
turn: Turn {
|
|
id: "turn-1".to_string(),
|
|
items_view: codex_app_server_protocol::TurnItemsView::Full,
|
|
items: Vec::new(),
|
|
status: AppServerTurnStatus::InProgress,
|
|
error: None,
|
|
started_at: None,
|
|
completed_at: None,
|
|
duration_ms: None,
|
|
},
|
|
})
|
|
}
|
|
|
|
fn sample_turn_steer_response() -> ClientResponsePayload {
|
|
ClientResponsePayload::TurnSteer(TurnSteerResponse {
|
|
turn_id: "turn-2".to_string(),
|
|
})
|
|
}
|
|
|
|
#[test]
|
|
fn track_request_only_enqueues_analytics_relevant_requests() {
|
|
let (client, mut receiver) = client_with_receiver();
|
|
|
|
for (request_id, request) in [
|
|
(RequestId::Integer(1), sample_turn_start_request()),
|
|
(RequestId::Integer(2), sample_turn_steer_request()),
|
|
] {
|
|
client.track_request(/*connection_id*/ 7, request_id, &request);
|
|
assert!(matches!(
|
|
receiver.try_recv(),
|
|
Ok(AnalyticsFact::ClientRequest { .. })
|
|
));
|
|
}
|
|
|
|
let ignored_request = sample_thread_archive_request();
|
|
client.track_request(
|
|
/*connection_id*/ 7,
|
|
RequestId::Integer(3),
|
|
&ignored_request,
|
|
);
|
|
assert!(matches!(receiver.try_recv(), Err(TryRecvError::Empty)));
|
|
}
|
|
|
|
#[test]
|
|
fn track_response_only_enqueues_analytics_relevant_responses() {
|
|
let (client, mut receiver) = client_with_receiver();
|
|
|
|
for (request_id, response) in [
|
|
(RequestId::Integer(1), sample_thread_start_response()),
|
|
(RequestId::Integer(2), sample_thread_resume_response()),
|
|
(RequestId::Integer(3), sample_thread_fork_response()),
|
|
(RequestId::Integer(4), sample_turn_start_response()),
|
|
(RequestId::Integer(5), sample_turn_steer_response()),
|
|
] {
|
|
client.track_response(/*connection_id*/ 7, request_id, response);
|
|
assert!(matches!(
|
|
receiver.try_recv(),
|
|
Ok(AnalyticsFact::ClientResponse { .. })
|
|
));
|
|
}
|
|
|
|
client.track_response(
|
|
/*connection_id*/ 7,
|
|
RequestId::Integer(6),
|
|
ClientResponsePayload::ThreadArchive(ThreadArchiveResponse {}),
|
|
);
|
|
assert!(matches!(receiver.try_recv(), Err(TryRecvError::Empty)));
|
|
}
|
|
|
|
#[test]
|
|
fn track_event_request_batches_only_isolates_accepted_line_fingerprint_events() {
|
|
let batches = track_event_request_batches(vec![
|
|
sample_regular_track_event("thread-1"),
|
|
sample_regular_track_event("thread-2"),
|
|
sample_accepted_line_fingerprint_event("thread-3"),
|
|
sample_accepted_line_fingerprint_event("thread-4"),
|
|
sample_regular_track_event("thread-5"),
|
|
sample_regular_track_event("thread-6"),
|
|
]);
|
|
|
|
assert_eq!(batches.len(), 4);
|
|
assert_eq!(batches[0].len(), 2);
|
|
assert_eq!(batches[1].len(), 1);
|
|
assert_eq!(batches[2].len(), 1);
|
|
assert_eq!(batches[3].len(), 2);
|
|
assert!(batches[1][0].should_send_in_isolated_request());
|
|
assert!(batches[2][0].should_send_in_isolated_request());
|
|
}
|