Merge branch 'main' into codex/se-7928-otel-best-effort

This commit is contained in:
malsamiri-oai
2026-07-06 13:33:10 -07:00
committed by GitHub
167 changed files with 5766 additions and 1107 deletions
+3
View File
@@ -28,6 +28,9 @@ jobs:
- name: Test Codex package builder
run: python3 -m unittest discover -s scripts/codex_package -p 'test_*.py'
- name: Test standalone installer
run: python3 -m unittest discover -s scripts/install -p 'test_*.py'
- name: Setup pnpm
uses: pnpm/action-setup@a8198c4bff370c8506180b035930dea56dbd5288 # v5
with:
+4 -3
View File
File diff suppressed because one or more lines are too long
-46
View File
@@ -1,46 +0,0 @@
# https://git-cliff.org/docs/configuration
[changelog]
header = """
# Changelog
You can install any of these versions: `npm install -g @openai/codex@<version>`
"""
body = """
{% if version -%}
## [{{ version | trim_start_matches(pat="v") }}] - {{ timestamp | date(format="%Y-%m-%d") }}
{%- else %}
## [unreleased]
{% endif %}
{%- for group, commits in commits | group_by(attribute="group") %}
### {{ group | striptags | trim }}
{% for commit in commits %}- {% if commit.scope %}*({{ commit.scope }})* {% endif %}{% if commit.breaking %}[**breaking**] {% endif %}{{ commit.message | upper_first }}
{% endfor %}
{%- endfor -%}
"""
footer = """
<!-- generated - do not edit -->
"""
trim = true
postprocessors = []
[git]
conventional_commits = true
commit_parsers = [
{ message = "^feat", group = "<!-- 0 -->🚀 Features" },
{ message = "^fix", group = "<!-- 1 -->🪲 Bug Fixes" },
{ message = "^bump", group = "<!-- 6 -->🛳️ Release" },
# Fallback – skip anything that didn't match the above rules.
{ message = ".*", group = "<!-- 10 -->💼 Other" },
]
filter_unconventional = false
sort_commits = "oldest"
topo_order = false
+3 -1
View File
@@ -1,5 +1,5 @@
[advisories]
# Reviewed 2026-06-07. Keep this list in sync with ../deny.toml.
# Reviewed 2026-07-02. Keep this list in sync with ../deny.toml.
ignore = [
"RUSTSEC-2024-0388", # derivative 2.2.0 via starlark/starlark_syntax; upstream crate is unmaintained
"RUSTSEC-2025-0057", # fxhash 0.2.1 via starlark_map/bm25; upstream crate is unmaintained
@@ -10,4 +10,6 @@ ignore = [
"RUSTSEC-2026-0118", # hickory-proto via rama-dns/rama-tcp; remove when rama updates to hickory 0.26.1 or hickory-net
"RUSTSEC-2026-0119", # hickory-proto via rama-dns/rama-tcp; remove when rama updates to hickory 0.26.1 or hickory-net
"RUSTSEC-2026-0173", # proc-macro-error2 via i18n-embed-fl/age/codex-secrets; remove when local secrets storage migrates off age or age drops i18n-embed-fl
"RUSTSEC-2026-0194", # quick-xml via plist/syntect and wayland-scanner; trusted inputs only; remove when rust-plist#191 and wayland-rs#938 are released
"RUSTSEC-2026-0195", # quick-xml via plist/syntect and wayland-scanner; trusted inputs only; remove when rust-plist#191 and wayland-rs#938 are released
]
+19 -9
View File
@@ -3077,6 +3077,7 @@ dependencies = [
"anyhow",
"codex-login",
"codex-protocol",
"mime_guess",
"pretty_assertions",
"sentry",
"tracing",
@@ -3712,7 +3713,7 @@ dependencies = [
"icu_provider",
"landlock",
"pretty_assertions",
"quick-xml",
"quick-xml 0.41.0",
"reqwest 0.12.28",
"schemars 0.8.22",
"seccompiler",
@@ -10271,13 +10272,13 @@ checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c"
[[package]]
name = "plist"
version = "1.8.0"
version = "1.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "740ebea15c5d1428f910cd1a5f52cebf8d25006245ed8ade92702f4943d91e07"
checksum = "092791278e026273c1b65bbdcfbba3a300f2994c896bd01ab01da613c29c46f1"
dependencies = [
"base64 0.22.1",
"indexmap 2.14.0",
"quick-xml",
"quick-xml 0.39.4",
"serde",
"time",
]
@@ -10710,9 +10711,18 @@ checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3"
[[package]]
name = "quick-xml"
version = "0.38.4"
version = "0.39.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b66c2058c55a409d601666cffe35f04333cf1013010882cec174a7467cd4e21c"
checksum = "cdcc8dd4e2f670d309a5f0e83fe36dfdc05af317008fea29144da1a2ac858e5e"
dependencies = [
"memchr",
]
[[package]]
name = "quick-xml"
version = "0.41.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e660451e55124f798a69a5af3f49ccfbefbd41910eefd25caf2393e1f3473ec1"
dependencies = [
"memchr",
"serde",
@@ -14673,12 +14683,12 @@ dependencies = [
[[package]]
name = "wayland-scanner"
version = "0.31.8"
version = "0.31.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5423e94b6a63e68e439803a3e153a9252d5ead12fd853334e2ad33997e3889e3"
checksum = "9c324a910fd86ebdc364a3e61ec1f11737d3b1d6c273c0239ee8ff4bc0d24b4a"
dependencies = [
"proc-macro2",
"quick-xml",
"quick-xml 0.39.4",
"quote",
]
+1 -1
View File
@@ -362,7 +362,7 @@ portable-pty = "0.9.0"
predicates = "3"
pretty_assertions = "1.4.1"
pulldown-cmark = "0.10"
quick-xml = "0.38.4"
quick-xml = "0.41.0"
rand = "0.9"
ratatui = "0.29.0"
ratatui-macros = "0.6.0"
-3
View File
@@ -78,9 +78,6 @@ pub use crate::remote::RemoteAppServerEndpoint;
/// module exists so clients can remove a direct `codex-core` dependency
/// while legacy startup/config paths are migrated to RPCs.
pub mod legacy_core {
pub use codex_core::check_execpolicy_for_warnings;
pub use codex_core::format_exec_policy_error_with_source;
pub mod config {
pub use codex_core::config::*;
+30 -7
View File
@@ -556,6 +556,13 @@
},
"ConsumeAccountRateLimitResetCreditParams": {
"properties": {
"creditId": {
"description": "Opaque reset-credit identifier to redeem. When omitted, the backend selects the next available credit.",
"type": [
"string",
"null"
]
},
"idempotencyKey": {
"description": "Identifies one logical reset attempt. A UUID is recommended; reuse the same value when retrying that attempt.",
"type": "string"
@@ -1786,13 +1793,29 @@
"type": "object"
},
"MultiAgentMode": {
"description": "Controls whether the model receives multi-agent delegation instructions and, when it does, whether it should only spawn sub-agents after an explicit user request or may delegate proactively when doing so would help. `none` leaves the multi-agent tools available without injecting delegation instructions.",
"enum": [
"none",
"explicitRequestOnly",
"proactive"
],
"type": "string"
"description": "Controls the effective multi-agent delegation instructions for a turn. `custom` means the configured mode hint defines the policy instead of a built-in policy.",
"oneOf": [
{
"enum": [
"explicitRequestOnly",
"proactive"
],
"type": "string"
},
{
"additionalProperties": false,
"properties": {
"custom": {
"type": "string"
}
},
"required": [
"custom"
],
"title": "CustomMultiAgentMode",
"type": "object"
}
]
},
"NetworkAccess": {
"enum": [
@@ -2713,13 +2713,29 @@
"type": "object"
},
"MultiAgentMode": {
"description": "Controls whether the model receives multi-agent delegation instructions and, when it does, whether it should only spawn sub-agents after an explicit user request or may delegate proactively when doing so would help. `none` leaves the multi-agent tools available without injecting delegation instructions.",
"enum": [
"none",
"explicitRequestOnly",
"proactive"
],
"type": "string"
"description": "Controls the effective multi-agent delegation instructions for a turn. `custom` means the configured mode hint defines the policy instead of a built-in policy.",
"oneOf": [
{
"enum": [
"explicitRequestOnly",
"proactive"
],
"type": "string"
},
{
"additionalProperties": false,
"properties": {
"custom": {
"type": "string"
}
},
"required": [
"custom"
],
"title": "CustomMultiAgentMode",
"type": "object"
}
]
},
"NetworkAccess": {
"enum": [
@@ -8688,6 +8688,13 @@
"ConsumeAccountRateLimitResetCreditParams": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"creditId": {
"description": "Opaque reset-credit identifier to redeem. When omitted, the backend selects the next available credit.",
"type": [
"string",
"null"
]
},
"idempotencyKey": {
"description": "Identifies one logical reset attempt. A UUID is recommended; reuse the same value when retrying that attempt.",
"type": "string"
@@ -12860,13 +12867,29 @@
"type": "object"
},
"MultiAgentMode": {
"description": "Controls whether the model receives multi-agent delegation instructions and, when it does, whether it should only spawn sub-agents after an explicit user request or may delegate proactively when doing so would help. `none` leaves the multi-agent tools available without injecting delegation instructions.",
"enum": [
"none",
"explicitRequestOnly",
"proactive"
],
"type": "string"
"description": "Controls the effective multi-agent delegation instructions for a turn. `custom` means the configured mode hint defines the policy instead of a built-in policy.",
"oneOf": [
{
"enum": [
"explicitRequestOnly",
"proactive"
],
"type": "string"
},
{
"additionalProperties": false,
"properties": {
"custom": {
"type": "string"
}
},
"required": [
"custom"
],
"title": "CustomMultiAgentMode",
"type": "object"
}
]
},
"NetworkAccess": {
"enum": [
@@ -14334,6 +14357,14 @@
},
"source": {
"$ref": "#/definitions/v2/PluginSource"
},
"version": {
"default": null,
"description": "Version advertised by the remote marketplace backend when available.",
"type": [
"string",
"null"
]
}
},
"required": [
@@ -14509,11 +14540,78 @@
],
"type": "string"
},
"RateLimitResetCredit": {
"properties": {
"description": {
"description": "Backend-provided display description for this credit, or `null` when unavailable.",
"type": [
"string",
"null"
]
},
"expiresAt": {
"description": "Unix timestamp in seconds when the credit expires, or `null` if it does not expire.",
"format": "int64",
"type": [
"integer",
"null"
]
},
"grantedAt": {
"description": "Unix timestamp in seconds when the credit was granted.",
"format": "int64",
"type": "integer"
},
"id": {
"description": "Opaque backend identifier for this reset credit.",
"type": "string"
},
"resetType": {
"$ref": "#/definitions/v2/RateLimitResetType"
},
"status": {
"$ref": "#/definitions/v2/RateLimitResetCreditStatus"
},
"title": {
"description": "Backend-provided display title for this credit, or `null` when unavailable.",
"type": [
"string",
"null"
]
}
},
"required": [
"grantedAt",
"id",
"resetType",
"status"
],
"type": "object"
},
"RateLimitResetCreditStatus": {
"enum": [
"available",
"redeeming",
"redeemed",
"unknown"
],
"type": "string"
},
"RateLimitResetCreditsSummary": {
"properties": {
"availableCount": {
"format": "int64",
"type": "integer"
},
"credits": {
"description": "Detail rows for available reset credits, when the backend provides them.\n\n`null` means only `availableCount` is known, while an empty array means details were fetched and no available credits were returned. The backend may cap this list, so its length can be less than `availableCount`.",
"items": {
"$ref": "#/definitions/v2/RateLimitResetCredit"
},
"type": [
"array",
"null"
]
}
},
"required": [
@@ -14521,6 +14619,13 @@
],
"type": "object"
},
"RateLimitResetType": {
"enum": [
"codexRateLimits",
"unknown"
],
"type": "string"
},
"RateLimitSnapshot": {
"properties": {
"credits": {
@@ -4928,6 +4928,13 @@
"ConsumeAccountRateLimitResetCreditParams": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"creditId": {
"description": "Opaque reset-credit identifier to redeem. When omitted, the backend selects the next available credit.",
"type": [
"string",
"null"
]
},
"idempotencyKey": {
"description": "Identifies one logical reset attempt. A UUID is recommended; reuse the same value when retrying that attempt.",
"type": "string"
@@ -9264,13 +9271,29 @@
"type": "object"
},
"MultiAgentMode": {
"description": "Controls whether the model receives multi-agent delegation instructions and, when it does, whether it should only spawn sub-agents after an explicit user request or may delegate proactively when doing so would help. `none` leaves the multi-agent tools available without injecting delegation instructions.",
"enum": [
"none",
"explicitRequestOnly",
"proactive"
],
"type": "string"
"description": "Controls the effective multi-agent delegation instructions for a turn. `custom` means the configured mode hint defines the policy instead of a built-in policy.",
"oneOf": [
{
"enum": [
"explicitRequestOnly",
"proactive"
],
"type": "string"
},
{
"additionalProperties": false,
"properties": {
"custom": {
"type": "string"
}
},
"required": [
"custom"
],
"title": "CustomMultiAgentMode",
"type": "object"
}
]
},
"NetworkAccess": {
"enum": [
@@ -10738,6 +10761,14 @@
},
"source": {
"$ref": "#/definitions/PluginSource"
},
"version": {
"default": null,
"description": "Version advertised by the remote marketplace backend when available.",
"type": [
"string",
"null"
]
}
},
"required": [
@@ -10913,11 +10944,78 @@
],
"type": "string"
},
"RateLimitResetCredit": {
"properties": {
"description": {
"description": "Backend-provided display description for this credit, or `null` when unavailable.",
"type": [
"string",
"null"
]
},
"expiresAt": {
"description": "Unix timestamp in seconds when the credit expires, or `null` if it does not expire.",
"format": "int64",
"type": [
"integer",
"null"
]
},
"grantedAt": {
"description": "Unix timestamp in seconds when the credit was granted.",
"format": "int64",
"type": "integer"
},
"id": {
"description": "Opaque backend identifier for this reset credit.",
"type": "string"
},
"resetType": {
"$ref": "#/definitions/RateLimitResetType"
},
"status": {
"$ref": "#/definitions/RateLimitResetCreditStatus"
},
"title": {
"description": "Backend-provided display title for this credit, or `null` when unavailable.",
"type": [
"string",
"null"
]
}
},
"required": [
"grantedAt",
"id",
"resetType",
"status"
],
"type": "object"
},
"RateLimitResetCreditStatus": {
"enum": [
"available",
"redeeming",
"redeemed",
"unknown"
],
"type": "string"
},
"RateLimitResetCreditsSummary": {
"properties": {
"availableCount": {
"format": "int64",
"type": "integer"
},
"credits": {
"description": "Detail rows for available reset credits, when the backend provides them.\n\n`null` means only `availableCount` is known, while an empty array means details were fetched and no available credits were returned. The backend may cap this list, so its length can be less than `availableCount`.",
"items": {
"$ref": "#/definitions/RateLimitResetCredit"
},
"type": [
"array",
"null"
]
}
},
"required": [
@@ -10925,6 +11023,13 @@
],
"type": "object"
},
"RateLimitResetType": {
"enum": [
"codexRateLimits",
"unknown"
],
"type": "string"
},
"RateLimitSnapshot": {
"properties": {
"credits": {
@@ -1,6 +1,13 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"creditId": {
"description": "Opaque reset-credit identifier to redeem. When omitted, the backend selects the next available credit.",
"type": [
"string",
"null"
]
},
"idempotencyKey": {
"description": "Identifies one logical reset attempt. A UUID is recommended; reuse the same value when retrying that attempt.",
"type": "string"
@@ -49,11 +49,78 @@
],
"type": "string"
},
"RateLimitResetCredit": {
"properties": {
"description": {
"description": "Backend-provided display description for this credit, or `null` when unavailable.",
"type": [
"string",
"null"
]
},
"expiresAt": {
"description": "Unix timestamp in seconds when the credit expires, or `null` if it does not expire.",
"format": "int64",
"type": [
"integer",
"null"
]
},
"grantedAt": {
"description": "Unix timestamp in seconds when the credit was granted.",
"format": "int64",
"type": "integer"
},
"id": {
"description": "Opaque backend identifier for this reset credit.",
"type": "string"
},
"resetType": {
"$ref": "#/definitions/RateLimitResetType"
},
"status": {
"$ref": "#/definitions/RateLimitResetCreditStatus"
},
"title": {
"description": "Backend-provided display title for this credit, or `null` when unavailable.",
"type": [
"string",
"null"
]
}
},
"required": [
"grantedAt",
"id",
"resetType",
"status"
],
"type": "object"
},
"RateLimitResetCreditStatus": {
"enum": [
"available",
"redeeming",
"redeemed",
"unknown"
],
"type": "string"
},
"RateLimitResetCreditsSummary": {
"properties": {
"availableCount": {
"format": "int64",
"type": "integer"
},
"credits": {
"description": "Detail rows for available reset credits, when the backend provides them.\n\n`null` means only `availableCount` is known, while an empty array means details were fetched and no available credits were returned. The backend may cap this list, so its length can be less than `availableCount`.",
"items": {
"$ref": "#/definitions/RateLimitResetCredit"
},
"type": [
"array",
"null"
]
}
},
"required": [
@@ -61,6 +128,13 @@
],
"type": "object"
},
"RateLimitResetType": {
"enum": [
"codexRateLimits",
"unknown"
],
"type": "string"
},
"RateLimitSnapshot": {
"properties": {
"credits": {
@@ -540,6 +540,14 @@
},
"source": {
"$ref": "#/definitions/PluginSource"
},
"version": {
"default": null,
"description": "Version advertised by the remote marketplace backend when available.",
"type": [
"string",
"null"
]
}
},
"required": [
@@ -540,6 +540,14 @@
},
"source": {
"$ref": "#/definitions/PluginSource"
},
"version": {
"default": null,
"description": "Version advertised by the remote marketplace backend when available.",
"type": [
"string",
"null"
]
}
},
"required": [
@@ -680,6 +680,14 @@
},
"source": {
"$ref": "#/definitions/PluginSource"
},
"version": {
"default": null,
"description": "Version advertised by the remote marketplace backend when available.",
"type": [
"string",
"null"
]
}
},
"required": [
@@ -496,6 +496,14 @@
},
"source": {
"$ref": "#/definitions/PluginSource"
},
"version": {
"default": null,
"description": "Version advertised by the remote marketplace backend when available.",
"type": [
"string",
"null"
]
}
},
"required": [
@@ -681,13 +681,29 @@
]
},
"MultiAgentMode": {
"description": "Controls whether the model receives multi-agent delegation instructions and, when it does, whether it should only spawn sub-agents after an explicit user request or may delegate proactively when doing so would help. `none` leaves the multi-agent tools available without injecting delegation instructions.",
"enum": [
"none",
"explicitRequestOnly",
"proactive"
],
"type": "string"
"description": "Controls the effective multi-agent delegation instructions for a turn. `custom` means the configured mode hint defines the policy instead of a built-in policy.",
"oneOf": [
{
"enum": [
"explicitRequestOnly",
"proactive"
],
"type": "string"
},
{
"additionalProperties": false,
"properties": {
"custom": {
"type": "string"
}
},
"required": [
"custom"
],
"title": "CustomMultiAgentMode",
"type": "object"
}
]
},
"NetworkAccess": {
"enum": [
@@ -681,13 +681,29 @@
]
},
"MultiAgentMode": {
"description": "Controls whether the model receives multi-agent delegation instructions and, when it does, whether it should only spawn sub-agents after an explicit user request or may delegate proactively when doing so would help. `none` leaves the multi-agent tools available without injecting delegation instructions.",
"enum": [
"none",
"explicitRequestOnly",
"proactive"
],
"type": "string"
"description": "Controls the effective multi-agent delegation instructions for a turn. `custom` means the configured mode hint defines the policy instead of a built-in policy.",
"oneOf": [
{
"enum": [
"explicitRequestOnly",
"proactive"
],
"type": "string"
},
{
"additionalProperties": false,
"properties": {
"custom": {
"type": "string"
}
},
"required": [
"custom"
],
"title": "CustomMultiAgentMode",
"type": "object"
}
]
},
"NetworkAccess": {
"enum": [
@@ -108,13 +108,29 @@
"type": "string"
},
"MultiAgentMode": {
"description": "Controls whether the model receives multi-agent delegation instructions and, when it does, whether it should only spawn sub-agents after an explicit user request or may delegate proactively when doing so would help. `none` leaves the multi-agent tools available without injecting delegation instructions.",
"enum": [
"none",
"explicitRequestOnly",
"proactive"
],
"type": "string"
"description": "Controls the effective multi-agent delegation instructions for a turn. `custom` means the configured mode hint defines the policy instead of a built-in policy.",
"oneOf": [
{
"enum": [
"explicitRequestOnly",
"proactive"
],
"type": "string"
},
{
"additionalProperties": false,
"properties": {
"custom": {
"type": "string"
}
},
"required": [
"custom"
],
"title": "CustomMultiAgentMode",
"type": "object"
}
]
},
"NetworkAccess": {
"enum": [
@@ -195,13 +195,29 @@
"type": "string"
},
"MultiAgentMode": {
"description": "Controls whether the model receives multi-agent delegation instructions and, when it does, whether it should only spawn sub-agents after an explicit user request or may delegate proactively when doing so would help. `none` leaves the multi-agent tools available without injecting delegation instructions.",
"enum": [
"none",
"explicitRequestOnly",
"proactive"
],
"type": "string"
"description": "Controls the effective multi-agent delegation instructions for a turn. `custom` means the configured mode hint defines the policy instead of a built-in policy.",
"oneOf": [
{
"enum": [
"explicitRequestOnly",
"proactive"
],
"type": "string"
},
{
"additionalProperties": false,
"properties": {
"custom": {
"type": "string"
}
},
"required": [
"custom"
],
"title": "CustomMultiAgentMode",
"type": "object"
}
]
},
"Personality": {
"enum": [
@@ -681,13 +681,29 @@
]
},
"MultiAgentMode": {
"description": "Controls whether the model receives multi-agent delegation instructions and, when it does, whether it should only spawn sub-agents after an explicit user request or may delegate proactively when doing so would help. `none` leaves the multi-agent tools available without injecting delegation instructions.",
"enum": [
"none",
"explicitRequestOnly",
"proactive"
],
"type": "string"
"description": "Controls the effective multi-agent delegation instructions for a turn. `custom` means the configured mode hint defines the policy instead of a built-in policy.",
"oneOf": [
{
"enum": [
"explicitRequestOnly",
"proactive"
],
"type": "string"
},
{
"additionalProperties": false,
"properties": {
"custom": {
"type": "string"
}
},
"required": [
"custom"
],
"title": "CustomMultiAgentMode",
"type": "object"
}
]
},
"NetworkAccess": {
"enum": [
@@ -141,13 +141,29 @@
"type": "string"
},
"MultiAgentMode": {
"description": "Controls whether the model receives multi-agent delegation instructions and, when it does, whether it should only spawn sub-agents after an explicit user request or may delegate proactively when doing so would help. `none` leaves the multi-agent tools available without injecting delegation instructions.",
"enum": [
"none",
"explicitRequestOnly",
"proactive"
],
"type": "string"
"description": "Controls the effective multi-agent delegation instructions for a turn. `custom` means the configured mode hint defines the policy instead of a built-in policy.",
"oneOf": [
{
"enum": [
"explicitRequestOnly",
"proactive"
],
"type": "string"
},
{
"additionalProperties": false,
"properties": {
"custom": {
"type": "string"
}
},
"required": [
"custom"
],
"title": "CustomMultiAgentMode",
"type": "object"
}
]
},
"NetworkAccess": {
"enum": [
@@ -3,9 +3,7 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
/**
* Controls whether the model receives multi-agent delegation instructions and,
* when it does, whether it should only spawn sub-agents after an explicit user
* request or may delegate proactively when doing so would help. `none` leaves
* the multi-agent tools available without injecting delegation instructions.
* Controls the effective multi-agent delegation instructions for a turn. `custom` means the
* configured mode hint defines the policy instead of a built-in policy.
*/
export type MultiAgentMode = "none" | "explicitRequestOnly" | "proactive";
export type MultiAgentMode = { "custom": string } | "explicitRequestOnly" | "proactive";
@@ -7,4 +7,9 @@ export type ConsumeAccountRateLimitResetCreditParams = {
* Identifies one logical reset attempt. A UUID is recommended; reuse the same value when
* retrying that attempt.
*/
idempotencyKey: string, };
idempotencyKey: string,
/**
* Opaque reset-credit identifier to redeem. When omitted, the backend selects the next
* available credit.
*/
creditId?: string | null, };
@@ -13,6 +13,10 @@ export type PluginSummary = { id: string,
* Backend remote plugin identifier when available.
*/
remotePluginId: string | null,
/**
* Version advertised by the remote marketplace backend when available.
*/
version: string | null,
/**
* Version of the locally materialized plugin package when available.
*/
@@ -0,0 +1,27 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { RateLimitResetCreditStatus } from "./RateLimitResetCreditStatus";
import type { RateLimitResetType } from "./RateLimitResetType";
export type RateLimitResetCredit = {
/**
* Opaque backend identifier for this reset credit.
*/
id: string, resetType: RateLimitResetType, status: RateLimitResetCreditStatus,
/**
* Unix timestamp in seconds when the credit was granted.
*/
grantedAt: number,
/**
* Unix timestamp in seconds when the credit expires, or `null` if it does not expire.
*/
expiresAt: number | null,
/**
* Backend-provided display title for this credit, or `null` when unavailable.
*/
title: string | null,
/**
* Backend-provided display description for this credit, or `null` when unavailable.
*/
description: string | null, };
@@ -0,0 +1,5 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type RateLimitResetCreditStatus = "available" | "redeeming" | "redeemed" | "unknown";
@@ -1,5 +1,14 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { RateLimitResetCredit } from "./RateLimitResetCredit";
export type RateLimitResetCreditsSummary = { availableCount: bigint, };
export type RateLimitResetCreditsSummary = { availableCount: bigint,
/**
* Detail rows for available reset credits, when the backend provides them.
*
* `null` means only `availableCount` is known, while an empty array means details were fetched
* and no available credits were returned. The backend may cap this list, so its length can be
* less than `availableCount`.
*/
credits: Array<RateLimitResetCredit> | null, };
@@ -0,0 +1,5 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type RateLimitResetType = "codexRateLimits" | "unknown";
@@ -335,7 +335,10 @@ export type { ProcessOutputDeltaNotification } from "./ProcessOutputDeltaNotific
export type { ProcessOutputStream } from "./ProcessOutputStream";
export type { ProcessTerminalSize } from "./ProcessTerminalSize";
export type { RateLimitReachedType } from "./RateLimitReachedType";
export type { RateLimitResetCredit } from "./RateLimitResetCredit";
export type { RateLimitResetCreditStatus } from "./RateLimitResetCreditStatus";
export type { RateLimitResetCreditsSummary } from "./RateLimitResetCreditsSummary";
export type { RateLimitResetType } from "./RateLimitResetType";
export type { RateLimitSnapshot } from "./RateLimitSnapshot";
export type { RateLimitWindow } from "./RateLimitWindow";
export type { RawResponseItemCompletedNotification } from "./RawResponseItemCompletedNotification";
@@ -284,6 +284,52 @@ pub struct GetAccountRateLimitsResponse {
#[ts(export_to = "v2/")]
pub struct RateLimitResetCreditsSummary {
pub available_count: i64,
/// Detail rows for available reset credits, when the backend provides them.
///
/// `null` means only `availableCount` is known, while an empty array means details were fetched
/// and no available credits were returned. The backend may cap this list, so its length can be
/// less than `availableCount`.
pub credits: Option<Vec<RateLimitResetCredit>>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct RateLimitResetCredit {
/// Opaque backend identifier for this reset credit.
pub id: String,
pub reset_type: RateLimitResetType,
pub status: RateLimitResetCreditStatus,
/// Unix timestamp in seconds when the credit was granted.
#[ts(type = "number")]
pub granted_at: i64,
/// Unix timestamp in seconds when the credit expires, or `null` if it does not expire.
#[ts(type = "number | null")]
pub expires_at: Option<i64>,
/// Backend-provided display title for this credit, or `null` when unavailable.
pub title: Option<String>,
/// Backend-provided display description for this credit, or `null` when unavailable.
pub description: Option<String>,
}
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/", rename_all = "camelCase")]
pub enum RateLimitResetType {
CodexRateLimits,
#[serde(other)]
Unknown,
}
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/", rename_all = "camelCase")]
pub enum RateLimitResetCreditStatus {
Available,
Redeeming,
Redeemed,
#[serde(other)]
Unknown,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
@@ -293,6 +339,10 @@ pub struct ConsumeAccountRateLimitResetCreditParams {
/// Identifies one logical reset attempt. A UUID is recommended; reuse the same value when
/// retrying that attempt.
pub idempotency_key: String,
/// Opaque reset-credit identifier to redeem. When omitted, the backend selects the next
/// available credit.
#[ts(optional = nullable)]
pub credit_id: Option<String>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
@@ -600,6 +600,9 @@ pub struct PluginSummary {
pub id: String,
/// Backend remote plugin identifier when available.
pub remote_plugin_id: Option<String>,
/// Version advertised by the remote marketplace backend when available.
#[serde(default)]
pub version: Option<String>,
/// Version of the locally materialized plugin package when available.
#[serde(default)]
pub local_version: Option<String>,
@@ -3642,6 +3642,7 @@ fn plugin_share_list_response_serializes_share_items() {
remote_plugin_id: Some(
"plugins~Plugin_00000000000000000000000000000000".to_string(),
),
version: None,
local_version: None,
name: "gmail".to_string(),
share_context: None,
@@ -3663,6 +3664,7 @@ fn plugin_share_list_response_serializes_share_items() {
"plugin": {
"id": "gmail@openai-curated-remote",
"remotePluginId": "plugins~Plugin_00000000000000000000000000000000",
"version": null,
"localVersion": null,
"name": "gmail",
"shareContext": null,
@@ -685,7 +685,7 @@ pub struct ThreadIncrementElicitationParams {
#[ts(export_to = "v2/")]
pub struct ThreadIncrementElicitationResponse {
/// Current out-of-band elicitation count after the increment.
pub count: u64,
pub count: i64,
/// Whether timeout accounting is paused after applying the increment.
pub paused: bool,
}
@@ -705,7 +705,7 @@ pub struct ThreadDecrementElicitationParams {
#[ts(export_to = "v2/")]
pub struct ThreadDecrementElicitationResponse {
/// Current out-of-band elicitation count after the decrement.
pub count: u64,
pub count: i64,
/// Whether timeout accounting remains paused after applying the decrement.
pub paused: bool,
}
+35 -8
View File
@@ -213,7 +213,7 @@ Example with notification opt-out:
- `marketplace/add` — add a remote plugin marketplace from an HTTP(S) Git URL, SSH Git URL, or GitHub `owner/repo` shorthand, then persist it into the user marketplace config. Returns the installed root path plus whether the marketplace was already present.
- `marketplace/remove` — remove a configured marketplace by name from the user marketplace config, and delete its installed marketplace root when one exists.
- `marketplace/upgrade` — upgrade all configured Git plugin marketplaces, or one named marketplace when `marketplaceName` is provided. Returns selected marketplace names, upgraded roots, and per-marketplace errors.
- `plugin/list` — list discovered plugin marketplaces and plugin state, including effective marketplace install/auth policy metadata, plugin `availability` (`AVAILABLE` by default or `DISABLED_BY_ADMIN` for remote plugins blocked upstream), fail-open `marketplaceLoadErrors` entries for marketplace files that could not be parsed or loaded, and best-effort `featuredPluginIds` for the official curated marketplace. Clients can explicitly request the remote `workspace-directory`, `shared-with-me`, or `created-by-me-remote` marketplace kinds. `interface.category` uses the marketplace category when present; otherwise it falls back to the plugin manifest category (**under development; do not call from production clients yet**).
- `plugin/list` — list discovered plugin marketplaces and plugin state, including effective marketplace install/auth policy metadata, the remote marketplace `version` and locally materialized `localVersion` when available, plugin `availability` (`AVAILABLE` by default or `DISABLED_BY_ADMIN` for remote plugins blocked upstream), fail-open `marketplaceLoadErrors` entries for marketplace files that could not be parsed or loaded, and best-effort `featuredPluginIds` for the official curated marketplace. Clients can explicitly request the remote `workspace-directory`, `shared-with-me`, or `created-by-me-remote` marketplace kinds. `interface.category` uses the marketplace category when present; otherwise it falls back to the plugin manifest category (**under development; do not call from production clients yet**).
- `plugin/installed` — list installed plugin rows plus any explicitly requested local install-suggestion plugin names, without fetching the broader remote catalog. Mention surfaces can use this narrower view when they need plugin mention payloads rather than plugin-page discovery data (**under development; do not call from production clients yet**).
- `plugin/read` — read one plugin by `marketplacePath` plus `pluginName`, returning marketplace info, a list-style `summary`, manifest descriptions/interface metadata, and bundled skills/hooks/apps/MCP server names. Remote plugin details expose the canonical `shareUrl` supplied by the remote catalog when available; it is `null` for local plugins or when the catalog omits it. This field is separate from `summary.shareContext`, which continues to describe user and workspace sharing state. Returned plugin skills include their current `enabled` state after local config filtering; bundled hooks are returned as lightweight declaration summaries keyed for correlation with `hooks/list`. Use `plugin/install`'s `appsNeedingAuth` to drive post-install authentication and `app/list`'s `isAccessible` to determine current connector accessibility (**under development; do not call from production clients yet**).
- `plugin/skill/read` — read remote plugin skill markdown on demand by `remoteMarketplaceName`, `remotePluginId`, and `skillName`. This lets clients preview uninstalled remote plugin skills without downloading the plugin bundle.
@@ -1301,7 +1301,7 @@ Event notifications are the server-initiated event stream for thread lifecycles,
Thread realtime uses a separate thread-scoped notification surface. `thread/realtime/*` notifications are ephemeral transport events, not `ThreadItem`s, and are not returned by `thread/read`, `thread/resume`, or `thread/fork`.
Recoverable configuration and initialization warnings use the existing `configWarning` notification: `{ summary, details?, path?, range? }`. App-server may emit it during initialization for config parsing and related setup diagnostics.
Recoverable configuration and initialization warnings use the existing `configWarning` notification: `{ summary, details?, path?, range? }`. App-server may emit it during initialization for config parsing and related setup diagnostics, or to the requesting connection during `thread/start` when that thread's exec-policy rules fail to parse.
Generic runtime warnings use the `warning` notification: `{ threadId?, message }`. App-server emits this for non-fatal warnings from the core event stream, including cases where not all enabled skills are included in the model-visible skills list for a session.
@@ -1929,8 +1929,8 @@ Codex supports these authentication modes. The current mode is surfaced in `acco
- `account/login/cancel` — cancel a pending managed ChatGPT login by `loginId`.
- `account/logout` — sign out; triggers `account/updated`.
- `account/updated` (notify) — emitted whenever auth mode changes (`authMode`: `apikey`, `chatgpt`, `personalAccessToken`, or `null`) and includes the current ChatGPT `planType` when available.
- `account/rateLimits/read` — fetch ChatGPT rate limits, an optional effective monthly credit limit, and the number of earned rate-limit resets currently available. Rate-limit updates arrive via `account/rateLimits/updated` (notify); the reset count is snapshot-only.
- `account/rateLimitResetCredit/consume` — consume one earned reset using a caller-provided idempotency key.
- `account/rateLimits/read` — fetch ChatGPT rate limits, an optional effective monthly credit limit, and the earned rate-limit resets currently available, including expiry details when provided by the backend. Rate-limit updates arrive via `account/rateLimits/updated` (notify); reset-credit data is snapshot-only.
- `account/rateLimitResetCredit/consume` — consume one earned reset using a caller-provided idempotency key, optionally selecting a reset-credit ID returned by `account/rateLimits/read`.
- `account/usage/read` — fetch ChatGPT account token-activity summary and daily buckets.
- `account/workspaceMessages/read` — fetch active workspace messages, including workspace notification headlines when available.
- `account/rateLimits/updated` (notify) — emitted whenever a user's ChatGPT rate limits change. This is a sparse rolling update; merge available values into the most recent `account/rateLimits/read` response or refetch that snapshot.
@@ -2032,7 +2032,30 @@ Field notes:
```json
{ "method": "account/rateLimits/read", "id": 7 }
{ "id": 7, "result": { "rateLimits": { "primary": { "usedPercent": 25, "windowDurationMins": 15, "resetsAt": 1730947200 }, "secondary": null, "rateLimitReachedType": null }, "rateLimitResetCredits": { "availableCount": 2 } } }
{
"id": 7,
"result": {
"rateLimits": {
"primary": { "usedPercent": 25, "windowDurationMins": 15, "resetsAt": 1730947200 },
"secondary": null,
"rateLimitReachedType": null
},
"rateLimitResetCredits": {
"availableCount": 2,
"credits": [
{
"id": "RateLimitResetCredit_1",
"resetType": "codexRateLimits",
"status": "available",
"grantedAt": 1781654400,
"expiresAt": 1784246400,
"title": "Full reset (Weekly + 5 hr)",
"description": "Ready to redeem"
}
]
}
}
}
{ "method": "account/rateLimits/updated", "params": { "rateLimits": { … } } }
```
@@ -2043,23 +2066,27 @@ Field notes:
- `resetsAt` is a Unix timestamp (seconds) for the next reset.
- `rateLimitReachedType` identifies the backend-classified limit state when one has been reached.
- `individualLimit` describes the effective monthly credit limit when available. In an `account/rateLimits/read` response, `null` means no monthly limit is available. In a sparse `account/rateLimits/updated` notification, nullable account metadata may be unavailable and does not clear a previously observed value.
- `rateLimitResetCredits` contains the available earned-reset count when the backend provides it; otherwise it is `null`. Refetch `account/rateLimits/read` after consuming a reset.
- `rateLimitResetCredits` contains the available earned-reset count when the backend provides it; otherwise it is `null`.
- `rateLimitResetCredits.credits` is `null` when only the count is available. An empty array means details were fetched and no available credits were returned.
- The backend may cap `rateLimitResetCredits.credits`, so `availableCount` is the authoritative total and can be greater than the number of detail rows.
- Refetch `account/rateLimits/read` after consuming a reset.
### 8) Earned rate-limit resets (ChatGPT)
```json
{ "method": "account/rateLimitResetCredit/consume", "id": 8, "params": { "idempotencyKey": "8ae96ff3-3425-4f4c-8772-b6fd61502868" } }
{ "method": "account/rateLimitResetCredit/consume", "id": 8, "params": { "idempotencyKey": "8ae96ff3-3425-4f4c-8772-b6fd61502868", "creditId": "RateLimitResetCredit_1" } }
{ "id": 8, "result": { "outcome": "reset" } }
```
Field notes:
- `idempotencyKey` must be non-empty. A UUID is recommended for each logical redemption attempt; reuse the same value when retrying that attempt.
- `creditId` is optional. When provided, it must be a non-empty opaque ID returned by `account/rateLimits/read`; when omitted, the backend selects the next available credit.
- `reset` means a credit was consumed.
- `alreadyRedeemed` means the same redemption completed previously. Treat it as an idempotent success and refresh account limits.
- `nothingToReset` means there is no eligible rate-limit window to reset.
- `noCredit` means the account has no earned reset credits available.
- Refetch `account/rateLimits/read` after consuming a reset instead of inferring updated windows from this response.
- Refetch `account/rateLimits/read` after consuming a reset instead of inferring updated state from this response.
### 9) Workspace messages (ChatGPT)
+11 -1
View File
@@ -80,6 +80,7 @@ use codex_arg0::Arg0DispatchPaths;
use codex_config::CloudConfigBundleLoader;
use codex_config::LoaderOverrides;
use codex_config::ThreadConfigLoader;
use codex_core::check_execpolicy_for_warnings;
use codex_core::config::Config;
use codex_core::resolve_installation_id;
use codex_exec_server::EnvironmentManager;
@@ -349,7 +350,16 @@ impl InProcessClientHandle {
/// This function sends `initialize` followed by `initialized` before returning
/// the handle, so callers receive a ready-to-use runtime. If initialize fails,
/// the runtime is shut down and an `InvalidData` error is returned.
pub async fn start(args: InProcessStartArgs) -> IoResult<InProcessClientHandle> {
pub async fn start(mut args: InProcessStartArgs) -> IoResult<InProcessClientHandle> {
if let Ok(Some(err)) = check_execpolicy_for_warnings(&args.config.config_layer_stack).await {
let (path, range) = crate::exec_policy_warning_location(&err);
args.config_warnings.push(ConfigWarningNotification {
summary: "Error parsing rules; custom rules not applied.".to_string(),
details: Some(err.to_string()),
path,
range,
});
}
let initialize = args.initialize.clone();
let client = start_uninitialized(args).await?;
+11 -8
View File
@@ -320,6 +320,16 @@ fn exec_policy_warning_location(err: &ExecPolicyError) -> (Option<String>, Optio
}
}
fn exec_policy_config_warning(err: &ExecPolicyError) -> ConfigWarningNotification {
let (path, range) = exec_policy_warning_location(err);
ConfigWarningNotification {
summary: "Error parsing rules; custom rules not applied.".to_string(),
details: Some(err.to_string()),
path,
range,
}
}
fn app_text_range(range: &CoreTextRange) -> AppTextRange {
AppTextRange {
start: AppTextPosition {
@@ -617,14 +627,7 @@ pub async fn run_main_with_transport_options(
}
if let Ok(Some(err)) = check_execpolicy_for_warnings(&config.config_layer_stack).await {
let (path, range) = exec_policy_warning_location(&err);
let message = ConfigWarningNotification {
summary: "Error parsing rules; custom rules not applied.".to_string(),
details: Some(err.to_string()),
path,
range,
};
config_warnings.push(message);
config_warnings.push(exec_policy_config_warning(&err));
}
if let Some(warning) = project_config_warning(&config) {
+2 -1
View File
@@ -447,7 +447,7 @@ impl MessageProcessor {
outgoing.clone(),
analytics_events_client.clone(),
Arc::clone(&config),
config_warnings,
config_warnings.clone(),
rpc_transport,
);
let marketplace_processor = MarketplaceRequestProcessor::new(
@@ -495,6 +495,7 @@ impl MessageProcessor {
state_db.clone(),
log_db,
Arc::clone(&skills_watcher),
config_warnings,
);
let turn_processor = TurnRequestProcessor::new(
auth_manager.clone(),
@@ -156,7 +156,10 @@ use codex_app_server_protocol::PluginSource;
use codex_app_server_protocol::PluginSummary;
use codex_app_server_protocol::PluginUninstallParams;
use codex_app_server_protocol::PluginUninstallResponse;
use codex_app_server_protocol::RateLimitResetCredit;
use codex_app_server_protocol::RateLimitResetCreditStatus;
use codex_app_server_protocol::RateLimitResetCreditsSummary;
use codex_app_server_protocol::RateLimitResetType;
use codex_app_server_protocol::RequestId;
use codex_app_server_protocol::ReviewDelivery as ApiReviewDelivery;
use codex_app_server_protocol::ReviewStartParams;
@@ -298,6 +301,8 @@ use codex_backend_client::CodexWorkspaceMessage as BackendWorkspaceMessage;
use codex_backend_client::CodexWorkspaceMessageType as BackendWorkspaceMessageType;
use codex_backend_client::CodexWorkspaceMessagesResponse as BackendWorkspaceMessagesResponse;
use codex_backend_client::ConsumeRateLimitResetCreditCode as BackendConsumeRateLimitResetCreditCode;
use codex_backend_client::RateLimitResetCreditDetails as BackendRateLimitResetCreditDetails;
use codex_backend_client::RateLimitResetCreditsDetails as BackendRateLimitResetCreditsDetails;
use codex_backend_client::RequestError as BackendRequestError;
use codex_backend_client::TokenUsageProfile;
use codex_chatgpt::connectors;
@@ -891,9 +891,11 @@ impl AccountRequestProcessor {
let client = BackendClient::from_auth(self.config.chatgpt_base_url.clone(), &auth)
.map_err(|err| internal_error(format!("failed to construct backend client: {err}")))?;
let response = client
.get_rate_limits_with_reset_credits()
.await
let (response, detailed_rate_limit_reset_credits) = tokio::join!(
client.get_rate_limits_with_reset_credits(),
Self::detailed_rate_limit_reset_credits(&client),
);
let response = response
.map_err(|err| internal_error(format!("failed to fetch codex rate limits: {err}")))?;
if response.rate_limits.is_empty() {
return Err(internal_error(
@@ -920,6 +922,15 @@ impl AccountRequestProcessor {
.cloned()
.unwrap_or_else(|| response.rate_limits[0].clone());
let rate_limit_reset_credits = detailed_rate_limit_reset_credits.or_else(|| {
response
.rate_limit_reset_credits
.map(|summary| RateLimitResetCreditsSummary {
available_count: summary.available_count,
credits: None,
})
});
Ok(GetAccountRateLimitsResponse {
rate_limits: rate_limits.into(),
rate_limits_by_limit_id: Some(
@@ -928,11 +939,7 @@ impl AccountRequestProcessor {
.map(|(limit_id, snapshot)| (limit_id, snapshot.into()))
.collect(),
),
rate_limit_reset_credits: response.rate_limit_reset_credits.map(|summary| {
RateLimitResetCreditsSummary {
available_count: summary.available_count,
}
}),
rate_limit_reset_credits,
})
}
@@ -1,11 +1,47 @@
use super::*;
const RATE_LIMIT_RESET_REQUEST_TIMEOUT: Duration = Duration::from_secs(/*secs*/ 10);
const RATE_LIMIT_RESET_DETAILS_REQUEST_TIMEOUT: Duration = Duration::from_secs(/*secs*/ 5);
#[cfg(debug_assertions)]
const RATE_LIMIT_RESET_REQUEST_TIMEOUT_ENV_VAR: &str =
"CODEX_TEST_RATE_LIMIT_RESET_REQUEST_TIMEOUT_MS";
impl AccountRequestProcessor {
pub(super) async fn detailed_rate_limit_reset_credits(
client: &BackendClient,
) -> Option<RateLimitResetCreditsSummary> {
let details = match tokio::time::timeout(
RATE_LIMIT_RESET_DETAILS_REQUEST_TIMEOUT,
client.list_rate_limit_reset_credits(),
)
.await
{
Ok(Ok(details)) => details,
Ok(Err(err)) => {
tracing::warn!(
"failed to fetch rate limit reset credit details; falling back to the usage response: {err}"
);
return None;
}
Err(_) => {
tracing::warn!(
"rate limit reset credit detail request timed out; falling back to the usage response"
);
return None;
}
};
match rate_limit_reset_credits_from_backend(details) {
Ok(summary) => Some(summary),
Err(err) => {
tracing::warn!(
"failed to parse rate limit reset credit details; falling back to the usage response: {err}"
);
None
}
}
}
pub(crate) async fn consume_account_rate_limit_reset_credit(
&self,
params: ConsumeAccountRateLimitResetCreditParams,
@@ -13,6 +49,9 @@ impl AccountRequestProcessor {
if params.idempotency_key.is_empty() {
return Err(invalid_request("idempotencyKey must not be empty"));
}
if params.credit_id.as_deref().is_some_and(str::is_empty) {
return Err(invalid_request("creditId must not be empty"));
}
let client = self.rate_limit_reset_backend_client().await?;
let request_timeout = RATE_LIMIT_RESET_REQUEST_TIMEOUT;
@@ -22,10 +61,20 @@ impl AccountRequestProcessor {
.and_then(|value| value.parse::<u64>().ok())
.map(Duration::from_millis)
.unwrap_or(request_timeout);
let response = tokio::time::timeout(
request_timeout,
client.consume_rate_limit_reset_credit(&params.idempotency_key),
)
let response = tokio::time::timeout(request_timeout, async {
match params.credit_id.as_deref() {
Some(credit_id) => {
client
.consume_rate_limit_reset_credit_by_id(&params.idempotency_key, credit_id)
.await
}
None => {
client
.consume_rate_limit_reset_credit(&params.idempotency_key)
.await
}
}
})
.await
.map_err(|_| internal_error("rate limit reset consume timed out"))?
.map_err(|err| internal_error(format!("failed to consume rate limit reset: {err}")))?;
@@ -64,3 +113,56 @@ impl AccountRequestProcessor {
.map_err(|err| internal_error(format!("failed to construct backend client: {err}")))
}
}
fn rate_limit_reset_credits_from_backend(
details: BackendRateLimitResetCreditsDetails,
) -> Result<RateLimitResetCreditsSummary, String> {
let credits = details
.credits
.into_iter()
.map(rate_limit_reset_credit_from_backend)
.collect::<Result<Vec<_>, _>>()?;
Ok(RateLimitResetCreditsSummary {
available_count: details.available_count,
credits: Some(credits),
})
}
fn rate_limit_reset_credit_from_backend(
credit: BackendRateLimitResetCreditDetails,
) -> Result<RateLimitResetCredit, String> {
let reset_type = match credit.reset_type.as_str() {
"codex_rate_limits" => RateLimitResetType::CodexRateLimits,
_ => RateLimitResetType::Unknown,
};
let status = match credit.status.as_str() {
"available" => RateLimitResetCreditStatus::Available,
"redeeming" => RateLimitResetCreditStatus::Redeeming,
"redeemed" => RateLimitResetCreditStatus::Redeemed,
_ => RateLimitResetCreditStatus::Unknown,
};
let granted_at = rate_limit_reset_credit_timestamp(&credit.granted_at)
.map_err(|err| format!("invalid granted_at for credit `{}`: {err}", credit.id))?;
let expires_at = credit
.expires_at
.as_deref()
.map(rate_limit_reset_credit_timestamp)
.transpose()
.map_err(|err| format!("invalid expires_at for credit `{}`: {err}", credit.id))?;
Ok(RateLimitResetCredit {
id: credit.id,
reset_type,
status,
granted_at,
expires_at,
title: credit.title,
description: credit.description,
})
}
fn rate_limit_reset_credit_timestamp(timestamp: &str) -> Result<i64, String> {
DateTime::parse_from_rfc3339(timestamp)
.map(|timestamp| timestamp.timestamp())
.map_err(|err| format!("failed to parse timestamp `{timestamp}`: {err}"))
}
@@ -155,6 +155,7 @@ fn convert_configured_marketplace_plugin_to_plugin_summary(
PluginSummary {
id: plugin.id,
remote_plugin_id: None,
version: None,
local_version: plugin.local_version,
installed: plugin.installed,
enabled: plugin.enabled,
@@ -1095,6 +1096,7 @@ impl PluginRequestProcessor {
summary: PluginSummary {
id: outcome.plugin.id,
remote_plugin_id: None,
version: None,
local_version: outcome.plugin.local_version,
name: outcome.plugin.name,
share_context,
@@ -2168,6 +2170,7 @@ fn remote_plugin_summary_to_info(summary: RemoteCatalogPluginSummary) -> PluginS
PluginSummary {
id: summary.id,
remote_plugin_id: Some(summary.remote_plugin_id),
version: summary.version,
local_version: summary.local_version,
name: summary.name,
share_context: summary
@@ -364,6 +364,7 @@ pub(crate) struct ThreadRequestProcessor {
pub(super) log_db: Option<LogDbLayer>,
pub(super) background_tasks: TaskTracker,
pub(super) skills_watcher: Arc<SkillsWatcher>,
pub(super) initial_config_warnings: Arc<Vec<ConfigWarningNotification>>,
}
/// Outcome of trying to satisfy a resume request from an already loaded thread.
@@ -395,6 +396,7 @@ impl ThreadRequestProcessor {
state_db: Option<StateDbHandle>,
log_db: Option<LogDbLayer>,
skills_watcher: Arc<SkillsWatcher>,
initial_config_warnings: Vec<ConfigWarningNotification>,
) -> Self {
Self {
auth_manager,
@@ -413,6 +415,7 @@ impl ThreadRequestProcessor {
log_db,
background_tasks: TaskTracker::new(),
skills_watcher,
initial_config_warnings: Arc::new(initial_config_warnings),
}
}
@@ -968,6 +971,7 @@ impl ThreadRequestProcessor {
};
let request_trace = request_context.request_trace();
let config_manager = self.config_manager.clone();
let initial_config_warnings = Arc::clone(&self.initial_config_warnings);
let outgoing = Arc::clone(&listener_task_context.outgoing);
let error_request_id = request_id.clone();
let thread_start_task = async move {
@@ -990,6 +994,7 @@ impl ThreadRequestProcessor {
allow_provider_model_fallback,
experimental_raw_events,
request_trace,
initial_config_warnings,
)
.await
{
@@ -1066,6 +1071,7 @@ impl ThreadRequestProcessor {
allow_provider_model_fallback: bool,
experimental_raw_events: bool,
request_trace: Option<W3cTraceContext>,
initial_config_warnings: Arc<Vec<ConfigWarningNotification>>,
) -> Result<(), JSONRPCErrorError> {
let thread_start_started_at = std::time::Instant::now();
let requested_cwd = typesafe_overrides.cwd.clone();
@@ -1139,6 +1145,21 @@ impl ThreadRequestProcessor {
.map_err(|err| config_load_error(&err))?;
}
if let Ok(Some(err)) =
codex_core::check_execpolicy_for_warnings(&config.config_layer_stack).await
{
let notification = crate::exec_policy_config_warning(&err);
if !initial_config_warnings.contains(&notification) {
listener_task_context
.outgoing
.send_server_notification_to_connections(
&[request_id.connection_id],
ServerNotification::ConfigWarning(notification),
)
.await;
}
}
let environments = environments.unwrap_or_else(|| {
listener_task_context
.thread_manager
@@ -1,11 +1,79 @@
use std::path::Path;
use std::process::Command;
use std::process::Stdio;
use std::sync::Arc;
use std::sync::Mutex;
use std::time::Duration;
use anyhow::Context;
use anyhow::Result;
use serde_json::Value;
use serde_json::json;
use tokio::sync::Notify;
#[derive(Clone, Default)]
pub(crate) struct JsonLogCapture {
lines: Arc<Mutex<Vec<String>>>,
updated: Arc<Notify>,
}
impl JsonLogCapture {
pub(crate) fn record(&self, line: String) {
self.lines
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.push(line);
self.updated.notify_one();
}
pub(crate) async fn wait_for_event(&self, event_name: &str) -> Result<Value> {
let mut events = self.wait_for_events(event_name, /*count*/ 1).await?;
Ok(events.remove(0))
}
pub(crate) async fn wait_for_events(
&self,
event_name: &str,
count: usize,
) -> Result<Vec<Value>> {
let result = tokio::time::timeout(Duration::from_secs(10), async {
loop {
let updated = self.updated.notified();
let events = self
.events()?
.into_iter()
.filter(|event| event["fields"]["event.name"].as_str() == Some(event_name))
.collect::<Vec<_>>();
if events.len() >= count {
return Ok(events);
}
updated.await;
}
})
.await;
match result {
Ok(result) => result,
Err(_) => {
let lines = self
.lines
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.join("\n");
anyhow::bail!(
"timed out waiting for {count} JSON log event(s) named `{event_name}`; captured stderr:\n{lines}"
)
}
}
}
pub(crate) fn events(&self) -> Result<Vec<Value>> {
let lines = self
.lines
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
json_log_events(lines.iter().map(String::as_str))
}
}
pub fn app_server_json_shutdown_event(
binary: &str,
@@ -31,25 +99,39 @@ pub fn app_server_json_shutdown_event(
let stderr = String::from_utf8(output.stderr)?;
anyhow::ensure!(output.status.success(), "app-server failed: {stderr}");
let events = stderr
.lines()
.filter(|line| !line.is_empty())
.map(serde_json::from_str::<Value>)
.collect::<serde_json::Result<Vec<_>>>()
.with_context(|| format!("app-server stderr was not JSONL: {stderr}"))?;
let events = json_log_events(stderr.lines())
.with_context(|| format!("app-server stderr was not valid JSONL: {stderr}"))?;
let event = events
.iter()
.find(|event| event["fields"]["message"] == "processor task exited")
.context("missing INFO shutdown event in app-server JSON logs")?;
let timestamp = event["timestamp"]
.as_str()
.context("shutdown event did not include a timestamp")?;
chrono::DateTime::parse_from_rfc3339(timestamp)
.with_context(|| format!("shutdown event timestamp was not RFC 3339: {timestamp}"))?;
Ok(json!({
"level": event["level"],
"fields": event["fields"],
"target": event["target"],
}))
}
fn json_log_events<'a>(lines: impl IntoIterator<Item = &'a str>) -> Result<Vec<Value>> {
lines
.into_iter()
.filter(|line| !line.is_empty())
.map(|line| {
let event = serde_json::from_str::<Value>(line)
.with_context(|| format!("log line was not JSON: {line}"))?;
anyhow::ensure!(
event["level"].is_string()
&& event["fields"].is_object()
&& event["target"].is_string(),
"JSON log event did not include level, fields, and target: {line}"
);
let timestamp = event["timestamp"]
.as_str()
.with_context(|| format!("JSON log event did not include a timestamp: {line}"))?;
chrono::DateTime::parse_from_rfc3339(timestamp).with_context(|| {
format!("JSON log event timestamp was not RFC 3339: {timestamp}")
})?;
Ok(event)
})
.collect()
}
@@ -123,6 +123,8 @@ use core_test_support::test_codex::TestEnv;
use core_test_support::test_codex::test_env;
use tokio::process::Command;
use crate::json_logging::JsonLogCapture;
pub struct TestAppServer {
next_request_id: AtomicI64,
/// Retain this child process until the client is dropped. The Tokio runtime
@@ -134,6 +136,7 @@ pub struct TestAppServer {
stdout: BufReader<ChildStdout>,
pending_messages: VecDeque<JSONRPCMessage>,
auto_env: Option<TestEnv>,
json_logs: JsonLogCapture,
}
pub const DEFAULT_CLIENT_NAME: &str = "codex-app-server-tests";
@@ -160,6 +163,31 @@ impl TestAppServer {
/// URL-based configuration, this helper rejects a `codex_home` containing
/// that file.
pub async fn new_with_auto_env(codex_home: &Path) -> anyhow::Result<Self> {
Self::new_with_auto_env_and_env(codex_home, &[]).await
}
/// Starts an auto-environment app server that emits JSON logs.
///
/// `rust_log` is the value to use for the `RUST_LOG` environment variable.
pub async fn new_with_auto_env_and_json_logging(
codex_home: &Path,
rust_log: impl Into<String>,
) -> anyhow::Result<Self> {
let rust_log = rust_log.into();
Self::new_with_auto_env_and_env(
codex_home,
&[
("LOG_FORMAT", Some("json")),
("RUST_LOG", Some(rust_log.as_str())),
],
)
.await
}
async fn new_with_auto_env_and_env(
codex_home: &Path,
extra_env_overrides: &[(&str, Option<&str>)],
) -> anyhow::Result<Self> {
let environments_toml = codex_home.join("environments.toml");
ensure!(
!environments_toml
@@ -172,7 +200,7 @@ impl TestAppServer {
let auto_env = test_env().await?;
// Noise registry configuration takes precedence over the URL-based
// provider, so clear inherited values to keep the selection hermetic.
let env_overrides = [
let mut env_overrides = vec![
(
CODEX_EXEC_SERVER_URL_ENV_VAR,
auto_env.environment().exec_server_url(),
@@ -182,6 +210,7 @@ impl TestAppServer {
(CODEX_EXEC_SERVER_NOISE_AUTH_TOKEN_ENV_VAR, None),
(CODEX_EXEC_SERVER_NOISE_CHATGPT_ACCOUNT_ID_ENV_VAR, None),
];
env_overrides.extend_from_slice(extra_env_overrides);
let mut app_server = Self::new_with_env(codex_home, &env_overrides).await?;
app_server.auto_env = Some(auto_env);
Ok(app_server)
@@ -202,6 +231,28 @@ impl TestAppServer {
})
}
/// Waits for a JSON stderr event whose structured `event.name` field matches.
pub async fn wait_for_json_log_event(
&self,
event_name: &str,
) -> anyhow::Result<serde_json::Value> {
self.json_logs.wait_for_event(event_name).await
}
/// Waits for the requested number of JSON stderr events with the same `event.name` field.
pub async fn wait_for_json_log_events(
&self,
event_name: &str,
count: usize,
) -> anyhow::Result<Vec<serde_json::Value>> {
self.json_logs.wait_for_events(event_name, count).await
}
/// Returns every stderr line parsed and validated as a JSON log event.
pub fn json_log_events(&self) -> anyhow::Result<Vec<serde_json::Value>> {
self.json_logs.events()
}
pub async fn new_without_managed_config(codex_home: &Path) -> anyhow::Result<Self> {
Self::new_with_env(codex_home, &[(DISABLE_MANAGED_CONFIG_ENV_VAR, Some("1"))]).await
}
@@ -322,10 +373,13 @@ impl TestAppServer {
// Forward child's stderr to our stderr so failures are visible even
// when stdout/stderr are captured by the test harness.
let json_logs = JsonLogCapture::default();
if let Some(stderr) = process.stderr.take() {
let json_logs = json_logs.clone();
let mut stderr_reader = BufReader::new(stderr).lines();
tokio::spawn(async move {
while let Ok(Some(line)) = stderr_reader.next_line().await {
json_logs.record(line.clone());
eprintln!("[mcp stderr] {line}");
}
});
@@ -337,6 +391,7 @@ impl TestAppServer {
stdout,
pending_messages: VecDeque::new(),
auto_env: None,
json_logs,
})
}
+145
View File
@@ -1,8 +1,30 @@
use anyhow::Context;
use anyhow::Result;
use app_test_support::TestAppServer;
use app_test_support::app_server_json_shutdown_event;
use app_test_support::create_exec_command_sse_response;
use app_test_support::create_final_assistant_message_sse_response;
use app_test_support::create_mock_responses_server_sequence;
use app_test_support::to_response;
use app_test_support::write_mock_responses_config_toml;
use codex_app_server_protocol::JSONRPCResponse;
use codex_app_server_protocol::RequestId;
use codex_app_server_protocol::ThreadStartParams;
use codex_app_server_protocol::ThreadStartResponse;
use codex_app_server_protocol::TurnStartParams;
use codex_app_server_protocol::TurnStartResponse;
use codex_app_server_protocol::UserInput;
use codex_features::Feature;
use core_test_support::skip_if_no_network;
use pretty_assertions::assert_eq;
use serde_json::Value;
use serde_json::json;
use std::collections::BTreeMap;
use tempfile::TempDir;
use tokio::time::Duration;
use tokio::time::timeout;
const READ_TIMEOUT: Duration = Duration::from_secs(10);
#[test]
fn standalone_app_server_emits_json_info_events() -> Result<()> {
@@ -25,3 +47,126 @@ fn standalone_app_server_emits_json_info_events() -> Result<()> {
Ok(())
}
#[tokio::test]
async fn app_server_emits_structured_tool_call_timing_event() -> Result<()> {
skip_if_no_network!(Ok(()));
let server = create_mock_responses_server_sequence(vec![
create_exec_command_sse_response("exec-call-1")?,
create_final_assistant_message_sse_response("done")?,
])
.await;
let codex_home = TempDir::new()?;
write_mock_responses_config_toml(
codex_home.path(),
&server.uri(),
&BTreeMap::from([(Feature::UnifiedExec, true)]),
/*auto_compact_limit*/ 100_000,
/*requires_openai_auth*/ None,
"mock_provider",
"compact",
)?;
let mut app_server = TestAppServer::new_with_auto_env_and_json_logging(
codex_home.path(),
"warn,codex_core::tools::parallel=info",
)
.await?;
timeout(READ_TIMEOUT, app_server.initialize()).await??;
let thread_start_id = app_server
.send_thread_start_request_with_auto_env(ThreadStartParams {
model: Some("mock-model".to_string()),
..Default::default()
})
.await?;
let thread_start_response: JSONRPCResponse = timeout(
READ_TIMEOUT,
app_server.read_stream_until_response_message(RequestId::Integer(thread_start_id)),
)
.await??;
let ThreadStartResponse { thread, .. } = to_response(thread_start_response)?;
let turn_start_id = app_server
.send_turn_start_request(TurnStartParams {
thread_id: thread.id.clone(),
input: vec![UserInput::Text {
text: "run a command".to_string(),
text_elements: Vec::new(),
}],
..Default::default()
})
.await?;
let turn_start_response: JSONRPCResponse = timeout(
READ_TIMEOUT,
app_server.read_stream_until_response_message(RequestId::Integer(turn_start_id)),
)
.await??;
let TurnStartResponse { turn } = to_response(turn_start_response)?;
timeout(
READ_TIMEOUT,
app_server.read_stream_until_notification_message("turn/completed"),
)
.await??;
let mut tool_call = app_server
.wait_for_json_log_event("codex.tool_call")
.await?;
let tool_call_object = tool_call
.as_object_mut()
.context("tool call log event must be an object")?;
// JsonLogCapture already validates the timestamp as RFC 3339.
tool_call_object
.remove("timestamp")
.context("tool call log event must include a timestamp")?;
let fields = tool_call_object
.get_mut("fields")
.and_then(Value::as_object_mut)
.context("tool call log event fields must be an object")?;
let trace_id = fields
.remove("trace_id")
.context("tool call log event must include trace_id")?;
anyhow::ensure!(trace_id.is_string(), "trace_id must be a string");
let dispatch_duration_ms = fields
.remove("dispatch_duration_ms")
.and_then(|duration| duration.as_u64())
.context("dispatch_duration_ms must be a nonnegative integer")?;
let handler_duration_ms = fields
.remove("handler_duration_ms")
.and_then(|duration| duration.as_u64())
.context("handler_duration_ms must be a nonnegative integer")?;
let total_duration_ms = fields
.remove("total_duration_ms")
.and_then(|duration| duration.as_u64())
.context("total_duration_ms must be a nonnegative integer")?;
let accounted_duration_ms = dispatch_duration_ms
.checked_add(handler_duration_ms)
.context("dispatch and handler durations must not overflow")?;
anyhow::ensure!(
total_duration_ms >= accounted_duration_ms
&& total_duration_ms - accounted_duration_ms <= 1,
"dispatch and handler durations must account for total duration within integer truncation"
);
assert_eq!(
tool_call,
json!({
"level": "INFO",
"fields": {
"message": "tool call completed",
"event.name": "codex.tool_call",
"conversation.id": thread.id,
"turn_id": turn.id,
"tool_name": "exec_command",
"call_id": "exec-call-1",
"tool_source": "direct",
"execution_started": true,
},
"target": "codex_core::tools::parallel",
})
);
Ok(())
}
@@ -7,6 +7,7 @@ use app_test_support::to_response;
use base64::Engine;
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use codex_app_server_protocol::ClientInfo;
use codex_app_server_protocol::ConfigWarningNotification;
use codex_app_server_protocol::InitializeParams;
use codex_app_server_protocol::JSONRPCError;
use codex_app_server_protocol::JSONRPCMessage;
@@ -18,6 +19,8 @@ use codex_app_server_protocol::ThreadLoadedListParams;
use codex_app_server_protocol::ThreadLoadedListResponse;
use codex_app_server_protocol::ThreadStartParams;
use codex_app_server_protocol::ThreadStartResponse;
use codex_core::config::set_project_trust_level;
use codex_protocol::config_types::TrustLevel;
use futures::SinkExt;
use futures::StreamExt;
use hmac::Hmac;
@@ -104,6 +107,106 @@ async fn websocket_transport_routes_per_connection_handshake_and_responses() ->
Ok(())
}
#[tokio::test]
async fn thread_start_routes_project_exec_policy_warning_to_requester() -> Result<()> {
let server = create_mock_responses_server_sequence_unchecked(Vec::new()).await;
let codex_home = TempDir::new()?;
create_config_toml(codex_home.path(), &server.uri(), "never")?;
let project = TempDir::new()?;
std::fs::create_dir(project.path().join(".git"))?;
let rules_dir = project.path().join(".codex/rules");
std::fs::create_dir_all(&rules_dir)?;
let rules_path = rules_dir.join("broken.rules");
std::fs::write(&rules_path, "prefix_rule(")?;
set_project_trust_level(codex_home.path(), project.path(), TrustLevel::Trusted)?;
let (mut process, bind_addr) = spawn_websocket_server(codex_home.path()).await?;
let mut requester = connect_websocket(bind_addr).await?;
let mut other_client = connect_websocket(bind_addr).await?;
send_initialize_request(&mut requester, /*id*/ 1, "requester").await?;
read_response_for_id(&mut requester, /*id*/ 1).await?;
send_initialize_request(&mut other_client, /*id*/ 2, "other_client").await?;
read_response_for_id(&mut other_client, /*id*/ 2).await?;
send_request(
&mut requester,
"thread/start",
/*id*/ 3,
Some(serde_json::to_value(ThreadStartParams {
cwd: Some(project.path().display().to_string()),
model: Some("mock-model".to_string()),
..Default::default()
})?),
)
.await?;
let target_id = RequestId::Integer(3);
let warning_summary = "Error parsing rules; custom rules not applied.";
let is_exec_policy_warning = |notification: &JSONRPCNotification| {
notification.method == "configWarning"
&& notification
.params
.as_ref()
.and_then(|params| params.get("summary"))
.and_then(serde_json::Value::as_str)
== Some(warning_summary)
};
let mut response = None;
let mut warning = None;
while response.is_none() || warning.is_none() {
match read_jsonrpc_message(&mut requester).await? {
JSONRPCMessage::Response(candidate) if candidate.id == target_id => {
response = Some(candidate);
}
JSONRPCMessage::Notification(candidate) if is_exec_policy_warning(&candidate) => {
warning = Some(candidate);
}
_ => {}
}
}
let _: ThreadStartResponse = to_response(response.context("missing thread/start response")?)?;
let warning: ConfigWarningNotification = serde_json::from_value(
warning
.context("missing exec-policy configWarning")?
.params
.context("configWarning should include params")?,
)?;
assert_eq!(
warning
.path
.as_deref()
.map(Path::new)
.and_then(Path::file_name),
Some(std::ffi::OsStr::new("broken.rules"))
);
match timeout(Duration::from_millis(250), async {
loop {
let message = read_jsonrpc_message(&mut other_client).await?;
if let JSONRPCMessage::Notification(notification) = message
&& is_exec_policy_warning(&notification)
{
return Ok::<_, anyhow::Error>(notification);
}
}
})
.await
{
Ok(Ok(_)) => bail!("exec-policy configWarning leaked to another connection"),
Ok(Err(err)) => return Err(err),
Err(_) => {}
}
process
.kill()
.await
.context("failed to stop websocket app-server process")?;
Ok(())
}
#[tokio::test]
async fn websocket_transport_serves_health_endpoints_on_same_listener() -> Result<()> {
let server = create_mock_responses_server_sequence_unchecked(Vec::new()).await;
@@ -452,6 +452,7 @@ async fn plugin_list_keeps_valid_marketplaces_when_another_marketplace_fails_to_
plugins: vec![PluginSummary {
id: "valid-plugin@valid-marketplace".to_string(),
remote_plugin_id: None,
version: None,
local_version: None,
name: "valid-plugin".to_string(),
share_context: None,
@@ -743,6 +744,7 @@ async fn plugin_list_uses_alternate_discoverable_manifest_and_keeps_undiscoverab
PluginSummary {
id: "valid-plugin@alternate-marketplace".to_string(),
remote_plugin_id: None,
version: None,
local_version: None,
name: "valid-plugin".to_string(),
share_context: None,
@@ -780,6 +782,7 @@ async fn plugin_list_uses_alternate_discoverable_manifest_and_keeps_undiscoverab
PluginSummary {
id: "missing-plugin@alternate-marketplace".to_string(),
remote_plugin_id: None,
version: None,
local_version: None,
name: "missing-plugin".to_string(),
share_context: None,
@@ -1683,6 +1686,7 @@ async fn plugin_list_includes_remote_marketplaces_when_remote_plugin_enabled() -
"authentication_policy": "ON_USE",
"status": "ENABLED",
"release": {
"version": "1.2.3",
"display_name": "Linear",
"description": "Track work in Linear",
"app_ids": [],
@@ -1831,6 +1835,10 @@ async fn plugin_list_includes_remote_marketplaces_when_remote_plugin_enabled() -
);
assert_eq!(remote_marketplace.plugins[0].name, "linear");
assert_eq!(remote_marketplace.plugins[0].source, PluginSource::Remote);
assert_eq!(
remote_marketplace.plugins[0].version.as_deref(),
Some("1.2.3")
);
assert_eq!(
remote_marketplace.plugins[0].local_version.as_deref(),
Some("1.2.3")
@@ -2030,6 +2038,7 @@ async fn plugin_list_includes_openai_curated_remote_collection_when_remote_plugi
"authentication_policy": "ON_USE",
"status": "ENABLED",
"release": {
"version": "1.2.3",
"display_name": "Linear",
"description": "Track work in Linear",
"app_ids": [],
@@ -2090,6 +2099,7 @@ async fn plugin_list_includes_openai_curated_remote_collection_when_remote_plugi
);
assert_eq!(plugin.name, "linear");
assert_eq!(plugin.source, PluginSource::Remote);
assert_eq!(plugin.version.as_deref(), Some("1.2.3"));
assert_eq!(plugin.installed, false);
assert_eq!(plugin.enabled, false);
@@ -2492,16 +2502,25 @@ plugin_sharing = true
marketplace
.plugins
.iter()
.map(|plugin| (plugin.id.clone(), plugin.installed, plugin.enabled))
.map(|plugin| {
(
plugin.id.clone(),
plugin.version.clone(),
plugin.installed,
plugin.enabled,
)
})
.collect::<Vec<_>>(),
vec![
(
"shared-linear@workspace-shared-with-me".to_string(),
Some("1.2.3".to_string()),
true,
true
),
(
"unlisted-linear@workspace-shared-with-me".to_string(),
Some("1.2.3".to_string()),
true,
false
)
@@ -171,6 +171,7 @@ async fn plugin_share_save_uploads_local_plugin() -> Result<()> {
plugin: PluginSummary {
id: "demo-plugin@workspace-shared-with-me".to_string(),
remote_plugin_id: Some("plugins_123".to_string()),
version: Some("0.1.0".to_string()),
local_version: Some("0.1.0".to_string()),
name: "demo-plugin".to_string(),
share_context: Some(expected_share_context("plugins_123")),
@@ -574,6 +575,7 @@ async fn plugin_share_list_returns_created_workspace_plugins() -> Result<()> {
plugin: PluginSummary {
id: "demo-plugin@workspace-shared-with-me".to_string(),
remote_plugin_id: Some("plugins_123".to_string()),
version: Some("0.1.0".to_string()),
local_version: Some("0.1.0".to_string()),
name: "demo-plugin".to_string(),
share_context: Some(expected_share_context("plugins_123")),
@@ -1174,6 +1176,7 @@ async fn plugin_share_delete_removes_created_workspace_plugin() -> Result<()> {
plugin: PluginSummary {
id: "demo-plugin@workspace-shared-with-me".to_string(),
remote_plugin_id: Some("plugins_123".to_string()),
version: Some("0.1.0".to_string()),
local_version: Some("0.1.0".to_string()),
name: "demo-plugin".to_string(),
share_context: Some(expected_share_context("plugins_123")),
@@ -44,6 +44,7 @@ async fn consume_rate_limit_reset_credit_requires_chatgpt_auth() -> Result<()> {
.send_consume_account_rate_limit_reset_credit_request(
ConsumeAccountRateLimitResetCreditParams {
idempotency_key: "request-1".to_string(),
credit_id: None,
},
)
.await?;
@@ -120,6 +121,44 @@ async fn consume_account_rate_limit_reset_credit_maps_backend_outcomes() -> Resu
Ok(())
}
#[tokio::test]
async fn consume_account_rate_limit_reset_credit_forwards_selected_credit_id() -> Result<()> {
let (codex_home, server) = chatgpt_test_context().await?;
Mock::given(method("POST"))
.and(path("/api/codex/rate-limit-reset-credits/consume"))
.and(header("authorization", "Bearer chatgpt-token"))
.and(header("chatgpt-account-id", "account-123"))
.and(body_json(json!({
"redeem_request_id": "request-selected",
"credit_id": "credit-123",
})))
.respond_with(
ResponseTemplate::new(200)
.set_body_json(json!({ "code": "reset", "windows_reset": 2 })),
)
.expect(1)
.mount(&server)
.await;
let mut mcp = initialized_app_server(codex_home.path()).await?;
let request_id = mcp
.send_consume_account_rate_limit_reset_credit_request(
ConsumeAccountRateLimitResetCreditParams {
idempotency_key: "request-selected".to_string(),
credit_id: Some("credit-123".to_string()),
},
)
.await?;
assert_eq!(
read_response::<ConsumeAccountRateLimitResetCreditResponse>(&mut mcp, request_id).await?,
ConsumeAccountRateLimitResetCreditResponse {
outcome: ConsumeAccountRateLimitResetCreditOutcome::Reset,
}
);
Ok(())
}
#[tokio::test]
async fn consume_account_rate_limit_reset_credit_rejects_empty_idempotency_key() -> Result<()> {
let (codex_home, _server) = chatgpt_test_context().await?;
@@ -129,6 +168,7 @@ async fn consume_account_rate_limit_reset_credit_rejects_empty_idempotency_key()
.send_consume_account_rate_limit_reset_credit_request(
ConsumeAccountRateLimitResetCreditParams {
idempotency_key: String::new(),
credit_id: None,
},
)
.await?;
@@ -139,6 +179,26 @@ async fn consume_account_rate_limit_reset_credit_rejects_empty_idempotency_key()
Ok(())
}
#[tokio::test]
async fn consume_account_rate_limit_reset_credit_rejects_empty_credit_id() -> Result<()> {
let (codex_home, _server) = chatgpt_test_context().await?;
let mut mcp = initialized_app_server(codex_home.path()).await?;
let request_id = mcp
.send_consume_account_rate_limit_reset_credit_request(
ConsumeAccountRateLimitResetCreditParams {
idempotency_key: "request-1".to_string(),
credit_id: Some(String::new()),
},
)
.await?;
let error = read_error_response(&mut mcp, request_id).await?;
assert_eq!(error.error.code, INVALID_REQUEST_ERROR_CODE);
assert_eq!(error.error.message, "creditId must not be empty");
Ok(())
}
#[tokio::test]
async fn consume_account_rate_limit_reset_credit_surfaces_backend_failure() -> Result<()> {
let (codex_home, server) = chatgpt_test_context().await?;
@@ -244,6 +304,7 @@ async fn send_consume_reset_credit(mcp: &mut TestAppServer, idempotency_key: &st
mcp.send_consume_account_rate_limit_reset_credit_request(
ConsumeAccountRateLimitResetCreditParams {
idempotency_key: idempotency_key.to_string(),
credit_id: None,
},
)
.await
@@ -10,7 +10,10 @@ use codex_app_server_protocol::JSONRPCError;
use codex_app_server_protocol::JSONRPCResponse;
use codex_app_server_protocol::LoginAccountResponse;
use codex_app_server_protocol::RateLimitReachedType;
use codex_app_server_protocol::RateLimitResetCredit;
use codex_app_server_protocol::RateLimitResetCreditStatus;
use codex_app_server_protocol::RateLimitResetCreditsSummary;
use codex_app_server_protocol::RateLimitResetType;
use codex_app_server_protocol::RateLimitSnapshot;
use codex_app_server_protocol::RateLimitWindow;
use codex_app_server_protocol::RequestId;
@@ -109,6 +112,16 @@ async fn get_account_rate_limits_returns_snapshot() -> Result<()> {
let secondary_reset_timestamp = chrono::DateTime::parse_from_rfc3339("2025-01-01T01:00:00Z")
.expect("parse secondary reset timestamp")
.timestamp();
let reset_credit_granted_at = chrono::DateTime::parse_from_rfc3339("2026-06-17T00:00:00Z")
.expect("parse reset credit grant timestamp")
.timestamp();
let reset_credit_expires_at = chrono::DateTime::parse_from_rfc3339("2026-07-17T00:00:00Z")
.expect("parse reset credit expiry timestamp")
.timestamp();
let second_reset_credit_granted_at =
chrono::DateTime::parse_from_rfc3339("2026-06-18T00:00:00Z")
.expect("parse second reset credit grant timestamp")
.timestamp();
let response_body = json!({
"plan_type": "pro",
"rate_limit": {
@@ -170,6 +183,35 @@ async fn get_account_rate_limits_returns_snapshot() -> Result<()> {
.expect(1)
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path("/api/codex/rate-limit-reset-credits"))
.and(header("authorization", "Bearer chatgpt-token"))
.and(header("chatgpt-account-id", "account-123"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"credits": [
{
"id": "credit-1",
"reset_type": "codex_rate_limits",
"status": "available",
"granted_at": "2026-06-17T00:00:00Z",
"expires_at": "2026-07-17T00:00:00Z",
"title": "Full reset (Weekly + 5 hr)",
"description": "Ready to redeem"
},
{
"id": "credit-2",
"reset_type": "future_reset_type",
"status": "future_status",
"granted_at": "2026-06-18T00:00:00Z",
"expires_at": null
}
],
"available_count": 2,
"total_earned_count": 4
})))
.expect(1)
.mount(&server)
.await;
let mut mcp =
TestAppServer::new_with_env(codex_home.path(), &[("OPENAI_API_KEY", None)]).await?;
@@ -260,13 +302,98 @@ async fn get_account_rate_limits_returns_snapshot() -> Result<()> {
.into_iter()
.collect(),
),
rate_limit_reset_credits: Some(RateLimitResetCreditsSummary { available_count: 3 }),
rate_limit_reset_credits: Some(RateLimitResetCreditsSummary {
available_count: 2,
credits: Some(vec![
RateLimitResetCredit {
id: "credit-1".to_string(),
reset_type: RateLimitResetType::CodexRateLimits,
status: RateLimitResetCreditStatus::Available,
granted_at: reset_credit_granted_at,
expires_at: Some(reset_credit_expires_at),
title: Some("Full reset (Weekly + 5 hr)".to_string()),
description: Some("Ready to redeem".to_string()),
},
RateLimitResetCredit {
id: "credit-2".to_string(),
reset_type: RateLimitResetType::Unknown,
status: RateLimitResetCreditStatus::Unknown,
granted_at: second_reset_credit_granted_at,
expires_at: None,
title: None,
description: None,
},
]),
}),
};
assert_eq!(received, expected);
Ok(())
}
#[tokio::test]
async fn get_account_rate_limits_preserves_count_when_reset_credit_details_fail() -> Result<()> {
let codex_home = TempDir::new()?;
write_chatgpt_auth(
codex_home.path(),
ChatGptAuthFixture::new("chatgpt-token")
.account_id("account-123")
.plan_type("pro"),
AuthCredentialsStoreMode::File,
)?;
let server = MockServer::start().await;
write_chatgpt_base_url(codex_home.path(), &server.uri())?;
Mock::given(method("GET"))
.and(path("/api/codex/usage"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"plan_type": "pro",
"rate_limit": {
"allowed": true,
"limit_reached": false,
"primary_window": {
"used_percent": 42,
"limit_window_seconds": 3600,
"reset_after_seconds": 120,
"reset_at": 1735689720
}
},
"rate_limit_reset_credits": { "available_count": 3 }
})))
.expect(1)
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path("/api/codex/rate-limit-reset-credits"))
.respond_with(ResponseTemplate::new(500).set_body_string("boom"))
.expect(1)
.mount(&server)
.await;
let mut mcp =
TestAppServer::new_with_env(codex_home.path(), &[("OPENAI_API_KEY", None)]).await?;
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
let request_id = mcp.send_get_account_rate_limits_request().await?;
let response: JSONRPCResponse = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
)
.await??;
let received: GetAccountRateLimitsResponse = to_response(response)?;
assert_eq!(
received.rate_limit_reset_credits,
Some(RateLimitResetCreditsSummary {
available_count: 3,
credits: None,
})
);
Ok(())
}
#[tokio::test]
async fn send_add_credits_nudge_email_requires_auth() -> Result<()> {
let codex_home = TempDir::new()?;
@@ -28,6 +28,7 @@ use codex_exec_server::LOCAL_ENVIRONMENT_ID;
use codex_protocol::config_types::CollaborationMode;
use codex_protocol::config_types::ModeKind;
use codex_protocol::config_types::Settings;
use codex_protocol::protocol::PLUGINS_INSTRUCTIONS_OPEN_TAG;
use codex_utils_absolute_path::AbsolutePathBuf;
use codex_utils_path_uri::PathUri;
use core_test_support::process::wait_for_pid_file;
@@ -235,7 +236,9 @@ async fn selected_capability_stack_tracks_environment_availability_and_resume()
for request in &requests[1..4] {
assert_selected_skill_is_injected(request, /*expected_count*/ 1);
assert_selected_plugin_tools(request);
assert_plugin_guidance_count(request, /*expected_count*/ 1);
}
assert_plugin_guidance_count(&requests[4], /*expected_count*/ 1);
assert_selected_skill_is_injected(&requests[5], /*expected_count*/ 2);
assert_selected_plugin_tools(&requests[5]);
let output = requests[2].function_call_output(MCP_CALL_ID);
@@ -398,7 +401,9 @@ async fn selected_capabilities_become_available_between_samples_in_one_turn() ->
assert_eq!(3, requests.len());
assert_selected_skill_catalog_available(&requests[1]);
assert_selected_plugin_tools(&requests[1]);
assert_plugin_guidance_count(&requests[1], /*expected_count*/ 1);
assert_selected_plugin_tools(&requests[2]);
assert_plugin_guidance_count(&requests[2], /*expected_count*/ 1);
let output = requests[2].function_call_output(MCP_CALL_ID);
let output = output["output"]
.as_str()
@@ -537,6 +542,7 @@ fn assert_selected_capabilities_absent(request: &ResponsesRequest) {
.all(|text| !text.contains(SKILL_DESCRIPTION))
);
assert_selected_plugin_tools_absent(request);
assert_plugin_guidance_count(request, /*expected_count*/ 0);
}
fn assert_selected_plugin_tools_absent(request: &ResponsesRequest) {
@@ -555,6 +561,17 @@ fn assert_selected_plugin_tools_absent(request: &ResponsesRequest) {
);
}
fn assert_plugin_guidance_count(request: &ResponsesRequest, expected_count: usize) {
assert_eq!(
expected_count,
request
.message_input_texts("developer")
.into_iter()
.filter(|text| text.starts_with(PLUGINS_INSTRUCTIONS_OPEN_TAG))
.count()
);
}
fn assert_selected_skill_is_injected(request: &ResponsesRequest, expected_count: usize) {
assert_selected_skill_catalog_available(request);
@@ -7,14 +7,18 @@ use app_test_support::create_mock_responses_server_repeating_assistant;
use app_test_support::to_response;
use app_test_support::write_chatgpt_auth;
use codex_app_server_protocol::AskForApproval;
use codex_app_server_protocol::ConfigWarningNotification;
use codex_app_server_protocol::JSONRPCError;
use codex_app_server_protocol::JSONRPCMessage;
use codex_app_server_protocol::JSONRPCNotification;
use codex_app_server_protocol::JSONRPCResponse;
use codex_app_server_protocol::McpServerStartupState;
use codex_app_server_protocol::McpServerStatusUpdatedNotification;
use codex_app_server_protocol::RequestId;
use codex_app_server_protocol::SandboxMode;
use codex_app_server_protocol::ServerNotification;
use codex_app_server_protocol::TextPosition;
use codex_app_server_protocol::TextRange;
use codex_app_server_protocol::ThreadHistoryMode;
use codex_app_server_protocol::ThreadSource;
use codex_app_server_protocol::ThreadStartParams;
@@ -54,6 +58,17 @@ use super::analytics::wait_for_analytics_payload;
const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
const INVALID_REQUEST_ERROR_CODE: i64 = -32600;
const EXEC_POLICY_PARSE_WARNING_SUMMARY: &str = "Error parsing rules; custom rules not applied.";
fn is_exec_policy_config_warning(notification: &JSONRPCNotification) -> bool {
notification.method == "configWarning"
&& notification
.params
.as_ref()
.and_then(|params| params.get("summary"))
.and_then(Value::as_str)
== Some(EXEC_POLICY_PARSE_WARNING_SUMMARY)
}
async fn start_thread_with_model(
mcp: &mut TestAppServer,
@@ -104,6 +119,124 @@ model = "gpt-5.4-mini"
Ok(())
}
#[tokio::test]
async fn thread_start_warns_for_exec_policy_parse_failure_after_initialize() -> Result<()> {
let codex_home = TempDir::new()?;
let mut mcp = TestAppServer::new_with_auto_env(codex_home.path()).await?;
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
let rules_dir = codex_home.path().join("rules");
std::fs::create_dir_all(&rules_dir)?;
let rules_path = rules_dir.join("broken.rules");
std::fs::write(&rules_path, "prefix_rule(")?;
let rules_path = std::fs::canonicalize(rules_path)?;
let request_id = mcp
.send_thread_start_request_with_auto_env(ThreadStartParams::default())
.await?;
let response = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
)
.await??;
let _: ThreadStartResponse = to_response(response)?;
let notification = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_matching_notification(
"exec-policy configWarning",
is_exec_policy_config_warning,
),
)
.await??;
let notification: ServerNotification = notification.try_into()?;
let ServerNotification::ConfigWarning(warning) = notification else {
anyhow::bail!("unexpected notification variant");
};
let ConfigWarningNotification {
summary,
details,
path,
range,
} = warning;
assert_eq!(
(summary, range),
(
"Error parsing rules; custom rules not applied.".to_string(),
Some(TextRange {
start: TextPosition {
line: 1,
column: 13,
},
end: TextPosition {
line: 1,
column: 13,
},
}),
)
);
let path = path.context("warning should include a path")?;
assert_eq!(
normalize_path_for_comparison(path),
normalize_path_for_comparison(&rules_path)
);
let details = details.context("warning should include details")?;
assert!(
details.contains("failed to parse rules file") && details.contains("broken.rules"),
"unexpected warning details: {details}"
);
assert!(
details.contains("Parse error"),
"unexpected warning details: {details}"
);
Ok(())
}
#[tokio::test]
async fn thread_start_does_not_repeat_initialize_exec_policy_warning() -> Result<()> {
let codex_home = TempDir::new()?;
let rules_dir = codex_home.path().join("rules");
std::fs::create_dir_all(&rules_dir)?;
std::fs::write(rules_dir.join("broken.rules"), "prefix_rule(")?;
let mut mcp = TestAppServer::new_with_auto_env(codex_home.path()).await?;
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_matching_notification(
"initialize exec-policy configWarning",
is_exec_policy_config_warning,
),
)
.await??;
let request_id = mcp
.send_thread_start_request_with_auto_env(ThreadStartParams::default())
.await?;
let response = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
)
.await??;
let _: ThreadStartResponse = to_response(response)?;
let duplicate_warning = timeout(
std::time::Duration::from_millis(250),
mcp.read_stream_until_matching_notification(
"duplicate exec-policy configWarning",
is_exec_policy_config_warning,
),
)
.await;
assert!(
duplicate_warning.is_err(),
"thread/start repeated the initialize exec-policy warning"
);
Ok(())
}
#[tokio::test]
async fn thread_start_provider_model_fallback_uses_bedrock_static_catalog() -> Result<()> {
let codex_home = TempDir::new()?;
@@ -3,6 +3,7 @@
use super::Client;
use super::PathStyle;
use crate::types::ConsumeRateLimitResetCreditResponse;
use crate::types::RateLimitResetCreditsDetails;
use crate::types::RateLimitStatusWithResetCredits;
use crate::types::RateLimitsWithResetCredits;
use anyhow::Result;
@@ -13,6 +14,8 @@ use serde::Serialize;
#[derive(Serialize)]
struct ConsumeRateLimitResetCreditRequest<'a> {
redeem_request_id: &'a str,
#[serde(skip_serializing_if = "Option::is_none")]
credit_id: Option<&'a str>,
}
impl Client {
@@ -31,9 +34,34 @@ impl Client {
self.decode_json(&url, &ct, &body)
}
pub async fn list_rate_limit_reset_credits(&self) -> Result<RateLimitResetCreditsDetails> {
let url = self.rate_limit_reset_credits_url();
let req = self.http.get(&url).headers(self.headers());
let (body, ct) = self.exec_request(req, "GET", &url).await?;
self.decode_json(&url, &ct, &body)
}
pub async fn consume_rate_limit_reset_credit(
&self,
redeem_request_id: &str,
) -> Result<ConsumeRateLimitResetCreditResponse> {
self.consume_rate_limit_reset_credit_request(redeem_request_id, /*credit_id*/ None)
.await
}
pub async fn consume_rate_limit_reset_credit_by_id(
&self,
redeem_request_id: &str,
credit_id: &str,
) -> Result<ConsumeRateLimitResetCreditResponse> {
self.consume_rate_limit_reset_credit_request(redeem_request_id, Some(credit_id))
.await
}
async fn consume_rate_limit_reset_credit_request(
&self,
redeem_request_id: &str,
credit_id: Option<&str>,
) -> Result<ConsumeRateLimitResetCreditResponse> {
let url = self.consume_rate_limit_reset_credit_url();
let req = self
@@ -41,7 +69,10 @@ impl Client {
.post(&url)
.headers(self.headers())
.header(CONTENT_TYPE, HeaderValue::from_static("application/json"))
.json(&ConsumeRateLimitResetCreditRequest { redeem_request_id });
.json(&ConsumeRateLimitResetCreditRequest {
redeem_request_id,
credit_id,
});
let (body, ct) = self.exec_request(req, "POST", &url).await?;
self.decode_json(&url, &ct, &body)
}
@@ -53,6 +84,17 @@ impl Client {
}
}
fn rate_limit_reset_credits_url(&self) -> String {
match self.path_style {
PathStyle::CodexApi => {
format!("{}/api/codex/rate-limit-reset-credits", self.base_url)
}
PathStyle::ChatGptApi => {
format!("{}/wham/rate-limit-reset-credits", self.base_url)
}
}
}
fn consume_rate_limit_reset_credit_url(&self) -> String {
match self.path_style {
PathStyle::CodexApi => {
@@ -1,5 +1,7 @@
use super::*;
use crate::types::ConsumeRateLimitResetCreditCode;
use crate::types::RateLimitResetCreditDetails;
use crate::types::RateLimitResetCreditsDetails;
use crate::types::RateLimitResetCreditsSummary;
use pretty_assertions::assert_eq;
@@ -9,6 +11,10 @@ fn rate_limit_reset_contract_uses_expected_paths_and_payloads() {
test_client("https://example.test", PathStyle::CodexApi).rate_limit_status_url(),
"https://example.test/api/codex/usage"
);
assert_eq!(
test_client("https://example.test", PathStyle::CodexApi).rate_limit_reset_credits_url(),
"https://example.test/api/codex/rate-limit-reset-credits"
);
assert_eq!(
test_client("https://example.test", PathStyle::CodexApi)
.consume_rate_limit_reset_credit_url(),
@@ -19,6 +25,11 @@ fn rate_limit_reset_contract_uses_expected_paths_and_payloads() {
.rate_limit_status_url(),
"https://chatgpt.com/backend-api/wham/usage"
);
assert_eq!(
test_client("https://chatgpt.com/backend-api", PathStyle::ChatGptApi)
.rate_limit_reset_credits_url(),
"https://chatgpt.com/backend-api/wham/rate-limit-reset-credits"
);
assert_eq!(
test_client("https://chatgpt.com/backend-api", PathStyle::ChatGptApi)
.consume_rate_limit_reset_credit_url(),
@@ -28,10 +39,22 @@ fn rate_limit_reset_contract_uses_expected_paths_and_payloads() {
assert_eq!(
serde_json::to_value(ConsumeRateLimitResetCreditRequest {
redeem_request_id: "redeem-123",
credit_id: None,
})
.unwrap(),
serde_json::json!({ "redeem_request_id": "redeem-123" })
);
assert_eq!(
serde_json::to_value(ConsumeRateLimitResetCreditRequest {
redeem_request_id: "redeem-456",
credit_id: Some("credit-123"),
})
.unwrap(),
serde_json::json!({
"redeem_request_id": "redeem-456",
"credit_id": "credit-123",
})
);
let status: RateLimitStatusWithResetCredits = serde_json::from_value(serde_json::json!({
"plan_type": "plus",
@@ -43,6 +66,60 @@ fn rate_limit_reset_contract_uses_expected_paths_and_payloads() {
Some(RateLimitResetCreditsSummary { available_count: 3 })
);
let details: RateLimitResetCreditsDetails = serde_json::from_value(serde_json::json!({
"credits": [
{
"id": "credit-1",
"reset_type": "codex_rate_limits",
"status": "available",
"granted_at": "2026-06-17T00:00:00Z",
"expires_at": "2026-07-17T00:00:00Z",
"redeem_started_at": null,
"redeemed_at": null,
"profile_image_url": "https://example.test/avatar.png",
"profile_user_id": "@friend",
"title": "Full reset (Weekly + 5 hr)",
"description": "Ready to redeem"
},
{
"id": "credit-2",
"reset_type": "codex_rate_limits",
"status": "available",
"granted_at": "2026-06-18T00:00:00Z",
"expires_at": null
}
],
"available_count": 2,
"total_earned_count": 4
}))
.unwrap();
assert_eq!(
details,
RateLimitResetCreditsDetails {
credits: vec![
RateLimitResetCreditDetails {
id: "credit-1".to_string(),
reset_type: "codex_rate_limits".to_string(),
status: "available".to_string(),
granted_at: "2026-06-17T00:00:00Z".to_string(),
expires_at: Some("2026-07-17T00:00:00Z".to_string()),
title: Some("Full reset (Weekly + 5 hr)".to_string()),
description: Some("Ready to redeem".to_string()),
},
RateLimitResetCreditDetails {
id: "credit-2".to_string(),
reset_type: "codex_rate_limits".to_string(),
status: "available".to_string(),
granted_at: "2026-06-18T00:00:00Z".to_string(),
expires_at: None,
title: None,
description: None,
},
],
available_count: 2,
}
);
let response: ConsumeRateLimitResetCreditResponse = serde_json::from_value(serde_json::json!({
"code": "reset",
"credit": { "id": "ignored-by-cli" },
+2
View File
@@ -18,6 +18,8 @@ pub use types::DeliveredConfigToml;
pub use types::DeliveredRequirementsToml;
pub use types::DeliveredTomlFragment;
pub use types::PaginatedListTaskListItem;
pub use types::RateLimitResetCreditDetails;
pub use types::RateLimitResetCreditsDetails;
pub use types::RateLimitResetCreditsSummary;
pub use types::RateLimitsWithResetCredits;
pub use types::TaskListItem;
+17
View File
@@ -23,6 +23,23 @@ pub struct RateLimitResetCreditsSummary {
pub available_count: i64,
}
#[derive(Clone, Debug, Deserialize, PartialEq, Eq)]
pub struct RateLimitResetCreditsDetails {
pub credits: Vec<RateLimitResetCreditDetails>,
pub available_count: i64,
}
#[derive(Clone, Debug, Deserialize, PartialEq, Eq)]
pub struct RateLimitResetCreditDetails {
pub id: String,
pub reset_type: String,
pub status: String,
pub granted_at: String,
pub expires_at: Option<String>,
pub title: Option<String>,
pub description: Option<String>,
}
#[derive(Clone, Debug, PartialEq)]
pub struct RateLimitsWithResetCredits {
pub rate_limits: Vec<RateLimitSnapshot>,
+1 -10
View File
@@ -121,21 +121,12 @@ pub struct SafetyBuffering {
pub reasons: Vec<String>,
#[serde(skip)]
pub show_buffering_ui: bool,
#[serde(skip)]
#[serde(rename = "retry_model")]
pub faster_model: Option<String>,
}
impl SafetyBuffering {
pub(crate) fn with_treatment(mut self, treatment: &SafetyBufferingTreatment) -> Self {
self.show_buffering_ui = treatment.show_buffering_ui;
self.faster_model.clone_from(&treatment.faster_model);
self
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub(crate) struct SafetyBufferingTreatment {
pub show_buffering_ui: bool,
pub faster_model: Option<String>,
}
@@ -689,17 +689,10 @@ async fn run_websocket_response_stream(
{
let _ = turn_state.set(response_turn_state);
}
if let Some(headers) = event.headers.as_ref().and_then(Value::as_object)
&& let Some(treatment) =
treatment_from_headers(&json_headers_to_http_headers(headers))
{
safety_buffering_treatment = treatment;
}
let model_verifications = event.model_verifications();
let turn_moderation_metadata = event.turn_moderation_metadata();
let safety_buffering = event
.safety_buffering()
.map(|buffering| buffering.with_treatment(&safety_buffering_treatment));
let safety_buffering =
safety_buffering_for_event(&event, &mut safety_buffering_treatment);
if event.kind() == "codex.rate_limits" {
if let Some(snapshot) = parse_rate_limit_event(&text) {
let _ = tx_event.send(Ok(ResponseEvent::RateLimits(snapshot))).await;
@@ -774,6 +767,19 @@ async fn run_websocket_response_stream(
Ok(())
}
fn safety_buffering_for_event(
event: &ResponsesStreamEvent,
treatment: &mut SafetyBufferingTreatment,
) -> Option<crate::common::SafetyBuffering> {
if let Some(headers) = event.headers.as_ref().and_then(Value::as_object)
&& let Some(updated_treatment) =
treatment_from_headers(&json_headers_to_http_headers(headers))
{
*treatment = updated_treatment;
}
event.safety_buffering(treatment)
}
async fn send_websocket_request(
ws_stream: &WsStream,
request_text: String,
@@ -1039,4 +1045,75 @@ mod tests {
Some(&HeaderValue::from_static("default-only"))
);
}
#[test]
fn websocket_safety_buffering_uses_event_before_header_fallback() {
let metadata: ResponsesStreamEvent = serde_json::from_value(json!({
"type": "codex.response.metadata",
"headers": {
"x-codex-safety-buffering-enabled": "true",
"x-codex-safety-buffering-faster-model": "gpt-fast-header"
}
}))
.expect("deserialize treatment metadata");
let event: ResponsesStreamEvent = serde_json::from_value(json!({
"type": "response.output_text.delta",
"safety_buffering": {
"use_cases": ["cyber"],
"reasons": ["user_risk"],
"retry_model": "gpt-fast-wire"
}
}))
.expect("deserialize safety buffering event");
let mut treatment = SafetyBufferingTreatment::default();
assert!(safety_buffering_for_event(&metadata, &mut treatment).is_none());
let buffering = safety_buffering_for_event(&event, &mut treatment)
.expect("expected safety buffering payload");
assert_eq!(
buffering,
crate::common::SafetyBuffering {
use_cases: vec!["cyber".to_string()],
reasons: vec!["user_risk".to_string()],
show_buffering_ui: true,
faster_model: Some("gpt-fast-wire".to_string()),
}
);
}
#[test]
fn websocket_safety_buffering_event_controls_visibility_when_header_disables_it() {
let metadata: ResponsesStreamEvent = serde_json::from_value(json!({
"type": "codex.response.metadata",
"headers": {
"x-codex-safety-buffering-enabled": "false",
"x-codex-safety-buffering-faster-model": "gpt-fast-header"
}
}))
.expect("deserialize treatment metadata");
let event: ResponsesStreamEvent = serde_json::from_value(json!({
"type": "response.output_text.delta",
"safety_buffering": {
"use_cases": ["cyber"],
"reasons": ["user_risk"]
}
}))
.expect("deserialize safety buffering event");
let mut treatment = SafetyBufferingTreatment::default();
assert!(safety_buffering_for_event(&metadata, &mut treatment).is_none());
let buffering = safety_buffering_for_event(&event, &mut treatment)
.expect("expected safety buffering payload");
assert_eq!(
buffering,
crate::common::SafetyBuffering {
use_cases: vec!["cyber".to_string()],
reasons: vec!["user_risk".to_string()],
show_buffering_ui: true,
faster_model: Some("gpt-fast-header".to_string()),
}
);
}
}
+30 -17
View File
@@ -6,23 +6,17 @@ pub(crate) const X_CODEX_SAFETY_BUFFERING_FASTER_MODEL_HEADER: &str =
"x-codex-safety-buffering-faster-model";
pub(crate) fn treatment_from_headers(headers: &HeaderMap) -> Option<SafetyBufferingTreatment> {
let show_buffering_ui = headers
.get(X_CODEX_SAFETY_BUFFERING_ENABLED_HEADER)
.and_then(|value| value.to_str().ok())?
.eq_ignore_ascii_case("true");
let faster_model = if show_buffering_ui {
headers
.get(X_CODEX_SAFETY_BUFFERING_FASTER_MODEL_HEADER)
.and_then(|value| value.to_str().ok())
.map(str::to_string)
} else {
None
};
if !headers.contains_key(X_CODEX_SAFETY_BUFFERING_ENABLED_HEADER)
&& !headers.contains_key(X_CODEX_SAFETY_BUFFERING_FASTER_MODEL_HEADER)
{
return None;
}
let faster_model = headers
.get(X_CODEX_SAFETY_BUFFERING_FASTER_MODEL_HEADER)
.and_then(|value| value.to_str().ok())
.map(str::to_string);
Some(SafetyBufferingTreatment {
show_buffering_ui,
faster_model,
})
Some(SafetyBufferingTreatment { faster_model })
}
#[cfg(test)]
@@ -46,7 +40,26 @@ mod tests {
assert_eq!(
treatment_from_headers(&headers),
Some(SafetyBufferingTreatment {
show_buffering_ui: true,
faster_model: Some("faster-model".to_string()),
})
);
}
#[test]
fn buffering_enabled_header_does_not_gate_the_faster_model_fallback() {
let mut headers = HeaderMap::new();
headers.insert(
X_CODEX_SAFETY_BUFFERING_ENABLED_HEADER,
HeaderValue::from_static("false"),
);
headers.insert(
X_CODEX_SAFETY_BUFFERING_FASTER_MODEL_HEADER,
HeaderValue::from_static("faster-model"),
);
assert_eq!(
treatment_from_headers(&headers),
Some(SafetyBufferingTreatment {
faster_model: Some("faster-model".to_string()),
})
);
+59 -7
View File
@@ -231,8 +231,18 @@ impl ResponsesStreamEvent {
.map(|metadata| TurnModerationMetadataEvent { metadata })
}
pub(crate) fn safety_buffering(&self) -> Option<SafetyBuffering> {
serde_json::from_value(self.safety_buffering.as_ref()?.clone()).ok()
pub(crate) fn safety_buffering(
&self,
treatment: &SafetyBufferingTreatment,
) -> Option<SafetyBuffering> {
let value = self.safety_buffering.as_ref()?;
let retry_model_present = value.as_object()?.contains_key("retry_model");
let mut buffering: SafetyBuffering = serde_json::from_value(value.clone()).ok()?;
buffering.show_buffering_ui = true;
if !retry_model_present {
buffering.faster_model.clone_from(&treatment.faster_model);
}
Some(buffering)
}
}
@@ -515,9 +525,7 @@ async fn process_sse_with_treatment(
};
let model_verifications = event.model_verifications();
let turn_moderation_metadata = event.turn_moderation_metadata();
let safety_buffering = event
.safety_buffering()
.map(|buffering| buffering.with_treatment(&safety_buffering_treatment));
let safety_buffering = event.safety_buffering(&safety_buffering_treatment);
if let Some(model) = event.response_model()
&& last_server_model.as_deref() != Some(model.as_str())
@@ -1354,7 +1362,8 @@ mod tests {
"delta": "hello",
"safety_buffering": {
"use_cases": ["cyber"],
"reasons": ["user_risk"]
"reasons": ["user_risk"],
"retry_model": "gpt-fast-wire"
}
}),
json!({
@@ -1381,7 +1390,10 @@ mod tests {
assert_matches!(
&events[1],
ResponseEvent::SafetyBuffering(buffering)
if buffering.use_cases == ["cyber"] && buffering.reasons == ["user_risk"]
if buffering.use_cases == ["cyber"]
&& buffering.reasons == ["user_risk"]
&& buffering.show_buffering_ui
&& buffering.faster_model.as_deref() == Some("gpt-fast-wire")
);
assert_matches!(&events[2], ResponseEvent::OutputTextDelta(delta) if delta == "hello");
assert_matches!(
@@ -1398,6 +1410,46 @@ mod tests {
assert_matches!(&events[6], ResponseEvent::Completed { response_id, .. } if response_id == "resp-1");
}
#[test]
fn safety_buffering_prefers_wire_retry_model_and_only_falls_back_when_omitted() {
let treatment = SafetyBufferingTreatment {
faster_model: Some("gpt-fast-header".to_string()),
};
for (retry_model, expected_faster_model) in [
(None, Some("gpt-fast-header")),
(Some(Value::Null), None),
(Some(json!("gpt-fast-wire")), Some("gpt-fast-wire")),
] {
let mut event = json!({
"type": "response.output_text.delta",
"safety_buffering": {
"use_cases": ["cyber"],
"reasons": ["user_risk"]
}
});
if let Some(retry_model) = retry_model {
event["safety_buffering"]["retry_model"] = retry_model;
}
let event: ResponsesStreamEvent =
serde_json::from_value(event).expect("deserialize safety buffering event");
let buffering = event
.safety_buffering(&treatment)
.expect("expected safety buffering payload");
assert_eq!(
buffering,
SafetyBuffering {
use_cases: vec!["cyber".to_string()],
reasons: vec!["user_risk".to_string()],
show_buffering_ui: true,
faster_model: expected_faster_model.map(str::to_string),
}
);
}
}
#[test]
fn responses_stream_event_response_model_reads_top_level_headers() {
let ev: ResponsesStreamEvent = serde_json::from_value(json!({
@@ -142,6 +142,7 @@ impl McpConnectionManager {
tool_plugin_provenance: ToolPluginProvenance,
auth: Option<&CodexAuth>,
elicitation_reviewer: Option<ElicitationReviewerHandle>,
elicitation_lifecycle: Option<crate::ElicitationLifecycle>,
elicitation_router: ElicitationRequestRouter,
) -> Self {
let mut required_servers = mcp_servers
@@ -157,6 +158,7 @@ impl McpConnectionManager {
approval_policy.value(),
initial_permission_profile,
elicitation_reviewer,
elicitation_lifecycle,
elicitation_router,
);
let tool_plugin_provenance = Arc::new(tool_plugin_provenance);
@@ -360,6 +362,7 @@ impl McpConnectionManager {
approval_policy.value(),
permission_profile.clone(),
/*reviewer*/ None,
/*lifecycle*/ None,
ElicitationRequestRouter::default(),
),
startup_cancellation_token: CancellationToken::new(),
@@ -2,6 +2,7 @@ use super::*;
use crate::codex_apps_cache::CodexAppsToolsCache;
use crate::codex_apps_cache::CodexAppsToolsCacheContext;
use crate::declared_openai_file_input_param_names;
use crate::elicitation::ElicitationLifecycle;
use crate::elicitation::ElicitationRequestManager;
use crate::elicitation::ElicitationRequestRouter;
use crate::elicitation::elicitation_is_rejected_by_policy;
@@ -323,6 +324,7 @@ async fn disabled_permissions_auto_accept_elicitation_with_empty_form_schema() {
AskForApproval::Never,
PermissionProfile::Disabled,
/*reviewer*/ None,
/*lifecycle*/ None,
ElicitationRequestRouter::default(),
);
let (tx_event, _rx_event) = async_channel::bounded(1);
@@ -359,6 +361,7 @@ async fn disabled_permissions_do_not_auto_accept_elicitation_with_requested_fiel
AskForApproval::Never,
PermissionProfile::Disabled,
/*reviewer*/ None,
/*lifecycle*/ None,
ElicitationRequestRouter::default(),
);
let (tx_event, _rx_event) = async_channel::bounded(1);
@@ -395,17 +398,35 @@ async fn disabled_permissions_do_not_auto_accept_elicitation_with_requested_fiel
#[tokio::test]
async fn shared_elicitation_router_targets_the_exact_pending_request() {
struct Registration(Arc<AtomicUsize>);
impl Drop for Registration {
fn drop(&mut self) {
self.0.fetch_sub(1, std::sync::atomic::Ordering::SeqCst);
}
}
let router = ElicitationRequestRouter::default();
let outstanding = Arc::new(AtomicUsize::new(0));
let lifecycle = ElicitationLifecycle::new({
let outstanding = outstanding.clone();
move || {
outstanding.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
Registration(outstanding.clone())
}
});
let manager_a = ElicitationRequestManager::new(
AskForApproval::OnRequest,
PermissionProfile::default(),
/*reviewer*/ None,
Some(lifecycle.clone()),
router.clone(),
);
let manager_b = ElicitationRequestManager::new(
AskForApproval::OnRequest,
PermissionProfile::default(),
/*reviewer*/ None,
Some(lifecycle),
router,
);
let (tx_event, rx_event) = async_channel::bounded(2);
@@ -435,6 +456,7 @@ async fn shared_elicitation_router_targets_the_exact_pending_request() {
else {
panic!("expected elicitation request");
};
assert_eq!(outstanding.load(std::sync::atomic::Ordering::SeqCst), 2);
let (
codex_protocol::mcp::RequestId::String(request_a_id),
codex_protocol::mcp::RequestId::String(request_b_id),
@@ -485,6 +507,7 @@ async fn shared_elicitation_router_targets_the_exact_pending_request() {
.expect("request B response"),
response_b
);
assert_eq!(outstanding.load(std::sync::atomic::Ordering::SeqCst), 0);
}
#[test]
@@ -1539,6 +1562,7 @@ async fn no_local_runtime_fails_local_stdio_but_keeps_local_http_server() {
ToolPluginProvenance::default(),
/*auth*/ None,
/*elicitation_reviewer*/ None,
/*elicitation_lifecycle*/ None,
ElicitationRequestRouter::default(),
)
.await;
+33
View File
@@ -52,6 +52,33 @@ pub trait ElicitationReviewer: Send + Sync {
pub type ElicitationReviewerHandle = Arc<dyn ElicitationReviewer>;
/// Holds an owner-provided registration while an MCP elicitation is waiting for a response.
#[derive(Clone)]
pub struct ElicitationLifecycle {
register: Arc<dyn Fn() -> Box<dyn Send + Sync> + Send + Sync>,
}
impl ElicitationLifecycle {
pub fn new<T>(register: impl Fn() -> T + Send + Sync + 'static) -> Self
where
T: Send + Sync + 'static,
{
Self {
register: Arc::new(move || Box::new(register())),
}
}
fn start(&self) -> ActiveElicitation {
ActiveElicitation {
_registration: (self.register)(),
}
}
}
struct ActiveElicitation {
_registration: Box<dyn Send + Sync>,
}
/// Routes model-visible elicitation response tokens to their exact pending responders.
///
/// One router is shared by every MCP runtime created for a thread. The public response token is
@@ -86,6 +113,7 @@ pub(crate) struct ElicitationRequestManager {
pub(crate) permission_profile: Arc<StdMutex<PermissionProfile>>,
auto_deny: Arc<StdMutex<bool>>,
reviewer: Option<ElicitationReviewerHandle>,
lifecycle: Option<ElicitationLifecycle>,
}
impl ElicitationRequestManager {
@@ -93,6 +121,7 @@ impl ElicitationRequestManager {
approval_policy: AskForApproval,
permission_profile: PermissionProfile,
reviewer: Option<ElicitationReviewerHandle>,
lifecycle: Option<ElicitationLifecycle>,
router: ElicitationRequestRouter,
) -> Self {
Self {
@@ -101,6 +130,7 @@ impl ElicitationRequestManager {
permission_profile: Arc::new(StdMutex::new(permission_profile)),
auto_deny: Arc::new(StdMutex::new(false)),
reviewer,
lifecycle,
}
}
@@ -140,6 +170,7 @@ impl ElicitationRequestManager {
let permission_profile = self.permission_profile.clone();
let auto_deny = self.auto_deny.clone();
let reviewer = self.reviewer.clone();
let lifecycle = self.lifecycle.clone();
Box::new(move |id, elicitation| {
let router = router.clone();
let tx_event = tx_event.clone();
@@ -148,6 +179,7 @@ impl ElicitationRequestManager {
let permission_profile = permission_profile.clone();
let auto_deny = auto_deny.clone();
let reviewer = reviewer.clone();
let lifecycle = lifecycle.clone();
async move {
let auto_deny = auto_deny
.lock()
@@ -249,6 +281,7 @@ impl ElicitationRequestManager {
},
};
let (tx, rx) = oneshot::channel();
let _active_elicitation = lifecycle.as_ref().map(ElicitationLifecycle::start);
{
let mut lock = router.requests.lock().await;
lock.insert((server_name.clone(), routed_request_id), tx);
+1
View File
@@ -1,5 +1,6 @@
pub use connection_manager::McpConnectionManager;
pub use connection_manager::tool_is_model_visible;
pub use elicitation::ElicitationLifecycle;
pub use elicitation::ElicitationRequestRouter;
pub use elicitation::ElicitationReviewRequest;
pub use elicitation::ElicitationReviewer;
+2
View File
@@ -339,6 +339,7 @@ pub async fn read_mcp_resource(
tool_plugin_provenance(config),
auth,
/*elicitation_reviewer*/ None,
/*elicitation_lifecycle*/ None,
crate::elicitation::ElicitationRequestRouter::default(),
)
.await;
@@ -416,6 +417,7 @@ pub async fn collect_mcp_server_status_snapshot_with_detail(
tool_plugin_provenance,
auth,
/*elicitation_reviewer*/ None,
/*elicitation_lifecycle*/ None,
crate::elicitation::ElicitationRequestRouter::default(),
)
.await;
@@ -777,6 +777,7 @@ fn remote_installed_plugin_in_marketplace(
RemoteInstalledPlugin {
marketplace_name: marketplace_name.to_string(),
id: format!("plugins~Plugin_{name}"),
version: None,
name: name.to_string(),
enabled: true,
install_policy: codex_app_server_protocol::PluginInstallPolicy::Available,
+5
View File
@@ -148,6 +148,7 @@ pub enum RemoteMarketplaceSource {
pub struct RemoteInstalledPlugin {
pub marketplace_name: String,
pub id: String,
pub version: Option<String>,
pub name: String,
pub enabled: bool,
pub install_policy: PluginInstallPolicy,
@@ -161,6 +162,7 @@ pub struct RemoteInstalledPlugin {
pub struct RemotePluginSummary {
pub id: String,
pub remote_plugin_id: String,
pub version: Option<String>,
pub local_version: Option<String>,
pub name: String,
pub share_context: Option<RemotePluginShareContext>,
@@ -1041,6 +1043,7 @@ pub fn group_remote_installed_plugins_by_marketplaces(
let plugin_summary = RemotePluginSummary {
id: plugin_id.as_key(),
remote_plugin_id: plugin.id.clone(),
version: plugin.version.clone(),
local_version: None,
name: plugin.name.clone(),
share_context: None,
@@ -1472,6 +1475,7 @@ fn build_remote_plugin_summary(
Ok(RemotePluginSummary {
id: plugin_id.as_key(),
remote_plugin_id: plugin.id.clone(),
version: plugin.release.version.clone(),
local_version: installed_plugin
.and_then(|installed| installed.plugin.release.version.clone()),
name: plugin.name.clone(),
@@ -1554,6 +1558,7 @@ fn remote_installed_plugin_to_cache_entry(
Ok(RemoteInstalledPlugin {
marketplace_name: remote_plugin_canonical_marketplace_name(plugin)?.to_string(),
id: plugin.id.clone(),
version: plugin.release.version.clone(),
name: plugin.name.clone(),
enabled: installed_plugin.enabled,
install_policy: plugin.installation_policy,
@@ -618,6 +618,7 @@ async fn list_remote_plugin_shares_fetches_created_workspace_plugins() {
summary: RemotePluginSummary {
id: "demo-plugin@workspace-shared-with-me".to_string(),
remote_plugin_id: "plugins_123".to_string(),
version: Some("0.1.0".to_string()),
local_version: None,
name: "demo-plugin".to_string(),
share_context: Some(RemotePluginShareContext {
@@ -658,6 +659,7 @@ async fn list_remote_plugin_shares_fetches_created_workspace_plugins() {
summary: RemotePluginSummary {
id: "demo-plugin@workspace-shared-with-me".to_string(),
remote_plugin_id: "plugins_456".to_string(),
version: Some("0.1.0".to_string()),
local_version: Some("0.1.0".to_string()),
name: "demo-plugin".to_string(),
share_context: Some(RemotePluginShareContext {
@@ -143,6 +143,12 @@ pub struct EnvironmentSkillLoadOutcome {
}
/// Discovers skills without converting environment-owned paths to host paths.
#[tracing::instrument(
name = "skills.environment.load",
level = "info",
skip_all,
fields(skill_count = tracing::field::Empty)
)]
pub async fn load_environment_skills_from_root(
file_system: &dyn ExecutorFileSystem,
root: &PathUri,
@@ -235,6 +241,7 @@ pub async fn load_environment_skills_from_root(
warnings: vec![format!("failed to walk skills root {root}: {error:#}")],
},
};
tracing::Span::current().record("skill_count", discovery.skills.len());
outcome.warnings.extend(discovery.warnings);
if discovery.skills.is_empty() {
return outcome;
+3
View File
@@ -1750,6 +1750,9 @@
"minimum": 0.0,
"type": "integer"
},
"multi_agent_mode_hint_text": {
"type": "string"
},
"non_code_mode_only": {
"type": "boolean"
},
+78 -37
View File
@@ -4,6 +4,8 @@ use crate::agent::registry::AgentRegistry;
use crate::agent::role::DEFAULT_ROLE_NAME;
use crate::agent::role::resolve_role_config;
use crate::agent::status::is_final;
use crate::agent_communication::AgentCommunicationContext;
use crate::agent_communication::AgentCommunicationKind;
use crate::codex_thread::ThreadConfigSnapshot;
use crate::config::Config;
use crate::config::RolloutBudgetConfig;
@@ -139,12 +141,12 @@ impl AgentControl {
pub(crate) async fn send_input(
&self,
agent_id: ThreadId,
initial_operation: Op,
input: Vec<UserInput>,
) -> CodexResult<String> {
let state = self.upgrade()?;
self.ensure_execution_capacity_for_op(agent_id, &initial_operation)
self.ensure_execution_capacity_for_turn_start(agent_id, /*starts_turn*/ true)
.await?;
self.send_input_after_capacity_check(agent_id, &state, initial_operation)
self.send_input_after_capacity_check(agent_id, &state, input)
.await
}
@@ -152,19 +154,14 @@ impl AgentControl {
&self,
agent_id: ThreadId,
state: &Arc<ThreadManagerState>,
initial_operation: Op,
input: Vec<UserInput>,
) -> CodexResult<String> {
let last_task_message = match &initial_operation {
Op::InterAgentCommunication { communication } => {
last_task_message_from_communication(communication)
}
_ => non_empty_task_message(render_input_preview(&initial_operation)),
};
let last_task_message = non_empty_task_message(render_input_preview(&input));
let result = self
.handle_thread_request_result(
agent_id,
state,
state.send_op(agent_id, initial_operation).await,
state.send_op(agent_id, input.into()).await,
)
.await;
if result.is_ok() {
@@ -182,14 +179,60 @@ impl AgentControl {
&self,
agent_id: ThreadId,
communication: InterAgentCommunication,
agent_communication_context: AgentCommunicationContext,
) -> CodexResult<String> {
let state = self.upgrade()?;
self.ensure_execution_capacity_for_turn_start(agent_id, communication.trigger_turn)
.await?;
self.send_inter_agent_communication_after_capacity_check(
agent_id,
&state,
communication,
agent_communication_context,
)
.await
}
async fn send_inter_agent_communication_after_capacity_check(
&self,
agent_id: ThreadId,
state: &Arc<ThreadManagerState>,
communication: InterAgentCommunication,
context: AgentCommunicationContext,
) -> CodexResult<String> {
self.submit_inter_agent_communication(agent_id, state, communication, context)
.await
}
async fn submit_inter_agent_communication(
&self,
agent_id: ThreadId,
state: &Arc<ThreadManagerState>,
communication: InterAgentCommunication,
context: AgentCommunicationContext,
) -> CodexResult<String> {
let last_task_message = last_task_message_from_communication(&communication);
let state = self.upgrade()?;
let op = Op::InterAgentCommunication { communication };
self.ensure_execution_capacity_for_op(agent_id, &op).await?;
let communication_for_log =
crate::agent_communication::logging_enabled().then(|| communication.clone());
let result = self
.handle_thread_request_result(agent_id, &state, state.send_op(agent_id, op).await)
.handle_thread_request_result(
agent_id,
state,
state
.send_op(agent_id, Op::InterAgentCommunication { communication })
.await,
)
.await;
if let (Some(communication), Ok(communication_id)) =
(communication_for_log, result.as_ref())
{
crate::agent_communication::emit_agent_communication_send(
communication_id,
&context,
&communication,
agent_id,
);
}
if result.is_ok() {
match last_task_message {
Some(last_task_message) => self
@@ -480,8 +523,10 @@ impl AgentControl {
message,
/*trigger_turn*/ false,
);
let context =
AgentCommunicationContext::new(AgentCommunicationKind::Result, child_thread_id);
let _ = control
.send_inter_agent_communication(parent_thread_id, communication)
.send_inter_agent_communication(parent_thread_id, communication, context)
.await;
return;
}
@@ -706,27 +751,23 @@ fn agent_matches_prefix(agent_path: Option<&AgentPath>, prefix: &AgentPath) -> b
})
}
pub(crate) fn render_input_preview(initial_operation: &Op) -> String {
match initial_operation {
Op::UserInput { items, .. } => items
.iter()
.map(|item| match item {
UserInput::Text { text, .. } => text.clone(),
UserInput::Image { .. } => "[image]".to_string(),
UserInput::LocalImage { path, .. } => {
format!("[local_image:{}]", path.display())
}
UserInput::Skill { name, path, .. } => {
format!("[skill:${name}]({})", path.display())
}
UserInput::Mention { name, path, .. } => format!("[mention:${name}]({path})"),
_ => "[input]".to_string(),
})
.collect::<Vec<_>>()
.join("\n"),
Op::InterAgentCommunication { communication } => communication.content.clone(),
_ => String::new(),
}
pub(crate) fn render_input_preview(input: &[UserInput]) -> String {
input
.iter()
.map(|item| match item {
UserInput::Text { text, .. } => text.clone(),
UserInput::Image { .. } => "[image]".to_string(),
UserInput::LocalImage { path, .. } => {
format!("[local_image:{}]", path.display())
}
UserInput::Skill { name, path, .. } => {
format!("[skill:${name}]({})", path.display())
}
UserInput::Mention { name, path, .. } => format!("[mention:${name}]({path})"),
_ => "[input]".to_string(),
})
.collect::<Vec<_>>()
.join("\n")
}
fn last_task_message_from_communication(communication: &InterAgentCommunication) -> Option<String> {
+10 -1
View File
@@ -32,7 +32,16 @@ impl AgentControl {
thread_id: ThreadId,
op: &Op,
) -> CodexResult<()> {
if !op_starts_turn(op) {
self.ensure_execution_capacity_for_turn_start(thread_id, op_starts_turn(op))
.await
}
pub(super) async fn ensure_execution_capacity_for_turn_start(
&self,
thread_id: ThreadId,
starts_turn: bool,
) -> CodexResult<()> {
if !starts_turn {
return Ok(());
}
let state = self.upgrade()?;
+54 -8
View File
@@ -9,6 +9,17 @@ struct SpawnAgentThreadInheritance {
exec_policy: Option<Arc<crate::exec_policy::ExecPolicyManager>>,
}
/// Initial input delivered after a spawned agent acquires execution capacity.
///
/// V2 communication spawns keep the communication and its context paired so centralized
/// submission and lifecycle logging cannot receive one without the other. Other spawn sources
/// provide user input directly, making an uncontextualized inter-agent communication
/// unrepresentable.
enum SpawnInitialInput {
UserInput(Vec<UserInput>),
InterAgentCommunication(InterAgentCommunication, AgentCommunicationContext),
}
fn default_agent_nickname_list() -> Vec<&'static str> {
AGENT_NAMES
.lines()
@@ -89,12 +100,12 @@ impl AgentControl {
pub(crate) async fn spawn_agent(
&self,
config: Config,
initial_operation: Op,
initial_input: Vec<UserInput>,
session_source: Option<SessionSource>,
) -> CodexResult<ThreadId> {
let spawned_agent = Box::pin(self.spawn_agent_internal(
config,
initial_operation,
SpawnInitialInput::UserInput(initial_input),
session_source,
SpawnAgentOptions::default(),
))
@@ -106,12 +117,34 @@ impl AgentControl {
pub(crate) async fn spawn_agent_with_metadata(
&self,
config: Config,
initial_operation: Op,
initial_input: Vec<UserInput>,
session_source: Option<SessionSource>,
options: SpawnAgentOptions, // TODO(jif) drop with new fork.
) -> CodexResult<LiveAgent> {
Box::pin(self.spawn_agent_internal(config, initial_operation, session_source, options))
.await
Box::pin(self.spawn_agent_internal(
config,
SpawnInitialInput::UserInput(initial_input),
session_source,
options,
))
.await
}
pub(crate) async fn spawn_agent_with_communication(
&self,
config: Config,
communication: InterAgentCommunication,
context: AgentCommunicationContext,
session_source: Option<SessionSource>,
options: SpawnAgentOptions,
) -> CodexResult<LiveAgent> {
Box::pin(self.spawn_agent_internal(
config,
SpawnInitialInput::InterAgentCommunication(communication, context),
session_source,
options,
))
.await
}
pub(crate) async fn ensure_v2_agent_loaded(
@@ -197,7 +230,7 @@ impl AgentControl {
async fn spawn_agent_internal(
&self,
config: Config,
initial_operation: Op,
initial_input: SpawnInitialInput,
session_source: Option<SessionSource>,
options: SpawnAgentOptions,
) -> CodexResult<LiveAgent> {
@@ -356,8 +389,21 @@ impl AgentControl {
)
.await;
self.send_input_after_capacity_check(new_thread.thread_id, &state, initial_operation)
.await?;
match initial_input {
SpawnInitialInput::UserInput(input) => {
self.send_input_after_capacity_check(new_thread.thread_id, &state, input)
.await?;
}
SpawnInitialInput::InterAgentCommunication(communication, context) => {
self.send_inter_agent_communication_after_capacity_check(
new_thread.thread_id,
&state,
communication,
context,
)
.await?;
}
}
if multi_agent_version != MultiAgentVersion::V2 {
let child_reference = agent_metadata
.agent_path
+21 -11
View File
@@ -3,6 +3,8 @@ use crate::CodexThread;
use crate::StateDbHandle;
use crate::ThreadManager;
use crate::agent::agent_status_from_event;
use crate::agent_communication::AgentCommunicationContext;
use crate::agent_communication::AgentCommunicationKind;
use crate::config::AgentRoleConfig;
use crate::config::Config;
use crate::config::ConfigBuilder;
@@ -65,12 +67,11 @@ async fn test_config() -> (TempDir, Config) {
test_config_with_cli_overrides(Vec::new()).await
}
fn text_input(text: &str) -> Op {
fn text_input(text: &str) -> Vec<UserInput> {
vec![UserInput::Text {
text: text.to_string(),
text_elements: Vec::new(),
}]
.into()
}
fn assistant_message(text: &str, phase: Option<MessagePhase>) -> ResponseItem {
@@ -311,8 +312,7 @@ async fn send_input_errors_when_manager_dropped() {
vec![UserInput::Text {
text: "hello".to_string(),
text_elements: Vec::new(),
}]
.into(),
}],
)
.await
.expect_err("send_input should fail without a manager");
@@ -423,8 +423,7 @@ async fn send_input_errors_when_thread_missing() {
vec![UserInput::Text {
text: "hello".to_string(),
text_elements: Vec::new(),
}]
.into(),
}],
)
.await
.expect_err("send_input should fail for missing thread");
@@ -490,8 +489,7 @@ async fn send_input_submits_user_message() {
vec![UserInput::Text {
text: "hello from tests".to_string(),
text_elements: Vec::new(),
}]
.into(),
}],
)
.await
.expect("send_input should succeed");
@@ -531,7 +529,11 @@ async fn send_inter_agent_communication_without_turn_queues_message_without_trig
let submission_id = harness
.control
.send_inter_agent_communication(thread_id, communication.clone())
.send_inter_agent_communication(
thread_id,
communication.clone(),
AgentCommunicationContext::new(AgentCommunicationKind::Message, ThreadId::new()),
)
.await
.expect("send_inter_agent_communication should succeed");
assert!(!submission_id.is_empty());
@@ -656,7 +658,11 @@ async fn ensure_v2_agent_loaded_reloads_registered_unloaded_agent() {
);
harness
.control
.send_inter_agent_communication(spawned_agent.thread_id, communication.clone())
.send_inter_agent_communication(
spawned_agent.thread_id,
communication.clone(),
AgentCommunicationContext::new(AgentCommunicationKind::Message, ThreadId::new()),
)
.await
.expect("send_inter_agent_communication should succeed after reload");
let expected = (
@@ -803,7 +809,11 @@ async fn encrypted_inter_agent_communication_clears_existing_last_task_message()
);
harness
.control
.send_inter_agent_communication(spawned_agent.thread_id, communication)
.send_inter_agent_communication(
spawned_agent.thread_id,
communication,
AgentCommunicationContext::new(AgentCommunicationKind::Followup, ThreadId::new()),
)
.await
.expect("send_inter_agent_communication should succeed");
+79
View File
@@ -0,0 +1,79 @@
use codex_protocol::ThreadId;
use codex_protocol::protocol::InterAgentCommunication;
const AGENT_COMMUNICATION_TARGET: &str = "codex_otel.agent_communication";
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum AgentCommunicationKind {
Spawn,
Message,
Followup,
Result,
}
impl AgentCommunicationKind {
fn as_str(self) -> &'static str {
match self {
Self::Spawn => "spawn",
Self::Message => "message",
Self::Followup => "followup",
Self::Result => "result",
}
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) struct AgentCommunicationContext {
kind: AgentCommunicationKind,
sender_thread_id: ThreadId,
}
impl AgentCommunicationContext {
pub(crate) fn new(kind: AgentCommunicationKind, sender_thread_id: ThreadId) -> Self {
Self {
kind,
sender_thread_id,
}
}
}
pub(crate) fn logging_enabled() -> bool {
tracing::enabled!(target: AGENT_COMMUNICATION_TARGET, tracing::Level::INFO)
}
pub(crate) fn emit_agent_communication_send(
communication_id: &str,
context: &AgentCommunicationContext,
communication: &InterAgentCommunication,
receiver_thread_id: ThreadId,
) {
tracing::info!(
target: AGENT_COMMUNICATION_TARGET,
{
event.name = "codex.agent_communication",
communication_id,
kind = context.kind.as_str(),
state = "send",
sender_thread_id = %context.sender_thread_id,
receiver_thread_id = %receiver_thread_id,
content = if communication.content.is_empty() {
communication.encrypted_content.as_deref().unwrap_or_default()
} else {
communication.content.as_str()
},
},
"agent communication"
);
}
pub(crate) fn emit_agent_communication_receive(communication_id: &str) {
tracing::info!(
target: AGENT_COMMUNICATION_TARGET,
{
event.name = "codex.agent_communication",
communication_id,
state = "receive",
},
"agent communication"
);
}
+33 -20
View File
@@ -1123,42 +1123,48 @@ impl ModelClientSession {
}
}
/// Checks whether the current request is an incremental extension of the previous request.
/// We only reuse an incremental input delta when non-input request fields are unchanged and
/// `input` is a strict extension of the previous known input. Server-returned output items
/// are treated as part of the baseline so we do not resend them.
fn get_incremental_items(
&self,
request: &ResponsesApiRequest,
last_response: Option<&LastResponse>,
allow_empty_delta: bool,
) -> Option<Vec<ResponseItem>> {
// Checks whether the current request is an incremental extension of the previous request.
// We only reuse an incremental input delta when non-input request fields are unchanged and
// `input` is a strict
// extension of the previous known input. Server-returned output items are treated as part
// of the baseline so we do not resend them.
let previous_request = self.websocket_session.last_request.as_ref()?;
if !responses_request_properties_match(previous_request, request) {
trace!("incremental request failed, websocket reuse properties didn't match");
return None;
}
let Some(after_previous_input) = request
.input
.strip_prefix(previous_request.input.as_slice())
else {
trace!("incremental request failed, items didn't match");
return None;
};
let mut response_items =
last_response.map_or_else(Vec::new, |response| response.items_added.clone());
if !self.client.state.provider.info().is_openai() {
response_items
.iter_mut()
.for_each(ResponseItem::clear_internal_chat_message_metadata_passthrough);
// To compare the inputs, we concatenate the previous request items with the response items,
// then compare that against the equivalent slice of request items, ignoring metadata. If
// they match, we can consider the remaining items the incremental request.
let mut previous_items = previous_request.input.clone();
if let Some(response) = last_response {
previous_items.extend_from_slice(&response.items_added);
}
let Some(incremental_items) = after_previous_input.strip_prefix(response_items.as_slice())
previous_items
.iter_mut()
.for_each(ResponseItem::clear_internal_chat_message_metadata_passthrough);
let Some((request_items_to_compare, incremental_items)) =
request.input.split_at_checked(previous_items.len())
else {
trace!("incremental request failed, items didn't match");
trace!("incremental request failed, incompatible request length");
return None;
};
let mut request_prefix = request_items_to_compare.to_vec();
request_prefix
.iter_mut()
.for_each(ResponseItem::clear_internal_chat_message_metadata_passthrough);
if previous_items != request_prefix {
trace!("incremental request failed, items didn't match");
return None;
}
if !allow_empty_delta && incremental_items.is_empty() {
return None;
}
@@ -1912,6 +1918,7 @@ where
let mut logged_error = false;
let mut tx_last_response = Some(tx_last_response);
let mut items_added: Vec<ResponseItem> = Vec::new();
let (request_start, mut ttft_ms) = (Instant::now(), None);
let mut api_stream = api_stream;
let upstream_request_id = upstream_request_id.as_deref();
if let Some(upstream_request_id) = upstream_request_id {
@@ -1961,6 +1968,7 @@ where
Some(usage.cached_input_tokens),
Some(usage.reasoning_output_tokens),
usage.total_tokens,
ttft_ms,
);
}
inference_trace_attempt.record_completed(
@@ -1988,6 +1996,11 @@ where
}
}
Ok(event) => {
if matches!(&event, ResponseEvent::OutputItemAdded(_)) && ttft_ms.is_none() {
ttft_ms = Some(
i64::try_from(request_start.elapsed().as_millis()).unwrap_or(i64::MAX),
);
}
if tx_event.send(Ok(event)).await.is_err() {
inference_trace_attempt.record_cancelled(
STREAM_DROPPED_REASON,
+5 -5
View File
@@ -299,11 +299,11 @@ async fn forward_events(
match event {
Event {
id: _,
msg: EventMsg::TokenCount(_),
} => {}
Event {
id: _,
msg: EventMsg::SessionConfigured(_),
msg:
EventMsg::TokenCount(_)
| EventMsg::SessionConfigured(_)
| EventMsg::McpStartupUpdate(_)
| EventMsg::McpStartupComplete(_),
} => {}
Event {
id,
+44 -21
View File
@@ -14,6 +14,9 @@ use codex_protocol::protocol::GuardianAssessmentAction;
use codex_protocol::protocol::GuardianAssessmentStatus;
use codex_protocol::protocol::GuardianCommandSource;
use codex_protocol::protocol::McpInvocation;
use codex_protocol::protocol::McpStartupCompleteEvent;
use codex_protocol::protocol::McpStartupStatus;
use codex_protocol::protocol::McpStartupUpdateEvent;
use codex_protocol::protocol::RawResponseItemEvent;
use codex_protocol::protocol::ReviewDecision;
use codex_protocol::protocol::TurnAbortReason;
@@ -34,8 +37,8 @@ use tokio::sync::watch;
use tokio::time::timeout;
#[tokio::test]
async fn forward_events_cancelled_while_send_blocked_shuts_down_delegate() {
let (tx_events, rx_events) = bounded(1);
async fn forward_events_filters_private_events_before_blocked_send_is_cancelled() {
let (tx_events, rx_events) = bounded(SUBMISSION_CHANNEL_CAPACITY);
let (tx_sub, rx_sub) = bounded(SUBMISSION_CHANNEL_CAPACITY);
let (_agent_status_tx, agent_status) = watch::channel(AgentStatus::PendingInit);
let (session, ctx, _rx_evt) = crate::session::tests::make_session_and_context_with_rx().await;
@@ -71,33 +74,53 @@ async fn forward_events_cancelled_while_send_blocked_shuts_down_delegate() {
cancel.clone(),
));
tx_events
.send(Event {
id: "evt".to_string(),
msg: EventMsg::RawResponseItem(RawResponseItemEvent {
item: ResponseItem::CustomToolCall {
id: None,
status: None,
call_id: "call-1".to_string(),
name: "tool".to_string(),
namespace: None,
input: "{}".to_string(),
internal_chat_message_metadata_passthrough: None,
},
}),
})
.await
.unwrap();
for msg in [
EventMsg::McpStartupUpdate(McpStartupUpdateEvent {
server: "pending".to_string(),
status: McpStartupStatus::Starting,
}),
EventMsg::McpStartupComplete(McpStartupCompleteEvent::default()),
] {
tx_events
.send(Event {
id: "delegate-startup".to_string(),
msg,
})
.await
.unwrap();
}
let visible_msg = EventMsg::RawResponseItem(RawResponseItemEvent {
item: ResponseItem::CustomToolCall {
id: None,
status: None,
call_id: "call-1".to_string(),
name: "tool".to_string(),
namespace: None,
input: "{}".to_string(),
internal_chat_message_metadata_passthrough: None,
},
});
for id in ["visible-1", "visible-2", "blocked"] {
tx_events
.send(Event {
id: id.to_string(),
msg: visible_msg.clone(),
})
.await
.unwrap();
}
drop(tx_events);
let received = rx_out.recv().await.expect("prefilled event missing");
assert_eq!(received.id, "full");
let received = rx_out.recv().await.expect("visible event missing");
assert_eq!(received.id, "visible-1");
cancel.cancel();
timeout(std::time::Duration::from_millis(1000), forward)
.await
.expect("forward_events hung")
.expect("forward_events join error");
let received = rx_out.recv().await.expect("prefilled event missing");
assert_eq!("full", received.id);
let mut ops = Vec::new();
while let Ok(sub) = rx_sub.try_recv() {
ops.push(sub.op);
+23 -24
View File
@@ -1,5 +1,6 @@
use crate::agent::AgentStatus;
use crate::config::ConstraintResult;
use crate::elicitation::ElicitationRegistration;
use crate::session::Codex;
use crate::session::SessionSettingsUpdate;
use crate::session::SteerInputError;
@@ -162,7 +163,13 @@ pub struct CodexThread {
pub(crate) session_source: SessionSource,
session_configured: SessionConfiguredEvent,
rollout_path: Option<PathBuf>,
out_of_band_elicitation_count: Mutex<u64>,
out_of_band_elicitations: Mutex<OutOfBandElicitations>,
}
#[derive(Default)]
struct OutOfBandElicitations {
count: i64,
registration: Option<ElicitationRegistration>,
}
#[derive(Debug, Eq, PartialEq)]
@@ -187,7 +194,7 @@ impl CodexThread {
session_source,
session_configured,
rollout_path,
out_of_band_elicitation_count: Mutex::new(0),
out_of_band_elicitations: Mutex::new(OutOfBandElicitations::default()),
}
}
@@ -655,38 +662,30 @@ impl CodexThread {
self.codex.enabled(feature)
}
pub async fn increment_out_of_band_elicitation_count(&self) -> CodexResult<u64> {
let mut guard = self.out_of_band_elicitation_count.lock().await;
let was_zero = *guard == 0;
*guard = guard.checked_add(1).ok_or_else(|| {
pub async fn increment_out_of_band_elicitation_count(&self) -> CodexResult<i64> {
let mut elicitations = self.out_of_band_elicitations.lock().await;
let incremented = elicitations.count.checked_add(1).ok_or_else(|| {
CodexErr::Fatal("out-of-band elicitation count overflowed".to_string())
})?;
if was_zero {
self.codex
.session
.set_out_of_band_elicitation_pause_state(/*paused*/ true);
if elicitations.count == 0 {
elicitations.registration = Some(self.codex.session.services.elicitations.register());
}
Ok(*guard)
elicitations.count = incremented;
Ok(incremented)
}
pub async fn decrement_out_of_band_elicitation_count(&self) -> CodexResult<u64> {
let mut guard = self.out_of_band_elicitation_count.lock().await;
if *guard == 0 {
pub async fn decrement_out_of_band_elicitation_count(&self) -> CodexResult<i64> {
let mut elicitations = self.out_of_band_elicitations.lock().await;
if elicitations.count == 0 {
return Err(CodexErr::InvalidRequest(
"out-of-band elicitation count is already zero".to_string(),
));
}
*guard -= 1;
let now_zero = *guard == 0;
if now_zero {
self.codex
.session
.set_out_of_band_elicitation_pause_state(/*paused*/ false);
elicitations.count -= 1;
if elicitations.count == 0 {
elicitations.registration = None;
}
Ok(*guard)
Ok(elicitations.count)
}
}
+21
View File
@@ -10307,6 +10307,7 @@ default_wait_timeout_ms = 30000
usage_hint_text = "Custom delegation guidance."
root_agent_usage_hint_text = "Root guidance."
subagent_usage_hint_text = "Subagent guidance."
multi_agent_mode_hint_text = "Custom mode guidance."
tool_namespace = "agents"
hide_spawn_agent_metadata = true
non_code_mode_only = true
@@ -10343,6 +10344,10 @@ non_code_mode_only = true
config.multi_agent_v2.subagent_usage_hint_text.as_deref(),
Some("Subagent guidance.")
);
assert_eq!(
config.multi_agent_v2.multi_agent_mode_hint_text.as_deref(),
Some("Custom mode guidance.")
);
assert_eq!(
config.multi_agent_v2.tool_namespace.as_deref(),
Some("agents")
@@ -10405,6 +10410,22 @@ max_concurrent_threads_per_session = 17
);
}
#[test]
fn multi_agent_v2_preserves_empty_mode_hint_override() {
let config_toml = toml::from_str(
r#"[features.multi_agent_v2]
multi_agent_mode_hint_text = ""
"#,
)
.expect("multi-agent v2 config should parse");
let expected = MultiAgentV2Config {
multi_agent_mode_hint_text: Some(String::new()),
..Default::default()
};
assert_eq!(resolve_multi_agent_v2_config(&config_toml), expected);
}
#[tokio::test]
async fn multi_agent_v2_empty_usage_hint_overrides_clear_default_hints() -> std::io::Result<()> {
let codex_home = TempDir::new()?;
+16
View File
@@ -52,6 +52,7 @@ use codex_config::types::TuiNotificationSettings;
use codex_config::types::TuiPetAnchor;
use codex_config::types::UriBasedFileOpener;
use codex_config::types::WindowsSandboxModeToml;
use codex_core_plugins::PluginLoadOutcome;
use codex_core_plugins::PluginsConfigInput;
use codex_exec_server::ExecutorFileSystem;
use codex_exec_server::LOCAL_FS;
@@ -1144,6 +1145,7 @@ pub struct MultiAgentV2Config {
pub usage_hint_text: Option<String>,
pub root_agent_usage_hint_text: Option<String>,
pub subagent_usage_hint_text: Option<String>,
pub multi_agent_mode_hint_text: Option<String>,
pub tool_namespace: Option<String>,
pub hide_spawn_agent_metadata: bool,
pub non_code_mode_only: bool,
@@ -1165,6 +1167,7 @@ impl MultiAgentV2Config {
DEFAULT_MULTI_AGENT_V2_SUBAGENT_USAGE_HINT_TEXT,
max_concurrent_threads_per_session,
)),
multi_agent_mode_hint_text: None,
tool_namespace: Some(DEFAULT_MULTI_AGENT_V2_TOOL_NAMESPACE.to_string()),
hide_spawn_agent_metadata: true,
non_code_mode_only: true,
@@ -1526,6 +1529,14 @@ impl Config {
) -> McpConfig {
let plugins_input = self.plugins_config_input();
let loaded_plugins = plugins_manager.plugins_for_config(&plugins_input).await;
self.to_mcp_config_with_loaded_plugins(&loaded_plugins, additional_plugin_registrations)
}
pub(crate) fn to_mcp_config_with_loaded_plugins(
&self,
loaded_plugins: &PluginLoadOutcome,
additional_plugin_registrations: impl IntoIterator<Item = McpServerRegistration>,
) -> McpConfig {
let mut catalog = ResolvedMcpCatalog::builder();
for (plugin_order, plugin) in loaded_plugins
.plugins()
@@ -2501,6 +2512,10 @@ fn resolve_multi_agent_v2_config(config_toml: &ConfigToml) -> MultiAgentV2Config
base.map(|config| &config.subagent_usage_hint_text),
default.subagent_usage_hint_text,
);
let multi_agent_mode_hint_text = base
.and_then(|config| config.multi_agent_mode_hint_text.as_ref())
.cloned()
.or(default.multi_agent_mode_hint_text);
let tool_namespace = base
.and_then(|config| config.tool_namespace.as_ref())
.cloned()
@@ -2520,6 +2535,7 @@ fn resolve_multi_agent_v2_config(config_toml: &ConfigToml) -> MultiAgentV2Config
usage_hint_text,
root_agent_usage_hint_text,
subagent_usage_hint_text,
multi_agent_mode_hint_text,
tool_namespace,
hide_spawn_agent_metadata,
non_code_mode_only,
+1
View File
@@ -283,6 +283,7 @@ pub async fn list_accessible_connectors_from_mcp_tools_with_mcp_manager(
ToolPluginProvenance::default(),
auth.as_ref(),
/*elicitation_reviewer*/ None,
/*elicitation_lifecycle*/ None,
codex_mcp::ElicitationRequestRouter::default(),
)
.await;
@@ -1,25 +1,10 @@
use codex_plugin::PluginCapabilitySummary;
use codex_protocol::protocol::PLUGINS_INSTRUCTIONS_CLOSE_TAG;
use codex_protocol::protocol::PLUGINS_INSTRUCTIONS_OPEN_TAG;
use super::ContextualUserFragment;
#[derive(Debug, Clone, PartialEq)]
pub(crate) struct AvailablePluginsInstructions {
plugins: Vec<PluginCapabilitySummary>,
}
impl AvailablePluginsInstructions {
pub(crate) fn from_plugins(plugins: &[PluginCapabilitySummary]) -> Option<Self> {
if plugins.is_empty() {
return None;
}
Some(Self {
plugins: plugins.to_vec(),
})
}
}
pub(crate) struct AvailablePluginsInstructions;
impl ContextualUserFragment for AvailablePluginsInstructions {
fn role(&self) -> &'static str {
@@ -4,7 +4,6 @@ use codex_protocol::protocol::MULTI_AGENT_MODE_CLOSE_TAG;
use codex_protocol::protocol::MULTI_AGENT_MODE_OPEN_TAG;
const EXPLICIT_REQUEST_ONLY_MULTI_AGENT_MODE_TEXT: &str = "Do not spawn sub-agents unless the user or applicable AGENTS.md/skill instructions explicitly ask for sub-agents, delegation, or parallel agent work.";
const NO_MULTI_AGENT_MODE_TEXT: &str = "Multi-agent delegation mode instructions are inactive. Any earlier multi-agent mode developer message no longer applies.";
const PROACTIVE_MULTI_AGENT_MODE_TEXT: &str = "Proactive multi-agent delegation is active. Any earlier instruction requiring an explicit user request before spawning sub-agents no longer applies. Use sub-agents when parallel work would materially improve speed or quality. This mode remains active until a later multi-agent mode developer message changes it.";
#[derive(Debug, Clone, PartialEq, Eq)]
@@ -32,8 +31,8 @@ impl ContextualUserFragment for MultiAgentModeInstructions {
}
fn body(&self) -> String {
match self.multi_agent_mode {
MultiAgentMode::None => NO_MULTI_AGENT_MODE_TEXT.to_string(),
match &self.multi_agent_mode {
MultiAgentMode::Custom(hint_text) => hint_text.clone(),
MultiAgentMode::ExplicitRequestOnly => {
EXPLICIT_REQUEST_ONLY_MULTI_AGENT_MODE_TEXT.to_string()
}
+15 -3
View File
@@ -1,5 +1,6 @@
mod agents_md;
mod environment;
mod plugins_instructions;
use crate::context::ContextualUserFragment;
use codex_extension_api::PreviousWorldStateSection;
@@ -17,6 +18,7 @@ use std::fmt;
pub(crate) use agents_md::AgentsMdState;
pub(crate) use environment::EnvironmentsState;
pub(crate) use plugins_instructions::PluginsInstructionsState;
trait ErasedWorldStateSection: Send + Sync {
fn snapshot(&self) -> Option<Value>;
@@ -62,11 +64,11 @@ impl<S: WorldStateSection> ErasedWorldStateSection for S {
}
fn has_retained_fragment_matcher(&self) -> bool {
false
S::has_retained_fragment_matcher()
}
fn matches_retained_fragment(&self, _role: &str, _text: &str) -> bool {
false
fn matches_retained_fragment(&self, role: &str, text: &str) -> bool {
S::matches_retained_fragment(role, text)
}
fn render_diff(
@@ -183,6 +185,16 @@ pub(crate) trait WorldStateSection: Send + Sync + 'static {
false
}
/// Whether retained history must still contain this section's rendered fragment.
fn has_retained_fragment_matcher() -> bool {
false
}
/// Recognizes this section's rendered fragment in retained model history.
fn matches_retained_fragment(_role: &str, _text: &str) -> bool {
false
}
fn render_diff(
&self,
previous: PreviousSectionState<'_, Self::Snapshot>,
@@ -0,0 +1,55 @@
use super::PreviousSectionState;
use super::WorldStateSection;
use crate::context::AvailablePluginsInstructions;
use crate::context::ContextualUserFragment;
/// Whether generic plugin usage guidance should be visible to the model.
#[derive(Clone, Copy, Debug, Default)]
pub(crate) struct PluginsInstructionsState {
available: bool,
}
impl PluginsInstructionsState {
pub(crate) fn new(available: bool) -> Self {
Self { available }
}
}
impl WorldStateSection for PluginsInstructionsState {
const ID: &'static str = "plugins_instructions";
type Snapshot = bool;
fn snapshot(&self) -> Self::Snapshot {
self.available
}
fn matches_legacy_fragment(role: &str, text: &str) -> bool {
role == "developer" && AvailablePluginsInstructions::matches_text(text)
}
fn has_retained_fragment_matcher() -> bool {
true
}
fn matches_retained_fragment(role: &str, text: &str) -> bool {
Self::matches_legacy_fragment(role, text)
}
fn render_diff(
&self,
previous: PreviousSectionState<'_, Self::Snapshot>,
) -> Option<Box<dyn ContextualUserFragment>> {
if !self.available
|| matches!(previous, PreviousSectionState::Known(previous) if *previous)
|| matches!(previous, PreviousSectionState::Unknown)
{
return None;
}
Some(Box::new(AvailablePluginsInstructions))
}
}
#[cfg(test)]
#[path = "plugins_instructions_tests.rs"]
mod tests;
@@ -0,0 +1,72 @@
use super::*;
use crate::context::ContextualUserFragment;
use codex_protocol::models::ResponseItem;
use pretty_assertions::assert_eq;
fn render(
state: PluginsInstructionsState,
previous: PreviousSectionState<'_, bool>,
) -> Vec<String> {
state
.render_diff(previous)
.into_iter()
.map(|fragment| fragment.render())
.collect()
}
#[test]
fn renders_only_when_plugins_become_available() {
let unavailable = PluginsInstructionsState::new(/*available*/ false);
let available = PluginsInstructionsState::new(/*available*/ true);
let false_snapshot = false;
let true_snapshot = true;
assert_eq!(
render(unavailable, PreviousSectionState::Absent),
Vec::<String>::new()
);
assert_eq!(render(available, PreviousSectionState::Absent).len(), 1);
assert_eq!(
render(available, PreviousSectionState::Known(&false_snapshot)).len(),
1
);
assert_eq!(
render(available, PreviousSectionState::Known(&true_snapshot)),
Vec::<String>::new()
);
assert_eq!(
render(unavailable, PreviousSectionState::Known(&true_snapshot)),
Vec::<String>::new()
);
}
#[test]
fn legacy_guidance_is_not_injected_again() {
let mut world_state = super::super::WorldState::default();
world_state.add_section(PluginsInstructionsState::new(/*available*/ true));
let legacy: ResponseItem = ContextualUserFragment::into(AvailablePluginsInstructions);
assert!(
world_state
.render_history_diff(/*previous*/ None, &[legacy])
.is_empty()
);
}
#[test]
fn persisted_guidance_is_restored_only_when_missing_from_history() {
let mut world_state = super::super::WorldState::default();
world_state.add_section(PluginsInstructionsState::new(/*available*/ true));
let snapshot = world_state.snapshot();
let retained: ResponseItem = ContextualUserFragment::into(AvailablePluginsInstructions);
assert_eq!(
world_state.render_history_diff(Some(&snapshot), &[]).len(),
1
);
assert!(
world_state
.render_history_diff(Some(&snapshot), &[retained])
.is_empty()
);
}
@@ -22,6 +22,7 @@ use codex_protocol::openai_models::InputModality;
use codex_protocol::openai_models::default_input_modalities;
use codex_protocol::protocol::AskForApproval;
use codex_protocol::protocol::InterAgentCommunication;
use codex_protocol::protocol::PLUGINS_INSTRUCTIONS_OPEN_TAG;
use codex_protocol::protocol::SandboxPolicy;
use codex_protocol::protocol::TurnContextItem;
use codex_utils_absolute_path::AbsolutePathBuf;
@@ -1021,6 +1022,9 @@ fn drop_last_n_user_turns_trims_context_updates_above_rolled_back_turn() {
user_input_text_msg("turn 1 user"),
assistant_msg("turn 1 assistant"),
developer_msg("Generated images are saved to /tmp as /tmp/image-1.png by default."),
developer_msg(&format!(
"{PLUGINS_INSTRUCTIONS_OPEN_TAG}\nROLLED_BACK_PLUGIN_INSTRUCTIONS"
)),
developer_msg("<collaboration_mode>ROLLED_BACK_DEV_INSTRUCTIONS</collaboration_mode>"),
developer_msg("<multi_agent_mode>ROLLED_BACK_MULTI_AGENT_MODE</multi_agent_mode>"),
user_input_text_msg(
@@ -85,9 +85,6 @@ fn build_multi_agent_mode_update_item(
}
match effective_multi_agent_mode {
Some(MultiAgentMode::None) => {
Some(MultiAgentModeInstructions::new(MultiAgentMode::None).render())
}
Some(multi_agent_mode) => Some(MultiAgentModeInstructions::new(multi_agent_mode).render()),
None if previous.multi_agent_mode == Some(MultiAgentMode::Proactive) => {
Some(MultiAgentModeInstructions::new(MultiAgentMode::ExplicitRequestOnly).render())
+100
View File
@@ -0,0 +1,100 @@
use std::sync::Arc;
use std::sync::Mutex;
use tokio::sync::watch;
/// Coordinates user elicitations that pause tool-result delivery for a session.
///
/// Registrations are counted so concurrent elicitations keep the session paused until all of them
/// finish. Consumers can subscribe to pause timeout progress or wait before returning an already
/// captured result.
#[derive(Clone)]
pub(crate) struct ElicitationService {
inner: Arc<Inner>,
}
struct Inner {
state: Mutex<State>,
paused: watch::Sender<bool>,
}
#[derive(Default)]
struct State {
outstanding: i64,
}
pub(crate) struct ElicitationRegistration {
service: ElicitationService,
}
impl ElicitationService {
pub(crate) fn new() -> Self {
let (paused, _paused_rx) = watch::channel(false);
Self {
inner: Arc::new(Inner {
state: Mutex::new(State::default()),
paused,
}),
}
}
pub(crate) fn register(&self) -> ElicitationRegistration {
self.increment();
ElicitationRegistration {
service: self.clone(),
}
}
fn increment(&self) {
let mut state = self
.inner
.state
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let was_clear = state.outstanding == 0;
assert_ne!(
state.outstanding,
i64::MAX,
"outstanding elicitation count overflowed"
);
state.outstanding += 1;
if was_clear {
self.inner.paused.send_replace(true);
}
}
pub(crate) fn subscribe(&self) -> watch::Receiver<bool> {
self.inner.paused.subscribe()
}
pub(crate) async fn wait_until_clear(&self) {
let mut paused = self.subscribe();
let _ = paused.wait_for(|paused| !*paused).await;
}
fn decrement(&self) {
let mut state = self
.inner
.state
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
assert!(
state.outstanding > 0,
"elicitation registration count underflowed"
);
state.outstanding -= 1;
if state.outstanding == 0 {
self.inner.paused.send_replace(false);
}
}
}
impl Drop for ElicitationRegistration {
fn drop(&mut self) {
self.service.decrement();
}
}
#[cfg(test)]
#[path = "elicitation_tests.rs"]
mod tests;
+19
View File
@@ -0,0 +1,19 @@
use super::*;
#[tokio::test]
async fn wait_until_clear_waits_for_every_registration() {
let service = ElicitationService::new();
let first = service.register();
let second = service.register();
let waiting = tokio::spawn({
let service = service.clone();
async move { service.wait_until_clear().await }
});
drop(first);
tokio::task::yield_now().await;
assert!(!waiting.is_finished());
drop(second);
waiting.await.expect("elicitation waiter should complete");
}
+2
View File
@@ -18,6 +18,7 @@ use codex_protocol::protocol::COLLABORATION_MODE_OPEN_TAG;
use codex_protocol::protocol::CONTEXT_WINDOW_GUIDANCE_OPEN_TAG;
use codex_protocol::protocol::CONTEXT_WINDOW_OPEN_TAG;
use codex_protocol::protocol::MULTI_AGENT_MODE_OPEN_TAG;
use codex_protocol::protocol::PLUGINS_INSTRUCTIONS_OPEN_TAG;
use codex_protocol::protocol::REALTIME_CONVERSATION_OPEN_TAG;
use codex_protocol::protocol::SKILLS_INSTRUCTIONS_OPEN_TAG;
use codex_protocol::user_input::UserInput;
@@ -33,6 +34,7 @@ const CONTEXTUAL_DEVELOPER_PREFIXES: &[&str] = &[
"<model_switch>",
COLLABORATION_MODE_OPEN_TAG,
MULTI_AGENT_MODE_OPEN_TAG,
PLUGINS_INSTRUCTIONS_OPEN_TAG,
REALTIME_CONVERSATION_OPEN_TAG,
SKILLS_INSTRUCTIONS_OPEN_TAG,
"<personality_spec>",
+2
View File
@@ -31,6 +31,7 @@ pub use codex_thread::TryStartTurnIfIdleError;
pub use codex_thread::TryStartTurnIfIdleRejectionReason;
pub use session::turn_context::TurnContext;
mod agent;
mod agent_communication;
mod attestation;
mod codex_delegate;
mod command_canonicalization;
@@ -39,6 +40,7 @@ pub mod connectors;
pub mod context;
mod context_manager;
mod current_time;
mod elicitation;
mod environment_selection;
pub mod exec;
pub mod exec_env;
+34 -16
View File
@@ -25,6 +25,12 @@ use codex_plugin::AppConnectorId;
const LEGACY_CODEX_APPS_REGISTRATION_ID: &str = "legacy_codex_apps";
/// MCP configuration and capability availability derived from the same inputs.
pub(crate) struct McpRuntimeProjection {
pub(crate) config: McpConfig,
pub(crate) plugins_available: bool,
}
enum OrderedMcpOverlay {
Set {
contributor_id: &'static str,
@@ -75,6 +81,7 @@ impl McpManager {
pub async fn runtime_config(&self, config: &Config) -> McpConfig {
self.runtime_config_with_context(McpServerContributionContext::global(config))
.await
.config
}
pub(crate) async fn runtime_config_for_step(
@@ -83,7 +90,7 @@ impl McpManager {
thread_init: &ExtensionDataInit,
thread_store: &ExtensionData,
available_environment_ids: &[String],
) -> McpConfig {
) -> McpRuntimeProjection {
self.runtime_config_with_context(McpServerContributionContext::for_step(
config,
thread_init,
@@ -96,8 +103,9 @@ impl McpManager {
async fn runtime_config_with_context(
&self,
context: McpServerContributionContext<'_, Config>,
) -> McpConfig {
) -> McpRuntimeProjection {
let config = context.config();
let mut selected_plugin_available = false;
let mut selected_plugin_connector_sources = Vec::new();
let mut selected_plugin_registrations = Vec::new();
let mut overlays = Vec::new();
@@ -129,17 +137,22 @@ impl McpManager {
*config,
),
),
McpServerContribution::SelectedPluginConnectors {
McpServerContribution::SelectedPluginPackage {
plugin_id,
plugin_display_name,
connector_ids,
} => selected_plugin_connector_sources.push(
PluginConnectorSource::from_connector_ids(
plugin_id,
plugin_display_name,
connector_ids.into_iter().map(AppConnectorId),
),
),
} => {
selected_plugin_available = true;
if !connector_ids.is_empty() {
selected_plugin_connector_sources.push(
PluginConnectorSource::from_connector_ids(
plugin_id,
plugin_display_name,
connector_ids.into_iter().map(AppConnectorId),
),
);
}
}
McpServerContribution::Remove { name } => {
overlays.push(OrderedMcpOverlay::Remove {
contributor_id: contributor.id(),
@@ -152,12 +165,14 @@ impl McpManager {
}
}
let mut mcp_config = config
.to_mcp_config_with_plugin_registrations(
self.plugins_manager.as_ref(),
selected_plugin_registrations,
)
let loaded_plugins = self
.plugins_manager
.plugins_for_config(&config.plugins_config_input())
.await;
let plugins_available =
selected_plugin_available || !loaded_plugins.capability_summaries().is_empty();
let mut mcp_config = config
.to_mcp_config_with_loaded_plugins(&loaded_plugins, selected_plugin_registrations);
let mut catalog = mcp_config.mcp_server_catalog.to_builder();
if mcp_config.apps_enabled {
catalog.register(McpServerRegistration::from_compatibility(
@@ -211,7 +226,10 @@ impl McpManager {
.merged_with(&ConnectorSnapshot::from_plugin_sources(
selected_plugin_connector_sources,
));
mcp_config
McpRuntimeProjection {
config: mcp_config,
plugins_available,
}
}
/// Returns config- and plugin-backed servers without runtime contributions.
+1
View File
@@ -1439,6 +1439,7 @@ async fn host_owned_codex_apps_manager(
codex_mcp::ToolPluginProvenance::default(),
auth.as_ref(),
/*elicitation_reviewer*/ None,
/*elicitation_lifecycle*/ None,
codex_mcp::ElicitationRequestRouter::default(),
)
.await;
+1 -1
View File
@@ -6,7 +6,7 @@ use crate::plugins::PluginCapabilitySummary;
#[cfg(test)]
pub(crate) fn render_plugins_section(plugins: &[PluginCapabilitySummary]) -> Option<String> {
AvailablePluginsInstructions::from_plugins(plugins).map(|instructions| instructions.render())
(!plugins.is_empty()).then(|| AvailablePluginsInstructions.render())
}
pub(crate) fn render_explicit_plugin_instructions(
+1
View File
@@ -285,6 +285,7 @@ pub async fn inter_agent_communication(
sess.input_queue
.enqueue_mailbox_communication(communication)
.await;
crate::agent_communication::emit_agent_communication_receive(&sub_id);
if trigger_turn {
sess.maybe_start_turn_for_pending_work_with_sub_id(sub_id)
.await;
+25 -8
View File
@@ -1,4 +1,5 @@
use super::*;
use crate::mcp::McpRuntimeProjection;
use codex_exec_server::ResolvedSelectedCapabilityRoot;
use codex_mcp::ElicitationReviewRequest;
use codex_mcp::ElicitationReviewer;
@@ -92,6 +93,7 @@ impl Session {
&available_environment_ids,
)
.await
.config
}
pub(crate) async fn runtime_mcp_servers(
@@ -123,7 +125,7 @@ impl Session {
if current.available_environment_ids() == available_environment_ids {
return current;
}
let mcp_config = self
let mcp_projection = self
.services
.mcp_manager
.runtime_config_for_step(
@@ -133,6 +135,7 @@ impl Session {
&available_environment_ids,
)
.await;
let mcp_config = &mcp_projection.config;
let changed_environment_is_used_by_mcp = mcp_config
.mcp_server_catalog
.configured_servers()
@@ -156,6 +159,7 @@ impl Session {
// replacing the live manager and restarting its processes.
let runtime = Arc::new(McpRuntimeSnapshot::new(
Arc::new(current.config().clone()),
mcp_projection.plugins_available,
current.manager_arc(),
current.runtime_context().clone(),
available_environment_ids,
@@ -165,7 +169,7 @@ impl Session {
}
self.refresh_mcp_servers_inner(
turn_context,
mcp_config,
mcp_projection,
environments,
&available_environment_ids,
Some(self.mcp_elicitation_reviewer()),
@@ -191,6 +195,11 @@ impl Session {
Arc::new(GuardianMcpElicitationReviewer::new(self))
}
pub(crate) fn mcp_elicitation_lifecycle(&self) -> codex_mcp::ElicitationLifecycle {
let elicitations = self.services.elicitations.clone();
codex_mcp::ElicitationLifecycle::new(move || elicitations.register())
}
#[expect(
clippy::await_holding_invalid_type,
reason = "active turn checks and turn state updates must remain atomic"
@@ -218,6 +227,7 @@ impl Session {
};
}
let _elicitation = self.services.elicitations.register();
let (tx_response, rx_response) = oneshot::channel();
let prev_entry = {
let mut active = self.active_turn.lock().await;
@@ -309,12 +319,16 @@ impl Session {
async fn refresh_mcp_servers_inner(
&self,
turn_context: &TurnContext,
mcp_config: McpConfig,
mcp_projection: McpRuntimeProjection,
environments: &TurnEnvironmentSnapshot,
available_environment_ids: &[String],
elicitation_reviewer: Option<ElicitationReviewerHandle>,
) -> Arc<McpRuntimeSnapshot> {
let auth = self.services.auth_manager.auth().await;
let McpRuntimeProjection {
config: mcp_config,
plugins_available,
} = mcp_projection;
let mcp_config = Arc::new(mcp_config);
let tool_plugin_provenance = codex_mcp::tool_plugin_provenance(&mcp_config);
let mcp_servers = effective_mcp_servers(&mcp_config, auth.as_ref());
@@ -369,6 +383,7 @@ impl Session {
tool_plugin_provenance,
auth.as_ref(),
elicitation_reviewer,
Some(self.mcp_elicitation_lifecycle()),
current_runtime.manager().elicitation_router(),
)
.await;
@@ -376,6 +391,7 @@ impl Session {
.set_elicitations_auto_deny(current_runtime.manager().elicitations_auto_deny());
self.services.publish_mcp_runtime(
mcp_config,
plugins_available,
mcp_runtime_context,
available_environment_ids.to_vec(),
refreshed_manager,
@@ -448,7 +464,7 @@ impl Session {
.latest_mcp_runtime()
.available_environment_ids()
.to_vec();
let mut mcp_config = self
let mut mcp_projection = self
.services
.mcp_manager
.runtime_config_for_step(
@@ -458,12 +474,13 @@ impl Session {
&available_environment_ids,
)
.await;
mcp_config.mcp_server_catalog = mcp_config
mcp_projection.config.mcp_server_catalog = mcp_projection
.config
.mcp_server_catalog
.with_materialized_servers(mcp_servers);
self.refresh_mcp_servers_inner(
turn_context,
mcp_config,
mcp_projection,
&turn_context.environments,
&available_environment_ids,
elicitation_reviewer,
@@ -515,7 +532,7 @@ impl Session {
.latest_mcp_runtime()
.available_environment_ids()
.to_vec();
let mcp_config = self
let mcp_projection = self
.services
.mcp_manager
.runtime_config_for_step(
@@ -527,7 +544,7 @@ impl Session {
.await;
self.refresh_mcp_servers_inner(
turn_context,
mcp_config,
mcp_projection,
&turn_context.environments,
&available_environment_ids,
elicitation_reviewer,

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