mirror of
https://github.com/openai/codex.git
synced 2026-10-20 17:38:45 +00:00
Merge remote-tracking branch 'origin/main' into imagegen_snadbox
# Conflicts: # codex-rs/ext/extension-api/src/lib.rs
This commit is contained in:
@@ -89,6 +89,11 @@ jobs:
|
||||
with:
|
||||
targets: ${{ matrix.target }}
|
||||
|
||||
- name: Configure LLVM linker
|
||||
uses: ./.github/actions/setup-msvc-env
|
||||
with:
|
||||
target: ${{ matrix.target }}
|
||||
|
||||
- name: Cargo build (Windows binaries)
|
||||
shell: bash
|
||||
run: |
|
||||
|
||||
@@ -8,12 +8,20 @@ retries = 1
|
||||
[profile.default.junit]
|
||||
path = "junit.xml"
|
||||
|
||||
[profile.local]
|
||||
inherits = "default"
|
||||
|
||||
[test-groups.app_server_protocol_codegen]
|
||||
max-threads = 1
|
||||
|
||||
[test-groups.app_server_integration]
|
||||
max-threads = 1
|
||||
|
||||
# Higher concurrency causes integration test timeouts under resource contention
|
||||
# on common developer machines.
|
||||
[test-groups.app_server_integration_local]
|
||||
max-threads = 4
|
||||
|
||||
[test-groups.core_apply_patch_cli_integration]
|
||||
max-threads = 1
|
||||
|
||||
@@ -42,6 +50,12 @@ test-group = 'app_server_protocol_codegen'
|
||||
filter = 'package(codex-app-server) & kind(test)'
|
||||
test-group = 'app_server_integration'
|
||||
|
||||
[[profile.local.overrides]]
|
||||
# Use up to four app-server subprocesses locally. The global nextest pool still
|
||||
# limits this to the machine's logical CPU count.
|
||||
filter = 'package(codex-app-server) & kind(test)'
|
||||
test-group = 'app_server_integration_local'
|
||||
|
||||
[[profile.default.overrides]]
|
||||
# These tests exercise full Codex turns and apply_patch execution, and they are
|
||||
# sensitive to Windows runner process-startup stalls when many cases launch at once.
|
||||
|
||||
Generated
+28
-7
@@ -1892,6 +1892,7 @@ dependencies = [
|
||||
"codex-model-provider",
|
||||
"codex-plugin",
|
||||
"codex-protocol",
|
||||
"codex-state",
|
||||
"codex-utils-absolute-path",
|
||||
"os_info",
|
||||
"pretty_assertions",
|
||||
@@ -1982,6 +1983,7 @@ dependencies = [
|
||||
"codex-image-generation-extension",
|
||||
"codex-login",
|
||||
"codex-mcp",
|
||||
"codex-mcp-extension",
|
||||
"codex-memories-extension",
|
||||
"codex-memories-write",
|
||||
"codex-model-provider",
|
||||
@@ -1994,6 +1996,7 @@ dependencies = [
|
||||
"codex-rollout",
|
||||
"codex-sandboxing",
|
||||
"codex-shell-command",
|
||||
"codex-skills-extension",
|
||||
"codex-state",
|
||||
"codex-thread-store",
|
||||
"codex-tools",
|
||||
@@ -2918,10 +2921,12 @@ dependencies = [
|
||||
name = "codex-extension-api"
|
||||
version = "0.0.0"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"codex-config",
|
||||
"codex-context-fragments",
|
||||
"codex-protocol",
|
||||
"codex-tools",
|
||||
"pretty_assertions",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3042,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",
|
||||
@@ -3063,7 +3068,6 @@ dependencies = [
|
||||
name = "codex-guardian"
|
||||
version = "0.0.0"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"codex-core",
|
||||
"codex-extension-api",
|
||||
"codex-protocol",
|
||||
@@ -3096,7 +3100,6 @@ dependencies = [
|
||||
name = "codex-image-generation-extension"
|
||||
version = "0.0.0"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"codex-api",
|
||||
"codex-core",
|
||||
"codex-extension-api",
|
||||
@@ -3244,6 +3247,22 @@ dependencies = [
|
||||
"url",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "codex-mcp-extension"
|
||||
version = "0.0.0"
|
||||
dependencies = [
|
||||
"codex-config",
|
||||
"codex-core",
|
||||
"codex-core-plugins",
|
||||
"codex-extension-api",
|
||||
"codex-features",
|
||||
"codex-login",
|
||||
"codex-mcp",
|
||||
"pretty_assertions",
|
||||
"tempfile",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "codex-mcp-server"
|
||||
version = "0.0.0"
|
||||
@@ -3280,7 +3299,6 @@ dependencies = [
|
||||
name = "codex-memories-extension"
|
||||
version = "0.0.0"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"codex-core",
|
||||
"codex-extension-api",
|
||||
"codex-features",
|
||||
@@ -3320,6 +3338,8 @@ dependencies = [
|
||||
"codex-features",
|
||||
"codex-git-utils",
|
||||
"codex-login",
|
||||
"codex-model-provider",
|
||||
"codex-model-provider-info",
|
||||
"codex-models-manager",
|
||||
"codex-otel",
|
||||
"codex-protocol",
|
||||
@@ -3796,8 +3816,11 @@ dependencies = [
|
||||
"async-trait",
|
||||
"codex-core",
|
||||
"codex-core-skills",
|
||||
"codex-exec-server",
|
||||
"codex-extension-api",
|
||||
"codex-protocol",
|
||||
"codex-utils-absolute-path",
|
||||
"codex-utils-string",
|
||||
"pretty_assertions",
|
||||
"tokio",
|
||||
]
|
||||
@@ -3890,7 +3913,6 @@ dependencies = [
|
||||
name = "codex-tools"
|
||||
version = "0.0.0"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"codex-app-server-protocol",
|
||||
"codex-code-mode",
|
||||
"codex-features",
|
||||
@@ -4271,7 +4293,6 @@ dependencies = [
|
||||
name = "codex-web-search-extension"
|
||||
version = "0.0.0"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"codex-api",
|
||||
"codex-core",
|
||||
"codex-extension-api",
|
||||
|
||||
@@ -49,6 +49,7 @@ members = [
|
||||
"ext/guardian",
|
||||
"ext/image-generation",
|
||||
"ext/memories",
|
||||
"ext/mcp",
|
||||
"ext/skills",
|
||||
"ext/web-search",
|
||||
"external-agent-migration",
|
||||
@@ -191,6 +192,7 @@ codex-web-search-extension = { path = "ext/web-search" }
|
||||
codex-memories-read = { path = "memories/read" }
|
||||
codex-memories-write = { path = "memories/write" }
|
||||
codex-mcp = { path = "codex-mcp" }
|
||||
codex-mcp-extension = { path = "ext/mcp" }
|
||||
codex-mcp-server = { path = "mcp-server" }
|
||||
codex-model-provider-info = { path = "model-provider-info" }
|
||||
codex-models-manager = { path = "models-manager" }
|
||||
@@ -212,6 +214,7 @@ codex-sandboxing = { path = "sandboxing" }
|
||||
codex-secrets = { path = "secrets" }
|
||||
codex-shell-command = { path = "shell-command" }
|
||||
codex-shell-escalation = { path = "shell-escalation" }
|
||||
codex-skills-extension = { path = "ext/skills" }
|
||||
codex-skills = { path = "skills" }
|
||||
codex-state = { path = "state" }
|
||||
codex-stdio-to-uds = { path = "stdio-to-uds" }
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -1374,7 +1374,7 @@ fn thread_initialized_event_serializes_expected_shape() {
|
||||
},
|
||||
model: "gpt-5".to_string(),
|
||||
ephemeral: true,
|
||||
thread_source: Some(ThreadSource::User),
|
||||
thread_source: Some(ThreadSource::Feature("automation".to_string())),
|
||||
initialization_mode: ThreadInitializationMode::New,
|
||||
subagent_source: None,
|
||||
parent_thread_id: None,
|
||||
@@ -1407,7 +1407,7 @@ fn thread_initialized_event_serializes_expected_shape() {
|
||||
},
|
||||
"model": "gpt-5",
|
||||
"ephemeral": true,
|
||||
"thread_source": "user",
|
||||
"thread_source": "automation",
|
||||
"initialization_mode": "new",
|
||||
"subagent_source": null,
|
||||
"parent_thread_id": null,
|
||||
@@ -1937,6 +1937,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 +2009,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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
@@ -771,6 +778,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,
|
||||
@@ -979,6 +1010,33 @@ pub(crate) fn codex_compaction_event_params(
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
|
||||
@@ -417,6 +417,27 @@ pub struct CodexCompactionEvent {
|
||||
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 +489,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,7 @@ pub use facts::AcceptedLineFingerprint;
|
||||
pub use facts::AnalyticsJsonRpcError;
|
||||
pub use facts::AppInvocation;
|
||||
pub use facts::CodexCompactionEvent;
|
||||
pub use facts::CodexGoalEvent;
|
||||
pub use facts::CodexTurnSteerEvent;
|
||||
pub use facts::CompactionImplementation;
|
||||
pub use facts::CompactionPhase;
|
||||
@@ -31,6 +32,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);
|
||||
}
|
||||
@@ -1263,7 +1279,7 @@ impl AnalyticsReducer {
|
||||
thread_metadata.session_id.clone(),
|
||||
connection_state.app_server_client.clone(),
|
||||
connection_state.runtime.clone(),
|
||||
thread_metadata.thread_source,
|
||||
thread_metadata.thread_source.clone(),
|
||||
thread_metadata.subagent_source.clone(),
|
||||
thread_metadata.parent_thread_id.clone(),
|
||||
),
|
||||
@@ -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,
|
||||
@@ -1368,7 +1404,7 @@ impl AnalyticsReducer {
|
||||
accepted_turn_id,
|
||||
app_server_client: connection_state.app_server_client.clone(),
|
||||
runtime: connection_state.runtime.clone(),
|
||||
thread_source: thread_metadata.thread_source,
|
||||
thread_source: thread_metadata.thread_source.clone(),
|
||||
subagent_source: thread_metadata.subagent_source.clone(),
|
||||
parent_thread_id: thread_metadata.parent_thread_id.clone(),
|
||||
num_input_images: pending_request.num_input_images,
|
||||
@@ -1411,7 +1447,7 @@ impl AnalyticsReducer {
|
||||
review_id: pending_review.review_id,
|
||||
app_server_client: connection_state.app_server_client.clone(),
|
||||
runtime: connection_state.runtime.clone(),
|
||||
thread_source: thread_metadata.thread_source,
|
||||
thread_source: thread_metadata.thread_source.clone(),
|
||||
subagent_source: thread_metadata.subagent_source.clone(),
|
||||
parent_thread_id: thread_metadata.parent_thread_id.clone(),
|
||||
subject_kind: pending_review.subject_kind,
|
||||
@@ -1979,7 +2015,7 @@ fn tool_item_base(
|
||||
item_id,
|
||||
app_server_client: context.connection_state.app_server_client.clone(),
|
||||
runtime: context.connection_state.runtime.clone(),
|
||||
thread_source: thread_metadata.thread_source,
|
||||
thread_source: thread_metadata.thread_source.clone(),
|
||||
subagent_source: thread_metadata.subagent_source.clone(),
|
||||
parent_thread_id: thread_metadata.parent_thread_id.clone(),
|
||||
tool_name,
|
||||
@@ -2459,7 +2495,7 @@ fn codex_turn_event_params(
|
||||
runtime,
|
||||
submission_type,
|
||||
ephemeral,
|
||||
thread_source: thread_metadata.thread_source,
|
||||
thread_source: thread_metadata.thread_source.clone(),
|
||||
initialization_mode: thread_metadata.initialization_mode,
|
||||
subagent_source: thread_metadata.subagent_source.clone(),
|
||||
parent_thread_id: thread_metadata.parent_thread_id.clone(),
|
||||
|
||||
@@ -70,13 +70,9 @@ pub use crate::remote::RemoteAppServerEndpoint;
|
||||
/// module exists so clients can remove a direct `codex-core` dependency
|
||||
/// while legacy startup/config paths are migrated to RPCs.
|
||||
pub mod legacy_core {
|
||||
pub use codex_core::DEFAULT_AGENTS_MD_FILENAME;
|
||||
pub use codex_core::LOCAL_AGENTS_MD_FILENAME;
|
||||
pub use codex_core::McpManager;
|
||||
pub use codex_core::check_execpolicy_for_warnings;
|
||||
pub use codex_core::format_exec_policy_error_with_source;
|
||||
pub use codex_core::grant_read_root_non_elevated;
|
||||
pub use codex_core::web_search_detail;
|
||||
|
||||
pub mod config {
|
||||
pub use codex_core::config::*;
|
||||
@@ -86,10 +82,6 @@ pub mod legacy_core {
|
||||
}
|
||||
}
|
||||
|
||||
pub mod connectors {
|
||||
pub use codex_core::connectors::*;
|
||||
}
|
||||
|
||||
pub mod otel_init {
|
||||
pub use codex_core::otel_init::*;
|
||||
}
|
||||
@@ -98,22 +90,10 @@ pub mod legacy_core {
|
||||
pub use codex_core::personality_migration::*;
|
||||
}
|
||||
|
||||
pub mod review_format {
|
||||
pub use codex_core::review_format::*;
|
||||
}
|
||||
|
||||
pub mod review_prompts {
|
||||
pub use codex_core::review_prompts::*;
|
||||
}
|
||||
|
||||
pub mod test_support {
|
||||
pub use codex_core::test_support::*;
|
||||
}
|
||||
|
||||
pub mod util {
|
||||
pub use codex_core::util::*;
|
||||
}
|
||||
|
||||
pub mod windows_sandbox {
|
||||
pub use codex_core::windows_sandbox::*;
|
||||
}
|
||||
|
||||
+94
-5
@@ -180,6 +180,36 @@
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"CapabilityRootLocation": {
|
||||
"description": "Location used to resolve a selected capability root.",
|
||||
"oneOf": [
|
||||
{
|
||||
"description": "A path owned by an execution environment.",
|
||||
"properties": {
|
||||
"environmentId": {
|
||||
"type": "string"
|
||||
},
|
||||
"path": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": {
|
||||
"enum": [
|
||||
"environment"
|
||||
],
|
||||
"title": "EnvironmentCapabilityRootLocationType",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"environmentId",
|
||||
"path",
|
||||
"type"
|
||||
],
|
||||
"title": "EnvironmentCapabilityRootLocation",
|
||||
"type": "object"
|
||||
}
|
||||
]
|
||||
},
|
||||
"ClientInfo": {
|
||||
"properties": {
|
||||
"name": {
|
||||
@@ -1963,6 +1993,13 @@
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"RealtimeConversationVersion": {
|
||||
"enum": [
|
||||
"v1",
|
||||
"v2"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"RealtimeOutputModality": {
|
||||
"enum": [
|
||||
"text",
|
||||
@@ -2947,6 +2984,28 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"SelectedCapabilityRoot": {
|
||||
"description": "A user-selected root that can expose one or more runtime capabilities.",
|
||||
"properties": {
|
||||
"id": {
|
||||
"description": "Stable identifier supplied by the capability selection platform.",
|
||||
"type": "string"
|
||||
},
|
||||
"location": {
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/CapabilityRootLocation"
|
||||
}
|
||||
],
|
||||
"description": "Where the selected root can be resolved."
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"id",
|
||||
"location"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"SendAddCreditsNudgeEmailParams": {
|
||||
"properties": {
|
||||
"creditType": {
|
||||
@@ -3144,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": {
|
||||
@@ -3795,11 +3865,6 @@
|
||||
"type": "string"
|
||||
},
|
||||
"ThreadSource": {
|
||||
"enum": [
|
||||
"user",
|
||||
"subagent",
|
||||
"memory_consolidation"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"ThreadSourceKind": {
|
||||
@@ -4460,6 +4525,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": {
|
||||
|
||||
@@ -3523,6 +3523,17 @@
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"ThreadDeletedNotification": {
|
||||
"properties": {
|
||||
"threadId": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"threadId"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"ThreadGoal": {
|
||||
"properties": {
|
||||
"createdAt": {
|
||||
@@ -4615,11 +4626,6 @@
|
||||
"type": "object"
|
||||
},
|
||||
"ThreadSource": {
|
||||
"enum": [
|
||||
"user",
|
||||
"subagent",
|
||||
"memory_consolidation"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"ThreadStartedNotification": {
|
||||
@@ -5452,6 +5458,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": {
|
||||
|
||||
+127
-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": {
|
||||
@@ -6765,6 +6809,36 @@
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"CapabilityRootLocation": {
|
||||
"description": "Location used to resolve a selected capability root.",
|
||||
"oneOf": [
|
||||
{
|
||||
"description": "A path owned by an execution environment.",
|
||||
"properties": {
|
||||
"environmentId": {
|
||||
"type": "string"
|
||||
},
|
||||
"path": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": {
|
||||
"enum": [
|
||||
"environment"
|
||||
],
|
||||
"title": "EnvironmentCapabilityRootLocationType",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"environmentId",
|
||||
"path",
|
||||
"type"
|
||||
],
|
||||
"title": "EnvironmentCapabilityRootLocation",
|
||||
"type": "object"
|
||||
}
|
||||
]
|
||||
},
|
||||
"CodexErrorInfo": {
|
||||
"description": "This translation layer make sure that we expose codex error code in camel case.\n\nWhen an upstream HTTP status is available (for example, from the Responses API or a provider), it is forwarded in `httpStatusCode` on the relevant `codexErrorInfo` variant.",
|
||||
"oneOf": [
|
||||
@@ -15061,6 +15135,28 @@
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"SelectedCapabilityRoot": {
|
||||
"description": "A user-selected root that can expose one or more runtime capabilities.",
|
||||
"properties": {
|
||||
"id": {
|
||||
"description": "Stable identifier supplied by the capability selection platform.",
|
||||
"type": "string"
|
||||
},
|
||||
"location": {
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/v2/CapabilityRootLocation"
|
||||
}
|
||||
],
|
||||
"description": "Where the selected root can be resolved."
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"id",
|
||||
"location"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"SendAddCreditsNudgeEmailParams": {
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"properties": {
|
||||
@@ -16001,6 +16097,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.",
|
||||
@@ -18057,11 +18184,6 @@
|
||||
"type": "string"
|
||||
},
|
||||
"ThreadSource": {
|
||||
"enum": [
|
||||
"user",
|
||||
"subagent",
|
||||
"memory_consolidation"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"ThreadSourceKind": {
|
||||
|
||||
+127
-5
@@ -1087,6 +1087,36 @@
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"CapabilityRootLocation": {
|
||||
"description": "Location used to resolve a selected capability root.",
|
||||
"oneOf": [
|
||||
{
|
||||
"description": "A path owned by an execution environment.",
|
||||
"properties": {
|
||||
"environmentId": {
|
||||
"type": "string"
|
||||
},
|
||||
"path": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": {
|
||||
"enum": [
|
||||
"environment"
|
||||
],
|
||||
"title": "EnvironmentCapabilityRootLocationType",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"environmentId",
|
||||
"path",
|
||||
"type"
|
||||
],
|
||||
"title": "EnvironmentCapabilityRootLocation",
|
||||
"type": "object"
|
||||
}
|
||||
]
|
||||
},
|
||||
"ClientInfo": {
|
||||
"properties": {
|
||||
"name": {
|
||||
@@ -1233,6 +1263,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": {
|
||||
@@ -11563,6 +11617,28 @@
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"SelectedCapabilityRoot": {
|
||||
"description": "A user-selected root that can expose one or more runtime capabilities.",
|
||||
"properties": {
|
||||
"id": {
|
||||
"description": "Stable identifier supplied by the capability selection platform.",
|
||||
"type": "string"
|
||||
},
|
||||
"location": {
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/CapabilityRootLocation"
|
||||
}
|
||||
],
|
||||
"description": "Where the selected root can be resolved."
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"id",
|
||||
"location"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"SendAddCreditsNudgeEmailParams": {
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"properties": {
|
||||
@@ -11674,6 +11750,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": {
|
||||
@@ -13818,6 +13914,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.",
|
||||
@@ -15874,11 +16001,6 @@
|
||||
"type": "string"
|
||||
},
|
||||
"ThreadSource": {
|
||||
"enum": [
|
||||
"user",
|
||||
"subagent",
|
||||
"memory_consolidation"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"ThreadSourceKind": {
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
@@ -73,11 +73,6 @@
|
||||
"type": "string"
|
||||
},
|
||||
"ThreadSource": {
|
||||
"enum": [
|
||||
"user",
|
||||
"subagent",
|
||||
"memory_consolidation"
|
||||
],
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1830,11 +1830,6 @@
|
||||
]
|
||||
},
|
||||
"ThreadSource": {
|
||||
"enum": [
|
||||
"user",
|
||||
"subagent",
|
||||
"memory_consolidation"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"ThreadStatus": {
|
||||
|
||||
@@ -1645,11 +1645,6 @@
|
||||
]
|
||||
},
|
||||
"ThreadSource": {
|
||||
"enum": [
|
||||
"user",
|
||||
"subagent",
|
||||
"memory_consolidation"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"ThreadStatus": {
|
||||
|
||||
-5
@@ -1645,11 +1645,6 @@
|
||||
]
|
||||
},
|
||||
"ThreadSource": {
|
||||
"enum": [
|
||||
"user",
|
||||
"subagent",
|
||||
"memory_consolidation"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"ThreadStatus": {
|
||||
|
||||
@@ -1645,11 +1645,6 @@
|
||||
]
|
||||
},
|
||||
"ThreadSource": {
|
||||
"enum": [
|
||||
"user",
|
||||
"subagent",
|
||||
"memory_consolidation"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"ThreadStatus": {
|
||||
|
||||
@@ -1830,11 +1830,6 @@
|
||||
]
|
||||
},
|
||||
"ThreadSource": {
|
||||
"enum": [
|
||||
"user",
|
||||
"subagent",
|
||||
"memory_consolidation"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"ThreadStatus": {
|
||||
|
||||
@@ -1645,11 +1645,6 @@
|
||||
]
|
||||
},
|
||||
"ThreadSource": {
|
||||
"enum": [
|
||||
"user",
|
||||
"subagent",
|
||||
"memory_consolidation"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"ThreadStatus": {
|
||||
|
||||
@@ -64,6 +64,36 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"CapabilityRootLocation": {
|
||||
"description": "Location used to resolve a selected capability root.",
|
||||
"oneOf": [
|
||||
{
|
||||
"description": "A path owned by an execution environment.",
|
||||
"properties": {
|
||||
"environmentId": {
|
||||
"type": "string"
|
||||
},
|
||||
"path": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": {
|
||||
"enum": [
|
||||
"environment"
|
||||
],
|
||||
"title": "EnvironmentCapabilityRootLocationType",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"environmentId",
|
||||
"path",
|
||||
"type"
|
||||
],
|
||||
"title": "EnvironmentCapabilityRootLocation",
|
||||
"type": "object"
|
||||
}
|
||||
]
|
||||
},
|
||||
"DynamicToolSpec": {
|
||||
"properties": {
|
||||
"deferLoading": {
|
||||
@@ -106,12 +136,29 @@
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"ThreadSource": {
|
||||
"enum": [
|
||||
"user",
|
||||
"subagent",
|
||||
"memory_consolidation"
|
||||
"SelectedCapabilityRoot": {
|
||||
"description": "A user-selected root that can expose one or more runtime capabilities.",
|
||||
"properties": {
|
||||
"id": {
|
||||
"description": "Stable identifier supplied by the capability selection platform.",
|
||||
"type": "string"
|
||||
},
|
||||
"location": {
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/CapabilityRootLocation"
|
||||
}
|
||||
],
|
||||
"description": "Where the selected root can be resolved."
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"id",
|
||||
"location"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"ThreadSource": {
|
||||
"type": "string"
|
||||
},
|
||||
"ThreadStartSource": {
|
||||
|
||||
@@ -1830,11 +1830,6 @@
|
||||
]
|
||||
},
|
||||
"ThreadSource": {
|
||||
"enum": [
|
||||
"user",
|
||||
"subagent",
|
||||
"memory_consolidation"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"ThreadStatus": {
|
||||
|
||||
@@ -1645,11 +1645,6 @@
|
||||
]
|
||||
},
|
||||
"ThreadSource": {
|
||||
"enum": [
|
||||
"user",
|
||||
"subagent",
|
||||
"memory_consolidation"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"ThreadStatus": {
|
||||
|
||||
@@ -1645,11 +1645,6 @@
|
||||
]
|
||||
},
|
||||
"ThreadSource": {
|
||||
"enum": [
|
||||
"user",
|
||||
"subagent",
|
||||
"memory_consolidation"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"ThreadStatus": {
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,8 @@
|
||||
// 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.
|
||||
|
||||
/**
|
||||
* Location used to resolve a selected capability root.
|
||||
*/
|
||||
export type CapabilityRootLocation = { "type": "environment", environmentId: string, path: string, };
|
||||
@@ -0,0 +1,17 @@
|
||||
// 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.
|
||||
import type { CapabilityRootLocation } from "./CapabilityRootLocation";
|
||||
|
||||
/**
|
||||
* A user-selected root that can expose one or more runtime capabilities.
|
||||
*/
|
||||
export type SelectedCapabilityRoot = {
|
||||
/**
|
||||
* Stable identifier supplied by the capability selection platform.
|
||||
*/
|
||||
id: string,
|
||||
/**
|
||||
* Where the selected root can be resolved.
|
||||
*/
|
||||
location: CapabilityRootLocation, };
|
||||
@@ -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, };
|
||||
@@ -2,4 +2,4 @@
|
||||
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
|
||||
export type ThreadSource = "user" | "subagent" | "memory_consolidation";
|
||||
export type ThreadSource = string;
|
||||
|
||||
@@ -40,6 +40,7 @@ export type { ByteRange } from "./ByteRange";
|
||||
export type { CancelLoginAccountParams } from "./CancelLoginAccountParams";
|
||||
export type { CancelLoginAccountResponse } from "./CancelLoginAccountResponse";
|
||||
export type { CancelLoginAccountStatus } from "./CancelLoginAccountStatus";
|
||||
export type { CapabilityRootLocation } from "./CapabilityRootLocation";
|
||||
export type { ChatgptAuthTokensRefreshParams } from "./ChatgptAuthTokensRefreshParams";
|
||||
export type { ChatgptAuthTokensRefreshReason } from "./ChatgptAuthTokensRefreshReason";
|
||||
export type { ChatgptAuthTokensRefreshResponse } from "./ChatgptAuthTokensRefreshResponse";
|
||||
@@ -334,6 +335,7 @@ export type { ReviewTarget } from "./ReviewTarget";
|
||||
export type { SandboxMode } from "./SandboxMode";
|
||||
export type { SandboxPolicy } from "./SandboxPolicy";
|
||||
export type { SandboxWorkspaceWrite } from "./SandboxWorkspaceWrite";
|
||||
export type { SelectedCapabilityRoot } from "./SelectedCapabilityRoot";
|
||||
export type { SendAddCreditsNudgeEmailParams } from "./SendAddCreditsNudgeEmailParams";
|
||||
export type { SendAddCreditsNudgeEmailResponse } from "./SendAddCreditsNudgeEmailResponse";
|
||||
export type { ServerRequestResolvedNotification } from "./ServerRequestResolvedNotification";
|
||||
@@ -372,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",
|
||||
|
||||
@@ -480,6 +480,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 +579,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 +1539,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 +2954,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 +3023,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 +3043,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 +3058,11 @@ mod tests {
|
||||
"id": 9,
|
||||
"params": {
|
||||
"threadId": "thr_123",
|
||||
"model": null,
|
||||
"outputModality": "audio",
|
||||
"realtimeSessionId": null,
|
||||
"transport": null,
|
||||
"version": null,
|
||||
"voice": null
|
||||
}
|
||||
}),
|
||||
@@ -2999,10 +3073,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 +3088,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 +3246,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(),
|
||||
|
||||
@@ -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>,
|
||||
}
|
||||
|
||||
@@ -57,6 +57,30 @@ fn test_absolute_path() -> AbsolutePathBuf {
|
||||
absolute_path("readable")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn thread_sources_round_trip_as_scalar_labels() {
|
||||
for (source, label) in [
|
||||
(ThreadSource::User, "user"),
|
||||
(ThreadSource::Subagent, "subagent"),
|
||||
(
|
||||
ThreadSource::Feature("automation".to_string()),
|
||||
"automation",
|
||||
),
|
||||
(ThreadSource::MemoryConsolidation, "memory_consolidation"),
|
||||
] {
|
||||
let value = serde_json::to_value(&source).expect("serialize thread source");
|
||||
|
||||
assert_eq!(value, json!(label));
|
||||
assert_eq!(
|
||||
serde_json::from_value::<ThreadSource>(value).expect("deserialize thread source"),
|
||||
source
|
||||
);
|
||||
|
||||
let core_source: codex_protocol::protocol::ThreadSource = source.clone().into();
|
||||
assert_eq!(ThreadSource::from(core_source), source);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn approvals_reviewer_serializes_auto_review_and_accepts_legacy_guardian_subagent() {
|
||||
assert_eq!(
|
||||
|
||||
@@ -11,6 +11,8 @@ use super::TurnEnvironmentParams;
|
||||
use super::TurnItemsView;
|
||||
use super::shared::v2_enum_from_core;
|
||||
use codex_experimental_api_macros::ExperimentalApi;
|
||||
pub use codex_protocol::capabilities::CapabilityRootLocation;
|
||||
pub use codex_protocol::capabilities::SelectedCapabilityRoot;
|
||||
use codex_protocol::config_types::CollaborationMode;
|
||||
use codex_protocol::config_types::Personality;
|
||||
use codex_protocol::config_types::ReasoningSummary;
|
||||
@@ -153,6 +155,10 @@ pub struct ThreadStartParams {
|
||||
#[experimental("thread/start.dynamicTools")]
|
||||
#[ts(optional = nullable)]
|
||||
pub dynamic_tools: Option<Vec<DynamicToolSpec>>,
|
||||
/// Capability roots selected for this thread by the hosting platform.
|
||||
#[experimental("thread/start.selectedCapabilityRoots")]
|
||||
#[ts(optional = nullable)]
|
||||
pub selected_capability_roots: Option<Vec<SelectedCapabilityRoot>>,
|
||||
/// Test-only experimental field used to validate experimental gating and
|
||||
/// schema filtering behavior in a stable way.
|
||||
#[experimental("thread/start.mockExperimentalField")]
|
||||
@@ -588,6 +594,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/")]
|
||||
@@ -929,6 +947,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/")]
|
||||
@@ -1324,6 +1393,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/")]
|
||||
|
||||
@@ -7,6 +7,8 @@ use codex_protocol::protocol::SubAgentSource as CoreSubAgentSource;
|
||||
use codex_protocol::protocol::ThreadSource as CoreThreadSource;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use schemars::JsonSchema;
|
||||
use schemars::r#gen::SchemaGenerator;
|
||||
use schemars::schema::Schema;
|
||||
use serde::Deserialize;
|
||||
use serde::Serialize;
|
||||
use std::path::PathBuf;
|
||||
@@ -61,20 +63,47 @@ impl From<SessionSource> for CoreSessionSource {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
#[ts(rename_all = "snake_case", export_to = "v2/")]
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, TS)]
|
||||
#[serde(try_from = "String", into = "String")]
|
||||
#[ts(type = "string")]
|
||||
#[ts(export_to = "v2/")]
|
||||
pub enum ThreadSource {
|
||||
User,
|
||||
Subagent,
|
||||
Feature(String),
|
||||
MemoryConsolidation,
|
||||
}
|
||||
|
||||
impl JsonSchema for ThreadSource {
|
||||
fn schema_name() -> String {
|
||||
"ThreadSource".to_string()
|
||||
}
|
||||
|
||||
fn json_schema(generator: &mut SchemaGenerator) -> Schema {
|
||||
String::json_schema(generator)
|
||||
}
|
||||
}
|
||||
|
||||
impl TryFrom<String> for ThreadSource {
|
||||
type Error = String;
|
||||
|
||||
fn try_from(value: String) -> Result<Self, Self::Error> {
|
||||
value.parse::<CoreThreadSource>().map(Into::into)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<ThreadSource> for String {
|
||||
fn from(value: ThreadSource) -> Self {
|
||||
CoreThreadSource::from(value).into()
|
||||
}
|
||||
}
|
||||
|
||||
impl From<CoreThreadSource> for ThreadSource {
|
||||
fn from(value: CoreThreadSource) -> Self {
|
||||
match value {
|
||||
CoreThreadSource::User => ThreadSource::User,
|
||||
CoreThreadSource::Subagent => ThreadSource::Subagent,
|
||||
CoreThreadSource::Feature(feature) => ThreadSource::Feature(feature),
|
||||
CoreThreadSource::MemoryConsolidation => ThreadSource::MemoryConsolidation,
|
||||
}
|
||||
}
|
||||
@@ -85,6 +114,7 @@ impl From<ThreadSource> for CoreThreadSource {
|
||||
match value {
|
||||
ThreadSource::User => CoreThreadSource::User,
|
||||
ThreadSource::Subagent => CoreThreadSource::Subagent,
|
||||
ThreadSource::Feature(feature) => CoreThreadSource::Feature(feature),
|
||||
ThreadSource::MemoryConsolidation => CoreThreadSource::MemoryConsolidation,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,6 +49,7 @@ codex-hooks = { workspace = true }
|
||||
codex-otel = { workspace = true }
|
||||
codex-plugin = { workspace = true }
|
||||
codex-shell-command = { workspace = true }
|
||||
codex-skills-extension = { workspace = true }
|
||||
codex-utils-cli = { workspace = true }
|
||||
codex-utils-pty = { workspace = true }
|
||||
codex-backend-client = { workspace = true }
|
||||
@@ -60,6 +61,7 @@ codex-memories-extension = { workspace = true }
|
||||
codex-web-search-extension = { workspace = true }
|
||||
codex-memories-write = { workspace = true }
|
||||
codex-mcp = { workspace = true }
|
||||
codex-mcp-extension = { workspace = true }
|
||||
codex-model-provider = { workspace = true }
|
||||
codex-models-manager = { workspace = true }
|
||||
codex-protocol = { workspace = true }
|
||||
|
||||
@@ -130,7 +130,7 @@ Example with notification opt-out:
|
||||
|
||||
## API Overview
|
||||
|
||||
- `thread/start` — create a new thread; emits `thread/started` (including the current `thread.status`) and auto-subscribes you to turn/item events for that thread. When the request includes a `cwd` and the resolved sandbox is `workspace-write` or full access, app-server also marks that project as trusted in the user `config.toml`. Pass `sessionStartSource: "clear"` when starting a replacement thread after clearing the current session so `SessionStart` hooks receive `source: "clear"` instead of the default `"startup"`. Experimental `runtimeWorkspaceRoots` replaces the thread-scoped runtime workspace roots used to materialize `:workspace_roots`; paths must be absolute. For permissions, prefer experimental `permissions` profile selection by id; the legacy `sandbox` shorthand is still accepted but cannot be combined with `permissions`. Experimental `environments` selects the sticky execution environments for turns on the thread; omit it to use the server default, pass `[]` to disable environments, or pass explicit environment ids with per-environment `cwd`.
|
||||
- `thread/start` — create a new thread; emits `thread/started` (including the current `thread.status`) and auto-subscribes you to turn/item events for that thread. When the request includes a `cwd` and the resolved sandbox is `workspace-write` or full access, app-server also marks that project as trusted in the user `config.toml`. Pass `sessionStartSource: "clear"` when starting a replacement thread after clearing the current session so `SessionStart` hooks receive `source: "clear"` instead of the default `"startup"`. Experimental `runtimeWorkspaceRoots` replaces the thread-scoped runtime workspace roots used to materialize `:workspace_roots`; paths must be absolute. For permissions, prefer experimental `permissions` profile selection by id; the legacy `sandbox` shorthand is still accepted but cannot be combined with `permissions`. Experimental `environments` selects the sticky execution environments for turns on the thread; omit it to use the server default, pass `[]` to disable environments, or pass explicit environment ids with per-environment `cwd`. Experimental `selectedCapabilityRoots` selects environment-owned plugin or standalone-skill roots. Skills found below those roots are listed and read through the owning environment; other plugin capabilities are not activated yet.
|
||||
- `thread/resume` — reopen an existing thread by id so subsequent `turn/start` calls append to it. Accepts the same permission override rules as `thread/start`.
|
||||
- `thread/fork` — fork an existing thread into a new thread id by copying the stored history; if the source thread is currently mid-turn, the fork records the same interruption marker as `turn/interrupt` instead of inheriting an unmarked partial turn suffix. The returned `thread.forkedFromId` points at the source thread when known. Accepts `ephemeral: true` for an in-memory temporary fork, emits `thread/started` (including the current `thread.status`), and auto-subscribes you to turn/item events for the new thread. Experimental clients can pass `excludeTurns: true` when they plan to page fork history via `thread/turns/list` instead of receiving the full turn array immediately. Accepts the same permission override rules as `thread/start`.
|
||||
- `thread/start`, `thread/resume`, and `thread/fork` responses include the legacy `sandbox` compatibility projection. Experimental clients can read `runtimeWorkspaceRoots` for the thread-scoped runtime roots and `activePermissionProfile` for the named or implicit built-in profile identity/provenance when known.
|
||||
@@ -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 `{}`.
|
||||
@@ -249,6 +252,18 @@ Start a fresh thread when you need a new Codex conversation.
|
||||
// "permissions": ":workspace"
|
||||
// Experimental runtime roots for :workspace_roots materialization:
|
||||
// "runtimeWorkspaceRoots": ["/Users/me/project", "/Users/me/openai"],
|
||||
// Experimental capability roots selected by the hosting platform:
|
||||
"selectedCapabilityRoots": [
|
||||
{
|
||||
"id": "github@openai",
|
||||
"location": {
|
||||
"type": "environment",
|
||||
"environmentId": "workspace",
|
||||
// Opaque to app-server; interpreted in the selected environment.
|
||||
"path": "/opt/cca/plugins/github"
|
||||
}
|
||||
}
|
||||
],
|
||||
// Do not send both "sandbox" and "permissions".
|
||||
"personality": "friendly",
|
||||
"serviceName": "my_app_server_client", // optional metrics tag (`service_name`)
|
||||
@@ -598,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.
|
||||
@@ -825,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({
|
||||
@@ -858,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
|
||||
|
||||
@@ -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;
|
||||
@@ -25,22 +26,38 @@ 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>,
|
||||
}
|
||||
|
||||
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>,
|
||||
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,
|
||||
} = 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,
|
||||
@@ -49,8 +66,14 @@ where
|
||||
}
|
||||
codex_guardian::install(&mut builder, guardian_agent_spawner);
|
||||
codex_memories_extension::install(&mut builder, codex_otel::global());
|
||||
codex_mcp_extension::install(&mut builder);
|
||||
codex_web_search_extension::install(&mut builder, auth_manager.clone());
|
||||
codex_image_generation_extension::install(&mut builder, auth_manager);
|
||||
codex_skills_extension::install_with_providers(
|
||||
&mut builder,
|
||||
codex_skills_extension::SkillProviders::new()
|
||||
.with_executor_provider(executor_skill_provider),
|
||||
);
|
||||
Arc::new(builder.build())
|
||||
}
|
||||
|
||||
@@ -133,7 +156,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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -67,10 +67,7 @@ async fn build_refresh_config(
|
||||
let config = config_manager
|
||||
.load_latest_config_for_thread(thread_config.as_ref())
|
||||
.await?;
|
||||
let mcp_servers = thread_manager
|
||||
.mcp_manager()
|
||||
.configured_servers(&config)
|
||||
.await;
|
||||
let mcp_servers = thread_manager.mcp_manager().runtime_servers(&config).await;
|
||||
Ok(McpServerRefreshConfig {
|
||||
mcp_servers: serde_json::to_value(mcp_servers).map_err(io::Error::other)?,
|
||||
mcp_oauth_credentials_store_mode: serde_json::to_value(
|
||||
@@ -99,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;
|
||||
@@ -181,19 +179,30 @@ mod tests {
|
||||
.await
|
||||
.expect("refresh tests require state db");
|
||||
let thread_store = thread_store_from_config(&good_config, Some(state_db.clone()));
|
||||
let environment_manager = Arc::new(EnvironmentManager::default_for_tests());
|
||||
let executor_skill_provider: Arc<dyn codex_skills_extension::SkillProvider> = Arc::new(
|
||||
codex_skills_extension::ExecutorSkillProvider::new_with_restriction_product(
|
||||
Arc::clone(&environment_manager),
|
||||
SessionSource::Exec.restriction_product(),
|
||||
),
|
||||
);
|
||||
let thread_manager = Arc::new_cyclic(|thread_manager| {
|
||||
ThreadManager::new(
|
||||
&good_config,
|
||||
auth_manager.clone(),
|
||||
SessionSource::Exec,
|
||||
Arc::new(EnvironmentManager::default_for_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()),
|
||||
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),
|
||||
},
|
||||
),
|
||||
/*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;
|
||||
@@ -307,6 +308,14 @@ impl MessageProcessor {
|
||||
// resumed, or forked threads to a different persistence backend/root.
|
||||
let thread_store = codex_core::thread_store_from_config(config.as_ref(), state_db.clone());
|
||||
let environment_manager_for_requests = Arc::clone(&environment_manager);
|
||||
let environment_manager_for_extensions = Arc::clone(&environment_manager);
|
||||
let restriction_product = session_source.restriction_product();
|
||||
let executor_skill_provider: Arc<dyn codex_skills_extension::SkillProvider> = Arc::new(
|
||||
codex_skills_extension::ExecutorSkillProvider::new_with_restriction_product(
|
||||
environment_manager_for_extensions,
|
||||
restriction_product,
|
||||
),
|
||||
);
|
||||
let goal_service = Arc::new(GoalService::new());
|
||||
let thread_manager = Arc::new_cyclic(|thread_manager| {
|
||||
ThreadManager::new(
|
||||
@@ -316,11 +325,18 @@ 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),
|
||||
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),
|
||||
},
|
||||
),
|
||||
Some(analytics_events_client.clone()),
|
||||
Arc::clone(&thread_store),
|
||||
@@ -384,7 +400,7 @@ impl MessageProcessor {
|
||||
Arc::clone(&thread_manager),
|
||||
Arc::clone(&config),
|
||||
feedback,
|
||||
log_db,
|
||||
log_db.clone(),
|
||||
state_db.clone(),
|
||||
);
|
||||
let git_processor = GitRequestProcessor::new();
|
||||
@@ -438,6 +454,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(
|
||||
@@ -1055,6 +1072,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)
|
||||
@@ -1110,6 +1132,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;
|
||||
@@ -280,6 +288,7 @@ use codex_config::types::McpServerTransportConfig;
|
||||
use codex_core::CodexThread;
|
||||
use codex_core::CodexThreadSettingsOverrides;
|
||||
use codex_core::ForkSnapshot;
|
||||
use codex_core::McpManager;
|
||||
use codex_core::NewThread;
|
||||
#[cfg(test)]
|
||||
use codex_core::SessionMeta;
|
||||
@@ -413,6 +422,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;
|
||||
@@ -533,6 +543,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;
|
||||
|
||||
@@ -88,11 +88,19 @@ impl AppsRequestProcessor {
|
||||
let request = request_id.clone();
|
||||
let outgoing = Arc::clone(&self.outgoing);
|
||||
let environment_manager = self.thread_manager.environment_manager();
|
||||
let mcp_manager = self.thread_manager.mcp_manager();
|
||||
let shutdown_token = self.shutdown_token.child_token();
|
||||
tokio::spawn(async move {
|
||||
tokio::select! {
|
||||
_ = shutdown_token.cancelled() => {}
|
||||
_ = Self::apps_list_task(outgoing, request, params, config, environment_manager) => {}
|
||||
_ = Self::apps_list_task(
|
||||
outgoing,
|
||||
request,
|
||||
params,
|
||||
config,
|
||||
environment_manager,
|
||||
mcp_manager,
|
||||
) => {}
|
||||
}
|
||||
});
|
||||
Ok(None)
|
||||
@@ -108,11 +116,15 @@ impl AppsRequestProcessor {
|
||||
params: AppsListParams,
|
||||
config: Config,
|
||||
environment_manager: Arc<EnvironmentManager>,
|
||||
mcp_manager: Arc<McpManager>,
|
||||
) {
|
||||
let retry_params = params.clone();
|
||||
let retry_config = config.clone();
|
||||
let retry_environment_manager = Arc::clone(&environment_manager);
|
||||
let result = Self::apps_list_response(&outgoing, params, config, environment_manager).await;
|
||||
let retry_mcp_manager = Arc::clone(&mcp_manager);
|
||||
let result =
|
||||
Self::apps_list_response(&outgoing, params, config, environment_manager, mcp_manager)
|
||||
.await;
|
||||
let should_retry = result
|
||||
.as_ref()
|
||||
.is_ok_and(|(_, codex_apps_ready)| !codex_apps_ready);
|
||||
@@ -128,6 +140,7 @@ impl AppsRequestProcessor {
|
||||
retry_params,
|
||||
retry_config,
|
||||
retry_environment_manager,
|
||||
retry_mcp_manager,
|
||||
)
|
||||
.await
|
||||
{
|
||||
@@ -141,6 +154,7 @@ impl AppsRequestProcessor {
|
||||
params: AppsListParams,
|
||||
config: Config,
|
||||
environment_manager: Arc<EnvironmentManager>,
|
||||
mcp_manager: Arc<McpManager>,
|
||||
) -> Result<(AppsListResponse, bool), JSONRPCErrorError> {
|
||||
let AppsListParams {
|
||||
cursor,
|
||||
@@ -167,14 +181,14 @@ impl AppsRequestProcessor {
|
||||
let accessible_config = config.clone();
|
||||
let accessible_tx = tx.clone();
|
||||
tokio::spawn(async move {
|
||||
let result =
|
||||
connectors::list_accessible_connectors_from_mcp_tools_with_environment_manager(
|
||||
&accessible_config,
|
||||
force_refetch,
|
||||
Arc::clone(&environment_manager),
|
||||
)
|
||||
.await
|
||||
.map_err(|err| format!("failed to load accessible apps: {err}"));
|
||||
let result = connectors::list_accessible_connectors_from_mcp_tools_with_mcp_manager(
|
||||
&accessible_config,
|
||||
force_refetch,
|
||||
Arc::clone(&environment_manager),
|
||||
mcp_manager,
|
||||
)
|
||||
.await
|
||||
.map_err(|err| format!("failed to load accessible apps: {err}"));
|
||||
let _ = accessible_tx.send(AppListLoadResult::Accessible(result));
|
||||
});
|
||||
|
||||
|
||||
@@ -120,12 +120,16 @@ impl McpRequestProcessor {
|
||||
timeout_secs,
|
||||
} = params;
|
||||
|
||||
let configured_servers = self
|
||||
let auth = self.auth_manager.auth().await;
|
||||
let effective_servers = self
|
||||
.thread_manager
|
||||
.mcp_manager()
|
||||
.configured_servers(&config)
|
||||
.effective_servers(&config, auth.as_ref())
|
||||
.await;
|
||||
let Some(server) = configured_servers.get(&name) else {
|
||||
let Some(server) = effective_servers
|
||||
.get(&name)
|
||||
.and_then(codex_mcp::EffectiveMcpServer::configured_config)
|
||||
else {
|
||||
return Err(invalid_request(format!(
|
||||
"No MCP server named '{name}' found."
|
||||
)));
|
||||
@@ -210,8 +214,10 @@ impl McpRequestProcessor {
|
||||
}
|
||||
None => self.load_latest_config(/*fallback_cwd*/ None).await?,
|
||||
};
|
||||
let mcp_config = config
|
||||
.to_mcp_config(self.thread_manager.plugins_manager().as_ref())
|
||||
let mcp_config = self
|
||||
.thread_manager
|
||||
.mcp_manager()
|
||||
.runtime_config(&config)
|
||||
.await;
|
||||
let auth = self.auth_manager.auth().await;
|
||||
let environment_manager = self.thread_manager.environment_manager();
|
||||
@@ -361,8 +367,10 @@ impl McpRequestProcessor {
|
||||
}
|
||||
|
||||
let config = self.load_latest_config(/*fallback_cwd*/ None).await?;
|
||||
let mcp_config = config
|
||||
.to_mcp_config(self.thread_manager.plugins_manager().as_ref())
|
||||
let mcp_config = self
|
||||
.thread_manager
|
||||
.mcp_manager()
|
||||
.runtime_config(&config)
|
||||
.await;
|
||||
let auth = self.auth_manager.auth().await;
|
||||
let environment_manager = self.thread_manager.environment_manager();
|
||||
|
||||
@@ -1036,6 +1036,7 @@ impl PluginRequestProcessor {
|
||||
&config,
|
||||
&outcome.plugin.apps,
|
||||
Arc::clone(&environment_manager),
|
||||
self.thread_manager.mcp_manager(),
|
||||
)
|
||||
.await;
|
||||
let visible_skills = outcome
|
||||
@@ -1118,6 +1119,7 @@ impl PluginRequestProcessor {
|
||||
&config,
|
||||
&plugin_apps,
|
||||
Arc::clone(&environment_manager),
|
||||
self.thread_manager.mcp_manager(),
|
||||
)
|
||||
.await;
|
||||
remote_plugin_detail_to_info(remote_detail, app_summaries)
|
||||
@@ -1611,10 +1613,11 @@ impl PluginRequestProcessor {
|
||||
let environment_manager = self.thread_manager.environment_manager();
|
||||
let (all_connectors_result, accessible_connectors_result) = tokio::join!(
|
||||
connectors::list_all_connectors_with_options(config, /*force_refetch*/ false),
|
||||
connectors::list_accessible_connectors_from_mcp_tools_with_environment_manager(
|
||||
connectors::list_accessible_connectors_from_mcp_tools_with_mcp_manager(
|
||||
config,
|
||||
/*force_refetch*/ true,
|
||||
Arc::clone(&environment_manager)
|
||||
Arc::clone(&environment_manager),
|
||||
self.thread_manager.mcp_manager(),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -1881,6 +1884,7 @@ 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();
|
||||
@@ -1900,10 +1904,11 @@ 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_environment_manager(
|
||||
match connectors::list_accessible_connectors_from_mcp_tools_with_mcp_manager(
|
||||
config,
|
||||
/*force_refetch*/ false,
|
||||
environment_manager,
|
||||
mcp_manager,
|
||||
)
|
||||
.await
|
||||
{
|
||||
|
||||
@@ -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}")),
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
use super::*;
|
||||
use crate::error_code::method_not_found;
|
||||
use codex_app_server_protocol::SelectedCapabilityRoot;
|
||||
use codex_extension_api::ExtensionDataInit;
|
||||
use codex_protocol::models::BUILT_IN_PERMISSION_PROFILE_DANGER_FULL_ACCESS;
|
||||
use codex_protocol::models::BUILT_IN_PERMISSION_PROFILE_WORKSPACE;
|
||||
|
||||
@@ -323,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>,
|
||||
}
|
||||
@@ -354,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 {
|
||||
@@ -370,6 +374,7 @@ impl ThreadRequestProcessor {
|
||||
thread_list_state_permit,
|
||||
thread_goal_processor,
|
||||
state_db,
|
||||
log_db,
|
||||
background_tasks: TaskTracker::new(),
|
||||
skills_watcher,
|
||||
}
|
||||
@@ -567,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,
|
||||
@@ -676,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
|
||||
@@ -748,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");
|
||||
@@ -755,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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -833,6 +860,7 @@ impl ThreadRequestProcessor {
|
||||
base_instructions,
|
||||
developer_instructions,
|
||||
dynamic_tools,
|
||||
selected_capability_roots,
|
||||
mock_experimental_field: _mock_experimental_field,
|
||||
experimental_raw_events,
|
||||
personality,
|
||||
@@ -888,6 +916,7 @@ impl ThreadRequestProcessor {
|
||||
config,
|
||||
typesafe_overrides,
|
||||
dynamic_tools,
|
||||
selected_capability_roots.unwrap_or_default(),
|
||||
session_start_source,
|
||||
thread_source.map(Into::into),
|
||||
environment_selections,
|
||||
@@ -960,6 +989,7 @@ impl ThreadRequestProcessor {
|
||||
config_overrides: Option<HashMap<String, serde_json::Value>>,
|
||||
typesafe_overrides: ConfigOverrides,
|
||||
dynamic_tools: Option<Vec<ApiDynamicToolSpec>>,
|
||||
selected_capability_roots: Vec<SelectedCapabilityRoot>,
|
||||
session_start_source: Option<codex_app_server_protocol::ThreadStartSource>,
|
||||
thread_source: Option<codex_protocol::protocol::ThreadSource>,
|
||||
environments: Option<Vec<TurnEnvironmentSelection>>,
|
||||
@@ -1061,6 +1091,10 @@ impl ThreadRequestProcessor {
|
||||
.collect()
|
||||
};
|
||||
let core_dynamic_tool_count = core_dynamic_tools.len();
|
||||
let mut thread_extension_init = ExtensionDataInit::new();
|
||||
if !selected_capability_roots.is_empty() {
|
||||
thread_extension_init.insert(selected_capability_roots);
|
||||
}
|
||||
let create_thread_started_at = std::time::Instant::now();
|
||||
let NewThread {
|
||||
thread_id,
|
||||
@@ -1083,6 +1117,7 @@ impl ThreadRequestProcessor {
|
||||
metrics_service_name: service_name,
|
||||
parent_trace: request_trace,
|
||||
environments,
|
||||
thread_extension_init,
|
||||
})
|
||||
.instrument(tracing::info_span!(
|
||||
"app_server.thread_start.create_thread",
|
||||
@@ -1284,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
|
||||
@@ -1385,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,
|
||||
@@ -1715,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,
|
||||
@@ -3833,7 +3925,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"))
|
||||
}
|
||||
|
||||
@@ -3954,7 +4046,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}"))
|
||||
@@ -4144,7 +4236,7 @@ fn summary_from_thread_metadata(metadata: &ThreadMetadata) -> ConversationSummar
|
||||
metadata.cwd.clone(),
|
||||
metadata.cli_version.clone(),
|
||||
metadata.source.clone(),
|
||||
metadata.thread_source,
|
||||
metadata.thread_source.clone(),
|
||||
metadata.agent_nickname.clone(),
|
||||
metadata.agent_role.clone(),
|
||||
metadata.git_sha.clone(),
|
||||
@@ -4233,13 +4325,41 @@ fn build_thread_from_snapshot(
|
||||
agent_nickname: config_snapshot.session_source.get_nickname(),
|
||||
agent_role: config_snapshot.session_source.get_agent_role(),
|
||||
source: config_snapshot.session_source.clone().into(),
|
||||
thread_source: config_snapshot.thread_source.map(Into::into),
|
||||
thread_source: config_snapshot.thread_source.clone().map(Into::into),
|
||||
git_info: None,
|
||||
name: None,
|
||||
turns: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
use super::*;
|
||||
use codex_protocol::protocol::AdditionalContextEntry as CoreAdditionalContextEntry;
|
||||
use codex_protocol::protocol::AdditionalContextKind as CoreAdditionalContextKind;
|
||||
use codex_protocol::protocol::MultiAgentVersion;
|
||||
use codex_protocol::protocol::SessionSource;
|
||||
use codex_protocol::protocol::SubAgentSource;
|
||||
|
||||
const DIRECT_INPUT_TO_MULTI_AGENT_V2_SUBAGENT_ERROR: &str =
|
||||
"direct app-server input is not allowed for multi-agent v2 sub-agents";
|
||||
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct TurnRequestProcessor {
|
||||
@@ -236,6 +242,26 @@ impl TurnRequestProcessor {
|
||||
|
||||
Ok((thread_id, thread))
|
||||
}
|
||||
|
||||
async fn ensure_direct_input_allowed(
|
||||
&self,
|
||||
request_id: &ConnectionRequestId,
|
||||
thread: &CodexThread,
|
||||
) -> Result<(), JSONRPCErrorError> {
|
||||
if thread.multi_agent_version() == Some(MultiAgentVersion::V2)
|
||||
&& matches!(
|
||||
thread.config_snapshot().await.session_source,
|
||||
SessionSource::SubAgent(SubAgentSource::ThreadSpawn { .. })
|
||||
)
|
||||
{
|
||||
let error = invalid_request(DIRECT_INPUT_TO_MULTI_AGENT_V2_SUBAGENT_ERROR);
|
||||
self.track_error_response(request_id, &error, /*error_type*/ None);
|
||||
return Err(error);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn normalize_collaboration_mode(
|
||||
&self,
|
||||
mut collaboration_mode: CollaborationMode,
|
||||
@@ -370,6 +396,14 @@ impl TurnRequestProcessor {
|
||||
app_server_client_name: Option<String>,
|
||||
app_server_client_version: Option<String>,
|
||||
) -> Result<TurnStartResponse, JSONRPCErrorError> {
|
||||
let (thread_id, thread) =
|
||||
self.load_thread(¶ms.thread_id)
|
||||
.await
|
||||
.inspect_err(|error| {
|
||||
self.track_error_response(&request_id, error, /*error_type*/ None);
|
||||
})?;
|
||||
self.ensure_direct_input_allowed(&request_id, thread.as_ref())
|
||||
.await?;
|
||||
if let Err(error) = Self::validate_v2_input_limit(¶ms.input) {
|
||||
self.track_error_response(
|
||||
&request_id,
|
||||
@@ -378,12 +412,6 @@ impl TurnRequestProcessor {
|
||||
);
|
||||
return Err(error);
|
||||
}
|
||||
let (thread_id, thread) =
|
||||
self.load_thread(¶ms.thread_id)
|
||||
.await
|
||||
.inspect_err(|error| {
|
||||
self.track_error_response(&request_id, error, /*error_type*/ None);
|
||||
})?;
|
||||
Self::set_app_server_client_info(
|
||||
thread.as_ref(),
|
||||
app_server_client_name,
|
||||
@@ -762,6 +790,8 @@ impl TurnRequestProcessor {
|
||||
.inspect_err(|error| {
|
||||
self.track_error_response(request_id, error, /*error_type*/ None);
|
||||
})?;
|
||||
self.ensure_direct_input_allowed(request_id, thread.as_ref())
|
||||
.await?;
|
||||
|
||||
if params.expected_turn_id.is_empty() {
|
||||
return Err(invalid_request("expectedTurnId must not be empty"));
|
||||
@@ -904,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,
|
||||
@@ -915,6 +946,7 @@ impl TurnRequestProcessor {
|
||||
ConversationStartTransport::Webrtc { sdp }
|
||||
}
|
||||
}),
|
||||
version: params.version,
|
||||
voice: params.voice,
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1516,7 +1516,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;
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::Result;
|
||||
use app_test_support::TestAppServer;
|
||||
use app_test_support::to_response;
|
||||
use codex_app_server_protocol::CapabilityRootLocation;
|
||||
use codex_app_server_protocol::JSONRPCResponse;
|
||||
use codex_app_server_protocol::RequestId;
|
||||
use codex_app_server_protocol::SelectedCapabilityRoot;
|
||||
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 core_test_support::responses;
|
||||
use tempfile::TempDir;
|
||||
use tokio::time::timeout;
|
||||
|
||||
const READ_TIMEOUT: Duration = Duration::from_secs(10);
|
||||
const SKILL_NAME: &str = "demo-plugin:deploy";
|
||||
const SKILL_MARKER: &str = "EXECUTOR_SKILL_BODY_MARKER";
|
||||
const LOCAL_SKILL_MARKER: &str = "LOCAL_SKILL_BODY_MARKER";
|
||||
|
||||
#[tokio::test]
|
||||
async fn selected_executor_root_exposes_plugin_skill() -> Result<()> {
|
||||
let server = responses::start_mock_server().await;
|
||||
let response_mock = responses::mount_sse_once(
|
||||
&server,
|
||||
responses::sse(vec![
|
||||
responses::ev_response_created("resp-selected"),
|
||||
responses::ev_assistant_message("msg-selected", "Done"),
|
||||
responses::ev_completed("resp-selected"),
|
||||
]),
|
||||
)
|
||||
.await;
|
||||
|
||||
let codex_home = TempDir::new()?;
|
||||
std::fs::write(
|
||||
codex_home.path().join("config.toml"),
|
||||
format!(
|
||||
r#"
|
||||
model = "mock-model"
|
||||
approval_policy = "never"
|
||||
sandbox_mode = "read-only"
|
||||
model_provider = "mock_provider"
|
||||
|
||||
[skills]
|
||||
include_instructions = true
|
||||
|
||||
[model_providers.mock_provider]
|
||||
name = "Mock provider for test"
|
||||
base_url = "{}/v1"
|
||||
wire_api = "responses"
|
||||
request_max_retries = 0
|
||||
stream_max_retries = 0
|
||||
"#,
|
||||
server.uri()
|
||||
),
|
||||
)?;
|
||||
let local_skill_dir = codex_home.path().join("skills/local-deploy");
|
||||
std::fs::create_dir_all(&local_skill_dir)?;
|
||||
std::fs::write(
|
||||
local_skill_dir.join("SKILL.md"),
|
||||
format!(
|
||||
"---\nname: {SKILL_NAME}\ndescription: Colliding local skill.\n---\n\n# Local deploy\n\n{LOCAL_SKILL_MARKER}\n"
|
||||
),
|
||||
)?;
|
||||
let plugin_dir = TempDir::new()?;
|
||||
let manifest_dir = plugin_dir.path().join(".codex-plugin");
|
||||
let skill_dir = plugin_dir.path().join("skills/deploy");
|
||||
std::fs::create_dir_all(&manifest_dir)?;
|
||||
std::fs::create_dir_all(&skill_dir)?;
|
||||
std::fs::write(
|
||||
manifest_dir.join("plugin.json"),
|
||||
r#"{"name":"demo-plugin"}"#,
|
||||
)?;
|
||||
std::fs::write(
|
||||
skill_dir.join("SKILL.md"),
|
||||
format!(
|
||||
"---\nname: deploy\ndescription: Deploy through the executor.\n---\n\n# Deploy\n\n{SKILL_MARKER}\n"
|
||||
),
|
||||
)?;
|
||||
|
||||
let mut app_server = TestAppServer::new(codex_home.path()).await?;
|
||||
timeout(READ_TIMEOUT, app_server.initialize()).await??;
|
||||
|
||||
let request_id = app_server
|
||||
.send_thread_start_request(ThreadStartParams {
|
||||
model: Some("mock-model".to_string()),
|
||||
selected_capability_roots: Some(vec![SelectedCapabilityRoot {
|
||||
id: "demo-plugin@1".to_string(),
|
||||
location: CapabilityRootLocation::Environment {
|
||||
environment_id: "local".to_string(),
|
||||
path: plugin_dir.path().to_string_lossy().into_owned(),
|
||||
},
|
||||
}]),
|
||||
..Default::default()
|
||||
})
|
||||
.await?;
|
||||
let response: JSONRPCResponse = timeout(
|
||||
READ_TIMEOUT,
|
||||
app_server.read_stream_until_response_message(RequestId::Integer(request_id)),
|
||||
)
|
||||
.await??;
|
||||
let ThreadStartResponse { thread, .. } = to_response(response)?;
|
||||
|
||||
let request_id = app_server
|
||||
.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(
|
||||
READ_TIMEOUT,
|
||||
app_server.read_stream_until_response_message(RequestId::Integer(request_id)),
|
||||
)
|
||||
.await??;
|
||||
timeout(
|
||||
READ_TIMEOUT,
|
||||
app_server.read_stream_until_notification_message("turn/completed"),
|
||||
)
|
||||
.await??;
|
||||
|
||||
let request = response_mock.single_request();
|
||||
assert!(
|
||||
request
|
||||
.message_input_texts("developer")
|
||||
.iter()
|
||||
.any(|text| text.contains(SKILL_NAME))
|
||||
);
|
||||
let skill_fragments = request
|
||||
.message_input_texts("user")
|
||||
.into_iter()
|
||||
.filter(|text| text.starts_with("<skill>"))
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(1, skill_fragments.len());
|
||||
let skill_fragment = skill_fragments
|
||||
.first()
|
||||
.expect("executor skill instructions should be model-visible");
|
||||
assert!(skill_fragment.contains(&format!("<name>{SKILL_NAME}</name>")));
|
||||
assert!(skill_fragment.contains(SKILL_MARKER));
|
||||
assert!(!skill_fragment.contains(LOCAL_SKILL_MARKER));
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -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?;
|
||||
|
||||
@@ -256,7 +256,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;
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -12,6 +12,7 @@ mod connection_handling_websocket;
|
||||
#[cfg(unix)]
|
||||
mod connection_handling_websocket_unix;
|
||||
mod dynamic_tools;
|
||||
mod executor_skills;
|
||||
mod experimental_api;
|
||||
mod experimental_feature_list;
|
||||
mod external_agent_config;
|
||||
@@ -49,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;
|
||||
|
||||
@@ -1289,7 +1289,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;
|
||||
|
||||
@@ -1914,7 +1914,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;
|
||||
|
||||
@@ -206,7 +206,7 @@ async fn plugin_uninstall_writes_remote_plugin_to_cloud_when_remote_plugin_enabl
|
||||
|
||||
Mock::given(method("POST"))
|
||||
.and(path(format!(
|
||||
"/backend-api/plugins/{REMOTE_PLUGIN_ID}/uninstall"
|
||||
"/backend-api/ps/plugins/{REMOTE_PLUGIN_ID}/uninstall"
|
||||
)))
|
||||
.and(header("authorization", "Bearer chatgpt-token"))
|
||||
.and(header("chatgpt-account-id", "account-123"))
|
||||
@@ -249,7 +249,7 @@ async fn plugin_uninstall_writes_remote_plugin_to_cloud_when_remote_plugin_enabl
|
||||
wait_for_remote_plugin_request_count(
|
||||
&server,
|
||||
"POST",
|
||||
&format!("/plugins/{REMOTE_PLUGIN_ID}/uninstall"),
|
||||
&format!("/ps/plugins/{REMOTE_PLUGIN_ID}/uninstall"),
|
||||
/*expected_count*/ 1,
|
||||
)
|
||||
.await?;
|
||||
@@ -278,7 +278,7 @@ async fn plugin_uninstall_uses_detail_scope_for_cache_namespace() -> Result<()>
|
||||
|
||||
Mock::given(method("POST"))
|
||||
.and(path(format!(
|
||||
"/backend-api/plugins/{REMOTE_PLUGIN_ID}/uninstall"
|
||||
"/backend-api/ps/plugins/{REMOTE_PLUGIN_ID}/uninstall"
|
||||
)))
|
||||
.and(header("authorization", "Bearer chatgpt-token"))
|
||||
.and(header("chatgpt-account-id", "account-123"))
|
||||
@@ -321,7 +321,7 @@ async fn plugin_uninstall_uses_detail_scope_for_cache_namespace() -> Result<()>
|
||||
wait_for_remote_plugin_request_count(
|
||||
&server,
|
||||
"POST",
|
||||
&format!("/plugins/{REMOTE_PLUGIN_ID}/uninstall"),
|
||||
&format!("/ps/plugins/{REMOTE_PLUGIN_ID}/uninstall"),
|
||||
/*expected_count*/ 1,
|
||||
)
|
||||
.await?;
|
||||
@@ -357,7 +357,7 @@ async fn plugin_uninstall_accepts_workspace_remote_plugin_id_shape() -> Result<(
|
||||
|
||||
Mock::given(method("POST"))
|
||||
.and(path(format!(
|
||||
"/backend-api/plugins/{WORKSPACE_REMOTE_PLUGIN_ID}/uninstall"
|
||||
"/backend-api/ps/plugins/{WORKSPACE_REMOTE_PLUGIN_ID}/uninstall"
|
||||
)))
|
||||
.and(header("authorization", "Bearer chatgpt-token"))
|
||||
.and(header("chatgpt-account-id", "account-123"))
|
||||
@@ -395,7 +395,7 @@ async fn plugin_uninstall_accepts_workspace_remote_plugin_id_shape() -> Result<(
|
||||
wait_for_remote_plugin_request_count(
|
||||
&server,
|
||||
"POST",
|
||||
&format!("/plugins/{WORKSPACE_REMOTE_PLUGIN_ID}/uninstall"),
|
||||
&format!("/ps/plugins/{WORKSPACE_REMOTE_PLUGIN_ID}/uninstall"),
|
||||
/*expected_count*/ 1,
|
||||
)
|
||||
.await?;
|
||||
@@ -451,7 +451,7 @@ async fn plugin_uninstall_rejects_before_post_when_remote_detail_fetch_fails() -
|
||||
wait_for_remote_plugin_request_count(
|
||||
&server,
|
||||
"POST",
|
||||
&format!("/plugins/{REMOTE_PLUGIN_ID}/uninstall"),
|
||||
&format!("/ps/plugins/{REMOTE_PLUGIN_ID}/uninstall"),
|
||||
/*expected_count*/ 0,
|
||||
)
|
||||
.await?;
|
||||
@@ -487,7 +487,7 @@ async fn plugin_uninstall_rejects_remote_plugin_id_with_spaces_before_network_ca
|
||||
wait_for_remote_plugin_request_count(
|
||||
&server,
|
||||
"POST",
|
||||
"/plugins/sample plugin/uninstall",
|
||||
"/ps/plugins/sample plugin/uninstall",
|
||||
/*expected_count*/ 0,
|
||||
)
|
||||
.await?;
|
||||
@@ -522,7 +522,7 @@ async fn plugin_uninstall_rejects_invalid_remote_plugin_id_before_network_call()
|
||||
wait_for_remote_plugin_request_count(
|
||||
&server,
|
||||
"POST",
|
||||
"/plugins/linear/../../oops/uninstall",
|
||||
"/ps/plugins/linear/../../oops/uninstall",
|
||||
/*expected_count*/ 0,
|
||||
)
|
||||
.await?;
|
||||
|
||||
@@ -311,12 +311,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 +558,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 +592,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 +813,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 +989,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 +1088,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?;
|
||||
@@ -1991,12 +2005,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 +2066,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
|
||||
|
||||
@@ -793,6 +793,7 @@ async fn skills_changed_notification_is_emitted_after_skill_change() -> Result<(
|
||||
thread_source: None,
|
||||
dynamic_tools: None,
|
||||
environments: None,
|
||||
selected_capability_roots: None,
|
||||
mock_experimental_field: None,
|
||||
experimental_raw_events: false,
|
||||
})
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -41,8 +41,14 @@ use codex_app_server_protocol::PatchChangeKind;
|
||||
use codex_app_server_protocol::RequestId;
|
||||
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;
|
||||
@@ -53,6 +59,7 @@ use codex_app_server_protocol::TurnStartParams;
|
||||
use codex_app_server_protocol::TurnStartResponse;
|
||||
use codex_app_server_protocol::TurnStartedNotification;
|
||||
use codex_app_server_protocol::TurnStatus;
|
||||
use codex_app_server_protocol::TurnSteerParams;
|
||||
use codex_app_server_protocol::UserInput as V2UserInput;
|
||||
use codex_app_server_protocol::WarningNotification;
|
||||
use codex_config::config_toml::ConfigToml;
|
||||
@@ -589,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
|
||||
@@ -3360,6 +3375,177 @@ 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(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn direct_input_to_multi_agent_v2_subagent_is_rejected() -> Result<()> {
|
||||
const CHILD_PROMPT: &str = "child: do work";
|
||||
const PARENT_PROMPT: &str = "spawn a child and continue";
|
||||
const SPAWN_CALL_ID: &str = "spawn-call-direct-input-rejection";
|
||||
const ERROR_MESSAGE: &str =
|
||||
"direct app-server input is not allowed for multi-agent v2 sub-agents";
|
||||
|
||||
let server = responses::start_mock_server().await;
|
||||
let spawn_args = serde_json::to_string(&json!({
|
||||
"message": CHILD_PROMPT,
|
||||
"task_name": "worker",
|
||||
}))?;
|
||||
let _parent_turn = responses::mount_sse_once_match(
|
||||
&server,
|
||||
|req: &wiremock::Request| body_contains(req, PARENT_PROMPT),
|
||||
responses::sse(vec![
|
||||
responses::ev_response_created("resp-parent-1"),
|
||||
responses::ev_function_call(SPAWN_CALL_ID, "spawn_agent", &spawn_args),
|
||||
responses::ev_completed("resp-parent-1"),
|
||||
]),
|
||||
)
|
||||
.await;
|
||||
let codex_home = TempDir::new()?;
|
||||
create_config_toml(
|
||||
codex_home.path(),
|
||||
&server.uri(),
|
||||
"never",
|
||||
&BTreeMap::from([(Feature::MultiAgentV2, true)]),
|
||||
)?;
|
||||
write_models_cache(codex_home.path())?;
|
||||
|
||||
let mut mcp = TestAppServer::new(codex_home.path()).await?;
|
||||
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
|
||||
|
||||
let thread_req = mcp
|
||||
.send_thread_start_request(ThreadStartParams {
|
||||
model: Some("gpt-5.3-codex".to_string()),
|
||||
..Default::default()
|
||||
})
|
||||
.await?;
|
||||
let thread_resp: JSONRPCResponse = timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
mcp.read_stream_until_response_message(RequestId::Integer(thread_req)),
|
||||
)
|
||||
.await??;
|
||||
let ThreadStartResponse { thread, .. } = to_response::<ThreadStartResponse>(thread_resp)?;
|
||||
|
||||
let turn_req = mcp
|
||||
.send_turn_start_request(TurnStartParams {
|
||||
thread_id: thread.id,
|
||||
input: vec![V2UserInput::Text {
|
||||
text: PARENT_PROMPT.to_string(),
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
..Default::default()
|
||||
})
|
||||
.await?;
|
||||
let turn_resp: JSONRPCResponse = timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
mcp.read_stream_until_response_message(RequestId::Integer(turn_req)),
|
||||
)
|
||||
.await??;
|
||||
let _: TurnStartResponse = to_response(turn_resp)?;
|
||||
|
||||
let child_thread_id = timeout(DEFAULT_READ_TIMEOUT, async {
|
||||
loop {
|
||||
let completed_notif = mcp
|
||||
.read_stream_until_notification_message("item/completed")
|
||||
.await?;
|
||||
let completed: ItemCompletedNotification =
|
||||
serde_json::from_value(completed_notif.params.expect("item/completed params"))?;
|
||||
if let ThreadItem::SubAgentActivity {
|
||||
id,
|
||||
kind: SubAgentActivityKind::Started,
|
||||
agent_thread_id,
|
||||
..
|
||||
} = completed.item
|
||||
&& id == SPAWN_CALL_ID
|
||||
{
|
||||
return Ok::<String, anyhow::Error>(agent_thread_id);
|
||||
}
|
||||
}
|
||||
})
|
||||
.await??;
|
||||
|
||||
let direct_turn_req = mcp
|
||||
.send_turn_start_request(TurnStartParams {
|
||||
thread_id: child_thread_id.clone(),
|
||||
input: vec![V2UserInput::Text {
|
||||
text: "direct app-server turn".to_string(),
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
..Default::default()
|
||||
})
|
||||
.await?;
|
||||
let direct_turn_error: JSONRPCError = timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
mcp.read_stream_until_error_message(RequestId::Integer(direct_turn_req)),
|
||||
)
|
||||
.await??;
|
||||
assert_eq!(direct_turn_error.error.code, INVALID_REQUEST_ERROR_CODE);
|
||||
assert_eq!(direct_turn_error.error.message, ERROR_MESSAGE);
|
||||
|
||||
let direct_steer_req = mcp
|
||||
.send_turn_steer_request(TurnSteerParams {
|
||||
thread_id: child_thread_id,
|
||||
client_user_message_id: None,
|
||||
input: vec![V2UserInput::Text {
|
||||
text: "direct app-server steer".to_string(),
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
responsesapi_client_metadata: None,
|
||||
additional_context: None,
|
||||
expected_turn_id: "any-active-turn".to_string(),
|
||||
})
|
||||
.await?;
|
||||
let direct_steer_error: JSONRPCError = timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
mcp.read_stream_until_error_message(RequestId::Integer(direct_steer_req)),
|
||||
)
|
||||
.await??;
|
||||
assert_eq!(direct_steer_error.error.code, INVALID_REQUEST_ERROR_CODE);
|
||||
assert_eq!(direct_steer_error.error.message, ERROR_MESSAGE);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ use codex_connectors::merge::merge_plugin_connectors;
|
||||
use codex_core::config::Config;
|
||||
pub use codex_core::connectors::list_accessible_connectors_from_mcp_tools;
|
||||
pub use codex_core::connectors::list_accessible_connectors_from_mcp_tools_with_environment_manager;
|
||||
pub use codex_core::connectors::list_accessible_connectors_from_mcp_tools_with_mcp_manager;
|
||||
pub use codex_core::connectors::list_accessible_connectors_from_mcp_tools_with_options;
|
||||
pub use codex_core::connectors::list_accessible_connectors_from_mcp_tools_with_options_and_status;
|
||||
pub use codex_core::connectors::list_cached_accessible_connectors_from_mcp_tools;
|
||||
|
||||
@@ -2141,8 +2141,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
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -2188,7 +2189,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 +2202,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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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"))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,128 @@ 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()
|
||||
}
|
||||
|
||||
/// 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 +420,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 +461,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 +516,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 +747,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 +795,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();
|
||||
|
||||
@@ -24,12 +24,13 @@ pub use auth_elicitation::build_auth_elicitation_plan;
|
||||
pub use auth_elicitation::connector_auth_failure_from_tool_result;
|
||||
pub use codex_apps::CodexAppsToolsCacheKey;
|
||||
pub use codex_apps::codex_apps_tools_cache_key;
|
||||
pub use mcp::codex_apps_mcp_server_config;
|
||||
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;
|
||||
|
||||
@@ -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,17 +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 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>,
|
||||
@@ -213,22 +213,6 @@ impl ToolPluginProvenance {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_codex_apps_mcp(
|
||||
mut servers: HashMap<String, EffectiveMcpServer>,
|
||||
auth: Option<&CodexAuth>,
|
||||
config: &McpConfig,
|
||||
) -> HashMap<String, EffectiveMcpServer> {
|
||||
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)),
|
||||
);
|
||||
} 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)
|
||||
}
|
||||
@@ -244,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 {
|
||||
@@ -278,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(),
|
||||
@@ -348,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(),
|
||||
@@ -381,13 +376,6 @@ pub async fn collect_mcp_server_status_snapshot_with_detail(
|
||||
snapshot
|
||||
}
|
||||
|
||||
pub(crate) fn codex_apps_mcp_url(config: &McpConfig) -> String {
|
||||
codex_apps_mcp_url_for_base_url(
|
||||
&config.chatgpt_base_url,
|
||||
config.apps_mcp_path_override.as_deref(),
|
||||
)
|
||||
}
|
||||
|
||||
/// The Responses API requires tool names to match `^[a-zA-Z0-9_-]+$`.
|
||||
/// MCP server/tool names are user-controlled, so sanitize the fully-qualified
|
||||
/// name we expose to the model by replacing any disallowed character with `_`.
|
||||
@@ -428,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")
|
||||
@@ -437,16 +425,36 @@ 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}")
|
||||
}
|
||||
|
||||
fn codex_apps_mcp_server_config(config: &McpConfig) -> McpServerConfig {
|
||||
let url = codex_apps_mcp_url(config);
|
||||
let http_headers = config.apps_mcp_product_sku.as_ref().map(|product_sku| {
|
||||
HashMap::from([("X-OpenAI-Product-Sku".to_string(), product_sku.clone())])
|
||||
pub fn codex_apps_mcp_server_config(
|
||||
chatgpt_base_url: &str,
|
||||
apps_mcp_product_sku: Option<&str>,
|
||||
) -> McpServerConfig {
|
||||
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())])
|
||||
});
|
||||
|
||||
McpServerConfig {
|
||||
|
||||
@@ -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(),
|
||||
@@ -177,62 +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_mcp_url_uses_legacy_codex_apps_path() {
|
||||
let config = test_mcp_config(PathBuf::from("/tmp"));
|
||||
|
||||
assert_eq!(
|
||||
codex_apps_mcp_url(&config),
|
||||
"https://chatgpt.com/backend-api/wham/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"),
|
||||
@@ -241,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 {
|
||||
@@ -298,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;
|
||||
@@ -354,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));
|
||||
|
||||
|
||||
@@ -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,
|
||||
@@ -162,6 +163,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 +219,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,
|
||||
|
||||
@@ -1124,7 +1124,7 @@ pub async fn uninstall_remote_plugin(
|
||||
let plugin_name = plugin.name;
|
||||
|
||||
let base_url = config.chatgpt_base_url.trim_end_matches('/');
|
||||
let url = format!("{base_url}/plugins/{plugin_id}/uninstall");
|
||||
let url = format!("{base_url}/ps/plugins/{plugin_id}/uninstall");
|
||||
let client = build_reqwest_client();
|
||||
let request = authenticated_request(client.post(&url), auth)?;
|
||||
let response: RemotePluginMutationResponse = send_and_decode(request, &url).await?;
|
||||
|
||||
@@ -169,8 +169,8 @@ where
|
||||
let mut file_systems_by_skill_path: HashMap<AbsolutePathBuf, Arc<dyn ExecutorFileSystem>> =
|
||||
HashMap::new();
|
||||
for root in roots {
|
||||
let root_path = canonicalize_for_skill_identity(&root.path);
|
||||
let fs = root.file_system;
|
||||
let root_path = canonicalize_for_skill_identity(fs.as_ref(), &root.path).await;
|
||||
let skills_before_root = outcome.skills.len();
|
||||
discover_skills_under_root(
|
||||
fs.as_ref(),
|
||||
@@ -471,8 +471,13 @@ fn dedupe_skill_roots_by_path(roots: &mut Vec<SkillRoot>) {
|
||||
roots.retain(|root| seen.insert(root.path.clone()));
|
||||
}
|
||||
|
||||
fn canonicalize_for_skill_identity(path: &AbsolutePathBuf) -> AbsolutePathBuf {
|
||||
path.canonicalize().unwrap_or_else(|_| path.clone())
|
||||
async fn canonicalize_for_skill_identity(
|
||||
fs: &dyn ExecutorFileSystem,
|
||||
path: &AbsolutePathBuf,
|
||||
) -> AbsolutePathBuf {
|
||||
fs.canonicalize(path, /*sandbox*/ None)
|
||||
.await
|
||||
.unwrap_or_else(|_| path.clone())
|
||||
}
|
||||
|
||||
async fn discover_skills_under_root(
|
||||
@@ -483,8 +488,11 @@ async fn discover_skills_under_root(
|
||||
plugin_root: Option<&AbsolutePathBuf>,
|
||||
outcome: &mut SkillLoadOutcome,
|
||||
) {
|
||||
let root = canonicalize_for_skill_identity(root);
|
||||
let plugin_root = plugin_root.map(canonicalize_for_skill_identity);
|
||||
let root = root.clone();
|
||||
let plugin_root = match plugin_root {
|
||||
Some(plugin_root) => Some(canonicalize_for_skill_identity(fs, plugin_root).await),
|
||||
None => None,
|
||||
};
|
||||
|
||||
match fs.get_metadata(&root, /*sandbox*/ None).await {
|
||||
Ok(metadata) if metadata.is_directory => {}
|
||||
@@ -557,7 +565,7 @@ async fn discover_skills_under_root(
|
||||
}
|
||||
match fs.read_directory(&path, /*sandbox*/ None).await {
|
||||
Ok(_) => {
|
||||
let resolved_dir = canonicalize_for_skill_identity(&path);
|
||||
let resolved_dir = canonicalize_for_skill_identity(fs, &path).await;
|
||||
enqueue_dir(
|
||||
&mut queue,
|
||||
&mut visited_dirs,
|
||||
@@ -582,7 +590,7 @@ async fn discover_skills_under_root(
|
||||
}
|
||||
|
||||
if metadata.is_directory {
|
||||
let resolved_dir = canonicalize_for_skill_identity(&path);
|
||||
let resolved_dir = canonicalize_for_skill_identity(fs, &path).await;
|
||||
enqueue_dir(
|
||||
&mut queue,
|
||||
&mut visited_dirs,
|
||||
@@ -672,7 +680,7 @@ async fn parse_skill_file(
|
||||
)?;
|
||||
}
|
||||
|
||||
let resolved_path = canonicalize_for_skill_identity(path);
|
||||
let resolved_path = canonicalize_for_skill_identity(fs, path).await;
|
||||
|
||||
Ok(SkillMetadata {
|
||||
name,
|
||||
|
||||
@@ -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"
|
||||
@@ -836,16 +840,6 @@
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"FeatureToml_for_AppsMcpPathOverrideConfigToml": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "boolean"
|
||||
},
|
||||
{
|
||||
"$ref": "#/definitions/AppsMcpPathOverrideConfigToml"
|
||||
}
|
||||
]
|
||||
},
|
||||
"FeatureToml_for_CodeModeConfigToml": {
|
||||
"anyOf": [
|
||||
{
|
||||
@@ -4534,7 +4528,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"
|
||||
@@ -5224,4 +5234,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
|
||||
|
||||
+31
-35
@@ -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,6 +743,7 @@ 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();
|
||||
@@ -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,
|
||||
|
||||
@@ -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([
|
||||
|
||||
@@ -103,6 +103,7 @@ pub(crate) async fn run_codex_thread_interactive(
|
||||
parent_rollout_thread_trace: codex_rollout_trace::ThreadTraceContext::disabled(),
|
||||
parent_trace: None,
|
||||
environment_selections: parent_ctx.environments.clone(),
|
||||
thread_extension_init: codex_extension_api::ExtensionDataInit::default(),
|
||||
analytics_events_client: Some(parent_session.services.analytics_events_client.clone()),
|
||||
thread_store: Arc::clone(&parent_session.services.thread_store),
|
||||
attestation_provider: parent_session.services.attestation_provider.clone(),
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
@@ -309,6 +310,7 @@ async fn run_compact_task_inner_impl(
|
||||
let compacted_item = CompactedItem {
|
||||
message: summary_text.clone(),
|
||||
replacement_history: Some(new_history.clone()),
|
||||
window_id: None,
|
||||
};
|
||||
sess.replace_compacted_history(new_history, reference_context_item, compacted_item)
|
||||
.await;
|
||||
@@ -579,11 +581,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,
|
||||
|
||||
@@ -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,6 +245,7 @@ async fn run_remote_compact_task_inner_impl(
|
||||
},
|
||||
&turn_context.session_telemetry,
|
||||
&compaction_trace,
|
||||
&window_id,
|
||||
turn_metadata_header.as_deref(),
|
||||
)
|
||||
.await?;
|
||||
@@ -263,6 +264,7 @@ async fn run_remote_compact_task_inner_impl(
|
||||
let compacted_item = CompactedItem {
|
||||
message: String::new(),
|
||||
replacement_history: Some(new_history.clone()),
|
||||
window_id: None,
|
||||
};
|
||||
// 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
|
||||
|
||||
@@ -240,7 +240,7 @@ async fn run_remote_compact_task_inner_impl(
|
||||
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);
|
||||
@@ -264,6 +264,7 @@ async fn run_remote_compact_task_inner_impl(
|
||||
turn_context,
|
||||
client_session,
|
||||
&prompt,
|
||||
&window_id,
|
||||
turn_metadata_header.as_deref(),
|
||||
)
|
||||
.await;
|
||||
@@ -299,6 +300,7 @@ async fn run_remote_compact_task_inner_impl(
|
||||
let compacted_item = CompactedItem {
|
||||
message: String::new(),
|
||||
replacement_history: Some(new_history.clone()),
|
||||
window_id: None,
|
||||
};
|
||||
compaction_trace.record_installed(&CompactionCheckpointTracePayload {
|
||||
input_history: &trace_input_history,
|
||||
@@ -323,6 +325,7 @@ async fn run_remote_compaction_request_v2(
|
||||
turn_context: &TurnContext,
|
||||
client_session: &mut ModelClientSession,
|
||||
prompt: &Prompt,
|
||||
window_id: &str,
|
||||
turn_metadata_header: Option<&str>,
|
||||
) -> CodexResult<RemoteCompactionV2Output> {
|
||||
let max_retries = turn_context
|
||||
@@ -334,6 +337,7 @@ async fn run_remote_compaction_request_v2(
|
||||
loop {
|
||||
let result = match client_session
|
||||
.stream(
|
||||
window_id,
|
||||
prompt,
|
||||
&turn_context.model_info,
|
||||
&turn_context.session_telemetry,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user