mirror of
https://github.com/hanzoai/dev.git
synced 2026-10-25 03:32:32 +00:00
sync: just-every/code 00910569e0 — 31 commits, MCP + tool_search + skills
Takes the upstream delta e4fe007150..00910569e0 from just-every/code, our proximate upstream. hanzo/dev has no merge-base with any upstream (its history was rewritten, so every SHA differs while the root trees are identical), so this lands as a 3-way apply of the delta rather than a merge. Zero conflicts: the upstream delta touches 249 files, our fork diverges in 140, and the two sets intersect in exactly 2 files (protocol.rs, chatwidget.rs), both pure additions that leave every Hanzo identity line alone. Carries the surface the sandbox agent needs, with the codex PR numbers intact:57f42a8113tool search: no clone of immutable metadata on cache hits (#37279)e1831db7c3reuse MCP handlers across sampling steps (#37273)bfb6a6ea22plugin roots in the host skill loader (#37267)1151b23f01start cached MCP servers lazily for subagents (#37261)7a0e974e08harden network proxy MITM authorization (#37211)98da2c4499reserve the `tool_search` namespace for the search tool (#37188)1dc93cdeb0refresh the codex-rs mirror to codex main codex-rs/ is a vendored mirror that just-every refreshes wholesale; patching it directly would be overwritten by the next refresh, so we take their refresh instead of cherry-picking from openai/codex.
This commit is contained in:
@@ -149,6 +149,7 @@ impl ThreadHistoryBuilder {
|
||||
status: payload.status.clone(),
|
||||
revised_prompt: payload.revised_prompt.clone(),
|
||||
result: payload.result.clone(),
|
||||
transparent_background: payload.transparent_background,
|
||||
saved_path: payload.saved_path.clone(),
|
||||
});
|
||||
}
|
||||
@@ -585,4 +586,3 @@ mod tests {
|
||||
assert_eq!(turns, Vec::<Turn>::new());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2801,6 +2801,7 @@ pub enum ThreadItem {
|
||||
status: String,
|
||||
revised_prompt: Option<String>,
|
||||
result: String,
|
||||
transparent_background: Option<bool>,
|
||||
saved_path: Option<AbsolutePathBuf>,
|
||||
},
|
||||
#[serde(rename_all = "camelCase")]
|
||||
@@ -2887,6 +2888,7 @@ impl From<CoreTurnItem> for ThreadItem {
|
||||
status: image.status,
|
||||
revised_prompt: image.revised_prompt,
|
||||
result: image.result,
|
||||
transparent_background: image.transparent_background,
|
||||
saved_path: image.saved_path,
|
||||
},
|
||||
CoreTurnItem::ContextCompaction(compaction) => {
|
||||
|
||||
@@ -4927,6 +4927,7 @@ async fn handle_image_generation_call(
|
||||
status,
|
||||
revised_prompt,
|
||||
result,
|
||||
transparent_background: None,
|
||||
saved_path,
|
||||
}),
|
||||
order,
|
||||
|
||||
@@ -455,6 +455,7 @@ mod tests {
|
||||
display_name: slug.to_string(),
|
||||
description: None,
|
||||
default_reasoning_level: None,
|
||||
model_specialty: None,
|
||||
supported_reasoning_levels: Vec::new(),
|
||||
shell_type: ConfigShellToolType::Default,
|
||||
visibility: ModelVisibility::None,
|
||||
|
||||
@@ -92,6 +92,9 @@ pub struct ImageGenerationItem {
|
||||
pub result: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[ts(optional)]
|
||||
pub transparent_background: Option<bool>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[ts(optional)]
|
||||
pub saved_path: Option<AbsolutePathBuf>,
|
||||
}
|
||||
|
||||
@@ -259,6 +262,7 @@ impl ImageGenerationItem {
|
||||
status: self.status.clone(),
|
||||
revised_prompt: self.revised_prompt.clone(),
|
||||
result: self.result.clone(),
|
||||
transparent_background: self.transparent_background,
|
||||
saved_path: self.saved_path.clone(),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -32,6 +32,8 @@ use crate::config_types::ServiceTier;
|
||||
use crate::config_types::Verbosity;
|
||||
|
||||
const PERSONALITY_PLACEHOLDER: &str = "{{ personality }}";
|
||||
/// Backend model-catalog specialty identifying cybersecurity-focused models.
|
||||
pub const MODEL_SPECIALTY_CYBER: &str = "cyber";
|
||||
pub const SPEED_TIER_FAST: &str = "fast";
|
||||
|
||||
/// See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#get-started-with-reasoning
|
||||
@@ -206,6 +208,8 @@ pub struct ModelPreset {
|
||||
pub display_name: String,
|
||||
/// Short human description shown in UIs.
|
||||
pub description: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub model_specialty: Option<String>,
|
||||
/// Reasoning effort applied when none is explicitly chosen.
|
||||
pub default_reasoning_effort: ReasoningEffort,
|
||||
/// Supported reasoning effort options.
|
||||
@@ -360,6 +364,8 @@ pub struct ModelInfo {
|
||||
pub description: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub default_reasoning_level: Option<ReasoningEffort>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub model_specialty: Option<String>,
|
||||
pub supported_reasoning_levels: Vec<ReasoningEffortPreset>,
|
||||
pub shell_type: ConfigShellToolType,
|
||||
pub visibility: ModelVisibility,
|
||||
@@ -586,6 +592,7 @@ impl From<ModelInfo> for ModelPreset {
|
||||
model: info.slug.clone(),
|
||||
display_name: info.display_name,
|
||||
description: info.description.unwrap_or_default(),
|
||||
model_specialty: info.model_specialty,
|
||||
default_reasoning_effort: info
|
||||
.default_reasoning_level
|
||||
.unwrap_or(ReasoningEffort::None),
|
||||
@@ -728,6 +735,7 @@ mod tests {
|
||||
display_name: "Test Model".to_string(),
|
||||
description: None,
|
||||
default_reasoning_level: None,
|
||||
model_specialty: None,
|
||||
supported_reasoning_levels: vec![],
|
||||
shell_type: ConfigShellToolType::ShellCommand,
|
||||
visibility: ModelVisibility::List,
|
||||
@@ -1013,6 +1021,7 @@ mod tests {
|
||||
assert!(!model.supports_image_detail_original);
|
||||
assert!(!model.supports_search_tool);
|
||||
assert!(!model.use_responses_lite);
|
||||
assert_eq!(model.model_specialty, None);
|
||||
assert_eq!(model.web_search_tool_type, WebSearchToolType::Text);
|
||||
assert_eq!(model.tool_mode, None);
|
||||
}
|
||||
@@ -1096,6 +1105,19 @@ mod tests {
|
||||
assert!(preset.supports_fast_mode());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn model_preset_preserves_model_specialty() {
|
||||
let preset = ModelPreset::from(ModelInfo {
|
||||
model_specialty: Some(MODEL_SPECIALTY_CYBER.to_string()),
|
||||
..test_model(None)
|
||||
});
|
||||
|
||||
assert_eq!(
|
||||
preset.model_specialty.as_deref(),
|
||||
Some(MODEL_SPECIALTY_CYBER)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn model_preset_supports_fast_mode_from_service_tiers() {
|
||||
let preset = ModelPreset::from(ModelInfo {
|
||||
|
||||
@@ -1647,6 +1647,9 @@ pub struct ImageGenerationEndEvent {
|
||||
#[ts(optional)]
|
||||
pub revised_prompt: Option<String>,
|
||||
pub result: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[ts(optional)]
|
||||
pub transparent_background: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[ts(optional)]
|
||||
pub saved_path: Option<AbsolutePathBuf>,
|
||||
@@ -2826,6 +2829,7 @@ mod tests {
|
||||
status: "in_progress".into(),
|
||||
revised_prompt: None,
|
||||
result: String::new(),
|
||||
transparent_background: None,
|
||||
saved_path: None,
|
||||
}),
|
||||
};
|
||||
|
||||
@@ -14129,6 +14129,7 @@ impl ChatWidget<'_> {
|
||||
status,
|
||||
revised_prompt: _,
|
||||
result: _,
|
||||
transparent_background: _,
|
||||
saved_path,
|
||||
}) => {
|
||||
let ok = match event.order.as_ref() {
|
||||
|
||||
Generated
+25
-3
@@ -2035,6 +2035,7 @@ dependencies = [
|
||||
"codex-otel",
|
||||
"codex-plugin",
|
||||
"codex-protocol",
|
||||
"codex-queue-extension",
|
||||
"codex-rmcp-client",
|
||||
"codex-rollout",
|
||||
"codex-sandboxing",
|
||||
@@ -2874,7 +2875,6 @@ dependencies = [
|
||||
"codex-protocol",
|
||||
"codex-shell-command",
|
||||
"codex-skills",
|
||||
"codex-skills-extension",
|
||||
"codex-tools",
|
||||
"codex-utils-absolute-path",
|
||||
"codex-utils-path",
|
||||
@@ -2918,7 +2918,6 @@ dependencies = [
|
||||
"codex-model-provider",
|
||||
"codex-otel",
|
||||
"codex-protocol",
|
||||
"codex-shell-command",
|
||||
"codex-skills",
|
||||
"codex-utils-absolute-path",
|
||||
"codex-utils-path-uri",
|
||||
@@ -2930,7 +2929,6 @@ dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_yaml",
|
||||
"shlex",
|
||||
"tempfile",
|
||||
"tokio",
|
||||
"toml 0.9.11+spec-1.1.0",
|
||||
@@ -3883,6 +3881,27 @@ dependencies = [
|
||||
"wildmatch",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "codex-queue-extension"
|
||||
version = "0.0.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"codex-core",
|
||||
"codex-extension-api",
|
||||
"codex-protocol",
|
||||
"codex-state",
|
||||
"codex-thread-store",
|
||||
"codex-utils-absolute-path",
|
||||
"core_test_support",
|
||||
"pretty_assertions",
|
||||
"serde_json",
|
||||
"tempfile",
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "codex-response-debug-context"
|
||||
version = "0.0.0"
|
||||
@@ -4087,12 +4106,14 @@ name = "codex-skills"
|
||||
version = "0.0.0"
|
||||
dependencies = [
|
||||
"codex-protocol",
|
||||
"codex-shell-command",
|
||||
"codex-utils-absolute-path",
|
||||
"codex-utils-path-uri",
|
||||
"include_dir",
|
||||
"pretty_assertions",
|
||||
"serde",
|
||||
"serde_yaml",
|
||||
"shlex",
|
||||
"thiserror 2.0.18",
|
||||
"tracing",
|
||||
]
|
||||
@@ -4209,6 +4230,7 @@ dependencies = [
|
||||
"codex-state",
|
||||
"codex-utils-absolute-path",
|
||||
"codex-utils-path",
|
||||
"codex-utils-path-uri",
|
||||
"futures",
|
||||
"pretty_assertions",
|
||||
"pulldown-cmark",
|
||||
|
||||
@@ -61,6 +61,7 @@ members = [
|
||||
"ext/items",
|
||||
"ext/memories",
|
||||
"ext/mcp",
|
||||
"ext/queue",
|
||||
"ext/skills",
|
||||
"ext/web-search",
|
||||
"external-agent-migration",
|
||||
@@ -226,6 +227,7 @@ codex-model-provider = { path = "model-provider" }
|
||||
codex-process-hardening = { path = "process-hardening" }
|
||||
codex-protocol = { path = "protocol" }
|
||||
codex-prompts = { path = "prompts" }
|
||||
codex-queue-extension = { path = "ext/queue" }
|
||||
codex-responses-api-proxy = { path = "responses-api-proxy" }
|
||||
codex-response-debug-context = { path = "response-debug-context" }
|
||||
codex-rmcp-client = { path = "rmcp-client" }
|
||||
|
||||
@@ -4786,6 +4786,7 @@ async fn turn_event_counts_completed_tool_items() {
|
||||
status: "completed".to_string(),
|
||||
revised_prompt: None,
|
||||
result: "ok".to_string(),
|
||||
transparent_background: None,
|
||||
saved_path: None,
|
||||
}),
|
||||
];
|
||||
|
||||
@@ -616,7 +616,9 @@ impl AnalyticsEventsClient {
|
||||
pub fn track_notification(&self, notification: &ServerNotification) {
|
||||
if !matches!(
|
||||
notification,
|
||||
ServerNotification::ThreadClosed(_)
|
||||
ServerNotification::ThreadArchived(_)
|
||||
| ServerNotification::ThreadClosed(_)
|
||||
| ServerNotification::ThreadUnarchived(_)
|
||||
| ServerNotification::TurnStarted(_)
|
||||
| ServerNotification::TurnCompleted(_)
|
||||
| ServerNotification::TurnDiffUpdated(_)
|
||||
|
||||
@@ -34,6 +34,12 @@ use crate::events::FinalApprovalOutcome;
|
||||
use crate::events::SkillInvocationEventParams;
|
||||
use crate::events::SkillInvocationEventRequest;
|
||||
#[cfg(debug_assertions)]
|
||||
use crate::events::ThreadArchiveAction;
|
||||
#[cfg(debug_assertions)]
|
||||
use crate::events::ThreadArchiveEvent;
|
||||
#[cfg(debug_assertions)]
|
||||
use crate::events::ThreadArchiveEventParams;
|
||||
#[cfg(debug_assertions)]
|
||||
use crate::events::ToolItemTerminalStatus;
|
||||
use crate::events::TrackEventRequest;
|
||||
use crate::facts::AnalyticsFact;
|
||||
@@ -50,6 +56,7 @@ use codex_app_server_protocol::SessionSource as AppServerSessionSource;
|
||||
use codex_app_server_protocol::Thread;
|
||||
use codex_app_server_protocol::ThreadArchiveParams;
|
||||
use codex_app_server_protocol::ThreadArchiveResponse;
|
||||
use codex_app_server_protocol::ThreadArchivedNotification;
|
||||
use codex_app_server_protocol::ThreadForkResponse;
|
||||
use codex_app_server_protocol::ThreadResumeResponse;
|
||||
use codex_app_server_protocol::ThreadStartResponse;
|
||||
@@ -375,6 +382,14 @@ async fn api_key_auth_sends_only_plugin_events_to_codex_backend() {
|
||||
sample_mcp_tool_call_event("non-plugin-mcp", /*plugin_id*/ None),
|
||||
sample_plugin_used_track_event("non-plugin-used", /*plugin_id*/ None),
|
||||
sample_accepted_line_fingerprint_event("other-event"),
|
||||
TrackEventRequest::ThreadArchive(ThreadArchiveEvent {
|
||||
event_type: "codex_thread_archive_event",
|
||||
event_params: ThreadArchiveEventParams {
|
||||
thread_id: "non-plugin-thread-archive".to_string(),
|
||||
action: ThreadArchiveAction::Archived,
|
||||
occurred_at_ms: 1,
|
||||
},
|
||||
}),
|
||||
sample_plugin_used_track_event("plugin-used", Some("sample@test")),
|
||||
sample_skill_track_event("plugin-skill", Some("sample@test")),
|
||||
sample_mcp_tool_call_event("plugin-mcp", Some("sample@test")),
|
||||
@@ -729,7 +744,20 @@ async fn flush_waits_for_preceding_fact_delivery() {
|
||||
|
||||
#[tokio::test]
|
||||
async fn flush_is_noop_when_analytics_is_disabled() {
|
||||
AnalyticsEventsClient::disabled().flush().await;
|
||||
let client = AnalyticsEventsClient::new(
|
||||
codex_login::AuthManager::from_auth_for_testing(
|
||||
codex_login::CodexAuth::create_dummy_chatgpt_auth_for_testing(),
|
||||
),
|
||||
"https://chatgpt.com/backend-api".to_string(),
|
||||
/*analytics_enabled*/ Some(false),
|
||||
);
|
||||
client.track_notification(&ServerNotification::ThreadArchived(
|
||||
ThreadArchivedNotification {
|
||||
thread_id: "thread-1".to_string(),
|
||||
},
|
||||
));
|
||||
assert!(client.queue.is_none());
|
||||
client.flush().await;
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -63,6 +63,7 @@ pub(crate) struct TrackEventsRequest {
|
||||
pub(crate) enum TrackEventRequest {
|
||||
SkillInvocation(SkillInvocationEventRequest),
|
||||
ThreadInitialized(ThreadInitializedEvent),
|
||||
ThreadArchive(ThreadArchiveEvent),
|
||||
GuardianReview(Box<GuardianReviewEventRequest>),
|
||||
AppMentioned(CodexAppMentionedEventRequest),
|
||||
AppUsed(CodexAppUsedEventRequest),
|
||||
@@ -187,6 +188,26 @@ pub(crate) struct ThreadInitializedEvent {
|
||||
pub(crate) event_params: ThreadInitializedEventParams,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Serialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub(crate) enum ThreadArchiveAction {
|
||||
Archived,
|
||||
Unarchived,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub(crate) struct ThreadArchiveEventParams {
|
||||
pub(crate) thread_id: String,
|
||||
pub(crate) action: ThreadArchiveAction,
|
||||
pub(crate) occurred_at_ms: u64,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub(crate) struct ThreadArchiveEvent {
|
||||
pub(crate) event_type: &'static str,
|
||||
pub(crate) event_params: ThreadArchiveEventParams,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub(crate) struct GuardianReviewEventRequest {
|
||||
pub(crate) event_type: &'static str,
|
||||
|
||||
@@ -51,6 +51,9 @@ use crate::events::ReviewTrigger;
|
||||
use crate::events::Reviewer;
|
||||
use crate::events::SkillInvocationEventParams;
|
||||
use crate::events::SkillInvocationEventRequest;
|
||||
use crate::events::ThreadArchiveAction;
|
||||
use crate::events::ThreadArchiveEvent;
|
||||
use crate::events::ThreadArchiveEventParams;
|
||||
use crate::events::ThreadInitializedEvent;
|
||||
use crate::events::ThreadInitializedEventParams;
|
||||
use crate::events::ToolItemFailureKind;
|
||||
@@ -98,6 +101,7 @@ use crate::facts::TurnStatus;
|
||||
use crate::facts::TurnSteerRejectionReason;
|
||||
use crate::facts::TurnSteerResult;
|
||||
use crate::facts::TurnTokenUsageFact;
|
||||
use crate::now_unix_millis;
|
||||
use crate::now_unix_seconds;
|
||||
use crate::option_i64_to_u64;
|
||||
use crate::serialize_enum_as_string;
|
||||
@@ -1572,6 +1576,26 @@ impl AnalyticsReducer {
|
||||
out: &mut Vec<TrackEventRequest>,
|
||||
) {
|
||||
match notification {
|
||||
ServerNotification::ThreadArchived(notification) => {
|
||||
out.push(TrackEventRequest::ThreadArchive(ThreadArchiveEvent {
|
||||
event_type: "codex_thread_archive_event",
|
||||
event_params: ThreadArchiveEventParams {
|
||||
thread_id: notification.thread_id,
|
||||
action: ThreadArchiveAction::Archived,
|
||||
occurred_at_ms: now_unix_millis(),
|
||||
},
|
||||
}));
|
||||
}
|
||||
ServerNotification::ThreadUnarchived(notification) => {
|
||||
out.push(TrackEventRequest::ThreadArchive(ThreadArchiveEvent {
|
||||
event_type: "codex_thread_archive_event",
|
||||
event_params: ThreadArchiveEventParams {
|
||||
thread_id: notification.thread_id,
|
||||
action: ThreadArchiveAction::Unarchived,
|
||||
occurred_at_ms: now_unix_millis(),
|
||||
},
|
||||
}));
|
||||
}
|
||||
ServerNotification::ItemStarted(notification) => {
|
||||
let Some(item_id) = tracked_tool_item_id(¬ification.item) else {
|
||||
return;
|
||||
|
||||
@@ -4731,6 +4731,13 @@
|
||||
"status": {
|
||||
"type": "string"
|
||||
},
|
||||
"transparentBackground": {
|
||||
"default": null,
|
||||
"type": [
|
||||
"boolean",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"type": {
|
||||
"enum": [
|
||||
"imageGeneration"
|
||||
|
||||
+7
@@ -19779,6 +19779,13 @@
|
||||
"status": {
|
||||
"type": "string"
|
||||
},
|
||||
"transparentBackground": {
|
||||
"default": null,
|
||||
"type": [
|
||||
"boolean",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"type": {
|
||||
"enum": [
|
||||
"imageGeneration"
|
||||
|
||||
+7
@@ -17538,6 +17538,13 @@
|
||||
"status": {
|
||||
"type": "string"
|
||||
},
|
||||
"transparentBackground": {
|
||||
"default": null,
|
||||
"type": [
|
||||
"boolean",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"type": {
|
||||
"enum": [
|
||||
"imageGeneration"
|
||||
|
||||
@@ -1241,6 +1241,13 @@
|
||||
"status": {
|
||||
"type": "string"
|
||||
},
|
||||
"transparentBackground": {
|
||||
"default": null,
|
||||
"type": [
|
||||
"boolean",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"type": {
|
||||
"enum": [
|
||||
"imageGeneration"
|
||||
|
||||
@@ -1241,6 +1241,13 @@
|
||||
"status": {
|
||||
"type": "string"
|
||||
},
|
||||
"transparentBackground": {
|
||||
"default": null,
|
||||
"type": [
|
||||
"boolean",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"type": {
|
||||
"enum": [
|
||||
"imageGeneration"
|
||||
|
||||
@@ -1386,6 +1386,13 @@
|
||||
"status": {
|
||||
"type": "string"
|
||||
},
|
||||
"transparentBackground": {
|
||||
"default": null,
|
||||
"type": [
|
||||
"boolean",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"type": {
|
||||
"enum": [
|
||||
"imageGeneration"
|
||||
|
||||
@@ -1935,6 +1935,13 @@
|
||||
"status": {
|
||||
"type": "string"
|
||||
},
|
||||
"transparentBackground": {
|
||||
"default": null,
|
||||
"type": [
|
||||
"boolean",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"type": {
|
||||
"enum": [
|
||||
"imageGeneration"
|
||||
|
||||
@@ -1726,6 +1726,13 @@
|
||||
"status": {
|
||||
"type": "string"
|
||||
},
|
||||
"transparentBackground": {
|
||||
"default": null,
|
||||
"type": [
|
||||
"boolean",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"type": {
|
||||
"enum": [
|
||||
"imageGeneration"
|
||||
|
||||
+7
@@ -1726,6 +1726,13 @@
|
||||
"status": {
|
||||
"type": "string"
|
||||
},
|
||||
"transparentBackground": {
|
||||
"default": null,
|
||||
"type": [
|
||||
"boolean",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"type": {
|
||||
"enum": [
|
||||
"imageGeneration"
|
||||
|
||||
@@ -1726,6 +1726,13 @@
|
||||
"status": {
|
||||
"type": "string"
|
||||
},
|
||||
"transparentBackground": {
|
||||
"default": null,
|
||||
"type": [
|
||||
"boolean",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"type": {
|
||||
"enum": [
|
||||
"imageGeneration"
|
||||
|
||||
@@ -1935,6 +1935,13 @@
|
||||
"status": {
|
||||
"type": "string"
|
||||
},
|
||||
"transparentBackground": {
|
||||
"default": null,
|
||||
"type": [
|
||||
"boolean",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"type": {
|
||||
"enum": [
|
||||
"imageGeneration"
|
||||
|
||||
@@ -1726,6 +1726,13 @@
|
||||
"status": {
|
||||
"type": "string"
|
||||
},
|
||||
"transparentBackground": {
|
||||
"default": null,
|
||||
"type": [
|
||||
"boolean",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"type": {
|
||||
"enum": [
|
||||
"imageGeneration"
|
||||
|
||||
@@ -1935,6 +1935,13 @@
|
||||
"status": {
|
||||
"type": "string"
|
||||
},
|
||||
"transparentBackground": {
|
||||
"default": null,
|
||||
"type": [
|
||||
"boolean",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"type": {
|
||||
"enum": [
|
||||
"imageGeneration"
|
||||
|
||||
@@ -1726,6 +1726,13 @@
|
||||
"status": {
|
||||
"type": "string"
|
||||
},
|
||||
"transparentBackground": {
|
||||
"default": null,
|
||||
"type": [
|
||||
"boolean",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"type": {
|
||||
"enum": [
|
||||
"imageGeneration"
|
||||
|
||||
@@ -1726,6 +1726,13 @@
|
||||
"status": {
|
||||
"type": "string"
|
||||
},
|
||||
"transparentBackground": {
|
||||
"default": null,
|
||||
"type": [
|
||||
"boolean",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"type": {
|
||||
"enum": [
|
||||
"imageGeneration"
|
||||
|
||||
@@ -1386,6 +1386,13 @@
|
||||
"status": {
|
||||
"type": "string"
|
||||
},
|
||||
"transparentBackground": {
|
||||
"default": null,
|
||||
"type": [
|
||||
"boolean",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"type": {
|
||||
"enum": [
|
||||
"imageGeneration"
|
||||
|
||||
@@ -1386,6 +1386,13 @@
|
||||
"status": {
|
||||
"type": "string"
|
||||
},
|
||||
"transparentBackground": {
|
||||
"default": null,
|
||||
"type": [
|
||||
"boolean",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"type": {
|
||||
"enum": [
|
||||
"imageGeneration"
|
||||
|
||||
@@ -1386,6 +1386,13 @@
|
||||
"status": {
|
||||
"type": "string"
|
||||
},
|
||||
"transparentBackground": {
|
||||
"default": null,
|
||||
"type": [
|
||||
"boolean",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"type": {
|
||||
"enum": [
|
||||
"imageGeneration"
|
||||
|
||||
BIN
Binary file not shown.
BIN
Binary file not shown.
@@ -3,4 +3,4 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
import type { AbsolutePathBuf } from "./AbsolutePathBuf";
|
||||
|
||||
export type ImageGenerationItem = { id: string, status: string, revisedPrompt: string | null, result: string, savedPath?: AbsolutePathBuf, };
|
||||
export type ImageGenerationItem = { id: string, status: string, revisedPrompt: string | null, result: string, transparentBackground?: boolean, savedPath?: AbsolutePathBuf, };
|
||||
|
||||
@@ -862,6 +862,7 @@ impl ThreadHistoryBuilder {
|
||||
status: String::new(),
|
||||
revised_prompt: None,
|
||||
result: String::new(),
|
||||
transparent_background: None,
|
||||
saved_path: None,
|
||||
});
|
||||
self.upsert_item_in_current_turn(item);
|
||||
@@ -873,6 +874,7 @@ impl ThreadHistoryBuilder {
|
||||
status: payload.status.clone(),
|
||||
revised_prompt: payload.revised_prompt.clone(),
|
||||
result: payload.result.clone(),
|
||||
transparent_background: payload.transparent_background,
|
||||
saved_path: payload.saved_path.clone(),
|
||||
});
|
||||
self.upsert_item_in_current_turn(item);
|
||||
@@ -2076,6 +2078,7 @@ mod tests {
|
||||
status: "completed".to_string(),
|
||||
revised_prompt: Some("A blue square".to_string()),
|
||||
result: "cG5n".to_string(),
|
||||
transparent_background: Some(true),
|
||||
saved_path: Some(saved_path.clone()),
|
||||
},
|
||||
)),
|
||||
@@ -2106,6 +2109,7 @@ mod tests {
|
||||
status: "completed".to_string(),
|
||||
revised_prompt: Some("A blue square".to_string()),
|
||||
result: "cG5n".to_string(),
|
||||
transparent_background: Some(true),
|
||||
saved_path: Some(saved_path),
|
||||
})]
|
||||
);
|
||||
@@ -2368,6 +2372,7 @@ mod tests {
|
||||
status: "completed".into(),
|
||||
revised_prompt: Some("final prompt".into()),
|
||||
result: "Zm9v".into(),
|
||||
transparent_background: Some(true),
|
||||
saved_path: Some(test_path_buf("/tmp/ig_123.png").abs()),
|
||||
})),
|
||||
RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent {
|
||||
@@ -2407,6 +2412,7 @@ mod tests {
|
||||
status: "completed".into(),
|
||||
revised_prompt: Some("final prompt".into()),
|
||||
result: "Zm9v".into(),
|
||||
transparent_background: Some(true),
|
||||
saved_path: Some(test_path_buf("/tmp/ig_123.png").abs()),
|
||||
}),
|
||||
],
|
||||
|
||||
@@ -928,6 +928,7 @@ impl From<CoreTurnItem> for ThreadItem {
|
||||
status: image.status,
|
||||
revised_prompt: image.revised_prompt,
|
||||
result: image.result,
|
||||
transparent_background: None,
|
||||
saved_path: image.saved_path,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -74,6 +74,7 @@ codex-mcp-extension = { workspace = true }
|
||||
codex-model-provider = { workspace = true }
|
||||
codex-models-manager = { workspace = true }
|
||||
codex-protocol = { workspace = true }
|
||||
codex-queue-extension = { workspace = true }
|
||||
codex-app-server-protocol = { workspace = true }
|
||||
codex-app-server-transport = { workspace = true }
|
||||
codex-feedback = { workspace = true }
|
||||
|
||||
@@ -1544,6 +1544,7 @@ The app-server streams JSON-RPC notifications while a turn is running. Each turn
|
||||
- `mcpToolCall` — `{id, server, tool, status, arguments, appContext, mcpAppResourceUri?, pluginId, readOnlyHint, result?, error?}` describing MCP calls; `appContext` is `{connectorId, linkId, resourceUri, appName, actionName}` for calls through a trusted MCP app, where `connectorId` identifies the connector that owns the tool, `linkId` identifies the app link, `resourceUri` points to the widget template, `appName` is the connector's display name, and `actionName` is the stable connector `Action.name`. `readOnlyHint` is `true` for read-only tools, `false` for write-capable tools, and `null` when the annotation is unavailable, including older rollout entries. The hint describes tool capability, not whether an invocation succeeded or performed a write; use `status`, `result`, and `error` to determine the execution outcome. `appName` and `actionName` may be null for older rollout entries. The top-level `mcpAppResourceUri` is deprecated and temporarily duplicated for client migration. `tool` identifies the raw MCP tool. `status` is `inProgress`, `completed`, or `failed`.
|
||||
- `collabToolCall` — `{id, tool, status, senderThreadId, receiverThreadId?, newThreadId?, prompt?, agentStatus?}` describing collab tool calls (`spawn_agent`, `send_input`, `resume_agent`, `wait`, `close_agent`); `status` is `inProgress`, `completed`, or `failed`.
|
||||
- `webSearch` — `{id, query, action?, results?}` for a web search request issued by the agent; `action` mirrors the Responses API web_search action payload (`search`, `open_page`, `find_in_page`) and may be omitted until completion. For standalone web search, `results` contains the out-of-band structured result DTOs returned by `/v1/alpha/search`; clients should ignore result types and fields they do not understand.
|
||||
- `imageGeneration` — `{id, status, revisedPrompt, result, transparentBackground, savedPath?}` for a generated image. `transparentBackground` is `true` when the Images API reports a transparent background, `false` when it reports an opaque background, and `null` when the background is automatic, unavailable, or the item has not completed. The field is always present on v2 item payloads, including persisted and resumed items.
|
||||
- `imageView` — `{id, path}` emitted when the agent invokes the image viewer tool.
|
||||
- `sleep` — `{id, durationMs}` emitted while the agent waits for a duration or new input.
|
||||
- `enteredReviewMode` — `{id, review}` sent when the reviewer starts; `review` is a short user-facing label such as `"current changes"` or the requested target description.
|
||||
|
||||
@@ -1186,6 +1186,7 @@ pub(crate) async fn apply_bespoke_event_handling(
|
||||
))
|
||||
.await;
|
||||
}
|
||||
EventMsg::ThreadQueueChanged(_) => {}
|
||||
EventMsg::ThreadSettingsApplied(thread_settings_event) => {
|
||||
let thread_settings =
|
||||
thread_settings_from_core_snapshot(thread_settings_event.thread_settings);
|
||||
|
||||
@@ -26,7 +26,7 @@ use codex_protocol::error::CodexErr;
|
||||
use codex_protocol::protocol::Event;
|
||||
use codex_protocol::protocol::EventMsg;
|
||||
use codex_rollout::state_db::StateDbHandle;
|
||||
use codex_thread_store::ThreadStore;
|
||||
use codex_thread_store::QueueStore;
|
||||
|
||||
use crate::outgoing_message::OutgoingMessageSender;
|
||||
use crate::outgoing_message::ThreadScopedOutgoingMessageSender;
|
||||
@@ -44,8 +44,8 @@ pub(crate) struct ThreadExtensionDependencies {
|
||||
pub(crate) executor_skill_provider: Arc<dyn codex_skills_extension::SkillProvider>,
|
||||
pub(crate) git_attribution_base_url: String,
|
||||
pub(crate) http_client_factory: HttpClientFactory,
|
||||
/// Process-scoped persistence backend for extensions that need stored thread history.
|
||||
pub(crate) thread_store: Arc<dyn ThreadStore>,
|
||||
/// Process-scoped persistence backend for queued user messages.
|
||||
pub(crate) queue_store: Option<Arc<dyn QueueStore>>,
|
||||
}
|
||||
|
||||
pub(crate) fn thread_extensions<S>(
|
||||
@@ -66,9 +66,17 @@ where
|
||||
executor_skill_provider,
|
||||
git_attribution_base_url,
|
||||
http_client_factory,
|
||||
thread_store: _thread_store,
|
||||
queue_store,
|
||||
} = dependencies;
|
||||
let mut builder = ExtensionRegistryBuilder::<Config>::with_event_sink(event_sink);
|
||||
let mut builder = ExtensionRegistryBuilder::<Config>::with_event_sink(Arc::clone(&event_sink));
|
||||
if let Some(queue) = queue_store {
|
||||
let queue_service = Arc::new(codex_queue_extension::QueuedItemService::new(
|
||||
queue,
|
||||
thread_manager.clone(),
|
||||
event_sink,
|
||||
));
|
||||
codex_queue_extension::install(&mut builder, queue_service);
|
||||
}
|
||||
if let Some(state_db) = state_db {
|
||||
codex_goal_extension::install_with_backend(
|
||||
&mut builder,
|
||||
|
||||
@@ -339,7 +339,7 @@ enabled = false
|
||||
executor_skill_provider: Arc::clone(&executor_skill_provider),
|
||||
git_attribution_base_url: good_config.chatgpt_base_url.clone(),
|
||||
http_client_factory: good_config.http_client_factory(),
|
||||
thread_store: Arc::clone(&thread_store),
|
||||
queue_store: None,
|
||||
},
|
||||
),
|
||||
Arc::new(CodexHomeUserInstructionsProvider::new(
|
||||
|
||||
@@ -66,6 +66,7 @@ use codex_chatgpt::workspace_settings;
|
||||
use codex_code_mode::CodeModeSessionProvider;
|
||||
use codex_core::ThreadManager;
|
||||
use codex_core::config::Config;
|
||||
use codex_core::config::ThreadStoreConfig;
|
||||
use codex_exec_server::EnvironmentManager;
|
||||
use codex_feedback::CodexFeedback;
|
||||
use codex_goal_extension::GoalService;
|
||||
@@ -77,6 +78,8 @@ use codex_protocol::protocol::SessionSource;
|
||||
use codex_protocol::protocol::W3cTraceContext;
|
||||
use codex_rollout::StateDbHandle;
|
||||
use codex_state::log_db::LogDbLayer;
|
||||
use codex_thread_store::LocalQueueStore;
|
||||
use codex_thread_store::QueueStore;
|
||||
use tokio::sync::Mutex;
|
||||
use tokio::sync::Semaphore;
|
||||
use tokio::sync::broadcast;
|
||||
@@ -247,6 +250,14 @@ impl MessageProcessor {
|
||||
// affect per-thread behavior, but they must not move newly started,
|
||||
// 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());
|
||||
// Queue persistence requires SQLite, so in-memory thread stores and
|
||||
// app servers without a state database do not have a queue backend.
|
||||
let queue_store: Option<Arc<dyn QueueStore>> = match &config.experimental_thread_store {
|
||||
ThreadStoreConfig::Local => state_db.as_ref().map(|state_db| {
|
||||
Arc::new(LocalQueueStore::new(Arc::clone(state_db))) as Arc<dyn QueueStore>
|
||||
}),
|
||||
ThreadStoreConfig::InMemory { .. } => None,
|
||||
};
|
||||
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();
|
||||
@@ -281,7 +292,7 @@ impl MessageProcessor {
|
||||
executor_skill_provider: Arc::clone(&executor_skill_provider),
|
||||
git_attribution_base_url: config.chatgpt_base_url.clone(),
|
||||
http_client_factory: config.http_client_factory(),
|
||||
thread_store: Arc::clone(&thread_store),
|
||||
queue_store,
|
||||
},
|
||||
),
|
||||
Arc::new(CodexHomeUserInstructionsProvider::new(
|
||||
|
||||
@@ -572,6 +572,13 @@ impl OutgoingMessageSender {
|
||||
}
|
||||
|
||||
pub(crate) async fn send_server_notification(&self, notification: ServerNotification) {
|
||||
if matches!(
|
||||
notification,
|
||||
ServerNotification::ThreadArchived(_) | ServerNotification::ThreadUnarchived(_)
|
||||
) {
|
||||
self.analytics_events_client
|
||||
.track_notification(¬ification);
|
||||
}
|
||||
self.send_server_notification_to_connections(&[], notification)
|
||||
.await;
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ use codex_mcp::CODEX_APPS_MCP_SERVER_NAME;
|
||||
use codex_mcp::MCP_TOOL_CODEX_APPS_META_KEY;
|
||||
use codex_mcp::McpRuntime;
|
||||
use codex_mcp::McpRuntimeInput;
|
||||
use codex_mcp::McpStartupPolicy;
|
||||
use codex_mcp::ToolInfo;
|
||||
use codex_mcp::effective_mcp_servers;
|
||||
use codex_mcp::host_owned_codex_apps_enabled;
|
||||
@@ -91,6 +92,7 @@ impl AppsRequestProcessor {
|
||||
host_owned_codex_apps_enabled(&mcp_config, auth.as_ref())
|
||||
.then(|| Arc::clone(&self.auth_manager));
|
||||
let runtime = McpRuntime::new(McpRuntimeInput {
|
||||
startup_policy: McpStartupPolicy::Eager,
|
||||
config: Arc::clone(&mcp_config),
|
||||
plugins_available: false,
|
||||
ready_selected_capability_roots: Vec::new(),
|
||||
|
||||
@@ -65,18 +65,11 @@ impl ThreadGoalRequestProcessor {
|
||||
.map(|()| None)
|
||||
}
|
||||
|
||||
pub(crate) async fn emit_resume_goal_snapshot_and_continue(
|
||||
&self,
|
||||
thread_id: ThreadId,
|
||||
thread: &CodexThread,
|
||||
) {
|
||||
pub(crate) async fn emit_resume_goal_snapshot(&self, thread_id: ThreadId) {
|
||||
if !self.config.features.enabled(Feature::Goals) {
|
||||
return;
|
||||
}
|
||||
self.emit_thread_goal_snapshot(thread_id).await;
|
||||
// App-server owns resume response and snapshot ordering, so wait until
|
||||
// those are sent before letting extensions react to the idle thread.
|
||||
thread.emit_thread_idle_lifecycle_if_idle().await;
|
||||
}
|
||||
|
||||
pub(crate) async fn pending_resume_goal_state(
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
use super::*;
|
||||
use crate::extensions::send_thread_warning;
|
||||
use codex_extension_api::ThreadIdleCause;
|
||||
use codex_protocol::config_types::MultiAgentMode;
|
||||
|
||||
pub(super) const THREAD_UNLOADING_DELAY: Duration = Duration::from_secs(30 * 60);
|
||||
@@ -750,9 +751,9 @@ pub(super) async fn handle_pending_thread_resume_request(
|
||||
.await;
|
||||
// App-server owns resume response and snapshot ordering, so wait until
|
||||
// replay completes before letting extensions react to the idle thread.
|
||||
if pending.emit_thread_goal_update {
|
||||
conversation.emit_thread_idle_lifecycle_if_idle().await;
|
||||
}
|
||||
conversation
|
||||
.emit_thread_idle_lifecycle_if_idle(ThreadIdleCause::Completed)
|
||||
.await;
|
||||
}
|
||||
|
||||
pub(super) async fn send_thread_goal_snapshot_notification(
|
||||
|
||||
@@ -8,6 +8,7 @@ use codex_app_server_protocol::ThreadSection;
|
||||
use codex_app_server_protocol::ThreadSectionMoveParams;
|
||||
use codex_app_server_protocol::ThreadSectionMoveResponse;
|
||||
use codex_extension_api::ExtensionDataInit;
|
||||
use codex_extension_api::ThreadIdleCause;
|
||||
use codex_protocol::config_types::MultiAgentMode;
|
||||
use codex_protocol::error::CodexErrorDetails;
|
||||
use codex_protocol::mcp::ClientMcpExtensions;
|
||||
@@ -3438,7 +3439,10 @@ impl ThreadRequestProcessor {
|
||||
.await;
|
||||
}
|
||||
self.thread_goal_processor
|
||||
.emit_resume_goal_snapshot_and_continue(thread_id, codex_thread.as_ref())
|
||||
.emit_resume_goal_snapshot(thread_id)
|
||||
.await;
|
||||
codex_thread
|
||||
.emit_thread_idle_lifecycle_if_idle(ThreadIdleCause::Completed)
|
||||
.await;
|
||||
}
|
||||
Err(err) => {
|
||||
|
||||
@@ -106,6 +106,7 @@ mod tests {
|
||||
status: "completed".to_string(),
|
||||
revised_prompt: Some("revised".to_string()),
|
||||
result: "base64-result".to_string(),
|
||||
transparent_background: None,
|
||||
saved_path: Some(test_path_buf("/tmp/ig-1.png").abs()),
|
||||
}),
|
||||
]);
|
||||
|
||||
@@ -66,6 +66,9 @@ fn normalized_summary_path(mut summary: ConversationSummary) -> Result<Conversat
|
||||
if !summary.path.as_os_str().is_empty() {
|
||||
summary.path = normalized_canonical_path(summary.path)?;
|
||||
}
|
||||
if !summary.cwd.as_os_str().is_empty() {
|
||||
summary.cwd = AbsolutePathBuf::from_absolute_path(summary.cwd)?.into_path_buf();
|
||||
}
|
||||
Ok(summary)
|
||||
}
|
||||
|
||||
|
||||
@@ -10,6 +10,10 @@ use app_test_support::write_chatgpt_auth;
|
||||
use codex_app_server_protocol::ImageGenerationItem;
|
||||
use codex_app_server_protocol::ItemCompletedNotification;
|
||||
use codex_app_server_protocol::ThreadItem;
|
||||
use codex_app_server_protocol::ThreadReadParams;
|
||||
use codex_app_server_protocol::ThreadReadResponse;
|
||||
use codex_app_server_protocol::ThreadResumeParams;
|
||||
use codex_app_server_protocol::ThreadResumeResponse;
|
||||
use codex_app_server_protocol::ThreadStartParams;
|
||||
use codex_app_server_protocol::ThreadStartResponse;
|
||||
use codex_app_server_protocol::TurnStartParams;
|
||||
@@ -117,6 +121,7 @@ async fn standalone_image_generation_returns_saved_path_hint_to_model() -> Resul
|
||||
status,
|
||||
revised_prompt,
|
||||
result,
|
||||
transparent_background,
|
||||
saved_path: Some(saved_path),
|
||||
..
|
||||
}) = completed.item
|
||||
@@ -126,6 +131,7 @@ async fn standalone_image_generation_returns_saved_path_hint_to_model() -> Resul
|
||||
assert_eq!(status, "completed");
|
||||
assert_eq!(revised_prompt.as_deref(), Some("paint a blue whale"));
|
||||
assert_eq!(result, RESULT);
|
||||
assert_eq!(transparent_background, Some(false));
|
||||
assert_eq!(std::fs::read(&saved_path)?, TINY_PNG_BYTES);
|
||||
|
||||
let image_request = server
|
||||
@@ -179,6 +185,179 @@ async fn standalone_image_generation_returns_saved_path_hint_to_model() -> Resul
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn transparent_image_preserves_output_metadata_and_persisted_history() -> Result<()> {
|
||||
let call_id = "transparent-image-run-1";
|
||||
let server = responses::start_mock_server().await;
|
||||
mount_image_response_with_background(&server, "transparent").await;
|
||||
responses::mount_sse_sequence(
|
||||
&server,
|
||||
vec![
|
||||
responses::sse(vec![
|
||||
responses::ev_response_created("resp-1"),
|
||||
responses::ev_function_call_with_namespace(
|
||||
call_id,
|
||||
"image_gen",
|
||||
"imagegen",
|
||||
&json!({"prompt": "a blue whale on a transparent background"}).to_string(),
|
||||
),
|
||||
responses::ev_completed("resp-1"),
|
||||
]),
|
||||
responses::sse(vec![
|
||||
responses::ev_assistant_message("msg-1", "Done"),
|
||||
responses::ev_completed("resp-2"),
|
||||
]),
|
||||
],
|
||||
)
|
||||
.await;
|
||||
|
||||
let codex_home = TempDir::new()?;
|
||||
create_config_toml(codex_home.path(), &server.uri(), ImagegenTestMode::Direct)?;
|
||||
write_chatgpt_auth(
|
||||
codex_home.path(),
|
||||
ChatGptAuthFixture::new("access-chatgpt"),
|
||||
AuthCredentialsStoreMode::File,
|
||||
)?;
|
||||
let mut mcp = TestAppServer::builder()
|
||||
.with_codex_home(codex_home.path())
|
||||
.with_env_overrides(&[("OPENAI_API_KEY", None)])
|
||||
.build_initialized_with_timeout(DEFAULT_READ_TIMEOUT)
|
||||
.await?;
|
||||
start_image_generation_turn(&mut mcp, ThreadStartParams::default()).await?;
|
||||
|
||||
let completed = timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
wait_for_image_generation_completed(&mut mcp),
|
||||
)
|
||||
.await??;
|
||||
timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
mcp.read_stream_until_notification_message("turn/completed"),
|
||||
)
|
||||
.await??;
|
||||
let thread_id = completed.thread_id.clone();
|
||||
let ThreadItem::ImageGeneration(ImageGenerationItem {
|
||||
status,
|
||||
result,
|
||||
transparent_background,
|
||||
..
|
||||
}) = completed.item
|
||||
else {
|
||||
panic!("expected completed image-generation item");
|
||||
};
|
||||
assert_eq!(status, "completed");
|
||||
assert_eq!(result, RESULT);
|
||||
assert_eq!(transparent_background, Some(true));
|
||||
|
||||
drop(mcp);
|
||||
let mut resumed = TestAppServer::builder()
|
||||
.with_codex_home(codex_home.path())
|
||||
.with_env_overrides(&[("OPENAI_API_KEY", None)])
|
||||
.build_initialized_with_timeout(DEFAULT_READ_TIMEOUT)
|
||||
.await?;
|
||||
let read_id = resumed
|
||||
.send_thread_read_request(ThreadReadParams {
|
||||
thread_id: thread_id.clone(),
|
||||
include_turns: true,
|
||||
})
|
||||
.await?;
|
||||
let ThreadReadResponse { thread, .. } =
|
||||
timeout(DEFAULT_READ_TIMEOUT, resumed.read_response(read_id)).await??;
|
||||
let persisted_image = thread
|
||||
.turns
|
||||
.iter()
|
||||
.flat_map(|turn| turn.items.iter())
|
||||
.find_map(|item| match item {
|
||||
ThreadItem::ImageGeneration(item) => Some(item),
|
||||
_ => None,
|
||||
})
|
||||
.context("persisted legacy history should contain the generated image")?;
|
||||
assert_eq!(persisted_image.transparent_background, Some(true));
|
||||
|
||||
let resume_id = resumed
|
||||
.send_thread_resume_request(ThreadResumeParams {
|
||||
thread_id,
|
||||
..Default::default()
|
||||
})
|
||||
.await?;
|
||||
let ThreadResumeResponse { thread, .. } =
|
||||
timeout(DEFAULT_READ_TIMEOUT, resumed.read_response(resume_id)).await??;
|
||||
let resumed_image = thread
|
||||
.turns
|
||||
.iter()
|
||||
.flat_map(|turn| turn.items.iter())
|
||||
.find_map(|item| match item {
|
||||
ThreadItem::ImageGeneration(item) => Some(item),
|
||||
_ => None,
|
||||
})
|
||||
.context("resumed legacy history should contain the generated image")?;
|
||||
assert_eq!(resumed_image.transparent_background, Some(true));
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn automatic_image_background_preserves_unknown_transparency() -> Result<()> {
|
||||
let call_id = "automatic-image-run-1";
|
||||
let server = responses::start_mock_server().await;
|
||||
mount_image_response_with_background(&server, "auto").await;
|
||||
responses::mount_sse_sequence(
|
||||
&server,
|
||||
vec![
|
||||
responses::sse(vec![
|
||||
responses::ev_response_created("resp-1"),
|
||||
responses::ev_function_call_with_namespace(
|
||||
call_id,
|
||||
"image_gen",
|
||||
"imagegen",
|
||||
&json!({"prompt": "paint a blue whale"}).to_string(),
|
||||
),
|
||||
responses::ev_completed("resp-1"),
|
||||
]),
|
||||
responses::sse(vec![
|
||||
responses::ev_assistant_message("msg-1", "Done"),
|
||||
responses::ev_completed("resp-2"),
|
||||
]),
|
||||
],
|
||||
)
|
||||
.await;
|
||||
|
||||
let codex_home = TempDir::new()?;
|
||||
create_config_toml(codex_home.path(), &server.uri(), ImagegenTestMode::Direct)?;
|
||||
write_chatgpt_auth(
|
||||
codex_home.path(),
|
||||
ChatGptAuthFixture::new("access-chatgpt"),
|
||||
AuthCredentialsStoreMode::File,
|
||||
)?;
|
||||
let mut mcp = TestAppServer::builder()
|
||||
.with_codex_home(codex_home.path())
|
||||
.with_env_overrides(&[("OPENAI_API_KEY", None)])
|
||||
.build_initialized_with_timeout(DEFAULT_READ_TIMEOUT)
|
||||
.await?;
|
||||
start_image_generation_turn(&mut mcp, ThreadStartParams::default()).await?;
|
||||
let completed = timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
wait_for_image_generation_completed(&mut mcp),
|
||||
)
|
||||
.await??;
|
||||
timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
mcp.read_stream_until_notification_message("turn/completed"),
|
||||
)
|
||||
.await??;
|
||||
let ThreadItem::ImageGeneration(image) = completed.item else {
|
||||
panic!("expected completed image-generation item");
|
||||
};
|
||||
assert_eq!(image.transparent_background, None);
|
||||
let value = serde_json::to_value(&image)?;
|
||||
assert_eq!(
|
||||
value.get("transparentBackground"),
|
||||
Some(&serde_json::Value::Null),
|
||||
"v2 image-generation items must always include nullable transparency metadata"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn standalone_image_generation_failure_emits_terminal_item() -> Result<()> {
|
||||
let call_id = "image-run-failed";
|
||||
@@ -236,6 +415,7 @@ async fn standalone_image_generation_failure_emits_terminal_item() -> Result<()>
|
||||
status: "failed".to_string(),
|
||||
revised_prompt: Some("paint a blue whale".to_string()),
|
||||
result: String::new(),
|
||||
transparent_background: None,
|
||||
saved_path: None,
|
||||
})
|
||||
);
|
||||
@@ -266,7 +446,7 @@ async fn standalone_image_edit_uses_attached_model_visible_image() -> Result<()>
|
||||
"remote executors use different imagegen storage approaches, so host-local image paths are unavailable"
|
||||
);
|
||||
|
||||
let edit_request = run_image_edit_test(|codex_home| {
|
||||
let (edit_request, _) = run_image_edit_test(|codex_home| {
|
||||
let image_path = codex_home.join("attached.png");
|
||||
std::fs::write(&image_path, TINY_PNG_BYTES)?;
|
||||
Ok((
|
||||
@@ -294,9 +474,9 @@ async fn standalone_image_edit_uses_attached_model_visible_image() -> Result<()>
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn standalone_image_edit_uses_recent_pathless_image() -> Result<()> {
|
||||
async fn transparent_image_edit_preserves_metadata_and_recent_pathless_image() -> Result<()> {
|
||||
let image_url = TINY_PNG_DATA_URL;
|
||||
let edit_request = run_image_edit_test(|_| {
|
||||
let (edit_request, completed_image) = run_image_edit_test(|_| {
|
||||
Ok((
|
||||
json!({
|
||||
"prompt": "add a red hat",
|
||||
@@ -317,6 +497,7 @@ async fn standalone_image_edit_uses_recent_pathless_image() -> Result<()> {
|
||||
.await?;
|
||||
assert_eq!(edit_request["prompt"], "add a red hat");
|
||||
assert_eq!(edit_request["images"][0]["image_url"], image_url);
|
||||
assert_eq!(completed_image.transparent_background, Some(true));
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -461,7 +642,7 @@ async fn start_image_generation_turn(
|
||||
|
||||
async fn run_image_edit_test(
|
||||
input: impl FnOnce(&Path) -> Result<(serde_json::Value, Vec<V2UserInput>)>,
|
||||
) -> Result<serde_json::Value> {
|
||||
) -> Result<(serde_json::Value, ImageGenerationItem)> {
|
||||
let call_id = "image-edit-1";
|
||||
let server = responses::start_mock_server().await;
|
||||
mount_image_edit_response(&server).await;
|
||||
@@ -510,11 +691,14 @@ async fn run_image_edit_test(
|
||||
input,
|
||||
)
|
||||
.await?;
|
||||
timeout(
|
||||
let completed = timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
wait_for_image_generation_completed(&mut mcp),
|
||||
)
|
||||
.await??;
|
||||
let ThreadItem::ImageGeneration(completed_image) = completed.item else {
|
||||
panic!("expected completed image-generation item");
|
||||
};
|
||||
timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
mcp.read_stream_until_notification_message("turn/completed"),
|
||||
@@ -540,7 +724,10 @@ async fn run_image_edit_test(
|
||||
"chatgpt_cca"
|
||||
);
|
||||
assert_image_turn_id_header(image_request, &turn_id)?;
|
||||
Ok(image_request.body_json::<serde_json::Value>()?)
|
||||
Ok((
|
||||
image_request.body_json::<serde_json::Value>()?,
|
||||
completed_image,
|
||||
))
|
||||
}
|
||||
|
||||
fn assert_image_turn_id_header(request: &wiremock::Request, expected_turn_id: &str) -> Result<()> {
|
||||
@@ -592,10 +779,15 @@ async fn wait_for_image_generation_completed(
|
||||
}
|
||||
|
||||
async fn mount_image_response(server: &MockServer) {
|
||||
mount_image_response_with_background(server, "opaque").await;
|
||||
}
|
||||
|
||||
async fn mount_image_response_with_background(server: &MockServer, background: &str) {
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/api/codex/images/generations"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
|
||||
"created": 1,
|
||||
"background": background,
|
||||
"data": [{"b64_json": RESULT}],
|
||||
})))
|
||||
.expect(1)
|
||||
@@ -608,6 +800,7 @@ async fn mount_image_edit_response(server: &MockServer) {
|
||||
.and(path("/api/codex/images/edits"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
|
||||
"created": 1,
|
||||
"background": "transparent",
|
||||
"data": [{"b64_json": RESULT}],
|
||||
})))
|
||||
.expect(1)
|
||||
|
||||
@@ -68,6 +68,7 @@ mod request_permissions;
|
||||
mod request_user_input;
|
||||
mod request_validation;
|
||||
mod review;
|
||||
mod rollout_migration;
|
||||
mod safety_check_downgrade;
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
mod selected_capability_stack;
|
||||
|
||||
@@ -16,6 +16,7 @@ use codex_app_server_protocol::ModelUpgradeInfo;
|
||||
use codex_app_server_protocol::ReasoningEffortOption;
|
||||
use codex_app_server_protocol::RequestId;
|
||||
use codex_config::types::AuthCredentialsStoreMode;
|
||||
use codex_protocol::openai_models::MODEL_SPECIALTY_CYBER;
|
||||
use codex_protocol::openai_models::ModelInfo;
|
||||
use codex_protocol::openai_models::ModelPreset;
|
||||
use codex_protocol::openai_models::ModelsResponse;
|
||||
@@ -157,7 +158,7 @@ async fn list_models_uses_chatgpt_remote_catalog_as_source_of_truth() -> Result<
|
||||
"slug": "chatgpt-remote-only",
|
||||
"display_name": "ChatGPT Remote Only",
|
||||
"description": "Remote-only model for app-server model/list coverage",
|
||||
"model_specialty": "cyber",
|
||||
"model_specialty": MODEL_SPECIALTY_CYBER,
|
||||
"default_reasoning_level": "max",
|
||||
"supported_reasoning_levels": [
|
||||
{"effort": "max", "description": "Maximum"},
|
||||
|
||||
@@ -36,6 +36,7 @@ const ROLE_INSTRUCTIONS: &str = "configured role developer instructions";
|
||||
#[test_case("blank override"; "blank override")]
|
||||
#[test_case("parent has no instructions"; "parent has no instructions")]
|
||||
#[test_case("explicit configured role"; "explicit configured role")]
|
||||
#[test_case("full history configured role"; "full history configured role")]
|
||||
#[test_case("implicit configured default"; "implicit configured default")]
|
||||
#[test_case("full fork skips default role"; "full fork skips default role")]
|
||||
#[tokio::test]
|
||||
@@ -44,18 +45,19 @@ async fn spawned_subagents_apply_configured_developer_instruction_precedence(
|
||||
) -> Result<()> {
|
||||
let fork_turns = match case {
|
||||
"bounded history" => Some("1"),
|
||||
"no history"
|
||||
| "configured role without instructions"
|
||||
| "explicit configured role"
|
||||
| "implicit configured default" => Some("none"),
|
||||
"no history" | "explicit configured role" | "implicit configured default" => Some("none"),
|
||||
_ => None,
|
||||
};
|
||||
let agent_type = match case {
|
||||
"configured role without instructions" | "explicit configured role" => Some("custom"),
|
||||
"configured role without instructions"
|
||||
| "explicit configured role"
|
||||
| "full history configured role" => Some("custom"),
|
||||
_ => None,
|
||||
};
|
||||
let configured_override = match case {
|
||||
"unset override" => None,
|
||||
"unset override"
|
||||
| "full history configured role"
|
||||
| "configured role without instructions" => None,
|
||||
"blank override" => Some(" "),
|
||||
"full history" => Some(" child-only developer instructions "),
|
||||
_ => Some(CHILD_INSTRUCTIONS),
|
||||
@@ -69,17 +71,23 @@ async fn spawned_subagents_apply_configured_developer_instruction_precedence(
|
||||
case,
|
||||
"configured role without instructions"
|
||||
| "explicit configured role"
|
||||
| "full history configured role"
|
||||
| "implicit configured default"
|
||||
| "full fork skips default role"
|
||||
);
|
||||
let role_has_instructions = matches!(
|
||||
case,
|
||||
"explicit configured role" | "implicit configured default" | "full fork skips default role"
|
||||
"explicit configured role"
|
||||
| "full history configured role"
|
||||
| "implicit configured default"
|
||||
| "full fork skips default role"
|
||||
);
|
||||
let expected = match case {
|
||||
"unset override" => Some(PARENT_INSTRUCTIONS),
|
||||
"unset override" | "configured role without instructions" => Some(PARENT_INSTRUCTIONS),
|
||||
"blank override" => None,
|
||||
"explicit configured role" | "implicit configured default" => Some(ROLE_INSTRUCTIONS),
|
||||
"explicit configured role"
|
||||
| "full history configured role"
|
||||
| "implicit configured default" => Some(ROLE_INSTRUCTIONS),
|
||||
_ => Some(CHILD_INSTRUCTIONS),
|
||||
};
|
||||
const PARENT_PROMPT: &str = "spawn the instruction override worker";
|
||||
@@ -149,7 +157,12 @@ async fn spawned_subagents_apply_configured_developer_instruction_precedence(
|
||||
);
|
||||
}
|
||||
let codex_home = TempDir::new()?;
|
||||
let mut config = MockResponsesConfig::new(&server.uri()).with_model("gpt-5.4");
|
||||
let configured_model = if case == "full history configured role" {
|
||||
"gpt-5.5"
|
||||
} else {
|
||||
"gpt-5.4"
|
||||
};
|
||||
let mut config = MockResponsesConfig::new(&server.uri()).with_model(configured_model);
|
||||
if role_has_instructions {
|
||||
config =
|
||||
config.with_root_config(&format!("developer_instructions = {ROLE_INSTRUCTIONS:?}"));
|
||||
@@ -206,6 +219,19 @@ async fn spawned_subagents_apply_configured_developer_instruction_precedence(
|
||||
);
|
||||
}
|
||||
let child_texts = child_request.message_input_texts("developer");
|
||||
if case == "full history configured role" {
|
||||
assert_eq!(child_request.body_json()["model"], json!("gpt-5.5"));
|
||||
assert!(
|
||||
child_request.body_contains_text(PARENT_PROMPT),
|
||||
"the child should inherit the parent's conversation history"
|
||||
);
|
||||
assert!(
|
||||
child_texts
|
||||
.iter()
|
||||
.any(|text| text.contains("<model_switch>")),
|
||||
"the child should preserve the parent's model context"
|
||||
);
|
||||
}
|
||||
let instruction_texts = child_texts
|
||||
.iter()
|
||||
.map(String::as_str)
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
use std::collections::BTreeMap;
|
||||
use std::sync::Mutex;
|
||||
use std::sync::OnceLock;
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::Result;
|
||||
@@ -55,6 +58,9 @@ const TEST_ALLOW_HTTP_REMOTE_PLUGIN_BUNDLE_DOWNLOADS: &str =
|
||||
"CODEX_TEST_ALLOW_HTTP_REMOTE_PLUGIN_BUNDLE_DOWNLOADS";
|
||||
const ALTERNATE_MARKETPLACE_RELATIVE_PATH: &str = ".claude-plugin/marketplace.json";
|
||||
const ALTERNATE_PLUGIN_MANIFEST_RELATIVE_PATH: &str = ".claude-plugin/plugin.json";
|
||||
type RemoteInstalledPluginFixtures = BTreeMap<String, BTreeMap<String, Vec<serde_json::Value>>>;
|
||||
static REMOTE_INSTALLED_PLUGIN_FIXTURES: OnceLock<Mutex<RemoteInstalledPluginFixtures>> =
|
||||
OnceLock::new();
|
||||
|
||||
fn write_plugins_enabled_config(codex_home: &std::path::Path) -> std::io::Result<()> {
|
||||
std::fs::write(
|
||||
@@ -3379,8 +3385,7 @@ plugin_sharing = true
|
||||
)
|
||||
]
|
||||
);
|
||||
wait_for_remote_installed_scope_request(&server, "WORKSPACE").await?;
|
||||
wait_for_remote_installed_scope_request(&server, "GLOBAL").await?;
|
||||
wait_for_remote_installed_snapshot_request(&server).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -3466,8 +3471,7 @@ plugin_sharing = false
|
||||
true
|
||||
)]
|
||||
);
|
||||
wait_for_remote_installed_scope_request(&server, "WORKSPACE").await?;
|
||||
wait_for_remote_installed_scope_request(&server, "GLOBAL").await?;
|
||||
wait_for_remote_installed_snapshot_request(&server).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -3553,7 +3557,7 @@ plugin_sharing = false
|
||||
),
|
||||
)
|
||||
.await?;
|
||||
wait_for_remote_installed_scope_request(&server, "USER").await?;
|
||||
wait_for_remote_installed_snapshot_request(&server).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -4271,8 +4275,7 @@ async fn plugin_list_fetches_shared_with_me_kind() -> Result<()> {
|
||||
share_context.discoverability,
|
||||
Some(PluginShareDiscoverability::Unlisted)
|
||||
);
|
||||
wait_for_remote_installed_scope_request(&server, "WORKSPACE").await?;
|
||||
wait_for_remote_installed_scope_request(&server, "GLOBAL").await?;
|
||||
wait_for_remote_installed_snapshot_request(&server).await?;
|
||||
wait_for_remote_plugin_request_count(&server, "/ps/plugins/list", /*expected_count*/ 0).await?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -4751,7 +4754,7 @@ async fn wait_for_remote_plugin_list_scope_request_count(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn wait_for_remote_installed_scope_request(server: &MockServer, scope: &str) -> Result<()> {
|
||||
async fn wait_for_remote_installed_snapshot_request(server: &MockServer) -> Result<()> {
|
||||
timeout(DEFAULT_TIMEOUT, async {
|
||||
loop {
|
||||
let Some(requests) = server.received_requests().await else {
|
||||
@@ -4760,10 +4763,7 @@ async fn wait_for_remote_installed_scope_request(server: &MockServer, scope: &st
|
||||
if requests.iter().any(|request| {
|
||||
request.method == "GET"
|
||||
&& request.url.path().ends_with("/ps/plugins/installed")
|
||||
&& request
|
||||
.url
|
||||
.query_pairs()
|
||||
.any(|(name, value)| name == "scope" && value == scope)
|
||||
&& request.url.query_pairs().all(|(name, _)| name != "scope")
|
||||
}) {
|
||||
return Ok::<(), anyhow::Error>(());
|
||||
}
|
||||
@@ -4998,6 +4998,19 @@ async fn mount_shared_workspace_plugins(server: &MockServer, body: &str) {
|
||||
}
|
||||
|
||||
async fn mount_remote_installed_plugins(server: &MockServer, scope: &str, body: &str) {
|
||||
let plugins = serde_json::from_str::<serde_json::Value>(body)
|
||||
.expect("installed plugin fixture should be valid JSON")["plugins"]
|
||||
.as_array()
|
||||
.expect("installed plugin fixture should contain plugins")
|
||||
.clone();
|
||||
REMOTE_INSTALLED_PLUGIN_FIXTURES
|
||||
.get_or_init(Default::default)
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||
.entry(server.uri())
|
||||
.or_default()
|
||||
.insert(scope.to_string(), plugins);
|
||||
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/backend-api/ps/plugins/installed"))
|
||||
.and(query_param("scope", scope))
|
||||
@@ -5006,6 +5019,34 @@ async fn mount_remote_installed_plugins(server: &MockServer, scope: &str, body:
|
||||
.respond_with(ResponseTemplate::new(200).set_body_string(body))
|
||||
.mount(server)
|
||||
.await;
|
||||
|
||||
let server_uri = server.uri();
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/backend-api/ps/plugins/installed"))
|
||||
.and(query_param_is_missing("scope"))
|
||||
.and(header("authorization", "Bearer chatgpt-token"))
|
||||
.and(header("chatgpt-account-id", "account-123"))
|
||||
.respond_with(move |_request: &wiremock::Request| {
|
||||
let fixtures = REMOTE_INSTALLED_PLUGIN_FIXTURES
|
||||
.get()
|
||||
.expect("installed plugin fixtures should exist")
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
let scoped_plugins = fixtures
|
||||
.get(&server_uri)
|
||||
.expect("installed plugin fixtures should exist for this server");
|
||||
let plugins = ["GLOBAL", "WORKSPACE", "USER"]
|
||||
.into_iter()
|
||||
.flat_map(|scope| scoped_plugins.get(scope).into_iter().flatten())
|
||||
.cloned()
|
||||
.collect::<Vec<_>>();
|
||||
ResponseTemplate::new(200).set_body_json(serde_json::json!({
|
||||
"plugins": plugins,
|
||||
"pagination": {"limit": 50, "next_page_token": null},
|
||||
}))
|
||||
})
|
||||
.mount(server)
|
||||
.await;
|
||||
}
|
||||
|
||||
async fn mount_empty_user_installed_plugins(server: &MockServer) {
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
use anyhow::Result;
|
||||
use app_test_support::MockResponsesConfig;
|
||||
use app_test_support::TestAppServer;
|
||||
use codex_app_server_protocol::ThreadHistoryMode;
|
||||
use codex_app_server_protocol::ThreadResumeParams;
|
||||
use codex_app_server_protocol::ThreadResumeResponse;
|
||||
use codex_app_server_protocol::ThreadStartParams;
|
||||
use codex_app_server_protocol::ThreadStartResponse;
|
||||
use codex_app_server_protocol::TurnStartParams;
|
||||
use codex_app_server_protocol::UserInput;
|
||||
use codex_thread_store::LocalThreadStore;
|
||||
use codex_thread_store::LocalThreadStoreConfig;
|
||||
use codex_thread_store::RolloutMigrationMode;
|
||||
use codex_thread_store::RolloutMigrationOptions;
|
||||
use codex_thread_store::RolloutMigrationStatus;
|
||||
use codex_utils_absolute_path::test_support::PathExt;
|
||||
use core_test_support::responses;
|
||||
use pretty_assertions::assert_eq;
|
||||
use tempfile::TempDir;
|
||||
use tokio::time::timeout;
|
||||
|
||||
const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
|
||||
|
||||
#[tokio::test]
|
||||
async fn migrated_legacy_thread_cold_resume_preserves_model_context() -> Result<()> {
|
||||
let server = responses::start_mock_server().await;
|
||||
let response_mock = responses::mount_sse_sequence(
|
||||
&server,
|
||||
vec![
|
||||
responses::sse(vec![
|
||||
responses::ev_response_created("resp-1"),
|
||||
responses::ev_assistant_message("msg-1", "legacy assistant message"),
|
||||
responses::ev_completed("resp-1"),
|
||||
]),
|
||||
responses::sse(vec![
|
||||
responses::ev_response_created("resp-2"),
|
||||
responses::ev_assistant_message("msg-2", "resumed assistant message"),
|
||||
responses::ev_completed("resp-2"),
|
||||
]),
|
||||
],
|
||||
)
|
||||
.await;
|
||||
let codex_home = TempDir::new()?;
|
||||
MockResponsesConfig::new(&server.uri()).write(codex_home.path())?;
|
||||
|
||||
let mut primary = TestAppServer::builder()
|
||||
.with_codex_home(codex_home.path())
|
||||
.build_initialized()
|
||||
.await?;
|
||||
let start_id = primary
|
||||
.send_thread_start_request_with_auto_env(ThreadStartParams {
|
||||
history_mode: Some(ThreadHistoryMode::Legacy),
|
||||
..Default::default()
|
||||
})
|
||||
.await?;
|
||||
let ThreadStartResponse { thread, .. } =
|
||||
timeout(DEFAULT_READ_TIMEOUT, primary.read_response(start_id)).await??;
|
||||
timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
primary.start_turn_and_wait_for_completion(TurnStartParams {
|
||||
thread_id: thread.id.clone(),
|
||||
input: vec![UserInput::Text {
|
||||
text: "legacy user message".to_string(),
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
..Default::default()
|
||||
}),
|
||||
)
|
||||
.await??;
|
||||
timeout(DEFAULT_READ_TIMEOUT, primary.shutdown_gracefully()).await??;
|
||||
|
||||
let sqlite = codex_state::SqliteConfig::new_for_testing(codex_home.path().abs());
|
||||
let state_db =
|
||||
codex_state::StateRuntime::init(sqlite.clone(), "mock_provider".to_string()).await?;
|
||||
let store = LocalThreadStore::new(
|
||||
LocalThreadStoreConfig {
|
||||
codex_home: codex_home.path().to_path_buf(),
|
||||
sqlite,
|
||||
default_model_provider_id: "mock_provider".to_string(),
|
||||
},
|
||||
Some(state_db),
|
||||
);
|
||||
let report = store
|
||||
.migrate_rollouts(RolloutMigrationOptions {
|
||||
mode: RolloutMigrationMode::Apply,
|
||||
max_mib_per_second: 1024,
|
||||
..RolloutMigrationOptions::default()
|
||||
})
|
||||
.await?;
|
||||
assert_eq!(report.outcomes.len(), 1);
|
||||
assert_eq!(report.outcomes[0].status, RolloutMigrationStatus::Migrated);
|
||||
drop(store);
|
||||
|
||||
let mut secondary = TestAppServer::builder()
|
||||
.with_codex_home(codex_home.path())
|
||||
.build_initialized()
|
||||
.await?;
|
||||
let resume_id = secondary
|
||||
.send_thread_resume_request(ThreadResumeParams {
|
||||
thread_id: thread.id.clone(),
|
||||
exclude_turns: true,
|
||||
..Default::default()
|
||||
})
|
||||
.await?;
|
||||
let ThreadResumeResponse {
|
||||
thread: resumed, ..
|
||||
} = timeout(DEFAULT_READ_TIMEOUT, secondary.read_response(resume_id)).await??;
|
||||
assert_eq!(resumed.history_mode, ThreadHistoryMode::Paginated);
|
||||
|
||||
timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
secondary.start_turn_and_wait_for_completion(TurnStartParams {
|
||||
thread_id: thread.id,
|
||||
input: vec![UserInput::Text {
|
||||
text: "resumed user message".to_string(),
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
..Default::default()
|
||||
}),
|
||||
)
|
||||
.await??;
|
||||
|
||||
let requests = response_mock.requests();
|
||||
assert_eq!(requests.len(), 2);
|
||||
let resumed_request = requests.last().expect("resumed turn request");
|
||||
let user_messages = resumed_request.message_input_texts("user");
|
||||
assert!(user_messages.contains(&"legacy user message".to_string()));
|
||||
assert!(user_messages.contains(&"resumed user message".to_string()));
|
||||
assert!(resumed_request.body_contains_text("legacy assistant message"));
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -37,6 +37,7 @@ use wiremock::matchers::header;
|
||||
use wiremock::matchers::method;
|
||||
use wiremock::matchers::path;
|
||||
use wiremock::matchers::query_param;
|
||||
use wiremock::matchers::query_param_is_missing;
|
||||
|
||||
const DEFAULT_TIMEOUT: Duration = Duration::from_secs(30);
|
||||
const WATCHER_TIMEOUT: Duration = Duration::from_secs(20);
|
||||
@@ -579,6 +580,14 @@ async fn skills_list_loads_remote_installed_plugin_skills_from_cache() -> Result
|
||||
.mount(&server)
|
||||
.await;
|
||||
}
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/backend-api/ps/plugins/installed"))
|
||||
.and(query_param_is_missing("scope"))
|
||||
.and(header("authorization", "Bearer chatgpt-token"))
|
||||
.and(header("chatgpt-account-id", "account-123"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_string(global_installed_body))
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let plugin_list_request_id = mcp
|
||||
.send_plugin_list_request(PluginListParams {
|
||||
|
||||
@@ -28,10 +28,15 @@ use codex_state::DirectionalThreadSpawnEdgeStatus;
|
||||
use codex_state::StateRuntime;
|
||||
use codex_utils_absolute_path::test_support::PathExt;
|
||||
use pretty_assertions::assert_eq;
|
||||
use serde_json::Value;
|
||||
use serde_json::json;
|
||||
use std::path::Path;
|
||||
use tempfile::TempDir;
|
||||
use tokio::time::timeout;
|
||||
|
||||
use super::analytics::mount_analytics_capture;
|
||||
use super::analytics::wait_for_matching_analytics_event;
|
||||
|
||||
const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
|
||||
|
||||
#[tokio::test]
|
||||
@@ -336,7 +341,10 @@ async fn thread_archive_archives_spawned_descendants() -> Result<()> {
|
||||
async fn thread_archive_succeeds_when_descendant_archive_fails() -> Result<()> {
|
||||
let server = create_mock_responses_server_repeating_assistant("Done").await;
|
||||
let codex_home = TempDir::new()?;
|
||||
MockResponsesConfig::new(&server.uri()).write(codex_home.path())?;
|
||||
MockResponsesConfig::new(&server.uri())
|
||||
.with_root_config(&format!(r#"chatgpt_base_url = "{}""#, server.uri()))
|
||||
.write(codex_home.path())?;
|
||||
mount_analytics_capture(&server, codex_home.path()).await?;
|
||||
|
||||
let parent_id = create_fake_rollout(
|
||||
codex_home.path(),
|
||||
@@ -423,7 +431,7 @@ async fn thread_archive_succeeds_when_descendant_archive_fails() -> Result<()> {
|
||||
.await??;
|
||||
archived_ids.push(archived_notification.thread_id);
|
||||
}
|
||||
assert_eq!(archived_ids, vec![parent_id, grandchild_id]);
|
||||
assert_eq!(archived_ids, vec![parent_id.clone(), grandchild_id.clone()]);
|
||||
|
||||
assert!(
|
||||
timeout(
|
||||
@@ -465,6 +473,88 @@ async fn thread_archive_succeeds_when_descendant_archive_fails() -> Result<()> {
|
||||
);
|
||||
}
|
||||
|
||||
let repeated_archive_id = mcp
|
||||
.send_thread_archive_request(ThreadArchiveParams {
|
||||
thread_id: parent_id.clone(),
|
||||
})
|
||||
.await?;
|
||||
let _: JSONRPCError = timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
mcp.read_stream_until_error_message(RequestId::Integer(repeated_archive_id)),
|
||||
)
|
||||
.await??;
|
||||
|
||||
let _: ThreadUnarchiveResponse = mcp
|
||||
.request(|request_id| ClientRequest::ThreadUnarchive {
|
||||
request_id,
|
||||
params: ThreadUnarchiveParams {
|
||||
thread_id: parent_id.clone(),
|
||||
},
|
||||
})
|
||||
.await?;
|
||||
wait_for_matching_analytics_event(&server, DEFAULT_READ_TIMEOUT, |event| {
|
||||
event["event_type"] == "codex_thread_archive_event"
|
||||
&& event["event_params"]["thread_id"] == parent_id
|
||||
&& event["event_params"]["action"] == "unarchived"
|
||||
})
|
||||
.await?;
|
||||
|
||||
let requests = server
|
||||
.received_requests()
|
||||
.await
|
||||
.ok_or_else(|| anyhow::anyhow!("wiremock did not record requests"))?;
|
||||
let mut archive_events = Vec::new();
|
||||
for request in requests {
|
||||
if request.url.path() != "/codex/analytics-events/events" {
|
||||
continue;
|
||||
}
|
||||
let payload: Value = serde_json::from_slice(&request.body)?;
|
||||
let events = payload["events"]
|
||||
.as_array()
|
||||
.ok_or_else(|| anyhow::anyhow!("analytics payload missing events array"))?;
|
||||
for event in events
|
||||
.iter()
|
||||
.filter(|event| event["event_type"] == "codex_thread_archive_event")
|
||||
{
|
||||
for (header, expected) in [
|
||||
("authorization", "Bearer chatgpt-token"),
|
||||
("chatgpt-account-id", "account-123"),
|
||||
] {
|
||||
assert_eq!(
|
||||
request
|
||||
.headers
|
||||
.get(header)
|
||||
.and_then(|value| value.to_str().ok()),
|
||||
Some(expected)
|
||||
);
|
||||
}
|
||||
archive_events.push(event.clone());
|
||||
}
|
||||
}
|
||||
|
||||
let expected = [
|
||||
(parent_id.as_str(), "archived"),
|
||||
(grandchild_id.as_str(), "archived"),
|
||||
(parent_id.as_str(), "unarchived"),
|
||||
];
|
||||
assert_eq!(archive_events.len(), expected.len());
|
||||
for (event, (thread_id, action)) in archive_events.iter().zip(expected) {
|
||||
let occurred_at_ms = event["event_params"]["occurred_at_ms"]
|
||||
.as_u64()
|
||||
.expect("thread archive analytics must include its producer timestamp");
|
||||
assert_eq!(
|
||||
event,
|
||||
&json!({
|
||||
"event_type": "codex_thread_archive_event",
|
||||
"event_params": {
|
||||
"thread_id": thread_id,
|
||||
"action": action,
|
||||
"occurred_at_ms": occurred_at_ms,
|
||||
},
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
@@ -833,6 +833,9 @@ async fn thread_resume_preserves_acknowledged_model_effort_and_approvals_reviewe
|
||||
-> Result<()> {
|
||||
let server = create_mock_responses_server_repeating_assistant("Done").await;
|
||||
let codex_home = TempDir::new()?;
|
||||
let updated_workspace = TempDir::new()?;
|
||||
let persisted_cwd = normalized_existing_path(updated_workspace.path())?;
|
||||
let live_cwd = normalized_existing_path(codex_home.path())?;
|
||||
mock_responses_config(&server.uri()).write(codex_home.path())?;
|
||||
let config_path = codex_home.path().join("config.toml");
|
||||
let config_toml = std::fs::read_to_string(&config_path)?;
|
||||
@@ -887,6 +890,7 @@ async fn thread_resume_preserves_acknowledged_model_effort_and_approvals_reviewe
|
||||
model: Some("gpt-5.2-codex".to_string()),
|
||||
effort: Some(ReasoningEffort::Ultra),
|
||||
approvals_reviewer: Some(ApprovalsReviewer::AutoReview),
|
||||
cwd: Some(persisted_cwd.clone()),
|
||||
..Default::default()
|
||||
})
|
||||
.await?;
|
||||
@@ -898,6 +902,31 @@ async fn thread_resume_preserves_acknowledged_model_effort_and_approvals_reviewe
|
||||
)
|
||||
.await??;
|
||||
|
||||
let read_id = mcp
|
||||
.send_thread_read_request(ThreadReadParams {
|
||||
thread_id: thread.id.clone(),
|
||||
include_turns: false,
|
||||
})
|
||||
.await?;
|
||||
let ThreadReadResponse { thread: read } =
|
||||
timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(read_id)).await??;
|
||||
assert_eq!(read.cwd.as_path(), persisted_cwd);
|
||||
|
||||
let list_id = mcp
|
||||
.send_raw_request(
|
||||
"thread/list",
|
||||
Some(json!({ "cwd": persisted_cwd, "useStateDbOnly": true })),
|
||||
)
|
||||
.await?;
|
||||
let ThreadListResponse { data, .. } =
|
||||
timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(list_id)).await??;
|
||||
assert_eq!(
|
||||
data.iter()
|
||||
.find(|listed| listed.id == thread.id)
|
||||
.map(|listed| &listed.cwd),
|
||||
Some(&read.cwd)
|
||||
);
|
||||
|
||||
thread.id
|
||||
};
|
||||
|
||||
@@ -908,10 +937,13 @@ async fn thread_resume_preserves_acknowledged_model_effort_and_approvals_reviewe
|
||||
let resume_id = mcp
|
||||
.send_thread_resume_request(ThreadResumeParams {
|
||||
thread_id: thread_id.clone(),
|
||||
cwd: Some(live_cwd.to_string_lossy().into_owned()),
|
||||
..Default::default()
|
||||
})
|
||||
.await?;
|
||||
let ThreadResumeResponse {
|
||||
thread,
|
||||
cwd,
|
||||
model,
|
||||
reasoning_effort,
|
||||
approvals_reviewer,
|
||||
@@ -921,6 +953,8 @@ async fn thread_resume_preserves_acknowledged_model_effort_and_approvals_reviewe
|
||||
assert_eq!(model, "gpt-5.2-codex");
|
||||
assert_eq!(reasoning_effort, Some(ReasoningEffort::Ultra));
|
||||
assert_eq!(approvals_reviewer, ApprovalsReviewer::AutoReview);
|
||||
assert_eq!(thread.cwd.as_path(), persisted_cwd);
|
||||
assert_eq!(cwd.as_path(), live_cwd);
|
||||
|
||||
let update_id = mcp
|
||||
.send_thread_settings_update_request(ThreadSettingsUpdateParams {
|
||||
@@ -1681,6 +1715,7 @@ fn append_resume_redaction_history(
|
||||
status: "completed".to_string(),
|
||||
revised_prompt: Some("secret revised prompt".to_string()),
|
||||
result: "base64-image-result".to_string(),
|
||||
transparent_background: None,
|
||||
saved_path: Some(test_absolute_path("/tmp/ig-1.png")),
|
||||
}),
|
||||
]
|
||||
|
||||
@@ -9,6 +9,8 @@ use crate::PUBLIC_TOOL_NAME;
|
||||
const MAX_JS_SAFE_INTEGER: u64 = (1_u64 << 53) - 1;
|
||||
const DEFERRED_NESTED_TOOLS_GUIDANCE: &str = r#"Some deferred nested tools may be omitted from this description. They are still available on the global `tools` object and listed in `ALL_TOOLS`.
|
||||
To find one, filter `ALL_TOOLS` by `name` and `description`."#;
|
||||
const LEGACY_IMAGE_HELPER_DESCRIPTION: &str = r#"`image(imageUrlOrItem: string | { image_url: string; detail?: "auto" | "low" | "high" | "original" | null } | ImageContent, detail?: "auto" | "low" | "high" | "original" | null)`: Appends an image item. `image_url` should be a base64-encoded `data:` URL. To forward an MCP tool image, pass an individual `ImageContent` block from `result.content`, for example `image(result.content[0])`. MCP image blocks may request detail with `_meta: { "codex/imageDetail": "original" }`. When provided, the second `detail` argument overrides any detail embedded in the first argument."#;
|
||||
const UNIFIED_IMAGE_HELPER_DESCRIPTION: &str = r#"`image(imageUrlOrItem: string | { image_url: string } | ImageContent)`: Appends an image item. `image_url` should be a base64-encoded `data:` URL. To forward an MCP tool image, pass an individual `ImageContent` block from `result.content`, for example `image(result.content[0])`."#;
|
||||
const EXEC_DESCRIPTION_TEMPLATE: &str = r#"Run JavaScript code to orchestrate/compose tool calls
|
||||
- Evaluates the provided JavaScript code in a fresh V8 isolate as an async module.
|
||||
- All nested tools are available on the global `tools` object, for example `await tools.exec_command(...)`. Tool names are exposed as normalized JavaScript identifiers, for example `await tools.mcp__ologs__get_profile(...)`.
|
||||
@@ -249,18 +251,31 @@ pub fn is_code_mode_nested_tool(tool_name: &str) -> bool {
|
||||
tool_name != crate::PUBLIC_TOOL_NAME && tool_name != crate::WAIT_TOOL_NAME
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub enum ImageDetailVisibility {
|
||||
Visible,
|
||||
Hidden,
|
||||
}
|
||||
|
||||
pub fn build_exec_tool_description(
|
||||
enabled_tools: &[ToolDefinition],
|
||||
deferred_tools: &[ToolDefinition],
|
||||
namespace_descriptions: &BTreeMap<String, ToolNamespaceDescription>,
|
||||
default_exec_yield_time_ms: u64,
|
||||
code_mode_only: bool,
|
||||
image_detail_visibility: ImageDetailVisibility,
|
||||
) -> String {
|
||||
let mut sections = Vec::new();
|
||||
sections.push(EXEC_DESCRIPTION_TEMPLATE.replace(
|
||||
"Defaults to 10000 ms.",
|
||||
&format!("Defaults to {default_exec_yield_time_ms} ms."),
|
||||
));
|
||||
if image_detail_visibility == ImageDetailVisibility::Hidden {
|
||||
sections[0] = sections[0].replace(
|
||||
LEGACY_IMAGE_HELPER_DESCRIPTION,
|
||||
UNIFIED_IMAGE_HELPER_DESCRIPTION,
|
||||
);
|
||||
}
|
||||
if !deferred_tools.is_empty() {
|
||||
sections.push(DEFERRED_NESTED_TOOLS_GUIDANCE.to_string());
|
||||
}
|
||||
@@ -714,6 +729,7 @@ fn render_json_schema_literal(value: &JsonValue) -> String {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::CodeModeToolKind;
|
||||
use super::ImageDetailVisibility;
|
||||
use super::ParsedExecSource;
|
||||
use super::ToolDefinition;
|
||||
use super::ToolNamespaceDescription;
|
||||
@@ -874,6 +890,7 @@ mod tests {
|
||||
&BTreeMap::new(),
|
||||
crate::DEFAULT_EXEC_YIELD_TIME_MS,
|
||||
/*code_mode_only*/ true,
|
||||
ImageDetailVisibility::Visible,
|
||||
);
|
||||
assert!(description.contains(
|
||||
"### `foo`
|
||||
@@ -890,6 +907,7 @@ bar"
|
||||
&BTreeMap::new(),
|
||||
crate::DEFAULT_EXEC_YIELD_TIME_MS,
|
||||
/*code_mode_only*/ false,
|
||||
ImageDetailVisibility::Visible,
|
||||
);
|
||||
assert!(description.contains("`audio(audioUrlOrItem:"));
|
||||
assert!(description.contains("`setTimeout(callback: () => void, delayMs?: number)`"));
|
||||
@@ -944,6 +962,7 @@ bar"
|
||||
&namespace_descriptions,
|
||||
crate::DEFAULT_EXEC_YIELD_TIME_MS,
|
||||
/*code_mode_only*/ true,
|
||||
ImageDetailVisibility::Visible,
|
||||
);
|
||||
assert_eq!(description.matches("## mcp__sample").count(), 1);
|
||||
assert!(description.contains("## mcp__sample\nShared namespace guidance."));
|
||||
@@ -985,6 +1004,7 @@ bar"
|
||||
&namespace_descriptions,
|
||||
crate::DEFAULT_EXEC_YIELD_TIME_MS,
|
||||
/*code_mode_only*/ true,
|
||||
ImageDetailVisibility::Visible,
|
||||
);
|
||||
|
||||
assert!(!description.contains("## mcp__sample"));
|
||||
@@ -1085,6 +1105,7 @@ bar"
|
||||
&BTreeMap::new(),
|
||||
crate::DEFAULT_EXEC_YIELD_TIME_MS,
|
||||
/*code_mode_only*/ true,
|
||||
ImageDetailVisibility::Visible,
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
@@ -1121,6 +1142,7 @@ bar"
|
||||
&BTreeMap::new(),
|
||||
crate::DEFAULT_EXEC_YIELD_TIME_MS,
|
||||
/*code_mode_only*/ true,
|
||||
ImageDetailVisibility::Visible,
|
||||
);
|
||||
|
||||
assert!(description.contains("Some deferred nested tools may be omitted"));
|
||||
@@ -1143,6 +1165,7 @@ bar"
|
||||
&BTreeMap::new(),
|
||||
crate::DEFAULT_EXEC_YIELD_TIME_MS,
|
||||
/*code_mode_only*/ false,
|
||||
ImageDetailVisibility::Visible,
|
||||
);
|
||||
|
||||
assert!(description.contains("Some deferred nested tools may be omitted"));
|
||||
|
||||
@@ -7,6 +7,7 @@ mod session;
|
||||
pub use description::CODE_MODE_PRAGMA_PREFIX;
|
||||
pub use description::CodeModeToolKind;
|
||||
pub use description::EnabledToolMetadata;
|
||||
pub use description::ImageDetailVisibility;
|
||||
pub use description::ToolDefinition;
|
||||
pub use description::ToolNamespaceDescription;
|
||||
pub use description::augment_tool_definition;
|
||||
|
||||
@@ -43,8 +43,10 @@ use crate::rmcp_client::prepare_codex_apps_tools_for_model;
|
||||
use crate::rmcp_client::prepare_regular_mcp_tools_for_model;
|
||||
use crate::runtime::McpPublicationGate;
|
||||
use crate::runtime::McpRuntimeInput;
|
||||
use crate::runtime::McpStartupPolicy;
|
||||
use crate::server::McpServerConnectionIdentity;
|
||||
use crate::server::McpServerMetadata;
|
||||
use crate::tool_catalog_cache::McpToolCatalogCacheContext;
|
||||
use crate::tools::ToolFilter;
|
||||
use crate::tools::ToolInfo;
|
||||
use crate::tools::filter_tools;
|
||||
@@ -65,12 +67,14 @@ use codex_protocol::protocol::McpStartupUpdateEvent;
|
||||
use codex_rmcp_client::determine_streamable_http_auth_status_from_credentials;
|
||||
use tokio::sync::Mutex;
|
||||
use tokio::sync::RwLock;
|
||||
use tokio::sync::watch;
|
||||
use tokio::task::JoinSet;
|
||||
use tracing::warn;
|
||||
|
||||
pub(crate) struct McpServerConnection {
|
||||
identity: Option<McpServerConnectionIdentity>,
|
||||
client: AsyncManagedClient,
|
||||
startup_trigger: Option<watch::Sender<bool>>,
|
||||
}
|
||||
|
||||
impl McpServerConnection {
|
||||
@@ -102,6 +106,9 @@ impl McpServerConnection {
|
||||
}
|
||||
|
||||
pub(crate) async fn client(&self) -> Result<ManagedClient, StartupOutcomeError> {
|
||||
if let Some(startup_trigger) = &self.startup_trigger {
|
||||
startup_trigger.send_replace(true);
|
||||
}
|
||||
self.client.client().await
|
||||
}
|
||||
|
||||
@@ -110,10 +117,16 @@ impl McpServerConnection {
|
||||
}
|
||||
|
||||
fn cancel_startup(&self) {
|
||||
if !self.client.startup_complete.load(Ordering::Acquire) {
|
||||
if !self.startup_is_dormant() && !self.client.startup_complete.load(Ordering::Acquire) {
|
||||
self.client.cancel_token.cancel();
|
||||
}
|
||||
}
|
||||
|
||||
fn startup_is_dormant(&self) -> bool {
|
||||
self.startup_trigger
|
||||
.as_ref()
|
||||
.is_some_and(|startup_trigger| !*startup_trigger.borrow())
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for McpServerConnection {
|
||||
@@ -170,6 +183,7 @@ impl McpConnectionSet {
|
||||
elicitation_router: ElicitationRequestRouter,
|
||||
) -> Self {
|
||||
let McpRuntimeInput {
|
||||
startup_policy,
|
||||
config,
|
||||
plugins_available: _,
|
||||
ready_selected_capability_roots: _,
|
||||
@@ -207,6 +221,9 @@ impl McpConnectionSet {
|
||||
required_servers.sort();
|
||||
let mut reused_ready = Vec::new();
|
||||
let mut join_set = JoinSet::new();
|
||||
// Explicit reconnects have no previous set and must replace their clients eagerly.
|
||||
let allow_deferred_startup =
|
||||
startup_policy == McpStartupPolicy::LazyWhenCached && previous.is_some();
|
||||
let reusable_previous = previous.filter(|previous| {
|
||||
!previous.servers.is_empty()
|
||||
&& previous.elicitation_requests.update(
|
||||
@@ -397,12 +414,32 @@ impl McpConnectionSet {
|
||||
protocol_mode,
|
||||
catalog_item_limit,
|
||||
);
|
||||
let defer_startup = allow_deferred_startup
|
||||
&& !configured_config.required
|
||||
&& !tool_plugin_provenance.is_selected_plugin_mcp_server(&server_name)
|
||||
&& async_managed_client
|
||||
.tool_catalog_cache_context
|
||||
.as_ref()
|
||||
.and_then(McpToolCatalogCacheContext::current_tools)
|
||||
.is_some_and(|tools| {
|
||||
tools.into_iter().any(|tool| {
|
||||
configured_tool_filter.allows(&tool.tool.name)
|
||||
&& tool_is_model_visible(&tool)
|
||||
})
|
||||
});
|
||||
let (startup_trigger, startup_receiver) = if defer_startup {
|
||||
let (trigger, receiver) = watch::channel(false);
|
||||
(Some(trigger), Some(receiver))
|
||||
} else {
|
||||
(None, None)
|
||||
};
|
||||
servers.insert(
|
||||
server_name.clone(),
|
||||
McpServerView {
|
||||
connection: Arc::new(McpServerConnection {
|
||||
identity: Some(connection_identity),
|
||||
client: async_managed_client.clone(),
|
||||
startup_trigger,
|
||||
}),
|
||||
metadata,
|
||||
tool_filter: configured_tool_filter,
|
||||
@@ -413,7 +450,15 @@ impl McpConnectionSet {
|
||||
let tx_event = tx_event.clone();
|
||||
let submit_id = startup_submit_id.clone();
|
||||
let publication_gate = publication_gate.clone();
|
||||
join_set.spawn(async move {
|
||||
let startup = async move {
|
||||
if let Some(mut startup_receiver) = startup_receiver
|
||||
&& tokio::select! {
|
||||
started = startup_receiver.wait_for(|started| *started) => started.is_err(),
|
||||
() = cancel_token.cancelled() => true,
|
||||
}
|
||||
{
|
||||
return (server_name, Err(StartupOutcomeError::Cancelled));
|
||||
}
|
||||
if !publication_gate.wait().await {
|
||||
return (server_name, Err(StartupOutcomeError::Cancelled));
|
||||
}
|
||||
@@ -506,7 +551,13 @@ impl McpConnectionSet {
|
||||
}
|
||||
|
||||
(server_name, outcome)
|
||||
});
|
||||
};
|
||||
if defer_startup {
|
||||
// Dormant servers must not hold the initial startup summary open.
|
||||
tokio::spawn(startup);
|
||||
} else {
|
||||
join_set.spawn(startup);
|
||||
}
|
||||
}
|
||||
let manager = Self {
|
||||
servers,
|
||||
@@ -690,7 +741,7 @@ impl McpConnectionSet {
|
||||
server_infos.insert(server_name.clone(), server_info);
|
||||
continue;
|
||||
}
|
||||
match client.client().await {
|
||||
match view.connection.client().await {
|
||||
Ok(managed_client) => {
|
||||
server_infos.insert(server_name.clone(), managed_client.server_info);
|
||||
}
|
||||
|
||||
@@ -177,9 +177,6 @@ impl McpConnectionSet {
|
||||
let revision = self.tool_catalog_revision.read().await;
|
||||
let mut listed_tools = Vec::new();
|
||||
let mut clients = std::collections::HashMap::new();
|
||||
let optional_startup_deadline = *self
|
||||
.optional_startup_deadline
|
||||
.get_or_init(|| tokio::time::Instant::now() + OPTIONAL_MCP_STARTUP_GRACE);
|
||||
join_all(self.servers.iter().map(|(server_name, view)| async move {
|
||||
if !view
|
||||
.connection
|
||||
@@ -199,6 +196,13 @@ impl McpConnectionSet {
|
||||
return;
|
||||
}
|
||||
if !must_wait_for_startup {
|
||||
let optional_startup_deadline = if view.connection.startup_is_dormant() {
|
||||
tokio::time::Instant::now() + OPTIONAL_MCP_STARTUP_GRACE
|
||||
} else {
|
||||
*self.optional_startup_deadline.get_or_init(|| {
|
||||
tokio::time::Instant::now() + OPTIONAL_MCP_STARTUP_GRACE
|
||||
})
|
||||
};
|
||||
let startup_deadline = view
|
||||
.connection
|
||||
.client
|
||||
@@ -206,7 +210,7 @@ impl McpConnectionSet {
|
||||
.as_ref()
|
||||
.map(|cache| cache.optional_startup_deadline(optional_startup_deadline))
|
||||
.unwrap_or(optional_startup_deadline);
|
||||
if tokio::time::timeout_at(startup_deadline, view.connection.client.client())
|
||||
if tokio::time::timeout_at(startup_deadline, view.connection.client())
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
@@ -214,7 +218,7 @@ impl McpConnectionSet {
|
||||
}
|
||||
return;
|
||||
}
|
||||
let _ = view.connection.client.client().await;
|
||||
let _ = view.connection.client().await;
|
||||
}
|
||||
}))
|
||||
.await;
|
||||
@@ -241,7 +245,7 @@ impl McpConnectionSet {
|
||||
return Some((server_name.clone(), None, server_tools));
|
||||
}
|
||||
view.connection.client.reconnect_failed_startup().await;
|
||||
let Ok(mut client) = view.connection.client.client().await else {
|
||||
let Ok(mut client) = view.connection.client().await else {
|
||||
trace!(server_name = %server_name, "omitting MCP server without an exact ready client");
|
||||
return None;
|
||||
};
|
||||
|
||||
@@ -115,6 +115,7 @@ impl McpConnectionSet {
|
||||
connection: Arc::new(McpServerConnection {
|
||||
identity: None,
|
||||
client,
|
||||
startup_trigger: None,
|
||||
}),
|
||||
metadata: McpServerMetadata {
|
||||
environment_id: String::new(),
|
||||
@@ -1325,6 +1326,7 @@ async fn codex_apps_extension_does_not_share_host_owned_tools_cache() -> anyhow:
|
||||
/*previous*/ None,
|
||||
McpPublicationGate::already_published(),
|
||||
McpRuntimeInput {
|
||||
startup_policy: McpStartupPolicy::Eager,
|
||||
config: Arc::new(config),
|
||||
plugins_available: false,
|
||||
ready_selected_capability_roots: Vec::new(),
|
||||
@@ -1635,6 +1637,9 @@ async fn tool_catalog_cache_sanitizes_tools_and_tracks_environment_generation()
|
||||
let first_environment = resolve_environment();
|
||||
let first_environment_weak = Arc::downgrade(&first_environment);
|
||||
let first_context = cache_context(&first_environment);
|
||||
first_context.publish_if_newest(first_context.begin_fetch(), &[]);
|
||||
assert!(!first_context.has_tools());
|
||||
|
||||
let mut tool = create_test_tool("docs", "search");
|
||||
tool.tool.annotations = Some(rmcp::model::ToolAnnotations::new().read_only(true));
|
||||
first_context.publish_if_newest(first_context.begin_fetch(), &[tool]);
|
||||
@@ -2062,12 +2067,10 @@ async fn capture_binding_shares_optional_startup_grace_across_connection_sets()
|
||||
deadline_after_publication,
|
||||
"publishing a catalog must not install a stale startup deadline"
|
||||
);
|
||||
let cached = tokio::time::timeout(
|
||||
Duration::from_millis(1),
|
||||
capture_binding(&create_connection_set()),
|
||||
)
|
||||
.await
|
||||
.expect("cached tools should be immediately available to later threads");
|
||||
let cached_manager = create_connection_set();
|
||||
let cached = tokio::time::timeout(Duration::from_millis(1), capture_binding(&cached_manager))
|
||||
.await
|
||||
.expect("cached tools should be immediately available to later threads");
|
||||
assert_eq!(
|
||||
cached
|
||||
.tools()
|
||||
@@ -2079,12 +2082,9 @@ async fn capture_binding_shares_optional_startup_grace_across_connection_sets()
|
||||
|
||||
tokio::time::advance(Duration::from_secs(30 * 60 + 1)).await;
|
||||
assert!(
|
||||
tokio::time::timeout(
|
||||
Duration::from_millis(1),
|
||||
capture_binding(&create_connection_set()),
|
||||
)
|
||||
.await
|
||||
.is_err(),
|
||||
tokio::time::timeout(Duration::from_millis(1), capture_binding(&cached_manager))
|
||||
.await
|
||||
.is_err(),
|
||||
"an expired catalog should receive a fresh startup grace"
|
||||
);
|
||||
|
||||
@@ -3070,6 +3070,7 @@ async fn executor_owned_chatgpt_mcp_accepts_only_safe_explicit_authorization() -
|
||||
/*previous*/ None,
|
||||
McpPublicationGate::already_published(),
|
||||
McpRuntimeInput {
|
||||
startup_policy: McpStartupPolicy::Eager,
|
||||
config: Arc::new(runtime_config.clone()),
|
||||
plugins_available: false,
|
||||
ready_selected_capability_roots: Vec::new(),
|
||||
@@ -3182,6 +3183,7 @@ async fn no_local_runtime_fails_local_stdio_but_keeps_local_http_server() {
|
||||
/*previous*/ None,
|
||||
McpPublicationGate::already_published(),
|
||||
McpRuntimeInput {
|
||||
startup_policy: McpStartupPolicy::Eager,
|
||||
config: Arc::new(crate::mcp::tests::test_mcp_config(
|
||||
codex_home.path().to_path_buf(),
|
||||
)),
|
||||
@@ -3482,6 +3484,7 @@ async fn manager_with_reusable_ready_server(
|
||||
connection: Arc::new(McpServerConnection {
|
||||
identity: Some(reusable_server_identity(config, runtime_context)),
|
||||
client: create_ready_async_managed_client(tools).await,
|
||||
startup_trigger: None,
|
||||
}),
|
||||
metadata: McpServerMetadata::from(&server),
|
||||
tool_filter: ToolFilter::from_config(config),
|
||||
@@ -3503,6 +3506,7 @@ async fn reconcile_reusable_server(
|
||||
Some(previous),
|
||||
McpPublicationGate::already_published(),
|
||||
McpRuntimeInput {
|
||||
startup_policy: McpStartupPolicy::Eager,
|
||||
config: Arc::new(crate::mcp::tests::test_mcp_config(
|
||||
codex_home.path().to_path_buf(),
|
||||
)),
|
||||
@@ -3613,6 +3617,7 @@ async fn reconciliation_reuses_connection_without_relisting_regular_tools() -> a
|
||||
startup_reconnect: None,
|
||||
cancel_token: CancellationToken::new(),
|
||||
},
|
||||
startup_trigger: None,
|
||||
}),
|
||||
metadata: McpServerMetadata::from(&server),
|
||||
tool_filter: ToolFilter::from_config(&config),
|
||||
@@ -3726,6 +3731,7 @@ async fn reconciliation_replaces_connection_when_protocol_mode_changes() {
|
||||
Some(&previous),
|
||||
McpPublicationGate::already_published(),
|
||||
McpRuntimeInput {
|
||||
startup_policy: McpStartupPolicy::Eager,
|
||||
config: Arc::new(mcp_config),
|
||||
plugins_available: false,
|
||||
ready_selected_capability_roots: Vec::new(),
|
||||
@@ -3783,6 +3789,7 @@ async fn reconciliation_reuses_legacy_stdio_server_when_modern_protocol_is_enabl
|
||||
Some(&previous),
|
||||
McpPublicationGate::already_published(),
|
||||
McpRuntimeInput {
|
||||
startup_policy: McpStartupPolicy::Eager,
|
||||
config: Arc::new(mcp_config),
|
||||
plugins_available: false,
|
||||
ready_selected_capability_roots: Vec::new(),
|
||||
@@ -3917,6 +3924,7 @@ async fn reconciliation_replaces_closed_connections() -> anyhow::Result<()> {
|
||||
startup_reconnect: None,
|
||||
cancel_token: CancellationToken::new(),
|
||||
},
|
||||
startup_trigger: None,
|
||||
});
|
||||
|
||||
assert!(!client.is_closed().await);
|
||||
|
||||
@@ -15,6 +15,7 @@ pub use rmcp_client::MCP_SANDBOX_STATE_META_CAPABILITY;
|
||||
pub use runtime::McpRuntime;
|
||||
pub use runtime::McpRuntimeContext;
|
||||
pub use runtime::McpRuntimeInput;
|
||||
pub use runtime::McpStartupPolicy;
|
||||
pub use runtime::SandboxState;
|
||||
pub use tool_catalog_cache::McpToolCatalogCache;
|
||||
pub use tools::ToolInfo;
|
||||
|
||||
@@ -53,6 +53,7 @@ use crate::connection_manager::McpConnectionSet;
|
||||
use crate::runtime::McpPublicationGate;
|
||||
use crate::runtime::McpRuntimeContext;
|
||||
use crate::runtime::McpRuntimeInput;
|
||||
use crate::runtime::McpStartupPolicy;
|
||||
use crate::server::EffectiveMcpServer;
|
||||
use crate::tools::ToolInfo;
|
||||
|
||||
@@ -359,6 +360,7 @@ pub async fn read_mcp_resource(
|
||||
/*previous*/ None,
|
||||
McpPublicationGate::already_published(),
|
||||
McpRuntimeInput {
|
||||
startup_policy: McpStartupPolicy::Eager,
|
||||
config: Arc::new(runtime_config),
|
||||
plugins_available: false,
|
||||
ready_selected_capability_roots: Vec::new(),
|
||||
@@ -436,6 +438,7 @@ pub async fn collect_mcp_server_status_snapshot_with_detail(
|
||||
/*previous*/ None,
|
||||
McpPublicationGate::already_published(),
|
||||
McpRuntimeInput {
|
||||
startup_policy: McpStartupPolicy::Eager,
|
||||
config: Arc::new(runtime_config),
|
||||
plugins_available: false,
|
||||
ready_selected_capability_roots: Vec::new(),
|
||||
|
||||
@@ -316,10 +316,10 @@ impl ManagedClientStartup {
|
||||
.startup_timeout_sec
|
||||
.unwrap_or(DEFAULT_STARTUP_TIMEOUT);
|
||||
let cancel_token_for_fut = cancel_token;
|
||||
let tool_catalog_fetch_ticket = tool_catalog_cache_context
|
||||
.as_ref()
|
||||
.map(McpToolCatalogCacheContext::begin_fetch);
|
||||
async move {
|
||||
let tool_catalog_fetch_ticket = tool_catalog_cache_context
|
||||
.as_ref()
|
||||
.map(McpToolCatalogCacheContext::begin_fetch);
|
||||
let refresh_start = is_codex_apps_mcp_server.then(Instant::now);
|
||||
let outcome = match async {
|
||||
if let Err(error) = validate_mcp_server_name(&server_name) {
|
||||
|
||||
@@ -48,8 +48,18 @@ use crate::server::EffectiveMcpServer;
|
||||
use crate::tool_catalog_cache::McpToolCatalogCache;
|
||||
use crate::tools::ToolInfo;
|
||||
|
||||
/// Controls when one task starts its optional MCP servers.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum McpStartupPolicy {
|
||||
/// Start configured servers when their task's MCP runtime is published.
|
||||
Eager,
|
||||
/// Start optional servers with cached tool definitions on first use.
|
||||
LazyWhenCached,
|
||||
}
|
||||
|
||||
/// Everything needed to materialize one exact MCP configuration.
|
||||
pub struct McpRuntimeInput {
|
||||
pub startup_policy: McpStartupPolicy,
|
||||
pub config: Arc<McpConfig>,
|
||||
pub plugins_available: bool,
|
||||
pub ready_selected_capability_roots: Vec<SelectedCapabilityRoot>,
|
||||
|
||||
@@ -107,7 +107,7 @@ impl Default for ToolCatalogCacheEntry {
|
||||
|
||||
impl McpToolCatalogCacheContext {
|
||||
pub(crate) fn has_tools(&self) -> bool {
|
||||
self.current_tools().is_some()
|
||||
self.current_tools().is_some_and(|tools| !tools.is_empty())
|
||||
}
|
||||
|
||||
pub(crate) fn optional_startup_deadline(&self, default_deadline: Instant) -> Instant {
|
||||
|
||||
@@ -60,7 +60,6 @@ which = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
codex-exec-server-test-support = { workspace = true }
|
||||
codex-skills-extension = { workspace = true }
|
||||
libc = { workspace = true }
|
||||
pretty_assertions = { workspace = true }
|
||||
tempfile = { workspace = true }
|
||||
|
||||
@@ -33,6 +33,7 @@ use wiremock::ResponseTemplate;
|
||||
use wiremock::matchers::method;
|
||||
use wiremock::matchers::path;
|
||||
use wiremock::matchers::query_param;
|
||||
use wiremock::matchers::query_param_is_missing;
|
||||
|
||||
#[tokio::test]
|
||||
async fn returns_fallback_plugins_when_remote_disabled_for_codex_auth() {
|
||||
@@ -786,7 +787,7 @@ source = "/tmp/{sales_marketplace_name}"
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn expands_cached_remote_plugins_by_loaded_apps() {
|
||||
async fn cached_remote_discovery_requires_installed_cache_and_filters_candidates() {
|
||||
let codex_home = tempdir().expect("tempdir should succeed");
|
||||
write_file(
|
||||
&codex_home.path().join(CONFIG_TOML_FILE),
|
||||
@@ -801,6 +802,25 @@ plugins = true
|
||||
.and(query_param("scope", "GLOBAL"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
|
||||
"plugins": [
|
||||
{
|
||||
"id": "plugins~Plugin_remote_github",
|
||||
"name": "github",
|
||||
"scope": "GLOBAL",
|
||||
"installation_policy": "AVAILABLE",
|
||||
"authentication_policy": "ON_USE",
|
||||
"status": "AVAILABLE",
|
||||
"release": {
|
||||
"display_name": "Remote GitHub",
|
||||
"description": "Remote GitHub long",
|
||||
"app_ids": ["github"],
|
||||
"interface": {"short_description": "Remote GitHub short"},
|
||||
"skills": [{
|
||||
"name": "github",
|
||||
"description": "Use GitHub",
|
||||
"interface": null
|
||||
}]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "plugins~Plugin_remote_unlisted",
|
||||
"name": "remote-unlisted",
|
||||
@@ -835,6 +855,32 @@ plugins = true
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "plugins~Plugin_remote_slack_not_available",
|
||||
"name": "slack",
|
||||
"scope": "GLOBAL",
|
||||
"installation_policy": "NOT_AVAILABLE",
|
||||
"authentication_policy": "ON_USE",
|
||||
"status": "AVAILABLE",
|
||||
"release": {
|
||||
"display_name": "Remote Slack",
|
||||
"description": "Remote Slack long",
|
||||
"interface": {"short_description": "Remote Slack short"}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "plugins~Plugin_remote_figma_admin_disabled",
|
||||
"name": "figma",
|
||||
"scope": "GLOBAL",
|
||||
"installation_policy": "AVAILABLE",
|
||||
"authentication_policy": "ON_USE",
|
||||
"status": "DISABLED_BY_ADMIN",
|
||||
"release": {
|
||||
"display_name": "Remote Figma",
|
||||
"description": "Remote Figma long",
|
||||
"interface": {"short_description": "Remote Figma short"}
|
||||
}
|
||||
}
|
||||
],
|
||||
"pagination": {
|
||||
@@ -860,20 +906,29 @@ plugins = true
|
||||
.await
|
||||
.expect("remote plugin catalog cache should write");
|
||||
|
||||
for scope in ["GLOBAL", "USER", "WORKSPACE"] {
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/backend-api/ps/plugins/installed"))
|
||||
.and(query_param("scope", scope))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
|
||||
"plugins": [],
|
||||
"pagination": {
|
||||
"next_page_token": null
|
||||
}
|
||||
})))
|
||||
.expect(1)
|
||||
.mount(&server)
|
||||
.await;
|
||||
}
|
||||
assert_eq!(
|
||||
list_discoverable_plugins(
|
||||
&plugins_manager,
|
||||
discovery_input(plugins.clone(), &[], &[], &["remote-unlisted-app"]),
|
||||
Some(&auth),
|
||||
)
|
||||
.await,
|
||||
Vec::new()
|
||||
);
|
||||
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/backend-api/ps/plugins/installed"))
|
||||
.and(query_param_is_missing("scope"))
|
||||
.and(query_param_is_missing("includeDownloadUrls"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
|
||||
"plugins": [],
|
||||
"pagination": {
|
||||
"next_page_token": null
|
||||
}
|
||||
})))
|
||||
.expect(1)
|
||||
.mount(&server)
|
||||
.await;
|
||||
plugins_manager
|
||||
.build_and_cache_remote_installed_plugin_marketplaces(
|
||||
&plugins,
|
||||
@@ -884,24 +939,55 @@ plugins = true
|
||||
.await
|
||||
.expect("remote installed plugin cache should write");
|
||||
|
||||
let expected_github = ToolSuggestDiscoverablePlugin {
|
||||
id: "github@openai-curated-remote".to_string(),
|
||||
remote_plugin_id: Some("plugins~Plugin_remote_github".to_string()),
|
||||
name: "Remote GitHub".to_string(),
|
||||
description: Some("Remote GitHub short".to_string()),
|
||||
has_skills: true,
|
||||
mcp_server_names: Vec::new(),
|
||||
app_connector_ids: vec!["github".to_string()],
|
||||
};
|
||||
assert_eq!(
|
||||
list_discoverable_plugins(
|
||||
&plugins_manager,
|
||||
discovery_input(plugins.clone(), &[], &[], &[]),
|
||||
Some(&auth),
|
||||
)
|
||||
.await,
|
||||
vec![expected_github.clone()]
|
||||
);
|
||||
|
||||
let discoverable_plugins = list_discoverable_plugins(
|
||||
&plugins_manager,
|
||||
discovery_input(plugins, &[], &[], &["remote-unlisted-app"]),
|
||||
discovery_input(plugins.clone(), &[], &[], &["remote-unlisted-app"]),
|
||||
Some(&auth),
|
||||
)
|
||||
.await;
|
||||
|
||||
assert_eq!(
|
||||
discoverable_plugins,
|
||||
vec![ToolSuggestDiscoverablePlugin {
|
||||
id: "remote-unlisted@openai-curated-remote".to_string(),
|
||||
remote_plugin_id: Some("plugins~Plugin_remote_unlisted".to_string()),
|
||||
name: "Remote Unlisted".to_string(),
|
||||
description: Some("Remote Unlisted short".to_string()),
|
||||
has_skills: true,
|
||||
mcp_server_names: Vec::new(),
|
||||
app_connector_ids: vec!["remote-unlisted-app".to_string()],
|
||||
}]
|
||||
vec![
|
||||
expected_github,
|
||||
ToolSuggestDiscoverablePlugin {
|
||||
id: "remote-unlisted@openai-curated-remote".to_string(),
|
||||
remote_plugin_id: Some("plugins~Plugin_remote_unlisted".to_string()),
|
||||
name: "Remote Unlisted".to_string(),
|
||||
description: Some("Remote Unlisted short".to_string()),
|
||||
has_skills: true,
|
||||
mcp_server_names: Vec::new(),
|
||||
app_connector_ids: vec!["remote-unlisted-app".to_string()],
|
||||
},
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
list_discoverable_plugins(
|
||||
&plugins_manager,
|
||||
discovery_input(plugins, &[], &["github@openai-curated-remote"], &[]),
|
||||
Some(&auth),
|
||||
)
|
||||
.await,
|
||||
Vec::new()
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -48,8 +48,6 @@ use codex_protocol::auth::AuthMode;
|
||||
use codex_protocol::protocol::HookEventName;
|
||||
use codex_protocol::protocol::Product;
|
||||
use codex_skills::SkillConfigRules;
|
||||
use codex_skills_extension::HostSkillsLoadInput;
|
||||
use codex_skills_extension::HostSkillsService;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use codex_utils_absolute_path::test_support::PathBufExt;
|
||||
use codex_utils_plugins::SkillDiscoveryMode;
|
||||
@@ -2880,7 +2878,7 @@ async fn plugin_cache_ignores_unrelated_session_overrides() {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn skill_snapshots_resolve_remote_plugin_identity_from_authoritative_source() {
|
||||
async fn skill_roots_resolve_remote_plugin_identity_from_authoritative_source() {
|
||||
let mut duplicate_plugin = remote_installed_plugin("sample");
|
||||
duplicate_plugin.id = "plugins~Plugin_duplicate".to_string();
|
||||
|
||||
@@ -2893,7 +2891,6 @@ async fn skill_snapshots_resolve_remote_plugin_identity_from_authoritative_sourc
|
||||
),
|
||||
] {
|
||||
let codex_home = TempDir::new().unwrap();
|
||||
let codex_home_abs = codex_home.path().to_path_buf().abs();
|
||||
let plugin_root = codex_home
|
||||
.path()
|
||||
.join("plugins/cache/openai-curated-remote/sample/local");
|
||||
@@ -2905,8 +2902,10 @@ async fn skill_snapshots_resolve_remote_plugin_identity_from_authoritative_sourc
|
||||
"sample/local",
|
||||
"sample",
|
||||
);
|
||||
let skill_path = plugin_root.join("skills/SKILL.md");
|
||||
write_file(&skill_path, "---\nname: search\ndescription: first\n---\n");
|
||||
write_file(
|
||||
&plugin_root.join("skills/SKILL.md"),
|
||||
"---\nname: search\ndescription: first\n---\n",
|
||||
);
|
||||
write_file(
|
||||
&codex_home.path().join(CONFIG_TOML_FILE),
|
||||
r#"[features]
|
||||
@@ -2941,39 +2940,21 @@ enabled = true
|
||||
.as_deref(),
|
||||
expected_remote_plugin_id
|
||||
);
|
||||
write_file(&skill_path, "---\nname: search\ndescription: second\n---\n");
|
||||
|
||||
let skills_input = HostSkillsLoadInput::new(
|
||||
codex_home_abs.clone(),
|
||||
plugin_outcome.effective_plugin_skill_roots(),
|
||||
config.config_layer_stack.clone(),
|
||||
/*bundled_skills_enabled*/ false,
|
||||
)
|
||||
.with_plugin_skill_snapshots(manager.plugin_skill_snapshots_for_config(&config));
|
||||
let skills_service =
|
||||
HostSkillsService::new(codex_home_abs, /*bundled_skills_enabled*/ false);
|
||||
let snapshot = skills_service
|
||||
.snapshot_for_config(&skills_input, /*fs*/ None)
|
||||
.await;
|
||||
|
||||
assert_eq!(
|
||||
snapshot
|
||||
.outcome()
|
||||
.skills
|
||||
.iter()
|
||||
.map(|skill| {
|
||||
plugin_outcome
|
||||
.effective_plugin_skill_roots()
|
||||
.into_iter()
|
||||
.map(|root| {
|
||||
(
|
||||
skill.description.as_str(),
|
||||
skill.plugin_id.as_deref(),
|
||||
skill.remote_plugin_id.as_deref(),
|
||||
root.plugin_identity.plugin_id,
|
||||
root.plugin_identity.remote_plugin_id,
|
||||
)
|
||||
})
|
||||
.collect::<Vec<_>>(),
|
||||
vec![(
|
||||
"first",
|
||||
Some("sample@openai-curated-remote"),
|
||||
expected_remote_plugin_id,
|
||||
)]
|
||||
"sample@openai-curated-remote".to_string(),
|
||||
expected_remote_plugin_id.map(str::to_string),
|
||||
)],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -468,6 +468,12 @@ pub enum RemotePluginScope {
|
||||
Workspace,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum RemoteInstalledPluginScope {
|
||||
All,
|
||||
Single(RemotePluginScope),
|
||||
}
|
||||
|
||||
impl RemotePluginScope {
|
||||
const CATALOG_CACHE_SCOPES: [Self; 3] = [Self::Global, Self::User, Self::Workspace];
|
||||
|
||||
@@ -1162,28 +1168,16 @@ pub(crate) async fn fetch_remote_installed_plugins(
|
||||
auth: Option<&CodexAuth>,
|
||||
) -> Result<Vec<RemoteInstalledPlugin>, RemotePluginCatalogError> {
|
||||
let auth = ensure_chatgpt_auth(auth)?;
|
||||
let global = async {
|
||||
let scope = RemotePluginScope::Global;
|
||||
let installed_plugins = fetch_installed_plugins_for_scope(config, auth, scope).await?;
|
||||
Ok::<_, RemotePluginCatalogError>((scope, installed_plugins))
|
||||
};
|
||||
let workspace = async {
|
||||
let scope = RemotePluginScope::Workspace;
|
||||
let installed_plugins = fetch_installed_plugins_for_scope(config, auth, scope).await?;
|
||||
Ok::<_, RemotePluginCatalogError>((scope, installed_plugins))
|
||||
};
|
||||
let user = async {
|
||||
let scope = RemotePluginScope::User;
|
||||
let installed_plugins = fetch_installed_plugins_for_scope(config, auth, scope).await?;
|
||||
Ok::<_, RemotePluginCatalogError>((scope, installed_plugins))
|
||||
};
|
||||
|
||||
let (global, workspace, user) = tokio::try_join!(global, workspace, user)?;
|
||||
let mut installed_plugins = [global, workspace, user]
|
||||
.into_iter()
|
||||
.flat_map(|(_scope, plugins)| plugins)
|
||||
.map(|plugin| remote_installed_plugin_to_cache_entry(&plugin))
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
let mut installed_plugins = fetch_installed_plugins(
|
||||
config,
|
||||
auth,
|
||||
RemoteInstalledPluginScope::All,
|
||||
/*include_download_urls*/ false,
|
||||
)
|
||||
.await?
|
||||
.into_iter()
|
||||
.map(|plugin| remote_installed_plugin_to_cache_entry(&plugin))
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
installed_plugins.sort_by(|left, right| {
|
||||
left.marketplace_name
|
||||
.cmp(&right.marketplace_name)
|
||||
@@ -1989,16 +1983,19 @@ async fn fetch_installed_plugins_for_scope(
|
||||
auth: &CodexAuth,
|
||||
scope: RemotePluginScope,
|
||||
) -> Result<Vec<RemotePluginInstalledItem>, RemotePluginCatalogError> {
|
||||
fetch_installed_plugins_for_scope_with_download_url(
|
||||
config, auth, scope, /*include_download_urls*/ false,
|
||||
fetch_installed_plugins(
|
||||
config,
|
||||
auth,
|
||||
RemoteInstalledPluginScope::Single(scope),
|
||||
/*include_download_urls*/ false,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn fetch_installed_plugins_for_scope_with_download_url(
|
||||
async fn fetch_installed_plugins(
|
||||
config: &RemotePluginServiceConfig,
|
||||
auth: &CodexAuth,
|
||||
scope: RemotePluginScope,
|
||||
scope: RemoteInstalledPluginScope,
|
||||
include_download_urls: bool,
|
||||
) -> Result<Vec<RemotePluginInstalledItem>, RemotePluginCatalogError> {
|
||||
let mut plugins = Vec::new();
|
||||
@@ -2066,15 +2063,23 @@ async fn get_remote_shared_workspace_plugins_page(
|
||||
async fn get_remote_plugin_installed_page(
|
||||
config: &RemotePluginServiceConfig,
|
||||
auth: &CodexAuth,
|
||||
scope: RemotePluginScope,
|
||||
scope: RemoteInstalledPluginScope,
|
||||
page_token: Option<&str>,
|
||||
include_download_urls: bool,
|
||||
) -> Result<RemotePluginInstalledResponse, RemotePluginCatalogError> {
|
||||
let base_url = config.chatgpt_base_url.trim_end_matches('/');
|
||||
let mut url = Url::parse(&format!("{base_url}/ps/plugins/installed"))
|
||||
.map_err(RemotePluginCatalogError::InvalidBaseUrl)?;
|
||||
url.query_pairs_mut()
|
||||
.append_pair("scope", scope.api_value());
|
||||
match scope {
|
||||
RemoteInstalledPluginScope::All => {
|
||||
url.query_pairs_mut()
|
||||
.append_pair("limit", &REMOTE_PLUGIN_LIST_PAGE_LIMIT.to_string());
|
||||
}
|
||||
RemoteInstalledPluginScope::Single(scope) => {
|
||||
url.query_pairs_mut()
|
||||
.append_pair("scope", scope.api_value());
|
||||
}
|
||||
}
|
||||
if include_download_urls {
|
||||
url.query_pairs_mut()
|
||||
.append_pair("includeDownloadUrls", "true");
|
||||
|
||||
@@ -4,12 +4,13 @@ use super::REMOTE_WORKSPACE_MARKETPLACE_NAME;
|
||||
use super::REMOTE_WORKSPACE_SHARED_WITH_ME_MARKETPLACE_NAME;
|
||||
use super::REMOTE_WORKSPACE_SHARED_WITH_ME_PRIVATE_MARKETPLACE_NAME;
|
||||
use super::REMOTE_WORKSPACE_SHARED_WITH_ME_UNLISTED_MARKETPLACE_NAME;
|
||||
use super::RemoteInstalledPluginScope;
|
||||
use super::RemotePluginCatalogError;
|
||||
use super::RemotePluginScope;
|
||||
use super::RemotePluginServiceConfig;
|
||||
use super::RemotePluginShareDiscoverability;
|
||||
use super::ensure_chatgpt_auth;
|
||||
use super::fetch_installed_plugins_for_scope_with_download_url;
|
||||
use super::fetch_installed_plugins;
|
||||
use super::remote_plugin_canonical_marketplace_name;
|
||||
use crate::store::PLUGINS_CACHE_DIR;
|
||||
use crate::store::PluginStore;
|
||||
@@ -136,32 +137,13 @@ pub async fn sync_remote_installed_plugin_bundles_once(
|
||||
) -> Result<RemoteInstalledPluginBundleSyncOutcome, RemoteInstalledPluginBundleSyncError> {
|
||||
let auth = ensure_chatgpt_auth(auth)?;
|
||||
let authenticated_account_id = auth.get_account_id();
|
||||
let global = async {
|
||||
let scope = RemotePluginScope::Global;
|
||||
let installed_plugins = fetch_installed_plugins_for_scope_with_download_url(
|
||||
config, auth, scope, /*include_download_urls*/ true,
|
||||
)
|
||||
.await?;
|
||||
Ok::<_, RemotePluginCatalogError>((scope, installed_plugins))
|
||||
};
|
||||
let workspace = async {
|
||||
let scope = RemotePluginScope::Workspace;
|
||||
let installed_plugins = fetch_installed_plugins_for_scope_with_download_url(
|
||||
config, auth, scope, /*include_download_urls*/ true,
|
||||
)
|
||||
.await?;
|
||||
Ok::<_, RemotePluginCatalogError>((scope, installed_plugins))
|
||||
};
|
||||
let user = async {
|
||||
let scope = RemotePluginScope::User;
|
||||
let installed_plugins = fetch_installed_plugins_for_scope_with_download_url(
|
||||
config, auth, scope, /*include_download_urls*/ true,
|
||||
)
|
||||
.await?;
|
||||
Ok::<_, RemotePluginCatalogError>((scope, installed_plugins))
|
||||
};
|
||||
|
||||
let (global, workspace, user) = tokio::try_join!(global, workspace, user)?;
|
||||
let installed_plugins = fetch_installed_plugins(
|
||||
config,
|
||||
auth,
|
||||
RemoteInstalledPluginScope::All,
|
||||
/*include_download_urls*/ true,
|
||||
)
|
||||
.await?;
|
||||
let store = PluginStore::try_new(codex_home.clone())?;
|
||||
let mut installed_plugin_names_by_marketplace =
|
||||
BTreeMap::<String, BTreeSet<String>>::from_iter([
|
||||
@@ -190,101 +172,99 @@ pub async fn sync_remote_installed_plugin_bundles_once(
|
||||
let mut materialized_remote_plugins = BTreeMap::new();
|
||||
let mut failed_remote_plugin_ids = BTreeSet::new();
|
||||
|
||||
for (_scope, installed_plugins) in [global, workspace, user] {
|
||||
for installed_plugin in installed_plugins {
|
||||
let plugin = installed_plugin.plugin;
|
||||
let scope = plugin.scope;
|
||||
let discoverability = plugin.discoverability;
|
||||
let marketplace_name = remote_plugin_canonical_marketplace_name(&plugin)?.to_string();
|
||||
installed_plugin_names_by_marketplace
|
||||
.entry(marketplace_name.clone())
|
||||
.or_default()
|
||||
.insert(plugin.name.clone());
|
||||
let plugin_id = match PluginId::new(plugin.name.clone(), marketplace_name.clone()) {
|
||||
Ok(plugin_id) => plugin_id,
|
||||
Err(err) => {
|
||||
warn!(
|
||||
remote_plugin_id = %plugin.id,
|
||||
plugin = %plugin.name,
|
||||
marketplace = %marketplace_name,
|
||||
error = %err,
|
||||
"skipping remote installed plugin with invalid local cache id"
|
||||
);
|
||||
failed_remote_plugin_ids.insert(plugin.id);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let release_version = plugin
|
||||
.release
|
||||
.version
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|version| !version.is_empty());
|
||||
if store.active_plugin_version(&plugin_id).as_deref() == release_version {
|
||||
if let Err(err) = store.write_remote_plugin_id(&plugin_id, &plugin.id) {
|
||||
warn!(
|
||||
remote_plugin_id = %plugin.id,
|
||||
plugin = %plugin.name,
|
||||
marketplace = %marketplace_name,
|
||||
error = %err,
|
||||
"failed to persist identity for cached remote installed plugin"
|
||||
);
|
||||
failed_remote_plugin_ids.insert(plugin.id);
|
||||
}
|
||||
for installed_plugin in installed_plugins {
|
||||
let plugin = installed_plugin.plugin;
|
||||
let scope = plugin.scope;
|
||||
let discoverability = plugin.discoverability;
|
||||
let marketplace_name = remote_plugin_canonical_marketplace_name(&plugin)?.to_string();
|
||||
installed_plugin_names_by_marketplace
|
||||
.entry(marketplace_name.clone())
|
||||
.or_default()
|
||||
.insert(plugin.name.clone());
|
||||
let plugin_id = match PluginId::new(plugin.name.clone(), marketplace_name.clone()) {
|
||||
Ok(plugin_id) => plugin_id,
|
||||
Err(err) => {
|
||||
warn!(
|
||||
remote_plugin_id = %plugin.id,
|
||||
plugin = %plugin.name,
|
||||
marketplace = %marketplace_name,
|
||||
error = %err,
|
||||
"skipping remote installed plugin with invalid local cache id"
|
||||
);
|
||||
failed_remote_plugin_ids.insert(plugin.id);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let release_version = plugin
|
||||
.release
|
||||
.version
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|version| !version.is_empty());
|
||||
if store.active_plugin_version(&plugin_id).as_deref() == release_version {
|
||||
if let Err(err) = store.write_remote_plugin_id(&plugin_id, &plugin.id) {
|
||||
warn!(
|
||||
remote_plugin_id = %plugin.id,
|
||||
plugin = %plugin.name,
|
||||
marketplace = %marketplace_name,
|
||||
error = %err,
|
||||
"failed to persist identity for cached remote installed plugin"
|
||||
);
|
||||
failed_remote_plugin_ids.insert(plugin.id);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
let bundle = match crate::remote_bundle::validate_remote_plugin_bundle(
|
||||
&plugin.id,
|
||||
&marketplace_name,
|
||||
&plugin.name,
|
||||
release_version,
|
||||
plugin.release.bundle_download_url.as_deref(),
|
||||
plugin.release.app_manifest.clone(),
|
||||
) {
|
||||
Ok(bundle) => bundle,
|
||||
Err(err) => {
|
||||
warn!(
|
||||
remote_plugin_id = %plugin.id,
|
||||
plugin = %plugin.name,
|
||||
marketplace = %marketplace_name,
|
||||
error = %err,
|
||||
"skipping remote installed plugin bundle download"
|
||||
);
|
||||
failed_remote_plugin_ids.insert(plugin.id);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let bundle = match crate::remote_bundle::validate_remote_plugin_bundle(
|
||||
&plugin.id,
|
||||
&marketplace_name,
|
||||
&plugin.name,
|
||||
release_version,
|
||||
plugin.release.bundle_download_url.as_deref(),
|
||||
plugin.release.app_manifest.clone(),
|
||||
) {
|
||||
Ok(bundle) => bundle,
|
||||
Err(err) => {
|
||||
warn!(
|
||||
remote_plugin_id = %plugin.id,
|
||||
plugin = %plugin.name,
|
||||
marketplace = %marketplace_name,
|
||||
error = %err,
|
||||
"skipping remote installed plugin bundle download"
|
||||
);
|
||||
failed_remote_plugin_ids.insert(plugin.id);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
match crate::remote_bundle::download_and_install_remote_plugin_bundle(
|
||||
config,
|
||||
codex_home.clone(),
|
||||
bundle,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(result) => {
|
||||
let plugin_id = result.plugin_id;
|
||||
materialized_remote_plugins.insert(
|
||||
plugin_id.as_key(),
|
||||
RemotePluginMaterialization {
|
||||
plugin_id,
|
||||
scope,
|
||||
discoverability,
|
||||
authenticated_account_id: authenticated_account_id.clone(),
|
||||
},
|
||||
);
|
||||
}
|
||||
Err(err) => {
|
||||
warn!(
|
||||
remote_plugin_id = %plugin.id,
|
||||
plugin = %plugin.name,
|
||||
marketplace = %marketplace_name,
|
||||
error = %err,
|
||||
"failed to download remote installed plugin bundle"
|
||||
);
|
||||
failed_remote_plugin_ids.insert(plugin.id);
|
||||
}
|
||||
match crate::remote_bundle::download_and_install_remote_plugin_bundle(
|
||||
config,
|
||||
codex_home.clone(),
|
||||
bundle,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(result) => {
|
||||
let plugin_id = result.plugin_id;
|
||||
materialized_remote_plugins.insert(
|
||||
plugin_id.as_key(),
|
||||
RemotePluginMaterialization {
|
||||
plugin_id,
|
||||
scope,
|
||||
discoverability,
|
||||
authenticated_account_id: authenticated_account_id.clone(),
|
||||
},
|
||||
);
|
||||
}
|
||||
Err(err) => {
|
||||
warn!(
|
||||
remote_plugin_id = %plugin.id,
|
||||
plugin = %plugin.name,
|
||||
marketplace = %marketplace_name,
|
||||
error = %err,
|
||||
"failed to download remote installed plugin bundle"
|
||||
);
|
||||
failed_remote_plugin_ids.insert(plugin.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -485,6 +465,7 @@ mod tests {
|
||||
use wiremock::matchers::method;
|
||||
use wiremock::matchers::path;
|
||||
use wiremock::matchers::query_param;
|
||||
use wiremock::matchers::query_param_is_missing;
|
||||
|
||||
#[test]
|
||||
fn remote_installed_plugin_sync_in_flight_dedupes_by_cache_root() {
|
||||
@@ -526,7 +507,8 @@ mod tests {
|
||||
let remote_plugin_id = "plugins~Plugin_linear";
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/backend-api/ps/plugins/installed"))
|
||||
.and(query_param("scope", "GLOBAL"))
|
||||
.and(query_param_is_missing("scope"))
|
||||
.and(query_param("limit", "200"))
|
||||
.and(query_param("includeDownloadUrls", "true"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
|
||||
"plugins": [{
|
||||
@@ -549,28 +531,6 @@ mod tests {
|
||||
.expect(1)
|
||||
.mount(&server)
|
||||
.await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/backend-api/ps/plugins/installed"))
|
||||
.and(query_param("scope", "USER"))
|
||||
.and(query_param("includeDownloadUrls", "true"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
|
||||
"plugins": [],
|
||||
"pagination": {"next_page_token": null},
|
||||
})))
|
||||
.expect(1)
|
||||
.mount(&server)
|
||||
.await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/backend-api/ps/plugins/installed"))
|
||||
.and(query_param("scope", "WORKSPACE"))
|
||||
.and(query_param("includeDownloadUrls", "true"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
|
||||
"plugins": [],
|
||||
"pagination": {"next_page_token": null},
|
||||
})))
|
||||
.expect(1)
|
||||
.mount(&server)
|
||||
.await;
|
||||
let config = RemotePluginServiceConfig::new(
|
||||
format!("{}/backend-api", server.uri()),
|
||||
crate::test_support::test_http_client_factory(),
|
||||
@@ -607,6 +567,177 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn sync_all_scopes_paginates_and_reconciles_each_marketplace() {
|
||||
let server = MockServer::start().await;
|
||||
let codex_home = tempfile::tempdir().expect("create codex home");
|
||||
let cached_plugins = [
|
||||
(
|
||||
REMOTE_GLOBAL_MARKETPLACE_NAME,
|
||||
"global-plugin",
|
||||
"GLOBAL",
|
||||
None,
|
||||
),
|
||||
(
|
||||
REMOTE_CREATED_BY_ME_MARKETPLACE_NAME,
|
||||
"user-plugin",
|
||||
"USER",
|
||||
None,
|
||||
),
|
||||
(
|
||||
REMOTE_WORKSPACE_MARKETPLACE_NAME,
|
||||
"workspace-plugin",
|
||||
"WORKSPACE",
|
||||
Some("LISTED"),
|
||||
),
|
||||
(
|
||||
REMOTE_WORKSPACE_SHARED_WITH_ME_MARKETPLACE_NAME,
|
||||
"shared-plugin",
|
||||
"WORKSPACE",
|
||||
Some("PRIVATE"),
|
||||
),
|
||||
];
|
||||
for (marketplace_name, plugin_name, _, _) in cached_plugins {
|
||||
for cached_plugin_name in [plugin_name, "stale"] {
|
||||
let manifest = codex_home
|
||||
.path()
|
||||
.join(PLUGINS_CACHE_DIR)
|
||||
.join(marketplace_name)
|
||||
.join(cached_plugin_name)
|
||||
.join("1.2.3")
|
||||
.join(".codex-plugin")
|
||||
.join("plugin.json");
|
||||
std::fs::create_dir_all(manifest.parent().expect("manifest parent"))
|
||||
.expect("create cached plugin manifest parent");
|
||||
std::fs::write(
|
||||
&manifest,
|
||||
format!(r#"{{"name":"{cached_plugin_name}","version":"1.2.3"}}"#),
|
||||
)
|
||||
.expect("write cached plugin manifest");
|
||||
}
|
||||
}
|
||||
let installed_plugins = cached_plugins
|
||||
.iter()
|
||||
.map(|(_, plugin_name, scope, discoverability)| {
|
||||
let mut plugin = json!({
|
||||
"id": format!("plugins~Plugin_{plugin_name}"),
|
||||
"name": plugin_name,
|
||||
"scope": scope,
|
||||
"installation_policy": "AVAILABLE",
|
||||
"authentication_policy": "ON_USE",
|
||||
"status": "ENABLED",
|
||||
"release": {
|
||||
"version": "1.2.3",
|
||||
"display_name": plugin_name,
|
||||
"description": "Installed plugin",
|
||||
"interface": {},
|
||||
},
|
||||
"enabled": true,
|
||||
});
|
||||
if let Some(discoverability) = discoverability {
|
||||
plugin["discoverability"] = json!(discoverability);
|
||||
}
|
||||
plugin
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/backend-api/ps/plugins/installed"))
|
||||
.and(query_param_is_missing("scope"))
|
||||
.and(query_param("limit", "200"))
|
||||
.and(query_param("includeDownloadUrls", "true"))
|
||||
.and(query_param_is_missing("pageToken"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
|
||||
"plugins": &installed_plugins[..2],
|
||||
"pagination": {"next_page_token": "page-2"},
|
||||
})))
|
||||
.expect(1)
|
||||
.mount(&server)
|
||||
.await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/backend-api/ps/plugins/installed"))
|
||||
.and(query_param_is_missing("scope"))
|
||||
.and(query_param("limit", "200"))
|
||||
.and(query_param("includeDownloadUrls", "true"))
|
||||
.and(query_param("pageToken", "page-2"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
|
||||
"plugins": &installed_plugins[2..],
|
||||
"pagination": {"next_page_token": null},
|
||||
})))
|
||||
.expect(1)
|
||||
.mount(&server)
|
||||
.await;
|
||||
let (config, selected_urls) = crate::test_support::recording_remote_plugin_service_config(
|
||||
format!("{}/backend-api", server.uri()),
|
||||
);
|
||||
let auth = CodexAuth::create_dummy_chatgpt_auth_for_testing();
|
||||
|
||||
let outcome = sync_remote_installed_plugin_bundles_once(
|
||||
codex_home.path().to_path_buf(),
|
||||
&config,
|
||||
Some(&auth),
|
||||
)
|
||||
.await
|
||||
.expect("sync installed plugins across every marketplace");
|
||||
let mut removed_cache_plugin_ids = cached_plugins
|
||||
.iter()
|
||||
.map(|(marketplace_name, _, _, _)| format!("stale@{marketplace_name}"))
|
||||
.collect::<Vec<_>>();
|
||||
removed_cache_plugin_ids.sort();
|
||||
|
||||
assert_eq!(
|
||||
outcome,
|
||||
RemoteInstalledPluginBundleSyncOutcome {
|
||||
materialized_remote_plugins: Vec::new(),
|
||||
removed_cache_plugin_ids,
|
||||
failed_remote_plugin_ids: Vec::new(),
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
crate::test_support::recorded_http_client_urls(&selected_urls),
|
||||
vec![
|
||||
format!(
|
||||
"{}/backend-api/ps/plugins/installed?limit=200&includeDownloadUrls=true",
|
||||
server.uri()
|
||||
),
|
||||
format!(
|
||||
"{}/backend-api/ps/plugins/installed?limit=200&includeDownloadUrls=true&pageToken=page-2",
|
||||
server.uri()
|
||||
),
|
||||
]
|
||||
);
|
||||
for (marketplace_name, plugin_name, _, _) in cached_plugins {
|
||||
let plugin_root = codex_home
|
||||
.path()
|
||||
.join(PLUGINS_CACHE_DIR)
|
||||
.join(marketplace_name)
|
||||
.join(plugin_name);
|
||||
assert!(
|
||||
plugin_root
|
||||
.join("1.2.3/.codex-plugin/plugin.json")
|
||||
.is_file()
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::from_str::<serde_json::Value>(
|
||||
&std::fs::read_to_string(plugin_root.join(".codex-remote-plugin-install.json"))
|
||||
.expect("read remote plugin install metadata")
|
||||
)
|
||||
.expect("parse remote plugin install metadata"),
|
||||
json!({
|
||||
"schema_version": 1,
|
||||
"remote_plugin_id": format!("plugins~Plugin_{plugin_name}"),
|
||||
})
|
||||
);
|
||||
assert!(
|
||||
!codex_home
|
||||
.path()
|
||||
.join(PLUGINS_CACHE_DIR)
|
||||
.join(marketplace_name)
|
||||
.join("stale")
|
||||
.exists()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stale_remote_plugin_cleanup_skips_cache_mutations_in_progress() {
|
||||
let codex_home = tempfile::tempdir().expect("create codex home");
|
||||
|
||||
@@ -8,6 +8,8 @@ use wiremock::ResponseTemplate;
|
||||
use wiremock::matchers::header_exists;
|
||||
use wiremock::matchers::method;
|
||||
use wiremock::matchers::path;
|
||||
use wiremock::matchers::query_param;
|
||||
use wiremock::matchers::query_param_is_missing;
|
||||
|
||||
#[tokio::test]
|
||||
async fn remote_plugin_list_routes_the_complete_query_url() {
|
||||
@@ -46,6 +48,84 @@ async fn remote_plugin_list_routes_the_complete_query_url() {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn remote_installed_plugins_paginate_across_all_scopes_without_download_urls() {
|
||||
let server = MockServer::start().await;
|
||||
let installed_plugin = |scope: RemotePluginScope, id: &str, name: &str| {
|
||||
let mut plugin = directory_plugin(id, name);
|
||||
plugin.scope = scope;
|
||||
if scope == RemotePluginScope::Workspace {
|
||||
plugin.discoverability = Some(RemotePluginShareDiscoverability::Listed);
|
||||
}
|
||||
let mut plugin = serde_json::to_value(plugin).expect("serialize installed plugin");
|
||||
plugin["enabled"] = serde_json::json!(true);
|
||||
plugin
|
||||
};
|
||||
let global = installed_plugin(RemotePluginScope::Global, "plugin-global", "global-plugin");
|
||||
let user = installed_plugin(RemotePluginScope::User, "plugin-user", "user-plugin");
|
||||
let workspace = installed_plugin(
|
||||
RemotePluginScope::Workspace,
|
||||
"plugin-workspace",
|
||||
"workspace-plugin",
|
||||
);
|
||||
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/backend-api/ps/plugins/installed"))
|
||||
.and(query_param_is_missing("scope"))
|
||||
.and(query_param("limit", "200"))
|
||||
.and(query_param_is_missing("includeDownloadUrls"))
|
||||
.and(query_param_is_missing("pageToken"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
|
||||
"plugins": [user.clone(), workspace.clone()],
|
||||
"pagination": {"next_page_token": "next page/+"},
|
||||
})))
|
||||
.expect(1)
|
||||
.mount(&server)
|
||||
.await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/backend-api/ps/plugins/installed"))
|
||||
.and(query_param_is_missing("scope"))
|
||||
.and(query_param("limit", "200"))
|
||||
.and(query_param_is_missing("includeDownloadUrls"))
|
||||
.and(query_param("pageToken", "next page/+"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
|
||||
"plugins": [global.clone()],
|
||||
"pagination": {"next_page_token": null},
|
||||
})))
|
||||
.expect(1)
|
||||
.mount(&server)
|
||||
.await;
|
||||
let (config, selected_urls) =
|
||||
recording_remote_plugin_service_config(format!("{}/backend-api", server.uri()));
|
||||
let auth = CodexAuth::create_dummy_chatgpt_auth_for_testing();
|
||||
|
||||
let installed_plugins = fetch_remote_installed_plugins(&config, Some(&auth))
|
||||
.await
|
||||
.expect("all-scopes installed plugin request should succeed");
|
||||
let expected_plugins = [user, global, workspace]
|
||||
.into_iter()
|
||||
.map(|plugin| {
|
||||
let plugin = serde_json::from_value(plugin).expect("deserialize installed plugin");
|
||||
remote_installed_plugin_to_cache_entry(&plugin).expect("valid installed plugin")
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
assert_eq!(installed_plugins, expected_plugins);
|
||||
assert_eq!(
|
||||
recorded_http_client_urls(&selected_urls),
|
||||
vec![
|
||||
format!(
|
||||
"{}/backend-api/ps/plugins/installed?limit=200",
|
||||
server.uri()
|
||||
),
|
||||
format!(
|
||||
"{}/backend-api/ps/plugins/installed?limit=200&pageToken=next+page%2F%2B",
|
||||
server.uri()
|
||||
),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cached_remote_plugin_catalog_scopes_returns_existing_scopes() {
|
||||
let codex_home = tempfile::tempdir().expect("create codex home");
|
||||
|
||||
@@ -22,7 +22,6 @@ codex-login = { workspace = true }
|
||||
codex-model-provider = { workspace = true }
|
||||
codex-otel = { workspace = true }
|
||||
codex-protocol = { workspace = true }
|
||||
codex-shell-command = { workspace = true }
|
||||
codex-skills = { workspace = true }
|
||||
codex-utils-absolute-path = { workspace = true }
|
||||
codex-utils-path-uri = { workspace = true }
|
||||
@@ -33,7 +32,6 @@ futures = { workspace = true }
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
serde_json = { workspace = true }
|
||||
serde_yaml = { workspace = true }
|
||||
shlex = { workspace = true }
|
||||
tokio = { workspace = true, features = ["fs", "macros", "rt"] }
|
||||
tracing = { workspace = true }
|
||||
zip = { workspace = true }
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
use std::collections::HashMap;
|
||||
use std::collections::HashSet;
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::SkillLoadOutcome;
|
||||
use crate::SkillMetadata;
|
||||
use crate::build_skill_name_counts;
|
||||
use codex_analytics::AnalyticsEventsClient;
|
||||
use codex_analytics::InvocationType;
|
||||
use codex_analytics::SkillInvocation;
|
||||
@@ -12,10 +10,15 @@ use codex_analytics::TrackEventsContext;
|
||||
use codex_exec_server::LOCAL_FS;
|
||||
use codex_otel::SessionTelemetry;
|
||||
use codex_otel::sanitize_metric_tag_value;
|
||||
use codex_protocol::user_input::UserInput;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
pub use codex_skills::ToolMentionKind;
|
||||
pub use codex_skills::ToolMentions;
|
||||
pub use codex_skills::app_id_from_path;
|
||||
pub use codex_skills::extract_tool_mentions;
|
||||
pub use codex_skills::extract_tool_mentions_with_sigil;
|
||||
pub use codex_skills::normalize_skill_path;
|
||||
pub use codex_skills::plugin_config_name_from_path;
|
||||
pub use codex_skills::tool_kind_for_path;
|
||||
use codex_utils_path_uri::PathUri;
|
||||
use codex_utils_plugins::mention_syntax::TOOL_MENTION_SIGIL;
|
||||
use codex_utils_string::take_bytes_at_char_boundary;
|
||||
|
||||
use crate::MAX_SKILL_PROMPT_BYTES;
|
||||
@@ -170,420 +173,6 @@ fn emit_skill_injected_metric(
|
||||
);
|
||||
}
|
||||
|
||||
/// Collect explicitly mentioned skills from structured and text mentions.
|
||||
///
|
||||
/// Structured `UserInput::Skill` selections are resolved first by path against
|
||||
/// enabled skills. Text inputs are then scanned to extract `$skill-name` tokens, and we
|
||||
/// iterate loaded skills in their existing order to preserve prior ordering semantics.
|
||||
/// Explicit paths match either a skill's canonical identity or its logical discovery
|
||||
/// path, and plain names are only used when the match is unambiguous.
|
||||
///
|
||||
/// Complexity: `O(T + (N_s + N_t) * S)` time, `O(S + M)` space, where:
|
||||
/// `S` = number of skills, `T` = total text length, `N_s` = number of structured skill inputs,
|
||||
/// `N_t` = number of text inputs, `M` = max mentions parsed from a single text input.
|
||||
pub fn collect_explicit_skill_mentions(
|
||||
inputs: &[UserInput],
|
||||
loaded_skills: &SkillLoadOutcome,
|
||||
connector_slug_counts: &HashMap<String, usize>,
|
||||
) -> Vec<SkillMetadata> {
|
||||
let skill_name_counts =
|
||||
build_skill_name_counts(&loaded_skills.skills, &loaded_skills.disabled_paths).0;
|
||||
|
||||
let selection_context = SkillSelectionContext {
|
||||
loaded_skills,
|
||||
skill_name_counts: &skill_name_counts,
|
||||
connector_slug_counts,
|
||||
};
|
||||
let mut selected: Vec<SkillMetadata> = Vec::new();
|
||||
let mut seen_names: HashSet<String> = HashSet::new();
|
||||
let mut seen_paths: HashSet<AbsolutePathBuf> = HashSet::new();
|
||||
let mut blocked_plain_names: HashSet<String> = HashSet::new();
|
||||
|
||||
for input in inputs {
|
||||
if let UserInput::Skill { name, path, .. } = input {
|
||||
blocked_plain_names.insert(name.clone());
|
||||
let Ok(path) = AbsolutePathBuf::relative_to_current_dir(path) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
let Some(skill) = selection_context.loaded_skills.skills.iter().find(|skill| {
|
||||
skill.path_to_skills_md == path
|
||||
|| selection_context
|
||||
.loaded_skills
|
||||
.skill_discovery_path_for_path(&skill.path_to_skills_md)
|
||||
.is_some_and(|discovery_path| discovery_path == &path)
|
||||
}) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
if !selection_context.loaded_skills.is_skill_enabled(skill)
|
||||
|| seen_paths.contains(&skill.path_to_skills_md)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
seen_paths.insert(skill.path_to_skills_md.clone());
|
||||
seen_names.insert(skill.name.clone());
|
||||
selected.push(skill.clone());
|
||||
}
|
||||
}
|
||||
|
||||
for input in inputs {
|
||||
if let UserInput::Text { text, .. } = input {
|
||||
let mentioned_names = extract_tool_mentions(text);
|
||||
select_skills_from_mentions(
|
||||
&selection_context,
|
||||
&blocked_plain_names,
|
||||
&mentioned_names,
|
||||
&mut seen_names,
|
||||
&mut seen_paths,
|
||||
&mut selected,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
selected
|
||||
}
|
||||
|
||||
struct SkillSelectionContext<'a> {
|
||||
loaded_skills: &'a SkillLoadOutcome,
|
||||
skill_name_counts: &'a HashMap<String, usize>,
|
||||
connector_slug_counts: &'a HashMap<String, usize>,
|
||||
}
|
||||
|
||||
pub struct ToolMentions<'a> {
|
||||
names: HashSet<&'a str>,
|
||||
paths: HashSet<&'a str>,
|
||||
plain_names: HashSet<&'a str>,
|
||||
}
|
||||
|
||||
impl<'a> ToolMentions<'a> {
|
||||
fn is_empty(&self) -> bool {
|
||||
self.names.is_empty() && self.paths.is_empty()
|
||||
}
|
||||
|
||||
pub fn plain_names(&self) -> impl Iterator<Item = &'a str> + '_ {
|
||||
self.plain_names.iter().copied()
|
||||
}
|
||||
|
||||
pub fn paths(&self) -> impl Iterator<Item = &'a str> + '_ {
|
||||
self.paths.iter().copied()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum ToolMentionKind {
|
||||
App,
|
||||
Mcp,
|
||||
Plugin,
|
||||
Skill,
|
||||
Other,
|
||||
}
|
||||
|
||||
const APP_PATH_PREFIX: &str = "app://";
|
||||
const MCP_PATH_PREFIX: &str = "mcp://";
|
||||
const PLUGIN_PATH_PREFIX: &str = "plugin://";
|
||||
const SKILL_PATH_PREFIX: &str = "skill://";
|
||||
const SKILL_FILENAME: &str = "SKILL.md";
|
||||
|
||||
pub fn tool_kind_for_path(path: &str) -> ToolMentionKind {
|
||||
if path.starts_with(APP_PATH_PREFIX) {
|
||||
ToolMentionKind::App
|
||||
} else if path.starts_with(MCP_PATH_PREFIX) {
|
||||
ToolMentionKind::Mcp
|
||||
} else if path.starts_with(PLUGIN_PATH_PREFIX) {
|
||||
ToolMentionKind::Plugin
|
||||
} else if path.starts_with(SKILL_PATH_PREFIX) || is_skill_filename(path) {
|
||||
ToolMentionKind::Skill
|
||||
} else {
|
||||
ToolMentionKind::Other
|
||||
}
|
||||
}
|
||||
|
||||
fn is_skill_filename(path: &str) -> bool {
|
||||
let file_name = path.rsplit(['/', '\\']).next().unwrap_or(path);
|
||||
file_name.eq_ignore_ascii_case(SKILL_FILENAME)
|
||||
}
|
||||
|
||||
pub fn app_id_from_path(path: &str) -> Option<&str> {
|
||||
path.strip_prefix(APP_PATH_PREFIX)
|
||||
.filter(|value| !value.is_empty())
|
||||
}
|
||||
|
||||
pub fn plugin_config_name_from_path(path: &str) -> Option<&str> {
|
||||
path.strip_prefix(PLUGIN_PATH_PREFIX)
|
||||
.filter(|value| !value.is_empty())
|
||||
}
|
||||
|
||||
pub(crate) fn normalize_skill_path(path: &str) -> &str {
|
||||
path.strip_prefix(SKILL_PATH_PREFIX).unwrap_or(path)
|
||||
}
|
||||
|
||||
/// Extract `$tool-name` mentions from a single text input.
|
||||
///
|
||||
/// Supports explicit resource links in the form `[$tool-name](resource path)`. When a
|
||||
/// resource path is present, it is captured for exact path matching while also tracking
|
||||
/// the name for fallback matching.
|
||||
pub fn extract_tool_mentions(text: &str) -> ToolMentions<'_> {
|
||||
extract_tool_mentions_with_sigil(text, TOOL_MENTION_SIGIL)
|
||||
}
|
||||
|
||||
pub fn extract_tool_mentions_with_sigil(text: &str, sigil: char) -> ToolMentions<'_> {
|
||||
let text_bytes = text.as_bytes();
|
||||
let mut mentioned_names: HashSet<&str> = HashSet::new();
|
||||
let mut mentioned_paths: HashSet<&str> = HashSet::new();
|
||||
let mut plain_names: HashSet<&str> = HashSet::new();
|
||||
|
||||
let mut index = 0;
|
||||
while index < text_bytes.len() {
|
||||
let byte = text_bytes[index];
|
||||
if byte == b'['
|
||||
&& let Some((name, path, end_index)) =
|
||||
parse_linked_tool_mention(text, text_bytes, index, sigil)
|
||||
{
|
||||
if !is_common_env_var(name) {
|
||||
if !matches!(
|
||||
tool_kind_for_path(path),
|
||||
ToolMentionKind::App | ToolMentionKind::Mcp | ToolMentionKind::Plugin
|
||||
) {
|
||||
mentioned_names.insert(name);
|
||||
}
|
||||
mentioned_paths.insert(path);
|
||||
}
|
||||
index = end_index;
|
||||
continue;
|
||||
}
|
||||
|
||||
if byte != sigil as u8 {
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
let name_start = index + 1;
|
||||
let Some(first_name_byte) = text_bytes.get(name_start) else {
|
||||
index += 1;
|
||||
continue;
|
||||
};
|
||||
if !is_mention_name_char(*first_name_byte) {
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
let mut name_end = name_start + 1;
|
||||
while let Some(next_byte) = text_bytes.get(name_end)
|
||||
&& is_mention_name_char(*next_byte)
|
||||
{
|
||||
name_end += 1;
|
||||
}
|
||||
|
||||
let name = &text[name_start..name_end];
|
||||
if !is_common_env_var(name) {
|
||||
mentioned_names.insert(name);
|
||||
plain_names.insert(name);
|
||||
}
|
||||
index = name_end;
|
||||
}
|
||||
|
||||
ToolMentions {
|
||||
names: mentioned_names,
|
||||
paths: mentioned_paths,
|
||||
plain_names,
|
||||
}
|
||||
}
|
||||
|
||||
/// Select mentioned skills while preserving the order of `skills`.
|
||||
fn select_skills_from_mentions(
|
||||
selection_context: &SkillSelectionContext<'_>,
|
||||
blocked_plain_names: &HashSet<String>,
|
||||
mentions: &ToolMentions<'_>,
|
||||
seen_names: &mut HashSet<String>,
|
||||
seen_paths: &mut HashSet<AbsolutePathBuf>,
|
||||
selected: &mut Vec<SkillMetadata>,
|
||||
) {
|
||||
if mentions.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
let mention_skill_paths: HashSet<String> = mentions
|
||||
.paths()
|
||||
.filter(|path| {
|
||||
!matches!(
|
||||
tool_kind_for_path(path),
|
||||
ToolMentionKind::App | ToolMentionKind::Mcp | ToolMentionKind::Plugin
|
||||
)
|
||||
})
|
||||
.map(normalize_host_skill_path)
|
||||
.collect();
|
||||
|
||||
for skill in &selection_context.loaded_skills.skills {
|
||||
if !selection_context.loaded_skills.is_skill_enabled(skill)
|
||||
|| seen_paths.contains(&skill.path_to_skills_md)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
let canonical_path = normalize_host_skill_path(&skill.path_to_skills_md.to_string_lossy());
|
||||
let matches_discovery_path = selection_context
|
||||
.loaded_skills
|
||||
.skill_discovery_path_for_path(&skill.path_to_skills_md)
|
||||
.is_some_and(|discovery_path| {
|
||||
mention_skill_paths.contains(&normalize_host_skill_path(
|
||||
&discovery_path.to_string_lossy(),
|
||||
))
|
||||
});
|
||||
if mention_skill_paths.contains(&canonical_path) || matches_discovery_path {
|
||||
seen_paths.insert(skill.path_to_skills_md.clone());
|
||||
seen_names.insert(skill.name.clone());
|
||||
selected.push(skill.clone());
|
||||
}
|
||||
}
|
||||
|
||||
for skill in &selection_context.loaded_skills.skills {
|
||||
if !selection_context.loaded_skills.is_skill_enabled(skill)
|
||||
|| seen_paths.contains(&skill.path_to_skills_md)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if blocked_plain_names.contains(skill.name.as_str()) {
|
||||
continue;
|
||||
}
|
||||
if !mentions.plain_names.contains(skill.name.as_str()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let skill_count = selection_context
|
||||
.skill_name_counts
|
||||
.get(skill.name.as_str())
|
||||
.copied()
|
||||
.unwrap_or(0);
|
||||
let connector_count = selection_context
|
||||
.connector_slug_counts
|
||||
.get(&skill.name.to_ascii_lowercase())
|
||||
.copied()
|
||||
.unwrap_or(0);
|
||||
if skill_count != 1 || connector_count != 0 {
|
||||
continue;
|
||||
}
|
||||
|
||||
if seen_names.insert(skill.name.clone()) {
|
||||
seen_paths.insert(skill.path_to_skills_md.clone());
|
||||
selected.push(skill.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_linked_tool_mention<'a>(
|
||||
text: &'a str,
|
||||
text_bytes: &[u8],
|
||||
start: usize,
|
||||
sigil: char,
|
||||
) -> Option<(&'a str, &'a str, usize)> {
|
||||
let sigil_index = start + 1;
|
||||
if text_bytes.get(sigil_index) != Some(&(sigil as u8)) {
|
||||
return None;
|
||||
}
|
||||
|
||||
let name_start = sigil_index + 1;
|
||||
let first_name_byte = text_bytes.get(name_start)?;
|
||||
if !is_mention_name_char(*first_name_byte) {
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut name_end = name_start + 1;
|
||||
while let Some(next_byte) = text_bytes.get(name_end)
|
||||
&& is_mention_name_char(*next_byte)
|
||||
{
|
||||
name_end += 1;
|
||||
}
|
||||
|
||||
if text_bytes.get(name_end) != Some(&b']') {
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut path_start = name_end + 1;
|
||||
while let Some(next_byte) = text_bytes.get(path_start)
|
||||
&& next_byte.is_ascii_whitespace()
|
||||
{
|
||||
path_start += 1;
|
||||
}
|
||||
if text_bytes.get(path_start) != Some(&b'(') {
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut path_end = path_start + 1;
|
||||
while let Some(next_byte) = text_bytes.get(path_end)
|
||||
&& *next_byte != b')'
|
||||
{
|
||||
path_end += 1;
|
||||
}
|
||||
if text_bytes.get(path_end) != Some(&b')') {
|
||||
return None;
|
||||
}
|
||||
|
||||
let path = text[path_start + 1..path_end].trim();
|
||||
if path.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let name = &text[name_start..name_end];
|
||||
Some((name, path, path_end + 1))
|
||||
}
|
||||
|
||||
fn is_common_env_var(name: &str) -> bool {
|
||||
let upper = name.to_ascii_uppercase();
|
||||
matches!(
|
||||
upper.as_str(),
|
||||
"PATH"
|
||||
| "HOME"
|
||||
| "USER"
|
||||
| "SHELL"
|
||||
| "PWD"
|
||||
| "TMPDIR"
|
||||
| "TEMP"
|
||||
| "TMP"
|
||||
| "LANG"
|
||||
| "TERM"
|
||||
| "XDG_CONFIG_HOME"
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn text_mentions_skill(text: &str, skill_name: &str) -> bool {
|
||||
if skill_name.is_empty() {
|
||||
return false;
|
||||
}
|
||||
|
||||
let text_bytes = text.as_bytes();
|
||||
let skill_bytes = skill_name.as_bytes();
|
||||
|
||||
for (index, byte) in text_bytes.iter().copied().enumerate() {
|
||||
if byte != b'$' {
|
||||
continue;
|
||||
}
|
||||
|
||||
let name_start = index + 1;
|
||||
let Some(rest) = text_bytes.get(name_start..) else {
|
||||
continue;
|
||||
};
|
||||
if !rest.starts_with(skill_bytes) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let after_index = name_start + skill_bytes.len();
|
||||
let after = text_bytes.get(after_index).copied();
|
||||
if after.is_none_or(|b| !is_mention_name_char(b)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
|
||||
fn is_mention_name_char(byte: u8) -> bool {
|
||||
matches!(byte, b'a'..=b'z' | b'A'..=b'Z' | b'0'..=b'9' | b'_' | b'-' | b':')
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "injection_tests.rs"]
|
||||
#[path = "prompt_injection_tests.rs"]
|
||||
mod tests;
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
use std::collections::HashMap;
|
||||
use std::path::Path;
|
||||
|
||||
use crate::SkillLoadOutcome;
|
||||
use crate::SkillMetadata;
|
||||
use codex_protocol::parse_command::ParsedCommand;
|
||||
use codex_shell_command::parse_command::parse_command_impl;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
|
||||
pub(crate) fn build_implicit_skill_path_indexes(
|
||||
@@ -28,105 +24,6 @@ pub(crate) fn build_implicit_skill_path_indexes(
|
||||
(by_scripts_dir, by_skill_doc_path)
|
||||
}
|
||||
|
||||
pub fn detect_implicit_skill_invocation_for_command(
|
||||
outcome: &SkillLoadOutcome,
|
||||
command: &str,
|
||||
workdir: &AbsolutePathBuf,
|
||||
) -> Option<SkillMetadata> {
|
||||
let workdir = canonicalize_if_exists(workdir);
|
||||
let tokens = tokenize_command(command);
|
||||
|
||||
if let Some(candidate) = detect_skill_script_run(outcome, tokens.as_slice(), &workdir) {
|
||||
return Some(candidate);
|
||||
}
|
||||
|
||||
detect_skill_doc_read(outcome, tokens.as_slice(), &workdir)
|
||||
}
|
||||
|
||||
fn tokenize_command(command: &str) -> Vec<String> {
|
||||
shlex::split(command)
|
||||
.unwrap_or_else(|| command.split_whitespace().map(str::to_string).collect())
|
||||
}
|
||||
|
||||
fn script_run_token(tokens: &[String]) -> Option<&str> {
|
||||
const RUNNERS: [&str; 10] = [
|
||||
"python", "python3", "bash", "zsh", "sh", "node", "deno", "ruby", "perl", "pwsh",
|
||||
];
|
||||
const SCRIPT_EXTENSIONS: [&str; 7] = [".py", ".sh", ".js", ".ts", ".rb", ".pl", ".ps1"];
|
||||
|
||||
let runner_token = tokens.first()?;
|
||||
let runner = command_basename(runner_token).to_ascii_lowercase();
|
||||
let runner = runner.strip_suffix(".exe").unwrap_or(&runner);
|
||||
if !RUNNERS.contains(&runner) {
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut script_token = None;
|
||||
for token in tokens.iter().skip(1) {
|
||||
if token == "--" || token.starts_with('-') {
|
||||
continue;
|
||||
}
|
||||
script_token = Some(token.as_str());
|
||||
break;
|
||||
}
|
||||
let script_token = script_token?;
|
||||
if SCRIPT_EXTENSIONS
|
||||
.iter()
|
||||
.any(|extension| script_token.to_ascii_lowercase().ends_with(extension))
|
||||
{
|
||||
return Some(script_token);
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
fn detect_skill_script_run(
|
||||
outcome: &SkillLoadOutcome,
|
||||
tokens: &[String],
|
||||
workdir: &AbsolutePathBuf,
|
||||
) -> Option<SkillMetadata> {
|
||||
let script_token = script_run_token(tokens)?;
|
||||
let script_path = Path::new(script_token);
|
||||
let script_path = canonicalize_if_exists(&workdir.join(script_path));
|
||||
|
||||
for path in script_path.ancestors() {
|
||||
if let Some(candidate) = outcome.implicit_skills_by_scripts_dir.get(&path) {
|
||||
return Some(candidate.clone());
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
fn detect_skill_doc_read(
|
||||
outcome: &SkillLoadOutcome,
|
||||
tokens: &[String],
|
||||
workdir: &AbsolutePathBuf,
|
||||
) -> Option<SkillMetadata> {
|
||||
for command in parse_command_impl(tokens) {
|
||||
if let ParsedCommand::Read { path, .. } = command {
|
||||
let candidate_path = canonicalize_if_exists(&workdir.join(path.as_path()));
|
||||
if let Some(candidate) = outcome.implicit_skills_by_doc_path.get(&candidate_path) {
|
||||
return Some(candidate.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
fn command_basename(command: &str) -> String {
|
||||
Path::new(command)
|
||||
.file_name()
|
||||
.and_then(|name| name.to_str())
|
||||
.unwrap_or(command)
|
||||
.to_string()
|
||||
}
|
||||
|
||||
fn canonicalize_if_exists(path: &AbsolutePathBuf) -> AbsolutePathBuf {
|
||||
path.canonicalize().unwrap_or_else(|_| path.clone())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "invocation_utils_tests.rs"]
|
||||
mod tests;
|
||||
|
||||
@@ -2,7 +2,6 @@ pub mod config_rules;
|
||||
pub mod injection;
|
||||
pub(crate) mod invocation_utils;
|
||||
pub mod loader;
|
||||
mod mention_counts;
|
||||
pub mod model;
|
||||
pub mod remote;
|
||||
mod root_loader;
|
||||
@@ -14,10 +13,10 @@ mod skill_instructions;
|
||||
/// limit so a skill cannot bypass context bounds by changing how it is loaded.
|
||||
pub const MAX_SKILL_PROMPT_BYTES: usize = 8_000;
|
||||
|
||||
pub use codex_skills::ImplicitSkillLookup;
|
||||
pub use codex_skills::build_skill_name_counts;
|
||||
pub use codex_skills::detect_implicit_skill_invocation_for_command;
|
||||
pub(crate) use invocation_utils::build_implicit_skill_path_indexes;
|
||||
pub use invocation_utils::detect_implicit_skill_invocation_for_command;
|
||||
pub use mention_counts::build_skill_name_counts;
|
||||
pub use model::HostSkillsSnapshot;
|
||||
pub use model::SkillError;
|
||||
pub use model::SkillLoadOutcome;
|
||||
pub use model::SkillMetadata;
|
||||
|
||||
@@ -2,6 +2,7 @@ mod discovery;
|
||||
mod environment;
|
||||
mod namespace;
|
||||
|
||||
pub use crate::root_loader::load_skill_root_snapshot;
|
||||
pub use environment::EnvironmentSkillLoadOutcome;
|
||||
pub use environment::EnvironmentSkillMetadata;
|
||||
pub use environment::load_environment_skills_from_root;
|
||||
@@ -161,8 +162,12 @@ where
|
||||
.await
|
||||
}
|
||||
|
||||
/// One loaded skill root and the filesystem that supplied it.
|
||||
///
|
||||
/// This is exposed temporarily while host discovery moves out of `core-skills`; callers should
|
||||
/// combine snapshots through [`SkillLoadOutcome::from_root_snapshots`].
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct SkillRootSnapshot {
|
||||
pub struct SkillRootSnapshot {
|
||||
pub(crate) root: AbsolutePathBuf,
|
||||
pub(crate) is_agent_plugin: bool,
|
||||
pub(crate) skills: Vec<SkillMetadata>,
|
||||
@@ -171,6 +176,25 @@ pub(crate) struct SkillRootSnapshot {
|
||||
pub(crate) file_system: Arc<dyn ExecutorFileSystem>,
|
||||
}
|
||||
|
||||
impl SkillRootSnapshot {
|
||||
pub fn new(
|
||||
root: AbsolutePathBuf,
|
||||
skills: Vec<SkillMetadata>,
|
||||
skill_discovery_path_by_path: Arc<HashMap<AbsolutePathBuf, AbsolutePathBuf>>,
|
||||
errors: Vec<SkillError>,
|
||||
file_system: Arc<dyn ExecutorFileSystem>,
|
||||
) -> Self {
|
||||
Self {
|
||||
root,
|
||||
is_agent_plugin: false,
|
||||
skills,
|
||||
skill_discovery_path_by_path,
|
||||
errors,
|
||||
file_system,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn load_skill_root(root: SkillRoot) -> SkillRootSnapshot {
|
||||
let canonical_root =
|
||||
canonicalize_for_skill_identity(root.file_system.as_ref(), &root.path).await;
|
||||
|
||||
@@ -102,27 +102,18 @@ impl SkillLoadOutcome {
|
||||
self.file_systems_by_skill_path
|
||||
.get(&skill.path_to_skills_md)
|
||||
}
|
||||
}
|
||||
|
||||
/// Immutable snapshot of host-owned skills and the filesystem mapping needed
|
||||
/// to read each skill through the environment that discovered it.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct HostSkillsSnapshot {
|
||||
outcome: Arc<SkillLoadOutcome>,
|
||||
}
|
||||
|
||||
impl HostSkillsSnapshot {
|
||||
pub fn new(outcome: Arc<SkillLoadOutcome>) -> Self {
|
||||
Self { outcome }
|
||||
}
|
||||
|
||||
pub fn outcome(&self) -> &SkillLoadOutcome {
|
||||
self.outcome.as_ref()
|
||||
/// Builds the legacy aggregate from independently loaded roots.
|
||||
///
|
||||
/// This is a temporary migration boundary while host root loading moves to the skills
|
||||
/// extension. It deliberately reuses the existing merge, precedence, and deduplication logic.
|
||||
pub fn from_root_snapshots(snapshots: Vec<crate::loader::SkillRootSnapshot>) -> Self {
|
||||
crate::root_loader::merge_skill_root_snapshots(snapshots)
|
||||
}
|
||||
|
||||
/// Reads one loaded skill through the filesystem that discovered it.
|
||||
pub async fn read_skill_text(&self, skill: &SkillMetadata) -> io::Result<String> {
|
||||
let fs = self
|
||||
.outcome
|
||||
.file_system_for_skill(skill)
|
||||
.unwrap_or_else(|| Arc::clone(&LOCAL_FS));
|
||||
let path = PathUri::from_abs_path(&skill.path_to_skills_md);
|
||||
@@ -130,6 +121,34 @@ impl HostSkillsSnapshot {
|
||||
}
|
||||
}
|
||||
|
||||
impl codex_skills::ImplicitSkillLookup for SkillLoadOutcome {
|
||||
fn implicit_skill_for_scripts_dir(&self, path: &AbsolutePathBuf) -> Option<&SkillMetadata> {
|
||||
self.implicit_skills_by_scripts_dir.get(path)
|
||||
}
|
||||
|
||||
fn implicit_skill_for_doc_path(&self, path: &AbsolutePathBuf) -> Option<&SkillMetadata> {
|
||||
self.implicit_skills_by_doc_path.get(path)
|
||||
}
|
||||
}
|
||||
|
||||
impl codex_skills::ExplicitSkillLookup for SkillLoadOutcome {
|
||||
fn skills(&self) -> &[SkillMetadata] {
|
||||
&self.skills
|
||||
}
|
||||
|
||||
fn disabled_paths(&self) -> &HashSet<AbsolutePathBuf> {
|
||||
&self.disabled_paths
|
||||
}
|
||||
|
||||
fn skill_discovery_path_for_path(&self, path: &AbsolutePathBuf) -> Option<&AbsolutePathBuf> {
|
||||
SkillLoadOutcome::skill_discovery_path_for_path(self, path)
|
||||
}
|
||||
|
||||
fn is_skill_enabled(&self, skill: &SkillMetadata) -> bool {
|
||||
SkillLoadOutcome::is_skill_enabled(self, skill)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Default)]
|
||||
pub(crate) struct SkillFileSystemsByPath {
|
||||
values: Arc<HashMap<AbsolutePathBuf, Arc<dyn ExecutorFileSystem>>>,
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
use super::MAX_SKILL_PROMPT_BYTES;
|
||||
use super::bounded_skill_prompt_contents;
|
||||
|
||||
#[test]
|
||||
fn skill_prompt_contents_are_bounded_at_utf8_boundaries() {
|
||||
let contents = format!("{}é", "a".repeat(MAX_SKILL_PROMPT_BYTES - 1));
|
||||
|
||||
let (bounded, truncated) = bounded_skill_prompt_contents(&contents);
|
||||
|
||||
assert_eq!(bounded.len(), MAX_SKILL_PROMPT_BYTES - 1);
|
||||
assert_eq!(truncated, true);
|
||||
}
|
||||
@@ -71,48 +71,7 @@ where
|
||||
.acquire()
|
||||
.await
|
||||
.unwrap_or_else(|_| unreachable!());
|
||||
let cache_key = match (
|
||||
root.plugin_identity.clone(),
|
||||
root.plugin_namespace.clone(),
|
||||
root.plugin_root.clone(),
|
||||
) {
|
||||
(Some(plugin_identity), Some(plugin_namespace), Some(plugin_root)) => {
|
||||
Some(PluginSkillRoot {
|
||||
path: root.path.clone(),
|
||||
plugin_identity,
|
||||
plugin_namespace,
|
||||
plugin_root,
|
||||
discovery_mode: root.discovery_mode,
|
||||
})
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
let cached_snapshot = cache_key.as_ref().and_then(|cache_key| {
|
||||
let plugin_skill_snapshots = plugin_skill_snapshots?;
|
||||
plugin_skill_snapshots
|
||||
.snapshots_by_root
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||
.get(cache_key)
|
||||
.cloned()
|
||||
});
|
||||
|
||||
let snapshot = match cached_snapshot {
|
||||
Some(snapshot) => snapshot,
|
||||
None => {
|
||||
let snapshot = load_skill_root(root).await;
|
||||
if let Some(plugin_skill_snapshots) = plugin_skill_snapshots
|
||||
&& let Some(cache_key) = cache_key
|
||||
{
|
||||
plugin_skill_snapshots
|
||||
.snapshots_by_root
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||
.insert(cache_key, snapshot.clone());
|
||||
}
|
||||
snapshot
|
||||
}
|
||||
};
|
||||
let snapshot = load_skill_root_snapshot(root, plugin_skill_snapshots).await;
|
||||
(root_index, snapshot)
|
||||
})
|
||||
// Keep each load's scan queue bounded while avoiding head-of-line blocking.
|
||||
@@ -129,7 +88,59 @@ where
|
||||
merge_skill_root_snapshots(root_snapshots)
|
||||
}
|
||||
|
||||
fn merge_skill_root_snapshots(snapshots: Vec<SkillRootSnapshot>) -> SkillLoadOutcome {
|
||||
/// Loads one root while preserving the existing plugin-loader snapshot reuse.
|
||||
///
|
||||
/// This is a temporary migration boundary for callers that load non-plugin roots elsewhere but
|
||||
/// still need plugin roots to use the legacy preload cache.
|
||||
pub async fn load_skill_root_snapshot(
|
||||
root: SkillRoot,
|
||||
plugin_skill_snapshots: Option<&PluginSkillSnapshots>,
|
||||
) -> SkillRootSnapshot {
|
||||
let cache_key = match (
|
||||
root.plugin_identity.clone(),
|
||||
root.plugin_namespace.clone(),
|
||||
root.plugin_root.clone(),
|
||||
) {
|
||||
(Some(plugin_identity), Some(plugin_namespace), Some(plugin_root)) => {
|
||||
Some(PluginSkillRoot {
|
||||
path: root.path.clone(),
|
||||
plugin_identity,
|
||||
plugin_namespace,
|
||||
plugin_root,
|
||||
discovery_mode: root.discovery_mode,
|
||||
})
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
let cached_snapshot = cache_key.as_ref().and_then(|cache_key| {
|
||||
let plugin_skill_snapshots = plugin_skill_snapshots?;
|
||||
plugin_skill_snapshots
|
||||
.snapshots_by_root
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||
.get(cache_key)
|
||||
.cloned()
|
||||
});
|
||||
|
||||
match cached_snapshot {
|
||||
Some(snapshot) => snapshot,
|
||||
None => {
|
||||
let snapshot = load_skill_root(root).await;
|
||||
if let Some(plugin_skill_snapshots) = plugin_skill_snapshots
|
||||
&& let Some(cache_key) = cache_key
|
||||
{
|
||||
plugin_skill_snapshots
|
||||
.snapshots_by_root
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||
.insert(cache_key, snapshot.clone());
|
||||
}
|
||||
snapshot
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn merge_skill_root_snapshots(snapshots: Vec<SkillRootSnapshot>) -> SkillLoadOutcome {
|
||||
fn scope_rank(scope: codex_protocol::protocol::SkillScope) -> u8 {
|
||||
use codex_protocol::protocol::SkillScope;
|
||||
|
||||
|
||||
@@ -756,6 +756,9 @@
|
||||
"unified_exec_zsh_fork": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"unified_image_budget": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"use_agent_identity": {
|
||||
"type": "boolean"
|
||||
},
|
||||
@@ -5387,6 +5390,9 @@
|
||||
"unified_exec_zsh_fork": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"unified_image_budget": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"use_agent_identity": {
|
||||
"type": "boolean"
|
||||
},
|
||||
|
||||
@@ -625,7 +625,9 @@ impl AgentControl {
|
||||
.subagent_developer_instructions
|
||||
.as_ref(),
|
||||
) {
|
||||
(MultiAgentVersion::V2, Some(_)) => {
|
||||
(MultiAgentVersion::V2, override_instructions)
|
||||
if override_instructions.is_some() || session_source.get_agent_role().is_some() =>
|
||||
{
|
||||
let parent_developer_instructions = match parent_thread
|
||||
.session
|
||||
.new_default_turn()
|
||||
@@ -642,7 +644,7 @@ impl AgentControl {
|
||||
)
|
||||
}
|
||||
(MultiAgentVersion::Disabled | MultiAgentVersion::V1, _)
|
||||
| (MultiAgentVersion::V2, None) => (None, None),
|
||||
| (MultiAgentVersion::V2, _) => (None, None),
|
||||
};
|
||||
let parent_history_mode = parent_thread.config_snapshot().await.history_mode;
|
||||
// `record_conversation_items` only queues persistence writes asynchronously.
|
||||
|
||||
@@ -6,8 +6,11 @@ use crate::session::SessionSettingsUpdate;
|
||||
use crate::session::SteerInputError;
|
||||
use crate::session::TurnInput;
|
||||
use crate::session::session::Session;
|
||||
use crate::user_message_admission::PendingUserMessageAdmissionState;
|
||||
use crate::user_message_admission::UserMessageAdmission;
|
||||
use crate::user_message_admission::UserMessageAdmissionError;
|
||||
use codex_exec_server::SelectedCapabilityRootsStatus;
|
||||
use codex_extension_api::ThreadIdleCause;
|
||||
use codex_features::Feature;
|
||||
use codex_otel::SessionTelemetry;
|
||||
use codex_protocol::ThreadId;
|
||||
@@ -100,6 +103,12 @@ pub enum TryStartTurnIfIdleRejectionReason {
|
||||
/// Another turn or task is active, or the idle reservation was lost before
|
||||
/// the automatic turn could start.
|
||||
Busy,
|
||||
/// A user-prompt hook consumed and rejected the submitted input.
|
||||
RejectedByHook,
|
||||
/// The automatic turn ended before its initial input was persisted.
|
||||
TaskEndedBeforePersistence,
|
||||
/// The initial input could not be durably written to the rollout.
|
||||
PersistenceFailed,
|
||||
}
|
||||
|
||||
/// Rejection returned when an extension asks to start automatic idle work but
|
||||
@@ -233,6 +242,11 @@ impl CodexThread {
|
||||
self.session.services.session_telemetry.clone()
|
||||
}
|
||||
|
||||
/// Returns extension-owned data attached to this thread runtime.
|
||||
pub fn thread_extension_data(&self) -> &codex_extension_api::ExtensionData {
|
||||
&self.session.services.thread_extension_data
|
||||
}
|
||||
|
||||
pub async fn shutdown_and_wait(&self) -> CodexResult<()> {
|
||||
self.io.shutdown_and_wait().await
|
||||
}
|
||||
@@ -258,8 +272,8 @@ impl CodexThread {
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn emit_thread_idle_lifecycle_if_idle(&self) {
|
||||
self.session.emit_thread_idle_lifecycle_if_idle().await;
|
||||
pub async fn emit_thread_idle_lifecycle_if_idle(&self, cause: ThreadIdleCause) {
|
||||
self.session.emit_thread_idle_lifecycle_if_idle(cause).await;
|
||||
}
|
||||
|
||||
#[doc(hidden)]
|
||||
@@ -298,28 +312,79 @@ impl CodexThread {
|
||||
.await
|
||||
}
|
||||
|
||||
/// Waits until Core has actually started a turn or steered the active turn.
|
||||
/// Waits until Core has started a turn or steered the active turn.
|
||||
pub async fn submit_user_input_and_wait_for_admission(
|
||||
&self,
|
||||
op: Op,
|
||||
trace: Option<W3cTraceContext>,
|
||||
client_user_message_id: Option<String>,
|
||||
) -> CodexResult<UserMessageAdmission> {
|
||||
if !matches!(op, Op::UserInput { .. }) {
|
||||
return Err(CodexErr::InvalidRequest(
|
||||
"user message admission requires user input".to_string(),
|
||||
self.submit_user_input_and_wait_for_admission_inner(
|
||||
op,
|
||||
trace,
|
||||
client_user_message_id,
|
||||
PendingUserMessageAdmissionState::Immediate,
|
||||
)
|
||||
.await
|
||||
.map_err(Into::into)
|
||||
}
|
||||
|
||||
/// Waits for durable admission and preserves its typed failure outcome.
|
||||
///
|
||||
/// A client user-message id is required to identify the persisted message.
|
||||
pub async fn submit_user_input_and_wait_for_persisted_admission(
|
||||
&self,
|
||||
op: Op,
|
||||
trace: Option<W3cTraceContext>,
|
||||
client_user_message_id: Option<String>,
|
||||
) -> Result<UserMessageAdmission, UserMessageAdmissionError> {
|
||||
if client_user_message_id.is_none() {
|
||||
return Err(UserMessageAdmissionError::Admission(
|
||||
CodexErr::InvalidRequest(
|
||||
"persisted user message admission requires a client user message id"
|
||||
.to_string(),
|
||||
),
|
||||
));
|
||||
}
|
||||
self.submit_user_input_and_wait_for_admission_inner(
|
||||
op,
|
||||
trace,
|
||||
client_user_message_id,
|
||||
PendingUserMessageAdmissionState::WaitingForAdmission,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn submit_user_input_and_wait_for_admission_inner(
|
||||
&self,
|
||||
op: Op,
|
||||
trace: Option<W3cTraceContext>,
|
||||
client_user_message_id: Option<String>,
|
||||
state: PendingUserMessageAdmissionState,
|
||||
) -> Result<UserMessageAdmission, UserMessageAdmissionError> {
|
||||
let Op::UserInput { items, .. } = &op else {
|
||||
return Err(UserMessageAdmissionError::Admission(
|
||||
CodexErr::InvalidRequest("user message admission requires user input".to_string()),
|
||||
));
|
||||
};
|
||||
if items.is_empty() {
|
||||
return Err(UserMessageAdmissionError::Admission(
|
||||
CodexErr::InvalidRequest(
|
||||
"user message admission requires nonempty user input".to_string(),
|
||||
),
|
||||
));
|
||||
}
|
||||
self.session
|
||||
.services
|
||||
.agent_control
|
||||
.ensure_execution_capacity_for_op(self.session.thread_id(), &op)
|
||||
.await?;
|
||||
.await
|
||||
.map_err(UserMessageAdmissionError::Admission)?;
|
||||
let submission_id = crate::session::new_submission_id();
|
||||
let (_pending_admission, admission) = self
|
||||
.session
|
||||
.pending_user_message_admissions
|
||||
.register(submission_id.clone());
|
||||
.register(submission_id.clone(), client_user_message_id.clone(), state);
|
||||
self.io
|
||||
.submit_with_id(Submission {
|
||||
id: submission_id.clone(),
|
||||
@@ -328,11 +393,15 @@ impl CodexThread {
|
||||
trace,
|
||||
parent_turn_id: None,
|
||||
})
|
||||
.await?;
|
||||
.await
|
||||
.map_err(UserMessageAdmissionError::Admission)?;
|
||||
tokio::select! {
|
||||
biased;
|
||||
result = admission => result.map_err(|_| CodexErr::InternalAgentDied)?,
|
||||
() = self.io.session_loop_termination.clone() => Err(CodexErr::InternalAgentDied),
|
||||
result = admission => result
|
||||
.unwrap_or(Err(UserMessageAdmissionError::TaskEndedBeforePersistence)),
|
||||
() = self.io.session_loop_termination.clone() => {
|
||||
Err(UserMessageAdmissionError::TaskEndedBeforePersistence)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -11029,8 +11029,9 @@ max_concurrent_threads_per_session = 17
|
||||
|
||||
let config = resolve_multi_agent_v2_config(&config_toml);
|
||||
let concurrency_guidance = "There are 17 available concurrency slots, meaning that up to 17 agents can be active at once, including you.";
|
||||
let expected_suffix =
|
||||
format!("{DEFAULT_MULTI_AGENT_V2_SHARED_USAGE_HINT_TEXT}\n{concurrency_guidance}");
|
||||
let expected_suffix = format!(
|
||||
"{DEFAULT_MULTI_AGENT_V2_SHARED_USAGE_HINT_TEXT}\n{DEFAULT_MULTI_AGENT_V2_WAIT_AGENT_USAGE_HINT_TEXT}\n\n{concurrency_guidance}"
|
||||
);
|
||||
assert!(
|
||||
[
|
||||
config.root_agent_usage_hint_text,
|
||||
|
||||
@@ -255,6 +255,8 @@ You may also see them addressed as to=/root/..., which indicates your identity i
|
||||
"#;
|
||||
const DEFAULT_MULTI_AGENT_V2_MODEL_OVERRIDE_USAGE_HINT_TEXT: &str = "Full-history forks (`fork_turns` omitted or `\"all\"`) inherit the parent model and reasoning effort and do not accept overrides. Only set `model` or `reasoning_effort` when explicitly requested by the user, applicable `AGENTS.md` instructions, or skill instructions; when doing so, set `fork_turns` to `\"none\"` or a positive integer string.";
|
||||
const DEFAULT_MULTI_AGENT_V2_TOOL_NAMESPACE: &str = "collaboration";
|
||||
const DEFAULT_MULTI_AGENT_V2_WAIT_AGENT_USAGE_HINT_TEXT: &str =
|
||||
"When calling `wait_agent`, prefer longer waits (minutes) to avoid busy polling.";
|
||||
const DEFAULT_MULTI_AGENT_V2_SHARED_USAGE_HINT_TEXT: &str = r#"Note that collaboration tools cannot be called from inside `functions.exec`. Call `spawn_agent`, `send_message`, `followup_task`, `wait_agent`, `interrupt_agent`, and `list_agents` only as direct tool calls using the recipient shown in their tool definitions, such as `to=functions.collaboration.spawn_agent`, since they are intentionally absent from the `functions.exec` `tools.*` namespace. Available tools in `functions.exec` are explicitly described with a `tools` namespace in the developer message.
|
||||
|
||||
All agents share the same directory. In detail:
|
||||
@@ -262,9 +264,17 @@ All agents share the same directory. In detail:
|
||||
- All agents use the same current working directory.
|
||||
- As a result, edits made by one agent are immediately visible to all other agents.
|
||||
"#;
|
||||
fn default_multi_agent_v2_usage_hint_text(usage_hint_text: &str, max_concurrency: usize) -> String {
|
||||
fn default_multi_agent_v2_usage_hint_text(
|
||||
usage_hint_text: &str,
|
||||
max_concurrency: usize,
|
||||
wait_agent_usage_hint_text: Option<&str>,
|
||||
) -> String {
|
||||
let wait_agent_usage_hint_text = match wait_agent_usage_hint_text {
|
||||
Some(wait_agent_usage_hint_text) => format!("{wait_agent_usage_hint_text}\n\n"),
|
||||
None => String::new(),
|
||||
};
|
||||
format!(
|
||||
"{usage_hint_text}\n{DEFAULT_MULTI_AGENT_V2_SHARED_USAGE_HINT_TEXT}\nThere are {max_concurrency} available concurrency slots, meaning that up to {max_concurrency} agents can be active at once, including you."
|
||||
"{usage_hint_text}\n{DEFAULT_MULTI_AGENT_V2_SHARED_USAGE_HINT_TEXT}\n{wait_agent_usage_hint_text}There are {max_concurrency} available concurrency slots, meaning that up to {max_concurrency} agents can be active at once, including you."
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1286,10 +1296,12 @@ impl MultiAgentV2Config {
|
||||
root_agent_usage_hint_text: Some(default_multi_agent_v2_usage_hint_text(
|
||||
DEFAULT_MULTI_AGENT_V2_ROOT_AGENT_USAGE_HINT_TEXT,
|
||||
max_concurrent_threads_per_session,
|
||||
Some(DEFAULT_MULTI_AGENT_V2_WAIT_AGENT_USAGE_HINT_TEXT),
|
||||
)),
|
||||
subagent_usage_hint_text: Some(default_multi_agent_v2_usage_hint_text(
|
||||
DEFAULT_MULTI_AGENT_V2_SUBAGENT_USAGE_HINT_TEXT,
|
||||
max_concurrent_threads_per_session,
|
||||
Some(DEFAULT_MULTI_AGENT_V2_WAIT_AGENT_USAGE_HINT_TEXT),
|
||||
)),
|
||||
subagent_developer_instructions: None,
|
||||
multi_agent_mode_hint_text: None,
|
||||
@@ -2726,8 +2738,21 @@ fn resolve_multi_agent_v2_config(config_toml: &ConfigToml) -> MultiAgentV2Config
|
||||
let wait_agent_enabled = base
|
||||
.and_then(|config| config.wait_agent_enabled)
|
||||
.unwrap_or(default.wait_agent_enabled);
|
||||
let mut default_root_agent_usage_hint_text = default.root_agent_usage_hint_text;
|
||||
let mut default_subagent_usage_hint_text = default.subagent_usage_hint_text;
|
||||
let default_wait_agent_usage_hint_text = if wait_agent_enabled {
|
||||
Some(DEFAULT_MULTI_AGENT_V2_WAIT_AGENT_USAGE_HINT_TEXT)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let mut default_root_agent_usage_hint_text = Some(default_multi_agent_v2_usage_hint_text(
|
||||
DEFAULT_MULTI_AGENT_V2_ROOT_AGENT_USAGE_HINT_TEXT,
|
||||
max_concurrent_threads_per_session,
|
||||
default_wait_agent_usage_hint_text,
|
||||
));
|
||||
let mut default_subagent_usage_hint_text = Some(default_multi_agent_v2_usage_hint_text(
|
||||
DEFAULT_MULTI_AGENT_V2_SUBAGENT_USAGE_HINT_TEXT,
|
||||
max_concurrent_threads_per_session,
|
||||
default_wait_agent_usage_hint_text,
|
||||
));
|
||||
if expose_spawn_agent_model_overrides {
|
||||
default_root_agent_usage_hint_text = Some(append_usage_hint_text(
|
||||
default_root_agent_usage_hint_text.as_deref(),
|
||||
|
||||
@@ -34,6 +34,7 @@ use codex_mcp::MCP_TOOL_CODEX_APPS_META_KEY;
|
||||
use codex_mcp::McpRuntime;
|
||||
use codex_mcp::McpRuntimeContext;
|
||||
use codex_mcp::McpRuntimeInput;
|
||||
use codex_mcp::McpStartupPolicy;
|
||||
use codex_mcp::ToolInfo;
|
||||
use codex_mcp::ToolPluginProvenance;
|
||||
use codex_mcp::effective_mcp_servers;
|
||||
@@ -246,6 +247,7 @@ pub async fn list_accessible_connectors_from_mcp_tools_with_mcp_manager(
|
||||
codex_mcp::host_owned_codex_apps_enabled(&mcp_config, auth.as_ref())
|
||||
.then(|| Arc::clone(&auth_manager));
|
||||
let mcp_runtime = McpRuntime::new(McpRuntimeInput {
|
||||
startup_policy: McpStartupPolicy::Eager,
|
||||
config: Arc::clone(&mcp_config),
|
||||
plugins_available: false,
|
||||
ready_selected_capability_roots: Vec::new(),
|
||||
|
||||
@@ -18,6 +18,7 @@ mod legacy_model_mismatch_warning;
|
||||
mod legacy_unified_exec_process_limit_warning;
|
||||
mod model_switch_instructions;
|
||||
mod multi_agent_mode_instructions;
|
||||
mod multi_agent_usage_hint;
|
||||
mod network_rule_saved;
|
||||
mod permissions_instructions;
|
||||
mod personality_spec_instructions;
|
||||
@@ -62,6 +63,7 @@ pub(crate) use legacy_apply_patch_exec_command_warning::LegacyApplyPatchExecComm
|
||||
pub(crate) use legacy_model_mismatch_warning::LegacyModelMismatchWarning;
|
||||
pub(crate) use legacy_unified_exec_process_limit_warning::LegacyUnifiedExecProcessLimitWarning;
|
||||
pub(crate) use model_switch_instructions::ModelSwitchInstructions;
|
||||
pub(crate) use multi_agent_usage_hint::MultiAgentUsageHint;
|
||||
pub(crate) use network_rule_saved::NetworkRuleSaved;
|
||||
pub use permissions_instructions::ApprovalPromptContext;
|
||||
pub use permissions_instructions::PermissionsInstructions;
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
use super::ContextualUserFragment;
|
||||
|
||||
/// Configured multi-agent instructions emitted as a standalone developer message.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub(crate) struct MultiAgentUsageHint {
|
||||
text: String,
|
||||
}
|
||||
|
||||
impl MultiAgentUsageHint {
|
||||
pub(crate) fn new(text: &str) -> Self {
|
||||
Self {
|
||||
text: text.to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ContextualUserFragment for MultiAgentUsageHint {
|
||||
fn role(&self) -> &'static str {
|
||||
"developer"
|
||||
}
|
||||
|
||||
fn requires_separate_message(&self) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn markers(&self) -> (&'static str, &'static str) {
|
||||
Self::type_markers()
|
||||
}
|
||||
|
||||
fn type_markers() -> (&'static str, &'static str) {
|
||||
("", "")
|
||||
}
|
||||
|
||||
fn body(&self) -> String {
|
||||
self.text.clone()
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ mod environment;
|
||||
mod environments_instructions;
|
||||
mod model;
|
||||
mod multi_agent_mode;
|
||||
mod multi_agent_usage_hint;
|
||||
mod permissions;
|
||||
mod personality;
|
||||
mod plugins_instructions;
|
||||
@@ -40,6 +41,7 @@ pub(crate) use environment::EnvironmentsState;
|
||||
pub(crate) use environments_instructions::EnvironmentsInstructionsState;
|
||||
pub(crate) use model::ModelInstructionsState;
|
||||
pub(crate) use multi_agent_mode::MultiAgentModeState;
|
||||
pub(crate) use multi_agent_usage_hint::MultiAgentUsageHintState;
|
||||
pub(crate) use permissions::PermissionsState;
|
||||
pub(crate) use personality::PersonalityState;
|
||||
pub(crate) use plugins_instructions::PluginsInstructionsState;
|
||||
@@ -89,7 +91,7 @@ impl<S: WorldStateSection> ErasedWorldStateSection for S {
|
||||
}
|
||||
|
||||
fn matches_legacy_fragment(&self, role: &str, text: &str) -> bool {
|
||||
S::matches_legacy_fragment(role, text)
|
||||
WorldStateSection::matches_current_legacy_fragment(self, role, text)
|
||||
}
|
||||
|
||||
fn has_retained_fragment_matcher(&self) -> bool {
|
||||
@@ -219,6 +221,11 @@ pub(crate) trait WorldStateSection: Send + Sync + 'static {
|
||||
false
|
||||
}
|
||||
|
||||
/// Recognizes legacy fragments whose identity depends on this section's current value.
|
||||
fn matches_current_legacy_fragment(&self, role: &str, text: &str) -> bool {
|
||||
Self::matches_legacy_fragment(role, text)
|
||||
}
|
||||
|
||||
/// Whether retained history must still contain this section's rendered fragment.
|
||||
fn has_retained_fragment_matcher() -> bool {
|
||||
false
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
use super::PreviousSectionState;
|
||||
use super::WorldStateHash;
|
||||
use super::WorldStateSection;
|
||||
use super::multi_agent_usage_hint::MultiAgentUsageHintState;
|
||||
use crate::context::ContextualUserFragment;
|
||||
use crate::context::multi_agent_mode_instructions::MultiAgentModeInstructions;
|
||||
use codex_protocol::config_types::MultiAgentMode;
|
||||
@@ -14,6 +16,8 @@ const MULTI_AGENT_MODE_MAX_TOKENS: usize = 400;
|
||||
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
|
||||
pub(crate) struct MultiAgentModeState {
|
||||
mode: Option<MultiAgentMode>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
usage_hint_hash: Option<WorldStateHash>,
|
||||
}
|
||||
|
||||
impl MultiAgentModeState {
|
||||
@@ -26,8 +30,14 @@ impl MultiAgentModeState {
|
||||
)),
|
||||
mode @ (MultiAgentMode::ExplicitRequestOnly | MultiAgentMode::Proactive) => mode,
|
||||
}),
|
||||
usage_hint_hash: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn with_usage_hint(mut self, usage_hint: &MultiAgentUsageHintState) -> Self {
|
||||
self.usage_hint_hash = Some(usage_hint.snapshot());
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl WorldStateSection for MultiAgentModeState {
|
||||
@@ -56,7 +66,8 @@ impl WorldStateSection for MultiAgentModeState {
|
||||
) -> Option<Box<dyn ContextualUserFragment>> {
|
||||
let mode = match (&self.mode, previous) {
|
||||
(Some(mode), PreviousSectionState::Known(previous))
|
||||
if previous.mode.as_ref() == Some(mode) =>
|
||||
if previous.mode.as_ref() == Some(mode)
|
||||
&& previous.usage_hint_hash == self.usage_hint_hash =>
|
||||
{
|
||||
return None;
|
||||
}
|
||||
|
||||
@@ -65,6 +65,26 @@ fn persisted_mode_is_restored_only_when_missing_from_history() {
|
||||
);
|
||||
}
|
||||
|
||||
/// Active mode instructions must follow a newly migrated multi-agent usage hint.
|
||||
#[test]
|
||||
fn unchanged_mode_is_reemitted_after_usage_hint_migration() {
|
||||
let previous = state(Some(MultiAgentMode::Proactive));
|
||||
let current = MultiAgentModeState::new(Some(MultiAgentMode::Proactive)).with_usage_hint(
|
||||
&MultiAgentUsageHintState::new("Current usage instructions."),
|
||||
);
|
||||
|
||||
let instructions = current
|
||||
.render_diff(PreviousSectionState::Known(&previous))
|
||||
.expect("unchanged mode should follow migrated usage instructions");
|
||||
|
||||
assert_eq!(
|
||||
instructions.render(),
|
||||
MultiAgentModeInstructions::from_mode(MultiAgentMode::Proactive)
|
||||
.expect("proactive mode should render")
|
||||
.render()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn custom_mode_is_bounded_before_snapshot_and_rendering() {
|
||||
let state = state(Some(MultiAgentMode::Custom("custom mode ".repeat(1_000))));
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
use super::PreviousSectionState;
|
||||
use super::WorldStateHash;
|
||||
use super::WorldStateSection;
|
||||
use crate::context::ContextualUserFragment;
|
||||
use crate::context::MultiAgentUsageHint;
|
||||
|
||||
/// Configured multi-agent instructions currently visible to the model.
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) struct MultiAgentUsageHintState {
|
||||
instructions: MultiAgentUsageHint,
|
||||
}
|
||||
|
||||
impl MultiAgentUsageHintState {
|
||||
pub(crate) fn new(text: &str) -> Self {
|
||||
Self {
|
||||
instructions: MultiAgentUsageHint::new(text),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl WorldStateSection for MultiAgentUsageHintState {
|
||||
const ID: &'static str = "multi_agent_usage_hint";
|
||||
type Snapshot = WorldStateHash;
|
||||
|
||||
fn snapshot(&self) -> Self::Snapshot {
|
||||
WorldStateHash::from_fragment(&self.instructions)
|
||||
}
|
||||
|
||||
fn matches_current_legacy_fragment(&self, role: &str, text: &str) -> bool {
|
||||
role == self.instructions.role() && text == self.instructions.render()
|
||||
}
|
||||
|
||||
fn render_diff(
|
||||
&self,
|
||||
previous: PreviousSectionState<'_, Self::Snapshot>,
|
||||
) -> Option<Box<dyn ContextualUserFragment>> {
|
||||
match previous {
|
||||
PreviousSectionState::Known(previous) if previous == &self.snapshot() => None,
|
||||
PreviousSectionState::Unknown => None,
|
||||
PreviousSectionState::Known(_) | PreviousSectionState::Absent => {
|
||||
Some(Box::new(self.instructions.clone()))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
use crate::context::ContextualUserFragment;
|
||||
use crate::context::ModelSwitchInstructions;
|
||||
use crate::context::world_state::WorldState;
|
||||
use crate::context::world_state::WorldStateSnapshot;
|
||||
use crate::context_manager::normalize;
|
||||
@@ -245,7 +246,30 @@ impl ContextManager {
|
||||
cut_idx =
|
||||
self.trim_pre_turn_context_updates(&snapshot, first_instruction_turn_idx, cut_idx);
|
||||
|
||||
self.replace(snapshot[..cut_idx].to_vec());
|
||||
let mut retained_items = snapshot[..cut_idx].to_vec();
|
||||
if cut_idx == first_instruction_turn_idx
|
||||
&& let Some(first_turn_id) = snapshot[first_instruction_turn_idx].turn_id()
|
||||
{
|
||||
retained_items.retain_mut(|item| {
|
||||
if item.turn_id() == Some(first_turn_id)
|
||||
&& let ResponseItem::Message { role, content, .. } = item
|
||||
&& role == "developer"
|
||||
{
|
||||
content.retain(|content| {
|
||||
!matches!(
|
||||
content,
|
||||
ContentItem::InputText { text }
|
||||
if ModelSwitchInstructions::matches_text(text)
|
||||
)
|
||||
});
|
||||
!content.is_empty()
|
||||
} else {
|
||||
true
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
self.replace(retained_items);
|
||||
}
|
||||
|
||||
pub(crate) fn update_token_info(
|
||||
|
||||
@@ -47,7 +47,9 @@ pub(crate) use review_session::prompt_cache_key_override_for_review_session;
|
||||
|
||||
pub(crate) const GUARDIAN_REVIEW_TIMEOUT: Duration = Duration::from_secs(90);
|
||||
pub(crate) const GUARDIAN_REVIEWER_NAME: &str = "guardian";
|
||||
pub(crate) const MAX_CONSECUTIVE_CYBER_GUARDIAN_DENIALS_PER_TURN: u32 = 1;
|
||||
pub(crate) const MAX_CONSECUTIVE_GUARDIAN_DENIALS_PER_TURN: u32 = 3;
|
||||
pub(crate) const MAX_RECENT_CYBER_AUTO_REVIEW_DENIALS_PER_TURN: u32 = 1;
|
||||
pub(crate) const MAX_RECENT_AUTO_REVIEW_DENIALS_PER_TURN: u32 = 10;
|
||||
pub(crate) const AUTO_REVIEW_DENIAL_WINDOW_SIZE: usize = 50;
|
||||
pub(crate) const AUTO_REVIEW_DENIED_ACTION_APPROVAL_DEVELOPER_PREFIX: &str =
|
||||
@@ -81,6 +83,12 @@ struct GuardianRejectionCircuitBreakerTurn {
|
||||
interrupt_triggered: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(crate) enum GuardianRejectionCircuitBreakerPolicy {
|
||||
Standard,
|
||||
CyberModel,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(crate) enum GuardianRejectionCircuitBreakerAction {
|
||||
Continue,
|
||||
@@ -95,14 +103,28 @@ impl GuardianRejectionCircuitBreaker {
|
||||
self.turns.remove(turn_id);
|
||||
}
|
||||
|
||||
pub(crate) fn record_denial(&mut self, turn_id: &str) -> GuardianRejectionCircuitBreakerAction {
|
||||
pub(crate) fn record_denial(
|
||||
&mut self,
|
||||
turn_id: &str,
|
||||
policy: GuardianRejectionCircuitBreakerPolicy,
|
||||
) -> GuardianRejectionCircuitBreakerAction {
|
||||
let turn = self.turns.entry(turn_id.to_string()).or_default();
|
||||
turn.consecutive_denials = turn.consecutive_denials.saturating_add(1);
|
||||
Self::record_recent_review(turn, /*denied*/ true);
|
||||
let recent_denials = turn.recent_denials.iter().filter(|denied| **denied).count() as u32;
|
||||
let (max_consecutive_denials, max_recent_denials) = match policy {
|
||||
GuardianRejectionCircuitBreakerPolicy::Standard => (
|
||||
MAX_CONSECUTIVE_GUARDIAN_DENIALS_PER_TURN,
|
||||
MAX_RECENT_AUTO_REVIEW_DENIALS_PER_TURN,
|
||||
),
|
||||
GuardianRejectionCircuitBreakerPolicy::CyberModel => (
|
||||
MAX_CONSECUTIVE_CYBER_GUARDIAN_DENIALS_PER_TURN,
|
||||
MAX_RECENT_CYBER_AUTO_REVIEW_DENIALS_PER_TURN,
|
||||
),
|
||||
};
|
||||
if !turn.interrupt_triggered
|
||||
&& (turn.consecutive_denials >= MAX_CONSECUTIVE_GUARDIAN_DENIALS_PER_TURN
|
||||
|| recent_denials >= MAX_RECENT_AUTO_REVIEW_DENIALS_PER_TURN)
|
||||
&& (turn.consecutive_denials >= max_consecutive_denials
|
||||
|| recent_denials >= max_recent_denials)
|
||||
{
|
||||
turn.interrupt_triggered = true;
|
||||
GuardianRejectionCircuitBreakerAction::InterruptTurn {
|
||||
|
||||
@@ -6,7 +6,9 @@ use codex_analytics::GuardianReviewTerminalStatus;
|
||||
use codex_analytics::GuardianReviewTrackContext;
|
||||
use codex_analytics::GuardianReviewedAction;
|
||||
use codex_core_plugins::PluginCommandAttribution;
|
||||
use codex_extension_api::ThreadIdleCause;
|
||||
use codex_protocol::config_types::ApprovalsReviewer;
|
||||
use codex_protocol::openai_models::MODEL_SPECIALTY_CYBER;
|
||||
use codex_protocol::protocol::AskForApproval;
|
||||
use codex_protocol::protocol::CodexErrorInfo;
|
||||
use codex_protocol::protocol::EventMsg;
|
||||
@@ -38,6 +40,7 @@ use super::GuardianApprovalRequest;
|
||||
use super::GuardianAssessment;
|
||||
use super::GuardianAssessmentOutcome;
|
||||
use super::GuardianRejectionCircuitBreakerAction;
|
||||
use super::GuardianRejectionCircuitBreakerPolicy;
|
||||
use super::approval_request::guardian_assessment_action;
|
||||
use super::approval_request::guardian_request_target_item_id;
|
||||
use super::approval_request::guardian_request_turn_id;
|
||||
@@ -235,12 +238,17 @@ async fn record_guardian_non_denial(session: &Arc<Session>, turn_id: &str) {
|
||||
}
|
||||
|
||||
async fn record_guardian_denial(session: &Arc<Session>, turn: &Arc<TurnContext>, turn_id: &str) {
|
||||
let policy = if turn.model_info.model_specialty.as_deref() == Some(MODEL_SPECIALTY_CYBER) {
|
||||
GuardianRejectionCircuitBreakerPolicy::CyberModel
|
||||
} else {
|
||||
GuardianRejectionCircuitBreakerPolicy::Standard
|
||||
};
|
||||
let action = session
|
||||
.services
|
||||
.guardian_rejection_circuit_breaker
|
||||
.lock()
|
||||
.await
|
||||
.record_denial(turn_id);
|
||||
.record_denial(turn_id, policy);
|
||||
let GuardianRejectionCircuitBreakerAction::InterruptTurn {
|
||||
consecutive_denials,
|
||||
recent_denials,
|
||||
@@ -274,7 +282,9 @@ async fn record_guardian_denial(session: &Arc<Session>, turn: &Arc<TurnContext>,
|
||||
if aborted {
|
||||
// Guardian aborts bypass normal task completion, so emit its idle lifecycle here.
|
||||
// User interrupts deliberately do not take this path.
|
||||
session.emit_thread_idle_lifecycle_if_idle().await;
|
||||
session
|
||||
.emit_thread_idle_lifecycle_if_idle(ThreadIdleCause::Interrupted)
|
||||
.await;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -139,22 +139,38 @@ impl codex_extension_api::ContextContributor for GuardianMemoryContextProbe {
|
||||
fn guardian_rejection_circuit_breaker_interrupts_after_three_consecutive_denials() {
|
||||
let mut circuit_breaker = GuardianRejectionCircuitBreaker::default();
|
||||
assert_eq!(
|
||||
circuit_breaker.record_denial("turn-1"),
|
||||
circuit_breaker.record_denial("turn-1", GuardianRejectionCircuitBreakerPolicy::Standard),
|
||||
GuardianRejectionCircuitBreakerAction::Continue
|
||||
);
|
||||
assert_eq!(
|
||||
circuit_breaker.record_denial("turn-1"),
|
||||
circuit_breaker.record_denial("turn-1", GuardianRejectionCircuitBreakerPolicy::Standard),
|
||||
GuardianRejectionCircuitBreakerAction::Continue
|
||||
);
|
||||
assert_eq!(
|
||||
circuit_breaker.record_denial("turn-1"),
|
||||
circuit_breaker.record_denial("turn-1", GuardianRejectionCircuitBreakerPolicy::Standard),
|
||||
GuardianRejectionCircuitBreakerAction::InterruptTurn {
|
||||
consecutive_denials: 3,
|
||||
recent_denials: 3,
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
circuit_breaker.record_denial("turn-1"),
|
||||
circuit_breaker.record_denial("turn-1", GuardianRejectionCircuitBreakerPolicy::Standard),
|
||||
GuardianRejectionCircuitBreakerAction::Continue
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn guardian_rejection_circuit_breaker_interrupts_cyber_models_after_one_denial() {
|
||||
let mut circuit_breaker = GuardianRejectionCircuitBreaker::default();
|
||||
assert_eq!(
|
||||
circuit_breaker.record_denial("turn-1", GuardianRejectionCircuitBreakerPolicy::CyberModel),
|
||||
GuardianRejectionCircuitBreakerAction::InterruptTurn {
|
||||
consecutive_denials: 1,
|
||||
recent_denials: 1,
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
circuit_breaker.record_denial("turn-1", GuardianRejectionCircuitBreakerPolicy::CyberModel),
|
||||
GuardianRejectionCircuitBreakerAction::Continue
|
||||
);
|
||||
}
|
||||
@@ -163,20 +179,20 @@ fn guardian_rejection_circuit_breaker_interrupts_after_three_consecutive_denials
|
||||
fn guardian_rejection_circuit_breaker_resets_consecutive_denials_on_non_denial() {
|
||||
let mut circuit_breaker = GuardianRejectionCircuitBreaker::default();
|
||||
assert_eq!(
|
||||
circuit_breaker.record_denial("turn-1"),
|
||||
circuit_breaker.record_denial("turn-1", GuardianRejectionCircuitBreakerPolicy::Standard),
|
||||
GuardianRejectionCircuitBreakerAction::Continue
|
||||
);
|
||||
circuit_breaker.record_non_denial("turn-1");
|
||||
assert_eq!(
|
||||
circuit_breaker.record_denial("turn-1"),
|
||||
circuit_breaker.record_denial("turn-1", GuardianRejectionCircuitBreakerPolicy::Standard),
|
||||
GuardianRejectionCircuitBreakerAction::Continue
|
||||
);
|
||||
assert_eq!(
|
||||
circuit_breaker.record_denial("turn-1"),
|
||||
circuit_breaker.record_denial("turn-1", GuardianRejectionCircuitBreakerPolicy::Standard),
|
||||
GuardianRejectionCircuitBreakerAction::Continue
|
||||
);
|
||||
assert_eq!(
|
||||
circuit_breaker.record_denial("turn-1"),
|
||||
circuit_breaker.record_denial("turn-1", GuardianRejectionCircuitBreakerPolicy::Standard),
|
||||
GuardianRejectionCircuitBreakerAction::InterruptTurn {
|
||||
consecutive_denials: 3,
|
||||
recent_denials: 4,
|
||||
@@ -189,13 +205,14 @@ fn auto_review_rejection_circuit_breaker_interrupts_after_ten_recent_denials() {
|
||||
let mut circuit_breaker = GuardianRejectionCircuitBreaker::default();
|
||||
for _ in 0..9 {
|
||||
assert_eq!(
|
||||
circuit_breaker.record_denial("turn-1"),
|
||||
circuit_breaker
|
||||
.record_denial("turn-1", GuardianRejectionCircuitBreakerPolicy::Standard),
|
||||
GuardianRejectionCircuitBreakerAction::Continue
|
||||
);
|
||||
circuit_breaker.record_non_denial("turn-1");
|
||||
}
|
||||
assert_eq!(
|
||||
circuit_breaker.record_denial("turn-1"),
|
||||
circuit_breaker.record_denial("turn-1", GuardianRejectionCircuitBreakerPolicy::Standard),
|
||||
GuardianRejectionCircuitBreakerAction::InterruptTurn {
|
||||
consecutive_denials: 1,
|
||||
recent_denials: 10,
|
||||
@@ -208,7 +225,8 @@ fn auto_review_rejection_circuit_breaker_forgets_denials_outside_recent_review_w
|
||||
let mut circuit_breaker = GuardianRejectionCircuitBreaker::default();
|
||||
for _ in 0..9 {
|
||||
assert_eq!(
|
||||
circuit_breaker.record_denial("turn-1"),
|
||||
circuit_breaker
|
||||
.record_denial("turn-1", GuardianRejectionCircuitBreakerPolicy::Standard),
|
||||
GuardianRejectionCircuitBreakerAction::Continue
|
||||
);
|
||||
circuit_breaker.record_non_denial("turn-1");
|
||||
@@ -217,7 +235,7 @@ fn auto_review_rejection_circuit_breaker_forgets_denials_outside_recent_review_w
|
||||
circuit_breaker.record_non_denial("turn-1");
|
||||
}
|
||||
assert_eq!(
|
||||
circuit_breaker.record_denial("turn-1"),
|
||||
circuit_breaker.record_denial("turn-1", GuardianRejectionCircuitBreakerPolicy::Standard),
|
||||
GuardianRejectionCircuitBreakerAction::Continue
|
||||
);
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user