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:
  57f42a8113 tool search: no clone of immutable metadata on cache hits (#37279)
  e1831db7c3 reuse MCP handlers across sampling steps (#37273)
  bfb6a6ea22 plugin roots in the host skill loader (#37267)
  1151b23f01 start cached MCP servers lazily for subagents (#37261)
  7a0e974e08 harden network proxy MITM authorization (#37211)
  98da2c4499 reserve the `tool_search` namespace for the search tool (#37188)
  1dc93cdeb0 refresh 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:
2026-08-06 14:17:15 -07:00
parent 0a1b157d2c
commit 7c9138a677
249 changed files with 14540 additions and 1972 deletions
@@ -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) => {
+1
View File
@@ -4927,6 +4927,7 @@ async fn handle_image_generation_call(
status,
revised_prompt,
result,
transparent_background: None,
saved_path,
}),
order,
+1
View File
@@ -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,
+4
View File
@@ -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(),
})
}
+22
View File
@@ -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 {
+4
View File
@@ -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,
}),
};
+1
View File
@@ -14129,6 +14129,7 @@ impl ChatWidget<'_> {
status,
revised_prompt: _,
result: _,
transparent_background: _,
saved_path,
}) => {
let ok = match event.order.as_ref() {
+25 -3
View File
@@ -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",
+2
View File
@@ -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,
}),
];
+3 -1
View File
@@ -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(_)
+29 -1
View File
@@ -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]
+21
View File
@@ -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,
+24
View File
@@ -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(&notification.item) else {
return;
@@ -4731,6 +4731,13 @@
"status": {
"type": "string"
},
"transparentBackground": {
"default": null,
"type": [
"boolean",
"null"
]
},
"type": {
"enum": [
"imageGeneration"
@@ -19779,6 +19779,13 @@
"status": {
"type": "string"
},
"transparentBackground": {
"default": null,
"type": [
"boolean",
"null"
]
},
"type": {
"enum": [
"imageGeneration"
@@ -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"
@@ -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"
@@ -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,
})
}
+1
View File
@@ -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 }
+1
View File
@@ -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);
+13 -5
View File
@@ -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,
+1 -1
View File
@@ -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(
+12 -1
View File
@@ -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(&notification);
}
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"));
+1
View File
@@ -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;
+55 -4
View File
@@ -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);
+1
View File
@@ -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;
+3
View File
@@ -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(),
+3 -3
View File
@@ -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) {
+10
View File
@@ -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>,
+1 -1
View File
@@ -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 {
-1
View File
@@ -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 }
+111 -25
View File
@@ -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()
);
}
+14 -33
View File
@@ -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),
)],
);
}
}
+34 -29
View File
@@ -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");
+80
View File
@@ -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");
-2
View File
@@ -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 }
+9 -420
View File
@@ -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;
+3 -4
View File
@@ -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;
+25 -1
View File
@@ -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;
+35 -16
View File
@@ -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);
}
+54 -43
View File
@@ -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;
+6
View File
@@ -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"
},
+4 -2
View File
@@ -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.
+80 -11
View File
@@ -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)
},
}
}
+3 -2
View File
@@ -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,
+29 -4
View File
@@ -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(),
+2
View File
@@ -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(),
+2
View File
@@ -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()
}
}
+8 -1
View File
@@ -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()))
}
}
}
}
+25 -1
View File
@@ -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(
+25 -3
View File
@@ -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 {
+12 -2
View File
@@ -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;
}
});
}
+30 -12
View File
@@ -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