Merge origin/main into jif/disable-side-multi-agent

This commit is contained in:
jif-oai
2026-06-09 22:45:38 +01:00
143 changed files with 5197 additions and 572 deletions
+6 -2
View File
@@ -28,7 +28,7 @@ Accept any of the following:
3. Inspect the `actions` list in the JSON response.
4. If `diagnose_ci_failure` is present, inspect failed run logs and classify the failure.
5. If the failure is likely caused by the current branch, patch code locally, commit, and push. Do not patch random flaky tests, CI infrastructure, dependency outages, runner issues, or other failures that are unrelated to the branch.
6. If `process_review_comment` is present, inspect surfaced review items and decide whether to address them.
6. If `process_review_comment` is present, inspect surfaced published review items and decide whether to address them.
7. If a review item is actionable and correct, patch code locally, commit, push, and then resolve the associated review thread only when allowed by the GitHub state mutation policy below.
8. Do not post replies to human-authored review comments/threads unless the user explicitly confirms the exact response. If a human review item is non-actionable, already addressed, or not valid, surface the item and recommended response to the user instead of replying on GitHub.
9. If the failure is likely flaky/unrelated and `retry_failed_checks` is present, rerun failed jobs with `--retry-failed-now`.
@@ -92,9 +92,13 @@ The watcher surfaces review items from:
- Inline review comments
- Review submissions (COMMENT / APPROVED / CHANGES_REQUESTED)
Only act on published feedback. Ignore review submissions in GitHub's `PENDING` state and inline
comments attached to those pending reviews. Do not mark pending review feedback as seen; it should
be eligible to surface after the reviewer submits the review.
It intentionally surfaces Codex reviewer bot feedback (for example comments/reviews from `chatgpt-codex-connector[bot]`) in addition to human reviewer feedback. Most unrelated bot noise should still be ignored.
For safety, the watcher only auto-surfaces trusted human review authors (for example repo OWNER/MEMBER/COLLABORATOR, plus the authenticated operator) and approved review bots such as Codex.
On a fresh watcher state file, existing pending review feedback may be surfaced immediately (not only comments that arrive after monitoring starts). This is intentional so already-open review comments are not missed.
On a fresh watcher state file, existing unaddressed published review feedback may be surfaced immediately (not only comments that arrive after monitoring starts). This is intentional so already-open review comments are not missed.
When you agree with a comment and it is actionable:
+1 -1
View File
@@ -1,4 +1,4 @@
interface:
display_name: "PR Babysitter"
short_description: "Watch PR review comments, CI, and merge conflicts"
default_prompt: "Babysit the current PR: monitor reviewer comments, CI, and merge-conflict status (prefer the watcher’s --watch mode for live monitoring); surface new review feedback before acting on CI or mergeability work, fix valid issues, push updates, and rerun flaky failures up to 3 times. Do not post replies to human-authored review comments unless the user explicitly confirms the exact response. Do not patch unrelated flaky tests, CI infrastructure, dependency outages, runner issues, or other failures that are not caused by the branch. Keep exactly one watcher session active for the PR (do not leave duplicate --watch terminals running). If you pause monitoring to patch review/CI feedback, restart --watch yourself immediately after the push in the same turn. If a watcher is still running and no strict stop condition has been reached, the task is still in progress: keep consuming watcher output and sending progress updates instead of ending the turn. Do not treat a green + mergeable PR as a terminal stop while it is still open; continue polling autonomously after any push/rerun so newly posted review comments are surfaced until a strict terminal stop condition is reached or the user interrupts."
default_prompt: "Babysit the current PR: monitor published reviewer comments, CI, and merge-conflict status (prefer the watcher’s --watch mode for live monitoring); ignore unpublished comments in pending GitHub reviews; surface new published review feedback before acting on CI or mergeability work, fix valid issues, push updates, and rerun flaky failures up to 3 times. Do not post replies to human-authored review comments unless the user explicitly confirms the exact response. Do not patch unrelated flaky tests, CI infrastructure, dependency outages, runner issues, or other failures that are not caused by the branch. Keep exactly one watcher session active for the PR (do not leave duplicate --watch terminals running). If you pause monitoring to patch review/CI feedback, restart --watch yourself immediately after the push in the same turn. If a watcher is still running and no strict stop condition has been reached, the task is still in progress: keep consuming watcher output and sending progress updates instead of ending the turn. Do not treat a green + mergeable PR as a terminal stop while it is still open; continue polling autonomously after any push/rerun so newly posted review comments are surfaced until a strict terminal stop condition is reached or the user interrupts."
@@ -44,6 +44,9 @@ Reruns only failed jobs (and dependencies) for a workflow run.
- Review submissions:
- `gh api repos/{owner}/{repo}/pulls/<pr_number>/reviews?per_page=100`
Use each inline comment's `pull_request_review_id` to find its parent review. Ignore parent reviews
whose `state` is `PENDING`, along with their inline comments, until the review is submitted.
## JSON fields consumed by the watcher
### `gh pr view`
@@ -452,11 +452,14 @@ def normalize_issue_comments(items):
return out
def normalize_review_comments(items):
def normalize_review_comments(items, review_states):
out = []
for item in items:
if not isinstance(item, dict):
continue
review_id = str(item.get("pull_request_review_id") or "")
if review_states.get(review_id) == "PENDING":
continue
line = item.get("line")
if line is None:
line = item.get("original_line")
@@ -481,6 +484,8 @@ def normalize_reviews(items):
for item in items:
if not isinstance(item, dict):
continue
if str(item.get("state") or "").upper() == "PENDING":
continue
out.append(
{
"kind": "review",
@@ -534,16 +539,33 @@ def fetch_new_review_items(pr, state, fresh_state, authenticated_login=None):
review_payload = gh_api_list_paginated(endpoints["review"], repo=repo)
issue_items = normalize_issue_comments(issue_payload)
review_comment_items = normalize_review_comments(review_comment_payload)
review_states = {
str(item.get("id")): str(item.get("state") or "").upper()
for item in review_payload
if isinstance(item, dict) and item.get("id") not in (None, "")
}
pending_review_ids = {
review_id for review_id, review_state in review_states.items() if review_state == "PENDING"
}
pending_review_comment_ids = {
str(item.get("id"))
for item in review_comment_payload
if isinstance(item, dict)
and item.get("id") not in (None, "")
and str(item.get("pull_request_review_id") or "") in pending_review_ids
}
review_comment_items = normalize_review_comments(review_comment_payload, review_states)
review_items = normalize_reviews(review_payload)
all_items = issue_items + review_comment_items + review_items
seen_issue = {str(x) for x in state.get("seen_issue_comment_ids") or []}
seen_review_comment = {str(x) for x in state.get("seen_review_comment_ids") or []}
seen_review = {str(x) for x in state.get("seen_review_ids") or []}
seen_review_comment.difference_update(pending_review_comment_ids)
seen_review.difference_update(pending_review_ids)
# On a brand-new state file, surface existing review activity instead of
# silently treating it as seen. This avoids missing already-pending review
# silently treating it as seen. This avoids missing already-published review
# feedback when monitoring starts after comments were posted.
new_items = []
@@ -118,6 +118,74 @@ def test_recommend_actions_prioritizes_review_comments():
]
def test_pending_review_feedback_surfaces_only_after_publication(monkeypatch):
state = {
"seen_review_comment_ids": ["20"],
"seen_review_ids": ["10"],
}
review = {
"id": 10,
"user": {"login": "octocat"},
"author_association": "MEMBER",
"state": "PENDING",
"body": "Please rename this.",
"created_at": "2026-06-08T10:00:00Z",
"submitted_at": None,
"html_url": "https://github.com/openai/codex/pull/123#pullrequestreview-10",
}
review_comment = {
"id": 20,
"pull_request_review_id": 10,
"user": {"login": "octocat"},
"author_association": "MEMBER",
"body": "Please rename this.",
"created_at": "2026-06-08T10:00:00Z",
"path": "src/example.rs",
"line": 7,
"html_url": "https://github.com/openai/codex/pull/123#discussion_r20",
}
def fake_list(endpoint, **kwargs):
if endpoint.endswith("/issues/123/comments"):
return []
if endpoint.endswith("/pulls/123/comments"):
return [review_comment]
if endpoint.endswith("/pulls/123/reviews"):
return [review]
raise AssertionError(f"unexpected endpoint: {endpoint}")
monkeypatch.setattr(gh_pr_watch, "gh_api_list_paginated", fake_list)
assert (
gh_pr_watch.fetch_new_review_items(
sample_pr(),
state,
fresh_state=True,
authenticated_login="octocat",
)
== []
)
assert state["seen_review_comment_ids"] == []
assert state["seen_review_ids"] == []
review["state"] = "COMMENTED"
review["submitted_at"] = "2026-06-08T10:05:00Z"
published_items = gh_pr_watch.fetch_new_review_items(
sample_pr(),
state,
fresh_state=False,
authenticated_login="octocat",
)
assert {(item["kind"], item["id"]) for item in published_items} == {
("review", "10"),
("review_comment", "20"),
}
assert state["seen_review_comment_ids"] == ["20"]
assert state["seen_review_ids"] == ["10"]
def test_run_watch_keeps_polling_open_ready_to_merge_pr(monkeypatch):
sleeps = []
events = []
+14
View File
@@ -8,12 +8,20 @@ retries = 1
[profile.default.junit]
path = "junit.xml"
[profile.local]
inherits = "default"
[test-groups.app_server_protocol_codegen]
max-threads = 1
[test-groups.app_server_integration]
max-threads = 1
# Higher concurrency causes integration test timeouts under resource contention
# on common developer machines.
[test-groups.app_server_integration_local]
max-threads = 4
[test-groups.core_apply_patch_cli_integration]
max-threads = 1
@@ -42,6 +50,12 @@ test-group = 'app_server_protocol_codegen'
filter = 'package(codex-app-server) & kind(test)'
test-group = 'app_server_integration'
[[profile.local.overrides]]
# Use up to four app-server subprocesses locally. The global nextest pool still
# limits this to the machine's logical CPU count.
filter = 'package(codex-app-server) & kind(test)'
test-group = 'app_server_integration_local'
[[profile.default.overrides]]
# These tests exercise full Codex turns and apply_patch execution, and they are
# sensitive to Windows runner process-startup stalls when many cases launch at once.
+25
View File
@@ -1982,6 +1982,7 @@ dependencies = [
"codex-image-generation-extension",
"codex-login",
"codex-mcp",
"codex-mcp-extension",
"codex-memories-extension",
"codex-memories-write",
"codex-model-provider",
@@ -1994,6 +1995,7 @@ dependencies = [
"codex-rollout",
"codex-sandboxing",
"codex-shell-command",
"codex-skills-extension",
"codex-state",
"codex-thread-store",
"codex-tools",
@@ -2919,9 +2921,12 @@ name = "codex-extension-api"
version = "0.0.0"
dependencies = [
"async-trait",
"codex-config",
"codex-context-fragments",
"codex-protocol",
"codex-tools",
"pretty_assertions",
"tokio",
]
[[package]]
@@ -3244,6 +3249,23 @@ dependencies = [
"url",
]
[[package]]
name = "codex-mcp-extension"
version = "0.0.0"
dependencies = [
"async-trait",
"codex-config",
"codex-core",
"codex-core-plugins",
"codex-extension-api",
"codex-features",
"codex-login",
"codex-mcp",
"pretty_assertions",
"tempfile",
"tokio",
]
[[package]]
name = "codex-mcp-server"
version = "0.0.0"
@@ -3796,8 +3818,11 @@ dependencies = [
"async-trait",
"codex-core",
"codex-core-skills",
"codex-exec-server",
"codex-extension-api",
"codex-protocol",
"codex-utils-absolute-path",
"codex-utils-string",
"pretty_assertions",
"tokio",
]
+3
View File
@@ -49,6 +49,7 @@ members = [
"ext/guardian",
"ext/image-generation",
"ext/memories",
"ext/mcp",
"ext/skills",
"ext/web-search",
"external-agent-migration",
@@ -191,6 +192,7 @@ codex-web-search-extension = { path = "ext/web-search" }
codex-memories-read = { path = "memories/read" }
codex-memories-write = { path = "memories/write" }
codex-mcp = { path = "codex-mcp" }
codex-mcp-extension = { path = "ext/mcp" }
codex-mcp-server = { path = "mcp-server" }
codex-model-provider-info = { path = "model-provider-info" }
codex-models-manager = { path = "models-manager" }
@@ -212,6 +214,7 @@ codex-sandboxing = { path = "sandboxing" }
codex-secrets = { path = "secrets" }
codex-shell-command = { path = "shell-command" }
codex-shell-escalation = { path = "shell-escalation" }
codex-skills-extension = { path = "ext/skills" }
codex-skills = { path = "skills" }
codex-state = { path = "state" }
codex-stdio-to-uds = { path = "stdio-to-uds" }
@@ -1374,7 +1374,7 @@ fn thread_initialized_event_serializes_expected_shape() {
},
model: "gpt-5".to_string(),
ephemeral: true,
thread_source: Some(ThreadSource::User),
thread_source: Some(ThreadSource::Feature("automation".to_string())),
initialization_mode: ThreadInitializationMode::New,
subagent_source: None,
parent_thread_id: None,
@@ -1407,7 +1407,7 @@ fn thread_initialized_event_serializes_expected_shape() {
},
"model": "gpt-5",
"ephemeral": true,
"thread_source": "user",
"thread_source": "automation",
"initialization_mode": "new",
"subagent_source": null,
"parent_thread_id": null,
+5 -5
View File
@@ -1263,7 +1263,7 @@ impl AnalyticsReducer {
thread_metadata.session_id.clone(),
connection_state.app_server_client.clone(),
connection_state.runtime.clone(),
thread_metadata.thread_source,
thread_metadata.thread_source.clone(),
thread_metadata.subagent_source.clone(),
thread_metadata.parent_thread_id.clone(),
),
@@ -1368,7 +1368,7 @@ impl AnalyticsReducer {
accepted_turn_id,
app_server_client: connection_state.app_server_client.clone(),
runtime: connection_state.runtime.clone(),
thread_source: thread_metadata.thread_source,
thread_source: thread_metadata.thread_source.clone(),
subagent_source: thread_metadata.subagent_source.clone(),
parent_thread_id: thread_metadata.parent_thread_id.clone(),
num_input_images: pending_request.num_input_images,
@@ -1411,7 +1411,7 @@ impl AnalyticsReducer {
review_id: pending_review.review_id,
app_server_client: connection_state.app_server_client.clone(),
runtime: connection_state.runtime.clone(),
thread_source: thread_metadata.thread_source,
thread_source: thread_metadata.thread_source.clone(),
subagent_source: thread_metadata.subagent_source.clone(),
parent_thread_id: thread_metadata.parent_thread_id.clone(),
subject_kind: pending_review.subject_kind,
@@ -1979,7 +1979,7 @@ fn tool_item_base(
item_id,
app_server_client: context.connection_state.app_server_client.clone(),
runtime: context.connection_state.runtime.clone(),
thread_source: thread_metadata.thread_source,
thread_source: thread_metadata.thread_source.clone(),
subagent_source: thread_metadata.subagent_source.clone(),
parent_thread_id: thread_metadata.parent_thread_id.clone(),
tool_name,
@@ -2459,7 +2459,7 @@ fn codex_turn_event_params(
runtime,
submission_type,
ephemeral,
thread_source: thread_metadata.thread_source,
thread_source: thread_metadata.thread_source.clone(),
initialization_mode: thread_metadata.initialization_mode,
subagent_source: thread_metadata.subagent_source.clone(),
parent_thread_id: thread_metadata.parent_thread_id.clone(),
-20
View File
@@ -70,13 +70,9 @@ pub use crate::remote::RemoteAppServerEndpoint;
/// module exists so clients can remove a direct `codex-core` dependency
/// while legacy startup/config paths are migrated to RPCs.
pub mod legacy_core {
pub use codex_core::DEFAULT_AGENTS_MD_FILENAME;
pub use codex_core::LOCAL_AGENTS_MD_FILENAME;
pub use codex_core::McpManager;
pub use codex_core::check_execpolicy_for_warnings;
pub use codex_core::format_exec_policy_error_with_source;
pub use codex_core::grant_read_root_non_elevated;
pub use codex_core::web_search_detail;
pub mod config {
pub use codex_core::config::*;
@@ -86,10 +82,6 @@ pub mod legacy_core {
}
}
pub mod connectors {
pub use codex_core::connectors::*;
}
pub mod otel_init {
pub use codex_core::otel_init::*;
}
@@ -98,22 +90,10 @@ pub mod legacy_core {
pub use codex_core::personality_migration::*;
}
pub mod review_format {
pub use codex_core::review_format::*;
}
pub mod review_prompts {
pub use codex_core::review_prompts::*;
}
pub mod test_support {
pub use codex_core::test_support::*;
}
pub mod util {
pub use codex_core::util::*;
}
pub mod windows_sandbox {
pub use codex_core::windows_sandbox::*;
}
+52 -5
View File
@@ -180,6 +180,36 @@
],
"type": "object"
},
"CapabilityRootLocation": {
"description": "Location used to resolve a selected capability root.",
"oneOf": [
{
"description": "A path owned by an execution environment.",
"properties": {
"environmentId": {
"type": "string"
},
"path": {
"type": "string"
},
"type": {
"enum": [
"environment"
],
"title": "EnvironmentCapabilityRootLocationType",
"type": "string"
}
},
"required": [
"environmentId",
"path",
"type"
],
"title": "EnvironmentCapabilityRootLocation",
"type": "object"
}
]
},
"ClientInfo": {
"properties": {
"name": {
@@ -2947,6 +2977,28 @@
}
]
},
"SelectedCapabilityRoot": {
"description": "A user-selected root that can expose one or more runtime capabilities.",
"properties": {
"id": {
"description": "Stable identifier supplied by the capability selection platform.",
"type": "string"
},
"location": {
"allOf": [
{
"$ref": "#/definitions/CapabilityRootLocation"
}
],
"description": "Where the selected root can be resolved."
}
},
"required": [
"id",
"location"
],
"type": "object"
},
"SendAddCreditsNudgeEmailParams": {
"properties": {
"creditType": {
@@ -3799,11 +3851,6 @@
"type": "string"
},
"ThreadSource": {
"enum": [
"user",
"subagent",
"memory_consolidation"
],
"type": "string"
},
"ThreadSourceKind": {
@@ -4615,11 +4615,6 @@
"type": "object"
},
"ThreadSource": {
"enum": [
"user",
"subagent",
"memory_consolidation"
],
"type": "string"
},
"ThreadStartedNotification": {
@@ -6765,6 +6765,36 @@
],
"type": "string"
},
"CapabilityRootLocation": {
"description": "Location used to resolve a selected capability root.",
"oneOf": [
{
"description": "A path owned by an execution environment.",
"properties": {
"environmentId": {
"type": "string"
},
"path": {
"type": "string"
},
"type": {
"enum": [
"environment"
],
"title": "EnvironmentCapabilityRootLocationType",
"type": "string"
}
},
"required": [
"environmentId",
"path",
"type"
],
"title": "EnvironmentCapabilityRootLocation",
"type": "object"
}
]
},
"CodexErrorInfo": {
"description": "This translation layer make sure that we expose codex error code in camel case.\n\nWhen an upstream HTTP status is available (for example, from the Responses API or a provider), it is forwarded in `httpStatusCode` on the relevant `codexErrorInfo` variant.",
"oneOf": [
@@ -15061,6 +15091,28 @@
},
"type": "object"
},
"SelectedCapabilityRoot": {
"description": "A user-selected root that can expose one or more runtime capabilities.",
"properties": {
"id": {
"description": "Stable identifier supplied by the capability selection platform.",
"type": "string"
},
"location": {
"allOf": [
{
"$ref": "#/definitions/v2/CapabilityRootLocation"
}
],
"description": "Where the selected root can be resolved."
}
},
"required": [
"id",
"location"
],
"type": "object"
},
"SendAddCreditsNudgeEmailParams": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
@@ -18061,11 +18113,6 @@
"type": "string"
},
"ThreadSource": {
"enum": [
"user",
"subagent",
"memory_consolidation"
],
"type": "string"
},
"ThreadSourceKind": {
@@ -1087,6 +1087,36 @@
],
"type": "string"
},
"CapabilityRootLocation": {
"description": "Location used to resolve a selected capability root.",
"oneOf": [
{
"description": "A path owned by an execution environment.",
"properties": {
"environmentId": {
"type": "string"
},
"path": {
"type": "string"
},
"type": {
"enum": [
"environment"
],
"title": "EnvironmentCapabilityRootLocationType",
"type": "string"
}
},
"required": [
"environmentId",
"path",
"type"
],
"title": "EnvironmentCapabilityRootLocation",
"type": "object"
}
]
},
"ClientInfo": {
"properties": {
"name": {
@@ -11563,6 +11593,28 @@
},
"type": "object"
},
"SelectedCapabilityRoot": {
"description": "A user-selected root that can expose one or more runtime capabilities.",
"properties": {
"id": {
"description": "Stable identifier supplied by the capability selection platform.",
"type": "string"
},
"location": {
"allOf": [
{
"$ref": "#/definitions/CapabilityRootLocation"
}
],
"description": "Where the selected root can be resolved."
}
},
"required": [
"id",
"location"
],
"type": "object"
},
"SendAddCreditsNudgeEmailParams": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
@@ -15878,11 +15930,6 @@
"type": "string"
},
"ThreadSource": {
"enum": [
"user",
"subagent",
"memory_consolidation"
],
"type": "string"
},
"ThreadSourceKind": {
@@ -73,11 +73,6 @@
"type": "string"
},
"ThreadSource": {
"enum": [
"user",
"subagent",
"memory_consolidation"
],
"type": "string"
}
},
@@ -1830,11 +1830,6 @@
]
},
"ThreadSource": {
"enum": [
"user",
"subagent",
"memory_consolidation"
],
"type": "string"
},
"ThreadStatus": {
@@ -1645,11 +1645,6 @@
]
},
"ThreadSource": {
"enum": [
"user",
"subagent",
"memory_consolidation"
],
"type": "string"
},
"ThreadStatus": {
@@ -1645,11 +1645,6 @@
]
},
"ThreadSource": {
"enum": [
"user",
"subagent",
"memory_consolidation"
],
"type": "string"
},
"ThreadStatus": {
@@ -1645,11 +1645,6 @@
]
},
"ThreadSource": {
"enum": [
"user",
"subagent",
"memory_consolidation"
],
"type": "string"
},
"ThreadStatus": {
@@ -1830,11 +1830,6 @@
]
},
"ThreadSource": {
"enum": [
"user",
"subagent",
"memory_consolidation"
],
"type": "string"
},
"ThreadStatus": {
@@ -1645,11 +1645,6 @@
]
},
"ThreadSource": {
"enum": [
"user",
"subagent",
"memory_consolidation"
],
"type": "string"
},
"ThreadStatus": {
@@ -64,6 +64,36 @@
}
]
},
"CapabilityRootLocation": {
"description": "Location used to resolve a selected capability root.",
"oneOf": [
{
"description": "A path owned by an execution environment.",
"properties": {
"environmentId": {
"type": "string"
},
"path": {
"type": "string"
},
"type": {
"enum": [
"environment"
],
"title": "EnvironmentCapabilityRootLocationType",
"type": "string"
}
},
"required": [
"environmentId",
"path",
"type"
],
"title": "EnvironmentCapabilityRootLocation",
"type": "object"
}
]
},
"DynamicToolSpec": {
"properties": {
"deferLoading": {
@@ -106,12 +136,29 @@
],
"type": "string"
},
"ThreadSource": {
"enum": [
"user",
"subagent",
"memory_consolidation"
"SelectedCapabilityRoot": {
"description": "A user-selected root that can expose one or more runtime capabilities.",
"properties": {
"id": {
"description": "Stable identifier supplied by the capability selection platform.",
"type": "string"
},
"location": {
"allOf": [
{
"$ref": "#/definitions/CapabilityRootLocation"
}
],
"description": "Where the selected root can be resolved."
}
},
"required": [
"id",
"location"
],
"type": "object"
},
"ThreadSource": {
"type": "string"
},
"ThreadStartSource": {
@@ -1830,11 +1830,6 @@
]
},
"ThreadSource": {
"enum": [
"user",
"subagent",
"memory_consolidation"
],
"type": "string"
},
"ThreadStatus": {
@@ -1645,11 +1645,6 @@
]
},
"ThreadSource": {
"enum": [
"user",
"subagent",
"memory_consolidation"
],
"type": "string"
},
"ThreadStatus": {
@@ -1645,11 +1645,6 @@
]
},
"ThreadSource": {
"enum": [
"user",
"subagent",
"memory_consolidation"
],
"type": "string"
},
"ThreadStatus": {
@@ -0,0 +1,8 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
/**
* Location used to resolve a selected capability root.
*/
export type CapabilityRootLocation = { "type": "environment", environmentId: string, path: string, };
@@ -0,0 +1,17 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { CapabilityRootLocation } from "./CapabilityRootLocation";
/**
* A user-selected root that can expose one or more runtime capabilities.
*/
export type SelectedCapabilityRoot = {
/**
* Stable identifier supplied by the capability selection platform.
*/
id: string,
/**
* Where the selected root can be resolved.
*/
location: CapabilityRootLocation, };
@@ -2,4 +2,4 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type ThreadSource = "user" | "subagent" | "memory_consolidation";
export type ThreadSource = string;
@@ -40,6 +40,7 @@ export type { ByteRange } from "./ByteRange";
export type { CancelLoginAccountParams } from "./CancelLoginAccountParams";
export type { CancelLoginAccountResponse } from "./CancelLoginAccountResponse";
export type { CancelLoginAccountStatus } from "./CancelLoginAccountStatus";
export type { CapabilityRootLocation } from "./CapabilityRootLocation";
export type { ChatgptAuthTokensRefreshParams } from "./ChatgptAuthTokensRefreshParams";
export type { ChatgptAuthTokensRefreshReason } from "./ChatgptAuthTokensRefreshReason";
export type { ChatgptAuthTokensRefreshResponse } from "./ChatgptAuthTokensRefreshResponse";
@@ -334,6 +335,7 @@ export type { ReviewTarget } from "./ReviewTarget";
export type { SandboxMode } from "./SandboxMode";
export type { SandboxPolicy } from "./SandboxPolicy";
export type { SandboxWorkspaceWrite } from "./SandboxWorkspaceWrite";
export type { SelectedCapabilityRoot } from "./SelectedCapabilityRoot";
export type { SendAddCreditsNudgeEmailParams } from "./SendAddCreditsNudgeEmailParams";
export type { SendAddCreditsNudgeEmailResponse } from "./SendAddCreditsNudgeEmailResponse";
export type { ServerRequestResolvedNotification } from "./ServerRequestResolvedNotification";
@@ -57,6 +57,30 @@ fn test_absolute_path() -> AbsolutePathBuf {
absolute_path("readable")
}
#[test]
fn thread_sources_round_trip_as_scalar_labels() {
for (source, label) in [
(ThreadSource::User, "user"),
(ThreadSource::Subagent, "subagent"),
(
ThreadSource::Feature("automation".to_string()),
"automation",
),
(ThreadSource::MemoryConsolidation, "memory_consolidation"),
] {
let value = serde_json::to_value(&source).expect("serialize thread source");
assert_eq!(value, json!(label));
assert_eq!(
serde_json::from_value::<ThreadSource>(value).expect("deserialize thread source"),
source
);
let core_source: codex_protocol::protocol::ThreadSource = source.clone().into();
assert_eq!(ThreadSource::from(core_source), source);
}
}
#[test]
fn approvals_reviewer_serializes_auto_review_and_accepts_legacy_guardian_subagent() {
assert_eq!(
@@ -11,6 +11,8 @@ use super::TurnEnvironmentParams;
use super::TurnItemsView;
use super::shared::v2_enum_from_core;
use codex_experimental_api_macros::ExperimentalApi;
pub use codex_protocol::capabilities::CapabilityRootLocation;
pub use codex_protocol::capabilities::SelectedCapabilityRoot;
use codex_protocol::config_types::CollaborationMode;
use codex_protocol::config_types::Personality;
use codex_protocol::config_types::ReasoningSummary;
@@ -156,6 +158,10 @@ pub struct ThreadStartParams {
#[experimental("thread/start.dynamicTools")]
#[ts(optional = nullable)]
pub dynamic_tools: Option<Vec<DynamicToolSpec>>,
/// Capability roots selected for this thread by the hosting platform.
#[experimental("thread/start.selectedCapabilityRoots")]
#[ts(optional = nullable)]
pub selected_capability_roots: Option<Vec<SelectedCapabilityRoot>>,
/// Test-only experimental field used to validate experimental gating and
/// schema filtering behavior in a stable way.
#[experimental("thread/start.mockExperimentalField")]
@@ -7,6 +7,8 @@ use codex_protocol::protocol::SubAgentSource as CoreSubAgentSource;
use codex_protocol::protocol::ThreadSource as CoreThreadSource;
use codex_utils_absolute_path::AbsolutePathBuf;
use schemars::JsonSchema;
use schemars::r#gen::SchemaGenerator;
use schemars::schema::Schema;
use serde::Deserialize;
use serde::Serialize;
use std::path::PathBuf;
@@ -61,20 +63,47 @@ impl From<SessionSource> for CoreSessionSource {
}
}
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "snake_case")]
#[ts(rename_all = "snake_case", export_to = "v2/")]
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, TS)]
#[serde(try_from = "String", into = "String")]
#[ts(type = "string")]
#[ts(export_to = "v2/")]
pub enum ThreadSource {
User,
Subagent,
Feature(String),
MemoryConsolidation,
}
impl JsonSchema for ThreadSource {
fn schema_name() -> String {
"ThreadSource".to_string()
}
fn json_schema(generator: &mut SchemaGenerator) -> Schema {
String::json_schema(generator)
}
}
impl TryFrom<String> for ThreadSource {
type Error = String;
fn try_from(value: String) -> Result<Self, Self::Error> {
value.parse::<CoreThreadSource>().map(Into::into)
}
}
impl From<ThreadSource> for String {
fn from(value: ThreadSource) -> Self {
CoreThreadSource::from(value).into()
}
}
impl From<CoreThreadSource> for ThreadSource {
fn from(value: CoreThreadSource) -> Self {
match value {
CoreThreadSource::User => ThreadSource::User,
CoreThreadSource::Subagent => ThreadSource::Subagent,
CoreThreadSource::Feature(feature) => ThreadSource::Feature(feature),
CoreThreadSource::MemoryConsolidation => ThreadSource::MemoryConsolidation,
}
}
@@ -85,6 +114,7 @@ impl From<ThreadSource> for CoreThreadSource {
match value {
ThreadSource::User => CoreThreadSource::User,
ThreadSource::Subagent => CoreThreadSource::Subagent,
ThreadSource::Feature(feature) => CoreThreadSource::Feature(feature),
ThreadSource::MemoryConsolidation => CoreThreadSource::MemoryConsolidation,
}
}
+2
View File
@@ -49,6 +49,7 @@ codex-hooks = { workspace = true }
codex-otel = { workspace = true }
codex-plugin = { workspace = true }
codex-shell-command = { workspace = true }
codex-skills-extension = { workspace = true }
codex-utils-cli = { workspace = true }
codex-utils-pty = { workspace = true }
codex-backend-client = { workspace = true }
@@ -60,6 +61,7 @@ codex-memories-extension = { workspace = true }
codex-web-search-extension = { workspace = true }
codex-memories-write = { workspace = true }
codex-mcp = { workspace = true }
codex-mcp-extension = { workspace = true }
codex-model-provider = { workspace = true }
codex-models-manager = { workspace = true }
codex-protocol = { workspace = true }
+14 -1
View File
@@ -130,7 +130,8 @@ Example with notification opt-out:
## API Overview
- `thread/start` — create a new thread; emits `thread/started` (including the current `thread.status`) and auto-subscribes you to turn/item events for that thread. Set `disableMultiAgentTools: true` to prevent the thread from receiving multi-agent collaboration tools. When the request includes a `cwd` and the resolved sandbox is `workspace-write` or full access, app-server also marks that project as trusted in the user `config.toml`. Pass `sessionStartSource: "clear"` when starting a replacement thread after clearing the current session so `SessionStart` hooks receive `source: "clear"` instead of the default `"startup"`. Experimental `runtimeWorkspaceRoots` replaces the thread-scoped runtime workspace roots used to materialize `:workspace_roots`; paths must be absolute. For permissions, prefer experimental `permissions` profile selection by id; the legacy `sandbox` shorthand is still accepted but cannot be combined with `permissions`. Experimental `environments` selects the sticky execution environments for turns on the thread; omit it to use the server default, pass `[]` to disable environments, or pass explicit environment ids with per-environment `cwd`.
<<<<<<< HEAD
- `thread/start` — create a new thread; emits `thread/started` (including the current `thread.status`) and auto-subscribes you to turn/item events for that thread. Set `disableMultiAgentTools: true` to prevent the thread from receiving multi-agent collaboration tools. When the request includes a `cwd` and the resolved sandbox is `workspace-write` or full access, app-server also marks that project as trusted in the user `config.toml`. Pass `sessionStartSource: "clear"` when starting a replacement thread after clearing the current session so `SessionStart` hooks receive `source: "clear"` instead of the default `"startup"`. Experimental `runtimeWorkspaceRoots` replaces the thread-scoped runtime workspace roots used to materialize `:workspace_roots`; paths must be absolute. For permissions, prefer experimental `permissions` profile selection by id; the legacy `sandbox` shorthand is still accepted but cannot be combined with `permissions`. Experimental `environments` selects the sticky execution environments for turns on the thread; omit it to use the server default, pass `[]` to disable environments, or pass explicit environment ids with per-environment `cwd`. Experimental `selectedCapabilityRoots` selects environment-owned plugin or standalone-skill roots. Skills found below those roots are listed and read through the owning environment; other plugin capabilities are not activated yet.
- `thread/resume` — reopen an existing thread by id so subsequent `turn/start` calls append to it. Accepts the same permission override rules as `thread/start`.
- `thread/fork` — fork an existing thread into a new thread id by copying the stored history; if the source thread is currently mid-turn, the fork records the same interruption marker as `turn/interrupt` instead of inheriting an unmarked partial turn suffix. The returned `thread.forkedFromId` points at the source thread when known. Accepts `ephemeral: true` for an in-memory temporary fork, emits `thread/started` (including the current `thread.status`), and auto-subscribes you to turn/item events for the new thread. Set `disableMultiAgentTools: true` to prevent the fork from inheriting or enabling multi-agent collaboration tools. Experimental clients can pass `excludeTurns: true` when they plan to page fork history via `thread/turns/list` instead of receiving the full turn array immediately. Accepts the same permission override rules as `thread/start`.
- `thread/start`, `thread/resume`, and `thread/fork` responses include the legacy `sandbox` compatibility projection. Experimental clients can read `runtimeWorkspaceRoots` for the thread-scoped runtime roots and `activePermissionProfile` for the named or implicit built-in profile identity/provenance when known.
@@ -249,6 +250,18 @@ Start a fresh thread when you need a new Codex conversation.
// "permissions": ":workspace"
// Experimental runtime roots for :workspace_roots materialization:
// "runtimeWorkspaceRoots": ["/Users/me/project", "/Users/me/openai"],
// Experimental capability roots selected by the hosting platform:
"selectedCapabilityRoots": [
{
"id": "github@openai",
"location": {
"type": "environment",
"environmentId": "workspace",
// Opaque to app-server; interpreted in the selected environment.
"path": "/opt/cca/plugins/github"
}
}
],
// Do not send both "sandbox" and "permissions".
"personality": "friendly",
"serviceName": "my_app_server_client", // optional metrics tag (`service_name`)
+7
View File
@@ -32,6 +32,7 @@ pub(crate) fn thread_extensions<S>(
state_db: Option<StateDbHandle>,
thread_manager: Weak<ThreadManager>,
goal_service: Arc<GoalService>,
executor_skill_provider: Arc<dyn codex_skills_extension::SkillProvider>,
) -> Arc<ExtensionRegistry<Config>>
where
S: AgentSpawner<StartThreadOptions, Spawned = NewThread, Error = CodexErr> + 'static,
@@ -49,8 +50,14 @@ where
}
codex_guardian::install(&mut builder, guardian_agent_spawner);
codex_memories_extension::install(&mut builder, codex_otel::global());
codex_mcp_extension::install(&mut builder);
codex_web_search_extension::install(&mut builder, auth_manager.clone());
codex_image_generation_extension::install(&mut builder, auth_manager);
codex_skills_extension::install_with_providers(
&mut builder,
codex_skills_extension::SkillProviders::new()
.with_executor_provider(executor_skill_provider),
);
Arc::new(builder.build())
}
+10 -5
View File
@@ -67,10 +67,7 @@ async fn build_refresh_config(
let config = config_manager
.load_latest_config_for_thread(thread_config.as_ref())
.await?;
let mcp_servers = thread_manager
.mcp_manager()
.configured_servers(&config)
.await;
let mcp_servers = thread_manager.mcp_manager().runtime_servers(&config).await;
Ok(McpServerRefreshConfig {
mcp_servers: serde_json::to_value(mcp_servers).map_err(io::Error::other)?,
mcp_oauth_credentials_store_mode: serde_json::to_value(
@@ -181,12 +178,19 @@ mod tests {
.await
.expect("refresh tests require state db");
let thread_store = thread_store_from_config(&good_config, Some(state_db.clone()));
let environment_manager = Arc::new(EnvironmentManager::default_for_tests());
let executor_skill_provider: Arc<dyn codex_skills_extension::SkillProvider> = Arc::new(
codex_skills_extension::ExecutorSkillProvider::new_with_restriction_product(
Arc::clone(&environment_manager),
SessionSource::Exec.restriction_product(),
),
);
let thread_manager = Arc::new_cyclic(|thread_manager| {
ThreadManager::new(
&good_config,
auth_manager.clone(),
SessionSource::Exec,
Arc::new(EnvironmentManager::default_for_tests()),
Arc::clone(&environment_manager),
thread_extensions(
guardian_agent_spawner(thread_manager.clone()),
Arc::new(NoopExtensionEventSink),
@@ -194,6 +198,7 @@ mod tests {
Some(state_db.clone()),
thread_manager.clone(),
Arc::new(codex_goal_extension::GoalService::new()),
Arc::clone(&executor_skill_provider),
),
/*analytics_events_client*/ None,
Arc::clone(&thread_store),
@@ -307,6 +307,14 @@ impl MessageProcessor {
// resumed, or forked threads to a different persistence backend/root.
let thread_store = codex_core::thread_store_from_config(config.as_ref(), state_db.clone());
let environment_manager_for_requests = Arc::clone(&environment_manager);
let environment_manager_for_extensions = Arc::clone(&environment_manager);
let restriction_product = session_source.restriction_product();
let executor_skill_provider: Arc<dyn codex_skills_extension::SkillProvider> = Arc::new(
codex_skills_extension::ExecutorSkillProvider::new_with_restriction_product(
environment_manager_for_extensions,
restriction_product,
),
);
let goal_service = Arc::new(GoalService::new());
let thread_manager = Arc::new_cyclic(|thread_manager| {
ThreadManager::new(
@@ -321,6 +329,7 @@ impl MessageProcessor {
state_db.clone(),
thread_manager.clone(),
Arc::clone(&goal_service),
Arc::clone(&executor_skill_provider),
),
Some(analytics_events_client.clone()),
Arc::clone(&thread_store),
@@ -280,6 +280,7 @@ use codex_config::types::McpServerTransportConfig;
use codex_core::CodexThread;
use codex_core::CodexThreadSettingsOverrides;
use codex_core::ForkSnapshot;
use codex_core::McpManager;
use codex_core::NewThread;
#[cfg(test)]
use codex_core::SessionMeta;
@@ -88,11 +88,19 @@ impl AppsRequestProcessor {
let request = request_id.clone();
let outgoing = Arc::clone(&self.outgoing);
let environment_manager = self.thread_manager.environment_manager();
let mcp_manager = self.thread_manager.mcp_manager();
let shutdown_token = self.shutdown_token.child_token();
tokio::spawn(async move {
tokio::select! {
_ = shutdown_token.cancelled() => {}
_ = Self::apps_list_task(outgoing, request, params, config, environment_manager) => {}
_ = Self::apps_list_task(
outgoing,
request,
params,
config,
environment_manager,
mcp_manager,
) => {}
}
});
Ok(None)
@@ -108,11 +116,15 @@ impl AppsRequestProcessor {
params: AppsListParams,
config: Config,
environment_manager: Arc<EnvironmentManager>,
mcp_manager: Arc<McpManager>,
) {
let retry_params = params.clone();
let retry_config = config.clone();
let retry_environment_manager = Arc::clone(&environment_manager);
let result = Self::apps_list_response(&outgoing, params, config, environment_manager).await;
let retry_mcp_manager = Arc::clone(&mcp_manager);
let result =
Self::apps_list_response(&outgoing, params, config, environment_manager, mcp_manager)
.await;
let should_retry = result
.as_ref()
.is_ok_and(|(_, codex_apps_ready)| !codex_apps_ready);
@@ -128,6 +140,7 @@ impl AppsRequestProcessor {
retry_params,
retry_config,
retry_environment_manager,
retry_mcp_manager,
)
.await
{
@@ -141,6 +154,7 @@ impl AppsRequestProcessor {
params: AppsListParams,
config: Config,
environment_manager: Arc<EnvironmentManager>,
mcp_manager: Arc<McpManager>,
) -> Result<(AppsListResponse, bool), JSONRPCErrorError> {
let AppsListParams {
cursor,
@@ -167,14 +181,14 @@ impl AppsRequestProcessor {
let accessible_config = config.clone();
let accessible_tx = tx.clone();
tokio::spawn(async move {
let result =
connectors::list_accessible_connectors_from_mcp_tools_with_environment_manager(
&accessible_config,
force_refetch,
Arc::clone(&environment_manager),
)
.await
.map_err(|err| format!("failed to load accessible apps: {err}"));
let result = connectors::list_accessible_connectors_from_mcp_tools_with_mcp_manager(
&accessible_config,
force_refetch,
Arc::clone(&environment_manager),
mcp_manager,
)
.await
.map_err(|err| format!("failed to load accessible apps: {err}"));
let _ = accessible_tx.send(AppListLoadResult::Accessible(result));
});
@@ -120,12 +120,16 @@ impl McpRequestProcessor {
timeout_secs,
} = params;
let configured_servers = self
let auth = self.auth_manager.auth().await;
let effective_servers = self
.thread_manager
.mcp_manager()
.configured_servers(&config)
.effective_servers(&config, auth.as_ref())
.await;
let Some(server) = configured_servers.get(&name) else {
let Some(server) = effective_servers
.get(&name)
.and_then(codex_mcp::EffectiveMcpServer::configured_config)
else {
return Err(invalid_request(format!(
"No MCP server named '{name}' found."
)));
@@ -210,8 +214,10 @@ impl McpRequestProcessor {
}
None => self.load_latest_config(/*fallback_cwd*/ None).await?,
};
let mcp_config = config
.to_mcp_config(self.thread_manager.plugins_manager().as_ref())
let mcp_config = self
.thread_manager
.mcp_manager()
.runtime_config(&config)
.await;
let auth = self.auth_manager.auth().await;
let environment_manager = self.thread_manager.environment_manager();
@@ -361,8 +367,10 @@ impl McpRequestProcessor {
}
let config = self.load_latest_config(/*fallback_cwd*/ None).await?;
let mcp_config = config
.to_mcp_config(self.thread_manager.plugins_manager().as_ref())
let mcp_config = self
.thread_manager
.mcp_manager()
.runtime_config(&config)
.await;
let auth = self.auth_manager.auth().await;
let environment_manager = self.thread_manager.environment_manager();
@@ -1036,6 +1036,7 @@ impl PluginRequestProcessor {
&config,
&outcome.plugin.apps,
Arc::clone(&environment_manager),
self.thread_manager.mcp_manager(),
)
.await;
let visible_skills = outcome
@@ -1118,6 +1119,7 @@ impl PluginRequestProcessor {
&config,
&plugin_apps,
Arc::clone(&environment_manager),
self.thread_manager.mcp_manager(),
)
.await;
remote_plugin_detail_to_info(remote_detail, app_summaries)
@@ -1611,10 +1613,11 @@ impl PluginRequestProcessor {
let environment_manager = self.thread_manager.environment_manager();
let (all_connectors_result, accessible_connectors_result) = tokio::join!(
connectors::list_all_connectors_with_options(config, /*force_refetch*/ false),
connectors::list_accessible_connectors_from_mcp_tools_with_environment_manager(
connectors::list_accessible_connectors_from_mcp_tools_with_mcp_manager(
config,
/*force_refetch*/ true,
Arc::clone(&environment_manager)
Arc::clone(&environment_manager),
self.thread_manager.mcp_manager(),
),
);
@@ -1881,6 +1884,7 @@ async fn load_plugin_app_summaries(
config: &Config,
plugin_apps: &[codex_plugin::AppConnectorId],
environment_manager: Arc<EnvironmentManager>,
mcp_manager: Arc<McpManager>,
) -> Vec<AppSummary> {
if plugin_apps.is_empty() {
return Vec::new();
@@ -1900,10 +1904,11 @@ async fn load_plugin_app_summaries(
let plugin_connectors = connectors::connectors_for_plugin_apps(connectors, plugin_apps);
let accessible_connectors =
match connectors::list_accessible_connectors_from_mcp_tools_with_environment_manager(
match connectors::list_accessible_connectors_from_mcp_tools_with_mcp_manager(
config,
/*force_refetch*/ false,
environment_manager,
mcp_manager,
)
.await
{
@@ -1,5 +1,7 @@
use super::*;
use crate::error_code::method_not_found;
use codex_app_server_protocol::SelectedCapabilityRoot;
use codex_extension_api::ExtensionDataInit;
use codex_protocol::models::BUILT_IN_PERMISSION_PROFILE_DANGER_FULL_ACCESS;
use codex_protocol::models::BUILT_IN_PERMISSION_PROFILE_WORKSPACE;
@@ -331,6 +333,17 @@ pub(crate) struct ThreadRequestProcessor {
pub(super) skills_watcher: Arc<SkillsWatcher>,
}
/// Outcome of trying to satisfy a resume request from an already loaded thread.
enum RunningThreadResumeResult {
/// The request was delegated to the loaded thread.
Handled,
/// No loaded thread handled the request.
///
/// The optional stored thread contains the history-bearing probe that cold
/// resume can reuse instead of reading the rollout again.
NotRunning(Option<Box<StoredThread>>),
}
impl ThreadRequestProcessor {
#[allow(clippy::too_many_arguments)]
pub(crate) fn new(
@@ -826,6 +839,7 @@ impl ThreadRequestProcessor {
base_instructions,
developer_instructions,
dynamic_tools,
selected_capability_roots,
mock_experimental_field: _mock_experimental_field,
experimental_raw_events,
personality,
@@ -882,6 +896,7 @@ impl ThreadRequestProcessor {
config,
typesafe_overrides,
dynamic_tools,
selected_capability_roots.unwrap_or_default(),
session_start_source,
thread_source.map(Into::into),
disable_multi_agent_tools,
@@ -955,6 +970,7 @@ impl ThreadRequestProcessor {
config_overrides: Option<HashMap<String, serde_json::Value>>,
typesafe_overrides: ConfigOverrides,
dynamic_tools: Option<Vec<ApiDynamicToolSpec>>,
selected_capability_roots: Vec<SelectedCapabilityRoot>,
session_start_source: Option<codex_app_server_protocol::ThreadStartSource>,
thread_source: Option<codex_protocol::protocol::ThreadSource>,
disable_multi_agent_tools: bool,
@@ -1057,6 +1073,10 @@ impl ThreadRequestProcessor {
.collect()
};
let core_dynamic_tool_count = core_dynamic_tools.len();
let mut thread_extension_init = ExtensionDataInit::new();
if !selected_capability_roots.is_empty() {
thread_extension_init.insert(selected_capability_roots);
}
let create_thread_started_at = std::time::Instant::now();
let NewThread {
thread_id,
@@ -1080,6 +1100,7 @@ impl ThreadRequestProcessor {
metrics_service_name: service_name,
parent_trace: request_trace,
environments,
thread_extension_init,
})
.instrument(tracing::info_span!(
"app_server.thread_start.create_thread",
@@ -2427,7 +2448,7 @@ impl ThreadRequestProcessor {
return Ok(());
}
};
match self
let stored_thread_from_running_probe = match self
.resume_running_thread(
&request_id,
&params,
@@ -2436,13 +2457,13 @@ impl ThreadRequestProcessor {
)
.await
{
Ok(true) => return Ok(()),
Ok(false) => {}
Ok(RunningThreadResumeResult::Handled) => return Ok(()),
Ok(RunningThreadResumeResult::NotRunning(stored_thread)) => stored_thread,
Err(error) => {
self.outgoing.send_error(request_id, error).await;
return Ok(());
}
}
};
let ThreadResumeParams {
thread_id,
@@ -2466,15 +2487,20 @@ impl ThreadRequestProcessor {
} = params;
let include_turns = !exclude_turns;
let (thread_history, resume_source_thread) = match if let Some(history) = history {
let resume_result = if let Some(history) = history {
self.resume_thread_from_history(history.as_slice())
.await
.map(|thread_history| (thread_history, None))
} else if let Some(stored_thread) = stored_thread_from_running_probe {
self.stored_thread_to_initial_history(&stored_thread)
.await
.map(|thread_history| (thread_history, Some(*stored_thread)))
} else {
self.resume_thread_from_rollout(&thread_id, path.as_ref())
.await
.map(|(thread_history, stored_thread)| (thread_history, Some(stored_thread)))
} {
};
let (thread_history, resume_source_thread) = match resume_result {
Ok(value) => value,
Err(error) => {
self.outgoing.send_error(request_id, error).await;
@@ -2715,7 +2741,7 @@ impl ThreadRequestProcessor {
params: &ThreadResumeParams,
app_server_client_name: Option<String>,
app_server_client_version: Option<String>,
) -> Result<bool, JSONRPCErrorError> {
) -> Result<RunningThreadResumeResult, JSONRPCErrorError> {
let running_thread = if params.history.is_some() {
if let Ok(existing_thread_id) = ThreadId::from_string(&params.thread_id)
&& self
@@ -2751,7 +2777,11 @@ impl ThreadRequestProcessor {
let existing_thread_id = source_thread.thread_id;
match self.thread_manager.get_thread(existing_thread_id).await {
Ok(existing_thread) => Some((existing_thread_id, existing_thread, source_thread)),
Err(_) => None,
Err(_) => {
return Ok(RunningThreadResumeResult::NotRunning(Some(Box::new(
source_thread,
))));
}
}
};
@@ -2792,7 +2822,9 @@ impl ThreadRequestProcessor {
ThreadShutdownResult::Complete => {
self.thread_manager.remove_thread(&existing_thread_id).await;
self.finalize_thread_teardown(existing_thread_id).await;
return Ok(false);
// Shutdown can flush newer rollout items, so reload the
// stored thread before starting the replacement session.
return Ok(RunningThreadResumeResult::NotRunning(None));
}
ThreadShutdownResult::SubmitFailed => {
warn!("failed to submit Shutdown to thread {existing_thread_id}");
@@ -2884,9 +2916,9 @@ impl ThreadRequestProcessor {
"failed to enqueue running thread resume for thread {existing_thread_id}: thread listener command channel is closed"
)));
}
return Ok(true);
return Ok(RunningThreadResumeResult::Handled);
}
Ok(false)
Ok(RunningThreadResumeResult::NotRunning(None))
}
async fn resume_thread_from_history(
@@ -4132,7 +4164,7 @@ fn summary_from_thread_metadata(metadata: &ThreadMetadata) -> ConversationSummar
metadata.cwd.clone(),
metadata.cli_version.clone(),
metadata.source.clone(),
metadata.thread_source,
metadata.thread_source.clone(),
metadata.agent_nickname.clone(),
metadata.agent_role.clone(),
metadata.git_sha.clone(),
@@ -4221,7 +4253,7 @@ fn build_thread_from_snapshot(
agent_nickname: config_snapshot.session_source.get_nickname(),
agent_role: config_snapshot.session_source.get_agent_role(),
source: config_snapshot.session_source.clone().into(),
thread_source: config_snapshot.thread_source.map(Into::into),
thread_source: config_snapshot.thread_source.clone().map(Into::into),
git_info: None,
name: None,
turns: Vec::new(),
@@ -1,6 +1,12 @@
use super::*;
use codex_protocol::protocol::AdditionalContextEntry as CoreAdditionalContextEntry;
use codex_protocol::protocol::AdditionalContextKind as CoreAdditionalContextKind;
use codex_protocol::protocol::MultiAgentVersion;
use codex_protocol::protocol::SessionSource;
use codex_protocol::protocol::SubAgentSource;
const DIRECT_INPUT_TO_MULTI_AGENT_V2_SUBAGENT_ERROR: &str =
"direct app-server input is not allowed for multi-agent v2 sub-agents";
#[derive(Clone)]
pub(crate) struct TurnRequestProcessor {
@@ -236,6 +242,26 @@ impl TurnRequestProcessor {
Ok((thread_id, thread))
}
async fn ensure_direct_input_allowed(
&self,
request_id: &ConnectionRequestId,
thread: &CodexThread,
) -> Result<(), JSONRPCErrorError> {
if thread.multi_agent_version() == Some(MultiAgentVersion::V2)
&& matches!(
thread.config_snapshot().await.session_source,
SessionSource::SubAgent(SubAgentSource::ThreadSpawn { .. })
)
{
let error = invalid_request(DIRECT_INPUT_TO_MULTI_AGENT_V2_SUBAGENT_ERROR);
self.track_error_response(request_id, &error, /*error_type*/ None);
return Err(error);
}
Ok(())
}
fn normalize_collaboration_mode(
&self,
mut collaboration_mode: CollaborationMode,
@@ -370,6 +396,14 @@ impl TurnRequestProcessor {
app_server_client_name: Option<String>,
app_server_client_version: Option<String>,
) -> Result<TurnStartResponse, JSONRPCErrorError> {
let (thread_id, thread) =
self.load_thread(&params.thread_id)
.await
.inspect_err(|error| {
self.track_error_response(&request_id, error, /*error_type*/ None);
})?;
self.ensure_direct_input_allowed(&request_id, thread.as_ref())
.await?;
if let Err(error) = Self::validate_v2_input_limit(&params.input) {
self.track_error_response(
&request_id,
@@ -378,12 +412,6 @@ impl TurnRequestProcessor {
);
return Err(error);
}
let (thread_id, thread) =
self.load_thread(&params.thread_id)
.await
.inspect_err(|error| {
self.track_error_response(&request_id, error, /*error_type*/ None);
})?;
Self::set_app_server_client_info(
thread.as_ref(),
app_server_client_name,
@@ -762,6 +790,8 @@ impl TurnRequestProcessor {
.inspect_err(|error| {
self.track_error_response(request_id, error, /*error_type*/ None);
})?;
self.ensure_direct_input_allowed(request_id, thread.as_ref())
.await?;
if params.expected_turn_id.is_empty() {
return Err(invalid_request("expectedTurnId must not be empty"));
@@ -0,0 +1,148 @@
use std::time::Duration;
use anyhow::Result;
use app_test_support::TestAppServer;
use app_test_support::to_response;
use codex_app_server_protocol::CapabilityRootLocation;
use codex_app_server_protocol::JSONRPCResponse;
use codex_app_server_protocol::RequestId;
use codex_app_server_protocol::SelectedCapabilityRoot;
use codex_app_server_protocol::ThreadStartParams;
use codex_app_server_protocol::ThreadStartResponse;
use codex_app_server_protocol::TurnStartParams;
use codex_app_server_protocol::UserInput;
use core_test_support::responses;
use tempfile::TempDir;
use tokio::time::timeout;
const READ_TIMEOUT: Duration = Duration::from_secs(10);
const SKILL_NAME: &str = "demo-plugin:deploy";
const SKILL_MARKER: &str = "EXECUTOR_SKILL_BODY_MARKER";
const LOCAL_SKILL_MARKER: &str = "LOCAL_SKILL_BODY_MARKER";
#[tokio::test]
async fn selected_executor_root_exposes_plugin_skill() -> Result<()> {
let server = responses::start_mock_server().await;
let response_mock = responses::mount_sse_once(
&server,
responses::sse(vec![
responses::ev_response_created("resp-selected"),
responses::ev_assistant_message("msg-selected", "Done"),
responses::ev_completed("resp-selected"),
]),
)
.await;
let codex_home = TempDir::new()?;
std::fs::write(
codex_home.path().join("config.toml"),
format!(
r#"
model = "mock-model"
approval_policy = "never"
sandbox_mode = "read-only"
model_provider = "mock_provider"
[skills]
include_instructions = true
[model_providers.mock_provider]
name = "Mock provider for test"
base_url = "{}/v1"
wire_api = "responses"
request_max_retries = 0
stream_max_retries = 0
"#,
server.uri()
),
)?;
let local_skill_dir = codex_home.path().join("skills/local-deploy");
std::fs::create_dir_all(&local_skill_dir)?;
std::fs::write(
local_skill_dir.join("SKILL.md"),
format!(
"---\nname: {SKILL_NAME}\ndescription: Colliding local skill.\n---\n\n# Local deploy\n\n{LOCAL_SKILL_MARKER}\n"
),
)?;
let plugin_dir = TempDir::new()?;
let manifest_dir = plugin_dir.path().join(".codex-plugin");
let skill_dir = plugin_dir.path().join("skills/deploy");
std::fs::create_dir_all(&manifest_dir)?;
std::fs::create_dir_all(&skill_dir)?;
std::fs::write(
manifest_dir.join("plugin.json"),
r#"{"name":"demo-plugin"}"#,
)?;
std::fs::write(
skill_dir.join("SKILL.md"),
format!(
"---\nname: deploy\ndescription: Deploy through the executor.\n---\n\n# Deploy\n\n{SKILL_MARKER}\n"
),
)?;
let mut app_server = TestAppServer::new(codex_home.path()).await?;
timeout(READ_TIMEOUT, app_server.initialize()).await??;
let request_id = app_server
.send_thread_start_request(ThreadStartParams {
model: Some("mock-model".to_string()),
selected_capability_roots: Some(vec![SelectedCapabilityRoot {
id: "demo-plugin@1".to_string(),
location: CapabilityRootLocation::Environment {
environment_id: "local".to_string(),
path: plugin_dir.path().to_string_lossy().into_owned(),
},
}]),
..Default::default()
})
.await?;
let response: JSONRPCResponse = timeout(
READ_TIMEOUT,
app_server.read_stream_until_response_message(RequestId::Integer(request_id)),
)
.await??;
let ThreadStartResponse { thread, .. } = to_response(response)?;
let request_id = app_server
.send_turn_start_request(TurnStartParams {
thread_id: thread.id,
input: vec![UserInput::Text {
text: format!("Use ${SKILL_NAME}"),
text_elements: Vec::new(),
}],
..Default::default()
})
.await?;
timeout(
READ_TIMEOUT,
app_server.read_stream_until_response_message(RequestId::Integer(request_id)),
)
.await??;
timeout(
READ_TIMEOUT,
app_server.read_stream_until_notification_message("turn/completed"),
)
.await??;
let request = response_mock.single_request();
assert!(
request
.message_input_texts("developer")
.iter()
.any(|text| text.contains(SKILL_NAME))
);
let skill_fragments = request
.message_input_texts("user")
.into_iter()
.filter(|text| text.starts_with("<skill>"))
.collect::<Vec<_>>();
assert_eq!(1, skill_fragments.len());
let skill_fragment = skill_fragments
.first()
.expect("executor skill instructions should be model-visible");
assert!(skill_fragment.contains(&format!("<name>{SKILL_NAME}</name>")));
assert!(skill_fragment.contains(SKILL_MARKER));
assert!(!skill_fragment.contains(LOCAL_SKILL_MARKER));
Ok(())
}
@@ -12,6 +12,7 @@ mod connection_handling_websocket;
#[cfg(unix)]
mod connection_handling_websocket_unix;
mod dynamic_tools;
mod executor_skills;
mod experimental_api;
mod experimental_feature_list;
mod external_agent_config;
@@ -206,7 +206,7 @@ async fn plugin_uninstall_writes_remote_plugin_to_cloud_when_remote_plugin_enabl
Mock::given(method("POST"))
.and(path(format!(
"/backend-api/plugins/{REMOTE_PLUGIN_ID}/uninstall"
"/backend-api/ps/plugins/{REMOTE_PLUGIN_ID}/uninstall"
)))
.and(header("authorization", "Bearer chatgpt-token"))
.and(header("chatgpt-account-id", "account-123"))
@@ -249,7 +249,7 @@ async fn plugin_uninstall_writes_remote_plugin_to_cloud_when_remote_plugin_enabl
wait_for_remote_plugin_request_count(
&server,
"POST",
&format!("/plugins/{REMOTE_PLUGIN_ID}/uninstall"),
&format!("/ps/plugins/{REMOTE_PLUGIN_ID}/uninstall"),
/*expected_count*/ 1,
)
.await?;
@@ -278,7 +278,7 @@ async fn plugin_uninstall_uses_detail_scope_for_cache_namespace() -> Result<()>
Mock::given(method("POST"))
.and(path(format!(
"/backend-api/plugins/{REMOTE_PLUGIN_ID}/uninstall"
"/backend-api/ps/plugins/{REMOTE_PLUGIN_ID}/uninstall"
)))
.and(header("authorization", "Bearer chatgpt-token"))
.and(header("chatgpt-account-id", "account-123"))
@@ -321,7 +321,7 @@ async fn plugin_uninstall_uses_detail_scope_for_cache_namespace() -> Result<()>
wait_for_remote_plugin_request_count(
&server,
"POST",
&format!("/plugins/{REMOTE_PLUGIN_ID}/uninstall"),
&format!("/ps/plugins/{REMOTE_PLUGIN_ID}/uninstall"),
/*expected_count*/ 1,
)
.await?;
@@ -357,7 +357,7 @@ async fn plugin_uninstall_accepts_workspace_remote_plugin_id_shape() -> Result<(
Mock::given(method("POST"))
.and(path(format!(
"/backend-api/plugins/{WORKSPACE_REMOTE_PLUGIN_ID}/uninstall"
"/backend-api/ps/plugins/{WORKSPACE_REMOTE_PLUGIN_ID}/uninstall"
)))
.and(header("authorization", "Bearer chatgpt-token"))
.and(header("chatgpt-account-id", "account-123"))
@@ -395,7 +395,7 @@ async fn plugin_uninstall_accepts_workspace_remote_plugin_id_shape() -> Result<(
wait_for_remote_plugin_request_count(
&server,
"POST",
&format!("/plugins/{WORKSPACE_REMOTE_PLUGIN_ID}/uninstall"),
&format!("/ps/plugins/{WORKSPACE_REMOTE_PLUGIN_ID}/uninstall"),
/*expected_count*/ 1,
)
.await?;
@@ -451,7 +451,7 @@ async fn plugin_uninstall_rejects_before_post_when_remote_detail_fetch_fails() -
wait_for_remote_plugin_request_count(
&server,
"POST",
&format!("/plugins/{REMOTE_PLUGIN_ID}/uninstall"),
&format!("/ps/plugins/{REMOTE_PLUGIN_ID}/uninstall"),
/*expected_count*/ 0,
)
.await?;
@@ -487,7 +487,7 @@ async fn plugin_uninstall_rejects_remote_plugin_id_with_spaces_before_network_ca
wait_for_remote_plugin_request_count(
&server,
"POST",
"/plugins/sample plugin/uninstall",
"/ps/plugins/sample plugin/uninstall",
/*expected_count*/ 0,
)
.await?;
@@ -522,7 +522,7 @@ async fn plugin_uninstall_rejects_invalid_remote_plugin_id_before_network_call()
wait_for_remote_plugin_request_count(
&server,
"POST",
"/plugins/linear/../../oops/uninstall",
"/ps/plugins/linear/../../oops/uninstall",
/*expected_count*/ 0,
)
.await?;
@@ -29,6 +29,7 @@ use codex_app_server_protocol::RequestId;
use codex_app_server_protocol::ServerNotification;
use codex_app_server_protocol::ThreadListParams;
use codex_app_server_protocol::ThreadListResponse;
use codex_app_server_protocol::ThreadResumeParams;
use codex_app_server_protocol::ThreadStartParams;
use codex_app_server_protocol::ThreadStartResponse;
use codex_app_server_protocol::TurnStartParams;
@@ -37,6 +38,7 @@ use codex_arg0::Arg0DispatchPaths;
use codex_config::CloudConfigBundleLoader;
use codex_config::LoaderOverrides;
use codex_config::NoopThreadConfigLoader;
use codex_core::config::Config;
use codex_core::config::ConfigBuilder;
use codex_exec_server::EnvironmentManager;
use codex_feedback::CodexFeedback;
@@ -183,6 +185,122 @@ async fn thread_start_with_non_local_thread_store_does_not_create_local_persiste
Ok(())
}
#[tokio::test]
async fn cold_thread_resume_reuses_non_local_history_probe() -> Result<()> {
let server = create_mock_responses_server_repeating_assistant("Done").await;
let codex_home = TempDir::new()?;
let store_id = Uuid::new_v4().to_string();
create_config_toml_with_thread_store(codex_home.path(), &server.uri(), &store_id)?;
let loader_overrides = LoaderOverrides::without_managed_config_for_tests();
let config = Arc::new(
ConfigBuilder::default()
.codex_home(codex_home.path().to_path_buf())
.fallback_cwd(Some(codex_home.path().to_path_buf()))
.loader_overrides(loader_overrides.clone())
.build()
.await?,
);
let thread_store = InMemoryThreadStore::for_id(store_id.clone());
let _in_memory_store = InMemoryThreadStoreId { store_id };
let mut client = start_in_process_client(config.clone(), loader_overrides.clone()).await?;
let response = client
.request(ClientRequest::ThreadStart {
request_id: RequestId::Integer(1),
params: ThreadStartParams::default(),
})
.await?
.expect("thread/start should succeed");
let ThreadStartResponse { thread, .. } = serde_json::from_value(response)?;
client
.request(ClientRequest::TurnStart {
request_id: RequestId::Integer(2),
params: TurnStartParams {
thread_id: thread.id.clone(),
client_user_message_id: None,
input: vec![V2UserInput::Text {
text: "Materialize the thread".to_string(),
text_elements: Vec::new(),
}],
..Default::default()
},
})
.await?
.expect("turn/start should succeed");
timeout(DEFAULT_READ_TIMEOUT, async {
loop {
let Some(event) = client.next_event().await else {
anyhow::bail!("in-process app-server stopped before turn/completed");
};
if let InProcessServerEvent::ServerNotification(ServerNotification::TurnCompleted(
completed,
)) = event
&& completed.thread_id == thread.id
{
return Ok::<(), anyhow::Error>(());
}
}
})
.await??;
client.shutdown().await?;
let client = start_in_process_client(config, loader_overrides).await?;
let reads_before_resume = thread_store.calls().await.read_thread_with_history;
// The in-memory store is pathless, so resume currently fails later while
// assembling the response. The history-bearing probe must still be reused.
let _resume_result = client
.request(ClientRequest::ThreadResume {
request_id: RequestId::Integer(3),
params: ThreadResumeParams {
thread_id: thread.id.clone(),
..Default::default()
},
})
.await?;
assert_eq!(
thread_store.calls().await.read_thread_with_history,
reads_before_resume + 1
);
client.shutdown().await?;
Ok(())
}
async fn start_in_process_client(
config: Arc<Config>,
loader_overrides: LoaderOverrides,
) -> std::io::Result<in_process::InProcessClientHandle> {
in_process::start(InProcessStartArgs {
arg0_paths: Arg0DispatchPaths::default(),
config,
cli_overrides: Vec::new(),
loader_overrides,
strict_config: false,
cloud_config_bundle: CloudConfigBundleLoader::default(),
thread_config_loader: Arc::new(NoopThreadConfigLoader),
feedback: CodexFeedback::new(),
log_db: None,
state_db: None,
environment_manager: Arc::new(EnvironmentManager::default_for_tests()),
config_warnings: Vec::new(),
session_source: SessionSource::Cli,
enable_codex_api_key_env: false,
initialize: InitializeParams {
client_info: ClientInfo {
name: "codex-app-server-tests".to_string(),
title: None,
version: "0.1.0".to_string(),
},
capabilities: None,
},
channel_capacity: in_process::DEFAULT_IN_PROCESS_CHANNEL_CAPACITY,
})
.await
}
fn assert_no_local_persistence_artifacts(codex_home: &Path) -> Result<()> {
// These are the observable tripwires for accidental local persistence. If a
// future code path constructs a local rollout/session store or opens the
@@ -791,8 +791,10 @@ async fn skills_changed_notification_is_emitted_after_skill_change() -> Result<(
ephemeral: None,
session_start_source: None,
thread_source: None,
disable_multi_agent_tools: false,
dynamic_tools: None,
environments: None,
selected_capability_roots: None,
mock_experimental_field: None,
experimental_raw_events: false,
})
@@ -41,6 +41,7 @@ use codex_app_server_protocol::PatchChangeKind;
use codex_app_server_protocol::RequestId;
use codex_app_server_protocol::ServerRequest;
use codex_app_server_protocol::ServerRequestResolvedNotification;
use codex_app_server_protocol::SubAgentActivityKind;
use codex_app_server_protocol::TextElement;
use codex_app_server_protocol::ThreadItem;
use codex_app_server_protocol::ThreadSource;
@@ -53,6 +54,7 @@ use codex_app_server_protocol::TurnStartParams;
use codex_app_server_protocol::TurnStartResponse;
use codex_app_server_protocol::TurnStartedNotification;
use codex_app_server_protocol::TurnStatus;
use codex_app_server_protocol::TurnSteerParams;
use codex_app_server_protocol::UserInput as V2UserInput;
use codex_app_server_protocol::WarningNotification;
use codex_config::config_toml::ConfigToml;
@@ -3363,6 +3365,134 @@ async fn turn_start_emits_spawn_agent_item_with_model_metadata_v2() -> Result<()
Ok(())
}
#[tokio::test]
async fn direct_input_to_multi_agent_v2_subagent_is_rejected() -> Result<()> {
const CHILD_PROMPT: &str = "child: do work";
const PARENT_PROMPT: &str = "spawn a child and continue";
const SPAWN_CALL_ID: &str = "spawn-call-direct-input-rejection";
const ERROR_MESSAGE: &str =
"direct app-server input is not allowed for multi-agent v2 sub-agents";
let server = responses::start_mock_server().await;
let spawn_args = serde_json::to_string(&json!({
"message": CHILD_PROMPT,
"task_name": "worker",
}))?;
let _parent_turn = responses::mount_sse_once_match(
&server,
|req: &wiremock::Request| body_contains(req, PARENT_PROMPT),
responses::sse(vec![
responses::ev_response_created("resp-parent-1"),
responses::ev_function_call(SPAWN_CALL_ID, "spawn_agent", &spawn_args),
responses::ev_completed("resp-parent-1"),
]),
)
.await;
let codex_home = TempDir::new()?;
create_config_toml(
codex_home.path(),
&server.uri(),
"never",
&BTreeMap::from([(Feature::MultiAgentV2, true)]),
)?;
write_models_cache(codex_home.path())?;
let mut mcp = TestAppServer::new(codex_home.path()).await?;
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
let thread_req = mcp
.send_thread_start_request(ThreadStartParams {
model: Some("gpt-5.3-codex".to_string()),
..Default::default()
})
.await?;
let thread_resp: JSONRPCResponse = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(thread_req)),
)
.await??;
let ThreadStartResponse { thread, .. } = to_response::<ThreadStartResponse>(thread_resp)?;
let turn_req = mcp
.send_turn_start_request(TurnStartParams {
thread_id: thread.id,
input: vec![V2UserInput::Text {
text: PARENT_PROMPT.to_string(),
text_elements: Vec::new(),
}],
..Default::default()
})
.await?;
let turn_resp: JSONRPCResponse = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(turn_req)),
)
.await??;
let _: TurnStartResponse = to_response(turn_resp)?;
let child_thread_id = timeout(DEFAULT_READ_TIMEOUT, async {
loop {
let completed_notif = mcp
.read_stream_until_notification_message("item/completed")
.await?;
let completed: ItemCompletedNotification =
serde_json::from_value(completed_notif.params.expect("item/completed params"))?;
if let ThreadItem::SubAgentActivity {
id,
kind: SubAgentActivityKind::Started,
agent_thread_id,
..
} = completed.item
&& id == SPAWN_CALL_ID
{
return Ok::<String, anyhow::Error>(agent_thread_id);
}
}
})
.await??;
let direct_turn_req = mcp
.send_turn_start_request(TurnStartParams {
thread_id: child_thread_id.clone(),
input: vec![V2UserInput::Text {
text: "direct app-server turn".to_string(),
text_elements: Vec::new(),
}],
..Default::default()
})
.await?;
let direct_turn_error: JSONRPCError = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_error_message(RequestId::Integer(direct_turn_req)),
)
.await??;
assert_eq!(direct_turn_error.error.code, INVALID_REQUEST_ERROR_CODE);
assert_eq!(direct_turn_error.error.message, ERROR_MESSAGE);
let direct_steer_req = mcp
.send_turn_steer_request(TurnSteerParams {
thread_id: child_thread_id,
client_user_message_id: None,
input: vec![V2UserInput::Text {
text: "direct app-server steer".to_string(),
text_elements: Vec::new(),
}],
responsesapi_client_metadata: None,
additional_context: None,
expected_turn_id: "any-active-turn".to_string(),
})
.await?;
let direct_steer_error: JSONRPCError = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_error_message(RequestId::Integer(direct_steer_req)),
)
.await??;
assert_eq!(direct_steer_error.error.code, INVALID_REQUEST_ERROR_CODE);
assert_eq!(direct_steer_error.error.message, ERROR_MESSAGE);
Ok(())
}
#[tokio::test]
async fn turn_start_emits_spawn_agent_item_with_effective_role_model_metadata_v2() -> Result<()> {
skip_if_no_network!(Ok(()));
+1
View File
@@ -14,6 +14,7 @@ use codex_connectors::merge::merge_plugin_connectors;
use codex_core::config::Config;
pub use codex_core::connectors::list_accessible_connectors_from_mcp_tools;
pub use codex_core::connectors::list_accessible_connectors_from_mcp_tools_with_environment_manager;
pub use codex_core::connectors::list_accessible_connectors_from_mcp_tools_with_mcp_manager;
pub use codex_core::connectors::list_accessible_connectors_from_mcp_tools_with_options;
pub use codex_core::connectors::list_accessible_connectors_from_mcp_tools_with_options_and_status;
pub use codex_core::connectors::list_cached_accessible_connectors_from_mcp_tools;
+1
View File
@@ -24,6 +24,7 @@ pub use auth_elicitation::build_auth_elicitation_plan;
pub use auth_elicitation::connector_auth_failure_from_tool_result;
pub use codex_apps::CodexAppsToolsCacheKey;
pub use codex_apps::codex_apps_tools_cache_key;
pub use mcp::codex_apps_mcp_server_config;
pub use mcp::configured_mcp_servers;
pub use mcp::effective_mcp_servers;
pub use mcp::effective_mcp_servers_from_configured;
+24 -12
View File
@@ -131,6 +131,11 @@ pub struct McpConfig {
/// ChatGPT auth is checked separately at runtime before the host-owned apps
/// MCP server is added.
pub apps_enabled: bool,
/// Whether to synthesize the legacy host-owned Apps MCP server.
///
/// Hosts that install an MCP extension for this server disable the legacy
/// loader and contribute the server through the normal runtime overlay.
pub legacy_apps_mcp_loader_enabled: bool,
/// Whether model-visible MCP tool namespaces should keep the legacy
/// `mcp__` prefix.
pub prefix_mcp_tool_names: bool,
@@ -218,10 +223,20 @@ pub fn with_codex_apps_mcp(
auth: Option<&CodexAuth>,
config: &McpConfig,
) -> HashMap<String, EffectiveMcpServer> {
if !config.legacy_apps_mcp_loader_enabled {
if !host_owned_codex_apps_enabled(config, auth) {
servers.remove(CODEX_APPS_MCP_SERVER_NAME);
}
return servers;
}
if host_owned_codex_apps_enabled(config, auth) {
servers.insert(
CODEX_APPS_MCP_SERVER_NAME.to_string(),
EffectiveMcpServer::configured(codex_apps_mcp_server_config(config)),
EffectiveMcpServer::configured(codex_apps_mcp_server_config(
&config.chatgpt_base_url,
config.apps_mcp_path_override.as_deref(),
config.apps_mcp_product_sku.as_deref(),
)),
);
} else {
servers.remove(CODEX_APPS_MCP_SERVER_NAME);
@@ -381,13 +396,6 @@ pub async fn collect_mcp_server_status_snapshot_with_detail(
snapshot
}
pub(crate) fn codex_apps_mcp_url(config: &McpConfig) -> String {
codex_apps_mcp_url_for_base_url(
&config.chatgpt_base_url,
config.apps_mcp_path_override.as_deref(),
)
}
/// The Responses API requires tool names to match `^[a-zA-Z0-9_-]+$`.
/// MCP server/tool names are user-controlled, so sanitize the fully-qualified
/// name we expose to the model by replacing any disallowed character with `_`.
@@ -443,10 +451,14 @@ fn codex_apps_mcp_url_for_base_url(base_url: &str, apps_mcp_path_override: Optio
format!("{base_url}/{path}")
}
fn codex_apps_mcp_server_config(config: &McpConfig) -> McpServerConfig {
let url = codex_apps_mcp_url(config);
let http_headers = config.apps_mcp_product_sku.as_ref().map(|product_sku| {
HashMap::from([("X-OpenAI-Product-Sku".to_string(), product_sku.clone())])
pub fn codex_apps_mcp_server_config(
chatgpt_base_url: &str,
apps_mcp_path_override: Option<&str>,
apps_mcp_product_sku: Option<&str>,
) -> McpServerConfig {
let url = codex_apps_mcp_url_for_base_url(chatgpt_base_url, apps_mcp_path_override);
let http_headers = apps_mcp_product_sku.map(|product_sku| {
HashMap::from([("X-OpenAI-Product-Sku".to_string(), product_sku.to_string())])
});
McpServerConfig {
+1 -10
View File
@@ -27,6 +27,7 @@ fn test_mcp_config(codex_home: PathBuf) -> McpConfig {
codex_linux_sandbox_exe: None,
use_legacy_landlock: false,
apps_enabled: false,
legacy_apps_mcp_loader_enabled: true,
prefix_mcp_tool_names: true,
client_elicitation_capability: ElicitationCapability::default(),
configured_mcp_servers: HashMap::new(),
@@ -206,16 +207,6 @@ fn codex_apps_mcp_url_for_base_url_keeps_existing_paths() {
);
}
#[test]
fn codex_apps_mcp_url_uses_legacy_codex_apps_path() {
let config = test_mcp_config(PathBuf::from("/tmp"));
assert_eq!(
codex_apps_mcp_url(&config),
"https://chatgpt.com/backend-api/wham/apps"
);
}
#[test]
fn codex_apps_server_config_uses_legacy_codex_apps_path() {
let mut config = test_mcp_config(PathBuf::from("/tmp"));
+1 -1
View File
@@ -1124,7 +1124,7 @@ pub async fn uninstall_remote_plugin(
let plugin_name = plugin.name;
let base_url = config.chatgpt_base_url.trim_end_matches('/');
let url = format!("{base_url}/plugins/{plugin_id}/uninstall");
let url = format!("{base_url}/ps/plugins/{plugin_id}/uninstall");
let client = build_reqwest_client();
let request = authenticated_request(client.post(&url), auth)?;
let response: RemotePluginMutationResponse = send_and_decode(request, &url).await?;
+16 -8
View File
@@ -169,8 +169,8 @@ where
let mut file_systems_by_skill_path: HashMap<AbsolutePathBuf, Arc<dyn ExecutorFileSystem>> =
HashMap::new();
for root in roots {
let root_path = canonicalize_for_skill_identity(&root.path);
let fs = root.file_system;
let root_path = canonicalize_for_skill_identity(fs.as_ref(), &root.path).await;
let skills_before_root = outcome.skills.len();
discover_skills_under_root(
fs.as_ref(),
@@ -471,8 +471,13 @@ fn dedupe_skill_roots_by_path(roots: &mut Vec<SkillRoot>) {
roots.retain(|root| seen.insert(root.path.clone()));
}
fn canonicalize_for_skill_identity(path: &AbsolutePathBuf) -> AbsolutePathBuf {
path.canonicalize().unwrap_or_else(|_| path.clone())
async fn canonicalize_for_skill_identity(
fs: &dyn ExecutorFileSystem,
path: &AbsolutePathBuf,
) -> AbsolutePathBuf {
fs.canonicalize(path, /*sandbox*/ None)
.await
.unwrap_or_else(|_| path.clone())
}
async fn discover_skills_under_root(
@@ -483,8 +488,11 @@ async fn discover_skills_under_root(
plugin_root: Option<&AbsolutePathBuf>,
outcome: &mut SkillLoadOutcome,
) {
let root = canonicalize_for_skill_identity(root);
let plugin_root = plugin_root.map(canonicalize_for_skill_identity);
let root = root.clone();
let plugin_root = match plugin_root {
Some(plugin_root) => Some(canonicalize_for_skill_identity(fs, plugin_root).await),
None => None,
};
match fs.get_metadata(&root, /*sandbox*/ None).await {
Ok(metadata) if metadata.is_directory => {}
@@ -557,7 +565,7 @@ async fn discover_skills_under_root(
}
match fs.read_directory(&path, /*sandbox*/ None).await {
Ok(_) => {
let resolved_dir = canonicalize_for_skill_identity(&path);
let resolved_dir = canonicalize_for_skill_identity(fs, &path).await;
enqueue_dir(
&mut queue,
&mut visited_dirs,
@@ -582,7 +590,7 @@ async fn discover_skills_under_root(
}
if metadata.is_directory {
let resolved_dir = canonicalize_for_skill_identity(&path);
let resolved_dir = canonicalize_for_skill_identity(fs, &path).await;
enqueue_dir(
&mut queue,
&mut visited_dirs,
@@ -672,7 +680,7 @@ async fn parse_skill_file(
)?;
}
let resolved_path = canonicalize_for_skill_identity(path);
let resolved_path = canonicalize_for_skill_identity(fs, path).await;
Ok(SkillMetadata {
name,
+1
View File
@@ -103,6 +103,7 @@ pub(crate) async fn run_codex_thread_interactive(
parent_rollout_thread_trace: codex_rollout_trace::ThreadTraceContext::disabled(),
parent_trace: None,
environment_selections: parent_ctx.environments.clone(),
thread_extension_init: codex_extension_api::ExtensionDataInit::default(),
analytics_events_client: Some(parent_session.services.analytics_events_client.clone()),
thread_store: Arc::clone(&parent_session.services.thread_store),
attestation_provider: parent_session.services.attestation_provider.clone(),
+1
View File
@@ -1428,6 +1428,7 @@ impl Config {
codex_linux_sandbox_exe: self.codex_linux_sandbox_exe.clone(),
use_legacy_landlock: self.features.use_legacy_landlock(),
apps_enabled: self.features.enabled(Feature::Apps),
legacy_apps_mcp_loader_enabled: true,
prefix_mcp_tool_names: self.prefix_mcp_tool_names(),
client_elicitation_capability: if self.features.enabled(Feature::AuthElicitation) {
ElicitationCapability {
+21 -6
View File
@@ -1,4 +1,3 @@
use std::collections::HashMap;
use std::collections::HashSet;
use std::sync::Arc;
use std::sync::LazyLock;
@@ -41,8 +40,8 @@ use codex_mcp::ToolInfo;
use codex_mcp::ToolPluginProvenance;
use codex_mcp::codex_apps_tools_cache_key;
use codex_mcp::compute_auth_statuses;
use codex_mcp::effective_mcp_servers;
use codex_mcp::host_owned_codex_apps_enabled;
use codex_mcp::with_codex_apps_mcp;
const CONNECTORS_READY_TIMEOUT_ON_EMPTY_TOOLS: Duration = Duration::from_secs(30);
@@ -220,6 +219,23 @@ pub async fn list_accessible_connectors_from_mcp_tools_with_environment_manager(
config: &Config,
force_refetch: bool,
environment_manager: Arc<EnvironmentManager>,
) -> anyhow::Result<AccessibleConnectorsStatus> {
let plugins_manager = Arc::new(PluginsManager::new(config.codex_home.to_path_buf()));
let mcp_manager = Arc::new(McpManager::new(plugins_manager));
list_accessible_connectors_from_mcp_tools_with_mcp_manager(
config,
force_refetch,
environment_manager,
mcp_manager,
)
.await
}
pub async fn list_accessible_connectors_from_mcp_tools_with_mcp_manager(
config: &Config,
force_refetch: bool,
environment_manager: Arc<EnvironmentManager>,
mcp_manager: Arc<McpManager>,
) -> anyhow::Result<AccessibleConnectorsStatus> {
let auth_manager =
AuthManager::shared_from_config(config, /*enable_codex_api_key_env*/ false).await;
@@ -234,8 +250,6 @@ pub async fn list_accessible_connectors_from_mcp_tools_with_environment_manager(
});
}
let cache_key = accessible_connectors_cache_key(config, auth.as_ref());
let plugins_manager = Arc::new(PluginsManager::new(config.codex_home.to_path_buf()));
let mcp_manager = McpManager::new(Arc::clone(&plugins_manager));
let tool_plugin_provenance = mcp_manager.tool_plugin_provenance(config).await;
if !force_refetch && let Some(cached_connectors) = read_cached_accessible_connectors(&cache_key)
{
@@ -250,8 +264,9 @@ pub async fn list_accessible_connectors_from_mcp_tools_with_environment_manager(
});
}
let mcp_config = config.to_mcp_config(plugins_manager.as_ref()).await;
let mcp_servers = with_codex_apps_mcp(HashMap::new(), auth.as_ref(), &mcp_config);
let mcp_config = mcp_manager.runtime_config(config).await;
let mut mcp_servers = effective_mcp_servers(&mcp_config, auth.as_ref());
mcp_servers.retain(|name, _| name == CODEX_APPS_MCP_SERVER_NAME);
let host_owned_codex_apps_enabled = host_owned_codex_apps_enabled(&mcp_config, auth.as_ref());
if mcp_servers.is_empty() {
return Ok(AccessibleConnectorsStatus {
+68 -2
View File
@@ -4,8 +4,12 @@ use std::sync::Arc;
use crate::config::Config;
use codex_config::McpServerConfig;
use codex_core_plugins::PluginsManager;
use codex_extension_api::ExtensionRegistry;
use codex_extension_api::McpServerContribution;
use codex_login::CodexAuth;
use codex_mcp::CODEX_APPS_MCP_SERVER_NAME;
use codex_mcp::EffectiveMcpServer;
use codex_mcp::McpConfig;
use codex_mcp::ToolPluginProvenance;
use codex_mcp::configured_mcp_servers;
use codex_mcp::effective_mcp_servers;
@@ -14,29 +18,91 @@ use codex_mcp::tool_plugin_provenance as collect_tool_plugin_provenance;
#[derive(Clone)]
pub struct McpManager {
plugins_manager: Arc<PluginsManager>,
extensions: Arc<ExtensionRegistry<Config>>,
}
impl McpManager {
pub fn new(plugins_manager: Arc<PluginsManager>) -> Self {
Self { plugins_manager }
Self {
plugins_manager,
extensions: codex_extension_api::empty_extension_registry(),
}
}
/// Creates a manager that resolves host-installed MCP contributions.
pub fn new_with_extensions(
plugins_manager: Arc<PluginsManager>,
extensions: Arc<ExtensionRegistry<Config>>,
) -> Self {
Self {
plugins_manager,
extensions,
}
}
/// Returns the MCP config after applying runtime-only extension overlays.
pub async fn runtime_config(&self, config: &Config) -> McpConfig {
let mut mcp_config = config.to_mcp_config(self.plugins_manager.as_ref()).await;
let contributions = self.contributions(config).await;
if contributions
.iter()
.any(|contribution| contribution.name() == CODEX_APPS_MCP_SERVER_NAME)
{
mcp_config.legacy_apps_mcp_loader_enabled = false;
}
Self::apply_to_configured_servers(&contributions, &mut mcp_config.configured_mcp_servers);
mcp_config
}
/// Returns config- and plugin-backed servers without runtime contributions.
pub async fn configured_servers(&self, config: &Config) -> HashMap<String, McpServerConfig> {
let mcp_config = config.to_mcp_config(self.plugins_manager.as_ref()).await;
configured_mcp_servers(&mcp_config)
}
/// Returns configured and host-contributed servers before auth gating.
pub async fn runtime_servers(&self, config: &Config) -> HashMap<String, McpServerConfig> {
let mcp_config = self.runtime_config(config).await;
configured_mcp_servers(&mcp_config)
}
/// Returns runtime servers after auth gating and compatibility built-ins.
pub async fn effective_servers(
&self,
config: &Config,
auth: Option<&CodexAuth>,
) -> HashMap<String, EffectiveMcpServer> {
let mcp_config = config.to_mcp_config(self.plugins_manager.as_ref()).await;
let mcp_config = self.runtime_config(config).await;
effective_mcp_servers(&mcp_config, auth)
}
/// Returns provenance for plugin-owned servers in the configured view.
pub async fn tool_plugin_provenance(&self, config: &Config) -> ToolPluginProvenance {
let mcp_config = config.to_mcp_config(self.plugins_manager.as_ref()).await;
collect_tool_plugin_provenance(&mcp_config)
}
async fn contributions(&self, config: &Config) -> Vec<McpServerContribution> {
let mut contributions = Vec::new();
for contributor in self.extensions.mcp_server_contributors() {
contributions.extend(contributor.contribute(config).await);
}
contributions
}
fn apply_to_configured_servers(
contributions: &[McpServerContribution],
servers: &mut HashMap<String, McpServerConfig>,
) {
for contribution in contributions {
match contribution {
McpServerContribution::Set { name, config } => {
servers.insert(name.clone(), config.as_ref().clone());
}
McpServerContribution::Remove { name } => {
servers.remove(name);
}
}
}
}
}
+14 -8
View File
@@ -56,7 +56,7 @@ pub(crate) async fn maybe_prompt_and_install_mcp_dependencies(
let installed = sess
.services
.mcp_manager
.configured_servers(config.as_ref())
.runtime_servers(config.as_ref())
.await;
let missing = collect_missing_mcp_dependencies(mentioned_skills, &installed);
if missing.is_empty() {
@@ -98,7 +98,7 @@ pub(crate) async fn maybe_install_mcp_dependencies(
}
let codex_home = config.codex_home.clone();
let installed = sess.services.mcp_manager.configured_servers(config).await;
let installed = sess.services.mcp_manager.runtime_servers(config).await;
let missing = collect_missing_mcp_dependencies(mentioned_skills, &installed);
if missing.is_empty() {
return;
@@ -190,16 +190,22 @@ pub(crate) async fn maybe_install_mcp_dependencies(
}
}
// Refresh from the config-backed merged MCP map (global + repo + managed)
// and overlay the updated global servers so we don't drop repo-scoped
// servers. Runtime additions such as built-ins are rebuilt by the refresh
// path from the current config.
let mut refresh_servers = sess.services.mcp_manager.configured_servers(config).await;
let mut refresh_config = config.clone();
let mut configured_servers = config.mcp_servers.get().clone();
for (name, server_config) in &servers {
refresh_servers
configured_servers
.entry(name.clone())
.or_insert_with(|| server_config.clone());
}
if let Err(err) = refresh_config.mcp_servers.set(configured_servers) {
warn!("failed to refresh MCP dependencies for mentioned skills: {err}");
return;
}
let refresh_servers = sess
.services
.mcp_manager
.runtime_servers(&refresh_config)
.await;
sess.refresh_mcp_servers_now(
turn_context,
refresh_servers,
+4 -2
View File
@@ -311,8 +311,10 @@ impl Session {
) {
let auth = self.services.auth_manager.auth().await;
let config = self.get_config().await;
let mcp_config = config
.to_mcp_config(self.services.plugins_manager.as_ref())
let mcp_config = self
.services
.mcp_manager
.runtime_config(config.as_ref())
.await;
let tool_plugin_provenance = self
.services
+4
View File
@@ -51,6 +51,7 @@ use codex_config::types::OAuthCredentialsStoreMode;
use codex_exec_server::Environment;
use codex_exec_server::EnvironmentManager;
use codex_exec_server::FileSystemSandboxContext;
use codex_extension_api::ExtensionDataInit;
use codex_extension_api::PromptSlot;
use codex_features::FEATURES;
use codex_features::Feature;
@@ -417,6 +418,7 @@ pub(crate) struct CodexSpawnArgs {
pub(crate) user_shell_override: Option<shell::Shell>,
pub(crate) parent_trace: Option<W3cTraceContext>,
pub(crate) environment_selections: ResolvedTurnEnvironments,
pub(crate) thread_extension_init: ExtensionDataInit,
pub(crate) analytics_events_client: Option<AnalyticsEventsClient>,
pub(crate) thread_store: Arc<dyn ThreadStore>,
pub(crate) attestation_provider: Option<Arc<dyn AttestationProvider>>,
@@ -497,6 +499,7 @@ impl Codex {
parent_rollout_thread_trace,
parent_trace: _,
environment_selections,
thread_extension_init,
analytics_events_client,
thread_store,
attestation_provider,
@@ -641,6 +644,7 @@ impl Codex {
plugins_manager,
mcp_manager.clone(),
extensions,
thread_extension_init,
agent_control,
environment_manager,
analytics_events_client,
+2 -2
View File
@@ -91,7 +91,7 @@ pub(super) async fn spawn_review_thread(
forked_from_thread_id,
parent_turn_context.parent_thread_id,
&session_source,
parent_turn_context.thread_source,
parent_turn_context.thread_source.clone(),
review_turn_id.clone(),
#[allow(deprecated)]
parent_turn_context.cwd.clone(),
@@ -121,7 +121,7 @@ pub(super) async fn spawn_review_thread(
reasoning_summary,
session_source,
parent_thread_id: parent_turn_context.parent_thread_id,
thread_source: parent_turn_context.thread_source,
thread_source: parent_turn_context.thread_source.clone(),
environments: parent_turn_context.environments.clone(),
available_models,
unified_exec_shell_mode,
+9 -5
View File
@@ -4,6 +4,7 @@ use crate::agents_md::LoadedAgentsMd;
use crate::config::ConstraintError;
use crate::skills::SkillError;
use crate::state::ActiveTurn;
use codex_extension_api::ExtensionDataInit;
use codex_protocol::SessionId;
use codex_protocol::config_types::SERVICE_TIER_DEFAULT_REQUEST_VALUE;
use codex_protocol::config_types::ServiceTier;
@@ -192,7 +193,7 @@ impl SessionConfiguration {
session_source: self.session_source.clone(),
forked_from_thread_id: self.forked_from_thread_id,
parent_thread_id: self.parent_thread_id,
thread_source: self.thread_source,
thread_source: self.thread_source.clone(),
}
}
@@ -487,6 +488,7 @@ impl Session {
plugins_manager: Arc<PluginsManager>,
mcp_manager: Arc<McpManager>,
extensions: Arc<codex_extension_api::ExtensionRegistry<crate::config::Config>>,
thread_extension_init: ExtensionDataInit,
agent_control: AgentControl,
environment_manager: Arc<EnvironmentManager>,
analytics_events_client: Option<AnalyticsEventsClient>,
@@ -547,7 +549,7 @@ impl Session {
forked_from_id,
parent_thread_id,
source: session_source,
thread_source: session_configuration.thread_source,
thread_source: session_configuration.thread_source.clone(),
base_instructions: BaseInstructions {
text: session_configuration.base_instructions.clone(),
},
@@ -961,8 +963,10 @@ impl Session {
);
let session_extension_data =
codex_extension_api::ExtensionData::new(session_id.to_string());
let thread_extension_data =
codex_extension_api::ExtensionData::new(thread_id.to_string());
let thread_extension_data = codex_extension_api::ExtensionData::new_with_init(
thread_id.to_string(),
thread_extension_init,
);
for contributor in extensions.thread_lifecycle_contributors() {
contributor.on_thread_start(codex_extension_api::ThreadStartInput {
config: config.as_ref(),
@@ -1085,7 +1089,7 @@ impl Session {
thread_id,
forked_from_id,
parent_thread_id,
thread_source: session_configuration.thread_source,
thread_source: session_configuration.thread_source.clone(),
thread_name: session_configuration.thread_name.clone(),
model: session_configuration.collaboration_mode.model().to_string(),
model_provider_id: config.model_provider_id.clone(),
+3
View File
@@ -4690,6 +4690,7 @@ async fn session_new_fails_when_zsh_fork_enabled_without_packaged_zsh() {
plugins_manager,
mcp_manager,
Arc::new(codex_extension_api::ExtensionRegistryBuilder::new().build()),
codex_extension_api::ExtensionDataInit::default(),
AgentControl::default(),
Arc::new(codex_exec_server::EnvironmentManager::default_for_tests()),
/*analytics_events_client*/ None,
@@ -5030,6 +5031,7 @@ async fn make_session_with_config_and_rx(
plugins_manager,
mcp_manager,
Arc::new(codex_extension_api::ExtensionRegistryBuilder::new().build()),
codex_extension_api::ExtensionDataInit::default(),
AgentControl::default(),
Arc::new(codex_exec_server::EnvironmentManager::default_for_tests()),
/*analytics_events_client*/ None,
@@ -5131,6 +5133,7 @@ async fn make_session_with_history_source_and_agent_control_and_rx(
plugins_manager,
mcp_manager,
Arc::new(codex_extension_api::ExtensionRegistryBuilder::new().build()),
codex_extension_api::ExtensionDataInit::default(),
agent_control,
Arc::new(codex_exec_server::EnvironmentManager::default_for_tests()),
/*analytics_events_client*/ None,
@@ -731,6 +731,7 @@ async fn guardian_subagent_does_not_inherit_parent_exec_policy_rules() {
environment_selections: ResolvedTurnEnvironments {
turn_environments: Vec::new(),
},
thread_extension_init: codex_extension_api::ExtensionDataInit::default(),
analytics_events_client: None,
thread_store,
attestation_provider: None,
+3 -3
View File
@@ -239,7 +239,7 @@ impl TurnContext {
reasoning_summary: self.reasoning_summary,
session_source: self.session_source.clone(),
parent_thread_id: self.parent_thread_id,
thread_source: self.thread_source,
thread_source: self.thread_source.clone(),
environments: self.environments.clone(),
#[allow(deprecated)]
cwd: self.cwd.clone(),
@@ -514,7 +514,7 @@ impl Session {
session_configuration.forked_from_thread_id,
session_configuration.parent_thread_id,
&session_configuration.session_source,
session_configuration.thread_source,
session_configuration.thread_source.clone(),
sub_id.clone(),
cwd.clone(),
&session_configuration.permission_profile(),
@@ -538,7 +538,7 @@ impl Session {
reasoning_summary,
session_source,
parent_thread_id: session_configuration.parent_thread_id,
thread_source: session_configuration.thread_source,
thread_source: session_configuration.thread_source.clone(),
environments,
#[allow(deprecated)]
cwd,
+19 -1
View File
@@ -21,6 +21,7 @@ use codex_app_server_protocol::ThreadHistoryBuilder;
use codex_app_server_protocol::TurnStatus;
use codex_core_plugins::PluginsManager;
use codex_exec_server::EnvironmentManager;
use codex_extension_api::ExtensionDataInit;
use codex_extension_api::ExtensionRegistry;
use codex_extension_api::empty_extension_registry;
use codex_features::Feature;
@@ -183,6 +184,7 @@ pub struct StartThreadOptions {
pub metrics_service_name: Option<String>,
pub parent_trace: Option<W3cTraceContext>,
pub environments: Vec<TurnEnvironmentSelection>,
pub thread_extension_init: ExtensionDataInit,
}
pub(crate) struct ResumeThreadWithHistoryOptions {
@@ -271,7 +273,10 @@ impl ThreadManager {
codex_home.to_path_buf(),
restriction_product,
));
let mcp_manager = Arc::new(McpManager::new(Arc::clone(&plugins_manager)));
let mcp_manager = Arc::new(McpManager::new_with_extensions(
Arc::clone(&plugins_manager),
Arc::clone(&extensions),
));
let skills_manager = Arc::new(SkillsManager::new_with_restriction_product(
codex_home,
config.bundled_skills_enabled(),
@@ -578,6 +583,7 @@ impl ThreadManager {
metrics_service_name: None,
parent_trace: None,
environments,
thread_extension_init: ExtensionDataInit::default(),
}))
.await
}
@@ -617,6 +623,7 @@ impl ThreadManager {
/*inherited_exec_policy*/ None,
options.parent_trace,
options.environments,
options.thread_extension_init,
/*user_shell_override*/ None,
))
.await
@@ -706,6 +713,7 @@ impl ThreadManager {
/*inherited_exec_policy*/ None,
parent_trace,
environments,
/*thread_extension_init*/ ExtensionDataInit::default(),
/*user_shell_override*/ None,
))
.await
@@ -733,6 +741,7 @@ impl ThreadManager {
/*metrics_service_name*/ None,
/*parent_trace*/ None,
environments,
/*thread_extension_init*/ ExtensionDataInit::default(),
/*user_shell_override*/ Some(user_shell_override),
))
.await
@@ -769,6 +778,7 @@ impl ThreadManager {
/*inherited_exec_policy*/ None,
/*parent_trace*/ None,
environments,
/*thread_extension_init*/ ExtensionDataInit::default(),
/*user_shell_override*/ Some(user_shell_override),
))
.await
@@ -952,6 +962,7 @@ impl ThreadManager {
/*metrics_service_name*/ None,
parent_trace,
environments,
/*thread_extension_init*/ ExtensionDataInit::default(),
/*user_shell_override*/ None,
))
.await
@@ -1158,6 +1169,7 @@ impl ThreadManagerState {
inherited_exec_policy,
/*parent_trace*/ None,
environments,
/*thread_extension_init*/ ExtensionDataInit::default(),
/*user_shell_override*/ None,
))
.await
@@ -1195,6 +1207,7 @@ impl ThreadManagerState {
inherited_exec_policy,
/*parent_trace*/ None,
environments,
/*thread_extension_init*/ ExtensionDataInit::default(),
/*user_shell_override*/ None,
))
.await
@@ -1233,6 +1246,7 @@ impl ThreadManagerState {
inherited_exec_policy,
/*parent_trace*/ None,
environments,
/*thread_extension_init*/ ExtensionDataInit::default(),
/*user_shell_override*/ None,
))
.await
@@ -1254,6 +1268,7 @@ impl ThreadManagerState {
metrics_service_name: Option<String>,
parent_trace: Option<W3cTraceContext>,
environments: Vec<TurnEnvironmentSelection>,
thread_extension_init: ExtensionDataInit,
user_shell_override: Option<crate::shell::Shell>,
) -> CodexResult<NewThread> {
Box::pin(self.spawn_thread_with_source(
@@ -1272,6 +1287,7 @@ impl ThreadManagerState {
/*inherited_exec_policy*/ None,
parent_trace,
environments,
thread_extension_init,
user_shell_override,
))
.await
@@ -1295,6 +1311,7 @@ impl ThreadManagerState {
inherited_exec_policy: Option<Arc<crate::exec_policy::ExecPolicyManager>>,
parent_trace: Option<W3cTraceContext>,
environments: Vec<TurnEnvironmentSelection>,
thread_extension_init: ExtensionDataInit,
user_shell_override: Option<crate::shell::Shell>,
) -> CodexResult<NewThread> {
let is_resumed_thread = matches!(&initial_history, InitialHistory::Resumed(_));
@@ -1363,6 +1380,7 @@ impl ThreadManagerState {
user_shell_override,
parent_trace,
environment_selections,
thread_extension_init,
analytics_events_client: self.analytics_events_client.clone(),
thread_store: Arc::clone(&self.thread_store),
attestation_provider: self.attestation_provider.clone(),
+80
View File
@@ -332,6 +332,7 @@ async fn start_thread_rejects_explicit_local_environment_when_default_provider_i
environment_id: "local".to_string(),
cwd: config.cwd.clone(),
}],
thread_extension_init: Default::default(),
})
.await;
let err = match result {
@@ -370,6 +371,7 @@ async fn start_thread_keeps_internal_threads_hidden_from_normal_lookups() {
metrics_service_name: None,
parent_trace: None,
environments: Vec::new(),
thread_extension_init: Default::default(),
})
.await
.expect("internal thread should start");
@@ -386,6 +388,82 @@ async fn start_thread_keeps_internal_threads_hidden_from_normal_lookups() {
assert!(manager.list_thread_ids().await.is_empty());
}
#[tokio::test]
async fn start_thread_seeds_extension_data_before_lifecycle_contributors_run() {
struct InitialMarker(&'static str);
struct InitialDataRecorder {
observed: Arc<std::sync::Mutex<Option<(String, String)>>>,
}
#[async_trait::async_trait]
impl codex_extension_api::ThreadLifecycleContributor<Config> for InitialDataRecorder {
async fn on_thread_start(&self, input: codex_extension_api::ThreadStartInput<'_, Config>) {
let marker = input
.thread_store
.get::<InitialMarker>()
.expect("initial extension data should be available");
*self
.observed
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner) = Some((
input.thread_store.level_id().to_string(),
marker.0.to_string(),
));
}
}
let temp_dir = tempdir().expect("tempdir");
let mut config = test_config().await;
config.codex_home = temp_dir.path().join("codex-home").abs();
config.cwd = config.codex_home.abs();
std::fs::create_dir_all(&config.codex_home).expect("create codex home");
let observed = Arc::new(std::sync::Mutex::new(None));
let mut extensions = codex_extension_api::ExtensionRegistryBuilder::new();
extensions.thread_lifecycle_contributor(Arc::new(InitialDataRecorder {
observed: Arc::clone(&observed),
}));
let manager = ThreadManager::new(
&config,
AuthManager::from_auth_for_testing(CodexAuth::create_dummy_chatgpt_auth_for_testing()),
SessionSource::Exec,
Arc::new(codex_exec_server::EnvironmentManager::default_for_tests()),
Arc::new(extensions.build()),
/*analytics_events_client*/ None,
thread_store_from_config(&config, /*state_db*/ None),
/*state_db*/ None,
TEST_INSTALLATION_ID.to_string(),
/*attestation_provider*/ None,
);
let mut thread_extension_init = codex_extension_api::ExtensionDataInit::new();
thread_extension_init.insert(InitialMarker("seeded"));
let thread = manager
.start_thread_with_options(StartThreadOptions {
config,
initial_history: InitialHistory::New,
session_source: None,
thread_source: None,
multi_agent_version: None,
dynamic_tools: Vec::new(),
metrics_service_name: None,
parent_trace: None,
environments: Vec::new(),
thread_extension_init,
})
.await
.expect("start thread");
assert_eq!(
observed
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.clone(),
Some((thread.thread_id.to_string(), "seeded".to_string()))
);
}
#[tokio::test]
async fn resume_and_fork_do_not_restore_thread_environments_from_rollout() {
let temp_dir = tempdir().expect("tempdir");
@@ -426,6 +504,7 @@ async fn resume_and_fork_do_not_restore_thread_environments_from_rollout() {
metrics_service_name: None,
parent_trace: None,
environments: environments.clone(),
thread_extension_init: Default::default(),
})
.await
.expect("start source thread");
@@ -697,6 +776,7 @@ async fn resume_stopped_thread_from_rollout_preserves_thread_source() {
metrics_service_name: None,
parent_trace: None,
environments: Vec::new(),
thread_extension_init: Default::default(),
})
.await
.expect("start source thread");
+601 -1
View File
@@ -1,18 +1,46 @@
use anyhow::Result;
use anyhow::anyhow;
use codex_core::ForkSnapshot;
use codex_exec_server::CreateDirectoryOptions;
use codex_features::Feature;
use codex_protocol::protocol::EventMsg;
use codex_protocol::protocol::Op;
use codex_protocol::user_input::UserInput;
use codex_utils_absolute_path::AbsolutePathBuf;
use core_test_support::PathBufExt;
use core_test_support::create_directory_symlink;
use core_test_support::load_default_config_for_test;
use core_test_support::responses;
use core_test_support::responses::ev_completed;
use core_test_support::responses::ev_response_created;
use core_test_support::responses::mount_sse_once;
use core_test_support::responses::sse;
use core_test_support::responses::start_mock_server;
use core_test_support::skip_if_no_network;
use core_test_support::test_codex::TestCodexBuilder;
use core_test_support::test_codex::test_codex;
use core_test_support::wait_for_event;
use core_test_support::wait_for_event_match;
use pretty_assertions::assert_eq;
use serde_json::json;
use std::sync::Arc;
use std::time::Duration;
use tempfile::TempDir;
const GLOBAL_AGENTS_FILENAME: &str = "AGENTS.md";
const GLOBAL_AGENTS_OVERRIDE_FILENAME: &str = "AGENTS.override.md";
const GLOBAL_INSTRUCTIONS: &str = "global instructions";
const NEW_GLOBAL_INSTRUCTIONS: &str = "new global instructions";
const NEW_PROJECT_INSTRUCTIONS: &str = "new project instructions";
const OLD_GLOBAL_INSTRUCTIONS: &str = "old global instructions";
const PROJECT_INSTRUCTIONS: &str = "project instructions";
const PROJECT_SEPARATOR: &str = "--- project-doc ---";
const SPAWN_CALL_ID: &str = "spawn-global-instructions-child";
const SPAWN_CHILD_PROMPT: &str = "inspect inherited global instructions";
const SPAWN_FRESH_PARENT_PROMPT: &str = "spawn a child with fresh context";
const SPAWN_PARENT_PROMPT: &str = "spawn a child with the parent context";
const SPAWN_SEED_PROMPT: &str = "seed parent history";
async fn agents_instructions(mut builder: TestCodexBuilder) -> Result<String> {
let server = start_mock_server().await;
let resp_mock = mount_sse_once(
@@ -32,6 +60,52 @@ async fn agents_instructions(mut builder: TestCodexBuilder) -> Result<String> {
.ok_or_else(|| anyhow::anyhow!("instructions message not found"))
}
fn write_global_file(
home: &TempDir,
filename: &str,
contents: impl AsRef<[u8]>,
) -> Result<AbsolutePathBuf> {
let path = home.path().join(filename);
std::fs::write(&path, contents)?;
Ok(path.abs())
}
fn instruction_fragments(request: &responses::ResponsesRequest) -> Vec<String> {
request
.message_input_texts("user")
.into_iter()
.filter(|text| text.starts_with("# AGENTS.md instructions for "))
.collect()
}
fn expected_instruction_fragment(cwd: &AbsolutePathBuf, contents: &str) -> String {
let cwd = cwd.as_path().display();
format!("# AGENTS.md instructions for {cwd}\n\n<INSTRUCTIONS>\n{contents}\n</INSTRUCTIONS>")
}
fn assert_single_instruction_fragment(request: &responses::ResponsesRequest, expected: &str) {
assert_eq!(instruction_fragments(request), vec![expected.to_string()]);
}
fn request_body_contains(request: &wiremock::Request, text: &str) -> bool {
let is_zstd = request
.headers
.get("content-encoding")
.and_then(|value| value.to_str().ok())
.is_some_and(|value| {
value
.split(',')
.any(|entry| entry.trim().eq_ignore_ascii_case("zstd"))
});
let body = if is_zstd {
zstd::stream::decode_all(std::io::Cursor::new(&request.body)).ok()
} else {
Some(request.body.clone())
};
body.and_then(|body| String::from_utf8(body).ok())
.is_some_and(|body| body.contains(text))
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn agents_override_is_preferred_over_agents_md() -> Result<()> {
let instructions =
@@ -250,7 +324,7 @@ async fn selected_environment_sources_match_model_visible_instructions() -> Resu
});
let test = builder.build_with_remote_env(&server).await?;
let project_agents = test.config.cwd.join("AGENTS.md");
let global_agents = AbsolutePathBuf::try_from(global_agents).expect("absolute path");
let global_agents = global_agents.abs();
assert_eq!(
test.codex.instruction_sources().await,
@@ -268,3 +342,529 @@ async fn selected_environment_sources_match_model_visible_instructions() -> Resu
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn fresh_thread_composes_global_before_project_and_reports_sources() -> Result<()> {
// Set up one global source, one project source, and two ordinary model turns.
let server = responses::start_mock_server().await;
let response_mock = responses::mount_sse_sequence(
&server,
vec![
responses::sse(vec![
responses::ev_response_created("response-1"),
responses::ev_completed("response-1"),
]),
responses::sse(vec![
responses::ev_response_created("response-2"),
responses::ev_completed("response-2"),
]),
],
)
.await;
let home = Arc::new(TempDir::new()?);
let global_source =
write_global_file(home.as_ref(), GLOBAL_AGENTS_FILENAME, GLOBAL_INSTRUCTIONS)?;
let mut builder = test_codex()
.with_home(Arc::clone(&home))
.with_workspace_setup(|cwd, fs| async move {
fs.write_file(
&cwd.join("AGENTS.md"),
PROJECT_INSTRUCTIONS.as_bytes().to_vec(),
/*sandbox*/ None,
)
.await?;
Ok(())
});
let test = builder.build_with_remote_env(&server).await?;
let project_source = test.config.cwd.join(GLOBAL_AGENTS_FILENAME);
let creation_sources = vec![global_source.clone(), project_source.clone()];
// Confirm the thread records both creation-time sources in composition order.
assert_eq!(test.codex.instruction_sources().await, creation_sources);
// Materialize the initial snapshot, then rewrite both selected files in place before another
// ordinary turn.
test.submit_turn("first turn").await?;
let rewritten_global_source = write_global_file(
home.as_ref(),
GLOBAL_AGENTS_FILENAME,
NEW_GLOBAL_INSTRUCTIONS,
)?;
test.fs()
.write_file(
&project_source,
NEW_PROJECT_INSTRUCTIONS.as_bytes().to_vec(),
/*sandbox*/ None,
)
.await?;
assert_eq!(
rewritten_global_source, global_source,
"same-path mutation should retain the selected global source path"
);
test.submit_turn("second turn").await?;
// Assert the running thread keeps its original rendering and structured prefix even though
// both files at the reported source paths now contain different text.
let requests = response_mock.requests();
assert_eq!(requests.len(), 2);
let expected_contents =
format!("{GLOBAL_INSTRUCTIONS}\n\n{PROJECT_SEPARATOR}\n\n{PROJECT_INSTRUCTIONS}");
let expected_fragment = expected_instruction_fragment(&test.config.cwd, &expected_contents);
let fragments = instruction_fragments(&requests[0]);
assert_eq!(fragments, vec![expected_fragment.clone()]);
assert_single_instruction_fragment(&requests[1], &expected_fragment);
let rendered = fragments
.into_iter()
.next()
.ok_or_else(|| anyhow!("expected one rendered instruction fragment"))?;
let global_position = rendered.find(GLOBAL_INSTRUCTIONS).ok_or_else(|| {
anyhow!(
"expected rendered instructions to contain {GLOBAL_INSTRUCTIONS:?}; observed: {rendered}"
)
})?;
let project_position = rendered.find(PROJECT_INSTRUCTIONS).ok_or_else(|| {
anyhow!(
"expected rendered instructions to contain {PROJECT_INSTRUCTIONS:?}; observed: {rendered}"
)
})?;
assert!(
global_position < project_position,
"global instructions should precede project instructions: {rendered}"
);
assert!(
rendered.contains(PROJECT_SEPARATOR),
"expected rendered instructions to contain {PROJECT_SEPARATOR:?}; observed: {rendered}"
);
assert_eq!(
test.codex.instruction_sources().await,
creation_sources,
"ordinary turns retain the creation-time source list"
);
let first_input = requests[0].input();
let second_input = requests[1].input();
assert_eq!(
second_input.get(..first_input.len()),
Some(first_input.as_slice()),
"the ordinary second turn should retain the cached prefix"
);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn global_loading_warning_surfaces_during_thread_creation() -> Result<()> {
// Set up a malformed global instruction file and one model response.
let server = responses::start_mock_server().await;
let response_mock = responses::mount_sse_once(
&server,
responses::sse(vec![
responses::ev_response_created("warning-response"),
responses::ev_completed("warning-response"),
]),
)
.await;
let home = Arc::new(TempDir::new()?);
let source = write_global_file(
home.as_ref(),
GLOBAL_AGENTS_FILENAME,
b"global\xFFinstructions",
)?;
// Create the thread, capture its load warning, and submit one turn for rendered output.
let mut builder = test_codex().with_home(home);
let test = builder.build(&server).await?;
let warning = wait_for_event_match(&test.codex, |event| match event {
EventMsg::Warning(warning)
if warning
.message
.contains(source.as_path().display().to_string().as_str()) =>
{
Some(warning.message.clone())
}
_ => None,
})
.await;
test.submit_turn("inspect lossy global instructions")
.await?;
// Assert the source is reported, the warning is specific, and rendering is lossily decoded.
assert_eq!(test.codex.instruction_sources().await, vec![source.clone()]);
assert!(
warning.contains("invalid UTF-8"),
"expected warning to contain \"invalid UTF-8\"; observed: {warning}"
);
let expected_fragment =
expected_instruction_fragment(&test.config.cwd, "global\u{FFFD}instructions");
assert_single_instruction_fragment(&response_mock.single_request(), &expected_fragment);
Ok(())
}
// TODO(anp): Align cold-resume instruction sources with the historical instructions replayed to
// the model so the API source list and model-visible context describe the same files.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn cold_resume_replays_rendered_instructions_but_reports_current_config_sources() -> Result<()>
{
// Set up an initial turn and a later cold-resumed turn against the same rollout.
let server = responses::start_mock_server().await;
let response_mock = responses::mount_sse_sequence(
&server,
vec![
responses::sse(vec![
responses::ev_response_created("initial-response"),
responses::ev_completed("initial-response"),
]),
responses::sse(vec![
responses::ev_response_created("resumed-response"),
responses::ev_completed("resumed-response"),
]),
],
)
.await;
let home = Arc::new(TempDir::new()?);
let old_source = write_global_file(
home.as_ref(),
GLOBAL_AGENTS_FILENAME,
OLD_GLOBAL_INSTRUCTIONS,
)?;
// Create the initial thread and persist its creation-time instruction snapshot.
let mut initial_builder = test_codex().with_home(Arc::clone(&home));
let initial = initial_builder.build(&server).await?;
// Assert the pre-resume thread reports the source used to create its snapshot.
assert_eq!(
initial.codex.instruction_sources().await,
vec![old_source.clone()],
"initial thread reports the creation-time global source"
);
initial.submit_turn("persist instructions").await?;
let rollout_path = initial
.session_configured
.rollout_path
.clone()
.expect("rollout path");
initial.codex.submit(Op::Shutdown).await?;
wait_for_event(&initial.codex, |event| {
matches!(event, EventMsg::ShutdownComplete)
})
.await;
// Add a preferred override source, then cold-resume with freshly loaded configuration.
let new_source = write_global_file(
home.as_ref(),
GLOBAL_AGENTS_OVERRIDE_FILENAME,
NEW_GLOBAL_INSTRUCTIONS,
)?;
assert_ne!(old_source, new_source);
let mut resume_builder = test_codex().with_home(Arc::clone(&home));
let resumed = resume_builder
.resume(&server, Arc::clone(&home), rollout_path)
.await?;
// Assert the API reports the new source while model history replays the old structured prefix.
assert_eq!(
resumed.codex.instruction_sources().await,
vec![new_source],
"resume reports sources from the newly loaded config"
);
resumed.submit_turn("continue resumed thread").await?;
let requests = response_mock.requests();
assert_eq!(requests.len(), 2);
let initial_input = requests[0].input();
let resumed_input = requests[1].input();
assert_eq!(
resumed_input.get(..initial_input.len()),
Some(initial_input.as_slice()),
"cold resume should replay the original structured input prefix"
);
let expected_fragment =
expected_instruction_fragment(&initial.config.cwd, OLD_GLOBAL_INSTRUCTIONS);
assert_single_instruction_fragment(&requests[0], &expected_fragment);
assert_single_instruction_fragment(&requests[1], &expected_fragment);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn fork_replays_rendered_instructions_from_shared_history() -> Result<()> {
// Set up a parent turn and a later fork turn against the parent's rollout.
let server = responses::start_mock_server().await;
let response_mock = responses::mount_sse_sequence(
&server,
vec![
responses::sse(vec![
responses::ev_response_created("parent-response"),
responses::ev_completed("parent-response"),
]),
responses::sse(vec![
responses::ev_response_created("fork-response"),
responses::ev_completed("fork-response"),
]),
],
)
.await;
let home = Arc::new(TempDir::new()?);
let source = write_global_file(
home.as_ref(),
GLOBAL_AGENTS_FILENAME,
OLD_GLOBAL_INSTRUCTIONS,
)?;
// Create the parent and persist its creation-time instruction snapshot.
let mut builder = test_codex().with_home(Arc::clone(&home));
let parent = builder.build(&server).await?;
// Assert the parent reports the source used to create its snapshot.
assert_eq!(
parent.codex.instruction_sources().await,
vec![source.clone()],
"parent reports the creation-time global source"
);
parent.submit_turn("persist instructions").await?;
parent.codex.ensure_rollout_materialized().await;
parent.codex.flush_rollout().await?;
let rollout_path = parent.codex.rollout_path().expect("rollout path");
// Add a preferred override source, then fork with freshly loaded configuration.
let new_source = write_global_file(
home.as_ref(),
GLOBAL_AGENTS_OVERRIDE_FILENAME,
NEW_GLOBAL_INSTRUCTIONS,
)?;
assert_ne!(source, new_source);
let mut fork_config = load_default_config_for_test(home.as_ref()).await;
fork_config.cwd = parent.config.cwd.clone();
fork_config.model = parent.config.model.clone();
fork_config.model_provider = parent.config.model_provider.clone();
fork_config.model_catalog = parent.config.model_catalog.clone();
fork_config.codex_self_exe = parent.config.codex_self_exe.clone();
let forked = parent
.thread_manager
.fork_thread(
ForkSnapshot::Interrupted,
fork_config,
rollout_path,
/*thread_source*/ None,
/*parent_trace*/ None,
)
.await?;
// Assert the fork reports the new source before issuing its first turn.
assert_eq!(
forked.thread.instruction_sources().await,
vec![new_source],
"fork config should reflect the newly loaded global source"
);
forked
.thread
.submit(Op::UserInput {
items: vec![UserInput::Text {
text: "continue fork".to_string(),
text_elements: Vec::new(),
}],
final_output_json_schema: None,
responsesapi_client_metadata: None,
additional_context: Default::default(),
thread_settings: Default::default(),
})
.await?;
wait_for_event(&forked.thread, |event| {
matches!(event, EventMsg::TurnComplete(_))
})
.await;
// Assert the forked model request replays the parent's exact structured history.
let requests = response_mock.requests();
assert_eq!(requests.len(), 2);
let parent_input = requests[0].input();
let fork_input = requests[1].input();
assert_eq!(
fork_input.get(..parent_input.len()),
Some(parent_input.as_slice()),
"fork should replay the parent's original structured input prefix"
);
let expected_fragment =
expected_instruction_fragment(&parent.config.cwd, OLD_GLOBAL_INSTRUCTIONS);
assert_single_instruction_fragment(&requests[0], &expected_fragment);
assert_single_instruction_fragment(&requests[1], &expected_fragment);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn forked_subagent_replays_one_creation_time_global_instruction_fragment() -> Result<()> {
skip_if_no_network!(Ok(()));
run_subagent_global_instruction_case(/*fork_context*/ true).await
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn fresh_subagent_uses_creation_time_instructions_without_parent_history() -> Result<()> {
skip_if_no_network!(Ok(()));
run_subagent_global_instruction_case(/*fork_context*/ false).await
}
async fn run_subagent_global_instruction_case(fork_context: bool) -> Result<()> {
// Set up matched responses for the parent seed, spawn call, child turn, and parent follow-up.
let server = responses::start_mock_server().await;
let parent_prompt = if fork_context {
SPAWN_PARENT_PROMPT
} else {
SPAWN_FRESH_PARENT_PROMPT
};
let seed_mock = responses::mount_sse_once_match(
&server,
|request: &wiremock::Request| request_body_contains(request, SPAWN_SEED_PROMPT),
responses::sse(vec![
responses::ev_response_created("seed-response"),
responses::ev_assistant_message("seed-message", "seeded"),
responses::ev_completed("seed-response"),
]),
)
.await;
let spawn_args = serde_json::to_string(&json!({
"message": SPAWN_CHILD_PROMPT,
"fork_context": fork_context,
}))?;
let spawn_mock = responses::mount_sse_once_match(
&server,
move |request: &wiremock::Request| request_body_contains(request, parent_prompt),
responses::sse(vec![
responses::ev_response_created("spawn-response"),
responses::ev_function_call_with_namespace(
SPAWN_CALL_ID,
"multi_agent_v1",
"spawn_agent",
&spawn_args,
),
responses::ev_completed("spawn-response"),
]),
)
.await;
let child_mock = responses::mount_sse_once_match(
&server,
|request: &wiremock::Request| {
request_body_contains(request, SPAWN_CHILD_PROMPT)
&& !request_body_contains(request, SPAWN_CALL_ID)
},
responses::sse(vec![
responses::ev_response_created("child-response"),
responses::ev_assistant_message("child-message", "done"),
responses::ev_completed("child-response"),
]),
)
.await;
responses::mount_sse_once_match(
&server,
|request: &wiremock::Request| request_body_contains(request, SPAWN_CALL_ID),
responses::sse(vec![
responses::ev_response_created("spawn-follow-up-response"),
responses::ev_assistant_message("spawn-follow-up-message", "child started"),
responses::ev_completed("spawn-follow-up-response"),
]),
)
.await;
// Create the parent thread, record its source, and seed the history inherited by the child.
let home = Arc::new(TempDir::new()?);
let source = write_global_file(
home.as_ref(),
GLOBAL_AGENTS_FILENAME,
OLD_GLOBAL_INSTRUCTIONS,
)?;
let mut builder = test_codex()
.with_home(Arc::clone(&home))
.with_config(|config| {
let _ = config.features.enable(Feature::Collab);
let _ = config.features.disable(Feature::EnableRequestCompression);
});
let test = builder.build(&server).await?;
// Assert the parent reports the creation-time source before spawning.
assert_eq!(
test.codex.instruction_sources().await,
vec![source.clone()],
"parent reports the creation-time global source before spawning"
);
test.submit_turn(SPAWN_SEED_PROMPT).await?;
let seed_request = seed_mock.single_request();
// Add a preferred override, then spawn a full-history child while observing its thread ID.
let new_source = write_global_file(
home.as_ref(),
GLOBAL_AGENTS_OVERRIDE_FILENAME,
NEW_GLOBAL_INSTRUCTIONS,
)?;
assert_ne!(source, new_source);
let mut created_threads = test.thread_manager.subscribe_thread_created();
test.submit_turn(parent_prompt).await?;
let child_thread_id = tokio::time::timeout(Duration::from_secs(10), created_threads.recv())
.await
.map_err(|_| anyhow!("timed out waiting for the subagent thread"))??;
let child_thread = test.thread_manager.get_thread(child_thread_id).await?;
let spawn_request = spawn_mock.single_request();
let child_request = tokio::time::timeout(Duration::from_secs(10), async {
loop {
if let Some(request) = child_mock.requests().into_iter().find(|request| {
request
.message_input_texts("user")
.iter()
.any(|text| text == SPAWN_CHILD_PROMPT)
}) {
break request;
}
tokio::time::sleep(Duration::from_millis(10)).await;
}
})
.await
.map_err(|_| anyhow!("timed out waiting for the subagent request"))?;
// Assert parent and child report and render the parent's creation-time snapshot exactly once.
let expected_fragment =
expected_instruction_fragment(&test.config.cwd, OLD_GLOBAL_INSTRUCTIONS);
assert_single_instruction_fragment(&seed_request, &expected_fragment);
assert_single_instruction_fragment(&spawn_request, &expected_fragment);
assert_single_instruction_fragment(&child_request, &expected_fragment);
assert_eq!(
test.codex.instruction_sources().await,
vec![source.clone()],
"running parent retains the creation-time global source after spawning"
);
assert_eq!(
child_thread.instruction_sources().await,
vec![source],
"subagent reports the parent's creation-time source"
);
if fork_context {
let seed_input = seed_request.input();
let child_input = child_request.input();
assert_eq!(
child_input.get(..seed_input.len()),
Some(seed_input.as_slice()),
"forked subagent should replay the parent's original structured input prefix"
);
} else {
let child_user_texts = child_request.message_input_texts("user");
assert_eq!(
child_user_texts
.iter()
.filter(|text| text.as_str() == SPAWN_SEED_PROMPT)
.count(),
0,
"fresh-context subagent should omit parent user history; observed: {child_user_texts:?}"
);
assert_eq!(
child_user_texts
.iter()
.filter(|text| text.as_str() == SPAWN_CHILD_PROMPT)
.count(),
1,
"fresh-context subagent should contain its own prompt exactly once; observed: {child_user_texts:?}"
);
}
Ok(())
}
+384
View File
@@ -1,4 +1,6 @@
#![allow(clippy::expect_used)]
use anyhow::Result;
use anyhow::anyhow;
use codex_core::compact::SUMMARIZATION_PROMPT;
use codex_core::compact::SUMMARY_PREFIX;
use codex_core::config::Config;
@@ -23,11 +25,13 @@ use codex_protocol::protocol::RolloutItem;
use codex_protocol::protocol::RolloutLine;
use codex_protocol::protocol::WarningEvent;
use codex_protocol::user_input::UserInput;
use codex_utils_absolute_path::AbsolutePathBuf;
use core_test_support::PathBufExt;
use core_test_support::context_snapshot;
use core_test_support::context_snapshot::ContextSnapshotOptions;
use core_test_support::context_snapshot::ContextSnapshotRenderMode;
use core_test_support::hooks::trust_discovered_hooks;
use core_test_support::responses;
use core_test_support::responses::ev_reasoning_item;
use core_test_support::responses::mount_models_once;
use core_test_support::skip_if_no_network;
@@ -57,6 +61,8 @@ use serde_json::Value;
use serde_json::json;
use std::fs;
use std::path::Path;
use std::sync::Arc;
use tempfile::TempDir;
use wiremock::MockServer;
// --- Test helpers -----------------------------------------------------------
@@ -78,6 +84,11 @@ const DUMMY_CALL_ID: &str = "call-multi-auto";
const FUNCTION_CALL_LIMIT_MSG: &str = "function call limit push";
const POST_AUTO_USER_MSG: &str = "post auto follow-up";
const PRETURN_CONTEXT_DIFF_CWD: &str = "/tmp/PRETURN_CONTEXT_DIFF_CWD";
const GLOBAL_AGENTS_FILENAME: &str = "AGENTS.md";
const GLOBAL_AGENTS_OVERRIDE_FILENAME: &str = "AGENTS.override.md";
const NEW_GLOBAL_INSTRUCTIONS: &str = "new global instructions";
const OLD_GLOBAL_INSTRUCTIONS: &str = "old global instructions";
const REMOTE_V2_SUMMARY: &str = "global-instructions-remote-v2-summary";
pub(super) const COMPACT_WARNING_MESSAGE: &str = "Heads up: Long threads and multiple compactions can cause the model to be less accurate. Start a new thread when possible to keep threads small and targeted.";
@@ -267,6 +278,92 @@ fn non_openai_model_provider(server: &MockServer) -> ModelProviderInfo {
provider
}
fn write_global_file(
home: &TempDir,
filename: &str,
contents: impl AsRef<[u8]>,
) -> Result<AbsolutePathBuf> {
let path = home.path().join(filename);
std::fs::write(&path, contents)?;
Ok(path.abs())
}
fn instruction_fragments(request: &responses::ResponsesRequest) -> Vec<String> {
request
.message_input_texts("user")
.into_iter()
.filter(|text| text.starts_with("# AGENTS.md instructions for "))
.collect()
}
fn instruction_fragments_in_items(items: &[Value]) -> Vec<String> {
items
.iter()
.filter(|item| {
item.get("type").and_then(Value::as_str) == Some("message")
&& item.get("role").and_then(Value::as_str) == Some("user")
})
.filter_map(|item| item.get("content").and_then(Value::as_array))
.flatten()
.filter_map(|span| span.get("text").and_then(Value::as_str))
.filter(|text| text.starts_with("# AGENTS.md instructions for "))
.map(str::to_string)
.collect()
}
fn expected_instruction_fragment(cwd: &AbsolutePathBuf, contents: &str) -> String {
let cwd = cwd.as_path().display();
format!("# AGENTS.md instructions for {cwd}\n\n<INSTRUCTIONS>\n{contents}\n</INSTRUCTIONS>")
}
fn assert_single_instruction_fragment(request: &responses::ResponsesRequest, expected: &str) {
assert_eq!(instruction_fragments(request), vec![expected.to_string()]);
}
fn replacement_history_from_rollout(path: &Path) -> Result<Vec<Value>> {
let rollout_text = fs::read_to_string(path)?;
let mut replacement_history = None;
for line in rollout_text
.lines()
.map(str::trim)
.filter(|line| !line.is_empty())
{
let entry: RolloutLine = serde_json::from_str(line)?;
if let RolloutItem::Compacted(compacted) = entry.item
&& let Some(items) = compacted.replacement_history
{
replacement_history = Some(
items
.into_iter()
.map(serde_json::to_value)
.collect::<std::result::Result<Vec<_>, _>>()?,
);
}
}
replacement_history.ok_or_else(|| anyhow!("expected rollout replacement history"))
}
fn remote_v2_compaction_response() -> String {
responses::sse(vec![
json!({
"type": "response.output_item.done",
"item": {
"type": "compaction",
"encrypted_content": REMOTE_V2_SUMMARY,
}
}),
responses::ev_completed("remote-v2-compact-response"),
])
}
fn local_compaction_provider(server: &wiremock::MockServer) -> ModelProviderInfo {
let mut provider = built_in_model_providers(/*openai_base_url*/ None)["openai"].clone();
provider.name = "OpenAI-compatible test provider".to_string();
provider.base_url = Some(format!("{}/v1", server.uri()));
provider.supports_websockets = false;
provider
}
fn model_info_with_context_window(slug: &str, context_window: i64) -> ModelInfo {
let models_response = bundled_models_response()
.unwrap_or_else(|err| panic!("bundled models.json should parse: {err}"));
@@ -3998,3 +4095,290 @@ async fn snapshot_request_shape_manual_compact_without_previous_user_messages()
)
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn manual_compaction_keeps_the_creation_time_global_instructions() -> Result<()> {
// Set up an initial turn, a manual compaction response, and a post-compaction turn.
let server = responses::start_mock_server().await;
let response_mock = responses::mount_sse_sequence(
&server,
vec![
responses::sse(vec![
responses::ev_response_created("first-response"),
responses::ev_completed("first-response"),
]),
responses::sse(vec![
responses::ev_response_created("compact-response"),
responses::ev_assistant_message("compact-message", "summary"),
responses::ev_completed("compact-response"),
]),
responses::sse(vec![
responses::ev_response_created("follow-up-response"),
responses::ev_completed("follow-up-response"),
]),
],
)
.await;
let home = Arc::new(TempDir::new()?);
let source = write_global_file(
home.as_ref(),
GLOBAL_AGENTS_FILENAME,
OLD_GLOBAL_INSTRUCTIONS,
)?;
let provider = local_compaction_provider(&server);
// Create the thread with the old global source loaded into its instruction snapshot.
let mut builder = test_codex()
.with_home(Arc::clone(&home))
.with_config(move |config| {
config.model_provider = provider;
});
let test = builder.build(&server).await?;
// Assert the pre-compaction source list points at the creation-time file.
assert_eq!(
test.codex.instruction_sources().await,
vec![source.clone()],
"thread reports the creation-time global source before compaction"
);
// Materialize the old snapshot, rewrite the selected file in place, and manually compact.
test.submit_turn("first turn").await?;
let rewritten_source = write_global_file(
home.as_ref(),
GLOBAL_AGENTS_FILENAME,
NEW_GLOBAL_INSTRUCTIONS,
)?;
assert_eq!(source, rewritten_source);
test.codex.submit(Op::Compact).await?;
wait_for_event(&test.codex, |event| {
matches!(event, EventMsg::TurnComplete(_))
})
.await;
test.submit_turn("after compact").await?;
// Assert ordinary and compact turns keep the old rendering even though the reported source
// path now contains new text.
let requests = response_mock.requests();
assert_eq!(requests.len(), 3);
let expected_fragment =
expected_instruction_fragment(&test.config.cwd, OLD_GLOBAL_INSTRUCTIONS);
assert_single_instruction_fragment(&requests[0], &expected_fragment);
assert_single_instruction_fragment(&requests[1], &expected_fragment);
assert_single_instruction_fragment(&requests[2], &expected_fragment);
assert_eq!(
test.codex.instruction_sources().await,
vec![source],
"thread retains the creation-time global source after compaction"
);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn mid_turn_compaction_keeps_the_creation_time_global_instructions() -> Result<()> {
// Set up a turn that crosses the auto-compaction limit and a post-compaction response.
let server = responses::start_mock_server().await;
let response_mock = responses::mount_sse_sequence(
&server,
vec![
responses::sse(vec![
responses::ev_function_call("call-1", "unsupported_tool", "{}"),
responses::ev_completed_with_tokens("first-response", /*total_tokens*/ 96),
]),
responses::sse(vec![
responses::ev_assistant_message("compact-message", "summary"),
responses::ev_completed_with_tokens("compact-response", /*total_tokens*/ 10),
]),
responses::sse(vec![
responses::ev_assistant_message("final-message", "done"),
responses::ev_completed_with_tokens("follow-up-response", /*total_tokens*/ 10),
]),
],
)
.await;
let home = Arc::new(TempDir::new()?);
let source = write_global_file(
home.as_ref(),
GLOBAL_AGENTS_FILENAME,
OLD_GLOBAL_INSTRUCTIONS,
)?;
let provider = local_compaction_provider(&server);
// Create the thread with the old global source loaded into its instruction snapshot.
let mut builder = test_codex()
.with_home(Arc::clone(&home))
.with_config(move |config| {
config.model_provider = provider;
config.model_context_window = Some(100);
config.model_auto_compact_token_limit = Some(90);
});
let test = builder.build(&server).await?;
// Assert the pre-compaction source list points at the creation-time file.
assert_eq!(
test.codex.instruction_sources().await,
vec![source.clone()],
"thread reports the creation-time global source before mid-turn compaction"
);
// Add a preferred override before the turn triggers automatic mid-turn compaction.
let new_source = write_global_file(
home.as_ref(),
GLOBAL_AGENTS_OVERRIDE_FILENAME,
NEW_GLOBAL_INSTRUCTIONS,
)?;
assert_ne!(source, new_source);
test.submit_turn("trigger mid-turn compaction").await?;
// Assert the initial, compact, and resumed requests all keep the old snapshot and source.
let requests = response_mock.requests();
assert_eq!(requests.len(), 3);
let expected_fragment =
expected_instruction_fragment(&test.config.cwd, OLD_GLOBAL_INSTRUCTIONS);
assert_single_instruction_fragment(&requests[0], &expected_fragment);
assert_single_instruction_fragment(&requests[1], &expected_fragment);
assert_single_instruction_fragment(&requests[2], &expected_fragment);
assert_eq!(
test.codex.instruction_sources().await,
vec![source],
"thread retains the creation-time global source after mid-turn compaction"
);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn remote_v2_compaction_keeps_creation_time_instructions_after_same_path_mutation()
-> Result<()> {
skip_if_no_network!(Ok(()));
// Set up an ordinary turn, a remote-v2 compact response, and a post-compaction turn.
let server = responses::start_mock_server().await;
let response_mock = responses::mount_sse_sequence(
&server,
vec![
responses::sse(vec![
responses::ev_response_created("remote-v2-initial-response"),
responses::ev_completed("remote-v2-initial-response"),
]),
remote_v2_compaction_response(),
responses::sse(vec![
responses::ev_response_created("remote-v2-follow-up-response"),
responses::ev_completed("remote-v2-follow-up-response"),
]),
responses::sse(vec![
responses::ev_response_created("remote-v2-resumed-response"),
responses::ev_completed("remote-v2-resumed-response"),
]),
],
)
.await;
let home = Arc::new(TempDir::new()?);
let source = write_global_file(
home.as_ref(),
GLOBAL_AGENTS_FILENAME,
OLD_GLOBAL_INSTRUCTIONS,
)?;
let mut builder = test_codex()
.with_home(Arc::clone(&home))
.with_auth(CodexAuth::create_dummy_chatgpt_auth_for_testing())
.with_config(|config| {
let _ = config.features.enable(Feature::RemoteCompactionV2);
});
let test = builder.build(&server).await?;
// Materialize the old snapshot, rewrite the selected file in place, and compact remotely.
test.submit_turn("before remote v2 compaction").await?;
let rewritten_source = write_global_file(
home.as_ref(),
GLOBAL_AGENTS_FILENAME,
NEW_GLOBAL_INSTRUCTIONS,
)?;
assert_eq!(source, rewritten_source);
test.codex.submit(Op::Compact).await?;
wait_for_event(&test.codex, |event| {
matches!(event, EventMsg::TurnComplete(_))
})
.await;
test.submit_turn("after remote v2 compaction").await?;
test.codex.flush_rollout().await?;
// Assert the compact request, installed replacement history, and follow-up all keep the
// creation-time item despite the file-backed source now containing new text.
let requests = response_mock.requests();
assert_eq!(requests.len(), 3);
let old_fragment = expected_instruction_fragment(&test.config.cwd, OLD_GLOBAL_INSTRUCTIONS);
assert_single_instruction_fragment(&requests[0], &old_fragment);
assert_single_instruction_fragment(&requests[1], &old_fragment);
assert_single_instruction_fragment(&requests[2], &old_fragment);
assert_eq!(
requests[1].input().last(),
Some(&json!({"type": "compaction_trigger"})),
"remote-v2 compact request should append exactly one compaction trigger"
);
let rollout_path = test.codex.rollout_path().expect("rollout path");
let replacement_history = replacement_history_from_rollout(&rollout_path)?;
assert_eq!(
instruction_fragments_in_items(&replacement_history),
Vec::<String>::new(),
"remote-v2 replacement history currently omits the global-instruction fragment"
);
assert_eq!(
test.codex.instruction_sources().await,
vec![source.clone()],
"running thread retains the selected same-path source"
);
assert_eq!(
fs::read_to_string(source.as_path())?,
NEW_GLOBAL_INSTRUCTIONS,
"the selected source path should contain the rewritten text"
);
// Cold-resume the persisted replacement history with freshly loaded same-path configuration.
test.codex.submit(Op::Shutdown).await?;
wait_for_event(&test.codex, |event| {
matches!(event, EventMsg::ShutdownComplete)
})
.await;
let resumed_cwd = test.config.cwd.clone();
let mut resume_builder = test_codex()
.with_home(Arc::clone(&home))
.with_auth(CodexAuth::create_dummy_chatgpt_auth_for_testing())
.with_config(move |config| {
config.cwd = resumed_cwd;
let _ = config.features.enable(Feature::RemoteCompactionV2);
});
let resumed = resume_builder
.resume(&server, Arc::clone(&home), rollout_path)
.await?;
resumed
.submit_turn("after remote v2 compaction cold resume")
.await?;
// Modern replacement-history resume replays the persisted checkpoint and its later old-context
// suffix even though the same source path now contains new text.
let requests = response_mock.requests();
assert_eq!(requests.len(), 4);
assert_single_instruction_fragment(&requests[3], &old_fragment);
let resumed_input = requests[3].input();
assert_eq!(
resumed_input.get(..replacement_history.len()),
Some(replacement_history.as_slice()),
"remote-v2 cold resume should replay persisted replacement history verbatim"
);
let post_compact_input = requests[2].input();
assert_eq!(
resumed_input.get(..post_compact_input.len()),
Some(post_compact_input.as_slice()),
"remote-v2 cold resume should replay the complete post-compaction structured prefix"
);
assert_eq!(
resumed.codex.instruction_sources().await,
vec![source],
"cold-resumed thread reports the same rewritten source path"
);
Ok(())
}
@@ -758,6 +758,7 @@ async fn subagent_stop_replaces_stop_and_skips_internal_subagents() -> Result<()
metrics_service_name: None,
parent_trace: None,
environments: Vec::new(),
thread_extension_init: Default::default(),
})
.await?;
+1
View File
@@ -100,6 +100,7 @@ async fn multi_agent_version_override_omits_multi_agent_tools_from_requests() ->
metrics_service_name: None,
parent_trace: None,
environments: Vec::new(),
thread_extension_init: Default::default(),
})
.await?;
assert_eq!(
+2 -2
View File
@@ -1146,7 +1146,7 @@ fn session_configured_from_thread_start_response(
&response.thread.session_id,
&response.thread.id,
response.thread.parent_thread_id.as_deref(),
response.thread.thread_source.map(Into::into),
response.thread.thread_source.clone().map(Into::into),
response.thread.name.clone(),
response.thread.path.clone(),
response.model.clone(),
@@ -1169,7 +1169,7 @@ fn session_configured_from_thread_resume_response(
&response.thread.session_id,
&response.thread.id,
response.thread.parent_thread_id.as_deref(),
response.thread.thread_source.map(Into::into),
response.thread.thread_source.clone().map(Into::into),
response.thread.name.clone(),
response.thread.path.clone(),
response.model.clone(),
+5
View File
@@ -15,6 +15,11 @@ workspace = true
[dependencies]
async-trait = { workspace = true }
codex-config = { workspace = true }
codex-context-fragments = { workspace = true }
codex-protocol = { workspace = true }
codex-tools = { workspace = true }
[dev-dependencies]
pretty_assertions = { workspace = true }
tokio = { workspace = true, features = ["macros", "rt-multi-thread"] }
@@ -10,12 +10,14 @@ use codex_tools::ToolExecutor;
use crate::ExtensionData;
mod mcp;
mod prompt;
mod thread_lifecycle;
mod tool_lifecycle;
mod turn_input;
mod turn_lifecycle;
pub use mcp::McpServerContribution;
pub use prompt::PromptFragment;
pub use prompt::PromptSlot;
pub use thread_lifecycle::ThreadIdleInput;
@@ -34,6 +36,18 @@ pub use turn_lifecycle::TurnErrorInput;
pub use turn_lifecycle::TurnStartInput;
pub use turn_lifecycle::TurnStopInput;
/// Extension contribution that resolves runtime MCP servers from host config.
///
/// Contributors run in registration order. Later contributions for the same
/// name replace earlier ones. Implementations must contribute only names they
/// own and must apply any source-specific policy before returning a server.
/// Plugin-owned servers and their provenance continue to be resolved by the
/// plugin manager until that ownership moves into an extension explicitly.
#[async_trait::async_trait]
pub trait McpServerContributor<C: Sync>: Send + Sync {
async fn contribute(&self, config: &C) -> Vec<McpServerContribution>;
}
/// Extension contribution that adds prompt fragments during prompt assembly.
pub trait ContextContributor: Send + Sync {
fn contribute<'a>(
@@ -0,0 +1,22 @@
use codex_config::McpServerConfig;
/// One extension-owned overlay for the runtime MCP server configuration.
#[derive(Clone, Debug)]
pub enum McpServerContribution {
/// Adds or replaces a named MCP server.
Set {
name: String,
config: Box<McpServerConfig>,
},
/// Removes a named MCP server.
Remove { name: String },
}
impl McpServerContribution {
/// Returns the stable server name owned by this contribution.
pub fn name(&self) -> &str {
match self {
Self::Set { name, .. } | Self::Remove { name } => name,
}
}
}
+3
View File
@@ -31,6 +31,8 @@ pub use codex_tools::parse_tool_input_schema_without_compaction;
pub use contributors::ApprovalReviewContributor;
pub use contributors::ConfigContributor;
pub use contributors::ContextContributor;
pub use contributors::McpServerContribution;
pub use contributors::McpServerContributor;
pub use contributors::PromptFragment;
pub use contributors::PromptSlot;
pub use contributors::ThreadIdleInput;
@@ -59,3 +61,4 @@ pub use registry::ExtensionRegistry;
pub use registry::ExtensionRegistryBuilder;
pub use registry::empty_extension_registry;
pub use state::ExtensionData;
pub use state::ExtensionDataInit;
@@ -7,6 +7,7 @@ use crate::ConfigContributor;
use crate::ContextContributor;
use crate::ExtensionData;
use crate::ExtensionEventSink;
use crate::McpServerContributor;
use crate::NoopExtensionEventSink;
use crate::ThreadLifecycleContributor;
use crate::TokenUsageContributor;
@@ -24,6 +25,7 @@ pub struct ExtensionRegistryBuilder<C: Sync> {
config_contributors: Vec<Arc<dyn ConfigContributor<C>>>,
token_usage_contributors: Vec<Arc<dyn TokenUsageContributor>>,
context_contributors: Vec<Arc<dyn ContextContributor>>,
mcp_server_contributors: Vec<Arc<dyn McpServerContributor<C>>>,
turn_input_contributors: Vec<Arc<dyn TurnInputContributor>>,
tool_contributors: Vec<Arc<dyn ToolContributor>>,
tool_lifecycle_contributors: Vec<Arc<dyn ToolLifecycleContributor>>,
@@ -41,6 +43,7 @@ impl<C: Sync> Default for ExtensionRegistryBuilder<C> {
token_usage_contributors: Vec::new(),
approval_review_contributors: Vec::new(),
context_contributors: Vec::new(),
mcp_server_contributors: Vec::new(),
turn_input_contributors: Vec::new(),
tool_contributors: Vec::new(),
tool_lifecycle_contributors: Vec::new(),
@@ -101,6 +104,11 @@ impl<C: Sync> ExtensionRegistryBuilder<C> {
self.context_contributors.push(contributor);
}
/// Registers one runtime MCP server contributor.
pub fn mcp_server_contributor(&mut self, contributor: Arc<dyn McpServerContributor<C>>) {
self.mcp_server_contributors.push(contributor);
}
/// Registers one turn-input contributor.
pub fn turn_input_contributor(&mut self, contributor: Arc<dyn TurnInputContributor>) {
self.turn_input_contributors.push(contributor);
@@ -131,6 +139,7 @@ impl<C: Sync> ExtensionRegistryBuilder<C> {
token_usage_contributors: self.token_usage_contributors,
approval_review_contributors: self.approval_review_contributors,
context_contributors: self.context_contributors,
mcp_server_contributors: self.mcp_server_contributors,
turn_input_contributors: self.turn_input_contributors,
tool_contributors: self.tool_contributors,
tool_lifecycle_contributors: self.tool_lifecycle_contributors,
@@ -147,6 +156,7 @@ pub struct ExtensionRegistry<C: Sync> {
config_contributors: Vec<Arc<dyn ConfigContributor<C>>>,
token_usage_contributors: Vec<Arc<dyn TokenUsageContributor>>,
context_contributors: Vec<Arc<dyn ContextContributor>>,
mcp_server_contributors: Vec<Arc<dyn McpServerContributor<C>>>,
turn_input_contributors: Vec<Arc<dyn TurnInputContributor>>,
tool_contributors: Vec<Arc<dyn ToolContributor>>,
tool_lifecycle_contributors: Vec<Arc<dyn ToolLifecycleContributor>>,
@@ -205,6 +215,11 @@ impl<C: Sync> ExtensionRegistry<C> {
&self.context_contributors
}
/// Returns the registered runtime MCP server contributors.
pub fn mcp_server_contributors(&self) -> &[Arc<dyn McpServerContributor<C>>] {
&self.mcp_server_contributors
}
/// Returns the registered turn-input contributors.
pub fn turn_input_contributors(&self) -> &[Arc<dyn TurnInputContributor>] {
&self.turn_input_contributors
+32 -1
View File
@@ -7,6 +7,32 @@ use std::sync::PoisonError;
type ErasedData = Arc<dyn Any + Send + Sync>;
/// Typed values supplied before an [`ExtensionData`] scope is created.
///
/// Hosts consume this value once to seed a scope before lifecycle contributors
/// run. It does not install extensions or provide persistence.
#[derive(Debug, Default)]
pub struct ExtensionDataInit {
entries: HashMap<TypeId, ErasedData>,
}
impl ExtensionDataInit {
/// Creates an empty extension data initializer.
pub fn new() -> Self {
Self::default()
}
/// Stores `value` as the initial attachment of type `T`.
pub fn insert<T>(&mut self, value: T) -> Option<Arc<T>>
where
T: Any + Send + Sync,
{
self.entries
.insert(TypeId::of::<T>(), Arc::new(value))
.map(downcast_data)
}
}
/// Typed extension-owned data attached to one host object.
#[derive(Debug)]
pub struct ExtensionData {
@@ -17,9 +43,14 @@ pub struct ExtensionData {
impl ExtensionData {
/// Creates an empty attachment map for one host-owned scope.
pub fn new(level_id: impl Into<String>) -> Self {
Self::new_with_init(level_id, ExtensionDataInit::default())
}
/// Creates an attachment map seeded with host-supplied initial data.
pub fn new_with_init(level_id: impl Into<String>, init: ExtensionDataInit) -> Self {
Self {
level_id: level_id.into(),
entries: Mutex::new(HashMap::new()),
entries: Mutex::new(init.entries),
}
}
@@ -0,0 +1,56 @@
use std::sync::Arc;
use std::sync::Mutex;
use codex_extension_api::AgentSpawnFuture;
use codex_extension_api::AgentSpawner;
use codex_extension_api::NoopResponseItemInjector;
use codex_extension_api::ResponseItemInjector;
use codex_protocol::ThreadId;
use codex_protocol::models::ContentItem;
use codex_protocol::models::ResponseInputItem;
use pretty_assertions::assert_eq;
#[tokio::test]
async fn noop_response_item_injector_returns_original_items() {
let items = vec![ResponseInputItem::Message {
role: "user".to_string(),
content: vec![ContentItem::InputText {
text: "keep this input".to_string(),
}],
phase: None,
}];
let returned_items = NoopResponseItemInjector
.inject_response_items(items.clone())
.await
.expect_err("noop injector should reject same-turn injection");
assert_eq!(returned_items, items);
}
#[tokio::test]
async fn closure_agent_spawner_forwards_arguments_and_result() {
let calls = Arc::new(Mutex::new(Vec::new()));
let recorded_calls = Arc::clone(&calls);
let spawner = move |thread_id: ThreadId,
request: String|
-> AgentSpawnFuture<'static, usize, &'static str> {
recorded_calls
.lock()
.expect("agent spawn calls lock")
.push((thread_id, request.clone()));
Box::pin(async move { Ok(request.len()) })
};
let thread_id =
ThreadId::from_string("11111111-1111-4111-8111-111111111111").expect("valid thread id");
let spawned = spawner
.spawn_subagent(thread_id, "delegate this".to_string())
.await;
assert_eq!(spawned, Ok(13));
assert_eq!(
calls.lock().expect("agent spawn calls lock").as_slice(),
[(thread_id, "delegate this".to_string())]
);
}
@@ -0,0 +1,370 @@
use std::future::Future;
use std::pin::Pin;
use std::sync::Arc;
use std::sync::Mutex;
use codex_extension_api::ApprovalReviewContributor;
use codex_extension_api::ConfigContributor;
use codex_extension_api::ContextContributor;
use codex_extension_api::ContextualUserFragment;
use codex_extension_api::ExtensionData;
use codex_extension_api::ExtensionEventSink;
use codex_extension_api::ExtensionRegistryBuilder;
use codex_extension_api::PromptFragment;
use codex_extension_api::ThreadLifecycleContributor;
use codex_extension_api::TokenUsageContributor;
use codex_extension_api::ToolCall;
use codex_extension_api::ToolContributor;
use codex_extension_api::ToolExecutor;
use codex_extension_api::ToolLifecycleContributor;
use codex_extension_api::TurnInputContext;
use codex_extension_api::TurnInputContributor;
use codex_extension_api::TurnItemContributor;
use codex_extension_api::TurnLifecycleContributor;
use codex_extension_api::empty_extension_registry;
use codex_protocol::items::HookPromptItem;
use codex_protocol::items::TurnItem;
use codex_protocol::protocol::Event;
use codex_protocol::protocol::EventMsg;
use codex_protocol::protocol::ReviewDecision;
use codex_protocol::protocol::WarningEvent;
use pretty_assertions::assert_eq;
struct AllContributors;
impl ContextContributor for AllContributors {
fn contribute<'a>(
&'a self,
_session_store: &'a ExtensionData,
_thread_store: &'a ExtensionData,
) -> Pin<Box<dyn Future<Output = Vec<PromptFragment>> + Send + 'a>> {
Box::pin(std::future::ready(Vec::new()))
}
}
#[async_trait::async_trait]
impl ThreadLifecycleContributor<()> for AllContributors {}
#[async_trait::async_trait]
impl TurnLifecycleContributor for AllContributors {}
impl ConfigContributor<()> for AllContributors {}
#[async_trait::async_trait]
impl TokenUsageContributor for AllContributors {}
#[async_trait::async_trait]
impl TurnInputContributor for AllContributors {
async fn contribute(
&self,
_input: TurnInputContext,
_session_store: &ExtensionData,
_thread_store: &ExtensionData,
_turn_store: &ExtensionData,
) -> Vec<Box<dyn ContextualUserFragment + Send>> {
Vec::new()
}
}
impl ToolContributor for AllContributors {
fn tools(
&self,
_session_store: &ExtensionData,
_thread_store: &ExtensionData,
) -> Vec<Arc<dyn ToolExecutor<ToolCall>>> {
Vec::new()
}
}
impl ToolLifecycleContributor for AllContributors {}
#[async_trait::async_trait]
impl TurnItemContributor for AllContributors {
async fn contribute(
&self,
_thread_store: &ExtensionData,
_turn_store: &ExtensionData,
_item: &mut TurnItem,
) -> Result<(), String> {
Ok(())
}
}
#[async_trait::async_trait]
impl ApprovalReviewContributor for AllContributors {
async fn contribute(
&self,
_session_store: &ExtensionData,
_thread_store: &ExtensionData,
_prompt: &str,
) -> Option<ReviewDecision> {
Some(ReviewDecision::ApprovedForSession)
}
}
#[tokio::test]
async fn build_round_trips_every_contributor_category() {
let contributor = Arc::new(AllContributors);
let mut builder = ExtensionRegistryBuilder::<()>::new();
builder.thread_lifecycle_contributor(contributor.clone());
builder.turn_lifecycle_contributor(contributor.clone());
builder.config_contributor(contributor.clone());
builder.token_usage_contributor(contributor.clone());
builder.prompt_contributor(contributor.clone());
builder.turn_input_contributor(contributor.clone());
builder.tool_contributor(contributor.clone());
builder.tool_lifecycle_contributor(contributor.clone());
builder.turn_item_contributor(contributor.clone());
builder.approval_review_contributor(contributor);
let registry = builder.build();
assert_eq!(registry.thread_lifecycle_contributors().len(), 1);
assert_eq!(registry.turn_lifecycle_contributors().len(), 1);
assert_eq!(registry.config_contributors().len(), 1);
assert_eq!(registry.token_usage_contributors().len(), 1);
assert_eq!(registry.context_contributors().len(), 1);
assert_eq!(registry.turn_input_contributors().len(), 1);
assert_eq!(registry.tool_contributors().len(), 1);
assert_eq!(registry.tool_lifecycle_contributors().len(), 1);
assert_eq!(registry.turn_item_contributors().len(), 1);
assert_eq!(
registry
.approval_review(
&ExtensionData::new("session"),
&ExtensionData::new("thread"),
"review this",
)
.await,
Some(ReviewDecision::ApprovedForSession)
);
}
struct NamedContextContributor(&'static str);
impl ContextContributor for NamedContextContributor {
fn contribute<'a>(
&'a self,
_session_store: &'a ExtensionData,
_thread_store: &'a ExtensionData,
) -> Pin<Box<dyn Future<Output = Vec<PromptFragment>> + Send + 'a>> {
Box::pin(std::future::ready(vec![PromptFragment::developer_policy(
self.0,
)]))
}
}
struct RecordingTurnItemContributor {
name: &'static str,
calls: Arc<Mutex<Vec<&'static str>>>,
}
#[async_trait::async_trait]
impl TurnItemContributor for RecordingTurnItemContributor {
async fn contribute(
&self,
_thread_store: &ExtensionData,
_turn_store: &ExtensionData,
_item: &mut TurnItem,
) -> Result<(), String> {
self.calls
.lock()
.unwrap_or_else(|error| panic!("turn item calls lock poisoned: {error}"))
.push(self.name);
Ok(())
}
}
#[tokio::test]
async fn contributors_preserve_registration_order() {
let turn_item_calls = Arc::new(Mutex::new(Vec::new()));
let mut builder = ExtensionRegistryBuilder::<()>::new();
builder.prompt_contributor(Arc::new(NamedContextContributor("first")));
builder.prompt_contributor(Arc::new(NamedContextContributor("second")));
for name in ["first", "second"] {
builder.turn_item_contributor(Arc::new(RecordingTurnItemContributor {
name,
calls: Arc::clone(&turn_item_calls),
}));
}
let registry = builder.build();
let session_store = ExtensionData::new("session");
let thread_store = ExtensionData::new("thread");
let turn_store = ExtensionData::new("turn");
let mut fragments = Vec::new();
for contributor in registry.context_contributors() {
fragments.extend(contributor.contribute(&session_store, &thread_store).await);
}
let mut item = TurnItem::HookPrompt(HookPromptItem {
id: "item".to_string(),
fragments: Vec::new(),
});
for contributor in registry.turn_item_contributors() {
contributor
.contribute(&thread_store, &turn_store, &mut item)
.await
.expect("turn item contribution should succeed");
}
assert_eq!(
fragments,
vec![
PromptFragment::developer_policy("first"),
PromptFragment::developer_policy("second"),
]
);
assert_eq!(
turn_item_calls
.lock()
.expect("turn item calls lock")
.as_slice(),
["first", "second"]
);
}
#[derive(Debug, PartialEq, Eq)]
struct ApprovalCall {
contributor: &'static str,
session_id: String,
thread_id: String,
prompt: String,
}
struct RecordingApprovalContributor {
name: &'static str,
decision: Option<ReviewDecision>,
calls: Arc<Mutex<Vec<ApprovalCall>>>,
}
#[async_trait::async_trait]
impl ApprovalReviewContributor for RecordingApprovalContributor {
async fn contribute(
&self,
session_store: &ExtensionData,
thread_store: &ExtensionData,
prompt: &str,
) -> Option<ReviewDecision> {
self.calls
.lock()
.unwrap_or_else(|error| panic!("approval calls lock poisoned: {error}"))
.push(ApprovalCall {
contributor: self.name,
session_id: session_store.level_id().to_string(),
thread_id: thread_store.level_id().to_string(),
prompt: prompt.to_string(),
});
self.decision.clone()
}
}
#[tokio::test]
async fn approval_review_returns_first_claim_and_short_circuits() {
let calls = Arc::new(Mutex::new(Vec::new()));
let mut builder = ExtensionRegistryBuilder::<()>::new();
for (name, decision) in [
("first", None),
("second", Some(ReviewDecision::Approved)),
("third", Some(ReviewDecision::Denied)),
] {
builder.approval_review_contributor(Arc::new(RecordingApprovalContributor {
name,
decision,
calls: Arc::clone(&calls),
}));
}
let registry = builder.build();
let decision = registry
.approval_review(
&ExtensionData::new("session-1"),
&ExtensionData::new("thread-1"),
"allow command?",
)
.await;
assert_eq!(decision, Some(ReviewDecision::Approved));
assert_eq!(
calls.lock().expect("approval calls lock").as_slice(),
[
ApprovalCall {
contributor: "first",
session_id: "session-1".to_string(),
thread_id: "thread-1".to_string(),
prompt: "allow command?".to_string(),
},
ApprovalCall {
contributor: "second",
session_id: "session-1".to_string(),
thread_id: "thread-1".to_string(),
prompt: "allow command?".to_string(),
},
]
);
}
#[derive(Default)]
struct RecordingEventSink {
events: Mutex<Vec<(String, String)>>,
}
impl ExtensionEventSink for RecordingEventSink {
fn emit(&self, event: Event) {
let EventMsg::Warning(warning) = event.msg else {
panic!("test sink only accepts warning events");
};
self.events
.lock()
.unwrap_or_else(|error| panic!("recording event sink lock poisoned: {error}"))
.push((event.id, warning.message));
}
}
#[test]
fn custom_event_sink_survives_registry_build() {
let sink = Arc::new(RecordingEventSink::default());
let builder = ExtensionRegistryBuilder::<()>::with_event_sink(sink.clone());
builder
.event_sink()
.emit(warning_event("builder", "before"));
let registry = builder.build();
registry
.event_sink()
.emit(warning_event("registry", "after"));
assert_eq!(
sink.events
.lock()
.expect("recording event sink lock")
.as_slice(),
[
("builder".to_string(), "before".to_string()),
("registry".to_string(), "after".to_string()),
]
);
}
#[tokio::test]
async fn empty_registry_does_not_claim_approval_review() {
let registry = empty_extension_registry::<()>();
assert_eq!(
registry
.approval_review(
&ExtensionData::new("session"),
&ExtensionData::new("thread"),
"unclaimed",
)
.await,
None
);
}
fn warning_event(id: &str, message: &str) -> Event {
Event {
id: id.to_string(),
msg: EventMsg::Warning(WarningEvent {
message: message.to_string(),
}),
}
}
+109
View File
@@ -0,0 +1,109 @@
use std::panic::AssertUnwindSafe;
use std::sync::Arc;
use std::sync::atomic::AtomicUsize;
use std::sync::atomic::Ordering;
use codex_extension_api::ExtensionData;
use pretty_assertions::assert_eq;
#[test]
fn typed_values_can_be_inserted_replaced_and_removed() {
let data = ExtensionData::new("thread-1");
assert_eq!(data.insert(/*value*/ 41_u64), None);
assert_eq!(data.insert("alpha".to_string()), None);
assert_eq!(data.get::<u64>().as_deref(), Some(&41));
assert_eq!(
data.get::<String>().map(|value| value.as_str().to_string()),
Some("alpha".to_string())
);
assert_eq!(data.insert(/*value*/ 42_u64).as_deref(), Some(&41));
assert_eq!(data.get::<u64>().as_deref(), Some(&42));
assert_eq!(
data.remove::<String>()
.map(|value| value.as_str().to_string()),
Some("alpha".to_string())
);
assert_eq!(data.get::<String>(), None);
assert_eq!(data.get::<u64>().as_deref(), Some(&42));
}
#[test]
fn get_or_init_initializes_once_and_returns_shared_value() {
const CALLER_COUNT: usize = 8;
#[derive(Debug, PartialEq, Eq)]
struct SharedValue(usize);
let data = Arc::new(ExtensionData::new("session"));
let callers_started = Arc::new(AtomicUsize::new(0));
let initialization_count = Arc::new(AtomicUsize::new(0));
let handles: [_; CALLER_COUNT] = std::array::from_fn(|_| {
let data = Arc::clone(&data);
let callers_started = Arc::clone(&callers_started);
let initialization_count = Arc::clone(&initialization_count);
std::thread::spawn(move || {
callers_started.fetch_add(1, Ordering::SeqCst);
data.get_or_init(|| {
initialization_count.fetch_add(1, Ordering::SeqCst);
// Keep the first initializer active until every worker has attempted
// get_or_init, forcing callers to overlap on the same missing entry.
while callers_started.load(Ordering::SeqCst) < CALLER_COUNT {
std::thread::yield_now();
}
SharedValue(7)
})
})
});
let values = handles
.into_iter()
.map(|handle| handle.join().expect("initializer thread should succeed"))
.collect::<Vec<_>>();
assert_eq!(initialization_count.load(Ordering::SeqCst), 1);
assert_eq!(
values.iter().map(Arc::as_ref).collect::<Vec<_>>(),
vec![&SharedValue(7); CALLER_COUNT]
);
assert!(
values
.iter()
.skip(1)
.all(|value| Arc::ptr_eq(&values[0], value))
);
}
#[test]
fn stores_are_isolated_and_preserve_level_id() {
let session_data = ExtensionData::new("root-1");
let thread_data = ExtensionData::new("root-1");
session_data.insert(/*value*/ 17_u32);
thread_data.insert("thread value".to_string());
assert_eq!(session_data.level_id(), "root-1");
assert_eq!(thread_data.level_id(), "root-1");
assert_eq!(session_data.get::<u32>().as_deref(), Some(&17));
assert_eq!(session_data.get::<String>(), None);
assert_eq!(thread_data.get::<u32>(), None);
assert_eq!(
thread_data
.get::<String>()
.map(|value| value.as_str().to_string()),
Some("thread value".to_string())
);
}
#[test]
fn store_remains_usable_after_panicking_initializer() {
let data = ExtensionData::new("turn-1");
let result = std::panic::catch_unwind(AssertUnwindSafe(|| {
data.get_or_init::<u64>(|| panic!("initializer failed"));
}));
assert!(result.is_err());
assert_eq!(*data.get_or_init(|| 99_u64), 99);
}
+2 -2
View File
@@ -27,7 +27,7 @@ pub fn create_create_goal_tool() -> ToolSpec {
(
"objective".to_string(),
JsonSchema::string(Some(
"Required. The concrete objective to start pursuing. This starts a new active goal only when no goal is currently defined; if a goal already exists, this tool fails."
"Required. The concrete objective to start pursuing. This starts a new active goal when no goal exists or replaces the current goal when it is complete."
.to_string(),
)),
),
@@ -44,7 +44,7 @@ pub fn create_create_goal_tool() -> ToolSpec {
name: CREATE_GOAL_TOOL_NAME.to_string(),
description: format!(
r#"Create a goal only when explicitly requested by the user or system/developer instructions; do not infer goals from ordinary tasks.
Set token_budget only when an explicit token budget is requested. Fails if a goal exists; use {UPDATE_GOAL_TOOL_NAME} only for status."#
Set token_budget only when an explicit token budget is requested. Fails if an unfinished goal exists; use {UPDATE_GOAL_TOOL_NAME} only for status."#
),
strict: false,
defer_loading: None,
+1 -1
View File
@@ -191,7 +191,7 @@ impl GoalToolExecutor {
.map_err(|err| FunctionCallError::RespondToModel(format!("failed to create goal: {err}")))?
.ok_or_else(|| {
FunctionCallError::RespondToModel(
"cannot create a new goal because this thread already has a goal; use update_goal only when the existing goal is complete"
"cannot create a new goal because this thread has an unfinished goal; complete the existing goal first"
.to_string(),
)
})?;
@@ -124,7 +124,7 @@ async fn goal_tools_hidden_for_review_subagents() -> anyhow::Result<()> {
}
#[tokio::test]
async fn installed_goal_tools_reject_duplicate_goal_creation() -> anyhow::Result<()> {
async fn installed_goal_tools_only_replace_complete_goal() -> anyhow::Result<()> {
let runtime = test_runtime().await?;
let thread_id = test_thread_id()?;
seed_thread_metadata(runtime.as_ref(), thread_id).await?;
@@ -152,10 +152,31 @@ async fn installed_goal_tools_reject_duplicate_goal_creation() -> anyhow::Result
assert_eq!(
err,
FunctionCallError::RespondToModel(
"cannot create a new goal because this thread already has a goal; use update_goal only when the existing goal is complete"
"cannot create a new goal because this thread has an unfinished goal; complete the existing goal first"
.to_string()
)
);
let update_tool = tool_by_name(&tools, "update_goal");
update_tool
.handle(tool_call(
"update_goal",
"call-complete-goal",
json!({ "status": "complete" }),
))
.await?;
let invocation = tool_call(
"create_goal",
"call-create-goal-3",
json!({ "objective": "replacement goal" }),
);
let output = create_tool.handle(invocation.clone()).await?;
let result = output.code_mode_result(&invocation.payload);
assert_eq!(json!("replacement goal"), result["goal"]["objective"]);
assert_eq!(json!("active"), result["goal"]["status"]);
assert_eq!(json!(0), result["goal"]["tokensUsed"]);
Ok(())
}
+6
View File
@@ -0,0 +1,6 @@
load("//:defs.bzl", "codex_rust_crate")
codex_rust_crate(
name = "mcp",
crate_name = "codex_mcp_extension",
)
+29
View File
@@ -0,0 +1,29 @@
[package]
edition.workspace = true
license.workspace = true
name = "codex-mcp-extension"
version.workspace = true
[lib]
name = "codex_mcp_extension"
path = "src/lib.rs"
test = false
doctest = false
[lints]
workspace = true
[dependencies]
async-trait = { workspace = true }
codex-core = { workspace = true }
codex-extension-api = { workspace = true }
codex-features = { workspace = true }
codex-mcp = { workspace = true }
[dev-dependencies]
codex-config = { workspace = true }
codex-core-plugins = { workspace = true }
codex-login = { workspace = true }
pretty_assertions = { workspace = true }
tempfile = { workspace = true }
tokio = { workspace = true, features = ["macros", "rt-multi-thread"] }
+31
View File
@@ -0,0 +1,31 @@
use codex_core::config::Config;
use codex_extension_api::ExtensionRegistryBuilder;
use codex_extension_api::McpServerContribution;
use codex_extension_api::McpServerContributor;
use codex_mcp::CODEX_APPS_MCP_SERVER_NAME;
use codex_mcp::codex_apps_mcp_server_config;
struct HostedAppsMcpExtension;
#[async_trait::async_trait]
impl McpServerContributor<Config> for HostedAppsMcpExtension {
async fn contribute(&self, config: &Config) -> Vec<McpServerContribution> {
let name = CODEX_APPS_MCP_SERVER_NAME.to_string();
if !config.features.enabled(codex_features::Feature::Apps) {
return vec![McpServerContribution::Remove { name }];
}
vec![McpServerContribution::Set {
name,
config: Box::new(codex_apps_mcp_server_config(
&config.chatgpt_base_url,
config.apps_mcp_path_override.as_deref(),
config.apps_mcp_product_sku.as_deref(),
)),
}]
}
}
pub fn install(builder: &mut ExtensionRegistryBuilder<Config>) {
builder.mcp_server_contributor(std::sync::Arc::new(HostedAppsMcpExtension));
}
+93
View File
@@ -0,0 +1,93 @@
use std::sync::Arc;
use codex_config::McpServerTransportConfig;
use codex_core::McpManager;
use codex_core::config::Config;
use codex_core::config::ConfigBuilder;
use codex_core_plugins::PluginsManager;
use codex_extension_api::ExtensionRegistryBuilder;
use codex_login::CodexAuth;
use codex_mcp::CODEX_APPS_MCP_SERVER_NAME;
use pretty_assertions::assert_eq;
#[tokio::test]
async fn contributes_hosted_apps_mcp_without_an_executor() -> Result<(), Box<dyn std::error::Error>>
{
let codex_home = tempfile::tempdir()?;
let config = ConfigBuilder::default()
.codex_home(codex_home.path().to_path_buf())
.fallback_cwd(Some(codex_home.path().to_path_buf()))
.cli_overrides(vec![
("features.apps".to_string(), true.into()),
("features.apps_mcp_path_override".to_string(), true.into()),
("chatgpt_base_url".to_string(), "https://chatgpt.com".into()),
])
.build()
.await?;
let auth = CodexAuth::create_dummy_chatgpt_auth_for_testing();
let manager = installed_manager(&config);
let runtime_config = manager.runtime_config(&config).await;
assert!(!runtime_config.legacy_apps_mcp_loader_enabled);
let servers = manager.effective_servers(&config, Some(&auth)).await;
let server = servers
.get(CODEX_APPS_MCP_SERVER_NAME)
.and_then(|server| server.configured_config())
.ok_or("Apps MCP should be contributed as a configured server")?;
let McpServerTransportConfig::StreamableHttp { url, .. } = &server.transport else {
panic!("Apps MCP should use streamable HTTP");
};
assert_eq!(url, "https://chatgpt.com/backend-api/ps/mcp");
Ok(())
}
#[tokio::test]
async fn hosted_apps_mcp_requires_chatgpt_auth() -> Result<(), Box<dyn std::error::Error>> {
let codex_home = tempfile::tempdir()?;
let config = ConfigBuilder::default()
.codex_home(codex_home.path().to_path_buf())
.fallback_cwd(Some(codex_home.path().to_path_buf()))
.cli_overrides(vec![("features.apps".to_string(), true.into())])
.build()
.await?;
let auth = CodexAuth::from_api_key("test");
let manager = installed_manager(&config);
let servers = manager.effective_servers(&config, Some(&auth)).await;
assert!(!servers.contains_key(CODEX_APPS_MCP_SERVER_NAME));
Ok(())
}
#[tokio::test]
async fn disabled_apps_remove_reserved_server_config() -> Result<(), Box<dyn std::error::Error>> {
let codex_home = tempfile::tempdir()?;
let config = ConfigBuilder::default()
.codex_home(codex_home.path().to_path_buf())
.fallback_cwd(Some(codex_home.path().to_path_buf()))
.cli_overrides(vec![
("features.apps".to_string(), false.into()),
(
"mcp_servers.codex_apps.url".to_string(),
"https://example.com/mcp".into(),
),
])
.build()
.await?;
let manager = installed_manager(&config);
let servers = manager.runtime_servers(&config).await;
assert!(!servers.contains_key(CODEX_APPS_MCP_SERVER_NAME));
Ok(())
}
fn installed_manager(config: &Config) -> McpManager {
let mut builder = ExtensionRegistryBuilder::new();
codex_mcp_extension::install(&mut builder);
McpManager::new_with_extensions(
Arc::new(PluginsManager::new(config.codex_home.to_path_buf())),
Arc::new(builder.build()),
)
}
+3
View File
@@ -17,8 +17,11 @@ workspace = true
async-trait = { workspace = true }
codex-core = { workspace = true }
codex-core-skills = { workspace = true }
codex-exec-server = { workspace = true }
codex-extension-api = { workspace = true }
codex-protocol = { workspace = true }
codex-utils-absolute-path = { workspace = true }
codex-utils-string = { workspace = true }
[dev-dependencies]
pretty_assertions = { workspace = true }
+32 -3
View File
@@ -1,4 +1,5 @@
use codex_core_skills::model::SkillDependencies;
use codex_exec_server::EnvironmentPathRef;
/// Source authority that owns a skill package and must be used to read it.
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
@@ -56,9 +57,37 @@ impl SkillAuthority {
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
pub struct SkillPackageId(pub String);
/// Opaque resource id inside a skill package.
/// Opaque resource id inside a skill package, optionally bound to the
/// environment path that owns its contents.
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
pub struct SkillResourceId(pub String);
pub struct SkillResourceId {
id: String,
environment_path: Option<EnvironmentPathRef>,
}
impl SkillResourceId {
pub fn new(id: impl Into<String>) -> Self {
Self {
id: id.into(),
environment_path: None,
}
}
pub fn environment(id: impl Into<String>, path: EnvironmentPathRef) -> Self {
Self {
id: id.into(),
environment_path: Some(path),
}
}
pub fn as_str(&self) -> &str {
&self.id
}
pub(crate) fn environment_path(&self) -> Option<&EnvironmentPathRef> {
self.environment_path.as_ref()
}
}
/// Metadata shown in the always-visible skills catalog.
#[derive(Clone, Debug, PartialEq, Eq)]
@@ -125,7 +154,7 @@ impl SkillCatalogEntry {
pub(crate) fn rendered_path(&self) -> &str {
self.display_path
.as_deref()
.unwrap_or(self.main_prompt.0.as_str())
.unwrap_or_else(|| self.main_prompt.as_str())
}
}
+81 -23
View File
@@ -6,27 +6,32 @@ use codex_core_skills::SkillInstructions;
use codex_core_skills::injection::InjectedHostSkillPrompts;
use codex_core_skills::injection::SkillInjection;
use codex_extension_api::ConfigContributor;
use codex_extension_api::ContextContributor;
use codex_extension_api::ContextualUserFragment;
use codex_extension_api::ExtensionData;
use codex_extension_api::ExtensionEventSink;
use codex_extension_api::ExtensionRegistryBuilder;
use codex_extension_api::PromptFragment;
use codex_extension_api::ThreadLifecycleContributor;
use codex_extension_api::ThreadStartInput;
use codex_extension_api::TurnInputContext;
use codex_extension_api::TurnInputContributor;
use codex_protocol::capabilities::SelectedCapabilityRoot;
use codex_protocol::protocol::Event;
use codex_protocol::protocol::EventMsg;
use codex_protocol::protocol::WarningEvent;
use crate::catalog::SkillAuthority;
use crate::catalog::SkillCatalogEntry;
use crate::catalog::SkillReadResult;
use crate::catalog::SkillSourceKind;
use crate::provider::HostSkillProvider;
use crate::provider::SkillListQuery;
use crate::provider::SkillReadRequest;
use crate::render::MAX_SKILL_NAME_BYTES;
use crate::render::MAX_SKILL_PATH_BYTES;
use crate::render::available_skills_fragment;
use crate::render::truncate_main_prompt_contents;
use crate::render::truncate_utf8_to_bytes;
use crate::selection::collect_explicit_skill_mentions;
use crate::sources::SkillProviders;
use crate::state::SkillsExtensionConfig;
@@ -42,11 +47,15 @@ struct SkillsExtension {
#[async_trait::async_trait]
impl ThreadLifecycleContributor<Config> for SkillsExtension {
async fn on_thread_start(&self, input: ThreadStartInput<'_, Config>) {
input
let selected_roots = input
.thread_store
.insert(SkillsThreadState::new(SkillsExtensionConfig::from_config(
input.config,
)));
.get::<Vec<SelectedCapabilityRoot>>()
.map(|selected_roots| selected_roots.as_ref().clone())
.unwrap_or_default();
input.thread_store.insert(SkillsThreadState::new(
SkillsExtensionConfig::from_config(input.config),
selected_roots,
));
}
}
@@ -62,11 +71,47 @@ impl ConfigContributor<Config> for SkillsExtension {
if let Some(state) = thread_store.get::<SkillsThreadState>() {
state.set_config(next_config);
} else {
thread_store.insert(SkillsThreadState::new(next_config));
thread_store.insert(SkillsThreadState::new(next_config, Vec::new()));
}
}
}
impl ContextContributor for SkillsExtension {
fn contribute<'a>(
&'a self,
_session_store: &'a ExtensionData,
thread_store: &'a ExtensionData,
) -> std::pin::Pin<Box<dyn std::future::Future<Output = Vec<PromptFragment>> + Send + 'a>> {
Box::pin(async move {
let Some(thread_state) = thread_store.get::<SkillsThreadState>() else {
return Vec::new();
};
let config = thread_state.config();
if !config.include_instructions || thread_state.selected_roots().is_empty() {
return Vec::new();
}
let catalog = self
.providers
.list_for_turn(SkillListQuery {
turn_id: thread_store.level_id().to_string(),
executor_roots: thread_state.selected_roots().to_vec(),
host: None,
include_host_skills: false,
include_bundled_skills: config.bundled_skills_enabled,
include_remote_skills: false,
})
.await;
for warning in &catalog.warnings {
self.emit_warning(thread_store.level_id(), warning.clone());
}
available_skills_fragment(&catalog)
.map(|fragment| PromptFragment::developer_capability(fragment.render()))
.into_iter()
.collect()
})
}
}
#[async_trait::async_trait]
impl TurnInputContributor for SkillsExtension {
async fn contribute(
@@ -84,16 +129,7 @@ impl TurnInputContributor for SkillsExtension {
let host_loaded_skills = turn_store.get::<HostLoadedSkills>();
let query = SkillListQuery {
turn_id: input.turn_id.clone(),
executor_authorities: input
.environments
.iter()
.map(|environment| {
SkillAuthority::new(
SkillSourceKind::Executor,
environment.environment_id.clone(),
)
})
.collect(),
executor_roots: thread_state.selected_roots().to_vec(),
host: host_loaded_skills.clone(),
include_host_skills: true,
include_bundled_skills: config.bundled_skills_enabled,
@@ -106,10 +142,14 @@ impl TurnInputContributor for SkillsExtension {
let selected_entries = collect_explicit_skill_mentions(&input.user_input, &catalog);
let mut fragments: Vec<Box<dyn ContextualUserFragment + Send>> = Vec::new();
if config.include_instructions
&& let Some(fragment) = available_skills_fragment(&catalog)
{
fragments.push(Box::new(fragment));
if config.include_instructions {
let mut turn_catalog = catalog.clone();
turn_catalog
.entries
.retain(|entry| entry.authority.kind != SkillSourceKind::Executor);
if let Some(fragment) = available_skills_fragment(&turn_catalog) {
fragments.push(Box::new(fragment));
}
}
let mut warnings = catalog.warnings.clone();
@@ -132,14 +172,14 @@ impl TurnInputContributor for SkillsExtension {
warnings.push(warning);
}
let injection = SkillInjection {
name: entry.name.clone(),
path: entry.rendered_path().to_string(),
name: truncate_utf8_to_bytes(&entry.name, MAX_SKILL_NAME_BYTES).0,
path: truncate_utf8_to_bytes(entry.rendered_path(), MAX_SKILL_PATH_BYTES).0,
contents,
};
fragments.push(Box::new(SkillInstructions::from(&injection)));
main_prompts_injected = true;
if entry.authority.kind == SkillSourceKind::Host {
injected_host_skill_prompts.insert_path(entry.main_prompt.0.clone());
injected_host_skill_prompts.insert_path(entry.main_prompt.as_str());
}
}
Err(message) => {
@@ -150,6 +190,23 @@ impl TurnInputContributor for SkillsExtension {
}
}
if let Some(host_loaded_skills) = &host_loaded_skills {
for entry in selected_entries
.iter()
.filter(|entry| entry.authority.kind != SkillSourceKind::Host)
{
for host_skill in host_loaded_skills
.outcome()
.skills
.iter()
.filter(|host_skill| host_skill.name == entry.name)
{
injected_host_skill_prompts
.insert_path(host_skill.path_to_skills_md.to_string_lossy());
}
}
}
turn_store.insert(SkillsTurnState {
catalog,
selected_entries,
@@ -206,5 +263,6 @@ pub fn install_with_providers(
});
registry.thread_lifecycle_contributor(extension.clone());
registry.config_contributor(extension.clone());
registry.prompt_contributor(extension.clone());
registry.turn_input_contributor(extension);
}
+2
View File
@@ -8,6 +8,8 @@ mod state;
pub use extension::install;
pub use extension::install_with_providers;
pub use provider::ExecutorSkillProvider;
pub use provider::HostSkillProvider;
pub use provider::SkillProvider;
pub use sources::SkillProviderSource;
pub use sources::SkillProviders;
+4 -1
View File
@@ -2,9 +2,11 @@ use std::future::Future;
use std::pin::Pin;
use std::sync::Arc;
mod executor;
mod host;
use codex_core_skills::HostLoadedSkills;
use codex_protocol::capabilities::SelectedCapabilityRoot;
use crate::catalog::SkillAuthority;
use crate::catalog::SkillCatalog;
@@ -14,12 +16,13 @@ use crate::catalog::SkillReadResult;
use crate::catalog::SkillResourceId;
use crate::catalog::SkillSearchResult;
pub use executor::ExecutorSkillProvider;
pub use host::HostSkillProvider;
#[derive(Clone, Debug)]
pub struct SkillListQuery {
pub turn_id: String,
pub executor_authorities: Vec<SkillAuthority>,
pub executor_roots: Vec<SelectedCapabilityRoot>,
pub host: Option<Arc<HostLoadedSkills>>,
pub include_host_skills: bool,
pub include_bundled_skills: bool,
@@ -0,0 +1,197 @@
use std::path::PathBuf;
use std::sync::Arc;
use codex_core_skills::SkillMetadata;
use codex_core_skills::filter_skill_load_outcome_for_product;
use codex_core_skills::loader::SkillRoot;
use codex_core_skills::loader::load_skills_from_roots;
use codex_exec_server::EnvironmentManager;
use codex_exec_server::EnvironmentPathRef;
use codex_protocol::capabilities::CapabilityRootLocation;
use codex_protocol::protocol::Product;
use codex_protocol::protocol::SkillScope;
use codex_utils_absolute_path::AbsolutePathBuf;
use crate::catalog::SkillAuthority;
use crate::catalog::SkillCatalog;
use crate::catalog::SkillCatalogEntry;
use crate::catalog::SkillPackageId;
use crate::catalog::SkillProviderError;
use crate::catalog::SkillReadResult;
use crate::catalog::SkillResourceId;
use crate::catalog::SkillSearchResult;
use crate::catalog::SkillSourceKind;
use crate::provider::SkillListQuery;
use crate::provider::SkillProvider;
use crate::provider::SkillProviderFuture;
use crate::provider::SkillReadRequest;
use crate::provider::SkillSearchRequest;
/// Discovers and reads skills through the filesystem owned by an execution environment.
#[derive(Clone, Debug)]
pub struct ExecutorSkillProvider {
environment_manager: Arc<EnvironmentManager>,
restriction_product: Option<Product>,
}
impl ExecutorSkillProvider {
pub fn new_with_restriction_product(
environment_manager: Arc<EnvironmentManager>,
restriction_product: Option<Product>,
) -> Self {
Self {
environment_manager,
restriction_product,
}
}
}
impl SkillProvider for ExecutorSkillProvider {
fn list(&self, query: SkillListQuery) -> SkillProviderFuture<'_, SkillCatalog> {
Box::pin(async move {
let mut catalog = SkillCatalog::default();
for selected_root in query.executor_roots {
let selected_root_id = selected_root.id;
let CapabilityRootLocation::Environment {
environment_id,
path,
} = selected_root.location;
let authority =
SkillAuthority::new(SkillSourceKind::Executor, selected_root_id.clone());
let Some(environment) = self.environment_manager.get_environment(&environment_id)
else {
catalog.warnings.push(format!(
"Selected capability root `{selected_root_id}` references unavailable environment `{environment_id}`."
));
continue;
};
let root_path = match executor_absolute_path(&path) {
Ok(root_path) => root_path,
Err(err) => {
catalog.warnings.push(format!(
"Selected capability root `{selected_root_id}` has invalid path `{path}`: {err}"
));
continue;
}
};
let file_system = environment.get_filesystem();
let outcome = filter_skill_load_outcome_for_product(
load_skills_from_roots([SkillRoot {
path: root_path.clone(),
scope: SkillScope::User,
file_system: Arc::clone(&file_system),
plugin_id: None,
plugin_root: None,
}])
.await,
self.restriction_product,
);
catalog.warnings.extend(outcome.errors.iter().map(|err| {
format!(
"Failed to load executor skill at {}: {}",
err.path.display(),
err.message
)
}));
for (skill, enabled) in outcome.skills_with_enabled() {
catalog.push_entry(catalog_entry_from_skill(
skill,
enabled,
authority.clone(),
&selected_root_id,
Arc::clone(&file_system),
));
}
}
Ok(catalog)
})
}
fn read(&self, request: SkillReadRequest) -> SkillProviderFuture<'_, SkillReadResult> {
Box::pin(async move {
if request.authority.kind != SkillSourceKind::Executor {
return Err(SkillProviderError::new(format!(
"executor skill provider cannot read {} resources",
request.authority.kind
)));
}
if request.package.0 != request.resource.as_str() {
return Err(SkillProviderError::new(
"executor skill resource does not match its package",
));
}
let Some(resource_path) = request.resource.environment_path() else {
return Err(SkillProviderError::new(
"executor skill resource is not bound to an environment",
));
};
let contents = resource_path
.read_to_string(/*sandbox*/ None)
.await
.map_err(|err| {
SkillProviderError::new(format!(
"failed to read executor skill resource {}: {err}",
request.resource.as_str()
))
})?;
Ok(SkillReadResult {
resource: request.resource,
contents,
})
})
}
fn search(&self, _request: SkillSearchRequest) -> SkillProviderFuture<'_, SkillSearchResult> {
Box::pin(async { Ok(SkillSearchResult::default()) })
}
}
fn catalog_entry_from_skill(
skill: &SkillMetadata,
enabled: bool,
authority: SkillAuthority,
selected_root_id: &str,
file_system: Arc<dyn codex_exec_server::ExecutorFileSystem>,
) -> SkillCatalogEntry {
let skill_path = skill.path_to_skills_md.to_string_lossy().into_owned();
let normalized_path = skill_path.replace('\\', "/");
let display_path = format!(
"skill://{selected_root_id}/{}",
normalized_path.trim_start_matches('/')
);
let mut entry = SkillCatalogEntry::new(
SkillPackageId(display_path.clone()),
authority,
skill.name.clone(),
skill.description.clone(),
SkillResourceId::environment(
display_path.clone(),
EnvironmentPathRef::new(file_system, skill.path_to_skills_md.clone()),
),
)
.with_short_description(skill.short_description.clone())
.with_display_path(display_path)
.with_dependencies(skill.dependencies.clone());
if !enabled {
entry = entry.disabled();
}
if !skill.allows_implicit_invocation() {
entry = entry.hidden_from_prompt();
}
entry
}
fn executor_absolute_path(path: &str) -> std::io::Result<AbsolutePathBuf> {
let path = PathBuf::from(path);
if !path.is_absolute() {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
"executor path must be absolute",
));
}
AbsolutePathBuf::from_absolute_path_checked(path)
}
+5 -5
View File
@@ -55,12 +55,12 @@ impl SkillProvider for HostSkillProvider {
};
let Some(skill) = host_loaded_skills.outcome().skills.iter().find(|skill| {
let skill_path = skill.path_to_skills_md.to_string_lossy();
skill_path == request.resource.0.as_str()
|| skill_path.replace('\\', "/") == request.resource.0
skill_path == request.resource.as_str()
|| skill_path.replace('\\', "/") == request.resource.as_str()
}) else {
return Err(SkillProviderError::new(format!(
"host skill resource is not loaded: {}",
request.resource.0
request.resource.as_str()
)));
};
@@ -70,7 +70,7 @@ impl SkillProvider for HostSkillProvider {
.map_err(|err| {
SkillProviderError::new(format!(
"failed to read host skill resource {}: {err}",
request.resource.0
request.resource.as_str()
))
})?;
@@ -117,7 +117,7 @@ fn catalog_entry_from_skill(skill: &SkillMetadata, enabled: bool) -> SkillCatalo
SkillAuthority::new(SkillSourceKind::Host, HOST_AUTHORITY_ID),
skill.name.clone(),
skill.description.clone(),
SkillResourceId(skill_path),
SkillResourceId::new(skill_path),
)
.with_short_description(skill.short_description.clone())
.with_display_path(display_path)
+15 -14
View File
@@ -2,11 +2,14 @@ use codex_core_skills::render_available_skills_body;
use codex_extension_api::ContextualUserFragment;
use codex_protocol::protocol::SKILLS_INSTRUCTIONS_CLOSE_TAG;
use codex_protocol::protocol::SKILLS_INSTRUCTIONS_OPEN_TAG;
use codex_utils_string::take_bytes_at_char_boundary;
use crate::catalog::SkillCatalog;
const MAX_AVAILABLE_SKILLS_CHARS: usize = 8_000;
const MAX_MAIN_PROMPT_CHARS: usize = 40_000;
const MAX_AVAILABLE_SKILLS_BYTES: usize = 8_000;
const MAX_MAIN_PROMPT_BYTES: usize = 8_000;
pub(crate) const MAX_SKILL_NAME_BYTES: usize = 256;
pub(crate) const MAX_SKILL_PATH_BYTES: usize = 1_024;
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct AvailableSkillsFragment {
@@ -32,7 +35,7 @@ impl ContextualUserFragment for AvailableSkillsFragment {
}
pub(crate) fn available_skills_fragment(catalog: &SkillCatalog) -> Option<AvailableSkillsFragment> {
let mut total_chars = 0usize;
let mut total_bytes = 0usize;
let mut omitted = 0usize;
let mut skill_lines = Vec::new();
@@ -46,12 +49,12 @@ pub(crate) fn available_skills_fragment(catalog: &SkillCatalog) -> Option<Availa
.as_deref()
.unwrap_or(entry.description.as_str());
let line = render_skill_line(entry.name.as_str(), description, entry.rendered_path());
let next_chars = total_chars.saturating_add(line.chars().count());
if next_chars > MAX_AVAILABLE_SKILLS_CHARS {
let next_bytes = total_bytes.saturating_add(line.len());
if next_bytes > MAX_AVAILABLE_SKILLS_BYTES {
omitted = omitted.saturating_add(1);
continue;
}
total_chars = next_chars;
total_bytes = next_bytes;
skill_lines.push(line);
}
@@ -79,12 +82,10 @@ fn render_skill_line(name: &str, description: &str, path: &str) -> String {
}
pub(crate) fn truncate_main_prompt_contents(contents: &str) -> (String, bool) {
let mut chars = 0usize;
for (index, _) in contents.char_indices() {
if chars == MAX_MAIN_PROMPT_CHARS {
return (contents[..index].to_string(), true);
}
chars = chars.saturating_add(1);
}
(contents.to_string(), false)
truncate_utf8_to_bytes(contents, MAX_MAIN_PROMPT_BYTES)
}
pub(crate) fn truncate_utf8_to_bytes(contents: &str, max_bytes: usize) -> (String, bool) {
let truncated = take_bytes_at_char_boundary(contents, max_bytes);
(truncated.to_string(), truncated.len() < contents.len())
}
+2 -2
View File
@@ -93,12 +93,12 @@ fn push_selected(
}
fn entry_matches_path(entry: &SkillCatalogEntry, path: &str) -> bool {
entry.main_prompt.0 == path
entry.main_prompt.as_str() == path
|| entry.id.0 == path
|| entry
.display_path
.as_deref()
.is_some_and(|display_path| display_path == path)
.is_some_and(|display_path| normalize_skill_path(display_path) == path)
}
fn path_is_skill(path: &str) -> bool {

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