Merge remote-tracking branch 'origin/main' into jchu/codex-cli-owner-nudge-analytics-2026.04.22

This commit is contained in:
James Chu
2026-04-27 18:32:37 -04:00
75 changed files with 3614 additions and 2516 deletions
@@ -1028,21 +1028,6 @@
"title": "MinimalFileSystemSpecialPath",
"type": "object"
},
{
"properties": {
"kind": {
"enum": [
"current_working_directory"
],
"type": "string"
}
},
"required": [
"kind"
],
"title": "CurrentWorkingDirectoryFileSystemSpecialPath",
"type": "object"
},
{
"properties": {
"kind": {
@@ -392,21 +392,6 @@
"title": "MinimalFileSystemSpecialPath",
"type": "object"
},
{
"properties": {
"kind": {
"enum": [
"current_working_directory"
],
"type": "string"
}
},
"required": [
"kind"
],
"title": "CurrentWorkingDirectoryFileSystemSpecialPath",
"type": "object"
},
{
"properties": {
"kind": {
@@ -177,21 +177,6 @@
"title": "MinimalFileSystemSpecialPath",
"type": "object"
},
{
"properties": {
"kind": {
"enum": [
"current_working_directory"
],
"type": "string"
}
},
"required": [
"kind"
],
"title": "CurrentWorkingDirectoryFileSystemSpecialPath",
"type": "object"
},
{
"properties": {
"kind": {
@@ -177,21 +177,6 @@
"title": "MinimalFileSystemSpecialPath",
"type": "object"
},
{
"properties": {
"kind": {
"enum": [
"current_working_directory"
],
"type": "string"
}
},
"required": [
"kind"
],
"title": "CurrentWorkingDirectoryFileSystemSpecialPath",
"type": "object"
},
{
"properties": {
"kind": {
@@ -1199,21 +1199,6 @@
"title": "MinimalFileSystemSpecialPath",
"type": "object"
},
{
"properties": {
"kind": {
"enum": [
"current_working_directory"
],
"type": "string"
}
},
"required": [
"kind"
],
"title": "CurrentWorkingDirectoryFileSystemSpecialPath",
"type": "object"
},
{
"properties": {
"kind": {
@@ -731,21 +731,6 @@
"title": "MinimalFileSystemSpecialPath",
"type": "object"
},
{
"properties": {
"kind": {
"enum": [
"current_working_directory"
],
"type": "string"
}
},
"required": [
"kind"
],
"title": "CurrentWorkingDirectoryFileSystemSpecialPath",
"type": "object"
},
{
"properties": {
"kind": {
@@ -8597,21 +8597,6 @@
"title": "MinimalFileSystemSpecialPath",
"type": "object"
},
{
"properties": {
"kind": {
"enum": [
"current_working_directory"
],
"type": "string"
}
},
"required": [
"kind"
],
"title": "CurrentWorkingDirectoryFileSystemSpecialPath",
"type": "object"
},
{
"properties": {
"kind": {
@@ -5116,21 +5116,6 @@
"title": "MinimalFileSystemSpecialPath",
"type": "object"
},
{
"properties": {
"kind": {
"enum": [
"current_working_directory"
],
"type": "string"
}
},
"required": [
"kind"
],
"title": "CurrentWorkingDirectoryFileSystemSpecialPath",
"type": "object"
},
{
"properties": {
"kind": {
@@ -146,21 +146,6 @@
"title": "MinimalFileSystemSpecialPath",
"type": "object"
},
{
"properties": {
"kind": {
"enum": [
"current_working_directory"
],
"type": "string"
}
},
"required": [
"kind"
],
"title": "CurrentWorkingDirectoryFileSystemSpecialPath",
"type": "object"
},
{
"properties": {
"kind": {
@@ -184,21 +184,6 @@
"title": "MinimalFileSystemSpecialPath",
"type": "object"
},
{
"properties": {
"kind": {
"enum": [
"current_working_directory"
],
"type": "string"
}
},
"required": [
"kind"
],
"title": "CurrentWorkingDirectoryFileSystemSpecialPath",
"type": "object"
},
{
"properties": {
"kind": {
@@ -177,21 +177,6 @@
"title": "MinimalFileSystemSpecialPath",
"type": "object"
},
{
"properties": {
"kind": {
"enum": [
"current_working_directory"
],
"type": "string"
}
},
"required": [
"kind"
],
"title": "CurrentWorkingDirectoryFileSystemSpecialPath",
"type": "object"
},
{
"properties": {
"kind": {
@@ -183,21 +183,6 @@
"title": "MinimalFileSystemSpecialPath",
"type": "object"
},
{
"properties": {
"kind": {
"enum": [
"current_working_directory"
],
"type": "string"
}
},
"required": [
"kind"
],
"title": "CurrentWorkingDirectoryFileSystemSpecialPath",
"type": "object"
},
{
"properties": {
"kind": {
@@ -569,21 +569,6 @@
"title": "MinimalFileSystemSpecialPath",
"type": "object"
},
{
"properties": {
"kind": {
"enum": [
"current_working_directory"
],
"type": "string"
}
},
"required": [
"kind"
],
"title": "CurrentWorkingDirectoryFileSystemSpecialPath",
"type": "object"
},
{
"properties": {
"kind": {
@@ -257,21 +257,6 @@
"title": "MinimalFileSystemSpecialPath",
"type": "object"
},
{
"properties": {
"kind": {
"enum": [
"current_working_directory"
],
"type": "string"
}
},
"required": [
"kind"
],
"title": "CurrentWorkingDirectoryFileSystemSpecialPath",
"type": "object"
},
{
"properties": {
"kind": {
@@ -569,21 +569,6 @@
"title": "MinimalFileSystemSpecialPath",
"type": "object"
},
{
"properties": {
"kind": {
"enum": [
"current_working_directory"
],
"type": "string"
}
},
"required": [
"kind"
],
"title": "CurrentWorkingDirectoryFileSystemSpecialPath",
"type": "object"
},
{
"properties": {
"kind": {
@@ -209,21 +209,6 @@
"title": "MinimalFileSystemSpecialPath",
"type": "object"
},
{
"properties": {
"kind": {
"enum": [
"current_working_directory"
],
"type": "string"
}
},
"required": [
"kind"
],
"title": "CurrentWorkingDirectoryFileSystemSpecialPath",
"type": "object"
},
{
"properties": {
"kind": {
@@ -569,21 +569,6 @@
"title": "MinimalFileSystemSpecialPath",
"type": "object"
},
{
"properties": {
"kind": {
"enum": [
"current_working_directory"
],
"type": "string"
}
},
"required": [
"kind"
],
"title": "CurrentWorkingDirectoryFileSystemSpecialPath",
"type": "object"
},
{
"properties": {
"kind": {
@@ -218,21 +218,6 @@
"title": "MinimalFileSystemSpecialPath",
"type": "object"
},
{
"properties": {
"kind": {
"enum": [
"current_working_directory"
],
"type": "string"
}
},
"required": [
"kind"
],
"title": "CurrentWorkingDirectoryFileSystemSpecialPath",
"type": "object"
},
{
"properties": {
"kind": {
@@ -2,4 +2,4 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type FileSystemSpecialPath = { "kind": "root" } | { "kind": "minimal" } | { "kind": "current_working_directory" } | { "kind": "project_roots", subpath: string | null, } | { "kind": "tmpdir" } | { "kind": "slash_tmp" } | { "kind": "unknown", path: string, subpath: string | null, };
export type FileSystemSpecialPath = { "kind": "root" } | { "kind": "minimal" } | { "kind": "project_roots", subpath: string | null, } | { "kind": "tmpdir" } | { "kind": "slash_tmp" } | { "kind": "unknown", path: string, subpath: string | null, };
@@ -1434,7 +1434,7 @@ v2_enum_from_core!(
pub enum FileSystemSpecialPath {
Root,
Minimal,
CurrentWorkingDirectory,
#[serde(alias = "current_working_directory")]
ProjectRoots {
subpath: Option<PathBuf>,
},
@@ -1451,7 +1451,6 @@ impl From<CoreFileSystemSpecialPath> for FileSystemSpecialPath {
match value {
CoreFileSystemSpecialPath::Root => Self::Root,
CoreFileSystemSpecialPath::Minimal => Self::Minimal,
CoreFileSystemSpecialPath::CurrentWorkingDirectory => Self::CurrentWorkingDirectory,
CoreFileSystemSpecialPath::ProjectRoots { subpath } => Self::ProjectRoots { subpath },
CoreFileSystemSpecialPath::Tmpdir => Self::Tmpdir,
CoreFileSystemSpecialPath::SlashTmp => Self::SlashTmp,
@@ -1465,7 +1464,6 @@ impl From<FileSystemSpecialPath> for CoreFileSystemSpecialPath {
match value {
FileSystemSpecialPath::Root => Self::Root,
FileSystemSpecialPath::Minimal => Self::Minimal,
FileSystemSpecialPath::CurrentWorkingDirectory => Self::CurrentWorkingDirectory,
FileSystemSpecialPath::ProjectRoots { subpath } => Self::ProjectRoots { subpath },
FileSystemSpecialPath::Tmpdir => Self::Tmpdir,
FileSystemSpecialPath::SlashTmp => Self::SlashTmp,
@@ -8151,6 +8149,26 @@ mod tests {
.expect_err("zero glob scan depth should fail deserialization");
}
#[test]
fn legacy_current_working_directory_special_path_deserializes_as_project_roots() {
let special_path = serde_json::from_value::<FileSystemSpecialPath>(json!({
"kind": "current_working_directory",
}))
.expect("legacy cwd special path should deserialize");
assert_eq!(
special_path,
FileSystemSpecialPath::ProjectRoots { subpath: None }
);
assert_eq!(
serde_json::to_value(&special_path).expect("serialize special path"),
json!({
"kind": "project_roots",
"subpath": null,
})
);
}
#[test]
fn permissions_request_approval_response_uses_granted_permission_profile_without_macos() {
let read_only_path = if cfg!(windows) {
+2 -2
View File
@@ -909,7 +909,7 @@ Run a standalone command (argv vector) in the server’s sandbox without creatin
"type": "managed",
"fileSystem": { "type": "restricted", "entries": [
{ "path": { "type": "special", "value": { "kind": "root" } }, "access": "read" },
{ "path": { "type": "special", "value": { "kind": "current_working_directory" } }, "access": "write" }
{ "path": { "type": "special", "value": { "kind": "project_roots", "subpath": null } }, "access": "write" }
] },
"network": { "enabled": false }
},
@@ -1255,7 +1255,7 @@ the client can offer session-scoped and/or persistent approval choices.
### Permission requests
The built-in `request_permissions` tool sends an `item/permissions/requestApproval` JSON-RPC request to the client with the requested permission profile. This v2 payload mirrors the command-execution `additionalPermissions` shape: it can request network access and additional filesystem access. The `cwd` field identifies the directory used to resolve cwd-relative permissions such as `:cwd`, `:project_roots`, and relative deny globs.
The built-in `request_permissions` tool sends an `item/permissions/requestApproval` JSON-RPC request to the client with the requested permission profile. This v2 payload mirrors the command-execution `additionalPermissions` shape: it can request network access and additional filesystem access. The `cwd` field identifies the directory used to resolve project-root permissions and relative deny globs.
```json
{
@@ -2,8 +2,8 @@ use crate::codex_message_processor::ApiVersion;
use crate::codex_message_processor::read_rollout_items_from_rollout;
use crate::codex_message_processor::read_summary_from_rollout;
use crate::codex_message_processor::summary_to_thread;
use crate::error_code::INTERNAL_ERROR_CODE;
use crate::error_code::INVALID_REQUEST_ERROR_CODE;
use crate::error_code::internal_error;
use crate::error_code::invalid_request;
use crate::outgoing_message::ClientRequestResult;
use crate::outgoing_message::ThreadScopedOutgoingMessageSender;
use crate::server_request_error::is_turn_transition_server_request_error;
@@ -51,7 +51,6 @@ use codex_app_server_protocol::HookStartedNotification;
use codex_app_server_protocol::InterruptConversationResponse;
use codex_app_server_protocol::ItemCompletedNotification;
use codex_app_server_protocol::ItemStartedNotification;
use codex_app_server_protocol::JSONRPCErrorError;
use codex_app_server_protocol::McpServerElicitationAction;
use codex_app_server_protocol::McpServerElicitationRequestParams;
use codex_app_server_protocol::McpServerElicitationRequestResponse;
@@ -1876,12 +1875,12 @@ pub(crate) async fn apply_bespoke_event_handling(
if let Some(request_id) = pending {
let Some(rollout_path) = conversation.rollout_path() else {
let error = JSONRPCErrorError {
code: INVALID_REQUEST_ERROR_CODE,
message: "thread has no persisted rollout".to_string(),
data: None,
};
outgoing.send_error(request_id, error).await;
outgoing
.send_error(
request_id,
invalid_request("thread has no persisted rollout"),
)
.await;
return;
};
let response = match read_summary_from_rollout(
@@ -1912,29 +1911,29 @@ pub(crate) async fn apply_bespoke_event_handling(
ThreadRollbackResponse { thread }
}
Err(err) => {
let error = JSONRPCErrorError {
code: INTERNAL_ERROR_CODE,
message: format!(
"failed to load rollout `{}`: {err}",
rollout_path.display()
),
data: None,
};
outgoing.send_error(request_id.clone(), error).await;
outgoing
.send_error(
request_id.clone(),
internal_error(format!(
"failed to load rollout `{}`: {err}",
rollout_path.display()
)),
)
.await;
return;
}
}
}
Err(err) => {
let error = JSONRPCErrorError {
code: INTERNAL_ERROR_CODE,
message: format!(
"failed to load rollout `{}`: {err}",
rollout_path.display()
),
data: None,
};
outgoing.send_error(request_id.clone(), error).await;
outgoing
.send_error(
request_id.clone(),
internal_error(format!(
"failed to load rollout `{}`: {err}",
rollout_path.display()
)),
)
.await;
return;
}
};
@@ -2332,14 +2331,7 @@ async fn handle_thread_rollback_failed(
if let Some(request_id) = pending_rollback {
outgoing
.send_error(
request_id,
JSONRPCErrorError {
code: INVALID_REQUEST_ERROR_CODE,
message: message.clone(),
data: None,
},
)
.send_error(request_id, invalid_request(message))
.await;
}
}
@@ -4008,7 +4000,7 @@ mod tests {
file_system: Some(CoreFileSystemPermissions {
entries: vec![FileSystemSandboxEntry {
path: FileSystemPath::Special {
value: FileSystemSpecialPath::CurrentWorkingDirectory,
value: FileSystemSpecialPath::project_roots(/*subpath*/ None),
},
access: FileSystemAccessMode::Write,
}],
@@ -4054,7 +4046,7 @@ mod tests {
file_system: Some(CoreFileSystemPermissions {
entries: vec![FileSystemSandboxEntry {
path: FileSystemPath::Special {
value: FileSystemSpecialPath::CurrentWorkingDirectory,
value: FileSystemSpecialPath::project_roots(/*subpath*/ None),
},
access: FileSystemAccessMode::Write,
}],
@@ -4104,7 +4096,8 @@ mod tests {
"path": {
"type": "special",
"value": {
"kind": "current_working_directory"
"kind": "project_roots",
"subpath": null
}
},
"access": "write"
File diff suppressed because it is too large Load Diff
@@ -246,7 +246,7 @@ async fn command_exec_accepts_permission_profile() -> Result<()> {
#[cfg(unix)]
#[tokio::test]
async fn command_exec_permission_profile_cwd_uses_command_cwd() -> Result<()> {
async fn command_exec_permission_profile_project_roots_use_command_cwd() -> Result<()> {
let server = create_mock_responses_server_sequence_unchecked(Vec::new()).await;
let codex_home = TempDir::new()?;
let command_dir = codex_home.path().join("command-cwd");
@@ -264,7 +264,7 @@ async fn command_exec_permission_profile_cwd_uses_command_cwd() -> Result<()> {
};
entries.push(FileSystemSandboxEntry {
path: FileSystemPath::Special {
value: FileSystemSpecialPath::CurrentWorkingDirectory,
value: FileSystemSpecialPath::ProjectRoots { subpath: None },
},
access: FileSystemAccessMode::Write,
});
@@ -298,7 +298,7 @@ async fn command_exec_permission_profile_cwd_uses_command_cwd() -> Result<()> {
let response: CommandExecResponse = to_response(response)?;
assert_eq!(
response.exit_code, 0,
"parent cwd write should fail under command-cwd-scoped profile: {response:?}"
"parent cwd write should fail under command project-root profile: {response:?}"
);
assert_eq!(
std::fs::read_to_string(command_dir.join("child.txt"))?,
@@ -306,7 +306,7 @@ async fn command_exec_permission_profile_cwd_uses_command_cwd() -> Result<()> {
);
assert!(
!codex_home.path().join("parent.txt").exists(),
"permissionProfile :cwd write should not grant the server cwd when command cwd differs"
"permissionProfile :project_roots write should not grant the server cwd when command cwd differs"
);
Ok(())
+37 -7
View File
@@ -98,6 +98,7 @@ use tokio::sync::oneshot;
use tokio::sync::oneshot::error::TryRecvError;
use tokio_tungstenite::tungstenite::Error;
use tokio_tungstenite::tungstenite::Message;
use tokio_util::sync::CancellationToken;
use tracing::instrument;
use tracing::trace;
use tracing::warn;
@@ -1232,7 +1233,8 @@ impl ModelClientSession {
Err(ApiError::Transport(
unauthorized_transport @ TransportError::Http { status, .. },
)) if status == StatusCode::UNAUTHORIZED => {
inference_trace_attempt.record_failed(&unauthorized_transport);
inference_trace_attempt
.record_failed(&unauthorized_transport, /*output_items*/ &[]);
pending_retry = PendingUnauthorizedRetry::from_recovery(
handle_unauthorized(
unauthorized_transport,
@@ -1245,7 +1247,7 @@ impl ModelClientSession {
}
Err(err) => {
let err = map_api_error(err);
inference_trace_attempt.record_failed(&err);
inference_trace_attempt.record_failed(&err, /*output_items*/ &[]);
return Err(err);
}
}
@@ -1372,7 +1374,7 @@ impl ModelClientSession {
.await
.map_err(|err| {
let err = map_api_error(err);
inference_trace_attempt.record_failed(&err);
inference_trace_attempt.record_failed(&err, /*output_items*/ &[]);
err
})?;
let (stream, last_request_rx) = map_response_stream(
@@ -1621,6 +1623,9 @@ fn parent_thread_id_header_value(session_source: &SessionSource) -> Option<Strin
}
}
const RESPONSE_STREAM_CHANNEL_CAPACITY: usize = 1600;
const STREAM_DROPPED_REASON: &str = "response stream dropped before provider terminal event";
fn map_response_stream<S>(
api_stream: S,
session_telemetry: SessionTelemetry,
@@ -1632,15 +1637,28 @@ where
+ Send
+ 'static,
{
let (tx_event, rx_event) = mpsc::channel::<Result<ResponseEvent>>(1600);
let (tx_event, rx_event) =
mpsc::channel::<Result<ResponseEvent>>(RESPONSE_STREAM_CHANNEL_CAPACITY);
let (tx_last_response, rx_last_response) = oneshot::channel::<LastResponse>();
let consumer_dropped = CancellationToken::new();
let consumer_dropped_for_stream = consumer_dropped.clone();
tokio::spawn(async move {
let mut logged_error = false;
let mut tx_last_response = Some(tx_last_response);
let mut items_added: Vec<ResponseItem> = Vec::new();
let mut api_stream = api_stream;
while let Some(event) = api_stream.next().await {
loop {
let event = tokio::select! {
_ = consumer_dropped.cancelled() => {
inference_trace_attempt.record_cancelled(STREAM_DROPPED_REASON, &items_added);
return;
}
event = api_stream.next() => event,
};
let Some(event) = event else {
break;
};
match event {
Ok(ResponseEvent::OutputItemDone(item)) => {
items_added.push(item.clone());
@@ -1649,6 +1667,8 @@ where
.await
.is_err()
{
inference_trace_attempt
.record_cancelled(STREAM_DROPPED_REASON, &items_added);
return;
}
}
@@ -1691,12 +1711,14 @@ where
}
Ok(event) => {
if tx_event.send(Ok(event)).await.is_err() {
inference_trace_attempt
.record_cancelled(STREAM_DROPPED_REASON, &items_added);
return;
}
}
Err(err) => {
let mapped = map_api_error(err);
inference_trace_attempt.record_failed(&mapped);
inference_trace_attempt.record_failed(&mapped, &items_added);
if !logged_error {
session_telemetry.see_event_completed_failed(&mapped);
logged_error = true;
@@ -1707,9 +1729,17 @@ where
}
}
}
inference_trace_attempt
.record_failed("stream closed before response.completed", &items_added);
});
(ResponseStream { rx_event }, rx_last_response)
(
ResponseStream {
rx_event,
consumer_dropped: consumer_dropped_for_stream,
},
rx_last_response,
)
}
/// Handles a 401 response by optionally refreshing ChatGPT tokens once.
+10
View File
@@ -13,6 +13,7 @@ use std::pin::Pin;
use std::task::Context;
use std::task::Poll;
use tokio::sync::mpsc;
use tokio_util::sync::CancellationToken;
/// Review thread system prompt. Edit `core/src/review_prompt.md` to customize.
pub const REVIEW_PROMPT: &str = include_str!("../review_prompt.md");
@@ -175,6 +176,9 @@ fn strip_total_output_header(output: &str) -> Option<(&str, u32)> {
pub struct ResponseStream {
pub(crate) rx_event: mpsc::Receiver<Result<ResponseEvent>>,
/// Signals the mapper task that the consumer stopped polling before the
/// provider stream reached its own terminal event.
pub(crate) consumer_dropped: CancellationToken,
}
impl Stream for ResponseStream {
@@ -185,6 +189,12 @@ impl Stream for ResponseStream {
}
}
impl Drop for ResponseStream {
fn drop(&mut self) {
self.consumer_dropped.cancel();
}
}
#[cfg(test)]
#[path = "client_common_tests.rs"]
mod tests;
+191
View File
@@ -7,17 +7,37 @@ use super::X_CODEX_PARENT_THREAD_ID_HEADER;
use super::X_CODEX_TURN_METADATA_HEADER;
use super::X_CODEX_WINDOW_ID_HEADER;
use super::X_OPENAI_SUBAGENT_HEADER;
use codex_api::ApiError;
use codex_api::ResponseEvent;
use codex_app_server_protocol::AuthMode;
use codex_model_provider::BearerAuthProvider;
use codex_model_provider_info::WireApi;
use codex_model_provider_info::create_oss_provider_with_base_url;
use codex_otel::SessionTelemetry;
use codex_protocol::ThreadId;
use codex_protocol::models::ContentItem;
use codex_protocol::models::ResponseItem;
use codex_protocol::openai_models::ModelInfo;
use codex_protocol::protocol::SessionSource;
use codex_protocol::protocol::SubAgentSource;
use codex_rollout_trace::ExecutionStatus;
use codex_rollout_trace::InferenceTraceAttempt;
use codex_rollout_trace::InferenceTraceContext;
use codex_rollout_trace::RawTraceEventPayload;
use codex_rollout_trace::RolloutTrace;
use codex_rollout_trace::TraceWriter;
use codex_rollout_trace::replay_bundle;
use futures::StreamExt;
use pretty_assertions::assert_eq;
use serde_json::json;
use std::collections::VecDeque;
use std::pin::Pin;
use std::sync::Arc;
use std::task::Context;
use std::task::Poll;
use std::time::Duration;
use tempfile::TempDir;
use tokio::sync::Notify;
fn test_model_client(session_source: SessionSource) -> ModelClient {
let provider = create_oss_provider_with_base_url("https://example.com/v1", WireApi::Responses);
@@ -79,6 +99,92 @@ fn test_session_telemetry() -> SessionTelemetry {
)
}
fn started_inference_attempt(temp: &TempDir) -> anyhow::Result<InferenceTraceAttempt> {
let writer = Arc::new(TraceWriter::create(
temp.path(),
"trace-1".to_string(),
"rollout-1".to_string(),
"thread-root".to_string(),
)?);
writer.append(RawTraceEventPayload::ThreadStarted {
thread_id: "thread-root".to_string(),
agent_path: "/root".to_string(),
metadata_payload: None,
})?;
writer.append(RawTraceEventPayload::CodexTurnStarted {
codex_turn_id: "turn-1".to_string(),
thread_id: "thread-root".to_string(),
})?;
let inference_trace = InferenceTraceContext::enabled(
writer,
"thread-root".to_string(),
"turn-1".to_string(),
"gpt-test".to_string(),
"test-provider".to_string(),
);
let attempt = inference_trace.start_attempt();
attempt.record_started(&json!({
"model": "gpt-test",
"input": [{
"type": "message",
"role": "user",
"content": [{"type": "input_text", "text": "hello"}]
}],
}));
Ok(attempt)
}
fn output_message(id: &str, text: &str) -> ResponseItem {
ResponseItem::Message {
id: Some(id.to_string()),
role: "assistant".to_string(),
content: vec![ContentItem::OutputText {
text: text.to_string(),
}],
phase: None,
}
}
async fn replay_until_cancelled(temp: &TempDir) -> anyhow::Result<RolloutTrace> {
let mut rollout = replay_bundle(temp.path())?;
for _ in 0..50 {
let inference = rollout
.inference_calls
.values()
.next()
.expect("inference should be reduced");
if inference.execution.status == ExecutionStatus::Cancelled {
return Ok(rollout);
}
tokio::time::sleep(Duration::from_millis(10)).await;
rollout = replay_bundle(temp.path())?;
}
Ok(rollout)
}
struct NotifyAfterEventStream {
events: VecDeque<ResponseEvent>,
yielded: usize,
notify_after: usize,
notify: Arc<Notify>,
}
impl futures::Stream for NotifyAfterEventStream {
type Item = std::result::Result<ResponseEvent, ApiError>;
fn poll_next(mut self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
let Some(event) = self.events.pop_front() else {
return Poll::Pending;
};
self.yielded += 1;
if self.yielded == self.notify_after {
self.notify.notify_one();
}
Poll::Ready(Some(Ok(event)))
}
}
#[test]
fn build_subagent_headers_sets_other_subagent_label() {
let client = test_model_client(SessionSource::SubAgent(SubAgentSource::Other(
@@ -151,6 +257,91 @@ async fn summarize_memories_returns_empty_for_empty_input() {
assert_eq!(output.len(), 0);
}
#[tokio::test]
async fn dropped_response_stream_traces_cancelled_partial_output() -> anyhow::Result<()> {
let temp = TempDir::new()?;
let attempt = started_inference_attempt(&temp)?;
// The provider has produced one complete output item, but no terminal
// response.completed event. The harness has enough information to keep this
// item in history, so the trace should preserve it when the stream is
// abandoned.
let item = output_message("msg-1", "partial answer");
let api_stream = futures::stream::iter([Ok(ResponseEvent::OutputItemDone(item))])
.chain(futures::stream::pending());
let (mut stream, _) = super::map_response_stream(api_stream, test_session_telemetry(), attempt);
let observed = stream
.next()
.await
.expect("mapped stream should yield output item")?;
assert!(matches!(observed, ResponseEvent::OutputItemDone(_)));
// Dropping the consumer is how turn interruption/preemption stops polling
// the provider stream. The mapper task observes that drop asynchronously
// and records cancellation using the output items it has already seen.
drop(stream);
// Cancellation is recorded by the mapper task after Drop wakes it, so the
// replay may need a short wait before the terminal event appears on disk.
let rollout = replay_until_cancelled(&temp).await?;
let inference = rollout
.inference_calls
.values()
.next()
.expect("inference should be reduced");
assert_eq!(inference.execution.status, ExecutionStatus::Cancelled);
assert_eq!(inference.response_item_ids.len(), 1);
assert_eq!(rollout.raw_payloads.len(), 2);
Ok(())
}
#[tokio::test]
async fn dropped_backpressured_response_stream_traces_cancelled_partial_output()
-> anyhow::Result<()> {
let temp = TempDir::new()?;
let attempt = started_inference_attempt(&temp)?;
let backpressured_item_yielded = Arc::new(Notify::new());
let mut events = VecDeque::new();
for _ in 0..super::RESPONSE_STREAM_CHANNEL_CAPACITY {
events.push_back(ResponseEvent::Created);
}
events.push_back(ResponseEvent::OutputItemDone(output_message(
"msg-1",
"partial answer",
)));
let api_stream = NotifyAfterEventStream {
events,
yielded: 0,
notify_after: super::RESPONSE_STREAM_CHANNEL_CAPACITY + 1,
notify: Arc::clone(&backpressured_item_yielded),
};
let (stream, _) = super::map_response_stream(api_stream, test_session_telemetry(), attempt);
// Fill the mapper channel with non-terminal events, then yield one output
// item. The mapper has observed that item and is blocked trying to send it
// downstream, so dropping the consumer covers the send-failure path rather
// than the `consumer_dropped` select branch.
backpressured_item_yielded.notified().await;
drop(stream);
let rollout = replay_until_cancelled(&temp).await?;
let inference = rollout
.inference_calls
.values()
.next()
.expect("inference should be reduced");
assert_eq!(inference.execution.status, ExecutionStatus::Cancelled);
assert_eq!(inference.response_item_ids.len(), 1);
assert_eq!(rollout.raw_payloads.len(), 2);
Ok(())
}
#[test]
fn auth_request_telemetry_context_tracks_attached_auth_and_retry_phase() {
let auth_context = AuthRequestTelemetryContext::new(
+1 -1
View File
@@ -947,7 +947,7 @@ async fn permission_profile_override_applies_runtime_roots_to_legacy_projection(
},
FileSystemSandboxEntry {
path: FileSystemPath::Special {
value: FileSystemSpecialPath::CurrentWorkingDirectory,
value: FileSystemSpecialPath::project_roots(/*subpath*/ None),
},
access: FileSystemAccessMode::Write,
},
+1 -1
View File
@@ -1004,7 +1004,7 @@ fn managed_cwd_write_profile_is_not_read_only() {
},
FileSystemSandboxEntry {
path: FileSystemPath::Special {
value: FileSystemSpecialPath::CurrentWorkingDirectory,
value: FileSystemSpecialPath::project_roots(/*subpath*/ None),
},
access: FileSystemAccessMode::Write,
},
+18 -6
View File
@@ -535,7 +535,9 @@ fn windows_restricted_token_rejects_split_only_filesystem_policies() {
let file_system_policy = FileSystemSandboxPolicy::restricted(vec![
codex_protocol::permissions::FileSystemSandboxEntry {
path: codex_protocol::permissions::FileSystemPath::Special {
value: codex_protocol::permissions::FileSystemSpecialPath::CurrentWorkingDirectory,
value: codex_protocol::permissions::FileSystemSpecialPath::project_roots(
/*subpath*/ None,
),
},
access: codex_protocol::permissions::FileSystemAccessMode::Write,
},
@@ -630,7 +632,9 @@ fn windows_restricted_token_supports_full_read_split_write_read_carveouts() {
},
codex_protocol::permissions::FileSystemSandboxEntry {
path: codex_protocol::permissions::FileSystemPath::Special {
value: codex_protocol::permissions::FileSystemSpecialPath::CurrentWorkingDirectory,
value: codex_protocol::permissions::FileSystemSpecialPath::project_roots(
/*subpath*/ None,
),
},
access: codex_protocol::permissions::FileSystemAccessMode::Write,
},
@@ -720,7 +724,9 @@ fn windows_elevated_supports_split_write_read_carveouts() {
},
codex_protocol::permissions::FileSystemSandboxEntry {
path: codex_protocol::permissions::FileSystemPath::Special {
value: codex_protocol::permissions::FileSystemSpecialPath::CurrentWorkingDirectory,
value: codex_protocol::permissions::FileSystemSpecialPath::project_roots(
/*subpath*/ None,
),
},
access: codex_protocol::permissions::FileSystemAccessMode::Write,
},
@@ -774,7 +780,9 @@ fn windows_elevated_rejects_unreadable_split_carveouts() {
},
codex_protocol::permissions::FileSystemSandboxEntry {
path: codex_protocol::permissions::FileSystemPath::Special {
value: codex_protocol::permissions::FileSystemSpecialPath::CurrentWorkingDirectory,
value: codex_protocol::permissions::FileSystemSpecialPath::project_roots(
/*subpath*/ None,
),
},
access: codex_protocol::permissions::FileSystemAccessMode::Write,
},
@@ -821,7 +829,9 @@ fn windows_elevated_rejects_unreadable_globs() {
},
codex_protocol::permissions::FileSystemSandboxEntry {
path: codex_protocol::permissions::FileSystemPath::Special {
value: codex_protocol::permissions::FileSystemSpecialPath::CurrentWorkingDirectory,
value: codex_protocol::permissions::FileSystemSpecialPath::project_roots(
/*subpath*/ None,
),
},
access: codex_protocol::permissions::FileSystemAccessMode::Write,
},
@@ -870,7 +880,9 @@ fn windows_elevated_rejects_reopened_writable_descendants() {
},
codex_protocol::permissions::FileSystemSandboxEntry {
path: codex_protocol::permissions::FileSystemPath::Special {
value: codex_protocol::permissions::FileSystemSpecialPath::CurrentWorkingDirectory,
value: codex_protocol::permissions::FileSystemSpecialPath::project_roots(
/*subpath*/ None,
),
},
access: codex_protocol::permissions::FileSystemAccessMode::Write,
},
+1 -1
View File
@@ -258,7 +258,7 @@ fn explicit_read_only_subpaths_prevent_auto_approval_for_external_sandbox() {
let file_system_sandbox_policy = FileSystemSandboxPolicy::restricted(vec![
FileSystemSandboxEntry {
path: FileSystemPath::Special {
value: FileSystemSpecialPath::CurrentWorkingDirectory,
value: FileSystemSpecialPath::project_roots(/*subpath*/ None),
},
access: FileSystemAccessMode::Write,
},
+14 -14
View File
@@ -149,19 +149,19 @@ impl SessionConfiguration {
);
let file_system_policy_matches_legacy = current_file_system_sandbox_policy
.is_semantically_equivalent_to(&legacy_file_system_projection, &self.cwd);
let file_system_policy_has_rebindable_cwd_write = current_file_system_sandbox_policy
.entries
.iter()
.any(|entry| {
entry.access.can_write()
&& matches!(
&entry.path,
FileSystemPath::Special {
value: FileSystemSpecialPath::CurrentWorkingDirectory
| FileSystemSpecialPath::ProjectRoots { subpath: None },
}
)
});
let file_system_policy_has_rebindable_project_root_write =
current_file_system_sandbox_policy
.entries
.iter()
.any(|entry| {
entry.access.can_write()
&& matches!(
&entry.path,
FileSystemPath::Special {
value: FileSystemSpecialPath::ProjectRoots { subpath: None },
}
)
});
if let Some(collaboration_mode) = updates.collaboration_mode.clone() {
next_configuration.collaboration_mode = collaboration_mode;
}
@@ -228,7 +228,7 @@ impl SessionConfiguration {
)?;
} else if cwd_changed
&& file_system_policy_matches_legacy
&& file_system_policy_has_rebindable_cwd_write
&& file_system_policy_has_rebindable_project_root_write
{
// Preserve richer split policies across cwd-only updates; only
// rederive when the session is already using a structurally
+3 -3
View File
@@ -2874,7 +2874,7 @@ async fn session_configuration_apply_preserves_profile_file_system_policy_on_cwd
let file_system_sandbox_policy = FileSystemSandboxPolicy::restricted(vec![
FileSystemSandboxEntry {
path: FileSystemPath::Special {
value: FileSystemSpecialPath::CurrentWorkingDirectory,
value: FileSystemSpecialPath::project_roots(/*subpath*/ None),
},
access: FileSystemAccessMode::Write,
},
@@ -3187,7 +3187,7 @@ async fn session_configuration_apply_preserves_absolute_cwd_write_root_on_cwd_up
!updated
.file_system_sandbox_policy()
.can_write_path_with_cwd(next_cwd.as_path(), updated.cwd.as_path()),
"cwd-only update must not reinterpret an absolute old-cwd grant as :cwd"
"cwd-only update must not reinterpret an absolute old-cwd grant as :project_roots"
);
}
@@ -3850,7 +3850,7 @@ async fn request_permissions_response_materializes_session_cwd_grants_before_rec
file_system: Some(FileSystemPermissions {
entries: vec![FileSystemSandboxEntry {
path: FileSystemPath::Special {
value: FileSystemSpecialPath::CurrentWorkingDirectory,
value: FileSystemSpecialPath::project_roots(/*subpath*/ None),
},
access: FileSystemAccessMode::Write,
}],
+1 -1
View File
@@ -354,7 +354,7 @@ mod tests {
entries: vec![
FileSystemSandboxEntry {
path: FileSystemPath::Special {
value: FileSystemSpecialPath::CurrentWorkingDirectory,
value: FileSystemSpecialPath::project_roots(/*subpath*/ None),
},
access: FileSystemAccessMode::Write,
},
+1 -3
View File
@@ -117,9 +117,7 @@ pub(crate) fn file_system_policy_has_cwd_dependent_entries(
.any(|entry| match &entry.path {
FileSystemPath::GlobPattern { pattern } => !Path::new(pattern).is_absolute(),
FileSystemPath::Special {
value:
FileSystemSpecialPath::CurrentWorkingDirectory
| FileSystemSpecialPath::ProjectRoots { .. },
value: FileSystemSpecialPath::ProjectRoots { .. },
} => true,
FileSystemPath::Path { .. } | FileSystemPath::Special { .. } => false,
})
+4 -4
View File
@@ -118,7 +118,7 @@ fn sandbox_cwd(sandbox: &FileSystemSandboxContext) -> Result<AbsolutePathBuf, JS
let file_system_policy = sandbox.permissions.file_system_sandbox_policy();
if file_system_policy_has_cwd_dependent_entries(&file_system_policy) {
return Err(invalid_request(
"file system sandbox context with cwd-relative permissions requires cwd".to_string(),
"file system sandbox context with dynamic permissions requires cwd".to_string(),
));
}
@@ -467,7 +467,7 @@ mod tests {
let cwd = AbsolutePathBuf::from_absolute_path(std::env::temp_dir().as_path())
.expect("absolute cwd");
let policy = restricted_policy(vec![special_entry(
FileSystemSpecialPath::CurrentWorkingDirectory,
FileSystemSpecialPath::project_roots(/*subpath*/ None),
FileSystemAccessMode::Write,
)]);
let sandbox_context = sandbox_context_with_cwd(&policy, cwd.clone());
@@ -479,7 +479,7 @@ mod tests {
fn sandbox_cwd_rejects_cwd_dependent_profile_without_context_cwd() {
let policy = FileSystemSandboxPolicy::restricted(vec![FileSystemSandboxEntry {
path: FileSystemPath::Special {
value: FileSystemSpecialPath::CurrentWorkingDirectory,
value: FileSystemSpecialPath::project_roots(/*subpath*/ None),
},
access: FileSystemAccessMode::Write,
}]);
@@ -491,7 +491,7 @@ mod tests {
assert_eq!(
err.message,
"file system sandbox context with cwd-relative permissions requires cwd"
"file system sandbox context with dynamic permissions requires cwd"
);
}
@@ -252,7 +252,7 @@ mod tests {
fn remote_sandbox_context_preserves_required_cwd() {
let policy = FileSystemSandboxPolicy::restricted(vec![FileSystemSandboxEntry {
path: FileSystemPath::Special {
value: FileSystemSpecialPath::CurrentWorkingDirectory,
value: FileSystemSpecialPath::project_roots(/*subpath*/ None),
},
access: FileSystemAccessMode::Write,
}]);
+3
View File
@@ -3,5 +3,8 @@ load("//:defs.bzl", "codex_rust_crate")
codex_rust_crate(
name = "exec",
crate_name = "codex_exec",
test_shard_counts = {
"exec-all-test": 8,
},
test_tags = ["no-sandbox"],
)
+1 -1
View File
@@ -1143,7 +1143,7 @@ mod tests {
},
FileSystemSandboxEntry {
path: FileSystemPath::Special {
value: FileSystemSpecialPath::CurrentWorkingDirectory,
value: FileSystemSpecialPath::project_roots(/*subpath*/ None),
},
access: FileSystemAccessMode::Write,
},
@@ -200,7 +200,9 @@ fn split_only_filesystem_policy_requires_direct_runtime_enforcement() {
let policy = FileSystemSandboxPolicy::restricted(vec![
codex_protocol::permissions::FileSystemSandboxEntry {
path: codex_protocol::permissions::FileSystemPath::Special {
value: codex_protocol::permissions::FileSystemSpecialPath::CurrentWorkingDirectory,
value: codex_protocol::permissions::FileSystemSpecialPath::project_roots(
/*subpath*/ None,
),
},
access: codex_protocol::permissions::FileSystemAccessMode::Write,
},
+6 -2
View File
@@ -402,7 +402,11 @@ impl PermissionProfile {
}
}
/// Managed workspace-write filesystem access with restricted network access.
/// Managed workspace-write filesystem access with restricted network
/// access.
///
/// The returned profile contains symbolic `:project_roots` entries that
/// must be resolved against the active permission root before enforcement.
pub fn workspace_write() -> Self {
Self::Managed {
file_system: ManagedFileSystemPermissions::Restricted {
@@ -415,7 +419,7 @@ impl PermissionProfile {
},
FileSystemSandboxEntry {
path: FileSystemPath::Special {
value: FileSystemSpecialPath::CurrentWorkingDirectory,
value: FileSystemSpecialPath::project_roots(/*subpath*/ None),
},
access: FileSystemAccessMode::Write,
},
+61 -39
View File
@@ -80,7 +80,7 @@ impl FileSystemAccessMode {
pub enum FileSystemSpecialPath {
Root,
Minimal,
CurrentWorkingDirectory,
#[serde(alias = "current_working_directory")]
ProjectRoots {
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional)]
@@ -414,9 +414,9 @@ impl FileSystemSandboxPolicy {
/// Converts a legacy sandbox policy into a cwd-independent filesystem policy.
///
/// `WorkspaceWrite` uses symbolic entries for cwd-scoped access so callers
/// can preserve the active cwd binding until the policy is actually
/// resolved for a turn or command.
/// `WorkspaceWrite` uses symbolic project-root entries so callers can keep
/// the profile independent of the concrete root until it is resolved for a
/// turn or command.
pub fn from_legacy_sandbox_policy(sandbox_policy: &SandboxPolicy) -> Self {
let mut file_system_policy = Self::from(sandbox_policy);
let SandboxPolicy::WorkspaceWrite {
@@ -571,6 +571,28 @@ impl FileSystemSandboxPolicy {
self.resolve_access_with_cwd(path, cwd).can_write()
}
/// Replaces symbolic `:project_roots` entries with absolute paths resolved
/// against `cwd`.
///
/// Use this when a durable permission profile must survive a cwd-only
/// update without rebinding its project-root authority to the new cwd.
pub fn materialize_project_roots_with_cwd(mut self, cwd: &Path) -> Self {
let cwd = AbsolutePathBuf::from_absolute_path(cwd).ok();
for entry in &mut self.entries {
let FileSystemPath::Special {
value: FileSystemSpecialPath::ProjectRoots { .. },
} = &entry.path
else {
continue;
};
if let Some(path) = resolve_file_system_path(&entry.path, cwd.as_ref()) {
entry.path = FileSystemPath::Path { path };
}
}
self
}
pub fn with_additional_readable_roots(
mut self,
cwd: &Path,
@@ -867,11 +889,6 @@ impl FileSystemSandboxPolicy {
}
},
FileSystemSpecialPath::Minimal => {}
FileSystemSpecialPath::CurrentWorkingDirectory => {
if entry.access.can_write() {
workspace_root_writable = true;
}
}
FileSystemSpecialPath::ProjectRoots { subpath } => {
if subpath.is_none() && entry.access.can_write() {
workspace_root_writable = true;
@@ -1001,7 +1018,7 @@ impl From<&SandboxPolicy> for FileSystemSandboxPolicy {
entries.push(FileSystemSandboxEntry {
path: FileSystemPath::Special {
value: FileSystemSpecialPath::CurrentWorkingDirectory,
value: FileSystemSpecialPath::project_roots(/*subpath*/ None),
},
access: FileSystemAccessMode::Write,
});
@@ -1099,20 +1116,8 @@ fn special_paths_share_target(left: &FileSystemSpecialPath, right: &FileSystemSp
match (left, right) {
(FileSystemSpecialPath::Root, FileSystemSpecialPath::Root)
| (FileSystemSpecialPath::Minimal, FileSystemSpecialPath::Minimal)
| (
FileSystemSpecialPath::CurrentWorkingDirectory,
FileSystemSpecialPath::CurrentWorkingDirectory,
)
| (FileSystemSpecialPath::Tmpdir, FileSystemSpecialPath::Tmpdir)
| (FileSystemSpecialPath::SlashTmp, FileSystemSpecialPath::SlashTmp) => true,
(
FileSystemSpecialPath::CurrentWorkingDirectory,
FileSystemSpecialPath::ProjectRoots { subpath: None },
)
| (
FileSystemSpecialPath::ProjectRoots { subpath: None },
FileSystemSpecialPath::CurrentWorkingDirectory,
) => true,
(
FileSystemSpecialPath::ProjectRoots { subpath: left },
FileSystemSpecialPath::ProjectRoots { subpath: right },
@@ -1210,10 +1215,6 @@ fn resolve_file_system_special_path(
FileSystemSpecialPath::Root
| FileSystemSpecialPath::Minimal
| FileSystemSpecialPath::Unknown { .. } => None,
FileSystemSpecialPath::CurrentWorkingDirectory => {
let cwd = cwd?;
Some(cwd.clone())
}
FileSystemSpecialPath::ProjectRoots { subpath } => {
let cwd = cwd?;
match subpath.as_ref() {
@@ -1550,7 +1551,7 @@ mod tests {
let policy = FileSystemSandboxPolicy::restricted(vec![FileSystemSandboxEntry {
path: FileSystemPath::Special {
value: FileSystemSpecialPath::CurrentWorkingDirectory,
value: FileSystemSpecialPath::project_roots(/*subpath*/ None),
},
access: FileSystemAccessMode::Write,
}]);
@@ -1566,7 +1567,7 @@ mod tests {
}
#[test]
fn legacy_workspace_write_projection_preserves_symbolic_cwd() {
fn legacy_workspace_write_projection_preserves_symbolic_project_root() {
let policy = SandboxPolicy::WorkspaceWrite {
writable_roots: Vec::new(),
network_access: false,
@@ -1585,7 +1586,7 @@ mod tests {
},
FileSystemSandboxEntry {
path: FileSystemPath::Special {
value: FileSystemSpecialPath::CurrentWorkingDirectory,
value: FileSystemSpecialPath::project_roots(/*subpath*/ None),
},
access: FileSystemAccessMode::Write,
},
@@ -1611,6 +1612,27 @@ mod tests {
);
}
#[test]
fn legacy_current_working_directory_special_path_deserializes_as_project_roots()
-> serde_json::Result<()> {
let value = serde_json::json!({
"kind": "current_working_directory",
});
let special_path = serde_json::from_value::<FileSystemSpecialPath>(value)?;
assert_eq!(
special_path,
FileSystemSpecialPath::project_roots(/*subpath*/ None)
);
assert_eq!(
serde_json::to_value(&special_path)?,
serde_json::json!({
"kind": "project_roots",
})
);
Ok(())
}
#[cfg(unix)]
#[test]
fn writable_roots_skip_default_dot_codex_when_explicit_user_rule_exists() {
@@ -1624,7 +1646,7 @@ mod tests {
let policy = FileSystemSandboxPolicy::restricted(vec![
FileSystemSandboxEntry {
path: FileSystemPath::Special {
value: FileSystemSpecialPath::CurrentWorkingDirectory,
value: FileSystemSpecialPath::project_roots(/*subpath*/ None),
},
access: FileSystemAccessMode::Write,
},
@@ -1703,7 +1725,7 @@ mod tests {
},
FileSystemSandboxEntry {
path: FileSystemPath::Special {
value: FileSystemSpecialPath::CurrentWorkingDirectory,
value: FileSystemSpecialPath::project_roots(/*subpath*/ None),
},
access: FileSystemAccessMode::Write,
},
@@ -1774,7 +1796,7 @@ mod tests {
#[cfg(unix)]
#[test]
fn current_working_directory_special_path_preserves_symlinked_cwd() {
fn project_roots_special_path_preserves_symlinked_root() {
let cwd = TempDir::new().expect("tempdir");
let real_root = cwd.path().join("real");
let link_root = cwd.path().join("link");
@@ -1804,7 +1826,7 @@ mod tests {
},
FileSystemSandboxEntry {
path: FileSystemPath::Special {
value: FileSystemSpecialPath::CurrentWorkingDirectory,
value: FileSystemSpecialPath::project_roots(/*subpath*/ None),
},
access: FileSystemAccessMode::Write,
},
@@ -2094,7 +2116,7 @@ mod tests {
let policy = FileSystemSandboxPolicy::restricted(vec![
FileSystemSandboxEntry {
path: FileSystemPath::Special {
value: FileSystemSpecialPath::CurrentWorkingDirectory,
value: FileSystemSpecialPath::project_roots(/*subpath*/ None),
},
access: FileSystemAccessMode::Write,
},
@@ -2141,7 +2163,7 @@ mod tests {
let policy = FileSystemSandboxPolicy::restricted(vec![
FileSystemSandboxEntry {
path: FileSystemPath::Special {
value: FileSystemSpecialPath::CurrentWorkingDirectory,
value: FileSystemSpecialPath::project_roots(/*subpath*/ None),
},
access: FileSystemAccessMode::Write,
},
@@ -2318,7 +2340,7 @@ mod tests {
let cwd_root = AbsolutePathBuf::from_absolute_path(cwd.path()).expect("absolute cwd");
let policy = FileSystemSandboxPolicy::restricted(vec![FileSystemSandboxEntry {
path: FileSystemPath::Special {
value: FileSystemSpecialPath::CurrentWorkingDirectory,
value: FileSystemSpecialPath::project_roots(/*subpath*/ None),
},
access: FileSystemAccessMode::Read,
}]);
@@ -2336,7 +2358,7 @@ mod tests {
let cwd_root = AbsolutePathBuf::from_absolute_path(cwd.path()).expect("absolute cwd");
let policy = FileSystemSandboxPolicy::restricted(vec![FileSystemSandboxEntry {
path: FileSystemPath::Special {
value: FileSystemSpecialPath::CurrentWorkingDirectory,
value: FileSystemSpecialPath::project_roots(/*subpath*/ None),
},
access: FileSystemAccessMode::Write,
}]);
@@ -2356,7 +2378,7 @@ mod tests {
.expect("resolve extra root");
let policy = FileSystemSandboxPolicy::restricted(vec![FileSystemSandboxEntry {
path: FileSystemPath::Special {
value: FileSystemSpecialPath::CurrentWorkingDirectory,
value: FileSystemSpecialPath::project_roots(/*subpath*/ None),
},
access: FileSystemAccessMode::Write,
}]);
@@ -2368,7 +2390,7 @@ mod tests {
FileSystemSandboxPolicy::restricted(vec![
FileSystemSandboxEntry {
path: FileSystemPath::Special {
value: FileSystemSpecialPath::CurrentWorkingDirectory,
value: FileSystemSpecialPath::project_roots(/*subpath*/ None),
},
access: FileSystemAccessMode::Write,
},
+2 -2
View File
@@ -4397,7 +4397,7 @@ mod tests {
},
FileSystemSandboxEntry {
path: FileSystemPath::Special {
value: FileSystemSpecialPath::CurrentWorkingDirectory,
value: FileSystemSpecialPath::project_roots(/*subpath*/ None),
},
access: FileSystemAccessMode::Write,
},
@@ -4459,7 +4459,7 @@ mod tests {
let policy = FileSystemSandboxPolicy::restricted(vec![
FileSystemSandboxEntry {
path: FileSystemPath::Special {
value: FileSystemSpecialPath::CurrentWorkingDirectory,
value: FileSystemSpecialPath::project_roots(/*subpath*/ None),
},
access: FileSystemAccessMode::Write,
},
+93 -26
View File
@@ -6,6 +6,7 @@
use std::fmt::Display;
use std::sync::Arc;
use std::sync::atomic::AtomicBool;
use std::sync::atomic::AtomicU64;
use std::sync::atomic::Ordering;
@@ -54,35 +55,36 @@ struct EnabledInferenceTraceContext {
///
/// A Codex turn can create multiple attempts when auth recovery retries the
/// HTTP request or WebSocket setup falls back to HTTP. Completion is often
/// observed after the client returns the response stream, so attempts are
/// cloneable and self-contained.
#[derive(Clone, Debug)]
/// observed after the client returns the response stream, so the attempt owns
/// the terminal guard that prevents duplicate lifecycle events.
#[derive(Debug)]
pub struct InferenceTraceAttempt {
state: InferenceTraceAttemptState,
}
#[derive(Clone, Debug)]
#[derive(Debug)]
enum InferenceTraceAttemptState {
Disabled,
Enabled(EnabledInferenceTraceAttempt),
}
#[derive(Clone, Debug)]
#[derive(Debug)]
struct EnabledInferenceTraceAttempt {
context: EnabledInferenceTraceContext,
inference_call_id: InferenceCallId,
terminal_recorded: AtomicBool,
}
/// Non-delta response payload saved when a traced inference stream completes.
/// Non-delta response payload saved for completed or interrupted inference streams.
///
/// We intentionally record completed output items instead of every stream delta
/// here. The raw stream can be added later as a separate payload class; this
/// response summary gives the reducer stable response identity, usage, and
/// model-visible output without duplicating high-volume text deltas.
/// response summary gives the reducer stable response identity when available
/// plus model-visible output without duplicating high-volume text deltas.
#[derive(Serialize)]
struct TracedResponseStreamCompleted<'a> {
response_id: &'a str,
token_usage: &'a Option<TokenUsage>,
struct TracedResponseStreamOutput<'a> {
response_id: Option<&'a str>,
token_usage: Option<&'a TokenUsage>,
output_items: Vec<JsonValue>,
}
@@ -123,6 +125,7 @@ impl InferenceTraceContext {
state: InferenceTraceAttemptState::Enabled(EnabledInferenceTraceAttempt {
context: context.clone(),
inference_call_id: next_inference_call_id(),
terminal_recorded: AtomicBool::new(false),
}),
}
}
@@ -162,9 +165,9 @@ impl InferenceTraceAttempt {
);
}
/// Records a bounded, non-streaming summary of the completed response stream.
/// Records successful provider completion and serializes the observed output items.
///
/// The caller passes protocol-native response items so this crate owns the
/// Callers pass protocol-native response items so this crate owns the
/// trace-specific serialization rules. That keeps codex-core focused on
/// transport behavior while preserving trace evidence that normal request
/// serialization intentionally omits.
@@ -174,18 +177,14 @@ impl InferenceTraceAttempt {
token_usage: &Option<TokenUsage>,
output_items: &[ResponseItem],
) {
let InferenceTraceAttemptState::Enabled(attempt) = &self.state else {
let Some(attempt) = self.take_terminal_attempt() else {
return;
};
let response_payload = TracedResponseStreamCompleted {
response_id,
token_usage,
output_items: output_items.iter().map(trace_response_item_json).collect(),
};
let Some(response_payload) = write_json_payload_best_effort(
&attempt.context.writer,
RawPayloadKind::InferenceResponse,
&response_payload,
let Some(response_payload) = write_response_payload_best_effort(
attempt,
Some(response_id),
token_usage.as_ref(),
output_items,
) else {
return;
};
@@ -201,19 +200,69 @@ impl InferenceTraceAttempt {
}
/// Records pre-response and mid-stream failures.
pub fn record_failed(&self, error: impl Display) {
let InferenceTraceAttemptState::Enabled(attempt) = &self.state else {
pub fn record_failed(&self, error: impl Display, output_items: &[ResponseItem]) {
let Some(attempt) = self.take_terminal_attempt() else {
return;
};
let partial_response_payload = if output_items.is_empty() {
None
} else {
write_response_payload_best_effort(
attempt,
/*response_id*/ None,
/*token_usage*/ None,
output_items,
)
};
append_with_context_best_effort(
&attempt.context,
RawTraceEventPayload::InferenceFailed {
inference_call_id: attempt.inference_call_id.clone(),
error: error.to_string(),
partial_response_payload: None,
partial_response_payload,
},
);
}
/// Records a provider stream that Codex intentionally stopped consuming.
///
/// This happens when the turn is interrupted or when mailbox delivery
/// preempts the current sampling request. Complete output items observed
/// before that point are retained as partial response evidence.
pub fn record_cancelled(&self, reason: impl Display, output_items: &[ResponseItem]) {
let Some(attempt) = self.take_terminal_attempt() else {
return;
};
let partial_response_payload = if output_items.is_empty() {
None
} else {
write_response_payload_best_effort(
attempt,
/*response_id*/ None,
/*token_usage*/ None,
output_items,
)
};
append_with_context_best_effort(
&attempt.context,
RawTraceEventPayload::InferenceCancelled {
inference_call_id: attempt.inference_call_id.clone(),
reason: reason.to_string(),
partial_response_payload,
},
);
}
fn take_terminal_attempt(&self) -> Option<&EnabledInferenceTraceAttempt> {
let attempt = match &self.state {
InferenceTraceAttemptState::Disabled => return None,
InferenceTraceAttemptState::Enabled(attempt) => attempt,
};
if attempt.terminal_recorded.swap(true, Ordering::AcqRel) {
return None;
}
Some(attempt)
}
}
/// Serializes a response item for trace evidence rather than future request construction.
@@ -260,6 +309,24 @@ fn write_json_payload_best_effort(
writer.write_json_payload(kind, payload).ok()
}
fn write_response_payload_best_effort(
attempt: &EnabledInferenceTraceAttempt,
response_id: Option<&str>,
token_usage: Option<&TokenUsage>,
output_items: &[ResponseItem],
) -> Option<crate::RawPayloadRef> {
let response_payload = TracedResponseStreamOutput {
response_id,
token_usage,
output_items: output_items.iter().map(trace_response_item_json).collect(),
};
write_json_payload_best_effort(
&attempt.context.writer,
RawPayloadKind::InferenceResponse,
&response_payload,
)
}
fn append_with_context_best_effort(
context: &EnabledInferenceTraceContext,
payload: RawTraceEventPayload,
+11
View File
@@ -110,6 +110,13 @@ pub enum RawTraceEventPayload {
/// Partial response payload, when stream events arrived before failure.
partial_response_payload: Option<RawPayloadRef>,
},
InferenceCancelled {
inference_call_id: InferenceCallId,
/// Why Codex stopped consuming the provider stream before a terminal response event.
reason: String,
/// Completed output items observed before cancellation, if any.
partial_response_payload: Option<RawPayloadRef>,
},
ToolCallStarted {
tool_call_id: ToolCallId,
/// Protocol/model call ID when this runtime call came from model output.
@@ -253,6 +260,10 @@ impl RawTraceEventPayload {
partial_response_payload,
..
}
| RawTraceEventPayload::InferenceCancelled {
partial_response_payload,
..
}
| RawTraceEventPayload::ToolCallStarted {
invocation_payload: partial_response_payload,
..
@@ -103,6 +103,35 @@ impl TraceReducer {
Ok(())
}
/// Closes any inference streams that are still live when the owning turn ends.
///
/// Normal completion events close the active inference before the turn ends.
/// If a call is still `Running`, Codex stopped observing that provider stream
/// earlier and the reduced graph should not present it as live.
pub(super) fn close_running_inference_calls_for_turn_end(
&mut self,
seq: RawEventSeq,
wall_time_unix_ms: i64,
codex_turn_id: &str,
turn_status: &ExecutionStatus,
) {
let inference_status = match turn_status {
ExecutionStatus::Running => return,
ExecutionStatus::Completed | ExecutionStatus::Cancelled => ExecutionStatus::Cancelled,
ExecutionStatus::Failed => ExecutionStatus::Failed,
ExecutionStatus::Aborted => ExecutionStatus::Aborted,
};
for inference in self.rollout.inference_calls.values_mut() {
if inference.codex_turn_id == codex_turn_id
&& inference.execution.status == ExecutionStatus::Running
{
inference.execution.ended_at_unix_ms = Some(wall_time_unix_ms);
inference.execution.ended_seq = Some(seq);
inference.execution.status = inference_status.clone();
}
}
}
/// Completes an inference call and, when present, reduces response output items.
pub(super) fn complete_inference_call(
&mut self,
@@ -130,14 +159,25 @@ impl TraceReducer {
let Some(inference) = self.rollout.inference_calls.get_mut(&inference_call_id) else {
bail!("inference call {inference_call_id} disappeared during response reduction");
};
inference.execution.ended_at_unix_ms = Some(wall_time_unix_ms);
inference.execution.ended_seq = Some(seq);
inference.execution.status = status;
inference.upstream_request_id = response_id;
inference.raw_response_payload_id = response_payload.map(|payload| payload.raw_payload_id);
// Turn-end cleanup can close a stream before the async mapper observes
// cancellation. Preserve that terminal status while still retaining any
// late partial response evidence from the mapper.
if inference.execution.status == ExecutionStatus::Running {
inference.execution.ended_at_unix_ms = Some(wall_time_unix_ms);
inference.execution.ended_seq = Some(seq);
inference.execution.status = status;
inference.upstream_request_id = response_id;
}
if let Some(response_payload) = response_payload {
inference.raw_response_payload_id = Some(response_payload.raw_payload_id);
}
if let Some(response_item_ids) = response_item_ids {
inference.response_item_ids = response_item_ids;
}
Ok(())
}
}
#[cfg(test)]
#[path = "inference_tests.rs"]
mod tests;
@@ -0,0 +1,148 @@
use pretty_assertions::assert_eq;
use serde_json::json;
use tempfile::TempDir;
use crate::model::ConversationItemKind;
use crate::model::ExecutionStatus;
use crate::payload::RawPayloadKind;
use crate::raw_event::RawTraceEventPayload;
use crate::reducer::test_support::append_inference_start;
use crate::reducer::test_support::create_started_writer;
use crate::reducer::test_support::message;
use crate::reducer::test_support::start_turn;
use crate::replay_bundle;
#[test]
fn cancelled_inference_reduces_partial_response_items() -> anyhow::Result<()> {
let temp = TempDir::new()?;
let writer = create_started_writer(&temp)?;
start_turn(&writer, "turn-1")?;
let request = writer.write_json_payload(
RawPayloadKind::InferenceRequest,
&json!({
"input": [message("user", "draft")]
}),
)?;
append_inference_start(&writer, "inference-1", "turn-1", request)?;
let partial_response = writer.write_json_payload(
RawPayloadKind::InferenceResponse,
&json!({
"response_id": null,
"token_usage": null,
"output_items": [{
"type": "message",
"role": "assistant",
"content": [{"type": "output_text", "text": "partial"}]
}]
}),
)?;
writer.append(RawTraceEventPayload::InferenceCancelled {
inference_call_id: "inference-1".to_string(),
reason: "test interruption".to_string(),
partial_response_payload: Some(partial_response),
})?;
let rollout = replay_bundle(temp.path())?;
let inference = &rollout.inference_calls["inference-1"];
let response_item_id = &inference.response_item_ids[0];
assert_eq!(inference.execution.status, ExecutionStatus::Cancelled);
assert_eq!(inference.response_item_ids.len(), 1);
assert_eq!(
rollout.conversation_items[response_item_id].kind,
ConversationItemKind::Message,
);
assert_eq!(
rollout.conversation_items[response_item_id].produced_by,
vec![crate::model::ProducerRef::Inference {
inference_call_id: "inference-1".to_string(),
}],
);
Ok(())
}
#[test]
fn cancelled_turn_closes_running_inference_call() -> anyhow::Result<()> {
let temp = TempDir::new()?;
let writer = create_started_writer(&temp)?;
start_turn(&writer, "turn-1")?;
let request = writer.write_json_payload(
RawPayloadKind::InferenceRequest,
&json!({
"input": [message("user", "wait")]
}),
)?;
append_inference_start(&writer, "inference-1", "turn-1", request)?;
let turn_end = writer.append(RawTraceEventPayload::CodexTurnEnded {
codex_turn_id: "turn-1".to_string(),
status: ExecutionStatus::Cancelled,
})?;
let rollout = replay_bundle(temp.path())?;
let inference = &rollout.inference_calls["inference-1"];
assert_eq!(inference.execution.status, ExecutionStatus::Cancelled);
assert_eq!(inference.execution.ended_seq, Some(turn_end.seq));
Ok(())
}
#[test]
fn late_cancelled_inference_preserves_turn_end_status() -> anyhow::Result<()> {
let temp = TempDir::new()?;
let writer = create_started_writer(&temp)?;
start_turn(&writer, "turn-1")?;
let request = writer.write_json_payload(
RawPayloadKind::InferenceRequest,
&json!({
"input": [message("user", "interrupt")]
}),
)?;
append_inference_start(&writer, "inference-1", "turn-1", request)?;
let turn_end = writer.append(RawTraceEventPayload::CodexTurnEnded {
codex_turn_id: "turn-1".to_string(),
status: ExecutionStatus::Failed,
})?;
let partial_response = writer.write_json_payload(
RawPayloadKind::InferenceResponse,
&json!({
"response_id": null,
"token_usage": null,
"output_items": [{
"type": "message",
"role": "assistant",
"content": [{"type": "output_text", "text": "late partial"}]
}]
}),
)?;
writer.append(RawTraceEventPayload::InferenceCancelled {
inference_call_id: "inference-1".to_string(),
reason: "stream mapper noticed cancellation after turn end".to_string(),
partial_response_payload: Some(partial_response.clone()),
})?;
let rollout = replay_bundle(temp.path())?;
let inference = &rollout.inference_calls["inference-1"];
assert_eq!(inference.execution.status, ExecutionStatus::Failed);
assert_eq!(inference.execution.ended_seq, Some(turn_end.seq));
assert_eq!(
inference.raw_response_payload_id,
Some(partial_response.raw_payload_id),
);
assert_eq!(inference.response_item_ids.len(), 1);
let response_item_id = &inference.response_item_ids[0];
assert_eq!(
rollout.conversation_items[response_item_id].body.parts,
vec![crate::model::ConversationPart::Text {
text: "late partial".to_string(),
}],
);
Ok(())
}
+14
View File
@@ -254,6 +254,20 @@ impl TraceReducer {
partial_response_payload,
)?;
}
RawTraceEventPayload::InferenceCancelled {
inference_call_id,
partial_response_payload,
..
} => {
self.complete_inference_call(
event.seq,
event.wall_time_unix_ms,
inference_call_id,
ExecutionStatus::Cancelled,
/*response_id*/ None,
partial_response_payload,
)?;
}
RawTraceEventPayload::ProtocolEventObserved { .. } => {
// Protocol wrappers are raw debug breadcrumbs. Typed hooks own
// the reduced graph, so these payload refs are retained without
@@ -187,6 +187,12 @@ impl TraceReducer {
&codex_turn_id,
&status,
)?;
self.close_running_inference_calls_for_turn_end(
seq,
wall_time_unix_ms,
&codex_turn_id,
&status,
);
Ok(())
}
+1 -1
View File
@@ -134,7 +134,7 @@ fn disabled_thread_context_accepts_trace_calls_without_writing() -> anyhow::Resu
inference_attempt.record_started(&serde_json::json!({ "kind": "inference" }));
let token_usage: Option<codex_protocol::protocol::TokenUsage> = None;
inference_attempt.record_completed("response-1", &token_usage, &[]);
inference_attempt.record_failed("inference failed");
inference_attempt.record_failed("inference failed", &[]);
let compaction_trace = thread_trace.compaction_trace_context(
"turn-1",
+21 -5
View File
@@ -444,9 +444,19 @@ impl RolloutRecorder {
));
}
// Warm the DB by repairing every filesystem hit before querying SQLite.
// For metadata-filtered listings the filesystem page is the page we return. Track those
// IDs so the later DB page only triggers full reconciliation for DB-only hits.
let fs_page_thread_ids = fs_page
.items
.iter()
.filter_map(|item| item.thread_id)
.collect::<HashSet<_>>();
// Warm the DB by repairing every filesystem hit before querying SQLite. Source/provider/cwd
// filters are already validated from rollout head metadata, so lightweight read-repair is
// enough there. Search can depend on full title metadata, so keep full reconciliation.
for item in &fs_page.items {
if listing_has_metadata_filters {
if search_term.is_some() {
state_db::reconcile_rollout(
state_db_ctx.as_deref(),
item.path.as_path(),
@@ -517,6 +527,12 @@ impl RolloutRecorder {
}
if listing_has_metadata_filters {
for item in &db_page.items {
// Rows that also appeared in the filesystem page were just validated from the
// rollout head. Rows only found by SQLite may be stale filter matches, so fully
// reconcile those before returning the filesystem-backed page.
if fs_page_thread_ids.contains(&item.id) {
continue;
}
state_db::reconcile_rollout(
state_db_ctx.as_deref(),
item.rollout_path.as_path(),
@@ -1030,13 +1046,13 @@ fn fill_missing_thread_item_metadata(item: &mut ThreadItem, state_item: ThreadIt
if item.cwd.is_none() {
item.cwd = cwd;
}
if item.git_branch.is_none() {
if git_branch.is_some() {
item.git_branch = git_branch;
}
if item.git_sha.is_none() {
if git_sha.is_some() {
item.git_sha = git_sha;
}
if item.git_origin_url.is_none() {
if git_origin_url.is_some() {
item.git_origin_url = git_origin_url;
}
if item.source.is_none() {
+4 -4
View File
@@ -779,7 +779,7 @@ async fn list_threads_metadata_filter_overlays_state_db_list_metadata() -> std::
}
#[test]
fn fill_missing_thread_item_metadata_preserves_filesystem_identity() {
fn fill_missing_thread_item_metadata_preserves_identity_and_prefers_state_git_fields() {
let filesystem_thread_id = ThreadId::new();
let state_thread_id = ThreadId::new();
let filesystem_path = PathBuf::from("/tmp/filesystem-rollout.jsonl");
@@ -789,9 +789,9 @@ fn fill_missing_thread_item_metadata_preserves_filesystem_identity() {
thread_id: Some(filesystem_thread_id),
first_user_message: Some("filesystem message".to_string()),
cwd: None,
git_branch: None,
git_sha: None,
git_origin_url: None,
git_branch: Some("filesystem-branch".to_string()),
git_sha: Some("filesystem-sha".to_string()),
git_origin_url: Some("https://example.com/filesystem.git".to_string()),
source: None,
agent_nickname: None,
agent_role: None,
+1 -6
View File
@@ -350,9 +350,7 @@ fn materialize_cwd_dependent_entry(
) -> FileSystemSandboxEntry {
match &entry.path {
FileSystemPath::Special {
value:
FileSystemSpecialPath::CurrentWorkingDirectory
| FileSystemSpecialPath::ProjectRoots { .. },
value: FileSystemSpecialPath::ProjectRoots { .. },
} => resolve_permission_path(&entry.path, cwd)
.map(|path| FileSystemSandboxEntry {
path: FileSystemPath::Path { path },
@@ -380,9 +378,6 @@ fn resolve_permission_path(path: &FileSystemPath, cwd: &Path) -> Option<Absolute
let root = cwd.ancestors().last()?;
AbsolutePathBuf::from_absolute_path(root).ok()
}
FileSystemSpecialPath::CurrentWorkingDirectory => {
AbsolutePathBuf::from_absolute_path(cwd).ok()
}
FileSystemSpecialPath::ProjectRoots { subpath } => {
let cwd = AbsolutePathBuf::from_absolute_path(cwd).ok()?;
Some(match subpath {
@@ -299,7 +299,7 @@ fn intersect_permission_profiles_accepts_child_path_granted_for_requested_cwd()
file_system: Some(FileSystemPermissions {
entries: vec![FileSystemSandboxEntry {
path: FileSystemPath::Special {
value: FileSystemSpecialPath::CurrentWorkingDirectory,
value: FileSystemSpecialPath::project_roots(/*subpath*/ None),
},
access: FileSystemAccessMode::Write,
}],
@@ -332,7 +332,7 @@ fn intersect_permission_profiles_materializes_cwd_grant_for_reuse() {
file_system: Some(FileSystemPermissions {
entries: vec![FileSystemSandboxEntry {
path: FileSystemPath::Special {
value: FileSystemSpecialPath::CurrentWorkingDirectory,
value: FileSystemSpecialPath::project_roots(/*subpath*/ None),
},
access: FileSystemAccessMode::Write,
}],
@@ -383,7 +383,7 @@ fn intersect_permission_profiles_deduplicates_materialized_grants() {
entries: vec![
FileSystemSandboxEntry {
path: FileSystemPath::Special {
value: FileSystemSpecialPath::CurrentWorkingDirectory,
value: FileSystemSpecialPath::project_roots(/*subpath*/ None),
},
access: FileSystemAccessMode::Write,
},
@@ -425,7 +425,7 @@ fn intersect_permission_profiles_materializes_cwd_deny_entries() {
},
FileSystemSandboxEntry {
path: FileSystemPath::Special {
value: FileSystemSpecialPath::CurrentWorkingDirectory,
value: FileSystemSpecialPath::project_roots(/*subpath*/ None),
},
access: FileSystemAccessMode::None,
},
@@ -474,7 +474,7 @@ fn intersect_permission_profiles_drops_deny_entries_without_filesystem_grants()
entries: vec![
FileSystemSandboxEntry {
path: FileSystemPath::Special {
value: FileSystemSpecialPath::CurrentWorkingDirectory,
value: FileSystemSpecialPath::project_roots(/*subpath*/ None),
},
access: FileSystemAccessMode::Write,
},
@@ -512,7 +512,7 @@ fn intersect_permission_profiles_rejects_concrete_grants_matched_by_requested_de
entries: vec![
FileSystemSandboxEntry {
path: FileSystemPath::Special {
value: FileSystemSpecialPath::CurrentWorkingDirectory,
value: FileSystemSpecialPath::project_roots(/*subpath*/ None),
},
access: FileSystemAccessMode::Write,
},
@@ -550,7 +550,7 @@ fn intersect_permission_profiles_materializes_relative_deny_globs_for_reuse() {
.expect("absolute later cwd");
let cwd_write = FileSystemSandboxEntry {
path: FileSystemPath::Special {
value: FileSystemSpecialPath::CurrentWorkingDirectory,
value: FileSystemSpecialPath::project_roots(/*subpath*/ None),
},
access: FileSystemAccessMode::Write,
};
@@ -629,7 +629,7 @@ fn intersect_permission_profiles_drops_broader_cwd_grant_for_requested_child_pat
file_system: Some(FileSystemPermissions {
entries: vec![FileSystemSandboxEntry {
path: FileSystemPath::Special {
value: FileSystemSpecialPath::CurrentWorkingDirectory,
value: FileSystemSpecialPath::project_roots(/*subpath*/ None),
},
access: FileSystemAccessMode::Write,
}],
@@ -102,7 +102,7 @@ impl ApprovalRequest {
}
}
fn matches_resolved_request(&self, request: &ResolvedAppServerRequest) -> bool {
pub(super) fn matches_resolved_request(&self, request: &ResolvedAppServerRequest) -> bool {
match (self, request) {
(
ApprovalRequest::Exec { id, .. },
@@ -875,7 +875,6 @@ fn special_path_label(value: &FileSystemSpecialPath) -> String {
match value {
FileSystemSpecialPath::Root => ":root".to_string(),
FileSystemSpecialPath::Minimal => ":minimal".to_string(),
FileSystemSpecialPath::CurrentWorkingDirectory => ":cwd".to_string(),
FileSystemSpecialPath::ProjectRoots { subpath } => path_label(":project_roots", subpath),
FileSystemSpecialPath::Tmpdir => ":tmpdir".to_string(),
FileSystemSpecialPath::SlashTmp => "/tmp".to_string(),
+244 -7
View File
@@ -13,6 +13,7 @@
//!
//! Some UI is time-based rather than input-based, such as the transient "press again to quit"
//! hint. The pane schedules redraws so those hints can expire even when the UI is otherwise idle.
use std::collections::VecDeque;
use std::path::PathBuf;
use crate::app::app_server_requests::ResolvedAppServerRequest;
@@ -42,6 +43,7 @@ use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::text::Line;
use std::time::Duration;
use std::time::Instant;
mod app_link_view;
mod approval_overlay;
@@ -139,6 +141,8 @@ pub(crate) use selection_tabs::SelectionTab;
/// Keeping a single value ensures Ctrl+C and Ctrl+D behave identically.
pub(crate) const QUIT_SHORTCUT_TIMEOUT: Duration = Duration::from_secs(1);
const APPROVAL_PROMPT_TYPING_IDLE_DELAY: Duration = Duration::from_secs(1);
/// Whether Ctrl+C/Ctrl+D require a second press to quit.
///
/// This UX experiment was enabled by default, but requiring a double press to quit feels janky in
@@ -170,6 +174,11 @@ pub(crate) use experimental_features_view::ExperimentalFeaturesView;
pub(crate) use list_selection_view::SelectionAction;
pub(crate) use list_selection_view::SelectionItem;
struct DelayedApprovalRequest {
request: ApprovalRequest,
features: Features,
}
/// Pane displayed in the lower half of the chat UI.
///
/// This is the owning container for the prompt input (`ChatComposer`) and the view stack
@@ -182,6 +191,8 @@ pub(crate) struct BottomPane {
/// Stack of views displayed instead of the composer (e.g. popups/modals).
view_stack: Vec<Box<dyn BottomPaneView>>,
delayed_approval_requests: VecDeque<DelayedApprovalRequest>,
last_composer_activity_at: Option<Instant>,
app_event_tx: AppEventSender,
frame_requester: FrameRequester,
@@ -243,6 +254,8 @@ impl BottomPane {
Self {
composer,
view_stack: Vec::new(),
delayed_approval_requests: VecDeque::new(),
last_composer_activity_at: None,
app_event_tx,
frame_requester,
has_input_focus,
@@ -442,6 +455,48 @@ impl BottomPane {
}
}
fn approval_prompt_delay_remaining(&self, now: Instant) -> Option<Duration> {
self.last_composer_activity_at.and_then(|last_activity_at| {
last_activity_at
.checked_add(APPROVAL_PROMPT_TYPING_IDLE_DELAY)
.and_then(|show_at| show_at.checked_duration_since(now))
.filter(|delay| !delay.is_zero())
})
}
fn record_composer_activity_at(&mut self, now: Instant) {
self.last_composer_activity_at = Some(now);
if !self.delayed_approval_requests.is_empty()
&& let Some(delay) = self.approval_prompt_delay_remaining(now)
{
self.request_redraw_in(delay);
}
}
fn maybe_show_delayed_approval_requests_at(&mut self, now: Instant) {
if self.delayed_approval_requests.is_empty() || !self.view_stack.is_empty() {
return;
}
if let Some(delay) = self.approval_prompt_delay_remaining(now) {
self.request_redraw_in(delay);
return;
}
// Promote the oldest delayed approval once typing has been idle long enough.
// `ApprovalOverlay` advances its internal queue with `pop()`, so drain the
// remaining delayed approvals from the back to preserve FIFO display order.
let Some(first) = self.delayed_approval_requests.pop_front() else {
return;
};
let mut modal =
ApprovalOverlay::new(first.request, self.app_event_tx.clone(), first.features);
while let Some(delayed) = self.delayed_approval_requests.pop_back() {
modal.enqueue_request(delayed.request);
}
self.pause_status_timer_for_modal();
self.push_view(Box::new(modal));
}
/// Forward a key event to the active view or the composer.
pub fn handle_key_event(&mut self, key_event: KeyEvent) -> InputResult {
// If a modal/view is active, handle it here; otherwise forward to composer.
@@ -512,7 +567,21 @@ impl BottomPane {
self.request_redraw();
return InputResult::None;
}
let records_composer_activity =
matches!(key_event.kind, KeyEventKind::Press | KeyEventKind::Repeat)
&& !key_hint::has_ctrl_or_alt(key_event.modifiers)
&& matches!(
key_event.code,
KeyCode::Char(_)
| KeyCode::Backspace
| KeyCode::Delete
| KeyCode::Enter
| KeyCode::Tab
);
let (input_result, needs_redraw) = self.composer.handle_key_event(key_event);
if records_composer_activity {
self.record_composer_activity_at(Instant::now());
}
if needs_redraw {
self.request_redraw();
}
@@ -560,6 +629,7 @@ impl BottomPane {
}
pub fn handle_paste(&mut self, pasted: String) {
let has_pasted_text = !pasted.is_empty();
if let Some(view) = self.view_stack.last_mut() {
let needs_redraw = view.handle_paste(pasted);
let view_complete = view.is_complete();
@@ -572,6 +642,9 @@ impl BottomPane {
}
} else {
let needs_redraw = self.composer.handle_paste(pasted);
if has_pasted_text {
self.record_composer_activity_at(Instant::now());
}
self.composer.sync_popups();
if needs_redraw {
self.request_redraw();
@@ -586,7 +659,12 @@ impl BottomPane {
}
pub(crate) fn pre_draw_tick(&mut self) {
self.pre_draw_tick_at(Instant::now());
}
fn pre_draw_tick_at(&mut self, now: Instant) {
self.composer.sync_popups();
self.maybe_show_delayed_approval_requests_at(now);
}
/// Replace the composer text with `text`.
@@ -989,10 +1067,22 @@ impl BottomPane {
request
};
// Otherwise create a new approval modal overlay.
let modal = ApprovalOverlay::new(request, self.app_event_tx.clone(), features.clone());
self.pause_status_timer_for_modal();
self.push_view(Box::new(modal));
let now = Instant::now();
if !self.delayed_approval_requests.is_empty()
|| self.approval_prompt_delay_remaining(now).is_some()
{
self.delayed_approval_requests
.push_back(DelayedApprovalRequest {
request,
features: features.clone(),
});
self.maybe_show_delayed_approval_requests_at(now);
} else {
// No recent composer activity, so show the approval modal immediately.
let modal = ApprovalOverlay::new(request, self.app_event_tx.clone(), features.clone());
self.pause_status_timer_for_modal();
self.push_view(Box::new(modal));
}
}
/// Called when the agent requests user input.
@@ -1107,11 +1197,19 @@ impl BottomPane {
&mut self,
request: &ResolvedAppServerRequest,
) -> bool {
let delayed_len = self.delayed_approval_requests.len();
self.delayed_approval_requests
.retain(|delayed| !delayed.request.matches_resolved_request(request));
let delayed_changed = self.delayed_approval_requests.len() != delayed_len;
if self.view_stack.is_empty() {
return false;
if delayed_changed {
self.request_redraw();
}
return delayed_changed;
}
let mut changed = false;
let mut changed = delayed_changed;
let mut completed_indices = Vec::new();
for index in (0..self.view_stack.len()).rev() {
let view = &mut self.view_stack[index];
@@ -1356,6 +1454,7 @@ mod tests {
use crate::test_support::PathBufExt;
use crate::test_support::test_path_buf;
use codex_protocol::protocol::Op;
use codex_protocol::protocol::ReviewDecision;
use codex_protocol::protocol::SkillScope;
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
@@ -1367,6 +1466,7 @@ mod tests {
use ratatui::layout::Rect;
use std::cell::Cell;
use std::rc::Rc;
use std::time::Instant;
use tokio::sync::mpsc::unbounded_channel;
fn snapshot_buffer(buf: &Buffer) -> String {
@@ -1388,13 +1488,20 @@ mod tests {
}
fn test_pane(app_event_tx: AppEventSender) -> BottomPane {
test_pane_with_disable_paste_burst(app_event_tx, /*disable_paste_burst*/ false)
}
fn test_pane_with_disable_paste_burst(
app_event_tx: AppEventSender,
disable_paste_burst: bool,
) -> BottomPane {
BottomPane::new(BottomPaneParams {
app_event_tx,
frame_requester: FrameRequester::test_dummy(),
has_input_focus: true,
enhanced_keys_supported: false,
placeholder_text: "Ask Codex to do anything".to_string(),
disable_paste_burst: false,
disable_paste_burst,
animations_enabled: true,
skills: Some(Vec::new()),
})
@@ -1565,6 +1672,136 @@ mod tests {
);
}
#[test]
fn approval_request_shows_immediately_without_recent_typing() {
let (tx_raw, _rx) = unbounded_channel::<AppEvent>();
let tx = AppEventSender::new(tx_raw);
let features = Features::with_defaults();
let mut pane = test_pane(tx);
pane.push_approval_request(exec_request(), &features);
assert_eq!(pane.view_stack.len(), 1);
assert!(pane.delayed_approval_requests.is_empty());
}
#[test]
fn approval_request_is_delayed_after_recent_typing() {
let (tx_raw, _rx) = unbounded_channel::<AppEvent>();
let tx = AppEventSender::new(tx_raw);
let features = Features::with_defaults();
let mut pane = test_pane(tx);
let now = Instant::now();
pane.last_composer_activity_at = Some(now);
pane.push_approval_request(exec_request(), &features);
assert!(pane.view_stack.is_empty());
assert_eq!(pane.delayed_approval_requests.len(), 1);
pane.pre_draw_tick_at(
now + APPROVAL_PROMPT_TYPING_IDLE_DELAY - Duration::from_millis(/*millis*/ 1),
);
assert!(pane.view_stack.is_empty());
assert_eq!(pane.delayed_approval_requests.len(), 1);
pane.pre_draw_tick_at(now + APPROVAL_PROMPT_TYPING_IDLE_DELAY);
assert_eq!(pane.view_stack.len(), 1);
assert!(pane.delayed_approval_requests.is_empty());
}
#[test]
fn continued_typing_resets_delayed_approval_idle_deadline() {
let (tx_raw, _rx) = unbounded_channel::<AppEvent>();
let tx = AppEventSender::new(tx_raw);
let features = Features::with_defaults();
let mut pane = test_pane(tx);
let first_activity = Instant::now();
pane.last_composer_activity_at = Some(first_activity);
pane.push_approval_request(exec_request(), &features);
let continued_activity = first_activity + Duration::from_millis(/*millis*/ 750);
pane.record_composer_activity_at(continued_activity);
pane.pre_draw_tick_at(first_activity + APPROVAL_PROMPT_TYPING_IDLE_DELAY);
assert!(pane.view_stack.is_empty());
assert_eq!(pane.delayed_approval_requests.len(), 1);
pane.pre_draw_tick_at(continued_activity + APPROVAL_PROMPT_TYPING_IDLE_DELAY);
assert_eq!(pane.view_stack.len(), 1);
assert!(pane.delayed_approval_requests.is_empty());
}
#[test]
fn typed_approval_shortcuts_during_delay_stay_in_composer() {
let (tx_raw, mut rx) = unbounded_channel::<AppEvent>();
let tx = AppEventSender::new(tx_raw);
let features = Features::with_defaults();
let mut pane = test_pane_with_disable_paste_burst(tx, /*disable_paste_burst*/ true);
pane.last_composer_activity_at = Some(Instant::now());
pane.push_approval_request(exec_request(), &features);
pane.handle_key_event(KeyEvent::new(KeyCode::Char('y'), KeyModifiers::NONE));
pane.handle_key_event(KeyEvent::new(KeyCode::Char('a'), KeyModifiers::NONE));
assert_eq!(pane.composer_text(), "ya");
assert!(pane.view_stack.is_empty());
assert_eq!(pane.delayed_approval_requests.len(), 1);
while let Ok(event) = rx.try_recv() {
assert!(
!matches!(event, AppEvent::SubmitThreadOp { .. }),
"delayed approval shortcut should not submit an approval: {event:?}"
);
}
}
#[test]
fn delayed_approval_shortcut_works_after_idle_deadline() {
let (tx_raw, mut rx) = unbounded_channel::<AppEvent>();
let tx = AppEventSender::new(tx_raw);
let features = Features::with_defaults();
let mut pane = test_pane(tx);
let now = Instant::now();
pane.last_composer_activity_at = Some(now);
pane.push_approval_request(exec_request(), &features);
pane.pre_draw_tick_at(now + APPROVAL_PROMPT_TYPING_IDLE_DELAY);
pane.handle_key_event(KeyEvent::new(KeyCode::Char('y'), KeyModifiers::NONE));
let mut approval_decision = None;
while let Ok(event) = rx.try_recv() {
if let AppEvent::SubmitThreadOp {
op: Op::ExecApproval { decision, .. },
..
} = event
{
approval_decision = Some(decision);
}
}
assert_eq!(approval_decision, Some(ReviewDecision::Approved));
}
#[test]
fn dismiss_app_server_request_prunes_delayed_approval() {
let (tx_raw, _rx) = unbounded_channel::<AppEvent>();
let tx = AppEventSender::new(tx_raw);
let features = Features::with_defaults();
let mut pane = test_pane(tx);
let now = Instant::now();
pane.last_composer_activity_at = Some(now);
pane.push_approval_request(exec_request(), &features);
assert!(
pane.dismiss_app_server_request(&ResolvedAppServerRequest::ExecApproval {
id: "1".to_string(),
})
);
assert!(pane.delayed_approval_requests.is_empty());
pane.pre_draw_tick_at(now + APPROVAL_PROMPT_TYPING_IDLE_DELAY);
assert!(pane.view_stack.is_empty());
}
#[test]
fn dismiss_app_server_request_removes_matching_buried_view() {
let (tx_raw, _rx) = unbounded_channel::<AppEvent>();
+23
View File
@@ -154,6 +154,8 @@ where
inline_styles: Vec<Style>,
indent_stack: Vec<IndentContext>,
list_indices: Vec<Option<u64>>,
list_needs_blank_before_next_item: Vec<bool>,
list_item_contains_code_block: Vec<bool>,
link: Option<LinkState>,
needs_newline: bool,
pending_marker_line: bool,
@@ -184,6 +186,8 @@ where
inline_styles: Vec::new(),
indent_stack: Vec::new(),
list_indices: Vec::new(),
list_needs_blank_before_next_item: Vec::new(),
list_item_contains_code_block: Vec::new(),
link: None,
needs_newline: false,
pending_marker_line: false,
@@ -292,6 +296,11 @@ where
TagEnd::CodeBlock => self.end_codeblock(),
TagEnd::List(_) => self.end_list(),
TagEnd::Item => {
if self.list_item_contains_code_block.pop().unwrap_or(false)
&& let Some(needs_blank) = self.list_needs_blank_before_next_item.last_mut()
{
*needs_blank = true;
}
self.indent_stack.pop();
self.pending_marker_line = false;
}
@@ -476,15 +485,26 @@ where
self.push_line(Line::default());
}
self.list_indices.push(index);
self.list_needs_blank_before_next_item.push(false);
}
fn end_list(&mut self) {
self.list_indices.pop();
self.list_needs_blank_before_next_item.pop();
self.needs_newline = true;
}
fn start_item(&mut self) {
if self
.list_needs_blank_before_next_item
.last_mut()
.map(std::mem::take)
.unwrap_or(false)
{
self.push_blank_line();
}
self.pending_marker_line = true;
self.list_item_contains_code_block.push(false);
let depth = self.list_indices.len();
let is_ordered = self
.list_indices
@@ -524,6 +544,9 @@ where
}
fn start_codeblock(&mut self, lang: Option<String>, indent: Option<Span<'static>>) {
for item_contains_code_block in &mut self.list_item_contains_code_block {
*item_contains_code_block = true;
}
self.flush_current_line();
if !self.text.lines.is_empty() {
self.push_blank_line();
+52
View File
@@ -15,6 +15,18 @@ fn render_markdown_text_for_cwd(input: &str, cwd: &Path) -> Text<'static> {
render_markdown_text_with_width_and_cwd(input, /*width*/ None, Some(cwd))
}
fn plain_lines(text: &Text<'_>) -> Vec<String> {
text.lines
.iter()
.map(|line| {
line.spans
.iter()
.map(|span| span.content.clone())
.collect::<String>()
})
.collect()
}
#[test]
fn empty() {
assert_eq!(render_markdown_text(""), Text::default());
@@ -1128,6 +1140,46 @@ fn code_block_inside_unordered_list_item_multiple_lines() {
assert_eq!(lines, vec!["- Item", "", " first", " second"]);
}
#[test]
fn list_item_after_code_block_keeps_blank_separator() {
let md = "1. First:\n\n ```rust\n fn first() {}\n ```\n\n2. Second:\n";
let text = render_markdown_text(md);
let lines = plain_lines(&text);
assert_eq!(
lines,
vec!["1. First:", "", " fn first() {}", "", "2. Second:"]
);
assert_snapshot!(
"list_item_after_code_block_keeps_blank_separator",
lines.join("\n")
);
}
#[test]
fn outer_list_item_after_nested_code_block_keeps_blank_separator() {
let md = "1. First:\n - Nested:\n\n ```rust\n fn first() {}\n ```\n\n2. Second:\n";
let text = render_markdown_text(md);
let lines = plain_lines(&text);
assert_eq!(
lines,
vec![
"1. First:",
" - Nested:",
"",
" fn first() {}",
"",
"2. Second:",
]
);
}
#[test]
fn list_item_after_simple_item_stays_compact() {
let md = "1. First\n\n2. Second\n";
let text = render_markdown_text(md);
assert_eq!(plain_lines(&text), vec!["1. First", "2. Second"]);
}
#[test]
fn markdown_render_complex_snapshot() {
let md = r#"# H1: Markdown Streaming Test
@@ -0,0 +1,9 @@
---
source: tui/src/markdown_render_tests.rs
expression: "lines.join(\"\\n\")"
---
1. First:
fn first() {}
2. Second:
+1 -1
View File
@@ -1,6 +1,6 @@
# Codex CLI Runtime for Python SDK
Platform-specific runtime package consumed by the published `codex-app-server-sdk`.
Platform-specific runtime package consumed by the published `openai-codex-app-server-sdk`.
This package is staged during release so the SDK can pin an exact Codex CLI
version without checking platform binaries into the repo.
+20 -14
View File
@@ -12,10 +12,11 @@ uv sync
source .venv/bin/activate
```
Published SDK builds pin an exact `openai-codex-cli-bin` runtime dependency. For local
repo development, either pass `AppServerConfig(codex_bin=...)` to point at a
local build explicitly, or use the repo examples/notebook bootstrap which
installs the pinned runtime package automatically.
Published SDK builds pin an exact `openai-codex-cli-bin` runtime dependency
with the same version as the SDK. For local repo development, either pass
`AppServerConfig(codex_bin=...)` to point at a local build explicitly, or use
the repo examples/notebook bootstrap which installs the pinned runtime package
automatically.
## Quickstart
@@ -54,9 +55,9 @@ python examples/01_quickstart_constructor/async.py
The repo no longer checks `codex` binaries into `sdk/python`.
Published SDK builds are pinned to an exact `openai-codex-cli-bin` package version,
and that runtime package carries the platform-specific binary for the target
wheel.
Published SDK builds are pinned to an exact `openai-codex-cli-bin` package
version, and that runtime package carries the platform-specific binary for the
target wheel. The SDK package version and runtime package version must match.
For local repo development, the checked-in `sdk/python-runtime` package is only
a template for staged release artifacts. Editable installs should use an
@@ -70,30 +71,35 @@ cd sdk/python
python scripts/update_sdk_artifacts.py generate-types
python scripts/update_sdk_artifacts.py \
stage-sdk \
/tmp/codex-python-release/codex-app-server-sdk \
--runtime-version 1.2.3
/tmp/codex-python-release/openai-codex-app-server-sdk \
--codex-version <codex-release-tag-or-pep440-version>
python scripts/update_sdk_artifacts.py \
stage-runtime \
/tmp/codex-python-release/openai-codex-cli-bin \
/path/to/codex \
--runtime-version 1.2.3
--codex-version <codex-release-tag-or-pep440-version>
```
Pass `--platform-tag ...` to `stage-runtime` when the wheel should be tagged for
a Rust target that differs from the Python build host. The intended one-off
matrix is `macosx_11_0_arm64`, `macosx_10_9_x86_64`,
`musllinux_1_1_aarch64`, `musllinux_1_1_x86_64`, `win_arm64`, and
`win_amd64`.
This supports the CI release flow:
- run `generate-types` before packaging
- stage `codex-app-server-sdk` once with an exact `openai-codex-cli-bin==...` dependency
- stage `openai-codex-app-server-sdk` once with an exact `openai-codex-cli-bin==...` dependency
- stage `openai-codex-cli-bin` on each supported platform runner with the same pinned runtime version
- build and publish `openai-codex-cli-bin` as platform wheels only; do not publish an sdist
## Compatibility and versioning
- Package: `codex-app-server-sdk`
- Package: `openai-codex-app-server-sdk`
- Runtime package: `openai-codex-cli-bin`
- Current SDK version in this repo: `0.2.0`
- Python: `>=3.10`
- Target protocol: Codex `app-server` JSON-RPC v2
- Recommendation: keep SDK and `codex` CLI reasonably up to date together
- Versioning rule: the SDK package version is the underlying Codex runtime version
## Notes
+79 -15
View File
@@ -1,10 +1,12 @@
from __future__ import annotations
import importlib
import importlib.metadata
import importlib.util
import json
import os
import platform
import re
import shutil
import subprocess
import sys
@@ -16,7 +18,7 @@ import zipfile
from pathlib import Path
PACKAGE_NAME = "openai-codex-cli-bin"
PINNED_RUNTIME_VERSION = "0.116.0-alpha.1"
SDK_PACKAGE_NAME = "openai-codex-app-server-sdk"
REPO_SLUG = "openai/codex"
@@ -25,7 +27,16 @@ class RuntimeSetupError(RuntimeError):
def pinned_runtime_version() -> str:
return PINNED_RUNTIME_VERSION
source_version = _source_tree_project_version()
if source_version is not None:
return _normalized_package_version(source_version)
try:
return _normalized_package_version(importlib.metadata.version(SDK_PACKAGE_NAME))
except importlib.metadata.PackageNotFoundError as exc:
raise RuntimeSetupError(
f"Unable to resolve {SDK_PACKAGE_NAME} version for runtime pinning."
) from exc
def ensure_runtime_package_installed(
@@ -39,7 +50,10 @@ def ensure_runtime_package_installed(
installed_version = _installed_runtime_version(python_executable)
normalized_requested = _normalized_package_version(requested_version)
if installed_version is not None and _normalized_package_version(installed_version) == normalized_requested:
if (
installed_version is not None
and _normalized_package_version(installed_version) == normalized_requested
):
return requested_version
with tempfile.TemporaryDirectory(prefix="codex-python-runtime-") as temp_root_str:
@@ -61,7 +75,10 @@ def ensure_runtime_package_installed(
importlib.invalidate_caches()
installed_version = _installed_runtime_version(python_executable)
if installed_version is None or _normalized_package_version(installed_version) != normalized_requested:
if (
installed_version is None
or _normalized_package_version(installed_version) != normalized_requested
):
raise RuntimeSetupError(
f"Expected {PACKAGE_NAME} {requested_version} in {python_executable}, "
f"but found {installed_version!r} after installation."
@@ -121,7 +138,8 @@ def _installed_runtime_version(python_executable: str | Path) -> str | None:
def _release_metadata(version: str) -> dict[str, object]:
url = f"https://api.github.com/repos/{REPO_SLUG}/releases/tags/rust-v{version}"
release_tag = _release_tag(version)
url = f"https://api.github.com/repos/{REPO_SLUG}/releases/tags/{release_tag}"
token = _github_token()
attempts = [True, False] if token is not None else [False]
last_error: urllib.error.HTTPError | None = None
@@ -146,7 +164,7 @@ def _release_metadata(version: str) -> dict[str, object]:
assert last_error is not None
raise RuntimeSetupError(
f"Failed to resolve release metadata for rust-v{version} from {REPO_SLUG}: "
f"Failed to resolve release metadata for {release_tag} from {REPO_SLUG}: "
f"{last_error.code} {last_error.reason}"
) from last_error
@@ -154,9 +172,10 @@ def _release_metadata(version: str) -> dict[str, object]:
def _download_release_archive(version: str, temp_root: Path) -> Path:
asset_name = platform_asset_name()
archive_path = temp_root / asset_name
release_tag = _release_tag(version)
browser_download_url = (
f"https://github.com/{REPO_SLUG}/releases/download/rust-v{version}/{asset_name}"
f"https://github.com/{REPO_SLUG}/releases/download/{release_tag}/{asset_name}"
)
request = urllib.request.Request(
browser_download_url,
@@ -172,7 +191,9 @@ def _download_release_archive(version: str, temp_root: Path) -> Path:
metadata = _release_metadata(version)
assets = metadata.get("assets")
if not isinstance(assets, list):
raise RuntimeSetupError(f"Release rust-v{version} returned malformed assets metadata.")
raise RuntimeSetupError(
f"Release {release_tag} returned malformed assets metadata."
)
asset = next(
(
item
@@ -183,7 +204,7 @@ def _download_release_archive(version: str, temp_root: Path) -> Path:
)
if asset is None:
raise RuntimeSetupError(
f"Release rust-v{version} does not contain asset {asset_name} for this platform."
f"Release {release_tag} does not contain asset {asset_name} for this platform."
)
api_url = asset.get("url")
@@ -198,7 +219,10 @@ def _download_release_archive(version: str, temp_root: Path) -> Path:
headers=_github_api_headers("application/octet-stream"),
)
try:
with urllib.request.urlopen(request) as response, archive_path.open("wb") as fh:
with (
urllib.request.urlopen(request) as response,
archive_path.open("wb") as fh,
):
shutil.copyfileobj(response, fh)
return archive_path
except urllib.error.HTTPError:
@@ -216,7 +240,7 @@ def _download_release_archive(version: str, temp_root: Path) -> Path:
"gh",
"release",
"download",
f"rust-v{version}",
release_tag,
"--repo",
REPO_SLUG,
"--pattern",
@@ -230,7 +254,7 @@ def _download_release_archive(version: str, temp_root: Path) -> Path:
)
except subprocess.CalledProcessError as exc:
raise RuntimeSetupError(
f"gh release download failed for rust-v{version} asset {asset_name}.\n"
f"gh release download failed for {release_tag} asset {asset_name}.\n"
f"STDOUT:\n{exc.stdout}\nSTDERR:\n{exc.stderr}"
) from exc
return archive_path
@@ -249,7 +273,9 @@ def _extract_runtime_binary(archive_path: Path, temp_root: Path) -> Path:
with zipfile.ZipFile(archive_path) as zip_file:
zip_file.extractall(extract_dir)
else:
raise RuntimeSetupError(f"Unsupported release archive format: {archive_path.name}")
raise RuntimeSetupError(
f"Unsupported release archive format: {archive_path.name}"
)
binary_name = runtime_binary_name()
archive_stem = archive_path.name.removesuffix(".tar.gz").removesuffix(".zip")
@@ -346,12 +372,50 @@ def _github_token() -> str | None:
def _normalized_package_version(version: str) -> str:
return version.strip().replace("-alpha.", "a").replace("-beta.", "b")
normalized = version.strip()
if normalized.startswith("rust-v"):
normalized = normalized.removeprefix("rust-v")
elif normalized.startswith("v"):
normalized = normalized.removeprefix("v")
normalized = re.sub(r"-alpha\.?([0-9]+)$", r"a\1", normalized)
normalized = re.sub(r"-beta\.?([0-9]+)$", r"b\1", normalized)
normalized = re.sub(r"-rc\.?([0-9]+)$", r"rc\1", normalized)
return normalized
def _codex_release_version(version: str) -> str:
normalized = _normalized_package_version(version)
match = re.fullmatch(r"([0-9]+(?:\.[0-9]+)*)(a|b|rc)([0-9]+)", normalized)
if match is None:
return normalized
base, prerelease, number = match.groups()
prerelease_name = {"a": "alpha", "b": "beta", "rc": "rc"}[prerelease]
return f"{base}-{prerelease_name}.{number}"
def _release_tag(version: str) -> str:
return f"rust-v{_codex_release_version(version)}"
def _source_tree_project_version() -> str | None:
pyproject_path = Path(__file__).resolve().parent / "pyproject.toml"
if not pyproject_path.exists():
return None
match = re.search(
r'(?m)^version = "([^"]+)"$',
pyproject_path.read_text(encoding="utf-8"),
)
if match is None:
return None
return match.group(1)
__all__ = [
"PACKAGE_NAME",
"PINNED_RUNTIME_VERSION",
"SDK_PACKAGE_NAME",
"RuntimeSetupError",
"ensure_runtime_package_installed",
"pinned_runtime_version",
+10 -5
View File
@@ -60,23 +60,28 @@ Common causes:
- incompatible/old app-server
Maintainers stage releases by building the SDK once and the runtime once per
platform with the same pinned runtime version. Publish `openai-codex-cli-bin` as
platform wheels only; do not publish an sdist:
platform with the same pinned runtime version. Publish `openai-codex-cli-bin`
as platform wheels only; do not publish an sdist:
```bash
cd sdk/python
python scripts/update_sdk_artifacts.py generate-types
python scripts/update_sdk_artifacts.py \
stage-sdk \
/tmp/codex-python-release/codex-app-server-sdk \
--runtime-version 1.2.3
/tmp/codex-python-release/openai-codex-app-server-sdk \
--codex-version <codex-release-tag-or-pep440-version>
python scripts/update_sdk_artifacts.py \
stage-runtime \
/tmp/codex-python-release/openai-codex-cli-bin \
/path/to/codex \
--runtime-version 1.2.3
--codex-version <codex-release-tag-or-pep440-version>
```
If you are packaging a binary for a different target than the Python build
host, pass `--platform-tag ...` to `stage-runtime`. The intended one-off matrix
is `macosx_11_0_arm64`, `macosx_10_9_x86_64`, `musllinux_1_1_aarch64`,
`musllinux_1_1_x86_64`, `win_arm64`, and `win_amd64`.
## Why does a turn "hang"?
A turn is complete only when `turn/completed` arrives for that turn ID.
+1 -1
View File
@@ -28,7 +28,7 @@ will download the matching GitHub release artifact, stage a temporary local
`openai-codex-cli-bin` package, install it into your active interpreter, and clean up
the temporary files afterward.
Current pinned runtime version: `0.116.0-alpha.1`
The pinned runtime version comes from the SDK package version.
## Run examples
+2 -2
View File
@@ -3,8 +3,8 @@ requires = ["hatchling>=1.24.0"]
build-backend = "hatchling.build"
[project]
name = "codex-app-server-sdk"
version = "0.2.0"
name = "openai-codex-app-server-sdk"
version = "0.116.0a1"
description = "Python SDK for Codex app-server v2"
readme = "README.md"
requires-python = ">=3.10"
+31 -17
View File
@@ -17,6 +17,7 @@ from dataclasses import dataclass
from pathlib import Path
from typing import Any, Callable, Sequence, get_args, get_origin
SDK_DISTRIBUTION_NAME = "openai-codex-app-server-sdk"
RUNTIME_DISTRIBUTION_NAME = "openai-codex-cli-bin"
@@ -178,15 +179,19 @@ def _rewrite_sdk_runtime_dependency(pyproject_text: str, runtime_version: str) -
)
raw_items = [item.strip() for item in match.group(1).split(",") if item.strip()]
raw_items = [item for item in raw_items if "codex-cli-bin" not in item]
raw_items = [
item
for item in raw_items
if RUNTIME_DISTRIBUTION_NAME.removeprefix("openai-") not in item
and RUNTIME_DISTRIBUTION_NAME not in item
]
raw_items.append(f'"{RUNTIME_DISTRIBUTION_NAME}=={runtime_version}"')
replacement = "dependencies = [\n " + ",\n ".join(raw_items) + ",\n]"
return pyproject_text[: match.start()] + replacement + pyproject_text[match.end() :]
def stage_python_sdk_package(
staging_dir: Path, sdk_version: str, runtime_version: str
) -> Path:
def stage_python_sdk_package(staging_dir: Path, codex_version: str) -> Path:
package_version = normalize_codex_version(codex_version)
_copy_package_tree(sdk_root(), staging_dir)
sdk_bin_dir = staging_dir / "src" / "codex_app_server" / "bin"
if sdk_bin_dir.exists():
@@ -194,8 +199,9 @@ def stage_python_sdk_package(
pyproject_path = staging_dir / "pyproject.toml"
pyproject_text = pyproject_path.read_text()
pyproject_text = _rewrite_project_version(pyproject_text, sdk_version)
pyproject_text = _rewrite_sdk_runtime_dependency(pyproject_text, runtime_version)
pyproject_text = _rewrite_project_name(pyproject_text, SDK_DISTRIBUTION_NAME)
pyproject_text = _rewrite_project_version(pyproject_text, package_version)
pyproject_text = _rewrite_sdk_runtime_dependency(pyproject_text, package_version)
pyproject_path.write_text(pyproject_text)
return staging_dir
@@ -625,7 +631,7 @@ class PublicFieldSpec:
@dataclass(frozen=True)
class CliOps:
generate_types: Callable[[], None]
stage_python_sdk_package: Callable[[Path, str, str], Path]
stage_python_sdk_package: Callable[[Path, str], Path]
stage_python_runtime_package: Callable[[Path, str, Path, str | None], Path]
current_sdk_version: Callable[[], str]
@@ -992,14 +998,21 @@ def build_parser() -> argparse.ArgumentParser:
type=Path,
help="Output directory for the staged SDK package",
)
stage_sdk_parser.add_argument(
"--codex-version",
help=(
"Codex release version to write into the staged SDK package and exact "
f"{RUNTIME_DISTRIBUTION_NAME} dependency. Accepts PEP 440 versions "
"or release tags such as rust-v0.116.0-alpha.1."
),
)
stage_sdk_parser.add_argument(
"--runtime-version",
required=True,
help="Pinned openai-codex-cli-bin version for the staged SDK package",
help=argparse.SUPPRESS,
)
stage_sdk_parser.add_argument(
"--sdk-version",
help="Version to write into the staged SDK package (defaults to sdk/python current version)",
help=argparse.SUPPRESS,
)
stage_runtime_parser = subparsers.add_parser(
@@ -1050,22 +1063,23 @@ def default_cli_ops() -> CliOps:
)
def _resolve_runtime_version(args: argparse.Namespace) -> str:
def _resolve_codex_version(args: argparse.Namespace) -> str:
versions = [
value
for value in (
getattr(args, "codex_version", None),
getattr(args, "runtime_version", None),
getattr(args, "sdk_version", None),
)
if value is not None
]
if not versions:
raise RuntimeError("Pass --codex-version to stage the Python runtime package")
raise RuntimeError("Pass --codex-version to stage Python release artifacts")
normalized_versions = [normalize_codex_version(version) for version in versions]
if len(set(normalized_versions)) != 1:
raise RuntimeError(
"Runtime package versions must match; pass one --codex-version"
"SDK and runtime package versions must match; pass one --codex-version"
)
return normalized_versions[0]
@@ -1074,17 +1088,17 @@ def run_command(args: argparse.Namespace, ops: CliOps) -> None:
if args.command == "generate-types":
ops.generate_types()
elif args.command == "stage-sdk":
codex_version = _resolve_codex_version(args)
ops.generate_types()
ops.stage_python_sdk_package(
args.staging_dir,
args.sdk_version or ops.current_sdk_version(),
args.runtime_version,
codex_version,
)
elif args.command == "stage-runtime":
runtime_version = _resolve_runtime_version(args)
codex_version = _resolve_codex_version(args)
ops.stage_python_runtime_package(
args.staging_dir,
runtime_version,
codex_version,
args.runtime_binary.resolve(),
args.platform_tag,
)
+1 -2
View File
@@ -54,8 +54,7 @@ from .api import (
TurnHandle,
)
from .retry import retry_on_overload
__version__ = "0.2.0"
from ._version import __version__
__all__ = [
"__version__",
@@ -0,0 +1,37 @@
from __future__ import annotations
import re
from importlib.metadata import PackageNotFoundError
from importlib.metadata import version as distribution_version
from pathlib import Path
DISTRIBUTION_NAME = "openai-codex-app-server-sdk"
UNKNOWN_VERSION = "0+unknown"
def package_version() -> str:
source_version = _source_tree_project_version()
if source_version is not None:
return source_version
try:
return distribution_version(DISTRIBUTION_NAME)
except PackageNotFoundError:
return UNKNOWN_VERSION
def _source_tree_project_version() -> str | None:
pyproject_path = Path(__file__).resolve().parents[2] / "pyproject.toml"
if not pyproject_path.exists():
return None
match = re.search(
r'(?m)^version = "([^"]+)"$',
pyproject_path.read_text(encoding="utf-8"),
)
if match is None:
return None
return match.group(1)
__version__ = package_version()
+38 -2
View File
@@ -10,15 +10,18 @@ from .generated.v2_all import (
ApprovalsReviewer,
AskForApproval,
ModelListResponse,
PermissionProfile,
Personality,
ReasoningEffort,
ReasoningSummary,
SandboxMode,
SandboxPolicy,
ServiceTier,
SortDirection,
ThreadArchiveResponse,
ThreadCompactStartResponse,
ThreadForkParams,
ThreadListCwdFilter,
ThreadListParams,
ThreadListResponse,
ThreadReadResponse,
@@ -26,6 +29,7 @@ from .generated.v2_all import (
ThreadSetNameResponse,
ThreadSortKey,
ThreadSourceKind,
ThreadStartSource,
ThreadStartParams,
Turn as AppServerTurn,
TurnCompletedNotification,
@@ -146,6 +150,7 @@ class Codex:
ephemeral: bool | None = None,
model: str | None = None,
model_provider: str | None = None,
permission_profile: PermissionProfile | None = None,
personality: Personality | None = None,
sandbox: SandboxMode | None = None,
service_name: str | None = None,
@@ -162,6 +167,7 @@ class Codex:
ephemeral=ephemeral,
model=model,
model_provider=model_provider,
permission_profile=permission_profile,
personality=personality,
sandbox=sandbox,
service_name=service_name,
@@ -176,13 +182,14 @@ class Codex:
*,
archived: bool | None = None,
cursor: str | None = None,
cwd: str | None = None,
cwd: ThreadListCwdFilter | None = None,
limit: int | None = None,
model_providers: list[str] | None = None,
search_term: str | None = None,
sort_direction: SortDirection | None = None,
sort_key: ThreadSortKey | None = None,
source_kinds: list[ThreadSourceKind] | None = None,
use_state_db_only: bool | None = None,
) -> ThreadListResponse:
params = ThreadListParams(
archived=archived,
@@ -194,6 +201,7 @@ class Codex:
sort_direction=sort_direction,
sort_key=sort_key,
source_kinds=source_kinds,
use_state_db_only=use_state_db_only,
)
return self._client.thread_list(params)
@@ -207,8 +215,10 @@ class Codex:
config: JsonObject | None = None,
cwd: str | None = None,
developer_instructions: str | None = None,
exclude_turns: bool | None = None,
model: str | None = None,
model_provider: str | None = None,
permission_profile: PermissionProfile | None = None,
personality: Personality | None = None,
sandbox: SandboxMode | None = None,
service_tier: ServiceTier | None = None,
@@ -221,8 +231,10 @@ class Codex:
config=config,
cwd=cwd,
developer_instructions=developer_instructions,
exclude_turns=exclude_turns,
model=model,
model_provider=model_provider,
permission_profile=permission_profile,
personality=personality,
sandbox=sandbox,
service_tier=service_tier,
@@ -241,8 +253,10 @@ class Codex:
cwd: str | None = None,
developer_instructions: str | None = None,
ephemeral: bool | None = None,
exclude_turns: bool | None = None,
model: str | None = None,
model_provider: str | None = None,
permission_profile: PermissionProfile | None = None,
sandbox: SandboxMode | None = None,
service_tier: ServiceTier | None = None,
) -> Thread:
@@ -255,8 +269,10 @@ class Codex:
cwd=cwd,
developer_instructions=developer_instructions,
ephemeral=ephemeral,
exclude_turns=exclude_turns,
model=model,
model_provider=model_provider,
permission_profile=permission_profile,
sandbox=sandbox,
service_tier=service_tier,
)
@@ -340,6 +356,7 @@ class AsyncCodex:
ephemeral: bool | None = None,
model: str | None = None,
model_provider: str | None = None,
permission_profile: PermissionProfile | None = None,
personality: Personality | None = None,
sandbox: SandboxMode | None = None,
service_name: str | None = None,
@@ -357,6 +374,7 @@ class AsyncCodex:
ephemeral=ephemeral,
model=model,
model_provider=model_provider,
permission_profile=permission_profile,
personality=personality,
sandbox=sandbox,
service_name=service_name,
@@ -371,13 +389,14 @@ class AsyncCodex:
*,
archived: bool | None = None,
cursor: str | None = None,
cwd: str | None = None,
cwd: ThreadListCwdFilter | None = None,
limit: int | None = None,
model_providers: list[str] | None = None,
search_term: str | None = None,
sort_direction: SortDirection | None = None,
sort_key: ThreadSortKey | None = None,
source_kinds: list[ThreadSourceKind] | None = None,
use_state_db_only: bool | None = None,
) -> ThreadListResponse:
await self._ensure_initialized()
params = ThreadListParams(
@@ -390,6 +409,7 @@ class AsyncCodex:
sort_direction=sort_direction,
sort_key=sort_key,
source_kinds=source_kinds,
use_state_db_only=use_state_db_only,
)
return await self._client.thread_list(params)
@@ -403,8 +423,10 @@ class AsyncCodex:
config: JsonObject | None = None,
cwd: str | None = None,
developer_instructions: str | None = None,
exclude_turns: bool | None = None,
model: str | None = None,
model_provider: str | None = None,
permission_profile: PermissionProfile | None = None,
personality: Personality | None = None,
sandbox: SandboxMode | None = None,
service_tier: ServiceTier | None = None,
@@ -418,8 +440,10 @@ class AsyncCodex:
config=config,
cwd=cwd,
developer_instructions=developer_instructions,
exclude_turns=exclude_turns,
model=model,
model_provider=model_provider,
permission_profile=permission_profile,
personality=personality,
sandbox=sandbox,
service_tier=service_tier,
@@ -438,8 +462,10 @@ class AsyncCodex:
cwd: str | None = None,
developer_instructions: str | None = None,
ephemeral: bool | None = None,
exclude_turns: bool | None = None,
model: str | None = None,
model_provider: str | None = None,
permission_profile: PermissionProfile | None = None,
sandbox: SandboxMode | None = None,
service_tier: ServiceTier | None = None,
) -> AsyncThread:
@@ -453,8 +479,10 @@ class AsyncCodex:
cwd=cwd,
developer_instructions=developer_instructions,
ephemeral=ephemeral,
exclude_turns=exclude_turns,
model=model,
model_provider=model_provider,
permission_profile=permission_profile,
sandbox=sandbox,
service_tier=service_tier,
)
@@ -491,6 +519,7 @@ class Thread:
effort: ReasoningEffort | None = None,
model: str | None = None,
output_schema: JsonObject | None = None,
permission_profile: PermissionProfile | None = None,
personality: Personality | None = None,
sandbox_policy: SandboxPolicy | None = None,
service_tier: ServiceTier | None = None,
@@ -504,6 +533,7 @@ class Thread:
effort=effort,
model=model,
output_schema=output_schema,
permission_profile=permission_profile,
personality=personality,
sandbox_policy=sandbox_policy,
service_tier=service_tier,
@@ -526,6 +556,7 @@ class Thread:
effort: ReasoningEffort | None = None,
model: str | None = None,
output_schema: JsonObject | None = None,
permission_profile: PermissionProfile | None = None,
personality: Personality | None = None,
sandbox_policy: SandboxPolicy | None = None,
service_tier: ServiceTier | None = None,
@@ -541,6 +572,7 @@ class Thread:
effort=effort,
model=model,
output_schema=output_schema,
permission_profile=permission_profile,
personality=personality,
sandbox_policy=sandbox_policy,
service_tier=service_tier,
@@ -575,6 +607,7 @@ class AsyncThread:
effort: ReasoningEffort | None = None,
model: str | None = None,
output_schema: JsonObject | None = None,
permission_profile: PermissionProfile | None = None,
personality: Personality | None = None,
sandbox_policy: SandboxPolicy | None = None,
service_tier: ServiceTier | None = None,
@@ -588,6 +621,7 @@ class AsyncThread:
effort=effort,
model=model,
output_schema=output_schema,
permission_profile=permission_profile,
personality=personality,
sandbox_policy=sandbox_policy,
service_tier=service_tier,
@@ -610,6 +644,7 @@ class AsyncThread:
effort: ReasoningEffort | None = None,
model: str | None = None,
output_schema: JsonObject | None = None,
permission_profile: PermissionProfile | None = None,
personality: Personality | None = None,
sandbox_policy: SandboxPolicy | None = None,
service_tier: ServiceTier | None = None,
@@ -626,6 +661,7 @@ class AsyncThread:
effort=effort,
model=model,
output_schema=output_schema,
permission_profile=permission_profile,
personality=personality,
sandbox_policy=sandbox_policy,
service_tier=service_tier,
+2 -1
View File
@@ -44,6 +44,7 @@ from .models import (
UnknownNotification,
)
from .retry import retry_on_overload
from ._version import __version__ as SDK_VERSION
ModelT = TypeVar("ModelT", bound=BaseModel)
ApprovalHandler = Callable[[str, JsonObject | None], JsonObject]
@@ -129,7 +130,7 @@ class AppServerConfig:
env: dict[str, str] | None = None
client_name: str = "codex_python_sdk"
client_title: str = "Codex Python SDK"
client_version: str = "0.2.0"
client_version: str = SDK_VERSION
experimental_api: bool = True
@@ -22,6 +22,7 @@ from .v2_all import FileChangePatchUpdatedNotification
from .v2_all import FsChangedNotification
from .v2_all import FuzzyFileSearchSessionCompletedNotification
from .v2_all import FuzzyFileSearchSessionUpdatedNotification
from .v2_all import GuardianWarningNotification
from .v2_all import HookCompletedNotification
from .v2_all import HookStartedNotification
from .v2_all import ItemCompletedNotification
@@ -32,6 +33,7 @@ from .v2_all import McpServerOauthLoginCompletedNotification
from .v2_all import McpServerStatusUpdatedNotification
from .v2_all import McpToolCallProgressNotification
from .v2_all import ModelReroutedNotification
from .v2_all import ModelVerificationNotification
from .v2_all import PlanDeltaNotification
from .v2_all import ReasoningSummaryPartAddedNotification
from .v2_all import ReasoningSummaryTextDeltaNotification
@@ -41,6 +43,8 @@ from .v2_all import SkillsChangedNotification
from .v2_all import TerminalInteractionNotification
from .v2_all import ThreadArchivedNotification
from .v2_all import ThreadClosedNotification
from .v2_all import ThreadGoalClearedNotification
from .v2_all import ThreadGoalUpdatedNotification
from .v2_all import ThreadNameUpdatedNotification
from .v2_all import ThreadRealtimeClosedNotification
from .v2_all import ThreadRealtimeErrorNotification
@@ -75,6 +79,7 @@ NOTIFICATION_MODELS: dict[str, type[BaseModel]] = {
"fs/changed": FsChangedNotification,
"fuzzyFileSearch/sessionCompleted": FuzzyFileSearchSessionCompletedNotification,
"fuzzyFileSearch/sessionUpdated": FuzzyFileSearchSessionUpdatedNotification,
"guardianWarning": GuardianWarningNotification,
"hook/completed": HookCompletedNotification,
"hook/started": HookStartedNotification,
"item/agentMessage/delta": AgentMessageDeltaNotification,
@@ -94,11 +99,14 @@ NOTIFICATION_MODELS: dict[str, type[BaseModel]] = {
"mcpServer/oauthLogin/completed": McpServerOauthLoginCompletedNotification,
"mcpServer/startupStatus/updated": McpServerStatusUpdatedNotification,
"model/rerouted": ModelReroutedNotification,
"model/verification": ModelVerificationNotification,
"serverRequest/resolved": ServerRequestResolvedNotification,
"skills/changed": SkillsChangedNotification,
"thread/archived": ThreadArchivedNotification,
"thread/closed": ThreadClosedNotification,
"thread/compacted": ContextCompactedNotification,
"thread/goal/cleared": ThreadGoalClearedNotification,
"thread/goal/updated": ThreadGoalUpdatedNotification,
"thread/name/updated": ThreadNameUpdatedNotification,
"thread/realtime/closed": ThreadRealtimeClosedNotification,
"thread/realtime/error": ThreadRealtimeErrorNotification,
File diff suppressed because it is too large Load Diff
@@ -29,7 +29,9 @@ def _load_runtime_setup_module():
runtime_setup_path = ROOT / "_runtime_setup.py"
spec = importlib.util.spec_from_file_location("_runtime_setup", runtime_setup_path)
if spec is None or spec.loader is None:
raise AssertionError(f"Failed to load runtime setup module: {runtime_setup_path}")
raise AssertionError(
f"Failed to load runtime setup module: {runtime_setup_path}"
)
module = importlib.util.module_from_spec(spec)
sys.modules[spec.name] = module
spec.loader.exec_module(module)
@@ -159,29 +161,32 @@ def test_runtime_package_template_has_no_checked_in_binaries() -> None:
) == ["__init__.py"]
def test_examples_readme_matches_pinned_runtime_version() -> None:
runtime_setup = _load_runtime_setup_module()
def test_examples_readme_points_to_runtime_version_source_of_truth() -> None:
readme = (ROOT / "examples" / "README.md").read_text()
assert (
f"Current pinned runtime version: `{runtime_setup.pinned_runtime_version()}`"
in readme
)
assert "The pinned runtime version comes from the SDK package version." in readme
def test_runtime_distribution_name_is_consistent() -> None:
script = _load_update_script_module()
runtime_setup = _load_runtime_setup_module()
from codex_app_server import client as client_module
from codex_app_server import _version
assert script.SDK_DISTRIBUTION_NAME == "openai-codex-app-server-sdk"
assert runtime_setup.SDK_PACKAGE_NAME == "openai-codex-app-server-sdk"
assert _version.DISTRIBUTION_NAME == "openai-codex-app-server-sdk"
assert script.RUNTIME_DISTRIBUTION_NAME == "openai-codex-cli-bin"
assert runtime_setup.PACKAGE_NAME == "openai-codex-cli-bin"
assert client_module.RUNTIME_PKG_NAME == "openai-codex-cli-bin"
assert "importlib.metadata.version('codex-cli-bin')" not in (
ROOT / "_runtime_setup.py"
).read_text()
assert (
"importlib.metadata.version('codex-cli-bin')"
not in (ROOT / "_runtime_setup.py").read_text()
)
def test_release_metadata_retries_without_invalid_auth(monkeypatch: pytest.MonkeyPatch) -> None:
def test_release_metadata_retries_without_invalid_auth(
monkeypatch: pytest.MonkeyPatch,
) -> None:
runtime_setup = _load_runtime_setup_module()
authorizations: list[str | None] = []
@@ -205,6 +210,19 @@ def test_release_metadata_retries_without_invalid_auth(monkeypatch: pytest.Monke
assert authorizations == ["Bearer invalid-token", None]
def test_runtime_setup_uses_pep440_package_version_and_codex_release_tags() -> None:
runtime_setup = _load_runtime_setup_module()
pyproject = tomllib.loads((ROOT / "pyproject.toml").read_text())
assert runtime_setup.PACKAGE_NAME == "openai-codex-cli-bin"
assert runtime_setup.pinned_runtime_version() == pyproject["project"]["version"]
assert (
runtime_setup._normalized_package_version("rust-v0.116.0-alpha.1")
== "0.116.0a1"
)
assert runtime_setup._release_tag("0.116.0a1") == "rust-v0.116.0-alpha.1"
def test_runtime_package_is_wheel_only_and_builds_platform_specific_wheels() -> None:
pyproject = tomllib.loads(
(ROOT.parent / "python-runtime" / "pyproject.toml").read_text()
@@ -334,12 +352,23 @@ def test_stage_runtime_release_can_pin_wheel_platform_tag(tmp_path: Path) -> Non
def test_stage_sdk_release_injects_exact_runtime_pin(tmp_path: Path) -> None:
script = _load_update_script_module()
staged = script.stage_python_sdk_package(tmp_path / "sdk-stage", "0.2.1", "1.2.3")
staged = script.stage_python_sdk_package(
tmp_path / "sdk-stage",
"rust-v0.116.0-alpha.1",
)
pyproject = (staged / "pyproject.toml").read_text()
assert 'version = "0.2.1"' in pyproject
assert '"openai-codex-cli-bin==1.2.3"' in pyproject
assert '"codex-cli-bin==1.2.3"' not in pyproject
assert 'name = "openai-codex-app-server-sdk"' in pyproject
assert 'version = "0.116.0a1"' in pyproject
assert '"openai-codex-cli-bin==0.116.0a1"' in pyproject
assert (
'__version__ = "0.116.0a1"'
not in (staged / "src" / "codex_app_server" / "__init__.py").read_text()
)
assert (
'client_version: str = "0.116.0a1"'
not in (staged / "src" / "codex_app_server" / "client.py").read_text()
)
assert not any((staged / "src" / "codex_app_server").glob("bin/**"))
@@ -350,12 +379,39 @@ def test_stage_sdk_release_replaces_existing_staging_dir(tmp_path: Path) -> None
old_file.parent.mkdir(parents=True)
old_file.write_text("stale")
staged = script.stage_python_sdk_package(staging_dir, "0.2.1", "1.2.3")
staged = script.stage_python_sdk_package(staging_dir, "0.116.0a1")
assert staged == staging_dir
assert not old_file.exists()
def test_staged_sdk_and_runtime_versions_match(tmp_path: Path) -> None:
script = _load_update_script_module()
fake_binary = tmp_path / script.runtime_binary_name()
fake_binary.write_text("fake codex\n")
sdk_stage = script.stage_python_sdk_package(
tmp_path / "sdk-stage",
"rust-v0.116.0-alpha.1",
)
runtime_stage = script.stage_python_runtime_package(
tmp_path / "runtime-stage",
"rust-v0.116.0-alpha.1",
fake_binary,
)
sdk_pyproject = tomllib.loads((sdk_stage / "pyproject.toml").read_text())
runtime_pyproject = tomllib.loads((runtime_stage / "pyproject.toml").read_text())
assert (
sdk_pyproject["project"]["version"] == runtime_pyproject["project"]["version"]
)
assert sdk_pyproject["project"]["dependencies"] == [
"pydantic>=2.12",
"openai-codex-cli-bin==0.116.0a1",
]
def test_stage_sdk_runs_type_generation_before_staging(tmp_path: Path) -> None:
script = _load_update_script_module()
calls: list[str] = []
@@ -363,18 +419,16 @@ def test_stage_sdk_runs_type_generation_before_staging(tmp_path: Path) -> None:
[
"stage-sdk",
str(tmp_path / "sdk-stage"),
"--runtime-version",
"1.2.3",
"--codex-version",
"rust-v0.116.0-alpha.1",
]
)
def fake_generate_types() -> None:
calls.append("generate_types")
def fake_stage_sdk_package(
_staging_dir: Path, _sdk_version: str, _runtime_version: str
) -> Path:
calls.append("stage_sdk")
def fake_stage_sdk_package(_staging_dir: Path, codex_version: str) -> Path:
calls.append(f"stage_sdk:{codex_version}")
return tmp_path / "sdk-stage"
def fake_stage_runtime_package(
@@ -386,7 +440,7 @@ def test_stage_sdk_runs_type_generation_before_staging(tmp_path: Path) -> None:
raise AssertionError("runtime staging should not run for stage-sdk")
def fake_current_sdk_version() -> str:
return "0.2.0"
return "0.116.0a1"
ops = script.CliOps(
generate_types=fake_generate_types,
@@ -397,7 +451,26 @@ def test_stage_sdk_runs_type_generation_before_staging(tmp_path: Path) -> None:
script.run_command(args, ops)
assert calls == ["generate_types", "stage_sdk"]
assert calls == ["generate_types", "stage_sdk:0.116.0a1"]
def test_stage_sdk_rejects_mismatched_legacy_versions(tmp_path: Path) -> None:
script = _load_update_script_module()
args = script.parse_args(
[
"stage-sdk",
str(tmp_path / "sdk-stage"),
"--codex-version",
"0.116.0a1",
"--runtime-version",
"0.116.0a1",
"--sdk-version",
"0.115.0",
]
)
with pytest.raises(RuntimeError, match="versions must match"):
script.run_command(args, script.default_cli_ops())
def test_stage_runtime_stages_binary_without_type_generation(tmp_path: Path) -> None:
@@ -420,9 +493,7 @@ def test_stage_runtime_stages_binary_without_type_generation(tmp_path: Path) ->
def fake_generate_types() -> None:
calls.append("generate_types")
def fake_stage_sdk_package(
_staging_dir: Path, _sdk_version: str, _runtime_version: str
) -> Path:
def fake_stage_sdk_package(_staging_dir: Path, _codex_version: str) -> Path:
raise AssertionError("sdk staging should not run for stage-runtime")
def fake_stage_runtime_package(
@@ -435,7 +506,7 @@ def test_stage_runtime_stages_binary_without_type_generation(tmp_path: Path) ->
return tmp_path / "runtime-stage"
def fake_current_sdk_version() -> str:
return "0.2.0"
return "0.116.0a1"
ops = script.CliOps(
generate_types=fake_generate_types,
@@ -2,8 +2,11 @@ from __future__ import annotations
import importlib.resources as resources
import inspect
import tomllib
from pathlib import Path
from typing import Any
import codex_app_server
from codex_app_server import AppServerConfig, RunResult
from codex_app_server.models import InitializeResponse
from codex_app_server.api import AsyncCodex, AsyncThread, Codex, Thread
@@ -37,6 +40,14 @@ def test_root_exports_run_result() -> None:
assert RunResult.__name__ == "RunResult"
def test_package_and_default_client_versions_follow_project_version() -> None:
pyproject_path = Path(__file__).resolve().parents[1] / "pyproject.toml"
pyproject = tomllib.loads(pyproject_path.read_text())
assert codex_app_server.__version__ == pyproject["project"]["version"]
assert AppServerConfig().client_version == codex_app_server.__version__
def test_package_includes_py_typed_marker() -> None:
marker = resources.files("codex_app_server").joinpath("py.typed")
assert marker.is_file()
@@ -54,6 +65,7 @@ def test_generated_public_signatures_are_snake_case_and_typed() -> None:
"ephemeral",
"model",
"model_provider",
"permission_profile",
"personality",
"sandbox",
"service_name",
@@ -70,6 +82,7 @@ def test_generated_public_signatures_are_snake_case_and_typed() -> None:
"sort_direction",
"sort_key",
"source_kinds",
"use_state_db_only",
],
Codex.thread_resume: [
"approval_policy",
@@ -78,8 +91,10 @@ def test_generated_public_signatures_are_snake_case_and_typed() -> None:
"config",
"cwd",
"developer_instructions",
"exclude_turns",
"model",
"model_provider",
"permission_profile",
"personality",
"sandbox",
"service_tier",
@@ -92,8 +107,10 @@ def test_generated_public_signatures_are_snake_case_and_typed() -> None:
"cwd",
"developer_instructions",
"ephemeral",
"exclude_turns",
"model",
"model_provider",
"permission_profile",
"sandbox",
"service_tier",
],
@@ -104,6 +121,7 @@ def test_generated_public_signatures_are_snake_case_and_typed() -> None:
"effort",
"model",
"output_schema",
"permission_profile",
"personality",
"sandbox_policy",
"service_tier",
@@ -116,6 +134,7 @@ def test_generated_public_signatures_are_snake_case_and_typed() -> None:
"effort",
"model",
"output_schema",
"permission_profile",
"personality",
"sandbox_policy",
"service_tier",
@@ -131,6 +150,7 @@ def test_generated_public_signatures_are_snake_case_and_typed() -> None:
"ephemeral",
"model",
"model_provider",
"permission_profile",
"personality",
"sandbox",
"service_name",
@@ -147,6 +167,7 @@ def test_generated_public_signatures_are_snake_case_and_typed() -> None:
"sort_direction",
"sort_key",
"source_kinds",
"use_state_db_only",
],
AsyncCodex.thread_resume: [
"approval_policy",
@@ -155,8 +176,10 @@ def test_generated_public_signatures_are_snake_case_and_typed() -> None:
"config",
"cwd",
"developer_instructions",
"exclude_turns",
"model",
"model_provider",
"permission_profile",
"personality",
"sandbox",
"service_tier",
@@ -169,8 +192,10 @@ def test_generated_public_signatures_are_snake_case_and_typed() -> None:
"cwd",
"developer_instructions",
"ephemeral",
"exclude_turns",
"model",
"model_provider",
"permission_profile",
"sandbox",
"service_tier",
],
@@ -181,6 +206,7 @@ def test_generated_public_signatures_are_snake_case_and_typed() -> None:
"effort",
"model",
"output_schema",
"permission_profile",
"personality",
"sandbox_policy",
"service_tier",
@@ -193,6 +219,7 @@ def test_generated_public_signatures_are_snake_case_and_typed() -> None:
"effort",
"model",
"output_schema",
"permission_profile",
"personality",
"sandbox_policy",
"service_tier",
+25 -25
View File
@@ -3,7 +3,7 @@ revision = 3
requires-python = ">=3.10"
[options]
exclude-newer = "2026-04-16T16:29:01.461661899Z"
exclude-newer = "2026-04-20T18:19:27.620299Z"
exclude-newer-span = "P7D"
[[package]]
@@ -80,30 +80,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/e4/20/71885d8b97d4f3dde17b1fdb92dbd4908b00541c5a3379787137285f602e/click-8.3.2-py3-none-any.whl", hash = "sha256:1924d2c27c5653561cd2cae4548d1406039cb79b858b747cfea24924bbc1616d", size = 108379, upload-time = "2026-04-03T19:14:43.505Z" },
]
[[package]]
name = "codex-app-server-sdk"
version = "0.2.0"
source = { editable = "." }
dependencies = [
{ name = "pydantic" },
]
[package.optional-dependencies]
dev = [
{ name = "datamodel-code-generator" },
{ name = "pytest" },
{ name = "ruff" },
]
[package.metadata]
requires-dist = [
{ name = "datamodel-code-generator", marker = "extra == 'dev'", specifier = "==0.31.2" },
{ name = "pydantic", specifier = ">=2.12" },
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0" },
{ name = "ruff", marker = "extra == 'dev'", specifier = ">=0.11" },
]
provides-extras = ["dev"]
[[package]]
name = "colorama"
version = "0.4.6"
@@ -301,6 +277,30 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" },
]
[[package]]
name = "openai-codex-app-server-sdk"
version = "0.116.0a1"
source = { editable = "." }
dependencies = [
{ name = "pydantic" },
]
[package.optional-dependencies]
dev = [
{ name = "datamodel-code-generator" },
{ name = "pytest" },
{ name = "ruff" },
]
[package.metadata]
requires-dist = [
{ name = "datamodel-code-generator", marker = "extra == 'dev'", specifier = "==0.31.2" },
{ name = "pydantic", specifier = ">=2.12" },
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0" },
{ name = "ruff", marker = "extra == 'dev'", specifier = ">=0.11" },
]
provides-extras = ["dev"]
[[package]]
name = "packaging"
version = "26.1"