mirror of
https://github.com/openai/codex.git
synced 2026-10-23 18:08:45 +00:00
Merge remote-tracking branch 'origin/main' into jif/pr-27179-cleanup
# Conflicts: # codex-rs/tui/src/app_server_session.rs
This commit is contained in:
Generated
+12
-9
@@ -1892,6 +1892,7 @@ dependencies = [
|
||||
"codex-model-provider",
|
||||
"codex-plugin",
|
||||
"codex-protocol",
|
||||
"codex-state",
|
||||
"codex-utils-absolute-path",
|
||||
"os_info",
|
||||
"pretty_assertions",
|
||||
@@ -2920,7 +2921,6 @@ dependencies = [
|
||||
name = "codex-extension-api"
|
||||
version = "0.0.0"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"codex-config",
|
||||
"codex-context-fragments",
|
||||
"codex-protocol",
|
||||
@@ -3047,8 +3047,8 @@ name = "codex-goal-extension"
|
||||
version = "0.0.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
"chrono",
|
||||
"codex-analytics",
|
||||
"codex-core",
|
||||
"codex-extension-api",
|
||||
"codex-otel",
|
||||
@@ -3068,7 +3068,6 @@ dependencies = [
|
||||
name = "codex-guardian"
|
||||
version = "0.0.0"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"codex-core",
|
||||
"codex-extension-api",
|
||||
"codex-protocol",
|
||||
@@ -3101,7 +3100,6 @@ dependencies = [
|
||||
name = "codex-image-generation-extension"
|
||||
version = "0.0.0"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"codex-api",
|
||||
"codex-core",
|
||||
"codex-extension-api",
|
||||
@@ -3222,6 +3220,7 @@ name = "codex-mcp"
|
||||
version = "0.0.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"arc-swap",
|
||||
"async-channel",
|
||||
"codex-api",
|
||||
"codex-async-utils",
|
||||
@@ -3253,7 +3252,6 @@ dependencies = [
|
||||
name = "codex-mcp-extension"
|
||||
version = "0.0.0"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"codex-config",
|
||||
"codex-core",
|
||||
"codex-core-plugins",
|
||||
@@ -3302,7 +3300,6 @@ dependencies = [
|
||||
name = "codex-memories-extension"
|
||||
version = "0.0.0"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"codex-core",
|
||||
"codex-extension-api",
|
||||
"codex-features",
|
||||
@@ -3342,6 +3339,8 @@ dependencies = [
|
||||
"codex-features",
|
||||
"codex-git-utils",
|
||||
"codex-login",
|
||||
"codex-model-provider",
|
||||
"codex-model-provider-info",
|
||||
"codex-models-manager",
|
||||
"codex-otel",
|
||||
"codex-protocol",
|
||||
@@ -3816,15 +3815,21 @@ name = "codex-skills-extension"
|
||||
version = "0.0.0"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"codex-core",
|
||||
"codex-core-skills",
|
||||
"codex-exec-server",
|
||||
"codex-extension-api",
|
||||
"codex-mcp",
|
||||
"codex-protocol",
|
||||
"codex-tools",
|
||||
"codex-utils-absolute-path",
|
||||
"codex-utils-string",
|
||||
"pretty_assertions",
|
||||
"schemars 0.8.22",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"url",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3915,7 +3920,6 @@ dependencies = [
|
||||
name = "codex-tools"
|
||||
version = "0.0.0"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"codex-app-server-protocol",
|
||||
"codex-code-mode",
|
||||
"codex-features",
|
||||
@@ -4296,7 +4300,6 @@ dependencies = [
|
||||
name = "codex-web-search-extension"
|
||||
version = "0.0.0"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"codex-api",
|
||||
"codex-core",
|
||||
"codex-extension-api",
|
||||
|
||||
@@ -19,6 +19,7 @@ codex-login = { workspace = true }
|
||||
codex-model-provider = { workspace = true }
|
||||
codex-plugin = { workspace = true }
|
||||
codex-protocol = { workspace = true }
|
||||
codex-state = { workspace = true }
|
||||
os_info = { workspace = true }
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
serde_json = { workspace = true }
|
||||
|
||||
@@ -43,6 +43,7 @@ use crate::facts::AppInvocation;
|
||||
use crate::facts::AppMentionedInput;
|
||||
use crate::facts::AppUsedInput;
|
||||
use crate::facts::CodexCompactionEvent;
|
||||
use crate::facts::CodexErrKind;
|
||||
use crate::facts::CompactionImplementation;
|
||||
use crate::facts::CompactionPhase;
|
||||
use crate::facts::CompactionReason;
|
||||
@@ -778,6 +779,33 @@ fn sample_initialize_fact(connection_id: u64) -> AnalyticsFact {
|
||||
}
|
||||
}
|
||||
|
||||
async fn ingest_complete_child_turn(
|
||||
reducer: &mut AnalyticsReducer,
|
||||
events: &mut Vec<TrackEventRequest>,
|
||||
thread_id: &str,
|
||||
turn_id: &str,
|
||||
) {
|
||||
for fact in [
|
||||
AnalyticsFact::Custom(CustomAnalyticsFact::TurnResolvedConfig(Box::new(
|
||||
sample_turn_resolved_config(thread_id, turn_id),
|
||||
))),
|
||||
AnalyticsFact::Custom(CustomAnalyticsFact::TurnProfile(Box::new(
|
||||
TurnProfileFact {
|
||||
turn_id: turn_id.to_string(),
|
||||
profile: sample_turn_profile(),
|
||||
},
|
||||
))),
|
||||
AnalyticsFact::Notification(Box::new(sample_turn_completed_notification(
|
||||
thread_id,
|
||||
turn_id,
|
||||
AppServerTurnStatus::Completed,
|
||||
/*codex_error_info*/ None,
|
||||
))),
|
||||
] {
|
||||
reducer.ingest(fact, events).await;
|
||||
}
|
||||
}
|
||||
|
||||
fn sample_command_execution_item(
|
||||
status: CommandExecutionStatus,
|
||||
exit_code: Option<i32>,
|
||||
@@ -1251,11 +1279,13 @@ fn compaction_event_serializes_expected_shape() {
|
||||
phase: CompactionPhase::MidTurn,
|
||||
strategy: CompactionStrategy::Memento,
|
||||
status: CompactionStatus::Completed,
|
||||
error: None,
|
||||
codex_error_kind: None,
|
||||
codex_error_http_status_code: None,
|
||||
active_context_tokens_before: 120_000,
|
||||
active_context_tokens_after: 18_000,
|
||||
retained_image_count: None,
|
||||
compaction_summary_tokens: None,
|
||||
cached_input_tokens: None,
|
||||
started_at: 100,
|
||||
completed_at: 106,
|
||||
duration_ms: Some(6543),
|
||||
@@ -1301,11 +1331,13 @@ fn compaction_event_serializes_expected_shape() {
|
||||
"phase": "mid_turn",
|
||||
"strategy": "memento",
|
||||
"status": "completed",
|
||||
"error": null,
|
||||
"codex_error_kind": null,
|
||||
"codex_error_http_status_code": null,
|
||||
"active_context_tokens_before": 120000,
|
||||
"active_context_tokens_after": 18000,
|
||||
"retained_image_count": null,
|
||||
"compaction_summary_tokens": null,
|
||||
"cached_input_tokens": null,
|
||||
"started_at": 100,
|
||||
"completed_at": 106,
|
||||
"duration_ms": 6543
|
||||
@@ -1823,11 +1855,13 @@ async fn compaction_event_ingests_custom_fact() {
|
||||
phase: CompactionPhase::StandaloneTurn,
|
||||
strategy: CompactionStrategy::Memento,
|
||||
status: CompactionStatus::Failed,
|
||||
error: Some("context limit exceeded".to_string()),
|
||||
codex_error_kind: Some(CodexErrKind::ContextWindowExceeded),
|
||||
codex_error_http_status_code: None,
|
||||
active_context_tokens_before: 131_000,
|
||||
active_context_tokens_after: 131_000,
|
||||
retained_image_count: None,
|
||||
compaction_summary_tokens: None,
|
||||
cached_input_tokens: None,
|
||||
started_at: 100,
|
||||
completed_at: 101,
|
||||
duration_ms: Some(1200),
|
||||
@@ -1843,6 +1877,14 @@ async fn compaction_event_ingests_custom_fact() {
|
||||
assert_eq!(payload[0]["event_params"]["session_id"], "session-thread-1");
|
||||
assert_eq!(payload[0]["event_params"]["thread_id"], "thread-1");
|
||||
assert_eq!(payload[0]["event_params"]["turn_id"], "turn-compact");
|
||||
assert_eq!(
|
||||
payload[0]["event_params"]["codex_error_kind"],
|
||||
json!("context_window_exceeded")
|
||||
);
|
||||
assert_eq!(
|
||||
payload[0]["event_params"]["codex_error_http_status_code"],
|
||||
json!(null)
|
||||
);
|
||||
assert_eq!(
|
||||
payload[0]["event_params"]["app_server_client"]["product_client_id"],
|
||||
DEFAULT_ORIGINATOR
|
||||
@@ -1937,6 +1979,7 @@ async fn guardian_review_event_ingests_custom_fact_with_optional_target_item() {
|
||||
decision: GuardianReviewDecision::Denied,
|
||||
terminal_status: GuardianReviewTerminalStatus::TimedOut,
|
||||
failure_reason: Some(GuardianReviewFailureReason::Timeout),
|
||||
attempt_count: 1,
|
||||
risk_level: None,
|
||||
user_authorization: None,
|
||||
outcome: None,
|
||||
@@ -2008,6 +2051,7 @@ async fn guardian_review_event_ingests_custom_fact_with_optional_target_item() {
|
||||
);
|
||||
assert_eq!(payload[0]["event_params"]["terminal_status"], "timed_out");
|
||||
assert_eq!(payload[0]["event_params"]["failure_reason"], "timeout");
|
||||
assert_eq!(payload[0]["event_params"]["attempt_count"], 1);
|
||||
assert_eq!(payload[0]["event_params"]["review_timeout_ms"], 90_000);
|
||||
}
|
||||
|
||||
@@ -2665,7 +2709,7 @@ async fn subagent_thread_started_publishes_without_initialize() {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn subagent_thread_started_inherits_parent_connection_for_new_thread() {
|
||||
async fn subagent_events_use_inherited_connection_unless_turn_connection_is_explicit() {
|
||||
let mut reducer = AnalyticsReducer::default();
|
||||
let mut events = Vec::new();
|
||||
let parent_thread_id =
|
||||
@@ -2747,11 +2791,13 @@ async fn subagent_thread_started_inherits_parent_connection_for_new_thread() {
|
||||
phase: CompactionPhase::StandaloneTurn,
|
||||
strategy: CompactionStrategy::Memento,
|
||||
status: CompactionStatus::Completed,
|
||||
error: None,
|
||||
codex_error_kind: None,
|
||||
codex_error_http_status_code: None,
|
||||
active_context_tokens_before: 131_000,
|
||||
active_context_tokens_after: 64_000,
|
||||
retained_image_count: None,
|
||||
compaction_summary_tokens: None,
|
||||
cached_input_tokens: None,
|
||||
started_at: 100,
|
||||
completed_at: 101,
|
||||
duration_ms: Some(1200),
|
||||
@@ -2772,6 +2818,68 @@ async fn subagent_thread_started_inherits_parent_connection_for_new_thread() {
|
||||
payload[0]["event_params"]["parent_thread_id"],
|
||||
"44444444-4444-4444-4444-444444444444"
|
||||
);
|
||||
|
||||
events.clear();
|
||||
ingest_complete_child_turn(&mut reducer, &mut events, "thread-review", "turn-inherited").await;
|
||||
let [TrackEventRequest::TurnEvent(event)] = events.as_slice() else {
|
||||
panic!("expected one turn event");
|
||||
};
|
||||
let params = &event.event_params;
|
||||
assert_eq!(params.session_id, "session-root");
|
||||
assert_eq!(params.thread_source, Some(ThreadSource::Subagent));
|
||||
assert_eq!(params.subagent_source.as_deref(), Some("thread_spawn"));
|
||||
assert_eq!(
|
||||
params.parent_thread_id.as_deref(),
|
||||
Some("44444444-4444-4444-4444-444444444444")
|
||||
);
|
||||
assert_eq!(params.app_server_client.product_client_id, "parent-client");
|
||||
assert_eq!(params.runtime.codex_rs_version, "0.1.0");
|
||||
|
||||
reducer
|
||||
.ingest(
|
||||
AnalyticsFact::Custom(CustomAnalyticsFact::TurnTokenUsage(Box::new(
|
||||
sample_turn_token_usage_fact("thread-review", "turn-inherited"),
|
||||
))),
|
||||
&mut events,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(events.len(), 1);
|
||||
|
||||
events.clear();
|
||||
reducer
|
||||
.ingest(sample_initialize_fact(/*connection_id*/ 8), &mut events)
|
||||
.await;
|
||||
reducer
|
||||
.ingest(
|
||||
AnalyticsFact::ClientRequest {
|
||||
connection_id: 8,
|
||||
request_id: RequestId::Integer(3),
|
||||
request: Box::new(sample_turn_start_request(
|
||||
"thread-review",
|
||||
/*request_id*/ 3,
|
||||
)),
|
||||
},
|
||||
&mut events,
|
||||
)
|
||||
.await;
|
||||
reducer
|
||||
.ingest(
|
||||
AnalyticsFact::ClientResponse {
|
||||
connection_id: 8,
|
||||
request_id: RequestId::Integer(3),
|
||||
response: Box::new(sample_turn_start_response("turn-explicit")),
|
||||
},
|
||||
&mut events,
|
||||
)
|
||||
.await;
|
||||
ingest_complete_child_turn(&mut reducer, &mut events, "thread-review", "turn-explicit").await;
|
||||
let [TrackEventRequest::TurnEvent(event)] = events.as_slice() else {
|
||||
panic!("expected one turn event");
|
||||
};
|
||||
assert_eq!(
|
||||
event.event_params.app_server_client.product_client_id,
|
||||
DEFAULT_ORIGINATOR
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
@@ -9,6 +9,7 @@ use crate::facts::AnalyticsJsonRpcError;
|
||||
use crate::facts::AppInvocation;
|
||||
use crate::facts::AppMentionedInput;
|
||||
use crate::facts::AppUsedInput;
|
||||
use crate::facts::CodexGoalEvent;
|
||||
use crate::facts::CustomAnalyticsFact;
|
||||
use crate::facts::HookRunFact;
|
||||
use crate::facts::HookRunInput;
|
||||
@@ -246,6 +247,12 @@ impl AnalyticsEventsClient {
|
||||
)));
|
||||
}
|
||||
|
||||
pub fn track_goal_event(&self, event: CodexGoalEvent) {
|
||||
self.record_fact(AnalyticsFact::Custom(CustomAnalyticsFact::Goal(Box::new(
|
||||
event,
|
||||
))));
|
||||
}
|
||||
|
||||
pub fn track_turn_resolved_config(&self, fact: TurnResolvedConfigFact) {
|
||||
self.record_fact(AnalyticsFact::Custom(
|
||||
CustomAnalyticsFact::TurnResolvedConfig(Box::new(fact)),
|
||||
|
||||
@@ -4,12 +4,14 @@ use crate::facts::AcceptedLineFingerprint;
|
||||
use crate::facts::AppInvocation;
|
||||
use crate::facts::CodexCompactionEvent;
|
||||
use crate::facts::CodexErrKind;
|
||||
use crate::facts::CodexGoalEvent;
|
||||
use crate::facts::CompactionImplementation;
|
||||
use crate::facts::CompactionPhase;
|
||||
use crate::facts::CompactionReason;
|
||||
use crate::facts::CompactionStatus;
|
||||
use crate::facts::CompactionStrategy;
|
||||
use crate::facts::CompactionTrigger;
|
||||
use crate::facts::GoalEventKind;
|
||||
use crate::facts::HookRunFact;
|
||||
use crate::facts::InvocationType;
|
||||
use crate::facts::PluginState;
|
||||
@@ -63,6 +65,7 @@ pub(crate) enum TrackEventRequest {
|
||||
AppUsed(CodexAppUsedEventRequest),
|
||||
HookRun(CodexHookRunEventRequest),
|
||||
Compaction(Box<CodexCompactionEventRequest>),
|
||||
Goal(Box<CodexGoalEventRequest>),
|
||||
TurnEvent(Box<CodexTurnEventRequest>),
|
||||
TurnSteer(CodexTurnSteerEventRequest),
|
||||
CommandExecution(CodexCommandExecutionEventRequest),
|
||||
@@ -263,6 +266,7 @@ pub struct GuardianReviewEventParams {
|
||||
pub decision: GuardianReviewDecision,
|
||||
pub terminal_status: GuardianReviewTerminalStatus,
|
||||
pub failure_reason: Option<GuardianReviewFailureReason>,
|
||||
pub attempt_count: i64,
|
||||
pub risk_level: Option<GuardianRiskLevel>,
|
||||
pub user_authorization: Option<GuardianUserAuthorization>,
|
||||
pub outcome: Option<GuardianAssessmentOutcome>,
|
||||
@@ -335,6 +339,7 @@ impl GuardianReviewTrackContext {
|
||||
decision: result.decision,
|
||||
terminal_status: result.terminal_status,
|
||||
failure_reason: result.failure_reason,
|
||||
attempt_count: result.attempt_count,
|
||||
risk_level: result.risk_level,
|
||||
user_authorization: result.user_authorization,
|
||||
outcome: result.outcome,
|
||||
@@ -370,6 +375,7 @@ pub struct GuardianReviewAnalyticsResult {
|
||||
pub decision: GuardianReviewDecision,
|
||||
pub terminal_status: GuardianReviewTerminalStatus,
|
||||
pub failure_reason: Option<GuardianReviewFailureReason>,
|
||||
pub attempt_count: i64,
|
||||
pub risk_level: Option<GuardianRiskLevel>,
|
||||
pub user_authorization: Option<GuardianUserAuthorization>,
|
||||
pub outcome: Option<GuardianAssessmentOutcome>,
|
||||
@@ -389,6 +395,7 @@ impl GuardianReviewAnalyticsResult {
|
||||
decision: GuardianReviewDecision::Denied,
|
||||
terminal_status: GuardianReviewTerminalStatus::FailedClosed,
|
||||
failure_reason: None,
|
||||
attempt_count: 1,
|
||||
risk_level: None,
|
||||
user_authorization: None,
|
||||
outcome: None,
|
||||
@@ -755,11 +762,13 @@ pub(crate) struct CodexCompactionEventParams {
|
||||
pub(crate) phase: CompactionPhase,
|
||||
pub(crate) strategy: CompactionStrategy,
|
||||
pub(crate) status: CompactionStatus,
|
||||
pub(crate) error: Option<String>,
|
||||
pub(crate) codex_error_kind: Option<CodexErrKind>,
|
||||
pub(crate) codex_error_http_status_code: Option<u16>,
|
||||
pub(crate) active_context_tokens_before: i64,
|
||||
pub(crate) active_context_tokens_after: i64,
|
||||
pub(crate) retained_image_count: Option<usize>,
|
||||
pub(crate) compaction_summary_tokens: Option<i64>,
|
||||
pub(crate) cached_input_tokens: Option<i64>,
|
||||
pub(crate) started_at: u64,
|
||||
pub(crate) completed_at: u64,
|
||||
pub(crate) duration_ms: Option<u64>,
|
||||
@@ -771,6 +780,30 @@ pub(crate) struct CodexCompactionEventRequest {
|
||||
pub(crate) event_params: CodexCompactionEventParams,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub(crate) struct CodexGoalEventParams {
|
||||
pub(crate) thread_id: String,
|
||||
pub(crate) session_id: String,
|
||||
pub(crate) turn_id: Option<String>,
|
||||
pub(crate) app_server_client: CodexAppServerClientMetadata,
|
||||
pub(crate) runtime: CodexRuntimeMetadata,
|
||||
pub(crate) thread_source: Option<ThreadSource>,
|
||||
pub(crate) subagent_source: Option<String>,
|
||||
pub(crate) parent_thread_id: Option<String>,
|
||||
pub(crate) goal_id: String,
|
||||
pub(crate) event_kind: GoalEventKind,
|
||||
pub(crate) goal_status: codex_state::ThreadGoalStatus,
|
||||
pub(crate) has_token_budget: bool,
|
||||
pub(crate) cumulative_tokens_accounted: Option<i64>,
|
||||
pub(crate) cumulative_time_accounted_seconds: Option<i64>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub(crate) struct CodexGoalEventRequest {
|
||||
pub(crate) event_type: &'static str,
|
||||
pub(crate) event_params: CodexGoalEventParams,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub(crate) struct CodexTurnEventParams {
|
||||
pub(crate) thread_id: String,
|
||||
@@ -968,17 +1001,46 @@ pub(crate) fn codex_compaction_event_params(
|
||||
phase: input.phase,
|
||||
strategy: input.strategy,
|
||||
status: input.status,
|
||||
error: input.error,
|
||||
codex_error_kind: input.codex_error_kind,
|
||||
codex_error_http_status_code: input.codex_error_http_status_code,
|
||||
active_context_tokens_before: input.active_context_tokens_before,
|
||||
active_context_tokens_after: input.active_context_tokens_after,
|
||||
retained_image_count: input.retained_image_count,
|
||||
compaction_summary_tokens: input.compaction_summary_tokens,
|
||||
cached_input_tokens: input.cached_input_tokens,
|
||||
started_at: input.started_at,
|
||||
completed_at: input.completed_at,
|
||||
duration_ms: input.duration_ms,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn codex_goal_event_params(
|
||||
input: CodexGoalEvent,
|
||||
session_id: String,
|
||||
app_server_client: CodexAppServerClientMetadata,
|
||||
runtime: CodexRuntimeMetadata,
|
||||
thread_source: Option<ThreadSource>,
|
||||
subagent_source: Option<String>,
|
||||
parent_thread_id: Option<String>,
|
||||
) -> CodexGoalEventParams {
|
||||
CodexGoalEventParams {
|
||||
thread_id: input.thread_id,
|
||||
session_id,
|
||||
turn_id: input.turn_id,
|
||||
app_server_client,
|
||||
runtime,
|
||||
thread_source,
|
||||
subagent_source,
|
||||
parent_thread_id,
|
||||
goal_id: input.goal_id,
|
||||
event_kind: input.event_kind,
|
||||
goal_status: input.goal_status,
|
||||
has_token_budget: input.has_token_budget,
|
||||
cumulative_tokens_accounted: input.cumulative_tokens_accounted,
|
||||
cumulative_time_accounted_seconds: input.cumulative_time_accounted_seconds,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn codex_plugin_used_metadata(
|
||||
tracking: &TrackEventsContext,
|
||||
plugin: PluginTelemetryMetadata,
|
||||
|
||||
@@ -137,7 +137,7 @@ impl TurnCodexErrorFact {
|
||||
|
||||
#[derive(Clone, Copy, Debug, Serialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub(crate) enum CodexErrKind {
|
||||
pub enum CodexErrKind {
|
||||
TurnAborted,
|
||||
Stream,
|
||||
ContextWindowExceeded,
|
||||
@@ -364,6 +364,7 @@ pub enum CompactionReason {
|
||||
UserRequested,
|
||||
ContextLimit,
|
||||
ModelDownshift,
|
||||
CompHashChanged,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Serialize)]
|
||||
@@ -407,16 +408,39 @@ pub struct CodexCompactionEvent {
|
||||
pub phase: CompactionPhase,
|
||||
pub strategy: CompactionStrategy,
|
||||
pub status: CompactionStatus,
|
||||
pub error: Option<String>,
|
||||
pub codex_error_kind: Option<CodexErrKind>,
|
||||
pub codex_error_http_status_code: Option<u16>,
|
||||
pub active_context_tokens_before: i64,
|
||||
pub active_context_tokens_after: i64,
|
||||
pub retained_image_count: Option<usize>,
|
||||
pub compaction_summary_tokens: Option<i64>,
|
||||
pub cached_input_tokens: Option<i64>,
|
||||
pub started_at: u64,
|
||||
pub completed_at: u64,
|
||||
pub duration_ms: Option<u64>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Serialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum GoalEventKind {
|
||||
Created,
|
||||
UsageAccounted,
|
||||
StatusChanged,
|
||||
Cleared,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct CodexGoalEvent {
|
||||
pub thread_id: String,
|
||||
pub turn_id: Option<String>,
|
||||
pub goal_id: String,
|
||||
pub event_kind: GoalEventKind,
|
||||
pub goal_status: codex_state::ThreadGoalStatus,
|
||||
pub has_token_budget: bool,
|
||||
pub cumulative_tokens_accounted: Option<i64>,
|
||||
pub cumulative_time_accounted_seconds: Option<i64>,
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub(crate) enum AnalyticsFact {
|
||||
Initialize {
|
||||
@@ -468,6 +492,7 @@ pub(crate) enum AnalyticsFact {
|
||||
pub(crate) enum CustomAnalyticsFact {
|
||||
SubAgentThreadStarted(SubAgentThreadStartedInput),
|
||||
Compaction(Box<CodexCompactionEvent>),
|
||||
Goal(Box<CodexGoalEvent>),
|
||||
GuardianReview(Box<GuardianReviewEventParams>),
|
||||
TurnResolvedConfig(Box<TurnResolvedConfigFact>),
|
||||
TurnTokenUsage(Box<TurnTokenUsageFact>),
|
||||
|
||||
@@ -24,6 +24,8 @@ pub use facts::AcceptedLineFingerprint;
|
||||
pub use facts::AnalyticsJsonRpcError;
|
||||
pub use facts::AppInvocation;
|
||||
pub use facts::CodexCompactionEvent;
|
||||
pub use facts::CodexErrKind;
|
||||
pub use facts::CodexGoalEvent;
|
||||
pub use facts::CodexTurnSteerEvent;
|
||||
pub use facts::CompactionImplementation;
|
||||
pub use facts::CompactionPhase;
|
||||
@@ -31,6 +33,7 @@ pub use facts::CompactionReason;
|
||||
pub use facts::CompactionStatus;
|
||||
pub use facts::CompactionStrategy;
|
||||
pub use facts::CompactionTrigger;
|
||||
pub use facts::GoalEventKind;
|
||||
pub use facts::HookRunFact;
|
||||
pub use facts::InputError;
|
||||
pub use facts::InvocationType;
|
||||
|
||||
@@ -15,6 +15,7 @@ use crate::events::CodexDynamicToolCallEventParams;
|
||||
use crate::events::CodexDynamicToolCallEventRequest;
|
||||
use crate::events::CodexFileChangeEventParams;
|
||||
use crate::events::CodexFileChangeEventRequest;
|
||||
use crate::events::CodexGoalEventRequest;
|
||||
use crate::events::CodexHookRunEventRequest;
|
||||
use crate::events::CodexImageGenerationEventParams;
|
||||
use crate::events::CodexImageGenerationEventRequest;
|
||||
@@ -51,6 +52,7 @@ use crate::events::TrackEventRequest;
|
||||
use crate::events::WebSearchActionKind;
|
||||
use crate::events::codex_app_metadata;
|
||||
use crate::events::codex_compaction_event_params;
|
||||
use crate::events::codex_goal_event_params;
|
||||
use crate::events::codex_hook_run_metadata;
|
||||
use crate::events::codex_plugin_metadata;
|
||||
use crate::events::codex_plugin_used_metadata;
|
||||
@@ -62,6 +64,7 @@ use crate::facts::AnalyticsJsonRpcError;
|
||||
use crate::facts::AppMentionedInput;
|
||||
use crate::facts::AppUsedInput;
|
||||
use crate::facts::CodexCompactionEvent;
|
||||
use crate::facts::CodexGoalEvent;
|
||||
use crate::facts::CustomAnalyticsFact;
|
||||
use crate::facts::HookRunInput;
|
||||
use crate::facts::PluginState;
|
||||
@@ -192,6 +195,16 @@ impl<'a> AnalyticsDropSite<'a> {
|
||||
}
|
||||
}
|
||||
|
||||
fn goal(input: &'a CodexGoalEvent) -> Self {
|
||||
Self {
|
||||
event_name: "goal",
|
||||
thread_id: &input.thread_id,
|
||||
turn_id: input.turn_id.as_deref(),
|
||||
review_id: None,
|
||||
item_id: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn tool_item(
|
||||
notification: &'a codex_app_server_protocol::ItemCompletedNotification,
|
||||
item_id: &'a str,
|
||||
@@ -461,6 +474,9 @@ impl AnalyticsReducer {
|
||||
CustomAnalyticsFact::Compaction(input) => {
|
||||
self.ingest_compaction(*input, out);
|
||||
}
|
||||
CustomAnalyticsFact::Goal(input) => {
|
||||
self.ingest_goal(*input, out);
|
||||
}
|
||||
CustomAnalyticsFact::GuardianReview(input) => {
|
||||
self.ingest_guardian_review(*input, out);
|
||||
}
|
||||
@@ -1271,6 +1287,26 @@ impl AnalyticsReducer {
|
||||
)));
|
||||
}
|
||||
|
||||
fn ingest_goal(&mut self, input: CodexGoalEvent, out: &mut Vec<TrackEventRequest>) {
|
||||
let Some((connection_state, thread_metadata)) =
|
||||
self.thread_context_or_warn(AnalyticsDropSite::goal(&input))
|
||||
else {
|
||||
return;
|
||||
};
|
||||
out.push(TrackEventRequest::Goal(Box::new(CodexGoalEventRequest {
|
||||
event_type: "codex_goal_event",
|
||||
event_params: codex_goal_event_params(
|
||||
input,
|
||||
thread_metadata.session_id.clone(),
|
||||
connection_state.app_server_client.clone(),
|
||||
connection_state.runtime.clone(),
|
||||
thread_metadata.thread_source.clone(),
|
||||
thread_metadata.subagent_source.clone(),
|
||||
thread_metadata.parent_thread_id.clone(),
|
||||
),
|
||||
})));
|
||||
}
|
||||
|
||||
fn ingest_guardian_review_completed(
|
||||
&mut self,
|
||||
notification: codex_app_server_protocol::ItemGuardianApprovalReviewCompletedNotification,
|
||||
@@ -1461,17 +1497,23 @@ impl AnalyticsReducer {
|
||||
let Some(thread_id) = turn_state.thread_id.as_ref() else {
|
||||
return;
|
||||
};
|
||||
let Some(connection_id) = turn_state.connection_id else {
|
||||
let drop_site = AnalyticsDropSite::turn(thread_id, turn_id);
|
||||
let connection_id = turn_state.connection_id.or_else(|| {
|
||||
self.threads
|
||||
.get(drop_site.thread_id)
|
||||
.and_then(|thread| thread.connection_id)
|
||||
});
|
||||
let Some(connection_id) = connection_id else {
|
||||
warn_missing_analytics_context(&drop_site, MissingAnalyticsContext::ThreadConnection);
|
||||
return;
|
||||
};
|
||||
let Some(connection_state) = self.connections.get(&connection_id) else {
|
||||
warn_missing_analytics_context(
|
||||
&AnalyticsDropSite::turn(thread_id, turn_id),
|
||||
&drop_site,
|
||||
MissingAnalyticsContext::Connection { connection_id },
|
||||
);
|
||||
return;
|
||||
};
|
||||
let drop_site = AnalyticsDropSite::turn(thread_id, turn_id);
|
||||
let Some(thread_metadata) = self
|
||||
.threads
|
||||
.get(drop_site.thread_id)
|
||||
|
||||
@@ -22,6 +22,7 @@ use std::fmt;
|
||||
use std::io::Error as IoError;
|
||||
use std::io::ErrorKind;
|
||||
use std::io::Result as IoResult;
|
||||
use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
@@ -48,7 +49,11 @@ use codex_config::LoaderOverrides;
|
||||
use codex_config::NoopThreadConfigLoader;
|
||||
use codex_config::RemoteThreadConfigLoader;
|
||||
use codex_config::ThreadConfigLoader;
|
||||
use codex_config::config_toml::ConfigToml;
|
||||
use codex_core::config::Config;
|
||||
pub use codex_core::otel_init::build_provider as build_otel_provider;
|
||||
use codex_core::personality_migration::PersonalityMigrationStatus;
|
||||
use codex_core::personality_migration::maybe_migrate_personality;
|
||||
pub use codex_exec_server::EnvironmentManager;
|
||||
pub use codex_exec_server::ExecServerRuntimePaths;
|
||||
use codex_feedback::CodexFeedback;
|
||||
@@ -82,18 +87,6 @@ pub mod legacy_core {
|
||||
}
|
||||
}
|
||||
|
||||
pub mod otel_init {
|
||||
pub use codex_core::otel_init::*;
|
||||
}
|
||||
|
||||
pub mod personality_migration {
|
||||
pub use codex_core::personality_migration::*;
|
||||
}
|
||||
|
||||
pub mod test_support {
|
||||
pub use codex_core::test_support::*;
|
||||
}
|
||||
|
||||
pub mod windows_sandbox {
|
||||
pub use codex_core::windows_sandbox::*;
|
||||
}
|
||||
@@ -101,6 +94,23 @@ pub mod legacy_core {
|
||||
|
||||
const SHUTDOWN_TIMEOUT: Duration = Duration::from_secs(5);
|
||||
|
||||
/// Runs the embedded app-server personality migration.
|
||||
///
|
||||
/// Returns `true` when the migration changed config and the caller should reload it.
|
||||
pub async fn migrate_personality_if_needed(
|
||||
codex_home: &Path,
|
||||
config_toml: &ConfigToml,
|
||||
state_db: Option<StateDbHandle>,
|
||||
) -> IoResult<bool> {
|
||||
let status = maybe_migrate_personality(codex_home, config_toml, state_db).await?;
|
||||
match status {
|
||||
PersonalityMigrationStatus::Applied => Ok(true),
|
||||
PersonalityMigrationStatus::SkippedMarker
|
||||
| PersonalityMigrationStatus::SkippedExplicitPersonality
|
||||
| PersonalityMigrationStatus::SkippedNoSessions => Ok(false),
|
||||
}
|
||||
}
|
||||
|
||||
/// Raw app-server request result for typed in-process requests.
|
||||
///
|
||||
/// Even on the in-process path, successful responses still travel back through
|
||||
@@ -158,6 +168,7 @@ pub(crate) fn server_notification_requires_delivery(notification: &ServerNotific
|
||||
ServerNotification::TurnCompleted(_)
|
||||
| ServerNotification::ThreadSettingsUpdated(_)
|
||||
| ServerNotification::ItemCompleted(_)
|
||||
| ServerNotification::ExternalAgentConfigImportCompleted(_)
|
||||
| ServerNotification::AgentMessageDelta(_)
|
||||
| ServerNotification::PlanDelta(_)
|
||||
| ServerNotification::ReasoningSummaryTextDelta(_)
|
||||
@@ -2139,6 +2150,13 @@ mod tests {
|
||||
)
|
||||
)
|
||||
));
|
||||
assert!(event_requires_delivery(
|
||||
&InProcessServerEvent::ServerNotification(
|
||||
codex_app_server_protocol::ServerNotification::ExternalAgentConfigImportCompleted(
|
||||
codex_app_server_protocol::ExternalAgentConfigImportCompletedNotification {},
|
||||
)
|
||||
)
|
||||
));
|
||||
assert!(!event_requires_delivery(&InProcessServerEvent::Lagged {
|
||||
skipped: 1
|
||||
}));
|
||||
|
||||
@@ -1993,6 +1993,13 @@
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"RealtimeConversationVersion": {
|
||||
"enum": [
|
||||
"v1",
|
||||
"v2"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"RealtimeOutputModality": {
|
||||
"enum": [
|
||||
"text",
|
||||
@@ -3196,6 +3203,17 @@
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"ThreadDeleteParams": {
|
||||
"properties": {
|
||||
"threadId": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"threadId"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"ThreadForkParams": {
|
||||
"description": "There are two ways to fork a thread: 1. By thread_id: load the thread from disk by thread_id and fork it into a new thread. 2. By path: load the thread from disk by path and fork it into a new thread.\n\nIf using a non-empty path, the thread_id param will be ignored. Empty string path values are treated as absent.\n\nPrefer using thread_id whenever possible.",
|
||||
"properties": {
|
||||
@@ -4511,6 +4529,30 @@
|
||||
"title": "Thread/archiveRequest",
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"id": {
|
||||
"$ref": "#/definitions/RequestId"
|
||||
},
|
||||
"method": {
|
||||
"enum": [
|
||||
"thread/delete"
|
||||
],
|
||||
"title": "Thread/deleteRequestMethod",
|
||||
"type": "string"
|
||||
},
|
||||
"params": {
|
||||
"$ref": "#/definitions/ThreadDeleteParams"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"id",
|
||||
"method",
|
||||
"params"
|
||||
],
|
||||
"title": "Thread/deleteRequest",
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"id": {
|
||||
|
||||
@@ -532,6 +532,13 @@
|
||||
"personalAccessToken"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"description": "Amazon Bedrock bearer token managed by Codex.",
|
||||
"enum": [
|
||||
"bedrockApiKey"
|
||||
],
|
||||
"type": "string"
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -3523,6 +3530,17 @@
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"ThreadDeletedNotification": {
|
||||
"properties": {
|
||||
"threadId": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"threadId"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"ThreadGoal": {
|
||||
"properties": {
|
||||
"createdAt": {
|
||||
@@ -5447,6 +5465,26 @@
|
||||
"title": "Thread/archivedNotification",
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"method": {
|
||||
"enum": [
|
||||
"thread/deleted"
|
||||
],
|
||||
"title": "Thread/deletedNotificationMethod",
|
||||
"type": "string"
|
||||
},
|
||||
"params": {
|
||||
"$ref": "#/definitions/ThreadDeletedNotification"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"method",
|
||||
"params"
|
||||
],
|
||||
"title": "Thread/deletedNotification",
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"method": {
|
||||
|
||||
+83
-5
@@ -324,6 +324,30 @@
|
||||
"title": "Thread/archiveRequest",
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"id": {
|
||||
"$ref": "#/definitions/v2/RequestId"
|
||||
},
|
||||
"method": {
|
||||
"enum": [
|
||||
"thread/delete"
|
||||
],
|
||||
"title": "Thread/deleteRequestMethod",
|
||||
"type": "string"
|
||||
},
|
||||
"params": {
|
||||
"$ref": "#/definitions/v2/ThreadDeleteParams"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"id",
|
||||
"method",
|
||||
"params"
|
||||
],
|
||||
"title": "Thread/deleteRequest",
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"id": {
|
||||
@@ -4061,6 +4085,26 @@
|
||||
"title": "Thread/archivedNotification",
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"method": {
|
||||
"enum": [
|
||||
"thread/deleted"
|
||||
],
|
||||
"title": "Thread/deletedNotificationMethod",
|
||||
"type": "string"
|
||||
},
|
||||
"params": {
|
||||
"$ref": "#/definitions/v2/ThreadDeletedNotification"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"method",
|
||||
"params"
|
||||
],
|
||||
"title": "Thread/deletedNotification",
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"method": {
|
||||
@@ -6389,15 +6433,11 @@
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"needsAuth": {
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"id",
|
||||
"name",
|
||||
"needsAuth"
|
||||
"name"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
@@ -6684,6 +6724,13 @@
|
||||
"personalAccessToken"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"description": "Amazon Bedrock bearer token managed by Codex.",
|
||||
"enum": [
|
||||
"bedrockApiKey"
|
||||
],
|
||||
"type": "string"
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -16053,6 +16100,37 @@
|
||||
"title": "ThreadCompactStartResponse",
|
||||
"type": "object"
|
||||
},
|
||||
"ThreadDeleteParams": {
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"properties": {
|
||||
"threadId": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"threadId"
|
||||
],
|
||||
"title": "ThreadDeleteParams",
|
||||
"type": "object"
|
||||
},
|
||||
"ThreadDeleteResponse": {
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"title": "ThreadDeleteResponse",
|
||||
"type": "object"
|
||||
},
|
||||
"ThreadDeletedNotification": {
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"properties": {
|
||||
"threadId": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"threadId"
|
||||
],
|
||||
"title": "ThreadDeletedNotification",
|
||||
"type": "object"
|
||||
},
|
||||
"ThreadForkParams": {
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"description": "There are two ways to fork a thread: 1. By thread_id: load the thread from disk by thread_id and fork it into a new thread. 2. By path: load the thread from disk by path and fork it into a new thread.\n\nIf using a non-empty path, the thread_id param will be ignored. Empty string path values are treated as absent.\n\nPrefer using thread_id whenever possible.",
|
||||
|
||||
+83
-5
@@ -711,15 +711,11 @@
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"needsAuth": {
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"id",
|
||||
"name",
|
||||
"needsAuth"
|
||||
"name"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
@@ -1006,6 +1002,13 @@
|
||||
"personalAccessToken"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"description": "Amazon Bedrock bearer token managed by Codex.",
|
||||
"enum": [
|
||||
"bedrockApiKey"
|
||||
],
|
||||
"type": "string"
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -1263,6 +1266,30 @@
|
||||
"title": "Thread/archiveRequest",
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"id": {
|
||||
"$ref": "#/definitions/RequestId"
|
||||
},
|
||||
"method": {
|
||||
"enum": [
|
||||
"thread/delete"
|
||||
],
|
||||
"title": "Thread/deleteRequestMethod",
|
||||
"type": "string"
|
||||
},
|
||||
"params": {
|
||||
"$ref": "#/definitions/ThreadDeleteParams"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"id",
|
||||
"method",
|
||||
"params"
|
||||
],
|
||||
"title": "Thread/deleteRequest",
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"id": {
|
||||
@@ -11726,6 +11753,26 @@
|
||||
"title": "Thread/archivedNotification",
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"method": {
|
||||
"enum": [
|
||||
"thread/deleted"
|
||||
],
|
||||
"title": "Thread/deletedNotificationMethod",
|
||||
"type": "string"
|
||||
},
|
||||
"params": {
|
||||
"$ref": "#/definitions/ThreadDeletedNotification"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"method",
|
||||
"params"
|
||||
],
|
||||
"title": "Thread/deletedNotification",
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"method": {
|
||||
@@ -13870,6 +13917,37 @@
|
||||
"title": "ThreadCompactStartResponse",
|
||||
"type": "object"
|
||||
},
|
||||
"ThreadDeleteParams": {
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"properties": {
|
||||
"threadId": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"threadId"
|
||||
],
|
||||
"title": "ThreadDeleteParams",
|
||||
"type": "object"
|
||||
},
|
||||
"ThreadDeleteResponse": {
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"title": "ThreadDeleteResponse",
|
||||
"type": "object"
|
||||
},
|
||||
"ThreadDeletedNotification": {
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"properties": {
|
||||
"threadId": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"threadId"
|
||||
],
|
||||
"title": "ThreadDeletedNotification",
|
||||
"type": "object"
|
||||
},
|
||||
"ThreadForkParams": {
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"description": "There are two ways to fork a thread: 1. By thread_id: load the thread from disk by thread_id and fork it into a new thread. 2. By path: load the thread from disk by path and fork it into a new thread.\n\nIf using a non-empty path, the thread_id param will be ignored. Empty string path values are treated as absent.\n\nPrefer using thread_id whenever possible.",
|
||||
|
||||
@@ -38,6 +38,13 @@
|
||||
"personalAccessToken"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"description": "Amazon Bedrock bearer token managed by Codex.",
|
||||
"enum": [
|
||||
"bedrockApiKey"
|
||||
],
|
||||
"type": "string"
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -21,15 +21,11 @@
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"needsAuth": {
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"id",
|
||||
"name",
|
||||
"needsAuth"
|
||||
"name"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
|
||||
@@ -25,15 +25,11 @@
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"needsAuth": {
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"id",
|
||||
"name",
|
||||
"needsAuth"
|
||||
"name"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"properties": {
|
||||
"threadId": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"threadId"
|
||||
],
|
||||
"title": "ThreadDeleteParams",
|
||||
"type": "object"
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"title": "ThreadDeleteResponse",
|
||||
"type": "object"
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"properties": {
|
||||
"threadId": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"threadId"
|
||||
],
|
||||
"title": "ThreadDeletedNotification",
|
||||
"type": "object"
|
||||
}
|
||||
+1
-1
@@ -5,4 +5,4 @@
|
||||
/**
|
||||
* Authentication mode for OpenAI-backed providers.
|
||||
*/
|
||||
export type AuthMode = "apikey" | "chatgpt" | "chatgptAuthTokens" | "agentIdentity" | "personalAccessToken";
|
||||
export type AuthMode = "apikey" | "chatgpt" | "chatgptAuthTokens" | "agentIdentity" | "personalAccessToken" | "bedrockApiKey";
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -5,4 +5,4 @@
|
||||
/**
|
||||
* EXPERIMENTAL - app metadata summary for plugin responses.
|
||||
*/
|
||||
export type AppSummary = { id: string, name: string, description: string | null, installUrl: string | null, needsAuth: boolean, };
|
||||
export type AppSummary = { id: string, name: string, description: string | null, installUrl: string | null, };
|
||||
|
||||
@@ -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 ThreadDeleteParams = { threadId: string, };
|
||||
@@ -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 ThreadDeleteResponse = Record<string, never>;
|
||||
+5
@@ -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 ThreadDeletedNotification = { threadId: string, };
|
||||
@@ -374,6 +374,9 @@ export type { ThreadArchivedNotification } from "./ThreadArchivedNotification";
|
||||
export type { ThreadClosedNotification } from "./ThreadClosedNotification";
|
||||
export type { ThreadCompactStartParams } from "./ThreadCompactStartParams";
|
||||
export type { ThreadCompactStartResponse } from "./ThreadCompactStartResponse";
|
||||
export type { ThreadDeleteParams } from "./ThreadDeleteParams";
|
||||
export type { ThreadDeleteResponse } from "./ThreadDeleteResponse";
|
||||
export type { ThreadDeletedNotification } from "./ThreadDeletedNotification";
|
||||
export type { ThreadForkParams } from "./ThreadForkParams";
|
||||
export type { ThreadForkResponse } from "./ThreadForkResponse";
|
||||
export type { ThreadGoal } from "./ThreadGoal";
|
||||
|
||||
@@ -39,8 +39,11 @@ use ts_rs::TS;
|
||||
pub(crate) const GENERATED_TS_HEADER: &str = "// GENERATED CODE! DO NOT MODIFY BY HAND!\n\n";
|
||||
const IGNORED_DEFINITIONS: &[&str] = &["Option<()>"];
|
||||
const JSON_V1_ALLOWLIST: &[&str] = &["InitializeParams", "InitializeResponse"];
|
||||
const EXPERIMENTAL_CLIENT_METHOD_DEPENDENCY_TYPES: &[&str] =
|
||||
&["RemoteControlClient", "RemoteControlClientsListOrder"];
|
||||
const EXPERIMENTAL_CLIENT_METHOD_DEPENDENCY_TYPES: &[&str] = &[
|
||||
"RemoteControlClient",
|
||||
"RemoteControlClientsListOrder",
|
||||
"ThreadBackgroundTerminal",
|
||||
];
|
||||
const SPECIAL_DEFINITIONS: &[&str] = &[
|
||||
"ClientNotification",
|
||||
"ClientRequest",
|
||||
|
||||
@@ -41,6 +41,11 @@ pub enum AuthMode {
|
||||
#[ts(rename = "personalAccessToken")]
|
||||
#[strum(serialize = "personalAccessToken")]
|
||||
PersonalAccessToken,
|
||||
/// Amazon Bedrock bearer token managed by Codex.
|
||||
#[serde(rename = "bedrockApiKey")]
|
||||
#[ts(rename = "bedrockApiKey")]
|
||||
#[strum(serialize = "bedrockApiKey")]
|
||||
BedrockApiKey,
|
||||
}
|
||||
|
||||
impl AuthMode {
|
||||
@@ -48,7 +53,7 @@ impl AuthMode {
|
||||
pub fn has_chatgpt_account(self) -> bool {
|
||||
match self {
|
||||
Self::Chatgpt | Self::ChatgptAuthTokens | Self::PersonalAccessToken => true,
|
||||
Self::ApiKey | Self::AgentIdentity => false,
|
||||
Self::ApiKey | Self::AgentIdentity | Self::BedrockApiKey => false,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -480,6 +485,11 @@ client_request_definitions! {
|
||||
serialization: thread_id(params.thread_id),
|
||||
response: v2::ThreadArchiveResponse,
|
||||
},
|
||||
ThreadDelete => "thread/delete" {
|
||||
params: v2::ThreadDeleteParams,
|
||||
serialization: thread_id(params.thread_id),
|
||||
response: v2::ThreadDeleteResponse,
|
||||
},
|
||||
ThreadUnsubscribe => "thread/unsubscribe" {
|
||||
params: v2::ThreadUnsubscribeParams,
|
||||
serialization: thread_id(params.thread_id),
|
||||
@@ -574,6 +584,18 @@ client_request_definitions! {
|
||||
serialization: thread_id(params.thread_id),
|
||||
response: v2::ThreadBackgroundTerminalsCleanResponse,
|
||||
},
|
||||
#[experimental("thread/backgroundTerminals/list")]
|
||||
ThreadBackgroundTerminalsList => "thread/backgroundTerminals/list" {
|
||||
params: v2::ThreadBackgroundTerminalsListParams,
|
||||
serialization: thread_id(params.thread_id),
|
||||
response: v2::ThreadBackgroundTerminalsListResponse,
|
||||
},
|
||||
#[experimental("thread/backgroundTerminals/terminate")]
|
||||
ThreadBackgroundTerminalsTerminate => "thread/backgroundTerminals/terminate" {
|
||||
params: v2::ThreadBackgroundTerminalsTerminateParams,
|
||||
serialization: thread_id(params.thread_id),
|
||||
response: v2::ThreadBackgroundTerminalsTerminateResponse,
|
||||
},
|
||||
ThreadRollback => "thread/rollback" {
|
||||
params: v2::ThreadRollbackParams,
|
||||
serialization: thread_id(params.thread_id),
|
||||
@@ -1522,6 +1544,7 @@ server_notification_definitions! {
|
||||
ThreadStarted => "thread/started" (v2::ThreadStartedNotification),
|
||||
ThreadStatusChanged => "thread/status/changed" (v2::ThreadStatusChangedNotification),
|
||||
ThreadArchived => "thread/archived" (v2::ThreadArchivedNotification),
|
||||
ThreadDeleted => "thread/deleted" (v2::ThreadDeletedNotification),
|
||||
ThreadUnarchived => "thread/unarchived" (v2::ThreadUnarchivedNotification),
|
||||
ThreadClosed => "thread/closed" (v2::ThreadClosedNotification),
|
||||
SkillsChanged => "skills/changed" (v2::SkillsChangedNotification),
|
||||
@@ -2936,16 +2959,66 @@ mod tests {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serialize_thread_background_terminals_list() -> Result<()> {
|
||||
let request = ClientRequest::ThreadBackgroundTerminalsList {
|
||||
request_id: RequestId::Integer(8),
|
||||
params: v2::ThreadBackgroundTerminalsListParams {
|
||||
thread_id: "thr_123".to_string(),
|
||||
cursor: None,
|
||||
limit: None,
|
||||
},
|
||||
};
|
||||
assert_eq!(
|
||||
json!({
|
||||
"method": "thread/backgroundTerminals/list",
|
||||
"id": 8,
|
||||
"params": {
|
||||
"threadId": "thr_123",
|
||||
"cursor": null,
|
||||
"limit": null
|
||||
}
|
||||
}),
|
||||
serde_json::to_value(&request)?,
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serialize_thread_background_terminals_terminate() -> Result<()> {
|
||||
let request = ClientRequest::ThreadBackgroundTerminalsTerminate {
|
||||
request_id: RequestId::Integer(8),
|
||||
params: v2::ThreadBackgroundTerminalsTerminateParams {
|
||||
thread_id: "thr_123".to_string(),
|
||||
process_id: "42".to_string(),
|
||||
},
|
||||
};
|
||||
assert_eq!(
|
||||
json!({
|
||||
"method": "thread/backgroundTerminals/terminate",
|
||||
"id": 8,
|
||||
"params": {
|
||||
"threadId": "thr_123",
|
||||
"processId": "42"
|
||||
}
|
||||
}),
|
||||
serde_json::to_value(&request)?,
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serialize_thread_realtime_start() -> Result<()> {
|
||||
let request = ClientRequest::ThreadRealtimeStart {
|
||||
request_id: RequestId::Integer(9),
|
||||
params: v2::ThreadRealtimeStartParams {
|
||||
thread_id: "thr_123".to_string(),
|
||||
model: Some("realtime-treatment-model".to_string()),
|
||||
output_modality: RealtimeOutputModality::Audio,
|
||||
prompt: Some(Some("You are on a call".to_string())),
|
||||
realtime_session_id: Some("sess_456".to_string()),
|
||||
transport: None,
|
||||
version: Some(RealtimeConversationVersion::V1),
|
||||
voice: Some(RealtimeVoice::Marin),
|
||||
},
|
||||
};
|
||||
@@ -2955,10 +3028,12 @@ mod tests {
|
||||
"id": 9,
|
||||
"params": {
|
||||
"threadId": "thr_123",
|
||||
"model": "realtime-treatment-model",
|
||||
"outputModality": "audio",
|
||||
"prompt": "You are on a call",
|
||||
"realtimeSessionId": "sess_456",
|
||||
"transport": null,
|
||||
"version": "v1",
|
||||
"voice": "marin"
|
||||
}
|
||||
}),
|
||||
@@ -2973,10 +3048,12 @@ mod tests {
|
||||
request_id: RequestId::Integer(9),
|
||||
params: v2::ThreadRealtimeStartParams {
|
||||
thread_id: "thr_123".to_string(),
|
||||
model: None,
|
||||
output_modality: RealtimeOutputModality::Audio,
|
||||
prompt: None,
|
||||
realtime_session_id: None,
|
||||
transport: None,
|
||||
version: None,
|
||||
voice: None,
|
||||
},
|
||||
};
|
||||
@@ -2986,9 +3063,11 @@ mod tests {
|
||||
"id": 9,
|
||||
"params": {
|
||||
"threadId": "thr_123",
|
||||
"model": null,
|
||||
"outputModality": "audio",
|
||||
"realtimeSessionId": null,
|
||||
"transport": null,
|
||||
"version": null,
|
||||
"voice": null
|
||||
}
|
||||
}),
|
||||
@@ -2999,10 +3078,12 @@ mod tests {
|
||||
request_id: RequestId::Integer(9),
|
||||
params: v2::ThreadRealtimeStartParams {
|
||||
thread_id: "thr_123".to_string(),
|
||||
model: None,
|
||||
output_modality: RealtimeOutputModality::Audio,
|
||||
prompt: Some(None),
|
||||
realtime_session_id: None,
|
||||
transport: None,
|
||||
version: None,
|
||||
voice: None,
|
||||
},
|
||||
};
|
||||
@@ -3012,10 +3093,12 @@ mod tests {
|
||||
"id": 9,
|
||||
"params": {
|
||||
"threadId": "thr_123",
|
||||
"model": null,
|
||||
"outputModality": "audio",
|
||||
"prompt": null,
|
||||
"realtimeSessionId": null,
|
||||
"transport": null,
|
||||
"version": null,
|
||||
"voice": null
|
||||
}
|
||||
}),
|
||||
@@ -3168,10 +3251,12 @@ mod tests {
|
||||
request_id: RequestId::Integer(1),
|
||||
params: v2::ThreadRealtimeStartParams {
|
||||
thread_id: "thr_123".to_string(),
|
||||
model: None,
|
||||
output_modality: RealtimeOutputModality::Audio,
|
||||
prompt: Some(Some("You are on a call".to_string())),
|
||||
realtime_session_id: None,
|
||||
transport: None,
|
||||
version: None,
|
||||
voice: None,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -2939,6 +2939,7 @@ mod tests {
|
||||
RolloutItem::Compacted(CompactedItem {
|
||||
message: String::new(),
|
||||
replacement_history: None,
|
||||
window_id: None,
|
||||
}),
|
||||
RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent {
|
||||
turn_id: "turn-compact".into(),
|
||||
|
||||
@@ -111,7 +111,6 @@ pub struct AppSummary {
|
||||
pub name: String,
|
||||
pub description: Option<String>,
|
||||
pub install_url: Option<String>,
|
||||
pub needs_auth: bool,
|
||||
}
|
||||
|
||||
impl From<AppInfo> for AppSummary {
|
||||
@@ -121,7 +120,6 @@ impl From<AppInfo> for AppSummary {
|
||||
name: value.name,
|
||||
description: value.description,
|
||||
install_url: value.install_url,
|
||||
needs_auth: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -65,6 +65,9 @@ impl From<ThreadRealtimeAudioChunk> for CoreRealtimeAudioFrame {
|
||||
#[ts(export_to = "v2/")]
|
||||
pub struct ThreadRealtimeStartParams {
|
||||
pub thread_id: String,
|
||||
/// Overrides the configured realtime model for this session only.
|
||||
#[ts(optional = nullable)]
|
||||
pub model: Option<String>,
|
||||
/// Selects text or audio output for the realtime session. Transport and voice stay
|
||||
/// independent so clients can choose how they connect separately from what the model emits.
|
||||
pub output_modality: RealtimeOutputModality,
|
||||
@@ -80,6 +83,9 @@ pub struct ThreadRealtimeStartParams {
|
||||
pub realtime_session_id: Option<String>,
|
||||
#[ts(optional = nullable)]
|
||||
pub transport: Option<ThreadRealtimeStartTransport>,
|
||||
/// Overrides the configured realtime protocol version for this session only.
|
||||
#[ts(optional = nullable)]
|
||||
pub version: Option<RealtimeConversationVersion>,
|
||||
#[ts(optional = nullable)]
|
||||
pub voice: Option<RealtimeVoice>,
|
||||
}
|
||||
|
||||
@@ -601,6 +601,18 @@ pub struct ThreadArchiveParams {
|
||||
#[ts(export_to = "v2/")]
|
||||
pub struct ThreadArchiveResponse {}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export_to = "v2/")]
|
||||
pub struct ThreadDeleteParams {
|
||||
pub thread_id: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export_to = "v2/")]
|
||||
pub struct ThreadDeleteResponse {}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export_to = "v2/")]
|
||||
@@ -942,6 +954,57 @@ pub struct ThreadBackgroundTerminalsCleanParams {
|
||||
#[ts(export_to = "v2/")]
|
||||
pub struct ThreadBackgroundTerminalsCleanResponse {}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export_to = "v2/")]
|
||||
pub struct ThreadBackgroundTerminalsListParams {
|
||||
pub thread_id: String,
|
||||
/// Opaque pagination cursor returned by a previous call.
|
||||
#[ts(optional = nullable)]
|
||||
pub cursor: Option<String>,
|
||||
/// Optional page size.
|
||||
#[ts(optional = nullable)]
|
||||
pub limit: Option<u32>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export_to = "v2/")]
|
||||
pub struct ThreadBackgroundTerminal {
|
||||
pub item_id: String,
|
||||
pub process_id: String,
|
||||
pub command: String,
|
||||
pub cwd: AbsolutePathBuf,
|
||||
pub os_pid: Option<u32>,
|
||||
pub cpu_percent: Option<f64>,
|
||||
pub rss_kb: Option<u64>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export_to = "v2/")]
|
||||
pub struct ThreadBackgroundTerminalsListResponse {
|
||||
pub data: Vec<ThreadBackgroundTerminal>,
|
||||
/// Opaque cursor to pass to the next call to continue after the last item.
|
||||
/// If None, there are no more items to return.
|
||||
pub next_cursor: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export_to = "v2/")]
|
||||
pub struct ThreadBackgroundTerminalsTerminateParams {
|
||||
pub thread_id: String,
|
||||
pub process_id: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export_to = "v2/")]
|
||||
pub struct ThreadBackgroundTerminalsTerminateResponse {
|
||||
pub terminated: bool,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export_to = "v2/")]
|
||||
@@ -1337,6 +1400,13 @@ pub struct ThreadArchivedNotification {
|
||||
pub thread_id: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export_to = "v2/")]
|
||||
pub struct ThreadDeletedNotification {
|
||||
pub thread_id: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export_to = "v2/")]
|
||||
|
||||
@@ -116,6 +116,7 @@ fn remote_control_auth_dot_json(account_id: Option<&str>) -> AuthDotJson {
|
||||
last_refresh: Some(chrono::Utc::now()),
|
||||
agent_identity: None,
|
||||
personal_access_token: None,
|
||||
bedrock_api_key: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1842,6 +1842,7 @@ mod tests {
|
||||
last_refresh: Some(Utc::now()),
|
||||
agent_identity: None,
|
||||
personal_access_token: None,
|
||||
bedrock_api_key: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -151,18 +151,21 @@ Example with notification opt-out:
|
||||
- `thread/settings/updated` — experimental notification emitted to subscribed clients when a loaded thread’s effective next-turn settings change; includes `threadId` and the full `threadSettings`.
|
||||
- `thread/status/changed` — notification emitted when a loaded thread’s status changes (`threadId` + new `status`).
|
||||
- `thread/archive` — move a thread’s rollout file into the archived directory and attempt to move any spawned descendant thread rollout files; returns `{}` on success and emits `thread/archived` for each archived thread.
|
||||
- `thread/delete` — hard-delete an active or archived thread and any spawned descendant threads; returns `{}` on success and emits `thread/deleted` for each deleted thread.
|
||||
- `thread/unsubscribe` — unsubscribe this connection from thread turn/item events. If this was the last subscriber, the server keeps the thread loaded and unloads it only after it has had no subscribers and no thread activity for 30 minutes, then emits `thread/closed`.
|
||||
- `thread/name/set` — set or update a thread’s user-facing name for either a loaded thread or a persisted rollout; returns `{}` on success and emits `thread/name/updated` to initialized, opted-in clients. Thread names are not required to be unique; name lookups resolve to the most recently updated thread.
|
||||
- `thread/unarchive` — move an archived rollout file back into the sessions directory; returns the restored `thread` on success and emits `thread/unarchived`.
|
||||
- `thread/compact/start` — trigger conversation history compaction for a thread; returns `{}` immediately while progress streams through standard turn/item notifications.
|
||||
- `thread/shellCommand` — run a user-initiated `!` shell command against a thread; this runs unsandboxed with full access rather than inheriting the thread sandbox policy. Returns `{}` immediately while progress streams through standard turn/item notifications and any active turn receives the formatted output in its message stream.
|
||||
- `thread/backgroundTerminals/clean` — terminate all running background terminals for a thread (experimental; requires `capabilities.experimentalApi`); returns `{}` when the cleanup request is accepted.
|
||||
- `thread/backgroundTerminals/list` — list running background terminals for a loaded thread (experimental; requires `capabilities.experimentalApi`); returns `data` with the running terminal ids.
|
||||
- `thread/backgroundTerminals/terminate` — terminate one running background terminal by app-server `processId` (experimental; requires `capabilities.experimentalApi`); returns whether a process was terminated.
|
||||
- `thread/rollback` — drop the last N turns from the agent’s in-memory context and persist a rollback marker in the rollout so future resumes see the pruned history; returns the updated `thread` (with `turns` populated) on success.
|
||||
- `turn/start` — add user input to a thread and begin Codex generation; responds with the initial `turn` object and streams `turn/started`, `item/*`, and `turn/completed` notifications. `clientUserMessageId` is optional; when supplied, the corresponding `userMessage` item echoes it as `clientId`. Experimental `runtimeWorkspaceRoots` replaces the thread-scoped runtime workspace roots used to materialize `:workspace_roots`; paths must be absolute. Prefer experimental `permissions` profile selection by id for permission overrides; the legacy `sandboxPolicy` field is still accepted but cannot be combined with `permissions`. For `collaborationMode`, `settings.developer_instructions: null` means "use built-in instructions for the selected mode".
|
||||
- `thread/inject_items` — append raw Responses API items to a loaded thread’s model-visible history without starting a user turn; returns `{}` on success.
|
||||
- `turn/steer` — add user input to an already in-flight regular turn without starting a new turn; returns the active `turnId` that accepted the input. `clientUserMessageId` is optional; when supplied, the corresponding `userMessage` item echoes it as `clientId`. Review and manual compaction turns reject `turn/steer`.
|
||||
- `turn/interrupt` — request cancellation of an in-flight turn by `(thread_id, turn_id)`; success is an empty `{}` response and the turn finishes with `status: "interrupted"`.
|
||||
- `thread/realtime/start` — start a thread-scoped realtime session (experimental); pass `outputModality: "text"` or `outputModality: "audio"` to choose model output, returns `{}` and streams `thread/realtime/*` notifications. Omit `transport` for the websocket transport, or pass `{ "type": "webrtc", "sdp": "..." }` to create a WebRTC session from a browser-generated SDP offer; the remote answer SDP is emitted as `thread/realtime/sdp`.
|
||||
- `thread/realtime/start` — start a thread-scoped realtime session (experimental); pass `outputModality: "text"` or `outputModality: "audio"` to choose model output, and optionally pass `model` and `version` to override configured realtime selection for this session only. Returns `{}` and streams `thread/realtime/*` notifications. Omit `transport` for the websocket transport, or pass `{ "type": "webrtc", "sdp": "..." }` to create a WebRTC session from a browser-generated SDP offer; the remote answer SDP is emitted as `thread/realtime/sdp`.
|
||||
- `thread/realtime/appendAudio` — append an input audio chunk to the active realtime session (experimental); returns `{}`.
|
||||
- `thread/realtime/appendText` — append text input to the active realtime session (experimental); returns `{}`.
|
||||
- `thread/realtime/stop` — stop the active realtime session for the thread (experimental); returns `{}`.
|
||||
@@ -203,7 +206,7 @@ Example with notification opt-out:
|
||||
- `marketplace/upgrade` — upgrade all configured Git plugin marketplaces, or one named marketplace when `marketplaceName` is provided. Returns selected marketplace names, upgraded roots, and per-marketplace errors.
|
||||
- `plugin/list` — list discovered plugin marketplaces and plugin state, including effective marketplace install/auth policy metadata, plugin `availability` (`AVAILABLE` by default or `DISABLED_BY_ADMIN` for remote plugins blocked upstream), fail-open `marketplaceLoadErrors` entries for marketplace files that could not be parsed or loaded, and best-effort `featuredPluginIds` for the official curated marketplace. `interface.category` uses the marketplace category when present; otherwise it falls back to the plugin manifest category (**under development; do not call from production clients yet**).
|
||||
- `plugin/installed` — list installed plugin rows plus any explicitly requested local install-suggestion plugin names, without fetching the broader remote catalog. Mention surfaces can use this narrower view when they need plugin mention payloads rather than plugin-page discovery data (**under development; do not call from production clients yet**).
|
||||
- `plugin/read` — read one plugin by `marketplacePath` plus `pluginName`, returning marketplace info, a list-style `summary`, manifest descriptions/interface metadata, and bundled skills/hooks/apps/MCP server names. Returned plugin skills include their current `enabled` state after local config filtering; bundled hooks are returned as lightweight declaration summaries keyed for correlation with `hooks/list`. Plugin app summaries also include `needsAuth` when the server can determine connector accessibility (**under development; do not call from production clients yet**).
|
||||
- `plugin/read` — read one plugin by `marketplacePath` plus `pluginName`, returning marketplace info, a list-style `summary`, manifest descriptions/interface metadata, and bundled skills/hooks/apps/MCP server names. Returned plugin skills include their current `enabled` state after local config filtering; bundled hooks are returned as lightweight declaration summaries keyed for correlation with `hooks/list`. Use `plugin/install`'s `appsNeedingAuth` to drive post-install authentication and `app/list`'s `isAccessible` to determine current connector accessibility (**under development; do not call from production clients yet**).
|
||||
- `plugin/skill/read` — read remote plugin skill markdown on demand by `remoteMarketplaceName`, `remotePluginId`, and `skillName`. This lets clients preview uninstalled remote plugin skills without downloading the plugin bundle.
|
||||
- `skills/changed` — notification emitted when watched local skill files change.
|
||||
- `app/list` — list available apps.
|
||||
@@ -610,6 +613,16 @@ Use `thread/archive` to move the persisted rollout (stored as a JSONL file on di
|
||||
|
||||
An archived thread will not appear in `thread/list` unless `archived` is set to `true`.
|
||||
|
||||
### Example: Delete a thread
|
||||
|
||||
Use `thread/delete` to hard-delete a thread and its spawned descendant threads. Existing rollout files and associated metadata must be removed before the request succeeds; missing rollout files are treated as already deleted.
|
||||
|
||||
```json
|
||||
{ "method": "thread/delete", "id": 23, "params": { "threadId": "thr_b" } }
|
||||
{ "id": 23, "result": {} }
|
||||
{ "method": "thread/deleted", "params": { "threadId": "thr_b" } }
|
||||
```
|
||||
|
||||
### Example: Unarchive a thread
|
||||
|
||||
Use `thread/unarchive` to move an archived rollout back into the sessions directory.
|
||||
@@ -837,6 +850,8 @@ Then send `offer.sdp` to app-server. Core uses `experimental_realtime_ws_backend
|
||||
|
||||
Omit `prompt` to use Codex's default realtime backend prompt. Send `prompt: null` or
|
||||
`prompt: ""` when the session should start without that default backend prompt.
|
||||
Clients may also pass `model` and `version` on `thread/realtime/start` to select a
|
||||
different realtime session configuration without changing thread or user config.
|
||||
|
||||
```javascript
|
||||
await pc.setRemoteDescription({
|
||||
@@ -870,6 +885,32 @@ Use `thread/backgroundTerminals/clean` to terminate all running background termi
|
||||
{ "id": 35, "result": {} }
|
||||
```
|
||||
|
||||
### Example: List and terminate background terminals
|
||||
|
||||
Use `thread/backgroundTerminals/list` to inspect running background terminals associated with a loaded thread. The `backgroundTerminals` segment intentionally follows the existing `thread/backgroundTerminals/clean` method. The returned `processId` is the app-server process id; host OS metadata is nullable. The request accepts the standard `cursor` and `limit` pagination fields. When `nextCursor` is non-null, pass it as `cursor` to fetch the next page.
|
||||
|
||||
```json
|
||||
{ "method": "thread/backgroundTerminals/list", "id": 36, "params": { "threadId": "thr_123" } }
|
||||
{ "id": 36, "result": { "data": [
|
||||
{
|
||||
"itemId": "item_456",
|
||||
"processId": "42",
|
||||
"command": "python3 -m http.server",
|
||||
"cwd": "/workspace",
|
||||
"osPid": null,
|
||||
"cpuPercent": null,
|
||||
"rssKb": null
|
||||
}
|
||||
], "nextCursor": null } }
|
||||
```
|
||||
|
||||
Use `thread/backgroundTerminals/terminate` to terminate one running background terminal by that `processId`.
|
||||
|
||||
```json
|
||||
{ "method": "thread/backgroundTerminals/terminate", "id": 37, "params": { "threadId": "thr_123", "processId": "42" } }
|
||||
{ "id": 37, "result": { "terminated": true } }
|
||||
```
|
||||
|
||||
### Example: Steer an active turn
|
||||
|
||||
Use `turn/steer` to append additional user input to the currently active regular turn. This does
|
||||
|
||||
@@ -1147,7 +1147,7 @@ fn configured_marketplace_plugins(
|
||||
) -> io::Result<BTreeMap<String, HashSet<String>>> {
|
||||
let plugins_input = config.plugins_config_input();
|
||||
let marketplaces = plugins_manager
|
||||
.list_marketplaces_for_config(&plugins_input, &[])
|
||||
.list_marketplaces_for_config(&plugins_input, &[], /*include_openai_curated*/ true)
|
||||
.map_err(|err| {
|
||||
invalid_data_error(format!("failed to list configured marketplaces: {err}"))
|
||||
})?;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
use std::sync::Arc;
|
||||
use std::sync::Weak;
|
||||
|
||||
use codex_analytics::AnalyticsEventsClient;
|
||||
use codex_app_server_protocol::ServerNotification;
|
||||
use codex_app_server_protocol::ThreadGoal;
|
||||
use codex_app_server_protocol::ThreadGoalUpdatedNotification;
|
||||
@@ -20,28 +21,47 @@ use codex_protocol::error::CodexErr;
|
||||
use codex_protocol::protocol::Event;
|
||||
use codex_protocol::protocol::EventMsg;
|
||||
use codex_rollout::state_db::StateDbHandle;
|
||||
use codex_thread_store::ThreadStore;
|
||||
|
||||
use crate::outgoing_message::OutgoingMessageSender;
|
||||
use crate::thread_state::ThreadListenerCommand;
|
||||
use crate::thread_state::ThreadStateManager;
|
||||
|
||||
pub(crate) struct ThreadExtensionDependencies {
|
||||
pub(crate) event_sink: Arc<dyn ExtensionEventSink>,
|
||||
pub(crate) auth_manager: Arc<AuthManager>,
|
||||
pub(crate) state_db: Option<StateDbHandle>,
|
||||
pub(crate) analytics_events_client: AnalyticsEventsClient,
|
||||
pub(crate) thread_manager: Weak<ThreadManager>,
|
||||
pub(crate) goal_service: Arc<GoalService>,
|
||||
pub(crate) executor_skill_provider: Arc<dyn codex_skills_extension::SkillProvider>,
|
||||
/// Process-scoped persistence backend for extensions that need stored thread history.
|
||||
pub(crate) thread_store: Arc<dyn ThreadStore>,
|
||||
}
|
||||
|
||||
pub(crate) fn thread_extensions<S>(
|
||||
guardian_agent_spawner: S,
|
||||
event_sink: Arc<dyn ExtensionEventSink>,
|
||||
auth_manager: Arc<AuthManager>,
|
||||
state_db: Option<StateDbHandle>,
|
||||
thread_manager: Weak<ThreadManager>,
|
||||
goal_service: Arc<GoalService>,
|
||||
executor_skill_provider: Arc<dyn codex_skills_extension::SkillProvider>,
|
||||
dependencies: ThreadExtensionDependencies,
|
||||
) -> Arc<ExtensionRegistry<Config>>
|
||||
where
|
||||
S: AgentSpawner<StartThreadOptions, Spawned = NewThread, Error = CodexErr> + 'static,
|
||||
{
|
||||
let ThreadExtensionDependencies {
|
||||
event_sink,
|
||||
auth_manager,
|
||||
state_db,
|
||||
analytics_events_client,
|
||||
thread_manager,
|
||||
goal_service,
|
||||
executor_skill_provider,
|
||||
thread_store: _thread_store,
|
||||
} = dependencies;
|
||||
let mut builder = ExtensionRegistryBuilder::<Config>::with_event_sink(event_sink);
|
||||
if let Some(state_db) = state_db {
|
||||
codex_goal_extension::install_with_backend(
|
||||
&mut builder,
|
||||
state_db,
|
||||
analytics_events_client,
|
||||
codex_otel::global(),
|
||||
thread_manager,
|
||||
goal_service,
|
||||
@@ -56,7 +76,14 @@ where
|
||||
codex_skills_extension::install_with_providers(
|
||||
&mut builder,
|
||||
codex_skills_extension::SkillProviders::new()
|
||||
.with_executor_provider(executor_skill_provider),
|
||||
.with_executor_provider(executor_skill_provider)
|
||||
.with_orchestrator_provider(Arc::new(
|
||||
codex_skills_extension::OrchestratorSkillProvider::new(),
|
||||
)),
|
||||
|config: &Config| codex_skills_extension::SkillsExtensionConfig {
|
||||
include_instructions: config.include_skill_instructions,
|
||||
bundled_skills_enabled: config.bundled_skills_enabled(),
|
||||
},
|
||||
);
|
||||
Arc::new(builder.build())
|
||||
}
|
||||
@@ -140,7 +167,6 @@ pub(crate) fn guardian_agent_spawner(
|
||||
mod tests {
|
||||
use std::time::Duration;
|
||||
|
||||
use codex_analytics::AnalyticsEventsClient;
|
||||
use codex_protocol::protocol::ThreadGoal as CoreThreadGoal;
|
||||
use codex_protocol::protocol::ThreadGoalStatus;
|
||||
use codex_protocol::protocol::ThreadGoalUpdatedEvent;
|
||||
|
||||
@@ -104,7 +104,9 @@ type PendingClientRequestResponse = std::result::Result<Result, JSONRPCErrorErro
|
||||
fn server_notification_requires_delivery(notification: &ServerNotification) -> bool {
|
||||
matches!(
|
||||
notification,
|
||||
ServerNotification::TurnCompleted(_) | ServerNotification::ThreadSettingsUpdated(_)
|
||||
ServerNotification::TurnCompleted(_)
|
||||
| ServerNotification::ThreadSettingsUpdated(_)
|
||||
| ServerNotification::ExternalAgentConfigImportCompleted(_)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -729,6 +731,7 @@ mod tests {
|
||||
use super::*;
|
||||
use codex_app_server_protocol::ClientInfo;
|
||||
use codex_app_server_protocol::ConfigRequirementsReadResponse;
|
||||
use codex_app_server_protocol::ExternalAgentConfigImportCompletedNotification;
|
||||
use codex_app_server_protocol::SessionSource as ApiSessionSource;
|
||||
use codex_app_server_protocol::ThreadStartParams;
|
||||
use codex_app_server_protocol::ThreadStartResponse;
|
||||
@@ -893,5 +896,10 @@ mod tests {
|
||||
},
|
||||
})
|
||||
));
|
||||
assert!(server_notification_requires_delivery(
|
||||
&ServerNotification::ExternalAgentConfigImportCompleted(
|
||||
ExternalAgentConfigImportCompletedNotification {},
|
||||
)
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
#![recursion_limit = "256"]
|
||||
#![deny(clippy::print_stdout, clippy::print_stderr)]
|
||||
|
||||
use codex_arg0::Arg0DispatchPaths;
|
||||
@@ -74,6 +75,8 @@ use tracing_subscriber::layer::SubscriberExt;
|
||||
use tracing_subscriber::registry::Registry;
|
||||
use tracing_subscriber::util::SubscriberInitExt;
|
||||
|
||||
const SQLITE_RECOVERY_CONFIG_WARNING_SUMMARY: &str = "Codex rebuilt its local database.";
|
||||
|
||||
mod analytics_utils;
|
||||
mod app_server_tracing;
|
||||
mod attestation;
|
||||
@@ -533,8 +536,8 @@ pub async fn run_main_with_transport_options(
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
let state_db = match rollout_state_db::try_init(&config).await {
|
||||
Ok(state_db) => Some(state_db),
|
||||
let state_db_init = match init_sqlite_state_db_with_fresh_start_on_corruption(&config).await {
|
||||
Ok(state_db_init) => state_db_init,
|
||||
Err(err) => {
|
||||
return Err(std::io::Error::other(format!(
|
||||
"failed to initialize sqlite state runtime under {}: {err}",
|
||||
@@ -542,6 +545,15 @@ pub async fn run_main_with_transport_options(
|
||||
)));
|
||||
}
|
||||
};
|
||||
let state_db = state_db_init.state_db;
|
||||
if let Some(recovery_notice) = state_db_init.recovery_notice {
|
||||
config_warnings.push(ConfigWarningNotification {
|
||||
summary: SQLITE_RECOVERY_CONFIG_WARNING_SUMMARY.to_string(),
|
||||
details: Some(recovery_notice.details),
|
||||
path: None,
|
||||
range: None,
|
||||
});
|
||||
}
|
||||
|
||||
if should_run_personality_migration {
|
||||
let effective_toml = config.config_layer_stack.effective_config();
|
||||
@@ -1099,6 +1111,121 @@ pub async fn run_main_with_transport_options(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
struct SqliteRecoveryNotice {
|
||||
details: String,
|
||||
}
|
||||
|
||||
struct RecoveredSqliteDatabase {
|
||||
database_path: String,
|
||||
backup_folder: String,
|
||||
}
|
||||
|
||||
struct StateDbInitResult {
|
||||
state_db: Option<rollout_state_db::StateDbHandle>,
|
||||
recovery_notice: Option<SqliteRecoveryNotice>,
|
||||
}
|
||||
|
||||
async fn init_sqlite_state_db_with_fresh_start_on_corruption(
|
||||
config: &Config,
|
||||
) -> anyhow::Result<StateDbInitResult> {
|
||||
let mut attempted_backups = HashSet::new();
|
||||
let mut recovered_databases = Vec::new();
|
||||
loop {
|
||||
let err = match rollout_state_db::try_init(config).await {
|
||||
Ok(state_db) => {
|
||||
let recovery_notice = sqlite_recovery_notice(&recovered_databases);
|
||||
if recovery_notice.is_some() {
|
||||
emit_state_db_backup_warning(SQLITE_RECOVERY_CONFIG_WARNING_SUMMARY);
|
||||
for recovered_database in &recovered_databases {
|
||||
emit_state_db_backup_warning(&format!(
|
||||
"Database path: {}",
|
||||
recovered_database.database_path
|
||||
));
|
||||
emit_state_db_backup_warning(&format!(
|
||||
"Backup folder: {}",
|
||||
recovered_database.backup_folder
|
||||
));
|
||||
}
|
||||
}
|
||||
return Ok(StateDbInitResult {
|
||||
state_db: Some(state_db),
|
||||
recovery_notice,
|
||||
});
|
||||
}
|
||||
Err(err) => err,
|
||||
};
|
||||
if !codex_state::is_sqlite_corruption_error(&err) {
|
||||
return Err(err);
|
||||
}
|
||||
|
||||
let database_path = codex_state::runtime_db_path_for_corruption_error(&err)
|
||||
.unwrap_or_else(|| codex_state::state_db_path(config.sqlite_home.as_path()));
|
||||
if !attempted_backups.insert(database_path.clone()) {
|
||||
return Err(anyhow::anyhow!(
|
||||
"failed to initialize sqlite state runtime after moving damaged database file into a backup folder: {err}"
|
||||
));
|
||||
}
|
||||
|
||||
let original_error = err.to_string();
|
||||
emit_state_db_backup_warning(&format!(
|
||||
"Codex local database at {} appears damaged. Moving it into a backup folder so the app server can rebuild it from saved data.",
|
||||
database_path.display()
|
||||
));
|
||||
let backups = codex_state::backup_runtime_db_for_fresh_start(database_path.as_path())
|
||||
.await
|
||||
.map_err(|backup_err| {
|
||||
anyhow::anyhow!(
|
||||
"failed to move damaged sqlite state database files into a backup folder: {backup_err}; original error: {original_error}"
|
||||
)
|
||||
})?;
|
||||
for backup in &backups {
|
||||
emit_state_db_backup_warning(&format!(
|
||||
"Moved damaged Codex local database file {} to {}",
|
||||
backup.original_path.display(),
|
||||
backup.backup_path.display()
|
||||
));
|
||||
}
|
||||
if let Some(first_backup) = backups.first()
|
||||
&& let Some(backup_folder) = first_backup.backup_path.parent()
|
||||
{
|
||||
recovered_databases.push(RecoveredSqliteDatabase {
|
||||
database_path: first_backup.original_path.display().to_string(),
|
||||
backup_folder: backup_folder.display().to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn sqlite_recovery_notice(
|
||||
recovered_databases: &[RecoveredSqliteDatabase],
|
||||
) -> Option<SqliteRecoveryNotice> {
|
||||
if recovered_databases.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let details = recovered_databases
|
||||
.iter()
|
||||
.map(|recovered_database| {
|
||||
format!(
|
||||
"Database path: {}\nBackup folder: {}",
|
||||
recovered_database.database_path, recovered_database.backup_folder
|
||||
)
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n\n");
|
||||
Some(SqliteRecoveryNotice { details })
|
||||
}
|
||||
|
||||
fn emit_state_db_backup_warning(message: &str) {
|
||||
warn!("{message}");
|
||||
if !tracing::dispatcher::has_been_set() {
|
||||
#[allow(clippy::print_stderr)]
|
||||
{
|
||||
eprintln!("{message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn analytics_rpc_transport(transport: &AppServerTransport) -> AppServerRpcTransport {
|
||||
match transport {
|
||||
AppServerTransport::Stdio => AppServerRpcTransport::Stdio,
|
||||
|
||||
@@ -96,6 +96,7 @@ async fn queue_refresh(
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::extensions::ThreadExtensionDependencies;
|
||||
use crate::extensions::guardian_agent_spawner;
|
||||
use crate::extensions::thread_extensions;
|
||||
use async_trait::async_trait;
|
||||
@@ -193,12 +194,16 @@ mod tests {
|
||||
Arc::clone(&environment_manager),
|
||||
thread_extensions(
|
||||
guardian_agent_spawner(thread_manager.clone()),
|
||||
Arc::new(NoopExtensionEventSink),
|
||||
auth_manager.clone(),
|
||||
Some(state_db.clone()),
|
||||
thread_manager.clone(),
|
||||
Arc::new(codex_goal_extension::GoalService::new()),
|
||||
Arc::clone(&executor_skill_provider),
|
||||
ThreadExtensionDependencies {
|
||||
event_sink: Arc::new(NoopExtensionEventSink),
|
||||
auth_manager: auth_manager.clone(),
|
||||
state_db: Some(state_db.clone()),
|
||||
analytics_events_client: codex_analytics::AnalyticsEventsClient::disabled(),
|
||||
thread_manager: thread_manager.clone(),
|
||||
goal_service: Arc::new(codex_goal_extension::GoalService::new()),
|
||||
executor_skill_provider: Arc::clone(&executor_skill_provider),
|
||||
thread_store: Arc::clone(&thread_store),
|
||||
},
|
||||
),
|
||||
/*analytics_events_client*/ None,
|
||||
Arc::clone(&thread_store),
|
||||
|
||||
@@ -8,6 +8,7 @@ use crate::attestation::app_server_attestation_provider;
|
||||
use crate::config_manager::ConfigManager;
|
||||
use crate::connection_rpc_gate::ConnectionRpcGate;
|
||||
use crate::error_code::invalid_request;
|
||||
use crate::extensions::ThreadExtensionDependencies;
|
||||
use crate::extensions::app_server_extension_event_sink;
|
||||
use crate::extensions::guardian_agent_spawner;
|
||||
use crate::extensions::thread_extensions;
|
||||
@@ -324,12 +325,19 @@ impl MessageProcessor {
|
||||
environment_manager,
|
||||
thread_extensions(
|
||||
guardian_agent_spawner(thread_manager.clone()),
|
||||
app_server_extension_event_sink(outgoing.clone(), thread_state_manager.clone()),
|
||||
auth_manager.clone(),
|
||||
state_db.clone(),
|
||||
thread_manager.clone(),
|
||||
Arc::clone(&goal_service),
|
||||
Arc::clone(&executor_skill_provider),
|
||||
ThreadExtensionDependencies {
|
||||
event_sink: app_server_extension_event_sink(
|
||||
outgoing.clone(),
|
||||
thread_state_manager.clone(),
|
||||
),
|
||||
auth_manager: auth_manager.clone(),
|
||||
state_db: state_db.clone(),
|
||||
analytics_events_client: analytics_events_client.clone(),
|
||||
thread_manager: thread_manager.clone(),
|
||||
goal_service: Arc::clone(&goal_service),
|
||||
executor_skill_provider: Arc::clone(&executor_skill_provider),
|
||||
thread_store: Arc::clone(&thread_store),
|
||||
},
|
||||
),
|
||||
Some(analytics_events_client.clone()),
|
||||
Arc::clone(&thread_store),
|
||||
@@ -393,7 +401,7 @@ impl MessageProcessor {
|
||||
Arc::clone(&thread_manager),
|
||||
Arc::clone(&config),
|
||||
feedback,
|
||||
log_db,
|
||||
log_db.clone(),
|
||||
state_db.clone(),
|
||||
);
|
||||
let git_processor = GitRequestProcessor::new();
|
||||
@@ -447,6 +455,7 @@ impl MessageProcessor {
|
||||
Arc::clone(&thread_list_state_permit),
|
||||
thread_goal_processor.clone(),
|
||||
state_db,
|
||||
log_db,
|
||||
Arc::clone(&skills_watcher),
|
||||
);
|
||||
let turn_processor = TurnRequestProcessor::new(
|
||||
@@ -1064,6 +1073,11 @@ impl MessageProcessor {
|
||||
.thread_archive(request_id.clone(), params)
|
||||
.await
|
||||
}
|
||||
ClientRequest::ThreadDelete { params, .. } => {
|
||||
self.thread_processor
|
||||
.thread_delete(request_id.clone(), params)
|
||||
.await
|
||||
}
|
||||
ClientRequest::ThreadIncrementElicitation { params, .. } => {
|
||||
self.thread_processor
|
||||
.thread_increment_elicitation(params)
|
||||
@@ -1119,6 +1133,16 @@ impl MessageProcessor {
|
||||
.thread_background_terminals_clean(&request_id, params)
|
||||
.await
|
||||
}
|
||||
ClientRequest::ThreadBackgroundTerminalsList { params, .. } => {
|
||||
self.thread_processor
|
||||
.thread_background_terminals_list(params)
|
||||
.await
|
||||
}
|
||||
ClientRequest::ThreadBackgroundTerminalsTerminate { params, .. } => {
|
||||
self.thread_processor
|
||||
.thread_background_terminals_terminate(params)
|
||||
.await
|
||||
}
|
||||
ClientRequest::ThreadRollback { params, .. } => {
|
||||
self.thread_processor
|
||||
.thread_rollback(&request_id, params)
|
||||
|
||||
@@ -171,13 +171,21 @@ use codex_app_server_protocol::ThreadApproveGuardianDeniedActionResponse;
|
||||
use codex_app_server_protocol::ThreadArchiveParams;
|
||||
use codex_app_server_protocol::ThreadArchiveResponse;
|
||||
use codex_app_server_protocol::ThreadArchivedNotification;
|
||||
use codex_app_server_protocol::ThreadBackgroundTerminal;
|
||||
use codex_app_server_protocol::ThreadBackgroundTerminalsCleanParams;
|
||||
use codex_app_server_protocol::ThreadBackgroundTerminalsCleanResponse;
|
||||
use codex_app_server_protocol::ThreadBackgroundTerminalsListParams;
|
||||
use codex_app_server_protocol::ThreadBackgroundTerminalsListResponse;
|
||||
use codex_app_server_protocol::ThreadBackgroundTerminalsTerminateParams;
|
||||
use codex_app_server_protocol::ThreadBackgroundTerminalsTerminateResponse;
|
||||
use codex_app_server_protocol::ThreadClosedNotification;
|
||||
use codex_app_server_protocol::ThreadCompactStartParams;
|
||||
use codex_app_server_protocol::ThreadCompactStartResponse;
|
||||
use codex_app_server_protocol::ThreadDecrementElicitationParams;
|
||||
use codex_app_server_protocol::ThreadDecrementElicitationResponse;
|
||||
use codex_app_server_protocol::ThreadDeleteParams;
|
||||
use codex_app_server_protocol::ThreadDeleteResponse;
|
||||
use codex_app_server_protocol::ThreadDeletedNotification;
|
||||
use codex_app_server_protocol::ThreadForkParams;
|
||||
use codex_app_server_protocol::ThreadForkResponse;
|
||||
use codex_app_server_protocol::ThreadGoal;
|
||||
@@ -416,6 +424,7 @@ use codex_rollout::state_db::reconcile_rollout;
|
||||
use codex_state::ThreadMetadata;
|
||||
use codex_state::log_db::LogDbLayer;
|
||||
use codex_thread_store::ArchiveThreadParams as StoreArchiveThreadParams;
|
||||
use codex_thread_store::DeleteThreadParams as StoreDeleteThreadParams;
|
||||
use codex_thread_store::GitInfoPatch as StoreGitInfoPatch;
|
||||
use codex_thread_store::ListThreadsParams as StoreListThreadsParams;
|
||||
use codex_thread_store::LocalThreadStore;
|
||||
@@ -536,6 +545,7 @@ fn resolve_runtime_workspace_roots(workspace_roots: Vec<AbsolutePathBuf>) -> Vec
|
||||
|
||||
mod config_errors;
|
||||
mod request_errors;
|
||||
mod thread_delete;
|
||||
mod thread_goal_processor;
|
||||
mod thread_lifecycle;
|
||||
mod thread_resume_redaction;
|
||||
|
||||
@@ -158,6 +158,9 @@ impl AccountRequestProcessor {
|
||||
|
||||
pub(crate) fn clear_external_auth(&self) {
|
||||
self.auth_manager.clear_external_auth();
|
||||
self.thread_manager
|
||||
.plugins_manager()
|
||||
.set_auth_mode(self.auth_manager.get_api_auth_mode());
|
||||
}
|
||||
|
||||
fn current_account_updated_notification(&self) -> AccountUpdatedNotification {
|
||||
@@ -173,6 +176,10 @@ impl AccountRequestProcessor {
|
||||
thread_manager: &Arc<ThreadManager>,
|
||||
auth: Option<CodexAuth>,
|
||||
) {
|
||||
thread_manager
|
||||
.plugins_manager()
|
||||
.set_auth_mode(auth.as_ref().map(CodexAuth::api_auth_mode));
|
||||
|
||||
match config_manager
|
||||
.load_latest_config(/*fallback_cwd*/ None)
|
||||
.await
|
||||
|
||||
@@ -573,6 +573,7 @@ impl PluginRequestProcessor {
|
||||
.list_marketplaces_for_config(
|
||||
&config_for_marketplace_listing,
|
||||
&roots_for_marketplace_listing,
|
||||
/*include_openai_curated*/ true,
|
||||
)?;
|
||||
Ok::<
|
||||
(
|
||||
@@ -837,8 +838,11 @@ impl PluginRequestProcessor {
|
||||
let config_for_marketplace_listing = plugins_input.clone();
|
||||
let shared_plugin_ids_by_local_path = load_shared_plugin_ids_by_local_path(config)?;
|
||||
match tokio::task::spawn_blocking(move || {
|
||||
let outcome = plugins_manager
|
||||
.list_marketplaces_for_config(&config_for_marketplace_listing, &roots)?;
|
||||
let outcome = plugins_manager.list_marketplaces_for_config(
|
||||
&config_for_marketplace_listing,
|
||||
&roots,
|
||||
/*include_openai_curated*/ true,
|
||||
)?;
|
||||
Ok::<
|
||||
(
|
||||
Vec<PluginMarketplaceEntry>,
|
||||
@@ -1031,14 +1035,7 @@ impl PluginRequestProcessor {
|
||||
}
|
||||
None => None,
|
||||
};
|
||||
let environment_manager = self.thread_manager.environment_manager();
|
||||
let app_summaries = load_plugin_app_summaries(
|
||||
&config,
|
||||
&outcome.plugin.apps,
|
||||
Arc::clone(&environment_manager),
|
||||
self.thread_manager.mcp_manager(),
|
||||
)
|
||||
.await;
|
||||
let app_summaries = load_plugin_app_summaries(&config, &outcome.plugin.apps).await;
|
||||
let visible_skills = outcome
|
||||
.plugin
|
||||
.skills
|
||||
@@ -1114,14 +1111,7 @@ impl PluginRequestProcessor {
|
||||
.cloned()
|
||||
.map(codex_plugin::AppConnectorId)
|
||||
.collect::<Vec<_>>();
|
||||
let environment_manager = self.thread_manager.environment_manager();
|
||||
let app_summaries = load_plugin_app_summaries(
|
||||
&config,
|
||||
&plugin_apps,
|
||||
Arc::clone(&environment_manager),
|
||||
self.thread_manager.mcp_manager(),
|
||||
)
|
||||
.await;
|
||||
let app_summaries = load_plugin_app_summaries(&config, &plugin_apps).await;
|
||||
remote_plugin_detail_to_info(remote_detail, app_summaries)
|
||||
}
|
||||
};
|
||||
@@ -1578,7 +1568,6 @@ impl PluginRequestProcessor {
|
||||
name: connector.name,
|
||||
description: connector.description,
|
||||
install_url: connector.install_url,
|
||||
needs_auth: true,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
@@ -1883,8 +1872,6 @@ impl PluginRequestProcessor {
|
||||
async fn load_plugin_app_summaries(
|
||||
config: &Config,
|
||||
plugin_apps: &[codex_plugin::AppConnectorId],
|
||||
environment_manager: Arc<EnvironmentManager>,
|
||||
mcp_manager: Arc<McpManager>,
|
||||
) -> Vec<AppSummary> {
|
||||
if plugin_apps.is_empty() {
|
||||
return Vec::new();
|
||||
@@ -1902,49 +1889,9 @@ async fn load_plugin_app_summaries(
|
||||
};
|
||||
|
||||
let plugin_connectors = connectors::connectors_for_plugin_apps(connectors, plugin_apps);
|
||||
|
||||
let accessible_connectors =
|
||||
match connectors::list_accessible_connectors_from_mcp_tools_with_mcp_manager(
|
||||
config,
|
||||
/*force_refetch*/ false,
|
||||
environment_manager,
|
||||
mcp_manager,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(status) if status.codex_apps_ready => status.connectors,
|
||||
Ok(_) => {
|
||||
return plugin_connectors
|
||||
.into_iter()
|
||||
.map(AppSummary::from)
|
||||
.collect();
|
||||
}
|
||||
Err(err) => {
|
||||
warn!("failed to load app auth state for plugin/read: {err:#}");
|
||||
return plugin_connectors
|
||||
.into_iter()
|
||||
.map(AppSummary::from)
|
||||
.collect();
|
||||
}
|
||||
};
|
||||
|
||||
let accessible_ids = accessible_connectors
|
||||
.iter()
|
||||
.map(|connector| connector.id.as_str())
|
||||
.collect::<HashSet<_>>();
|
||||
|
||||
plugin_connectors
|
||||
.into_iter()
|
||||
.map(|connector| {
|
||||
let needs_auth = !accessible_ids.contains(connector.id.as_str());
|
||||
AppSummary {
|
||||
id: connector.id,
|
||||
name: connector.name,
|
||||
description: connector.description,
|
||||
install_url: connector.install_url,
|
||||
needs_auth,
|
||||
}
|
||||
})
|
||||
.map(AppSummary::from)
|
||||
.collect()
|
||||
}
|
||||
|
||||
@@ -1979,7 +1926,6 @@ fn plugin_apps_needing_auth(
|
||||
name: connector.name,
|
||||
description: connector.description,
|
||||
install_url: connector.install_url,
|
||||
needs_auth: true,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
//! `thread/delete` request handling.
|
||||
|
||||
use super::thread_processor::core_thread_write_error;
|
||||
use super::thread_processor::unsupported_thread_store_operation;
|
||||
use super::*;
|
||||
|
||||
impl ThreadRequestProcessor {
|
||||
pub(crate) async fn thread_delete(
|
||||
&self,
|
||||
request_id: ConnectionRequestId,
|
||||
params: ThreadDeleteParams,
|
||||
) -> Result<Option<ClientResponsePayload>, JSONRPCErrorError> {
|
||||
let mut deleted_thread_ids = Vec::new();
|
||||
let result = {
|
||||
let _thread_list_state_permit = self.acquire_thread_list_state_permit().await?;
|
||||
self.thread_delete_response(params, &mut deleted_thread_ids)
|
||||
.await
|
||||
};
|
||||
match result {
|
||||
Ok(response) => {
|
||||
self.outgoing
|
||||
.send_response(request_id.clone(), response)
|
||||
.await;
|
||||
self.send_thread_deleted_notifications(deleted_thread_ids)
|
||||
.await;
|
||||
Ok(None)
|
||||
}
|
||||
Err(error) => Err(error),
|
||||
}
|
||||
}
|
||||
|
||||
async fn thread_delete_response(
|
||||
&self,
|
||||
params: ThreadDeleteParams,
|
||||
deleted_thread_ids: &mut Vec<String>,
|
||||
) -> Result<ThreadDeleteResponse, JSONRPCErrorError> {
|
||||
let thread_id = ThreadId::from_string(¶ms.thread_id)
|
||||
.map_err(|err| invalid_request(format!("invalid thread id: {err}")))?;
|
||||
|
||||
let mut thread_ids = self.state_db_spawn_subtree_thread_ids(thread_id).await?;
|
||||
let mut seen = thread_ids.iter().copied().collect::<HashSet<_>>();
|
||||
|
||||
match self
|
||||
.thread_manager
|
||||
.list_agent_subtree_thread_ids(thread_id)
|
||||
.await
|
||||
{
|
||||
Ok(live_thread_ids) => {
|
||||
for live_thread_id in live_thread_ids {
|
||||
if seen.insert(live_thread_id) {
|
||||
thread_ids.push(live_thread_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(err) => return Err(core_thread_write_error("delete thread", err)),
|
||||
}
|
||||
|
||||
self.validate_root_thread_delete(thread_id, thread_ids.len() > 1)
|
||||
.await?;
|
||||
for thread_id_to_delete in thread_ids.iter().copied() {
|
||||
self.prepare_thread_for_delete(thread_id_to_delete).await;
|
||||
}
|
||||
|
||||
let mut delete_order: Vec<_> = thread_ids.iter().skip(1).rev().copied().collect();
|
||||
delete_order.push(thread_id);
|
||||
|
||||
for thread_id_to_delete in delete_order.iter().copied() {
|
||||
match self
|
||||
.thread_store
|
||||
.delete_thread(StoreDeleteThreadParams {
|
||||
thread_id: thread_id_to_delete,
|
||||
})
|
||||
.await
|
||||
{
|
||||
Ok(()) => {}
|
||||
Err(ThreadStoreError::ThreadNotFound { .. }) => {
|
||||
warn!(
|
||||
"thread {thread_id_to_delete} was already missing while deleting {thread_id}"
|
||||
);
|
||||
}
|
||||
Err(err) => {
|
||||
return Err(thread_store_delete_error(err));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(state_db) = self.state_db.as_ref() {
|
||||
state_db
|
||||
.delete_threads_strict(thread_ids.as_slice())
|
||||
.await
|
||||
.map_err(|err| {
|
||||
internal_error(format!(
|
||||
"failed to delete app-server state for {thread_id}: {err}"
|
||||
))
|
||||
})?;
|
||||
}
|
||||
|
||||
deleted_thread_ids.extend(
|
||||
delete_order
|
||||
.into_iter()
|
||||
.map(|thread_id| thread_id.to_string()),
|
||||
);
|
||||
Ok(ThreadDeleteResponse {})
|
||||
}
|
||||
|
||||
async fn send_thread_deleted_notifications(&self, deleted_thread_ids: Vec<String>) {
|
||||
for thread_id in deleted_thread_ids {
|
||||
self.outgoing
|
||||
.send_server_notification(ServerNotification::ThreadDeleted(
|
||||
ThreadDeletedNotification { thread_id },
|
||||
))
|
||||
.await;
|
||||
}
|
||||
}
|
||||
|
||||
async fn validate_root_thread_delete(
|
||||
&self,
|
||||
thread_id: ThreadId,
|
||||
has_descendants: bool,
|
||||
) -> Result<(), JSONRPCErrorError> {
|
||||
if let Ok(thread) = self.thread_manager.get_thread(thread_id).await {
|
||||
if !thread.config_snapshot().await.ephemeral {
|
||||
return Ok(());
|
||||
}
|
||||
return Err(invalid_request(format!(
|
||||
"thread is not persisted and cannot be deleted: {thread_id}"
|
||||
)));
|
||||
}
|
||||
match self
|
||||
.thread_store
|
||||
.read_thread(StoreReadThreadParams {
|
||||
thread_id,
|
||||
include_archived: true,
|
||||
include_history: false,
|
||||
})
|
||||
.await
|
||||
{
|
||||
Ok(_) => Ok(()),
|
||||
Err(ThreadStoreError::ThreadNotFound { .. }) => {
|
||||
if has_descendants {
|
||||
return Ok(());
|
||||
}
|
||||
let Some(state_db) = self.state_db.as_ref() else {
|
||||
return Err(thread_store_delete_error(
|
||||
ThreadStoreError::ThreadNotFound { thread_id },
|
||||
));
|
||||
};
|
||||
if state_db
|
||||
.get_thread(thread_id)
|
||||
.await
|
||||
.map_err(|err| {
|
||||
internal_error(format!(
|
||||
"failed to read app-server state for {thread_id}: {err}"
|
||||
))
|
||||
})?
|
||||
.is_some()
|
||||
{
|
||||
Ok(())
|
||||
} else {
|
||||
Err(thread_store_delete_error(
|
||||
ThreadStoreError::ThreadNotFound { thread_id },
|
||||
))
|
||||
}
|
||||
}
|
||||
Err(err) => Err(thread_store_delete_error(err)),
|
||||
}
|
||||
}
|
||||
|
||||
async fn prepare_thread_for_delete(&self, thread_id: ThreadId) {
|
||||
self.prepare_thread_for_removal(thread_id, "delete").await;
|
||||
if let Some(log_db) = self.log_db.as_ref() {
|
||||
log_db.flush().await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn thread_store_delete_error(err: ThreadStoreError) -> JSONRPCErrorError {
|
||||
match err {
|
||||
ThreadStoreError::ThreadNotFound { thread_id } => {
|
||||
invalid_request(format!("thread not found: {thread_id}"))
|
||||
}
|
||||
ThreadStoreError::InvalidRequest { message } => invalid_request(message),
|
||||
ThreadStoreError::Unsupported { operation } => {
|
||||
unsupported_thread_store_operation(operation)
|
||||
}
|
||||
err => internal_error(format!("failed to delete thread: {err}")),
|
||||
}
|
||||
}
|
||||
@@ -325,6 +325,7 @@ pub(crate) struct ThreadRequestProcessor {
|
||||
pub(super) thread_list_state_permit: Arc<Semaphore>,
|
||||
pub(super) thread_goal_processor: ThreadGoalRequestProcessor,
|
||||
pub(super) state_db: Option<StateDbHandle>,
|
||||
pub(super) log_db: Option<LogDbLayer>,
|
||||
pub(super) background_tasks: TaskTracker,
|
||||
pub(super) skills_watcher: Arc<SkillsWatcher>,
|
||||
}
|
||||
@@ -356,6 +357,7 @@ impl ThreadRequestProcessor {
|
||||
thread_list_state_permit: Arc<Semaphore>,
|
||||
thread_goal_processor: ThreadGoalRequestProcessor,
|
||||
state_db: Option<StateDbHandle>,
|
||||
log_db: Option<LogDbLayer>,
|
||||
skills_watcher: Arc<SkillsWatcher>,
|
||||
) -> Self {
|
||||
Self {
|
||||
@@ -372,6 +374,7 @@ impl ThreadRequestProcessor {
|
||||
thread_list_state_permit,
|
||||
thread_goal_processor,
|
||||
state_db,
|
||||
log_db,
|
||||
background_tasks: TaskTracker::new(),
|
||||
skills_watcher,
|
||||
}
|
||||
@@ -569,6 +572,24 @@ impl ThreadRequestProcessor {
|
||||
.map(|response| Some(response.into()))
|
||||
}
|
||||
|
||||
pub(crate) async fn thread_background_terminals_list(
|
||||
&self,
|
||||
params: ThreadBackgroundTerminalsListParams,
|
||||
) -> Result<Option<ClientResponsePayload>, JSONRPCErrorError> {
|
||||
self.thread_background_terminals_list_inner(params)
|
||||
.await
|
||||
.map(|response| Some(response.into()))
|
||||
}
|
||||
|
||||
pub(crate) async fn thread_background_terminals_terminate(
|
||||
&self,
|
||||
params: ThreadBackgroundTerminalsTerminateParams,
|
||||
) -> Result<Option<ClientResponsePayload>, JSONRPCErrorError> {
|
||||
self.thread_background_terminals_terminate_inner(params)
|
||||
.await
|
||||
.map(|response| Some(response.into()))
|
||||
}
|
||||
|
||||
pub(crate) async fn thread_rollback(
|
||||
&self,
|
||||
request_id: &ConnectionRequestId,
|
||||
@@ -678,7 +699,7 @@ impl ThreadRequestProcessor {
|
||||
|
||||
Ok((thread_id, thread))
|
||||
}
|
||||
async fn acquire_thread_list_state_permit(
|
||||
pub(super) async fn acquire_thread_list_state_permit(
|
||||
&self,
|
||||
) -> Result<SemaphorePermit<'_>, JSONRPCErrorError> {
|
||||
self.thread_list_state_permit
|
||||
@@ -750,6 +771,10 @@ impl ThreadRequestProcessor {
|
||||
}
|
||||
|
||||
async fn prepare_thread_for_archive(&self, thread_id: ThreadId) {
|
||||
self.prepare_thread_for_removal(thread_id, "archive").await;
|
||||
}
|
||||
|
||||
pub(super) async fn prepare_thread_for_removal(&self, thread_id: ThreadId, operation: &str) {
|
||||
let removed_conversation = self.thread_manager.remove_thread(&thread_id).await;
|
||||
if let Some(conversation) = removed_conversation {
|
||||
info!("thread {thread_id} was active; shutting down");
|
||||
@@ -757,11 +782,11 @@ impl ThreadRequestProcessor {
|
||||
ThreadShutdownResult::Complete => {}
|
||||
ThreadShutdownResult::SubmitFailed => {
|
||||
error!(
|
||||
"failed to submit Shutdown to thread {thread_id}; proceeding with archive"
|
||||
"failed to submit Shutdown to thread {thread_id}; proceeding with {operation}"
|
||||
);
|
||||
}
|
||||
ThreadShutdownResult::TimedOut => {
|
||||
warn!("thread {thread_id} shutdown timed out; proceeding with archive");
|
||||
warn!("thread {thread_id} shutdown timed out; proceeding with {operation}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1294,23 +1319,7 @@ impl ThreadRequestProcessor {
|
||||
let thread_id = ThreadId::from_string(¶ms.thread_id)
|
||||
.map_err(|err| invalid_request(format!("invalid session id: {err}")))?;
|
||||
|
||||
let mut thread_ids = vec![thread_id];
|
||||
if let Some(state_db_ctx) = self.state_db.as_ref() {
|
||||
let descendants = state_db_ctx
|
||||
.list_thread_spawn_descendants(thread_id)
|
||||
.await
|
||||
.map_err(|err| {
|
||||
internal_error(format!(
|
||||
"failed to list spawned descendants for session {thread_id}: {err}"
|
||||
))
|
||||
})?;
|
||||
let mut seen = HashSet::from([thread_id]);
|
||||
for descendant_id in descendants {
|
||||
if seen.insert(descendant_id) {
|
||||
thread_ids.push(descendant_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
let thread_ids = self.state_db_spawn_subtree_thread_ids(thread_id).await?;
|
||||
|
||||
let mut archive_thread_ids = Vec::new();
|
||||
match self
|
||||
@@ -1395,6 +1404,31 @@ impl ThreadRequestProcessor {
|
||||
Ok((ThreadArchiveResponse {}, archived_thread_ids))
|
||||
}
|
||||
|
||||
pub(super) async fn state_db_spawn_subtree_thread_ids(
|
||||
&self,
|
||||
thread_id: ThreadId,
|
||||
) -> Result<Vec<ThreadId>, JSONRPCErrorError> {
|
||||
let mut thread_ids = vec![thread_id];
|
||||
let Some(state_db_ctx) = self.state_db.as_ref() else {
|
||||
return Ok(thread_ids);
|
||||
};
|
||||
let mut seen = HashSet::from([thread_id]);
|
||||
let descendants = state_db_ctx
|
||||
.list_thread_spawn_descendants(thread_id)
|
||||
.await
|
||||
.map_err(|err| {
|
||||
internal_error(format!(
|
||||
"failed to list spawned descendants for thread id {thread_id}: {err}"
|
||||
))
|
||||
})?;
|
||||
for descendant_id in descendants {
|
||||
if seen.insert(descendant_id) {
|
||||
thread_ids.push(descendant_id);
|
||||
}
|
||||
}
|
||||
Ok(thread_ids)
|
||||
}
|
||||
|
||||
async fn thread_increment_elicitation_inner(
|
||||
&self,
|
||||
params: ThreadIncrementElicitationParams,
|
||||
@@ -1725,6 +1759,54 @@ impl ThreadRequestProcessor {
|
||||
Ok(ThreadBackgroundTerminalsCleanResponse {})
|
||||
}
|
||||
|
||||
async fn thread_background_terminals_list_inner(
|
||||
&self,
|
||||
params: ThreadBackgroundTerminalsListParams,
|
||||
) -> Result<ThreadBackgroundTerminalsListResponse, JSONRPCErrorError> {
|
||||
let ThreadBackgroundTerminalsListParams {
|
||||
thread_id,
|
||||
cursor,
|
||||
limit,
|
||||
} = params;
|
||||
|
||||
let (_, thread) = self.load_thread(&thread_id).await?;
|
||||
let terminals = thread
|
||||
.list_background_terminals()
|
||||
.await
|
||||
.into_iter()
|
||||
.map(|terminal| ThreadBackgroundTerminal {
|
||||
item_id: terminal.item_id,
|
||||
process_id: terminal.process_id,
|
||||
command: terminal.command,
|
||||
cwd: terminal.cwd,
|
||||
os_pid: None,
|
||||
cpu_percent: None,
|
||||
rss_kb: None,
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let (data, next_cursor) = paginate_background_terminals(&terminals, cursor, limit)?;
|
||||
|
||||
Ok(ThreadBackgroundTerminalsListResponse { data, next_cursor })
|
||||
}
|
||||
|
||||
async fn thread_background_terminals_terminate_inner(
|
||||
&self,
|
||||
params: ThreadBackgroundTerminalsTerminateParams,
|
||||
) -> Result<ThreadBackgroundTerminalsTerminateResponse, JSONRPCErrorError> {
|
||||
let ThreadBackgroundTerminalsTerminateParams {
|
||||
thread_id,
|
||||
process_id,
|
||||
} = params;
|
||||
let process_id = process_id.parse::<i32>().map_err(|err| {
|
||||
invalid_request(format!("invalid background terminal process id: {err}"))
|
||||
})?;
|
||||
|
||||
let (_, thread) = self.load_thread(&thread_id).await?;
|
||||
let terminated = thread.terminate_background_terminal(process_id).await;
|
||||
Ok(ThreadBackgroundTerminalsTerminateResponse { terminated })
|
||||
}
|
||||
|
||||
async fn thread_shell_command_inner(
|
||||
&self,
|
||||
request_id: &ConnectionRequestId,
|
||||
@@ -3851,7 +3933,7 @@ fn thread_read_view_error(err: ThreadReadViewError) -> JSONRPCErrorError {
|
||||
}
|
||||
}
|
||||
|
||||
fn unsupported_thread_store_operation(operation: &'static str) -> JSONRPCErrorError {
|
||||
pub(super) fn unsupported_thread_store_operation(operation: &'static str) -> JSONRPCErrorError {
|
||||
method_not_found(format!("{operation} is not supported yet"))
|
||||
}
|
||||
|
||||
@@ -3972,7 +4054,7 @@ fn conversation_summary_rollout_path_read_error(
|
||||
}
|
||||
}
|
||||
|
||||
fn core_thread_write_error(operation: &str, err: CodexErr) -> JSONRPCErrorError {
|
||||
pub(super) fn core_thread_write_error(operation: &str, err: CodexErr) -> JSONRPCErrorError {
|
||||
match err {
|
||||
CodexErr::ThreadNotFound(thread_id) => {
|
||||
invalid_request(format!("thread not found: {thread_id}"))
|
||||
@@ -4258,6 +4340,34 @@ fn build_thread_from_snapshot(
|
||||
}
|
||||
}
|
||||
|
||||
fn paginate_background_terminals(
|
||||
terminals: &[ThreadBackgroundTerminal],
|
||||
cursor: Option<String>,
|
||||
limit: Option<u32>,
|
||||
) -> Result<(Vec<ThreadBackgroundTerminal>, Option<String>), JSONRPCErrorError> {
|
||||
let start = match cursor {
|
||||
Some(cursor) => {
|
||||
let cursor = cursor
|
||||
.parse::<i32>()
|
||||
.map_err(|err| invalid_request(format!("invalid cursor: {err}")))?;
|
||||
terminals
|
||||
.iter()
|
||||
.position(|terminal| {
|
||||
terminal
|
||||
.process_id
|
||||
.parse::<i32>()
|
||||
.is_ok_and(|process_id| process_id > cursor)
|
||||
})
|
||||
.unwrap_or(terminals.len())
|
||||
}
|
||||
None => 0,
|
||||
};
|
||||
let effective_limit = limit.unwrap_or(terminals.len() as u32).max(1) as usize;
|
||||
let end = start.saturating_add(effective_limit).min(terminals.len());
|
||||
let next_cursor = (end < terminals.len()).then(|| terminals[end - 1].process_id.clone());
|
||||
Ok((terminals[start..end].to_vec(), next_cursor))
|
||||
}
|
||||
|
||||
fn build_thread_from_loaded_snapshot(
|
||||
thread_id: ThreadId,
|
||||
config_snapshot: &ThreadConfigSnapshot,
|
||||
|
||||
@@ -36,6 +36,65 @@ mod thread_list_cwd_filter_tests {
|
||||
}
|
||||
}
|
||||
|
||||
mod background_terminal_pagination_tests {
|
||||
use super::super::paginate_background_terminals;
|
||||
use codex_app_server_protocol::ThreadBackgroundTerminal;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
fn terminal(process_id: &str) -> ThreadBackgroundTerminal {
|
||||
let cwd = if cfg!(windows) { r"C:\tmp" } else { "/tmp" };
|
||||
|
||||
ThreadBackgroundTerminal {
|
||||
item_id: format!("item-{process_id}"),
|
||||
process_id: process_id.to_string(),
|
||||
command: format!("command-{process_id}"),
|
||||
cwd: AbsolutePathBuf::from_absolute_path(cwd).expect("absolute cwd"),
|
||||
os_pid: None,
|
||||
cpu_percent: None,
|
||||
rss_kb: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paginates_with_process_id_cursor() {
|
||||
let terminals = vec![
|
||||
terminal("1"),
|
||||
terminal("2"),
|
||||
terminal("3"),
|
||||
terminal("4"),
|
||||
terminal("5"),
|
||||
];
|
||||
|
||||
let (data, next_cursor) =
|
||||
paginate_background_terminals(&terminals, /*cursor*/ None, Some(2))
|
||||
.expect("valid page");
|
||||
|
||||
assert_eq!(data, vec![terminal("1"), terminal("2")]);
|
||||
assert_eq!(next_cursor, Some("2".to_string()));
|
||||
let first_cursor = next_cursor;
|
||||
|
||||
let terminals_without_anchor = vec![terminal("1"), terminal("3"), terminal("4")];
|
||||
let (data, next_cursor) =
|
||||
paginate_background_terminals(&terminals_without_anchor, first_cursor.clone(), Some(2))
|
||||
.expect("valid page");
|
||||
|
||||
assert_eq!(data, vec![terminal("3"), terminal("4")]);
|
||||
assert_eq!(next_cursor, None);
|
||||
|
||||
let (data, next_cursor) =
|
||||
paginate_background_terminals(&terminals, first_cursor, Some(2)).expect("valid page");
|
||||
|
||||
assert_eq!(data, vec![terminal("3"), terminal("4")]);
|
||||
assert_eq!(next_cursor, Some("4".to_string()));
|
||||
|
||||
assert!(
|
||||
paginate_background_terminals(&terminals, Some("missing".to_string()), Some(1))
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
mod thread_processor_behavior_tests {
|
||||
async fn forked_from_id_from_rollout(path: &Path) -> Option<String> {
|
||||
codex_core::read_session_meta_line(path)
|
||||
|
||||
@@ -934,6 +934,7 @@ impl TurnRequestProcessor {
|
||||
request_id,
|
||||
thread.as_ref(),
|
||||
Op::RealtimeConversationStart(ConversationStartParams {
|
||||
model: params.model,
|
||||
output_modality: params.output_modality,
|
||||
prompt: params.prompt,
|
||||
realtime_session_id: params.realtime_session_id,
|
||||
@@ -945,6 +946,7 @@ impl TurnRequestProcessor {
|
||||
ConversationStartTransport::Webrtc { sdp }
|
||||
}
|
||||
}),
|
||||
version: params.version,
|
||||
voice: params.voice,
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -165,6 +165,7 @@ pub fn write_chatgpt_auth(
|
||||
last_refresh,
|
||||
agent_identity: None,
|
||||
personal_access_token: None,
|
||||
bedrock_api_key: None,
|
||||
};
|
||||
|
||||
save_auth(codex_home, &auth, cli_auth_credentials_store_mode).context("write auth.json")
|
||||
|
||||
@@ -47,6 +47,7 @@ fn preset_to_info(preset: &ModelPreset, priority: i32) -> ModelInfo {
|
||||
context_window: Some(272_000),
|
||||
max_context_window: None,
|
||||
auto_compact_token_limit: None,
|
||||
comp_hash: None,
|
||||
effective_context_window_percent: 95,
|
||||
experimental_supported_tools: Vec::new(),
|
||||
input_modalities: default_input_modalities(),
|
||||
|
||||
@@ -79,6 +79,7 @@ use codex_app_server_protocol::SkillsExtraRootsSetParams;
|
||||
use codex_app_server_protocol::SkillsListParams;
|
||||
use codex_app_server_protocol::ThreadArchiveParams;
|
||||
use codex_app_server_protocol::ThreadCompactStartParams;
|
||||
use codex_app_server_protocol::ThreadDeleteParams;
|
||||
use codex_app_server_protocol::ThreadForkParams;
|
||||
use codex_app_server_protocol::ThreadInjectItemsParams;
|
||||
use codex_app_server_protocol::ThreadListParams;
|
||||
@@ -456,6 +457,15 @@ impl TestAppServer {
|
||||
self.send_request("thread/archive", params).await
|
||||
}
|
||||
|
||||
/// Send a `thread/delete` JSON-RPC request.
|
||||
pub async fn send_thread_delete_request(
|
||||
&mut self,
|
||||
params: ThreadDeleteParams,
|
||||
) -> anyhow::Result<i64> {
|
||||
let params = Some(serde_json::to_value(params)?);
|
||||
self.send_request("thread/delete", params).await
|
||||
}
|
||||
|
||||
/// Send a `thread/name/set` JSON-RPC request.
|
||||
pub async fn send_thread_set_name_request(
|
||||
&mut self,
|
||||
|
||||
@@ -124,6 +124,31 @@ pub(crate) async fn wait_for_analytics_event(
|
||||
server: &MockServer,
|
||||
read_timeout: Duration,
|
||||
event_type: &str,
|
||||
) -> Result<Value> {
|
||||
wait_for_matching_analytics_event(server, read_timeout, |event| {
|
||||
event["event_type"] == event_type
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub(crate) async fn wait_for_goal_event(
|
||||
server: &MockServer,
|
||||
read_timeout: Duration,
|
||||
event_kind: &str,
|
||||
goal_status: &str,
|
||||
) -> Result<Value> {
|
||||
wait_for_matching_analytics_event(server, read_timeout, |event| {
|
||||
event["event_type"] == "codex_goal_event"
|
||||
&& event["event_params"]["event_kind"] == event_kind
|
||||
&& event["event_params"]["goal_status"] == goal_status
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
async fn wait_for_matching_analytics_event(
|
||||
server: &MockServer,
|
||||
read_timeout: Duration,
|
||||
matches: impl Fn(&Value) -> bool,
|
||||
) -> Result<Value> {
|
||||
timeout(read_timeout, async {
|
||||
loop {
|
||||
@@ -142,10 +167,7 @@ pub(crate) async fn wait_for_analytics_event(
|
||||
let Some(events) = payload["events"].as_array() else {
|
||||
continue;
|
||||
};
|
||||
if let Some(event) = events
|
||||
.iter()
|
||||
.find(|event| event["event_type"] == event_type)
|
||||
{
|
||||
if let Some(event) = events.iter().find(|event| matches(event)) {
|
||||
return Ok::<Value, anyhow::Error>(event.clone());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -119,6 +119,7 @@ async fn list_apps_returns_empty_with_api_key_auth() -> Result<()> {
|
||||
last_refresh: None,
|
||||
agent_identity: None,
|
||||
personal_access_token: None,
|
||||
bedrock_api_key: None,
|
||||
},
|
||||
AuthCredentialsStoreMode::File,
|
||||
)?;
|
||||
@@ -1516,7 +1517,7 @@ async fn start_apps_server_with_delays_and_control_inner(
|
||||
get(workspace_settings_response),
|
||||
)
|
||||
.with_state(state)
|
||||
.nest_service("/api/codex/apps", mcp_service);
|
||||
.nest_service("/api/codex/ps/mcp", mcp_service);
|
||||
|
||||
let handle = tokio::spawn(async move {
|
||||
let _ = axum::serve(listener, router).await;
|
||||
|
||||
@@ -29,6 +29,7 @@ use codex_app_server_protocol::TurnStartParams;
|
||||
use codex_app_server_protocol::TurnStartResponse;
|
||||
use codex_app_server_protocol::UserInput as V2UserInput;
|
||||
use codex_config::types::AuthCredentialsStoreMode;
|
||||
use codex_features::Feature;
|
||||
use codex_protocol::models::ContentItem;
|
||||
use codex_protocol::models::ResponseItem;
|
||||
use core_test_support::responses;
|
||||
@@ -150,7 +151,7 @@ async fn auto_compaction_remote_emits_started_and_completed_items() -> Result<()
|
||||
write_mock_responses_config_toml(
|
||||
codex_home.path(),
|
||||
&server.uri(),
|
||||
&BTreeMap::default(),
|
||||
&BTreeMap::from([(Feature::RemoteCompactionV2, false)]),
|
||||
REMOTE_AUTO_COMPACT_LIMIT,
|
||||
Some(true),
|
||||
"mock_provider",
|
||||
|
||||
@@ -80,10 +80,12 @@ async fn realtime_conversation_start_requires_experimental_api_capability() -> R
|
||||
let request_id = mcp
|
||||
.send_thread_realtime_start_request(ThreadRealtimeStartParams {
|
||||
thread_id: "thr_123".to_string(),
|
||||
model: None,
|
||||
output_modality: RealtimeOutputModality::Audio,
|
||||
prompt: Some(Some("hello".to_string())),
|
||||
realtime_session_id: None,
|
||||
transport: None,
|
||||
version: None,
|
||||
voice: None,
|
||||
})
|
||||
.await?;
|
||||
@@ -186,12 +188,14 @@ async fn realtime_webrtc_start_requires_experimental_api_capability() -> Result<
|
||||
let request_id = mcp
|
||||
.send_thread_realtime_start_request(ThreadRealtimeStartParams {
|
||||
thread_id: "thr_123".to_string(),
|
||||
model: None,
|
||||
output_modality: RealtimeOutputModality::Audio,
|
||||
prompt: Some(Some("hello".to_string())),
|
||||
realtime_session_id: None,
|
||||
transport: Some(ThreadRealtimeStartTransport::Webrtc {
|
||||
sdp: "v=offer\r\n".to_string(),
|
||||
}),
|
||||
version: None,
|
||||
voice: None,
|
||||
})
|
||||
.await?;
|
||||
|
||||
@@ -19,6 +19,8 @@ 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_app_server_protocol::TurnStartParams;
|
||||
use codex_app_server_protocol::UserInput;
|
||||
use codex_arg0::Arg0DispatchPaths;
|
||||
use codex_config::CloudConfigBundleLoader;
|
||||
use codex_config::LoaderOverrides;
|
||||
@@ -30,9 +32,14 @@ use codex_protocol::protocol::SessionSource;
|
||||
use core_test_support::responses;
|
||||
use pretty_assertions::assert_eq;
|
||||
use rmcp::handler::server::ServerHandler;
|
||||
use rmcp::model::ListResourcesResult;
|
||||
use rmcp::model::Meta;
|
||||
use rmcp::model::PaginatedRequestParams;
|
||||
use rmcp::model::ProtocolVersion;
|
||||
use rmcp::model::RawResource;
|
||||
use rmcp::model::ReadResourceRequestParams;
|
||||
use rmcp::model::ReadResourceResult;
|
||||
use rmcp::model::Resource;
|
||||
use rmcp::model::ResourceContents;
|
||||
use rmcp::model::ServerCapabilities;
|
||||
use rmcp::model::ServerInfo;
|
||||
@@ -41,6 +48,7 @@ use rmcp::service::RoleServer;
|
||||
use rmcp::transport::StreamableHttpServerConfig;
|
||||
use rmcp::transport::StreamableHttpService;
|
||||
use rmcp::transport::streamable_http_server::session::local::LocalSessionManager;
|
||||
use serde_json::json;
|
||||
use tempfile::TempDir;
|
||||
use tokio::net::TcpListener;
|
||||
use tokio::task::JoinHandle;
|
||||
@@ -51,49 +59,34 @@ const TEST_RESOURCE_URI: &str = "test://codex/resource";
|
||||
const TEST_BLOB_RESOURCE_URI: &str = "test://codex/resource.bin";
|
||||
const TEST_RESOURCE_BLOB: &str = "YmluYXJ5LXJlc291cmNl";
|
||||
const TEST_RESOURCE_TEXT: &str = "Resource body from the MCP server.";
|
||||
const SKILL_NAME: &str = "demo-plugin:deploy";
|
||||
const RAW_SKILL_DESCRIPTION: &str = "Deploy\nthrough the <hosted> orchestrator.";
|
||||
const SKILL_DESCRIPTION: &str = "Deploy through the <hosted> orchestrator.";
|
||||
const SKILL_RESOURCE_URI: &str = "skill://plugin_demo/deploy";
|
||||
const SKILL_MAIN_PROMPT_URI: &str = "skill://plugin_demo/deploy/SKILL.md";
|
||||
const SKILL_REFERENCE_URI: &str = "skill://plugin_demo/deploy/references/deploy.md";
|
||||
const SKILL_MARKER: &str = "ORCHESTRATOR_SKILL_BODY_MARKER";
|
||||
const SKILL_CONTENTS: &str = concat!(
|
||||
"---\n",
|
||||
"name: deploy\n",
|
||||
"description: Deploy through the orchestrator.\n",
|
||||
"---\n\n",
|
||||
"# Deploy\n\n",
|
||||
"ORCHESTRATOR_SKILL_BODY_MARKER\n\n",
|
||||
"Read the [deployment reference](skill://plugin_demo/deploy/references/deploy.md).\n",
|
||||
);
|
||||
const SKILL_REFERENCE_CONTENTS: &str =
|
||||
"# Deploy reference\n\nUse the orchestrator deployment API.\n";
|
||||
const SKILLS_LIST_CALL_ID: &str = "skills-list";
|
||||
const SKILLS_READ_CALL_ID: &str = "skills-read";
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn mcp_resource_read_returns_resource_contents() -> Result<()> {
|
||||
let responses_server = responses::start_mock_server().await;
|
||||
let (apps_server_url, apps_server_handle) = start_resource_apps_mcp_server().await?;
|
||||
|
||||
let codex_home = TempDir::new()?;
|
||||
let responses_server_uri = responses_server.uri();
|
||||
std::fs::write(
|
||||
codex_home.path().join("config.toml"),
|
||||
format!(
|
||||
r#"
|
||||
model = "mock-model"
|
||||
approval_policy = "untrusted"
|
||||
sandbox_mode = "read-only"
|
||||
|
||||
model_provider = "mock_provider"
|
||||
chatgpt_base_url = "{apps_server_url}"
|
||||
mcp_oauth_credentials_store = "file"
|
||||
|
||||
[features]
|
||||
apps = true
|
||||
|
||||
[model_providers.mock_provider]
|
||||
name = "Mock provider for test"
|
||||
base_url = "{responses_server_uri}/v1"
|
||||
wire_api = "responses"
|
||||
request_max_retries = 0
|
||||
stream_max_retries = 0
|
||||
"#
|
||||
),
|
||||
)?;
|
||||
write_chatgpt_auth(
|
||||
codex_home.path(),
|
||||
ChatGptAuthFixture::new("chatgpt-token")
|
||||
.account_id("account-123")
|
||||
.chatgpt_user_id("user-123")
|
||||
.chatgpt_account_id("account-123"),
|
||||
AuthCredentialsStoreMode::File,
|
||||
)?;
|
||||
|
||||
let mut mcp = TestAppServer::new(codex_home.path()).await?;
|
||||
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
|
||||
let (_codex_home, mut mcp) =
|
||||
start_resource_test_app_server(&apps_server_url, &responses_server_uri).await?;
|
||||
|
||||
let thread_start_id = mcp
|
||||
.send_thread_start_request(ThreadStartParams {
|
||||
@@ -120,7 +113,6 @@ stream_max_retries = 0
|
||||
mcp.read_stream_until_response_message(RequestId::Integer(read_request_id)),
|
||||
)
|
||||
.await??;
|
||||
|
||||
assert_eq!(
|
||||
to_response::<McpResourceReadResponse>(read_response)?,
|
||||
expected_resource_read_response()
|
||||
@@ -131,6 +123,164 @@ stream_max_retries = 0
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn orchestrator_skill_can_read_referenced_resource_without_an_executor() -> Result<()> {
|
||||
let responses_server = responses::start_mock_server().await;
|
||||
let (apps_server_url, apps_server_handle) = start_resource_apps_mcp_server().await?;
|
||||
let responses_server_uri = responses_server.uri();
|
||||
let (_codex_home, mut mcp) =
|
||||
start_resource_test_app_server(&apps_server_url, &responses_server_uri).await?;
|
||||
|
||||
let thread_start_id = mcp
|
||||
.send_thread_start_request(ThreadStartParams {
|
||||
model: Some("mock-model".to_string()),
|
||||
environments: Some(Vec::new()),
|
||||
..Default::default()
|
||||
})
|
||||
.await?;
|
||||
let thread_start_resp: JSONRPCResponse = timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
mcp.read_stream_until_response_message(RequestId::Integer(thread_start_id)),
|
||||
)
|
||||
.await??;
|
||||
let ThreadStartResponse { thread, .. } = to_response(thread_start_resp)?;
|
||||
|
||||
let response_mock = responses::mount_sse_sequence(
|
||||
&responses_server,
|
||||
vec![
|
||||
responses::sse(vec![
|
||||
responses::ev_response_created("resp-skills-list"),
|
||||
responses::ev_function_call_with_namespace(
|
||||
SKILLS_LIST_CALL_ID,
|
||||
"skills",
|
||||
"list",
|
||||
&json!({
|
||||
"authority": {
|
||||
"kind": "orchestrator",
|
||||
},
|
||||
})
|
||||
.to_string(),
|
||||
),
|
||||
responses::ev_completed("resp-skills-list"),
|
||||
]),
|
||||
responses::sse(vec![
|
||||
responses::ev_response_created("resp-skills-read"),
|
||||
responses::ev_function_call_with_namespace(
|
||||
SKILLS_READ_CALL_ID,
|
||||
"skills",
|
||||
"read",
|
||||
&json!({
|
||||
"authority": {
|
||||
"kind": "orchestrator",
|
||||
},
|
||||
"package": SKILL_RESOURCE_URI,
|
||||
"resource": SKILL_REFERENCE_URI,
|
||||
})
|
||||
.to_string(),
|
||||
),
|
||||
responses::ev_completed("resp-skills-read"),
|
||||
]),
|
||||
responses::sse(vec![
|
||||
responses::ev_response_created("resp-orchestrator-skill"),
|
||||
responses::ev_assistant_message("msg-orchestrator-skill", "Done"),
|
||||
responses::ev_completed("resp-orchestrator-skill"),
|
||||
]),
|
||||
],
|
||||
)
|
||||
.await;
|
||||
let turn_start_id = mcp
|
||||
.send_turn_start_request(TurnStartParams {
|
||||
thread_id: thread.id,
|
||||
input: vec![UserInput::Text {
|
||||
text: format!("Use ${SKILL_NAME}"),
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
..Default::default()
|
||||
})
|
||||
.await?;
|
||||
timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
mcp.read_stream_until_response_message(RequestId::Integer(turn_start_id)),
|
||||
)
|
||||
.await??;
|
||||
timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
mcp.read_stream_until_notification_message("turn/completed"),
|
||||
)
|
||||
.await??;
|
||||
|
||||
let requests = response_mock.requests();
|
||||
assert_eq!(requests.len(), 3);
|
||||
let first_request = &requests[0];
|
||||
assert!(first_request.tool_by_name("skills", "list").is_some());
|
||||
assert!(first_request.tool_by_name("skills", "read").is_some());
|
||||
assert!(first_request.tool_by_name("skills", "search").is_none());
|
||||
|
||||
let developer_messages = first_request.message_input_texts("developer");
|
||||
let catalog_line = format!(
|
||||
"- {SKILL_NAME}: {SKILL_DESCRIPTION} (orchestrator resource: {SKILL_RESOURCE_URI})"
|
||||
);
|
||||
assert_eq!(
|
||||
1,
|
||||
developer_messages
|
||||
.iter()
|
||||
.filter(|text| text.contains(&catalog_line))
|
||||
.count()
|
||||
);
|
||||
assert!(
|
||||
developer_messages
|
||||
.iter()
|
||||
.all(|text| !text.contains("ignored-plugin:ignored"))
|
||||
);
|
||||
assert!(
|
||||
developer_messages
|
||||
.iter()
|
||||
.any(|text| text.contains("do not treat `skill://` identifiers as filesystem paths"))
|
||||
);
|
||||
let skill_fragments = first_request
|
||||
.message_input_texts("user")
|
||||
.into_iter()
|
||||
.filter(|text| text.starts_with("<skill>"))
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(1, skill_fragments.len());
|
||||
assert!(skill_fragments[0].contains(&format!("<name>{SKILL_NAME}</name>")));
|
||||
assert!(skill_fragments[0].contains(SKILL_MARKER));
|
||||
assert!(skill_fragments[0].contains(SKILL_REFERENCE_URI));
|
||||
|
||||
let list_output = requests[1]
|
||||
.function_call_output_text(SKILLS_LIST_CALL_ID)
|
||||
.ok_or_else(|| anyhow::anyhow!("skills.list output should be sent to the model"))?;
|
||||
assert_eq!(
|
||||
serde_json::from_str::<serde_json::Value>(&list_output)?,
|
||||
json!({
|
||||
"skills": [{
|
||||
"authority": {
|
||||
"kind": "orchestrator",
|
||||
},
|
||||
"package": SKILL_RESOURCE_URI,
|
||||
"name": SKILL_NAME,
|
||||
"description": SKILL_DESCRIPTION,
|
||||
"main_resource": SKILL_MAIN_PROMPT_URI,
|
||||
}],
|
||||
"warnings": ["Orchestrator skill discovery stopped after 2 resource pages: failed to list orchestrator skill resources: resources/list failed for `codex_apps`: Mcp error: -32603: simulated later-page failure"],
|
||||
})
|
||||
);
|
||||
|
||||
let read_output = requests[2]
|
||||
.function_call_output_text(SKILLS_READ_CALL_ID)
|
||||
.ok_or_else(|| anyhow::anyhow!("skills.read output should be sent to the model"))?;
|
||||
assert_eq!(
|
||||
serde_json::from_str::<serde_json::Value>(&read_output)?,
|
||||
json!({
|
||||
"resource": SKILL_REFERENCE_URI,
|
||||
"contents": SKILL_REFERENCE_CONTENTS,
|
||||
})
|
||||
);
|
||||
apps_server_handle.abort();
|
||||
let _ = apps_server_handle.await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn mcp_resource_read_returns_resource_contents_without_thread() -> Result<()> {
|
||||
let (apps_server_url, apps_server_handle) = start_resource_apps_mcp_server().await?;
|
||||
@@ -246,6 +396,52 @@ async fn mcp_resource_read_returns_error_for_unknown_thread() -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn start_resource_test_app_server(
|
||||
apps_server_url: &str,
|
||||
responses_server_uri: &str,
|
||||
) -> Result<(TempDir, TestAppServer)> {
|
||||
let codex_home = TempDir::new()?;
|
||||
std::fs::write(
|
||||
codex_home.path().join("config.toml"),
|
||||
format!(
|
||||
r#"
|
||||
model = "mock-model"
|
||||
approval_policy = "untrusted"
|
||||
sandbox_mode = "read-only"
|
||||
|
||||
model_provider = "mock_provider"
|
||||
chatgpt_base_url = "{apps_server_url}"
|
||||
mcp_oauth_credentials_store = "file"
|
||||
|
||||
[features]
|
||||
apps = true
|
||||
|
||||
[skills]
|
||||
include_instructions = true
|
||||
|
||||
[model_providers.mock_provider]
|
||||
name = "Mock provider for test"
|
||||
base_url = "{responses_server_uri}/v1"
|
||||
wire_api = "responses"
|
||||
request_max_retries = 0
|
||||
stream_max_retries = 0
|
||||
"#
|
||||
),
|
||||
)?;
|
||||
write_chatgpt_auth(
|
||||
codex_home.path(),
|
||||
ChatGptAuthFixture::new("chatgpt-token")
|
||||
.account_id("account-123")
|
||||
.chatgpt_user_id("user-123")
|
||||
.chatgpt_account_id("account-123"),
|
||||
AuthCredentialsStoreMode::File,
|
||||
)?;
|
||||
|
||||
let mut mcp = TestAppServer::new(codex_home.path()).await?;
|
||||
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
|
||||
Ok((codex_home, mcp))
|
||||
}
|
||||
|
||||
async fn start_resource_apps_mcp_server() -> Result<(String, JoinHandle<()>)> {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await?;
|
||||
let addr = listener.local_addr()?;
|
||||
@@ -256,7 +452,7 @@ async fn start_resource_apps_mcp_server() -> Result<(String, JoinHandle<()>)> {
|
||||
Arc::new(LocalSessionManager::default()),
|
||||
StreamableHttpServerConfig::default(),
|
||||
);
|
||||
let router = Router::new().nest_service("/api/codex/apps", mcp_service);
|
||||
let router = Router::new().nest_service("/api/codex/ps/mcp", mcp_service);
|
||||
let apps_server_handle = tokio::spawn(async move {
|
||||
let _ = axum::serve(listener, router).await;
|
||||
});
|
||||
@@ -292,12 +488,79 @@ impl ServerHandler for ResourceAppsMcpServer {
|
||||
.with_protocol_version(ProtocolVersion::V_2025_06_18)
|
||||
}
|
||||
|
||||
async fn list_resources(
|
||||
&self,
|
||||
request: Option<PaginatedRequestParams>,
|
||||
_context: RequestContext<RoleServer>,
|
||||
) -> Result<ListResourcesResult, rmcp::ErrorData> {
|
||||
let cursor = request.and_then(|request| request.cursor);
|
||||
if cursor.is_none() {
|
||||
return Ok(ListResourcesResult {
|
||||
resources: vec![skill_resource(
|
||||
"skill://plugin_ignored/ignored",
|
||||
"plugin_ignored/ignored",
|
||||
"Not an MCP skill resource.",
|
||||
"text/plain",
|
||||
"ignored-plugin",
|
||||
"ignored",
|
||||
)],
|
||||
next_cursor: Some("skills-page".to_string()),
|
||||
meta: None,
|
||||
});
|
||||
}
|
||||
if cursor.as_deref() == Some("failing-page") {
|
||||
return Err(rmcp::ErrorData::internal_error(
|
||||
"simulated later-page failure",
|
||||
/*data*/ None,
|
||||
));
|
||||
}
|
||||
if cursor.as_deref() != Some("skills-page") {
|
||||
return Err(rmcp::ErrorData::invalid_params(
|
||||
"unexpected resources/list cursor",
|
||||
/*data*/ None,
|
||||
));
|
||||
}
|
||||
|
||||
Ok(ListResourcesResult {
|
||||
resources: vec![skill_resource(
|
||||
SKILL_RESOURCE_URI,
|
||||
"plugin_demo/deploy",
|
||||
RAW_SKILL_DESCRIPTION,
|
||||
"mcp/skill",
|
||||
"demo-plugin",
|
||||
"deploy",
|
||||
)],
|
||||
next_cursor: Some("failing-page".to_string()),
|
||||
meta: None,
|
||||
})
|
||||
}
|
||||
|
||||
async fn read_resource(
|
||||
&self,
|
||||
request: ReadResourceRequestParams,
|
||||
_context: RequestContext<RoleServer>,
|
||||
) -> Result<ReadResourceResult, rmcp::ErrorData> {
|
||||
let uri = request.uri;
|
||||
if uri == SKILL_MAIN_PROMPT_URI {
|
||||
return Ok(ReadResourceResult::new(vec![
|
||||
ResourceContents::TextResourceContents {
|
||||
uri: SKILL_MAIN_PROMPT_URI.to_string(),
|
||||
mime_type: Some("text/markdown".to_string()),
|
||||
text: SKILL_CONTENTS.to_string(),
|
||||
meta: None,
|
||||
},
|
||||
]));
|
||||
}
|
||||
if uri == SKILL_REFERENCE_URI {
|
||||
return Ok(ReadResourceResult::new(vec![
|
||||
ResourceContents::TextResourceContents {
|
||||
uri: SKILL_REFERENCE_URI.to_string(),
|
||||
mime_type: Some("text/markdown".to_string()),
|
||||
text: SKILL_REFERENCE_CONTENTS.to_string(),
|
||||
meta: None,
|
||||
},
|
||||
]));
|
||||
}
|
||||
if uri != TEST_RESOURCE_URI {
|
||||
return Err(rmcp::ErrorData::resource_not_found(
|
||||
format!("resource not found: {uri}"),
|
||||
@@ -321,3 +584,27 @@ impl ServerHandler for ResourceAppsMcpServer {
|
||||
]))
|
||||
}
|
||||
}
|
||||
|
||||
fn skill_resource(
|
||||
uri: &str,
|
||||
name: &str,
|
||||
description: &str,
|
||||
mime_type: &str,
|
||||
plugin_name: &str,
|
||||
skill_name: &str,
|
||||
) -> Resource {
|
||||
Resource::new(
|
||||
RawResource::new(uri, name)
|
||||
.with_description(description)
|
||||
.with_mime_type(mime_type)
|
||||
.with_meta(skill_resource_meta(plugin_name, skill_name)),
|
||||
/*annotations*/ None,
|
||||
)
|
||||
}
|
||||
|
||||
fn skill_resource_meta(plugin_name: &str, skill_name: &str) -> Meta {
|
||||
Meta(serde_json::Map::from_iter([
|
||||
("plugin_name".to_string(), json!(plugin_name)),
|
||||
("skill_name".to_string(), json!(skill_name)),
|
||||
]))
|
||||
}
|
||||
|
||||
@@ -406,7 +406,7 @@ async fn start_apps_server() -> Result<(String, JoinHandle<()>)> {
|
||||
get(list_directory_connectors),
|
||||
)
|
||||
.with_state(state)
|
||||
.nest_service("/api/codex/apps", mcp_service);
|
||||
.nest_service("/api/codex/ps/mcp", mcp_service);
|
||||
|
||||
let handle = tokio::spawn(async move {
|
||||
let _ = axum::serve(listener, router).await;
|
||||
|
||||
@@ -50,6 +50,7 @@ mod review;
|
||||
mod safety_check_downgrade;
|
||||
mod skills_list;
|
||||
mod thread_archive;
|
||||
mod thread_delete;
|
||||
mod thread_fork;
|
||||
mod thread_inject_items;
|
||||
mod thread_list;
|
||||
|
||||
@@ -327,7 +327,6 @@ async fn plugin_install_uses_remote_apps_needing_auth_response() -> Result<()> {
|
||||
name: "alpha".to_string(),
|
||||
description: None,
|
||||
install_url: Some("https://chatgpt.com/apps/alpha/alpha".to_string()),
|
||||
needs_auth: true,
|
||||
}],
|
||||
}
|
||||
);
|
||||
@@ -1001,7 +1000,6 @@ async fn plugin_install_returns_apps_needing_auth() -> Result<()> {
|
||||
name: "Alpha".to_string(),
|
||||
description: Some("Alpha connector".to_string()),
|
||||
install_url: Some("https://chatgpt.com/apps/alpha/alpha".to_string()),
|
||||
needs_auth: true,
|
||||
}],
|
||||
}
|
||||
);
|
||||
@@ -1089,7 +1087,6 @@ async fn plugin_install_filters_disallowed_apps_needing_auth() -> Result<()> {
|
||||
name: "Alpha".to_string(),
|
||||
description: Some("Alpha connector".to_string()),
|
||||
install_url: Some("https://chatgpt.com/apps/alpha/alpha".to_string()),
|
||||
needs_auth: true,
|
||||
}],
|
||||
}
|
||||
);
|
||||
@@ -1289,7 +1286,7 @@ async fn start_apps_server(
|
||||
get(list_directory_connectors),
|
||||
)
|
||||
.with_state(state)
|
||||
.nest_service("/api/codex/apps", mcp_service);
|
||||
.nest_service("/api/codex/ps/mcp", mcp_service);
|
||||
|
||||
let handle = tokio::spawn(async move {
|
||||
let _ = axum::serve(listener, router).await;
|
||||
|
||||
@@ -1,6 +1,3 @@
|
||||
use std::borrow::Cow;
|
||||
use std::sync::Arc;
|
||||
use std::sync::Mutex as StdMutex;
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::Result;
|
||||
@@ -8,15 +5,6 @@ use app_test_support::ChatGptAuthFixture;
|
||||
use app_test_support::TestAppServer;
|
||||
use app_test_support::to_response;
|
||||
use app_test_support::write_chatgpt_auth;
|
||||
use axum::Json;
|
||||
use axum::Router;
|
||||
use axum::extract::State;
|
||||
use axum::http::HeaderMap;
|
||||
use axum::http::StatusCode;
|
||||
use axum::http::Uri;
|
||||
use axum::http::header::AUTHORIZATION;
|
||||
use axum::routing::get;
|
||||
use codex_app_server_protocol::AppInfo;
|
||||
use codex_app_server_protocol::AppTemplateSummary;
|
||||
use codex_app_server_protocol::AppTemplateUnavailableReason;
|
||||
use codex_app_server_protocol::HookEventName;
|
||||
@@ -37,21 +25,8 @@ use codex_app_server_protocol::RequestId;
|
||||
use codex_config::types::AuthCredentialsStoreMode;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use pretty_assertions::assert_eq;
|
||||
use rmcp::handler::server::ServerHandler;
|
||||
use rmcp::model::JsonObject;
|
||||
use rmcp::model::ListToolsResult;
|
||||
use rmcp::model::Meta;
|
||||
use rmcp::model::ServerCapabilities;
|
||||
use rmcp::model::ServerInfo;
|
||||
use rmcp::model::Tool;
|
||||
use rmcp::model::ToolAnnotations;
|
||||
use rmcp::transport::StreamableHttpServerConfig;
|
||||
use rmcp::transport::StreamableHttpService;
|
||||
use rmcp::transport::streamable_http_server::session::local::LocalSessionManager;
|
||||
use serde_json::json;
|
||||
use tempfile::TempDir;
|
||||
use tokio::net::TcpListener;
|
||||
use tokio::task::JoinHandle;
|
||||
use tokio::time::timeout;
|
||||
use wiremock::Mock;
|
||||
use wiremock::MockServer;
|
||||
@@ -1483,104 +1458,11 @@ enabled = false
|
||||
response.plugin.apps[0].install_url.as_deref(),
|
||||
Some("https://chatgpt.com/apps/gmail/gmail")
|
||||
);
|
||||
assert_eq!(response.plugin.apps[0].needs_auth, true);
|
||||
assert_eq!(response.plugin.mcp_servers.len(), 1);
|
||||
assert_eq!(response.plugin.mcp_servers[0], "demo");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn plugin_read_returns_app_needs_auth() -> Result<()> {
|
||||
let connectors = vec![
|
||||
AppInfo {
|
||||
id: "alpha".to_string(),
|
||||
name: "Alpha".to_string(),
|
||||
description: Some("Alpha connector".to_string()),
|
||||
logo_url: Some("https://example.com/alpha.png".to_string()),
|
||||
logo_url_dark: None,
|
||||
distribution_channel: Some("featured".to_string()),
|
||||
branding: None,
|
||||
app_metadata: None,
|
||||
labels: None,
|
||||
install_url: None,
|
||||
is_accessible: false,
|
||||
is_enabled: true,
|
||||
plugin_display_names: Vec::new(),
|
||||
},
|
||||
AppInfo {
|
||||
id: "beta".to_string(),
|
||||
name: "Beta".to_string(),
|
||||
description: Some("Beta connector".to_string()),
|
||||
logo_url: None,
|
||||
logo_url_dark: None,
|
||||
distribution_channel: None,
|
||||
branding: None,
|
||||
app_metadata: None,
|
||||
labels: None,
|
||||
install_url: None,
|
||||
is_accessible: false,
|
||||
is_enabled: true,
|
||||
plugin_display_names: Vec::new(),
|
||||
},
|
||||
];
|
||||
let tools = vec![connector_tool("beta", "Beta App")?];
|
||||
let (server_url, server_handle) = start_apps_server(connectors, tools).await?;
|
||||
|
||||
let codex_home = TempDir::new()?;
|
||||
write_connectors_config(codex_home.path(), &server_url)?;
|
||||
write_chatgpt_auth(
|
||||
codex_home.path(),
|
||||
ChatGptAuthFixture::new("chatgpt-token")
|
||||
.account_id("account-123")
|
||||
.chatgpt_user_id("user-123")
|
||||
.chatgpt_account_id("account-123"),
|
||||
AuthCredentialsStoreMode::File,
|
||||
)?;
|
||||
|
||||
let repo_root = TempDir::new()?;
|
||||
write_plugin_marketplace(
|
||||
repo_root.path(),
|
||||
"debug",
|
||||
"sample-plugin",
|
||||
"./sample-plugin",
|
||||
)?;
|
||||
write_plugin_source(repo_root.path(), "sample-plugin", &["alpha", "beta"])?;
|
||||
let marketplace_path =
|
||||
AbsolutePathBuf::try_from(repo_root.path().join(".agents/plugins/marketplace.json"))?;
|
||||
|
||||
let mut mcp = TestAppServer::new(codex_home.path()).await?;
|
||||
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
|
||||
|
||||
let request_id = mcp
|
||||
.send_plugin_read_request(PluginReadParams {
|
||||
marketplace_path: Some(marketplace_path),
|
||||
remote_marketplace_name: None,
|
||||
plugin_name: "sample-plugin".to_string(),
|
||||
})
|
||||
.await?;
|
||||
|
||||
let response: JSONRPCResponse = timeout(
|
||||
DEFAULT_TIMEOUT,
|
||||
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
|
||||
)
|
||||
.await??;
|
||||
let response: PluginReadResponse = to_response(response)?;
|
||||
|
||||
assert_eq!(
|
||||
response
|
||||
.plugin
|
||||
.apps
|
||||
.iter()
|
||||
.map(|app| (app.id.as_str(), app.needs_auth))
|
||||
.collect::<Vec<_>>(),
|
||||
vec![("alpha", true), ("beta", false)]
|
||||
);
|
||||
|
||||
server_handle.abort();
|
||||
let _ = server_handle.await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn plugin_read_accepts_legacy_string_default_prompt() -> Result<()> {
|
||||
let codex_home = TempDir::new()?;
|
||||
@@ -1846,151 +1728,6 @@ plugins = true
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct AppsServerState {
|
||||
response: Arc<StdMutex<serde_json::Value>>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct PluginReadMcpServer {
|
||||
tools: Arc<StdMutex<Vec<Tool>>>,
|
||||
}
|
||||
|
||||
impl ServerHandler for PluginReadMcpServer {
|
||||
fn get_info(&self) -> ServerInfo {
|
||||
ServerInfo::new(ServerCapabilities::builder().enable_tools().build())
|
||||
}
|
||||
|
||||
fn list_tools(
|
||||
&self,
|
||||
_request: Option<rmcp::model::PaginatedRequestParams>,
|
||||
_context: rmcp::service::RequestContext<rmcp::service::RoleServer>,
|
||||
) -> impl std::future::Future<Output = Result<ListToolsResult, rmcp::ErrorData>> + Send + '_
|
||||
{
|
||||
let tools = self.tools.clone();
|
||||
async move {
|
||||
let tools = tools
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||
.clone();
|
||||
Ok(ListToolsResult {
|
||||
tools,
|
||||
next_cursor: None,
|
||||
meta: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn start_apps_server(
|
||||
connectors: Vec<AppInfo>,
|
||||
tools: Vec<Tool>,
|
||||
) -> Result<(String, JoinHandle<()>)> {
|
||||
let state = Arc::new(AppsServerState {
|
||||
response: Arc::new(StdMutex::new(
|
||||
json!({ "apps": connectors, "next_token": null }),
|
||||
)),
|
||||
});
|
||||
let tools = Arc::new(StdMutex::new(tools));
|
||||
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await?;
|
||||
let addr = listener.local_addr()?;
|
||||
let mcp_service = StreamableHttpService::new(
|
||||
{
|
||||
let tools = tools.clone();
|
||||
move || {
|
||||
Ok(PluginReadMcpServer {
|
||||
tools: tools.clone(),
|
||||
})
|
||||
}
|
||||
},
|
||||
Arc::new(LocalSessionManager::default()),
|
||||
StreamableHttpServerConfig::default(),
|
||||
);
|
||||
let router = Router::new()
|
||||
.route("/connectors/directory/list", get(list_directory_connectors))
|
||||
.route(
|
||||
"/connectors/directory/list_workspace",
|
||||
get(list_directory_connectors),
|
||||
)
|
||||
.with_state(state)
|
||||
.nest_service("/api/codex/apps", mcp_service);
|
||||
|
||||
let handle = tokio::spawn(async move {
|
||||
let _ = axum::serve(listener, router).await;
|
||||
});
|
||||
|
||||
Ok((format!("http://{addr}"), handle))
|
||||
}
|
||||
|
||||
async fn list_directory_connectors(
|
||||
State(state): State<Arc<AppsServerState>>,
|
||||
headers: HeaderMap,
|
||||
uri: Uri,
|
||||
) -> Result<impl axum::response::IntoResponse, StatusCode> {
|
||||
let bearer_ok = headers
|
||||
.get(AUTHORIZATION)
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.is_some_and(|value| value == "Bearer chatgpt-token");
|
||||
let account_ok = headers
|
||||
.get("chatgpt-account-id")
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.is_some_and(|value| value == "account-123");
|
||||
let external_logos_ok = uri
|
||||
.query()
|
||||
.is_some_and(|query| query.split('&').any(|pair| pair == "external_logos=true"));
|
||||
|
||||
if !bearer_ok || !account_ok {
|
||||
Err(StatusCode::UNAUTHORIZED)
|
||||
} else if !external_logos_ok {
|
||||
Err(StatusCode::BAD_REQUEST)
|
||||
} else {
|
||||
let response = state
|
||||
.response
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||
.clone();
|
||||
Ok(Json(response))
|
||||
}
|
||||
}
|
||||
|
||||
fn connector_tool(connector_id: &str, connector_name: &str) -> Result<Tool> {
|
||||
let schema: JsonObject = serde_json::from_value(json!({
|
||||
"type": "object",
|
||||
"additionalProperties": false
|
||||
}))?;
|
||||
let mut tool = Tool::new(
|
||||
Cow::Owned(format!("connector_{connector_id}")),
|
||||
Cow::Borrowed("Connector test tool"),
|
||||
Arc::new(schema),
|
||||
);
|
||||
tool.annotations = Some(ToolAnnotations::new().read_only(true));
|
||||
|
||||
let mut meta = Meta::new();
|
||||
meta.0
|
||||
.insert("connector_id".to_string(), json!(connector_id));
|
||||
meta.0
|
||||
.insert("connector_name".to_string(), json!(connector_name));
|
||||
tool.meta = Some(meta);
|
||||
Ok(tool)
|
||||
}
|
||||
|
||||
fn write_connectors_config(codex_home: &std::path::Path, base_url: &str) -> std::io::Result<()> {
|
||||
std::fs::write(
|
||||
codex_home.join("config.toml"),
|
||||
format!(
|
||||
r#"
|
||||
chatgpt_base_url = "{base_url}"
|
||||
mcp_oauth_credentials_store = "file"
|
||||
|
||||
[features]
|
||||
plugins = true
|
||||
connectors = true
|
||||
"#
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
fn write_remote_plugin_catalog_config(
|
||||
codex_home: &std::path::Path,
|
||||
base_url: &str,
|
||||
|
||||
@@ -36,7 +36,10 @@ use codex_app_server_protocol::ThreadRealtimeTranscriptDoneNotification;
|
||||
use codex_app_server_protocol::ThreadStartParams;
|
||||
use codex_app_server_protocol::ThreadStartResponse;
|
||||
use codex_app_server_protocol::TurnCompletedNotification;
|
||||
use codex_app_server_protocol::TurnStartParams;
|
||||
use codex_app_server_protocol::TurnStartResponse;
|
||||
use codex_app_server_protocol::TurnStartedNotification;
|
||||
use codex_app_server_protocol::UserInput as V2UserInput;
|
||||
use codex_features::FEATURES;
|
||||
use codex_features::Feature;
|
||||
use codex_protocol::protocol::RealtimeConversationVersion;
|
||||
@@ -311,12 +314,14 @@ impl RealtimeE2eHarness {
|
||||
.mcp
|
||||
.send_thread_realtime_start_request(ThreadRealtimeStartParams {
|
||||
thread_id: self.thread_id.clone(),
|
||||
model: None,
|
||||
output_modality: RealtimeOutputModality::Audio,
|
||||
prompt: Some(Some("backend prompt".to_string())),
|
||||
realtime_session_id: None,
|
||||
transport: Some(ThreadRealtimeStartTransport::Webrtc {
|
||||
sdp: offer_sdp.to_string(),
|
||||
}),
|
||||
version: None,
|
||||
voice: None,
|
||||
})
|
||||
.await?;
|
||||
@@ -556,10 +561,12 @@ async fn realtime_conversation_streams_v2_notifications() -> Result<()> {
|
||||
let start_request_id = mcp
|
||||
.send_thread_realtime_start_request(ThreadRealtimeStartParams {
|
||||
thread_id: thread_start.thread.id.clone(),
|
||||
model: Some("realtime-treatment-model".to_string()),
|
||||
output_modality: RealtimeOutputModality::Audio,
|
||||
prompt: None,
|
||||
realtime_session_id: None,
|
||||
transport: None,
|
||||
version: None,
|
||||
voice: Some(RealtimeVoice::Cedar),
|
||||
})
|
||||
.await?;
|
||||
@@ -588,6 +595,10 @@ async fn realtime_conversation_streams_v2_notifications() -> Result<()> {
|
||||
startup_context_request.body_json()["session"]["audio"]["output"]["voice"],
|
||||
"cedar"
|
||||
);
|
||||
assert_eq!(
|
||||
realtime_server.single_handshake().uri(),
|
||||
"/v1/realtime?model=realtime-treatment-model"
|
||||
);
|
||||
assert_eq!(
|
||||
startup_context_request.body_json()["session"]["output_modalities"],
|
||||
json!(["audio"])
|
||||
@@ -805,10 +816,12 @@ async fn realtime_text_output_modality_requests_text_output_and_final_transcript
|
||||
let start_request_id = mcp
|
||||
.send_thread_realtime_start_request(ThreadRealtimeStartParams {
|
||||
thread_id: thread_start.thread.id.clone(),
|
||||
model: None,
|
||||
output_modality: RealtimeOutputModality::Text,
|
||||
prompt: None,
|
||||
realtime_session_id: None,
|
||||
transport: None,
|
||||
version: None,
|
||||
voice: None,
|
||||
})
|
||||
.await?;
|
||||
@@ -979,10 +992,12 @@ async fn realtime_conversation_stop_emits_closed_notification() -> Result<()> {
|
||||
let start_request_id = mcp
|
||||
.send_thread_realtime_start_request(ThreadRealtimeStartParams {
|
||||
thread_id: thread_start.thread.id.clone(),
|
||||
model: None,
|
||||
output_modality: RealtimeOutputModality::Audio,
|
||||
prompt: Some(Some("backend prompt".to_string())),
|
||||
realtime_session_id: None,
|
||||
transport: None,
|
||||
version: None,
|
||||
voice: None,
|
||||
})
|
||||
.await?;
|
||||
@@ -1076,12 +1091,14 @@ async fn realtime_webrtc_start_emits_sdp_notification() -> Result<()> {
|
||||
let start_request_id = mcp
|
||||
.send_thread_realtime_start_request(ThreadRealtimeStartParams {
|
||||
thread_id: thread_id.clone(),
|
||||
model: None,
|
||||
output_modality: RealtimeOutputModality::Audio,
|
||||
prompt: Some(Some("backend prompt".to_string())),
|
||||
realtime_session_id: None,
|
||||
transport: Some(ThreadRealtimeStartTransport::Webrtc {
|
||||
sdp: "v=offer\r\n".to_string(),
|
||||
}),
|
||||
version: None,
|
||||
voice: None,
|
||||
})
|
||||
.await?;
|
||||
@@ -1328,6 +1345,136 @@ async fn webrtc_v1_handoff_request_delegates_and_appends_result() -> Result<()>
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn webrtc_assistant_output_without_handoff_reaches_realtime() -> Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
|
||||
let final_answer = "long output ".repeat(1_000);
|
||||
for (version, expected_version, preamble) in [
|
||||
(
|
||||
RealtimeTestVersion::V1,
|
||||
RealtimeConversationVersion::V1,
|
||||
"direct preamble from v1",
|
||||
),
|
||||
(
|
||||
RealtimeTestVersion::V2,
|
||||
RealtimeConversationVersion::V2,
|
||||
"direct preamble from v2",
|
||||
),
|
||||
] {
|
||||
let mut harness = RealtimeE2eHarness::new(
|
||||
version,
|
||||
main_loop_responses(vec![responses::sse(vec![
|
||||
responses::ev_response_created("resp-1"),
|
||||
json!({
|
||||
"type": "response.output_item.done",
|
||||
"item": {
|
||||
"type": "message",
|
||||
"role": "assistant",
|
||||
"id": "msg-preamble",
|
||||
"phase": "commentary",
|
||||
"content": [{"type": "output_text", "text": preamble}]
|
||||
}
|
||||
}),
|
||||
responses::ev_assistant_message("msg-final", &final_answer),
|
||||
responses::ev_completed("resp-1"),
|
||||
])]),
|
||||
realtime_sideband(vec![realtime_sideband_connection(vec![
|
||||
vec![session_updated("sess_standalone_output")],
|
||||
vec![],
|
||||
match version {
|
||||
RealtimeTestVersion::V1 => vec![],
|
||||
RealtimeTestVersion::V2 => vec![
|
||||
json!({
|
||||
"type": "response.created",
|
||||
"response": { "id": "resp_preamble" }
|
||||
}),
|
||||
json!({
|
||||
"type": "response.done",
|
||||
"response": { "id": "resp_preamble" }
|
||||
}),
|
||||
],
|
||||
},
|
||||
vec![],
|
||||
vec![],
|
||||
])]),
|
||||
)
|
||||
.await?;
|
||||
|
||||
let started = harness.start_webrtc_realtime("v=offer\r\n").await?;
|
||||
assert_eq!(started.started.version, expected_version);
|
||||
|
||||
let request_id = harness
|
||||
.mcp
|
||||
.send_turn_start_request(TurnStartParams {
|
||||
thread_id: harness.thread_id.clone(),
|
||||
input: vec![V2UserInput::Text {
|
||||
text: "direct text turn".to_string(),
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
..Default::default()
|
||||
})
|
||||
.await?;
|
||||
let response: JSONRPCResponse = timeout(
|
||||
DEFAULT_TIMEOUT,
|
||||
harness
|
||||
.mcp
|
||||
.read_stream_until_response_message(RequestId::Integer(request_id)),
|
||||
)
|
||||
.await??;
|
||||
let _: TurnStartResponse = to_response(response)?;
|
||||
let _ = harness
|
||||
.read_notification::<TurnCompletedNotification>("turn/completed")
|
||||
.await?;
|
||||
|
||||
let preamble_request = harness.sideband_outbound_request(/*request_index*/ 1).await;
|
||||
let output_text = match version {
|
||||
RealtimeTestVersion::V1 => {
|
||||
let final_request = harness.sideband_outbound_request(/*request_index*/ 2).await;
|
||||
assert_eq!(
|
||||
preamble_request,
|
||||
json!({
|
||||
"type": "conversation.handoff.append",
|
||||
"handoff_id": "codex",
|
||||
"output_text": preamble,
|
||||
})
|
||||
);
|
||||
assert_eq!(final_request["type"], "conversation.handoff.append");
|
||||
assert_eq!(final_request["handoff_id"], "codex");
|
||||
final_request["output_text"]
|
||||
.as_str()
|
||||
.expect("output text")
|
||||
.to_string()
|
||||
}
|
||||
RealtimeTestVersion::V2 => {
|
||||
assert_v2_progress_update(&preamble_request, preamble);
|
||||
assert_v2_response_create(
|
||||
&harness.sideband_outbound_request(/*request_index*/ 2).await,
|
||||
);
|
||||
let final_request = harness.sideband_outbound_request(/*request_index*/ 3).await;
|
||||
assert_eq!(final_request["type"], "conversation.item.create");
|
||||
assert_eq!(final_request["item"]["type"], "message");
|
||||
assert_eq!(final_request["item"]["role"], "user");
|
||||
assert_eq!(final_request["item"]["content"][0]["type"], "input_text");
|
||||
let output_text = final_request["item"]["content"][0]["text"]
|
||||
.as_str()
|
||||
.expect("output text");
|
||||
assert!(output_text.starts_with("[BACKEND] "));
|
||||
assert_v2_response_create(
|
||||
&harness.sideband_outbound_request(/*request_index*/ 4).await,
|
||||
);
|
||||
output_text.to_string()
|
||||
}
|
||||
};
|
||||
assert!(output_text.contains("tokens truncated"));
|
||||
assert!(output_text.len() <= 4_000);
|
||||
|
||||
harness.shutdown().await;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn webrtc_v2_forwards_audio_and_text_between_client_and_sideband() -> Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
@@ -1991,12 +2138,14 @@ async fn realtime_webrtc_start_surfaces_backend_error() -> Result<()> {
|
||||
let start_request_id = mcp
|
||||
.send_thread_realtime_start_request(ThreadRealtimeStartParams {
|
||||
thread_id: thread_start.thread.id,
|
||||
model: None,
|
||||
output_modality: RealtimeOutputModality::Audio,
|
||||
prompt: Some(Some("backend prompt".to_string())),
|
||||
realtime_session_id: None,
|
||||
transport: Some(ThreadRealtimeStartTransport::Webrtc {
|
||||
sdp: "v=offer\r\n".to_string(),
|
||||
}),
|
||||
version: None,
|
||||
voice: None,
|
||||
})
|
||||
.await?;
|
||||
@@ -2050,10 +2199,12 @@ async fn realtime_conversation_requires_feature_flag() -> Result<()> {
|
||||
let start_request_id = mcp
|
||||
.send_thread_realtime_start_request(ThreadRealtimeStartParams {
|
||||
thread_id: thread_start.thread.id.clone(),
|
||||
model: None,
|
||||
output_modality: RealtimeOutputModality::Audio,
|
||||
prompt: Some(Some("backend prompt".to_string())),
|
||||
realtime_session_id: None,
|
||||
transport: None,
|
||||
version: None,
|
||||
voice: None,
|
||||
})
|
||||
.await?;
|
||||
|
||||
@@ -20,6 +20,7 @@ use std::sync::Arc;
|
||||
use anyhow::Result;
|
||||
use app_test_support::create_mock_responses_server_repeating_assistant;
|
||||
use codex_app_server::in_process;
|
||||
use codex_app_server::in_process::InProcessClientHandle;
|
||||
use codex_app_server::in_process::InProcessServerEvent;
|
||||
use codex_app_server::in_process::InProcessStartArgs;
|
||||
use codex_app_server_protocol::ClientInfo;
|
||||
@@ -27,6 +28,8 @@ use codex_app_server_protocol::ClientRequest;
|
||||
use codex_app_server_protocol::InitializeParams;
|
||||
use codex_app_server_protocol::RequestId;
|
||||
use codex_app_server_protocol::ServerNotification;
|
||||
use codex_app_server_protocol::ThreadDeleteParams;
|
||||
use codex_app_server_protocol::ThreadDeleteResponse;
|
||||
use codex_app_server_protocol::ThreadListParams;
|
||||
use codex_app_server_protocol::ThreadListResponse;
|
||||
use codex_app_server_protocol::ThreadResumeParams;
|
||||
@@ -42,8 +45,14 @@ use codex_core::config::Config;
|
||||
use codex_core::config::ConfigBuilder;
|
||||
use codex_exec_server::EnvironmentManager;
|
||||
use codex_feedback::CodexFeedback;
|
||||
use codex_protocol::ThreadId;
|
||||
use codex_protocol::models::BaseInstructions;
|
||||
use codex_protocol::protocol::SessionSource;
|
||||
use codex_protocol::protocol::ThreadMemoryMode;
|
||||
use codex_thread_store::CreateThreadParams as StoreCreateThreadParams;
|
||||
use codex_thread_store::InMemoryThreadStore;
|
||||
use codex_thread_store::ThreadPersistenceMetadata;
|
||||
use codex_thread_store::ThreadStore;
|
||||
use pretty_assertions::assert_eq;
|
||||
use tempfile::TempDir;
|
||||
use tokio::time::timeout;
|
||||
@@ -52,7 +61,7 @@ use uuid::Uuid;
|
||||
const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
|
||||
|
||||
#[tokio::test]
|
||||
async fn thread_start_with_non_local_thread_store_does_not_create_local_persistence() -> Result<()>
|
||||
async fn thread_delete_with_non_local_thread_store_does_not_create_local_persistence() -> Result<()>
|
||||
{
|
||||
let server = create_mock_responses_server_repeating_assistant("Done").await;
|
||||
let codex_home = TempDir::new()?;
|
||||
@@ -61,43 +70,10 @@ async fn thread_start_with_non_local_thread_store_does_not_create_local_persiste
|
||||
// here so this regression stays focused on thread persistence artifacts.
|
||||
create_config_toml_with_thread_store(codex_home.path(), &server.uri(), &store_id)?;
|
||||
|
||||
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 thread_store = InMemoryThreadStore::for_id(store_id.clone());
|
||||
let _in_memory_store = InMemoryThreadStoreId { store_id };
|
||||
|
||||
let mut client = in_process::start(InProcessStartArgs {
|
||||
arg0_paths: Arg0DispatchPaths::default(),
|
||||
config: Arc::new(config),
|
||||
cli_overrides: Vec::new(),
|
||||
loader_overrides,
|
||||
strict_config: false,
|
||||
cloud_config_bundle: CloudConfigBundleLoader::default(),
|
||||
thread_config_loader: Arc::new(NoopThreadConfigLoader),
|
||||
feedback: CodexFeedback::new(),
|
||||
log_db: None,
|
||||
state_db: None,
|
||||
environment_manager: Arc::new(EnvironmentManager::default_for_tests()),
|
||||
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 mut client = start_in_process_server(codex_home.path()).await?;
|
||||
|
||||
let response = client
|
||||
.request(ClientRequest::ThreadStart {
|
||||
@@ -166,11 +142,39 @@ async fn thread_start_with_non_local_thread_store_does_not_create_local_persiste
|
||||
assert_eq!(data[0].id, thread.id);
|
||||
assert_eq!(data[0].path, None);
|
||||
|
||||
delete_thread(&client, /*request_id*/ 4, thread.id.clone()).await?;
|
||||
let unloaded_thread_id = ThreadId::from_string(&Uuid::new_v4().to_string())?;
|
||||
thread_store
|
||||
.create_thread(StoreCreateThreadParams {
|
||||
thread_id: unloaded_thread_id,
|
||||
extra_config: None,
|
||||
forked_from_id: None,
|
||||
parent_thread_id: None,
|
||||
source: SessionSource::Cli,
|
||||
thread_source: None,
|
||||
base_instructions: BaseInstructions::default(),
|
||||
dynamic_tools: Vec::new(),
|
||||
multi_agent_version: None,
|
||||
metadata: ThreadPersistenceMetadata {
|
||||
cwd: Some(codex_home.path().to_path_buf()),
|
||||
model_provider: "mock_provider".to_string(),
|
||||
memory_mode: ThreadMemoryMode::Enabled,
|
||||
},
|
||||
})
|
||||
.await?;
|
||||
delete_thread(
|
||||
&client,
|
||||
/*request_id*/ 5,
|
||||
unloaded_thread_id.to_string(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
client.shutdown().await?;
|
||||
|
||||
let calls = thread_store.calls().await;
|
||||
assert_eq!(calls.create_thread, 1);
|
||||
assert_eq!(calls.create_thread, 2);
|
||||
assert_eq!(calls.list_threads, 1);
|
||||
assert_eq!(calls.delete_thread, 2);
|
||||
assert!(
|
||||
calls.append_items > 0,
|
||||
"turn/start should append rollout items through the injected store"
|
||||
@@ -269,10 +273,24 @@ async fn cold_thread_resume_reuses_non_local_history_probe() -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn start_in_process_server(codex_home: &Path) -> Result<InProcessClientHandle> {
|
||||
let loader_overrides = LoaderOverrides::without_managed_config_for_tests();
|
||||
let config = Arc::new(
|
||||
ConfigBuilder::default()
|
||||
.codex_home(codex_home.to_path_buf())
|
||||
.fallback_cwd(Some(codex_home.to_path_buf()))
|
||||
.loader_overrides(loader_overrides.clone())
|
||||
.build()
|
||||
.await?,
|
||||
);
|
||||
|
||||
Ok(start_in_process_client(config, loader_overrides).await?)
|
||||
}
|
||||
|
||||
async fn start_in_process_client(
|
||||
config: Arc<Config>,
|
||||
loader_overrides: LoaderOverrides,
|
||||
) -> std::io::Result<in_process::InProcessClientHandle> {
|
||||
) -> std::io::Result<InProcessClientHandle> {
|
||||
in_process::start(InProcessStartArgs {
|
||||
arg0_paths: Arg0DispatchPaths::default(),
|
||||
config,
|
||||
@@ -301,6 +319,22 @@ async fn start_in_process_client(
|
||||
.await
|
||||
}
|
||||
|
||||
async fn delete_thread(
|
||||
client: &InProcessClientHandle,
|
||||
request_id: i64,
|
||||
thread_id: String,
|
||||
) -> Result<()> {
|
||||
let response = client
|
||||
.request(ClientRequest::ThreadDelete {
|
||||
request_id: RequestId::Integer(request_id),
|
||||
params: ThreadDeleteParams { thread_id },
|
||||
})
|
||||
.await?
|
||||
.map_err(|error| anyhow::anyhow!("thread/delete failed: {}", error.message))?;
|
||||
let _: ThreadDeleteResponse = serde_json::from_value(response)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn assert_no_local_persistence_artifacts(codex_home: &Path) -> Result<()> {
|
||||
// These are the observable tripwires for accidental local persistence. If a
|
||||
// future code path constructs a local rollout/session store or opens the
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
use anyhow::Result;
|
||||
use app_test_support::TestAppServer;
|
||||
use app_test_support::create_fake_rollout;
|
||||
use app_test_support::to_response;
|
||||
use codex_app_server_protocol::JSONRPCError;
|
||||
use codex_app_server_protocol::JSONRPCResponse;
|
||||
use codex_app_server_protocol::RequestId;
|
||||
use codex_app_server_protocol::ThreadDeleteParams;
|
||||
use codex_app_server_protocol::ThreadDeleteResponse;
|
||||
use codex_app_server_protocol::ThreadDeletedNotification;
|
||||
use codex_app_server_protocol::ThreadLoadedListParams;
|
||||
use codex_app_server_protocol::ThreadLoadedListResponse;
|
||||
use codex_app_server_protocol::ThreadStartParams;
|
||||
use codex_app_server_protocol::ThreadStartResponse;
|
||||
use codex_core::find_thread_path_by_id_str;
|
||||
use codex_protocol::ThreadId;
|
||||
use codex_state::DirectionalThreadSpawnEdgeStatus;
|
||||
use codex_state::StateRuntime;
|
||||
use pretty_assertions::assert_eq;
|
||||
use std::path::Path;
|
||||
use tempfile::TempDir;
|
||||
use tokio::time::timeout;
|
||||
|
||||
const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
|
||||
|
||||
#[tokio::test]
|
||||
async fn thread_delete_deletes_spawned_descendants() -> Result<()> {
|
||||
let codex_home = TempDir::new()?;
|
||||
|
||||
let parent_id = create_delete_test_rollout(codex_home.path(), /*minute*/ 0, "parent")?;
|
||||
let child_id = create_delete_test_rollout(codex_home.path(), /*minute*/ 1, "child")?;
|
||||
let grandchild_id =
|
||||
create_delete_test_rollout(codex_home.path(), /*minute*/ 2, "grandchild")?;
|
||||
|
||||
let state_db =
|
||||
StateRuntime::init(codex_home.path().to_path_buf(), "mock_provider".into()).await?;
|
||||
let parent_thread_id = ThreadId::from_string(&parent_id)?;
|
||||
let child_thread_id = ThreadId::from_string(&child_id)?;
|
||||
let grandchild_thread_id = ThreadId::from_string(&grandchild_id)?;
|
||||
|
||||
for (parent, child, status) in [
|
||||
(
|
||||
parent_thread_id,
|
||||
child_thread_id,
|
||||
DirectionalThreadSpawnEdgeStatus::Closed,
|
||||
),
|
||||
(
|
||||
child_thread_id,
|
||||
grandchild_thread_id,
|
||||
DirectionalThreadSpawnEdgeStatus::Open,
|
||||
),
|
||||
] {
|
||||
state_db
|
||||
.upsert_thread_spawn_edge(parent, child, status)
|
||||
.await?;
|
||||
}
|
||||
|
||||
let mut mcp = TestAppServer::new(codex_home.path()).await?;
|
||||
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
|
||||
|
||||
let delete_id = mcp
|
||||
.send_thread_delete_request(ThreadDeleteParams {
|
||||
thread_id: parent_id.clone(),
|
||||
})
|
||||
.await?;
|
||||
let delete_resp: JSONRPCResponse = timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
mcp.read_stream_until_response_message(RequestId::Integer(delete_id)),
|
||||
)
|
||||
.await??;
|
||||
let _: ThreadDeleteResponse = to_response::<ThreadDeleteResponse>(delete_resp)?;
|
||||
|
||||
let mut deleted_ids = Vec::new();
|
||||
for _ in 0..3 {
|
||||
let notification = timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
mcp.read_stream_until_notification_message("thread/deleted"),
|
||||
)
|
||||
.await??;
|
||||
let deleted_notification: ThreadDeletedNotification = serde_json::from_value(
|
||||
notification
|
||||
.params
|
||||
.expect("thread/deleted notification params"),
|
||||
)?;
|
||||
deleted_ids.push(deleted_notification.thread_id);
|
||||
}
|
||||
assert_eq!(deleted_ids, vec![grandchild_id, child_id, parent_id]);
|
||||
|
||||
for thread_id in [parent_thread_id, child_thread_id, grandchild_thread_id] {
|
||||
let rollout_path = find_thread_path_by_id_str(
|
||||
codex_home.path(),
|
||||
&thread_id.to_string(),
|
||||
/*state_db_ctx*/ None,
|
||||
)
|
||||
.await?;
|
||||
assert!(
|
||||
rollout_path.is_none(),
|
||||
"expected active rollout for {thread_id} to be deleted"
|
||||
);
|
||||
}
|
||||
assert_eq!(
|
||||
state_db
|
||||
.list_thread_spawn_descendants(parent_thread_id)
|
||||
.await?,
|
||||
Vec::<ThreadId>::new()
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn create_delete_test_rollout(codex_home: &Path, minute: u8, preview: &str) -> Result<String> {
|
||||
create_fake_rollout(
|
||||
codex_home,
|
||||
&format!("2025-01-01T00-{minute:02}-00"),
|
||||
&format!("2025-01-01T00:{minute:02}:00Z"),
|
||||
preview,
|
||||
Some("mock_provider"),
|
||||
/*git_info*/ None,
|
||||
)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn thread_delete_handles_live_threads_before_rollout_exists() -> Result<()> {
|
||||
let codex_home = TempDir::new()?;
|
||||
|
||||
let mut mcp = TestAppServer::new(codex_home.path()).await?;
|
||||
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
|
||||
|
||||
let start_id = mcp
|
||||
.send_thread_start_request(ThreadStartParams::default())
|
||||
.await?;
|
||||
let start_resp: JSONRPCResponse = timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
mcp.read_stream_until_response_message(RequestId::Integer(start_id)),
|
||||
)
|
||||
.await??;
|
||||
let persisted_thread = to_response::<ThreadStartResponse>(start_resp)?.thread;
|
||||
let rollout_path = find_thread_path_by_id_str(
|
||||
codex_home.path(),
|
||||
&persisted_thread.id,
|
||||
/*state_db_ctx*/ None,
|
||||
)
|
||||
.await?;
|
||||
assert_eq!(rollout_path, None);
|
||||
|
||||
let delete_id = mcp
|
||||
.send_thread_delete_request(ThreadDeleteParams {
|
||||
thread_id: persisted_thread.id,
|
||||
})
|
||||
.await?;
|
||||
let delete_resp: JSONRPCResponse = timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
mcp.read_stream_until_response_message(RequestId::Integer(delete_id)),
|
||||
)
|
||||
.await??;
|
||||
let _: ThreadDeleteResponse = to_response::<ThreadDeleteResponse>(delete_resp)?;
|
||||
|
||||
let start_id = mcp
|
||||
.send_thread_start_request(ThreadStartParams {
|
||||
ephemeral: Some(true),
|
||||
..Default::default()
|
||||
})
|
||||
.await?;
|
||||
let start_resp: JSONRPCResponse = timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
mcp.read_stream_until_response_message(RequestId::Integer(start_id)),
|
||||
)
|
||||
.await??;
|
||||
let ThreadStartResponse { thread, .. } = to_response::<ThreadStartResponse>(start_resp)?;
|
||||
|
||||
let delete_id = mcp
|
||||
.send_thread_delete_request(ThreadDeleteParams {
|
||||
thread_id: thread.id.clone(),
|
||||
})
|
||||
.await?;
|
||||
let delete_err: JSONRPCError = timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
mcp.read_stream_until_error_message(RequestId::Integer(delete_id)),
|
||||
)
|
||||
.await??;
|
||||
let expected_message = format!(
|
||||
"thread is not persisted and cannot be deleted: {}",
|
||||
thread.id
|
||||
);
|
||||
assert_eq!(delete_err.error.message, expected_message);
|
||||
|
||||
let list_id = mcp
|
||||
.send_thread_loaded_list_request(ThreadLoadedListParams::default())
|
||||
.await?;
|
||||
let list_resp: JSONRPCResponse = timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
mcp.read_stream_until_response_message(RequestId::Integer(list_id)),
|
||||
)
|
||||
.await??;
|
||||
let ThreadLoadedListResponse { mut data, .. } =
|
||||
to_response::<ThreadLoadedListResponse>(list_resp)?;
|
||||
data.sort();
|
||||
assert_eq!(data, vec![thread.id]);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -103,6 +103,7 @@ use super::analytics::assert_basic_thread_initialized_event;
|
||||
use super::analytics::mount_analytics_capture;
|
||||
use super::analytics::thread_initialized_event;
|
||||
use super::analytics::wait_for_analytics_payload;
|
||||
use super::analytics::wait_for_goal_event;
|
||||
|
||||
#[cfg(windows)]
|
||||
const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(25);
|
||||
@@ -1316,19 +1317,30 @@ async fn thread_goal_set_edits_objective_without_resetting_usage() -> Result<()>
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn thread_goal_clear_deletes_goal_and_notifies() -> Result<()> {
|
||||
let server = create_mock_responses_server_repeating_assistant("Done").await;
|
||||
async fn thread_goal_lifecycle_emits_analytics_and_clear_deletes_goal() -> Result<()> {
|
||||
let server = create_mock_responses_server_sequence_unchecked(vec![
|
||||
responses::sse(vec![
|
||||
responses::ev_response_created("materialize-thread"),
|
||||
responses::ev_completed("materialize-thread"),
|
||||
]),
|
||||
responses::sse(vec![
|
||||
responses::ev_response_created("goal-continuation"),
|
||||
responses::ev_completed_with_tokens("goal-continuation", /*total_tokens*/ 200),
|
||||
]),
|
||||
])
|
||||
.await;
|
||||
let codex_home = TempDir::new()?;
|
||||
create_config_toml(codex_home.path(), &server.uri())?;
|
||||
create_config_toml_with_chatgpt_base_url(codex_home.path(), &server.uri(), &server.uri())?;
|
||||
let config_path = codex_home.path().join("config.toml");
|
||||
let config = std::fs::read_to_string(&config_path)?;
|
||||
std::fs::write(
|
||||
&config_path,
|
||||
config.replace("personality = true\n", "personality = true\ngoals = true\n"),
|
||||
)?;
|
||||
mount_analytics_capture(&server, codex_home.path()).await?;
|
||||
|
||||
let mut mcp = TestAppServer::new_without_managed_config(codex_home.path()).await?;
|
||||
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
|
||||
timeout(DEFAULT_READ_TIMEOUT.saturating_mul(2), mcp.initialize()).await??;
|
||||
|
||||
let start_id = mcp
|
||||
.send_thread_start_request(ThreadStartParams {
|
||||
@@ -1370,7 +1382,8 @@ async fn thread_goal_clear_deletes_goal_and_notifies() -> Result<()> {
|
||||
"thread/goal/set",
|
||||
Some(json!({
|
||||
"threadId": thread.id,
|
||||
"objective": "keep polishing",
|
||||
"objective": "do not serialize this objective",
|
||||
"tokenBudget": 100,
|
||||
})),
|
||||
)
|
||||
.await?;
|
||||
@@ -1386,6 +1399,55 @@ async fn thread_goal_clear_deletes_goal_and_notifies() -> Result<()> {
|
||||
)
|
||||
.await??;
|
||||
|
||||
let created = wait_for_goal_event(&server, DEFAULT_READ_TIMEOUT, "created", "active").await?;
|
||||
let persisted_goal_id = created["event_params"]["goal_id"]
|
||||
.as_str()
|
||||
.expect("created goal id");
|
||||
assert_eq!(created["event_params"]["thread_id"], thread.id);
|
||||
assert_eq!(created["event_params"]["turn_id"], serde_json::Value::Null);
|
||||
assert_eq!(created["event_params"]["has_token_budget"], true);
|
||||
assert!(created["event_params"]["session_id"].is_string());
|
||||
assert!(created["event_params"]["app_server_client"].is_object());
|
||||
assert!(created["event_params"]["runtime"].is_object());
|
||||
assert!(created["event_params"].get("objective").is_none());
|
||||
assert!(created["event_params"].get("token_budget").is_none());
|
||||
|
||||
let usage = wait_for_goal_event(
|
||||
&server,
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
"usage_accounted",
|
||||
"budget_limited",
|
||||
)
|
||||
.await?;
|
||||
let causal_turn_id = usage["event_params"]["turn_id"]
|
||||
.as_str()
|
||||
.expect("accounted usage turn id");
|
||||
assert_eq!(usage["event_params"]["goal_id"], persisted_goal_id);
|
||||
assert_eq!(usage["event_params"]["cumulative_tokens_accounted"], 200);
|
||||
assert!(
|
||||
usage["event_params"]["cumulative_time_accounted_seconds"]
|
||||
.as_i64()
|
||||
.is_some()
|
||||
);
|
||||
|
||||
let status = wait_for_goal_event(
|
||||
&server,
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
"status_changed",
|
||||
"budget_limited",
|
||||
)
|
||||
.await?;
|
||||
assert_eq!(status["event_params"]["goal_id"], persisted_goal_id);
|
||||
assert_eq!(status["event_params"]["turn_id"], causal_turn_id);
|
||||
assert_eq!(
|
||||
status["event_params"]["cumulative_tokens_accounted"],
|
||||
serde_json::Value::Null
|
||||
);
|
||||
assert_eq!(
|
||||
status["event_params"]["cumulative_time_accounted_seconds"],
|
||||
serde_json::Value::Null
|
||||
);
|
||||
|
||||
let clear_id = mcp
|
||||
.send_raw_request(
|
||||
"thread/goal/clear",
|
||||
@@ -1408,6 +1470,11 @@ async fn thread_goal_clear_deletes_goal_and_notifies() -> Result<()> {
|
||||
)
|
||||
.await??;
|
||||
|
||||
let cleared =
|
||||
wait_for_goal_event(&server, DEFAULT_READ_TIMEOUT, "cleared", "budget_limited").await?;
|
||||
assert_eq!(cleared["event_params"]["goal_id"], persisted_goal_id);
|
||||
assert_eq!(cleared["event_params"]["turn_id"], serde_json::Value::Null);
|
||||
|
||||
let get_id = mcp
|
||||
.send_raw_request(
|
||||
"thread/goal/get",
|
||||
|
||||
@@ -43,7 +43,12 @@ use codex_app_server_protocol::ServerRequest;
|
||||
use codex_app_server_protocol::ServerRequestResolvedNotification;
|
||||
use codex_app_server_protocol::SubAgentActivityKind;
|
||||
use codex_app_server_protocol::TextElement;
|
||||
use codex_app_server_protocol::ThreadDeleteParams;
|
||||
use codex_app_server_protocol::ThreadDeleteResponse;
|
||||
use codex_app_server_protocol::ThreadDeletedNotification;
|
||||
use codex_app_server_protocol::ThreadItem;
|
||||
use codex_app_server_protocol::ThreadLoadedListParams;
|
||||
use codex_app_server_protocol::ThreadLoadedListResponse;
|
||||
use codex_app_server_protocol::ThreadSource;
|
||||
use codex_app_server_protocol::ThreadStartParams;
|
||||
use codex_app_server_protocol::ThreadStartResponse;
|
||||
@@ -591,7 +596,15 @@ async fn turn_start_emits_thread_scoped_warning_notification_for_trimmed_skills(
|
||||
write_test_skill(codex_home.path(), "alpha-skill")?;
|
||||
write_test_skill(codex_home.path(), "beta-skill")?;
|
||||
|
||||
let mut mcp = TestAppServer::new(codex_home.path()).await?;
|
||||
let isolated_home = codex_home.path().to_string_lossy();
|
||||
let mut mcp = TestAppServer::new_with_env(
|
||||
codex_home.path(),
|
||||
&[
|
||||
("HOME", Some(isolated_home.as_ref())),
|
||||
("USERPROFILE", Some(isolated_home.as_ref())),
|
||||
],
|
||||
)
|
||||
.await?;
|
||||
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
|
||||
|
||||
let thread_req = mcp
|
||||
@@ -3362,6 +3375,49 @@ async fn turn_start_emits_spawn_agent_item_with_model_metadata_v2() -> Result<()
|
||||
assert_eq!(turn_completed.thread_id, thread.id);
|
||||
assert_eq!(turn_completed.turn.id, turn.turn.id);
|
||||
|
||||
// Reuse this live spawn setup to cover thread/delete's ThreadManager descendant path.
|
||||
let delete_req = mcp
|
||||
.send_thread_delete_request(ThreadDeleteParams {
|
||||
thread_id: thread.id.clone(),
|
||||
})
|
||||
.await?;
|
||||
let delete_resp: JSONRPCResponse = timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
mcp.read_stream_until_response_message(RequestId::Integer(delete_req)),
|
||||
)
|
||||
.await??;
|
||||
let _: ThreadDeleteResponse = to_response::<ThreadDeleteResponse>(delete_resp)?;
|
||||
|
||||
let mut deleted_thread_ids = Vec::new();
|
||||
for _ in 0..2 {
|
||||
let deleted_notif = timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
mcp.read_stream_until_notification_message("thread/deleted"),
|
||||
)
|
||||
.await??;
|
||||
let deleted: ThreadDeletedNotification = serde_json::from_value(
|
||||
deleted_notif
|
||||
.params
|
||||
.expect("thread/deleted notification params"),
|
||||
)?;
|
||||
deleted_thread_ids.push(deleted.thread_id);
|
||||
}
|
||||
assert_eq!(
|
||||
deleted_thread_ids,
|
||||
vec![receiver_thread_id, thread.id.clone()]
|
||||
);
|
||||
|
||||
let list_req = mcp
|
||||
.send_thread_loaded_list_request(ThreadLoadedListParams::default())
|
||||
.await?;
|
||||
let list_resp: JSONRPCResponse = timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
mcp.read_stream_until_response_message(RequestId::Integer(list_req)),
|
||||
)
|
||||
.await??;
|
||||
let ThreadLoadedListResponse { data, .. } = to_response::<ThreadLoadedListResponse>(list_resp)?;
|
||||
assert_eq!(data, Vec::<String>::new());
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
load("//:defs.bzl", "MACOS_WEBRTC_RUSTC_LINK_FLAGS", "codex_rust_crate", "multiplatform_binaries")
|
||||
load("//:defs.bzl", "MACOS_WEBRTC_RUSTC_LINK_FLAGS", "codex_rust_crate")
|
||||
load("//bazel/platforms:release_binaries.bzl", "multiplatform_binaries")
|
||||
|
||||
codex_rust_crate(
|
||||
name = "cli",
|
||||
|
||||
@@ -1324,6 +1324,7 @@ fn stored_auth_mode(auth: &codex_login::AuthDotJson) -> &'static str {
|
||||
codex_app_server_protocol::AuthMode::ChatgptAuthTokens => "chatgpt_auth_tokens",
|
||||
codex_app_server_protocol::AuthMode::AgentIdentity => "agent_identity",
|
||||
codex_app_server_protocol::AuthMode::PersonalAccessToken => "personal_access_token",
|
||||
codex_app_server_protocol::AuthMode::BedrockApiKey => "bedrock_api_key",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1331,10 +1332,12 @@ fn stored_auth_mode_value(auth: &AuthDotJson) -> codex_app_server_protocol::Auth
|
||||
if let Some(mode) = auth.auth_mode {
|
||||
return mode;
|
||||
}
|
||||
if auth.openai_api_key.is_some() {
|
||||
codex_app_server_protocol::AuthMode::ApiKey
|
||||
} else if auth.personal_access_token.is_some() {
|
||||
if auth.personal_access_token.is_some() {
|
||||
codex_app_server_protocol::AuthMode::PersonalAccessToken
|
||||
} else if auth.bedrock_api_key.is_some() {
|
||||
codex_app_server_protocol::AuthMode::BedrockApiKey
|
||||
} else if auth.openai_api_key.is_some() {
|
||||
codex_app_server_protocol::AuthMode::ApiKey
|
||||
} else {
|
||||
codex_app_server_protocol::AuthMode::Chatgpt
|
||||
}
|
||||
@@ -1407,6 +1410,11 @@ fn stored_auth_issues(
|
||||
issues.push("personal access token auth is missing a personal access token");
|
||||
}
|
||||
}
|
||||
codex_app_server_protocol::AuthMode::BedrockApiKey => {
|
||||
if auth.bedrock_api_key.is_none() {
|
||||
issues.push("Bedrock API key auth is missing a Bedrock API key");
|
||||
}
|
||||
}
|
||||
}
|
||||
issues
|
||||
}
|
||||
@@ -2141,8 +2149,9 @@ async fn state_check(config: &Config) -> DoctorCheck {
|
||||
};
|
||||
let mut check = DoctorCheck::new("state.paths", "state", status, summary).details(details);
|
||||
if status == CheckStatus::Fail {
|
||||
check = check
|
||||
.remediation("Back up CODEX_HOME, then remove or repair the affected SQLite database.");
|
||||
check = check.remediation(
|
||||
"Move the damaged SQLite database aside, then restart the interactive CLI or app server so it can rebuild that runtime database from saved data. Other entry points may not rebuild automatically.",
|
||||
);
|
||||
}
|
||||
check
|
||||
}
|
||||
@@ -2436,6 +2445,7 @@ fn auth_mode_name(auth: &CodexAuth) -> &'static str {
|
||||
codex_app_server_protocol::AuthMode::ChatgptAuthTokens => "chatgpt_auth_tokens",
|
||||
codex_app_server_protocol::AuthMode::AgentIdentity => "agent_identity",
|
||||
codex_app_server_protocol::AuthMode::PersonalAccessToken => "personal_access_token",
|
||||
codex_app_server_protocol::AuthMode::BedrockApiKey => "bedrock_api_key",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2565,7 +2575,10 @@ fn provider_auth_reachability_mode_from_auth(
|
||||
return ProviderAuthReachabilityMode::Chatgpt;
|
||||
}
|
||||
match stored_auth.map(stored_auth_mode_value) {
|
||||
Some(codex_app_server_protocol::AuthMode::ApiKey) => ProviderAuthReachabilityMode::ApiKey,
|
||||
Some(
|
||||
codex_app_server_protocol::AuthMode::ApiKey
|
||||
| codex_app_server_protocol::AuthMode::BedrockApiKey,
|
||||
) => ProviderAuthReachabilityMode::ApiKey,
|
||||
Some(
|
||||
codex_app_server_protocol::AuthMode::Chatgpt
|
||||
| codex_app_server_protocol::AuthMode::ChatgptAuthTokens
|
||||
@@ -3470,6 +3483,7 @@ mod tests {
|
||||
last_refresh: None,
|
||||
agent_identity: None,
|
||||
personal_access_token: None,
|
||||
bedrock_api_key: None,
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
@@ -3488,6 +3502,7 @@ mod tests {
|
||||
last_refresh: None,
|
||||
agent_identity: None,
|
||||
personal_access_token: None,
|
||||
bedrock_api_key: None,
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
@@ -3508,6 +3523,7 @@ mod tests {
|
||||
last_refresh: None,
|
||||
agent_identity: None,
|
||||
personal_access_token: Some("at-test".to_string()),
|
||||
bedrock_api_key: None,
|
||||
};
|
||||
|
||||
assert_eq!(stored_auth_mode(&auth), "personal_access_token");
|
||||
@@ -3530,6 +3546,7 @@ mod tests {
|
||||
last_refresh: None,
|
||||
agent_identity: None,
|
||||
personal_access_token: None,
|
||||
bedrock_api_key: None,
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
|
||||
@@ -395,6 +395,10 @@ pub async fn run_login_status(cli_config_overrides: CliConfigOverrides) -> ! {
|
||||
eprintln!("Logged in using personal access token");
|
||||
std::process::exit(0);
|
||||
}
|
||||
AuthMode::BedrockApiKey => {
|
||||
eprintln!("Logged in using Amazon Bedrock API key");
|
||||
std::process::exit(0);
|
||||
}
|
||||
},
|
||||
Ok(None) => {
|
||||
eprintln!("Not logged in");
|
||||
|
||||
@@ -38,6 +38,7 @@ use codex_utils_cli::ProfileV2Name;
|
||||
use codex_utils_cli::SharedCliOptions;
|
||||
use codex_utils_cli::resume_hint;
|
||||
use owo_colors::OwoColorize;
|
||||
use std::collections::HashSet;
|
||||
use std::io::IsTerminal;
|
||||
use std::path::PathBuf;
|
||||
use supports_color::Stream;
|
||||
@@ -179,6 +180,9 @@ enum Subcommand {
|
||||
/// Archive a saved session by id or session name.
|
||||
Archive(SessionArchiveCommand),
|
||||
|
||||
/// Permanently delete a saved session by id or session name.
|
||||
Delete(DeleteCommand),
|
||||
|
||||
/// Unarchive a saved session by id or session name.
|
||||
Unarchive(SessionArchiveCommand),
|
||||
|
||||
@@ -353,6 +357,16 @@ struct SessionArchiveConfigOverrides {
|
||||
config_overrides: CliConfigOverrides,
|
||||
}
|
||||
|
||||
#[derive(Debug, Args)]
|
||||
struct DeleteCommand {
|
||||
#[clap(flatten)]
|
||||
session: SessionArchiveCommand,
|
||||
|
||||
/// Delete without prompting. SESSION must be a UUID.
|
||||
#[arg(long, default_value_t = false)]
|
||||
force: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Parser)]
|
||||
struct ForkCommand {
|
||||
/// Conversation/session id (UUID). When provided, forks this session.
|
||||
@@ -828,6 +842,17 @@ async fn run_session_archive_cli_command(
|
||||
.map_err(|err| anyhow::anyhow!("{err}"))
|
||||
}
|
||||
|
||||
fn delete_action(target: &str, force: bool) -> anyhow::Result<codex_tui::SessionArchiveAction> {
|
||||
if force && codex_protocol::ThreadId::from_string(target).is_err() {
|
||||
anyhow::bail!("--force requires a session UUID; names must be confirmed interactively");
|
||||
}
|
||||
let confirmation = match force {
|
||||
true => codex_tui::DeleteConfirmation::Skip,
|
||||
false => codex_tui::DeleteConfirmation::Prompt,
|
||||
};
|
||||
Ok(codex_tui::SessionArchiveAction::Delete(confirmation))
|
||||
}
|
||||
|
||||
async fn run_debug_app_server_command(cmd: DebugAppServerCommand) -> anyhow::Result<()> {
|
||||
match cmd.subcommand {
|
||||
DebugAppServerSubcommand::SendMessageV2(cmd) => {
|
||||
@@ -1232,6 +1257,20 @@ async fn cli_main(arg0_paths: Arg0DispatchPaths) -> anyhow::Result<()> {
|
||||
.await?;
|
||||
println!("{output}");
|
||||
}
|
||||
Some(Subcommand::Delete(DeleteCommand { session, force })) => {
|
||||
let action = delete_action(&session.target, force)?;
|
||||
let output = run_session_archive_cli_command(
|
||||
action,
|
||||
session,
|
||||
interactive,
|
||||
root_config_overrides.clone(),
|
||||
root_remote.clone(),
|
||||
root_remote_auth_token_env.clone(),
|
||||
arg0_paths.clone(),
|
||||
)
|
||||
.await?;
|
||||
println!("{output}");
|
||||
}
|
||||
Some(Subcommand::Unarchive(cmd)) => {
|
||||
let output = run_session_archive_cli_command(
|
||||
codex_tui::SessionArchiveAction::Unarchive,
|
||||
@@ -1596,6 +1635,7 @@ fn profile_v2_for_subcommand<'a>(
|
||||
| Subcommand::Review(_)
|
||||
| Subcommand::Resume(_)
|
||||
| Subcommand::Archive(_)
|
||||
| Subcommand::Delete(_)
|
||||
| Subcommand::Unarchive(_)
|
||||
| Subcommand::Fork(_)
|
||||
| Subcommand::Mcp(_)
|
||||
@@ -1604,7 +1644,7 @@ fn profile_v2_for_subcommand<'a>(
|
||||
subcommand: DebugSubcommand::PromptInput(_),
|
||||
}) => Ok(Some(profile_v2)),
|
||||
_ => anyhow::bail!(
|
||||
"--profile only applies to runtime commands and `codex mcp`: `codex`, `codex exec`, `codex review`, `codex resume`, `codex archive`, `codex unarchive`, `codex fork`, `codex mcp`, `codex sandbox`, and `codex debug prompt-input`."
|
||||
"--profile only applies to runtime commands and `codex mcp`: `codex`, `codex exec`, `codex review`, `codex resume`, `codex archive`, `codex delete`, `codex unarchive`, `codex fork`, `codex mcp`, `codex sandbox`, and `codex debug prompt-input`."
|
||||
),
|
||||
}
|
||||
}
|
||||
@@ -2025,6 +2065,7 @@ fn unsupported_subcommand_name_for_strict_config(
|
||||
| Some(Subcommand::ExecServer(_))
|
||||
| Some(Subcommand::Resume(_))
|
||||
| Some(Subcommand::Archive(_))
|
||||
| Some(Subcommand::Delete(_))
|
||||
| Some(Subcommand::Unarchive(_))
|
||||
| Some(Subcommand::Fork(_))
|
||||
| Some(Subcommand::Doctor(_)) => None,
|
||||
@@ -2188,7 +2229,7 @@ async fn run_interactive_tui(
|
||||
remote_endpoint.clone(),
|
||||
)
|
||||
};
|
||||
let mut attempted_repair = false;
|
||||
let mut attempted_backups = HashSet::new();
|
||||
loop {
|
||||
let err = match start_tui().await {
|
||||
Ok(exit_info) => return Ok(exit_info),
|
||||
@@ -2201,25 +2242,25 @@ async fn run_interactive_tui(
|
||||
local_state_db::print_locked_guidance(startup_error);
|
||||
return Ok(AppExitInfo::fatal(startup_error.to_string()));
|
||||
}
|
||||
if attempted_repair {
|
||||
if !local_state_db::is_auto_backup_recoverable(startup_error) {
|
||||
local_state_db::print_diagnostic_guidance(startup_error);
|
||||
return Ok(AppExitInfo::fatal(startup_error.to_string()));
|
||||
}
|
||||
if !local_state_db::confirm_repair(startup_error)? {
|
||||
if !attempted_backups.insert(startup_error.database_path().to_path_buf()) {
|
||||
local_state_db::print_diagnostic_guidance(startup_error);
|
||||
return Ok(AppExitInfo::fatal(startup_error.to_string()));
|
||||
}
|
||||
|
||||
match local_state_db::repair_files(startup_error).await {
|
||||
Ok(backups) => local_state_db::print_repair_backups(&backups),
|
||||
Err(repair_err) => {
|
||||
local_state_db::print_auto_backup_start(startup_error);
|
||||
match local_state_db::backup_files_for_fresh_start(startup_error).await {
|
||||
Ok(backups) => local_state_db::confirm_fresh_start_rebuild(startup_error, &backups)?,
|
||||
Err(backup_err) => {
|
||||
local_state_db::print_diagnostic_guidance(startup_error);
|
||||
return Ok(AppExitInfo::fatal(format!(
|
||||
"failed to repair Codex local data automatically: {repair_err}"
|
||||
"failed to move damaged Codex local database files into a backup folder automatically: {backup_err}"
|
||||
)));
|
||||
}
|
||||
}
|
||||
attempted_repair = true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2599,6 +2640,14 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn import_remains_an_interactive_prompt() {
|
||||
let cli = MultitoolCli::try_parse_from(["codex", "import"]).expect("parse");
|
||||
|
||||
assert!(cli.subcommand.is_none());
|
||||
assert_eq!(cli.interactive.prompt.as_deref(), Some("import"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn profile_v2_rejects_non_plain_names_at_parse_time() {
|
||||
assert!(
|
||||
@@ -2859,6 +2908,17 @@ mod tests {
|
||||
assert!(interactive.bypass_hook_trust);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delete_force_requires_uuid() {
|
||||
assert!(delete_action("123e4567-e89b-12d3-a456-426614174000", true).is_ok());
|
||||
|
||||
let err = delete_action("my-thread", true).expect_err("name should require prompt");
|
||||
assert_eq!(
|
||||
err.to_string(),
|
||||
"--force requires a session UUID; names must be confirmed interactively"
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(any(target_os = "macos", target_os = "linux", target_os = "windows"))]
|
||||
#[test]
|
||||
fn sandbox_parses_permissions_profile() {
|
||||
|
||||
@@ -204,7 +204,7 @@ pub async fn run_plugin_list(
|
||||
..
|
||||
} = load_plugin_command_context(overrides).await?;
|
||||
let outcome = manager
|
||||
.list_marketplaces_for_config(&plugins_input, &[])
|
||||
.list_marketplaces_for_config(&plugins_input, &[], /*include_openai_curated*/ true)
|
||||
.context("failed to list marketplace plugins")?;
|
||||
ensure_configured_marketplace_snapshots_loaded(
|
||||
codex_home.as_path(),
|
||||
@@ -609,7 +609,7 @@ fn find_marketplace_for_plugin(
|
||||
plugin_name: &str,
|
||||
) -> Result<ConfiguredMarketplace> {
|
||||
let outcome = manager
|
||||
.list_marketplaces_for_config(plugins_input, &[])
|
||||
.list_marketplaces_for_config(plugins_input, &[], /*include_openai_curated*/ true)
|
||||
.context("failed to list marketplace plugins")?;
|
||||
ensure_configured_marketplace_snapshots_loaded(
|
||||
codex_home,
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
//! CLI recovery for local state database startup failures.
|
||||
//! CLI handling for local state database startup failures.
|
||||
//!
|
||||
//! This keeps user-facing repair and lock-contention handling out of the main
|
||||
//! This keeps user-facing backup and lock-contention handling out of the main
|
||||
//! CLI dispatch path while preserving the TUI startup error as the boundary type.
|
||||
|
||||
use codex_state::RuntimeDbBackup;
|
||||
use codex_tui::LocalStateDbStartupError;
|
||||
use std::path::PathBuf;
|
||||
use std::io::IsTerminal;
|
||||
use std::path::Path;
|
||||
|
||||
pub(crate) fn startup_error(err: &std::io::Error) -> Option<&LocalStateDbStartupError> {
|
||||
err.get_ref()
|
||||
@@ -12,66 +14,60 @@ pub(crate) fn startup_error(err: &std::io::Error) -> Option<&LocalStateDbStartup
|
||||
}
|
||||
|
||||
pub(crate) fn is_locked(detail: &str) -> bool {
|
||||
let detail = detail.to_ascii_lowercase();
|
||||
detail.contains("database is locked") || detail.contains("database is busy")
|
||||
codex_state::sqlite_error_detail_is_lock(detail)
|
||||
}
|
||||
|
||||
pub(crate) fn confirm_repair(startup_error: &LocalStateDbStartupError) -> std::io::Result<bool> {
|
||||
pub(crate) fn is_corruption(detail: &str) -> bool {
|
||||
codex_state::sqlite_error_detail_is_corruption(detail)
|
||||
}
|
||||
|
||||
pub(crate) fn is_auto_backup_recoverable(startup_error: &LocalStateDbStartupError) -> bool {
|
||||
is_corruption(startup_error.detail()) || sqlite_home_is_blocking_file(startup_error)
|
||||
}
|
||||
|
||||
fn sqlite_home_is_blocking_file(startup_error: &LocalStateDbStartupError) -> bool {
|
||||
startup_error
|
||||
.database_path()
|
||||
.parent()
|
||||
.and_then(|path| std::fs::metadata(path).ok())
|
||||
.is_some_and(|metadata| metadata.is_file())
|
||||
}
|
||||
|
||||
pub(crate) fn print_auto_backup_start(startup_error: &LocalStateDbStartupError) {
|
||||
eprintln!("Codex couldn't start because its local database appears to be damaged.");
|
||||
eprintln!("Codex can try a safe repair by backing up those files and rebuilding them.");
|
||||
eprintln!("Moving the damaged local database aside so Codex can rebuild it from saved data.");
|
||||
print_technical_details(startup_error);
|
||||
crate::confirm("Repair Codex local data now? [y/N]: ")
|
||||
}
|
||||
|
||||
pub(crate) async fn repair_files(
|
||||
pub(crate) async fn backup_files_for_fresh_start(
|
||||
startup_error: &LocalStateDbStartupError,
|
||||
) -> std::io::Result<Vec<PathBuf>> {
|
||||
let state_db_path = startup_error.state_db_path();
|
||||
let sqlite_home = state_db_path.parent().ok_or_else(|| {
|
||||
std::io::Error::other("state database path does not have a parent directory")
|
||||
})?;
|
||||
let timestamp = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map_or(0, |duration| duration.as_secs());
|
||||
let repair_suffix = format!("codex-repair-{timestamp}");
|
||||
let mut backups = Vec::new();
|
||||
|
||||
match tokio::fs::metadata(sqlite_home).await {
|
||||
Ok(metadata) if metadata.is_dir() => {}
|
||||
Ok(_) => {
|
||||
backups.push(backup_path(sqlite_home, &repair_suffix).await?);
|
||||
tokio::fs::create_dir_all(sqlite_home).await?;
|
||||
}
|
||||
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
|
||||
tokio::fs::create_dir_all(sqlite_home).await?;
|
||||
}
|
||||
Err(err) => return Err(err),
|
||||
}
|
||||
|
||||
for path in codex_state::runtime_db_paths(sqlite_home)
|
||||
.into_iter()
|
||||
.flat_map(|db| sqlite_paths(db.path.as_path()))
|
||||
{
|
||||
if tokio::fs::try_exists(path.as_path()).await? {
|
||||
backups.push(backup_path(path.as_path(), &repair_suffix).await?);
|
||||
}
|
||||
}
|
||||
|
||||
if backups.is_empty() {
|
||||
return Err(std::io::Error::other(
|
||||
"no repairable Codex local data files were found",
|
||||
));
|
||||
}
|
||||
|
||||
Ok(backups)
|
||||
) -> std::io::Result<Vec<RuntimeDbBackup>> {
|
||||
codex_state::backup_runtime_db_for_fresh_start(startup_error.database_path()).await
|
||||
}
|
||||
|
||||
pub(crate) fn print_repair_backups(backups: &[PathBuf]) {
|
||||
eprintln!("Backed up Codex local data before repair:");
|
||||
for backup in backups {
|
||||
eprintln!(" {}", backup.display());
|
||||
pub(crate) fn confirm_fresh_start_rebuild(
|
||||
startup_error: &LocalStateDbStartupError,
|
||||
backups: &[RuntimeDbBackup],
|
||||
) -> std::io::Result<()> {
|
||||
eprintln!("Codex rebuilt its local database.");
|
||||
eprintln!(
|
||||
"Codex detected a damaged local database, moved it into a backup folder, and will continue startup with a fresh database."
|
||||
);
|
||||
eprintln!("Database path: {}", startup_error.database_path().display());
|
||||
if let Some(backup_folder) = backup_folder(backups) {
|
||||
eprintln!("Backup folder: {}", backup_folder.display());
|
||||
} else {
|
||||
eprintln!("Backup folder: unavailable");
|
||||
}
|
||||
eprintln!("Retrying startup with rebuilt local data...");
|
||||
|
||||
if std::io::stdin().is_terminal() && std::io::stderr().is_terminal() {
|
||||
eprintln!("Press Enter to continue.");
|
||||
let mut input = String::new();
|
||||
std::io::stdin().read_line(&mut input)?;
|
||||
} else {
|
||||
eprintln!("Continuing startup with a fresh local database...");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn print_diagnostic_guidance(startup_error: &LocalStateDbStartupError) {
|
||||
@@ -87,79 +83,50 @@ pub(crate) fn print_locked_guidance(startup_error: &LocalStateDbStartupError) {
|
||||
print_technical_details(startup_error);
|
||||
}
|
||||
|
||||
fn sqlite_paths(db_path: &std::path::Path) -> Vec<PathBuf> {
|
||||
let mut wal_path = db_path.as_os_str().to_os_string();
|
||||
wal_path.push("-wal");
|
||||
let mut shm_path = db_path.as_os_str().to_os_string();
|
||||
shm_path.push("-shm");
|
||||
vec![
|
||||
db_path.to_path_buf(),
|
||||
PathBuf::from(wal_path),
|
||||
PathBuf::from(shm_path),
|
||||
]
|
||||
}
|
||||
|
||||
async fn backup_path(path: &std::path::Path, repair_suffix: &str) -> std::io::Result<PathBuf> {
|
||||
let file_name = path.file_name().ok_or_else(|| {
|
||||
std::io::Error::other(format!(
|
||||
"cannot create a repair backup name for {}",
|
||||
path.display()
|
||||
))
|
||||
})?;
|
||||
let mut sequence = 0;
|
||||
loop {
|
||||
let mut backup_name = file_name.to_os_string();
|
||||
backup_name.push(format!(".{repair_suffix}.{sequence}.bak"));
|
||||
let backup_path = path.with_file_name(backup_name);
|
||||
if !tokio::fs::try_exists(backup_path.as_path()).await? {
|
||||
tokio::fs::rename(path, backup_path.as_path()).await?;
|
||||
return Ok(backup_path);
|
||||
}
|
||||
sequence += 1;
|
||||
}
|
||||
}
|
||||
|
||||
fn print_technical_details(startup_error: &LocalStateDbStartupError) {
|
||||
eprintln!("Technical details:");
|
||||
eprintln!(" Location: {}", startup_error.state_db_path().display());
|
||||
eprintln!(" Location: {}", startup_error.database_path().display());
|
||||
eprintln!(" Cause: {}", startup_error.detail());
|
||||
}
|
||||
|
||||
fn backup_folder(backups: &[RuntimeDbBackup]) -> Option<&Path> {
|
||||
backups.first()?.backup_path.parent()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use pretty_assertions::assert_eq;
|
||||
use std::path::PathBuf;
|
||||
use tempfile::TempDir;
|
||||
|
||||
#[tokio::test]
|
||||
async fn repair_backs_up_owned_database_files() -> std::io::Result<()> {
|
||||
async fn backup_backs_up_only_failed_database_file() -> std::io::Result<()> {
|
||||
let temp_dir = TempDir::new()?;
|
||||
let state_path = codex_state::state_db_path(temp_dir.path());
|
||||
let logs_path = codex_state::logs_db_path(temp_dir.path());
|
||||
let goals_path = codex_state::goals_db_path(temp_dir.path());
|
||||
let state_sidecars = sqlite_paths(state_path.as_path());
|
||||
let failed_db_path = codex_state::logs_db_path(temp_dir.path());
|
||||
tokio::fs::write(state_path.as_path(), b"state").await?;
|
||||
tokio::fs::write(state_sidecars[1].as_path(), b"state-wal").await?;
|
||||
tokio::fs::write(logs_path.as_path(), b"logs").await?;
|
||||
tokio::fs::write(goals_path.as_path(), b"goals").await?;
|
||||
tokio::fs::write(failed_db_path.as_path(), b"logs").await?;
|
||||
|
||||
let startup_error =
|
||||
LocalStateDbStartupError::new(state_path.clone(), "corrupt".to_string());
|
||||
let backups = repair_files(&startup_error).await?;
|
||||
LocalStateDbStartupError::new(failed_db_path.clone(), "corrupt".to_string());
|
||||
let backups = backup_files_for_fresh_start(&startup_error).await?;
|
||||
|
||||
assert_eq!(backups.len(), 4);
|
||||
assert!(!tokio::fs::try_exists(state_path.as_path()).await?);
|
||||
assert!(!tokio::fs::try_exists(state_sidecars[1].as_path()).await?);
|
||||
assert!(!tokio::fs::try_exists(logs_path.as_path()).await?);
|
||||
assert!(!tokio::fs::try_exists(goals_path.as_path()).await?);
|
||||
for backup in backups {
|
||||
assert!(tokio::fs::try_exists(backup.as_path()).await?);
|
||||
}
|
||||
assert_eq!(
|
||||
backups
|
||||
.iter()
|
||||
.map(|backup| &backup.original_path)
|
||||
.collect::<Vec<_>>(),
|
||||
vec![&failed_db_path]
|
||||
);
|
||||
assert!(!tokio::fs::try_exists(failed_db_path.as_path()).await?);
|
||||
assert!(tokio::fs::try_exists(state_path.as_path()).await?);
|
||||
assert!(tokio::fs::try_exists(backups[0].backup_path.as_path()).await?);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn repair_replaces_blocking_sqlite_home_file() -> std::io::Result<()> {
|
||||
async fn backup_replaces_blocking_sqlite_home_file() -> std::io::Result<()> {
|
||||
let temp_dir = TempDir::new()?;
|
||||
let sqlite_home = temp_dir.path().join("sqlite-home");
|
||||
tokio::fs::write(sqlite_home.as_path(), b"not-a-directory").await?;
|
||||
@@ -168,18 +135,25 @@ mod tests {
|
||||
"File exists".to_string(),
|
||||
);
|
||||
|
||||
let backups = repair_files(&startup_error).await?;
|
||||
assert!(is_auto_backup_recoverable(&startup_error));
|
||||
let backups = backup_files_for_fresh_start(&startup_error).await?;
|
||||
|
||||
assert_eq!(backups.len(), 1);
|
||||
assert!(tokio::fs::metadata(sqlite_home.as_path()).await?.is_dir());
|
||||
assert!(tokio::fs::try_exists(backups[0].as_path()).await?);
|
||||
assert!(tokio::fs::try_exists(backups[0].backup_path.as_path()).await?);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lock_failures_skip_repair() {
|
||||
assert!(is_locked("database is locked"));
|
||||
assert!(is_locked("database is busy"));
|
||||
assert!(!is_locked("database disk image is malformed"));
|
||||
fn backup_folder_uses_parent_of_first_backup_path() {
|
||||
let backups = vec![RuntimeDbBackup {
|
||||
original_path: PathBuf::from("/tmp/state_5.sqlite"),
|
||||
backup_path: PathBuf::from("/tmp/db-backups/sqlite-1-0/state_5.sqlite"),
|
||||
}];
|
||||
|
||||
assert_eq!(
|
||||
backup_folder(&backups),
|
||||
Some(Path::new("/tmp/db-backups/sqlite-1-0"))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
use predicates::prelude::*;
|
||||
|
||||
#[test]
|
||||
fn missing_session_fails_before_delete_confirmation() -> anyhow::Result<()> {
|
||||
let codex_home = tempfile::tempdir()?;
|
||||
let mut cmd = assert_cmd::Command::new(codex_utils_cargo_bin::cargo_bin("codex")?);
|
||||
cmd.env("CODEX_HOME", codex_home.path())
|
||||
.args(["delete", "123e4567-e89b-12d3-a456-426614174000"]);
|
||||
|
||||
cmd.assert()
|
||||
.failure()
|
||||
.stderr(predicate::str::contains(
|
||||
"No active or archived session found matching",
|
||||
))
|
||||
.stderr(predicate::str::contains("cannot confirm").not());
|
||||
Ok(())
|
||||
}
|
||||
@@ -22,7 +22,7 @@ use tokio::fs;
|
||||
|
||||
const CLOUD_CONFIG_BUNDLE_CACHE_VERSION: u32 = 1;
|
||||
pub(super) const CLOUD_CONFIG_BUNDLE_CACHE_FILENAME: &str = "cloud-config-bundle-cache.json";
|
||||
const CLOUD_CONFIG_BUNDLE_CACHE_TTL: Duration = Duration::from_secs(30 * 60);
|
||||
const CLOUD_CONFIG_BUNDLE_CACHE_TTL: Duration = Duration::from_secs(60 * 60);
|
||||
const CLOUD_CONFIG_BUNDLE_CACHE_WRITE_HMAC_KEY: &[u8] =
|
||||
b"codex-cloud-config-bundle-cache-v1-6160ae70-bcfd-4ca8-a99b-40f73b3b072e";
|
||||
const CLOUD_CONFIG_BUNDLE_CACHE_READ_HMAC_KEYS: &[&[u8]] =
|
||||
|
||||
@@ -81,7 +81,7 @@ async fn save_writes_signed_payload_and_loads_for_matching_identity() {
|
||||
.expect("parse cache");
|
||||
assert!(
|
||||
cache_file.signed_payload.expires_at
|
||||
<= cache_file.signed_payload.cached_at + ChronoDuration::minutes(30)
|
||||
<= cache_file.signed_payload.cached_at + ChronoDuration::minutes(60)
|
||||
);
|
||||
assert!(cache_file.signed_payload.expires_at > cache_file.signed_payload.cached_at);
|
||||
assert_eq!(
|
||||
|
||||
@@ -32,7 +32,7 @@ use tokio::time::timeout;
|
||||
|
||||
pub(crate) const CLOUD_CONFIG_BUNDLE_TIMEOUT: Duration = Duration::from_secs(15);
|
||||
const CLOUD_CONFIG_BUNDLE_MAX_ATTEMPTS: usize = 5;
|
||||
const CLOUD_CONFIG_BUNDLE_CACHE_REFRESH_INTERVAL: Duration = Duration::from_secs(5 * 60);
|
||||
const CLOUD_CONFIG_BUNDLE_CACHE_REFRESH_INTERVAL: Duration = Duration::from_secs(15 * 60);
|
||||
const CLOUD_CONFIG_BUNDLE_LOAD_FAILED_MESSAGE: &str =
|
||||
"Failed to load cloud config bundle (workspace-managed policies).";
|
||||
const CLOUD_CONFIG_BUNDLE_AUTH_RECOVERY_FAILED_MESSAGE: &str = concat!(
|
||||
|
||||
@@ -291,6 +291,18 @@ impl RealtimeWebsocketWriter {
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn send_conversation_handoff_append(
|
||||
&self,
|
||||
handoff_id: String,
|
||||
output_text: String,
|
||||
) -> Result<(), ApiError> {
|
||||
self.send_json(&RealtimeOutboundMessage::ConversationHandoffAppend {
|
||||
handoff_id,
|
||||
output_text,
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn send_conversation_function_call_output(
|
||||
&self,
|
||||
call_id: String,
|
||||
|
||||
@@ -93,6 +93,7 @@ async fn models_client_hits_models_endpoint() {
|
||||
context_window: Some(272_000),
|
||||
max_context_window: None,
|
||||
auto_compact_token_limit: None,
|
||||
comp_hash: None,
|
||||
effective_context_window_percent: 95,
|
||||
experimental_supported_tools: Vec::new(),
|
||||
input_modalities: default_input_modalities(),
|
||||
|
||||
@@ -14,6 +14,7 @@ workspace = true
|
||||
|
||||
[dependencies]
|
||||
anyhow = { workspace = true }
|
||||
arc-swap = { workspace = true }
|
||||
async-channel = { workspace = true }
|
||||
codex-async-utils = { workspace = true }
|
||||
codex-api = { workspace = true }
|
||||
|
||||
@@ -68,6 +68,7 @@ use serde_json::Value as JsonValue;
|
||||
use tokio::task::JoinSet;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
use tracing::Instrument;
|
||||
use tracing::info_span;
|
||||
use tracing::instrument;
|
||||
use tracing::trace;
|
||||
use tracing::trace_span;
|
||||
@@ -105,6 +106,7 @@ pub fn tool_is_model_visible(tool: &ToolInfo) -> bool {
|
||||
pub struct McpConnectionManager {
|
||||
clients: HashMap<String, AsyncManagedClient>,
|
||||
server_metadata: HashMap<String, McpServerMetadata>,
|
||||
required_servers: Vec<String>,
|
||||
tool_plugin_provenance: Arc<ToolPluginProvenance>,
|
||||
host_owned_codex_apps_enabled: bool,
|
||||
prefix_mcp_tool_names: bool,
|
||||
@@ -113,103 +115,7 @@ pub struct McpConnectionManager {
|
||||
}
|
||||
|
||||
impl McpConnectionManager {
|
||||
pub fn new_uninitialized(
|
||||
approval_policy: &Constrained<AskForApproval>,
|
||||
permission_profile: &Constrained<PermissionProfile>,
|
||||
prefix_mcp_tool_names: bool,
|
||||
) -> Self {
|
||||
Self::new_uninitialized_with_permission_profile(
|
||||
approval_policy,
|
||||
permission_profile.get(),
|
||||
prefix_mcp_tool_names,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn new_uninitialized_with_permission_profile(
|
||||
approval_policy: &Constrained<AskForApproval>,
|
||||
permission_profile: &PermissionProfile,
|
||||
prefix_mcp_tool_names: bool,
|
||||
) -> Self {
|
||||
Self {
|
||||
clients: HashMap::new(),
|
||||
server_metadata: HashMap::new(),
|
||||
tool_plugin_provenance: Arc::new(ToolPluginProvenance::default()),
|
||||
host_owned_codex_apps_enabled: false,
|
||||
prefix_mcp_tool_names,
|
||||
elicitation_requests: ElicitationRequestManager::new(
|
||||
approval_policy.value(),
|
||||
permission_profile.clone(),
|
||||
/*reviewer*/ None,
|
||||
),
|
||||
startup_cancellation_token: CancellationToken::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn has_servers(&self) -> bool {
|
||||
!self.clients.is_empty()
|
||||
}
|
||||
|
||||
/// Drain all MCP clients from this manager and return a future that stops
|
||||
/// them and terminates their stdio server processes.
|
||||
pub fn begin_shutdown(&mut self) -> impl std::future::Future<Output = ()> + Send + 'static {
|
||||
self.startup_cancellation_token.cancel();
|
||||
let clients = std::mem::take(&mut self.clients);
|
||||
self.server_metadata.clear();
|
||||
async move {
|
||||
for client in clients.into_values() {
|
||||
client.shutdown().await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Stop all MCP clients owned by this manager and terminate stdio server processes.
|
||||
pub async fn shutdown(&mut self) {
|
||||
self.begin_shutdown().await;
|
||||
}
|
||||
|
||||
pub fn server_origin(&self, server_name: &str) -> Option<&str> {
|
||||
self.server_metadata
|
||||
.get(server_name)
|
||||
.and_then(|metadata| metadata.origin.as_ref())
|
||||
.map(super::server::McpServerOrigin::as_str)
|
||||
}
|
||||
|
||||
pub fn server_pollutes_memory(&self, server_name: &str) -> bool {
|
||||
self.server_metadata
|
||||
.get(server_name)
|
||||
.is_none_or(|metadata| metadata.pollutes_memory)
|
||||
}
|
||||
|
||||
pub fn plugin_id_for_mcp_server_name(&self, server_name: &str) -> Option<&str> {
|
||||
self.tool_plugin_provenance
|
||||
.plugin_id_for_mcp_server_name(server_name)
|
||||
}
|
||||
|
||||
pub fn is_host_owned_codex_apps_server(&self, server_name: &str) -> bool {
|
||||
self.host_owned_codex_apps_enabled && server_name == CODEX_APPS_MCP_SERVER_NAME
|
||||
}
|
||||
|
||||
pub fn set_approval_policy(&self, approval_policy: &Constrained<AskForApproval>) {
|
||||
if let Ok(mut policy) = self.elicitation_requests.approval_policy.lock() {
|
||||
*policy = approval_policy.value();
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_permission_profile(&self, permission_profile: PermissionProfile) {
|
||||
if let Ok(mut profile) = self.elicitation_requests.permission_profile.lock() {
|
||||
*profile = permission_profile;
|
||||
}
|
||||
}
|
||||
|
||||
pub fn elicitations_auto_deny(&self) -> bool {
|
||||
self.elicitation_requests.auto_deny()
|
||||
}
|
||||
|
||||
pub fn set_elicitations_auto_deny(&self, auto_deny: bool) {
|
||||
self.elicitation_requests.set_auto_deny(auto_deny);
|
||||
}
|
||||
|
||||
#[allow(clippy::new_ret_no_self, clippy::too_many_arguments)]
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub async fn new(
|
||||
mcp_servers: &HashMap<String, EffectiveMcpServer>,
|
||||
store_mode: OAuthCredentialsStoreMode,
|
||||
@@ -217,6 +123,7 @@ impl McpConnectionManager {
|
||||
approval_policy: &Constrained<AskForApproval>,
|
||||
submit_id: String,
|
||||
tx_event: Sender<Event>,
|
||||
startup_cancellation_token: CancellationToken,
|
||||
initial_permission_profile: PermissionProfile,
|
||||
runtime_context: McpRuntimeContext,
|
||||
codex_home: PathBuf,
|
||||
@@ -227,8 +134,13 @@ impl McpConnectionManager {
|
||||
tool_plugin_provenance: ToolPluginProvenance,
|
||||
auth: Option<&CodexAuth>,
|
||||
elicitation_reviewer: Option<ElicitationReviewerHandle>,
|
||||
) -> (Self, CancellationToken) {
|
||||
let cancel_token = CancellationToken::new();
|
||||
) -> Self {
|
||||
let mut required_servers = mcp_servers
|
||||
.iter()
|
||||
.filter(|(_, server)| server.enabled() && server.required())
|
||||
.map(|(name, _)| name.clone())
|
||||
.collect::<Vec<_>>();
|
||||
required_servers.sort();
|
||||
let mut clients = HashMap::new();
|
||||
let mut server_metadata = HashMap::new();
|
||||
let mut join_set = JoinSet::new();
|
||||
@@ -248,7 +160,7 @@ impl McpConnectionManager {
|
||||
.filter(|(_, server)| server.enabled())
|
||||
{
|
||||
server_metadata.insert(server_name.clone(), McpServerMetadata::from(&server));
|
||||
let cancel_token = cancel_token.child_token();
|
||||
let cancel_token = startup_cancellation_token.child_token();
|
||||
let _ = emit_update(
|
||||
startup_submit_id.as_str(),
|
||||
&tx_event,
|
||||
@@ -333,11 +245,12 @@ impl McpConnectionManager {
|
||||
let manager = Self {
|
||||
clients,
|
||||
server_metadata,
|
||||
required_servers,
|
||||
tool_plugin_provenance,
|
||||
host_owned_codex_apps_enabled,
|
||||
prefix_mcp_tool_names,
|
||||
elicitation_requests: elicitation_requests.clone(),
|
||||
startup_cancellation_token: cancel_token.clone(),
|
||||
startup_cancellation_token: startup_cancellation_token.clone(),
|
||||
};
|
||||
tokio::spawn(async move {
|
||||
let outcomes = join_set.join_all().await;
|
||||
@@ -361,7 +274,132 @@ impl McpConnectionManager {
|
||||
})
|
||||
.await;
|
||||
});
|
||||
(manager, cancel_token)
|
||||
manager
|
||||
}
|
||||
|
||||
/// Waits for every required server and reports their startup failures together.
|
||||
///
|
||||
/// Callers must make the manager reachable to request handlers before awaiting this method,
|
||||
/// because server initialization may require client elicitation.
|
||||
pub async fn validate_required_servers(&self) -> Result<()> {
|
||||
let failures = async {
|
||||
let mut failures = Vec::new();
|
||||
for server_name in &self.required_servers {
|
||||
let Some(async_managed_client) = self.clients.get(server_name).cloned() else {
|
||||
failures.push(McpStartupFailure {
|
||||
server: server_name.clone(),
|
||||
error: format!("required MCP server `{server_name}` was not initialized"),
|
||||
});
|
||||
continue;
|
||||
};
|
||||
|
||||
match async_managed_client.client().await {
|
||||
Ok(_) => {}
|
||||
Err(error) => failures.push(McpStartupFailure {
|
||||
server: server_name.clone(),
|
||||
error: startup_outcome_error_message(error),
|
||||
}),
|
||||
}
|
||||
}
|
||||
failures
|
||||
}
|
||||
.instrument(info_span!(
|
||||
"session_init.required_mcp_wait",
|
||||
otel.name = "session_init.required_mcp_wait",
|
||||
session_init.required_mcp_server_count = self.required_servers.len(),
|
||||
))
|
||||
.await;
|
||||
if failures.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let details = failures
|
||||
.iter()
|
||||
.map(|failure| format!("{}: {}", failure.server, failure.error))
|
||||
.collect::<Vec<_>>()
|
||||
.join("; ");
|
||||
Err(anyhow!(
|
||||
"required MCP servers failed to initialize: {details}"
|
||||
))
|
||||
}
|
||||
|
||||
pub fn new_uninitialized_with_permission_profile(
|
||||
approval_policy: &Constrained<AskForApproval>,
|
||||
permission_profile: &PermissionProfile,
|
||||
prefix_mcp_tool_names: bool,
|
||||
) -> Self {
|
||||
Self {
|
||||
clients: HashMap::new(),
|
||||
server_metadata: HashMap::new(),
|
||||
required_servers: Vec::new(),
|
||||
tool_plugin_provenance: Arc::new(ToolPluginProvenance::default()),
|
||||
host_owned_codex_apps_enabled: false,
|
||||
prefix_mcp_tool_names,
|
||||
elicitation_requests: ElicitationRequestManager::new(
|
||||
approval_policy.value(),
|
||||
permission_profile.clone(),
|
||||
/*reviewer*/ None,
|
||||
),
|
||||
startup_cancellation_token: CancellationToken::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn has_servers(&self) -> bool {
|
||||
!self.clients.is_empty()
|
||||
}
|
||||
|
||||
pub(crate) fn contains_server(&self, server_name: &str) -> bool {
|
||||
self.clients.contains_key(server_name)
|
||||
}
|
||||
|
||||
/// Stop all MCP clients owned by this manager and terminate stdio server processes.
|
||||
pub async fn shutdown(&self) {
|
||||
self.startup_cancellation_token.cancel();
|
||||
for client in self.clients.values() {
|
||||
client.shutdown().await;
|
||||
}
|
||||
}
|
||||
|
||||
pub fn server_origin(&self, server_name: &str) -> Option<&str> {
|
||||
self.server_metadata
|
||||
.get(server_name)
|
||||
.and_then(|metadata| metadata.origin.as_ref())
|
||||
.map(super::server::McpServerOrigin::as_str)
|
||||
}
|
||||
|
||||
pub fn server_pollutes_memory(&self, server_name: &str) -> bool {
|
||||
self.server_metadata
|
||||
.get(server_name)
|
||||
.is_none_or(|metadata| metadata.pollutes_memory)
|
||||
}
|
||||
|
||||
pub fn plugin_id_for_mcp_server_name(&self, server_name: &str) -> Option<&str> {
|
||||
self.tool_plugin_provenance
|
||||
.plugin_id_for_mcp_server_name(server_name)
|
||||
}
|
||||
|
||||
pub fn is_host_owned_codex_apps_server(&self, server_name: &str) -> bool {
|
||||
self.host_owned_codex_apps_enabled && server_name == CODEX_APPS_MCP_SERVER_NAME
|
||||
}
|
||||
|
||||
pub fn set_approval_policy(&self, approval_policy: &Constrained<AskForApproval>) {
|
||||
if let Ok(mut policy) = self.elicitation_requests.approval_policy.lock() {
|
||||
*policy = approval_policy.value();
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_permission_profile(&self, permission_profile: PermissionProfile) {
|
||||
if let Ok(mut profile) = self.elicitation_requests.permission_profile.lock() {
|
||||
*profile = permission_profile;
|
||||
}
|
||||
}
|
||||
|
||||
pub fn elicitations_auto_deny(&self) -> bool {
|
||||
self.elicitation_requests.auto_deny()
|
||||
}
|
||||
|
||||
pub fn set_elicitations_auto_deny(&self, auto_deny: bool) {
|
||||
self.elicitation_requests.set_auto_deny(auto_deny);
|
||||
}
|
||||
|
||||
pub async fn resolve_elicitation(
|
||||
@@ -386,31 +424,6 @@ impl McpConnectionManager {
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn required_startup_failures(
|
||||
&self,
|
||||
required_servers: &[String],
|
||||
) -> Vec<McpStartupFailure> {
|
||||
let mut failures = Vec::new();
|
||||
for server_name in required_servers {
|
||||
let Some(async_managed_client) = self.clients.get(server_name).cloned() else {
|
||||
failures.push(McpStartupFailure {
|
||||
server: server_name.clone(),
|
||||
error: format!("required MCP server `{server_name}` was not initialized"),
|
||||
});
|
||||
continue;
|
||||
};
|
||||
|
||||
match async_managed_client.client().await {
|
||||
Ok(_) => {}
|
||||
Err(error) => failures.push(McpStartupFailure {
|
||||
server: server_name.clone(),
|
||||
error: startup_outcome_error_message(error),
|
||||
}),
|
||||
}
|
||||
}
|
||||
failures
|
||||
}
|
||||
|
||||
/// Returns all tools with model-visible names normalized.
|
||||
#[instrument(level = "trace", skip_all, fields(mcp_server_count = self.clients.len()))]
|
||||
pub async fn list_all_tools(&self) -> Vec<ToolInfo> {
|
||||
@@ -452,31 +465,6 @@ impl McpConnectionManager {
|
||||
normalize_tools_for_model_with_prefix(tools, self.prefix_mcp_tool_names)
|
||||
}
|
||||
|
||||
/// Returns presentation metadata without waiting for uncached clients still initializing.
|
||||
/// Cached values will be used if available and the server is still starting up.
|
||||
pub async fn list_available_server_infos(&self) -> HashMap<String, McpServerInfo> {
|
||||
let mut server_infos = HashMap::new();
|
||||
for (server_name, client) in &self.clients {
|
||||
if !client.startup_complete.load(Ordering::Acquire) {
|
||||
if let Some(server_info) = client.cached_server_info.clone() {
|
||||
server_infos.insert(server_name.clone(), server_info);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
match client.client().await {
|
||||
Ok(managed_client) => {
|
||||
server_infos.insert(server_name.clone(), managed_client.server_info);
|
||||
}
|
||||
Err(_) => {
|
||||
if let Some(server_info) = client.cached_server_info.clone() {
|
||||
server_infos.insert(server_name.clone(), server_info);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
server_infos
|
||||
}
|
||||
|
||||
/// Force-refresh codex apps tools by bypassing the in-process cache.
|
||||
///
|
||||
/// On success, the refreshed tools replace the cache contents and the
|
||||
@@ -532,21 +520,6 @@ impl McpConnectionManager {
|
||||
))
|
||||
}
|
||||
|
||||
fn with_server_metadata(&self, mut tool: ToolInfo) -> ToolInfo {
|
||||
let Some(metadata) = self.server_metadata.get(&tool.server_name) else {
|
||||
tool.supports_parallel_tool_calls = false;
|
||||
tool.server_origin = None;
|
||||
return tool;
|
||||
};
|
||||
|
||||
tool.supports_parallel_tool_calls = metadata.supports_parallel_tool_calls;
|
||||
tool.server_origin = metadata
|
||||
.origin
|
||||
.as_ref()
|
||||
.map(|origin| origin.as_str().to_string());
|
||||
tool
|
||||
}
|
||||
|
||||
/// Returns a single map that contains all resources. Each key is the
|
||||
/// server name and the value is a vector of resources.
|
||||
pub async fn list_all_resources(&self) -> HashMap<String, Vec<Resource>> {
|
||||
@@ -778,6 +751,46 @@ impl McpConnectionManager {
|
||||
.with_context(|| format!("resources/read failed for `{server}` ({uri})"))
|
||||
}
|
||||
|
||||
/// Returns presentation metadata without waiting for uncached clients still initializing.
|
||||
/// Cached values will be used if available and the server is still starting up.
|
||||
pub(crate) async fn list_available_server_infos(&self) -> HashMap<String, McpServerInfo> {
|
||||
let mut server_infos = HashMap::new();
|
||||
for (server_name, client) in &self.clients {
|
||||
if !client.startup_complete.load(Ordering::Acquire) {
|
||||
if let Some(server_info) = client.cached_server_info.clone() {
|
||||
server_infos.insert(server_name.clone(), server_info);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
match client.client().await {
|
||||
Ok(managed_client) => {
|
||||
server_infos.insert(server_name.clone(), managed_client.server_info);
|
||||
}
|
||||
Err(_) => {
|
||||
if let Some(server_info) = client.cached_server_info.clone() {
|
||||
server_infos.insert(server_name.clone(), server_info);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
server_infos
|
||||
}
|
||||
|
||||
fn with_server_metadata(&self, mut tool: ToolInfo) -> ToolInfo {
|
||||
let Some(metadata) = self.server_metadata.get(&tool.server_name) else {
|
||||
tool.supports_parallel_tool_calls = false;
|
||||
tool.server_origin = None;
|
||||
return tool;
|
||||
};
|
||||
|
||||
tool.supports_parallel_tool_calls = metadata.supports_parallel_tool_calls;
|
||||
tool.server_origin = metadata
|
||||
.origin
|
||||
.as_ref()
|
||||
.map(|origin| origin.as_str().to_string());
|
||||
tool
|
||||
}
|
||||
|
||||
async fn client_by_name(&self, name: &str) -> Result<ManagedClient> {
|
||||
self.clients
|
||||
.get(name)
|
||||
@@ -786,6 +799,19 @@ impl McpConnectionManager {
|
||||
.await
|
||||
.context("failed to get client")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn new_uninitialized(
|
||||
approval_policy: &Constrained<AskForApproval>,
|
||||
permission_profile: &Constrained<PermissionProfile>,
|
||||
prefix_mcp_tool_names: bool,
|
||||
) -> Self {
|
||||
Self::new_uninitialized_with_permission_profile(
|
||||
approval_policy,
|
||||
permission_profile.get(),
|
||||
prefix_mcp_tool_names,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for McpConnectionManager {
|
||||
|
||||
@@ -980,6 +980,48 @@ async fn list_all_tools_blocks_while_client_is_pending_without_cached_tool_info_
|
||||
assert!(timeout_result.is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn shutdown_cancels_pending_tool_listing() {
|
||||
let cancel_token = CancellationToken::new();
|
||||
let cancel_token_for_startup = cancel_token.clone();
|
||||
let (started_tx, started_rx) = tokio::sync::oneshot::channel();
|
||||
let pending_client = async move {
|
||||
let _ = started_tx.send(());
|
||||
cancel_token_for_startup.cancelled().await;
|
||||
Err(StartupOutcomeError::Cancelled)
|
||||
}
|
||||
.boxed()
|
||||
.shared();
|
||||
let approval_policy = Constrained::allow_any(AskForApproval::OnFailure);
|
||||
let permission_profile = Constrained::allow_any(PermissionProfile::default());
|
||||
let mut manager = McpConnectionManager::new_uninitialized(
|
||||
&approval_policy,
|
||||
&permission_profile,
|
||||
/*prefix_mcp_tool_names*/ true,
|
||||
);
|
||||
manager.clients.insert(
|
||||
CODEX_APPS_MCP_SERVER_NAME.to_string(),
|
||||
AsyncManagedClient {
|
||||
client: pending_client,
|
||||
cached_tool_info_snapshot: None,
|
||||
cached_server_info: None,
|
||||
startup_complete: Arc::new(std::sync::atomic::AtomicBool::new(false)),
|
||||
tool_plugin_provenance: Arc::new(ToolPluginProvenance::default()),
|
||||
cancel_token,
|
||||
},
|
||||
);
|
||||
let manager = Arc::new(manager);
|
||||
let manager_for_list = Arc::clone(&manager);
|
||||
let list_task = tokio::spawn(async move { manager_for_list.list_all_tools().await });
|
||||
|
||||
started_rx.await.expect("tool listing should start");
|
||||
tokio::time::timeout(Duration::from_secs(1), manager.shutdown())
|
||||
.await
|
||||
.expect("shutdown should cancel speculative tool listing");
|
||||
let tools = list_task.await.expect("tool listing task should not panic");
|
||||
assert!(tools.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn list_all_tools_does_not_block_when_cached_tool_info_snapshot_is_empty() {
|
||||
let pending_client = futures::future::pending::<Result<ManagedClient, StartupOutcomeError>>()
|
||||
@@ -1167,13 +1209,15 @@ async fn no_local_runtime_fails_local_stdio_but_keeps_local_http_server() {
|
||||
),
|
||||
]);
|
||||
|
||||
let (manager, cancel_token) = McpConnectionManager::new(
|
||||
let cancel_token = CancellationToken::new();
|
||||
let manager = McpConnectionManager::new(
|
||||
&mcp_servers,
|
||||
OAuthCredentialsStoreMode::default(),
|
||||
HashMap::new(),
|
||||
&approval_policy,
|
||||
String::new(),
|
||||
tx_event,
|
||||
cancel_token.clone(),
|
||||
PermissionProfile::default(),
|
||||
McpRuntimeContext::new(
|
||||
Arc::new(EnvironmentManager::without_environments()),
|
||||
@@ -1201,13 +1245,18 @@ async fn no_local_runtime_fails_local_stdio_but_keeps_local_http_server() {
|
||||
.wait_for_server_ready("stdio", Duration::from_millis(10))
|
||||
.await
|
||||
);
|
||||
let failures = manager
|
||||
.required_startup_failures(&["stdio".to_string()])
|
||||
.await;
|
||||
assert_eq!(failures.len(), 1);
|
||||
assert_eq!(failures[0].server, "stdio");
|
||||
let error = match manager
|
||||
.clients
|
||||
.get("stdio")
|
||||
.expect("stdio client")
|
||||
.client()
|
||||
.await
|
||||
{
|
||||
Ok(_) => panic!("local stdio MCP startup should fail"),
|
||||
Err(error) => error,
|
||||
};
|
||||
assert_eq!(
|
||||
failures[0].error,
|
||||
startup_outcome_error_message(error),
|
||||
"local stdio MCP server `stdio` requires a local environment"
|
||||
);
|
||||
cancel_token.cancel();
|
||||
|
||||
@@ -3,6 +3,9 @@ pub use connection_manager::tool_is_model_visible;
|
||||
pub use elicitation::ElicitationReviewRequest;
|
||||
pub use elicitation::ElicitationReviewer;
|
||||
pub use elicitation::ElicitationReviewerHandle;
|
||||
pub use resource_client::McpResourceClient;
|
||||
pub use resource_client::McpResourcePage;
|
||||
pub use resource_client::McpResourceReadResult;
|
||||
pub use rmcp_client::MCP_SANDBOX_STATE_META_CAPABILITY;
|
||||
pub use runtime::McpRuntimeContext;
|
||||
pub use runtime::SandboxState;
|
||||
@@ -29,8 +32,8 @@ pub use mcp::configured_mcp_servers;
|
||||
pub use mcp::effective_mcp_servers;
|
||||
pub use mcp::effective_mcp_servers_from_configured;
|
||||
pub use mcp::host_owned_codex_apps_enabled;
|
||||
pub use mcp::hosted_plugin_runtime_mcp_server_config;
|
||||
pub use mcp::tool_plugin_provenance;
|
||||
pub use mcp::with_codex_apps_mcp;
|
||||
|
||||
pub use mcp::McpServerStatusSnapshot;
|
||||
pub use mcp::McpSnapshotDetail;
|
||||
@@ -58,6 +61,7 @@ pub(crate) mod codex_apps;
|
||||
pub(crate) mod connection_manager;
|
||||
pub(crate) mod elicitation;
|
||||
pub(crate) mod mcp;
|
||||
mod resource_client;
|
||||
pub(crate) mod rmcp_client;
|
||||
pub(crate) mod runtime;
|
||||
pub(crate) mod server;
|
||||
|
||||
@@ -35,6 +35,7 @@ use rmcp::model::ElicitationCapability;
|
||||
use rmcp::model::ReadResourceRequestParams;
|
||||
use rmcp::model::ReadResourceResult;
|
||||
use serde_json::Value;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
|
||||
use crate::codex_apps::codex_apps_tools_cache_key;
|
||||
use crate::connection_manager::McpConnectionManager;
|
||||
@@ -106,8 +107,6 @@ pub struct McpPermissionPromptAutoApproveContext {
|
||||
pub struct McpConfig {
|
||||
/// Base URL for ChatGPT-hosted app MCP servers, copied from the root config.
|
||||
pub chatgpt_base_url: String,
|
||||
/// Optional path override for the host-owned apps MCP server.
|
||||
pub apps_mcp_path_override: Option<String>,
|
||||
/// Optional product SKU forwarded to the host-owned apps MCP server.
|
||||
pub apps_mcp_product_sku: Option<String>,
|
||||
/// Codex home directory used for MCP OAuth state and app-tool cache files.
|
||||
@@ -128,22 +127,18 @@ pub struct McpConfig {
|
||||
pub use_legacy_landlock: bool,
|
||||
/// Whether the app MCP integration is enabled by config.
|
||||
///
|
||||
/// ChatGPT auth is checked separately at runtime before the host-owned apps
|
||||
/// MCP server is added.
|
||||
/// ChatGPT auth is checked separately before a materialized host-owned Apps
|
||||
/// server can be used.
|
||||
pub apps_enabled: bool,
|
||||
/// Whether to synthesize the legacy host-owned Apps MCP server.
|
||||
///
|
||||
/// Hosts that install an MCP extension for this server disable the legacy
|
||||
/// loader and contribute the server through the normal runtime overlay.
|
||||
pub legacy_apps_mcp_loader_enabled: bool,
|
||||
/// Whether model-visible MCP tool namespaces should keep the legacy
|
||||
/// `mcp__` prefix.
|
||||
pub prefix_mcp_tool_names: bool,
|
||||
/// Client-side elicitation capabilities advertised during MCP initialization.
|
||||
pub client_elicitation_capability: ElicitationCapability,
|
||||
/// Config-backed MCP servers keyed by server name.
|
||||
/// Materialized MCP servers keyed by server name.
|
||||
///
|
||||
/// Runtime-only additions are merged later by [`effective_mcp_servers`].
|
||||
/// A host may add compatibility built-ins and extension overlays before
|
||||
/// calling runtime entry points in this crate.
|
||||
pub configured_mcp_servers: HashMap<String, McpServerConfig>,
|
||||
/// Winning plugin owner for plugin-provided MCP servers, keyed by server name.
|
||||
pub plugin_ids_by_mcp_server_name: HashMap<String, String>,
|
||||
@@ -218,32 +213,6 @@ impl ToolPluginProvenance {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_codex_apps_mcp(
|
||||
mut servers: HashMap<String, EffectiveMcpServer>,
|
||||
auth: Option<&CodexAuth>,
|
||||
config: &McpConfig,
|
||||
) -> HashMap<String, EffectiveMcpServer> {
|
||||
if !config.legacy_apps_mcp_loader_enabled {
|
||||
if !host_owned_codex_apps_enabled(config, auth) {
|
||||
servers.remove(CODEX_APPS_MCP_SERVER_NAME);
|
||||
}
|
||||
return servers;
|
||||
}
|
||||
if host_owned_codex_apps_enabled(config, auth) {
|
||||
servers.insert(
|
||||
CODEX_APPS_MCP_SERVER_NAME.to_string(),
|
||||
EffectiveMcpServer::configured(codex_apps_mcp_server_config(
|
||||
&config.chatgpt_base_url,
|
||||
config.apps_mcp_path_override.as_deref(),
|
||||
config.apps_mcp_product_sku.as_deref(),
|
||||
)),
|
||||
);
|
||||
} else {
|
||||
servers.remove(CODEX_APPS_MCP_SERVER_NAME);
|
||||
}
|
||||
servers
|
||||
}
|
||||
|
||||
pub fn host_owned_codex_apps_enabled(config: &McpConfig, auth: Option<&CodexAuth>) -> bool {
|
||||
config.apps_enabled && auth.is_some_and(CodexAuth::uses_codex_backend)
|
||||
}
|
||||
@@ -259,16 +228,23 @@ pub fn effective_mcp_servers(
|
||||
effective_mcp_servers_from_configured(configured_mcp_servers(config), config, auth)
|
||||
}
|
||||
|
||||
/// Converts a materialized server map to its auth-gated runtime view.
|
||||
///
|
||||
/// Compatibility built-ins and extension overlays must already be reflected in
|
||||
/// `configured_servers`; this function does not synthesize missing servers.
|
||||
pub fn effective_mcp_servers_from_configured(
|
||||
configured_servers: HashMap<String, McpServerConfig>,
|
||||
config: &McpConfig,
|
||||
auth: Option<&CodexAuth>,
|
||||
) -> HashMap<String, EffectiveMcpServer> {
|
||||
let servers = configured_servers
|
||||
let mut servers = configured_servers
|
||||
.into_iter()
|
||||
.map(|(name, server)| (name, EffectiveMcpServer::configured(server)))
|
||||
.collect::<HashMap<_, _>>();
|
||||
with_codex_apps_mcp(servers, auth, config)
|
||||
if !host_owned_codex_apps_enabled(config, auth) {
|
||||
servers.remove(CODEX_APPS_MCP_SERVER_NAME);
|
||||
}
|
||||
servers
|
||||
}
|
||||
|
||||
pub fn tool_plugin_provenance(config: &McpConfig) -> ToolPluginProvenance {
|
||||
@@ -293,13 +269,15 @@ pub async fn read_mcp_resource(
|
||||
.await;
|
||||
let (tx_event, rx_event) = unbounded();
|
||||
drop(rx_event);
|
||||
let (manager, cancel_token) = McpConnectionManager::new(
|
||||
let cancel_token = CancellationToken::new();
|
||||
let manager = McpConnectionManager::new(
|
||||
&mcp_servers,
|
||||
config.mcp_oauth_credentials_store_mode,
|
||||
auth_statuses,
|
||||
&config.approval_policy,
|
||||
String::new(),
|
||||
tx_event,
|
||||
cancel_token.clone(),
|
||||
PermissionProfile::default(),
|
||||
runtime_context,
|
||||
config.codex_home.clone(),
|
||||
@@ -363,13 +341,15 @@ pub async fn collect_mcp_server_status_snapshot_with_detail(
|
||||
let (tx_event, rx_event) = unbounded();
|
||||
drop(rx_event);
|
||||
|
||||
let (mcp_connection_manager, cancel_token) = McpConnectionManager::new(
|
||||
let cancel_token = CancellationToken::new();
|
||||
let mcp_connection_manager = McpConnectionManager::new(
|
||||
&mcp_servers,
|
||||
config.mcp_oauth_credentials_store_mode,
|
||||
auth_status_entries.clone(),
|
||||
&config.approval_policy,
|
||||
submit_id,
|
||||
tx_event,
|
||||
cancel_token.clone(),
|
||||
PermissionProfile::default(),
|
||||
runtime_context,
|
||||
config.codex_home.clone(),
|
||||
@@ -436,7 +416,7 @@ fn normalize_codex_apps_base_url(base_url: &str) -> String {
|
||||
base_url
|
||||
}
|
||||
|
||||
fn codex_apps_mcp_url_for_base_url(base_url: &str, apps_mcp_path_override: Option<&str>) -> String {
|
||||
fn codex_apps_mcp_url_for_base_url(base_url: &str) -> String {
|
||||
let base_url = normalize_codex_apps_base_url(base_url);
|
||||
let (base_url, default_path) = if base_url.contains("/backend-api") {
|
||||
(base_url, "wham/apps")
|
||||
@@ -445,18 +425,34 @@ fn codex_apps_mcp_url_for_base_url(base_url: &str, apps_mcp_path_override: Optio
|
||||
} else {
|
||||
(format!("{base_url}/api/codex"), "apps")
|
||||
};
|
||||
let path = apps_mcp_path_override
|
||||
.unwrap_or(default_path)
|
||||
.trim_start_matches('/');
|
||||
format!("{base_url}/{path}")
|
||||
format!("{base_url}/{default_path}")
|
||||
}
|
||||
|
||||
pub fn codex_apps_mcp_server_config(
|
||||
chatgpt_base_url: &str,
|
||||
apps_mcp_path_override: Option<&str>,
|
||||
apps_mcp_product_sku: Option<&str>,
|
||||
) -> McpServerConfig {
|
||||
let url = codex_apps_mcp_url_for_base_url(chatgpt_base_url, apps_mcp_path_override);
|
||||
mcp_server_config_for_url(
|
||||
codex_apps_mcp_url_for_base_url(chatgpt_base_url),
|
||||
apps_mcp_product_sku,
|
||||
)
|
||||
}
|
||||
|
||||
/// Builds the ChatGPT-hosted plugin runtime served by plugin-service.
|
||||
pub fn hosted_plugin_runtime_mcp_server_config(
|
||||
chatgpt_base_url: &str,
|
||||
apps_mcp_product_sku: Option<&str>,
|
||||
) -> McpServerConfig {
|
||||
let base_url = normalize_codex_apps_base_url(chatgpt_base_url);
|
||||
let base_url = if base_url.contains("/backend-api") || base_url.contains("/api/codex") {
|
||||
base_url
|
||||
} else {
|
||||
format!("{base_url}/api/codex")
|
||||
};
|
||||
mcp_server_config_for_url(format!("{base_url}/ps/mcp"), apps_mcp_product_sku)
|
||||
}
|
||||
|
||||
fn mcp_server_config_for_url(url: String, apps_mcp_product_sku: Option<&str>) -> McpServerConfig {
|
||||
let http_headers = apps_mcp_product_sku.map(|product_sku| {
|
||||
HashMap::from([("X-OpenAI-Product-Sku".to_string(), product_sku.to_string())])
|
||||
});
|
||||
|
||||
@@ -16,7 +16,6 @@ use std::path::PathBuf;
|
||||
fn test_mcp_config(codex_home: PathBuf) -> McpConfig {
|
||||
McpConfig {
|
||||
chatgpt_base_url: "https://chatgpt.com".to_string(),
|
||||
apps_mcp_path_override: None,
|
||||
apps_mcp_product_sku: None,
|
||||
codex_home,
|
||||
mcp_oauth_credentials_store_mode: OAuthCredentialsStoreMode::default(),
|
||||
@@ -27,7 +26,6 @@ fn test_mcp_config(codex_home: PathBuf) -> McpConfig {
|
||||
codex_linux_sandbox_exe: None,
|
||||
use_legacy_landlock: false,
|
||||
apps_enabled: false,
|
||||
legacy_apps_mcp_loader_enabled: true,
|
||||
prefix_mcp_tool_names: true,
|
||||
client_elicitation_capability: ElicitationCapability::default(),
|
||||
configured_mcp_servers: HashMap::new(),
|
||||
@@ -178,52 +176,27 @@ fn tool_plugin_provenance_collects_app_and_mcp_sources() {
|
||||
#[test]
|
||||
fn codex_apps_mcp_url_for_base_url_keeps_existing_paths() {
|
||||
assert_eq!(
|
||||
codex_apps_mcp_url_for_base_url(
|
||||
"https://chatgpt.com/backend-api",
|
||||
/*apps_mcp_path_override*/ None,
|
||||
),
|
||||
codex_apps_mcp_url_for_base_url("https://chatgpt.com/backend-api"),
|
||||
"https://chatgpt.com/backend-api/wham/apps"
|
||||
);
|
||||
assert_eq!(
|
||||
codex_apps_mcp_url_for_base_url(
|
||||
"https://chat.openai.com",
|
||||
/*apps_mcp_path_override*/ None,
|
||||
),
|
||||
codex_apps_mcp_url_for_base_url("https://chat.openai.com"),
|
||||
"https://chat.openai.com/backend-api/wham/apps"
|
||||
);
|
||||
assert_eq!(
|
||||
codex_apps_mcp_url_for_base_url(
|
||||
"http://localhost:8080/api/codex",
|
||||
/*apps_mcp_path_override*/ None,
|
||||
),
|
||||
codex_apps_mcp_url_for_base_url("http://localhost:8080/api/codex"),
|
||||
"http://localhost:8080/api/codex/apps"
|
||||
);
|
||||
assert_eq!(
|
||||
codex_apps_mcp_url_for_base_url(
|
||||
"http://localhost:8080",
|
||||
/*apps_mcp_path_override*/ None,
|
||||
),
|
||||
codex_apps_mcp_url_for_base_url("http://localhost:8080"),
|
||||
"http://localhost:8080/api/codex/apps"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn codex_apps_server_config_uses_legacy_codex_apps_path() {
|
||||
let mut config = test_mcp_config(PathBuf::from("/tmp"));
|
||||
let auth = CodexAuth::create_dummy_chatgpt_auth_for_testing();
|
||||
|
||||
let mut servers = with_codex_apps_mcp(HashMap::new(), /*auth*/ None, &config);
|
||||
assert!(!servers.contains_key(CODEX_APPS_MCP_SERVER_NAME));
|
||||
|
||||
config.apps_enabled = true;
|
||||
|
||||
servers = with_codex_apps_mcp(servers, Some(&auth), &config);
|
||||
let server = servers
|
||||
.get(CODEX_APPS_MCP_SERVER_NAME)
|
||||
.expect("codex apps should be present when apps is enabled");
|
||||
let config = server
|
||||
.configured_config()
|
||||
.expect("codex apps should use configured transport");
|
||||
let config =
|
||||
codex_apps_mcp_server_config("https://chatgpt.com", /*apps_mcp_product_sku*/ None);
|
||||
let url = match &config.transport {
|
||||
McpServerTransportConfig::StreamableHttp { url, .. } => url,
|
||||
_ => panic!("expected streamable http transport for codex apps"),
|
||||
@@ -232,42 +205,9 @@ fn codex_apps_server_config_uses_legacy_codex_apps_path() {
|
||||
assert_eq!(url, "https://chatgpt.com/backend-api/wham/apps");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn codex_apps_server_config_uses_configured_apps_mcp_path_override() {
|
||||
let mut config = test_mcp_config(PathBuf::from("/tmp"));
|
||||
config.apps_mcp_path_override = Some("/custom/mcp".to_string());
|
||||
config.apps_enabled = true;
|
||||
let auth = CodexAuth::create_dummy_chatgpt_auth_for_testing();
|
||||
|
||||
let servers = with_codex_apps_mcp(HashMap::new(), Some(&auth), &config);
|
||||
let server = servers
|
||||
.get(CODEX_APPS_MCP_SERVER_NAME)
|
||||
.expect("codex apps should be present when apps is enabled");
|
||||
let config = server
|
||||
.configured_config()
|
||||
.expect("codex apps should use configured transport");
|
||||
let url = match &config.transport {
|
||||
McpServerTransportConfig::StreamableHttp { url, .. } => url,
|
||||
_ => panic!("expected streamable http transport for codex apps"),
|
||||
};
|
||||
|
||||
assert_eq!(url, "https://chatgpt.com/backend-api/custom/mcp");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn codex_apps_server_config_forwards_configured_product_sku_header() {
|
||||
let mut config = test_mcp_config(PathBuf::from("/tmp"));
|
||||
config.apps_mcp_product_sku = Some("tpp".to_string());
|
||||
config.apps_enabled = true;
|
||||
let auth = CodexAuth::create_dummy_chatgpt_auth_for_testing();
|
||||
|
||||
let servers = with_codex_apps_mcp(HashMap::new(), Some(&auth), &config);
|
||||
let server = servers
|
||||
.get(CODEX_APPS_MCP_SERVER_NAME)
|
||||
.expect("codex apps should be present when apps is enabled");
|
||||
let config = server
|
||||
.configured_config()
|
||||
.expect("codex apps should use configured transport");
|
||||
let config = codex_apps_mcp_server_config("https://chatgpt.com", Some("tpp"));
|
||||
|
||||
match &config.transport {
|
||||
McpServerTransportConfig::StreamableHttp {
|
||||
@@ -289,7 +229,7 @@ fn codex_apps_server_config_forwards_configured_product_sku_header() {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn effective_mcp_servers_preserve_user_servers_and_add_codex_apps() {
|
||||
async fn effective_mcp_servers_preserve_runtime_servers() {
|
||||
let codex_home = tempfile::tempdir().expect("tempdir");
|
||||
let mut config = test_mcp_config(codex_home.path().to_path_buf());
|
||||
config.apps_enabled = true;
|
||||
@@ -345,6 +285,13 @@ async fn effective_mcp_servers_preserve_user_servers_and_add_codex_apps() {
|
||||
tools: HashMap::new(),
|
||||
},
|
||||
);
|
||||
config.configured_mcp_servers.insert(
|
||||
CODEX_APPS_MCP_SERVER_NAME.to_string(),
|
||||
codex_apps_mcp_server_config(
|
||||
&config.chatgpt_base_url,
|
||||
config.apps_mcp_product_sku.as_deref(),
|
||||
),
|
||||
);
|
||||
|
||||
let effective = effective_mcp_servers(&config, Some(&auth));
|
||||
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use anyhow::Context;
|
||||
use anyhow::Result;
|
||||
use arc_swap::ArcSwap;
|
||||
use codex_protocol::mcp::Resource;
|
||||
use codex_protocol::mcp::ResourceContent;
|
||||
use rmcp::model::PaginatedRequestParams;
|
||||
use rmcp::model::ReadResourceRequestParams;
|
||||
|
||||
use crate::McpConnectionManager;
|
||||
|
||||
/// One page of resources returned by an MCP server.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct McpResourcePage {
|
||||
/// Resources advertised on this page.
|
||||
pub resources: Vec<Resource>,
|
||||
/// Opaque cursor to supply when requesting the next page.
|
||||
pub next_cursor: Option<String>,
|
||||
}
|
||||
|
||||
/// Contents returned after reading one MCP resource.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct McpResourceReadResult {
|
||||
/// Text or blob content returned for the requested resource.
|
||||
pub contents: Vec<ResourceContent>,
|
||||
}
|
||||
|
||||
/// Session-scoped access to MCP resources through the currently installed manager.
|
||||
///
|
||||
/// The client retains the manager's shared publication handle rather than a manager
|
||||
/// snapshot, so calls automatically use replacements installed during startup and refresh.
|
||||
#[derive(Clone)]
|
||||
pub struct McpResourceClient {
|
||||
manager: Arc<ArcSwap<McpConnectionManager>>,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for McpResourceClient {
|
||||
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
formatter
|
||||
.debug_struct("McpResourceClient")
|
||||
.finish_non_exhaustive()
|
||||
}
|
||||
}
|
||||
|
||||
impl McpResourceClient {
|
||||
/// Creates a resource client backed by the session's replaceable MCP manager.
|
||||
pub fn new(manager: Arc<ArcSwap<McpConnectionManager>>) -> Self {
|
||||
Self { manager }
|
||||
}
|
||||
|
||||
/// Returns whether the current manager contains the named server.
|
||||
///
|
||||
/// This does not wait for server startup or imply that startup succeeded.
|
||||
pub async fn has_server(&self, server: &str) -> bool {
|
||||
self.manager.load_full().contains_server(server)
|
||||
}
|
||||
|
||||
/// Lists one resource page from the named server.
|
||||
pub async fn list_resources(
|
||||
&self,
|
||||
server: &str,
|
||||
cursor: Option<String>,
|
||||
) -> Result<McpResourcePage> {
|
||||
let params =
|
||||
cursor.map(|cursor| PaginatedRequestParams::default().with_cursor(Some(cursor)));
|
||||
let result = self
|
||||
.manager
|
||||
.load_full()
|
||||
.list_resources(server, params)
|
||||
.await?;
|
||||
let resources = result
|
||||
.resources
|
||||
.into_iter()
|
||||
.map(resource_from_rmcp)
|
||||
.collect::<Result<Vec<_>>>()?;
|
||||
Ok(McpResourcePage {
|
||||
resources,
|
||||
next_cursor: result.next_cursor,
|
||||
})
|
||||
}
|
||||
|
||||
/// Reads one resource from the named server.
|
||||
pub async fn read_resource(&self, server: &str, uri: &str) -> Result<McpResourceReadResult> {
|
||||
let result = self
|
||||
.manager
|
||||
.load_full()
|
||||
.read_resource(server, ReadResourceRequestParams::new(uri.to_string()))
|
||||
.await?;
|
||||
let contents = result
|
||||
.contents
|
||||
.into_iter()
|
||||
.map(resource_content_from_rmcp)
|
||||
.collect::<Result<Vec<_>>>()?;
|
||||
Ok(McpResourceReadResult { contents })
|
||||
}
|
||||
}
|
||||
|
||||
fn resource_from_rmcp(resource: rmcp::model::Resource) -> Result<Resource> {
|
||||
let value = serde_json::to_value(resource).context("failed to serialize MCP resource")?;
|
||||
Resource::from_mcp_value(value).context("failed to convert MCP resource")
|
||||
}
|
||||
|
||||
fn resource_content_from_rmcp(content: rmcp::model::ResourceContents) -> Result<ResourceContent> {
|
||||
let value =
|
||||
serde_json::to_value(content).context("failed to serialize MCP resource content")?;
|
||||
serde_json::from_value(value).context("failed to convert MCP resource content")
|
||||
}
|
||||
@@ -9,6 +9,7 @@ use schemars::schema::ObjectValidation;
|
||||
use schemars::schema::RootSchema;
|
||||
use schemars::schema::Schema;
|
||||
use schemars::schema::SchemaObject;
|
||||
use schemars::schema::SubschemaValidation;
|
||||
use serde_json::Map;
|
||||
use serde_json::Value;
|
||||
use std::path::Path;
|
||||
@@ -46,9 +47,7 @@ pub fn features_schema(schema_gen: &mut SchemaGenerator) -> Schema {
|
||||
if feature.id == codex_features::Feature::AppsMcpPathOverride {
|
||||
validation.properties.insert(
|
||||
feature.key.to_string(),
|
||||
schema_gen.subschema_for::<codex_features::FeatureToml<
|
||||
codex_features::AppsMcpPathOverrideConfigToml,
|
||||
>>(),
|
||||
removed_apps_mcp_path_override_schema(schema_gen),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
@@ -76,6 +75,30 @@ pub fn features_schema(schema_gen: &mut SchemaGenerator) -> Schema {
|
||||
Schema::Object(object)
|
||||
}
|
||||
|
||||
fn removed_apps_mcp_path_override_schema(schema_gen: &mut SchemaGenerator) -> Schema {
|
||||
let mut config_validation = ObjectValidation::default();
|
||||
config_validation
|
||||
.properties
|
||||
.insert("enabled".to_string(), schema_gen.subschema_for::<bool>());
|
||||
config_validation
|
||||
.properties
|
||||
.insert("path".to_string(), schema_gen.subschema_for::<String>());
|
||||
config_validation.additional_properties = Some(Box::new(Schema::Bool(false)));
|
||||
|
||||
let config = Schema::Object(SchemaObject {
|
||||
instance_type: Some(InstanceType::Object.into()),
|
||||
object: Some(Box::new(config_validation)),
|
||||
..Default::default()
|
||||
});
|
||||
Schema::Object(SchemaObject {
|
||||
subschemas: Some(Box::new(SubschemaValidation {
|
||||
any_of: Some(vec![schema_gen.subschema_for::<bool>(), config]),
|
||||
..Default::default()
|
||||
})),
|
||||
..Default::default()
|
||||
})
|
||||
}
|
||||
|
||||
/// Schema for the `[mcp_servers]` map using the raw input shape.
|
||||
pub fn mcp_servers_schema(schema_gen: &mut SchemaGenerator) -> Schema {
|
||||
let mut object = SchemaObject {
|
||||
|
||||
@@ -68,6 +68,7 @@ pub struct ToolSuggestPluginDiscoveryInput {
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct ToolSuggestDiscoverablePlugin {
|
||||
pub id: String,
|
||||
pub remote_plugin_id: Option<String>,
|
||||
pub name: String,
|
||||
pub description: Option<String>,
|
||||
pub has_skills: bool,
|
||||
@@ -86,7 +87,11 @@ impl PluginsManager {
|
||||
}
|
||||
|
||||
let marketplaces = self
|
||||
.list_marketplaces_for_config(&input.plugins, &[])
|
||||
.list_marketplaces_for_config(
|
||||
&input.plugins,
|
||||
&[],
|
||||
/*include_openai_curated*/ !input.plugins.remote_plugin_enabled,
|
||||
)
|
||||
.context("failed to list plugin marketplaces for tool suggestions")?
|
||||
.marketplaces;
|
||||
let mut installed_app_connector_ids = self
|
||||
@@ -109,11 +114,6 @@ impl PluginsManager {
|
||||
let mut discoverable_plugins = Vec::<ToolSuggestDiscoverablePlugin>::new();
|
||||
for marketplace in marketplaces {
|
||||
let marketplace_name = marketplace.name;
|
||||
if input.plugins.remote_plugin_enabled
|
||||
&& marketplace_name == OPENAI_CURATED_MARKETPLACE_NAME
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let use_legacy_local_curated_filter = should_use_legacy_local_curated_discovery_filter(
|
||||
&marketplace_name,
|
||||
marketplace.path.as_path(),
|
||||
@@ -162,6 +162,7 @@ impl PluginsManager {
|
||||
|
||||
discoverable_plugins.push(ToolSuggestDiscoverablePlugin {
|
||||
id: plugin.config_name,
|
||||
remote_plugin_id: None,
|
||||
name: plugin.display_name,
|
||||
description: plugin.description,
|
||||
has_skills: plugin.has_skills,
|
||||
@@ -217,6 +218,7 @@ impl PluginsManager {
|
||||
|
||||
discoverable_plugins.push(ToolSuggestDiscoverablePlugin {
|
||||
id: plugin.config_id,
|
||||
remote_plugin_id: Some(plugin.remote_plugin_id),
|
||||
name: plugin.name,
|
||||
description: plugin.description,
|
||||
has_skills: plugin.has_skills,
|
||||
|
||||
@@ -48,6 +48,7 @@ use crate::store::PluginInstallResult as StorePluginInstallResult;
|
||||
use crate::store::PluginStore;
|
||||
use crate::store::PluginStoreError;
|
||||
use codex_analytics::AnalyticsEventsClient;
|
||||
use codex_app_server_protocol::AuthMode;
|
||||
use codex_config::ConfigLayerStack;
|
||||
use codex_config::clear_user_plugin;
|
||||
use codex_config::set_user_plugin_enabled;
|
||||
@@ -203,6 +204,13 @@ fn featured_plugin_ids_cache_key(
|
||||
}
|
||||
}
|
||||
|
||||
fn project_plugin_load_outcome_for_auth(
|
||||
outcome: PluginLoadOutcome,
|
||||
_auth_mode: Option<AuthMode>,
|
||||
) -> PluginLoadOutcome {
|
||||
outcome
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct PluginInstallRequest {
|
||||
pub plugin_name: String,
|
||||
@@ -319,6 +327,7 @@ pub struct PluginsManager {
|
||||
remote_installed_plugins_cache_refresh_state: RwLock<RemoteInstalledPluginsCacheRefreshState>,
|
||||
global_remote_catalog_cache_refresh_state: RwLock<GlobalRemoteCatalogCacheRefreshState>,
|
||||
restriction_product: Option<Product>,
|
||||
auth_mode: RwLock<Option<AuthMode>>,
|
||||
analytics_events_client: RwLock<Option<AnalyticsEventsClient>>,
|
||||
}
|
||||
|
||||
@@ -375,10 +384,30 @@ impl PluginsManager {
|
||||
GlobalRemoteCatalogCacheRefreshState::default(),
|
||||
),
|
||||
restriction_product,
|
||||
auth_mode: RwLock::new(None),
|
||||
analytics_events_client: RwLock::new(None),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_auth_mode(&self, auth_mode: Option<AuthMode>) -> bool {
|
||||
let mut stored_auth_mode = match self.auth_mode.write() {
|
||||
Ok(auth_mode_guard) => auth_mode_guard,
|
||||
Err(err) => err.into_inner(),
|
||||
};
|
||||
if *stored_auth_mode == auth_mode {
|
||||
return false;
|
||||
}
|
||||
*stored_auth_mode = auth_mode;
|
||||
true
|
||||
}
|
||||
|
||||
pub fn auth_mode(&self) -> Option<AuthMode> {
|
||||
match self.auth_mode.read() {
|
||||
Ok(auth_mode_guard) => *auth_mode_guard,
|
||||
Err(err) => *err.into_inner(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_analytics_events_client(&self, analytics_events_client: AnalyticsEventsClient) {
|
||||
let mut stored_client = match self.analytics_events_client.write() {
|
||||
Ok(client_guard) => client_guard,
|
||||
@@ -417,7 +446,7 @@ impl PluginsManager {
|
||||
remote_plugin_enabled: config.remote_plugin_enabled,
|
||||
};
|
||||
if !force_reload && let Some(outcome) = self.cached_enabled_outcome(&cache_key) {
|
||||
return outcome;
|
||||
return self.project_plugins_for_auth(outcome);
|
||||
}
|
||||
|
||||
let Ok(_load_permit) = self.enabled_outcome_load_semaphore.acquire().await else {
|
||||
@@ -425,7 +454,7 @@ impl PluginsManager {
|
||||
return PluginLoadOutcome::default();
|
||||
};
|
||||
if !force_reload && let Some(outcome) = self.cached_enabled_outcome(&cache_key) {
|
||||
return outcome;
|
||||
return self.project_plugins_for_auth(outcome);
|
||||
}
|
||||
let cache_generation = self.enabled_outcome_cache_generation();
|
||||
let outcome = load_plugins_from_layer_stack(
|
||||
@@ -438,7 +467,11 @@ impl PluginsManager {
|
||||
.await;
|
||||
log_plugin_load_errors(&outcome);
|
||||
self.cache_enabled_outcome_if_current(cache_generation, cache_key, outcome.clone());
|
||||
outcome
|
||||
self.project_plugins_for_auth(outcome)
|
||||
}
|
||||
|
||||
fn project_plugins_for_auth(&self, outcome: PluginLoadOutcome) -> PluginLoadOutcome {
|
||||
project_plugin_load_outcome_for_auth(outcome, self.auth_mode())
|
||||
}
|
||||
|
||||
pub fn clear_cache(&self) {
|
||||
@@ -468,14 +501,15 @@ impl PluginsManager {
|
||||
if !config.plugins_enabled {
|
||||
return PluginLoadOutcome::default();
|
||||
}
|
||||
load_plugins_from_layer_stack(
|
||||
let outcome = load_plugins_from_layer_stack(
|
||||
config_layer_stack,
|
||||
self.remote_installed_plugin_configs(),
|
||||
&self.store,
|
||||
self.restriction_product,
|
||||
config.remote_plugin_enabled,
|
||||
)
|
||||
.await
|
||||
.await;
|
||||
self.project_plugins_for_auth(outcome)
|
||||
}
|
||||
|
||||
/// Resolve plugin hooks for a config layer stack without loading other plugin capabilities.
|
||||
@@ -992,14 +1026,19 @@ impl PluginsManager {
|
||||
&self,
|
||||
config: &PluginsConfigInput,
|
||||
additional_roots: &[AbsolutePathBuf],
|
||||
include_openai_curated: bool,
|
||||
) -> Result<ConfiguredMarketplaceListOutcome, MarketplaceError> {
|
||||
if !config.plugins_enabled {
|
||||
return Ok(ConfiguredMarketplaceListOutcome::default());
|
||||
}
|
||||
|
||||
let (installed_plugins, enabled_plugins) = self.configured_plugin_states(config);
|
||||
let marketplace_outcome =
|
||||
self.discover_marketplaces_for_config(config, additional_roots)?;
|
||||
let mut marketplace_roots = self.marketplace_roots(config, additional_roots);
|
||||
if !include_openai_curated {
|
||||
let curated_repo_root = curated_plugins_repo_path(self.codex_home.as_path());
|
||||
marketplace_roots.retain(|root| root.as_path() != curated_repo_root.as_path());
|
||||
}
|
||||
let marketplace_outcome = list_marketplaces(&marketplace_roots)?;
|
||||
let mut seen_plugin_keys = HashSet::new();
|
||||
let marketplaces = marketplace_outcome
|
||||
.marketplaces
|
||||
|
||||
@@ -17,6 +17,7 @@ use crate::test_support::load_plugins_config as load_plugins_config_input;
|
||||
use crate::test_support::write_curated_plugin_sha_with as write_curated_plugin_sha;
|
||||
use crate::test_support::write_file;
|
||||
use crate::test_support::write_openai_curated_marketplace;
|
||||
use codex_app_server_protocol::AuthMode;
|
||||
use codex_app_server_protocol::ConfigLayerSource;
|
||||
use codex_config::AppToolApproval;
|
||||
use codex_config::CONFIG_TOML_FILE;
|
||||
@@ -47,6 +48,21 @@ use wiremock::matchers::query_param;
|
||||
|
||||
const MAX_CAPABILITY_SUMMARY_DESCRIPTION_LEN: usize = 1024;
|
||||
|
||||
#[test]
|
||||
fn plugins_manager_tracks_auth_mode() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let manager = PluginsManager::new(tmp.path().to_path_buf());
|
||||
|
||||
assert_eq!(manager.auth_mode(), None);
|
||||
assert!(manager.set_auth_mode(Some(AuthMode::ApiKey)));
|
||||
assert_eq!(manager.auth_mode(), Some(AuthMode::ApiKey));
|
||||
assert!(!manager.set_auth_mode(Some(AuthMode::ApiKey)));
|
||||
assert!(manager.set_auth_mode(Some(AuthMode::ChatgptAuthTokens)));
|
||||
assert_eq!(manager.auth_mode(), Some(AuthMode::ChatgptAuthTokens));
|
||||
assert!(manager.set_auth_mode(/*auth_mode*/ None));
|
||||
assert_eq!(manager.auth_mode(), None);
|
||||
}
|
||||
|
||||
fn write_plugin_with_version(
|
||||
root: &Path,
|
||||
dir_name: &str,
|
||||
@@ -1815,7 +1831,11 @@ enabled = false
|
||||
|
||||
let config = load_config(tmp.path(), &repo_root).await;
|
||||
let marketplaces = PluginsManager::new(tmp.path().to_path_buf())
|
||||
.list_marketplaces_for_config(&config, &[AbsolutePathBuf::try_from(repo_root).unwrap()])
|
||||
.list_marketplaces_for_config(
|
||||
&config,
|
||||
&[AbsolutePathBuf::try_from(repo_root).unwrap()],
|
||||
/*include_openai_curated*/ true,
|
||||
)
|
||||
.unwrap()
|
||||
.marketplaces;
|
||||
|
||||
@@ -1917,7 +1937,11 @@ enabled = true
|
||||
|
||||
let config = load_config(tmp.path(), &repo_root).await;
|
||||
let marketplaces = PluginsManager::new(tmp.path().to_path_buf())
|
||||
.list_marketplaces_for_config(&config, &[AbsolutePathBuf::try_from(repo_root).unwrap()])
|
||||
.list_marketplaces_for_config(
|
||||
&config,
|
||||
&[AbsolutePathBuf::try_from(repo_root).unwrap()],
|
||||
/*include_openai_curated*/ true,
|
||||
)
|
||||
.unwrap()
|
||||
.marketplaces;
|
||||
|
||||
@@ -1965,7 +1989,11 @@ plugins = true
|
||||
|
||||
let config = load_config(tmp.path(), &repo_root).await;
|
||||
let marketplaces = PluginsManager::new(tmp.path().to_path_buf())
|
||||
.list_marketplaces_for_config(&config, &[AbsolutePathBuf::try_from(repo_root).unwrap()])
|
||||
.list_marketplaces_for_config(
|
||||
&config,
|
||||
&[AbsolutePathBuf::try_from(repo_root).unwrap()],
|
||||
/*include_openai_curated*/ true,
|
||||
)
|
||||
.unwrap()
|
||||
.marketplaces;
|
||||
|
||||
@@ -2402,7 +2430,11 @@ enabled = true
|
||||
|
||||
let config = load_config(tmp.path(), &repo_root).await;
|
||||
let marketplaces = PluginsManager::new(tmp.path().to_path_buf())
|
||||
.list_marketplaces_for_config(&config, &[AbsolutePathBuf::try_from(repo_root).unwrap()])
|
||||
.list_marketplaces_for_config(
|
||||
&config,
|
||||
&[AbsolutePathBuf::try_from(repo_root).unwrap()],
|
||||
/*include_openai_curated*/ true,
|
||||
)
|
||||
.unwrap()
|
||||
.marketplaces;
|
||||
|
||||
@@ -2496,7 +2528,7 @@ plugins = true
|
||||
|
||||
let config = load_config(tmp.path(), tmp.path()).await;
|
||||
let marketplaces = PluginsManager::new(tmp.path().to_path_buf())
|
||||
.list_marketplaces_for_config(&config, &[])
|
||||
.list_marketplaces_for_config(&config, &[], /*include_openai_curated*/ true)
|
||||
.unwrap()
|
||||
.marketplaces;
|
||||
|
||||
@@ -2534,6 +2566,37 @@ plugins = true
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn list_marketplaces_can_skip_openai_curated_before_loading() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let curated_root = curated_plugins_repo_path(tmp.path());
|
||||
|
||||
write_file(
|
||||
&tmp.path().join(CONFIG_TOML_FILE),
|
||||
r#"[features]
|
||||
plugins = true
|
||||
"#,
|
||||
);
|
||||
write_file(
|
||||
&curated_root.join(".agents/plugins/marketplace.json"),
|
||||
"{not valid json",
|
||||
);
|
||||
|
||||
let config = load_config(tmp.path(), tmp.path()).await;
|
||||
let outcome = PluginsManager::new(tmp.path().to_path_buf())
|
||||
.list_marketplaces_for_config(&config, &[], /*include_openai_curated*/ false)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(outcome.errors, Vec::new());
|
||||
assert_eq!(
|
||||
outcome
|
||||
.marketplaces
|
||||
.iter()
|
||||
.any(|marketplace| marketplace.name == OPENAI_CURATED_MARKETPLACE_NAME),
|
||||
false
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn list_marketplaces_includes_installed_marketplace_roots() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
@@ -2576,7 +2639,7 @@ source = "/tmp/debug"
|
||||
.unwrap();
|
||||
let config = load_config(tmp.path(), tmp.path()).await;
|
||||
let marketplaces = PluginsManager::new(tmp.path().to_path_buf())
|
||||
.list_marketplaces_for_config(&config, &[])
|
||||
.list_marketplaces_for_config(&config, &[], /*include_openai_curated*/ true)
|
||||
.unwrap()
|
||||
.marketplaces;
|
||||
|
||||
@@ -2652,7 +2715,7 @@ source = "/tmp/debug"
|
||||
|
||||
let config = load_config(tmp.path(), tmp.path()).await;
|
||||
let marketplaces = PluginsManager::new(tmp.path().to_path_buf())
|
||||
.list_marketplaces_for_config(&config, &[])
|
||||
.list_marketplaces_for_config(&config, &[], /*include_openai_curated*/ true)
|
||||
.unwrap()
|
||||
.marketplaces;
|
||||
|
||||
@@ -2707,7 +2770,7 @@ plugins = true
|
||||
.unwrap();
|
||||
let config = load_config(tmp.path(), tmp.path()).await;
|
||||
let marketplaces = PluginsManager::new(tmp.path().to_path_buf())
|
||||
.list_marketplaces_for_config(&config, &[])
|
||||
.list_marketplaces_for_config(&config, &[], /*include_openai_curated*/ true)
|
||||
.unwrap()
|
||||
.marketplaces;
|
||||
|
||||
@@ -2792,6 +2855,7 @@ enabled = false
|
||||
AbsolutePathBuf::try_from(repo_a_root).unwrap(),
|
||||
AbsolutePathBuf::try_from(repo_b_root).unwrap(),
|
||||
],
|
||||
/*include_openai_curated*/ true,
|
||||
)
|
||||
.unwrap()
|
||||
.marketplaces;
|
||||
@@ -2902,7 +2966,11 @@ enabled = true
|
||||
|
||||
let config = load_config(tmp.path(), &repo_root).await;
|
||||
let marketplaces = PluginsManager::new(tmp.path().to_path_buf())
|
||||
.list_marketplaces_for_config(&config, &[AbsolutePathBuf::try_from(repo_root).unwrap()])
|
||||
.list_marketplaces_for_config(
|
||||
&config,
|
||||
&[AbsolutePathBuf::try_from(repo_root).unwrap()],
|
||||
/*include_openai_curated*/ true,
|
||||
)
|
||||
.unwrap()
|
||||
.marketplaces;
|
||||
|
||||
|
||||
@@ -22,17 +22,17 @@ pub const SKILL_DESCRIPTION_TRUNCATED_WARNING: &str = "Skill descriptions were s
|
||||
pub const SKILL_DESCRIPTION_TRUNCATED_WARNING_WITH_PERCENT: &str = "Skill descriptions were shortened to fit the 2% skills context budget. Codex can still see every skill, but some descriptions are shorter. Disable unused skills or plugins to leave more room for the rest.";
|
||||
pub const SKILL_DESCRIPTIONS_REMOVED_WARNING_PREFIX: &str =
|
||||
"Exceeded skills context budget. All skill descriptions were removed and";
|
||||
pub const SKILLS_INTRO_WITH_ABSOLUTE_PATHS: &str = "A skill is a set of local instructions to follow that is stored in a `SKILL.md` file. Below is the list of skills that can be used. Each entry includes a name, description, and file path so you can open the source for full instructions when using a specific skill.";
|
||||
pub const SKILLS_INTRO_WITH_ABSOLUTE_PATHS: &str = "A skill is a set of instructions provided through a `SKILL.md` source. Below is the list of skills that can be used. Each entry includes a name, description, and source locator. `file` locators are on the host filesystem, `environment resource` locators are owned by an execution environment, `orchestrator resource` locators are opaque non-filesystem resources, and `custom resource` locators use their provider's access mechanism.";
|
||||
pub const SKILLS_INTRO_WITH_ALIASES: &str = "A skill is a set of local instructions to follow that is stored in a `SKILL.md` file. Below is the list of skills that can be used. Each entry includes a name, description, and a short path that can be expanded into an absolute path using the skill roots table.";
|
||||
pub const SKILLS_HOW_TO_USE_WITH_ABSOLUTE_PATHS: &str = r###"- Discovery: The list above is the skills available in this session (name + description + file path). Skill bodies live on disk at the listed paths.
|
||||
pub const SKILLS_HOW_TO_USE_WITH_ABSOLUTE_PATHS: &str = r###"- Discovery: The list above is the skills available in this session (name + description + source locator). `file` entries live on the host filesystem, `environment resource` entries are owned by their execution environment, `orchestrator resource` entries must be accessed through `skills.list` and `skills.read`, and `custom resource` entries use their provider's access mechanism.
|
||||
- Trigger rules: If the user names a skill (with `$SkillName` or plain text) OR the task clearly matches a skill's description shown above, you must use that skill for that turn. Multiple mentions mean use them all. Do not carry skills across turns unless re-mentioned.
|
||||
- Missing/blocked: If a named skill isn't in the list or the path can't be read, say so briefly and continue with the best fallback.
|
||||
- Missing/blocked: If a named skill isn't in the list or its source can't be read, say so briefly and continue with the best fallback.
|
||||
- How to use a skill (progressive disclosure):
|
||||
1) After deciding to use a skill, the main agent must open and read its `SKILL.md` completely before taking task actions. If a read is truncated or paginated, continue until EOF.
|
||||
2) When `SKILL.md` references relative paths (e.g., `scripts/foo.py`), resolve them relative to the skill directory listed above first, and only consider other paths if needed.
|
||||
3) If `SKILL.md` points to extra folders such as `references/`, use its routing instructions to identify the files required for the task. The main agent must read each required instruction or reference file itself before acting on it. Do not delegate reading, summarizing, or interpreting skill instructions to a subagent. Subagents may still perform task work when the selected skill allows it.
|
||||
4) If `scripts/` exist, prefer running or patching them instead of retyping large code blocks.
|
||||
5) If `assets/` or templates exist, reuse them instead of recreating from scratch.
|
||||
1) After deciding to use a skill, the main agent must read its `SKILL.md` completely before taking task actions. For a `file` entry, open the listed path. For an `environment resource`, use the filesystem of the owning environment. For an `orchestrator resource`, call `skills.list` with `{"authority":{"kind":"orchestrator"}}`, select the matching package, and pass its `main_resource` to `skills.read`. If a read is truncated or paginated, continue until EOF.
|
||||
2) When `SKILL.md` references another resource, use the same access mechanism. Resolve relative paths against a filesystem-backed skill directory. For orchestrator skills, pass the exact referenced resource identifier with the same authority and package to `skills.read`; do not treat `skill://` identifiers as filesystem paths.
|
||||
3) If `SKILL.md` points to extra folders such as `references/`, use its routing instructions to identify the resources required for the task. The main agent must read each required instruction or reference file itself before acting on it. Do not delegate reading, summarizing, or interpreting skill instructions to a subagent. Subagents may still perform task work when the selected skill allows it.
|
||||
4) For filesystem-backed skills, prefer running or patching provided scripts instead of retyping large code blocks. For orchestrator skills, use `skills.read` and the available tools; do not invent a local path.
|
||||
5) Reuse provided assets or templates through the same source access mechanism instead of recreating them.
|
||||
- Coordination and sequencing:
|
||||
- If multiple skills apply, choose the minimal set that covers the request and state the order you'll use them.
|
||||
- Announce which skill(s) you're using and why (one short line). If you skip an obvious skill, say why.
|
||||
|
||||
@@ -226,18 +226,6 @@
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"AppsMcpPathOverrideConfigToml": {
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"enabled": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"path": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"AskForApproval": {
|
||||
"description": "Determines the conditions under which the user is consulted to approve running the command proposed by Codex.",
|
||||
"oneOf": [
|
||||
@@ -408,7 +396,23 @@
|
||||
"type": "boolean"
|
||||
},
|
||||
"apps_mcp_path_override": {
|
||||
"$ref": "#/definitions/FeatureToml_for_AppsMcpPathOverrideConfigToml"
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "boolean"
|
||||
},
|
||||
{
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"enabled": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"path": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
}
|
||||
]
|
||||
},
|
||||
"auth_elicitation": {
|
||||
"type": "boolean"
|
||||
@@ -572,6 +576,9 @@
|
||||
"request_rule": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"resize_all_images": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"responses_websockets": {
|
||||
"type": "boolean"
|
||||
},
|
||||
@@ -617,6 +624,9 @@
|
||||
"terminal_visualization_instructions": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"token_budget": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"tool_call_mcp_elicitation": {
|
||||
"type": "boolean"
|
||||
},
|
||||
@@ -836,16 +846,6 @@
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"FeatureToml_for_AppsMcpPathOverrideConfigToml": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "boolean"
|
||||
},
|
||||
{
|
||||
"$ref": "#/definitions/AppsMcpPathOverrideConfigToml"
|
||||
}
|
||||
]
|
||||
},
|
||||
"FeatureToml_for_CodeModeConfigToml": {
|
||||
"anyOf": [
|
||||
{
|
||||
@@ -4534,7 +4534,23 @@
|
||||
"type": "boolean"
|
||||
},
|
||||
"apps_mcp_path_override": {
|
||||
"$ref": "#/definitions/FeatureToml_for_AppsMcpPathOverrideConfigToml"
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "boolean"
|
||||
},
|
||||
{
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"enabled": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"path": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
}
|
||||
]
|
||||
},
|
||||
"auth_elicitation": {
|
||||
"type": "boolean"
|
||||
@@ -4698,6 +4714,9 @@
|
||||
"request_rule": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"resize_all_images": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"responses_websockets": {
|
||||
"type": "boolean"
|
||||
},
|
||||
@@ -4743,6 +4762,9 @@
|
||||
"terminal_visualization_instructions": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"token_budget": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"tool_call_mcp_elicitation": {
|
||||
"type": "boolean"
|
||||
},
|
||||
@@ -5224,4 +5246,4 @@
|
||||
},
|
||||
"title": "ConfigToml",
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
@@ -1097,6 +1097,7 @@ async fn spawn_agent_fork_strips_parent_usage_hints_from_compacted_history() {
|
||||
RolloutItem::Compacted(CompactedItem {
|
||||
message: String::new(),
|
||||
replacement_history: Some(replacement_history),
|
||||
window_id: None,
|
||||
}),
|
||||
RolloutItem::TurnContext(turn_context.to_turn_context_item()),
|
||||
RolloutItem::ResponseItem(spawn_agent_call(&parent_spawn_call_id)),
|
||||
@@ -2490,7 +2491,7 @@ async fn list_agent_subtree_thread_ids_includes_anonymous_and_closed_descendants
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn list_agent_subtree_thread_ids_includes_live_descendants_without_state_db() {
|
||||
async fn list_agent_subtree_thread_ids_finds_live_descendants_of_unloaded_root() {
|
||||
let (_home, config) = test_config().await;
|
||||
let manager = ThreadManager::with_models_provider_home_and_state_for_tests(
|
||||
CodexAuth::from_api_key("dummy"),
|
||||
@@ -2535,6 +2536,8 @@ async fn list_agent_subtree_thread_ids_includes_live_descendants_without_state_d
|
||||
.await
|
||||
.expect("grandchild spawn should succeed");
|
||||
|
||||
manager.remove_thread(&parent_thread_id).await;
|
||||
|
||||
let mut subtree_thread_ids = manager
|
||||
.list_agent_subtree_thread_ids(parent_thread_id)
|
||||
.await
|
||||
|
||||
+33
-37
@@ -28,7 +28,6 @@ use std::sync::Arc;
|
||||
use std::sync::Mutex as StdMutex;
|
||||
use std::sync::OnceLock;
|
||||
use std::sync::atomic::AtomicBool;
|
||||
use std::sync::atomic::AtomicU64;
|
||||
use std::sync::atomic::Ordering;
|
||||
|
||||
use codex_api::ApiError;
|
||||
@@ -172,7 +171,6 @@ pub(crate) struct CompactConversationRequestSettings {
|
||||
struct ModelClientState {
|
||||
session_id: SessionId,
|
||||
thread_id: ThreadId,
|
||||
window_generation: AtomicU64,
|
||||
installation_id: String,
|
||||
provider: SharedModelProvider,
|
||||
auth_env_telemetry: AuthEnvTelemetry,
|
||||
@@ -345,7 +343,6 @@ impl ModelClient {
|
||||
state: Arc::new(ModelClientState {
|
||||
session_id,
|
||||
thread_id,
|
||||
window_generation: AtomicU64::new(0),
|
||||
installation_id,
|
||||
provider: model_provider,
|
||||
auth_env_telemetry,
|
||||
@@ -394,24 +391,6 @@ impl ModelClient {
|
||||
self.state.provider.auth_manager()
|
||||
}
|
||||
|
||||
pub(crate) fn set_window_generation(&self, window_generation: u64) {
|
||||
self.state
|
||||
.window_generation
|
||||
.store(window_generation, Ordering::Relaxed);
|
||||
self.store_cached_websocket_session(WebsocketSession::default());
|
||||
}
|
||||
|
||||
pub(crate) fn advance_window_generation(&self) {
|
||||
self.state.window_generation.fetch_add(1, Ordering::Relaxed);
|
||||
self.store_cached_websocket_session(WebsocketSession::default());
|
||||
}
|
||||
|
||||
pub(crate) fn current_window_id(&self) -> String {
|
||||
let thread_id = self.state.thread_id;
|
||||
let window_generation = self.state.window_generation.load(Ordering::Relaxed);
|
||||
format!("{thread_id}:{window_generation}")
|
||||
}
|
||||
|
||||
fn take_cached_websocket_session(&self) -> WebsocketSession {
|
||||
let mut cached_websocket_session = self
|
||||
.state
|
||||
@@ -457,6 +436,7 @@ impl ModelClient {
|
||||
///
|
||||
/// The model selection and telemetry context are passed explicitly to keep `ModelClient`
|
||||
/// session-scoped.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(crate) async fn compact_conversation_history(
|
||||
&self,
|
||||
prompt: &Prompt,
|
||||
@@ -464,6 +444,7 @@ impl ModelClient {
|
||||
settings: CompactConversationRequestSettings,
|
||||
session_telemetry: &SessionTelemetry,
|
||||
compaction_trace: &CompactionTraceContext,
|
||||
window_id: &str,
|
||||
turn_metadata_header: Option<&str>,
|
||||
) -> Result<Vec<ResponseItem>> {
|
||||
if prompt.input.is_empty() {
|
||||
@@ -488,6 +469,7 @@ impl ModelClient {
|
||||
settings.effort,
|
||||
settings.summary,
|
||||
settings.service_tier,
|
||||
window_id,
|
||||
)?;
|
||||
let ResponsesApiRequest {
|
||||
model,
|
||||
@@ -522,7 +504,7 @@ impl ModelClient {
|
||||
/*turn_state*/ None,
|
||||
parse_turn_metadata_header(turn_metadata_header).as_ref(),
|
||||
));
|
||||
extra_headers.extend(self.build_responses_identity_headers());
|
||||
extra_headers.extend(self.build_responses_identity_headers(Some(window_id)));
|
||||
extra_headers.extend(build_session_headers(
|
||||
Some(self.state.session_id.to_string()),
|
||||
Some(self.state.thread_id.to_string()),
|
||||
@@ -644,14 +626,16 @@ impl ModelClient {
|
||||
extra_headers
|
||||
}
|
||||
|
||||
fn build_responses_identity_headers(&self) -> ApiHeaderMap {
|
||||
fn build_responses_identity_headers(&self, window_id: Option<&str>) -> ApiHeaderMap {
|
||||
let mut extra_headers = self.build_subagent_headers();
|
||||
if let Some(parent_thread_id) = parent_thread_id_header_value(self.state.parent_thread_id)
|
||||
&& let Ok(val) = HeaderValue::from_str(&parent_thread_id)
|
||||
{
|
||||
extra_headers.insert(X_CODEX_PARENT_THREAD_ID_HEADER, val);
|
||||
}
|
||||
if let Ok(val) = HeaderValue::from_str(&self.current_window_id()) {
|
||||
if let Some(window_id) = window_id
|
||||
&& let Ok(val) = HeaderValue::from_str(window_id)
|
||||
{
|
||||
extra_headers.insert(X_CODEX_WINDOW_ID_HEADER, val);
|
||||
}
|
||||
extra_headers
|
||||
@@ -659,6 +643,7 @@ impl ModelClient {
|
||||
|
||||
fn build_ws_client_metadata(
|
||||
&self,
|
||||
window_id: &str,
|
||||
turn_metadata_header: Option<&str>,
|
||||
use_responses_lite: bool,
|
||||
) -> HashMap<String, String> {
|
||||
@@ -667,10 +652,7 @@ impl ModelClient {
|
||||
X_CODEX_INSTALLATION_ID_HEADER.to_string(),
|
||||
self.state.installation_id.clone(),
|
||||
);
|
||||
client_metadata.insert(
|
||||
X_CODEX_WINDOW_ID_HEADER.to_string(),
|
||||
self.current_window_id(),
|
||||
);
|
||||
client_metadata.insert(X_CODEX_WINDOW_ID_HEADER.to_string(), window_id.to_string());
|
||||
if let Some(subagent) = subagent_header_value(&self.state.session_source) {
|
||||
client_metadata.insert(X_OPENAI_SUBAGENT_HEADER.to_string(), subagent);
|
||||
}
|
||||
@@ -752,6 +734,7 @@ impl ModelClient {
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn build_responses_request(
|
||||
&self,
|
||||
provider: &codex_api::Provider,
|
||||
@@ -760,9 +743,10 @@ impl ModelClient {
|
||||
effort: Option<ReasoningEffortConfig>,
|
||||
summary: ReasoningSummaryConfig,
|
||||
service_tier: Option<String>,
|
||||
window_id: &str,
|
||||
) -> Result<ResponsesApiRequest> {
|
||||
let instructions = &prompt.base_instructions.text;
|
||||
let input = prompt.get_formatted_input();
|
||||
let input = prompt.get_formatted_input_for_request(model_info.use_responses_lite);
|
||||
let tools = create_tools_json_for_responses_api(&prompt.tools)?;
|
||||
let reasoning = Self::build_reasoning(model_info, effort, summary);
|
||||
let include = if reasoning.is_some() {
|
||||
@@ -807,10 +791,7 @@ impl ModelClient {
|
||||
X_CODEX_INSTALLATION_ID_HEADER.to_string(),
|
||||
self.state.installation_id.clone(),
|
||||
),
|
||||
(
|
||||
X_CODEX_WINDOW_ID_HEADER.to_string(),
|
||||
self.current_window_id(),
|
||||
),
|
||||
(X_CODEX_WINDOW_ID_HEADER.to_string(), window_id.to_string()),
|
||||
])),
|
||||
};
|
||||
Ok(request)
|
||||
@@ -955,7 +936,7 @@ impl ModelClient {
|
||||
headers.insert("x-client-request-id", header_value);
|
||||
}
|
||||
headers.extend(build_session_headers(Some(session_id), Some(thread_id)));
|
||||
headers.extend(self.build_responses_identity_headers());
|
||||
headers.extend(self.build_responses_identity_headers(/*window_id*/ None));
|
||||
if let Some(header_value) = self.generate_attestation_header_for().await {
|
||||
headers.insert(X_OAI_ATTESTATION_HEADER, header_value);
|
||||
}
|
||||
@@ -998,6 +979,7 @@ impl ModelClientSession {
|
||||
/// regardless of transport choice.
|
||||
async fn build_responses_options(
|
||||
&self,
|
||||
window_id: &str,
|
||||
turn_metadata_header: Option<&str>,
|
||||
compression: Compression,
|
||||
use_responses_lite: bool,
|
||||
@@ -1015,7 +997,10 @@ impl ModelClientSession {
|
||||
Some(&self.turn_state),
|
||||
turn_metadata_header.as_ref(),
|
||||
);
|
||||
headers.extend(self.client.build_responses_identity_headers());
|
||||
headers.extend(
|
||||
self.client
|
||||
.build_responses_identity_headers(Some(window_id)),
|
||||
);
|
||||
if let Some(header_value) = self.client.generate_attestation_header_for().await {
|
||||
headers.insert(X_OAI_ATTESTATION_HEADER, header_value);
|
||||
}
|
||||
@@ -1115,7 +1100,6 @@ impl ModelClientSession {
|
||||
pub async fn preconnect_websocket(
|
||||
&mut self,
|
||||
session_telemetry: &SessionTelemetry,
|
||||
_model_info: &ModelInfo,
|
||||
) -> std::result::Result<(), ApiError> {
|
||||
if !self.client.responses_websocket_enabled() {
|
||||
return Ok(());
|
||||
@@ -1257,6 +1241,7 @@ impl ModelClientSession {
|
||||
)]
|
||||
async fn stream_responses_api(
|
||||
&self,
|
||||
window_id: &str,
|
||||
prompt: &Prompt,
|
||||
model_info: &ModelInfo,
|
||||
session_telemetry: &SessionTelemetry,
|
||||
@@ -1288,6 +1273,7 @@ impl ModelClientSession {
|
||||
let compression = self.responses_request_compression(client_setup.auth.as_ref());
|
||||
let mut options = self
|
||||
.build_responses_options(
|
||||
window_id,
|
||||
turn_metadata_header,
|
||||
compression,
|
||||
model_info.use_responses_lite,
|
||||
@@ -1301,6 +1287,7 @@ impl ModelClientSession {
|
||||
effort.clone(),
|
||||
summary,
|
||||
service_tier.clone(),
|
||||
window_id,
|
||||
)?;
|
||||
let inference_trace_attempt = inference_trace.start_attempt();
|
||||
inference_trace_attempt.add_request_headers(&mut options.extra_headers);
|
||||
@@ -1374,6 +1361,7 @@ impl ModelClientSession {
|
||||
)]
|
||||
async fn stream_responses_websocket(
|
||||
&mut self,
|
||||
window_id: &str,
|
||||
prompt: &Prompt,
|
||||
model_info: &ModelInfo,
|
||||
session_telemetry: &SessionTelemetry,
|
||||
@@ -1402,6 +1390,7 @@ impl ModelClientSession {
|
||||
|
||||
let options = self
|
||||
.build_responses_options(
|
||||
window_id,
|
||||
turn_metadata_header,
|
||||
compression,
|
||||
model_info.use_responses_lite,
|
||||
@@ -1414,10 +1403,12 @@ impl ModelClientSession {
|
||||
effort.clone(),
|
||||
summary,
|
||||
service_tier.clone(),
|
||||
window_id,
|
||||
)?;
|
||||
let mut ws_payload = ResponseCreateWsRequest {
|
||||
client_metadata: response_create_client_metadata(
|
||||
Some(self.client.build_ws_client_metadata(
|
||||
window_id,
|
||||
turn_metadata_header,
|
||||
model_info.use_responses_lite,
|
||||
)),
|
||||
@@ -1553,6 +1544,7 @@ impl ModelClientSession {
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub async fn prewarm_websocket(
|
||||
&mut self,
|
||||
window_id: &str,
|
||||
prompt: &Prompt,
|
||||
model_info: &ModelInfo,
|
||||
session_telemetry: &SessionTelemetry,
|
||||
@@ -1571,6 +1563,7 @@ impl ModelClientSession {
|
||||
let disabled_trace = InferenceTraceContext::disabled();
|
||||
match self
|
||||
.stream_responses_websocket(
|
||||
window_id,
|
||||
prompt,
|
||||
model_info,
|
||||
session_telemetry,
|
||||
@@ -1614,6 +1607,7 @@ impl ModelClientSession {
|
||||
/// branches.
|
||||
pub async fn stream(
|
||||
&mut self,
|
||||
window_id: &str,
|
||||
prompt: &Prompt,
|
||||
model_info: &ModelInfo,
|
||||
session_telemetry: &SessionTelemetry,
|
||||
@@ -1630,6 +1624,7 @@ impl ModelClientSession {
|
||||
let request_trace = current_span_w3c_trace_context();
|
||||
match self
|
||||
.stream_responses_websocket(
|
||||
window_id,
|
||||
prompt,
|
||||
model_info,
|
||||
session_telemetry,
|
||||
@@ -1651,6 +1646,7 @@ impl ModelClientSession {
|
||||
}
|
||||
|
||||
self.stream_responses_api(
|
||||
window_id,
|
||||
prompt,
|
||||
model_info,
|
||||
session_telemetry,
|
||||
@@ -1989,7 +1985,7 @@ impl AuthRequestTelemetryContext {
|
||||
let auth_telemetry = auth_header_telemetry(api_auth);
|
||||
Self {
|
||||
auth_mode: auth_mode.map(|mode| match mode {
|
||||
AuthMode::ApiKey => "ApiKey",
|
||||
AuthMode::ApiKey | AuthMode::BedrockApiKey => "ApiKey",
|
||||
AuthMode::Chatgpt
|
||||
| AuthMode::ChatgptAuthTokens
|
||||
| AuthMode::AgentIdentity
|
||||
|
||||
@@ -2,6 +2,8 @@ pub use codex_api::ResponseEvent;
|
||||
use codex_config::types::Personality;
|
||||
use codex_protocol::error::Result;
|
||||
use codex_protocol::models::BaseInstructions;
|
||||
use codex_protocol::models::ContentItem;
|
||||
use codex_protocol::models::FunctionCallOutputContentItem;
|
||||
use codex_protocol::models::ResponseItem;
|
||||
use codex_protocol::protocol::InterAgentCommunication;
|
||||
use codex_tools::ToolSpec;
|
||||
@@ -71,6 +73,56 @@ impl Prompt {
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub(crate) fn get_formatted_input_for_request(
|
||||
&self,
|
||||
use_responses_lite: bool,
|
||||
) -> Vec<ResponseItem> {
|
||||
let mut input = self.get_formatted_input();
|
||||
if use_responses_lite {
|
||||
strip_image_details(&mut input);
|
||||
}
|
||||
input
|
||||
}
|
||||
}
|
||||
|
||||
fn strip_image_details(items: &mut [ResponseItem]) {
|
||||
for item in items {
|
||||
match item {
|
||||
ResponseItem::Message { content, .. } => {
|
||||
for content_item in content {
|
||||
if let ContentItem::InputImage { detail, .. } = content_item {
|
||||
*detail = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
ResponseItem::FunctionCallOutput { output, .. }
|
||||
| ResponseItem::CustomToolCallOutput { output, .. } => {
|
||||
if let Some(content) = output.content_items_mut() {
|
||||
for content_item in content {
|
||||
if let FunctionCallOutputContentItem::InputImage { detail, .. } =
|
||||
content_item
|
||||
{
|
||||
*detail = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
ResponseItem::Reasoning { .. }
|
||||
| ResponseItem::AgentMessage { .. }
|
||||
| ResponseItem::LocalShellCall { .. }
|
||||
| ResponseItem::FunctionCall { .. }
|
||||
| ResponseItem::ToolSearchCall { .. }
|
||||
| ResponseItem::CustomToolCall { .. }
|
||||
| ResponseItem::ToolSearchOutput { .. }
|
||||
| ResponseItem::WebSearchCall { .. }
|
||||
| ResponseItem::ImageGenerationCall { .. }
|
||||
| ResponseItem::Compaction { .. }
|
||||
| ResponseItem::CompactionTrigger
|
||||
| ResponseItem::ContextCompaction { .. }
|
||||
| ResponseItem::Other => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct ResponseStream {
|
||||
|
||||
@@ -3,10 +3,95 @@ use codex_api::ResponsesApiRequest;
|
||||
use codex_api::TextControls;
|
||||
use codex_api::create_text_param_for_request;
|
||||
use codex_protocol::config_types::ServiceTier;
|
||||
use codex_protocol::models::FunctionCallOutputPayload;
|
||||
use codex_protocol::models::ImageDetail;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
use super::*;
|
||||
|
||||
fn prompt_with_image_outputs() -> Prompt {
|
||||
Prompt {
|
||||
input: vec![
|
||||
ResponseItem::Message {
|
||||
id: None,
|
||||
role: "user".to_string(),
|
||||
content: vec![ContentItem::InputImage {
|
||||
image_url: "https://example.com/image.png".to_string(),
|
||||
detail: Some(ImageDetail::Original),
|
||||
}],
|
||||
phase: None,
|
||||
},
|
||||
ResponseItem::FunctionCallOutput {
|
||||
call_id: "function-call".to_string(),
|
||||
output: FunctionCallOutputPayload::from_content_items(vec![
|
||||
FunctionCallOutputContentItem::InputImage {
|
||||
image_url: "data:image/png;base64,function".to_string(),
|
||||
detail: Some(ImageDetail::High),
|
||||
},
|
||||
]),
|
||||
},
|
||||
ResponseItem::CustomToolCallOutput {
|
||||
call_id: "custom-call".to_string(),
|
||||
name: None,
|
||||
output: FunctionCallOutputPayload::from_content_items(vec![
|
||||
FunctionCallOutputContentItem::InputImage {
|
||||
image_url: "data:image/png;base64,custom".to_string(),
|
||||
detail: Some(ImageDetail::Auto),
|
||||
},
|
||||
]),
|
||||
},
|
||||
],
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn responses_lite_request_copies_strip_image_details() {
|
||||
let prompt = prompt_with_image_outputs();
|
||||
let original = prompt.input.clone();
|
||||
|
||||
let stripped = prompt.get_formatted_input_for_request(/*use_responses_lite*/ true);
|
||||
|
||||
assert_eq!(
|
||||
stripped,
|
||||
vec![
|
||||
ResponseItem::Message {
|
||||
id: None,
|
||||
role: "user".to_string(),
|
||||
content: vec![ContentItem::InputImage {
|
||||
image_url: "https://example.com/image.png".to_string(),
|
||||
detail: None,
|
||||
}],
|
||||
phase: None,
|
||||
},
|
||||
ResponseItem::FunctionCallOutput {
|
||||
call_id: "function-call".to_string(),
|
||||
output: FunctionCallOutputPayload::from_content_items(vec![
|
||||
FunctionCallOutputContentItem::InputImage {
|
||||
image_url: "data:image/png;base64,function".to_string(),
|
||||
detail: None,
|
||||
},
|
||||
]),
|
||||
},
|
||||
ResponseItem::CustomToolCallOutput {
|
||||
call_id: "custom-call".to_string(),
|
||||
name: None,
|
||||
output: FunctionCallOutputPayload::from_content_items(vec![
|
||||
FunctionCallOutputContentItem::InputImage {
|
||||
image_url: "data:image/png;base64,custom".to_string(),
|
||||
detail: None,
|
||||
},
|
||||
]),
|
||||
},
|
||||
]
|
||||
);
|
||||
assert_eq!(prompt.input, original);
|
||||
assert_eq!(
|
||||
prompt.get_formatted_input_for_request(/*use_responses_lite*/ false),
|
||||
original
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serializes_text_verbosity_when_set() {
|
||||
let input: Vec<ResponseItem> = vec![];
|
||||
|
||||
@@ -291,13 +291,13 @@ fn build_ws_client_metadata_includes_window_lineage_and_turn_metadata() {
|
||||
Some(parent_thread_id),
|
||||
);
|
||||
|
||||
client.advance_window_generation();
|
||||
|
||||
let thread_id = client.state.thread_id;
|
||||
let window_id = format!("{thread_id}:1");
|
||||
let client_metadata = client.build_ws_client_metadata(
|
||||
&window_id,
|
||||
Some(r#"{"turn_id":"turn-123"}"#),
|
||||
/*use_responses_lite*/ false,
|
||||
);
|
||||
let thread_id = client.state.thread_id;
|
||||
assert_eq!(
|
||||
client_metadata,
|
||||
std::collections::HashMap::from([
|
||||
|
||||
@@ -452,7 +452,7 @@ async fn delegated_mcp_guardian_abort_returns_synthetic_decline_answer() {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn delegated_mcp_user_reviewer_waits_for_metadata_lookup() {
|
||||
async fn delegated_mcp_user_reviewer_returns_none_without_metadata() {
|
||||
let (parent_session, parent_ctx, _rx_events) =
|
||||
crate::session::tests::make_session_and_context_with_rx().await;
|
||||
let pending_mcp_invocations = Arc::new(Mutex::new(HashMap::from([(
|
||||
@@ -464,21 +464,6 @@ async fn delegated_mcp_user_reviewer_waits_for_metadata_lookup() {
|
||||
},
|
||||
)])));
|
||||
let cancel_token = CancellationToken::new();
|
||||
let manager = Arc::clone(&parent_session.services.mcp_connection_manager);
|
||||
let (manager_locked_tx, manager_locked_rx) = std::sync::mpsc::sync_channel(0);
|
||||
let (release_manager_tx, release_manager_rx) = std::sync::mpsc::sync_channel(0);
|
||||
let manager_lock = tokio::task::spawn_blocking(move || {
|
||||
let _manager_guard = manager.blocking_write();
|
||||
manager_locked_tx
|
||||
.send(())
|
||||
.expect("manager lock receiver should remain open");
|
||||
release_manager_rx
|
||||
.recv()
|
||||
.expect("manager lock release sender should remain open");
|
||||
});
|
||||
manager_locked_rx
|
||||
.recv_timeout(Duration::from_secs(1))
|
||||
.expect("manager write lock should be acquired");
|
||||
|
||||
let event = RequestUserInputEvent {
|
||||
call_id: "call-1".to_string(),
|
||||
@@ -498,24 +483,7 @@ async fn delegated_mcp_user_reviewer_waits_for_metadata_lookup() {
|
||||
&pending_mcp_invocations,
|
||||
&event,
|
||||
&cancel_token,
|
||||
);
|
||||
tokio::pin!(response);
|
||||
assert!(
|
||||
timeout(Duration::from_millis(100), &mut response)
|
||||
.await
|
||||
.is_err(),
|
||||
"manual reviewer should wait for MCP metadata"
|
||||
);
|
||||
release_manager_tx
|
||||
.send(())
|
||||
.expect("manager lock holder should remain open");
|
||||
manager_lock
|
||||
.await
|
||||
.expect("manager lock task should not panic");
|
||||
assert_eq!(
|
||||
timeout(Duration::from_secs(1), response)
|
||||
.await
|
||||
.expect("manual reviewer should finish after MCP metadata lookup"),
|
||||
None
|
||||
);
|
||||
)
|
||||
.await;
|
||||
assert_eq!(response, None);
|
||||
}
|
||||
|
||||
@@ -159,6 +159,14 @@ pub struct CodexThread {
|
||||
out_of_band_elicitation_count: Mutex<u64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Eq, PartialEq)]
|
||||
pub struct BackgroundTerminalInfo {
|
||||
pub item_id: String,
|
||||
pub process_id: String,
|
||||
pub command: String,
|
||||
pub cwd: AbsolutePathBuf,
|
||||
}
|
||||
|
||||
/// Conduit for the bidirectional stream of messages that compose a thread
|
||||
/// (formerly called a conversation) in Codex.
|
||||
impl CodexThread {
|
||||
@@ -396,6 +404,17 @@ impl CodexThread {
|
||||
self.codex.agent_status().await
|
||||
}
|
||||
|
||||
pub async fn list_background_terminals(&self) -> Vec<BackgroundTerminalInfo> {
|
||||
self.codex.session.list_background_terminals().await
|
||||
}
|
||||
|
||||
pub async fn terminate_background_terminal(&self, process_id: i32) -> bool {
|
||||
self.codex
|
||||
.session
|
||||
.terminate_background_terminal(process_id)
|
||||
.await
|
||||
}
|
||||
|
||||
pub(crate) fn subscribe_status(&self) -> watch::Receiver<AgentStatus> {
|
||||
self.codex.agent_status.clone()
|
||||
}
|
||||
|
||||
@@ -143,17 +143,17 @@ async fn run_compact_task_inner(
|
||||
let pre_compact_outcome = run_pre_compact_hooks(&sess, &turn_context, trigger).await;
|
||||
match pre_compact_outcome {
|
||||
PreCompactHookOutcome::Continue => {}
|
||||
PreCompactHookOutcome::Stopped { reason } => {
|
||||
let error = reason.unwrap_or_else(|| "PreCompact hook stopped execution".to_string());
|
||||
PreCompactHookOutcome::Stopped => {
|
||||
let error = CodexErr::TurnAborted;
|
||||
attempt
|
||||
.track(
|
||||
sess.as_ref(),
|
||||
CompactionStatus::Interrupted,
|
||||
Some(error),
|
||||
Some(&error),
|
||||
CompactionAnalyticsDetails::default(),
|
||||
)
|
||||
.await;
|
||||
return Err(CodexErr::TurnAborted);
|
||||
return Err(error);
|
||||
}
|
||||
}
|
||||
let result = run_compact_task_inner_impl(
|
||||
@@ -165,7 +165,7 @@ async fn run_compact_task_inner(
|
||||
)
|
||||
.await;
|
||||
let status = compaction_status_from_result(&result);
|
||||
let error = result.as_ref().err().map(ToString::to_string);
|
||||
let codex_error = result.as_ref().err();
|
||||
if result.is_ok() {
|
||||
let post_compact_outcome = run_post_compact_hooks(&sess, &turn_context, trigger).await;
|
||||
if let PostCompactHookOutcome::Stopped = post_compact_outcome {
|
||||
@@ -173,7 +173,7 @@ async fn run_compact_task_inner(
|
||||
.track(
|
||||
sess.as_ref(),
|
||||
status,
|
||||
error,
|
||||
codex_error,
|
||||
CompactionAnalyticsDetails::default(),
|
||||
)
|
||||
.await;
|
||||
@@ -184,7 +184,7 @@ async fn run_compact_task_inner(
|
||||
.track(
|
||||
sess.as_ref(),
|
||||
status,
|
||||
error,
|
||||
codex_error,
|
||||
CompactionAnalyticsDetails::default(),
|
||||
)
|
||||
.await;
|
||||
@@ -228,7 +228,7 @@ async fn run_compact_task_inner_impl(
|
||||
personality: turn_context.personality,
|
||||
..Default::default()
|
||||
};
|
||||
let window_id = sess.services.model_client.current_window_id();
|
||||
let window_id = sess.current_window_id().await;
|
||||
let turn_metadata_header = turn_context
|
||||
.turn_metadata_state
|
||||
.current_header_value_for_compaction(&window_id, compaction_metadata);
|
||||
@@ -236,6 +236,7 @@ async fn run_compact_task_inner_impl(
|
||||
&sess,
|
||||
turn_context.as_ref(),
|
||||
&mut client_session,
|
||||
&window_id,
|
||||
turn_metadata_header.as_deref(),
|
||||
&prompt,
|
||||
)
|
||||
@@ -293,6 +294,7 @@ async fn run_compact_task_inner_impl(
|
||||
let user_messages = collect_user_messages(history_items);
|
||||
|
||||
let mut new_history = build_compacted_history(Vec::new(), &user_messages, &summary_text);
|
||||
let window_id = sess.advance_auto_compact_window_id().await;
|
||||
|
||||
if matches!(
|
||||
initial_context_injection,
|
||||
@@ -309,6 +311,7 @@ async fn run_compact_task_inner_impl(
|
||||
let compacted_item = CompactedItem {
|
||||
message: summary_text.clone(),
|
||||
replacement_history: Some(new_history.clone()),
|
||||
window_id: Some(window_id),
|
||||
};
|
||||
sess.replace_compacted_history(new_history, reference_context_item, compacted_item)
|
||||
.await;
|
||||
@@ -340,6 +343,7 @@ pub(crate) struct CompactionAnalyticsDetails {
|
||||
pub(crate) active_context_tokens_before: Option<i64>,
|
||||
pub(crate) retained_image_count: Option<usize>,
|
||||
pub(crate) compaction_summary_tokens: Option<i64>,
|
||||
pub(crate) cached_input_tokens: Option<i64>,
|
||||
}
|
||||
|
||||
impl CompactionAnalyticsAttempt {
|
||||
@@ -369,13 +373,14 @@ impl CompactionAnalyticsAttempt {
|
||||
self,
|
||||
sess: &Session,
|
||||
status: CompactionStatus,
|
||||
error: Option<String>,
|
||||
codex_error: Option<&CodexErr>,
|
||||
details: CompactionAnalyticsDetails,
|
||||
) {
|
||||
let CompactionAnalyticsDetails {
|
||||
active_context_tokens_before,
|
||||
retained_image_count,
|
||||
compaction_summary_tokens,
|
||||
cached_input_tokens,
|
||||
} = details;
|
||||
let active_context_tokens_before =
|
||||
active_context_tokens_before.unwrap_or(self.active_context_tokens_before);
|
||||
@@ -391,11 +396,14 @@ impl CompactionAnalyticsAttempt {
|
||||
phase: self.phase,
|
||||
strategy: CompactionStrategy::Memento,
|
||||
status,
|
||||
error,
|
||||
codex_error_kind: codex_error.map(Into::into),
|
||||
codex_error_http_status_code: codex_error
|
||||
.and_then(CodexErr::http_status_code_value),
|
||||
active_context_tokens_before,
|
||||
active_context_tokens_after,
|
||||
retained_image_count,
|
||||
compaction_summary_tokens,
|
||||
cached_input_tokens,
|
||||
started_at: self.started_at,
|
||||
completed_at: now_unix_seconds(),
|
||||
duration_ms: Some(
|
||||
@@ -579,11 +587,13 @@ async fn drain_to_completed(
|
||||
sess: &Session,
|
||||
turn_context: &TurnContext,
|
||||
client_session: &mut ModelClientSession,
|
||||
window_id: &str,
|
||||
turn_metadata_header: Option<&str>,
|
||||
prompt: &Prompt,
|
||||
) -> CodexResult<()> {
|
||||
let mut stream = client_session
|
||||
.stream(
|
||||
window_id,
|
||||
prompt,
|
||||
&turn_context.model_info,
|
||||
&turn_context.session_telemetry,
|
||||
|
||||
@@ -113,17 +113,17 @@ async fn run_remote_compact_task_inner(
|
||||
let pre_compact_outcome = run_pre_compact_hooks(sess, turn_context, trigger).await;
|
||||
match pre_compact_outcome {
|
||||
PreCompactHookOutcome::Continue => {}
|
||||
PreCompactHookOutcome::Stopped { reason } => {
|
||||
let error = reason.unwrap_or_else(|| "PreCompact hook stopped execution".to_string());
|
||||
PreCompactHookOutcome::Stopped => {
|
||||
let error = CodexErr::TurnAborted;
|
||||
attempt
|
||||
.track(
|
||||
sess.as_ref(),
|
||||
codex_analytics::CompactionStatus::Interrupted,
|
||||
Some(error),
|
||||
Some(&error),
|
||||
analytics_details,
|
||||
)
|
||||
.await;
|
||||
return Err(CodexErr::TurnAborted);
|
||||
return Err(error);
|
||||
}
|
||||
}
|
||||
let result = run_remote_compact_task_inner_impl(
|
||||
@@ -135,18 +135,18 @@ async fn run_remote_compact_task_inner(
|
||||
)
|
||||
.await;
|
||||
let status = compaction_status_from_result(&result);
|
||||
let error = result.as_ref().err().map(ToString::to_string);
|
||||
let codex_error = result.as_ref().err();
|
||||
if result.is_ok() {
|
||||
let post_compact_outcome = run_post_compact_hooks(sess, turn_context, trigger).await;
|
||||
if let PostCompactHookOutcome::Stopped = post_compact_outcome {
|
||||
attempt
|
||||
.track(sess.as_ref(), status, error, analytics_details)
|
||||
.track(sess.as_ref(), status, codex_error, analytics_details)
|
||||
.await;
|
||||
return Err(CodexErr::TurnAborted);
|
||||
}
|
||||
}
|
||||
attempt
|
||||
.track(sess.as_ref(), status, error.clone(), analytics_details)
|
||||
.track(sess.as_ref(), status, codex_error, analytics_details)
|
||||
.await;
|
||||
if let Err(err) = result {
|
||||
sess.track_turn_codex_error(turn_context, &err);
|
||||
@@ -224,7 +224,7 @@ async fn run_remote_compact_task_inner_impl(
|
||||
output_schema: None,
|
||||
output_schema_strict: true,
|
||||
};
|
||||
let window_id = sess.services.model_client.current_window_id();
|
||||
let window_id = sess.current_window_id().await;
|
||||
let turn_metadata_header = turn_context
|
||||
.turn_metadata_state
|
||||
.current_header_value_for_compaction(&window_id, compaction_metadata);
|
||||
@@ -245,9 +245,11 @@ async fn run_remote_compact_task_inner_impl(
|
||||
},
|
||||
&turn_context.session_telemetry,
|
||||
&compaction_trace,
|
||||
&window_id,
|
||||
turn_metadata_header.as_deref(),
|
||||
)
|
||||
.await?;
|
||||
let new_window_id = sess.advance_auto_compact_window_id().await;
|
||||
new_history = process_compacted_history(
|
||||
sess.as_ref(),
|
||||
turn_context.as_ref(),
|
||||
@@ -263,6 +265,7 @@ async fn run_remote_compact_task_inner_impl(
|
||||
let compacted_item = CompactedItem {
|
||||
message: String::new(),
|
||||
replacement_history: Some(new_history.clone()),
|
||||
window_id: Some(new_window_id),
|
||||
};
|
||||
// Install is the semantic boundary where the compact endpoint's output becomes live
|
||||
// thread history. Keep it distinct from the later inference request so the reducer can
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user