Merge branch 'main' into etraut/tui-queue-user-cmd-followup

This commit is contained in:
Eric Traut
2026-04-16 23:41:22 -07:00
committed by GitHub
381 changed files with 21757 additions and 9493 deletions
+164 -8
View File
@@ -940,6 +940,15 @@ dependencies = [
"serde_core",
]
[[package]]
name = "blake2"
version = "0.10.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe"
dependencies = [
"digest",
]
[[package]]
name = "block-buffer"
version = "0.10.4"
@@ -1446,6 +1455,7 @@ dependencies = [
"codex-cloud-requirements",
"codex-config",
"codex-core",
"codex-core-plugins",
"codex-exec-server",
"codex-features",
"codex-feedback",
@@ -1530,7 +1540,6 @@ dependencies = [
"anyhow",
"clap",
"codex-experimental-api-macros",
"codex-git-utils",
"codex-protocol",
"codex-shell-command",
"codex-utils-absolute-path",
@@ -1648,6 +1657,7 @@ version = "0.0.0"
dependencies = [
"anyhow",
"clap",
"codex-app-server-protocol",
"codex-config",
"codex-connectors",
"codex-core",
@@ -1687,6 +1697,7 @@ dependencies = [
"codex-login",
"codex-mcp",
"codex-mcp-server",
"codex-model-provider",
"codex-protocol",
"codex-responses-api-proxy",
"codex-rmcp-client",
@@ -1855,12 +1866,11 @@ dependencies = [
"codex-app-server-protocol",
"codex-execpolicy",
"codex-features",
"codex-git-utils",
"codex-model-provider-info",
"codex-network-proxy",
"codex-protocol",
"codex-utils-absolute-path",
"dunce",
"codex-utils-path",
"futures",
"multimap",
"pretty_assertions",
@@ -1913,6 +1923,7 @@ dependencies = [
"codex-code-mode",
"codex-config",
"codex-connectors",
"codex-core-plugins",
"codex-core-skills",
"codex-exec-server",
"codex-execpolicy",
@@ -1923,6 +1934,7 @@ dependencies = [
"codex-instructions",
"codex-login",
"codex-mcp",
"codex-model-provider",
"codex-model-provider-info",
"codex-models-manager",
"codex-network-proxy",
@@ -1957,6 +1969,7 @@ dependencies = [
"codex-windows-sandbox",
"core-foundation 0.9.4",
"core_test_support",
"crypto_box",
"csv",
"ctor 0.6.3",
"dirs",
@@ -1987,6 +2000,7 @@ dependencies = [
"serde_json",
"serial_test",
"sha1",
"sha2",
"shlex",
"similar",
"tempfile",
@@ -2013,6 +2027,34 @@ dependencies = [
"zstd 0.13.3",
]
[[package]]
name = "codex-core-plugins"
version = "0.0.0"
dependencies = [
"chrono",
"codex-app-server-protocol",
"codex-config",
"codex-core-skills",
"codex-exec-server",
"codex-git-utils",
"codex-login",
"codex-plugin",
"codex-protocol",
"codex-utils-absolute-path",
"codex-utils-plugins",
"dirs",
"pretty_assertions",
"reqwest",
"serde",
"serde_json",
"tempfile",
"thiserror 2.0.18",
"tokio",
"toml 0.9.11+spec-1.1.0",
"tracing",
"url",
]
[[package]]
name = "codex-core-skills"
version = "0.0.0"
@@ -2222,6 +2264,8 @@ name = "codex-git-utils"
version = "0.0.0"
dependencies = [
"assert_matches",
"codex-exec-server",
"codex-protocol",
"codex-utils-absolute-path",
"futures",
"once_cell",
@@ -2292,6 +2336,7 @@ dependencies = [
"codex-protocol",
"codex-sandboxing",
"codex-utils-absolute-path",
"globset",
"landlock",
"libc",
"pkg-config",
@@ -2326,7 +2371,6 @@ dependencies = [
"async-trait",
"base64 0.22.1",
"chrono",
"codex-api",
"codex-app-server-protocol",
"codex-client",
"codex-config",
@@ -2422,6 +2466,19 @@ dependencies = [
"wiremock",
]
[[package]]
name = "codex-model-provider"
version = "0.0.0"
dependencies = [
"async-trait",
"codex-api",
"codex-login",
"codex-model-provider-info",
"codex-protocol",
"http 1.4.0",
"pretty_assertions",
]
[[package]]
name = "codex-model-provider-info"
version = "0.0.0"
@@ -2451,6 +2508,7 @@ dependencies = [
"codex-config",
"codex-feedback",
"codex-login",
"codex-model-provider",
"codex-model-provider-info",
"codex-otel",
"codex-protocol",
@@ -2578,13 +2636,13 @@ dependencies = [
"chrono",
"codex-async-utils",
"codex-execpolicy",
"codex-git-utils",
"codex-network-proxy",
"codex-utils-absolute-path",
"codex-utils-image",
"codex-utils-string",
"codex-utils-template",
"encoding_rs",
"globset",
"http 1.4.0",
"icu_decimal",
"icu_locale_core",
@@ -2717,6 +2775,7 @@ dependencies = [
"dunce",
"libc",
"pretty_assertions",
"regex-lite",
"serde_json",
"tempfile",
"tokio",
@@ -2857,11 +2916,16 @@ dependencies = [
"codex-rollout",
"codex-state",
"pretty_assertions",
"prost 0.14.3",
"serde",
"serde_json",
"tempfile",
"thiserror 2.0.18",
"tokio",
"tokio-stream",
"tonic",
"tonic-prost",
"tonic-prost-build",
"uuid",
]
@@ -2900,6 +2964,7 @@ dependencies = [
"codex-cli",
"codex-cloud-requirements",
"codex-config",
"codex-connectors",
"codex-exec-server",
"codex-features",
"codex-feedback",
@@ -3618,9 +3683,40 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
dependencies = [
"generic-array",
"rand_core 0.6.4",
"typenum",
]
[[package]]
name = "crypto_box"
version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "16182b4f39a82ec8a6851155cc4c0cda3065bb1db33651726a29e1951de0f009"
dependencies = [
"aead",
"blake2",
"crypto_secretbox",
"curve25519-dalek",
"salsa20",
"subtle",
"zeroize",
]
[[package]]
name = "crypto_secretbox"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9d6cf87adf719ddf43a805e92c6870a531aedda35ff640442cbaf8674e141e1"
dependencies = [
"aead",
"cipher",
"generic-array",
"poly1305",
"salsa20",
"subtle",
"zeroize",
]
[[package]]
name = "csv"
version = "1.4.0"
@@ -4910,6 +5006,7 @@ checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
dependencies = [
"typenum",
"version_check",
"zeroize",
]
[[package]]
@@ -7532,7 +7629,7 @@ dependencies = [
"heck 0.4.1",
"itertools 0.11.0",
"prost 0.12.6",
"prost-types",
"prost-types 0.12.6",
]
[[package]]
@@ -7546,7 +7643,7 @@ dependencies = [
"pbjson",
"pbjson-build",
"prost 0.12.6",
"prost-build",
"prost-build 0.12.6",
"serde",
]
@@ -7970,7 +8067,26 @@ dependencies = [
"petgraph 0.6.5",
"prettyplease",
"prost 0.12.6",
"prost-types",
"prost-types 0.12.6",
"regex",
"syn 2.0.114",
"tempfile",
]
[[package]]
name = "prost-build"
version = "0.14.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "343d3bd7056eda839b03204e68deff7d1b13aba7af2b2fd16890697274262ee7"
dependencies = [
"heck 0.5.0",
"itertools 0.14.0",
"log",
"multimap",
"petgraph 0.8.3",
"prettyplease",
"prost 0.14.3",
"prost-types 0.14.3",
"regex",
"syn 2.0.114",
"tempfile",
@@ -8011,6 +8127,15 @@ dependencies = [
"prost 0.12.6",
]
[[package]]
name = "prost-types"
version = "0.14.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8991c4cbdb8bc5b11f0b074ffe286c30e523de90fee5ba8132f1399f23cb3dd7"
dependencies = [
"prost 0.14.3",
]
[[package]]
name = "psl"
version = "2.1.184"
@@ -10919,8 +11044,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a286e33f82f8a1ee2df63f4fa35c0becf4a85a0cb03091a15fd7bf0b402dc94a"
dependencies = [
"async-trait",
"axum",
"base64 0.22.1",
"bytes",
"h2",
"http 1.4.0",
"http-body",
"http-body-util",
@@ -10930,6 +11057,7 @@ dependencies = [
"percent-encoding",
"pin-project",
"rustls-native-certs",
"socket2 0.6.2",
"sync_wrapper",
"tokio",
"tokio-rustls",
@@ -10940,6 +11068,18 @@ dependencies = [
"tracing",
]
[[package]]
name = "tonic-build"
version = "0.14.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "27aac809edf60b741e2d7db6367214d078856b8a5bff0087e94ff330fb97b6fc"
dependencies = [
"prettyplease",
"proc-macro2",
"quote",
"syn 2.0.114",
]
[[package]]
name = "tonic-prost"
version = "0.14.3"
@@ -10951,6 +11091,22 @@ dependencies = [
"tonic",
]
[[package]]
name = "tonic-prost-build"
version = "0.14.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a4556786613791cfef4ed134aa670b61a85cfcacf71543ef33e8d801abae988f"
dependencies = [
"prettyplease",
"proc-macro2",
"prost-build 0.14.3",
"prost-types 0.14.3",
"quote",
"syn 2.0.114",
"tempfile",
"tonic-build",
]
[[package]]
name = "tower"
version = "0.5.3"
+7
View File
@@ -28,6 +28,7 @@ members = [
"shell-escalation",
"skills",
"core",
"core-plugins",
"core-skills",
"hooks",
"instructions",
@@ -91,6 +92,7 @@ members = [
"thread-store",
"codex-experimental-api-macros",
"plugin",
"model-provider",
]
resolver = "2"
@@ -128,6 +130,7 @@ codex-code-mode = { path = "code-mode" }
codex-config = { path = "config" }
codex-connectors = { path = "connectors" }
codex-core = { path = "core" }
codex-core-plugins = { path = "core-plugins" }
codex-core-skills = { path = "core-skills" }
codex-exec = { path = "exec" }
codex-exec-server = { path = "exec-server" }
@@ -152,6 +155,7 @@ codex-network-proxy = { path = "network-proxy" }
codex-ollama = { path = "ollama" }
codex-otel = { path = "otel" }
codex-plugin = { path = "plugin" }
codex-model-provider = { path = "model-provider" }
codex-process-hardening = { path = "process-hardening" }
codex-protocol = { path = "protocol" }
codex-realtime-webrtc = { path = "realtime-webrtc" }
@@ -222,6 +226,7 @@ color-eyre = "0.6.3"
constant_time_eq = "0.3.1"
crossbeam-channel = "0.5.15"
crossterm = "0.28.1"
crypto_box = { version = "0.9.1", features = ["seal"] }
csv = "1.3.1"
ctor = "0.6.3"
deno_core_icudata = "0.77.0"
@@ -345,6 +350,8 @@ tracing-appender = "0.2.3"
tracing-opentelemetry = "0.32.0"
tracing-subscriber = "0.3.22"
tracing-test = "0.2.5"
tonic = { version = "0.14.3", default-features = false, features = ["channel", "codegen"] }
tonic-prost = "0.14.3"
tree-sitter = "0.25.10"
tree-sitter-bash = "0.25"
ts-rs = "11"
@@ -4,6 +4,7 @@ use crate::events::CodexAppMentionedEventRequest;
use crate::events::CodexAppServerClientMetadata;
use crate::events::CodexAppUsedEventRequest;
use crate::events::CodexCompactionEventRequest;
use crate::events::CodexHookRunEventRequest;
use crate::events::CodexPluginEventRequest;
use crate::events::CodexPluginUsedEventRequest;
use crate::events::CodexRuntimeMetadata;
@@ -12,6 +13,7 @@ use crate::events::ThreadInitializedEvent;
use crate::events::ThreadInitializedEventParams;
use crate::events::TrackEventRequest;
use crate::events::codex_app_metadata;
use crate::events::codex_hook_run_metadata;
use crate::events::codex_plugin_metadata;
use crate::events::codex_plugin_used_metadata;
use crate::events::subagent_thread_started_event_request;
@@ -28,6 +30,8 @@ use crate::facts::CompactionStatus;
use crate::facts::CompactionStrategy;
use crate::facts::CompactionTrigger;
use crate::facts::CustomAnalyticsFact;
use crate::facts::HookRunFact;
use crate::facts::HookRunInput;
use crate::facts::InputError;
use crate::facts::InvocationType;
use crate::facts::PluginState;
@@ -81,6 +85,9 @@ use codex_plugin::PluginTelemetryMetadata;
use codex_protocol::config_types::ApprovalsReviewer;
use codex_protocol::config_types::ModeKind;
use codex_protocol::protocol::AskForApproval;
use codex_protocol::protocol::HookEventName;
use codex_protocol::protocol::HookRunStatus;
use codex_protocol::protocol::HookSource;
use codex_protocol::protocol::SandboxPolicy;
use codex_protocol::protocol::SessionSource;
use codex_protocol::protocol::SubAgentSource;
@@ -1282,6 +1289,109 @@ fn plugin_management_event_serializes_expected_shape() {
);
}
#[test]
fn hook_run_event_serializes_expected_shape() {
let tracking = TrackEventsContext {
model_slug: "gpt-5".to_string(),
thread_id: "thread-3".to_string(),
turn_id: "turn-3".to_string(),
};
let event = TrackEventRequest::HookRun(CodexHookRunEventRequest {
event_type: "codex_hook_run",
event_params: codex_hook_run_metadata(
&tracking,
HookRunFact {
event_name: HookEventName::PreToolUse,
hook_source: HookSource::User,
status: HookRunStatus::Completed,
},
),
});
let payload = serde_json::to_value(&event).expect("serialize hook run event");
assert_eq!(
payload,
json!({
"event_type": "codex_hook_run",
"event_params": {
"thread_id": "thread-3",
"turn_id": "turn-3",
"model_slug": "gpt-5",
"hook_name": "PreToolUse",
"hook_source": "user",
"status": "completed"
}
})
);
}
#[test]
fn hook_run_metadata_maps_sources_and_statuses() {
let tracking = TrackEventsContext {
model_slug: "gpt-5".to_string(),
thread_id: "thread-1".to_string(),
turn_id: "turn-1".to_string(),
};
let system = serde_json::to_value(codex_hook_run_metadata(
&tracking,
HookRunFact {
event_name: HookEventName::SessionStart,
hook_source: HookSource::System,
status: HookRunStatus::Completed,
},
))
.expect("serialize system hook");
let project = serde_json::to_value(codex_hook_run_metadata(
&tracking,
HookRunFact {
event_name: HookEventName::Stop,
hook_source: HookSource::Project,
status: HookRunStatus::Blocked,
},
))
.expect("serialize project hook");
let unknown = serde_json::to_value(codex_hook_run_metadata(
&tracking,
HookRunFact {
event_name: HookEventName::UserPromptSubmit,
hook_source: HookSource::Unknown,
status: HookRunStatus::Failed,
},
))
.expect("serialize unknown hook");
assert_eq!(system["hook_source"], "system");
assert_eq!(system["status"], "completed");
assert_eq!(project["hook_source"], "project");
assert_eq!(project["status"], "blocked");
assert_eq!(unknown["hook_source"], "unknown");
assert_eq!(unknown["status"], "failed");
}
#[test]
fn hook_run_metadata_maps_stopped_status() {
let tracking = TrackEventsContext {
model_slug: "gpt-5".to_string(),
thread_id: "thread-1".to_string(),
turn_id: "turn-1".to_string(),
};
let stopped = serde_json::to_value(codex_hook_run_metadata(
&tracking,
HookRunFact {
event_name: HookEventName::Stop,
hook_source: HookSource::User,
status: HookRunStatus::Stopped,
},
))
.expect("serialize stopped hook");
assert_eq!(stopped["hook_source"], "user");
assert_eq!(stopped["status"], "stopped");
}
#[test]
fn plugin_used_dedupe_is_keyed_by_turn_and_plugin() {
let (sender, _receiver) = mpsc::channel(1);
@@ -1359,6 +1469,37 @@ async fn reducer_ingests_skill_invoked_fact() {
);
}
#[tokio::test]
async fn reducer_ingests_hook_run_fact() {
let mut reducer = AnalyticsReducer::default();
let mut events = Vec::new();
reducer
.ingest(
AnalyticsFact::Custom(CustomAnalyticsFact::HookRun(HookRunInput {
tracking: TrackEventsContext {
model_slug: "gpt-5".to_string(),
thread_id: "thread-1".to_string(),
turn_id: "turn-1".to_string(),
},
hook: HookRunFact {
event_name: HookEventName::PostToolUse,
hook_source: HookSource::Unknown,
status: HookRunStatus::Failed,
},
})),
&mut events,
)
.await;
let payload = serde_json::to_value(&events).expect("serialize events");
assert_eq!(payload.as_array().expect("events array").len(), 1);
assert_eq!(payload[0]["event_type"], "codex_hook_run");
assert_eq!(payload[0]["event_params"]["hook_name"], "PostToolUse");
assert_eq!(payload[0]["event_params"]["hook_source"], "unknown");
assert_eq!(payload[0]["event_params"]["status"], "failed");
}
#[tokio::test]
async fn reducer_ingests_app_and_plugin_facts() {
let mut reducer = AnalyticsReducer::default();
+8
View File
@@ -9,6 +9,8 @@ use crate::facts::AppInvocation;
use crate::facts::AppMentionedInput;
use crate::facts::AppUsedInput;
use crate::facts::CustomAnalyticsFact;
use crate::facts::HookRunFact;
use crate::facts::HookRunInput;
use crate::facts::PluginState;
use crate::facts::PluginStateChangedInput;
use crate::facts::SkillInvocation;
@@ -191,6 +193,12 @@ impl AnalyticsEventsClient {
)));
}
pub fn track_hook_run(&self, tracking: TrackEventsContext, hook: HookRunFact) {
self.record_fact(AnalyticsFact::Custom(CustomAnalyticsFact::HookRun(
HookRunInput { tracking, hook },
)));
}
pub fn track_plugin_used(&self, tracking: TrackEventsContext, plugin: PluginTelemetryMetadata) {
if !self.queue.should_enqueue_plugin_used(&tracking, &plugin) {
return;
+66
View File
@@ -1,5 +1,6 @@
use crate::facts::AppInvocation;
use crate::facts::CodexCompactionEvent;
use crate::facts::HookRunFact;
use crate::facts::InvocationType;
use crate::facts::PluginState;
use crate::facts::SubAgentThreadStartedInput;
@@ -15,6 +16,9 @@ use codex_plugin::PluginTelemetryMetadata;
use codex_protocol::approvals::NetworkApprovalProtocol;
use codex_protocol::models::PermissionProfile;
use codex_protocol::models::SandboxPermissions;
use codex_protocol::protocol::HookEventName;
use codex_protocol::protocol::HookRunStatus;
use codex_protocol::protocol::HookSource;
use codex_protocol::protocol::SubAgentSource;
use serde::Serialize;
@@ -39,6 +43,7 @@ pub(crate) enum TrackEventRequest {
GuardianReview(Box<GuardianReviewEventRequest>),
AppMentioned(CodexAppMentionedEventRequest),
AppUsed(CodexAppUsedEventRequest),
HookRun(CodexHookRunEventRequest),
Compaction(Box<CodexCompactionEventRequest>),
TurnEvent(Box<CodexTurnEventRequest>),
TurnSteer(CodexTurnSteerEventRequest),
@@ -300,6 +305,22 @@ pub(crate) struct CodexAppUsedEventRequest {
pub(crate) event_params: CodexAppMetadata,
}
#[derive(Serialize)]
pub(crate) struct CodexHookRunMetadata {
pub(crate) thread_id: Option<String>,
pub(crate) turn_id: Option<String>,
pub(crate) model_slug: Option<String>,
pub(crate) hook_name: Option<String>,
pub(crate) hook_source: Option<&'static str>,
pub(crate) status: Option<HookRunStatus>,
}
#[derive(Serialize)]
pub(crate) struct CodexHookRunEventRequest {
pub(crate) event_type: &'static str,
pub(crate) event_params: CodexHookRunMetadata,
}
#[derive(Serialize)]
pub(crate) struct CodexCompactionEventParams {
pub(crate) thread_id: String,
@@ -529,6 +550,43 @@ pub(crate) fn codex_plugin_used_metadata(
}
}
pub(crate) fn codex_hook_run_metadata(
tracking: &TrackEventsContext,
hook: HookRunFact,
) -> CodexHookRunMetadata {
CodexHookRunMetadata {
thread_id: Some(tracking.thread_id.clone()),
turn_id: Some(tracking.turn_id.clone()),
model_slug: Some(tracking.model_slug.clone()),
hook_name: Some(analytics_hook_event_name(hook.event_name).to_owned()),
hook_source: Some(analytics_hook_source(hook.hook_source)),
status: Some(analytics_hook_status(hook.status)),
}
}
fn analytics_hook_event_name(event_name: HookEventName) -> &'static str {
match event_name {
HookEventName::PreToolUse => "PreToolUse",
HookEventName::PostToolUse => "PostToolUse",
HookEventName::SessionStart => "SessionStart",
HookEventName::UserPromptSubmit => "UserPromptSubmit",
HookEventName::Stop => "Stop",
}
}
fn analytics_hook_source(source: HookSource) -> &'static str {
match source {
HookSource::System => "system",
HookSource::User => "user",
HookSource::Project => "project",
HookSource::Mdm => "mdm",
HookSource::SessionFlags => "session_flags",
HookSource::LegacyManagedConfigFile => "legacy_managed_config_file",
HookSource::LegacyManagedConfigMdm => "legacy_managed_config_mdm",
HookSource::Unknown => "unknown",
}
}
pub(crate) fn current_runtime_metadata() -> CodexRuntimeMetadata {
let os_info = os_info::get();
CodexRuntimeMetadata {
@@ -586,3 +644,11 @@ pub(crate) fn subagent_parent_thread_id(subagent_source: &SubAgentSource) -> Opt
_ => None,
}
}
fn analytics_hook_status(status: HookRunStatus) -> HookRunStatus {
match status {
// Running is unexpected here and normalized defensively.
HookRunStatus::Running => HookRunStatus::Failed,
other => other,
}
}
+15
View File
@@ -15,6 +15,9 @@ use codex_protocol::config_types::ReasoningSummary;
use codex_protocol::config_types::ServiceTier;
use codex_protocol::openai_models::ReasoningEffort;
use codex_protocol::protocol::AskForApproval;
use codex_protocol::protocol::HookEventName;
use codex_protocol::protocol::HookRunStatus;
use codex_protocol::protocol::HookSource;
use codex_protocol::protocol::SandboxPolicy;
use codex_protocol::protocol::SessionSource;
use codex_protocol::protocol::SkillScope;
@@ -298,6 +301,7 @@ pub(crate) enum CustomAnalyticsFact {
SkillInvoked(SkillInvokedInput),
AppMentioned(AppMentionedInput),
AppUsed(AppUsedInput),
HookRun(HookRunInput),
PluginUsed(PluginUsedInput),
PluginStateChanged(PluginStateChangedInput),
}
@@ -317,6 +321,17 @@ pub(crate) struct AppUsedInput {
pub app: AppInvocation,
}
pub(crate) struct HookRunInput {
pub tracking: TrackEventsContext,
pub hook: HookRunFact,
}
pub struct HookRunFact {
pub event_name: HookEventName,
pub hook_source: HookSource,
pub status: HookRunStatus,
}
pub(crate) struct PluginUsedInput {
pub tracking: TrackEventsContext,
pub plugin: PluginTelemetryMetadata,
+1
View File
@@ -29,6 +29,7 @@ pub use facts::CompactionReason;
pub use facts::CompactionStatus;
pub use facts::CompactionStrategy;
pub use facts::CompactionTrigger;
pub use facts::HookRunFact;
pub use facts::InputError;
pub use facts::InvocationType;
pub use facts::SkillInvocation;
+14
View File
@@ -3,6 +3,7 @@ use crate::events::CodexAppMentionedEventRequest;
use crate::events::CodexAppServerClientMetadata;
use crate::events::CodexAppUsedEventRequest;
use crate::events::CodexCompactionEventRequest;
use crate::events::CodexHookRunEventRequest;
use crate::events::CodexPluginEventRequest;
use crate::events::CodexPluginUsedEventRequest;
use crate::events::CodexRuntimeMetadata;
@@ -20,6 +21,7 @@ use crate::events::ThreadInitializedEventParams;
use crate::events::TrackEventRequest;
use crate::events::codex_app_metadata;
use crate::events::codex_compaction_event_params;
use crate::events::codex_hook_run_metadata;
use crate::events::codex_plugin_metadata;
use crate::events::codex_plugin_used_metadata;
use crate::events::plugin_state_event_type;
@@ -32,6 +34,7 @@ use crate::facts::AppMentionedInput;
use crate::facts::AppUsedInput;
use crate::facts::CodexCompactionEvent;
use crate::facts::CustomAnalyticsFact;
use crate::facts::HookRunInput;
use crate::facts::PluginState;
use crate::facts::PluginStateChangedInput;
use crate::facts::PluginUsedInput;
@@ -217,6 +220,9 @@ impl AnalyticsReducer {
CustomAnalyticsFact::AppUsed(input) => {
self.ingest_app_used(input, out);
}
CustomAnalyticsFact::HookRun(input) => {
self.ingest_hook_run(input, out);
}
CustomAnalyticsFact::PluginUsed(input) => {
self.ingest_plugin_used(input, out);
}
@@ -442,6 +448,14 @@ impl AnalyticsReducer {
}));
}
fn ingest_hook_run(&mut self, input: HookRunInput, out: &mut Vec<TrackEventRequest>) {
let HookRunInput { tracking, hook } = input;
out.push(TrackEventRequest::HookRun(CodexHookRunEventRequest {
event_type: "codex_hook_run",
event_params: codex_hook_run_metadata(&tracking, hook),
}));
}
fn ingest_plugin_used(&mut self, input: PluginUsedInput, out: &mut Vec<TrackEventRequest>) {
let PluginUsedInput { tracking, plugin } = input;
out.push(TrackEventRequest::PluginUsed(CodexPluginUsedEventRequest {
+2 -3
View File
@@ -65,9 +65,9 @@ pub use crate::remote::RemoteAppServerConnectArgs;
/// while legacy startup/config paths are migrated to RPCs.
pub mod legacy_core {
pub use codex_core::Cursor;
pub use codex_core::DEFAULT_PROJECT_DOC_FILENAME;
pub use codex_core::DEFAULT_AGENTS_MD_FILENAME;
pub use codex_core::INTERACTIVE_SESSION_SOURCES;
pub use codex_core::LOCAL_PROJECT_DOC_FILENAME;
pub use codex_core::LOCAL_AGENTS_MD_FILENAME;
pub use codex_core::McpManager;
pub use codex_core::PLUGIN_TEXT_MENTION_SIGIL;
pub use codex_core::RolloutRecorder;
@@ -77,7 +77,6 @@ pub mod legacy_core {
pub use codex_core::ThreadsPage;
pub use codex_core::append_message_history_entry;
pub use codex_core::check_execpolicy_for_warnings;
pub use codex_core::discover_project_doc_paths;
pub use codex_core::find_thread_meta_by_name_str;
pub use codex_core::find_thread_name_by_id;
pub use codex_core::find_thread_names_by_ids;
-1
View File
@@ -15,7 +15,6 @@ workspace = true
anyhow = { workspace = true }
clap = { workspace = true, features = ["derive"] }
codex-experimental-api-macros = { workspace = true }
codex-git-utils = { workspace = true }
codex-protocol = { workspace = true }
codex-shell-command = { workspace = true }
codex-utils-absolute-path = { workspace = true }
@@ -1517,6 +1517,14 @@
"scope": {
"$ref": "#/definitions/HookScope"
},
"source": {
"allOf": [
{
"$ref": "#/definitions/HookSource"
}
],
"default": "unknown"
},
"sourcePath": {
"$ref": "#/definitions/AbsolutePathBuf"
},
@@ -1555,6 +1563,19 @@
],
"type": "string"
},
"HookSource": {
"enum": [
"system",
"user",
"project",
"mdm",
"sessionFlags",
"legacyManagedConfigFile",
"legacyManagedConfigMdm",
"unknown"
],
"type": "string"
},
"HookStartedNotification": {
"properties": {
"run": {
@@ -2891,6 +2912,12 @@
"id": {
"type": "string"
},
"mcpAppResourceUri": {
"type": [
"string",
"null"
]
},
"result": {
"anyOf": [
{
@@ -8597,6 +8597,14 @@
"scope": {
"$ref": "#/definitions/v2/HookScope"
},
"source": {
"allOf": [
{
"$ref": "#/definitions/v2/HookSource"
}
],
"default": "unknown"
},
"sourcePath": {
"$ref": "#/definitions/v2/AbsolutePathBuf"
},
@@ -8635,6 +8643,19 @@
],
"type": "string"
},
"HookSource": {
"enum": [
"system",
"user",
"project",
"mdm",
"sessionFlags",
"legacyManagedConfigFile",
"legacyManagedConfigMdm",
"unknown"
],
"type": "string"
},
"HookStartedNotification": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
@@ -13369,6 +13390,12 @@
"id": {
"type": "string"
},
"mcpAppResourceUri": {
"type": [
"string",
"null"
]
},
"result": {
"anyOf": [
{
@@ -5325,6 +5325,14 @@
"scope": {
"$ref": "#/definitions/HookScope"
},
"source": {
"allOf": [
{
"$ref": "#/definitions/HookSource"
}
],
"default": "unknown"
},
"sourcePath": {
"$ref": "#/definitions/AbsolutePathBuf"
},
@@ -5363,6 +5371,19 @@
],
"type": "string"
},
"HookSource": {
"enum": [
"system",
"user",
"project",
"mdm",
"sessionFlags",
"legacyManagedConfigFile",
"legacyManagedConfigMdm",
"unknown"
],
"type": "string"
},
"HookStartedNotification": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
@@ -11213,6 +11234,12 @@
"id": {
"type": "string"
},
"mcpAppResourceUri": {
"type": [
"string",
"null"
]
},
"result": {
"anyOf": [
{
@@ -106,6 +106,14 @@
"scope": {
"$ref": "#/definitions/HookScope"
},
"source": {
"allOf": [
{
"$ref": "#/definitions/HookSource"
}
],
"default": "unknown"
},
"sourcePath": {
"$ref": "#/definitions/AbsolutePathBuf"
},
@@ -143,6 +151,19 @@
"turn"
],
"type": "string"
},
"HookSource": {
"enum": [
"system",
"user",
"project",
"mdm",
"sessionFlags",
"legacyManagedConfigFile",
"legacyManagedConfigMdm",
"unknown"
],
"type": "string"
}
},
"properties": {
@@ -106,6 +106,14 @@
"scope": {
"$ref": "#/definitions/HookScope"
},
"source": {
"allOf": [
{
"$ref": "#/definitions/HookSource"
}
],
"default": "unknown"
},
"sourcePath": {
"$ref": "#/definitions/AbsolutePathBuf"
},
@@ -143,6 +151,19 @@
"turn"
],
"type": "string"
},
"HookSource": {
"enum": [
"system",
"user",
"project",
"mdm",
"sessionFlags",
"legacyManagedConfigFile",
"legacyManagedConfigMdm",
"unknown"
],
"type": "string"
}
},
"properties": {
@@ -787,6 +787,12 @@
"id": {
"type": "string"
},
"mcpAppResourceUri": {
"type": [
"string",
"null"
]
},
"result": {
"anyOf": [
{
@@ -787,6 +787,12 @@
"id": {
"type": "string"
},
"mcpAppResourceUri": {
"type": [
"string",
"null"
]
},
"result": {
"anyOf": [
{
@@ -930,6 +930,12 @@
"id": {
"type": "string"
},
"mcpAppResourceUri": {
"type": [
"string",
"null"
]
},
"result": {
"anyOf": [
{
@@ -1444,6 +1444,12 @@
"id": {
"type": "string"
},
"mcpAppResourceUri": {
"type": [
"string",
"null"
]
},
"result": {
"anyOf": [
{
@@ -1206,6 +1206,12 @@
"id": {
"type": "string"
},
"mcpAppResourceUri": {
"type": [
"string",
"null"
]
},
"result": {
"anyOf": [
{
@@ -1206,6 +1206,12 @@
"id": {
"type": "string"
},
"mcpAppResourceUri": {
"type": [
"string",
"null"
]
},
"result": {
"anyOf": [
{
@@ -1206,6 +1206,12 @@
"id": {
"type": "string"
},
"mcpAppResourceUri": {
"type": [
"string",
"null"
]
},
"result": {
"anyOf": [
{
@@ -1444,6 +1444,12 @@
"id": {
"type": "string"
},
"mcpAppResourceUri": {
"type": [
"string",
"null"
]
},
"result": {
"anyOf": [
{
@@ -1206,6 +1206,12 @@
"id": {
"type": "string"
},
"mcpAppResourceUri": {
"type": [
"string",
"null"
]
},
"result": {
"anyOf": [
{
@@ -1444,6 +1444,12 @@
"id": {
"type": "string"
},
"mcpAppResourceUri": {
"type": [
"string",
"null"
]
},
"result": {
"anyOf": [
{
@@ -1206,6 +1206,12 @@
"id": {
"type": "string"
},
"mcpAppResourceUri": {
"type": [
"string",
"null"
]
},
"result": {
"anyOf": [
{
@@ -1206,6 +1206,12 @@
"id": {
"type": "string"
},
"mcpAppResourceUri": {
"type": [
"string",
"null"
]
},
"result": {
"anyOf": [
{
@@ -930,6 +930,12 @@
"id": {
"type": "string"
},
"mcpAppResourceUri": {
"type": [
"string",
"null"
]
},
"result": {
"anyOf": [
{
@@ -930,6 +930,12 @@
"id": {
"type": "string"
},
"mcpAppResourceUri": {
"type": [
"string",
"null"
]
},
"result": {
"anyOf": [
{
@@ -930,6 +930,12 @@
"id": {
"type": "string"
},
"mcpAppResourceUri": {
"type": [
"string",
"null"
]
},
"result": {
"anyOf": [
{
@@ -8,5 +8,6 @@ import type { HookHandlerType } from "./HookHandlerType";
import type { HookOutputEntry } from "./HookOutputEntry";
import type { HookRunStatus } from "./HookRunStatus";
import type { HookScope } from "./HookScope";
import type { HookSource } from "./HookSource";
export type HookRunSummary = { id: string, eventName: HookEventName, handlerType: HookHandlerType, executionMode: HookExecutionMode, scope: HookScope, sourcePath: AbsolutePathBuf, displayOrder: bigint, status: HookRunStatus, statusMessage: string | null, startedAt: bigint, completedAt: bigint | null, durationMs: bigint | null, entries: Array<HookOutputEntry>, };
export type HookRunSummary = { id: string, eventName: HookEventName, handlerType: HookHandlerType, executionMode: HookExecutionMode, scope: HookScope, sourcePath: AbsolutePathBuf, source: HookSource, displayOrder: bigint, status: HookRunStatus, statusMessage: string | null, startedAt: bigint, completedAt: bigint | null, durationMs: bigint | null, entries: Array<HookOutputEntry>, };
@@ -0,0 +1,5 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type HookSource = "system" | "user" | "project" | "mdm" | "sessionFlags" | "legacyManagedConfigFile" | "legacyManagedConfigMdm" | "unknown";
@@ -53,7 +53,7 @@ exitCode: number | null,
/**
* The duration of the command execution in milliseconds.
*/
durationMs: number | null, } | { "type": "fileChange", id: string, changes: Array<FileUpdateChange>, status: PatchApplyStatus, } | { "type": "mcpToolCall", id: string, server: string, tool: string, status: McpToolCallStatus, arguments: JsonValue, result: McpToolCallResult | null, error: McpToolCallError | null,
durationMs: number | null, } | { "type": "fileChange", id: string, changes: Array<FileUpdateChange>, status: PatchApplyStatus, } | { "type": "mcpToolCall", id: string, server: string, tool: string, status: McpToolCallStatus, arguments: JsonValue, mcpAppResourceUri?: string, result: McpToolCallResult | null, error: McpToolCallError | null,
/**
* The duration of the MCP tool call in milliseconds.
*/
@@ -139,6 +139,7 @@ export type { HookPromptFragment } from "./HookPromptFragment";
export type { HookRunStatus } from "./HookRunStatus";
export type { HookRunSummary } from "./HookRunSummary";
export type { HookScope } from "./HookScope";
export type { HookSource } from "./HookSource";
export type { HookStartedNotification } from "./HookStartedNotification";
export type { ItemCompletedNotification } from "./ItemCompletedNotification";
export type { ItemGuardianApprovalReviewCompletedNotification } from "./ItemGuardianApprovalReviewCompletedNotification";
+1 -1
View File
@@ -4,7 +4,6 @@ mod jsonrpc_lite;
mod protocol;
mod schema_fixtures;
pub use codex_git_utils::GitSha;
pub use experimental_api::*;
pub use export::GenerateTsOptions;
pub use export::generate_internal_json_schema;
@@ -30,6 +29,7 @@ pub use protocol::v1::GetConversationSummaryParams;
pub use protocol::v1::GetConversationSummaryResponse;
pub use protocol::v1::GitDiffToRemoteParams;
pub use protocol::v1::GitDiffToRemoteResponse;
pub use protocol::v1::GitSha;
pub use protocol::v1::InitializeCapabilities;
pub use protocol::v1::InitializeParams;
pub use protocol::v1::InitializeResponse;
@@ -764,6 +764,7 @@ macro_rules! server_notification_definitions {
Display,
ExperimentalApi,
)]
#[allow(clippy::large_enum_variant)]
#[serde(tag = "method", content = "params", rename_all = "camelCase")]
#[strum(serialize_all = "camelCase")]
pub enum ServerNotification {
@@ -123,6 +123,20 @@ impl ThreadHistoryBuilder {
.or_else(|| self.turns.last().cloned())
}
/// Returns the index of the active turn snapshot within the finished turn list.
///
/// When a turn is still open, this is the index it will occupy after
/// `finish`. When no turn is open, it is the index of the last finished turn.
pub fn active_turn_position(&self) -> Option<usize> {
if self.current_turn.is_some() {
Some(self.turns.len())
} else if self.turns.is_empty() {
None
} else {
Some(self.turns.len() - 1)
}
}
pub fn has_active_turn(&self) -> bool {
self.current_turn.is_some()
}
@@ -502,6 +516,7 @@ impl ThreadHistoryBuilder {
.arguments
.clone()
.unwrap_or(serde_json::Value::Null),
mcp_app_resource_uri: payload.mcp_app_resource_uri.clone(),
result: None,
error: None,
duration_ms: None,
@@ -518,11 +533,11 @@ impl ThreadHistoryBuilder {
let duration_ms = i64::try_from(payload.duration.as_millis()).ok();
let (result, error) = match &payload.result {
Ok(value) => (
Some(McpToolCallResult {
Some(Box::new(McpToolCallResult {
content: value.content.clone(),
structured_content: value.structured_content.clone(),
meta: value.meta.clone(),
}),
})),
None,
),
Err(message) => (
@@ -542,6 +557,7 @@ impl ThreadHistoryBuilder {
.arguments
.clone()
.unwrap_or(serde_json::Value::Null),
mcp_app_resource_uri: payload.mcp_app_resource_uri.clone(),
result,
error,
duration_ms,
@@ -1809,6 +1825,7 @@ mod tests {
tool: "lookup".into(),
arguments: Some(serde_json::json!({"id":"123"})),
},
mcp_app_resource_uri: None,
duration: Duration::from_millis(8),
result: Err("boom".into()),
}),
@@ -1857,6 +1874,7 @@ mod tests {
tool: "lookup".into(),
status: McpToolCallStatus::Failed,
arguments: serde_json::json!({"id":"123"}),
mcp_app_resource_uri: None,
result: None,
error: Some(McpToolCallError {
message: "boom".into(),
@@ -1882,6 +1900,7 @@ mod tests {
tool: "lookup".into(),
arguments: Some(serde_json::json!({"id":"123"})),
},
mcp_app_resource_uri: Some("ui://widget/lookup.html".into()),
duration: Duration::from_millis(8),
result: Ok(CallToolResult {
content: vec![serde_json::json!({
@@ -1911,7 +1930,8 @@ mod tests {
tool: "lookup".into(),
status: McpToolCallStatus::Completed,
arguments: serde_json::json!({"id":"123"}),
result: Some(McpToolCallResult {
mcp_app_resource_uri: Some("ui://widget/lookup.html".into()),
result: Some(Box::new(McpToolCallResult {
content: vec![serde_json::json!({
"type": "text",
"text": "result"
@@ -1920,7 +1940,7 @@ mod tests {
meta: Some(serde_json::json!({
"ui/resourceUri": "ui://widget/lookup.html"
})),
}),
})),
error: None,
duration_ms: Some(8),
}
@@ -1,7 +1,6 @@
use std::collections::HashMap;
use std::path::PathBuf;
use codex_git_utils::GitSha;
use codex_protocol::ThreadId;
use codex_protocol::config_types::ForcedLoginMethod;
use codex_protocol::config_types::ReasoningSummary;
@@ -11,6 +10,7 @@ use codex_protocol::openai_models::ReasoningEffort;
use codex_protocol::parse_command::ParsedCommand;
use codex_protocol::protocol::AskForApproval;
use codex_protocol::protocol::FileChange;
pub use codex_protocol::protocol::GitSha;
use codex_protocol::protocol::ReviewDecision;
use codex_protocol::protocol::SandboxPolicy;
use codex_protocol::protocol::SessionSource;
@@ -65,6 +65,7 @@ use codex_protocol::protocol::HookOutputEntryKind as CoreHookOutputEntryKind;
use codex_protocol::protocol::HookRunStatus as CoreHookRunStatus;
use codex_protocol::protocol::HookRunSummary as CoreHookRunSummary;
use codex_protocol::protocol::HookScope as CoreHookScope;
use codex_protocol::protocol::HookSource as CoreHookSource;
use codex_protocol::protocol::ModelRerouteReason as CoreModelRerouteReason;
use codex_protocol::protocol::NetworkAccess as CoreNetworkAccess;
use codex_protocol::protocol::NonSteerableTurnKind as CoreNonSteerableTurnKind;
@@ -402,6 +403,23 @@ v2_enum_from_core!(
}
);
v2_enum_from_core!(
pub enum HookSource from CoreHookSource {
System,
User,
Project,
Mdm,
SessionFlags,
LegacyManagedConfigFile,
LegacyManagedConfigMdm,
Unknown,
}
);
fn default_hook_source() -> HookSource {
HookSource::Unknown
}
v2_enum_from_core!(
pub enum HookRunStatus from CoreHookRunStatus {
Running, Completed, Failed, Blocked, Stopped
@@ -449,6 +467,8 @@ pub struct HookRunSummary {
pub execution_mode: HookExecutionMode,
pub scope: HookScope,
pub source_path: AbsolutePathBuf,
#[serde(default = "default_hook_source")]
pub source: HookSource,
pub display_order: i64,
pub status: HookRunStatus,
pub status_message: Option<String>,
@@ -467,6 +487,7 @@ impl From<CoreHookRunSummary> for HookRunSummary {
execution_mode: value.execution_mode.into(),
scope: value.scope.into(),
source_path: value.source_path,
source: value.source.into(),
display_order: value.display_order,
status: value.status.into(),
status_message: value.status_message,
@@ -4585,7 +4606,10 @@ pub enum ThreadItem {
tool: String,
status: McpToolCallStatus,
arguments: JsonValue,
result: Option<McpToolCallResult>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional)]
mcp_app_resource_uri: Option<String>,
result: Option<Box<McpToolCallResult>>,
error: Option<McpToolCallError>,
/// The duration of the MCP tool call in milliseconds.
#[ts(type = "number | null")]
+1
View File
@@ -34,6 +34,7 @@ codex-arg0 = { workspace = true }
codex-cloud-requirements = { workspace = true }
codex-config = { workspace = true }
codex-core = { workspace = true }
codex-core-plugins = { workspace = true }
codex-exec-server = { workspace = true }
codex-features = { workspace = true }
codex-git-utils = { workspace = true }
+2 -2
View File
@@ -250,7 +250,7 @@ Start a fresh thread when you need a new Codex conversation.
Valid `personality` values are `"friendly"`, `"pragmatic"`, and `"none"`. When `"none"` is selected, the personality placeholder is replaced with an empty string.
To continue a stored session, call `thread/resume` with the `thread.id` you previously recorded. The response shape matches `thread/start`, and no additional notifications are emitted. You can also pass the same configuration overrides supported by `thread/start`, including `approvalsReviewer`.
To continue a stored session, call `thread/resume` with the `thread.id` you previously recorded. The response shape matches `thread/start`. When the stored session includes persisted token usage, the server emits `thread/tokenUsage/updated` immediately after the response so clients can render restored usage before the next turn starts. You can also pass the same configuration overrides supported by `thread/start`, including `approvalsReviewer`.
By default, resume uses the latest persisted `model` and `reasoningEffort` values associated with the thread. Supplying any of `model`, `modelProvider`, `config.model`, or `config.model_reasoning_effort` disables that persisted fallback and uses the explicit overrides plus normal config resolution instead.
@@ -264,7 +264,7 @@ Example:
{ "id": 11, "result": { "thread": { "id": "thr_123", … } } }
```
To branch from a stored session, call `thread/fork` with the `thread.id`. This creates a new thread id and emits a `thread/started` notification for it. If the source thread is actively running, the fork snapshots it as if the current turn had been interrupted first. Pass `ephemeral: true` when the fork should stay in-memory only:
To branch from a stored session, call `thread/fork` with the `thread.id`. This creates a new thread id and emits a `thread/started` notification for it. When the source history includes persisted token usage, the server also emits `thread/tokenUsage/updated` for the new thread immediately after the response. If the source thread is actively running, the fork snapshots it as if the current turn had been interrupted first. Pass `ephemeral: true` when the fork should stay in-memory only:
```json
{ "method": "thread/fork", "id": 12, "params": { "threadId": "thr_123", "ephemeral": true } }
@@ -2910,6 +2910,7 @@ async fn construct_mcp_tool_call_notification(
tool: begin_event.invocation.tool,
status: McpToolCallStatus::InProgress,
arguments: begin_event.invocation.arguments.unwrap_or(JsonValue::Null),
mcp_app_resource_uri: begin_event.mcp_app_resource_uri,
result: None,
error: None,
duration_ms: None,
@@ -2936,11 +2937,11 @@ async fn construct_mcp_tool_call_end_notification(
let (result, error) = match &end_event.result {
Ok(value) => (
Some(McpToolCallResult {
Some(Box::new(McpToolCallResult {
content: value.content.clone(),
structured_content: value.structured_content.clone(),
meta: value.meta.clone(),
}),
})),
None,
),
Err(message) => (
@@ -2957,6 +2958,7 @@ async fn construct_mcp_tool_call_end_notification(
tool: end_event.invocation.tool,
status,
arguments: end_event.invocation.arguments.unwrap_or(JsonValue::Null),
mcp_app_resource_uri: end_event.mcp_app_resource_uri,
result,
error,
duration_ms,
@@ -4219,6 +4221,7 @@ mod tests {
tool: "list_mcp_resources".to_string(),
arguments: Some(serde_json::json!({"server": ""})),
},
mcp_app_resource_uri: Some("ui://widget/list-resources.html".to_string()),
};
let thread_id = ThreadId::new().to_string();
@@ -4239,6 +4242,7 @@ mod tests {
tool: begin_event.invocation.tool,
status: McpToolCallStatus::InProgress,
arguments: serde_json::json!({"server": ""}),
mcp_app_resource_uri: Some("ui://widget/list-resources.html".to_string()),
result: None,
error: None,
duration_ms: None,
@@ -4379,6 +4383,7 @@ mod tests {
tool: "list_mcp_resources".to_string(),
arguments: None,
},
mcp_app_resource_uri: None,
};
let thread_id = ThreadId::new().to_string();
@@ -4399,6 +4404,7 @@ mod tests {
tool: begin_event.invocation.tool,
status: McpToolCallStatus::InProgress,
arguments: JsonValue::Null,
mcp_app_resource_uri: None,
result: None,
error: None,
duration_ms: None,
@@ -4430,6 +4436,7 @@ mod tests {
tool: "list_mcp_resources".to_string(),
arguments: Some(serde_json::json!({"server": ""})),
},
mcp_app_resource_uri: Some("ui://widget/list-resources.html".to_string()),
duration: Duration::from_nanos(92708),
result: Ok(result),
};
@@ -4452,13 +4459,14 @@ mod tests {
tool: end_event.invocation.tool,
status: McpToolCallStatus::Completed,
arguments: serde_json::json!({"server": ""}),
result: Some(McpToolCallResult {
mcp_app_resource_uri: Some("ui://widget/list-resources.html".to_string()),
result: Some(Box::new(McpToolCallResult {
content,
structured_content: None,
meta: Some(serde_json::json!({
"ui/resourceUri": "ui://widget/list-resources.html"
})),
}),
})),
error: None,
duration_ms: Some(0),
},
@@ -4476,6 +4484,7 @@ mod tests {
tool: "list_mcp_resources".to_string(),
arguments: None,
},
mcp_app_resource_uri: None,
duration: Duration::from_millis(1),
result: Err("boom".to_string()),
};
@@ -4498,6 +4507,7 @@ mod tests {
tool: end_event.invocation.tool,
status: McpToolCallStatus::Failed,
arguments: JsonValue::Null,
mcp_app_resource_uri: None,
result: None,
error: Some(McpToolCallError {
message: "boom".to_string(),
+316 -194
View File
@@ -186,6 +186,7 @@ use codex_app_server_protocol::ThreadUnsubscribeStatus;
use codex_app_server_protocol::Turn;
use codex_app_server_protocol::TurnError;
use codex_app_server_protocol::TurnInterruptParams;
use codex_app_server_protocol::TurnInterruptResponse;
use codex_app_server_protocol::TurnStartParams;
use codex_app_server_protocol::TurnStartResponse;
use codex_app_server_protocol::TurnStatus;
@@ -211,6 +212,7 @@ use codex_core::SteerInputError;
use codex_core::ThreadConfigSnapshot;
use codex_core::ThreadManager;
use codex_core::append_thread_name;
use codex_core::clear_memory_roots_contents;
use codex_core::config::Config;
use codex_core::config::ConfigOverrides;
use codex_core::config::NetworkProxyAuditMetadata;
@@ -232,22 +234,23 @@ use codex_core::find_thread_names_by_ids;
use codex_core::find_thread_path_by_id_str;
use codex_core::path_utils;
use codex_core::plugins::MarketplaceAddError;
use codex_core::plugins::MarketplaceError;
use codex_core::plugins::MarketplacePluginSource;
use codex_core::plugins::OPENAI_CURATED_MARKETPLACE_NAME;
use codex_core::plugins::PluginInstallError as CorePluginInstallError;
use codex_core::plugins::PluginInstallRequest;
use codex_core::plugins::PluginReadRequest;
use codex_core::plugins::PluginUninstallError as CorePluginUninstallError;
use codex_core::plugins::add_marketplace as add_marketplace_to_codex_home;
use codex_core::plugins::load_plugin_apps;
use codex_core::plugins::load_plugin_mcp_servers;
use codex_core::read_head_for_summary;
use codex_core::read_session_meta_line;
use codex_core::sandboxing::SandboxPermissions;
use codex_core::windows_sandbox::WindowsSandboxLevelExt;
use codex_core::windows_sandbox::WindowsSandboxSetupMode as CoreWindowsSandboxSetupMode;
use codex_core::windows_sandbox::WindowsSandboxSetupRequest;
use codex_core_plugins::loader::load_plugin_apps;
use codex_core_plugins::loader::load_plugin_mcp_servers;
use codex_core_plugins::manifest::PluginManifestInterface;
use codex_core_plugins::marketplace::MarketplaceError;
use codex_core_plugins::marketplace::MarketplacePluginSource;
use codex_exec_server::LOCAL_FS;
use codex_features::FEATURES;
use codex_features::Feature;
@@ -360,12 +363,17 @@ use codex_app_server_protocol::ServerRequest;
mod apps_list_helpers;
mod plugin_app_helpers;
mod plugin_mcp_oauth;
mod token_usage_replay;
use crate::filters::compute_source_filters;
use crate::filters::source_kind_matches;
use crate::thread_state::ThreadListenerCommand;
use crate::thread_state::ThreadState;
use crate::thread_state::ThreadStateManager;
use token_usage_replay::latest_token_usage_turn_id_for_thread_path;
use token_usage_replay::latest_token_usage_turn_id_from_rollout_items;
use token_usage_replay::latest_token_usage_turn_id_from_rollout_path;
use token_usage_replay::send_thread_token_usage_update_to_connection;
const THREAD_LIST_DEFAULT_LIMIT: usize = 25;
const THREAD_LIST_MAX_LIMIT: usize = 100;
@@ -430,6 +438,11 @@ enum ThreadShutdownResult {
TimedOut,
}
enum ThreadReadViewError {
InvalidRequest(String),
Internal(String),
}
impl Drop for ActiveLogin {
fn drop(&mut self) {
self.cancel();
@@ -614,17 +627,9 @@ pub(crate) struct CodexMessageProcessorArgs {
impl CodexMessageProcessor {
async fn instruction_sources_from_config(config: &Config) -> Vec<AbsolutePathBuf> {
let mut paths: Vec<AbsolutePathBuf> =
config.user_instructions_path.iter().cloned().collect();
match codex_core::discover_project_doc_paths(config, LOCAL_FS.as_ref()).await {
Ok(project_doc_paths) => {
paths.extend(project_doc_paths);
}
Err(err) => {
tracing::warn!(error = %err, "failed to discover project docs for thread response");
}
}
paths
codex_core::AgentsMdManager::new(config)
.instruction_sources(LOCAL_FS.as_ref())
.await
}
pub(crate) fn handle_config_mutation(&self) {
@@ -1697,12 +1702,15 @@ impl CodexMessageProcessor {
}
}
if let Err(err) = self.auth_manager.logout() {
return Err(JSONRPCErrorError {
code: INTERNAL_ERROR_CODE,
message: format!("logout failed: {err}"),
data: None,
});
match self.auth_manager.logout_with_revoke().await {
Ok(_) => {}
Err(err) => {
return Err(JSONRPCErrorError {
code: INTERNAL_ERROR_CODE,
message: format!("logout failed: {err}"),
data: None,
});
}
}
// Reflect the current auth method after logout (likely None).
@@ -2441,9 +2449,9 @@ impl CodexMessageProcessor {
| codex_protocol::protocol::SandboxPolicy::ExternalSandbox { .. }
))
{
let trust_target = resolve_root_git_project_for_trust(config.cwd.as_path())
let trust_target = resolve_root_git_project_for_trust(LOCAL_FS.as_ref(), &config.cwd)
.await
.unwrap_or_else(|| config.cwd.to_path_buf());
.unwrap_or_else(|| config.cwd.clone());
let cli_overrides_with_trust;
let cli_overrides_for_reload = if let Err(err) =
codex_core::config::set_project_trust_level(
@@ -3078,48 +3086,12 @@ impl CodexMessageProcessor {
return;
}
let memory_root = self.config.codex_home.join("memories");
let memory_extensions_root = self.config.codex_home.join("memories_extensions");
let clear_memory_root_result: std::io::Result<()> = async {
for directory in [memory_root.as_path(), memory_extensions_root.as_path()] {
match tokio::fs::symlink_metadata(directory).await {
Ok(metadata) if metadata.file_type().is_symlink() => {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
format!(
"refusing to clear symlinked memory root {}",
directory.display()
),
));
}
Ok(_) => {}
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {}
Err(err) => return Err(err),
}
tokio::fs::create_dir_all(directory).await?;
let mut entries = tokio::fs::read_dir(directory).await?;
while let Some(entry) = entries.next_entry().await? {
let path = entry.path();
let file_type = entry.file_type().await?;
if file_type.is_dir() {
tokio::fs::remove_dir_all(path).await?;
} else {
tokio::fs::remove_file(path).await?;
}
}
}
Ok(())
}
.await;
if let Err(err) = clear_memory_root_result {
if let Err(err) = clear_memory_roots_contents(&self.config.codex_home).await {
self.send_internal_error(
request_id,
format!(
"failed to clear memory directory {}: {err}",
memory_root.display()
"failed to clear memory directories under {}: {err}",
self.config.codex_home.display()
),
)
.await;
@@ -3822,126 +3794,45 @@ impl CodexMessageProcessor {
}
};
let loaded_thread = self.thread_manager.get_thread(thread_uuid).await.ok();
let loaded_thread_state_db = loaded_thread.as_ref().and_then(|thread| thread.state_db());
let db_summary = if let Some(state_db_ctx) = loaded_thread_state_db.as_ref() {
read_summary_from_state_db_context_by_thread_id(Some(state_db_ctx), thread_uuid).await
} else {
read_summary_from_state_db_by_thread_id(&self.config, thread_uuid).await
};
let mut rollout_path = db_summary.as_ref().map(|summary| summary.path.clone());
if rollout_path.is_none() || include_turns {
rollout_path =
match find_thread_path_by_id_str(&self.config.codex_home, &thread_uuid.to_string())
.await
{
Ok(Some(path)) => Some(path),
Ok(None) => {
if include_turns {
None
} else {
rollout_path
}
}
Err(err) => {
self.send_invalid_request_error(
request_id,
format!("failed to locate thread id {thread_uuid}: {err}"),
)
.await;
return;
}
};
}
if include_turns && rollout_path.is_none() && db_summary.is_some() {
self.send_internal_error(
request_id,
format!("failed to locate rollout for thread {thread_uuid}"),
)
.await;
return;
}
let mut thread = if let Some(summary) = db_summary {
summary_to_thread(summary, &self.config.cwd)
} else if let Some(rollout_path) = rollout_path.as_ref() {
let fallback_provider = self.config.model_provider_id.as_str();
match read_summary_from_rollout(rollout_path, fallback_provider).await {
Ok(summary) => summary_to_thread(summary, &self.config.cwd),
Err(err) => {
self.send_internal_error(
request_id,
format!(
"failed to load rollout `{}` for thread {thread_uuid}: {err}",
rollout_path.display()
),
)
.await;
return;
}
}
} else {
let Some(thread) = loaded_thread.as_ref() else {
self.send_invalid_request_error(
request_id,
format!("thread not loaded: {thread_uuid}"),
)
.await;
return;
};
let config_snapshot = thread.config_snapshot().await;
let loaded_rollout_path = thread.rollout_path();
if include_turns && loaded_rollout_path.is_none() {
self.send_invalid_request_error(
request_id,
"ephemeral threads do not support includeTurns".to_string(),
)
.await;
let thread = match self.read_thread_view(thread_uuid, include_turns).await {
Ok(thread) => thread,
Err(ThreadReadViewError::InvalidRequest(message)) => {
self.send_invalid_request_error(request_id, message).await;
return;
}
if include_turns {
rollout_path = loaded_rollout_path.clone();
Err(ThreadReadViewError::Internal(message)) => {
self.send_internal_error(request_id, message).await;
return;
}
build_thread_from_snapshot(thread_uuid, &config_snapshot, loaded_rollout_path)
};
if thread.forked_from_id.is_none()
&& let Some(rollout_path) = rollout_path.as_ref()
let response = ThreadReadResponse { thread };
self.outgoing.send_response(request_id, response).await;
}
/// Builds the API view for `thread/read` from persisted metadata plus optional live state.
async fn read_thread_view(
&self,
thread_id: ThreadId,
include_turns: bool,
) -> Result<Thread, ThreadReadViewError> {
let loaded_thread = self.load_live_thread_for_read(thread_id).await;
let mut thread = if let Some(thread) = self
.load_persisted_thread_for_read(thread_id, include_turns, loaded_thread.as_ref())
.await?
{
thread.forked_from_id = forked_from_id_from_rollout(rollout_path).await;
}
self.attach_thread_name(thread_uuid, &mut thread).await;
thread
} else if let Some(thread) = self
.load_live_thread_view(thread_id, include_turns, loaded_thread.as_ref())
.await?
{
thread
} else {
return Err(ThreadReadViewError::InvalidRequest(format!(
"thread not loaded: {thread_id}"
)));
};
if include_turns && let Some(rollout_path) = rollout_path.as_ref() {
match read_rollout_items_from_rollout(rollout_path).await {
Ok(items) => {
thread.turns = build_turns_from_rollout_items(&items);
}
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
self.send_invalid_request_error(
request_id,
format!(
"thread {thread_uuid} is not materialized yet; includeTurns is unavailable before first user message"
),
)
.await;
return;
}
Err(err) => {
self.send_internal_error(
request_id,
format!(
"failed to load rollout `{}` for thread {thread_uuid}: {err}",
rollout_path.display()
),
)
.await;
return;
}
}
}
let has_live_in_progress_turn = if let Some(loaded_thread) = loaded_thread.as_ref() {
let has_live_in_progress_turn = if let Some(loaded_thread) = loaded_thread {
matches!(loaded_thread.agent_status().await, AgentStatus::Running)
} else {
false
@@ -3957,8 +3848,151 @@ impl CodexMessageProcessor {
thread_status,
has_live_in_progress_turn,
);
let response = ThreadReadResponse { thread };
self.outgoing.send_response(request_id, response).await;
Ok(thread)
}
async fn load_live_thread_for_read(&self, thread_id: ThreadId) -> Option<Arc<CodexThread>> {
self.thread_manager.get_thread(thread_id).await.ok()
}
async fn load_persisted_thread_for_read(
&self,
thread_id: ThreadId,
include_turns: bool,
loaded_thread: Option<&Arc<CodexThread>>,
) -> Result<Option<Thread>, ThreadReadViewError> {
let loaded_thread_state_db = loaded_thread.and_then(|thread| thread.state_db());
let db_summary = if let Some(state_db_ctx) = loaded_thread_state_db.as_ref() {
read_summary_from_state_db_context_by_thread_id(Some(state_db_ctx), thread_id).await
} else {
read_summary_from_state_db_by_thread_id(&self.config, thread_id).await
};
let mut rollout_path = db_summary.as_ref().map(|summary| summary.path.clone());
if rollout_path.is_none() || include_turns {
rollout_path =
match find_thread_path_by_id_str(&self.config.codex_home, &thread_id.to_string())
.await
{
Ok(Some(path)) => Some(path),
Ok(None) => {
if include_turns {
None
} else {
rollout_path
}
}
Err(err) => {
return Err(ThreadReadViewError::InvalidRequest(format!(
"failed to locate thread id {thread_id}: {err}"
)));
}
};
}
if include_turns && rollout_path.is_none() && db_summary.is_some() {
return Err(ThreadReadViewError::Internal(format!(
"failed to locate rollout for thread {thread_id}"
)));
}
if let Some(summary) = db_summary {
let mut thread = summary_to_thread(summary, &self.config.cwd);
self.apply_thread_read_rollout_fields(
thread_id,
&mut thread,
rollout_path.as_deref(),
include_turns,
)
.await?;
return Ok(Some(thread));
}
let Some(rollout_path) = rollout_path else {
return Ok(None);
};
let fallback_provider = self.config.model_provider_id.as_str();
match read_summary_from_rollout(&rollout_path, fallback_provider).await {
Ok(summary) => {
let mut thread = summary_to_thread(summary, &self.config.cwd);
self.apply_thread_read_rollout_fields(
thread_id,
&mut thread,
Some(rollout_path.as_path()),
include_turns,
)
.await?;
Ok(Some(thread))
}
Err(err) => Err(ThreadReadViewError::Internal(format!(
"failed to load rollout `{}` for thread {thread_id}: {err}",
rollout_path.display()
))),
}
}
async fn load_live_thread_view(
&self,
thread_id: ThreadId,
include_turns: bool,
loaded_thread: Option<&Arc<CodexThread>>,
) -> Result<Option<Thread>, ThreadReadViewError> {
let Some(thread) = loaded_thread else {
return Ok(None);
};
let config_snapshot = thread.config_snapshot().await;
let loaded_rollout_path = thread.rollout_path();
if include_turns && loaded_rollout_path.is_none() {
return Err(ThreadReadViewError::InvalidRequest(
"ephemeral threads do not support includeTurns".to_string(),
));
}
let mut thread =
build_thread_from_snapshot(thread_id, &config_snapshot, loaded_rollout_path.clone());
self.apply_thread_read_rollout_fields(
thread_id,
&mut thread,
loaded_rollout_path.as_deref(),
include_turns,
)
.await?;
Ok(Some(thread))
}
async fn apply_thread_read_rollout_fields(
&self,
thread_id: ThreadId,
thread: &mut Thread,
rollout_path: Option<&Path>,
include_turns: bool,
) -> Result<(), ThreadReadViewError> {
if thread.forked_from_id.is_none()
&& let Some(rollout_path) = rollout_path
{
thread.forked_from_id = forked_from_id_from_rollout(rollout_path).await;
}
self.attach_thread_name(thread_id, thread).await;
if include_turns && let Some(rollout_path) = rollout_path {
match read_rollout_items_from_rollout(rollout_path).await {
Ok(items) => {
thread.turns = build_turns_from_rollout_items(&items);
}
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
return Err(ThreadReadViewError::InvalidRequest(format!(
"thread {thread_id} is not materialized yet; includeTurns is unavailable before first user message"
)));
}
Err(err) => {
return Err(ThreadReadViewError::Internal(format!(
"failed to load rollout `{}` for thread {thread_id}: {err}",
rollout_path.display()
)));
}
}
}
Ok(())
}
pub(crate) fn thread_created_receiver(&self) -> broadcast::Receiver<ThreadId> {
@@ -4144,7 +4178,7 @@ impl CodexMessageProcessor {
{
Ok(NewThread {
thread_id,
thread,
thread: codex_thread,
session_configured,
}) => {
let SessionConfiguredEvent { rollout_path, .. } = session_configured;
@@ -4173,7 +4207,7 @@ impl CodexMessageProcessor {
let mut thread = match self
.load_thread_from_resume_source_or_send_internal(
thread_id,
thread.as_ref(),
codex_thread.as_ref(),
&response_history,
rollout_path.as_path(),
fallback_model_provider.as_str(),
@@ -4225,7 +4259,25 @@ impl CodexMessageProcessor {
);
}
let connection_id = request_id.connection_id;
let token_usage_thread = response.thread.clone();
let token_usage_turn_id = latest_token_usage_turn_id_from_rollout_items(
&response_history.get_rollout_items(),
&token_usage_thread,
);
self.outgoing.send_response(request_id, response).await;
// The client needs restored usage before it starts another turn.
// Sending after the response preserves JSON-RPC request ordering while
// still filling the status line before the next turn lifecycle begins.
send_thread_token_usage_update_to_connection(
&self.outgoing,
connection_id,
thread_id,
&token_usage_thread,
codex_thread.as_ref(),
token_usage_turn_id,
)
.await;
}
Err(err) => {
let error = JSONRPCErrorError {
@@ -4861,7 +4913,31 @@ impl CodexMessageProcessor {
);
}
let connection_id = request_id.connection_id;
let token_usage_thread = response.thread.clone();
let token_usage_turn_id = if let Some(turn_id) =
latest_token_usage_turn_id_for_thread_path(&token_usage_thread).await
{
Some(turn_id)
} else {
latest_token_usage_turn_id_from_rollout_path(
rollout_path.as_path(),
&token_usage_thread,
)
.await
};
self.outgoing.send_response(request_id, response).await;
// Mirror the resume contract for forks: the new thread is usable as soon
// as the response arrives, so restored usage must follow immediately.
send_thread_token_usage_update_to_connection(
&self.outgoing,
connection_id,
thread_id,
&token_usage_thread,
forked_thread.as_ref(),
token_usage_turn_id,
)
.await;
let notif = ThreadStartedNotification { thread };
self.outgoing
@@ -6149,6 +6225,7 @@ impl CodexMessageProcessor {
}
};
let config_layer_stack = match load_config_layers_state(
LOCAL_FS.as_ref(),
&self.config.codex_home,
Some(cwd_abs.clone()),
&cli_overrides,
@@ -6211,7 +6288,7 @@ impl CodexMessageProcessor {
force_remote_sync,
} = params;
let roots = cwds.unwrap_or_default();
plugins_manager.maybe_start_non_curated_plugin_cache_refresh_for_roots(&roots);
plugins_manager.maybe_start_non_curated_plugin_cache_refresh(&roots);
let mut config = match self.load_latest_config(/*fallback_cwd*/ None).await {
Ok(config) => config,
@@ -7558,9 +7635,12 @@ impl CodexMessageProcessor {
async fn turn_interrupt(&self, request_id: ConnectionRequestId, params: TurnInterruptParams) {
let TurnInterruptParams { thread_id, turn_id } = params;
self.outgoing
.record_request_turn_id(&request_id, &turn_id)
.await;
let is_startup_interrupt = turn_id.is_empty();
if !is_startup_interrupt {
self.outgoing
.record_request_turn_id(&request_id, &turn_id)
.await;
}
let (thread_uuid, thread) = match self.load_thread(&thread_id).await {
Ok(v) => v,
@@ -7570,21 +7650,48 @@ impl CodexMessageProcessor {
}
};
let request = request_id.clone();
// Record the pending interrupt so we can reply when TurnAborted arrives.
{
// Record turn interrupts so we can reply when TurnAborted arrives. Startup
// interrupts do not have a turn and are acknowledged after submission.
if !is_startup_interrupt {
let thread_state = self.thread_state_manager.thread_state(thread_uuid).await;
let mut thread_state = thread_state.lock().await;
thread_state
.pending_interrupts
.push((request, ApiVersion::V2));
.push((request_id.clone(), ApiVersion::V2));
}
// Submit the interrupt; we'll respond upon TurnAborted.
let _ = self
// Submit the interrupt. Turn interrupts respond upon TurnAborted; startup
// interrupts respond here because startup cancellation has no turn event.
let submit_result = self
.submit_core_op(&request_id, thread.as_ref(), Op::Interrupt)
.await;
match submit_result {
Ok(_) if is_startup_interrupt => {
self.outgoing
.send_response(request_id, TurnInterruptResponse {})
.await;
}
Ok(_) => {}
Err(err) => {
if !is_startup_interrupt {
let thread_state = self.thread_state_manager.thread_state(thread_uuid).await;
let mut thread_state = thread_state.lock().await;
thread_state
.pending_interrupts
.retain(|(pending_request_id, _)| pending_request_id != &request_id);
}
let interrupt_target = if is_startup_interrupt {
"startup"
} else {
"turn"
};
self.send_internal_error(
request_id,
format!("failed to interrupt {interrupt_target}: {err}"),
)
.await;
}
}
}
async fn ensure_conversation_listener(
@@ -8536,7 +8643,24 @@ async fn handle_pending_thread_resume_request(
sandbox: sandbox_policy.into(),
reasoning_effort,
};
let token_usage_thread = response.thread.clone();
let token_usage_turn_id = latest_token_usage_turn_id_from_rollout_path(
pending.rollout_path.as_path(),
&token_usage_thread,
)
.await;
outgoing.send_response(request_id, response).await;
// Rejoining a loaded thread has the same UI contract as a cold resume, but
// uses the live conversation state instead of reconstructing a new session.
send_thread_token_usage_update_to_connection(
outgoing,
connection_id,
conversation_id,
&token_usage_thread,
conversation.as_ref(),
token_usage_turn_id,
)
.await;
outgoing
.replay_requests_to_connection_for_thread(connection_id, conversation_id)
.await;
@@ -8834,9 +8958,7 @@ fn plugin_skills_to_info(
.collect()
}
fn plugin_interface_to_info(
interface: codex_core::plugins::PluginManifestInterface,
) -> PluginInterface {
fn plugin_interface_to_info(interface: PluginManifestInterface) -> PluginInterface {
PluginInterface {
display_name: interface.display_name,
short_description: interface.short_description,
@@ -0,0 +1,133 @@
//! Replays persisted token usage snapshots when a client attaches to an existing thread.
//!
//! The message processor decides when replay is allowed and preserves JSON-RPC response
//! ordering. This module owns notification construction and the attribution rules that
//! map the latest persisted `TokenCount` back to a v2 turn id.
//!
//! Rollout histories can contain explicit turn ids or generated turn ids. When explicit
//! ids do not match the rebuilt thread, replay falls back to the active turn position at
//! the time the `TokenCount` was persisted so the notification still targets the
//! corresponding rebuilt turn.
use std::path::Path;
use std::sync::Arc;
use codex_app_server_protocol::ServerNotification;
use codex_app_server_protocol::Thread;
use codex_app_server_protocol::ThreadHistoryBuilder;
use codex_app_server_protocol::ThreadTokenUsage;
use codex_app_server_protocol::ThreadTokenUsageUpdatedNotification;
use codex_app_server_protocol::TurnStatus;
use codex_core::CodexThread;
use codex_protocol::ThreadId;
use codex_protocol::protocol::EventMsg;
use codex_protocol::protocol::RolloutItem;
use crate::codex_message_processor::read_rollout_items_from_rollout;
use crate::outgoing_message::ConnectionId;
use crate::outgoing_message::OutgoingMessageSender;
/// Sends a restored token usage update to the connection that attached to a thread.
///
/// This is lifecycle replay rather than a model event: the rollout already contains
/// the original `TokenCount`, and emitting through `send_event` here would duplicate
/// persisted usage records. Keeping this helper connection-scoped also avoids
/// surprising other subscribers with a historical usage update while they may be
/// rendering live turn events.
pub(super) async fn send_thread_token_usage_update_to_connection(
outgoing: &Arc<OutgoingMessageSender>,
connection_id: ConnectionId,
thread_id: ThreadId,
thread: &Thread,
conversation: &CodexThread,
token_usage_turn_id: Option<String>,
) {
let Some(info) = conversation.token_usage_info().await else {
return;
};
let notification = ThreadTokenUsageUpdatedNotification {
thread_id: thread_id.to_string(),
turn_id: token_usage_turn_id.unwrap_or_else(|| latest_token_usage_turn_id(thread)),
token_usage: ThreadTokenUsage::from(info),
};
outgoing
.send_server_notification_to_connections(
&[connection_id],
ServerNotification::ThreadTokenUsageUpdated(notification),
)
.await;
}
pub(super) async fn latest_token_usage_turn_id_for_thread_path(thread: &Thread) -> Option<String> {
let rollout_path = thread.path.as_deref()?;
latest_token_usage_turn_id_from_rollout_path(rollout_path, thread).await
}
pub(super) async fn latest_token_usage_turn_id_from_rollout_path(
rollout_path: &Path,
thread: &Thread,
) -> Option<String> {
let rollout_items = read_rollout_items_from_rollout(rollout_path).await.ok()?;
latest_token_usage_turn_id_from_rollout_items(&rollout_items, thread)
}
/// Identifies the turn that was active when a `TokenCount` record appeared.
///
/// The id is preferred when it still appears in the rebuilt thread. The position is a
/// fallback for histories whose implicit turn ids are regenerated during reconstruction.
struct TokenUsageTurnOwner {
id: String,
position: Option<usize>,
}
pub(super) fn latest_token_usage_turn_id_from_rollout_items(
rollout_items: &[RolloutItem],
thread: &Thread,
) -> Option<String> {
let owner = latest_token_usage_turn_owner_from_rollout_items(rollout_items)?;
if thread.turns.iter().any(|turn| turn.id == owner.id) {
return Some(owner.id);
}
owner
.position
.and_then(|position| thread.turns.get(position))
.map(|turn| turn.id.clone())
}
fn latest_token_usage_turn_owner_from_rollout_items(
rollout_items: &[RolloutItem],
) -> Option<TokenUsageTurnOwner> {
let mut builder = ThreadHistoryBuilder::new();
let mut token_usage_turn_owner = None;
for item in rollout_items {
if matches!(item, RolloutItem::EventMsg(EventMsg::TokenCount(_))) {
token_usage_turn_owner =
builder
.active_turn_snapshot()
.map(|turn| TokenUsageTurnOwner {
id: turn.id,
position: builder.active_turn_position(),
});
}
builder.handle_rollout_item(item);
}
token_usage_turn_owner
}
/// Chooses a fallback turn id that should own a replayed token usage update.
///
/// Normal replay derives the owner from the rollout position of the latest
/// `TokenCount` event. This fallback only preserves a stable wire shape for
/// unusual histories where that rollout information cannot be read.
fn latest_token_usage_turn_id(thread: &Thread) -> String {
thread
.turns
.iter()
.rev()
.find(|turn| matches!(turn.status, TurnStatus::Completed | TurnStatus::Failed))
.or_else(|| thread.turns.last())
.map(|turn| turn.id.clone())
.unwrap_or_default()
}
+2 -2
View File
@@ -27,8 +27,8 @@ use codex_core::config_loader::LoaderOverrides;
use codex_core::config_loader::ResidencyRequirement as CoreResidencyRequirement;
use codex_core::config_loader::SandboxModeRequirement as CoreSandboxModeRequirement;
use codex_core::plugins::PluginId;
use codex_core::plugins::collect_plugin_enabled_candidates;
use codex_core::plugins::installed_plugin_telemetry_metadata;
use codex_core_plugins::loader::installed_plugin_telemetry_metadata;
use codex_core_plugins::toggles::collect_plugin_enabled_candidates;
use codex_features::canonical_feature_for_key;
use codex_features::feature_for_key;
use codex_protocol::config_types::WebSearchMode;
@@ -37,6 +37,7 @@ impl ExternalAgentConfigApi {
include_home: params.include_home,
cwds: params.cwds,
})
.await
.map_err(map_io_error)?;
Ok(ExternalAgentConfigDetectResponse {
+1 -2
View File
@@ -725,8 +725,6 @@ impl MessageProcessor {
session: Arc<ConnectionSessionState>,
request_context: RequestContext,
) {
let connection_id = connection_request_id.connection_id;
if !session.initialized() {
let error = JSONRPCErrorError {
code: INVALID_REQUEST_ERROR_CODE,
@@ -748,6 +746,7 @@ impl MessageProcessor {
self.outgoing.send_error(connection_request_id, error).await;
return;
}
let connection_id = connection_request_id.connection_id;
if self.config.features.enabled(Feature::GeneralAnalytics)
&& let ClientRequest::TurnStart { request_id, .. }
| ClientRequest::TurnSteer { request_id, .. } = &codex_request
@@ -4,7 +4,6 @@ mod protocol;
mod websocket;
use crate::transport::remote_control::websocket::RemoteControlWebsocket;
use crate::transport::remote_control::websocket::load_remote_control_auth;
pub use self::protocol::ClientId;
use self::protocol::ServerEvent;
@@ -59,9 +58,6 @@ pub(crate) async fn start_remote_control(
} else {
None
};
if initial_enabled {
validate_remote_control_auth(&auth_manager).await?;
}
let (enabled_tx, enabled_rx) = watch::channel(initial_enabled);
let join_handle = tokio::spawn(async move {
@@ -86,15 +82,5 @@ pub(crate) async fn start_remote_control(
))
}
pub(crate) async fn validate_remote_control_auth(
auth_manager: &Arc<AuthManager>,
) -> io::Result<()> {
match load_remote_control_auth(auth_manager).await {
Ok(_) => Ok(()),
Err(err) if err.kind() == io::ErrorKind::WouldBlock => Ok(()),
Err(err) => Err(err),
}
}
#[cfg(test)]
mod tests;
@@ -480,6 +480,44 @@ async fn remote_control_start_allows_remote_control_invalid_url_when_disabled()
.expect("remote control task should join");
}
#[tokio::test]
async fn remote_control_start_allows_missing_auth_when_enabled() {
let listener = TcpListener::bind("127.0.0.1:0")
.await
.expect("listener should bind");
let remote_control_url = remote_control_url_for_listener(&listener);
let codex_home = TempDir::new().expect("temp dir should create");
let auth_manager = AuthManager::shared(
codex_home.path().to_path_buf(),
/*enable_codex_api_key_env*/ false,
AuthCredentialsStoreMode::File,
);
let (transport_event_tx, _transport_event_rx) =
mpsc::channel::<TransportEvent>(CHANNEL_CAPACITY);
let shutdown_token = CancellationToken::new();
let (remote_task, _remote_handle) = start_remote_control(
remote_control_url,
/*state_db*/ None,
auth_manager,
transport_event_tx,
shutdown_token.clone(),
/*app_server_client_name_rx*/ None,
/*initial_enabled*/ true,
)
.await
.expect("remote control should start before ChatGPT auth is available");
timeout(Duration::from_millis(100), listener.accept())
.await
.expect_err("remote control should wait for auth before connecting");
shutdown_token.cancel();
timeout(Duration::from_secs(1), remote_task)
.await
.expect("remote control task should stop")
.expect("remote control task should join");
}
#[tokio::test]
async fn remote_control_handle_set_enabled_stops_and_restarts_connections() {
let listener = TcpListener::bind("127.0.0.1:0")
@@ -933,6 +933,13 @@ mod tests {
use tokio::time::timeout;
use tokio_tungstenite::accept_async;
// Windows Bazel CI can take longer than a few seconds for the websocket
// client connection attempt to reach the local test listener.
#[cfg(windows)]
const TEST_HTTP_ACCEPT_TIMEOUT: Duration = Duration::from_secs(30);
#[cfg(not(windows))]
const TEST_HTTP_ACCEPT_TIMEOUT: Duration = Duration::from_secs(5);
async fn remote_control_state_runtime(codex_home: &TempDir) -> Arc<StateRuntime> {
StateRuntime::init(codex_home.path().to_path_buf(), "test-provider".to_string())
.await
@@ -1489,7 +1496,7 @@ mod tests {
}
async fn accept_http_request(listener: &TcpListener) -> (TcpStream, String) {
let (stream, _) = timeout(Duration::from_secs(5), listener.accept())
let (stream, _) = timeout(TEST_HTTP_ACCEPT_TIMEOUT, listener.accept())
.await
.expect("HTTP request should arrive in time")
.expect("listener accept should succeed");
+1
View File
@@ -40,6 +40,7 @@ pub use responses::create_shell_command_sse_response;
pub use rollout::create_fake_rollout;
pub use rollout::create_fake_rollout_with_source;
pub use rollout::create_fake_rollout_with_text_elements;
pub use rollout::create_fake_rollout_with_token_usage;
pub use rollout::rollout_path;
use serde::de::DeserializeOwned;
@@ -1,9 +1,13 @@
use anyhow::Result;
use codex_protocol::ThreadId;
use codex_protocol::protocol::EventMsg;
use codex_protocol::protocol::GitInfo;
use codex_protocol::protocol::SessionMeta;
use codex_protocol::protocol::SessionMetaLine;
use codex_protocol::protocol::SessionSource;
use codex_protocol::protocol::TokenCountEvent;
use codex_protocol::protocol::TokenUsage;
use codex_protocol::protocol::TokenUsageInfo;
use serde_json::json;
use std::fs;
use std::fs::FileTimes;
@@ -50,6 +54,61 @@ pub fn create_fake_rollout(
)
}
/// Creates a minimal rollout whose history includes a persisted token usage event.
///
/// Resume and fork tests use this fixture to verify lifecycle replay of restored
/// usage without starting a model turn. The exact token values are intentionally
/// non-zero and asymmetric so assertions catch swapped total/last fields and
/// dropped cached or reasoning counters.
pub fn create_fake_rollout_with_token_usage(
codex_home: &Path,
filename_ts: &str,
meta_rfc3339: &str,
preview: &str,
model_provider: Option<&str>,
) -> Result<String> {
let thread_id = create_fake_rollout(
codex_home,
filename_ts,
meta_rfc3339,
preview,
model_provider,
/*git_info*/ None,
)?;
let payload = serde_json::to_value(EventMsg::TokenCount(TokenCountEvent {
info: Some(TokenUsageInfo {
total_token_usage: TokenUsage {
input_tokens: 120,
cached_input_tokens: 20,
output_tokens: 30,
reasoning_output_tokens: 10,
total_tokens: 150,
},
last_token_usage: TokenUsage {
input_tokens: 70,
cached_input_tokens: 10,
output_tokens: 20,
reasoning_output_tokens: 5,
total_tokens: 90,
},
model_context_window: Some(200_000),
}),
rate_limits: None,
}))?;
let file_path = rollout_path(codex_home, filename_ts, &thread_id);
let line = json!({
"timestamp": meta_rfc3339,
"type": "event_msg",
"payload": payload
})
.to_string();
fs::write(
&file_path,
format!("{}{}\n", fs::read_to_string(&file_path)?, line),
)?;
Ok(thread_id)
}
/// Create a minimal rollout file with an explicit session source.
pub fn create_fake_rollout_with_source(
codex_home: &Path,
+9 -3
View File
@@ -24,7 +24,9 @@ use wiremock::ResponseTemplate;
use wiremock::matchers::method;
use wiremock::matchers::path;
const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
// Bazel CI can spend tens of seconds starting app-server subprocesses or
// processing auth RPCs under load.
const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(60);
fn create_config_toml_custom_provider(
codex_home: &Path,
@@ -349,6 +351,8 @@ async fn get_auth_status_omits_token_after_proactive_refresh_failure() -> Result
)?;
let server = MockServer::start().await;
// App-server startup may proactively read stale auth before this test sends
// getAuthStatus; require the refresh path without depending on that race.
Mock::given(method("POST"))
.and(path("/oauth/token"))
.respond_with(ResponseTemplate::new(401).set_body_json(serde_json::json!({
@@ -356,7 +360,7 @@ async fn get_auth_status_omits_token_after_proactive_refresh_failure() -> Result
"code": "refresh_token_reused"
}
})))
.expect(2)
.expect(1..=2)
.mount(&server)
.await;
@@ -416,6 +420,8 @@ async fn get_auth_status_returns_token_after_proactive_refresh_recovery() -> Res
)?;
let server = MockServer::start().await;
// App-server startup may proactively read stale auth before this test sends
// getAuthStatus; require the refresh path without depending on that race.
Mock::given(method("POST"))
.and(path("/oauth/token"))
.respond_with(ResponseTemplate::new(401).set_body_json(serde_json::json!({
@@ -423,7 +429,7 @@ async fn get_auth_status_returns_token_after_proactive_refresh_recovery() -> Res
"code": "refresh_token_reused"
}
})))
.expect(2)
.expect(1..=2)
.mount(&server)
.await;
@@ -11,6 +11,11 @@ use std::path::Path;
use tempfile::TempDir;
use tokio::time::timeout;
// macOS arm64 and Windows Bazel CI can spend tens of seconds in app-server
// startup before the initialize response or fuzzy-search notifications arrive.
#[cfg(any(target_os = "macos", windows))]
const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(60);
#[cfg(not(any(target_os = "macos", windows)))]
const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
const SHORT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_millis(500);
const STOP_GRACE_PERIOD: std::time::Duration = std::time::Duration::from_millis(250);
@@ -56,7 +56,9 @@ use tokio::net::TcpListener;
use tokio::task::JoinHandle;
use tokio::time::timeout;
const DEFAULT_TIMEOUT: Duration = Duration::from_secs(10);
// Bazel CI can spend tens of seconds starting app-server subprocesses or
// processing app-list RPCs under load.
const DEFAULT_TIMEOUT: Duration = Duration::from_secs(60);
#[tokio::test]
async fn list_apps_returns_empty_when_connectors_disabled() -> Result<()> {
@@ -18,7 +18,9 @@ use std::path::Path;
use tempfile::TempDir;
use tokio::time::timeout;
const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
// Bazel CI can spend tens of seconds starting app-server subprocesses or
// processing turn RPCs under load.
const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(60);
#[tokio::test]
async fn turn_start_forwards_client_metadata_to_responses_request_v2() -> Result<()> {
@@ -21,7 +21,9 @@ use pretty_assertions::assert_eq;
use tempfile::TempDir;
use tokio::time::timeout;
const DEFAULT_TIMEOUT: Duration = Duration::from_secs(10);
// Bazel CI can spend tens of seconds starting app-server subprocesses or
// processing list RPCs under load.
const DEFAULT_TIMEOUT: Duration = Duration::from_secs(60);
/// Confirms the server returns the default collaboration mode presets in a stable order.
#[tokio::test]
@@ -38,6 +38,11 @@ use std::collections::BTreeMap;
use tempfile::TempDir;
use tokio::time::timeout;
// macOS and Windows Bazel CI can spend tens of seconds starting app-server
// subprocesses or processing test RPCs under load.
#[cfg(any(target_os = "macos", windows))]
const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(60);
#[cfg(not(any(target_os = "macos", windows)))]
const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
const AUTO_COMPACT_LIMIT: i64 = 1_000;
const COMPACT_PROMPT: &str = "Summarize the conversation.";
@@ -33,7 +33,9 @@ use serde_json::json;
use tempfile::TempDir;
use tokio::time::timeout;
const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
// Bazel CI can spend tens of seconds starting app-server subprocesses or
// processing config RPCs under load.
const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(60);
fn write_config(codex_home: &TempDir, contents: &str) -> Result<()> {
Ok(std::fs::write(
@@ -47,6 +47,12 @@ use tokio_tungstenite::tungstenite::http::HeaderValue;
use tokio_tungstenite::tungstenite::http::header::AUTHORIZATION;
use tokio_tungstenite::tungstenite::http::header::ORIGIN;
// macOS and Windows CI can spend tens of seconds starting the app-server test
// binary under Bazel before it accepts JSON-RPC or reports its websocket bind
// address.
#[cfg(any(target_os = "macos", windows))]
pub(super) const DEFAULT_READ_TIMEOUT: Duration = Duration::from_secs(60);
#[cfg(not(any(target_os = "macos", windows)))]
pub(super) const DEFAULT_READ_TIMEOUT: Duration = Duration::from_secs(10);
pub(super) type WsClient = WebSocketStream<MaybeTlsStream<tokio::net::TcpStream>>;
@@ -399,7 +405,7 @@ pub(super) async fn spawn_websocket_server_with_args(
.take()
.context("failed to capture websocket app-server stderr")?;
let mut stderr_reader = BufReader::new(stderr).lines();
let deadline = Instant::now() + Duration::from_secs(10);
let deadline = Instant::now() + DEFAULT_READ_TIMEOUT;
let bind_addr = loop {
let line = timeout(
deadline.saturating_duration_since(Instant::now()),
@@ -457,7 +463,7 @@ pub(super) async fn connect_websocket_with_bearer(
) -> Result<WsClient> {
let url = format!("ws://{}", connectable_bind_addr(bind_addr));
let request = websocket_request(url.as_str(), bearer_token, /*origin*/ None)?;
let deadline = Instant::now() + Duration::from_secs(10);
let deadline = Instant::now() + DEFAULT_READ_TIMEOUT;
loop {
match connect_async(request.clone()).await {
Ok((stream, _response)) => return Ok(stream),
@@ -524,7 +530,7 @@ async fn run_websocket_server_to_completion_with_args(
.stderr(Stdio::piped())
.env("CODEX_HOME", codex_home)
.env("RUST_LOG", "debug");
timeout(Duration::from_secs(10), cmd.output())
timeout(DEFAULT_READ_TIMEOUT, cmd.output())
.await
.context("timed out waiting for websocket app-server to exit")?
.context("failed to run websocket app-server")
@@ -536,7 +542,7 @@ async fn http_get(
path: &str,
) -> Result<reqwest::Response> {
let connectable_bind_addr = connectable_bind_addr(bind_addr);
let deadline = Instant::now() + Duration::from_secs(10);
let deadline = Instant::now() + DEFAULT_READ_TIMEOUT;
loop {
match client
.get(format!("http://{connectable_bind_addr}{path}"))
@@ -34,6 +34,11 @@ use tempfile::TempDir;
use tokio::time::timeout;
use wiremock::MockServer;
// macOS and Windows Bazel CI can spend tens of seconds starting app-server
// subprocesses or processing test RPCs under load.
#[cfg(any(target_os = "macos", windows))]
const DEFAULT_READ_TIMEOUT: Duration = Duration::from_secs(60);
#[cfg(not(any(target_os = "macos", windows)))]
const DEFAULT_READ_TIMEOUT: Duration = Duration::from_secs(10);
/// Ensures dynamic tool specs are serialized into the model request payload.
+5
View File
@@ -27,6 +27,11 @@ use std::os::unix::fs::symlink;
#[cfg(unix)]
use std::process::Command;
// macOS and Windows Bazel CI can spend tens of seconds starting app-server
// subprocesses or processing test RPCs under load.
#[cfg(any(target_os = "macos", windows))]
const DEFAULT_READ_TIMEOUT: Duration = Duration::from_secs(60);
#[cfg(not(any(target_os = "macos", windows)))]
const DEFAULT_READ_TIMEOUT: Duration = Duration::from_secs(10);
async fn initialized_mcp(codex_home: &TempDir) -> Result<McpProcess> {
@@ -7,7 +7,11 @@ use app_test_support::McpProcess;
use app_test_support::to_response;
use app_test_support::write_chatgpt_auth;
use axum::Router;
use codex_app_server_protocol::JSONRPCError;
use codex_app_server::in_process;
use codex_app_server::in_process::InProcessStartArgs;
use codex_app_server_protocol::ClientInfo;
use codex_app_server_protocol::ClientRequest;
use codex_app_server_protocol::InitializeParams;
use codex_app_server_protocol::JSONRPCResponse;
use codex_app_server_protocol::McpResourceContent;
use codex_app_server_protocol::McpResourceReadParams;
@@ -15,7 +19,14 @@ use codex_app_server_protocol::McpResourceReadResponse;
use codex_app_server_protocol::RequestId;
use codex_app_server_protocol::ThreadStartParams;
use codex_app_server_protocol::ThreadStartResponse;
use codex_arg0::Arg0DispatchPaths;
use codex_config::types::AuthCredentialsStoreMode;
use codex_core::config::ConfigBuilder;
use codex_core::config_loader::CloudRequirementsLoader;
use codex_core::config_loader::LoaderOverrides;
use codex_exec_server::EnvironmentManager;
use codex_feedback::CodexFeedback;
use codex_protocol::protocol::SessionSource;
use core_test_support::responses;
use pretty_assertions::assert_eq;
use rmcp::handler::server::ServerHandler;
@@ -149,24 +160,57 @@ stream_max_retries = 0
#[tokio::test]
async fn mcp_resource_read_returns_error_for_unknown_thread() -> Result<()> {
let codex_home = TempDir::new()?;
let mut mcp = McpProcess::new(codex_home.path()).await?;
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
let request_id = mcp
.send_mcp_resource_read_request(McpResourceReadParams {
thread_id: "00000000-0000-4000-8000-000000000000".to_string(),
server: "codex_apps".to_string(),
uri: TEST_RESOURCE_URI.to_string(),
})
let loader_overrides = LoaderOverrides::without_managed_config_for_tests();
let config = ConfigBuilder::default()
.codex_home(codex_home.path().to_path_buf())
.fallback_cwd(Some(codex_home.path().to_path_buf()))
.loader_overrides(loader_overrides.clone())
.build()
.await?;
let error: JSONRPCError = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_error_message(RequestId::Integer(request_id)),
)
.await??;
// This negative-path test does not need the stdio subprocess; keeping it
// in-process avoids child-process teardown timing in nextest leak detection.
let client = in_process::start(InProcessStartArgs {
arg0_paths: Arg0DispatchPaths::default(),
config: Arc::new(config),
cli_overrides: Vec::new(),
loader_overrides,
cloud_requirements: CloudRequirementsLoader::default(),
feedback: CodexFeedback::new(),
log_db: None,
environment_manager: Arc::new(EnvironmentManager::new(/*exec_server_url*/ None)),
config_warnings: Vec::new(),
session_source: SessionSource::Cli,
enable_codex_api_key_env: false,
initialize: InitializeParams {
client_info: ClientInfo {
name: "codex-app-server-tests".to_string(),
title: None,
version: "0.1.0".to_string(),
},
capabilities: None,
},
channel_capacity: in_process::DEFAULT_IN_PROCESS_CHANNEL_CAPACITY,
})
.await?;
let response = client
.request(ClientRequest::McpResourceRead {
request_id: RequestId::Integer(1),
params: McpResourceReadParams {
thread_id: "00000000-0000-4000-8000-000000000000".to_string(),
server: "codex_apps".to_string(),
uri: TEST_RESOURCE_URI.to_string(),
},
})
.await;
client.shutdown().await?;
let error = match response? {
Ok(result) => anyhow::bail!("expected thread-not-found error, got response: {result:?}"),
Err(error) => error,
};
assert!(
error.error.message.contains("thread not found"),
error.message.contains("thread not found"),
"expected thread-not-found error, got: {error:?}"
);
@@ -51,7 +51,9 @@ use wiremock::matchers::header;
use wiremock::matchers::method;
use wiremock::matchers::path;
const DEFAULT_TIMEOUT: Duration = Duration::from_secs(10);
// Plugin install tests wait on connector discovery after the install response path
// starts, which is noticeably slower on Windows CI.
const DEFAULT_TIMEOUT: Duration = Duration::from_secs(60);
#[tokio::test]
async fn plugin_install_rejects_relative_marketplace_paths() -> Result<()> {
@@ -30,9 +30,12 @@ use wiremock::matchers::method;
use wiremock::matchers::path;
use wiremock::matchers::query_param;
const DEFAULT_TIMEOUT: Duration = Duration::from_secs(10);
// These tests start full app-server processes and can also run plugin startup warmers.
const DEFAULT_TIMEOUT: Duration = Duration::from_secs(60);
const TEST_CURATED_PLUGIN_SHA: &str = "0123456789abcdef0123456789abcdef01234567";
const STARTUP_REMOTE_PLUGIN_SYNC_MARKER_FILE: &str = ".tmp/app-server-remote-plugin-sync-v1";
const ALTERNATE_MARKETPLACE_RELATIVE_PATH: &str = ".claude-plugin/marketplace.json";
const ALTERNATE_PLUGIN_MANIFEST_RELATIVE_PATH: &str = ".claude-plugin/plugin.json";
fn write_plugins_enabled_config(codex_home: &std::path::Path) -> std::io::Result<()> {
std::fs::write(
@@ -245,6 +248,138 @@ async fn plugin_list_keeps_valid_marketplaces_when_another_marketplace_fails_to_
Ok(())
}
#[tokio::test]
async fn plugin_list_uses_alternate_discoverable_manifest_and_keeps_undiscoverable_plugins()
-> Result<()> {
let codex_home = TempDir::new()?;
let repo_root = TempDir::new()?;
let valid_plugin_root = repo_root.path().join("plugins/valid-plugin");
std::fs::create_dir_all(repo_root.path().join(".git"))?;
std::fs::create_dir_all(
repo_root
.path()
.join(ALTERNATE_MARKETPLACE_RELATIVE_PATH)
.parent()
.unwrap(),
)?;
std::fs::create_dir_all(
valid_plugin_root
.join(ALTERNATE_PLUGIN_MANIFEST_RELATIVE_PATH)
.parent()
.unwrap(),
)?;
write_plugins_enabled_config(codex_home.path())?;
let marketplace_path =
AbsolutePathBuf::try_from(repo_root.path().join(ALTERNATE_MARKETPLACE_RELATIVE_PATH))?;
let valid_plugin_path = AbsolutePathBuf::try_from(valid_plugin_root.clone())?;
std::fs::write(
marketplace_path.as_path(),
r#"{
"name": "alternate-marketplace",
"plugins": [
{
"name": "valid-plugin",
"source": "./plugins/valid-plugin"
},
{
"name": "missing-plugin",
"source": "./plugins/missing-plugin"
}
]
}"#,
)?;
std::fs::write(
valid_plugin_root.join(ALTERNATE_PLUGIN_MANIFEST_RELATIVE_PATH),
r#"{
"name": "valid-plugin",
"interface": {
"displayName": "Valid Plugin"
}
}"#,
)?;
let home = codex_home.path().to_string_lossy().into_owned();
let mut mcp = McpProcess::new_with_env(
codex_home.path(),
&[
("HOME", Some(home.as_str())),
("USERPROFILE", Some(home.as_str())),
],
)
.await?;
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
let request_id = mcp
.send_plugin_list_request(PluginListParams {
cwds: Some(vec![AbsolutePathBuf::try_from(repo_root.path())?]),
force_remote_sync: false,
})
.await?;
let response: JSONRPCResponse = timeout(
DEFAULT_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
)
.await??;
let response: PluginListResponse = to_response(response)?;
assert_eq!(
response.marketplaces,
vec![PluginMarketplaceEntry {
name: "alternate-marketplace".to_string(),
path: marketplace_path,
interface: None,
plugins: vec![
PluginSummary {
id: "valid-plugin@alternate-marketplace".to_string(),
name: "valid-plugin".to_string(),
source: PluginSource::Local {
path: valid_plugin_path,
},
installed: false,
enabled: false,
install_policy: PluginInstallPolicy::Available,
auth_policy: PluginAuthPolicy::OnInstall,
interface: Some(codex_app_server_protocol::PluginInterface {
display_name: Some("Valid Plugin".to_string()),
short_description: None,
long_description: None,
developer_name: None,
category: None,
capabilities: Vec::new(),
website_url: None,
privacy_policy_url: None,
terms_of_service_url: None,
default_prompt: None,
brand_color: None,
composer_icon: None,
logo: None,
screenshots: Vec::new(),
}),
},
PluginSummary {
id: "missing-plugin@alternate-marketplace".to_string(),
name: "missing-plugin".to_string(),
source: PluginSource::Local {
path: AbsolutePathBuf::try_from(
repo_root.path().join("plugins/missing-plugin"),
)?,
},
installed: false,
enabled: false,
install_policy: PluginInstallPolicy::Available,
auth_policy: PluginAuthPolicy::OnInstall,
interface: None,
},
],
}]
);
assert!(response.marketplace_load_errors.is_empty());
Ok(())
}
#[tokio::test]
async fn plugin_list_accepts_omitted_cwds() -> Result<()> {
let codex_home = TempDir::new()?;
@@ -512,11 +512,7 @@ async fn plugin_read_returns_invalid_request_when_plugin_manifest_is_missing() -
.await??;
assert_eq!(err.error.code, -32600);
assert!(
err.error
.message
.contains("missing or invalid .codex-plugin/plugin.json")
);
assert!(err.error.message.contains("missing or invalid plugin.json"));
Ok(())
}
@@ -72,6 +72,7 @@ use wiremock::matchers::path;
use wiremock::matchers::path_regex;
const DEFAULT_TIMEOUT: Duration = Duration::from_secs(10);
const DELEGATED_SHELL_TOOL_TIMEOUT_MS: u64 = 30_000;
const STARTUP_CONTEXT_HEADER: &str = "Startup context from Codex.";
const V2_STEERING_ACKNOWLEDGEMENT: &str =
"This was sent to steer the previous background agent task.";
@@ -1141,7 +1142,7 @@ async fn realtime_webrtc_start_emits_sdp_notification() -> Result<()> {
Some("multipart/form-data; boundary=codex-realtime-call-boundary")
);
let body = String::from_utf8(request.body).context("multipart body should be utf-8")?;
let session = r#"{"tool_choice":"auto","type":"realtime","model":"gpt-realtime-1.5","instructions":"backend prompt\n\nstartup context","output_modalities":["audio"],"audio":{"input":{"format":{"type":"audio/pcm","rate":24000},"noise_reduction":{"type":"near_field"},"turn_detection":{"type":"server_vad","interrupt_response":true,"create_response":true}},"output":{"format":{"type":"audio/pcm","rate":24000},"voice":"marin"}},"tools":[{"type":"function","name":"background_agent","description":"Send a user request to the background agent. Use this as the default action. Do not rephrase the user's ask or rewrite it in your own words; pass along the user's own words. If the background agent is idle, this starts a new task and returns the final result to the user. If the background agent is already working on a task, this sends the request as guidance to steer that previous task. If the user asks to do something next, later, after this, or once current work finishes, call this tool so the work is actually queued instead of merely promising to do it later.","parameters":{"type":"object","properties":{"prompt":{"type":"string","description":"The user request to delegate to the background agent."}},"required":["prompt"],"additionalProperties":false}}]}"#;
let session = r#"{"tool_choice":"auto","type":"realtime","model":"gpt-realtime-1.5","instructions":"backend prompt\n\nstartup context","output_modalities":["audio"],"audio":{"input":{"format":{"type":"audio/pcm","rate":24000},"noise_reduction":{"type":"near_field"},"turn_detection":{"type":"server_vad","interrupt_response":true,"create_response":true,"silence_duration_ms":500}},"output":{"format":{"type":"audio/pcm","rate":24000},"voice":"marin"}},"tools":[{"type":"function","name":"background_agent","description":"Send a user request to the background agent. Use this as the default action. Do not rephrase the user's ask or rewrite it in your own words; pass along the user's own words. If the background agent is idle, this starts a new task and returns the final result to the user. If the background agent is already working on a task, this sends the request as guidance to steer that previous task. If the user asks to do something next, later, after this, or once current work finishes, call this tool so the work is actually queued instead of merely promising to do it later.","parameters":{"type":"object","properties":{"prompt":{"type":"string","description":"The user request to delegate to the background agent."}},"required":["prompt"],"additionalProperties":false}}]}"#;
let session = normalized_json_string(session)?;
assert_eq!(
body,
@@ -1717,7 +1718,9 @@ async fn webrtc_v2_tool_call_delegated_turn_can_execute_shell_tool() -> Result<(
create_shell_command_sse_response(
realtime_tool_ok_command(),
/*workdir*/ None,
Some(5000),
// Windows CI can spend several seconds starting the nested PowerShell command. This
// test verifies delegated shell-tool plumbing, not timeout enforcement.
Some(DELEGATED_SHELL_TOOL_TIMEOUT_MS),
"shell_call",
)?,
create_final_assistant_message_sse_response("shell tool finished")?,
@@ -2,6 +2,7 @@ use anyhow::Result;
use app_test_support::ChatGptAuthFixture;
use app_test_support::McpProcess;
use app_test_support::create_fake_rollout;
use app_test_support::create_fake_rollout_with_token_usage;
use app_test_support::create_mock_responses_server_repeating_assistant;
use app_test_support::to_response;
use app_test_support::write_chatgpt_auth;
@@ -9,6 +10,7 @@ use codex_app_server_protocol::JSONRPCError;
use codex_app_server_protocol::JSONRPCMessage;
use codex_app_server_protocol::JSONRPCResponse;
use codex_app_server_protocol::RequestId;
use codex_app_server_protocol::ServerNotification;
use codex_app_server_protocol::SessionSource;
use codex_app_server_protocol::ThreadForkParams;
use codex_app_server_protocol::ThreadForkResponse;
@@ -43,6 +45,9 @@ use super::analytics::enable_analytics_capture;
use super::analytics::thread_initialized_event;
use super::analytics::wait_for_analytics_payload;
#[cfg(windows)]
const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(25);
#[cfg(not(windows))]
const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
#[tokio::test]
@@ -183,6 +188,59 @@ async fn thread_fork_creates_new_thread_and_emits_started() -> Result<()> {
Ok(())
}
#[tokio::test]
async fn thread_fork_emits_restored_token_usage_before_next_turn() -> Result<()> {
let server = create_mock_responses_server_repeating_assistant("Done").await;
let codex_home = TempDir::new()?;
create_config_toml(codex_home.path(), &server.uri())?;
let conversation_id = create_fake_rollout_with_token_usage(
codex_home.path(),
"2025-01-05T12-00-00",
"2025-01-05T12:00:00Z",
"Saved user message",
Some("mock_provider"),
)?;
let mut mcp = McpProcess::new(codex_home.path()).await?;
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
let fork_id = mcp
.send_thread_fork_request(ThreadForkParams {
thread_id: conversation_id,
..Default::default()
})
.await?;
let fork_resp: JSONRPCResponse = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(fork_id)),
)
.await??;
let ThreadForkResponse { thread, .. } = to_response::<ThreadForkResponse>(fork_resp)?;
let note = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_notification_message("thread/tokenUsage/updated"),
)
.await??;
let parsed: ServerNotification = note.try_into()?;
let ServerNotification::ThreadTokenUsageUpdated(notification) = parsed else {
panic!("expected thread/tokenUsage/updated notification");
};
assert_eq!(notification.thread_id, thread.id);
assert_eq!(notification.turn_id, thread.turns[0].id);
assert_eq!(notification.token_usage.total.total_tokens, 150);
assert_eq!(notification.token_usage.total.input_tokens, 120);
assert_eq!(notification.token_usage.total.cached_input_tokens, 20);
assert_eq!(notification.token_usage.total.output_tokens, 30);
assert_eq!(notification.token_usage.total.reasoning_output_tokens, 10);
assert_eq!(notification.token_usage.last.total_tokens, 90);
assert_eq!(notification.token_usage.model_context_window, Some(200_000));
Ok(())
}
#[tokio::test]
async fn thread_fork_tracks_thread_initialized_analytics() -> Result<()> {
let server = create_mock_responses_server_repeating_assistant("Done").await;
@@ -36,6 +36,9 @@ use std::path::Path;
use tempfile::TempDir;
use tokio::time::timeout;
#[cfg(windows)]
const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(25);
#[cfg(not(windows))]
const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
#[tokio::test]
@@ -3,6 +3,7 @@ use app_test_support::ChatGptAuthFixture;
use app_test_support::McpProcess;
use app_test_support::create_apply_patch_sse_response;
use app_test_support::create_fake_rollout_with_text_elements;
use app_test_support::create_fake_rollout_with_token_usage;
use app_test_support::create_final_assistant_message_sse_response;
use app_test_support::create_mock_responses_server_repeating_assistant;
use app_test_support::create_mock_responses_server_sequence_unchecked;
@@ -23,6 +24,7 @@ use codex_app_server_protocol::JSONRPCResponse;
use codex_app_server_protocol::PatchApplyStatus;
use codex_app_server_protocol::PatchChangeKind;
use codex_app_server_protocol::RequestId;
use codex_app_server_protocol::ServerNotification;
use codex_app_server_protocol::ServerRequest;
use codex_app_server_protocol::SessionSource;
use codex_app_server_protocol::ThreadItem;
@@ -50,6 +52,11 @@ use codex_protocol::protocol::EventMsg;
use codex_protocol::protocol::SessionMeta;
use codex_protocol::protocol::SessionMetaLine;
use codex_protocol::protocol::SessionSource as RolloutSessionSource;
use codex_protocol::protocol::TokenCountEvent;
use codex_protocol::protocol::TokenUsage;
use codex_protocol::protocol::TokenUsageInfo;
use codex_protocol::protocol::TurnAbortReason;
use codex_protocol::protocol::TurnAbortedEvent;
use codex_protocol::protocol::TurnStartedEvent;
use codex_protocol::user_input::ByteRange;
use codex_protocol::user_input::TextElement;
@@ -76,6 +83,9 @@ use super::analytics::enable_analytics_capture;
use super::analytics::thread_initialized_event;
use super::analytics::wait_for_analytics_payload;
#[cfg(windows)]
const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(25);
#[cfg(not(windows))]
const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
const CODEX_5_2_INSTRUCTIONS_TEMPLATE_DEFAULT: &str = "You are Codex, a coding agent based on GPT-5. You and the user share the same workspace and collaborate to achieve the user's goals.";
@@ -275,6 +285,268 @@ async fn thread_resume_returns_rollout_history() -> Result<()> {
Ok(())
}
#[tokio::test]
async fn thread_resume_emits_restored_token_usage_before_next_turn() -> Result<()> {
let server = create_mock_responses_server_repeating_assistant("Done").await;
let codex_home = TempDir::new()?;
create_config_toml(codex_home.path(), &server.uri())?;
let conversation_id = create_fake_rollout_with_token_usage(
codex_home.path(),
"2025-01-05T12-00-00",
"2025-01-05T12:00:00Z",
"Saved user message",
Some("mock_provider"),
)?;
let mut mcp = McpProcess::new(codex_home.path()).await?;
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
let resume_id = mcp
.send_thread_resume_request(ThreadResumeParams {
thread_id: conversation_id,
..Default::default()
})
.await?;
let resume_resp: JSONRPCResponse = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(resume_id)),
)
.await??;
let ThreadResumeResponse { thread, .. } = to_response::<ThreadResumeResponse>(resume_resp)?;
let note = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_notification_message("thread/tokenUsage/updated"),
)
.await??;
let parsed: ServerNotification = note.try_into()?;
let ServerNotification::ThreadTokenUsageUpdated(notification) = parsed else {
panic!("expected thread/tokenUsage/updated notification");
};
assert_eq!(notification.thread_id, thread.id);
assert_eq!(notification.turn_id, thread.turns[0].id);
assert_eq!(notification.token_usage.total.total_tokens, 150);
assert_eq!(notification.token_usage.total.input_tokens, 120);
assert_eq!(notification.token_usage.total.cached_input_tokens, 20);
assert_eq!(notification.token_usage.total.output_tokens, 30);
assert_eq!(notification.token_usage.total.reasoning_output_tokens, 10);
assert_eq!(notification.token_usage.last.total_tokens, 90);
assert_eq!(notification.token_usage.model_context_window, Some(200_000));
Ok(())
}
#[tokio::test]
async fn thread_resume_token_usage_replay_ignores_stale_interrupted_tail_turn() -> Result<()> {
let server = create_mock_responses_server_repeating_assistant("Done").await;
let codex_home = TempDir::new()?;
create_config_toml(codex_home.path(), &server.uri())?;
let filename_ts = "2025-01-05T12-00-00";
let meta_rfc3339 = "2025-01-05T12:00:00Z";
let conversation_id = create_fake_rollout_with_token_usage(
codex_home.path(),
filename_ts,
meta_rfc3339,
"Saved user message",
Some("mock_provider"),
)?;
let rollout_file_path = rollout_path(codex_home.path(), filename_ts, &conversation_id);
let persisted_rollout = std::fs::read_to_string(&rollout_file_path)?;
let stale_turn_id = "incomplete-turn-after-token-usage";
let appended_rollout = [
json!({
"timestamp": meta_rfc3339,
"type": "event_msg",
"payload": serde_json::to_value(EventMsg::TurnStarted(TurnStartedEvent {
turn_id: stale_turn_id.to_string(),
started_at: None,
model_context_window: None,
collaboration_mode_kind: Default::default(),
}))?,
})
.to_string(),
json!({
"timestamp": meta_rfc3339,
"type": "event_msg",
"payload": serde_json::to_value(EventMsg::AgentMessage(AgentMessageEvent {
message: "Still running".to_string(),
phase: None,
memory_citation: None,
}))?,
})
.to_string(),
]
.join("\n");
std::fs::write(
&rollout_file_path,
format!("{persisted_rollout}{appended_rollout}\n"),
)?;
let mut mcp = McpProcess::new(codex_home.path()).await?;
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
let resume_id = mcp
.send_thread_resume_request(ThreadResumeParams {
thread_id: conversation_id,
..Default::default()
})
.await?;
let resume_resp: JSONRPCResponse = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(resume_id)),
)
.await??;
let ThreadResumeResponse { thread, .. } = to_response::<ThreadResumeResponse>(resume_resp)?;
assert_eq!(thread.turns.len(), 2);
assert_eq!(thread.turns[0].status, TurnStatus::Completed);
assert_eq!(thread.turns[1].id, stale_turn_id);
assert_eq!(thread.turns[1].status, TurnStatus::Interrupted);
let note = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_notification_message("thread/tokenUsage/updated"),
)
.await??;
let parsed: ServerNotification = note.try_into()?;
let ServerNotification::ThreadTokenUsageUpdated(notification) = parsed else {
panic!("expected thread/tokenUsage/updated notification");
};
assert_eq!(notification.thread_id, thread.id);
assert_eq!(notification.turn_id, thread.turns[0].id);
assert_ne!(notification.turn_id, stale_turn_id);
assert_eq!(notification.token_usage.total.total_tokens, 150);
assert_eq!(notification.token_usage.last.total_tokens, 90);
Ok(())
}
#[tokio::test]
async fn thread_resume_token_usage_replay_can_belong_to_interrupted_turn() -> Result<()> {
let server = create_mock_responses_server_repeating_assistant("Done").await;
let codex_home = TempDir::new()?;
create_config_toml(codex_home.path(), &server.uri())?;
let filename_ts = "2025-01-05T12-00-00";
let meta_rfc3339 = "2025-01-05T12:00:00Z";
let conversation_id = create_fake_rollout_with_token_usage(
codex_home.path(),
filename_ts,
meta_rfc3339,
"Saved user message",
Some("mock_provider"),
)?;
let rollout_file_path = rollout_path(codex_home.path(), filename_ts, &conversation_id);
let persisted_rollout = std::fs::read_to_string(&rollout_file_path)?;
let interrupted_turn_id = "interrupted-turn-with-token-usage";
let appended_rollout = [
json!({
"timestamp": meta_rfc3339,
"type": "event_msg",
"payload": serde_json::to_value(EventMsg::TurnStarted(TurnStartedEvent {
turn_id: interrupted_turn_id.to_string(),
started_at: None,
model_context_window: None,
collaboration_mode_kind: Default::default(),
}))?,
})
.to_string(),
json!({
"timestamp": meta_rfc3339,
"type": "event_msg",
"payload": serde_json::to_value(EventMsg::AgentMessage(AgentMessageEvent {
message: "Interrupted after usage".to_string(),
phase: None,
memory_citation: None,
}))?,
})
.to_string(),
json!({
"timestamp": meta_rfc3339,
"type": "event_msg",
"payload": serde_json::to_value(EventMsg::TokenCount(TokenCountEvent {
info: Some(TokenUsageInfo {
total_token_usage: TokenUsage {
input_tokens: 180,
cached_input_tokens: 40,
output_tokens: 50,
reasoning_output_tokens: 15,
total_tokens: 230,
},
last_token_usage: TokenUsage {
input_tokens: 90,
cached_input_tokens: 30,
output_tokens: 40,
reasoning_output_tokens: 12,
total_tokens: 130,
},
model_context_window: Some(200_000),
}),
rate_limits: None,
}))?,
})
.to_string(),
json!({
"timestamp": meta_rfc3339,
"type": "event_msg",
"payload": serde_json::to_value(EventMsg::TurnAborted(TurnAbortedEvent {
turn_id: Some(interrupted_turn_id.to_string()),
reason: TurnAbortReason::Interrupted,
completed_at: None,
duration_ms: None,
}))?,
})
.to_string(),
]
.join("\n");
std::fs::write(
&rollout_file_path,
format!("{persisted_rollout}{appended_rollout}\n"),
)?;
let mut mcp = McpProcess::new(codex_home.path()).await?;
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
let resume_id = mcp
.send_thread_resume_request(ThreadResumeParams {
thread_id: conversation_id,
..Default::default()
})
.await?;
let resume_resp: JSONRPCResponse = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(resume_id)),
)
.await??;
let ThreadResumeResponse { thread, .. } = to_response::<ThreadResumeResponse>(resume_resp)?;
assert_eq!(thread.turns.len(), 2);
assert_eq!(thread.turns[0].status, TurnStatus::Completed);
assert_eq!(thread.turns[1].id, interrupted_turn_id);
assert_eq!(thread.turns[1].status, TurnStatus::Interrupted);
let note = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_notification_message("thread/tokenUsage/updated"),
)
.await??;
let parsed: ServerNotification = note.try_into()?;
let ServerNotification::ThreadTokenUsageUpdated(notification) = parsed else {
panic!("expected thread/tokenUsage/updated notification");
};
assert_eq!(notification.thread_id, thread.id);
assert_eq!(notification.turn_id, interrupted_turn_id);
assert_eq!(notification.token_usage.total.total_tokens, 230);
assert_eq!(notification.token_usage.last.total_tokens, 130);
Ok(())
}
#[tokio::test]
async fn thread_resume_prefers_persisted_git_metadata_for_local_threads() -> Result<()> {
let server = create_mock_responses_server_repeating_assistant("Done").await;
@@ -1,6 +1,7 @@
use anyhow::Result;
use app_test_support::ChatGptAuthFixture;
use app_test_support::McpProcess;
use app_test_support::PathBufExt;
use app_test_support::create_mock_responses_server_repeating_assistant;
use app_test_support::to_response;
use app_test_support::write_chatgpt_auth;
@@ -20,6 +21,7 @@ use codex_app_server_protocol::ThreadStatus;
use codex_app_server_protocol::ThreadStatusChangedNotification;
use codex_config::types::AuthCredentialsStoreMode;
use codex_core::config::set_project_trust_level;
use codex_exec_server::LOCAL_FS;
use codex_git_utils::resolve_root_git_project_for_trust;
use codex_login::REFRESH_TOKEN_URL_OVERRIDE_ENV_VAR;
use codex_protocol::config_types::ServiceTier;
@@ -716,10 +718,11 @@ model_reasoning_effort = "high"
assert_eq!(reasoning_effort, Some(ReasoningEffort::High));
let config_toml = std::fs::read_to_string(codex_home.path().join("config.toml"))?;
let trusted_root = resolve_root_git_project_for_trust(workspace.path())
let workspace_abs = workspace.path().to_path_buf().abs();
let trusted_root = resolve_root_git_project_for_trust(LOCAL_FS.as_ref(), &workspace_abs)
.await
.unwrap_or_else(|| workspace.path().to_path_buf());
assert!(config_toml.contains(&persisted_trust_path(&trusted_root)));
.unwrap_or(workspace_abs);
assert!(config_toml.contains(&persisted_trust_path(trusted_root.as_path())));
assert!(config_toml.contains("trust_level = \"trusted\""));
Ok(())
@@ -754,10 +757,11 @@ async fn thread_start_with_nested_git_cwd_trusts_repo_root() -> Result<()> {
.await??;
let config_toml = std::fs::read_to_string(codex_home.path().join("config.toml"))?;
let trusted_root = resolve_root_git_project_for_trust(&nested)
let nested_abs = nested.abs();
let trusted_root = resolve_root_git_project_for_trust(LOCAL_FS.as_ref(), &nested_abs)
.await
.expect("git root should resolve");
assert!(config_toml.contains(&persisted_trust_path(&trusted_root)));
assert!(config_toml.contains(&persisted_trust_path(trusted_root.as_path())));
assert!(!config_toml.contains(&persisted_trust_path(&nested)));
Ok(())
@@ -1,14 +1,15 @@
use anyhow::Context;
use anyhow::Result;
use app_test_support::McpProcess;
use app_test_support::create_final_assistant_message_sse_response;
use app_test_support::create_mock_responses_server_repeating_assistant;
use app_test_support::create_mock_responses_server_sequence_unchecked;
use app_test_support::create_shell_command_sse_response;
use app_test_support::to_response;
use codex_app_server_protocol::DynamicToolCallOutputContentItem;
use codex_app_server_protocol::DynamicToolCallParams;
use codex_app_server_protocol::DynamicToolCallResponse;
use codex_app_server_protocol::DynamicToolSpec;
use codex_app_server_protocol::ItemStartedNotification;
use codex_app_server_protocol::JSONRPCResponse;
use codex_app_server_protocol::RequestId;
use codex_app_server_protocol::ServerRequest;
use codex_app_server_protocol::ThreadItem;
use codex_app_server_protocol::ThreadLoadedListParams;
use codex_app_server_protocol::ThreadLoadedListResponse;
@@ -26,57 +27,14 @@ use codex_app_server_protocol::TurnStartParams;
use codex_app_server_protocol::TurnStartResponse;
use codex_app_server_protocol::UserInput as V2UserInput;
use core_test_support::responses;
use core_test_support::streaming_sse::StreamingSseChunk;
use core_test_support::streaming_sse::start_streaming_sse_server;
use pretty_assertions::assert_eq;
use serde_json::json;
use tempfile::TempDir;
use tokio::time::timeout;
const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
async fn wait_for_responses_request_count_to_stabilize(
server: &wiremock::MockServer,
expected_count: usize,
settle_duration: std::time::Duration,
) -> Result<()> {
timeout(DEFAULT_READ_TIMEOUT, async {
let mut stable_since: Option<tokio::time::Instant> = None;
loop {
let requests = server
.received_requests()
.await
.context("failed to fetch received requests")?;
let responses_request_count = requests
.iter()
.filter(|request| {
request.method == "POST" && request.url.path().ends_with("/responses")
})
.count();
if responses_request_count > expected_count {
anyhow::bail!(
"expected exactly {expected_count} /responses requests, got {responses_request_count}"
);
}
if responses_request_count == expected_count {
match stable_since {
Some(stable_since) if stable_since.elapsed() >= settle_duration => {
return Ok::<(), anyhow::Error>(());
}
None => stable_since = Some(tokio::time::Instant::now()),
Some(_) => {}
}
} else {
stable_since = None;
}
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
}
})
.await??;
Ok(())
}
#[tokio::test]
async fn thread_unsubscribe_keeps_thread_loaded_until_idle_timeout() -> Result<()> {
let server = create_mock_responses_server_repeating_assistant("Done").await;
@@ -128,14 +86,10 @@ async fn thread_unsubscribe_keeps_thread_loaded_until_idle_timeout() -> Result<(
#[tokio::test]
async fn thread_unsubscribe_during_turn_keeps_turn_running() -> Result<()> {
#[cfg(target_os = "windows")]
let shell_command = vec![
"powershell".to_string(),
"-Command".to_string(),
"Start-Sleep -Seconds 1".to_string(),
];
#[cfg(not(target_os = "windows"))]
let shell_command = vec!["sleep".to_string(), "1".to_string()];
let call_id = "deterministic-wait-call";
let tool_name = "deterministic_wait";
let tool_args = json!({});
let tool_call_arguments = serde_json::to_string(&tool_args)?;
let tmp = TempDir::new()?;
let codex_home = tmp.path().join("codex_home");
@@ -143,28 +97,61 @@ async fn thread_unsubscribe_during_turn_keeps_turn_running() -> Result<()> {
let working_directory = tmp.path().join("workdir");
std::fs::create_dir(&working_directory)?;
let server = create_mock_responses_server_sequence_unchecked(vec![
create_shell_command_sse_response(
shell_command.clone(),
Some(&working_directory),
Some(10_000),
"call_sleep",
)?,
create_final_assistant_message_sse_response("Done")?,
let (server, mut completions) = start_streaming_sse_server(vec![
vec![StreamingSseChunk {
gate: None,
body: responses::sse(vec![
responses::ev_response_created("resp-1"),
responses::ev_function_call(call_id, tool_name, &tool_call_arguments),
responses::ev_completed("resp-1"),
]),
}],
vec![StreamingSseChunk {
gate: None,
body: responses::sse(vec![
responses::ev_response_created("resp-2"),
responses::ev_assistant_message("msg-1", "Done"),
responses::ev_completed("resp-2"),
]),
}],
])
.await;
create_config_toml(&codex_home, &server.uri())?;
let first_response_completed = completions.remove(0);
let final_response_completed = completions.remove(0);
create_config_toml(&codex_home, server.uri())?;
let mut mcp = McpProcess::new(&codex_home).await?;
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
let thread_id = start_thread(&mut mcp).await?;
let thread_req = mcp
.send_thread_start_request(ThreadStartParams {
model: Some("mock-model".to_string()),
dynamic_tools: Some(vec![DynamicToolSpec {
name: tool_name.to_string(),
description: "Deterministic wait tool".to_string(),
input_schema: json!({
"type": "object",
"properties": {},
"additionalProperties": false,
}),
defer_loading: false,
}]),
..Default::default()
})
.await?;
let thread_resp: JSONRPCResponse = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(thread_req)),
)
.await??;
let ThreadStartResponse { thread, .. } = to_response::<ThreadStartResponse>(thread_resp)?;
let thread_id = thread.id;
let turn_req = mcp
.send_turn_start_request(TurnStartParams {
thread_id: thread_id.clone(),
input: vec![V2UserInput::Text {
text: "run sleep".to_string(),
text: "run deterministic tool".to_string(),
text_elements: Vec::new(),
}],
cwd: Some(working_directory),
@@ -180,9 +167,37 @@ async fn thread_unsubscribe_during_turn_keeps_turn_running() -> Result<()> {
timeout(
DEFAULT_READ_TIMEOUT,
wait_for_command_execution_item_started(&mut mcp),
server.wait_for_request_count(/*count*/ 1),
)
.await?;
timeout(DEFAULT_READ_TIMEOUT, first_response_completed).await??;
let started = timeout(
DEFAULT_READ_TIMEOUT,
wait_for_dynamic_tool_started(&mut mcp, call_id),
)
.await??;
assert_eq!(started.thread_id, thread_id);
let request = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_request_message(),
)
.await??;
let (request_id, params) = match request {
ServerRequest::DynamicToolCall { request_id, params } => (request_id, params),
other => panic!("expected DynamicToolCall request, got {other:?}"),
};
assert_eq!(
params,
DynamicToolCallParams {
thread_id: thread_id.clone(),
turn_id: started.turn_id,
call_id: call_id.to_string(),
tool: tool_name.to_string(),
arguments: tool_args,
}
);
let unsubscribe_id = mcp
.send_thread_unsubscribe_request(ThreadUnsubscribeParams {
@@ -197,21 +212,29 @@ async fn thread_unsubscribe_during_turn_keeps_turn_running() -> Result<()> {
let unsubscribe = to_response::<ThreadUnsubscribeResponse>(unsubscribe_resp)?;
assert_eq!(unsubscribe.status, ThreadUnsubscribeStatus::Unsubscribed);
assert!(
timeout(
std::time::Duration::from_millis(250),
mcp.read_stream_until_notification_message("thread/closed"),
)
.await
.is_err()
let closed_while_tool_call_blocked = timeout(
std::time::Duration::from_millis(250),
mcp.read_stream_until_notification_message("thread/closed"),
);
let closed_while_tool_call_blocked = closed_while_tool_call_blocked.await;
assert!(closed_while_tool_call_blocked.is_err());
wait_for_responses_request_count_to_stabilize(
&server,
/*expected_count*/ 2,
std::time::Duration::from_millis(200),
let response = DynamicToolCallResponse {
content_items: vec![DynamicToolCallOutputContentItem::InputText {
text: "dynamic-ok".to_string(),
}],
success: true,
};
mcp.send_response(request_id, serde_json::to_value(response)?)
.await?;
timeout(
DEFAULT_READ_TIMEOUT,
server.wait_for_request_count(/*count*/ 2),
)
.await?;
timeout(DEFAULT_READ_TIMEOUT, final_response_completed).await??;
server.shutdown().await;
Ok(())
}
@@ -350,15 +373,20 @@ async fn thread_unsubscribe_reports_not_subscribed_before_idle_unload() -> Resul
Ok(())
}
async fn wait_for_command_execution_item_started(mcp: &mut McpProcess) -> Result<()> {
async fn wait_for_dynamic_tool_started(
mcp: &mut McpProcess,
call_id: &str,
) -> Result<ItemStartedNotification> {
loop {
let started_notif = mcp
let notification = mcp
.read_stream_until_notification_message("item/started")
.await?;
let started_params = started_notif.params.context("item/started params")?;
let started: ItemStartedNotification = serde_json::from_value(started_params)?;
if let ThreadItem::CommandExecution { .. } = started.item {
return Ok(());
let Some(params) = notification.params else {
continue;
};
let started: ItemStartedNotification = serde_json::from_value(params)?;
if matches!(&started.item, ThreadItem::DynamicToolCall { id, .. } if id == call_id) {
return Ok(started);
}
}
}
+2 -1
View File
@@ -18,8 +18,9 @@ use codex_utils_absolute_path::AbsolutePathBuf;
pub use parser::Hunk;
pub use parser::ParseError;
use parser::ParseError::*;
use parser::UpdateFileChunk;
pub use parser::UpdateFileChunk;
pub use parser::parse_patch;
pub use parser::parse_patch_streaming;
use similar::TextDiff;
use thiserror::Error;
+233 -26
View File
@@ -132,6 +132,14 @@ pub fn parse_patch(patch: &str) -> Result<ApplyPatchArgs, ParseError> {
parse_patch_text(patch, mode)
}
/// Parses streamed patch text that may not have reached `*** End Patch` yet.
///
/// This entry point is for progress reporting only; callers must not use its
/// output to apply a patch.
pub fn parse_patch_streaming(patch: &str) -> Result<ApplyPatchArgs, ParseError> {
parse_patch_text(patch, ParseMode::Streaming)
}
enum ParseMode {
/// Parse the patch text argument as is.
Strict,
@@ -169,32 +177,33 @@ enum ParseMode {
/// `<<'EOF'` and ends with `EOF\n`. If so, we strip off these markers,
/// trim() the result, and treat what is left as the patch text.
Lenient,
/// Parse partial patch text for progress reporting while the model is
/// still streaming tool input. This mode requires a begin marker but does
/// not require an end marker, and its output must not be used to apply a
/// patch.
Streaming,
}
fn parse_patch_text(patch: &str, mode: ParseMode) -> Result<ApplyPatchArgs, ParseError> {
let lines: Vec<&str> = patch.trim().lines().collect();
let lines: &[&str] = match check_patch_boundaries_strict(&lines) {
Ok(()) => &lines,
Err(e) => match mode {
ParseMode::Strict => {
return Err(e);
}
ParseMode::Lenient => check_patch_boundaries_lenient(&lines, e)?,
},
let (patch_lines, hunk_lines) = match mode {
ParseMode::Strict => check_patch_boundaries_strict(&lines)?,
ParseMode::Lenient => check_patch_boundaries_lenient(&lines)?,
ParseMode::Streaming => check_patch_boundaries_streaming(&lines)?,
};
let mut hunks: Vec<Hunk> = Vec::new();
// The above checks ensure that lines.len() >= 2.
let last_line_index = lines.len().saturating_sub(1);
let mut remaining_lines = &lines[1..last_line_index];
let mut remaining_lines = hunk_lines;
let mut line_number = 2;
let allow_incomplete = matches!(mode, ParseMode::Streaming);
while !remaining_lines.is_empty() {
let (hunk, hunk_lines) = parse_one_hunk(remaining_lines, line_number)?;
let (hunk, hunk_lines) = parse_one_hunk(remaining_lines, line_number, allow_incomplete)?;
hunks.push(hunk);
line_number += hunk_lines;
remaining_lines = &remaining_lines[hunk_lines..]
}
let patch = lines.join("\n");
let patch = patch_lines.join("\n");
Ok(ApplyPatchArgs {
hunks,
patch,
@@ -202,15 +211,37 @@ fn parse_patch_text(patch: &str, mode: ParseMode) -> Result<ApplyPatchArgs, Pars
})
}
fn check_patch_boundaries_streaming<'a>(
original_lines: &'a [&'a str],
) -> Result<(&'a [&'a str], &'a [&'a str]), ParseError> {
match original_lines {
[first, ..] if first.trim() == BEGIN_PATCH_MARKER => {
let body_lines = if original_lines
.last()
.is_some_and(|line| line.trim() == END_PATCH_MARKER)
{
&original_lines[1..original_lines.len() - 1]
} else {
&original_lines[1..]
};
Ok((original_lines, body_lines))
}
_ => check_patch_boundaries_strict(original_lines),
}
}
/// Checks the start and end lines of the patch text for `apply_patch`,
/// returning an error if they do not match the expected markers.
fn check_patch_boundaries_strict(lines: &[&str]) -> Result<(), ParseError> {
fn check_patch_boundaries_strict<'a>(
lines: &'a [&'a str],
) -> Result<(&'a [&'a str], &'a [&'a str]), ParseError> {
let (first_line, last_line) = match lines {
[] => (None, None),
[first] => (Some(first), Some(first)),
[first, .., last] => (Some(first), Some(last)),
};
check_start_and_end_lines_strict(first_line, last_line)
check_start_and_end_lines_strict(first_line, last_line)?;
Ok((lines, &lines[1..lines.len() - 1]))
}
/// If we are in lenient mode, we check if the first line starts with `<<EOF`
@@ -222,8 +253,12 @@ fn check_patch_boundaries_strict(lines: &[&str]) -> Result<(), ParseError> {
/// contents, excluding the heredoc markers.
fn check_patch_boundaries_lenient<'a>(
original_lines: &'a [&'a str],
original_parse_error: ParseError,
) -> Result<&'a [&'a str], ParseError> {
) -> Result<(&'a [&'a str], &'a [&'a str]), ParseError> {
let original_parse_error = match check_patch_boundaries_strict(original_lines) {
Ok(lines) => return Ok(lines),
Err(e) => e,
};
match original_lines {
[first, .., last] => {
if (first == &"<<EOF" || first == &"<<'EOF'" || first == &"<<\"EOF\"")
@@ -231,10 +266,7 @@ fn check_patch_boundaries_lenient<'a>(
&& original_lines.len() >= 4
{
let inner_lines = &original_lines[1..original_lines.len() - 1];
match check_patch_boundaries_strict(inner_lines) {
Ok(()) => Ok(inner_lines),
Err(e) => Err(e),
}
check_patch_boundaries_strict(inner_lines)
} else {
Err(original_parse_error)
}
@@ -265,7 +297,11 @@ fn check_start_and_end_lines_strict(
/// Attempts to parse a single hunk from the start of lines.
/// Returns the parsed hunk and the number of lines parsed (or a ParseError).
fn parse_one_hunk(lines: &[&str], line_number: usize) -> Result<(Hunk, usize), ParseError> {
fn parse_one_hunk(
lines: &[&str],
line_number: usize,
allow_incomplete: bool,
) -> Result<(Hunk, usize), ParseError> {
// Be tolerant of case mismatches and extra padding around marker strings.
let first_line = lines[0].trim();
if let Some(path) = first_line.strip_prefix(ADD_FILE_MARKER) {
@@ -321,15 +357,26 @@ fn parse_one_hunk(lines: &[&str], line_number: usize) -> Result<(Hunk, usize), P
continue;
}
if remaining_lines[0].starts_with("***") {
if remaining_lines[0].starts_with('*') {
break;
}
let (chunk, chunk_lines) = parse_update_file_chunk(
if allow_incomplete && remaining_lines[0] == "@" {
break;
}
let parsed_chunk = parse_update_file_chunk(
remaining_lines,
line_number + parsed_lines,
chunks.is_empty(),
)?;
);
let (chunk, chunk_lines) = match parsed_chunk {
Ok(parsed) => parsed,
Err(InvalidHunkError { .. }) if allow_incomplete && !chunks.is_empty() => {
break;
}
Err(err) => return Err(err),
};
chunks.push(chunk);
parsed_lines += chunk_lines;
remaining_lines = &remaining_lines[chunk_lines..]
@@ -453,6 +500,166 @@ fn parse_update_file_chunk(
Ok((chunk, parsed_lines + start_index))
}
#[test]
fn test_parse_patch_streaming() {
assert_eq!(
parse_patch_streaming("*** Begin Patch\n*** Add File: src/hello.txt\n+hello\n+wor"),
Ok(ApplyPatchArgs {
hunks: vec![AddFile {
path: PathBuf::from("src/hello.txt"),
contents: "hello\nwor\n".to_string(),
}],
patch: "*** Begin Patch\n*** Add File: src/hello.txt\n+hello\n+wor".to_string(),
workdir: None,
})
);
assert_eq!(
parse_patch_streaming(
"*** Begin Patch\n*** Update File: src/old.rs\n*** Move to: src/new.rs\n@@\n-old\n+new",
),
Ok(ApplyPatchArgs {
hunks: vec![UpdateFile {
path: PathBuf::from("src/old.rs"),
move_path: Some(PathBuf::from("src/new.rs")),
chunks: vec![UpdateFileChunk {
change_context: None,
old_lines: vec!["old".to_string()],
new_lines: vec!["new".to_string()],
is_end_of_file: false,
}],
}],
patch: "*** Begin Patch\n*** Update File: src/old.rs\n*** Move to: src/new.rs\n@@\n-old\n+new".to_string(),
workdir: None,
})
);
assert!(
parse_patch_text(
"*** Begin Patch\n*** Delete File: gone.txt",
ParseMode::Streaming
)
.is_ok()
);
assert!(
parse_patch_text(
"*** Begin Patch\n*** Delete File: gone.txt",
ParseMode::Strict
)
.is_err()
);
assert_eq!(
parse_patch_streaming(
"*** Begin Patch\n*** Add File: src/one.txt\n+one\n*** Delete File: src/two.txt\n",
),
Ok(ApplyPatchArgs {
hunks: vec![
AddFile {
path: PathBuf::from("src/one.txt"),
contents: "one\n".to_string(),
},
DeleteFile {
path: PathBuf::from("src/two.txt"),
},
],
patch: "*** Begin Patch\n*** Add File: src/one.txt\n+one\n*** Delete File: src/two.txt"
.to_string(),
workdir: None,
})
);
}
#[test]
fn test_parse_patch_streaming_large_patch_by_character() {
let patch = "\
*** Begin Patch
*** Add File: docs/release-notes.md
+# Release notes
+
+## CLI
+- Surface apply_patch progress while arguments stream.
+- Keep final patch application gated on the completed tool call.
+- Include file summaries in the progress event payload.
*** Update File: src/config.rs
@@ impl Config
- pub apply_patch_progress: bool,
+ pub stream_apply_patch_progress: bool,
pub include_diagnostics: bool,
@@ fn default_progress_interval()
- Duration::from_millis(500)
+ Duration::from_millis(250)
*** Delete File: src/legacy_patch_progress.rs
*** Update File: crates/cli/src/main.rs
*** Move to: crates/cli/src/bin/codex.rs
@@ fn run()
- let args = Args::parse();
- dispatch(args)
+ let cli = Cli::parse();
+ dispatch(cli)
*** Add File: tests/fixtures/apply_patch_progress.json
+{
+ \"type\": \"apply_patch_progress\",
+ \"hunks\": [
+ { \"operation\": \"add\", \"path\": \"docs/release-notes.md\" },
+ { \"operation\": \"update\", \"path\": \"src/config.rs\" }
+ ]
+}
*** Update File: README.md
@@ Development workflow
Build the Rust workspace before opening a pull request.
+When touching streamed tool calls, include parser coverage for partial input.
+Prefer tests that exercise the exact event payload shape.
*** Delete File: docs/old-apply-patch-progress.md
*** End Patch";
let mut max_hunk_count = 0;
let mut saw_hunk_counts = Vec::new();
for i in 1..=patch.len() {
let partial = &patch[..i];
if let Ok(parsed) = parse_patch_streaming(partial) {
let hunk_count = parsed.hunks.len();
assert!(
hunk_count >= max_hunk_count,
"hunk count should never decrease while streaming: {hunk_count} < {max_hunk_count} for {partial:?}",
);
if hunk_count > max_hunk_count {
saw_hunk_counts.push(hunk_count);
max_hunk_count = hunk_count;
}
}
}
assert_eq!(saw_hunk_counts, vec![1, 2, 3, 4, 5, 6, 7]);
let parsed = parse_patch_streaming(patch).unwrap();
assert_eq!(parsed.hunks.len(), 7);
assert_eq!(
parsed
.hunks
.iter()
.map(|hunk| match hunk {
AddFile { .. } => "add",
DeleteFile { .. } => "delete",
UpdateFile {
move_path: Some(_), ..
} => "move-update",
UpdateFile {
move_path: None, ..
} => "update",
})
.collect::<Vec<_>>(),
vec![
"add",
"update",
"delete",
"move-update",
"add",
"update",
"delete"
]
);
}
#[test]
fn test_parse_patch() {
assert_eq!(
@@ -794,7 +1001,7 @@ fn test_parse_patch_lenient() {
#[test]
fn test_parse_one_hunk() {
assert_eq!(
parse_one_hunk(&["bad"], /*line_number*/ 234),
parse_one_hunk(&["bad"], /*line_number*/ 234, /*allow_incomplete*/ false),
Err(InvalidHunkError {
message: "'bad' is not a valid hunk header. \
Valid hunk headers: '*** Add File: {path}', '*** Delete File: {path}', '*** Update File: {path}'".to_string(),
+15
View File
@@ -104,6 +104,7 @@ pub struct Client {
bearer_token: Option<String>,
user_agent: Option<HeaderValue>,
chatgpt_account_id: Option<String>,
chatgpt_account_is_fedramp: bool,
path_style: PathStyle,
}
@@ -129,6 +130,7 @@ impl Client {
bearer_token: None,
user_agent: None,
chatgpt_account_id: None,
chatgpt_account_is_fedramp: false,
path_style,
})
}
@@ -141,6 +143,9 @@ impl Client {
if let Some(account_id) = auth.get_account_id() {
client = client.with_chatgpt_account_id(account_id);
}
if auth.is_fedramp_account() {
client = client.with_fedramp_routing_header();
}
Ok(client)
}
@@ -161,6 +166,11 @@ impl Client {
self
}
pub fn with_fedramp_routing_header(mut self) -> Self {
self.chatgpt_account_is_fedramp = true;
self
}
pub fn with_path_style(mut self, style: PathStyle) -> Self {
self.path_style = style;
self
@@ -185,6 +195,11 @@ impl Client {
{
h.insert(name, hv);
}
if self.chatgpt_account_is_fedramp
&& let Ok(name) = HeaderName::from_bytes(b"X-OpenAI-Fedramp")
{
h.insert(name, HeaderValue::from_static("true"));
}
h
}
+1
View File
@@ -10,6 +10,7 @@ workspace = true
[dependencies]
anyhow = { workspace = true }
clap = { workspace = true, features = ["derive"] }
codex-app-server-protocol = { workspace = true }
codex-connectors = { workspace = true }
codex-config = { workspace = true }
codex-core = { workspace = true }
+40 -57
View File
@@ -1,7 +1,3 @@
use codex_core::config::Config;
use codex_login::AuthManager;
use codex_login::CodexAuth;
use codex_login::token_data::TokenData;
use std::collections::HashSet;
use std::time::Duration;
@@ -9,21 +5,24 @@ use crate::chatgpt_client::chatgpt_get_request_with_timeout;
use crate::chatgpt_token::get_chatgpt_token_data;
use crate::chatgpt_token::init_chatgpt_token_from_auth;
use codex_app_server_protocol::AppInfo;
use codex_connectors::AllConnectorsCacheKey;
use codex_connectors::DirectoryListResponse;
pub use codex_core::connectors::AppInfo;
pub use codex_core::connectors::connector_display_label;
use codex_core::connectors::filter_disallowed_connectors;
use codex_connectors::filter::filter_disallowed_connectors;
use codex_connectors::merge::merge_connectors;
use codex_connectors::merge::merge_plugin_connectors;
use codex_core::config::Config;
pub use codex_core::connectors::list_accessible_connectors_from_mcp_tools;
pub use codex_core::connectors::list_accessible_connectors_from_mcp_tools_with_options;
pub use codex_core::connectors::list_accessible_connectors_from_mcp_tools_with_options_and_status;
pub use codex_core::connectors::list_cached_accessible_connectors_from_mcp_tools;
use codex_core::connectors::merge_connectors;
use codex_core::connectors::merge_plugin_apps;
pub use codex_core::connectors::with_app_enabled_state;
use codex_core::plugins::AppConnectorId;
use codex_core::plugins::PluginsManager;
use codex_login::AuthManager;
use codex_login::CodexAuth;
use codex_login::default_client::originator;
use codex_login::token_data::TokenData;
const DIRECTORY_CONNECTORS_TIMEOUT: Duration = Duration::from_secs(60);
@@ -74,8 +73,17 @@ pub async fn list_cached_all_connectors(config: &Config) -> Option<Vec<AppInfo>>
let token_data = get_chatgpt_token_data()?;
let cache_key = all_connectors_cache_key(config, &token_data);
let connectors = codex_connectors::cached_all_connectors(&cache_key)?;
let connectors = merge_plugin_apps(connectors, plugin_apps_for_config(config).await);
Some(filter_disallowed_connectors(connectors))
let connectors = merge_plugin_connectors(
connectors,
plugin_apps_for_config(config)
.await
.into_iter()
.map(|connector_id| connector_id.0),
);
Some(filter_disallowed_connectors(
connectors,
originator().value.as_str(),
))
}
pub async fn list_all_connectors_with_options(
@@ -105,8 +113,17 @@ pub async fn list_all_connectors_with_options(
},
)
.await?;
let connectors = merge_plugin_apps(connectors, plugin_apps_for_config(config).await);
Ok(filter_disallowed_connectors(connectors))
let connectors = merge_plugin_connectors(
connectors,
plugin_apps_for_config(config)
.await
.into_iter()
.map(|connector_id| connector_id.0),
);
Ok(filter_disallowed_connectors(
connectors,
originator().value.as_str(),
))
}
fn all_connectors_cache_key(config: &Config, token_data: &TokenData) -> AllConnectorsCacheKey {
@@ -134,7 +151,13 @@ pub fn connectors_for_plugin_apps(
.map(|connector_id| connector_id.0.as_str())
.collect::<HashSet<_>>();
filter_disallowed_connectors(merge_plugin_apps(connectors, plugin_apps.to_vec()))
let connectors = merge_plugin_connectors(
connectors,
plugin_apps
.iter()
.map(|connector_id| connector_id.0.clone()),
);
filter_disallowed_connectors(connectors, originator().value.as_str())
.into_iter()
.filter(|connector| plugin_app_ids.contains(connector.id.as_str()))
.collect()
@@ -158,13 +181,13 @@ pub fn merge_connectors_with_accessible(
accessible_connectors
};
let merged = merge_connectors(connectors, accessible_connectors);
filter_disallowed_connectors(merged)
filter_disallowed_connectors(merged, originator().value.as_str())
}
#[cfg(test)]
mod tests {
use super::*;
use codex_core::connectors::connector_install_url;
use codex_connectors::metadata::connector_install_url;
use codex_core::plugins::AppConnectorId;
use pretty_assertions::assert_eq;
@@ -186,46 +209,6 @@ mod tests {
}
}
#[test]
fn allows_asdk_connectors() {
let filtered = filter_disallowed_connectors(vec![app("asdk_app_hidden"), app("alpha")]);
assert_eq!(filtered, vec![app("asdk_app_hidden"), app("alpha")]);
}
#[test]
fn allows_whitelisted_asdk_connectors() {
let filtered = filter_disallowed_connectors(vec![
app("asdk_app_69781557cc1481919cf5e9824fa2e792"),
app("beta"),
]);
assert_eq!(
filtered,
vec![
app("asdk_app_69781557cc1481919cf5e9824fa2e792"),
app("beta")
]
);
}
#[test]
fn filters_openai_prefixed_connectors() {
let filtered = filter_disallowed_connectors(vec![
app("connector_openai_foo"),
app("connector_openai_bar"),
app("gamma"),
]);
assert_eq!(filtered, vec![app("gamma")]);
}
#[test]
fn filters_disallowed_connector_ids() {
let filtered = filter_disallowed_connectors(vec![
app("asdk_app_6938a94a61d881918ef32cb999ff937c"),
app("delta"),
]);
assert_eq!(filtered, vec![app("delta")]);
}
fn merged_app(id: &str, is_accessible: bool) -> AppInfo {
AppInfo {
id: id.to_string(),
+1
View File
@@ -37,6 +37,7 @@ codex-features = { workspace = true }
codex-login = { workspace = true }
codex-mcp = { workspace = true }
codex-mcp-server = { workspace = true }
codex-model-provider = { workspace = true }
codex-protocol = { workspace = true }
codex-responses-api-proxy = { workspace = true }
codex-rmcp-client = { workspace = true }
+2 -2
View File
@@ -14,7 +14,7 @@ use codex_login::CLIENT_ID;
use codex_login::CodexAuth;
use codex_login::ServerOptions;
use codex_login::login_with_api_key;
use codex_login::logout;
use codex_login::logout_with_revoke;
use codex_login::run_device_code_login;
use codex_login::run_login_server;
use codex_protocol::config_types::ForcedLoginMethod;
@@ -347,7 +347,7 @@ pub async fn run_login_status(cli_config_overrides: CliConfigOverrides) -> ! {
pub async fn run_logout(cli_config_overrides: CliConfigOverrides) -> ! {
let config = load_config_or_exit(cli_config_overrides).await;
match logout(&config.codex_home, config.cli_auth_credentials_store_mode) {
match logout_with_revoke(&config.codex_home, config.cli_auth_credentials_store_mode).await {
Ok(true) => {
eprintln!("Successfully logged out");
std::process::exit(0);
+65 -24
View File
@@ -49,6 +49,7 @@ use crate::mcp_cmd::McpCli;
use crate::responses_cmd::ResponsesCommand;
use crate::responses_cmd::run_responses_command;
use codex_core::clear_memory_roots_contents;
use codex_core::config::Config;
use codex_core::config::ConfigOverrides;
use codex_core::config::edit::ConfigEditsBuilder;
@@ -110,8 +111,8 @@ enum Subcommand {
/// Manage external MCP servers for Codex.
Mcp(McpCli),
/// Manage plugin marketplaces for Codex.
Marketplace(MarketplaceCli),
/// Manage Codex plugins.
Plugin(PluginCli),
/// Start Codex as an MCP server (stdio).
McpServer,
@@ -169,6 +170,21 @@ enum Subcommand {
Features(FeaturesCli),
}
#[derive(Debug, Parser)]
struct PluginCli {
#[clap(flatten)]
pub config_overrides: CliConfigOverrides,
#[command(subcommand)]
subcommand: PluginSubcommand,
}
#[derive(Debug, clap::Subcommand)]
enum PluginSubcommand {
/// Manage plugin marketplaces for Codex.
Marketplace(MarketplaceCli),
}
#[derive(Debug, Parser)]
struct CompletionCommand {
/// Shell to generate completions for
@@ -725,17 +741,23 @@ async fn cli_main(arg0_paths: Arg0DispatchPaths) -> anyhow::Result<()> {
prepend_config_flags(&mut mcp_cli.config_overrides, root_config_overrides.clone());
mcp_cli.run().await?;
}
Some(Subcommand::Marketplace(mut marketplace_cli)) => {
Some(Subcommand::Plugin(plugin_cli)) => {
reject_remote_mode_for_subcommand(
root_remote.as_deref(),
root_remote_auth_token_env.as_deref(),
"marketplace",
"plugin",
)?;
prepend_config_flags(
&mut marketplace_cli.config_overrides,
root_config_overrides.clone(),
);
marketplace_cli.run().await?;
let PluginCli {
mut config_overrides,
subcommand,
} = plugin_cli;
prepend_config_flags(&mut config_overrides, root_config_overrides.clone());
match subcommand {
PluginSubcommand::Marketplace(mut marketplace_cli) => {
prepend_config_flags(&mut marketplace_cli.config_overrides, config_overrides);
marketplace_cli.run().await?;
}
}
}
Some(Subcommand::AppServer(app_server_cli)) => {
let AppServerCommand {
@@ -1282,27 +1304,17 @@ async fn run_debug_clear_memories_command(
cleared_state_db = true;
}
let memory_root = config.codex_home.join("memories");
let removed_memory_root = match tokio::fs::remove_dir_all(&memory_root).await {
Ok(()) => true,
Err(err) if err.kind() == std::io::ErrorKind::NotFound => false,
Err(err) => return Err(err.into()),
};
clear_memory_roots_contents(&config.codex_home).await?;
let mut message = if cleared_state_db {
format!("Cleared memory state from {}.", state_path.display())
} else {
format!("No state db found at {}.", state_path.display())
};
if removed_memory_root {
message.push_str(&format!(" Removed {}.", memory_root.display()));
} else {
message.push_str(&format!(
" No memory directory found at {}.",
memory_root.display()
));
}
message.push_str(&format!(
" Cleared memory directories under {}.",
config.codex_home.display()
));
println!("{message}");
@@ -1699,6 +1711,35 @@ mod tests {
assert!(matches!(cli.subcommand, Some(Subcommand::Responses(_))));
}
#[test]
fn plugin_marketplace_add_parses_under_plugin() {
let cli =
MultitoolCli::try_parse_from(["codex", "plugin", "marketplace", "add", "owner/repo"])
.expect("parse");
assert!(matches!(cli.subcommand, Some(Subcommand::Plugin(_))));
}
#[test]
fn plugin_marketplace_upgrade_parses_under_plugin() {
let cli =
MultitoolCli::try_parse_from(["codex", "plugin", "marketplace", "upgrade", "debug"])
.expect("parse");
assert!(matches!(cli.subcommand, Some(Subcommand::Plugin(_))));
}
#[test]
fn marketplace_no_longer_parses_at_top_level() {
let add_result =
MultitoolCli::try_parse_from(["codex", "marketplace", "add", "owner/repo"]);
assert!(add_result.is_err());
let upgrade_result =
MultitoolCli::try_parse_from(["codex", "marketplace", "upgrade", "debug"]);
assert!(upgrade_result.is_err());
}
fn sample_exit_info(conversation_id: Option<&str>, thread_name: Option<&str>) -> AppExitInfo {
let token_usage = TokenUsage {
output_tokens: 2,
+75 -6
View File
@@ -1,8 +1,12 @@
use anyhow::Context;
use anyhow::Result;
use anyhow::bail;
use clap::Parser;
use codex_core::config::Config;
use codex_core::config::find_codex_home;
use codex_core::plugins::MarketplaceAddRequest;
use codex_core::plugins::PluginMarketplaceUpgradeOutcome;
use codex_core::plugins::PluginsManager;
use codex_core::plugins::add_marketplace;
use codex_utils_cli::CliConfigOverrides;
@@ -17,8 +21,8 @@ pub struct MarketplaceCli {
#[derive(Debug, clap::Subcommand)]
enum MarketplaceSubcommand {
/// Add a remote marketplace repository.
Add(AddMarketplaceArgs),
Upgrade(UpgradeMarketplaceArgs),
}
#[derive(Debug, Parser)]
@@ -27,11 +31,9 @@ struct AddMarketplaceArgs {
/// or local marketplace root directories.
source: String,
/// Git ref to check out. Overrides any @ref or #ref suffix in SOURCE.
#[arg(long = "ref", value_name = "REF")]
ref_name: Option<String>,
/// Sparse-checkout path to use while cloning git sources. Repeat to include multiple paths.
#[arg(
long = "sparse",
value_name = "PATH",
@@ -40,6 +42,11 @@ struct AddMarketplaceArgs {
sparse_paths: Vec<String>,
}
#[derive(Debug, Parser)]
struct UpgradeMarketplaceArgs {
marketplace_name: Option<String>,
}
impl MarketplaceCli {
pub async fn run(self) -> Result<()> {
let MarketplaceCli {
@@ -47,14 +54,13 @@ impl MarketplaceCli {
subcommand,
} = self;
// Validate overrides now. This command writes to CODEX_HOME only; marketplace discovery
// happens from that cache root after the next plugin/list or app-server start.
config_overrides
let overrides = config_overrides
.parse_overrides()
.map_err(anyhow::Error::msg)?;
match subcommand {
MarketplaceSubcommand::Add(args) => run_add(args).await?,
MarketplaceSubcommand::Upgrade(args) => run_upgrade(overrides, args).await?,
}
Ok(())
@@ -98,6 +104,60 @@ async fn run_add(args: AddMarketplaceArgs) -> Result<()> {
Ok(())
}
async fn run_upgrade(
overrides: Vec<(String, toml::Value)>,
args: UpgradeMarketplaceArgs,
) -> Result<()> {
let UpgradeMarketplaceArgs { marketplace_name } = args;
let config = Config::load_with_cli_overrides(overrides)
.await
.context("failed to load configuration")?;
let codex_home = find_codex_home().context("failed to resolve CODEX_HOME")?;
let manager = PluginsManager::new(codex_home.to_path_buf());
let outcome = manager
.upgrade_configured_marketplaces_for_config(&config, marketplace_name.as_deref())
.map_err(anyhow::Error::msg)?;
print_upgrade_outcome(&outcome, marketplace_name.as_deref())
}
fn print_upgrade_outcome(
outcome: &PluginMarketplaceUpgradeOutcome,
marketplace_name: Option<&str>,
) -> Result<()> {
for error in &outcome.errors {
eprintln!(
"Failed to upgrade marketplace `{}`: {}",
error.marketplace_name, error.message
);
}
if !outcome.all_succeeded() {
bail!("{} upgrade failure(s) occurred.", outcome.errors.len());
}
let selection_label = marketplace_name.unwrap_or("all configured Git marketplaces");
if outcome.selected_marketplaces.is_empty() {
println!("No configured Git marketplaces to upgrade.");
} else if outcome.upgraded_roots.is_empty() {
if marketplace_name.is_some() {
println!("Marketplace `{selection_label}` is already up to date.");
} else {
println!("All configured Git marketplaces are already up to date.");
}
} else if marketplace_name.is_some() {
println!("Upgraded marketplace `{selection_label}` to the latest configured revision.");
for root in &outcome.upgraded_roots {
println!("Installed marketplace root: {}", root.display());
}
} else {
println!("Upgraded {} marketplace(s).", outcome.upgraded_roots.len());
for root in &outcome.upgraded_roots {
println!("Installed marketplace root: {}", root.display());
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
@@ -132,4 +192,13 @@ mod tests {
vec!["plugins/foo", "skills/bar"]
);
}
#[test]
fn upgrade_subcommand_parses_optional_marketplace_name() {
let upgrade_all = UpgradeMarketplaceArgs::try_parse_from(["upgrade"]).unwrap();
assert_eq!(upgrade_all.marketplace_name, None);
let upgrade_one = UpgradeMarketplaceArgs::try_parse_from(["upgrade", "debug"]).unwrap();
assert_eq!(upgrade_one.marketplace_name.as_deref(), Some("debug"));
}
}
+11
View File
@@ -6,6 +6,7 @@ use anyhow::Result;
use anyhow::anyhow;
use anyhow::bail;
use clap::ArgGroup;
use codex_config::types::AppToolApproval;
use codex_config::types::McpServerConfig;
use codex_config::types::McpServerTransportConfig;
use codex_core::McpManager;
@@ -297,12 +298,14 @@ async fn run_add(config_overrides: &CliConfigOverrides, add_args: AddArgs) -> Re
let new_entry = McpServerConfig {
transport: transport.clone(),
experimental_environment: None,
enabled: true,
required: false,
supports_parallel_tool_calls: false,
disabled_reason: None,
startup_timeout_sec: None,
tool_timeout_sec: None,
default_tools_approval_mode: None,
enabled_tools: None,
disabled_tools: None,
scopes: None,
@@ -878,6 +881,14 @@ async fn run_get(config_overrides: &CliConfigOverrides, get_args: GetArgs) -> Re
if let Some(timeout) = server.tool_timeout_sec {
println!(" tool_timeout_sec: {}", timeout.as_secs_f64());
}
if let Some(approval_mode) = server.default_tools_approval_mode {
let approval_mode = match approval_mode {
AppToolApproval::Auto => "auto",
AppToolApproval::Prompt => "prompt",
AppToolApproval::Approve => "approve",
};
println!(" default_tools_approval_mode: {approval_mode}");
}
println!(" remove: codex mcp remove {}", get_args.name);
Ok(())
+34 -10
View File
@@ -1,5 +1,6 @@
use clap::Parser;
use codex_core::config::Config;
use codex_model_provider::create_model_provider;
use codex_utils_cli::CliConfigOverrides;
use serde_json::json;
use tokio::io::AsyncReadExt;
@@ -29,16 +30,9 @@ pub(crate) async fn run_responses_command(
let base_auth_manager = codex_login::AuthManager::shared_from_config(
&config, /*enable_codex_api_key_env*/ true,
);
let auth_manager =
codex_login::auth_manager_for_provider(Some(base_auth_manager), &config.model_provider);
let auth = match auth_manager {
Some(auth_manager) => auth_manager.auth().await,
None => None,
};
let api_provider = config
.model_provider
.to_api_provider(auth.as_ref().map(codex_login::CodexAuth::auth_mode))?;
let api_auth = codex_login::auth_provider_from_auth(auth, &config.model_provider)?;
let model_provider = create_model_provider(config.model_provider, Some(base_auth_manager));
let api_provider = model_provider.api_provider().await?;
let api_auth = model_provider.api_auth().await?;
let client = codex_api::ResponsesClient::new(
codex_api::ReqwestTransport::new(codex_login::default_client::build_reqwest_client()),
api_provider,
@@ -104,6 +98,18 @@ fn response_event_to_json(event: codex_api::ResponseEvent) -> serde_json::Value
codex_api::ResponseEvent::OutputTextDelta(delta) => {
json!({ "type": "response.output_text.delta", "delta": delta })
}
codex_api::ResponseEvent::ToolCallInputDelta {
item_id,
call_id,
delta,
} => {
json!({
"type": "response.tool_call_input.delta",
"item_id": item_id,
"call_id": call_id,
"delta": delta,
})
}
codex_api::ResponseEvent::ReasoningSummaryDelta {
delta,
summary_index,
@@ -216,4 +222,22 @@ mod tests {
})
);
}
#[test]
fn tool_call_input_delta_uses_responses_event_name() {
let delta = response_event_to_json(codex_api::ResponseEvent::ToolCallInputDelta {
item_id: "item-1".to_string(),
call_id: Some("call-1".to_string()),
delta: "patch".to_string(),
});
assert_eq!(
delta,
json!({
"type": "response.tool_call_input.delta",
"item_id": "item-1",
"call_id": "call-1",
"delta": "patch",
})
);
}
}
+2 -1
View File
@@ -125,7 +125,8 @@ INSERT INTO jobs (
.fetch_one(&pool)
.await?;
assert_eq!(memory_jobs_count, 0);
assert!(!memory_root.exists());
assert!(memory_root.exists());
assert_eq!(std::fs::read_dir(memory_root)?.count(), 0);
Ok(())
}
+31 -2
View File
@@ -48,7 +48,7 @@ async fn marketplace_add_local_directory_source() -> Result<()> {
codex_command(codex_home.path())?
.current_dir(source_parent)
.args(["marketplace", "add", source_arg.as_str()])
.args(["plugin", "marketplace", "add", source_arg.as_str()])
.assert()
.success();
@@ -78,7 +78,12 @@ async fn marketplace_add_rejects_local_manifest_file_source() -> Result<()> {
let manifest_path = source.path().join(".agents/plugins/marketplace.json");
codex_command(codex_home.path())?
.args(["marketplace", "add", manifest_path.to_str().unwrap()])
.args([
"plugin",
"marketplace",
"add",
manifest_path.to_str().unwrap(),
])
.assert()
.failure()
.stderr(contains(
@@ -87,3 +92,27 @@ async fn marketplace_add_rejects_local_manifest_file_source() -> Result<()> {
Ok(())
}
#[tokio::test]
async fn marketplace_add_rejects_sparse_for_local_directory_source() -> Result<()> {
let codex_home = TempDir::new()?;
let source = TempDir::new()?;
write_marketplace_source(source.path(), "local ref")?;
codex_command(codex_home.path())?
.args([
"plugin",
"marketplace",
"add",
"--sparse",
".agents",
source.path().to_str().unwrap(),
])
.assert()
.failure()
.stderr(contains(
"--sparse is only supported for git marketplace sources",
));
Ok(())
}
+36
View File
@@ -0,0 +1,36 @@
use anyhow::Result;
use predicates::str::contains;
use std::path::Path;
use tempfile::TempDir;
fn codex_command(codex_home: &Path) -> Result<assert_cmd::Command> {
let mut cmd = assert_cmd::Command::new(codex_utils_cargo_bin::cargo_bin("codex")?);
cmd.env("CODEX_HOME", codex_home);
Ok(cmd)
}
#[tokio::test]
async fn marketplace_upgrade_runs_under_plugin() -> Result<()> {
let codex_home = TempDir::new()?;
codex_command(codex_home.path())?
.args(["plugin", "marketplace", "upgrade"])
.assert()
.success()
.stdout(contains("No configured Git marketplaces to upgrade."));
Ok(())
}
#[tokio::test]
async fn marketplace_upgrade_no_longer_runs_at_top_level() -> Result<()> {
let codex_home = TempDir::new()?;
codex_command(codex_home.path())?
.args(["marketplace", "upgrade"])
.assert()
.failure()
.stderr(contains("unexpected argument 'upgrade' found"));
Ok(())
}
+36 -9
View File
@@ -9,6 +9,8 @@ use crate::PUBLIC_TOOL_NAME;
const MAX_JS_SAFE_INTEGER: u64 = (1_u64 << 53) - 1;
const CODE_MODE_ONLY_PREFACE: &str =
"Use `exec/wait` tool to run all other tools, do not attempt to use any other tools directly";
const DEFERRED_NESTED_TOOLS_GUIDANCE: &str = r#"Some nested MCP/app tools may be omitted from this description. They are still available on the global `tools` object and listed in `ALL_TOOLS`.
To find one, filter `ALL_TOOLS` by `name` and `description`; do not print the full `ALL_TOOLS` array. Print only a small set of relevant matches if you need to inspect them."#;
const EXEC_DESCRIPTION_TEMPLATE: &str = r#"Run JavaScript code to orchestrate/compose tool calls
- Evaluates the provided JavaScript code in a fresh V8 isolate as an async module.
- All nested tools are available on the global `tools` object, for example `await tools.exec_command(...)`. Tool names are exposed as normalized JavaScript identifiers, for example `await tools.mcp__ologs__get_profile(...)`.
@@ -251,15 +253,19 @@ pub fn build_exec_tool_description(
enabled_tools: &[ToolDefinition],
namespace_descriptions: &BTreeMap<String, ToolNamespaceDescription>,
code_mode_only: bool,
deferred_tools_available: bool,
) -> String {
if !code_mode_only {
return EXEC_DESCRIPTION_TEMPLATE.to_string();
let mut sections = Vec::new();
if code_mode_only {
sections.push(CODE_MODE_ONLY_PREFACE.to_string());
}
sections.push(EXEC_DESCRIPTION_TEMPLATE.to_string());
if deferred_tools_available {
sections.push(DEFERRED_NESTED_TOOLS_GUIDANCE.to_string());
}
if !code_mode_only {
return sections.join("\n\n");
}
let mut sections = vec![
CODE_MODE_ONLY_PREFACE.to_string(),
EXEC_DESCRIPTION_TEMPLATE.to_string(),
];
if !enabled_tools.is_empty() {
let mut current_namespace: Option<&str> = None;
@@ -863,6 +869,7 @@ mod tests {
}],
&BTreeMap::new(),
/*code_mode_only*/ true,
/*deferred_tools_available*/ false,
);
assert!(description.contains(
"### `foo`
@@ -872,8 +879,12 @@ bar"
#[test]
fn exec_description_mentions_timeout_helpers() {
let description =
build_exec_tool_description(&[], &BTreeMap::new(), /*code_mode_only*/ false);
let description = build_exec_tool_description(
&[],
&BTreeMap::new(),
/*code_mode_only*/ false,
/*deferred_tools_available*/ false,
);
assert!(description.contains("`setTimeout(callback: () => void, delayMs?: number)`"));
assert!(description.contains("`clearTimeout(timeoutId?: number)`"));
}
@@ -924,6 +935,7 @@ bar"
],
&namespace_descriptions,
/*code_mode_only*/ true,
/*deferred_tools_available*/ false,
);
assert_eq!(description.matches("## mcp__sample").count(), 1);
assert!(description.contains("## mcp__sample\nShared namespace guidance."));
@@ -963,6 +975,7 @@ bar"
}],
&namespace_descriptions,
/*code_mode_only*/ true,
/*deferred_tools_available*/ false,
);
assert!(!description.contains("## mcp__sample"));
@@ -1061,6 +1074,7 @@ bar"
],
&BTreeMap::new(),
/*code_mode_only*/ true,
/*deferred_tools_available*/ false,
);
assert_eq!(
@@ -1071,4 +1085,17 @@ bar"
);
assert_eq!(description.matches("Shared MCP Types:").count(), 1);
}
#[test]
fn exec_description_mentions_deferred_nested_tools_when_available() {
let description = build_exec_tool_description(
&[],
&BTreeMap::new(),
/*code_mode_only*/ false,
/*deferred_tools_available*/ true,
);
assert!(description.contains("Some nested MCP/app tools may be omitted"));
assert!(description.contains("filter `ALL_TOOLS` by `name` and `description`"));
}
}
-42
View File
@@ -1,4 +1,3 @@
use crate::AuthProvider as ApiAuthProvider;
use crate::TransportError;
use crate::error::ApiError;
use crate::rate_limits::parse_promo_message;
@@ -12,7 +11,6 @@ use codex_protocol::error::RetryLimitReachedError;
use codex_protocol::error::UnexpectedResponseError;
use codex_protocol::error::UsageLimitReachedError;
use http::HeaderMap;
use http::HeaderValue;
use serde::Deserialize;
use serde_json::Value;
@@ -174,43 +172,3 @@ struct UsageErrorBody {
plan_type: Option<PlanType>,
resets_at: Option<i64>,
}
#[derive(Clone, Default)]
pub struct CoreAuthProvider {
pub token: Option<String>,
pub account_id: Option<String>,
}
impl CoreAuthProvider {
pub fn auth_header_attached(&self) -> bool {
self.token
.as_ref()
.is_some_and(|token| http::HeaderValue::from_str(&format!("Bearer {token}")).is_ok())
}
pub fn auth_header_name(&self) -> Option<&'static str> {
self.auth_header_attached().then_some("authorization")
}
pub fn for_test(token: Option<&str>, account_id: Option<&str>) -> Self {
Self {
token: token.map(str::to_string),
account_id: account_id.map(str::to_string),
}
}
}
impl ApiAuthProvider for CoreAuthProvider {
fn add_auth_headers(&self, headers: &mut HeaderMap) {
if let Some(token) = self.token.as_ref()
&& let Ok(header) = HeaderValue::from_str(&format!("Bearer {token}"))
{
let _ = headers.insert(http::header::AUTHORIZATION, header);
}
if let Some(account_id) = self.account_id.as_ref()
&& let Ok(header) = HeaderValue::from_str(account_id)
{
let _ = headers.insert("ChatGPT-Account-ID", header);
}
}
}
@@ -130,35 +130,3 @@ fn map_api_error_extracts_identity_auth_details_from_headers() {
);
assert_eq!(err.identity_error_code.as_deref(), Some("token_expired"));
}
#[test]
fn core_auth_provider_reports_when_auth_header_will_attach() {
let auth = CoreAuthProvider {
token: Some("access-token".to_string()),
account_id: None,
};
assert!(auth.auth_header_attached());
assert_eq!(auth.auth_header_name(), Some("authorization"));
}
#[test]
fn core_auth_provider_adds_auth_headers() {
let auth = CoreAuthProvider::for_test(Some("access-token"), Some("workspace-123"));
let mut headers = HeaderMap::new();
crate::AuthProvider::add_auth_headers(&auth, &mut headers);
assert_eq!(
headers
.get(http::header::AUTHORIZATION)
.and_then(|value| value.to_str().ok()),
Some("Bearer access-token")
);
assert_eq!(
headers
.get("ChatGPT-Account-ID")
.and_then(|value| value.to_str().ok()),
Some("workspace-123")
);
}
+22
View File
@@ -1,4 +1,5 @@
use http::HeaderMap;
use std::sync::Arc;
/// Adds authentication headers to API requests.
///
@@ -8,3 +9,24 @@ use http::HeaderMap;
pub trait AuthProvider: Send + Sync {
fn add_auth_headers(&self, headers: &mut HeaderMap);
}
/// Shared auth handle passed through API clients.
pub type SharedAuthProvider = Arc<dyn AuthProvider>;
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct AuthHeaderTelemetry {
pub attached: bool,
pub name: Option<&'static str>,
}
pub fn auth_header_telemetry(auth: &dyn AuthProvider) -> AuthHeaderTelemetry {
let mut headers = HeaderMap::new();
auth.add_auth_headers(&mut headers);
let name = headers
.contains_key(http::header::AUTHORIZATION)
.then_some("authorization");
AuthHeaderTelemetry {
attached: name.is_some(),
name,
}
}
+5
View File
@@ -80,6 +80,11 @@ pub enum ResponseEvent {
token_usage: Option<TokenUsage>,
},
OutputTextDelta(String),
ToolCallInputDelta {
item_id: String,
call_id: Option<String>,
delta: String,
},
ReasoningSummaryDelta {
delta: String,
summary_index: i64,
+6 -16
View File
@@ -1,4 +1,4 @@
use crate::auth::AuthProvider;
use crate::auth::SharedAuthProvider;
use crate::common::CompactionInput;
use crate::endpoint::session::EndpointSession;
use crate::error::ApiError;
@@ -12,12 +12,12 @@ use serde::Deserialize;
use serde_json::to_value;
use std::sync::Arc;
pub struct CompactClient<T: HttpTransport, A: AuthProvider> {
session: EndpointSession<T, A>,
pub struct CompactClient<T: HttpTransport> {
session: EndpointSession<T>,
}
impl<T: HttpTransport, A: AuthProvider> CompactClient<T, A> {
pub fn new(transport: T, provider: Provider, auth: A) -> Self {
impl<T: HttpTransport> CompactClient<T> {
pub fn new(transport: T, provider: Provider, auth: SharedAuthProvider) -> Self {
Self {
session: EndpointSession::new(transport, provider, auth),
}
@@ -86,18 +86,8 @@ mod tests {
}
}
#[derive(Clone, Default)]
struct DummyAuth;
impl AuthProvider for DummyAuth {
fn add_auth_headers(&self, _headers: &mut HeaderMap) {}
}
#[test]
fn path_is_responses_compact() {
assert_eq!(
CompactClient::<DummyTransport, DummyAuth>::path(),
"responses/compact"
);
assert_eq!(CompactClient::<DummyTransport>::path(), "responses/compact");
}
}
+8 -7
View File
@@ -1,4 +1,4 @@
use crate::auth::AuthProvider;
use crate::auth::SharedAuthProvider;
use crate::common::MemorySummarizeInput;
use crate::common::MemorySummarizeOutput;
use crate::endpoint::session::EndpointSession;
@@ -12,12 +12,12 @@ use serde::Deserialize;
use serde_json::to_value;
use std::sync::Arc;
pub struct MemoriesClient<T: HttpTransport, A: AuthProvider> {
session: EndpointSession<T, A>,
pub struct MemoriesClient<T: HttpTransport> {
session: EndpointSession<T>,
}
impl<T: HttpTransport, A: AuthProvider> MemoriesClient<T, A> {
pub fn new(transport: T, provider: Provider, auth: A) -> Self {
impl<T: HttpTransport> MemoriesClient<T> {
pub fn new(transport: T, provider: Provider, auth: SharedAuthProvider) -> Self {
Self {
session: EndpointSession::new(transport, provider, auth),
}
@@ -67,6 +67,7 @@ struct SummarizeResponse {
#[cfg(test)]
mod tests {
use super::*;
use crate::auth::AuthProvider;
use crate::common::RawMemory;
use crate::common::RawMemoryMetadata;
use crate::provider::RetryConfig;
@@ -157,7 +158,7 @@ mod tests {
#[test]
fn path_is_memories_trace_summarize_for_wire_compatibility() {
assert_eq!(
MemoriesClient::<DummyTransport, DummyAuth>::path(),
MemoriesClient::<DummyTransport>::path(),
"memories/trace_summarize"
);
}
@@ -178,7 +179,7 @@ mod tests {
let client = MemoriesClient::new(
transport.clone(),
provider("https://example.com/api/codex"),
DummyAuth,
Arc::new(DummyAuth),
);
let input = MemorySummarizeInput {
+9 -8
View File
@@ -1,4 +1,4 @@
use crate::auth::AuthProvider;
use crate::auth::SharedAuthProvider;
use crate::endpoint::session::EndpointSession;
use crate::error::ApiError;
use crate::provider::Provider;
@@ -11,12 +11,12 @@ use http::Method;
use http::header::ETAG;
use std::sync::Arc;
pub struct ModelsClient<T: HttpTransport, A: AuthProvider> {
session: EndpointSession<T, A>,
pub struct ModelsClient<T: HttpTransport> {
session: EndpointSession<T>,
}
impl<T: HttpTransport, A: AuthProvider> ModelsClient<T, A> {
pub fn new(transport: T, provider: Provider, auth: A) -> Self {
impl<T: HttpTransport> ModelsClient<T> {
pub fn new(transport: T, provider: Provider, auth: SharedAuthProvider) -> Self {
Self {
session: EndpointSession::new(transport, provider, auth),
}
@@ -76,6 +76,7 @@ impl<T: HttpTransport, A: AuthProvider> ModelsClient<T, A> {
#[cfg(test)]
mod tests {
use super::*;
use crate::auth::AuthProvider;
use crate::provider::RetryConfig;
use async_trait::async_trait;
use codex_client::Request;
@@ -165,7 +166,7 @@ mod tests {
let client = ModelsClient::new(
transport.clone(),
provider("https://example.com/api/codex"),
DummyAuth,
Arc::new(DummyAuth),
);
let (models, _) = client
@@ -229,7 +230,7 @@ mod tests {
let client = ModelsClient::new(
transport,
provider("https://example.com/api/codex"),
DummyAuth,
Arc::new(DummyAuth),
);
let (models, _) = client
@@ -256,7 +257,7 @@ mod tests {
let client = ModelsClient::new(
transport,
provider("https://example.com/api/codex"),
DummyAuth,
Arc::new(DummyAuth),
);
let (models, etag) = client
@@ -1,4 +1,4 @@
use crate::auth::AuthProvider;
use crate::auth::SharedAuthProvider;
use crate::endpoint::realtime_websocket::RealtimeSessionConfig;
use crate::endpoint::realtime_websocket::session_update_session_json;
use crate::endpoint::session::EndpointSession;
@@ -24,8 +24,8 @@ use tracing::trace;
const MULTIPART_BOUNDARY: &str = "codex-realtime-call-boundary";
const MULTIPART_CONTENT_TYPE: &str = "multipart/form-data; boundary=codex-realtime-call-boundary";
pub struct RealtimeCallClient<T: HttpTransport, A: AuthProvider> {
session: EndpointSession<T, A>,
pub struct RealtimeCallClient<T: HttpTransport> {
session: EndpointSession<T>,
}
/// Answer from creating a WebRTC Realtime call.
@@ -44,8 +44,8 @@ struct BackendRealtimeCallRequest<'a> {
session: &'a Value,
}
impl<T: HttpTransport, A: AuthProvider> RealtimeCallClient<T, A> {
pub fn new(transport: T, provider: Provider, auth: A) -> Self {
impl<T: HttpTransport> RealtimeCallClient<T> {
pub fn new(transport: T, provider: Provider, auth: SharedAuthProvider) -> Self {
Self {
session: EndpointSession::new(transport, provider, auth),
}
@@ -221,6 +221,7 @@ fn decode_call_id_from_location(headers: &HeaderMap) -> Result<String, ApiError>
#[cfg(test)]
mod tests {
use super::*;
use crate::auth::AuthProvider;
use crate::endpoint::realtime_websocket::RealtimeEventParser;
use crate::endpoint::realtime_websocket::RealtimeOutputModality;
use crate::endpoint::realtime_websocket::RealtimeSessionMode;
@@ -327,7 +328,7 @@ mod tests {
let client = RealtimeCallClient::new(
transport.clone(),
provider("https://api.openai.com/v1"),
DummyAuth,
Arc::new(DummyAuth),
);
let response = client
@@ -370,7 +371,7 @@ mod tests {
let client = RealtimeCallClient::new(
transport.clone(),
provider("https://chatgpt.com/backend-api/codex"),
DummyAuth,
Arc::new(DummyAuth),
);
let response = client
@@ -404,7 +405,7 @@ mod tests {
let client = RealtimeCallClient::new(
transport.clone(),
provider("https://api.openai.com/v1"),
DummyAuth,
Arc::new(DummyAuth),
);
let response = client
@@ -466,7 +467,7 @@ mod tests {
let client = RealtimeCallClient::new(
transport.clone(),
provider("https://chatgpt.com/backend-api/codex"),
DummyAuth,
Arc::new(DummyAuth),
);
let response = client
@@ -512,8 +513,11 @@ mod tests {
#[tokio::test]
async fn errors_when_location_is_missing() {
let transport = CapturingTransport::without_location();
let client =
RealtimeCallClient::new(transport, provider("https://api.openai.com/v1"), DummyAuth);
let client = RealtimeCallClient::new(
transport,
provider("https://api.openai.com/v1"),
Arc::new(DummyAuth),
);
let err = client
.create("v=offer\r\n".to_string())
@@ -1588,6 +1588,7 @@ mod tests {
"type": "server_vad",
"interrupt_response": true,
"create_response": true,
"silence_duration_ms": 500,
})
);
assert_eq!(
@@ -84,6 +84,7 @@ pub(super) fn session_update_session(
r#type: TurnDetectionType::ServerVad,
interrupt_response: true,
create_response: true,
silence_duration_ms: 500,
}),
},
output: Some(SessionAudioOutput {
@@ -130,6 +130,7 @@ pub(super) struct SessionTurnDetection {
pub(super) r#type: TurnDetectionType,
pub(super) interrupt_response: bool,
pub(super) create_response: bool,
pub(super) silence_duration_ms: u32,
}
#[derive(Debug, Clone, Copy, Serialize)]
+5 -5
View File
@@ -1,4 +1,4 @@
use crate::auth::AuthProvider;
use crate::auth::SharedAuthProvider;
use crate::common::ResponseStream;
use crate::common::ResponsesApiRequest;
use crate::endpoint::session::EndpointSession;
@@ -23,8 +23,8 @@ use std::sync::Arc;
use std::sync::OnceLock;
use tracing::instrument;
pub struct ResponsesClient<T: HttpTransport, A: AuthProvider> {
session: EndpointSession<T, A>,
pub struct ResponsesClient<T: HttpTransport> {
session: EndpointSession<T>,
sse_telemetry: Option<Arc<dyn SseTelemetry>>,
}
@@ -37,8 +37,8 @@ pub struct ResponsesOptions {
pub turn_state: Option<Arc<OnceLock<String>>>,
}
impl<T: HttpTransport, A: AuthProvider> ResponsesClient<T, A> {
pub fn new(transport: T, provider: Provider, auth: A) -> Self {
impl<T: HttpTransport> ResponsesClient<T> {
pub fn new(transport: T, provider: Provider, auth: SharedAuthProvider) -> Self {
Self {
session: EndpointSession::new(transport, provider, auth),
sse_telemetry: None,
@@ -1,4 +1,4 @@
use crate::auth::AuthProvider;
use crate::auth::SharedAuthProvider;
use crate::common::ResponseEvent;
use crate::common::ResponseStream;
use crate::common::ResponsesWsRequest;
@@ -279,13 +279,13 @@ impl ResponsesWebsocketConnection {
}
}
pub struct ResponsesWebsocketClient<A: AuthProvider> {
pub struct ResponsesWebsocketClient {
provider: Provider,
auth: A,
auth: SharedAuthProvider,
}
impl<A: AuthProvider> ResponsesWebsocketClient<A> {
pub fn new(provider: Provider, auth: A) -> Self {
impl ResponsesWebsocketClient {
pub fn new(provider: Provider, auth: SharedAuthProvider) -> Self {
Self { provider, auth }
}

Some files were not shown because too many files have changed in this diff Show More