mirror of
https://github.com/openai/codex.git
synced 2026-11-06 20:20:34 +00:00
Merge branch 'main' into codex/se-7928-otel-best-effort
This commit is contained in:
@@ -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:
|
||||
|
||||
Generated
+4
-3
File diff suppressed because one or more lines are too long
-46
@@ -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
|
||||
@@ -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
|
||||
]
|
||||
|
||||
Generated
+19
-9
@@ -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
@@ -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"
|
||||
|
||||
@@ -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
@@ -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": [
|
||||
|
||||
+112
-7
@@ -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": {
|
||||
|
||||
+112
-7
@@ -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": {
|
||||
|
||||
+7
@@ -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"
|
||||
|
||||
+74
@@ -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": [
|
||||
|
||||
+23
-7
@@ -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";
|
||||
|
||||
Generated
+6
-1
@@ -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, };
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
// GENERATED CODE! DO NOT MODIFY BY HAND!
|
||||
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
|
||||
export type RateLimitResetCreditStatus = "available" | "redeeming" | "redeemed" | "unknown";
|
||||
+10
-1
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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?;
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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(¶ms.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(¶ms.idempotency_key, credit_id)
|
||||
.await
|
||||
}
|
||||
None => {
|
||||
client
|
||||
.consume_rate_limit_reset_credit(¶ms.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(¬ification) {
|
||||
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,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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(¬ification)
|
||||
{
|
||||
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" },
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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()),
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()),
|
||||
})
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -1750,6 +1750,9 @@
|
||||
"minimum": 0.0,
|
||||
"type": "integer"
|
||||
},
|
||||
"multi_agent_mode_hint_text": {
|
||||
"type": "string"
|
||||
},
|
||||
"non_code_mode_only": {
|
||||
"type": "boolean"
|
||||
},
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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()?;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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");
|
||||
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()?;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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;
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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>",
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user